Skip to content

Commit 6b29456

Browse files
committed
fix(sra): prevent activeQ regression and align remove exclusion with the share map
Two review findings (PR filecoin-project#24 review round 2): B1 (blocking): correctVolume/postVolume could regress activeQ. The advance guard assumed the mirror only moves forward, but the constructor did not enforce non-overlapping windows (POST + VERIFY <= EPOCHS). With overlapping windows a governance call to correctVolume(older q) legitimately fell inside the still-open verification window and _advanceMirror rewound activeQ, clearing the newer quarter's posted FPV, re-enabling duplicate posts and double-counting totalUsd. - constructor: require(postPeriod + verificationWindow <= epochsPerQuarter) to eliminate overlapping windows at the configuration level; - _assertMirrorWindow: q must be activeQ or activeQ + 1 (write entry semantics - a write can only target the current or the next quarter; skipping quarters would misalign prevFpv, same family as the A3 fix). Applied before the advance in postVolume and correctVolume as defense in depth. S1 (should fix): remove deducted totalUsd after the quarter was bound, drifting the aggregatedFPV historical snapshot. The exclusion boundary must mirror the share map collection, not the freeze boundary: remove drops the orchestrator from the admitted list (so the map no longer includes it), while freeze keeps it. Deduct only while the quarter is not yet bound (!_afterBinding): posting-window and verification-window removals both exclude the FPV from the aggregate, keeping aggregatedFPV == map sum; after binding the snapshot stays fixed. Regression tests (Red -> Green): - Ctor_WindowOverlap_Rejected / Ctor_WindowBoundary_Accepted - PostVolume_SkipQuarter_Reverts / CorrectVolume_SkipQuarter_Reverts - Remove_InPostingWindow_DeductsAggregate - Remove_InVerificationWindow_Excludes (probe scenario formalised) - Remove_AfterBinding_KeepsSnapshot 311 tests pass (17 suites incl. invariant fuzz); 3 seeds; halmos 2/2.
1 parent fb940aa commit 6b29456

3 files changed

Lines changed: 170 additions & 6 deletions

File tree

src/ServiceRewardsActor.sol

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,11 @@ contract ServiceRewardsActor is UnanimousGovernance {
165165
// deployment-time parameter validation, aligned with setPricingParams
166166
require(priceBand <= BASIS_POINTS, InvalidParameter());
167167
require(epochsPerQuarter > 0 && postPeriod > 0 && verificationWindow > 0, InvalidParameter());
168+
// Review B1: the mirror advances only forward, so the windows must not overlap — a quarter's
169+
// verification window closing after the next quarter has begun would let a governance
170+
// CorrectVolume target an already-advanced quarter, rewinding activeQ (uint256 intermediate
171+
// guards the addition against overflow).
172+
require(uint256(postPeriod) + uint256(verificationWindow) <= uint256(epochsPerQuarter), InvalidParameter());
168173

169174
EPOCHS_PER_QUARTER = Epoch.wrap(epochsPerQuarter);
170175
POST_PERIOD = Epoch.wrap(postPeriod);
@@ -237,6 +242,18 @@ contract ServiceRewardsActor is UnanimousGovernance {
237242
return (false, 0);
238243
}
239244

245+
/// @dev Mirror advance guard (review B1): a write may target the active quarter (q == activeQ,
246+
/// same-quarter updates) or the next quarter (q == activeQ + 1 — the first write of a new
247+
/// quarter advances the mirror). Anything else is rejected: q < activeQ would rewind the
248+
/// mirror (backing up and clearing a later quarter's contributions — possible when the
249+
/// windows overlap, hence also forbidden at the constructor), and q > activeQ + 1 would
250+
/// skip a quarter, misaligning the prevFpv mirror (it can only hold activeQ - 1's data).
251+
/// The window checks already bound q in a non-overlapping configuration; this guard is
252+
/// defense-in-depth if the deployment parameters ever change.
253+
function _assertMirrorWindow(SraStorage.SraStorageQuarter storage qt, uint64 q) internal view {
254+
require(q == qt.activeQ || q == qt.activeQ + 1, InvalidParameter());
255+
}
256+
240257
/// @dev Mirror advance: the first write of a new quarter (postVolume or correctVolume with
241258
/// q != activeQ) backs the active-quarter contributions up into the previous-quarter
242259
/// mirror — exclusion-fixed (frozenAtPostEnd ? 0 : fpv), because the freeze state of the
@@ -303,6 +320,7 @@ contract ServiceRewardsActor is UnanimousGovernance {
303320
// Mirror advance on the first write of the quarter (q == activeQ afterwards; the
304321
// previous quarter's contributions back up into prevFpv, exclusion-fixed). The advance
305322
// clears the active slot, so the already-posted check below is against the new quarter.
323+
_assertMirrorWindow(qt, q);
306324
if (qt.activeQ != q) _advanceMirror(qt, q);
307325
require(FixedU18.unwrap(o.fpv) == 0, AlreadyPosted(q));
308326
o.fpv = fpv; // FixedU18 — 18-decimal USD, type-checked from the entry
@@ -349,11 +367,17 @@ contract ServiceRewardsActor is UnanimousGovernance {
349367
require(o.admitted, NotAdmitted(orch));
350368
(bool hasPending, uint64 pendingQ) = _pendingSharesQuarter();
351369
if (hasPending) revert PendingShares(pendingQ);
352-
// Mirror: drop the active-quarter contribution if it is still effective — a freeze
353-
// before E+POST already deducted it (frozenAtPostEnd), so no double deduction.
354-
if (!o.frozenAtPostEnd && FixedU18.unwrap(o.fpv) > 0) {
355-
SraStorage.SraStorageQuarter storage qt = _quarter();
356-
qt.totalUsd[qt.activeQ] = qt.totalUsd[qt.activeQ] - o.fpv;
370+
// Mirror: drop the active-quarter contribution from the aggregate while the quarter is not
371+
// yet bound — an orchestrator removed before binding is excluded: omitted from the
372+
// submitted share map (it leaves the admitted list, which submitShares collects) and its
373+
// FPV does not enter AggregatedFPV(Q) (spec §2.2). Once the verification window has closed
374+
// the aggregate is a binding snapshot (the read view exposes the bound values directly) and
375+
// a later removal must not rewrite it. The boundary is binding (not E+POST — freeze's
376+
// boundary): unlike freeze, removal drops the orchestrator from the admitted list, so the
377+
// map and the aggregate must exclude it together for every pre-binding removal (review S1).
378+
uint64 q = _quarter().activeQ;
379+
if (!_afterBinding(q) && !o.frozenAtPostEnd && FixedU18.unwrap(o.fpv) > 0) {
380+
_quarter().totalUsd[q] = _quarter().totalUsd[q] - o.fpv;
357381
}
358382
o.admitted = false;
359383
o.frozenSince = Epoch.wrap(0);
@@ -518,7 +542,10 @@ contract ServiceRewardsActor is UnanimousGovernance {
518542
SraStorage.SraStorageQuarter storage qt = _quarter();
519543

520544
// Mirror advance on the first write of the quarter — correctVolume can be the first
521-
// writer (supplying recomputed figures for a quarter nobody posted).
545+
// writer (supplying recomputed figures for a quarter nobody posted). Bounded by the
546+
// window guard: q must be the active or the next quarter (rewinding would clear a later
547+
// quarter's contributions — review B1).
548+
_assertMirrorWindow(qt, q);
522549
if (qt.activeQ != q) _advanceMirror(qt, q);
523550

524551
// Read the old value *after* the advance: on an advance the previous

test/SRAAdversarial.t.sol

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,72 @@ contract SRAAdversarial is SRATestBase {
331331
// helpers
332332
// ------------------------------------------------------------------------
333333

334+
// ------------------------------------------------------------------------
335+
// Review B1: mirror-advance direction guard + window-overlap constructor constraint
336+
// ------------------------------------------------------------------------
337+
338+
/// Constructor rejects window overlap: a quarter's verification window must close before the
339+
/// next quarter begins (POST + VERIFY <= EPOCHS), otherwise a governance CorrectVolume could
340+
/// target an already-advanced quarter and rewind the mirror (review B1).
341+
function test_Ctor_WindowOverlap_Rejected() public {
342+
vm.expectRevert(abi.encodeWithSelector(ServiceRewardsActor.InvalidParameter.selector));
343+
new ServiceRewardsActor(
344+
owner1,
345+
owner2,
346+
500, // EPOCHS
347+
300, // POST
348+
400, // VERIFY: 300 + 400 = 700 > 500 -> overlap
349+
SRA_CANCEL_HOLD,
350+
ACTIVATION_EPOCH,
351+
MIN_LOT,
352+
PRICE_BAND
353+
);
354+
}
355+
356+
/// Constructor accepts the exact boundary: POST + VERIFY == EPOCHS (verification closes at the
357+
/// next quarter's boundary — no overlap, the mirror stays forward-only).
358+
function test_Ctor_WindowBoundary_Accepted() public {
359+
// deployment succeeded (no revert) — the boundary POST + VERIFY == EPOCHS is accepted
360+
ServiceRewardsActor tight = new ServiceRewardsActor(
361+
owner1,
362+
owner2,
363+
700, // EPOCHS
364+
300, // POST
365+
400, // VERIFY: 300 + 400 = 700 == EPOCHS -> accepted
366+
SRA_CANCEL_HOLD,
367+
ACTIVATION_EPOCH,
368+
MIN_LOT,
369+
PRICE_BAND
370+
);
371+
}
372+
373+
/// A write must target the active or the next quarter: skipping a quarter (q > activeQ + 1)
374+
/// would misalign the prevFpv mirror (it can only hold activeQ - 1's data) — rejected by the
375+
/// mirror-window guard (review B1).
376+
function test_PostVolume_SkipQuarter_Reverts() public {
377+
address orch = makeAddr("orch");
378+
_admit(orch);
379+
380+
vm.roll(_qEnd(2) + 1); // Q2 posting window; mirror still at genesis activeQ = 0
381+
vm.prank(orch);
382+
vm.expectRevert(abi.encodeWithSelector(ServiceRewardsActor.InvalidParameter.selector));
383+
sra.postVolume(2, FixedU18.wrap(_fpv(100e18)));
384+
}
385+
386+
/// Same guard on the governance path: CorrectVolume may not target a quarter beyond the next
387+
/// one (unanimousNoHold: the second approval executes the body and reverts).
388+
function test_CorrectVolume_SkipQuarter_Reverts() public {
389+
address orch = makeAddr("orch");
390+
_admit(orch);
391+
392+
vm.roll(_qEnd(2) + POST_PERIOD + 1); // Q2 verification window; activeQ still 0
393+
vm.prank(owner1);
394+
sra.correctVolume(orch, 2, FixedU18.wrap(_fpv(100e18)));
395+
vm.prank(owner2);
396+
vm.expectRevert(abi.encodeWithSelector(ServiceRewardsActor.InvalidParameter.selector));
397+
sra.correctVolume(orch, 2, FixedU18.wrap(_fpv(100e18)));
398+
}
399+
334400
function _sumShares(Share[] memory shares) internal pure returns (uint256 sum) {
335401
for (uint256 i = 0; i < shares.length; i++) {
336402
sum += shares[i].share;

test/SRAggregateMirror.t.sol

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,4 +395,75 @@ contract SRAggregateMirrorTest is SRATestBase {
395395
assertEq(shares[0].wallet, a, "map still the q1 distribution");
396396
assertEq(FixedU18.unwrap(sra.aggregatedFPV(2)), 0, "q2 has no contributions");
397397
}
398+
399+
/// Review S1: an orchestrator removed before the close of the posting period is excluded —
400+
/// its FPV does not enter AggregatedFPV(Q) (spec §2.2). With activeQ still in its posting
401+
/// window the removal deducts the contribution from the aggregate (read once the quarter binds).
402+
function test_Mirror_Remove_InPostingWindow_DeductsAggregate() public {
403+
address a = makeAddr("a");
404+
address b = makeAddr("b");
405+
_admit(a);
406+
_admit(b);
407+
408+
vm.roll(_qEnd(0) + 1); // Q0 posting window
409+
_postAs(a, 0, _fpv(100e18));
410+
_postAs(b, 0, _fpv(200e18));
411+
412+
_remove(b); // still within E+POST (hold 100 < POST 300): contribution excluded
413+
414+
vm.roll(_qVerifyEnd(0) + 1); // Q0 binds — aggregate readable
415+
assertEq(FixedU18.unwrap(sra.aggregatedFPV(0)), 100e18, "removed pre-E+POST FPV excluded");
416+
}
417+
418+
/// Review S1: once the verification window closes, AggregatedFPV(activeQ) is a fixed binding
419+
/// snapshot (spec §2.2: the read view exposes the bound values directly). A removal after
420+
/// binding must not rewrite it — only a pre-E+POST removal excludes the contribution. The
421+
/// former code deducted totalUsd unconditionally, drifting the bound aggregate.
422+
function test_Mirror_Remove_AfterBinding_KeepsSnapshot() public {
423+
address a = makeAddr("a");
424+
address b = makeAddr("b");
425+
_admit(a);
426+
_admit(b);
427+
428+
vm.roll(_qEnd(0) + 1); // Q0 posting window
429+
_postAs(a, 0, _fpv(100e18));
430+
_postAs(b, 0, _fpv(200e18));
431+
432+
vm.roll(_qVerifyEnd(0) + 1); // Q0 binds
433+
sra.submitShares(0);
434+
Share[] memory shares = rewardActor().getShares(SERVICE_STREAM_ID);
435+
assertEq(shares.length, 2, "both contributors in the bound map");
436+
assertEq(FixedU18.unwrap(sra.aggregatedFPV(0)), 300e18, "bound aggregate");
437+
438+
_remove(b); // post-binding removal: aggregate is a binding snapshot, must not drift
439+
assertEq(FixedU18.unwrap(sra.aggregatedFPV(0)), 300e18, "bound aggregate unchanged after removal");
440+
shares = rewardActor().getShares(SERVICE_STREAM_ID);
441+
assertEq(shares.length, 2, "submitted map stands (removal does not rewrite a submitted map)");
442+
}
443+
444+
/// Review S1: a removal in the verification window (E+POST passed, not yet bound) must exclude
445+
/// the orchestrator from BOTH the share map (it leaves the admitted list, which submitShares
446+
/// collects) and the aggregate — otherwise aggregatedFPV(0) = 300 != map sum 100. The former
447+
/// E+POST boundary (freeze's) left the aggregate at 300 while the map dropped b (probe).
448+
function test_Mirror_Remove_InVerificationWindow_Excludes() public {
449+
address a = makeAddr("a");
450+
address b = makeAddr("b");
451+
_admit(a);
452+
_admit(b);
453+
454+
vm.roll(_qEnd(0) + 1); // Q0 posting window
455+
_postAs(a, 0, _fpv(100e18));
456+
_postAs(b, 0, _fpv(200e18));
457+
458+
vm.roll(_qPostEnd(0) + 1); // Q0 verification window (E+POST+1); the 100-epoch hold keeps the
459+
// executing removal inside the window (E+401 < E+700), not yet bound
460+
_remove(b);
461+
462+
vm.roll(_qVerifyEnd(0) + 1); // Q0 binds
463+
sra.submitShares(0);
464+
Share[] memory shares = rewardActor().getShares(SERVICE_STREAM_ID);
465+
assertEq(shares.length, 1, "removed orchestrator absent from the map");
466+
assertEq(shares[0].wallet, a, "map = a only");
467+
assertEq(FixedU18.unwrap(sra.aggregatedFPV(0)), 100e18, "removed FPV excluded -- consistent with map");
468+
}
398469
}

0 commit comments

Comments
 (0)