Architecture
Winternitz is a monorepo with one on-chain layer, one shared TypeScript SDK, and three interfaces built on top of it.
Interfaces
Web wallet
web/, Next.js
Chrome extension
extension/, Manifest V3
CLI
cli/, Node.js
↓ all built on ↓
SDK in sdk/ (TypeScript and viem)
WOTS
key generation, signing, verification
Wallet client
key journal and rotation guard
Submitters
bundler or self-relay
↓ talks to ↓
On-chain in contracts/ (Solidity)
QuantumSafeAccount
ERC-4337 account
WOTS.sol
signature verifier
Account factory
CREATE2 clones
Repository layout#
winternitz/
├── contracts/ Solidity + Foundry: WOTS verifier, account, factory, tests, deploy script
├── sdk/ TypeScript: WOTS, wallet client, key journal, submitters, networks, tokens
├── cli/ Command-line wallet (Node 24 runs the TypeScript directly)
├── extension/ Chrome extension, Manifest V3 (esbuild + Tailwind)
├── web/ Next.js: landing page, web wallet (/wallet), docs (/docs)
└── docs/ Design notes and benchmark report
| Choice | Used for |
|---|---|
| Solidity 0.8.28, Foundry | Contracts, unit, fuzz and differential tests |
| TypeScript, viem | SDK, CLI, extension and web wallet |
| ERC-4337, EntryPoint v0.7 | Account standard (canonical 0x0000000071727De22E5E9d8BAf0edAc6f37da032) |
| Alto (local), Alchemy (Robinhood Chain) | Bundlers |
| Robinhood Chain, Ethereum Sepolia, Base Sepolia, Arbitrum Sepolia | Networks |
| Next.js 16, React 19 | Web |
On-chain#
WOTS.solis a library that walks each hash chain from the signature to its end and hashes the 67 results into a public key hash. It is written in assembly to keep verification around 80k gas.QuantumSafeAccountis the ERC-4337 account. It storespublicKeyHashandkeyIndex, validates signatures withWOTS.sol, rotates the key on success, and executes single or batched calls. It has no owner and no direct-call path: only the EntryPoint (or the account itself) can make it act.QuantumSafeAccountFactorydeploys accounts as minimal proxies (EIP-1167) at CREATE2 addresses derived from the first public key hash. The address is known before deployment and cannot be claimed by anyone else.
Off-chain#
- WOTS module: key derivation from the seed, signing, verification; byte-for-byte identical to the Solidity verifier (checked by differential tests).
- Wallet client: builds UserOperations, reads the key index from the chain before every signature, and keeps a write-ahead journal of every signature so a key can never sign twice.
- Submitters: send through an ERC-4337 bundler, or "self-relay" by calling
handleOpsfrom an ordinary account that is refunded by the EntryPoint. - Networks and tokens: chain registry (Robinhood Chain testnet and mainnet, Sepolia, local) and the verified token lists.
One transaction, end to end#
Your device
derives one-time key #n from the seed
Bundler / relayer
ERC-4337
EntryPoint v0.7
canonical
Your account
checks key #n, then stores #n+1
- The interface asks the wallet client to send a set of calls.
- The client reads
keyIndexandpublicKeyHashfrom the account (or uses key #0 if it isn't deployed yet). - It builds the UserOperation, gets a gas estimate, and on Arbitrum chains adds the L1 data fee.
- It records the operation in the journal, then signs with key #n while committing to key #n+1.
- The bundler or relayer submits it; the EntryPoint calls
validateUserOp, which verifies and rotates the key; then the calls execute. - The client confirms the new key index on-chain and marks the journal entry as confirmed.
Robinhood Chain specifics#
Robinhood Chain is an Arbitrum Orbit chain. The contracts need no changes, but three things are handled off-chain:
- L1 data fee. Arbitrum charges the cost of posting data to Ethereum as extra gas on the bundle. The SDK queries Arbitrum's NodeInterface for it and includes it in
preVerificationGas. - Incompressible estimate. The fee is based on compressed data, and a real WOTS signature doesn't compress. Gas estimates therefore use a pseudo-random placeholder signature; a constant one would under-estimate by roughly 89k gas.
- Bundler pricing. Alchemy's bundler requires a minimum priority fee, which the SDK reads with
rundler_maxPriorityFeePerGas.