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.