feat(rust/mpp): confidential charges + solana 4.0 migration#181
feat(rust/mpp): confidential charges + solana 4.0 migration#181lgalabru wants to merge 23 commits into
Conversation
Add an opt-in `confidential` feature to solana-mpp implementing the Token-2022 confidential-transfer bundle for the charge intent: - protocol: CredentialPayload::Bundle; MethodDetails confidential / auditorElgamalPubkey / recipientElgamalPubkey; USDPT placeholder mint; validate_confidential_charge gate. - client: confidential transfer bundle builder — proof generation (proof-generation 0.6.1 / zk-sdk 7), verify-into-context-state accounts, spl-record range-proof staging, auditor handle, async-signer key derivation (modern KDF). Wired into the charge builder; fail-closed when the feature is off. - server: fail-closed Bundle arm + recover_amount_via_auditor primitive. - tests: litesvm proof-acceptance + auditor amount-recovery (728 pass). Migrate the workspace to the solana 4.0 client line (rpc-client and transaction-status 4.0, keychain rev allowing solana-signature 3.3.x, solana-transaction-status-client-types) so surfpool 1.4 / litesvm 0.13 resolve alongside the confidential proof crates.
…fication Wire production settlement of confidential-transfer bundles, using the recipient-key model (the gateway is the payee; it confirms the amount by decrypting what its own account received — no auditor key at the gateway). - recover_split_amount(key, lo, hi): generic ElGamal split-amount decryption primitive (replaces the auditor-specific helper; key-agnostic). - server: Config.recipient_signer + settle_confidential_bundle — submit the bundle txs sequentially, read the recipient's pending balance before/after, decrypt the delta with the recipient key, and require it equals the charge. - litesvm e2e (recipient_recovers_confidential_transfer_amount_in_litesvm): full on-chain lifecycle (CT mint, configure, deposit, apply-pending, transfer) proving recipient-side recovery; proofs verified inline (litesvm has no 1232-byte limit, so no spl-record needed).
…lement settle_confidential_bundle no longer requires recipient_signer. Two modes: - recipient_signer SET (gateway controls the payee): enforce the exact amount by decrypting the recipient's pending-balance delta with the recipient key. - recipient_signer ABSENT (facilitator settling to an arbitrary recipient): trust-proofs — submit the bundle and verify the transfer targets the recipient ATA; the on-chain ZK program guarantees proof validity and the recipient reconciles the amount out of band. Both modes now also structurally verify the final transfer's destination is the expected recipient ATA, so a bundle can't silently pay someone else.
…traint) Replaces the local-path litesvm [patch.crates-io] with the pushed fork branch (github.com/lgalabru/litesvm) while the upstream PR is open.
Greptile SummaryThis PR adds an opt-in
Confidence Score: 3/5The core bundle allow-list and destination checks are now pre-broadcast, but the The orphan sweeper's rust/crates/mpp/src/server/charge.rs — Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Client as Pay Client
participant GW as Gateway
participant RPC as Solana RPC
participant ZK as ZK ElGamal Proof
participant T22 as Token-2022
Client->>GW: "POST charge (confidential=true)"
GW-->>Client: "402 challenge (feePayerKey=gateway)"
Client->>RPC: fetch recipient ATA, mint CT config, sender CT account
Note over Client: derive ElGamal+AES keys, generate 3 split proofs, partial-sign
Client-->>GW: CredentialPayload::Bundle
loop each tx
Note over GW: allow-list check, fee-payer check, dest check
GW->>GW: co-sign gateway fee-payer slot
GW->>RPC: simulate + send_transaction
RPC->>ZK: VerifyEquality / VerifyValidity / VerifyRange
RPC->>T22: inner_transfer + close accounts
GW->>RPC: confirm_transaction
end
Note over GW: assert exactly 1 confidential transfer
alt recipient-key mode
GW->>RPC: getAccountInfo recipient ATA after
Note over GW: recover_split_amount, assert delta == amount
else facilitator mode
Note over GW: trust on-chain ZK proofs
end
GW->>GW: consume_signature (replay protection)
GW-->>Client: Receipt::success
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Client as Pay Client
participant GW as Gateway
participant RPC as Solana RPC
participant ZK as ZK ElGamal Proof
participant T22 as Token-2022
Client->>GW: "POST charge (confidential=true)"
GW-->>Client: "402 challenge (feePayerKey=gateway)"
Client->>RPC: fetch recipient ATA, mint CT config, sender CT account
Note over Client: derive ElGamal+AES keys, generate 3 split proofs, partial-sign
Client-->>GW: CredentialPayload::Bundle
loop each tx
Note over GW: allow-list check, fee-payer check, dest check
GW->>GW: co-sign gateway fee-payer slot
GW->>RPC: simulate + send_transaction
RPC->>ZK: VerifyEquality / VerifyValidity / VerifyRange
RPC->>T22: inner_transfer + close accounts
GW->>RPC: confirm_transaction
end
Note over GW: assert exactly 1 confidential transfer
alt recipient-key mode
GW->>RPC: getAccountInfo recipient ATA after
Note over GW: recover_split_amount, assert delta == amount
else facilitator mode
Note over GW: trust on-chain ZK proofs
end
GW->>GW: consume_signature (replay protection)
GW-->>Client: Receipt::success
|
…tion The recipient-key correction left validate_confidential_charge still *requiring* auditorElgamalPubkey, so the client would have rejected the gateway's (correct) no-auditor confidential challenge. The auditor is the mint issuer's optional compliance facility — not required for a charge — so only a present-but-empty value is now rejected. Removes the obsolete client test that asserted the old mandatory-auditor behavior (covered by the solana.rs unit test).
Adds docs/confidential-transfers.md: ElGamal/CT primitives, the account lifecycle, the proof bundle, the end-to-end pay-push-confidential flow, the two settlement modes (recipient-key vs facilitator trust-proofs), and repo layout — with mermaid diagrams.
Confidential bundles were client-paid, but clients have no SOL. Flip the model so the gateway is fee payer, rent funder, proof/record-account authority, and rent-reclaim destination for every bundle tx; the client only signs the transfer authority and the ephemeral account keypairs and leaves the fee-payer slot empty for the gateway to co-sign at settlement. Builder (client/confidential.rs): take the gateway feePayerKey from the challenge; partial-sign; route create_account/authority/close to the gateway. charge.rs requires feePayerKey for confidential charges. Settlement (server/charge.rs): hard-verify every bundle tx before co-signing — fee payer must be the gateway, instructions are allow-listed (ZK proof, spl-record, Token-2022, System create_account only, funded by the gateway and assigning to the ZK/record program), bundle size is bounded. Then co-sign the empty fee-payer slot, simulate, broadcast. Memo is disallowed (confidential charges reconcile by signature, not an on-chain order-id). Unit-tested the allow-list (accepts valid shapes; rejects System transfer, bad create_account owner, unknown program, wrong fee payer).
Gateway-paid bundles fund proof/record accounts that are closed in the final tx; a partial failure (e.g. blockhash expiry mid-bundle) orphans them with the gateway's rent locked inside. Add Mpp::sweep_confidential_orphans: scan the ZK proof + spl-record programs for accounts whose authority is the gateway (memcmp, zero-length data slice), and close them back to the gateway (it is their authority). Race-safe two-pass guard (store-backed): an account is closed only if it was already seen in a PRIOR sweep, so an in-flight settlement's transient accounts (created+closed within one bounded call) are never closed out from under it. Schedule with an interval comfortably larger than settlement latency. Unit-tests the guard (defer first sighting → confirm on next pass → reset after close).
Before generating the (expensive) transfer proofs, the bundle builder now checks: the recipient accepts confidential credits, the sender's account is approved, and the sender holds enough confidential balance. Previously the balance check ran AFTER proof generation, and a recipient that disallows credits would only fail on-chain. Decrypt the balance once and reuse it for the new decryptable-balance ciphertext.
Move the confidential worker run-loop into pay-kit behind an opt-in `worker` feature (= confidential + server; server already pulls tokio). Exposes spawn_confidential_worker(ConfidentialWorkerConfig, signer) -> ConfidentialHandle with ConfidentialHandle::settle(...) -> Result<Receipt, VerificationError>. The loop owns one shared store (consistent orphan guard + replay protection) and a resident fee-payer signer, serves settlement over an mpsc channel with oneshot replies, and runs the periodic orphan sweep on the same select! loop.
End-to-end against an embedded Surfnet: set up a Token-2022 confidential mint + funded sender + recipient (keys derived from the signers), then drive build_credential_header (client builds the partially-signed gateway-paid bundle) → Mpp::verify (gateway co-signs every tx, runs the instruction allow-list, submits the bundle, and confirms the amount by decrypting its own recipient balance). Real on-chain execution of the proofs + confidential transfer. Lifts client/confidential.rs line coverage 0% → 90%.
Refactor the confidential integration setup into a shared helper and add a second e2e that settles through the confidential worker run-loop (spawn_confidential_worker → ConfidentialHandle::settle), trust-proofs mode. Lifts server::confidential_worker.rs 0% → 94% and complements the direct test's recipient-key settlement branch.
Create gateway-owned proof-context + spl-record accounts (as a partially-failed bundle would strand) and confirm sweep_confidential_orphans defers on the first pass and closes them back to the gateway on the second. Covers the sweep scan + broadcast_close paths. pay-kit confidential codepaths now ≥90% line coverage.
| let final_tx = final_tx.ok_or_else(|| { | ||
| VerificationError::new("Confidential bundle produced no transactions") | ||
| })?; | ||
| let account_keys = final_tx.message.static_account_keys(); | ||
| let token_prog_idx = account_keys.iter().position(|k| k == &token_program); | ||
| let targets_recipient = token_prog_idx.is_some_and(|tp| { | ||
| final_tx.message.instructions().iter().any(|ix| { | ||
| ix.program_id_index as usize == tp | ||
| && ix | ||
| .accounts | ||
| .get(2) | ||
| .and_then(|i| account_keys.get(*i as usize)) | ||
| == Some(&recipient_ata) | ||
| }) | ||
| }); | ||
| if !targets_recipient { | ||
| return Err(VerificationError::credential_mismatch( | ||
| "Confidential transfer does not target the expected recipient account", | ||
| )); | ||
| } |
There was a problem hiding this comment.
Recipient-destination check runs after the final transaction is already broadcast
targets_recipient is evaluated after all bundle transactions — including the final inner_transfer — have been co-signed, broadcast, and confirmed. When a malicious client sets the confidential transfer destination to an attacker-controlled ATA, verify_confidential_bundle_tx passes (Token-2022 is in the allow-list, fee-payer check passes), the gateway co-signs, the transaction lands on-chain, and only then does this check return false. By that point the chain state cannot be reversed: the attacker's account holds the tokens and the gateway has already expended fee-payer lamports.
The check must be moved to before the gateway co-signs the final transaction. Concretely: in the submission loop, when processing the last transaction in the bundle, add a pre-signing recipient-destination validation step that inspects ix.program_id_index against token_program and ix.accounts.get(2) against recipient_ata — the same logic that currently runs in the post-loop block — and fail without signing if it does not match.
There was a problem hiding this comment.
Fixed in 6719f14: the recipient-destination check now runs inside verify_confidential_bundle_tx, per-tx in the submit loop BEFORE co-signing and broadcast — a wrong destination is rejected before anything lands. The post-broadcast block was removed.
| let targets_recipient = token_prog_idx.is_some_and(|tp| { | ||
| final_tx.message.instructions().iter().any(|ix| { | ||
| ix.program_id_index as usize == tp | ||
| && ix | ||
| .accounts | ||
| .get(2) | ||
| .and_then(|i| account_keys.get(*i as usize)) | ||
| == Some(&recipient_ata) | ||
| }) | ||
| }); |
There was a problem hiding this comment.
targets_recipient accepts any Token-2022 instruction at position 2 — not just inner_transfer
The check iterates over all Token-2022 instructions and succeeds if ANY of them has recipient_ata at account index 2. In facilitator mode (no recipient_signer), this is the only destination check. An attacker can include a dummy Token-2022 instruction (e.g., a plain transfer_checked of 1 lamport from attacker to recipient_ata, signed only by the attacker) alongside an inner_transfer to an attacker-controlled ATA; the dummy instruction satisfies the position-2 check, while the actual confidential transfer goes elsewhere. Since no amount-enforcement runs in facilitator mode, the gateway accepts the bundle.
The check should verify specifically the inner_transfer instruction by matching the Token-2022 instruction discriminant for confidential transfers, not just any instruction that happens to have recipient_ata at index 2.
There was a problem hiding this comment.
Fixed in 6719f14: the destination is now checked on the specific confidential-transfer instruction (discriminant-matched), not 'any Token-2022 ix with recipient_ata at index 2', and the bundle must contain exactly one confidential transfer — so a decoy 1-lamport transfer can't satisfy the check while the real transfer goes elsewhere.
- Tighten the Token-2022 allow-list to ONLY the confidential Transfer/ TransferWithFee opcode (CT extension 27, sub 7/13). The gateway co-signs each tx's fee-payer slot, which would otherwise authorise any Token-2022 op naming the gateway as signer (transfer_checked/burn/close) → token drain. - Validate the transfer destination BEFORE co-signing/broadcasting (in verify_confidential_bundle_tx), tied to the specific confidential-transfer instruction — not "any Token-2022 ix with recipient_ata at index 2", and not after the tx already landed. - Require exactly one confidential transfer per bundle (rejects a transfer-less bundle that would "settle" with no payment, and decoy/second transfers). - Worker: ConfidentialWorkerConfig gains recipient_signer so the worker can run recipient-key amount enforcement instead of silently always trust-proofs. Updates the allow-list unit test + the worker e2e (now recipient-key mode).
Avoids a duplicate 'entrypoint' symbol when a downstream test links the confidential deps together with surfpool's bundled programs on linux.
…vailable The confidential dep set forces a forked litesvm (zk-sdk 7 needs solana-address 2.5; no newer litesvm exists) which destabilizes surfpool's embedded validator in CI. Probe the cheatcode RPC after startup and skip (not fail) when it can't serve; where surfnet works (local/main) the tests run unchanged.
…d worker The sweeper is only used by the worker, and its solana-rpc-client-api dep shifted solana-signature's feature unification (DecodeError: !Error) in confidential-only consumers like the pay client. Move sweep_confidential_orphans / broadcast_close / ConfidentialSweepReport / the orphan guard + the dep from the confidential feature to worker, so confidential-only builds don't pull it.
…FPOOL_TESTS) Surfnet is unstable in CI with the confidential dep set's forked litesvm (serves briefly then dies mid-test), so these network/validator integration tests skip unless RUN_SURFPOOL_TESTS=1. Unit tests are unaffected. Run locally with the env set, where surfnet is stable.
Realign with the gateway-paid model + hardening: gateway is fee payer/funder/ authority and co-signs (client partially signs); per-tx allow-list + destination-before-co-sign + exactly-one-transfer; the confidential worker run-loop + orphan sweeper; fee is absorbed (no splits); client pre-flight. Fix the auditor description (it's OPTIONAL — mint-issuer facility, only a present-but-empty hint is rejected) and mark the auditor handle optional. Fix the §4.2 sequence-diagram mermaid parse error (drop ';' + '<br/>' from Notes). Refresh repo layout + dev shims (RUN_SURFPOOL_TESTS gate, five8_core std).
Summary
Adds an opt-in
confidentialfeature tosolana-mppimplementing Token-2022confidential transfers for the charge intent, and migrates the workspace to
the solana 4.0 client line.
Confidential transfers
CredentialPayload::Bundle(multi-tx);MethodDetailsconfidential/auditorElgamalPubkey/recipientElgamalPubkey;validate_confidential_chargegate; USDPT placeholder mint.(
proof-generation 0.6.1/zk-sdk 7), verify-into-context-state accounts,spl-recordrange-proof staging, async-signer key derivation (modern KDF).settle_confidential_bundlewith two modes —recipient-key (gateway controls the payee → enforces the exact amount by
decrypting its own pending-balance delta) and facilitator trust-proofs
(submit + verify the transfer targets the recipient; the on-chain ZK program
guarantees proof validity; the recipient reconciles the amount out of band).
recover_split_amount— the shared ElGamal split-amount decryption primitive.solana 4.0 migration
Moves
rpc-client/transaction-statusto 4.0 (interface crates stay 3.x),bumps the
solana-keychainrev to one allowingsolana-signature3.3.x, andswitches to
solana-transaction-status-client-types(its parent gates the libbehind
agave-unstable-api). Enablessurfpool 1.4/litesvm 0.13.Tests
cargo test -p solana-mpp --features confidential→ 729 pass, includinglitesvm end-to-end tests: the ZK ElGamal Proof program accepts our generated
proofs, and a full on-chain lifecycle (mint → configure → deposit →
apply-pending → transfer) confirms the recipient recovers the exact amount with
its own key.
Note (dev shim)
The
litesvm[patch.crates-io]points at a fork branch(
lgalabru/litesvm@loosen-solana-address-constraint) that loosens an overlystrict
solana-address =2.2.0pin so litesvm 0.13 can coexist with theconfidential proof crates. Pending the upstream litesvm PR.
🤖 Generated with Claude Code