protocol

Shield

Depositing funds into the anonymity pool

Overview#

The shield instruction deposits SOL into the anonymity pool vault. The deposit creates a commitment that is inserted into the Poseidon Merkle tree as a leaf. The commitment binds a note (nullifier + secret) to a specific leaf position.

Shield Flow

Wallet

User's SOL wallet

Note

nullifier + secret

Commitment

Poseidon(nullifier, secret)

Merkle Tree

Inserted as leaf

Deposit Event

Emitted on-chain

Note Structure#

Each deposit creates a note composed of:

nullifier     = Poseidon(spendingKey, DOMAIN_SEPARATOR, counter)
nullifierHash = Poseidon(nullifier)                   ← public signal
leaf          = Poseidon(nullifier, secret)            ← Merkle leaf
commitment    = leaf                                   ← emitted in DepositEvent

| Component | Derivation | Visibility | |---|---|---| | spendingKey | Derived from wallet signature | Private | | DOMAIN_SEPARATOR | Poseidon(["mixer_nullifier"]) = 1965203435... | Public constant | | counter | Per-(key, denomination) counter from relayer | Private | | secret | Random 254-bit field element | Private | | nullifier | Poseidon(spendingKey, domain, counter) | Private | | nullifierHash | Poseidon(nullifier) | Public (in proof) | | commitment | Poseidon(nullifier, secret) | Public (in event) |

Commitment Collision

If two deposits produce the same commitment, the Merkle tree would have duplicate leaves, breaking the unlinkability guarantee. The counter ensures uniqueness per (key, denomination) pair. The random secret prevents cross-key collisions.

On-chain Instruction#

shield(denomination: u64, commitment: [u8; 32])

Accounts:

  • payer (Signer, mut) — the depositor
  • mixer_state (mut, PDA) — the pool state for this denomination
  • vault (mut, PDA, system-owned) — the pool's SOL vault
  • system_program

Logic:

  1. Validates denomination against allowed values (0.1, 1, 10 SOL)
  2. Derives and verifies mixer and vault PDA addresses
  3. Transfers exactly denomination lamports from payer to vault via system_program::transfer
  4. Increments mixer_state.total_shielded
  5. Emits DepositEvent { commitment, amount, denomination }

Relayer Processing#

After the shield transaction confirms:

  1. The relayer's connection.onLogs(PROGRAM_ID) callback fires
  2. parseDepositEventsFromLogs() extracts the DepositEvent discriminator SHA-256("event:DepositEvent")[0:8]
  3. The commitment is inserted into the relayer's in-memory Poseidon Merkle tree
  4. If pendingLeaves >= threshold, a multisig update_root transaction is submitted
  5. The /proof endpoint resolves for this commitment

Counter Management#

The counter is a nonce that ensures each note from a given spending key has a unique nullifier. It's managed by the relayer:

  • GET /next-counter?key=0x{keyHash}&denomination={value} increments and returns the counter
  • Persisted to relayer-counters.json on disk
  • Fallback: frontend uses localStorage counter mixer-counter-{pk}-{denom}

Token Shield Coming Soon#

For SPL tokens, shield_token follows the same pattern but uses CPI spl_token::transfer to move tokens from the payer's ATA to the vault's ATA. The MixerTokenState includes the token mint address. Token pool support is implemented in the Solana program but not yet available in the frontend.