Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions protocols/cap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
74 changes: 50 additions & 24 deletions protocols/cap/liquidity.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand All @@ -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"))
Expand All @@ -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))
Expand Down
177 changes: 177 additions & 0 deletions tests/test_cap_liquidity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
from decimal import localcontext
from types import SimpleNamespace

import pytest

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,
asset_responses: list[object],
*,
current_supply: int = 1_000_000 * 10**18,
previous_supply: int = 0,
) -> 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[RpcCall] = []
writes: list[tuple[object, ...]] = []
assets = [f"asset-{i}" for i in range(len(asset_responses) // 5)]
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:
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: RpcCall) -> 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)]
Loading