// SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.24; import {Test} from "forge-std/Test.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} from "../src/RiskEngine.sol"; import {MockUSDC, MockRandomness} from "./Protocol.t.sol"; /// Fuzz + randomized-sequence tests: the accounting identities must hold for /// ANY market shape, ANY stake, ANY outcome sequence. contract FuzzTest 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"); function setUp() public { usdc = new MockUSDC(); registry = new MarketRegistry(gov); vault = new RiskVault(IERC20(address(usdc)), gov, 300_000_000e6); 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(); usdc.mint(lp, 30_000_000e6); vm.startPrank(lp); usdc.approve(address(vault), type(uint256).max); vault.deposit(30_000_000e6, lp); vm.stopPrank(); usdc.mint(trader, 100_000_000e6); vm.prank(trader); usdc.approve(address(engine), type(uint256).max); } /// Any listable binary market keeps EV ≤ 1 − spread — fuzz the win prob. function testFuzz_ListingSpreadFloorHolds(uint32 winProb) public { winProb = uint32(bound(winProb, 10_000, 990_000)); // 1%–99% // payout chosen to hit exactly EV = 0.9875 uint64 payout = uint64(uint256(987_500) * 1e6 / winProb); uint32[] memory p = new uint32[](2); uint64[] memory x = new uint64[](2); p[0] = winProb; x[0] = payout; p[1] = 1_000_000 - winProb; x[1] = 0; uint256 id = registry.listMarket(p, x); MarketRegistry.MarketDefinition memory m = registry.getMarket(id); assertLe(uint256(m.evX), 987_500 + 1); // rounding tolerance 1 ppm } /// Conservation of value holds for any stake size and any outcome sequence. function testFuzz_ConservationOfValue(uint96 stakeSeed, uint256 wordSeed) public { uint256 stake = bound(uint256(stakeSeed), 100e6, 20_000e6); uint32[] memory p = new uint32[](2); uint64[] memory x = new uint64[](2); p[0] = 500_000; x[0] = 1_975_000; p[1] = 500_000; x[1] = 0; uint256 mkt = registry.listMarket(p, x); uint256 trader0 = usdc.balanceOf(trader); uint256 vault0 = usdc.balanceOf(address(vault)); for (uint256 i; i < 10; ++i) { vm.prank(trader); engine.placePosition(mkt, stake, brand, 10_000); rng.fulfill(i + 1, uint256(keccak256(abi.encode(wordSeed, i)))); } int256 traderDelta = int256(usdc.balanceOf(trader)) - int256(trader0); int256 sysDelta = int256(usdc.balanceOf(address(vault))) - int256(vault0) + int256(usdc.balanceOf(burnSink)) + int256(usdc.balanceOf(rebatePool)) + int256(usdc.balanceOf(anchorReserve)) + int256(splitter.brandAccrued(brand)); // zero-sum: system gain == trader loss (and vice versa), exactly. assertEq(sysDelta, -traderDelta); // engine escrow must be empty after all settlements assertEq(usdc.balanceOf(address(engine)), 0); } /// Exposure accounting: globalNetExposure returns to zero after settle/void. function testFuzz_ExposureReleased(uint96 stakeSeed, bool viaVoid) public { uint256 stake = bound(uint256(stakeSeed), 100e6, 20_000e6); uint32[] memory p = new uint32[](2); uint64[] memory x = new uint64[](2); p[0] = 500_000; x[0] = 1_975_000; p[1] = 500_000; x[1] = 0; uint256 mkt = registry.listMarket(p, x); vm.prank(trader); uint256 id = engine.placePosition(mkt, stake, brand, 0); assertGt(engine.globalNetExposure(), 0); if (viaVoid) { vm.warp(block.timestamp + 2 hours); engine.voidTimedOut(id); } else { rng.fulfill(1, 42); } assertEq(engine.globalNetExposure(), 0); } /// Vault never pays more than Rule 2 allows, for any market and stake. function testFuzz_Rule2BoundsWorstCase(uint32 winProb, uint96 stakeSeed) public { winProb = uint32(bound(winProb, 5_000, 500_000)); uint64 payout = uint64(uint256(987_500) * 1e6 / winProb); uint32[] memory p = new uint32[](2); uint64[] memory x = new uint64[](2); p[0] = winProb; x[0] = payout; p[1] = 1_000_000 - winProb; x[1] = 0; uint256 mkt = registry.listMarket(p, x); uint256 stake = bound(uint256(stakeSeed), 10e6, 50_000_000e6); uint256 tvlBefore = vault.totalAssets(); vm.prank(trader); try engine.placePosition(mkt, stake, brand, 0) returns (uint256) { // if accepted, worst-case payout must be ≤ 1% TVL (, , , , uint256 netStake, uint256 maxPayout, , , , ) = engine.positions(0); netStake; // silence assertLe(maxPayout, tvlBefore / 100); } catch { // rejected by rules — always acceptable } } /// HWM: performance fee can never be charged twice on the same ground. function testFuzz_HwmMonotone(uint256 wordSeed) public { uint32[] memory p = new uint32[](2); uint64[] memory x = new uint64[](2); p[0] = 500_000; x[0] = 1_975_000; p[1] = 500_000; x[1] = 0; uint256 mkt = registry.listMarket(p, x); uint256 hwm0 = vault.hwmPricePerShare(); for (uint256 i; i < 5; ++i) { vm.prank(trader); engine.placePosition(mkt, 10_000e6, brand, 0); rng.fulfill(i + 1, uint256(keccak256(abi.encode(wordSeed, i)))); engine.crystallizePerformanceFee(); // HWM never decreases assertGe(vault.hwmPricePerShare(), hwm0); hwm0 = vault.hwmPricePerShare(); } } }