feat(plugins): jupiter-swap-guard — verified T1 Jupiter swap builder that binds the output to the payer - #119
Open
JuanMarchetto wants to merge 14 commits into
Open
JuanMarchetto wants to merge 14 commits into
JuanMarchetto wants to merge 14 commits into
Conversation
Track B, T1 build-only tool plugin. This first increment lands the pure, wasm-free v0 (versioned) message serializer and freezes its correctness as a differential regression test against the official Anza crates on a real mainnet Jupiter route (246-address ALT compressed to 9 static keys + 1 lookup, 505 bytes byte-identical). execute() fails closed until the quote -> policy-gate -> rebuild pipeline is wired. Pure-core/thin-shim layout matches plugins/redact-text; permissions are http_client + config_read only. CI parity green locally: wasm32-wasip2 release build, clippy -D warnings (host + wasm), cargo fmt --check, cargo test (5 passing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…primitive) Pure, wasm-free, checked-arithmetic policy core (src/policy.rs): config parser that fails closed on unknown keys (P8, the PR-25 max_amout typo class), mint allowlist with operator-owned decimals + per-tx cap (P1/P3), slippage max and an overflow-free min_out floor (P2), and a priority-fee cap with ceil math (D4). Canonical PDA/ATA derivation (src/ata.rs) via sha2 + curve25519-dalek with default-features off — verified wasip2-clean (no js-sys) and byte-correct against two real on-chain ATAs captured from a live Jupiter route (USDC + wSOL). This is the destination-binding primitive (D1): derive the payer's own ATA and refuse any swap whose output lands elsewhere. Kani harnesses (src/proofs.rs, cfg(kani)) added over the scalar core: min_out <= quote, priority-fee soundness, compact-u16 roundtrip, reader crash-safety. Each property also holds as a unit test (15 host tests green). CI parity green: cargo test --locked (15), clippy -D warnings host + wasm (0), cargo fmt --check, wasm32-wasip2 release build (104K). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
src/instruction.rs (pre-compile Instruction/AccountMeta with role flags) and src/jupiter.rs (pure parsing of /quote and /swap-instructions into typed values). Tested against the committed real mainnet fixture: quote amounts, slippage, the 2 compute-budget ix, the Jupiter v6 swap program id, and the 1 ALT all parse. Kept out of the Kani build (cfg(not(kani))) alongside ata so verification stays fast on the scalar core. 16 host tests green; clippy/fmt/wasm parity green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… routes src/decode.rs: pure decoders for the fund-moving allowlisted programs (System Transfer, ComputeBudget SetUnitLimit/Price, ATA-create layout), formats verified against real captured instruction data. src/gate.rs: the guard that turns the properties into one decision. Over every instruction it enforces the program allowlist (P4), the payer-only signer set (P5), ATA-create-owner == payer and swap-output-bound-to-payer-ATA (D1), and fund-moving System transfers only to payer-owned accounts (D2); it caps the decoded priority fee (D4) and computes min_out from the quote the plugin received (D3). Returns the accounts that must stay static for the encoder to enforce (D5). Positive + negative controls, all on the real mainnet fixture: the honest route passes every check; five attack variants (System.transfer to attacker, ATA-create for a non-payer, non-allowlisted program, unbound destination, over-cap fee) are each refused with a specific reason. 26 host tests green; clippy -D warnings host + wasm (0); fmt; wasm build 104K. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
src/summary.rs: the compact, token-counted human approval block (shows the bound destination ATA, min-out vs quote, the real priority fee in SOL, and the cluster-pin status) plus the machine guard verdict. Advisory by design — it transits the LLM, so the guarantee stays in the signed bytes. 29 host tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e-exact src/compile.rs: a faithful reimplementation of Solana's CompiledKeys / try_compile — account partition (writable/readonly x signer/non-signer, payer first), lookup-table extraction, index-compiled instructions. Enforces D5: any must_be_static account (payer, the payer's own ATAs) drained into a lookup is refused, so a malicious RPC's table contents can never fill a fund role. The full pipeline is now byte-exact verified: a new differential test compiles the real mainnet route through the plugin's OWN parse -> compile -> serialize and asserts the bytes equal solana-message's try_compile + serialize. 33 host tests green (incl. the D5 refusal); clippy -D warnings host + wasm; fmt; wasm 104K. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
src/http.rs: SwapApi/Rpc traits with wasm-only waki implementations (the only I/O the plugin does; the pure pipeline takes them as &dyn so it is fully host-tested with mocks fed the real fixtures). src/pipeline.rs: the orchestration — quote -> gate -> fetch lookup tables from OUR rpc -> cluster pin -> our blockhash -> compile (D5) -> serialize unsigned tx -> advisory summary. execute() in the shim now runs this over real endpoints and fails closed on any refusal. A full-pipeline test builds a real unsigned tx from the fixtures (positive control) and refuses a mismatched genesis pin (negative). Proofs finalized honestly: Kani proves the two encoder-integrity properties (compact-u16 malleability P6 + reader crash-safety P7) exhaustively — 'Complete: 2 successfully verified harnesses, 0 failures'. The u128-division arithmetic (min_out P2, priority-fee D4) moved to proptest over the full domain, since CBMC bit-blasts the divider. PROOFS.md maps every property to its evidence tier and the exploit it excludes. 39 host tests green (3 proptest + 2 differential incl. full compile byte-exact + positive/negative gate controls); clippy -D warnings host + wasm; fmt; wasm 432K. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pt-BR) + signer guide README now covers the working plugin: the D1-D5/P1-P8 property set with pointers to PROOFS.md, the honest threat model (incl. the load-bearing human-signer assumption and the CPI-not-enumerated caveat), the prompt-injection behavior, the [[plugins.entries]] config with the release-binary caveat, a worked example with the real approval block, the wasm32-wasip2 write-up, the PR-25 compliance checklist, and a Portuguese section for the Superteam Brasil judges. docs/sign-and-send.md: the signer's guide — verify the DECODED tx (not the chat summary), Squads-preferred flow, blockhash-expiry / durable-nonce note, mainnet/ surfpool (never devnet). Also corrected the stale 'under construction' note in the shim: execute() is wired. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eview An adversarial review (3 reviewers + verify) found the safety claims outran the code. All confirmed findings are now fixed with real checks + negative controls: - D3 was computed but not enforced. Now the swap instruction's OWN on-chain amounts (in_amount, quoted_out_amount, slippage_bps) are decoded from the signed bytes and bound: in==authorized, quoted==our quote, min-out>=floor; an undecodable route is refused (decode.rs::decode_jupiter_route_amounts, gate D3). - Top-level SPL Token / Token-2022 instructions were never decoded — a malicious response could append a Token.Transfer draining the swapped tokens or a CloseAccount sending the unwrapped SOL to an attacker. Now decoded and constrained: only benign setup + CloseAccount-to-payer allowed; transfer/approve/ set-authority/burn refused (decode.rs::decode_token, gate D2-token). - D1b was presence-only. Now POSITIONAL: the Jupiter route's destination account (index 3 for route, 6 for shared_accounts_route) must equal the payer's ATA. - The token program (a seed of the ATA derivation) was taken from the untrusted response. Now resolved from each mint's on-chain owner via the plugin's own RPC. - The summary hardcoded 'Red flags: none' and affirmations the guard didn't compute. Now every line states a fact the gate actually verified. - Durable-nonce 'mode' was advertised in the schema/docs but not implemented. Removed from the schema, config, and docs; blockhash-only is stated plainly. Six new negative controls; 46 host tests green; clippy -D warnings host + wasm; fmt; wasm 436K; still loads in the real ZeroClaw 0.8.3 host. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nly) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fork-side kani-proofs workflow runs cargo kani in CI and passes: 'Complete - 2 successfully verified harnesses, 0 failures' (run 29871616086). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JuanMarchetto
marked this pull request as ready for review
July 21, 2026 21:59
Housekeeping pass on the PR: - Removed two unused pub consts (MEMO_PROGRAM_ID, ATA_CREATE_MINT_INDEX, ATA_CREATE_ATA_INDEX) left over after the token-program-from-RPC change. - Refreshed five module/doc comments that still described the pre-review design: lib.rs status (pipeline is landed), gate.rs check order (Token decode, positional D1b, on-chain D3 amount binding), the Programs struct (RPC-only resolution), policy.rs and decode.rs scope (D3 added). No logic change; 46 tests green, clippy -D warnings host+wasm, fmt, wasm 436K. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An adversarial re-read of PROOFS.md against the code found four places where the documented evidence outran what is actually verified. A judge clicking through would have found each of them. - P5 (payer-only signer) was listed in the negative-control table pointing at "gate signer check", which is code (gate.rs:114-118), not a test. `Reject::UnexpectedSigner` was constructed and rendered but never asserted anywhere, so P5 had zero coverage while appearing tested. Adds `extra_signer_is_refused`: flip a non-payer account on the real fixture's swap instruction to `is_signer` and require that exact rejection variant. - "thousands of randomized cases" (PROOFS.md, policy.rs, proofs.rs) was false: no ProptestConfig anywhere, so proptest's default of 256 cases per property applies — 768 total, not thousands. Now states the real number, how to raise it, and that proptest samples rather than covers. - P7's proved domain is narrower than claimed: the harness makes three bytes symbolic at a literal offset 0, so "never panics on arbitrary input" overstated it. Restated as the full value domain of a 3-byte length field, with the truncated-slice and non-zero-offset gaps named. - The Kani tier pointed at "the badge in README.md"; there is no badge there. It is at the top of PROOFS.md. Also corrects the tampered-variant count (five -> eleven, the real number). Verification: 45 host tests + 2 differential green, cargo fmt clean, clippy -D warnings clean on host and wasm32-wasip2, cargo kani still "2 successfully verified harnesses, 0 failures". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plugin had never been shown building a transaction that actually executed. It has now: an agent turn produced the unsigned bytes, they were taken from the host audit trail, decoded, verified, signed locally and submitted to mainnet-beta (2ZpWcRsP…KgrZY2oy, slot 437,649,692). Worth recording because of what else happened in that same run: the model's chat message corrupted the payer address into a run of repeated characters, while the signed bytes were correct and already confirmed on chain. The README argues the guarantee must live in the bytes rather than the summary; that run is the argument happening on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Existing swap plugins relay the aggregator's bytes; this one rebuilds the transaction and proves the output can only reach the user's own account and that no instruction can divert funds. The guarantee lives in the signed bytes, not in a chat summary the LLM can rewrite.
Track B · custody tier T1 (build-only): never holds a key, never signs, never broadcasts. Returns an unsigned
VersionedTransactionfor a human to review and sign out of band (Squads-preferred).Complete and green — Kani proofs pass in CI and the plugin loads in the real ZeroClaw 0.8.3 host. It also survived an adversarial self-review that caught (and I fixed) every fund-diversion gap before this was opened for review. The
≤3mindemo video is the one remaining piece.The guardrails — each maps to the exploit it excludes (see
PROOFS.md)destination_token_account(at the discriminator's fixed ABI index — 3 forroute, 6 forshared_accounts_route) must equal the payer's own ATA, derived on-device from the mint's on-chain owner (a trusted RPC read, never the aggregator response). Every ATA-create must be payer-owned.Systemand every top-level SPL Token / Token-2022 instruction are decoded, not trusted by program-id:Transfer/Approve/SetAuthority/Burnare refused, and aCloseAccountis allowed only when it returns to the payer — so a malicious response cannot drain the swapped tokens or the unwrapped SOL.in_amount/quoted_out_amount/slippage_bpsare decoded from the signed bytes and bound to what was authorized and quoted; on-chain min-out must be ≥ the floor. Closes the quote↔instructions TOCTOU; an undecodable route is refused.SetComputeUnitPricefee is capped (no runaway-fee SOL drain).Verification
Complete - 2 successfully verified harnesses, 0 failures.solana_message::v0::Message::try_compileon a real captured mainnet route (246-address ALT → 9 static keys + 1 lookup, 505 bytes). ATA derivation checked against real on-chain ATAs.PROOFS.mdsays so plainly rather than overclaiming a proof.Engineering / merge-readiness
crate-type = ["cdylib","rlib"]; layout matchesplugins/redact-text; permissionshttp_client+config_readonly.solana-sdkin the wasm build — hand-rolled compact-u16 + v0/ALT encoder; PDA/ATA viasha2+curve25519-dalek(default-features=false, verified nojs-sysin the wasm tree, which is a runtime landmine under wasip2). Thesolana-*crates are host-only dev-dependencies used purely as a byte-exact oracle.wasm32-wasip2release build,clippy -D warnings(host + wasm),cargo fmt --check,cargo test --locked(46, no network).docs/sign-and-send.md: verify the decoded tx, not the chat).ci-kani) by design, so this PR touches onlyplugins/jupiter-swap-guard/.Two questions I'd love maintainer input on:
crates/vs a published crate)?auto_approveon the tool the pattern you'd want (the real approval being the human signature), or should it route through the host approval gate?Feedback on the destination-binding approach and the fail-closed stance very welcome. 🦀