The launch window — the launch block for the creator alone, then caps for everyone else — was written in block.number. Our own contract notes flagged that on an Arbitrum Orbit chain this might be the parent chain’s block rather than the chain’s own, and that the fork tests could not tell, because hardhat runs its own counter. It sat there as a “confirm before deploying” item. This week we confirmed it.
The measurement
One read of each, seconds apart, on 20 September 2026: eth_blockNumber returned 67,532,809. A contract read of block.number (Multicall3’s getBlockNumber(), which just returns it) returned 26,015,315. ArbSys(0x64).arbBlockNumber() returned 67,532,835. Two more eth_blockNumber reads 31 seconds apart differed by 303.
| Read | Value | Moves every |
|---|---|---|
| eth_blockNumber | 67,532,809 | ~0.1 s |
| block.number inside the EVM | 26,015,315 | ~12 s |
| ArbSys.arbBlockNumber() | 67,532,835 | ~0.1 s |
So the window our documentation described as “two blocks” was two Ethereum blocks: somewhere between 24 and 36 seconds, not a fraction of a second, and in a unit nothing else in the system uses. The indexer stores L2 heights; restrictionsEndBlock in the launch event was an L1 height; comparing them was meaningless. The protection itself still worked — it was just measured in the wrong clock and described wrongly. This post is the correction.
What the token does now
It asks the chain for its own height. ArbSys is a precompile at address 100 on every Nitro and Orbit chain; arbBlockNumber() is the L2 block, the one the explorer shows and the indexer stores. The token calls it with a fixed gas allowance and falls back to block.number if the address does not answer, which is what happens on a plain hardhat network. The allowance matters more than it looks: on a fork, address 100 is fetched as a single INVALID opcode, and an uncapped call to it eats every unit of gas the transaction has. We found that the hard way, with 25 failing fork tests.
With the unit fixed, the number had to be chosen again. Two L2 blocks is two hundred milliseconds — a rule about ordering, technically, but not one anyone could use. We set restrictionBlocks = 300: about thirty seconds, the same order of protection the old code gave by accident, now in the unit it claims. The launch block is still the creator’s alone; that part was never about duration.
One more change while we were in the hook. Once a transfer observes that the window is over, the token records it, and every later transfer skips the block read for the rest of the token’s life. A launched token is a plain ERC-20 for all but its first thirty seconds, and it should cost like one.
The loophole we closed on the way
The caps applied to transfers out of the pool. Uniswap’s SwapRouter02 lets a caller set the recipient to the router itself and hand the tokens out with sweepToken in the same call. The first leg was pool-to-router, and the router’s balance was always near zero, so every chunk passed; the second leg was router-to-wallet, which was not a pool transfer and was not checked. A sniper could take well over 5% in the window through that route while the honest path was capped.
The token now knows the router. A transfer to it during the window is not counted — parked tokens belong to nobody yet — and a transfer from it is capped exactly like one from the pool, keyed by the wallet that ends up holding the tokens. The fork test that used to pass through now reverts on the sweep leg, with the same wrapped reason a capped pool buy gives.
Three smaller things
- A launch configuration whose opening tick was not a multiple of the pool’s tick spacing was accepted, and would have reverted every launch deep inside Uniswap after the fee had been paid. The factory now checks it first and names the error.
- A DEX configuration with no router was accepted; launches without an opening buy would succeed and launches with one would revert after all the work was done. Rejected on add now.
- Uniswap rounds the position mint down, leaving about twelve thousand units of each token in the factory with no way out. They now go to the creator along with everything else.
And then the factory did not fit
Every one of these adds bytecode, and the factory embeds the token’s entire creation code, so it grows when the token does. It went from 133 bytes under the 24,576-byte limit to 945 over. Lowering the optimizer setting recovered two hundred. We moved the token bytecode into a fourth contract, CountdownTokenDeployer, which does the CREATE2 on the factory’s behalf and nothing else. The factory is now 15,699 bytes, with room for years of this.
The deployer has no owner and anyone can call it, which sounds like the kind of thing that lets someone squat on the address the factory is about to use. It does not: the salt is namespaced by caller, so an address the factory predicts can only be created by the factory. A unit test deploys the same arguments and salt from a stranger and checks that the factory’s address stays empty.
The lesson is the one we keep relearning: an assumption that is cheap to check and expensive to be wrong about should be checked, and the docs should say which. The measurement took four RPC calls.