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" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9a332d2..f815fec 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 diff --git a/Cargo.lock b/Cargo.lock index f16ff21..073a651 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 ab76e7b..90a5069 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 = [] diff --git a/README.md b/README.md index 207395b..24e6265 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 --- 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/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/network_params.yaml b/tests/common/network_params.yaml new file mode 100644 index 0000000..35ef161 --- /dev/null +++ b/tests/common/network_params.yaml @@ -0,0 +1,10 @@ +participants: + - el_type: geth + - el_type: nethermind + - el_type: besu + - el_type: reth + cl_type: lighthouse + +network_params: + # How long you want the network to wait before starting up + genesis_delay: 1 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..908e77b --- /dev/null +++ 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) +} 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 05ee725..5c16d14 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/integration/http_endpoints.rs b/tests/integration/http_endpoints.rs new file mode 100644 index 0000000..433c266 --- /dev/null +++ b/tests/integration/http_endpoints.rs @@ -0,0 +1,511 @@ +//! 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?; + + // 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, + BlockNumberOrTag::Earliest, + BlockNumberOrTag::Safe, + BlockNumberOrTag::Finalized, + ]; + + 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"); + + 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?; + + // 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(), + BlockId::earliest(), + BlockId::safe(), + BlockId::finalized(), + ]; + + 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 | 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"); + + 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_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()?; + + 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?; + + // 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)?; + + assert_eq!( + robust_logs.len(), + alloy_logs.len(), + "Logs count mismatch for client: {}", + endpoint.client + ); + } + + 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 - Geth returns "invalid block range params" error + let filter = Filter::new().from_block(999_999_990).to_block(999_999_999); + + 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(()) +} 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 new file mode 100644 index 0000000..e78302a --- /dev/null +++ b/tests/mod.rs @@ -0,0 +1,2 @@ +mod common; +mod integration; 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 // ============================================================================