Skip to content

Commit f615201

Browse files
committed
fix: correct distribution end epoch calculation and improve button pressability check
Fix an off-by-one error where distributions were ending one epoch too late. A distribution with totalEpochs=N should run for exactly N epochs, not N+1.
1 parent 7723fd6 commit f615201

File tree

2 files changed

+201
-12
lines changed

2 files changed

+201
-12
lines changed

src/contracts/core/EmissionsController.sol

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,12 @@ contract EmissionsController is
8282
// Prevents minting EIGEN before the first epoch has started.
8383
require(currentEpoch != type(uint256).max, EmissionsNotStarted());
8484

85+
// Check if there are any active distributions to process.
86+
require(isButtonPressable(), AllDistributionsProcessed());
87+
8588
uint256 totalDistributions = getTotalDistributions();
8689
uint256 nextDistributionId = _epochs[currentEpoch].totalProcessed;
8790

88-
// Check if all distributions have already been processed.
89-
require(nextDistributionId < totalDistributions, AllDistributionsProcessed());
90-
9191
// Mint the total amount of bEIGEN/EIGEN needed for all distributions.
9292
if (!_epochs[currentEpoch].minted) {
9393
// NOTE: Approvals may not be entirely spent.
@@ -118,14 +118,8 @@ contract EmissionsController is
118118
for (uint256 distributionId = nextDistributionId; distributionId < lastIndex; ++distributionId) {
119119
Distribution memory distribution = _distributions[distributionId];
120120

121-
// Skip disabled distributions...
122-
if (distribution.distributionType == DistributionType.Disabled) continue;
123-
// Skip distributions that haven't started yet...
124-
if (distribution.startEpoch > currentEpoch) continue;
125-
// Skip distributions that have ended (0 means infinite)...
126-
if (distribution.totalEpochs != 0) {
127-
if (currentEpoch > distribution.startEpoch + distribution.totalEpochs) continue;
128-
}
121+
// Skip inactive distributions
122+
if (!_isDistributionActive(distribution, currentEpoch)) continue;
129123

130124
_processDistribution({
131125
distribution: distribution,
@@ -370,6 +364,26 @@ contract EmissionsController is
370364
);
371365
}
372366

367+
/// @dev Internal helper to check if a distribution is active for the given epoch.
368+
/// A distribution is active if it is not disabled, has started, and has not ended.
369+
/// @param distribution The distribution to check.
370+
/// @param currentEpoch The epoch to check against.
371+
/// @return True if the distribution is active for the given epoch, false otherwise.
372+
function _isDistributionActive(
373+
Distribution memory distribution,
374+
uint256 currentEpoch
375+
) internal pure returns (bool) {
376+
// Skip disabled distributions
377+
if (distribution.distributionType == DistributionType.Disabled) return false;
378+
// Skip distributions that haven't started yet
379+
if (distribution.startEpoch > currentEpoch) return false;
380+
// Skip distributions that have ended (0 means infinite)
381+
if (distribution.totalEpochs != 0) {
382+
if (currentEpoch >= distribution.startEpoch + distribution.totalEpochs) return false;
383+
}
384+
return true;
385+
}
386+
373387
/// -----------------------------------------------------------------------
374388
/// View
375389
/// -----------------------------------------------------------------------
@@ -384,7 +398,18 @@ contract EmissionsController is
384398

385399
/// @inheritdoc IEmissionsController
386400
function isButtonPressable() public view returns (bool) {
387-
return _epochs[getCurrentEpoch()].totalProcessed < getTotalDistributions();
401+
uint256 currentEpoch = getCurrentEpoch();
402+
uint256 totalDistributions = getTotalDistributions();
403+
uint256 nextDistributionId = _epochs[currentEpoch].totalProcessed;
404+
405+
// Check if any remaining distribution is active for the current epoch
406+
for (uint256 i = nextDistributionId; i < totalDistributions; ++i) {
407+
if (_isDistributionActive(_distributions[i], currentEpoch)) {
408+
return true;
409+
}
410+
}
411+
412+
return false;
388413
}
389414

390415
/// @inheritdoc IEmissionsController

src/test/unit/EmissionsControllerUnit.t.sol

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,170 @@ contract EmissionsControllerUnitTests_pressButton is EmissionsControllerUnitTest
137137
cheats.expectRevert(IEmissionsControllerErrors.EmissionsNotStarted.selector);
138138
emissionsController.pressButton(1);
139139
}
140+
141+
function test_revert_pressButton_AfterOneEpoch_AllDistributionsProcessed() public {
142+
// Add a distribution that should run for exactly 1 epoch (epoch 0 only)
143+
cheats.prank(incentiveCouncil);
144+
emissionsController.addDistribution(
145+
Distribution({
146+
weight: 10_000,
147+
startEpoch: 0,
148+
totalEpochs: 1,
149+
distributionType: DistributionType.RewardsForAllEarners,
150+
operatorSet: emptyOperatorSet(),
151+
strategiesAndMultipliers: defaultStrategiesAndMultipliers()
152+
})
153+
);
154+
155+
// Epoch 0: should process
156+
cheats.warp(EMISSIONS_START_TIME);
157+
assertEq(emissionsController.getCurrentEpoch(), 0);
158+
assertTrue(emissionsController.isButtonPressable());
159+
emissionsController.pressButton(1);
160+
161+
// Epoch 1: should NOT process (distribution ended at epoch 0+1=1)
162+
cheats.warp(EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH);
163+
assertEq(emissionsController.getCurrentEpoch(), 1);
164+
assertFalse(emissionsController.isButtonPressable());
165+
cheats.expectRevert(IEmissionsControllerErrors.AllDistributionsProcessed.selector);
166+
emissionsController.pressButton(1);
167+
}
168+
169+
function test_revert_pressButton_AfterTwoEpochs_AllDistributionsProcessed() public {
170+
// Add a distribution that should run for exactly 2 epochs (epochs 0 and 1)
171+
cheats.prank(incentiveCouncil);
172+
emissionsController.addDistribution(
173+
Distribution({
174+
weight: 10_000,
175+
startEpoch: 0,
176+
totalEpochs: 2,
177+
distributionType: DistributionType.RewardsForAllEarners,
178+
operatorSet: emptyOperatorSet(),
179+
strategiesAndMultipliers: defaultStrategiesAndMultipliers()
180+
})
181+
);
182+
183+
// Epoch 0: should process
184+
cheats.warp(EMISSIONS_START_TIME);
185+
assertEq(emissionsController.getCurrentEpoch(), 0);
186+
emissionsController.pressButton(1);
187+
// Verify button is not pressable again in same epoch
188+
assertFalse(emissionsController.isButtonPressable());
189+
190+
// Epoch 1: should process
191+
cheats.warp(EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH);
192+
assertEq(emissionsController.getCurrentEpoch(), 1);
193+
assertTrue(emissionsController.isButtonPressable());
194+
emissionsController.pressButton(1);
195+
assertFalse(emissionsController.isButtonPressable());
196+
197+
// Epoch 2: should NOT process (distribution ended at epoch 0+2=2)
198+
cheats.warp(EMISSIONS_START_TIME + 2 * EMISSIONS_EPOCH_LENGTH);
199+
assertEq(emissionsController.getCurrentEpoch(), 2);
200+
// Button should not be pressable because all distributions have ended
201+
assertFalse(emissionsController.isButtonPressable());
202+
cheats.expectRevert(IEmissionsControllerErrors.AllDistributionsProcessed.selector);
203+
emissionsController.pressButton(1);
204+
}
205+
206+
function test_revert_pressButton_AfterThreeFutureEpochs_AllDistributionsProcessed() public {
207+
// Add a distribution that starts at epoch 2 and runs for 3 epochs (epochs 2, 3, 4)
208+
cheats.prank(incentiveCouncil);
209+
emissionsController.addDistribution(
210+
Distribution({
211+
weight: 10_000,
212+
startEpoch: 2,
213+
totalEpochs: 3,
214+
distributionType: DistributionType.RewardsForAllEarners,
215+
operatorSet: emptyOperatorSet(),
216+
strategiesAndMultipliers: defaultStrategiesAndMultipliers()
217+
})
218+
);
219+
220+
// Epoch 0: should not process (hasn't started)
221+
cheats.warp(EMISSIONS_START_TIME);
222+
assertEq(emissionsController.getCurrentEpoch(), 0);
223+
assertFalse(emissionsController.isButtonPressable());
224+
225+
// Epoch 1: should not process (hasn't started)
226+
cheats.warp(EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH);
227+
assertEq(emissionsController.getCurrentEpoch(), 1);
228+
assertFalse(emissionsController.isButtonPressable());
229+
230+
// Epoch 2: should process (first epoch)
231+
cheats.warp(EMISSIONS_START_TIME + 2 * EMISSIONS_EPOCH_LENGTH);
232+
assertEq(emissionsController.getCurrentEpoch(), 2);
233+
assertTrue(emissionsController.isButtonPressable());
234+
emissionsController.pressButton(1);
235+
236+
// Epoch 3: should process
237+
cheats.warp(EMISSIONS_START_TIME + 3 * EMISSIONS_EPOCH_LENGTH);
238+
assertEq(emissionsController.getCurrentEpoch(), 3);
239+
assertTrue(emissionsController.isButtonPressable());
240+
emissionsController.pressButton(1);
241+
242+
// Epoch 4: should process (last epoch)
243+
cheats.warp(EMISSIONS_START_TIME + 4 * EMISSIONS_EPOCH_LENGTH);
244+
assertEq(emissionsController.getCurrentEpoch(), 4);
245+
assertTrue(emissionsController.isButtonPressable());
246+
emissionsController.pressButton(1);
247+
248+
// Epoch 5: should NOT process (ended at epoch 2+3=5)
249+
cheats.warp(EMISSIONS_START_TIME + 5 * EMISSIONS_EPOCH_LENGTH);
250+
assertEq(emissionsController.getCurrentEpoch(), 5);
251+
assertFalse(emissionsController.isButtonPressable());
252+
cheats.expectRevert(IEmissionsControllerErrors.AllDistributionsProcessed.selector);
253+
emissionsController.pressButton(1);
254+
}
255+
256+
function testFuzz_revert_pressButton_AfterDistributionEnds_AllDistributionsProcessed(uint8 _startEpoch, uint8 _totalEpochs) public {
257+
// Bound inputs to reasonable values
258+
uint64 startEpoch = uint64(bound(_startEpoch, 0, 10));
259+
uint64 totalEpochs = uint64(bound(_totalEpochs, 1, 10));
260+
261+
// Add a distribution with fuzzed parameters
262+
cheats.prank(incentiveCouncil);
263+
emissionsController.addDistribution(
264+
Distribution({
265+
weight: 10_000,
266+
startEpoch: startEpoch,
267+
totalEpochs: totalEpochs,
268+
distributionType: DistributionType.RewardsForAllEarners,
269+
operatorSet: emptyOperatorSet(),
270+
strategiesAndMultipliers: defaultStrategiesAndMultipliers()
271+
})
272+
);
273+
274+
uint endEpoch = startEpoch + totalEpochs;
275+
uint maxTestEpoch = endEpoch + 2; // Test a couple epochs after the end
276+
277+
// Test each epoch from 0 to maxTestEpoch
278+
for (uint epoch = 0; epoch <= maxTestEpoch; epoch++) {
279+
// Warp to the beginning of this epoch
280+
cheats.warp(EMISSIONS_START_TIME + epoch * EMISSIONS_EPOCH_LENGTH);
281+
assertEq(emissionsController.getCurrentEpoch(), epoch);
282+
283+
// Determine if the button should be pressable in this epoch
284+
bool shouldBeActive = (epoch >= startEpoch && epoch < endEpoch);
285+
286+
if (shouldBeActive) {
287+
// Button should be pressable if we haven't pressed it in this epoch yet
288+
assertTrue(emissionsController.isButtonPressable(), "Button should be pressable during active epochs");
289+
emissionsController.pressButton(1);
290+
// After pressing, button should not be pressable again in the same epoch
291+
assertFalse(emissionsController.isButtonPressable(), "Button should not be pressable after pressing in same epoch");
292+
} else {
293+
// Button should not be pressable before start or after end
294+
assertFalse(emissionsController.isButtonPressable(), "Button should not be pressable outside active epochs");
295+
296+
// If we're at or past the end epoch, verify the correct revert
297+
if (epoch >= endEpoch) {
298+
cheats.expectRevert(IEmissionsControllerErrors.AllDistributionsProcessed.selector);
299+
emissionsController.pressButton(1);
300+
}
301+
}
302+
}
303+
}
140304
}
141305

142306
contract EmissionsControllerUnitTests_sweep is EmissionsControllerUnitTests {

0 commit comments

Comments
 (0)