Skip to content
home/guides/viewing keys

Prove a balance without revealing it.

A viewing key lets someone read your shielded history. An attestation lets them verify one fact about it and learn nothing else. This guide covers generating the second from the first — entirely inside the user's browser.

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.

Attestation walkthroughsimulated · illustrative timings
uview1qqqqqpqy4v9nq0xmq6h3k8p2t7w5m9c4r8s6d0f2g1h7j5k3l9n8p4q2r6t0

Stays on this device for the whole run.

101000
0%0.0s
Load viewing keylocal

Parsed into local memory. Never serialised to the network.

Fetch note rangenetwork

Compact blocks pulled from a public lightwalletd endpoint.

Trial-decrypt outputslocal

Each candidate note is decrypted locally with the incoming key.

Build witnesslocal

Merkle path, nullifier set and balance assembled in the worker.

Generate Groth16 prooflocal

BN254 proving key, ~1.8MB WASM, running on this device.

This console is a simulation
The timings are representative of a mid-range laptop and the values are placeholders — this page ships no proving key and performs no cryptography. Run the real thing with client.attest() below.

Generating an attestation

attest.ts
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.

prove.worker.ts
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});
Handle the key like a password
A full viewing key discloses a wallet's entire history to whoever holds it. Never log it, never put it in a URL, never persist it to localStorage, and prefer an incoming viewing key (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.

submit.ts
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.

precheck.ts
// 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.