// SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.24; import {Test} from "forge-std/Test.sol"; import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {MarketRegistry} from "../src/MarketRegistry.sol"; import {RiskVault} from "../src/RiskVault.sol"; import {FeeSplitter} from "../src/FeeSplitter.sol"; import {RiskEngine, IRandomnessProvider} from "../src/RiskEngine.sol"; contract MockUSDC is ERC20 { constructor() ERC20("USD Coin", "USDC") {} function decimals() public pure override returns (uint8) { return 6; } function mint(address to, uint256 amt) external { _mint(to, amt); } } /// Deterministic randomness mock: engine requests, test fulfills manually. contract MockRandomness is IRandomnessProvider { uint256 public nextId = 1; RiskEngine public engine; function setEngine(RiskEngine e) external { engine = e; } function requestRandomness() external returns (uint256 id) { id = nextId++; } function fulfill(uint256 requestId, uint256 word) external { engine.fulfillRandomness(requestId, word); } } contract ProtocolTest is Test { MockUSDC usdc; MarketRegistry registry; RiskVault vault; FeeSplitter splitter; RiskEngine engine; MockRandomness rng; address gov = makeAddr("gov"); address lp = makeAddr("lp"); address trader = makeAddr("trader"); address brand = makeAddr("brand"); address burnSink = makeAddr("burnSink"); address rebatePool = makeAddr("rebatePool"); address anchorReserve = makeAddr("anchorReserve"); uint256 constant TVL_CAP = 300_000_000e6; uint256 marketEvenMoney; // ~binary even-money at 1.25% spread uint256 marketLongShot36; // single outcome 1/37 at 35.44x function setUp() public { usdc = new MockUSDC(); registry = new MarketRegistry(gov); vault = new RiskVault(IERC20(address(usdc)), gov, TVL_CAP); splitter = new FeeSplitter(IERC20(address(usdc)), gov, burnSink, rebatePool, anchorReserve); rng = new MockRandomness(); engine = new RiskEngine(registry, vault, splitter, rng, gov); rng.setEngine(engine); vm.startPrank(gov); vault.setEngine(address(engine)); splitter.setEngine(address(engine)); vm.stopPrank(); // LP seeds the vault with $30M usdc.mint(lp, 30_000_000e6); vm.startPrank(lp); usdc.approve(address(vault), type(uint256).max); vault.deposit(30_000_000e6, lp); vm.stopPrank(); // trader funds usdc.mint(trader, 10_000_000e6); vm.prank(trader); usdc.approve(address(engine), type(uint256).max); // Markets. Even-money: p=0.5 win pays 1.975x -> EV=0.9875 (1.25% spread) uint32[] memory p2 = new uint32[](2); uint64[] memory x2 = new uint64[](2); p2[0] = 500_000; x2[0] = 1_975_000; p2[1] = 500_000; x2[1] = 0; marketEvenMoney = registry.listMarket(p2, x2); // 1/37 long shot paying 36.5375x -> EV = 36.5375/37 = 0.98750 (1.25%) uint32[] memory p37 = new uint32[](2); uint64[] memory x37 = new uint64[](2); p37[0] = 27_027; x37[0] = 36_537_500; // 1/37 ≈ 27027 ppm p37[1] = 972_973; x37[1] = 0; marketLongShot36 = registry.listMarket(p37, x37); } // ------------------------------------------------------------ registry function test_ListingRejectsSpreadViolation() public { // EV = 1.0 (no spread) must revert uint32[] memory p = new uint32[](2); uint64[] memory x = new uint64[](2); p[0] = 500_000; x[0] = 2_000_000; p[1] = 500_000; x[1] = 0; vm.expectRevert(MarketRegistry.SpreadFloor.selector); registry.listMarket(p, x); } function test_ListingRejectsBadProbSum() public { uint32[] memory p = new uint32[](2); uint64[] memory x = new uint64[](2); p[0] = 400_000; x[0] = 1_975_000; p[1] = 500_000; x[1] = 0; vm.expectRevert(MarketRegistry.BadDistribution.selector); registry.listMarket(p, x); } function test_SigmaComputedForEvenMoney() public view { MarketRegistry.MarketDefinition memory m = registry.getMarket(marketEvenMoney); // sigma for even money ≈ payout/2 = 0.9875 → ~987500 (1e6 scale) assertApproxEqRel(uint256(m.sigmaX), 987_500, 0.01e18); assertEq(uint256(m.evX), 987_500); } // ------------------------------------------------------------ exposure rules function test_Rule1_VolatilityCap() public { // TVL=30M, k=0.2% → cap = 60_000e6/σ ≈ $60.76K for even money vm.prank(trader); vm.expectRevert(RiskEngine.Rule1Violation.selector); engine.placePosition(marketEvenMoney, 100_000e6, brand, 0); } function test_Rule2_TailCap() public { // long shot 36.5x: rule2 cap = 1% TVL = 300K payout → netStake ≤ ~$8.2K // rule1 cap is ~$10.2K (σ≈5.9) so rule2 binds first at 9K vm.prank(trader); vm.expectRevert(RiskEngine.Rule2Violation.selector); engine.placePosition(marketLongShot36, 9_000e6, brand, 0); } function test_PlaceWithinLimits() public { vm.prank(trader); uint256 id = engine.placePosition(marketEvenMoney, 50_000e6, brand, 0); (, , , uint256 stake, , , , , bool settled, ) = engine.positions(id); assertEq(stake, 50_000e6); assertFalse(settled); assertGt(engine.globalNetExposure(), 0); } // ------------------------------------------------------------ settlement function test_SettleLoss_VaultGains() public { uint256 vaultBefore = vault.totalAssets(); vm.prank(trader); engine.placePosition(marketEvenMoney, 50_000e6, brand, 0); rng.fulfill(1, _wordForOutcome(marketEvenMoney, 1)); // losing outcome // vault gains netStake; also gets residual spread share assertGt(vault.totalAssets(), vaultBefore); assertEq(engine.globalNetExposure(), 0); } function test_SettleWin_TraderPaid() public { vm.prank(trader); engine.placePosition(marketEvenMoney, 50_000e6, brand, 0); uint256 traderBefore = usdc.balanceOf(trader); rng.fulfill(1, _wordForOutcome(marketEvenMoney, 0)); // winning outcome uint256 got = usdc.balanceOf(trader) - traderBefore; // net stake = 50K * (1-0.0125) = 49_375; payout = 49_375 * 1.975 = 97_515.625 assertApproxEqAbs(got, 97_515_625_000, 1e6); } function test_FeeWaterfall() public { vm.prank(trader); engine.placePosition(marketEvenMoney, 100_000e6 / 2, brand, 10_000); // 1% markup rng.fulfill(1, _wordForOutcome(marketEvenMoney, 1)); uint256 vol = 50_000e6; // protocol fee 0.15% → burn; standby 0.05% → anchor assertEq(usdc.balanceOf(burnSink), vol * 15 / 10_000 + (vol / 100) * 10 / 100); // + 10% markup royalty assertEq(usdc.balanceOf(anchorReserve), vol * 5 / 10_000); assertEq(usdc.balanceOf(rebatePool), vol * 17 / 10_000); // brand: 0.25% base + 90% of 1% markup, accrued (pull) assertEq(splitter.brandAccrued(brand), vol * 25 / 10_000 + (vol / 100) * 90 / 100); } function test_BrandClaim() public { vm.prank(trader); engine.placePosition(marketEvenMoney, 50_000e6, brand, 10_000); rng.fulfill(1, _wordForOutcome(marketEvenMoney, 1)); uint256 accrued = splitter.brandAccrued(brand); assertGt(accrued, 0); vm.prank(brand); splitter.claimBrand(); assertEq(usdc.balanceOf(brand), accrued); assertEq(splitter.brandAccrued(brand), 0); } // ------------------------------------------------------------ recovery gate function test_RecoveryGate_RedirectsToVault() public { // Put the vault under water: big win against it vm.prank(trader); engine.placePosition(marketEvenMoney, 60_000e6 / 2, brand, 0); rng.fulfill(1, _wordForOutcome(marketEvenMoney, 0)); // trader wins → vault under HWM assertTrue(vault.underWater()); uint256 rebatesBefore = usdc.balanceOf(rebatePool); uint256 brandBefore = splitter.brandAccrued(brand); // next settled position: distribution + rebates must go to the vault vm.prank(trader); engine.placePosition(marketEvenMoney, 30_000e6, brand, 0); rng.fulfill(2, _wordForOutcome(marketEvenMoney, 1)); // trader loses assertEq(usdc.balanceOf(rebatePool), rebatesBefore); // no new rebates assertEq(splitter.brandAccrued(brand), brandBefore); // no new brand accrual } // ------------------------------------------------------------ timeout void function test_VoidTimedOut_FullRefund() public { vm.prank(trader); uint256 id = engine.placePosition(marketEvenMoney, 50_000e6, brand, 0); uint256 before = usdc.balanceOf(trader); vm.warp(block.timestamp + 2 hours); engine.voidTimedOut(id); assertEq(usdc.balanceOf(trader) - before, 50_000e6); assertEq(engine.globalNetExposure(), 0); // settlement after void must be a no-op rng.fulfill(1, 12345); assertEq(usdc.balanceOf(trader) - before, 50_000e6); } function test_VoidBeforeTimeout_Reverts() public { vm.prank(trader); uint256 id = engine.placePosition(marketEvenMoney, 50_000e6, brand, 0); vm.expectRevert(RiskEngine.NotTimedOut.selector); engine.voidTimedOut(id); } // ------------------------------------------------------------ vault mechanics function test_TvlCapBlocksDeposits() public { address whale = makeAddr("whaleLp"); usdc.mint(whale, 400_000_000e6); vm.startPrank(whale); usdc.approve(address(vault), type(uint256).max); vm.expectRevert(RiskVault.CapExceeded.selector); vault.deposit(300_000_000e6, whale); // 30M already in → exceeds 300M cap vault.deposit(270_000_000e6, whale); // exactly to cap: fine vm.expectRevert(RiskVault.CapExceeded.selector); vault.deposit(1e6, whale); vm.stopPrank(); } function test_WithdrawalDelayEnforced() public { vm.startPrank(lp); vault.requestWithdrawal(1_000_000e6); vm.expectRevert(RiskVault.StillLocked.selector); vault.redeem(1_000_000e6, lp, lp); vm.warp(block.timestamp + 48 hours); uint256 got = vault.redeem(1_000_000e6, lp, lp); assertGt(got, 0); vm.stopPrank(); } function test_DirectWithdrawDisabled() public { vm.prank(lp); vm.expectRevert(bytes("use requestWithdrawal + redeem")); vault.withdraw(1e6, lp, lp); } // ------------------------------------------------------------ perf fee function test_PerfFeeAboveHwmBurns() public { // grow the vault: trader loses several positions for (uint256 i; i < 5; ++i) { vm.prank(trader); engine.placePosition(marketEvenMoney, 50_000e6, brand, 0); rng.fulfill(i + 1, _wordForOutcome(marketEvenMoney, 1)); } uint256 burnBefore = usdc.balanceOf(burnSink); engine.crystallizePerformanceFee(); assertGt(usdc.balanceOf(burnSink), burnBefore); // 10% of gain burned // second crystallization with no new profit: no fee uint256 burnAfter = usdc.balanceOf(burnSink); engine.crystallizePerformanceFee(); assertEq(usdc.balanceOf(burnSink), burnAfter); } // ------------------------------------------------------------ accounting identity /// The invariant we enforced in the simulator: value distributed to all /// parties can never exceed value paid in by traders (deposits ≥ distributions). function test_Invariant_ConservationOfValue() public { uint256 traderPaid0 = usdc.balanceOf(trader); uint256 sys0 = usdc.balanceOf(address(vault)); for (uint256 i; i < 20; ++i) { vm.prank(trader); engine.placePosition(marketEvenMoney, 20_000e6, brand, 10_000); rng.fulfill(i + 1, uint256(keccak256(abi.encode("run", i)))); } uint256 traderNet = traderPaid0 - usdc.balanceOf(trader); // what traders paid in (net) uint256 sysGain = usdc.balanceOf(address(vault)) - sys0 + usdc.balanceOf(burnSink) + usdc.balanceOf(rebatePool) + usdc.balanceOf(anchorReserve) + splitter.brandAccrued(brand); // Conservation: everything the system gained came from traders, exactly. assertEq(sysGain, traderNet); } // ------------------------------------------------------------ helpers /// Find a random word that resolves market `id` to outcome index `want`. function _wordForOutcome(uint256 id, uint256 want) internal view returns (uint256) { // settlement hashes (word, positionId); search words until outcome matches // positionId = nextPositionId - 1 (the just-placed position) uint256 pid = engine.nextPositionId() - 1; for (uint256 w; w < 500; ++w) { uint256 hashed = uint256(keccak256(abi.encode(w, pid))); (uint256 idx, ) = registry.resolveOutcome(id, hashed); if (idx == want) return w; } revert("no word found"); } }