From 289448ba00d53db3c22320ca9fba9ba4787fe64d Mon Sep 17 00:00:00 2001 From: Matthieu Vachon Date: Tue, 28 Jul 2026 11:22:37 -0400 Subject: [PATCH] test(firehose): add tracing-regression coverage for Base transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire base-firehose-tests onto the shared firehose-tracer-test (crates.io 5.3.0) framework — capture, block invariants, descriptor-driven JSON projection and golden — and add Base-specific coverage on top: - BaseFirehoseCapture: reth binding that installs the process-wide buffer-backed tracer so a test capturing a running node's FIRE output. - Docker-free prestate test asserting invariants + a projection golden. - base-system-tests firehose_b20: traces a real B-20 precompile transfer end to end (run with RUSTFLAGS="-C debug-assertions=off"). Also fix in-process consensus nodes in system tests sharing one checkpoint database ($HOME/.base//checkpoint.redb) — the sequencer and validator now each get their own temp dir, which was preventing every system-test stack from starting. Move firehose test notes into CLAUDE.md and drop the .dev todo plans. --- .dev/todo/firehose-flashblocks-more-tests.md | 195 -- .dev/todo/firehose-flashblocks-support.md | 974 ---------- .dev/todo/firehose-integration-tests.md | 246 --- .../improve-flashblocks-test-framework.md | 116 -- .dev/todo/upgrade-upstream-v0-8-0.md | 81 - CHANGELOG.sf.md | 12 + CLAUDE.md | 4 + Cargo.lock | 51 +- Cargo.toml | 9 +- crates/execution/firehose-tests/Cargo.toml | 3 +- crates/execution/firehose-tests/README.md | 21 +- .../execution/firehose-tests/src/capture.rs | 40 + crates/execution/firehose-tests/src/lib.rs | 9 +- .../execution/firehose-tests/src/prestate.rs | 6 +- .../nop_transfer/block.2099.actual.binpb | Bin 1483 -> 0 bytes .../cases/nop_transfer/block.2099.actual.txt | 1512 --------------- .../tests/cases/nop_transfer/block.2099.binpb | Bin 1579 -> 0 bytes .../nop_transfer/block.2099.expected.txt | 1618 ----------------- .../tests/cases/nop_transfer/block.2099.json | 74 + .../firehose-tests/tests/prestate.rs | 48 +- etc/systems/Cargo.toml | 3 + etc/systems/src/l2/in_process_consensus.rs | 22 +- etc/systems/tests/firehose_b20.rs | 126 ++ etc/systems/tests/goldens/b20_transfer.json | 81 + 24 files changed, 472 insertions(+), 4779 deletions(-) delete mode 100644 .dev/todo/firehose-flashblocks-more-tests.md delete mode 100644 .dev/todo/firehose-flashblocks-support.md delete mode 100644 .dev/todo/firehose-integration-tests.md delete mode 100644 .dev/todo/improve-flashblocks-test-framework.md delete mode 100644 .dev/todo/upgrade-upstream-v0-8-0.md create mode 100644 crates/execution/firehose-tests/src/capture.rs delete mode 100644 crates/execution/firehose-tests/tests/cases/nop_transfer/block.2099.actual.binpb delete mode 100644 crates/execution/firehose-tests/tests/cases/nop_transfer/block.2099.actual.txt delete mode 100644 crates/execution/firehose-tests/tests/cases/nop_transfer/block.2099.binpb delete mode 100644 crates/execution/firehose-tests/tests/cases/nop_transfer/block.2099.expected.txt create mode 100644 crates/execution/firehose-tests/tests/cases/nop_transfer/block.2099.json create mode 100644 etc/systems/tests/firehose_b20.rs create mode 100644 etc/systems/tests/goldens/b20_transfer.json diff --git a/.dev/todo/firehose-flashblocks-more-tests.md b/.dev/todo/firehose-flashblocks-more-tests.md deleted file mode 100644 index f79b0190f5..0000000000 --- a/.dev/todo/firehose-flashblocks-more-tests.md +++ /dev/null @@ -1,195 +0,0 @@ -# Flashblocks More Test Coverage - -mode: feature -state: review -root_git: .worktrees/feature/firehose-flashblocks-more-tests -worktree: .worktrees/feature/firehose-flashblocks-more-tests -branch: feature/firehose-flashblocks-more-tests -target_branch: firehose/0.x - -> **Resume protocol:** read **Dev Feedback** and the **State Tracker** below first, then jump to the -> step marked `Current`. Ensure that you are in the correct worktree and branch according to preamble here. Update current with Developer feedback and update the tracker after every meaningful change. -> Do not mutate completed steps; append a new entry instead. - ---- - -## Initial Description - -Task to add more firehose flashblocks tests as well as making minor refactoring to the test framework. - -### Refactoring - -- Rename `ParsedFireBlock` to `FireEvent` which would be an enum with three types `Init`, `Block`, `FlashBlock` (when a Block is a flash block). Each type would contain only what makes sense for it. `Block` and `FlashBlock` should contain a Payload `sf.ethereum.type.Block` object. -- In the flashblocks test cases, instead of using assertions when checking the FIRE output, use equalities against `FireEvent` of the right type. Create helpers to reduce the clutter in test cases — ideally checking the vec directly. -- Use the `pretty_assertions` library for testing to improve the rendered diffs when there is a mismatch. - -### Coverage - -- Improve the overall test coverage once refactoring is done by testing more edge cases: - - Test sequence of base + delta + next base - - Improve the overall coverage of various cases highlighted in `.dev/todo/firehose-flashblocks-support.md` (the prior task spec) - -## Dev Feedback - -1. Payload handling - -``` -**Payload omitted from `FireEvent`:** The base64-encoded protobuf payload field is intentionally omitted from the enum variants. Adding it would require either a prost decode (adding complexity) or a raw base64 string (preventing meaningful comparison). For all current test assertions, the protocol metadata fields are sufficient. The decision is documented in code. -``` - -I agree there is a good portion of cases that can be expressed without looking at the payload traced context. - -However, it's important for coverage to test that part since tracing is at the heart of Firehose and the saas service we are offering. We need the complexity since we need to ensure that the flash block tracing is actually working correctly. The correct way is to properly read the base64 bytes into a sf.ethereum.type.v2.Block. This Rust struct exists in firehose-tracer has the right type in there, so it should be possible to decode it with Prost into - -Let's create two set of test cases, those ignoring payload the other only checking the payload correctness across delta and full. - -1. Prepare firehose-tracer helpers - -Our FireEvent and parse helper should be made part of firehose-tracer crate upstream library. Other chain-agnostic testing utils could be moved there. - -Prepare a plan as a new document at the very end of this task file where you write instructions for a future agent implementation. - -1. The coverage of cases seems a bit low - -For example, I see no test with two succesive delta, no jumping delta. Covers a 100% all cases that could happen according to Flashblock spec at https://docs.base.org/base-chain/api-reference/flashblocks-api/flashblocks-api-overview#flashblock-object - -## Spec & Implementation - -### Summary - -Implemented the refactoring and new test coverage in a single commit on the feature branch. - -### Decisions Made - -**`FireEvent` enum design:** -The enum has three variants: -- `Init { version, node_name, node_version }` — for `FIRE INIT` lines -- `Block { block_number, prev_block_number, lib_num, timestamp_ns }` — for `FIRE BLOCK` lines where `printed_flash_idx == 0` (base flashblocks and canonical blocks) -- `FlashBlock { block_number, flash_idx, is_final, prev_block_number, lib_num, timestamp_ns }` — for `FIRE BLOCK` lines where `printed_flash_idx > 0` (delta flashblocks) - -**Payload omitted from `FireEvent`:** The base64-encoded protobuf payload field is intentionally omitted from the enum variants. Adding it would require either a prost decode (adding complexity) or a raw base64 string (preventing meaningful comparison). For all current test assertions, the protocol metadata fields are sufficient. The decision is documented in code. - -**Constructor helpers:** `FireEvent::canonical_block(n)` and `FireEvent::flash_block(n, idx, is_final)` produce expected values with `lib_num=0` and `timestamp_ns=0`, which are treated as wildcards by `assert_fire_events_eq`. This avoids coupling tests to genesis-derived timestamp values. - -**`assert_fire_events_eq` normalisation:** The helper normalises `actual` events before comparing with `pretty_assertions::assert_eq!` — any field set to `0` in the expected event is zeroed in the actual copy, so diffs only show fields the test author cares about. - -**`pretty_assertions` workspace integration:** Added to `[workspace.dependencies]` in the root `Cargo.toml` and to `[dev-dependencies]` in `crates/firehose-flashblocks/Cargo.toml`. - -**Drive-by fix:** Removed the duplicate `SignatureFields for BaseTxEnvelope` implementation from `crates/common/consensus/src/reth_compat.rs`. The same trait was already implemented in `crates/common/consensus/src/transaction/envelope.rs`. The upstream `reth-firehose c5a3616c` bump made `SignatureFields` a public trait, causing a compile error. The `envelope.rs` implementation is kept as the canonical one. - -### Test Coverage Added - -1. **`base_plus_delta_emits_two_fire_blocks`** — base + one delta → two FIRE BLOCK lines with consecutive flash_idx values (`Block(1)`, `FlashBlock(1, idx=1, is_final=false)`). - -2. **`base_plus_delta_plus_next_base`** — base(N) + delta(N) + base(N+1) → three FIRE BLOCK lines: `Block(1)`, `FlashBlock(1, idx=1)`, `Block(2)`. Exercises cross-block state carry-forward. - -3. **`duplicate_base_is_ignored`** — same base sent twice → only one FIRE BLOCK emitted (sequence validator returns `Duplicate`). - -4. **`non_sequential_delta_is_skipped`** — base + delta with index=2 (skipping index=1) → only the base FIRE BLOCK emitted (sequence validator returns `NonSequentialGap`, sets `is_skipping=true`). - -The original `flash_base_emits_fire_block` test is kept (renamed effectively to `flash_base_emits_fire_block` with updated assertion style using `assert_fire_events_eq`). - -### Build & Test Status - -- `cargo test -p base-firehose-flashblocks` — 9 tests total (4 unit + 5 integration), all pass. -- `cargo clippy -p base-firehose-flashblocks --tests -- -D warnings` — clean. - -## State Tracker - -**Last Updated:** 2026-05-21 -**Current Step:** Step 8 — Dev feedback applied, ready for re-review -**Status:** Ready for re-review - -| Step | Status | Notes | -|---|---|---| -| Initial setup | Done | Worktree created at .worktrees/feature/firehose-flashblocks-more-tests | -| Step 1 — Refactoring (FireEvent enum, parse_fire_events, assert_fire_events_eq) | Done | See Spec & Implementation section | -| Step 2 — pretty_assertions integration | Done | Added to workspace and crate dev-dependencies | -| Step 3 — New tests (4 edge cases) | Done | See Spec & Implementation section | -| Step 4 — Drive-by fix | Done | Removed duplicate SignatureFields impl from reth_compat.rs | -| Step 5 — Add EthBlock payload to FireEvent variants | Done | Added `block: EthBlock` field to Block and FlashBlock variants; prost+base64 decode in parse_fire_events | -| Step 6 — Dual assertion helpers | Done | `assert_fire_events_metadata_eq` (ignores payload) and `assert_fire_events_eq` (full payload comparison) | -| Step 7 — Expand test coverage to 10 integration tests | Done | two_successive_deltas, jumping_delta_is_skipped, three_successive_deltas, two_blocks_with_deltas, block_payload_has_correct_block_number | -| Step 8 — Future Work section + task update | Done | Appended upstream plan at end of task file | - -## Future Work: Upstream FireEvent to firehose-tracer - -This section is addressed to a future agent that will move the chain-agnostic testing utilities from `base-firehose-flashblocks` into the upstream `firehose-tracer` crate, so that any firehose-instrumented chain (not just Base/OP) can reuse them. - -### What to move upstream - -The following items in `crates/firehose-flashblocks/tests/framework/mod.rs` are fully chain-agnostic and belong in `firehose-tracer`: - -- **`FireEvent` enum** — `Init`, `Block`, `FlashBlock` variants with their metadata fields (`block_number`, `flash_idx`, `is_final`, `prev_block_number`, `lib_num`, `timestamp_ns`) and the decoded `EthBlock` (`firehose_tracer::pb::Block`) payload field. The wire format parsed here (FIRE INIT / FIRE BLOCK) is defined by `firehose-tracer` itself, so the parser naturally belongs there. -- **`parse_fire_events(raw: &[u8]) -> Vec`** — base64+prost decode of the FIRE line format. The proto type used (`firehose_tracer::pb::Block`) is already in `firehose-tracer`, making it the natural home. -- **`decode_eth_block(payload_base64: &str) -> EthBlock`** — private helper; should be moved alongside `parse_fire_events`. -- **`assert_fire_events_metadata_eq`** — compares protocol metadata, ignores block payload. -- **`assert_fire_events_eq`** — full comparison including decoded block payload. -- **`normalize_metadata` / `normalize_full`** — private normalisation helpers. - -### What stays in `base-firehose-flashblocks` tests - -The following are Base/OP-specific and must remain in this repository: - -- `GenesisClient` and `GenesisStateProvider` — implemented against `reth_provider` traits using Base-specific types (`BasePrimitives`, `BaseChainSpec`, `BaseTxEnvelope`, etc.). -- `flash_base` / `flash_delta` — build `Flashblock` fixtures using Base-specific types (`ExecutionPayloadBaseV1`, `ExecutionPayloadFlashblockDeltaV1`, `Metadata`) from `base-common-flashblocks`. -- `ws_server_once` / `run_flashblock_sequence` — drive the `FirehoseFlashblocksProcessor` which is a Base-specific type. -- `test_genesis()` — returns a Base-chain genesis (chain id 8453 with OP-stack hardforks). - -### Target module structure in firehose-tracer - -The upstream crate should expose a `testing` module, gated behind a `test-utils` Cargo feature to avoid pulling in `prost`, `base64`, and `pretty_assertions` in non-test builds: - -``` -firehose-tracer/ - src/ - testing.rs # FireEvent, parse_fire_events, assert_fire_events_* -``` - -`Cargo.toml` additions: -```toml -[features] -test-utils = ["dep:base64", "dep:pretty_assertions"] - -[dependencies] -# ... existing deps ... - -[dependencies.base64] -version = "0.22" -optional = true - -[dependencies.pretty_assertions] -version = "1" -optional = true -``` - -`lib.rs` addition: -```rust -#[cfg(feature = "test-utils")] -pub mod testing; -``` - -Consumers would add: -```toml -[dev-dependencies] -firehose-tracer = { workspace = true, features = ["test-utils"] } -``` - -### Steps for the future agent - -1. Clone / check out the `firehose-tracer` repository (currently at version 5.1.1). -2. Create `src/testing.rs` with the chain-agnostic items listed above (copy from `framework/mod.rs`, removing the Base-specific imports). -3. Add the `test-utils` feature gate to `Cargo.toml` with optional `base64` and `pretty_assertions` deps. -4. Export `testing` from `lib.rs` behind `#[cfg(feature = "test-utils")]`. -5. Publish a new patch version (e.g. 5.1.2) of `firehose-tracer`. -6. In this repo, bump `firehose-tracer` to 5.1.2 in `Cargo.toml` and update `[dev-dependencies]` for `base-firehose-flashblocks` to add `features = ["test-utils"]`. -7. In `crates/firehose-flashblocks/tests/framework/mod.rs`, replace the local definitions of `FireEvent`, `parse_fire_events`, `assert_fire_events_metadata_eq`, and `assert_fire_events_eq` with `use firehose_tracer::testing::*;`. -8. Run `cargo test -p base-firehose-flashblocks` to confirm everything still passes. - -### API considerations - -- `EthBlock` (`firehose_tracer::pb::Block`) is already a first-class type in `firehose-tracer`, so no new proto dependencies are needed. -- `prost` is already a dependency of `firehose-tracer` (used for `encode_to_vec` / `decode`), so it does not need to be added as a feature-gated dep — just make it available without the feature gate. -- The `pretty_assertions` crate should be feature-gated because it is a dev/testing tool. -- `base64` is only needed for decoding in tests; gate it behind `test-utils`. -- The `FireEvent` enum derives `Debug + Clone + PartialEq` but not `Eq` (because `EthBlock` contains `Vec` fields which are `Eq` — in practice it is fine to add `Eq` as well if the prost-generated type implements it). diff --git a/.dev/todo/firehose-flashblocks-support.md b/.dev/todo/firehose-flashblocks-support.md deleted file mode 100644 index eb1c9ebb27..0000000000 --- a/.dev/todo/firehose-flashblocks-support.md +++ /dev/null @@ -1,974 +0,0 @@ -# Firehose Flashblocks Support - -mode: feature -state: review -root_git: .worktrees/feature/firehose-flashblocks-support -worktree: .worktrees/feature/firehose-flashblocks-support -branch: feature/firehose-flashblocks-support -target_branch: firehose/0.x - -> **Resume protocol:** read **Dev Feedback** and the **State Tracker** below first, then jump to the -> step marked `Current`. Ensure that you are in the correct worktree and branch according to preamble here. Update current with Developer feedback and update the tracker after every meaningful change. -> Do not mutate completed steps; append a new entry instead. - ---- - -## Initial Description - -### Context - -We have for base-geth a full Flashblocks support on using op-geth Firehose tracing node at https://github.com/streamingfast/go-ethereum/tree/release/optimism-1.x-fh3.0. - -This receives via WebSocket the Flashblocks base + deltas, for each base, get the correct state for block's parent hash. Re-apply the base block on top of that, re-execution transactions and tracing them and sending a `FIRE ...` signal with the partial block's base - -For each delta that is then receive, validate first that delta follows correctly previous delta or base, re-execute on top of base state's the delta's transactions, tracing them along the way. Then the traced transaction are append to previously traced data (so we have a Firehose Traced block containing base + delta(s) tracing) and then send the new bigger partial blocks our with a `FIRE ...` signal. - -You can all our implementation at https://github.com/streamingfast/go-ethereum/tree/release/optimism-1.x-fh3.0/node/flashblock and the important files are the `processor.go` and `controller.go`. - -### Task - -The whole idea is to port our Flashblocks support back into `base-reth` directly. - -Now, the good news is that Base Rust has already a full Flashblocks support that can be found at: -- crates/execution/flashblocks - -This contains websocket code, payload structs but also all the logic to execute flashblock deltas and keeping a partial state that is then used by RPC logic to fetch more "recent" state as well as a few other features. - -The plan that we need to define is about how we can re-use existing flashblocks code Base has but with respecting more Firehose desired behavior. - -I can see for example that we can most probably re-use the Websocket client, the payload and a few other structures and pieces of code like the one that validate that a delta is valid in the sense that it strictly follows previous delta/base. - -Stuff I think we would no-reuse: -- Actual execution of base/deltas and state accumulation, we need execution but with our our traced execution flow. - -My own thinking is that we have our own firehose_flashblock_url config. If set, it starts our own "streamer" of Flashblock events, and on each event, would replicate what we had in Geth on how we manage the flow of events how we execute them etc. Of course this woulb be using Reth APIs as well as using Base Reth Firehose Block Executor to ensure we can trace block correctly. - -### Goal - -Define the best plan possible that ports the logic we had in op-geth for handling events, trace them and propagate them properly. - -The `evm-firehose-tracer-rs` dependency (`firehose-tracer`) might requires some adjustement, if it's the case, this plan should have a section that explains what it needs so I can make that work properly. - -## Dev Feedback - -1. `The reason: `FlashblocksPayloadV1` / `Metadata` does not carry a per-payload "final" marker.` - - Investigate this further, where `is_final` comes from exactly in op-geth? We normally follow the same semantic structure for the WebSocket received message, AFAIK, there is no indeed such field is_final. So where is_final would get populated in op-geth counterpart? I know we added a quick change in engine newPayload to be notified rapidly of a new block in geth, even before it gets executed in geth. - - **Investigation complete — see "is_final Investigation" section in Spec & Implementation below.** - -2. Split .worktrees/feature/firehose-flashblocks-support/crates/firehose-flashblocks/tests/flashblock_sequence.rs so the framework is in its own file `framework.rs` and then each group file will contain on test cases. - - **Done. Framework is now at `tests/framework/mod.rs` (a Cargo subdirectory, so it is not compiled as a standalone binary). `tests/flashblock_sequence.rs` imports it with `mod framework;` and contains only test cases.** - -3. Add actual parsing of FIRE ... lines so we can verify the content too. The firehose-tracer should have some code to do that but you might need to re-implement some elements (that we would then port back to firehose-tracer) as I don't think it supports FIRE 3.1 protocol parsing (flashblock support, but maybe ...). - - **Done. `firehose-tracer 5.1.0` has `printer::compute_printed_flash_block_index` but no line parser. Implemented `ParsedFireBlock` struct and `parse_fire_blocks()` in `tests/framework/mod.rs`. Two integration tests now assert block_number, printed_flash_idx, is_final, flash_idx, block_hash, prev_block_number, lib_num, and timestamp_ns.** - -## Spec & Implementation - -### Summary - -Port the Firehose flashblock tracing support from `streamingfast/go-ethereum` (op-geth) into -`base-reth`. The feature adds a new `--firehose-flashblocks-url ` CLI flag. When set -(non-empty), a dedicated background streamer subscribes to the flashblock WebSocket feed, re-executes -each flashblock (base + deltas) through a **dedicated** Firehose tracer instance (separate from the -global live-block tracer), and emits partial-block Firehose events after each flashblock. When the -canonical block eventually arrives via the engine-API path, the already-emitted partial traces are -superseded by the canonical full-block trace as usual. - -The feature lives in a new top-level crate `crates/firehose-flashblocks/` named -`base-firehose-flashblocks`. If `--firehose-flashblocks-url` is absent or empty, the entire -subsystem is disabled and has zero runtime cost. - ---- - -### Scope - -**In scope:** - -- New `--firehose-flashblocks-url ` CLI argument in `bin/node/src/cli.rs`. -- New crate `crates/firehose-flashblocks/` (`base-firehose-flashblocks`) containing all Firehose+flashblocks logic. -- WebSocket subscriber reusing `FlashblocksSubscriber` + `FlashblocksReceiver` trait from `base-flashblocks`. -- Sequence validation reusing `FlashblockSequenceValidator` and `CanonicalBlockReconciler` from `base-flashblocks`. -- Block assembly reusing `BlockAssembler` from `base-flashblocks`. -- Traced block execution using `OpFirehoseEvmConfig` / `FirehoseWrappedExecutor::with_hooks` + `OpPreTxAdjust` + `OpPostTxExtras` from `base-execution-firehose`. -- Incremental Firehose event emission: one partial-block event per flashblock (base + each delta). -- **Cross-block state accumulation**: the accumulated EVM `State` is carried across flashblocks within the same canonical block AND across blocks (so Block N+1's base can start directly from Block N's completed accumulated state, without waiting for the canonical `StateProvider` to reflect Block N). -- A dedicated (non-global) `firehose_tracer::Tracer` instance owned by the flashblock processor, with **stdout write coordination** between the global (live) tracer and the flashblock tracer. -- Reconnect/backoff logic inherited from `FlashblocksSubscriber`. -- Integration into `bin/node/src/main.rs` alongside the existing `FirehoseExtension`. - -**Out of scope:** - -- Modifying the existing `FlashblocksExtension` / `StateProcessor` / `PendingBlocks` RPC path. -- Canonical block reconciliation / reorg replay in the flashblock processor (the engine-API path handles canonical blocks; the flashblock tracer is purely additive and pre-canonical). -- Testing against a live flashblock feed (unit tests only for the processor logic). - ---- - -### Key Design Decisions - -| Decision | Rationale | -|---|---| -| New crate `base-firehose-flashblocks` | Keeps all Firehose+flashblocks logic isolated; avoids bloating `base-execution-firehose`. The boundary is substantial enough to warrant a crate. | -| Separate CLI flag `--firehose-flashblocks-url` | The existing `--flashblocks-url` drives RPC pending state. The two features are independent and may be used independently. | -| **Dedicated (non-global) tracer instance** | The global tracer is locked by the live-block execution path. Two concurrent executions using the same tracer would corrupt output. The flashblock processor owns its own `firehose_tracer::Tracer`. | -| **Stdout write coordination via a shared Mutex** | Both the global tracer and the flashblock tracer write to stdout. Lines must not interleave. A process-wide `Mutex<()>` guards each `write_all` call; both tracer instances must acquire it before flushing a line. This mutex is already installed on `firehose/1.x` via `reth_firehose::init_stdout_lock()` and is wired into the global tracer automatically by `reth_firehose::init_tracer(Config)`. The flashblock tracer obtains the same `Arc>` via `reth_firehose::stdout_lock()`. | -| **Cross-block state accumulation** | The processor keeps `accumulated_db: State` alive across flashblocks of the same block AND across blocks. On a new base (index 0), if the current block number equals `previous_block_number + 1`, the state is carried forward directly (no provider lookup needed). If there is a gap (skipped blocks, restart), a fresh `StateProvider` is fetched for the parent. | -| Reuse `FlashblocksSubscriber` | WS connection management + reconnect is already battle-tested. The subscriber calls `FlashblocksReceiver::on_flashblock_received` — we implement that on `FirehoseFlashblocksProcessor`. | -| Reuse `BlockAssembler` | Converts accumulated `Vec` → `AssembledBlock` (header + body) needed for EVM env construction and `FirehoseBlockTracer` initialization. | -| Reuse `FlashblockSequenceValidator` | Stateless; validates that each incoming flashblock follows the expected sequence. | -| Do NOT reuse `StateProcessor` or `PendingStateBuilder` | Those accumulate EVM state for RPC, not for traced execution. We need `FirehoseWrappedExecutor`. | -| Emit partial block after each flashblock | Matches Geth behavior: `on_block_start(BlockEvent{flash_block: Some(FlashBlockData{idx, is_final})})` → execute txs → `on_block_end(None)` for this partial. | -| Only execute when Firehose is enabled | Guard the entire streamer startup behind `reth_firehose::is_tracer_initialized()`. If not initialized, the flag is a no-op (with a warning log). | - ---- - -### Cross-Block State Accumulation — Edge Cases (Ultra-Think) - -This is the most complex part of the design. The fundamental challenge: flashblock events arrive -**before** the canonical chain updates its in-memory `StateProvider`. We may receive Block 2 base -& several deltas before the node's state provider reflects Block 1 as finalized. - -#### State Machine - -The processor maintains: - -``` -current_block_number: Option -accumulated_db: Option>>> -stored_flashblocks: Vec // all flashblocks for current block -latest_flashblock_index: Option -is_skipping: bool // set on errors; cleared on new base -``` - -#### Event Flows - -**Happy path — sequential blocks:** - -``` -Event: Block N, index 0 (base) - → if current_block_number == Some(N-1): carry accumulated_db forward - (the state already reflects all of Block N-1's transactions) - → if current_block_number == None or Some(M) where M != N-1: - → must bootstrap from canonical StateProvider at block N-1 - → if StateProvider not yet available for N-1: WAIT (see below) - → reset stored_flashblocks = [flashblock], latest_flashblock_index = Some(0) - → apply pre-execution changes (EIP-4788, create2 deployer) on fresh execution context - → execute base transactions, emit partial block (idx=0, is_final=false) - → update accumulated_db - -Event: Block N, index K (delta, K > 0) - → validate sequence: NextInSequence expected - → execute ONLY the new transactions from this delta on accumulated_db - → emit partial block (idx=K, is_final=false or true if last known) - → update accumulated_db -``` - -**Edge case 1 — StateProvider not yet available:** - -When we receive Block N base (index 0) but `StateProvider` for block N-1 is not yet available -(canonical chain hasn't caught up), we must wait. Strategy: - -- Before processing the base event, attempt `client.state_by_block_hash(parent_hash)` or - `client.latest()` and check if it reflects block N-1. -- If not available: **park the event** in a `pending_base: Option` field, spawn a - retry task (e.g., loop with 5ms sleep up to 2 seconds). If timeout exceeded: log warning, set - `is_skipping = true`, discard this base event. Deltas for this block will also be discarded - (sequence validator returns `NextInSequence` but `is_skipping = true` causes immediate return). -- If the carried `accumulated_db` from the previous block is available (normal sequential case): - skip the provider lookup entirely — this is the fast path. - -**Edge case 2 — gap in block numbers (restart / reorg):** - -If `current_block_number == Some(M)` and the new base is for block N where N ≠ M+1: -- Drop `accumulated_db`. -- Attempt to fetch `StateProvider` at block N-1 from the canonical chain. -- Same timeout/retry logic as edge case 1. -- If N < M: this could be a reorg; treat as a fresh start. Log a warning. - -**Edge case 3 — duplicate base (index 0 for same block number):** - -- `FlashblockSequenceValidator` returns `Duplicate`. Log + skip. - -**Edge case 4 — non-sequential gap within a block:** - -- `FlashblockSequenceValidator` returns `NonSequentialGap`. Log warning, set `is_skipping = true` - for this block. All subsequent deltas for this block are discarded. On the next base event - (new block number), `is_skipping` is cleared and we attempt fresh bootstrap. - -**Edge case 5 — processor falls behind (slow execution):** - -- The WS subscriber channel may accumulate events. The processor must process events sequentially - (single-threaded inner loop). If the WS feed gets too far ahead, we will observe - `NonSequentialGap` (missed deltas) which triggers `is_skipping`. This is acceptable: we emit - whatever partial blocks we can and skip the rest. The canonical block is still traced correctly. - -**Edge case 6 — is_final semantics:** - -- The `FlashBlockData.is_final` field tells the downstream consumer whether this is the last - flashblock for this block number. The flashblock WS protocol encodes "final" blocks differently - (the last delta has a specific marker). The processor must inspect the incoming `Flashblock` - to determine if this is the final delta and set `is_final` accordingly when building `FlashBlockData`. - -**Edge case 7 — accumulated_db corruption on execution error:** - -- If `FirehoseWrappedExecutor` returns an error mid-execution, the `accumulated_db` may be in a - partially-mutated state. To avoid carrying corrupt state forward, on any execution error: - - Set `is_skipping = true`. - - Drop `accumulated_db` (set to `None`). - - Log the error with block/index fields. - - The next base event will attempt a fresh bootstrap from the canonical provider. - -#### State Accumulation Implementation - -```rust -// On successful execution of flashblock transactions: -// We commit the EVM state changes into accumulated_db -// The State bundle grows with each flashblock's transactions. -// We do NOT call db.merge_transitions() between flashblocks: -// that would collapse history and break incremental execution. -// We DO NOT call db.take_bundle() until we want to discard the state. -// The natural progression: db.commit(result.state) accumulates all changes. -``` - -The key: `State` in Reth wraps a read-through cache over the provider. After -`db.commit(state_changes)`, subsequent reads from the same address see the committed values. So Block -N+1's execution naturally reads Block N's post-state without needing a new `StateProvider`. This -eliminates the canonical-chain delay for the common sequential case. - ---- - -### New Crate: `base-firehose-flashblocks` - -**Location:** `crates/firehose-flashblocks/` - -**Cargo.toml name:** `base-firehose-flashblocks` - -**Files:** - -``` -crates/firehose-flashblocks/ - Cargo.toml - README.md - src/ - lib.rs — minimal; re-exports only - processor.rs — FirehoseFlashblocksProcessor (core logic) - streamer.rs — FirehoseFlashblocksStreamer (wires subscriber → processor) - tracer.rs — FlashblocksTracerHandle (dedicated non-global tracer) - error.rs — Error enum - metrics.rs — Metrics struct (processing duration, error counts, skip counts) -``` - -**Dependencies (`Cargo.toml`):** - -```toml -[dependencies] -# core -tokio.workspace = true -tracing.workspace = true -url.workspace = true - -# firehose -reth-firehose.workspace = true -firehose-tracer.workspace = true - -# flashblocks (reused components) -base-flashblocks.workspace = true - -# execution (for OpFirehoseEvmConfig, hooks, executor) -base-execution-firehose.workspace = true -base-execution-evm.workspace = true # OpNextBlockEnvAttributes, etc. - -# reth primitives/provider -reth-primitives.workspace = true -reth-provider.workspace = true -reth-revm.workspace = true -alloy-primitives.workspace = true - -[lints] -workspace = true -``` - ---- - -### Upstream Prerequisites (already landed on `streamingfast/reth` `firehose/1.x`) - -All the upstream changes that the original plan called for are now merged on the -`firehose/1.x` branch of `streamingfast/reth`. The workspace dependency has been bumped -accordingly: - -- `Cargo.toml`: every `git = "https://github.com/streamingfast/reth.git"` line now uses - `branch = "firehose/1.x"` instead of `tag = "v1.11.4-fh-1"` (72 dependency lines - + 1 `[workspace.dependencies] reth-firehose` line). -- `Cargo.lock`: regenerated; all `reth-*` crates now resolve to commit `c052fdfe…` - (head of `firehose/1.x`). The transitive `firehose-tracer` resolution remains - `5.1.0` (registry), which is the version that already exposes `FlashBlockData`, - `BlockEvent.flash_block: Option`, and - `Tracer::new_with_writer(Config, Box)`. - -What the upstream now provides (read directly from -`/tmp/reth-firehose-1x/crates/firehose/src/{lib.rs,block_tracer.rs}`): - -#### 1. Stdout write coordination — `crates/firehose/src/lib.rs` - -```rust -pub struct SynchronizedStdout { lock: Arc> } -impl SynchronizedStdout { - pub fn new(lock: Arc>) -> Self; -} -impl Write for SynchronizedStdout { /* acquires lock, then delegates to stdout */ } - -pub fn init_stdout_lock() -> Arc>; // idempotent; returns the shared lock -pub fn stdout_lock() -> Arc>; // panics if init_tracer hasn't run -pub fn is_tracer_initialized() -> bool; -pub fn init_tracer(config: firehose_tracer::config::Config); -pub fn tracer() -> MutexGuard<'static, firehose_tracer::Tracer>; -``` - -Key semantics: - -- `init_tracer` now takes a `firehose_tracer::config::Config` (it used to take a pre-built - `Tracer`). Internally it calls `init_stdout_lock()`, wraps the lock in - `SynchronizedStdout`, and builds the global tracer with - `Tracer::new_with_writer(config, Box::new(SynchronizedStdout::new(lock)))`. The global - tracer is therefore already serialised against the shared lock. -- Any additional tracer (e.g. our flashblock tracer) must be built **after** `init_tracer` - has run, using `stdout_lock()` + `SynchronizedStdout::new(...)` + the tracer's - `new_with_writer` so it shares the same `Arc>`. -- When only one tracer is active the lock is uncontested → effectively zero overhead. - -#### 2. `FirehoseBlockTracer::start_flashblock_local` — `crates/firehose/src/block_tracer.rs` - -```rust -impl<'a> FirehoseBlockTracer<&'a mut firehose_tracer::Tracer> { - pub fn start_flashblock_local( - tracer: &'a mut firehose_tracer::Tracer, - block: &SealedBlock, - finalized: Option, - flash_block_idx: u64, - is_final: bool, - ) -> Self - where - N: NodePrimitives, - N::Block: BlockTrait, - ::Header: BlockHeader + Sealable, - ::Body: BlockBody, - <::Body as BlockBody>::OmmerHeader: BlockHeader + Sealable, - { /* emits on_block_start with flash_block: Some(FlashBlockData { idx, is_final }) */ } -} -``` - -Only the `_local` variant exists upstream — there is intentionally **no** -`start_flashblock` (global) constructor. Flashblock emission *always* uses a dedicated -tracer, so the design no longer needs the global-tracer flavor. - -#### 3. `FirehoseBlockTracer::mark_flashblock` — same file - -```rust -impl FirehoseBlockTracer where G: DerefMut { - pub fn mark_flashblock(mut self) { - self.guard.on_block_end(None); - self.status = Status::Consumed; - } -} -``` - -Identical to `mark_verified` but without the genesis-skip path (a flashblock never represents -block 1). The doc-comment explicitly tells callers to only invoke this on guards created via -`start_flashblock_local`. - -#### 4. `firehose-tracer 5.1.0` is_final wire-protocol behavior - -The `is_final` bit on `FlashBlockData` is not a passive label — when `true`, the tracer -encodes the flashblock index as `idx + 1000` in the FIRE BLOCK line -(`printer::compute_printed_flash_block_index`). The downstream Firehose consumer uses this -sentinel to identify the last partial of a block. Our processor must set this bool correctly -based on the WebSocket payload's "final" marker; no extra wire-protocol work is needed. - -#### Summary of upstream landed changes (no additional reth-firehose work required) - -| File | Symbol | Status | -|---|---|---| -| `crates/firehose/src/lib.rs` | `SynchronizedStdout`, `init_stdout_lock`, `stdout_lock` | Landed | -| `crates/firehose/src/lib.rs` | `init_tracer(Config)` (signature change) | Landed | -| `crates/firehose/src/block_tracer.rs` | `start_flashblock_local` | Landed | -| `crates/firehose/src/block_tracer.rs` | `mark_flashblock` | Landed | - ---- - -### Architecture Overview - -``` -bin/node/src/main.rs - │ - ├── FirehoseExtension (existing – ExEx for canonical block tracing via global tracer) - └── FirehoseFlashblocksExtension (new – installed only if --firehose-flashblocks-url is set - │ AND is_tracer_initialized()) - └── FirehoseFlashblocksStreamer::new(ws_url, provider).start() - │ - └── FlashblocksSubscriber - └── calls processor.on_flashblock_received(flashblock) - └── FirehoseFlashblocksProcessor - ├── FlashblockSequenceValidator (reused) - ├── BlockAssembler (reused) - ├── accumulated_db: Option> - ├── FlashblocksTracerHandle (dedicated tracer) - └── emits partial FIRE lines via mark_flashblock() - -crates/firehose-flashblocks/src/ - ├── lib.rs - ├── processor.rs (FirehoseFlashblocksProcessor) - ├── streamer.rs (FirehoseFlashblocksStreamer) - ├── tracer.rs (FlashblocksTracerHandle — owns the dedicated Tracer) - ├── error.rs - └── metrics.rs -``` - ---- - -### Implementation Plan - -**Step 0 — Dependency bump (already done in this replan)** - -The workspace `Cargo.toml` and `Cargo.lock` have been updated to track `streamingfast/reth` -on `branch = "firehose/1.x"` (commit `c052fdfe` at the time of writing). This brings in: - -- `FirehoseBlockTracer::start_flashblock_local` -- `FirehoseBlockTracer::mark_flashblock` -- `reth_firehose::{SynchronizedStdout, init_stdout_lock, stdout_lock, is_tracer_initialized}` -- `init_tracer` now takes `firehose_tracer::config::Config` - -`firehose-tracer 5.1.0` (with `FlashBlockData` and `Tracer::new_with_writer`) is pulled -transitively. No additional upstream changes are required for the rest of this plan. - -Verify before implementing: - -```bash -cd /Users/maoueh/work/sf/base/.worktrees/feature/firehose-flashblocks-support -cargo check -p reth-firehose 2>&1 | tail -10 # should compile clean -cargo doc -p reth-firehose --no-deps --open # confirm new symbols visible -``` - -**Step 1 — New crate `crates/firehose-flashblocks/`** - -- Create directory structure as described above. -- `Cargo.toml`: name = `"base-firehose-flashblocks"`, add all dependencies listed in §New Crate. -- Add to workspace `Cargo.toml` members list and `[workspace.dependencies]`. -- Write `README.md` (brief description). - -**Step 2 — `error.rs`** - -Define `Error` enum: - -```rust -pub enum Error { - StateProviderTimeout { block_number: u64, parent_hash: B256 }, - SequenceGap { block_number: u64, expected_index: u64, got_index: u64 }, - BlockAssembly(anyhow::Error), - Execution(anyhow::Error), - TransactionDecoding(alloy_rlp::Error), -} -``` - -**Step 3 — `metrics.rs`** - -Following the existing `Metrics` pattern in `base-flashblocks/src/metrics.rs`: - -```rust -pub struct Metrics { - pub flashblocks_processed: Counter, - pub flashblocks_skipped: Counter, - pub flashblocks_errors: Counter, - pub execution_duration: Histogram, - pub state_bootstrap_duration: Histogram, -} -``` - -**Step 4 — `tracer.rs` — `FlashblocksTracerHandle`** - -Owns a dedicated (non-global) `firehose_tracer::Tracer` for flashblock execution. The -tracer writes through `SynchronizedStdout` so its output is serialised against the global -live-block tracer at the stdout-write level. Must be constructed **after** -`reth_firehose::init_tracer(...)` has been called by the main binary (otherwise -`stdout_lock()` panics). - -```rust -use std::io::Write; - -pub struct FlashblocksTracerHandle { - tracer: firehose_tracer::Tracer, -} - -impl FlashblocksTracerHandle { - /// Constructs a dedicated Tracer that shares the process-wide stdout lock with the - /// global tracer. The supplied `config` should typically mirror the one used for the - /// global tracer (e.g. same `chain_client`, same emission mode), with the caveat that - /// the cursor file path must NOT be shared — the flashblock tracer is pre-canonical - /// and emits separate FIRE BLOCK lines. - pub fn new(config: firehose_tracer::config::Config) -> Self { - let lock = reth_firehose::stdout_lock(); - let writer: Box = - Box::new(reth_firehose::SynchronizedStdout::new(lock)); - let mut tracer = firehose_tracer::Tracer::new_with_writer(config, writer); - // on_blockchain_init must be emitted once before any block events — but the global - // tracer has already emitted it. The downstream consumer is keyed by tracer-id, so - // we either (a) emit a separate init using a distinct tracer-id, or (b) skip the - // init and rely on the global one. The decision is captured in the spec below. - Self { tracer } - } - - pub fn tracer_mut(&mut self) -> &mut firehose_tracer::Tracer { - &mut self.tracer - } -} -``` - -**On `on_blockchain_init` for the dedicated tracer:** The implementor must inspect -`firehose_tracer::Tracer::on_blockchain_init` semantics. If the downstream Firehose -consumer expects exactly one init per FIRE stream, the flashblock tracer should NOT emit -its own init (the global tracer already did). If init is per-tracer-instance and the -consumer can handle multiple inits with distinct ids, emit one with a separate -tracer-id (e.g. `"reth-flashblock"`). Inspect -`/usr/local/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/firehose-tracer-5.1.0/src/tracer.rs` -and the firehose-core consumer side to decide; the safe default is to skip init for the -flashblock tracer. - -**Step 5 — `processor.rs` — `FirehoseFlashblocksProcessor`** - -Core logic. The struct and its `on_flashblock_received` implementation. - -```rust -pub struct FirehoseFlashblocksProcessor { - client: Client, - inner: Mutex, - tracer: Mutex, - metrics: Metrics, -} - -struct ProcessorState { - current_block_number: Option, - latest_flashblock_index: Option, - accumulated_db: Option>>>, - stored_flashblocks: Vec, - is_skipping: bool, -} -``` - -The `process` method (called from `on_flashblock_received` under `inner` lock): - -``` -fn process(&mut self, flashblock: Flashblock, client: &Client, tracer: &mut FlashblocksTracerHandle) -> Result<()>: - - 1. Validate sequence: - - current: (current_block_number, latest_flashblock_index) - - incoming: (flashblock.metadata.block_number, flashblock.metadata.index) - - Call FlashblockSequenceValidator::validate(current_num, current_idx, new_num, new_idx) - - Duplicate → warn + return Ok(()) - - InvalidNewBlockIndex / NonSequentialGap → warn, set is_skipping=true, drop accumulated_db, return Ok(()) - - FirstOfNextBlock or NextInSequence → continue - - 2. If is_skipping AND index != 0 → warn + return Ok(()) (still skipping within this block) - If is_skipping AND index == 0 → clear is_skipping (new block, fresh start) - - 3. If index == 0 (new base): - a. Determine state source: - - If current_block_number == Some(N-1) where N == flashblock.metadata.block_number: - FAST PATH: accumulated_db is already at post-Block-N-1 state. Carry it forward. - - Else (gap, restart, first ever): - BOOTSTRAP: fetch StateProvider at parent (block N-1) from client. - Retry loop: attempt client.state_by_block_number(N-1) up to ~20 attempts × 100ms. - On timeout: warn, set is_skipping=true, return Ok(()). - On success: State::builder().with_database(StateProviderDatabase::new(provider)) - .with_bundle_update().build() - b. Reset stored_flashblocks = vec![flashblock.clone()], latest_flashblock_index = Some(0) - c. current_block_number = Some(N) - - 4. If index > 0: - a. stored_flashblocks.push(flashblock.clone()) - b. latest_flashblock_index = Some(index) - - 5. Determine is_final from flashblock metadata (inspect the WS protocol's final-block marker field) - - 6. Assemble partial block for tracer init: - assembled = BlockAssembler::assemble(&stored_flashblocks)? - (assembled.sealed_block has B256::ZERO hash, which is correct for pre-canonical) - - 7. Determine new transactions for this flashblock: - - index == 0: all transactions from the base flashblock - - index > 0: only transactions in this delta (flashblock.diff.transactions) - Decode + recover senders. - - 8. Initialize per-flashblock tracer guard: - let tracer_guard = FirehoseBlockTracer::start_flashblock_local( - tracer.tracer_mut(), - &assembled.sealed_block, - None, // finalized: None for pre-canonical - index, - is_final, - ); - - 9. Build EVM environment from assembled block's header (using OpNextBlockEnvAttributes or - equivalent; chain spec from client.chain_spec()). - - 10. If index == 0: apply pre-execution changes (EIP-4788, EIP-2935, create2 deployer) - using the appropriate hook (matches how OpChainHooks does it in the canonical path). - - 11. Execute new transactions using FirehoseWrappedExecutor: - - inspector = tracer_guard.inspector() - - evm = evm_config.evm_with_env_and_inspector(&mut accumulated_db, evm_env, inspector) - - For each new_tx: execute through evm + accumulate result into accumulated_db - On error: tracer_guard.mark_failed(&err), set is_skipping=true, drop accumulated_db, return. - - 12. Emit partial block: - tracer_guard.mark_flashblock() - - 13. Update metrics. -``` - -**Step 6 — `streamer.rs` — `FirehoseFlashblocksStreamer`** - -```rust -pub struct FirehoseFlashblocksStreamer { - processor: Arc>, - ws_url: Url, -} - -impl FirehoseFlashblocksStreamer { - pub fn new(ws_url: Url, client: Client) -> Self { - let processor = Arc::new(FirehoseFlashblocksProcessor::new(client)); - Self { processor, ws_url } - } - - pub fn start(self) { - tokio::spawn(async move { - let mut subscriber = FlashblocksSubscriber::new( - Arc::clone(&self.processor), - self.ws_url, - ); - subscriber.start().await; - }); - } -} -``` - -**Step 7 — CLI flag in `bin/node/src/cli.rs`** - -```rust -/// WebSocket URL for the Firehose flashblocks feed. -/// When set, partial Firehose block events are emitted as flashblocks arrive. -/// Disabled (no-op) if empty or if the Firehose tracer is not initialized. -#[arg(long = "firehose-flashblocks-url", default_value = "")] -pub firehose_flashblocks_url: String, -``` - -**Step 8 — Wiring in `bin/node/src/main.rs`** - -```rust -// After FirehoseExtension is installed: -let fb_url = args.firehose_flashblocks_url.trim(); -if !fb_url.is_empty() { - if reth_firehose::is_tracer_initialized() { - let url = fb_url.parse::().expect("invalid --firehose-flashblocks-url"); - // Install extension that starts the streamer once the node's provider is ready. - runner.install_ext(FirehoseFlashblocksExtension::new(url)); - } else { - warn!("--firehose-flashblocks-url is set but Firehose tracer is not initialized; ignoring"); - } -} -``` - -**Step 9 — `FirehoseFlashblocksExtension` in `bin/node/src/`** - -Following the existing extension pattern (e.g., `runner.rs` / `extension.rs`): - -```rust -pub struct FirehoseFlashblocksExtension { - ws_url: Url, -} - -impl FirehoseFlashblocksExtension { - pub fn new(ws_url: Url) -> Self { Self { ws_url } } -} - -// Implement the node extension trait used by runner.install_ext() -// The hook fires after the node is started and a provider handle is available. -// Inspect bin/node/src/runner.rs to find the correct trait and hook point. -impl RethNodeCommandConfig for FirehoseFlashblocksExtension -where Node: FullNodeComponents, ... -{ - fn on_node_started(&self, ctx: &FullNodeContext) -> eyre::Result<()> { - let provider = ctx.provider().clone(); - let streamer = FirehoseFlashblocksStreamer::new(self.ws_url.clone(), provider); - streamer.start(); - Ok(()) - } -} -``` - -(The exact trait and hook API must be determined by reading `bin/node/src/runner.rs` and the -existing `FirehoseExtension` wiring. The implementor should align with that pattern.) - -**Step 10 — Cargo.toml workspace integration** - -- Add `crates/firehose-flashblocks` to `[workspace]` members. -- Add `base-firehose-flashblocks = { path = "crates/firehose-flashblocks" }` to - `[workspace.dependencies]`. -- Update `bin/node/Cargo.toml` to add `base-firehose-flashblocks.workspace = true`. -- (No reth-firehose bump needed — already done in Step 0.) - ---- - -### Decisions & Assumptions - -| Decision/Assumption | Rationale | -|---|---| -| `base-firehose-flashblocks` as a standalone crate | Clean separation; flashblocks + firehose intersection is non-trivial. | -| Dedicated non-global `firehose_tracer::Tracer` | Prevents live-block tracing from being blocked or corrupted by flashblock execution. | -| `SynchronizedStdout` mutex in `reth-firehose` | Both tracer instances share a single `Arc>` to ensure atomic line writes to stdout. Uncontested in normal (no-flashblocks) mode = zero overhead. | -| `--firehose-flashblocks-url` default is empty string | CLI convention; empty = disabled. Matches how the existing `--flashblocks-url` is handled. | -| Fast-path state carry-forward (no provider lookup for sequential blocks) | Critical for performance: avoids waiting for canonical chain state to catch up with in-flight flashblocks. The `State` carries all prior transactions' effects. | -| Retry loop (up to ~2s) for StateProvider bootstrap on gap/restart | Without some waiting, the first flashblock after a restart would always fail (canonical state may not yet be at the right height). A bounded retry is pragmatic. | -| Carry `accumulated_db` across blocks (not just within a block) | Key insight from the dev feedback: Block N+1's base arrives before canonical reflects Block N. The carried `State` is the only reliable source of Block N's post-state. | -| `is_final` derived from WS payload | The flashblock WS protocol encodes finality in the payload. The implementor must inspect `Flashblock` struct in `base-flashblocks` to find the right field. | -| Pre-execution changes (EIP-4788, etc.) only on index == 0 | Matches Geth `StateProcessor.Process` `isFirstExecution` gate. These are block-level, not per-delta. | -| Track `streamingfast/reth` `branch = "firehose/1.x"` (not a fixed tag) | The upstream changes landed on `firehose/1.x` after the original plan was written. Branch tracking is acceptable for the duration of this feature work; once stabilised the maintainer will cut a tag (e.g. `v1.11.4-fh-2`) and we will pin to it. | -| `init_tracer` signature now takes `Config` (not a pre-built `Tracer`) | API change on `firehose/1.x`: `init_tracer` builds the global tracer internally using `SynchronizedStdout`. Bin/node already on `firehose/0.x` was calling `init_tracer(Tracer::new(...))`; that call site must be updated to pass `Config` directly. | -| `firehose-tracer` 5.1.0 already has `FlashBlockData` and `new_with_writer` | Confirmed by reading `types.rs` and `tracer.rs` in the registry-vendored source. The flashblock tracer is built via `Tracer::new_with_writer(Config, Box::new(SynchronizedStdout::new(stdout_lock())))`. | -| `is_final` wire encoding (`idx + 1000`) handled inside `firehose-tracer` | The processor only sets the `bool`; the printer adjusts the printed index automatically (`printer::compute_printed_flash_block_index`). No protocol work on our side. | -| Flashblock tracer's `on_blockchain_init` is deferred to the implementor to verify | The global tracer already emits `on_blockchain_init`. Whether the dedicated flashblock tracer should emit a separate init (with a distinct tracer-id) or skip it depends on what the downstream Firehose consumer accepts. Default to skip; revise if integration testing shows the consumer requires per-tracer init. | - ---- - -## Semantic Equivalence Analysis (Rust vs Go) - -Comparing `FirehoseFlashblocksProcessor` (Rust) against the Go implementation at -`streamingfast/go-ethereum/tree/release/optimism-1.x-fh3.0/node/flashblock/`. - -**Verdict: Functionally equivalent for the core tracing path. Four known behavioral divergences, -all documented below. None affect correctness of the emitted transaction traces.** - -### Behavioral Equivalences - -Both implementations: -- Subscribe via WebSocket to `FlashblocksPayloadV1` messages -- Validate flashblock sequence (must be strictly monotonic within a block) -- Carry EVM state across flashblocks (accumulated_db / bundle state) -- Bootstrap from canonical chain provider when there is a gap in block numbers -- Apply pre-execution changes (EIP-4788, etc.) only on the first flashblock (index=0) -- Execute each flashblock's transactions through a traced EVM executor -- Emit one `FIRE BLOCK` partial per flashblock via `on_block_start` + `on_block_end` -- Pass `idx = 0` for the base flashblock to the firehose tracer (both Go and Rust) - -### Behavioral Divergences - -**1. `is_final` flag (important)** - -Go: Uses peek-ahead (`LastSentIndex` + next message's block number) to determine if the -current flashblock is the last one for a block. When the next base message arrives for -block N+1, Go retroactively marks block N's last flashblock as `is_final=true`, emitting -`FIRE BLOCK N ...` for that block. - -Rust: Always hardcodes `is_final = false`. The `idx + 1000` sentinel in the FIRE BLOCK -line is never emitted. The reason: `FlashblocksPayloadV1` / `Metadata` does not carry a -per-payload "final" marker. The peek-ahead pattern from Go would require buffering the -current flashblock until the next message arrives, which complicates the sequential -processing model significantly. - -Impact: Downstream consumers cannot detect the last partial for a block from the FIRE -BLOCK line alone. The consumer must infer finality from the canonical FIRE BLOCK (idx=0) -that the live-block tracer emits when the canonical block arrives. - -**2. PayloadID validation** - -Go: Validates that all flashblocks within the same block number carry the same PayloadID. -A mismatch triggers a reset/skip for the block. - -Rust: No PayloadID validation. The sequence validator only checks block number + index -monotonicity. In practice, the WS feed should not send mismatched PayloadIDs for the same -block, so this is a defense-in-depth gap rather than a functional correctness gap. - -**3. Timestamp/staleness check** - -Go: Skips execution if the flashblock timestamp is more than some threshold in the past -("too far behind"). Avoids executing stale flashblocks that arrived very late. - -Rust: No timestamp check. Every flashblock is executed regardless of how old it is. This -can be addressed later if excessive staleness becomes an operational concern. - -**4. Notification messages / PayloadID-keyed feed** - -Go: Receives special `0xdeadbeef` notification messages from NewPayload calls on the -engine API path. These are used to initialize the state for a new block before the first -flashblock arrives. - -Rust: No equivalent notification. The processor is purely reactive to WS messages from -the flashblocks feed. Bootstrap happens on-demand when the first base flashblock for a -block arrives. - -### Non-Issue Differences (out of scope per dev feedback) - -- Architecture (Go: controller+processor split; Rust: single processor struct) -- Parallel transaction message preparation (Go uses up to 10 workers; Rust is sequential) -- `SnapshotFlashBlockForNextIteration` call (Go explicitly snapshots; Rust's State - accumulates naturally without explicit snapshots) -- Reconnection strategy (both implement reconnect/retry; different implementations) - ---- - -## is_final Investigation (Dev Feedback Item 1) - -**Source files read:** `node/flashblock/controller.go` from `streamingfast/go-ethereum` branch -`release/optimism-1.x-fh3.0`. - -### Where `is_final` comes from in op-geth - -`is_final` is **not** a field anywhere in `FlashblocksPayloadV1` or `Metadata`. The WS protocol -has no per-message "final" marker. Go derives `is_final` at the call site through two distinct -mechanisms, both implemented in `controller.go::processMessage`. - -#### Mechanism 1 — Peek-ahead on the WS message channel - -After the controller accumulates a delta (index > 0), it peeks at the next message in the -in-memory buffered channel: - -```go -if nextMsg, ok := c.msgChannel.Peek(); ok { - // ... skip any 0xdeadbeef notification messages in the peek position ... - if nextMsg != nil && nextMsg.Static != nil { - if uint64(nextMsg.Static.BlockNumber) == c.state.ExecutableData.Number+1 { - // Next message is a base for block N+1 → current delta is the last for block N - expectedBlockHash = &nextMsg.Static.ParentHash - } - } -} -isFinalBlock := expectedBlockHash != nil -``` - -When the next queued message is the base for `block N+1`, the controller knows the current -flashblock is the last one for `block N` and passes `isLastFlashBlock=true` to the processor, -which then calls `tracer.SetFinalFlashBlock(...)`. The `OnBlockStart` hook receives -`FlashBlock.Idx = currentIndex` and Go's Firehose tracer maps that through its own -`compute_printed_flash_block_index` equivalent to emit `idx + 1000` in the FIRE BLOCK line. - -**Key property**: The peek can only see the NEXT message; if the message queue is empty (the WS -feed hasn't sent the next base yet), the peek fails and `isFinalBlock = false`. So even in Go, -if messages arrive slowly (one at a time with gaps), `is_final` is set on the same iteration -that the next base happens to be buffered, not necessarily on the actual last delta. - -#### Mechanism 2 — Engine API `NewPayload` notification (the `0xdeadbeef` path) - -In `engine/payload_handler.go` (or equivalent), the op-geth team added a hook in the engine -API `newPayload` call that sends a special synthetic message into the flashblock controller's -channel: - -```go -// From Controller.SendNotification (called by engine newPayload handler): -notification := &FlashblocksPayloadV1{ - Version: hexutil.Bytes{0xde, 0xad, 0xbe, 0xef}, - Index: blockNumber, // the canonical block number arriving - ParentFlashHash: &blockHash, // the canonical block hash -} -c.msgChannel.C <- notification -``` - -When the controller sees this notification (via `isNotificationMessage(msg)`), it knows: -> "The canonical block N just arrived via engine API — execute the current accumulated state -> with `isLastFlashBlock=true` right now, before we even receive the next WS message." - -This means the `is_final` signal can come either from (a) the WS peek or (b) the engine API -notification, whichever arrives first. In practice the engine `newPayload` arrives a few ms -before the next flashblock's WS base message, so the notification path fires first. - -### What the Rust equivalent would look like - -The Rust equivalent requires two changes: - -**A. WS peek-ahead**: The `FlashblocksSubscriber` would need to expose a `Peek` operation on -its internal channel, or the processor's `on_flashblock_received` callback would need to accept -a "next message" hint. Currently `FlashblocksReceiver::on_flashblock_received` only receives -the current message. A redesign would be needed: either the subscriber calls back with -`(current, Option)`, or we use a tokio `watch` channel where the processor can always -read the latest state. - -**B. Engine API notification**: The reth engine tree (`crates/execution/engine-tree/`) would -need to call a method on `FirehoseFlashblocksProcessor` when `engine_newPayload` is called. -This is analogous to the Go `SendNotification` call. The processor would then immediately -execute the current accumulated state with `is_final=true`, even if no more WS messages arrive. - -### Recommendation - -**Do not implement `is_final` in this PR.** The current `is_final=false` hardcode is acceptable -because: - -1. Downstream consumers already distinguish flashblock streams from canonical streams via the - distinct `FIRE INIT` header (`reth-flashblock` vs `reth`). Within the flashblock stream, - `is_final` is a "quality of life" signal, not a correctness requirement. -2. The peek-ahead mechanism requires a non-trivial redesign of `FlashblocksReceiver` trait. -3. The engine API notification hook requires cross-crate wiring into `engine-tree`, which is - a significant change outside the scope of this feature. - -A follow-up PR can add `is_final` support once the architecture is stable. The two levers: -- **Short-term heuristic**: buffer the current flashblock and delay execution by one tick; - when the next WS message arrives for block N+1, re-execute the buffered flashblock with - `is_final=true`. Adds one flashblock of latency but requires no engine-tree changes. -- **Proper signal**: wire `engine_newPayload` → `FirehoseFlashblocksProcessor::notify_canonical` - callback to trigger immediate final execution with `is_final=true`. - ---- - -## State Tracker - -**Last Updated:** 2026-05-20 UTC -**Current Step:** Step 11 — Dev Feedback round 2 (is_final investigation, test split, FIRE parsing). -**Status:** New crate `base-firehose-flashblocks` (5 modules) is wired into `bin/node` behind `--firehose-flashblocks-url`; full default-members workspace builds; `cargo clippy -- -D warnings` is clean; `cargo test -p base-firehose-flashblocks` runs 6 tests (all pass). - -### Known gaps / follow-ups for the reviewer - -- `is_final` on the emitted partial block is hardcoded to `false`. The wire-level - `FlashblocksPayloadV1` / `Metadata` does not expose a per-payload "final" marker, - so the `idx + 1000` sentinel in `firehose-tracer 5.1.0::printer::compute_printed_flash_block_index` - is never triggered. Downstream consumers still see correct partial idx values; they - just can't tell ahead of time which idx is the last for the block. The proper signal - would either need (a) an upstream wire-protocol addition or (b) a heuristic (e.g. - "this idx N has been followed by block N+1's base"). -- The processor logs metrics via `tracing` only; no Prometheus/`base-metrics` - Counter/Histogram are exposed yet. The spec called for a `metrics.rs` module — - trivially addable once we know which counters the operations team wants. -- The `accumulated_db` cross-block carry-forward path uses - `State::builder().with_database(StateProviderDatabase::new(provider))…build()` - to bootstrap. This depends on `state_by_block_number_or_tag(parent)` returning a - provider whose underlying snapshot reflects everything before block N — which - the canonical-chain provider does but may briefly lag the flashblock feed. - The 20×100ms retry covers that; if it turns out to be too aggressive in - practice, raising `STATE_PROVIDER_MAX_RETRIES` is the lever. -- End-to-end behavior (a real flashblock WS feed driving a running node and the - downstream firehose consumer reading the emitted partials) is not exercised by - the unit tests in this branch. -- The base flashblock (raw index=0) emits `FIRE BLOCK N 0` (flash_idx=0), which is - the same as a canonical block in the FIRE protocol. Both Go and Rust pass `idx=0` - to the tracer for the base flashblock. In practice, the two tracer streams are - distinguishable by their distinct `FIRE INIT` headers (`reth` vs `reth-flashblock`) - rather than by per-line `flash_idx` values. - -| Step | Status | Notes | -|---|---|---| -| Phase 1 — Contextual Understanding | Done | Explored flashblocks, firehose, engine-tree, runner, bin/node crates | -| Phase 2 — Gap Analysis | Done | Identified: reth-firehose API gaps, state accumulation strategy, CLI wiring | -| Phase 3 — Challenging Dialogue | Done | Dev feedback addressed all open questions | -| Phase 4 — Specification Writing | Done | Full updated spec written above | -| Phase 5 — Spec Review (round 1) | Done | Updated per dev feedback; crate rename, cross-block state, firehose-tracer confirmed, concurrency design, reth-firehose changes section, rebase step added | -| Phase 5 — Spec Review (round 2, 2026-05-20) | Done | Upstream changes landed on `streamingfast/reth` `firehose/1.x` (commits `bb7699f28` + `c9cf230de`). Workspace `Cargo.toml` + `Cargo.lock` bumped from `tag = "v1.11.4-fh-1"` to `branch = "firehose/1.x"`. Removed entire "Required Changes to reth-firehose" section (3 sub-changes were all landed). Removed Step 1 (upstream patch work) — implementation now starts at "create new crate". `FlashblocksTracerHandle::new` rewritten to use `Tracer::new_with_writer(Config, Box::new(SynchronizedStdout::new(stdout_lock())))` matching the actual upstream API. Documented `is_final` wire encoding (`idx + 1000`) handled inside `firehose-tracer 5.1.0`. Added open item: whether the dedicated flashblock tracer should emit its own `on_blockchain_init`. | -| Phase 6 — Rebase + prereqs (2026-05-20) | Done | Rebased branch onto `firehose/0.x` so the live-tracer prereq commits (`3f5b1b124` restoring `firehose::init()` / `FirehoseExtension` + the `OpFirehoseEvmConfig` wrappers) are present. Conflict-resolved `Cargo.toml`: kept `branch = "firehose/1.x"` and the SP1 v6.1.0 `[patch.crates-io]` block (lost on the prior branch base); bumped `firehose-tracer = "5.1.0"`. Removed the duplicate `SignatureFields for BaseTxEnvelope` impl that the rebase produced. Updated `bin/node/src/firehose.rs::init()` for the new `init_tracer(Config)` signature. Workspace `cargo check` of default-members passes. (Pre-existing `base-test-utils` failure due to missing contract artifacts is unrelated and outside default-members.) | -| Step 1 — Crate scaffolding | Done | Created `crates/firehose-flashblocks/` with `Cargo.toml`, `README.md`, `src/{lib,error,tracer,processor,streamer}.rs`. Added to workspace `[workspace]` members and `[workspace.dependencies]`. `lib.rs` is minimal (module decls + re-exports only). All modules adopt the `//!` doc-comment convention. | -| Step 2 — Error / metrics module | Done | `error.rs`: `Error` enum with `StateProviderTimeout`, `BlockAssembly`, `Execution`, `TransactionDecoding { tx_index, message }`, `EvmEnv { block_number, source }`. Decoupled the decoding error variant from `alloy_rlp::Error` (which doesn't cover `Decodable2718`'s `Eip2718Error`) by storing a stringified message. Skipped the `metrics.rs` module from the spec — the processor itself doesn't ship metrics yet; downstream `FlashblocksSubscriber` already records WS-level counters, and processor-level counters can be added later without an API break. | -| Step 3 — Tracer handle | Done | `tracer.rs`: `FlashblocksTracerHandle` wraps a non-global `firehose_tracer::Tracer` constructed via `Tracer::new_with_writer(Config, SynchronizedStdout::new(stdout_lock()))`. Emits its own `FIRE INIT` with the distinct tracer-id `"reth-flashblock"` — verified by a unit test that calls `init_stdout_lock` (idempotent) and constructs the handle. Skipping `on_blockchain_init` would panic in `on_block_start` because the Tracer's `init_sent` flag is per-instance. | -| Step 4 — Processor | Done | `processor.rs`: `FirehoseFlashblocksProcessor` implements `FlashblocksReceiver`. Single `Mutex` guards the (current_block_number, latest_flashblock_index, stored_flashblocks, accumulated_db, is_skipping) tuple. The fast path carries `accumulated_db` across blocks when `block_number == previous + 1`; the bootstrap path retries `state_by_block_number_or_tag` 20× with 100ms backoff. Per-flashblock execution: `BlockAssembler` → decode/recover deltas → `start_flashblock_local` → `BaseBlockExecutor` wrapped in `FirehoseWrappedExecutor::with_hooks(OpPreTxAdjust, OpPostTxExtras)` → `apply_pre_execution_changes` only on `index == 0` → `execute_transaction` per delta tx → `mark_flashblock`. On execution error: `mark_failed`, set `is_skipping`, drop DB. | -| Step 5 — Streamer | Done | `streamer.rs`: `FirehoseFlashblocksStreamer` wraps the processor in `Arc` and reuses `base_flashblocks::FlashblocksSubscriber` for WS connection management + reconnect/backoff. Single `.start()` entrypoint. | -| Step 6 — CLI flag | Done | `bin/node/src/cli.rs`: `--firehose-flashblocks-url ` as `Option`. Independent of `--flashblocks-url`. | -| Step 7 — Node wiring | Done | `bin/node/src/main.rs`: declares `pub mod firehose;` and installs `FirehoseFlashblocksExtension` when the flag is `Some`. `bin/node/src/firehose.rs::FirehoseFlashblocksExtension` uses `add_node_started_hook` — gates on `reth_firehose::is_tracer_initialized()`, builds the dedicated tracer from the node's chain id, and starts the streamer. `bin/node/Cargo.toml` gets `base-firehose-flashblocks`, `reth-chainspec`, and `tracing`. | -| Step 8 — Tests + clippy + drive-by fixes | Done | 4 unit tests in the new crate: 3× `Error` Display invariants + 1× `FlashblocksTracerHandle::new` smoke test that emits `FIRE INIT 3.1 reth-flashblock 0.8.0` (verified by inspecting stdout). Drive-by clippy fixes outside the new crate (all pre-existing `-D warnings` failures, none caused by this work): `crates/builder/core/src/flashblocks/context.rs` (gate `B256` import behind `cfg(any(test, feature = "test-utils"))`), `crates/execution/engine-tree/src/validator.rs` (backtick `BaseFeeVault`/`L1FeeVault`/`OperatorFeeVault` in doc comment), `crates/execution/firehose/{src/extras.rs, README.md}` (same doc_markdown treatment). `cargo clippy -- -D warnings` is now clean across default-members. `cargo build` of default-members succeeds. | -| Step 9 — Dev Feedback: Integration test framework (2026-05-20) | Done | Added `crates/firehose-flashblocks/tests/flashblock_sequence.rs` integration test. Framework: `GenesisClient` mock (implements all 40+ reth provider traits, returns genesis header for all header lookups, routes state to `GenesisStateProvider` wrapping `StateProviderTest`); `flash_base(block_number, parent_hash, timestamp)` builder; `ws_server_once(sequence)` one-shot WS server using `tokio-tungstenite`; `fire_block_lines(raw)` parser; `run_flashblock_sequence(client, sequence)` harness (buffer-backed tracer via `FlashblocksTracerHandle::with_writer`). Added `with_writer` constructor to `FlashblocksTracerHandle` for test isolation. Test `flash_base_emits_fire_block` sends one base flashblock for block 1 and asserts `FIRE BLOCK 1 0` is emitted (raw index=0 maps to printed flash_idx=0). Added workspace deps: `reth-db-models` (needed by `BlockBodyIndicesProvider`). Added dev-deps: `reth-revm` with `test-utils` feature, `revm`, `alloy-rpc-types-engine`. `cargo test -p base-firehose-flashblocks` → 5 tests, all pass. `cargo clippy -p base-firehose-flashblocks --tests -- -D warnings` → clean. | -| Step 10 — Dev Feedback: Semantic equivalence analysis (2026-05-20) | Done | See "Semantic Equivalence Analysis" section above. Summary: 4 behavioral divergences (is_final peek-ahead missing, no PayloadID validation, no timestamp staleness check, no notification messages). Core tracing path (tx execution + FIRE BLOCK emission) is equivalent to the Go implementation. | -| Step 11 — Dev Feedback round 2: is_final investigation + test split + FIRE parsing (2026-05-20) | Done | (1) Investigated is_final in op-geth controller.go — see "is_final Investigation" section. Two mechanisms: WS peek-ahead and engine newPayload 0xdeadbeef notification. Documented recommendation to defer to a follow-up PR. (2) Split test file: framework moved to `tests/framework/mod.rs` (Cargo subdirectory, not compiled as standalone binary); `tests/flashblock_sequence.rs` imports via `mod framework;` and holds only test cases. (3) Replaced `fire_block_lines` with `ParsedFireBlock` struct + `parse_fire_blocks()` implementing the full FIRE 3.1 BLOCK line format. Added second integration test `flash_base_plus_delta_emits_two_fire_blocks` that exercises `flash_delta`, `flash_idx`, `is_final`, `block_hash`, `prev_block_number`, `lib_num`, `timestamp_ns`. `cargo test -p base-firehose-flashblocks` → 6 tests, all pass. `cargo clippy -p base-firehose-flashblocks --tests -- -D warnings` → clean. | diff --git a/.dev/todo/firehose-integration-tests.md b/.dev/todo/firehose-integration-tests.md deleted file mode 100644 index b07e158cbb..0000000000 --- a/.dev/todo/firehose-integration-tests.md +++ /dev/null @@ -1,246 +0,0 @@ -# Firehose Integration Tests for Base Transactions - -mode: feature -state: review -root_git: .worktrees/feature/firehose-integration-tests -worktree: .worktrees/feature/firehose-integration-tests -branch: feature/firehose-integration-tests -target_branch: firehose/0.x - -> **Resume protocol:** read **Dev Feedback** and the **State Tracker** below first, then jump to the -> step marked `Current`. Ensure that you are in the correct worktree and branch according to preamble here. Update current with Developer feedback and update the tracker after every meaningful change. -> Do not mutate completed steps; append a new entry instead. - ---- - -## Initial Description - -We want to have Firehose integration tests that run real Base transaction, trace it and ensure it works properly. We would like to have prestate tests working just like in reth https://github.com/streamingfast/reth/tree/firehose/1.x/crates/firehose-tests/src - -## Dev Feedback - -**Round #2** - -1. Change `GOLDEN_UPDATE` to `GOLDEN_UPDATE` everywhere needed -1. Delete generate_golden.rs, this is not the right approach anyway, will revisit this after merger in a subsequent task - -## Spec & Implementation - -### Summary - -Create a new `base-firehose-tests` library crate under `crates/execution/firehose-tests/` that reuses the generic harness infrastructure from `reth-firehose-tests` (`streamingfast/reth`). The upstream `reth-firehose-tests` crate on the `firehose/1.x` branch already exposes all required public types (`Prestate`, `TraceContext`, `RunOutcome`, `seed_cache_db`, `build_account_info`, `parse_fire_block_for`, `decode_hex`, serde helpers) — these changes are on the branch but not yet tagged. Step 0 is simply: cut a new tag on the branch and update base's Cargo.toml to point at it. - -The new `base-firehose-tests` crate depends on `reth-firehose-tests` and provides only the Base/OP-specific `run_prestate` function and `OpTraceContext` extension. All shared machinery comes from `reth-firehose-tests` directly. - -### Scope - -**In scope:** -- Step 0: cut a new tag on `streamingfast/reth` `firehose/1.x` branch (all Part 1 changes already exist on the branch) and update base's Cargo.toml to that tag + add `reth-firehose-tests` to `[workspace.dependencies]` -- New workspace crate `base-firehose-tests` at `crates/execution/firehose-tests/` -- `src/lib.rs` and `src/prestate.rs` — the Base-specific harness (thin layer over `reth-firehose-tests`) -- `tests/prestate.rs` — the Cargo integration test driver -- At minimum one test case: a simple ETH transfer (`nop_transfer`) with `prestate.json` + golden `*.binpb` -- The crate registered in `Cargo.toml` workspace members - -**Out of scope:** -- Generating golden files programmatically (goldens are committed artifacts, generated once) -- CI pipeline changes (the implementor can wire that separately) -- More than one initial test case - ---- - -### Implementation Plan - -#### Step 0 — Tag the upstream changes and update base's Cargo.toml - -The `firehose/1.x` branch of `streamingfast/reth` already contains all the required public-visibility changes to `reth-firehose-tests` (verified at commit `06e46c3`). No PR is needed — the code is already there. The implementor should: - -0b. Update **all** `reth`-namespaced entries in base's root `Cargo.toml` from `tag = "v1.11.4-fh-1"` to branch `firehose/1.x` - -0c. Add `reth-firehose-tests` to `[workspace.dependencies]` in base's root `Cargo.toml`: - ```toml - reth-firehose-tests = { git = "https://github.com/streamingfast/reth.git", } - ``` - -#### Step 1 — Create crate skeleton at `crates/execution/firehose-tests/` - -- `Cargo.toml` — `name = "base-firehose-tests"`, `publish = false`, workspace deps (see below) -- `README.md` — one-liner describing the crate purpose -- `src/lib.rs` — minimal re-export per AGENTS.md -- `src/prestate.rs` — Base-specific harness (thin layer over `reth-firehose-tests`) -- `tests/prestate.rs` — test driver -- `tests/cases/.gitkeep` - -#### Step 2 — Register in workspace - -Add `"crates/execution/firehose-tests"` to `members` in root `Cargo.toml` (after `"crates/execution/firehose"`). Add `base-firehose-tests = { path = "crates/execution/firehose-tests" }` to `[workspace.dependencies]`. - -#### Step 3 — Implement `src/prestate.rs` - -Port the Ethereum reference `run_prestate`, replacing: -- `ChainSpec` → `BaseChainSpec` -- `EthEvmConfig` → `BaseEvmConfig` -- `EthPrimitives` → `BasePrimitives` -- `TransactionSigned::network_decode` → `OpTxEnvelope::decode_2718` -- `NoPreTxAdjust` / `NoPostTxExtras` → `OpPreTxAdjust` / `OpPostTxExtras` -- `firehose_tracer::config::ChainConfig` fork timestamps: `canyon_time` → `shanghai_time`, `ecotone_time` → `cancun_time`, `isthmus_time` → `prague_time` - -Import and use from `reth_firehose_tests`: `Prestate`, `TraceContext`, `seed_cache_db`, `parse_fire_block_for`, `decode_hex`, `RunOutcome`. - -Define a local `build_op_header` (analogous to `build_header` in the reference) using OP block types. - -#### Step 4 — Implement `src/lib.rs` - -```rust -#![doc = include_str!("../README.md")] - -mod prestate; -pub use prestate::{run_prestate}; - -// Re-export shared types from reth-firehose-tests for convenience -pub use reth_firehose_tests::{assert_block_equals_golden, RunOutcome}; -``` - -#### Step 5 — Create test data for `nop_transfer` - -- Craft a minimal `prestate.json` with a simple ETH-transfer signed transaction, a genesis seeding the sender with enough ETH, and a block context matching a post-Regolith OP block -- Run with `GOLDEN_UPDATE=true` to produce the golden `.binpb` -- Commit both files under `tests/cases/nop_transfer/` - -#### Step 6 — Write `tests/prestate.rs` - -```rust -use std::path::PathBuf; -use base_firehose_tests::{assert_block_equals_golden, run_prestate}; - -#[test] -fn nop_transfer() { - let folder = case_dir("nop_transfer"); - let outcome = run_prestate(&folder).expect("nop_transfer prestate must succeed"); - let golden = folder.join("block.2099.binpb"); - assert_block_equals_golden(&outcome.block, &golden).expect("captured block must match golden"); -} - -fn case_dir(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("cases").join(name) -} -``` - -#### Step 7 — `Cargo.toml` for `base-firehose-tests` - -```toml -[package] -name = "base-firehose-tests" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -homepage.workspace = true -repository.workspace = true -publish = false - -[lints] -workspace = true - -[dependencies] -# reth firehose shared harness -reth-firehose-tests.workspace = true - -# base -base-execution-evm.workspace = true -base-common-consensus.workspace = true -base-execution-firehose.workspace = true -base-execution-chainspec.workspace = true - -# reth / firehose -reth-firehose.workspace = true -firehose-tracer.workspace = true -reth-revm.workspace = true -reth-primitives-traits.workspace = true - -# alloy / op consensus -alloy-eips.workspace = true -alloy-genesis.workspace = true -alloy-consensus.workspace = true -alloy-primitives.workspace = true -alloy-op-consensus.workspace = true - -# revm -revm.workspace = true - -# encoding & errors -eyre.workspace = true -prost.workspace = true -serde.workspace = true -serde_json.workspace = true - -[[test]] -name = "prestate" -path = "tests/prestate.rs" -``` - -Note: `hex`, `base64`, and other low-level encoding deps are no longer needed directly — they are encapsulated inside `reth-firehose-tests`. - -### Key Implementation Notes - -#### `alloy-op-consensus` in workspace deps - -Check if `alloy-op-consensus` is already in `[workspace.dependencies]` via `grep "alloy-op-consensus" Cargo.toml`. If not, add it before use. - -#### `BaseEvmConfig` constructor - -Check `crates/execution/evm/src/lib.rs` around line 135 for the concrete type parameters. Look at how it is instantiated in `crates/execution/node/` for the production configuration and mirror that. - -#### OP chain config fields for `firehose_tracer::config::ChainConfig` - -The OP genesis config is accessible via `prestate.genesis.config.optimism` (or similar field from `alloy_op_genesis`). The mapping is: -- `canyon_time` → `shanghai_time` -- `ecotone_time` → `cancun_time` -- `isthmus_time` → `prague_time` -- `verkle_time` → `None` - -Verify the actual field names in the `alloy` OP genesis types used by this workspace. - -#### `firehose_tracer::config::ChainConfig` fields - -Verify the exact fields available in `firehose-tracer` at the version pinned in this workspace. The names above (`shanghai_time`, `cancun_time`, `prague_time`, `verkle_time`) are based on the reference code; confirm they match. - -### Decisions & Assumptions - -| Decision/Assumption | Rationale | -|---|---| -| Step 0 = tag branch + update Cargo.toml (no PR needed) | All Part 1 changes already exist on `firehose/1.x` branch HEAD (verified at commit `06e46c3`); only a new tag and Cargo.toml bump needed | -| New tag (e.g. `v1.11.4-fh-2`) required in `streamingfast/reth` | The workspace uses tagged git deps; a new tag is the standard release mechanism | -| `Prestate` and `TraceContext` already `pub` in branch HEAD | Verified by inspection — no changes needed to reth fork | -| OP-specific context fields go in a local `OpTraceContext` wrapping `TraceContext` | Avoids polluting Ethereum-centric struct with OP fields; `#[serde(flatten)]` provides transparent JSON merging | -| `assert_block_equals_golden` re-exported from `reth-firehose-tests` | Already public and fully generic; no reason to duplicate | -| `RunOutcome` re-exported from `reth-firehose-tests` | Already public and fully generic | -| Start with one test case (`nop_transfer`) | Minimal viable harness; more cases easy to add | -| Use `OpPreTxAdjust` / `OpPostTxExtras` from `base-execution-firehose` | Already implemented and tested for OP Stack | -| Goldens are committed binary files (`.binpb`) | Same pattern as reference; no runtime generation | -| `GOLDEN_UPDATE` env-var convenience in `assert_block_equals_golden` | Standard pattern for regenerating goldens (already exists in reference, lives in `reth-firehose-tests`) | - ---- - -## State Tracker - -**Last Updated:** 2026-05-20 -**Current Step:** Done — ready for review (Round 2) -**Status:** Round 2 feedback applied; `cargo test -p base-firehose-tests --test prestate` and `cargo clippy -p base-firehose-tests -- -D warnings` both pass - -| Step | Status | Notes | -|---|---|---| -| Phase 1 — Contextual Understanding | Done | Explored execution/firehose, common/evm, common/consensus, execution/chainspec; fetched reference lib.rs + prestate.rs + Cargo.toml from reth | -| Phase 2 — Gap Analysis | Done | Key gaps: OP tx decode, BaseChainSpec construction, ChainConfig mapping | -| Phase 3 — Challenging Dialogue | Skipped | Sufficient info from codebase + reference to write spec without questions | -| Phase 4 — Specification Writing | Done | Full spec written above | -| Phase 5 — Spec Review (Round 1) | Done | User rejected: requested reuse of reth-firehose-tests generic parts | -| Phase 5 — Spec Review (Round 2) | Done | Revised spec: Part 1 = upstream changes to reth-firehose-tests; Part 2 = thin base-firehose-tests layer | -| Dev Feedback — Verify reth fork | Done | Cloned v1.11.4-fh-1 tag and firehose/1.x branch; confirmed Part 1 changes already on branch at commit 06e46c3; no v1.11.4-fh-2 tag yet; Part 1 removed from spec; Step 0 updated to "cut new tag + update Cargo.toml" | -| Step 0 — Update workspace deps to branch | Done | All reth deps changed from tag v1.11.4-fh-1 → branch firehose/1.x; reth-firehose-tests + base-firehose-tests added to workspace; firehose-tracer bumped to 5.1.1 | -| Step 1–7 — Crate implementation | Done | crates/execution/firehose-tests/ created with src/lib.rs, src/prestate.rs, tests/prestate.rs, tests/cases/nop_transfer/ (prestate.json + block.2099.binpb), examples/generate_golden.rs | -| SignatureFields for BaseTxEnvelope | Done | Added to crates/common/consensus/src/reth_compat.rs; deposit txs return (B256::ZERO, B256::ZERO, Bytes::new()) | -| Tests | Done | `cargo test -p base-firehose-tests --test prestate` → `test nop_transfer ... ok` | -| Dev Feedback Round 2 — Rename UPDATE_GOLDENS → GOLDEN_UPDATE | Done | Renamed in tests/prestate.rs and task file spec | -| Dev Feedback Round 2 — Delete examples/generate_golden.rs | Done | File deleted; [[example]] entry removed from Cargo.toml | -| Dev Feedback Round 2 — Clippy clean | Done | Fixed option_if_let_else (reth_compat.rs), redundant_clone (prestate.rs), doc_markdown backticks (firehose README + extras.rs) | diff --git a/.dev/todo/improve-flashblocks-test-framework.md b/.dev/todo/improve-flashblocks-test-framework.md deleted file mode 100644 index 77a38e196a..0000000000 --- a/.dev/todo/improve-flashblocks-test-framework.md +++ /dev/null @@ -1,116 +0,0 @@ -# Improve Flashblocks Test Framework - -mode: feature -state: review -root_git: .worktrees/feature/improve-flashblocks-test-framework -worktree: .worktrees/feature/improve-flashblocks-test-framework -branch: feature/improve-flashblocks-test-framework -target_branch: firehose/0.x - -> **Resume protocol:** read **Dev Feedback** and the **State Tracker** below first, then jump to the -> step marked `Current`. Ensure that you are in the correct worktree and branch according to preamble here. Update current with Developer feedback and update the tracker after every meaningful change. -> Do not mutate completed steps; append a new entry instead. - ---- - -## Initial Description - -In flashblocks tests at `crates/firehose-flashblocks/tests/flashblock_sequence.rs`, improve the test framework so we can simulate when the chain's state is made available. - -### Core Change - -Update `run_flashblock_sequence` to accept input as `TestEvent` enum instead of raw `Flashblock`: - -```rust -enum TestEvent { - Flashblock(Flashblock), - CanonicalBlock(), -} -``` - -This enables tests like: - -```rust -let raw = run_flashblock_sequence(client, vec![base_1, delta_1, canonical_1, base_2, delta_2]).await; -``` - -### Behavior - -- When `run_flashblock_sequence` sees a `TestEvent::Flashblock`, it behaves exactly as today — emits the flashblock through the WebSocket server. -- When `run_flashblock_sequence` sees a `TestEvent::CanonicalBlock`, it modifies `GenesisClient` so that when `.state_by_block_number_or_tag(BlockNumberOrTag::Number(parent_block))` is called, it returns the correct new canonical block state. - -### Goal - -This change enables better coverage where we can simulate when a canonical block on the chain actually happens — testing the cross-block state carry-forward path where the processor bootstraps from a canonical provider (instead of carrying `accumulated_db` forward). - -## Dev Feedback - -2. It seems `GenesisClient` .header_by_number which is used in processor.rs for flashblocks isn't properly implemented, should look into canonical block list to return the correct one. -2. Add a test that check for `base1, canonical 1, canonical 2, base3` sequence which should correctly emit Flash1 Canonical1 Canonical2 Flash3. - -### Applied (2026-05-22) - -**Item 1 — Fix `header_by_number`:** -Added `header_for_block(n)` method to `GenesisClient` that synthesises a header with `number = n` and `timestamp = genesis_timestamp + n * 2` using the genesis header as a template. `header_by_number(n)` now delegates to this helper so the processor's `next_evm_env` correctly computes `block_env.number = parent.number + 1` for any block N. - -**Item 2 — Canonical FIRE BLOCK emission + new test:** -Changed `run_flashblock_sequence` from WS-based to direct sequential processing: -- `TestEvent::Flashblock` → calls `processor.on_flashblock_received` directly (synchronous, preserves ordering). -- `TestEvent::CanonicalBlock(n)` → marks block N available in the provider AND emits a canonical FIRE BLOCK through a dedicated canonical tracer sharing the same `InMemoryBuffer`. - -Two tracers write to the same buffer so output ordering exactly mirrors event processing order. Tests are now plain `#[test]` (no longer `#[tokio::test]`). - -Added `base_canonical_gap_then_base_emits_four_fire_blocks` test verifying `base1 → canonical_block(1) → canonical_block(2) → base3` emits Flash1, Canonical1, Canonical2, Flash3. - -Updated `canonical_block_unblocks_next_base` and `canonical_block_unblocks_non_sequential_gap` to expect the canonical FIRE BLOCK now emitted alongside provider availability. - -All 13 integration tests pass; clippy clean. - -## Spec & Implementation - -### TestEvent Enum - -Added `TestEvent` enum in `framework/mod.rs`: -- `Flashblock(Box)` — boxed to avoid large-variant clippy warning (Flashblock is 688 bytes vs u64's 8 bytes) -- `CanonicalBlock(u64)` — marks a block number as available in the provider - -Constructor helpers: -- `TestEvent::flashblock(fb: Flashblock) -> Self` -- `TestEvent::canonical_block(block_number: u64) -> Self` (const fn) - -### GenesisClient Changes - -Added `Arc>` inner state to `GenesisClient`. `GenesisClientInner` holds `available_blocks: HashSet`. - -Key behavior changes: -- `is_block_available(n)` returns `true` for block 0 (genesis) unconditionally, and for any block N that was marked via `mark_canonical_block_available(N)`. -- `state_by_block_number_or_tag(BlockNumberOrTag::Number(n))` now returns `Err(ProviderError::BlockBodyIndicesNotFound(n))` if block `n` is not available. All other tag variants (Latest, Pending, etc.) still return genesis state. -- `GenesisClient` remains `Clone` because the inner state is wrapped in `Arc>`. - -### run_flashblock_sequence Changes - -Signature changed from `Vec` to `Vec`. Events are pre-processed before the WS server starts: `CanonicalBlock` events call `client.mark_canonical_block_available(n)` synchronously; `Flashblock` events are collected and forwarded to `ws_server_once`. - -This pre-processing approach (apply canonical blocks before starting the subscriber) means provider calls succeed on the first attempt in tests, avoiding the 20-retry timeout. - -### New Tests - -Three new tests total: -1. `canonical_block_unblocks_next_base` — sends base_1, marks block 1 canonical, sends base_2; verifies three events (Flash1, Canonical1, Flash2). -2. `canonical_block_unblocks_non_sequential_gap` — sends canonical_block(1) then base_2; verifies two events (Canonical1, Flash2). -3. `base_canonical_gap_then_base_emits_four_fire_blocks` — sends base1, canonical_block(1), canonical_block(2), base3; verifies four events (Flash1, Canonical1, Canonical2, Flash3). - -Total: 13 integration tests, all passing. - -## State Tracker - -**Last Updated:** 2026-05-22 -**Current Step:** Step 4 — Second dev feedback applied, ready for review -**Status:** Ready for review - -| Step | Status | Notes | -|---|---|---| -| Initial setup | Done | Worktree created at .worktrees/feature/improve-flashblocks-test-framework | -| Implementation | Done | TestEvent enum, GenesisClient inner state, updated tests, 2 new tests, all 12 pass, clippy clean | -| Dev feedback (round 1) | Done | flash_base/flash_delta/canonical_block return TestEvent directly; delta vars renamed to delta_ pattern; all 12 tests pass, clippy clean | -| Dev feedback (round 2) | Done | Fixed header_by_number; canonical FIRE BLOCK emission; WS server removed; new 4-event sequence test; all 13 tests pass, clippy clean | diff --git a/.dev/todo/upgrade-upstream-v0-8-0.md b/.dev/todo/upgrade-upstream-v0-8-0.md deleted file mode 100644 index 85a3430329..0000000000 --- a/.dev/todo/upgrade-upstream-v0-8-0.md +++ /dev/null @@ -1,81 +0,0 @@ -# Upgrade to upstream v0.8.0 - -mode: bug -state: review -root_git: .worktrees/fix/upgrade-upstream-v0-8-0 -worktree: .worktrees/fix/upgrade-upstream-v0-8-0 -branch: fix/upgrade-upstream-v0-8-0 -target_branch: firehose/0.x - -> **Resume protocol:** read **Dev Feedback** and the **State Tracker** below first, then jump to the -> step marked `Current`. Ensure that you are in the correct worktree and branch according to preamble here. Update current with Developer feedback and update the tracker after every meaningful change. -> Do not mutate completed steps; append a new entry instead. - ---- - -## Initial Description - -Upgrade to upstream v0.8.0 version which we are now based on v0.7.6. We have bumped our own reth fork to v1.11.4 using a tag (https://github.com/streamingfast/reth/tree/v1.11.4-fh). Update to use tag when referencing the repository. - -Concretely this means: -1. Update workspace version from v0.7.6 to v0.8.0 in Cargo.toml -2. Update all streamingfast/reth dependencies from `branch = "release/reth-1.x"` to `tag = "v1.11.4-fh"` in Cargo.toml - -## Dev Feedback - -Now that the merge is done, I noticed we had to redefine constants because compilation was not working anymore. - -It seems those were actually renamed, for example I found "0x4200000000000000000000000000000000000019" but under name `BASE_FEE_VAULT` in crates/common/consensus/src/predeploys.rs as well as `L1_FEE_VAULT` and `OPERATOR_FEE_VAULT`. - -## Spec & Implementation - -### Changes Made - -1. Updated `[workspace.package] version` from `"0.7.6"` to `"0.8.0"` in `Cargo.toml`. -2. Replaced all 68 occurrences of `branch = "release/reth-1.x"` with `tag = "v1.11.4-fh"` in `Cargo.toml` (comments referencing the branch name were left as-is since they are informational). -3. Added `v0.8.0-fh` section to `CHANGELOG.sf.md`. - -Note: `cargo check` could not be run in the sandbox (Rust toolchain not installed), but the changes are purely dependency reference updates with no logic changes. - -## State Tracker - -**Last Updated:** 2026-05-13 -**Current Step:** Step 6 — Used proper Predeploys constants for fee vault addresses -**Status:** Ready for review - -### Step 1 — Implementation (Completed) -- Bumped workspace version to v0.8.0 -- Updated all 68 reth dependency references from branch to tag v1.11.4-fh -- Updated CHANGELOG.sf.md -- Committed: `d0d396013` - -### Step 2 — Cargo.lock Updated (Completed) -- Updated 109 reth source entries in Cargo.lock from `branch=release%2Freth-1.x` to `tag=v1.11.4-fh#18dec45a3d48e6fa8f16a51ff0cd30ad5f86f3dd` -- `cargo fetch` failed in sandbox due to libgit2 bug with gitlink-without-.gitmodules in reth tag; Cargo.lock updated directly with verified commit SHA -- Committed: `c669ff610` - -### Step 3 — Updated to v1.11.4-fh-1 (Completed) -- User pushed new tag v1.11.4-fh-1 which removes the problematic gitlink entry -- Updated all 68 references in Cargo.toml from `tag = "v1.11.4-fh"` to `tag = "v1.11.4-fh-1"` -- Updated all 109 Cargo.lock entries to use new SHA `54e9307e50bf85e5190aac2ea8b288394229b1cc` -- Committed: `ee152398b` - -### Step 4 — Merged v0.8.0 tag (Completed) -- Ran `git merge v0.8.0` and resolved all conflicts -- Non-firehose files took `--theirs` (upstream v0.8.0) -- Resolved Cargo.toml conflicts: alloy upgraded to 1.8, kept firehose patch sections -- Committed merge: `6eb9894f9` - -### Step 5 — Post-merge compilation fixes (Completed) -- Removed duplicate `alloy-sol-types` key in `crates/client/metering/Cargo.toml` -- Updated `crates/execution/firehose/Cargo.toml`: replaced old deps with `base-common-evm` (reth feature) and `base-common-consensus` (reth feature) -- Updated `extras.rs`: replaced `base_alloy_evm::OpEvm`/`base_revm::*` with `base_common_evm::{BaseEvm, ...}`, inlined fee recipient addresses -- Updated `evm_config.rs`: replaced `OpEvmFactory`→`BaseEvmFactory`, `OpPrimitives`→`BasePrimitives`, added `OpTransaction: TransactionEnv` where clauses -- `cargo check --workspace` passes (only unrelated `sp1-prover-types` build script failure, pre-existing) -- `cargo test -p base-execution-firehose` passes -- Committed: `db62c390f` - -### Step 6 — Used proper Predeploys constants (Completed) -- Replaced inlined fee vault address constants (`BASE_FEE_RECIPIENT`, `L1_FEE_RECIPIENT`, `OPERATOR_FEE_RECIPIENT`) in `extras.rs` with `Predeploys::BASE_FEE_VAULT`, `Predeploys::L1_FEE_VAULT`, `Predeploys::OPERATOR_FEE_VAULT` from `base_common_consensus` -- `cargo test -p base-execution-firehose` passes -- Committed: `a4b88c30d` diff --git a/CHANGELOG.sf.md b/CHANGELOG.sf.md index bccdecf42b..91662e292a 100644 --- a/CHANGELOG.sf.md +++ b/CHANGELOG.sf.md @@ -1,3 +1,15 @@ +## Unreleased + +### Added + +* Added Firehose tracing-regression coverage for Base transactions: a prestate-driven test plus an + end-to-end `base-system-tests` integration test tracing a B-20 precompile transfer. + +### Fixed + +* Fixed `base-system-tests` failing to start because the in-process sequencer and validator shared + one consensus checkpoint database. + ## v1.1.1-fh-1 * Bumped `streamingfast/reth` dependencies to `tag = "v2.3.0-fh-5"`, which fixes a call/receipt log-count mismatch panic (`N call logs but N+1 receipt logs`) when a native-precompile log (B-20 token event) is emitted at a journal index freed by a reverted opcode `LOG` — seen on Base mainnet block 48387796 (Uniswap V4 revert-based quote). Also pulls in fh-2 (post-tx balance resolver), fh-3 (keccak OOM cap), fh-4 (Docker release CI). diff --git a/CLAUDE.md b/CLAUDE.md index 5e945760f8..ea156a600a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,3 +17,7 @@ All `use` imports must be at the top of the file or the top of a `mod` block. Ne Use structured tracing instead of interpolated strings. Always use key=value fields for any dynamic data: `info!(block = %block_number, "processed block")` rather than `info!("processed block {block_number}")`. Use `%` for Display, `?` for Debug. The message string should be a static description; all variable data goes in fields. Correct: `error!(error = %e, peer = %peer_id, "connection failed")`. Incorrect: `error!("connection to {peer_id} failed: {e}")`. `#[cfg(test)] mod tests { ... }` must always be placed at the end of the file, after all non-test code. + +## Firehose tracing tests + +The tracing-regression framework (capture, invariants, JSON projection, golden) is chain-agnostic and lives in the `firehose-tracer-test` crate of `evm-firehose-tracer-rs`; `base-firehose-tests` re-exports it and adds the Base bindings (`BaseFirehoseCapture::install`, `run_prestate`). Regenerate any golden with `GOLDEN_UPDATE=1`. The Docker-backed `base-system-tests --test firehose_b20` must run with `RUSTFLAGS="-C debug-assertions=off"` (reth's `deferred_trie::wait_cloned` debug-asserts from a Rayon worker). diff --git a/Cargo.lock b/Cargo.lock index 44b2261e63..60abc6ea2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4803,14 +4803,13 @@ dependencies = [ "base-execution-firehose", "eyre", "firehose-tracer", + "firehose-tracer-test", "k256", - "prost 0.14.4", "reth-firehose", "reth-firehose-tests", "reth-primitives-traits", "reth-revm", "revm", - "serde", "serde_json", ] @@ -6348,6 +6347,7 @@ dependencies = [ "base-execution-chainspec", "base-execution-rpc", "base-execution-txpool", + "base-firehose-tests", "base-flashblocks", "base-flashblocks-node", "base-node-core", @@ -6803,7 +6803,7 @@ dependencies = [ "bitflags 2.13.0", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -6823,7 +6823,7 @@ dependencies = [ "bitflags 2.13.0", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -9711,8 +9711,9 @@ checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" [[package]] name = "firehose-tracer" -version = "5.2.2" -source = "git+https://github.com/streamingfast/evm-firehose-tracer-rs.git?tag=v5.2.2#8cbc6f9fc175455ddd285fd3dcfdaa44433a76f0" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f116192c9a01744168c80024324eb8d25fa94917f644a84236da685041eb8b" dependencies = [ "alloy-consensus 2.0.5", "alloy-eips 2.0.5", @@ -9731,6 +9732,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "firehose-tracer-test" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23840e080c438da1ff6feb80b7bfcc6474f330c2e49579651c8d5bb7de0be325" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "base64-simd", + "eyre", + "firehose-tracer", + "hex", + "k256", + "prost 0.14.4", + "prost-reflect", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "fixed-cache" version = "0.1.10" @@ -15582,7 +15603,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools 0.12.1", + "itertools 0.14.0", "log", "multimap", "once_cell", @@ -15602,7 +15623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", - "itertools 0.12.1", + "itertools 0.14.0", "log", "multimap", "petgraph 0.8.3", @@ -15636,7 +15657,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -15649,12 +15670,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "prost-reflect" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1" +dependencies = [ + "prost 0.14.4", + "prost-types 0.14.4", +] + [[package]] name = "prost-types" version = "0.13.5" diff --git a/Cargo.toml b/Cargo.toml index f3113e479b..868704fda8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -638,13 +638,12 @@ ark-bls12-381 = { version = "0.5.0", default-features = false } # Firehose tracer (defined by the streamingfast reth fork, firehose/2.x branch). reth-firehose = { git = "https://github.com/streamingfast/reth.git", tag = "v2.3.0-fh-5" } reth-firehose-tests = { git = "https://github.com/streamingfast/reth.git", tag = "v2.3.0-fh-5" } -firehose-tracer = "5.2.1" +firehose-tracer = "5.3.0" -[patch.crates-io] -# Hotfix: gate the "mismatch between call logs and receipt logs" panic behind the -# FIREHOSE_TRACER_IGNORE_LOG_MISMATCH env var so it logs and skips instead of crashing. -firehose-tracer = { git = "https://github.com/streamingfast/evm-firehose-tracer-rs.git", tag = "v5.2.2" } +# Shared tracing-regression framework (capture / invariants / projection / golden). +firehose-tracer-test = "5.3.0" +[patch.crates-io] # SF fork of alloy-evm that routes system calls (EIP-4788, EIP-2935, etc.) through the # Inspector, so Firehose tracing observes them. Matches the patch in streamingfast/reth # v2.3.0-fh. See https://github.com/alloy-rs/evm/pull/323 for the upstream PR. diff --git a/crates/execution/firehose-tests/Cargo.toml b/crates/execution/firehose-tests/Cargo.toml index 504ee0b37c..b942e837c5 100644 --- a/crates/execution/firehose-tests/Cargo.toml +++ b/crates/execution/firehose-tests/Cargo.toml @@ -22,6 +22,7 @@ base-execution-chainspec.workspace = true reth-firehose.workspace = true reth-revm.workspace = true firehose-tracer.workspace = true +firehose-tracer-test.workspace = true reth-firehose-tests.workspace = true reth-primitives-traits.workspace = true @@ -36,8 +37,6 @@ revm.workspace = true # encoding & errors eyre.workspace = true -prost.workspace = true -serde.workspace = true serde_json.workspace = true [[test]] diff --git a/crates/execution/firehose-tests/README.md b/crates/execution/firehose-tests/README.md index 6b370d35e6..dd8cfa4959 100644 --- a/crates/execution/firehose-tests/README.md +++ b/crates/execution/firehose-tests/README.md @@ -1 +1,20 @@ -Integration test harness for the Firehose tracer on Base / OP Stack chains. +Base-side bindings for the Firehose tracing-regression framework. + +The framework itself — `FirehoseCapture`, `BlockInvariants`, `BlockProjection` / `SymbolTable` / +`VolatilePolicy`, `Golden` — is chain-agnostic and lives in `firehose-tracer-test` (in +`evm-firehose-tracer-rs`). This crate re-exports it and adds the two Base-specific pieces: + +- `run_prestate` — replays a hand-written `prestate.json` (genesis + block context + one signed + transaction) through the tracer with no node involved. Fast, hermetic, but the fixture is + hand-maintained. +- `BaseFirehoseCapture::install` — the reth binding: installs the process-wide buffer-backed tracer + (`reth_firehose::init_tracer_with_buffer`) and hands the buffer to `FirehoseCapture`, so a test + driving a real node captures the `FIRE BLOCK` lines it emits. + +Assert on the captured `Block` with: + +- `BlockInvariants` — content-independent property assertions that never need regenerating. +- `BlockProjection` + `Golden` — a descriptor-driven JSON golden with volatile fields removed by a + `VolatilePolicy` (`none()` for reproducible prestate replays, `live_node()` for system tests). + +Regenerate every golden with `GOLDEN_UPDATE=1`. diff --git a/crates/execution/firehose-tests/src/capture.rs b/crates/execution/firehose-tests/src/capture.rs new file mode 100644 index 0000000000..c0b5582a6a --- /dev/null +++ b/crates/execution/firehose-tests/src/capture.rs @@ -0,0 +1,40 @@ +//! Base's reth binding for the shared [`FirehoseCapture`] framework. +//! +//! The capture / invariants / projection / golden machinery lives in `firehose-tracer-test` and is +//! chain-agnostic. Installing the process-wide tracer is the one part that cannot: the +//! `GLOBAL_TRACER` singleton and the `is_tracer_initialized()` gate the live engine path checks +//! (`crates/execution/engine-tree/src/validator.rs`) both live in `reth-firehose`. This module is +//! the thin adapter that installs reth's buffer-backed tracer and hands the buffer to +//! [`FirehoseCapture`]. + +use firehose_tracer_test::FirehoseCapture; + +/// Base-specific installer for the shared Firehose capture framework. +#[derive(Debug)] +pub struct BaseFirehoseCapture; + +impl BaseFirehoseCapture { + /// Installs the process-wide buffer-backed tracer and returns a capture handle over it. + /// + /// Must be called before any block is validated by the node under test — the traced execution + /// path is gated on `reth_firehose::is_tracer_initialized()`. Panics if a tracer was already + /// installed in this process (the tracer is a process-wide singleton, so a test binary using + /// this holds a single `#[test]`). + /// + /// The fork timestamps only affect how block contents are mapped, not whether a block is + /// emitted; `Some(0)` means "active from genesis" and `None` means "never". + pub fn install( + chain_id: u64, + shanghai_time: Option, + cancun_time: Option, + prague_time: Option, + ) -> FirehoseCapture { + let buffer = reth_firehose::init_tracer_with_buffer( + chain_id, + shanghai_time, + cancun_time, + prague_time, + ); + FirehoseCapture::new(buffer) + } +} diff --git a/crates/execution/firehose-tests/src/lib.rs b/crates/execution/firehose-tests/src/lib.rs index 4b9a61daf9..7def4a0a6e 100644 --- a/crates/execution/firehose-tests/src/lib.rs +++ b/crates/execution/firehose-tests/src/lib.rs @@ -1,6 +1,13 @@ #![doc = include_str!("../README.md")] mod prestate; -pub use prestate::{assert_block_equals_golden, run_prestate}; +pub use prestate::run_prestate; +mod capture; +pub use capture::BaseFirehoseCapture; +// The tracing-regression framework is chain-agnostic and lives in `firehose-tracer-test`. +pub use firehose_tracer_test::{ + BlockDiff, BlockInvariants, BlockProjection, FirehoseCapture, Golden, InvariantConfig, + SymbolTable, Violation, VolatilePolicy, +}; pub use reth_firehose_tests::RunOutcome; diff --git a/crates/execution/firehose-tests/src/prestate.rs b/crates/execution/firehose-tests/src/prestate.rs index ffe6666f3d..7fa77f9fbe 100644 --- a/crates/execution/firehose-tests/src/prestate.rs +++ b/crates/execution/firehose-tests/src/prestate.rs @@ -34,8 +34,8 @@ use revm::database::{CacheDB, EmptyDB}; /// /// `case_folder` must contain `prestate.json` (Base/OP-style genesis + block context + EIP-2718 /// encoded signed transaction). The captured `Block` is the protobuf parsed from the single -/// `FIRE BLOCK` line emitted for the executed block; assert against a `.binpb` golden via -/// [`assert_block_equals_golden`]. +/// `FIRE BLOCK` line emitted for the executed block; assert against it with `BlockInvariants` and a +/// `BlockProjection` golden (see `tests/prestate.rs`). pub fn run_prestate(case_folder: &Path) -> eyre::Result { use alloy_consensus::Block; use base_common_consensus::BaseBlockBody; @@ -148,5 +148,3 @@ fn build_op_header( ..Default::default() } } - -pub use reth_firehose_tests::assert_block_equals_golden; diff --git a/crates/execution/firehose-tests/tests/cases/nop_transfer/block.2099.actual.binpb b/crates/execution/firehose-tests/tests/cases/nop_transfer/block.2099.actual.binpb deleted file mode 100644 index 04f589f9a04a6c891d37969a067903874ea48c99..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1483 zcmd;J6;k-(W?mY-bZXywFBfGKiRrudT8KYuU6=GJIF~7I>E5|z5}O4S4zOyyXW>#X z==A9}QEn|0b7$SBo5^kQ@o?42u>Je`XI~N(F}oW7SV%$kjPHwk$78Ejw{A_}cIJxI zWg%DTDP1MP9~L_Ob+|IwT}tFG-KDHtSLjG=IB$!Gj zF#?TdpT$Tp7B(<4>|kU##7NN6pfzk;t1FSu%v}LMGo#qKSebXOS-_sa zlm)ShVHKmo>I-Ry!({{|xTntyJ!E#qS|-})`t!6mjt+k%9*7#~Dr{mD`8@yHQ>nEb z-@cs7+R;>0dH(-SrL+@_j29TWJ}@%=Vr*v;0Gci}nhKkkE;4a2Dllqc4KX9Ek#2<) z(VG}exUgy!;^yKsGd;QX=)DCD3{nuzMENa#o+lL8HJAu#gQx^+Q-5(QhvyYg8>*L13@| diff --git a/crates/execution/firehose-tests/tests/cases/nop_transfer/block.2099.actual.txt b/crates/execution/firehose-tests/tests/cases/nop_transfer/block.2099.actual.txt deleted file mode 100644 index 9fbbd6412e..0000000000 --- a/crates/execution/firehose-tests/tests/cases/nop_transfer/block.2099.actual.txt +++ /dev/null @@ -1,1512 +0,0 @@ -Block { - hash: [ - 244, - 70, - 55, - 117, - 91, - 165, - 149, - 142, - 239, - 74, - 68, - 35, - 52, - 24, - 151, - 187, - 189, - 56, - 23, - 230, - 133, - 174, - 98, - 242, - 83, - 109, - 2, - 94, - 165, - 189, - 157, - 118, - ], - number: 2099, - size: 704, - header: Some( - BlockHeader { - parent_hash: [ - 48, - 137, - 76, - 139, - 52, - 35, - 133, - 118, - 22, - 71, - 5, - 190, - 45, - 105, - 11, - 56, - 241, - 195, - 122, - 201, - 86, - 191, - 190, - 143, - 155, - 210, - 21, - 20, - 54, - 213, - 95, - 227, - ], - uncle_hash: [ - 29, - 204, - 77, - 232, - 222, - 199, - 93, - 122, - 171, - 133, - 181, - 103, - 182, - 204, - 212, - 26, - 211, - 18, - 69, - 27, - 148, - 138, - 116, - 19, - 240, - 161, - 66, - 253, - 64, - 212, - 147, - 71, - ], - coinbase: [ - 249, - 126, - 24, - 12, - 5, - 14, - 90, - 176, - 114, - 33, - 26, - 210, - 194, - 19, - 235, - 90, - 238, - 77, - 241, - 52, - ], - state_root: [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - ], - transactions_root: [ - 45, - 248, - 49, - 224, - 47, - 175, - 228, - 66, - 158, - 133, - 134, - 224, - 213, - 130, - 65, - 27, - 168, - 155, - 228, - 101, - 66, - 102, - 128, - 255, - 58, - 183, - 169, - 160, - 219, - 6, - 168, - 53, - ], - receipt_root: [ - 86, - 232, - 31, - 23, - 27, - 204, - 85, - 166, - 255, - 131, - 69, - 230, - 146, - 192, - 248, - 110, - 91, - 72, - 224, - 27, - 153, - 108, - 173, - 192, - 1, - 98, - 47, - 181, - 227, - 99, - 180, - 33, - ], - logs_bloom: [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - ], - difficulty: Some( - BigInt { - bytes: [ - 0, - ], - }, - ), - total_difficulty: None, - number: 2099, - gas_limit: 30000000, - gas_used: 0, - timestamp: Some( - Timestamp { - seconds: 1742240844, - nanos: 0, - }, - ), - extra_data: [], - mix_hash: [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - ], - nonce: 0, - hash: [ - 244, - 70, - 55, - 117, - 91, - 165, - 149, - 142, - 239, - 74, - 68, - 35, - 52, - 24, - 151, - 187, - 189, - 56, - 23, - 230, - 133, - 174, - 98, - 242, - 83, - 109, - 2, - 94, - 165, - 189, - 157, - 118, - ], - base_fee_per_gas: Some( - BigInt { - bytes: [ - 7, - ], - }, - ), - withdrawals_root: [ - 86, - 232, - 31, - 23, - 27, - 204, - 85, - 166, - 255, - 131, - 69, - 230, - 146, - 192, - 248, - 110, - 91, - 72, - 224, - 27, - 153, - 108, - 173, - 192, - 1, - 98, - 47, - 181, - 227, - 99, - 180, - 33, - ], - tx_dependency: None, - blob_gas_used: Some( - 0, - ), - excess_blob_gas: Some( - 0, - ), - parent_beacon_root: Some( - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - ], - ), - requests_hash: None, - slot_number: None, - }, - ), - uncles: [], - transaction_traces: [ - TransactionTrace { - to: [ - 167, - 20, - 172, - 151, - 243, - 65, - 135, - 152, - 98, - 11, - 68, - 134, - 249, - 63, - 132, - 150, - 147, - 249, - 146, - 100, - ], - nonce: 0, - gas_price: Some( - BigInt { - bytes: [ - 89, - 104, - 47, - 7, - ], - }, - ), - gas_limit: 21000, - value: Some( - BigInt { - bytes: [ - 13, - 224, - 182, - 179, - 167, - 100, - 0, - 0, - ], - }, - ), - input: [], - v: [], - r: [ - 142, - 102, - 85, - 21, - 150, - 87, - 196, - 63, - 81, - 90, - 146, - 56, - 158, - 25, - 30, - 207, - 74, - 107, - 157, - 31, - 152, - 137, - 57, - 215, - 150, - 254, - 11, - 206, - 232, - 128, - 210, - 128, - ], - s: [ - 38, - 224, - 156, - 207, - 122, - 41, - 70, - 107, - 26, - 103, - 203, - 241, - 57, - 223, - 30, - 1, - 224, - 123, - 146, - 41, - 226, - 20, - 64, - 224, - 31, - 162, - 67, - 98, - 31, - 109, - 105, - 186, - ], - gas_used: 21000, - r#type: TrxTypeDynamicFee, - access_list: [], - max_fee_per_gas: Some( - BigInt { - bytes: [ - 3, - 185, - 172, - 160, - 7, - ], - }, - ), - max_priority_fee_per_gas: Some( - BigInt { - bytes: [ - 89, - 104, - 47, - 0, - ], - }, - ), - index: 0, - hash: [ - 171, - 208, - 102, - 195, - 87, - 28, - 17, - 24, - 11, - 151, - 153, - 85, - 194, - 54, - 204, - 59, - 28, - 91, - 50, - 215, - 231, - 102, - 236, - 65, - 64, - 253, - 24, - 224, - 21, - 48, - 45, - 32, - ], - from: [ - 243, - 159, - 214, - 229, - 26, - 173, - 136, - 246, - 244, - 206, - 106, - 184, - 130, - 114, - 121, - 207, - 255, - 185, - 34, - 102, - ], - return_data: [], - public_key: [], - begin_ordinal: 1, - end_ordinal: 10, - status: Succeeded, - receipt: Some( - TransactionReceipt { - state_root: [], - cumulative_gas_used: 21000, - logs_bloom: [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - ], - logs: [], - blob_gas_used: None, - blob_gas_price: None, - }, - ), - calls: [ - Call { - index: 1, - parent_index: 0, - depth: 0, - call_type: Call, - caller: [ - 243, - 159, - 214, - 229, - 26, - 173, - 136, - 246, - 244, - 206, - 106, - 184, - 130, - 114, - 121, - 207, - 255, - 185, - 34, - 102, - ], - address: [ - 167, - 20, - 172, - 151, - 243, - 65, - 135, - 152, - 98, - 11, - 68, - 134, - 249, - 63, - 132, - 150, - 147, - 249, - 146, - 100, - ], - address_delegates_to: None, - value: Some( - BigInt { - bytes: [ - 13, - 224, - 182, - 179, - 167, - 100, - 0, - 0, - ], - }, - ), - gas_limit: 0, - gas_consumed: 0, - return_data: [], - input: [], - executed_code: false, - suicide: false, - keccak_preimages: {}, - storage_changes: [], - balance_changes: [ - BalanceChange { - address: [ - 243, - 159, - 214, - 229, - 26, - 173, - 136, - 246, - 244, - 206, - 106, - 184, - 130, - 114, - 121, - 207, - 255, - 185, - 34, - 102, - ], - old_value: Some( - BigInt { - bytes: [ - 54, - 53, - 201, - 173, - 197, - 222, - 160, - 0, - 0, - ], - }, - ), - new_value: Some( - BigInt { - bytes: [ - 54, - 53, - 201, - 145, - 31, - 180, - 78, - 73, - 200, - ], - }, - ), - reason: GasBuy, - ordinal: 2, - }, - BalanceChange { - address: [ - 243, - 159, - 214, - 229, - 26, - 173, - 136, - 246, - 244, - 206, - 106, - 184, - 130, - 114, - 121, - 207, - 255, - 185, - 34, - 102, - ], - old_value: Some( - BigInt { - bytes: [ - 54, - 53, - 201, - 145, - 31, - 180, - 78, - 73, - 200, - ], - }, - ), - new_value: Some( - BigInt { - bytes: [ - 54, - 39, - 232, - 218, - 108, - 12, - 234, - 73, - 200, - ], - }, - ), - reason: Transfer, - ordinal: 5, - }, - BalanceChange { - address: [ - 167, - 20, - 172, - 151, - 243, - 65, - 135, - 152, - 98, - 11, - 68, - 134, - 249, - 63, - 132, - 150, - 147, - 249, - 146, - 100, - ], - old_value: None, - new_value: Some( - BigInt { - bytes: [ - 13, - 224, - 182, - 179, - 167, - 100, - 0, - 0, - ], - }, - ), - reason: Transfer, - ordinal: 6, - }, - BalanceChange { - address: [ - 249, - 126, - 24, - 12, - 5, - 14, - 90, - 176, - 114, - 33, - 26, - 210, - 194, - 19, - 235, - 90, - 238, - 77, - 241, - 52, - ], - old_value: Some( - BigInt { - bytes: [ - 58, - 123, - 205, - 225, - 64, - 48, - 190, - 25, - 86, - ], - }, - ), - new_value: Some( - BigInt { - bytes: [ - 58, - 123, - 205, - 253, - 230, - 91, - 13, - 145, - 86, - ], - }, - ), - reason: RewardTransactionFee, - ordinal: 8, - }, - BalanceChange { - address: [ - 66, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 25, - ], - old_value: None, - new_value: Some( - BigInt { - bytes: [ - 2, - 62, - 56, - ], - }, - ), - reason: RewardTransactionFee, - ordinal: 9, - }, - ], - nonce_changes: [ - NonceChange { - address: [ - 243, - 159, - 214, - 229, - 26, - 173, - 136, - 246, - 244, - 206, - 106, - 184, - 130, - 114, - 121, - 207, - 255, - 185, - 34, - 102, - ], - old_value: 0, - new_value: 1, - ordinal: 3, - }, - ], - logs: [], - code_changes: [], - gas_changes: [], - status_failed: false, - status_reverted: false, - failure_reason: "", - state_reverted: false, - begin_ordinal: 4, - end_ordinal: 7, - account_creations: [], - }, - ], - blob_gas: None, - blob_gas_fee_cap: None, - blob_hashes: [], - set_code_authorizations: [], - }, - ], - balance_changes: [], - detail_level: DetaillevelExtended, - code_changes: [], - system_calls: [], - withdrawals: [], - ver: 5, -} \ No newline at end of file diff --git a/crates/execution/firehose-tests/tests/cases/nop_transfer/block.2099.binpb b/crates/execution/firehose-tests/tests/cases/nop_transfer/block.2099.binpb deleted file mode 100644 index 55339627a7a1cb46f6690cfd54a75f9367c7a096..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1579 zcmd;J6;k-(W?mY-bZXywFBfGKiRrudT8KYuU6=GJIF~7I>E5|z5}O4S4zOyyXW>#X z==A9}QEn|0b7$SBo5^kQ@o?42u>Je`XI~N(F}oW7SV%$kjPHwk$78Ejw{A_}cIJxI zWg%DTDP1MP9~L_Ob+|IwT}tFG-KDHtSLjG=IB$!Gj zF#?TdpT$Tp7B(<4>|kU##7NN6pfzk;t1FSu%v}LMGo#qKSebXOS-_sa zlm)ShVHKmo>I-Ry!({{|xTntyJ!E#qS|-})`t!6mjt+k%9*7#~Dr{mD`8@yHQ>nEb z-@cs7+R;>0dH(-SrL+@_%oiAWJ}@%=Vr*v;0Gci}nhKkkE;4a2Dllqc4KX9Ek#2<) z(VG}exUgy!;^yKsGd;QX=)DCD3{nuzMENa#o+lL8HCPB~gQx^+Q-5(QhvyYg8> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("cases").join(name) +fn case_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("cases").join("nop_transfer") } diff --git a/etc/systems/Cargo.toml b/etc/systems/Cargo.toml index 6faca8b1ac..cf1c3cf516 100644 --- a/etc/systems/Cargo.toml +++ b/etc/systems/Cargo.toml @@ -92,6 +92,9 @@ testcontainers = { workspace = true, features = ["blocking", "host-port-exposure # cli clap = { workspace = true, features = ["derive"] } +# firehose +base-firehose-tests.workspace = true + # proof base-zk-client.workspace = true base-proof-rpc.workspace = true diff --git a/etc/systems/src/l2/in_process_consensus.rs b/etc/systems/src/l2/in_process_consensus.rs index b06bc53e17..df48cb94c0 100644 --- a/etc/systems/src/l2/in_process_consensus.rs +++ b/etc/systems/src/l2/in_process_consensus.rs @@ -26,6 +26,7 @@ use base_consensus_rpc::{AdminApiClient, BaseP2PApiClient, RollupNodeApiClient, use base_consensus_sources::BlockSigner; use eyre::{Result, WrapErr}; use jsonrpsee::http_client::HttpClientBuilder; +use tempfile::TempDir; use tokio::task::JoinHandle; use tracing::info; use url::Url; @@ -73,6 +74,9 @@ pub struct InProcessConsensus { p2p_tcp_port: u16, peer_id: String, _handle: JoinHandle<()>, + /// Kept alive so the node's checkpoint database survives for the node's lifetime and is + /// removed on drop. + _checkpoint_dir: TempDir, } impl std::fmt::Debug for InProcessConsensus { @@ -167,6 +171,13 @@ impl InProcessConsensus { max_concurrent_requests: NonZeroUsize::new(1024).expect("nonzero"), }; + // Every consensus node needs its own checkpoint database. The default path is derived from + // the L2 chain id alone (`$HOME/.base//checkpoint.redb`), so the sequencer and + // the validator — which run in the same test process on the same chain — would both try to + // open it and the second one would fail with "Database already open". + let checkpoint_dir = + TempDir::new().wrap_err("Failed to create consensus checkpoint directory")?; + let mut builder = RollupNodeBuilder::new( rollup_config, l1_config, @@ -174,7 +185,8 @@ impl InProcessConsensus { engine_config, net_config, Some(rpc_config), - ); + ) + .with_checkpoint_path(checkpoint_dir.path().join("checkpoint.redb")); if config.mode == NodeMode::Sequencer { builder = builder.with_sequencer_config(SequencerConfig { @@ -207,7 +219,13 @@ impl InProcessConsensus { } } - Ok(Self { rpc_addr, p2p_tcp_port, peer_id, _handle: handle }) + Ok(Self { + rpc_addr, + p2p_tcp_port, + peer_id, + _handle: handle, + _checkpoint_dir: checkpoint_dir, + }) } /// Connects this node to a peer at the given libp2p multiaddr via the `opp2p_connectPeer` RPC. diff --git a/etc/systems/tests/firehose_b20.rs b/etc/systems/tests/firehose_b20.rs new file mode 100644 index 0000000000..35b2279433 --- /dev/null +++ b/etc/systems/tests/firehose_b20.rs @@ -0,0 +1,126 @@ +//! Firehose tracing regression coverage for the B-20 precompile, driven by the real system stack. +//! +//! ## Why this lives here +//! +//! `base-system-tests` boots a genuine L1 (Docker reth + lighthouse) and a genuine in-process L2 +//! (builder + sequencer consensus + batcher + follower client + validator consensus). The follower +//! client executes every block it receives through `engine_newPayload`, which is exactly the path +//! `base_engine_tree`'s payload validator routes into the Firehose tracer. Installing a +//! buffer-backed global tracer before the stack starts therefore captures real `FIRE BLOCK` output +//! for real Base-specific transactions — B-20 precompile calls included — without a separate +//! prestate fixture that has to be hand-maintained. +//! +//! ## Why a dedicated test binary with a single test +//! +//! The Firehose tracer is process-wide and may only be installed once. cargo/nextest give each +//! integration-test binary its own process, so this file holds exactly one test; adding a second +//! one here would panic in `BaseFirehoseCapture::install` or interleave two stacks' blocks into one +//! buffer. +//! +//! ## Layers asserted +//! +//! 1. [`BlockInvariants`] over every traced block — property assertions that never need +//! regenerating (ordinal uniqueness and nesting, call-tree shape, receipt/call log agreement, +//! no no-op state changes). +//! 2. A narrow [`BlockProjection`] golden over the B-20 transfer transaction alone, with volatile +//! fields excluded by construction. Regenerate with `GOLDEN_UPDATE=1`. + +mod common; + +use std::{path::PathBuf, time::Duration}; + +use alloy_primitives::{B256, U256}; +use alloy_signer_local::PrivateKeySigner; +use base_common_precompiles::{ActivationFeature, B20FactoryStorage, B20Variant, IB20}; +use base_firehose_tests::{ + BaseFirehoseCapture, BlockInvariants, BlockProjection, Golden, SymbolTable, VolatilePolicy, +}; +use base_system_tests::{ANVIL_ACCOUNT_5, ANVIL_ACCOUNT_6, B20PrecompileClient}; +use eyre::{Result, WrapErr}; + +/// Initial supply minted to the admin when the token is created. +const INITIAL_SUPPLY: u64 = 1_000_000_000; +/// Amount moved by the traced `transfer` call. +const TRANSFER_AMOUNT: u64 = 100_000_000; +/// `CREATE2` salt for the traced token; fixed so the token address is reproducible. +const TOKEN_SALT: u8 = 0x42; +/// How long to wait for the follower node to validate (and therefore trace) the target block. +const TRACE_TIMEOUT: Duration = Duration::from_secs(60); + +#[tokio::test(flavor = "multi_thread")] +async fn b20_transfer_is_traced() -> Result<()> { + // Must happen before the stack starts producing blocks: the traced execution path is gated on + // `reth_firehose::is_tracer_initialized()` at payload-validation time. + let capture = BaseFirehoseCapture::install(common::L2_CHAIN_ID, Some(0), Some(0), None); + + let (_system, provider) = common::start_beryl_system().await?; + let admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key) + .wrap_err("Failed to parse admin private key")?; + let recipient = ANVIL_ACCOUNT_6.address; + common::wait_for_balance(&provider, admin.address()).await?; + + let b20 = B20PrecompileClient::new(&provider, &admin, common::L2_CHAIN_ID) + .with_receipt_timeout(common::TX_RECEIPT_TIMEOUT); + b20.activate_feature(ActivationFeature::B20Asset.id()).await?; + + let salt = B256::repeat_byte(TOKEN_SALT); + let params = B20PrecompileClient::token_params( + "Firehose B20", + "FHB20", + admin.address(), + U256::from(INITIAL_SUPPLY), + admin.address(), + ); + let token = b20.create_token(B20Variant::Asset, params, salt).await?; + b20.wait_for_token_code(token, common::TX_RECEIPT_TIMEOUT, common::BLOCK_POLL_INTERVAL).await?; + + let receipt = b20 + .send_call_receipt( + token, + IB20::transferCall { to: recipient, amount: U256::from(TRANSFER_AMOUNT) }, + "B-20 transfer", + ) + .await?; + let transfer_block = receipt + .inner + .block_number + .ok_or_else(|| eyre::eyre!("B-20 transfer receipt carries no block number"))?; + let transfer_hash = receipt.inner.transaction_hash; + + let block = capture.wait_for_block(transfer_block, TRACE_TIMEOUT).await?; + + // Layer 1: property assertions over every block the follower traced so far. The most recently + // written line may still be in flight, so the tail block is covered by the explicit assert + // below rather than by this sweep. + let traced = capture.traced_block_numbers(); + for number in traced.iter().rev().skip(1).rev() { + let traced_block = capture.block(*number)?; + BlockInvariants::assert(&traced_block) + .wrap_err_with(|| format!("invariants failed on traced block #{number}"))?; + } + BlockInvariants::assert(&block)?; + + // Layer 2: narrow projection golden over the B-20 transfer transaction only. The rest of the + // block (the L1-info deposit) carries the L1 head and is therefore not reproducible. + let symbols = SymbolTable::new() + .with(admin.address(), "admin") + .with(recipient, "recipient") + .with(token, "b20-token") + .with(B20FactoryStorage::ADDRESS, "b20-factory"); + + // A live sequencer moves the base fee, block number and wall clock between runs, so drop what + // the tracer cannot reproduce (`VolatilePolicy::live_node()`): hashes, timestamps, gas, absolute + // ordinals; balance/nonce changes are deltaized; storage values are kept verbatim. + let projection = BlockProjection::new() + .with_symbols(symbols) + .with_policy(VolatilePolicy::live_node()) + .transaction(&block, transfer_hash.as_slice())?; + Golden::is_json_equal(&projection, &golden_path("b20_transfer.json"))?.assert_equal(); + + Ok(()) +} + +/// Resolves `name` inside this crate's golden directory. +fn golden_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("goldens").join(name) +} diff --git a/etc/systems/tests/goldens/b20_transfer.json b/etc/systems/tests/goldens/b20_transfer.json new file mode 100644 index 0000000000..1cecfc3541 --- /dev/null +++ b/etc/systems/tests/goldens/b20_transfer.json @@ -0,0 +1,81 @@ +{ + "calls": [ + { + "address": "@b20-token", + "balance_changes": [ + { + "address": "@admin", + "reason": "REASON_GAS_BUY" + }, + { + "address": "@admin", + "reason": "REASON_GAS_REFUND" + }, + { + "address": "0x4200000000000000000000000000000000000019", + "reason": "REASON_REWARD_TRANSACTION_FEE" + }, + { + "address": "0x420000000000000000000000000000000000001a", + "reason": "REASON_REWARD_TRANSACTION_FEE" + } + ], + "call_type": "CALL", + "caller": "@admin", + "index": 1, + "input": "0xa9059cbb000000000000000000000000976ea74026e726554db657fa54763abd0c3a0aa90000000000000000000000000000000000000000000000000000000005f5e100", + "logs": [ + { + "address": "@b20-token", + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000009965507d1a55bcc2695c58ba16fb37d819b0a4dc", + "0x000000000000000000000000976ea74026e726554db657fa54763abd0c3a0aa9" + ] + } + ], + "nonce_changes": [ + { + "address": "@admin", + "delta": "+1" + } + ], + "return_data": "0x0000000000000000000000000000000000000000000000000000000000000001", + "storage_changes": [ + { + "address": "@b20-token", + "key": "0xa14eb246d421839fa53a0bb3113dd730a7c59dd0d6c9f6ee0274cb69fde27d03", + "new_value": "0x0000000000000000000000000000000000000000000000000000000035a4e900", + "old_value": "0x000000000000000000000000000000000000000000000000000000003b9aca00" + }, + { + "address": "@b20-token", + "key": "0xfb9e2669d4ad56e6cd9cd11cf03d0045e632f60ef6a978d4f18dad7d94c3c6a0", + "new_value": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "old_value": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ], + "from": "@admin", + "index": 1, + "input": "0xa9059cbb000000000000000000000000976ea74026e726554db657fa54763abd0c3a0aa90000000000000000000000000000000000000000000000000000000005f5e100", + "receipt": { + "logs": [ + { + "address": "@b20-token", + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000009965507d1a55bcc2695c58ba16fb37d819b0a4dc", + "0x000000000000000000000000976ea74026e726554db657fa54763abd0c3a0aa9" + ] + } + ] + }, + "return_data": "0x0000000000000000000000000000000000000000000000000000000000000001", + "status": "SUCCEEDED", + "to": "@b20-token", + "type": "TRX_TYPE_DYNAMIC_FEE" +}