Skip to content

Add verifiable transaction preview (swiss-knife + payload-hash proof) - #35

Merged
szhygulin merged 21 commits into
mainfrom
feat/tx-verification
Apr 15, 2026
Merged

Add verifiable transaction preview (swiss-knife + payload-hash proof)#35
szhygulin merged 21 commits into
mainfrom
feat/tx-verification

Conversation

@szhygulin

Copy link
Copy Markdown
Collaborator

Summary

  • Every prepare_* response now ends with a VERIFY BEFORE SIGNING block giving the user three independent ways to cross-check what they're about to approve on their Ledger: a preloaded swiss-knife.xyz decoder URL, a local ABI decode from src/abis/*, and a domain-tagged keccak256 fingerprint they can recompute themselves.
  • At send_transaction time the server re-hashes the exact {chainId, to, value, data} (or rawDataHex on TRON) being forwarded and refuses to submit on hash drift — identical inputs → identical hashes, enforced at both ends.
  • Replaces the factually-incorrect "clear-signing" classifier from PR Add clear-signing guard + direct Uniswap V3 swap path #33. The verification block is unconditional — there is no safe/unsafe classification, the user is always the arbiter.

What changed

  • New TxVerification / HumanDecode / DecodedArg types on UnsignedTx and UnsignedTronTx.
  • New src/signing/verification.tspayloadFingerprint, tronPayloadFingerprint, swissKnifeDecoderUrl, buildVerification, buildTronVerification.
  • New src/signing/decode-calldata.ts — viem-based calldata decoder, ERC-20 amount args get valueHuman like "1 USDC" via a new TOKEN_META map in src/config/contracts.ts.
  • New src/signing/render-verification.ts — chat template (EVM + TRON variants).
  • issueHandles + issueTronHandle stamp verification on every node, including approve→action chains.
  • src/index.ts appends a second {type: "text"} content element so the user sees the prose verbatim (JSON stays machine-readable).
  • Send-time guard in both sendTransaction and sendTronTransaction.
  • README: new section + one-line cast command for independent hash recomputation.

TRON omits the decoder URL (swiss-knife is EVM-only) and points at tronscan instead; hash + local decode still apply.

External-fact verification (per rnd-skill)

  • swiss-knife URL format (calldata.swiss-knife.xyz/decoder?calldata=…&address=…&chainId=…): Verified from the project's source on GitHub (components/pages/CalldataDecoderPage.tsx, master branch). Conservative fallback: if calldata exceeds a ~3500-char URL budget, decoderUrl is omitted and decoderPasteInstructions point at the landing page.
  • payloadHash domain-tag format ("VaultPilot-txverify-v1:" ‖ chainId ‖ to ‖ value ‖ data): Internally-defined — this server defines the tag, not a third-party spec. Tag rename from the closed PR's clearsign-v1 is intentional: "clear-signing" was the wrong frame, this proves what-you-preview == what-you-sign, not Ledger on-device decode.
  • Token decimals in TOKEN_META (USDC/USDT=6, WETH/DAI/LINK/etc.=18, WBTC=8): Documented — standard on-chain values for these canonical tokens.
  • Ledger Live on-device screen shows to/value but not full calldata: Documented — this is why the swiss-knife cross-check matters; called out in the chat block's REJECT nudge.

Test plan

  • npx tsc --noEmit clean
  • npx vitest run — 356/356 passing, includes 15 new tests in test/verification.test.ts:
    • fingerprint determinism + single-byte mutation detection + EVM↔TRON domain separation
    • swiss-knife URL has all three params; oversized calldata falls back to paste instructions
    • buildVerification always populates payloadHash, humanDecode, comparisonString
    • ERC-20 transfer(USDC, 1_000_000)valueHuman: "1 USDC"
    • native send decodes as nativeTransfer with ETH formatting
    • unknown destination → source: "none" (no decode, user relies on swiss-knife)
    • issueHandles stamps both nodes of an approve→action chain with distinct hashes
    • TRON handle has hash + paste instructions, no decoderUrl
    • rendered block contains URL, full hash, short fingerprint, REJECT nudge, function signature
  • Manual: call prepare_aave_supply for 1 USDC on Ethereum → open returned decoder URL → confirm swiss-knife decodes supply(USDC, 1000000, wallet, 0) matching chat
  • Manual (negative): mutate tx.data between issueHandles and sendTransaction → guard throws hash-mismatch

🤖 Generated with Claude Code

szhygulin and others added 20 commits April 15, 2026 11:23
…ayload hash

Every prepare_* response now ends with a VERIFY BEFORE SIGNING block giving
the user three independent ways to cross-check what they are about to approve:

- swiss-knife.xyz decoder URL preloaded with calldata+address+chainId (EVM)
- local ABI decode via viem decodeFunctionData against src/abis/*
- domain-tagged keccak256 payload fingerprint the user can recompute

At send time the server re-hashes the exact {chainId, to, value, data} being
forwarded to WalletConnect (or rawDataHex on TRON) and refuses to submit if
the hash drifted from preview. Identical inputs produce identical hashes at
both ends — "what you preview is what you sign."

TRON omits the decoder URL (swiss-knife is EVM-only) and points at tronscan
instead; hash + local decode still apply.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
End-to-end manual test showed the orchestrating agent summarized the
prepare_swap output (route, expected out, fingerprint short) but dropped
the decoder URL and the REJECT guidance — the user never saw the actual
verification block. Without the URL reaching the user, the entire swiss-
knife cross-check is dead weight.

Two belt-and-suspenders fixes:

- Every rendered verification block now starts with a bold directive
  ("⚠️ SECURITY-CRITICAL — SHOW THIS ENTIRE BLOCK TO THE USER VERBATIM")
  telling the agent not to paraphrase, summarize, or drop fields.
- Server-level `instructions` gain a TRANSACTION VERIFICATION section
  telling the orchestrator to relay the second content element
  character-for-character, including for each step of approve→action
  chains.

The block already was emitted as a separate MCP content element; the
missing piece was making it obvious to the agent that this is not
supplementary narration it can compress.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Follow-up to end-to-end testing feedback from the first iteration:

1. The agent-facing "SHOW VERBATIM" preamble bled into user chat. It
   served no purpose for the user — the equivalent directive now lives
   only in the server-level `instructions` field, which orchestrator
   agents see but users don't. Removed from both EVM and TRON blocks.

2. ERC-20 `approve(address,uint256)` is clear-signed natively on Ledger's
   Ethereum app (spender + amount shown on-device), so the swiss-knife
   cross-check adds no security for those txs and just doubled the chat
   on approve→action flows. `shouldRenderVerificationBlock` now filters
   them out — the send-time payload-hash guard still runs, only the
   user-visible block is suppressed. Server instructions explain the
   asymmetry so agents don't flag it as missing.

3. Template compressed from ~18 lines to ~8: one-line Decoder/Call/Args
   header, single-line `chainId=… to=… value=… data=…` row, single-line
   Hash row. Decoder URL, full 32-byte payload hash, short fingerprint,
   and the REJECT guidance all still present.

4. Swiss-knife URL budget raised from 3 500 → 12 000 chars. Under the
   old budget a typical ~2 kB LiFi intra-chain swap calldata fell back
   to paste-only for no good reason — swiss-knife.xyz is a Next.js SPA
   on Vercel (practical request limit ~14 kB per Vercel docs), and all
   modern browsers accept 32 kB+. New regression test pins this.

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

Three follow-ups driven by the second end-to-end test:

1. The 4 KB swiss-knife URL rendered as a wall of hex in chat. Wrapping
   it as markdown `[open in swiss-knife](…)` collapses it to a clickable
   link in Claude Code / Desktop while leaving the raw URL inside the
   parens readable for plain-text clients.

2. "Call: unknown / Args: (unknown destination — the decoder URL is your
   only decode)" was confusing — users read "unknown" as a failure state.
   Rewrote to "Call: (decoded by swiss-knife only — open the link above)"
   and dropped the Args line entirely when `humanDecode.source === "none"`.
   The URL's role as the authoritative decode is now stated directly.

3. Added a `collectVerificationBlocks` integration test (the function is
   now exported from src/index.ts) that wires a real approve→swap chain
   through `issueHandles` and asserts only the swap block is rendered.
   This pins the approve-skip behavior that landed in 2cabc5a at the
   orchestrator-collection layer, not just at the predicate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Approvals no longer render a verification block (ERC-20 approve clear-
signs on Ledger's Ethereum app), but the user still needs to know what
to verify on-device. Added a second sentence to the TRANSACTION
VERIFICATION instructions telling the agent to explicitly call out
(1) the spender address matches the intended protocol, and (2) the
approved amount matches what the user asked for in human units.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous two test iterations showed "Call: (decoded by swiss-knife
only)" for LiFi swap calldata because `classifyDestination` recognized
the diamond address but had `abi: null`. The user flagged that the
resulting "unknown destination" wording looked like a failure state.

Added src/abis/lifi-diamond.ts covering the seven GenericSwap-facet
entry points that LiFi's aggregator emits for intra-chain routes:

  0x4630a0d8  swapTokensGeneric                  (legacy, SwapData[])
  0x4666fc80  swapTokensSingleV3ERC20ToERC20
  0x733214a3  swapTokensSingleV3ERC20ToNative
  0xaf7060fd  swapTokensSingleV3NativeToERC20
  0x5fd9ae2e  swapTokensMultipleV3ERC20ToERC20
  0x2c57e884  swapTokensMultipleV3ERC20ToNative  (user's test tx)
  0x736eac0b  swapTokensMultipleV3NativeToERC20

Signatures verified from primary source: lifinance/contracts master at
src/Facets/GenericSwapFacet.sol (legacy), GenericSwapFacetV3.sol (V3
variants), src/Libraries/LibSwap.sol (SwapData struct). Selectors
computed locally via viem keccak256 against the source signatures.

Cross-chain bridge facets (across/amarok/stargate/etc.) intentionally
out of scope — they'd double this file's size without covering the
intra-chain swap path that vaultpilot-mcp actually uses. Unrecognized
LiFi selectors still fall back cleanly to source:"none" via viem's
decode-throws path; new test locks that behavior in.

The user's 0x2c57e884 USDC→ETH swap will now render as:
  Call:    swapTokensMultipleV3ERC20ToNative(bytes32,string,string,...)
instead of the swiss-knife-only fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Exposes `amountSide: "to"` on get_swap_quote/prepare_swap so callers can
specify a target output amount (routed via LiFi's toAmount quote). 1inch
comparison is skipped because v6 has no exact-out endpoint.

Approval sizing on exact-out uses ceilDiv(fromAmount * (10_000 +
slippageBps), 10_000) rather than the aggregator's point estimate —
otherwise any adverse pool drift between quote and execution causes
transferFrom to revert on 1 wei of under-approval. Exact-in unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The chainId/to/value line previously included a hex preview of the
calldata, which duplicated information already shown in the `Args:`
section. When local decode succeeds, show only `(N calldata bytes)` as
sizing context. The hex preview is preserved for `source: "none"` txs
where the user has no other local signal before opening the decoder URL.

Update the orchestrator-agent instructions: in addition to forwarding
the verification block verbatim, the agent must run an independent
cross-check (4byte.directory selector lookup, local ABI decode, or
known-contract recognition) and report the result. Explicitly forbid
claiming to have fetched swiss-knife.xyz programmatically — it's a
client-side SPA so WebFetch only returns the JS shell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a long-running session compacts the original prepare_* tool result
out of the agent's context, agents have been resorting to scripting
direct reads of Claude Code's persisted tool-result JSON files
(`~/.claude/projects/<id>/tool-results/*.json`) to recover the
VERIFY-BEFORE-SIGNING block. That bypasses the MCP boundary, scrapes
harness internals, and produces brittle ad-hoc python per call.

Add `get_tx_verification(handle)` so the agent asks the server instead.
Re-emits the exact same JSON shape and rendered verification block as
the original prepare_*, sourced from the existing in-memory tx store
(15-min TTL, already populated). Routes EVM vs TRON handles through
the right store and throws one clear "expired or unknown" error.

Update orchestrator instructions to mandate this path and explicitly
forbid disk-scraping the tool-result files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Session-level instructions get ignored by the orchestrator after a few
turns — the agent skipped the 4byte.directory cross-check and echoed
the opaque handle UUID to the user. Move both rules into a per-call
content block rendered adjacent to the verification block, where the
model is far more likely to act on them.

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

- Collapse embedded 0x… runs (>32 bytes) inside rendered struct args to a
  head…tail preview, so LiFi _swapData[].callData blobs don't dump ~2 KB
  of hex into the chat.
- Rewrite the agent-task cross-check instruction: instead of a terse
  "Cross-check (4byte.directory): ✓ <name>" prefix, tell the agent to
  explain in one sentence what the selector lookup actually confirmed
  (function name only, not argument correctness) and to nudge the user
  toward opening swiss-knife for the arg-level check.
- Add a third directive: the agent must end its reply with a one-line
  reminder to compare the short payload hash against what the Ledger
  shows at approval time — the final tamper check.

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

The previous cross-check relied on the agent WebFetch'ing 4byte.directory
and paraphrasing the result. Two problems: (1) agents would either skip
the fetch or fabricate the match; (2) it only confirmed the function
NAME, not the arguments — leaving the user with a nagging "did the args
really match?" feeling and no way to check without opening swiss-knife
themselves. The user explicitly asked for an MCP-tool-driven full
cross-check rather than more agent-side scripting.

verify_tx_decode(handle) now:
  - fetches candidate function signatures from 4byte.directory on the
    server
  - decodes the calldata via viem against each candidate
  - re-encodes the decoded args and compares byte-for-byte against the
    original calldata — a lossless round-trip proves the signature
    describes the EXACT bytes, which in turn mathematically implies
    every argument value matches any other decode of the same data
    (including the local ABI decode shown in the verification block)
  - returns a VerifyDecodeResult whose `summary` field is pre-written
    for end-user consumption; the orchestrator relays it verbatim

For TRON handles the tool returns status=not-applicable with an
explanation — TRON has no 4-byte selector concept, so this specific
cross-check doesn't fit.

Agent task block is rewritten to point at this tool, and the
server-level instructions drop the old "choose one of three WebFetch
strategies" paragraph in favor of a single mandatory MCP call.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Previous summary said "I ran the calldata through a SECOND decoder on
the MCP server" which implied independence from swiss-knife's approach.
In reality the server-side tool runs the SAME algorithm swiss-knife
runs in-browser (pull the function signature from 4byte.directory,
decode via viem, re-encode to prove bytes round-trip). The difference
is just the execution context, not the method.

- Rewrite the match/no-local summary to say so explicitly, flag that
  this check shares a trust boundary with the MCP server itself, and
  nudge the user toward the swiss-knife URL as the out-of-boundary
  option they fully control.
- Extend the per-call agent task block: after relaying the summary,
  the orchestrator must OFFER the user two further checks —
  (a) open the swiss-knife URL in their browser, or (b) ask the agent
  to decode the calldata itself from its built-in ABI knowledge
  (a trust boundary outside the MCP server's code). Both framed as
  user-choosable options; the agent only performs its own decode if
  the user asks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The verify_tx_decode summary was ~900 chars of technical narration
(the algorithm, the viem mention, the server-vs-browser distinction,
and a redundant repeat of the swiss-knife nudge). For a tech-unsavvy
user, that wall of text drowns the one sentence that actually matters.

- Rewrite the match/no-local summaries down to ~3 sentences each. Keep
  "which mathematically implies every argument below matches" — the
  user explicitly called that phrasing out as worth preserving. Drop
  the detailed algorithm walk-through and the duplicate swiss-knife
  nudge (covered by the agent-added paragraph after the block).
- Shorten the "VERIFY BEFORE SIGNING" header from "open the decoder URL,
  confirm it decodes to the same call shown below, and REJECT on Ledger
  if they differ" to "check the decoded call below matches what you
  asked for, and REJECT on Ledger if it doesn't". The decoder-URL
  instruction was duplicated by the summary above AND the agent-added
  options paragraph below, so prose-duplication × 3 becomes × 2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The options paragraph after the verification block listed only two
trust-boundary paths: user opens swiss-knife themselves, or agent
decodes from its ABI knowledge. The user asked for a third — the
agent fetching the swiss-knife URL itself and reporting what it
sees.

Implemented as option (c) with an explicit honesty caveat the agent
must state up front: swiss-knife is a client-side Next.js SPA, so
WebFetch returns the JS shell without the decoded output. What the
agent CAN still do after fetching is pull the calldata out of the
URL query string and decode it itself — weaker than option (b)
because the ABI decode is the same, but on a URL the user named
rather than on server-provided bytes, so it's a different path.

The directive tells the agent to state the limitation before running
the fetch so the user can redirect to (b) if they'd rather skip the
performative fetch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Lock in the validated verify-block UX the user endorsed: agent produces
a compact bullet summary (Headline / From / To-with-label / Value /
Function / Short hash + tx-specific field) instead of relaying the
VERIFY-BEFORE-SIGNING block verbatim. Server still emits the raw block
for non-summarizing clients, but agent clients replace it with the
bullet form so the cryptographic guarantees stay visible without the
hex-dump wall.

Substitute payloadHashShort directly into the per-call directive so the
final Ledger-match reminder carries the exact hash without a placeholder.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two bugs surfaced when the agent ran verify_tx_decode on an ERC-20
approve and got a "DO NOT SEND" mismatch against a spam signature.

1. Pick the candidate that NAMES the function right, not the first that
   re-encodes. 4byte often has multiple entries for the same selector
   that share a calldata layout (e.g. `(address, uint256)` and
   `(address, address)` — any 64-byte payload round-trips bijectively
   through both). Breaking on the first lossless match let whichever
   entry was registered first win arbitrarily, including registry spam.
   Now collect every losslessly-matching candidate and prefer the one
   whose name agrees with the local ABI decode.

2. Short-circuit on the ERC-20 approve selector (0x095ea7b3). Ledger's
   Ethereum app clear-signs approvals natively and the verification +
   agent-task blocks are already suppressed for that selector; the tool
   now matches that suppression and returns a "not-applicable" summary
   explaining the device-level check. 4byte has well-known collision
   spam here (`watch_tg_invmru_*(address,address)`), so running the
   cross-check produced false mismatches on a universally-known
   selector.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Ledger can either blind-sign (device shows a hash to match) or clear-sign
(device shows decoded fields via an Ethereum-app plugin like Lido / Aave /
1inch / LiFi / approve-native). We can't predict which, so the final
end-of-reply reminder now covers both modes in one sentence: "if the
device shows a hash, it must be <short>; if it clear-signs with decoded
fields, confirm <function> + <key field>." Previously we hard-coded the
hash case, which misled the user on plugin-decoded flows (LiFi swap
showed structured fields on-device, not the hash we told them to check).

Also trimmed the ERC-20 approve "not-applicable" summary from verify_tx_decode:
dropped the 4byte spam-collision tangent and left a single sentence the
user actually needs ("Ledger clear-signs this natively; verify spender +
amount on the device"). Session-level instructions now explicitly forbid
tacking a short-hash reminder onto approval replies — Ledger never shows
a hash for approves, so that reminder is wrong for this flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Portfolio aggregator reported coverage.compound.covered=true with zero
positions while get_compound_positions returned a live six-figure supply
on the same wallet/chain. A single flaky RPC read on cUSDCv3 dropped the
market silently because readMarketPosition's outer .catch(() => null)
made all errors indistinguishable from "market not deployed," and the
null filter in getCompoundPositions removed them before the aggregator's
top-level .catch could fire.

Two changes restore the distinction:

1. readMarketPosition now throws when baseToken succeeds but balanceOf /
   borrowBalanceOf fail — the market IS there, the read just flaked.
   Only returns null when baseToken itself failed (clean "not deployed"
   skip, same as before).

2. getCompoundPositions uses allSettled to count per-market rejections
   and returns { errored: boolean, erroredMarkets?: [...] }. Portfolio
   aggregator flips errors.compound when errored=true so
   coverage.compound.errored becomes true with a note about missing
   positions, instead of claiming clean coverage while totals are short.

Adds three regression tests covering both branches of readMarketPosition
and the aggregator's handling of the new errored flag.

Closes #34.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Follow-up to bd4318a. The previous fix flagged per-market read failures
only when baseToken succeeded but balanceOf/borrowBalanceOf failed. If
the RPC was flaky enough to drop baseToken too, readMarketPosition
still returned null silently — which is how a live cUSDCv3 position
stayed invisible in a just-tested session (get_compound_positions
returned positions:[] and coverage.compound={covered:true} while the
user reported a real supply on the wallet from issue #34).

Since CONTRACTS[chain].compound is a curated list of known-deployed
Comet proxies, a failure on ANY of {baseToken, balanceOf,
borrowBalanceOf} is an RPC problem, not "contract not deployed."
readMarketPosition now throws on any of those three, with a message
listing the failed calls so the user can see what flaked. Legitimate
null returns (genuinely empty market, decimals-fail-with-nonzero-
balance) are preserved.

Updated Bug 3 / issue #34 tests — the old "baseToken fail = silent
skip" assertion is now inverted to "baseToken fail = errored:true
with the market name in erroredMarkets."

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

Three related verification-flow fixes:

* `collectVerificationBlocks` now runs the 4byte.directory independent
  decode inline and emits the summary as a `[CROSS-CHECK SUMMARY]` text
  block alongside every `prepare_*` response. An agent that forgets (or
  chooses not to) call `verify_tx_decode` can no longer fabricate a "✓
  cross-check passed" line — the server emits the real result.

* `send_transaction` appends a post-send `[AGENT TASK]` block instructing
  the agent to poll `get_transaction_status` itself every ~5s until the
  tx mines, instead of waiting for the user to type "next". The post-
  send auto-poll is how approve→action chains now wait for inclusion.

* Dropped the "confirm short hash X on Ledger" reminder for EVM. Our
  `payloadHash` is over `{chain, to, value, data}`; Ledger's blind-sign
  hash is over the full RLP including nonce + fee fields that Ledger
  Live picks at send time. Those hashes never matched, and claiming
  they would trains the user to rubber-stamp a real mismatch. The
  honest on-device reminder now covers both modes: clear-sign →
  confirm function + key field; blind-sign → `To` and `Value` (which
  do round-trip into the RLP verbatim).

Follow-up issue #37 scopes pinning nonce+gas server-side so we could
restore an on-device hash-match check in blind-sign.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@szhygulin
szhygulin merged commit f4b8774 into main Apr 15, 2026
3 checks passed
szhygulin added a commit that referenced this pull request Apr 15, 2026
PR #35 tightened three of the four position-critical multicalls but left
the base-token decimals guard at `return null` — the aggregator then
reported clean coverage with a curated-registry market silently missing.
Throw instead so Promise.allSettled classifies it as errored.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
szhygulin added a commit that referenced this pull request Apr 15, 2026
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>
@szhygulin
szhygulin deleted the feat/tx-verification 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.

1 participant