use std::time::Duration;
use alloy::providers::{Provider, ProviderBuilder};
use alloy::transports::mock::Asserter;
use anyhow::Result;
use ethl::rpc::{
RpcError,
config::{CaptureTarget, ProviderSettings},
heads::resolve_capture_target,
};
use tokio::time::Instant;
fn block_json(number: u64) -> serde_json::Value {
serde_json::json!({
"hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
"parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
"sha3Uncles": "0x0000000000000000000000000000000000000000000000000000000000000000",
"miner": "0x0000000000000000000000000000000000000000",
"stateRoot": "0x0000000000000000000000000000000000000000000000000000000000000000",
"transactionsRoot": "0x0000000000000000000000000000000000000000000000000000000000000000",
"receiptsRoot": "0x0000000000000000000000000000000000000000000000000000000000000000",
"logsBloom": format!("0x{}", "0".repeat(512)),
"difficulty": "0x0",
"number": format!("{:#x}", number),
"gasLimit": "0x0",
"gasUsed": "0x0",
"timestamp": "0x0",
"extraData": "0x",
"mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
"nonce": "0x0000000000000000",
"transactions": [],
"uncles": []
})
}
fn mock_settings(asserter: Asserter) -> ProviderSettings {
let provider = ProviderBuilder::new()
.disable_recommended_fillers()
.connect_mocked_client(asserter)
.erased();
ProviderSettings::from_mock(provider)
}
#[tokio::test]
async fn latest_returns_tip() -> Result<()> {
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!("0x80")); let mut settings = mock_settings(asserter);
settings.capture_target = CaptureTarget::Latest;
let block = resolve_capture_target(&settings).await?;
assert_eq!(block, 128);
Ok(())
}
#[tokio::test]
async fn lag_subtracts_from_tip() -> Result<()> {
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!("0x80")); let mut settings = mock_settings(asserter);
settings.capture_target = CaptureTarget::Lag(10);
let block = resolve_capture_target(&settings).await?;
assert_eq!(block, 118); Ok(())
}
#[tokio::test]
async fn lag_saturates_at_zero() -> Result<()> {
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!("0x05")); let mut settings = mock_settings(asserter);
settings.capture_target = CaptureTarget::Lag(100);
let block = resolve_capture_target(&settings).await?;
assert_eq!(block, 0);
Ok(())
}
#[tokio::test(start_paused = true)]
async fn latest_ladder_unchanged_by_extraction() -> Result<()> {
let asserter = Asserter::new();
asserter.push_failure_msg("transport blip");
asserter.push_success(&serde_json::json!("0x0a")); let mut settings = mock_settings(asserter.clone());
settings.capture_target = CaptureTarget::Latest;
let start = Instant::now();
let block = resolve_capture_target(&settings).await?;
assert_eq!(block, 10);
assert_eq!(start.elapsed(), Duration::from_secs(5));
assert!(asserter.read_q().is_empty(), "queue must be fully consumed");
let asserter = Asserter::new();
for _ in 0..11 {
asserter.push_failure_msg("transport blip");
}
let mut settings = mock_settings(asserter.clone());
settings.capture_target = CaptureTarget::Latest;
let start = Instant::now();
let result = resolve_capture_target(&settings).await;
assert_eq!(start.elapsed(), Duration::from_secs(275));
let Err(RpcError::ConnectionError(msg)) = result else {
panic!("expected ConnectionError, got {result:?}");
};
assert!(
msg.starts_with(
"Failed to fetch latest block number after 10 attempts from HTTP provider "
),
"got {msg}"
);
assert!(
asserter.read_q().is_empty(),
"a loop that stops early leaves responses unconsumed"
);
Ok(())
}
#[tokio::test]
async fn finalized_returns_finalized_block_number() -> Result<()> {
let asserter = Asserter::new();
asserter.push_success(&block_json(64));
let mut settings = mock_settings(asserter);
settings.capture_target = CaptureTarget::Finalized;
let block = resolve_capture_target(&settings).await?;
assert_eq!(block, 64);
Ok(())
}
#[tokio::test(start_paused = true)]
async fn finalized_retries_a_provider_error() -> Result<()> {
let asserter = Asserter::new();
asserter.push_failure_msg("transport blip");
asserter.push_success(&block_json(64));
let mut settings = mock_settings(asserter);
settings.capture_target = CaptureTarget::Finalized;
let block = resolve_capture_target(&settings).await?;
assert_eq!(block, 64);
Ok(())
}
#[tokio::test(start_paused = true)]
async fn finalized_retries_a_provider_with_no_finalized_block() -> Result<()> {
let asserter = Asserter::new();
asserter.push_success(&serde_json::Value::Null);
asserter.push_success(&block_json(64));
let mut settings = mock_settings(asserter);
settings.capture_target = CaptureTarget::Finalized;
let block = resolve_capture_target(&settings).await?;
assert_eq!(block, 64);
Ok(())
}
#[tokio::test(start_paused = true)]
async fn finalized_exhaustion_names_the_finalized_tag() -> Result<()> {
let asserter = Asserter::new();
for _ in 0..11 {
asserter.push_success(&serde_json::Value::Null);
}
let mut settings = mock_settings(asserter.clone());
settings.capture_target = CaptureTarget::Finalized;
let result = resolve_capture_target(&settings).await;
let Err(RpcError::ConnectionError(msg)) = result else {
panic!("expected ConnectionError, got {result:?}");
};
assert!(
msg.contains("Ensure the provider supports the `finalized` tag (mainnet only)"),
"exhaustion message dropped the finalized-tag guidance: {msg}"
);
assert!(
asserter.read_q().is_empty(),
"a loop that stops early leaves responses unconsumed"
);
Ok(())
}
#[tokio::test(start_paused = true)]
async fn finalized_null_response_exhausts_the_ladder() -> Result<()> {
let asserter = Asserter::new();
asserter.push_success(&serde_json::Value::Null);
let mut settings = mock_settings(asserter);
settings.capture_target = CaptureTarget::Finalized;
let result = resolve_capture_target(&settings).await;
assert!(
matches!(result, Err(RpcError::ConnectionError(_))),
"expected RpcError::ConnectionError for an exhausted finalized ladder, got {result:?}"
);
Ok(())
}