Unshield
ZK-proof based withdrawal from the anonymity pool
Overview#
The unshield instruction withdraws funds from the pool vault by presenting a Groth16 proof of Merkle membership. The proof is verified on-chain via Solana's alt_bn128 pairing syscalls. The withdrawal creates a nullifier PDA that marks the note as spent.
Instruction#
unshield(
proof_a: [u8; 64], // G1 point
proof_b: [u8; 128], // G2 point
proof_c: [u8; 64], // G1 point
public_inputs: [[u8; 32]; 4],
denomination: u64,
)
Accounts:
payer(Signer, mut) — transaction fee payerrecipient(UncheckedAccount, mut) — stealth address receiving fundsmixer_state(mut, PDA) — pool statevault(mut, PDA) — pool vault, signer via PDA seedsnullifier_account(UncheckedAccount, mut) — created if not existssystem_program
Verification Flow#
1. Validate public_inputs[3] == denomination_to_field(denomination)
2. Validate public_inputs[0] == mixer_state.merkle_root
3. Validate public_inputs[2] == recipient.key().to_bytes()
4. Call verifier::verify_groth16_proof(proof, vk, public_inputs)
a. Linear combination: vk_x = IC[0] + sum(pub_i * IC[i+1])
b. Negate A: -A_y mod BN254 modulus
c. Pairing check: e(-A, B) * e(α, β) * e(vk_x, γ) * e(C, δ) == 1
5. Derive nullifier PDA: ["nullifier", nullifier_hash]
6. Check nullifier account is empty (not spent)
7. Create nullifier account via invoke_signed
8. Transfer denomination from vault to recipient via PDA signer
Nullifier Reuse
Reusing a nullifier hash is prevented by the on-chain check. If the nullifier PDA already exists, the transaction is rejected with NullifierAlreadySpent (error 0x1779). This is critical for double-spend prevention.
Proof Packing#
The Groth16 proof must be packed into Solana-compatible byte arrays:
G1 points (64 bytes): 32-byte X coordinate, 32-byte Y coordinate — big-endian field elements
G2 points (128 bytes): Two Fp2 elements, each with real and imaginary parts. The ordering follows alt_bn128_pairing expectations (re, im order), which differs from snarkjs's output (im, re). The packG2 function handles this swap:
// snarkjs returns [im, re]; Solana expects [re, im]
point[idx][i] → point[idx][1 - i]
Public Signal Verification#
The on-chain program checks three of the four public signals:
| Index | Signal | Constraint |
|---|---|---|
| 0 | merkle_root | Must equal mixer_state.merkle_root |
| 1 | nullifier_hash | Used to derive nullifier PDA |
| 2 | recipient | Must equal recipient.key().to_bytes() |
| 3 | denomination | Must equal current pool's denomination |
The fourth signal (nullifier_hash) is constrained by the circuit but not checked explicitly on-chain — the nullifier PDA derivation serves as the implicit constraint.
Compute Budget#
The unshield proof verification requires significant compute:
ComputeBudgetProgram.setComputeUnitLimit({ units: 700_000 })
This is higher than typical Solana transactions due to the four alt_bn128 pairing operations.
Token Unshield#
unshield_token and unshield_compressed follow the same verification path but use:
unshield_token: CPIspl_token::transferfrom vault ATA to recipient ATAunshield_compressed: Uses seed"nullifier_c"instead of"nullifier", emitsUnshieldCompressedEvent, saves ~0.000875 SOL per withdrawal (smaller PDA rent)