diff --git a/examples/checkpoints-eth.rs b/examples/checkpoints-eth.rs index 1368465..7344bd5 100644 --- a/examples/checkpoints-eth.rs +++ b/examples/checkpoints-eth.rs @@ -4,22 +4,11 @@ //! test-utils provided helpers for creating mock instances. use { - alloy::{ - consensus::{EthereumTxEnvelope, Transaction, TxEip4844}, - network::{TransactionBuilder, TxSignerSync}, - primitives::{Address, U256}, - signers::local::PrivateKeySigner, - }, + alloy::{consensus::Transaction, primitives::U256}, rblib::{ alloy, prelude::*, - reth, - test_utils::{BlockContextMocked, FundedAccounts}, - }, - reth::{ - ethereum::{TransactionSigned, primitives::SignedTransaction}, - primitives::Recovered, - rpc::types::TransactionRequest, + test_utils::{BlockContextMocked, FundedAccounts, transfer_tx}, }, }; @@ -69,27 +58,3 @@ fn main() -> eyre::Result<()> { Ok(()) } - -fn transfer_tx( - signer: &PrivateKeySigner, - nonce: u64, - value: U256, -) -> Recovered> { - let mut tx = TransactionRequest::default() - .with_nonce(nonce) - .with_to(Address::random()) - .value(value) - .with_gas_price(1_000_000_000) - .with_gas_limit(21_000) - .with_max_priority_fee_per_gas(1_000_000) - .with_max_fee_per_gas(2_000_000) - .build_unsigned() - .expect("valid transaction request"); - - let sig = signer - .sign_transaction_sync(&mut tx) - .expect("signing should succeed"); - - TransactionSigned::new_unhashed(tx.into(), sig) // - .with_signer(signer.address()) -} diff --git a/src/payload/checkpoint.rs b/src/payload/checkpoint.rs index 2ad0ea5..68eab10 100644 --- a/src/payload/checkpoint.rs +++ b/src/payload/checkpoint.rs @@ -546,3 +546,80 @@ impl Display for Checkpoint

{ } } } + +#[cfg(test)] +mod tests { + use { + crate::{ + alloy::primitives::U256, + prelude::{BlockContext, Ethereum}, + test_utils::{BlockContextMocked, FundedAccounts, transfer_tx}, + }, + std::time::Instant, + }; + + #[test] + fn test_barrier_depth_and_is_barrier() { + let block = BlockContext::::mocked(); + + let checkpoint = block.start(); + + // Expected: initial checkpoint is depth 0 and is barrier + assert_eq!(checkpoint.depth(), 0); + assert!(checkpoint.is_barrier()); + assert!(checkpoint.prev().is_none()); + } + + #[test] + fn test_named_barrier_and_prev_depth() { + // Outline: + // 1. create initial checkpoint (depth 0) + // 2. create named barrier on top + // 3. verify new depth is 1, prev is initial, and is_named_barrier returns + // true + let block = BlockContext::::mocked(); + + let root = block.start(); + + let named = root.barrier_with_tag("sequencer-synced"); + + assert_eq!(named.depth(), root.depth() + 1); + assert_eq!(named.tag(), Some("sequencer-synced")); + assert!(named.prev().is_some()); + assert_eq!(named.prev().unwrap().depth(), root.depth()); + } + + #[test] + fn test_created_at() { + let block = BlockContext::::mocked(); + + let before = Instant::now(); + let cp = block.start(); + let after = Instant::now(); + assert!((before..=after).contains(&cp.created_at())); + } + + #[test] + fn test_iter() { + let block = BlockContext::::mocked(); + + let checkpoint = block.start(); + + let checkpoint2 = checkpoint.barrier(); + let checkpoint3 = checkpoint2.barrier(); + + let tx = transfer_tx(&FundedAccounts::signer(0), 0, U256::from(10u64)); + let checkpoint4 = checkpoint3.apply(tx).unwrap(); + + let history: Vec<_> = checkpoint4.into_iter().collect(); + assert_eq!(history.len(), 4); + assert_eq!(history[0], checkpoint4); + assert_eq!(history[0].depth(), 3); + assert_eq!(history[1], checkpoint3); + assert_eq!(history[1].depth(), 2); + assert_eq!(history[2], checkpoint2); + assert_eq!(history[2].depth(), 1); + assert_eq!(history[3], checkpoint); + assert_eq!(history[3].depth(), 0); + } +} diff --git a/src/payload/ext/checkpoint.rs b/src/payload/ext/checkpoint.rs index a40657e..27b33e1 100644 --- a/src/payload/ext/checkpoint.rs +++ b/src/payload/ext/checkpoint.rs @@ -327,3 +327,237 @@ impl CheckpointExt

for Checkpoint

{ self.root().created_at() } } + +#[cfg(test)] +mod tests { + use { + crate::{ + alloy::primitives::{Address, U256}, + payload::{Checkpoint, CheckpointExt}, + prelude::{BlockContext, Ethereum}, + test_utils::{BlockContextMocked, FundedAccounts, transfer_tx}, + }, + std::{ + thread, + time::{Duration, Instant}, + }, + }; + + #[test] + fn test_new_at_block() { + let block = BlockContext::::mocked(); + let cp = Checkpoint::new_at_block(block); + + let cp2 = cp.barrier(); + let cp3 = cp2.barrier(); + + assert!(cp.is_empty()); + assert_eq!(cp2.root(), cp); + assert_eq!(cp3.root(), cp); + + assert_eq!(cp.gas_used(), 0); + assert_eq!(cp.cumulative_gas_used(), 0); + + assert_eq!(cp.effective_tip_per_gas(), 0); + assert!(!cp.has_blobs()); + assert_eq!(cp.blob_gas_used(), Some(0)); + assert_eq!(cp.cumulative_blob_gas_used(), 0); + + let span1 = cp3.to(&cp).unwrap(); + let span2 = cp.to(&cp3).unwrap(); + + assert_eq!(span1.len(), span2.len()); + for i in 0..span2.len() { + assert_eq!(span1.at(i), span2.at(i)); + } + assert_eq!(span1.len(), 3); + assert_eq!(span2.len(), 3); + + let addr = Address::ZERO; + assert_eq!(cp.balance_of(addr).unwrap(), U256::ZERO); + assert_eq!(cp.nonce_of(addr).unwrap(), 0); + + assert!(cp.signers().is_empty()); + assert!(cp.nonces().is_empty()); + + assert_eq!(cp.hash(), None); + assert!(!cp.is_bundle()); + assert!(!cp.has_failures()); + assert_eq!(cp.failed_txs().count(), 0); + + let random_addr = Address::random(); + assert_eq!( + cp.balance_of(random_addr).unwrap(), + U256::ZERO, + "Nonexistent account should have zero balance" + ); + + assert_eq!( + cp.nonce_of(random_addr).unwrap(), + 0, + "Nonexistent account should have zero nonce" + ); + } + + #[test] + fn test_contains_is_false_without_txs() { + let block = BlockContext::::mocked(); + let cp1 = Checkpoint::new_at_block(block); + + let tx1 = transfer_tx(&FundedAccounts::signer(0), 0, U256::from(50_000u64)); + let tx1_hash = *tx1.hash(); + assert!(!cp1.contains(tx1_hash)); + let cp2 = cp1.apply(tx1).unwrap(); + + assert!(cp2.contains(tx1_hash)); + } + + #[test] + fn test_history_timestamps() { + let block = BlockContext::::mocked(); + let cp1 = Checkpoint::new_at_block(block); + + thread::sleep(Duration::from_millis(5)); + + let cp2 = cp1.barrier(); + + assert!(cp2.building_since() <= Instant::now()); + assert!(cp2.building_since() >= cp1.created_at()); + } + + #[test] + fn test_to_self() { + let block = BlockContext::::mocked(); + let cp = Checkpoint::new_at_block(block); + + // to(self, self) should produce a span of length 1 containing the + // checkpoint itself + let span = cp.to(&cp).expect("to(self,self) must succeed"); + assert_eq!(span.len(), 1); + assert_eq!(*span.at(0).unwrap(), cp); + } + + #[test] + fn test_to_non_linear_error() { + let block_a = BlockContext::::mocked(); + let block_b = BlockContext::::mocked(); + + let cp_a = Checkpoint::new_at_block(block_a); + let cp_b = Checkpoint::new_at_block(block_b); + + // They are not on the same linear history, so to should return an Err. + assert!(cp_a.to(&cp_b).is_err()); + assert!(cp_b.to(&cp_a).is_err()); + } + + #[test] + fn test_to_includes_all_intermediates_and_is_linear() { + let block = BlockContext::::mocked(); + let base = Checkpoint::new_at_block(block); + + // base -> x -> y + let tx_x = transfer_tx(&FundedAccounts::signer(0), 0, U256::from(10u64)); + let x = base.apply(tx_x).unwrap(); + + let tx_y = transfer_tx(&FundedAccounts::signer(1), 0, U256::from(20u64)); + let y = x.apply(tx_y).unwrap(); + + let x_barrier = x.barrier(); + let y_barrier = y.barrier(); + + // `to` between base and y_barrier should include `base``, `x` (or + // x_barrier), `y` (or y_barrier) + let span_by = y_barrier + .to(&base) + .expect("to should succeed for linear history"); + let collected: Vec> = (0..span_by.len()) + .map(|i| span_by.at(i).unwrap().clone()) + .collect(); + + assert!(collected.contains(&base)); + assert!(collected.contains(&y_barrier)); + assert!(collected.iter().any(|cp| *cp == x || *cp == x_barrier)); + } + + #[test] + fn test_to_different_roots_error() { + let block1 = BlockContext::::mocked(); + let block2 = BlockContext::::mocked(); + + let root1 = Checkpoint::new_at_block(block1); + let root2 = Checkpoint::new_at_block(block2); + + let tx = transfer_tx(&FundedAccounts::signer(0), 0, U256::from(5u64)); + let root1_child = root1.apply(tx).unwrap(); + + assert!(root1_child.to(&root2).is_err()); + assert!(root2.to(&root1_child).is_err()); + } + + #[test] + fn test_effective_tip_checkpoint() { + let block = BlockContext::::mocked(); + let cp = Checkpoint::new_at_block(block); + assert_eq!( + cp.effective_tip_per_gas(), + 0, + "Empty checkpoint should have zero tip" + ); + + let tx = transfer_tx(&FundedAccounts::signer(0), 0, U256::from(100u64)); + let cp2 = cp.apply(tx.clone()).unwrap(); + + let tip = cp2.effective_tip_per_gas(); + assert!(tip > 0, "Transaction should have positive effective tip"); + } + + #[test] + fn test_history_staging_no_barrier() { + let block = BlockContext::::mocked(); + let base = Checkpoint::new_at_block(block); + + let tx1 = transfer_tx(&FundedAccounts::signer(0), 0, U256::from(50u64)); + let cp1 = base.apply(tx1).unwrap(); + + let tx2 = transfer_tx(&FundedAccounts::signer(1), 0, U256::from(75u64)); + let cp2 = cp1.apply(tx2).unwrap(); + + let staging = cp2.history_staging(); + let full = cp2.history(); + + assert_eq!( + staging.len(), + full.len(), + "Without barriers, staging should equal full history" + ); + } + + #[test] + fn test_history_staging_with_barrier() { + let block = BlockContext::::mocked(); + let base = Checkpoint::new_at_block(block); + + let tx1 = transfer_tx(&FundedAccounts::signer(0), 0, U256::from(50u64)); + let cp1 = base.apply(tx1).unwrap(); + + let barrier = cp1.barrier(); + + let tx2 = transfer_tx(&FundedAccounts::signer(1), 0, U256::from(75u64)); + let cp2 = barrier.apply(tx2).unwrap(); + + let staging = cp2.history_staging(); + + // Staging should only include checkpoints after the barrier + assert!( + staging.len() < cp2.history().len(), + "Staging should be shorter than full history" + ); + let sealed = cp2.history_sealed(); + + // Sealed should include everything up to and including the barrier + assert!( + !sealed.is_empty(), + "Sealed should include checkpoints up to barrier" + ); + } +} diff --git a/src/test_utils/mod.rs b/src/test_utils/mod.rs index ee31cf9..7944f6b 100644 --- a/src/test_utils/mod.rs +++ b/src/test_utils/mod.rs @@ -20,6 +20,7 @@ mod mock; mod node; mod platform; mod step; +mod transactions; #[allow(unused_imports)] pub(crate) use step::fake_step; @@ -32,6 +33,7 @@ pub use { platform::{TestNodeFactory, TestablePlatform}, rblib_tests_macros::{assert_is_dyn_safe, if_platform, rblib_test}, step::{AlwaysBreakStep, AlwaysFailStep, AlwaysOkStep, OneStep}, + transactions::transfer_tx, }; #[cfg(feature = "optimism")] diff --git a/src/test_utils/transactions.rs b/src/test_utils/transactions.rs new file mode 100644 index 0000000..a0db458 --- /dev/null +++ b/src/test_utils/transactions.rs @@ -0,0 +1,39 @@ +use { + crate::{alloy, reth}, + alloy::{ + consensus::{EthereumTxEnvelope, TxEip4844}, + network::{TransactionBuilder, TxSignerSync}, + primitives::{Address, U256}, + signers::local::PrivateKeySigner, + }, + reth::{ + ethereum::{TransactionSigned, primitives::SignedTransaction}, + primitives::Recovered, + rpc::types::TransactionRequest, + }, +}; + +#[allow(clippy::missing_panics_doc)] +pub fn transfer_tx( + signer: &PrivateKeySigner, + nonce: u64, + value: U256, +) -> Recovered> { + let mut tx = TransactionRequest::default() + .with_nonce(nonce) + .with_to(Address::random()) + .value(value) + .with_gas_price(1_000_000_000) + .with_gas_limit(21_000) + .with_max_priority_fee_per_gas(1_000_000) + .with_max_fee_per_gas(2_000_000) + .build_unsigned() + .expect("valid transaction request"); + + let sig = signer + .sign_transaction_sync(&mut tx) + .expect("signing should succeed"); + + TransactionSigned::new_unhashed(tx.into(), sig) // + .with_signer(signer.address()) +}