Skip to main content

  1. The required property for nullifiers is that they can not be created twice.
  2. On Solana, you typically would create a PDA account. Nullifier accounts must remain active, hence lock ~0.001 SOL in rent per nullifier PDA permanently.
  3. Light uses rent-free PDAs to track nullifiers in an address Merkle tree.
  4. The address tree is the nullifier set and indexed by Helius. You don’t need to index your own Merkle tree.
StorageCost per nullifier
PDA~0.001 SOL
Compressed PDA~0.000005 SOL
You need an additional ZK proof to create the compressed PDA and a CPI to the Light system program. If you’re already generating a ZK proof for your application logic, the marginal cost of the extra proof is low.

Implementation Guide

This is the complete flow of how nullifiers are used in zk applications.
┌─────────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│ User Secret │ --> │ ZK Circuit  │ --> │ Groth16     │ --> │ On-chain    │
│ + Merkle    │     │             │     │ Proof       │     │ Verifier    │
│   Proof     │     │             │     │             │     │             │
└─────────────┘     └─────────────┘     └─────────────┘     └─────────────┘
       ↑                                                            │
       │                                                            ↓
┌─────────────┐                                             ┌─────────────┐
│ Indexer     │ <------------------------------------------ │ Nullifier   │
│             │                                             │ Account     │
└─────────────┘                                             └─────────────┘
Find a full code example at the end for single & batched nullifier creation.
1

Client computes the nullifier

The nullifier combines a context (e.g., verification_id) with the user’s secret:
fn compute_nullifier(
    verification_id: &[u8; 32],
    secret: &[u8; 32],
) -> [u8; 32] {
    Poseidon::hashv(&[verification_id, secret]).unwrap()
}
2

Prove correct derivation in the ZK circuit

The circuit constrains that the public nullifier matches the hash of private inputs:
pragma circom 2.0.0;

include "../node_modules/circomlib/circuits/poseidon.circom";

// Single nullifier: proves nullifier = Poseidon(verification_id, secret)
template Nullifier() {
    signal input verification_id;
    signal input nullifier;
    signal input secret;

    component hasher = Poseidon(2);
    hasher.inputs[0] <== verification_id;
    hasher.inputs[1] <== secret;
    nullifier === hasher.out;
}

// Batch nullifier: proves n nullifiers with single proof
template BatchNullifier(n) {
    signal input verification_id;
    signal input nullifier[n];
    signal input secret[n];

    component nullifiers[n];
    for (var i = 0; i < n; i++) {
        nullifiers[i] = Nullifier();
        nullifiers[i].verification_id <== verification_id;
        nullifiers[i].nullifier <== nullifier[i];
        nullifiers[i].secret <== secret[i];
    }
}
pragma circom 2.0.0;

include "./nullifier.circom";

component main { public [verification_id, nullifier] } = Nullifier();
3

Derive address from nullifier on-chain

The program derives a deterministic address from the nullifier:
let (address, address_seed) = derive_address(
    &[
        NULLIFIER_PREFIX,               // prefix
        nullifier.as_slice(),           // nullifier hash
        verification_id.as_slice(),     // context
    ],
    &address_tree_pubkey,
    &crate::ID,
);
4

Create account at that address

Create a compressed account at the derived address:
let nullifier_account = LightAccount::<NullifierAccount>::new_init(
    &crate::ID,
    Some(address),
    output_state_tree_index,
);
The nullifier now prevents double spending:
  1. Same secret + same context = same nullifier
  2. Same nullifier = same derived address
  3. Address already exists = transaction fails

Full Code Example

A minimal Solana program to create one or four nullifiers. Uses Groth16 proofs and compressed accounts.
See the full implementation at program-examples/zk/zk-nullifiers.
#![allow(unexpected_cfgs)]
#![allow(deprecated)]

use anchor_lang::prelude::*;
use borsh::{BorshDeserialize, BorshSerialize};
use groth16_solana::groth16::Groth16Verifier;
use light_sdk::account::LightAccount;
use light_sdk::cpi::v1::CpiAccounts;
use light_sdk::{
    address::v2::derive_address,
    cpi::{v1::LightSystemProgramCpi, InvokeLightSystemProgram, LightCpiInstruction},
    derive_light_cpi_signer,
    instruction::{CompressedProof, PackedAddressTreeInfo, ValidityProof},
    LightDiscriminator,
};
use light_sdk_types::CpiSigner;

declare_id!("NuL1fiErPRoCxidvVji4t8T5XvZBBdN5w1GWYxPxpJk");

pub const LIGHT_CPI_SIGNER: CpiSigner =
    derive_light_cpi_signer!("NuL1fiErPRoCxidvVji4t8T5XvZBBdN5w1GWYxPxpJk");

pub const NULLIFIER_PREFIX: &[u8] = b"nullifier";

// Max nullifiers per tx: 1 (single) or 4 (batch)
pub const BATCH_SIZE: usize = 4;

pub mod nullifier_1;
pub mod nullifier_batch_4;

#[program]
pub mod zk_nullifier {
    use groth16_solana::decompression::{decompress_g1, decompress_g2};

    use super::*;

    /// Creates 1 nullifier
    pub fn create_nullifier<'info>(
        ctx: Context<'_, '_, '_, 'info, CreateNullifierAccounts<'info>>,
        proof: ValidityProof,
        address_tree_info: PackedAddressTreeInfo,
        output_state_tree_index: u8,
        zk_proof: CompressedProof,
        verification_id: [u8; 32],
        nullifier: [u8; 32],
    ) -> Result<()> {
        let light_cpi_accounts = CpiAccounts::new(
            ctx.accounts.signer.as_ref(),
            ctx.remaining_accounts,
            crate::LIGHT_CPI_SIGNER,
        );

        let address_tree_pubkey = address_tree_info
            .get_tree_pubkey(&light_cpi_accounts)
            .map_err(|_| ErrorCode::AccountNotEnoughKeys)?;

        if address_tree_pubkey.to_bytes() != light_sdk::constants::ADDRESS_TREE_V2 {
            return Err(ProgramError::InvalidAccountData.into());
        }

        let public_inputs: [[u8; 32]; 2] = [verification_id, nullifier];

        let proof_a = decompress_g1(&zk_proof.a).map_err(|e| {
            let code: u32 = e.into();
            Error::from(ProgramError::Custom(code))
        })?;

        let proof_b = decompress_g2(&zk_proof.b).map_err(|e| {
            let code: u32 = e.into();
            Error::from(ProgramError::Custom(code))
        })?;

        let proof_c = decompress_g1(&zk_proof.c).map_err(|e| {
            let code: u32 = e.into();
            Error::from(ProgramError::Custom(code))
        })?;

        let mut verifier = Groth16Verifier::new(
            &proof_a,
            &proof_b,
            &proof_c,
            &public_inputs,
            &crate::nullifier_1::VERIFYINGKEY,
        )
        .map_err(|e| {
            let code: u32 = e.into();
            Error::from(ProgramError::Custom(code))
        })?;

        verifier.verify().map_err(|e| {
            let code: u32 = e.into();
            Error::from(ProgramError::Custom(code))
        })?;

        let (address, address_seed) = derive_address(
            &[
                NULLIFIER_PREFIX,
                nullifier.as_slice(),
                verification_id.as_slice(),
            ],
            &address_tree_pubkey,
            &crate::ID,
        );

        let nullifier_account = LightAccount::<NullifierAccount>::new_init(
            &crate::ID,
            Some(address),
            output_state_tree_index,
        );

        LightSystemProgramCpi::new_cpi(LIGHT_CPI_SIGNER, proof)
            .with_light_account(nullifier_account)?
            .with_new_addresses(&[address_tree_info.into_new_address_params_packed(address_seed)])
            .invoke(light_cpi_accounts)?;

        Ok(())
    }

    /// Creates 4 nullifiers with single proof
    pub fn create_batch_nullifier<'info>(
        ctx: Context<'_, '_, '_, 'info, CreateNullifierAccounts<'info>>,
        proof: ValidityProof,
        address_tree_infos: [PackedAddressTreeInfo; BATCH_SIZE],
        output_state_tree_index: u8,
        zk_proof: CompressedProof,
        verification_id: [u8; 32],
        nullifiers: [[u8; 32]; BATCH_SIZE],
    ) -> Result<()> {
        let light_cpi_accounts = CpiAccounts::new(
            ctx.accounts.signer.as_ref(),
            ctx.remaining_accounts,
            crate::LIGHT_CPI_SIGNER,
        );

        let address_tree_pubkey = address_tree_infos[0]
            .get_tree_pubkey(&light_cpi_accounts)
            .map_err(|_| ErrorCode::AccountNotEnoughKeys)?;

        if address_tree_pubkey.to_bytes() != light_sdk::constants::ADDRESS_TREE_V2 {
            return Err(ProgramError::InvalidAccountData.into());
        }

        // 5 public inputs: verification_id + 4 nullifiers
        let public_inputs: [[u8; 32]; 5] = [
            verification_id,
            nullifiers[0],
            nullifiers[1],
            nullifiers[2],
            nullifiers[3],
        ];

        let proof_a = decompress_g1(&zk_proof.a).map_err(|e| {
            let code: u32 = e.into();
            Error::from(ProgramError::Custom(code))
        })?;

        let proof_b = decompress_g2(&zk_proof.b).map_err(|e| {
            let code: u32 = e.into();
            Error::from(ProgramError::Custom(code))
        })?;

        let proof_c = decompress_g1(&zk_proof.c).map_err(|e| {
            let code: u32 = e.into();
            Error::from(ProgramError::Custom(code))
        })?;

        let mut verifier = Groth16Verifier::new(
            &proof_a,
            &proof_b,
            &proof_c,
            &public_inputs,
            &crate::nullifier_batch_4::VERIFYINGKEY,
        )
        .map_err(|e| {
            let code: u32 = e.into();
            Error::from(ProgramError::Custom(code))
        })?;

        verifier.verify().map_err(|e| {
            let code: u32 = e.into();
            Error::from(ProgramError::Custom(code))
        })?;

        // Create 4 nullifier accounts
        let mut cpi_builder = LightSystemProgramCpi::new_cpi(LIGHT_CPI_SIGNER, proof);
        let mut new_address_params = Vec::with_capacity(BATCH_SIZE);

        for i in 0..BATCH_SIZE {
            let (address, address_seed) = derive_address(
                &[
                    NULLIFIER_PREFIX,
                    nullifiers[i].as_slice(),
                    verification_id.as_slice(),
                ],
                &address_tree_pubkey,
                &crate::ID,
            );

            let nullifier_account = LightAccount::<NullifierAccount>::new_init(
                &crate::ID,
                Some(address),
                output_state_tree_index,
            );

            cpi_builder = cpi_builder.with_light_account(nullifier_account)?;
            new_address_params
                .push(address_tree_infos[i].into_new_address_params_packed(address_seed));
        }

        cpi_builder
            .with_new_addresses(&new_address_params)
            .invoke(light_cpi_accounts)?;

        Ok(())
    }
}

#[derive(Accounts)]
pub struct CreateNullifierAccounts<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,
}

#[derive(Clone, Debug, Default, BorshSerialize, BorshDeserialize, LightDiscriminator)]
pub struct NullifierAccount {}

#[error_code]
pub enum ErrorCode {
    #[msg("Not enough keys in remaining accounts")]
    AccountNotEnoughKeys,
}

Next Steps

Create Compressed Accounts for ZK Applications