Shielded pool internals
The native Groth16 verifier, the incremental Poseidon tree, the nullifier set, the recent-roots window, and the enforced construction rules.
The shielded pool is embedded into the chain executor as a builtin — a
transaction whose single instruction targets POOL_PROGRAM_ID
(svmchain-shielded-pool-builtin!!) is intercepted by the Node and executed
directly and atomically. It is not a deployed program. Shielded lamports are
custodied by the POOL_VAULT account.
The circuit proves a transfer is valid; this state machine is what prevents double-spends and value theft. Get the nullifier/commitment bookkeeping wrong and perfect proofs still drain the pool.
The verifier: real, native (no mock)
Proofs are verified with solana-bn254's alt_bn128_pairing_be /
alt_bn128_g1_addition_be / alt_bn128_g1_multiplication_be. On a non-Solana
target these run the exact arithmetic the sol_alt_bn128_* syscalls dispatch
to, so the builtin verifier is bit-identical to what a BPF program would compute
— a real verifier, not a shape-checking mock.
- Encoding: EIP-197 / big-endian. G1 =
x‖y(64 B), G2 =x_c1‖x_c0‖y_c1‖y_c0(128 B), scalars 32-byte BE. - Groth16 check:
e(-A,B)·e(α,β)·e(vk_x,γ)·e(C,δ) == 1, withvk_x = IC[0] + Σ pub_i·IC[i+1]. - The verifying key is embedded from the committed
verification_key.json(dev-grade — see Trusted setup). - Poseidon uses
solana-poseidonBn254X5(the circom-compatible parameterization thesol_poseidonsyscall wraps); a test asserts it equalscircomlibjs poseidon([1,2])and that the empty-tree root matches the client.
BPF verifier path is pending
A verifier deployed as a BPF program would call the same functions, which then
dispatch to the real syscalls — that path is not exercised here (litesvm BPF
alt_bn128/poseidon syscall availability is unverified). It is on the
real-validator debt list. The native builtin path that ships is fully real and
tested.
Incremental tree & recent-roots window
Output commitments are appended to an incremental Poseidon(2) tree of depth 20
at monotonically increasing indices. The pool keeps a recent-roots window of
N = 30 (Tornado's ROOT_HISTORY_SIZE): a proof built against a root up to 29
insertions stale still verifies while the tree grows underneath it. A
recent-but-not-latest root is accepted; an unknown root is rejected.
The construction rules enforced on-chain
The circuit leaves five rules to the contract; the pool enforces each:
- Dummy-input nullifiers — the pool cannot tell a dummy (
amount == 0) from a real input, so nullifiers are handled uniformly: both nullifiers in a tx must be distinct from each other and absent from the spent-set. - Nullifier uniqueness — an on-chain spent-set; any tx whose input nullifier already exists is rejected (the core double-spend defense).
- Commitment uniqueness / append-only — a commitment that already exists is rejected, and the two outputs in a tx must differ.
- Shield XOR unshield — shield ⇒
pubIn>0 ∧ pubOut==0; transfer ⇒pubIn==0 ∧ pubOut==0; unshield ⇒pubIn==0 ∧ pubOut>0. - Fee binding —
feeis a public signal bound by the proof. Shield and transfer requirefee == 0; an unshield may carry a fee, routed to the bound relayer (see Relayer).
Recipient / relayer binding (nPublic 12)
The unshield recipient and relayer are bound in the proof, so a third
party cannot resubmit a valid unshield with a different destination or steal the
fee. The circuit has 12 public signals; the pool reconstructs recipientHi/Lo and
relayerHi/Lo from the instruction and Groth16 verification forces them to match
— no separate check needed. See Reference → Public signals.
Instruction wire format (505 bytes)
[tag u8] 1=shield 2=transfer 3=unshield
[proof 256] A(64) ‖ B(128, EIP-197) ‖ C(64)
[root 32]
[nullifier0 32][nullifier1 32]
[outCommitment0 32][outCommitment1 32]
[publicAmountIn u64 LE][publicAmountOut u64 LE][fee u64 LE]
[recipient 32] (unshield target; zero otherwise — bound in proof)
[relayer 32] (fee recipient; zero if none — bound in proof)The Node reconstructs the 12 public signals from these fields (never from a separate client blob) and verifies the proof against them — binding the proof to the exact nullifiers it spends, commitments it inserts, root it checks, amounts it moves, and destination it pays.
Value invariant
POOL_VAULT.lamports == pool_shielded_in_total − pool_shielded_out_total at all
times. Shield debits the caller and credits the vault; unshield debits the vault
by publicAmountOut + fee and credits the recipient publicAmountOut and the
relayer fee; transfer moves nothing external. Nothing enters or leaves the vault
without a verified proof.