From a63958965d1443951fca3aab1c8b5cd08cacaac8 Mon Sep 17 00:00:00 2001 From: obchain Date: Sun, 26 Apr 2026 16:07:50 +0530 Subject: [PATCH] fix(scanner): per-feed Chainlink max_age overrides for stable-coin heartbeats (closes #331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single global DEFAULT_MAX_AGE = 600s in oracle.rs cannot fit feeds whose heartbeats span two orders of magnitude. BSC stable-coin Chainlink feeds (USDT, USDC, FDUSD) update on deviation, not heartbeat — heartbeat is ~24h — so the 600s gate routinely flags them as stale even when the price has not moved, and the freshness gate then silently drops every position priced against them. Add a top-level config section `[chainlink_max_age_secs.]` keyed by symbol → seconds. Missing entries fall through to the global default, so volatile feeds (BNB, ETH, BTCB) stay gated tightly at 600s. Wire the parsed map into `PriceCache::with_per_symbol_max_age` in the CLI bootstrap. Top-level section instead of `[chainlink..max_age_secs]`: the existing `chainlink.` table is `HashMap` with `deny_unknown_fields`, so a typed sub-table would require a breaking schema change. Doc comment on the new field justifies. Ship sane stable defaults out of the box: USDT and USDC = 86400s in both default.toml and fork.toml, matching Chainlink's documented 24h heartbeat ceiling. Operators can lower per-deployment without rebuilding. Tests: round-trip TOML deserialization + serde-default empty fallthrough. --- config/default.toml | 10 ++++++ config/fork.toml | 8 +++++ crates/charon-cli/src/main.rs | 18 +++++++++-- crates/charon-core/src/config.rs | 55 ++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) diff --git a/config/default.toml b/config/default.toml index 57b8e53..a39756b 100644 --- a/config/default.toml +++ b/config/default.toml @@ -107,3 +107,13 @@ BTCB = "0x8ECF7dE377F788A813F5215668E282556b35f300" # BTC / USD — 18-dec Aggre ETH = "0x9ef1B8c0E4F7dc8bF5719Ea496883DC6401d5b2e" USDT = "0xB97Ad0E74fa7d920791E90258A6E2085088b4320" USDC = "0x51597f405303C4377E36123cBc172b13269EA163" + +# Per-feed staleness windows (seconds). Stable-coin Chainlink feeds on +# BSC update on deviation, not heartbeat — the global 600s default +# routinely flags USDT / USDC / FDUSD as stale even when the price has +# not moved, and the freshness gate then drops every position priced +# against them. Volatile feeds (BNB / ETH / BTCB) keep the global +# default; only stables override here. See #331. +[chainlink_max_age_secs.bnb] +USDT = 86400 +USDC = 86400 diff --git a/config/fork.toml b/config/fork.toml index 9a3efad..c137030 100644 --- a/config/fork.toml +++ b/config/fork.toml @@ -100,3 +100,11 @@ BTCB = "0x8ECF7dE377F788A813F5215668E282556b35f300" # BTC / USD — 18-dec Aggre ETH = "0x9ef1B8c0E4F7dc8bF5719Ea496883DC6401d5b2e" USDT = "0xB97Ad0E74fa7d920791E90258A6E2085088b4320" USDC = "0x51597f405303C4377E36123cBc172b13269EA163" + +# Per-feed staleness windows (seconds). Stable feeds (USDT/USDC) on BSC +# are deviation-triggered — heartbeat ~24h. The global 600s default +# routinely flags them stale and silently drops every position priced +# against them. Match `default.toml`. See #331. +[chainlink_max_age_secs.bnb] +USDT = 86400 +USDC = 86400 diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index 7c4ae1a..0a02252 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -41,7 +41,7 @@ //! and a fresh scan is cheaper than reconciling retroactive bucket //! transitions. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; @@ -604,10 +604,24 @@ async fn run_listen(config: &Config, borrowers: Vec
, execute: bool) -> .get(chain_name) .cloned() .unwrap_or_default(); - let prices = Arc::new(PriceCache::new( + // Per-symbol max_age overrides: stable feeds (USDT/USDC/FDUSD) + // update on deviation, not heartbeat, so the global 600s + // default flags them as stale even when the price has not + // moved. Operators set these in `[chainlink_max_age_secs.]`. + let per_symbol_max_age: HashMap = config + .chainlink_max_age_secs + .get(chain_name) + .map(|m| { + m.iter() + .map(|(sym, secs)| (sym.clone(), Duration::from_secs(*secs))) + .collect() + }) + .unwrap_or_default(); + let prices = Arc::new(PriceCache::with_per_symbol_max_age( provider.clone(), price_feeds, DEFAULT_MAX_AGE, + per_symbol_max_age, )); prices.refresh_all().await; let fresh_feeds: Vec = prices.symbols().map(str::to_string).collect(); diff --git a/crates/charon-core/src/config.rs b/crates/charon-core/src/config.rs index fdee2c3..72cda9e 100644 --- a/crates/charon-core/src/config.rs +++ b/crates/charon-core/src/config.rs @@ -111,6 +111,20 @@ pub struct Config { /// configured, scanner falls back to protocol oracle. #[serde(default)] pub chainlink: HashMap>, + /// Per-feed Chainlink staleness window overrides, keyed by chain + /// then asset symbol (`chainlink_max_age_secs.bnb.USDT = 86400`). + /// Stable feeds (USDC/USDT/FDUSD on BSC) update on deviation, not + /// heartbeat, so the global 600s default routinely flags them as + /// stale even though the price has not moved. Missing entry = + /// fall back to the global default (`DEFAULT_MAX_AGE` / the + /// `CHARON_PRICE_MAX_AGE_SECS` env override). See #331. + /// + /// Top-level section (not nested under `[chainlink.]`) + /// because the existing `chainlink.` table is symbol-keyed + /// and mixing a typed `max_age_secs` sub-table with raw symbol + /// entries breaks serde's `HashMap` parse. + #[serde(default)] + pub chainlink_max_age_secs: HashMap>, /// Prometheus exporter configuration. Missing `[metrics]` block ⇒ /// defaults from [`MetricsConfig::default`] (enabled, port 9091). #[serde(default)] @@ -915,6 +929,7 @@ mod private_rpc_tests { flashloan: HashMap::new(), liquidator: HashMap::new(), chainlink: HashMap::new(), + chainlink_max_age_secs: HashMap::new(), metrics: MetricsConfig::default(), } } @@ -1168,6 +1183,7 @@ mod fork_profile_tests { flashloan: HashMap::new(), liquidator: HashMap::new(), chainlink: HashMap::new(), + chainlink_max_age_secs: HashMap::new(), metrics: MetricsConfig::default(), } } @@ -1268,4 +1284,43 @@ mod fork_profile_tests { assert!(!is_loopback_url("http://192.168.1.1")); assert!(!is_loopback_url("not-a-url")); } + + #[test] + fn chainlink_max_age_secs_round_trips_through_toml() { + // Round-trip the per-feed override map through the same + // serde derive Config uses. A standalone wrapper keeps the + // test focused on the new field — the rest of Config has + // a dozen required sections that are out of scope here. + #[derive(Deserialize)] + struct Wrapper { + #[serde(default)] + chainlink_max_age_secs: HashMap>, + } + + let toml_src = r#" + [chainlink_max_age_secs.bnb] + USDT = 86400 + USDC = 3600 + "#; + let w: Wrapper = toml::from_str(toml_src).expect("parse"); + let bnb = w + .chainlink_max_age_secs + .get("bnb") + .expect("bnb override section"); + assert_eq!(bnb.get("USDT"), Some(&86400u64)); + assert_eq!(bnb.get("USDC"), Some(&3600u64)); + assert_eq!(bnb.get("BNB"), None, "unspecified feed must fall through"); + } + + #[test] + fn chainlink_max_age_secs_defaults_to_empty_when_missing() { + #[derive(Deserialize)] + struct Wrapper { + #[serde(default)] + chainlink_max_age_secs: HashMap>, + } + + let w: Wrapper = toml::from_str("").expect("parse empty"); + assert!(w.chainlink_max_age_secs.is_empty()); + } }