Receipt export and auditor verification
End-to-end: send a private payment, export a receipt as a JSON file, hand it to an auditor, auditor verifies independently against the chain.
Step 1: Send the payment and capture the receipt data
const toHex = (value: bigint): `0x${string}` => `0x${value.toString(16)}`;
const amount = parseUnits("0.5", 6); // 0.5 USDC (bigint)
const prepared = await session.prepareTransfer({
inputCommitments: [noteToSpend.commitment],
amount: toHex(amount),
tokenId: USDC_SEPOLIA,
recipientDiscoveryData: { evmAddress: recipient },
});
const recipientPending = prepared.executeOptions.recipientPendingNotes[0];
const result = await session.relayTransfer(prepared.relayOptions[0]);
const receipt = {
type: "privacy-pools-v2-payment-receipt",
version: "1",
pool: { chainId: 11155111, poolAddress, tokenId: USDC_SEPOLIA },
payment: {
fromSenderWallet: address,
toRecipientWallet: recipient,
amount: toHex(amount),
amountFormatted: "0.5 USDC",
txHash: result.txReceipt.txHash,
explorerUrl: `https://sepolia.etherscan.io/tx/${result.txReceipt.txHash}`,
},
note: {
commitment: recipientPending.commitment,
value: recipientPending.value,
tokenId: recipientPending.tokenId,
label: recipientPending.label,
ownerAddress: recipientPending.ownerAddress ?? recipient,
noteSecret: recipientPending.noteSecret,
noteAddressHash: recipientPending.noteAddressHash,
},
verification: {
commitmentFormula: [
"noteAddressHash = Poseidon(note.ownerAddress, note.noteSecret)",
"precommitment = Poseidon(noteAddressHash, note.tokenId, note.value, 0x0)",
"commitment = Poseidon(precommitment, note.label)",
],
onChainCheck: "PoolVault.commitments(commitment) returns non-zero",
},
};
await persistReceipt(receipt);
Step 2: Hand the JSON to the auditor
The receipt is a single self-contained JSON file, with no URLs, API keys, or external dependencies to manage. It does carry the note's noteSecret, which reveals the payment to anyone who reads it, so deliver it over a secure channel and never post it publicly.
Frontend transport. The JSON above is the inner payload, not necessarily the surface a user shares. The v2 frontend encodes the same receipt into a base64url string and hands it off through a route rather than as a loose file: an /import-note/{encoded} link for the recipient, and a /verify-style audit route for the auditor. The encoded link and the JSON file carry the same data, so an auditor can verify either one.
Step 3: Auditor verifies, client-side
The auditor pastes / drops the JSON into a verifier page (see the Payroll PoC's /audit route for a working example). The verifier runs four checks:
// 1. Parse + shape validation
const r = ReceiptSchema.parse(jsonBlob);
// 2. Recompute noteAddressHash locally
const hash = await PoseidonHashService.create();
const computedNAH = hash.hash([r.note.ownerAddress, r.note.noteSecret]);
assert(BigInt(computedNAH) === BigInt(r.note.noteAddressHash));
// 3. Recompute commitment locally
const pre = hash.hash([r.note.noteAddressHash, r.note.tokenId, r.note.value, "0x0"]);
const commitment = hash.hash([pre, r.note.label]);
assert(BigInt(commitment) === BigInt(r.note.commitment));
// 4. Confirm the commitment is on-chain at the claimed pool
const client = createPublicClient({ chain: sepolia, transport: http(rpcUrl) });
const ts = await client.readContract({
address: r.pool.poolAddress,
abi: POOL_VAULT_ABI,
functionName: "commitments",
args: [BigInt(r.note.commitment)],
});
assert(ts !== 0n);
When all four checks pass, the payment is proven. The auditor learned nothing about any other transaction in the sender's wallet.
What this gives an accountant
- An auditable record per payment without invasive viewing-key disclosure.
- Independence: they verify against the chain, not against the sender's claims.
- Selectivity: one receipt covers exactly one payment. No other activity exposed.
For the cryptographic background, see Selective disclosure. For an implementation example, see the Payroll PoC source (apps/payroll-server/src/routes/proof-submit.ts for the export endpoint and apps/payroll-client/app/audit/page.tsx for the verifier UI).