Reference
The proof format
A .pof file carries one claim, for one audience and period, bound to one context, anchored to the chain, with an expiry and a revocation tag. Version 2 adds “unmoved since block H”, scopes in which reuse is visible, and exit certificates. This page is written so that someone else can implement a verifier against it.
On this page
#Principles
The proof is a file, not an API call. Every component talks through this artifact. That gives offline verification, a testable boundary, and a format that can outlive any one implementation.
No optional semantics. Every field is always present. Optionality in a security format is how verifiers end up disagreeing about what was proven.
The verifier never trusts a field it can derive. The circuit’s round id, nullifier domain, threshold and both ledger roots are recomputed or looked up by the verifier; the file carries only the public inputs it cannot derive.
#The file
Binary, then base64url (RFC 4648 §5, no padding) wherever it travels as text or inside a URL. The extension is .pof; a holding proof is about 11,900 bytes.
magic "POF1" 4 bytes 50 4F 46 31
version u16, little-endian 2 bytes 01 00 or 02 00
body postcard(Body) variable
checksum blake2b-256(body) 32 bytesThe magic bytes let a verifier reject garbage instantly. The checksum makes a truncated paste distinguishable from a forgery: a forger re-seals the checksum, a clipboard does not. The version lives in the header only; the body never repeats it.
#The body
Postcard encoding: integers as LEB128 varints, fixed arrays as raw bytes, vectors as a varint length and elements, enums as a varint tag.
Version 2 (written by current provers):
circuit u8 1 = threshold · 2 = threshold + revealed nullifiers (exit certificates)
claim enum Claim (varint tag + fields)
audience [u8; 32] blake2b-256("pof-audience:" ‖ lowercase(trim(id)))
epoch u64 the verifier's period; with circuit and audience it is the scope
binding [u8; 32] all zero · a Solana pubkey · blake2b("vouch-intent-v1", deposit intent)
nc_height u32 the block whose note-commitment tree holds the notes
nc_root [u8; 32]
height u32 the block whose spent set does not hold them ("as of")
nf_root [u8; 32] nc_height ≤ height; equal unless the proof says "unmoved since"
issued_at u64
expires_at u64
revocation [u8; 16]
evidence { public_inputs: Vec<[u8; 32]>, proof: Vec<u8>, signature: [u8; 64] }Version 1 (still read, and re-encoded byte for byte):
claim enum Claim (varint tag + fields)
audience [u8; 32] blake2b-256("pof-audience:" ‖ lowercase(trim(id)))
binding [u8; 32] all zero, or e.g. the holder's Solana pubkey
anchor { height: u32, nc_root: [u8; 32], nf_root: [u8; 32] }
issued_at u64 unix seconds
expires_at u64 unix seconds, absolute
revocation [u8; 16] blake2b("vouch-revoke-v1", secret)[..16]
evidence { public_inputs: Vec<[u8; 32]>, proof: Vec<u8>, signature: [u8; 64] }| Field | Rule |
|---|---|
| claim | Exactly one statement. Never a list. Two facts are two proofs. |
| audience | A hash, not a name. The verifier proves it is the audience by matching; nobody else learns who it was for. |
| binding | Who may use the proof, for audiences that act on it (a Solana program). All zero when unbound. |
| anchor | A height and both ledger roots at that height. The verifier accepts only roots in its authenticated anchor table. |
| expires_at | Absolute, not a duration. |
| revocation | Opaque. The holder revokes by publishing the secret; see the trust model. |
| evidence | 9 public inputs (15 for circuit 2), the Halo2 proof, and the spend-authorisation signature. |
| epoch | Chosen by the verifier in its request. A proof made without one gets a scope of its own, so it links to nothing. |
| nc_height | When earlier than height, the notes were already in the chain at nc_height and have not moved since. |
#Claims
pub enum Claim {
HoldsAtLeast { zatoshi: u64 }, // tag 0 · v1
HoldsExactly { zatoshi: u64 }, // tag 1 · reserved
ReceivedPayment { txid: [u8; 32], zatoshi: u64 }, // tag 2 · roadmap
ReceivedAtLeastSince { zatoshi: u64, from_height: u32 }, // tag 3 · roadmap
}v1 verifiers accept HoldsAtLeast only, and only for whole units of 0.125 ZEC (12,500,000 zatoshi): the circuit counts value in those units. 500 ZEC is 50000000000 zatoshi, 4,000 units. HoldsExactly is deliberately not offered: a threshold reveals less.
#What binds the file to the proof
The statement is every body field except the evidence. The holder signs it, with every public input, under the key the circuit proves controls the notes, so changing any field — the claim, audience, epoch, binding, either anchor height, the expiry — fails the signature. In version 1 the circuit’s vote_round_id is also the statement hash. In version 2 it is the scope: the circuit, audience and epoch. The same note then yields the same tag inside one scope, so a verifier sees the same coins used twice with it, and nobody else can link anything.
statement = blake2b-256[personal "vouch-stmt-v1"](head)
scope = blake2b-256[personal "vouch-scope-v2"](circuit ‖ audience ‖ epoch_le) (version 2)
round_id = statement (v1) or scope (v2), bits 254..255 cleared, as a Pallas base element
dom = Poseidon("governance authorization", round_id)
min_units = claim.zatoshi / 12_500_000
message = blake2b-256[personal "vouch-sig-v1"](statement ‖ public_inputs)
signature = RedPallas SpendAuth over message, under rkThe signature is the holder’s spend authority. Someone who only has the holder’s viewing key can find the notes but cannot sign, so a viewing key is not enough to prove funds on someone’s behalf.
#The circuit
The evidence is a Halo2 proof (IPA over Pasta, K = 12, no trusted setup) of the delegation circuit from voting-circuits 0.12.1, with a Vouch modification (described fully in The circuit): a 15th public input, min_ballots, and the constraint num_ballots − min_ballots ∈ [0, 2³⁰). It proves, for up to five of the holder’s notes:
- each note is in the Ironwood note-commitment tree at
nc_root; - each note is unspent: its real nullifier is not in the spent-nullifier IMT at
nf_root(the nullifier stays private); - the prover holds the keys the notes are addressed to (
rkis a re-randomisation of the notes’ak); - the notes together hold at least
min_ballots× 0.125 ZEC. The sum itself is committed, never revealed.
The circuit checks each note slot on its own, so it would accept one note placed in several slots and count it more than once. Upstream, the vote chain refuses a repeated governance nullifier; Vouch has no chain, so the verifier refuses any proof whose five gov_null values are not all distinct. A repeated note always produces the same one.
Carried public inputs, in order: nf_signed, rk, cmx_new, van_comm, gov_null_1..5. Derived by the verifier: vote_round_id, dom, nc_root, nf_imt_root, min_ballots. The governance nullifiers (tags) are domain-separated per scope and unlinkable to the real nullifiers.
nc_root and nf_imt_root are independent public inputs. Taking the first at an earlier block than the second proves the notes existed then and are unspent now: the tree only grows, and a note moves only by revealing its nullifier.
Circuit 2 (exit certificates) is the same circuit with a second Vouch modification: a boolean reveal (public input 15) and reveal × real_nf for each slot (16–20). With reveal = 1 the five real nullifiers are public, so a recipient can match a deposit to exactly the certified notes; with 0 they are forced to zero. It has its own verifying key; circuit 1’s is unchanged. Carried inputs for circuit 2: the nine above, then reveal, revealed_nf_1..5.
#Verification and verdicts
Six checks, cheapest first. The verifier stops at the first failure and never runs cryptography on an artifact that failed a cheap check. The flowchart and the policy options are in The verification pipeline.
| # | Check | Failure |
|---|---|---|
| 1 | Magic, version, checksum, body parse; HoldsAtLeast in whole units | Malformed |
| 2 | Now is before expires_at | Expired |
| 3 | No published secret hashes to the revocation tag | Revoked |
| 4 | blake2b(identifier) equals audience; the epoch is the one the verifier asked for, if it asked | WrongAudience · WrongScope |
| 5 | The note-commitment root at nc_height and the spent-set root at height are both authenticated; the spent set is fresh enough and the notes unmoved long enough for the verifier’s policy | AnchorNotFound · AnchorTooOld · NotDormantLongEnough |
| 6 | Distinct tags, the signature under rk, the Halo2 proof against the circuit’s key; then none of the tags is in the verifier’s reuse registry | ProofInvalid · AlreadyUsed |
pub enum Verdict {
Valid { claim: Claim, anchor_height: u32, dormant_since: Option<u32> },
Expired { at: u64 },
Revoked,
WrongAudience,
AnchorNotFound,
AnchorTooOld { anchor_height: u32 },
NotDormantLongEnough { dormant_since: u32, required: u32 },
WrongScope { epoch: u64, required: u64 },
AlreadyUsed,
ProofInvalid { detail: String },
Malformed { reason: String },
}
pub struct Policy { // what this verifier additionally requires; all optional
tip_height: Option<u32>, // with max_anchor_age: refuse a stale spent set
max_anchor_age: Option<u32>,
dormant_since: Option<u32>, // notes unmoved since this block or earlier
epoch: Option<u64>, // the period the request named
}A typed verdict, never a bool. ProofInvalid says the evidence does not prove the statement and not which constraint failed; verbose failure detail in a security tool is a debugging aid for an attacker.
#Anchors
An anchor is a finalised height and two roots: the Ironwood note-commitment tree root and the root of the indexed Merkle tree of every Ironwood nullifier revealed up to that height. pof-anchor rebuilds both from compact blocks since Ironwood activation (block 3,428,143), checks the first against lightwalletd’s own tree state, and publishes the pair.
{
"network": "mainnet",
"height": 3493000,
"ncRoot": "1780a43b9054d8f45de3763df336ec4f443697b2762d397b014255d202b56a3c",
"nfRoot": "34da5ef38d024c99b8ec7fc464c78cda5543b06ba412c775ca9dfcb26453c336"
}The table is served at /api/anchors. Anyone can re-derive every entry: pof-anchor scan --to <height>. The demo anchor belongs to the published demo ledger and is labelled as such wherever it is used.
Zcash testnet works the same way (Ironwood from block 4,134,000): pof-anchor scan --network testnet reads testnet.zec.rocks and records network: "testnet". Verdicts on a testnet anchor say so: the proof is real, the coins (TAZ) have no monetary value. The network is recorded in the anchor table, not in the proof or the attestation, so a verifier that only wants mainnet should trust only mainnet anchors.
#Reserves batches
A holder with more than five notes, or a treasury, proves its reserves as a batch: several version 2 proofs in one scope and against one anchor, in a .pofb container. The verifier checks every member, refuses a batch in which any tag appears twice (a note counted twice), and states the sum. Natively the members’ Halo2 proofs are checked together, with one multi-scalar multiplication.
magic "POFB" 4 bytes
version u16, little-endian 2 bytes 01 00
count u16, little-endian 2 bytes 1 … 64
members count × (u32 LE length ‖ a complete POF1 file)
checksum blake2b-256 of everything before itCheck one at /reserves, or with pof-verify check reserves.pofb. On Solana, pof-reserve turns an attested batch into a feed that any program can read, and its demo token mints only within the proven total.
#As JSON
Every verifier returns the same JSON for a file: the WASM verify(), pof-verify check --json and the attestor’s /v1/verify. It holds the verdict, the six checks in order, the matched anchor record, the proof’s scope and tags(for a reuse registry), an exit certificate’s revealed nullifiers, and envelope, the JSON projection of the proof itself (hex in lowercase, amounts in zatoshi, times in unix seconds). Its JSON Schema is published at /schema/pof-v1.json, and CI checks every test vector’s result against it.
{ "verdict": { "kind": "Valid", "claim": { "kind": "HoldsAtLeast", "zatoshi": 100000000 }, "anchorHeight": 4410000 },
"checks": [ { "id": "format", "label": "…", "status": "pass", "detail": "…" }, … ],
"envelope": { "version": 1, "claim": { … }, "audience": "412a96…", "binding": "0000…", "anchor": { … },
"issuedAt": 1790622467, "expiresAt": 1800990467, "revocation": "704960c1…", "evidence": { … } },
"sizeBytes": 11884, "checksum": "c82489…", "anchor": { "network": "testnet", "height": 4410000, … },
"now": 1790693805, "verifier": "pof-verify 0.1.0" }#Test vectors
Every preset on /verify is a real proof committed in fixtures/, together with the verdict each must produce. The native verifier, the WASM verifier and the TypeScript codec are all checked against that one list in CI.
The first, testnet-valid.pof, was made by pof-prove prove from a real Zcash testnet wallet: at least 1 TAZ, as of testnet block 4,410,000, for the audience vouch:testnet-demo. Its tampered, forged and re-addressed copies sit beside it. The rest were written by pof-prove demo fixtures against the demo ledger, for the audience pof-credit:usdc-pool-1.