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..06505d7f 100644 --- a/crates/flashblocks/src/cache/mod.rs +++ b/crates/flashblocks/src/cache/mod.rs @@ -560,3 +560,352 @@ 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_chain_state::CanonicalInMemoryState; + use reth_optimism_primitives::OpPrimitives; + + type TestCache = FlashblockStateCache; + type TestInner = FlashblockStateCacheInner; + + fn make_test_cache() -> TestCache { + TestCache::new(CanonicalInMemoryState::new( + Default::default(), + Default::default(), + None, + None, + None, + )) + } + + #[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()); + } + + #[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, 0).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, 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, 0).unwrap(); + + assert!(inner.pending_cache.is_some()); + assert_ne!(seq1_hash, seq2_hash); + assert_eq!(inner.pending_cache.as_ref().unwrap().block_hash, seq2_hash); + assert_eq!(inner.confirm_height, 0); + } + + #[test] + fn test_handle_pending_advance_commits_to_confirm() { + let mut inner = TestInner::new(); + let seq1 = make_pending_sequence(1, B256::ZERO); + inner.handle_pending_sequence(seq1, 0).unwrap(); + + let seq2 = make_pending_sequence(2, B256::repeat_byte(0xBB)); + inner.handle_pending_sequence(seq2, 0).unwrap(); + + assert_eq!(inner.confirm_height, 1); + assert_eq!(inner.pending_cache.as_ref().unwrap().get_height(), 2); + 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(); + let seq = make_pending_sequence(2, B256::ZERO); + let result = inner.handle_pending_sequence(seq, 0); + 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(); + let seq = make_pending_sequence(5, B256::ZERO); + let result = inner.handle_pending_sequence(seq, 0); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("not next consecutive pending height block")); + } + + #[test] + fn test_handle_confirmed_block_non_consecutive_errors() { + let mut inner = TestInner::new(); + 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")); + } + + #[test] + fn test_handle_canonical_evicts_confirm() { + let mut inner = TestInner::new(); + for i in 1..=5 { + let seq = make_pending_sequence(i, B256::repeat_byte(i as u8)); + inner.handle_pending_sequence(seq, 0).unwrap(); + } + assert_eq!(inner.confirm_height, 4); + 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); + 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()); + 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(); + let seq = make_pending_sequence(1, B256::ZERO); + inner.handle_pending_sequence(seq, 0).unwrap(); + + 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, 0).unwrap(); + + let flushed = inner.handle_canonical_block((0, B256::repeat_byte(0xDD)), true); + assert!(flushed); + assert!(inner.pending_cache.is_none()); + } + + #[test] + fn test_get_block_by_number_pending_priority() { + 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)), 0) + .unwrap(); + + 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, 0).unwrap(); + + 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 = make_test_cache(); + let seq1 = make_pending_sequence(1, B256::ZERO); + let seq1_hash = seq1.block_hash; + cache.handle_pending_sequence(seq1, 0).unwrap(); + cache + .handle_pending_sequence(make_pending_sequence(2, B256::repeat_byte(0x01)), 0) + .unwrap(); + + 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 = make_test_cache(); + let seq = make_pending_sequence(1, B256::ZERO); + let pending_hash = seq.block_hash; + cache.handle_pending_sequence(seq, 0).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 = make_test_cache(); + let seq1 = make_pending_sequence(1, B256::ZERO); + let seq1_hash = seq1.block_hash; + cache.handle_pending_sequence(seq1, 0).unwrap(); + cache + .handle_pending_sequence(make_pending_sequence(2, B256::repeat_byte(0x01)), 0) + .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 = make_test_cache(); + let seq = make_pending_sequence(1, B256::ZERO); + let pending_hash = seq.block_hash; + cache.handle_pending_sequence(seq, 0).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 = 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, 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, 0).unwrap(); + + let (info, _) = cache.get_tx_info(&pending_tx_hash).unwrap(); + assert_eq!(info.block_number, 2); + + let (info, _) = cache.get_tx_info(&confirm_tx_hash).unwrap(); + assert_eq!(info.block_number, 1); + + assert!(cache.get_tx_info(&B256::repeat_byte(0xFF)).is_none()); + } + + #[test] + fn test_get_executed_blocks_returns_none_when_uninitialized() { + let inner = TestInner::new(); + 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(); + inner.canon_info = (1, B256::repeat_byte(0x01)); + inner.confirm_height = 1; + + for i in 2..=4 { + let seq = make_pending_sequence(i, B256::repeat_byte(i as u8)); + inner.handle_pending_sequence(seq, 0).unwrap(); + } + + let blocks = inner.get_executed_blocks_up_to_height(4).unwrap().unwrap(); + assert_eq!(blocks.len(), 3); + assert_eq!(blocks[0].recovered_block.number(), 4); + } + + #[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; + + let result = inner.get_executed_blocks_up_to_height(5).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_subscribe_receives_update_on_pending_insert() { + 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, 0).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 = make_test_cache(); + let mut rx = cache.subscribe_pending_sequence(); + + 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, 0).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 = make_test_cache(); + let mut rx = cache.subscribe_pending_sequence(); + + 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, 0).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); + } + + #[test] + fn test_outer_handle_canonical_block_updates_canon_info() { + let cache = make_test_cache(); + let seq = make_pending_sequence(1, B256::ZERO); + cache.handle_pending_sequence(seq, 0).unwrap(); + cache + .handle_pending_sequence(make_pending_sequence(2, B256::repeat_byte(0x01)), 0) + .unwrap(); + + let canon_hash = B256::repeat_byte(0xCC); + cache.handle_canonical_block((1, canon_hash), false); + + assert_eq!(cache.get_canon_height(), 1); + } + + #[test] + fn test_flush_resets_confirm_height_to_canon() { + let mut inner = TestInner::new(); + inner.handle_pending_sequence(make_pending_sequence(1, B256::ZERO), 0).unwrap(); + + inner.handle_canonical_block((1, B256::repeat_byte(0xAA)), false); + + 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..3fc6c385 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 { @@ -294,3 +297,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..db11f912 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,24 @@ 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 +199,247 @@ async fn fb_smoke_test() { let _ = operations::eth_get_block_receipts(&fb_client, operations::BlockId::Pending) .await .expect("Pending eth_getBlockReceipts failed"); + + // 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" + ); +} + +/// 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; + assert!(result.is_err(), "eth_call with invalid selector should revert, got: {result:?}"); +} + +/// 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 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() { + 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); + + 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()) + && 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"); + + let confirmed_block = pending_number - 1; + let mut snapshot = Vec::new(); + for height in (canonical_height + 1)..=confirmed_block { + let block = operations::eth_get_block_by_number_or_hash( + &fb_client, + operations::BlockId::Number(height), + true, + ) + .await + .unwrap_or(Value::Null); + + 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(); + + 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()); + + 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"); + + 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..907d5f3f 100644 --- a/crates/tests/operations/eth_rpc.rs +++ b/crates/tests/operations/eth_rpc.rs @@ -358,3 +358,30 @@ pub async fn eth_flashblocks_enabled(client_rpc: &HttpClient) -> Result { .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) +} diff --git a/crates/tests/operations/utils.rs b/crates/tests/operations/utils.rs index fe265a88..73e3e705 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,35 @@ pub async fn native_balance_transfer( Ok(format!("{tx_hash:#x}")) } +/// 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, + 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,