A two-factor authentication (2FA) service using shielded Zcash transactions. Users send verification requests via encrypted transaction memos, and ZVS responds with HMAC-derived one-time passwords (OTPs).
- User sends verification request - A shielded Zcash transaction with a memo containing the verification payload
- ZVS monitors the blockchain - Connects to a lightwalletd server and scans for incoming transactions
- OTP generation - For valid verification requests, ZVS generates an HMAC-SHA256 based 6-digit OTP
- Response transaction - ZVS sends the OTP back to the user via a shielded transaction memo
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ USER ZVS (service wallet) │
│ │
│ ┌─────────┐ tx with memo: ┌─────────┐ │
│ │ │ "{zvs/1234567890123456,u1...}" │ │ │
│ │ Wallet │ ──────────────────────────────► │ Wallet │ │
│ │ │ │ │ │
│ └─────────┘ └────┬────┘ │
│ ▲ │ │
│ │ │ 1. Decrypt memo │
│ │ │ 2. Parse session ID │
│ │ │ 3. HMAC(secret, session)│
│ │ │ 4. Generate OTP │
│ │ ▼ │
│ │ ┌─────────┐ │
│ │ tx with memo: "847291" │ Build │ │
│ │ (50,000 zats) │ tx │ │
│ └────────────────────────────────────── │ │ │
│ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
- Rust toolchain (edition 2021)
- Access to a lightwalletd server
ZVS reads secrets from zvs_data/keys.toml:
mnemonic = "word1 word2 ... word24"
otp_secret = "a71440d829e0403019d195a78afd89efe4c18a4e"
birthday_height = 3150000| Field | Description |
|---|---|
mnemonic |
24-word BIP39 mnemonic phrase |
otp_secret |
Secret key for HMAC-based OTP generation (hex-encoded) |
birthday_height |
Block height when the wallet was created |
Set RUST_LOG=debug to increase log verbosity (default: info).
cargo build --releasecargo run --releaseOn startup, ZVS will:
- Perform an initial sync to chain tip
- Display the wallet's unified address and balance
- Process any pending verification requests found during sync
- Enter the monitoring loop
The main loop exclusively owns the wallet. The mempool monitor runs in a separate task with read-only access (UFVK decryption only) and sends detected requests back via an mpsc channel.
┌─────────────────────────┐ ┌─────────────────────────┐
│ MEMPOOL MONITOR │ │ MAIN LOOP │
│ (spawned task) │ │ (owns wallet) │
│ │ mpsc channel │ │
│ • Streams mempool txs │────────────────────────► │ • Periodic block sync │
│ • Decrypts memos (UFVK) │ VerificationRequest │ • Enhances transactions │
│ • Validates requests │ │ • Sends OTP responses │
│ • No wallet mutation │ │ • Deduplicates via │
│ │ │ HashSet<TxId> │
└─────────────────────────┘ └─────────────────────────┘
- Mempool Monitor: Streams unconfirmed transactions in real-time, decrypts memos with the UFVK, and forwards valid requests over the channel.
- Main Loop: Periodically syncs the wallet to chain tip (every 30s), receives mempool requests from the channel, and sends OTP responses. All wallet mutation happens here.
- Deduplication: An in-memory
HashSet<TxId>prevents duplicate OTP responses across both the mempool and sync paths.
src/
├── main.rs # Entry point, keys.toml loading, event loop
├── wallet.rs # Local wallet operations (keys, DB, proving, signing)
├── sync.rs # Block sync with lightwalletd, in-memory block cache
├── mempool.rs # Real-time mempool streaming and memo decryption
├── memo_rules.rs # Memo format validation and parsing
└── otp_rules.rs # OTP generation, transaction building, and broadcast
Wallet- Local-only wallet: keys, database, proving, signing (no network I/O)MemBlockCache- In-memory cache for compact blocks during syncDecryptedMemo- Decrypted memo with txid and value from a received transactionVerificationRequest- Validated request ready for OTP generation
Send a shielded transaction to the ZVS wallet address with:
- Memo format:
(DO NOT MODIFY){zvs/session_id,u-address} - Minimum payment: 200,000 zatoshis (0.002 ZEC)
ZVS responds with a shielded transaction containing:
- Memo: 6-digit OTP code (e.g.,
847291) - Amount: 50,000 zatoshis (0.0005 ZEC)
The memo payload is extracted from between the first { and }:
zvs/session_id,u-address
zvs/- Prefix identifying a verification request (lowercase)session_id- Exactly 16 ASCII digits for OTP entropyu-address- User's unified address for OTP response
Example memo:
(DO NOT MODIFY){zvs/1234567890123456,u1abc123...}
Web applications can independently verify OTPs by sharing the otp_secret with ZVS:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Web App │ │ User Wallet │ │ ZVS │
│(has otp_secret) │ │ │ │(has otp_secret) │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
1. Generate │ │
session_id │ │
│ │ │
2. Show memo ───────────────►│ │
to user │ │
│ 3. Send tx ─────────────────►│
│ with memo │
│ │ 4. Parse memo
│ │ Generate OTP
│ │◄──────────────────────│
│ │ 5. Send OTP in tx │
│ │ │
6. User enters ◄────────────│ │
OTP from wallet │ │
│ │ │
7. Web app computes │ │
HMAC(secret, session_id) │ │
and verifies match │ │
async function generateOTP(secretHex, sessionId) {
const encoder = new TextEncoder();
const secretBytes = hexToBytes(secretHex);
const key = await crypto.subtle.importKey(
'raw', secretBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(sessionId));
const bytes = new Uint8Array(signature);
const code = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0;
return String(code % 1000000).padStart(6, '0');
}
function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}- Shielded transactions - All communication uses Zcash shielded pools (Sapling/Orchard)
- HMAC-SHA256 - OTPs are derived deterministically from session IDs
- No shared wallet state - The mempool task only decrypts; all spending is serialized in the main loop
Key dependencies:
zcash_client_backend/zcash_client_sqlite- Zcash wallet functionalityzcash_proofs- Zero-knowledge proof generationtonic- gRPC client for lightwalletdhmac/sha2- OTP generationtokio- Async runtime
MIT