// SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.24; import {IReserveManager} from "./interfaces/IUSDR.sol"; /// @title SolvencyGuard — the invariant (White Paper v3.2 §4) /// @notice Enforces, on every exposure increase: /// /// Stress-MaxLoss(t) + CDP_shortfall_risk(t) <= Stable Reserve(t) /// /// @dev IMMUTABLE CORE. No owner can change the enforcement logic. Market adapters /// (the live RAIN LMSR/CLOB contracts) are registered once by the deployer and /// then the adapter set is finalized. Correlated markets share a groupId; group /// exposures are summed (a conservative upper bound — netting within a group can /// only reduce the true worst case, so summing never under-reserves; WP §4.1). /// Decreasing exposure ALWAYS succeeds so de-risking is never trapped. contract SolvencyGuard { error NotAdapter(); error NotDeployer(); error NotLiquidationEngine(); error AlreadyFinalized(); error InvariantBreached(uint256 stressExposure, uint256 reserve); address public immutable deployer; IReserveManager public reserveManager; address public liquidationEngine; bool public finalized; mapping(address => bool) public isAdapter; // per-market exposure records struct Exposure { bytes32 groupId; uint256 normal; // WAD uint256 stress; // WAD } mapping(bytes32 => Exposure) public exposures; // marketId => exposure uint256 public totalNormalExposure; // Σ normal MaxLoss (WAD) uint256 public totalStressExposure; // Σ stress MaxLoss (WAD) mapping(bytes32 => uint256) public groupStressExposure; // per correlated group (dashboard) uint256 public cdpShortfallRisk; // WAD, reported by LiquidationEngine (WP §4.6) event ExposureReported(bytes32 indexed marketId, bytes32 indexed groupId, uint256 normal, uint256 stress); event CdpShortfallReported(uint256 shortfall); event AdapterAdded(address indexed adapter); event Finalized(); constructor() { deployer = msg.sender; } // -- one-time wiring --------------------------------------------------- function setup(address _reserveManager, address _liquidationEngine) external { if (msg.sender != deployer) revert NotDeployer(); if (finalized) revert AlreadyFinalized(); reserveManager = IReserveManager(_reserveManager); liquidationEngine = _liquidationEngine; } function addAdapter(address adapter) external { if (msg.sender != deployer) revert NotDeployer(); if (finalized) revert AlreadyFinalized(); isAdapter[adapter] = true; emit AdapterAdded(adapter); } function finalize() external { if (msg.sender != deployer) revert NotDeployer(); finalized = true; emit Finalized(); } // -- the invariant ------------------------------------------------------- /// @notice Market adapters report their current worst-case net exposure. /// @dev Reverts iff the report INCREASES total stress exposure above the reserve. /// "The system simply refuses to extend depth that would breach the floor" (WP §4.3). function reportExposure(bytes32 marketId, bytes32 groupId, uint256 normalWad, uint256 stressWad) external { if (!isAdapter[msg.sender]) revert NotAdapter(); Exposure storage e = exposures[marketId]; // update aggregates totalNormalExposure = totalNormalExposure - e.normal + normalWad; uint256 newStressTotal = totalStressExposure - e.stress + stressWad; groupStressExposure[e.groupId] -= e.stress; groupStressExposure[groupId] += stressWad; bool increasing = stressWad > e.stress; e.groupId = groupId; e.normal = normalWad; e.stress = stressWad; totalStressExposure = newStressTotal; if (increasing) _checkInvariant(); emit ExposureReported(marketId, groupId, normalWad, stressWad); } /// @notice LiquidationEngine prices in-flight liquidation risk across the full /// circuit-breaker window at stress drawdown (WP §4.6, §9). function reportCdpShortfall(uint256 shortfallWad) external { if (msg.sender != liquidationEngine) revert NotLiquidationEngine(); cdpShortfallRisk = shortfallWad; // Note: shortfall increase does NOT revert — it reflects reality that already // happened; instead it consumes headroom so no NEW exposure can be opened. emit CdpShortfallReported(shortfallWad); } function _checkInvariant() internal view { uint256 stress = totalStressExposure + cdpShortfallRisk; uint256 reserve = reserveManager.reserveBalance(); if (stress > reserve) revert InvariantBreached(stress, reserve); } // -- dashboard views (WP: "radical transparency") ------------------------ function maxLoss() external view returns (uint256) { return totalNormalExposure + cdpShortfallRisk; } function stressMaxLoss() public view returns (uint256) { return totalStressExposure + cdpShortfallRisk; } /// @notice Free headroom before the floor: how much new stress exposure may open. function headroom() external view returns (uint256) { uint256 reserve = reserveManager.reserveBalance(); uint256 stress = stressMaxLoss(); return reserve > stress ? reserve - stress : 0; } function isSolvent() external view returns (bool) { return stressMaxLoss() <= reserveManager.reserveBalance(); } }