From d7de94a08930e66ef55482fb7e3e775247950441 Mon Sep 17 00:00:00 2001 From: obchain Date: Mon, 20 Apr 2026 15:33:04 +0530 Subject: [PATCH 1/4] feat(scanner): add ChainProvider with HTTP RPC + test-connection CLI command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First concrete network code. ChainProvider wraps a chain's HTTP RPC via alloy's ProviderBuilder::on_builtin, which auto-selects the transport from the URL scheme. Today it's HTTP-only — WebSocket follows in the next chunk alongside the block listener. Add `test-connection --chain ` subcommand that connects to the named chain and prints its latest block number. Verified against the public BSC endpoint: charon: connected — latest block chain=bnb block=93617402 charon-cli now depends on charon-scanner. Refs #6 --- Cargo.lock | 8 ++++ crates/charon-cli/Cargo.toml | 1 + crates/charon-cli/src/main.rs | 21 ++++++++- crates/charon-scanner/Cargo.toml | 7 +++ crates/charon-scanner/src/lib.rs | 4 ++ crates/charon-scanner/src/provider.rs | 61 +++++++++++++++++++++++++++ 6 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 crates/charon-scanner/src/provider.rs diff --git a/Cargo.lock b/Cargo.lock index a9c1953..5f48dbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1127,6 +1127,7 @@ version = "0.1.0" dependencies = [ "anyhow", "charon-core", + "charon-scanner", "clap", "dotenvy", "tokio", @@ -1148,6 +1149,13 @@ dependencies = [ [[package]] name = "charon-scanner" version = "0.1.0" +dependencies = [ + "alloy", + "anyhow", + "charon-core", + "tokio", + "tracing", +] [[package]] name = "clap" diff --git a/crates/charon-cli/Cargo.toml b/crates/charon-cli/Cargo.toml index 1c594a6..e80e544 100644 --- a/crates/charon-cli/Cargo.toml +++ b/crates/charon-cli/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" [dependencies] charon-core = { workspace = true } +charon-scanner = { workspace = true } clap = { workspace = true } tokio = { workspace = true } anyhow = { workspace = true } diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index ecc7c69..4d7dba6 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -2,12 +2,14 @@ //! //! ```text //! charon --config config/default.toml listen +//! charon --config config/default.toml test-connection --chain bnb //! ``` use std::path::PathBuf; use anyhow::{Context, Result}; use charon_core::Config; +use charon_scanner::ChainProvider; use clap::{Parser, Subcommand}; use tracing::info; use tracing_subscriber::EnvFilter; @@ -27,8 +29,15 @@ struct Cli { #[derive(Subcommand, Debug)] enum Command { /// Listen to chain events and track positions. - /// (Scanner wiring arrives in Day 2 — for now this just loads config.) + /// (Scanner wiring lands across multiple M1 issues — currently a stub.) Listen, + + /// Connect to a configured chain and print its latest block number. + TestConnection { + /// Chain key (must match a `[chain.]` section in the config). + #[arg(long, default_value = "bnb")] + chain: String, + }, } #[tokio::main] @@ -62,7 +71,15 @@ async fn main() -> Result<()> { match cli.command { Command::Listen => { - info!("listen: not wired up yet — scanner arrives in Day 2"); + info!("listen: not wired up yet — scanner arrives across M1 issues"); + } + Command::TestConnection { chain } => { + let chain_cfg = config.chain.get(&chain).with_context(|| { + format!("chain '{chain}' not found in config") + })?; + let provider = ChainProvider::connect(&chain, chain_cfg).await?; + let block = provider.test_connection().await?; + info!(chain = %chain, block = block, "connected — latest block"); } } diff --git a/crates/charon-scanner/Cargo.toml b/crates/charon-scanner/Cargo.toml index d0f7ef9..f17080b 100644 --- a/crates/charon-scanner/Cargo.toml +++ b/crates/charon-scanner/Cargo.toml @@ -4,3 +4,10 @@ version.workspace = true edition.workspace = true license.workspace = true description = "Chain listener and health-factor scanner for Charon" + +[dependencies] +charon-core = { workspace = true } +alloy = { workspace = true } +anyhow = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } diff --git a/crates/charon-scanner/src/lib.rs b/crates/charon-scanner/src/lib.rs index 6208fab..e4e9352 100644 --- a/crates/charon-scanner/src/lib.rs +++ b/crates/charon-scanner/src/lib.rs @@ -1 +1,5 @@ //! Charon scanner — chain listener and health-factor scanner. + +pub mod provider; + +pub use provider::ChainProvider; diff --git a/crates/charon-scanner/src/provider.rs b/crates/charon-scanner/src/provider.rs new file mode 100644 index 0000000..14c402f --- /dev/null +++ b/crates/charon-scanner/src/provider.rs @@ -0,0 +1,61 @@ +//! Per-chain RPC connection surface. +//! +//! For v0.1 we wrap a single chain's HTTP provider (one-shot reads such as +//! `get_block_number` and multicall). The WebSocket provider for block +//! subscriptions is added alongside the block listener in the next issue. + +use alloy::providers::{Provider, ProviderBuilder, RootProvider}; +use alloy::transports::BoxTransport; +use anyhow::{Context, Result}; +use charon_core::config::ChainConfig; +use tracing::debug; + +/// Wraps the RPC connections for a single chain. +/// +/// The struct is intentionally owned by name so logs and errors can refer +/// to the chain by its config key (e.g. `"bnb"`). +pub struct ChainProvider { + /// Short name of the chain (matches the `[chain.]` key in config). + pub name: String, + /// HTTP provider — reliable for one-shot reads and multicall. + /// + /// `BoxTransport` erases the concrete transport type so we can swap + /// http / https / ws / wss behind one field via `on_builtin`. + http: RootProvider, +} + +impl ChainProvider { + /// Connect to the chain's HTTP RPC. + /// + /// Accepts URL schemes `http(s)://` and `ws(s)://` — alloy's + /// `on_builtin` auto-selects the right transport. + pub async fn connect( + name: impl Into, + config: &ChainConfig, + ) -> Result { + let name = name.into(); + debug!(chain = %name, url = %config.http_url, "connecting http provider"); + + let http = ProviderBuilder::new() + .on_builtin(&config.http_url) + .await + .with_context(|| { + format!( + "chain '{name}': failed to connect to {}", + config.http_url + ) + })?; + + Ok(Self { name, http }) + } + + /// Fetch the latest block number. Lightweight RPC health check. + pub async fn test_connection(&self) -> Result { + self.http + .get_block_number() + .await + .with_context(|| { + format!("chain '{}': get_block_number failed", self.name) + }) + } +} From 563639fe92278ec80e508717804cfd6fe6309f7b Mon Sep 17 00:00:00 2001 From: obchain Date: Mon, 20 Apr 2026 16:47:34 +0530 Subject: [PATCH 2/4] chore: apply cargo fmt --all across workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No functional change — purely rustfmt output. Lands ahead of upcoming WebSocket provider work so subsequent commits diff cleanly against a formatted baseline. --- crates/charon-cli/src/main.rs | 11 +++++------ crates/charon-core/src/config.rs | 4 ++-- crates/charon-core/src/lib.rs | 3 +-- crates/charon-core/src/traits.rs | 15 +++------------ crates/charon-scanner/src/provider.rs | 16 +++------------- 5 files changed, 14 insertions(+), 35 deletions(-) diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index 4d7dba6..bc5e77a 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -47,9 +47,7 @@ async fn main() -> Result<()> { // Structured logging. Override verbosity with RUST_LOG=debug etc. tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), - ) + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) .init(); let cli = Cli::parse(); @@ -74,9 +72,10 @@ async fn main() -> Result<()> { info!("listen: not wired up yet — scanner arrives across M1 issues"); } Command::TestConnection { chain } => { - let chain_cfg = config.chain.get(&chain).with_context(|| { - format!("chain '{chain}' not found in config") - })?; + let chain_cfg = config + .chain + .get(&chain) + .with_context(|| format!("chain '{chain}' not found in config"))?; let provider = ChainProvider::connect(&chain, chain_cfg).await?; let block = provider.test_connection().await?; info!(chain = %chain, block = block, "connected — latest block"); diff --git a/crates/charon-core/src/config.rs b/crates/charon-core/src/config.rs index c77848f..eee714e 100644 --- a/crates/charon-core/src/config.rs +++ b/crates/charon-core/src/config.rs @@ -99,8 +99,8 @@ fn substitute_env_vars(input: &str) -> anyhow::Result { .find('}') .ok_or_else(|| anyhow!("unterminated `${{` in config"))?; let var_name = &after[..end]; - let value = std::env::var(var_name) - .with_context(|| format!("env var `{var_name}` is not set"))?; + let value = + std::env::var(var_name).with_context(|| format!("env var `{var_name}` is not set"))?; output.push_str(&value); rest = &after[end + 1..]; } diff --git a/crates/charon-core/src/lib.rs b/crates/charon-core/src/lib.rs index a03f44e..476d796 100644 --- a/crates/charon-core/src/lib.rs +++ b/crates/charon-core/src/lib.rs @@ -7,6 +7,5 @@ pub mod types; pub use config::Config; pub use traits::LendingProtocol; pub use types::{ - FlashLoanSource, LiquidationOpportunity, LiquidationParams, Position, - ProtocolId, SwapRoute, + FlashLoanSource, LiquidationOpportunity, LiquidationParams, Position, ProtocolId, SwapRoute, }; diff --git a/crates/charon-core/src/traits.rs b/crates/charon-core/src/traits.rs index 1870ab2..349a3d0 100644 --- a/crates/charon-core/src/traits.rs +++ b/crates/charon-core/src/traits.rs @@ -26,24 +26,15 @@ pub trait LendingProtocol: Send + Sync { /// /// The scanner is responsible for maintaining the list of tracked /// borrowers; this method is a pure query over protocol state. - async fn fetch_positions( - &self, - borrowers: &[Address], - ) -> anyhow::Result>; + async fn fetch_positions(&self, borrowers: &[Address]) -> anyhow::Result>; /// Compute protocol-specific liquidation parameters for a position. /// /// Handles close-factor math (Aave's 50% cap, Compound's 100% absorb, /// etc.) and resolves any protocol-specific token addresses (e.g., Venus /// vToken addresses). - fn get_liquidation_params( - &self, - position: &Position, - ) -> anyhow::Result; + fn get_liquidation_params(&self, position: &Position) -> anyhow::Result; /// Encode the ABI calldata for `CharonLiquidator.executeLiquidation(...)`. - fn build_liquidation_calldata( - &self, - params: &LiquidationParams, - ) -> anyhow::Result>; + fn build_liquidation_calldata(&self, params: &LiquidationParams) -> anyhow::Result>; } diff --git a/crates/charon-scanner/src/provider.rs b/crates/charon-scanner/src/provider.rs index 14c402f..62bd0c5 100644 --- a/crates/charon-scanner/src/provider.rs +++ b/crates/charon-scanner/src/provider.rs @@ -29,22 +29,14 @@ impl ChainProvider { /// /// Accepts URL schemes `http(s)://` and `ws(s)://` — alloy's /// `on_builtin` auto-selects the right transport. - pub async fn connect( - name: impl Into, - config: &ChainConfig, - ) -> Result { + pub async fn connect(name: impl Into, config: &ChainConfig) -> Result { let name = name.into(); debug!(chain = %name, url = %config.http_url, "connecting http provider"); let http = ProviderBuilder::new() .on_builtin(&config.http_url) .await - .with_context(|| { - format!( - "chain '{name}': failed to connect to {}", - config.http_url - ) - })?; + .with_context(|| format!("chain '{name}': failed to connect to {}", config.http_url))?; Ok(Self { name, http }) } @@ -54,8 +46,6 @@ impl ChainProvider { self.http .get_block_number() .await - .with_context(|| { - format!("chain '{}': get_block_number failed", self.name) - }) + .with_context(|| format!("chain '{}': get_block_number failed", self.name)) } } From 17eaa9fb6986833ca86f43547891260d7602ec07 Mon Sep 17 00:00:00 2001 From: obchain Date: Mon, 20 Apr 2026 16:49:03 +0530 Subject: [PATCH 3/4] feat(scanner): connect ChainProvider over WebSocket (closes #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the HTTP builder call for an explicit WebSocket handshake via `ProviderBuilder::on_ws(WsConnect)`. WebSocket is required for the upcoming block listener's `subscribe_blocks` — polling HTTP would waste RPC quota and add latency to the hot path. - `ChainProvider::connect` now performs a WS handshake against `ws_url` - `provider()` exposes the pub-sub `RootProvider` for downstream consumers (block listener, scanner, executor) - `test_connection()` remains a lightweight `get_block_number()` probe, now over WS - Errors carry chain name + URL in context; no panics, no silent fallbacks Verified against BSC mainnet: `test-connection --chain bnb` returns the latest block number over a live wss endpoint. --- crates/charon-scanner/src/provider.rs | 61 ++++++++++++++++----------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/crates/charon-scanner/src/provider.rs b/crates/charon-scanner/src/provider.rs index 62bd0c5..bc7ca17 100644 --- a/crates/charon-scanner/src/provider.rs +++ b/crates/charon-scanner/src/provider.rs @@ -1,49 +1,60 @@ //! Per-chain RPC connection surface. //! -//! For v0.1 we wrap a single chain's HTTP provider (one-shot reads such as -//! `get_block_number` and multicall). The WebSocket provider for block -//! subscriptions is added alongside the block listener in the next issue. +//! Holds a WebSocket provider for a single chain. WebSocket is required for +//! `subscribe_blocks` / `subscribe_logs` — the scanner's hot path depends on +//! push events, not polling. One `ChainProvider` per configured chain; +//! multi-chain support is a config-driven fan-out at the call site. -use alloy::providers::{Provider, ProviderBuilder, RootProvider}; -use alloy::transports::BoxTransport; +use alloy::providers::{Provider, ProviderBuilder, RootProvider, WsConnect}; +use alloy::pubsub::PubSubFrontend; use anyhow::{Context, Result}; use charon_core::config::ChainConfig; use tracing::debug; -/// Wraps the RPC connections for a single chain. +/// WebSocket RPC wrapper for one chain. /// -/// The struct is intentionally owned by name so logs and errors can refer -/// to the chain by its config key (e.g. `"bnb"`). +/// The `name` field matches the `[chain.]` key from the config, so +/// logs and errors can be attributed to the chain by its short name +/// (e.g. `"bnb"`). pub struct ChainProvider { - /// Short name of the chain (matches the `[chain.]` key in config). + /// Short name of the chain (matches the `[chain.]` key). pub name: String, - /// HTTP provider — reliable for one-shot reads and multicall. - /// - /// `BoxTransport` erases the concrete transport type so we can swap - /// http / https / ws / wss behind one field via `on_builtin`. - http: RootProvider, + ws: RootProvider, } impl ChainProvider { - /// Connect to the chain's HTTP RPC. + /// Connect over WebSocket to the chain's RPC endpoint. /// - /// Accepts URL schemes `http(s)://` and `ws(s)://` — alloy's - /// `on_builtin` auto-selects the right transport. + /// Takes the chain's short name (for logging) and its [`ChainConfig`]. + /// Fails with a contextualized error if the WS handshake does not + /// succeed — no panics, no silent fallbacks. pub async fn connect(name: impl Into, config: &ChainConfig) -> Result { let name = name.into(); - debug!(chain = %name, url = %config.http_url, "connecting http provider"); + debug!(chain = %name, url = %config.ws_url, "connecting ws provider"); - let http = ProviderBuilder::new() - .on_builtin(&config.http_url) - .await - .with_context(|| format!("chain '{name}': failed to connect to {}", config.http_url))?; + let ws = WsConnect::new(&config.ws_url); + let provider = ProviderBuilder::new().on_ws(ws).await.with_context(|| { + format!( + "chain '{name}': failed to connect over ws to {}", + config.ws_url + ) + })?; - Ok(Self { name, http }) + Ok(Self { name, ws: provider }) + } + + /// Borrow the underlying pub-sub provider. + /// + /// Consumers (block listener, scanner, executor) use this to build + /// subscriptions and make one-shot reads without re-establishing a + /// connection. + pub fn provider(&self) -> &RootProvider { + &self.ws } - /// Fetch the latest block number. Lightweight RPC health check. + /// Fetch the latest block number over WebSocket. Lightweight health check. pub async fn test_connection(&self) -> Result { - self.http + self.ws .get_block_number() .await .with_context(|| format!("chain '{}': get_block_number failed", self.name)) From cc0519ce7c81bcefd5ae9d4fe9d89090ecf200f7 Mon Sep 17 00:00:00 2001 From: obchain Date: Wed, 22 Apr 2026 20:23:28 +0530 Subject: [PATCH 4/4] feat(scanner): redact URLs, verify chain id, connect timeout, ChainProviderT + Arc - redact_url() strips the final path segment (typically the API-key slug) before any debug! or error includes the URL. WebSocket errors and boot debug logs now show 'wss://bsc-mainnet.nodereal.io/' instead of the raw bearer-token URL. Three unit tests cover the redaction helper. - connect() wraps ProviderBuilder::on_ws in tokio::time::timeout (DEFAULT_CONNECT_TIMEOUT = 10 s; connect_with_timeout exposes a caller-chosen deadline). Unreachable RPC no longer hangs the process forever. - After the WS handshake, call eth_chainId and reject any endpoint whose chain id does not match config.chain_id. Misconfigured URLs pointing at the wrong network fail fast at boot, before any state-dependent call runs. - Introduce ChainProviderT trait (Send + Sync) + MockChainProvider so downstream scanner logic can be unit-tested without a live node. Concrete ChainProvider implements the trait. - connect() now returns Arc; a compile-time assertion pins ChainProvider: Send + Sync so accidental non-Send fields become compile errors rather than task-spawn failures. - async-trait added to charon-scanner dependencies. Closes #87 #88 #89 #90 #91 --- Cargo.lock | 1 + crates/charon-scanner/Cargo.toml | 1 + crates/charon-scanner/src/lib.rs | 2 +- crates/charon-scanner/src/provider.rs | 183 +++++++++++++++++++++++--- 4 files changed, 169 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5f48dbd..1aaab39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1152,6 +1152,7 @@ version = "0.1.0" dependencies = [ "alloy", "anyhow", + "async-trait", "charon-core", "tokio", "tracing", diff --git a/crates/charon-scanner/Cargo.toml b/crates/charon-scanner/Cargo.toml index f17080b..ee8ebd0 100644 --- a/crates/charon-scanner/Cargo.toml +++ b/crates/charon-scanner/Cargo.toml @@ -9,5 +9,6 @@ description = "Chain listener and health-factor scanner for Charon" charon-core = { workspace = true } alloy = { workspace = true } anyhow = { workspace = true } +async-trait = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } diff --git a/crates/charon-scanner/src/lib.rs b/crates/charon-scanner/src/lib.rs index e4e9352..401a66b 100644 --- a/crates/charon-scanner/src/lib.rs +++ b/crates/charon-scanner/src/lib.rs @@ -2,4 +2,4 @@ pub mod provider; -pub use provider::ChainProvider; +pub use provider::{ChainProvider, ChainProviderT, MockChainProvider}; diff --git a/crates/charon-scanner/src/provider.rs b/crates/charon-scanner/src/provider.rs index bc7ca17..bc5d3aa 100644 --- a/crates/charon-scanner/src/provider.rs +++ b/crates/charon-scanner/src/provider.rs @@ -5,42 +5,102 @@ //! push events, not polling. One `ChainProvider` per configured chain; //! multi-chain support is a config-driven fan-out at the call site. +use std::sync::Arc; +use std::time::Duration; + use alloy::providers::{Provider, ProviderBuilder, RootProvider, WsConnect}; use alloy::pubsub::PubSubFrontend; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, anyhow, bail}; +use async_trait::async_trait; use charon_core::config::ChainConfig; +use tokio::time::timeout; use tracing::debug; +/// Default deadline for the initial WebSocket handshake. +pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Trait abstraction over a chain RPC surface. +/// +/// Exists so downstream scanner logic can be unit-tested against a +/// `MockChainProvider` without a live BSC node. The concrete +/// [`ChainProvider`] is the production impl. +#[async_trait] +pub trait ChainProviderT: Send + Sync { + /// Short name of the chain (`[chain.]` key). + fn name(&self) -> &str; + /// Latest block number over the underlying transport. + async fn get_block_number(&self) -> Result; +} + /// WebSocket RPC wrapper for one chain. /// /// The `name` field matches the `[chain.]` key from the config, so /// logs and errors can be attributed to the chain by its short name -/// (e.g. `"bnb"`). +/// (e.g. `"bnb"`). Returned from [`connect`] wrapped in `Arc` so the +/// provider can be cheaply shared across tokio tasks. pub struct ChainProvider { - /// Short name of the chain (matches the `[chain.]` key). - pub name: String, + name: String, ws: RootProvider, } impl ChainProvider { - /// Connect over WebSocket to the chain's RPC endpoint. + /// Connect over WebSocket, verify chain id matches config, return `Arc`. + /// + /// Fails with a contextualized, URL-redacted error if: + /// - the WS handshake does not complete within [`DEFAULT_CONNECT_TIMEOUT`]; + /// - `eth_chainId` does not match `config.chain_id`. /// - /// Takes the chain's short name (for logging) and its [`ChainConfig`]. - /// Fails with a contextualized error if the WS handshake does not - /// succeed — no panics, no silent fallbacks. - pub async fn connect(name: impl Into, config: &ChainConfig) -> Result { + /// No panics, no silent fallbacks. Embedded API keys in the RPC URL are + /// never printed — logs show only the URL's scheme + host portion. + pub async fn connect( + name: impl Into, + config: &ChainConfig, + ) -> Result> { + Self::connect_with_timeout(name, config, DEFAULT_CONNECT_TIMEOUT).await + } + + /// As [`connect`] but with a caller-chosen deadline on the handshake. + pub async fn connect_with_timeout( + name: impl Into, + config: &ChainConfig, + deadline: Duration, + ) -> Result> { let name = name.into(); - debug!(chain = %name, url = %config.ws_url, "connecting ws provider"); + let safe_url = redact_url(&config.ws_url); + debug!(chain = %name, url = %safe_url, "connecting ws provider"); let ws = WsConnect::new(&config.ws_url); - let provider = ProviderBuilder::new().on_ws(ws).await.with_context(|| { - format!( - "chain '{name}': failed to connect over ws to {}", - config.ws_url - ) - })?; + let provider = timeout(deadline, ProviderBuilder::new().on_ws(ws)) + .await + .map_err(|_| { + anyhow!( + "chain '{name}': ws connect timed out after {}s to {safe_url}", + deadline.as_secs() + ) + })? + .with_context(|| { + format!("chain '{name}': failed to connect over ws to {safe_url}") + })?; - Ok(Self { name, ws: provider }) + // Chain id verification — reject a misconfigured endpoint pointing at + // the wrong network before any state-dependent call runs. + let actual_chain_id = provider + .get_chain_id() + .await + .with_context(|| format!("chain '{name}': eth_chainId probe failed"))?; + if actual_chain_id != config.chain_id { + bail!( + "chain '{name}': expected chain id {}, got {actual_chain_id}", + config.chain_id + ); + } + + Ok(Arc::new(Self { name, ws: provider })) + } + + /// Short name of the chain (matches the `[chain.]` key). + pub fn name(&self) -> &str { + &self.name } /// Borrow the underlying pub-sub provider. @@ -60,3 +120,92 @@ impl ChainProvider { .with_context(|| format!("chain '{}': get_block_number failed", self.name)) } } + +#[async_trait] +impl ChainProviderT for ChainProvider { + fn name(&self) -> &str { + self.name() + } + async fn get_block_number(&self) -> Result { + self.test_connection().await + } +} + +/// Compile-time assertion that `ChainProvider` is safe to share across +/// tokio tasks. +const _: fn() = || { + fn assert_send_sync() {} + assert_send_sync::(); +}; + +/// Return an RPC URL with the final path segment (commonly the API-key slug) +/// replaced by ``. Preserves scheme + host so logs stay useful. +fn redact_url(url: &str) -> String { + let (scheme_end, rest) = match url.find("://") { + Some(i) => (i + 3, &url[i + 3..]), + None => return "".to_string(), + }; + let (host, tail) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, ""), + }; + if tail.is_empty() || tail == "/" { + format!("{}{host}{tail}", &url[..scheme_end]) + } else { + format!("{}{host}/", &url[..scheme_end]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redact_url_strips_api_key_path() { + assert_eq!( + redact_url("wss://bsc-mainnet.nodereal.io/ws/v1/ABCDEFG"), + "wss://bsc-mainnet.nodereal.io/" + ); + } + + #[test] + fn redact_url_keeps_bare_host() { + assert_eq!( + redact_url("wss://bsc-rpc.publicnode.com"), + "wss://bsc-rpc.publicnode.com" + ); + } + + #[test] + fn redact_url_handles_missing_scheme() { + assert_eq!(redact_url("bsc-rpc.publicnode.com/key"), ""); + } +} + +/// In-memory [`ChainProviderT`] implementation for unit tests. +/// +/// Feeds deterministic block numbers to downstream logic without touching +/// the network. `name` defaults to `"mock"`. +pub struct MockChainProvider { + pub name: String, + pub block_number: std::sync::atomic::AtomicU64, +} + +impl MockChainProvider { + pub fn new(block_number: u64) -> Arc { + Arc::new(Self { + name: "mock".into(), + block_number: std::sync::atomic::AtomicU64::new(block_number), + }) + } +} + +#[async_trait] +impl ChainProviderT for MockChainProvider { + fn name(&self) -> &str { + &self.name + } + async fn get_block_number(&self) -> Result { + Ok(self.block_number.load(std::sync::atomic::Ordering::Relaxed)) + } +}