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()); + } }