Repository navigation
feat(morpho): critical alerts for market removals that can lose vault funds - #436
Conversation
… funds V1 submitMarketRemoval is now CRITICAL when the vault still supplies the market (any amount), with the amount at risk and its share of totalAssets, and re-alerted once within 24h of removableAt. Empty-market removals stay MEDIUM. The V1 timelock alert now shows current -> new and executable time. V2 removeAdapter of an adapter holding assets, and decreaseTimelock on removeAdapter while adapters hold assets, are CRITICAL; other timelock decreases and abdicate are HIGH. removeAdapter labels name the adapter again. New adapter_timelocks_v2 reads MorphoMarketV1AdapterV2's own timelock on-chain (not in the Morpho API): executableAt(burnShares(id)) for every held market, plus a HIGH alert when timelock(burnShares) drops. Loss findings are sent as their own "POTENTIAL VAULT LOSS" message on the morpho channel. Adds Robinhood's official public RPC as a fallback. Refs #434 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Noncanonical calldata: executableAt is keyed by exact calldata and Solidity ignores trailing bytes, so a padded burnShares submission evaded the canonical-key check (reproduced on a mainnet fork: padded submit pending, canonical key 0, padded call burned the shares). Pending burns are now also discovered from the adapter's Submit(burnShares) logs, scanned incrementally from its creation block with a per-adapter cursor; every discovered calldata is kept verbatim and re-read until it resolves. Canonical keys stay as a fast path, and a failed or incomplete scan fails the run after alerting. - Valuation: burned-market amounts use stored supplyShares and Morpho Blue market totals instead of expectedSupplyAssets, which goes through the IRM and may revert; criticality follows shares. - V1: totalAssets() is best effort, so a reverting withdraw-queue IRM only drops the "% of vault" figure instead of aborting governance checks. - V2 governance query: page size 100 (the added fields pushed first: 200 past Morpho's 1M complexity limit), passed as a variable and guarded by a test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getLogs history scans are too expensive for our RPC budget. The adapter check now uses batched eth_call only: - pending burns: canonical executableAt(burnShares(id)) per held market; - executed burns: adapter supplyShares[id] always equals its Morpho Blue position (verified on all 50 live positions), and burnShares zeroes only the former, so any gap is a written-off position -> CRITICAL. This catches burns submitted with padded calldata once they execute (fork-tested); markets are remembered after leaving marketIds. Known limitation, documented: a padded-calldata burn is not visible while pending without Submit events. Removes the log cursor, chunk config, and the adapter creationBlockNumber query field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop the share-parity check (extra Morpho position/market reads per market and a remembered-markets cache). Non-canonical burnShares submissions need the adapter's Submit events, which the Morpho API does not expose; that gap is now a documented TODO. Valuation calls run only when a canonical burn is pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Could you add a zero-to-positive shares transition test for the same pending burn? At I reproduced that sequence with the extracted function and mocked cache, clock and delivery; a clean-cache positive-share control sends CRITICAL. I have not run the full suite or a live-chain test. The existing tests cover zero and positive shares separately; the transition would catch this gap. |
# Conflicts: # tests/test_web3_wrapper.py
… ops Lower every alert this PR adds by one level: potential vault loss alerts (and their reminders) are HIGH instead of CRITICAL, still sent as their own POTENTIAL VAULT LOSS message. Other V2 timelock decreases and abdicate are back to MEDIUM, an adapter burnShares timelock decrease is MEDIUM, and a burn on a market with no shares is LOW. Track the loss alert on its own cache key instead of the "pending seen" cursor. An operation first seen with nothing at risk (V1 removal of an empty market, removeAdapter of an empty adapter, burnShares with no shares) now escalates as soon as funds arrive instead of waiting for the 24h reminder window. Resolved operations clear their loss and reminder keys so a re-submit with the same deadline alerts again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
thanks, good catch. Fixed in a8fa3a3. It wasn't just the adapter burn. V1 submitMarketRemoval and V2 removeAdapter had the same problem, it only remembered the deadline, so an operation first alerted with nothing at risk didn't escalate when funds arrived. It only got the 24h reminder. |
Closes #434
What
Curator actions that can write off vault funds now send a HIGH alert for any non-zero amount. Each goes out as its own
🚨 POTENTIAL VAULT LOSSmessage on themorphochannel, and is re-alerted once within 24h of its execution time. An operation first seen with nothing at risk escalates to this alert as soon as funds arrive.submitMarketRemovalconfig.removableAt+ Morpho Blueposition/marketremoveAdapter(adapter)pendingConfigs+ adapterassetsdecreaseTimelock(removeAdapter, …)pendingConfigsburnShares(marketId)executableAt(burnShares(id))Other changes:
Timelock decrease: 3d → 1d, with the execution time and a countdown. It used to say just "Timelock is changing".decreaseTimelockcalls andabdicatestay MEDIUM.removeAdapter(...)names the adapter again.timelock(burnShares)sends MEDIUM, and aburnShareson a market with no shares sends LOW. The first run seeds the timelock cache silently.How
burnSharesis detected (batchedeth_callonly)The Morpho GraphQL API has no adapter-level timelock data, so each
MorphoMarketV1AdapterV2is read on-chain:executableAt(burnShares(id))for every held market → HIGH with the amount at risk, plus a reminder within 24h. A drop intimelock(burnShares)→ MEDIUM. Valuation calls run only when a burn is pending.TODO: a burn submitted with non-canonical calldata (canonical + trailing bytes) is not detected. Solidity ignores the extra bytes, but
executableAtis keyed by the exact calldata, so the key can't be derived. Catching it needs the adapter'sSubmitevents, which the Morpho API does not expose. It can be added once an indexer provides them.Amounts come from stored shares and Morpho Blue market totals, never the IRM (it can revert), and the loss alert follows shares. On V1,
totalAssets()is best effort, so a broken IRM only drops the "% of vault" figure.Robinhood had no RPC in the env or in
PUBLIC_RPC_URLS, so I added its official public RPC (rpc.mainnet.chain.robinhood.com, listed on chainid.network) as a fallback.The added GraphQL fields pushed
first: 200past Morpho's 1M complexity limit (1,151,000). The page size is now 100 (575,500), passed as a variable, and a test checks that the configured vaults fit.Testing
ruff,ty, and the full test suite pass (1945 tests). New tests cover severity classification, standalone delivery, reminder-once logic, timelock decrease, and adapterexecutableAtreads. The fake client in the adapter tests rejects.call()outside a batch context; that is the bug the first live run caught.eth_blockNumberandeth_call. No pending loss operations exist today. The Katana Yearn OG USDCweETH/vbUSDCremoval (no supply) correctly came out as MEDIUM. Every adapter returnstimelock(burnShares)of 3d or 7d.Rollout notes
The loss alert is tracked on its own cache key, so removals and
removeAdapteroperations that were already pending (and already alerted at MEDIUM) before this deploys get the HIGH loss alert on the first run if they still have funds at risk.🤖 Generated with Claude Code