Skip to content

Pin the parent chain hash via ArbSys on an Arbitrum host chain - #448

Draft
hkalodner wants to merge 3 commits into
feat/mel-validation-changesfrom
fix/l3-terminal-anchor
Draft

Pin the parent chain hash via ArbSys on an Arbitrum host chain#448
hkalodner wants to merge 3 commits into
feat/mel-validation-changesfrom
fix/l3-terminal-anchor

Conversation

@hkalodner

@hkalodner hkalodner commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

createNewAssertion records the next assertion's terminal as:

bytes32 nextParentChainBlockHash = blockhash(block.number - 1);

On an Arbitrum host chain blockhash resolves through ProcessingHook.L1BlockHash to ArbOS's L1 block hash ring buffer, so this stores an L1 hash where afterMELState.parentChainBlockHash is a hash of this chain's own parent. The equality check twenty lines earlier then cannot pass:

require(
    afterMELState.parentChainBlockHash
        == assertion.beforeStateData.configData.nextParentChainBlockHash,
    "BAD_PARENT_CHAIN_BLOCK_HASH"
);

No L3 assertion satisfies that, and because block.number - 1 is inside the 256-block window it returns a plausible non-zero hash rather than reverting — so it fails silently.

This is new code missing an established pattern, not a latent bug

The class of issue is well known here. main already has five hostChainIsArbitrum branches in SequencerInbox.sol and _assertionCreatedAtArbSysBlock in this very file, commented "this will be the ArbSys blockNumber if the host chain is an Arbitrum chain."

What's new is the call site. blockhash( does not appear anywhere in src/ on main — this is the only use of it in the contracts, introduced with MEL, and it didn't take the branch the codebase takes everywhere else. The fix mirrors the pattern thirty lines below it in the same function.

ArbSys.arbBlockHash reverts only outside currentBlockNum-256 <= n < currentBlockNum (precompiles/ArbSys.go), so one block back is comfortably inside the window.

Both terminals, not just one

createNewAssertion is the recurring case; RollupAdminLogic.initialize seeds the genesis assertion's terminal the same way. Fixing only the first leaves the very first L3 assertion unmatchable, so both go through the shared _nextParentChainBlockHash helper.

Scope

Nothing in production is affected — none of nextParentChainBlockHash, afterMELState or MELState exists on main. On a non-Arbitrum host the else branch is the current code, so L1-parent chains are unchanged.

Pairs with OffchainLabs/nitro-private#672

That one fixes MEL writing the parent chain block number where the Bridge hashed the L1 block number in delayed message headers.

Both are needed before MEL works on an L3; neither is sufficient alone. They can merge independently and in any order — each is a strict improvement and a no-op on L1 parents — but do not read either landing as "L3 fixed." nitro-private picks this up via a contracts submodule bump once this lands in the MEL stack.

Base branch

Retargeted from mel-bold-changes to feat/mel-validation-changes (#427). mel-bold-changes is abandoned — its own PRs #436 and #438 were both closed unmerged and its tip has not moved since 2026-07-09, so nothing based on it could merge. #427 is where SAME_BLOCK_ASSERTION and the MEL assertion machinery this patches are introduced, which makes it the lowest point in the live stack that the fix belongs at.

That also cleared the Check Contracts Format failure. src/osp/OneStepProofEntry.sol had a line at 104 chars against line_length = 100, pushed over by one of the five OSP commits exclusive to the old base; on feat/mel-validation-changes it is exactly 100.

Verification

forge fmt --check clean, forge build succeeds, and FOUNDRY_PROFILE=test forge test --match-path test/foundry/RollupParentChainHash.t.sol passes 2/2 on the new base.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates RollupCore.createNewAssertion so that, on Arbitrum host chains, the “next parent chain block hash” is pinned using ArbSys (instead of blockhash), aligning the pinned hash with what MEL tracks as the parent-chain hash and preventing BAD_PARENT_CHAIN_BLOCK_HASH failures in that environment.

Changes:

  • Replace blockhash(block.number - 1) with an _hostChainIsArbitrum conditional that uses ArbSys.arbBlockHash(arbBlockNumber() - 1) on Arbitrum host chains.
  • Add inline commentary explaining why blockhash() is incorrect on Arbitrum host chains for this value.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/rollup/RollupCore.sol Outdated
Comment thread src/rollup/RollupCore.sol Outdated
@hkalodner

Copy link
Copy Markdown
Contributor Author

Both comments were right, and the second one meant this PR did not actually fix L3. Addressed in 767522fc.

RollupAdminLogic.initialize was still on blockhash. Confirmed: src/rollup/RollupAdminLogic.sol:77 computed nextParentChainBlockHash = blockhash(block.number - 1) untouched. Since the genesis assertion pins the terminal its first child has to extend from, fixing only createNewAssertion fixes nothing — the first post-genesis assertion on an Arbitrum host chain would still fail BAD_PARENT_CHAIN_BLOCK_HASH. Good catch; it was the whole point of the change.

Rather than copy the conditional, extracted _nextParentChainBlockHash() into RollupCore and called it from both sites. RollupAdminLogic already inherits RollupCore and uses _hostChainIsArbitrum twenty-five lines below the bug, and the immutable is initialized at construction so it is live during initialize.

Test added in test/foundry/RollupParentChainHash.t.sol. Two notes on why it looks the way it does:

  • _hostChainIsArbitrum is an immutable initialized from ArbitrumChecker, so the arbOSVersion mock has to be in place before the contract is deployed, not before the call. That is what makes vm.mockCall in setUp-then-deploy the wrong order here.
  • RollupAdminLogic is concrete, so a small harness deriving from it exposes the helper without standing up a whole rollup. Restructuring Rollup.t.sol for a mocked Arbitrum host would touch every one of its blockhash(block.number - 1) expectations.

Verified it discriminates: reverting the fix fails testUsesArbSysOnArbitrumHostChain with must pin arbBlockHash(arbBlockNumber() - 1), not the L1 ring buffer, while testUsesBlockhashOffArbitrum keeps passing. The test also asserts up front that the two sources differ in the fixture, so it cannot pass vacuously.

70 existing RollupTest cases still pass. forge fmt diffs in both touched files are pre-existing on the base branch and none fall in the changed regions.

One thing worth recording for anyone else running these: tests need FOUNDRY_PROFILE=test, since the default profile sets skip = ['test/*'].

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

hkalodner and others added 3 commits August 21, 2026 07:42
createNewAssertion recorded the next assertion's terminal as
blockhash(block.number - 1). On an Arbitrum host chain that resolves
through ProcessingHook.L1BlockHash to ArbOS's L1 block hash ring buffer,
so it stored an L1 hash where afterMELState.parentChainBlockHash is a
hash of this chain's own parent.

The equality check in createNewAssertion then cannot pass, and because
block.number - 1 sits inside the 256-block window it returns a plausible
non-zero hash rather than reverting. This makes MEL unusable on any L3.

Use ArbSys.arbBlockHash(arbBlockNumber() - 1) on an Arbitrum host,
matching the branch this function already takes for
_assertionCreatedAtArbSysBlock and the one SequencerInbox takes for
keyset creation blocks. One block back is well inside arbBlockHash's
256-block window.

Pairs with the nitro-side fix on fix/mel-l3-correctness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback: the fix was incomplete. createNewAssertion was changed
but RollupAdminLogic.initialize still computed nextParentChainBlockHash
from blockhash(block.number - 1), so on an Arbitrum host chain the
genesis assertion pinned an L1 ring-buffer hash and the first
post-genesis assertion would still fail BAD_PARENT_CHAIN_BLOCK_HASH.
The two have to agree -- genesis pins the terminal its first child must
extend from -- so fixing only one of them fixes nothing.

Extract _nextParentChainBlockHash() into RollupCore and call it from
both, rather than copying the conditional. RollupAdminLogic already
inherits RollupCore and uses _hostChainIsArbitrum twenty-five lines
below the bug; the immutable is initialized at construction, so it is
available during initialize.

Also adds the test the branch never had. ArbitrumChecker.runningOnArbitrum
returns false under Foundry unless ArbSys answers, and because
_hostChainIsArbitrum is an immutable the mock has to be in place before
deployment, not after. A RollupAdminLogic-derived harness exposes the
helper, which is enough to exercise both branches without standing up a
rollup. Reverting the fix fails the Arbitrum case and leaves the
off-Arbitrum one passing, so it discriminates.

70 existing RollupTest cases still pass. Note tests need
FOUNDRY_PROFILE=test; the default profile sets skip = ['test/*'].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Swept for other places the new helper belongs. There are none -- every
producer of nextParentChainBlockHash now routes through it, and the two
remaining references consume a stored value rather than computing one
(decreaseBaseStake reconstructs an existing configHash from a
caller-supplied hash; EdgeChallengeManager reads prevConfig).

The one site that invites a second look is the SAME_BLOCK_ASSERTION
guard, which counts L1 blocks while the hash it protects now advances
with arbBlockNumber(). That mismatch is real and it is sound: every host
block carries exactly one block.number, so two assertions in the same
host block always see the same one and are rejected, while many host
blocks can share a block.number, so the guard can only reject assertions
whose hashes would in fact have differed. Over-rejects, never
under-rejects, at the cost of waiting for the next L1 block on a
fast-block host.

Comment only -- no behaviour change. Recorded so the next reader does
not have to derive it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hkalodner
hkalodner force-pushed the fix/l3-terminal-anchor branch from 4db62e5 to b42e891 Compare August 21, 2026 11:45
@hkalodner
hkalodner changed the base branch from mel-bold-changes to feat/mel-validation-changes August 21, 2026 11:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants