diff --git a/Cargo.lock b/Cargo.lock index 15812a4db..2320f99ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1454,6 +1454,20 @@ dependencies = [ "sp-runtime", ] +[[package]] +name = "crypto-utils-evm" +version = "0.1.0" +dependencies = [ + "anyhow", + "bip32", + "hex-literal", + "libsecp256k1", + "sha3 0.10.6", + "sp-core", + "thiserror", + "tiny-bip39", +] + [[package]] name = "ctr" version = "0.8.0" @@ -3541,9 +3555,9 @@ dependencies = [ "async-trait", "bioauth-flow-rpc", "bioauth-keys", - "bip32", "clap", "crypto-utils", + "crypto-utils-evm", "fc-cli", "fc-consensus", "fc-db", @@ -3565,7 +3579,6 @@ dependencies = [ "humanode-runtime", "indoc", "keystore-bioauth-account-id", - "libsecp256k1", "ngrok-api", "pallet-balances", "pallet-bioauth", @@ -3595,7 +3608,6 @@ dependencies = [ "sc-utils", "serde", "serde_json", - "sha3 0.10.6", "sp-api", "sp-application-crypto", "sp-consensus", diff --git a/crates/crypto-utils-evm/Cargo.toml b/crates/crypto-utils-evm/Cargo.toml new file mode 100644 index 000000000..f977f864d --- /dev/null +++ b/crates/crypto-utils-evm/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "crypto-utils-evm" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +anyhow = "1" +bip32 = "0.5" +libsecp256k1 = { version = "0.7", default-features = false } +sha3 = "0.10" +sp-core = { git = "https://github.com/humanode-network/substrate", branch = "locked/polkadot-v0.9.38" } +thiserror = "1" +tiny-bip39 = "1.0" + +[dev-dependencies] +hex-literal = "0.4" diff --git a/crates/crypto-utils-evm/src/lib.rs b/crates/crypto-utils-evm/src/lib.rs new file mode 100644 index 000000000..a55ee7250 --- /dev/null +++ b/crates/crypto-utils-evm/src/lib.rs @@ -0,0 +1,204 @@ +//! Various crypto helper functions for EVM. + +use sp_core::{H160, H256}; + +/// A structure representing the key information. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyData { + /// The account address. + pub account: H160, + /// The private key of this account. + pub private_key: H256, + /// The mnemonic phrase. + pub mnemonic: String, + /// The drivation path used for this account. + pub derivation_path: bip32::DerivationPath, +} + +/// An error that can occur at [`KeyData::from_mnemonic_bip39`] call. +#[derive(Debug, thiserror::Error)] +pub enum FromMnemonicBip39Error { + /// Derivation has failed. + #[error("derivation: {0}")] + Derivation(bip32::Error), + /// Secret key parsing failed. + #[error("secret key: {0}")] + SecretKey(libsecp256k1::Error), +} + +/// An error that can occur at [`KeyData::from_mnemonic_bip44`] call. +#[derive(Debug, thiserror::Error)] +pub enum FromMnemonicBip44Error { + /// Derivation path was invalid. + #[error("derivation path: {0}")] + DerivationPath(bip32::Error), + /// Inner [`KeyData::from_mnemonic_bip39`] call failed. + #[error(transparent)] + FromMnemonicBip39(FromMnemonicBip39Error), +} + +/// An error that can occur at [`KeyData::from_phrase_bip44`] call. +#[derive(Debug, thiserror::Error)] +pub enum FromPhraseBip44 { + /// Mnemonic parsing failed. + #[error("mnemonic: {0}")] + Mnemonic(anyhow::Error), + /// Inner [`KeyData::from_mnemonic_bip44`] call failed. + #[error(transparent)] + FromMnemonicBip44(FromMnemonicBip44Error), +} + +impl KeyData { + /// Create a new [`KeyData`] from the given BIP39 mnemonic and an account index. + pub fn from_mnemonic_bip39( + mnemonic: &bip39::Mnemonic, + password: &str, + derivation_path: &bip32::DerivationPath, + ) -> Result { + // Retrieve the seed from the mnemonic. + let seed = bip39::Seed::new(mnemonic, password); + + // Derives the private key from. + let ext = bip32::XPrv::derive_from_path(seed, derivation_path) + .map_err(FromMnemonicBip39Error::Derivation)?; + + let private_key = libsecp256k1::SecretKey::parse_slice(&ext.to_bytes()) + .map_err(FromMnemonicBip39Error::SecretKey)?; + + // Retrieves the public key. + let public_key = libsecp256k1::PublicKey::from_secret_key(&private_key); + + // Convert into Ethereum-style address. + let mut raw_public_key = [0u8; 64]; + raw_public_key.copy_from_slice(&public_key.serialize()[1..65]); + + use sha3::Digest; + let digest = sha3::Keccak256::digest(raw_public_key); + + let account = H160::from(H256::from_slice(digest.as_ref())); + + Ok(Self { + account, + mnemonic: mnemonic.phrase().to_owned(), + private_key: H256::from(private_key.serialize()), + derivation_path: derivation_path.clone(), + }) + } + + /// Construct the key info from the BIP39 mnemonic using BIP44 convenions. + pub fn from_mnemonic_bip44( + mnemonic: &bip39::Mnemonic, + password: &str, + account_index: Option, + ) -> Result { + let derivation_path = format!("m/44'/60'/0'/0/{}", account_index.unwrap_or(0)); + let derivation_path = derivation_path + .parse() + .map_err(FromMnemonicBip44Error::DerivationPath)?; + Self::from_mnemonic_bip39(mnemonic, password, &derivation_path) + .map_err(FromMnemonicBip44Error::FromMnemonicBip39) + } + + /// Construct the key info from the BIP39 mnemonic phrase (in English) using BIP44 convenions. + /// If you need other language - use [`Self::from_mnemonic_bip44`]. + pub fn from_phrase_bip44( + phrase: &str, + password: &str, + account_index: Option, + ) -> Result { + let mnemonic = bip39::Mnemonic::from_phrase(phrase, bip39::Language::English) + .map_err(FromPhraseBip44::Mnemonic)?; + Self::from_mnemonic_bip44(&mnemonic, password, account_index) + .map_err(FromPhraseBip44::FromMnemonicBip44) + } + + /// Construct the key info from the account on the Substrate standard dev seed. + pub fn from_dev_seed(account_index: u32) -> Self { + Self::from_phrase_bip44(sp_core::crypto::DEV_PHRASE, "", Some(account_index)).unwrap() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mnemonic_bip44() { + let cases = [ + ( + "test test test test test test test test test test test junk", + "", + KeyData { + account: H160(hex_literal::hex!( + "f39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + )), + private_key: H256(hex_literal::hex!( + "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + )), + mnemonic: "test test test test test test test test test test test junk".into(), + derivation_path: "m/44'/60'/0'/0/0".parse().unwrap(), + }, + ), + ( + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong", + "Substrate", + KeyData { + account: H160(hex_literal::hex!( + "cf1269fb02698ab9ee45426297e20c84142d9195" + )), + private_key: H256(hex_literal::hex!( + "b29728f71053098351f20350e7087dcb091b151689f8a878734b519901d19853" + )), + mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong".into(), + derivation_path: "m/44'/60'/0'/0/0".parse().unwrap(), + }, + ), + ]; + + for (phrase, pw, expected_key_info) in cases { + let key_info = KeyData::from_phrase_bip44(phrase, pw, None).unwrap(); + assert_eq!(key_info, expected_key_info); + } + } + + #[test] + fn dev_seed() { + let cases = [ + ( + 0, + KeyData { + account: H160(hex_literal::hex!( + "f24ff3a9cf04c71dbc94d0b566f7a27b94566cac" + )), + private_key: H256(hex_literal::hex!( + "5fb92d6e98884f76de468fa3f6278f8807c48bebc13595d45af5bdc4da702133" + )), + mnemonic: + "bottom drive obey lake curtain smoke basket hold race lonely fit walk" + .into(), + derivation_path: "m/44'/60'/0'/0/0".parse().unwrap(), + }, + ), + ( + 1, + KeyData { + account: H160(hex_literal::hex!( + "3cd0a705a2dc65e5b1e1205896baa2be8a07c6e0" + )), + private_key: H256(hex_literal::hex!( + "8075991ce870b93a8870eca0c0f91913d12f47948ca0fd25b49c6fa7cdbeee8b" + )), + mnemonic: + "bottom drive obey lake curtain smoke basket hold race lonely fit walk" + .into(), + derivation_path: "m/44'/60'/0'/0/1".parse().unwrap(), + }, + ), + ]; + + for (account_index, expected_key_info) in cases { + let key_info = KeyData::from_dev_seed(account_index); + assert_eq!(key_info, expected_key_info); + } + } +} diff --git a/crates/humanode-peer/Cargo.toml b/crates/humanode-peer/Cargo.toml index 58e2491a9..1dbed2cec 100644 --- a/crates/humanode-peer/Cargo.toml +++ b/crates/humanode-peer/Cargo.toml @@ -12,6 +12,7 @@ default-run = "humanode-peer" bioauth-flow-rpc = { version = "0.1", path = "../bioauth-flow-rpc" } bioauth-keys = { version = "0.1", path = "../bioauth-keys" } crypto-utils = { version = "0.1", path = "../crypto-utils" } +crypto-utils-evm = { version = "0.1", path = "../crypto-utils-evm" } humanode-rpc = { version = "0.1", path = "../humanode-rpc" } humanode-runtime = { version = "0.1", path = "../humanode-runtime" } keystore-bioauth-account-id = { version = "0.1", path = "../keystore-bioauth-account-id" } @@ -22,7 +23,6 @@ robonode-client = { version = "0.1", path = "../robonode-client" } anyhow = "1" async-trait = "0.1" -bip32 = "0.5.0" clap = { version = "4.1", features = ["derive"] } codec = { package = "parity-scale-codec", version = "3.2.2" } fc-cli = { git = "https://github.com/humanode-network/frontier", branch = "locked/polkadot-v0.9.38" } @@ -42,7 +42,6 @@ frame-system-rpc-runtime-api = { git = "https://github.com/humanode-network/subs futures = "0.3" hex = "0.4.3" hex-literal = "0.4" -libsecp256k1 = "0.7" pallet-balances = { default-features = false, git = "https://github.com/humanode-network/substrate", branch = "locked/polkadot-v0.9.38" } pallet-dynamic-fee = { default-features = false, git = "https://github.com/humanode-network/frontier", branch = "locked/polkadot-v0.9.38" } pallet-im-online = { default-features = false, git = "https://github.com/humanode-network/substrate", branch = "locked/polkadot-v0.9.38" } @@ -67,7 +66,6 @@ sc-transaction-pool = { git = "https://github.com/humanode-network/substrate", b sc-utils = { git = "https://github.com/humanode-network/substrate", branch = "locked/polkadot-v0.9.38" } serde = { version = "1", features = ["derive"] } serde_json = "1" -sha3 = "0.10" sp-api = { git = "https://github.com/humanode-network/substrate", branch = "locked/polkadot-v0.9.38" } sp-application-crypto = { git = "https://github.com/humanode-network/substrate", branch = "locked/polkadot-v0.9.38" } sp-consensus = { git = "https://github.com/humanode-network/substrate", branch = "locked/polkadot-v0.9.38" } @@ -82,7 +80,7 @@ sp-panic-handler = { git = "https://github.com/humanode-network/substrate", bran sp-runtime = { git = "https://github.com/humanode-network/substrate", branch = "locked/polkadot-v0.9.38" } sp-timestamp = { git = "https://github.com/humanode-network/substrate", branch = "locked/polkadot-v0.9.38" } thiserror = "1" -tiny-bip39 = "1.0" +tiny-bip39 = "1" tokio = { version = "1", features = ["full"] } tracing = "0.1" try-runtime-cli = { git = "https://github.com/humanode-network/substrate", branch = "locked/polkadot-v0.9.38", optional = true } diff --git a/crates/humanode-peer/src/chain_spec.rs b/crates/humanode-peer/src/chain_spec.rs index a6fd32850..d6822d6f4 100644 --- a/crates/humanode-peer/src/chain_spec.rs +++ b/crates/humanode-peer/src/chain_spec.rs @@ -1,6 +1,6 @@ //! Provides the [`ChainSpec`] portion of the config. -use crypto_utils::{authority_keys_from_seed, evm_account_from_seed, get_account_id_from_seed}; +use crypto_utils::{authority_keys_from_seed, get_account_id_from_seed}; use frame_support::BoundedVec; use hex_literal::hex; use humanode_runtime::{ @@ -15,7 +15,6 @@ use sc_chain_spec_derive::{ChainSpecExtension, ChainSpecGroup}; use sc_service::ChainType; use serde::{Deserialize, Serialize}; use sp_consensus_babe::AuthorityId as BabeId; -use sp_core::H160; use sp_finality_grandpa::AuthorityId as GrandpaId; use sp_runtime::{app_crypto::sr25519, traits::Verify}; @@ -49,8 +48,9 @@ pub fn authority_keys(seed: &str) -> (AccountId, BabeId, GrandpaId, ImOnlineId) } /// Generate an EVM account from seed. -pub fn evm_account_id(seed: &str) -> EvmAccountId { - H160::from_slice(&evm_account_from_seed(seed)) +pub fn evm_account_id_from_dev_seed(account_index: u32) -> EvmAccountId { + let key_data = crypto_utils_evm::KeyData::from_dev_seed(account_index); + key_data.account } /// The default Humanode ss58 prefix. @@ -126,7 +126,10 @@ pub fn local_testnet_config() -> Result { account_id("Eve//stash"), account_id("Ferdie//stash"), ], - vec![evm_account_id("EvmAlice"), evm_account_id("EvmBob")], + vec![ + evm_account_id_from_dev_seed(0), + evm_account_id_from_dev_seed(1), + ], robonode_public_key, vec![account_id("Alice")], ) @@ -172,7 +175,10 @@ pub fn development_config() -> Result { account_id("Alice//stash"), account_id("Bob//stash"), ], - vec![evm_account_id("EvmAlice"), evm_account_id("EvmBob")], + vec![ + evm_account_id_from_dev_seed(0), + evm_account_id_from_dev_seed(1), + ], robonode_public_key, vec![account_id("Alice")], ) @@ -223,7 +229,10 @@ pub fn benchmark_config() -> Result { account_id("Alice//stash"), account_id("Bob//stash"), ], - vec![evm_account_id("EvmAlice"), evm_account_id("EvmBob")], + vec![ + evm_account_id_from_dev_seed(0), + evm_account_id_from_dev_seed(1), + ], robonode_public_key, vec![account_id("Alice")], ) diff --git a/crates/humanode-peer/src/cli/run.rs b/crates/humanode-peer/src/cli/run.rs index 18d7311e9..44b01241c 100644 --- a/crates/humanode-peer/src/cli/run.rs +++ b/crates/humanode-peer/src/cli/run.rs @@ -133,7 +133,7 @@ pub async fn run() -> sc_cli::Result<()> { .async_run(|config| async move { cmd.run(config.bioauth_flow).await }) .await } - Some(Subcommand::Ethereum(cmd)) => cmd.run().await, + Some(Subcommand::Evm(cmd)) => cmd.run().await, Some(Subcommand::Benchmark(cmd)) => { let cmd = &**cmd; let runner = root.create_humanode_runner(cmd)?; diff --git a/crates/humanode-peer/src/cli/subcommand/ethereum/utils.rs b/crates/humanode-peer/src/cli/subcommand/ethereum/utils.rs deleted file mode 100644 index 3e0a9fe2f..000000000 --- a/crates/humanode-peer/src/cli/subcommand/ethereum/utils.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Common utils to process ethereum keys. - -use bip32::XPrv; -use bip39::{Mnemonic, Seed}; -use libsecp256k1::{PublicKey, SecretKey}; -use sha3::{Digest, Keccak256}; -use sp_core::{H160, H256}; - -/// A helper function to extract and print keys based on provided mnemonic. -pub fn extract_and_print_keys( - mnemonic: &Mnemonic, - account_index: Option, -) -> sc_cli::Result<()> { - // Retrieves the seed from the mnemonic. - let seed = Seed::new(mnemonic, ""); - - // Generate the derivation path from the account-index - let derivation_path = format!("m/44'/60'/0'/0/{}", account_index.unwrap_or(0)) - .parse() - .expect("Verified DerivationPath is used"); - - // Derives the private key from. - let ext = XPrv::derive_from_path(seed, &derivation_path) - .expect("Mnemonic is either a new one or verified before"); - let private_key = - SecretKey::parse_slice(&ext.to_bytes()).expect("Verified ExtendedPrivKey is used"); - - // Retrieves the public key. - let public_key = PublicKey::from_secret_key(&private_key); - - // Convert into Ethereum-style address. - let mut m = [0u8; 64]; - m.copy_from_slice(&public_key.serialize()[1..65]); - let account = H160::from(H256::from_slice(Keccak256::digest(m).as_slice())); - - println!("Address: {account:?}"); - println!("Mnemonic: {}", mnemonic.phrase()); - println!("Private Key: {:?}", H256::from(private_key.serialize())); - println!("Path: {derivation_path}"); - - Ok(()) -} diff --git a/crates/humanode-peer/src/cli/subcommand/ethereum/generate.rs b/crates/humanode-peer/src/cli/subcommand/evm/generate.rs similarity index 85% rename from crates/humanode-peer/src/cli/subcommand/ethereum/generate.rs rename to crates/humanode-peer/src/cli/subcommand/evm/generate.rs index 291a7b8d4..9d83718f2 100644 --- a/crates/humanode-peer/src/cli/subcommand/ethereum/generate.rs +++ b/crates/humanode-peer/src/cli/subcommand/evm/generate.rs @@ -24,7 +24,8 @@ impl GenerateAccountCmd { false => Mnemonic::new(MnemonicType::Words12, Language::English), }; - extract_and_print_keys(&mnemonic, self.account_index)?; + extract_and_print_keys(&mnemonic, self.account_index) + .map_err(|err| sc_cli::Error::Application(Box::new(err)))?; Ok(()) } diff --git a/crates/humanode-peer/src/cli/subcommand/ethereum/inspect.rs b/crates/humanode-peer/src/cli/subcommand/evm/inspect.rs similarity index 84% rename from crates/humanode-peer/src/cli/subcommand/ethereum/inspect.rs rename to crates/humanode-peer/src/cli/subcommand/evm/inspect.rs index e702f6791..e7f7d8d0c 100644 --- a/crates/humanode-peer/src/cli/subcommand/ethereum/inspect.rs +++ b/crates/humanode-peer/src/cli/subcommand/evm/inspect.rs @@ -22,7 +22,8 @@ impl InspectAccountCmd { let mnemonic = Mnemonic::from_phrase(&self.mnemonic, Language::English) .map_err(|err| sc_cli::Error::Input(err.to_string()))?; - extract_and_print_keys(&mnemonic, self.account_index)?; + extract_and_print_keys(&mnemonic, self.account_index) + .map_err(|err| sc_cli::Error::Application(Box::new(err)))?; Ok(()) } diff --git a/crates/humanode-peer/src/cli/subcommand/ethereum/mod.rs b/crates/humanode-peer/src/cli/subcommand/evm/mod.rs similarity index 73% rename from crates/humanode-peer/src/cli/subcommand/ethereum/mod.rs rename to crates/humanode-peer/src/cli/subcommand/evm/mod.rs index 26f740215..66ac40ed8 100644 --- a/crates/humanode-peer/src/cli/subcommand/ethereum/mod.rs +++ b/crates/humanode-peer/src/cli/subcommand/evm/mod.rs @@ -6,19 +6,19 @@ pub mod utils; /// Subcommands for the `ethereum` command. #[derive(Debug, clap::Subcommand)] -pub enum EthereumCmd { +pub enum EvmCmd { /// Generate a random account. GenerateAccount(generate::GenerateAccountCmd), /// Inspect a provided mnemonic. InspectAccount(inspect::InspectAccountCmd), } -impl EthereumCmd { +impl EvmCmd { /// Run the ethereum subcommands pub async fn run(&self) -> sc_cli::Result<()> { match self { - EthereumCmd::GenerateAccount(cmd) => cmd.run().await, - EthereumCmd::InspectAccount(cmd) => cmd.run().await, + EvmCmd::GenerateAccount(cmd) => cmd.run().await, + EvmCmd::InspectAccount(cmd) => cmd.run().await, } } } diff --git a/crates/humanode-peer/src/cli/subcommand/evm/utils.rs b/crates/humanode-peer/src/cli/subcommand/evm/utils.rs new file mode 100644 index 000000000..d02bcea68 --- /dev/null +++ b/crates/humanode-peer/src/cli/subcommand/evm/utils.rs @@ -0,0 +1,16 @@ +//! Common utils to process ethereum keys. + +/// A helper function to extract and print keys based on provided mnemonic. +pub fn extract_and_print_keys( + mnemonic: &bip39::Mnemonic, + account_index: Option, +) -> Result<(), crypto_utils_evm::FromMnemonicBip44Error> { + let key_data = crypto_utils_evm::KeyData::from_mnemonic_bip44(mnemonic, "", account_index)?; + + println!("Address: {:?}", key_data.account); + println!("Mnemonic: {}", key_data.mnemonic); + println!("Private Key: {:?}", key_data.private_key); + println!("Path: {}", key_data.derivation_path); + + Ok(()) +} diff --git a/crates/humanode-peer/src/cli/subcommand/mod.rs b/crates/humanode-peer/src/cli/subcommand/mod.rs index 1ea3611df..84aca7173 100644 --- a/crates/humanode-peer/src/cli/subcommand/mod.rs +++ b/crates/humanode-peer/src/cli/subcommand/mod.rs @@ -5,7 +5,7 @@ use super::CliConfigurationExt; pub mod bioauth; -pub mod ethereum; +pub mod evm; pub mod export_embedded_runtime; /// Humanode peer subcommands. @@ -40,9 +40,9 @@ pub enum Subcommand { #[command(subcommand)] Bioauth(bioauth::BioauthCmd), - /// Ethereum related subcommands. + /// EVM related subcommands. #[command(subcommand)] - Ethereum(ethereum::EthereumCmd), + Evm(evm::EvmCmd), /// The custom benchmark subcommmand benchmarking runtime pallets. #[command(name = "benchmark", about = "Benchmark runtime pallets.")] diff --git a/utils/checks/snapshots/features.yaml b/utils/checks/snapshots/features.yaml index 49ad2eca8..ccf875d91 100644 --- a/utils/checks/snapshots/features.yaml +++ b/utils/checks/snapshots/features.yaml @@ -492,6 +492,8 @@ features: [] - name: crypto-utils 0.1.0 features: [] +- name: crypto-utils-evm 0.1.0 + features: [] - name: ctr 0.8.0 features: [] - name: ctr 0.9.2 diff --git a/utils/e2e-tests/bash/fixtures/help.stdout.txt b/utils/e2e-tests/bash/fixtures/help.stdout.txt index e1ee3acd8..01a8ee8bb 100644 --- a/utils/e2e-tests/bash/fixtures/help.stdout.txt +++ b/utils/e2e-tests/bash/fixtures/help.stdout.txt @@ -22,8 +22,8 @@ Commands: Revert the chain to a previous state bioauth Biometric authentication related subcommands - ethereum - Ethereum related subcommands + evm + EVM related subcommands benchmark Benchmark runtime pallets. frontier-db diff --git a/utils/e2e-tests/bash/tests/block-check.sh b/utils/e2e-tests/bash/tests/block-check.sh index d24fd6482..e5e9dbf65 100755 --- a/utils/e2e-tests/bash/tests/block-check.sh +++ b/utils/e2e-tests/bash/tests/block-check.sh @@ -20,7 +20,7 @@ ADDR="$(get_address "//Alice")" # Send TX and wait for block creation. # The test will also fail if no block is created within 20 sec. -POLKA_JSON="$(timeout 20 yarn workspace humanode-e2e-tests-bash polkadot-js-api --ws "ws://127.0.0.1:9944" --seed "//Alice" tx.balances.transfer "$ADDR" 10000)" +POLKA_JSON="$(timeout 30 yarn workspace humanode-e2e-tests-bash polkadot-js-api --ws "ws://127.0.0.1:9944" --seed "//Alice" tx.balances.transfer "$ADDR" 10000)" # Log polkadot-js-api response. printf "polkadot-js-api response:\n%s\n" "$POLKA_JSON" >&2