use alloy::providers::{Provider, ProviderBuilder};
use alloy::rpc::types::Filter;
use alloy::transports::mock::Asserter;
use anyhow::Result;
use ethl::rpc::RpcError;
use ethl::rpc::config::ProviderSettings;
use ethl::rpc::progress::ScanProgress;
use ethl::rpc::stream::backfill_then_watch_logs;
use ethl::rpc::stream::backfill_then_watch_logs_with_progress;
use futures_util::pin_mut;
use futures_util::stream::StreamExt;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use tokio::runtime::Builder;
#[tokio::test]
async fn test_single_block_bounded_range() -> Result<()> {
let logs: serde_json::Value = serde_json::from_str(include_str!(
"fixtures/transfer_getlogs_34762687_34762688.json"
))?;
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!("0x2126fc2")); asserter.push_success(&logs);
let provider = ProviderBuilder::new()
.disable_recommended_fillers()
.connect_mocked_client(asserter)
.erased();
let settings = ProviderSettings::from_mock(provider);
let filter = Filter::new().from_block(34762687).to_block(34762687);
let stream = backfill_then_watch_logs(&settings, Some(&filter)).await;
pin_mut!(stream);
let result = stream.next().await;
assert!(
result.is_some(),
"single-block range should yield at least one result"
);
let log_range_result = result.unwrap();
assert!(
log_range_result.is_ok(),
"result should be Ok, not an error"
);
let range = log_range_result?;
assert!(
range.from_block <= 34762687 && range.to_block >= 34762687,
"returned range [{}, {}] should contain requested block 34762687",
range.from_block,
range.to_block
);
assert!(
!range.logs.is_empty(),
"single-block range should return logs from the fixture"
);
Ok(())
}
#[tokio::test]
async fn test_two_block_bounded_range() -> Result<()> {
let logs: serde_json::Value = serde_json::from_str(include_str!(
"fixtures/transfer_getlogs_34762687_34762688.json"
))?;
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!("0x2126fc2")); asserter.push_success(&logs);
let provider = ProviderBuilder::new()
.disable_recommended_fillers()
.connect_mocked_client(asserter)
.erased();
let settings = ProviderSettings::from_mock(provider);
let filter = Filter::new().from_block(34762687).to_block(34762688);
let stream = backfill_then_watch_logs(&settings, Some(&filter)).await;
pin_mut!(stream);
let result = stream.next().await;
assert!(
result.is_some(),
"two-block range should yield at least one result"
);
let log_range_result = result.unwrap();
assert!(log_range_result.is_ok(), "result should be Ok");
let range = log_range_result?;
assert!(
range.from_block <= 34762688 && range.to_block >= 34762687,
"range should cover blocks 34762687-34762688"
);
assert!(!range.logs.is_empty(), "two-block range should return logs");
Ok(())
}
#[tokio::test]
async fn connect_site_low_block_number_does_not_panic_on_bounded_fetch() -> Result<()> {
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!("0x0"));
let provider = ProviderBuilder::new()
.disable_recommended_fillers()
.connect_mocked_client(asserter)
.erased();
let settings = ProviderSettings::from_mock(provider);
let filter = Filter::new().from_block(0u64).to_block(0u64);
let stream = backfill_then_watch_logs(&settings, Some(&filter)).await;
pin_mut!(stream);
assert!(
stream.next().await.is_none(),
"a [0, 0] bounded fetch clamps to nothing, height 0 or not"
);
let logs: serde_json::Value = serde_json::from_str(include_str!(
"fixtures/transfer_getlogs_34762687_34762688.json"
))?;
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!("0x1"));
asserter.push_success(&logs);
let provider = ProviderBuilder::new()
.disable_recommended_fillers()
.connect_mocked_client(asserter)
.erased();
let settings = ProviderSettings::from_mock(provider);
let filter = Filter::new().from_block(1u64).to_block(1u64);
let stream = backfill_then_watch_logs(&settings, Some(&filter)).await;
pin_mut!(stream);
let range = stream
.next()
.await
.expect("bounded [1, 1] should yield a range")?;
assert_eq!((range.from_block, range.to_block), (1, 1));
assert!(!range.logs.is_empty(), "the fixture returns logs");
assert!(
stream.next().await.is_none(),
"a bounded fetch ends after its range, never going live"
);
Ok(())
}
#[tokio::test]
async fn repoll_site_low_block_number_does_not_panic_after_one_backfill_pass() -> Result<()> {
let logs: serde_json::Value = serde_json::from_str(include_str!(
"fixtures/transfer_getlogs_34762687_34762688.json"
))?;
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!("0x64"));
asserter.push_success(&logs);
asserter.push_success(&serde_json::json!("0x1"));
let provider = ProviderBuilder::new()
.disable_recommended_fillers()
.connect_mocked_client(asserter)
.erased();
let settings = ProviderSettings::from_mock(provider);
let filter = Filter::new().from_block(0u64);
let stream = backfill_then_watch_logs(&settings, Some(&filter)).await;
pin_mut!(stream);
let backfilled = stream
.next()
.await
.expect("the one backfill pass should yield a range")?;
assert_eq!((backfilled.from_block, backfilled.to_block), (1, 98));
assert!(!backfilled.logs.is_empty(), "the fixture returns logs");
let live = stream
.next()
.await
.expect("the re-poll tip of 0 ends backfill and hands off to the live phase");
assert!(
matches!(live, Err(RpcError::AllProvidersSuspended(_))),
"from_mock configures no WS endpoint; the live phase must fail closed, not hang"
);
assert!(
stream.next().await.is_none(),
"the stream ends after the live-phase error"
);
Ok(())
}
#[tokio::test]
async fn bounded_range_with_no_matching_events_still_records_scan_progress() -> Result<()> {
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!("0x2126fc2")); asserter.push_success(&serde_json::json!([])); asserter.push_success(&serde_json::json!("0x2126fc2"));
let provider = ProviderBuilder::new()
.disable_recommended_fillers()
.connect_mocked_client(asserter)
.erased();
let settings = ProviderSettings::from_mock(provider);
let filter = Filter::new().from_block(34762687).to_block(34762688);
let progress = ScanProgress::default();
assert_eq!(
progress.scanned_to(),
None,
"progress must be unset before the stream is driven"
);
let stream =
backfill_then_watch_logs_with_progress(&settings, Some(&filter), Some(&progress)).await;
pin_mut!(stream);
let result = stream.next().await;
assert!(
result.is_some(),
"a zero-event range must still reach the consumer as (from, to, [])"
);
let range = result.unwrap()?;
assert_eq!((range.from_block, range.to_block), (34762687, 34762688));
assert!(
range.logs.is_empty(),
"the fixture returns zero matching events"
);
assert_eq!(
progress.scanned_to(),
Some(34762688),
"scanned_to() must report the top of the range even though nothing matched the filter"
);
Ok(())
}
#[test]
fn zero_to_zero_bounded_range_progress_stays_unset() {
let (tx, rx) = mpsc::channel::<(Vec<Result<(u64, u64, usize), RpcError>>, Option<u64>)>();
thread::spawn(move || {
let runtime = Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build current-thread runtime");
let (collected, scanned_to) = runtime.block_on(async {
let logs: serde_json::Value = serde_json::from_str(include_str!(
"fixtures/transfer_getlogs_34762687_34762688.json"
))
.expect("fixture parses as JSON");
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!("0x2126fc2"));
asserter.push_success(&logs);
let provider = ProviderBuilder::new()
.disable_recommended_fillers()
.connect_mocked_client(asserter)
.erased();
let settings = ProviderSettings::from_mock(provider);
let filter = Filter::new().from_block(0u64).to_block(0u64);
let progress = ScanProgress::default();
let stream =
backfill_then_watch_logs_with_progress(&settings, Some(&filter), Some(&progress))
.await;
pin_mut!(stream);
let mut collected = Vec::new();
while let Some(item) = stream.next().await {
collected.push(item.map(|r| (r.from_block, r.to_block, r.logs.len())));
}
(collected, progress.scanned_to())
});
let _ = tx.send((collected, scanned_to));
});
let (collected, scanned_to) = rx
.recv_timeout(Duration::from_secs(10))
.expect("bounded [0, 0] must terminate");
assert_eq!(
collected.len(),
0,
"bounded [0, 0] should terminate yielding zero ranges, got {collected:?}"
);
assert_eq!(
scanned_to, None,
"block 0 is never scanned; scanned_to() must stay None, not Some(0)"
);
}
#[tokio::test]
async fn test_unbounded_filter_unchanged() -> Result<()> {
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!("0x2"));
let provider = ProviderBuilder::new()
.disable_recommended_fillers()
.connect_mocked_client(asserter)
.erased();
let settings = ProviderSettings::from_mock(provider);
let filter = Filter::new().from_block(0);
let stream = backfill_then_watch_logs(&settings, Some(&filter)).await;
pin_mut!(stream);
let result = stream.next().await;
assert!(
result.is_some(),
"unbounded stream should yield a result (error or data)"
);
let item = result.unwrap();
assert!(
item.is_err(),
"unbounded stream without WSS should yield AllProvidersSuspended error"
);
Ok(())
}
#[test]
fn zero_to_zero_bounded_range_terminates() {
let (tx, rx) = mpsc::channel::<Vec<Result<(u64, u64, usize), RpcError>>>();
thread::spawn(move || {
let runtime = Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build current-thread runtime");
let collected = runtime.block_on(async {
let logs: serde_json::Value = serde_json::from_str(include_str!(
"fixtures/transfer_getlogs_34762687_34762688.json"
))
.expect("fixture parses as JSON");
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!("0x2126fc2"));
asserter.push_success(&logs);
let provider = ProviderBuilder::new()
.disable_recommended_fillers()
.connect_mocked_client(asserter)
.erased();
let settings = ProviderSettings::from_mock(provider);
let filter = Filter::new().from_block(0u64).to_block(0u64);
let stream = backfill_then_watch_logs(&settings, Some(&filter)).await;
pin_mut!(stream);
let mut collected = Vec::new();
while let Some(item) = stream.next().await {
collected.push(item.map(|r| (r.from_block, r.to_block, r.logs.len())));
}
collected
});
let _ = tx.send(collected);
});
let collected = rx
.recv_timeout(Duration::from_secs(10))
.expect("bounded [0, 0] must terminate");
assert_eq!(
collected.len(),
0,
"bounded [0, 0] should terminate yielding zero ranges, got {collected:?}"
);
}
#[tokio::test]
async fn bounded_range_from_zero_starts_at_block_one() -> Result<()> {
let logs: serde_json::Value = serde_json::from_str(include_str!(
"fixtures/transfer_getlogs_34762687_34762688.json"
))?;
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!("0x2126fc2"));
asserter.push_success(&logs);
let provider = ProviderBuilder::new()
.disable_recommended_fillers()
.connect_mocked_client(asserter)
.erased();
let settings = ProviderSettings::from_mock(provider);
let filter = Filter::new().from_block(0u64).to_block(2u64);
let stream = backfill_then_watch_logs(&settings, Some(&filter)).await;
pin_mut!(stream);
let result = stream.next().await;
assert!(
result.is_some(),
"range starting at block 0 should yield at least one result"
);
let range = result.unwrap()?;
assert_eq!(
range.from_block, 1,
"the first yielded range must start at block 1, skipping genesis"
);
Ok(())
}