ethl 0.1.44

Tools for capturing, processing, archiving, and replaying Ethereum events
Documentation
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;

/// Minimal synthetic block JSON for `eth_getBlockByNumber`.
/// Only fields required by alloy's serde deserialization are included.
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)
}

// === CaptureTarget::Latest ===

#[tokio::test]
async fn latest_returns_tip() -> Result<()> {
    let asserter = Asserter::new();
    asserter.push_success(&serde_json::json!("0x80")); // tip = 128
    let mut settings = mock_settings(asserter);
    settings.capture_target = CaptureTarget::Latest;

    let block = resolve_capture_target(&settings).await?;
    assert_eq!(block, 128);
    Ok(())
}

// === CaptureTarget::Lag ===

#[tokio::test]
async fn lag_subtracts_from_tip() -> Result<()> {
    let asserter = Asserter::new();
    asserter.push_success(&serde_json::json!("0x80")); // tip = 128
    let mut settings = mock_settings(asserter);
    settings.capture_target = CaptureTarget::Lag(10);

    let block = resolve_capture_target(&settings).await?;
    assert_eq!(block, 118); // 128 - 10
    Ok(())
}

#[tokio::test]
async fn lag_saturates_at_zero() -> Result<()> {
    let asserter = Asserter::new();
    asserter.push_success(&serde_json::json!("0x05")); // tip = 5
    let mut settings = mock_settings(asserter);
    settings.capture_target = CaptureTarget::Lag(100);

    let block = resolve_capture_target(&settings).await?;
    assert_eq!(block, 0);
    Ok(())
}

/// PIN — `Latest`'s ladder (attempts, backoff, error text) must survive
/// lifting `last_block`'s loop into a helper shared with `Finalized`,
/// unchanged in both timing and wording.
#[tokio::test(start_paused = true)]
async fn latest_ladder_unchanged_by_extraction() -> Result<()> {
    // One failure then a block: retried once, Ok after exactly the ladder's
    // first backoff step (5s).
    let asserter = Asserter::new();
    asserter.push_failure_msg("transport blip");
    asserter.push_success(&serde_json::json!("0x0a")); // tip = 10
    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");

    // Eleven failures exhaust the ladder: error after exactly 5+10+...+50 =
    // 275s, text naming the attempt count and the failed host.
    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(())
}

// === CaptureTarget::Finalized ===

#[tokio::test]
async fn finalized_returns_finalized_block_number() -> Result<()> {
    let asserter = Asserter::new();
    // eth_getBlockByNumber("finalized") → block at 64 (= 0x40)
    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();
    // A provider that will not serve the `finalized` tag is a provider
    // fault, so it counts as that provider's failed attempt and rotates.
    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(())
}

/// Renamed from `finalized_null_response_is_error`: a null finalized block
/// used to end the call at once with `RpcError::Other`. It now counts as a
/// failed attempt and rotates, so a lone queued null exhausts the ladder —
/// the ten empty-queue requests after it fail too — rather than returning
/// immediately.
#[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(())
}