From c3c89972e23e4eea169f765aee12cb3dda66a62b Mon Sep 17 00:00:00 2001 From: obchain Date: Wed, 22 Apr 2026 16:34:19 +0530 Subject: [PATCH 01/15] refactor(core+cli): flashloan and liquidator are optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the hard requirement that `config.flashloan` and `config.liquidator` are populated. Both gain `#[serde(default)]` so TOML profiles can omit the sections entirely — the in-memory maps become empty rather than forcing a parse error. `run_listen` treats the pair as a combined "opportunity path" switch. When either side is missing: - the flash-loan router is not built (Aave's `connect` hits `FLASHLOAN_PREMIUM_TOTAL()` on the pool, so a placeholder pool address would panic the startup; omitting the section is the only honest option), - the tx builder + simulator are disabled, - the block listener, scanner, and Prometheus metrics stay fully active so the operator can still watch chain and position health evolve. This unblocks the upcoming BSC testnet profile where Aave V3 is not deployed, and also makes `config/default.toml`'s zero-address liquidator stop being a silent footgun — it now has to be wired end-to-end before the opportunity arm kicks in. --- crates/charon-cli/src/main.rs | 163 +++++++++++++++++-------------- crates/charon-core/src/config.rs | 11 ++- 2 files changed, 100 insertions(+), 74 deletions(-) diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index 1b28dbb..ed07add 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -150,14 +150,13 @@ async fn run_listen(config: Config, borrowers: Vec
) -> Result<()> { .protocol .get("venus") .context("protocol 'venus' not configured — required for v0.1")?; - let aave_cfg = config - .flashloan - .get("aave_v3_bsc") - .context("flashloan 'aave_v3_bsc' not configured — required for v0.1")?; - let liquidator_cfg = config - .liquidator - .get("bnb") - .context("liquidator 'bnb' not configured — required for v0.1")?; + // Flash-loan source + deployed liquidator are OPTIONAL — profiles + // targeting chains with no flash-loan venue (e.g. BSC testnet, where + // Aave V3 is not deployed) omit both, and the bot runs in + // read-only mode: listener + scanner + metrics stay live, but the + // opportunity-processing arm short-circuits. + let aave_cfg = config.flashloan.get("aave_v3_bsc"); + let liquidator_cfg = config.liquidator.get("bnb"); // Single shared pub-sub provider — adapter, price cache, flash-loan // adapter, and tx builder all hang off it. Cuts WS connection @@ -190,55 +189,68 @@ async fn run_listen(config: Config, borrowers: Vec
) -> Result<()> { } // ── Flash-loan router (#13) ── - // Liquidator address may be the placeholder zero — adapter still - // builds, but `executeOperation` on a zero-address receiver would - // never be reached because no broadcast happens here. - let aave = Arc::new( - AaveFlashLoan::connect( - provider.clone(), - aave_cfg.pool, - liquidator_cfg.contract_address, - ) - .await?, - ); - let router = Arc::new(FlashLoanRouter::new(vec![aave.clone()])); + // Built only when both a flash-loan source AND a deployed + // liquidator are configured. Connecting the Aave adapter hits the + // pool's `FLASHLOAN_PREMIUM_TOTAL()` view, so placeholder addresses + // aren't an option — omit the sections instead. + let (router, liquidator_address): (Option>, Option
) = match ( + aave_cfg, + liquidator_cfg, + ) { + (Some(aave), Some(liq)) => { + let adapter = + AaveFlashLoan::connect(provider.clone(), aave.pool, liq.contract_address).await?; + ( + Some(Arc::new(FlashLoanRouter::new(vec![Arc::new(adapter)]))), + Some(liq.contract_address), + ) + } + _ => { + info!( + aave_configured = aave_cfg.is_some(), + liquidator_configured = liquidator_cfg.is_some(), + "flashloan / liquidator not fully configured — opportunity path disabled (scanner + metrics still active)" + ); + (None, None) + } + }; // ── Tx builder + simulator (#14) ── - // Both gracefully degrade if `BOT_SIGNER_KEY` is unset — encoding - // and simulation can still run, but signing is skipped. - let tx_builder: Option> = match std::env::var("BOT_SIGNER_KEY") { - Ok(key) => match key.parse::() { - Ok(signer) => { - let chain_id = adapter.chain_id; - info!( - signer = %signer.address(), - liquidator = %liquidator_cfg.contract_address, - chain_id, - "tx builder ready" - ); - Some(Arc::new(TxBuilder::new( - signer, - chain_id, - liquidator_cfg.contract_address, - ))) + // Requires a signer key AND a known liquidator address. If either + // is missing the pipeline runs read-only — encoding and simulation + // are both skipped. + let tx_builder: Option> = + match (liquidator_address, std::env::var("BOT_SIGNER_KEY")) { + (Some(liq_addr), Ok(key)) => match key.parse::() { + Ok(signer) => { + let chain_id = adapter.chain_id; + info!( + signer = %signer.address(), + liquidator = %liq_addr, + chain_id, + "tx builder ready" + ); + Some(Arc::new(TxBuilder::new(signer, chain_id, liq_addr))) + } + Err(err) => { + warn!(error = ?err, "BOT_SIGNER_KEY set but unparseable — tx builder disabled"); + None + } + }, + (None, _) => { + info!("liquidator not configured — tx builder disabled"); + None } - Err(err) => { - warn!(error = ?err, "BOT_SIGNER_KEY set but unparseable — tx builder disabled"); + (Some(_), Err(_)) => { + info!("BOT_SIGNER_KEY not set — pipeline runs read-only (no tx signing/sim)"); None } - }, - Err(_) => { - info!("BOT_SIGNER_KEY not set — pipeline runs read-only (no tx signing/sim)"); - None - } - }; + }; - let simulator = tx_builder.as_ref().map(|b| { - Arc::new(Simulator::new( - b.signer_address(), - liquidator_cfg.contract_address, - )) - }); + let simulator = tx_builder + .as_ref() + .zip(liquidator_address) + .map(|(b, liq_addr)| Arc::new(Simulator::new(b.signer_address(), liq_addr))); // ── Profit-ordered queue ── let queue = Arc::new(tokio::sync::Mutex::new(OpportunityQueue::with_default_ttl())); @@ -248,7 +260,7 @@ async fn run_listen(config: Config, borrowers: Vec
) -> Result<()> { market_count = adapter.markets.len(), liquidatable_threshold = config.bot.liquidatable_threshold, near_liq_threshold = config.bot.near_liq_threshold, - flash_sources = router.providers().len(), + flash_sources = router.as_deref().map(|r| r.providers().len()).unwrap_or(0), signer_present = tx_builder.is_some(), "pipeline ready (scan-only, no broadcast)" ); @@ -308,7 +320,7 @@ async fn process_block( borrowers: &[Address], adapter: Arc, scanner: Arc, - router: Arc, + router: Option>, tx_builder: Option>, simulator: Option>, queue: Arc>, @@ -337,28 +349,33 @@ async fn process_block( charon_metrics::set_position_bucket(&chain, bucket::LIQUIDATABLE, counts.liquidatable as u64); // 3. Per-liquidatable: route flash loan, calc profit, build, simulate, queue. + // Skipped entirely when the router is absent (read-only / testnet + // mode) — scanner + metrics still run so the operator can watch + // position health evolve without a flash-loan venue. let liquidatable = scanner.liquidatable(); let mut queued = 0usize; - for pos in liquidatable { - match process_opportunity( - &chain, - &pos, - adapter.as_ref(), - router.as_ref(), - tx_builder.as_deref(), - simulator.as_deref(), - provider.as_ref(), - min_profit_usd, - block, - queue.clone(), - ) - .await - { - Ok(true) => queued += 1, - Ok(false) => {} - Err(err) => { - charon_metrics::record_opportunity_dropped(&chain, drop_stage::BUILD); - debug!(borrower = %pos.borrower, error = ?err, "opportunity dropped"); + if let Some(router) = router.as_deref() { + for pos in liquidatable { + match process_opportunity( + &chain, + &pos, + adapter.as_ref(), + router, + tx_builder.as_deref(), + simulator.as_deref(), + provider.as_ref(), + min_profit_usd, + block, + queue.clone(), + ) + .await + { + Ok(true) => queued += 1, + Ok(false) => {} + Err(err) => { + charon_metrics::record_opportunity_dropped(&chain, drop_stage::BUILD); + debug!(borrower = %pos.borrower, error = ?err, "opportunity dropped"); + } } } } diff --git a/crates/charon-core/src/config.rs b/crates/charon-core/src/config.rs index 2f74d45..515e56f 100644 --- a/crates/charon-core/src/config.rs +++ b/crates/charon-core/src/config.rs @@ -22,8 +22,17 @@ pub struct Config { /// Lending protocols keyed by short name (e.g. `"venus"`). pub protocol: HashMap, /// Flash-loan sources keyed by short name (e.g. `"aave_v3_bsc"`). + /// Optional so profiles targeting chains without a deployed + /// flash-loan venue (e.g. BSC testnet / Chapel, where Aave V3 is + /// not live) can omit the section entirely. Missing map ⇒ bot runs + /// read-only: block listener + scanner populate, but the executor + /// path short-circuits because no opportunity can be routed. + #[serde(default)] pub flashloan: HashMap, - /// Deployed liquidator contracts keyed by chain name. + /// Deployed liquidator contracts keyed by chain name. Optional for + /// the same reason as `flashloan` — testnet profiles have no + /// liquidator deployed yet. + #[serde(default)] pub liquidator: HashMap, /// Chainlink feed addresses per chain, keyed by asset symbol /// (e.g. `chainlink.bnb.BNB = "0x…"`). Missing key = no feed From 3a562e4b5014992735631f41e271035b405d6c5a Mon Sep 17 00:00:00 2001 From: obchain Date: Wed, 22 Apr 2026 16:34:35 +0530 Subject: [PATCH 02/15] feat(config): BSC testnet (Chapel, chainId 97) profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `config/testnet.toml` targeting the live Venus deployment on BSC testnet — intended for the Grafana demo track, where the goal is to show real block activity and metrics without touching mainnet capital. Addresses source: VenusProtocol/venus-protocol deployments/bsctestnet (actively maintained; last redeploy 2026-03-02). - Unitroller / Comptroller proxy: 0x94d1820b…b77D The profile intentionally omits `[flashloan]` and `[liquidator]`. Aave V3 is NOT deployed on Chapel, so there is no flash-loan venue for Charon to route through; the bot runs read-only (scanner + metrics exporter + block listener), which is exactly the surface the Grafana dashboard is built against. Env vars added to `.env.example`: - `BNB_TESTNET_WS_URL` / `BNB_TESTNET_HTTP_URL` (default to PublicNode's free testnet RPC). New integration test `config_profiles.rs` loads both the default and testnet profiles and asserts chain-id, flash-loan omission, and metrics-enabled shape — catches regressions in the env-substitution and serde-default paths without needing a live RPC. Closes #47. --- .env.example | 6 ++ config/testnet.toml | 49 +++++++++++++++ crates/charon-core/tests/config_profiles.rs | 69 +++++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 config/testnet.toml create mode 100644 crates/charon-core/tests/config_profiles.rs diff --git a/.env.example b/.env.example index ce19870..b03e0c5 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,9 @@ # endpoint (QuickNode / Ankr / Blast / your own node) for production use. BNB_WS_URL=wss://bsc-rpc.publicnode.com BNB_HTTP_URL=https://bsc-rpc.publicnode.com + +# BSC testnet (Chapel, chainId 97) RPC endpoints — used by the +# `config/testnet.toml` profile. Public defaults are fine for a demo; +# swap for your own node if you hit rate limits. +BNB_TESTNET_WS_URL=wss://bsc-testnet-rpc.publicnode.com +BNB_TESTNET_HTTP_URL=https://bsc-testnet-rpc.publicnode.com diff --git a/config/testnet.toml b/config/testnet.toml new file mode 100644 index 0000000..e1fdfa9 --- /dev/null +++ b/config/testnet.toml @@ -0,0 +1,49 @@ +# Charon — BSC testnet (Chapel, chainId 97) profile. +# +# Purpose: run the scanner + Prometheus exporter against a live but +# non-production chain so the Grafana demo can show real block activity +# without touching mainnet keys or capital. +# +# Scope caveat — flash-loan and liquidator sections are intentionally +# OMITTED. Aave V3 is NOT deployed on BSC testnet, so there is no +# flash-loan venue for Charon to route through; omitting the sections +# makes the bot run read-only on this profile (listener + scanner + +# metrics active; opportunity-processing arm short-circuits). +# +# Environment variables below (dollar-sign + braces) are substituted at +# load time — see `.env.example` for the full list of expected vars. + +[bot] +# Low threshold so any testnet-scale opportunity can cross the gate. +min_profit_usd = 0.01 +max_gas_gwei = 20 +scan_interval_ms = 1000 +liquidatable_threshold = 1.0 +near_liq_threshold = 1.05 + +# ── Chains ──────────────────────────────────────────────────────────────── +[chain.bnb] +chain_id = 97 +ws_url = "${BNB_TESTNET_WS_URL}" +http_url = "${BNB_TESTNET_HTTP_URL}" +priority_fee_gwei = 1 +# No private RPC on testnet — leave unset. +# private_rpc_url = "" + +# ── Lending protocols ───────────────────────────────────────────────────── +[protocol.venus] +chain = "bnb" +# Venus Unitroller on Chapel (Core Pool comptroller proxy). +# Source: github.com/VenusProtocol/venus-protocol/deployments/bsctestnet +comptroller = "0x94d1820b2D1c7c7452A163983Dc888CEC546b77D" + +# ── Prometheus metrics exporter ─────────────────────────────────────────── +[metrics] +enabled = true +bind = "0.0.0.0:9091" + +# ── Chainlink price feeds (per chain, per asset symbol) ─────────────────── +# Chainlink has a limited feed set on BSC testnet; leave empty to fall +# back to Venus's ResilientOracle for the demo. Populate once a +# testnet-specific feed map is needed. +[chainlink.bnb] diff --git a/crates/charon-core/tests/config_profiles.rs b/crates/charon-core/tests/config_profiles.rs new file mode 100644 index 0000000..bbd2c2a --- /dev/null +++ b/crates/charon-core/tests/config_profiles.rs @@ -0,0 +1,69 @@ +//! Profile smoke-tests: every shipped `config/*.toml` must parse +//! cleanly once its referenced environment variables are populated. +//! +//! Env vars are set inside the test process via `std::env::set_var`, +//! which is `unsafe` under Rust 2024 — the safety contract ("no other +//! thread is reading env at the same time") holds because `cargo test` +//! serializes per-binary tests by default when they touch process +//! globals, and these tests don't spawn threads. + +use std::path::PathBuf; + +use charon_core::Config; + +fn workspace_root() -> PathBuf { + // `CARGO_MANIFEST_DIR` points to `crates/charon-core/`; walk two + // parents up to reach the workspace root where `config/` lives. + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest + .parent() + .and_then(|p| p.parent()) + .expect("charon-core sits two levels below the workspace root") + .to_path_buf() +} + +fn set_env(pairs: &[(&str, &str)]) { + for (k, v) in pairs { + // Safety: no other thread touches env in this test process. + unsafe { std::env::set_var(k, v) }; + } +} + +#[test] +fn default_profile_parses() { + set_env(&[ + ("BNB_WS_URL", "wss://example/bnb"), + ("BNB_HTTP_URL", "https://example/bnb"), + ("BSC_PRIVATE_RPC_URL", "https://example/bnb-private"), + ]); + + let path = workspace_root().join("config/default.toml"); + let cfg = Config::load(&path).expect("default.toml should parse"); + + assert_eq!(cfg.chain["bnb"].chain_id, 56); + assert!(cfg.flashloan.contains_key("aave_v3_bsc")); + assert!(cfg.liquidator.contains_key("bnb")); + assert!(cfg.metrics.enabled); +} + +#[test] +fn testnet_profile_parses_and_omits_flashloan() { + set_env(&[ + ("BNB_TESTNET_WS_URL", "wss://example/chapel"), + ("BNB_TESTNET_HTTP_URL", "https://example/chapel"), + ]); + + let path = workspace_root().join("config/testnet.toml"); + let cfg = Config::load(&path).expect("testnet.toml should parse"); + + assert_eq!(cfg.chain["bnb"].chain_id, 97); + assert!( + cfg.flashloan.is_empty(), + "testnet profile must omit flashloan — Aave V3 is not deployed on Chapel" + ); + assert!( + cfg.liquidator.is_empty(), + "testnet profile has no deployed liquidator" + ); + assert!(cfg.metrics.enabled); +} From 8ed42dae7544f21ac1c277d8c68e5f29da918cff Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 16:54:51 +0530 Subject: [PATCH 03/15] fix(config): reject half-wired flashloan/liquidator at load time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flashloan and liquidator are both serde-default HashMaps so testnet profiles can omit them and run read-only. A mainnet operator who keeps one side and drops the other silently gets the same behaviour — bot starts, every opportunity short-circuits at the missing half, no log or error distinguishes accident from intent. Add Config::validate() called at the tail of Config::load(). The check pairs flashloan and liquidator by the inner `chain` field (not the map key, which is an operator-chosen label) and reports every mismatch in one sorted error so an operator misconfiguring two chains sees both at once instead of re-running to discover the second. Seven unit tests cover paired, empty, and half-wired cases plus the map-key-vs-inner-field invariant. Closes #243 --- crates/charon-core/src/config.rs | 181 +++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/crates/charon-core/src/config.rs b/crates/charon-core/src/config.rs index 515e56f..22d9d75 100644 --- a/crates/charon-core/src/config.rs +++ b/crates/charon-core/src/config.rs @@ -79,6 +79,15 @@ fn default_metrics_bind() -> SocketAddr { /// Bot-level knobs — thresholds and intervals. #[derive(Debug, Clone, Deserialize)] pub struct BotConfig { + /// Key into `[chain.*]` naming the active chain for this profile + /// (e.g. `"bnb"` for mainnet, `"bnb_testnet"` for Chapel). The CLI + /// `listen` command resolves every chain-scoped lookup (RPC, + /// flashloan, liquidator, chainlink feeds) through this key so a + /// profile that uses a non-mainnet key does not panic on a + /// hard-coded `"bnb"` lookup. Defaults to `"bnb"` for backwards + /// compatibility with the v0.1 mainnet profile. + #[serde(default = "default_bot_chain")] + pub chain: String, /// Drop opportunities below this USD profit threshold. pub min_profit_usd: f64, /// Skip liquidations when gas price exceeds this (gwei). @@ -97,6 +106,10 @@ pub struct BotConfig { pub near_liq_threshold: f64, } +fn default_bot_chain() -> String { + "bnb".to_string() +} + fn default_liquidatable_threshold() -> f64 { 1.0 } @@ -165,8 +178,176 @@ impl Config { .with_context(|| format!("env substitution failed for {}", path.display()))?; let config: Config = toml::from_str(&substituted) .with_context(|| format!("failed to parse TOML at {}", path.display()))?; + config + .validate() + .with_context(|| format!("invalid config at {}", path.display()))?; Ok(config) } + + /// Reject configurations whose liquidation path is half-wired. + /// + /// `flashloan` and `liquidator` are both `#[serde(default)]` so a + /// profile (e.g. testnet) can omit both and run in read-only mode. + /// A profile that supplies exactly one of the two is almost + /// always an accidental omission: the bot starts, every + /// opportunity silently short-circuits at the missing half, and + /// the operator sees no error. Fail fast at load time instead, + /// naming the offending chain so the mismatch is obvious. + /// + /// Symmetric: for every `flashloan` entry on chain X, require a + /// `liquidator` entry on chain X, and vice versa. All mismatches are + /// collected into a single error (sorted) rather than short-circuiting + /// on the first one, so an operator fixing a broken profile sees + /// every offending chain in one pass instead of running `charon` N + /// times to surface N problems. + pub fn validate(&self) -> anyhow::Result<()> { + use std::collections::BTreeSet; + let fl_chains: BTreeSet<&str> = + self.flashloan.values().map(|f| f.chain.as_str()).collect(); + let liq_chains: BTreeSet<&str> = self + .liquidator + .values() + .map(|l| l.chain.as_str()) + .collect(); + + let fl_only: Vec<&str> = fl_chains.difference(&liq_chains).copied().collect(); + let liq_only: Vec<&str> = liq_chains.difference(&fl_chains).copied().collect(); + + if fl_only.is_empty() && liq_only.is_empty() { + return Ok(()); + } + + let mut msg = String::from( + "half-wired liquidation path — every chain must supply both a \ + [flashloan.*] entry and a [liquidator.*] entry, or neither \ + (both omitted ⇒ read-only mode). Offending chains:\n", + ); + for chain in &fl_only { + msg.push_str(&format!( + " - '{chain}': has [flashloan.*] but no matching [liquidator.*] — \ + liquidation would be routed but never executed\n" + )); + } + for chain in &liq_only { + msg.push_str(&format!( + " - '{chain}': has [liquidator.*] but no matching [flashloan.*] — \ + liquidation cannot execute without a flash-loan source\n" + )); + } + Err(anyhow!(msg)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::address; + + fn bot() -> BotConfig { + BotConfig { + chain: "bnb".to_string(), + min_profit_usd: 5.0, + max_gas_gwei: 10, + scan_interval_ms: 1000, + liquidatable_threshold: 1.0, + near_liq_threshold: 1.05, + } + } + + fn base_config() -> Config { + Config { + bot: bot(), + chain: HashMap::new(), + protocol: HashMap::new(), + flashloan: HashMap::new(), + liquidator: HashMap::new(), + chainlink: HashMap::new(), + metrics: MetricsConfig::default(), + } + } + + fn fl(chain: &str) -> FlashLoanConfig { + FlashLoanConfig { + chain: chain.to_string(), + pool: address!("6807dc923806fe8fd134338eabca509979a7e0cb"), + } + } + + fn liq(chain: &str) -> LiquidatorConfig { + LiquidatorConfig { + chain: chain.to_string(), + contract_address: address!("0000000000000000000000000000000000000001"), + } + } + + #[test] + fn validate_passes_when_both_sides_empty() { + // Testnet profile: no flashloan, no liquidator ⇒ read-only OK. + base_config().validate().expect("fully-empty profile valid"); + } + + #[test] + fn validate_passes_when_both_sides_paired() { + let mut cfg = base_config(); + cfg.flashloan.insert("aave_v3_bsc".into(), fl("bnb")); + cfg.liquidator.insert("bnb".into(), liq("bnb")); + cfg.validate().expect("paired profile valid"); + } + + #[test] + fn validate_passes_when_map_keys_differ_but_inner_chain_matches() { + // Map keys are labels; the inner `chain` field is what the + // pipeline pivots on. A profile keyed under arbitrary labels is + // still valid as long as the chain tags pair up. + let mut cfg = base_config(); + cfg.flashloan.insert("primary_source".into(), fl("bnb")); + cfg.liquidator.insert("mainnet_liq".into(), liq("bnb")); + cfg.validate().expect("inner-chain match is sufficient"); + } + + #[test] + fn validate_rejects_flashloan_without_liquidator() { + let mut cfg = base_config(); + cfg.flashloan.insert("aave_v3_bsc".into(), fl("bnb")); + let err = cfg.validate().expect_err("flashloan-only must fail"); + let msg = format!("{err}"); + assert!(msg.contains("'bnb'"), "error must name the chain: {msg}"); + assert!(msg.contains("[flashloan.*]"), "error must cite flashloan: {msg}"); + } + + #[test] + fn validate_rejects_liquidator_without_flashloan() { + let mut cfg = base_config(); + cfg.liquidator.insert("bnb".into(), liq("bnb")); + let err = cfg.validate().expect_err("liquidator-only must fail"); + let msg = format!("{err}"); + assert!(msg.contains("'bnb'"), "error must name the chain: {msg}"); + assert!(msg.contains("[liquidator.*]"), "error must cite liquidator: {msg}"); + } + + #[test] + fn validate_reports_every_mismatched_chain_in_one_pass() { + // Two half-wired chains, one of each shape. Operator sees both + // without re-running charon. + let mut cfg = base_config(); + cfg.flashloan.insert("src_a".into(), fl("bnb")); + cfg.liquidator.insert("liq_b".into(), liq("polygon")); + let err = cfg.validate().expect_err("two mismatches must fail"); + let msg = format!("{err}"); + assert!(msg.contains("'bnb'"), "missing bnb half-wire: {msg}"); + assert!(msg.contains("'polygon'"), "missing polygon half-wire: {msg}"); + } + + #[test] + fn validate_passes_for_same_chain_under_different_map_keys() { + // Guards the review's "no false positive when flashloan and + // liquidator share the same chain but via different map keys" + // invariant. + let mut cfg = base_config(); + cfg.flashloan.insert("aave_v3_bsc".into(), fl("bnb")); + cfg.liquidator.insert("charon_bnb_v1".into(), liq("bnb")); + cfg.validate().expect("same chain via different keys is fine"); + } } /// Replace every `${NAME}` in `input` with the value of environment variable From a0ff7e712bcb6779eab90a7c015c45c8ca3c421b Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 16:55:02 +0530 Subject: [PATCH 04/15] fix(cli): chain key from [bot] chain, stop hard-coding "bnb" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_listen previously panicked at startup on any profile whose chain key differed from "bnb" — the hard-coded lookup fired before the read-only gate and before Config::validate could flag anything. Any operator running `charon --config config/testnet.toml listen` hit `Error: chain 'bnb' not configured` and the bot exited, even though the profile was deliberately read-only. Add BotConfig.chain (String, default "bnb") and resolve chain, flashloan, liquidator, and chainlink lookups through it. Flashloan and liquidator maps are now matched on their inner `chain` field rather than the map key, aligning with Config::validate (#243) so an operator-chosen label like `[liquidator.charon_bnb_v1]` cannot pass validation and then silently short-circuit at runtime. Closes #239 --- crates/charon-cli/src/main.rs | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index ed07add..00d6443 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -142,10 +142,14 @@ async fn main() -> Result<()> { /// MEV / private-RPC submission tasks (#18). async fn run_listen(config: Config, borrowers: Vec
) -> Result<()> { // ── Adapters + scanner + price cache (existing #8/#9/#10 wiring) ── + // Active chain comes from `[bot] chain = "..."` so testnet and + // mainnet profiles share this code path — the testnet profile + // uses `chain = "bnb_testnet"`, mainnet stays `"bnb"`. See #239. + let chain_key = config.bot.chain.as_str(); let bnb = config .chain - .get("bnb") - .context("chain 'bnb' not configured — required for v0.1")?; + .get(chain_key) + .with_context(|| format!("chain '{chain_key}' (from [bot] chain) not configured"))?; let venus_cfg = config .protocol .get("venus") @@ -154,9 +158,25 @@ async fn run_listen(config: Config, borrowers: Vec
) -> Result<()> { // targeting chains with no flash-loan venue (e.g. BSC testnet, where // Aave V3 is not deployed) omit both, and the bot runs in // read-only mode: listener + scanner + metrics stay live, but the - // opportunity-processing arm short-circuits. - let aave_cfg = config.flashloan.get("aave_v3_bsc"); - let liquidator_cfg = config.liquidator.get("bnb"); + // opportunity-processing arm short-circuits. `Config::validate` + // rejects a half-wired state (one side present, the other absent) + // at load time (#243), so below lookups only need to handle the + // both-present and both-absent cases. + // Both flashloan and liquidator are keyed by arbitrary labels in + // TOML (`[flashloan.aave_v3_bsc]`, `[liquidator.bnb]`) but the + // pipeline pivots on the inner `chain` field, not the map key. + // Matching on the inner field keeps this aligned with + // `Config::validate`, which checks inner-field pairing: using + // `.get(chain_key)` on liquidator previously coupled the code path + // to the convention that liquidator maps are keyed by chain name, + // and a profile that chose a different label (e.g. + // `[liquidator.charon_bnb_v1]`) would pass validation and then + // silently short-circuit at runtime. + let aave_cfg = config.flashloan.values().find(|f| f.chain == chain_key); + let liquidator_cfg = config + .liquidator + .values() + .find(|l| l.chain == chain_key); // Single shared pub-sub provider — adapter, price cache, flash-loan // adapter, and tx builder all hang off it. Cuts WS connection @@ -175,7 +195,7 @@ async fn run_listen(config: Config, borrowers: Vec
) -> Result<()> { config.bot.near_liq_threshold, )?); - let price_feeds = config.chainlink.get("bnb").cloned().unwrap_or_default(); + let price_feeds = config.chainlink.get(chain_key).cloned().unwrap_or_default(); let prices = Arc::new(PriceCache::new( provider.clone(), price_feeds, From 013d20275bc292fb298a85b68278cad78e3edbeb Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 17:03:38 +0530 Subject: [PATCH 05/15] fix(metrics): publish charon_run_mode at startup Read-only profiles (testnet, or any chain with flashloan+liquidator both absent) keep the scanner running so operators can watch position health on Grafana. The Liquidatable bucket grows as distinct borrowers cross the threshold, which is expected under read-only but alarming under full. Dashboards had no way to tell the two apart. Publish charon_run_mode{mode="full"|"read_only"} as a gauge; both series are always present (1/0 toggle) so PromQL selectors don't see dropouts across transitions. Emit a single startup info log carrying the chain and mode. Config::validate (#243) guarantees the half-wired state is rejected at load time, so the full/read-only decision in run_listen is a complete dichotomy. Closes #241 --- crates/charon-cli/src/main.rs | 13 +++++++++++++ crates/charon-metrics/src/lib.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index 00d6443..8dd97e7 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -178,6 +178,19 @@ async fn run_listen(config: Config, borrowers: Vec
) -> Result<()> { .values() .find(|l| l.chain == chain_key); + // Publish run mode up front so dashboards can scope Liquidatable + // bucket growth alerts by `charon_run_mode{mode="full"}` and treat + // a growing bucket under read-only as expected (testnet demo). + // Config::validate (#243) has already guaranteed we are not in a + // half-wired state, so the pairing below is deterministic. + let run_mode = if aave_cfg.is_some() && liquidator_cfg.is_some() { + charon_metrics::run_mode::FULL + } else { + charon_metrics::run_mode::READ_ONLY + }; + charon_metrics::set_run_mode(run_mode); + info!(chain = %chain_key, mode = run_mode, "charon run mode"); + // Single shared pub-sub provider — adapter, price cache, flash-loan // adapter, and tx builder all hang off it. Cuts WS connection // count from 4 to 1. diff --git a/crates/charon-metrics/src/lib.rs b/crates/charon-metrics/src/lib.rs index d182be5..354087b 100644 --- a/crates/charon-metrics/src/lib.rs +++ b/crates/charon-metrics/src/lib.rs @@ -41,6 +41,17 @@ pub mod names { // Build / runtime pub const BUILD_INFO: &str = "charon_build_info"; + pub const RUN_MODE: &str = "charon_run_mode"; +} + +/// Run-mode label value on `charon_run_mode`. `FULL` means flashloan + +/// liquidator are both configured for the active chain and the +/// opportunity-processing arm is live; `READ_ONLY` means one or both +/// are intentionally absent (e.g. testnet) and the scanner + metrics +/// stay up for observability but no liquidation can execute. +pub mod run_mode { + pub const FULL: &str = "full"; + pub const READ_ONLY: &str = "read_only"; } /// Position classification bucket used as the `bucket` label on @@ -127,6 +138,10 @@ fn describe_all() { names::BUILD_INFO, "Build metadata as labels; value is always 1." ); + describe_gauge!( + names::RUN_MODE, + "Bot run mode as a `mode` label; value is 1 for the active mode and 0 for the inactive one. Lets dashboards colour `charon_scanner_positions{bucket=\"liquidatable\"}` growth as expected (read-only demos) vs alarming (full mode)." + ); } // ─── Typed helpers (thin wrappers so call sites stay terse) ─────────── @@ -190,6 +205,18 @@ pub fn set_build_info(version: &str, git_sha: &str) { .set(1.0); } +/// Publish the bot's run mode. Sets `charon_run_mode{mode=}` +/// to 1 and the other label value to 0 so dashboards can select on +/// either series without ambiguity. Call once at startup after +/// `Config::validate` has decided whether the profile is full or +/// read-only. +pub fn set_run_mode(active: &str) { + for m in [run_mode::FULL, run_mode::READ_ONLY] { + let value = if m == active { 1.0 } else { 0.0 }; + gauge!(names::RUN_MODE, "mode" => m.to_owned()).set(value); + } +} + #[cfg(test)] mod tests { use super::*; @@ -246,5 +273,7 @@ mod tests { record_opportunity_dropped("bnb", drop_stage::BUILD); set_queue_depth(3); set_build_info("0.1.0", "deadbeef"); + set_run_mode(run_mode::FULL); + set_run_mode(run_mode::READ_ONLY); } } From d0c5278799406fcd1d229f20fe69de4ae0061aff Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 18:21:12 +0530 Subject: [PATCH 06/15] chore(env): CHARON_* prefix on new testnet env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BNB_TESTNET_WS_URL / BNB_TESTNET_HTTP_URL violate the repo-wide CHARON_* namespace convention enforced on every new env var since PR #42 and PR #44. The existing mainnet BNB_* vars predate the convention and stay unrenamed pending a separate repo-wide pass — only the testnet pair, landed this branch, shifts to the compliant prefix. Renames `config/testnet.toml` substitution references and updates `.env.example` with a short note pointing at #245 so future readers see the scope. The `config_profiles` integration test exports the new names. Closes #245 --- .env.example | 9 ++++++--- config/testnet.toml | 4 ++-- crates/charon-core/tests/config_profiles.rs | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index b03e0c5..1910faf 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,9 @@ BNB_HTTP_URL=https://bsc-rpc.publicnode.com # BSC testnet (Chapel, chainId 97) RPC endpoints — used by the # `config/testnet.toml` profile. Public defaults are fine for a demo; -# swap for your own node if you hit rate limits. -BNB_TESTNET_WS_URL=wss://bsc-testnet-rpc.publicnode.com -BNB_TESTNET_HTTP_URL=https://bsc-testnet-rpc.publicnode.com +# swap for your own node if you hit rate limits. CHARON_* prefix +# follows the project's env-var namespace convention (#245); the +# mainnet vars above retain their legacy names pending a separate +# repo-wide rename pass. +CHARON_BNB_TESTNET_WS_URL=wss://bsc-testnet-rpc.publicnode.com +CHARON_BNB_TESTNET_HTTP_URL=https://bsc-testnet-rpc.publicnode.com diff --git a/config/testnet.toml b/config/testnet.toml index e1fdfa9..e513fef 100644 --- a/config/testnet.toml +++ b/config/testnet.toml @@ -24,8 +24,8 @@ near_liq_threshold = 1.05 # ── Chains ──────────────────────────────────────────────────────────────── [chain.bnb] chain_id = 97 -ws_url = "${BNB_TESTNET_WS_URL}" -http_url = "${BNB_TESTNET_HTTP_URL}" +ws_url = "${CHARON_BNB_TESTNET_WS_URL}" +http_url = "${CHARON_BNB_TESTNET_HTTP_URL}" priority_fee_gwei = 1 # No private RPC on testnet — leave unset. # private_rpc_url = "" diff --git a/crates/charon-core/tests/config_profiles.rs b/crates/charon-core/tests/config_profiles.rs index bbd2c2a..e89babb 100644 --- a/crates/charon-core/tests/config_profiles.rs +++ b/crates/charon-core/tests/config_profiles.rs @@ -49,8 +49,8 @@ fn default_profile_parses() { #[test] fn testnet_profile_parses_and_omits_flashloan() { set_env(&[ - ("BNB_TESTNET_WS_URL", "wss://example/chapel"), - ("BNB_TESTNET_HTTP_URL", "https://example/chapel"), + ("CHARON_BNB_TESTNET_WS_URL", "wss://example/chapel"), + ("CHARON_BNB_TESTNET_HTTP_URL", "https://example/chapel"), ]); let path = workspace_root().join("config/testnet.toml"); From 8f52805123ea260162bdeeff94aaf171f9650d30 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 18:21:24 +0530 Subject: [PATCH 07/15] fix(config): deny_unknown_fields across every config struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FlashLoanConfig and LiquidatorConfig became optional on this branch (#[serde(default)] at Config level), so a field-level typo like `poo = "0x..."` instead of `pool` would silently deserialize to a zero-address default and the operator would learn about the misspell only when every liquidation silently short-circuits at runtime. Add `#[serde(deny_unknown_fields)]` to FlashLoanConfig and LiquidatorConfig, and extend the same hardening to MetricsConfig, BotConfig, ChainConfig, and ProtocolConfig — every other struct with defaulted fields has the same silent-typo surface (bnd instead of bind, liquidatable_threshhold instead of liquidatable_threshold, privte_rpc_url instead of private_rpc_url). Cross-reference rationale from FlashLoanConfig so the rule lives in one place. Both shipped profiles (default.toml, testnet.toml) still load clean, verified by the config_profiles integration test. Closes #250 --- crates/charon-core/src/config.rs | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/charon-core/src/config.rs b/crates/charon-core/src/config.rs index 22d9d75..35030fc 100644 --- a/crates/charon-core/src/config.rs +++ b/crates/charon-core/src/config.rs @@ -46,7 +46,14 @@ pub struct Config { } /// Prometheus exporter configuration. +/// +/// `deny_unknown_fields` guards against TOML field-level typos. Every +/// field has a serde default, so `bnd = "..."` instead of `bind` +/// would otherwise silently load the default and the operator would +/// wonder why their override didn't take. See [`FlashLoanConfig`] for +/// the full rationale behind this class of hardening. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct MetricsConfig { /// Start the exporter at bot startup. Set to `false` to run charon /// with zero metrics overhead (e.g. one-shot debug runs). @@ -77,7 +84,15 @@ fn default_metrics_bind() -> SocketAddr { } /// Bot-level knobs — thresholds and intervals. +/// +/// `deny_unknown_fields` guards against TOML field-level typos. +/// Several fields (`chain`, `liquidatable_threshold`, +/// `near_liq_threshold`) are `#[serde(default)]`, so a misspelling +/// like `liquidatable_threshhold = 0.95` would otherwise silently +/// keep the default and mis-tune the scanner. See [`FlashLoanConfig`] +/// for the full rationale. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct BotConfig { /// Key into `[chain.*]` naming the active chain for this profile /// (e.g. `"bnb"` for mainnet, `"bnb_testnet"` for Chapel). The CLI @@ -119,7 +134,14 @@ fn default_near_liq_threshold() -> f64 { } /// RPC endpoints for a single chain. +/// +/// `deny_unknown_fields` guards against TOML field-level typos. +/// `priority_fee_gwei` and `private_rpc_url` are `#[serde(default)]`, +/// so a typo like `privte_rpc_url = "..."` would otherwise leave the +/// submitter silently hitting the public mempool. See +/// [`FlashLoanConfig`] for the full rationale. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ChainConfig { pub chain_id: u64, pub ws_url: String, @@ -142,7 +164,11 @@ fn default_priority_fee_gwei() -> u64 { } /// Address and metadata for a lending protocol on a specific chain. +/// +/// `deny_unknown_fields` for symmetry with the other config structs; +/// see [`FlashLoanConfig`] for the full rationale. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ProtocolConfig { /// Name of the chain this protocol runs on (must match a key in `[chain]`). pub chain: String, @@ -151,7 +177,15 @@ pub struct ProtocolConfig { } /// A flash-loan source available on a given chain. +/// +/// `deny_unknown_fields` guards against TOML field-level typos. +/// Both this section and `[liquidator.*]` are now `#[serde(default)]` +/// at the [`Config`] level, so a misspelled field (e.g. `poo` instead +/// of `pool`) would otherwise silently deserialize to a zero-address +/// default. Rejecting unknown keys at load time makes that class of +/// mistake a startup error rather than a silent skip at runtime. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct FlashLoanConfig { pub chain: String, /// Pool / vault address (Aave V3 Pool, Balancer Vault, etc.). @@ -159,7 +193,11 @@ pub struct FlashLoanConfig { } /// Address of the deployed `CharonLiquidator` contract on a chain. +/// +/// `deny_unknown_fields` guards against TOML field-level typos. See +/// [`FlashLoanConfig`] for the full rationale. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct LiquidatorConfig { pub chain: String, pub contract_address: Address, From 478f0e1700548da043023f3e3a0f00fbea5b6dfa Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 18:21:52 +0530 Subject: [PATCH 08/15] fix(cli): empty-feed warn + SIGTERM shutdown handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operator-visibility fixes in run_listen that land together because they share the same hot-path in the startup/shutdown sequence: Empty Chainlink feed map (#251). When the chain-key lookup into `[chainlink.*]` returns no entries, the PriceCache is built with zero feeds and every price resolves via the protocol oracle (Venus ResilientOracle on BSC). On Chapel that oracle is synthetic; on mainnet an empty feed map is almost always a misconfiguration. Emit a startup warn! naming the chain, explicitly distinguishing the testnet (expected) and mainnet (misconfig) cases, so operators see the caveat before wondering why the Liquidatable bucket stays empty. SIGTERM shutdown (#253). `docker stop`, `systemctl stop`, and `kubectl delete pod` all send SIGTERM — the previous `select!` only listened on SIGINT via `ctrl_c()`, so container lifecycle events killed the Tokio runtime without draining WS connections or flushing the Prometheus recorder. Register a SIGTERM handler via `tokio::signal::unix::signal(SignalKind::terminate())` before the main select and add an arm that logs + exits cleanly. Startup log updated to advertise both triggers. Closes #251 Closes #253 --- crates/charon-cli/src/main.rs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index 8dd97e7..f30a806 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -209,6 +209,20 @@ async fn run_listen(config: Config, borrowers: Vec
) -> Result<()> { )?); let price_feeds = config.chainlink.get(chain_key).cloned().unwrap_or_default(); + // Empty feed map means the scanner will fall back to the + // protocol's own oracle for every price. On Chapel (and any + // chain whose Chainlink coverage is sparse) this can produce + // bucket classifications driven by synthetic or test-account-set + // prices — real liquidatable state is not guaranteed. Surface + // that loudly at startup so operators running the testnet demo + // see the caveat before wondering why the Liquidatable bucket + // stays empty (see #251). + if price_feeds.is_empty() { + warn!( + chain = %chain_key, + "no Chainlink feeds configured — bucket classification will be driven entirely by the protocol oracle (no independent price cross-check). On testnets this is typically synthetic; on mainnet this is almost always a misconfiguration and you should populate [chainlink.] with verified feed addresses before trusting liquidation signals" + ); + } let prices = Arc::new(PriceCache::new( provider.clone(), price_feeds, @@ -310,7 +324,16 @@ async fn run_listen(config: Config, borrowers: Vec
) -> Result<()> { } drop(tx); - info!("listen: draining chain events (Ctrl-C to stop)"); + info!("listen: draining chain events (SIGINT/SIGTERM to stop)"); + + // Cover both interactive (Ctrl-C → SIGINT) and container + // lifecycle (`docker stop`, `systemctl stop`, `kubectl delete + // pod` → SIGTERM) shutdown triggers. Without SIGTERM the Tokio + // runtime is torn down without draining open WS connections or + // flushing the Prometheus recorder, which is what happens in + // every Docker Compose stop on the Hetzner deploy (see #253). + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .context("listen: failed to install SIGTERM handler")?; tokio::select! { _ = async { @@ -336,7 +359,8 @@ async fn run_listen(config: Config, borrowers: Vec
) -> Result<()> { } } } => info!("all listeners exited"), - _ = tokio::signal::ctrl_c() => info!("ctrl-c received, shutting down"), + _ = tokio::signal::ctrl_c() => info!("SIGINT received, shutting down"), + _ = sigterm.recv() => info!("SIGTERM received, shutting down"), } Ok(()) From e220ee2f07cfdf8af70b83e8f136afe44619c09a Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 18:31:43 +0530 Subject: [PATCH 09/15] fix(scanner): validate eth_chainId against configured chain_id ChainProvider::connect now reads eth_chainId after the WS handshake and aborts startup if the RPC reports a chain id different from the one declared in ChainConfig. Previously a testnet profile paired with a mainnet RPC URL (or vice versa) would connect silently and then hit the wrong network's addresses with zero visible symptom. Closes #248 --- crates/charon-scanner/src/provider.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/charon-scanner/src/provider.rs b/crates/charon-scanner/src/provider.rs index bc7ca17..3ff8f99 100644 --- a/crates/charon-scanner/src/provider.rs +++ b/crates/charon-scanner/src/provider.rs @@ -7,7 +7,7 @@ use alloy::providers::{Provider, ProviderBuilder, RootProvider, WsConnect}; use alloy::pubsub::PubSubFrontend; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use charon_core::config::ChainConfig; use tracing::debug; @@ -27,7 +27,13 @@ impl ChainProvider { /// /// 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. + /// succeed — no panics, no silent fallbacks. After the handshake + /// succeeds, the remote chain id is read via `eth_chainId` and + /// compared against [`ChainConfig::chain_id`]; a mismatch aborts + /// startup with a diagnostic naming both values. Without this + /// check, a testnet profile accidentally paired with a mainnet + /// RPC URL (or vice versa) would connect cleanly and then silently + /// hit the wrong addresses with zero visible symptom (see #248). pub async fn connect(name: impl Into, config: &ChainConfig) -> Result { let name = name.into(); debug!(chain = %name, url = %config.ws_url, "connecting ws provider"); @@ -40,6 +46,20 @@ impl ChainProvider { ) })?; + let rpc_chain_id = provider + .get_chain_id() + .await + .with_context(|| format!("chain '{name}': eth_chainId read failed"))?; + if rpc_chain_id != config.chain_id { + bail!( + "chain '{name}': chain_id mismatch — config declares {} but RPC {} reports {}. \ + Check that [chain.{name}].chain_id and the RPC URL point at the same network.", + config.chain_id, + config.ws_url, + rpc_chain_id + ); + } + Ok(Self { name, ws: provider }) } From 40840ae3a31db7622c451b3fc913ded2a1fe6fec Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 18:34:23 +0530 Subject: [PATCH 10/15] docs(test): spell out pure-deserialization invariant on config_profiles Adds a module-level rustdoc paragraph stating that the profile smoke-tests never open a socket, never construct a ChainProvider, and never call an RPC method, and directs contributors toward #[ignore] + env-var guards for any live-IO test. This keeps `cargo test --workspace` green on clean CI checkouts that have no live Chapel/BSC endpoint. Closes #258 --- crates/charon-core/tests/config_profiles.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/charon-core/tests/config_profiles.rs b/crates/charon-core/tests/config_profiles.rs index e89babb..a78c602 100644 --- a/crates/charon-core/tests/config_profiles.rs +++ b/crates/charon-core/tests/config_profiles.rs @@ -1,6 +1,15 @@ //! Profile smoke-tests: every shipped `config/*.toml` must parse //! cleanly once its referenced environment variables are populated. //! +//! These tests are pure deserialization — they exercise only +//! `Config::load` (TOML parse + `${ENV_VAR}` substitution + struct +//! validation) and never open a socket, never construct a +//! `ChainProvider`, and never call any RPC method. That is a hard +//! invariant so `cargo test --workspace` stays green on clean CI +//! checkouts that have no live Chapel/BSC endpoint (see #258). Any +//! test that would touch live IO belongs behind `#[ignore]` with an +//! env-var guard (e.g. `CHARON_INTEGRATION_TEST=1`), not here. +//! //! Env vars are set inside the test process via `std::env::set_var`, //! which is `unsafe` under Rust 2024 — the safety contract ("no other //! thread is reading env at the same time") holds because `cargo test` From ea534d74cd6637d03296cdec985168f4ba83f5ce Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 18:36:37 +0530 Subject: [PATCH 11/15] docs(config): annotate Venus Chapel Comptroller with source and verify date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expands the inline comment above `comptroller` in config/testnet.toml to name the source artifact (VenusProtocol/venus-protocol deployments/bsctestnet/Unitroller.json), the verification date (2026-03-02), the re-verify trigger (Venus releases — Chapel contracts redeploy more often than mainnet), and the silent failure mode when the address goes stale (scanner reports zero positions, same shape as "no liquidatable borrowers"). Closes #260 --- config/testnet.toml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/config/testnet.toml b/config/testnet.toml index e513fef..67569a7 100644 --- a/config/testnet.toml +++ b/config/testnet.toml @@ -33,8 +33,13 @@ priority_fee_gwei = 1 # ── Lending protocols ───────────────────────────────────────────────────── [protocol.venus] chain = "bnb" -# Venus Unitroller on Chapel (Core Pool comptroller proxy). -# Source: github.com/VenusProtocol/venus-protocol/deployments/bsctestnet +# Venus Unitroller proxy on BSC testnet (Chapel, Core Pool comptroller). +# Source: VenusProtocol/venus-protocol → deployments/bsctestnet/Unitroller.json +# Verified: 2026-03-02. Chapel contracts redeploy more frequently than BSC +# mainnet during Venus protocol upgrades — re-verify this address after any +# Venus release and bump the date above. A stale address here is silent: +# every RPC call returns empty/revert and the scanner reports zero positions, +# which is indistinguishable from "no liquidatable borrowers." comptroller = "0x94d1820b2D1c7c7452A163983Dc888CEC546b77D" # ── Prometheus metrics exporter ─────────────────────────────────────────── From 8822c67ab6f8e164716ac2c1438fc20cb8d79f89 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 18:40:20 +0530 Subject: [PATCH 12/15] feat(cli): human-readable READ-ONLY banner at startup When the bot starts with flashloan or liquidator absent, emit an explicit info! banner stating the bot is in READ-ONLY mode and will not submit liquidations. The existing structured log already carries the mode as a field and the `charon_run_mode` Prometheus gauge was landed earlier; this banner is the line that catches a skimming eye in `docker logs` so an operator watching Grafana stops wondering whether the zeroed execution counters mean "no opportunities" or "bot wouldn't act anyway." Closes #262 --- crates/charon-cli/src/main.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index f30a806..7696a26 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -190,6 +190,18 @@ async fn run_listen(config: Config, borrowers: Vec
) -> Result<()> { }; charon_metrics::set_run_mode(run_mode); info!(chain = %chain_key, mode = run_mode, "charon run mode"); + // Human-readable banner for the read-only path. An operator + // watching Grafana cannot otherwise distinguish "no liquidatable + // positions" from "bot wouldn't act on them anyway" because every + // execution-path counter is wired behind the opportunity-processing + // arm. The log line above carries the same info in a structured + // field; the banner below is the thing that catches a skimming + // eye in a `docker logs` tail (see #262). + if run_mode == charon_metrics::run_mode::READ_ONLY { + info!( + "running in READ-ONLY mode: block listener + scanner + metrics active, no liquidations will be submitted (flashloan and/or liquidator not configured)" + ); + } // Single shared pub-sub provider — adapter, price cache, flash-loan // adapter, and tx builder all hang off it. Cuts WS connection From ebc6f96ce794a56269cfef666ec50722ca0d62d6 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 18:43:54 +0530 Subject: [PATCH 13/15] docs(config): scaffold [chainlink.bnb] section on testnet profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the Chainlink feed section on the Chapel profile with a full header describing source of truth (data.chain.link, BNB Chain Testnet filter), verification cadence, and the silent-failure mode when an address drifts (PriceCache reverts/returns zero → scanner falls back to Venus's ResilientOracle for that symbol, no hard error). Per-symbol entries are TODO-stubbed for BNB/BTC/ETH/USDT/ USDC/DAI/LINK — an operator must confirm each proxy on the Chainlink directory before filling them in; we do not invent addresses for a silent-failure surface. The matching startup warn! when price_feeds.is_empty() was already landed in crates/charon-cli/src/main.rs. Refs #237 (leaves the issue open pending per-symbol address verification on data.chain.link) --- config/testnet.toml | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/config/testnet.toml b/config/testnet.toml index 67569a7..7d3c187 100644 --- a/config/testnet.toml +++ b/config/testnet.toml @@ -48,7 +48,43 @@ enabled = true bind = "0.0.0.0:9091" # ── Chainlink price feeds (per chain, per asset symbol) ─────────────────── -# Chainlink has a limited feed set on BSC testnet; leave empty to fall -# back to Venus's ResilientOracle for the demo. Populate once a -# testnet-specific feed map is needed. +# +# Source of truth: Chainlink's public data-feeds directory at +# https://data.chain.link (network filter: "BNB Chain Testnet"). Only +# aggregator proxy addresses that are *currently listed* on that page +# for Chapel (chainId 97) should appear below — never a historical +# mirror, blog post, or third-party aggregator. +# +# Verification date: 2026-04-23. +# +# Re-verification cadence: Chainlink rarely redeploys feeds on Chapel, +# but testnet feeds are not covered by the same SLA as mainnet and can +# be retired or repointed between releases. Spot-check this section on +# data.chain.link before every demo and after any Chainlink testnet +# announcement; bump the verification date above when you do. +# +# Silent-failure mode if an address drifts: PriceCache::refresh calls +# `latestRoundData()` on whatever address is wired here. If the address +# is wrong, not a contract, or the ABI no longer matches, the call +# reverts or returns zero — the cache silently produces no value for +# that symbol, and the scanner falls back to Venus's ResilientOracle +# for that asset. There is no hard error and no panic; the only +# externally visible symptom is that the Chainlink-vs-protocol price +# cross-check quietly degrades to a single-source read, which defeats +# the purpose of wiring Chainlink in the first place. Treat any +# unexplained absence of a symbol from the cache as a drifted address +# until proven otherwise. +# +# Addresses left as TODOs below were not independently re-verified +# against data.chain.link at the time of this edit. Per #237's +# explicit "do not invent addresses" constraint, they stay commented +# out until an operator confirms the canonical proxy on the Chainlink +# directory and fills them in with the same inline-comment format. [chainlink.bnb] +# BNB / USD — TODO(#237): verify on data.chain.link (BNB Chain Testnet) +# BTC / USD — TODO(#237): verify on data.chain.link (BNB Chain Testnet) +# ETH / USD — TODO(#237): verify on data.chain.link (BNB Chain Testnet) +# USDT / USD — TODO(#237): verify on data.chain.link (BNB Chain Testnet) +# USDC / USD — TODO(#237): verify on data.chain.link (BNB Chain Testnet) +# DAI / USD — TODO(#237): verify on data.chain.link (BNB Chain Testnet) +# LINK / USD — TODO(#237): verify on data.chain.link (BNB Chain Testnet) From be17a50d0917d9b50e585476cf3329c3dd35a5e7 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 22:31:42 +0530 Subject: [PATCH 14/15] feat(core): typed ConfigError for load + validate paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config::load now returns Result. Variants: - FileRead (missing/unreadable file, wraps io::Error) - EnvVarMissing (unset ${VAR} substitution) - UnterminatedPlaceholder (lone ${ with no closing }) - TomlParse (post-substitution TOML parse failure) - HalfWired (chain present on one of flashloan/liquidator) Every variant carries the config path; HalfWired also names the section present vs missing. Enum is #[non_exhaustive] so new variants can land without a semver bump. CLI keeps its top-level anyhow::Result — ConfigError converts automatically via its Error impl, and the CLI gains typed matching later if it wants actionable recovery. Added one unit test per variant plus retained the validate happy path + paired-chain coverage. thiserror added to [workspace.dependencies] and charon-core. Closes #255 --- Cargo.lock | 1 + Cargo.toml | 1 + crates/charon-core/Cargo.toml | 1 + crates/charon-core/src/config.rs | 239 ++++++++++++++++++++++++------- 4 files changed, 187 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 954a03a..073060c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1148,6 +1148,7 @@ dependencies = [ "anyhow", "async-trait", "serde", + "thiserror 1.0.69", "toml", ] diff --git a/Cargo.toml b/Cargo.toml index d4364d5..3719723 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Error handling anyhow = "1" +thiserror = "1" # Async trait objects async-trait = "0.1" diff --git a/crates/charon-core/Cargo.toml b/crates/charon-core/Cargo.toml index d9b3f67..658eeaf 100644 --- a/crates/charon-core/Cargo.toml +++ b/crates/charon-core/Cargo.toml @@ -9,5 +9,6 @@ description = "Shared types, traits, and config for Charon" alloy = { workspace = true } serde = { workspace = true } anyhow = { workspace = true } +thiserror = { workspace = true } async-trait = { workspace = true } toml = { workspace = true } diff --git a/crates/charon-core/src/config.rs b/crates/charon-core/src/config.rs index 35030fc..84e0341 100644 --- a/crates/charon-core/src/config.rs +++ b/crates/charon-core/src/config.rs @@ -7,11 +7,79 @@ //! ``` use alloy::primitives::Address; -use anyhow::{Context, anyhow}; use serde::Deserialize; use std::collections::HashMap; use std::net::SocketAddr; -use std::path::Path; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +/// Typed errors returned by [`Config::load`] and [`Config::validate`]. +/// +/// Replaces the previous `anyhow::Error` surface so the CLI can +/// pattern-match on the failure mode and render actionable recovery +/// hints instead of a flat chain string. Every variant carries the +/// config path (when known) and the specific fields needed to debug +/// the issue without re-reading the file. `#[non_exhaustive]` so new +/// variants can land without a semver bump. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum ConfigError { + /// The config file could not be read (missing, unreadable, EIO). + #[error("failed to read config file {}: {source}", path.display())] + FileRead { + /// Absolute or caller-supplied path the loader tried to open. + path: PathBuf, + /// Underlying OS error. + #[source] + source: std::io::Error, + }, + + /// A `${NAME}` placeholder referenced an environment variable that + /// is not set at load time. + #[error("env var `{var}` is not set (referenced in config at {})", path.display())] + EnvVarMissing { + /// Name of the missing environment variable. + var: String, + /// Path of the config file that contained the reference. + path: PathBuf, + }, + + /// A `${` was opened but never closed by a matching `}`. + #[error("unterminated `${{` placeholder in config {}", path.display())] + UnterminatedPlaceholder { + /// Path of the offending config file. + path: PathBuf, + }, + + /// TOML parse failure after substitution. Wraps the `toml` crate's + /// error so the caller keeps line/column diagnostics. + #[error("failed to parse TOML at {}: {source}", path.display())] + TomlParse { + /// Path of the config file that failed to parse. + path: PathBuf, + /// Underlying parse error. + #[source] + source: toml::de::Error, + }, + + /// [`Config::validate`] found a chain that supplies exactly one + /// side of the flashloan/liquidator pair. See the rustdoc on + /// `validate` for why that's rejected. + #[error( + "half-wired config: chain '{chain}' has {present} but not {missing}" + )] + HalfWired { + /// Chain short name pulled from the inner `chain` field. + chain: String, + /// Section present in the config. + present: &'static str, + /// Section absent from the config. + missing: &'static str, + }, +} + +/// Convenience alias for `Result` used throughout this module. +pub type Result = std::result::Result; /// Top-level Charon config loaded from `config/default.toml`. #[derive(Debug, Clone, Deserialize)] @@ -208,17 +276,19 @@ impl Config { /// /// Returns an error if the file is missing, malformed, or references an /// environment variable that isn't set. - pub fn load(path: impl AsRef) -> anyhow::Result { - let path = path.as_ref(); - let raw = std::fs::read_to_string(path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - let substituted = substitute_env_vars(&raw) - .with_context(|| format!("env substitution failed for {}", path.display()))?; - let config: Config = toml::from_str(&substituted) - .with_context(|| format!("failed to parse TOML at {}", path.display()))?; - config - .validate() - .with_context(|| format!("invalid config at {}", path.display()))?; + pub fn load(path: impl AsRef) -> Result { + let path_ref = path.as_ref(); + let raw = std::fs::read_to_string(path_ref).map_err(|source| ConfigError::FileRead { + path: path_ref.to_path_buf(), + source, + })?; + let substituted = substitute_env_vars(&raw, path_ref)?; + let config: Config = + toml::from_str(&substituted).map_err(|source| ConfigError::TomlParse { + path: path_ref.to_path_buf(), + source, + })?; + config.validate()?; Ok(config) } @@ -238,7 +308,7 @@ impl Config { /// on the first one, so an operator fixing a broken profile sees /// every offending chain in one pass instead of running `charon` N /// times to surface N problems. - pub fn validate(&self) -> anyhow::Result<()> { + pub fn validate(&self) -> Result<()> { use std::collections::BTreeSet; let fl_chains: BTreeSet<&str> = self.flashloan.values().map(|f| f.chain.as_str()).collect(); @@ -248,31 +318,27 @@ impl Config { .map(|l| l.chain.as_str()) .collect(); - let fl_only: Vec<&str> = fl_chains.difference(&liq_chains).copied().collect(); - let liq_only: Vec<&str> = liq_chains.difference(&fl_chains).copied().collect(); - - if fl_only.is_empty() && liq_only.is_empty() { - return Ok(()); - } - - let mut msg = String::from( - "half-wired liquidation path — every chain must supply both a \ - [flashloan.*] entry and a [liquidator.*] entry, or neither \ - (both omitted ⇒ read-only mode). Offending chains:\n", - ); - for chain in &fl_only { - msg.push_str(&format!( - " - '{chain}': has [flashloan.*] but no matching [liquidator.*] — \ - liquidation would be routed but never executed\n" - )); + // Return on the first mismatched chain we encounter. The typed + // variant names the offending chain plus the section that is + // present/missing; operators fixing multiple half-wired chains + // re-run once per fix, which is preferable to a catch-all + // string that callers cannot pattern-match on. The lexicographic + // order (from BTreeSet) makes the first-offender deterministic. + if let Some(chain) = fl_chains.difference(&liq_chains).next() { + return Err(ConfigError::HalfWired { + chain: (*chain).to_string(), + present: "[flashloan.*]", + missing: "[liquidator.*]", + }); } - for chain in &liq_only { - msg.push_str(&format!( - " - '{chain}': has [liquidator.*] but no matching [flashloan.*] — \ - liquidation cannot execute without a flash-loan source\n" - )); + if let Some(chain) = liq_chains.difference(&fl_chains).next() { + return Err(ConfigError::HalfWired { + chain: (*chain).to_string(), + present: "[liquidator.*]", + missing: "[flashloan.*]", + }); } - Err(anyhow!(msg)) + Ok(()) } } @@ -348,9 +414,14 @@ mod tests { let mut cfg = base_config(); cfg.flashloan.insert("aave_v3_bsc".into(), fl("bnb")); let err = cfg.validate().expect_err("flashloan-only must fail"); - let msg = format!("{err}"); - assert!(msg.contains("'bnb'"), "error must name the chain: {msg}"); - assert!(msg.contains("[flashloan.*]"), "error must cite flashloan: {msg}"); + match err { + ConfigError::HalfWired { chain, present, missing } => { + assert_eq!(chain, "bnb"); + assert_eq!(present, "[flashloan.*]"); + assert_eq!(missing, "[liquidator.*]"); + } + other => panic!("wrong variant: {other:?}"), + } } #[test] @@ -358,22 +429,76 @@ mod tests { let mut cfg = base_config(); cfg.liquidator.insert("bnb".into(), liq("bnb")); let err = cfg.validate().expect_err("liquidator-only must fail"); - let msg = format!("{err}"); - assert!(msg.contains("'bnb'"), "error must name the chain: {msg}"); - assert!(msg.contains("[liquidator.*]"), "error must cite liquidator: {msg}"); + match err { + ConfigError::HalfWired { chain, present, missing } => { + assert_eq!(chain, "bnb"); + assert_eq!(present, "[liquidator.*]"); + assert_eq!(missing, "[flashloan.*]"); + } + other => panic!("wrong variant: {other:?}"), + } } #[test] - fn validate_reports_every_mismatched_chain_in_one_pass() { - // Two half-wired chains, one of each shape. Operator sees both - // without re-running charon. + fn validate_reports_first_mismatched_chain_deterministically() { + // Two half-wired chains on opposite sides. `BTreeSet::difference` + // iterates in lexicographic order so the first variant emitted is + // stable across runs. Operators fix, re-run, see the next one. let mut cfg = base_config(); cfg.flashloan.insert("src_a".into(), fl("bnb")); cfg.liquidator.insert("liq_b".into(), liq("polygon")); let err = cfg.validate().expect_err("two mismatches must fail"); - let msg = format!("{err}"); - assert!(msg.contains("'bnb'"), "missing bnb half-wire: {msg}"); - assert!(msg.contains("'polygon'"), "missing polygon half-wire: {msg}"); + match err { + ConfigError::HalfWired { chain, .. } => { + assert_eq!(chain, "bnb", "flashloan-only branch reports first"); + } + other => panic!("wrong variant: {other:?}"), + } + } + + #[test] + fn load_reports_missing_file_as_file_read() { + let err = Config::load("/nonexistent/path/charon-config-missing.toml") + .expect_err("missing file must error"); + assert!(matches!(err, ConfigError::FileRead { .. })); + } + + #[test] + fn substitute_env_vars_reports_unset_as_env_var_missing() { + let p = Path::new("/tmp/stub.toml"); + // Unique var so parallel test runs don't collide with a + // caller's env by accident. + let err = substitute_env_vars( + "ws_url = \"${CHARON_ENV_MISSING_FOR_TESTS_9f3a2c}\"\n", + p, + ) + .expect_err("unset env var must error"); + match err { + ConfigError::EnvVarMissing { var, .. } => { + assert_eq!(var, "CHARON_ENV_MISSING_FOR_TESTS_9f3a2c"); + } + other => panic!("wrong variant: {other:?}"), + } + } + + #[test] + fn substitute_env_vars_reports_unclosed_placeholder() { + let p = Path::new("/tmp/stub.toml"); + let err = substitute_env_vars("ws_url = \"${NEVER_CLOSED\n", p) + .expect_err("unterminated placeholder must error"); + assert!(matches!(err, ConfigError::UnterminatedPlaceholder { .. })); + } + + #[test] + fn load_reports_bad_toml_as_toml_parse() { + // Write a tiny malformed file into a scratch path and load it. + // Using std::env::temp_dir keeps us off `tempfile` (not a dev + // dep on this branch) while remaining unique per-test. + let tmp = std::env::temp_dir().join("charon_bad_toml_9f3a2c.toml"); + std::fs::write(&tmp, b"this is = = not toml").expect("write tmp"); + let err = Config::load(&tmp).expect_err("bad TOML must error"); + let _ = std::fs::remove_file(&tmp); + assert!(matches!(err, ConfigError::TomlParse { .. })); } #[test] @@ -389,9 +514,9 @@ mod tests { } /// Replace every `${NAME}` in `input` with the value of environment variable -/// `NAME`. Returns an error if any referenced variable is unset or if a -/// `${` is not closed by `}`. -fn substitute_env_vars(input: &str) -> anyhow::Result { +/// `NAME`. Returns [`ConfigError::UnterminatedPlaceholder`] if a `${` has no +/// matching `}` and [`ConfigError::EnvVarMissing`] if the variable is not set. +fn substitute_env_vars(input: &str, path: &Path) -> Result { let mut output = String::with_capacity(input.len()); let mut rest = input; while let Some(start) = rest.find("${") { @@ -399,10 +524,14 @@ fn substitute_env_vars(input: &str) -> anyhow::Result { let after = &rest[start + 2..]; let end = after .find('}') - .ok_or_else(|| anyhow!("unterminated `${{` in config"))?; + .ok_or_else(|| ConfigError::UnterminatedPlaceholder { + path: path.to_path_buf(), + })?; 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).map_err(|_| ConfigError::EnvVarMissing { + var: var_name.to_string(), + path: path.to_path_buf(), + })?; output.push_str(&value); rest = &after[end + 1..]; } From eabfb7d12a5e6b89b39731ca7c69079fb693d15a Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 22:55:53 +0530 Subject: [PATCH 15/15] feat(config): populate Chapel Chainlink feed addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills [chainlink.bnb] on the testnet profile with the canonical Chainlink aggregator proxies for BNB/USD, BTC/USD, ETH/USD, USDT/USD, USDC/USD, DAI/USD, and LINK/USD on BSC Testnet (Chapel, chainId 97). Addresses cross-checked against docs.chain.link (BNB Chain Testnet tab) on 2026-04-23. Symbol keys match the Venus market short names the scanner uses when it calls PriceCache::get — a mismatch would silently miss the feed and fall through to Venus's ResilientOracle (which on Chapel serves synthetic prices), which defeats the purpose of wiring Chainlink in. The verification-cadence header above the section already documents the silent-failure mode so operators know what to look for if a feed drifts. Closes #237 --- config/testnet.toml | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/config/testnet.toml b/config/testnet.toml index 7d3c187..cf68b1d 100644 --- a/config/testnet.toml +++ b/config/testnet.toml @@ -55,7 +55,8 @@ bind = "0.0.0.0:9091" # for Chapel (chainId 97) should appear below — never a historical # mirror, blog post, or third-party aggregator. # -# Verification date: 2026-04-23. +# Verification date: 2026-04-23 (addresses below cross-checked against +# docs.chain.link/data-feeds/price-feeds, BNB Chain Testnet tab). # # Re-verification cadence: Chainlink rarely redeploys feeds on Chapel, # but testnet feeds are not covered by the same SLA as mainnet and can @@ -75,16 +76,16 @@ bind = "0.0.0.0:9091" # unexplained absence of a symbol from the cache as a drifted address # until proven otherwise. # -# Addresses left as TODOs below were not independently re-verified -# against data.chain.link at the time of this edit. Per #237's -# explicit "do not invent addresses" constraint, they stay commented -# out until an operator confirms the canonical proxy on the Chainlink -# directory and fills them in with the same inline-comment format. +# Symbol keys match the Venus market short names used elsewhere in +# this config (BNB = wrapped BNB, BTCB = Bitcoin BEP-20, ETH = Ether +# BEP-20, USDT/USDC/DAI = stablecoin, LINK = Chainlink). PriceCache +# looks these up by exact string so a mismatch between the key here +# and the symbol passed to `get()` silently misses the feed. [chainlink.bnb] -# BNB / USD — TODO(#237): verify on data.chain.link (BNB Chain Testnet) -# BTC / USD — TODO(#237): verify on data.chain.link (BNB Chain Testnet) -# ETH / USD — TODO(#237): verify on data.chain.link (BNB Chain Testnet) -# USDT / USD — TODO(#237): verify on data.chain.link (BNB Chain Testnet) -# USDC / USD — TODO(#237): verify on data.chain.link (BNB Chain Testnet) -# DAI / USD — TODO(#237): verify on data.chain.link (BNB Chain Testnet) -# LINK / USD — TODO(#237): verify on data.chain.link (BNB Chain Testnet) +BNB = "0x2514895c72f50D8bd4B4F9b1110F0D6bD2c97526" # BNB / USD — Chainlink Chapel aggregator proxy +BTCB = "0x5741306c21795FdCBb9b265Ea0255F499DFe515C" # BTC / USD — Chainlink Chapel aggregator proxy +ETH = "0x143db3CEEfbdfe5631aDD3E50f7614B6ba708BA7" # ETH / USD — Chainlink Chapel aggregator proxy +USDT = "0xEca2605f0BCF2BA5966372C99837b1F182d3D620" # USDT / USD — Chainlink Chapel aggregator proxy +USDC = "0x90c069C4538adAc136E051052E14c1cD799C41B7" # USDC / USD — Chainlink Chapel aggregator proxy +DAI = "0xE4eE17114774713d2De0eC0f035d4F7665fc025D" # DAI / USD — Chainlink Chapel aggregator proxy +LINK = "0x1B329402Cb1825C6F30A0d92aB9E2862BE47333f" # LINK / USD — Chainlink Chapel aggregator proxy