What you are building
By the end of this guide a user will be able to click a button in your app, prove they hold at least some threshold of shielded ZEC, and submit that proof to your contract — without your app, Z-Relay, or the chain ever seeing their key, their address or their actual balance.
The flow, step by step
The walkthrough below mirrors what the SDK does. Only one stage touches the network, and the data it fetches is already encrypted to the user's key.
uview1qqqqqpqy4v9nq0xmq6h3k8p2t7w5m9c4r8s6d0f2g1h7j5k3l9n8p4q2r6t0Stays on this device for the whole run.
Parsed into local memory. Never serialised to the network.
Compact blocks pulled from a public lightwalletd endpoint.
Each candidate note is decrypted locally with the incoming key.
Merkle path, nullifier set and balance assembled in the worker.
BN254 proving key, ~1.8MB WASM, running on this device.
client.attest() below.Generating an attestation
1import { ZRelayClient } from "@zrelay/sdk";2 3const client = new ZRelayClient({ chainId: 4663 });4 5// The witness never leaves the browser — only the proof is published.6const attestation = await client.attest({7 viewingKey: "uview1qqqqq...", // held client-side, never transmitted8 condition: { minZecBalance: "100.0", atBlockHeight: 2_654_900 },9 destinationContract: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",10});11 12// Groth16 proof, ready to submit from the user's own wallet.13await contract.verifySolvencyAndBorrow(14 loanAmount,15 attestation.claimHash,16 attestation.minBalance,17 attestation.proof,18);Supported predicates
- minZecBalance
- Balance ≥ threshold at an anchor height
- receivedPayment
- A payment matching a memo hash settled
- settledTrade
- A Z-Relay settlement completed
- Proving time
- 1.5–5s depending on note count and device
- Proof size
- ~192 bytes
- Verification
- ~180,000 gas
Run the prover in a worker
Proof generation is CPU-bound and will freeze the main thread for a second or more. Use the bundled worker entry point and clear the key as soon as the proof exists.
1// Proving blocks the main thread for ~1.5s. Always run it in a worker.2const worker = new Worker(3 new URL("@zrelay/sdk/prover.worker", import.meta.url),4 { type: "module" },5);6 7worker.postMessage({ viewingKey, condition, destinationContract });8 9worker.onmessage = ({ data }) => {10 if (data.type === "progress") setStage(data.stage);11 if (data.type === "proof") setAttestation(data.attestation);12};13 14// Wipe the key from memory as soon as the proof exists.15worker.addEventListener("message", ({ data }) => {16 if (data.type === "proof") viewingKey = "";17});ivk) when the predicate only concerns received value. Ask for the narrowest key that answers the question.Submitting the proof
The proof is submitted by the user's own wallet, in an ordinary transaction to your contract. Z-Relay is not in this path — which means there is no relayer to censor the submission and no service whose outage blocks your users.
1import { ZRelayClient } from "@zrelay/sdk";2import { ethers } from "ethers";3 4const client = new ZRelayClient({ chainId: 4663, signer });5 6const attestation = await client.attest({7 viewingKey, // from the user, in memory8 condition: { minZecBalance: "100.0" },9 destinationContract: VAULT_ADDRESS,10});11 12// Submitted by the user's own wallet — Z-Relay never relays this for you.13const vault = new ethers.Contract(VAULT_ADDRESS, VAULT_ABI, signer);14const tx = await vault.verifySolvencyAndBorrow(15 loanAmount,16 attestation.claimHash,17 attestation.minBalance,18 attestation.proof,19);20 21await tx.wait();Verify before you spend gas
Run the verification locally first. A proof that would revert costs the user nothing to discover client-side and real money to discover on-chain.
// Check a proof locally before paying gas to submit it.
const ok = await client.verifyAttestation(attestation);
if (!ok) throw new Error("proof would revert on-chain");Anchors and expiry
Every attestation is bound to a Zcash block anchor and rejected by the verifier once that anchor falls outside ANCHOR_WINDOW (roughly a day). If your flow has a long gap between proving and submitting — a governance delay, a manual approval — generate the proof at submission time, not at request time.
What an attestation does not prove
- Not ongoing solvency. It is a statement about one moment. A user can prove 100 ZEC and spend it in the next block. Protocols extending credit against it need their own liquidation logic.
- Not exclusivity. The same balance can back proofs to several protocols unless they coordinate. The claim hash prevents replaying one proof, not proving the same fact twice.
- Not identity. It proves control of a key, not who controls it. KYC, where you need it, remains a separate problem.
The conceptual background is on the selective disclosure page; the verifier interface is in smart contracts.