Skip to Content
ProtocolOTP Protocol (ZVS)

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 sessionId and userAddress, so an attacker who observes a sessionId cannot get a valid code by reusing it with their own address.

Parties and secrets

PartyWhat they know
UserThe 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 / verifierThe HMAC secret (ZVS_SECRET_SEED), can compute the OTP for any (sessionId, address) pair
ZVS serviceThe same HMAC secret, scans the mempool, computes OTPs on the fly, sends reply memos
Chain observersNothing 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

NetworkAddress
Testnetutest100qlkeru5c3m5kfrwe2hsmcfzmusreaza2prdyelg2kd2tr2842nceq952vay3gpmgky09fgft4z57h4z2zqzz5rcwgd4q90u54ek5yyca4s6e6y2jja9sww27kzedzznjcupcu0svq2exvq995c0lhl5zm53g4ksnm2xuwt3snv4dgh
Mainnetu1gphl7vrklduuv96kpw4eetx4vrs8nnk7w9tuzvppyuuctw0tuskkpmfulrjapr05zh78p3chpxhx3tm28qau3uwd36k94vgucpxphyv5hkg36nhvr4axeljpz04acdhc7vskg9nsxfhylcl5lnspxtkrhjzn5xaedr2ae567ks3gz24u

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_000

Formatted 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:

  • sessionId and userAddress are 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.
Last updated on