ethl-cli 0.1.31

Tools for capturing, processing, archiving, and replaying Ethereum events
Documentation
use alloy::{json_abi::Event, primitives::Address, rpc::types::Filter};
use anyhow::Result;
use clap::{Parser, Subcommand};
use ethl::rpc::config::{ProviderOptions, ProviderSettings};

pub mod cat;
pub mod extract;
pub mod merge;
pub mod repair;

#[derive(Debug, Subcommand)]
pub enum Commands {
    /// Extract a set of events from RPC nodes to parquet files to a given path (file://, s3://, etc)
    #[command(arg_required_else_help = true)]
    Extract(extract::ExtractArgs),

    /// Stream latest logs (or parsed events) to console
    #[command(arg_required_else_help = false)]
    Cat(cat::CatArgs),

    /// Merge event parquet files into larger files with a minimum size
    #[command(arg_required_else_help = true)]
    Merge(merge::MergeArgs),

    /// Repair event parquet files by removing orphaned files that are fully covered by other files
    #[command(arg_required_else_help = true)]
    Repair(repair::RepairArgs),
}

/// Common provider configuration for all commands
#[derive(Debug, Parser)]
pub struct ProviderArgs {
    /// Chain ID used when auto-configuring provider URLs.
    /// Supported: 1 (Ethereum), 42161 (Arbitrum), 8453 (Base), 10 (Optimism), 137 (Polygon), 56 (BNB Smart Chain)
    /// If not specified, defaults to 1 (Ethereum mainnet)
    #[arg(long, env, global = true)]
    chain_id: Option<u64>,

    /// Ankr API Key - Auto configures Ankr RPC and WSS endpoints for supported chain
    #[arg(long, env, short, global = true)]
    ankr_api_key: Option<String>,

    /// Infura API Key - Auto configures Infura RPC and WSS endpoints for supported chain
    #[arg(long, env, global = true)]
    infura_api_key: Option<String>,

    /// Quicknode API Key - Auto configures Quicknode RPC and WSS endpoints for supported chain
    #[arg(long, env, global = true)]
    quicknode_api_key: Option<String>,

    /// Alchemy API Key - Auto configures Alchemy RPC and WSS endpoints for supported chain
    #[arg(long, env, global = true)]
    alchemy_api_key: Option<String>,

    /// Comma separated list of custom HTTP RPC URLs
    #[arg(long, env, global = true)]
    rpc_url: Option<Vec<String>>,

    /// Comma separated list of custom websocket URLs
    #[arg(long, env, global = true)]
    ws_url: Option<Vec<String>>,
}

impl TryFrom<ProviderArgs> for ProviderSettings {
    type Error = anyhow::Error;
    fn try_from(args: ProviderArgs) -> Result<Self, Self::Error> {
        ProviderSettings::build(
            ProviderOptions {
                ankr_api_key: args.ankr_api_key,
                infura_api_key: args.infura_api_key,
                quicknode_api_key: args.quicknode_api_key,
                alchemy_api_key: args.alchemy_api_key,
                rpc_urls: args.rpc_url,
                ws_urls: args.ws_url,
            },
            args.chain_id.unwrap_or(1), // Default to Ethereum mainnet
        )
    }
}

#[derive(Debug, Parser)]
pub struct FilterArgs {
    /// Full event signatures to filter and parse against (e.g. "event Transfer(address from,address to,uint256 amount)")
    #[arg(long, required = false)]
    events: Option<Vec<String>>,

    /// Comma separated list of contract addresses to filter logs by
    #[arg(long, short, required = false, value_delimiter = ',')]
    addresses: Option<Vec<Address>>,

    /// Optional end block for the archive (default: latest)
    #[arg(long)]
    to_block: Option<u64>,

    /// Optional start block for the archive (default: 0)
    #[arg(long)]
    from_block: Option<u64>,
}

impl FilterArgs {
    pub fn parsed_events(&self) -> Option<Result<Vec<Event>>> {
        self.events.as_ref().map(|e| full_signatures_to_events(e))
    }
}

impl TryInto<Option<Filter>> for &FilterArgs {
    type Error = anyhow::Error;

    fn try_into(self) -> Result<Option<Filter>> {
        if self.addresses.is_none() && self.events.is_none() {
            return Ok(None);
        }

        let mut filter = Filter::new();
        if let Some(addresses) = &self.addresses {
            filter = filter.address(addresses.clone());
        }

        let events: Option<Vec<Event>> = self.try_into()?;

        if let Some(events) = events {
            filter = filter.events(
                events
                    .iter()
                    .map(|e| e.signature())
                    .collect::<Vec<String>>(),
            );
        }

        if let Some(from_block) = self.from_block {
            filter = filter.from_block(from_block);
        }

        if let Some(to_block) = self.to_block {
            filter = filter.to_block(to_block);
        }

        Ok(Some(filter))
    }
}

impl TryInto<Option<Vec<Event>>> for &FilterArgs {
    type Error = anyhow::Error;
    fn try_into(self) -> Result<Option<Vec<Event>>> {
        self.events
            .as_ref()
            .map(|e| full_signatures_to_events(e))
            .transpose()
    }
}

fn full_signatures_to_events(signatures: &[String]) -> Result<Vec<Event>> {
    signatures
        .iter()
        .map(|s| Event::parse(s).map_err(anyhow::Error::from))
        .collect::<Result<Vec<Event>>>()
}