diff --git a/protocols/3jane/README.md b/protocols/3jane/README.md index 5e3bfb3..ebd1b2d 100644 --- a/protocols/3jane/README.md +++ b/protocols/3jane/README.md @@ -7,6 +7,7 @@ - **PPS (Price Per Share):** `convertToAssets(1e6)` on USD3 and sUSD3 vs cached prior run. Alerts on any decrease — indicates loan markdowns or defaults (critical since loans are unsecured). - **TVL (Total Value Locked):** `totalAssets()` on both vaults vs cached prior run. Alerts when absolute change is **≥15%**. - **Junior Buffer Ratio:** USD3 held by sUSD3, valued in USDC, as a percentage of deployed credit (`getMarketLiquidity().totalBorrowAssets` converted from waUSDC to USDC). Alerts below **15%** — thin first-loss coverage puts the senior tranche at risk. This matches the 3Jane backing UI's `sUSD3 / Deployed` loss-buffer metric. +- **USD3 OC:** Deployed credit divided by senior at-risk credit after sUSD3 absorbs first loss: `Deployed / (Deployed - sUSD3)`. Alerts below the **111%** target as HIGH and below **106%** as CRITICAL. This excludes indirect enhancement from underlying credit-line assets and warehouse equity slices. - **Insurance Fund:** Tracks the fund's raw waUSDC share balance and alerts when an outflow is worth **≥$50k USDC**. Caching shares instead of asset value prevents waUSDC yield from masking withdrawals. - **Withdraw Liquidity:** `availableWithdrawLimit()` on the USD3 vault. Alerts when it falls below **$4M** — low withdraw liquidity means senior-tranche withdrawals may queue or stall. - **Vault Shutdown:** `isShutdown()` on both vaults. Alert-once when either vault enters emergency shutdown. @@ -31,6 +32,8 @@ | sUSD3 PPS decrease | Any decrease vs cached prior | HIGH | | TVL change | ≥15% absolute change vs prior run | LOW | | Junior buffer ratio | sUSD3 backing < 15% of deployed credit | HIGH | +| USD3 OC low | OC < 111% | HIGH | +| USD3 OC critical | OC < 106% | CRITICAL | | Insurance fund outflow | ≥$50k USDC since prior run | MEDIUM | | Withdraw liquidity low | `availableWithdrawLimit()` < $4M | MEDIUM | | Vault shutdown | `isShutdown()` transitions to true (alert-once) | CRITICAL | diff --git a/protocols/3jane/main.py b/protocols/3jane/main.py index 58c7de9..88e5714 100644 --- a/protocols/3jane/main.py +++ b/protocols/3jane/main.py @@ -9,6 +9,7 @@ - PPS (Price Per Share) for USD3 and sUSD3 — alerts on any decrease - TVL (Total Value Locked) via totalAssets() — alerts on >15% change - Junior tranche buffer — alerts when sUSD3 coverage drops below threshold +- USD3 OC — alerts when senior-tranche overcollateralization drops below thresholds - Insurance fund — alerts on waUSDC outflows of at least $50k - Withdraw liquidity — alerts when USD3 availableWithdrawLimit falls below $4M - Vault shutdown status — alerts once if either vault enters emergency shutdown @@ -70,6 +71,8 @@ # --- Thresholds --- TVL_CHANGE_THRESHOLD = 0.15 # 15% TVL change alert JUNIOR_BUFFER_THRESHOLD = 0.15 # Alert when sUSD3 backing < 15% of deployed credit +USD3_OC_HIGH_THRESHOLD = 1.11 # Alert when USD3 OC drops below the 111% target +USD3_OC_CRITICAL_THRESHOLD = 1.06 # Alert when USD3 OC drops below 106% INSURANCE_FUND_OUTFLOW_THRESHOLD = 50_000 # USDC WITHDRAW_LIMIT_THRESHOLD = 4_000_000 # USDC, alert when USD3 availableWithdrawLimit falls below @@ -232,6 +235,62 @@ def check_junior_buffer(susd3_backing: float, deployed_credit: float) -> None: send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) +def check_usd3_oc(susd3_backing: float, deployed_credit: float) -> None: + """Check senior-tranche overcollateralization from sUSD3 subordination. + + USD3 OC is deployed credit divided by senior at-risk credit after sUSD3 + absorbs first losses: deployed / (deployed - sUSD3). Alert thresholds use + the direct OC ratio, so 111% means OC is below 1.11x. + + Args: + susd3_backing: USD3 held by sUSD3, valued in USDC. + deployed_credit: Borrowed waUSDC in the credit market, converted to USDC. + """ + if deployed_credit <= 0: + return + + senior_at_risk = deployed_credit - susd3_backing + if senior_at_risk <= 0: + logger.info( + "USD3 OC: fully covered by sUSD3 backing (sUSD3: %s / deployed credit: %s)", + format_usd(susd3_backing), + format_usd(deployed_credit), + ) + return + + oc_ratio = deployed_credit / senior_at_risk + oc_excess = oc_ratio - 1 + logger.info( + "USD3 OC: %.2f%% (%.4fx; %.2f%% excess; deployed: %s / senior at-risk: %s)", + oc_ratio * 100, + oc_ratio, + oc_excess * 100, + format_usd(deployed_credit), + format_usd(senior_at_risk), + ) + + if oc_ratio < USD3_OC_CRITICAL_THRESHOLD: + severity = AlertSeverity.CRITICAL + title = "3Jane USD3 OC Critical" + threshold = USD3_OC_CRITICAL_THRESHOLD + elif oc_ratio < USD3_OC_HIGH_THRESHOLD: + severity = AlertSeverity.HIGH + title = "3Jane USD3 OC Low" + threshold = USD3_OC_HIGH_THRESHOLD + else: + return + + message = ( + f"🚨 *{title}*\n" + f"📊 USD3 OC: {oc_ratio:.2%} ({oc_ratio:.4f}x; {oc_excess:.2%} excess)\n" + f"💰 Deployed: {format_usd(deployed_credit)} | Senior at-risk: {format_usd(senior_at_risk)}\n" + f"🛡️ sUSD3 subordination: {format_usd(susd3_backing)}\n" + f"⚠️ Threshold: {threshold:.0%} OC\n" + f"🔗 [USD3](https://etherscan.io/address/{USD3_ADDRESS})" + ) + send_alert(Alert(severity, message, PROTOCOL)) + + def check_insurance_fund( previous_shares: int, current_shares: int, @@ -545,6 +604,7 @@ def main() -> None: check_pps(usd3_pps, susd3_pps) check_tvl(usd3_tvl, susd3_tvl) check_junior_buffer(susd3_backing, deployed_credit) + check_usd3_oc(susd3_backing, deployed_credit) check_insurance_fund( previous_insurance_shares, insurance_fund_shares, diff --git a/tests/conftest.py b/tests/conftest.py index ff6d4d0..55144ac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,7 @@ """ import os +import warnings from pathlib import Path import pytest @@ -44,7 +45,16 @@ def _isolate_from_live_apis(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> monkeypatch.setattr("utils.paths.CACHE_DIR", str(tmp_path)) monkeypatch.setattr("utils.cache.CACHE_DIR", str(tmp_path)) try: - from utils.web3_wrapper import ChainManager + # web3 7.16 imports websockets.legacy; keep this upstream import warning + # from hiding warnings produced by tests in this repository. + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="websockets.legacy is deprecated.*", + category=DeprecationWarning, + module="websockets.legacy", + ) + from utils.web3_wrapper import ChainManager ChainManager._instances.clear() except Exception: # noqa: BLE001 - test setup is best-effort diff --git a/tests/test_3jane.py b/tests/test_3jane.py index 84f8818..2782e1c 100644 --- a/tests/test_3jane.py +++ b/tests/test_3jane.py @@ -2,6 +2,8 @@ from pathlib import Path from types import ModuleType +import pytest + from utils import paths, store @@ -15,7 +17,7 @@ def load_3jane_module() -> ModuleType: return module -def test_junior_buffer_uses_backing_over_deployed_credit(monkeypatch) -> None: +def test_junior_buffer_uses_backing_over_deployed_credit(monkeypatch: pytest.MonkeyPatch) -> None: module = load_3jane_module() alerts: list = [] monkeypatch.setattr(module, "send_alert", alerts.append) @@ -25,7 +27,7 @@ def test_junior_buffer_uses_backing_over_deployed_credit(monkeypatch) -> None: assert alerts == [] -def test_junior_buffer_alert_describes_deployed_credit(monkeypatch) -> None: +def test_junior_buffer_alert_describes_deployed_credit(monkeypatch: pytest.MonkeyPatch) -> None: module = load_3jane_module() alerts: list = [] monkeypatch.setattr(module, "send_alert", alerts.append) @@ -38,7 +40,44 @@ def test_junior_buffer_alert_describes_deployed_credit(monkeypatch) -> None: assert "sUSD3 backing: $5.00M | Deployed: $40.00M" in alerts[0].message -def test_insurance_fund_alerts_on_large_share_outflow(monkeypatch) -> None: +def test_usd3_oc_does_not_alert_above_high_threshold(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + monkeypatch.setattr(module, "send_alert", alerts.append) + + module.check_usd3_oc(11_000_000, 100_000_000) + + assert alerts == [] + + +def test_usd3_oc_alerts_high_below_target(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + monkeypatch.setattr(module, "send_alert", alerts.append) + + module.check_usd3_oc(9_000_000, 100_000_000) + + assert len(alerts) == 1 + assert alerts[0].severity == module.AlertSeverity.HIGH + assert "USD3 OC: 109.89% (1.0989x; 9.89% excess)" in alerts[0].message + assert "Senior at-risk: $91.00M" in alerts[0].message + assert "Threshold: 111% OC" in alerts[0].message + + +def test_usd3_oc_alerts_critical_below_critical_threshold(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + monkeypatch.setattr(module, "send_alert", alerts.append) + + module.check_usd3_oc(5_000_000, 100_000_000) + + assert len(alerts) == 1 + assert alerts[0].severity == module.AlertSeverity.CRITICAL + assert "USD3 OC: 105.26% (1.0526x; 5.26% excess)" in alerts[0].message + assert "Threshold: 106% OC" in alerts[0].message + + +def test_insurance_fund_alerts_on_large_share_outflow(monkeypatch: pytest.MonkeyPatch) -> None: module = load_3jane_module() alerts: list = [] cached: list[tuple[str, int | float]] = [] @@ -53,7 +92,7 @@ def test_insurance_fund_alerts_on_large_share_outflow(monkeypatch) -> None: assert cached == [(module.CACHE_KEY_INSURANCE_FUND_SHARES, 850_000_000_000)] -def test_insurance_fund_ignores_yield_and_small_outflows(monkeypatch) -> None: +def test_insurance_fund_ignores_yield_and_small_outflows(monkeypatch: pytest.MonkeyPatch) -> None: module = load_3jane_module() alerts: list = [] monkeypatch.setattr(module, "set_cache_value", lambda _key, _value: None) @@ -65,7 +104,7 @@ def test_insurance_fund_ignores_yield_and_small_outflows(monkeypatch) -> None: assert alerts == [] -def test_withdraw_limit_alerts_below_threshold(monkeypatch) -> None: +def test_withdraw_limit_alerts_below_threshold(monkeypatch: pytest.MonkeyPatch) -> None: module = load_3jane_module() alerts: list = [] monkeypatch.setattr(module, "send_alert", alerts.append) @@ -78,7 +117,7 @@ def test_withdraw_limit_alerts_below_threshold(monkeypatch) -> None: assert "threshold $4.00M" in alerts[0].message -def test_withdraw_limit_no_alert_at_or_above_threshold(monkeypatch) -> None: +def test_withdraw_limit_no_alert_at_or_above_threshold(monkeypatch: pytest.MonkeyPatch) -> None: module = load_3jane_module() alerts: list = [] monkeypatch.setattr(module, "send_alert", alerts.append) @@ -89,7 +128,7 @@ def test_withdraw_limit_no_alert_at_or_above_threshold(monkeypatch) -> None: assert alerts == [] -def test_insurance_shares_round_trip_exactly_through_sqlite(monkeypatch, tmp_path) -> None: +def test_insurance_shares_round_trip_exactly_through_sqlite(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: module = load_3jane_module() monkeypatch.setattr(paths, "CACHE_DIR", str(tmp_path)) monkeypatch.setattr(store, "_initialized", False)