diff --git a/.github/workflows/hourly.yml b/.github/workflows/hourly.yml index 5fe60561..5d721111 100644 --- a/.github/workflows/hourly.yml +++ b/.github/workflows/hourly.yml @@ -29,6 +29,7 @@ jobs: silo/ur_sniff.py usdai/main.py yearn/alert_large_flows.py + yearn/yvusd.py maple/main.py timelock/timelock_alerts.py # always run proposals after timelock alerts diff --git a/morpho/abi/morpho_blue.json b/morpho/abi/morpho_blue.json new file mode 100644 index 00000000..e85c06d6 --- /dev/null +++ b/morpho/abi/morpho_blue.json @@ -0,0 +1,30 @@ +[ + { + "name": "market", + "type": "function", + "inputs": [{"name": "id", "type": "bytes32"}], + "outputs": [ + {"name": "totalSupplyAssets", "type": "uint128"}, + {"name": "totalSupplyShares", "type": "uint128"}, + {"name": "totalBorrowAssets", "type": "uint128"}, + {"name": "totalBorrowShares", "type": "uint128"}, + {"name": "lastUpdate", "type": "uint128"}, + {"name": "fee", "type": "uint128"} + ], + "stateMutability": "view" + }, + { + "name": "position", + "type": "function", + "inputs": [ + {"name": "id", "type": "bytes32"}, + {"name": "user", "type": "address"} + ], + "outputs": [ + {"name": "supplyShares", "type": "uint256"}, + {"name": "borrowShares", "type": "uint128"}, + {"name": "collateral", "type": "uint128"} + ], + "stateMutability": "view" + } +] diff --git a/tests/test_yvusd.py b/tests/test_yvusd.py new file mode 100644 index 00000000..5ae3eac7 --- /dev/null +++ b/tests/test_yvusd.py @@ -0,0 +1,591 @@ +import unittest +from unittest.mock import MagicMock, patch + +from utils.chains import Chain +from yearn.yvusd import ( + CACHE_KEY_COOLDOWN_FETCH_FAILED, + CACHE_KEY_FLASHLOAN_PREFIX, + CACHE_KEY_NEG_APR_PREFIX, + CACHE_KEY_RPC_MISSING_PREFIX, + CCTP_REPORT_SKEW_HOURS, + CCTP_REPORT_STALENESS_HOURS, + LOOPER_CHAIN_CONFIG, + YVUSD_VAULT, + LooperPosition, + _check_negative_strategy_apr, + _collect_looper_positions, + check_flashloan_liquidity, + check_large_cooldowns, + check_strategy_staleness, +) + + +def _make_remote_strategy_mock(last_report: int, total_assets: int) -> MagicMock: + """Build a mock V3 tokenized strategy contract for the remote side.""" + contract = MagicMock() + contract.functions.lastReport.return_value.call.return_value = last_report + contract.functions.totalAssets.return_value.call.return_value = total_assets + return contract + + +@patch("yearn.yvusd._has_configured_rpc", return_value=True) +class TestYvUsdCctpChecks(unittest.TestCase): + @patch("yearn.yvusd.send_alert") + @patch("yearn.yvusd.ChainManager.get_client") + def test_alerts_on_report_skew_between_local_and_remote( + self, mock_get_client: MagicMock, mock_send_alert: MagicMock, _mock_has_rpc: MagicMock + ): + now = 1_000_000 + local_last_report = now - 3600 + remote_last_report = now - int((CCTP_REPORT_SKEW_HOURS + 2) * 3600) + + remote_strategy = _make_remote_strategy_mock(remote_last_report, 100_000_000) + remote_client = MagicMock() + remote_client.eth.contract.return_value = remote_strategy + mock_get_client.return_value = remote_client + + mainnet_vault = MagicMock() + client = MagicMock() + client.eth.contract.return_value = mainnet_vault + client.batch_requests.return_value.__enter__.return_value = MagicMock() + client.batch_requests.return_value.__exit__.return_value = False + client.execute_batch.return_value = [(1, local_last_report, 100_000_000, 0)] + + api_data = { + YVUSD_VAULT: { + "meta": { + "strategies": [ + { + "address": "0x1983923e5a3591AFe036d38A8C8011e66Cd76e9E", + "meta": { + "name": "Arbitrum Yearn Degen Morpho Compounder", + "type": "cross-chain", + "remote_chain_id": Chain.ARBITRUM.chain_id, + "remote_vault": "0x78b7774c4368df8f2c115Abf6210F557753a6aC5", + }, + } + ] + } + } + } + + with patch("yearn.yvusd.time.time", return_value=now): + check_strategy_staleness(client, api_data) + + mock_send_alert.assert_called_once() + message = mock_send_alert.call_args.args[0].message + self.assertIn("report skew", message) + self.assertIn("Arbitrum Yearn Degen Morpho Compounder", message) + + @patch("yearn.yvusd.send_alert") + @patch("yearn.yvusd.ChainManager.get_client") + def test_alerts_on_remote_staleness( + self, mock_get_client: MagicMock, mock_send_alert: MagicMock, _mock_has_rpc: MagicMock + ): + now = 1_000_000 + stale_seconds = int((CCTP_REPORT_STALENESS_HOURS + 1) * 3600) + + remote_strategy = _make_remote_strategy_mock(now - stale_seconds, 200_000_000) + remote_client = MagicMock() + remote_client.eth.contract.return_value = remote_strategy + mock_get_client.return_value = remote_client + + client = MagicMock() + client.eth.contract.return_value = MagicMock() + client.batch_requests.return_value.__enter__.return_value = MagicMock() + client.batch_requests.return_value.__exit__.return_value = False + client.execute_batch.return_value = [(1, now - 3600, 100_000_000, 0)] + + api_data = { + YVUSD_VAULT: { + "meta": { + "strategies": [ + { + "address": "0x2F56D106C6Df739bdbb777C2feE79FFaED88D179", + "meta": { + "name": "Arbitrum syrupUSDC/USDC Morpho Looper", + "type": "cross-chain", + "remote_chain_id": Chain.ARBITRUM.chain_id, + "remote_vault": "0xBCf08997C34183d1b7B0f99e13aCeACFBA88E453", + }, + } + ] + } + } + } + + with patch("yearn.yvusd.time.time", return_value=now): + check_strategy_staleness(client, api_data) + + mock_send_alert.assert_called_once() + self.assertIn("report stale", mock_send_alert.call_args.args[0].message) + + @patch("yearn.yvusd.send_alert") + @patch("yearn.yvusd.ChainManager.get_client") + def test_alerts_when_remote_lookup_fails( + self, mock_get_client: MagicMock, mock_send_alert: MagicMock, _mock_has_rpc: MagicMock + ): + """Failure to read remote state must surface an alert, not silently skip.""" + now = 1_000_000 + + remote_strategy = MagicMock() + remote_strategy.functions.lastReport.return_value.call.side_effect = RuntimeError("execution reverted") + remote_client = MagicMock() + remote_client.eth.contract.return_value = remote_strategy + mock_get_client.return_value = remote_client + + client = MagicMock() + client.eth.contract.return_value = MagicMock() + client.batch_requests.return_value.__enter__.return_value = MagicMock() + client.batch_requests.return_value.__exit__.return_value = False + client.execute_batch.return_value = [(1, now - 3600, 100_000_000, 0)] + + api_data = { + YVUSD_VAULT: { + "meta": { + "strategies": [ + { + "address": "0x2F56D106C6Df739bdbb777C2feE79FFaED88D179", + "meta": { + "name": "Arbitrum syrupUSDC/USDC Morpho Looper", + "type": "cross-chain", + "remote_chain_id": Chain.ARBITRUM.chain_id, + "remote_vault": "0xBCf08997C34183d1b7B0f99e13aCeACFBA88E453", + }, + } + ] + } + } + } + + with patch("yearn.yvusd.time.time", return_value=now): + check_strategy_staleness(client, api_data) + + mock_send_alert.assert_called_once() + message = mock_send_alert.call_args.args[0].message + self.assertIn("Remote Lookup Failed", message) + self.assertIn("Arbitrum syrupUSDC/USDC Morpho Looper", message) + + +class TestYvUsdLooperPositionCollection(unittest.TestCase): + def test_includes_cross_chain_loopers_with_remote_morpho_market(self): + """Cross-chain wrappers whose remote side is a looper must be covered.""" + strategies = [ + { + "address": "0xMainnetLooper", + "debt": "5000000000000", + "meta": { + "name": "Mainnet Direct Looper", + "type": "morpho-looper", + "market_id": "0xaaaa", + }, + }, + { + "address": "0x2F56D106C6Df739bdbb777C2feE79FFaED88D179", + "debt": "100404831974", + "meta": { + "name": "Arbitrum syrupUSDC/USDC Morpho Looper", + "type": "cross-chain", + "remote_chain_id": Chain.ARBITRUM.chain_id, + "remote_vault": "0xBCf08997C34183d1b7B0f99e13aCeACFBA88E453", + "remote_vault_type": "morpho-looper", + "remote_meta": { + "type": "morpho-looper", + "market_id": "0xf86f3edd6f16cd8211f4d206866dc4ecd41be6211063ac11f8508e1b7112ef40", + }, + }, + }, + { + "address": "0xCrossChainNonLooper", + "debt": "1000", + "meta": { + "name": "Cross-chain default vault", + "type": "cross-chain", + "remote_chain_id": Chain.ARBITRUM.chain_id, + "remote_vault": "0x000000000000000000000000000000000000dead", + "remote_vault_type": "default", + "remote_meta": {"type": "default"}, + }, + }, + { + "address": "0xZeroDebtLooper", + "debt": "0", + "meta": {"name": "Zero debt", "type": "morpho-looper", "market_id": "0xbbbb"}, + }, + ] + + positions = _collect_looper_positions(strategies) + + self.assertEqual(len(positions), 2) + mainnet = next(p for p in positions if p.chain == Chain.MAINNET) + cross = next(p for p in positions if p.chain == Chain.ARBITRUM) + + self.assertEqual(mainnet.borrower, "0xMainnetLooper") + self.assertEqual(mainnet.market_id, "0xaaaa") + + # Borrower for cross-chain is the remote tokenized strategy, not the mainnet wrapper. + self.assertEqual(cross.borrower, "0xBCf08997C34183d1b7B0f99e13aCeACFBA88E453") + self.assertEqual(cross.mainnet_strategy, "0x2F56D106C6Df739bdbb777C2feE79FFaED88D179") + self.assertEqual(cross.market_id, "0xf86f3edd6f16cd8211f4d206866dc4ecd41be6211063ac11f8508e1b7112ef40") + + +class TestYvUsdFlashloanLiquidity(unittest.TestCase): + @patch("yearn.yvusd.get_cache_value", return_value=0) + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd._has_configured_rpc", return_value=True) + @patch("yearn.yvusd.send_alert") + @patch("yearn.yvusd.ChainManager.get_client") + def test_alerts_on_insufficient_liquidity_for_cross_chain_looper( + self, + mock_get_client: MagicMock, + mock_send_alert: MagicMock, + _mock_has_rpc: MagicMock, + _mock_set_cache: MagicMock, + _mock_get_cache: MagicMock, + ): + # Borrow shares == borrow assets when total_borrow_shares == total_borrow_assets + # market: total_supply=10M USDC, total_borrow=9M USDC, shares match -> liquidity = 1M + # position: borrow shares 50M USDC -> way more than 1M market liquidity and 100k Balancer + market = ( + 10_000_000 * 10**6, # totalSupplyAssets + 10_000_000 * 10**6, # totalSupplyShares + 9_000_000 * 10**6, # totalBorrowAssets + 9_000_000 * 10**6, # totalBorrowShares + 0, # lastUpdate + 0, # fee + ) + position = (0, 50_000_000 * 10**6, 0) # supplyShares, borrowShares, collateral + balancer_balance = 100_000 * 10**6 + + arb_client = MagicMock() + arb_client.batch_requests.return_value.__enter__.return_value = MagicMock() + arb_client.batch_requests.return_value.__exit__.return_value = False + arb_client.execute_batch.return_value = [market, position, balancer_balance] + mock_get_client.return_value = arb_client + + # Verify the chain we care about is configured + self.assertIn(Chain.ARBITRUM, LOOPER_CHAIN_CONFIG) + + api_data = { + YVUSD_VAULT: { + "meta": { + "strategies": [ + { + "address": "0x2F56D106C6Df739bdbb777C2feE79FFaED88D179", + "debt": "50000000000000", + "meta": { + "name": "Arbitrum syrupUSDC/USDC Morpho Looper", + "type": "cross-chain", + "remote_chain_id": Chain.ARBITRUM.chain_id, + "remote_vault": "0xBCf08997C34183d1b7B0f99e13aCeACFBA88E453", + "remote_vault_type": "morpho-looper", + "remote_meta": { + "market_id": "0xf86f3edd6f16cd8211f4d206866dc4ecd41be6211063ac11f8508e1b7112ef40", + }, + }, + } + ] + } + } + } + + check_flashloan_liquidity(api_data) + + mock_get_client.assert_called_once_with(Chain.ARBITRUM) + mock_send_alert.assert_called_once() + message = mock_send_alert.call_args.args[0].message + self.assertIn("Flashloan Liquidity Warning", message) + self.assertIn("arbitrum", message.lower()) + # Both the mainnet strategy link and the remote borrower link should appear + self.assertIn("0x2F56D106C6Df739bdbb777C2feE79FFaED88D179", message) + self.assertIn("0xBCf08997C34183d1b7B0f99e13aCeACFBA88E453", message) + + +class TestYvUsdCooldownScanning(unittest.TestCase): + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=123) + def test_does_not_advance_cache_when_log_fetch_fails(self, mock_get_cache: MagicMock, mock_set_cache: MagicMock): + client = MagicMock() + client.eth.block_number = 200 + + locked = MagicMock() + locked.events.CooldownStarted.get_logs.side_effect = RuntimeError("rpc failure") + client.eth.contract.return_value = locked + + check_large_cooldowns(client) + + mock_set_cache.assert_not_called() + + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=100) + def test_uses_snake_case_kwargs_for_get_logs(self, mock_get_cache: MagicMock, mock_set_cache: MagicMock): + """web3 7.x's get_logs takes from_block/to_block, not fromBlock/toBlock.""" + client = MagicMock() + client.eth.block_number = 250 + + locked = MagicMock() + locked.events.CooldownStarted.get_logs.return_value = [] + client.eth.contract.return_value = locked + + check_large_cooldowns(client) + + # web3 7.x raises TypeError on camelCase kwargs; assert we used snake_case. + kwargs = locked.events.CooldownStarted.get_logs.call_args.kwargs + self.assertIn("from_block", kwargs) + self.assertIn("to_block", kwargs) + self.assertNotIn("fromBlock", kwargs) + self.assertNotIn("toBlock", kwargs) + + +class TestLooperPositionDataclass(unittest.TestCase): + def test_dataclass_is_hashable_for_dedup(self): + """LooperPosition is frozen and should be usable as a dict key.""" + a = LooperPosition(Chain.MAINNET, "0xaa", "0xbb", "name", "0xbb") + b = LooperPosition(Chain.MAINNET, "0xaa", "0xbb", "name", "0xbb") + self.assertEqual(a, b) + self.assertEqual(hash(a), hash(b)) + + +class TestYvUsdMissingRpcGuard(unittest.TestCase): + """Cross-chain strategies on chains without a configured PROVIDER_URL should + surface a single MEDIUM alert and skip — not spam a failure alert hourly.""" + + KATANA_STRATEGY = { + "address": "0xc5b16E7eFe1CA05714477b8edcAb4deE9b93a27C", + "debt": "2220302251405", + "meta": { + "name": "Katana yvUSDC Compounder", + "type": "cross-chain", + "remote_chain_id": Chain.KATANA.chain_id, + "remote_vault": "0x80c34BD3A3569E126e7055831036aa7b212cB159", + "remote_vault_type": "default", + }, + } + + def _client(self, local_last_report: int = 0): + client = MagicMock() + client.eth.contract.return_value = MagicMock() + client.batch_requests.return_value.__enter__.return_value = MagicMock() + client.batch_requests.return_value.__exit__.return_value = False + client.execute_batch.return_value = [(1, local_last_report, 2_220_302_251_405, 0)] + return client + + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=0) + @patch("yearn.yvusd.send_alert") + @patch("yearn.yvusd.ChainManager.get_client") + @patch("yearn.yvusd._has_configured_rpc", return_value=False) + def test_staleness_alerts_once_when_remote_rpc_missing( + self, + _mock_has_rpc: MagicMock, + mock_get_client: MagicMock, + mock_send_alert: MagicMock, + _mock_get_cache: MagicMock, + mock_set_cache: MagicMock, + ): + now = 1_000_000 + api_data = {YVUSD_VAULT: {"meta": {"strategies": [self.KATANA_STRATEGY]}}} + + with patch("yearn.yvusd.time.time", return_value=now): + check_strategy_staleness(self._client(local_last_report=now - 3600), api_data) + + # ChainManager.get_client must NOT have been called for the remote chain. + mock_get_client.assert_not_called() + + # One MEDIUM alert about the missing RPC. + mock_send_alert.assert_called_once() + alert = mock_send_alert.call_args.args[0] + self.assertIn("missing RPC", alert.message) + self.assertIn("katana", alert.message.lower()) + + # Dedup flag is written so subsequent runs stay silent. + mock_set_cache.assert_called_once() + cache_key = mock_set_cache.call_args.args[0] + self.assertEqual(cache_key, f"{CACHE_KEY_RPC_MISSING_PREFIX}KATANA") + + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=1) # already-alerted flag set + @patch("yearn.yvusd.send_alert") + @patch("yearn.yvusd.ChainManager.get_client") + @patch("yearn.yvusd._has_configured_rpc", return_value=False) + def test_staleness_does_not_realert_when_already_flagged( + self, + _mock_has_rpc: MagicMock, + mock_get_client: MagicMock, + mock_send_alert: MagicMock, + _mock_get_cache: MagicMock, + mock_set_cache: MagicMock, + ): + now = 1_000_000 + api_data = {YVUSD_VAULT: {"meta": {"strategies": [self.KATANA_STRATEGY]}}} + + with patch("yearn.yvusd.time.time", return_value=now): + check_strategy_staleness(self._client(local_last_report=now - 3600), api_data) + + mock_get_client.assert_not_called() + mock_send_alert.assert_not_called() + mock_set_cache.assert_not_called() + + +class TestYvUsdCooldownFailureAlert(unittest.TestCase): + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=0) + @patch("yearn.yvusd.send_alert") + def test_alerts_medium_on_first_get_logs_failure( + self, mock_send_alert: MagicMock, _mock_get_cache: MagicMock, mock_set_cache: MagicMock + ): + client = MagicMock() + client.eth.block_number = 200 + locked = MagicMock() + locked.events.CooldownStarted.get_logs.side_effect = RuntimeError("rpc failure") + client.eth.contract.return_value = locked + + check_large_cooldowns(client) + + mock_send_alert.assert_called_once() + self.assertIn("Cooldown Scan Failed", mock_send_alert.call_args.args[0].message) + + # The failure flag is the only thing written; last-block must not advance. + cache_writes = {call.args[0] for call in mock_set_cache.call_args_list} + self.assertIn(CACHE_KEY_COOLDOWN_FETCH_FAILED, cache_writes) + self.assertNotIn("YVUSD_LAST_BLOCK", cache_writes) + + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=1) # failure flag already set + @patch("yearn.yvusd.send_alert") + def test_does_not_realert_while_failure_flag_set( + self, mock_send_alert: MagicMock, _mock_get_cache: MagicMock, _mock_set_cache: MagicMock + ): + client = MagicMock() + client.eth.block_number = 200 + locked = MagicMock() + locked.events.CooldownStarted.get_logs.side_effect = RuntimeError("still failing") + client.eth.contract.return_value = locked + + check_large_cooldowns(client) + + mock_send_alert.assert_not_called() + + +class TestYvUsdNegativeAprDebounce(unittest.TestCase): + NEG_STRATEGY = { + "address": "0xAbCdEf0000000000000000000000000000000001", + "debt": "1000000000000", + "apr_raw": "-50000000000000000", + "meta": {"name": "Losing Money Strategy", "type": "default"}, + } + + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=0) + @patch("yearn.yvusd.send_alert") + def test_alerts_once_then_sets_dedup_flag( + self, mock_send_alert: MagicMock, _mock_get_cache: MagicMock, mock_set_cache: MagicMock + ): + _check_negative_strategy_apr({"meta": {"strategies": [self.NEG_STRATEGY]}}) + + mock_send_alert.assert_called_once() + self.assertIn("Negative Strategy APR", mock_send_alert.call_args.args[0].message) + + expected_key = f"{CACHE_KEY_NEG_APR_PREFIX}{self.NEG_STRATEGY['address'].lower()}" + mock_set_cache.assert_called_once_with(expected_key, 1) + + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=1) # already-alerted + @patch("yearn.yvusd.send_alert") + def test_skips_when_already_alerted( + self, mock_send_alert: MagicMock, _mock_get_cache: MagicMock, mock_set_cache: MagicMock + ): + _check_negative_strategy_apr({"meta": {"strategies": [self.NEG_STRATEGY]}}) + + mock_send_alert.assert_not_called() + mock_set_cache.assert_not_called() + + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=1) + @patch("yearn.yvusd.send_alert") + def test_clears_flag_on_recovery( + self, mock_send_alert: MagicMock, _mock_get_cache: MagicMock, mock_set_cache: MagicMock + ): + recovered = {**self.NEG_STRATEGY, "apr_raw": "10000000000000000"} + _check_negative_strategy_apr({"meta": {"strategies": [recovered]}}) + + mock_send_alert.assert_not_called() + expected_key = f"{CACHE_KEY_NEG_APR_PREFIX}{recovered['address'].lower()}" + mock_set_cache.assert_called_once_with(expected_key, 0) + + +class TestYvUsdFlashloanDebounce(unittest.TestCase): + POSITION_STRATEGY = { + "address": "0x2F56D106C6Df739bdbb777C2feE79FFaED88D179", + "debt": "50000000000000", + "meta": { + "name": "Arbitrum syrupUSDC/USDC Morpho Looper", + "type": "cross-chain", + "remote_chain_id": Chain.ARBITRUM.chain_id, + "remote_vault": "0xBCf08997C34183d1b7B0f99e13aCeACFBA88E453", + "remote_vault_type": "morpho-looper", + "remote_meta": {"market_id": "0xf86f3edd6f16cd8211f4d206866dc4ecd41be6211063ac11f8508e1b7112ef40"}, + }, + } + + def _arb_client_with_shortfall(self) -> MagicMock: + # Same numbers as TestYvUsdFlashloanLiquidity: ~50M borrow vs 1M market + 100k Balancer. + market = (10_000_000 * 10**6, 10_000_000 * 10**6, 9_000_000 * 10**6, 9_000_000 * 10**6, 0, 0) + position = (0, 50_000_000 * 10**6, 0) + balancer_balance = 100_000 * 10**6 + client = MagicMock() + client.batch_requests.return_value.__enter__.return_value = MagicMock() + client.batch_requests.return_value.__exit__.return_value = False + client.execute_batch.return_value = [market, position, balancer_balance] + return client + + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=1) # already-alerted flag set + @patch("yearn.yvusd._has_configured_rpc", return_value=True) + @patch("yearn.yvusd.send_alert") + @patch("yearn.yvusd.ChainManager.get_client") + def test_skips_when_flashloan_dedup_flag_set( + self, + mock_get_client: MagicMock, + mock_send_alert: MagicMock, + _mock_has_rpc: MagicMock, + _mock_get_cache: MagicMock, + mock_set_cache: MagicMock, + ): + mock_get_client.return_value = self._arb_client_with_shortfall() + api_data = {YVUSD_VAULT: {"meta": {"strategies": [self.POSITION_STRATEGY]}}} + + check_flashloan_liquidity(api_data) + + mock_send_alert.assert_not_called() + mock_set_cache.assert_not_called() + + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=0) + @patch("yearn.yvusd._has_configured_rpc", return_value=True) + @patch("yearn.yvusd.send_alert") + @patch("yearn.yvusd.ChainManager.get_client") + def test_alerts_once_then_writes_flashloan_dedup_flag( + self, + mock_get_client: MagicMock, + mock_send_alert: MagicMock, + _mock_has_rpc: MagicMock, + _mock_get_cache: MagicMock, + mock_set_cache: MagicMock, + ): + mock_get_client.return_value = self._arb_client_with_shortfall() + api_data = {YVUSD_VAULT: {"meta": {"strategies": [self.POSITION_STRATEGY]}}} + + check_flashloan_liquidity(api_data) + + mock_send_alert.assert_called_once() + # Dedup flag is keyed by chain.name + lowercase borrower address. + expected_key = ( + f"{CACHE_KEY_FLASHLOAN_PREFIX}{Chain.ARBITRUM.name}_" + f"{self.POSITION_STRATEGY['meta']['remote_vault'].lower()}" + ) + mock_set_cache.assert_called_once_with(expected_key, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/yearn/abi/LockedYvUSD.json b/yearn/abi/LockedYvUSD.json new file mode 100644 index 00000000..ec59604f --- /dev/null +++ b/yearn/abi/LockedYvUSD.json @@ -0,0 +1,30 @@ +[ + { + "anonymous": false, + "inputs": [ + {"indexed": true, "name": "user", "type": "address"}, + {"indexed": true, "name": "shares", "type": "uint256"}, + {"indexed": true, "name": "timestamp", "type": "uint256"} + ], + "name": "CooldownStarted", + "type": "event" + }, + { + "name": "getCooldownStatus", + "type": "function", + "inputs": [{"name": "user", "type": "address"}], + "outputs": [ + {"name": "cooldownEnd", "type": "uint256"}, + {"name": "windowEnd", "type": "uint256"}, + {"name": "shares", "type": "uint256"} + ], + "stateMutability": "view" + }, + { + "name": "totalSupply", + "type": "function", + "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view" + } +] diff --git a/yearn/abi/YearnV3Vault.json b/yearn/abi/YearnV3Vault.json new file mode 100644 index 00000000..69b56815 --- /dev/null +++ b/yearn/abi/YearnV3Vault.json @@ -0,0 +1,21 @@ +[ + { + "name": "strategies", + "type": "function", + "inputs": [{"name": "strategy", "type": "address"}], + "outputs": [ + {"name": "activation", "type": "uint256"}, + {"name": "last_report", "type": "uint256"}, + {"name": "current_debt", "type": "uint256"}, + {"name": "max_debt", "type": "uint256"} + ], + "stateMutability": "view" + }, + { + "name": "totalAssets", + "type": "function", + "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view" + } +] diff --git a/yearn/yvusd.py b/yearn/yvusd.py new file mode 100644 index 00000000..bbf35a4f --- /dev/null +++ b/yearn/yvusd.py @@ -0,0 +1,751 @@ +""" +yvUSD vault monitoring script. + +Monitors: +- APY anomalies: unlocked APY > locked APY inversion, negative strategy APR +- CCTP bridging delays: stale or out-of-sync cross-chain strategy reports +- Flashloan liquidity: available liquidity for looper strategy unwinding (mainnet + cross-chain) +- Large cooldown requests: significant LockedyvUSD cooldown events +""" + +import os +import time +from dataclasses import dataclass + +from utils.abi import load_abi +from utils.alert import Alert, AlertSeverity, send_alert +from utils.cache import get_last_value_for_key_from_file, write_last_value_to_file +from utils.chains import Chain +from utils.formatting import format_usd +from utils.http import fetch_json +from utils.logging import get_logger +from utils.web3_wrapper import ChainManager, Web3Client + +PROTOCOL = "yearn" +logger = get_logger("yvusd") + +CACHE_FILENAME = "cache-id.txt" + +# --- ABIs --- +ABI_VAULT = load_abi("yearn/abi/YearnV3Vault.json") +ABI_MORPHO_BLUE = load_abi("morpho/abi/morpho_blue.json") +ABI_LOCKED = load_abi("yearn/abi/LockedYvUSD.json") + +# --- Contract Addresses --- +YVUSD_VAULT = "0x696d02Db93291651ED510704c9b286841d506987" +LOCKED_YVUSD = "0xAaaFEa48472f77563961Cdb53291DEDfB46F9040" + +# Per-chain Morpho Blue + flashloan source addresses for looper unwinding checks. +LOOPER_CHAIN_CONFIG: dict[Chain, dict[str, str]] = { + Chain.MAINNET: { + "morpho": "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb", + "balancer_vault": "0xBA12222222228d8Ba445958a75a0704d566BF2C8", + "usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + }, + Chain.ARBITRUM: { + "morpho": "0x6c247b1F6182318877311737BaC0844bAa518F5e", + "balancer_vault": "0xBA12222222228d8Ba445958a75a0704d566BF2C8", + "usdc": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, +} + +# --- API --- +YVUSD_API_URL = "https://yvusd-api.yearn.fi/api/aprs" + +# --- Thresholds --- +APY_INVERSION_HOURS = 6 # Alert after this many hours of unlocked APY > locked APY +CCTP_REPORT_STALENESS_HOURS = 48 # Report freshness threshold +CCTP_REPORT_SKEW_HOURS = 6 # Max allowed skew between local and remote reports +LARGE_COOLDOWN_THRESHOLD = 100_000 # $100K in USD + +USDC_DECIMALS = 6 +ONE_USDC = 10**USDC_DECIMALS + +# --- Cache Keys --- +CACHE_KEY_APY_INVERSION_START = "YVUSD_APY_INVERSION_START" +CACHE_KEY_APY_INVERSION_ALERTED = "YVUSD_APY_INVERSION_ALERTED" +CACHE_KEY_LAST_BLOCK = "YVUSD_LAST_BLOCK" +CACHE_KEY_COOLDOWN_FETCH_FAILED = "YVUSD_COOLDOWN_FETCH_FAILED" +# Per-condition dedup keys are suffixed with a stable identifier (lowercased address or chain/borrower pair). +CACHE_KEY_NEG_APR_PREFIX = "YVUSD_NEG_APR_ALERTED_" +CACHE_KEY_FLASHLOAN_PREFIX = "YVUSD_FLASHLOAN_ALERTED_" +CACHE_KEY_RPC_MISSING_PREFIX = "YVUSD_RPC_MISSING_ALERTED_" + +# Number of blocks to scan per run (~1 hour at 12s/block) +BLOCKS_PER_HOUR = 300 +MAX_SCAN_BLOCKS = 5000 + +# Minimal ERC20 ABI for balanceOf +ABI_ERC20_BALANCE = [ + { + "type": "function", + "name": "balanceOf", + "inputs": [{"name": "account", "type": "address"}], + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + } +] + +# Minimal V3 tokenized strategy ABI used for remote-side health checks. +# Remote "vaults" exposed by the cross-chain strategy metadata are actually V3 +# tokenized strategies (no strategies() mapping); they expose lastReport() and +# totalAssets() directly. +ABI_TOKENIZED_STRATEGY = [ + { + "type": "function", + "name": "lastReport", + "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + }, + { + "type": "function", + "name": "totalAssets", + "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + }, +] + +# Strategy types that use Morpho leverage and need flashloans to unwind +LOOPER_STRATEGY_TYPES = ("morpho-looper", "pt-morpho-looper") + + +@dataclass(frozen=True) +class LooperPosition: + """A Morpho-looper borrow position to monitor for flashloan unwind capacity.""" + + chain: Chain + market_id: str # 0x-prefixed hex + borrower: str # address of the contract holding the Morpho borrow + name: str # human-readable label + mainnet_strategy: str # mainnet yvUSD strategy that owns this position (for explorer link) + + +def get_cache_value(key: str) -> float: + """Read a cached float value, returns 0 if not found.""" + val = get_last_value_for_key_from_file(CACHE_FILENAME, key) + try: + return float(val) + except (ValueError, TypeError): + return 0.0 + + +def set_cache_value(key: str, value: float) -> None: + """Write a float value to cache.""" + write_last_value_to_file(CACHE_FILENAME, key, value) + + +def _has_configured_rpc(chain: Chain) -> bool: + """Return True if at least one PROVIDER_URL_{CHAIN}[_N] env var is set.""" + base = f"PROVIDER_URL_{chain.name.upper()}" + if os.getenv(base): + return True + return any(os.getenv(f"{base}_{i}") for i in range(1, 4)) + + +def check_apy_anomalies(api_data: dict) -> None: + """Check for APY anomalies using the yvUSD API. + + Alerts when: + - Unlocked APY > locked APY for more than APY_INVERSION_HOURS + - Any active strategy has negative APR + """ + yvusd_data = api_data.get(YVUSD_VAULT) + locked_data = api_data.get(LOCKED_YVUSD) + + if not yvusd_data or not locked_data: + logger.error("Missing vault data in API response") + send_alert(Alert(AlertSeverity.MEDIUM, "Missing vault data in yvUSD API response", PROTOCOL)) + return + + unlocked_apy = yvusd_data.get("apy", 0) + locked_apy = locked_data.get("apy", 0) + logger.info("APY — Unlocked: %.2f%%, Locked: %.2f%%", unlocked_apy * 100, locked_apy * 100) + + _check_apy_inversion(unlocked_apy, locked_apy) + _check_negative_strategy_apr(yvusd_data) + + +def _check_apy_inversion(unlocked_apy: float, locked_apy: float) -> None: + """Alert if unlocked APY exceeds locked APY for more than APY_INVERSION_HOURS.""" + now = time.time() + + if unlocked_apy > locked_apy: + inversion_start = get_cache_value(CACHE_KEY_APY_INVERSION_START) + if inversion_start == 0: + set_cache_value(CACHE_KEY_APY_INVERSION_START, now) + logger.warning( + "APY inversion detected: unlocked (%.2f%%) > locked (%.2f%%)", + unlocked_apy * 100, + locked_apy * 100, + ) + else: + hours_inverted = (now - inversion_start) / 3600 + already_alerted = get_cache_value(CACHE_KEY_APY_INVERSION_ALERTED) + if hours_inverted >= APY_INVERSION_HOURS and not already_alerted: + message = ( + f"*yvUSD APY Inversion Alert*\n" + f"Unlocked APY ({unlocked_apy:.2%}) > Locked APY ({locked_apy:.2%})\n" + f"Inverted for {hours_inverted:.1f} hours\n" + f"Locked users are earning less than unlocked — incentive misalignment\n" + f"[yvUSD Vault](https://etherscan.io/address/{YVUSD_VAULT})" + ) + send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) + set_cache_value(CACHE_KEY_APY_INVERSION_ALERTED, 1) + else: + # Inversion resolved — reset tracking + if get_cache_value(CACHE_KEY_APY_INVERSION_START) > 0: + set_cache_value(CACHE_KEY_APY_INVERSION_START, 0) + set_cache_value(CACHE_KEY_APY_INVERSION_ALERTED, 0) + logger.info("APY inversion resolved") + + +def _check_negative_strategy_apr(yvusd_data: dict) -> None: + """Alert if any active strategy has a negative APR. + + Debounced per-strategy so a sustained negative APR doesn't spam alerts every + hourly run. The dedup flag resets once the strategy returns to non-negative. + """ + strategies = yvusd_data.get("meta", {}).get("strategies", []) + + for strategy in strategies: + apr_raw = int(strategy.get("apr_raw", "0")) + debt = int(strategy.get("debt", "0")) + name = strategy.get("meta", {}).get("name", strategy.get("address", "unknown")) + address = strategy.get("address", "unknown") + cache_key = f"{CACHE_KEY_NEG_APR_PREFIX}{address.lower()}" + already_alerted = get_cache_value(cache_key) == 1 + + if debt > 0 and apr_raw < 0: + if already_alerted: + continue + apr_pct = apr_raw / 1e18 * 100 + debt_usd = debt / ONE_USDC + message = ( + f"*yvUSD Negative Strategy APR*\n" + f"{name}: {apr_pct:.2f}% APR\n" + f"Debt: {format_usd(debt_usd)}\n" + f"Strategy is losing money\n" + f"[Strategy](https://etherscan.io/address/{address})" + ) + send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) + set_cache_value(cache_key, 1) + elif already_alerted: + # Recovered — clear the dedup flag so a new dip will alert again. + set_cache_value(cache_key, 0) + logger.info("Negative APR resolved for %s", name) + + +def check_strategy_staleness(client: Web3Client, api_data: dict) -> None: + """Check cross-chain strategy report freshness. + + Alerts when a CCTP cross-chain strategy or its remote counterpart: + - has not reported in more than CCTP_REPORT_STALENESS_HOURS, or + - is out of sync with the other side by more than CCTP_REPORT_SKEW_HOURS + """ + strategies = api_data.get(YVUSD_VAULT, {}).get("meta", {}).get("strategies", []) + cross_chain = [s for s in strategies if s.get("meta", {}).get("type") == "cross-chain"] + + if not cross_chain: + logger.info("No cross-chain strategies found") + return + + vault = client.eth.contract(address=YVUSD_VAULT, abi=ABI_VAULT) + + with client.batch_requests() as batch: + for strategy in cross_chain: + batch.add(vault.functions.strategies(strategy["address"])) + responses = client.execute_batch(batch) + + if len(responses) != len(cross_chain): + logger.error("Unexpected batch response count for strategy staleness check") + return + + now = int(time.time()) + + for strategy, local_state in zip(cross_chain, responses, strict=False): + activation, local_last_report, local_debt, _ = local_state + name = strategy.get("meta", {}).get("name", strategy["address"]) + address = strategy["address"] + meta = strategy.get("meta", {}) + remote_chain_id = meta.get("remote_chain_id") + remote_vault = meta.get("remote_vault") + + if activation == 0 or not remote_chain_id or not remote_vault: + continue + + try: + remote_chain = Chain.from_chain_id(remote_chain_id) + except ValueError: + logger.error("Unknown remote chain_id %s for strategy %s", remote_chain_id, name) + send_alert( + Alert( + AlertSeverity.MEDIUM, + f"yvUSD CCTP: unknown remote chain_id {remote_chain_id} for {name}", + PROTOCOL, + ) + ) + continue + + if not _has_configured_rpc(remote_chain): + _alert_missing_rpc_once(remote_chain, name, kind="CCTP staleness") + continue + + remote_state = _fetch_remote_strategy_state(remote_chain, remote_vault, name) + if remote_state is None: + continue + remote_last_report, remote_debt = remote_state + + local_hours_since = (now - local_last_report) / 3600 + remote_hours_since = (now - remote_last_report) / 3600 + report_skew_hours = abs(local_last_report - remote_last_report) / 3600 + local_debt_usd = local_debt / ONE_USDC + remote_debt_usd = remote_debt / ONE_USDC + + logger.info( + "CCTP strategy %s — local report: %.1fh, remote report: %.1fh, skew: %.1fh, local debt: %s, remote debt: %s", + name, + local_hours_since, + remote_hours_since, + report_skew_hours, + format_usd(local_debt_usd), + format_usd(remote_debt_usd), + ) + + alert_lines = _build_cctp_alert_lines( + name=name, + local_chain=Chain.MAINNET, + local_last_report=local_last_report, + local_debt=local_debt, + remote_chain=remote_chain, + remote_last_report=remote_last_report, + remote_debt=remote_debt, + now=now, + ) + if alert_lines: + message = ( + "*yvUSD CCTP Bridge Health Alert*\n" + "\n".join(alert_lines) + "\n" + f"[Strategy](https://etherscan.io/address/{address})" + ) + send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) + + +def _alert_missing_rpc_once(chain: Chain, name: str, *, kind: str) -> None: + """Send a one-shot MEDIUM alert when a remote chain has no PROVIDER_URL configured. + + Without this, ChainManager.get_client() would raise on every hourly run for + chains like Katana when PROVIDER_URL_KATANA isn't set, which would either + spam the failure path or be silently skipped depending on call site. + """ + cache_key = f"{CACHE_KEY_RPC_MISSING_PREFIX}{chain.name}" + if get_cache_value(cache_key) == 1: + logger.info("Skipping %s for %s on %s: no PROVIDER_URL configured (already alerted)", kind, name, chain.name) + return + + logger.warning("No PROVIDER_URL_%s configured; cannot run %s for %s", chain.name.upper(), kind, name) + send_alert( + Alert( + AlertSeverity.MEDIUM, + ( + f"*yvUSD: missing RPC for {chain.network_name}*\n" + f"PROVIDER_URL_{chain.name.upper()} is not set — {kind} cannot run " + f"for cross-chain strategies on {chain.network_name} (e.g. {name}).\n" + f"Configure the env var to re-enable this check." + ), + PROTOCOL, + ) + ) + set_cache_value(cache_key, 1) + + +def _fetch_remote_strategy_state(remote_chain: Chain, remote_vault: str, name: str) -> tuple[int, int] | None: + """Fetch (lastReport, totalAssets) for a remote V3 tokenized strategy. + + Returns None and surfaces a MEDIUM alert if the lookup fails — silent + skips would let the CCTP health check disable itself for misconfigured + strategies without anyone noticing. + """ + try: + remote_client = ChainManager.get_client(remote_chain) + remote_strategy = remote_client.eth.contract(address=remote_vault, abi=ABI_TOKENIZED_STRATEGY) + last_report = remote_strategy.functions.lastReport().call() + total_assets = remote_strategy.functions.totalAssets().call() + return int(last_report), int(total_assets) + except Exception as e: + explorer = remote_chain.explorer_url or "" + link = f"{explorer}/address/{remote_vault}" if explorer else remote_vault + logger.error("Failed to fetch remote state for %s on %s: %s", name, remote_chain.network_name, e) + send_alert( + Alert( + AlertSeverity.MEDIUM, + ( + f"*yvUSD CCTP Remote Lookup Failed*\n" + f"{name} on {remote_chain.network_name}\n" + f"Remote vault: {link}\n" + f"Error: {e}\n" + f"CCTP health check is unable to verify this strategy" + ), + PROTOCOL, + ) + ) + return None + + +def _build_cctp_alert_lines( + *, + name: str, + local_chain: Chain, + local_last_report: int, + local_debt: int, + remote_chain: Chain, + remote_last_report: int, + remote_debt: int, + now: int, +) -> list[str]: + local_hours_since = (now - local_last_report) / 3600 + remote_hours_since = (now - remote_last_report) / 3600 + report_skew_hours = abs(local_last_report - remote_last_report) / 3600 + has_position = local_debt > 0 or remote_debt > 0 + if not has_position: + return [] + + problems = [] + if local_debt > 0 and local_hours_since > CCTP_REPORT_STALENESS_HOURS: + problems.append(f"{local_chain.network_name} report stale: {local_hours_since:.1f}h") + if remote_debt > 0 and remote_hours_since > CCTP_REPORT_STALENESS_HOURS: + problems.append(f"{remote_chain.network_name} report stale: {remote_hours_since:.1f}h") + if report_skew_hours > CCTP_REPORT_SKEW_HOURS: + newer_chain = local_chain if local_last_report >= remote_last_report else remote_chain + problems.append( + f"report skew {report_skew_hours:.1f}h ({newer_chain.network_name} is newer than the other side)" + ) + + if not problems: + return [] + + return [ + name, + *problems, + f"{local_chain.network_name.title()} last report: {local_hours_since:.1f}h ago, debt: {format_usd(local_debt / ONE_USDC)}", + f"{remote_chain.network_name.title()} last report: {remote_hours_since:.1f}h ago, debt: {format_usd(remote_debt / ONE_USDC)}", + "Bridge accounting may be delayed or unsynced", + ] + + +def _collect_looper_positions(strategies: list[dict]) -> list[LooperPosition]: + """Discover all active Morpho-looper borrow positions from the API metadata. + + Includes both: + - Direct mainnet looper strategies (type in LOOPER_STRATEGY_TYPES) + - Cross-chain wrappers where the remote side is itself a looper + (remote_vault_type in LOOPER_STRATEGY_TYPES); the borrower on Morpho is + the remote tokenized strategy (`remote_vault`). + """ + positions: list[LooperPosition] = [] + + for s in strategies: + meta = s.get("meta", {}) or {} + type_ = meta.get("type") + debt = int(s.get("debt", "0")) + name = meta.get("name", s.get("address", "unknown")) + address = s.get("address", "") + + if debt <= 0: + continue + + if type_ in LOOPER_STRATEGY_TYPES: + market_id = meta.get("market_id") + if not market_id: + continue + positions.append( + LooperPosition( + chain=Chain.MAINNET, + market_id=market_id, + borrower=address, + name=name, + mainnet_strategy=address, + ) + ) + continue + + if type_ != "cross-chain": + continue + + if meta.get("remote_vault_type") not in LOOPER_STRATEGY_TYPES: + continue + + remote_chain_id = meta.get("remote_chain_id") + remote_vault = meta.get("remote_vault") + remote_meta = meta.get("remote_meta") or {} + market_id = remote_meta.get("market_id") + + if not (remote_chain_id and remote_vault and market_id): + logger.warning("Cross-chain looper %s missing remote market metadata; skipping", name) + continue + + try: + remote_chain = Chain.from_chain_id(remote_chain_id) + except ValueError: + logger.error("Cross-chain looper %s on unknown chain_id %s; skipping", name, remote_chain_id) + send_alert( + Alert( + AlertSeverity.MEDIUM, + f"yvUSD: cross-chain looper {name} references unknown chain_id {remote_chain_id}", + PROTOCOL, + ) + ) + continue + + positions.append( + LooperPosition( + chain=remote_chain, + market_id=market_id, + borrower=remote_vault, + name=name, + mainnet_strategy=address, + ) + ) + + return positions + + +def check_flashloan_liquidity(api_data: dict) -> None: + """Check available flashloan liquidity for looper strategy unwinding. + + Compares each looper strategy's Morpho borrow position against available + flashloan liquidity from the chain's Balancer vault and the Morpho market. + Cross-chain loopers are checked on their remote chain (where the actual + leverage and unwind liquidity live). + """ + strategies = api_data.get(YVUSD_VAULT, {}).get("meta", {}).get("strategies", []) + positions = _collect_looper_positions(strategies) + + if not positions: + logger.info("No active Morpho looper positions found") + return + + by_chain: dict[Chain, list[LooperPosition]] = {} + for p in positions: + by_chain.setdefault(p.chain, []).append(p) + + for chain, chain_positions in by_chain.items(): + config = LOOPER_CHAIN_CONFIG.get(chain) + if not config: + logger.error("No looper config for chain %s; %d positions uncovered", chain.name, len(chain_positions)) + send_alert( + Alert( + AlertSeverity.MEDIUM, + ( + f"yvUSD: flashloan liquidity check unsupported on {chain.network_name} " + f"({len(chain_positions)} looper position(s) uncovered)" + ), + PROTOCOL, + ) + ) + continue + + if chain != Chain.MAINNET and not _has_configured_rpc(chain): + names = ", ".join(p.name for p in chain_positions) + _alert_missing_rpc_once(chain, names, kind="flashloan liquidity check") + continue + + try: + _check_chain_flashloan_liquidity(chain, chain_positions, config) + except Exception as e: + logger.error("Flashloan liquidity check failed on %s: %s", chain.name, e) + send_alert( + Alert( + AlertSeverity.MEDIUM, + f"yvUSD flashloan liquidity check failed on {chain.network_name}: {e}", + PROTOCOL, + ) + ) + + +def _check_chain_flashloan_liquidity(chain: Chain, positions: list[LooperPosition], config: dict[str, str]) -> None: + """Run the flashloan liquidity check for all positions on a single chain.""" + client = ChainManager.get_client(chain) + morpho = client.eth.contract(address=config["morpho"], abi=ABI_MORPHO_BLUE) + usdc = client.eth.contract(address=config["usdc"], abi=ABI_ERC20_BALANCE) + + with client.batch_requests() as batch: + for p in positions: + market_id = bytes.fromhex(p.market_id[2:]) + batch.add(morpho.functions.market(market_id)) + batch.add(morpho.functions.position(market_id, p.borrower)) + batch.add(usdc.functions.balanceOf(config["balancer_vault"])) + responses = client.execute_batch(batch) + + expected = len(positions) * 2 + 1 + if len(responses) != expected: + logger.error("Unexpected batch response count on %s: got %d, expected %d", chain.name, len(responses), expected) + return + + balancer_usdc = responses[-1] / ONE_USDC + logger.info("[%s] Balancer vault USDC balance: %s", chain.name, format_usd(balancer_usdc)) + + explorer = chain.explorer_url or "https://etherscan.io" + + for i, p in enumerate(positions): + market_data = responses[i * 2] + position_data = responses[i * 2 + 1] + + total_supply_assets = market_data[0] + total_borrow_assets = market_data[2] + total_borrow_shares = market_data[3] + borrow_shares = position_data[1] + + # Convert borrow shares to assets + if total_borrow_shares > 0 and borrow_shares > 0: + borrow_assets = borrow_shares * total_borrow_assets // total_borrow_shares + else: + borrow_assets = 0 + + borrow_usd = borrow_assets / ONE_USDC + market_liquidity = (total_supply_assets - total_borrow_assets) / ONE_USDC + + logger.info( + "[%s] Looper %s — borrow: %s, market liquidity: %s", + chain.name, + p.name, + format_usd(borrow_usd), + format_usd(market_liquidity), + ) + + # Debounce alerts per (chain, borrower) so a persistent shortfall doesn't refire hourly. + dedup_key = f"{CACHE_KEY_FLASHLOAN_PREFIX}{chain.name}_{p.borrower.lower()}" + already_alerted = get_cache_value(dedup_key) == 1 + + if borrow_assets == 0: + if already_alerted: + set_cache_value(dedup_key, 0) + continue + + # Strategy needs to flashloan approximately borrow_assets to unwind. + # Alert if neither Balancer vault nor Morpho market has sufficient liquidity. + if balancer_usdc < borrow_usd and market_liquidity < borrow_usd: + if already_alerted: + continue + links = [f"[Strategy](https://etherscan.io/address/{p.mainnet_strategy})"] + if p.chain != Chain.MAINNET: + links.append(f"[Borrower on {p.chain.network_name}]({explorer}/address/{p.borrower})") + message = ( + f"*yvUSD Flashloan Liquidity Warning*\n" + f"{p.name} ({p.chain.network_name})\n" + f"Borrow position: {format_usd(borrow_usd)}\n" + f"Balancer flashloan available: {format_usd(balancer_usdc)}\n" + f"Morpho market liquidity: {format_usd(market_liquidity)}\n" + f"Insufficient flashloan liquidity for strategy unwinding\n" + " | ".join(links) + ) + send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) + set_cache_value(dedup_key, 1) + elif already_alerted: + # Recovered — clear the dedup flag so the next shortfall alerts again. + set_cache_value(dedup_key, 0) + logger.info("[%s] Flashloan liquidity recovered for %s", chain.name, p.name) + + +def check_large_cooldowns(client: Web3Client) -> None: + """Check for large cooldown requests on LockedyvUSD. + + Scans recent blocks for CooldownStarted events exceeding LARGE_COOLDOWN_THRESHOLD. + """ + locked = client.eth.contract(address=LOCKED_YVUSD, abi=ABI_LOCKED) + + current_block = client.eth.block_number + last_block = int(get_cache_value(CACHE_KEY_LAST_BLOCK)) + + if last_block == 0: + from_block = current_block - BLOCKS_PER_HOUR + else: + from_block = last_block + 1 + + if from_block >= current_block: + logger.info("No new blocks to scan for cooldown events") + set_cache_value(CACHE_KEY_LAST_BLOCK, current_block) + return + + # Cap scan range to avoid hitting RPC limits + if current_block - from_block > MAX_SCAN_BLOCKS: + from_block = current_block - MAX_SCAN_BLOCKS + logger.warning("Capped scan range to last %d blocks", MAX_SCAN_BLOCKS) + + logger.info("Scanning blocks %d to %d for cooldown events", from_block, current_block) + + try: + events = locked.events.CooldownStarted.get_logs(from_block=from_block, to_block=current_block) + except Exception as e: + logger.warning("Could not fetch CooldownStarted events: %s", e) + if get_cache_value(CACHE_KEY_COOLDOWN_FETCH_FAILED) == 0: + send_alert( + Alert( + AlertSeverity.MEDIUM, + ( + f"*yvUSD Cooldown Scan Failed*\n" + f"Could not fetch CooldownStarted events: {e}\n" + f"Large cooldown monitoring is paused until RPC recovers; " + f"cache is not advanced so missed events will be picked up on recovery." + ), + PROTOCOL, + ) + ) + set_cache_value(CACHE_KEY_COOLDOWN_FETCH_FAILED, 1) + return + + # Successful fetch — clear any previously-set failure flag. + if get_cache_value(CACHE_KEY_COOLDOWN_FETCH_FAILED) == 1: + set_cache_value(CACHE_KEY_COOLDOWN_FETCH_FAILED, 0) + + large_count = 0 + for event in events: + shares = event["args"]["shares"] + owner = event["args"]["user"] + # yvUSD shares are roughly 1:1 with USDC (PPS ~ 1.004) + shares_usd = shares / ONE_USDC + + if shares_usd >= LARGE_COOLDOWN_THRESHOLD: + large_count += 1 + message = ( + f"*yvUSD Large Cooldown Request*\n" + f"{format_usd(shares_usd)} cooldown requested\n" + f"Owner: [{owner}](https://etherscan.io/address/{owner})\n" + f"Cooldown period: 14 days\n" + f"Large withdrawal incoming — may impact vault liquidity\n" + f"[LockedyvUSD](https://etherscan.io/address/{LOCKED_YVUSD})" + ) + send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) + + logger.info("Found %d cooldown events (%d large)", len(events), large_count) + set_cache_value(CACHE_KEY_LAST_BLOCK, current_block) + + +def main() -> None: + """Run all yvUSD monitoring checks.""" + logger.info("Starting yvUSD monitoring...") + + client = ChainManager.get_client(Chain.MAINNET) + + try: + api_data = fetch_json(YVUSD_API_URL) + if api_data: + check_apy_anomalies(api_data) + check_strategy_staleness(client, api_data) + check_flashloan_liquidity(api_data) + else: + send_alert(Alert(AlertSeverity.MEDIUM, "Failed to fetch yvUSD API data", PROTOCOL)) + except Exception as e: + logger.error("Error during yvUSD API checks: %s", e) + send_alert(Alert(AlertSeverity.MEDIUM, f"yvUSD API checks failed: {e}", PROTOCOL)) + + try: + check_large_cooldowns(client) + except Exception as e: + logger.error("Error during cooldown check: %s", e) + send_alert(Alert(AlertSeverity.MEDIUM, f"yvUSD cooldown check failed: {e}", PROTOCOL)) + + logger.info("yvUSD monitoring complete") + + +if __name__ == "__main__": + main()