Apr 12, 2026 · 7 min
Building Veltrix L2
What I got wrong three times before the chain ever produced a block, and the parts nobody warns you about.
Most L2 write-ups start with the contracts. I am starting with the directory layout, because that is the part I kept getting wrong.
It's four programs, not one
An OP-Stack rollup is not a single thing. It is four:
op-geth- the execution clientop-node- the sequencerop-batcher- publishes batch data to L1op-proposer- submits state roots to L1
Each one has its own flags. Each one has its own L1 contract addresses. Each one expects a specific data format. If any of them drift out of sync the chain does not throw an error. It just gets slow. Slow in a way you only catch by staring at batcher logs for two days.
The bug that cost me a weekend
First version of Veltrix used the standard monorepo and docker-compose. The sequencer started. The batcher crashed on every submission. The proposer silently fell behind. I spent two days grepping logs.
The bug was a typo in rollup.json. The L1 bindings pointed at a contract address that did not exist on Sepolia. Every batcher transaction reverted but did not show up as an error in the standard log stream. I only caught it by manually calling the address with cast and getting back "contract does not exist."
If I had built a single binary that bundles all four with a shared config file, I would have caught it in 20 minutes.
The bridge
Once the chain was producing blocks the next wall was the bridge.
Mental model:
- deposit:
OptimismPortal.depositTransactionon L1 - withdraw:
L2ToL1MessagePasser.initiateWithdrawalon L2
Implementation is not the mental model. The L1 part has to wait for the state root to be proposed and finalized. On a real chain that takes about a week. The only way to test it end to end without burning a calendar week per cycle is a devnet with a zero-second challenge period and a controlled proposer.
The bridge UI
This was where most of the real debugging happened.
Three states matter:
- initiated - L2 tx succeeded, not yet proven
- proven - state root on L1 matches the L2 output
- finalized - challenge period elapsed
Each one needs its own UI state. Each one needs its own retry path because the L1 RPC rate-limits constantly. Each one needs copy that explains to the user why their withdrawal is taking seven days.
Pattern that actually works: poll L1 every 15 seconds with exponential backoff. WebSockets sound nice. Sepolia public RPCs drop them constantly.
If I did it again
Single binary. Shared config file. Skip docker-compose entirely. The OP Stack team is moving in that direction anyway.
The lesson is the same as every other multi-process system I have ever built. Make the failure mode visible. A silently slow chain is worse than a loudly broken one.