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.
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.
sk alone.| Value | Definition | Visibility |
|---|---|---|
| owner | address(sk), the primary address. It never signs. | public: identifies the vault |
| otk_n | sha256(0x01 ‖ sk ‖ n), used as a secp256k1 private key | wallet only; signs exactly one tx |
| oneTimeAccount_n | address(otk_n) | public: msg.sender of the withdraw |
| intentHash | uint256(keccak256(abi.encode(chainId, vault, token, to, amount))) % P | public |
Try it
Step the nonce. The owner and vault stay fixed, and every n gives a new one-time key and account.
Sending
- Find the next unused nonce
nand deriveotk_nandoneTimeAccount_n. - The local prover proves: I know
sk, nsuch thataddress(sk) = ownerandaddress(sha256(0x01‖sk‖n)) = oneTimeAccount, bound tointentHash. - A funding account sends
oneTimeAccount_ngas 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. - 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. - The vault rejects a
msg.senderit has already seen, recomputesintentHash, verifies the proof against[owner, msg.sender, intentHash], marksmsg.senderused, 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 wallet | Client-proof wallet | |
|---|---|---|
| Proof is made | on the server: a prover on this machine | in this browser, inside the page |
| sk leaves the browser | yes, to your own machine | never |
| Circuit | secp256k1 key → address twice, plus SHA-256: ~841k constraints | two Poseidon hashes: ~1.1k constraints |
| Proving key | ~400 MB | 0.5 MB, downloaded by the page |
| Proof time | ~17 s | ~0.2 s |
| Owner | address(sk) | Poseidon(sk), a commitment |
| Vault spends once | used[msg.sender], the one-time account | used[nullifier], nullifier = Poseidon(sk, n) |
| Sender binding | the circuit proves msg.sender = address(otk_n) | intentHash includes msg.sender |
| Replay reverts with | AccountUsed | NullifierUsed |
Replay and stolen proofs
- 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.
- n = 0 1 tx · used
- n = 1 1 tx · used
- n = 2 1 tx · reverted, skipped
- 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.