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