From 1a8e3a61a6014203da6cb289e78998e9b7f055c3 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Thu, 18 Jun 2026 12:27:07 +0200 Subject: [PATCH 1/7] fix: use deployed credit for 3jane buffer --- protocols/3jane/README.md | 6 +-- protocols/3jane/abi/ERC4626Vault.json | 12 +++++ protocols/3jane/main.py | 67 +++++++++++++++++++-------- tests/test_3jane.py | 35 ++++++++++++++ 4 files changed, 97 insertions(+), 23 deletions(-) create mode 100644 tests/test_3jane.py diff --git a/protocols/3jane/README.md b/protocols/3jane/README.md index da1ab654..6d2627ce 100644 --- a/protocols/3jane/README.md +++ b/protocols/3jane/README.md @@ -6,10 +6,10 @@ - **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. - **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 @@ -26,7 +26,7 @@ |--------|-----------|----------| | 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 | +| Junior buffer ratio | sUSD3 backing < 15% of deployed credit | MEDIUM | | Vault shutdown | `isShutdown()` transitions to true (alert-once) | HIGH | | Debt cap change | Any change to `getDebtCap()` | MEDIUM | | 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 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..c91e2fa8 100644 --- a/protocols/3jane/main.py +++ b/protocols/3jane/main.py @@ -38,6 +38,7 @@ USD3_ADDRESS = "0x056B269Eb1f75477a8666ae8C7fE01b64dD55eCc" SUSD3_ADDRESS = "0xf689555121e529Ff0463e191F9Bd9d1E496164a7" PROTOCOL_CONFIG_ADDRESS = "0x6b276A2A7dd8b629adBA8A06AD6573d01C84f34E" +WAUSDC_ADDRESS = "0xD4fa2D31b7968E448877f69A96DE69f5de8cD23E" # USDC has 6 decimals, USD3 and sUSD3 inherit this DECIMALS = 6 @@ -175,36 +176,34 @@ def check_tvl(usd3_tvl: float, susd3_tvl: float) -> None: 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})" ) @@ -377,6 +376,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 +388,13 @@ 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)) responses = client.execute_batch(batch) - if len(responses) != 8: - raise ValueError(f"Expected 8 responses, got {len(responses)}") + if len(responses) != 10: + raise ValueError(f"Expected 10 responses, got {len(responses)}") usd3_total_assets = responses[0] usd3_total_supply = responses[1] @@ -400,8 +402,26 @@ 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]) + + if len(market_liquidity) != 4: + raise ValueError(f"Expected 4 market liquidity values, got {len(market_liquidity)}") + total_borrow_wausdc = market_liquidity[2] + + # Value the USD3 shares held by sUSD3 through USD3, and convert borrowed + # waUSDC shares to USDC, matching the protocol's loss-buffer accounting. + with client.batch_requests() as batch: + batch.add(usd3_vault.functions.convertToAssets(susd3_usd3_balance)) + batch.add(wausdc_vault.functions.convertToAssets(total_borrow_wausdc)) + 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] + deployed_credit_raw = backing_responses[1] # Convert to human-readable floats usd3_tvl = usd3_total_assets / ONE_SHARE @@ -410,6 +430,8 @@ 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 nominal_floor = nominal_floor_raw / ONE_SHARE logger.info( @@ -424,14 +446,19 @@ 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_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( diff --git a/tests/test_3jane.py b/tests/test_3jane.py new file mode 100644 index 00000000..ea761181 --- /dev/null +++ b/tests/test_3jane.py @@ -0,0 +1,35 @@ +import importlib.util +from pathlib import Path +from types import ModuleType + + +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() + messages: list[str] = [] + monkeypatch.setattr(module, "send_telegram_message", lambda message, _protocol: messages.append(message)) + + module.check_junior_buffer(7_504_000, 37_776_000) + + assert messages == [] + + +def test_junior_buffer_alert_describes_deployed_credit(monkeypatch) -> None: + module = load_3jane_module() + messages: list[str] = [] + monkeypatch.setattr(module, "send_telegram_message", lambda message, _protocol: messages.append(message)) + + module.check_junior_buffer(5_000_000, 40_000_000) + + assert len(messages) == 1 + assert "12.50% of deployed credit" in messages[0] + assert "sUSD3 backing: $5.00M | Deployed: $40.00M" in messages[0] From b1312d96fc56e8fdf089de5cc4c055f4386318fa Mon Sep 17 00:00:00 2001 From: spalen0 Date: Thu, 18 Jun 2026 12:36:56 +0200 Subject: [PATCH 2/7] feat: monitor 3jane insurance and upgrade timelock --- protocols/3jane/README.md | 7 +++- protocols/3jane/main.py | 53 ++++++++++++++++++++++++--- protocols/timelock/README.md | 3 +- protocols/timelock/timelock_alerts.py | 3 +- tests/test_3jane.py | 28 ++++++++++++++ tests/test_timelock_alerts.py | 9 ++++- 6 files changed, 93 insertions(+), 10 deletions(-) diff --git a/protocols/3jane/README.md b/protocols/3jane/README.md index 6d2627ce..daded998 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. +- **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 the floor exceeds sUSD3's USD3 holdings valued in USDC — sUSD3 redemptions can be blocked while floor > backing. @@ -19,6 +20,7 @@ | 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 @@ -27,16 +29,17 @@ | PPS decrease | Any decrease vs cached prior (USD3 or sUSD3) | HIGH | | TVL change | ≥15% absolute change vs prior run | HIGH | | Junior buffer ratio | sUSD3 backing < 15% of deployed credit | MEDIUM | +| Insurance fund outflow | ≥$50k USDC since prior run | HIGH | | Vault shutdown | `isShutdown()` transitions to true (alert-once) | HIGH | | Debt cap change | Any change to `getDebtCap()` | MEDIUM | | Nominal backing floor change | Any change to `SUSD3_NOMINAL_BACKING_FLOOR` | MEDIUM | -| Nominal floor breach | Floor > sUSD3 `totalAssets()` (alert-once) | HIGH | +| Nominal floor breach | Floor > sUSD3 backing valued in USDC (alert-once) | HIGH | | Protocol paused | `IS_PAUSED` transitions to true (alert-once) | HIGH | | Monitoring run failure | Uncaught exception in `main()` | LOW | ## 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/main.py b/protocols/3jane/main.py index c91e2fa8..168fea81 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 @@ -39,6 +40,7 @@ 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 @@ -55,6 +57,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") @@ -62,7 +65,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: @@ -210,6 +214,34 @@ def check_junior_buffer(susd3_backing: float, deployed_credit: float) -> None: send_telegram_message(message, PROTOCOL) +def check_insurance_fund(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. + """ + previous_shares = int(get_cache_value(CACHE_KEY_INSURANCE_FUND_SHARES)) + logger.info( + "Insurance fund — balance: %s USDC, shares: %d (previous: %d)", + format_usd(current_assets), + current_shares, + previous_shares, + ) + + if previous_shares > current_shares and 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_telegram_message(message, PROTOCOL) + + if current_shares != previous_shares: + set_cache_value(CACHE_KEY_INSURANCE_FUND_SHARES, float(current_shares)) + + def check_vault_shutdown(client, usd3_vault, susd3_vault) -> None: # type: ignore[no-untyped-def] """Check if either vault has been emergency shut down. @@ -392,9 +424,10 @@ def main() -> None: 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) != 10: - raise ValueError(f"Expected 10 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] @@ -406,22 +439,29 @@ def main() -> None: 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 = int(get_cache_value(CACHE_KEY_INSURANCE_FUND_SHARES)) + insurance_outflow_shares = max(previous_insurance_shares - insurance_fund_shares, 0) # Value the USD3 shares held by sUSD3 through USD3, and convert borrowed # waUSDC shares to USDC, matching the protocol's loss-buffer accounting. with client.batch_requests() as batch: batch.add(usd3_vault.functions.convertToAssets(susd3_usd3_balance)) batch.add(wausdc_vault.functions.convertToAssets(total_borrow_wausdc)) + batch.add(wausdc_vault.functions.convertToAssets(insurance_fund_shares)) + batch.add(wausdc_vault.functions.convertToAssets(insurance_outflow_shares)) backing_responses = client.execute_batch(batch) - if len(backing_responses) != 2: - raise ValueError(f"Expected 2 backing responses, got {len(backing_responses)}") + if len(backing_responses) != 4: + raise ValueError(f"Expected 4 backing responses, got {len(backing_responses)}") susd3_backing_raw = backing_responses[0] deployed_credit_raw = backing_responses[1] + insurance_fund_assets_raw = backing_responses[2] + insurance_outflow_assets_raw = backing_responses[3] # Convert to human-readable floats usd3_tvl = usd3_total_assets / ONE_SHARE @@ -432,6 +472,8 @@ def main() -> None: 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( @@ -456,6 +498,7 @@ def main() -> None: check_pps(usd3_pps, susd3_pps) check_tvl(usd3_tvl, susd3_tvl) check_junior_buffer(susd3_backing, deployed_credit) + check_insurance_fund(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_backing) 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 index ea761181..9e40624c 100644 --- a/tests/test_3jane.py +++ b/tests/test_3jane.py @@ -33,3 +33,31 @@ def test_junior_buffer_alert_describes_deployed_credit(monkeypatch) -> None: assert len(messages) == 1 assert "12.50% of deployed credit" in messages[0] assert "sUSD3 backing: $5.00M | Deployed: $40.00M" in messages[0] + + +def test_insurance_fund_alerts_on_large_share_outflow(monkeypatch) -> None: + module = load_3jane_module() + messages: list[str] = [] + cached: list[tuple[str, float]] = [] + monkeypatch.setattr(module, "get_cache_value", lambda _key: 900_000_000_000) + monkeypatch.setattr(module, "set_cache_value", lambda key, value: cached.append((key, value))) + monkeypatch.setattr(module, "send_telegram_message", lambda message, _protocol: messages.append(message)) + + module.check_insurance_fund(850_000_000_000, 1_000_000, 58_000) + + assert len(messages) == 1 + assert "Outflow: $58.00K" in messages[0] + assert cached == [(module.CACHE_KEY_INSURANCE_FUND_SHARES, 850_000_000_000.0)] + + +def test_insurance_fund_ignores_yield_and_small_outflows(monkeypatch) -> None: + module = load_3jane_module() + messages: list[str] = [] + monkeypatch.setattr(module, "get_cache_value", lambda _key: 900_000_000_000) + monkeypatch.setattr(module, "set_cache_value", lambda _key, _value: None) + monkeypatch.setattr(module, "send_telegram_message", lambda message, _protocol: messages.append(message)) + + module.check_insurance_fund(901_000_000_000, 1_050_000, 0) + module.check_insurance_fund(899_000_000_000, 1_048_000, 1_200) + + assert messages == [] 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, From c3d2e9fa00831794bb0c1b7174f4ea50d2a4cfe8 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Thu, 18 Jun 2026 12:45:09 +0200 Subject: [PATCH 3/7] refactor: simplify 3jane backing conversions --- protocols/3jane/main.py | 49 ++++++++++++++++++++++++----------------- tests/test_3jane.py | 8 +++---- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/protocols/3jane/main.py b/protocols/3jane/main.py index 168fea81..d6f44b4a 100644 --- a/protocols/3jane/main.py +++ b/protocols/3jane/main.py @@ -45,6 +45,7 @@ # 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" @@ -214,13 +215,17 @@ def check_junior_buffer(susd3_backing: float, deployed_credit: float) -> None: send_telegram_message(message, PROTOCOL) -def check_insurance_fund(current_shares: int, current_assets: float, outflow_assets: float) -> None: +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. """ - previous_shares = int(get_cache_value(CACHE_KEY_INSURANCE_FUND_SHARES)) logger.info( "Insurance fund — balance: %s USDC, shares: %d (previous: %d)", format_usd(current_assets), @@ -228,7 +233,7 @@ def check_insurance_fund(current_shares: int, current_assets: float, outflow_ass previous_shares, ) - if previous_shares > current_shares and outflow_assets >= INSURANCE_FUND_OUTFLOW_THRESHOLD: + if outflow_assets >= INSURANCE_FUND_OUTFLOW_THRESHOLD: message = ( f"🚨 *3Jane Insurance Fund Outflow*\n" f"📉 Outflow: {format_usd(outflow_assets)}\n" @@ -321,12 +326,12 @@ def check_debt_cap(client) -> None: # type: ignore[no-untyped-def] 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). @@ -334,7 +339,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) @@ -362,13 +367,13 @@ def check_nominal_backing_floor(nominal_floor: float, susd3_tvl: float) -> None: 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})" @@ -447,21 +452,20 @@ def main() -> None: previous_insurance_shares = int(get_cache_value(CACHE_KEY_INSURANCE_FUND_SHARES)) insurance_outflow_shares = max(previous_insurance_shares - insurance_fund_shares, 0) - # Value the USD3 shares held by sUSD3 through USD3, and convert borrowed - # waUSDC shares to USDC, matching the protocol's loss-buffer accounting. + # 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(total_borrow_wausdc)) - batch.add(wausdc_vault.functions.convertToAssets(insurance_fund_shares)) - batch.add(wausdc_vault.functions.convertToAssets(insurance_outflow_shares)) + batch.add(wausdc_vault.functions.convertToAssets(RATE_SCALE)) backing_responses = client.execute_batch(batch) - if len(backing_responses) != 4: - raise ValueError(f"Expected 4 backing responses, got {len(backing_responses)}") + if len(backing_responses) != 2: + raise ValueError(f"Expected 2 backing responses, got {len(backing_responses)}") susd3_backing_raw = backing_responses[0] - deployed_credit_raw = backing_responses[1] - insurance_fund_assets_raw = backing_responses[2] - insurance_outflow_assets_raw = backing_responses[3] + 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 @@ -498,7 +502,12 @@ def main() -> None: check_pps(usd3_pps, susd3_pps) check_tvl(usd3_tvl, susd3_tvl) check_junior_buffer(susd3_backing, deployed_credit) - check_insurance_fund(insurance_fund_shares, insurance_fund_assets, insurance_outflow_assets) + 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_backing) diff --git a/tests/test_3jane.py b/tests/test_3jane.py index 9e40624c..ad86c02a 100644 --- a/tests/test_3jane.py +++ b/tests/test_3jane.py @@ -39,11 +39,10 @@ def test_insurance_fund_alerts_on_large_share_outflow(monkeypatch) -> None: module = load_3jane_module() messages: list[str] = [] cached: list[tuple[str, float]] = [] - monkeypatch.setattr(module, "get_cache_value", lambda _key: 900_000_000_000) monkeypatch.setattr(module, "set_cache_value", lambda key, value: cached.append((key, value))) monkeypatch.setattr(module, "send_telegram_message", lambda message, _protocol: messages.append(message)) - module.check_insurance_fund(850_000_000_000, 1_000_000, 58_000) + module.check_insurance_fund(900_000_000_000, 850_000_000_000, 1_000_000, 58_000) assert len(messages) == 1 assert "Outflow: $58.00K" in messages[0] @@ -53,11 +52,10 @@ def test_insurance_fund_alerts_on_large_share_outflow(monkeypatch) -> None: def test_insurance_fund_ignores_yield_and_small_outflows(monkeypatch) -> None: module = load_3jane_module() messages: list[str] = [] - monkeypatch.setattr(module, "get_cache_value", lambda _key: 900_000_000_000) monkeypatch.setattr(module, "set_cache_value", lambda _key, _value: None) monkeypatch.setattr(module, "send_telegram_message", lambda message, _protocol: messages.append(message)) - module.check_insurance_fund(901_000_000_000, 1_050_000, 0) - module.check_insurance_fund(899_000_000_000, 1_048_000, 1_200) + 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 messages == [] From 3237f56cd43f166633acaaeaa168465b3f6212bd Mon Sep 17 00:00:00 2001 From: spalen0 Date: Thu, 18 Jun 2026 12:48:56 +0200 Subject: [PATCH 4/7] fix: preserve 3jane share precision in sqlite --- protocols/3jane/main.py | 21 +++++++++++++++++---- tests/test_3jane.py | 25 +++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/protocols/3jane/main.py b/protocols/3jane/main.py index d6f44b4a..3f8b46da 100644 --- a/protocols/3jane/main.py +++ b/protocols/3jane/main.py @@ -79,8 +79,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) @@ -244,7 +257,7 @@ def check_insurance_fund( send_telegram_message(message, PROTOCOL) if current_shares != previous_shares: - set_cache_value(CACHE_KEY_INSURANCE_FUND_SHARES, float(current_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] @@ -449,7 +462,7 @@ def main() -> None: 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 = int(get_cache_value(CACHE_KEY_INSURANCE_FUND_SHARES)) + 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 diff --git a/tests/test_3jane.py b/tests/test_3jane.py index ad86c02a..fffe4e9f 100644 --- a/tests/test_3jane.py +++ b/tests/test_3jane.py @@ -2,6 +2,8 @@ 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" @@ -38,7 +40,7 @@ def test_junior_buffer_alert_describes_deployed_credit(monkeypatch) -> None: def test_insurance_fund_alerts_on_large_share_outflow(monkeypatch) -> None: module = load_3jane_module() messages: list[str] = [] - cached: list[tuple[str, float]] = [] + cached: list[tuple[str, int | float]] = [] monkeypatch.setattr(module, "set_cache_value", lambda key, value: cached.append((key, value))) monkeypatch.setattr(module, "send_telegram_message", lambda message, _protocol: messages.append(message)) @@ -46,7 +48,7 @@ def test_insurance_fund_alerts_on_large_share_outflow(monkeypatch) -> None: assert len(messages) == 1 assert "Outflow: $58.00K" in messages[0] - assert cached == [(module.CACHE_KEY_INSURANCE_FUND_SHARES, 850_000_000_000.0)] + assert cached == [(module.CACHE_KEY_INSURANCE_FUND_SHARES, 850_000_000_000)] def test_insurance_fund_ignores_yield_and_small_outflows(monkeypatch) -> None: @@ -59,3 +61,22 @@ def test_insurance_fund_ignores_yield_and_small_outflows(monkeypatch) -> None: module.check_insurance_fund(900_000_000_000, 899_000_000_000, 1_048_000, 1_200) assert messages == [] + + +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 From f7f9dc8bd4ebcfbc165264e8d90a241b47afb3b0 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Thu, 18 Jun 2026 13:05:44 +0200 Subject: [PATCH 5/7] refactor: use structured alerts for 3jane --- protocols/3jane/README.md | 23 +++++++++++++++-------- protocols/3jane/main.py | 33 +++++++++++++++------------------ tests/test_3jane.py | 32 +++++++++++++++++--------------- 3 files changed, 47 insertions(+), 41 deletions(-) diff --git a/protocols/3jane/README.md b/protocols/3jane/README.md index daded998..9b4af8a8 100644 --- a/protocols/3jane/README.md +++ b/protocols/3jane/README.md @@ -26,17 +26,24 @@ | 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 backing < 15% of deployed credit | MEDIUM | -| Insurance fund outflow | ≥$50k USDC since prior run | HIGH | -| 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 backing valued in USDC (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, so HIGH and CRITICAL alerts invoke the default emergency-dispatch hook after Telegram delivery. However, 3Jane emergency actions are **not currently enabled**: `utils.dispatch.DISPATCHABLE_PROTOCOLS` does not include `3jane`, and the receiving `liquidity-monitoring` service has no 3Jane entry or USD3 market mapping in `emergency_config.json`. + +Until both sides are configured, HIGH and CRITICAL 3Jane alerts are recorded and sent to Telegram but do not dispatch an emergency withdrawal. Enabling dispatch requires an explicit mapping of the Yearn vaults and markets whose caps should be zeroed; adding only `3jane` to the sender whitelist would result in a rejected or no-op webhook. + ## Governance [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. diff --git a/protocols/3jane/main.py b/protocols/3jane/main.py index 3f8b46da..409e19e1 100644 --- a/protocols/3jane/main.py +++ b/protocols/3jane/main.py @@ -19,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" @@ -120,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) @@ -138,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) @@ -168,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) @@ -188,7 +189,7 @@ 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) @@ -225,7 +226,7 @@ def check_junior_buffer(susd3_backing: float, deployed_credit: float) -> None: 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( @@ -254,7 +255,7 @@ def check_insurance_fund( f"⚠️ First-loss insurance available for debt settlement decreased\n" f"🔗 [Insurance Fund](https://etherscan.io/address/{INSURANCE_FUND_ADDRESS})" ) - send_telegram_message(message, PROTOCOL) + send_alert(Alert(AlertSeverity.MEDIUM, message, PROTOCOL)) if current_shares != previous_shares: set_cache_value(CACHE_KEY_INSURANCE_FUND_SHARES, current_shares) @@ -291,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)) @@ -303,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)) @@ -333,7 +334,7 @@ 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) @@ -374,7 +375,7 @@ def check_nominal_backing_floor(nominal_floor: float, susd3_backing: float) -> N 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) @@ -391,7 +392,7 @@ def check_nominal_backing_floor(nominal_floor: float, susd3_backing: float) -> 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)) @@ -414,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)) @@ -535,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/tests/test_3jane.py b/tests/test_3jane.py index fffe4e9f..c8870078 100644 --- a/tests/test_3jane.py +++ b/tests/test_3jane.py @@ -17,50 +17,52 @@ def load_3jane_module() -> ModuleType: def test_junior_buffer_uses_backing_over_deployed_credit(monkeypatch) -> None: module = load_3jane_module() - messages: list[str] = [] - monkeypatch.setattr(module, "send_telegram_message", lambda message, _protocol: messages.append(message)) + alerts: list = [] + monkeypatch.setattr(module, "send_alert", alerts.append) module.check_junior_buffer(7_504_000, 37_776_000) - assert messages == [] + assert alerts == [] def test_junior_buffer_alert_describes_deployed_credit(monkeypatch) -> None: module = load_3jane_module() - messages: list[str] = [] - monkeypatch.setattr(module, "send_telegram_message", lambda message, _protocol: messages.append(message)) + alerts: list = [] + monkeypatch.setattr(module, "send_alert", alerts.append) module.check_junior_buffer(5_000_000, 40_000_000) - assert len(messages) == 1 - assert "12.50% of deployed credit" in messages[0] - assert "sUSD3 backing: $5.00M | Deployed: $40.00M" in messages[0] + 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() - messages: list[str] = [] + 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_telegram_message", lambda message, _protocol: messages.append(message)) + 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(messages) == 1 - assert "Outflow: $58.00K" in messages[0] + 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() - messages: list[str] = [] + alerts: list = [] monkeypatch.setattr(module, "set_cache_value", lambda _key, _value: None) - monkeypatch.setattr(module, "send_telegram_message", lambda message, _protocol: messages.append(message)) + 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 messages == [] + assert alerts == [] def test_insurance_shares_round_trip_exactly_through_sqlite(monkeypatch, tmp_path) -> None: From fb0ed7d63fba55fcc3293dc421253f1d5bd99469 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Thu, 18 Jun 2026 13:15:23 +0200 Subject: [PATCH 6/7] feat: enable 3jane emergency dispatch --- deploy/emergency-dispatch-demo.md | 1 + protocols/3jane/README.md | 6 ++++-- tests/test_utils.py | 19 +++++++++++++++++++ utils/dispatch.py | 2 +- 4 files changed, 25 insertions(+), 3 deletions(-) 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 9b4af8a8..3738b8ac 100644 --- a/protocols/3jane/README.md +++ b/protocols/3jane/README.md @@ -40,9 +40,11 @@ ## Alert dispatch -Alerts use the structured `send_alert` path, so HIGH and CRITICAL alerts invoke the default emergency-dispatch hook after Telegram delivery. However, 3Jane emergency actions are **not currently enabled**: `utils.dispatch.DISPATCHABLE_PROTOCOLS` does not include `3jane`, and the receiving `liquidity-monitoring` service has no 3Jane entry or USD3 market mapping in `emergency_config.json`. +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`. -Until both sides are configured, HIGH and CRITICAL 3Jane alerts are recorded and sent to Telegram but do not dispatch an emergency withdrawal. Enabling dispatch requires an explicit mapping of the Yearn vaults and markets whose caps should be zeroed; adding only `3jane` to the sender whitelist would result in a rejected or no-op webhook. +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 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: From 06339ff8ac298b21214ad1e38afc9a92ff17de31 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Thu, 18 Jun 2026 13:31:25 +0200 Subject: [PATCH 7/7] chore: add morpho 3jane markets --- protocols/morpho/markets.py | 2 ++ 1 file changed, 2 insertions(+) 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.