diff --git a/deploy/emergency-dispatch-demo.md b/deploy/emergency-dispatch-demo.md index c686b672..29805776 100644 --- a/deploy/emergency-dispatch-demo.md +++ b/deploy/emergency-dispatch-demo.md @@ -47,6 +47,7 @@ monitoring-scripts-py liquidity-monitoring | ethplus | rtoken | Coverage below threshold, StRSR rate drop | | origin | pegs | Wrapped OETH redeem value drop, backing ratio drop | | usdai | usdai | _(hook registered)_ | +| 3jane | 3jane | USD3/sUSD3 PPS decrease, junior buffer low, vault shutdown, protocol pause | ## Safety mechanisms diff --git a/protocols/3jane/README.md b/protocols/3jane/README.md index da1ab654..3738b8ac 100644 --- a/protocols/3jane/README.md +++ b/protocols/3jane/README.md @@ -6,10 +6,11 @@ - **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:** sUSD3 TVL as a percentage of USD3 TVL. Alerts when sUSD3 buffer drops below **15%** of USD3 TVL — thin first-loss coverage puts senior tranche at risk. +- **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. - **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 floor exceeds sUSD3 `totalAssets()` — sUSD3 redemptions can be blocked while floor > backing. +- **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. - **Protocol Pause:** `ProtocolConfig.config(keccak256("IS_PAUSED"))`. Alert-once on transition to true. Distinct from per-vault `isShutdown()` — pauses the underlying credit market. ## Key Contracts @@ -19,24 +20,35 @@ | USD3 Vault | [`0x056B269Eb1f75477a8666ae8C7fE01b64dD55eCc`](https://etherscan.io/address/0x056B269Eb1f75477a8666ae8C7fE01b64dD55eCc) | Senior tranche ERC-4626 vault | | sUSD3 Vault | [`0xf689555121e529Ff0463e191F9Bd9d1E496164a7`](https://etherscan.io/address/0xf689555121e529Ff0463e191F9Bd9d1E496164a7) | Junior (first-loss) tranche | | ProtocolConfig | [`0x6b276A2A7dd8b629adBA8A06AD6573d01C84f34E`](https://etherscan.io/address/0x6b276A2A7dd8b629adBA8A06AD6573d01C84f34E) | Governance config: debt cap, pause, sUSD3 floor | +| Insurance Fund | [`0x4507B5B23340D248457d955a211C8B0634D29935`](https://etherscan.io/address/0x4507B5B23340D248457d955a211C8B0634D29935) | waUSDC reserve used for debt settlement | ## Alert Thresholds | Metric | Threshold | Severity | |--------|-----------|----------| -| PPS decrease | Any decrease vs cached prior (USD3 or sUSD3) | HIGH | -| TVL change | ≥15% absolute change vs prior run | HIGH | -| Junior buffer ratio | sUSD3 < 15% of USD3 TVL | MEDIUM | -| Vault shutdown | `isShutdown()` transitions to true (alert-once) | HIGH | -| Debt cap change | Any change to `getDebtCap()` | MEDIUM | +| USD3 PPS decrease | Any decrease vs cached prior | CRITICAL | +| 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 | +| Insurance fund outflow | ≥$50k USDC since prior run | 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 | -| Nominal floor breach | Floor > sUSD3 `totalAssets()` (alert-once) | HIGH | -| Protocol paused | `IS_PAUSED` transitions to true (alert-once) | HIGH | +| Nominal floor breach | Floor > sUSD3 backing valued in USDC (alert-once) | MEDIUM | +| Protocol paused | `IS_PAUSED` transitions to true (alert-once) | CRITICAL | | Monitoring run failure | Uncaught exception in `main()` | LOW | +## Alert dispatch + +Alerts use the structured `send_alert` path. HIGH and CRITICAL alerts invoke the default emergency-dispatch hook after Telegram delivery, and `3jane` is enabled in `utils.dispatch.DISPATCHABLE_PROTOCOLS`. + +The sender posts a signed `emergency_withdrawal` webhook using protocol key `3jane`. Dispatch requires `LIQUIDITY_WEBHOOK_SECRET`, is skipped in `LOG_LEVEL=DEBUG`, and has a 60-minute per-protocol cooldown. The receiving liquidity-monitoring deployment must independently map `3jane` to the vaults, collateral names, and markets whose caps should be zeroed. + +Only HIGH and CRITICAL alerts dispatch. LOW and MEDIUM alerts—including insurance-fund outflows—remain Telegram/database alerts only. + ## Governance -[Internal timelock monitoring](../timelock/README.md) for CallScheduled events on the [3Jane TimelockController](https://etherscan.io/address/0x1dccd4628d48a50c1a7adea3848bcc869f08f8c2) on Mainnet. +[Internal timelock monitoring](../timelock/README.md) covers CallScheduled events from the [3Jane 24-hour timelock](https://etherscan.io/address/0x1dccd4628d48a50c1a7adea3848bcc869f08f8c2) and [7-day upgrade timelock](https://etherscan.io/address/0x3d3c41419ab401cd25055e8f9421d7d96d887885) on Mainnet. ## Running diff --git a/protocols/3jane/abi/ERC4626Vault.json b/protocols/3jane/abi/ERC4626Vault.json index 4c125633..6c58ca5e 100644 --- a/protocols/3jane/abi/ERC4626Vault.json +++ b/protocols/3jane/abi/ERC4626Vault.json @@ -82,5 +82,17 @@ "outputs": [{"name": "", "type": "uint256"}], "stateMutability": "view", "type": "function" + }, + { + "inputs": [], + "name": "getMarketLiquidity", + "outputs": [ + {"name": "totalSupplyAssets", "type": "uint256"}, + {"name": "totalShares", "type": "uint256"}, + {"name": "totalBorrowAssets", "type": "uint256"}, + {"name": "waUSDCLiquidity", "type": "uint256"} + ], + "stateMutability": "view", + "type": "function" } ] diff --git a/protocols/3jane/main.py b/protocols/3jane/main.py index d1f7d4a9..409e19e1 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 +- Insurance fund — alerts on waUSDC outflows of at least $50k - 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 @@ -18,11 +19,12 @@ from web3 import Web3 from utils.abi import load_abi +from utils.alert import Alert, AlertSeverity, send_alert from utils.cache import cache_path, get_last_value_for_key_from_file, write_last_value_to_file from utils.chains import Chain from utils.formatting import format_usd from utils.logger import get_logger -from utils.telegram import send_telegram_message +from utils.telegram import escape_markdown from utils.web3_wrapper import ChainManager PROTOCOL = "3jane" @@ -38,10 +40,13 @@ USD3_ADDRESS = "0x056B269Eb1f75477a8666ae8C7fE01b64dD55eCc" SUSD3_ADDRESS = "0xf689555121e529Ff0463e191F9Bd9d1E496164a7" PROTOCOL_CONFIG_ADDRESS = "0x6b276A2A7dd8b629adBA8A06AD6573d01C84f34E" +WAUSDC_ADDRESS = "0xD4fa2D31b7968E448877f69A96DE69f5de8cD23E" +INSURANCE_FUND_ADDRESS = "0x4507B5B23340D248457d955a211C8B0634D29935" # USDC has 6 decimals, USD3 and sUSD3 inherit this DECIMALS = 6 ONE_SHARE = 10**DECIMALS +RATE_SCALE = 10**18 # --- Cache Keys --- CACHE_KEY_USD3_PPS = "3JANE_USD3_PPS" @@ -54,6 +59,7 @@ CACHE_KEY_NOMINAL_FLOOR = "3JANE_NOMINAL_FLOOR" CACHE_KEY_FLOOR_BREACH = "3JANE_FLOOR_BREACH" CACHE_KEY_IS_PAUSED = "3JANE_IS_PAUSED" +CACHE_KEY_INSURANCE_FUND_SHARES = "3JANE_INSURANCE_FUND_SHARES" # --- ProtocolConfig keys (keccak256 of the string label) --- CFG_KEY_SUSD3_NOMINAL_BACKING_FLOOR = Web3.keccak(text="SUSD3_NOMINAL_BACKING_FLOOR") @@ -61,7 +67,8 @@ # --- Thresholds --- TVL_CHANGE_THRESHOLD = 0.15 # 15% TVL change alert -JUNIOR_BUFFER_THRESHOLD = 0.15 # Alert when sUSD3 buffer < 15% of USD3 TVL +JUNIOR_BUFFER_THRESHOLD = 0.15 # Alert when sUSD3 backing < 15% of deployed credit +INSURANCE_FUND_OUTFLOW_THRESHOLD = 50_000 # USDC def get_cache_value(key: str) -> float: @@ -73,8 +80,21 @@ def get_cache_value(key: str) -> float: return 0.0 -def set_cache_value(key: str, value: float) -> None: - """Write a float value to cache.""" +def get_cache_int(key: str) -> int: + """Read an integer cache value without passing it through float.""" + val = get_last_value_for_key_from_file(CACHE_FILENAME, key) + try: + return int(val) + except (ValueError, TypeError): + # Accept values written by the previous implementation as "123.0". + try: + return int(float(val)) + except (ValueError, TypeError): + return 0 + + +def set_cache_value(key: str, value: int | float) -> None: + """Write a numeric value to cache.""" write_last_value_to_file(CACHE_FILENAME, key, value) @@ -101,7 +121,7 @@ def check_pps(usd3_pps_float: float, susd3_pps_float: float) -> None: f"⚠️ Possible loan markdown or default\n" f"🔗 [USD3](https://etherscan.io/address/{USD3_ADDRESS})" ) - send_telegram_message(message, PROTOCOL) + send_alert(Alert(AlertSeverity.CRITICAL, message, PROTOCOL)) if usd3_pps_float != previous_usd3_pps: set_cache_value(CACHE_KEY_USD3_PPS, usd3_pps_float) @@ -119,7 +139,7 @@ def check_pps(usd3_pps_float: float, susd3_pps_float: float) -> None: f"⚠️ Junior tranche absorbing losses — first-loss buffer impacted\n" f"🔗 [sUSD3](https://etherscan.io/address/{SUSD3_ADDRESS})" ) - send_telegram_message(message, PROTOCOL) + send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) if susd3_pps_float != previous_susd3_pps: set_cache_value(CACHE_KEY_SUSD3_PPS, susd3_pps_float) @@ -149,7 +169,7 @@ def check_tvl(usd3_tvl: float, susd3_tvl: float) -> None: f"📊 {format_usd(previous_usd3_tvl)} → {format_usd(usd3_tvl)}\n" f"🔗 [USD3](https://etherscan.io/address/{USD3_ADDRESS})" ) - send_telegram_message(message, PROTOCOL) + send_alert(Alert(AlertSeverity.LOW, message, PROTOCOL)) if usd3_tvl != previous_usd3_tvl: set_cache_value(CACHE_KEY_USD3_TVL, usd3_tvl) @@ -169,46 +189,76 @@ def check_tvl(usd3_tvl: float, susd3_tvl: float) -> None: f"⚠️ Junior tranche buffer size changed significantly\n" f"🔗 [sUSD3](https://etherscan.io/address/{SUSD3_ADDRESS})" ) - send_telegram_message(message, PROTOCOL) + send_alert(Alert(AlertSeverity.LOW, message, PROTOCOL)) if susd3_tvl != previous_susd3_tvl: set_cache_value(CACHE_KEY_SUSD3_TVL, susd3_tvl) -def check_junior_buffer(usd3_tvl: float, susd3_tvl: float, susd3_pps_float: float) -> None: +def check_junior_buffer(susd3_backing: float, deployed_credit: float) -> None: """Check if sUSD3 junior tranche provides adequate first-loss coverage. The sUSD3 junior tranche absorbs losses before the senior USD3 tranche. A thin buffer means USD3 holders are closer to bearing losses directly. - We convert sUSD3 TVL to USDC terms using its PPS for accurate comparison. + This matches the protocol's backing metric: sUSD3 backing value divided by + deployed credit. The caller supplies both values converted to USDC. Args: - usd3_tvl: USD3 totalAssets in USDC. - susd3_tvl: sUSD3 totalAssets in USD3 terms. - susd3_pps_float: sUSD3 price per share (USD3 per sUSD3 share). + susd3_backing: USD3 held by sUSD3, valued in USDC. + deployed_credit: Borrowed waUSDC in the credit market, converted to USDC. """ - if usd3_tvl <= 0: + if deployed_credit <= 0: return - # sUSD3 totalAssets is in USD3 terms; USD3 PPS converts to USDC - # But for buffer ratio, USD3-denominated value is sufficient since USD3 ≈ USDC - buffer_ratio = susd3_tvl / usd3_tvl + buffer_ratio = susd3_backing / deployed_credit logger.info( - "Junior buffer ratio: %.2f%% (sUSD3: %s / USD3: %s)", + "Junior buffer ratio: %.2f%% (sUSD3 backing: %s / deployed credit: %s)", buffer_ratio * 100, - format_usd(susd3_tvl), - format_usd(usd3_tvl), + format_usd(susd3_backing), + format_usd(deployed_credit), ) if buffer_ratio < JUNIOR_BUFFER_THRESHOLD: message = ( f"⚠️ *3Jane Junior Buffer Low*\n" - f"📊 sUSD3 buffer: {buffer_ratio:.2%} of USD3 TVL\n" - f"💰 sUSD3: {format_usd(susd3_tvl)} | USD3: {format_usd(usd3_tvl)}\n" + f"📊 sUSD3 buffer: {buffer_ratio:.2%} of deployed credit\n" + f"💰 sUSD3 backing: {format_usd(susd3_backing)} | Deployed: {format_usd(deployed_credit)}\n" f"⚠️ First-loss coverage is thin — USD3 holders at higher risk\n" f"🔗 [sUSD3](https://etherscan.io/address/{SUSD3_ADDRESS})" ) - send_telegram_message(message, PROTOCOL) + send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) + + +def check_insurance_fund( + previous_shares: int, + current_shares: int, + current_assets: float, + outflow_assets: float, +) -> None: + """Alert when the insurance fund loses at least $50k of waUSDC shares. + + The raw share balance is cached so normal waUSDC appreciation cannot hide an + outflow. The caller values both the current balance and share delta in USDC. + """ + logger.info( + "Insurance fund — balance: %s USDC, shares: %d (previous: %d)", + format_usd(current_assets), + current_shares, + previous_shares, + ) + + if outflow_assets >= INSURANCE_FUND_OUTFLOW_THRESHOLD: + message = ( + f"🚨 *3Jane Insurance Fund Outflow*\n" + f"📉 Outflow: {format_usd(outflow_assets)}\n" + f"💰 Remaining balance: {format_usd(current_assets)}\n" + f"⚠️ First-loss insurance available for debt settlement decreased\n" + f"🔗 [Insurance Fund](https://etherscan.io/address/{INSURANCE_FUND_ADDRESS})" + ) + send_alert(Alert(AlertSeverity.MEDIUM, message, PROTOCOL)) + + if current_shares != previous_shares: + set_cache_value(CACHE_KEY_INSURANCE_FUND_SHARES, current_shares) def check_vault_shutdown(client, usd3_vault, susd3_vault) -> None: # type: ignore[no-untyped-def] @@ -242,7 +292,7 @@ def check_vault_shutdown(client, usd3_vault, susd3_vault) -> None: # type: igno f"⚠️ USD3 vault has entered emergency shutdown\n" f"🔗 [USD3](https://etherscan.io/address/{USD3_ADDRESS})" ) - send_telegram_message(message, PROTOCOL) + send_alert(Alert(AlertSeverity.CRITICAL, message, PROTOCOL)) if float(usd3_shutdown) != previous_usd3_shutdown: set_cache_value(CACHE_KEY_SHUTDOWN_USD3, float(usd3_shutdown)) @@ -254,7 +304,7 @@ def check_vault_shutdown(client, usd3_vault, susd3_vault) -> None: # type: igno f"⚠️ sUSD3 vault has entered emergency shutdown\n" f"🔗 [sUSD3](https://etherscan.io/address/{SUSD3_ADDRESS})" ) - send_telegram_message(message, PROTOCOL) + send_alert(Alert(AlertSeverity.CRITICAL, message, PROTOCOL)) if float(susd3_shutdown) != previous_susd3_shutdown: set_cache_value(CACHE_KEY_SHUTDOWN_SUSD3, float(susd3_shutdown)) @@ -284,18 +334,18 @@ def check_debt_cap(client) -> None: # type: ignore[no-untyped-def] f"💰 {format_usd(previous_debt_cap)} → {format_usd(debt_cap)}\n" f"🔗 [ProtocolConfig](https://etherscan.io/address/{PROTOCOL_CONFIG_ADDRESS})" ) - send_telegram_message(message, PROTOCOL) + send_alert(Alert(AlertSeverity.LOW, message, PROTOCOL)) if debt_cap != previous_debt_cap: set_cache_value(CACHE_KEY_DEBT_CAP, debt_cap) -def check_nominal_backing_floor(nominal_floor: float, susd3_tvl: float) -> None: +def check_nominal_backing_floor(nominal_floor: float, susd3_backing: float) -> None: """Check ProtocolConfig SUSD3_NOMINAL_BACKING_FLOOR. The nominal floor is an absolute USDC amount of sUSD3 backing the protocol requires (in addition to the ratio-based floor). When set above current - sUSD3 totalAssets, sUSD3 redemptions can be blocked. + sUSD3 backing valued in USDC, sUSD3 redemptions can be blocked. Sends two distinct alerts: - Any change to the floor value (governance lever). @@ -303,7 +353,7 @@ def check_nominal_backing_floor(nominal_floor: float, susd3_tvl: float) -> None: Args: nominal_floor: Current SUSD3_NOMINAL_BACKING_FLOOR in USDC. - susd3_tvl: Current sUSD3 totalAssets in USDC. + susd3_backing: Current sUSD3 backing value in USDC. """ # --- Alert on any change (treat first-run as a non-alert init) --- raw_previous = get_last_value_for_key_from_file(CACHE_FILENAME, CACHE_KEY_NOMINAL_FLOOR) @@ -325,24 +375,24 @@ def check_nominal_backing_floor(nominal_floor: float, susd3_tvl: float) -> None: f"ℹ️ Withdrawals blocked while sUSD3 backing < floor\n" f"🔗 [ProtocolConfig](https://etherscan.io/address/{PROTOCOL_CONFIG_ADDRESS})" ) - send_telegram_message(message, PROTOCOL) + send_alert(Alert(AlertSeverity.MEDIUM, message, PROTOCOL)) if nominal_floor != previous_floor or first_run: set_cache_value(CACHE_KEY_NOMINAL_FLOOR, nominal_floor) # --- Alert-once on breach transition (floor > backing) --- - breach = nominal_floor > susd3_tvl and nominal_floor > 0 + breach = nominal_floor > susd3_backing and nominal_floor > 0 previous_breach = get_cache_value(CACHE_KEY_FLOOR_BREACH) if breach and previous_breach == 0: - shortfall = nominal_floor - susd3_tvl + shortfall = nominal_floor - susd3_backing message = ( f"🚨 *3Jane sUSD3 Backing Below Nominal Floor*\n" - f"📊 Floor: {format_usd(nominal_floor)} | sUSD3 backing: {format_usd(susd3_tvl)}\n" + f"📊 Floor: {format_usd(nominal_floor)} | sUSD3 backing: {format_usd(susd3_backing)}\n" f"💰 Shortfall: {format_usd(shortfall)}\n" f"⚠️ sUSD3 redemptions may be blocked until backing recovers\n" f"🔗 [sUSD3](https://etherscan.io/address/{SUSD3_ADDRESS})" ) - send_telegram_message(message, PROTOCOL) + send_alert(Alert(AlertSeverity.MEDIUM, message, PROTOCOL)) if float(breach) != previous_breach: set_cache_value(CACHE_KEY_FLOOR_BREACH, float(breach)) @@ -365,7 +415,7 @@ def check_protocol_paused(is_paused: bool) -> None: f"⚠️ ProtocolConfig IS_PAUSED flipped to true\n" f"🔗 [ProtocolConfig](https://etherscan.io/address/{PROTOCOL_CONFIG_ADDRESS})" ) - send_telegram_message(message, PROTOCOL) + send_alert(Alert(AlertSeverity.CRITICAL, message, PROTOCOL)) if float(is_paused) != previous_paused: set_cache_value(CACHE_KEY_IS_PAUSED, float(is_paused)) @@ -377,6 +427,7 @@ def main() -> None: client = ChainManager.get_client(Chain.MAINNET) usd3_vault = client.eth.contract(address=USD3_ADDRESS, abi=ABI_VAULT) susd3_vault = client.eth.contract(address=SUSD3_ADDRESS, abi=ABI_VAULT) + wausdc_vault = client.eth.contract(address=WAUSDC_ADDRESS, abi=ABI_VAULT) protocol_config = client.eth.contract(address=PROTOCOL_CONFIG_ADDRESS, abi=ABI_PROTOCOL_CONFIG) try: @@ -388,11 +439,14 @@ def main() -> None: batch.add(susd3_vault.functions.totalAssets()) batch.add(susd3_vault.functions.totalSupply()) batch.add(susd3_vault.functions.convertToAssets(ONE_SHARE)) + batch.add(usd3_vault.functions.balanceOf(SUSD3_ADDRESS)) + batch.add(usd3_vault.functions.getMarketLiquidity()) 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)) responses = client.execute_batch(batch) - if len(responses) != 8: - raise ValueError(f"Expected 8 responses, got {len(responses)}") + if len(responses) != 11: + raise ValueError(f"Expected 11 responses, got {len(responses)}") usd3_total_assets = responses[0] usd3_total_supply = responses[1] @@ -400,8 +454,32 @@ def main() -> None: susd3_total_assets = responses[3] susd3_total_supply = responses[4] susd3_pps_raw = responses[5] - nominal_floor_raw = responses[6] - is_paused = bool(responses[7]) + susd3_usd3_balance = responses[6] + market_liquidity = responses[7] + nominal_floor_raw = responses[8] + is_paused = bool(responses[9]) + insurance_fund_shares = responses[10] + + if len(market_liquidity) != 4: + raise ValueError(f"Expected 4 market liquidity values, got {len(market_liquidity)}") + total_borrow_wausdc = market_liquidity[2] + previous_insurance_shares = get_cache_int(CACHE_KEY_INSURANCE_FUND_SHARES) + insurance_outflow_shares = max(previous_insurance_shares - insurance_fund_shares, 0) + + # Value the USD3 shares held by sUSD3 and fetch one high-precision waUSDC + # conversion rate. All waUSDC values below use that same rate and block. + with client.batch_requests() as batch: + batch.add(usd3_vault.functions.convertToAssets(susd3_usd3_balance)) + batch.add(wausdc_vault.functions.convertToAssets(RATE_SCALE)) + backing_responses = client.execute_batch(batch) + if len(backing_responses) != 2: + raise ValueError(f"Expected 2 backing responses, got {len(backing_responses)}") + + susd3_backing_raw = backing_responses[0] + wausdc_assets_per_scale = backing_responses[1] + deployed_credit_raw = total_borrow_wausdc * wausdc_assets_per_scale // RATE_SCALE + insurance_fund_assets_raw = insurance_fund_shares * wausdc_assets_per_scale // RATE_SCALE + insurance_outflow_assets_raw = insurance_outflow_shares * wausdc_assets_per_scale // RATE_SCALE # Convert to human-readable floats usd3_tvl = usd3_total_assets / ONE_SHARE @@ -410,6 +488,10 @@ def main() -> None: susd3_tvl = susd3_total_assets / ONE_SHARE susd3_supply = susd3_total_supply / ONE_SHARE susd3_pps = susd3_pps_raw / ONE_SHARE + susd3_backing = susd3_backing_raw / ONE_SHARE + 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 nominal_floor = nominal_floor_raw / ONE_SHARE logger.info( @@ -424,14 +506,25 @@ def main() -> None: format_usd(susd3_supply), susd3_pps, ) + logger.info( + "Junior backing — sUSD3: %s USDC, deployed credit: %s USDC", + format_usd(susd3_backing), + format_usd(deployed_credit), + ) # Run all checks check_pps(usd3_pps, susd3_pps) check_tvl(usd3_tvl, susd3_tvl) - check_junior_buffer(usd3_tvl, susd3_tvl, susd3_pps) + check_junior_buffer(susd3_backing, deployed_credit) + check_insurance_fund( + previous_insurance_shares, + insurance_fund_shares, + insurance_fund_assets, + insurance_outflow_assets, + ) check_vault_shutdown(client, usd3_vault, susd3_vault) check_debt_cap(client) - check_nominal_backing_floor(nominal_floor, susd3_tvl) + check_nominal_backing_floor(nominal_floor, susd3_backing) check_protocol_paused(is_paused) logger.info( @@ -443,11 +536,7 @@ def main() -> None: ) except Exception as e: logger.error("Error during 3Jane monitoring: %s", e) - send_telegram_message( - f"🚨 *3Jane Monitoring Error*\n❌ {e}", - PROTOCOL, - plain_text=True, - ) + send_alert(Alert(AlertSeverity.LOW, f"🚨 *3Jane Monitoring Error*\n❌ {escape_markdown(str(e))}", PROTOCOL)) if __name__ == "__main__": diff --git a/protocols/morpho/markets.py b/protocols/morpho/markets.py index a794c153..7ade7065 100644 --- a/protocols/morpho/markets.py +++ b/protocols/morpho/markets.py @@ -317,6 +317,8 @@ "0xdf034d0351a4c0af947e1a37ecd5ccbce60d72eac90de6fcad48c74e2869d14c", # PT-iUSD-25JUN2026/USDC -> lltv 91.5%, oracle: same stack as PT-siUSD row but Ojo PT Feed (Pendle-compatible) for PT leg; InfiniFi RT + dummy USDC. "0xc6ae8e71e11ef511acee3f6cc6ad2af67b862877d459e3789905f537c85db5e3", # PT-sUSDE-25SEP2025/DAI -> lltv 91.5%, oracle: PendleSparkLinearDiscountOracle with linear discount oracle for sUSDE. No price oracle for DAI, USDe = DAI. "0x27b9a0a5bfee98a31eb51e3850250d103a9f8e41673c782defc66aa943af0e65", # PT-srUSDe-2APR2026/USDC -> lltv 91.5%, oracle: Pendle PT exchange rate(PT to asset) srUSDe. USDC = 1 using dummy oracle. + "0xe3df58f9d3011b7481ff36b939fa5f8da642f34ea5792d25d3958dbf1efa26d7", # USD3/USDC -> lltv 91.5%, oracle: MorphoChainlinkOracleV2, USD3 ERC4626 vault rate (underlying USDC). No price feeds; USDC = $1. + "0xf8c5aa31ea6b2a068a9eddb46dd110cae57bf0f12be9583a3f9a818effecba89", # PT-USD3-17DEC2026/USDC -> lltv 86%, oracle: MorphoChainlinkOracleV2, PendleSparkLinearDiscountOracle PT feed for PT-USD3. No quote feed (USDC = $1). Discount 30% per year. ], Chain.BASE: [ "0x4944a1169bc07b441473b830308ffe5bb535c10a9f824e33988b60738120c48e", # LBTC/cbBTC -> lltv 91.5%, oracle: Custom moonwell oracle. Base feed is fetched from upgradeable oracle which uses 2 oracles. Primary oracle is redstone oracle, if the price changes more than 2% than it uses fallback oracle chainlink oracle. Chainlink didn't have an exchange rate feed. Redstone was the only provider for the LBTC reserves. diff --git a/protocols/timelock/README.md b/protocols/timelock/README.md index 62823c8b..96e9e9c6 100644 --- a/protocols/timelock/README.md +++ b/protocols/timelock/README.md @@ -91,7 +91,8 @@ For complete field mapping details, see [`detils.md`](./detils.md). | [0x2386dc45added673317ef068992f19421b481f4c](https://etherscan.io/address/0x2386dc45added673317ef068992f19421b481f4c) | Mainnet | FLUID | Fluid Timelock | | [0x2e59a20f205bb85a89c53f1936454680651e618e](https://etherscan.io/address/0x2e59a20f205bb85a89c53f1936454680651e618e) | Mainnet | LIDO | Lido Timelock | | [0x2efff88747eb5a3ff00d4d8d0f0800e306c0426b](https://etherscan.io/address/0x2efff88747eb5a3ff00d4d8d0f0800e306c0426b) | Mainnet | MAPLE | Maple GovernorTimelock | -| [0x1dccd4628d48a50c1a7adea3848bcc869f08f8c2](https://etherscan.io/address/0x1dccd4628d48a50c1a7adea3848bcc869f08f8c2) | Mainnet | 3JANE | 3Jane TimelockController | +| [0x1dccd4628d48a50c1a7adea3848bcc869f08f8c2](https://etherscan.io/address/0x1dccd4628d48a50c1a7adea3848bcc869f08f8c2) | Mainnet | 3JANE | 3Jane 24h TimelockController | +| [0x3d3c41419ab401cd25055e8f9421d7d96d887885](https://etherscan.io/address/0x3d3c41419ab401cd25055e8f9421d7d96d887885) | Mainnet | 3JANE | 3Jane 7d TimelockController | | [0xf817cb3092179083c48c014688d98b72fb61464f](https://basescan.org/address/0xf817cb3092179083c48c014688d98b72fb61464f) | Base | LRT | superOETH Timelock | | [0x88ba032be87d5ef1fbe87336b7090767f367bf73](https://etherscan.io/address/0x88ba032be87d5ef1fbe87336b7090767f367bf73) | Mainnet | YEARN | Yearn TimelockController | | [0x88ba032be87d5ef1fbe87336b7090767f367bf73](https://basescan.org/address/0x88ba032be87d5ef1fbe87336b7090767f367bf73) | Base | YEARN | Yearn TimelockController | diff --git a/protocols/timelock/timelock_alerts.py b/protocols/timelock/timelock_alerts.py index 68c88205..ac7ade4f 100644 --- a/protocols/timelock/timelock_alerts.py +++ b/protocols/timelock/timelock_alerts.py @@ -62,7 +62,8 @@ class TimelockConfig: TimelockConfig("0x2efff88747eb5a3ff00d4d8d0f0800e306c0426b", 1, "MAPLE", "Maple GovernorTimelock"), TimelockConfig("0xb2a3cf69c97afd4de7882e5fee120e4efc77b706", 1, "STRATA", "Strata 48h Timelock"), TimelockConfig("0x4f2682b78f37910704fb1aff29358a1da07e022d", 1, "STRATA", "Strata 24h Timelock"), - TimelockConfig("0x1dccd4628d48a50c1a7adea3848bcc869f08f8c2", 1, "3JANE", "3Jane TimelockController"), + TimelockConfig("0x1dccd4628d48a50c1a7adea3848bcc869f08f8c2", 1, "3JANE", "3Jane 24h TimelockController"), + TimelockConfig("0x3d3c41419ab401cd25055e8f9421d7d96d887885", 1, "3JANE", "3Jane 7d TimelockController"), # Chain 8453 - Base TimelockConfig("0xf817cb3092179083c48c014688d98b72fb61464f", 8453, "LRT", "superOETH Timelock"), # Yearn Timelock (0x88Ba032be87d5EF1fbE87336B7090767F367BF73) - all chains diff --git a/tests/test_3jane.py b/tests/test_3jane.py new file mode 100644 index 00000000..c8870078 --- /dev/null +++ b/tests/test_3jane.py @@ -0,0 +1,84 @@ +import importlib.util +from pathlib import Path +from types import ModuleType + +from utils import paths, store + + +def load_3jane_module() -> ModuleType: + path = Path(__file__).parents[1] / "protocols" / "3jane" / "main.py" + spec = importlib.util.spec_from_file_location("three_jane", path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_junior_buffer_uses_backing_over_deployed_credit(monkeypatch) -> None: + module = load_3jane_module() + alerts: list = [] + monkeypatch.setattr(module, "send_alert", alerts.append) + + module.check_junior_buffer(7_504_000, 37_776_000) + + assert alerts == [] + + +def test_junior_buffer_alert_describes_deployed_credit(monkeypatch) -> None: + module = load_3jane_module() + alerts: list = [] + monkeypatch.setattr(module, "send_alert", alerts.append) + + module.check_junior_buffer(5_000_000, 40_000_000) + + assert len(alerts) == 1 + assert alerts[0].severity == module.AlertSeverity.HIGH + assert "12.50% of deployed credit" in alerts[0].message + assert "sUSD3 backing: $5.00M | Deployed: $40.00M" in alerts[0].message + + +def test_insurance_fund_alerts_on_large_share_outflow(monkeypatch) -> None: + module = load_3jane_module() + alerts: list = [] + cached: list[tuple[str, int | float]] = [] + monkeypatch.setattr(module, "set_cache_value", lambda key, value: cached.append((key, value))) + monkeypatch.setattr(module, "send_alert", alerts.append) + + module.check_insurance_fund(900_000_000_000, 850_000_000_000, 1_000_000, 58_000) + + assert len(alerts) == 1 + assert alerts[0].severity == module.AlertSeverity.MEDIUM + assert "Outflow: $58.00K" in alerts[0].message + assert cached == [(module.CACHE_KEY_INSURANCE_FUND_SHARES, 850_000_000_000)] + + +def test_insurance_fund_ignores_yield_and_small_outflows(monkeypatch) -> None: + module = load_3jane_module() + alerts: list = [] + monkeypatch.setattr(module, "set_cache_value", lambda _key, _value: None) + monkeypatch.setattr(module, "send_alert", alerts.append) + + module.check_insurance_fund(900_000_000_000, 901_000_000_000, 1_050_000, 0) + module.check_insurance_fund(900_000_000_000, 899_000_000_000, 1_048_000, 1_200) + + 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)) + monkeypatch.setattr(store, "_initialized", False) + monkeypatch.setattr(store, "_initialized_path", None) + monkeypatch.setattr(module, "CACHE_FILENAME", str(tmp_path / "cache-id.txt")) + monkeypatch.delenv("CACHE_DIR", raising=False) + monkeypatch.delenv("CACHE_BACKEND", raising=False) + raw_shares = 9_007_199_254_740_993 # Larger than the exact integer range of float. + + module.set_cache_value(module.CACHE_KEY_INSURANCE_FUND_SHARES, raw_shares) + + assert module.get_cache_int(module.CACHE_KEY_INSURANCE_FUND_SHARES) == raw_shares + assert store.state_get("cache-id.txt", module.CACHE_KEY_INSURANCE_FUND_SHARES) == str(raw_shares) + + store.state_set("cache-id.txt", module.CACHE_KEY_INSURANCE_FUND_SHARES, "868288861448.0") + assert module.get_cache_int(module.CACHE_KEY_INSURANCE_FUND_SHARES) == 868_288_861_448 diff --git a/tests/test_timelock_alerts.py b/tests/test_timelock_alerts.py index 2a88a514..d418bb10 100644 --- a/tests/test_timelock_alerts.py +++ b/tests/test_timelock_alerts.py @@ -4,10 +4,17 @@ import unittest.mock from unittest.mock import patch -from protocols.timelock.timelock_alerts import TimelockConfig, build_alert_message +from protocols.timelock.timelock_alerts import TIMELOCKS, TimelockConfig, build_alert_message from utils.telegram import MAX_MESSAGE_LENGTH +def test_3jane_seven_day_timelock_is_monitored() -> None: + timelock = TIMELOCKS[("0x3d3c41419ab401cd25055e8f9421d7d96d887885", 1)] + + assert timelock.protocol == "3JANE" + assert timelock.label == "3Jane 7d TimelockController" + + def _make_event( timelock_type: str = "TimelockController", chain_id: int = 1, diff --git a/tests/test_utils.py b/tests/test_utils.py index 851a6db2..85acdd8f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -581,6 +581,25 @@ def test_hook_exception_swallowed(self, mock_send): class TestDispatch(unittest.TestCase): """Tests for the emergency dispatch utility.""" + @patch("utils.dispatch.requests.post") + @patch("utils.dispatch._record_dispatch") + @patch("utils.dispatch._is_on_cooldown", return_value=False) + def test_dispatch_sends_for_3jane(self, mock_cooldown, mock_record, mock_post): + from utils.dispatch import dispatch_emergency_withdrawal + + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + alert = Alert(severity=AlertSeverity.HIGH, message="Junior buffer low", protocol="3jane") + + with patch.dict(os.environ, {"LIQUIDITY_WEBHOOK_SECRET": "test_secret", "LOG_LEVEL": "INFO"}): + dispatch_emergency_withdrawal(alert) + + payload = json.loads(mock_post.call_args[1]["data"].decode("utf-8")) + self.assertEqual(payload["client_payload"]["protocol"], "3jane") + self.assertEqual(payload["client_payload"]["severity"], "HIGH") + mock_record.assert_called_once_with("3jane") + @patch("utils.dispatch.requests.post") @patch("utils.dispatch._record_dispatch") @patch("utils.dispatch._is_on_cooldown", return_value=False) diff --git a/utils/dispatch.py b/utils/dispatch.py index a7da80c1..dbb7c066 100644 --- a/utils/dispatch.py +++ b/utils/dispatch.py @@ -29,7 +29,7 @@ # Protocols that have emergency withdrawal config in liquidity-monitoring. # Only these protocols will trigger a dispatch. -DISPATCHABLE_PROTOCOLS = {"infinifi", "cap", "ethena", "ethplus", "usdai", "origin", "maple"} +DISPATCHABLE_PROTOCOLS = {"infinifi", "cap", "ethena", "ethplus", "usdai", "origin", "maple", "3jane"} def _is_on_cooldown(protocol: str, cooldown_seconds: int = DEFAULT_COOLDOWN_SECONDS) -> bool: