Skip to content

feat: Ethereum (secp256k1) accounts + pallet-revive eth_transact submission - #286

Open
peetzweg wants to merge 11 commits into
mainfrom
feature-eth_transact
Open

feat: Ethereum (secp256k1) accounts + pallet-revive eth_transact submission#286
peetzweg wants to merge 11 commits into
mainfrom
feature-eth_transact

Conversation

@peetzweg

@peetzweg peetzweg commented Aug 4, 2026

Copy link
Copy Markdown
Member

Closes #284

Adds Ethereum (secp256k1) accounts to the keystore and lets them submit contract calls through pallet-revive's unsigned Revive.eth_transact extrinsic — over the same WebSocket connection dot already holds.

Why: Contract admin operations on revive chains (DotNS being the concrete case) must execute as an Ethereum-key identityowner()/role holders are addresses derived from secp256k1 keys, which a substrate signer's mapped H160 can by construction never equal. Until now that meant running a dockerized eth-rpc sidecar and hand-writing a viem script per operation.

How: --scheme ethereum stores a secp256k1 key whose stored publicKey is the deterministic revive fallback AccountId32 (H160 ‖ 0xEE×12) — the one deliberate trick that makes every existing address-resolution path (balances, --from, list/inspect) work unchanged. When such an account signs, tx.Revive.call switches transport: price via ReviveApi.eth_transact dry-run → sign an EIP-1559 envelope (ox, lazy-loaded) → submit wrapped in unsigned Revive.eth_transact. The call executes on-chain with the eth address as msg.sender.

Accounts

# Import a key / generate a fresh one / keep it off disk
dot account add dotns-admin --scheme ethereum --secret 0x59c6…690d
dot account create hot-wallet --scheme ethereum
dot account add ci-admin --scheme ethereum --env DOTNS_ADMIN_KEY

# Account Imported
#
#   Name:    dotns-admin
#   Scheme:  ethereum (secp256k1)
#   Address: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
#   SS58:    5EcLp2gKW3p2fTL3h4mTeqr9yV4eNcbQCkygCgDqwd3NtvAR (fallback account)

The identity is the EIP-55 H160; the SS58 is the fallback account — fund it for fees, read nonce/balance through it like any account. dot account inspect dotns-admin --show-secret reveals the key. Substrate signing paths (dot sign, ordinary extrinsics) error with guidance:

Error: Account "dotns-admin" is an Ethereum (secp256k1) account. It cannot sign
substrate extrinsics — it acts through Revive.eth_transact on chains with
pallet-revive (e.g. dot <chain>.tx.Revive.call … --from dotns-admin).

One mnemonic, two identities — alice-eth

Every mnemonic-backed account also has a derived ethereum identity: the MetaMask-compatible BIP44 key (m/44'/60'/0'/0/0) of the same phrase, selected with the -eth name suffix. Dev accounts use their position as the index, which reproduces the well-known revive/Moonbeam dev accounts — alice-eth is Alith, bob-eth is Baltathar, and so on.

dot account inspect alice
#   SS58:        5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY
#   H160:        0x9621DDe636dE098B43Efb0fA9b61fAcFE328F99D          ← mapped (substrate-signed calls act as this)
#   Ethereum:    0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac (--from alice-eth, m/44'/60'/0'/0/0)

dot account inspect alice-eth --show-secret
#   Kind:        signer (ethereum, derived from alice)
#   H160:        0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac          ← Alith
#   SS58:        5HYRCKHYJN9z5xUtfFkyMj4JUhsAwWyvuU8vKB1FcnYTf9ZQ   ← fund this fallback for fees
#   Derivation:  m/44'/60'/0'/0/0
#   Private Key: 0x5fb92d6e98884f76de468fa3f6278f8807c48bebc13595d45af5bdc4da702133

One phrase, two on-chain identities: a substrate-signed Revive.call from alice executes as her mapped keccak-H160, while --from alice-eth executes as the BIP44 address — contracts see different msg.sender. Resolution is deliberately unmagical: a real stored account named <name>-eth always wins, and non-mnemonic secrets (hex seeds, raw sr25519 keys, watch-only) error with a hint to import a key directly. map_account relates to receiving, not signing (docs explain; chains with Revive.AutoMap = true, like previewnet, map automatically).

Transactions

# Cast-style: calldata from a human ABI signature (the #284 headline use case)
dot preview-asset-hub.tx.Revive.call 0xf209…899B 'whiteListAddress(address,bool)' 0xAbC… true \
  --from dotns-admin

# Raw calldata
dot preview-asset-hub.tx.Revive.call 0x03e9…6eB1 0x42cbb15c --from dotns-admin

# Bare value transfer (wei, 18 EVM decimals)
dot preview-asset-hub.tx.Revive.call 0x7099…79C8 --value 1000000000000000000 --from dotns-admin

# Dry-run: gas, storage deposit, max fee, decoded return/revert — nothing submitted
dot preview-asset-hub.tx.Revive.call 0xf209…899B 'available(string)' myname123 \
  --from dotns-admin --dry-run
#   Chain:  preview-asset-hub (eth chain id 420420417)
#   From:   dotns-admin (0x3243631Cb1EADF0FbA31BFA8e739585c6953ba73)
#   To:     0xf209507ab5e6Cf1245aeC020E94c7E213020899B
#   Method: available(string)
#   Data:   0xaeb8ce9b0000…
#   Nonce:  2
#   Gas:    10933 @ 1000000000000 wei
#   Max fee: 10933000000000000 wei
#   Return: 0x0000…0001

Signature args: uint*/int* as integers, bool as true/false, address/bytes* as 0x-hex, string verbatim, arrays/tuples as JSON. Chain id, nonce, and gas come from the chain (--nonce overrides). Failed dry-runs decode Solidity Error(string)/Panic(uint256):

Error: Dry-run failed: Contract reverted: boom

Guards: --tip/--mortality/--asset/--ext are rejected (not applicable to eth transactions); any target other than Revive.call/Revive.instantiate_with_code and raw call hex error with pointed guidance. DOT_DRY_RUN/--dry-run/--no-dry-run, --wait, --json NDJSON output, and explorer links behave exactly like substrate txs.

Deploying contracts

Revive.instantiate_with_code deploys through the same identity and transport. At the ethereum layer a deployment is not a call but a different transaction kindto omitted from the envelope — which pallet-revive branches on to reach eth_instantiate_with_code, so to becomes nullable through the envelope, the GenericTransaction, and the dry-run pricing. The deployer is the eth address, so the constructor sees it as msg.sender and owner() lands on the key in the keystore — unreachable via a substrate-signed instantiate, which executes as the signer's mapped H160.

solc --optimize --bin -o out --overwrite Greeter.sol   # revive runs EVM bytecode natively (code_type: Evm)

dot preview-asset-hub.tx.Revive.instantiate_with_code @out/Greeter.bin \
  'constructor(string)' 'hello previewnet' --from alice-eth
#   Deploy: 1777 bytes of init code (CREATE)
#   Contract: 0x3ed62137c5DB927cb137c26455969116BF0c23Cb
#   Events:
#     Revive.Instantiated { deployer: 0xf24ff3a9…, contract: 0x3ed62137… }

Bytecode is inline hex or @<path> — the file may omit the 0x and carry a trailing newline, so solc --bin and foundry artifacts work unmodified. Constructor arguments need a literal 'constructor(types)' and are ABI-encoded onto the init code with no selector; a function signature is rejected rather than silently prepending four bytes and producing an undeployable blob. The address is read from Revive.Instantiated, and --dry-run reports the one predicted from sender+nonce plus the size of the runtime code the constructor would return.

eth_transact now goes out as a bare extrinsic

Worth reviewer attention, because it was a live bug rather than a cleanup. eth_transact was wrapped in a general transaction carrying a default value for every transaction extension in the chain's metadata. That data was always inert — pallet-revive intercepts the extrinsic in Checkable::check() and substitutes its own eth extension, discarding whatever was attached.

Inert until the extension set moves. asset-hub-next spec 2000035 replaced AuthorizeValueTransfer Option<[u8; 64]> with a UnitTransactionExtension placeholder, and the runtime then panics in validate_transaction — the entire ethereum path, contract calls included, failed with a wasm trap on submit. spec 2000033 tolerated it, which is why the original verification passed. The tell is that dry-runs kept succeeding throughout: they are runtime-API reads and never enter validate_transaction.

A bare extrinsic (0x05 | call, no extensions at all) is the correct format regardless — eth_transact derives its origin from the secp256k1 signature inside its own payload, not from extension data.

Verified live on previewnet

Both identity kinds executed real transactions: a stored-key account in block #183766 and the derived --from alice-eth (Alith) in block #239273 — in each case with fees withdrawn from the identity's fallback account, the gas estimate within 5% of actual, and the wei→planck ratio (10⁸ on a 10-decimals chain) confirmed by the fee events. Revert path, guards, and value-transfer pricing (incl. storage deposit) exercised against the live chain too.

Deployment and the bare-extrinsic switch were then verified on both runtimes — preview-asset-hub (spec 2000035, the one that panics on a general transaction) and paseo-asset-hub-next (2000033). On each: deploy a constructor-arg contract, read the value back through greeting(), and land an owner-only write, with owner() returning the signing identity's H160. The dry-run's predicted CREATE address matched Revive.Instantiated exactly.

Two implementation judgment calls worth reviewer attention: the GenericTransaction runtime-API argument is built as JSON and routed through the CLI's own parseTypedArg (hand-built papi structs fail the runtime-entry compatibility check — the metadata field is literally r#type), and all ox modules load through a non-literal dynamic import because Bun eagerly prefetches literal import() specifiers, which regressed every CLI spawn by ~250ms.

70+ new tests (including a byte-for-byte regression vector of the previewnet-executed payload, a buildBareTx encoding check, and the previewnet-assigned contract address pinned as a vector); README, docs site, the bundled skill, and the scripting reference are updated. The contract registry / dot <chain>.query.<contract>.<method> porcelain stays in #181, building on this transport.

Add --scheme ethereum to account add/create (plus --env support): stores a
secp256k1 private key whose identity is its EIP-55 H160. The stored publicKey
is the deterministic revive fallback AccountId32 (H160 || 0xEE*12), so every
existing address-resolution path works unchanged. list/inspect render the
H160 as the address with scheme/ss58 attributes; --show-secret reveals the
raw key. Ethereum accounts cannot sign substrate extrinsics — sr25519 signer
paths error with guidance.

Adds ox for secp256k1/EIP-1559/ABI primitives. All ox modules are lazy-loaded
through a non-literal dynamic import: Bun eagerly prefetches literal import()
specifiers, and ox/Secp256k1 + ox/AbiFunction each cost >100ms — eager
loading regressed every CLI spawn measurably.
…ransact

When --from resolves to an ethereum-scheme account, dot <chain>.tx.Revive.call
takes eth-style args — <dest> [<0xcalldata> | 'sig(types)' args...] and
--value <wei> — prices the call via a ReviveApi.eth_transact dry-run (gas,
storage deposit, decoded Error(string)/Panic reverts), signs an EIP-1559
transaction with the account's key, and submits it wrapped in the unsigned
Revive.eth_transact extrinsic over the existing ws connection. The call
executes with the eth address as msg.sender — no eth-rpc sidecar needed.

Chain id comes from const Revive.ChainId, the nonce from the fallback
account (--nonce overrides), calldata cast-style from a human ABI signature.
--tip/--mortality/--asset/--ext are rejected; non-Revive.call targets error
with guidance. The GenericTransaction argument is built as JSON and routed
through the CLI's own parseTypedArg — hand-built papi structs fail the
runtime-entry compatibility check (the metadata field is literally r#type).

Verified end-to-end on previewnet asset-hub (chain id 420420417): executed
in block #183766 with fees withdrawn from the eth account's fallback.
…skill

README and docs/content/_index.md gain matching sections (accounts +
transactions, features bullet, --value in the transaction-options table).
The bundled skill documents the feature for agents, adds --scheme/--value
to the key-flags table and two common-error entries, and the scripting
reference gains a check-then-act contract-admin pattern validated against
previewnet.
@socket-security

socket-security Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedox@​1.6.01001009997100

View full report

@codecov-commenter

codecov-commenter commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 56.84358% with 309 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.97%. Comparing base (0eaf4f6) to head (bb96ac2).
⚠️ Report is 32 commits behind head on main.

Files with missing lines Patch % Lines
src/commands/tx-eth.ts 47.93% 214 Missing ⚠️
src/core/accounts.ts 8.97% 71 Missing ⚠️
src/core/ethereum.ts 93.62% 13 Missing ⚠️
src/commands/tx.ts 52.38% 10 Missing ⚠️
src/config/accounts-types.ts 50.00% 1 Missing ⚠️
Additional details and impacted files
Files with missing lines Coverage Δ
dot-cli/SKILL.md 100.00% <ø> (ø)
dot-cli/references/scripting-patterns.md 100.00% <ø> (ø)
src/config/accounts-types.ts 50.00% <50.00%> (ø)
src/commands/tx.ts 45.10% <52.38%> (+0.06%) ⬆️
src/core/ethereum.ts 93.62% <93.62%> (ø)
src/core/accounts.ts 73.31% <8.97%> (-21.56%) ⬇️
src/commands/tx-eth.ts 47.93% <47.93%> (ø)

... and 5 files with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 8f213ab...bb96ac2. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Every mnemonic-backed account now has an ethereum identity selected with
--from <name>-eth: the MetaMask-compatible BIP44 key (m/44'/60'/0'/0/0) of
the same phrase. Dev accounts use their position as the index, which on the
substrate dev phrase reproduces the well-known revive/Moonbeam dev accounts
(alice-eth = Alith, bob-eth = Baltathar, ...). One phrase, two on-chain
identities: substrate-signed Revive.call acts as the mapped keccak-H160,
--from <name>-eth acts as the BIP44 address.

resolveEthereumIdentity centralizes resolution: a stored account of the
exact name always wins (no magic on real names); hex-seed/expanded/watch-only
bases error with an import hint since BIP44 needs the phrase. account inspect
resolves the -eth form directly (kind, BIP44 path, --show-secret) and shows
the derived identity as an Ethereum line on the base account. Docs explain
the two-identities model and how Revive.map_account relates (receiving, not
signing; AutoMap chains map automatically).

Verified live on previewnet: --from alice-eth executed as Alith in block
239273 with fees paid by its fallback account.
Subprocess runCli tests verify behavior but earn no coverage
instrumentation, which left tx-eth.ts at ~16% lines. Cover the pure
helpers (toHexData, buildGenericTransaction, formatEthTransactError) and
the pre-connect guards in-process, and add a live previewnet dry-run
(value-0, needs no funds, retried against network flakiness) that
exercises the full pricing flow as the derived alice-eth identity.

The live test runs as a subprocess deliberately: load-meta.test.ts
installs process-global mock.module replacements for core/client.ts, so
an in-process variant silently receives the mocked revive-less fixture
chain in full-suite runs. The submit/report flow remains covered by real
previewnet submissions only — the case codecov.yml already documents.
…IVE_TESTS

Review follow-ups: --value was silently ignored when --from resolved to a
substrate signer — now a pointed CliError like the other inapplicable-flag
guards. The previewnet live dry-run test made bare bun test nondeterministic;
it now skips unless DOT_LIVE_TESTS=1. Also hoist the TxEvent type import in
tx-eth.ts to match tx.ts.
`account export` now emits `scheme: "ethereum"` for secp256k1 accounts: the
secret is a 32-byte hex string either way, so without the marker a re-import
resolved it as an sr25519 seed and the account came back as a different
identity. `account import` honors the field, re-deriving a blank publicKey
with secp256k1 and rejecting a secret that is not an ethereum private key.

`account derive` refuses an ethereum source outright — substrate HD
derivation on a secp256k1 key would silently mint an unrelated sr25519
account from the ethereum private key rather than erroring.
eth_transact was wrapped in a general transaction carrying a default value
for every transaction extension in the chain's metadata. That data was
always inert: pallet-revive intercepts the extrinsic in `Checkable::check()`
and substitutes its own eth extension, discarding whatever was attached.

Inert until the extension set moves. asset-hub-next spec 2000035 replaced
`AuthorizeValueTransfer Option<[u8; 64]>` with a `UnitTransactionExtension`
placeholder, and the runtime then panics in validate_transaction — the whole
ethereum path, contract calls included, failed with a wasm trap on submit
while dry-runs (runtime-API reads, which never enter validate_transaction)
kept succeeding. spec 2000033 tolerated it, which is why this was not caught
when the feature was verified.

A bare extrinsic (`0x05 | call`, no extensions at all) is the correct format
regardless: eth_transact derives its origin from the secp256k1 signature
inside its own payload, not from extension data. Verified on both runtimes.

The skill's "wasm trap on submit" entry claimed this symptom is almost
always stale metadata; it now covers the case where the metadata is current
and names the dry-run-succeeds/submit-traps signature.
`dot <chain>.tx.Revive.instantiate_with_code <0xcode|@file>
['constructor(types)' args...]` deploys through the same identity and
transport as Revive.call. At the ethereum layer a deployment is not a call
but a distinct transaction kind — `to` omitted from the EIP-1559 envelope —
which pallet-revive branches on to route the payload to
eth_instantiate_with_code. So `to` becomes nullable through the envelope,
the GenericTransaction, and the dry-run pricing.

The deployer is the eth address, so the constructor sees it as msg.sender
and `owner()` lands on the key in the keystore — the whole point of the
feature, and unreachable via a substrate-signed instantiate, which would
execute as the signer's mapped H160 instead.

Bytecode is inline hex or `@<path>`; the file may omit the 0x prefix and
carry trailing whitespace, so `solc --bin` and foundry artifacts work
unmodified. Constructor arguments require a literal 'constructor(types)'
signature and are ABI-encoded onto the init code with no selector — a
function signature is rejected rather than silently prepending four bytes
and producing an undeployable blob.

The deployed address is read from the Revive.Instantiated event. A dry-run
cannot observe that event, so it reports the address predicted from sender
and nonce, and prints the size of the runtime code the constructor returns
rather than kilobytes of hex.

Verified on preview-asset-hub (spec 2000035) and paseo-asset-hub-next
(2000033): deploy, read back through greeting(), and an owner-only write.
The predicted address matched Revive.Instantiated exactly.
Adds a "Deploying a contract" section to README and the docs site, a deploy
block plus flag/error updates to the skill, and a deploy-then-configure-as-
owner pattern to the scripting reference. The scripting pattern uses --wait
finalized deliberately: reads and dry-runs resolve against the finalized
block, so a contract that exists only in a best block is invisible to the
next command.

The changeset gains the deployment surface and the bare-extrinsic transport
change.
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.

Ethereum (secp256k1) accounts + pallet-revive eth_transact submission

2 participants