[ethresear.ch] [Layer 2] A hash-only rollup: OP Stack L2 accounts as Lamport-style hash chains, no ECDSA inside the L2
October 24
Disclosure: I’m the founder of bb84, the payments app that runs on this chain. Feedback, especially critical feedback, is very welcome.
TL;DR
We run an OP Stack rollup on Ethereum mainnet (chain ID 42069000, live since 2026-09-30) where L2 transactions carry no signatures at all. An account is the tip of a keccak-256 hash chain. Spending reveals the next preimage. A commit–reveal step binds each preimage to one transaction before the preimage becomes public. Inside the L2 the only cryptographic assumption is preimage resistance of keccak-256, which Grover reduces to about 2^128 work. No known quantum algorithm does better. ECDSA still appears at the L1 boundary, and the system has other trust assumptions (listed below).
I’d like to discuss whether this is a sensible interim design for post-quantum payments on Ethereum L2s, and where it breaks.
Accounts
s_0 = keccak256("OP_OTA_SPEND_v1" ‖ spend_sk ‖ r) // per-account secret
s_k = keccak256(s_{k-1}) // k = 1..N, N = 1000 by default
C = s_N // public commitment
address = trunc20(keccak256("OP_HASH_ADDR_v1" ‖ C))
A predeploy (NullifierTree, 0x4200…0042) stores per address: lastReveal (initially C), chainDepth (initially N) and the set of spent nullifiers. Accounts are registered through an L1 deposit or by the system-side StealthPool predeploy when someone pays a fresh one-time address.
Transactions
Two new EIP-2718 types in op-geth:
- 0x7C HashCommitTx:
(chainId, from, nonce, hashCommit, gas…, expireAfterBlocks) - 0x7F HashRevealTx:
(chainId, from, nonce, to, value, gas…, data, nullifier, preImage, viewTag, newCommitment, newChainLength)
from is declared explicitly, like a deposit tx. The sender’s authority is checked in the state transition, before gas purchase. That check replaces ecrecover:
txDigest = keccak256(RLP(chainId, from, nonce, to, value, gas, feeCap, tipCap, data,
newCommitment, newChainLength))
1. from is registered and chainDepth[from] > 0
2. keccak256(preImage) == lastReveal[from]
3. nullifier == keccak256("OP_NULLIFIER_v1" ‖ preImage ‖ nonce ‖ txDigest)
4. nullifier not spent
5. a matching commitment exists:
keccak256("OP_COMMIT_BIND_v1" ‖ nullifier ‖ preImage ‖ txDigest ‖ nonce)
After execution: lastReveal ← preImage, chainDepth −= 1, and the nullifier is marked spent. Authorization is consumed even if the EVM call reverts. After the Onyx hardfork the state transition rejects every ECDSA-typed transaction outright. eth_call still works.
Chain renewal. A chain has N uses. On the last step (chainDepth == 1) the reveal may carry newCommitment/newChainLength. Both are part of txDigest, so they are bound by the commitment. The account continues under the same address with a fresh chain. If you miss that step, the address is dead. Spending everything down to one-left therefore has to be handled carefully by the wallet.
Front-running
Once preImage is broadcast, anyone can read it. Without protection, an attacker could put the same preimage into a transaction with a different to. The commit step exists to prevent that. The user first lands hashCommit, which binds (preimage, txDigest) without revealing them, and reveals only afterwards (same block, or within expireAfterBlocks, default 5).
The subtle part is that the commit itself is not authenticated: there is nothing to sign it with. Our first version simply overwrote the pending commit of an address with the latest one. That is broken: an attacker who reads a preimage from a pending reveal can replace the victim’s commit with one binding the same preimage to their own txDigest. We found this ourselves while writing this post and fixed it with a consensus rule that activated on 2026-10-21 00:00 UTC:
- First commit wins. Each address has one pending-commit slot. A
HashCommitTxis rejected while the address still has an unexpired pending commit. The slot is cleared only by the matching reveal or by expiry (expireAfterBlocks, default 5). - The wallet reveals only after its commit is in a block. It waits for the commit receipt before it broadcasts the reveal. From then on no other commit for that address can land until the honest reveal has consumed the slot.
- Txpool: a commit/reveal pair that is bound to an existing pending commit cannot be replaced. A commit that the first-wins rule would reject is refused at admission.
- RPC: the public endpoint only serves
eth_,net_andweb3_methods, without pending-transaction filters, so the pool is not readable from outside.
What remains:
- Griefing, not theft. Anyone can post a junk commit for an address and block it for up to 5 blocks. Such a commit cannot be opened with a preimage the attacker does not know, so it cannot move funds. It is limited by the per-address rate limit.
- Edge cases with a malicious sequencer: if the honest commit expires or is reorged out while its reveal is pending, the preimage is known to whoever sees the pool and is no longer bound. Today only our sequencer sees the pool, and the wallet re-checks the commit before retrying, but the protocol should not rely on that.
- A cleaner rule would be Guy Fawkes-style: commitments are appended rather than kept in one slot, and a reveal is valid only if the earliest commitment that opens with its preimage binds its
txDigest. That removes the griefing vector and the expiry edge case, but needs a change to the transaction format and the wallet. We have not built it yet.
Payments-specific additions
- One-time addresses. A payer derives a fresh account for the recipient. An 8-bit view tag lets recipients scan the
StealthPoolannouncement events cheaply. - Token model. Bridged USDC/EURC only, as
StealthMintableERC20instances (factory predeploy at 0x4200…0045). There is no native-ETH spending by users. Commit and reveal transactions are gas-free for users. Spam is limited by a per-address rate limit in the txpool (10 transactions per minute). - Withdrawals. These go through a separate message passer (0x4200…0043) and a modified
OptimismPortal. A withdrawal is proven against the output root, so L2-side authorization is again a hash reveal.
What this does NOT give you (honest limits)
- L1 is still ECDSA. Deposits come from L1 EOAs, and withdrawals pay out to L1 addresses. A quantum attacker on L1 is out of scope.
- No fault proofs yet. Output roots are posted by a permissioned proposer through a custom dispute game type that resolves immediately. There is no proof system, so by L2BEAT’s framework this would not even be Stage 0. Withdrawals therefore trust the proposer. Merchant fast withdrawals (2 h instead of 7 days) additionally trust a registry operator.
- Centralized sequencer, operated by us.
- 160-bit addresses. Truncation leaves about 2^80 quantum second-preimage resistance on the address alone. Registration is first-come, which limits what an attacker can do with such a preimage, but it is weaker than the 2^128 for the chain itself.
- Stateful keys. A user who reuses a preimage cannot double-spend, because the nullifier and chain step prevent it. A wallet that loses track of its position, however, can lock itself out until it resyncs from chain state. That happened to us in production (desync between app state and chain), so wallet-side resynchronization is mandatory.
Questions for the community
- Is “first commit wins” with a short expiry sound under a sequencer that can reorder or reorg? A malicious sequencer can always censor, but under which conditions can it steal (e.g. by letting an honest commit expire while holding its reveal)? Is the append-only Guy Fawkes variant worth the extra state?
- Are there cleaner ways to authenticate the commit step without signatures, for example a second, shorter hash chain used only for commitments?
- Would hash-chain accounts make sense as an EIP-7702-style or native AA validation mode on general-purpose L2s, rather than as a dedicated chain?
- Fault proofs: the state transition only adds keccak checks and storage writes, so it should be provable with Cannon/asterisc unchanged. Has anyone extended
op-programwith custom tx types?
A longer write-up with the full state-transition rules is in preparation for arXiv; I’ll link it here. Developer docs are at BB84 Developer Docs — Hash-Only L2 , the app at https://bb84.com.
Visit Topic to respond.
You are receiving this because you enabled mailing list mode.