Nullifier Tracking
Prevent users from reusing the same World ID proof with nullifier tracking.
This is part of the Verification Strategy for World ID Bridge integration.
Understanding Nullifiers
When someone generates a World ID proof for your app, they also generate a nullifier - basically a unique hash that acts as a fingerprint for that specific proof. Here's what makes nullifiers useful:
- Each person gets a different nullifier for the same action
- The same person gets different nullifiers for different actions
- The same person doing the same action always produces the same nullifier
- You can't trace a nullifier back to someone's identity
This means when someone tries to use the same proof twice, you'll see the exact same nullifier and can reject the duplicate attempt.
Security Implications
Without proper nullifier tracking, your application faces significant vulnerabilities. Replay attacks allow malicious users to reuse valid proofs multiple times, potentially enabling multiple votes, reward claims, or access attempts. This fundamentally undermines World ID's sybil resistance guarantees and can compromise fair distribution mechanisms in token distributions, voting systems, or access controls.
Implementation
Implementing nullifier tracking requires minimal changes to your existing contract structure:
use starknet::ContractAddress;
use starknet::storage::*;
#[starknet::interface]
pub trait IGroth16VerifierBN254<TContractState> {
fn verify_groth16_proof_bn254(
self: @TContractState,
full_proof_with_hints: Span<felt252>
) -> Option<Span<u256>>;
}
#[starknet::contract]
pub mod MyApp {
use starknet::ContractAddress;
use starknet::storage::*;
use super::{IGroth16VerifierBN254Dispatcher, IGroth16VerifierBN254DispatcherTrait};
#[storage]
pub struct Storage {
world_id_bridge: ContractAddress,
used_nullifiers: Map<u256, bool>, // Track used nullifiers
// Your existing storage fields...
}
#[constructor]
fn constructor(ref self: ContractState, world_id_bridge_address: ContractAddress) {
self.world_id_bridge.write(world_id_bridge_address);
}
#[generate_trait]
impl InternalFunctions of InternalFunctionsTrait {
fn _verify_human(ref self: ContractState, proof: Span<felt252>) -> bool {
let world_id = IGroth16VerifierBN254Dispatcher {
contract_address: self.world_id_bridge.read()
};
match world_id.verify_groth16_proof_bn254(proof) {
Option::Some(public_inputs) => {
let nullifier = *public_inputs.at(1);
if self.used_nullifiers.entry(nullifier).read() {
return false; // Prevent replay attack
}
self.used_nullifiers.entry(nullifier).write(true);
true
},
Option::None => false
}
}
}
#[abi(embed_v0)]
impl MyAppImpl of super::IMyApp<ContractState> {
fn claim_reward(ref self: ContractState, proof: Span<felt252>) -> bool {
if self._verify_human(proof) {
// Execute verified action
true
} else {
false
}
}
}
}The implementation requires three components. A storage mapping to track used nullifiers, extraction of the nullifier from proof verification results, and validation logic that checks for previous use before marking the nullifier as consumed.
For production applications requiring sophisticated error handling and monitoring capabilities, see Error Handling.