Developers
Everything you need to build on Winternitz: the exact signature scheme, the contracts and their addresses, the TypeScript SDK, dApp integration, deployment and tests.
The WOTS scheme#
All integers are big-endian and ‖ means concatenation. The TypeScript SDK (sdk/src/wots.ts) and the Solidity verifier (contracts/src/WOTS.sol) implement exactly this.
Chain step F(x, i, j) = keccak256(x ‖ uint16(i) ‖ uint8(j)) i = chain 0..66, j = position 0..14
Secret element sk[k][i] = keccak256(seed ‖ uint32(k) ‖ uint16(i)) k = key index
Public element pk[k][i] = F¹⁵(sk[k][i])
Key commitment pkh[k] = keccak256(pk[k][0] ‖ … ‖ pk[k][66])
Digits d[0..63] = nibbles of the 32-byte digest, high nibble first
C = Σ (15 − d[t]) d[64..66] = the 3 nibbles of C
Sign sig[i] = F^d[i](sk[k][i])
Verify walk each sig[i] from position d[i] to 15; hash the 67 results; compare with pkh
In a UserOperation:
userOp.signature = nextPkh (32 bytes) ‖ WOTS signature (2,144 bytes) // 2,176 bytes
signed digest = keccak256(userOpHash ‖ nextPkh)
A stuck operation is replaced with a recovery key (key index 0x80000000 + r of the same seed):
userOp.signature = 0x01 ‖ nextPkh (32) ‖ nextRecoveryPkh (32) ‖ WOTS signature (2,144) // 2,209 bytes
signed digest = keccak256(userOpHash ‖ 0x01 ‖ nextPkh ‖ nextRecoveryPkh)
The account checks it against recoveryKeyHash, moves the main key to nextPkh (skipping the stuck key) and the recovery key to nextRecoveryPkh.
Putting the chain index and position into every hash step keeps each step in its own domain, so a value from one chain or position can't be reused on another.
Contracts#
| Contract | Role |
|---|---|
WOTS | Library: recoverPkh(digest, sig) and verify(pkh, digest, sig) |
QuantumSafeAccount | ERC-4337 account: publicKeyHash(), keyIndex(), recoveryKeyHash(), recoveryIndex(), execute, executeBatch, rotateRecoveryKey(next) (self only), events KeyRotated(keyIndex, used, next) and KeyRecovered(keyIndex, recoveryIndex, nextPkh, nextRecovery) |
QuantumSafeAccountFactory | createAccount(initialPkh, initialRecoveryPkh, salt) and getAddress(initialPkh, initialRecoveryPkh, salt) |
Addresses#
The deploy script uses the deterministic CREATE2 deployer, so these addresses are the same on every chain that has the canonical EntryPoint v0.7 (Robinhood Chain testnet and mainnet, Ethereum Sepolia, Base Sepolia, Arbitrum Sepolia):
| Contract | Address |
|---|---|
| EntryPoint v0.7 (canonical) | 0x0000000071727De22E5E9d8BAf0edAc6f37da032 |
| QuantumSafeAccountFactory | 0xBe3a0B5f698a4F83C7679aAe7E7743D5E3d2F468 |
| QuantumSafeAccount implementation | 0xcCBA0b7B10f809e8f3B3C1e9835acDeEdC020c39 |
| SimpleAccountFactory (ECDSA baseline for benchmarks) | 0x804B16c10153467c4bF15AA900BF69c13A4ceB8c |
Deployment status
These are the addresses the contracts will have. They are not yet deployed on Robinhood Chain; see Deploying. A test pins them, so any bytecode change that would move them fails CI.
SDK#
import { createPublicClient, http, parseEther } from "viem";
import { robinhoodTestnet } from "viem/chains";
import { QuantumSafeWallet, bundlerSubmitter, deployments, generateSeed } from "@winternitz/sdk";
const client = createPublicClient({ chain: robinhoodTestnet, transport: http() });
const d = deployments[robinhoodTestnet.id]!;
const wallet = await QuantumSafeWallet.load({
seed: generateSeed(), // keep this secret: it derives every key
factory: d.factory,
accountImplementation: d.accountImplementation, // address computed locally, no RPC call
entryPoint: d.entryPoint,
publicClient: client,
// journal: persist this in production (see "Never reuse a key")
});
console.log(wallet.address); // final address, usable before deployment
const result = await wallet.send(
[{ to: "0x…", value: parseEther("0.001") }],
bundlerSubmitter({ url: process.env.BUNDLER_URL!, entryPoint: d.entryPoint, client }),
);
console.log(result.transactionHash, `signed with key #${result.keyIndex}`);
| API | Purpose |
|---|---|
QuantumSafeWallet.load(opts) | Derive the address and prepare the client |
wallet.send(calls, submitter, onStep?) | Prepare, sign with the current key, submit, confirm |
wallet.getKeyState() | On-chain keyIndex, publicKeyHash, recoveryIndex, recoveryKeyHash |
wallet.sync() | Reconcile the journal with the chain: pending entry, recoveryExposed |
wallet.resendPending(submitter) | Re-submit an operation already signed with the current key |
wallet.replacePending(submitter) | Replace a stuck operation via the recovery key (same nonce, ≥ 125% fees) |
wallet.rotateRecoveryKey(submitter) | Retire a recovery key that was revealed but not used |
wallet.rotationHistory() | KeyRotated and KeyRecovered events |
bundlerSubmitter / selfSubmitter | Submit via a bundler, or via your own account calling handleOps |
wots.sign / verify / publicKeyHash | The raw signature scheme |
Swaps#
import { NATIVE, buildSwapCalls, quoteSwap } from "@winternitz/sdk";
const NVDA = "0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC";
const quote = await quoteSwap(client, { chainId: 4663, tokenIn: NATIVE, tokenOut: NVDA, amountIn: parseEther("0.01") });
const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200);
await wallet.send(buildSwapCalls(quote, { slippageBps: 100, deadline }), submitter);
quoteSwap tries every direct pool and every two-hop route among the listed hookless Uniswap v4 pools, with the on-chain V4 Quoter, and returns the best. buildSwapCalls returns [approve, Permit2.approve, UniversalRouter.execute] (just the router call for ETH), so the swap is one operation and one key. A WOTS key is consumed by its first signature, so the account cannot sign off-chain messages: no Permit2 signatures and no UniswapX orders.
| API | Purpose |
|---|---|
quoteSwap(client, { chainId, tokenIn, tokenOut, amountIn }) | Best route and output; NoRouteError if no pool connects the pair |
buildSwapCalls(quote, { slippageBps, deadline }) | Calls for one operation; reverts on-chain below the minimum |
minimumOut(quote, slippageBps) | The minimum output the router enforces |
swapSupported(chainId) / swappableTokens(chainId) | Where swaps work, and which currencies the pools reach |
wallet.estimateFee(calls) | Network fee for any calls, swaps included |
Never reuse a key#
The wallet client enforces the one rule that matters most:
- It reads the key index from the chain before signing and refuses if the seed doesn't match the on-chain key (
KeyMismatchError). - It writes every signature to a journal before it leaves the wallet, and refuses to sign a different operation with a journaled key (
KeyReuseError). - While an operation signed with the current key is unconfirmed, new sends are blocked (
PendingOperationError) until youresendPending, orreplacePendingif it is stuck.
Provide a persistent journal (the CLI uses a file, the extension uses chrome.storage). The default in-memory journal is only suitable for tests.
dApp integration#
The Chrome extension is a standard EIP-1193 provider, announced via EIP-6963:
window.addEventListener("eip6963:announceProvider", (event) => {
if (event.detail.info.rdns === "xyz.winternitz.wallet") {
const provider = event.detail.provider;
provider.request({ method: "eth_requestAccounts" });
}
});
window.dispatchEvent(new Event("eip6963:requestProvider"));
Supported: eth_requestAccounts, eth_accounts, eth_chainId, eth_sendTransaction, wallet_switchEthereumChain, permission methods and read-only RPC calls. Refused with 4200: personal_sign, eth_sign, eth_signTypedData*, eth_signTransaction, eth_sendRawTransaction, and contract creation.
Deploying#
cp .env.example .env # set DEPLOYER_PRIVATE_KEY (a funded testnet account)
set -a; source .env; set +a
npm run deploy:robinhood-testnet # ~0.0001 ETH, verifies on Blockscout
The script writes contracts/deployments/<chainId>.json. Local development uses Anvil (npm run deploy:local); an Anvil fork of Robinhood Chain Testnet reproduces the real addresses.
Tests#
cd contracts && forge test # unit + fuzz (512 runs each)
cd contracts && WOTS_FFI=true FOUNDRY_PROFILE=ffi forge test --mt Ffi # live TypeScript ↔ Solidity fuzzing
npm test -w @winternitz/sdk # WOTS unit tests
npm run test:int -w @winternitz/sdk # wallet against Anvil
node sdk/scripts/benchmark.ts # gas vs ECDSA on a chain