protocol

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 payer
  • recipient (UncheckedAccount, mut) — stealth address receiving funds
  • mixer_state (mut, PDA) — pool state
  • vault (mut, PDA) — pool vault, signer via PDA seeds
  • nullifier_account (UncheckedAccount, mut) — created if not exists
  • system_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: CPI spl_token::transfer from vault ATA to recipient ATA
  • unshield_compressed: Uses seed "nullifier_c" instead of "nullifier", emits UnshieldCompressedEvent, saves ~0.000875 SOL per withdrawal (smaller PDA rent)