Skip to content
VOUCHVOUCHDocs

Guides

Accepting a proof

For verifiers: one verifier implementation, three ways to run it. Pick the one that fits where your decision is made.

On this page

#Choose where to verify

Where your decision is madeUseTrust added
A script, a back office, CIpof-verify CLInone
A web pagepof-wasm in a Web Workernone
A Solana programan attestor + pof-gatethe attestor allowlist (k of n)
A rail’s deposit flowthe rail API on an attestor you runnone, if you run it

#1 · Ask for a proof

Build a request at /request: the threshold, your identifier, an expiry, and optionally a period (so reuse shows), an unmoved-since block, a response deadline, and a binding to the holder’s Solana account. Hand the link to the holder.

Choosing an identifier

Your identifier is what proofs are addressed to, and only its hash travels. Use something stable and specific, like lender:acme-desk; a proof made for anyone else is refused at check 4.

#2 · Verify with the CLI

Shell
curl -s $SITE/api/anchors     > anchors.json
curl -s $SITE/api/revocations > revocations.json
pof-verify check proof.pof --audience pof-credit:usdc-pool-1 \
           --anchors anchors.json --revocations revocations.json \
           --epoch 202610 --seen registry.json --dormant-since 4410000
# Valid · 500.00000000 ZEC at least · anchor 3491040
echo $?   # 0 valid · 1 invalid · 2 expired · 3 malformed · 4 could not run

With --json it prints the full result, including the tags to append to your registry. It reads local files only, so it can gate CI or a batch job.

#3 · Verify in the browser

pof-wasm is the same Rust crate compiled with wasm-bindgen. Its verifying keys are embedded (a native test proves they equal a fresh keygen), so it warms up in about 300 ms and verifies in about 90 ms. The boundary is pure: bytes, the anchor table and the revocation list in; a verdict out.

TypeScript
import init, { verify } from '/wasm/pof_wasm.js'

await init()
const anchors = await (await fetch('/api/anchors')).text()
const revoked = JSON.stringify((await (await fetch('/api/revocations')).json()).secrets)
const now = BigInt(Math.floor(Date.now() / 1000))
const result = JSON.parse(verify(bytes, 'pof-credit:usdc-pool-1', now, anchors, revoked))
if (result.verdict.kind !== 'Valid') refuse(result.verdict)

Run it in a Web Worker, as the site does, so the warm-up never blocks the page.

#4 · Verify on Solana

One attestation, one transactionTransactionix 0 · Ed25519 precompileverify(attestor pubkey, message, signature)every offset = u16::MAX: the data is inside this instructiona bad signature fails the whole transactionix 1 · pof-gate::submit_attestation(subject, message)reads ix 0 from the Instructions sysvarcounts distinct allowlisted signers of exactly this messagechecks domain, verdict, slot age, expiry, thresholdcreates ClaimReceipt ["receipt", subject]introspectsConfig PDA · [config]attestors (up to 8)threshold k · max_age_slotsClaimReceipt PDA["receipt", subject]one per proof: replay failsbeneficiary = bound walletreadscreates
Solana's Ed25519 precompile checks the signature; pof-gate then reads that instruction back through the Instructions sysvar and checks that its signer is allowlisted and its message is exactly the one being submitted.

A Solana program cannot verify a Halo2 proof over Pallas or read Zcash state. So pof-attest runs the verifier and signs a domain-separated 139-byte message, and pof-gate checks that signature through instruction introspection:

Text
ix 0  Ed25519SigVerify111111111111111111111111111   verify(attestor, message, signature)
ix 1  pof-gate::submit_attestation(subject, message)
        1. parse and domain-check; verdict = Valid; claim kind known
        2. slot within max_age_slots; expires_at in the future
        3. count distinct allowlisted keys that signed exactly this message in
           earlier Ed25519 instructions whose offsets all point into themselves
        4. count ≥ threshold (k of n)
        5. create ClaimReceipt PDA [b"receipt", subject]   ← one proof, one receipt

A consumer program reads the receipt, checks the audience, the claim value, the expiry and that the signer is the beneficiary, then calls mark_consumed by CPI, signed by the beneficiary and by the consumer program’s own [b"consumer"] PDA. pof-credit is the worked example: see Credit lines.

ProgramDevnet id
pof-gateA6WmRTgEAHC9GHstaj9oD8woVNRw9jgu31bpKVREXn8C
pof-creditJ6ewFZAzcTkbVsYNBrcZvzdBtTTUK5UrpqCg9jiNyra1
pof-reserve5Q26Tjcd4equ5te5hF15eG4xK6uvkbZtKkEeqdf535h9

Read the trust model before you rely on it, and run your own attestor.

#Integration checklist

  • Load only the anchors you trust (mainnet only, if you only accept mainnet).
  • Fetch the revocation list at decision time, not once at start-up.
  • Name a period in your requests and keep a registry of accepted tags, or reuse is invisible to you.
  • Set --tip-height and --max-anchor-age when “unspent” must be recent.
  • Treat anything but Valid as a refusal, and show the holder the verdict’s reason.
  • Ask for a fresh proof at each decision: a proof is “as of” one block.