From 9e2172111441d66d2cc7f89bf04f38dfce124bbf Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 8 Jan 2026 22:54:11 +0100 Subject: [PATCH 01/25] feat: add safe finalized block method --- src/robust_provider/builder.rs | 24 +++++++- src/robust_provider/provider.rs | 103 ++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/robust_provider/builder.rs b/src/robust_provider/builder.rs index eecc044..c5a77df 100644 --- a/src/robust_provider/builder.rs +++ b/src/robust_provider/builder.rs @@ -3,7 +3,9 @@ use std::{pin::Pin, time::Duration}; use alloy::{network::Network, providers::RootProvider}; use crate::robust_provider::{ - IntoRootProvider, RobustProvider, provider::Error, subscription::DEFAULT_RECONNECT_INTERVAL, + IntoRootProvider, RobustProvider, + provider::{DEFAULT_FINALIZATION_HEIGHT, Error}, + subscription::DEFAULT_RECONNECT_INTERVAL, }; type BoxedProviderFuture = Pin, Error>> + Send>>; @@ -32,6 +34,7 @@ pub struct RobustProviderBuilder> { min_delay: Duration, reconnect_interval: Duration, subscription_buffer_capacity: usize, + finalization_height: u64, } impl> RobustProviderBuilder { @@ -50,6 +53,7 @@ impl> RobustProviderBuilder { min_delay: DEFAULT_MIN_DELAY, reconnect_interval: DEFAULT_RECONNECT_INTERVAL, subscription_buffer_capacity: DEFAULT_SUBSCRIPTION_BUFFER_CAPACITY, + finalization_height: DEFAULT_FINALIZATION_HEIGHT, } } @@ -125,6 +129,23 @@ impl> RobustProviderBuilder { self } + /// Set the number of blocks required before finalization is expected. + /// + /// This is used by [`RobustProvider::get_safe_finalized_block`] and + /// [`RobustProvider::get_safe_finalized_block_number`] to determine whether + /// to query for the finalized block or fall back to genesis. + /// + /// If the chain height is less than this value, the safe finalized methods + /// will return the earliest block (genesis) instead of querying for the + /// finalized block, which may not exist on young chains. + /// + /// Default is [`DEFAULT_FINALIZATION_HEIGHT`] (64 blocks). + #[must_use] + pub fn finalization_height(mut self, height: u64) -> Self { + self.finalization_height = height; + self + } + /// Build the `RobustProvider`. /// /// Final builder method: consumes the builder and returns the built [`RobustProvider`]. @@ -160,6 +181,7 @@ impl> RobustProviderBuilder { min_delay: self.min_delay, reconnect_interval: self.reconnect_interval, subscription_buffer_capacity: self.subscription_buffer_capacity, + finalization_height: self.finalization_height, }) } } diff --git a/src/robust_provider/provider.rs b/src/robust_provider/provider.rs index 0e74f5c..bc68c0c 100644 --- a/src/robust_provider/provider.rs +++ b/src/robust_provider/provider.rs @@ -80,6 +80,14 @@ impl From for Error { } } +/// Default number of blocks required for finalization. +/// +/// This is used by [`RobustProvider::get_safe_finalized_block`] and +/// [`RobustProvider::get_safe_finalized_block_number`] to determine whether +/// the chain has reached sufficient height for finalization queries. +/// On Ethereum mainnet, finalization typically occurs after 64 blocks. +pub const DEFAULT_FINALIZATION_HEIGHT: u64 = 64; + /// Provider wrapper with built-in retry and timeout mechanisms. /// /// This wrapper around Alloy providers automatically handles retries, @@ -94,6 +102,7 @@ pub struct RobustProvider { pub(crate) min_delay: Duration, pub(crate) reconnect_interval: Duration, pub(crate) subscription_buffer_capacity: usize, + pub(crate) finalization_height: u64, } impl RobustProvider { @@ -215,6 +224,68 @@ impl RobustProvider { Ok(confirmed_block) } + /// Fetch the finalized block, falling back to genesis if finalization hasn't occurred yet. + /// + /// This method handles the behavioral difference between development nodes (like Anvil) and + /// production nodes when querying for finalized blocks on young chains. + /// + /// If the current chain height is less than + /// [`finalization_height`](RobustProviderBuilder::finalization_height) (default 64), this + /// method returns the earliest block (genesis) instead of querying for the finalized block, + /// which may not exist or behave inconsistently across node implementations. + /// + /// # Errors + /// + /// * [`Error::RpcError`] - if no fallback providers succeeded. + /// * [`Error::Timeout`] - if the overall operation timeout elapses (i.e. exceeds + /// `call_timeout`). + /// * [`Error::BlockNotFound`] - if the block was not found on-chain. + pub async fn get_safe_finalized_block(&self) -> Result { + let chain_height = self.get_block_number().await?; + + if chain_height < self.finalization_height { + debug!( + chain_height = chain_height, + finalization_height = self.finalization_height, + "Chain height below finalization threshold, returning earliest block" + ); + return self.get_block_by_number(BlockNumberOrTag::Earliest).await; + } + + self.get_block_by_number(BlockNumberOrTag::Finalized).await + } + + /// Fetch the finalized block number, falling back to 0 if finalization hasn't occurred yet. + /// + /// This method handles the behavioral difference between development nodes (like Anvil) and + /// production nodes when querying for finalized blocks on young chains. + /// + /// If the current chain height is less than + /// [`finalization_height`](RobustProviderBuilder::finalization_height) (default 64), this + /// method returns 0 instead of querying for the finalized block number, which may not exist + /// or behave inconsistently across node implementations. + /// + /// # Errors + /// + /// * [`Error::RpcError`] - if no fallback providers succeeded. + /// * [`Error::Timeout`] - if the overall operation timeout elapses (i.e. exceeds + /// `call_timeout`). + /// * [`Error::BlockNotFound`] - if the block was not found on-chain. + pub async fn get_safe_finalized_block_number(&self) -> Result { + let chain_height = self.get_block_number().await?; + + if chain_height < self.finalization_height { + debug!( + chain_height = chain_height, + finalization_height = self.finalization_height, + "Chain height below finalization threshold, returning 0" + ); + return Ok(0); + } + + self.get_block_number_by_id(BlockNumberOrTag::Finalized.into()).await + } + /// Fetch a block by [`BlockHash`] with retry and timeout. /// /// This is a wrapper function for [`Provider::get_block_by_hash`]. @@ -454,6 +525,7 @@ mod tests { min_delay: Duration::from_millis(min_delay), reconnect_interval: DEFAULT_RECONNECT_INTERVAL, subscription_buffer_capacity: DEFAULT_SUBSCRIPTION_BUFFER_CAPACITY, + finalization_height: DEFAULT_FINALIZATION_HEIGHT, } } @@ -804,4 +876,35 @@ mod tests { Ok(()) } + + // TODO: these need to be tested with live nodes + + // #[tokio::test] + // async fn test_get_safe_finalized_block_with_small_chain_height() -> anyhow::Result<()> { + // // With only genesis block, finalized block may not be available on real nodes + // // but Anvil returns block 0. Either way, get_safe_finalized_block should succeed. + // let (_anvil, robust, _alloy_provider) = setup_anvil_with_blocks(0).await?; + // + // let safe_finalized = robust.get_safe_finalized_block().await?; + // + // // Should return genesis or block 0 (Anvil behavior) + // assert_eq!(safe_finalized.header.number, 0); + // + // Ok(()) + // } + // + // #[tokio::test] + // async fn test_get_safe_finalized_block_number_with_small_chain_height() -> + // anyhow::Result<()> { // With only genesis block, finalized block number may not + // be available on real nodes // but Anvil returns 0. Either way, + // get_safe_finalized_block_number should succeed. let (_anvil, robust, + // _alloy_provider) = setup_anvil_with_blocks(0).await?; + // + // let safe_finalized_num = robust.get_safe_finalized_block_number().await?; + // + // // Should return 0 (either directly from Anvil or as fallback) + // assert_eq!(safe_finalized_num, 0); + // + // Ok(()) + // } } From 9c8aafb936dbe171788d3080abb540e2a34cde71 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 8 Jan 2026 23:14:15 +0100 Subject: [PATCH 02/25] feat: add comment about tests --- src/robust_provider/provider.rs | 59 ++++++++++++++++----------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/src/robust_provider/provider.rs b/src/robust_provider/provider.rs index bc68c0c..82e18f1 100644 --- a/src/robust_provider/provider.rs +++ b/src/robust_provider/provider.rs @@ -877,34 +877,33 @@ mod tests { Ok(()) } - // TODO: these need to be tested with live nodes - - // #[tokio::test] - // async fn test_get_safe_finalized_block_with_small_chain_height() -> anyhow::Result<()> { - // // With only genesis block, finalized block may not be available on real nodes - // // but Anvil returns block 0. Either way, get_safe_finalized_block should succeed. - // let (_anvil, robust, _alloy_provider) = setup_anvil_with_blocks(0).await?; - // - // let safe_finalized = robust.get_safe_finalized_block().await?; - // - // // Should return genesis or block 0 (Anvil behavior) - // assert_eq!(safe_finalized.header.number, 0); - // - // Ok(()) - // } - // - // #[tokio::test] - // async fn test_get_safe_finalized_block_number_with_small_chain_height() -> - // anyhow::Result<()> { // With only genesis block, finalized block number may not - // be available on real nodes // but Anvil returns 0. Either way, - // get_safe_finalized_block_number should succeed. let (_anvil, robust, - // _alloy_provider) = setup_anvil_with_blocks(0).await?; - // - // let safe_finalized_num = robust.get_safe_finalized_block_number().await?; - // - // // Should return 0 (either directly from Anvil or as fallback) - // assert_eq!(safe_finalized_num, 0); - // - // Ok(()) - // } + #[tokio::test] + #[ignore = "This currently passes but does not test against a 'real' node - see issue https://github.com/OpenZeppelin/Robust-Provider/issues/7 "] + async fn test_get_safe_finalized_block_with_small_chain_height() -> anyhow::Result<()> { + // With only genesis block, finalized block may not be available on real nodes + // but Anvil returns block 0. Either way, get_safe_finalized_block should succeed. + let (_anvil, robust, _alloy_provider) = setup_anvil_with_blocks(0).await?; + + let safe_finalized = robust.get_safe_finalized_block().await?; + + // Should return genesis or block 0 (Anvil behavior) + assert_eq!(safe_finalized.header.number, 0); + + Ok(()) + } + + #[tokio::test] + #[ignore = "This currently passes but does not test against a 'real' node - see issue https://github.com/OpenZeppelin/Robust-Provider/issues/7 "] + async fn test_get_safe_finalized_block_number_with_small_chain_height() -> anyhow::Result<()> { + // With only genesis block, finalized block number may not be available on real nodes + // but Anvil returns 0. Either way, get_safe_finalized_block_number should succeed. + let (_anvil, robust, _alloy_provider) = setup_anvil_with_blocks(0).await?; + + let safe_finalized_num = robust.get_safe_finalized_block_number().await?; + + // Should return 0 (either directly from Anvil or as fallback) + assert_eq!(safe_finalized_num, 0); + + Ok(()) + } } From 240557fb4bb2d0dc4b47f174a789b574b2c3543e Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 8 Jan 2026 23:20:15 +0100 Subject: [PATCH 03/25] fix: doc --- src/robust_provider/provider.rs | 4 ++-- src/robust_provider/subscription.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/robust_provider/provider.rs b/src/robust_provider/provider.rs index 82e18f1..0819eda 100644 --- a/src/robust_provider/provider.rs +++ b/src/robust_provider/provider.rs @@ -230,7 +230,7 @@ impl RobustProvider { /// production nodes when querying for finalized blocks on young chains. /// /// If the current chain height is less than - /// [`finalization_height`](RobustProviderBuilder::finalization_height) (default 64), this + /// [`finalization_height`](super::RobustProviderBuilder::finalization_height) (default 64), this /// method returns the earliest block (genesis) instead of querying for the finalized block, /// which may not exist or behave inconsistently across node implementations. /// @@ -261,7 +261,7 @@ impl RobustProvider { /// production nodes when querying for finalized blocks on young chains. /// /// If the current chain height is less than - /// [`finalization_height`](RobustProviderBuilder::finalization_height) (default 64), this + /// [`finalization_height`](super::RobustProviderBuilder::finalization_height) (default 64), this /// method returns 0 instead of querying for the finalized block number, which may not exist /// or behave inconsistently across node implementations. /// diff --git a/src/robust_provider/subscription.rs b/src/robust_provider/subscription.rs index 9a5cc8c..33f5508 100644 --- a/src/robust_provider/subscription.rs +++ b/src/robust_provider/subscription.rs @@ -131,8 +131,8 @@ impl RobustSubscription { /// Returns true if reconnection was successful, false if it's not time yet or if it failed. async fn try_reconnect_to_primary(&mut self, force: bool) -> bool { // Check if we should attempt reconnection - let should_reconnect = force || - match self.last_reconnect_attempt { + let should_reconnect = force + || match self.last_reconnect_attempt { None => false, Some(last_attempt) => { last_attempt.elapsed() >= self.robust_provider.reconnect_interval From 10196bd523734bf759db36c20ef556911c1ae84e Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 8 Jan 2026 23:21:59 +0100 Subject: [PATCH 04/25] fix: fmt --- src/robust_provider/provider.rs | 12 ++++++------ src/robust_provider/subscription.rs | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/robust_provider/provider.rs b/src/robust_provider/provider.rs index 0819eda..1066fd2 100644 --- a/src/robust_provider/provider.rs +++ b/src/robust_provider/provider.rs @@ -230,9 +230,9 @@ impl RobustProvider { /// production nodes when querying for finalized blocks on young chains. /// /// If the current chain height is less than - /// [`finalization_height`](super::RobustProviderBuilder::finalization_height) (default 64), this - /// method returns the earliest block (genesis) instead of querying for the finalized block, - /// which may not exist or behave inconsistently across node implementations. + /// [`finalization_height`](super::RobustProviderBuilder::finalization_height) (default 64), + /// this method returns the earliest block (genesis) instead of querying for the finalized + /// block, which may not exist or behave inconsistently across node implementations. /// /// # Errors /// @@ -261,9 +261,9 @@ impl RobustProvider { /// production nodes when querying for finalized blocks on young chains. /// /// If the current chain height is less than - /// [`finalization_height`](super::RobustProviderBuilder::finalization_height) (default 64), this - /// method returns 0 instead of querying for the finalized block number, which may not exist - /// or behave inconsistently across node implementations. + /// [`finalization_height`](super::RobustProviderBuilder::finalization_height) (default 64), + /// this method returns 0 instead of querying for the finalized block number, which may not + /// exist or behave inconsistently across node implementations. /// /// # Errors /// diff --git a/src/robust_provider/subscription.rs b/src/robust_provider/subscription.rs index 33f5508..9a5cc8c 100644 --- a/src/robust_provider/subscription.rs +++ b/src/robust_provider/subscription.rs @@ -131,8 +131,8 @@ impl RobustSubscription { /// Returns true if reconnection was successful, false if it's not time yet or if it failed. async fn try_reconnect_to_primary(&mut self, force: bool) -> bool { // Check if we should attempt reconnection - let should_reconnect = force - || match self.last_reconnect_attempt { + let should_reconnect = force || + match self.last_reconnect_attempt { None => false, Some(last_attempt) => { last_attempt.elapsed() >= self.robust_provider.reconnect_interval From 73a9090578cf37971396e5723a589075b5e1d8ea Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 13 Jan 2026 12:37:43 +0100 Subject: [PATCH 05/25] refactor: move tests to dedicated tests/ directory - Move integration tests from inline #[cfg(test)] modules to tests/ folder: - tests/common.rs - shared test utilities - tests/eth_namespace.rs - Ethereum RPC method tests - tests/custom_methods.rs - RobustProvider-specific method tests - tests/subscription.rs - WebSocket subscription tests - Keep internal unit tests in src/robust_provider/provider.rs (retry logic) - Export additional types needed by tests (DEFAULT_FINALIZATION_HEIGHT, SubscriptionError, RobustSubscriptionStream) --- src/lib.rs | 2 +- src/robust_provider/mod.rs | 7 +- src/robust_provider/provider.rs | 330 +----------- src/robust_provider/subscription.rs | 758 +------------------------- tests/common.rs | 45 ++ tests/custom_methods.rs | 85 +++ tests/eth_namespace.rs | 216 ++++++++ tests/subscription.rs | 800 ++++++++++++++++++++++++++++ 8 files changed, 1165 insertions(+), 1078 deletions(-) create mode 100644 tests/common.rs create mode 100644 tests/custom_methods.rs create mode 100644 tests/eth_namespace.rs create mode 100644 tests/subscription.rs diff --git a/src/lib.rs b/src/lib.rs index 36caee0..ad8ee5b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,6 @@ #[macro_use] pub mod macros; -mod robust_provider; +pub mod robust_provider; pub use robust_provider::*; diff --git a/src/robust_provider/mod.rs b/src/robust_provider/mod.rs index a7e6fa7..77ce97d 100644 --- a/src/robust_provider/mod.rs +++ b/src/robust_provider/mod.rs @@ -65,6 +65,9 @@ pub mod provider_conversion; pub mod subscription; pub use builder::*; -pub use provider::{Error, RobustProvider}; +pub use provider::{DEFAULT_FINALIZATION_HEIGHT, Error, RobustProvider}; pub use provider_conversion::{IntoRobustProvider, IntoRootProvider}; -pub use subscription::{DEFAULT_RECONNECT_INTERVAL, RobustSubscription}; +pub use subscription::{ + DEFAULT_RECONNECT_INTERVAL, Error as SubscriptionError, RobustSubscription, + RobustSubscriptionStream, +}; diff --git a/src/robust_provider/provider.rs b/src/robust_provider/provider.rs index 1066fd2..0e30feb 100644 --- a/src/robust_provider/provider.rs +++ b/src/robust_provider/provider.rs @@ -487,33 +487,16 @@ impl RobustProvider { mod tests { use super::*; use crate::robust_provider::{ - DEFAULT_SUBSCRIPTION_BUFFER_CAPACITY, RobustProviderBuilder, - builder::DEFAULT_SUBSCRIPTION_TIMEOUT, subscription::DEFAULT_RECONNECT_INTERVAL, + DEFAULT_SUBSCRIPTION_BUFFER_CAPACITY, builder::DEFAULT_SUBSCRIPTION_TIMEOUT, + subscription::DEFAULT_RECONNECT_INTERVAL, }; - use alloy::providers::{ProviderBuilder, WsConnect, ext::AnvilApi}; - use alloy_node_bindings::{Anvil, AnvilInstance}; + use alloy::transports::TransportErrorKind; use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::time::sleep; - async fn setup_anvil() -> anyhow::Result<(AnvilInstance, RobustProvider, impl Provider)> { - let anvil = Anvil::new().try_spawn()?; - let alloy_provider = ProviderBuilder::new().connect_http(anvil.endpoint_url()); - - let robust = RobustProviderBuilder::new(alloy_provider.clone()) - .call_timeout(Duration::from_secs(5)) - .build() - .await?; - - Ok((anvil, robust, alloy_provider)) - } - - async fn setup_anvil_with_blocks( - num_blocks: u64, - ) -> anyhow::Result<(AnvilInstance, RobustProvider, impl Provider)> { - let (anvil, robust, alloy_provider) = setup_anvil().await?; - alloy_provider.anvil_mine(Some(num_blocks), None).await?; - Ok((anvil, robust, alloy_provider)) - } + // ============================================================================ + // Test Helpers + // ============================================================================ fn test_provider(timeout: u64, max_retries: usize, min_delay: u64) -> RobustProvider { RobustProvider { @@ -529,6 +512,10 @@ mod tests { } } + // ============================================================================ + // Retry Logic Tests (try_operation_with_failover internals) + // ============================================================================ + #[tokio::test] async fn test_retry_with_timeout_succeeds_on_first_attempt() { let provider = test_provider(100, 3, 10); @@ -609,301 +596,4 @@ mod tests { assert!(matches!(result, Err(CoreError::Timeout))); } - - #[tokio::test] - async fn test_subscribe_fails_when_all_providers_lack_pubsub() -> anyhow::Result<()> { - let anvil = Anvil::new().try_spawn()?; - - let http_provider = ProviderBuilder::new().connect_http(anvil.endpoint_url()); - - let robust = RobustProviderBuilder::new(http_provider.clone()) - .fallback(http_provider) - .call_timeout(Duration::from_secs(5)) - .min_delay(Duration::from_millis(100)) - .build() - .await?; - - let result = robust.subscribe_blocks().await.unwrap_err(); - - match result { - Error::RpcError(e) => { - assert!(matches!( - e.as_ref(), - RpcError::Transport(TransportErrorKind::PubsubUnavailable) - )); - } - other => panic!("Expected PubsubUnavailable error type, got: {other:?}"), - } - - Ok(()) - } - - #[tokio::test] - async fn test_subscribe_succeeds_if_primary_provider_lacks_pubsub_but_fallback_supports_it() - -> anyhow::Result<()> { - let anvil = Anvil::new().try_spawn()?; - - let http_provider = ProviderBuilder::new().connect_http(anvil.endpoint_url()); - let ws_provider = ProviderBuilder::new() - .connect_ws(WsConnect::new(anvil.ws_endpoint_url().as_str())) - .await?; - - let robust = RobustProviderBuilder::fragile(http_provider) - .fallback(ws_provider) - .call_timeout(Duration::from_secs(5)) - .build() - .await?; - - let result = robust.subscribe_blocks().await; - assert!(result.is_ok()); - - Ok(()) - } - - #[tokio::test] - async fn test_get_block_by_number_succeeds() -> anyhow::Result<()> { - let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; - - let tags = [ - BlockNumberOrTag::Number(50), - BlockNumberOrTag::Latest, - BlockNumberOrTag::Earliest, - BlockNumberOrTag::Safe, - BlockNumberOrTag::Finalized, - ]; - - for tag in tags { - let robust_block = robust.get_block_by_number(tag).await?; - let alloy_block = - alloy_provider.get_block_by_number(tag).await?.expect("block should exist"); - - assert_eq!(robust_block.header.number, alloy_block.header.number); - assert_eq!(robust_block.header.hash, alloy_block.header.hash); - } - - Ok(()) - } - - #[tokio::test] - async fn test_get_block_by_number_future_block_fails() -> anyhow::Result<()> { - let (_anvil, robust, _alloy_provider) = setup_anvil().await?; - - let future_block = 999_999; - let result = robust.get_block_by_number(BlockNumberOrTag::Number(future_block)).await; - - assert!(matches!(result, Err(Error::BlockNotFound(_)))); - - Ok(()) - } - - #[tokio::test] - async fn test_get_block_succeeds() -> anyhow::Result<()> { - let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; - - let block_ids = [ - BlockId::number(50), - BlockId::latest(), - BlockId::earliest(), - BlockId::safe(), - BlockId::finalized(), - ]; - - for block_id in block_ids { - let robust_block = robust.get_block(block_id).await?; - let alloy_block = - alloy_provider.get_block(block_id).await?.expect("block should exist"); - - assert_eq!(robust_block.header.number, alloy_block.header.number); - assert_eq!(robust_block.header.hash, alloy_block.header.hash); - } - - // test block hash - let block = alloy_provider - .get_block_by_number(BlockNumberOrTag::Number(50)) - .await? - .expect("block should exist"); - let block_hash = block.header.hash; - let block_id = BlockId::hash(block_hash); - let robust_block = robust.get_block(block_id).await?; - assert_eq!(robust_block.header.hash, block_hash); - assert_eq!(robust_block.header.number, 50); - - Ok(()) - } - - #[tokio::test] - async fn test_get_block_fails() -> anyhow::Result<()> { - let (_anvil, robust, _alloy_provider) = setup_anvil().await?; - - // Future block number - let result = robust.get_block(BlockId::number(999_999)).await; - assert!(matches!(result, Err(Error::BlockNotFound(_)))); - - // Non-existent hash - let result = robust.get_block(BlockId::hash(BlockHash::ZERO)).await; - assert!(matches!(result, Err(Error::BlockNotFound(_)))); - - Ok(()) - } - - #[tokio::test] - async fn test_get_block_number_succeeds() -> anyhow::Result<()> { - let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; - - let robust_block_num = robust.get_block_number().await?; - let alloy_block_num = alloy_provider.get_block_number().await?; - assert_eq!(robust_block_num, alloy_block_num); - assert_eq!(robust_block_num, 100); - - alloy_provider.anvil_mine(Some(10), None).await?; - let new_block = robust.get_block_number().await?; - assert_eq!(new_block, 110); - - Ok(()) - } - - #[tokio::test] - async fn test_get_block_number_by_id_succeeds() -> anyhow::Result<()> { - let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; - - let block_num = robust.get_block_number_by_id(BlockId::number(50)).await?; - assert_eq!(block_num, 50); - - let block = alloy_provider - .get_block_by_number(BlockNumberOrTag::Number(50)) - .await? - .expect("block should exist"); - let block_num = robust.get_block_number_by_id(BlockId::hash(block.header.hash)).await?; - assert_eq!(block_num, 50); - - let block_num = robust.get_block_number_by_id(BlockId::latest()).await?; - assert_eq!(block_num, 100); - - let block_num = robust.get_block_number_by_id(BlockId::earliest()).await?; - assert_eq!(block_num, 0); - - // Returns block number even if it doesnt 'exist' on chain - let block_num = robust.get_block_number_by_id(BlockId::number(999_999)).await?; - let alloy_block_num = alloy_provider - .get_block_number_by_id(BlockId::number(999_999)) - .await? - .expect("Should return block num"); - assert_eq!(alloy_block_num, block_num); - assert_eq!(block_num, 999_999); - - Ok(()) - } - - #[tokio::test] - async fn test_get_block_number_by_id_fails() -> anyhow::Result<()> { - let (_anvil, robust, _alloy_provider) = setup_anvil().await?; - - let result = robust.get_block_number_by_id(BlockId::hash(BlockHash::ZERO)).await; - assert!(matches!(result, Err(Error::BlockNotFound(_)))); - - Ok(()) - } - - #[tokio::test] - async fn test_get_latest_confirmed_succeeds() -> anyhow::Result<()> { - let (_anvil, robust, _alloy_provider) = setup_anvil_with_blocks(100).await?; - - // With confirmations - let confirmed_block = robust.get_latest_confirmed(10).await?; - assert_eq!(confirmed_block, 90); - - // Zero confirmations returns latest - let confirmed_block = robust.get_latest_confirmed(0).await?; - assert_eq!(confirmed_block, 100); - - // Single confirmation - let confirmed_block = robust.get_latest_confirmed(1).await?; - assert_eq!(confirmed_block, 99); - - // confirmations = latest - 1 - let confirmed_block = robust.get_latest_confirmed(99).await?; - assert_eq!(confirmed_block, 1); - - // confirmations = latest (should return 0) - let confirmed_block = robust.get_latest_confirmed(100).await?; - assert_eq!(confirmed_block, 0); - - // confirmations = latest + 1 (saturates at zero) - let confirmed_block = robust.get_latest_confirmed(101).await?; - assert_eq!(confirmed_block, 0); - - // Saturates at zero when confirmations > latest - let confirmed_block = robust.get_latest_confirmed(200).await?; - assert_eq!(confirmed_block, 0); - - Ok(()) - } - - #[tokio::test] - async fn test_get_block_by_hash_succeeds() -> anyhow::Result<()> { - let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; - - let block = alloy_provider - .get_block_by_number(BlockNumberOrTag::Number(50)) - .await? - .expect("block should exist"); - let block_hash = block.header.hash; - - let robust_block = robust.get_block_by_hash(block_hash).await?; - let alloy_block = - alloy_provider.get_block_by_hash(block_hash).await?.expect("block should exist"); - assert_eq!(robust_block.header.hash, alloy_block.header.hash); - assert_eq!(robust_block.header.number, alloy_block.header.number); - - let genesis = alloy_provider - .get_block_by_number(BlockNumberOrTag::Earliest) - .await? - .expect("genesis should exist"); - let genesis_hash = genesis.header.hash; - let robust_block = robust.get_block_by_hash(genesis_hash).await?; - assert_eq!(robust_block.header.number, 0); - assert_eq!(robust_block.header.hash, genesis_hash); - - Ok(()) - } - - #[tokio::test] - async fn test_get_block_by_hash_fails() -> anyhow::Result<()> { - let (_anvil, robust, _alloy_provider) = setup_anvil().await?; - - let result = robust.get_block_by_hash(BlockHash::ZERO).await; - assert!(matches!(result, Err(Error::BlockNotFound(_)))); - - Ok(()) - } - - #[tokio::test] - #[ignore = "This currently passes but does not test against a 'real' node - see issue https://github.com/OpenZeppelin/Robust-Provider/issues/7 "] - async fn test_get_safe_finalized_block_with_small_chain_height() -> anyhow::Result<()> { - // With only genesis block, finalized block may not be available on real nodes - // but Anvil returns block 0. Either way, get_safe_finalized_block should succeed. - let (_anvil, robust, _alloy_provider) = setup_anvil_with_blocks(0).await?; - - let safe_finalized = robust.get_safe_finalized_block().await?; - - // Should return genesis or block 0 (Anvil behavior) - assert_eq!(safe_finalized.header.number, 0); - - Ok(()) - } - - #[tokio::test] - #[ignore = "This currently passes but does not test against a 'real' node - see issue https://github.com/OpenZeppelin/Robust-Provider/issues/7 "] - async fn test_get_safe_finalized_block_number_with_small_chain_height() -> anyhow::Result<()> { - // With only genesis block, finalized block number may not be available on real nodes - // but Anvil returns 0. Either way, get_safe_finalized_block_number should succeed. - let (_anvil, robust, _alloy_provider) = setup_anvil_with_blocks(0).await?; - - let safe_finalized_num = robust.get_safe_finalized_block_number().await?; - - // Should return 0 (either directly from Anvil or as fallback) - assert_eq!(safe_finalized_num, 0); - - Ok(()) - } } diff --git a/src/robust_provider/subscription.rs b/src/robust_provider/subscription.rs index 9a5cc8c..c59aa9d 100644 --- a/src/robust_provider/subscription.rs +++ b/src/robust_provider/subscription.rs @@ -131,8 +131,8 @@ impl RobustSubscription { /// Returns true if reconnection was successful, false if it's not time yet or if it failed. async fn try_reconnect_to_primary(&mut self, force: bool) -> bool { // Check if we should attempt reconnection - let should_reconnect = force || - match self.last_reconnect_attempt { + let should_reconnect = force + || match self.last_reconnect_attempt { None => false, Some(last_attempt) => { last_attempt.elapsed() >= self.robust_provider.reconnect_interval @@ -248,756 +248,4 @@ impl From> } } -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - - use crate::robust_provider::{DEFAULT_SUBSCRIPTION_BUFFER_CAPACITY, RobustProviderBuilder}; - use alloy::{ - network::Ethereum, - providers::{Provider, ProviderBuilder, RootProvider, ext::AnvilApi}, - }; - use alloy_node_bindings::{Anvil, AnvilInstance}; - use tokio::time::sleep; - use tokio_stream::StreamExt; - - const SHORT_TIMEOUT: Duration = Duration::from_millis(300); - const RECONNECT_INTERVAL: Duration = Duration::from_millis(500); - const BUFFER_TIME: Duration = Duration::from_millis(100); - - async fn spawn_ws_anvil() -> anyhow::Result<(AnvilInstance, RootProvider)> { - let anvil = Anvil::new().try_spawn()?; - let provider = ProviderBuilder::new().connect(anvil.ws_endpoint_url().as_str()).await?; - Ok((anvil, provider.root().to_owned())) - } - - macro_rules! assert_next_block { - ($stream: expr, $expected: expr) => { - assert_next_block!($stream, $expected, timeout = 5) - }; - ($stream: expr, $expected: expr, timeout = $secs: expr) => { - let block = tokio::time::timeout( - std::time::Duration::from_secs($secs), - tokio_stream::StreamExt::next(&mut $stream), - ) - .await - .expect("timed out") - .unwrap(); - let block = block.unwrap(); - assert_eq!(block.number, $expected); - }; - } - - /// Waits for current provider to timeout, then mines on `next_provider` to trigger failover. - async fn trigger_failover_with_delay( - stream: &mut RobustSubscriptionStream, - next_provider: RootProvider, - expected_block: u64, - extra_delay: Duration, - ) -> anyhow::Result<()> { - let task = tokio::spawn(async move { - sleep(SHORT_TIMEOUT + extra_delay + BUFFER_TIME).await; - next_provider.anvil_mine(Some(1), None).await.unwrap(); - }); - assert_next_block!(*stream, expected_block); - task.await?; - Ok(()) - } - - async fn trigger_failover( - stream: &mut RobustSubscriptionStream, - next_provider: RootProvider, - expected_block: u64, - ) -> anyhow::Result<()> { - trigger_failover_with_delay(stream, next_provider, expected_block, Duration::ZERO).await - } - - // ---------------------------------------------------------------------------- - // Basic Subscription Tests - // ---------------------------------------------------------------------------- - - #[tokio::test] - async fn test_successful_subscription_on_primary() -> anyhow::Result<()> { - let (_anvil, provider) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(provider.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - // Subscription is created successfully - is_empty() returns true initially (no pending - // messages) - assert!(subscription.is_empty()); - - Ok(()) - } - - #[tokio::test] - async fn test_multiple_consecutive_recv_calls() -> anyhow::Result<()> { - let (_anvil, provider) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(provider.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .build() - .await?; - - let mut subscription = robust.subscribe_blocks().await?; - - for i in 1..=5 { - provider.anvil_mine(Some(1), None).await?; - let block = subscription.recv().await?; - assert_eq!(block.number, i); - } - - Ok(()) - } - - // ---------------------------------------------------------------------------- - // Stream Tests - // ---------------------------------------------------------------------------- - - #[tokio::test] - async fn test_convert_subscription_to_stream() -> anyhow::Result<()> { - let (_anvil, provider) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(provider.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - - // Convert to stream - let mut stream = subscription.into_stream(); - - // Use the stream - provider.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 1); - - Ok(()) - } - - #[tokio::test] - async fn test_stream_consuming_multiple_blocks() -> anyhow::Result<()> { - let (_anvil, provider) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(provider.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - let mut stream = subscription.into_stream(); - - for i in 1..=5 { - provider.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, i); - } - - Ok(()) - } - - #[tokio::test] - async fn test_stream_consumes_multiple_blocks_in_sequence() -> anyhow::Result<()> { - let (_anvil, provider) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(provider.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - let mut stream = subscription.into_stream(); - - provider.anvil_mine(Some(5), None).await?; - assert_next_block!(stream, 1); - assert_next_block!(stream, 2); - assert_next_block!(stream, 3); - assert_next_block!(stream, 4); - assert_next_block!(stream, 5); - - Ok(()) - } - - #[tokio::test] - async fn test_stream_creation() -> anyhow::Result<()> { - let (_anvil, provider) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(provider.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - let mut stream = subscription.into_stream(); - - // Stream should work normally - provider.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 1); - - Ok(()) - } - - #[tokio::test] - async fn test_stream_continues_streaming_errors() -> anyhow::Result<()> { - let (_anvil, provider) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(provider.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - let mut stream = subscription.into_stream(); - - // Get one block - provider.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 1); - - // Trigger timeout error - the stream will continue to stream errors - assert!(matches!(stream.next().await.unwrap(), Err(Error::Timeout))); - - // Without fallbacks, subsequent calls will continue to return errors - // (not None, since only Error::Closed terminates the stream) - assert!(matches!(stream.next().await.unwrap(), Err(Error::Timeout))); - - Ok(()) - } - - // ---------------------------------------------------------------------------- - // Basic Failover Tests - // ---------------------------------------------------------------------------- - - #[tokio::test] - async fn robust_subscription_stream_with_failover() -> anyhow::Result<()> { - let (_anvil_1, primary) = spawn_ws_anvil().await?; - let (_anvil_2, fallback) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(primary.clone()) - .fallback(fallback.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .build() - .await?; - - let mut subscription = robust.subscribe_blocks().await?; - - // Initial state: on primary - assert!(subscription.current_fallback_index.is_none()); - assert!(subscription.last_reconnect_attempt.is_none()); - - // Test: Primary works initially - primary.anvil_mine(Some(1), None).await?; - assert_eq!(subscription.recv().await?.number, 1); - - primary.anvil_mine(Some(1), None).await?; - assert_eq!(subscription.recv().await?.number, 2); - - // After timeout, should failover to fallback provider - let fb = fallback.clone(); - tokio::spawn(async move { - sleep(SHORT_TIMEOUT + BUFFER_TIME).await; - fb.anvil_mine(Some(1), None).await.unwrap(); - }); - assert_eq!(subscription.recv().await?.number, 1); - - // After failover: on fallback[0] - assert_eq!(subscription.current_fallback_index, Some(0)); - assert!(subscription.last_reconnect_attempt.is_some()); - - // PP is not used after failover - primary.anvil_mine(Some(1), None).await?; - fallback.anvil_mine(Some(1), None).await?; - - // From fallback, not primary's block 3 - assert_eq!(subscription.recv().await?.number, 2); - - Ok(()) - } - - #[tokio::test] - async fn subscription_fails_with_no_fallbacks() -> anyhow::Result<()> { - let (_anvil, provider) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(provider.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - let mut stream = subscription.into_stream(); - - provider.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 1); - - // No fallback available - should error after timeout - assert!(matches!(stream.next().await.unwrap(), Err(Error::Timeout))); - - Ok(()) - } - - #[tokio::test] - async fn ws_fails_http_fallback_returns_primary_error() -> anyhow::Result<()> { - // Setup: Create WS primary and HTTP fallback - let anvil_1 = Anvil::new().try_spawn()?; - let ws_provider = - ProviderBuilder::new().connect(anvil_1.ws_endpoint_url().as_str()).await?; - - let anvil_2 = Anvil::new().try_spawn()?; - let http_provider = ProviderBuilder::new().connect_http(anvil_2.endpoint_url()); - - let robust = RobustProviderBuilder::fragile(ws_provider.clone()) - .fallback(http_provider.clone()) - .subscription_timeout(Duration::from_secs(1)) - .build() - .await?; - - // Test: Verify subscription works on primary - let subscription = robust.subscribe_blocks().await?; - let mut stream = subscription.into_stream(); - - ws_provider.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 1); - - ws_provider.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 2); - - // Verify: HTTP fallback can't provide subscription, so we get an error - assert!(matches!(stream.next().await.unwrap(), Err(Error::Timeout))); - - Ok(()) - } - - // ---------------------------------------------------------------------------- - // Fallback Cycling Tests - // ---------------------------------------------------------------------------- - - #[tokio::test] - async fn test_single_fallback_provider() -> anyhow::Result<()> { - let (anvil_pp, primary) = spawn_ws_anvil().await?; - let (_anvil_2, fallback) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(primary.clone()) - .fallback(fallback.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .call_timeout(SHORT_TIMEOUT) - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - let mut stream = subscription.into_stream(); - - // Start on primary - primary.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 1); - - // Kill primary so reconnect attempts fail - drop(anvil_pp); - - // PP -> FB - trigger_failover(&mut stream, fallback.clone(), 1).await?; - - // FB -> try PP (fails) -> no more fallbacks -> error - assert!(matches!(stream.next().await.unwrap(), Err(Error::Timeout))); - - Ok(()) - } - - #[tokio::test] - async fn subscription_cycles_through_multiple_fallbacks() -> anyhow::Result<()> { - let (anvil_pp, primary) = spawn_ws_anvil().await?; - let (_anvil_1, fb_1) = spawn_ws_anvil().await?; - let (_anvil_2, fb_2) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(primary.clone()) - .fallback(fb_1.clone()) - .fallback(fb_2.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .call_timeout(SHORT_TIMEOUT) - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - let mut stream = subscription.into_stream(); - - // Start on primary - primary.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 1); - - // Kill primary - all future PP reconnection attempts will fail - drop(anvil_pp); - - // PP times out -> FP1 - trigger_failover(&mut stream, fb_1.clone(), 1).await?; - - // FP1 times out -> tries PP (fails, takes call_timeout) -> FP2 - trigger_failover_with_delay(&mut stream, fb_2.clone(), 1, SHORT_TIMEOUT).await?; - - fb_2.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 2); - - // FP2 times out -> tries PP (fails) -> no more fallbacks -> error - assert!(matches!(stream.next().await.unwrap(), Err(Error::Timeout))); - - Ok(()) - } - - #[tokio::test] - async fn test_many_fallback_providers() -> anyhow::Result<()> { - let (anvil_pp, primary) = spawn_ws_anvil().await?; - let (_anvil_1, fb_1) = spawn_ws_anvil().await?; - let (_anvil_2, fb_2) = spawn_ws_anvil().await?; - let (_anvil_3, fb_3) = spawn_ws_anvil().await?; - let (_anvil_4, fb_4) = spawn_ws_anvil().await?; - let (_anvil_5, fb_5) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(primary.clone()) - .fallback(fb_1.clone()) - .fallback(fb_2.clone()) - .fallback(fb_3.clone()) - .fallback(fb_4.clone()) - .fallback(fb_5.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .call_timeout(SHORT_TIMEOUT) - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - let mut stream = subscription.into_stream(); - - // Start on primary - primary.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 1); - - // Kill primary - drop(anvil_pp); - - // Cycle through all fallbacks - trigger_failover(&mut stream, fb_1.clone(), 1).await?; - trigger_failover_with_delay(&mut stream, fb_2.clone(), 1, SHORT_TIMEOUT).await?; - trigger_failover_with_delay(&mut stream, fb_3.clone(), 1, SHORT_TIMEOUT).await?; - trigger_failover_with_delay(&mut stream, fb_4.clone(), 1, SHORT_TIMEOUT).await?; - trigger_failover_with_delay(&mut stream, fb_5.clone(), 1, SHORT_TIMEOUT).await?; - - assert!(matches!(stream.next().await.unwrap(), Err(Error::Timeout))); - - Ok(()) - } - - // ---------------------------------------------------------------------------- - // Reconnection Tests - // ---------------------------------------------------------------------------- - - #[tokio::test] - async fn subscription_reconnects_to_primary() -> anyhow::Result<()> { - let (_anvil_1, primary) = spawn_ws_anvil().await?; - let (_anvil_2, fallback) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(primary.clone()) - .fallback(fallback.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .reconnect_interval(RECONNECT_INTERVAL) - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - let mut stream = subscription.into_stream(); - - // Start on primary - primary.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 1); - - // PP times out -> FP1 - trigger_failover(&mut stream, fallback.clone(), 1).await?; - - fallback.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 2); - - // FP1 times out -> PP (reconnect succeeds) - trigger_failover(&mut stream, primary.clone(), 2).await?; - - // PP times out -> FP1 (fallback index was reset) - trigger_failover(&mut stream, fallback.clone(), 3).await?; - - fallback.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 4); - - Ok(()) - } - - #[tokio::test] - async fn subscription_periodically_reconnects_to_primary_while_on_fallback() - -> anyhow::Result<()> { - // Use a longer reconnect interval to make timing more predictable - let reconnect_interval = Duration::from_millis(800); - - let (_anvil_1, primary) = spawn_ws_anvil().await?; - let (_anvil_2, fallback) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(primary.clone()) - .fallback(fallback.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .reconnect_interval(reconnect_interval) - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - let mut stream = subscription.into_stream(); - - // Start on primary - primary.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 1); - - // PP times out -> FP (this sets last_reconnect_attempt) - trigger_failover(&mut stream, fallback.clone(), 1).await?; - let failover_time = Instant::now(); - - // Now on fallback - mine blocks before reconnect_interval elapses - // These should stay on fallback (no reconnect attempt) - fallback.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 2); - - fallback.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 3); - - // Ensure reconnect_interval has fully elapsed since failover - let elapsed = failover_time.elapsed(); - if elapsed < reconnect_interval + BUFFER_TIME { - sleep(reconnect_interval + BUFFER_TIME - elapsed).await; - } - - // Mine on fallback - receiving this block triggers try_reconnect_to_primary - fallback.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 4); - - // Now we should be back on primary - primary.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 2); - - primary.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 3); - - Ok(()) - } - - #[tokio::test] - async fn test_reconnection_skipped_before_interval_elapsed() -> anyhow::Result<()> { - let (_anvil_1, primary) = spawn_ws_anvil().await?; - let (_anvil_2, fallback) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(primary.clone()) - .fallback(fallback.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .reconnect_interval(Duration::from_secs(10)) // Long interval - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - let mut stream = subscription.into_stream(); - - // Start on primary - primary.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 1); - primary.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 2); - - // Failover to fallback - trigger_failover(&mut stream, fallback.clone(), 1).await?; - - // Immediately try another recv - should stay on fallback (no reconnect attempt) - fallback.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 2); - - Ok(()) - } - - #[tokio::test] - async fn test_successful_reconnection_resets_state() -> anyhow::Result<()> { - let (_anvil_1, primary) = spawn_ws_anvil().await?; - let (_anvil_2, fb_1) = spawn_ws_anvil().await?; - let (_anvil_3, fb_2) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(primary.clone()) - .fallback(fb_1.clone()) - .fallback(fb_2.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .reconnect_interval(RECONNECT_INTERVAL) - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - let mut stream = subscription.into_stream(); - - // Start on primary - primary.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 1); - - // Failover to fallback - trigger_failover(&mut stream, fb_1.clone(), 1).await?; - - fb_1.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 2); - - // Wait for reconnect interval, then timeout - reconnect to primary - sleep(RECONNECT_INTERVAL).await; - trigger_failover(&mut stream, primary.clone(), 2).await?; - - // After reconnection, next failover should go to fallback[0] again (not fallback[1]) - trigger_failover(&mut stream, fb_1.clone(), 3).await?; - - Ok(()) - } - - #[tokio::test] - async fn test_multiple_failed_reconnection_attempts() -> anyhow::Result<()> { - let (anvil_pp, primary) = spawn_ws_anvil().await?; - let (_anvil_1, fb_1) = spawn_ws_anvil().await?; - let (_anvil_2, fb_2) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(primary.clone()) - .fallback(fb_1.clone()) - .fallback(fb_2.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .reconnect_interval(RECONNECT_INTERVAL) - .call_timeout(SHORT_TIMEOUT) - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - let mut stream = subscription.into_stream(); - - // Start on primary - primary.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 1); - - // Kill primary - drop(anvil_pp); - - // Failover to fb_1 (primary is dead) - trigger_failover(&mut stream, fb_1.clone(), 1).await?; - - // Stay on fb_1 for a bit - fb_1.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 2); - - // Wait for reconnect interval, then timeout - try primary (fails), go to fb_2 - sleep(RECONNECT_INTERVAL).await; - trigger_failover_with_delay(&mut stream, fb_2.clone(), 1, SHORT_TIMEOUT).await?; - - // fb_2 continues to work - fb_2.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 2); - - Ok(()) - } - - #[tokio::test] - async fn test_primary_reconnect_attempt_before_next_fallback() -> anyhow::Result<()> { - let (_anvil_1, primary) = spawn_ws_anvil().await?; - let (_anvil_2, fb_1) = spawn_ws_anvil().await?; - let (_anvil_3, fb_2) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(primary.clone()) - .fallback(fb_1.clone()) - .fallback(fb_2.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .reconnect_interval(RECONNECT_INTERVAL) - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - let mut stream = subscription.into_stream(); - - // Start on primary - primary.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 1); - - // PP -> FB1 - trigger_failover(&mut stream, fb_1.clone(), 1).await?; - - // FB1 -> PP (reconnect succeeds, not FB2) - trigger_failover(&mut stream, primary.clone(), 2).await?; - - Ok(()) - } - - // ---------------------------------------------------------------------------- - // Error Propagation Tests - // ---------------------------------------------------------------------------- - - #[tokio::test] - async fn test_backend_gone_error_propagation() -> anyhow::Result<()> { - let (anvil, provider) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(provider.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - let mut stream = subscription.into_stream(); - - // Get one block - provider.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 1); - - // Kill the provider - drop(anvil); - - // Should get BackendGone or Timeout error - assert!(matches!(stream.next().await.unwrap(), Err(Error::Timeout))); - - Ok(()) - } - - #[tokio::test] - async fn test_immediate_consecutive_failures() -> anyhow::Result<()> { - let (anvil, provider) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(provider.clone()) - .subscription_timeout(SHORT_TIMEOUT) - .build() - .await?; - - let subscription = robust.subscribe_blocks().await?; - let mut stream = subscription.into_stream(); - - // Get one block - provider.anvil_mine(Some(1), None).await?; - assert_next_block!(stream, 1); - - // Kill provider immediately - drop(anvil); - - // First failure - assert!(matches!(stream.next().await.unwrap(), Err(Error::Timeout))); - - Ok(()) - } - - #[tokio::test] - async fn test_subscription_lagged_error() -> anyhow::Result<()> { - let (_anvil, provider) = spawn_ws_anvil().await?; - - let robust = RobustProviderBuilder::fragile(provider.clone()) - .subscription_timeout(Duration::from_secs(5)) - .build() - .await?; - - let mut subscription = robust.subscribe_blocks().await?; - - // Mine more blocks than channel can hold without consuming - provider.anvil_mine(Some(DEFAULT_SUBSCRIPTION_BUFFER_CAPACITY as u64 + 1), None).await?; - - // Allow time for block notifications to propagate through WebSocket - // and fill the subscription channel - sleep(BUFFER_TIME).await; - - // First recv should return Lagged error (skipped some blocks) - let result = subscription.recv().await; - assert!(matches!(result, Err(Error::Lagged(_)))); - - Ok(()) - } -} +// Tests for subscription functionality have been moved to tests/subscription.rs diff --git a/tests/common.rs b/tests/common.rs new file mode 100644 index 0000000..1890595 --- /dev/null +++ b/tests/common.rs @@ -0,0 +1,45 @@ +//! Common test utilities and helpers for integration tests. + +use std::time::Duration; + +use alloy::providers::{Provider, ProviderBuilder, RootProvider, ext::AnvilApi}; +use alloy_node_bindings::{Anvil, AnvilInstance}; +use robust_provider::{RobustProvider, RobustProviderBuilder}; + +/// Setup a basic Anvil instance with a RobustProvider. +pub async fn setup_anvil() -> anyhow::Result<(AnvilInstance, RobustProvider, impl Provider)> { + let anvil = Anvil::new().try_spawn()?; + let alloy_provider = ProviderBuilder::new().connect_http(anvil.endpoint_url()); + + let robust = RobustProviderBuilder::new(alloy_provider.clone()) + .call_timeout(Duration::from_secs(5)) + .build() + .await?; + + Ok((anvil, robust, alloy_provider)) +} + +/// Setup an Anvil instance with pre-mined blocks. +pub async fn setup_anvil_with_blocks( + num_blocks: u64, +) -> anyhow::Result<(AnvilInstance, RobustProvider, impl Provider)> { + let (anvil, robust, alloy_provider) = setup_anvil().await?; + alloy_provider.anvil_mine(Some(num_blocks), None).await?; + Ok((anvil, robust, alloy_provider)) +} + +/// Spawn a WebSocket-enabled Anvil instance. +pub async fn spawn_ws_anvil() -> anyhow::Result<(AnvilInstance, RootProvider)> { + let anvil = Anvil::new().try_spawn()?; + let provider = ProviderBuilder::new().connect(anvil.ws_endpoint_url().as_str()).await?; + Ok((anvil, provider.root().to_owned())) +} + +/// Short timeout for tests. +pub const SHORT_TIMEOUT: Duration = Duration::from_millis(300); + +/// Reconnect interval for tests. +pub const RECONNECT_INTERVAL: Duration = Duration::from_millis(500); + +/// Buffer time for async operations. +pub const BUFFER_TIME: Duration = Duration::from_millis(100); diff --git a/tests/custom_methods.rs b/tests/custom_methods.rs new file mode 100644 index 0000000..4655d04 --- /dev/null +++ b/tests/custom_methods.rs @@ -0,0 +1,85 @@ +//! Tests for RobustProvider custom methods. +//! +//! These tests cover methods that are unique to RobustProvider and not +//! direct wrappers of standard Ethereum JSON-RPC methods. + +mod common; + +use common::setup_anvil_with_blocks; + +// ============================================================================ +// get_latest_confirmed +// ============================================================================ + +#[tokio::test] +async fn test_get_latest_confirmed_succeeds() -> anyhow::Result<()> { + let (_anvil, robust, _alloy_provider) = setup_anvil_with_blocks(100).await?; + + // With confirmations + let confirmed_block = robust.get_latest_confirmed(10).await?; + assert_eq!(confirmed_block, 90); + + // Zero confirmations returns latest + let confirmed_block = robust.get_latest_confirmed(0).await?; + assert_eq!(confirmed_block, 100); + + // Single confirmation + let confirmed_block = robust.get_latest_confirmed(1).await?; + assert_eq!(confirmed_block, 99); + + // confirmations = latest - 1 + let confirmed_block = robust.get_latest_confirmed(99).await?; + assert_eq!(confirmed_block, 1); + + // confirmations = latest (should return 0) + let confirmed_block = robust.get_latest_confirmed(100).await?; + assert_eq!(confirmed_block, 0); + + // confirmations = latest + 1 (saturates at zero) + let confirmed_block = robust.get_latest_confirmed(101).await?; + assert_eq!(confirmed_block, 0); + + // Saturates at zero when confirmations > latest + let confirmed_block = robust.get_latest_confirmed(200).await?; + assert_eq!(confirmed_block, 0); + + Ok(()) +} + +// ============================================================================ +// get_safe_finalized_block +// ============================================================================ + +#[tokio::test] +#[ignore = "This currently passes but does not test against a 'real' node - see issue https://github.com/OpenZeppelin/Robust-Provider/issues/7 "] +async fn test_get_safe_finalized_block_with_small_chain_height() -> anyhow::Result<()> { + // With only genesis block, finalized block may not be available on real nodes + // but Anvil returns block 0. Either way, get_safe_finalized_block should succeed. + let (_anvil, robust, _alloy_provider) = setup_anvil_with_blocks(0).await?; + + let safe_finalized = robust.get_safe_finalized_block().await?; + + // Should return genesis or block 0 (Anvil behavior) + assert_eq!(safe_finalized.header.number, 0); + + Ok(()) +} + +// ============================================================================ +// get_safe_finalized_block_number +// ============================================================================ + +#[tokio::test] +#[ignore = "This currently passes but does not test against a 'real' node - see issue https://github.com/OpenZeppelin/Robust-Provider/issues/7 "] +async fn test_get_safe_finalized_block_number_with_small_chain_height() -> anyhow::Result<()> { + // With only genesis block, finalized block number may not be available on real nodes + // but Anvil returns 0. Either way, get_safe_finalized_block_number should succeed. + let (_anvil, robust, _alloy_provider) = setup_anvil_with_blocks(0).await?; + + let safe_finalized_num = robust.get_safe_finalized_block_number().await?; + + // Should return 0 (either directly from Anvil or as fallback) + assert_eq!(safe_finalized_num, 0); + + Ok(()) +} diff --git a/tests/eth_namespace.rs b/tests/eth_namespace.rs new file mode 100644 index 0000000..ee36b3c --- /dev/null +++ b/tests/eth_namespace.rs @@ -0,0 +1,216 @@ +//! Tests for Ethereum JSON-RPC namespace methods exposed by RobustProvider. +//! +//! These tests verify the behavior of standard Ethereum RPC methods wrapped +//! by RobustProvider with retry and failover logic. + +mod common; + +use alloy::{ + eips::{BlockId, BlockNumberOrTag}, + primitives::BlockHash, + providers::{Provider, ext::AnvilApi}, + rpc::types::Filter, +}; +use common::{setup_anvil, setup_anvil_with_blocks}; +use robust_provider::Error; + +// ============================================================================ +// eth_getBlockByNumber +// ============================================================================ + +#[tokio::test] +async fn test_get_block_by_number_succeeds() -> anyhow::Result<()> { + let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; + + let tags = [ + BlockNumberOrTag::Number(50), + BlockNumberOrTag::Latest, + BlockNumberOrTag::Earliest, + BlockNumberOrTag::Safe, + BlockNumberOrTag::Finalized, + ]; + + for tag in tags { + let robust_block = robust.get_block_by_number(tag).await?; + let alloy_block = + alloy_provider.get_block_by_number(tag).await?.expect("block should exist"); + + assert_eq!(robust_block.header.number, alloy_block.header.number); + assert_eq!(robust_block.header.hash, alloy_block.header.hash); + } + + Ok(()) +} + +#[tokio::test] +async fn test_get_block_by_number_future_block_fails() -> anyhow::Result<()> { + let (_anvil, robust, _alloy_provider) = setup_anvil().await?; + + let future_block = 999_999; + let result = robust.get_block_by_number(BlockNumberOrTag::Number(future_block)).await; + + assert!(matches!(result, Err(Error::BlockNotFound(_)))); + + Ok(()) +} + +// ============================================================================ +// eth_getBlockByHash +// ============================================================================ + +#[tokio::test] +async fn test_get_block_by_hash_succeeds() -> anyhow::Result<()> { + let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; + + let block = alloy_provider + .get_block_by_number(BlockNumberOrTag::Number(50)) + .await? + .expect("block should exist"); + let block_hash = block.header.hash; + + let robust_block = robust.get_block_by_hash(block_hash).await?; + let alloy_block = + alloy_provider.get_block_by_hash(block_hash).await?.expect("block should exist"); + assert_eq!(robust_block.header.hash, alloy_block.header.hash); + assert_eq!(robust_block.header.number, alloy_block.header.number); + + let genesis = alloy_provider + .get_block_by_number(BlockNumberOrTag::Earliest) + .await? + .expect("genesis should exist"); + let genesis_hash = genesis.header.hash; + let robust_block = robust.get_block_by_hash(genesis_hash).await?; + assert_eq!(robust_block.header.number, 0); + assert_eq!(robust_block.header.hash, genesis_hash); + + Ok(()) +} + +#[tokio::test] +async fn test_get_block_by_hash_fails() -> anyhow::Result<()> { + let (_anvil, robust, _alloy_provider) = setup_anvil().await?; + + let result = robust.get_block_by_hash(BlockHash::ZERO).await; + assert!(matches!(result, Err(Error::BlockNotFound(_)))); + + Ok(()) +} + +// ============================================================================ +// eth_blockNumber +// ============================================================================ + +#[tokio::test] +async fn test_get_block_number_succeeds() -> anyhow::Result<()> { + let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; + + let robust_block_num = robust.get_block_number().await?; + let alloy_block_num = alloy_provider.get_block_number().await?; + assert_eq!(robust_block_num, alloy_block_num); + assert_eq!(robust_block_num, 100); + + alloy_provider.anvil_mine(Some(10), None).await?; + let new_block = robust.get_block_number().await?; + assert_eq!(new_block, 110); + + Ok(()) +} + +// ============================================================================ +// eth_getBlock (by BlockId) +// ============================================================================ + +#[tokio::test] +async fn test_get_block_succeeds() -> anyhow::Result<()> { + let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; + + let block_ids = [ + BlockId::number(50), + BlockId::latest(), + BlockId::earliest(), + BlockId::safe(), + BlockId::finalized(), + ]; + + for block_id in block_ids { + let robust_block = robust.get_block(block_id).await?; + let alloy_block = alloy_provider.get_block(block_id).await?.expect("block should exist"); + + assert_eq!(robust_block.header.number, alloy_block.header.number); + assert_eq!(robust_block.header.hash, alloy_block.header.hash); + } + + // test block hash + let block = alloy_provider + .get_block_by_number(BlockNumberOrTag::Number(50)) + .await? + .expect("block should exist"); + let block_hash = block.header.hash; + let block_id = BlockId::hash(block_hash); + let robust_block = robust.get_block(block_id).await?; + assert_eq!(robust_block.header.hash, block_hash); + assert_eq!(robust_block.header.number, 50); + + Ok(()) +} + +#[tokio::test] +async fn test_get_block_fails() -> anyhow::Result<()> { + let (_anvil, robust, _alloy_provider) = setup_anvil().await?; + + // Future block number + let result = robust.get_block(BlockId::number(999_999)).await; + assert!(matches!(result, Err(Error::BlockNotFound(_)))); + + // Non-existent hash + let result = robust.get_block(BlockId::hash(BlockHash::ZERO)).await; + assert!(matches!(result, Err(Error::BlockNotFound(_)))); + + Ok(()) +} + +// ============================================================================ +// eth_getBlockNumberByBlockId (custom helper) +// ============================================================================ + +#[tokio::test] +async fn test_get_block_number_by_id_succeeds() -> anyhow::Result<()> { + let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; + + let block_num = robust.get_block_number_by_id(BlockId::number(50)).await?; + assert_eq!(block_num, 50); + + let block = alloy_provider + .get_block_by_number(BlockNumberOrTag::Number(50)) + .await? + .expect("block should exist"); + let block_num = robust.get_block_number_by_id(BlockId::hash(block.header.hash)).await?; + assert_eq!(block_num, 50); + + let block_num = robust.get_block_number_by_id(BlockId::latest()).await?; + assert_eq!(block_num, 100); + + let block_num = robust.get_block_number_by_id(BlockId::earliest()).await?; + assert_eq!(block_num, 0); + + // Returns block number even if it doesnt 'exist' on chain + let block_num = robust.get_block_number_by_id(BlockId::number(999_999)).await?; + let alloy_block_num = alloy_provider + .get_block_number_by_id(BlockId::number(999_999)) + .await? + .expect("Should return block num"); + assert_eq!(alloy_block_num, block_num); + assert_eq!(block_num, 999_999); + + Ok(()) +} + +#[tokio::test] +async fn test_get_block_number_by_id_fails() -> anyhow::Result<()> { + let (_anvil, robust, _alloy_provider) = setup_anvil().await?; + + let result = robust.get_block_number_by_id(BlockId::hash(BlockHash::ZERO)).await; + assert!(matches!(result, Err(Error::BlockNotFound(_)))); + + Ok(()) +} diff --git a/tests/subscription.rs b/tests/subscription.rs new file mode 100644 index 0000000..ee7c9c6 --- /dev/null +++ b/tests/subscription.rs @@ -0,0 +1,800 @@ +//! Tests for RobustSubscription and block subscription functionality. +//! +//! These tests cover WebSocket subscriptions, failover behavior, +//! reconnection logic, and stream handling. + +mod common; + +use std::time::{Duration, Instant}; + +use alloy::{ + network::Ethereum, + providers::{ProviderBuilder, RootProvider, ext::AnvilApi}, +}; +use alloy_node_bindings::Anvil; +use common::{BUFFER_TIME, RECONNECT_INTERVAL, SHORT_TIMEOUT, spawn_ws_anvil}; +use robust_provider::{ + DEFAULT_SUBSCRIPTION_BUFFER_CAPACITY, RobustProviderBuilder, RobustSubscriptionStream, + SubscriptionError, +}; +use tokio::time::sleep; +use tokio_stream::StreamExt; + +// ============================================================================ +// Test Helpers +// ============================================================================ + +macro_rules! assert_next_block { + ($stream: expr, $expected: expr) => { + assert_next_block!($stream, $expected, timeout = 5) + }; + ($stream: expr, $expected: expr, timeout = $secs: expr) => { + let block = tokio::time::timeout( + std::time::Duration::from_secs($secs), + tokio_stream::StreamExt::next(&mut $stream), + ) + .await + .expect("timed out") + .unwrap(); + let block = block.unwrap(); + assert_eq!(block.number, $expected); + }; +} + +/// Waits for current provider to timeout, then mines on `next_provider` to trigger failover. +async fn trigger_failover_with_delay( + stream: &mut RobustSubscriptionStream, + next_provider: RootProvider, + expected_block: u64, + extra_delay: Duration, +) -> anyhow::Result<()> { + let task = tokio::spawn(async move { + sleep(SHORT_TIMEOUT + extra_delay + BUFFER_TIME).await; + next_provider.anvil_mine(Some(1), None).await.unwrap(); + }); + assert_next_block!(*stream, expected_block); + task.await?; + Ok(()) +} + +async fn trigger_failover( + stream: &mut RobustSubscriptionStream, + next_provider: RootProvider, + expected_block: u64, +) -> anyhow::Result<()> { + trigger_failover_with_delay(stream, next_provider, expected_block, Duration::ZERO).await +} + +// ============================================================================ +// Basic Subscription Tests +// ============================================================================ + +#[tokio::test] +async fn test_successful_subscription_on_primary() -> anyhow::Result<()> { + let (_anvil, provider) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(provider.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + // Subscription is created successfully - is_empty() returns true initially (no pending + // messages) + assert!(subscription.is_empty()); + + Ok(()) +} + +#[tokio::test] +async fn test_multiple_consecutive_recv_calls() -> anyhow::Result<()> { + let (_anvil, provider) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(provider.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .build() + .await?; + + let mut subscription = robust.subscribe_blocks().await?; + + for i in 1..=5 { + provider.anvil_mine(Some(1), None).await?; + let block = subscription.recv().await?; + assert_eq!(block.number, i); + } + + Ok(()) +} + +// ============================================================================ +// Stream Tests +// ============================================================================ + +#[tokio::test] +async fn test_convert_subscription_to_stream() -> anyhow::Result<()> { + let (_anvil, provider) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(provider.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + + // Convert to stream + let mut stream = subscription.into_stream(); + + // Use the stream + provider.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 1); + + Ok(()) +} + +#[tokio::test] +async fn test_stream_consuming_multiple_blocks() -> anyhow::Result<()> { + let (_anvil, provider) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(provider.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + let mut stream = subscription.into_stream(); + + for i in 1..=5 { + provider.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, i); + } + + Ok(()) +} + +#[tokio::test] +async fn test_stream_consumes_multiple_blocks_in_sequence() -> anyhow::Result<()> { + let (_anvil, provider) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(provider.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + let mut stream = subscription.into_stream(); + + provider.anvil_mine(Some(5), None).await?; + assert_next_block!(stream, 1); + assert_next_block!(stream, 2); + assert_next_block!(stream, 3); + assert_next_block!(stream, 4); + assert_next_block!(stream, 5); + + Ok(()) +} + +#[tokio::test] +async fn test_stream_creation() -> anyhow::Result<()> { + let (_anvil, provider) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(provider.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + let mut stream = subscription.into_stream(); + + // Stream should work normally + provider.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 1); + + Ok(()) +} + +#[tokio::test] +async fn test_stream_continues_streaming_errors() -> anyhow::Result<()> { + let (_anvil, provider) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(provider.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + let mut stream = subscription.into_stream(); + + // Get one block + provider.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 1); + + // Trigger timeout error - the stream will continue to stream errors + assert!(matches!(stream.next().await.unwrap(), Err(SubscriptionError::Timeout))); + + // Without fallbacks, subsequent calls will continue to return errors + // (not None, since only Error::Closed terminates the stream) + assert!(matches!(stream.next().await.unwrap(), Err(SubscriptionError::Timeout))); + + Ok(()) +} + +// ============================================================================ +// Basic Failover Tests +// ============================================================================ + +#[tokio::test] +async fn robust_subscription_stream_with_failover() -> anyhow::Result<()> { + let (_anvil_1, primary) = spawn_ws_anvil().await?; + let (_anvil_2, fallback) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(primary.clone()) + .fallback(fallback.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .build() + .await?; + + let mut subscription = robust.subscribe_blocks().await?; + + // Test: Primary works initially + primary.anvil_mine(Some(1), None).await?; + assert_eq!(subscription.recv().await?.number, 1); + + primary.anvil_mine(Some(1), None).await?; + assert_eq!(subscription.recv().await?.number, 2); + + // After timeout, should failover to fallback provider + let fb = fallback.clone(); + tokio::spawn(async move { + sleep(SHORT_TIMEOUT + BUFFER_TIME).await; + fb.anvil_mine(Some(1), None).await.unwrap(); + }); + assert_eq!(subscription.recv().await?.number, 1); + + // PP is not used after failover + primary.anvil_mine(Some(1), None).await?; + fallback.anvil_mine(Some(1), None).await?; + + // From fallback, not primary's block 3 + assert_eq!(subscription.recv().await?.number, 2); + + Ok(()) +} + +#[tokio::test] +async fn subscription_fails_with_no_fallbacks() -> anyhow::Result<()> { + let (_anvil, provider) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(provider.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + let mut stream = subscription.into_stream(); + + provider.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 1); + + // No fallback available - should error after timeout + assert!(matches!(stream.next().await.unwrap(), Err(SubscriptionError::Timeout))); + + Ok(()) +} + +#[tokio::test] +async fn ws_fails_http_fallback_returns_primary_error() -> anyhow::Result<()> { + // Setup: Create WS primary and HTTP fallback + let anvil_1 = Anvil::new().try_spawn()?; + let ws_provider = ProviderBuilder::new().connect(anvil_1.ws_endpoint_url().as_str()).await?; + + let anvil_2 = Anvil::new().try_spawn()?; + let http_provider = ProviderBuilder::new().connect_http(anvil_2.endpoint_url()); + + let robust = RobustProviderBuilder::fragile(ws_provider.clone()) + .fallback(http_provider.clone()) + .subscription_timeout(Duration::from_secs(1)) + .build() + .await?; + + // Test: Verify subscription works on primary + let subscription = robust.subscribe_blocks().await?; + let mut stream = subscription.into_stream(); + + ws_provider.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 1); + + ws_provider.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 2); + + // Verify: HTTP fallback can't provide subscription, so we get an error + assert!(matches!(stream.next().await.unwrap(), Err(SubscriptionError::Timeout))); + + Ok(()) +} + +// ============================================================================ +// Fallback Cycling Tests +// ============================================================================ + +#[tokio::test] +async fn test_single_fallback_provider() -> anyhow::Result<()> { + let (anvil_pp, primary) = spawn_ws_anvil().await?; + let (_anvil_2, fallback) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(primary.clone()) + .fallback(fallback.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .call_timeout(SHORT_TIMEOUT) + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + let mut stream = subscription.into_stream(); + + // Start on primary + primary.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 1); + + // Kill primary so reconnect attempts fail + drop(anvil_pp); + + // PP -> FB + trigger_failover(&mut stream, fallback.clone(), 1).await?; + + // FB -> try PP (fails) -> no more fallbacks -> error + assert!(matches!(stream.next().await.unwrap(), Err(SubscriptionError::Timeout))); + + Ok(()) +} + +#[tokio::test] +async fn subscription_cycles_through_multiple_fallbacks() -> anyhow::Result<()> { + let (anvil_pp, primary) = spawn_ws_anvil().await?; + let (_anvil_1, fb_1) = spawn_ws_anvil().await?; + let (_anvil_2, fb_2) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(primary.clone()) + .fallback(fb_1.clone()) + .fallback(fb_2.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .call_timeout(SHORT_TIMEOUT) + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + let mut stream = subscription.into_stream(); + + // Start on primary + primary.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 1); + + // Kill primary - all future PP reconnection attempts will fail + drop(anvil_pp); + + // PP times out -> FP1 + trigger_failover(&mut stream, fb_1.clone(), 1).await?; + + // FP1 times out -> tries PP (fails, takes call_timeout) -> FP2 + trigger_failover_with_delay(&mut stream, fb_2.clone(), 1, SHORT_TIMEOUT).await?; + + fb_2.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 2); + + // FP2 times out -> tries PP (fails) -> no more fallbacks -> error + assert!(matches!(stream.next().await.unwrap(), Err(SubscriptionError::Timeout))); + + Ok(()) +} + +#[tokio::test] +async fn test_many_fallback_providers() -> anyhow::Result<()> { + let (anvil_pp, primary) = spawn_ws_anvil().await?; + let (_anvil_1, fb_1) = spawn_ws_anvil().await?; + let (_anvil_2, fb_2) = spawn_ws_anvil().await?; + let (_anvil_3, fb_3) = spawn_ws_anvil().await?; + let (_anvil_4, fb_4) = spawn_ws_anvil().await?; + let (_anvil_5, fb_5) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(primary.clone()) + .fallback(fb_1.clone()) + .fallback(fb_2.clone()) + .fallback(fb_3.clone()) + .fallback(fb_4.clone()) + .fallback(fb_5.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .call_timeout(SHORT_TIMEOUT) + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + let mut stream = subscription.into_stream(); + + // Start on primary + primary.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 1); + + // Kill primary + drop(anvil_pp); + + // Cycle through all fallbacks + trigger_failover(&mut stream, fb_1.clone(), 1).await?; + trigger_failover_with_delay(&mut stream, fb_2.clone(), 1, SHORT_TIMEOUT).await?; + trigger_failover_with_delay(&mut stream, fb_3.clone(), 1, SHORT_TIMEOUT).await?; + trigger_failover_with_delay(&mut stream, fb_4.clone(), 1, SHORT_TIMEOUT).await?; + trigger_failover_with_delay(&mut stream, fb_5.clone(), 1, SHORT_TIMEOUT).await?; + + assert!(matches!(stream.next().await.unwrap(), Err(SubscriptionError::Timeout))); + + Ok(()) +} + +// ============================================================================ +// Reconnection Tests +// ============================================================================ + +#[tokio::test] +async fn subscription_reconnects_to_primary() -> anyhow::Result<()> { + let (_anvil_1, primary) = spawn_ws_anvil().await?; + let (_anvil_2, fallback) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(primary.clone()) + .fallback(fallback.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .reconnect_interval(RECONNECT_INTERVAL) + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + let mut stream = subscription.into_stream(); + + // Start on primary + primary.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 1); + + // PP times out -> FP1 + trigger_failover(&mut stream, fallback.clone(), 1).await?; + + fallback.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 2); + + // FP1 times out -> PP (reconnect succeeds) + trigger_failover(&mut stream, primary.clone(), 2).await?; + + // PP times out -> FP1 (fallback index was reset) + trigger_failover(&mut stream, fallback.clone(), 3).await?; + + fallback.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 4); + + Ok(()) +} + +#[tokio::test] +async fn subscription_periodically_reconnects_to_primary_while_on_fallback() -> anyhow::Result<()> { + // Use a longer reconnect interval to make timing more predictable + let reconnect_interval = Duration::from_millis(800); + + let (_anvil_1, primary) = spawn_ws_anvil().await?; + let (_anvil_2, fallback) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(primary.clone()) + .fallback(fallback.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .reconnect_interval(reconnect_interval) + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + let mut stream = subscription.into_stream(); + + // Start on primary + primary.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 1); + + // PP times out -> FP (this sets last_reconnect_attempt) + trigger_failover(&mut stream, fallback.clone(), 1).await?; + let failover_time = Instant::now(); + + // Now on fallback - mine blocks before reconnect_interval elapses + // These should stay on fallback (no reconnect attempt) + fallback.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 2); + + fallback.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 3); + + // Ensure reconnect_interval has fully elapsed since failover + let elapsed = failover_time.elapsed(); + if elapsed < reconnect_interval + BUFFER_TIME { + sleep(reconnect_interval + BUFFER_TIME - elapsed).await; + } + + // Mine on fallback - receiving this block triggers try_reconnect_to_primary + fallback.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 4); + + // Now we should be back on primary + primary.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 2); + + primary.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 3); + + Ok(()) +} + +#[tokio::test] +async fn test_reconnection_skipped_before_interval_elapsed() -> anyhow::Result<()> { + let (_anvil_1, primary) = spawn_ws_anvil().await?; + let (_anvil_2, fallback) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(primary.clone()) + .fallback(fallback.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .reconnect_interval(Duration::from_secs(10)) // Long interval + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + let mut stream = subscription.into_stream(); + + // Start on primary + primary.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 1); + primary.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 2); + + // Failover to fallback + trigger_failover(&mut stream, fallback.clone(), 1).await?; + + // Immediately try another recv - should stay on fallback (no reconnect attempt) + fallback.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 2); + + Ok(()) +} + +#[tokio::test] +async fn test_successful_reconnection_resets_state() -> anyhow::Result<()> { + let (_anvil_1, primary) = spawn_ws_anvil().await?; + let (_anvil_2, fb_1) = spawn_ws_anvil().await?; + let (_anvil_3, fb_2) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(primary.clone()) + .fallback(fb_1.clone()) + .fallback(fb_2.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .reconnect_interval(RECONNECT_INTERVAL) + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + let mut stream = subscription.into_stream(); + + // Start on primary + primary.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 1); + + // Failover to fallback + trigger_failover(&mut stream, fb_1.clone(), 1).await?; + + fb_1.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 2); + + // Wait for reconnect interval, then timeout - reconnect to primary + sleep(RECONNECT_INTERVAL).await; + trigger_failover(&mut stream, primary.clone(), 2).await?; + + // After reconnection, next failover should go to fallback[0] again (not fallback[1]) + trigger_failover(&mut stream, fb_1.clone(), 3).await?; + + Ok(()) +} + +#[tokio::test] +async fn test_multiple_failed_reconnection_attempts() -> anyhow::Result<()> { + let (anvil_pp, primary) = spawn_ws_anvil().await?; + let (_anvil_1, fb_1) = spawn_ws_anvil().await?; + let (_anvil_2, fb_2) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(primary.clone()) + .fallback(fb_1.clone()) + .fallback(fb_2.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .reconnect_interval(RECONNECT_INTERVAL) + .call_timeout(SHORT_TIMEOUT) + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + let mut stream = subscription.into_stream(); + + // Start on primary + primary.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 1); + + // Kill primary + drop(anvil_pp); + + // Failover to fb_1 (primary is dead) + trigger_failover(&mut stream, fb_1.clone(), 1).await?; + + // Stay on fb_1 for a bit + fb_1.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 2); + + // Wait for reconnect interval, then timeout - try primary (fails), go to fb_2 + sleep(RECONNECT_INTERVAL).await; + trigger_failover_with_delay(&mut stream, fb_2.clone(), 1, SHORT_TIMEOUT).await?; + + // fb_2 continues to work + fb_2.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 2); + + Ok(()) +} + +#[tokio::test] +async fn test_primary_reconnect_attempt_before_next_fallback() -> anyhow::Result<()> { + let (_anvil_1, primary) = spawn_ws_anvil().await?; + let (_anvil_2, fb_1) = spawn_ws_anvil().await?; + let (_anvil_3, fb_2) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(primary.clone()) + .fallback(fb_1.clone()) + .fallback(fb_2.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .reconnect_interval(RECONNECT_INTERVAL) + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + let mut stream = subscription.into_stream(); + + // Start on primary + primary.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 1); + + // PP -> FB1 + trigger_failover(&mut stream, fb_1.clone(), 1).await?; + + // FB1 -> PP (reconnect succeeds, not FB2) + trigger_failover(&mut stream, primary.clone(), 2).await?; + + Ok(()) +} + +// ============================================================================ +// Error Propagation Tests +// ============================================================================ + +#[tokio::test] +async fn test_backend_gone_error_propagation() -> anyhow::Result<()> { + let (anvil, provider) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(provider.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + let mut stream = subscription.into_stream(); + + // Get one block + provider.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 1); + + // Kill the provider + drop(anvil); + + // Should get BackendGone or Timeout error + assert!(matches!(stream.next().await.unwrap(), Err(SubscriptionError::Timeout))); + + Ok(()) +} + +#[tokio::test] +async fn test_immediate_consecutive_failures() -> anyhow::Result<()> { + let (anvil, provider) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(provider.clone()) + .subscription_timeout(SHORT_TIMEOUT) + .build() + .await?; + + let subscription = robust.subscribe_blocks().await?; + let mut stream = subscription.into_stream(); + + // Get one block + provider.anvil_mine(Some(1), None).await?; + assert_next_block!(stream, 1); + + // Kill provider immediately + drop(anvil); + + // First failure + assert!(matches!(stream.next().await.unwrap(), Err(SubscriptionError::Timeout))); + + Ok(()) +} + +#[tokio::test] +async fn test_subscription_lagged_error() -> anyhow::Result<()> { + let (_anvil, provider) = spawn_ws_anvil().await?; + + let robust = RobustProviderBuilder::fragile(provider.clone()) + .subscription_timeout(Duration::from_secs(5)) + .build() + .await?; + + let mut subscription = robust.subscribe_blocks().await?; + + // Mine more blocks than channel can hold without consuming + provider.anvil_mine(Some(DEFAULT_SUBSCRIPTION_BUFFER_CAPACITY as u64 + 1), None).await?; + + // Allow time for block notifications to propagate through WebSocket + // and fill the subscription channel + sleep(BUFFER_TIME).await; + + // First recv should return Lagged error (skipped some blocks) + let result = subscription.recv().await; + assert!(matches!(result, Err(SubscriptionError::Lagged(_)))); + + Ok(()) +} + +// ============================================================================ +// Pubsub Support Tests +// ============================================================================ + +#[tokio::test] +async fn test_subscribe_fails_when_all_providers_lack_pubsub() -> anyhow::Result<()> { + let anvil = Anvil::new().try_spawn()?; + + let http_provider = ProviderBuilder::new().connect_http(anvil.endpoint_url()); + + let robust = RobustProviderBuilder::new(http_provider.clone()) + .fallback(http_provider) + .call_timeout(Duration::from_secs(5)) + .min_delay(Duration::from_millis(100)) + .build() + .await?; + + let result = robust.subscribe_blocks().await.unwrap_err(); + + match result { + robust_provider::Error::RpcError(e) => { + assert!(matches!( + e.as_ref(), + alloy::transports::RpcError::Transport( + alloy::transports::TransportErrorKind::PubsubUnavailable + ) + )); + } + other => panic!("Expected PubsubUnavailable error type, got: {other:?}"), + } + + Ok(()) +} + +#[tokio::test] +async fn test_subscribe_succeeds_if_primary_provider_lacks_pubsub_but_fallback_supports_it() +-> anyhow::Result<()> { + let anvil = Anvil::new().try_spawn()?; + + let http_provider = ProviderBuilder::new().connect_http(anvil.endpoint_url()); + let ws_provider = ProviderBuilder::new() + .connect_ws(alloy::providers::WsConnect::new(anvil.ws_endpoint_url().as_str())) + .await?; + + let robust = RobustProviderBuilder::fragile(http_provider) + .fallback(ws_provider) + .call_timeout(Duration::from_secs(5)) + .build() + .await?; + + let result = robust.subscribe_blocks().await; + assert!(result.is_ok()); + + Ok(()) +} From d45ca5f6b1b67b9c5b2e09ef30f7a04e1f85b676 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 13 Jan 2026 12:41:04 +0100 Subject: [PATCH 06/25] ref: const to top --- tests/common.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/common.rs b/tests/common.rs index 1890595..ce06071 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -6,6 +6,15 @@ use alloy::providers::{Provider, ProviderBuilder, RootProvider, ext::AnvilApi}; use alloy_node_bindings::{Anvil, AnvilInstance}; use robust_provider::{RobustProvider, RobustProviderBuilder}; +/// Short timeout for tests. +pub const SHORT_TIMEOUT: Duration = Duration::from_millis(300); + +/// Reconnect interval for tests. +pub const RECONNECT_INTERVAL: Duration = Duration::from_millis(500); + +/// Buffer time for async operations. +pub const BUFFER_TIME: Duration = Duration::from_millis(100); + /// Setup a basic Anvil instance with a RobustProvider. pub async fn setup_anvil() -> anyhow::Result<(AnvilInstance, RobustProvider, impl Provider)> { let anvil = Anvil::new().try_spawn()?; @@ -34,12 +43,3 @@ pub async fn spawn_ws_anvil() -> anyhow::Result<(AnvilInstance, RootProvider)> { let provider = ProviderBuilder::new().connect(anvil.ws_endpoint_url().as_str()).await?; Ok((anvil, provider.root().to_owned())) } - -/// Short timeout for tests. -pub const SHORT_TIMEOUT: Duration = Duration::from_millis(300); - -/// Reconnect interval for tests. -pub const RECONNECT_INTERVAL: Duration = Duration::from_millis(500); - -/// Buffer time for async operations. -pub const BUFFER_TIME: Duration = Duration::from_millis(100); From 6d8833feb5b6bd06cea17ded679529a4952a902f Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 13 Jan 2026 12:44:15 +0100 Subject: [PATCH 07/25] fix: format --- src/robust_provider/subscription.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/robust_provider/subscription.rs b/src/robust_provider/subscription.rs index c59aa9d..b97ad77 100644 --- a/src/robust_provider/subscription.rs +++ b/src/robust_provider/subscription.rs @@ -131,8 +131,8 @@ impl RobustSubscription { /// Returns true if reconnection was successful, false if it's not time yet or if it failed. async fn try_reconnect_to_primary(&mut self, force: bool) -> bool { // Check if we should attempt reconnection - let should_reconnect = force - || match self.last_reconnect_attempt { + let should_reconnect = force || + match self.last_reconnect_attempt { None => false, Some(last_attempt) => { last_attempt.elapsed() >= self.robust_provider.reconnect_interval From 38fab68214641b73784bf060655790c86e4f5a84 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 13 Jan 2026 12:50:38 +0100 Subject: [PATCH 08/25] fix: clippy --- tests/common.rs | 5 ++++- tests/custom_methods.rs | 4 ++-- tests/eth_namespace.rs | 5 ++--- tests/subscription.rs | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/common.rs b/tests/common.rs index ce06071..d24c266 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -1,5 +1,8 @@ //! Common test utilities and helpers for integration tests. +#![allow(dead_code)] +#![allow(clippy::missing_errors_doc)] + use std::time::Duration; use alloy::providers::{Provider, ProviderBuilder, RootProvider, ext::AnvilApi}; @@ -15,7 +18,7 @@ pub const RECONNECT_INTERVAL: Duration = Duration::from_millis(500); /// Buffer time for async operations. pub const BUFFER_TIME: Duration = Duration::from_millis(100); -/// Setup a basic Anvil instance with a RobustProvider. +/// Setup a basic Anvil instance with a `RobustProvider`. pub async fn setup_anvil() -> anyhow::Result<(AnvilInstance, RobustProvider, impl Provider)> { let anvil = Anvil::new().try_spawn()?; let alloy_provider = ProviderBuilder::new().connect_http(anvil.endpoint_url()); diff --git a/tests/custom_methods.rs b/tests/custom_methods.rs index 4655d04..2735bdb 100644 --- a/tests/custom_methods.rs +++ b/tests/custom_methods.rs @@ -1,6 +1,6 @@ -//! Tests for RobustProvider custom methods. +//! Tests for `RobustProvider` custom methods. //! -//! These tests cover methods that are unique to RobustProvider and not +//! These tests cover methods that are unique to `RobustProvider` and not //! direct wrappers of standard Ethereum JSON-RPC methods. mod common; diff --git a/tests/eth_namespace.rs b/tests/eth_namespace.rs index ee36b3c..4d2df0d 100644 --- a/tests/eth_namespace.rs +++ b/tests/eth_namespace.rs @@ -1,7 +1,7 @@ -//! Tests for Ethereum JSON-RPC namespace methods exposed by RobustProvider. +//! Tests for Ethereum JSON-RPC namespace methods exposed by `RobustProvider`. //! //! These tests verify the behavior of standard Ethereum RPC methods wrapped -//! by RobustProvider with retry and failover logic. +//! by `RobustProvider` with retry and failover logic. mod common; @@ -9,7 +9,6 @@ use alloy::{ eips::{BlockId, BlockNumberOrTag}, primitives::BlockHash, providers::{Provider, ext::AnvilApi}, - rpc::types::Filter, }; use common::{setup_anvil, setup_anvil_with_blocks}; use robust_provider::Error; diff --git a/tests/subscription.rs b/tests/subscription.rs index ee7c9c6..f018dbd 100644 --- a/tests/subscription.rs +++ b/tests/subscription.rs @@ -1,4 +1,4 @@ -//! Tests for RobustSubscription and block subscription functionality. +//! Tests for `RobustSubscription` and block subscription functionality. //! //! These tests cover WebSocket subscriptions, failover behavior, //! reconnection logic, and stream handling. From 54f3d565dd6d658d2e428f55bc715eac98c8c5b7 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 13 Jan 2026 13:26:54 +0100 Subject: [PATCH 09/25] feat: add mod.rs --- tests/{common.rs => common/mod.rs} | 2 +- tests/mod.rs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) rename tests/{common.rs => common/mod.rs} (96%) create mode 100644 tests/mod.rs diff --git a/tests/common.rs b/tests/common/mod.rs similarity index 96% rename from tests/common.rs rename to tests/common/mod.rs index d24c266..f24ed4c 100644 --- a/tests/common.rs +++ b/tests/common/mod.rs @@ -18,7 +18,7 @@ pub const RECONNECT_INTERVAL: Duration = Duration::from_millis(500); /// Buffer time for async operations. pub const BUFFER_TIME: Duration = Duration::from_millis(100); -/// Setup a basic Anvil instance with a `RobustProvider`. +// Setup a basic Anvil instance with a `RobustProvider`. pub async fn setup_anvil() -> anyhow::Result<(AnvilInstance, RobustProvider, impl Provider)> { let anvil = Anvil::new().try_spawn()?; let alloy_provider = ProviderBuilder::new().connect_http(anvil.endpoint_url()); diff --git a/tests/mod.rs b/tests/mod.rs new file mode 100644 index 0000000..16030b0 --- /dev/null +++ b/tests/mod.rs @@ -0,0 +1,4 @@ +mod common; +mod custom_methods; +mod eth_namespace; +mod subscription; From d92bcb135994edf17aca5f00392bcbeb3dabc427 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 13 Jan 2026 13:37:34 +0100 Subject: [PATCH 10/25] fix: remove mod --- tests/mod.rs | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 tests/mod.rs diff --git a/tests/mod.rs b/tests/mod.rs deleted file mode 100644 index 16030b0..0000000 --- a/tests/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod common; -mod custom_methods; -mod eth_namespace; -mod subscription; From 7334aaf5c9b134956cb61c248cebf4b5cfb9fcad Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Jan 2026 19:20:13 +0100 Subject: [PATCH 11/25] feat: remove test utils --- .vscode/settings.json | 1 - 1 file changed, 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 4ed7e21..a47cdf9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,3 @@ { - "rust-analyzer.cargo.features": ["test-utils"], "rust-analyzer.rustfmt.extraArgs": ["+nightly"] } From 2e517b0f60f60f775d9f2f08b038365555d4e4a5 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Jan 2026 19:26:00 +0100 Subject: [PATCH 12/25] ref: remove safe finalized --- src/robust_provider/builder.rs | 24 +-- src/robust_provider/mod.rs | 2 +- src/robust_provider/provider.rs | 372 +++++++++++++++++++++++++------- tests/custom_methods.rs | 38 ---- 4 files changed, 292 insertions(+), 144 deletions(-) diff --git a/src/robust_provider/builder.rs b/src/robust_provider/builder.rs index c5a77df..eecc044 100644 --- a/src/robust_provider/builder.rs +++ b/src/robust_provider/builder.rs @@ -3,9 +3,7 @@ use std::{pin::Pin, time::Duration}; use alloy::{network::Network, providers::RootProvider}; use crate::robust_provider::{ - IntoRootProvider, RobustProvider, - provider::{DEFAULT_FINALIZATION_HEIGHT, Error}, - subscription::DEFAULT_RECONNECT_INTERVAL, + IntoRootProvider, RobustProvider, provider::Error, subscription::DEFAULT_RECONNECT_INTERVAL, }; type BoxedProviderFuture = Pin, Error>> + Send>>; @@ -34,7 +32,6 @@ pub struct RobustProviderBuilder> { min_delay: Duration, reconnect_interval: Duration, subscription_buffer_capacity: usize, - finalization_height: u64, } impl> RobustProviderBuilder { @@ -53,7 +50,6 @@ impl> RobustProviderBuilder { min_delay: DEFAULT_MIN_DELAY, reconnect_interval: DEFAULT_RECONNECT_INTERVAL, subscription_buffer_capacity: DEFAULT_SUBSCRIPTION_BUFFER_CAPACITY, - finalization_height: DEFAULT_FINALIZATION_HEIGHT, } } @@ -129,23 +125,6 @@ impl> RobustProviderBuilder { self } - /// Set the number of blocks required before finalization is expected. - /// - /// This is used by [`RobustProvider::get_safe_finalized_block`] and - /// [`RobustProvider::get_safe_finalized_block_number`] to determine whether - /// to query for the finalized block or fall back to genesis. - /// - /// If the chain height is less than this value, the safe finalized methods - /// will return the earliest block (genesis) instead of querying for the - /// finalized block, which may not exist on young chains. - /// - /// Default is [`DEFAULT_FINALIZATION_HEIGHT`] (64 blocks). - #[must_use] - pub fn finalization_height(mut self, height: u64) -> Self { - self.finalization_height = height; - self - } - /// Build the `RobustProvider`. /// /// Final builder method: consumes the builder and returns the built [`RobustProvider`]. @@ -181,7 +160,6 @@ impl> RobustProviderBuilder { min_delay: self.min_delay, reconnect_interval: self.reconnect_interval, subscription_buffer_capacity: self.subscription_buffer_capacity, - finalization_height: self.finalization_height, }) } } diff --git a/src/robust_provider/mod.rs b/src/robust_provider/mod.rs index 77ce97d..28dd58a 100644 --- a/src/robust_provider/mod.rs +++ b/src/robust_provider/mod.rs @@ -65,7 +65,7 @@ pub mod provider_conversion; pub mod subscription; pub use builder::*; -pub use provider::{DEFAULT_FINALIZATION_HEIGHT, Error, RobustProvider}; +pub use provider::{Error, RobustProvider}; pub use provider_conversion::{IntoRobustProvider, IntoRootProvider}; pub use subscription::{ DEFAULT_RECONNECT_INTERVAL, Error as SubscriptionError, RobustSubscription, diff --git a/src/robust_provider/provider.rs b/src/robust_provider/provider.rs index 0e30feb..0e74f5c 100644 --- a/src/robust_provider/provider.rs +++ b/src/robust_provider/provider.rs @@ -80,14 +80,6 @@ impl From for Error { } } -/// Default number of blocks required for finalization. -/// -/// This is used by [`RobustProvider::get_safe_finalized_block`] and -/// [`RobustProvider::get_safe_finalized_block_number`] to determine whether -/// the chain has reached sufficient height for finalization queries. -/// On Ethereum mainnet, finalization typically occurs after 64 blocks. -pub const DEFAULT_FINALIZATION_HEIGHT: u64 = 64; - /// Provider wrapper with built-in retry and timeout mechanisms. /// /// This wrapper around Alloy providers automatically handles retries, @@ -102,7 +94,6 @@ pub struct RobustProvider { pub(crate) min_delay: Duration, pub(crate) reconnect_interval: Duration, pub(crate) subscription_buffer_capacity: usize, - pub(crate) finalization_height: u64, } impl RobustProvider { @@ -224,68 +215,6 @@ impl RobustProvider { Ok(confirmed_block) } - /// Fetch the finalized block, falling back to genesis if finalization hasn't occurred yet. - /// - /// This method handles the behavioral difference between development nodes (like Anvil) and - /// production nodes when querying for finalized blocks on young chains. - /// - /// If the current chain height is less than - /// [`finalization_height`](super::RobustProviderBuilder::finalization_height) (default 64), - /// this method returns the earliest block (genesis) instead of querying for the finalized - /// block, which may not exist or behave inconsistently across node implementations. - /// - /// # Errors - /// - /// * [`Error::RpcError`] - if no fallback providers succeeded. - /// * [`Error::Timeout`] - if the overall operation timeout elapses (i.e. exceeds - /// `call_timeout`). - /// * [`Error::BlockNotFound`] - if the block was not found on-chain. - pub async fn get_safe_finalized_block(&self) -> Result { - let chain_height = self.get_block_number().await?; - - if chain_height < self.finalization_height { - debug!( - chain_height = chain_height, - finalization_height = self.finalization_height, - "Chain height below finalization threshold, returning earliest block" - ); - return self.get_block_by_number(BlockNumberOrTag::Earliest).await; - } - - self.get_block_by_number(BlockNumberOrTag::Finalized).await - } - - /// Fetch the finalized block number, falling back to 0 if finalization hasn't occurred yet. - /// - /// This method handles the behavioral difference between development nodes (like Anvil) and - /// production nodes when querying for finalized blocks on young chains. - /// - /// If the current chain height is less than - /// [`finalization_height`](super::RobustProviderBuilder::finalization_height) (default 64), - /// this method returns 0 instead of querying for the finalized block number, which may not - /// exist or behave inconsistently across node implementations. - /// - /// # Errors - /// - /// * [`Error::RpcError`] - if no fallback providers succeeded. - /// * [`Error::Timeout`] - if the overall operation timeout elapses (i.e. exceeds - /// `call_timeout`). - /// * [`Error::BlockNotFound`] - if the block was not found on-chain. - pub async fn get_safe_finalized_block_number(&self) -> Result { - let chain_height = self.get_block_number().await?; - - if chain_height < self.finalization_height { - debug!( - chain_height = chain_height, - finalization_height = self.finalization_height, - "Chain height below finalization threshold, returning 0" - ); - return Ok(0); - } - - self.get_block_number_by_id(BlockNumberOrTag::Finalized.into()).await - } - /// Fetch a block by [`BlockHash`] with retry and timeout. /// /// This is a wrapper function for [`Provider::get_block_by_hash`]. @@ -487,16 +416,33 @@ impl RobustProvider { mod tests { use super::*; use crate::robust_provider::{ - DEFAULT_SUBSCRIPTION_BUFFER_CAPACITY, builder::DEFAULT_SUBSCRIPTION_TIMEOUT, - subscription::DEFAULT_RECONNECT_INTERVAL, + DEFAULT_SUBSCRIPTION_BUFFER_CAPACITY, RobustProviderBuilder, + builder::DEFAULT_SUBSCRIPTION_TIMEOUT, subscription::DEFAULT_RECONNECT_INTERVAL, }; - use alloy::transports::TransportErrorKind; + use alloy::providers::{ProviderBuilder, WsConnect, ext::AnvilApi}; + use alloy_node_bindings::{Anvil, AnvilInstance}; use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::time::sleep; - // ============================================================================ - // Test Helpers - // ============================================================================ + async fn setup_anvil() -> anyhow::Result<(AnvilInstance, RobustProvider, impl Provider)> { + let anvil = Anvil::new().try_spawn()?; + let alloy_provider = ProviderBuilder::new().connect_http(anvil.endpoint_url()); + + let robust = RobustProviderBuilder::new(alloy_provider.clone()) + .call_timeout(Duration::from_secs(5)) + .build() + .await?; + + Ok((anvil, robust, alloy_provider)) + } + + async fn setup_anvil_with_blocks( + num_blocks: u64, + ) -> anyhow::Result<(AnvilInstance, RobustProvider, impl Provider)> { + let (anvil, robust, alloy_provider) = setup_anvil().await?; + alloy_provider.anvil_mine(Some(num_blocks), None).await?; + Ok((anvil, robust, alloy_provider)) + } fn test_provider(timeout: u64, max_retries: usize, min_delay: u64) -> RobustProvider { RobustProvider { @@ -508,14 +454,9 @@ mod tests { min_delay: Duration::from_millis(min_delay), reconnect_interval: DEFAULT_RECONNECT_INTERVAL, subscription_buffer_capacity: DEFAULT_SUBSCRIPTION_BUFFER_CAPACITY, - finalization_height: DEFAULT_FINALIZATION_HEIGHT, } } - // ============================================================================ - // Retry Logic Tests (try_operation_with_failover internals) - // ============================================================================ - #[tokio::test] async fn test_retry_with_timeout_succeeds_on_first_attempt() { let provider = test_provider(100, 3, 10); @@ -596,4 +537,271 @@ mod tests { assert!(matches!(result, Err(CoreError::Timeout))); } + + #[tokio::test] + async fn test_subscribe_fails_when_all_providers_lack_pubsub() -> anyhow::Result<()> { + let anvil = Anvil::new().try_spawn()?; + + let http_provider = ProviderBuilder::new().connect_http(anvil.endpoint_url()); + + let robust = RobustProviderBuilder::new(http_provider.clone()) + .fallback(http_provider) + .call_timeout(Duration::from_secs(5)) + .min_delay(Duration::from_millis(100)) + .build() + .await?; + + let result = robust.subscribe_blocks().await.unwrap_err(); + + match result { + Error::RpcError(e) => { + assert!(matches!( + e.as_ref(), + RpcError::Transport(TransportErrorKind::PubsubUnavailable) + )); + } + other => panic!("Expected PubsubUnavailable error type, got: {other:?}"), + } + + Ok(()) + } + + #[tokio::test] + async fn test_subscribe_succeeds_if_primary_provider_lacks_pubsub_but_fallback_supports_it() + -> anyhow::Result<()> { + let anvil = Anvil::new().try_spawn()?; + + let http_provider = ProviderBuilder::new().connect_http(anvil.endpoint_url()); + let ws_provider = ProviderBuilder::new() + .connect_ws(WsConnect::new(anvil.ws_endpoint_url().as_str())) + .await?; + + let robust = RobustProviderBuilder::fragile(http_provider) + .fallback(ws_provider) + .call_timeout(Duration::from_secs(5)) + .build() + .await?; + + let result = robust.subscribe_blocks().await; + assert!(result.is_ok()); + + Ok(()) + } + + #[tokio::test] + async fn test_get_block_by_number_succeeds() -> anyhow::Result<()> { + let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; + + let tags = [ + BlockNumberOrTag::Number(50), + BlockNumberOrTag::Latest, + BlockNumberOrTag::Earliest, + BlockNumberOrTag::Safe, + BlockNumberOrTag::Finalized, + ]; + + for tag in tags { + let robust_block = robust.get_block_by_number(tag).await?; + let alloy_block = + alloy_provider.get_block_by_number(tag).await?.expect("block should exist"); + + assert_eq!(robust_block.header.number, alloy_block.header.number); + assert_eq!(robust_block.header.hash, alloy_block.header.hash); + } + + Ok(()) + } + + #[tokio::test] + async fn test_get_block_by_number_future_block_fails() -> anyhow::Result<()> { + let (_anvil, robust, _alloy_provider) = setup_anvil().await?; + + let future_block = 999_999; + let result = robust.get_block_by_number(BlockNumberOrTag::Number(future_block)).await; + + assert!(matches!(result, Err(Error::BlockNotFound(_)))); + + Ok(()) + } + + #[tokio::test] + async fn test_get_block_succeeds() -> anyhow::Result<()> { + let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; + + let block_ids = [ + BlockId::number(50), + BlockId::latest(), + BlockId::earliest(), + BlockId::safe(), + BlockId::finalized(), + ]; + + for block_id in block_ids { + let robust_block = robust.get_block(block_id).await?; + let alloy_block = + alloy_provider.get_block(block_id).await?.expect("block should exist"); + + assert_eq!(robust_block.header.number, alloy_block.header.number); + assert_eq!(robust_block.header.hash, alloy_block.header.hash); + } + + // test block hash + let block = alloy_provider + .get_block_by_number(BlockNumberOrTag::Number(50)) + .await? + .expect("block should exist"); + let block_hash = block.header.hash; + let block_id = BlockId::hash(block_hash); + let robust_block = robust.get_block(block_id).await?; + assert_eq!(robust_block.header.hash, block_hash); + assert_eq!(robust_block.header.number, 50); + + Ok(()) + } + + #[tokio::test] + async fn test_get_block_fails() -> anyhow::Result<()> { + let (_anvil, robust, _alloy_provider) = setup_anvil().await?; + + // Future block number + let result = robust.get_block(BlockId::number(999_999)).await; + assert!(matches!(result, Err(Error::BlockNotFound(_)))); + + // Non-existent hash + let result = robust.get_block(BlockId::hash(BlockHash::ZERO)).await; + assert!(matches!(result, Err(Error::BlockNotFound(_)))); + + Ok(()) + } + + #[tokio::test] + async fn test_get_block_number_succeeds() -> anyhow::Result<()> { + let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; + + let robust_block_num = robust.get_block_number().await?; + let alloy_block_num = alloy_provider.get_block_number().await?; + assert_eq!(robust_block_num, alloy_block_num); + assert_eq!(robust_block_num, 100); + + alloy_provider.anvil_mine(Some(10), None).await?; + let new_block = robust.get_block_number().await?; + assert_eq!(new_block, 110); + + Ok(()) + } + + #[tokio::test] + async fn test_get_block_number_by_id_succeeds() -> anyhow::Result<()> { + let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; + + let block_num = robust.get_block_number_by_id(BlockId::number(50)).await?; + assert_eq!(block_num, 50); + + let block = alloy_provider + .get_block_by_number(BlockNumberOrTag::Number(50)) + .await? + .expect("block should exist"); + let block_num = robust.get_block_number_by_id(BlockId::hash(block.header.hash)).await?; + assert_eq!(block_num, 50); + + let block_num = robust.get_block_number_by_id(BlockId::latest()).await?; + assert_eq!(block_num, 100); + + let block_num = robust.get_block_number_by_id(BlockId::earliest()).await?; + assert_eq!(block_num, 0); + + // Returns block number even if it doesnt 'exist' on chain + let block_num = robust.get_block_number_by_id(BlockId::number(999_999)).await?; + let alloy_block_num = alloy_provider + .get_block_number_by_id(BlockId::number(999_999)) + .await? + .expect("Should return block num"); + assert_eq!(alloy_block_num, block_num); + assert_eq!(block_num, 999_999); + + Ok(()) + } + + #[tokio::test] + async fn test_get_block_number_by_id_fails() -> anyhow::Result<()> { + let (_anvil, robust, _alloy_provider) = setup_anvil().await?; + + let result = robust.get_block_number_by_id(BlockId::hash(BlockHash::ZERO)).await; + assert!(matches!(result, Err(Error::BlockNotFound(_)))); + + Ok(()) + } + + #[tokio::test] + async fn test_get_latest_confirmed_succeeds() -> anyhow::Result<()> { + let (_anvil, robust, _alloy_provider) = setup_anvil_with_blocks(100).await?; + + // With confirmations + let confirmed_block = robust.get_latest_confirmed(10).await?; + assert_eq!(confirmed_block, 90); + + // Zero confirmations returns latest + let confirmed_block = robust.get_latest_confirmed(0).await?; + assert_eq!(confirmed_block, 100); + + // Single confirmation + let confirmed_block = robust.get_latest_confirmed(1).await?; + assert_eq!(confirmed_block, 99); + + // confirmations = latest - 1 + let confirmed_block = robust.get_latest_confirmed(99).await?; + assert_eq!(confirmed_block, 1); + + // confirmations = latest (should return 0) + let confirmed_block = robust.get_latest_confirmed(100).await?; + assert_eq!(confirmed_block, 0); + + // confirmations = latest + 1 (saturates at zero) + let confirmed_block = robust.get_latest_confirmed(101).await?; + assert_eq!(confirmed_block, 0); + + // Saturates at zero when confirmations > latest + let confirmed_block = robust.get_latest_confirmed(200).await?; + assert_eq!(confirmed_block, 0); + + Ok(()) + } + + #[tokio::test] + async fn test_get_block_by_hash_succeeds() -> anyhow::Result<()> { + let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; + + let block = alloy_provider + .get_block_by_number(BlockNumberOrTag::Number(50)) + .await? + .expect("block should exist"); + let block_hash = block.header.hash; + + let robust_block = robust.get_block_by_hash(block_hash).await?; + let alloy_block = + alloy_provider.get_block_by_hash(block_hash).await?.expect("block should exist"); + assert_eq!(robust_block.header.hash, alloy_block.header.hash); + assert_eq!(robust_block.header.number, alloy_block.header.number); + + let genesis = alloy_provider + .get_block_by_number(BlockNumberOrTag::Earliest) + .await? + .expect("genesis should exist"); + let genesis_hash = genesis.header.hash; + let robust_block = robust.get_block_by_hash(genesis_hash).await?; + assert_eq!(robust_block.header.number, 0); + assert_eq!(robust_block.header.hash, genesis_hash); + + Ok(()) + } + + #[tokio::test] + async fn test_get_block_by_hash_fails() -> anyhow::Result<()> { + let (_anvil, robust, _alloy_provider) = setup_anvil().await?; + + let result = robust.get_block_by_hash(BlockHash::ZERO).await; + assert!(matches!(result, Err(Error::BlockNotFound(_)))); + + Ok(()) + } } diff --git a/tests/custom_methods.rs b/tests/custom_methods.rs index 2735bdb..301fa27 100644 --- a/tests/custom_methods.rs +++ b/tests/custom_methods.rs @@ -45,41 +45,3 @@ async fn test_get_latest_confirmed_succeeds() -> anyhow::Result<()> { Ok(()) } - -// ============================================================================ -// get_safe_finalized_block -// ============================================================================ - -#[tokio::test] -#[ignore = "This currently passes but does not test against a 'real' node - see issue https://github.com/OpenZeppelin/Robust-Provider/issues/7 "] -async fn test_get_safe_finalized_block_with_small_chain_height() -> anyhow::Result<()> { - // With only genesis block, finalized block may not be available on real nodes - // but Anvil returns block 0. Either way, get_safe_finalized_block should succeed. - let (_anvil, robust, _alloy_provider) = setup_anvil_with_blocks(0).await?; - - let safe_finalized = robust.get_safe_finalized_block().await?; - - // Should return genesis or block 0 (Anvil behavior) - assert_eq!(safe_finalized.header.number, 0); - - Ok(()) -} - -// ============================================================================ -// get_safe_finalized_block_number -// ============================================================================ - -#[tokio::test] -#[ignore = "This currently passes but does not test against a 'real' node - see issue https://github.com/OpenZeppelin/Robust-Provider/issues/7 "] -async fn test_get_safe_finalized_block_number_with_small_chain_height() -> anyhow::Result<()> { - // With only genesis block, finalized block number may not be available on real nodes - // but Anvil returns 0. Either way, get_safe_finalized_block_number should succeed. - let (_anvil, robust, _alloy_provider) = setup_anvil_with_blocks(0).await?; - - let safe_finalized_num = robust.get_safe_finalized_block_number().await?; - - // Should return 0 (either directly from Anvil or as fallback) - assert_eq!(safe_finalized_num, 0); - - Ok(()) -} From 4f7bfea070d161dc0aa41bc952762cb83ebd4052 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 14 Jan 2026 23:49:30 +0100 Subject: [PATCH 13/25] ref: split test function --- tests/common/mod.rs | 35 ++------------------------------ tests/common/setup_anvil.rs | 37 ++++++++++++++++++++++++++++++++++ tests/common/setup_kurtosis.rs | 0 tests/custom_methods.rs | 8 ++++---- tests/eth_namespace.rs | 7 ++++--- tests/mod.rs | 1 + tests/subscription.rs | 4 +++- 7 files changed, 51 insertions(+), 41 deletions(-) create mode 100644 tests/common/setup_anvil.rs create mode 100644 tests/common/setup_kurtosis.rs create mode 100644 tests/mod.rs diff --git a/tests/common/mod.rs b/tests/common/mod.rs index f24ed4c..9f3bb51 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,14 +1,9 @@ //! Common test utilities and helpers for integration tests. #![allow(dead_code)] -#![allow(clippy::missing_errors_doc)] use std::time::Duration; -use alloy::providers::{Provider, ProviderBuilder, RootProvider, ext::AnvilApi}; -use alloy_node_bindings::{Anvil, AnvilInstance}; -use robust_provider::{RobustProvider, RobustProviderBuilder}; - /// Short timeout for tests. pub const SHORT_TIMEOUT: Duration = Duration::from_millis(300); @@ -18,31 +13,5 @@ pub const RECONNECT_INTERVAL: Duration = Duration::from_millis(500); /// Buffer time for async operations. pub const BUFFER_TIME: Duration = Duration::from_millis(100); -// Setup a basic Anvil instance with a `RobustProvider`. -pub async fn setup_anvil() -> anyhow::Result<(AnvilInstance, RobustProvider, impl Provider)> { - let anvil = Anvil::new().try_spawn()?; - let alloy_provider = ProviderBuilder::new().connect_http(anvil.endpoint_url()); - - let robust = RobustProviderBuilder::new(alloy_provider.clone()) - .call_timeout(Duration::from_secs(5)) - .build() - .await?; - - Ok((anvil, robust, alloy_provider)) -} - -/// Setup an Anvil instance with pre-mined blocks. -pub async fn setup_anvil_with_blocks( - num_blocks: u64, -) -> anyhow::Result<(AnvilInstance, RobustProvider, impl Provider)> { - let (anvil, robust, alloy_provider) = setup_anvil().await?; - alloy_provider.anvil_mine(Some(num_blocks), None).await?; - Ok((anvil, robust, alloy_provider)) -} - -/// Spawn a WebSocket-enabled Anvil instance. -pub async fn spawn_ws_anvil() -> anyhow::Result<(AnvilInstance, RootProvider)> { - let anvil = Anvil::new().try_spawn()?; - let provider = ProviderBuilder::new().connect(anvil.ws_endpoint_url().as_str()).await?; - Ok((anvil, provider.root().to_owned())) -} +pub mod setup_anvil; +pub mod setup_kurtosis; diff --git a/tests/common/setup_anvil.rs b/tests/common/setup_anvil.rs new file mode 100644 index 0000000..487004a --- /dev/null +++ b/tests/common/setup_anvil.rs @@ -0,0 +1,37 @@ +#![allow(dead_code)] +#![allow(clippy::missing_errors_doc)] + +use std::time::Duration; + +use alloy::providers::{Provider, ProviderBuilder, RootProvider, ext::AnvilApi}; +use alloy_node_bindings::{Anvil, AnvilInstance}; +use robust_provider::{RobustProvider, RobustProviderBuilder}; + +// Setup a basic Anvil instance with a `RobustProvider`. +pub async fn setup_anvil() -> anyhow::Result<(AnvilInstance, RobustProvider, impl Provider)> { + let anvil = Anvil::new().try_spawn()?; + let alloy_provider = ProviderBuilder::new().connect_http(anvil.endpoint_url()); + + let robust = RobustProviderBuilder::new(alloy_provider.clone()) + .call_timeout(Duration::from_secs(5)) + .build() + .await?; + + Ok((anvil, robust, alloy_provider)) +} + +/// Setup an Anvil instance with pre-mined blocks. +pub async fn setup_anvil_with_blocks( + num_blocks: u64, +) -> anyhow::Result<(AnvilInstance, RobustProvider, impl Provider)> { + let (anvil, robust, alloy_provider) = setup_anvil().await?; + alloy_provider.anvil_mine(Some(num_blocks), None).await?; + Ok((anvil, robust, alloy_provider)) +} + +/// Spawn a WebSocket-enabled Anvil instance. +pub async fn spawn_ws_anvil() -> anyhow::Result<(AnvilInstance, RootProvider)> { + let anvil = Anvil::new().try_spawn()?; + let provider = ProviderBuilder::new().connect(anvil.ws_endpoint_url().as_str()).await?; + Ok((anvil, provider.root().to_owned())) +} diff --git a/tests/common/setup_kurtosis.rs b/tests/common/setup_kurtosis.rs new file mode 100644 index 0000000..e69de29 diff --git a/tests/custom_methods.rs b/tests/custom_methods.rs index 301fa27..99d5800 100644 --- a/tests/custom_methods.rs +++ b/tests/custom_methods.rs @@ -3,14 +3,14 @@ //! These tests cover methods that are unique to `RobustProvider` and not //! direct wrappers of standard Ethereum JSON-RPC methods. -mod common; - -use common::setup_anvil_with_blocks; - // ============================================================================ // get_latest_confirmed // ============================================================================ +use crate::common::setup_anvil::setup_anvil_with_blocks; + +mod common; + #[tokio::test] async fn test_get_latest_confirmed_succeeds() -> anyhow::Result<()> { let (_anvil, robust, _alloy_provider) = setup_anvil_with_blocks(100).await?; diff --git a/tests/eth_namespace.rs b/tests/eth_namespace.rs index 4d2df0d..cf6d8f1 100644 --- a/tests/eth_namespace.rs +++ b/tests/eth_namespace.rs @@ -3,16 +3,17 @@ //! These tests verify the behavior of standard Ethereum RPC methods wrapped //! by `RobustProvider` with retry and failover logic. -mod common; - use alloy::{ eips::{BlockId, BlockNumberOrTag}, primitives::BlockHash, providers::{Provider, ext::AnvilApi}, }; -use common::{setup_anvil, setup_anvil_with_blocks}; use robust_provider::Error; +use crate::common::setup_anvil::{setup_anvil, setup_anvil_with_blocks}; + +mod common; + // ============================================================================ // eth_getBlockByNumber // ============================================================================ diff --git a/tests/mod.rs b/tests/mod.rs new file mode 100644 index 0000000..a5961aa --- /dev/null +++ b/tests/mod.rs @@ -0,0 +1 @@ +mod common; diff --git a/tests/subscription.rs b/tests/subscription.rs index f018dbd..0dd3fd5 100644 --- a/tests/subscription.rs +++ b/tests/subscription.rs @@ -12,7 +12,7 @@ use alloy::{ providers::{ProviderBuilder, RootProvider, ext::AnvilApi}, }; use alloy_node_bindings::Anvil; -use common::{BUFFER_TIME, RECONNECT_INTERVAL, SHORT_TIMEOUT, spawn_ws_anvil}; +use common::{BUFFER_TIME, RECONNECT_INTERVAL, SHORT_TIMEOUT}; use robust_provider::{ DEFAULT_SUBSCRIPTION_BUFFER_CAPACITY, RobustProviderBuilder, RobustSubscriptionStream, SubscriptionError, @@ -20,6 +20,8 @@ use robust_provider::{ use tokio::time::sleep; use tokio_stream::StreamExt; +use crate::common::setup_anvil::spawn_ws_anvil; + // ============================================================================ // Test Helpers // ============================================================================ From ecd5a74200ad1ac62c981b705aab3213a348acf4 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 15 Jan 2026 10:41:13 +0100 Subject: [PATCH 14/25] feat: add network params and kurtosis script --- scripts/setup-kurtosis.sh | 102 +++++++++++++++++++++++++++++++ tests/common/network_params.yaml | 6 ++ tests/common/setup_kurtosis.rs | 44 +++++++++++++ 3 files changed, 152 insertions(+) create mode 100755 scripts/setup-kurtosis.sh create mode 100644 tests/common/network_params.yaml diff --git a/scripts/setup-kurtosis.sh b/scripts/setup-kurtosis.sh new file mode 100755 index 0000000..31c2e39 --- /dev/null +++ b/scripts/setup-kurtosis.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash + +# +# WARNING: This script was AI generated +# +# Setup Kurtosis ethereum-package devnet and output EL endpoints as JSON. +# +# Usage: +# ./scripts/setup-kurtosis.sh +# +# Output: target/kurtosis-endpoints.json +# +# JSON format: +# [ +# { "client": "geth", "http": "http://127.0.0.1:32003", "ws": "ws://127.0.0.1:32004" }, +# ... +# ] + +set -euo pipefail + +ENCLAVE="local-eth-testnet" +OUTPUT_DIR="target" +OUTPUT_FILE="$OUTPUT_DIR/kurtosis-endpoints.json" + +# Ensure output directory exists +mkdir -p "$OUTPUT_DIR" + +# Check if kurtosis is installed +if ! command -v kurtosis &> /dev/null; then + echo "Error: kurtosis CLI not found. Please install it first." >&2 + echo "See: https://docs.kurtosis.com/install" >&2 + exit 1 +fi + +# Check if kurtosis engine is running +if ! kurtosis engine status &> /dev/null; then + echo "Starting Kurtosis engine..." + kurtosis engine start +fi + +# Check if enclave exists +enclave_exists() { + kurtosis enclave ls 2>/dev/null | grep -q "^[^ ]*[[:space:]]*$ENCLAVE[[:space:]]" +} + +if ! enclave_exists; then + echo "Enclave '$ENCLAVE' not found. Creating new enclave with ethereum-package..." + kurtosis run --enclave "$ENCLAVE" github.com/ethpandaops/ethereum-package --args-file ./tests/common/network_params.yaml +fi + +echo "Inspecting enclave '$ENCLAVE'..." + +# Get list of EL services (lines starting with "el-") +el_services=$(kurtosis enclave inspect "$ENCLAVE" 2>/dev/null | grep -E "^[a-f0-9]+[[:space:]]+el-" | awk '{print $2}') + +if [ -z "$el_services" ]; then + echo "Error: No EL services found in enclave '$ENCLAVE'" >&2 + exit 1 +fi + +# Build JSON array +json="[" +first=true + +for service in $el_services; do + # Extract client name from service name (e.g., "el-1-geth-lighthouse" -> "geth") + # Format: el-{index}-{client}-{cl_client} + client=$(echo "$service" | sed -E 's/^el-[0-9]+-([^-]+)-.*/\1/') + + # Get port mappings + rpc_addr=$(kurtosis port print "$ENCLAVE" "$service" rpc 2>/dev/null || echo "") + ws_addr=$(kurtosis port print "$ENCLAVE" "$service" ws 2>/dev/null || echo "") + + if [ -z "$rpc_addr" ]; then + echo "Warning: Could not get RPC port for $service, skipping..." >&2 + continue + fi + + # Build JSON object + if [ "$first" = true ]; then + first=false + else + json+="," + fi + + http_url="http://$rpc_addr" + + if [ -n "$ws_addr" ]; then + ws_url="ws://$ws_addr" + json+=$(printf '\n {"client": "%s", "http": "%s", "ws": "%s"}' "$client" "$http_url" "$ws_url") + else + json+=$(printf '\n {"client": "%s", "http": "%s", "ws": null}' "$client" "$http_url") + fi +done + +json+="\n]" + +# Write JSON to file +printf "$json\n" > "$OUTPUT_FILE" + +echo "Wrote endpoints to $OUTPUT_FILE:" +cat "$OUTPUT_FILE" diff --git a/tests/common/network_params.yaml b/tests/common/network_params.yaml new file mode 100644 index 0000000..4390123 --- /dev/null +++ b/tests/common/network_params.yaml @@ -0,0 +1,6 @@ +participants: + - el_type: geth + - el_type: nethermind + - el_type: besu + - el_type: reth + cl_type: lighthouse diff --git a/tests/common/setup_kurtosis.rs b/tests/common/setup_kurtosis.rs index e69de29..908e77b 100644 --- a/tests/common/setup_kurtosis.rs +++ b/tests/common/setup_kurtosis.rs @@ -0,0 +1,44 @@ +#![allow(dead_code)] +#![allow(clippy::missing_errors_doc)] + +use std::fs; + +use serde::Deserialize; + +const ENDPOINTS_FILE: &str = "target/kurtosis-endpoints.json"; + +/// A single EL endpoint entry from the JSON file. +#[derive(Debug, Clone, Deserialize)] +pub struct ElEndpoint { + pub client: String, + pub http: String, + pub ws: Option, +} + +/// Load Execution Layer (EL) endpoints from the JSON file generated by `scripts/setup-kurtosis.sh`. +/// +/// The script must be run before the tests to populate `target/kurtosis-endpoints.json`. +/// +/// # Example +/// +/// ```bash +/// ./scripts/setup-kurtosis.sh +/// cargo test --features integration +/// ``` +pub fn load_el_endpoints() -> anyhow::Result> { + let content = fs::read_to_string(ENDPOINTS_FILE).map_err(|e| { + anyhow::anyhow!( + "Failed to read '{ENDPOINTS_FILE}': {e}\n\n\ + Run './scripts/setup-kurtosis.sh ' first to generate the endpoints file." + ) + })?; + + let endpoints: Vec = serde_json::from_str(&content) + .map_err(|e| anyhow::anyhow!("Failed to parse '{ENDPOINTS_FILE}': {e}"))?; + + if endpoints.is_empty() { + anyhow::bail!("No EL endpoints found in '{ENDPOINTS_FILE}'"); + } + + Ok(endpoints) +} From 82dc3258a3f4b37473f12e4c5fa0fa52d278c6f1 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 15 Jan 2026 10:41:34 +0100 Subject: [PATCH 15/25] feat: add json serde --- Cargo.lock | 2 ++ Cargo.toml | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 22b71ea..b227956 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3015,6 +3015,8 @@ dependencies = [ "anyhow", "backon", "futures", + "serde", + "serde_json", "thiserror", "tokio", "tokio-stream", diff --git a/Cargo.toml b/Cargo.toml index a91a674..061721c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,5 +32,12 @@ tokio-util = "0.7.17" anyhow = "1.0" +[dev-dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + + [features] tracing = [] +# Enables tests that require an external environment (e.g. Kurtosis devnet) +integration = [] From ad11a19773e0d773fae152213f96db6510bb4085 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 15 Jan 2026 11:36:58 +0100 Subject: [PATCH 16/25] feat: add alias --- .cargo/config.toml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..29d306d --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[alias] +local-test = "nextest run" +int-test = "nextest run --features integration" From 9630c9e22829ed3704dd1e6f0e06bcd84285c34e Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 15 Jan 2026 11:47:46 +0100 Subject: [PATCH 17/25] feat: update readme to explain how to run int tests --- README.md | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 71bb6ee..4973d59 100644 --- a/README.md +++ b/README.md @@ -203,13 +203,46 @@ let robust: RobustProvider = provider.into().await?; ## Testing -Run the test suite: +### Local Tests (Anvil) + +Run unit and local integration tests using Anvil instances: + +```bash +cargo local-test +``` + +These tests verify retry logic, failover behaviour, and subscription resilience against local Anvil instances that are spawned automatically. + +### Integration Tests (Kurtosis) + +Integration tests run against real Ethereum execution clients (geth, nethermind, besu, reth) in a Kurtosis devnet. + +**Prerequisites:** +- [Docker](https://docs.docker.com/get-docker/) +- [Kurtosis CLI](https://docs.kurtosis.com/install) + +**Setup and run:** + +```bash +# 1. Start the Kurtosis devnet (creates enclave and outputs endpoints) +./scripts/setup-kurtosis.sh + +# 2. Run integration tests +cargo int-test +``` + +The setup script will: +1. Start the Kurtosis engine if not running +2. Create a `local-eth-testnet` enclave with multiple EL clients +3. Write endpoint URLs to `target/kurtosis-endpoints.json` + +**Cleanup:** ```bash -cargo nextest run +kurtosis enclave rm local-eth-testnet ``` -The tests use local Anvil instances to verify retry logic, failover behaviour, and subscription resilience. +Advised that you run this before re-spinning up the testnet --- From 241438f770193e421209f55b086678daeacd6398 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 15 Jan 2026 11:54:00 +0100 Subject: [PATCH 18/25] feat: add integration tests --- tests/integration/http_endpoints.rs | 407 ++++++++++++++++++++++++++++ tests/integration/mod.rs | 1 + tests/mod.rs | 1 + 3 files changed, 409 insertions(+) create mode 100644 tests/integration/http_endpoints.rs create mode 100644 tests/integration/mod.rs diff --git a/tests/integration/http_endpoints.rs b/tests/integration/http_endpoints.rs new file mode 100644 index 0000000..fc92d8e --- /dev/null +++ b/tests/integration/http_endpoints.rs @@ -0,0 +1,407 @@ +//! Integration tests for `RobustProvider` HTTP endpoints against Kurtosis devnet. +//! +//! These tests verify that the RobustProvider methods work correctly against +//! real Ethereum execution clients (geth, nethermind, besu, reth) running in Kurtosis. +//! +//! Prerequisites: +//! ```bash +//! ./scripts/setup-kurtosis.sh local-eth-testnet +//! ``` + +#![cfg(feature = "integration")] + +use std::time::Duration; + +use alloy::{ + eips::{BlockId, BlockNumberOrTag}, + primitives::BlockHash, + providers::{Provider, ProviderBuilder}, + rpc::types::Filter, + transports::http::reqwest::Url, +}; +use anyhow::Context; +use robust_provider::{Error, RobustProviderBuilder}; + +use crate::common::setup_kurtosis::{ElEndpoint, load_el_endpoints}; + +/// Adds client context to errors for better debugging in parameterized tests. +macro_rules! ctx { + ($expr:expr, $client:expr) => { + $expr.await.with_context(|| format!("client: {}", $client)) + }; +} + +/// Helper to create a RobustProvider from an endpoint +async fn setup_robust_provider( + endpoint: &ElEndpoint, +) -> anyhow::Result<(robust_provider::RobustProvider, impl Provider)> { + let alloy_provider = ProviderBuilder::new().connect_http(Url::parse(&endpoint.http)?); + + let robust = RobustProviderBuilder::new(alloy_provider.clone()) + .call_timeout(Duration::from_secs(30)) + .build() + .await?; + + Ok((robust, alloy_provider)) +} + +// ============================================================================ +// eth_blockNumber +// ============================================================================ + +#[tokio::test] +async fn test_get_block_number_succeeds() -> anyhow::Result<()> { + let endpoints = load_el_endpoints()?; + + for endpoint in endpoints { + let (robust, alloy_provider) = setup_robust_provider(&endpoint).await?; + + let robust_block_num = ctx!(robust.get_block_number(), &endpoint.client)?; + let alloy_block_num = ctx!(alloy_provider.get_block_number(), &endpoint.client)?; + + assert_eq!( + robust_block_num, alloy_block_num, + "Block number mismatch for client: {}", + endpoint.client + ); + assert!(robust_block_num >= 1, "Expected at least block 1 for client: {}", endpoint.client); + } + + Ok(()) +} + +// ============================================================================ +// eth_getBlockByNumber +// ============================================================================ + +#[tokio::test] +async fn test_get_block_by_number_succeeds() -> anyhow::Result<()> { + let endpoints = load_el_endpoints()?; + + for endpoint in endpoints { + let (robust, alloy_provider) = setup_robust_provider(&endpoint).await?; + + let tags = [ + BlockNumberOrTag::Number(0), + BlockNumberOrTag::Latest, + BlockNumberOrTag::Earliest, + BlockNumberOrTag::Safe, + BlockNumberOrTag::Finalized, + ]; + + for tag in tags { + let robust_block = ctx!(robust.get_block_by_number(tag), &endpoint.client)?; + let alloy_block = ctx!(alloy_provider.get_block_by_number(tag), &endpoint.client)? + .expect("block should exist"); + + assert_eq!( + robust_block.header.number, alloy_block.header.number, + "Block number mismatch for client: {}, tag: {:?}", + endpoint.client, tag + ); + assert_eq!( + robust_block.header.hash, alloy_block.header.hash, + "Block hash mismatch for client: {}, tag: {:?}", + endpoint.client, tag + ); + } + } + + Ok(()) +} + +#[tokio::test] +async fn test_get_block_by_number_future_block_fails() -> anyhow::Result<()> { + let endpoints = load_el_endpoints()?; + + for endpoint in endpoints { + let (robust, _) = setup_robust_provider(&endpoint).await?; + + let future_block = 999_999_999; + let result = robust.get_block_by_number(BlockNumberOrTag::Number(future_block)).await; + + assert!( + matches!(result, Err(Error::BlockNotFound(_))), + "Expected BlockNotFound for client: {}, got: {:?}", + endpoint.client, + result + ); + } + + Ok(()) +} + +// ============================================================================ +// eth_getBlockByHash +// ============================================================================ + +#[tokio::test] +async fn test_get_block_by_hash_succeeds() -> anyhow::Result<()> { + let endpoints = load_el_endpoints()?; + + for endpoint in endpoints { + let (robust, alloy_provider) = setup_robust_provider(&endpoint).await?; + + // Get genesis block hash + let genesis = + ctx!(alloy_provider.get_block_by_number(BlockNumberOrTag::Earliest), &endpoint.client)? + .expect("genesis should exist"); + let genesis_hash = genesis.header.hash; + + let robust_block = ctx!(robust.get_block_by_hash(genesis_hash), &endpoint.client)?; + + assert_eq!( + robust_block.header.number, 0, + "Genesis block number should be 0 for client: {}", + endpoint.client + ); + assert_eq!( + robust_block.header.hash, genesis_hash, + "Genesis block hash mismatch for client: {}", + endpoint.client + ); + } + + Ok(()) +} + +#[tokio::test] +async fn test_get_block_by_hash_fails() -> anyhow::Result<()> { + let endpoints = load_el_endpoints()?; + + for endpoint in endpoints { + let (robust, _) = setup_robust_provider(&endpoint).await?; + + let result = robust.get_block_by_hash(BlockHash::ZERO).await; + + assert!( + matches!(result, Err(Error::BlockNotFound(_))), + "Expected BlockNotFound for client: {}, got: {:?}", + endpoint.client, + result + ); + } + + Ok(()) +} + +// ============================================================================ +// eth_getBlock (by BlockId) +// ============================================================================ + +#[tokio::test] +async fn test_get_block_succeeds() -> anyhow::Result<()> { + let endpoints = load_el_endpoints()?; + + for endpoint in endpoints { + let (robust, alloy_provider) = setup_robust_provider(&endpoint).await?; + + let block_ids = [ + BlockId::number(0), + BlockId::latest(), + BlockId::earliest(), + BlockId::safe(), + BlockId::finalized(), + ]; + + for block_id in block_ids { + let robust_block = ctx!(robust.get_block(block_id), &endpoint.client)?; + let alloy_block = ctx!(alloy_provider.get_block(block_id), &endpoint.client)? + .expect("block should exist"); + + assert_eq!( + robust_block.header.number, alloy_block.header.number, + "Block number mismatch for client: {}, block_id: {:?}", + endpoint.client, block_id + ); + assert_eq!( + robust_block.header.hash, alloy_block.header.hash, + "Block hash mismatch for client: {}, block_id: {:?}", + endpoint.client, block_id + ); + } + + // Test with block hash + let genesis = + ctx!(alloy_provider.get_block_by_number(BlockNumberOrTag::Earliest), &endpoint.client)? + .expect("genesis should exist"); + let block_id = BlockId::hash(genesis.header.hash); + let robust_block = ctx!(robust.get_block(block_id), &endpoint.client)?; + + assert_eq!( + robust_block.header.hash, genesis.header.hash, + "Block hash mismatch for client: {}", + endpoint.client + ); + } + + Ok(()) +} + +#[tokio::test] +async fn test_get_block_fails() -> anyhow::Result<()> { + let endpoints = load_el_endpoints()?; + + for endpoint in endpoints { + let (robust, _) = setup_robust_provider(&endpoint).await?; + + // Future block number + let result = robust.get_block(BlockId::number(999_999_999)).await; + assert!( + matches!(result, Err(Error::BlockNotFound(_))), + "Expected BlockNotFound for future block, client: {}, got: {:?}", + endpoint.client, + result + ); + + // Non-existent hash + let result = robust.get_block(BlockId::hash(BlockHash::ZERO)).await; + assert!( + matches!(result, Err(Error::BlockNotFound(_))), + "Expected BlockNotFound for zero hash, client: {}, got: {:?}", + endpoint.client, + result + ); + } + + Ok(()) +} + +// ============================================================================ +// get_block_number_by_id (custom helper) +// ============================================================================ + +#[tokio::test] +async fn test_get_block_number_by_id_succeeds() -> anyhow::Result<()> { + let endpoints = load_el_endpoints()?; + + for endpoint in endpoints { + let (robust, alloy_provider) = setup_robust_provider(&endpoint).await?; + + // By number + let block_num = ctx!(robust.get_block_number_by_id(BlockId::number(0)), &endpoint.client)?; + assert_eq!(block_num, 0, "Block number should be 0 for client: {}", endpoint.client); + + // By hash + let genesis = + ctx!(alloy_provider.get_block_by_number(BlockNumberOrTag::Earliest), &endpoint.client)? + .expect("genesis should exist"); + let block_num = ctx!( + robust.get_block_number_by_id(BlockId::hash(genesis.header.hash)), + &endpoint.client + )?; + assert_eq!( + block_num, 0, + "Genesis block number should be 0 for client: {}", + endpoint.client + ); + + // Latest + let robust_latest = + ctx!(robust.get_block_number_by_id(BlockId::latest()), &endpoint.client)?; + let alloy_latest = ctx!(alloy_provider.get_block_number(), &endpoint.client)?; + assert_eq!( + robust_latest, alloy_latest, + "Latest block number mismatch for client: {}", + endpoint.client + ); + + // Earliest + let block_num = ctx!(robust.get_block_number_by_id(BlockId::earliest()), &endpoint.client)?; + assert_eq!( + block_num, 0, + "Earliest block number should be 0 for client: {}", + endpoint.client + ); + } + + Ok(()) +} + +#[tokio::test] +async fn test_get_block_number_by_id_fails() -> anyhow::Result<()> { + let endpoints = load_el_endpoints()?; + + for endpoint in endpoints { + let (robust, _) = setup_robust_provider(&endpoint).await?; + + let result = robust.get_block_number_by_id(BlockId::hash(BlockHash::ZERO)).await; + + assert!( + matches!(result, Err(Error::BlockNotFound(_))), + "Expected BlockNotFound for client: {}, got: {:?}", + endpoint.client, + result + ); + } + + Ok(()) +} + +// ============================================================================ +// get_latest_confirmed +// ============================================================================ + +#[tokio::test] +async fn test_get_latest_confirmed_succeeds() -> anyhow::Result<()> { + let endpoints = load_el_endpoints()?; + + for endpoint in endpoints { + let (robust, alloy_provider) = setup_robust_provider(&endpoint).await?; + + let latest = ctx!(alloy_provider.get_block_number(), &endpoint.client)?; + + // Zero confirmations returns latest + let confirmed = ctx!(robust.get_latest_confirmed(0), &endpoint.client)?; + assert_eq!( + confirmed, latest, + "Zero confirmations should return latest for client: {}", + endpoint.client + ); + + // With confirmations + if latest >= 10 { + let confirmed = ctx!(robust.get_latest_confirmed(10), &endpoint.client)?; + assert_eq!( + confirmed, + latest - 10, + "Confirmed block mismatch for client: {}", + endpoint.client + ); + } + + // Confirmations exceeding latest should saturate at 0 + let confirmed = ctx!(robust.get_latest_confirmed(latest + 100), &endpoint.client)?; + assert_eq!(confirmed, 0, "Should saturate at 0 for client: {}", endpoint.client); + } + + Ok(()) +} + +// ============================================================================ +// get_logs +// ============================================================================ + +#[tokio::test] +async fn test_get_logs_succeeds() -> anyhow::Result<()> { + let endpoints = load_el_endpoints()?; + + for endpoint in endpoints { + let (robust, alloy_provider) = setup_robust_provider(&endpoint).await?; + + // Query logs for the first few blocks (may be empty, but should not error) + let filter = Filter::new().from_block(0).to_block(100); + + let robust_logs = ctx!(robust.get_logs(&filter), &endpoint.client)?; + let alloy_logs = ctx!(alloy_provider.get_logs(&filter), &endpoint.client)?; + + assert_eq!( + robust_logs.len(), + alloy_logs.len(), + "Logs count mismatch for client: {}", + endpoint.client + ); + } + + Ok(()) +} diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs new file mode 100644 index 0000000..daa3536 --- /dev/null +++ b/tests/integration/mod.rs @@ -0,0 +1 @@ +mod http_endpoints; diff --git a/tests/mod.rs b/tests/mod.rs index a5961aa..e78302a 100644 --- a/tests/mod.rs +++ b/tests/mod.rs @@ -1 +1,2 @@ mod common; +mod integration; From 7d1328dcc721c321a72e54ffd03667abf971c091 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 15 Jan 2026 11:57:19 +0100 Subject: [PATCH 19/25] ref: delete duplicate tests --- src/robust_provider/provider.rs | 241 +--------------------------- src/robust_provider/subscription.rs | 2 - 2 files changed, 2 insertions(+), 241 deletions(-) diff --git a/src/robust_provider/provider.rs b/src/robust_provider/provider.rs index 0e74f5c..2734b36 100644 --- a/src/robust_provider/provider.rs +++ b/src/robust_provider/provider.rs @@ -419,31 +419,11 @@ mod tests { DEFAULT_SUBSCRIPTION_BUFFER_CAPACITY, RobustProviderBuilder, builder::DEFAULT_SUBSCRIPTION_TIMEOUT, subscription::DEFAULT_RECONNECT_INTERVAL, }; - use alloy::providers::{ProviderBuilder, WsConnect, ext::AnvilApi}; - use alloy_node_bindings::{Anvil, AnvilInstance}; + use alloy::providers::{ProviderBuilder, WsConnect}; + use alloy_node_bindings::Anvil; use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::time::sleep; - async fn setup_anvil() -> anyhow::Result<(AnvilInstance, RobustProvider, impl Provider)> { - let anvil = Anvil::new().try_spawn()?; - let alloy_provider = ProviderBuilder::new().connect_http(anvil.endpoint_url()); - - let robust = RobustProviderBuilder::new(alloy_provider.clone()) - .call_timeout(Duration::from_secs(5)) - .build() - .await?; - - Ok((anvil, robust, alloy_provider)) - } - - async fn setup_anvil_with_blocks( - num_blocks: u64, - ) -> anyhow::Result<(AnvilInstance, RobustProvider, impl Provider)> { - let (anvil, robust, alloy_provider) = setup_anvil().await?; - alloy_provider.anvil_mine(Some(num_blocks), None).await?; - Ok((anvil, robust, alloy_provider)) - } - fn test_provider(timeout: u64, max_retries: usize, min_delay: u64) -> RobustProvider { RobustProvider { primary_provider: RootProvider::new_http("http://localhost:8545".parse().unwrap()), @@ -587,221 +567,4 @@ mod tests { Ok(()) } - - #[tokio::test] - async fn test_get_block_by_number_succeeds() -> anyhow::Result<()> { - let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; - - let tags = [ - BlockNumberOrTag::Number(50), - BlockNumberOrTag::Latest, - BlockNumberOrTag::Earliest, - BlockNumberOrTag::Safe, - BlockNumberOrTag::Finalized, - ]; - - for tag in tags { - let robust_block = robust.get_block_by_number(tag).await?; - let alloy_block = - alloy_provider.get_block_by_number(tag).await?.expect("block should exist"); - - assert_eq!(robust_block.header.number, alloy_block.header.number); - assert_eq!(robust_block.header.hash, alloy_block.header.hash); - } - - Ok(()) - } - - #[tokio::test] - async fn test_get_block_by_number_future_block_fails() -> anyhow::Result<()> { - let (_anvil, robust, _alloy_provider) = setup_anvil().await?; - - let future_block = 999_999; - let result = robust.get_block_by_number(BlockNumberOrTag::Number(future_block)).await; - - assert!(matches!(result, Err(Error::BlockNotFound(_)))); - - Ok(()) - } - - #[tokio::test] - async fn test_get_block_succeeds() -> anyhow::Result<()> { - let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; - - let block_ids = [ - BlockId::number(50), - BlockId::latest(), - BlockId::earliest(), - BlockId::safe(), - BlockId::finalized(), - ]; - - for block_id in block_ids { - let robust_block = robust.get_block(block_id).await?; - let alloy_block = - alloy_provider.get_block(block_id).await?.expect("block should exist"); - - assert_eq!(robust_block.header.number, alloy_block.header.number); - assert_eq!(robust_block.header.hash, alloy_block.header.hash); - } - - // test block hash - let block = alloy_provider - .get_block_by_number(BlockNumberOrTag::Number(50)) - .await? - .expect("block should exist"); - let block_hash = block.header.hash; - let block_id = BlockId::hash(block_hash); - let robust_block = robust.get_block(block_id).await?; - assert_eq!(robust_block.header.hash, block_hash); - assert_eq!(robust_block.header.number, 50); - - Ok(()) - } - - #[tokio::test] - async fn test_get_block_fails() -> anyhow::Result<()> { - let (_anvil, robust, _alloy_provider) = setup_anvil().await?; - - // Future block number - let result = robust.get_block(BlockId::number(999_999)).await; - assert!(matches!(result, Err(Error::BlockNotFound(_)))); - - // Non-existent hash - let result = robust.get_block(BlockId::hash(BlockHash::ZERO)).await; - assert!(matches!(result, Err(Error::BlockNotFound(_)))); - - Ok(()) - } - - #[tokio::test] - async fn test_get_block_number_succeeds() -> anyhow::Result<()> { - let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; - - let robust_block_num = robust.get_block_number().await?; - let alloy_block_num = alloy_provider.get_block_number().await?; - assert_eq!(robust_block_num, alloy_block_num); - assert_eq!(robust_block_num, 100); - - alloy_provider.anvil_mine(Some(10), None).await?; - let new_block = robust.get_block_number().await?; - assert_eq!(new_block, 110); - - Ok(()) - } - - #[tokio::test] - async fn test_get_block_number_by_id_succeeds() -> anyhow::Result<()> { - let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; - - let block_num = robust.get_block_number_by_id(BlockId::number(50)).await?; - assert_eq!(block_num, 50); - - let block = alloy_provider - .get_block_by_number(BlockNumberOrTag::Number(50)) - .await? - .expect("block should exist"); - let block_num = robust.get_block_number_by_id(BlockId::hash(block.header.hash)).await?; - assert_eq!(block_num, 50); - - let block_num = robust.get_block_number_by_id(BlockId::latest()).await?; - assert_eq!(block_num, 100); - - let block_num = robust.get_block_number_by_id(BlockId::earliest()).await?; - assert_eq!(block_num, 0); - - // Returns block number even if it doesnt 'exist' on chain - let block_num = robust.get_block_number_by_id(BlockId::number(999_999)).await?; - let alloy_block_num = alloy_provider - .get_block_number_by_id(BlockId::number(999_999)) - .await? - .expect("Should return block num"); - assert_eq!(alloy_block_num, block_num); - assert_eq!(block_num, 999_999); - - Ok(()) - } - - #[tokio::test] - async fn test_get_block_number_by_id_fails() -> anyhow::Result<()> { - let (_anvil, robust, _alloy_provider) = setup_anvil().await?; - - let result = robust.get_block_number_by_id(BlockId::hash(BlockHash::ZERO)).await; - assert!(matches!(result, Err(Error::BlockNotFound(_)))); - - Ok(()) - } - - #[tokio::test] - async fn test_get_latest_confirmed_succeeds() -> anyhow::Result<()> { - let (_anvil, robust, _alloy_provider) = setup_anvil_with_blocks(100).await?; - - // With confirmations - let confirmed_block = robust.get_latest_confirmed(10).await?; - assert_eq!(confirmed_block, 90); - - // Zero confirmations returns latest - let confirmed_block = robust.get_latest_confirmed(0).await?; - assert_eq!(confirmed_block, 100); - - // Single confirmation - let confirmed_block = robust.get_latest_confirmed(1).await?; - assert_eq!(confirmed_block, 99); - - // confirmations = latest - 1 - let confirmed_block = robust.get_latest_confirmed(99).await?; - assert_eq!(confirmed_block, 1); - - // confirmations = latest (should return 0) - let confirmed_block = robust.get_latest_confirmed(100).await?; - assert_eq!(confirmed_block, 0); - - // confirmations = latest + 1 (saturates at zero) - let confirmed_block = robust.get_latest_confirmed(101).await?; - assert_eq!(confirmed_block, 0); - - // Saturates at zero when confirmations > latest - let confirmed_block = robust.get_latest_confirmed(200).await?; - assert_eq!(confirmed_block, 0); - - Ok(()) - } - - #[tokio::test] - async fn test_get_block_by_hash_succeeds() -> anyhow::Result<()> { - let (_anvil, robust, alloy_provider) = setup_anvil_with_blocks(100).await?; - - let block = alloy_provider - .get_block_by_number(BlockNumberOrTag::Number(50)) - .await? - .expect("block should exist"); - let block_hash = block.header.hash; - - let robust_block = robust.get_block_by_hash(block_hash).await?; - let alloy_block = - alloy_provider.get_block_by_hash(block_hash).await?.expect("block should exist"); - assert_eq!(robust_block.header.hash, alloy_block.header.hash); - assert_eq!(robust_block.header.number, alloy_block.header.number); - - let genesis = alloy_provider - .get_block_by_number(BlockNumberOrTag::Earliest) - .await? - .expect("genesis should exist"); - let genesis_hash = genesis.header.hash; - let robust_block = robust.get_block_by_hash(genesis_hash).await?; - assert_eq!(robust_block.header.number, 0); - assert_eq!(robust_block.header.hash, genesis_hash); - - Ok(()) - } - - #[tokio::test] - async fn test_get_block_by_hash_fails() -> anyhow::Result<()> { - let (_anvil, robust, _alloy_provider) = setup_anvil().await?; - - let result = robust.get_block_by_hash(BlockHash::ZERO).await; - assert!(matches!(result, Err(Error::BlockNotFound(_)))); - - Ok(()) - } } diff --git a/src/robust_provider/subscription.rs b/src/robust_provider/subscription.rs index b97ad77..d2f9000 100644 --- a/src/robust_provider/subscription.rs +++ b/src/robust_provider/subscription.rs @@ -247,5 +247,3 @@ impl From> Self::new(recv) } } - -// Tests for subscription functionality have been moved to tests/subscription.rs From 993a2ad79bb20bee3e985a3d954a3f13fa2f628a Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 15 Jan 2026 11:57:57 +0100 Subject: [PATCH 20/25] feat: remove pub mod --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index ad8ee5b..36caee0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,6 @@ #[macro_use] pub mod macros; -pub mod robust_provider; +mod robust_provider; pub use robust_provider::*; From d136667dfbe43a1959120242bc70b5b86e0f0fe1 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 15 Jan 2026 12:14:02 +0100 Subject: [PATCH 21/25] feat: quicker startup times --- tests/common/network_params.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/common/network_params.yaml b/tests/common/network_params.yaml index 4390123..a01310b 100644 --- a/tests/common/network_params.yaml +++ b/tests/common/network_params.yaml @@ -4,3 +4,6 @@ participants: - el_type: besu - el_type: reth cl_type: lighthouse + +# How long you want the network to wait before starting up +genesis_delay: 1 From 671b94b502086e6a9689398f36797736b9cd97a9 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 15 Jan 2026 12:14:11 +0100 Subject: [PATCH 22/25] feat: add more edge case and error test cases --- tests/integration/http_endpoints.rs | 97 +++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 5 deletions(-) diff --git a/tests/integration/http_endpoints.rs b/tests/integration/http_endpoints.rs index fc92d8e..dd7bf1b 100644 --- a/tests/integration/http_endpoints.rs +++ b/tests/integration/http_endpoints.rs @@ -81,6 +81,12 @@ async fn test_get_block_by_number_succeeds() -> anyhow::Result<()> { for endpoint in endpoints { let (robust, alloy_provider) = setup_robust_provider(&endpoint).await?; + // Get latest block to check if this is a "young chain" (< 64 blocks) + let latest_block = + ctx!(alloy_provider.get_block_by_number(BlockNumberOrTag::Latest), &endpoint.client)? + .expect("latest block should exist"); + let is_young_chain = latest_block.header.number < 64; + let tags = [ BlockNumberOrTag::Number(0), BlockNumberOrTag::Latest, @@ -90,6 +96,20 @@ async fn test_get_block_by_number_succeeds() -> anyhow::Result<()> { ]; for tag in tags { + // For young chains, Safe and Finalized tags will return BlockNotFound + if is_young_chain && matches!(tag, BlockNumberOrTag::Safe | BlockNumberOrTag::Finalized) + { + let result = robust.get_block_by_number(tag).await; + assert!( + matches!(result, Err(Error::BlockNotFound)), + "Expected BlockNotFound for young chain client: {}, tag: {:?}, got: {:?}", + endpoint.client, + tag, + result + ); + continue; + } + let robust_block = ctx!(robust.get_block_by_number(tag), &endpoint.client)?; let alloy_block = ctx!(alloy_provider.get_block_by_number(tag), &endpoint.client)? .expect("block should exist"); @@ -121,7 +141,7 @@ async fn test_get_block_by_number_future_block_fails() -> anyhow::Result<()> { let result = robust.get_block_by_number(BlockNumberOrTag::Number(future_block)).await; assert!( - matches!(result, Err(Error::BlockNotFound(_))), + matches!(result, Err(Error::BlockNotFound)), "Expected BlockNotFound for client: {}, got: {:?}", endpoint.client, result @@ -175,7 +195,7 @@ async fn test_get_block_by_hash_fails() -> anyhow::Result<()> { let result = robust.get_block_by_hash(BlockHash::ZERO).await; assert!( - matches!(result, Err(Error::BlockNotFound(_))), + matches!(result, Err(Error::BlockNotFound)), "Expected BlockNotFound for client: {}, got: {:?}", endpoint.client, result @@ -196,6 +216,12 @@ async fn test_get_block_succeeds() -> anyhow::Result<()> { for endpoint in endpoints { let (robust, alloy_provider) = setup_robust_provider(&endpoint).await?; + // Get latest block to check if this is a "young chain" (< 64 blocks) + let latest_block = + ctx!(alloy_provider.get_block_by_number(BlockNumberOrTag::Latest), &endpoint.client)? + .expect("latest block should exist"); + let is_young_chain = latest_block.header.number < 64; + let block_ids = [ BlockId::number(0), BlockId::latest(), @@ -205,6 +231,25 @@ async fn test_get_block_succeeds() -> anyhow::Result<()> { ]; for block_id in block_ids { + // For young chains, Safe and Finalized tags will return BlockNotFound + if is_young_chain && + matches!( + block_id, + BlockId::Number(BlockNumberOrTag::Safe) | + BlockId::Number(BlockNumberOrTag::Finalized) + ) + { + let result = robust.get_block(block_id).await; + assert!( + matches!(result, Err(Error::BlockNotFound)), + "Expected BlockNotFound for young chain client: {}, block_id: {:?}, got: {:?}", + endpoint.client, + block_id, + result + ); + continue; + } + let robust_block = ctx!(robust.get_block(block_id), &endpoint.client)?; let alloy_block = ctx!(alloy_provider.get_block(block_id), &endpoint.client)? .expect("block should exist"); @@ -248,7 +293,7 @@ async fn test_get_block_fails() -> anyhow::Result<()> { // Future block number let result = robust.get_block(BlockId::number(999_999_999)).await; assert!( - matches!(result, Err(Error::BlockNotFound(_))), + matches!(result, Err(Error::BlockNotFound)), "Expected BlockNotFound for future block, client: {}, got: {:?}", endpoint.client, result @@ -257,7 +302,7 @@ async fn test_get_block_fails() -> anyhow::Result<()> { // Non-existent hash let result = robust.get_block(BlockId::hash(BlockHash::ZERO)).await; assert!( - matches!(result, Err(Error::BlockNotFound(_))), + matches!(result, Err(Error::BlockNotFound)), "Expected BlockNotFound for zero hash, client: {}, got: {:?}", endpoint.client, result @@ -318,6 +363,27 @@ async fn test_get_block_number_by_id_succeeds() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn test_get_block_number_by_id_future_block_succeeds() -> anyhow::Result<()> { + let endpoints = load_el_endpoints()?; + + for endpoint in endpoints { + let (robust, _) = setup_robust_provider(&endpoint).await?; + + // Future block number - should return the number even if block doesn't exist + let future_block = 999_999_999; + let block_num = + ctx!(robust.get_block_number_by_id(BlockId::number(future_block)), &endpoint.client)?; + assert_eq!( + block_num, future_block, + "Future block number should be returned as-is for client: {}", + endpoint.client + ); + } + + Ok(()) +} + #[tokio::test] async fn test_get_block_number_by_id_fails() -> anyhow::Result<()> { let endpoints = load_el_endpoints()?; @@ -328,7 +394,7 @@ async fn test_get_block_number_by_id_fails() -> anyhow::Result<()> { let result = robust.get_block_number_by_id(BlockId::hash(BlockHash::ZERO)).await; assert!( - matches!(result, Err(Error::BlockNotFound(_))), + matches!(result, Err(Error::BlockNotFound)), "Expected BlockNotFound for client: {}, got: {:?}", endpoint.client, result @@ -405,3 +471,24 @@ async fn test_get_logs_succeeds() -> anyhow::Result<()> { Ok(()) } + +#[tokio::test] +async fn test_get_logs_empty_range() -> anyhow::Result<()> { + let endpoints = load_el_endpoints()?; + + for endpoint in endpoints { + let (robust, _) = setup_robust_provider(&endpoint).await?; + + // Query logs for future blocks - should return empty, not error + let filter = Filter::new().from_block(999_999_990).to_block(999_999_999); + + let logs = ctx!(robust.get_logs(&filter), &endpoint.client)?; + assert!( + logs.is_empty(), + "Expected empty logs for future blocks, client: {}", + endpoint.client + ); + } + + Ok(()) +} From 60b67514b4a8235d55463598684d24714c9e57ea Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 15 Jan 2026 12:34:42 +0100 Subject: [PATCH 23/25] feat: better error matching for tests --- tests/common/network_params.yaml | 5 ++-- tests/integration/http_endpoints.rs | 36 +++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/tests/common/network_params.yaml b/tests/common/network_params.yaml index a01310b..35ef161 100644 --- a/tests/common/network_params.yaml +++ b/tests/common/network_params.yaml @@ -5,5 +5,6 @@ participants: - el_type: reth cl_type: lighthouse -# How long you want the network to wait before starting up -genesis_delay: 1 +network_params: + # How long you want the network to wait before starting up + genesis_delay: 1 diff --git a/tests/integration/http_endpoints.rs b/tests/integration/http_endpoints.rs index dd7bf1b..721809b 100644 --- a/tests/integration/http_endpoints.rs +++ b/tests/integration/http_endpoints.rs @@ -455,8 +455,13 @@ async fn test_get_logs_succeeds() -> anyhow::Result<()> { for endpoint in endpoints { let (robust, alloy_provider) = setup_robust_provider(&endpoint).await?; - // Query logs for the first few blocks (may be empty, but should not error) - let filter = Filter::new().from_block(0).to_block(100); + // Get latest block to use as upper bound + let latest_block = + ctx!(alloy_provider.get_block_by_number(BlockNumberOrTag::Latest), &endpoint.client)? + .expect("latest block should exist"); + + // Query logs from genesis to latest block (may be empty, but should not error) + let filter = Filter::new().from_block(0).to_block(latest_block.header.number); let robust_logs = ctx!(robust.get_logs(&filter), &endpoint.client)?; let alloy_logs = ctx!(alloy_provider.get_logs(&filter), &endpoint.client)?; @@ -479,15 +484,28 @@ async fn test_get_logs_empty_range() -> anyhow::Result<()> { for endpoint in endpoints { let (robust, _) = setup_robust_provider(&endpoint).await?; - // Query logs for future blocks - should return empty, not error + // Query logs for future blocks - Geth returns "invalid block range params" error let filter = Filter::new().from_block(999_999_990).to_block(999_999_999); - let logs = ctx!(robust.get_logs(&filter), &endpoint.client)?; - assert!( - logs.is_empty(), - "Expected empty logs for future blocks, client: {}", - endpoint.client - ); + let result = robust.get_logs(&filter).await; + + // Geth returns error code -32000: "invalid block range params" for future block ranges + // Other clients may return empty logs or similar errors + match &result { + Ok(logs) => { + assert!( + logs.is_empty(), + "Expected empty logs for future blocks, client: {}", + endpoint.client + ); + } + Err(Error::RpcError(_)) => { + // Accept any RPC error for invalid block ranges (e.g., Geth's -32000) + } + Err(e) => { + panic!("Unexpected error type for client: {}, got: {:?}", endpoint.client, e); + } + } } Ok(()) From 39e6c085465dcb0e06a4c702cf9c1a370c3010a8 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 15 Jan 2026 12:46:30 +0100 Subject: [PATCH 24/25] feat: remove all features from ci --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 04ce4c1..5af710d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,7 +50,7 @@ jobs: tool: cargo-nextest - name: Cargo test - run: cargo nextest run --locked --all-targets --all-features --no-tests=pass --no-fail-fast + run: cargo nextest run --locked --all-targets --no-tests=pass --no-fail-fast # https://github.com/rust-lang/cargo/issues/6669 - name: Run doc tests From 9a21146e2cf3467ce63431b9d3642367df27c592 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 15 Jan 2026 12:50:32 +0100 Subject: [PATCH 25/25] fix: clippy --- tests/integration/http_endpoints.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/integration/http_endpoints.rs b/tests/integration/http_endpoints.rs index 721809b..433c266 100644 --- a/tests/integration/http_endpoints.rs +++ b/tests/integration/http_endpoints.rs @@ -1,6 +1,6 @@ //! Integration tests for `RobustProvider` HTTP endpoints against Kurtosis devnet. //! -//! These tests verify that the RobustProvider methods work correctly against +//! These tests verify that the `RobustProvider` methods work correctly against //! real Ethereum execution clients (geth, nethermind, besu, reth) running in Kurtosis. //! //! Prerequisites: @@ -31,7 +31,7 @@ macro_rules! ctx { }; } -/// Helper to create a RobustProvider from an endpoint +/// Helper to create a `RobustProvider` from an endpoint async fn setup_robust_provider( endpoint: &ElEndpoint, ) -> anyhow::Result<(robust_provider::RobustProvider, impl Provider)> { @@ -235,8 +235,7 @@ async fn test_get_block_succeeds() -> anyhow::Result<()> { if is_young_chain && matches!( block_id, - BlockId::Number(BlockNumberOrTag::Safe) | - BlockId::Number(BlockNumberOrTag::Finalized) + BlockId::Number(BlockNumberOrTag::Safe | BlockNumberOrTag::Finalized) ) { let result = robust.get_block(block_id).await;