Summary
Anchor.getPreconfMetadata() uses anchorBlockNumber != 0 as its sentinel for "metadata exists", but BlockParams.anchorBlockNumber is documented as "0 to skip" (a legitimate input meaning "do not advance the L1 checkpoint this block"). A block legitimately recorded with anchorBlockNumber == 0 is therefore indistinguishable from a block that was never recorded, so getPreconfMetadata reverts for it. _validateBlock performs no anchorBlockNumber != 0 guard, so nothing on-chain prevents the collision.
Code references
contracts/layer2/core/Anchor.sol:36 — uint48 anchorBlockNumber; // L1 block number to anchor (0 to skip)
contracts/layer2/core/Anchor.sol:208 — require(preconfMetadata.anchorBlockNumber != 0, InvalidBlockNumber()); (the overloaded sentinel)
contracts/layer2/core/Anchor.sol:250-256 — _storePreconfMetadata stores anchorBlockNumber verbatim, unconditionally, for every block.
contracts/layer2/core/Anchor.sol:218-237 — _validateBlock has no anchorBlockNumber != 0 validation.
- Consumers:
contracts/layer2/preconf/PreconfSlasherL2.sol:45,106,174 call getPreconfMetadata(...) and rely on its revert-on-absent behavior.
Impact
If a preconf-relevant block is ever produced with anchorBlockNumber == 0, every getPreconfMetadata(thatBlock) reverts, so PreconfSlasherL2 cannot validate any fault for that block. Because _validateMissingEOPFault / _validateInvalidEOPFault also read getPreconfMetadata(blockNumber + 1), a block produced with anchorBlockNumber == 0 can additionally block slashing of the prior block. Net effect: slashing evasion / fault-proof DoS.
Reachability
Currently latent. The driver passes the actual (possibly unchanged) L1 block number rather than 0 — test_anchorV4_allowsMultipleAnchorsAcrossBlocks confirms a non-advancing block reuses the real number (1000), and the only anchorBlockNumber: 0 usage in the tree is the genesis generator (test/genesis/generate/taikoAnchor.ts), which is not slashable. It becomes exploitable only if anchorBlockNumber == 0 can reach a preconfirmed block via a driver change or proposer-influenced input. The point of the fix is to remove the safety dependence on off-chain driver behavior.
Fix
Decouple metadata-existence from anchorBlockNumber by adding an explicit bool stored marker to PreconfMetadata (packed into the existing uint48 slot, so the contract-level storage layout and the bytes32 sub-slots are unchanged) and using it as the sentinel. This preserves the documented "anchorBlockNumber == 0 to skip" semantic rather than removing it (which a naive require(anchorBlockNumber != 0) in _validateBlock would do).
PR with the minimal change + regression tests: see linked PR below.
Found during a recursive audit of the core protocol contracts and their dependencies.
Summary
Anchor.getPreconfMetadata()usesanchorBlockNumber != 0as its sentinel for "metadata exists", butBlockParams.anchorBlockNumberis documented as "0 to skip" (a legitimate input meaning "do not advance the L1 checkpoint this block"). A block legitimately recorded withanchorBlockNumber == 0is therefore indistinguishable from a block that was never recorded, sogetPreconfMetadatareverts for it._validateBlockperforms noanchorBlockNumber != 0guard, so nothing on-chain prevents the collision.Code references
contracts/layer2/core/Anchor.sol:36—uint48 anchorBlockNumber; // L1 block number to anchor (0 to skip)contracts/layer2/core/Anchor.sol:208—require(preconfMetadata.anchorBlockNumber != 0, InvalidBlockNumber());(the overloaded sentinel)contracts/layer2/core/Anchor.sol:250-256—_storePreconfMetadatastoresanchorBlockNumberverbatim, unconditionally, for every block.contracts/layer2/core/Anchor.sol:218-237—_validateBlockhas noanchorBlockNumber != 0validation.contracts/layer2/preconf/PreconfSlasherL2.sol:45,106,174callgetPreconfMetadata(...)and rely on its revert-on-absent behavior.Impact
If a preconf-relevant block is ever produced with
anchorBlockNumber == 0, everygetPreconfMetadata(thatBlock)reverts, soPreconfSlasherL2cannot validate any fault for that block. Because_validateMissingEOPFault/_validateInvalidEOPFaultalso readgetPreconfMetadata(blockNumber + 1), a block produced withanchorBlockNumber == 0can additionally block slashing of the prior block. Net effect: slashing evasion / fault-proof DoS.Reachability
Currently latent. The driver passes the actual (possibly unchanged) L1 block number rather than 0 —
test_anchorV4_allowsMultipleAnchorsAcrossBlocksconfirms a non-advancing block reuses the real number (1000), and the onlyanchorBlockNumber: 0usage in the tree is the genesis generator (test/genesis/generate/taikoAnchor.ts), which is not slashable. It becomes exploitable only ifanchorBlockNumber == 0can reach a preconfirmed block via a driver change or proposer-influenced input. The point of the fix is to remove the safety dependence on off-chain driver behavior.Fix
Decouple metadata-existence from
anchorBlockNumberby adding an explicitbool storedmarker toPreconfMetadata(packed into the existinguint48slot, so the contract-level storage layout and thebytes32sub-slots are unchanged) and using it as the sentinel. This preserves the documented "anchorBlockNumber == 0to skip" semantic rather than removing it (which a naiverequire(anchorBlockNumber != 0)in_validateBlockwould do).PR with the minimal change + regression tests: see linked PR below.
Found during a recursive audit of the core protocol contracts and their dependencies.