| name | zk |
| description | For custom ZK Solana programs and privacy-preserving applications to prevent double spending. Guide to integrate rent-free nullifier PDAs for double-spend prevention. |
| metadata | {"source":"https://github.com/Lightprotocol/skills","documentation":"https://www.zkcompression.com","openclaw":{"requires":{"env":[],"bins":["node","cargo","light"]}}} |
ZK Nullifiers
Overview
Building a ZK Solana program requires:
- Nullifiers to prevent double spending
- Proof verification
- A Merkle tree to store state
- An indexer to serve Merkle proofs
- Encrypted state
For non-ZK applications that use nullifiers, see the compressed-pda skill.
Workflow
- Clarify intent
- Recommend plan mode, if it's not activated
- Use
AskUserQuestion to resolve blind spots
- All questions must be resolved before execution
- Identify references and skills
- Match task to resources below
- Locate relevant documentation and examples
- Write plan file (YAML task format)
- Use
AskUserQuestion for anything unclear — never guess or assume
- Identify blockers: permissions, dependencies, unknowns
- Plan must be complete before execution begins
- Execute
- Use
Task tool with subagents for parallel research
- Subagents load skills via
Skill tool
- Track progress with
TodoWrite
- When stuck: ask to spawn a read-only subagent with
Read, Glob, Grep, and DeepWiki MCP access, loading skills/ask-mcp. Scope reads to skill references, example repos, and docs.
Nullifiers on Solana
A nullifier is a deterministically derived hash to ensure an action can only be performed once. The nullifier cannot be linked to the action or user. For example Zcash uses nullifiers to prevent double spending.
To implement nullifiers we need a data structure that ensures every nullifier is only created once and never deleted. On Solana a straight forward way to implement nullifiers is to create a PDA account with the nullifier as seed.
PDA accounts cannot be closed and permanently lock 890,880 lamports (per nullifier rent-exemption).
Compressed PDAs are derived similar to Solana PDAs and cost 15,000 lamports to create (no rent-exemption).
| Storage | Cost per nullifier |
|---|
| PDA | 890,880 lamports |
| Compressed PDA | 15,000 lamports |
Testing
cargo test-sbf -p nullifier
light test-validator
npm run test:ts
Pattern Overview
1. Client computes nullifier = hash(secret, context)
2. Client fetches validity proof for derived address (proves it does not exist)
3. Client calls create_nullifier with nullifier values and proof
4. Program derives address from nullifier, creates compressed account via CPI
5. Light system program rejects CPI if address already exists
Resources
Reference Implementation
Source: program-examples/zk/nullifier
Account Structure
#[derive(Clone, Debug, Default, BorshSerialize, BorshDeserialize, LightDiscriminator)]
pub struct NullifierAccount {}
Empty struct since existence alone proves the nullifier was used.
Address Derivation
pub const NULLIFIER_PREFIX: &[u8] = b"nullifier";
let (address, address_seed) = derive_address(
&[NULLIFIER_PREFIX, nullifier.as_slice()],
&address_tree_pubkey,
&program_id,
);
Address is deterministically derived from:
- Constant prefix (prevents collisions with other account types)
- Nullifier value (32 bytes)
- Address tree pubkey
- Program ID
Instruction Data
#[derive(Clone, Debug, AnchorSerialize, AnchorDeserialize)]
pub struct NullifierInstructionData {
pub proof: ValidityProof,
pub address_tree_info: PackedAddressTreeInfo,
pub output_state_tree_index: u8,
pub system_accounts_offset: u8,
}
Create Nullifiers Function
pub fn create_nullifiers<'info>(
nullifiers: &[[u8; 32]],
data: NullifierInstructionData,
signer: &AccountInfo<'info>,
remaining_accounts: &[AccountInfo<'info>],
) -> Result<()> {
let light_cpi_accounts = CpiAccounts::new(
signer,
&remaining_accounts[data.system_accounts_offset as usize..],
LIGHT_CPI_SIGNER,
);
let address_tree_pubkey = data
.address_tree_info
.get_tree_pubkey(&light_cpi_accounts)
.map_err(|_| ErrorCode::AccountNotEnoughKeys)?;
let mut cpi_builder = LightSystemProgramCpi::new_cpi(LIGHT_CPI_SIGNER, data.proof);
let mut new_address_params: Vec<NewAddressParamsAssignedPacked> =
Vec::with_capacity(nullifiers.len());
for (i, nullifier) in nullifiers.iter().enumerate() {
let (address, address_seed) = derive_address(
&[NULLIFIER_PREFIX, nullifier.as_slice()],
&address_tree_pubkey,
&crate::ID,
);
let nullifier_account = LightAccount::<NullifierAccount>::new_init(
&crate::ID,
Some(address),
data.output_state_tree_index,
);
cpi_builder = cpi_builder.with_light_account(nullifier_account)?;
new_address_params.push(
data.address_tree_info
.into_new_address_params_assigned_packed(address_seed, Some(i as u8)),
);
}
cpi_builder
.with_new_addresses(&new_address_params)
.invoke(light_cpi_accounts)?;
Ok(())
}
Program Entry Point
#[program]
pub mod nullifier {
pub fn create_nullifier<'info>(
ctx: Context<'_, '_, '_, 'info, CreateNullifierAccounts<'info>>,
data: NullifierInstructionData,
nullifiers: Vec<[u8; 32]>,
) -> Result<()> {
create_nullifiers(
&nullifiers,
data,
ctx.accounts.signer.as_ref(),
ctx.remaining_accounts,
)
}
}
#[derive(Accounts)]
pub struct CreateNullifierAccounts<'info> {
#[account(mut)]
pub signer: Signer<'info>,
}
Client Implementation (TypeScript)
const NULLIFIER_PREFIX = Buffer.from("nullifier");
const addressTree = new web3.PublicKey(batchAddressTree);
const addressesWithTree = nullifiers.map((nullifier) => {
const seed = deriveAddressSeedV2([NULLIFIER_PREFIX, nullifier]);
const address = deriveAddressV2(seed, addressTree, programId);
return { tree: addressTree, queue: addressTree, address: bn(address.toBytes()) };
});
const proofResult = await rpc.getValidityProofV0([], addressesWithTree);
const remainingAccounts = new PackedAccounts();
remainingAccounts.addSystemAccountsV2(SystemAccountMetaConfig.new(programId));
const addressMerkleTreeIndex = remainingAccounts.insertOrGet(addressTree);
const outputStateTreeIndex = remainingAccounts.insertOrGet(outputStateTree);
const data = {
proof: { 0: proofResult.compressedProof },
addressTreeInfo: {
addressMerkleTreePubkeyIndex: addressMerkleTreeIndex,
addressQueuePubkeyIndex: addressMerkleTreeIndex,
rootIndex: proofResult.rootIndices[0],
},
outputStateTreeIndex,
systemAccountsOffset: systemStart,
};
const ix = await program.methods
.createNullifier(data, nullifiers.map((n) => Array.from(n)))
.accounts({ signer: signer.publicKey })
.remainingAccounts(remainingAccounts)
.instruction();
Client Implementation (Rust)
use light_sdk::address::v2::derive_address;
let address_tree_info = rpc.get_address_tree_v2();
let address_with_trees: Vec<AddressWithTree> = nullifiers
.iter()
.map(|n| {
let (address, _) = derive_address(
&[NULLIFIER_PREFIX, n.as_slice()],
&address_tree_info.tree,
&program_id,
);
AddressWithTree {
address,
tree: address_tree_info.tree,
}
})
.collect();
let rpc_result = rpc
.get_validity_proof(vec![], address_with_trees, None)
.await?
.value;
let mut remaining_accounts = PackedAccounts::default();
let config = SystemAccountMetaConfig::new(program_id);
remaining_accounts.add_system_accounts_v2(config)?;
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)?;
SDK references
Security
This skill provides code patterns and documentation references only.
- No credentials consumed. The skill requires no API keys, private keys, or signing secrets.
env: [] is declared explicitly.
- Subagent scope. When stuck, the skill asks to spawn a read-only subagent with
Read, Glob, Grep scoped to skill references, example repos, and docs.
- Install source.
npx skills add Lightprotocol/skills from Lightprotocol/skills.
- Audited protocol. Light Protocol smart contracts are independently audited. Reports are published at github.com/Lightprotocol/light-protocol/tree/main/audits.