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
1 change: 1 addition & 0 deletions automation/jobs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
7 changes: 6 additions & 1 deletion monitoring.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 11 additions & 2 deletions protocols/cap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
47 changes: 47 additions & 0 deletions protocols/cap/abi/StakedCap.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
157 changes: 157 additions & 0 deletions protocols/cap/status.py
Original file line number Diff line number Diff line change
@@ -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)
Loading