// SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.24; import {Test} from "forge-std/Test.sol"; import {USDR} from "../src/USDR.sol"; import {ReserveManager} from "../src/ReserveManager.sol"; import {SettlementEscrow} from "../src/SettlementEscrow.sol"; import {SolvencyGuard} from "../src/SolvencyGuard.sol"; import {PSM} from "../src/PSM.sol"; import {CDPVault} from "../src/CDPVault.sol"; import {OracleHub} from "../src/OracleHub.sol"; import {LiquidationEngine} from "../src/LiquidationEngine.sol"; import {SurplusBuffer} from "../src/SurplusBuffer.sol"; import {MockERC20, MockAggregator, MockTwap} from "./mocks/Mocks.sol"; contract USDRProtocolTest is Test { USDR usdr; ReserveManager reserve; SettlementEscrow escrow; SolvencyGuard guard; PSM psm; CDPVault vault; OracleHub oracle; LiquidationEngine engine; SurplusBuffer buffer; MockERC20 usdt; MockERC20 usdc; MockERC20 rain; MockERC20 weth; MockTwap rainTwap; MockAggregator ethFeed; address timelock = makeAddr("timelock"); address guardian = makeAddr("guardian"); address alice = makeAddr("alice"); address bob = makeAddr("bob"); address keeper = makeAddr("keeper"); address marketAdapter = makeAddr("marketAdapter"); address burnSink = makeAddr("burnSink"); bytes32 constant ARB_STACK = keccak256("ARBITRUM_STACK"); bytes32 constant MKT1 = keccak256("market-1"); bytes32 constant GRP1 = keccak256("group-1"); function setUp() public { usdt = new MockERC20("Tether", "USDT", 6); usdc = new MockERC20("Circle", "USDC", 6); rain = new MockERC20("Rain", "RAIN", 18); weth = new MockERC20("WETH", "WETH", 18); // core deploy usdr = new USDR(); escrow = new SettlementEscrow(address(usdt), address(usdc)); reserve = new ReserveManager(address(usdt), address(usdc)); guard = new SolvencyGuard(); oracle = new OracleHub(timelock); psm = new PSM(address(usdr), address(reserve), guardian); vault = new CDPVault(address(usdr), address(oracle)); engine = new LiquidationEngine(address(vault), address(usdr), address(oracle)); buffer = new SurplusBuffer(address(usdt), address(rain)); // wiring usdr.addMinter(address(psm)); usdr.addMinter(address(vault)); usdr.addMinter(address(engine)); // engine burns keeper USDR usdr.finalizeMinters(); reserve.setup(address(psm), address(escrow), address(guard)); escrow.setup(address(reserve)); escrow.addAdapter(marketAdapter); escrow.finalize(); guard.setup(address(reserve), address(engine)); guard.addAdapter(marketAdapter); guard.finalize(); vault.setup(timelock, address(engine)); engine.setup(address(guard), address(buffer), timelock); buffer.setup(timelock, address(engine), burnSink); // oracle config rainTwap = new MockTwap(1e18); // RAIN = $1 ethFeed = new MockAggregator(2000e8, 8); // ETH = $2000 vm.startPrank(timelock); oracle.configureAsset( address(rain), OracleHub.AssetConfig(OracleHub.SourceKind.Twap, address(rainTwap), 0, 3000, 1 hours, 5000) ); oracle.configureAsset( address(weth), OracleHub.AssetConfig(OracleHub.SourceKind.Chainlink, address(ethFeed), 1 days, 3000, 1 hours, 5000) ); // collateral config: RAIN 400%, ETH 140% (WP §12) vault.configureCollateral( address(rain), CDPVault.CollateralConfig(true, 40000, 1_000_000e18, 1300, ARB_STACK, 18) ); vault.configureCollateral( address(weth), CDPVault.CollateralConfig(true, 14000, 10_000_000e18, 1300, bytes32(0), 18) ); vault.setGroupCeiling(ARB_STACK, 1_500_000e18); vm.stopPrank(); // fund users usdt.mint(alice, 10_000_000e6); usdc.mint(alice, 10_000_000e6); usdt.mint(bob, 10_000_000e6); rain.mint(alice, 100_000_000e18); weth.mint(alice, 100_000e18); } // --------------------------------------------------------------- // USDR token — no admin-mint (WP §10) // --------------------------------------------------------------- function test_noAdminMint_afterFinalize() public { vm.expectRevert(USDR.AlreadyFinalized.selector); usdr.addMinter(address(this)); vm.expectRevert(USDR.NotMinter.selector); usdr.mint(address(this), 1e18); } function test_onlyMintersCanMintBurn() public { vm.prank(alice); vm.expectRevert(USDR.NotMinter.selector); usdr.mint(alice, 1e18); } // --------------------------------------------------------------- // PSM — 1:1 onramp, best-effort exit (WP §3, §4.4, S10) // --------------------------------------------------------------- function test_psmMint1to1() public { vm.startPrank(alice); usdt.approve(address(reserve), 1000e6); psm.mint(address(usdt), 1000e6); vm.stopPrank(); assertEq(usdr.balanceOf(alice), 1000e18); assertEq(reserve.reserveBalance(), 1000e18); } function test_psmRedeem_fromSlack() public { vm.startPrank(alice); usdt.approve(address(reserve), 1000e6); psm.mint(address(usdt), 1000e6); psm.redeem(address(usdt), 400e18); vm.stopPrank(); assertEq(usdr.balanceOf(alice), 600e18); assertEq(usdt.balanceOf(alice), 10_000_000e6 - 600e6); } function test_psmRedeem_revertsBeyondSlack_S10() public { // alice mints 1000 USDR; market opens exposure consuming 800 of reserve vm.startPrank(alice); usdt.approve(address(reserve), 1000e6); psm.mint(address(usdt), 1000e6); vm.stopPrank(); vm.prank(marketAdapter); guard.reportExposure(MKT1, GRP1, 500e18, 800e18); // slack = 1000 - 800 = 200. Redeeming 300 must revert (escrow priority). vm.startPrank(alice); vm.expectRevert(); psm.redeem(address(usdt), 300e18); // 200 works psm.redeem(address(usdt), 200e18); vm.stopPrank(); } // --------------------------------------------------------------- // SolvencyGuard — the invariant (WP §4) // --------------------------------------------------------------- function test_invariant_refusesDepthBeyondReserve() public { _fundReserve(1000e6); // $1000 reserve vm.startPrank(marketAdapter); guard.reportExposure(MKT1, GRP1, 600e18, 900e18); // ok: 900 <= 1000 vm.expectRevert(); guard.reportExposure(MKT1, GRP1, 800e18, 1100e18); // 1100 > 1000 → refuse depth vm.stopPrank(); assertTrue(guard.isSolvent()); } function test_invariant_decreaseAlwaysAllowed() public { _fundReserve(1000e6); vm.startPrank(marketAdapter); guard.reportExposure(MKT1, GRP1, 600e18, 900e18); guard.reportExposure(MKT1, GRP1, 100e18, 200e18); // de-risking always passes vm.stopPrank(); assertEq(guard.stressMaxLoss(), 200e18); } function test_escrowRebalance_tracksStressMaxLoss() public { _fundReserve(1000e6); vm.prank(marketAdapter); guard.reportExposure(MKT1, GRP1, 400e18, 700e18); reserve.rebalanceEscrow(); assertEq(escrow.escrowBalance(), 700e18); assertEq(reserve.slack(), 300e18); // exposure drops → excess flows back vm.prank(marketAdapter); guard.reportExposure(MKT1, GRP1, 100e18, 100e18); reserve.rebalanceEscrow(); assertEq(escrow.escrowBalance(), 100e18); assertEq(reserve.slack(), 900e18); } // --------------------------------------------------------------- // Settlement Escrow — traders only (WP §4.2, §4.4) // --------------------------------------------------------------- function test_escrow_paysWinningTrader() public { _fundReserve(1000e6); vm.prank(marketAdapter); guard.reportExposure(MKT1, GRP1, 500e18, 500e18); reserve.rebalanceEscrow(); vm.prank(marketAdapter); escrow.paySettlement(bob, 500e18); assertEq(usdt.balanceOf(bob), 10_000_000e6 + 500e6); } function test_escrow_inaccessibleToNonAdapters() public { _fundReserve(1000e6); vm.prank(alice); vm.expectRevert(SettlementEscrow.NotAdapter.selector); escrow.paySettlement(alice, 1e18); } // --------------------------------------------------------------- // CDP — issuance, ceilings, no-reflexivity (WP §3, §5.4, §6, S3) // --------------------------------------------------------------- function test_cdp_rain400_borrowAndClose_returnsCollateralOnly() public { vm.startPrank(alice); uint256 id = vault.open(address(rain)); rain.approve(address(vault), 4000e18); vault.depositCollateral(id, 4000e18); // $4000 RAIN vault.borrow(id, 1000e18); // exactly 400% assertEq(usdr.balanceOf(alice), 1000e18); uint256 rainBefore = rain.balanceOf(alice); uint256 usdtBefore = usdt.balanceOf(alice); vault.close(id); vm.stopPrank(); assertEq(rain.balanceOf(alice), rainBefore + 4000e18); // collateral back assertEq(usdt.balanceOf(alice), usdtBefore); // NEVER stables (WP §6) assertEq(usdr.balanceOf(alice), 0); } function test_cdp_borrowBeyondRatio_reverts() public { vm.startPrank(alice); uint256 id = vault.open(address(rain)); rain.approve(address(vault), 4000e18); vault.depositCollateral(id, 4000e18); vm.expectRevert(CDPVault.Unhealthy.selector); vault.borrow(id, 1001e18); vm.stopPrank(); } function test_cdp_debtCeiling_S3_attackerCannotOvermint() public { vm.startPrank(alice); uint256 id = vault.open(address(rain)); rain.approve(address(vault), 100_000_000e18); vault.depositCollateral(id, 100_000_000e18); // $100M RAIN — way over ceiling worth vm.expectRevert(CDPVault.CeilingExceeded.selector); vault.borrow(id, 1_000_001e18); // ceiling is 1M vault.borrow(id, 1_000_000e18); // exactly at ceiling: ok vm.stopPrank(); } function test_cdp_ceilingHysteresis_existingDebtUntouched() public { vm.startPrank(alice); uint256 id = vault.open(address(rain)); rain.approve(address(vault), 4_000_000e18); vault.depositCollateral(id, 4_000_000e18); vault.borrow(id, 900_000e18); vm.stopPrank(); // timelock lowers ceiling below existing debt — blocks only NEW mints vm.prank(timelock); vault.configureCollateral( address(rain), CDPVault.CollateralConfig(true, 40000, 500_000e18, 1300, ARB_STACK, 18) ); vm.startPrank(alice); vm.expectRevert(CDPVault.CeilingExceeded.selector); vault.borrow(id, 1e18); // new mint blocked vault.repay(id, 100_000e18); // existing position operates normally vm.stopPrank(); } function test_cdp_breakerBlocksNewBorrows() public { vm.startPrank(alice); uint256 id = vault.open(address(rain)); rain.approve(address(vault), 8000e18); vault.depositCollateral(id, 8000e18); vm.stopPrank(); // trip breaker: RAIN moves -40% fast oracle.getPriceAndUpdate(address(rain)); rainTwap.setPrice(0.6e18); oracle.getPriceAndUpdate(address(rain)); vm.prank(alice); vm.expectRevert(CDPVault.BreakerActive.selector); vault.borrow(id, 100e18); } // --------------------------------------------------------------- // Liquidation — keeper auction, rate-limits, shortfall (WP §7, §9, S1) // --------------------------------------------------------------- function test_liquidation_flow_S1() public { // alice opens RAIN CDP at the edge vm.startPrank(alice); uint256 id = vault.open(address(rain)); rain.approve(address(vault), 4000e18); vault.depositCollateral(id, 4000e18); vault.borrow(id, 1000e18); vm.stopPrank(); // RAIN drops 70% → vault unhealthy; stress value (−50% on top) = 4000*0.15=600 < debt rainTwap.setPrice(0.3e18); assertFalse(vault.isHealthy(id)); // keeper needs USDR — bob mints via PSM and transfers vm.startPrank(bob); usdt.approve(address(reserve), 2000e6); psm.mint(address(usdt), 2000e6); usdr.transfer(keeper, 1000e18); vm.stopPrank(); engine.kick(id); assertGt(guard.cdpShortfallRisk(), 0); // stress shortfall priced in (WP §4.6) vm.prank(keeper); engine.take(id, 1000e18); assertEq(vault.vaultDebt(id), 0); assertGt(rain.balanceOf(keeper), 0); // keeper got discounted collateral assertEq(guard.cdpShortfallRisk(), 0); // auction closed, shortfall released } function test_liquidation_healthyVault_cannotKick() public { vm.startPrank(alice); uint256 id = vault.open(address(rain)); rain.approve(address(vault), 4000e18); vault.depositCollateral(id, 4000e18); vault.borrow(id, 500e18); vm.stopPrank(); vm.expectRevert(LiquidationEngine.VaultHealthy.selector); engine.kick(id); } // --------------------------------------------------------------- // Fuzz: invariant holds across arbitrary exposure sequences // --------------------------------------------------------------- function testFuzz_invariantNeverBreached(uint96 reserveAmt, uint96 exp1, uint96 exp2) public { reserveAmt = uint96(bound(reserveAmt, 1e6, 1_000_000e6)); _fundReserve(reserveAmt); uint256 reserveWad = uint256(reserveAmt) * 1e12; vm.startPrank(marketAdapter); uint256 e1 = bound(exp1, 0, 2 * reserveWad); if (e1 > reserveWad) { vm.expectRevert(); guard.reportExposure(MKT1, GRP1, e1, e1); } else { guard.reportExposure(MKT1, GRP1, e1, e1); } uint256 e2 = bound(exp2, 0, 2 * reserveWad); uint256 current = guard.totalStressExposure(); if (e2 > current && e2 > reserveWad) { vm.expectRevert(); guard.reportExposure(MKT1, GRP1, e2, e2); } else { guard.reportExposure(MKT1, GRP1, e2, e2); } vm.stopPrank(); assertLe(guard.stressMaxLoss(), reserveWad); // the invariant, always } // --------------------------------------------------------------- // helpers // --------------------------------------------------------------- function _fundReserve(uint256 amount6) internal { vm.startPrank(alice); usdt.approve(address(reserve), amount6); psm.mint(address(usdt), amount6); vm.stopPrank(); } }