feat: monitor Yearn lender-borrower risk - #342
Conversation
|
Review of 1.
|
| 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— theexcept Exceptionpath callssend_alertunconditionally, unlike the fingerprinted breach path. A Katana RPC outage or oracle revert produces 48 identical MEDIUM alerts/day fromhalf_hourly. The samestorefingerprint mechanism should gate error alerts.:555— breach and monitor-error alerts shareAlertSeverity.MEDIUM, making them indistinguishable. Perutils/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_tokenduplicateutils/formatting.pyand render USD differently from the rest of the repo ($675,905.76vsformat_usd's$675.91K), so the yearn channel gets two USD styles.normalize_token_amount+format_decimal_amountalready solve theDecimalprecision problem_format_tokenre-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 needsudo systemctl restart monitoringto render into the supercronic crontab (cmd_render_crontabemits one line per profile). Without it both tasks look configured, passtests/test_monitoring_config.py, and never execute. Worth calling out in the description/runbook, or folding the LTV task into the existinghourlyprofile.:41—MorphoCore.json,MorphoIrm.jsonandMorphoOracle.jsonare Morpho ABIs filed underprotocols/yearn/abi/. No duplication today (protocols/morpho/abi/morpho.jsonis a MetaMorpho vault ABI), butprotocols/morpho/abi/orcommon-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 to0xcd2dc555dced7422a3144a4126286675449019366f83e9717be7c2deb3daae3e, already registered asMARKETS_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 currentmarketParams/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_metadata—lltvplus loan symbol/decimals, replacing two of the four ERC-20 metadata calls;utils/erc20_metadata.fetch_erc20_metadatais 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_sentre-implement by hand. The hand-rolled version is correct for network failures (send_telegram_messageraises), 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
|
| strategy.functions.name(), | ||
| strategy.functions.asset(), | ||
| strategy.functions.borrowToken(), | ||
| strategy.functions.lenderVault(), | ||
| strategy.functions.morpho(), | ||
| strategy.functions.marketId(), | ||
| strategy.functions.borrowUsdOracle(), | ||
| strategy.functions.marketParams(), |
There was a problem hiding this comment.
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.
| 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. |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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,
)| collateral_token.functions.symbol(), | ||
| collateral_token.functions.decimals(), | ||
| borrow_token.functions.symbol(), | ||
| borrow_token.functions.decimals(), |
There was a problem hiding this comment.
We should also store these values once in StrategyConfig.
|
|
||
|
|
||
| def _error_state_key(config: StrategyConfig, checks: str) -> str: | ||
| return f"{config.address.lower()}:{checks}" |
There was a problem hiding this comment.
_alert_state_key (L533) and _error_state_key (L567) are byte-identical. Different namespaces keep them from colliding.
No description provided.