From e7135593c264adb2c29aad472fa657e191da7f58 Mon Sep 17 00:00:00 2001 From: yvtapir Date: Fri, 3 Jul 2026 16:05:48 +0300 Subject: [PATCH 1/5] 3jane default and delinquent checker --- protocols/3jane/README.md | 29 ++++ protocols/3jane/main.py | 280 ++++++++++++++++++++++++++++++++++++++ tests/test_3jane.py | 170 +++++++++++++++++++++++ 3 files changed, 479 insertions(+) diff --git a/protocols/3jane/README.md b/protocols/3jane/README.md index ebd1b2d..77588ee 100644 --- a/protocols/3jane/README.md +++ b/protocols/3jane/README.md @@ -14,6 +14,7 @@ - **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. - **Protocol Pause:** `ProtocolConfig.config(keccak256("IS_PAUSED"))`. Alert-once on transition to true. Distinct from per-vault `isShutdown()` — pauses the underlying credit market. +- **Borrower Default Watch:** optional Envio-computed borrower default risk feed. The Envio indexer maintains `ThreeJaneBorrowerMarket` rows from MorphoCredit events, including repayment status, default timestamp, time since default, and alert bucket. The monitor only queries Delinquent/Default rows, sends **MEDIUM only** alerts, and dedupes per borrower/cycle/default milestone. ## Key Contracts @@ -41,8 +42,36 @@ | Nominal backing floor change | Any change to `SUSD3_NOMINAL_BACKING_FLOOR` | MEDIUM | | Nominal floor breach | Floor > sUSD3 backing valued in USDC (alert-once) | MEDIUM | | Protocol paused | `IS_PAUSED` transitions to true (alert-once) | CRITICAL | +| Borrower delinquent/default watch | New milestone: delinquent, ≤14d, ≤7d, ≤3d, ≤1d, default | MEDIUM | | Monitoring run failure | Uncaught exception in `main()` | LOW | +## Borrower default watch + +Set `ENVIO_GRAPHQL_URL` to the 3Jane Envio GraphQL endpoint to enable proactive borrower monitoring. Without this env var, the borrower default watch is skipped and all other 3Jane checks continue normally. + +The monitor expects Envio to expose a `ThreeJaneBorrowerMarket` entity with at least: + +| Field | Purpose | +|-------|---------| +| `marketId` | MorphoCredit market id (`bytes32`) | +| `borrower` | Borrower address | +| `credit` | Latest indexed credit line | +| `amountDue` | Latest indexed repayment amount due | +| `cycleId` | Payment cycle id for the current obligation | +| `cycleEnd` | Indexed cycle end timestamp | +| `endingBalance` | Borrower balance at cycle close | +| `defaultAt` | Computed default timestamp | +| `secondsToDefault` | Envio-computed countdown at last refresh | +| `secondsSinceDefault` | Envio-computed time since default began | +| `repaymentStatus` | Computed repayment status | +| `defaultBucket` | Alert bucket (`delinquent`, `14d`, `7d`, `3d`, `1d`, `default`) | +| `settled` | Whether the account was settled and should be skipped | +| `lastComputedBlock` | Ordering/pagination | + +The indexer should populate/update that entity from `SetCreditLine`, `Borrow`, `Repay`, `PaymentCycleCreated`, `RepaymentObligationPosted`, `RepaymentTracked`, `DefaultStarted`, `DefaultCleared`, and `AccountSettled` events on `MorphoCredit`. + +Envio refreshes borrower repayment/default buckets once per day by default (`THREE_JANE_STATUS_BLOCK_INTERVAL=7200`, assuming 12-second Ethereum blocks). Grace and delinquency windows default to 7 days and 23 days respectively, and can be overridden with `THREE_JANE_GRACE_PERIOD_SECONDS` and `THREE_JANE_DELINQUENCY_PERIOD_SECONDS`. + ## 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`. diff --git a/protocols/3jane/main.py b/protocols/3jane/main.py index 88e5714..e1a6fb7 100644 --- a/protocols/3jane/main.py +++ b/protocols/3jane/main.py @@ -18,6 +18,14 @@ - Protocol-wide pause — alerts once when ProtocolConfig IS_PAUSED flips to true """ +import json +import os +import urllib.error +import urllib.request +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + from web3 import Web3 from utils.abi import load_abi @@ -46,10 +54,15 @@ INSURANCE_FUND_ADDRESS = "0x4507B5B23340D248457d955a211C8B0634D29935" ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" +# --- External data sources --- +ENVIO_GRAPHQL_URL = os.getenv("ENVIO_GRAPHQL_URL") +ENVIO_PAGE_SIZE = int(os.getenv("THREE_JANE_ENVIO_PAGE_SIZE", "1000")) + # USDC has 6 decimals, USD3 and sUSD3 inherit this DECIMALS = 6 ONE_SHARE = 10**DECIMALS RATE_SCALE = 10**18 +SECONDS_PER_DAY = 86_400 # --- Cache Keys --- CACHE_KEY_USD3_PPS = "3JANE_USD3_PPS" @@ -63,6 +76,7 @@ CACHE_KEY_FLOOR_BREACH = "3JANE_FLOOR_BREACH" CACHE_KEY_IS_PAUSED = "3JANE_IS_PAUSED" CACHE_KEY_INSURANCE_FUND_SHARES = "3JANE_INSURANCE_FUND_SHARES" +CACHE_KEY_BORROWER_DEFAULT_WATCH_PREFIX = "3JANE_BORROWER_DEFAULT_WATCH" # --- ProtocolConfig keys (keccak256 of the string label) --- CFG_KEY_SUSD3_NOMINAL_BACKING_FLOOR = Web3.keccak(text="SUSD3_NOMINAL_BACKING_FLOOR") @@ -76,6 +90,49 @@ INSURANCE_FUND_OUTFLOW_THRESHOLD = 50_000 # USDC WITHDRAW_LIMIT_THRESHOLD = 4_000_000 # USDC, alert when USD3 availableWithdrawLimit falls below +THREE_JANE_BORROWER_DEFAULT_WATCH_QUERY = """ +query GetThreeJaneBorrowerDefaultWatch($limit: Int!, $offset: Int!) { + ThreeJaneBorrowerMarket( + where: { settled: { _neq: true } } + order_by: { lastComputedBlock: asc } + limit: $limit + offset: $offset + ) { + id + marketId + borrower + credit + amountDue + cycleId + cycleEnd + endingBalance + defaultAt + secondsToDefault + secondsSinceDefault + repaymentStatus + defaultBucket + settled + lastComputedBlock + } +} +""" + + +@dataclass(frozen=True) +class BorrowerRepaymentSnapshot: + market_id: str + borrower: str + cycle_id: int + cycle_end: int + amount_due_raw: int + ending_balance_raw: int + credit_raw: int + repayment_status: str + default_at: int + seconds_to_default: int + seconds_since_default: int + default_bucket: str | None + def get_cache_value(key: str) -> float: """Read a cached float value, returns 0.0 if not found.""" @@ -104,6 +161,228 @@ def set_cache_value(key: str, value: int | float) -> None: write_last_value_to_file(CACHE_FILENAME, key, value) +def _as_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in {"1", "true", "yes"} + return bool(value) + + +def _normalize_market_id(value: Any) -> str | None: + if not isinstance(value, str): + return None + market_id = value.lower() + if market_id.startswith("0x") and len(market_id) == 66: + return market_id + return None + + +def _normalize_borrower(value: Any) -> str | None: + if not isinstance(value, str) or not Web3.is_address(value): + return None + return Web3.to_checksum_address(value) + + +def _as_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _is_actionable_repayment_status(status: str) -> bool: + return status in {"Delinquent", "Default"} + + +def http_json(url: str, body: dict[str, Any]) -> dict[str, Any] | None: + """POST JSON and return parsed JSON, or None on transient/indexer errors.""" + req = urllib.request.Request( + url, + data=json.dumps(body).encode("utf-8"), + headers={"Accept": "application/json", "Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc: + logger.warning("3Jane Envio request failed; skipping borrower default watch this run: %s", exc) + return None + + +def gql_request(query: str, variables: dict[str, Any]) -> dict[str, Any] | None: + if not ENVIO_GRAPHQL_URL: + logger.info("ENVIO_GRAPHQL_URL is not set; skipping borrower default watch") + return None + return http_json(ENVIO_GRAPHQL_URL, {"query": query, "variables": variables}) + + +def _extract_envio_borrower_default_watch_rows(payload: dict[str, Any]) -> list[dict[str, Any]]: + data = payload.get("data") + if not isinstance(data, dict): + return [] + rows = ( + data.get("ThreeJaneBorrowerMarket") + or data.get("threeJaneBorrowerMarkets") + or data.get("threeJaneBorrowerMarket") + ) + return rows if isinstance(rows, list) else [] + + +def parse_envio_borrower_default_watch_rows(rows: list[dict[str, Any]]) -> list[BorrowerRepaymentSnapshot]: + """Parse Envio-computed 3Jane borrower default risk rows.""" + parsed: list[BorrowerRepaymentSnapshot] = [] + seen: set[tuple[str, str]] = set() + + for row in rows: + if not isinstance(row, dict) or _as_bool(row.get("settled")): + continue + market_id = _normalize_market_id(row.get("marketId") or row.get("market_id") or row.get("market")) + borrower = _normalize_borrower(row.get("borrower") or row.get("onBehalf")) + bucket = row.get("defaultBucket") or row.get("default_bucket") + repayment_status = str(row.get("repaymentStatus") or "Unknown") + amount_due_raw = _as_int(row.get("amountDue")) + if ( + market_id is None + or borrower is None + or not isinstance(bucket, str) + or amount_due_raw <= 0 + or not _is_actionable_repayment_status(repayment_status) + ): + continue + key = (market_id, borrower.lower()) + if key in seen: + continue + seen.add(key) + parsed.append( + BorrowerRepaymentSnapshot( + market_id=market_id, + borrower=borrower, + cycle_id=_as_int(row.get("cycleId")), + cycle_end=_as_int(row.get("cycleEnd")), + amount_due_raw=amount_due_raw, + ending_balance_raw=_as_int(row.get("endingBalance")), + credit_raw=_as_int(row.get("credit")), + repayment_status=repayment_status, + default_at=_as_int(row.get("defaultAt")), + seconds_to_default=_as_int(row.get("secondsToDefault")), + seconds_since_default=_as_int(row.get("secondsSinceDefault")), + default_bucket=bucket, + ) + ) + + return parsed + + +def load_borrower_default_watch_snapshots_from_envio() -> list[BorrowerRepaymentSnapshot]: + """Load Envio-computed 3Jane borrower default watch rows.""" + snapshots: list[BorrowerRepaymentSnapshot] = [] + seen: set[tuple[str, str]] = set() + offset = 0 + + while True: + payload = gql_request(THREE_JANE_BORROWER_DEFAULT_WATCH_QUERY, {"limit": ENVIO_PAGE_SIZE, "offset": offset}) + if payload is None: + return snapshots + if payload.get("errors"): + logger.warning("3Jane Envio GraphQL errors; skipping borrower default watch: %s", payload["errors"]) + return snapshots + + rows = _extract_envio_borrower_default_watch_rows(payload) + page = parse_envio_borrower_default_watch_rows(rows) + for snapshot in page: + key = (snapshot.market_id, snapshot.borrower.lower()) + if key not in seen: + seen.add(key) + snapshots.append(snapshot) + + if len(rows) < ENVIO_PAGE_SIZE: + return snapshots + offset += ENVIO_PAGE_SIZE + + +def format_utc_timestamp(timestamp: int) -> str: + return datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def format_duration(seconds: int) -> str: + if seconds <= 0: + return "now" + days = seconds // SECONDS_PER_DAY + hours = (seconds % SECONDS_PER_DAY) // 3600 + minutes = (seconds % 3600) // 60 + parts: list[str] = [] + if days: + parts.append(f"{days}d") + if hours: + parts.append(f"{hours}h") + if minutes and not days: + parts.append(f"{minutes}m") + return " ".join(parts) if parts else f"{seconds}s" + + +def _borrower_default_cache_key(snapshot: BorrowerRepaymentSnapshot, bucket: str) -> str: + return ( + f"{CACHE_KEY_BORROWER_DEFAULT_WATCH_PREFIX}:" + f"{snapshot.market_id}:{snapshot.borrower.lower()}:" + f"{snapshot.cycle_id}:{snapshot.default_at}:{bucket}" + ) + + +def _default_watch_bucket_was_sent(snapshot: BorrowerRepaymentSnapshot, bucket: str) -> bool: + return str(get_last_value_for_key_from_file(CACHE_FILENAME, _borrower_default_cache_key(snapshot, bucket))) == "1" + + +def _mark_default_watch_bucket_sent(snapshot: BorrowerRepaymentSnapshot, bucket: str) -> None: + write_last_value_to_file(CACHE_FILENAME, _borrower_default_cache_key(snapshot, bucket), 1) + + +def check_borrower_default_watch_snapshot(snapshot: BorrowerRepaymentSnapshot) -> None: + """Send a MEDIUM-only borrower default countdown alert when a new bucket is reached.""" + bucket = snapshot.default_bucket + if bucket is None or _default_watch_bucket_was_sent(snapshot, bucket): + return + + amount_due = snapshot.amount_due_raw / ONE_SHARE + ending_balance = snapshot.ending_balance_raw / ONE_SHARE + credit = snapshot.credit_raw / ONE_SHARE + time_left = format_duration(snapshot.seconds_to_default) + time_since_default = format_duration(snapshot.seconds_since_default) + default_at = format_utc_timestamp(snapshot.default_at) + cycle_end = format_utc_timestamp(snapshot.cycle_end) + default_timing_line = ( + f"⏳ Defaulted at: {default_at} ({time_since_default} ago)" + if snapshot.repayment_status == "Default" + else f"⏳ Default at: {default_at} ({time_left})" + ) + + message = ( + f"⚠️ *3Jane Borrower Default Watch*\n" + f"📊 Status: {snapshot.repayment_status} ({bucket})\n" + f"👤 Borrower: `{snapshot.borrower}`\n" + f"🏦 Market: `{snapshot.market_id}`\n" + f"💰 Amount due: {format_usd(amount_due)} | Ending balance: {format_usd(ending_balance)}\n" + f"📏 Credit line: {format_usd(credit)}\n" + f"🗓️ Cycle end: {cycle_end}\n" + f"{default_timing_line}\n" + f"🔗 [Borrower](https://etherscan.io/address/{snapshot.borrower})" + ) + send_alert(Alert(AlertSeverity.MEDIUM, message, PROTOCOL)) + _mark_default_watch_bucket_sent(snapshot, bucket) + + +def check_borrower_default_watch(_client, _protocol_config) -> None: # type: ignore[no-untyped-def] + """Alert on Envio-computed 3Jane borrower default buckets.""" + snapshots = load_borrower_default_watch_snapshots_from_envio() + if not snapshots: + return + + logger.info("3Jane borrower default watch — Envio alert candidates: %d", len(snapshots)) + for snapshot in snapshots: + check_borrower_default_watch_snapshot(snapshot) + + def check_pps(usd3_pps_float: float, susd3_pps_float: float) -> None: """Check Price Per Share for USD3 and sUSD3, alert on any decrease. @@ -616,6 +895,7 @@ def main() -> None: check_debt_cap(client) check_nominal_backing_floor(nominal_floor, susd3_backing) check_protocol_paused(is_paused) + check_borrower_default_watch(client, protocol_config) logger.info( "Monitoring complete — USD3 PPS: %.8f, TVL: %s | sUSD3 PPS: %.8f, TVL: %s", diff --git a/tests/test_3jane.py b/tests/test_3jane.py index 2782e1c..c3ecdc4 100644 --- a/tests/test_3jane.py +++ b/tests/test_3jane.py @@ -145,3 +145,173 @@ def test_insurance_shares_round_trip_exactly_through_sqlite(monkeypatch: pytest. 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 + + +def test_parse_envio_borrower_default_watch_rows_dedupes_and_requires_alert_bucket() -> None: + module = load_3jane_module() + market_id = "0x" + "12" * 32 + borrower = "0x00000000000000000000000000000000000000a1" + + parsed = module.parse_envio_borrower_default_watch_rows( + [ + { + "marketId": market_id, + "borrower": borrower, + "credit": str(2_000_000 * module.ONE_SHARE), + "amountDue": str(250_000 * module.ONE_SHARE), + "cycleId": "4", + "cycleEnd": "1700000000", + "endingBalance": str(1_000_000 * module.ONE_SHARE), + "defaultAt": str(1_700_000_000 + 30 * module.SECONDS_PER_DAY), + "secondsToDefault": str(6 * module.SECONDS_PER_DAY), + "secondsSinceDefault": "0", + "repaymentStatus": "Delinquent", + "defaultBucket": "7d", + "settled": False, + }, + { + "marketId": market_id.upper(), + "borrower": borrower, + "amountDue": str(250_000 * module.ONE_SHARE), + "defaultBucket": "7d", + "settled": "false", + }, + {"marketId": market_id, "borrower": borrower, "amountDue": "1", "defaultBucket": "7d", "settled": True}, + {"marketId": market_id, "borrower": borrower, "amountDue": "1", "settled": False}, + {"marketId": market_id, "borrower": borrower, "amountDue": "0", "defaultBucket": "7d"}, + { + "marketId": market_id, + "borrower": borrower, + "amountDue": "1", + "repaymentStatus": "GracePeriod", + "defaultBucket": "7d", + }, + {"marketId": "bad", "borrower": borrower, "amountDue": "1", "defaultBucket": "7d"}, + {"marketId": market_id, "borrower": "not-an-address", "amountDue": "1", "defaultBucket": "7d"}, + ] + ) + + assert parsed == [ + module.BorrowerRepaymentSnapshot( + market_id=market_id, + borrower=module.Web3.to_checksum_address(borrower), + cycle_id=4, + cycle_end=1_700_000_000, + amount_due_raw=250_000 * module.ONE_SHARE, + ending_balance_raw=1_000_000 * module.ONE_SHARE, + credit_raw=2_000_000 * module.ONE_SHARE, + repayment_status="Delinquent", + default_at=1_700_000_000 + 30 * module.SECONDS_PER_DAY, + seconds_to_default=6 * module.SECONDS_PER_DAY, + seconds_since_default=0, + default_bucket="7d", + ) + ] + + +def test_borrower_default_watch_snapshot_without_envio_bucket_does_not_alert( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = load_3jane_module() + alerts: list = [] + monkeypatch.setattr(module, "send_alert", alerts.append) + + module.check_borrower_default_watch_snapshot( + module.BorrowerRepaymentSnapshot( + market_id="0x" + "34" * 32, + borrower="0x00000000000000000000000000000000000000A1", + cycle_id=4, + cycle_end=1_700_000_000, + amount_due_raw=250_000 * module.ONE_SHARE, + ending_balance_raw=1_000_000 * module.ONE_SHARE, + credit_raw=2_000_000 * module.ONE_SHARE, + repayment_status="GracePeriod", + default_at=1_700_000_000 + 30 * module.SECONDS_PER_DAY, + seconds_to_default=23 * module.SECONDS_PER_DAY, + seconds_since_default=0, + default_bucket=None, + ) + ) + assert alerts == [] + + +def test_borrower_default_watch_alert_is_medium_and_deduped(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + cache: dict[str, str] = {} + monkeypatch.setattr(module, "send_alert", alerts.append) + monkeypatch.setattr( + module, + "get_last_value_for_key_from_file", + lambda _filename, key: cache.get(key, 0), + ) + monkeypatch.setattr( + module, + "write_last_value_to_file", + lambda _filename, key, value: cache.__setitem__(key, str(value)), + ) + + snapshot = module.BorrowerRepaymentSnapshot( + market_id="0x" + "34" * 32, + borrower="0x00000000000000000000000000000000000000A1", + cycle_id=4, + cycle_end=1_700_000_000, + amount_due_raw=250_000 * module.ONE_SHARE, + ending_balance_raw=1_000_000 * module.ONE_SHARE, + credit_raw=2_000_000 * module.ONE_SHARE, + repayment_status="Delinquent", + default_at=1_700_000_000 + 30 * module.SECONDS_PER_DAY, + seconds_to_default=6 * module.SECONDS_PER_DAY, + seconds_since_default=0, + default_bucket="7d", + ) + + module.check_borrower_default_watch_snapshot(snapshot) + module.check_borrower_default_watch_snapshot(snapshot) + + assert len(alerts) == 1 + assert alerts[0].severity == module.AlertSeverity.MEDIUM + assert "3Jane Borrower Default Watch" in alerts[0].message + assert "Status: Delinquent (7d)" in alerts[0].message + assert "Ending balance" in alerts[0].message + assert len(cache) == 1 + + +def test_borrower_default_watch_alert_shows_time_since_default(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_3jane_module() + alerts: list = [] + cache: dict[str, str] = {} + monkeypatch.setattr(module, "send_alert", alerts.append) + monkeypatch.setattr( + module, + "get_last_value_for_key_from_file", + lambda _filename, key: cache.get(key, 0), + ) + monkeypatch.setattr( + module, + "write_last_value_to_file", + lambda _filename, key, value: cache.__setitem__(key, str(value)), + ) + + snapshot = module.BorrowerRepaymentSnapshot( + market_id="0x" + "56" * 32, + borrower="0x00000000000000000000000000000000000000A2", + cycle_id=5, + cycle_end=1_700_000_000, + amount_due_raw=100_000 * module.ONE_SHARE, + ending_balance_raw=900_000 * module.ONE_SHARE, + credit_raw=2_000_000 * module.ONE_SHARE, + repayment_status="Default", + default_at=1_700_000_000 + 30 * module.SECONDS_PER_DAY, + seconds_to_default=-2 * module.SECONDS_PER_DAY, + seconds_since_default=2 * module.SECONDS_PER_DAY + 90 * 60, + default_bucket="default", + ) + + module.check_borrower_default_watch_snapshot(snapshot) + + assert len(alerts) == 1 + assert alerts[0].severity == module.AlertSeverity.MEDIUM + assert "Status: Default (default)" in alerts[0].message + assert "Defaulted at:" in alerts[0].message + assert "2d 1h ago" in alerts[0].message From fbc7474c3008a808ba47d3a5d21840d1dd160655 Mon Sep 17 00:00:00 2001 From: yvtapir Date: Fri, 3 Jul 2026 19:25:30 +0300 Subject: [PATCH 2/5] fixes to engineers robot --- protocols/3jane/README.md | 15 +++-- protocols/3jane/main.py | 115 ++++++++++++++++++++++++++++++-------- tests/test_3jane.py | 93 ++++++++++++++++++++++-------- 3 files changed, 169 insertions(+), 54 deletions(-) diff --git a/protocols/3jane/README.md b/protocols/3jane/README.md index 77588ee..958371d 100644 --- a/protocols/3jane/README.md +++ b/protocols/3jane/README.md @@ -14,7 +14,7 @@ - **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. - **Protocol Pause:** `ProtocolConfig.config(keccak256("IS_PAUSED"))`. Alert-once on transition to true. Distinct from per-vault `isShutdown()` — pauses the underlying credit market. -- **Borrower Default Watch:** optional Envio-computed borrower default risk feed. The Envio indexer maintains `ThreeJaneBorrowerMarket` rows from MorphoCredit events, including repayment status, default timestamp, time since default, and alert bucket. The monitor only queries Delinquent/Default rows, sends **MEDIUM only** alerts, and dedupes per borrower/cycle/default milestone. +- **Borrower Default Watch:** optional Envio-backed borrower default risk feed. The Envio indexer maintains `ThreeJaneBorrowerMarket` rows from MorphoCredit events, and the monitor computes the current delinquent/default status at runtime. Alerts are **MEDIUM only** and deduped per borrower/cycle/default milestone. ## Key Contracts @@ -60,17 +60,16 @@ The monitor expects Envio to expose a `ThreeJaneBorrowerMarket` entity with at l | `cycleId` | Payment cycle id for the current obligation | | `cycleEnd` | Indexed cycle end timestamp | | `endingBalance` | Borrower balance at cycle close | -| `defaultAt` | Computed default timestamp | -| `secondsToDefault` | Envio-computed countdown at last refresh | -| `secondsSinceDefault` | Envio-computed time since default began | -| `repaymentStatus` | Computed repayment status | -| `defaultBucket` | Alert bucket (`delinquent`, `14d`, `7d`, `3d`, `1d`, `default`) | +| `gracePeriod` | Grace period in seconds | +| `delinquencyPeriod` | Delinquency period in seconds | +| `defaultAt` | Event-derived default timestamp | +| `defaultStarted` | Whether `DefaultStarted` has been emitted for the borrower | | `settled` | Whether the account was settled and should be skipped | -| `lastComputedBlock` | Ordering/pagination | +| `lastSeenBlock` | Ordering/pagination | The indexer should populate/update that entity from `SetCreditLine`, `Borrow`, `Repay`, `PaymentCycleCreated`, `RepaymentObligationPosted`, `RepaymentTracked`, `DefaultStarted`, `DefaultCleared`, and `AccountSettled` events on `MorphoCredit`. -Envio refreshes borrower repayment/default buckets once per day by default (`THREE_JANE_STATUS_BLOCK_INTERVAL=7200`, assuming 12-second Ethereum blocks). Grace and delinquency windows default to 7 days and 23 days respectively, and can be overridden with `THREE_JANE_GRACE_PERIOD_SECONDS` and `THREE_JANE_DELINQUENCY_PERIOD_SECONDS`. +The current countdown and alert bucket are intentionally computed in this monitoring script, not in Envio, because they depend on wall-clock time. Grace and delinquency windows default to 7 days and 23 days respectively in the indexer, and can be overridden there with `THREE_JANE_GRACE_PERIOD_SECONDS` and `THREE_JANE_DELINQUENCY_PERIOD_SECONDS`. ## Alert dispatch diff --git a/protocols/3jane/main.py b/protocols/3jane/main.py index e1a6fb7..fb21633 100644 --- a/protocols/3jane/main.py +++ b/protocols/3jane/main.py @@ -94,7 +94,7 @@ query GetThreeJaneBorrowerDefaultWatch($limit: Int!, $offset: Int!) { ThreeJaneBorrowerMarket( where: { settled: { _neq: true } } - order_by: { lastComputedBlock: asc } + order_by: { lastSeenBlock: asc } limit: $limit offset: $offset ) { @@ -106,13 +106,12 @@ cycleId cycleEnd endingBalance + gracePeriod + delinquencyPeriod defaultAt - secondsToDefault - secondsSinceDefault - repaymentStatus - defaultBucket + defaultStarted settled - lastComputedBlock + lastSeenBlock } } """ @@ -127,6 +126,7 @@ class BorrowerRepaymentSnapshot: amount_due_raw: int ending_balance_raw: int credit_raw: int + default_started: bool repayment_status: str default_at: int seconds_to_default: int @@ -195,6 +195,58 @@ def _is_actionable_repayment_status(status: str) -> bool: return status in {"Delinquent", "Default"} +def current_unix_timestamp() -> int: + return int(datetime.now(tz=timezone.utc).timestamp()) + + +def select_default_watch_bucket(repayment_status: str, seconds_to_default: int) -> str | None: + if repayment_status == "Default": + return "default" + if repayment_status != "Delinquent": + return None + if seconds_to_default <= SECONDS_PER_DAY: + return "1d" + if seconds_to_default <= 3 * SECONDS_PER_DAY: + return "3d" + if seconds_to_default <= 7 * SECONDS_PER_DAY: + return "7d" + if seconds_to_default <= 14 * SECONDS_PER_DAY: + return "14d" + return "delinquent" + + +def compute_default_watch_status( + amount_due_raw: int, + cycle_end: int, + grace_period: int, + delinquency_period: int, + default_started: bool, + now_timestamp: int, +) -> tuple[str, int, int, str | None] | None: + if amount_due_raw <= 0 or cycle_end <= 0: + return None + + grace_end = cycle_end + grace_period + default_at = grace_end + delinquency_period + seconds_to_default = default_at - now_timestamp + + if default_started or now_timestamp >= default_at: + repayment_status = "Default" + seconds_since_default = max(0, now_timestamp - default_at) + elif now_timestamp > grace_end: + repayment_status = "Delinquent" + seconds_since_default = 0 + else: + return None + + return ( + repayment_status, + seconds_to_default, + seconds_since_default, + select_default_watch_bucket(repayment_status, seconds_to_default), + ) + + def http_json(url: str, body: dict[str, Any]) -> dict[str, Any] | None: """POST JSON and return parsed JSON, or None on transient/indexer errors.""" req = urllib.request.Request( @@ -230,8 +282,13 @@ def _extract_envio_borrower_default_watch_rows(payload: dict[str, Any]) -> list[ return rows if isinstance(rows, list) else [] -def parse_envio_borrower_default_watch_rows(rows: list[dict[str, Any]]) -> list[BorrowerRepaymentSnapshot]: - """Parse Envio-computed 3Jane borrower default risk rows.""" +def parse_envio_borrower_default_watch_rows( + rows: list[dict[str, Any]], now_timestamp: int | None = None +) -> list[BorrowerRepaymentSnapshot]: + """Parse Envio 3Jane borrower rows and compute current default risk.""" + if now_timestamp is None: + now_timestamp = current_unix_timestamp() + parsed: list[BorrowerRepaymentSnapshot] = [] seen: set[tuple[str, str]] = set() @@ -240,16 +297,24 @@ def parse_envio_borrower_default_watch_rows(rows: list[dict[str, Any]]) -> list[ continue market_id = _normalize_market_id(row.get("marketId") or row.get("market_id") or row.get("market")) borrower = _normalize_borrower(row.get("borrower") or row.get("onBehalf")) - bucket = row.get("defaultBucket") or row.get("default_bucket") - repayment_status = str(row.get("repaymentStatus") or "Unknown") amount_due_raw = _as_int(row.get("amountDue")) - if ( - market_id is None - or borrower is None - or not isinstance(bucket, str) - or amount_due_raw <= 0 - or not _is_actionable_repayment_status(repayment_status) - ): + cycle_end = _as_int(row.get("cycleEnd")) + grace_period = _as_int(row.get("gracePeriod"), 7 * SECONDS_PER_DAY) + delinquency_period = _as_int(row.get("delinquencyPeriod"), 23 * SECONDS_PER_DAY) + default_started = _as_bool(row.get("defaultStarted")) + default_watch_status = compute_default_watch_status( + amount_due_raw, + cycle_end, + grace_period, + delinquency_period, + default_started, + now_timestamp, + ) + if market_id is None or borrower is None or default_watch_status is None: + continue + + repayment_status, seconds_to_default, seconds_since_default, bucket = default_watch_status + if bucket is None or not _is_actionable_repayment_status(repayment_status): continue key = (market_id, borrower.lower()) if key in seen: @@ -260,14 +325,15 @@ def parse_envio_borrower_default_watch_rows(rows: list[dict[str, Any]]) -> list[ market_id=market_id, borrower=borrower, cycle_id=_as_int(row.get("cycleId")), - cycle_end=_as_int(row.get("cycleEnd")), + cycle_end=cycle_end, amount_due_raw=amount_due_raw, ending_balance_raw=_as_int(row.get("endingBalance")), credit_raw=_as_int(row.get("credit")), + default_started=default_started, repayment_status=repayment_status, - default_at=_as_int(row.get("defaultAt")), - seconds_to_default=_as_int(row.get("secondsToDefault")), - seconds_since_default=_as_int(row.get("secondsSinceDefault")), + default_at=cycle_end + grace_period + delinquency_period, + seconds_to_default=seconds_to_default, + seconds_since_default=seconds_since_default, default_bucket=bucket, ) ) @@ -276,10 +342,11 @@ def parse_envio_borrower_default_watch_rows(rows: list[dict[str, Any]]) -> list[ def load_borrower_default_watch_snapshots_from_envio() -> list[BorrowerRepaymentSnapshot]: - """Load Envio-computed 3Jane borrower default watch rows.""" + """Load Envio 3Jane borrower rows and compute current default watch candidates.""" snapshots: list[BorrowerRepaymentSnapshot] = [] seen: set[tuple[str, str]] = set() offset = 0 + now_timestamp = current_unix_timestamp() while True: payload = gql_request(THREE_JANE_BORROWER_DEFAULT_WATCH_QUERY, {"limit": ENVIO_PAGE_SIZE, "offset": offset}) @@ -290,7 +357,7 @@ def load_borrower_default_watch_snapshots_from_envio() -> list[BorrowerRepayment return snapshots rows = _extract_envio_borrower_default_watch_rows(payload) - page = parse_envio_borrower_default_watch_rows(rows) + page = parse_envio_borrower_default_watch_rows(rows, now_timestamp) for snapshot in page: key = (snapshot.market_id, snapshot.borrower.lower()) if key not in seen: @@ -373,7 +440,7 @@ def check_borrower_default_watch_snapshot(snapshot: BorrowerRepaymentSnapshot) - def check_borrower_default_watch(_client, _protocol_config) -> None: # type: ignore[no-untyped-def] - """Alert on Envio-computed 3Jane borrower default buckets.""" + """Alert on 3Jane borrower default buckets computed from Envio rows.""" snapshots = load_borrower_default_watch_snapshots_from_envio() if not snapshots: return diff --git a/tests/test_3jane.py b/tests/test_3jane.py index c3ecdc4..0888c9b 100644 --- a/tests/test_3jane.py +++ b/tests/test_3jane.py @@ -147,10 +147,13 @@ def test_insurance_shares_round_trip_exactly_through_sqlite(monkeypatch: pytest. assert module.get_cache_int(module.CACHE_KEY_INSURANCE_FUND_SHARES) == 868_288_861_448 -def test_parse_envio_borrower_default_watch_rows_dedupes_and_requires_alert_bucket() -> None: +def test_parse_envio_borrower_default_watch_rows_computes_bucket_and_dedupes() -> None: module = load_3jane_module() market_id = "0x" + "12" * 32 borrower = "0x00000000000000000000000000000000000000a1" + cycle_end = 1_700_000_000 + default_at = cycle_end + 30 * module.SECONDS_PER_DAY + now = default_at - 6 * module.SECONDS_PER_DAY parsed = module.parse_envio_borrower_default_watch_rows( [ @@ -160,35 +163,27 @@ def test_parse_envio_borrower_default_watch_rows_dedupes_and_requires_alert_buck "credit": str(2_000_000 * module.ONE_SHARE), "amountDue": str(250_000 * module.ONE_SHARE), "cycleId": "4", - "cycleEnd": "1700000000", + "cycleEnd": str(cycle_end), "endingBalance": str(1_000_000 * module.ONE_SHARE), - "defaultAt": str(1_700_000_000 + 30 * module.SECONDS_PER_DAY), - "secondsToDefault": str(6 * module.SECONDS_PER_DAY), - "secondsSinceDefault": "0", - "repaymentStatus": "Delinquent", - "defaultBucket": "7d", + "gracePeriod": str(7 * module.SECONDS_PER_DAY), + "delinquencyPeriod": str(23 * module.SECONDS_PER_DAY), + "defaultStarted": False, "settled": False, }, { "marketId": market_id.upper(), "borrower": borrower, "amountDue": str(250_000 * module.ONE_SHARE), - "defaultBucket": "7d", + "cycleEnd": str(cycle_end), "settled": "false", }, - {"marketId": market_id, "borrower": borrower, "amountDue": "1", "defaultBucket": "7d", "settled": True}, + {"marketId": market_id, "borrower": borrower, "amountDue": "1", "settled": True}, {"marketId": market_id, "borrower": borrower, "amountDue": "1", "settled": False}, - {"marketId": market_id, "borrower": borrower, "amountDue": "0", "defaultBucket": "7d"}, - { - "marketId": market_id, - "borrower": borrower, - "amountDue": "1", - "repaymentStatus": "GracePeriod", - "defaultBucket": "7d", - }, - {"marketId": "bad", "borrower": borrower, "amountDue": "1", "defaultBucket": "7d"}, - {"marketId": market_id, "borrower": "not-an-address", "amountDue": "1", "defaultBucket": "7d"}, - ] + {"marketId": market_id, "borrower": borrower, "amountDue": "0", "cycleEnd": str(cycle_end)}, + {"marketId": "bad", "borrower": borrower, "amountDue": "1", "cycleEnd": str(cycle_end)}, + {"marketId": market_id, "borrower": "not-an-address", "amountDue": "1", "cycleEnd": str(cycle_end)}, + ], + now, ) assert parsed == [ @@ -196,12 +191,13 @@ def test_parse_envio_borrower_default_watch_rows_dedupes_and_requires_alert_buck market_id=market_id, borrower=module.Web3.to_checksum_address(borrower), cycle_id=4, - cycle_end=1_700_000_000, + cycle_end=cycle_end, amount_due_raw=250_000 * module.ONE_SHARE, ending_balance_raw=1_000_000 * module.ONE_SHARE, credit_raw=2_000_000 * module.ONE_SHARE, + default_started=False, repayment_status="Delinquent", - default_at=1_700_000_000 + 30 * module.SECONDS_PER_DAY, + default_at=default_at, seconds_to_default=6 * module.SECONDS_PER_DAY, seconds_since_default=0, default_bucket="7d", @@ -225,6 +221,7 @@ def test_borrower_default_watch_snapshot_without_envio_bucket_does_not_alert( amount_due_raw=250_000 * module.ONE_SHARE, ending_balance_raw=1_000_000 * module.ONE_SHARE, credit_raw=2_000_000 * module.ONE_SHARE, + default_started=False, repayment_status="GracePeriod", default_at=1_700_000_000 + 30 * module.SECONDS_PER_DAY, seconds_to_default=23 * module.SECONDS_PER_DAY, @@ -259,6 +256,7 @@ def test_borrower_default_watch_alert_is_medium_and_deduped(monkeypatch: pytest. amount_due_raw=250_000 * module.ONE_SHARE, ending_balance_raw=1_000_000 * module.ONE_SHARE, credit_raw=2_000_000 * module.ONE_SHARE, + default_started=False, repayment_status="Delinquent", default_at=1_700_000_000 + 30 * module.SECONDS_PER_DAY, seconds_to_default=6 * module.SECONDS_PER_DAY, @@ -301,6 +299,7 @@ def test_borrower_default_watch_alert_shows_time_since_default(monkeypatch: pyte amount_due_raw=100_000 * module.ONE_SHARE, ending_balance_raw=900_000 * module.ONE_SHARE, credit_raw=2_000_000 * module.ONE_SHARE, + default_started=True, repayment_status="Default", default_at=1_700_000_000 + 30 * module.SECONDS_PER_DAY, seconds_to_default=-2 * module.SECONDS_PER_DAY, @@ -315,3 +314,53 @@ def test_borrower_default_watch_alert_shows_time_since_default(monkeypatch: pyte assert "Status: Default (default)" in alerts[0].message assert "Defaulted at:" in alerts[0].message assert "2d 1h ago" in alerts[0].message + + +def test_parse_envio_borrower_default_watch_rows_skips_grace_period() -> None: + module = load_3jane_module() + market_id = "0x" + "78" * 32 + borrower = "0x00000000000000000000000000000000000000a3" + cycle_end = 1_700_000_000 + + parsed = module.parse_envio_borrower_default_watch_rows( + [ + { + "marketId": market_id, + "borrower": borrower, + "amountDue": str(250_000 * module.ONE_SHARE), + "cycleEnd": str(cycle_end), + "gracePeriod": str(7 * module.SECONDS_PER_DAY), + "delinquencyPeriod": str(23 * module.SECONDS_PER_DAY), + }, + ], + cycle_end + 3 * module.SECONDS_PER_DAY, + ) + + assert parsed == [] + + +def test_parse_envio_borrower_default_watch_rows_default_started_forces_default() -> None: + module = load_3jane_module() + market_id = "0x" + "9a" * 32 + borrower = "0x00000000000000000000000000000000000000a4" + cycle_end = 1_700_000_000 + default_at = cycle_end + 30 * module.SECONDS_PER_DAY + + parsed = module.parse_envio_borrower_default_watch_rows( + [ + { + "marketId": market_id, + "borrower": borrower, + "amountDue": str(250_000 * module.ONE_SHARE), + "cycleId": "8", + "cycleEnd": str(cycle_end), + "defaultStarted": True, + }, + ], + default_at - module.SECONDS_PER_DAY, + ) + + assert len(parsed) == 1 + assert parsed[0].repayment_status == "Default" + assert parsed[0].default_bucket == "default" + assert parsed[0].seconds_since_default == 0 From ea6233d7d3621fe1c02c4a92fd28cf8044d3b6f1 Mon Sep 17 00:00:00 2001 From: yvtapir Date: Fri, 3 Jul 2026 19:56:27 +0300 Subject: [PATCH 3/5] spalens review --- protocols/3jane/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/protocols/3jane/main.py b/protocols/3jane/main.py index fb21633..9d2f4b1 100644 --- a/protocols/3jane/main.py +++ b/protocols/3jane/main.py @@ -265,7 +265,7 @@ def http_json(url: str, body: dict[str, Any]) -> dict[str, Any] | None: def gql_request(query: str, variables: dict[str, Any]) -> dict[str, Any] | None: if not ENVIO_GRAPHQL_URL: - logger.info("ENVIO_GRAPHQL_URL is not set; skipping borrower default watch") + logger.warning("ENVIO_GRAPHQL_URL is not set; skipping borrower default watch") return None return http_json(ENVIO_GRAPHQL_URL, {"query": query, "variables": variables}) @@ -295,7 +295,7 @@ def parse_envio_borrower_default_watch_rows( for row in rows: if not isinstance(row, dict) or _as_bool(row.get("settled")): continue - market_id = _normalize_market_id(row.get("marketId") or row.get("market_id") or row.get("market")) + market_id = _normalize_market_id(row.get("marketId")) borrower = _normalize_borrower(row.get("borrower") or row.get("onBehalf")) amount_due_raw = _as_int(row.get("amountDue")) cycle_end = _as_int(row.get("cycleEnd")) From 32f18654a029f98626931be2cf52894f1167dc3a Mon Sep 17 00:00:00 2001 From: yvtapir Date: Fri, 3 Jul 2026 19:58:35 +0300 Subject: [PATCH 4/5] readme update for loan statuses --- protocols/3jane/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/protocols/3jane/README.md b/protocols/3jane/README.md index 958371d..405ea2d 100644 --- a/protocols/3jane/README.md +++ b/protocols/3jane/README.md @@ -49,6 +49,15 @@ Set `ENVIO_GRAPHQL_URL` to the 3Jane Envio GraphQL endpoint to enable proactive borrower monitoring. Without this env var, the borrower default watch is skipped and all other 3Jane checks continue normally. +Borrowers move through repayment states based on the active repayment obligation: + +- `Current`: no unpaid obligation, or the payment cycle is still open. +- `GracePeriod`: the cycle ended and `amountDue > 0`, but the borrower is still inside the grace window. This does not alert. +- `Delinquent`: the grace window has passed and `amountDue > 0`, but the default timestamp has not been reached yet. This is the proactive warning period, and the monitor alerts at `delinquent`, `14d`, `7d`, `3d`, and `1d` buckets. +- `Default`: the default timestamp has passed, or the protocol emitted `DefaultStarted`. The monitor sends a MEDIUM alert and includes how long the borrower has been defaulted. + +By default, `defaultAt = cycleEnd + 7 days grace + 23 days delinquency`. These windows come from `gracePeriod` and `delinquencyPeriod` on the indexed borrower row. + The monitor expects Envio to expose a `ThreeJaneBorrowerMarket` entity with at least: | Field | Purpose | From fa569e0aaa220a7ec03fc6a628a3950a4e8a490f Mon Sep 17 00:00:00 2001 From: yvtapir Date: Fri, 3 Jul 2026 20:03:05 +0300 Subject: [PATCH 5/5] update monitoring.yaml --- monitoring.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/monitoring.yaml b/monitoring.yaml index f75c50d..9a1e658 100644 --- a/monitoring.yaml +++ b/monitoring.yaml @@ -43,6 +43,8 @@ protocols: description: "Any floor change; alert-once when floor > sUSD3 backing" - name: "Protocol Pause" description: "Alert-once when ProtocolConfig IS_PAUSED flips true" + - name: "Borrower Default Watch" + description: "Envio-backed MorphoCredit borrower watch; MEDIUM alerts when unpaid obligations become delinquent after grace or reach default" - name: "Timelock" description: "CallScheduled events from 24h and 7-day TimelockControllers via Envio"