Skip to content
VOUCHVOUCHDocs

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.

Text
 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 bytes
The .pof filemagic"POF1"4 Bversionu16 LE · 1 or 22 Bbodypostcard(Body)variablechecksumblake2b-256(body)32 Ba holding proof: about 11,900 bytes
Binary, or base64url (no padding) wherever it travels as text. The checksum makes a truncated paste distinguishable from a forgery.

The 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):

Text
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):

Text
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] }
FieldRule
claimExactly one statement. Never a list. Two facts are two proofs.
audienceA hash, not a name. The verifier proves it is the audience by matching; nobody else learns who it was for.
bindingWho may use the proof, for audiences that act on it (a Solana program). All zero when unbound.
anchorA height and both ledger roots at that height. The verifier accepts only roots in its authenticated anchor table.
expires_atAbsolute, not a duration.
revocationOpaque. The holder revokes by publishing the secret; see the trust model.
evidence9 public inputs (15 for circuit 2), the Halo2 proof, and the spend-authorisation signature.
epochChosen by the verifier in its request. A proof made without one gets a scope of its own, so it links to nothing.
nc_heightWhen earlier than height, the notes were already in the chain at nc_height and have not moved since.

#Claims

Rust
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.

Text
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 rk
What binds the file to the proofEnvelope headclaim · audience · epochbinding · circuitnc_height · heightnc_root · nf_rootissued · expires · revocationevery field except the evidencestatementblake2b · vouch-stmt-v1scope (v2)blake2b · vouch-scope-v2circuit ‖ audience ‖ epochpublic inputs9 carried · 15 for circuit 2signing messagestatement ‖ public inputsRedPallas signatureunder rk: spend authorityround_idv1: the statementv2: the scopeHalo2 circuitdom from round_idmin_ballots from claimv1v2and the proof is checked against them
The holder signs the statement and every public input under the key the circuit proves controls the notes. Edit any field after proving and the signature fails; in version 1 the circuit itself is also bound to the statement through round_id.

The 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 (rk is 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.

Public inputs: carried in the file or derived by the verifiernf_signed#0 · carriedrk (x, y)#1–2 · carriedcmx_new#3 · carriedvan_comm#4 · carriedvote_round_id#5 · derivednc_root#6 · derivednf_imt_root#7 · derivedgov_null 1–5#8–12 · carrieddom#13 · derivedmin_ballots#14 · derivedreveal#15 · carriedrevealed_nf 1–5#16–20 · carriedCircuit 1 · 15 inputsCircuit 2 adds · 6 inputscarried in the .pofderived by the verifier
The verifier never trusts a field it can derive: the round id, the nullifier domain, both roots and the threshold are recomputed from the statement and the anchor table. Circuit 2 (exit certificates) adds six inputs.

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.

#CheckFailure
1Magic, version, checksum, body parse; HoldsAtLeast in whole unitsMalformed
2Now is before expires_atExpired
3No published secret hashes to the revocation tagRevoked
4blake2b(identifier) equals audience; the epoch is the one the verifier asked for, if it askedWrongAudience · WrongScope
5The 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 policyAnchorNotFound · AnchorTooOld · NotDormantLongEnough
6Distinct tags, the signature under rk, the Halo2 proof against the circuit’s key; then none of the tags is in the verifier’s reuse registryProofInvalid · AlreadyUsed
Rust
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.

JSON
{
  "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.

Text
 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 it
The .pofb reserves batchmagic"POFB"4 Bversion12 Bcount1 … 642 Bmember 1a complete proofu32 len + POF1…member na complete proofu32 len + POF1checksumblake2b-25632 B
Members are complete POF1 files, each prefixed by its length. They share one scope and one anchor; the checksum covers everything before it.

Check 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.

JSON
{ "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.