Your service asks a user to prove something — that they hold enough, that they paid an invoice. This page is the checking side, in a backend or a script.
Attestations
The user sends you a transaction signature. The proof was already verified on-chain; you are establishing that the transaction is what it claims to be.
1. Issue a scope
Give each request its own wording and keep it:
import { textToFr } from "@zksvm/sdk";
const wording = `example.org login ${crypto.randomUUID()}`;
const scope = textToFr(wording); // sha256, reduced into the field
A fresh value per request is what stops an old attestation being presented again.
2. Read the transaction
import { parseAttestedEvents, frFromBytes } from "@zksvm/sdk";
const tx = await conn.getTransaction(signature, { maxSupportedTransactionVersion: 0 });
if (!tx || tx.meta?.err) throw new Error("no such successful transaction");
const [event] = parseAttestedEvents(tx.meta.logMessages ?? [], PROGRAM_ID);
if (!event) throw new Error("not an attestation by this pool");
parseAttestedEvents only returns events that were logged while your pool
program was executing. Any program can print bytes that look like an event;
the parser follows the invoke lines so that those do not count. Always pass the
program id you trust.
3. Decide
const ok =
frFromBytes(event.scope) === scope &&
event.threshold >= required &&
(tx.blockTime ?? 0) > issuedAt;
const holder = frFromBytes(event.tag); // stable per key, per scope
Store holder if one person should qualify once. It is a pseudonym: the same
key under the same scope always produces it, and it means nothing under any
other scope.
Receipts
The user sends you a receipt: a proof plus what it states. Nothing is on-chain, so all three checks are yours.
import { verifyReceipt, addressPk } from "@zksvm/sdk";
import verifyingKey from "./receipt.vkey.json"; // circuits/build/receipt/verification_key.json
// 1. The proof is sound.
if (!(await verifyReceipt(receipt, verifyingKey))) throw new Error("bad proof");
// 2. It is about the recipient you expect.
if (receipt.pk !== (await addressPk(myAddress.toBytes()))) throw new Error("wrong recipient");
// 3. The note is in the pool.
if (!leaves.has(receipt.commitment)) throw new Error("no such note");
For the second check, derive pk the way the payment was addressed:
| Paid to | pk |
|---|---|
a zks… address | its first 32 bytes, as a field element |
| a Solana address | addressPk(address) |
| a Solana address, returnably | returnablePk(address, refundPk, notBefore) — the receipt carries the last two |
For the third, keep the set of commitments from NoteAdded events, as any
pool mirror does.
Then apply your own rules: receipt.minAmount >= invoice.total,
receipt.context === invoice.reference.
What you have established
That a note paying your address at least that much exists in the pool, made out for that purpose. Either party to a payment can write its receipt, so it identifies the payment, not the presenter — match it to an invoice reference you issued, and accept each commitment once.
For a returnable payment, wait until it has been redeemed before treating it as settled: until then it can still go back to its sender.
Concepts: Attestations and receipts.