feat(plugins): jupiter-swap-guard — verified T1 Jupiter swap builder that binds the output to the payer#119
Open
JuanMarchetto wants to merge 12 commits into
Open
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>
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. 🦀