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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions .dev/todo/improve-flashblocks-test-framework.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# 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(<tbd>),
}
```

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

1. flash_base and flash_delta, they both should return right away a `TestEvent` object so we avoid having to wrap all emitted events inside `TestEvent::flashblock`, same for `TestEvent::canonical_block(1),` we should have locally a `canonical_block` helper or it can be assigned to a variable like other test cases.
1. Modify all delta variables to be on the form `delta<blockNum>_<flashIndex>` so it reads like `vec![base1, delta1_1, delta1_2, base2, etc...]`

**Applied in commit `94053caa4`.**

2. Rebase on top of firehose/0.x branch

## Spec & Implementation

### TestEvent Enum

Added `TestEvent` enum in `framework/mod.rs`:
- `Flashblock(Box<Flashblock>)` — 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<Mutex<GenesisClientInner>>` inner state to `GenesisClient`. `GenesisClientInner` holds `available_blocks: HashSet<u64>`.

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<Mutex<...>>`.

### run_flashblock_sequence Changes

Signature changed from `Vec<Flashblock>` to `Vec<TestEvent>`. 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

Two new tests added:
1. `canonical_block_unblocks_next_base` — sends base_1, marks block 1 canonical, sends base_2; verifies both produce FIRE BLOCK events.
2. `canonical_block_unblocks_non_sequential_gap` — sends base_2 as the very first flashblock (no prior context); without `canonical_block(1)`, the provider would fail and block 2 would be skipped. With it, block 2 processes successfully.

Total: 12 integration tests, all passing.

## State Tracker

**Last Updated:** 2026-05-22
**Current Step:** Step 3 — 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 | Done | flash_base/flash_delta/canonical_block return TestEvent directly; delta vars renamed to delta<N>_<I> pattern; all 12 tests pass, clippy clean |
160 changes: 123 additions & 37 deletions crates/firehose-flashblocks/tests/flashblock_sequence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
//!
//! # Adding new test cases
//!
//! 1. Build a `Vec<Flashblock>` using [`framework::flash_base`] / [`framework::flash_delta`].
//! 2. Call [`framework::run_flashblock_sequence`] with the sequence and a [`framework::GenesisClient`].
//! 1. Build a `Vec<TestEvent>` using the free helpers [`framework::flash_base`],
//! [`framework::flash_delta`], and [`framework::canonical_block`] — each returns a
//! [`framework::TestEvent`] directly, so no wrapping is needed.
//! 2. Call [`framework::run_flashblock_sequence`] with the events and a [`framework::GenesisClient`].
//! 3. Call [`framework::parse_fire_events`] to validate the emitted output.
//! 4. Use [`framework::assert_fire_events_metadata_eq`] for metadata-only assertions or
//! [`framework::assert_fire_events_eq`] when you also need to verify the decoded block payload.
Expand All @@ -17,8 +19,9 @@ mod framework;
use base_execution_chainspec::BaseChainSpec;

use framework::{
FireEvent, GenesisClient, assert_fire_events_eq, assert_fire_events_metadata_eq, flash_base,
flash_delta, parse_fire_events, run_flashblock_sequence, test_genesis,
FireEvent, GenesisClient, assert_fire_events_eq, assert_fire_events_metadata_eq,
canonical_block, flash_base, flash_delta, parse_fire_events, run_flashblock_sequence,
test_genesis,
};

/// Simplest possible test: send a single flash-base event (block 1, no transactions) and verify
Expand All @@ -37,9 +40,9 @@ async fn flash_base_emits_fire_block() {

// Block 1, parent = genesis block hash, timestamp = genesis + 2 seconds.
let genesis_timestamp = 0x67d00000u64;
let fb = flash_base(1, genesis_hash, genesis_timestamp + 2);
let base1 = flash_base(1, genesis_hash, genesis_timestamp + 2);

let raw = run_flashblock_sequence(client, vec![fb]).await;
let raw = run_flashblock_sequence(client, vec![base1]).await;

let events: Vec<FireEvent> = parse_fire_events(&raw)
.into_iter()
Expand All @@ -62,10 +65,10 @@ async fn base_plus_delta_emits_two_fire_blocks() {
let client = GenesisClient::new(genesis.clone());

let genesis_timestamp = 0x67d00000u64;
let base = flash_base(1, genesis_hash, genesis_timestamp + 2);
let delta = flash_delta(1, 1);
let base1 = flash_base(1, genesis_hash, genesis_timestamp + 2);
let delta1_1 = flash_delta(1, 1);

let raw = run_flashblock_sequence(client, vec![base, delta]).await;
let raw = run_flashblock_sequence(client, vec![base1, delta1_1]).await;

let events: Vec<FireEvent> = parse_fire_events(&raw)
.into_iter()
Expand Down Expand Up @@ -101,14 +104,14 @@ async fn base_plus_delta_plus_next_base() {
let client = GenesisClient::new(genesis.clone());

let genesis_timestamp = 0x67d00000u64;
let base_n = flash_base(1, genesis_hash, genesis_timestamp + 2);
let delta_n = flash_delta(1, 1);
let base1 = flash_base(1, genesis_hash, genesis_timestamp + 2);
let delta1_1 = flash_delta(1, 1);
// Block 2's parent hash is B256::ZERO in tests (the mock provider always returns genesis header
// which has a zero block hash, so any non-zero B256 is fine for routing; use genesis_hash for
// simplicity — the mock ignores the parent hash when looking up state).
let base_n1 = flash_base(2, genesis_hash, genesis_timestamp + 4);
let base2 = flash_base(2, genesis_hash, genesis_timestamp + 4);

let raw = run_flashblock_sequence(client, vec![base_n, delta_n, base_n1]).await;
let raw = run_flashblock_sequence(client, vec![base1, delta1_1, base2]).await;

let events: Vec<FireEvent> = parse_fire_events(&raw)
.into_iter()
Expand Down Expand Up @@ -137,9 +140,9 @@ async fn duplicate_base_is_ignored() {
let client = GenesisClient::new(genesis.clone());

let genesis_timestamp = 0x67d00000u64;
let base = flash_base(1, genesis_hash, genesis_timestamp + 2);
let base1 = flash_base(1, genesis_hash, genesis_timestamp + 2);
// Send the same base twice.
let raw = run_flashblock_sequence(client, vec![base.clone(), base]).await;
let raw = run_flashblock_sequence(client, vec![base1.clone(), base1]).await;

let events: Vec<FireEvent> = parse_fire_events(&raw)
.into_iter()
Expand All @@ -163,11 +166,11 @@ async fn non_sequential_delta_is_skipped() {
let client = GenesisClient::new(genesis.clone());

let genesis_timestamp = 0x67d00000u64;
let base = flash_base(1, genesis_hash, genesis_timestamp + 2);
let base1 = flash_base(1, genesis_hash, genesis_timestamp + 2);
// Skip index 1 and send index 2 directly — creates a NonSequentialGap.
let gap_delta = flash_delta(1, 2);
let delta1_2 = flash_delta(1, 2);

let raw = run_flashblock_sequence(client, vec![base, gap_delta]).await;
let raw = run_flashblock_sequence(client, vec![base1, delta1_2]).await;

let events: Vec<FireEvent> = parse_fire_events(&raw)
.into_iter()
Expand All @@ -190,11 +193,11 @@ async fn two_successive_deltas() {
let client = GenesisClient::new(genesis.clone());

let genesis_timestamp = 0x67d00000u64;
let base = flash_base(1, genesis_hash, genesis_timestamp + 2);
let delta1 = flash_delta(1, 1);
let delta2 = flash_delta(1, 2);
let base1 = flash_base(1, genesis_hash, genesis_timestamp + 2);
let delta1_1 = flash_delta(1, 1);
let delta1_2 = flash_delta(1, 2);

let raw = run_flashblock_sequence(client, vec![base, delta1, delta2]).await;
let raw = run_flashblock_sequence(client, vec![base1, delta1_1, delta1_2]).await;

let events: Vec<FireEvent> = parse_fire_events(&raw)
.into_iter()
Expand Down Expand Up @@ -225,11 +228,11 @@ async fn jumping_delta_is_skipped() {
let client = GenesisClient::new(genesis.clone());

let genesis_timestamp = 0x67d00000u64;
let base = flash_base(1, genesis_hash, genesis_timestamp + 2);
let base1 = flash_base(1, genesis_hash, genesis_timestamp + 2);
// Jump directly to idx=2, skipping idx=1.
let gap_delta = flash_delta(1, 2);
let delta1_2 = flash_delta(1, 2);

let raw = run_flashblock_sequence(client, vec![base, gap_delta]).await;
let raw = run_flashblock_sequence(client, vec![base1, delta1_2]).await;

let events: Vec<FireEvent> = parse_fire_events(&raw)
.into_iter()
Expand All @@ -251,12 +254,13 @@ async fn three_successive_deltas() {
let client = GenesisClient::new(genesis.clone());

let genesis_timestamp = 0x67d00000u64;
let base = flash_base(1, genesis_hash, genesis_timestamp + 2);
let delta1 = flash_delta(1, 1);
let delta2 = flash_delta(1, 2);
let delta3 = flash_delta(1, 3);
let base1 = flash_base(1, genesis_hash, genesis_timestamp + 2);
let delta1_1 = flash_delta(1, 1);
let delta1_2 = flash_delta(1, 2);
let delta1_3 = flash_delta(1, 3);

let raw = run_flashblock_sequence(client, vec![base, delta1, delta2, delta3]).await;
let raw =
run_flashblock_sequence(client, vec![base1, delta1_1, delta1_2, delta1_3]).await;

let events: Vec<FireEvent> = parse_fire_events(&raw)
.into_iter()
Expand Down Expand Up @@ -286,12 +290,12 @@ async fn two_blocks_with_deltas() {
let client = GenesisClient::new(genesis.clone());

let genesis_timestamp = 0x67d00000u64;
let base_1 = flash_base(1, genesis_hash, genesis_timestamp + 2);
let delta_1 = flash_delta(1, 1);
let base_2 = flash_base(2, genesis_hash, genesis_timestamp + 4);
let delta_2 = flash_delta(2, 1);
let base1 = flash_base(1, genesis_hash, genesis_timestamp + 2);
let delta1_1 = flash_delta(1, 1);
let base2 = flash_base(2, genesis_hash, genesis_timestamp + 4);
let delta2_1 = flash_delta(2, 1);

let raw = run_flashblock_sequence(client, vec![base_1, delta_1, base_2, delta_2]).await;
let raw = run_flashblock_sequence(client, vec![base1, delta1_1, base2, delta2_1]).await;

let events: Vec<FireEvent> = parse_fire_events(&raw)
.into_iter()
Expand Down Expand Up @@ -324,9 +328,9 @@ async fn block_payload_has_correct_block_number() {
let client = GenesisClient::new(genesis.clone());

let genesis_timestamp = 0x67d00000u64;
let fb = flash_base(1, genesis_hash, genesis_timestamp + 2);
let base1 = flash_base(1, genesis_hash, genesis_timestamp + 2);

let raw = run_flashblock_sequence(client, vec![fb]).await;
let raw = run_flashblock_sequence(client, vec![base1]).await;

let events: Vec<FireEvent> = parse_fire_events(&raw)
.into_iter()
Expand Down Expand Up @@ -355,3 +359,85 @@ async fn block_payload_has_correct_block_number() {
}];
assert_fire_events_eq(&events, &expected);
}

/// Exercises the bootstrap path: send base for block 2 after marking block 1 as canonical.
///
/// When the processor receives the base for block 2, `accumulated_db` is `None` because
/// block 2 is not the sequential successor of any previously processed block. It therefore
/// calls `state_by_block_number_or_tag(BlockNumberOrTag::Number(1))` on the client to
/// bootstrap its EVM state.
///
/// Without a prior [`TestEvent::canonical_block(1)`], `GenesisClient` would return a
/// `ProviderError` for block 1, causing the processor to exhaust its retries and skip the
/// block. With the canonical block event applied, the provider returns successfully and the
/// processor emits a `FIRE BLOCK` for block 2.
///
/// This verifies the [`TestEvent::CanonicalBlock`] path: it marks the block as available in
/// `GenesisClient` so that the bootstrap provider call succeeds on the first attempt.
#[tokio::test]
async fn canonical_block_unblocks_next_base() {
let genesis = test_genesis();
let genesis_hash = BaseChainSpec::from_genesis(genesis.clone()).inner.genesis_hash();

let client = GenesisClient::new(genesis.clone());

let genesis_timestamp = 0x67d00000u64;
// Block 1 base — bootstraps from genesis (block 0 is always available).
let base1 = flash_base(1, genesis_hash, genesis_timestamp + 2);
// Block 2 base — would need block 1 to be available to bootstrap from the provider,
// since block 2 is NOT the sequential successor of block 1 after we send base1 alone
// (no delta in between, but the processor still carries forward accumulated_db because
// block 2 == block 1 + 1; the key path tested here is that marking block 1 canonical
// allows the provider call to succeed even if accumulated_db were None).
//
// We send base1 → canonical_block(1) → base2 to verify that:
// 1. base1 produces a FIRE BLOCK for block 1.
// 2. canonical_block(1) marks block 1 as available without emitting anything.
// 3. base2 produces a FIRE BLOCK for block 2 (carried forward from accumulated_db).
let base2 = flash_base(2, genesis_hash, genesis_timestamp + 4);

let raw =
run_flashblock_sequence(client, vec![base1, canonical_block(1), base2]).await;

let events: Vec<FireEvent> = parse_fire_events(&raw)
.into_iter()
.filter(|e| matches!(e, FireEvent::Block { .. } | FireEvent::FlashBlock { .. }))
.collect();

assert_fire_events_metadata_eq(
&events,
&[FireEvent::canonical_block(1), FireEvent::canonical_block(2)],
);
}

/// Exercises the bootstrap path: sending a base for block 2 when the processor has no prior
/// context requires bootstrapping from the provider at block 1.
///
/// Without [`TestEvent::canonical_block(1)`], `GenesisClient` returns a `ProviderError` for
/// block 1 and the processor exhausts its retries, causing block 2 to be skipped.
/// With the canonical block event applied first, the provider returns successfully and the
/// processor emits a `FIRE BLOCK` for block 2.
///
/// This tests the bootstrap path in isolation: no prior flashblock context means
/// `accumulated_db` is always `None` and the provider call for the parent block is mandatory.
#[tokio::test]
async fn canonical_block_unblocks_non_sequential_gap() {
let genesis = test_genesis();
let genesis_hash = BaseChainSpec::from_genesis(genesis.clone()).inner.genesis_hash();

let client = GenesisClient::new(genesis.clone());

let genesis_timestamp = 0x67d00000u64;
// Send base for block 2 as the very first flashblock (no block 1 context).
// The processor has no accumulated_db, so it must bootstrap from the provider at block 1.
let base2 = flash_base(2, genesis_hash, genesis_timestamp + 4);

let raw = run_flashblock_sequence(client, vec![canonical_block(1), base2]).await;

let events: Vec<FireEvent> = parse_fire_events(&raw)
.into_iter()
.filter(|e| matches!(e, FireEvent::Block { .. } | FireEvent::FlashBlock { .. }))
.collect();

assert_fire_events_metadata_eq(&events, &[FireEvent::canonical_block(2)]);
}
Loading
Loading