- The required property for nullifiers is that they can not be created twice.
- 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.
- Light uses rent-free PDAs to track nullifiers in an address Merkle tree.
- The address tree is the nullifier set and indexed by Helius. You don’t need to index your own Merkle tree.
| Storage | Cost 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.Report incorrect code
Copy
Ask AI
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 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.
- Guide
- Circuit Example
- Get started with AI
1
Client computes the nullifier
The nullifier combines a context (e.g.,verification_id) with the user’s secret:Report incorrect code
Copy
Ask AI
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:Report incorrect code
Copy
Ask AI
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];
}
}
Report incorrect code
Copy
Ask AI
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:Report incorrect code
Copy
Ask AI
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:Report incorrect code
Copy
Ask AI
let nullifier_account = LightAccount::<NullifierAccount>::new_init(
&crate::ID,
Some(address),
output_state_tree_index,
);
- Same secret + same context = same nullifier
- Same nullifier = same derived address
- Address already exists = transaction fails
This circuit example constrains that the public nullifier matches the hash of private inputs:
Report incorrect code
Copy
Ask AI
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];
}
}
Report incorrect code
Copy
Ask AI
pragma circom 2.0.0;
include "./nullifier.circom";
component main { public [verification_id, nullifier] } = Nullifier();
For AI assistance with your ZK App, copy this prompt and add your design ideas:
Report incorrect code
Copy
Ask AI
---
argument-hint: <add_your_app_description>
description: Design a ZK App POC with rent-free nullifiers, compressed accounts, and Groth16 circuits
allowed-tools: [Bash, Read, Glob, Grep, Task, WebFetch]
---
Design a Solana program with tests that uses rent-free nullifiers, compressed accounts, and Groth16 circuits.
## Initial App Design
<ADD YOUR IDEA AND DESIGN HERE>
## Goal
Produce a **fully working POC** that builds and tests pass.
## Available commands
Via Bash tool:
- `cargo build-sbf`, `cargo test-sbf`, `cargo fmt`, `cargo clippy`
- `anchor build`, `anchor test`, `anchor deploy`
- `circom`, `snarkjs`, `solana`, `light`
## Documentation
- Nullifiers: https://zkcompression.com/zk/nullifiers
- Compressed Accounts with Poseidon Hashes: https://zkcompression.com/zk/compressed-account-zk
## Reference repos
program-examples/zk/zk-id/
├── programs/zk-id/src/
│ ├── lib.rs # create_issuer, add_credential, zk_verify_credential
│ └── verifying_key.rs # Groth16 key from circom trusted setup
├── circuits/
│ └── compressed_account_merkle_proof.circom # Merkle proof + nullifier circuit
└── tests/
└── zk-id.ts # Proof generation + on-chain verification
## Workflow
### Phase 1: Design application
**1.1 Define private state**
What data stays private? (credentials, balances, votes, etc.)
**1.2 Define public inputs**
What does the circuit prove publicly? (nullifier, merkle root, commitments)
**1.3 Define nullifier scheme**
nullifier = Poseidon(context, secret)
### Phase 2: Index reference implementation
grep -r "LightAccountPoseidon" program-examples/zk/
grep -r "Groth16Verifier" program-examples/zk/
grep -r "derive_address.*nullifier" program-examples/zk/
grep -r "read_state_merkle_tree_root" program-examples/zk/
Read matching files to understand patterns.
### Phase 3: Circuit development
**3.1 Write circom circuit**
Based on compressed_account_merkle_proof.circom:
- Merkle proof verification
- Nullifier computation
- Public input constraints
**3.2 Trusted setup**
circom circuit.circom --r1cs --wasm --sym
snarkjs groth16 setup circuit.r1cs pot_final.ptau circuit_0000.zkey
snarkjs zkey export verificationkey circuit_final.zkey verification_key.json
snarkjs zkey export solidityverifier circuit_final.zkey # adapt for Solana
**3.3 Add sensitive files to .gitignore**
*.zkey
*.ptau
*.r1cs
*_js/
### Phase 4: Program implementation
| Pattern | Function | Reference |
|---------|----------|-----------|
| Poseidon state | `LightAccountPoseidon::new_init()` | zk-id/lib.rs |
| Nullifier address | `derive_address([prefix, nullifier, ctx], tree, program)` | zk-id/lib.rs |
| Read root only | `read_state_merkle_tree_root()` | zk-id/lib.rs |
| Groth16 verify | `Groth16Verifier::new().verify()` | zk-id/lib.rs |
**Dependencies:**
[dependencies]
anchor-lang = "0.31.1"
light-sdk = { version = "0.17.1", features = ["anchor", "poseidon", "merkle-tree", "v2"] }
light-hasher = "5.0.0"
light-sdk-types = { version = "0.17.1", features = ["v2"] }
groth16-solana = { git = "https://github.com/Lightprotocol/groth16-solana", rev = "66c0dc87" }
[dev-dependencies]
light-program-test = "0.17.1"
light-client = "0.17.1"
### Phase 5: Build and test loop
**Required commands (no shortcuts):**
For Anchor programs: `anchor build && anchor test`
For Native programs: `cargo build-sbf && cargo test-sbf`
**NO shortcuts allowed:**
- Do NOT use `cargo build` (must use `cargo build-sbf`)
- Do NOT use `cargo test` (must use `cargo test-sbf`)
- Do NOT skip SBF compilation
- Tests MUST run against real BPF bytecode
**On failure:** Spawn debugger agent with error context.
**Loop rules:**
1. Each debugger gets fresh context + previous debug reports
2. Each attempt tries something DIFFERENT
3. **NEVER GIVE UP** - keep spawning until fixed
Do NOT proceed until all tests pass.
### Phase 6: Cleanup (only after tests pass)
rm -rf target/
## DeepWiki fallback
If no matching pattern in reference repos:
mcp__deepwiki__ask_question("Lightprotocol/light-protocol", "How to {operation}?")
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.
- Program
- Single nullifier test
- Batch nullifier test
Report incorrect code
Copy
Ask AI
#![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,
}
Report incorrect code
Copy
Ask AI
use anchor_lang::{InstructionData, ToAccountMetas};
use circom_prover::{prover::ProofLib, witness::WitnessFn, CircomProver};
use groth16_solana::proof_parser::circom_prover::{convert_proof, convert_proof_to_compressed};
use light_hasher::{Hasher, Poseidon};
use light_program_test::{
program_test::LightProgramTest, utils::simulate_cu, AddressWithTree, Indexer, ProgramTestConfig,
Rpc, RpcError,
};
use light_sdk::{
address::v2::derive_address,
instruction::{PackedAccounts, SystemAccountMetaConfig},
};
use num_bigint::BigUint;
use solana_sdk::{
instruction::Instruction,
pubkey::Pubkey,
signature::{Keypair, Signer},
};
use std::collections::HashMap;
use zk_nullifier::NULLIFIER_PREFIX;
#[link(name = "circuit_single", kind = "static")]
extern "C" {}
rust_witness::witness!(nullifier);
#[tokio::test]
async fn test_create_nullifier() {
let config = ProgramTestConfig::new(true, Some(vec![("zk_nullifier", zk_nullifier::ID)]));
let mut rpc = LightProgramTest::new(config).await.unwrap();
let payer = rpc.get_payer().insecure_clone();
let address_tree_info = rpc.get_address_tree_v2();
let secret = generate_random_secret();
let verification_id = Pubkey::new_unique().to_bytes();
let nullifier = compute_nullifier(&verification_id, &secret);
let (nullifier_address, _) = derive_address(
&[
NULLIFIER_PREFIX,
nullifier.as_slice(),
verification_id.as_slice(),
],
&address_tree_info.tree,
&zk_nullifier::ID,
);
let instruction = build_create_nullifier_instruction(
&mut rpc,
&payer,
&nullifier_address,
address_tree_info.clone(),
&verification_id,
&nullifier,
&secret,
)
.await
.unwrap();
let cu = simulate_cu(&mut rpc, &payer, &instruction).await;
println!("=== Single nullifier CU: {} ===", cu);
rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer])
.await
.unwrap();
let nullifier_accounts = rpc
.get_compressed_accounts_by_owner(&zk_nullifier::ID, None, None)
.await
.unwrap();
assert_eq!(nullifier_accounts.value.items.len(), 1);
// Duplicate should fail
let dup_instruction = build_create_nullifier_instruction(
&mut rpc,
&payer,
&nullifier_address,
address_tree_info,
&verification_id,
&nullifier,
&secret,
)
.await
.unwrap();
let result = rpc
.create_and_send_transaction(&[dup_instruction], &payer.pubkey(), &[&payer])
.await;
assert!(result.is_err());
}
fn generate_random_secret() -> [u8; 32] {
let random_keypair = Keypair::new();
let mut secret = [0u8; 32];
secret[1..32].copy_from_slice(&random_keypair.to_bytes()[0..31]);
secret
}
fn compute_nullifier(verification_id: &[u8; 32], secret: &[u8; 32]) -> [u8; 32] {
Poseidon::hashv(&[verification_id, secret]).unwrap()
}
async fn build_create_nullifier_instruction<R>(
rpc: &mut R,
payer: &Keypair,
address: &[u8; 32],
address_tree_info: light_client::indexer::TreeInfo,
verification_id: &[u8; 32],
nullifier: &[u8; 32],
secret: &[u8; 32],
) -> Result<Instruction, RpcError>
where
R: Rpc + Indexer,
{
let mut remaining_accounts = PackedAccounts::default();
let config = SystemAccountMetaConfig::new(zk_nullifier::ID);
remaining_accounts.add_system_accounts(config)?;
let rpc_result = rpc
.get_validity_proof(
vec![],
vec![AddressWithTree {
address: *address,
tree: address_tree_info.tree,
}],
None,
)
.await?
.value;
let packed_address_tree_accounts = rpc_result
.pack_tree_infos(&mut remaining_accounts)
.address_trees;
let output_state_tree_index = rpc
.get_random_state_tree_info()?
.pack_output_tree_index(&mut remaining_accounts)?;
let zk_proof = generate_zk_proof(verification_id, nullifier, secret);
let instruction_data = zk_nullifier::instruction::CreateNullifier {
proof: rpc_result.proof,
address_tree_info: packed_address_tree_accounts[0],
output_state_tree_index,
zk_proof,
verification_id: *verification_id,
nullifier: *nullifier,
};
let accounts = zk_nullifier::accounts::CreateNullifierAccounts {
signer: payer.pubkey(),
};
Ok(Instruction {
program_id: zk_nullifier::ID,
accounts: [
accounts.to_account_metas(None),
remaining_accounts.to_account_metas().0,
]
.concat(),
data: instruction_data.data(),
})
}
fn generate_zk_proof(
verification_id: &[u8; 32],
nullifier: &[u8; 32],
secret: &[u8; 32],
) -> light_compressed_account::instruction_data::compressed_proof::CompressedProof {
let zkey_path = "./build/nullifier_final.zkey".to_string();
let mut proof_inputs = HashMap::new();
proof_inputs.insert(
"verification_id".to_string(),
vec![BigUint::from_bytes_be(verification_id).to_string()],
);
proof_inputs.insert(
"nullifier".to_string(),
vec![BigUint::from_bytes_be(nullifier).to_string()],
);
proof_inputs.insert(
"secret".to_string(),
vec![BigUint::from_bytes_be(secret).to_string()],
);
let circuit_inputs = serde_json::to_string(&proof_inputs).unwrap();
let proof = CircomProver::prove(
ProofLib::Arkworks,
WitnessFn::RustWitness(nullifier_witness),
circuit_inputs,
zkey_path.clone(),
)
.expect("Proof generation failed");
let is_valid = CircomProver::verify(ProofLib::Arkworks, proof.clone(), zkey_path)
.expect("Proof verification failed");
assert!(is_valid);
compress_proof(&proof.proof)
}
fn compress_proof(
proof: &circom_prover::prover::circom::Proof,
) -> light_compressed_account::instruction_data::compressed_proof::CompressedProof {
let (proof_a_uncompressed, proof_b_uncompressed, proof_c_uncompressed) =
convert_proof(proof).expect("Failed to convert proof");
let (proof_a, proof_b, proof_c) = convert_proof_to_compressed(
&proof_a_uncompressed,
&proof_b_uncompressed,
&proof_c_uncompressed,
)
.expect("Failed to compress proof");
light_compressed_account::instruction_data::compressed_proof::CompressedProof {
a: proof_a,
b: proof_b,
c: proof_c,
}
}
Report incorrect code
Copy
Ask AI
use anchor_lang::{InstructionData, ToAccountMetas};
use circom_prover::{prover::ProofLib, witness::WitnessFn, CircomProver};
use groth16_solana::proof_parser::circom_prover::{convert_proof, convert_proof_to_compressed};
use light_hasher::{Hasher, Poseidon};
use light_program_test::{
program_test::LightProgramTest, utils::simulate_cu, AddressWithTree, Indexer, ProgramTestConfig,
Rpc, RpcError,
};
use light_sdk::{
address::v2::derive_address,
instruction::{PackedAccounts, SystemAccountMetaConfig},
};
use num_bigint::BigUint;
use solana_sdk::{
instruction::Instruction,
pubkey::Pubkey,
signature::{Keypair, Signer},
};
use std::collections::HashMap;
use zk_nullifier::{BATCH_SIZE, NULLIFIER_PREFIX};
#[link(name = "circuit_batch", kind = "static")]
extern "C" {}
rust_witness::witness!(batchnullifier);
#[tokio::test]
async fn test_create_batch_nullifier() {
let config = ProgramTestConfig::new(true, Some(vec![("zk_nullifier", zk_nullifier::ID)]));
let mut rpc = LightProgramTest::new(config).await.unwrap();
let payer = rpc.get_payer().insecure_clone();
let address_tree_info = rpc.get_address_tree_v2();
let secrets: [[u8; 32]; BATCH_SIZE] = [
generate_random_secret(),
generate_random_secret(),
generate_random_secret(),
generate_random_secret(),
];
let verification_id = Pubkey::new_unique().to_bytes();
let nullifiers: [[u8; 32]; BATCH_SIZE] = [
compute_nullifier(&verification_id, &secrets[0]),
compute_nullifier(&verification_id, &secrets[1]),
compute_nullifier(&verification_id, &secrets[2]),
compute_nullifier(&verification_id, &secrets[3]),
];
let mut addresses = Vec::with_capacity(BATCH_SIZE);
for i in 0..BATCH_SIZE {
let (addr, _) = derive_address(
&[
NULLIFIER_PREFIX,
nullifiers[i].as_slice(),
verification_id.as_slice(),
],
&address_tree_info.tree,
&zk_nullifier::ID,
);
addresses.push(addr);
}
let instruction = build_create_batch_nullifier_instruction(
&mut rpc,
&payer,
&addresses,
address_tree_info.clone(),
&verification_id,
&nullifiers,
&secrets,
)
.await
.unwrap();
let cu = simulate_cu(&mut rpc, &payer, &instruction).await;
println!("=== Batch (4 nullifiers) CU: {} ===", cu);
println!("=== CU per nullifier (batch): {} ===", cu / 4);
rpc.create_and_send_transaction(&[instruction], &payer.pubkey(), &[&payer])
.await
.unwrap();
let nullifier_accounts = rpc
.get_compressed_accounts_by_owner(&zk_nullifier::ID, None, None)
.await
.unwrap();
assert_eq!(nullifier_accounts.value.items.len(), BATCH_SIZE);
// Duplicate batch should fail
let dup_instruction = build_create_batch_nullifier_instruction(
&mut rpc,
&payer,
&addresses,
address_tree_info,
&verification_id,
&nullifiers,
&secrets,
)
.await
.unwrap();
let result = rpc
.create_and_send_transaction(&[dup_instruction], &payer.pubkey(), &[&payer])
.await;
assert!(result.is_err());
}
fn generate_random_secret() -> [u8; 32] {
let random_keypair = Keypair::new();
let mut secret = [0u8; 32];
secret[1..32].copy_from_slice(&random_keypair.to_bytes()[0..31]);
secret
}
fn compute_nullifier(verification_id: &[u8; 32], secret: &[u8; 32]) -> [u8; 32] {
Poseidon::hashv(&[verification_id, secret]).unwrap()
}
async fn build_create_batch_nullifier_instruction<R>(
rpc: &mut R,
payer: &Keypair,
addresses: &[[u8; 32]],
address_tree_info: light_client::indexer::TreeInfo,
verification_id: &[u8; 32],
nullifiers: &[[u8; 32]; BATCH_SIZE],
secrets: &[[u8; 32]; BATCH_SIZE],
) -> Result<Instruction, RpcError>
where
R: Rpc + Indexer,
{
let mut remaining_accounts = PackedAccounts::default();
let config = SystemAccountMetaConfig::new(zk_nullifier::ID);
remaining_accounts.add_system_accounts(config)?;
let address_with_trees: Vec<AddressWithTree> = addresses
.iter()
.map(|addr| AddressWithTree {
address: *addr,
tree: address_tree_info.tree,
})
.collect();
let rpc_result = rpc
.get_validity_proof(vec![], address_with_trees, None)
.await?
.value;
let packed_address_tree_accounts = rpc_result
.pack_tree_infos(&mut remaining_accounts)
.address_trees;
let output_state_tree_index = rpc
.get_random_state_tree_info()?
.pack_output_tree_index(&mut remaining_accounts)?;
let zk_proof = generate_batch_zk_proof(verification_id, nullifiers, secrets);
let address_tree_infos: [_; BATCH_SIZE] = [
packed_address_tree_accounts[0],
packed_address_tree_accounts[1],
packed_address_tree_accounts[2],
packed_address_tree_accounts[3],
];
let instruction_data = zk_nullifier::instruction::CreateBatchNullifier {
proof: rpc_result.proof,
address_tree_infos,
output_state_tree_index,
zk_proof,
verification_id: *verification_id,
nullifiers: *nullifiers,
};
let accounts = zk_nullifier::accounts::CreateNullifierAccounts {
signer: payer.pubkey(),
};
Ok(Instruction {
program_id: zk_nullifier::ID,
accounts: [
accounts.to_account_metas(None),
remaining_accounts.to_account_metas().0,
]
.concat(),
data: instruction_data.data(),
})
}
fn generate_batch_zk_proof(
verification_id: &[u8; 32],
nullifiers: &[[u8; 32]; BATCH_SIZE],
secrets: &[[u8; 32]; BATCH_SIZE],
) -> light_compressed_account::instruction_data::compressed_proof::CompressedProof {
let zkey_path = "./build/batchnullifier_final.zkey".to_string();
let mut proof_inputs = HashMap::new();
proof_inputs.insert(
"verification_id".to_string(),
vec![BigUint::from_bytes_be(verification_id).to_string()],
);
let nullifier_strings: Vec<String> = nullifiers
.iter()
.map(|n| BigUint::from_bytes_be(n).to_string())
.collect();
proof_inputs.insert("nullifier".to_string(), nullifier_strings);
let secret_strings: Vec<String> = secrets
.iter()
.map(|s| BigUint::from_bytes_be(s).to_string())
.collect();
proof_inputs.insert("secret".to_string(), secret_strings);
let circuit_inputs = serde_json::to_string(&proof_inputs).unwrap();
let proof = CircomProver::prove(
ProofLib::Arkworks,
WitnessFn::RustWitness(batchnullifier_witness),
circuit_inputs,
zkey_path.clone(),
)
.expect("Proof generation failed");
let is_valid = CircomProver::verify(ProofLib::Arkworks, proof.clone(), zkey_path)
.expect("Proof verification failed");
assert!(is_valid);
compress_proof(&proof.proof)
}
fn compress_proof(
proof: &circom_prover::prover::circom::Proof,
) -> light_compressed_account::instruction_data::compressed_proof::CompressedProof {
let (proof_a_uncompressed, proof_b_uncompressed, proof_c_uncompressed) =
convert_proof(proof).expect("Failed to convert proof");
let (proof_a, proof_b, proof_c) = convert_proof_to_compressed(
&proof_a_uncompressed,
&proof_b_uncompressed,
&proof_c_uncompressed,
)
.expect("Failed to compress proof");
light_compressed_account::instruction_data::compressed_proof::CompressedProof {
a: proof_a,
b: proof_b,
c: proof_c,
}
}