Vernam Wallet

Sepolia

How it works

A wallet whose primary key never signs

Each transaction is sent by a one-time account whose key is sha256(0x01 ‖ sk ‖ n). A zk proof shows the vault that the sender is the one-time account for nonce n of the vault owner's key. The vault accepts each one-time account once, so every signing key signs exactly one transaction.

Open the wallet →

Why

A future attacker with a quantum computer running Shor's algorithm can recover a private key from any public key or signature it has seen. An ordinary account reveals its public key with its first signature, then keeps signing with the same key.

  • Primary key sk

    Never signs. Only its address, a hash of the public key, goes on chain.

  • One-time key n

    Signs one withdraw, then is burned. A key recovered from that signature is already spent.

  • Vault

    Holds the funds. Pays out only to a one-time account that proves it comes from sk.

Keys

sk is a secp256k1 private key and the only thing to back up. The owner address is address(sk). A factory deploys the vault with CREATE2, salted by the owner, so the vault address follows from sk and can receive funds before it is deployed. For each send, nonce n gives a fresh one-time key and account.

Key derivation: sk to owner, vault and one-time accountsskprimary keythe only backupaddress(·)hash of pubkeyownerpublic · never signsCREATE2salt = ownervaultaddress knownbefore deployn = 0otk₀ = sha256(0x01‖sk‖0)address(·)oneTimeAccount₀signs exactly one withdrawn = 1otk₁ = sha256(0x01‖sk‖1)address(·)oneTimeAccount₁signs exactly one withdrawn = 2otk₂ = sha256(0x01‖sk‖2)address(·)oneTimeAccount₂signs exactly one withdrawmsg.sender of a withdraw… one per send, n = 0, 1, 2, … The vault accepts each one-time account once.
Amber is secret and stays in the wallet. Green is public. Everything here follows from sk alone.
ValueDefinitionVisibility
owneraddress(sk), the primary address. It never signs.public: identifies the vault
otk_nsha256(0x01 ‖ sk ‖ n), used as a secp256k1 private keywallet only; signs exactly one tx
oneTimeAccount_naddress(otk_n)public: msg.sender of the withdraw
intentHashuint256(keccak256(abi.encode(chainId, vault, token, to, amount))) % Ppublic

Try it

Step the nonce. The owner and vault stay fixed, and every n gives a new one-time key and account.

Sending

Send flow: one withdrawWalletbrowser · holds skLocal proveron this machineFunding accountyour injected walletOne-time account nkey otk_n, in the walletVaultVernamVault1find next unused nderive otk_n2sk, n, intentHashprivate: sk, n (never leave this machine)proof πpublic: owner, oneTimeAccount, intentHash3gas (ETH)for one withdraw4withdraw(token, to, amount, π)signed by otk_n, sent via the RPC5check, mark used,transfer to recipient
  1. Find the next unused nonce n and derive otk_n and oneTimeAccount_n.
  2. The local prover proves: I know sk, n such that address(sk) = owner and address(sha256(0x01‖sk‖n)) = oneTimeAccount, bound to intentHash.
  3. A funding account sends oneTimeAccount_n gas for one transaction. When ZeroDev sponsors gas there is no top-up: the one-time EOA delegates to ZeroDev's Kernel v3.3 with EIP-7702, keeps its address, and ZeroDev's UltraRelay pays for its one UserOp.
  4. The one-time key signs vault.withdraw(token, to, amount, a, b, c) in the wallet and sends it through the RPC, never through the injected wallet.
  5. The vault rejects a msg.sender it has already seen, recomputes intentHash, verifies the proof against [owner, msg.sender, intentHash], marks msg.sender used, and transfers.

What the proof shows

Private inputs

sk and n. In the server-proof wallet they go from the browser to the prover on this machine and nowhere else. In the client-proof wallet they never leave the page.

Public inputs

[owner, oneTimeAccount, intentHash]. The vault fills them in from its own owner, msg.sender, and the withdraw's arguments.

In plain words: “whoever made this proof knows the key behind owner, this sender is that key's one-time account for some nonce, and this exact token, recipient and amount on this chain and vault were agreed to.” The proof reveals nothing about sk or n.

The EVM verifies the withdraw's ECDSA signature and gives the signer as msg.sender. The circuit proves that signer is derived from the owner's key and n. Together they prove “this signature was made by the one-time account for nonce n of owner.” Verifying the ECDSA signature again inside the circuit would add about 1.5M constraints and no extra guarantee.

Two wallets: server proof and client proof

The client-proof wallet drops the elliptic-curve math, which is what makes the circuit small enough for a browser. Its proof shows knowledge of the sk behind owner = Poseidon(sk) and reveals nullifier = Poseidon(sk, n). It no longer proves the sender comes from sk, and it doesn't need to: the vault spends each nullifier once, and intentHash names the sender, so a copied proof works only from the account it was made for. The wallet still sends every withdraw from a fresh one-time account.

Server-proof walletClient-proof wallet
Proof is madeon the server: a prover on this machinein this browser, inside the page
sk leaves the browseryes, to your own machinenever
Circuitsecp256k1 key → address twice, plus SHA-256: ~841k constraintstwo Poseidon hashes: ~1.1k constraints
Proving key~400 MB0.5 MB, downloaded by the page
Proof time~17 s~0.2 s
Owneraddress(sk)Poseidon(sk), a commitment
Vault spends onceused[msg.sender], the one-time accountused[nullifier], nullifier = Poseidon(sk, n)
Sender bindingthe circuit proves msg.sender = address(otk_n)intentHash includes msg.sender
Replay reverts withAccountUsedNullifierUsed

Replay and stolen proofs

Vault withdraw checkswithdraw(token, to, amount, π)sent by msg.senderused[msg.sender]?yesrevert AccountUsedreplay from the same accountnorecompute intentHash fromchainId, vault, token, to, amountproof valid for[owner, msg.sender, intentHash]?norevert InvalidProofstolen proof, other sender or intentyesused[msg.sender] = truetransfer amount to `to`
Replay
Resubmit a withdraw from the same one-time account: used[msg.sender] is already set, so the vault reverts with AccountUsed.
Stolen proof
Copy the proof and arguments and send them from another account: msg.sender no longer matches the proof's oneTimeAccount, so it reverts with InvalidProof.
Key recovered from the mempool
A quantum attacker can recover otk_n from the pending withdraw's signature and race a replacement from the same account. The proof binds (token, to, amount), so any replacement pays the same recipient.

The wallet's Advanced section runs the first two against your own last withdraw.

Nonce recovery

The nonce is not stored. The next nonce is the first n whose one-time account has never sent a transaction (pending tx count 0). If a withdraw reverts, that nonce is skipped rather than retried, so no one-time key ever signs twice.

  1. n = 0 1 tx · used
  2. n = 1 1 tx · used
  3. n = 2 1 tx · reverted, skipped
  4. n = 3 0 txs · next

Losing local state loses nothing: sk gives the owner, then the vault address, then the nonce, and the history comes back from the vault's Withdrawn events.

Limits

  • Groth16 on BN254 is not post-quantum sound. An attacker who can run Shor can recover the setup trapdoor and forge proofs for any vault. The demo shows the key discipline only. Real post-quantum security needs a hash-based proof system, such as a STARK.
  • Phase 1 of the trusted setup is the multi-party Hermez ceremony. Phase 2 is a single local contribution, for the demo only.
  • The secret is kept in localStorage in plaintext. It is for the demo only.
  • Gas for one-time accounts comes from a funding account (your injected wallet), unless ZeroDev sponsors it through EIP-7702.