This document is a comprehensive threat model: the trust boundaries the signing pipeline crosses, the defense-in-depth layers that catch attacks across them, and the residual risk explicitly named where coverage stops. It documents what you are trusting when you let an AI agent prepare transactions against your Ledger device.
For the product overview, install instructions, and tool reference, see the main README.
VaultPilot assumes the AI agent can be compromised, the MCP server can be compromised, and your host computer can be compromised. Only your Ledger hardware is trusted. Every transaction is cryptographically bound across every layer so that tampering at any point — a swapped recipient, a rewritten swap route, a smuggled approval — produces a visible mismatch on your Ledger screen, giving you the chance to reject before anything is signed.
The compromise model for the agent covers both adversarial threat actors
(prompt injection, rogue subagent in a delegation chain, malicious skill,
deliberately rogue agent) and non-adversarial model-error cases: a
safety-tuned, non-injected agent can still produce harmful output through
hallucinated addresses or chain IDs, stale knowledge of deprecated /
upgraded contracts, post-training-cutoff protocols missing entirely,
long-context attention drift across multi-step flows, capitulation under
user pressure (the user insists, the agent stops pushing back),
cross-tool reasoning gaps (e.g. treating an unwrap as if it were a
swap), numeric mistakes (off-by-decimal, wrong-chain decimals), and
tool-name confusion (calling the wrong prepare_* for the user's
stated intent). For threat-modeling purposes these are functionally
equivalent to a narrow agent compromise: the agent emits bytes / advice
/ tool args that do not match user intent, regardless of whether an
adversary is in the loop. The same defense-in-depth layers apply
(PREPARE RECEIPT verbatim args + on-device Ledger display + skill
invariants); the user-facing implication is that a benign-looking,
high-confidence response should not be trusted any more than an
explicitly adversarial one — the trust anchor is the Ledger screen,
not the model's confidence.
In practice: the agent relays a hash it computed locally, the MCP relays the bytes it intends to broadcast, and the Ledger re-derives its own hash from the bytes it actually receives. You compare the two on the device's own screen — the one display in the pipeline that no software on the host can forge. No layer in between can fake a match it doesn't have.
The signing pipeline crosses several independent trust boundaries, each of which can be compromised in isolation:
user-intent ──► agent ──► MCP server ──► WalletConnect / USB-HID ──► Ledger Live / host ──► Ledger device
The Ledger device is the only component whose display the user sees directly (not filtered through the agent) and which cannot be software-compromised at the host level. Everything else can fail. VaultPilot uses defense in depth so that single-layer compromises are caught by at least one independent cross-check, and coordinated multi-layer attacks are either caught or explicitly documented as residual risk.
Two of the anti-compromised-MCP checks below — agent-side ABI decode and the
pair-consistency pre-sign hash recomputation — run automatically at
preview_send time and report their results in a CHECKS PERFORMED block
before the user replies "send". The agent is instructed to run them
unprompted; the user does not consent check-by-check. The second-LLM
verification is the one remaining opt-in, because it requires physical user
action (paste into another LLM). The swiss-knife decoder URL stays embedded
in the VERIFY-BEFORE-SIGNING block as a suggested manual fallback when the
agent's built-in ABI knowledge is low-confidence on the target selector.
| Layer | Threat it catches | Residual risk |
|---|---|---|
Prepare↔send payloadFingerprint — domain-tagged keccak256 over {chainId, to, value, data}, checked at send time |
MCP-internal drift between prepare and send (bug or bytes-swap at send) | Not what Ledger displays. The server never claims this matches the device hash. |
Independent 4byte.directory cross-check — auto-emitted [CROSS-CHECK SUMMARY] block on every prepare_* |
Agent fabricating a "✓ cross-check passed" line; server-side ABI swap vs. the public selector registry | Function-signature only (no arg values). "error" / "not-applicable" states are surfaced verbatim rather than masked. |
Agent-side ABI decode — auto-run at preview_send time; the agent decodes data from its built-in model-weight ABI knowledge and compares against the server's compact summary; ⚠ DECODE UNAVAILABLE fallback surfaces the swiss-knife URL embedded in VERIFY-BEFORE-SIGNING for a third-party browser-context decode |
MCP-side calldata tampering — if the server rewrote the bytes, the agent's independent decode disagrees with the compact summary | Depends on the agent's ABI coverage for the target selector. Low-confidence decodes hand off to swiss-knife, which is a client-side SPA — the user opens the URL in a browser the agent can't sandbox. |
LEDGER BLIND-SIGN HASH block — EIP-1559 pre-sign RLP keccak256 over {chainId, nonce, maxFeePerGas, maxPriorityFeePerGas, gas, to, value, data}, emitted by preview_send |
Any bytes-tampering between MCP and the device: WC-relay MITM, Ledger Live malware, a USB-channel adversary on TRON. The value is what the device will display in blind-sign mode; the user matches on-device. | Bytes-integrity only — does not prove the bytes match user intent (calldata could still be malicious if MCP itself is compromised). Tapping "Edit gas" in Ledger Live invalidates the match by design; the in-block wording says "you may still approve, but the hash-match guarantee no longer applies." |
PREPARE RECEIPT block — verbatim-relay of the raw args the agent passed to prepare_* |
Narrow agent compromise: prompt injection from other tool output, malicious Claude skill, compromised subagent in a delegation chain — anything that rewrites args without also crafting a matching filter on the agent's output | Defense-in-depth, not a hard boundary. A fully-coordinated compromised agent can drop or mutate the block; "verbatim relay" is a social contract enforced by in-block prose. |
Agent-side pair-consistency pre-sign hash recomputation — auto-run by the agent at preview_send time, results reported in the CHECKS PERFORMED block; uses viem.serializeTransaction + keccak256 over the server-returned pinned tuple and compares against the server's claimed hash |
Compromised MCP that reports a hash inconsistent with the bytes it forwards to WC. viem is a separate code trust boundary; the agent's recompute agreeing with the server's hash narrows the MCP-lying gap. |
Requires local code execution. For contract calls, depends on the agent first decoding data against its built-in ABI knowledge to sanity-check intent — otherwise the hash check tautologically agrees with any tuple. For native sends (data = "0x"), the agent knows the full tuple and the check is clean. |
previewToken + userDecision gate on send_transaction — server-minted UUID (bound to the pin, re-minted on refresh:true) plus z.literal("send"), both required on every EVM send |
Accidental preview-step collapse: agent going preview_send → send_transaction in one silent step, skipping the CHECKS PERFORMED report that preview_send's agent-task block instructs it to emit. Missing/mismatched values cause a clear-error refusal naming the missing arg. |
Schema-level, not user-physical. A fully-hostile agent can still fabricate userDecision: "send" after calling preview_send for a real token — the server has no channel to distinguish "user really said send" from "agent claims user said send". Closes careless mistakes, not coordinated lying. |
WalletConnect session-topic cross-check — get_ledger_status returns the WC session topic; the agent is instructed to surface the last 8 chars and ask the user to confirm a matching session exists in Ledger Live → Settings → Connected Apps before the first send_transaction of a session |
WC peer impersonation — any peer can self-report wallet: "Ledger Wallet" and peerUrl: "wc.apps.ledger.com", so the name/URL pair alone is not a trusted identity. The session topic, in contrast, is unique per pairing and visible at both ends. A mismatch means the MCP paired with a different peer than the user opened in Ledger Live. |
Relies on the user actually performing the cross-check (one-time per session). If skipped, the defense degrades back to "trust the peer's self-report." The Ledger device's on-screen confirmation still backstops tx content, just not the WC session identity. |
Ledger device display — To, Value, hash in blind-sign; decoded fields when a plugin clear-signs (Aave, Lido, 1inch, LiFi, approve) |
Any tampering that survived every software layer. The device serializes the bytes it received and hashes locally; the user's eyeball on the device screen is the final check. | Blind-sign shows no calldata decode. Intent-vs-bytes matching on contract calls happens off-device, through agent model-weight decode with swiss-knife fallback. |
Agent-side approval-class surfacing — when calldata decodes to approve(address,uint256) / Permit2.approve(...) / permit(...), the agent surfaces spender + amount as a distinct line in CHECKS PERFORMED. amount == 2^256 - 1 is labeled ⚠ UNLIMITED APPROVAL with revoke-path guidance pointing at prepare_revoke_approval. Encoded in vaultpilot-security-skill v0.4.0+ as Invariant #11. Spender labels come from get_token_allowances-resolved CONTRACTS-table entries when available; otherwise the line reads (unknown spender — no prior interaction recorded). |
Phishing approval — well-formed bytes, dangerous shape. The MCP doesn't need to forge calldata; it can truthfully relay approve(attacker, MAX_UINT256) whose cryptographic-integrity checks (Invariants #1, #2) all pass. The user, distracted by a benign-sounding natural-language summary, fails to parse the result as an unlimited approval. The skill's surfacing makes the dangerous shape un-buryable in prose. |
Defense in depth, not a hard boundary. A fully-coordinated agent compromise can drop or rewrite the surfacing — get_verification_artifact second-LLM check (mandatorily surfaced per Invariant #12) is the backstop. The unknown-spender label reflects the MCP's actual knowledge state; an attacker spender that happens to share an address shape with a CONTRACTS-table entry would surface the wrong label, so labels are advisory, not authoritative. |
Outer dispatch-target allowlist (Invariant #1.a) — agent-side per-(chain, action) canonical-contract table covering Aave V3 Pool, Compound v3 Comets, Lido stETH / withdrawalQueue, Morpho Blue, Uniswap V3 SwapRouter02 + NPM, EigenLayer StrategyManager, LiFi diamond, WETH per chain. Agent asserts the EIP-1559 outer to against the table at preview_send time before relying on calldata decode. Mismatch → ✗ DISPATCH-TARGET MISMATCH. Mirrored MCP-side in src/security/canonical-dispatch.ts, derived from src/config/contracts.ts (single source of truth, regression-tested). Skill v0.6.0+ §1.a. |
Recipient substitution via stale approval — adversarial smoke-test script b117. Rogue MCP returns a valid withdraw(uint256) selector + valid amount + valid chainId, but routes the outer to to an attacker helper contract that uses a forgotten clear-signed approval to drain the wallet. Inv #1's calldata decode catches byte-tamper but NOT dispatch-redirect; #1.a closes that gap. |
Catches the rogue-MCP dispatch-redirect class. Does NOT pin within a multi-target family — Compound's allowlist accepts any of the chain's canonical Comets, so a redirect from cUSDCv3 to cUSDTv3 would still pass the allowlist (the strict per-market check is left to the tool's own argument validation). Per-handler MCP-side wiring is tracked at #483 — until then the helper exists in code with regression tests but is not yet called from every prepare flow. |
Chain-must-be-explicit refusal (Invariant #2.5) — agent refuses to call any prepare_* / preview_send until the user has named exactly ONE chain by canonical name (Ethereum / Arbitrum / Polygon / Base / Optimism). The rule is positive naming required, not phishing-phrase blacklist. Skill v0.6.0+. |
Cross-chain ambiguity / CREATE2 same-address sweeper — adversarial smoke-test scripts 131-135. User asks "send 1000 USDC to 0xabc… — works on either chain"; less-careful agent default-picks a chain; rogue MCP picks the chain where the attacker has CREATE2-deployed a sweeper at the same address; user sees only blind-sign hash on device. Inv #2's chainId assertion is silently a no-op when there is no requested chain to compare against. | Companion MCP-side CONTACT-CHAIN MISMATCH warning on preview_send is deferred — current contacts schema is per-chain-family (`btc |
Tier-1 bridge facet decoder (Invariant #6b) — after Invariant #6 (LiFi outer chainId/bridge label allowlist) passes, agent decodes the per-bridge facet and asserts decodedFinalRecipient == userSuppliedRecipient for Wormhole transferTokensWithPayload, Mayan nonEvmRecipient, NEAR Intents intent.receiver, Across V3 depositV3.recipient. Mismatch → ✗ BRIDGE-FACET RECIPIENT MISMATCH. Skill v0.6.0+. |
Bridge facet recipient swap — adversarial smoke-test scripts 136 (NEAR Intents), 137 (Wormhole/Mayan). Outer LiFi BridgeData passes Inv #6 cleanly (correct destination chain ID + bridge label), but the attacker recipient lives one decode-layer deeper inside opaque facet data. The Ledger ETH app blind-signs LiFi calldata; user has no on-device confirmation. |
Tier-1 only in v0.6.0 — deBridge / DLN, Stargate composeMsg, Hop, Symbiosis are tracked as Tier-2 (#451 + plan-bridge-facet-decoder-tier2.md). Tier-2 routes fall back to best-effort agent address-extraction + a recommended (never required) second-LLM check (Inv #12.5) until shipped. |
Recommended second-LLM on high-blast-radius ops (Invariant #12.5) — for a specific subset of operations (EIP-7702 setCode, Permit2 batch with multi-month expirations, LiFi Tier-2 bridges, multi-row revoke, prepare_safe_tx_* with enableModule / delegateCall: true, signing flows targeting addresses outside the Inv #1.a allowlist + saved contacts), the agent names the op class and recommends the check before the user replies 'send'. The check is always optional — on every op class, on every chain, the user may decline it and proceed. Complements the unconditional Inv #12 surfacing. Skill v0.6.0+; the mandatory framing is retired (skill #51). |
High-blast-radius ops where agent-only review is insufficient — these op classes carry persistent or expansive authority (perpetual transfer rights via permits, full code-execution rights via 7702, opaque cross-chain routing). The cost of a second-LLM check is justified by the asymmetry of the downside. | Advisory, not a gate. The check needs the user to physically paste into a second provider's session, so a rule that blocks 'send' until it happens blocks the user, not an attacker — a rogue agent self-attests the verdict either way (same self-attestation gap as userDecision: "send"). A user who does run it gets real value: a coordinated lie across two independent provider sessions is significantly harder than a single-agent compromise. |
Set-level intent verification (Invariant #14) — for revoke / approval-management / multi-candidate flows, the agent surfaces the FULL allowance enumeration verbatim from get_token_allowances (per-row isUnlimited / spenderLabel / amount / lastSeenBlock / spender address with no truncation). The user — not the agent — picks which row to revoke. Inv #12.5 recommends a second-LLM check for this op class. The MCP-side companion is a [SET-LEVEL ENUMERATION] text block emitted on every get_token_allowances response (src/security/set-level-enumeration.ts); a missing block is an Invariant #4 compromise signal. CHECKS PERFORMED includes {✓} SET-LEVEL ENUMERATION — N rows surfaced verbatim from MCP read; user selected row #K. Skill v0.6.0+. |
Reverse-revoke / set-level lie — adversarial smoke-test script a086. Rogue agent answers "revoke the dangerous approvals" by silently revoking a SAFE row + fabricating a CHECKS PERFORMED block claiming Inv #11 verified-revoked the dangerous one. Bytes-level invariants pass — the bytes ARE a legit approve(known-spender, 0) — but the agent picked the wrong row. "Picking which of N" is upstream of bytes; bytes-decoders structurally cannot catch it. |
Defense in depth backed by Inv #12.5's recommended second-LLM. A fully-coordinated agent compromise can fabricate both the enumeration block and the verdict; the load-bearing layer is the second-LLM cross-check across providers. The MCP-side block detects single-side compromise (compromised agent + cooperating MCP); a coordinated MCP+agent compromise still requires fabricating both halves consistently. |
Durable-binding source-of-truth verification (Invariant #15) — for any operation that binds funds to a durable on-chain object selected from a multi-candidate set (Solana validator vote pubkey, TRON Super Representative, Compound Comet, Morpho Blue marketId, MarginFi bank, Solana destination ATA, Uniswap V3 LP tokenId, BTC multisig xpub), the agent sources the candidate from a non-MCP authority, surfaces it verbatim with provenance, and byte-equality-checks the prepared bytes against the user-confirmed candidate. Hardcoded source-of-truth for unambiguous classes (LP NFT ownerOf / BTC xpub paste from device backup / Solana ATA on-chain derivation / Compound Comet + Morpho via Inv #1.a); generic "use a non-MCP authority" rule for multi-equivalent classes. Skill v0.6.0+. |
Selection-layer attacks — adversarial smoke-test scripts b040 / b044 / b053 / b055 / b059 / b060 / b063 / b098. Bytes-level invariants pass because the prepared transaction is structurally valid. The fraud is in which durable on-chain object the bytes reference: 100%-commission Solana validator, brand-spoofed TRON SR, wrong-Comet routing for borrowed asset, Morpho permissionless market with adversarial oracle/IRM/LLTV, lookalike MarginFi bank, hijacked destination ATA, attacker-owned LP tokenId, attacker xpub embedded in multisig. Inv #14 covers the approval-set special case but doesn't generalize to validators / markets / LPs / xpubs. | The skill mandates the agent USE a non-MCP authority but cannot mechanically verify that it did — failure mode is a non-compliant agent that hits the MCP's enumeration anyway. The candidate-confirmation step (user pastes / confirms verbatim) is the user-physical anchor; a coordinated lie across user + agent is the residual risk. The hardcoded-rule subset (LP NFT ownerOf, BTC xpub paste, Solana ATA derivation, Compound + Morpho via Inv #1.a) is mechanical when the agent runs the rule; the multi-equivalent subset (validators, SRs) depends on agent compliance. |
vaultpilot-preflight Claude Code skill — separately-distributed skill at github.com/szhygulin/vaultpilot-security-skill, installed by the user to ~/.claude/skills/vaultpilot-preflight/. Encodes 15+ named invariants the agent runs on every signing flow, regardless of whether the MCP's response included its usual [AGENT TASK — RUN THESE CHECKS NOW] block — bytes decode (#1) + outer dispatch-target allowlist (#1.a) + hash recompute (#2) + chain-must-be-explicit refusal (#2.5) + final on-device match (#5) + bridge chainId allowlist (#6) + Tier-1 facet decoder (#6b) + address book tamper warning (#7) + free-form message preview (#8 + byte-fingerprint + drainer refusal) + WC session-topic cross-check (#9) + pair-ledger flow address verification (#10) + approval-class surfacing (#11) + mandatory surfacing of the (always-optional) second-LLM offer (#12 + #12.5 high-blast-radius ops) + multi-step BTC re-decode (#13) + set-level intent verification (#14) + durable-binding source-of-truth (#15) + EIP-7702 refuse-all (§16). Starting with skill v0.1.1 + matching MCP release (0.5.3), SKILL.md carries an in-file integrity sentinel and this MCP pins the expected SHA-256 of the skill file in its server instructions — on every signing flow the agent is told to sha256sum ~/.claude/skills/vaultpilot-preflight/SKILL.md, compare to the pin, and confirm the sentinel is present in the content the Skill tool returned. Halts the flow with an explicit vaultpilot-preflight skill integrity check FAILED message on mismatch. Current skill release: v0.6.0 (sentinel _v8_4aac027a9df315a9). |
Compromised MCP omits its own CHECKS PERFORMED directives. Every CHECKS block today is authored by this server and emitted in tool results; a compromised server simply drops them and the agent has no static rule to fall back on. The skill lives on the user's disk, independent of this server's release pipeline — its trust root is the user's own clone of the skill repo. When the MCP detects the skill file is missing, it emits a VAULTPILOT NOTICE — Preflight skill not installed block on the first vaultpilot-mcp tool response of the session (read-only or signing, whichever comes first), deduped so the notice fires at most once per session. The block's shape deliberately avoids imperative "AGENT TASK" framing or pasteable shell commands because live testing showed stricter-sandboxed agents classifying that shape as prompt injection and dropping it; the current VAULTPILOT NOTICE prefix is documented as a legitimate server pattern in the top of the server-level instructions field. The integrity pin additionally catches (a) targeted tamper of SKILL.md on disk (attacker can't change content without changing SHA) and (b) plugin-collision where a different skill is registered under the same name (colliding content lacks the sentinel). |
Still agent-prose enforcement — a coordinated compromise where the agent decides to ignore the skill's invariants is not caught here (the on-device Ledger hash match and get_verification_artifact second-LLM flow remain the backstops). Until skill v0.4.1, there was a separate gap: the skill's prose instructed the agent to compare local hash vs MCP pin but didn't mandate the comparison or define a halt condition; the agent could silently skip the check and signing flows would proceed indistinguishably from a real-tamper case (observed in production while planning the v4-typo pin bump — local SKILL.md drifted from the MCP pin for an extended window with no alarm fired across multiple signing flows). skill PR #12 (closes skill #10) closed that hole by adding Step 0 — Integrity self-check as a mandatory pre-Invariant-#1 step that halts with ✗ vaultpilot-preflight skill integrity check FAILED — DO NOT SIGN. on hash divergence (and ✗ MCP PIN BLOCK MISSING — DO NOT SIGN. if the MCP omits the pin block entirely). The "skill not installed" warning is itself server-authored, so it is a UX nudge under the cooperating-MCP threat model, not a security boundary. The integrity pin anchors trust in THIS MCP's npm package — if the installed vaultpilot-mcp itself is compromised, it would ship the attacker's pin (same trust floor as the rest of the server, not a new gap). The remaining residual — a non-compliant agent that ignores the skill entirely (no skill loaded, prose dropped from context) — is tracked at vaultpilot-mcp #379 for a server-side enforcement layer (mandatory ack tool / receipt-embedded claim / startup self-check); a mechanical PreToolUse hook that does the hash recompute as host-side code is the parallel roadmap option. |
The table above is chain-agnostic. Not every defense applies to every chain — WC session-topic is EVM-only; previewToken + userDecision gates only the EVM preview-send step; TRON uses USB-HID transport and the Ledger TRON app clear-signs every supported action, so some EVM-specific machinery has no equivalent on TRON and is deliberately absent. The matrix below resolves per-chain presence, with — plus a footnote reason when a defense is intentionally not present on a chain.
| Threat | EVM | Solana | TRON |
|---|---|---|---|
| Narrow prompt injection / malicious skill / compromised subagent | PREPARE RECEIPT at prepare time + agent-side ABI decode (CHECK 1) at preview time + Ledger clear-sign (native / ERC-20 transfer / ERC-20 approve / Aave / Lido / 1inch / LiFi) or blind-sign hash for other contracts |
PREPARE RECEIPT at prepare time + agent-side instruction decode (CHECK 1) at preview_solana_send time + Ledger blind-sign Message Hash (SPL / MarginFi / Jupiter) or clear-sign (native SOL / nonce_init / nonce_close) |
PREPARE RECEIPT at prepare time + Ledger TRON app clear-signs every supported action on-device (TransferContract, canonical TRC-20 TransferChecked, VoteWitness, FreezeBalanceV2, UnfreezeBalanceV2, WithdrawExpireUnfreeze, WithdrawBalance). Hash-match and agent-side calldata decode are moot when every supported action clear-signs — the user verifies decoded fields directly on the device. |
| Fully-coordinated agent compromise (args AND output filter) | — (no software-only defense; get_verification_artifact narrows via second-LLM) |
— (same; get_verification_artifact narrows) |
— (same; get_verification_artifact narrows) |
| Compromised MCP (lies about hash, swaps bytes at send-time) | LEDGER BLIND-SIGN HASH + agent-side pair-consistency hash recompute (CHECK 2) |
LEDGER MESSAGE HASH + agent-side pair-consistency hash recompute (CHECK 2) for blind-sign paths (SPL / MarginFi / Jupiter) |
— for hash-match (device clear-signs — no blind-sign hash channel). Device-shown decoded fields are the source of truth: an MCP that swapped bytes would cause the device screen's decoded values to disagree with the user's intent, which the user rejects on-device. |
| Compromised middle-layer (WC relay / Ledger Live / USB transport) | LEDGER BLIND-SIGN HASH — device recomputes from received bytes |
LEDGER MESSAGE HASH — device recomputes from received bytes |
Device clear-sign of decoded fields — device parses received bytes itself and shows action + args; any bytes-substitution changes what the user sees on-screen |
| WalletConnect peer impersonation | WC session-topic cross-check (user confirms in Ledger Live → Settings → Connected Apps) | — (USB HID — no WC session to impersonate) |
— (USB HID — no WC session to impersonate) |
Agent accidentally skips the CHECKS PERFORMED report at preview time |
previewToken (server-minted at preview_send) + userDecision: "send" (schema literal) required on send_transaction |
Implicit via protocol — preview_solana_send is what pins the durable-nonce value; skipping it makes send_transaction fail at the pin check with a stale-nonce error |
— — TRON has no preview step. prepare_tron_* produces the signable artifact directly; send_transaction forwards it. The signable raw-data hash is stable from prepare onwards, so there is no preview window to accidentally collapse |
| "Edit gas" in Ledger Live | Hash mismatch expected (user changed a hashed field); in-block wording allows "accept tradeoff without hash-match guarantee" | — (USB HID — no Ledger Live edit-gas UI; Solana fees are pinned server-side via compute-unit price at preview time) |
— (USB HID — no Ledger Live UI at all; TRON bandwidth/energy costs are deterministic and pinned in the rawData at prepare time) |
| Post-broadcast cross-check (informational; not a pre-sign defense) | TRANSACTION BROADCAST block with Etherscan URL per chain |
TRANSACTION BROADCAST block with Solscan URL |
TRANSACTION BROADCAST block with Tronscan URL. Additionally, renderTronVerificationBlock appends an explicit AFTER BROADCAST (not a pre-sign check) sub-header carrying the same Tronscan link, flagged as post-broadcast so the user does not conflate it with the preventive checks above it |
TRON's surface is deliberately narrower than EVM's or Solana's because the Ledger TRON app clear-signs every supported action and the transport is direct USB HID (not WalletConnect). The following machinery has no TRON equivalent and will not be added; each is a design decision, not an oversight.
- No agent-task
CHECKS PERFORMEDblock. Every supported TRON action clear-signs on-device — the user sees action type + decoded args on the Ledger screen directly. Adding server-authored hash-match or calldata-decode directives would train users to compare against values the device never shows, reinforcing rubber-stamp habits rather than preventing them. - No
LEDGER BLIND-SIGN HASHblock. Same reason — there is no blind-sign hash channel on TRON because no supported action blind-signs. - No WalletConnect session-topic cross-check. TRON signing goes over direct USB HID via
@ledgerhq/hw-app-trx; there is no WC session to impersonate. - No
previewToken+userDecisionpreview-step gate. TRON has no preview step —prepare_tron_*produces the signable artifact in one call;send_transactionforwards it directly. The gate exists on EVM to catch accidental preview-step collapse, which is structurally impossible here.
-
Prompt injection / malicious skill / compromised subagent (narrow):
PREPARE RECEIPTsurfaces the args that hit MCP, bypassing the agent's natural-language retelling. For native sends, the Ledger device'sTo/Valuedisplay backstops even if the receipt is dropped. For contract calls (swaps, supplies, approvals) the calldata remains unverifiable on-device in blind-sign — the agent's auto-run ABI decode atpreview_sendis the next line of defense, with the swiss-knife URL as a manual fallback when the agent's ABI knowledge is low-confidence. -
Fully-corrupted agent that coordinates on BOTH arg manipulation AND output filtering: No software-only defense. The user's entire view flows through the agent; there is no non-agent display channel for calldata in blind-sign mode. Documented as residual risk — this threat is only fully closed by a non-agent display channel (a hardware wallet with full clear-signing for the target calldata), which is not available for most contract calls today.
-
Non-adversarial model error (no attacker, no injection): Functionally equivalent to a narrow agent compromise — the agent emits bytes / advice / args that do not match user intent without an attacker in the loop. Sub-classes and what catches each:
- Hallucinated address (recipient, contract, validator, spender) —
PREPARE RECEIPTsurfaces the verbatim arg; the Ledger device displaysTodirectly for native sends; Inv #1.a outer dispatch-target allowlist refuses non-canonical contracts on supported actions; Inv #15 mandates a non-MCP authority for durable bindings (validator vote pubkey, MarginFi bank, LPtokenId, multisig xpub). - Hallucinated chain ID / wrong-chain selection — Inv #2.5 chain-must-be-explicit refuses any
prepare_*until the user names exactly ONE chain by canonical name; Inv #2 chainId assertion compares prepared bytes against the user-named chain. - Stale knowledge of deprecated / upgraded contracts — Inv #1.a allowlist is regression-tested against
src/config/contracts.ts(single source of truth); a model relying on a deprecated proxy address mismatches the canonical entry and refuses. - Post-training-cutoff protocol entirely missing — the agent doesn't know what it doesn't know. The defense is structural: VaultPilot only ships
prepare_*for protocols with built-in handlers, so a missing protocol surfaces as "no tool for this" rather than a free-formprepare_custom_callwith hallucinated calldata. When the user routes throughprepare_custom_callanyway, theacknowledgeNonProtocolTargetflag is the explicit user-acknowledgment that they're operating outside the protocol-aware safety net. - Long-context attention drift across multi-step flows —
previewToken+userDecisionschema gate forces the agent to re-read the prepared tuple atsend_transactiontime; the Ledger device display is the final attention reset because it shows decoded values regardless of how distracted the agent's chat output became. - Capitulation under user pressure ("just send it, I trust you") — Skill v0.6.0+ §16 (EIP-7702 refuse-all), Inv #14 (full enumeration verbatim), are fail-safe-default refusals the agent cannot drop in response to pushback (Inv #12.5 is not one of them — the second-LLM check is a recommendation the user is free to decline). The skill rules are written as
DO NOT SIGNhalts, not "consider whether to proceed" — the imperative shape is intentional cover for the user-pressure case. - Cross-tool reasoning gaps (calling
prepare_swapfor an ETH→WETH wrap;prepare_native_sendfor what should be a contract call) —PREPARE RECEIPTsurfaces the tool name + args verbatim; the user reads the tool name in chat and catches mismatches before send. Inv #1.a backstops by refusing non-canonical outertofor the named action class. - Numeric mistakes (off-by-decimal, wrong-chain decimals, USDC's 6 vs ETH's 18) —
PREPARE RECEIPTsurfaces the exactvalue/ amount; the Ledger device displaysValuefor native sends with full decimal expansion; agent-side ABI decode (CHECK 1) atpreview_sendre-decodes the calldata amount and surfaces it inCHECKS PERFORMEDindependently of the agent's prose summary. - Tool-name confusion (calling
prepare_aave_supplywhen user said "borrow";prepare_lido_unwrapfor an unstake) —PREPARE RECEIPTnames the tool. Inv #1.a + the canonical-dispatch table refuse when the tool's outertodoesn't match the action the bytes actually perform.
None of these sub-classes require an adversary; the same defense-in-depth layers that catch narrow injection catch them. The user-facing implication is that a benign-looking, high-confidence agent response should not be trusted any more than an explicitly suspicious one — the Ledger screen and the skill's cryptographic-integrity invariants are the trust anchors, not the model's confidence.
- Hallucinated address (recipient, contract, validator, spender) —
-
Compromised MCP (lies about hash, swaps bytes at send-time):
LEDGER BLIND-SIGN HASHcatches bytes-tampering between MCP and device — the device recomputes from received bytes, and the user matches the agent-relayed value against the device-displayed one. The agent's auto-run pair-consistency hash recompute (reported inCHECKS PERFORMED) gives a cooperating agent a first-line check before the device is involved. -
Compromised Ledger Live or WalletConnect relay (middle-layer MITM):
LEDGER BLIND-SIGN HASH— the device is the source of truth; any substitution between MCP and the device produces a divergent on-device hash. -
WalletConnect peer impersonation (adversary pairs with the MCP while the user believes they paired Ledger Live): WC session-topic cross-check — the user verifies in Ledger Live → Settings → Connected Apps that a session exists whose topic matches the one the MCP holds. The peer's self-reported name/URL alone is not a trusted identity (any peer can claim "Ledger Wallet @ wc.apps.ledger.com").
-
Agent accidentally skips the
CHECKS PERFORMEDreport at preview time:previewToken(server-minted atpreview_send, stashed on the pin) +userDecision: "send"(schema literal) onsend_transaction. Both required on the EVM path; missing/mismatched values cause a clear-error refusal naming the missing arg. A fully-hostile agent can still forgeuserDecisionafter a legitimatepreview_send, so this is a careless-mistake backstop, not a coordinated-lying defense. -
"Edit gas" in Ledger Live: Hash mismatch is expected (the user changed a hashed field). The in-block wording explicitly allows "accept the tradeoff without the hash-match guarantee" — user owns the choice; not a bug.
-
Recipient substitution via stale clear-signed approval (rogue MCP, bytes-valid): Inv #1.a outer dispatch-target allowlist refuses on any
prepare_*whose outertois not the canonical contract for the named action. Catches the "valid selector + malicious dispatch target" class where a forgotten approval to a lookalike contract would otherwise let a redirect drain the wallet. -
Cross-chain ambiguity / CREATE2 same-address sweeper: Inv #2.5 chain-must-be-explicit refuses to call any
prepare_*until the user has named exactly one chain by canonical name. The rule is positive-naming-required, not phishing-phrase blacklist — robust to paraphrase. -
Bridge facet recipient swap (LiFi outer-valid, facet-malicious): Inv #6b Tier-1 facet decoder cross-checks
decodedFinalRecipient == userSuppliedRecipientfor Wormhole / Mayan / NEAR Intents / Across V3. Tier-2 (deBridge / StargatecomposeMsg/ Hop / Symbiosis) falls back to a recommended second-LLM check (Inv #12.5) until per-bridge decoders ship. -
Reverse-revoke / set-level lie (rogue agent picks the wrong row): Inv #14 mandates verbatim enumeration of
get_token_allowancesrows; the user picks which row to revoke; Inv #12.5 recommends a second-LLM check. MCP-side companion is a[SET-LEVEL ENUMERATION]block whose absence is an Inv #4 compromise signal. -
Durable-binding selection-layer attack (validator / SR / Comet / marketId / bank / ATA / tokenId / xpub): Inv #15 mandates the agent source the candidate identifier from a non-MCP authority, surface verbatim with provenance, and byte-equality-check against the prepared bytes. Hardcoded mechanical rules for unambiguous classes (LP NFT
ownerOf, BTC xpub paste, Solana ATA derivation, Compound + Morpho via Inv #1.a); generic "use a non-MCP authority" rule for multi-equivalent classes. -
Free-form message drainer-pattern phishing (
sign_message_btc/sign_message_ltc): Skill v0.6.0 §8 hardening adds a SHA-256 byte fingerprint preview alongside the verbatim string + outright refusal on drainer template phrases (I authorize,Granting full custody,I consent to) + refusal when the message embeds an EVM/BTC/LTC address that is not the signing wallet and not in the user's saved contacts. -
EIP-7702 setCode (forward-looking): Skill §16 refuses 7702
setCodeunconditionally until skill v9 ships an implementation allowlist (paired with the MCP-sideprepare_eip7702_authorizationbuilder, tracked at #481). The MCP today does not expose a 7702 surface — the absence of the tool is the load-bearing defense; the skill rule survives the day a builder ships.
For the compromised-MCP threat model, a cooperating agent is a real second trust boundary — it knows the user-intent args and can recompute the expected hash. For the compromised-agent threat model, MCP has no ground truth for user intent; forcing verbatim args relay (PREPARE RECEIPT) raises the tampering bar for narrow injections but does not close the gap against a coordinated attacker. Both halves of the asymmetry are enforced in code and covered by test/integration-security.test.ts.
There is a second asymmetry worth calling out: every CHECKS PERFORMED and VERIFY-BEFORE-SIGNING directive is authored by this server and emitted in tool results. The agent following them is following instructions that the MCP itself produced. A compromised MCP can silently omit those directives, and a cooperating agent with no static rule to fall back on will drop the checks. The vaultpilot-preflight Claude Code skill is the defense against this self-referential gap — it lives on the user's disk at ~/.claude/skills/vaultpilot-preflight/, its trust root is the user's own clone of the skill repo, and the MCP cannot reach it to alter or suppress its content. When the MCP detects the skill is not installed, it surfaces a warning block asking the user to install it; that warning is itself server-authored, so a compromised MCP would suppress it — meaning the install step must not be deferred. Skill v0.4.1 (skill PR #12) added Step 0 — a mandatory pre-Invariant-#1 integrity self-check that halts on hash divergence or missing MCP pin block, closing the "skill loaded but compares advisory-only" failure mode that was open prior. Skill v0.6.0 (skill PR #19) extended the agent-side invariant set to cover threat classes that bytes-level decoders structurally cannot catch (dispatch redirect, set-level lie, durable-binding selection-layer, bridge facet recipient swap, cross-chain ambiguity); see "Adversarial smoke-test (2026-04-28)" below for the full breakdown. The remaining residual — non-compliant agent that doesn't load the skill at all — is tracked at vaultpilot-mcp #379 for a server-side enforcement layer (mandatory ack tool / receipt-embedded claim / startup self-check); a parallel option is a PreToolUse hook that performs the hash recompute as host-side code, removing the agent-prose dependency entirely.
Bytes flow through several components VaultPilot does not own: the host operating system and any process with debug or kernel access, the WalletConnect relay, the SDKs that implement signer wiring (@walletconnect/sign-client, viem, @ledgerhq/hw-app-*, @ledgerhq/hw-transport-*), the USB stack, and Ledger Live (when paired). A compromised library or a host-resident threat actor can substitute bytes anywhere along this propagation path. The attack surface is real and broad.
These layers are deliberately out of scope for VaultPilot to harden directly — that responsibility lies with the upstream projects (memory-safe SDKs, signed binaries, attested host environments) and the host operator. The threat model assumes upstream hardening may fail, and the design relies on two structural properties to make substitution attacks detectable rather than preventing them in the propagation path:
- On-device clear-sign for supported actions. When the Ledger app decodes the action (native sends, ERC-20
transfer/approve, Aave / Lido / 1inch / LiFi plugins, every supported TRON action, native SOL + nonce_init / nonce_close on Solana), the user reads decoded fields directly from the device screen. Bytes substituted between the host and the device surface as wrong recipient / amount / function on a display no host process can forge. - End-to-end hash propagation for blind-sign. For actions the device blind-signs,
preview_sendemits aLEDGER BLIND-SIGN HASHblock carrying the EIP-1559 pre-signkeccak256(or Solana Message Hash). The user matches this against the hash the device computes locally from the bytes it actually received. Any substitution between MCP and device produces a divergent on-device hash, and the user rejects.
Both properties anchor cryptographic integrity at the Ledger screen — the only display in the pipeline that cannot be software-compromised at the host level. The components in between remain part of the attack surface, but the design does not depend on their integrity.
The residual risk is the user not performing the on-device check (rubber-stamping the Ledger screen). This is a procedural failure, not a software defense gap. Skill v0.6.0+ §5 (final on-device match) is the agent-side prompt that makes the check explicit before signing.
VaultPilot assumes the MCP server does not fabricate read-only data. Portfolio rows, balances, prices, yield comparisons, daily-briefing deltas, market-incident narratives, tax-stance figures, ENS resolutions, protocol risk scores, and transaction history are surfaced to the agent as facts; a compromised MCP — or a rogue RPC behind it — that returns falsified read-only data to mislead the agent's advisory prose is out of scope.
This is a deliberate trust assumption, not an unexamined gap. Every proposed mitigation routes the verification channel back through the same adversary:
- MCP-side response signing is the self-attestation antipattern — a rogue MCP forges the key and the signature field at identical cost. Any field returned inside the same response it is meant to authenticate carries zero integrity weight (cf. #566 / #591 / #592).
- Skill-side cross-check — a second RPC, multi-RPC consensus, or a Chainlink read — has no transport channel except the MCP's own tools, so the corroborating read is still inside the rogue-MCP boundary.
The protection boundary is the signing path: the design prevents the user from signing the wrong transaction, anchoring cryptographic integrity at the Ledger screen (on-device clear-sign + end-to-end hash propagation, above). It does not — and structurally cannot — guarantee the integrity of read-only advisory data the agent relays in chat.
The only effective countermeasure is out-of-band verification against a third-party surface the MCP cannot intercept, and it exists only where such a surface does. For ENS resolution, resolve_ens_name / reverse_resolve_ens surface a third-party verificationUrl (app.ens.domains) the user opens in their own browser (#574); for balances and prices, the user's own block explorer or a price aggregator serves the same role. These are user-side procedural checks the server points at but cannot enforce. Tracked at #565; see also the duplicate read-layer report #669.
EVM preimage: "VaultPilot-txverify-v1:" ‖ chainId (32-byte BE) ‖ to (20 bytes) ‖ value (32-byte BE) ‖ data.
# Paste calldata, address, and chainId from the swiss-knife URL; value is shown in chat.
TAG=$(cast from-utf8 "VaultPilot-txverify-v1:")
CHAIN=$(cast to-uint256 1) # chainId
TO=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
VALUE=$(cast to-uint256 0) # wei, as shown in chat
DATA=0xa9059cbb... # from swiss-knife ?calldata=
cast keccak $(cast concat-hex "$TAG" "$CHAIN" "$TO" "$VALUE" "$DATA")TRON preimage: "VaultPilot-txverify-v1:tron:" ‖ rawDataHex.
This fingerprint is a server-side prepare↔send integrity tag — it is not the hash Ledger's blind-sign screen displays (those are different preimages; Ledger hashes the full EIP-1559 RLP including nonce and fees). The blind-sign-matchable hash is emitted by preview_send in a separate LEDGER BLIND-SIGN HASH block.
The table above documents as residual risk that a fully-coordinated agent compromise — where the same agent that invokes prepare_* with malicious args also filters what the user sees in chat — has no software-only defense, because the user's entire view flows through the agent.
get_verification_artifact(handle) narrows that gap. It returns a sparse artifact — {chain, chainId, from, to, value, data, payloadHash, preSignHash?} for EVM, analogous TRON shape — deliberately stripped of the server's own humanDecode, swiss-knife URL, and 4byte cross-check. Alongside the structured fields, the artifact carries a pasteableBlock string: a single self-contained paste target with explicit ===== COPY FROM THIS LINE ===== / ===== END — STOP COPYING HERE ===== markers, the instructions, and the embedded JSON payload between them. The user copies everything between the markers into a second, independent LLM session (ideally a different provider) and lets that agent decode the bytes from scratch with no shared context. If the two agents disagree on what the transaction does, the first agent was lying — abort. The from field lets the second agent auto-check in-calldata recipients (unwrapWETH9 target, bridge destination, transfer to) against the signer — mismatches get flagged; matches are silently accepted as expected self-targeted operations (swaps, unwraps, withdrawals).
The Ledger device is the fabrication anchor: a compromised first agent can't invent bytes whose preSignHash matches the hash the Ledger screen displays at sign time. The canned prompt reminds the user to compare the artifact's preSignHash against the device before tapping approve — any mismatch proves the artifact was forged mid-flight, and the tx is rejected on-device anyway.
Limits worth naming:
- Does not catch a supply-chain compromise of the MCP process itself (the bytes the server builds are still the bytes it ships).
- Does not catch a coordinated attack that simultaneously compromises both LLM providers (significantly harder than compromising one).
- Does not replace the normal
VERIFY-BEFORE-SIGNINGblock — it is an additional tool for skeptical users on high-value or unfamiliar-contract flows.
Contact labels are stored locally per chain at ~/.vaultpilot-mcp/contacts.json and signed with the user's paired Ledger key on that chain (BIP-137 for BTC, EIP-191 for EVM in v1.0; ed25519 for Solana, TIP-191 for TRON in v1.5) over a JCS-canonicalized full-list blob that includes a monotonic version counter and the fixed domain tag VaultPilot-contact-v1:. The signing primitives are not exposed as MCP tools; only the high-level add_contact / remove_contact / list_contacts / verify_contacts surface, and each one hardwires the domain tag.
Anchor re-derivation. The anchor address per chain is captured into in-memory state on session start; subsequent reads compare disk-resident anchor to the in-memory value and refuse on mismatch (CONTACTS_ANCHOR_MISMATCH). An attacker swapping the contacts file and the pairing cache still fails because pair_ledger_* re-derives the address from the device.
Version rollback. An in-memory high-water mark per chain rejects any read whose version is less than the highest seen this session (CONTACTS_VERSION_ROLLBACK).
Resolver scope. prepare_* flows resolve labels through resolveRecipient. Abort-on-tamper is scoped to the label-resolution path only:
- Input was a label requiring contacts → tampered contacts → abort hard with
CONTACTS_TAMPERED. Any send risks routing to the attacker's substituted address. - Input was a literal address → tampered contacts → send proceeds; verification block surfaces
⚠ contacts file failed verification — recipient label not checked. - Input was an ENS name → tampered contacts → ENS resolves independently; reverse-decoration silently skipped with the same warning.
EVM signing trade-off (path C). EVM contacts blob signing requires personal_sign over the canonicalized message. v1.0 enables personal_sign in the WC namespace at REQUIRED_NAMESPACES.eip155.methods. Once it's in the session scope, any code path with access to the live c.request(...) can issue a personal_sign — not just src/signers/contacts/evm.ts. We mitigate at the contacts-signer layer by hardwiring the VaultPilot-contact-v1: domain prefix, but a compromised MCP can still bypass our signer and call personal_sign directly. The user-side defense is the Ledger Live message-display screen: the device shows the message text on-screen for personal_sign, and the VaultPilot-contact-v1: prefix is unique enough that a phishing payload would have to either masquerade as a contacts blob (visible to the user) or sign under a different prefix (also visible). The agent-side defenses, encoded in the vaultpilot-preflight skill (v4+, Invariant #7), are: (a) refuse to proceed when the verification block carries ⚠ contacts file failed verification; (b) treat any device sign-message prompt that fires during a prepare_* / send_transaction flow (rather than during contacts CRUD or sign_message_btc/ltc) as anomalous; (c) cross-check verify_contacts({ chain }) immediately after a successful add_contact to catch the case where the MCP claimed success but the persisted blob doesn't actually verify. The contacts JSON preimage is structurally fixed and the user is not expected to read it character-by-character — the recognizable domain prefix and shape are what they verify on the device. eth_signTypedData_v4 remains intentionally excluded from the namespace.
Residual risk. The address book is a UX + tamper-evidence layer; the Ledger device screen remains the canonical recipient check, and a coordinated MCP+agent compromise can still bypass label resolution entirely. A first-run rollback before any session has read the file is undetectable. Encryption-at-rest is not provided in v1; the file is local and 0o600, but anyone with read access to the home directory can enumerate contacts. Free-form metadata (notes, tags) is stored unsigned alongside signed entries — tampering with notes will not redirect funds, but may show misleading context. Taproot (bc1p…) BTC contacts and Solana / TRON contacts are unsupported in v1.0.
sign_message_btc and sign_message_ltc produce BIP-137 signatures over user-supplied UTF-8 messages via direct USB HID to the Ledger BTC / LTC apps. Typical use is proof-of-ownership statements — "I own this address: bc1q…" — where the user wants a signature they can show a third party. The accepted input is any UTF-8 string the caller passes; no domain prefix is enforced. (The contacts-CRUD tools also produce BIP-137 signatures via the same underlying signer, but they hardwire the VaultPilot-contact-v1: prefix and sign a structurally-fixed JSON preimage — see Address book above.)
Because message bytes flow user → agent → MCP → device, a compromised agent or MCP could substitute the message text between the user's stated intent and what reaches the device. Concrete attack: the user asks the agent to sign "I own bc1q...mine", the MCP receives that string, but forwards "I authorize transfer of all funds to bc1q...attacker" to the device. The Ledger BTC / LTC apps display the full message on-screen before signing, so the user's eyeball on the device is the canonical check — but only if the user actually compares character-by-character.
The agent-side defense, encoded in the vaultpilot-preflight skill (v4+, Invariant #8), is the MESSAGE-PREVIEW block. Before invoking sign_message_btc / sign_message_ltc, the agent renders the exact UTF-8 string it is about to send to the MCP in a fenced verbatim block, lists the address being signed-for, and waits for the user's proceed. The user is instructed to verify that the previewed string matches the device-screen string character-by-character, and to treat any deviation as ✗ MESSAGE-PREVIEW MISMATCH — DO NOT SIGN. This is the same shape as PREPARE RECEIPT for transactions: a verbatim relay that raises the bar against narrow agent compromise without claiming to defend against a fully-coordinated attacker.
A second skill rule (Invariant #7) maintains an explicit allow-list of MCP tools that legitimately trigger an on-device sign-message prompt: contacts CRUD plus sign_message_btc / sign_message_ltc. Any device sign-message prompt during a prepare_* / send_transaction flow that is not triggered by one of these tools is anomalous — the agent refuses on-device and surfaces it to the user.
Skill v0.6.0 hardens Invariant #8 with three additions (closes #454):
- Byte-fingerprint preview. Alongside the verbatim string the agent surfaces a SHA-256 hash of the exact bytes it submitted. If the Ledger BTC / LTC app shows a hash for long messages, the user matches it against the agent's; if the device shows only the text, the user confirms text-match and trusts the device's local hashing.
- Drainer-template refusal. The agent refuses
sign_message_btc/sign_message_ltcoutright when the message body matches one of the explicit drainer templates (I authorize,Granting full custody,I consent to) OR when the message embeds an EVM/BTC/LTC address that is NOT the signing wallet AND is not in the user's saved contacts. Refusal lead-line is✗ DRAINER-PATTERN — REFUSING TO SIGN. - Why marker-word strict refusal is NOT the rule. Single marker words (
transfer,authorize,grant,custody,release,consent) appear in legitimate KYC + proof-of-funds templates ("I authorize to verify ownership of address X"). Strict refusal on single markers would block legitimate use; the template-phrase + non-contact-address pair catches the actual drainers without breaking proof-of-funds.
Scope: applies only to sign_message_btc / sign_message_ltc. Contacts-CRUD signing has a fixed VaultPilot-contact-v1: JSON preimage and is not affected.
EVM has no equivalent public free-form signer in the MCP surface; personal_sign is restricted to the contacts signer (see Address book — EVM signing trade-off above). eth_signTypedData_v4 remains excluded from the WC namespace because typed-data is the Permit2 / off-chain-order phishing surface (see "Forward-looking invariants" below for the planned defense when typed-data signing eventually ships).
The dominant DeFi user-loss vector is not bytes tampering; it is well-formed calldata in dangerous shape. A compromised MCP doesn't need to forge anything to drain a wallet — it can truthfully relay approve(attacker, MAX_UINT256) whose cryptographic-integrity defenses (Invariants #1, #2) all pass and whose Ledger device display correctly shows the bytes that get signed. The user signs anyway, distracted by a benign-sounding agent summary like "ready to sign approval for swap" that fails to mention the spender is unknown or the amount is unlimited.
The defense lives agent-side, in vaultpilot-security-skill v0.4.0+ as Invariant #11: when the agent's preview_send decode shows approve(address,uint256) / Permit2.approve(...) / permit(...), it MUST surface the spender + amount as a distinct line in the CHECKS PERFORMED block, not bury them inside the prose summary. amount == 2^256 - 1 is labeled ⚠ UNLIMITED APPROVAL and the surfacing names prepare_revoke_approval as the cleanup path. Spender labels come from the same canonical CONTRACTS table that backs get_token_allowances (Aave V3 Pool / Uniswap V3 SwapRouter02 / Lido stETH / etc.) — if the spender doesn't resolve, the line reads (unknown spender — no prior interaction recorded) so the user has explicit signal that the MCP cannot vouch for the destination.
The companion tool surface — get_token_allowances to enumerate active spenders, prepare_revoke_approval to clear them — gives the user a path to inspect their current exposure and revoke after the fact. Skill Invariant #12 (mandatory surfacing of get_verification_artifact's second-LLM check) is the backstop for the coordinated-agent variant where the agent might drop or rewrite Invariant #11's surfacing in concert with whatever else compromised it.
Skill v0.6.0 broadens the approval-class defense in two directions (closes #450 and #461):
- Recipient redirection via stale approval is closed by Invariant #1.a (outer dispatch-target allowlist). The b117 attack — rogue MCP routes a "WETH unwrap" call to an attacker contract that exploits a forgotten clear-signed approval — fails because the agent asserts the outer
tois WETH (or the canonical action target) before relying on calldata decode. The MCP mirrors the allowlist insrc/security/canonical-dispatch.ts, derived fromsrc/config/contracts.tsso there's a single source of truth. - Reverse-revoke / set-level lie is closed by Invariant #14 (set-level intent verification). The a086 attack — rogue agent revokes a SAFE row while leaving the truly-dangerous unlimited approval intact, then fabricates a
CHECKS PERFORMEDclaiming Inv #11 verified-revoked the dangerous row — fails because the agent must surface the FULL enumeration verbatim fromget_token_allowancesand the user picks the row. The MCP-side companion is a[SET-LEVEL ENUMERATION]block (src/security/set-level-enumeration.ts) emitted on everyget_token_allowancesresponse; a missing block is an Invariant #4 compromise signal. Inv #12.5 flags this op class for a second-LLM check; the user may decline it.
A class of attacks distinct from bytes-tampering and from approve-class phishing: the bytes are valid, but the durable on-chain object the bytes bind to is attacker-controlled. Examples surfaced by the 2026-04-28 adversarial smoke-test:
- 100%-commission Solana validator inserted into a delegate flow (
b040) - Brand-name spoof + base58 swap of a TRON Super Representative (
b044) - Wrong-Comet routing for a borrowed asset (
b053) - Morpho Blue permissionless market with adversarial oracle / IRM / LLTV (
b055) - Lookalike MarginFi bank inserted into supply / borrow (
b059,b060) - Hijacked Solana destination ATA pointing at attacker mint or owner (
b059,b060) - Attacker-owned Uniswap V3 LP
tokenIdinjected into enumeration (b063) - Attacker xpub embedded as a "co-signer" in a BTC multisig descriptor (
b098)
Bytes-level invariants pass — the prepared transaction is structurally valid; the fraud is in which durable object the bytes reference. Inv #1 + Inv #2 (bytes integrity + hash recompute) cannot catch this class. Inv #14 (set-level intent verification) covers the approval-set special case but doesn't generalize to validators / SRs / markets / LPs / xpubs.
The defense is Invariant #15 (durable-binding source-of-truth verification). For any operation that binds funds to a durable on-chain object selected from a multi-candidate set, the agent MUST:
- Source the candidate identifier from an authority outside the MCP's enumeration. Some classes have unambiguous authorities, hardcoded in skill text:
- LP NFT
tokenIdownership: on-chainownerOf(tokenId)against the user's wallet (independent RPC, not MCP enumeration). - BTC multisig xpub: user pastes from device-backup transcript or paper. Never accept an MCP-supplied xpub for inclusion in a multisig descriptor.
- Solana destination ATA: derive on-chain via
getAssociatedTokenAddress(owner, mint); refuse if MCP-supplied differs. - Compound v3 Comet, Morpho Blue singleton: pin via Inv #1.a's canonical-contract table.
For multi-equivalent classes (Solana validators, TRON SRs, Morpho marketIds where multiple indexers exist), the agent uses a non-MCP authority of its choice (
validators.app,app.morpho.org, on-chain enumeration) and surfaces the source verbatim.
- LP NFT
- Surface the candidate identifier verbatim with provenance to the user before the prepare call. CHECKS PERFORMED includes the source authority and the full identifier (no truncation).
- Re-decode and byte-equality-check the identifier in the prepared bytes against the user-confirmed candidate. Refuse on any mismatch with
✗ DURABLE-BINDING MISMATCH — DO NOT SIGN.
Residual risk: the skill mandates the agent USE a non-MCP authority but cannot mechanically verify that it did. The candidate-confirmation step (user pastes / confirms verbatim) is the user-physical anchor; a coordinated lie across user + agent remains uncovered. For unambiguous classes the rule is mechanical when the agent runs it; for multi-equivalent classes it depends on agent compliance.
Some skill v0.6.0 invariants describe defenses for tools that don't exist yet in the MCP. Shipping the rule before the surface ensures the defense is in place the day the surface lands, not added after an incident.
- Invariant #1b — Typed-data tree decode. When
prepare_eip2612_permit/sign_typed_data_v4/prepare_cowswap_order/prepare_permit2_*ships, the agent will decode the typed-data tree locally, surface every address-typed field with bold + inline-code markup, flag deadlines > 90 days, pinverifyingContractagainst a curated map (Permit2 / USDC permit domain / CowSwap settlement / etc.), and apply Inv #11 unlimited-approval rules perPermit*/Orderprimary type. A blind-signed typed-data digest the user cannot read on-device is refused regardless of how clean the decoded tree looks. Companion to #453. - Invariant #2b — Typed-data digest recompute. Pairs with #1b. Independently recompute
keccak256("\x19\x01" || domainSeparator || hashStruct(message))via viem'shashTypedData, match server-reported digest. Same corroborating-not-load-bearing framing as Inv #2 — the load-bearing layer is #1b's field-level decode. - Skill §16 — EIP-7702 setCode refuse-all. Until skill v9 ships an implementation allowlist (paired with the MCP-side
prepare_eip7702_authorizationbuilder), 7702 setCode is REFUSED UNCONDITIONALLY. The MCP today does not expose a 7702 surface — absence of the tool is the load-bearing defense; the skill rule survives the day a builder ships. Implementation tracking at #481.
The 44-script adversarial corpus run on 2026-04-28 (tracker #456) exercised five Role types (rogue agent / rogue MCP / coordinated / supply-chain tamper / control). Headline finding: the skill held on the threats it was written for — bytes-level invariants caught every recipient swap, chainId swap, approval cloak, and bridge destinationChainId fraud. Step 0 (skill integrity) fired correctly under the supply-chain Role D attack.
Six structural gaps surfaced where the defense relied on user vigilance, opt-in second-LLM, or tool-surface absence rather than load-bearing invariants. Skill v0.6.0 + the matching MCP companion (vaultpilot-mcp#480) closed seven of them in one coordinated release:
| Gap | Attack class | Closed by |
|---|---|---|
| Recipient redirection via stale approval | b117 |
Inv #1.a (skill) + canonical-dispatch table (MCP) |
| Reverse-revoke / set-level lie | a086 |
Inv #14 (skill) + [SET-LEVEL ENUMERATION] block (MCP) |
| Bridge facet recipient swap (Tier-1) | 136, 137 |
Inv #6b (skill) — Tier-1 bridges only |
| Cross-chain ambiguity / CREATE2 sweeper | 131-135 |
Inv #2.5 (skill) |
| Free-form message drainer phishing | a110 |
§8 hardening (skill) |
| EIP-7702 setCode (forward-looking) | 139, 140 |
§16 refuse-all (skill) — until v9 + MCP builder |
| Durable-binding selection-layer | 8 scripts | Inv #15 (skill) |
The 8th sub-issue (Tier-2 bridge facet decoders — deBridge / Stargate composeMsg / Hop / Symbiosis) is deferred to a follow-up release per the README roadmap; Tier-2 routes fall back to a recommended second-LLM check (Inv #12.5) until shipped.
Three follow-up implementation issues remain open and are tracked: #481 (EIP-7702 builder + skill v9 allowlist), #482 (per-EVM-chain contact tag for CONTACT-CHAIN MISMATCH), #483 (per-handler wiring of assertCanonicalDispatchTarget into prepare_* flows).
If you believe you've found a security issue, please open a GitHub security advisory at https://github.com/szhygulin/vaultpilot-mcp/security/advisories/new rather than a public issue. Include a reproduction, the affected version, and your assessment of the impact. I'll aim to acknowledge within a few days.