Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/rollup/RollupAdminLogic.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
33 changes: 32 additions & 1 deletion src/rollup/RollupCore.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
71 changes: 71 additions & 0 deletions test/foundry/RollupParentChainHash.t.sol
Original file line number Diff line number Diff line change
@@ -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)"
);
}
}
Loading