From b7e4a213be9691421693faabb9f6f255cfbcedec Mon Sep 17 00:00:00 2001 From: Jack Chuma Date: Wed, 19 Aug 2026 13:00:26 -0400 Subject: [PATCH] fix(L1): reject proof games for not-yet-reached L2 timestamps `AggregateVerifier.initializeWithInitData` pinned `scheduleId` from the claimed L2 block's deterministic timestamp without requiring that timestamp to have passed on L1. Because post-Fjord batch validation allows an L2 timestamp up to 1800s ahead of its L1 origin, a modified sequencer could open a game pinning an activation that `ProtocolVersions` still allowed the owner to clear or delay, leaving the game permanently bound to a schedule the canonical chain rolled back. Requiring the claim timestamp to be at or before the L1 block timestamp closes that window, since an activation becomes immutable once L1 reaches it. Cantina finding #21. Co-authored-by: Cursor --- snapshots/abi/AggregateVerifier.json | 16 ++++++++++ snapshots/semver-lock.json | 4 +-- src/L1/proofs/AggregateVerifier.sol | 9 ++++++ test/L1/OptimismPortal2.t.sol | 9 ++++-- test/L1/proofs/AggregateVerifier.t.sol | 39 +++++++++++++++++++++++++ test/L1/proofs/BaseTest.t.sol | 9 ++++++ test/L1/proofs/DisputeGameFactory.t.sol | 4 +++ 7 files changed, 86 insertions(+), 4 deletions(-) diff --git a/snapshots/abi/AggregateVerifier.json b/snapshots/abi/AggregateVerifier.json index 9f332e12b..ee3b801a1 100644 --- a/snapshots/abi/AggregateVerifier.json +++ b/snapshots/abi/AggregateVerifier.json @@ -1177,6 +1177,22 @@ "name": "L2BlockBeforeGenesis", "type": "error" }, + { + "inputs": [ + { + "internalType": "uint64", + "name": "claimTimestamp", + "type": "uint64" + }, + { + "internalType": "uint256", + "name": "l1Timestamp", + "type": "uint256" + } + ], + "name": "L2TimestampInFuture", + "type": "error" + }, { "inputs": [ { diff --git a/snapshots/semver-lock.json b/snapshots/semver-lock.json index 5642bd2f7..a3a2963e5 100644 --- a/snapshots/semver-lock.json +++ b/snapshots/semver-lock.json @@ -28,8 +28,8 @@ "sourceCodeHash": "0x780ff372493ba9010bc0d13100ac896f2bf75730a9b17d4bb63aaf694dc3c634" }, "src/L1/proofs/AggregateVerifier.sol:AggregateVerifier": { - "initCodeHash": "0x83b451d1b77752c41bef7a038cf9ce9a7c7d3b59ef601acd0239a15a8ce32279", - "sourceCodeHash": "0x0dc7ebcb331c4ea3ac23cace04e69995589828a5ab9087376fbf1241db7a62ea" + "initCodeHash": "0x84c6bf00e337c889993c409f18b17986f9d9546ac6754f7730962459d301c21d", + "sourceCodeHash": "0xab669e0a55462740b4c1c06fe94aa709044146e3eeec4a0123fbc67dd7f35c28" }, "src/L1/proofs/AnchorStateRegistry.sol:AnchorStateRegistry": { "initCodeHash": "0x6f3afd2d0ef97a82ca3111976322b99343a270e54cd4a405028f2f29c75f7fb1", diff --git a/src/L1/proofs/AggregateVerifier.sol b/src/L1/proofs/AggregateVerifier.sol index 9f05b9e81..788e70fb6 100644 --- a/src/L1/proofs/AggregateVerifier.sol +++ b/src/L1/proofs/AggregateVerifier.sol @@ -284,6 +284,9 @@ contract AggregateVerifier is Clone, ReentrancyGuard, ISemver { /// @notice Thrown when an L2 block timestamp cannot be represented as a uint64. error L2TimestampOverflow(uint256 blockNumber); + /// @notice Thrown when the claimed L2 block's timestamp has not yet been reached on L1. + error L2TimestampInFuture(uint64 claimTimestamp, uint256 l1Timestamp); + /// @notice Thrown when there are not enough proofs to resolve the game. error NotEnoughProofs(); @@ -423,6 +426,12 @@ contract AggregateVerifier is Clone, ReentrancyGuard, ISemver { if (blocksSinceGenesis > maxBlocks) revert L2TimestampOverflow(claimBlock); uint64 claimTimestamp = L2_GENESIS_TIMESTAMP + uint64(blocksSinceGenesis * uint256(L2_BLOCK_TIME)); + + // `ProtocolVersions` only freezes an activation once L1 time reaches it, so a claim whose L2 + // timestamp is still in L1's future would pin a schedule the owner can afterwards clear or + // delay. Requiring the claim to have already passed on L1 keeps the pin canonical for life. + if (claimTimestamp > block.timestamp) revert L2TimestampInFuture(claimTimestamp, block.timestamp); + scheduleId = PROTOCOL_VERSIONS.activatedScheduleId(claimTimestamp); // Set the game's starting timestamp. diff --git a/test/L1/OptimismPortal2.t.sol b/test/L1/OptimismPortal2.t.sol index 3f8865b6d..afa597ae1 100644 --- a/test/L1/OptimismPortal2.t.sol +++ b/test/L1/OptimismPortal2.t.sol @@ -103,8 +103,13 @@ abstract contract OptimismPortal2_TestInit is DisputeGameFactory_TestInit { depositor = makeAddr("depositor"); - // Warp forward in time to ensure that the game is created after the retirement timestamp. - vm.warp(anchorStateRegistry.retirementTimestamp() + 1); + // Warp forward in time to ensure that the game is created after the retirement timestamp, + // and after the proposed block's deterministic L2 timestamp, which L1 must have reached + // before a game claiming that block can be created. + uint256 retirementFloor = anchorStateRegistry.retirementTimestamp() + 1; + uint256 proposedBlockTimestamp = gameImpl.L2_GENESIS_TIMESTAMP() + + (_proposedBlockNumber - gameImpl.L2_GENESIS_BLOCK_NUMBER()) * gameImpl.L2_BLOCK_TIME(); + vm.warp(retirementFloor > proposedBlockTimestamp ? retirementFloor : proposedBlockTimestamp); game = _createDisputeGame(Claim.wrap(_outputRoot), 0); diff --git a/test/L1/proofs/AggregateVerifier.t.sol b/test/L1/proofs/AggregateVerifier.t.sol index cf18583c0..80b7d2942 100644 --- a/test/L1/proofs/AggregateVerifier.t.sol +++ b/test/L1/proofs/AggregateVerifier.t.sol @@ -93,6 +93,43 @@ contract AggregateVerifierTest is BaseTest { assertEq(secondGame.scheduleId(), protocolVersions.activatedScheduleId(400)); } + /// @notice A claim whose L2 timestamp L1 has not yet reached cannot open a game, so a game can + /// never pin an activation that the owner is still able to clear or delay. + function test_initialize_l2TimestampInFuture_reverts() public { + // The first game ends at L2 block 100, whose deterministic timestamp is 200. Scheduling the + // upgrade there and leaving the L1 clock short of it is the window the finding exploits. + uint64 activationTimestamp = uint64(BLOCK_INTERVAL * L2_BLOCK_TIME); + protocolVersions.registerUpgrade(activationTimestamp, 1); + assertLt(block.timestamp, activationTimestamp); + + // Created straight through the factory, since the shared helper advances L1 to the claim. + Claim rootClaim = _advanceL2BlockAndClaim(); + bytes memory proof = _generateProof("future-l2", AggregateVerifier.ProofType.TEE); + bytes memory extraData = + _aggregateVerifierExtraData(rootClaim, currentL2BlockNumber, address(anchorStateRegistry)); + + vm.deal(TEE_PROVER, INIT_BOND); + vm.prank(TEE_PROVER); + vm.expectRevert( + abi.encodeWithSelector(AggregateVerifier.L2TimestampInFuture.selector, activationTimestamp, block.timestamp) + ); + factory.createWithInitData{ value: INIT_BOND }(GameTypes.AGGREGATE_VERIFIER, rootClaim, extraData, proof); + + // Once L1 reaches the activation, the schedule is frozen and the game becomes creatable. + vm.warp(activationTimestamp); + vm.expectRevert( + abi.encodeWithSelector( + IProtocolVersions.ProtocolVersions_ActivationAlreadyPassed.selector, 0, activationTimestamp + ) + ); + protocolVersions.setTimestamp(0, 0); + + AggregateVerifier game = _createAggregateVerifierGame( + TEE_PROVER, rootClaim, currentL2BlockNumber, address(anchorStateRegistry), proof + ); + assertEq(game.scheduleId(), protocolVersions.activatedScheduleId(activationTimestamp)); + } + function test_constructor_zeroL2BlockTime_reverts() public { AggregateVerifier.ScheduleConfig memory scheduleConfig = AggregateVerifier.ScheduleConfig({ protocolVersions: IProtocolVersions(address(protocolVersions)), @@ -507,6 +544,8 @@ contract AggregateVerifierTest is BaseTest { bytes32 l1Head = blockhash(block.number - 1); address clone = address(impl).clone(abi.encodePacked(creator, rootClaim, l1Head, extraData)); + _warpToL2Timestamp(l2BlockNumber); + vm.deal(creator, INIT_BOND); vm.prank(creator); AggregateVerifier(payable(clone)).initializeWithInitData{ value: INIT_BOND }(proof); diff --git a/test/L1/proofs/BaseTest.t.sol b/test/L1/proofs/BaseTest.t.sol index c0b6a101e..7edbb9cfc 100644 --- a/test/L1/proofs/BaseTest.t.sol +++ b/test/L1/proofs/BaseTest.t.sol @@ -152,6 +152,8 @@ contract BaseTest is Test { { bytes memory extraData = _aggregateVerifierExtraData(rootClaim, l2BlockNumber, parentAddress); + _warpToL2Timestamp(l2BlockNumber); + vm.deal(creator, INIT_BOND); vm.prank(creator); return AggregateVerifier( @@ -163,6 +165,13 @@ contract BaseTest is Test { ); } + /// @dev A game is only creatable once L1 has reached the claimed L2 block's deterministic + /// timestamp, which mirrors production: a block is proven well after it is produced. + function _warpToL2Timestamp(uint256 l2BlockNumber) internal { + uint256 claimTimestamp = L2_GENESIS_TIMESTAMP + (l2BlockNumber - L2_GENESIS_BLOCK_NUMBER) * L2_BLOCK_TIME; + if (block.timestamp < claimTimestamp) vm.warp(claimTimestamp); + } + function _provideProof(AggregateVerifier game, address prover, bytes memory proofBytes) internal { vm.prank(prover); game.verifyProposalProof(proofBytes); diff --git a/test/L1/proofs/DisputeGameFactory.t.sol b/test/L1/proofs/DisputeGameFactory.t.sol index ec94880a5..367ecd2a7 100644 --- a/test/L1/proofs/DisputeGameFactory.t.sol +++ b/test/L1/proofs/DisputeGameFactory.t.sol @@ -258,6 +258,10 @@ contract DisputeGameFactory_Create_Test is DisputeGameFactory_TestInit { uint256 bondAmount = disputeGameFactory.initBonds(GameTypes.AGGREGATE_VERIFIER); vm.deal(address(this), bondAmount); + // The claimed L2 block's deterministic timestamp (block number x blockTime) must have + // already passed on L1 for the game to be creatable. + vm.warp((startingRoot.l2SequenceNumber + AGGREGATE_BLOCK_INTERVAL) * 2); + uint256 gameCountBefore = disputeGameFactory.gameCount(); IDisputeGame proxy = disputeGameFactory.createWithInitData{ value: bondAmount }( GameTypes.AGGREGATE_VERIFIER, rootClaim, extraData, proof