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
2 changes: 2 additions & 0 deletions protocols/3jane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- **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.
- **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.
- **Debt Cap:** `ProtocolConfig.getDebtCap()` vs cached prior. Alerts on any change — signals governance scaling the protocol up or down.
- **Nominal sUSD3 Backing Floor:** `ProtocolConfig.config(keccak256("SUSD3_NOMINAL_BACKING_FLOOR"))` vs cached prior. Alerts on any change (governance lever). Separate alert-once when the floor exceeds sUSD3's USD3 holdings valued in USDC — sUSD3 redemptions can be blocked while floor > backing.
Expand All @@ -31,6 +32,7 @@
| TVL change | ≥15% absolute change vs prior run | LOW |
| Junior buffer ratio | sUSD3 backing < 15% of deployed credit | HIGH |
| Insurance fund outflow | ≥$50k USDC since prior run | MEDIUM |
| Withdraw liquidity low | `availableWithdrawLimit()` < $4M | MEDIUM |
| Vault shutdown | `isShutdown()` transitions to true (alert-once) | CRITICAL |
| Debt cap change | Any change to `getDebtCap()` | LOW |
| Nominal backing floor change | Any change to `SUSD3_NOMINAL_BACKING_FLOOR` | MEDIUM |
Expand Down
7 changes: 7 additions & 0 deletions protocols/3jane/abi/ERC4626Vault.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,12 @@
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [{"name": "owner", "type": "address"}],
"name": "availableWithdrawLimit",
"outputs": [{"name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function"
}
]
34 changes: 32 additions & 2 deletions protocols/3jane/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- TVL (Total Value Locked) via totalAssets() — alerts on >15% change
- Junior tranche buffer — alerts when sUSD3 coverage drops below threshold
- 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
- Debt cap changes — alerts when ProtocolConfig debt cap is modified
- Nominal sUSD3 backing floor — alerts on change and when floor > sUSD3 backing
Expand Down Expand Up @@ -42,6 +43,7 @@
PROTOCOL_CONFIG_ADDRESS = "0x6b276A2A7dd8b629adBA8A06AD6573d01C84f34E"
WAUSDC_ADDRESS = "0xD4fa2D31b7968E448877f69A96DE69f5de8cD23E"
INSURANCE_FUND_ADDRESS = "0x4507B5B23340D248457d955a211C8B0634D29935"
ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"

# USDC has 6 decimals, USD3 and sUSD3 inherit this
DECIMALS = 6
Expand Down Expand Up @@ -69,6 +71,7 @@
TVL_CHANGE_THRESHOLD = 0.15 # 15% TVL change alert
JUNIOR_BUFFER_THRESHOLD = 0.15 # Alert when sUSD3 backing < 15% of deployed credit
INSURANCE_FUND_OUTFLOW_THRESHOLD = 50_000 # USDC
WITHDRAW_LIMIT_THRESHOLD = 4_000_000 # USDC, alert when USD3 availableWithdrawLimit falls below


def get_cache_value(key: str) -> float:
Expand Down Expand Up @@ -261,6 +264,29 @@ def check_insurance_fund(
set_cache_value(CACHE_KEY_INSURANCE_FUND_SHARES, current_shares)


def check_withdraw_limit(withdraw_limit: float) -> None:
"""Alert when USD3 withdraw liquidity drops below the safety threshold.

availableWithdrawLimit is the USDC the USD3 vault can immediately honor for
withdrawals. When it falls below the threshold, withdrawals may queue or
stall, signalling a liquidity squeeze on the senior tranche.

Args:
withdraw_limit: USD3 availableWithdrawLimit in USDC.
"""
logger.info("USD3 available withdraw limit: %s", format_usd(withdraw_limit))

if withdraw_limit < WITHDRAW_LIMIT_THRESHOLD:
message = (
f"🚨 *3Jane USD3 Withdraw Liquidity Low*\n"
f"📉 Available withdraw limit: {format_usd(withdraw_limit)} "
f"(threshold {format_usd(WITHDRAW_LIMIT_THRESHOLD)})\n"
f"⚠️ Senior-tranche withdrawals may queue or stall\n"
f"🔗 [USD3](https://etherscan.io/address/{USD3_ADDRESS})"
)
send_alert(Alert(AlertSeverity.MEDIUM, message, PROTOCOL))


def check_vault_shutdown(client, usd3_vault, susd3_vault) -> None: # type: ignore[no-untyped-def]
"""Check if either vault has been emergency shut down.

Expand Down Expand Up @@ -444,9 +470,10 @@ def main() -> None:
batch.add(protocol_config.functions.config(CFG_KEY_SUSD3_NOMINAL_BACKING_FLOOR))
batch.add(protocol_config.functions.config(CFG_KEY_IS_PAUSED))
batch.add(wausdc_vault.functions.balanceOf(INSURANCE_FUND_ADDRESS))
batch.add(usd3_vault.functions.availableWithdrawLimit(ZERO_ADDRESS))
responses = client.execute_batch(batch)
if len(responses) != 11:
raise ValueError(f"Expected 11 responses, got {len(responses)}")
if len(responses) != 12:
raise ValueError(f"Expected 12 responses, got {len(responses)}")

usd3_total_assets = responses[0]
usd3_total_supply = responses[1]
Expand All @@ -459,6 +486,7 @@ def main() -> None:
nominal_floor_raw = responses[8]
is_paused = bool(responses[9])
insurance_fund_shares = responses[10]
withdraw_limit_raw = responses[11]

if len(market_liquidity) != 4:
raise ValueError(f"Expected 4 market liquidity values, got {len(market_liquidity)}")
Expand Down Expand Up @@ -492,6 +520,7 @@ def main() -> None:
deployed_credit = deployed_credit_raw / ONE_SHARE
insurance_fund_assets = insurance_fund_assets_raw / ONE_SHARE
insurance_outflow_assets = insurance_outflow_assets_raw / ONE_SHARE
withdraw_limit = withdraw_limit_raw / ONE_SHARE
nominal_floor = nominal_floor_raw / ONE_SHARE

logger.info(
Expand Down Expand Up @@ -522,6 +551,7 @@ def main() -> None:
insurance_fund_assets,
insurance_outflow_assets,
)
check_withdraw_limit(withdraw_limit)
check_vault_shutdown(client, usd3_vault, susd3_vault)
check_debt_cap(client)
check_nominal_backing_floor(nominal_floor, susd3_backing)
Expand Down
24 changes: 24 additions & 0 deletions tests/test_3jane.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,30 @@ def test_insurance_fund_ignores_yield_and_small_outflows(monkeypatch) -> None:
assert alerts == []


def test_withdraw_limit_alerts_below_threshold(monkeypatch) -> None:
module = load_3jane_module()
alerts: list = []
monkeypatch.setattr(module, "send_alert", alerts.append)

module.check_withdraw_limit(3_500_000)

assert len(alerts) == 1
assert alerts[0].severity == module.AlertSeverity.MEDIUM
assert "Available withdraw limit: $3.50M" in alerts[0].message
assert "threshold $4.00M" in alerts[0].message


def test_withdraw_limit_no_alert_at_or_above_threshold(monkeypatch) -> None:
module = load_3jane_module()
alerts: list = []
monkeypatch.setattr(module, "send_alert", alerts.append)

module.check_withdraw_limit(module.WITHDRAW_LIMIT_THRESHOLD)
module.check_withdraw_limit(4_548_324)

assert alerts == []


def test_insurance_shares_round_trip_exactly_through_sqlite(monkeypatch, tmp_path) -> None:
module = load_3jane_module()
monkeypatch.setattr(paths, "CACHE_DIR", str(tmp_path))
Expand Down