From 7054e1d00b3e2b8950c55b1fea38dcd7acfa4f78 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Tue, 23 Jun 2026 12:53:06 +0200 Subject: [PATCH] feat: monitor 3Jane USD3 withdraw liquidity Alert (MEDIUM) when the USD3 vault's availableWithdrawLimit falls below $4M. Low withdraw liquidity means senior-tranche withdrawals may queue or stall. - add availableWithdrawLimit(address) to the vault ABI - read it in the main batch (zero address returns the global limit) - add check_withdraw_limit() and wire it into the checks - document the monitor and threshold in the README - add regression tests Co-Authored-By: Claude Opus 4.8 --- protocols/3jane/README.md | 2 ++ protocols/3jane/abi/ERC4626Vault.json | 7 ++++++ protocols/3jane/main.py | 34 +++++++++++++++++++++++++-- tests/test_3jane.py | 24 +++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/protocols/3jane/README.md b/protocols/3jane/README.md index 3738b8a..5e3bfb3 100644 --- a/protocols/3jane/README.md +++ b/protocols/3jane/README.md @@ -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. @@ -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 | diff --git a/protocols/3jane/abi/ERC4626Vault.json b/protocols/3jane/abi/ERC4626Vault.json index 6c58ca5..d4fe17c 100644 --- a/protocols/3jane/abi/ERC4626Vault.json +++ b/protocols/3jane/abi/ERC4626Vault.json @@ -94,5 +94,12 @@ ], "stateMutability": "view", "type": "function" + }, + { + "inputs": [{"name": "owner", "type": "address"}], + "name": "availableWithdrawLimit", + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function" } ] diff --git a/protocols/3jane/main.py b/protocols/3jane/main.py index 409e19e..58c7de9 100644 --- a/protocols/3jane/main.py +++ b/protocols/3jane/main.py @@ -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 @@ -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 @@ -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: @@ -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. @@ -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] @@ -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)}") @@ -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( @@ -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) diff --git a/tests/test_3jane.py b/tests/test_3jane.py index c887007..84f8818 100644 --- a/tests/test_3jane.py +++ b/tests/test_3jane.py @@ -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))