Docs · Developers▾
Build

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#

ContractRole
WOTSLibrary: recoverPkh(digest, sig) and verify(pkh, digest, sig)
QuantumSafeAccountERC-4337 account: publicKeyHash(), keyIndex(), recoveryKeyHash(), recoveryIndex(), execute, executeBatch, rotateRecoveryKey(next) (self only), events KeyRotated(keyIndex, used, next) and KeyRecovered(keyIndex, recoveryIndex, nextPkh, nextRecovery)
QuantumSafeAccountFactorycreateAccount(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):

ContractAddress
EntryPoint v0.7 (canonical)0x0000000071727De22E5E9d8BAf0edAc6f37da032
QuantumSafeAccountFactory0xBe3a0B5f698a4F83C7679aAe7E7743D5E3d2F468
QuantumSafeAccount implementation0xcCBA0b7B10f809e8f3B3C1e9835acDeEdC020c39
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}`);
APIPurpose
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 / selfSubmitterSubmit via a bundler, or via your own account calling handleOps
wots.sign / verify / publicKeyHashThe 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.

APIPurpose
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:

  1. It reads the key index from the chain before signing and refuses if the seed doesn't match the on-chain key (KeyMismatchError).
  2. It writes every signature to a journal before it leaves the wallet, and refuses to sign a different operation with a journaled key (KeyReuseError).
  3. While an operation signed with the current key is unconfirmed, new sends are blocked (PendingOperationError) until you resendPending, or replacePending if 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