From 7bf686313274dd26ac607ae10f10027ae280f888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ste=CC=81phane=20Duchesneau?= Date: Thu, 2 Jul 2026 13:58:56 -0400 Subject: [PATCH 1/4] Trace locally-built derivation blocks in Firehose op-node reconstructs the L1-derived (safe) chain by building each block via engine getPayload (no_tx_pool), which inserts it as canonical without passing through the traced newPayload path. Those blocks never emitted a FIRE BLOCK, leaving gaps whenever a node caught up from an old snapshot. Add a firehose_trace_built_block hook on ConfigurePostExecEvm (default no-op) that OpFirehoseEvmConfig overrides to re-execute the frozen block through the existing OpChainHooks tracing executor, reusing the exact OpPostTxExtras/OpPreTxAdjust wrapping as the pipeline and engine paths so output is byte-identical. The payload builder calls it after a Freeze, gated on is_tracer_initialized so non-firehose builds are unaffected. Re-execution is confined to these built (older, derived) blocks and never touches the live gossip newPayload path. --- rust/Cargo.lock | 1 + rust/op-reth/crates/evm/src/post_exec_ext.rs | 24 +++++++++- .../op-reth/crates/firehose/src/evm_config.rs | 46 ++++++++++++++++++- rust/op-reth/crates/payload/Cargo.toml | 1 + rust/op-reth/crates/payload/src/builder.rs | 34 +++++++++++--- 5 files changed, 97 insertions(+), 9 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 8ebabae4392..74746e42b3f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -13581,6 +13581,7 @@ dependencies = [ "reth-chainspec", "reth-evm", "reth-execution-types", + "reth-firehose", "reth-metrics", "reth-optimism-chainspec", "reth-optimism-evm", diff --git a/rust/op-reth/crates/evm/src/post_exec_ext.rs b/rust/op-reth/crates/evm/src/post_exec_ext.rs index 0cfd0927240..a7465a5ed47 100644 --- a/rust/op-reth/crates/evm/src/post_exec_ext.rs +++ b/rust/op-reth/crates/evm/src/post_exec_ext.rs @@ -12,12 +12,14 @@ use op_revm::OpSpecId; use reth_chainspec::EthChainSpec; use reth_evm::{ ConfigureEvm, Database, - execute::{BasicBlockBuilder, BlockBuilder}, + execute::{BasicBlockBuilder, BlockBuilder, BlockExecutionError}, precompiles::PrecompilesMap, }; use reth_optimism_forks::OpHardforks; use reth_optimism_primitives::DepositReceipt; -use reth_primitives_traits::{NodePrimitives, SealedBlock, SealedHeader, SignedTransaction}; +use reth_primitives_traits::{ + NodePrimitives, RecoveredBlock, SealedBlock, SealedHeader, SignedTransaction, +}; use revm::{context::BlockEnv, database::State}; use crate::{OpBlockExecutorFactory, OpEvmConfig, OpEvmFactory, OpTx, PostExecMode}; @@ -65,6 +67,24 @@ pub trait ConfigurePostExecEvm: ConfigureEvm { > + 'a, Self::Error, >; + + /// Firehose hook: re-execute an already-built block through the tracing executor so it + /// emits a `FIRE BLOCK`. + /// + /// The OP payload builder constructs derivation (`no_tx_pool`) blocks via `getPayload`, + /// which never pass through the traced `newPayload` engine path, so those blocks are + /// otherwise invisible to Firehose. This hook lets a Firehose-aware EVM config re-run such + /// a block against a fresh parent `state` and emit it. The default is a no-op; only the + /// Firehose wrapper overrides it. + /// + /// `state` must be a fresh [`State`] over the block's parent. + fn firehose_trace_built_block( + &self, + _state: &mut State, + _block: &RecoveredBlock<::Block>, + ) -> Result<(), BlockExecutionError> { + Ok(()) + } } impl ConfigurePostExecEvm for OpEvmConfig diff --git a/rust/op-reth/crates/firehose/src/evm_config.rs b/rust/op-reth/crates/firehose/src/evm_config.rs index 96eefb7be0b..d345302bd3a 100644 --- a/rust/op-reth/crates/firehose/src/evm_config.rs +++ b/rust/op-reth/crates/firehose/src/evm_config.rs @@ -29,7 +29,7 @@ use reth_evm::{ }; use reth_firehose::{ ChainHooks, FirehoseBlockExecutor, FirehoseBlockTracer, FirehoseWrappedExecutor, - mapper::SignatureFields, + is_tracer_initialized, mapper::SignatureFields, }; use reth_optimism_evm::{ConfigurePostExecEvm, OpTx, PostExecExecutorExt, PostExecMode}; use reth_optimism_primitives::OpPrimitives; @@ -283,4 +283,48 @@ where > { self.inner.post_exec_builder_for_next_block(db, parent, attributes, post_exec_mode) } + + /// Re-executes a locally-built (derivation `no_tx_pool`) block through the tracing executor + /// so it emits a `FIRE BLOCK`. + /// + /// Such blocks are produced via `getPayload` and inserted as canonical without passing + /// through the traced `newPayload` engine path, so this is their only tracing opportunity. + /// The re-execution is deterministic and reuses the exact [`OpChainHooks`] wrapping (same + /// `OpPostTxExtras` / `OpPreTxAdjust`) as the pipeline and engine paths, so the emitted block + /// is byte-identical to what those paths would produce. `state` must be a fresh [`State`] + /// over the block's parent. + fn firehose_trace_built_block( + &self, + state: &mut State, + block: &RecoveredBlock<::Block>, + ) -> Result<(), BlockExecutionError> { + if !is_tracer_initialized() { + return Ok(()); + } + + let finalized = Some(firehose_tracer::types::FinalizedBlockRef { + number: block.header().number(), + hash: Some(block.hash()), + }); + let mut tracer = + FirehoseBlockTracer::start::(block.sealed_block(), finalized); + + // The builder always extends an existing parent, so block 1 never reaches here; guard + // anyway since the wrapped executor would panic on the genesis marker. + if tracer.is_genesis() { + tracer.mark_verified(); + return Ok(()); + } + + match OpChainHooks.execute_one_traced(&self.inner, state, block, &mut tracer) { + Ok(_) => { + tracer.mark_verified(); + Ok(()) + } + Err(err) => { + tracer.mark_failed(&err); + Err(err) + } + } + } } diff --git a/rust/op-reth/crates/payload/Cargo.toml b/rust/op-reth/crates/payload/Cargo.toml index a2d0ef59c9e..afb4bf53e7d 100644 --- a/rust/op-reth/crates/payload/Cargo.toml +++ b/rust/op-reth/crates/payload/Cargo.toml @@ -26,6 +26,7 @@ reth-payload-primitives.workspace = true reth-basic-payload-builder.workspace = true reth-payload-validator.workspace = true reth-metrics.workspace = true +reth-firehose.workspace = true # op-reth reth-optimism-evm.workspace = true diff --git a/rust/op-reth/crates/payload/src/builder.rs b/rust/op-reth/crates/payload/src/builder.rs index 571acf1fd3f..11e3f85cffb 100644 --- a/rust/op-reth/crates/payload/src/builder.rs +++ b/rust/op-reth/crates/payload/src/builder.rs @@ -38,7 +38,7 @@ use reth_optimism_txpool::{ interop::{MaybeInteropTransaction, is_interop_tx, is_valid_interop}, }; use reth_payload_builder_primitives::PayloadBuilderError; -use reth_payload_primitives::{BuildNextEnv, BuiltPayloadExecutedBlock}; +use reth_payload_primitives::{BuildNextEnv, BuiltPayload, BuiltPayloadExecutedBlock}; use reth_payload_util::{BestPayloadTransactions, NoopPayloadTransactions, PayloadTransactions}; use reth_primitives_traits::{ HeaderTy, NodePrimitives, SealedHeader, SealedHeaderFor, SignedTransaction, TxTy, @@ -270,13 +270,35 @@ where let state_provider = self.client.state_by_block_hash(ctx.parent().hash())?; let state = StateProviderDatabase::new(&state_provider); - if ctx.attributes().no_tx_pool() { - builder.build(state, &state_provider, ctx) + let outcome = if ctx.attributes().no_tx_pool() { + let outcome = builder.build(state, &state_provider, ctx)?; + + // Firehose: `no_tx_pool` (derivation) blocks are constructed here and inserted as + // canonical without going through the traced `newPayload` engine path, so they would + // otherwise never emit a `FIRE BLOCK`. Re-execute the frozen block through the tracing + // executor. Re-execution is confined to these built blocks (older, L1-derived range), + // never the latency-critical live gossip path. No-op unless the tracer is installed. + if reth_firehose::is_tracer_initialized() { + if let BuildOutcomeKind::Freeze(payload) = &outcome { + if let Some(executed) = payload.executed_block() { + let mut trace_state = State::builder() + .with_database(StateProviderDatabase::new(&state_provider)) + .with_bundle_update() + .build(); + self.evm_config + .firehose_trace_built_block(&mut trace_state, &executed.recovered_block) + .map_err(|err| PayloadBuilderError::Internal(err.into()))?; + } + } + } + + outcome } else { // sequencer mode we can reuse cachedreads from previous runs - builder.build(cached_reads.as_db_mut(state), &state_provider, ctx) - } - .map(|out| out.with_cached_reads(cached_reads)) + builder.build(cached_reads.as_db_mut(state), &state_provider, ctx)? + }; + + Ok(outcome.with_cached_reads(cached_reads)) } /// Computes the witness for the payload. From 7ea8c6e0d42599fdfb8ada245e2e08e42cb91cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ste=CC=81phane=20Duchesneau?= Date: Thu, 2 Jul 2026 14:18:45 -0400 Subject: [PATCH 2/4] Speed up sf-release image build with gha cache and fast-build on PRs --- .github/workflows/sf-release.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/sf-release.yml b/.github/workflows/sf-release.yml index 4a9b196e256..4ebf16c37ba 100644 --- a/.github/workflows/sf-release.yml +++ b/.github/workflows/sf-release.yml @@ -122,9 +122,18 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} provenance: false + # Cache the cargo-chef dependency layer across runs (ephemeral runners + # otherwise cold-rebuild all deps every time, ~13m). Keyed per profile by + # buildx, so PR (fast-build) and tag (maxperf) caches stay separate. + cache-from: type=gha + cache-to: type=gha,mode=max build-args: | VERSION=${{ steps.extract-version.outputs.VERSION }} FIREHOSE_ETHEREUM=${{ inputs.firehose_ethereum || 'latest' }} + # Full LTO `maxperf` is only worth its ~19m link tail for shippable + # builds (tags); PRs use `fast-build` (no LTO) to validate the build + # faster. Mirrors upstream build-images.yaml. + BUILD_PROFILE=${{ github.event_name == 'pull_request' && 'fast-build' || 'maxperf' }} release: if: startsWith(github.ref, 'refs/tags/') From dea0954b79da18e3aef6ca12c08f0b1b0a6db8a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ste=CC=81phane=20Duchesneau?= Date: Thu, 2 Jul 2026 14:21:15 -0400 Subject: [PATCH 3/4] Skip duplicate same-repo PR image build (push trigger covers it) --- .github/workflows/sf-release.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/sf-release.yml b/.github/workflows/sf-release.yml index 4ebf16c37ba..9a40baf33d3 100644 --- a/.github/workflows/sf-release.yml +++ b/.github/workflows/sf-release.yml @@ -34,6 +34,14 @@ env: jobs: build: + # A same-repo `firehose/*` branch already builds via the `push` trigger, and + # that run's checks show on the PR (same commit SHA). Skip the duplicate + # same-repo PR build. Fork PRs (push can't cover them) and non-`firehose/*` + # PR branches still build here. + if: >- + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name != github.repository || + !startsWith(github.head_ref, 'firehose/') runs-on: ubuntu-24.04 permissions: From 7a173f5d6389fa87db93386664c875eb37f66aac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ste=CC=81phane=20Duchesneau?= Date: Thu, 2 Jul 2026 14:37:00 -0400 Subject: [PATCH 4/4] Build images on tags/PRs/dispatch only, full LTO on tags --- .github/workflows/sf-release.yml | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/.github/workflows/sf-release.yml b/.github/workflows/sf-release.yml index 9a40baf33d3..021523111b1 100644 --- a/.github/workflows/sf-release.yml +++ b/.github/workflows/sf-release.yml @@ -3,10 +3,12 @@ # Maintained by StreamingFast and intentionally kept separate from upstream CI # so that upstream merges stay clean. # -# Triggers: `firehose/*` branch pushes, `*-fh*` tags, pull requests and manual -# dispatch. Pull requests opened from a fork get a read-only GITHUB_TOKEN, so the -# login/push steps are skipped for them (the image still builds, to validate the -# Dockerfile); same-repo ("inner") PRs and pushes authenticate and push normally. +# Triggers: `*-fh*` tags, pull requests and manual dispatch. Branch pushes do not +# build — open a PR (or push a tag) to get an image, which keeps every commit to a +# single build. Pull requests opened from a fork get a read-only GITHUB_TOKEN, so +# the login/push steps are skipped for them (the image still builds, to validate +# the Dockerfile); same-repo ("inner") PRs and tag pushes authenticate and push +# normally. Only tag builds use the full-LTO `maxperf` profile. # # Single architecture (linux/amd64): the op-reth `maxperf` full-node build is too # heavy to reliably build on GitHub-hosted arm64 runners, so we do not produce a @@ -16,8 +18,6 @@ name: Build, push and release (if tag) on: push: - branches: - - "firehose/*" tags: - "*-fh*" pull_request: @@ -34,14 +34,6 @@ env: jobs: build: - # A same-repo `firehose/*` branch already builds via the `push` trigger, and - # that run's checks show on the PR (same commit SHA). Skip the duplicate - # same-repo PR build. Fork PRs (push can't cover them) and non-`firehose/*` - # PR branches still build here. - if: >- - github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name != github.repository || - !startsWith(github.head_ref, 'firehose/') runs-on: ubuntu-24.04 permissions: @@ -49,8 +41,8 @@ jobs: packages: write env: - # Pushes (branch/tag), same-repo PRs and manual dispatch can authenticate - # to ghcr and push images. A PR opened from a fork gets a read-only + # Tag pushes, same-repo PRs and manual dispatch can authenticate to ghcr + # and push images. A PR opened from a fork gets a read-only # GITHUB_TOKEN with no `packages: write`, so we must skip login and push for # it — the build still runs to validate the Dockerfile compiles. The value # is the string "true"/"false". @@ -139,9 +131,9 @@ jobs: VERSION=${{ steps.extract-version.outputs.VERSION }} FIREHOSE_ETHEREUM=${{ inputs.firehose_ethereum || 'latest' }} # Full LTO `maxperf` is only worth its ~19m link tail for shippable - # builds (tags); PRs use `fast-build` (no LTO) to validate the build - # faster. Mirrors upstream build-images.yaml. - BUILD_PROFILE=${{ github.event_name == 'pull_request' && 'fast-build' || 'maxperf' }} + # builds (tags); PRs and manual dispatch use `fast-build` (no LTO) to + # validate faster. + BUILD_PROFILE=${{ startsWith(github.ref, 'refs/tags/') && 'maxperf' || 'fast-build' }} release: if: startsWith(github.ref, 'refs/tags/')