Add verifiable transaction preview (swiss-knife + payload-hash proof) - #35
Merged
Conversation
…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>
6 tasks
…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
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>
3 tasks
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>
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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 fromsrc/abis/*, and a domain-taggedkeccak256fingerprint they can recompute themselves.send_transactiontime the server re-hashes the exact{chainId, to, value, data}(orrawDataHexon TRON) being forwarded and refuses to submit on hash drift — identical inputs → identical hashes, enforced at both ends.What changed
TxVerification/HumanDecode/DecodedArgtypes onUnsignedTxandUnsignedTronTx.src/signing/verification.ts—payloadFingerprint,tronPayloadFingerprint,swissKnifeDecoderUrl,buildVerification,buildTronVerification.src/signing/decode-calldata.ts— viem-based calldata decoder, ERC-20 amount args getvalueHumanlike"1 USDC"via a newTOKEN_METAmap insrc/config/contracts.ts.src/signing/render-verification.ts— chat template (EVM + TRON variants).issueHandles+issueTronHandlestampverificationon every node, including approve→action chains.src/index.tsappends a second{type: "text"}content element so the user sees the prose verbatim (JSON stays machine-readable).sendTransactionandsendTronTransaction.castcommand 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)
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,decoderUrlis omitted anddecoderPasteInstructionspoint at the landing page."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'sclearsign-v1is intentional: "clear-signing" was the wrong frame, this proves what-you-preview == what-you-sign, not Ledger on-device decode.TOKEN_META(USDC/USDT=6, WETH/DAI/LINK/etc.=18, WBTC=8): Documented — standard on-chain values for these canonical tokens.to/valuebut 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 --noEmitcleannpx vitest run— 356/356 passing, includes 15 new tests intest/verification.test.ts:buildVerificationalways populatespayloadHash,humanDecode,comparisonStringtransfer(USDC, 1_000_000)→valueHuman: "1 USDC"nativeTransferwith ETH formattingsource: "none"(no decode, user relies on swiss-knife)issueHandlesstamps both nodes of an approve→action chain with distinct hashesdecoderUrlprepare_aave_supplyfor 1 USDC on Ethereum → open returned decoder URL → confirm swiss-knife decodessupply(USDC, 1000000, wallet, 0)matching chattx.databetweenissueHandlesandsendTransaction→ guard throws hash-mismatch🤖 Generated with Claude Code