(
+ cfg: &Args,
+ l1: &Arc,
+ l2: &Arc
,
+ m: &Metrics,
+) -> anyhow::Result>> {
+ let mut services: Vec> = vec![];
+
+ // ── Health server ─────────────────────────────────────
+ let health_server = HealthServer { addr: cfg.health_addr };
+ services.push(Box::new(health_server));
+
+ // ── L2 metrics collector ──────────────────────────────
+ let l2_collector = L2Collector::new(
+ L2CollectorConfig {
+ poll_interval: Duration::from_millis(cfg.poll_l2_interval_ms),
+ fee_disburser_address: cfg.fee_disburser_address,
+ l1_system_config_address: cfg.l1_system_config_address,
+ contract_balance_calls: cfg.l2_contract_balance_calls.clone(),
+ },
+ Arc::clone(l2),
+ m.clone(),
+ )?;
+ services.push(Box::new(l2_collector));
+
+ // ── L1 metrics collector ──────────────────────────────
+ let l1_collector = L1Collector::new(
+ L1CollectorConfig {
+ poll_interval: Duration::from_millis(cfg.poll_l1_interval_ms),
+ optimism_portal_address: cfg.optimism_portal_address,
+ output_oracle_address: cfg.output_oracle_address,
+ dispute_game_factory_address: cfg.dispute_game_factory_address,
+ l1_standard_bridge_address: cfg.l1_standard_bridge_address,
+ ethereum_proposer_address: cfg.ethereum_proposer_address,
+ ethereum_batcher_address: cfg.ethereum_batcher_address,
+ batch_inbox_address: cfg.batch_inbox_address,
+ balance_tracker_address: cfg.balance_tracker_address,
+ contract_balance_calls: cfg.l1_contract_balance_calls.clone(),
+ },
+ Arc::clone(l1),
+ m.clone(),
+ )?;
+ services.push(Box::new(l1_collector));
+
+ // ── L1 health checks ─────────────────────────────
+ let l1_health_checker = HealthChecker {
+ nodes: build_node_list(&cfg.l1_nodes, "internal"),
+ sequencer: None,
+ poll_interval: Duration::from_millis(cfg.poll_health_check_interval_ms),
+ grace_period: Duration::from_millis(cfg.l1_nodes_grace_period_ms),
+ metrics: m.clone(),
+ layer: "l1",
+ };
+ services.push(Box::new(l1_health_checker));
+
+ // ── L2 health checks ─────────────────────────────────
+ let mut l2_nodes = build_node_list(&cfg.l2_nodes, "internal");
+ l2_nodes.extend(build_node_list_sanitized(&cfg.external_nodes_url, "external"));
+ let sequencer = cfg.l2_sequencer.as_deref().and_then(|url| {
+ Node::new(url, "sequencer").or_else(|| {
+ warn!("failed to parse sequencer URL");
+ None
+ })
+ });
+ let l2_health_checker = HealthChecker {
+ nodes: l2_nodes,
+ sequencer,
+ poll_interval: Duration::from_millis(cfg.poll_health_check_interval_ms),
+ grace_period: Duration::from_millis(cfg.l2_nodes_grace_period_ms),
+ metrics: m.clone(),
+ layer: "l2",
+ };
+ services.push(Box::new(l2_health_checker));
+
+ // ── Flashblock validators ─────────────────────────────
+ let fb_urls = cfg
+ .flashblock_websocket_url
+ .iter()
+ .map(|u| (u.clone(), "websocket-proxy".to_string()))
+ .chain(
+ cfg.flashblock_rb_urls.iter().enumerate().map(|(i, u)| (u.clone(), format!("rb-{i}"))),
+ )
+ .chain(
+ cfg.flashblock_builder_urls
+ .iter()
+ .enumerate()
+ .map(|(i, u)| (u.clone(), format!("builder-{i}"))),
+ );
+ for (url, name) in fb_urls {
+ info!(url, name, "registering flashblock validator");
+ let flashblock_validator = FlashblockValidator::new(
+ name,
+ url,
+ Arc::clone(l2),
+ Duration::from_millis(cfg.poll_l2_interval_ms),
+ m.clone(),
+ );
+ services.push(Box::new(flashblock_validator));
+ }
+
+ // ── Mempool listener ──────────────────────────────────
+ match (cfg.geth_mempool_rpc_url.as_deref(), cfg.reth_mempool_rpc_url.as_deref()) {
+ (Some(g), Some(r)) => {
+ let geth = ProviderBuilder::new().connect_http(g.parse::()?);
+ let reth = ProviderBuilder::new().connect_http(r.parse::()?);
+ let mempool_listener = MempoolListenerService {
+ geth,
+ reth,
+ poll_interval: Duration::from_millis(cfg.poll_mempool_interval_ms),
+ metrics: m.clone(),
+ listener_name: "mempool-listener".to_string(),
+ };
+ services.push(Box::new(mempool_listener));
+ }
+ (None, None) => info!("no mempool RPC URLs provided, skipping"),
+ (None, _) => warn!("geth mempool URL missing, skipping mempool listener"),
+ (_, None) => warn!("reth mempool URL missing, skipping mempool listener"),
+ }
+
+ // ── Divergence checker ────────────────────────────────
+ if let (Some(g), Some(r)) =
+ (cfg.divergence_geth_node.as_deref(), cfg.divergence_reth_node.as_deref())
+ {
+ let geth = ProviderBuilder::new().connect_http(g.parse::()?);
+ let reth = ProviderBuilder::new().connect_http(r.parse::()?);
+ let divergence_checker = DivergenceCheckerService {
+ geth,
+ reth,
+ poll_interval: Duration::from_millis(cfg.divergence_poll_interval_ms),
+ geth_grace_period: Duration::from_millis(cfg.divergence_geth_grace_period_ms),
+ metrics: m.clone(),
+ checker_name: "divergence-checker".to_string(),
+ };
+ services.push(Box::new(divergence_checker));
+ } else {
+ info!("divergence checker not configured, skipping");
+ }
+
+ // ── Snapshots ─────────────────────────────────────────
+ let snapshots_service = SnapshotsService {
+ buckets: cfg.snapshot_buckets.clone(),
+ poll_interval: Duration::from_secs(3600),
+ metrics: m.clone(),
+ };
+ services.push(Box::new(snapshots_service));
+
+ Ok(services)
+}
+
+/// Initialise `tracing` with the given level and format (`json` or `text`).
+fn init_tracing(level: &str, format: &str) {
+ use tracing_subscriber::EnvFilter;
+
+ let filter = EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info"));
+
+ match format {
+ "json" => {
+ tracing_subscriber::fmt().with_env_filter(filter).json().init();
+ }
+ _ => {
+ tracing_subscriber::fmt().with_env_filter(filter).init();
+ }
+ }
+}
+
+/// Wait for SIGINT / SIGTERM, cancel all tasks, and drain the join set.
+async fn await_shutdown(cancel: CancellationToken, mut set: JoinSet<()>) {
+ use tokio::signal;
+
+ tokio::select! {
+ _ = signal::ctrl_c() => {
+ info!("received SIGINT, shutting down");
+ }
+ _ = async {
+ #[cfg(unix)]
+ {
+ let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate())
+ .expect("failed to register SIGTERM handler");
+ sigterm.recv().await;
+ }
+ #[cfg(not(unix))]
+ {
+ std::future::pending::<()>().await;
+ }
+ } => {
+ info!("received SIGTERM, shutting down");
+ }
+ }
+
+ cancel.cancel();
+
+ // Give tasks 15 seconds to finish.
+ let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
+
+ loop {
+ tokio::select! {
+ result = set.join_next() => {
+ match result {
+ None => break,
+ Some(Ok(())) => {}
+ Some(Err(e)) => {
+ error!(error = %e, "task panicked during shutdown");
+ }
+ }
+ }
+ _ = tokio::time::sleep_until(deadline) => {
+ warn!("timeout waiting for tasks to shut down");
+ set.abort_all();
+ break;
+ }
+ }
+ }
+}
diff --git a/crates/basectl/Cargo.toml b/crates/basectl/Cargo.toml
index 3064f76..4bd30eb 100644
--- a/crates/basectl/Cargo.toml
+++ b/crates/basectl/Cargo.toml
@@ -24,7 +24,7 @@ alloy-rpc-types-eth = { workspace = true }
alloy-eips = { workspace = true }
op-alloy-network = { workspace = true }
op-alloy-consensus = { workspace = true }
-base-flashtypes = { workspace = true }
+base-primitives = { workspace = true, features = ["flashblocks"] }
url = { workspace = true }
alloy-primitives = { workspace = true }
alloy-sol-types = { workspace = true }
diff --git a/crates/basectl/src/app/resources.rs b/crates/basectl/src/app/resources.rs
index ac74277..55696a1 100644
--- a/crates/basectl/src/app/resources.rs
+++ b/crates/basectl/src/app/resources.rs
@@ -1,6 +1,6 @@
use std::collections::VecDeque;
-use base_flashtypes::Flashblock;
+use base_primitives::flashblocks::Flashblock;
use tokio::sync::mpsc;
use crate::{
diff --git a/crates/basectl/src/app/runner.rs b/crates/basectl/src/app/runner.rs
index a596675..07a6359 100644
--- a/crates/basectl/src/app/runner.rs
+++ b/crates/basectl/src/app/runner.rs
@@ -1,5 +1,5 @@
use anyhow::Result;
-use base_flashtypes::Flashblock;
+use base_primitives::flashblocks::Flashblock;
use tokio::sync::mpsc;
use super::{App, Resources, ViewId, views::create_view};
diff --git a/crates/basectl/src/rpc.rs b/crates/basectl/src/rpc.rs
index 2957a4d..41c4713 100644
--- a/crates/basectl/src/rpc.rs
+++ b/crates/basectl/src/rpc.rs
@@ -5,7 +5,7 @@ use alloy_primitives::{Address, B256};
use alloy_provider::{Provider, ProviderBuilder};
use alloy_rpc_types_eth::{BlockNumberOrTag, TransactionTrait};
use anyhow::Result;
-use base_flashtypes::Flashblock;
+use base_primitives::flashblocks::Flashblock;
use futures_util::{StreamExt, stream};
use op_alloy_network::Optimism;
use tokio::sync::mpsc;
diff --git a/crates/mempool-rebroadcaster/tests/e2e_tests.rs b/crates/mempool-rebroadcaster/tests/e2e_tests.rs
index 5943136..8c6b659 100644
--- a/crates/mempool-rebroadcaster/tests/e2e_tests.rs
+++ b/crates/mempool-rebroadcaster/tests/e2e_tests.rs
@@ -20,7 +20,7 @@ async fn test_e2e_static_data() {
let reth_mempool = load_static_mempool_content("testdata/reth_mempool.json")
.expect("Failed to load reth mempool data");
- // Use constant network fees for testing (same as Go version)
+ // Use constant network fees for testing
let base_fee = 0x2601ff_u128; // 0x2601ff
let gas_price = 0x36daa7_u128; // 0x36daa7
diff --git a/crates/rpc-collector/Cargo.toml b/crates/rpc-collector/Cargo.toml
new file mode 100644
index 0000000..cc5d00f
--- /dev/null
+++ b/crates/rpc-collector/Cargo.toml
@@ -0,0 +1,57 @@
+[package]
+name = "rpc-collector"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/lib.rs"
+
+[dependencies]
+# async
+tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "sync", "time"] }
+tokio-tungstenite.workspace = true
+tokio-util = { workspace = true, features = ["rt"] }
+async-trait.workspace = true
+futures-util.workspace = true
+
+# alloy
+alloy-primitives = { workspace = true, features = ["serde"] }
+alloy-provider = { workspace = true, features = ["reqwest"] }
+alloy-rpc-types-eth.workspace = true
+alloy-consensus = { workspace = true, features = ["std"] }
+alloy-sol-types.workspace = true
+alloy-contract.workspace = true
+alloy-rpc-client.workspace = true
+alloy-transport-http.workspace = true
+alloy-network.workspace = true
+alloy-rlp.workspace = true
+alloy-eips.workspace = true
+alloy-network-primitives = { workspace = true }
+
+# op-alloy
+op-alloy-consensus = { workspace = true, features = ["std"] }
+op-alloy-rpc-types.workspace = true
+op-alloy-network = { workspace = true, features = ["std"] }
+
+# metrics
+cadence.workspace = true
+
+# web
+axum = { workspace = true, features = ["tokio", "http1", "json"] }
+reqwest.workspace = true
+
+# serialization
+serde = { workspace = true }
+serde_json = { workspace = true, features = ["std"] }
+
+# base
+base-primitives = { workspace = true, features = ["flashblocks"] }
+
+# misc
+tracing = { workspace = true, features = ["std"] }
+anyhow = { workspace = true, features = ["std"] }
+url.workspace = true
diff --git a/crates/rpc-collector/src/bindings.rs b/crates/rpc-collector/src/bindings.rs
new file mode 100644
index 0000000..8b46c22
--- /dev/null
+++ b/crates/rpc-collector/src/bindings.rs
@@ -0,0 +1,211 @@
+//! Contract bindings via `alloy::sol!`.
+//!
+//! Only the functions/events we actually call are included.
+//! Also re-exports well-known OP Stack predeploy addresses.
+
+use alloy_primitives::{Address, address};
+use alloy_sol_types::sol;
+
+// ── OP Stack L2 Predeploy Addresses ─────────────────────────
+
+/// `SequencerFeeVault` predeploy.
+pub const SEQUENCER_FEE_VAULT: Address = address!("4200000000000000000000000000000000000011");
+/// `BaseFeeVault` predeploy.
+pub const BASE_FEE_VAULT: Address = address!("4200000000000000000000000000000000000019");
+/// `L1FeeVault` predeploy.
+pub const L1_FEE_VAULT: Address = address!("420000000000000000000000000000000000001A");
+/// `L2ToL1MessagePasser` predeploy.
+pub const L2_TO_L1_MSG_PASSER: Address = address!("4200000000000000000000000000000000000016");
+/// `L2StandardBridge` predeploy.
+pub const L2_STANDARD_BRIDGE: Address = address!("4200000000000000000000000000000000000010");
+/// `L1Block` predeploy.
+pub const L1_BLOCK: Address = address!("4200000000000000000000000000000000000015");
+/// `GasPriceOracle` predeploy.
+pub const GAS_PRICE_ORACLE: Address = address!("420000000000000000000000000000000000000F");
+
+// ── GasPriceOracle (L2 predeploy 0x420000000000000000000000000000000000000F) ──
+
+sol! {
+ #[sol(rpc)]
+ interface GasPriceOracle {
+ function l1BaseFee() external view returns (uint256);
+ function blobBaseFee() external view returns (uint256);
+ }
+}
+
+// ── L2ToL1MessagePasser (L2 predeploy 0x4200000000000000000000000000000000000016) ──
+
+sol! {
+ #[sol(rpc)]
+ interface L2ToL1MessagePasser {
+ event MessagePassed(
+ uint256 indexed nonce,
+ address indexed sender,
+ address indexed target,
+ uint256 value,
+ uint256 gasLimit,
+ bytes data,
+ bytes32 withdrawalHash
+ );
+ }
+}
+
+// ── L2StandardBridge (L2 predeploy 0x4200000000000000000000000000000000000010) ──
+
+sol! {
+ #[sol(rpc)]
+ interface L2StandardBridge {
+ event WithdrawalInitiated(
+ address indexed l1Token,
+ address indexed l2Token,
+ address indexed from,
+ address to,
+ uint256 amount,
+ bytes extraData
+ );
+
+ event DepositFinalized(
+ address indexed l1Token,
+ address indexed l2Token,
+ address indexed from,
+ address to,
+ uint256 amount,
+ bytes extraData
+ );
+ }
+}
+
+// ── L1StandardBridge ──
+
+sol! {
+ #[sol(rpc)]
+ interface L1StandardBridge {
+ event ETHBridgeInitiated(
+ address indexed from,
+ address indexed to,
+ uint256 amount,
+ bytes extraData
+ );
+
+ event ERC20BridgeInitiated(
+ address indexed localToken,
+ address indexed remoteToken,
+ address indexed from,
+ address to,
+ uint256 amount,
+ bytes extraData
+ );
+
+ event ETHWithdrawalFinalized(
+ address indexed from,
+ address indexed to,
+ uint256 amount,
+ bytes extraData
+ );
+
+ event ERC20WithdrawalFinalized(
+ address indexed localToken,
+ address indexed remoteToken,
+ address indexed from,
+ address to,
+ uint256 amount,
+ bytes extraData
+ );
+ }
+}
+
+// ── OptimismPortal ──
+
+sol! {
+ #[sol(rpc)]
+ interface OptimismPortal {
+ event TransactionDeposited(
+ address indexed from,
+ address indexed to,
+ uint256 indexed version,
+ bytes opaqueData
+ );
+
+ event WithdrawalFinalized(bytes32 indexed withdrawalHash, bool success);
+ }
+}
+
+// ── L2OutputOracle (legacy) ──
+
+sol! {
+ #[sol(rpc)]
+ interface L2OutputOracle {
+ function latestBlockNumber() external view returns (uint256);
+ }
+}
+
+// ── DisputeGameFactory ──
+
+sol! {
+ #[sol(rpc)]
+ interface DisputeGameFactory {
+ function gameCount() external view returns (uint256);
+
+ function gameAtIndex(uint256 _index) external view returns (
+ uint32 gameType,
+ uint64 timestamp,
+ address proxy
+ );
+ }
+}
+
+// ── FaultDisputeGame (proxied via DisputeGameFactory) ──
+
+sol! {
+ #[sol(rpc)]
+ interface FaultDisputeGame {
+ function l2BlockNumber() external view returns (uint256);
+ }
+}
+
+// ── SystemConfig (L1) ──
+
+sol! {
+ #[sol(rpc)]
+ interface SystemConfig {
+ function eip1559Elasticity() external view returns (uint32);
+ function eip1559Denominator() external view returns (uint32);
+ function scalar() external view returns (uint256);
+ function basefeeScalar() external view returns (uint32);
+ function blobbasefeeScalar() external view returns (uint32);
+ }
+}
+
+// ── L1Block (L2 predeploy 0x4200000000000000000000000000000000000015) ──
+
+sol! {
+ #[sol(rpc)]
+ interface L1Block {
+ function baseFeeScalar() external view returns (uint32);
+ function blobBaseFeeScalar() external view returns (uint32);
+ function daFootprintGasScalar() external view returns (uint16);
+ }
+}
+
+// ── FeeDisburser (L2) ──
+
+sol! {
+ #[sol(rpc)]
+ interface FeeDisburser {
+ event FeesDisbursed(
+ uint256 _disbursementTime,
+ uint256 _paidToOptimism,
+ uint256 _totalFeesDisbursed
+ );
+ }
+}
+
+// ── BalanceTracker (L1) ──
+
+sol! {
+ #[sol(rpc)]
+ interface BalanceTracker {
+ event ReceivedFunds(address indexed _sender, uint256 _amount);
+ event SentProfit(address indexed _profitWallet, bool indexed _success, uint256 _balanceSent);
+ }
+}
diff --git a/crates/rpc-collector/src/lib.rs b/crates/rpc-collector/src/lib.rs
new file mode 100644
index 0000000..e1a7252
--- /dev/null
+++ b/crates/rpc-collector/src/lib.rs
@@ -0,0 +1,85 @@
+//! RPC Collector — collects `OPStack` chain metrics from L1 and L2 RPC endpoints.
+
+pub mod bindings;
+pub mod metrics;
+pub mod recorders;
+pub mod services;
+pub mod utils;
+
+/// Test helpers shared across unit test modules.
+#[cfg(test)]
+pub(crate) mod test_helpers {
+ use alloy_consensus::{Signed, TxEnvelope, TxLegacy, transaction::Recovered};
+ use alloy_network_primitives::BlockTransactions;
+ use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256};
+ use alloy_rpc_types_eth::{
+ Block as RpcBlock, Header as RpcHeader, Transaction as RpcTransaction,
+ };
+
+ /// The concrete RPC block type used throughout the crate.
+ pub(crate) type Block =
+ RpcBlock, RpcHeader>;
+ /// The concrete RPC header type.
+ #[allow(dead_code)]
+ pub(crate) type Header = RpcHeader;
+ /// The concrete RPC transaction type.
+ pub(crate) type Transaction = RpcTransaction;
+
+ /// Build a minimal [`Block`] for testing with the given header fields.
+ ///
+ /// All other fields are left at their defaults.
+ pub(crate) fn make_block(number: u64, hash: B256, parent_hash: B256) -> Block {
+ let mut block: Block = Default::default();
+ block.header.hash = hash;
+ block.header.inner.number = number;
+ block.header.inner.parent_hash = parent_hash;
+ block
+ }
+
+ /// Build a minimal [`Block`] with the given transactions.
+ pub(crate) fn make_block_with_txs(number: u64, txs: Vec) -> Block {
+ let mut block: Block = Default::default();
+ block.header.inner.number = number;
+ block.transactions = BlockTransactions::Full(txs);
+ block
+ }
+
+ /// Build a minimal RPC [`Header`] for testing.
+ #[allow(dead_code)]
+ pub(crate) fn make_header(number: u64, hash: B256, parent_hash: B256) -> Header {
+ Header {
+ hash,
+ inner: alloy_consensus::Header { number, parent_hash, ..Default::default() },
+ ..Default::default()
+ }
+ }
+
+ /// Build a minimal RPC [`Transaction`] (legacy type) for testing.
+ ///
+ /// `from` is the sender, `to` is the recipient, and `input` is the calldata.
+ pub(crate) fn make_legacy_tx(from: Address, to: Address, input: Bytes) -> Transaction {
+ let tx_legacy = TxLegacy {
+ chain_id: Some(1),
+ nonce: 0,
+ gas_price: 1_000_000_000,
+ gas_limit: 21_000,
+ to: TxKind::Call(to),
+ value: U256::ZERO,
+ input,
+ };
+
+ let fake_sig = Signature::new(U256::from(1u64), U256::from(2u64), false);
+ let fake_hash = B256::default();
+ let signed = Signed::new_unchecked(tx_legacy, fake_sig, fake_hash);
+ let envelope = TxEnvelope::Legacy(signed);
+ let recovered = Recovered::new_unchecked(envelope, from);
+
+ RpcTransaction {
+ inner: recovered,
+ block_hash: None,
+ block_number: None,
+ transaction_index: None,
+ effective_gas_price: None,
+ }
+ }
+}
diff --git a/crates/rpc-collector/src/metrics.rs b/crates/rpc-collector/src/metrics.rs
new file mode 100644
index 0000000..80d761a
--- /dev/null
+++ b/crates/rpc-collector/src/metrics.rs
@@ -0,0 +1,219 @@
+//! `StatsD` metrics client wrapper and metric name constants.
+
+use std::{net::UdpSocket, sync::Arc};
+
+use cadence::{
+ BufferedUdpMetricSink, Counted, CountedExt, Gauged, Histogrammed, QueuingMetricSink,
+ StatsdClient,
+};
+
+// ── Metric name constants ────────────────────────────────────
+
+// Block / gas metrics
+pub const BLOCK_GAS_LIMIT: &str = "base.gas.limit";
+pub const BLOCK_GAS_USED: &str = "base.gas.used";
+pub const BLOCK_GAS_TARGET: &str = "base.gas.target";
+pub const BLOCK_BASE_GAS_FEE: &str = "base.gas.base.fee";
+pub const MIN_BASE_FEE: &str = "base.gas.minbasefee";
+pub const DA_FOOTPRINT: &str = "base.gas.dafootprint";
+pub const DA_FOOTPRINT_GAS_SCALAR: &str = "base.gas.dafootprintscalar";
+pub const ELASTICITY: &str = "base.elasticity";
+
+// Collector progress
+pub const L2_LATEST_BLOCK: &str = "base.l2.collector.block";
+pub const L1_LATEST_BLOCK: &str = "base.l1.collector.block";
+
+// Transactions
+pub const TRANSACTION_COUNT: &str = "base.transactions";
+pub const TRANSACTION_TYPE: &str = "base.txn.type";
+pub const TRANSACTION_GAS_PRICE: &str = "base.txn.gas.price";
+pub const TRANSACTION_GAS_PRICE_MAX: &str = "base.txn.gas.maximum";
+pub const TRANSACTION_MAX_PRIORITY_FEE: &str = "base.txn.gas.priorityfee";
+pub const TRANSACTION_TOTAL_PRI_FEES: &str = "base.txn.gas.totalpriorityfee";
+pub const TRANSACTION_L2_FEES: &str = "base.txn.l2.fees";
+pub const TRANSACTIONS_SUCCESS: &str = "base.l2.txn.success";
+pub const TRANSACTIONS_FAILED: &str = "base.l2.txn.failed";
+pub const EMPTY_BLOCKS: &str = "base.blocks.empty";
+
+// Vault balances
+pub const SEQUENCER_BALANCE: &str = "base.sequencer.vault.balance";
+pub const BASE_FEE_BALANCE: &str = "base.base.fee.vault.balance";
+pub const L1_FEE_BALANCE: &str = "base.l1.fee.vault.balance";
+
+// Bridge events
+pub const WITHDRAWAL_INITIATED_COUNT: &str = "base.withdrawal.initiated.count";
+pub const WITHDRAWAL_FINALIZED_COUNT: &str = "base.withdrawal.finalized.count";
+pub const DEPOSIT_INITIATED_COUNT: &str = "base.deposit.initiated.count";
+pub const DEPOSIT_FINALIZED_COUNT: &str = "base.deposit.finalized.count";
+
+// Gas price oracle
+pub const L1_BASE_FEE: &str = "base.l1.base.fee";
+pub const L1_BLOB_BASE_FEE: &str = "base.l1.blob.base.fee";
+
+// Proposer / batcher
+pub const PROPOSER_BALANCE: &str = "base.proposer.balance";
+pub const BATCH_BALANCE: &str = "base.batch.balance";
+
+// Output oracle
+pub const OUTPUT_ORACLE_LATEST_BLOCK: &str = "base.output.latest.block";
+
+// Batch inbox
+pub const BATCH_SENT_BY_NON_BATCH_SENDER: &str = "base.batch.inbox.unknown";
+pub const BATCH_SENT_BY_BATCH_SENDER: &str = "base.batch.inbox.batcher";
+
+// Fee disburser / balance tracker
+pub const FEES_DISBURSED_COUNT: &str = "base.fees.disbursed.count";
+pub const RECEIVED_FUNDS_COUNT: &str = "base.received.funds.count";
+pub const SENT_PROFIT_COUNT: &str = "base.sent.profit.count";
+
+// Reorg
+pub const REORG_DEPTH: &str = "base.reorg.depth";
+
+// Snapshots
+pub const SNAPSHOT_ERROR: &str = "base.snapshot.error";
+pub const SNAPSHOT_SUCCESS: &str = "base.snapshot.time";
+pub const SNAPSHOT_AGE: &str = "base.snapshot.age";
+pub const SNAPSHOT_SIZE: &str = "base.snapshot.size";
+
+// Flashblocks
+pub const FLASHBLOCK_HASH_MISMATCH: &str = "base.flashblock.hash.mismatch";
+pub const FLASHBLOCK_VALIDATION_SUCCESS: &str = "base.flashblock.validation.success";
+pub const FLASHBLOCK_MISSING_INDICES: &str = "base.flashblock.missing.indices";
+pub const FLASHBLOCK_TOTAL_RECEIVED: &str = "base.flashblock.total.received";
+pub const FLASHBLOCK_NO_DATA_RECEIVED: &str = "base.flashblock.no.data.received";
+pub const FLASHBLOCK_SUCCESS: &str = "base.flashblock.success";
+pub const FLASHBLOCK_REORG: &str = "base.flashblock.reorg";
+pub const FLASHBLOCK_TX_REORGED: &str = "base.flashblock.tx.reorged";
+pub const FLASHBLOCK_TX_INCLUDED: &str = "base.flashblock.tx.included";
+pub const FLASHBLOCK_TX_TOTAL: &str = "base.flashblock.tx.total";
+pub const FLASHBLOCK_TX_ORDER_VIOLATION: &str = "base.flashblock.tx.order.violation";
+pub const FLASHBLOCK_TX_ORDER_SUCCESS: &str = "base.flashblock.tx.order.success";
+pub const FLASHBLOCK_PRIORITY_FEE_MIN: &str = "base.flashblock.priorityfee.min";
+pub const FLASHBLOCK_PRIORITY_FEE_TX_COUNT: &str = "base.flashblock.priorityfee.tx.count";
+pub const FLASHBLOCK_PRIORITY_FEE_TIP: &str = "base.flashblock.priorityfee.tip";
+
+// Mempool
+pub const MEMPOOL_PENDING_COUNT: &str = "base.mempool.pending.count";
+pub const MEMPOOL_QUEUED_COUNT: &str = "base.mempool.queued.count";
+pub const MEMPOOL_ERROR: &str = "base.mempool.error";
+pub const MEMPOOL_COLLECTION_SUCCESS: &str = "base.mempool.collection.success";
+pub const MEMPOOL_RECLASSIFIED_COUNT: &str = "base.mempool.reclassified.count";
+pub const MEMPOOL_ORIGINAL_PENDING_COUNT: &str = "base.mempool.original.pending.count";
+pub const MEMPOOL_PENDING_DIFF: &str = "base.mempool.pending.diff";
+pub const MEMPOOL_QUEUED_DIFF: &str = "base.mempool.queued.diff";
+pub const MEMPOOL_TOTAL_DIFF: &str = "base.mempool.total.diff";
+
+// Node health
+pub const NODE_LATEST_BLOCK: &str = "base.node.latest.num";
+pub const NODE_LATEST_TIME: &str = "base.node.latest.time";
+pub const NODE_HEALTHY: &str = "base.node.healthy";
+pub const NODE_ERROR: &str = "base.node.error";
+pub const NODE_STALL: &str = "base.node.stall";
+pub const NODE_RATE_LIMITED: &str = "base.node.ratelimited";
+pub const SEQUENCER_LATEST_BLOCK: &str = "base.sequencer.latest.num";
+pub const SEQUENCER_DELTA: &str = "base.node.sequencer.delta";
+
+// Divergence
+pub const DIVERGENCE_BLOCK_PROCESSED: &str = "base.divergence.block.processed";
+pub const DIVERGENCE_CROSS_GROUP_DETECTED: &str = "base.divergence.cross.group.detected";
+pub const DIVERGENCE_GETH_TIMEOUT: &str = "base.divergence.geth.timeout";
+pub const DIVERGENCE_NODE_ERROR: &str = "base.divergence.node.error";
+
+// ── StatsD client wrapper ────────────────────────────────────
+
+/// Thin wrapper around [`cadence::StatsdClient`].
+///
+/// For untagged metrics, use the inherent `gauge`, `count`, and `histogram`
+/// methods. For tagged metrics, use the `*_with_tags` convenience methods.
+#[derive(Clone, Debug)]
+pub struct Metrics {
+ inner: Arc,
+}
+
+impl Metrics {
+ /// Create a new [`Metrics`] client connected to the given `StatsD` endpoint.
+ pub fn new(host: &str, port: u16, prefix: &str) -> anyhow::Result {
+ let socket = UdpSocket::bind("0.0.0.0:0")?;
+ socket.set_nonblocking(true)?;
+ let addr = format!("{host}:{port}");
+ let udp_sink = BufferedUdpMetricSink::from(addr.as_str(), socket)?;
+ let queuing_sink = QueuingMetricSink::from(udp_sink);
+ let client = StatsdClient::from_sink(prefix, queuing_sink);
+ Ok(Self { inner: Arc::new(client) })
+ }
+
+ /// Create a no-op metrics client that silently drops all metrics.
+ pub fn noop() -> Self {
+ Self { inner: Arc::new(StatsdClient::from_sink("", cadence::NopMetricSink)) }
+ }
+
+ // ── Untagged metric helpers ────────────────────────────
+
+ pub fn gauge(&self, key: &str, value: f64) -> cadence::MetricResult {
+ self.inner.gauge(key, value)
+ }
+
+ pub fn count(&self, key: &str, value: i64) -> cadence::MetricResult {
+ self.inner.count(key, value)
+ }
+
+ pub fn histogram(&self, key: &str, value: f64) -> cadence::MetricResult {
+ self.inner.histogram(key, value)
+ }
+
+ // ── Tagged convenience helpers ───────────────────────────
+
+ pub fn gauge_with_tags(&self, name: &str, value: f64, tags: &[(&str, &str)]) {
+ let mut builder = self.inner.gauge_with_tags(name, value);
+ for &(k, v) in tags {
+ builder = builder.with_tag(k, v);
+ }
+ builder.send();
+ }
+
+ pub fn count_with_tags(&self, name: &str, value: i64, tags: &[(&str, &str)]) {
+ let mut builder = self.inner.count_with_tags(name, value);
+ for &(k, v) in tags {
+ builder = builder.with_tag(k, v);
+ }
+ builder.send();
+ }
+
+ pub fn incr_with_tags(&self, name: &str, tags: &[(&str, &str)]) {
+ let mut builder = self.inner.incr_with_tags(name);
+ for &(k, v) in tags {
+ builder = builder.with_tag(k, v);
+ }
+ builder.send();
+ }
+
+ pub fn histogram_with_tags(&self, name: &str, value: f64, tags: &[(&str, &str)]) {
+ let mut builder = self.inner.histogram_with_tags(name, value);
+ for &(k, v) in tags {
+ builder = builder.with_tag(k, v);
+ }
+ builder.send();
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn noop_does_not_panic() {
+ let m = Metrics::noop();
+
+ // Untagged helpers
+ let _ = m.gauge("test.gauge", 42.0);
+ let _ = m.count("test.count", 1);
+ let _ = m.histogram("test.histogram", 2.78);
+
+ // Tagged helpers
+ let tags = [("env", "test"), ("layer", "l2")];
+ m.gauge_with_tags("test.gauge.tagged", 1.0, &tags);
+ m.count_with_tags("test.count.tagged", 1, &tags);
+ m.incr_with_tags("test.incr.tagged", &tags);
+ m.histogram_with_tags("test.histogram.tagged", 2.0, &tags);
+ }
+}
diff --git a/crates/rpc-collector/src/recorders/batches_info.rs b/crates/rpc-collector/src/recorders/batches_info.rs
new file mode 100644
index 0000000..4871847
--- /dev/null
+++ b/crates/rpc-collector/src/recorders/batches_info.rs
@@ -0,0 +1,122 @@
+//! Batch inbox recorder — counts data posted to the batch inbox address.
+
+use alloy_consensus::Transaction as _;
+use alloy_eips::Typed2718;
+use alloy_network::TransactionResponse as _;
+use alloy_primitives::Address;
+use alloy_rpc_types_eth::Block;
+use async_trait::async_trait;
+
+use crate::{
+ metrics::{self, Metrics},
+ recorders::MetricRecorder,
+};
+
+/// Size of a single blob: 4096 field elements × 32 bytes.
+const BLOB_SIZE: usize = 4096 * 32;
+
+/// Reports the volume of data sent to the batch inbox, split by whether the
+/// sender is the canonical batcher or not.
+#[derive(Debug)]
+pub struct BatchesInfoRecorder {
+ batch_inbox: Address,
+ batch_sender: Address,
+}
+
+impl BatchesInfoRecorder {
+ pub const fn new(batch_inbox: Address, batch_sender: Address) -> Self {
+ Self { batch_inbox, batch_sender }
+ }
+}
+
+#[async_trait]
+impl MetricRecorder for BatchesInfoRecorder {
+ fn name(&self) -> &'static str {
+ "batches_info"
+ }
+
+ async fn record(&self, block: &Block, m: &Metrics) -> anyhow::Result<()> {
+ for tx in block.transactions.txns() {
+ let to = match tx.to() {
+ Some(addr) => addr,
+ None => continue,
+ };
+
+ if to != self.batch_inbox {
+ continue;
+ }
+
+ let data_len = if tx.ty() == alloy_consensus::TxType::Eip4844 as u8 {
+ tx.blob_versioned_hashes()
+ .map_or(0, |h: &[alloy_primitives::B256]| h.len() * BLOB_SIZE)
+ } else {
+ tx.input().len()
+ };
+
+ let from = tx.from();
+ if from == self.batch_sender {
+ let _ = m.count(metrics::BATCH_SENT_BY_BATCH_SENDER, data_len as i64);
+ } else {
+ let _ = m.count(metrics::BATCH_SENT_BY_NON_BATCH_SENDER, data_len as i64);
+ }
+ }
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use alloy_primitives::Bytes;
+
+ use super::*;
+ use crate::test_helpers::{make_block_with_txs, make_legacy_tx};
+
+ const BATCH_INBOX: Address = Address::new([0xBB; 20]);
+ const BATCH_SENDER: Address = Address::new([0xCC; 20]);
+ const RANDOM_SENDER: Address = Address::new([0xDD; 20]);
+ const RANDOM_ADDR: Address = Address::new([0xEE; 20]);
+
+ fn recorder() -> BatchesInfoRecorder {
+ BatchesInfoRecorder::new(BATCH_INBOX, BATCH_SENDER)
+ }
+
+ #[tokio::test]
+ async fn counts_batcher_calldata() {
+ let r = recorder();
+ let m = Metrics::noop();
+ let input = Bytes::from(vec![0u8; 100]);
+ let tx = make_legacy_tx(BATCH_SENDER, BATCH_INBOX, input);
+ let block = make_block_with_txs(1, vec![tx]);
+ // Should not error; metric is emitted via BATCH_SENT_BY_BATCH_SENDER.
+ assert!(r.record(&block, &m).await.is_ok());
+ }
+
+ #[tokio::test]
+ async fn counts_non_batcher_calldata() {
+ let r = recorder();
+ let m = Metrics::noop();
+ let input = Bytes::from(vec![0u8; 50]);
+ let tx = make_legacy_tx(RANDOM_SENDER, BATCH_INBOX, input);
+ let block = make_block_with_txs(1, vec![tx]);
+ assert!(r.record(&block, &m).await.is_ok());
+ }
+
+ #[tokio::test]
+ async fn ignores_tx_to_other_addresses() {
+ let r = recorder();
+ let m = Metrics::noop();
+ let tx = make_legacy_tx(BATCH_SENDER, RANDOM_ADDR, Bytes::from(vec![0u8; 10]));
+ let block = make_block_with_txs(1, vec![tx]);
+ // Should succeed but emit no batch metrics.
+ assert!(r.record(&block, &m).await.is_ok());
+ }
+
+ #[tokio::test]
+ async fn empty_block_no_metrics() {
+ let r = recorder();
+ let m = Metrics::noop();
+ let block = make_block_with_txs(1, vec![]);
+ assert!(r.record(&block, &m).await.is_ok());
+ }
+}
diff --git a/crates/rpc-collector/src/recorders/dispute_game_factory.rs b/crates/rpc-collector/src/recorders/dispute_game_factory.rs
new file mode 100644
index 0000000..8a28c72
--- /dev/null
+++ b/crates/rpc-collector/src/recorders/dispute_game_factory.rs
@@ -0,0 +1,63 @@
+//! Dispute game factory recorder — reports the latest L2 block number from the
+//! most recent dispute game.
+
+use std::sync::Arc;
+
+use alloy_primitives::Address;
+use alloy_provider::Provider;
+use alloy_rpc_types_eth::Block;
+use async_trait::async_trait;
+
+use crate::{
+ bindings::{DisputeGameFactory, FaultDisputeGame},
+ metrics::{self, Metrics},
+ recorders::MetricRecorder,
+};
+
+/// Reports the latest L2 block from the newest dispute game.
+#[derive(Debug)]
+pub struct DisputeGameFactoryRecorder {
+ provider: Arc
,
+ address: Address,
+}
+
+impl
DisputeGameFactoryRecorder
{
+ pub const fn new(provider: Arc
, address: Address) -> Self {
+ Self { provider, address }
+ }
+}
+
+#[async_trait]
+impl MetricRecorder for DisputeGameFactoryRecorder {
+ fn name(&self) -> &'static str {
+ "dispute_game_factory"
+ }
+
+ async fn record(&self, block: &Block, m: &Metrics) -> anyhow::Result<()> {
+ let block_id = alloy_eips::BlockId::Number(block.header.number.into());
+ let factory = DisputeGameFactory::new(self.address, &*self.provider);
+
+ // 1. Get total game count.
+ let count_ret = factory.gameCount().block(block_id).call().await?;
+ let game_count: u64 = count_ret.try_into().unwrap_or(0);
+ if game_count == 0 {
+ return Ok(());
+ }
+
+ // 2. Get the latest game.
+ let game_ret = factory
+ .gameAtIndex(alloy_primitives::U256::from(game_count - 1))
+ .block(block_id)
+ .call()
+ .await?;
+
+ // 3. Query the fault dispute game proxy for l2BlockNumber.
+ let fault_game = FaultDisputeGame::new(game_ret.proxy, &*self.provider);
+ let bn_ret = fault_game.l2BlockNumber().block(block_id).call().await?;
+ let l2_block: u128 = bn_ret.try_into().unwrap_or(u128::MAX);
+
+ let _ = m.gauge(metrics::OUTPUT_ORACLE_LATEST_BLOCK, l2_block as f64);
+
+ Ok(())
+ }
+}
diff --git a/crates/rpc-collector/src/recorders/eth_balance.rs b/crates/rpc-collector/src/recorders/eth_balance.rs
new file mode 100644
index 0000000..f645ced
--- /dev/null
+++ b/crates/rpc-collector/src/recorders/eth_balance.rs
@@ -0,0 +1,61 @@
+//! ETH and contract balance recorder.
+
+use std::sync::Arc;
+
+use alloy_primitives::{Address, Bytes, U256, utils::Unit};
+use alloy_provider::Provider;
+use alloy_rpc_types_eth::{Block, TransactionRequest};
+use async_trait::async_trait;
+
+use crate::{metrics::Metrics, recorders::MetricRecorder, utils::wei_to_unit};
+
+/// Reports the ETH or contract balance of an address as a gauge.
+#[derive(Debug)]
+pub struct EthBalanceRecorder
{
+ provider: Arc
,
+ address: Address,
+ metric_name: String,
+ calldata: Option>,
+}
+
+impl EthBalanceRecorder
{
+ /// Create a recorder for a plain ETH balance.
+ pub const fn eth(provider: Arc
, address: Address, metric_name: String) -> Self {
+ Self { provider, address, metric_name, calldata: None }
+ }
+
+ /// Create a recorder for a contract balance (calls the contract with `calldata`).
+ pub const fn contract(
+ provider: Arc
,
+ address: Address,
+ metric_name: String,
+ calldata: Vec,
+ ) -> Self {
+ Self { provider, address, metric_name, calldata: Some(calldata) }
+ }
+}
+
+#[async_trait]
+impl MetricRecorder for EthBalanceRecorder {
+ fn name(&self) -> &'static str {
+ "eth_balance"
+ }
+
+ async fn record(&self, block: &Block, m: &Metrics) -> anyhow::Result<()> {
+ let block_id = alloy_eips::BlockId::Number(block.header.number.into());
+
+ let balance = match &self.calldata {
+ None => self.provider.get_balance(self.address).block_id(block_id).await?,
+ Some(cd) => {
+ let tx = TransactionRequest::default()
+ .to(self.address)
+ .input(Bytes::copy_from_slice(cd).into());
+ let result = self.provider.call(tx).block(block_id).await?;
+ U256::from_be_slice(&result)
+ }
+ };
+
+ let _ = m.gauge(&self.metric_name, wei_to_unit(balance, Unit::ETHER));
+ Ok(())
+ }
+}
diff --git a/crates/rpc-collector/src/recorders/event_counter.rs b/crates/rpc-collector/src/recorders/event_counter.rs
new file mode 100644
index 0000000..63729a6
--- /dev/null
+++ b/crates/rpc-collector/src/recorders/event_counter.rs
@@ -0,0 +1,63 @@
+//! Generic event counter recorder — counts contract events in a block range.
+
+use std::{fmt, sync::Arc};
+
+use alloy_primitives::{Address, FixedBytes};
+use alloy_provider::Provider;
+use alloy_rpc_types_eth::{Block, Filter};
+use async_trait::async_trait;
+
+use crate::{metrics::Metrics, recorders::MetricRecorder};
+
+/// Reports the count of a specific event emitted by a contract.
+pub struct EventCounterRecorder
{
+ provider: Arc
,
+ address: Address,
+ event_topic: FixedBytes<32>,
+ metric_name: String,
+}
+
+impl
fmt::Debug for EventCounterRecorder
{
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("EventCounterRecorder")
+ .field("address", &self.address)
+ .field("event_topic", &self.event_topic)
+ .field("metric_name", &self.metric_name)
+ .finish()
+ }
+}
+
+impl
EventCounterRecorder
{
+ pub const fn new(
+ provider: Arc
,
+ address: Address,
+ event_topic: FixedBytes<32>,
+ metric_name: String,
+ ) -> Self {
+ Self { provider, address, event_topic, metric_name }
+ }
+}
+
+#[async_trait]
+impl MetricRecorder for EventCounterRecorder {
+ fn name(&self) -> &'static str {
+ "event_counter"
+ }
+
+ async fn record(&self, block: &Block, m: &Metrics) -> anyhow::Result<()> {
+ let block_num = block.header.number;
+
+ let filter = Filter::new()
+ .address(self.address)
+ .event_signature(self.event_topic)
+ .from_block(block_num)
+ .to_block(block_num);
+
+ let count = self.provider.get_logs(&filter).await?.len();
+ if count > 0 {
+ let _ = m.count(&self.metric_name, count as i64);
+ }
+
+ Ok(())
+ }
+}
diff --git a/crates/rpc-collector/src/recorders/gas_price_oracle.rs b/crates/rpc-collector/src/recorders/gas_price_oracle.rs
new file mode 100644
index 0000000..376b1aa
--- /dev/null
+++ b/crates/rpc-collector/src/recorders/gas_price_oracle.rs
@@ -0,0 +1,62 @@
+//! Gas price oracle recorder — fetches L1 base fee and blob base fee from the
+//! `GasPriceOracle` L2 predeploy.
+
+use std::sync::Arc;
+
+use alloy_provider::Provider;
+use alloy_rpc_types_eth::Block;
+use async_trait::async_trait;
+
+use crate::{
+ bindings::{self, GasPriceOracle},
+ metrics::{self, Metrics},
+ recorders::MetricRecorder,
+};
+
+/// Reports L1 base fee and blob base fee from the `GasPriceOracle` contract.
+#[derive(Debug)]
+pub struct GasPriceOracleRecorder
{
+ provider: Arc
,
+}
+
+impl
GasPriceOracleRecorder
{
+ pub const fn new(provider: Arc
) -> Self {
+ Self { provider }
+ }
+}
+
+#[async_trait]
+impl MetricRecorder for GasPriceOracleRecorder {
+ fn name(&self) -> &'static str {
+ "gas_price_oracle"
+ }
+
+ async fn record(&self, block: &Block, m: &Metrics) -> anyhow::Result<()> {
+ let block_id = alloy_eips::BlockId::Number(block.header.number.into());
+ let oracle = GasPriceOracle::new(bindings::GAS_PRICE_ORACLE, &*self.provider);
+
+ // L1 base fee
+ match oracle.l1BaseFee().block(block_id).call().await {
+ Ok(ret) => {
+ let fee: u128 = ret.try_into().unwrap_or(u128::MAX);
+ let _ = m.gauge(metrics::L1_BASE_FEE, fee as f64);
+ }
+ Err(e) => {
+ tracing::warn!(error = %e, "failed to call GasPriceOracle.l1BaseFee");
+ }
+ }
+
+ // Blob base fee
+ match oracle.blobBaseFee().block(block_id).call().await {
+ Ok(ret) => {
+ let fee: u128 = ret.try_into().unwrap_or(u128::MAX);
+ let _ = m.gauge(metrics::L1_BLOB_BASE_FEE, fee as f64);
+ }
+ Err(e) => {
+ tracing::warn!(error = %e, "failed to call GasPriceOracle.blobBaseFee");
+ }
+ }
+
+ Ok(())
+ }
+}
diff --git a/crates/rpc-collector/src/recorders/l2_block_info.rs b/crates/rpc-collector/src/recorders/l2_block_info.rs
new file mode 100644
index 0000000..0f1f0ce
--- /dev/null
+++ b/crates/rpc-collector/src/recorders/l2_block_info.rs
@@ -0,0 +1,192 @@
+//! L2 block info recorder — the richest per-block metric recorder.
+//!
+//! Emits gas usage/limits/target, base fee, transaction type breakdown,
+//! priority fees, DA footprint, empty block counts and transaction statuses.
+
+use std::sync::Arc;
+
+use alloy_consensus::Transaction;
+use alloy_eips::Typed2718;
+use alloy_network::TransactionResponse;
+use alloy_primitives::{Address, U256, utils::Unit};
+use alloy_provider::Provider;
+use alloy_rpc_types_eth::Block;
+use async_trait::async_trait;
+use tokio::task::JoinSet;
+use tracing::{error, warn};
+
+use crate::{
+ bindings,
+ metrics::{self, Metrics},
+ recorders::MetricRecorder,
+ utils::{self, wei_to_unit},
+};
+
+/// Reports L2 block-level metrics: gas, fees, transactions, DA footprint.
+
+#[derive(Debug)]
+pub struct L2BlockInfoRecorder
{
+ provider: Arc
,
+ system_config_address: Address,
+}
+
+impl
L2BlockInfoRecorder
{
+ pub const fn new(provider: Arc
, system_config_address: Address) -> Self {
+ Self { provider, system_config_address }
+ }
+}
+
+#[async_trait]
+impl MetricRecorder for L2BlockInfoRecorder {
+ fn name(&self) -> &'static str {
+ "l2_block_info"
+ }
+
+ async fn record(&self, block: &Block, m: &Metrics) -> anyhow::Result<()> {
+ let block_num = block.header.number;
+ let gas_limit = block.header.gas_limit;
+ let gas_used = block.header.gas_used;
+ let base_fee = block.header.base_fee_per_gas.unwrap_or(0);
+
+ let _ = m.gauge(metrics::BLOCK_GAS_LIMIT, gas_limit as f64);
+ let _ = m.histogram(metrics::BLOCK_GAS_USED, gas_used as f64);
+ let _ = m.histogram(metrics::BLOCK_BASE_GAS_FEE, base_fee as f64);
+ let tx_count = block.transactions.len();
+ let _ = m.count(metrics::TRANSACTION_COUNT, tx_count as i64);
+
+ // Extract minBaseFee from ExtraData (Jovian format: bytes 9..17).
+ let extra = &block.header.extra_data;
+ if extra.len() >= 17 {
+ let min_base_fee = u64::from_be_bytes(extra[9..17].try_into().unwrap_or_default());
+ let _ = m.gauge(metrics::MIN_BASE_FEE, min_base_fee as f64);
+ }
+
+ // DA footprint from blob_gas_used field (Jovian format).
+ if let Some(blob_gas_used) = block.header.blob_gas_used {
+ let _ = m.gauge(metrics::DA_FOOTPRINT, blob_gas_used as f64);
+ }
+
+ // Fetch DA footprint gas scalar from L1Block predeploy.
+ let l1_block = bindings::L1Block::new(bindings::L1_BLOCK, &*self.provider);
+ match l1_block.daFootprintGasScalar().call().await {
+ Ok(ret) => {
+ let _ = m.gauge(metrics::DA_FOOTPRINT_GAS_SCALAR, ret as f64);
+ }
+ Err(e) => {
+ warn!(error = %e, "failed to get DA footprint gas scalar");
+ }
+ }
+
+ // Empty block detection.
+ if tx_count <= 1 {
+ warn!(block = block_num, tx_count, "empty block detected");
+ let _ = m.count(metrics::EMPTY_BLOCKS, 1);
+ }
+
+ // EIP-1559 elasticity + gas target (from L1 SystemConfig).
+ let sys_config = bindings::SystemConfig::new(self.system_config_address, &*self.provider);
+ match sys_config.eip1559Elasticity().call().await {
+ Ok(elasticity) => {
+ if elasticity > 0 {
+ let gas_target = gas_limit as f64 / f64::from(elasticity);
+ let _ = m.gauge(metrics::BLOCK_GAS_TARGET, gas_target);
+ let _ = m.gauge(metrics::ELASTICITY, f64::from(elasticity));
+ }
+ }
+ Err(e) => {
+ warn!(error = %e, "failed to get EIP-1559 elasticity");
+ }
+ }
+
+ // Per-transaction metrics.
+ for tx in block.transactions.txns() {
+ let tx_type = tx.ty();
+ let type_str = tx_type.to_string();
+ let tags: [(&str, &str); 1] = [("txnType", type_str.as_str())];
+
+ m.count_with_tags(metrics::TRANSACTION_TYPE, i64::from(tx_type), &tags);
+
+ let gas_price = Transaction::gas_price(tx).unwrap_or(0);
+ let tip_cap = tx.max_priority_fee_per_gas().unwrap_or(0);
+ let effective_tip =
+ tip_cap.min(Transaction::max_fee_per_gas(tx).saturating_sub(base_fee as u128));
+ let effective_price = base_fee as u128 + effective_tip;
+
+ m.histogram_with_tags(
+ metrics::TRANSACTION_GAS_PRICE_MAX,
+ wei_to_unit(U256::from(gas_price), Unit::GWEI),
+ &tags,
+ );
+ m.histogram_with_tags(
+ metrics::TRANSACTION_MAX_PRIORITY_FEE,
+ wei_to_unit(U256::from(tip_cap), Unit::GWEI),
+ &tags,
+ );
+ m.histogram_with_tags(
+ metrics::TRANSACTION_GAS_PRICE,
+ wei_to_unit(U256::from(effective_price), Unit::GWEI),
+ &tags,
+ );
+ }
+
+ // Receipt-based metrics (tx success/fail, L1 DA fees, priority fees).
+ self.record_transaction_status(block, m).await;
+
+ Ok(())
+ }
+}
+
+impl L2BlockInfoRecorder {
+ async fn record_transaction_status(&self, block: &Block, m: &Metrics) {
+ let base_fee = block.header.base_fee_per_gas.unwrap_or(0);
+ let mut set = JoinSet::new();
+
+ for tx in block.transactions.txns() {
+ let tx_hash = tx.tx_hash();
+ let provider = Arc::clone(&self.provider);
+ let metrics = m.clone();
+ let tip_cap = tx.max_priority_fee_per_gas().unwrap_or(0);
+ let max_fee = Transaction::max_fee_per_gas(tx);
+ let tx_type = tx.ty();
+
+ set.spawn(async move {
+ let receipt = match provider.get_transaction_receipt(tx_hash).await {
+ Ok(Some(r)) => r,
+ Ok(None) => return,
+ Err(e) => {
+ error!(error = %e, "error fetching receipt");
+ return;
+ }
+ };
+
+ // Skip system transactions (type 126).
+ if tx_type != utils::DEPOSIT_TX_TYPE {
+ // Compute L2 fee.
+ let effective_gas_price = receipt.effective_gas_price;
+ let gas_used = receipt.gas_used as u128;
+ let l2_fee = effective_gas_price * gas_used;
+ let _ = metrics.histogram(
+ metrics::TRANSACTION_L2_FEES,
+ wei_to_unit(U256::from(l2_fee), Unit::GWEI),
+ );
+
+ // Priority fees.
+ let effective_tip = tip_cap.min(max_fee.saturating_sub(base_fee as u128));
+ let tx_priority_fees = effective_tip * gas_used;
+ let _ = metrics.gauge(
+ metrics::TRANSACTION_TOTAL_PRI_FEES,
+ wei_to_unit(U256::from(tx_priority_fees), Unit::GWEI),
+ );
+ }
+
+ if receipt.status() {
+ let _ = metrics.count(metrics::TRANSACTIONS_SUCCESS, 1);
+ } else {
+ let _ = metrics.count(metrics::TRANSACTIONS_FAILED, 1);
+ }
+ });
+ }
+
+ while set.join_next().await.is_some() {}
+ }
+}
diff --git a/crates/rpc-collector/src/recorders/mod.rs b/crates/rpc-collector/src/recorders/mod.rs
new file mode 100644
index 0000000..fed7669
--- /dev/null
+++ b/crates/rpc-collector/src/recorders/mod.rs
@@ -0,0 +1,30 @@
+//! Metric recorder trait and implementations.
+//!
+//! Each recorder receives a block and emits `StatsD` metrics for a specific
+//! aspect of the chain (balances, events, gas prices, etc.).
+
+use alloy_rpc_types_eth::Block;
+use async_trait::async_trait;
+
+use crate::metrics::Metrics;
+
+/// A metric recorder receives a block and emits `StatsD` metrics.
+///
+/// Implementations should be cheap to clone (wrap heavy state in [`Arc`]).
+#[async_trait]
+pub trait MetricRecorder: Send + Sync + 'static {
+ /// Human-readable name, used in error logs.
+ fn name(&self) -> &'static str;
+
+ /// Record metrics for the given block.
+ async fn record(&self, block: &Block, metrics: &Metrics) -> anyhow::Result<()>;
+}
+
+pub mod batches_info;
+pub mod dispute_game_factory;
+pub mod eth_balance;
+pub mod event_counter;
+pub mod gas_price_oracle;
+pub mod l2_block_info;
+pub mod output_oracle;
+pub mod reorg_detector;
diff --git a/crates/rpc-collector/src/recorders/output_oracle.rs b/crates/rpc-collector/src/recorders/output_oracle.rs
new file mode 100644
index 0000000..4f03cdd
--- /dev/null
+++ b/crates/rpc-collector/src/recorders/output_oracle.rs
@@ -0,0 +1,45 @@
+//! L2 Output Oracle recorder — reports the latest L2 block number posted on L1.
+
+use std::sync::Arc;
+
+use alloy_primitives::Address;
+use alloy_provider::Provider;
+use alloy_rpc_types_eth::Block;
+use async_trait::async_trait;
+
+use crate::{
+ bindings::L2OutputOracle,
+ metrics::{self, Metrics},
+ recorders::MetricRecorder,
+};
+
+/// Reports the latest block number from the `L2OutputOracle` contract.
+#[derive(Debug)]
+pub struct OutputOracleRecorder
{
+ provider: Arc
,
+ address: Address,
+}
+
+impl
OutputOracleRecorder
{
+ pub const fn new(provider: Arc
, address: Address) -> Self {
+ Self { provider, address }
+ }
+}
+
+#[async_trait]
+impl MetricRecorder for OutputOracleRecorder {
+ fn name(&self) -> &'static str {
+ "output_oracle"
+ }
+
+ async fn record(&self, block: &Block, m: &Metrics) -> anyhow::Result<()> {
+ let block_id = alloy_eips::BlockId::Number(block.header.number.into());
+ let oracle = L2OutputOracle::new(self.address, &*self.provider);
+
+ let ret = oracle.latestBlockNumber().block(block_id).call().await?;
+ let block_number: u128 = ret.try_into().unwrap_or(u128::MAX);
+ let _ = m.gauge(metrics::OUTPUT_ORACLE_LATEST_BLOCK, block_number as f64);
+
+ Ok(())
+ }
+}
diff --git a/crates/rpc-collector/src/recorders/reorg_detector.rs b/crates/rpc-collector/src/recorders/reorg_detector.rs
new file mode 100644
index 0000000..bc0eaf5
--- /dev/null
+++ b/crates/rpc-collector/src/recorders/reorg_detector.rs
@@ -0,0 +1,163 @@
+//! Reorg detector — tracks chain reorganisations by comparing parent hashes.
+
+use std::collections::VecDeque;
+
+use alloy_primitives::B256;
+use alloy_rpc_types_eth::Block;
+use async_trait::async_trait;
+use tokio::sync::Mutex;
+use tracing::warn;
+
+use crate::{
+ metrics::{self, Metrics},
+ recorders::MetricRecorder,
+};
+
+const MAX_BLOCK_HASH_LEN: usize = 10_000;
+
+/// Detects reorgs by keeping a sliding window of recent block hashes.
+#[derive(Debug)]
+pub struct ReorgDetectorRecorder {
+ state: Mutex,
+}
+
+#[derive(Debug)]
+struct ReorgState {
+ parent_hash: Option,
+ block_hashes: VecDeque,
+}
+
+impl Default for ReorgDetectorRecorder {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl ReorgDetectorRecorder {
+ pub fn new() -> Self {
+ Self { state: Mutex::new(ReorgState { parent_hash: None, block_hashes: VecDeque::new() }) }
+ }
+}
+
+#[async_trait]
+impl MetricRecorder for ReorgDetectorRecorder {
+ fn name(&self) -> &'static str {
+ "reorg_detector"
+ }
+
+ async fn record(&self, block: &Block, m: &Metrics) -> anyhow::Result<()> {
+ let block_hash = block.header.hash;
+ let parent_hash = block.header.parent_hash;
+ let block_num = block.header.number;
+
+ let mut state = self.state.lock().await;
+
+ // Check for reorg.
+ if let Some(prev_hash) = state.parent_hash
+ && parent_hash != prev_hash
+ {
+ // Reorg detected. Estimate depth.
+ let depth = state.block_hashes.len() as u64;
+ warn!(
+ block = block_num,
+ depth,
+ new_parent = %parent_hash,
+ old_parent = %prev_hash,
+ "reorg detected"
+ );
+ let _ = m.histogram(metrics::REORG_DEPTH, depth as f64);
+ }
+
+ state.parent_hash = Some(block_hash);
+ state.block_hashes.push_back(block_hash);
+ if state.block_hashes.len() > MAX_BLOCK_HASH_LEN {
+ state.block_hashes.pop_front();
+ }
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::test_helpers::make_block;
+
+ fn hash(n: u8) -> B256 {
+ let mut bytes = [0u8; 32];
+ bytes[31] = n;
+ B256::from(bytes)
+ }
+
+ #[tokio::test]
+ async fn first_block_never_reorgs() {
+ let detector = ReorgDetectorRecorder::new();
+ let m = Metrics::noop();
+
+ let block = make_block(1, hash(1), hash(0));
+ // First block should never produce an error or reorg.
+ assert!(detector.record(&block, &m).await.is_ok());
+ }
+
+ #[tokio::test]
+ async fn no_reorg_sequential_blocks() {
+ let detector = ReorgDetectorRecorder::new();
+ let m = Metrics::noop();
+
+ // Block 1: hash=1, parent=0
+ let b1 = make_block(1, hash(1), hash(0));
+ detector.record(&b1, &m).await.unwrap();
+
+ // Block 2: hash=2, parent=1 (correct chain)
+ let b2 = make_block(2, hash(2), hash(1));
+ detector.record(&b2, &m).await.unwrap();
+
+ // Block 3: hash=3, parent=2 (correct chain)
+ let b3 = make_block(3, hash(3), hash(2));
+ detector.record(&b3, &m).await.unwrap();
+
+ // Verify the state is consistent.
+ let state = detector.state.lock().await;
+ assert_eq!(state.parent_hash, Some(hash(3)));
+ assert_eq!(state.block_hashes.len(), 3);
+ }
+
+ #[tokio::test]
+ async fn reorg_detected_on_parent_mismatch() {
+ let detector = ReorgDetectorRecorder::new();
+ let m = Metrics::noop();
+
+ // Block 1
+ let b1 = make_block(1, hash(1), hash(0));
+ detector.record(&b1, &m).await.unwrap();
+
+ // Block 2 with WRONG parent (hash(99) instead of hash(1)) → reorg
+ let b2 = make_block(2, hash(2), hash(99));
+ // Should not error, but internally detects reorg.
+ assert!(detector.record(&b2, &m).await.is_ok());
+
+ // After the reorg, state should still track the latest block.
+ let state = detector.state.lock().await;
+ assert_eq!(state.parent_hash, Some(hash(2)));
+ }
+
+ #[tokio::test]
+ async fn sliding_window_capped() {
+ let detector = ReorgDetectorRecorder::new();
+ let m = Metrics::noop();
+
+ // Insert MAX_BLOCK_HASH_LEN + 100 blocks.
+ let total = MAX_BLOCK_HASH_LEN + 100;
+ for i in 0..total {
+ let num = i as u64;
+ let parent =
+ if i == 0 { hash(0) } else { B256::from(alloy_primitives::U256::from(i - 1)) };
+ let h = B256::from(alloy_primitives::U256::from(i));
+ let block = make_block(num, h, parent);
+ detector.record(&block, &m).await.unwrap();
+ }
+
+ let state = detector.state.lock().await;
+ assert_eq!(state.block_hashes.len(), MAX_BLOCK_HASH_LEN);
+ }
+}
diff --git a/crates/rpc-collector/src/services/collector/l1.rs b/crates/rpc-collector/src/services/collector/l1.rs
new file mode 100644
index 0000000..dee8c66
--- /dev/null
+++ b/crates/rpc-collector/src/services/collector/l1.rs
@@ -0,0 +1,260 @@
+//! L1 collector service — assembles L1-specific metric recorders
+//! and wraps the shared [`Collector`](super::Collector) logic.
+
+use std::{sync::Arc, time::Duration};
+
+use alloy_primitives::Address;
+use alloy_provider::Provider;
+use alloy_sol_types::SolEvent;
+use tokio::task::JoinSet;
+use tokio_util::sync::CancellationToken;
+use tracing::info;
+
+use crate::{
+ bindings,
+ metrics::{self, Metrics},
+ recorders::{
+ MetricRecorder, batches_info::BatchesInfoRecorder,
+ dispute_game_factory::DisputeGameFactoryRecorder, eth_balance::EthBalanceRecorder,
+ event_counter::EventCounterRecorder, output_oracle::OutputOracleRecorder,
+ reorg_detector::ReorgDetectorRecorder,
+ },
+ services::{Service, collector::Collector},
+ utils::parse_contract_balance_calls,
+};
+
+/// Configuration needed to build the L1 collector service.
+#[derive(Debug, Clone)]
+pub struct L1CollectorConfig {
+ pub poll_interval: Duration,
+ pub optimism_portal_address: Address,
+ pub output_oracle_address: Option,
+ pub dispute_game_factory_address: Option,
+ pub l1_standard_bridge_address: Address,
+ pub ethereum_proposer_address: Address,
+ pub ethereum_batcher_address: Address,
+ pub batch_inbox_address: Address,
+ pub balance_tracker_address: Option,
+ pub contract_balance_calls: Vec,
+}
+
+// ── L1Collector ─────────────────────────────────────────────
+
+/// The L1 collector service.
+///
+/// Wraps the shared [`Collector`] polling loop with the L1-specific
+/// set of metric recorders.
+pub struct L1Collector {
+ inner: Collector>,
+}
+
+impl std::fmt::Debug for L1Collector
{
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("L1Collector").field("inner", &self.inner).finish()
+ }
+}
+
+impl L1Collector {
+ /// Build a fully-wired L1 collector.
+ pub fn new(cfg: L1CollectorConfig, provider: Arc
, metrics: Metrics) -> anyhow::Result {
+ let recorders = Self::build_recorders(&cfg, &provider)?;
+ Ok(Self {
+ inner: Collector::new(
+ provider,
+ cfg.poll_interval,
+ recorders,
+ metrics,
+ "l1",
+ metrics::L1_LATEST_BLOCK,
+ ),
+ })
+ }
+
+ /// Assemble the L1-specific set of metric recorders.
+ fn build_recorders(
+ cfg: &L1CollectorConfig,
+ l1: &Arc,
+ ) -> anyhow::Result>> {
+ let mut rec: Vec> = vec![Arc::new(ReorgDetectorRecorder::new())];
+
+ // Bridge + portal event counters.
+ let bridge = cfg.l1_standard_bridge_address;
+ let portal = cfg.optimism_portal_address;
+ for (addr, topic, metric) in [
+ (
+ bridge,
+ bindings::L1StandardBridge::ETHBridgeInitiated::SIGNATURE_HASH,
+ metrics::DEPOSIT_INITIATED_COUNT,
+ ),
+ (
+ bridge,
+ bindings::L1StandardBridge::ERC20BridgeInitiated::SIGNATURE_HASH,
+ metrics::DEPOSIT_INITIATED_COUNT,
+ ),
+ (
+ bridge,
+ bindings::L1StandardBridge::ETHWithdrawalFinalized::SIGNATURE_HASH,
+ metrics::WITHDRAWAL_FINALIZED_COUNT,
+ ),
+ (
+ bridge,
+ bindings::L1StandardBridge::ERC20WithdrawalFinalized::SIGNATURE_HASH,
+ metrics::WITHDRAWAL_FINALIZED_COUNT,
+ ),
+ (
+ portal,
+ bindings::OptimismPortal::TransactionDeposited::SIGNATURE_HASH,
+ metrics::DEPOSIT_INITIATED_COUNT,
+ ),
+ (
+ portal,
+ bindings::OptimismPortal::WithdrawalFinalized::SIGNATURE_HASH,
+ metrics::WITHDRAWAL_FINALIZED_COUNT,
+ ),
+ ] {
+ rec.push(Arc::new(EventCounterRecorder::new(
+ Arc::clone(l1),
+ addr,
+ topic,
+ metric.to_string(),
+ )));
+ }
+
+ // Proposer + batcher balances.
+ rec.push(Arc::new(EthBalanceRecorder::eth(
+ Arc::clone(l1),
+ cfg.ethereum_proposer_address,
+ metrics::PROPOSER_BALANCE.to_string(),
+ )));
+ rec.push(Arc::new(EthBalanceRecorder::eth(
+ Arc::clone(l1),
+ cfg.ethereum_batcher_address,
+ metrics::BATCH_BALANCE.to_string(),
+ )));
+
+ // Batches info.
+ rec.push(Arc::new(BatchesInfoRecorder::new(
+ cfg.batch_inbox_address,
+ cfg.ethereum_batcher_address,
+ )));
+
+ // Contract balance calls.
+ let calls = parse_contract_balance_calls(&cfg.contract_balance_calls)?;
+ for c in calls {
+ rec.push(Arc::new(EthBalanceRecorder::contract(
+ Arc::clone(l1),
+ c.address,
+ c.metric,
+ c.calldata,
+ )));
+ }
+
+ // Output Oracle or Dispute Game Factory.
+ if let Some(dgf) = cfg.dispute_game_factory_address {
+ rec.push(Arc::new(DisputeGameFactoryRecorder::new(Arc::clone(l1), dgf)));
+ } else if let Some(oo) = cfg.output_oracle_address {
+ rec.push(Arc::new(OutputOracleRecorder::new(Arc::clone(l1), oo)));
+ } else {
+ anyhow::bail!(
+ "either --dispute-game-factory-address or --output-oracle-address must be set"
+ );
+ }
+
+ // Balance tracker (optional).
+ if let Some(bt) = cfg.balance_tracker_address {
+ info!("monitoring BalanceTracker events");
+ rec.push(Arc::new(EventCounterRecorder::new(
+ Arc::clone(l1),
+ bt,
+ bindings::BalanceTracker::ReceivedFunds::SIGNATURE_HASH,
+ metrics::RECEIVED_FUNDS_COUNT.to_string(),
+ )));
+ rec.push(Arc::new(EventCounterRecorder::new(
+ Arc::clone(l1),
+ bt,
+ bindings::BalanceTracker::SentProfit::SIGNATURE_HASH,
+ metrics::SENT_PROFIT_COUNT.to_string(),
+ )));
+ }
+
+ Ok(rec)
+ }
+}
+
+impl Service for L1Collector {
+ fn name(&self) -> &str {
+ self.inner.label()
+ }
+
+ fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken) {
+ self.inner.spawn(set, cancel);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use alloy_primitives::Address;
+ use alloy_provider::ProviderBuilder;
+
+ use super::*;
+ use crate::services::Service;
+
+ /// Dummy provider pointing at a non-existent endpoint (never contacted).
+ fn dummy_provider() -> Arc {
+ Arc::new(
+ ProviderBuilder::new()
+ .disable_recommended_fillers()
+ .connect_http("http://127.0.0.1:1".parse().unwrap()),
+ )
+ }
+
+ /// Minimal L1 config with both oracle addresses set to `None`.
+ fn base_cfg() -> L1CollectorConfig {
+ L1CollectorConfig {
+ poll_interval: Duration::from_millis(50),
+ optimism_portal_address: Address::ZERO,
+ output_oracle_address: None,
+ dispute_game_factory_address: None,
+ l1_standard_bridge_address: Address::ZERO,
+ ethereum_proposer_address: Address::ZERO,
+ ethereum_batcher_address: Address::ZERO,
+ batch_inbox_address: Address::ZERO,
+ balance_tracker_address: None,
+ contract_balance_calls: vec![],
+ }
+ }
+
+ #[test]
+ fn l1_collector_new_fails_without_oracle_or_dgf() {
+ let cfg = base_cfg(); // both addresses are None
+ let result = L1Collector::new(cfg, dummy_provider(), Metrics::noop());
+ assert!(result.is_err(), "expected error when both oracle addresses are None");
+ }
+
+ #[test]
+ fn l1_collector_new_with_dgf() {
+ let mut cfg = base_cfg();
+ cfg.dispute_game_factory_address = Some(Address::ZERO);
+
+ let collector = L1Collector::new(cfg, dummy_provider(), Metrics::noop());
+ assert!(collector.is_ok(), "expected Ok when dispute_game_factory_address is set");
+ }
+
+ #[test]
+ fn l1_collector_new_with_output_oracle() {
+ let mut cfg = base_cfg();
+ cfg.output_oracle_address = Some(Address::ZERO);
+
+ let collector = L1Collector::new(cfg, dummy_provider(), Metrics::noop());
+ assert!(collector.is_ok(), "expected Ok when output_oracle_address is set");
+ }
+
+ #[test]
+ fn l1_collector_name_returns_l1() {
+ let mut cfg = base_cfg();
+ cfg.dispute_game_factory_address = Some(Address::ZERO);
+
+ let collector = L1Collector::new(cfg, dummy_provider(), Metrics::noop()).unwrap();
+ assert_eq!(collector.name(), "l1");
+ }
+}
diff --git a/crates/rpc-collector/src/services/collector/l2.rs b/crates/rpc-collector/src/services/collector/l2.rs
new file mode 100644
index 0000000..750ce9e
--- /dev/null
+++ b/crates/rpc-collector/src/services/collector/l2.rs
@@ -0,0 +1,190 @@
+//! L2 collector service — assembles L2-specific metric recorders
+//! and wraps the shared [`Collector`](super::Collector) logic.
+
+use std::{sync::Arc, time::Duration};
+
+use alloy_primitives::Address;
+use alloy_provider::Provider;
+use alloy_sol_types::SolEvent;
+use tokio::task::JoinSet;
+use tokio_util::sync::CancellationToken;
+use tracing::info;
+
+use crate::{
+ bindings::{
+ self, BASE_FEE_VAULT, L1_FEE_VAULT, L2_STANDARD_BRIDGE, L2_TO_L1_MSG_PASSER,
+ SEQUENCER_FEE_VAULT,
+ },
+ metrics::{self, Metrics},
+ recorders::{
+ MetricRecorder, eth_balance::EthBalanceRecorder, event_counter::EventCounterRecorder,
+ gas_price_oracle::GasPriceOracleRecorder, l2_block_info::L2BlockInfoRecorder,
+ reorg_detector::ReorgDetectorRecorder,
+ },
+ services::{Service, collector::Collector},
+ utils::parse_contract_balance_calls,
+};
+
+/// Configuration needed to build the L2 collector service.
+#[derive(Debug, Clone)]
+pub struct L2CollectorConfig {
+ pub poll_interval: Duration,
+ pub fee_disburser_address: Option,
+ pub l1_system_config_address: Address,
+ pub contract_balance_calls: Vec,
+}
+
+// ── L2Collector ─────────────────────────────────────────────
+
+/// The L2 collector service.
+///
+/// Wraps the shared [`Collector`] polling loop with the L2-specific
+/// set of metric recorders.
+pub struct L2Collector {
+ inner: Collector>,
+}
+
+impl std::fmt::Debug for L2Collector
{
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("L2Collector").field("inner", &self.inner).finish()
+ }
+}
+
+impl L2Collector {
+ /// Build a fully-wired L2 collector.
+ pub fn new(cfg: L2CollectorConfig, provider: Arc
, metrics: Metrics) -> anyhow::Result {
+ let recorders = Self::build_recorders(&cfg, &provider)?;
+ Ok(Self {
+ inner: Collector::new(
+ provider,
+ cfg.poll_interval,
+ recorders,
+ metrics,
+ "l2",
+ metrics::L2_LATEST_BLOCK,
+ ),
+ })
+ }
+
+ /// Assemble the L2-specific set of metric recorders.
+ fn build_recorders(
+ cfg: &L2CollectorConfig,
+ l2: &Arc,
+ ) -> anyhow::Result>> {
+ let mut rec: Vec> = Vec::new();
+
+ // Fee Disburser (optional).
+ if let Some(addr) = cfg.fee_disburser_address {
+ info!("monitoring FeeDisburser events");
+ rec.push(Arc::new(EventCounterRecorder::new(
+ Arc::clone(l2),
+ addr,
+ bindings::FeeDisburser::FeesDisbursed::SIGNATURE_HASH,
+ metrics::FEES_DISBURSED_COUNT.to_string(),
+ )));
+ }
+
+ rec.push(Arc::new(L2BlockInfoRecorder::new(Arc::clone(l2), cfg.l1_system_config_address)));
+ rec.push(Arc::new(ReorgDetectorRecorder::new()));
+
+ // Predeploy balances.
+ for (addr, metric) in [
+ (SEQUENCER_FEE_VAULT, metrics::SEQUENCER_BALANCE),
+ (BASE_FEE_VAULT, metrics::BASE_FEE_BALANCE),
+ (L1_FEE_VAULT, metrics::L1_FEE_BALANCE),
+ ] {
+ rec.push(Arc::new(EthBalanceRecorder::eth(Arc::clone(l2), addr, metric.to_string())));
+ }
+
+ // L2 event counters.
+ for (addr, topic, metric) in [
+ (
+ L2_TO_L1_MSG_PASSER,
+ bindings::L2ToL1MessagePasser::MessagePassed::SIGNATURE_HASH,
+ metrics::WITHDRAWAL_INITIATED_COUNT,
+ ),
+ (
+ L2_STANDARD_BRIDGE,
+ bindings::L2StandardBridge::WithdrawalInitiated::SIGNATURE_HASH,
+ metrics::WITHDRAWAL_INITIATED_COUNT,
+ ),
+ (
+ L2_STANDARD_BRIDGE,
+ bindings::L2StandardBridge::DepositFinalized::SIGNATURE_HASH,
+ metrics::DEPOSIT_FINALIZED_COUNT,
+ ),
+ ] {
+ rec.push(Arc::new(EventCounterRecorder::new(
+ Arc::clone(l2),
+ addr,
+ topic,
+ metric.to_string(),
+ )));
+ }
+
+ // Gas Price Oracle.
+ rec.push(Arc::new(GasPriceOracleRecorder::new(Arc::clone(l2))));
+
+ // Contract balance calls.
+ let calls = parse_contract_balance_calls(&cfg.contract_balance_calls)?;
+ for c in calls {
+ rec.push(Arc::new(EthBalanceRecorder::contract(
+ Arc::clone(l2),
+ c.address,
+ c.metric,
+ c.calldata,
+ )));
+ }
+
+ Ok(rec)
+ }
+}
+
+impl Service for L2Collector {
+ fn name(&self) -> &str {
+ self.inner.label()
+ }
+
+ fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken) {
+ self.inner.spawn(set, cancel);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use alloy_primitives::Address;
+ use alloy_provider::ProviderBuilder;
+
+ use super::*;
+ use crate::services::Service;
+
+ /// Dummy provider pointing at a non-existent endpoint (never contacted).
+ fn dummy_provider() -> Arc {
+ Arc::new(
+ ProviderBuilder::new()
+ .disable_recommended_fillers()
+ .connect_http("http://127.0.0.1:1".parse().unwrap()),
+ )
+ }
+
+ fn minimal_cfg() -> L2CollectorConfig {
+ L2CollectorConfig {
+ poll_interval: Duration::from_millis(50),
+ fee_disburser_address: None,
+ l1_system_config_address: Address::ZERO,
+ contract_balance_calls: vec![],
+ }
+ }
+
+ #[test]
+ fn l2_collector_new_minimal() {
+ let collector = L2Collector::new(minimal_cfg(), dummy_provider(), Metrics::noop());
+ assert!(collector.is_ok(), "expected Ok with minimal L2 config");
+ }
+
+ #[test]
+ fn l2_collector_name_returns_l2() {
+ let collector = L2Collector::new(minimal_cfg(), dummy_provider(), Metrics::noop()).unwrap();
+ assert_eq!(collector.name(), "l2");
+ }
+}
diff --git a/crates/rpc-collector/src/services/collector/mod.rs b/crates/rpc-collector/src/services/collector/mod.rs
new file mode 100644
index 0000000..248a75b
--- /dev/null
+++ b/crates/rpc-collector/src/services/collector/mod.rs
@@ -0,0 +1,349 @@
+//! Core collector: block-polling loop that dispatches blocks to
+//! [`MetricRecorder`](crate::recorders::MetricRecorder) implementations.
+//!
+//! The [`l1`] and [`l2`] submodules provide [`L1Collector`](l1::L1Collector)
+//! and [`L2Collector`](l2::L2Collector), which wrap this shared [`Collector`]
+//! and implement the [`Service`](crate::services::Service) trait.
+
+pub mod l1;
+pub mod l2;
+
+use std::{sync::Arc, time::Duration};
+
+use alloy_provider::Provider;
+use alloy_rpc_types_eth::Block;
+use tokio::task::JoinSet;
+use tokio_util::sync::CancellationToken;
+use tracing::{error, info, warn};
+
+use crate::{metrics::Metrics, recorders::MetricRecorder};
+
+// ── Collector struct ─────────────────────────────────────────
+
+/// Shared block-polling collector logic.
+///
+/// It fetches blocks sequentially starting from the chain tip and dispatches
+/// every block to all registered [`MetricRecorder`]s in parallel.
+///
+struct Collector {
+ provider: P,
+ poll_interval: Duration,
+ recorders: Vec>,
+ metrics: Metrics,
+ label: &'static str,
+ /// Metric name for the "current block" gauge (e.g. `base.l2.collector.block`).
+ block_metric: &'static str,
+}
+
+impl std::fmt::Debug for Collector
{
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("Collector")
+ .field("poll_interval", &self.poll_interval)
+ .field("recorders", &self.recorders.len())
+ .field("label", &self.label)
+ .field("block_metric", &self.block_metric)
+ .finish()
+ }
+}
+
+impl Collector {
+ fn new(
+ provider: P,
+ poll_interval: Duration,
+ recorders: Vec>,
+ metrics: Metrics,
+ label: &'static str,
+ block_metric: &'static str,
+ ) -> Self {
+ Self { provider, poll_interval, recorders, metrics, label, block_metric }
+ }
+
+ /// Human-readable label for this collector (e.g. `"l1"`, `"l2"`).
+ const fn label(&self) -> &'static str {
+ self.label
+ }
+
+ /// Spawn the collector polling loop as a task on the given [`JoinSet`].
+ ///
+ /// This is the shared spawn implementation used by both
+ /// [`L1Collector`](l1::L1Collector) and [`L2Collector`](l2::L2Collector).
+ fn spawn(self, set: &mut JoinSet<()>, cancel: CancellationToken) {
+ let label = self.label;
+ set.spawn(async move {
+ info!(label, "starting metrics collector");
+ if let Err(e) = self.run(cancel).await {
+ error!(label, error = %e, "metrics collector stopped with error");
+ } else {
+ info!(label, "metrics collector stopped gracefully");
+ }
+ });
+ }
+
+ /// Run the polling loop until the cancellation token fires.
+ async fn run(self, cancel: CancellationToken) -> anyhow::Result<()> {
+ let mut current_block: Option = None;
+ let mut interval = tokio::time::interval(self.poll_interval);
+ interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+
+ loop {
+ tokio::select! {
+ _ = cancel.cancelled() => {
+ info!(label = self.label, "collector shutting down");
+ return Ok(());
+ }
+ _ = interval.tick() => {
+ // If we don't know the start block yet, fetch the latest.
+ if current_block.is_none() {
+ match self.provider.get_block_number().await {
+ Ok(n) => current_block = Some(n),
+ Err(e) => {
+ warn!(label = self.label, error = %e, "failed to get latest block number");
+ continue;
+ }
+ }
+ }
+
+ // Drain all available blocks.
+ loop {
+ let block_num = current_block.unwrap();
+
+ let block = match self
+ .provider
+ .get_block_by_number(block_num.into())
+ .full()
+ .await
+ {
+ Ok(Some(b)) => b,
+ Ok(None) => break, // block not yet available
+ Err(e) => {
+ info!(
+ label = self.label,
+ block = block_num,
+ error = %e,
+ "unable to fetch block"
+ );
+ break;
+ }
+ };
+
+ info!(label = self.label, block = block_num, "fetched block");
+
+ // Emit the collector-progress gauge.
+ let _ = self.metrics.gauge(self.block_metric, block_num as f64);
+
+ self.record_all(&block).await;
+
+ current_block = Some(block_num + 1);
+ }
+ }
+ }
+ }
+ }
+
+ /// Dispatch all recorders in parallel for a single block.
+ async fn record_all(&self, block: &Block) {
+ let mut set = JoinSet::new();
+
+ for recorder in &self.recorders {
+ let recorder = Arc::clone(recorder);
+ let block = block.clone();
+ let metrics = self.metrics.clone();
+ let label = self.label;
+
+ set.spawn(async move {
+ if let Err(e) = recorder.record(&block, &metrics).await {
+ error!(
+ label,
+ recorder = recorder.name(),
+ error = %e,
+ "metric recorder failed"
+ );
+ }
+ });
+ }
+
+ // Await all.
+ while set.join_next().await.is_some() {}
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
+
+ use alloy_provider::ProviderBuilder;
+ use async_trait::async_trait;
+
+ use super::*;
+
+ /// A test recorder that counts how many times `record` was called.
+ #[derive(Debug)]
+ struct CountingRecorder {
+ count: AtomicUsize,
+ }
+
+ impl CountingRecorder {
+ fn new() -> Self {
+ Self { count: AtomicUsize::new(0) }
+ }
+ fn calls(&self) -> usize {
+ self.count.load(AtomicOrdering::SeqCst)
+ }
+ }
+
+ #[async_trait]
+ impl MetricRecorder for CountingRecorder {
+ fn name(&self) -> &'static str {
+ "counting_recorder"
+ }
+ async fn record(&self, _block: &Block, _metrics: &Metrics) -> anyhow::Result<()> {
+ self.count.fetch_add(1, AtomicOrdering::SeqCst);
+ Ok(())
+ }
+ }
+
+ #[tokio::test]
+ async fn collector_cancellation() {
+ // Create a collector pointing at a non-existent endpoint.
+ // It will fail to connect, but should still exit cleanly on cancel.
+ let provider = ProviderBuilder::new()
+ .disable_recommended_fillers()
+ .connect_http("http://127.0.0.1:1".parse().unwrap());
+
+ let collector = Collector::new(
+ provider,
+ Duration::from_millis(50),
+ vec![],
+ Metrics::noop(),
+ "test",
+ "test.block",
+ );
+
+ let cancel = CancellationToken::new();
+ let cancel_clone = cancel.clone();
+
+ let handle = tokio::spawn(async move { collector.run(cancel_clone).await });
+
+ // Give it a moment to start, then cancel.
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ cancel.cancel();
+
+ // Task should complete without panicking.
+ let result = handle.await;
+ assert!(result.is_ok(), "collector task panicked");
+ assert!(result.unwrap().is_ok(), "collector returned an error");
+ }
+
+ #[tokio::test]
+ async fn collector_processes_blocks() {
+ use std::sync::atomic::AtomicU64;
+
+ use axum::{Json, Router, routing::post};
+
+ // A minimal JSON-RPC handler that returns canned responses:
+ // - eth_blockNumber → 1
+ // - eth_getBlockByNumber → a minimal block for block 1, then null
+ async fn rpc_handler(Json(body): Json) -> Json {
+ let id = body.get("id").cloned().unwrap_or_else(|| serde_json::json!(1));
+ let method = body.get("method").and_then(|m| m.as_str()).unwrap_or("");
+
+ let result = match method {
+ "eth_blockNumber" => serde_json::json!("0x1"),
+ "eth_getBlockByNumber" => {
+ let count = CALL_COUNT.fetch_add(1, AtomicOrdering::SeqCst);
+ if count == 0 {
+ // Return a minimal block for block 1.
+ serde_json::json!({
+ "hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
+ "parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
+ "number": "0x1",
+ "timestamp": "0x0",
+ "gasLimit": "0x0",
+ "gasUsed": "0x0",
+ "miner": "0x0000000000000000000000000000000000000000",
+ "extraData": "0x",
+ "baseFeePerGas": "0x0",
+ "stateRoot": "0x0000000000000000000000000000000000000000000000000000000000000000",
+ "transactionsRoot": "0x0000000000000000000000000000000000000000000000000000000000000000",
+ "receiptsRoot": "0x0000000000000000000000000000000000000000000000000000000000000000",
+ "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+ "difficulty": "0x0",
+ "nonce": "0x0000000000000000",
+ "sha3Uncles": "0x0000000000000000000000000000000000000000000000000000000000000000",
+ "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
+ "transactions": [],
+ "size": "0x0",
+ "totalDifficulty": "0x0",
+ "uncles": []
+ })
+ } else {
+ serde_json::Value::Null
+ }
+ }
+ _ => serde_json::Value::Null,
+ };
+
+ Json(serde_json::json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "result": result
+ }))
+ }
+
+ // Use a thread_local-like static for the call counter in the handler.
+ static CALL_COUNT: AtomicU64 = AtomicU64::new(0);
+ CALL_COUNT.store(0, AtomicOrdering::SeqCst);
+
+ // Start mock RPC server.
+ let app = Router::new().route("/", post(rpc_handler));
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap();
+ let server_cancel = CancellationToken::new();
+ let server_cancel_clone = server_cancel.clone();
+ tokio::spawn(async move {
+ axum::serve(listener, app)
+ .with_graceful_shutdown(server_cancel_clone.cancelled_owned())
+ .await
+ .unwrap();
+ });
+ tokio::time::sleep(Duration::from_millis(50)).await;
+
+ // Build a counting recorder.
+ let recorder = Arc::new(CountingRecorder::new());
+ let recorder_ref = Arc::clone(&recorder);
+
+ let provider = ProviderBuilder::new()
+ .disable_recommended_fillers()
+ .connect_http(format!("http://{addr}").parse().unwrap());
+
+ let collector = Collector::new(
+ provider,
+ Duration::from_millis(50),
+ vec![recorder_ref as Arc],
+ Metrics::noop(),
+ "test",
+ "test.block",
+ );
+
+ let cancel = CancellationToken::new();
+ let cancel_clone = cancel.clone();
+
+ let handle = tokio::spawn(async move { collector.run(cancel_clone).await });
+
+ // Wait for the collector to process at least one block.
+ tokio::time::sleep(Duration::from_millis(500)).await;
+ cancel.cancel();
+
+ let result = handle.await;
+ assert!(result.is_ok(), "collector task panicked");
+
+ // The recorder should have been called at least once.
+ assert!(
+ recorder.calls() >= 1,
+ "recorder was called {} times, expected >= 1",
+ recorder.calls()
+ );
+
+ server_cancel.cancel();
+ }
+}
diff --git a/crates/rpc-collector/src/services/divergence_checker.rs b/crates/rpc-collector/src/services/divergence_checker.rs
new file mode 100644
index 0000000..07cccaa
--- /dev/null
+++ b/crates/rpc-collector/src/services/divergence_checker.rs
@@ -0,0 +1,181 @@
+//! Divergence checker — fetches the same block from Geth and Reth and compares
+//! block hash, state root, transaction root and receipt root.
+
+use std::time::Duration;
+
+use alloy_provider::Provider;
+use tokio::task::JoinSet;
+use tokio_util::sync::CancellationToken;
+use tracing::{error, info, warn};
+
+use crate::{
+ metrics::{self, Metrics},
+ services::Service,
+};
+
+// ── Service ──────────────────────────────────────────────────
+
+/// The divergence checker service, implementing [`Service`].
+#[derive(Debug)]
+pub struct DivergenceCheckerService {
+ /// Geth provider.
+ pub geth: P,
+ /// Reth provider.
+ pub reth: P,
+ /// Interval between divergence check polls.
+ pub poll_interval: Duration,
+ /// Grace period for Geth node sync.
+ pub geth_grace_period: Duration,
+ /// Metrics client.
+ pub metrics: Metrics,
+ /// Human-readable checker name.
+ pub checker_name: String,
+}
+
+impl Service for DivergenceCheckerService {
+ fn name(&self) -> &str {
+ &self.checker_name
+ }
+
+ fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken) {
+ let name = self.checker_name.clone();
+ set.spawn(async move {
+ if let Err(e) = run(
+ self.geth,
+ self.reth,
+ self.poll_interval,
+ self.geth_grace_period,
+ self.metrics,
+ self.checker_name,
+ cancel,
+ )
+ .await
+ {
+ error!(checker = name, error = %e, "divergence checker failed");
+ }
+ });
+ }
+}
+
+// ── Core logic ───────────────────────────────────────────────
+
+/// Run the divergence checker loop.
+async fn run(
+ geth: P,
+ reth: P,
+ poll_interval: Duration,
+ geth_grace_period: Duration,
+ metrics: Metrics,
+ checker_name: String,
+ cancel: CancellationToken,
+) -> anyhow::Result<()> {
+ let geth_block = geth.get_block_number().await?;
+ let reth_block = reth.get_block_number().await?;
+ let mut current_block = geth_block.min(reth_block);
+
+ info!(checker = checker_name, start_block = current_block, "starting divergence checker");
+
+ let mut interval = tokio::time::interval(poll_interval);
+ interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+ let tags = [("checker", checker_name.as_str())];
+
+ loop {
+ tokio::select! {
+ _ = cancel.cancelled() => return Ok(()),
+ _ = interval.tick() => {
+ let geth_latest = match geth.get_block_number().await {
+ Ok(n) => n,
+ Err(e) => {
+ error!(error = %e, "failed to get latest block from Geth");
+ metrics.count_with_tags(
+ metrics::DIVERGENCE_NODE_ERROR,
+ 1,
+ &[("checker", checker_name.as_str()), ("node_type", "geth")],
+ );
+ continue;
+ }
+ };
+
+ let reth_latest = match reth.get_block_number().await {
+ Ok(n) => n,
+ Err(e) => {
+ error!(error = %e, "failed to get latest block from Reth");
+ metrics.count_with_tags(
+ metrics::DIVERGENCE_NODE_ERROR,
+ 1,
+ &[("checker", checker_name.as_str()), ("node_type", "reth")],
+ );
+ continue;
+ }
+ };
+
+ // Check geth staleness.
+ if let Ok(Some(header)) = geth.get_header_by_number(geth_latest.into()).await {
+ let age = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH + Duration::from_secs(header.timestamp))
+ .unwrap_or_default();
+ if age > geth_grace_period {
+ warn!(checker = checker_name, geth_block = geth_latest, "geth node appears stale");
+ metrics.count_with_tags(metrics::DIVERGENCE_GETH_TIMEOUT, 1, &tags);
+ }
+ }
+
+ let next_block = current_block + 1;
+ if geth_latest.min(reth_latest) < next_block {
+ continue;
+ }
+
+ // Fetch block from both nodes in parallel.
+ let (geth_res, reth_res) = tokio::join!(
+ geth.get_block_by_number(next_block.into()).full(),
+ reth.get_block_by_number(next_block.into()).full(),
+ );
+
+ let geth_block = match geth_res {
+ Ok(Some(b)) => b,
+ _ => {
+ metrics.count_with_tags(
+ metrics::DIVERGENCE_NODE_ERROR,
+ 1,
+ &[("checker", checker_name.as_str()), ("node_type", "geth")],
+ );
+ continue;
+ }
+ };
+
+ let reth_block = match reth_res {
+ Ok(Some(b)) => b,
+ _ => {
+ metrics.count_with_tags(
+ metrics::DIVERGENCE_NODE_ERROR,
+ 1,
+ &[("checker", checker_name.as_str()), ("node_type", "reth")],
+ );
+ continue;
+ }
+ };
+
+ // Compare the four critical roots.
+ let matched = geth_block.header.hash == reth_block.header.hash
+ && geth_block.header.state_root == reth_block.header.state_root
+ && geth_block.header.transactions_root == reth_block.header.transactions_root
+ && geth_block.header.receipts_root == reth_block.header.receipts_root;
+
+ if matched {
+ metrics.count_with_tags(metrics::DIVERGENCE_CROSS_GROUP_DETECTED, 0, &tags);
+ } else {
+ error!(
+ block = next_block,
+ geth_hash = %geth_block.header.hash,
+ reth_hash = %reth_block.header.hash,
+ "divergence detected between geth and reth"
+ );
+ metrics.count_with_tags(metrics::DIVERGENCE_CROSS_GROUP_DETECTED, 1, &tags);
+ }
+
+ metrics.count_with_tags(metrics::DIVERGENCE_BLOCK_PROCESSED, 1, &tags);
+ current_block = next_block;
+ }
+ }
+ }
+}
diff --git a/crates/rpc-collector/src/services/flashblock_validator.rs b/crates/rpc-collector/src/services/flashblock_validator.rs
new file mode 100644
index 0000000..85c51c7
--- /dev/null
+++ b/crates/rpc-collector/src/services/flashblock_validator.rs
@@ -0,0 +1,525 @@
+//! Flashblock validator — WebSocket listener, in-memory cache,
+//! and priority-fee ordering validation.
+//!
+//! Uses the canonical [`base_primitives::flashtypes::Flashblock`] type for
+//! message decoding (brotli + JSON) rather than hand-rolled structs.
+
+use std::{
+ collections::{HashMap, HashSet},
+ sync::{
+ Arc,
+ atomic::{AtomicU64, Ordering},
+ },
+ time::{Duration, Instant},
+};
+
+use alloy_consensus::Transaction as _;
+use alloy_primitives::{B256, U256, utils::Unit};
+use alloy_provider::Provider;
+use base_primitives::flashblocks::Flashblock;
+use futures_util::StreamExt;
+use tokio::{sync::RwLock, task::JoinSet};
+use tokio_util::sync::CancellationToken;
+use tracing::{error, info, warn};
+
+use crate::{
+ metrics::{self, Metrics},
+ services::Service,
+ utils::{self, wei_to_unit},
+};
+
+// ── Types ────────────────────────────────────────────────────
+
+#[derive(Debug, Clone)]
+struct FlashblockTransaction {
+ effective_priority_fee: u128,
+ is_user: bool,
+}
+
+#[derive(Debug, Clone)]
+struct FlashblockData {
+ flashblock_indices: HashSet,
+ last_updated: Instant,
+ transaction_hashes: HashMap,
+ base_fee: Option,
+}
+
+impl Default for FlashblockData {
+ fn default() -> Self {
+ Self {
+ flashblock_indices: HashSet::new(),
+ last_updated: Instant::now(),
+ transaction_hashes: HashMap::new(),
+ base_fee: None,
+ }
+ }
+}
+
+// ── Validator ────────────────────────────────────────────────
+
+/// The main flashblock validator, owning the cache and config.
+#[derive(Debug)]
+pub struct FlashblockValidator {
+ name: String,
+ ws_url: String,
+ provider: Arc
,
+ cache: Arc>>,
+ current_block: Arc,
+ poll_interval: Duration,
+ metrics: Metrics,
+}
+
+impl FlashblockValidator {
+ pub fn new(
+ name: String,
+ ws_url: String,
+ provider: Arc
,
+ poll_interval: Duration,
+ metrics: Metrics,
+ ) -> Self {
+ Self {
+ name,
+ ws_url,
+ provider,
+ cache: Arc::new(RwLock::new(HashMap::new())),
+ current_block: Arc::new(AtomicU64::new(0)),
+ poll_interval,
+ metrics,
+ }
+ }
+}
+
+// ── Service impl ─────────────────────────────────────────────
+
+impl Service for FlashblockValidator {
+ fn name(&self) -> &str {
+ &self.name
+ }
+
+ fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken) {
+ let name = self.name.clone();
+ set.spawn(async move {
+ if let Err(e) = self.run(cancel).await {
+ error!(name, error = %e, "flashblock validator failed");
+ }
+ });
+ }
+}
+
+// ── Core logic ───────────────────────────────────────────────
+
+impl FlashblockValidator {
+ /// Start the flashblock validator: spawns WS listener, L2 poller, and
+ /// cache cleanup tasks, then awaits cancellation.
+ async fn run(self, cancel: CancellationToken) -> anyhow::Result<()> {
+ let current = self.provider.get_block_number().await?;
+ self.current_block.store(current, Ordering::Relaxed);
+
+ tokio::spawn(listen_stream(
+ self.ws_url.clone(),
+ Arc::clone(&self.cache),
+ self.metrics.clone(),
+ self.name.clone(),
+ cancel.clone(),
+ ));
+
+ tokio::spawn(poll_l2_blocks(
+ Arc::clone(&self.provider),
+ Arc::clone(&self.cache),
+ Arc::clone(&self.current_block),
+ self.poll_interval,
+ self.metrics.clone(),
+ self.name.clone(),
+ cancel.clone(),
+ ));
+
+ tokio::spawn(cleanup_loop(Arc::clone(&self.cache), cancel.clone()));
+
+ cancel.cancelled().await;
+ Ok(())
+ }
+}
+
+// ── WebSocket listener ───────────────────────────────────────
+
+async fn listen_stream(
+ ws_url: String,
+ cache: Arc>>,
+ metrics: Metrics,
+ name: String,
+ cancel: CancellationToken,
+) {
+ loop {
+ if cancel.is_cancelled() {
+ return;
+ }
+
+ match tokio_tungstenite::connect_async(&ws_url).await {
+ Ok((mut ws, _)) => {
+ info!(stream = name, "connected to flashblock WebSocket");
+
+ loop {
+ tokio::select! {
+ _ = cancel.cancelled() => return,
+ msg = ws.next() => {
+ match msg {
+ Some(Ok(msg)) if msg.is_binary() || msg.is_text() => {
+ process_flashblock_message(msg.into_data(), &cache, &metrics, &name).await;
+ }
+ Some(Err(e)) => {
+ error!(stream = name, error = %e, "WS read error");
+ break;
+ }
+ None => break,
+ _ => {}
+ }
+ }
+ }
+ }
+ }
+ Err(e) => {
+ error!(stream = name, error = %e, "failed to connect to flashblock WS");
+ }
+ }
+
+ tokio::time::sleep(Duration::from_secs(5)).await;
+ }
+}
+
+async fn process_flashblock_message(
+ message: impl Into>,
+ cache: &Arc>>,
+ metrics: &Metrics,
+ validator_name: &str,
+) {
+ // Decode (brotli + JSON) via the canonical type.
+ let fb_entry = match Flashblock::try_decode_message(message.into()) {
+ Ok(fb) => fb,
+ Err(e) => {
+ error!(error = %e, "failed to decode flashblock message");
+ return;
+ }
+ };
+
+ let block_number = fb_entry.metadata.block_number;
+ let index = fb_entry.index;
+
+ let mut cache = cache.write().await;
+ let fb = cache.entry(block_number).or_default();
+
+ // Store base fee from index 0.
+ if index == 0
+ && let Some(base) = &fb_entry.base
+ {
+ fb.base_fee = base.base_fee_per_gas.try_into().ok();
+ }
+
+ let base_fee = fb.base_fee;
+
+ fb.flashblock_indices.insert(index);
+
+ // Decode transactions from diff.
+ let mut flashblock_txs = Vec::new();
+ for tx_raw in &fb_entry.diff.transactions {
+ use alloy_consensus::TxEnvelope;
+ use alloy_rlp::Decodable;
+
+ let tx = match TxEnvelope::decode(&mut tx_raw.as_ref()) {
+ Ok(t) => t,
+ Err(_) => continue,
+ };
+
+ fb.transaction_hashes.insert(*tx.tx_hash(), index);
+
+ // Priority fee.
+ let tip = tx.max_priority_fee_per_gas().unwrap_or(0);
+ let effective_tip = base_fee.map_or(tip, |bf| {
+ let max_fee = tx.max_fee_per_gas();
+ tip.min(max_fee.saturating_sub(bf))
+ });
+
+ let is_user = tx.tx_type() as u8 != utils::DEPOSIT_TX_TYPE;
+ flashblock_txs
+ .push(FlashblockTransaction { effective_priority_fee: effective_tip, is_user });
+ }
+
+ fb.last_updated = Instant::now();
+
+ // Priority fee metrics.
+ emit_priority_fee_metrics(index, &flashblock_txs, metrics, validator_name);
+ validate_priority_fee_ordering(block_number, index, &flashblock_txs, metrics, validator_name);
+}
+
+fn emit_priority_fee_metrics(
+ index: u64,
+ txs: &[FlashblockTransaction],
+ metrics: &Metrics,
+ name: &str,
+) {
+ if index > 10 {
+ return;
+ }
+
+ let idx_str = index.to_string();
+ let tags = [("validator", name), ("flashblock_index", idx_str.as_str())];
+
+ let user_txs: Vec<_> = txs.iter().filter(|tx| tx.is_user).collect();
+
+ for tx in &user_txs {
+ metrics.histogram_with_tags(
+ metrics::FLASHBLOCK_PRIORITY_FEE_TIP,
+ wei_to_unit(U256::from(tx.effective_priority_fee), Unit::GWEI),
+ &tags,
+ );
+ }
+
+ metrics.histogram_with_tags(
+ metrics::FLASHBLOCK_PRIORITY_FEE_TX_COUNT,
+ user_txs.len() as f64,
+ &tags,
+ );
+
+ if let Some(min) = user_txs.iter().map(|tx| tx.effective_priority_fee).min() {
+ metrics.histogram_with_tags(
+ metrics::FLASHBLOCK_PRIORITY_FEE_MIN,
+ wei_to_unit(U256::from(min), Unit::GWEI),
+ &tags,
+ );
+ }
+}
+
+fn validate_priority_fee_ordering(
+ block_number: u64,
+ flashblock_index: u64,
+ txs: &[FlashblockTransaction],
+ metrics: &Metrics,
+ name: &str,
+) {
+ let tags = [("validator", name)];
+
+ let violations = txs
+ .windows(2)
+ .filter(|pair| pair[1].effective_priority_fee > pair[0].effective_priority_fee)
+ .count();
+
+ if violations > 0 {
+ warn!(
+ block = block_number,
+ flashblock_index, violations, "priority fee ordering violation"
+ );
+ metrics.count_with_tags(metrics::FLASHBLOCK_TX_ORDER_VIOLATION, 1, &tags);
+ } else {
+ metrics.count_with_tags(metrics::FLASHBLOCK_TX_ORDER_SUCCESS, 1, &tags);
+ }
+}
+
+// ── L2 block poller ──────────────────────────────────────────
+
+async fn poll_l2_blocks(
+ provider: Arc,
+ cache: Arc>>,
+ current_block: Arc,
+ poll_interval: Duration,
+ metrics: Metrics,
+ name: String,
+ cancel: CancellationToken,
+) {
+ let mut interval = tokio::time::interval(poll_interval);
+ interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+
+ loop {
+ tokio::select! {
+ _ = cancel.cancelled() => return,
+ _ = interval.tick() => {
+ let latest = match provider.get_block_number().await {
+ Ok(n) => n,
+ Err(e) => {
+ error!(error = %e, "failed to get L2 block number");
+ continue;
+ }
+ };
+
+ let cur = current_block.load(Ordering::Relaxed);
+ if latest > cur {
+ for block_num in (cur + 1)..=latest {
+ let cache_guard = cache.read().await;
+ if let Some(fb_data) = cache_guard.get(&block_num) {
+ let fb_data = fb_data.clone();
+ drop(cache_guard);
+ validate_block(&provider, block_num, &fb_data, &metrics, &name).await;
+ } else {
+ drop(cache_guard);
+ warn!(block = block_num, "no flashblocks received for block");
+ metrics.count_with_tags(
+ metrics::FLASHBLOCK_NO_DATA_RECEIVED,
+ 1,
+ &[("validator", name.as_str())],
+ );
+ }
+ }
+ current_block.store(latest, Ordering::Relaxed);
+ }
+ }
+ }
+ }
+}
+
+async fn validate_block(
+ provider: &P,
+ block_number: u64,
+ cache: &FlashblockData,
+ metrics: &Metrics,
+ name: &str,
+) {
+ let block = match provider.get_block_by_number(block_number.into()).full().await {
+ Ok(Some(b)) => b,
+ Ok(None) | Err(_) => return,
+ };
+
+ let l2_tx_hashes: HashSet = {
+ use alloy_network::TransactionResponse as _;
+ block.transactions.txns().map(|tx| tx.tx_hash()).collect()
+ };
+
+ let total_flashblocks = cache.flashblock_indices.len();
+ let tags = [("validator", name)];
+
+ metrics.gauge_with_tags(metrics::FLASHBLOCK_TOTAL_RECEIVED, total_flashblocks as f64, &tags);
+
+ // Find missing indices (0..=10).
+ let missing: Vec = (0..=10).filter(|i| !cache.flashblock_indices.contains(i)).collect();
+ if !missing.is_empty() {
+ metrics.count_with_tags(metrics::FLASHBLOCK_MISSING_INDICES, missing.len() as i64, &tags);
+ }
+
+ // Compare tx sets.
+ let mut reorged_indices = HashSet::new();
+ let mut reorged_txs = 0usize;
+
+ for (tx_hash, &fb_index) in &cache.transaction_hashes {
+ if !l2_tx_hashes.contains(tx_hash) {
+ reorged_txs += 1;
+ reorged_indices.insert(fb_index);
+ }
+ }
+
+ let total_txs = cache.transaction_hashes.len();
+ let included_txs = total_txs.saturating_sub(reorged_txs);
+
+ metrics.count_with_tags(metrics::FLASHBLOCK_TX_TOTAL, total_txs as i64, &tags);
+ metrics.count_with_tags(metrics::FLASHBLOCK_TX_REORGED, reorged_txs as i64, &tags);
+ metrics.count_with_tags(metrics::FLASHBLOCK_TX_INCLUDED, included_txs as i64, &tags);
+
+ let successful_flashblocks = total_flashblocks.saturating_sub(reorged_indices.len());
+ metrics.count_with_tags(metrics::FLASHBLOCK_SUCCESS, successful_flashblocks as i64, &tags);
+ metrics.count_with_tags(metrics::FLASHBLOCK_REORG, reorged_indices.len() as i64, &tags);
+
+ if reorged_txs > 0 {
+ metrics.count_with_tags(metrics::FLASHBLOCK_HASH_MISMATCH, 1, &tags);
+ } else {
+ metrics.count_with_tags(metrics::FLASHBLOCK_VALIDATION_SUCCESS, 1, &tags);
+ }
+}
+
+// ── Cache cleanup ────────────────────────────────────────────
+
+async fn cleanup_loop(cache: Arc>>, cancel: CancellationToken) {
+ let mut interval = tokio::time::interval(Duration::from_secs(60));
+ interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+
+ loop {
+ tokio::select! {
+ _ = cancel.cancelled() => return,
+ _ = interval.tick() => {
+ // Clean cache entries older than 20 s.
+ let mut c = cache.write().await;
+ let now = Instant::now();
+ c.retain(|_, fb| now.duration_since(fb.last_updated) < Duration::from_secs(20));
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn user_tx(fee: u128) -> FlashblockTransaction {
+ FlashblockTransaction { effective_priority_fee: fee, is_user: true }
+ }
+
+ fn system_tx(fee: u128) -> FlashblockTransaction {
+ FlashblockTransaction { effective_priority_fee: fee, is_user: false }
+ }
+
+ // ── validate_priority_fee_ordering ───────────────────────
+
+ #[test]
+ fn ordering_valid_no_violations() {
+ let m = Metrics::noop();
+ // Descending priority fees → no violations
+ let txs = vec![user_tx(100), user_tx(80), user_tx(50), user_tx(10)];
+ // Should not panic; will emit FLASHBLOCK_TX_ORDER_SUCCESS
+ validate_priority_fee_ordering(1, 0, &txs, &m, "test");
+ }
+
+ #[test]
+ fn ordering_violation_detected() {
+ let m = Metrics::noop();
+ // A tx with higher fee after a lower-fee tx → violation
+ let txs = vec![user_tx(100), user_tx(50), user_tx(80)]; // 80 > 50 → violation
+ validate_priority_fee_ordering(1, 0, &txs, &m, "test");
+ }
+
+ #[test]
+ fn ordering_empty_txs() {
+ let m = Metrics::noop();
+ validate_priority_fee_ordering(1, 0, &[], &m, "test");
+ }
+
+ #[test]
+ fn ordering_single_tx() {
+ let m = Metrics::noop();
+ validate_priority_fee_ordering(1, 0, &[user_tx(42)], &m, "test");
+ }
+
+ // ── emit_priority_fee_metrics ────────────────────────────
+
+ #[test]
+ fn emit_priority_fee_skips_high_index() {
+ let m = Metrics::noop();
+ let txs = vec![user_tx(100)];
+ // Index > 10 → should return early and emit nothing (no panic)
+ emit_priority_fee_metrics(11, &txs, &m, "test");
+ emit_priority_fee_metrics(100, &txs, &m, "test");
+ }
+
+ #[test]
+ fn emit_priority_fee_filters_system_txs() {
+ let m = Metrics::noop();
+ // Mix of user and system txs: only user txs should contribute to fee metrics
+ let txs = vec![
+ user_tx(100),
+ system_tx(0), // Deposit tx — should be filtered
+ user_tx(50),
+ ];
+ // Index within range → should emit metrics for user txs only (no panic)
+ emit_priority_fee_metrics(0, &txs, &m, "test");
+ }
+
+ #[test]
+ fn emit_priority_fee_boundary_index() {
+ let m = Metrics::noop();
+ let txs = vec![user_tx(42)];
+ // Index exactly 10 → should emit
+ emit_priority_fee_metrics(10, &txs, &m, "test");
+ }
+
+ #[test]
+ fn emit_priority_fee_no_user_txs() {
+ let m = Metrics::noop();
+ let txs = vec![system_tx(0), system_tx(0)];
+ // All system txs → should not panic, just emit 0 count
+ emit_priority_fee_metrics(0, &txs, &m, "test");
+ }
+}
diff --git a/crates/rpc-collector/src/services/health_checker.rs b/crates/rpc-collector/src/services/health_checker.rs
new file mode 100644
index 0000000..20bf1c0
--- /dev/null
+++ b/crates/rpc-collector/src/services/health_checker.rs
@@ -0,0 +1,315 @@
+//! Node health checking — unified for both L1 and L2.
+//!
+//! Each node is polled in parallel: fetch latest block number,
+//! check staleness against a grace period, detect rate-limiting.
+//! For L2, an optional sequencer node provides a "delta" metric.
+
+use std::time::Duration;
+
+use alloy_provider::{Provider, ProviderBuilder, RootProvider};
+use op_alloy_network::Optimism;
+use tokio::task::JoinSet;
+use tokio_util::sync::CancellationToken;
+use tracing::{error, info, warn};
+
+use crate::{
+ metrics::{self, Metrics},
+ services::Service,
+};
+
+// ── Node type ────────────────────────────────────────────────
+
+/// Represents a node used in health checks.
+#[derive(Debug, Clone)]
+pub struct Node {
+ pub client: RootProvider,
+ pub url: String,
+ pub provider: String,
+}
+
+impl Node {
+ /// Create a [`Node`] from a URL. Returns `None` if the URL cannot be parsed.
+ pub fn new(url: &str, provider_name: &str) -> Option {
+ let parsed: url::Url = url.parse().ok()?;
+ let client = ProviderBuilder::new()
+ .disable_recommended_fillers()
+ .network::()
+ .connect_http(parsed);
+ Some(Self { client, url: url.to_string(), provider: provider_name.to_string() })
+ }
+}
+
+/// Build a list of [`Node`]s, logging and skipping failures.
+pub fn build_node_list(urls: &[String], provider_name: &str) -> Vec {
+ urls.iter()
+ .filter_map(|url| {
+ Node::new(url, provider_name).or_else(|| {
+ warn!(url, "failed to parse node URL, skipping");
+ None
+ })
+ })
+ .collect()
+}
+
+/// Build a node list for external nodes, sanitizing URLs to just the host.
+pub fn build_node_list_sanitized(urls: &[String], provider_name: &str) -> Vec {
+ urls.iter()
+ .filter_map(|raw_url| {
+ let parsed = raw_url.parse::().ok().or_else(|| {
+ warn!(url = raw_url.as_str(), "invalid external node URL, skipping");
+ None
+ })?;
+ let sanitized = parsed.host_str().unwrap_or(raw_url).to_string();
+ let client = ProviderBuilder::new()
+ .disable_recommended_fillers()
+ .network::()
+ .connect_http(parsed);
+ Some(Node { client, url: sanitized, provider: provider_name.to_string() })
+ })
+ .collect()
+}
+
+// ── Service ──────────────────────────────────────────────────
+
+/// Node health checking service (L1 or L2), implementing [`Service`].
+#[derive(Debug)]
+pub struct HealthChecker {
+ /// Nodes to check.
+ pub nodes: Vec,
+ /// Optional sequencer node (L2 only).
+ pub sequencer: Option,
+ /// Interval between health check polls.
+ pub poll_interval: Duration,
+ /// Grace period before a node is considered stale.
+ pub grace_period: Duration,
+ /// Metrics client.
+ pub metrics: Metrics,
+ /// Layer label: `"l1"` or `"l2"`.
+ pub layer: &'static str,
+}
+
+impl Service for HealthChecker {
+ fn name(&self) -> &str {
+ self.layer
+ }
+
+ fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken) {
+ if self.nodes.is_empty() {
+ return;
+ }
+ let layer = self.layer;
+ set.spawn(async move {
+ info!(layer, "starting node health checks");
+ if let Err(e) = run(
+ self.nodes,
+ self.sequencer,
+ self.poll_interval,
+ self.grace_period,
+ self.metrics,
+ layer,
+ cancel,
+ )
+ .await
+ {
+ error!(layer, error = %e, "health checker stopped with error");
+ }
+ });
+ }
+}
+
+// ── Core logic ───────────────────────────────────────────────
+
+/// Run health checks for a set of nodes at the given interval.
+///
+/// Works for both L1 and L2. Pass `sequencer: Some(node)` for L2 to
+/// enable sequencer-delta tracking.
+async fn run(
+ nodes: Vec,
+ sequencer: Option,
+ poll_interval: Duration,
+ grace_period: Duration,
+ metrics: Metrics,
+ layer: &str,
+ cancel: CancellationToken,
+) -> anyhow::Result<()> {
+ let mut interval = tokio::time::interval(poll_interval);
+ interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+
+ loop {
+ tokio::select! {
+ _ = cancel.cancelled() => return Ok(()),
+ _ = interval.tick() => {
+ let seq_block = fetch_sequencer_block(sequencer.as_ref(), &metrics).await;
+ check_nodes(&nodes, seq_block, grace_period, &metrics, layer).await;
+ }
+ }
+ }
+}
+
+/// Fetch the sequencer's latest block (if configured). Returns `None` if
+/// there is no sequencer or the fetch fails.
+async fn fetch_sequencer_block(sequencer: Option<&Node>, metrics: &Metrics) -> Option {
+ let seq = sequencer?;
+ let tags = [("url", seq.url.as_str()), ("layer", "l2")];
+ match tokio::time::timeout(Duration::from_secs(5), seq.client.get_block_number()).await {
+ Ok(Ok(n)) => {
+ metrics.gauge_with_tags(metrics::SEQUENCER_LATEST_BLOCK, n as f64, &tags);
+ info!(url = seq.url, block = n, "fetched sequencer block");
+ Some(n)
+ }
+ _ => {
+ metrics.count_with_tags(metrics::NODE_ERROR, 1, &tags);
+ None
+ }
+ }
+}
+
+/// Check all nodes in parallel.
+async fn check_nodes(
+ nodes: &[Node],
+ sequencer_block: Option,
+ grace_period: Duration,
+ metrics: &Metrics,
+ layer: &str,
+) {
+ let mut set = JoinSet::new();
+
+ for node in nodes {
+ let url = node.url.clone();
+ let provider = node.provider.clone();
+ let client = node.client.clone();
+ let metrics = metrics.clone();
+ let layer = layer.to_string();
+
+ set.spawn(async move {
+ let base_tags: [(&str, &str); 2] = [("url", url.as_str()), ("layer", layer.as_str())];
+
+ match tokio::time::timeout(Duration::from_secs(5), client.get_block_number()).await {
+ Err(_) => {
+ error!(url, "timeout fetching latest block");
+ metrics.count_with_tags(metrics::NODE_ERROR, 1, &base_tags);
+ }
+ Ok(Err(e)) => {
+ let err_str = e.to_string();
+ if err_str.contains("429") {
+ metrics.count_with_tags(metrics::NODE_RATE_LIMITED, 1, &base_tags);
+ } else {
+ metrics.count_with_tags(metrics::NODE_ERROR, 1, &base_tags);
+ }
+ error!(url, error = %e, "unable to fetch latest block");
+ }
+ Ok(Ok(block_num)) => {
+ let prov_tags: [(&str, &str); 3] = [
+ ("url", url.as_str()),
+ ("layer", layer.as_str()),
+ ("provider", provider.as_str()),
+ ];
+ metrics.gauge_with_tags(
+ metrics::NODE_LATEST_BLOCK,
+ block_num as f64,
+ &prov_tags,
+ );
+ info!(url, provider, block = block_num, "fetched latest block");
+
+ if let Some(seq_block) = sequencer_block {
+ let delta = seq_block.saturating_sub(block_num);
+ metrics.gauge_with_tags(metrics::SEQUENCER_DELTA, delta as f64, &base_tags);
+ }
+
+ // Staleness check via header timestamp.
+ if let Ok(Some(header)) = client.get_header_by_number(block_num.into()).await {
+ let block_time =
+ std::time::UNIX_EPOCH + Duration::from_secs(header.timestamp);
+ let age = std::time::SystemTime::now()
+ .duration_since(block_time)
+ .unwrap_or_default();
+
+ metrics.gauge_with_tags(
+ metrics::NODE_LATEST_TIME,
+ header.timestamp as f64,
+ &base_tags,
+ );
+
+ if age > grace_period {
+ metrics.count_with_tags(metrics::NODE_STALL, 1, &base_tags);
+ } else {
+ metrics.gauge_with_tags(
+ metrics::NODE_HEALTHY,
+ block_num as f64,
+ &base_tags,
+ );
+ }
+ }
+ }
+ }
+ });
+ }
+
+ while set.join_next().await.is_some() {}
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // ── build_node_list ──────────────────────────────────────
+
+ #[test]
+ fn build_node_list_valid_urls() {
+ let urls =
+ vec!["http://localhost:8545".to_string(), "http://geth.example.com:8545".to_string()];
+ let nodes = build_node_list(&urls, "test_provider");
+ assert_eq!(nodes.len(), 2);
+ assert_eq!(nodes[0].provider, "test_provider");
+ assert_eq!(nodes[0].url, "http://localhost:8545");
+ assert_eq!(nodes[1].url, "http://geth.example.com:8545");
+ }
+
+ #[test]
+ fn build_node_list_skips_invalid() {
+ let urls = vec![
+ "http://valid.host:8545".to_string(),
+ "not a valid url at all!@#$%".to_string(),
+ "http://another-valid.host:8545".to_string(),
+ ];
+ let nodes = build_node_list(&urls, "test");
+ // The invalid URL should be silently skipped.
+ assert_eq!(nodes.len(), 2);
+ }
+
+ #[test]
+ fn build_node_list_empty() {
+ let urls: Vec = vec![];
+ let nodes = build_node_list(&urls, "test");
+ assert!(nodes.is_empty());
+ }
+
+ // ── build_node_list_sanitized ────────────────────────────
+
+ #[test]
+ fn build_node_list_sanitized_strips_path() {
+ let urls = vec!["http://host.example.com/path?key=val".to_string()];
+ let nodes = build_node_list_sanitized(&urls, "ext");
+ assert_eq!(nodes.len(), 1);
+ // The URL should be sanitized to just the host.
+ assert_eq!(nodes[0].url, "host.example.com");
+ assert_eq!(nodes[0].provider, "ext");
+ }
+
+ #[test]
+ fn build_node_list_sanitized_skips_invalid() {
+ let urls = vec!["http://valid.host:8545".to_string(), "garbage://???".to_string()];
+ let nodes = build_node_list_sanitized(&urls, "ext");
+ // The second URL doesn't produce a valid host but may still parse.
+ // Only URLs that can be parsed at all are included.
+ assert!(!nodes.is_empty());
+ }
+
+ #[test]
+ fn build_node_list_sanitized_preserves_host_only() {
+ let urls = vec!["http://192.168.1.100:8545/v1/mainnet".to_string()];
+ let nodes = build_node_list_sanitized(&urls, "infra");
+ assert_eq!(nodes.len(), 1);
+ assert_eq!(nodes[0].url, "192.168.1.100");
+ }
+}
diff --git a/crates/rpc-collector/src/services/health_server.rs b/crates/rpc-collector/src/services/health_server.rs
new file mode 100644
index 0000000..ef186c1
--- /dev/null
+++ b/crates/rpc-collector/src/services/health_server.rs
@@ -0,0 +1,118 @@
+//! Health check HTTP server.
+//!
+//! Exposes a `/_health` endpoint that returns 200 OK.
+
+use std::net::SocketAddr;
+
+use axum::{Router, routing::get};
+use tokio::task::JoinSet;
+use tokio_util::sync::CancellationToken;
+use tracing::{error, info};
+
+use crate::services::Service;
+
+async fn health() -> &'static str {
+ "OK"
+}
+
+/// Bind and serve the health check server.
+///
+/// The server shuts down gracefully when the `cancel` token is triggered.
+async fn serve(addr: SocketAddr, cancel: CancellationToken) -> anyhow::Result<()> {
+ let app = Router::new().route("/_health", get(health));
+
+ let listener = tokio::net::TcpListener::bind(addr).await?;
+ info!("health server listening on {addr}");
+
+ axum::serve(listener, app).with_graceful_shutdown(cancel.cancelled_owned()).await?;
+
+ Ok(())
+}
+
+/// The health HTTP server, implementing [`Service`].
+#[derive(Debug)]
+pub struct HealthServer {
+ /// Address to bind the health check server.
+ pub addr: SocketAddr,
+}
+
+impl Service for HealthServer {
+ fn name(&self) -> &str {
+ "health-server"
+ }
+
+ fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken) {
+ let addr = self.addr;
+ set.spawn(async move {
+ if let Err(e) = serve(addr, cancel).await {
+ error!(error = %e, "health server failed");
+ }
+ });
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Helper: start the health server on an OS-assigned port, returning the
+ /// bound address and a cancel token.
+ async fn start_server() -> (SocketAddr, CancellationToken) {
+ let cancel = CancellationToken::new();
+ // Bind to port 0 to get an OS-assigned port.
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap();
+
+ let cancel_clone = cancel.clone();
+ tokio::spawn(async move {
+ let app = Router::new().route("/_health", get(health));
+ axum::serve(listener, app)
+ .with_graceful_shutdown(cancel_clone.cancelled_owned())
+ .await
+ .unwrap();
+ });
+
+ // Give the server a moment to start.
+ tokio::time::sleep(std::time::Duration::from_millis(50)).await;
+
+ (addr, cancel)
+ }
+
+ #[tokio::test]
+ async fn health_endpoint_returns_ok() {
+ let (addr, cancel) = start_server().await;
+
+ let url = format!("http://{addr}/_health");
+ let resp = reqwest::get(&url).await.unwrap();
+
+ assert_eq!(resp.status(), 200);
+ let body = resp.text().await.unwrap();
+ assert_eq!(body, "OK");
+
+ cancel.cancel();
+ }
+
+ #[tokio::test]
+ async fn health_server_shuts_down() {
+ let (addr, cancel) = start_server().await;
+
+ // Verify it's running.
+ let url = format!("http://{addr}/_health");
+ assert!(reqwest::get(&url).await.is_ok());
+
+ // Cancel and give it time to shut down.
+ cancel.cancel();
+ tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+
+ // After shutdown, connections should fail.
+ let result = reqwest::Client::builder()
+ .timeout(std::time::Duration::from_millis(200))
+ .build()
+ .unwrap()
+ .get(&url)
+ .send()
+ .await;
+
+ assert!(result.is_err());
+ }
+}
diff --git a/crates/rpc-collector/src/services/mempool_listener.rs b/crates/rpc-collector/src/services/mempool_listener.rs
new file mode 100644
index 0000000..9b781b1
--- /dev/null
+++ b/crates/rpc-collector/src/services/mempool_listener.rs
@@ -0,0 +1,577 @@
+//! Mempool listener — polls `txpool_content` from Geth and Reth nodes,
+//! reclassifies underpriced Geth transactions, and compares snapshots.
+
+use std::{collections::HashMap, time::Duration};
+
+use alloy_provider::Provider;
+use serde::Deserialize;
+use tokio::task::JoinSet;
+use tokio_util::sync::CancellationToken;
+use tracing::{error, info};
+
+use crate::{
+ metrics::{self, Metrics},
+ services::Service,
+};
+
+// ── Types ────────────────────────────────────────────────────
+
+#[derive(Debug, Deserialize)]
+struct MempoolTransaction {
+ #[serde(rename = "gasPrice", default)]
+ gas_price: String,
+ #[serde(rename = "maxFeePerGas", default)]
+ max_fee_per_gas: String,
+ #[serde(rename = "type", default)]
+ tx_type: String,
+}
+
+/// `txpool_content` response shape: `{ pending: { addr: { nonce: tx } }, queued: ... }`.
+type TxpoolContent = HashMap>>;
+/// Pool category: `"pending"` or `"queued"`.
+type TxPool = HashMap>;
+
+/// Count total transactions across all addresses in a pool.
+fn count_pool_txs(pool: Option<&TxPool>) -> i64 {
+ pool.map_or(0, |p| p.values().map(|txs| txs.len() as i64).sum())
+}
+
+#[derive(Debug, Default)]
+struct MempoolSnapshot {
+ pending_count: i64,
+ queued_count: i64,
+ reclassified_count: i64,
+ original_pending_count: i64,
+}
+
+// ── Service ──────────────────────────────────────────────────
+
+/// The mempool listener service, implementing [`Service`].
+#[derive(Debug)]
+pub struct MempoolListenerService {
+ /// Geth provider for `txpool_content`.
+ pub geth: P,
+ /// Reth provider for `txpool_content`.
+ pub reth: P,
+ /// Interval between mempool polls.
+ pub poll_interval: Duration,
+ /// Metrics client.
+ pub metrics: Metrics,
+ /// Human-readable listener name.
+ pub listener_name: String,
+}
+
+impl Service for MempoolListenerService {
+ fn name(&self) -> &str {
+ &self.listener_name
+ }
+
+ fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken) {
+ let name = self.listener_name.clone();
+ set.spawn(async move {
+ if let Err(e) = run(
+ self.geth,
+ self.reth,
+ self.poll_interval,
+ self.metrics,
+ self.listener_name,
+ cancel,
+ )
+ .await
+ {
+ error!(listener = name, error = %e, "mempool listener failed");
+ }
+ });
+ }
+}
+
+// ── Core logic ───────────────────────────────────────────────
+
+/// Run the mempool listener loop. Both Geth and Reth providers are required.
+async fn run(
+ geth: P,
+ reth: P,
+ poll_interval: Duration,
+ metrics: Metrics,
+ listener_name: String,
+ cancel: CancellationToken,
+) -> anyhow::Result<()> {
+ info!(listener = listener_name, "starting mempool listener");
+
+ let mut interval = tokio::time::interval(poll_interval);
+ interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+
+ loop {
+ tokio::select! {
+ _ = cancel.cancelled() => {
+ info!(listener = listener_name, "mempool listener shutting down");
+ return Ok(());
+ }
+ _ = interval.tick() => {
+ collect_and_compare(&geth, &reth, &metrics, &listener_name).await;
+ }
+ }
+ }
+}
+
+async fn collect_and_compare(
+ geth: &P,
+ reth: &P,
+ metrics: &Metrics,
+ listener_name: &str,
+) {
+ // Fetch base fee from Geth (needed for reclassification).
+ let base_fee: Option = match geth
+ .get_block_by_number(alloy_eips::BlockNumberOrTag::Latest)
+ .await
+ {
+ Ok(Some(block)) => block.header.base_fee_per_gas.map(|b| b as u128),
+ _ => {
+ metrics.count_with_tags(
+ metrics::MEMPOOL_ERROR,
+ 1,
+ &[("listener", listener_name), ("client", "geth"), ("error", "base_fee_failed")],
+ );
+ None
+ }
+ };
+
+ let geth_snap = collect_geth_snapshot(geth, base_fee, metrics, listener_name).await;
+ let reth_snap = collect_reth_snapshot(reth, metrics, listener_name).await;
+
+ if let Some(ref snap) = geth_snap {
+ report_snapshot(snap, "geth", metrics, listener_name);
+ }
+ if let Some(ref snap) = reth_snap {
+ report_snapshot(snap, "reth", metrics, listener_name);
+ }
+
+ if let (Some(g), Some(r)) = (&geth_snap, &reth_snap) {
+ compare_snapshots(g, r, metrics, listener_name);
+ }
+}
+
+async fn collect_geth_snapshot(
+ provider: &P,
+ base_fee: Option,
+ metrics: &Metrics,
+ listener: &str,
+) -> Option {
+ let content: TxpoolContent = match provider.client().request_noparams("txpool_content").await {
+ Ok(c) => c,
+ Err(e) => {
+ error!(error = %e, "failed to get Geth txpool_content");
+ metrics.count_with_tags(
+ metrics::MEMPOOL_ERROR,
+ 1,
+ &[("listener", listener), ("client", "geth"), ("error", "snapshot_failed")],
+ );
+ return None;
+ }
+ };
+
+ let mut snap = MempoolSnapshot::default();
+
+ if let Some(pending) = content.get("pending") {
+ for txs_by_nonce in pending.values() {
+ let addr_count = txs_by_nonce.len() as i64;
+ snap.original_pending_count += addr_count;
+
+ if let Some(bf) = base_fee {
+ let result = reclassify_for_address(txs_by_nonce, bf);
+ snap.pending_count += result.0;
+ snap.reclassified_count += result.1;
+ } else {
+ snap.pending_count += addr_count;
+ }
+ }
+ }
+
+ snap.queued_count = count_pool_txs(content.get("queued")) + snap.reclassified_count;
+ Some(snap)
+}
+
+async fn collect_reth_snapshot(
+ provider: &P,
+ metrics: &Metrics,
+ listener: &str,
+) -> Option {
+ let content: TxpoolContent = match provider.client().request_noparams("txpool_content").await {
+ Ok(c) => c,
+ Err(e) => {
+ error!(error = %e, "failed to get Reth txpool_content");
+ metrics.count_with_tags(
+ metrics::MEMPOOL_ERROR,
+ 1,
+ &[("listener", listener), ("client", "reth"), ("error", "snapshot_failed")],
+ );
+ return None;
+ }
+ };
+
+ Some(MempoolSnapshot {
+ pending_count: count_pool_txs(content.get("pending")),
+ queued_count: count_pool_txs(content.get("queued")),
+ ..Default::default()
+ })
+}
+
+/// Geth reclassification: returns `(remaining_pending, reclassified)`.
+fn reclassify_for_address(
+ txs_by_nonce: &HashMap,
+ base_fee: u128,
+) -> (i64, i64) {
+ let total = txs_by_nonce.len() as i64;
+ if total == 0 {
+ return (0, 0);
+ }
+
+ // Sort by nonce.
+ let mut nonces: Vec<(u64, &MempoolTransaction)> = txs_by_nonce
+ .iter()
+ .filter_map(|(nonce_str, tx)| {
+ let nonce_str = nonce_str.strip_prefix("0x").unwrap_or(nonce_str);
+ u64::from_str_radix(nonce_str, 16).ok().map(|n| (n, tx))
+ })
+ .collect();
+ nonces.sort_by_key(|(n, _)| *n);
+
+ if nonces.is_empty() {
+ return (total, 0);
+ }
+
+ // Find first underpriced tx — everything from there on is reclassified.
+ nonces.iter().position(|(_, tx)| is_underpriced(tx, base_fee)).map_or((total, 0), |idx| {
+ let pending = idx as i64;
+ (pending, nonces.len() as i64 - pending)
+ })
+}
+
+fn is_underpriced(tx: &MempoolTransaction, base_fee: u128) -> bool {
+ let gas_price_str = if tx.tx_type == "0x2" && !tx.max_fee_per_gas.is_empty() {
+ &tx.max_fee_per_gas
+ } else {
+ &tx.gas_price
+ };
+
+ if gas_price_str.is_empty() {
+ return false;
+ }
+
+ let hex = gas_price_str.strip_prefix("0x").unwrap_or(gas_price_str);
+ u128::from_str_radix(hex, 16).is_ok_and(|price| price < base_fee)
+}
+
+fn report_snapshot(snap: &MempoolSnapshot, client: &str, metrics: &Metrics, listener: &str) {
+ let tags = [("listener", listener), ("client", client)];
+ metrics.gauge_with_tags(metrics::MEMPOOL_PENDING_COUNT, snap.pending_count as f64, &tags);
+ metrics.gauge_with_tags(metrics::MEMPOOL_QUEUED_COUNT, snap.queued_count as f64, &tags);
+
+ if client == "geth" {
+ metrics.gauge_with_tags(
+ metrics::MEMPOOL_RECLASSIFIED_COUNT,
+ snap.reclassified_count as f64,
+ &tags,
+ );
+ metrics.gauge_with_tags(
+ metrics::MEMPOOL_ORIGINAL_PENDING_COUNT,
+ snap.original_pending_count as f64,
+ &tags,
+ );
+ }
+
+ metrics.count_with_tags(metrics::MEMPOOL_COLLECTION_SUCCESS, 1, &tags);
+}
+
+fn compare_snapshots(
+ geth: &MempoolSnapshot,
+ reth: &MempoolSnapshot,
+ metrics: &Metrics,
+ listener: &str,
+) {
+ let pending_diff = geth.pending_count - reth.pending_count;
+ let queued_diff = geth.queued_count - reth.queued_count;
+ let total_geth = geth.pending_count + geth.queued_count;
+ let total_reth = reth.pending_count + reth.queued_count;
+ let total_diff = total_geth - total_reth;
+
+ let tags = [("listener", listener)];
+ metrics.gauge_with_tags(metrics::MEMPOOL_PENDING_DIFF, pending_diff as f64, &tags);
+ metrics.gauge_with_tags(metrics::MEMPOOL_QUEUED_DIFF, queued_diff as f64, &tags);
+ metrics.gauge_with_tags(metrics::MEMPOOL_TOTAL_DIFF, total_diff as f64, &tags);
+
+ info!(
+ listener,
+ geth_pending = geth.pending_count,
+ reth_pending = reth.pending_count,
+ pending_diff,
+ geth_queued = geth.queued_count,
+ reth_queued = reth.queued_count,
+ queued_diff,
+ total_diff,
+ "mempool comparison"
+ );
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // ── count_pool_txs ───────────────────────────────────────
+
+ #[test]
+ fn count_pool_txs_none() {
+ assert_eq!(count_pool_txs(None), 0);
+ }
+
+ #[test]
+ fn count_pool_txs_empty_map() {
+ let pool: TxPool = HashMap::new();
+ assert_eq!(count_pool_txs(Some(&pool)), 0);
+ }
+
+ #[test]
+ fn count_pool_txs_counts_all() {
+ let mut pool: TxPool = HashMap::new();
+ // Address "0xAA" with 2 txs
+ let mut aa_txs = HashMap::new();
+ aa_txs.insert(
+ "0x0".to_string(),
+ MempoolTransaction {
+ gas_price: "0x3b9aca00".to_string(),
+ max_fee_per_gas: String::new(),
+ tx_type: "0x0".to_string(),
+ },
+ );
+ aa_txs.insert(
+ "0x1".to_string(),
+ MempoolTransaction {
+ gas_price: "0x3b9aca00".to_string(),
+ max_fee_per_gas: String::new(),
+ tx_type: "0x0".to_string(),
+ },
+ );
+ pool.insert("0xAA".to_string(), aa_txs);
+ // Address "0xBB" with 1 tx
+ let mut bb_txs = HashMap::new();
+ bb_txs.insert(
+ "0x0".to_string(),
+ MempoolTransaction {
+ gas_price: "0x3b9aca00".to_string(),
+ max_fee_per_gas: String::new(),
+ tx_type: "0x0".to_string(),
+ },
+ );
+ pool.insert("0xBB".to_string(), bb_txs);
+
+ assert_eq!(count_pool_txs(Some(&pool)), 3);
+ }
+
+ // ── is_underpriced ───────────────────────────────────────
+
+ #[test]
+ fn is_underpriced_type2_below_base() {
+ // EIP-1559 tx with max_fee < base_fee → underpriced
+ let tx = MempoolTransaction {
+ gas_price: String::new(),
+ max_fee_per_gas: "0x5".to_string(), // 5 wei
+ tx_type: "0x2".to_string(),
+ };
+ assert!(is_underpriced(&tx, 10)); // base_fee = 10
+ }
+
+ #[test]
+ fn is_underpriced_type2_above_base() {
+ let tx = MempoolTransaction {
+ gas_price: String::new(),
+ max_fee_per_gas: "0x14".to_string(), // 20 wei
+ tx_type: "0x2".to_string(),
+ };
+ assert!(!is_underpriced(&tx, 10));
+ }
+
+ #[test]
+ fn is_underpriced_legacy_above_base() {
+ let tx = MempoolTransaction {
+ gas_price: "0x3b9aca00".to_string(), // 1 Gwei
+ max_fee_per_gas: String::new(),
+ tx_type: "0x0".to_string(),
+ };
+ assert!(!is_underpriced(&tx, 1_000)); // base_fee = 1000 wei
+ }
+
+ #[test]
+ fn is_underpriced_legacy_below_base() {
+ let tx = MempoolTransaction {
+ gas_price: "0x1".to_string(), // 1 wei
+ max_fee_per_gas: String::new(),
+ tx_type: "0x0".to_string(),
+ };
+ assert!(is_underpriced(&tx, 1_000)); // base_fee = 1000 wei
+ }
+
+ #[test]
+ fn is_underpriced_empty_gas_price() {
+ let tx = MempoolTransaction {
+ gas_price: String::new(),
+ max_fee_per_gas: String::new(),
+ tx_type: "0x0".to_string(),
+ };
+ // Empty gas price → not underpriced (can't parse)
+ assert!(!is_underpriced(&tx, 10));
+ }
+
+ // ── reclassify_for_address ───────────────────────────────
+
+ #[test]
+ fn reclassify_all_priced_ok() {
+ let mut txs: HashMap = HashMap::new();
+ // Two txs both above base_fee
+ txs.insert(
+ "0x0".to_string(),
+ MempoolTransaction {
+ gas_price: "0x64".to_string(), // 100 wei
+ max_fee_per_gas: String::new(),
+ tx_type: "0x0".to_string(),
+ },
+ );
+ txs.insert(
+ "0x1".to_string(),
+ MempoolTransaction {
+ gas_price: "0xc8".to_string(), // 200 wei
+ max_fee_per_gas: String::new(),
+ tx_type: "0x0".to_string(),
+ },
+ );
+
+ let (pending, reclassified) = reclassify_for_address(&txs, 50); // base_fee = 50
+ assert_eq!(pending, 2);
+ assert_eq!(reclassified, 0);
+ }
+
+ #[test]
+ fn reclassify_first_underpriced_cascades() {
+ let mut txs: HashMap = HashMap::new();
+ // Nonce 0: underpriced (1 wei < 50 base fee)
+ txs.insert(
+ "0x0".to_string(),
+ MempoolTransaction {
+ gas_price: "0x1".to_string(),
+ max_fee_per_gas: String::new(),
+ tx_type: "0x0".to_string(),
+ },
+ );
+ // Nonce 1: properly priced but after an underpriced one → still reclassified
+ txs.insert(
+ "0x1".to_string(),
+ MempoolTransaction {
+ gas_price: "0x64".to_string(),
+ max_fee_per_gas: String::new(),
+ tx_type: "0x0".to_string(),
+ },
+ );
+ // Nonce 2: also properly priced
+ txs.insert(
+ "0x2".to_string(),
+ MempoolTransaction {
+ gas_price: "0xc8".to_string(),
+ max_fee_per_gas: String::new(),
+ tx_type: "0x0".to_string(),
+ },
+ );
+
+ let (pending, reclassified) = reclassify_for_address(&txs, 50);
+ // First tx (nonce 0) is underpriced → all 3 reclassified, 0 pending
+ assert_eq!(pending, 0);
+ assert_eq!(reclassified, 3);
+ }
+
+ #[test]
+ fn reclassify_middle_underpriced_cascades() {
+ let mut txs: HashMap = HashMap::new();
+ // Nonce 0: properly priced
+ txs.insert(
+ "0x0".to_string(),
+ MempoolTransaction {
+ gas_price: "0x64".to_string(),
+ max_fee_per_gas: String::new(),
+ tx_type: "0x0".to_string(),
+ },
+ );
+ // Nonce 1: underpriced
+ txs.insert(
+ "0x1".to_string(),
+ MempoolTransaction {
+ gas_price: "0x1".to_string(),
+ max_fee_per_gas: String::new(),
+ tx_type: "0x0".to_string(),
+ },
+ );
+ // Nonce 2: properly priced but after an underpriced one
+ txs.insert(
+ "0x2".to_string(),
+ MempoolTransaction {
+ gas_price: "0x64".to_string(),
+ max_fee_per_gas: String::new(),
+ tx_type: "0x0".to_string(),
+ },
+ );
+
+ let (pending, reclassified) = reclassify_for_address(&txs, 50);
+ assert_eq!(pending, 1); // Only nonce 0 is pending
+ assert_eq!(reclassified, 2); // Nonce 1 and 2 reclassified
+ }
+
+ #[test]
+ fn reclassify_empty_map() {
+ let txs: HashMap = HashMap::new();
+ let (pending, reclassified) = reclassify_for_address(&txs, 50);
+ assert_eq!(pending, 0);
+ assert_eq!(reclassified, 0);
+ }
+
+ // ── compare_snapshots ────────────────────────────────────
+
+ #[test]
+ fn compare_snapshots_diffs() {
+ let m = Metrics::noop();
+ let geth = MempoolSnapshot {
+ pending_count: 100,
+ queued_count: 50,
+ reclassified_count: 10,
+ original_pending_count: 110,
+ };
+ let reth = MempoolSnapshot { pending_count: 80, queued_count: 30, ..Default::default() };
+
+ // This should not panic. The diffs are:
+ // pending_diff = 100 - 80 = 20
+ // queued_diff = 50 - 30 = 20
+ // total_diff = (100+50) - (80+30) = 40
+ compare_snapshots(&geth, &reth, &m, "test_listener");
+ }
+
+ #[test]
+ fn compare_snapshots_negative_diff() {
+ let m = Metrics::noop();
+ let geth = MempoolSnapshot { pending_count: 10, queued_count: 5, ..Default::default() };
+ let reth = MempoolSnapshot { pending_count: 50, queued_count: 30, ..Default::default() };
+
+ // Negative diffs should not panic.
+ compare_snapshots(&geth, &reth, &m, "test_listener");
+ }
+
+ // ── report_snapshot ──────────────────────────────────────
+
+ #[test]
+ fn report_snapshot_does_not_panic() {
+ let m = Metrics::noop();
+ let snap = MempoolSnapshot {
+ pending_count: 42,
+ queued_count: 13,
+ reclassified_count: 5,
+ original_pending_count: 47,
+ };
+ report_snapshot(&snap, "geth", &m, "test_listener");
+ report_snapshot(&snap, "reth", &m, "test_listener");
+ }
+}
diff --git a/crates/rpc-collector/src/services/mod.rs b/crates/rpc-collector/src/services/mod.rs
new file mode 100644
index 0000000..6a3427b
--- /dev/null
+++ b/crates/rpc-collector/src/services/mod.rs
@@ -0,0 +1,39 @@
+//! Service trait and implementations for all rpc-collector services.
+//!
+//! Every long-running component of rpc-collector implements [`Service`],
+//! enabling the binary to build them via a factory and spawn them uniformly.
+
+pub mod collector;
+mod divergence_checker;
+mod flashblock_validator;
+mod health_checker;
+mod health_server;
+mod mempool_listener;
+mod snapshots;
+
+use tokio::task::JoinSet;
+use tokio_util::sync::CancellationToken;
+
+/// A self-contained service that can be spawned onto a [`JoinSet`].
+///
+/// Every long-running component of rpc-collector implements this trait,
+/// enabling the binary to build them via a factory and spawn them uniformly.
+pub trait Service: Send {
+ /// Human-readable name, used in logs.
+ fn name(&self) -> &str;
+
+ /// Spawn this service as one or more tasks on the given [`JoinSet`].
+ fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken);
+}
+
+// Re-export all public types from service modules.
+pub use collector::{
+ l1::{L1Collector, L1CollectorConfig},
+ l2::{L2Collector, L2CollectorConfig},
+};
+pub use divergence_checker::DivergenceCheckerService;
+pub use flashblock_validator::FlashblockValidator;
+pub use health_checker::{HealthChecker, Node, build_node_list, build_node_list_sanitized};
+pub use health_server::HealthServer;
+pub use mempool_listener::MempoolListenerService;
+pub use snapshots::SnapshotsService;
diff --git a/crates/rpc-collector/src/services/snapshots.rs b/crates/rpc-collector/src/services/snapshots.rs
new file mode 100644
index 0000000..e4b184b
--- /dev/null
+++ b/crates/rpc-collector/src/services/snapshots.rs
@@ -0,0 +1,211 @@
+//! Snapshots collector — periodically polls S3 bucket endpoints to check
+//! snapshot freshness, age, and size.
+
+use std::time::Duration;
+
+use tokio::task::JoinSet;
+use tokio_util::sync::CancellationToken;
+use tracing::{error, info};
+
+use super::Service;
+use crate::metrics::{self, Metrics};
+
+// ── Service ──────────────────────────────────────────────────
+
+/// Snapshot monitoring service, implementing [`Service`].
+#[derive(Debug)]
+pub struct SnapshotsService {
+ /// S3 bucket URLs to monitor.
+ pub buckets: Vec,
+ /// Interval between snapshot checks.
+ pub poll_interval: Duration,
+ /// Metrics client.
+ pub metrics: Metrics,
+}
+
+impl Service for SnapshotsService {
+ fn name(&self) -> &str {
+ "snapshots"
+ }
+
+ fn spawn(self: Box, set: &mut JoinSet<()>, cancel: CancellationToken) {
+ if self.buckets.is_empty() {
+ info!("no snapshot buckets configured, skipping snapshots collector");
+ return;
+ }
+ set.spawn(async move {
+ info!("starting snapshots collector");
+ if let Err(e) = run(self.buckets, self.poll_interval, self.metrics, cancel).await {
+ error!(error = %e, "snapshots collector failed");
+ }
+ });
+ }
+}
+
+// ── Core logic ───────────────────────────────────────────────
+
+/// Run the snapshots collector loop.
+async fn run(
+ buckets: Vec,
+ poll_interval: Duration,
+ metrics: Metrics,
+ cancel: CancellationToken,
+) -> anyhow::Result<()> {
+ let http_client = reqwest::Client::builder().timeout(Duration::from_secs(30)).build()?;
+
+ // Record once immediately, then enter the polling loop.
+ record_metrics_in_parallel(&http_client, &buckets, &metrics).await;
+
+ let mut interval = tokio::time::interval(poll_interval);
+ interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+ // Skip the first tick since we already recorded above.
+ interval.tick().await;
+
+ loop {
+ tokio::select! {
+ _ = cancel.cancelled() => return Ok(()),
+ _ = interval.tick() => {
+ record_metrics_in_parallel(&http_client, &buckets, &metrics).await;
+ }
+ }
+ }
+}
+
+/// Fetch snapshot data for every bucket in parallel.
+async fn record_metrics_in_parallel(
+ http_client: &reqwest::Client,
+ buckets: &[String],
+ metrics: &Metrics,
+) {
+ let mut set = tokio::task::JoinSet::new();
+
+ for bucket in buckets {
+ let client = http_client.clone();
+ let m = metrics.clone();
+ let bucket_url = bucket.clone();
+
+ set.spawn(async move {
+ check_bucket(&client, &bucket_url, &m).await;
+ });
+ }
+
+ while set.join_next().await.is_some() {}
+}
+
+/// Extract a 10-digit Unix timestamp from a snapshot filename.
+///
+/// Looks for a 10-digit number immediately before `.tar.gz` or `.tar.zst`.
+/// e.g. `snapshot-1234567890.tar.zst` → `Some(1234567890)`.
+fn parse_snapshot_timestamp(filename: &str) -> Option {
+ let stem = filename.strip_suffix(".tar.gz").or_else(|| filename.strip_suffix(".tar.zst"))?;
+ // The timestamp is the last 10 chars of the stem (or follows a separator).
+ let digits: String = stem.chars().rev().take_while(|c| c.is_ascii_digit()).collect();
+ let digits: String = digits.chars().rev().collect();
+ if digits.len() == 10 { digits.parse().ok() } else { None }
+}
+
+/// Check a single S3 bucket endpoint for its latest snapshot.
+async fn check_bucket(http_client: &reqwest::Client, bucket_url: &str, metrics: &Metrics) {
+ let tags: [(&str, &str); 1] = [("bucket", bucket_url)];
+
+ info!(bucket = bucket_url, "fetching snapshot information");
+
+ // 1. Fetch the "latest" file to get the snapshot filename.
+ let latest_url = format!("{bucket_url}/latest");
+ let res = match http_client.get(&latest_url).send().await {
+ Ok(r) => r,
+ Err(e) => {
+ error!(error = %e, bucket = bucket_url, "error fetching snapshot latest");
+ metrics.incr_with_tags(metrics::SNAPSHOT_ERROR, &tags);
+ return;
+ }
+ };
+
+ if !res.status().is_success() {
+ error!(
+ bucket = bucket_url,
+ status = %res.status(),
+ "non-200 response fetching snapshot latest"
+ );
+ metrics.incr_with_tags(metrics::SNAPSHOT_ERROR, &tags);
+ return;
+ }
+
+ let body = match res.text().await {
+ Ok(b) => b,
+ Err(e) => {
+ error!(error = %e, bucket = bucket_url, "error reading latest file body");
+ metrics.incr_with_tags(metrics::SNAPSHOT_ERROR, &tags);
+ return;
+ }
+ };
+
+ let file_name = body.trim();
+
+ // 2. Parse the 10-digit Unix timestamp from the filename.
+ let timestamp = match parse_snapshot_timestamp(file_name) {
+ Some(t) => t,
+ None => {
+ error!(bucket = bucket_url, file = file_name, "invalid snapshot filename");
+ metrics.incr_with_tags(metrics::SNAPSHOT_ERROR, &tags);
+ return;
+ }
+ };
+
+ // 3. HEAD request to get the file size.
+ let file_url = format!("{bucket_url}/{file_name}");
+ let head_res = match http_client.head(&file_url).send().await {
+ Ok(r) => r,
+ Err(e) => {
+ error!(error = %e, bucket = bucket_url, "error fetching snapshot HEAD");
+ metrics.incr_with_tags(metrics::SNAPSHOT_ERROR, &tags);
+ return;
+ }
+ };
+
+ if !head_res.status().is_success() {
+ error!(
+ bucket = bucket_url,
+ status = %head_res.status(),
+ "non-200 response for snapshot HEAD"
+ );
+ metrics.incr_with_tags(metrics::SNAPSHOT_ERROR, &tags);
+ return;
+ }
+
+ let content_length = head_res.content_length().unwrap_or(0);
+
+ // 4. Compute age and emit metrics.
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs();
+ let age_secs = now.saturating_sub(timestamp) as f64;
+
+ metrics.gauge_with_tags(metrics::SNAPSHOT_SUCCESS, timestamp as f64, &tags);
+ metrics.gauge_with_tags(metrics::SNAPSHOT_AGE, age_secs, &tags);
+ metrics.gauge_with_tags(metrics::SNAPSHOT_SIZE, content_length as f64, &tags);
+
+ info!(
+ bucket = bucket_url,
+ file = file_name,
+ timestamp = timestamp,
+ size = content_length,
+ "fetched snapshot information"
+ );
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_snapshot_timestamp() {
+ assert_eq!(parse_snapshot_timestamp("snapshot-1234567890.tar.zst"), Some(1234567890));
+ assert_eq!(parse_snapshot_timestamp("snapshot-1234567890.tar.gz"), Some(1234567890));
+ assert_eq!(parse_snapshot_timestamp("1234567890.tar.zst"), Some(1234567890));
+ assert_eq!(parse_snapshot_timestamp("bad.tar.zst"), None);
+ assert_eq!(parse_snapshot_timestamp("snapshot-123.tar.zst"), None); // too short
+ assert_eq!(parse_snapshot_timestamp("snapshot.txt"), None);
+ }
+}
diff --git a/crates/rpc-collector/src/utils.rs b/crates/rpc-collector/src/utils.rs
new file mode 100644
index 0000000..1c40501
--- /dev/null
+++ b/crates/rpc-collector/src/utils.rs
@@ -0,0 +1,119 @@
+//! Utility helpers for the RPC Collector.
+
+use alloy_primitives::{Address, U256, utils::Unit};
+
+/// OP Stack deposit (system) transaction type (`0x7E`).
+///
+/// Transactions with this type are injected by the sequencer and should
+/// generally be excluded from user-facing metrics (priority fees, L2 fees, etc.).
+pub const DEPOSIT_TX_TYPE: u8 = 126;
+
+/// Convert a wei amount to the given [`Unit`] as an `f64`.
+///
+/// Uses direct numeric division — no string formatting round-trip.
+/// For very large values this may lose precision, but it is sufficient
+/// for metric reporting.
+pub fn wei_to_unit(wei: U256, unit: Unit) -> f64 {
+ let divisor = 10_f64.powi(unit.get() as i32);
+ u128::try_from(wei).unwrap_or(u128::MAX) as f64 / divisor
+}
+
+/// Parsed contract-balance call triple (`metric|address|calldata`).
+#[derive(Debug, Clone)]
+pub struct ContractBalanceCall {
+ pub metric: String,
+ pub address: Address,
+ pub calldata: Vec,
+}
+
+/// Parse `metric|address|calldata` triples from a string slice.
+pub fn parse_contract_balance_calls(raw: &[String]) -> anyhow::Result> {
+ raw.iter()
+ .map(|v| {
+ let parts: Vec<&str> = v.split('|').collect();
+ if parts.len() != 3 {
+ anyhow::bail!("invalid contract balance call flag: {v}");
+ }
+ Ok(ContractBalanceCall {
+ metric: parts[0].to_string(),
+ address: parts[1].parse()?,
+ calldata: alloy_primitives::hex::decode(parts[2])?,
+ })
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // ── wei_to_unit ──────────────────────────────────────────
+
+ #[test]
+ fn wei_to_unit_one_ether() {
+ let one_eth = U256::from(1_000_000_000_000_000_000u128);
+ let result = wei_to_unit(one_eth, Unit::ETHER);
+ assert!((result - 1.0).abs() < 1e-12, "expected ~1.0, got {result}");
+ }
+
+ #[test]
+ fn wei_to_unit_one_gwei() {
+ let result = wei_to_unit(U256::from(1_000_000_000u128), Unit::GWEI);
+ assert!((result - 1.0).abs() < 1e-12, "expected ~1.0, got {result}");
+ }
+
+ #[test]
+ fn wei_to_unit_gwei_large() {
+ // 100 Gwei = 100e9 wei
+ let result = wei_to_unit(U256::from(100_000_000_000u128), Unit::GWEI);
+ assert!((result - 100.0).abs() < 1e-9, "expected ~100.0, got {result}");
+ }
+
+ // ── parse_contract_balance_calls ─────────────────────────
+
+ #[test]
+ fn parse_contract_balance_calls_valid() {
+ let input =
+ vec!["my.metric|0x0000000000000000000000000000000000000001|deadbeef".to_string()];
+ let result = parse_contract_balance_calls(&input).unwrap();
+ assert_eq!(result.len(), 1);
+ assert_eq!(result[0].metric, "my.metric");
+ assert_eq!(
+ result[0].address,
+ "0x0000000000000000000000000000000000000001".parse::().unwrap()
+ );
+ assert_eq!(result[0].calldata, vec![0xde, 0xad, 0xbe, 0xef]);
+ }
+
+ #[test]
+ fn parse_contract_balance_calls_invalid_parts() {
+ // Only two parts instead of three
+ let input = vec!["my.metric|0x0000000000000000000000000000000000000001".to_string()];
+ assert!(parse_contract_balance_calls(&input).is_err());
+ }
+
+ #[test]
+ fn parse_contract_balance_calls_invalid_address() {
+ let input = vec!["my.metric|not_an_address|deadbeef".to_string()];
+ assert!(parse_contract_balance_calls(&input).is_err());
+ }
+
+ #[test]
+ fn parse_contract_balance_calls_empty() {
+ let input: Vec = vec![];
+ let result = parse_contract_balance_calls(&input).unwrap();
+ assert!(result.is_empty());
+ }
+
+ #[test]
+ fn parse_contract_balance_calls_multiple() {
+ let input = vec![
+ "metric.a|0x0000000000000000000000000000000000000001|aa".to_string(),
+ "metric.b|0x0000000000000000000000000000000000000002|bb".to_string(),
+ ];
+ let result = parse_contract_balance_calls(&input).unwrap();
+ assert_eq!(result.len(), 2);
+ assert_eq!(result[0].metric, "metric.a");
+ assert_eq!(result[1].metric, "metric.b");
+ }
+}