diff --git a/Cargo.toml b/Cargo.toml index c68e9d7..c21a11c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ signet-zenith = { version = "0.13.0" } trevm = { version = "0.29", features = ["concurrent-db", "test-utils"] } -alloy = { version = "1.0.35", features = [ +alloy = { version = "1.0.37", features = [ "full", "json-rpc", "signer-aws", diff --git a/src/test_utils.rs b/src/test_utils.rs index c239366..8d7e8c7 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -14,15 +14,14 @@ use init4_bin_base::{ perms::OAuthConfig, utils::{calc::SlotCalculator, provider::ProviderConfig}, }; -use signet_constants::SignetSystemConstants; +use signet_constants::{SignetSystemConstants, pecorino}; use std::env; use std::str::FromStr; use trevm::revm::{context::BlockEnv, context_interface::block::BlobExcessGasAndPrice}; /// Sets up a block builder with test values pub fn setup_test_config() -> Result { - let config = BuilderConfig { - // host_chain_id: signet_constants::pecorino::HOST_CHAIN_ID, + let pecorino_config = BuilderConfig { host_rpc: "ws://host-rpc.pecorino.signet.sh" .parse::() .map(ProviderConfig::new) @@ -32,10 +31,10 @@ pub fn setup_test_config() -> Result { .unwrap() .try_into() .unwrap(), - flashbots_endpoint: Some("https://relay-sepolia.flashbots.net:443".parse().unwrap()), + flashbots_endpoint: Some("https://host-builder-rpc.pecorino.signet.sh".parse().unwrap()), quincey_url: "http://localhost:8080".into(), sequencer_key: None, - builder_key: env::var("SEPOLIA_ETH_PRIV_KEY") + builder_key: env::var("BUILDER_KEY") .unwrap_or_else(|_| B256::repeat_byte(0x42).to_string()), builder_port: 8080, builder_rewards_address: Address::default(), @@ -56,20 +55,21 @@ pub fn setup_test_config() -> Result { max_host_gas_coefficient: Some(80), constants: SignetSystemConstants::pecorino(), }; - Ok(config) + Ok(pecorino_config) } -/// Returns a new signed test transaction with the provided nonce, value, and mpfpg. -pub fn new_signed_tx( +/// Returns a new signed test transaction with the provided nonce, value, mpfpg, and max fee. +pub fn new_signed_tx_with_max_fee( wallet: &PrivateKeySigner, nonce: u64, value: U256, mpfpg: u128, + max_fee_per_gas: u128, ) -> Result { let tx = TxEip1559 { - chain_id: 11155111, + chain_id: pecorino::RU_CHAIN_ID, nonce, - max_fee_per_gas: 10_000_000, + max_fee_per_gas, max_priority_fee_per_gas: mpfpg, to: TxKind::Call(Address::from_str("0x0000000000000000000000000000000000000000").unwrap()), value, diff --git a/tests/block_builder_test.rs b/tests/block_builder_test.rs index 92adfef..c9b8cc1 100644 --- a/tests/block_builder_test.rs +++ b/tests/block_builder_test.rs @@ -11,19 +11,16 @@ use alloy::{ use builder::{ tasks::{ block::sim::Simulator, - env::{EnvTask, Environment, SimEnv}, + env::{Environment, SimEnv}, }, - test_utils::{new_signed_tx, setup_logging, setup_test_config, test_block_env}, + test_utils::{new_signed_tx_with_max_fee, setup_logging, setup_test_config, test_block_env}, }; use signet_sim::SimCache; use std::time::{Duration, Instant}; -/// Tests the `handle_build` method of the `Simulator`. -/// -/// This test sets up a simulated environment using Anvil, creates a block builder, -/// and verifies that the block builder can successfully build a block containing -/// transactions from multiple senders. -#[ignore = "integration test"] +mod harness; +use harness::TestHarness; + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_handle_build() { setup_logging(); @@ -41,30 +38,35 @@ async fn test_handle_build() { // Create a rollup provider let ru_provider = RootProvider::::new_http(anvil_instance.endpoint_url()); - let host_provider = config.connect_host_provider().await.unwrap(); - let block_env = - EnvTask::new(config.clone(), host_provider.clone(), ru_provider.clone()).spawn().0; + // Create a host provider + let host_provider = config.connect_host_provider().await.unwrap(); - let block_builder = - Simulator::new(&config, host_provider.clone(), ru_provider.clone(), block_env); + // Provide a dummy env receiver; this test calls handle_build directly and + // doesn't use the env watch channel. + let (_env_tx, env_rx) = tokio::sync::watch::channel(None); + let block_builder = Simulator::new(&config, host_provider, ru_provider.clone(), env_rx); // Setup a sim cache let sim_items = SimCache::new(); // Add two transactions from two senders to the sim cache - let tx_1 = new_signed_tx(&test_key_0, 0, U256::from(1_f64), 11_000).unwrap(); + let tx_1 = + new_signed_tx_with_max_fee(&test_key_0, 0, U256::from(1_u64), 11_000, 10_000_000).unwrap(); sim_items.add_tx(tx_1, 0); - let tx_2 = new_signed_tx(&test_key_1, 0, U256::from(2_f64), 10_000).unwrap(); + let tx_2 = + new_signed_tx_with_max_fee(&test_key_1, 0, U256::from(2_u64), 10_000, 10_000_000).unwrap(); sim_items.add_tx(tx_2, 0); // Setup the block envs let finish_by = Instant::now() + Duration::from_secs(2); let ru_header = ru_provider.get_block(BlockId::latest()).await.unwrap().unwrap().header.inner; - let number = ru_header.number + 1; - let timestamp = ru_header.timestamp + config.slot_calculator.slot_duration(); - let block_env = test_block_env(config, number, 7, timestamp); + let target_block_number = ru_header.number + 1; + let target_timestamp = ru_header.timestamp + config.slot_calculator.slot_duration(); + + assert!(target_timestamp > ru_header.timestamp); + let block_env = test_block_env(config, target_block_number, 7, target_timestamp); // Spawn the block builder task let sim_env = SimEnv { @@ -78,3 +80,179 @@ async fn test_handle_build() { assert!(got.is_ok()); assert!(got.unwrap().tx_count() == 2); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_harness_ticks_and_emits() { + setup_logging(); + + // Build harness + let mut h = TestHarness::new().await.unwrap(); + + // Prepare two senders and fund them if needed from anvil default accounts + let keys = h.rollup.anvil().keys(); + let test_key_0 = PrivateKeySigner::from_signing_key(keys[0].clone().into()); + + // Start simulator and tick a new SimEnv + h.start().await; + + // Add a transaction into the sim cache + h.add_tx(&test_key_0, 0, U256::from(1_u64), 11_000); + + // Tick using the latest rollup and host headers + h.mine_blocks(1).await.unwrap(); + + // Expect a SimResult. Use the harness slot duration plus a small buffer so + // we wait long enough for the simulator to complete heavy simulations. + let wait = Duration::from_secs(h.config.slot_calculator.slot_duration() + 5); + let got = h.recv_result(wait).await.expect("sim result"); + assert_eq!(got.block.tx_count(), 1); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_harness_simulates_full_flow() { + setup_logging(); + + // Build harness + let mut h = TestHarness::new().await.unwrap(); + + // Prepare two senders and fund them if needed from anvil default accounts + let keys = h.rollup.anvil().keys(); + let test_key_0 = PrivateKeySigner::from_signing_key(keys[0].clone().into()); + let test_key_1 = PrivateKeySigner::from_signing_key(keys[1].clone().into()); + + // Add two transactions into the sim cache + h.add_tx(&test_key_0, 0, U256::from(1_u64), 11_000); + h.add_tx(&test_key_1, 0, U256::from(2_u64), 10_000); + + // Start simulator and tick a new SimEnv + h.start().await; + + h.mine_blocks(1).await.unwrap(); + + // Expect a SimResult. Use the harness slot duration plus a small buffer. + let wait = Duration::from_secs(h.config.slot_calculator.slot_duration() + 5); + let got = h.recv_result(wait).await.expect("sim result"); + assert_eq!(got.block.tx_count(), 2); +} + +/// Ensure the harness can manually advance the Anvil chain. +// #[ignore = "integration test"] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_harness_advances_anvil_chain() { + setup_logging(); + let h = TestHarness::new().await.unwrap(); + + let (rollup, host) = h.get_headers().await.unwrap(); + + h.mine_blocks(2).await.unwrap(); + + let (new_rollup, new_host) = h.get_headers().await.unwrap(); + assert_eq!(new_rollup.number, rollup.number + 2); + assert_eq!(new_host.number, host.number + 2); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_harness_stops() { + setup_logging(); + let mut h = TestHarness::new().await.unwrap(); + + h.start().await; + + h.stop().await; + + assert_eq!(h.simulator_handle.is_none(), true); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_harness_timeout_without_results() { + setup_logging(); + let mut h = TestHarness::new().await.unwrap(); + + h.start().await; + + let wait = Duration::from_millis(250); + let got = h.recv_result(wait).await; + + h.stop().await; + + assert!(got.is_none(), "expected timeout when no blocks are mined"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_harness_start_is_idempotent() { + setup_logging(); + let mut h = TestHarness::new().await.unwrap(); + + h.start().await; + let first_id = h.simulator_handle.as_ref().expect("simulator handle").id(); + + h.start().await; + let second_id = h.simulator_handle.as_ref().expect("simulator handle").id(); + + h.stop().await; + + assert_eq!(first_id, second_id, "second start should reuse existing task"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_harness_stop_without_start() { + setup_logging(); + let mut h = TestHarness::new().await.unwrap(); + + h.stop().await; + + assert!(h.simulator_handle.is_none()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_harness_emits_multiple_results() { + setup_logging(); + let mut h = TestHarness::new().await.unwrap(); + + let keys = h.rollup.anvil().keys(); + let signer = PrivateKeySigner::from_signing_key(keys[0].clone().into()); + + // First tick uses a transaction added before the simulator starts. + h.add_tx(&signer, 0, U256::from(1_u64), 11_000); + h.start().await; + h.mine_blocks(1).await.unwrap(); + + let wait = Duration::from_secs(h.config.slot_calculator.slot_duration() + 5); + let first = h.recv_result(wait).await.expect("first sim result"); + assert_eq!(first.block.tx_count(), 1); + + // Second tick shouldn't need new transactions to emit a block. + h.mine_blocks(1).await.unwrap(); + let second = h.recv_result(wait).await.expect("second sim result"); + assert_eq!(second.block.tx_count(), 0); + assert_eq!(second.block.block_number(), first.block.block_number() + 1); + + h.stop().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_harness_result_matches_headers() { + setup_logging(); + let mut h = TestHarness::new().await.unwrap(); + + let keys = h.rollup.anvil().keys(); + let signer = PrivateKeySigner::from_signing_key(keys[0].clone().into()); + + // Capture the headers the harness should target. + let (prev_rollup, prev_host) = h.get_headers().await.unwrap(); + + h.add_tx(&signer, 0, U256::from(1_u64), 11_000); + h.start().await; + h.mine_blocks(1).await.unwrap(); + + let wait = Duration::from_secs(h.config.slot_calculator.slot_duration() + 5); + let got = h.recv_result(wait).await.expect("sim result"); + + assert_eq!(got.block.tx_count(), 1); + assert_eq!(got.rollup_block_number(), prev_rollup.number + 2); + assert_eq!(got.host_block_number(), prev_host.number + 2); + assert_eq!(got.prev_rollup().number, prev_rollup.number + 1); + assert_eq!(got.prev_host().number, prev_host.number + 1); + + h.stop().await; +} diff --git a/tests/harness.rs b/tests/harness.rs new file mode 100644 index 0000000..8341eaa --- /dev/null +++ b/tests/harness.rs @@ -0,0 +1,307 @@ +//! Test harness for end-to-end simulation flow. +//! +//! This harness wires a [`Simulator`] up to a manual [`SimEnv`] watch channel +//! and a submit channel so tests can manually control the simulation environment, +//! ticking along new blocks and asserting on the state of the simulator's results. +//! +//! It also manages two Anvil instances to simulate the rollup and host chains, +//! and provides utility functions for advancing blocks and adding transactions to +//! the simulator's mempool. + +use std::{ + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use alloy::{ + consensus::Header, + eips::{BlockId, eip1559::BaseFeeParams}, + network::{Ethereum, EthereumWallet}, + node_bindings::Anvil, + primitives::{B256, U256}, + providers::{ + Provider, ProviderBuilder, RootProvider, + ext::AnvilApi, + fillers::{BlobGasFiller, SimpleNonceManager}, + layers::AnvilProvider, + }, + signers::local::PrivateKeySigner, +}; +use builder::{ + config::{BuilderConfig, HostProvider}, + tasks::{ + block::sim::{SimResult, Simulator}, + env::{Environment, SimEnv}, + }, + test_utils::{new_signed_tx_with_max_fee, setup_logging, setup_test_config}, +}; +use init4_bin_base::utils::calc::SlotCalculator; +use signet_sim::SimCache; +use tokio::{ + sync::{mpsc, watch}, + task::JoinHandle, +}; +use trevm::revm::{context::BlockEnv, context_interface::block::BlobExcessGasAndPrice}; + +// Default test slot duration (seconds) +const DEFAULT_SLOT_DURATION: u64 = 5; // seconds +const TEST_TX_MAX_FEE: u128 = 1_000_000_000_000; + +pub struct SimulatorTask { + sim_env_tx: watch::Sender>, + sim_env_rx: watch::Receiver>, + sim_cache: SimCache, +} + +pub struct TestHarness { + /// Builder configuration for the Harness + pub config: BuilderConfig, + /// Anvil provider made from the Rollup Anvil instance + pub rollup: RollupAnvilProvider, + /// Anvil provider made from the Host Anvil instance + pub host: HostAnvilProvider, + /// The Simulator task that is assembled each tick. + pub simulator: SimulatorTask, + /// Transaction plumbing - Submit + pub submit_tx: mpsc::UnboundedSender, + /// Transaction plumbing - Receive + pub submit_rx: mpsc::UnboundedReceiver, + /// Keeps the simulator task alive for the duration of the harness so the + /// background task isn't aborted when `start()` returns. + pub simulator_handle: Option>, +} + +type HostAnvilProvider = AnvilProvider>; +type RollupAnvilProvider = AnvilProvider>; + +type RollupHeader = Header; +type HostHeader = Header; +type Headers = (RollupHeader, HostHeader); + +impl TestHarness { + /// Create a new harness with a fresh Anvil rollup chain and default test config. + pub async fn new() -> eyre::Result { + setup_logging(); + let mut config = setup_test_config()?; + configure_slot_timing(&mut config)?; + + // Spawn host and rollup anvil chains (providers + keeping anvil alive) + let host_anvil_provider = spawn_chain(signet_constants::pecorino::HOST_CHAIN_ID)?; + let rollup_anvil_provider = spawn_chain(signet_constants::pecorino::RU_CHAIN_ID)?; + + // Create a new sim cache. + let sim_cache = SimCache::new(); + + // Plumb the sim environment and submit channels + let (sim_env_tx, sim_env_rx) = watch::channel::>(None); + let (submit_tx, submit_rx) = mpsc::unbounded_channel::(); + + Ok(Self { + config, + rollup: rollup_anvil_provider, + host: host_anvil_provider, + simulator: SimulatorTask { sim_env_tx, sim_env_rx, sim_cache }, + submit_tx, + submit_rx, + simulator_handle: None, + }) + } + + /// Add a signed transaction from a provided signer to the sim cache. + pub fn add_tx(&self, signer: &PrivateKeySigner, nonce: u64, value: U256, mpfpg: u128) { + let tx = new_signed_tx_with_max_fee(signer, nonce, value, mpfpg, TEST_TX_MAX_FEE) + .expect("tx signing"); + // group index 0 for simplicity in tests + self.simulator.sim_cache.add_tx(tx, 0); + } + + /// Mine additional blocks on the underlying Anvil instance and update the sim_env with the latest headers. + pub async fn mine_blocks(&self, count: u64) -> eyre::Result<()> { + if count == 0 { + return Ok(()); + } + + self.host.anvil_mine(Some(count), Some(1)).await?; + self.rollup.anvil_mine(Some(count), Some(1)).await?; + + let (ru, host) = self.get_headers().await?; + self.tick_from_headers(ru, host).await; + + Ok(()) + } + + /// Starts the simulator task. + pub async fn start(&mut self) { + if self.simulator_handle.is_some() { + tracing::warn!("TestHarness simulator already running"); + return; + } + tracing::debug!("TestHarness starting simulator task"); + + // Spawn the simulator background task + let cache = self.simulator.sim_cache.clone(); + + // Rollup provider + let ru_provider = RootProvider::::new_http(self.rollup.anvil().endpoint_url()); + + // Host provider + let host_provider = self.host_provider().await; + + // Wire up the simulator with the providers and submit channels + let submit_tx = self.submit_tx.clone(); + let simulator = Simulator::new( + &self.config, + host_provider, + ru_provider, + self.simulator.sim_env_rx.clone(), + ); + + // Keep the JoinHandle on the harness so the task isn't aborted when + // this function returns. + let jh = simulator.spawn_simulator_task(cache, submit_tx); + self.simulator_handle = Some(jh); + tracing::debug!("TestHarness spawned simulator task"); + } + + /// Returns the latest rollup and host headers. + pub async fn get_headers(&self) -> eyre::Result { + let ru_block = self.rollup.get_block(BlockId::latest()).await?; + let ru_header = ru_block.expect("rollup latest exists").header.inner; + + let host_block = self.host.get_block(BlockId::latest()).await?; + let host_header = host_block.expect("host latest exists").header.inner; + + tracing::debug!( + rollup_header_number = ru_header.number, + rollup_header_gas_limit = ru_header.gas_limit + ); + + let headers = (ru_header, host_header); + Ok(headers) + } + + /// Tick a new `SimEnv` computed from the current latest rollup and host headers. + pub async fn tick_from_headers(&self, prev_ru_header: Header, prev_host_header: Header) { + // Set new simulation deadline and target block number from previous header + let target_ru_block_number = prev_ru_header.number + 1; + let deadline = prev_ru_header.timestamp + self.config.slot_calculator.slot_duration(); + + let span = tracing::info_span!( + "TestHarness::tick", + target_ru_block_number = target_ru_block_number, + deadline = deadline, + prev_ru_number = prev_ru_header.number, + prev_host_number = prev_host_header.number + ); + + let host_env = build_host_environment(&self.config, prev_host_header); + let rollup_env = build_rollup_environment(&self.config, prev_ru_header); + + self.simulator + .sim_env_tx + .send(Some(SimEnv { host: host_env, rollup: rollup_env, span })) + .expect("send sim_env environment"); + } + + /// Receive the next SimResult with a timeout. + pub async fn recv_result(&mut self, timeout: Duration) -> Option { + tracing::debug!(?timeout, "TestHarness waiting for sim result"); + let res = tokio::time::timeout(timeout, self.submit_rx.recv()).await.ok().flatten(); + tracing::debug!(received = res.is_some(), "TestHarness recv_result returning"); + res + } + + /// Stop the background simulator task if running. + pub async fn stop(&mut self) { + if let Some(handle) = self.simulator_handle.take() { + // Abort and give it a short period to finish cleanup. + handle.abort(); + let _ = tokio::time::timeout(Duration::from_millis(200), handle).await; + } + } + + /// Returns a host provider configured with the builder's wallet and blob gas params + async fn host_provider(&self) -> HostProvider { + let wallet = EthereumWallet::from( + self.config.connect_builder_signer().await.expect("builder signer"), + ); + + let host_provider_inner = + RootProvider::::new_http(self.host.anvil().endpoint_url()); + + ProviderBuilder::new_with_network() + .disable_recommended_fillers() + .filler(BlobGasFiller) + .with_gas_estimation() + .with_nonce_management(SimpleNonceManager::default()) + .fetch_chain_id() + .wallet(wallet) + .connect_provider(host_provider_inner) + } +} + +// This function sets the slot timing to start now with a 10 second slot duration for tests. +fn configure_slot_timing(config: &mut BuilderConfig) -> Result<(), eyre::Error> { + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + config.slot_calculator = SlotCalculator::new(now, 0, DEFAULT_SLOT_DURATION); + Ok(()) +} + +/// Builds a host environment from the given prev_header for the _next_ block. +fn build_host_environment(config: &BuilderConfig, prev_header: Header) -> Environment { + let block_env = BlockEnv { + number: U256::from(prev_header.number + 1), + beneficiary: config.builder_rewards_address, + timestamp: U256::from(prev_header.timestamp + config.slot_calculator.slot_duration()), + gas_limit: config.max_host_gas(prev_header.gas_limit), + basefee: prev_header + .next_block_base_fee(BaseFeeParams::ethereum()) + .expect("signet has no non-1559 headers"), + difficulty: U256::ZERO, + prevrandao: Some(B256::random()), + blob_excess_gas_and_price: Some(BlobExcessGasAndPrice { + excess_blob_gas: 0, + blob_gasprice: 0, + }), + }; + + Environment::new(block_env, prev_header) +} + +/// Builds a rollup environment from the given prev_header for the _next_ block. +fn build_rollup_environment(config: &BuilderConfig, prev_header: Header) -> Environment { + let block_env = BlockEnv { + number: U256::from(prev_header.number + 1), + beneficiary: config.builder_rewards_address, + timestamp: U256::from(prev_header.timestamp + config.slot_calculator.slot_duration()), + gas_limit: config.rollup_block_gas_limit, + basefee: prev_header + .next_block_base_fee(BaseFeeParams::ethereum()) + .expect("signet has no non-1559 headers"), + difficulty: U256::ZERO, + prevrandao: Some(B256::random()), + blob_excess_gas_and_price: Some(BlobExcessGasAndPrice { + excess_blob_gas: 0, + blob_gasprice: 0, + }), + }; + + Environment::new(block_env, prev_header) +} + +// Spawn an Anvil instance and return its provider and an AnvilProvider wrapper that +// keeps the Anvil process alive for the lifetime of the provider. +fn spawn_chain(chain_id: u64) -> eyre::Result>> { + let anvil = Anvil::new().chain_id(chain_id).spawn(); + let provider = RootProvider::::new_http(anvil.endpoint_url().clone()); + let anvil_provider = AnvilProvider::new(provider.clone(), Arc::new(anvil)); + Ok(anvil_provider) +} + +impl Drop for TestHarness { + fn drop(&mut self) { + if let Some(handle) = self.simulator_handle.take() { + handle.abort(); + } + } +} diff --git a/tests/tx_poller_test.rs b/tests/tx_poller_test.rs index e48338f..92be5dc 100644 --- a/tests/tx_poller_test.rs +++ b/tests/tx_poller_test.rs @@ -2,7 +2,7 @@ use alloy::{primitives::U256, signers::local::PrivateKeySigner}; use builder::{ config::BuilderConfig, tasks::cache::TxPoller, - test_utils::{new_signed_tx, setup_logging, setup_test_config}, + test_utils::{new_signed_tx_with_max_fee, setup_logging, setup_test_config}, }; // Import the refactored function use eyre::{Ok, Result}; @@ -34,7 +34,7 @@ async fn post_tx(config: &BuilderConfig) -> Result<()> { let client = reqwest::Client::new(); let wallet = PrivateKeySigner::random(); - let tx_envelope = new_signed_tx(&wallet, 1, U256::from(1), 10_000)?; + let tx_envelope = new_signed_tx_with_max_fee(&wallet, 1, U256::from(1), 10_000, 10_000_000)?; let url = format!("{}/transactions", config.tx_pool_url); let response = client.post(&url).json(&tx_envelope).send().await?;