From 9ba9443b8f10e6b5071e50a1d86e512887bd3335 Mon Sep 17 00:00:00 2001 From: brendontan03 Date: Thu, 26 Mar 2026 17:53:12 +0800 Subject: [PATCH 1/3] initial e2e and cache tests --- Cargo.lock | 1 + crates/flashblocks/src/cache/mod.rs | 400 +++++++++++++++++++++++++ crates/flashblocks/src/test_utils.rs | 66 +++- crates/tests/Cargo.toml | 1 + crates/tests/e2e-tests/main.rs | 2 +- crates/tests/flashblocks-tests/main.rs | 209 +++++++++++++ crates/tests/operations/eth_rpc.rs | 41 +++ crates/tests/operations/utils.rs | 29 ++ 8 files changed, 746 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3b9785e8..e5ed67c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14161,6 +14161,7 @@ dependencies = [ name = "xlayer-e2e-test" version = "0.1.0" dependencies = [ + "alloy-eips", "alloy-network", "alloy-primitives", "alloy-provider", diff --git a/crates/flashblocks/src/cache/mod.rs b/crates/flashblocks/src/cache/mod.rs index 8463e7a2..7c684224 100644 --- a/crates/flashblocks/src/cache/mod.rs +++ b/crates/flashblocks/src/cache/mod.rs @@ -560,3 +560,403 @@ impl FlashblockStateCacheInner { self.pending_sequence_rx.clone() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{make_executed_block, make_pending_sequence, make_pending_sequence_with_txs}; + use alloy_consensus::BlockHeader; + use reth_optimism_primitives::OpPrimitives; + + type TestCache = FlashblockStateCache; + type TestInner = FlashblockStateCacheInner; + + // ── Defaults ────────────────────────────────────────────────── + + #[test] + fn test_new_defaults() { + let inner = TestInner::new(); + assert_eq!(inner.confirm_height, 0); + assert_eq!(inner.canon_info, (0, B256::ZERO)); + assert!(inner.pending_cache.is_none()); + } + + // ── handle_pending_sequence ─────────────────────────────────── + + #[test] + fn test_handle_pending_first_at_expected_height() { + let mut inner = TestInner::new(); + // confirm_height=0, expected_height=1 + let seq = make_pending_sequence(1, B256::ZERO); + inner.handle_pending_sequence(seq).unwrap(); + + assert!(inner.pending_cache.is_some()); + assert_eq!(inner.pending_cache.as_ref().unwrap().get_height(), 1); + assert_eq!(inner.confirm_height, 0); + } + + #[test] + fn test_handle_pending_replace_same_height() { + let mut inner = TestInner::new(); + let seq1 = make_pending_sequence(1, B256::ZERO); + let seq1_hash = seq1.block_hash; + inner.handle_pending_sequence(seq1).unwrap(); + + // Replace at same height with different parent_hash to produce a different block + let seq2 = make_pending_sequence(1, B256::repeat_byte(0xAA)); + let seq2_hash = seq2.block_hash; + inner.handle_pending_sequence(seq2).unwrap(); + + assert!(inner.pending_cache.is_some()); + // Block hash should have changed due to different parent hash + assert_ne!(seq1_hash, seq2_hash); + assert_eq!(inner.pending_cache.as_ref().unwrap().block_hash, seq2_hash); + // confirm_height unchanged + assert_eq!(inner.confirm_height, 0); + } + + #[test] + fn test_handle_pending_advance_commits_to_confirm() { + let mut inner = TestInner::new(); + // Insert pending at height 1 + let seq1 = make_pending_sequence(1, B256::ZERO); + inner.handle_pending_sequence(seq1).unwrap(); + + // Advance to height 2 — seq at height 1 should be committed to confirm + let seq2 = make_pending_sequence(2, B256::repeat_byte(0xBB)); + inner.handle_pending_sequence(seq2).unwrap(); + + // confirm_height advanced to 1 (old pending committed) + assert_eq!(inner.confirm_height, 1); + // Pending is now at height 2 + assert_eq!(inner.pending_cache.as_ref().unwrap().get_height(), 2); + // Block at height 1 should be in confirm cache + assert!(inner.confirm_cache.get_block_by_number(1).is_some()); + } + + #[test] + fn test_handle_pending_advance_without_existing_errors() { + let mut inner = TestInner::new(); + // No pending exists, try to advance to expected_height + 1 = 2 + let seq = make_pending_sequence(2, B256::ZERO); + let result = inner.handle_pending_sequence(seq); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("trying to advance pending tip but no current pending")); + } + + #[test] + fn test_handle_pending_wrong_height_errors() { + let mut inner = TestInner::new(); + // confirm_height=0, expected=1, so heights 3+ should fail + let seq = make_pending_sequence(5, B256::ZERO); + let result = inner.handle_pending_sequence(seq); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("not next consecutive pending height block")); + } + + // ── handle_confirmed_block ──────────────────────────────────── + + #[test] + fn test_handle_confirmed_block_non_consecutive_errors() { + let mut inner = TestInner::new(); + // confirm_height=0, block_number=5 should fail + let executed = make_executed_block(5, B256::ZERO); + let result = inner.handle_confirmed_block(5, executed, Arc::new(vec![])); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("not next consecutive target confirm height block")); + } + + // ── handle_canonical_block ──────────────────────────────────── + + #[test] + fn test_handle_canonical_evicts_confirm() { + let mut inner = TestInner::new(); + // Build up confirm cache with blocks 1-5 via pending sequence advances + for i in 1..=5 { + let seq = make_pending_sequence(i, B256::repeat_byte(i as u8)); + inner.handle_pending_sequence(seq).unwrap(); + } + // After inserting 1-5 sequentially: blocks 1-4 in confirm, 5 is pending + assert_eq!(inner.confirm_height, 4); + assert_eq!(inner.pending_cache.as_ref().unwrap().get_height(), 5); + + // Canonical at height 2 should flush blocks 1-2 from confirm + let flushed = + inner.handle_canonical_block((2, B256::repeat_byte(0xFF)), false); + assert!(!flushed); // No full flush (pending at 5 > canon 2) + assert_eq!(inner.canon_info.0, 2); + // Blocks 1-2 should be evicted, 3-4 should remain + assert!(inner.confirm_cache.get_block_by_number(1).is_none()); + assert!(inner.confirm_cache.get_block_by_number(2).is_none()); + assert!(inner.confirm_cache.get_block_by_number(3).is_some()); + assert!(inner.confirm_cache.get_block_by_number(4).is_some()); + } + + #[test] + fn test_handle_canonical_flush_on_pending_stale() { + let mut inner = TestInner::new(); + // Insert pending at height 1 + let seq = make_pending_sequence(1, B256::ZERO); + inner.handle_pending_sequence(seq).unwrap(); + + // Canonical catches up to height 1 — pending is stale + let flushed = + inner.handle_canonical_block((1, B256::repeat_byte(0xCC)), false); + assert!(flushed); + assert!(inner.pending_cache.is_none()); + assert_eq!(inner.confirm_height, 1); // max(0, 1) + } + + #[test] + fn test_handle_canonical_flush_on_reorg() { + let mut inner = TestInner::new(); + let seq = make_pending_sequence(1, B256::ZERO); + inner.handle_pending_sequence(seq).unwrap(); + + // Even if pending is ahead, reorg flag forces full flush + let flushed = + inner.handle_canonical_block((0, B256::repeat_byte(0xDD)), true); + assert!(flushed); + assert!(inner.pending_cache.is_none()); + } + + // ── Block/tx routing ────────────────────────────────────────── + + #[test] + fn test_get_block_by_number_pending_priority() { + let cache = TestCache::new(); + // Insert pending at height 1, then advance to height 2 so height 1 is in confirm + cache.handle_pending_sequence(make_pending_sequence(1, B256::ZERO)).unwrap(); + cache + .handle_pending_sequence(make_pending_sequence(2, B256::repeat_byte(0x01))) + .unwrap(); + + // Now replace pending at height 2 with a new sequence (different parent) + let new_pending = make_pending_sequence(2, B256::repeat_byte(0x02)); + let new_pending_hash = new_pending.block_hash; + cache.handle_pending_sequence(new_pending).unwrap(); + + // Query height 2 should return the pending block (not something from confirm) + let result = cache.get_block_by_number(2).unwrap(); + assert_eq!(result.block.hash(), new_pending_hash); + } + + #[test] + fn test_get_block_by_number_falls_to_confirm() { + let cache = TestCache::new(); + // Insert pending at 1, advance to 2 — block 1 is in confirm + let seq1 = make_pending_sequence(1, B256::ZERO); + let seq1_hash = seq1.block_hash; + cache.handle_pending_sequence(seq1).unwrap(); + cache + .handle_pending_sequence(make_pending_sequence(2, B256::repeat_byte(0x01))) + .unwrap(); + + // Query height 1 — pending is at 2, so should fall through to confirm + let result = cache.get_block_by_number(1).unwrap(); + assert_eq!(result.block.hash(), seq1_hash); + } + + #[test] + fn test_get_block_by_hash_pending_priority() { + let cache = TestCache::new(); + let seq = make_pending_sequence(1, B256::ZERO); + let pending_hash = seq.block_hash; + cache.handle_pending_sequence(seq).unwrap(); + + let result = cache.get_block_by_hash(&pending_hash).unwrap(); + assert_eq!(result.block.number(), 1); + } + + #[test] + fn test_get_rpc_block_latest_returns_confirmed() { + let cache = TestCache::new(); + // Build: pending at 1, advance to 2 → confirm_height=1 + let seq1 = make_pending_sequence(1, B256::ZERO); + let seq1_hash = seq1.block_hash; + cache.handle_pending_sequence(seq1).unwrap(); + cache + .handle_pending_sequence(make_pending_sequence(2, B256::repeat_byte(0x01))) + .unwrap(); + + let result = cache.get_rpc_block(BlockNumberOrTag::Latest).unwrap(); + assert_eq!(result.block.hash(), seq1_hash); + assert_eq!(result.block.number(), 1); + } + + #[test] + fn test_get_rpc_block_pending_returns_pending() { + let cache = TestCache::new(); + let seq = make_pending_sequence(1, B256::ZERO); + let pending_hash = seq.block_hash; + cache.handle_pending_sequence(seq).unwrap(); + + let result = cache.get_rpc_block(BlockNumberOrTag::Pending).unwrap(); + assert_eq!(result.block.hash(), pending_hash); + } + + #[test] + fn test_get_tx_info_checks_pending_then_confirm() { + let cache = TestCache::new(); + // Insert pending with txs at height 1, advance to 2 with different txs + let seq1 = make_pending_sequence_with_txs(1, B256::ZERO, 0, 2); + let confirm_tx_hash = *seq1.tx_index.keys().next().unwrap(); + cache.handle_pending_sequence(seq1).unwrap(); + + let seq2 = make_pending_sequence_with_txs(2, B256::repeat_byte(0x01), 100, 1); + let pending_tx_hash = *seq2.tx_index.keys().next().unwrap(); + cache.handle_pending_sequence(seq2).unwrap(); + + // Pending tx should be found + let (info, _) = cache.get_tx_info(&pending_tx_hash).unwrap(); + assert_eq!(info.block_number, 2); + + // Confirm tx should also be found + let (info, _) = cache.get_tx_info(&confirm_tx_hash).unwrap(); + assert_eq!(info.block_number, 1); + + // Unknown tx should return None + assert!(cache.get_tx_info(&B256::repeat_byte(0xFF)).is_none()); + } + + // ── Overlay state ───────────────────────────────────────────── + + #[test] + fn test_get_executed_blocks_returns_none_when_uninitialized() { + let inner = TestInner::new(); + // confirm_height=0, should return None + let result = inner.get_executed_blocks_up_to_height(1).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_get_executed_blocks_contiguous() { + let mut inner = TestInner::new(); + // Set canon_info so the cache considers itself initialized + inner.canon_info = (0, B256::repeat_byte(0x01)); + + // Build blocks 1-3 via pending advances + for i in 1..=3 { + let seq = make_pending_sequence(i, B256::repeat_byte(i as u8)); + inner.handle_pending_sequence(seq).unwrap(); + } + // Now: blocks 1, 2 in confirm, block 3 is pending, confirm_height=2 + + let blocks = inner.get_executed_blocks_up_to_height(3).unwrap().unwrap(); + // Should include pending (3) + confirm (2, 1) + assert_eq!(blocks.len(), 3); + // First block should be pending (3), rest from confirm newest-to-oldest + assert_eq!(blocks[0].recovered_block.number(), 3); + } + + #[test] + fn test_get_executed_blocks_returns_none_for_target_below_canon() { + let mut inner = TestInner::new(); + inner.canon_info = (5, B256::repeat_byte(0x01)); + inner.confirm_height = 5; + + // target_height <= canon_info.0 should return None + let result = inner.get_executed_blocks_up_to_height(5).unwrap(); + assert!(result.is_none()); + } + + // ── Watch channel ───────────────────────────────────────────── + + #[test] + fn test_subscribe_receives_update_on_pending_insert() { + let cache = TestCache::new(); + let mut rx = cache.subscribe_pending_sequence(); + + let seq = make_pending_sequence(1, B256::ZERO); + cache.handle_pending_sequence(seq).unwrap(); + + assert!(rx.has_changed().unwrap()); + let val = rx.borrow_and_update(); + assert!(val.is_some()); + assert_eq!(val.as_ref().unwrap().get_height(), 1); + } + + #[test] + fn test_subscribe_sees_replacement() { + let cache = TestCache::new(); + let mut rx = cache.subscribe_pending_sequence(); + + // Insert then replace at same height + cache + .handle_pending_sequence(make_pending_sequence(1, B256::ZERO)) + .unwrap(); + rx.borrow_and_update(); // consume first update + + let replacement = make_pending_sequence(1, B256::repeat_byte(0xAA)); + let replacement_hash = replacement.block_hash; + cache.handle_pending_sequence(replacement).unwrap(); + + assert!(rx.has_changed().unwrap()); + let val = rx.borrow_and_update(); + assert_eq!(val.as_ref().unwrap().block_hash, replacement_hash); + } + + #[test] + fn test_subscribe_receives_on_advance() { + let cache = TestCache::new(); + let mut rx = cache.subscribe_pending_sequence(); + + // Insert at 1, advance to 2 + cache + .handle_pending_sequence(make_pending_sequence(1, B256::ZERO)) + .unwrap(); + rx.borrow_and_update(); // consume + + let seq2 = make_pending_sequence(2, B256::repeat_byte(0x01)); + let seq2_hash = seq2.block_hash; + cache.handle_pending_sequence(seq2).unwrap(); + + assert!(rx.has_changed().unwrap()); + let val = rx.borrow_and_update(); + assert_eq!(val.as_ref().unwrap().get_height(), 2); + assert_eq!(val.as_ref().unwrap().block_hash, seq2_hash); + } + + // ── Outer FlashblockStateCache handle_canonical_block ───────── + + #[test] + fn test_outer_handle_canonical_block_updates_canon_info() { + let cache = TestCache::new(); + let seq = make_pending_sequence(1, B256::ZERO); + cache.handle_pending_sequence(seq).unwrap(); + // Advance to height 2 so pending is ahead of canon + cache + .handle_pending_sequence(make_pending_sequence(2, B256::repeat_byte(0x01))) + .unwrap(); + + let canon_hash = B256::repeat_byte(0xCC); + cache.handle_canonical_block((1, canon_hash), false); + + assert_eq!(cache.get_canon_height(), 1); + } + + // ── flush resets confirm_height to canon_info ───────────────── + + #[test] + fn test_flush_resets_confirm_height_to_canon() { + let mut inner = TestInner::new(); + // Build state: pending at 1 + inner.handle_pending_sequence(make_pending_sequence(1, B256::ZERO)).unwrap(); + + // Canonical at 1 — triggers flush since pending is stale + inner.handle_canonical_block((1, B256::repeat_byte(0xAA)), false); + + // After flush, confirm_height should equal canon height + assert_eq!(inner.confirm_height, 1); + assert!(inner.pending_cache.is_none()); + } +} diff --git a/crates/flashblocks/src/test_utils.rs b/crates/flashblocks/src/test_utils.rs index 777fb6c5..f7026d75 100644 --- a/crates/flashblocks/src/test_utils.rs +++ b/crates/flashblocks/src/test_utils.rs @@ -1,7 +1,7 @@ -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc, time::Instant}; use alloy_consensus::{Header, Receipt, TxEip7702}; -use alloy_primitives::{Address, Bloom, Bytes, Signature, B256, U256}; +use alloy_primitives::{Address, Bloom, Bytes, Signature, TxHash, B256, U256}; use alloy_rpc_types_engine::PayloadId; use op_alloy_consensus::OpTypedTransaction; use op_alloy_rpc_types_engine::{ @@ -15,6 +15,9 @@ use reth_optimism_primitives::{ OpBlock, OpBlockBody, OpPrimitives, OpReceipt, OpTransactionSigned, }; use reth_primitives_traits::{RecoveredBlock, SealedBlock, SealedHeader}; +use reth_rpc_eth_types::PendingBlock; + +use crate::cache::{pending::PendingSequence, CachedTxInfo}; pub(crate) fn mock_tx(nonce: u64) -> OpTransactionSigned { let tx = TxEip7702 { @@ -255,6 +258,12 @@ impl TestFlashBlockBuilder { self } + #[allow(dead_code)] + pub(crate) fn blob_gas_used(mut self, blob_gas_used: u64) -> Self { + self.blob_gas_used = Some(blob_gas_used); + self + } + pub(crate) fn build(mut self) -> OpFlashblockPayload { // Auto-create base for index 0 if not set if self.index == 0 && self.base.is_none() { @@ -294,3 +303,56 @@ impl TestFlashBlockBuilder { } } } + +/// Creates a `PendingSequence` at the given block number with the given parent hash. +pub(crate) fn make_pending_sequence( + block_number: u64, + parent_hash: B256, +) -> PendingSequence { + let executed = make_executed_block(block_number, parent_hash); + let block_hash = executed.recovered_block.hash(); + let pending_block = PendingBlock::with_executed_block(Instant::now(), executed); + PendingSequence { + pending: pending_block, + tx_index: HashMap::new(), + block_hash, + parent_hash, + prefix_execution_meta: Default::default(), + } +} + +/// Creates a `PendingSequence` at the given block number with transactions and tx index. +pub(crate) fn make_pending_sequence_with_txs( + block_number: u64, + parent_hash: B256, + nonce_start: u64, + tx_count: usize, +) -> PendingSequence { + let (executed, receipts) = + make_executed_block_with_txs(block_number, parent_hash, nonce_start, tx_count); + let block_hash = executed.recovered_block.hash(); + let pending_block = PendingBlock::with_executed_block(Instant::now(), executed); + + let mut tx_index: HashMap> = HashMap::new(); + for (i, tx) in pending_block.block().body().transactions.iter().enumerate() { + let tx_hash = *alloy_consensus::transaction::TxHashRef::tx_hash(tx); + tx_index.insert( + tx_hash, + CachedTxInfo { + block_number, + block_hash, + tx_index: i as u64, + tx: tx.clone(), + receipt: receipts[i].clone(), + }, + ); + } + + PendingSequence { + pending: pending_block, + tx_index, + block_hash, + parent_hash, + prefix_execution_meta: Default::default(), + } +} diff --git a/crates/tests/Cargo.toml b/crates/tests/Cargo.toml index 1e88272c..e32a83ca 100644 --- a/crates/tests/Cargo.toml +++ b/crates/tests/Cargo.toml @@ -22,6 +22,7 @@ alloy-rpc-types-eth.workspace = true alloy-network.workspace = true alloy-provider = { workspace = true, features = ["reqwest"] } alloy-sol-types.workspace = true +alloy-eips.workspace = true futures-util.workspace = true eyre.workspace = true diff --git a/crates/tests/e2e-tests/main.rs b/crates/tests/e2e-tests/main.rs index 430264ab..463c3226 100644 --- a/crates/tests/e2e-tests/main.rs +++ b/crates/tests/e2e-tests/main.rs @@ -1,7 +1,7 @@ //! Functional tests for e2e tests //! //! Run all tests with: `cargo test -p xlayer-e2e-test --test e2e_tests -- --nocapture --test-threads=1` -//! or run a specific test with: `cargo test -p xlayer-e2e-test --test e2e_tests -- -- --nocapture --test-threads=1` +//! or run a specific test with: `cargo test -p xlayer-e2e-test --test e2e_tests -- --nocapture --test-threads=1` use alloy_network::TransactionBuilder; use alloy_primitives::{hex, Address, Bytes, B256, U256}; diff --git a/crates/tests/flashblocks-tests/main.rs b/crates/tests/flashblocks-tests/main.rs index dccf1e91..d7afe0b9 100644 --- a/crates/tests/flashblocks-tests/main.rs +++ b/crates/tests/flashblocks-tests/main.rs @@ -11,6 +11,7 @@ use futures_util::StreamExt; use serde_json::{json, Value}; use std::{ collections::{HashMap, HashSet}, + fs, str::FromStr, time::{Duration, Instant}, }; @@ -164,6 +165,21 @@ async fn fb_smoke_test() { .expect("Pending eth_getBlockByNumber failed"); assert!(!fb_block.is_null(), "Block should not be empty"); + // eth_getBlockByNumber - verify pending block is queryable by its actual block number + let fb_block_number = fb_block["number"] + .as_str() + .and_then(|s| u64::from_str_radix(s.trim_start_matches("0x"), 16).ok()) + .expect("Block number should be a valid hex string"); + println!("fb_block_number: {fb_block_number}"); + let fb_block_by_number = operations::eth_get_block_by_number_or_hash( + &fb_client, + operations::BlockId::Number(fb_block_number), + false, + ) + .await + .expect("eth_getBlockByNumber with actual block number failed"); + assert!(!fb_block_by_number.is_null(), "Pending block should be queryable by its actual block number"); + // eth_getBlockTransactionCountByNumber let fb_block_transaction_count = operations::eth_get_block_transaction_count_by_number_or_hash( &fb_client, @@ -180,6 +196,199 @@ async fn fb_smoke_test() { let _ = operations::eth_get_block_receipts(&fb_client, operations::BlockId::Pending) .await .expect("Pending eth_getBlockReceipts failed"); + + println!("fb_block['hash']: {}", fb_block["hash"].as_str().expect("Block hash should not be empty")); + println!("fb_block['number']: {}", fb_block["number"].as_str().expect("Block number should not be empty")); + + // eth_getRawTransactionByBlockNumberAndIndex + let fb_raw_transaction_by_block_number_and_index = operations::eth_get_raw_transaction_by_block_number_and_index( + &fb_client, + fb_block["number"].as_str().expect("Block number should not be empty"), + "0x0", + ) + .await + .expect("Pending eth_getRawTransactionByBlockNumberAndIndex failed"); + assert!(!fb_raw_transaction_by_block_number_and_index.is_null(), "Raw transaction should not be empty"); + + // eth_sendRawTransactionSync + let raw_tx = operations::sign_raw_transaction( + operations::DEFAULT_L2_NETWORK_URL_FB, + U256::from(operations::GWEI), + test_address, + ) + .await + .expect("Failed to sign raw transaction"); + println!("Raw tx: {raw_tx}"); + let fb_send_raw_transaction_sync = operations::eth_send_raw_transaction_sync(&fb_client, &raw_tx) + .await + .expect("Pending eth_sendRawTransactionSync failed"); + assert!(!fb_send_raw_transaction_sync.is_null(), "Send raw transaction sync should not be empty"); + let sync_tx_hash = fb_send_raw_transaction_sync["transactionHash"].as_str().expect("eth_sendRawTransactionSync result should contain a transactionHash"); + assert!(sync_tx_hash.starts_with("0x"), "Transaction hash should start with 0x"); + let sync_tx = operations::eth_get_transaction_by_hash(&fb_client, sync_tx_hash) + .await + .expect("eth_getTransactionByHash after sendRawTransactionSync failed"); + assert!(!sync_tx.is_null(), "Transaction should be visible in pending state after eth_sendRawTransactionSync"); + +} + +/// Cache correctness test: snapshots all confirmed flashblock cache entries currently ahead +/// of the canonical chain, writes them to a file, waits for canonical to catch up, then +/// compares block hash, stateRoot, transactionsRoot, receiptsRoot, gasUsed, and receipts +/// against the non-flashblock canonical node to verify the cache was correct. +/// +/// Only compares blocks from the confirm cache (not the pending cache), since the pending +/// block is still being built and its contents may change before finalization. +#[ignore = "Requires a second non-flashblock RPC node to be running"] +#[tokio::test] +async fn fb_cache_correctness_test() { + const SNAPSHOT_FILE: &str = "/tmp/fb_cache_snapshot.json"; + const CATCHUP_TIMEOUT: Duration = Duration::from_secs(120); + const POLL_INTERVAL: Duration = Duration::from_millis(200); + + let fb_client = operations::create_test_client(operations::DEFAULT_L2_NETWORK_URL_FB); + let canonical_client = operations::create_test_client(operations::DEFAULT_L2_NETWORK_URL_NO_FB); + + // Step 1: record current canonical height and wait for at least two blocks ahead + // (so we have at least one confirmed block between canonical and pending) + let canonical_height = operations::eth_block_number(&canonical_client) + .await + .expect("Failed to get canonical block number"); + println!("Canonical height: {canonical_height}"); + + println!("Waiting for at least two flashblocks ahead of canonical (need confirmed + pending)..."); + let pending_number = tokio::time::timeout(CATCHUP_TIMEOUT, async { + loop { + let pending = operations::eth_get_block_by_number_or_hash( + &fb_client, + operations::BlockId::Pending, + false, + ) + .await + .unwrap_or(Value::Null); + + if let Some(n) = pending["number"] + .as_str() + .and_then(|s| u64::from_str_radix(s.trim_start_matches("0x"), 16).ok()) + { + // Need at least 2 blocks ahead: one confirmed, one pending + if n > canonical_height + 1 { + println!("Flashblock pending height: {n}"); + return n; + } + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }) + .await + .expect("Timed out waiting for confirmed flashblocks ahead of canonical"); + + // Step 2: discover confirmed cache entries by querying canonical+1 up to pending-1 + // (exclude pending_number since that block is still being built) + let confirm_upper = pending_number - 1; + let mut snapshot = Vec::new(); + for height in (canonical_height + 1)..=confirm_upper { + let block = operations::eth_get_block_by_number_or_hash( + &fb_client, + operations::BlockId::Number(height), + true, + ) + .await + .unwrap_or(Value::Null); + + // Stop if the cache doesn't have this height (gap or eviction) + if block.is_null() { + println!("Cache has no block at height {height}, stopping discovery at {}", height - 1); + break; + } + + let receipts = operations::eth_get_block_receipts( + &fb_client, + operations::BlockId::Number(height), + ) + .await + .unwrap_or(Value::Null); + + println!( + "Snapshotted height {height}: hash={} stateRoot={}", + block["hash"].as_str().unwrap_or("?"), + block["stateRoot"].as_str().unwrap_or("?"), + ); + snapshot.push(json!({ "height": height, "block": block, "receipts": receipts })); + } + + assert!(!snapshot.is_empty(), "No flashblock cache entries found ahead of canonical height {canonical_height}"); + println!("Snapshotted {} block(s) from flashblock cache", snapshot.len()); + let snapshot_target = snapshot.last().unwrap()["height"].as_u64().unwrap(); + + // Step 4: write snapshot to file + let snapshot_json = + serde_json::to_string_pretty(&json!(snapshot)).expect("Failed to serialize snapshot"); + fs::write(SNAPSHOT_FILE, &snapshot_json).expect("Failed to write snapshot file"); + println!("Snapshot written to {SNAPSHOT_FILE} ({} bytes)", snapshot_json.len()); + + // Step 5: wait for the non-FB canonical node to reach snapshot_target + println!("Waiting for canonical node to reach height {snapshot_target}..."); + tokio::time::timeout(CATCHUP_TIMEOUT, async { + loop { + let h = operations::eth_block_number(&canonical_client).await.unwrap_or(0); + println!("Canonical height: {h} / {snapshot_target}"); + if h >= snapshot_target { + break; + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }) + .await + .expect("Timed out waiting for canonical node to catch up"); + + // Step 6: read snapshot and compare each block against canonical + let saved: Vec = + serde_json::from_str(&fs::read_to_string(SNAPSHOT_FILE).expect("Failed to read snapshot")) + .expect("Failed to parse snapshot"); + + let mut mismatches = 0; + for entry in &saved { + let height = entry["height"].as_u64().expect("Missing height in snapshot"); + let fb_block = &entry["block"]; + let fb_receipts = &entry["receipts"]; + + let canonical_block = operations::eth_get_block_by_number_or_hash( + &canonical_client, + operations::BlockId::Number(height), + true, + ) + .await + .unwrap_or_else(|_| panic!("Failed to get canonical block at height {height}")); + assert!(!canonical_block.is_null(), "Canonical block at height {height} is null"); + + let canonical_receipts = operations::eth_get_block_receipts( + &canonical_client, + operations::BlockId::Number(height), + ) + .await + .unwrap_or_else(|_| panic!("Failed to get canonical receipts at height {height}")); + + for field in ["hash", "stateRoot", "transactionsRoot", "receiptsRoot", "gasUsed"] { + if fb_block[field] != canonical_block[field] { + eprintln!( + "MISMATCH height={height} field='{field}': flashblock={} canonical={}", + fb_block[field], canonical_block[field] + ); + mismatches += 1; + } + } + if fb_receipts != &canonical_receipts { + eprintln!("MISMATCH height={height}: receipts differ"); + mismatches += 1; + } + if mismatches == 0 { + println!("✓ height {height}: all fields match canonical"); + } + } + + let _ = fs::remove_file(SNAPSHOT_FILE); + assert_eq!(mismatches, 0, "{mismatches} cache mismatch(es) — flashblock cache was incorrect"); } /// Flashblock native balance transfer tx confirmation benchmark between a flashblock diff --git a/crates/tests/operations/eth_rpc.rs b/crates/tests/operations/eth_rpc.rs index e3872565..5b1c7635 100644 --- a/crates/tests/operations/eth_rpc.rs +++ b/crates/tests/operations/eth_rpc.rs @@ -358,3 +358,44 @@ pub async fn eth_flashblocks_enabled(client_rpc: &HttpClient) -> Result { .await??; Ok(result) } + +// /// For eth_getRawTransactionByBlockHashAndIndex +// pub async fn eth_get_raw_transaction_by_block_hash_and_index( +// client_rpc: &HttpClient, +// block_hash: &str, +// index: &str, +// ) -> Result { +// let result: Value = tokio::time::timeout( +// RPC_TIMEOUT, +// client_rpc.request("eth_getRawTransactionByBlockHashAndIndex", jsonrpsee::rpc_params![block_hash, index]), +// ) +// .await??; +// Ok(result) +// } + +/// For eth_getRawTransactionByBlockNumberAndIndex +pub async fn eth_get_raw_transaction_by_block_number_and_index( + client_rpc: &HttpClient, + block_number: &str, + index: &str, +) -> Result { + let result: Value = tokio::time::timeout( + RPC_TIMEOUT, + client_rpc.request("eth_getRawTransactionByBlockNumberAndIndex", jsonrpsee::rpc_params![block_number, index]), + ) + .await??; + Ok(result) +} + +/// For eth_sendRawTransactionSync +pub async fn eth_send_raw_transaction_sync( + client_rpc: &HttpClient, + raw_tx: &str, +) -> Result { + let result: Value = tokio::time::timeout( + RPC_TIMEOUT, + client_rpc.request("eth_sendRawTransactionSync", jsonrpsee::rpc_params![raw_tx]), + ) + .await??; + Ok(result) +} \ No newline at end of file diff --git a/crates/tests/operations/utils.rs b/crates/tests/operations/utils.rs index fe265a88..2f096fb4 100644 --- a/crates/tests/operations/utils.rs +++ b/crates/tests/operations/utils.rs @@ -5,6 +5,7 @@ use crate::operations::{ eth_get_transaction_count, eth_get_transaction_receipt, get_balance, manager::*, BlockId, HttpClient, }; +use alloy_eips::eip2718::Encodable2718; use alloy_network::{EthereumWallet, TransactionBuilder}; use alloy_primitives::{hex, Address, Bytes, U256}; use alloy_provider::{Provider, ProviderBuilder}; @@ -76,6 +77,34 @@ pub async fn native_balance_transfer( Ok(format!("{tx_hash:#x}")) } +/// Sign a native transfer transaction and return its raw EIP-2718 encoded bytes as a hex string, +/// without broadcasting it to the network. +pub async fn sign_raw_transaction( + endpoint_url: &str, + amount: U256, + to_address: &str, +) -> Result { + let signer = PrivateKeySigner::from_str(DEFAULT_RICH_PRIVATE_KEY.trim_start_matches("0x"))?; + let wallet = EthereumWallet::from(signer.clone()); + let provider = ProviderBuilder::new().wallet(wallet.clone()).connect_http(endpoint_url.parse()?); + + let from = signer.address(); + let to = Address::from_str(to_address)?; + let nonce = provider.get_transaction_count(from).pending().await?; + let gas_price = provider.get_gas_price().await?; + let tx = TransactionRequest::default() + .with_from(from) + .with_to(to) + .with_value(amount) + .with_nonce(nonce) + .with_chain_id(DEFAULT_L2_CHAIN_ID) + .with_gas_limit(21_000) + .with_gas_price(gas_price); + + let envelope = tx.build(&wallet).await?; + Ok(format!("0x{}", hex::encode(envelope.encoded_2718()))) +} + /// Funds an address with native tokens and waits for the balance to be available pub async fn fund_address_and_wait_for_balance( client: &HttpClient, From 4819fc5a4ffbb2075058bf3c89f7c05ef784e844 Mon Sep 17 00:00:00 2001 From: brendontan03 Date: Fri, 27 Mar 2026 14:33:12 +0800 Subject: [PATCH 2/3] feat: Add e2e and unit tests --- crates/flashblocks/src/cache/mod.rs | 168 +++++++++---------------- crates/tests/flashblocks-tests/main.rs | 158 ++++++++++++++++------- crates/tests/operations/eth_rpc.rs | 12 +- crates/tests/operations/utils.rs | 3 +- 4 files changed, 183 insertions(+), 158 deletions(-) diff --git a/crates/flashblocks/src/cache/mod.rs b/crates/flashblocks/src/cache/mod.rs index 7c684224..cfcf3fb9 100644 --- a/crates/flashblocks/src/cache/mod.rs +++ b/crates/flashblocks/src/cache/mod.rs @@ -564,14 +564,25 @@ impl FlashblockStateCacheInner { #[cfg(test)] mod tests { use super::*; - use crate::test_utils::{make_executed_block, make_pending_sequence, make_pending_sequence_with_txs}; + use crate::test_utils::{ + make_executed_block, make_pending_sequence, make_pending_sequence_with_txs, + }; use alloy_consensus::BlockHeader; + use reth_chain_state::CanonicalInMemoryState; use reth_optimism_primitives::OpPrimitives; type TestCache = FlashblockStateCache; type TestInner = FlashblockStateCacheInner; - // ── Defaults ────────────────────────────────────────────────── + fn make_test_cache() -> TestCache { + TestCache::new(CanonicalInMemoryState::new( + Default::default(), + Default::default(), + None, + None, + None, + )) + } #[test] fn test_new_defaults() { @@ -581,14 +592,12 @@ mod tests { assert!(inner.pending_cache.is_none()); } - // ── handle_pending_sequence ─────────────────────────────────── - #[test] fn test_handle_pending_first_at_expected_height() { let mut inner = TestInner::new(); // confirm_height=0, expected_height=1 let seq = make_pending_sequence(1, B256::ZERO); - inner.handle_pending_sequence(seq).unwrap(); + inner.handle_pending_sequence(seq, 0).unwrap(); assert!(inner.pending_cache.is_some()); assert_eq!(inner.pending_cache.as_ref().unwrap().get_height(), 1); @@ -600,46 +609,40 @@ mod tests { let mut inner = TestInner::new(); let seq1 = make_pending_sequence(1, B256::ZERO); let seq1_hash = seq1.block_hash; - inner.handle_pending_sequence(seq1).unwrap(); + inner.handle_pending_sequence(seq1, 0).unwrap(); // Replace at same height with different parent_hash to produce a different block let seq2 = make_pending_sequence(1, B256::repeat_byte(0xAA)); let seq2_hash = seq2.block_hash; - inner.handle_pending_sequence(seq2).unwrap(); + inner.handle_pending_sequence(seq2, 0).unwrap(); assert!(inner.pending_cache.is_some()); // Block hash should have changed due to different parent hash assert_ne!(seq1_hash, seq2_hash); assert_eq!(inner.pending_cache.as_ref().unwrap().block_hash, seq2_hash); - // confirm_height unchanged assert_eq!(inner.confirm_height, 0); } #[test] fn test_handle_pending_advance_commits_to_confirm() { let mut inner = TestInner::new(); - // Insert pending at height 1 let seq1 = make_pending_sequence(1, B256::ZERO); - inner.handle_pending_sequence(seq1).unwrap(); + inner.handle_pending_sequence(seq1, 0).unwrap(); // Advance to height 2 — seq at height 1 should be committed to confirm let seq2 = make_pending_sequence(2, B256::repeat_byte(0xBB)); - inner.handle_pending_sequence(seq2).unwrap(); + inner.handle_pending_sequence(seq2, 0).unwrap(); - // confirm_height advanced to 1 (old pending committed) assert_eq!(inner.confirm_height, 1); - // Pending is now at height 2 assert_eq!(inner.pending_cache.as_ref().unwrap().get_height(), 2); - // Block at height 1 should be in confirm cache assert!(inner.confirm_cache.get_block_by_number(1).is_some()); } #[test] fn test_handle_pending_advance_without_existing_errors() { let mut inner = TestInner::new(); - // No pending exists, try to advance to expected_height + 1 = 2 let seq = make_pending_sequence(2, B256::ZERO); - let result = inner.handle_pending_sequence(seq); + let result = inner.handle_pending_sequence(seq, 0); assert!(result.is_err()); assert!(result .unwrap_err() @@ -650,9 +653,8 @@ mod tests { #[test] fn test_handle_pending_wrong_height_errors() { let mut inner = TestInner::new(); - // confirm_height=0, expected=1, so heights 3+ should fail let seq = make_pending_sequence(5, B256::ZERO); - let result = inner.handle_pending_sequence(seq); + let result = inner.handle_pending_sequence(seq, 0); assert!(result.is_err()); assert!(result .unwrap_err() @@ -660,12 +662,9 @@ mod tests { .contains("not next consecutive pending height block")); } - // ── handle_confirmed_block ──────────────────────────────────── - #[test] fn test_handle_confirmed_block_non_consecutive_errors() { let mut inner = TestInner::new(); - // confirm_height=0, block_number=5 should fail let executed = make_executed_block(5, B256::ZERO); let result = inner.handle_confirmed_block(5, executed, Arc::new(vec![])); assert!(result.is_err()); @@ -675,26 +674,19 @@ mod tests { .contains("not next consecutive target confirm height block")); } - // ── handle_canonical_block ──────────────────────────────────── - #[test] fn test_handle_canonical_evicts_confirm() { let mut inner = TestInner::new(); - // Build up confirm cache with blocks 1-5 via pending sequence advances for i in 1..=5 { let seq = make_pending_sequence(i, B256::repeat_byte(i as u8)); - inner.handle_pending_sequence(seq).unwrap(); + inner.handle_pending_sequence(seq, 0).unwrap(); } - // After inserting 1-5 sequentially: blocks 1-4 in confirm, 5 is pending assert_eq!(inner.confirm_height, 4); assert_eq!(inner.pending_cache.as_ref().unwrap().get_height(), 5); - // Canonical at height 2 should flush blocks 1-2 from confirm - let flushed = - inner.handle_canonical_block((2, B256::repeat_byte(0xFF)), false); + let flushed = inner.handle_canonical_block((2, B256::repeat_byte(0xFF)), false); assert!(!flushed); // No full flush (pending at 5 > canon 2) assert_eq!(inner.canon_info.0, 2); - // Blocks 1-2 should be evicted, 3-4 should remain assert!(inner.confirm_cache.get_block_by_number(1).is_none()); assert!(inner.confirm_cache.get_block_by_number(2).is_none()); assert!(inner.confirm_cache.get_block_by_number(3).is_some()); @@ -706,11 +698,10 @@ mod tests { let mut inner = TestInner::new(); // Insert pending at height 1 let seq = make_pending_sequence(1, B256::ZERO); - inner.handle_pending_sequence(seq).unwrap(); + inner.handle_pending_sequence(seq, 0).unwrap(); // Canonical catches up to height 1 — pending is stale - let flushed = - inner.handle_canonical_block((1, B256::repeat_byte(0xCC)), false); + let flushed = inner.handle_canonical_block((1, B256::repeat_byte(0xCC)), false); assert!(flushed); assert!(inner.pending_cache.is_none()); assert_eq!(inner.confirm_height, 1); // max(0, 1) @@ -720,58 +711,50 @@ mod tests { fn test_handle_canonical_flush_on_reorg() { let mut inner = TestInner::new(); let seq = make_pending_sequence(1, B256::ZERO); - inner.handle_pending_sequence(seq).unwrap(); + inner.handle_pending_sequence(seq, 0).unwrap(); // Even if pending is ahead, reorg flag forces full flush - let flushed = - inner.handle_canonical_block((0, B256::repeat_byte(0xDD)), true); + let flushed = inner.handle_canonical_block((0, B256::repeat_byte(0xDD)), true); assert!(flushed); assert!(inner.pending_cache.is_none()); } - // ── Block/tx routing ────────────────────────────────────────── - #[test] fn test_get_block_by_number_pending_priority() { - let cache = TestCache::new(); - // Insert pending at height 1, then advance to height 2 so height 1 is in confirm - cache.handle_pending_sequence(make_pending_sequence(1, B256::ZERO)).unwrap(); + let cache = make_test_cache(); + cache.handle_pending_sequence(make_pending_sequence(1, B256::ZERO), 0).unwrap(); cache - .handle_pending_sequence(make_pending_sequence(2, B256::repeat_byte(0x01))) + .handle_pending_sequence(make_pending_sequence(2, B256::repeat_byte(0x01)), 0) .unwrap(); - // Now replace pending at height 2 with a new sequence (different parent) let new_pending = make_pending_sequence(2, B256::repeat_byte(0x02)); let new_pending_hash = new_pending.block_hash; - cache.handle_pending_sequence(new_pending).unwrap(); + cache.handle_pending_sequence(new_pending, 0).unwrap(); - // Query height 2 should return the pending block (not something from confirm) let result = cache.get_block_by_number(2).unwrap(); assert_eq!(result.block.hash(), new_pending_hash); } #[test] fn test_get_block_by_number_falls_to_confirm() { - let cache = TestCache::new(); - // Insert pending at 1, advance to 2 — block 1 is in confirm + let cache = make_test_cache(); let seq1 = make_pending_sequence(1, B256::ZERO); let seq1_hash = seq1.block_hash; - cache.handle_pending_sequence(seq1).unwrap(); + cache.handle_pending_sequence(seq1, 0).unwrap(); cache - .handle_pending_sequence(make_pending_sequence(2, B256::repeat_byte(0x01))) + .handle_pending_sequence(make_pending_sequence(2, B256::repeat_byte(0x01)), 0) .unwrap(); - // Query height 1 — pending is at 2, so should fall through to confirm let result = cache.get_block_by_number(1).unwrap(); assert_eq!(result.block.hash(), seq1_hash); } #[test] fn test_get_block_by_hash_pending_priority() { - let cache = TestCache::new(); + let cache = make_test_cache(); let seq = make_pending_sequence(1, B256::ZERO); let pending_hash = seq.block_hash; - cache.handle_pending_sequence(seq).unwrap(); + cache.handle_pending_sequence(seq, 0).unwrap(); let result = cache.get_block_by_hash(&pending_hash).unwrap(); assert_eq!(result.block.number(), 1); @@ -779,13 +762,12 @@ mod tests { #[test] fn test_get_rpc_block_latest_returns_confirmed() { - let cache = TestCache::new(); - // Build: pending at 1, advance to 2 → confirm_height=1 + let cache = make_test_cache(); let seq1 = make_pending_sequence(1, B256::ZERO); let seq1_hash = seq1.block_hash; - cache.handle_pending_sequence(seq1).unwrap(); + cache.handle_pending_sequence(seq1, 0).unwrap(); cache - .handle_pending_sequence(make_pending_sequence(2, B256::repeat_byte(0x01))) + .handle_pending_sequence(make_pending_sequence(2, B256::repeat_byte(0x01)), 0) .unwrap(); let result = cache.get_rpc_block(BlockNumberOrTag::Latest).unwrap(); @@ -795,10 +777,10 @@ mod tests { #[test] fn test_get_rpc_block_pending_returns_pending() { - let cache = TestCache::new(); + let cache = make_test_cache(); let seq = make_pending_sequence(1, B256::ZERO); let pending_hash = seq.block_hash; - cache.handle_pending_sequence(seq).unwrap(); + cache.handle_pending_sequence(seq, 0).unwrap(); let result = cache.get_rpc_block(BlockNumberOrTag::Pending).unwrap(); assert_eq!(result.block.hash(), pending_hash); @@ -806,34 +788,27 @@ mod tests { #[test] fn test_get_tx_info_checks_pending_then_confirm() { - let cache = TestCache::new(); - // Insert pending with txs at height 1, advance to 2 with different txs + let cache = make_test_cache(); let seq1 = make_pending_sequence_with_txs(1, B256::ZERO, 0, 2); let confirm_tx_hash = *seq1.tx_index.keys().next().unwrap(); - cache.handle_pending_sequence(seq1).unwrap(); + cache.handle_pending_sequence(seq1, 0).unwrap(); let seq2 = make_pending_sequence_with_txs(2, B256::repeat_byte(0x01), 100, 1); let pending_tx_hash = *seq2.tx_index.keys().next().unwrap(); - cache.handle_pending_sequence(seq2).unwrap(); + cache.handle_pending_sequence(seq2, 0).unwrap(); - // Pending tx should be found let (info, _) = cache.get_tx_info(&pending_tx_hash).unwrap(); assert_eq!(info.block_number, 2); - // Confirm tx should also be found let (info, _) = cache.get_tx_info(&confirm_tx_hash).unwrap(); assert_eq!(info.block_number, 1); - // Unknown tx should return None assert!(cache.get_tx_info(&B256::repeat_byte(0xFF)).is_none()); } - // ── Overlay state ───────────────────────────────────────────── - #[test] fn test_get_executed_blocks_returns_none_when_uninitialized() { let inner = TestInner::new(); - // confirm_height=0, should return None let result = inner.get_executed_blocks_up_to_height(1).unwrap(); assert!(result.is_none()); } @@ -841,21 +816,17 @@ mod tests { #[test] fn test_get_executed_blocks_contiguous() { let mut inner = TestInner::new(); - // Set canon_info so the cache considers itself initialized - inner.canon_info = (0, B256::repeat_byte(0x01)); + inner.canon_info = (1, B256::repeat_byte(0x01)); + inner.confirm_height = 1; - // Build blocks 1-3 via pending advances - for i in 1..=3 { + for i in 2..=4 { let seq = make_pending_sequence(i, B256::repeat_byte(i as u8)); - inner.handle_pending_sequence(seq).unwrap(); + inner.handle_pending_sequence(seq, 0).unwrap(); } - // Now: blocks 1, 2 in confirm, block 3 is pending, confirm_height=2 - let blocks = inner.get_executed_blocks_up_to_height(3).unwrap().unwrap(); - // Should include pending (3) + confirm (2, 1) + let blocks = inner.get_executed_blocks_up_to_height(4).unwrap().unwrap(); assert_eq!(blocks.len(), 3); - // First block should be pending (3), rest from confirm newest-to-oldest - assert_eq!(blocks[0].recovered_block.number(), 3); + assert_eq!(blocks[0].recovered_block.number(), 4); } #[test] @@ -864,20 +835,17 @@ mod tests { inner.canon_info = (5, B256::repeat_byte(0x01)); inner.confirm_height = 5; - // target_height <= canon_info.0 should return None let result = inner.get_executed_blocks_up_to_height(5).unwrap(); assert!(result.is_none()); } - // ── Watch channel ───────────────────────────────────────────── - #[test] fn test_subscribe_receives_update_on_pending_insert() { - let cache = TestCache::new(); + let cache = make_test_cache(); let mut rx = cache.subscribe_pending_sequence(); let seq = make_pending_sequence(1, B256::ZERO); - cache.handle_pending_sequence(seq).unwrap(); + cache.handle_pending_sequence(seq, 0).unwrap(); assert!(rx.has_changed().unwrap()); let val = rx.borrow_and_update(); @@ -887,18 +855,15 @@ mod tests { #[test] fn test_subscribe_sees_replacement() { - let cache = TestCache::new(); + let cache = make_test_cache(); let mut rx = cache.subscribe_pending_sequence(); - // Insert then replace at same height - cache - .handle_pending_sequence(make_pending_sequence(1, B256::ZERO)) - .unwrap(); + cache.handle_pending_sequence(make_pending_sequence(1, B256::ZERO), 0).unwrap(); rx.borrow_and_update(); // consume first update let replacement = make_pending_sequence(1, B256::repeat_byte(0xAA)); let replacement_hash = replacement.block_hash; - cache.handle_pending_sequence(replacement).unwrap(); + cache.handle_pending_sequence(replacement, 0).unwrap(); assert!(rx.has_changed().unwrap()); let val = rx.borrow_and_update(); @@ -907,18 +872,15 @@ mod tests { #[test] fn test_subscribe_receives_on_advance() { - let cache = TestCache::new(); + let cache = make_test_cache(); let mut rx = cache.subscribe_pending_sequence(); - // Insert at 1, advance to 2 - cache - .handle_pending_sequence(make_pending_sequence(1, B256::ZERO)) - .unwrap(); + cache.handle_pending_sequence(make_pending_sequence(1, B256::ZERO), 0).unwrap(); rx.borrow_and_update(); // consume let seq2 = make_pending_sequence(2, B256::repeat_byte(0x01)); let seq2_hash = seq2.block_hash; - cache.handle_pending_sequence(seq2).unwrap(); + cache.handle_pending_sequence(seq2, 0).unwrap(); assert!(rx.has_changed().unwrap()); let val = rx.borrow_and_update(); @@ -926,16 +888,13 @@ mod tests { assert_eq!(val.as_ref().unwrap().block_hash, seq2_hash); } - // ── Outer FlashblockStateCache handle_canonical_block ───────── - #[test] fn test_outer_handle_canonical_block_updates_canon_info() { - let cache = TestCache::new(); + let cache = make_test_cache(); let seq = make_pending_sequence(1, B256::ZERO); - cache.handle_pending_sequence(seq).unwrap(); - // Advance to height 2 so pending is ahead of canon + cache.handle_pending_sequence(seq, 0).unwrap(); cache - .handle_pending_sequence(make_pending_sequence(2, B256::repeat_byte(0x01))) + .handle_pending_sequence(make_pending_sequence(2, B256::repeat_byte(0x01)), 0) .unwrap(); let canon_hash = B256::repeat_byte(0xCC); @@ -944,18 +903,13 @@ mod tests { assert_eq!(cache.get_canon_height(), 1); } - // ── flush resets confirm_height to canon_info ───────────────── - #[test] fn test_flush_resets_confirm_height_to_canon() { let mut inner = TestInner::new(); - // Build state: pending at 1 - inner.handle_pending_sequence(make_pending_sequence(1, B256::ZERO)).unwrap(); + inner.handle_pending_sequence(make_pending_sequence(1, B256::ZERO), 0).unwrap(); - // Canonical at 1 — triggers flush since pending is stale inner.handle_canonical_block((1, B256::repeat_byte(0xAA)), false); - // After flush, confirm_height should equal canon height assert_eq!(inner.confirm_height, 1); assert!(inner.pending_cache.is_none()); } diff --git a/crates/tests/flashblocks-tests/main.rs b/crates/tests/flashblocks-tests/main.rs index d7afe0b9..0405ca04 100644 --- a/crates/tests/flashblocks-tests/main.rs +++ b/crates/tests/flashblocks-tests/main.rs @@ -178,7 +178,10 @@ async fn fb_smoke_test() { ) .await .expect("eth_getBlockByNumber with actual block number failed"); - assert!(!fb_block_by_number.is_null(), "Pending block should be queryable by its actual block number"); + assert!( + !fb_block_by_number.is_null(), + "Pending block should be queryable by its actual block number" + ); // eth_getBlockTransactionCountByNumber let fb_block_transaction_count = operations::eth_get_block_transaction_count_by_number_or_hash( @@ -197,18 +200,28 @@ async fn fb_smoke_test() { .await .expect("Pending eth_getBlockReceipts failed"); - println!("fb_block['hash']: {}", fb_block["hash"].as_str().expect("Block hash should not be empty")); - println!("fb_block['number']: {}", fb_block["number"].as_str().expect("Block number should not be empty")); + println!( + "fb_block['hash']: {}", + fb_block["hash"].as_str().expect("Block hash should not be empty") + ); + println!( + "fb_block['number']: {}", + fb_block["number"].as_str().expect("Block number should not be empty") + ); // eth_getRawTransactionByBlockNumberAndIndex - let fb_raw_transaction_by_block_number_and_index = operations::eth_get_raw_transaction_by_block_number_and_index( - &fb_client, - fb_block["number"].as_str().expect("Block number should not be empty"), - "0x0", - ) - .await - .expect("Pending eth_getRawTransactionByBlockNumberAndIndex failed"); - assert!(!fb_raw_transaction_by_block_number_and_index.is_null(), "Raw transaction should not be empty"); + let fb_raw_transaction_by_block_number_and_index = + operations::eth_get_raw_transaction_by_block_number_and_index( + &fb_client, + fb_block["number"].as_str().expect("Block number should not be empty"), + "0x0", + ) + .await + .expect("Pending eth_getRawTransactionByBlockNumberAndIndex failed"); + assert!( + !fb_raw_transaction_by_block_number_and_index.is_null(), + "Raw transaction should not be empty" + ); // eth_sendRawTransactionSync let raw_tx = operations::sign_raw_transaction( @@ -219,26 +232,91 @@ async fn fb_smoke_test() { .await .expect("Failed to sign raw transaction"); println!("Raw tx: {raw_tx}"); - let fb_send_raw_transaction_sync = operations::eth_send_raw_transaction_sync(&fb_client, &raw_tx) - .await - .expect("Pending eth_sendRawTransactionSync failed"); - assert!(!fb_send_raw_transaction_sync.is_null(), "Send raw transaction sync should not be empty"); - let sync_tx_hash = fb_send_raw_transaction_sync["transactionHash"].as_str().expect("eth_sendRawTransactionSync result should contain a transactionHash"); + let fb_send_raw_transaction_sync = + operations::eth_send_raw_transaction_sync(&fb_client, &raw_tx) + .await + .expect("Pending eth_sendRawTransactionSync failed"); + assert!( + !fb_send_raw_transaction_sync.is_null(), + "Send raw transaction sync should not be empty" + ); + let sync_tx_hash = fb_send_raw_transaction_sync["transactionHash"] + .as_str() + .expect("eth_sendRawTransactionSync result should contain a transactionHash"); assert!(sync_tx_hash.starts_with("0x"), "Transaction hash should start with 0x"); let sync_tx = operations::eth_get_transaction_by_hash(&fb_client, sync_tx_hash) .await .expect("eth_getTransactionByHash after sendRawTransactionSync failed"); - assert!(!sync_tx.is_null(), "Transaction should be visible in pending state after eth_sendRawTransactionSync"); + assert!( + !sync_tx.is_null(), + "Transaction should be visible in pending state after eth_sendRawTransactionSync" + ); +} + +/// Negative tests for flashblock RPCs +#[tokio::test] +async fn fb_negative_test() { + let fb_client = operations::create_test_client(operations::DEFAULT_L2_NETWORK_URL_FB); + + // Non-existent block returns null + let result = operations::eth_get_block_by_number_or_hash( + &fb_client, + operations::BlockId::Number(0xFFFFFFFF), + false, + ) + .await + .expect("eth_getBlockByNumber should not return an RPC error for non-existent block"); + assert!(result.is_null(), "Non-existent block should return null, got: {result}"); + // Non-existent tx returns null + let fake_hash = "0x0000000000000000000000000000000000000000000000000000000000000001"; + + let tx = operations::eth_get_transaction_by_hash(&fb_client, fake_hash) + .await + .expect("eth_getTransactionByHash should not return an RPC error for non-existent tx"); + assert!(tx.is_null(), "Non-existent tx should return null, got: {tx}"); + + let receipt = operations::eth_get_transaction_receipt(&fb_client, fake_hash) + .await + .expect("eth_getTransactionReceipt should not return an RPC error for non-existent tx"); + assert!(receipt.is_null(), "Non-existent tx receipt should return null, got: {receipt}"); + + // Invalid raw tx is rejected + let result = operations::eth_send_raw_transaction_sync(&fb_client, "0xdeadbeef").await; + assert!( + result.is_err(), + "eth_sendRawTransactionSync with invalid tx should return an error, got: {result:?}" + ); + + // eth_call with invalid selector reverts + let contracts = operations::try_deploy_contracts().await.expect("Failed to deploy contracts"); + + let call_args = json!({ + "from": operations::DEFAULT_RICH_ADDRESS, + "to": contracts.erc20, + "gas": "0x100000", + "data": "0xdeadbeef", + }); + + let result = + operations::eth_call(&fb_client, Some(call_args), Some(operations::BlockId::Pending)).await; + + match result { + Err(e) => { + println!("eth_call reverted as expected: {e}"); + } + Ok(data) => { + // Some contracts may return empty data for unknown selectors rather than reverting + println!( + "eth_call returned data (contract may not revert on unknown selector): {data}" + ); + } + } } /// Cache correctness test: snapshots all confirmed flashblock cache entries currently ahead /// of the canonical chain, writes them to a file, waits for canonical to catch up, then -/// compares block hash, stateRoot, transactionsRoot, receiptsRoot, gasUsed, and receipts -/// against the non-flashblock canonical node to verify the cache was correct. -/// -/// Only compares blocks from the confirm cache (not the pending cache), since the pending -/// block is still being built and its contents may change before finalization. +/// compares against the non-flashblock canonical node to verify the cache was correct. #[ignore = "Requires a second non-flashblock RPC node to be running"] #[tokio::test] async fn fb_cache_correctness_test() { @@ -249,14 +327,14 @@ async fn fb_cache_correctness_test() { let fb_client = operations::create_test_client(operations::DEFAULT_L2_NETWORK_URL_FB); let canonical_client = operations::create_test_client(operations::DEFAULT_L2_NETWORK_URL_NO_FB); - // Step 1: record current canonical height and wait for at least two blocks ahead - // (so we have at least one confirmed block between canonical and pending) let canonical_height = operations::eth_block_number(&canonical_client) .await .expect("Failed to get canonical block number"); println!("Canonical height: {canonical_height}"); - println!("Waiting for at least two flashblocks ahead of canonical (need confirmed + pending)..."); + println!( + "Waiting for at least two flashblocks ahead of canonical (need confirmed + pending)..." + ); let pending_number = tokio::time::timeout(CATCHUP_TIMEOUT, async { loop { let pending = operations::eth_get_block_by_number_or_hash( @@ -270,24 +348,19 @@ async fn fb_cache_correctness_test() { if let Some(n) = pending["number"] .as_str() .and_then(|s| u64::from_str_radix(s.trim_start_matches("0x"), 16).ok()) - { - // Need at least 2 blocks ahead: one confirmed, one pending - if n > canonical_height + 1 { + && n > canonical_height + 1 { println!("Flashblock pending height: {n}"); return n; } - } tokio::time::sleep(POLL_INTERVAL).await; } }) .await .expect("Timed out waiting for confirmed flashblocks ahead of canonical"); - // Step 2: discover confirmed cache entries by querying canonical+1 up to pending-1 - // (exclude pending_number since that block is still being built) - let confirm_upper = pending_number - 1; + let confirmed_block = pending_number - 1; let mut snapshot = Vec::new(); - for height in (canonical_height + 1)..=confirm_upper { + for height in (canonical_height + 1)..=confirmed_block { let block = operations::eth_get_block_by_number_or_hash( &fb_client, operations::BlockId::Number(height), @@ -296,18 +369,15 @@ async fn fb_cache_correctness_test() { .await .unwrap_or(Value::Null); - // Stop if the cache doesn't have this height (gap or eviction) if block.is_null() { println!("Cache has no block at height {height}, stopping discovery at {}", height - 1); break; } - let receipts = operations::eth_get_block_receipts( - &fb_client, - operations::BlockId::Number(height), - ) - .await - .unwrap_or(Value::Null); + let receipts = + operations::eth_get_block_receipts(&fb_client, operations::BlockId::Number(height)) + .await + .unwrap_or(Value::Null); println!( "Snapshotted height {height}: hash={} stateRoot={}", @@ -317,17 +387,18 @@ async fn fb_cache_correctness_test() { snapshot.push(json!({ "height": height, "block": block, "receipts": receipts })); } - assert!(!snapshot.is_empty(), "No flashblock cache entries found ahead of canonical height {canonical_height}"); + assert!( + !snapshot.is_empty(), + "No flashblock cache entries found ahead of canonical height {canonical_height}" + ); println!("Snapshotted {} block(s) from flashblock cache", snapshot.len()); let snapshot_target = snapshot.last().unwrap()["height"].as_u64().unwrap(); - // Step 4: write snapshot to file let snapshot_json = serde_json::to_string_pretty(&json!(snapshot)).expect("Failed to serialize snapshot"); fs::write(SNAPSHOT_FILE, &snapshot_json).expect("Failed to write snapshot file"); println!("Snapshot written to {SNAPSHOT_FILE} ({} bytes)", snapshot_json.len()); - // Step 5: wait for the non-FB canonical node to reach snapshot_target println!("Waiting for canonical node to reach height {snapshot_target}..."); tokio::time::timeout(CATCHUP_TIMEOUT, async { loop { @@ -342,7 +413,6 @@ async fn fb_cache_correctness_test() { .await .expect("Timed out waiting for canonical node to catch up"); - // Step 6: read snapshot and compare each block against canonical let saved: Vec = serde_json::from_str(&fs::read_to_string(SNAPSHOT_FILE).expect("Failed to read snapshot")) .expect("Failed to parse snapshot"); diff --git a/crates/tests/operations/eth_rpc.rs b/crates/tests/operations/eth_rpc.rs index 5b1c7635..3721ccf8 100644 --- a/crates/tests/operations/eth_rpc.rs +++ b/crates/tests/operations/eth_rpc.rs @@ -381,21 +381,21 @@ pub async fn eth_get_raw_transaction_by_block_number_and_index( ) -> Result { let result: Value = tokio::time::timeout( RPC_TIMEOUT, - client_rpc.request("eth_getRawTransactionByBlockNumberAndIndex", jsonrpsee::rpc_params![block_number, index]), + client_rpc.request( + "eth_getRawTransactionByBlockNumberAndIndex", + jsonrpsee::rpc_params![block_number, index], + ), ) .await??; Ok(result) } /// For eth_sendRawTransactionSync -pub async fn eth_send_raw_transaction_sync( - client_rpc: &HttpClient, - raw_tx: &str, -) -> Result { +pub async fn eth_send_raw_transaction_sync(client_rpc: &HttpClient, raw_tx: &str) -> Result { let result: Value = tokio::time::timeout( RPC_TIMEOUT, client_rpc.request("eth_sendRawTransactionSync", jsonrpsee::rpc_params![raw_tx]), ) .await??; Ok(result) -} \ No newline at end of file +} diff --git a/crates/tests/operations/utils.rs b/crates/tests/operations/utils.rs index 2f096fb4..e33266b3 100644 --- a/crates/tests/operations/utils.rs +++ b/crates/tests/operations/utils.rs @@ -86,7 +86,8 @@ pub async fn sign_raw_transaction( ) -> Result { let signer = PrivateKeySigner::from_str(DEFAULT_RICH_PRIVATE_KEY.trim_start_matches("0x"))?; let wallet = EthereumWallet::from(signer.clone()); - let provider = ProviderBuilder::new().wallet(wallet.clone()).connect_http(endpoint_url.parse()?); + let provider = + ProviderBuilder::new().wallet(wallet.clone()).connect_http(endpoint_url.parse()?); let from = signer.address(); let to = Address::from_str(to_address)?; From 72c0976d2faa06ca56868dab98933af10e638bda Mon Sep 17 00:00:00 2001 From: brendontan03 Date: Fri, 27 Mar 2026 15:04:49 +0800 Subject: [PATCH 3/3] chore: fix formatting --- crates/flashblocks/src/cache/mod.rs | 7 +----- crates/flashblocks/src/test_utils.rs | 6 ----- crates/tests/flashblocks-tests/main.rs | 31 +++++--------------------- crates/tests/operations/eth_rpc.rs | 14 ------------ crates/tests/operations/utils.rs | 2 +- 5 files changed, 8 insertions(+), 52 deletions(-) diff --git a/crates/flashblocks/src/cache/mod.rs b/crates/flashblocks/src/cache/mod.rs index cfcf3fb9..06505d7f 100644 --- a/crates/flashblocks/src/cache/mod.rs +++ b/crates/flashblocks/src/cache/mod.rs @@ -617,7 +617,6 @@ mod tests { inner.handle_pending_sequence(seq2, 0).unwrap(); assert!(inner.pending_cache.is_some()); - // Block hash should have changed due to different parent hash assert_ne!(seq1_hash, seq2_hash); assert_eq!(inner.pending_cache.as_ref().unwrap().block_hash, seq2_hash); assert_eq!(inner.confirm_height, 0); @@ -629,7 +628,6 @@ mod tests { let seq1 = make_pending_sequence(1, B256::ZERO); inner.handle_pending_sequence(seq1, 0).unwrap(); - // Advance to height 2 — seq at height 1 should be committed to confirm let seq2 = make_pending_sequence(2, B256::repeat_byte(0xBB)); inner.handle_pending_sequence(seq2, 0).unwrap(); @@ -685,7 +683,7 @@ mod tests { assert_eq!(inner.pending_cache.as_ref().unwrap().get_height(), 5); let flushed = inner.handle_canonical_block((2, B256::repeat_byte(0xFF)), false); - assert!(!flushed); // No full flush (pending at 5 > canon 2) + assert!(!flushed); assert_eq!(inner.canon_info.0, 2); assert!(inner.confirm_cache.get_block_by_number(1).is_none()); assert!(inner.confirm_cache.get_block_by_number(2).is_none()); @@ -696,11 +694,9 @@ mod tests { #[test] fn test_handle_canonical_flush_on_pending_stale() { let mut inner = TestInner::new(); - // Insert pending at height 1 let seq = make_pending_sequence(1, B256::ZERO); inner.handle_pending_sequence(seq, 0).unwrap(); - // Canonical catches up to height 1 — pending is stale let flushed = inner.handle_canonical_block((1, B256::repeat_byte(0xCC)), false); assert!(flushed); assert!(inner.pending_cache.is_none()); @@ -713,7 +709,6 @@ mod tests { let seq = make_pending_sequence(1, B256::ZERO); inner.handle_pending_sequence(seq, 0).unwrap(); - // Even if pending is ahead, reorg flag forces full flush let flushed = inner.handle_canonical_block((0, B256::repeat_byte(0xDD)), true); assert!(flushed); assert!(inner.pending_cache.is_none()); diff --git a/crates/flashblocks/src/test_utils.rs b/crates/flashblocks/src/test_utils.rs index f7026d75..3fc6c385 100644 --- a/crates/flashblocks/src/test_utils.rs +++ b/crates/flashblocks/src/test_utils.rs @@ -258,12 +258,6 @@ impl TestFlashBlockBuilder { self } - #[allow(dead_code)] - pub(crate) fn blob_gas_used(mut self, blob_gas_used: u64) -> Self { - self.blob_gas_used = Some(blob_gas_used); - self - } - pub(crate) fn build(mut self) -> OpFlashblockPayload { // Auto-create base for index 0 if not set if self.index == 0 && self.base.is_none() { diff --git a/crates/tests/flashblocks-tests/main.rs b/crates/tests/flashblocks-tests/main.rs index 0405ca04..db11f912 100644 --- a/crates/tests/flashblocks-tests/main.rs +++ b/crates/tests/flashblocks-tests/main.rs @@ -200,15 +200,6 @@ async fn fb_smoke_test() { .await .expect("Pending eth_getBlockReceipts failed"); - println!( - "fb_block['hash']: {}", - fb_block["hash"].as_str().expect("Block hash should not be empty") - ); - println!( - "fb_block['number']: {}", - fb_block["number"].as_str().expect("Block number should not be empty") - ); - // eth_getRawTransactionByBlockNumberAndIndex let fb_raw_transaction_by_block_number_and_index = operations::eth_get_raw_transaction_by_block_number_and_index( @@ -300,18 +291,7 @@ async fn fb_negative_test() { let result = operations::eth_call(&fb_client, Some(call_args), Some(operations::BlockId::Pending)).await; - - match result { - Err(e) => { - println!("eth_call reverted as expected: {e}"); - } - Ok(data) => { - // Some contracts may return empty data for unknown selectors rather than reverting - println!( - "eth_call returned data (contract may not revert on unknown selector): {data}" - ); - } - } + assert!(result.is_err(), "eth_call with invalid selector should revert, got: {result:?}"); } /// Cache correctness test: snapshots all confirmed flashblock cache entries currently ahead @@ -348,10 +328,11 @@ async fn fb_cache_correctness_test() { if let Some(n) = pending["number"] .as_str() .and_then(|s| u64::from_str_radix(s.trim_start_matches("0x"), 16).ok()) - && n > canonical_height + 1 { - println!("Flashblock pending height: {n}"); - return n; - } + && n > canonical_height + 1 + { + println!("Flashblock pending height: {n}"); + return n; + } tokio::time::sleep(POLL_INTERVAL).await; } }) diff --git a/crates/tests/operations/eth_rpc.rs b/crates/tests/operations/eth_rpc.rs index 3721ccf8..907d5f3f 100644 --- a/crates/tests/operations/eth_rpc.rs +++ b/crates/tests/operations/eth_rpc.rs @@ -359,20 +359,6 @@ pub async fn eth_flashblocks_enabled(client_rpc: &HttpClient) -> Result { Ok(result) } -// /// For eth_getRawTransactionByBlockHashAndIndex -// pub async fn eth_get_raw_transaction_by_block_hash_and_index( -// client_rpc: &HttpClient, -// block_hash: &str, -// index: &str, -// ) -> Result { -// let result: Value = tokio::time::timeout( -// RPC_TIMEOUT, -// client_rpc.request("eth_getRawTransactionByBlockHashAndIndex", jsonrpsee::rpc_params![block_hash, index]), -// ) -// .await??; -// Ok(result) -// } - /// For eth_getRawTransactionByBlockNumberAndIndex pub async fn eth_get_raw_transaction_by_block_number_and_index( client_rpc: &HttpClient, diff --git a/crates/tests/operations/utils.rs b/crates/tests/operations/utils.rs index e33266b3..73e3e705 100644 --- a/crates/tests/operations/utils.rs +++ b/crates/tests/operations/utils.rs @@ -77,7 +77,7 @@ pub async fn native_balance_transfer( Ok(format!("{tx_hash:#x}")) } -/// Sign a native transfer transaction and return its raw EIP-2718 encoded bytes as a hex string, +/// Sign a native transfer transaction and return its raw encoded bytes as a hex string, /// without broadcasting it to the network. pub async fn sign_raw_transaction( endpoint_url: &str,