OTP Protocol (ZVS)
The Zcash Verification Service (ZVS) is the stateless authenticator that binds a 6-digit code to a Zcash address. It lets ZNS authorize management actions (UPDATE, LIST, DELIST, RELEASE) without accounts, passwords, or any off-chain session state.
This page is the low-level spec. For the end-user walkthrough see OTP Verification.
Design goals
- No accounts. The only credential is the ability to spend from a Zcash address.
- Stateless. Both ZVS and the verifier compute the OTP deterministically from public inputs plus a shared HMAC secret. No database, no session store, no nonces.
- Shielded. All messages travel in Orchard memos; the OTP is never on a public channel.
- Address-bound. The OTP is a function of both
sessionIdanduserAddress, so an attacker who observes asessionIdcannot get a valid code by reusing it with their own address.
Parties and secrets
| Party | What they know |
|---|---|
| User | The address they want to prove control of, the session ID the web UI gave them, and (after sending a tx) the OTP in the reply memo |
| Web app / verifier | The HMAC secret (ZVS_SECRET_SEED), can compute the OTP for any (sessionId, address) pair |
| ZVS service | The same HMAC secret, scans the mempool, computes OTPs on the fly, sends reply memos |
| Chain observers | Nothing useful - all memos are inside Orchard shielded notes |
The HMAC secret is held in two places: the web app server process and the ZVS service. Both compute the same OTP independently and never need to communicate directly.
Session ID
A 16-digit cryptographically random decimal string. The web UI generates it via crypto.getRandomValues and embeds it in the verification memo.
export function generateSessionId(): string {
const digits = "0123456789";
const array = new Uint8Array(16);
crypto.getRandomValues(array);
let id = "";
for (let i = 0; i < 16; i++) id += digits[array[i] % 10];
return id;
}The modulo bias for base-10 from 256 is negligible at 16 digits. Session IDs are single-use by convention - nothing enforces non-reuse, but the web UI generates a fresh one per verification attempt.
Verification memo
The user sends an Orchard shielded transaction to the ZVS address with this memo:
DO NOT MODIFY:{zvs/<sessionId>,<userAddress>}The DO NOT MODIFY: prefix is a hint to wallets that prompt users to edit memos - do not touch this. The content inside the braces is what ZVS (and the verifier) parse.
Parse regex
/\{zvs\/(\d{16}),(.+)\}$/Group 1 is the 16-digit session ID. Group 2 is the unified address, verbatim. Nothing else about the memo is significant - whitespace, case, and extra prefixes are all tolerated as long as the regex matches at the end of the string.
Amount
Fixed at 0.002 ZEC (200,000 zats). Any amount works in principle, but ZVS’s mempool scanner ignores dust and the web UI builds URIs with this amount.
ZVS addresses
| Network | Address |
|---|---|
| Testnet | utest100qlkeru5c3m5kfrwe2hsmcfzmusreaza2prdyelg2kd2tr2842nceq952vay3gpmgky09fgft4z57h4z2zqzz5rcwgd4q90u54ek5yyca4s6e6y2jja9sww27kzedzznjcupcu0svq2exvq995c0lhl5zm53g4ksnm2xuwt3snv4dgh |
| Mainnet | u1gphl7vrklduuv96kpw4eetx4vrs8nnk7w9tuzvppyuuctw0tuskkpmfulrjapr05zh78p3chpxhx3tm28qau3uwd36k94vgucpxphyv5hkg36nhvr4axeljpz04acdhc7vskg9nsxfhylcl5lnspxtkrhjzn5xaedr2ae567ks3gz24u |
These are different from the ZNS registry addresses used for claim/list/etc.
OTP derivation
otp = HMAC-SHA256(ZVS_SECRET_SEED, sessionId || userAddress)[0..4] as u32 (big endian) % 1_000_000Formatted as a zero-padded 6-digit decimal string. Reference implementation from lib/payment/otp.ts:
const key = await crypto.subtle.importKey(
"raw",
secretSeedBytes,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, encode(sessionId + userAddress));
const h = new Uint8Array(sig);
const code = ((h[0] << 24) | (h[1] << 16) | (h[2] << 8) | h[3]) >>> 0;
return (code % 1_000_000).toString().padStart(6, "0");Notes:
sessionIdanduserAddressare concatenated with no delimiter. The web UI and ZVS must agree on this byte sequence exactly.- The first 4 bytes of the 32-byte HMAC output are interpreted as an unsigned 32-bit big-endian integer. Modulo 1,000,000 yields the OTP.
- The modulo bias at 4 bytes (≈4.29B) across 1M codes is negligible for a 6-digit code.
Flow
User Wallet ZVS Verifier (web)
│ │ │ │
│─── click verify ──────>│ │ │
│ │ │ │
│<── ZIP-321 URI ────────│ │ │
│ (amount=0.002, │ │ │
│ memo=ZVS verif memo) │ │ │
│ │ │ │
│── sends shielded tx ──>│ │ │
│ │──── mempool ────>│ │
│ │ │── computes OTP ──────│
│ │<── reply memo ───│ │
│<── sees 6-digit code ──│ │ │
│ │ │ │
│── types OTP into UI ─────────────────────────────────────────────>│
│ │ │ │─── verify
│ │ │ │ OTP ≡
│ │ │ │ HMAC(...)
│ │ │ │ && addr match
│ │ │ │
│<─── signed memo for the management action ──────────────────────<│Verification
The verifier re-parses the memo, rejects if the userAddress field doesn’t match the address it expects to be authorizing, recomputes the OTP, and compares using a constant-time equality check. From verifyOtp:
export async function verifyOtp(memo: string, providedOtp: string, expectedAddress: string) {
const parsed = parseZvsMemo(memo);
if (!parsed) return false;
if (parsed.userAddress !== expectedAddress) return false;
const expected = await generateOtp(memo);
return expected === providedOtp.trim();
}The expectedAddress parameter is critical. Without it, an attacker who observed sessionId in flight could construct their own memo with a different userAddress, get the corresponding OTP back from ZVS, and submit it - the OTP would match whatever was computed from the memo, not the address the web UI was authorizing. Binding the check to the expected address closes this.
Limitations
- ZVS is a single service. If it’s down, verification is blocked. A production deployment should run multiple instances - they’d all compute the same OTPs.
- The reply memo is delivered on-chain, so there’s a wall-clock delay of however long it takes for ZVS to detect the tx and for the user’s wallet to see the reply. Expect 15–60 seconds on testnet.
- The session ID is not stored anywhere, so if the user closes the tab mid-flow, they can always start a new session - but the 0.002 ZEC from the previous attempt is spent and unrecoverable.