Skip to main content

Verify a payment receipt

What this lets you do

Take a payment receipt JSON (produced by generate a receipt) and prove independently, without trusting the issuer, that the claimed payment landed on-chain at the claimed pool, for the claimed amount, addressed to the claimed recipient.

This is the operation an accountant, auditor, or compliance reviewer runs. It's the receiving end of the selective-disclosure contract.

The four checks

  1. Parse and shape. The JSON has all required fields with the right types (hex strings, lengths, addresses).
  2. noteAddressHash recomputes. Poseidon(ownerAddress, noteSecret) equals the claimed noteAddressHash.
  3. Commitment recomputes. Poseidon(Poseidon(noteAddressHash, tokenId, value, 0x0), label) equals the claimed commitment.
  4. Commitment is on-chain. PoolVault.commitments(commitment) returns a non-zero timestamp at the receipt's pool.poolAddress on the receipt's chainId.

When all four checks pass, the payment is proven. If any check fails, the receipt is forged, corrupt, or was issued for a different deployment.

Constraints & limits

  • Trust the chain, not the receipt. Check that the receipt's pool.poolAddress matches a known Privacy Pools deployment for the claimed chain, because otherwise an attacker could point you at a fake pool they control.
  • No private keys required. Verification is entirely public, so any RPC endpoint works, and no SDK config is needed beyond the contract address.
  • Doesn't prove ownership or non-repudiation. A receipt only proves that this note exists at this commitment, with this value, addressed to this address. It doesn't prove the sender meant this particular payment to be the receipt's referent. If that distinction matters legally, you need a separate signing layer.
  • Only proves existence, not unspent-ness. This is by design: a receipt answers "did the payment happen," not "are the funds still there." Pair it with verify spent status if you also need to know the note hasn't been moved.

What it unlocks next

  • Build an auditor portal, like the one included in the Payroll PoC app.
  • Run automated compliance pipelines that parse receipts as they come in, batch-verify them against the chain, and emit accounting entries.
  • Treat receipts as an API: any internal tool that needs to answer "did X pay Y this amount?" can drive off receipt JSON without holding wallet keys.

How to use it

import { PoseidonHashService } from "@privacy-pools-v2/sdk";
import { createPublicClient, http } from "viem";
import { sepolia } from "viem/chains";

// Minimal read-only ABI fragment that mirrors the SDK-internal POOL_VAULT_ABI,
// which isn't value-importable from the package entrypoint.
const POOL_VAULT_ABI = [
{
type: "function",
name: "commitments",
inputs: [{ name: "_commitmentHash", type: "uint256" }],
outputs: [{ name: "createdAt_", type: "uint256" }],
stateMutability: "view",
},
] as const;

async function verifyReceipt(receipt) {
// 1. Shape: parse with a Zod schema or hand-rolled checks.

// 2. noteAddressHash recomputes.
const hash = await PoseidonHashService.create();
const computedNAH = hash.hash([receipt.note.ownerAddress, receipt.note.noteSecret]);
if (BigInt(computedNAH) !== BigInt(receipt.note.noteAddressHash))
throw new Error("noteAddressHash mismatch");

// 3. Commitment recomputes.
const precommitment = hash.hash([
receipt.note.noteAddressHash,
receipt.note.tokenId,
receipt.note.value,
"0x0",
]);
const commitment = hash.hash([precommitment, receipt.note.label]);
if (BigInt(commitment) !== BigInt(receipt.note.commitment))
throw new Error("commitment mismatch");

// 4. On-chain.
const client = createPublicClient({ chain: sepolia, transport: http(rpcUrl) });
const timestamp = await client.readContract({
address: receipt.pool.poolAddress,
abi: POOL_VAULT_ABI,
functionName: "commitments",
args: [BigInt(commitment)],
});
if (timestamp === 0n) throw new Error("commitment not on-chain");

return { verified: true, timestamp };
}

Worked example. The receipt export and audit guide walks through a complete browser-based verifier: drop in a receipt JSON, the four checks run, and you get an all-green result or the exact failure.

Behind the scenes

SDK utility PoseidonHashService.create() and hash.hash([...])
Contract method PoolVault.commitments(uint256), a free view function
No session needed A public RPC endpoint and the SDK's hash service are sufficient. Don't build a full PoolSessionBuilder for this, since it pulls in a lot of unneeded surface.