Two ways to say something true about shielded value without opening it: one on-chain, one off.
Attestations
The statement
A note in this pool, unspent right now, holds at least threshold lamports.
Not which note, how much more, or whose.
Why it is not a membership proof
The obvious circuit — "I know a note in the tree with amount ≥ threshold" — is wrong, and instructively so. The tree is append-only: a note spent last year is still a leaf. Whether a note is unspent lives in the nullifier records, which are Solana accounts, not something a circuit can read. A pure membership proof would let anyone who once held 100 SOL prove it forever.
So attest spends the note and recreates it in one transaction:
in: note(amount, pk, blinding) nullifier goes on record
out: note(amount, pk, fresh blinding) same owner, same amount
The circuit enforces that the output is the same asset and amount under the
same key, and that amount >= threshold. The nullifier being accepted is
the proof of unspent-ness — the system program would have refused it
otherwise. The holder's balance does not change.
The holder ID
The public inputs include
tag = Poseidon2(s, scope)
where s is the holder's spending key and scope is a field element chosen
by the verifier — in the app, the hash of the wording. Within one scope the
same key always yields the same tag; across scopes tags are unlinkable. A
verifier can recognize a returning holder without learning who they are, and
two verifiers cannot compare.
A verifier should put a nonce in the scope. Without one, an old attestation can be presented again.
Reading one
The program emits
Attested { scope, tag, threshold }
and a verifier needs only the transaction: that it succeeded, and that the
event was logged while the pool program was the one executing. The SDK's
parseAttestedEvents follows the invoke lines for that reason — any program
can print bytes that look like an event. On-chain, another program calls
attest by CPI and takes success as the answer.
What it is not
It is not a way to count funds. The tag is a pseudonym of a key: value moved to a fresh key attests again under a fresh tag. Weighting a vote by shielded balance needs funds locked for the duration — an escrow, which returnable notes are most of the way to.
Receipts
The statement
The commitment C opens to a note owned by pk, holding at least
min_amount. This receipt is for context.
Public inputs: [commitment, asset_id, pk, min_amount, context]. About 700
constraints; it proves in well under a second.
Why not just reveal the opening
Anyone can check a payment if you hand over (amount, pk, blinding). But the
blinding is not harmless. For a note owned by an address or a condition, it is
the nullifier key — its holder can compute the note's nullifier and watch
the chain for the moment it is spent. And the amount becomes exact when a
floor would have done.
A receipt proves the same fact and discloses neither.
Verification is three checks
- The proof verifies against the receipt circuit's key.
pkis the recipient the verifier expects — recomputed from the address: thepkhalf of azks…address,Poseidon2(hi, lo)for a Solana address, orPoseidon4(…)for a returnable note.commitmentis one of the pool's leaves.
The proof alone covers only the first. The app does all three.
What it does not show
Who wrote it. The payer knows the opening because they built the note; the payee knows it because they received it. A receipt proves the payment. It also does not show that the note is unspent — and for a returnable note, the payment may since have gone back, which is why the app displays the return date alongside.
Wire formats: Protocol. SDK: Verifying proofs.