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
3 changes: 3 additions & 0 deletions protocols/3jane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 |
Expand Down
60 changes: 60 additions & 0 deletions protocols/3jane/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"""

import os
import warnings
from pathlib import Path

import pytest
Expand Down Expand Up @@ -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
Expand Down
53 changes: 46 additions & 7 deletions tests/test_3jane.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from pathlib import Path
from types import ModuleType

import pytest

from utils import paths, store


Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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]] = []
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down