diff --git a/src/rollup/RollupAdminLogic.sol b/src/rollup/RollupAdminLogic.sol index 2cf1144d..fe54a9ab 100644 --- a/src/rollup/RollupAdminLogic.sol +++ b/src/rollup/RollupAdminLogic.sol @@ -74,7 +74,10 @@ contract RollupAdminLogic is RollupCore, IRollupAdmin, DoubleLogicUUPSUpgradeabl afterStateHash: config.genesisAssertionState.hash() }); - bytes32 nextParentChainBlockHash = blockhash(block.number - 1); + // Must match createNewAssertion's: this pins the terminal the first post-genesis + // assertion has to extend from, and on an Arbitrum host chain blockhash() would pin + // an L1 hash that MEL's parentChainBlockHash can never equal. + bytes32 nextParentChainBlockHash = _nextParentChainBlockHash(); AssertionNode memory initialAssertion = AssertionNodeLib.createAssertion( true, RollupLib.configHash({ diff --git a/src/rollup/RollupCore.sol b/src/rollup/RollupCore.sol index d342f8ca..573fae79 100644 --- a/src/rollup/RollupCore.sol +++ b/src/rollup/RollupCore.sol @@ -416,6 +416,28 @@ abstract contract RollupCore is IRollupCore, PausableUpgradeable { delete _stakerMap[stakerAddress]; } + /** + * @dev The parent chain block hash to pin as the terminal an assertion's child must + * extend from. Compared against afterMELState.parentChainBlockHash when that child + * is created, so it has to be a hash of *this* chain's parent chain. + * + * On an Arbitrum host chain blockhash() resolves against the L1 block hash ring + * buffer, so it would pin an L1 hash where MEL supplies a hash of the host chain's + * parent, and the equality check could never pass. ArbSys gives the right one; a + * single block back is well inside its 256-block window. + * + * Used by both createNewAssertion and RollupAdminLogic.initialize — the genesis + * assertion pins the terminal for the first post-genesis assertion, so both have to + * agree or that first assertion cannot be created. + */ + function _nextParentChainBlockHash() internal view returns (bytes32) { + if (_hostChainIsArbitrum) { + ArbSys arbSys = ArbSys(address(100)); + return arbSys.arbBlockHash(arbSys.arbBlockNumber() - 1); + } + return blockhash(block.number - 1); + } + function createNewAssertion( AssertionInputs calldata assertion, bytes32 prevAssertionHash, @@ -460,6 +482,15 @@ abstract contract RollupCore is IRollupCore, PausableUpgradeable { { // We want to prevent multiple assertions from being created in the same block, as this would allow them to have the same `nextParentChainBlockHash`, // which would be an already processed block hash by the time the assertions are created. + // + // On an Arbitrum host chain these are deliberately different units: block.number and + // createdAtBlock are L1 block numbers, while _nextParentChainBlockHash() advances with + // arbBlockNumber(). That is still sound, and in the safe direction. Every host block + // carries exactly one block.number, so two assertions in the same host block always + // see the same one and are rejected; many host blocks can share a block.number, so the + // check can only reject assertions whose hashes would in fact have differed. It over- + // rejects, never under-rejects. The cost is waiting for the next L1 block on a + // fast-block host, far below any real assertion cadence. require((block.number - prevAssertion.createdAtBlock) >= 1, "SAME_BLOCK_ASSERTION"); // This new assertion consumes the messages from prevParentChainBlockHash to afterParentChainBlockHash @@ -507,7 +538,7 @@ abstract contract RollupCore is IRollupCore, PausableUpgradeable { ); // Next assertion will have to process messages from blocks up to the previous one - bytes32 nextParentChainBlockHash = blockhash(block.number - 1); + bytes32 nextParentChainBlockHash = _nextParentChainBlockHash(); // state updates AssertionNode memory newAssertion = AssertionNodeLib.createAssertion( diff --git a/test/foundry/RollupParentChainHash.t.sol b/test/foundry/RollupParentChainHash.t.sol new file mode 100644 index 00000000..2fc8a3c1 --- /dev/null +++ b/test/foundry/RollupParentChainHash.t.sol @@ -0,0 +1,71 @@ +// Copyright 2026, Offchain Labs, Inc. +// For license information, see https://github.com/OffchainLabs/nitro-contracts/blob/main/LICENSE.md +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "../../src/rollup/RollupAdminLogic.sol"; +import "../../src/precompiles/ArbSys.sol"; + +/// Exposes the internal helper both createNewAssertion and RollupAdminLogic.initialize use to +/// pin the terminal an assertion's child must extend from. RollupAdminLogic is concrete and +/// inherits RollupCore's `_hostChainIsArbitrum` immutable, so deploying one is enough to +/// exercise the branch without standing up a whole rollup. +contract NextParentChainBlockHashHarness is RollupAdminLogic { + function exposedNextParentChainBlockHash() external view returns (bytes32) { + return _nextParentChainBlockHash(); + } +} + +contract RollupParentChainHashTest is Test { + uint256 constant ARB_BLOCK = 12_345; + bytes32 constant ARB_BLOCK_HASH = keccak256("arbBlockHash(12344)"); + + /// `_hostChainIsArbitrum` is an immutable initialized from ArbitrumChecker at construction, + /// so ArbSys has to answer before the harness is deployed, not after. + function _mockArbitrumHost() internal { + vm.mockCall( + address(100), + abi.encodeWithSelector(ArbSys.arbOSVersion.selector), + abi.encode(uint256(11)) + ); + } + + function testUsesArbSysOnArbitrumHostChain() public { + _mockArbitrumHost(); + NextParentChainBlockHashHarness harness = new NextParentChainBlockHashHarness(); + + vm.mockCall( + address(100), + abi.encodeWithSelector(ArbSys.arbBlockNumber.selector), + abi.encode(ARB_BLOCK) + ); + vm.mockCall( + address(100), + abi.encodeWithSelector(ArbSys.arbBlockHash.selector, ARB_BLOCK - 1), + abi.encode(ARB_BLOCK_HASH) + ); + + assertEq( + harness.exposedNextParentChainBlockHash(), + ARB_BLOCK_HASH, + "must pin arbBlockHash(arbBlockNumber() - 1), not the L1 ring buffer" + ); + // The bug this guards: blockhash() on an Arbitrum host resolves against the L1 ring + // buffer, so MEL's parentChainBlockHash could never equal it. + assertTrue( + harness.exposedNextParentChainBlockHash() != blockhash(block.number - 1), + "fixture must make the two sources differ, or this test proves nothing" + ); + } + + function testUsesBlockhashOffArbitrum() public { + NextParentChainBlockHashHarness harness = new NextParentChainBlockHashHarness(); + assertEq( + harness.exposedNextParentChainBlockHash(), + blockhash(block.number - 1), + "off an Arbitrum host chain the terminal is still blockhash(block.number - 1)" + ); + } +}