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" diff --git a/protocols/3jane/README.md b/protocols/3jane/README.md index ebd1b2d..405ea2d 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-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 @@ -41,8 +42,44 @@ | 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. + +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 | +|-------|---------| +| `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 | +| `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 | +| `lastSeenBlock` | Ordering/pagination | + +The indexer should populate/update that entity from `SetCreditLine`, `Borrow`, `Repay`, `PaymentCycleCreated`, `RepaymentObligationPosted`, `RepaymentTracked`, `DefaultStarted`, `DefaultCleared`, and `AccountSettled` events on `MorphoCredit`. + +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 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..9d2f4b1 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: { lastSeenBlock: asc } + limit: $limit + offset: $offset + ) { + id + marketId + borrower + credit + amountDue + cycleId + cycleEnd + endingBalance + gracePeriod + delinquencyPeriod + defaultAt + defaultStarted + settled + lastSeenBlock + } +} +""" + + +@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 + default_started: bool + 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,295 @@ 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 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( + 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.warning("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]], 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() + + for row in rows: + if not isinstance(row, dict) or _as_bool(row.get("settled")): + continue + 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")) + 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: + continue + seen.add(key) + parsed.append( + BorrowerRepaymentSnapshot( + market_id=market_id, + borrower=borrower, + cycle_id=_as_int(row.get("cycleId")), + 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=cycle_end + grace_period + delinquency_period, + seconds_to_default=seconds_to_default, + seconds_since_default=seconds_since_default, + default_bucket=bucket, + ) + ) + + return parsed + + +def load_borrower_default_watch_snapshots_from_envio() -> list[BorrowerRepaymentSnapshot]: + """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}) + 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, now_timestamp) + 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 3Jane borrower default buckets computed from Envio rows.""" + 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 +962,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..0888c9b 100644 --- a/tests/test_3jane.py +++ b/tests/test_3jane.py @@ -145,3 +145,222 @@ 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_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( + [ + { + "marketId": market_id, + "borrower": borrower, + "credit": str(2_000_000 * module.ONE_SHARE), + "amountDue": str(250_000 * module.ONE_SHARE), + "cycleId": "4", + "cycleEnd": str(cycle_end), + "endingBalance": str(1_000_000 * module.ONE_SHARE), + "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), + "cycleEnd": str(cycle_end), + "settled": "false", + }, + {"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", "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 == [ + module.BorrowerRepaymentSnapshot( + market_id=market_id, + borrower=module.Web3.to_checksum_address(borrower), + cycle_id=4, + 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=default_at, + 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, + 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, + 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, + 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, + 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, + 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, + 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 + + +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