Skip to content

Test: MetaMask external-signer transaction signing harness#341

Open
heifner wants to merge 6 commits into
masterfrom
feature/metamask-trx-signing-test
Open

Test: MetaMask external-signer transaction signing harness#341
heifner wants to merge 6 commits into
masterfrom
feature/metamask-trx-signing-test

Conversation

@heifner
Copy link
Copy Markdown
Contributor

@heifner heifner commented May 15, 2026

End-to-end test that a Wire transaction signed by an external EIP-191 personal_sign wallet (MetaMask) is accepted by nodeop.

Wire's C++ side already supports this: signature_shim::recover() applies the EIP-191 prefix to a sha256 trx digest and recovers an em::public_key, and the expandauth_sign_with_eth_key unit test proves the in-process flow. This adds the external-signer half: a real browser wallet signing a real trx that gets pushed to a running node, exercised both via HTTP /v1/chain/send_transaction2 and clio push transaction.

What's added (tests/metamask/)

  • metamask-sign.html -- static page; personal_sign over a 32-byte digest, local ecRecover, surfaces the Wire SIG_EM_ / PUB_EM_ forms. ethers.js pinned with a Subresource Integrity hash.
  • metamask_sim.py -- headless eth_account substitute, byte-identical to MetaMask for the same digest; drives unattended CI.
  • verify_em_sig.py -- offline recovery check using the same EIP-191 envelope nodeop applies.
  • em_sig_to_wire.py -- bidirectional converter. Wire's EM form is plain hex (SIG_EM_0x<130hex> / PUB_EM_<130hex>), not base58 + ripemd160 like K1/R1/BLS.
  • push_metamask_trx.py -- TestHarness orchestrator: stands up a single-node cluster, expandauth-registers the EM key, seeds via transfer from sysio, then signs and pushes two transfers (HTTP then clio), asserting the sender balance dropped by exactly the transfer amount.

Registered as the nonparallelizable ctest metamask_trx_signing_test, which runs --simulate so CI needs no browser.

Manual-path robustness

The real-MetaMask path has a human pasting values, so failures are caught offline with actionable messages instead of opaque chain rejections:

  • The pasted identity (address / uncompressed pubkey / PUB_EM_) is cross-checked for mutual consistency before expandauth.
  • Each pasted signature is recovered offline and matched against the registered PUB_EM_ before being sent; a stale or wrong-digest paste is rejected locally with recovered-vs-expected and re-prompted, distinct from a non-recoverable paste, with tooling/env errors fatal rather than an infinite loop.
  • --trx-expiration (default 600s, bounded to the chain max_transaction_lifetime) gives a human time to sign before the trx expires, since the expiration is baked into the signed bytes.
  • The eth_account interpreter for the sim / pre-flight subprocesses is auto-resolved (launcher if it has the deps, else $VIRTUAL_ENV, else the repo .venv), failing fast before cluster launch if none qualifies, so a bare python3 launch works.

heifner added 6 commits May 15, 2026 11:34
End-to-end test that a Wire transaction signed by an external EIP-191
personal_sign wallet (MetaMask) is accepted by nodeop, exercising both
the HTTP /v1/chain/send_transaction2 path and clio push transaction.

Components under tests/metamask/:
- metamask-sign.html: browser page, signs a 32-byte trx digest via
  personal_sign and surfaces the Wire SIG_EM_/PUB_EM_ forms.
- metamask_sim.py: headless eth_account substitute, byte-identical to
  MetaMask for the same digest; drives unattended CI.
- verify_em_sig.py: offline recovery check using the same EIP-191
  envelope nodeop applies.
- em_sig_to_wire.py: bidirectional converter (Wire EM form is hex, not
  base58+ripemd160 like K1/R1/BLS).
- push_metamask_trx.py: TestHarness orchestrator; expandauth-registers
  the EM key, builds an unsigned transfer, signs, pushes both ways.

Registered as the nonparallelizable ctest metamask_trx_signing_test,
which runs the simulator (--simulate) so CI needs no browser.

The manual (real-MetaMask) path is hardened against two issues the
simulator never hits: an offline pre-flight recovers the pasted
signature and checks it against the registered PUB_EM before pushing,
so a stale or wrong paste is rejected locally with recovered-vs-expected
instead of as an opaque unsatisfied_authorization; and --trx-expiration
(default 600s) builds the transfer with a window wide enough for human
browser signing, since clio's 30s default expires first.
First-class CLI support for Wire's external-signer key types, built on libfc's own crypto (the same code nodeop runs to validate these signatures), all offline (no node, no wallet):

create key
  --em   EM key (Ethereum-style secp256k1, PVT_EM_/PUB_EM_) -- the form a MetaMask / EIP-191 personal_sign wallet uses to sign Wire transactions
  --sol  Solana key (ed25519, PVT_ED_/PUB_ED_)
  --r1/--em/--sol are mutually exclusive via CLI11 ->excludes()

convert
  em_private_key  import a raw Ethereum hex secret (what MetaMask/eth tooling exports) or a PVT_EM_ string, print the Wire PVT_EM_/PUB_EM_ forms
  em_sign         sign a 32-byte sha256 digest with an EM key; dispatches to em::sign_sha256, which applies the EIP-191 personal_sign envelope, byte-identical to MetaMask and to what nodeop recovers; prints SIG_EM_
  em_recover      recover the PUB_EM_ from a SIG_EM_ over a digest

These mirror the existing convert k1_private_key/k1_public_key idiom and share two local parse helpers.

clio_em_key_test (offline, parallelizable) covers create-key round-trips and a frozen known-answer vector minted from eth_account, the Ethereum-ecosystem reference that is byte-identical to MetaMask personal_sign. The vector pins libfc's EIP-191 envelope, low-s normalization and recovery-id handling against an independent implementation with no pip/apt/browser dependency; any divergence fails hermetically. Provenance is documented in the test for deliberate, reviewed regeneration.
…k-trx-signing-test

# Conflicts:
#	tests/CMakeLists.txt
The metamask_trx_signing_test ctest failed in CI: it required the Python eth_account stack (eth_account/eth_keys/eth_utils), which the CI test images do not provide -- they have python3-numpy via apt for the performance tests, but the eth stack is not in apt for Ubuntu noble and was never pip-installed. The harness now drives clio's own EM tooling, so its only dependency is the clio binary the build already produces: no pip, no apt, no browser, no .venv.

  - --simulate keygen: clio create key --em (or convert em_private_key for a fixed --sim-private-key, which now accepts a PVT_EM_ or a raw 0x Ethereum secret)
  - --simulate sign: clio convert em_sign over the same sha256 sig_digest
  - manual real-MetaMask pre-flight: clio convert em_recover, compared to the registered PUB_EM_ (replaces verify_em_sig.py)

clio's EM path is libfc's own crypto -- the exact code nodeop runs to validate these signatures. Its byte-for-byte equivalence to the Ethereum-ecosystem reference (eth_account / MetaMask personal_sign) is pinned hermetically by the separate clio_em_key_test ctest, so the cross-implementation assurance the eth_account simulator used to provide is preserved without the runtime dependency.

Removes metamask_sim.py, verify_em_sig.py, em_sig_to_wire.py and the _dep_python interpreter-resolution machinery. The build/push/balance-assertion flow and both push paths (HTTP send_transaction2, clio push transaction) are unchanged. README updated; metamask-sign.html (real MetaMask manual path) kept.

Manual pre-flight now hints that a persistent recover-mismatch may mean the pasted PUB_EM_ is not this wallet's key; drop a dead sys.path entry; README documents the --private-key argv exposure and uses a copy-pasteable digest example.
create key: --k1/--r1/--em/--sol are now mutually exclusive (CLI11 ->excludes()), so any contradictory combination is a non-zero parse error instead of being silently resolved by precedence.

em_private_key/em_sign --private-key help: note the secret is visible in ps and shell history; omit the option to enter it at the interactive prompt instead.

clio_em_key_test: add a digest-binding negative (the reference signature recovered against a different well-formed digest must yield a different key -- the property the external-signer pre-flight depends on), a 64-char non-hex digest rejection, and the new --k1 exclusion pairs.

elliptic_em.hpp: fix the from_native_string doc comment, which described a public key; it takes the raw Ethereum private-key hex.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant