Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions protocols/stables/oracles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
)
Expand All @@ -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.

Expand All @@ -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]


Expand Down
40 changes: 37 additions & 3 deletions tests/test_stables_oracles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
62 changes: 55 additions & 7 deletions utils/pegged_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,27 @@ 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
heartbeat: int
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)
Expand Down Expand Up @@ -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",
Expand All @@ -205,15 +224,25 @@ 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",
defillama_key="ethereum:0xdC035D45d973E3EC169d2276DDab16f1e407384F",
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(
Expand All @@ -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",
Expand All @@ -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
),
Expand All @@ -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(
Expand All @@ -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
),
Expand Down