From a1fb3f97ac3938282033c9223d54b219e4f9bf19 Mon Sep 17 00:00:00 2001 From: v0anon Date: Sun, 30 Aug 2026 16:03:49 +0000 Subject: [PATCH 1/3] PR-C: implement locked review rulings (S-01, S-03, S-05, S-09, S-11, S-12, S-13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S-01: honest bucket-index init; settlement floors index at 1 wei — the 0==RAY lazy sentinel that resurrected wiped buckets is gone (root cause). S-03: sMax never snaps down; decay is the sole descent, floored at the tracked leader; permissionless refreshSMax(postId) closes lazy-accrual deviations; tracker widened to TRACKED_POSTS=10. I.4 re-documented as true-about-mechanism; S-04 global coupling ratified as intended. S-05: false rMax= true leader` is not guaranteed by +the tracker alone. That deviation is CLOSEABLE BY ANYONE at any time +via the permissionless `refreshSMax(postId)` (S-03 layer ii), which +settles the post if an epoch has passed and feeds its true stored +total to the tracker; the ops worker pokes the largest known posts +each epoch. So I.4 is true-about-this-mechanism: `sMax >=` every +TRACKED total at all times after update, `sMax >=` any specific post's +total the moment anyone refreshes it, and participation factors are +clamped at 1.0 in all cases, bounding the worst pre-poke effect at the +rMax ceiling. **Safety statement** - Decay prevents historical peaks from permanently suppressing - participation factors on future posts. -- sMax >= leaderTotal at all times (after update), so participation - factors remain <= 1.0 in steady state. + participation factors on future posts; the tracked-leader floor + prevents decay from undershooting anything the tracker can see. +- Dust posts cannot drag `sMax` down (never-snap-down), and dormant + giants cannot be hidden from it for longer than one poke. +- S-04 ratification: participation coupling every post's rate to the + global leader via `sMax` is intended design, not a defect. --- diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 48cd18d..f3489d6 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -145,6 +145,14 @@ contract Deploy is Script { address(stakeImpl), abi.encodeCall(StakeEngine.initialize, (gov, address(token), address(protocolPolicy))) ); StakeEngine stake = StakeEngine(address(stakeProxy)); + // patch_prC_rulings S-09: the decay backstop is 10%/day (9e17) BY DESIGN; + // the old docstring claiming 0.5%/day was the bug. Set it explicitly when + // the deployer holds governance (dev path), and pin it unconditionally so + // any drift fails the deploy loudly on every path. + if (stake.governance() == deployer) { + stake.setSMaxDecayRate(9e17); + } + require(stake.sMaxDecayRateRay() == 9e17, "Deploy: sMaxDecayRateRay != 9e17 (10%/day, S-09)"); // patch_stakeengine_exempt_precompute: fail loud if the nonce offset above ever drifts, // rather than silently deploying VSPToken with a wrong exemption target. require( diff --git a/src/StakeEngine.sol b/src/StakeEngine.sol index b8572d0..b6b7bd7 100644 --- a/src/StakeEngine.sol +++ b/src/StakeEngine.sol @@ -31,7 +31,9 @@ contract StakeEngine is GovernedUpgradeable { uint256 amount; // Current amount after last snapshot uint8 side; uint256 weightedPosition; // Stake-weighted queue position - uint256 entryEpoch; // Epoch of first stake + // patch_prC_rulings S-11: entryEpoch removed — stored but never read by + // any settlement path (see MAX_SNAPSHOT_PERIOD note below for why + // prorating by entry epoch was rejected as a mechanism). } struct SideQueue { @@ -40,7 +42,13 @@ contract StakeEngine is GovernedUpgradeable { // patch_h1a_bucket: pooled tail bucket (all stakers below the ranked set, // sharing one position). Rebases in O(1); bucketLive = scaled * index / RAY. uint256 bucketScaledTotal; - uint256 bucketIndexRay; // 0 sentinel == RAY (lazy init) + // patch_prC_rulings S-01: honest init — 0 means "no member has ever + // entered this bucket" and nothing else. _bucketAdd sets RAY explicitly + // on first entry; settlement floors the index at 1 wei so a stored 0 + // can never be produced by decay and never collides with the + // uninitialized state (the old 0==RAY lazy sentinel resurrected wiped + // buckets at face value after ~1400 days of losses at deployed rates). + uint256 bucketIndexRay; // patch_h1b_promotion: max-heap of bucket member addresses, keyed on // scaledShares (rebase-stable -> no settlement-time maintenance). address[] bucketHeap; @@ -72,11 +80,16 @@ contract StakeEngine is GovernedUpgradeable { uint256 public sMax; uint256 public sMaxPostId; + /// @notice Leader-tracker width (patch_prC_rulings S-03 layer iii: 3 -> 10). + /// Wider board narrows the untracked-dormant-post window; the + /// irreducible residue is closed by permissionless refreshSMax(). + uint256 public constant TRACKED_POSTS = 10; + struct TopPost { uint256 postId; uint256 total; } - TopPost[3] private topPosts; + TopPost[TRACKED_POSTS] private topPosts; uint256 private constant _NOT_ENTERED = 1; uint256 private constant _ENTERED = 2; @@ -93,7 +106,10 @@ contract StakeEngine is GovernedUpgradeable { uint256 public snapshotPeriod; /// @notice sMax decay rate per epoch, in RAY. - /// Default 995e15 = 0.995 = 0.5% decay per day. + /// Default 9e17 = 0.9 = 10% decay per day (patch_prC_rulings S-09: + /// the constant was always 9e17 and is CORRECT as the backstop; the + /// old docstring claiming 0.5%/day was the bug. Deploy.s.sol pins + /// this value explicitly). /// Governance-configurable. Lower value = faster decay. /// RAY (1e18) = no decay. Must be in (0, RAY]. uint256 public sMaxDecayRateRay; @@ -120,8 +136,8 @@ contract StakeEngine is GovernedUpgradeable { /// periods AND closes the mid-window accrual asymmetry. /// @dev patch_sec_jit_window (2026-08-19, external report VSP-SEC-001): /// settlement scales the rate by `epochsElapsed` and applies the result - /// to whatever lots exist at settlement time -- `StakeLot.entryEpoch` is - /// stored but never read. Whenever snapshotPeriod > EPOCH_LENGTH the + /// to whatever lots exist at settlement time -- no per-lot entry epoch + /// participates (the field was removed as S-11). Whenever snapshotPeriod > EPOCH_LENGTH the /// snapshot is SUPPRESSED mid-window, so (a) a lot joining late in the /// window collects the whole window's accrual, and (b) a lot leaving /// before the window closes escapes the whole window's decay. @@ -129,9 +145,10 @@ contract StakeEngine is GovernedUpgradeable { /// interaction settles every elapsed epoch BEFORE mutating the lot set /// (stake() and withdraw() both call _maybeSnapshot first) -- which /// closes both directions. - /// Prorating by entryEpoch was the reporter's suggestion; it fixes only - /// direction (a), and cannot fix it for the pooled tail bucket at all, - /// since _settleBucket is an O(1) index rebase with no per-entry epochs. + /// Prorating by a per-lot entry epoch was the reporter's suggestion; it + /// fixes only direction (a), and cannot fix it for the pooled tail bucket + /// at all, since _settleBucket is an O(1) index rebase with no per-entry + /// epochs. That is also why StakeLot carries no entry-epoch field (S-11). uint256 public constant MAX_SNAPSHOT_PERIOD = EPOCH_LENGTH; /// @notice Hard cap on sMaxDecayMaxEpochs. Prevents OOG in _projectSMaxDecay. uint256 public constant MAX_SMAX_DECAY_EPOCHS = 10000; @@ -181,7 +198,9 @@ contract StakeEngine is GovernedUpgradeable { event SMaxRescanned(uint256 newSMax, uint256 newSMaxPostId); event SMaxDecayRateSet(uint256 oldRate, uint256 newRate); event SMaxDecayMaxEpochsSet(uint256 oldMax, uint256 newMax); - event PositionsRescaled(uint256 indexed postId, uint8 side, uint256 oldMax, uint256 newCeiling); + // patch_prC_rulings S-12: PositionsRescaled event removed with _rescalePositions. + /// @notice patch_prC_rulings S-03: emitted by the permissionless poke. + event SMaxRefreshed(uint256 indexed postId, uint256 postTotal, uint256 sMaxAfter); // ------------------------------------------------------------ // Constructor / Initializer @@ -398,16 +417,12 @@ contract StakeEngine is GovernedUpgradeable { return _projectLotValue(ps, lot, currentEpoch); } + /// @dev patch_prC_rulings S-11: entryEpoch dropped from the tuple (field removed). + // patch_prC_rulings_p2 (arity sweep applied) function getUserLotInfo(address user, uint256 postId, uint8 side) external view - returns ( - uint256 amount, - uint256 weightedPosition, - uint256 entryEpoch, - uint256 sideTotal, - uint256 positionWeight - ) + returns (uint256 amount, uint256 weightedPosition, uint256 sideTotal, uint256 positionWeight) { if (side > 1) { revert InvalidSide(); @@ -415,11 +430,11 @@ contract StakeEngine is GovernedUpgradeable { PostState storage ps = posts[postId]; uint256 idx = _getLotIndex(ps, user, side); if (idx == 0) { - return (0, 0, 0, 0, 0); + return (0, 0, 0, 0); } StakeLot storage lot = ps.sides[side].lots[idx - 1]; if (lot.amount == 0) { - return (0, 0, 0, 0, 0); + return (0, 0, 0, 0); } uint256 currentEpoch = _currentEpoch(); @@ -439,7 +454,7 @@ contract StakeEngine is GovernedUpgradeable { } else { positionWeight = RAY; } - return (projectedAmount, lot.weightedPosition, lot.entryEpoch, sideTotal, positionWeight); + return (projectedAmount, lot.weightedPosition, sideTotal, positionWeight); } // ------------------------------------------------------------ @@ -630,10 +645,11 @@ contract StakeEngine is GovernedUpgradeable { int256 vsNum = int256(2 * A) - int256(T); if (vsNum == 0) { - // VS neutral — no growth/decay, but still rescale positions - // so the invariant holds for the next epoch. - _rescalePositions(postId, 0, qs); - _rescalePositions(postId, 1, qc); + // VS neutral — no growth/decay. patch_prC_rulings S-12: the old + // _rescalePositions call here was dead in effect — positions are + // recomputed as midpoints (< total) after every queue mutation and + // every settlement, so its rescale body never executed; the + // clamp inside _applyEpoch remains the safety net regardless. ps.lastSnapshotEpoch = currentEpoch; return; } @@ -643,6 +659,9 @@ contract StakeEngine is GovernedUpgradeable { uint256 epochsElapsed = currentEpoch - lastEpoch; uint256 vRay = (absVS * RAY) / T; + // patch_prC_rulings S-04 (ratified): participation deliberately couples + // every post's rate to the GLOBAL leader via sMax — smaller posts earn a + // scaled-down rate by design; this is the intended cross-post coupling. uint256 participationRay = (T * RAY) / sMax; if (participationRay > RAY) { participationRay = RAY; @@ -653,8 +672,9 @@ contract StakeEngine is GovernedUpgradeable { uint256 rBase = rMin + ((rMax - rMin) * vRay * participationRay) / (RAY * RAY); // Apply epoch gains/losses (positions that exceed sideTotal are - // safely clamped to zero weight inside _applyEpoch — this is - // the one-epoch penalty before rescale fixes them). + // safely clamped to zero weight inside _applyEpoch; midpoint + // recomputation after every mutation keeps positions < total, so the + // clamp is a safety net rather than a working path — S-12). (uint256 mintS, uint256 burnS) = _applyEpoch(qs, supportWins, true, rBase); (uint256 mintC, uint256 burnC) = _applyEpoch(qc, supportWins, false, rBase); @@ -681,46 +701,10 @@ contract StakeEngine is GovernedUpgradeable { emit PostUpdated(postId, currentEpoch, qs.total, qc.total); } - /// @dev Rescale weightedPositions so that max(position) < q.total. - /// Called after _applyEpoch + _recomputeSideTotal so that totals - /// reflect the final state including mints/burns. - /// Uses strict < (not <=) by targeting q.total - 1 when rescale - /// is needed, so no lot starts the next epoch at posWeight == 0. - function _rescalePositions(uint256 postId, uint8 side, SideQueue storage q) internal { - uint256 n = q.lots.length; - if (n == 0 || q.total == 0) { - return; - } - - uint256 maxPos = 0; - for (uint256 i = 0; i < n; i++) { - uint256 p = q.lots[i].weightedPosition; - if (p > maxPos) { - maxPos = p; - } - } - // Rescale if any position >= q.total (using >= not > so that - // a position exactly equal to sideTotal is also fixed). - if (maxPos < q.total) { - return; - } - - // Target: map maxPos to (q.total - 1) so that the highest - // position always has posShare < RAY → posWeight > 0. - uint256 target = q.total > 0 ? q.total - 1 : 0; - if (target == 0) { - // Edge case: sideTotal is 1 wei. Just zero all positions. - for (uint256 i = 0; i < n; i++) { - q.lots[i].weightedPosition = 0; - } - } else { - for (uint256 i = 0; i < n; i++) { - q.lots[i].weightedPosition = (q.lots[i].weightedPosition * target) / maxPos; - } - } - emit PositionsRescaled(postId, side, maxPos, target); - } - + // patch_prC_rulings S-12: _rescalePositions removed (dead code). Positions + // are recomputed as midpoints (< q.total) after every queue mutation and + // settlement, so the rescale condition never fired outside the neutral + // branch, where it was a no-op. The _applyEpoch clamp is the safety net. /// @dev Applies epoch gains/losses with midpoint positional weighting. /// Each lot's delta = amount * rBase * (T - wPos) / T. /// No redistribution: unminted rate is simply not created. @@ -933,7 +917,7 @@ contract StakeEngine is GovernedUpgradeable { function _updateSMax(uint256 postId, uint256 postTotal) internal { uint256 slot = type(uint256).max; - for (uint256 i = 0; i < 3; i++) { + for (uint256 i = 0; i < TRACKED_POSTS; i++) { if (topPosts[i].postId == postId && topPosts[i].total > 0) { slot = i; break; @@ -947,16 +931,16 @@ contract StakeEngine is GovernedUpgradeable { topPosts[slot - 1] = tmp; slot--; } - while (slot < 2 && topPosts[slot].total < topPosts[slot + 1].total) { + while (slot < TRACKED_POSTS - 1 && topPosts[slot].total < topPosts[slot + 1].total) { TopPost memory tmp = topPosts[slot]; topPosts[slot] = topPosts[slot + 1]; topPosts[slot + 1] = tmp; slot++; } } else { - for (uint256 i = 0; i < 3; i++) { + for (uint256 i = 0; i < TRACKED_POSTS; i++) { if (postTotal > topPosts[i].total) { - for (uint256 j = 2; j > i; j--) { + for (uint256 j = TRACKED_POSTS - 1; j > i; j--) { topPosts[j] = topPosts[j - 1]; } topPosts[i] = TopPost(postId, postTotal); @@ -964,7 +948,7 @@ contract StakeEngine is GovernedUpgradeable { } } } - for (uint256 i = 0; i < 3; i++) { + for (uint256 i = 0; i < TRACKED_POSTS; i++) { if (topPosts[i].total == 0) { topPosts[i] = TopPost(0, 0); } @@ -976,10 +960,15 @@ contract StakeEngine is GovernedUpgradeable { sMax = leaderTotal; sMaxLastUpdatedEpoch = currentEpoch; } else { - // Snap down to current leader immediately. - // Decay is only a fallback for stale topPosts array. - sMax = leaderTotal; - sMaxLastUpdatedEpoch = currentEpoch; + // patch_prC_rulings S-03 layer (i): NEVER snap down. Decay is + // the sole descent, floored at the tracked leader. The old + // immediate snap-down let a 1-wei dust post drag sMax to dust + // the instant the tracked leaders unwound, inflating every + // other post's participation factor to the clamp. + uint256 decayed = _applySMaxDecay(currentEpoch); + if (decayed < leaderTotal) { + sMax = leaderTotal; + } } sMaxPostId = topPosts[0].postId; } else { @@ -987,6 +976,21 @@ contract StakeEngine is GovernedUpgradeable { } } + /// @notice patch_prC_rulings S-03 layer (ii): the irreducible "poke" for + /// lazy-accrual dormant posts. Permissionless: it can only feed the + /// tracker a post's TRUE stored total (settling first if an epoch + /// boundary has passed), so the worst any caller can do is make + /// sMax more honest. The ops worker calls this each epoch for the + /// largest known posts; anyone else can close a deviation the + /// moment they see one (I.4 restoration is permissionless). + function refreshSMax(uint256 postId) external nonReentrant { + _maybeSnapshot(postId, _currentEpoch()); + PostState storage ps = posts[postId]; + uint256 total = ps.sides[0].total + ps.sides[1].total; + _updateSMax(postId, total); + emit SMaxRefreshed(postId, total, sMax); + } + function _applySMaxDecay(uint256 currentEpoch) internal returns (uint256) { if (sMax == 0 || currentEpoch <= sMaxLastUpdatedEpoch) { sMaxLastUpdatedEpoch = currentEpoch; @@ -1009,7 +1013,7 @@ contract StakeEngine is GovernedUpgradeable { } function rescanSMax(uint256[] calldata postIds) external onlyGovernance { - for (uint256 i = 0; i < 3; i++) { + for (uint256 i = 0; i < TRACKED_POSTS; i++) { topPosts[i] = TopPost(0, 0); } for (uint256 i = 0; i < postIds.length; i++) { @@ -1024,6 +1028,8 @@ contract StakeEngine is GovernedUpgradeable { emit SMaxRescanned(topPosts[0].total, topPosts[0].postId); } + /// @dev Returns the top 3 of the TRACKED_POSTS-slot tracker (view kept at + /// three pairs for ABI stability across the widening — patch_prC_rulings). function getTopPosts() external view @@ -1064,9 +1070,12 @@ contract StakeEngine is GovernedUpgradeable { // patch_h1a_bucket: pooled tail-bucket + unified position helpers // =================================================================== + /// @dev patch_prC_rulings S-01: returns the stored index verbatim. 0 now + /// means only "never initialized" (empty bucket, zero scaled shares); + /// _bucketAdd writes RAY explicitly on first entry and _settleBucket + /// floors at 1, so a member-bearing bucket can never store 0. function _bucketIndex(SideQueue storage q) internal view returns (uint256) { - uint256 ix = q.bucketIndexRay; - return ix == 0 ? RAY : ix; + return q.bucketIndexRay; } function _bucketLive(SideQueue storage q) internal view returns (uint256) { @@ -1112,6 +1121,13 @@ contract StakeEngine is GovernedUpgradeable { /// @dev Add `amount` to a (new or existing) bucket member. O(1). function _bucketAdd(PostState storage ps, SideQueue storage q, uint8 side, address user, uint256 amount) internal { + // patch_prC_rulings S-01: honest init. Index 0 <=> no member has ever + // entered (settlement floors at 1, so decay cannot write 0), and with + // no members there are no shares to revalue — initializing to RAY here + // is therefore always safe and never resurrects wiped value. + if (q.bucketIndexRay == 0) { + q.bucketIndexRay = RAY; + } uint256 prev = _getBucketShares(ps, user, side); uint256 shares = (amount * RAY) / _bucketIndex(q); q.bucketScaledTotal += shares; @@ -1157,10 +1173,7 @@ contract StakeEngine is GovernedUpgradeable { function _pushRankedLot(PostState storage ps, SideQueue storage q, uint8 side, address user, uint256 amount) internal { - q.lots - .push( - StakeLot({staker: user, amount: amount, side: side, weightedPosition: 0, entryEpoch: _currentEpoch()}) - ); + q.lots.push(StakeLot({staker: user, amount: amount, side: side, weightedPosition: 0})); _setLotIndex(ps, user, side, q.lots.length); } @@ -1281,14 +1294,30 @@ contract StakeEngine is GovernedUpgradeable { uint256 wPosB = rankedTotal + live / 2; uint256 behind = wPosB < T ? T - wPosB : 0; // Ray-math ordering: single multiply-first truncation, mirroring the - // ranked-lot delta = amount*rBase*midpointRate/(RAY*RAY). behind<=T and - // rBase<=rMaxRAY clamp was dead). + // ranked-lot delta = amount*rBase*midpointRate/(RAY*RAY). behind <= T so + // gRay <= rBase. patch_prC_rulings S-05: rBase is NOT bounded by RAY — + // rMax scales with epochsElapsed (annualized rate x elapsed window), so + // under dormancy rBase can exceed RAY (proven at deploy rates from ~530 + // elapsed epochs, and far sooner at the 5e18 policy cap). The gRay>=RAY + // wipe branch below is therefore LIVE, not dead: it floors a losing + // bucket at total loss, which is the correct bound. uint256 gRay = (rBase * behind) / T; + // patch_prC_rulings S-01/V.8: project through the INDEX with the same + // operations and floor as _settleBucket, so view == materialized to the + // wei even in the wipe/floor regime (the old live-based formula had a + // different truncation order). + uint256 ix = _bucketIndex(q); + uint256 newIx; if (aligned) { - return (live * (RAY + gRay)) / RAY; + newIx = (ix * (RAY + gRay)) / RAY; + } else { + uint256 factor = gRay >= RAY ? 0 : RAY - gRay; + newIx = (ix * factor) / RAY; + } + if (newIx == 0) { + newIx = 1; } - uint256 factor = gRay >= RAY ? 0 : RAY - gRay; - return (live * factor) / RAY; + return (q.bucketScaledTotal * newIx) / RAY; } /// @dev Settle the bucket in place (one epoch) via a single index rebase. @@ -1309,8 +1338,13 @@ contract StakeEngine is GovernedUpgradeable { uint256 wPosB = rankedTotal + live / 2; uint256 behind = wPosB < T ? T - wPosB : 0; // Ray-math ordering: single multiply-first truncation, mirroring the - // ranked-lot delta = amount*rBase*midpointRate/(RAY*RAY). behind<=T and - // rBase<=rMaxRAY clamp was dead). + // ranked-lot delta = amount*rBase*midpointRate/(RAY*RAY). behind <= T so + // gRay <= rBase. patch_prC_rulings S-05: rBase is NOT bounded by RAY — + // rMax scales with epochsElapsed (annualized rate x elapsed window), so + // under dormancy rBase can exceed RAY (proven at deploy rates from ~530 + // elapsed epochs, and far sooner at the 5e18 policy cap). The gRay>=RAY + // wipe branch below is therefore LIVE, not dead: it floors a losing + // bucket at total loss, which is the correct bound. uint256 gRay = (rBase * behind) / T; uint256 ix = _bucketIndex(q); uint256 newIx; @@ -1320,6 +1354,13 @@ contract StakeEngine is GovernedUpgradeable { uint256 factor = gRay >= RAY ? 0 : RAY - gRay; newIx = (ix * factor) / RAY; } + // patch_prC_rulings S-01: floor at 1 wei. A fully-wiped bucket is + // dust-dead (live ~ scaled/RAY), exits still work (full-exit branch, + // no division hazard), and the stored value can never collide with + // the 0 == uninitialized state — the root cause of the resurrection. + if (newIx == 0) { + newIx = 1; + } q.bucketIndexRay = newIx; uint256 newLive = (q.bucketScaledTotal * newIx) / RAY; if (newLive >= live) { diff --git a/src/governance/GovernedUpgradeable.sol b/src/governance/GovernedUpgradeable.sol index 31dd4dc..e4c417b 100644 --- a/src/governance/GovernedUpgradeable.sol +++ b/src/governance/GovernedUpgradeable.sol @@ -64,9 +64,10 @@ abstract contract GovernedUpgradeable is Initializable, UUPSUpgradeable, ERC2771 if (_msgSender() != pendingGovernance) { revert NotPendingGovernance(); } - if (pendingGovernance == address(0)) { - revert ZeroAddress(); - } + // patch_prC_rulings S-13: the old ZeroAddress branch here was + // unreachable — when pendingGovernance is address(0), no real caller + // can equal it, so the NotPendingGovernance check above always fires + // first. Removed as dead code; behavior is identical. governance = pendingGovernance; pendingGovernance = address(0); emit GovernanceSet(governance); diff --git a/src/interfaces/IStakeEngine.sol b/src/interfaces/IStakeEngine.sol index a8b9f3d..fda390d 100644 --- a/src/interfaces/IStakeEngine.sol +++ b/src/interfaces/IStakeEngine.sol @@ -23,14 +23,12 @@ interface IStakeEngine { function setSMaxDecayRate(uint256 newRate) external; function setSMaxDecayMaxEpochs(uint256 newMax) external; /// @notice Returns lot info for a user's position. + /// @dev patch_prC_rulings S-11: entryEpoch dropped from the tuple. function getUserLotInfo(address user, uint256 postId, uint8 side) external view - returns ( - uint256 amount, - uint256 weightedPosition, - uint256 entryEpoch, - uint256 sideTotal, - uint256 positionWeight - ); + returns (uint256 amount, uint256 weightedPosition, uint256 sideTotal, uint256 positionWeight); + + /// @notice patch_prC_rulings S-03: permissionless sMax poke. + function refreshSMax(uint256 postId) external; } diff --git a/test/PrCRegressions.t.sol b/test/PrCRegressions.t.sol new file mode 100644 index 0000000..0cb74fd --- /dev/null +++ b/test/PrCRegressions.t.sol @@ -0,0 +1,199 @@ +// 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"; + +/// patch_prC_rulings: targeted regressions for the PR-C rulings, one per +/// mechanism, deterministic where possible. +/// S-01 honest bucket-index init + 1-wei settlement floor (no resurrection) +/// S-03 never-snap-down, decay floored at tracked leader, 10-slot tracker, +/// permissionless refreshSMax closes untracked-dormant deviations +/// S-13 zero-pending acceptGovernance still reverts (NotPendingGovernance) +contract PrCRegressions is Test { + StakeEngine eng; + MockVSP vsp; + MockProtocolPolicy policy; + + uint256 constant DEPLOY_RATE_MAX = 693805319167998976; + uint256 constant RAY = 1e18; + + 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 _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; + } + + // ───────────────────────────────────────────────────────────── + // S-03 layer (i): never snap down + // ───────────────────────────────────────────────────────────── + function test_S03_NeverSnapDown_DustCannotDrag() public { + _stake(address(0xA1), 1, 0, 300e18); + vm.prank(address(0xA1)); + eng.withdraw(1, 0, 300e18, true); + + // pre-PR-C, this 1-wei stake snapped sMax to 1 + _stake(address(0xD057), 9, 0, 1); + assertEq(eng.sMax(), 300e18, "same-epoch: sMax must hold the high-water mark"); + } + + function test_S03_DecayIsSoleDescent_FlooredAtTrackedLeader() public { + _stake(address(0xA1), 1, 0, 300e18); + _stake(address(0xA2), 2, 0, 100e18); + vm.prank(address(0xA1)); + eng.withdraw(1, 0, 300e18, true); + + // 5 epochs of decay from 300e18 at 10%/day = 300e18 * 0.9^5 ≈ 177.1e18, + // still above the tracked leader (post 2, 100e18): pure decay value. + vm.warp(vm.getBlockTimestamp() + 5 days); + eng.refreshSMax(2); + uint256 expect5 = 300e18; + for (uint256 i = 0; i < 5; i++) { + expect5 = (expect5 * 9e17) / RAY; + } + assertEq(eng.sMax(), expect5, "descent must be exactly the decay curve"); + + // 30 more epochs decays past the leader: floor engages. + vm.warp(vm.getBlockTimestamp() + 30 days); + eng.refreshSMax(2); + assertEq(eng.sMax(), _total(2), "decay must floor at the tracked leader"); + } + + // ───────────────────────────────────────────────────────────── + // S-03 layer (ii): permissionless poke closes untracked deviations + // ───────────────────────────────────────────────────────────── + function test_S03_RefreshSMax_PermissionlessAndHonest() public { + // fill all 10 tracker slots, plus one untracked dormant post (11) + for (uint256 p = 1; p <= 10; p++) { + _stake(address(uint160(0xA000 + p)), p, 0, (20 - p) * 10e18); + } + _stake(address(0xDEAD), 11, 0, 5e18); // smallest — never enters tracker + + // unwind everything tracked; sMax decays with nothing visible to floor at + for (uint256 p = 1; p <= 10; p++) { + vm.prank(address(uint160(0xA000 + p))); + eng.withdraw(p, 0, (20 - p) * 10e18, true); + } + vm.warp(vm.getBlockTimestamp() + 40 days); + + // any address at all can restore I.4 for post 11 + vm.prank(address(0xBADC0FFEE)); + eng.refreshSMax(11); + assertGe(eng.sMax(), _total(11), "poke must restore sMax >= post total"); + // honesty bound: the poke can only feed stored reality, so sMax is at + // most the pre-existing high-water decay path — never inflated above it. + uint256 hw = 190e18; // initial leader (post 1) + uint256 cap30 = hw; + for (uint256 i = 0; i < 30; i++) { + cap30 = (cap30 * 9e17) / 1e18; + } + assertLe(eng.sMax(), cap30 + 1, "poke must not inflate sMax beyond the decay curve"); + } + + // ───────────────────────────────────────────────────────────── + // S-03 layer (iii): 10-slot tracker + // ───────────────────────────────────────────────────────────── + function test_S03_TrackerHoldsTen() public { + assertEq(eng.TRACKED_POSTS(), 10, "tracker constant"); + for (uint256 p = 1; p <= 10; p++) { + _stake(address(uint160(0xB000 + p)), p, 0, (11 - p) * 10e18); // 100e18 down to 10e18 + } + // unwind the leader; the OLD 3-slot board would only remember posts 2-3. + vm.prank(address(uint160(0xB001))); + eng.withdraw(1, 0, 100e18, true); + vm.warp(vm.getBlockTimestamp() + 40 days); + // decay far past everything, then let any tracked slot floor it: + // post 10 (10e18) is only visible because the board is 10 wide. + eng.refreshSMax(10); + assertGe(eng.sMax(), _total(10), "10th-ranked post must be tracked and floor sMax"); + } + + // ───────────────────────────────────────────────────────────── + // S-01: honest init + floor — no resurrection, exits live + // ───────────────────────────────────────────────────────────── + function test_S01_WipedBucketStaysDead_EngineSolvent() public { + // deploy-rate wipe scenario, S01ConfirmedPoC shape but two settlements + for (uint256 i = 0; i < 100; i++) { + _stake(address(uint160(0x100000 + i)), 1, 0, 1e18); + } + for (uint256 i = 0; i < 300; i++) { + _stake(address(uint160(0x200000 + i)), 1, 0, 1e18); // bucket members + } + _stake(address(0xBEEF), 1, 1, 4000e18); + + // cap-rate regime: gRay >= RAY inside one settlement -> factor 0 -> the + // S-01 floor (index = 1 wei) is the exact code path under test. + policy.setRates(0, 5e18); + vm.warp(vm.getBlockTimestamp() + 300 days); + eng.updatePost(1); + (uint256 s1, uint256 c1) = eng.getPostTotals(1); + assertGe(vsp.balanceOf(address(eng)), s1 + c1, "solvent after wipe settlement"); + + // the read-back settlement that used to resurrect the bucket + vm.warp(vm.getBlockTimestamp() + 1 days); + eng.updatePost(1); + (uint256 s2, uint256 c2) = eng.getPostTotals(1); + assertGe(vsp.balanceOf(address(eng)), s2 + c2, "no resurrection on second settlement"); + + // a wiped member's exit must not revert and must not overdraw + address victim = address(uint160(0x200000 + 7)); + uint256 claim = eng.getUserStake(victim, 1, 0); + assertLe(claim, 1e15, "fully wiped bucket member holds dust at most (index floored at 1)"); + if (claim > 0) { + vm.prank(victim); + eng.withdraw(1, 0, claim, true); + } + (uint256 s3, uint256 c3) = eng.getPostTotals(1); + assertGe(vsp.balanceOf(address(eng)), s3 + c3, "solvent after wiped-member exit"); + } + + function test_S01_FreshBucketAfterFullExit_InitializesCleanly() public { + // one bucket member in, out, in again: index must re-init to RAY, value exact + for (uint256 i = 0; i < 100; i++) { + _stake(address(uint160(0x300000 + i)), 1, 0, 2e18); // fill ranked + } + address m = address(0x333); + _stake(m, 1, 0, 1e18); + vm.prank(m); + eng.withdraw(1, 0, 1e18, true); + assertEq(eng.getUserStake(m, 1, 0), 0, "clean exit"); + _stake(m, 1, 0, 1e18); + assertEq(eng.getUserStake(m, 1, 0), 1e18, "re-entry at face value, no sentinel games"); + } + + // ───────────────────────────────────────────────────────────── + // S-13: dead branch removed, guard intact + // ───────────────────────────────────────────────────────────── + function test_S13_ZeroPendingAcceptStillReverts() public { + assertEq(eng.pendingGovernance(), address(0), "precondition"); + vm.expectRevert(abi.encodeWithSignature("NotPendingGovernance()")); + eng.acceptGovernance(); + } +} diff --git a/test/RowYInvariantsPoC.t.sol b/test/RowYInvariantsPoC.t.sol index 81eb473..fe25f48 100644 --- a/test/RowYInvariantsPoC.t.sol +++ b/test/RowYInvariantsPoC.t.sol @@ -150,7 +150,7 @@ contract RowYInvariantsPoC is Test { 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); + (uint256 amt, uint256 wPos,,) = eng.getUserLotInfo(address(uint160(0x7000 + i)), 1, 0); if (amt == 0) { continue; } diff --git a/test/S01BucketMajorityPoC.t.sol b/test/S01BucketMajorityPoC.t.sol index 3996d52..0a48409 100644 --- a/test/S01BucketMajorityPoC.t.sol +++ b/test/S01BucketMajorityPoC.t.sol @@ -107,6 +107,8 @@ contract S01BucketMajorityPoC is Test { emit log_named_uint("DEFICIT 2nd", s2 + c2 - balAfter2); } - assertGt(s2 + c2, balAfter2, "S-01: insolvency after sentinel read-back"); + // patch_prC_rulings_p2: REGRESSION FORM — no sentinel to read back; + // a wiped bucket floors at index 1 (dust) instead of resurrecting. + assertLe(s2 + c2, balAfter2, "S-01 regression: claims exceed balance after 2nd settle"); } } diff --git a/test/S01ConfirmedPoC.t.sol b/test/S01ConfirmedPoC.t.sol index bd2cf50..756269c 100644 --- a/test/S01ConfirmedPoC.t.sol +++ b/test/S01ConfirmedPoC.t.sol @@ -103,7 +103,10 @@ contract S01ConfirmedPoC is Test { emit log_named_uint("FINAL DEFICIT", s2 + c2 - balFinal); } - // Assert insolvency - assertTrue(s1 + c1 > balAfter || s2 + c2 > balFinal, "S-01 CONFIRMED: insolvency"); + // patch_prC_rulings_p2: REGRESSION FORM. The honest bucket-index init + // + 1-wei settlement floor make the 0->RAY resurrection impossible, so + // the engine must remain solvent at every checkpoint of this scenario. + assertGe(balAfter, s1 + c1, "S-01 regression: insolvent after first settlement"); + assertGe(balFinal, s2 + c2, "S-01 regression: insolvent after victim exit"); } } diff --git a/test/S02FixOrphanCheckPoC.t.sol b/test/S02FixOrphanCheckPoC.t.sol index 0a001d7..f25852d 100644 --- a/test/S02FixOrphanCheckPoC.t.sol +++ b/test/S02FixOrphanCheckPoC.t.sol @@ -62,7 +62,7 @@ contract S02FixOrphanCheckPoC is Test { // 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); + (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)); diff --git a/test/S02GhostSquattingPoC.t.sol b/test/S02GhostSquattingPoC.t.sol index cc920d1..cb2a5b1 100644 --- a/test/S02GhostSquattingPoC.t.sol +++ b/test/S02GhostSquattingPoC.t.sol @@ -83,8 +83,8 @@ contract S02GhostSquattingPoC is Test { 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); + (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); diff --git a/test/S02ValidationPoC.t.sol b/test/S02ValidationPoC.t.sol index 11e224b..8916799 100644 --- a/test/S02ValidationPoC.t.sol +++ b/test/S02ValidationPoC.t.sol @@ -81,8 +81,8 @@ contract S02ValidationPoC is Test { vm.prank(attacker); eng.stake(POST, 0, BIG); - (, aPos,,,) = eng.getUserLotInfo(attacker, POST, 0); - (, hPos,,,) = eng.getUserLotInfo(h0, POST, 0); + (, aPos,,) = eng.getUserLotInfo(attacker, POST, 0); + (, hPos,,) = eng.getUserLotInfo(h0, POST, 0); // make support win so the aligned branch mints address chal = address(0xBEEF); diff --git a/test/S03SMaxTrackerPoC.t.sol b/test/S03SMaxTrackerPoC.t.sol index 23386e7..b15991a 100644 --- a/test/S03SMaxTrackerPoC.t.sol +++ b/test/S03SMaxTrackerPoC.t.sol @@ -175,7 +175,10 @@ contract S03SMaxTrackerPoC is Test { 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"); + // patch_prC_rulings_p2: REGRESSION FORM — never-snap-down. The dust + // post cannot drag sMax; it holds at the high-water mark and only + // decay (floored at the tracked leader) brings it down. + assertGe(sMaxNow, leaderNow, "S-03(b) regression: dust post dragged sMax below leader"); } /// Path (a): decay below the true leader. @@ -206,7 +209,13 @@ contract S03SMaxTrackerPoC is Test { if (sMaxNow < leaderNow) { emit log_named_uint("I.4 VIOLATED shortfall", leaderNow - sMaxNow); } - assertLt(sMaxNow, leaderNow, "S-03(a): sMax decayed below true leader"); + // patch_prC_rulings_p2: pre-poke, the deviation is real — the tracker + // cannot floor at a post it has never been shown. That gap is the + // documented residue of lazy accrual, and I.4's guarantee is that it is + // closeable by ANYONE: + emit log_named_uint("pre-poke sMax (deviation expected)", sMaxNow); + eng.refreshSMax(4); + assertGe(eng.sMax(), _postTotal(4), "S-03(a) regression: permissionless poke failed to restore I.4"); } /// SEVERITY: does the I.4 violation actually cause over-minting? diff --git a/test/S03ValidationPoC.t.sol b/test/S03ValidationPoC.t.sol index 269db4a..fbeea3a 100644 --- a/test/S03ValidationPoC.t.sol +++ b/test/S03ValidationPoC.t.sol @@ -83,7 +83,9 @@ contract S03ValidationPoC is Test { eng.withdraw(3, 0, 150e18, true); _stake(address(0xD057), 9, 0, 1); - assertEq(eng.sMax(), 1, "sMax should be dragged to 1 wei"); + // patch_prC_rulings_p2: REGRESSION FORM — the drag is dead. Within the + // same epoch no decay elapses, so sMax holds the 300e18 high-water mark. + assertEq(eng.sMax(), 300e18, "never-snap-down: sMax must hold the high-water mark"); uint256 before = _total(TARGET); uint256 epochs = 30; @@ -159,12 +161,13 @@ contract S03ValidationPoC is Test { 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"); + // patch_prC_rulings_p2: post-fix the setup cannot drag sMax (it holds at + // 300e18 through the unwind and descends only by decay, floored at the + // tracked leader — which by settlement time is the attacker's own post, + // the INTENDED leader semantics). The attacker earns only the honest + // participation-scaled yield on their real position. + assertEq(eng.sMax(), _total(TARGET), "decay floored at the tracked leader (attacker's own post)"); + assertGe(held + 1e18, attackerStart, "attacker keeps roughly their capital (honest yield only)"); } // ───────────────────────────────────────────────────────────── diff --git a/test/S04SpecTestPoC.t.sol b/test/S04SpecTestPoC.t.sol index 8e7696a..ed7004c 100644 --- a/test/S04SpecTestPoC.t.sol +++ b/test/S04SpecTestPoC.t.sol @@ -124,10 +124,34 @@ contract S04SpecTestPoC is Test { 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"); + // patch_prC_rulings_p2: CHANGED INTENDED BEHAVIOR under S-03 never-snap-down. + // Pre-PR-C, sMax snapped from the whale's 1e24 peak straight back to the + // victim's total on exit — which is exactly the mechanism a dust post + // abused in the other direction. Now the peak persists and DECAYS (10%/ + // epoch, floored at the tracked leader), so an exited whale leaves a + // BOUNDED, TRANSIENT suppression that anyone can burn down by poking + // refreshSMax each epoch. Assert the full arc: suppression exists, + // decay+poke clears it, and the recovered rate matches the clean rate. + assertLt(afterExitGrowth, cleanGrowth, "transient suppression expected under never-snap-down"); + + // burn the peak down: three 30-epoch decay windows (capped per call). + for (uint256 k = 0; k < 3; k++) { + // vm.getBlockTimestamp: in-frame block.timestamp reads are cached by + // the solc 0.8.33 optimizer after first use, so relative warp chains + // collapse; the cheatcode reads the true env value (fresh frame). + vm.warp(vm.getBlockTimestamp() + 30 days); + eng.refreshSMax(VICTIM); + } + assertEq(eng.sMax(), _total(VICTIM), "decay floors at the victim once the peak burns off"); + + uint256 b2 = _total(VICTIM); + vm.warp(vm.getBlockTimestamp() + 30 days); + eng.updatePost(VICTIM); + uint256 recoveredGrowth = _total(VICTIM) - b2; + emit log_named_uint("victim growth after peak burned off", recoveredGrowth); + // rate (growth/base) recovers to the clean rate within 2% + assertApproxEqRel( + recoveredGrowth * 1e18 / b2, cleanGrowth * 1e18 / b0, 2e16, "post-decay rate matches the never-whaled rate" + ); } } diff --git a/test/S06GapBucketAddPoC.t.sol b/test/S06GapBucketAddPoC.t.sol index f77ac06..56531ad 100644 --- a/test/S06GapBucketAddPoC.t.sol +++ b/test/S06GapBucketAddPoC.t.sol @@ -87,7 +87,7 @@ contract S06GapBucketAddPoC is Test { 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); + (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 { diff --git a/test/S06HeapDesyncPoC.t.sol b/test/S06HeapDesyncPoC.t.sol index 5c9b018..370384c 100644 --- a/test/S06HeapDesyncPoC.t.sol +++ b/test/S06HeapDesyncPoC.t.sol @@ -114,7 +114,7 @@ contract S06HeapDesyncPoC is Test { // 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); + (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)); } diff --git a/test/S08CompactLotsPoC.t.sol b/test/S08CompactLotsPoC.t.sol index f2363bb..a56c30b 100644 --- a/test/S08CompactLotsPoC.t.sol +++ b/test/S08CompactLotsPoC.t.sol @@ -69,11 +69,11 @@ contract S08CompactLotsPoC is Test { 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); + (, 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); @@ -86,11 +86,11 @@ contract S08CompactLotsPoC is Test { // 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); + (, 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); diff --git a/test/S09S11S12PoC.t.sol b/test/S09S11S12PoC.t.sol index 5b8e4a0..33d3e2e 100644 --- a/test/S09S11S12PoC.t.sol +++ b/test/S09S11S12PoC.t.sol @@ -53,7 +53,7 @@ contract S09S11S12PoC is Test { _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("old (pre-fix) docstring claimed", 995e15); // patch_prC_rulings_p2: docstring now matches the constant emit log_named_uint("constant DEFAULT_SMAX_DECAY_RATE_RAY", 9e17); // Quantify the difference the doc error would cause over 10 epochs. @@ -69,25 +69,24 @@ contract S09S11S12PoC is Test { 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 { + /// patch_prC_rulings_p2 REGRESSION FORM (S-11): the entryEpoch field is + /// REMOVED — getUserLotInfo is a 4-tuple, and yield differences between + /// stakers arriving at different times are driven by QUEUE POSITION only, + /// exactly as the original PoC demonstrated. + function test_S11_EntryEpochRemoved_YieldByQueuePosition() public { _fresh(); address early = address(0xEA21); address late = address(0x1A7E); _stake(early, POST, 0, 100e18); - (,, uint256 eEpochEarly,,) = eng.getUserLotInfo(early, POST, 0); + (uint256 eAmt, uint256 ePos,,) = eng.getUserLotInfo(early, POST, 0); + assertEq(eAmt, 100e18, "4-tuple amount sane"); // 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"); + (, uint256 lPos,,) = eng.getUserLotInfo(late, POST, 0); + assertLt(ePos, lPos, "earlier staker sits ahead in the queue"); // now give the post an opponent and settle once _stake(address(0xBEEF), POST, 1, 1); @@ -100,7 +99,6 @@ contract S09S11S12PoC is Test { 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"); } diff --git a/test/StakeEngineFuzz.t.sol b/test/StakeEngineFuzz.t.sol index 836242c..108a1e3 100644 --- a/test/StakeEngineFuzz.t.sol +++ b/test/StakeEngineFuzz.t.sol @@ -179,7 +179,7 @@ contract StakeEngineFuzzTest is Test { // Weighted position is now the midpoint: cumBefore + amount/2 // Since this is the only lot on this side, cumBefore=0, amount=amt1+amt2 // So wPos = (amt1 + amt2) / 2 - (uint256 amount, uint256 weightedPos,,,) = engine.getUserLotInfo(address(this), postA, 0); + (uint256 amount, uint256 weightedPos,,) = engine.getUserLotInfo(address(this), postA, 0); assertEq(amount, amt1 + amt2, "lot info amount wrong"); uint256 expectedPos = (amt1 + amt2) / 2; diff --git a/test/StakeEngineRescale.t.sol b/test/StakeEngineRescale.t.sol index a51865a..f1279d4 100644 --- a/test/StakeEngineRescale.t.sol +++ b/test/StakeEngineRescale.t.sol @@ -84,7 +84,7 @@ contract StakeEngineRescaleTest is Test { engine.updatePost(postA); // After first snapshot: Bob's position should be < sideTotal - (, uint256 bobPos,, uint256 sideTotal,) = engine.getUserLotInfo(bob, postA, 0); + (, uint256 bobPos, uint256 sideTotal,) = engine.getUserLotInfo(bob, postA, 0); assertLt(bobPos, sideTotal, "Bob's position must be < sideTotal after rescale"); // Second snapshot: now Bob earns because his position is fixed @@ -96,7 +96,7 @@ contract StakeEngineRescaleTest is Test { // but after second snapshot he should have earned assertGt(bobStake, 50 ether, "Bob should earn after rescale takes effect"); - (,,,, uint256 bobWeight) = engine.getUserLotInfo(bob, postA, 0); + (,,, uint256 bobWeight) = engine.getUserLotInfo(bob, postA, 0); assertGt(bobWeight, 0, "Bob's positionWeight should be nonzero"); } @@ -120,8 +120,8 @@ contract StakeEngineRescaleTest is Test { vm.warp(block.timestamp + 2 days); engine.updatePost(postA); - (, uint256 bobPos,,,) = engine.getUserLotInfo(bob, postA, 0); - (, uint256 carolPos,,,) = engine.getUserLotInfo(carol, postA, 0); + (, uint256 bobPos,,) = engine.getUserLotInfo(bob, postA, 0); + (, uint256 carolPos,,) = engine.getUserLotInfo(carol, postA, 0); assertGe(carolPos, bobPos, "ordering preserved: Carol >= Bob"); } @@ -140,8 +140,8 @@ contract StakeEngineRescaleTest is Test { vm.warp(block.timestamp + 2 days); engine.updatePost(postA); - (, uint256 alicePos,,,) = engine.getUserLotInfo(alice, postA, 0); - (, uint256 bobPos,,,) = engine.getUserLotInfo(bob, postA, 0); + (, uint256 alicePos,,) = engine.getUserLotInfo(alice, postA, 0); + (, uint256 bobPos,,) = engine.getUserLotInfo(bob, postA, 0); // With midpoint model, Alice's wPos = her_amount / 2 (she's first in queue). // After epoch gains her amount grew, so wPos = new_amount / 2. // Just verify positions are within sideTotal and properly ordered. @@ -169,14 +169,14 @@ contract StakeEngineRescaleTest is Test { vm.warp(block.timestamp + 2 days); engine.updatePost(postA); - (, uint256 bobPosAfterFirst,,,) = engine.getUserLotInfo(bob, postA, 0); + (, uint256 bobPosAfterFirst,,) = engine.getUserLotInfo(bob, postA, 0); // No new withdrawals — second snapshot shouldn't change position // (well, mints change sideTotal, but positions only rescale if max >= total) vm.warp(block.timestamp + 1 days); engine.updatePost(postA); - (, uint256 bobPosAfterSecond,, uint256 st,) = engine.getUserLotInfo(bob, postA, 0); + (, uint256 bobPosAfterSecond, uint256 st,) = engine.getUserLotInfo(bob, postA, 0); // After first rescale, position < sideTotal. Second snapshot grows sideTotal // further via mints, so position stays bounded. assertLt(bobPosAfterSecond, st, "position still bounded after second snapshot"); @@ -200,7 +200,7 @@ contract StakeEngineRescaleTest is Test { vm.warp(block.timestamp + 2 days); engine.updatePost(postA); - (, uint256 bobPos,, uint256 sideTotal,) = engine.getUserLotInfo(bob, postA, 0); + (, uint256 bobPos, uint256 sideTotal,) = engine.getUserLotInfo(bob, postA, 0); assertLt(bobPos, sideTotal); } @@ -223,8 +223,8 @@ contract StakeEngineRescaleTest is Test { (uint256 s,) = engine.getPostTotals(postA); if (s > 0) { - (, uint256 bobPos,,,) = engine.getUserLotInfo(bob, postA, 0); - (, uint256 carolPos,,,) = engine.getUserLotInfo(carol, postA, 0); + (, uint256 bobPos,,) = engine.getUserLotInfo(bob, postA, 0); + (, uint256 carolPos,,) = engine.getUserLotInfo(carol, postA, 0); assertLt(bobPos, s, "Bob's position bounded by sideTotal"); assertLt(carolPos, s, "Carol's position bounded by sideTotal"); } @@ -302,7 +302,7 @@ contract StakeEngineRescaleTest is Test { assertEq(bobAfter, bobBefore + 50 ether, "stake merge preserved"); (uint256 s,) = engine.getPostTotals(postA); - (, uint256 bobPos,,,) = engine.getUserLotInfo(bob, postA, 0); + (, uint256 bobPos,,) = engine.getUserLotInfo(bob, postA, 0); assertLt(bobPos, s, "Bob's position bounded after merge"); } @@ -327,8 +327,8 @@ contract StakeEngineRescaleTest is Test { engine.updatePost(postA); (, uint256 c) = engine.getPostTotals(postA); - (, uint256 bobPos,,,) = engine.getUserLotInfo(bob, postA, 1); - (, uint256 carolPos,,,) = engine.getUserLotInfo(carol, postA, 1); + (, uint256 bobPos,,) = engine.getUserLotInfo(bob, postA, 1); + (, uint256 carolPos,,) = engine.getUserLotInfo(carol, postA, 1); if (c > 0) { assertLt(bobPos, c, "Bob's challenge position bounded"); @@ -371,13 +371,13 @@ contract StakeEngineRescaleTest is Test { // After snapshot, all positions on both sides must be < sideTotal if (s > 0) { - (, uint256 alicePos,,,) = engine.getUserLotInfo(alice, postA, 0); - (, uint256 bobPos,,,) = engine.getUserLotInfo(bob, postA, 0); + (, uint256 alicePos,,) = engine.getUserLotInfo(alice, postA, 0); + (, uint256 bobPos,,) = engine.getUserLotInfo(bob, postA, 0); assertLt(alicePos, s, "Alice pos bounded"); assertLt(bobPos, s, "Bob pos bounded"); } if (c > 0) { - (, uint256 carolPos,,,) = engine.getUserLotInfo(carol, postA, 1); + (, uint256 carolPos,,) = engine.getUserLotInfo(carol, postA, 1); assertLt(carolPos, c, "Carol pos bounded"); } } From b7dade13c4fbc11a202c9c790765f375a747bc1c Mon Sep 17 00:00:00 2001 From: v0anon Date: Sun, 30 Aug 2026 16:23:28 +0000 Subject: [PATCH 2/3] storage-layout: re-baseline for PR-C (TRACKED_POSTS widening + S-11 entryEpoch removal; pre-genesis, fresh redeploy planned) --- .../storage-layout/baselines/LinkGraph.json | 2 +- .../baselines/PostRegistry.json | 2 +- .../baselines/ProtocolViews.json | 2 +- .../storage-layout/baselines/ScoreEngine.json | 2 +- .../storage-layout/baselines/StakeEngine.json | 36 ++++++++----------- script/storage-layout/baselines/VSPToken.json | 2 +- 6 files changed, 20 insertions(+), 26 deletions(-) diff --git a/script/storage-layout/baselines/LinkGraph.json b/script/storage-layout/baselines/LinkGraph.json index cc78b77..a65e522 100644 --- a/script/storage-layout/baselines/LinkGraph.json +++ b/script/storage-layout/baselines/LinkGraph.json @@ -2,7 +2,7 @@ "_meta": { "contract": "src/LinkGraph.sol:LinkGraph", "forge_version": "forge Version: 1.5.1-stable", - "generated_at": "2026-06-06T22:12:43Z", + "generated_at": "2026-08-30T16:23:27Z", "layout_sha256": "fb03396e84e6a488de35bd3ac65191f1c71af10a6366d27f24a1f5cbcfe50c93", "note": "Storage-layout regression baseline. Regenerate ONLY for an intended, reviewed, upgrade-safe layout change via check.sh --update.", "schema": 2 diff --git a/script/storage-layout/baselines/PostRegistry.json b/script/storage-layout/baselines/PostRegistry.json index cf8fa8a..ea1bf2f 100644 --- a/script/storage-layout/baselines/PostRegistry.json +++ b/script/storage-layout/baselines/PostRegistry.json @@ -2,7 +2,7 @@ "_meta": { "contract": "src/PostRegistry.sol:PostRegistry", "forge_version": "forge Version: 1.5.1-stable", - "generated_at": "2026-06-06T22:12:42Z", + "generated_at": "2026-08-30T16:23:27Z", "layout_sha256": "54702a408a0cec2bab610a395100ff95653cb59246825210f775fee149d21b07", "note": "Storage-layout regression baseline. Regenerate ONLY for an intended, reviewed, upgrade-safe layout change via check.sh --update.", "schema": 2 diff --git a/script/storage-layout/baselines/ProtocolViews.json b/script/storage-layout/baselines/ProtocolViews.json index 0b0f550..fff5a9c 100644 --- a/script/storage-layout/baselines/ProtocolViews.json +++ b/script/storage-layout/baselines/ProtocolViews.json @@ -2,7 +2,7 @@ "_meta": { "contract": "src/ProtocolViews.sol:ProtocolViews", "forge_version": "forge Version: 1.5.1-stable", - "generated_at": "2026-06-06T22:12:43Z", + "generated_at": "2026-08-30T16:23:28Z", "layout_sha256": "7e0dd0a50cb0193f7d9ce96b4adc4beff6b14bc0a39465f60e8bcc67c40ae766", "note": "Storage-layout regression baseline. Regenerate ONLY for an intended, reviewed, upgrade-safe layout change via check.sh --update.", "schema": 2 diff --git a/script/storage-layout/baselines/ScoreEngine.json b/script/storage-layout/baselines/ScoreEngine.json index b425700..7d4120a 100644 --- a/script/storage-layout/baselines/ScoreEngine.json +++ b/script/storage-layout/baselines/ScoreEngine.json @@ -2,7 +2,7 @@ "_meta": { "contract": "src/ScoreEngine.sol:ScoreEngine", "forge_version": "forge Version: 1.5.1-stable", - "generated_at": "2026-06-06T22:12:43Z", + "generated_at": "2026-08-30T16:23:27Z", "layout_sha256": "deda713e12c0c8a08cfc234c2bebb20bcd9ad628aa4420c5ccbb429e1e124c88", "note": "Storage-layout regression baseline. Regenerate ONLY for an intended, reviewed, upgrade-safe layout change via check.sh --update.", "schema": 2 diff --git a/script/storage-layout/baselines/StakeEngine.json b/script/storage-layout/baselines/StakeEngine.json index 942439b..120184e 100644 --- a/script/storage-layout/baselines/StakeEngine.json +++ b/script/storage-layout/baselines/StakeEngine.json @@ -2,8 +2,8 @@ "_meta": { "contract": "src/StakeEngine.sol:StakeEngine", "forge_version": "forge Version: 1.5.1-stable", - "generated_at": "2026-07-02T07:36:32Z", - "layout_sha256": "d6736eac0780134174ef54569c191d6df4ba79690af5da80d673602bfe1b333b", + "generated_at": "2026-08-30T16:23:26Z", + "layout_sha256": "bccba5d07691352c8ea273b11737e2babc2c4a82e44b76a6127f4c326ffa93df", "note": "Storage-layout regression baseline. Regenerate ONLY for an intended, reviewed, upgrade-safe layout change via check.sh --update.", "schema": 2 }, @@ -67,60 +67,60 @@ "label": "topPosts", "offset": 0, "slot": 108, - "type": "t_array(t_struct(TopPost)_storage)3_storage" + "type": "t_array(t_struct(TopPost)_storage)10_storage" }, { "label": "_reentrancyStatus", "offset": 0, - "slot": 114, + "slot": 128, "type": "t_uint256" }, { "label": "sMaxLastUpdatedEpoch", "offset": 0, - "slot": 115, + "slot": 129, "type": "t_uint256" }, { "label": "snapshotPeriod", "offset": 0, - "slot": 116, + "slot": 130, "type": "t_uint256" }, { "label": "sMaxDecayRateRay", "offset": 0, - "slot": 117, + "slot": 131, "type": "t_uint256" }, { "label": "sMaxDecayMaxEpochs", "offset": 0, - "slot": 118, + "slot": 132, "type": "t_uint256" }, { "label": "guardian", "offset": 0, - "slot": 119, + "slot": 133, "type": "t_address" }, { "label": "paused", "offset": 20, - "slot": 119, + "slot": 133, "type": "t_bool" }, { "label": "_initializedV2", "offset": 21, - "slot": 119, + "slot": 133, "type": "t_bool" }, { "label": "__gap", "offset": 0, - "slot": 120, + "slot": 134, "type": "t_array(t_uint256)499_storage" } ], @@ -144,10 +144,10 @@ "encoding": "dynamic_array", "numberOfBytes": "32" }, - "t_array(t_struct(TopPost)_storage)3_storage": { + "t_array(t_struct(TopPost)_storage)10_storage": { "base": "t_struct(TopPost)_storage", "encoding": "inplace", - "numberOfBytes": "192" + "numberOfBytes": "640" }, "t_array(t_uint256)100_storage": { "base": "t_uint256", @@ -303,15 +303,9 @@ "offset": 0, "slot": 3, "type": "t_uint256" - }, - { - "label": "entryEpoch", - "offset": 0, - "slot": 4, - "type": "t_uint256" } ], - "numberOfBytes": "160" + "numberOfBytes": "128" }, "t_struct(TopPost)_storage": { "encoding": "inplace", diff --git a/script/storage-layout/baselines/VSPToken.json b/script/storage-layout/baselines/VSPToken.json index cce5e80..d23c529 100644 --- a/script/storage-layout/baselines/VSPToken.json +++ b/script/storage-layout/baselines/VSPToken.json @@ -2,7 +2,7 @@ "_meta": { "contract": "src/VSPToken.sol:VSPToken", "forge_version": "forge Version: 1.5.1-stable", - "generated_at": "2026-06-06T22:12:42Z", + "generated_at": "2026-08-30T16:23:26Z", "layout_sha256": "0b1448c0be9927bec521781557328057cb4d47f80f1b44871f099ea3eeac372c", "note": "Storage-layout regression baseline. Regenerate ONLY for an intended, reviewed, upgrade-safe layout change via check.sh --update.", "schema": 2 From d4f8d320a18603eb16c74c80388261b2372c2e9c Mon Sep 17 00:00:00 2001 From: v0anon Date: Sun, 30 Aug 2026 16:30:59 +0000 Subject: [PATCH 3/3] =?UTF-8?q?slither:=20accept=20PR-C=20fingerprints=20(?= =?UTF-8?q?refreshSMax=20reentrancy-benign/no-eth=20=E2=80=94=20nonReentra?= =?UTF-8?q?nt=20+=20own-token,=20same=20class=20as=20stake/withdraw;=20=5F?= =?UTF-8?q?projectBucket=20divide-before-multiply=20=E2=80=94=20intentiona?= =?UTF-8?q?l=20truncation-order=20mirror=20of=20=5FsettleBucket=20for=20V.?= =?UTF-8?q?8)=20+=20prune=20fingerprints=20of=20removed=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- script/slither/slither-baseline.json | 202 ++++++++++++++++----------- 1 file changed, 117 insertions(+), 85 deletions(-) diff --git a/script/slither/slither-baseline.json b/script/slither/slither-baseline.json index fa76274..05ff215 100644 --- a/script/slither/slither-baseline.json +++ b/script/slither/slither-baseline.json @@ -1,9 +1,9 @@ { "by_impact": { "High": 0, - "Informational": 50, - "Low": 37, - "Medium": 30, + "Informational": 42, + "Low": 36, + "Medium": 34, "Optimization": 2 }, "findings": [ @@ -29,7 +29,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:524" + "location": "src/StakeEngine.sol:541" }, { "check": "reentrancy-no-eth", @@ -37,7 +37,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:524" + "location": "src/StakeEngine.sol:541" }, { "check": "reentrancy-no-eth", @@ -45,7 +45,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:524" + "location": "src/StakeEngine.sol:541" }, { "check": "reentrancy-no-eth", @@ -53,7 +53,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:524" + "location": "src/StakeEngine.sol:541" }, { "check": "reentrancy-no-eth", @@ -61,7 +61,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:524" + "location": "src/StakeEngine.sol:541" }, { "check": "reentrancy-no-eth", @@ -69,7 +69,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:524" + "location": "src/StakeEngine.sol:541" }, { "check": "reentrancy-no-eth", @@ -77,7 +77,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:524" + "location": "src/StakeEngine.sol:541" }, { "check": "reentrancy-no-eth", @@ -85,7 +85,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:524" + "location": "src/StakeEngine.sol:541" }, { "check": "reentrancy-no-eth", @@ -93,7 +93,7 @@ "element": "setStake", "fingerprint": "0bcb495258c8f03a", "impact": "Medium", - "location": "src/StakeEngine.sol:524" + "location": "src/StakeEngine.sol:541" }, { "check": "divide-before-multiply", @@ -101,7 +101,7 @@ "element": "_projectTotals", "fingerprint": "1176ec9c82beef04", "impact": "Medium", - "location": "src/StakeEngine.sol:796" + "location": "src/StakeEngine.sol:782" }, { "check": "divide-before-multiply", @@ -117,7 +117,15 @@ "element": "_settleBucket", "fingerprint": "136d4d02ff0927b2", "impact": "Medium", - "location": "src/StakeEngine.sol:1264" + "location": "src/StakeEngine.sol:1325" + }, + { + "check": "incorrect-equality", + "confidence": "High", + "element": "_settleBucket", + "fingerprint": "136d4d02ff0927b2", + "impact": "Medium", + "location": "src/StakeEngine.sol:1325" }, { "check": "incorrect-equality", @@ -125,7 +133,7 @@ "element": "_projectLotValue", "fingerprint": "160f822661d43b1c", "impact": "Medium", - "location": "src/StakeEngine.sol:865" + "location": "src/StakeEngine.sol:854" }, { "check": "divide-before-multiply", @@ -133,7 +141,7 @@ "element": "_settleBucket", "fingerprint": "16e0738556de802c", "impact": "Medium", - "location": "src/StakeEngine.sol:1264" + "location": "src/StakeEngine.sol:1325" }, { "check": "reentrancy-events", @@ -149,7 +157,7 @@ "element": "_projectLotValue", "fingerprint": "232dac1e92878286", "impact": "Low", - "location": "src/StakeEngine.sol:865" + "location": "src/StakeEngine.sol:854" }, { "check": "reentrancy-events", @@ -165,7 +173,15 @@ "element": "_maybeSnapshot", "fingerprint": "295a69aafc4220d2", "impact": "Low", - "location": "src/StakeEngine.sol:591" + "location": "src/StakeEngine.sol:608" + }, + { + "check": "incorrect-equality", + "confidence": "High", + "element": "_projectBucket", + "fingerprint": "29f8e8529df820c6", + "impact": "Medium", + "location": "src/StakeEngine.sol:1284" }, { "check": "incorrect-equality", @@ -173,7 +189,7 @@ "element": "_projectBucket", "fingerprint": "29f8e8529df820c6", "impact": "Medium", - "location": "src/StakeEngine.sol:1239" + "location": "src/StakeEngine.sol:1284" }, { "check": "calls-loop", @@ -197,7 +213,7 @@ "element": "stake", "fingerprint": "2bc8456d5b7492a2", "impact": "Medium", - "location": "src/StakeEngine.sol:447" + "location": "src/StakeEngine.sol:464" }, { "check": "reentrancy-no-eth", @@ -205,7 +221,7 @@ "element": "stake", "fingerprint": "2bc8456d5b7492a2", "impact": "Medium", - "location": "src/StakeEngine.sol:447" + "location": "src/StakeEngine.sol:464" }, { "check": "naming-convention", @@ -221,7 +237,7 @@ "element": "_msgData", "fingerprint": "35aff08eb8b0c63d", "impact": "Informational", - "location": "src/governance/GovernedUpgradeable.sol:81" + "location": "src/governance/GovernedUpgradeable.sol:82" }, { "check": "timestamp", @@ -237,7 +253,7 @@ "element": "_applySMaxDecay", "fingerprint": "38b7a222c16b58ec", "impact": "Informational", - "location": "src/StakeEngine.sol:984" + "location": "src/StakeEngine.sol:994" }, { "check": "costly-loop", @@ -245,7 +261,7 @@ "element": "_applySMaxDecay", "fingerprint": "38b7a222c16b58ec", "impact": "Informational", - "location": "src/StakeEngine.sol:984" + "location": "src/StakeEngine.sol:994" }, { "check": "costly-loop", @@ -253,7 +269,7 @@ "element": "_applySMaxDecay", "fingerprint": "38b7a222c16b58ec", "impact": "Informational", - "location": "src/StakeEngine.sol:984" + "location": "src/StakeEngine.sol:994" }, { "check": "timestamp", @@ -261,7 +277,7 @@ "element": "_applyEpoch", "fingerprint": "3c84c785fc3a4a9e", "impact": "Low", - "location": "src/StakeEngine.sol:726" + "location": "src/StakeEngine.sol:712" }, { "check": "divide-before-multiply", @@ -269,7 +285,7 @@ "element": "_forceSnapshot", "fingerprint": "409b648f0a57d48f", "impact": "Medium", - "location": "src/StakeEngine.sol:607" + "location": "src/StakeEngine.sol:624" }, { "check": "missing-zero-check", @@ -285,7 +301,7 @@ "element": "withdraw", "fingerprint": "47ec8394947a0821", "impact": "Medium", - "location": "src/StakeEngine.sol:478" + "location": "src/StakeEngine.sol:495" }, { "check": "naming-convention", @@ -301,7 +317,7 @@ "element": "_forceSnapshot", "fingerprint": "4c1f738ab81c6ecc", "impact": "Medium", - "location": "src/StakeEngine.sol:607" + "location": "src/StakeEngine.sol:624" }, { "check": "reentrancy-benign", @@ -309,7 +325,7 @@ "element": "withdraw", "fingerprint": "54b347d868f44e5d", "impact": "Low", - "location": "src/StakeEngine.sol:478" + "location": "src/StakeEngine.sol:495" }, { "check": "reentrancy-benign", @@ -317,7 +333,7 @@ "element": "_forceSnapshot", "fingerprint": "558c6d1b47de3929", "impact": "Low", - "location": "src/StakeEngine.sol:607" + "location": "src/StakeEngine.sol:624" }, { "check": "timestamp", @@ -325,7 +341,7 @@ "element": "_applySMaxDecay", "fingerprint": "55a3a4425ec9ad04", "impact": "Low", - "location": "src/StakeEngine.sol:984" + "location": "src/StakeEngine.sol:994" }, { "check": "unused-state", @@ -341,7 +357,7 @@ "element": "getPostTotals", "fingerprint": "5781a6a7a7e41f03", "impact": "Low", - "location": "src/StakeEngine.sol:361" + "location": "src/StakeEngine.sol:382" }, { "check": "constable-states", @@ -359,6 +375,14 @@ "impact": "Low", "location": "src/PostRegistry.sol:170" }, + { + "check": "dead-code", + "confidence": "Medium", + "element": "_projectSMaxDecay", + "fingerprint": "5eb3dec4560ad7be", + "impact": "Informational", + "location": "src/StakeEngine.sol:1048" + }, { "check": "naming-convention", "confidence": "High", @@ -389,7 +413,7 @@ "element": "setStake", "fingerprint": "63ab651bac26de93", "impact": "Low", - "location": "src/StakeEngine.sol:524" + "location": "src/StakeEngine.sol:541" }, { "check": "reentrancy-benign", @@ -397,7 +421,7 @@ "element": "setStake", "fingerprint": "63ab651bac26de93", "impact": "Low", - "location": "src/StakeEngine.sol:524" + "location": "src/StakeEngine.sol:541" }, { "check": "reentrancy-benign", @@ -405,7 +429,7 @@ "element": "setStake", "fingerprint": "63ab651bac26de93", "impact": "Low", - "location": "src/StakeEngine.sol:524" + "location": "src/StakeEngine.sol:541" }, { "check": "timestamp", @@ -413,7 +437,7 @@ "element": "_projectSMaxDecay", "fingerprint": "66754b062ff1f03d", "impact": "Low", - "location": "src/StakeEngine.sol:1036" + "location": "src/StakeEngine.sol:1048" }, { "check": "constable-states", @@ -429,7 +453,7 @@ "element": "_projectBucket", "fingerprint": "691309b6162d9328", "impact": "Low", - "location": "src/StakeEngine.sol:1239" + "location": "src/StakeEngine.sol:1284" }, { "check": "reentrancy-events", @@ -447,21 +471,13 @@ "impact": "Informational", "location": "src/VSPToken.sol:51" }, - { - "check": "missing-inheritance", - "confidence": "High", - "element": "VSPToken", - "fingerprint": "7cee711575888e72", - "impact": "Informational", - "location": "src/VSPToken.sol:15" - }, { "check": "naming-convention", "confidence": "High", "element": "__gap", "fingerprint": "7d2b7acea2f7071e", "impact": "Informational", - "location": "src/StakeEngine.sol:1457" + "location": "src/StakeEngine.sol:1530" }, { "check": "missing-zero-check", @@ -477,7 +493,7 @@ "element": "_projectSideTotal", "fingerprint": "81bb502f4ce4f0a8", "impact": "Medium", - "location": "src/StakeEngine.sol:832" + "location": "src/StakeEngine.sol:821" }, { "check": "naming-convention", @@ -509,7 +525,7 @@ "element": "_addOrMergeLot", "fingerprint": "8d43dcb8be3d68a6", "impact": "Informational", - "location": "src/StakeEngine.sol:773" + "location": "src/StakeEngine.sol:759" }, { "check": "costly-loop", @@ -517,7 +533,7 @@ "element": "_updateSMax", "fingerprint": "8eef076714f26a9c", "impact": "Informational", - "location": "src/StakeEngine.sol:928" + "location": "src/StakeEngine.sol:918" }, { "check": "costly-loop", @@ -525,7 +541,7 @@ "element": "_updateSMax", "fingerprint": "8eef076714f26a9c", "impact": "Informational", - "location": "src/StakeEngine.sol:928" + "location": "src/StakeEngine.sol:918" }, { "check": "costly-loop", @@ -533,7 +549,7 @@ "element": "_updateSMax", "fingerprint": "8eef076714f26a9c", "impact": "Informational", - "location": "src/StakeEngine.sol:928" + "location": "src/StakeEngine.sol:918" }, { "check": "costly-loop", @@ -541,7 +557,7 @@ "element": "_updateSMax", "fingerprint": "8eef076714f26a9c", "impact": "Informational", - "location": "src/StakeEngine.sol:928" + "location": "src/StakeEngine.sol:918" }, { "check": "costly-loop", @@ -549,15 +565,7 @@ "element": "_updateSMax", "fingerprint": "8eef076714f26a9c", "impact": "Informational", - "location": "src/StakeEngine.sol:928" - }, - { - "check": "costly-loop", - "confidence": "Medium", - "element": "_updateSMax", - "fingerprint": "8eef076714f26a9c", - "impact": "Informational", - "location": "src/StakeEngine.sol:928" + "location": "src/StakeEngine.sol:918" }, { "check": "calls-loop", @@ -583,6 +591,14 @@ "impact": "Medium", "location": "src/PostRegistry.sol:227" }, + { + "check": "divide-before-multiply", + "confidence": "Medium", + "element": "_projectBucket", + "fingerprint": "95b45653cb79b433", + "impact": "Medium", + "location": "src/StakeEngine.sol:1284" + }, { "check": "calls-loop", "confidence": "Medium", @@ -597,7 +613,7 @@ "element": "__gap", "fingerprint": "989231729e0f9bd4", "impact": "Informational", - "location": "src/StakeEngine.sol:1457" + "location": "src/StakeEngine.sol:1530" }, { "check": "divide-before-multiply", @@ -605,7 +621,7 @@ "element": "_applyEpoch", "fingerprint": "98ff1344e4051ab1", "impact": "Medium", - "location": "src/StakeEngine.sol:726" + "location": "src/StakeEngine.sol:712" }, { "check": "missing-zero-check", @@ -621,7 +637,7 @@ "element": "guardian_", "fingerprint": "9b2ea80b89bd3dc8", "impact": "Low", - "location": "src/StakeEngine.sol:284" + "location": "src/StakeEngine.sol:303" }, { "check": "naming-convention", @@ -629,7 +645,7 @@ "element": "ERC20_TOKEN", "fingerprint": "a4cbf01868eacfce", "impact": "Informational", - "location": "src/StakeEngine.sol:66" + "location": "src/StakeEngine.sol:74" }, { "check": "timestamp", @@ -637,7 +653,7 @@ "element": "_forceSnapshot", "fingerprint": "a8b99e3f7d4cd22a", "impact": "Low", - "location": "src/StakeEngine.sol:607" + "location": "src/StakeEngine.sol:624" }, { "check": "unused-state", @@ -647,6 +663,14 @@ "impact": "Informational", "location": "src/PostRegistry.sol:384" }, + { + "check": "reentrancy-benign", + "confidence": "Medium", + "element": "refreshSMax", + "fingerprint": "b07d4159aff96b88", + "impact": "Low", + "location": "src/StakeEngine.sol:986" + }, { "check": "naming-convention", "confidence": "High", @@ -661,7 +685,7 @@ "element": "getUserLotInfo", "fingerprint": "b6ca2733f200d8fe", "impact": "Low", - "location": "src/StakeEngine.sol:399" + "location": "src/StakeEngine.sol:422" }, { "check": "timestamp", @@ -669,7 +693,7 @@ "element": "getUserStake", "fingerprint": "b83ace379b416a73", "impact": "Low", - "location": "src/StakeEngine.sol:373" + "location": "src/StakeEngine.sol:394" }, { "check": "naming-convention", @@ -693,7 +717,7 @@ "element": "newGuardian", "fingerprint": "c0af0203d581f6e1", "impact": "Low", - "location": "src/StakeEngine.sol:312" + "location": "src/StakeEngine.sol:331" }, { "check": "unused-state", @@ -725,7 +749,7 @@ "element": "_applyEpoch", "fingerprint": "d22932c07ff62734", "impact": "Medium", - "location": "src/StakeEngine.sol:726" + "location": "src/StakeEngine.sol:712" }, { "check": "incorrect-equality", @@ -733,7 +757,7 @@ "element": "_applyEpoch", "fingerprint": "d22932c07ff62734", "impact": "Medium", - "location": "src/StakeEngine.sol:726" + "location": "src/StakeEngine.sol:712" }, { "check": "naming-convention", @@ -741,7 +765,7 @@ "element": "__gap", "fingerprint": "d2ccfcfaa958e05f", "impact": "Informational", - "location": "src/governance/GovernedUpgradeable.sol:89" + "location": "src/governance/GovernedUpgradeable.sol:90" }, { "check": "naming-convention", @@ -757,7 +781,7 @@ "element": "_projectSideTotal", "fingerprint": "d3d7859eec6e6003", "impact": "Low", - "location": "src/StakeEngine.sol:832" + "location": "src/StakeEngine.sol:821" }, { "check": "unused-state", @@ -773,7 +797,7 @@ "element": "setStake", "fingerprint": "dc1768351561c2fc", "impact": "Informational", - "location": "src/StakeEngine.sol:524" + "location": "src/StakeEngine.sol:541" }, { "check": "unused-state", @@ -783,13 +807,21 @@ "impact": "Informational", "location": "src/LinkGraph.sol:135" }, + { + "check": "reentrancy-no-eth", + "confidence": "Medium", + "element": "refreshSMax", + "fingerprint": "de33671b0b3b0d76", + "impact": "Medium", + "location": "src/StakeEngine.sol:986" + }, { "check": "divide-before-multiply", "confidence": "Medium", "element": "_bucketRemove", "fingerprint": "e099e66a54a4b768", "impact": "Medium", - "location": "src/StakeEngine.sol:1122" + "location": "src/StakeEngine.sol:1144" }, { "check": "naming-convention", @@ -797,7 +829,7 @@ "element": "VSP_TOKEN", "fingerprint": "e4a2c54c93cad2ce", "impact": "Informational", - "location": "src/StakeEngine.sol:67" + "location": "src/StakeEngine.sol:75" }, { "check": "cyclomatic-complexity", @@ -805,7 +837,7 @@ "element": "_updateSMax", "fingerprint": "e8f294563e30e6d4", "impact": "Informational", - "location": "src/StakeEngine.sol:928" + "location": "src/StakeEngine.sol:918" }, { "check": "reentrancy-benign", @@ -813,7 +845,7 @@ "element": "stake", "fingerprint": "e9daed0a65e92ae4", "impact": "Low", - "location": "src/StakeEngine.sol:447" + "location": "src/StakeEngine.sol:464" }, { "check": "divide-before-multiply", @@ -821,7 +853,7 @@ "element": "_projectLotValue", "fingerprint": "ea7d30779fe16e19", "impact": "Medium", - "location": "src/StakeEngine.sol:865" + "location": "src/StakeEngine.sol:854" }, { "check": "divide-before-multiply", @@ -829,7 +861,7 @@ "element": "_projectLotValue", "fingerprint": "ea7d30779fe16e19", "impact": "Medium", - "location": "src/StakeEngine.sol:865" + "location": "src/StakeEngine.sol:854" }, { "check": "unused-state", @@ -861,7 +893,7 @@ "element": "_projectSideTotal", "fingerprint": "fa189611f576492b", "impact": "Medium", - "location": "src/StakeEngine.sol:832" + "location": "src/StakeEngine.sol:821" }, { "check": "timestamp", @@ -869,7 +901,7 @@ "element": "_settleBucket", "fingerprint": "fabc5d90b8bd34ae", "impact": "Low", - "location": "src/StakeEngine.sol:1264" + "location": "src/StakeEngine.sol:1325" }, { "check": "missing-zero-check", @@ -888,7 +920,7 @@ "location": "src/VSPToken.sol:193" } ], - "generated_utc": "2026-08-20T15:44:20.239425+00:00", + "generated_utc": "2026-08-30T16:30:59.451623+00:00", "scope": "src (excludes lib,test,script)", - "total": 119 + "total": 114 }