// 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 {ISolvencyGuard} from "./interfaces/IUSDR.sol"; import {SettlementEscrow} from "./SettlementEscrow.sol"; /// @title ReserveManager — the exogenous stable reserve (White Paper v3.2 §4.2) /// @notice Holds USDT + USDC. Splits the reserve: the Settlement Escrow is funded /// FIRST to exactly Stress-MaxLoss(t); everything left is free slack that /// backs best-effort redemption (PSM exit, WP §4.4). Volatile collateral is /// never held here and never counted. /// @dev Escrow rebalancing is permissionless so keepers/anyone can keep it current. contract ReserveManager is ReentrancyGuard { using SafeERC20 for IERC20; error NotDeployer(); error NotPSM(); error AlreadyFinalized(); error UnsupportedStable(); error ExceedsSlack(uint256 requested, uint256 available); error InsufficientStableLiquidity(); address public immutable deployer; IERC20 public immutable usdt; IERC20 public immutable usdc; uint256 private constant SCALE = 1e12; // 6 dec -> 18 dec address public psm; SettlementEscrow public escrow; ISolvencyGuard public guard; bool public finalized; event StableDeposited(address indexed stable, uint256 amount6, address indexed from); event SlackRedeemed(address indexed stable, uint256 amountWad, address indexed to); event EscrowRebalanced(uint256 target, uint256 topUp, uint256 refund); constructor(address _usdt, address _usdc) { deployer = msg.sender; usdt = IERC20(_usdt); usdc = IERC20(_usdc); } function setup(address _psm, address _escrow, address _guard) external { if (msg.sender != deployer) revert NotDeployer(); if (finalized) revert AlreadyFinalized(); psm = _psm; escrow = SettlementEscrow(_escrow); guard = ISolvencyGuard(_guard); finalized = true; } // -- views ---------------------------------------------------------------- /// @notice Stable Reserve(t) in WAD = main reserve + escrow (the invariant compares /// against the WHOLE stable reserve; the escrow split is internal accounting). function reserveBalance() public view returns (uint256) { uint256 here = usdt.balanceOf(address(this)) + usdc.balanceOf(address(this)); uint256 inEscrow = usdt.balanceOf(address(escrow)) + usdc.balanceOf(address(escrow)); return (here + inEscrow) * SCALE; } /// @notice Free slack = Reserve − required escrow (WP §4.2 "reserve accounting"). function slack() public view returns (uint256) { uint256 required = guard.stressMaxLoss(); uint256 reserve = reserveBalance(); return reserve > required ? reserve - required : 0; } // -- flows ------------------------------------------------------------------ /// @notice PSM deposits stables here on mint (1:1 onramp). function depositStable(address stable, uint256 amount6, address from) external nonReentrant { if (msg.sender != psm) revert NotPSM(); if (stable != address(usdt) && stable != address(usdc)) revert UnsupportedStable(); IERC20(stable).safeTransferFrom(from, address(this), amount6); emit StableDeposited(stable, amount6, from); } /// @notice Best-effort redemption from slack ONLY (WP §4.4). PSM-gated. /// Reverts beyond slack — "holders use the secondary market or wait". function redeemFromSlack(address stable, uint256 amountWad, address to) external nonReentrant { if (msg.sender != psm) revert NotPSM(); if (stable != address(usdt) && stable != address(usdc)) revert UnsupportedStable(); uint256 s = slack(); if (amountWad > s) revert ExceedsSlack(amountWad, s); uint256 amount6 = amountWad / SCALE; // main-reserve liquidity only; escrow is untouchable by redemption if (IERC20(stable).balanceOf(address(this)) < amount6) revert InsufficientStableLiquidity(); IERC20(stable).safeTransfer(to, amount6); emit SlackRedeemed(stable, amountWad, to); } /// @notice Permissionless: keep escrow == Stress-MaxLoss(t) ("topped up dynamically /// as exposure changes", WP §4.2). Excess flows back to the main reserve. function rebalanceEscrow() external nonReentrant { uint256 targetWad = guard.stressMaxLoss(); uint256 target6 = (targetWad + SCALE - 1) / SCALE; // round escrow UP (protocol-safe) uint256 current6 = usdt.balanceOf(address(escrow)) + usdc.balanceOf(address(escrow)); if (current6 < target6) { uint256 need = target6 - current6; // top up with USDT first, then USDC uint256 t = usdt.balanceOf(address(this)); uint256 sendT = need > t ? t : need; if (sendT > 0) usdt.safeTransfer(address(escrow), sendT); uint256 rem = need - sendT; if (rem > 0) { uint256 c = usdc.balanceOf(address(this)); uint256 sendC = rem > c ? c : rem; if (sendC > 0) usdc.safeTransfer(address(escrow), sendC); // NOTE: if reserve can't fully fund the escrow the invariant was already // breached upstream; SolvencyGuard prevents new exposure in that state. } emit EscrowRebalanced(target6, need, 0); } else if (current6 > target6) { uint256 refund = current6 - target6; escrow.refundToReserve(refund); emit EscrowRebalanced(target6, 0, refund); } } }