Skip to content

feat: monitor Yearn lender-borrower risk - #342

Merged
spalen0 merged 3 commits into
mainfrom
codex/lender-borrower-monitor
Aug 25, 2026
Merged

feat: monitor Yearn lender-borrower risk#342
spalen0 merged 3 commits into
mainfrom
codex/lender-borrower-monitor

Conversation

@tapired

@tapired tapired commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

@tapired
tapired requested a review from spalen0 August 24, 2026 16:44
@spalen0

spalen0 commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Review of codex/lender-borrower-monitor. Ran the script live against Katana (both --dry-run modes work); tests, ruff and mypy are clean. Findings below are ordered by severity, and the on-chain numbers were measured against the live market (0xcd2dc555dced7422a3144a4126286675449019366f83e9717be7c2deb3daae3e, Morpho 0xd50f2dfffd62f94ee4aed9ca05c61d0753268abc).

1. borrowRateView returns a window average, so borrow APR is understated on stale markets

protocols/yearn/lender_borrower.py:379

AdaptiveCurveIRM computes elapsed = block.timestamp - market.lastUpdate and returns _curve(avgRateAtTarget, err) — the average rate over that window, not the current rate. accrue_market() returns the market with lastUpdate unchanged, so the second borrowRateView(params, expected_market) re-averages over the same window and lands within 1.5e-5 of the first call (1203406963 vs 1203424711). It's an extra eth_call every 6h that buys nothing measurable.

The real cost is the averaging itself. Divergence from the true instantaneous rate scales with err, so it is worst at high utilization — exactly when a leveraged position is in trouble:

util 99% (err +0.90) avg (current code) instantaneous error
6h stale 9.3075% 9.4514% -1.52%
1d stale 9.7557% 10.3652% -5.88%
3d stale 11.1396% 13.3071% -16.29%
7d stale 14.7984% 21.7756% -32.04%

Above target utilization the borrow APR is understated, which inflates net_spread and pushes it away from the -1% threshold — a missed alert, not a false one. (Below target the sign flips, which only over-alerts.) At err = 0 all variants agree exactly.

Note that setting lastUpdate = now is not the fix — that drops the adaptation entirely and returns _curve(startRateAtTarget, err), which measures as consistently 2x the error in the same direction (-11.58% at util 99% / 1d).

Suggested fix — no extra RPC. Two borrowRateView calls are already being spent. Spend the second at elapsed = 0 instead of on the accrued-balance re-call, and the instantaneous rate falls out algebraically, with no need to replicate Morpho's wExp:

k_start = _call_irm(client, irm, market_params, market[:4] + (block_timestamp, market[5]))
avg     = _call_irm(client, irm, market_params, market)   # the call already being made
u       = 2 * isqrt(avg * WAD**2 // k_start) - WAD         # avg = start*((1+u)/2)^2
borrow_rate_per_second = k_start * u * u // WAD**2         # end = start*u^2

_curve is linear in rateAtTarget, so avg / k_start = ((1+u)/2)^2 inverts exactly. Validated against the contract: implied adaptation speed 2.808525e-07 (probing at 1d) and 2.800897e-07 (at 7d) vs theoretical ADJUSTMENT_SPEED * err = 2.808598e-07.

Keep stored_rate as the input to accrue_market — that is exactly what Morpho's own _accrueInterest uses, so balance accrual stays faithful. Only borrow_apr_wad changes. Worth a sanity bound on u: _newRateAtTarget clamps at MAX_RATE_AT_TARGET (200% APR at target), which would break the inversion, though from 2.48% that needs ~32 days of staleness at max err.

Exposure today is small — the market was 2.3h stale when measured, and any supply/withdraw/borrow/repay/liquidate by anyone accrues it. It grows as quieter markets get added.

2. Lender APR of 0 is accepted as a real 0% and fires a false alert

protocols/yearn/lender_borrower.py:381

apr_oracle.oracles(lenderVault) is address(0) for 0x80c34BD3A3569E126e7055831036aa7b212cB159, so getStrategyApr falls back to the vault's profit-unlocking rate. Once fullProfitUnlockDate (1787985407) passes with no new report, that path returns 0. The monitor then records spread = 0 - borrowAPR ~= -3.8% on every sample and fires a MEDIUM "24h average net spread is below -1%" alert on a healthy position after four samples. Treat a zero/absent lender APR as "no data" and skip the sample.

3. Chainlink latestRoundData decoded by index, no staleness check

protocols/yearn/lender_borrower.py:343

Only price_round[1] is read; updatedAt is discarded, so a frozen borrowUsdOracle silently feeds borrow_price_usd_e8, collateral_price_usd_e8 and the $ figure in the debt-coverage gate. utils/chainlink.py (read_feeds, RoundData.from_tuple) already batches latestRoundData + decimals, validates the 5-tuple and exposes updated_at — reusing it removes ~10 lines and closes the gap.

4. minimum_rate_samples=4 at a 6-hourly cadence has no margin

protocols/yearn/lender_borrower.py:59

Four runs/day means exactly 4-5 samples survive prune_rate_samples. One missed run drops it to 3, average_spread() returns None, and the net-spread check goes dark for ~18-24h with no signal in the channel. Either sample rates on the existing 30-minute LTV run (2 extra eth_calls, 48 samples/day) or lower the minimum to 3.

5. No check on the borrow market's own liquidity

protocols/yearn/lender_borrower.py:224

The market is at 91.8% utilization (totalSupplyAssets 14.45M vs totalBorrowAssets 13.26M) against 675k of strategy debt. As utilization approaches 100% the strategy cannot repay or unwind, while LTV and spread checks stay green throughout. protocols/morpho/_shared.fetch_market_metrics([market_id], chain) already returns utilization, borrow_assets, supply_assets and bad_debt for this exact market.

6. Alert hygiene

  • :578 — the except Exception path calls send_alert unconditionally, unlike the fingerprinted breach path. A Katana RPC outage or oracle revert produces 48 identical MEDIUM alerts/day from half_hourly. The same store fingerprint mechanism should gate error alerts.
  • :555 — breach and monitor-error alerts share AlertSeverity.MEDIUM, making them indistinguishable. Per utils/alert.py's own guide, a live LTV breach reads as HIGH; MEDIUM is for "fetch errors, stale data, degraded state".
  • :487_format_usd / _format_token duplicate utils/formatting.py and render USD differently from the rest of the repo ($675,905.76 vs format_usd's $675.91K), so the yearn channel gets two USD styles. normalize_token_amount + format_decimal_amount already solve the Decimal precision problem _format_token re-implements.
  • Per CLAUDE.md, addresses should be printed as full links — none of the alert lines currently are.

7. Ops / layout

  • automation/jobs.yaml:20 — two brand-new profiles need sudo systemctl restart monitoring to render into the supercronic crontab (cmd_render_crontab emits one line per profile). Without it both tasks look configured, pass tests/test_monitoring_config.py, and never execute. Worth calling out in the description/runbook, or folding the LTV task into the existing hourly profile.
  • :41MorphoCore.json, MorphoIrm.json and MorphoOracle.json are Morpho ABIs filed under protocols/yearn/abi/. No duplication today (protocols/morpho/abi/morpho.json is a MetaMorpho vault ABI), but protocols/morpho/abi/ or common-abi/ would save the next Morpho monitor from re-adding them.

Reuse from protocols/morpho/

The on-chain layer here (Morpho Blue market(), borrowRateView, wTaylorCompounded, oracle price()) genuinely does not exist in the repo — the current Morpho monitors are entirely GraphQL-based. So the IRM/accrual code is not duplication. What is reusable:

  • risk.py::get_market_risk_level — the strategy's market resolves on-chain to 0xcd2dc555dced7422a3144a4126286675449019366f83e9717be7c2deb3daae3e, already registered as MARKETS_RISK_1[Chain.KATANA] (protocols/morpho/risk.py:48). Calling it asserts the market is vetted, surfaces its tier in the alert, and loudly catches a market migration that the current marketParams/LLTV asserts would only report as a generic error.
  • _shared.py::fetch_market_metrics — supplies the missing liquidity check in Add bad debt alerts #5; Katana is already covered by the Morpho API.
  • _shared.py::get_market_url — Morpho UI deep link for the alert body (currently only links JOC).
  • _shared.py::fetch_market_metadatalltv plus loan symbol/decimals, replacing two of the four ERC-20 metadata calls; utils/erc20_metadata.fetch_erc20_metadata is the on-chain cached alternative if an API dependency is unwanted.
  • _alerts.py::VaultDiff — the established "buffer findings, commit cache writes only after delivery succeeds" pattern that _should_send_alert / _record_alert_sent re-implement by hand. The hand-rolled version is correct for network failures (send_telegram_message raises), but it does record a send when Telegram credentials are missing.

Plus utils/chainlink.py (#3) and utils/formatting.py (#6).

🤖 Generated with Claude Code

@tapired

tapired commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author
  • Morpho market liquidity: Not added as a fourth check. High utilization increases the borrow rate, which the corrected spread check captures, but it does not prevent repayment—repaying adds liquidity. Lender-vault withdrawability would be the more relevant unwind constraint.

  • HIGH/CRITICAL severity: Alerts remain MEDIUM because the requirement is Telegram notification only. MEDIUM sends a non-silent Telegram alert without invoking the HIGH/CRITICAL emergency-dispatch hook. No dispatch is needed for this monitoring.

  • Morpho GraphQL/risk helpers: Not added to keep this monitor fully on-chain and avoid introducing an external API dependency outside the three requested metrics.

  • Formatting, additional links, and ABI relocation: Left for separate cleanup because they do not affect monitoring correctness. Exact values are useful for the debt-coverage threshold, and alerts already include the JOC strategy link.

Comment thread protocols/yearn/lender_borrower.py Outdated
Comment on lines +337 to +344
strategy.functions.name(),
strategy.functions.asset(),
strategy.functions.borrowToken(),
strategy.functions.lenderVault(),
strategy.functions.morpho(),
strategy.functions.marketId(),
strategy.functions.borrowUsdOracle(),
strategy.functions.marketParams(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of these values should change, we should create a data class with this data alongside the strategy address, tell the agent to fetch it only once and add it to StrategyConfig.
if you use this every 30min it is dumb to burn RPC on calls on static data.

Comment thread protocols/yearn/README.md Outdated
Comment on lines +7 to +10
The script `yearn/lender_borrower.py` monitors the active Katana Morpho
`vbWBTC/yvUSDC` lender-borrower strategy. The strategy deposits vbWBTC as
Morpho collateral, borrows vbUSDC, and lends the borrowed vbUSDC into the Yearn
vbUSDC vault.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pls, keep everything in one line. This is not code that we need to break after 100 characters. The same should apply to other stuff added to this readme, keep it clean and in line with the current format

from utils.logger import get_logger
from utils.web3_wrapper import ChainManager, Web3Client

PROTOCOL = "yearn"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would keep these alerts in the internal channel. Either new channel for us and vaults, or send it to the curation channel for testing, like following:

from utils.telegram import CURATION_CHANNEL, resolve_channel

send_alert(
    Alert(AlertSeverity.MEDIUM, message, PROTOCOL, channel=resolve_channel(CURATION_CHANNEL, PROTOCOL)),
    plain_text=True,
)

Comment thread protocols/yearn/lender_borrower.py Outdated
Comment on lines +403 to +406
collateral_token.functions.symbol(),
collateral_token.functions.decimals(),
borrow_token.functions.symbol(),
borrow_token.functions.decimals(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also store these values once in StrategyConfig.

Comment thread protocols/yearn/lender_borrower.py Outdated


def _error_state_key(config: StrategyConfig, checks: str) -> str:
return f"{config.address.lower()}:{checks}"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_alert_state_key (L533) and _error_state_key (L567) are byte-identical. Different namespaces keep them from colliding.

@spalen0
spalen0 merged commit 970d5bf into main Aug 25, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants