// SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.24; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IUSDR, IOracleHub} from "./interfaces/IUSDR.sol"; /// @title CDPVault — over-collateralized volatile issuance (White Paper v3.2 §3, §5.4, §6) /// @notice Users lock volatile collateral (ETH, wBTC, ARB, RAIN) and mint USDR against it. /// /// - Launch ratios: ETH/wBTC 140%, ARB/RAIN 400% (correlated group shares a ceiling). /// - Liquidity-derived debt ceilings with HYSTERESIS: lowering a ceiling blocks only /// NEW mints; existing positions are never stranded (WP §5.4). /// - NO-REFLEXIVITY: closing/withdrawing returns COLLATERAL ONLY. There is no function /// in this contract that pays USDT/USDC. The protocol never sells collateral to /// honor redemption (WP §6). /// - Rounding is protocol-favoring: debt rounds up, collateral-out rounds down. contract CDPVault is ReentrancyGuard { using SafeERC20 for IERC20; error NotTimelock(); error NotLiquidationEngine(); error NotDeployer(); error AlreadyFinalized(); error UnsupportedCollateral(); error Unhealthy(); error CeilingExceeded(); error GroupCeilingExceeded(); error BreakerActive(); error NotVaultOwner(); error DebtRemaining(); error ZeroAmount(); struct CollateralConfig { bool enabled; uint256 ratioBps; // e.g. 14000 = 140%, 40000 = 400% uint256 debtCeiling; // WAD cap on total USDR minted against this asset uint256 liquidationPenaltyBps; // keeper discount, e.g. 1300 = 13% bytes32 correlatedGroup; // e.g. keccak256("ARBITRUM_STACK") for ARB+RAIN uint8 decimals; // collateral token decimals } struct Vault { address owner; address collateral; uint256 collateralAmount; // native decimals uint256 debt; // WAD USDR } uint256 private constant BPS = 10_000; address public immutable deployer; IUSDR public immutable usdr; IOracleHub public immutable oracle; address public timelock; address public liquidationEngine; bool public finalized; mapping(address => CollateralConfig) public configs; mapping(address => uint256) public totalDebtPerCollateral; // WAD mapping(bytes32 => uint256) public groupDebt; // WAD mapping(bytes32 => uint256) public groupCeiling; // WAD uint256 public nextVaultId = 1; mapping(uint256 => Vault) public vaults; event VaultOpened(uint256 indexed vaultId, address indexed owner, address indexed collateral); event CollateralDeposited(uint256 indexed vaultId, uint256 amount); event CollateralWithdrawn(uint256 indexed vaultId, uint256 amount); event Borrowed(uint256 indexed vaultId, uint256 amountWad); event Repaid(uint256 indexed vaultId, uint256 amountWad); event VaultClosed(uint256 indexed vaultId); event CollateralConfigured(address indexed collateral, uint256 ratioBps, uint256 ceiling); event GroupCeilingSet(bytes32 indexed group, uint256 ceiling); event Liquidated(uint256 indexed vaultId, uint256 debtCleared, uint256 collateralSeized, address keeper); constructor(address _usdr, address _oracle) { deployer = msg.sender; usdr = IUSDR(_usdr); oracle = IOracleHub(_oracle); } function setup(address _timelock, address _liquidationEngine) external { if (msg.sender != deployer) revert NotDeployer(); if (finalized) revert AlreadyFinalized(); timelock = _timelock; liquidationEngine = _liquidationEngine; finalized = true; } // -- risk parameters: timelock-governed (WP §12) ------------------------------ function configureCollateral(address collateral, CollateralConfig calldata cfg) external { if (msg.sender != timelock) revert NotTimelock(); // HYSTERESIS by construction: changing config (incl. lowering the ceiling) // affects only future borrows; existing debt and health checks on repayment // paths are unaffected (WP §5.4). configs[collateral] = cfg; emit CollateralConfigured(collateral, cfg.ratioBps, cfg.debtCeiling); } function setGroupCeiling(bytes32 group, uint256 ceiling) external { if (msg.sender != timelock) revert NotTimelock(); groupCeiling[group] = ceiling; emit GroupCeilingSet(group, ceiling); } // -- user flows ----------------------------------------------------------------- function open(address collateral) external returns (uint256 vaultId) { if (!configs[collateral].enabled) revert UnsupportedCollateral(); vaultId = nextVaultId++; vaults[vaultId] = Vault({owner: msg.sender, collateral: collateral, collateralAmount: 0, debt: 0}); emit VaultOpened(vaultId, msg.sender, collateral); } function depositCollateral(uint256 vaultId, uint256 amount) external nonReentrant { Vault storage v = _ownedVault(vaultId); if (amount == 0) revert ZeroAmount(); IERC20(v.collateral).safeTransferFrom(msg.sender, address(this), amount); v.collateralAmount += amount; emit CollateralDeposited(vaultId, amount); } function withdrawCollateral(uint256 vaultId, uint256 amount) external nonReentrant { Vault storage v = _ownedVault(vaultId); if (amount == 0 || amount > v.collateralAmount) revert ZeroAmount(); v.collateralAmount -= amount; if (!_healthy(v, false)) revert Unhealthy(); IERC20(v.collateral).safeTransfer(msg.sender, amount); // collateral only — never stables emit CollateralWithdrawn(vaultId, amount); } /// @notice Mint USDR against the vault. Checked against: health ratio, per-asset /// ceiling, correlated-group ceiling, and the asset's circuit breaker. function borrow(uint256 vaultId, uint256 amountWad) external nonReentrant { Vault storage v = _ownedVault(vaultId); if (amountWad == 0) revert ZeroAmount(); CollateralConfig memory cfg = configs[v.collateral]; (, bool breaker) = oracle.getPrice(v.collateral); if (breaker) revert BreakerActive(); // no new mints against a tripped asset (WP §9) v.debt += amountWad; uint256 newAssetDebt = totalDebtPerCollateral[v.collateral] + amountWad; if (newAssetDebt > cfg.debtCeiling) revert CeilingExceeded(); totalDebtPerCollateral[v.collateral] = newAssetDebt; if (cfg.correlatedGroup != bytes32(0)) { uint256 newGroupDebt = groupDebt[cfg.correlatedGroup] + amountWad; if (newGroupDebt > groupCeiling[cfg.correlatedGroup]) revert GroupCeilingExceeded(); groupDebt[cfg.correlatedGroup] = newGroupDebt; } if (!_healthy(v, false)) revert Unhealthy(); usdr.mint(msg.sender, amountWad); emit Borrowed(vaultId, amountWad); } function repay(uint256 vaultId, uint256 amountWad) public nonReentrant { Vault storage v = vaults[vaultId]; // anyone may repay if (amountWad == 0) revert ZeroAmount(); if (amountWad > v.debt) amountWad = v.debt; usdr.burn(msg.sender, amountWad); _reduceDebt(v, amountWad); emit Repaid(vaultId, amountWad); } /// @notice Close: repay all debt, receive ALL collateral back — never USDT (WP §6). function close(uint256 vaultId) external nonReentrant { Vault storage v = _ownedVault(vaultId); if (v.debt > 0) { usdr.burn(msg.sender, v.debt); _reduceDebt(v, v.debt); } uint256 amount = v.collateralAmount; address collateral = v.collateral; v.collateralAmount = 0; delete vaults[vaultId]; if (amount > 0) IERC20(collateral).safeTransfer(msg.sender, amount); emit VaultClosed(vaultId); } // -- liquidation hook (engine-gated) --------------------------------------------- /// @notice Seize collateral against cleared debt. Only the LiquidationEngine, /// which burns the keeper's USDR and enforces auction pricing + rate limits. function seize(uint256 vaultId, uint256 debtClearedWad, uint256 collateralOut, address keeper) external nonReentrant { if (msg.sender != liquidationEngine) revert NotLiquidationEngine(); Vault storage v = vaults[vaultId]; if (debtClearedWad > v.debt) debtClearedWad = v.debt; if (collateralOut > v.collateralAmount) collateralOut = v.collateralAmount; _reduceDebt(v, debtClearedWad); v.collateralAmount -= collateralOut; IERC20(v.collateral).safeTransfer(keeper, collateralOut); emit Liquidated(vaultId, debtClearedWad, collateralOut, keeper); } /// @notice Engine-gated bad-debt write-off after collateral is exhausted (WP §7). function writeOffBadDebt(uint256 vaultId) external returns (uint256 badDebt) { if (msg.sender != liquidationEngine) revert NotLiquidationEngine(); Vault storage v = vaults[vaultId]; require(v.collateralAmount == 0, "collateral remains"); badDebt = v.debt; _reduceDebt(v, badDebt); } // -- views --------------------------------------------------------------------------- function isHealthy(uint256 vaultId) external view returns (bool) { return _healthy(vaults[vaultId], false); } /// @notice Health under stress prices — used by the LiquidationEngine to size /// CDP shortfall risk (WP §4.6). function isHealthyStress(uint256 vaultId) external view returns (bool) { return _healthy(vaults[vaultId], true); } function collateralValueWad(uint256 vaultId, bool stress) public view returns (uint256) { Vault memory v = vaults[vaultId]; return _collateralValue(v, stress); } function vaultDebt(uint256 vaultId) external view returns (uint256) { return vaults[vaultId].debt; } // -- internals ------------------------------------------------------------------------- function _ownedVault(uint256 vaultId) internal view returns (Vault storage v) { v = vaults[vaultId]; if (v.owner != msg.sender) revert NotVaultOwner(); } function _reduceDebt(Vault storage v, uint256 amountWad) internal { v.debt -= amountWad; totalDebtPerCollateral[v.collateral] -= amountWad; bytes32 g = configs[v.collateral].correlatedGroup; if (g != bytes32(0)) groupDebt[g] -= amountWad; } function _collateralValue(Vault memory v, bool stress) internal view returns (uint256) { uint256 price; if (stress) { price = oracle.stressPrice(v.collateral); } else { (price,) = oracle.getPrice(v.collateral); } uint8 dec = configs[v.collateral].decimals; return v.collateralAmount * price / (10 ** dec); } function _healthy(Vault memory v, bool stress) internal view returns (bool) { if (v.debt == 0) return true; uint256 value = _collateralValue(v, stress); // value * BPS >= debt * ratioBps return value * BPS >= v.debt * configs[v.collateral].ratioBps; } }