Skip to content

feat(wallet-cli): add the mm wallet send command#9636

Open
sirtimid wants to merge 8 commits into
mainfrom
sirtimid/wallet-cli-send-transaction
Open

feat(wallet-cli): add the mm wallet send command#9636
sirtimid wants to merge 8 commits into
mainfrom
sirtimid/wallet-cli-send-transaction

Conversation

@sirtimid

@sirtimid sirtimid commented Jul 23, 2026

Copy link
Copy Markdown
Member

Explanation

Adds the user-facing mm wallet send command that sends a transaction end-to-end through the daemon-hosted TransactionController, closing #9513. This is the CLI surface on top of the daemon's transaction capability (#9512 / #9612): it collects transaction parameters, dispatches them to the daemon, waits for the broadcast, and reports the resulting transaction hash.

Send a transaction. --value is in ether; select the network with --network-client-id or --chain-id; the sender defaults to the selected account. The command previews the resolved plan and asks for confirmation before broadcasting, then prints the transaction hash:

Because the daemon auto-approves the confirmation prompt — or your explicit --yes — is the only boundary before funds move; use --dry-run first if unsure. Gas is estimated automatically unless overridden with --gas / --max-fee-per-gas / --max-priority-fee-per-gas / --gas-price (each a 0x-prefixed hex quantity).

Dedicated sendTransaction RPC handler

TransactionController:addTransaction returns a Result shaped like { transactionMeta, result }, where result is a Promise<hash> that resolves once the transaction is signed and broadcast. That promise is not JSON-serializable, so it cannot travel back over the daemon's generic call dispatch. The daemon therefore exposes a dedicated sendTransaction handler (src/daemon/send-transaction.ts) that, server-side:

  1. resolves the network client — from networkClientId, or from chainId via NetworkController:findNetworkClientIdByChainId;
  2. resolves the sender — the provided --from, or the selected account;
  3. calls addTransaction(txParams, { networkClientId, origin: 'metamask', isInternal: true }) (internal, so it skips origin/permitted-account validation and is auto-approved by the headless daemon);
  4. awaits the broadcast and re-reads the live record so the returned status reflects the post-broadcast state (submitted), not the unapproved creation snapshot;
  5. returns a serializable { transactionHash, transactionId, status }.

Params are validated with superstruct at the daemon boundary (exactly one of networkClientId / chainId; 0x address and hex quantities).

The mm wallet send command

A thin client over that handler (src/commands/wallet/send.ts):

➜  wallet-cli: yarn mm wallet send --help
  Send a transaction through the daemon-hosted TransactionController. Estimates gas automatically
  unless overridden, signs, broadcasts, and prints the resulting transaction hash. The daemon
  auto-approves, so the confirmation boundary is this command.

USAGE
  $ mm wallet send --to <value> [--value <value>] [--from <value>] [--data <value>]
    [--network-client-id <value>] [--chain-id <value>] [--gas <value>] [--max-fee-per-gas <value>]
    [--max-priority-fee-per-gas <value>] [--gas-price <value>] [--dry-run] [-y] [-t <value>]

FLAGS
  -t, --timeout=<value>                   Response timeout in milliseconds
  -y, --yes                               Skip the confirmation prompt and broadcast immediately.
      --chain-id=<value>                  Chain ID (0x-prefixed hex) to resolve to a network client.
                                          Provide this or --network-client-id, not both.
      --data=<value>                      Calldata as a 0x-prefixed hex string (for contract calls)
      --dry-run                           Resolve the network client and sender and validate params,
                                          but do not broadcast.
      --from=<value>                      Sender address (0x-prefixed). Defaults to the selected
                                          account.
      --gas=<value>                       Gas limit override, as a 0x-prefixed hex quantity
      --gas-price=<value>                 Legacy gasPrice override, as a 0x-prefixed hex wei quantity
      --max-fee-per-gas=<value>           maxFeePerGas override, as a 0x-prefixed hex wei quantity
      --max-priority-fee-per-gas=<value>  maxPriorityFeePerGas override, as a 0x-prefixed hex wei
                                          quantity
      --network-client-id=<value>         Network client to send on. Provide this or --chain-id, not
                                          both.
      --to=<value>                        (required) Recipient address (0x-prefixed)
      --value=<value>                     [default: 0] Amount to send, in ether (e.g. 0.01). Defaults
                                          to 0.

EXAMPLES
  $ mm wallet send --to 0xRecipient --value 0.01 --chain-id 0x1

  $ mm wallet send --to 0xRecipient --value 0.01 --network-client-id mainnet --yes

  $ mm wallet send --to 0xContract --data 0xabcdef --value 0 --chain-id 0x1 --dry-run

Testing

  • Unit tests for the handler (network/sender resolution, internal submit, dry-run, broadcast + live status, param validation) and the command (arg parsing, preview/confirm/abort, --yes, --dry-run, error surfaces); 100% coverage maintained.
  • Real-chain e2e (tests/wallet-send.e2e.test.ts): boots a local anvil node, adds it as a custom network, and drives the built mm CLI to sign, broadcast, and mine a real transaction (asserts receipt status: 0x1 and a recipient balance increase). It is skip-if-absent; CI installs anvil for it only when packages/wallet-cli/ changed. See packages/wallet-cli/tests/README.md.
  • build, package test, test:e2e, yarn lint, changelog:validate pass.

References

Note

Stacked on sirtimid/wallet-cli-daemon-transaction-capable (#9612); this PR is based on that branch so the diff is scoped. Once #9612 merges, this will be rebased onto main and retargeted.

Checklist

  • Tests cover the handler and command (unit) plus a real-chain broadcast (e2e); 100% coverage maintained.
  • build, test, test:e2e, yarn lint:fix, yarn lint, changelog:validate pass.
  • Confirmation prompt (--yes to skip) and --dry-run gate a real, irreversible send.

🤖 Generated with Claude Code


Note

High Risk
Introduces an irreversible fund-moving send path; the headless daemon still auto-approves, so the CLI confirmation (or --yes) is the main per-tx gate—timeouts and duplicate-send guidance are safety-critical.

Overview
Adds mm wallet send, a CLI path to sign and broadcast through the daemon’s TransactionController. Ether --value is converted to wei; the network is chosen with --network-client-id or --chain-id; interactive runs dry-run preview → confirmSend → broadcast, with --yes, --dry-run, optional gas overrides, and a longer default timeout on broadcast (with guidance if the client times out while a send may still be in flight).

Because addTransaction’s result promise is not JSON-serializable, the daemon gains a dedicated sendTransaction RPC (runSendTransaction) that resolves network/sender, supports dryRun, submits as internal/auto-approved, awaits the hash server-side, and returns { transactionHash, transactionId, status }.

CI/docs/tooling: wallet-cli e2e installs anvil via @metamask/foundryup, sets MM_E2E_REQUIRE_ANVIL in CI, adds wallet-send.e2e.test.ts (local anvil, real broadcast), and ignores .metamask/ / knip anvil binary.

Reviewed by Cursor Bugbot for commit 208b060. Bugbot is set up for automated code reviews on this repo. Configure here.

sirtimid added a commit that referenced this pull request Jul 23, 2026
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sirtimid added a commit that referenced this pull request Jul 23, 2026
Address review findings on #9636:

- Broadcast now uses a 120s default timeout (was the 30s used for cheap
  RPCs; `--timeout` still overrides) and, on a read-timeout, warns the
  transaction may already be broadcasting and not to re-run — closing the
  double-send door that the ECONNRESET no-retry already guards.
- Report `submitted` (never the stale `unapproved` creation snapshot) when
  the live record is gone after broadcast; the fallback test now uses a
  realistic snapshot and asserts the record is re-read by id.
- Preview shows `data` and gas overrides, and the confirmed broadcast pins
  the sender/network the preview resolved instead of re-resolving them.
- Validate daemon responses against shared structs; a malformed broadcast
  payload now fails loudly instead of printing `Hash: (unknown)`.
- Tighten `networkClientId` to a non-empty string at the daemon boundary.
- e2e hard-fails when `MM_E2E_REQUIRE_ANVIL` is set but anvil is absent; CI
  sets it whenever it installs anvil, so a broken install can no longer pass
  as a green no-op.
- Add the `../foundryup` tsconfig project references and the root README
  dependency-graph edge required by the new workspace devDependency.
- Comment/doc accuracy fixes (toWei, isValidHexAddress, e2e fetch reason,
  gas attribution) and trim repeated rationale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Base automatically changed from sirtimid/wallet-cli-daemon-transaction-capable to main July 24, 2026 11:04
@sirtimid
sirtimid force-pushed the sirtimid/wallet-cli-send-transaction branch from 14da5e6 to 927bcba Compare July 24, 2026 12:11
sirtimid added a commit that referenced this pull request Jul 24, 2026
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sirtimid added a commit that referenced this pull request Jul 24, 2026
Address review findings on #9636:

- Broadcast now uses a 120s default timeout (was the 30s used for cheap
  RPCs; `--timeout` still overrides) and, on a read-timeout, warns the
  transaction may already be broadcasting and not to re-run — closing the
  double-send door that the ECONNRESET no-retry already guards.
- Report `submitted` (never the stale `unapproved` creation snapshot) when
  the live record is gone after broadcast; the fallback test now uses a
  realistic snapshot and asserts the record is re-read by id.
- Preview shows `data` and gas overrides, and the confirmed broadcast pins
  the sender/network the preview resolved instead of re-resolving them.
- Validate daemon responses against shared structs; a malformed broadcast
  payload now fails loudly instead of printing `Hash: (unknown)`.
- Tighten `networkClientId` to a non-empty string at the daemon boundary.
- e2e hard-fails when `MM_E2E_REQUIRE_ANVIL` is set but anvil is absent; CI
  sets it whenever it installs anvil, so a broken install can no longer pass
  as a green no-op.
- Add the `../foundryup` tsconfig project references and the root README
  dependency-graph edge required by the new workspace devDependency.
- Comment/doc accuracy fixes (toWei, isValidHexAddress, e2e fetch reason,
  gas attribution) and trim repeated rationale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sirtimid added a commit that referenced this pull request Jul 24, 2026
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sirtimid added a commit that referenced this pull request Jul 24, 2026
Address review findings on #9636:

- Broadcast now uses a 120s default timeout (was the 30s used for cheap
  RPCs; `--timeout` still overrides) and, on a read-timeout, warns the
  transaction may already be broadcasting and not to re-run — closing the
  double-send door that the ECONNRESET no-retry already guards.
- Report `submitted` (never the stale `unapproved` creation snapshot) when
  the live record is gone after broadcast; the fallback test now uses a
  realistic snapshot and asserts the record is re-read by id.
- Preview shows `data` and gas overrides, and the confirmed broadcast pins
  the sender/network the preview resolved instead of re-resolving them.
- Validate daemon responses against shared structs; a malformed broadcast
  payload now fails loudly instead of printing `Hash: (unknown)`.
- Tighten `networkClientId` to a non-empty string at the daemon boundary.
- e2e hard-fails when `MM_E2E_REQUIRE_ANVIL` is set but anvil is absent; CI
  sets it whenever it installs anvil, so a broken install can no longer pass
  as a green no-op.
- Add the `../foundryup` tsconfig project references and the root README
  dependency-graph edge required by the new workspace devDependency.
- Comment/doc accuracy fixes (toWei, isValidHexAddress, e2e fetch reason,
  gas attribution) and trim repeated rationale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sirtimid
sirtimid force-pushed the sirtimid/wallet-cli-send-transaction branch from 927bcba to fcffb1e Compare July 24, 2026 13:20
@sirtimid
sirtimid marked this pull request as ready for review July 24, 2026 13:20
@sirtimid
sirtimid requested review from a team as code owners July 24, 2026 13:20
@sirtimid
sirtimid temporarily deployed to default-branch July 24, 2026 13:20 — with GitHub Actions Inactive
sirtimid and others added 6 commits July 24, 2026 22:03
Add the user-facing `mm wallet send` command and a dedicated daemon
`sendTransaction` RPC handler for sending a transaction through the
daemon-hosted `TransactionController`.

`addTransaction` returns `{ transactionMeta, result }` where `result` is a
`Promise<hash>` that cannot travel back over the generic `call` dispatch, so
the daemon exposes a dedicated `sendTransaction` handler that resolves the
network client (from `--network-client-id` or `--chain-id`) and sender
(defaulting to the selected account), submits the transaction as internal
(auto-approved by the daemon), awaits the broadcast server-side, and returns a
serializable `{ transactionHash, transactionId, status }`.

The command converts the ether `--value` to wei via `@metamask/utils`' shared
`toWei`, previews the resolved plan, and broadcasts after confirmation. `--yes`
skips the prompt; `--dry-run` resolves and validates without broadcasting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…status

Add a true end-to-end test for `mm wallet send`: it boots a local anvil node
(seeded with the same mnemonic the wallet imports, so the account is funded),
points the daemon at it via a custom `NetworkController:addNetwork`, sends a
transaction through the built CLI, and asserts it was signed, broadcast, and
mined (receipt `status: 0x1`, recipient balance increased).

The suite is skip-if-absent: when the `anvil` binary is not found it is skipped
rather than failed. In CI, anvil is installed (via `@metamask/foundryup`) only
when files under `packages/wallet-cli/` changed, so unrelated PRs don't pay the
Foundry download and the test skips itself.

Also report the post-broadcast transaction status by re-reading the live record
via `TransactionController:getTransactions`: the `addTransaction` result's
`transactionMeta` is the creation snapshot, whose status is still `unapproved`,
so the command previously printed a misleading status after a successful send.

- Scope the nock net-connect allowlist to exactly the anvil host:port, restored
  in `afterEach`, so the shared "no real network" safety net stays in place.
- Document both e2e suites in `packages/wallet-cli/tests/README.md`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review findings on #9636:

- Broadcast now uses a 120s default timeout (was the 30s used for cheap
  RPCs; `--timeout` still overrides) and, on a read-timeout, warns the
  transaction may already be broadcasting and not to re-run — closing the
  double-send door that the ECONNRESET no-retry already guards.
- Report `submitted` (never the stale `unapproved` creation snapshot) when
  the live record is gone after broadcast; the fallback test now uses a
  realistic snapshot and asserts the record is re-read by id.
- Preview shows `data` and gas overrides, and the confirmed broadcast pins
  the sender/network the preview resolved instead of re-resolving them.
- Validate daemon responses against shared structs; a malformed broadcast
  payload now fails loudly instead of printing `Hash: (unknown)`.
- Tighten `networkClientId` to a non-empty string at the daemon boundary.
- e2e hard-fails when `MM_E2E_REQUIRE_ANVIL` is set but anvil is absent; CI
  sets it whenever it installs anvil, so a broken install can no longer pass
  as a green no-op.
- Add the `../foundryup` tsconfig project references and the root README
  dependency-graph edge required by the new workspace devDependency.
- Comment/doc accuracy fixes (toWei, isValidHexAddress, e2e fetch reason,
  gas attribution) and trim repeated rationale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `pull-requests: read` permission on `test-wallet-cli-e2e` exceeded what
`main.yml` grants the `lint-build-test` reusable-workflow call, which failed
`Main` at startup (0s, "workflow file issue") so none of the lint/build/test
jobs ran.

Install `anvil` unconditionally and always set `MM_E2E_REQUIRE_ANVIL=true`
instead of detecting wallet-cli file changes via the PR files API (the reason
the extra permission was requested). This job already only runs when
`@metamask/wallet-cli` is in the affected package set, so unrelated PRs skip it
entirely; the only behavior change is that the real-chain e2e now also runs when
wallet-cli is affected as a dependant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sirtimid
sirtimid force-pushed the sirtimid/wallet-cli-send-transaction branch from b92b41a to b41b710 Compare July 24, 2026 19:12

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b41b710. Configure here.

Comment thread packages/wallet-cli/tests/wallet-send.e2e.test.ts Outdated
sirtimid and others added 2 commits July 24, 2026 22:17
… PATH

`resolveAnvilPath` returned the bare command `'anvil'` as its last-resort
fallback without confirming it was actually on PATH, and `anvilSpawnable`
special-cased that string as always spawnable. On a machine with no
`node_modules/.bin/anvil` and no `anvil` on PATH, the suite would try to
boot anvil in `beforeEach` and fail (spawn ENOENT + readiness timeout)
instead of skipping as documented.

Probe the PATH fallback with `spawnSync('anvil', ['--version'])` and
return `undefined` when it is not runnable, so `anvilSpawnable` reduces to
"did we resolve a usable path". The concrete-path and override branches
already existed via `existsSync`, so the real-chain run is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… lint

The real-chain send e2e now probes `anvil` via a literal
`spawnSync('anvil', ...)`, which knip's binary detection flags as an
unlisted binary. `anvil` (from Foundry) is an external system binary
installed via `foundryup`, not an npm package — the same reason
`packages/foundryup` already ignores it — so add `anvil` to
`ignoreBinaries` for the wallet-cli workspace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sirtimid
sirtimid enabled auto-merge July 24, 2026 20:12
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.

wallet-cli: add the mm send-transaction command

1 participant