Skip to content

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
zeroclaw-labs:mainfrom
JuanMarchetto:feat/jupiter-swap-guard
Open

feat(plugins): jupiter-swap-guard — verified T1 Jupiter swap builder that binds the output to the payer#119
JuanMarchetto wants to merge 12 commits into
zeroclaw-labs:mainfrom
JuanMarchetto:feat/jupiter-swap-guard

Conversation

@JuanMarchetto

@JuanMarchetto JuanMarchetto commented Jul 21, 2026

Copy link
Copy Markdown

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 VersionedTransaction for 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 ≤3min demo video is the one remaining piece.

The guardrails — each maps to the exploit it excludes (see PROOFS.md)

  • D1 — positional destination binding. The Jupiter route's actual destination_token_account (at the discriminator's fixed ABI index — 3 for route, 6 for shared_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.
  • D2 — no smuggled fund movement. Top-level System and every top-level SPL Token / Token-2022 instruction are decoded, not trusted by program-id: Transfer/Approve/SetAuthority/Burn are refused, and a CloseAccount is allowed only when it returns to the payer — so a malicious response cannot drain the swapped tokens or the unwrapped SOL.
  • D3 — on-chain amount binding. The swap instruction's own in_amount / quoted_out_amount / slippage_bps are 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.
  • D4 — priority-fee cap. The decoded SetComputeUnitPrice fee is capped (no runaway-fee SOL drain).
  • D5 — static account roles. The payer and its ATAs must be static message keys, so a malicious RPC's lookup-table contents can never fill a fund role.
  • P1–P8 — mint allowlist, per-tx cap, max slippage, program allowlist, payer-only signer, and fail-closed config parsing (an unknown/misspelled key is a hard error) — enforced inside the plugin, so the model cannot talk its way past them.

Verification

  • Kani proves the encoder-integrity properties exhaustively (compact-u16 malleability P6, reader crash-safety P7): CI reports Complete - 2 successfully verified harnesses, 0 failures.
  • Byte-exact differential: the plugin's own parse → compile → serialize equals solana_message::v0::Message::try_compile on a real captured mainnet route (246-address ALT → 9 static keys + 1 lookup, 505 bytes). ATA derivation checked against real on-chain ATAs.
  • Positive + negative controls on the real fixture: the honest route passes every check; ~11 tampered variants (attacker destination, smuggled System/Token transfer, CloseAccount-to-attacker, over-cap fee, tampered on-chain min-out, over-authorized amount, unaccountable route, wrong cluster, …) are each refused with a specific reason. 46 host tests, no network.
  • The u128-division arithmetic guardrails (min-out floor, priority-fee ceil) are proptest-covered over the full domain — CBMC can't verify the divider tractably, and PROOFS.md says so plainly rather than overclaiming a proof.

Engineering / merge-readiness

  • Pure-core/thin-shim; crate-type = ["cdylib","rlib"]; layout matches plugins/redact-text; permissions http_client + config_read only.
  • No solana-sdk in the wasm build — hand-rolled compact-u16 + v0/ALT encoder; PDA/ATA via sha2 + curve25519-dalek (default-features=false, verified no js-sys in the wasm tree, which is a runtime landmine under wasip2). The solana-* crates are host-only dev-dependencies used purely as a byte-exact oracle.
  • CI parity green: wasm32-wasip2 release build, clippy -D warnings (host + wasm), cargo fmt --check, cargo test --locked (46, no network).
  • Full README (threat model incl. the load-bearing human-signer assumption + the CPI-not-enumerated caveat, custody mechanics, PR-25 compliance appendix, and a pt-BR section) and a signer guide (docs/sign-and-send.md: verify the decoded tx, not the chat).
  • The Kani workflow lives on a dedicated fork branch (ci-kani) by design, so this PR touches only plugins/jupiter-swap-guard/.

Two questions I'd love maintainer input on:

  1. Preferred home for a small shared Solana core (in-repo crates/ vs a published crate)?
  2. For a build-only T1 tool that returns an unsigned tx, is auto_approve on 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. 🦀

JuanMarchetto and others added 11 commits July 21, 2026 13:52
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
JuanMarchetto marked this pull request as ready for review July 21, 2026 21:59
@JuanMarchetto JuanMarchetto changed the title feat(plugins): jupiter-swap-guard — T1 verified Jupiter swap builder (WIP) feat(plugins): jupiter-swap-guard — verified T1 Jupiter swap builder that binds the output to the payer Jul 21, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant