diff --git a/automation/jobs.yaml b/automation/jobs.yaml index da74d104..c6eb6e2a 100644 --- a/automation/jobs.yaml +++ b/automation/jobs.yaml @@ -43,6 +43,7 @@ profiles: - { name: "usdai-large-mints", script: protocols/usdai/large_mints.py } - { name: "stables-dune-large-transfers", script: protocols/stables/dune_large_transfers.py } - { name: "stables-oracles", script: protocols/stables/oracles.py } + - { name: "cap-status", script: protocols/cap/status.py } - { name: "yearn-alert-large-flows", script: protocols/yearn/alert_large_flows.py } # Cache: tks-trigger-cache.json under $CACHE_DIR (check_stuck_triggers.DEFAULT_CACHE_FILE). - { name: "yearn-check-stuck-triggers", script: protocols/yearn/check_stuck_triggers.py, enabled: false } diff --git a/monitoring.yaml b/monitoring.yaml index 741ad165..cfda9423 100644 --- a/monitoring.yaml +++ b/monitoring.yaml @@ -78,10 +78,15 @@ protocols: cap: display_name: "CAP" description: "CAP cUSD liquidity and governance monitoring on Mainnet" - cadence: "Daily" + cadence: "Hourly / Daily" tasks: + - protocols/cap/status.py - protocols/cap/liquidity.py monitors: + - name: "stcUSD Backing" + description: "cUSD balance covers stcUSD totalAssets plus lockedProfit" + - name: "stcUSD Assets Per Share" + description: "Critical alert if convertToAssets(1 stcUSD) decreases" - name: "Withdrawable Liquidity" description: "Total withdrawable liquidity across cUSD assets < $15M" - name: "Large cUSD Mints" diff --git a/protocols/cap/README.md b/protocols/cap/README.md index 3af1fe12..4b46fed3 100644 --- a/protocols/cap/README.md +++ b/protocols/cap/README.md @@ -4,9 +4,18 @@ For more info about CAP protocol check [the docs](https://docs.cap.app/). ## Governance -[cUSD](https://etherscan.io/address/0x16d06500192c12a3306748346511c07c955f0f96#code) contract is upgradable proxy on Mainnet. The roles are set in `AccessStorageLocation` at `0xb413d65cb88f23816c329284a0d3eb15a99df7963ab7402ade4c5da22bff6b00` which points to [AccessControl](https://etherscan.io/address/0x7731129a10d51e18cde607c5c115f26503d2c683#code) proxy contract. Default admin role of the contract is set to [Timelock contact](https://etherscan.io/address/0xD8236031d8279d82E615aF2BFab5FC0127A329ab#readContract) with minimum [24h delay](https://etherscan.io/address/0xD8236031d8279d82E615aF2BFab5FC0127A329ab#readContract#F5). +[cUSD](https://etherscan.io/address/0xcCcc62962d17b8914c62D74FfB843d73B2a3cccC#code) is an upgradeable proxy on Mainnet. Its roles are stored in `AccessStorageLocation` at `0xb413d65cb88f23816c329284a0d3eb15a99df7963ab7402ade4c5da22bff6b00`, which points to the [AccessControl](https://etherscan.io/address/0x7731129a10d51e18cde607c5c115f26503d2c683#code) proxy. The sole default admin is the [Timelock contract](https://etherscan.io/address/0xD8236031d8279d82E615aF2BFab5FC0127A329ab#readContract), which has a minimum [24-hour delay](https://etherscan.io/address/0xD8236031d8279d82E615aF2BFab5FC0127A329ab#readContract#F5). -[Internal timelock monitoring](../timelock/README.md) for queueing tx to [Timelock contract on Mainnet](https://etherscan.io/address/0xD8236031d8279d82E615aF2BFab5FC0127A329ab#code). +[Internal timelock monitoring](../timelock/README.md) alerts on transactions queued to the [Mainnet Timelock](https://etherscan.io/address/0xD8236031d8279d82E615aF2BFab5FC0127A329ab#code). + +## stcUSD Monitoring + +The hourly [status.py](./status.py) monitor checks the [stcUSD contract](https://etherscan.io/address/0x88887bE419578051FF9F4eb6C858A951921D8888): + +1. The contract's cUSD balance must cover `totalAssets() + lockedProfit()`. A deficit sends one critical alert; recovery re-arms the monitor. +2. `convertToAssets(1e18)` must not decrease between runs. Any decrease sends a critical alert. + +stcUSD has no withdrawal pause, cooldown, queue, allowlist, or withdrawal-cap override. Its 86,400-second `lockDuration` only vests newly received profit. The duration is set during the one-time initializer and has no setter. Changing it requires a UUPS upgrade, whose sole live upgrade-role holder is the monitored CAP TimelockController. ## Data Monitoring diff --git a/protocols/cap/abi/StakedCap.json b/protocols/cap/abi/StakedCap.json new file mode 100644 index 00000000..5e7f5457 --- /dev/null +++ b/protocols/cap/abi/StakedCap.json @@ -0,0 +1,47 @@ +[ + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "name": "convertToAssets", + "outputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "lockedProfit", + "outputs": [ + { + "internalType": "uint256", + "name": "locked", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalAssets", + "outputs": [ + { + "internalType": "uint256", + "name": "total", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/protocols/cap/status.py b/protocols/cap/status.py new file mode 100644 index 00000000..8abd4de3 --- /dev/null +++ b/protocols/cap/status.py @@ -0,0 +1,157 @@ +"""Monitor stcUSD accounting invariants.""" + +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +from utils.abi import load_abi +from utils.alert import Alert, AlertSeverity, send_alert +from utils.cache import cache_filename, get_last_value_for_key_from_file, write_last_value_to_file +from utils.chains import Chain +from utils.logger import get_logger +from utils.web3_wrapper import ChainManager + +PROTOCOL = "cap" +CUSD = "0xcCcc62962d17b8914c62D74FfB843d73B2a3cccC" +STCUSD = "0x88887bE419578051FF9F4eb6C858A951921D8888" +CUSD_DECIMALS = 18 +ONE_STCUSD = 10**18 + +CACHE_KEY_STCUSD_BACKING_DEFICIT = "CAP_STCUSD_BACKING_DEFICIT" +CACHE_KEY_STCUSD_ASSETS_PER_SHARE = "CAP_STCUSD_ASSETS_PER_SHARE" + +logger = get_logger("cap_status") + + +@dataclass(frozen=True) +class StcUsdState: + """Current backing and exchange-rate state for stcUSD.""" + + cusd_balance: int + total_assets: int + locked_profit: int + assets_per_share: int + + +def _cache_flag(key: str) -> bool: + """Return a cached boolean flag stored as zero or one.""" + return str(get_last_value_for_key_from_file(cache_filename, key)) == "1" + + +def _to_int(value: Any, label: str) -> int: + """Convert an RPC response to int and fail with field context when absent.""" + if value is None: + raise RuntimeError(f"CAP status RPC returned no value for {label}") + return int(value) + + +def _format_cusd(raw_value: int) -> str: + """Format an 18-decimal cUSD amount for alert messages.""" + value = Decimal(raw_value) / Decimal(10**CUSD_DECIMALS) + return f"{value:,.6f}" + + +def check_stcusd_backing(state: StcUsdState) -> None: + """Alert once while stcUSD lacks cUSD for accounted assets and locked profit. + + Args: + state: Current stcUSD accounting values read from Mainnet. + """ + required_backing = state.total_assets + state.locked_profit + has_deficit = state.cusd_balance < required_backing + previous_deficit = _cache_flag(CACHE_KEY_STCUSD_BACKING_DEFICIT) + logger.info( + "stcUSD backing: balance=%s required=%s deficit=%s", + state.cusd_balance, + required_backing, + has_deficit, + ) + + if has_deficit and not previous_deficit: + shortfall = required_backing - state.cusd_balance + message = ( + "*stcUSD BACKING DEFICIT*\n" + "The stcUSD contract does not hold enough cUSD for totalAssets plus locked profit.\n" + f"cUSD held: {_format_cusd(state.cusd_balance)}\n" + f"Required: {_format_cusd(required_backing)}\n" + f"Shortfall: {_format_cusd(shortfall)} cUSD\n" + f"🔗 [stcUSD](https://etherscan.io/address/{STCUSD})" + ) + send_alert(Alert(AlertSeverity.CRITICAL, message, PROTOCOL)) + + if has_deficit != previous_deficit: + write_last_value_to_file(cache_filename, CACHE_KEY_STCUSD_BACKING_DEFICIT, int(has_deficit)) + + +def check_stcusd_assets_per_share(current_assets_per_share: int) -> None: + """Alert when one stcUSD converts to fewer cUSD than on the previous run. + + Args: + current_assets_per_share: Result of ``convertToAssets(1e18)``. + """ + cached_value = get_last_value_for_key_from_file(cache_filename, CACHE_KEY_STCUSD_ASSETS_PER_SHARE) + previous_assets_per_share = int(cached_value) if cached_value else 0 + logger.info( + "stcUSD assets per share: current=%s previous=%s", + current_assets_per_share, + previous_assets_per_share, + ) + + if previous_assets_per_share > 0 and current_assets_per_share < previous_assets_per_share: + decrease = previous_assets_per_share - current_assets_per_share + message = ( + "*stcUSD ASSETS PER SHARE DECREASED*\n" + f"Previous: {_format_cusd(previous_assets_per_share)} cUSD\n" + f"Current: {_format_cusd(current_assets_per_share)} cUSD\n" + f"Decrease: {_format_cusd(decrease)} cUSD per stcUSD\n" + f"🔗 [stcUSD](https://etherscan.io/address/{STCUSD})" + ) + send_alert(Alert(AlertSeverity.CRITICAL, message, PROTOCOL)) + + if current_assets_per_share != previous_assets_per_share: + write_last_value_to_file( + cache_filename, + CACHE_KEY_STCUSD_ASSETS_PER_SHARE, + current_assets_per_share, + ) + + +def load_status(client: Any) -> StcUsdState: + """Load stcUSD status values in one Mainnet RPC batch. + + Args: + client: Mainnet Web3 client supporting batch requests. + + Returns: + Current stcUSD accounting state. + """ + cusd = client.eth.contract(address=CUSD, abi=load_abi("protocols/cap/abi/CToken.json")) + stcusd = client.eth.contract(address=STCUSD, abi=load_abi("protocols/cap/abi/StakedCap.json")) + + with client.batch_requests() as batch: + batch.add(cusd.functions.balanceOf(STCUSD)) + batch.add(stcusd.functions.totalAssets()) + batch.add(stcusd.functions.lockedProfit()) + batch.add(stcusd.functions.convertToAssets(ONE_STCUSD)) + responses = batch.execute() + + return StcUsdState( + cusd_balance=_to_int(responses[0], "stcUSD cUSD balance"), + total_assets=_to_int(responses[1], "stcUSD totalAssets"), + locked_profit=_to_int(responses[2], "stcUSD lockedProfit"), + assets_per_share=_to_int(responses[3], "stcUSD convertToAssets"), + ) + + +def main() -> None: + """Fetch and check CAP status on Mainnet.""" + client = ChainManager.get_client(Chain.MAINNET) + stcusd_state = load_status(client) + check_stcusd_backing(stcusd_state) + check_stcusd_assets_per_share(stcusd_state.assets_per_share) + + +if __name__ == "__main__": + from utils.runner import run_with_alert + + run_with_alert(main, PROTOCOL) diff --git a/tests/test_cap_status.py b/tests/test_cap_status.py new file mode 100644 index 00000000..9ecc6ab7 --- /dev/null +++ b/tests/test_cap_status.py @@ -0,0 +1,151 @@ +from collections.abc import Sequence +from types import SimpleNamespace + +import pytest + +import protocols.cap.status as status +from utils.alert import Alert + + +def stub_cache(monkeypatch: pytest.MonkeyPatch) -> dict[str, str]: + """Replace status cache reads and writes with an in-memory mapping.""" + cache: dict[str, str] = {} + monkeypatch.setattr( + status, + "get_last_value_for_key_from_file", + lambda _filename, key: cache.get(key, 0), + ) + monkeypatch.setattr( + status, + "write_last_value_to_file", + lambda _filename, key, value: cache.__setitem__(key, str(value)), + ) + return cache + + +def make_status_client(responses: Sequence[int | None]) -> tuple[SimpleNamespace, list[object]]: + """Build a batch-capable fake client and capture its submitted calls.""" + added_calls: list[object] = [] + + class Batch: + def __enter__(self) -> "Batch": + return self + + def __exit__(self, *_args: object) -> None: + return None + + def add(self, call: object) -> None: + added_calls.append(call) + + def execute(self) -> list[int | None]: + return list(responses) + + functions = SimpleNamespace( + balanceOf=lambda _owner: "balanceOf", + totalAssets=lambda: "totalAssets", + lockedProfit=lambda: "lockedProfit", + convertToAssets=lambda _shares: "convertToAssets", + ) + contract = SimpleNamespace(functions=functions) + client = SimpleNamespace( + eth=SimpleNamespace(contract=lambda **_kwargs: contract), + batch_requests=Batch, + ) + return client, added_calls + + +def test_stcusd_backing_deficit_sends_one_critical_alert(monkeypatch: pytest.MonkeyPatch) -> None: + alerts: list[Alert] = [] + cache = stub_cache(monkeypatch) + monkeypatch.setattr(status, "send_alert", alerts.append) + state = status.StcUsdState( + cusd_balance=109 * status.ONE_STCUSD, + total_assets=100 * status.ONE_STCUSD, + locked_profit=10 * status.ONE_STCUSD, + assets_per_share=status.ONE_STCUSD, + ) + + status.check_stcusd_backing(state) + status.check_stcusd_backing(state) + + assert len(alerts) == 1 + assert alerts[0].severity == status.AlertSeverity.CRITICAL + assert "Shortfall: 1.000000 cUSD" in alerts[0].message + assert cache[status.CACHE_KEY_STCUSD_BACKING_DEFICIT] == "1" + + +def test_stcusd_backing_recovery_rearms_next_alert(monkeypatch: pytest.MonkeyPatch) -> None: + alerts: list[Alert] = [] + stub_cache(monkeypatch) + monkeypatch.setattr(status, "send_alert", alerts.append) + deficient = status.StcUsdState(109, 100, 10, 1) + healthy = status.StcUsdState(110, 100, 10, 1) + + status.check_stcusd_backing(deficient) + status.check_stcusd_backing(healthy) + status.check_stcusd_backing(deficient) + + assert len(alerts) == 2 + + +def test_stcusd_assets_per_share_decrease_is_critical(monkeypatch: pytest.MonkeyPatch) -> None: + alerts: list[Alert] = [] + cache = stub_cache(monkeypatch) + monkeypatch.setattr(status, "send_alert", alerts.append) + + status.check_stcusd_assets_per_share(1_100_000_000_000_000_000) + status.check_stcusd_assets_per_share(1_090_000_000_000_000_000) + + assert len(alerts) == 1 + assert alerts[0].severity == status.AlertSeverity.CRITICAL + assert "Decrease: 0.010000 cUSD per stcUSD" in alerts[0].message + assert cache[status.CACHE_KEY_STCUSD_ASSETS_PER_SHARE] == "1090000000000000000" + + +def test_stcusd_assets_per_share_increase_does_not_alert(monkeypatch: pytest.MonkeyPatch) -> None: + alerts: list[Alert] = [] + stub_cache(monkeypatch) + monkeypatch.setattr(status, "send_alert", alerts.append) + + status.check_stcusd_assets_per_share(1_000_000_000_000_000_000) + status.check_stcusd_assets_per_share(1_010_000_000_000_000_000) + + assert alerts == [] + + +def test_load_status_batches_all_calls() -> None: + responses = [120, 100, 10, 1_050_000_000_000_000_000] + client, added_calls = make_status_client(responses) + + stcusd_state = status.load_status(client) + + assert added_calls == ["balanceOf", "totalAssets", "lockedProfit", "convertToAssets"] + assert stcusd_state == status.StcUsdState(120, 100, 10, 1_050_000_000_000_000_000) + + +def test_load_status_rejects_missing_numeric_response() -> None: + client, _added_calls = make_status_client([120, None, 10, 1_050_000_000_000_000_000]) + + with pytest.raises(RuntimeError, match="stcUSD totalAssets"): + status.load_status(client) + + +def test_main_checks_all_status_values(monkeypatch: pytest.MonkeyPatch) -> None: + state = status.StcUsdState(120, 100, 10, 1_050_000_000_000_000_000) + observed: list[object] = [] + + monkeypatch.setattr(status.ChainManager, "get_client", lambda _chain: object()) + monkeypatch.setattr(status, "load_status", lambda _client: state) + monkeypatch.setattr(status, "check_stcusd_backing", lambda value: observed.append(("backing", value))) + monkeypatch.setattr( + status, + "check_stcusd_assets_per_share", + lambda value: observed.append(("assets_per_share", value)), + ) + + status.main() + + assert observed == [ + ("backing", state), + ("assets_per_share", state.assets_per_share), + ]