Skip to main content

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_NullifierAlreadySpent reverts. If a relay failed with selector 0x66f527f1, 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_call per 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.