From a90cb3d5629ffe46ce35e1d0d6ee8b32840f5e6f Mon Sep 17 00:00:00 2001 From: spalen0 Date: Wed, 1 Jul 2026 14:10:12 +0200 Subject: [PATCH] =?UTF-8?q?fix(pegs):=20per-feed=20oracle=E2=86=94market?= =?UTF-8?q?=20divergence=20threshold=20(fix=20cbBTC=20false=20positive)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The L2 divergence check used a flat 1% threshold, tighter than some feeds' own on-chain deviation band. A Chainlink feed only re-pushes when price moves past that band, so the oracle legitimately lags the live market by up to the band — cbBTC/USD (2% band) lagging ~1.14% tripped a HIGH alert and the emergency dispatch hook (forced_cap: 0 on coinbase markets). A false positive by design. - Add per-feed ChainlinkFeed.deviation_threshold, verified against Chainlink's reference data directory: USDC/USDT 0.25%, USDS 0.3%, USDe/WBTC-BTC/LBTC-BTC 0.5%, cbBTC 2%. - Divergence now fires at deviation_threshold + buffer. Buffer defaults to 0.25% (tight, for stable/ratio answers) and is per-feed overridable: cbBTC widens to 0.5% since its answer tracks the volatile BTC price and can overshoot the band. - Real depegs remain covered independently by check_peg_deviation. Co-Authored-By: Claude Opus 4.8 --- protocols/stables/oracles.py | 29 ++++++++++++---- tests/test_stables_oracles.py | 40 ++++++++++++++++++++-- utils/pegged_assets.py | 62 +++++++++++++++++++++++++++++++---- 3 files changed, 115 insertions(+), 16 deletions(-) diff --git a/protocols/stables/oracles.py b/protocols/stables/oracles.py index f58dea1..238b0e9 100644 --- a/protocols/stables/oracles.py +++ b/protocols/stables/oracles.py @@ -40,7 +40,11 @@ # Tunables (env-overridable). STALENESS_BUFFER = Config.get_env_int("PEG_ORACLE_STALENESS_BUFFER", 600) # 10 min grace on heartbeat -DIVERGENCE_THRESHOLD = Decimal(str(Config.get_env_float("PEG_ORACLE_DIVERGENCE_THRESHOLD", 0.01))) # 1% +# Default slack over a feed's on-chain deviation band before flagging an oracle↔market +# gap (a feed legitimately lags the market by up to its band). Tight by default — right +# for stable/ratio answers; feeds with a volatile answer (e.g. cbBTC/USD) widen it via +# ChainlinkFeed.divergence_buffer. Env-tunable as a global override for all feeds. +DIVERGENCE_BUFFER = Decimal(str(Config.get_env_float("PEG_ORACLE_DIVERGENCE_BUFFER", 0.0025))) # 0.25% RATE_DELTA_THRESHOLD = Decimal(str(Config.get_env_float("PEG_ORACLE_RATE_DELTA_THRESHOLD", 0.05))) # 5% CACHE_FILE = cache_filename @@ -170,10 +174,23 @@ def check_peg_deviation(obs: OracleObservation) -> Alert | None: ) -def check_market_divergence(obs: OracleObservation, threshold: Decimal = DIVERGENCE_THRESHOLD) -> Alert | None: - """Alert (HIGH) if the oracle and DeFiLlama market price diverge beyond ``threshold``.""" +def check_market_divergence(obs: OracleObservation, buffer: Decimal | None = None) -> Alert | None: + """Alert (HIGH) if oracle and DeFiLlama market diverge beyond the feed's band + buffer. + + A Chainlink feed only re-pushes when the price moves past its on-chain deviation + threshold, so the oracle legitimately lags the live market by up to that band. The + buffer is per-feed (``ChainlinkFeed.divergence_buffer``, else ``DIVERGENCE_BUFFER``) + — tight for stable/ratio answers, wider for volatile ones like cbBTC/USD. Only + divergence beyond ``deviation_threshold + buffer`` signals a stuck oracle or a + genuine gap; a real depeg is caught independently by ``check_peg_deviation``. + """ + feed = obs.asset.chainlink_feed + assert feed is not None if obs.market_price_usd is None or obs.market_price_usd <= 0: return None + if buffer is None: + buffer = feed.divergence_buffer if feed.divergence_buffer is not None else DIVERGENCE_BUFFER + threshold = feed.deviation_threshold + buffer dev = price_deviation(obs.oracle_price_usd, obs.market_price_usd) if abs(dev) < threshold: return None @@ -182,7 +199,8 @@ def check_market_divergence(obs: OracleObservation, threshold: Decimal = DIVERGE f"*{obs.asset.name} oracle ↔ market divergence*\n" f"Oracle: ${obs.oracle_price_usd:.6f}\n" f"Market (DeFiLlama): ${obs.market_price_usd:.6f}\n" - f"Divergence: {dev:+.2%} (threshold {threshold:.2%})", + f"Divergence: {dev:+.2%} " + f"(threshold {threshold:.2%} = {feed.deviation_threshold:.2%} feed band + {buffer:.2%} buffer)", obs.asset.protocol, channel=obs.asset.channel, ) @@ -192,7 +210,6 @@ def evaluate_chainlink_asset( obs: OracleObservation, *, buffer: int = STALENESS_BUFFER, - divergence_threshold: Decimal = DIVERGENCE_THRESHOLD, ) -> list[Alert]: """Run all Chainlink-asset checks, returning the alerts that fired. @@ -208,7 +225,7 @@ def evaluate_chainlink_asset( candidates.append(check_staleness(obs, buffer)) candidates.append(check_round_health(obs)) candidates.append(check_peg_deviation(obs)) - candidates.append(check_market_divergence(obs, divergence_threshold)) + candidates.append(check_market_divergence(obs)) return [alert for alert in candidates if alert is not None] diff --git a/tests/test_stables_oracles.py b/tests/test_stables_oracles.py index 0a8ca15..57ba125 100644 --- a/tests/test_stables_oracles.py +++ b/tests/test_stables_oracles.py @@ -125,12 +125,46 @@ def test_upside_does_not_fire_for_downside_only(self): class TestMarketDivergence(unittest.TestCase): def test_aligned_ok(self): - self.assertIsNone(check_market_divergence(_cbbtc_obs(), threshold=Decimal("0.01"))) + self.assertIsNone(check_market_divergence(_cbbtc_obs())) + + def test_within_feed_band_is_quiet(self): + # The production false positive: cbBTC feed band is 2%, so the oracle lagging + # the live market by ~1.14% is normal update lag, NOT an anomaly. + obs = _cbbtc_obs( + reading=_reading(get_asset("cbBTC").chainlink_feed.address, 59_211 * 10**8), + market_price_usd=Decimal("58544"), + ) + self.assertIsNone(check_market_divergence(obs)) # ~1.14% < 2% band + 0.5% buffer + + def test_volatile_feed_uses_wider_buffer(self): + # cbBTC overrides the buffer to 0.5%; band 2% -> trigger 2.5%. + addr = get_asset("cbBTC").chainlink_feed.address + quiet = _cbbtc_obs(reading=_reading(addr, 61_320 * 10**8), market_price_usd=Decimal("60000")) + self.assertIsNone(check_market_divergence(quiet)) # 2.2% < 2.5% + fires = _cbbtc_obs(reading=_reading(addr, 61_560 * 10**8), market_price_usd=Decimal("60000")) + self.assertIsNotNone(check_market_divergence(fires)) # 2.6% > 2.5% + + def test_stable_feed_uses_tight_default_buffer(self): + # USDC: 0.25% band + 0.25% default buffer -> 0.5% trigger (no per-feed override). + usdc = get_asset("USDC") + + def usdc_obs(oracle_usd: str, market_usd: str) -> OracleObservation: + return OracleObservation( + asset=usdc, + reading=_reading(usdc.chainlink_feed.address, int(Decimal(oracle_usd) * 10**8)), + peg_price_usd=Decimal("1"), + quote_price_usd=Decimal("1"), + now=NOW, + market_price_usd=Decimal(market_usd), + ) + + self.assertIsNone(check_market_divergence(usdc_obs("1.004", "1"))) # 0.4% < 0.5% + self.assertIsNotNone(check_market_divergence(usdc_obs("1.006", "1"))) # 0.6% > 0.5% def test_forced_divergence_fires(self): - # oracle $60,100 vs market $50,000 ~ +20% + # oracle $60,100 vs market $50,000 ~ +20% >> 2% band + buffer obs = _cbbtc_obs(market_price_usd=Decimal("50000")) - alert = check_market_divergence(obs, threshold=Decimal("0.01")) + alert = check_market_divergence(obs) self.assertIsNotNone(alert) self.assertEqual(alert.severity, AlertSeverity.HIGH) diff --git a/utils/pegged_assets.py b/utils/pegged_assets.py index cf68a66..9621a66 100644 --- a/utils/pegged_assets.py +++ b/utils/pegged_assets.py @@ -54,6 +54,18 @@ class ChainlinkFeed: round/timestamp values (some non-standard aggregators do); L2 then skips the staleness and round-health checks for that feed to avoid false positives. Verify on-chain before trusting these for a new feed. + deviation_threshold: The feed's on-chain deviation parameter — the price + move that triggers a new answer (see data.chain.link). Between updates + the oracle legitimately lags the live market by up to this band, so L2's + oracle↔market divergence alert only fires beyond ``deviation_threshold`` + + a buffer. Must be ``>=`` the feed's real band or the divergence check + false-positives on normal update lag. Defaults to 1%. + divergence_buffer: Extra slack over ``deviation_threshold`` for the L2 + oracle↔market divergence alert. ``None`` uses the global default + (``oracles.DIVERGENCE_BUFFER``, 0.25%), which suits stable/ratio answers + (USDC ≈ $1, WBTC/BTC ≈ 1.0). Set a wider value for feeds whose answer is + volatile (e.g. cbBTC/USD tracks the full BTC price), where the market can + overshoot the band before the oracle re-pushes. """ address: str @@ -61,6 +73,8 @@ class ChainlinkFeed: description: str = "" quote: PegTarget = PegTarget.USD reports_round_metadata: bool = True + deviation_threshold: Decimal = Decimal("0.01") + divergence_buffer: Decimal | None = None @dataclass(frozen=True) @@ -196,7 +210,12 @@ def get_asset(name: str) -> PeggedAsset: channel="pegs", peg=PegTarget.USD, depeg_pct=Decimal("0.02"), - chainlink_feed=ChainlinkFeed("0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6", _STABLE_HEARTBEAT, "USDC/USD"), + chainlink_feed=ChainlinkFeed( + "0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6", + _STABLE_HEARTBEAT, + "USDC/USD", + deviation_threshold=Decimal("0.0025"), # 0.25% band (data.chain.link) + ), ), PeggedAsset( name="USDT", @@ -205,7 +224,12 @@ def get_asset(name: str) -> PeggedAsset: channel="pegs", peg=PegTarget.USD, depeg_pct=Decimal("0.02"), - chainlink_feed=ChainlinkFeed("0x3E7d1eAB13ad0104d2750B8863b489D65364e32D", _STABLE_HEARTBEAT, "USDT/USD"), + chainlink_feed=ChainlinkFeed( + "0x3E7d1eAB13ad0104d2750B8863b489D65364e32D", + _STABLE_HEARTBEAT, + "USDT/USD", + deviation_threshold=Decimal("0.0025"), # 0.25% band (data.chain.link) + ), ), PeggedAsset( name="USDS", @@ -213,7 +237,12 @@ def get_asset(name: str) -> PeggedAsset: protocol="maker", peg=PegTarget.USD, depeg_pct=Decimal("0.02"), - chainlink_feed=ChainlinkFeed("0xfF30586cD0F29eD462364C7e81375FC0C71219b1", _STABLE_HEARTBEAT, "USDS/USD"), + chainlink_feed=ChainlinkFeed( + "0xfF30586cD0F29eD462364C7e81375FC0C71219b1", + _STABLE_HEARTBEAT, + "USDS/USD", + deviation_threshold=Decimal("0.003"), # 0.3% band (data.chain.link) + ), ), # --- USD-pegged protocol stables ------------------------------------------ PeggedAsset( @@ -222,7 +251,12 @@ def get_asset(name: str) -> PeggedAsset: protocol="ethena", peg=PegTarget.USD, depeg_pct=Decimal("0.03"), - chainlink_feed=ChainlinkFeed("0xa569d910839Ae8865Da8F8e70FfFb0cBA869F961", _STABLE_HEARTBEAT, "USDe/USD"), + chainlink_feed=ChainlinkFeed( + "0xa569d910839Ae8865Da8F8e70FfFb0cBA869F961", + _STABLE_HEARTBEAT, + "USDe/USD", + deviation_threshold=Decimal("0.005"), # 0.5% band (data.chain.link) + ), ), PeggedAsset( name="cUSD", @@ -247,7 +281,11 @@ def get_asset(name: str) -> PeggedAsset: peg=PegTarget.BTC, depeg_pct=Decimal("0.02"), chainlink_feed=ChainlinkFeed( - "0xfdFD9C85aD200c506Cf9e21F1FD8dd01932FBB23", _STABLE_HEARTBEAT, "WBTC/BTC", quote=PegTarget.BTC + "0xfdFD9C85aD200c506Cf9e21F1FD8dd01932FBB23", + _STABLE_HEARTBEAT, + "WBTC/BTC", + quote=PegTarget.BTC, + deviation_threshold=Decimal("0.005"), # 0.5% band (data.chain.link) ), downside_only=True, # only a drop below BTC is a risk ), @@ -258,7 +296,13 @@ def get_asset(name: str) -> PeggedAsset: channel="pegs", peg=PegTarget.BTC, depeg_pct=Decimal("0.02"), - chainlink_feed=ChainlinkFeed("0x2665701293fCbEB223D11A08D826563EDcCE423A", _STABLE_HEARTBEAT, "cbBTC/USD"), + chainlink_feed=ChainlinkFeed( + "0x2665701293fCbEB223D11A08D826563EDcCE423A", + _STABLE_HEARTBEAT, + "cbBTC/USD", + deviation_threshold=Decimal("0.02"), # 2% band (data.chain.link) — lags market up to 2% + divergence_buffer=Decimal("0.005"), # volatile USD answer; allow overshoot on fast BTC moves + ), downside_only=True, # only a drop below BTC is a risk ), PeggedAsset( @@ -270,7 +314,11 @@ def get_asset(name: str) -> PeggedAsset: depeg_pct=Decimal("0.03"), # LBTC/BTC market-rate feed (8 decimals); can sit slightly above 1 BTC. chainlink_feed=ChainlinkFeed( - "0x5c29868C58b6e15e2b962943278969Ab6a7D3212", _STABLE_HEARTBEAT, "LBTC/BTC", quote=PegTarget.BTC + "0x5c29868C58b6e15e2b962943278969Ab6a7D3212", + _STABLE_HEARTBEAT, + "LBTC/BTC", + quote=PegTarget.BTC, + deviation_threshold=Decimal("0.005"), # 0.5% band (data.chain.link) ), downside_only=True, # LBTC can trade above peg; only a drop below BTC matters ),