Skip to content

Pin nonce+EIP-1559 fees at send; surface pre-sign hash (#37) - #39

Merged
szhygulin merged 3 commits into
mainfrom
feat/pin-gas-presign-hash
Apr 15, 2026
Merged

Pin nonce+EIP-1559 fees at send; surface pre-sign hash (#37)#39
szhygulin merged 3 commits into
mainfrom
feat/pin-gas-presign-hash

Conversation

@szhygulin

Copy link
Copy Markdown
Collaborator

Summary

  • Closes Scope: pin gas + expose pre-sign RLP hash for on-device match in blind-sign mode #37. Pins nonce, maxFeePerGas, maxPriorityFeePerGas, and gasLimit server-side at SEND time (not prepare — gas moves), forwards them through WalletConnect's eth_sendTransaction, and computes the EIP-1559 pre-sign RLP hash that Ledger displays in blind-sign mode.
  • Emits a new LEDGER BLIND-SIGN HASH — RELAY VERBATIM TO USER; THEY MATCH ON-DEVICE content block on every EVM send_transaction, with an explicit load-bearing warning that tapping "Edit gas" / "Edit fees" in Ledger Live legitimately desyncs the hash (reject on-device, re-run).
  • Restores on-device calldata integrity in blind-sign mode — before this, PR Add verifiable transaction preview (swiss-knife + payload-hash proof) #35 correctly dropped the bogus "match short hash" reminder, but that left the user with only To + Value checks on-device for any tx that didn't clear-sign.
  • RPC failure during the pin throws — no silent fallback to unpinned, because unpinned defeats the purpose of emitting a hash.
  • Rewrote renderAgentTaskBlock's final Ledger reminder to point at the send-time block as authoritative; updated TRANSACTION VERIFICATION and added LEDGER BLIND-SIGN HASH paragraphs to the server-level instructions.

Approach

  • New helper eip1559PreSignHash in src/signing/verification.ts via viem's serializeTransaction({type:"eip1559", ...}) + keccak256. Golden cross-check in tests against a viem-independent re-serialize to catch shape drift.
  • requestSendTransaction in src/signing/walletconnect.ts gained a PinnedGasFields arg; when present, all four fields are hex-encoded in the RPC params. Legacy tx.gasEstimate fallback kept for any caller that doesn't pin.
  • sendTransaction in src/modules/execution/index.ts fetches pending getTransactionCount, estimateFeesPerGas, and estimateGas in parallel just before the WC request; returns {preSignHash, pinned, to, valueWei} so the handler can emit the block without re-reading the consumed handle.
  • sendTransactionHandler in src/index.ts splices the new block between the JSON result and renderPostSendPollBlock.

Caveats & acceptance-criterion follow-up

  • Empirical Ledger Live test still required — the issue flags that Ledger Live may treat dApp-supplied fee fields as hints and fall back to its own estimator when it judges them stale. Needs a tiny live prepare_native_send + send_transaction on mainnet/testnet to confirm it honors all four pinned fields (or document which ones it silently overrides). I can run this at your direction; if any field turns out to be overridden, we'd scope the pin down in a follow-up PR before relying on the hash match in docs.
  • Stale-nonce races — if the user has a pending tx in mempool between our nonce read and the actual sign, the pin is stale. Ledger Live likely refetches silently; same Edit-gas warning covers the symptom (hash mismatch → reject → retry).
  • Threat model — defense-in-depth only. A compromised Ledger Live ignores the pin entirely; a compromised MCP could lie about the hash too. The cross-channel check remains the local-ABI + 4byte.directory decode that already ships.

Test plan

  • npx tsc --noEmit
  • npx vitest run — 399/399 passing (added test/send-hash-pin.test.ts for the golden hash, pinned-field propagation, RPC-failure-throws path; updated test/simulation.test.ts forward-test to assert the pin reaches WC)
  • Live Ledger Live round-trip on a tiny native send: confirm the on-device hash matches the emitted block in blind-sign, confirm the Edit-gas path triggers a legitimate mismatch
  • Session smoke test: orchestrator relays the new block verbatim (it's a user-facing block, not agent-task) and does not collapse it into a bullet summary

🤖 Generated with Claude Code

szhygulin and others added 3 commits April 15, 2026 16:29
Server now pins nonce, maxFeePerGas, maxPriorityFeePerGas, and gasLimit
at send time, forwards them via WalletConnect's eth_sendTransaction, and
computes the EIP-1559 pre-sign RLP hash Ledger displays in blind-sign
mode. A new LEDGER BLIND-SIGN HASH content block is emitted on every
EVM send for verbatim user relay — restoring on-device calldata
integrity that the PR #35 hash-drop left unchecked. Explicit in-block
warning covers Ledger Live's Edit-gas path; RPC failure throws rather
than silently falling back to unpinned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…e txHash

Live-test follow-ups to the EIP-1559 pre-sign hash work:

- A single MCP tool call cannot emit content while the Ledger device prompt
  blocks — the hash block appeared AFTER the user approved on-device. Split
  the flow: `preview_send(handle)` pins nonce + fees, computes the pre-sign
  hash, stashes both on the handle, and emits the LEDGER BLIND-SIGN HASH
  block. `send_transaction` reads the stash and submits; throws "Missing
  pinned gas" if preview_send was skipped. TRON is unaffected.

- Fee pin was too conservative (viem's 1.2× baseFee + node priority estimate
  left a live-test tx stuck in the mempool). Bumped to `baseFee × 2 +
  max(estimateMaxPriorityFeePerGas, 1.5 gwei)`.

- Agent sometimes dropped the txHash from chat after a successful broadcast,
  forcing the user to dig through Ledger Live. Added a verbatim-relay
  TRANSACTION BROADCAST block with the hash + explorer link, emitted before
  the polling directive.

- Edit-gas paragraph in the hash block now phrases the tradeoff as the
  user's choice (accept divergence without the hash-match guarantee, or
  reject and re-preview) rather than a flat "must reject" directive.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two layered defenses on top of the prepare/send fingerprint and Ledger
device display, with integration tests pinning both behaviors.

PREPARE RECEIPT: every prepare_* now emits a verbatim-relay block
listing the raw args the agent supplied. Raises the tampering bar for
narrow agent compromise (prompt injection from other tool output,
malicious skills, compromised subagents) — the agent's bullet summary
is free-composed prose; the receipt is receipts. Threaded through
handler({toolName}) so all 17 EVM + 7 TRON prepare tools emit it.

Agent-side hash recompute: preview_send now attaches an agent-task
block instructing the agent to OFFER the user a fourth trust-boundary
option — independently keccak(serializeTransaction(tuple)) with viem
and compare against MCP's reported preSignHash. Catches a compromised
MCP that lies about the hash. Per-call values are spliced into the
command template so the check is cheap to run.

README has a reworked "Security model" section with a threat→defense
table, honest limits, and the agent/MCP asymmetry. Integration tests
cover both the happy path and tampered cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@szhygulin
szhygulin merged commit d3e58e1 into main Apr 15, 2026
3 checks passed
@szhygulin szhygulin mentioned this pull request Apr 15, 2026
3 tasks
@szhygulin
szhygulin deleted the feat/pin-gas-presign-hash branch April 25, 2026 02:15
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.

Scope: pin gas + expose pre-sign RLP hash for on-device match in blind-sign mode

1 participant