DOC# ZERAWA SLUG zera_wallet_v3_zkp PRINTED 2026-05-06 03:47 UTC

Zera Wallet v3: ZK Proofs in a Tauri Webview

A Tauri 2 desktop wallet that proves Groth16 in the browser, persists encrypted notes locally, talks NFC to physical bearer cards, and never lets the private key out of Rust.

FROM
Dax the Dev <[email protected]>
SOURCE
https://blog.skill-issue.dev/blog/zera_wallet_v3_zkp/
FILED
2026-03-24 15:45 UTC
REVISED
2026-03-25 05:13 UTC
TIME
8 min read
SERIES
Wallets I've Wired
TAGS
#zera #wallet #tauri #rust #react #zk #groth16 #nfc

The Zera SDK is the engine. The wallet is the car. Three weeks after the SDK shipped, I started building the v3 desktop wallet — Tauri 2 + React 18, with a Rust keystore that never lets the seed touch JavaScript and a webview that runs Groth16 provers in WebAssembly.

The initial commit is 39b5518 on 2026-03-24. The follow-up — the one that made the wallet actually do anything — is 660283fZKP core, real data layer, wallet unlock, note scanning the same day. The third commit, d061813 on 2026-03-25, added P2P send + NFC bearer cards. Three commits, ~3000 lines of meaningful code, full privacy stack.

This post is about what’s load-bearing in those three commits.

The trust model: Rust holds the key

The hardest design decision in any Tauri wallet is where the private key lives. The naive thing is to load it into JavaScript, sign in JS, send. The naive thing leaks the key the first time anything in the JS supply chain (Rusty Pipes, say) gets compromised.

The right thing is keystore.rs:

// src-tauri/src/keystore.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WalletFile {
    pub version: u32,
    pub salt: String,        // Argon2 salt, hex
    pub nonce: String,       // ChaCha20 nonce, hex
    pub ciphertext: String,  // Encrypted payload (JSON: { seed, entropy })
    pub pubkey: String,      // base58, unencrypted for display before unlock
}

struct WalletPayload {
    seed: String,            // 64-byte BIP39 seed for mnemonic, 32-byte raw key otherwise
    entropy: String,         // 16-byte entropy for 12-word recovery
    key_type: String,        // "mnemonic" or "raw_key"
}

The seed lives in $APPDATA/zera/wallet.enc, encrypted with ChaCha20-Poly1305 under a key derived from the user’s password via Argon2id. The pubkey is stored in plaintext so the unlock screen can show “Unlock wallet ABC123…” before the user types anything.

The frontend never sees the seed. Ever. Sign requests go through a Tauri command:

#[tauri::command]
pub async fn sign_and_send_transaction(/* ... */) -> Result<String, String> {
    // Decrypt seed using the in-memory unlock key, sign tx, send to RPC,
    // return signature. Seed is zeroized at end of scope.
}

If the frontend gets compromised, the worst it can do is request signatures. It cannot exfiltrate the key.

Importing keys from Phantom and Solflare without breaking the trust model

The keystore had to handle three import paths from day one:

  1. Generate a new wallet — fresh BIP39 mnemonic, derive seed, encrypt, store.
  2. Import a 12/24-word mnemonic — same as above but seeded by user input.
  3. Import a raw private key — base58 from Phantom, base64 from Solflare, base58 from solana-keygen. Raw 32-byte key gets put in seed with key_type = "raw_key" so the unlock path knows not to treat it as BIP39 entropy.

The viewing-key derivation is web-wallet-compatible — same HKDF schedule the original web wallet used so a user could import the same seed and see the same shielded notes. That backwards-compat constraint cost me a day; without it the wallet would have been quietly incompatible with the SDK’s MemoryNoteStore semantics in practice.

Groth16 in a webview

The wallet ships the same circuit files as the web wallet: deposit.wasm, deposit_final.zkey, withdraw.wasm, withdraw_final.zkey, transfer.wasm, transfer_final.zkey, plus relayed_withdraw variants. The Tauri webview loads them statically, runs snarkjs.groth16.fullProve, gets a proof + public signals out, and hands them back to Rust to format for Solana.

The split is intentional:

The tx flow is therefore:

JS:    build inputs → snarkjs.fullProve → proof + publicSignals
JS:    send to Tauri command with proof, commitment, recipient
Rust:  decrypt seed → build solana tx (using SDK builders) → sign → send
Rust:  return signature to JS
JS:    on success, append note to encrypted note store

Snarkjs is heavy — about 30s on a cold proof, 5–8s warm — but the alternative is “ship a Rust-native Groth16 prover,” which is a multi-week project of its own and which would still need to consume the same .zkey artifacts.

Notes are private. Notes are also a database.

A privacy wallet without a note store is just a key manager. Every shielded transaction produces output notes that only the recipient can decrypt, and the recipient has to scan the chain to find them. The wallet ships src/lib/noteEncryption.ts, which implements ECDH + nacl.box (XSalsa20-Poly1305). The plaintext format is versioned and binary-packed:

// v2: single note — 169 bytes plaintext
// [0x02][amount u64 LE][secret 32B][blinding 32B]
// [asset 32B][commitment 32B][nullifier 32B]

// v3: note pair — 145 bytes plaintext
// [0x03][amt1 u64 LE][secret1 32B][blinding1 32B]
//       [amt2 u64 LE][secret2 32B][blinding2 32B]
// Used for splits where both outputs go to the same key.
// Packing two notes into one nacl.box saves 265 bytes on-chain.

const BINARY_V2_LEN = 1 + 8 + 32 + 32 + 32 + 32 + 32; // 169
const BINARY_V3_LEN = 1 + 72 + 72;                    // 145

Why not JSON? Two reasons:

  1. Bytes are cheap on Solana, JSON is expensive. Every byte you encrypt is a byte you store on-chain (or in an encrypted memo). 169 binary bytes compress to about 80% the size of equivalent JSON.
  2. Format versioning is robust. A leading tag byte (0x02, 0x03) lets older wallets recognize unsupported formats and fall back gracefully instead of decrypting garbage.

Note persistence

The thing nobody warns you about with privacy wallets: if you lose your local note store, you can only recover funds by scanning the on-chain Merkle tree with your viewing key. That scan is slow, expensive in RPC calls, and has to be done from scratch every time. So the wallet auto-persists notes to disk:

// src/lib/notePersistence.ts
const NOTES_FILE = "zera/notes.json";
const NFC_FILE   = "zera/nfc-cards.json";

export async function saveNotesToDisk(notes: any[]): Promise<void> {
  await mkdir("zera", { baseDir: BaseDirectory.AppData, recursive: true })
    .catch(() => {});
  await writeTextFile(NOTES_FILE, JSON.stringify(notes, null, 2),
    { baseDir: BaseDirectory.AppData });
}

Notes auto-save on every change and load on startup. The encrypted-at-rest version of this is on the roadmap; for v3 the notes file is plain JSON in $APPDATA, which assumes the user trusts their own machine. The next iteration wraps it in the same ChaCha20 layer the keystore uses.

NFC bearer cards

The wallet’s most futuristic feature — and the one most likely to feel like sci-fi to anyone who hasn’t used it — is NFC bearer cards. From the d061813 commit message:

NFC page: real shielded notes, arbitrary amounts, custom mint, write pool notes to tags, read tags back into pool

The model: take an unspent shielded note from your pool, serialize the encrypted plaintext into an NFC tag’s NDEF record, hand the physical card to someone. They tap it on their wallet, the wallet pulls the encrypted blob, decrypts it with their viewing key, and the note becomes theirs. No on-chain transaction at all. The note’s nullifier is only revealed when the recipient eventually spends it.

This is the “physical cash” path I’d been sketching since the a better cryptocurrency post a year earlier, and the m0n3y voting proposal. The wallet shipped it as a real button. PC/SC + Proxmark3 hardware support, both supported in src-tauri/.

Trade-offs

Why Tauri instead of Electron? Because Electron ships a 200MB Chrome runtime and its security model has been a moving target for years. Tauri’s webview + minimal-IPC model gives me the trust boundary I need (Rust ↔ JS) for free.

Why snarkjs in JS instead of a Rust prover? Because snarkjs is the audited canonical prover for circomlib circuits. Rolling my own Rust prover would have shifted weeks of audit risk onto a Rust crate that nobody else uses.

Why plain JSON note persistence in v3? Because the alternative was holding the wallet release for an encrypted-at-rest design pass that was already a TODO. v3 ships now, encryption-at-rest of the note store ships in v3.1.

Why ship a viewing-key compatibility layer with the web wallet? Because the only thing worse than a privacy wallet you can’t import into is a privacy wallet that silently doesn’t import the same notes. Compatibility is a design constraint that has to be in v1 of any new client.

What this taught me

The trust boundary of a wallet is the most expensive surface in the project. Every subsystem you build either reinforces it (Rust holds the seed; JS sees ciphertexts) or breaks it (JS reads the keystore; key escrow services). v3 reinforced. The cost: ~30% of the codebase is the IPC plumbing. The benefit: a Rusty Pipes compromise of the JS supply chain doesn’t lose anyone’s funds.

Further reading

← Back to article