Skip to content

feat(actions): Wire Production Payload Builder#1861

Open
refcell wants to merge 6 commits intomainfrom
feat/actions-wire-production-payload-builder
Open

feat(actions): Wire Production Payload Builder#1861
refcell wants to merge 6 commits intomainfrom
feat/actions-wire-production-payload-builder

Conversation

@refcell
Copy link
Copy Markdown
Contributor

@refcell refcell commented Apr 1, 2026

Summary

Replaces the bespoke StatefulL2Executor inside ActionEngineClient with the real OpPayloadBuilder stack. L2Sequencer now uses the production L1OriginSelector and StatefulAttributesBuilder to produce payload attributes, then delegates block building to ActionEngineClient via start_build_block / get_sealed_payload / insert_unsafe_payload. This eliminates the divergence between the test harness and the production block-building code path, so action tests exercise the real execution pipeline end-to-end. get_next_payload_l1_origin and build_attributes on PayloadBuilder are made pub to support this wiring.

@refcell refcell added K-feature Kind: New feature M-refactor Meta: refactors code A-actions Area: actions testing labels Apr 1, 2026
@refcell refcell self-assigned this Apr 1, 2026
@refcell refcell added K-feature Kind: New feature M-refactor Meta: refactors code A-actions Area: actions testing labels Apr 1, 2026
@refcell refcell requested review from danyalprout and jackchuma April 1, 2026 18:33
@cb-heimdall
Copy link
Copy Markdown
Collaborator

cb-heimdall commented Apr 1, 2026

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 0
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 1, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
base Ignored Ignored Preview Apr 6, 2026 3:52pm

Request Review

@refcell refcell marked this pull request as draft April 1, 2026 18:35
@github-actions github-actions bot added the M-prevent-stale Meta: Not Stale label Apr 2, 2026
@refcell refcell force-pushed the feat/actions-wire-production-payload-builder branch from cb494fe to 4b29e90 Compare April 3, 2026 12:20
@refcell refcell force-pushed the feat/actions-wire-production-payload-builder branch from 2408906 to a4b6082 Compare April 5, 2026 16:27
Comment on lines +343 to +344
if let Some(executed) = built.executed_block() {
let execution_output = executed.execution_output;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

executed.execution_output is an Arc<ExecutionOutput>, and on the next lines execution_output.state.clone() and execution_output.result.receipts.clone() deep-clone the state bundle and receipts. Since ExecutionOutcome owns these fields by value, this is unavoidable with the current API — but built is not used after this branch, so consider using Arc::try_unwrap on the execution output to take ownership without cloning:

let execution_output = Arc::try_unwrap(executed.execution_output)
    .unwrap_or_else(|arc| (*arc).clone());

This avoids the redundant clones when the Arc refcount is 1 (which it will be for non-shared payloads).

Comment on lines +413 to +419
// In derivation mode (`TestRollupNode`) the payload is constructed with a zeroed
// `block_hash` placeholder because the engine is expected to fill it in. When we see
// B256::ZERO we treat the block-number lookup alone as sufficient — the block was
// pre-built by the sequencer and its state is already committed to the DB.
if let Some(existing) = inner.executed_headers.get(&payload.block_number) {
let existing_hash = existing.hash_slow();
if payload.block_hash == B256::ZERO || payload.block_hash == existing_hash {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The block_hash == B256::ZERO fallback means that any external caller (e.g. a future test utility) that happens to pass B256::ZERO as the block hash will skip execution entirely and return a stale header from a different block at the same number.

If the intent is specifically to support TestRollupNode's derivation path, consider using a dedicated flag or method (execute_from_attrs, which this PR already provides) rather than overloading B256::ZERO as a sentinel. The derivation node already uses execute_from_attrs in execute_and_advance, so this fallback in execute_v1_inner may be dead code in practice.

Comment on lines +583 to 591
// 8. Update L2 provider state for next iteration.
self.l2_provider.insert_block(self.head);
// The system config is updated via the attributes builder's internal
// L2 chain provider when the epoch changes. For the sequencer's
// L2 provider copy, inherit the genesis config — the attributes
// builder reads the correct config from its own provider clone.

Ok(block)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The L2 provider is updated with the new block (line 584), but the system config is never updated for new epochs. The comment says "the attributes builder reads the correct config from its own provider clone" — but StatefulAttributesBuilder takes l2_provider.clone() at construction time (harness.rs:277). If l2_provider is cloned, it's a separate instance, and the sequencer's self.l2_provider updates are not visible to the attributes builder's clone.

This works today because ActionL2ChainProvider::system_config_by_number walks back from number to find the nearest config, which always finds the genesis config. But this means system config updates from L1 deposits (e.g. gas limit changes, fee scalar updates) are never reflected in the sequencer's attribute construction. If a test ever relies on mid-sequence system config changes, it will silently use stale values.

@refcell refcell marked this pull request as ready for review April 6, 2026 16:43
let genesis_header = inner.chain_spec.genesis_header().clone();
let genesis_hash = genesis_header.hash_slow();
(genesis_hash, genesis_header)
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Silent genesis fallback for unknown parent hashes: when parent_hash doesn't match any entry in executed_headers, build_and_commit unconditionally falls back to the genesis header. This is intentional for the first block (where the rollup config has B256::ZERO as genesis hash), but it means that any unrecognized parent hash — including one from a bug in the caller or a missed block — silently builds on top of genesis rather than returning an error.

Consider making this fallback explicit with a genesis-hash check:

Suggested change
});
.unwrap_or_else(|| {
// Genesis case: derive the real hash from the chain spec genesis header so the
// builder can locate the committed state in the DB.
let genesis_header = inner.chain_spec.genesis_header().clone();
let genesis_hash = genesis_header.hash_slow();
debug_assert!(
parent_hash == B256::ZERO || parent_hash == genesis_hash,
"unknown parent hash {parent_hash}, expected genesis {genesis_hash}",
);
(genesis_hash, genesis_header)
});

This would catch misuse in tests without silently building an incorrect chain.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 6, 2026

Review Summary

This PR replaces the bespoke StatefulL2Executor with the production OpPayloadBuilder stack in the action test harness — a significant improvement that eliminates divergence between test and production block-building code paths. The architecture is sound: L2Sequencer now uses L1OriginSelector + StatefulAttributesBuilder + ActionEngineClient (backed by a real Reth DB and OpPayloadBuilder::try_build), giving end-to-end coverage of the real execution pipeline.

New finding

Silent genesis fallback for unknown parent hashes (engine.rs:295-306): build_and_commit falls back to the genesis header whenever parent_hash doesn't match any entry in executed_headers. This is correct for the first block (rollup config uses B256::ZERO), but any other unrecognized hash — from a caller bug, missed block, or race — silently builds on genesis instead of erroring. A debug_assert! guarding the fallback would catch misuse early.

Notes on prior review comments

The existing comment about "partial headers making hash_slow() lookups fail" appears to be outdated — all three insertion sites (execute_v1_inner:461, execute_from_attrs:503, build_payload_inner:531) now store full headers cloned from built.block().header(), so hash_slow() produces the correct hash. The code comment at line 528-530 explicitly documents this design choice.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-actions Area: actions testing K-feature Kind: New feature M-prevent-stale Meta: Not Stale M-refactor Meta: refactors code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants