On 55 live EVM chains, block.number inside a contract is not that chain's block number.
It is the parent chain's height, off by a median of 23 million blocks and a maximum of 468
million. Nothing reverts. Code that compares it against eth_blockNumber to get a
confirmation depth just gets a wrong number, and code that waits on that depth waits forever.
node chainclock.js https://rpc.degen.tips
chain 666666666 via https://rpc.degen.tips
eth_blockNumber 26961433 (what your indexer sees)
block.number 49964976 (what a contract sees, read via state override)
ArbSys.arbBlockNumber 26961433 (this chain's own height)
The clocks DISAGREE by 23,003,543 blocks, and it is the chain, not the RPC:
pinned 16 blocks back, block.number did not follow the tag.
...
This chain has ArbSys. Publish this from your contract and compare against THAT:
address constant ARBSYS = 0x0000000000000000000000000000000000000064;
function chainBlock() public view returns (uint256) {
(bool ok, bytes memory d) = ARBSYS.staticcall(abi.encodeWithSignature("arbBlockNumber()"));
if (ok && d.length == 32) return abi.decode(d, (uint256));
return block.number; // not an Arbitrum-family chain
}
No install, no key, no transaction, no deployment. Exit 0 if the clocks agree, 1 if they don't, 2 if the chain couldn't be measured — so it drops into CI in front of a deploy.
Every chain in ethereum-lists/chains with a public HTTPS RPC, measured 2026-08-14:
| chains with a usable HTTPS endpoint | 2,491 |
| answered at all | 915 |
block.number readable |
746 |
| a genuinely different clock | 55 |
| of those, have ArbSys | 52 |
of those 52, arbBlockNumber() == eth_blockNumber |
52 — every one |
That last row is the useful part. The fix isn't a heuristic: on all 52 Arbitrum-family
chains, without a single exception, ArbSys.arbBlockNumber() returned exactly the chain's
own head. One staticcall with a fallback is correct everywhere, and you don't need to know
what kind of chain you're on to use it.
Full data in chains.report.json, the 55 in TABLE.md.
Worst offenders:
| chain id | name | skew |
|---|---|---|
| 42161 | Arbitrum One | −468,733,834 |
| 421614 | Arbitrum Sepolia | −286,592,296 |
| 1829 | PlayBlock | −238,344,739 |
| 1729 | Reya Network | −181,225,391 |
| 44474237230 | Deriw Devnet | −131,557,513 |
| 660279 | Xai Mainnet | −109,238,277 |
| 1625 | Gravity Alpha Mainnet | −104,072,675 |
| 46630 | Robinhood Chain Testnet | −89,641,485 |
| 98866 | Plume Mainnet | −61,461,643 |
| … | ||
| 18896214 | Crynux on Base | +49,956,229 |
| 666666666 | Degen Chain | +23,003,543 |
The sign flips depending on whether the L3 or its parent has been running longer, which is why "is it ahead or behind" is not a thing you can assume — only measure.
It really is the parent's height, not an offset: in the run above Degen's block.number
read 49,964,976 while Base — Degen's parent — answered eth_blockNumber 49,964,990 a few
seconds later. Same clock.
The first version of this survey reported 98 chains. That number was wrong three separate ways, and every one of them inflates the result:
Ask for latest twice and you race yourself. Reading eth_blockNumber and evaluating
block.number are two different moments, even inside one JSON-RPC batch. On a fast chain the
head moves between them and you record a skew of ±1 that belongs to the clock on the wall.
24 of the 98 were this — they read exactly zero once the call was pinned to a fixed
block, which is what this tool now does.
Some nodes execute eth_call in a block other than the one you name. Pin the call at
block N and they build a pending block on top and run there, so block.number is N+1. It
looks identical to a one-block clock skew. It isn't: it's an RPC convention, a deployed
contract never sees it, and the test is to pin 16 blocks back and check whether the answer
moves by 16 too. 11 chains do exactly that — Conflux eSpace, IoTeX, IOTA EVM, Etherlink,
ZKFair, ShimmerEVM, Jovay. Waterfall Network is the strange one: it tracks the tag faithfully
but offset by −252.
Some nodes ignore the block tag entirely, answering from the head whatever you ask for. Then the two numbers can't be pinned to one moment, and a gap of one block proves nothing in either direction. 7 chains — six SKALE hubs and Velas — are unprovable this way, so they're reported as inconclusive rather than counted. They may be fine; the measurement can't say.
98 → 55. The three things look the same from one request and are only distinguishable from two, which is the whole reason this is a tool and not a one-liner.
You hit this the moment an off-chain process compares a number a contract wrote against a
number a node reported. That is most bridges, most indexers, most "wait for N
confirmations" loops, most subgraph handlers that stamp block.number into an entity, and
every dispute window measured in blocks.
The failure is silent and it is not a revert. On Degen a relayer computes a confirmation depth of minus 23 million, waits for it to reach 12, and relays nothing, ever. Found by deploying a real bridge to Degen and watching it do exactly that.
Two things that follow, if you're writing this code:
- Stamp
chainBlock(), notblock.number, and have your off-chain side read the same function rather thaneth_blockNumber. Then it never needs to know what chain it's on. - Depth alone doesn't terminate on a quiet L3. Degen produces a block only when someone transacts — measured zero blocks in twenty seconds idle — so "wait 12 confirmations" means "wait for eleven strangers." Pair every depth rule with an age rule.
A survey that stops at "55 chains can break this way" is a hypothesis. So I read every verified contract on one of them.
Degen has 437 verified Solidity contracts. Twenty-six touch block.number or
blockhash. Twenty of those are widely-deployed infrastructure that happens to live there —
ERC-4337 EntryPoints, Multicall3, LayerZero's EndpointV2, Hyperlane's Mailbox, proxies. Six
are applications someone deployed for this chain.
Correction — that 26 is a 27 % over-count. Seven of the twenty-six have every hit inside a doc comment, and all seven are ERC-4337 contracts vendoring the same upstream line:
Note that the validation code cannot use block.timestamp (or block.number) directly.The scanner counted the warning as the offence, so the most prominent "user" ofblock.numberon this chain is a comment telling you not to use it. Corrected funnel: 26 mention it → 19 in real code → 11 depend on it → 10 after checking each deployment. See DISCLOSURE.md andclassify.py, which asserts the comment calls against the source text so the table cannot rot.DISCLOSURE.md also adds a second failure mode that needs no boundary crossing: on Degen
block.numberis not unique per block — twelve consecutive local blocks at height 5,000,000 all report13048713. Any contract whose safety argument contains the words "in the same block" — ablockhash(block.number - 1)guard, a checkpoint key — is relying on a guarantee this chain does not provide, whether or not the value ever leaves the contract.
Most uses are self-consistent and therefore fine: if a contract writes block.number + N
and later compares it against block.number, both reads are the same clock and the logic
works. It only means the deadline is denominated in the parent chain's blocks, which is a
surprise rather than a bug. The failure needs a boundary — a block height that leaves the
contract and gets compared against this chain's eth_blockNumber.
One live contract crosses it. A MasterChef fork at
0xe3584Ce2…
with 44,858 LP tokens and 276,196 DSWAP staked in it stores, right now:
pool 0 lastRewardBlock = 48,240,221
pool 1 lastRewardBlock = 48,491,751
Degen eth_blockNumber = 26,961,445 <- the chain it is deployed on
Base eth_blockNumber = 49,965,426 <- the number in its storage
That is not an inference, it's a storage read: the parent chain's height, sitting in a Degen contract's state, twenty-one million blocks away from anything Degen's own explorer will show you. Two consequences follow, and only the first is certain:
- Any tool that reads
lastRewardBlockand interprets it against Degen's block height is wrong by 21 million. That includes the obvious "blocks since last update" on a dashboard. - The emission clock is Base's.
cubPerBlockis 0.001 DSWAP and rewards are minted, so the token inflates once per Base block (~2s) rather than once per Degen block (~75s measured): 47.5 DSWAP/day instead of 1.27, a factor of 37.5, or 1.64%/yr against the current supply instead of 0.044%. Whether that is a bug depends on intent, which I can't read from the chain — but it is not the number a Degen block explorer would lead you to.
And one honest negative: IceCreamSwapBridge expires proposals on block.number - proposedBlock > _expiry, which is exactly the shape that breaks — except _expiry is set to
1,000,000,000 blocks, so the window is ~63 years either way and the skew cannot matter.
So the trap is real, and on this chain it is rarely stepped in. That's the useful finding:
worth checking, not worth panicking about. fetch.py and analyze.py reproduce it against
any Blockscout explorer.
block.number is only visible from inside the EVM, and deploying a contract on 2,491 chains
to find out is not an option. So: nine bytes of runtime code
0x43 60 00 52 60 20 60 00 f3 NUMBER, PUSH1 0, MSTORE, PUSH1 32, PUSH1 0, RETURN
installed at a throwaway address for the duration of a single eth_call via a state
override. Nothing is deployed, nothing is spent, no key is involved. No PUSH0, so it runs
on pre-Shanghai EVMs too. Where a node rejects state overrides, it falls back to
Multicall3.getBlockNumber() at 0xcA11bde05977b3631167028862bE2a173976CA11, which returns
the same value where both answer.
Use the canonical address for that fallback, and no other. Arbitrum-family chains also
carry ArbMulticall2 — on Degen at 0x5304b5DbBfCe2fb40AE11Ed51E70699FC1F25fC9 — whose
source mentions block.number but whose getBlockNumber() deliberately routes through
ArbSys.arbBlockNumber(). Called there, it returns the local height and the skew reads
zero. Probing with it would report every Orbit chain as clean, which is the exact opposite
of the truth.
chainclock.js the tool: measure one chain, classify, print the fix
scan.js sweep every chain in the registry -> results.json
confirm.js re-probe the hits with the call pinned to a fixed block
pin2.js pin 16 blocks back: an RPC convention, or a real second clock?
deep.js pin 10,000 back: does this node honour the tag at all?
chains.report.json the final classified dataset
TABLE.md the 55, sorted by magnitude
fetch.py pull every verified contract from a Blockscout explorer
analyze.py classify how they use block.number: internal, boundary, blockhash
degen.usage.json the 26 Degen contracts that touch it
classify.py comment vs code, then LOCAL vs UNIQUE: 26 -> 19 -> 11 -> 9
rate.py sample all three clocks every 15s -> rate.json
DISCLOSURE.md which of them it actually breaks, and what to do about it
Reproduce: curl -o chains.json https://chainid.network/chains.json && node scan.js && node confirm.js && node pin2.js. Endpoints come from a list published for public use, one
read each, and nothing writes to any chain.
MIT.