From d47d27af94fad3168482adab21908e2a7bb9b084 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Sun, 4 Oct 2026 20:32:33 +0200 Subject: [PATCH 1/2] Use TVL ratios for CAP withdrawable liquidity alerts --- protocols/cap/README.md | 4 +- protocols/cap/liquidity.py | 74 ++++++++++----- tests/test_cap_liquidity.py | 174 ++++++++++++++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 26 deletions(-) create mode 100644 tests/test_cap_liquidity.py diff --git a/protocols/cap/README.md b/protocols/cap/README.md index 58c6989f..f3f46356 100644 --- a/protocols/cap/README.md +++ b/protocols/cap/README.md @@ -27,8 +27,8 @@ It monitors withdrawable liquidity for the CAP protocol's cUSD contract [`0xcCcc 2. **For each asset**, calculates total withdrawable liquidity: - Withdrawable amount from the fractional reserve vault (via `maxWithdraw` for the cUSD contract) - Direct token balance held by the cUSD contract -3. **Sums normalized values** across all assets -4. **Sends Telegram alert** if total withdrawable liquidity falls below [defined threshold](./liquidity.py#L8) telegram alert is sent +3. **Sums normalized values** across all assets for both withdrawable liquidity and total TVL. TVL is the sum of cUSD's `totalSupplies(asset)` values, including borrowed assets. Both totals value backing assets at par after adjusting for token decimals; all reads are pinned to the same block. +4. **Sends Telegram alert** when total withdrawable liquidity is **below 10% of total TVL** (high) or **below 3%** (critical). Exactly 10% does not alert; exactly 3% sends high. Alerts include each asset's withdrawable amount, total withdrawable liquidity, total TVL, and the percentage. A zero TVL skips the ratio check. 5. **RedStone Price Feed for cUSD_FUNDAMENTAL** if the value falls below 99980000, telegram alert is sent. [Tenderly alert](https://dashboard.tenderly.co/yearn/sam/alerts/rules/316f440e-457b-4cfa-a69e-f7f54230bf44) ## Large Mint Monitoring (No Event Scanning) diff --git a/protocols/cap/liquidity.py b/protocols/cap/liquidity.py index cda8aab6..cbfb7eb7 100644 --- a/protocols/cap/liquidity.py +++ b/protocols/cap/liquidity.py @@ -1,4 +1,4 @@ -from decimal import Decimal +from decimal import Decimal, localcontext from utils.abi import load_abi from utils.alert import Alert, AlertSeverity, send_alert @@ -21,7 +21,8 @@ MINT_THRESHOLD_PERCENT = Decimal(Config.get_env("CUSD_LARGE_MINT_THRESHOLD_PERCENT", "0.05")) CACHE_KEY_LAST_SUPPLY = f"{PROTOCOL}_large_mints_last_supply" -ALERT_THRESHOLD = 15_000_000 +HIGH_LIQUIDITY_THRESHOLD = Decimal("0.10") +CRITICAL_LIQUIDITY_THRESHOLD = Decimal("0.03") def _to_int(value) -> int: @@ -48,7 +49,7 @@ def main(): batch.add(ctoken.functions.fractionalReserveVault(asset).call(block_identifier=block_number)) vault_addresses = batch.execute() - # Batch 2: for each asset, get vault maxWithdraw for CUSD owner, token balance, decimals, and symbol + # Batch 2: read withdrawable liquidity and total supplied assets at the same block. with client.batch_requests() as batch: for asset, vault_addr in zip(assets, vault_addresses): vault = client.eth.contract(address=vault_addr, abi=load_abi("protocols/cap/abi/YearnV3Vault.json")) @@ -57,29 +58,54 @@ def main(): batch.add(token.functions.balanceOf(CUSD).call(block_identifier=block_number)) batch.add(token.functions.decimals().call(block_identifier=block_number)) batch.add(token.functions.symbol().call(block_identifier=block_number)) + batch.add(ctoken.functions.totalSupplies(asset).call(block_identifier=block_number)) responses = batch.execute() - # Parse batched results (4 entries per asset) - lines = [] - total_normalized = 0 - for i in range(0, len(responses), 4): - vault_withdrawable = responses[i] or 0 - direct_balance = responses[i + 1] or 0 - decimals = responses[i + 2] if responses[i + 2] is not None else 18 - symbol = responses[i + 3] or "UNKNOWN" - - total_units = int(vault_withdrawable) + int(direct_balance) - - divisor = 10 ** int(decimals) - normalized = total_units / divisor if divisor else 0 - line = f"{symbol}: {normalized:,.6f}" - logger.info("%s", line) - total_normalized += normalized - lines.append(line) - - if total_normalized < ALERT_THRESHOLD: - message = "🔻 CAP Withdrawable Liquidity (Mainnet)\n" + "\n".join(lines) - send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) + # Keep uint256 amounts precise even when other monitors lower the global Decimal precision. + with localcontext(prec=100): + # Parse batched results (5 entries per asset), valuing normalized backing assets at par. + lines = [] + total_normalized = Decimal(0) + total_tvl = Decimal(0) + for i in range(0, len(responses), 5): + vault_withdrawable = responses[i] or 0 + direct_balance = responses[i + 1] or 0 + decimals = responses[i + 2] if responses[i + 2] is not None else 18 + symbol = responses[i + 3] or "UNKNOWN" + total_supplied = responses[i + 4] + if total_supplied is None: + raise RuntimeError(f"CAP liquidity RPC returned no value for {symbol} totalSupplies") + + total_units = int(vault_withdrawable) + int(direct_balance) + + divisor = Decimal(10) ** int(decimals) + normalized = Decimal(total_units) / divisor + line = f"{symbol}: {normalized:,.6f}" + logger.info("%s", line) + total_normalized += normalized + total_tvl += Decimal(int(total_supplied)) / divisor + lines.append(line) + + if total_tvl > 0: + liquidity_percent = total_normalized / total_tvl * Decimal(100) + logger.info("CAP withdrawable liquidity: %s%% of total TVL %s", liquidity_percent, total_tvl) + severity = None + if total_normalized < total_tvl * CRITICAL_LIQUIDITY_THRESHOLD: + severity = AlertSeverity.CRITICAL + elif total_normalized < total_tvl * HIGH_LIQUIDITY_THRESHOLD: + severity = AlertSeverity.HIGH + + if severity is not None: + message = ( + "🔻 CAP Withdrawable Liquidity (Mainnet)\n" + + "\n".join(lines) + + f"\nTotal withdrawable: {total_normalized:,.6f}\n" + f"Total TVL: {total_tvl:,.6f}\n" + f"Withdrawable / TVL: {liquidity_percent:,.2f}%" + ) + send_alert(Alert(severity, message, PROTOCOL)) + else: + logger.warning("CAP total TVL is zero; skipping withdrawable liquidity ratio check") # --- cUSD Large Mint Monitoring (No Event Scanning) --- current_supply_raw = int(ctoken.functions.totalSupply().call(block_identifier=block_number)) diff --git a/tests/test_cap_liquidity.py b/tests/test_cap_liquidity.py new file mode 100644 index 00000000..efab121d --- /dev/null +++ b/tests/test_cap_liquidity.py @@ -0,0 +1,174 @@ +from decimal import localcontext +from types import SimpleNamespace + +import pytest + +import protocols.cap.liquidity as liquidity +from utils.alert import Alert, AlertSeverity + + +def run_monitor( + monkeypatch: pytest.MonkeyPatch, + asset_responses: list[object], + *, + current_supply: int = 1_000_000 * 10**18, + previous_supply: int = 0, +) -> tuple[list[Alert], list[object], list[object]]: + """Run the monitor with mocked RPC and cache, capturing calls and alerts.""" + alerts: list[Alert] = [] + calls: list[object] = [] + writes: list[object] = [] + assets = [f"asset-{i}" for i in range(len(asset_responses) // 5)] + batches = iter([[f"vault-{i}" for i in range(len(assets))], asset_responses]) + + class ContractCall: + def __init__(self, name: str, args: tuple[object, ...]) -> None: + self.name = name + self.args = args + + def call(self, *, block_identifier: int) -> object: + call = (self.name, self.args, block_identifier) + if self.name in {"assets", "totalSupply"}: + calls.append(call) + return assets if self.name == "assets" else current_supply + return call + + class Batch: + def __enter__(self) -> "Batch": + return self + + def __exit__(self, *_args: object) -> None: + return None + + def add(self, call: object) -> None: + calls.append(call) + + def execute(self) -> list[object]: + return next(batches) + + functions = SimpleNamespace( + **{ + name: lambda *args, name=name: ContractCall(name, args) + for name in [ + "assets", + "fractionalReserveVault", + "maxWithdraw", + "balanceOf", + "decimals", + "symbol", + "totalSupplies", + "totalSupply", + ] + } + ) + contract = SimpleNamespace(functions=functions) + client = SimpleNamespace( + eth=SimpleNamespace(block_number=23_456_789, contract=lambda **_kwargs: contract), + batch_requests=Batch, + ) + monkeypatch.setattr(liquidity.ChainManager, "get_client", lambda _chain: client) + monkeypatch.setattr(liquidity, "send_alert", alerts.append) + monkeypatch.setattr(liquidity, "get_fresh_last_value_for_key_from_file", lambda *_args: previous_supply) + monkeypatch.setattr(liquidity, "write_last_value_with_timestamp_to_file", lambda *args: writes.append(args)) + + liquidity.main() + + return alerts, calls, writes + + +@pytest.mark.parametrize("precision", [18, 28]) +@pytest.mark.parametrize( + ("withdrawable_raw", "expected_severity"), + [ + (0, AlertSeverity.CRITICAL), + (3 * 10**18 - 1, AlertSeverity.CRITICAL), + (3 * 10**18, AlertSeverity.HIGH), + (3 * 10**18 + 1, AlertSeverity.HIGH), + (10 * 10**18 - 1, AlertSeverity.HIGH), + (10 * 10**18, None), + (10 * 10**18 + 1, None), + (100 * 10**18, None), + ], +) +def test_liquidity_severity_boundaries( + monkeypatch: pytest.MonkeyPatch, withdrawable_raw: int, expected_severity: AlertSeverity | None, precision: int +) -> None: + with localcontext(prec=precision) as context: + alerts, _, _ = run_monitor(monkeypatch, [withdrawable_raw, 0, 18, "USDC", 100 * 10**18]) + assert context.prec == precision + + assert [alert.severity for alert in alerts] == ([] if expected_severity is None else [expected_severity]) + assert all(alert.protocol == "cap" for alert in alerts) + + +def test_liquidity_aggregates_assets_with_different_decimals_at_one_block(monkeypatch: pytest.MonkeyPatch) -> None: + alerts, calls, writes = run_monitor( + monkeypatch, + [ + 2_000_000, + 1_000_000, + 6, + "USDC", + 40_000_000, + 2 * 10**18, + 10**18, + 18, + "wWTGXX", + 60 * 10**18, + ], + ) + + assert len(alerts) == 1 + assert alerts[0].severity == AlertSeverity.HIGH + assert "USDC: 3.000000" in alerts[0].message + assert "wWTGXX: 3.000000" in alerts[0].message + assert "Total withdrawable: 6.000000" in alerts[0].message + assert "Total TVL: 100.000000" in alerts[0].message + assert "Withdrawable / TVL: 6.00%" in alerts[0].message + assert {call[2] for call in calls} == {23_456_789} + assert [call[1] for call in calls if call[0] == "totalSupplies"] == [("asset-0",), ("asset-1",)] + assert writes == [(liquidity.cache_filename, liquidity.CACHE_KEY_LAST_SUPPLY, 1_000_000 * 10**18)] + + +@pytest.mark.parametrize( + ("withdrawable", "tvl", "expected_severity"), + [ + (6_000_000, 20_000_000, None), + (20_000_000, 1_000_000_000, AlertSeverity.CRITICAL), + ], +) +def test_liquidity_threshold_scales_with_tvl( + monkeypatch: pytest.MonkeyPatch, withdrawable: int, tvl: int, expected_severity: AlertSeverity | None +) -> None: + alerts, _, _ = run_monitor(monkeypatch, [withdrawable * 10**6, 0, 6, "USDC", tvl * 10**6]) + + assert [alert.severity for alert in alerts] == ([] if expected_severity is None else [expected_severity]) + + +@pytest.mark.parametrize("asset_responses", [[], [0, 0, 6, "USDC", 0]]) +def test_zero_tvl_skips_liquidity_alert_and_updates_supply( + monkeypatch: pytest.MonkeyPatch, asset_responses: list[object] +) -> None: + alerts, _, writes = run_monitor(monkeypatch, asset_responses) + + assert alerts == [] + assert writes == [(liquidity.cache_filename, liquidity.CACHE_KEY_LAST_SUPPLY, 1_000_000 * 10**18)] + + +def test_missing_total_supplies_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + with pytest.raises(RuntimeError, match="USDC totalSupplies"): + run_monitor(monkeypatch, [1_000_000, 0, 6, "USDC", None]) + + +def test_large_mint_monitoring_still_runs_with_zero_tvl(monkeypatch: pytest.MonkeyPatch) -> None: + alerts, _, writes = run_monitor( + monkeypatch, + [0, 0, 6, "USDC", 0], + current_supply=105 * 10**18, + previous_supply=100 * 10**18, + ) + + assert len(alerts) == 1 + assert alerts[0].severity == AlertSeverity.LOW + assert "Supply increase: 5.00 cUSD" in alerts[0].message + assert writes == [(liquidity.cache_filename, liquidity.CACHE_KEY_LAST_SUPPLY, 105 * 10**18)] From f758273c6b17f1037597439d2afcc688ec6a4c7a Mon Sep 17 00:00:00 2001 From: spalen0 Date: Sun, 4 Oct 2026 20:37:56 +0200 Subject: [PATCH 2/2] Fix CAP test helper types for CI --- tests/test_cap_liquidity.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/test_cap_liquidity.py b/tests/test_cap_liquidity.py index efab121d..67595b67 100644 --- a/tests/test_cap_liquidity.py +++ b/tests/test_cap_liquidity.py @@ -6,6 +6,8 @@ import protocols.cap.liquidity as liquidity from utils.alert import Alert, AlertSeverity +type RpcCall = tuple[str, tuple[object, ...], int] + def run_monitor( monkeypatch: pytest.MonkeyPatch, @@ -13,13 +15,14 @@ def run_monitor( *, current_supply: int = 1_000_000 * 10**18, previous_supply: int = 0, -) -> tuple[list[Alert], list[object], list[object]]: +) -> tuple[list[Alert], list[RpcCall], list[tuple[object, ...]]]: """Run the monitor with mocked RPC and cache, capturing calls and alerts.""" alerts: list[Alert] = [] - calls: list[object] = [] - writes: list[object] = [] + calls: list[RpcCall] = [] + writes: list[tuple[object, ...]] = [] assets = [f"asset-{i}" for i in range(len(asset_responses) // 5)] - batches = iter([[f"vault-{i}" for i in range(len(assets))], asset_responses]) + vault_responses: list[object] = [f"vault-{i}" for i in range(len(assets))] + batches = iter([vault_responses, asset_responses]) class ContractCall: def __init__(self, name: str, args: tuple[object, ...]) -> None: @@ -40,7 +43,7 @@ def __enter__(self) -> "Batch": def __exit__(self, *_args: object) -> None: return None - def add(self, call: object) -> None: + def add(self, call: RpcCall) -> None: calls.append(call) def execute(self) -> list[object]: