diff --git a/libraries/libfc/include/fc/crypto/elliptic_em.hpp b/libraries/libfc/include/fc/crypto/elliptic_em.hpp index c876beb65e..47991398b7 100644 --- a/libraries/libfc/include/fc/crypto/elliptic_em.hpp +++ b/libraries/libfc/include/fc/crypto/elliptic_em.hpp @@ -93,9 +93,9 @@ namespace fc { static private_key regenerate( const private_key_secret& secret ); /** - * @brief - * @param pub_key_str Public key in ethereum format 0x<128 hex chars> - * @return public key + * @brief Parse an Ethereum-native private key (the raw secret a wallet exports). + * @param priv_key_str 32-byte secret as hex, with or without a 0x prefix. + * @return the corresponding em private key */ static private_key from_native_string( const std::string& priv_key_str ); diff --git a/programs/clio/main.cpp b/programs/clio/main.cpp index b01a0b35c2..42fd4ed430 100644 --- a/programs/clio/main.cpp +++ b/programs/clio/main.cpp @@ -1904,18 +1904,33 @@ int main( int argc, char** argv ) { bool r1 = false; bool k1 = false; + bool em = false; + bool sol = false; string key_file; bool print_console = false; // create key - auto create_key_cmd = create_cmd->add_subcommand("key", localized("Create a new keypair and print the public and private keys"))->callback( [&r1, &k1, &key_file, &print_console](){ + auto create_key_cmd = create_cmd->add_subcommand("key", localized("Create a new keypair and print the public and private keys"))->callback( [&r1, &k1, &em, &sol, &key_file, &print_console](){ if (key_file.empty() && !print_console) { std::cerr << "ERROR: Either indicate a file using \"--file\" or pass \"--to-console\"" << std::endl; return; } - auto pk = r1 ? private_key_type::generate(crypto::private_key::key_type::r1) : private_key_type::generate(); - auto privs = pk.to_string({}, k1); - auto pubs = pk.get_public_key().to_string({}, k1); + // --k1/--r1/--em/--sol are mutually exclusive (enforced by CLI11 ->excludes() + // below, so any combination is a non-zero parse error). No flag => the default + // K1 key in its legacy unprefixed form; --k1 => the same K1 key in the prefixed + // PVT_K1_/PUB_K1_ form; --r1/--em/--sol => that curve (always prefixed). + auto kt = crypto::private_key::key_type::k1; + if (sol) kt = crypto::private_key::key_type::ed; // Solana ed25519 + else if (em) kt = crypto::private_key::key_type::em; // Ethereum-style secp256k1 (MetaMask personal_sign) + else if (r1) kt = crypto::private_key::key_type::r1; + + // K1 has a legacy unprefixed form; --k1 requests the prefixed PVT_K1_/PUB_K1_ + // form. r1/em/ed are only ever emitted in their prefixed (PVT_*_/PUB_*_) form. + const bool include_prefix = (kt != crypto::private_key::key_type::k1) || k1; + + auto pk = private_key_type::generate(kt); + auto privs = pk.to_string({}, include_prefix); + auto pubs = pk.get_public_key().to_string({}, include_prefix); if (print_console) { std::cout << localized("Private key: ${key}", ("key", privs) ) << std::endl; std::cout << localized("Public key: ${key}", ("key", pubs ) ) << std::endl; @@ -1926,8 +1941,14 @@ int main( int argc, char** argv ) { out << localized("Public key: ${key}", ("key", pubs ) ) << std::endl; } }); - create_key_cmd->add_flag( "--k1", k1, "Generate a key using the K1 curve (Bitcoin) with PUB_K1_ & PVT_K1_ prefix instead of legacy" ); - create_key_cmd->add_flag( "--r1", r1, "Generate a key using the R1 curve (iPhone), instead of the K1 curve (Bitcoin)" ); + auto k1_flag = create_key_cmd->add_flag( "--k1", k1, "Generate a key using the K1 curve (Bitcoin) with PUB_K1_ & PVT_K1_ prefix instead of legacy" ); + auto r1_flag = create_key_cmd->add_flag( "--r1", r1, "Generate a key using the R1 curve (iPhone), instead of the K1 curve (Bitcoin)" ); + auto em_flag = create_key_cmd->add_flag( "--em", em, "Generate an EM key (Ethereum-style secp256k1, PUB_EM_/PVT_EM_) for MetaMask/external personal_sign" ); + auto sol_flag = create_key_cmd->add_flag( "--sol", sol, "Generate a Solana key (ed25519, PUB_ED_/PVT_ED_) for external Solana signers" ); + // --k1/--r1/--em/--sol are mutually exclusive; selecting more than one is a usage error. + k1_flag->excludes(r1_flag)->excludes(em_flag)->excludes(sol_flag); + r1_flag->excludes(em_flag)->excludes(sol_flag); + em_flag->excludes(sol_flag); create_key_cmd->add_option("-f,--file", key_file, localized("Name of file to write private/public key output to. (Must be set, unless \"--to-console\" is passed")); create_key_cmd->add_flag( "--to-console", print_console, localized("Print private/public keys to console.")); @@ -2087,6 +2108,95 @@ int main( int argc, char** argv ) { std::cout << localized("Public key: ${key}", ("key", pubk.to_string({}, true) ) ) << std::endl; }); + // EM (Ethereum-style secp256k1) key utilities. These let an external Ethereum signer (MetaMask personal_sign) + // interoperate with Wire offline: import a raw Ethereum secret as a Wire PVT_EM_ key, and sign/recover a Wire + // transaction sig_digest exactly as nodeop validates it, using libfc's own em path (the same code the chain runs). + + /// Parse a Wire PVT_EM_ string or a raw 0x-prefixed Ethereum hex secret into a unified em private key. + /// One helper, used by every em_* subcommand below. + auto parse_em_private_key = [](const std::string& s) -> fc::crypto::private_key { + if (s.rfind("PVT_EM_", 0) == 0) + return fc::crypto::private_key::from_string(s, fc::crypto::private_key::key_type::em); + // Raw Ethereum form (what MetaMask / eth tooling exports): 0x<64hex> or 64hex. + auto em_priv = fc::em::private_key::from_native_string(s); + return fc::crypto::private_key::regenerate(em_priv.get_secret()); + }; + + /// Parse a 32-byte sha256 digest given as 64 hex chars (optional 0x prefix). + auto parse_sha256_hex = [](std::string s) -> fc::sha256 { + if (s.rfind("0x", 0) == 0 || s.rfind("0X", 0) == 0) + s = s.substr(2); + SYSC_ASSERT(s.size() == 64, "ERROR: digest must be a 32-byte sha256 (64 hex chars, 0x optional)"); + return fc::sha256(s); + }; + + string em_private_key; + auto em_private_key_cmd = convert_cmd->add_subcommand("em_private_key", localized("Convert a raw Ethereum secret (or PVT_EM_) to Wire PVT_EM_/PUB_EM_ key forms")); + em_private_key_cmd->add_option("--private-key", em_private_key, localized("PVT_EM_... or a raw 0x Ethereum hex secret. Omit to enter it at the prompt; " + "passing it here exposes the secret in ps/shell history"))->expected(0, 1); + em_private_key_cmd->add_option("-f,--file", key_file, localized("Name of file to write private/public key output to. (Must be set, unless \"--to-console\" is passed")); + em_private_key_cmd->add_flag("--to-console", print_console, localized("Print private/public keys to console.")); + em_private_key_cmd->callback([&] { + if (key_file.empty() && !print_console) { + std::cerr << "ERROR: Either indicate a file using \"--file\" or pass \"--to-console\"" << std::endl; + return; + } + if (em_private_key.empty()) { + std::cout << localized("private key: "); + fc::set_console_echo(false); + std::getline(std::cin, em_private_key, '\n'); + fc::set_console_echo(true); + std::cout << std::endl; + } + auto privk = parse_em_private_key(em_private_key); + auto pubk = privk.get_public_key(); + if (print_console) { + std::cout << localized("Private key: ${key}", ("key", privk.to_string({}, true)) ) << std::endl; + std::cout << localized("Public key: ${key}", ("key", pubk.to_string({}, true)) ) << std::endl; + } else { + std::cerr << localized("saving keys to ${filename}", ("filename", key_file)) << std::endl; + std::ofstream out( key_file.c_str() ); + out << localized("Private key: ${key}", ("key", privk.to_string({}, true)) ) << std::endl; + out << localized("Public key: ${key}", ("key", pubk.to_string({}, true)) ) << std::endl; + } + }); + + string em_sign_digest; + string em_sign_priv; + auto em_sign_cmd = convert_cmd->add_subcommand("em_sign", localized("Sign a 32-byte sha256 digest with an EM key (EIP-191 personal_sign), printing SIG_EM_")); + em_sign_cmd->add_option("digest", em_sign_digest, localized("32-byte sha256 digest, 64 hex chars (0x optional)"))->required(); + em_sign_cmd->add_option("--private-key", em_sign_priv, localized("PVT_EM_... or a raw 0x Ethereum hex secret. Omit to enter it at the prompt; " + "passing it here exposes the secret in ps/shell history"))->expected(0, 1); + em_sign_cmd->callback([&] { + if (em_sign_priv.empty()) { + std::cout << localized("private key: "); + fc::set_console_echo(false); + std::getline(std::cin, em_sign_priv, '\n'); + fc::set_console_echo(true); + std::cout << std::endl; + } + auto privk = parse_em_private_key(em_sign_priv); + auto digest = parse_sha256_hex(em_sign_digest); + // private_key::sign dispatches to em::sign_sha256, which wraps the digest in the EIP-191 personal_sign + // envelope before secp256k1 -- identical to what MetaMask produces and to what nodeop recovers. Emit the + // prefixed SIG_EM_ form, which is what `clio push transaction --signature` and send_transaction2 expect. + std::cout << localized("Signature: ${sig}", ("sig", privk.sign(digest).to_string({}, true)) ) << std::endl; + }); + + string em_recover_sig; + string em_recover_digest; + auto em_recover_cmd = convert_cmd->add_subcommand("em_recover", localized("Recover the PUB_EM_ from a SIG_EM_ over a 32-byte sha256 digest (EIP-191)")); + em_recover_cmd->add_option("signature", em_recover_sig, localized("SIG_EM_... signature to recover from"))->required(); + em_recover_cmd->add_option("digest", em_recover_digest, localized("32-byte sha256 digest, 64 hex chars (0x optional)"))->required(); + em_recover_cmd->callback([&] { + auto sig = fc::crypto::signature::from_string(em_recover_sig, fc::crypto::signature::sig_type::em); + auto digest = parse_sha256_hex(em_recover_digest); + // public_key::recover dispatches to em::recover, which applies the same EIP-191 envelope before recovery. + // Emit the prefixed PUB_EM_ form so it compares directly against an expandauth-registered key. + auto pubk = fc::crypto::public_key::recover(sig, digest); + std::cout << localized("Public key: ${key}", ("key", pubk.to_string({}, true)) ) << std::endl; + }); + string name_input; auto name_cmd = convert_cmd->add_subcommand("name", localized("Convert between sysio::name and uint64_t, printing both interpretations")); name_cmd->add_option("input", name_input, localized("A sysio name or uint64_t value (decimal, or 0x-prefixed hex)"))->required(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f4cadd008f..449f4cd30e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -23,6 +23,10 @@ configure_file(${CMAKE_CURRENT_SOURCE_DIR}/cluster_launcher.py ${CMAKE_CURRENT_B configure_file(${CMAKE_CURRENT_SOURCE_DIR}/distributed-transactions-test.py ${CMAKE_CURRENT_BINARY_DIR}/distributed-transactions-test.py COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/sysio_util_bls_test.py ${CMAKE_CURRENT_BINARY_DIR}/sysio_util_bls_test.py COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/sysio_util_snapshot_info_test.py ${CMAKE_CURRENT_BINARY_DIR}/sysio_util_snapshot_info_test.py COPYONLY) +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/metamask/push_metamask_trx.py ${CMAKE_CURRENT_BINARY_DIR}/metamask/push_metamask_trx.py COPYONLY) +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/metamask/metamask-sign.html ${CMAKE_CURRENT_BINARY_DIR}/metamask/metamask-sign.html COPYONLY) +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/metamask/README.md ${CMAKE_CURRENT_BINARY_DIR}/metamask/README.md COPYONLY) +configure_file(${CMAKE_CURRENT_SOURCE_DIR}/clio_em_key_test.py ${CMAKE_CURRENT_BINARY_DIR}/clio_em_key_test.py COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/sample-cluster-map.json ${CMAKE_CURRENT_BINARY_DIR}/sample-cluster-map.json COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/restart-scenarios-test.py ${CMAKE_CURRENT_BINARY_DIR}/restart-scenarios-test.py COPYONLY) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/terminate-scenarios-test.py ${CMAKE_CURRENT_BINARY_DIR}/terminate-scenarios-test.py COPYONLY) @@ -233,6 +237,8 @@ add_np_test(NAME nodeop_chainbase_allocation_test COMMAND tests/nodeop_chainbase add_np_test(NAME nodeop_signal_throw_test COMMAND tests/nodeop_signal_throw_test.py -v ${UNSHARE}) +add_np_test(NAME metamask_trx_signing_test COMMAND tests/metamask/push_metamask_trx.py --simulate -v ${UNSHARE}) + add_lr_test(NAME nodeop_startup_catchup_lr_test COMMAND tests/nodeop_startup_catchup.py -v ${UNSHARE}) add_np_test(NAME nodeop_short_fork_take_over_test COMMAND tests/nodeop_short_fork_take_over_test.py -v --wallet-port 9905 ${UNSHARE}) @@ -252,6 +258,7 @@ add_np_test(NAME lib_advance_test COMMAND tests/lib_advance_test.py -v ${UNSHARE add_np_test(NAME producer_rank_test COMMAND tests/producer_rank_test.py -v ${UNSHARE}) add_p_test(NAME sysio_util_bls_test COMMAND tests/sysio_util_bls_test.py) +add_p_test(NAME clio_em_key_test COMMAND tests/clio_em_key_test.py) add_p_test(NAME sysio_util_snapshot_info_test COMMAND tests/sysio_util_snapshot_info_test.py) add_np_test(NAME http_plugin_test COMMAND tests/http_plugin_test.py ${UNSHARE} TIMEOUT 200) diff --git a/tests/clio_em_key_test.py b/tests/clio_em_key_test.py new file mode 100755 index 0000000000..1314ff36b7 --- /dev/null +++ b/tests/clio_em_key_test.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 + +"""Offline test for clio's EM (Ethereum-style secp256k1) and SOL (ed25519) key support. + +Exercises, with no running node: + + * ``clio create key --em`` -> PVT_EM_/PUB_EM_ keypair, round-tripped through + ``clio convert em_private_key`` / ``em_sign`` / ``em_recover``. + * ``clio create key --sol`` -> PVT_ED_/PUB_ED_ (Solana ed25519) keypair. + * A frozen known-answer vector proving libfc's EM path is byte-for-byte + identical to the Ethereum ecosystem reference (eth_account / MetaMask + ``personal_sign``). This is the cross-implementation anchor: a regression in + Wire's EIP-191 envelope, low-s normalization, or recovery-id handling fails + here, hermetically, with no pip/apt/browser dependency. + * Flag mutual-exclusion and input-validation error paths. + +Known-answer vector provenance (regenerate only by a deliberate, reviewed act): + + Reference implementation : eth_account 0.13.7 (eth-keys/eth-utils stack), + byte-identical to MetaMask personal_sign for the + same key and message. + Ethereum private key : 0x46 * 32 (the well-known EIP-155 example key) + Message / digest : sha256(b"wire-sysio metamask external-signer KAT v1") + = 7358248b67726c147e6b100d188dec3b5be0bdc08735378b6146ec0207cf58c4 + Recipe : personal_sign over the 32 raw digest bytes + (EIP-191: keccak256("\\x19Ethereum Signed Message:\\n32" + digest)), + deterministic RFC-6979 secp256k1, low-s. + +The expected PUB_EM_/SIG_EM_ below were produced by that reference and MUST NOT +be edited to match a code change -- a mismatch means clio/libfc diverged from +the Ethereum ecosystem, which is a bug in libfc, not in this vector. +""" + +import re +import subprocess +import tempfile + +from TestHarness import Utils + +Print = Utils.Print + +# --- frozen known-answer vector (see module docstring for provenance) --- +KAT_ETH_SECRET = "0x4646464646464646464646464646464646464646464646464646464646464646" +KAT_PVT_EM = "PVT_EM_0x4646464646464646464646464646464646464646464646464646464646464646" +KAT_DIGEST = "7358248b67726c147e6b100d188dec3b5be0bdc08735378b6146ec0207cf58c4" +KAT_PUB_EM = ("PUB_EM_044bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb629742" + "47bb493382ce28cab79ad7119ee1ad3ebcdb98a16805211530ecc6cfefa1b88e6dff99232a") +KAT_SIG_EM = ("SIG_EM_0xea0847dc6f2c9a189b85f01e9a1d51931ac92ee381b67641384b523" + "14a803fd5064a69f67b5967f2cac3fa3b9cf3f4f0be9f08e282e0e71ed0ce13d202a892b71b") +# KAT_DIGEST with the last hex nibble flipped: a valid but different 32-byte +# digest. Recovering KAT_SIG_EM against this must yield a *different* key -- +# proving recovery is digest-bound (the property the stale-signature pre-flight +# in the metamask harness relies on). +KAT_WRONG_DIGEST = KAT_DIGEST[:-1] + ("5" if KAT_DIGEST[-1] != "5" else "6") + +testSuccessful = False + + +def clio(*args, expect_fail=False, raw=False): + """Run the clio binary offline with `args`. + + Default: assert exit 0 and return a dict of the ``Label: value`` lines clio + prints. `expect_fail=True`: assert a non-zero exit (CLI11 parse error or a + thrown libfc exception) and return None. `raw=True`: return the + ``subprocess.CompletedProcess`` for callers that assert on stderr / exit code + directly (used for clio's soft-validation paths, which by long-standing + convention print ``ERROR:`` and emit nothing but still exit 0). + """ + cmd = [Utils.SysClientPath, *args] + if Utils.Debug: + Print("cmd: %s" % " ".join(cmd)) + res = subprocess.run(cmd, capture_output=True, text=True) + if raw: + return res + if expect_fail: + assert res.returncode != 0, "expected failure but clio succeeded: %s\n%s" % (cmd, res.stdout) + return None + assert res.returncode == 0, "clio failed: %s\nstderr: %s\nstdout: %s" % (cmd, res.stderr, res.stdout) + return {k: v for k, v in re.findall(r'(\w[^:\n]*): ([^\n]+)', res.stdout)} + + +def test_create_key_em_roundtrip(): + """create key --em yields a PVT_EM_/PUB_EM_ pair that round-trips through convert.""" + r = clio("create", "key", "--em", "--to-console") + priv, pub = r["Private key"], r["Public key"] + assert priv.startswith("PVT_EM_"), priv + assert pub.startswith("PUB_EM_"), pub + + # PVT_EM_ -> same PUB_EM_ via convert em_private_key + assert clio("convert", "em_private_key", "--private-key", priv, "--to-console")["Public key"] == pub + + # sign an arbitrary digest with it, then recover -> must yield the same PUB_EM_ + sig = clio("convert", "em_sign", KAT_DIGEST, "--private-key", priv)["Signature"] + assert sig.startswith("SIG_EM_"), sig + assert clio("convert", "em_recover", sig, KAT_DIGEST)["Public key"] == pub + + # two fresh keys must differ (sanity that generation is not fixed) + assert clio("create", "key", "--em", "--to-console")["Private key"] != priv + + +def test_create_key_sol(): + """create key --sol yields a Solana ed25519 PVT_ED_/PUB_ED_ pair.""" + r = clio("create", "key", "--sol", "--to-console") + assert r["Private key"].startswith("PVT_ED_"), r["Private key"] + assert r["Public key"].startswith("PUB_ED_"), r["Public key"] + assert clio("create", "key", "--sol", "--to-console")["Private key"] != r["Private key"] + + +def test_create_key_to_file(): + """--file persists the same forms --to-console prints.""" + with tempfile.NamedTemporaryFile(mode="w+") as f: + clio("create", "key", "--em", "--file", f.name) + f.seek(0) + body = f.read() + assert "PVT_EM_" in body and "PUB_EM_" in body, body + + +def test_known_answer_vector(): + """libfc's EM path is byte-for-byte identical to eth_account / MetaMask. + + Both the raw-Ethereum-secret import path and the PVT_EM_ path must derive + the reference public key; em_sign must reproduce the reference signature + (RFC-6979 deterministic, so this is exact); em_recover must invert it. + """ + # raw Ethereum secret import -> reference PUB_EM_ + assert clio("convert", "em_private_key", "--private-key", KAT_ETH_SECRET, + "--to-console")["Public key"] == KAT_PUB_EM + # PVT_EM_ import -> same + assert clio("convert", "em_private_key", "--private-key", KAT_PVT_EM, + "--to-console")["Public key"] == KAT_PUB_EM + + # signature is deterministic and must match the reference, from either key form + assert clio("convert", "em_sign", KAT_DIGEST, "--private-key", KAT_ETH_SECRET)["Signature"] == KAT_SIG_EM + assert clio("convert", "em_sign", KAT_DIGEST, "--private-key", KAT_PVT_EM)["Signature"] == KAT_SIG_EM + # 0x-prefixed digest is accepted and equivalent + assert clio("convert", "em_sign", "0x" + KAT_DIGEST, "--private-key", KAT_PVT_EM)["Signature"] == KAT_SIG_EM + + # recover the reference signature back to the reference public key + assert clio("convert", "em_recover", KAT_SIG_EM, KAT_DIGEST)["Public key"] == KAT_PUB_EM + + +def test_recover_is_digest_bound(): + """Recovery is bound to the exact digest signed. + + Recovering the reference signature against a *different* (but well-formed) + digest still succeeds -- ECDSA recovery yields some point for almost any + digest -- but must yield a DIFFERENT public key, never the reference one. + This is precisely what lets the metamask harness reject a stale or + wrong-digest paste offline instead of as an opaque unsatisfied_authorization. + """ + recovered = clio("convert", "em_recover", KAT_SIG_EM, KAT_WRONG_DIGEST)["Public key"] + assert recovered.startswith("PUB_EM_"), recovered + assert recovered != KAT_PUB_EM, "recovery is not digest-bound: wrong digest recovered the reference key" + + +def test_error_handling(): + """Mutually exclusive curve flags and malformed crypto inputs are hard errors. + + The missing-output-sink case is clio's long-standing soft-validation + convention (shared by ``create key`` / ``convert k1_private_key``): print + ``ERROR:`` and emit no key, exit 0. Assert that contract rather than an exit + code so this test does not silently encode a behavior change to it. + """ + # mutually exclusive curve flags -> CLI11 parse error (non-zero); all of + # --k1/--r1/--em/--sol exclude each other + clio("create", "key", "--em", "--sol", "--to-console", expect_fail=True) + clio("create", "key", "--em", "--r1", "--to-console", expect_fail=True) + clio("create", "key", "--k1", "--em", "--to-console", expect_fail=True) + clio("create", "key", "--k1", "--r1", "--to-console", expect_fail=True) + + # neither --file nor --to-console: soft validation -> ERROR, no key, exit 0 + res = clio("create", "key", "--em", raw=True) + assert "ERROR" in res.stderr, res.stderr + assert "PVT_EM_" not in res.stdout, res.stdout + + # malformed crypto inputs throw in libfc -> non-zero + clio("convert", "em_sign", "deadbeef", "--private-key", KAT_PVT_EM, expect_fail=True) # 8 hex != 64 + clio("convert", "em_sign", "z" * 64, "--private-key", KAT_PVT_EM, expect_fail=True) # 64 chars, not hex + clio("convert", "em_recover", "SIG_EM_0xdeadbeef", KAT_DIGEST, expect_fail=True) # malformed sig + + +try: + test_create_key_em_roundtrip() + test_create_key_sol() + test_create_key_to_file() + test_known_answer_vector() + test_recover_is_digest_bound() + test_error_handling() + testSuccessful = True +except Exception as e: + Print(e) + Utils.errorExit("exception during processing") + +exit(0 if testSuccessful else 1) diff --git a/tests/metamask/.gitignore b/tests/metamask/.gitignore new file mode 100644 index 0000000000..7a60b85e14 --- /dev/null +++ b/tests/metamask/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/tests/metamask/README.md b/tests/metamask/README.md new file mode 100644 index 0000000000..869e670790 --- /dev/null +++ b/tests/metamask/README.md @@ -0,0 +1,136 @@ +# Metamask transaction-signing harness + +End-to-end test that a Wire transaction signed by **MetaMask** (or any wallet that +exposes EIP-191 `personal_sign` over secp256k1) is accepted by `nodeop`. + +Wire supports this in C++ -- `signature_shim::recover()` applies the EIP-191 +prefix to a `sha256` transaction digest and recovers an `em::public_key`, and +the `expandauth_sign_with_eth_key` unit test proves the full in-process flow. +This directory adds the **external-signer** half: real MetaMask in a browser, +signing a real trx that gets pushed to nodeop. + +The automated path drives `clio`'s own EM tooling (`clio create key --em`, +`clio convert em_sign` / `em_recover`), which is libfc's own crypto -- the exact +code nodeop runs to validate these signatures. There is **no Python crypto +dependency**: the only thing this harness needs is the `clio` binary the build +already produces. clio's EM path is pinned byte-for-byte against the +Ethereum-ecosystem reference (`eth_account` / MetaMask `personal_sign`) by the +separate, hermetic `clio_em_key_test` ctest; this harness exercises the full +chain round-trip. + + +## What's here + +| File | Role | +| ---- | ---- | +| `metamask-sign.html` | Static page that talks to MetaMask via `window.ethereum`. Calls `personal_sign` on a 32-byte digest, locally ecRecovers the public key, and displays both raw hex (MetaMask format) and Wire's `SIG_EM_` / `PUB_EM_` forms ready to paste into the push helper. | +| `push_metamask_trx.py` | TestHarness-driven end-to-end test. Stands up a single-node cluster, creates an account, calls `expandauth` to add the EM key, seeds it with SYS transferred from `sysio`, builds two unsigned `sysio.token::transfer` trxs, signs each digest (via clio in `--simulate`, or pasted-from-browser in manual mode), and pushes one via HTTP `send_transaction2` and one via `clio push transaction`. | + + +## Quick offline sanity check + +No node or Python deps -- just clio: + +```bash +cd $BUILD_DIR +eval "$(bin/clio create key --em --to-console | sed 's/Private key: /PRIV=/;s/Public key: /PUB=/')" +DIGEST=$(printf 'wire metamask sanity' | sha256sum | cut -d' ' -f1) # any 32-byte sha256 +SIG=$(bin/clio convert em_sign "$DIGEST" --private-key "$PRIV" | sed 's/Signature: //') +bin/clio convert em_recover "$SIG" "$DIGEST" # -> Public key: $PUB +``` + +The recovered `PUB_EM_` equals the generated one when the EIP-191 envelope, the +secp256k1 recovery, and Wire's plain-hex `SIG_EM_` / `PUB_EM_` framing all +round-trip. The exhaustive version of this check, including the frozen +known-answer vector minted from `eth_account`, is `ctest -R clio_em_key_test`. + + +## Full end-to-end (automated) + +This is the path `ctest -R metamask_trx_signing_test` runs. + +```bash +cd $BUILD_DIR +python3 tests/metamask/push_metamask_trx.py --simulate -v +``` + +The script: + +1. Launches a single-node cluster via TestHarness. +2. Creates `mmtest11`, gives it K1 keys. +3. Generates an EM keypair with `clio create key --em` and adds the `PUB_EM_` + to `mmtest11@active` via `sysio::expandauth`. (`--sim-private-key` pins a + fixed `PVT_EM_` or raw `0x` Ethereum secret for reproducibility.) +4. Transfers `100.0000 SYS` from `sysio` to `mmtest11` (bootstrap issued the + full max_supply to `sysio`, so just transfer instead of `issue`). +5. **Round 1 - HTTP path:** builds an unsigned `sysio.token::transfer + mmtest11 -> defproducera` via `clio push action -s -d --return-packed -j`, + computes `sig_digest = sha256(chain_id || packed_trx || zero_32)`, signs + via `clio convert em_sign`, and `POST`s the packed_transaction directly to + `/v1/chain/send_transaction2`. +6. **Round 2 - clio path:** builds a second unsigned transfer (different + memo so the trx id is unique), computes its digest, signs, and pushes via + `clio convert unpack_transaction ... | clio push transaction --signature SIG_EM_... -s`. +7. After each round, fetches `mmtest11`'s SYS balance and asserts it dropped by + exactly the transfer amount (integer minimal units, no float), and that the + trx reports `processed.except == null` and `processed.error_code == null`. + + +## Full end-to-end (real MetaMask) + +```bash +cd $BUILD_DIR +python3 tests/metamask/push_metamask_trx.py -v +``` + +Open `metamask-sign.html` in a browser with MetaMask. If the browser can open +the local file directly, that works with no server. Under WSL (browser on the +Windows side) serve it instead, e.g. from `tests/metamask/`: +`python3 -m http.server 8866 --bind 0.0.0.0`, then open +`http://localhost:8866/metamask-sign.html`. The script will: + +1. Spin up the cluster as above. +2. Prompt for your Wire `PUB_EM_` -- the HTML page shows it once you sign any + test message. +3. Add the `PUB_EM_` to the test account via `expandauth`. +4. Print the trx `sig_digest`. Paste that into the HTML page's "Digest to sign" + field, click "Sign with Metamask", copy the `SIG_EM_` value. +5. Paste the `SIG_EM_` back into the script. It is recovered offline via + `clio convert em_recover` and checked against the registered `PUB_EM_` + before being sent; a stale or wrong-digest signature is rejected locally and + re-prompted. +6. Push, check execution. Repeated for Round 2 (different digest). + + +## Notes / gotchas + +- MetaMask's `personal_sign` accepts the message either as a UTF-8 string or as + `0x`-prefixed hex. **Always pass `0x` + 64 hex chars** for a 32-byte digest: + if you pass a UTF-8 string by mistake, MetaMask prefix-wraps the UTF-8 bytes + of the *string*, not the underlying digest, and the chain rejects the + signature. +- EIP-191 v-byte is `27` or `28` (recovery_id + 27). libfc's `em::sign_sha256` + emits `27/28`; recovery accepts either. +- ECDSA signatures must be canonical (`s < n/2`). MetaMask and libfc produce + canonical signatures, but ad-hoc third-party signers may not; Wire's + `em::public_key::is_canonical()` rejects them. +- The Wire EM form is hex, not base58 + ripemd160 like K1/R1/BLS: + - `SIG_EM_0x<130hex>`: `to_hex()` always prefixes 0x, so the 0x lives + between `SIG_EM_` and the hex chars. + - `PUB_EM_<130hex>`: no `0x` in the middle; first two hex chars are the + SEC1 `04` uncompressed prefix. +- The Wire send_transaction2 reply's success indicator is + `processed.except == null` and `processed.error_code == null`; the trx-level + `receipt` field can be `null` while the trx still executes successfully. +- `--trx-expiration` (default 600s) must be in `(0, 3600]` -- the chain's + `max_transaction_lifetime` under TestHarness. Manual signing needs the wide + window because the expiration is baked into the bytes the signer hashes and + cannot be changed after signing. +- This test pushes through `sysio.token::transfer` because it requires + authentication to succeed and visibly mutates state (balance change). The + same path works for any action; swap the body of `push_metamask_trx.py` if + you need to exercise something else. +- `--sim-private-key` and `clio convert em_private_key/em_sign --private-key` + put a secret on the command line, which is visible in `ps` and shell history. + These are test/throwaway keys, so it does not matter here; for real keys omit + the option and enter it at clio's interactive prompt instead. diff --git a/tests/metamask/metamask-sign.html b/tests/metamask/metamask-sign.html new file mode 100644 index 0000000000..c1757a9161 --- /dev/null +++ b/tests/metamask/metamask-sign.html @@ -0,0 +1,201 @@ + + + + +Wire: Metamask signing harness + + + +

Wire transaction signing harness for Metamask

+

+Connect Metamask, paste a 32-byte transaction sig-digest (sha256 of the trx), +and click Sign. The signature is produced via personal_sign which +applies the EIP-191 prefix exactly the way Wire's +signature_shim::recover() expects. +The page also outputs the Wire-formatted SIG_EM_... / +PUB_EM_... values you'll paste into push_metamask_trx.py. +

+ +
+
Wallet
+ +
not connected
+
+ +
+
Digest to sign (32-byte trx digest as 0x-prefixed 64 hex chars)
+ +
+ +
+ + +
+ +

Signature

+
+
Raw Metamask signature (0x + r||s||v hex, 132 chars)
+
+
+ +
+
+
+
Wire SIG_EM_
+
+
+ +
+
+ +

Signer identity (recovered locally)

+
+
Ethereum address
+
+
+ +
+
+
+
Uncompressed pubkey (0x04 + X || Y, 130 hex chars)
+
+
+ +
+
+
+
Wire PUB_EM_
+
+
+ +
+
+ +

+Uses ethers.js v6 (pinned 6.13.4, loaded from cdnjs with a Subresource +Integrity hash) for personal_sign + ecRecover + keccak256. Wire's EM signature +and pubkey forms are plain hex (SIG_EM_0x... / PUB_EM_...), not the base58 + +ripemd160 checksum used by K1/R1/BLS keys, so no extra encoding is applied. +

+ + + + + diff --git a/tests/metamask/push_metamask_trx.py b/tests/metamask/push_metamask_trx.py new file mode 100755 index 0000000000..bf1f0213a1 --- /dev/null +++ b/tests/metamask/push_metamask_trx.py @@ -0,0 +1,556 @@ +#!/usr/bin/env python3 +"""End-to-end Metamask-signed transaction test. + +What this exercises: + 1. Stand up a single-node TestHarness cluster. + 2. Create a test account with K1 keys on its active permission. + 3. Append a Metamask-style EM pubkey to the active permission via + sysio::expandauth. + 4. Seed the test account by transferring SYS from `sysio` (the bootstrap + issues the full max_supply to `sysio`, so issuing more would exceed it). + 5. For each of two rounds, build an unsigned `sysio.token::transfer`, + compute its sig_digest (sha256(chain_id || packed_trx || cfd_digest)), + sign that digest, push it, and assert the sender balance dropped by + exactly the transfer amount: + Round 1 - POST the packed_transaction to /v1/chain/send_transaction2. + Round 2 - clio convert unpack_transaction | clio push transaction + --signature SIG_EM_... -s. + 6. Signing has two modes: + --simulate : clio's own EM tooling drives the signing automatically + (`clio create key --em` for the identity, + `clio convert em_sign` for the signature). This is what + the `metamask_trx_signing_test` ctest runs, so CI needs + no browser and no Python eth stack -- the only dependency + is the clio binary the build already produces. + (default) : print the digest and wait for the user to paste the + SIG_EM_... value from metamask-sign.html (real MetaMask). + Every pasted signature is recovered offline via + `clio convert em_recover` and checked against the EM key + registered on the account before anything reaches the + chain, so a stale or wrong paste is rejected locally with + a clear message instead of as an opaque + unsatisfied_authorization. + +clio's EM path is libfc's own crypto -- the exact code nodeop runs to validate +these signatures -- and is pinned byte-for-byte against the Ethereum-ecosystem +reference (eth_account / MetaMask personal_sign) by the separate, hermetic +`clio_em_key_test` ctest. This harness exercises the full chain round-trip. + +Run from your build directory (so $BUILD_DIR/programs/clio resolves): + + cd $BUILD_DIR + python3 tests/metamask/push_metamask_trx.py --simulate + python3 tests/metamask/push_metamask_trx.py # manual (real MetaMask) + +Use `--leave-running` to leave the cluster up after the test. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent)) # tests/ -- so `import TestHarness` resolves + +# TestHarness shaping +from TestHarness import ( # noqa: E402 + Cluster, + TestHelper, + Utils, + WalletMgr, + createAccountKeys, +) +from TestHarness.TestHelper import AppArgs # noqa: E402 + + +Print = Utils.Print +errorExit = Utils.errorExit + +# Mirrors the max_transaction_lifetime the TestHarness launcher configures +# (tests/TestHarness/launcher.py). A built transfer's expiration cannot exceed +# this, so an out-of-range --trx-expiration is rejected up front with a clear +# message rather than failing opaquely inside clio. +MAX_TRX_EXPIRATION_SECONDS = 3600 + +def _prompt(label: str) -> str: + """input() that turns EOF / Ctrl-C into a clean TestHarness exit. + + Manual mode is interactive; if stdin is closed (piped, no TTY) a bare + input() raises EOFError and dumps a traceback. Fail with a clear message + instead so the cluster still shuts down via main()'s finally. + """ + try: + return input(label).strip() + except (EOFError, KeyboardInterrupt): + errorExit("aborted: no input on stdin (manual mode needs an interactive terminal)") + + +def _run_clio(node, *args, capture_json=True): + """Run clio against `node` and return stdout (json-decoded if requested). + + A non-zero clio exit is fatal via errorExit, matching how the rest of this + test reports failures (clean TestHarness shutdown, no raw traceback). + """ + cmd = [Utils.SysClientPath, *node.sysClientArgs().split(), *args] + if Utils.Debug: + Print(f"cmd: {' '.join(cmd)}") + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode != 0: + errorExit(f"clio failed: {' '.join(cmd)}\nstderr: {res.stderr}\nstdout: {res.stdout}") + out = res.stdout.strip() + if not capture_json: + return out + return json.loads(out) if out else {} + + +def _clio_offline(*args, expect_fail=False) -> dict[str, str] | None: + """Run clio for an offline subcommand (create key / convert em_*); no node. + + These subcommands print `Label: value` lines and never touch a wallet or + nodeop, so they take no node args. Returns the parsed lines as a dict, or + None when `expect_fail` and clio exited non-zero (a malformed signature in + the manual pre-flight, recovered locally rather than at the chain). + """ + cmd = [Utils.SysClientPath, *args] + if Utils.Debug: + Print(f"cmd: {' '.join(cmd)}") + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode != 0: + if expect_fail: + return None + errorExit(f"clio failed: {' '.join(cmd)}\nstderr: {res.stderr}\nstdout: {res.stdout}") + return {k: v for k, v in re.findall(r'(\w[^:\n]*): ([^\n]+)', res.stdout)} + + +def _parse_asset(asset: str) -> tuple[int, int, str]: + """Parse a Wire asset string ("100.0000 SYS") into (units, precision, symbol). + + `units` is the amount in integer minimal units (here 1000000 for the + example) so balance math never touches floating point. + """ + asset = (asset or "").strip() + if not asset: + raise ValueError("empty asset string") + amount_str, _, symbol = asset.partition(" ") + symbol = symbol.strip() + if not symbol: + raise ValueError(f"asset missing symbol: {asset!r}") + whole, dot, frac = amount_str.partition(".") + precision = len(frac) if dot else 0 + units = int(whole or "0") * (10 ** precision) + (int(frac) if frac else 0) + return units, precision, symbol + + +def _assert_balance_decreased(label: str, before: str, after: str, expected_delta: str): + """Assert `before - after == expected_delta` exactly (integer units, same symbol).""" + b_units, _, b_sym = _parse_asset(before) + a_units, _, a_sym = _parse_asset(after) + d_units, _, d_sym = _parse_asset(expected_delta) + if not (b_sym == a_sym == d_sym): + errorExit(f"{label}: asset symbol mismatch: " + f"before={before!r} after={after!r} delta={expected_delta!r}") + if b_units - a_units != d_units: + errorExit(f"{label}: balance did not drop by {expected_delta}: " + f"before={before} after={after} " + f"(observed delta {b_units - a_units} minimal units, expected {d_units})") + Print(f"{label}: balance {before} -> {after} (-{expected_delta}) OK") + + +def _create_em_key(simulate: bool, + sim_priv_arg: str | None) -> tuple[dict[str, str], str | None]: + """Obtain the EM identity via clio (simulate) or by paste (manual). + + Returns (em_info, sim_priv_em). `em_info` always has exactly {wire_pub_em}. + `sim_priv_em` is the PVT_EM_ to sign with in --simulate mode and None in + manual mode (the private key never leaves the browser there). Keeping the + shape identical across both modes avoids a branch-dependent KeyError. + """ + if simulate: + if sim_priv_arg: + # Reproducible: a fixed PVT_EM_ or raw 0x Ethereum hex secret. + r = _clio_offline("convert", "em_private_key", + "--private-key", sim_priv_arg, "--to-console") + else: + r = _clio_offline("create", "key", "--em", "--to-console") + return {"wire_pub_em": r["Public key"]}, r["Private key"] + + Print("\nOpen tests/metamask/metamask-sign.html in a browser with Metamask") + Print("installed. Click 'Connect Metamask' and then sign any test message so") + Print("the page can ecRecover and surface your Wire PUB_EM_ pubkey.") + Print("Paste it below (the per-signature pre-flight checks every signature") + Print("recovers to THIS key before anything reaches the chain):\n") + wire_pub_em = _prompt("Wire PUB_EM_ pubkey: ") + if not wire_pub_em.startswith("PUB_EM_"): + errorExit(f"pasted Wire pubkey is not a PUB_EM_ value: {wire_pub_em!r}") + return {"wire_pub_em": wire_pub_em}, None + + +def _verify_sig_locally(digest_hex: str, sig_em: str, + expected_wire_pub: str) -> tuple[int, str]: + """Offline pre-flight before pushing: recover the signer pubkey from + (digest, sig) via `clio convert em_recover` -- the SAME libfc EIP-191 path + nodeop applies -- and confirm it matches the EM key registered on the + account. + + Returns (status, detail): 0 ok; 1 not a recoverable signature (clio could + not recover, e.g. malformed paste); 2 recovered-key mismatch (wrong/stale + digest signed). `detail` carries the recovered-vs-expected text so a bad + paste is diagnosable HERE instead of as an opaque unsatisfied_authorization. + """ + r = _clio_offline("convert", "em_recover", sig_em, digest_hex, expect_fail=True) + if r is None or "Public key" not in r: + return 1, "clio could not recover a public key from the pasted signature" + recovered = r["Public key"] + if recovered == expected_wire_pub: + return 0, recovered + return 2, (f" recovered : {recovered}\n" + f" expected : {expected_wire_pub}") + + +def _sign_with_metamask(simulate: bool, sim_priv_em: str | None, + digest_hex: str, expected_wire_pub: str) -> str: + """Get a SIG_EM_... signature for `digest_hex` from clio (sim) or human paste. + + Manual mode runs an offline recovery pre-flight on the pasted signature before + returning it: anything that does not recover to the account's registered EM + key is rejected locally with a clear diagnostic and the user is re-prompted, + rather than letting nodeop reject it later with an opaque + unsatisfied_authorization. + """ + if simulate: + assert sim_priv_em, "simulator mode requires the simulator's PVT_EM_ key" + # clio convert em_sign applies the same EIP-191 envelope nodeop recovers + # with; pinned byte-for-byte to MetaMask by the clio_em_key_test ctest. + return _clio_offline("convert", "em_sign", digest_hex, + "--private-key", sim_priv_em)["Signature"] + + Print("\n" + "=" * 72) + Print("Paste THIS EXACT digest into the HTML page's 'Digest to sign' field:") + Print(f" {digest_hex}") + Print("Click 'Sign with Metamask', then copy the SIG_EM_... value back here.") + Print("(blank input aborts the test)") + Print("=" * 72) + while True: + sig = _prompt("SIG_EM_...: ") + if not sig: + errorExit("aborted: no signature entered") + rc, detail = _verify_sig_locally(digest_hex, sig, expected_wire_pub) + if rc == 0: + Print("pre-flight OK: signature recovers to the registered EM key; pushing") + return sig + Print(f"\n*** REJECTED locally (NOT sent to chain):\n{detail}\n") + if rc == 2: + Print("The signature recovers to a DIFFERENT key than the one registered.") + Print("Almost always a stale signature from an earlier run, or the") + Print("pubkey-reveal signature instead of this transaction's digest.") + Print(f"The digest to sign is still: {digest_hex}") + Print("Re-sign THAT exact digest in the browser and paste again.") + Print("If this persists across a fresh re-sign, the PUB_EM_ you") + Print("pasted earlier may not be this wallet's key.\n") + else: # rc == 1 + Print("That is not a recoverable signature. Paste the full") + Print("SIG_EM_0x... value exactly as the page's 'Wire SIG_EM_' box") + Print(f"shows it, signed over digest: {digest_hex}\n") + + +def _compute_sig_digest(chain_id_hex: str, packed_trx_hex: str) -> str: + """Wire trx sig_digest: sha256(chain_id || packed_trx || cfd_digest). + + No context-free data: cfd_digest is 32 zero bytes. + """ + chain_id = bytes.fromhex(chain_id_hex) + if len(chain_id) != 32: + raise ValueError(f"chain_id must be 32 bytes, got {len(chain_id)}") + packed_trx = bytes.fromhex(packed_trx_hex) + cfd_digest = b"\x00" * 32 + return "0x" + hashlib.sha256(chain_id + packed_trx + cfd_digest).hexdigest() + + +def _build_unsigned_transfer(node, sender: str, recipient: str, quantity: str, + memo: str, expiration_seconds: int) -> dict: + """Use `clio push action -s -d --return-packed` to build an unsigned trx. + + Returns the {signatures, compression, packed_context_free_data, packed_trx} + object from clio. Signatures is always []. + + `expiration_seconds` is baked into the packed bytes the signer hashes, so it + cannot be changed after signing. Manual MetaMask signing has a human in the + loop (open page, paste digest, approve in the extension, maybe re-sign after + a pre-flight rejection); clio's 30s default expires long before that. The + caller passes a generous window (still <= chain max_transaction_lifetime). + """ + transfer_data = json.dumps({ + "from": sender, "to": recipient, + "quantity": quantity, "memo": memo, + }) + return _run_clio( + node, + "push", "action", "sysio.token", "transfer", transfer_data, + "-p", f"{sender}@active", + "-x", str(expiration_seconds), + "-s", "-d", "-j", "--return-packed", + ) + + +def _sign_unsigned_packed(packed, chain_id_hex: str, simulate: bool, + sim_priv_em: str | None, + expected_wire_pub: str) -> tuple[str, str]: + """Compute sig_digest and obtain a SIG_EM_... over it. Returns (sig_em, digest_hex). + + `expected_wire_pub` is the PUB_EM_ registered on the account; the manual path + pre-flights the pasted signature against it before returning. + """ + sig_digest = _compute_sig_digest(chain_id_hex, packed["packed_trx"]) + sig_em = _sign_with_metamask(simulate, sim_priv_em, sig_digest, expected_wire_pub) + return sig_em, sig_digest + + +def _push_via_http(node, packed, sig_em: str) -> dict: + """POST a packed_transaction directly to /v1/chain/send_transaction2.""" + return _post_send_transaction2(node, packed["packed_trx"], sig_em) + + +def _push_via_clio(node, packed, sig_em: str) -> dict: + """Round-trip the packed trx through `clio convert unpack_transaction` to recover + the JSON form, then hand to `clio push transaction --signature SIG_EM_... -s`. + + fc::raw::pack is deterministic, so the unpack -> repack on the send path yields + bytes identical to those we used to compute the sig_digest. If that invariant + ever breaks the test will fail with a recovered-key mismatch. + + `compression` is hardcoded "none": `clio push action -d --return-packed` + returns an uncompressed packed_trx, and the digest was computed over those + raw bytes. If clio ever returned a compressed form, repacking here would + change the bytes and the test would fail loudly at the pre-flight / chain. + """ + fake_packed = { + "signatures": [], + "compression": "none", + "packed_context_free_data": "", + "packed_trx": packed["packed_trx"], + } + unsigned_trx_obj = _run_clio(node, "convert", "unpack_transaction", json.dumps(fake_packed)) + return _run_clio( + node, + "push", "transaction", json.dumps(unsigned_trx_obj), + "--signature", sig_em, + "-s", "-j", + ) + + +def _post_send_transaction2(node, packed_trx_hex: str, sig_em: str) -> dict: + """POST a packed_transaction directly to /v1/chain/send_transaction2. + + Sending the packed form preserves the exact bytes used to compute the + sig_digest. Going through `clio push transaction` would re-pack from JSON + and (in principle) could produce different bytes than the ones we hashed. + + Returns the decoded JSON reply, or a {"http_error": ...} / {"net_error": ...} + marker so the caller's _assert_executed reports a clean message instead of a + raw traceback for an HTTP-status error or a connection/timeout failure. + """ + body = { + "return_failure_trace": True, + "retry_trx": False, + "transaction": { + "signatures": [sig_em], + "compression": "none", + "packed_context_free_data": "", + "packed_trx": packed_trx_hex, + }, + } + url = f"http://{node.host}:{node.port}/v1/chain/send_transaction2" + req = urllib.request.Request( + url, data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json"}, method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + return {"http_error": exc.code, "body": exc.read().decode("utf-8")} + except (urllib.error.URLError, TimeoutError) as exc: + return {"net_error": str(getattr(exc, "reason", exc)), "url": url} + + +def _assert_executed(label: str, result: dict): + """Success criterion shared by both push paths. + + send_transaction2 and `clio push transaction -j` return the same trace + shape: success means processed.except is null and processed.error_code is + null. The trx-level `receipt` can be null even on success, so it is not + checked. http_error / net_error markers from _post_send_transaction2 are + surfaced as clean failures. + """ + if "http_error" in result: + errorExit(f"{label}: HTTP error {result['http_error']}: {result['body']}") + if "net_error" in result: + errorExit(f"{label}: could not reach {result['url']}: {result['net_error']}") + processed = result.get("processed", {}) + exc = processed.get("except") + if exc: + errorExit(f"{label}: trx raised: {exc.get('name')}: {exc.get('message')}") + err_code = processed.get("error_code") + if err_code: + errorExit(f"{label}: trx error_code={err_code}") + trx_id = result.get("transaction_id") or processed.get("id") + block = processed.get("block_num") + Print(f"{label}: executed, trx_id={trx_id}, block_num={block}") + + +def main() -> int: + app_args = AppArgs() + app_args.add_bool("--simulate", + help="Sign automatically via clio's EM tooling instead of asking the user to paste") + app_args.add("--sim-private-key", type=str, default=None, + help="If --simulate, sign with this fixed key (PVT_EM_... or a raw 0x " + "Ethereum hex secret) instead of a freshly generated one") + app_args.add("--transfer-amount", type=str, default="1.0000 SYS", + help="Asset string to transfer (default: '1.0000 SYS')") + app_args.add("--trx-expiration", type=int, default=600, + help="Seconds before each built transfer expires. Manual signing " + "needs a wide window; default 600s, must be in " + f"(0, {MAX_TRX_EXPIRATION_SECONDS}] (chain max_transaction_lifetime).") + args = TestHelper.parse_args({ + "--keep-logs", "--dump-error-details", "-v", "--leave-running", "--unshared", + "--host", "--port", + }, applicationSpecificArgs=app_args) + + if not 0 < args.trx_expiration <= MAX_TRX_EXPIRATION_SECONDS: + errorExit(f"--trx-expiration must be in (0, {MAX_TRX_EXPIRATION_SECONDS}] " + f"seconds, got {args.trx_expiration}") + + Utils.Debug = args.v + cluster = Cluster( + host=args.host, port=args.port, + unshared=args.unshared, + keepRunning=args.leave_running, keepLogs=args.keep_logs, + ) + wallet_mgr = WalletMgr(True, keepRunning=args.leave_running, keepLogs=args.keep_logs) + cluster.setWalletMgr(wallet_mgr) + + test_ok = False + try: + TestHelper.printSystemInfo("BEGIN metamask trx signing test") + + Print("launching single-node cluster") + if not cluster.launch(pnodes=1, totalNodes=1, prodCount=1, dontBootstrap=False): + errorExit("cluster launch failed") + node = cluster.getNode(0) + + Print("creating wallet and test account") + wallet = wallet_mgr.create("mm_test") + + # Import the bootstrap accounts we need to sign as: + # sysio -> calls expandauth, issue/transfer of test tokens. + # defproducera -> creator of the test account. + defproducera = cluster.defproduceraAccount + sysio_account = cluster.sysioAccount + wallet_mgr.importKey(defproducera, wallet, ignoreDupKeyWarning=True) + wallet_mgr.importKey(sysio_account, wallet, ignoreDupKeyWarning=True) + + test = createAccountKeys(1)[0] + test.name = "mmtest11" + wallet_mgr.importKey(test, wallet, ignoreDupKeyWarning=True) + + Print(f"creating account {test.name}") + # Wire post-ROA: new accounts created by sysio with carl as nodeOwner. + trans = cluster.createAccountAndVerify( + test, cluster.sysioAccount, + nodeOwner=cluster.carlAccount, + stakedDeposit=0, buyRAM=200000, + ) + if not trans: + errorExit("createAccountAndVerify failed") + + Print("setting up Metamask-style EM key on the test account") + em_info, sim_priv_em = _create_em_key(args.simulate, args.sim_private_key) + Print(json.dumps(em_info, indent=2)) + + Print("calling sysio::expandauth to add the EM key to active permission") + expand_action_data = { + "account": test.name, + "permission": "active", + "keys": [{"key": em_info["wire_pub_em"], "weight": 1}], + "accounts": [], + } + success, _ = node.pushMessage("sysio", "expandauth", + json.dumps(expand_action_data), + opts="-p sysio@active") + if not success: + errorExit("expandauth failed") + + # The bootstrap issues the full max_supply to `sysio`, so just transfer + # from sysio to the test account. Issuing more would exceed max_supply. + seed_qty = "100.0000 SYS" + Print(f"transferring {seed_qty} from sysio to {test.name}") + seed_ok, _ = node.pushMessage("sysio.token", "transfer", + json.dumps({"from": "sysio", "to": test.name, + "quantity": seed_qty, "memo": "seed"}), + opts="-p sysio@active", silentErrors=False) + if not seed_ok: + errorExit(f"seed transfer of {seed_qty} from sysio to {test.name} failed") + + info = _run_clio(node, "get", "info") + chain_id_hex = info["chain_id"] + Print(f"chain_id : {chain_id_hex}") + + balance_initial = node.getCurrencyBalance("sysio.token", test.name) + Print(f"balance before any transfer: {balance_initial}") + + recipient = defproducera.name + + # --- Round 1: push via HTTP /v1/chain/send_transaction2 --- + Print("\n=== Round 1: Metamask-signed transfer via HTTP send_transaction2 ===") + packed_http = _build_unsigned_transfer(node, test.name, recipient, + args.transfer_amount, + "metamask http transfer", + args.trx_expiration) + sig_em_http, digest_http = _sign_unsigned_packed( + packed_http, chain_id_hex, args.simulate, sim_priv_em, + em_info["wire_pub_em"]) + Print(f"sig_digest: {digest_http}") + Print(f"signature : {sig_em_http}") + http_result = _push_via_http(node, packed_http, sig_em_http) + _assert_executed("HTTP path", http_result) + balance_after_http = node.getCurrencyBalance("sysio.token", test.name) + _assert_balance_decreased("HTTP path", balance_initial, balance_after_http, + args.transfer_amount) + + # --- Round 2: push via clio push transaction --signature --- + Print("\n=== Round 2: Metamask-signed transfer via clio push transaction ===") + packed_clio = _build_unsigned_transfer(node, test.name, recipient, + args.transfer_amount, + "metamask clio transfer", + args.trx_expiration) + sig_em_clio, digest_clio = _sign_unsigned_packed( + packed_clio, chain_id_hex, args.simulate, sim_priv_em, + em_info["wire_pub_em"]) + Print(f"sig_digest: {digest_clio}") + Print(f"signature : {sig_em_clio}") + clio_result = _push_via_clio(node, packed_clio, sig_em_clio) + _assert_executed("clio path", clio_result) + balance_after_clio = node.getCurrencyBalance("sysio.token", test.name) + _assert_balance_decreased("clio path", balance_after_http, balance_after_clio, + args.transfer_amount) + + Print("\nOK: Metamask-signed transactions executed via BOTH HTTP and clio paths") + test_ok = True + return 0 + finally: + TestHelper.shutdown( + cluster, wallet_mgr, + testSuccessful=test_ok, + dumpErrorDetails=args.dump_error_details, + ) + + +if __name__ == "__main__": + sys.exit(main())