Verify a note's spent status on-chain
What this lets you do
Ask the chain directly whether a specific note has been spent, bypassing the local NoteManager entirely. The pool stores each spent note's nullifier in PoolVault.spentNullifiers, and a non-zero return means "spent."
When to use it
- Before picking inputs to spend. Local state can drift after failed or partial flows. Always cross-check.
- To diagnose
PoolVault_NullifierAlreadySpentreverts. If a relay failed with selector0x66f527f1, this confirms the nullifier was already on-chain. - For receipt verification. Spent vs. unspent is part of payment-status answers, even though the canonical receipt only proves "the note exists on-chain."
Constraints & limits
- You need the note's nullifier, not its commitment. Derive:
nullifier = Poseidon(privateNullifyingKey, commitment). - One
eth_callper note. Batching depends on your RPC's multicall support. - This is a chain read: free, but it counts against your RPC's rate limit. Rather than running it for every note on every render, use it at decision points.
What it unlocks next
- Input picking that survives state drift: filter candidate notes by their on-chain spent status before selecting one, so a stale local note never gets picked.
- Key-free payment confirmation: a service can confirm a note was spent from just its nullifier, since this is a public read that needs no private keys.
How to use it
import { PoseidonHashService, type Note, type Secret } from "@privacy-pools-v2/sdk";
// Minimal ABI for the spentNullifiers read (not exported from the SDK entry point).
const POOL_VAULT_ABI = [
{
type: "function",
name: "spentNullifiers",
inputs: [{ name: "_nullifierHash", type: "uint256" }],
outputs: [{ name: "when_", type: "uint256" }],
stateMutability: "view",
},
] as const;
const hash = await PoseidonHashService.create();
// For each note you're considering spending:
async function isNoteSpent(note: Note, privateNullifyingKey: Secret) {
const nullifier = hash.hash([privateNullifyingKey, note.commitment]);
const result = await publicClient.readContract({
address: poolAddress,
abi: POOL_VAULT_ABI,
functionName: "spentNullifiers",
args: [BigInt(nullifier)],
});
return result !== 0n;
}
// Chain-verified input picker.
async function pickUnspentNoteForSpend(candidates: Note[], privateNullifyingKey: Secret, amount: bigint) {
for (const candidate of candidates) {
if (BigInt(candidate.value) < amount) continue;
if (await isNoteSpent(candidate, privateNullifyingKey)) continue;
return candidate;
}
return null;
}
Check the chain before spending. Local note state can lag after a partial flow. Check the selected note's nullifier on-chain before using it as an input, and skip the note if the nullifier is already spent.
Behind the scenes
Contract method
PoolVault.spentNullifiers(uint256 nullifier) (returns uint256)Nullifier formula
Poseidon(privateNullifyingKey, commitment)SDK helper No direct one-liner in the SDK. You build it from
PoseidonHashService and publicClient.readContract.