From 608edba92c611aa03b851b9d1fb04a8028630211 Mon Sep 17 00:00:00 2001 From: Pavel Hornak Date: Wed, 11 Mar 2026 12:50:22 +0100 Subject: [PATCH 01/11] feat(protocol): add voter reward commission for validator groups Allows validator groups to take a percentage of voter CELO epoch rewards. Adds voterRewardCommission fields to ValidatorGroup struct with time-delayed update pattern, commission deduction in EpochManager before distributing voter rewards, and comprehensive unit tests covering all edge cases. Changes: - Validators.sol: new struct fields, setNextVoterRewardCommissionUpdate(), updateVoterRewardCommission(), getVoterRewardCommission(), setMaxVoterRewardCommission(), version bump to (1,5,0,0) - EpochManager.sol: _deductVoterRewardCommission() helper, commission deduction in processGroup() and finishNextEpochProcess(), version bump to (1,1,1,0) - IValidators.sol: 5 new interface signatures - MockValidators.sol: mock support for voter reward commission - Validators.t.sol: 33 new tests across 4 test contracts - EpochManager.t.sol: 7 new tests for commission distribution --- .../contracts-0.8/common/EpochManager.sol | 56 +++- .../contracts-0.8/governance/Validators.sol | 113 ++++++- .../governance/test/IMockValidators.sol | 6 + .../governance/interfaces/IValidators.sol | 5 + .../governance/test/MockValidators.sol | 27 ++ .../test-sol/unit/common/EpochManager.t.sol | 181 +++++++++++ .../governance/validators/Validators.t.sol | 285 ++++++++++++++++++ 7 files changed, 669 insertions(+), 4 deletions(-) diff --git a/packages/protocol/contracts-0.8/common/EpochManager.sol b/packages/protocol/contracts-0.8/common/EpochManager.sol index 30f9e19b927..7456ed81838 100644 --- a/packages/protocol/contracts-0.8/common/EpochManager.sol +++ b/packages/protocol/contracts-0.8/common/EpochManager.sol @@ -139,6 +139,18 @@ contract EpochManager is uint256 indexed epochNumber ); + /** + * @notice Emitted when voter reward commission is distributed to a group. + * @param group Address of the validator group receiving commission. + * @param commission Amount of CELO released to the group as commission. + * @param epochNumber The epoch number for which the commission is distributed. + */ + event VoterRewardCommissionDistributed( + address indexed group, + uint256 commission, + uint256 indexed epochNumber + ); + /** * @notice Throws if called by other than EpochManagerEnabler contract. */ @@ -315,7 +327,11 @@ contract EpochManager is IElection election = getElection(); if (epochRewards != type(uint256).max) { - election.distributeEpochRewards(group, epochRewards, lesser, greater); + uint256 commissionAmount = _deductVoterRewardCommission(group, epochRewards); + uint256 voterRewards = epochRewards - commissionAmount; + if (voterRewards > 0) { + election.distributeEpochRewards(group, voterRewards, lesser, greater); + } } delete processedGroups[group]; @@ -376,7 +392,10 @@ contract EpochManager is // checks that group is actually from elected group require(epochRewards > 0, "group not from current elected set"); if (epochRewards != type(uint256).max) { - election.distributeEpochRewards(groups[i], epochRewards, lessers[i], greaters[i]); + epochRewards -= _deductVoterRewardCommission(groups[i], epochRewards); + if (epochRewards > 0) { + election.distributeEpochRewards(groups[i], epochRewards, lessers[i], greaters[i]); + } } delete processedGroups[groups[i]]; @@ -604,7 +623,7 @@ contract EpochManager is * @return Patch version of the contract. */ function getVersionNumber() external pure returns (uint256, uint256, uint256, uint256) { - return (1, 1, 0, 3); + return (1, 1, 1, 0); } /** @@ -682,6 +701,37 @@ contract EpochManager is return (_epoch.firstBlock, _epoch.lastBlock, _epoch.startTimestamp, _epoch.rewardsBlock); } + /** + * @notice Deducts voter reward commission for a group and releases CELO from treasury to group. + * @param group The validator group address. + * @param epochRewards The total voter epoch rewards for this group. + * @return commissionAmount The amount deducted as commission. + */ + function _deductVoterRewardCommission( + address group, + uint256 epochRewards + ) internal returns (uint256 commissionAmount) { + IValidators validators = getValidators(); + (uint256 voterRewardCommissionUnwrapped, , ) = validators.getVoterRewardCommission(group); + + if (voterRewardCommissionUnwrapped == 0) { + return 0; + } + + commissionAmount = FixidityLib + .newFixed(epochRewards) + .multiply(FixidityLib.wrap(voterRewardCommissionUnwrapped)) + .fromFixed(); + + if (commissionAmount > 0) { + // Release CELO from treasury directly to the group. + // This mirrors the pattern used for community and carbon fund rewards + // in _finishEpochHelper(). + getCeloUnreleasedTreasury().release(group, commissionAmount); + emit VoterRewardCommissionDistributed(group, commissionAmount, currentEpochNumber); + } + } + /** * @notice Allocates rewards to elected validator accounts. */ diff --git a/packages/protocol/contracts-0.8/governance/Validators.sol b/packages/protocol/contracts-0.8/governance/Validators.sol index fc0df3a463d..d911dc54932 100644 --- a/packages/protocol/contracts-0.8/governance/Validators.sol +++ b/packages/protocol/contracts-0.8/governance/Validators.sol @@ -66,6 +66,11 @@ contract Validators is // sizeHistory[i] contains the last time the group contained i members. uint256[] sizeHistory; SlashingInfo slashInfo; + // Commission on voter CELO rewards (independent from validator payment commission above). + // Groups set this to take a percentage of epoch rewards that would otherwise go to voters. + FixidityLib.Fraction voterRewardCommission; + FixidityLib.Fraction nextVoterRewardCommission; + uint256 nextVoterRewardCommissionBlock; } // Stores the epoch number at which a validator joined a particular group. @@ -129,6 +134,11 @@ contract Validators is uint256 public slashingMultiplierResetPeriod; uint256 public deprecated_downtimeGracePeriod; + // Cap on voter reward commission to protect voters from excessive commission rates. + // Set via governance. A value of 0 means no cap is enforced. + // Recommended initial value: 20% (FixidityLib representation). + uint256 public maxVoterRewardCommission; + event MaxGroupSizeSet(uint256 size); event CommissionUpdateDelaySet(uint256 delay); event GroupLockedGoldRequirementsSet(uint256 value, uint256 duration); @@ -150,6 +160,13 @@ contract Validators is uint256 activationBlock ); event ValidatorGroupCommissionUpdated(address indexed group, uint256 commission); + event ValidatorGroupVoterRewardCommissionUpdateQueued( + address indexed group, + uint256 commission, + uint256 activationBlock + ); + event ValidatorGroupVoterRewardCommissionUpdated(address indexed group, uint256 commission); + event MaxVoterRewardCommissionSet(uint256 maxCommission); modifier onlySlasher() { require(getLockedGold().isSlasher(msg.sender), "Only registered slasher can call"); @@ -452,6 +469,65 @@ contract Validators is emit ValidatorGroupCommissionUpdated(account, group.commission.unwrap()); } + /** + * @notice Queues an update to a validator group's voter reward commission. + * If there was a previously scheduled update, that is overwritten. + * @param commission Fixidity representation of the commission this group receives on epoch + * voter rewards. Must be in the range [0, 1.0] and below maxVoterRewardCommission if set. + */ + function setNextVoterRewardCommissionUpdate(uint256 commission) external { + address account = getAccounts().validatorSignerToAccount(msg.sender); + require(isValidatorGroup(account), "Not a validator group"); + ValidatorGroup storage group = groups[account]; + require( + commission <= FixidityLib.fixed1().unwrap(), + "Voter reward commission can't be greater than 100%" + ); + if (maxVoterRewardCommission > 0) { + require( + commission <= maxVoterRewardCommission, + "Voter reward commission exceeds max allowed" + ); + } + require( + commission != group.voterRewardCommission.unwrap(), + "Voter reward commission must be different" + ); + + group.nextVoterRewardCommission = FixidityLib.wrap(commission); + group.nextVoterRewardCommissionBlock = block.number.add(commissionUpdateDelay); + emit ValidatorGroupVoterRewardCommissionUpdateQueued( + account, + commission, + group.nextVoterRewardCommissionBlock + ); + } + + /** + * @notice Updates a validator group's voter reward commission based on the previously queued + * update. + * @dev Note: Consider adding onlyWhenNotBlocked modifier if Validators inherits Blockable + * in the future, to prevent updates during epoch processing. + */ + function updateVoterRewardCommission() external { + address account = getAccounts().validatorSignerToAccount(msg.sender); + require(isValidatorGroup(account), "Not a validator group"); + ValidatorGroup storage group = groups[account]; + + _sendValidatorGroupPaymentsIfNecessary(group); + + require(group.nextVoterRewardCommissionBlock != 0, "No voter reward commission update queued"); + require( + group.nextVoterRewardCommissionBlock <= block.number, + "Can't apply voter reward commission update yet" + ); + + group.voterRewardCommission = group.nextVoterRewardCommission; + delete group.nextVoterRewardCommission; + delete group.nextVoterRewardCommissionBlock; + emit ValidatorGroupVoterRewardCommissionUpdated(account, group.voterRewardCommission.unwrap()); + } + /** * @notice Removes a validator from the group for which it is a member. * @param validatorAccount The validator to deaffiliate from their affiliated validator group. @@ -541,6 +617,25 @@ contract Validators is ); } + /** + * @notice Returns the voter reward commission for a validator group. + * @param account The address of the validator group. + * @return The current voter reward commission (Fixidity). + * @return The queued voter reward commission (Fixidity). + * @return The block at which the queued commission activates. + */ + function getVoterRewardCommission( + address account + ) external view returns (uint256, uint256, uint256) { + require(isValidatorGroup(account), "Not a validator group"); + ValidatorGroup storage group = groups[account]; + return ( + group.voterRewardCommission.unwrap(), + group.nextVoterRewardCommission.unwrap(), + group.nextVoterRewardCommissionBlock + ); + } + /** * @notice Returns the top n group members for a particular group. * @param account The address of the validator group. @@ -779,7 +874,7 @@ contract Validators is * @return Patch version of the contract. */ function getVersionNumber() external pure returns (uint256, uint256, uint256, uint256) { - return (1, 4, 0, 1); + return (1, 5, 0, 0); } /** @@ -821,6 +916,22 @@ contract Validators is emit CommissionUpdateDelaySet(delay); } + /** + * @notice Sets the maximum voter reward commission that groups can set. + * @param maxCommission Fixidity representation of the max commission. + * A value of 0 means no cap is enforced. + * Recommended initial value: 20% (FixidityLib representation). + */ + function setMaxVoterRewardCommission(uint256 maxCommission) external onlyOwner { + require( + maxCommission <= FixidityLib.fixed1().unwrap(), + "Max voter reward commission can't be greater than 100%" + ); + require(maxCommission != maxVoterRewardCommission, "Max voter reward commission not changed"); + maxVoterRewardCommission = maxCommission; + emit MaxVoterRewardCommissionSet(maxCommission); + } + /** * @notice Updates the maximum number of members a group can have. * @param size The maximum group size. diff --git a/packages/protocol/contracts-0.8/governance/test/IMockValidators.sol b/packages/protocol/contracts-0.8/governance/test/IMockValidators.sol index 087150a1ada..06f0dc39f67 100644 --- a/packages/protocol/contracts-0.8/governance/test/IMockValidators.sol +++ b/packages/protocol/contracts-0.8/governance/test/IMockValidators.sol @@ -29,6 +29,12 @@ interface IMockValidators { function setCommission(address group, uint256 commission) external; + function setVoterRewardCommission(address group, uint256 commission) external; + + function getVoterRewardCommission( + address group + ) external view returns (uint256, uint256, uint256); + function setAccountLockedGoldRequirement(address account, uint256 value) external; function halveSlashingMultiplier(address) external; diff --git a/packages/protocol/contracts/governance/interfaces/IValidators.sol b/packages/protocol/contracts/governance/interfaces/IValidators.sol index 822f92baa11..30ebc7a7354 100644 --- a/packages/protocol/contracts/governance/interfaces/IValidators.sol +++ b/packages/protocol/contracts/governance/interfaces/IValidators.sol @@ -15,10 +15,13 @@ interface IValidators { function reorderMember(address, address, address) external returns (bool); function updateCommission() external; function setNextCommissionUpdate(uint256) external; + function updateVoterRewardCommission() external; + function setNextVoterRewardCommissionUpdate(uint256) external; function resetSlashingMultiplier() external; // only owner function setCommissionUpdateDelay(uint256) external; + function setMaxVoterRewardCommission(uint256) external; function setMaxGroupSize(uint256) external returns (bool); function setMembershipHistoryLength(uint256) external returns (bool); function setGroupLockedGoldRequirements(uint256, uint256) external returns (bool); @@ -36,6 +39,8 @@ interface IValidators { // view functions function maxGroupSize() external view returns (uint256); function getCommissionUpdateDelay() external view returns (uint256); + function getVoterRewardCommission(address) external view returns (uint256, uint256, uint256); + function maxVoterRewardCommission() external view returns (uint256); function getMembershipHistory( address ) external view returns (uint256[] memory, address[] memory, uint256, uint256); diff --git a/packages/protocol/contracts/governance/test/MockValidators.sol b/packages/protocol/contracts/governance/test/MockValidators.sol index 5523a8ed00c..ac90df29721 100644 --- a/packages/protocol/contracts/governance/test/MockValidators.sol +++ b/packages/protocol/contracts/governance/test/MockValidators.sol @@ -26,6 +26,7 @@ contract MockValidators is IValidators { mapping(address => address[]) private members; mapping(address => address) private affiliations; mapping(address => uint256) private commissions; + mapping(address => uint256) private voterRewardCommissions; uint256 private numRegisteredValidators; mapping(address => uint256) private epochRewards; uint256 public mintedStable; @@ -66,6 +67,16 @@ contract MockValidators is IValidators { commissions[group] = commission; } + function setVoterRewardCommission(address group, uint256 commission) external { + voterRewardCommissions[group] = commission; + } + + function getVoterRewardCommission( + address group + ) external view returns (uint256, uint256, uint256) { + return (voterRewardCommissions[group], 0, 0); + } + function setAccountLockedGoldRequirement(address account, uint256 value) external { lockedGoldRequirements[account] = value; } @@ -192,6 +203,22 @@ contract MockValidators is IValidators { revert("Method not implemented in mock"); } + function setNextVoterRewardCommissionUpdate(uint256) external { + revert("Method not implemented in mock"); + } + + function updateVoterRewardCommission() external { + revert("Method not implemented in mock"); + } + + function setMaxVoterRewardCommission(uint256) external { + revert("Method not implemented in mock"); + } + + function maxVoterRewardCommission() external view returns (uint256) { + return 0; + } + function resetSlashingMultiplier() external { revert("Method not implemented in mock"); } diff --git a/packages/protocol/test-sol/unit/common/EpochManager.t.sol b/packages/protocol/test-sol/unit/common/EpochManager.t.sol index 5e602424db7..6f69cbc43f4 100644 --- a/packages/protocol/test-sol/unit/common/EpochManager.t.sol +++ b/packages/protocol/test-sol/unit/common/EpochManager.t.sol @@ -71,6 +71,11 @@ contract EpochManagerTest is TestWithUtils08 { address indexed group, uint256 indexed epochNumber ); + event VoterRewardCommissionDistributed( + address indexed group, + uint256 commission, + uint256 indexed epochNumber + ); function setUp() public virtual override { super.setUp(); @@ -985,3 +990,179 @@ contract EpochManagerTest_getElectedSignerByIndex is EpochManagerTest { assertEq(epochManagerContract.getElectedSignerByIndex(1), electedSigners[1]); } } + +contract EpochManagerTest_voterRewardCommission is EpochManagerTest { + uint256 groupEpochRewards = 1000e18; + uint256 tenPercent = 100000000000000000000000; // FixidityLib.newFixedFraction(10, 100) + + function setUp() public override(EpochManagerTest) { + super.setUp(); + + setupAndElectValidators(); + + election.setGroupEpochRewardsBasedOnScore(group, groupEpochRewards); + } + + function test_distributesFullRewardsWhenNoVoterRewardCommission() public { + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + epochManagerContract.processGroup(group, address(0), address(0)); + + assertEq( + election.distributedEpochRewards(group), + groupEpochRewards, + "Full rewards should be distributed when no commission is set" + ); + } + + function test_deductsVoterRewardCommissionAndReleasesToGroup() public { + validators.setVoterRewardCommission(group, tenPercent); + + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + + uint256 groupBalanceBefore = celoToken.balanceOf(group); + + epochManagerContract.processGroup(group, address(0), address(0)); + + // Voters should receive 90% of rewards + uint256 expectedVoterRewards = 900e18; + assertEq( + election.distributedEpochRewards(group), + expectedVoterRewards, + "Voters should receive rewards minus commission" + ); + + // Group should receive 10% as CELO from treasury + uint256 expectedCommission = 100e18; + uint256 groupBalanceAfter = celoToken.balanceOf(group); + assertEq( + groupBalanceAfter - groupBalanceBefore, + expectedCommission, + "Group should receive commission CELO from treasury" + ); + } + + function test_emitsVoterRewardCommissionDistributedEvent() public { + validators.setVoterRewardCommission(group, tenPercent); + + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + + uint256 expectedCommission = 100e18; + + vm.expectEmit(true, true, true, true); + emit VoterRewardCommissionDistributed(group, expectedCommission, firstEpochNumber); + + epochManagerContract.processGroup(group, address(0), address(0)); + } + + function test_handlesZeroEpochRewardsSentinel() public { + // When epochRewards == type(uint256).max (sentinel for 0 rewards), + // no distribution should happen + election.setGroupEpochRewardsBasedOnScore(group, 0); + validators.setVoterRewardCommission(group, tenPercent); + + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + epochManagerContract.processGroup(group, address(0), address(0)); + + assertEq( + election.distributedEpochRewards(group), + 0, + "No rewards should be distributed for zero epoch rewards" + ); + } + + function test_handlesFullCommission() public { + uint256 fullCommission = 1000000000000000000000000; // FixidityLib.fixed1() = 100% + validators.setVoterRewardCommission(group, fullCommission); + + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + + uint256 groupBalanceBefore = celoToken.balanceOf(group); + + epochManagerContract.processGroup(group, address(0), address(0)); + + // Voters should receive nothing + assertEq( + election.distributedEpochRewards(group), + 0, + "Voters should receive nothing with 100% commission" + ); + + // Group should receive everything + uint256 groupBalanceAfter = celoToken.balanceOf(group); + assertEq( + groupBalanceAfter - groupBalanceBefore, + groupEpochRewards, + "Group should receive all rewards as commission" + ); + } + + function test_deductsCommissionViaFinishNextEpochProcess() public { + validators.setVoterRewardCommission(group, tenPercent); + + epochManagerContract.startNextEpochProcess(); + + ( + address[] memory groups, + address[] memory lessers, + address[] memory greaters + ) = getGroupsWithLessersAndGreaters(); + + uint256 groupBalanceBefore = celoToken.balanceOf(group); + + epochManagerContract.finishNextEpochProcess(groups, lessers, greaters); + + // Voters should receive 90% of rewards + uint256 expectedVoterRewards = 900e18; + assertEq( + election.distributedEpochRewards(group), + expectedVoterRewards, + "Voters should receive rewards minus commission via finishNextEpochProcess" + ); + + // Group should receive 10% as CELO from treasury + uint256 expectedCommission = 100e18; + uint256 groupBalanceAfter = celoToken.balanceOf(group); + assertEq( + groupBalanceAfter - groupBalanceBefore, + expectedCommission, + "Group should receive commission via finishNextEpochProcess" + ); + } + + function test_noTreasuryReleaseWhenCommissionRoundsToZero() public { + // Set a very tiny commission (1 wei in Fixidity representation). + // With small epoch rewards, the multiply should round down to 0. + uint256 tinyCommission = 1; + validators.setVoterRewardCommission(group, tinyCommission); + + // Use small rewards so tinyCommission * rewards rounds to 0 + election.setGroupEpochRewardsBasedOnScore(group, 1); + + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + + uint256 groupBalanceBefore = celoToken.balanceOf(group); + + epochManagerContract.processGroup(group, address(0), address(0)); + + // Commission rounds to 0, so group should not receive any CELO + uint256 groupBalanceAfter = celoToken.balanceOf(group); + assertEq( + groupBalanceAfter, + groupBalanceBefore, + "No CELO should be released when commission rounds to zero" + ); + + // Full rewards should go to voters + assertEq( + election.distributedEpochRewards(group), + 1, + "Full rewards should go to voters when commission rounds to zero" + ); + } +} diff --git a/packages/protocol/test-sol/unit/governance/validators/Validators.t.sol b/packages/protocol/test-sol/unit/governance/validators/Validators.t.sol index 9d3790e7466..ee79443893a 100644 --- a/packages/protocol/test-sol/unit/governance/validators/Validators.t.sol +++ b/packages/protocol/test-sol/unit/governance/validators/Validators.t.sol @@ -117,6 +117,13 @@ contract ValidatorsTest is TestWithUtils, ECDSAHelper { uint256 activationBlock ); event ValidatorGroupCommissionUpdated(address indexed group, uint256 commission); + event ValidatorGroupVoterRewardCommissionUpdateQueued( + address indexed group, + uint256 commission, + uint256 activationBlock + ); + event ValidatorGroupVoterRewardCommissionUpdated(address indexed group, uint256 commission); + event MaxVoterRewardCommissionSet(uint256 maxCommission); event ValidatorEpochPaymentDistributed( address indexed validator, uint256 validatorPayment, @@ -2496,3 +2503,281 @@ contract ValidatorsTest_ResetSlashingMultiplier is ValidatorsTest { assertEq(actualMultiplier, FixidityLib.fixed1().unwrap()); } } + +contract ValidatorsTest_SetNextVoterRewardCommissionUpdate is ValidatorsTest { + uint256 newVoterRewardCommission = FixidityLib.newFixedFraction(5, 100).unwrap(); // 5% + + function setUp() public { + super.setUp(); + _registerValidatorGroupHelper(group, 1); + } + + function test_ShouldNotSetVoterRewardCommissionImmediately() public { + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(newVoterRewardCommission); + + (uint256 _commission, , ) = validators.getVoterRewardCommission(group); + assertEq(_commission, 0, "Voter reward commission should not be set immediately"); + } + + function test_ShouldSetNextVoterRewardCommission() public { + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(newVoterRewardCommission); + + (, uint256 _nextCommission, ) = validators.getVoterRewardCommission(group); + assertEq(_nextCommission, newVoterRewardCommission); + } + + function test_ShouldSetNextVoterRewardCommissionBlock() public { + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(newVoterRewardCommission); + + (, , uint256 _nextBlock) = validators.getVoterRewardCommission(group); + assertEq(_nextBlock, commissionUpdateDelay.add(uint256(block.number))); + } + + function test_Emits_VoterRewardCommissionUpdateQueuedEvent() public { + vm.expectEmit(true, true, true, true); + emit ValidatorGroupVoterRewardCommissionUpdateQueued( + group, + newVoterRewardCommission, + commissionUpdateDelay.add(uint256(block.number)) + ); + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(newVoterRewardCommission); + } + + function test_Reverts_WhenCommissionIsUnchanged() public { + vm.expectRevert("Voter reward commission must be different"); + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(0); // default is 0 + } + + function test_Reverts_WhenCommissionGreaterThan100Percent() public { + vm.expectRevert("Voter reward commission can't be greater than 100%"); + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(FixidityLib.fixed1().unwrap().add(1)); + } + + function test_Reverts_WhenNotValidatorGroup() public { + vm.expectRevert("Not a validator group"); + vm.prank(validator); + validators.setNextVoterRewardCommissionUpdate(newVoterRewardCommission); + } + + function test_Reverts_WhenCommissionExceedsMax() public { + uint256 maxCommission = FixidityLib.newFixedFraction(2, 100).unwrap(); // 2% + validators.setMaxVoterRewardCommission(maxCommission); + + vm.expectRevert("Voter reward commission exceeds max allowed"); + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(newVoterRewardCommission); // 5% > 2% + } + + function test_ShouldAllowCommissionAtMax() public { + uint256 maxCommission = FixidityLib.newFixedFraction(5, 100).unwrap(); // 5% + validators.setMaxVoterRewardCommission(maxCommission); + + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(newVoterRewardCommission); // 5% == 5% + + (, uint256 _nextCommission, ) = validators.getVoterRewardCommission(group); + assertEq(_nextCommission, newVoterRewardCommission); + } + + function test_ShouldAllowExactly100PercentCommission() public { + uint256 fullCommission = FixidityLib.fixed1().unwrap(); // 100% + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(fullCommission); + + (, uint256 _nextCommission, ) = validators.getVoterRewardCommission(group); + assertEq(_nextCommission, fullCommission); + } + + function test_ShouldOverwritePreviouslyQueuedUpdate() public { + uint256 firstCommission = FixidityLib.newFixedFraction(5, 100).unwrap(); // 5% + uint256 secondCommission = FixidityLib.newFixedFraction(10, 100).unwrap(); // 10% + + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(firstCommission); + + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(secondCommission); + + (, uint256 _nextCommission, ) = validators.getVoterRewardCommission(group); + assertEq(_nextCommission, secondCommission, "Should overwrite with second value"); + } +} + +contract ValidatorsTest_UpdateVoterRewardCommission is ValidatorsTest { + uint256 newVoterRewardCommission = FixidityLib.newFixedFraction(5, 100).unwrap(); // 5% + + function setUp() public { + super.setUp(); + _registerValidatorGroupHelper(group, 1); + } + + function test_ShouldSetVoterRewardCommission() public { + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(newVoterRewardCommission); + + blockTravel(commissionUpdateDelay); + + vm.prank(group); + validators.updateVoterRewardCommission(); + + (uint256 _commission, , ) = validators.getVoterRewardCommission(group); + assertEq(_commission, newVoterRewardCommission); + } + + function test_Emits_VoterRewardCommissionUpdatedEvent() public { + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(newVoterRewardCommission); + + blockTravel(commissionUpdateDelay); + + vm.expectEmit(true, true, true, true); + emit ValidatorGroupVoterRewardCommissionUpdated(group, newVoterRewardCommission); + + vm.prank(group); + validators.updateVoterRewardCommission(); + } + + function test_Reverts_WhenDelayNotPassed() public { + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(newVoterRewardCommission); + + vm.expectRevert("Can't apply voter reward commission update yet"); + vm.prank(group); + validators.updateVoterRewardCommission(); + } + + function test_Reverts_WhenNoUpdateQueued() public { + vm.expectRevert("No voter reward commission update queued"); + vm.prank(group); + validators.updateVoterRewardCommission(); + } + + function test_Reverts_WhenApplyingAlreadyAppliedUpdate() public { + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(newVoterRewardCommission); + blockTravel(commissionUpdateDelay); + + vm.prank(group); + validators.updateVoterRewardCommission(); + + vm.expectRevert("No voter reward commission update queued"); + vm.prank(group); + validators.updateVoterRewardCommission(); + } + + function test_ClearsPendingValuesAfterUpdate() public { + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(newVoterRewardCommission); + + blockTravel(commissionUpdateDelay); + + vm.prank(group); + validators.updateVoterRewardCommission(); + + (, uint256 _next, uint256 _block) = validators.getVoterRewardCommission(group); + assertEq(_next, 0, "Next commission should be cleared"); + assertEq(_block, 0, "Next block should be cleared"); + } + + function test_Reverts_WhenNotValidatorGroup() public { + vm.expectRevert("Not a validator group"); + vm.prank(validator); + validators.updateVoterRewardCommission(); + } +} + +contract ValidatorsTest_SetMaxVoterRewardCommission is ValidatorsTest { + function setUp() public { + super.setUp(); + } + + function test_ShouldSetMaxVoterRewardCommission() public { + uint256 maxCommission = FixidityLib.newFixedFraction(20, 100).unwrap(); // 20% + validators.setMaxVoterRewardCommission(maxCommission); + assertEq(validators.maxVoterRewardCommission(), maxCommission); + } + + function test_Emits_MaxVoterRewardCommissionSetEvent() public { + uint256 maxCommission = FixidityLib.newFixedFraction(20, 100).unwrap(); + vm.expectEmit(true, true, true, true); + emit MaxVoterRewardCommissionSet(maxCommission); + validators.setMaxVoterRewardCommission(maxCommission); + } + + function test_Reverts_WhenNotOwner() public { + uint256 maxCommission = FixidityLib.newFixedFraction(20, 100).unwrap(); + vm.expectRevert("Ownable: caller is not the owner"); + vm.prank(group); + validators.setMaxVoterRewardCommission(maxCommission); + } + + function test_Reverts_WhenGreaterThan100Percent() public { + vm.expectRevert("Max voter reward commission can't be greater than 100%"); + validators.setMaxVoterRewardCommission(FixidityLib.fixed1().unwrap().add(1)); + } + + function test_Reverts_WhenUnchanged() public { + vm.expectRevert("Max voter reward commission not changed"); + validators.setMaxVoterRewardCommission(0); // default is 0 + } + + function test_ShouldAllow100PercentMax() public { + uint256 fullMax = FixidityLib.fixed1().unwrap(); // 100% + validators.setMaxVoterRewardCommission(fullMax); + assertEq(validators.maxVoterRewardCommission(), fullMax); + } +} + +contract ValidatorsTest_GetVoterRewardCommission is ValidatorsTest { + function setUp() public { + super.setUp(); + _registerValidatorGroupHelper(group, 1); + } + + function test_ShouldReturnZeroByDefault() public { + (uint256 _commission, uint256 _next, uint256 _block) = validators.getVoterRewardCommission( + group + ); + assertEq(_commission, 0); + assertEq(_next, 0); + assertEq(_block, 0); + } + + function test_ShouldReturnCorrectValues() public { + uint256 newCommission = FixidityLib.newFixedFraction(10, 100).unwrap(); // 10% + + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(newCommission); + blockTravel(commissionUpdateDelay); + vm.prank(group); + validators.updateVoterRewardCommission(); + + (uint256 _commission, , ) = validators.getVoterRewardCommission(group); + assertEq(_commission, newCommission); + } + + function test_Reverts_WhenNotValidatorGroup() public { + vm.expectRevert("Not a validator group"); + validators.getVoterRewardCommission(validator); + } + + function test_ShouldReturnPendingValuesBeforeActivation() public { + uint256 newCommission = FixidityLib.newFixedFraction(10, 100).unwrap(); // 10% + + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(newCommission); + + (uint256 _commission, uint256 _next, uint256 _block) = validators.getVoterRewardCommission( + group + ); + assertEq(_commission, 0, "Current commission should still be 0"); + assertEq(_next, newCommission, "Next commission should be set"); + assertGt(_block, block.number, "Activation block should be in the future"); + } +} From 1ad36689da2cf7f25444549763593709f7635de1 Mon Sep 17 00:00:00 2001 From: Pavel Hornak Date: Wed, 11 Mar 2026 13:46:06 +0100 Subject: [PATCH 02/11] fix(protocol): correct version numbers for Validators and EpochManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validators: (1,4,1,0) not (1,5,0,0) — additive changes = minor bump EpochManager: (1,1,0,3) not (1,1,1,0) — internal logic change = patch bump --- packages/protocol/contracts-0.8/common/EpochManager.sol | 2 +- packages/protocol/contracts-0.8/governance/Validators.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/protocol/contracts-0.8/common/EpochManager.sol b/packages/protocol/contracts-0.8/common/EpochManager.sol index 7456ed81838..512c64064a9 100644 --- a/packages/protocol/contracts-0.8/common/EpochManager.sol +++ b/packages/protocol/contracts-0.8/common/EpochManager.sol @@ -623,7 +623,7 @@ contract EpochManager is * @return Patch version of the contract. */ function getVersionNumber() external pure returns (uint256, uint256, uint256, uint256) { - return (1, 1, 1, 0); + return (1, 1, 0, 3); } /** diff --git a/packages/protocol/contracts-0.8/governance/Validators.sol b/packages/protocol/contracts-0.8/governance/Validators.sol index d911dc54932..a6af1a8f52f 100644 --- a/packages/protocol/contracts-0.8/governance/Validators.sol +++ b/packages/protocol/contracts-0.8/governance/Validators.sol @@ -874,7 +874,7 @@ contract Validators is * @return Patch version of the contract. */ function getVersionNumber() external pure returns (uint256, uint256, uint256, uint256) { - return (1, 5, 0, 0); + return (1, 4, 1, 0); } /** From d2563cac70643a7b30f813a1f4ed2f3ea132fb28 Mon Sep 17 00:00:00 2001 From: Pavel Hornak Date: Tue, 17 Mar 2026 10:53:28 +0100 Subject: [PATCH 03/11] fix(protocol): address PR review comments on voter reward commission - Remove unnecessary _sendValidatorGroupPaymentsIfNecessary call from updateVoterRewardCommission (voterRewardCommission is independent from cUSD validator payment commission) - Re-check maxVoterRewardCommission at activation time to prevent cap bypass when governance lowers the cap between queue and activation - Bump EpochManager version to (1,1,0,4) for new behavior - Add NatSpec documenting treasury economic trade-off on _deductVoterRewardCommission - Add tests for max cap re-check at activation --- .../contracts-0.8/common/EpochManager.sol | 10 ++++- .../contracts-0.8/governance/Validators.sol | 12 ++++- .../governance/validators/Validators.t.sol | 44 +++++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/protocol/contracts-0.8/common/EpochManager.sol b/packages/protocol/contracts-0.8/common/EpochManager.sol index 512c64064a9..df550d998d8 100644 --- a/packages/protocol/contracts-0.8/common/EpochManager.sol +++ b/packages/protocol/contracts-0.8/common/EpochManager.sol @@ -392,6 +392,8 @@ contract EpochManager is // checks that group is actually from elected group require(epochRewards > 0, "group not from current elected set"); if (epochRewards != type(uint256).max) { + // Note: Uses in-place subtraction instead of explicit variable names (as in processGroup) + // to avoid stack-too-deep in this function which has more local variables. epochRewards -= _deductVoterRewardCommission(groups[i], epochRewards); if (epochRewards > 0) { election.distributeEpochRewards(groups[i], epochRewards, lessers[i], greaters[i]); @@ -623,7 +625,7 @@ contract EpochManager is * @return Patch version of the contract. */ function getVersionNumber() external pure returns (uint256, uint256, uint256, uint256) { - return (1, 1, 0, 3); + return (1, 1, 0, 4); } /** @@ -706,6 +708,12 @@ contract EpochManager is * @param group The validator group address. * @param epochRewards The total voter epoch rewards for this group. * @return commissionAmount The amount deducted as commission. + * @dev ECONOMIC NOTE: This releases real CELO from CeloUnreleasedTreasury that is NOT accounted + * for in EpochRewards.calculateTargetEpochRewards(). Voter rewards are normally distributed as + * vote credit inflation (no CELO release), but this commission converts a portion into real CELO. + * The additional treasury outflow per epoch equals the sum of (groupVoterRewards * groupCommission) + * across all elected groups. This is intentional — the commission incentivizes groups but does + * increase the effective CELO emission rate beyond what calculateTargetEpochRewards() plans for. */ function _deductVoterRewardCommission( address group, diff --git a/packages/protocol/contracts-0.8/governance/Validators.sol b/packages/protocol/contracts-0.8/governance/Validators.sol index a6af1a8f52f..58bc4c49102 100644 --- a/packages/protocol/contracts-0.8/governance/Validators.sol +++ b/packages/protocol/contracts-0.8/governance/Validators.sol @@ -514,14 +514,22 @@ contract Validators is require(isValidatorGroup(account), "Not a validator group"); ValidatorGroup storage group = groups[account]; - _sendValidatorGroupPaymentsIfNecessary(group); - require(group.nextVoterRewardCommissionBlock != 0, "No voter reward commission update queued"); require( group.nextVoterRewardCommissionBlock <= block.number, "Can't apply voter reward commission update yet" ); + // Re-check max cap at activation time. Governance may have lowered the cap since the + // update was queued, and unlike regular commission, voter reward commission directly + // releases CELO from treasury — so bypassing the cap is more consequential. + if (maxVoterRewardCommission > 0) { + require( + group.nextVoterRewardCommission.unwrap() <= maxVoterRewardCommission, + "Voter reward commission exceeds max allowed" + ); + } + group.voterRewardCommission = group.nextVoterRewardCommission; delete group.nextVoterRewardCommission; delete group.nextVoterRewardCommissionBlock; diff --git a/packages/protocol/test-sol/unit/governance/validators/Validators.t.sol b/packages/protocol/test-sol/unit/governance/validators/Validators.t.sol index ee79443893a..71a0cd4d808 100644 --- a/packages/protocol/test-sol/unit/governance/validators/Validators.t.sol +++ b/packages/protocol/test-sol/unit/governance/validators/Validators.t.sol @@ -2690,6 +2690,50 @@ contract ValidatorsTest_UpdateVoterRewardCommission is ValidatorsTest { vm.prank(validator); validators.updateVoterRewardCommission(); } + + function test_Reverts_WhenMaxCapLoweredAfterQueue() public { + uint256 maxCap = FixidityLib.newFixedFraction(20, 100).unwrap(); // 20% + validators.setMaxVoterRewardCommission(maxCap); + + // Queue 15% — valid at queue time (below 20% cap) + uint256 commission = FixidityLib.newFixedFraction(15, 100).unwrap(); + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(commission); + + // Governance lowers cap to 10% + uint256 newMaxCap = FixidityLib.newFixedFraction(10, 100).unwrap(); + validators.setMaxVoterRewardCommission(newMaxCap); + + blockTravel(commissionUpdateDelay); + + // Activation should revert because queued 15% exceeds new 10% cap + vm.expectRevert("Voter reward commission exceeds max allowed"); + vm.prank(group); + validators.updateVoterRewardCommission(); + } + + function test_ShouldActivate_WhenQueuedValueStillBelowLoweredCap() public { + uint256 maxCap = FixidityLib.newFixedFraction(20, 100).unwrap(); // 20% + validators.setMaxVoterRewardCommission(maxCap); + + // Queue 5% — valid at queue time + uint256 commission = FixidityLib.newFixedFraction(5, 100).unwrap(); + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(commission); + + // Governance lowers cap to 10% + uint256 newMaxCap = FixidityLib.newFixedFraction(10, 100).unwrap(); + validators.setMaxVoterRewardCommission(newMaxCap); + + blockTravel(commissionUpdateDelay); + + // Activation should succeed because queued 5% is still below new 10% cap + vm.prank(group); + validators.updateVoterRewardCommission(); + + (uint256 _commission, , ) = validators.getVoterRewardCommission(group); + assertEq(_commission, commission); + } } contract ValidatorsTest_SetMaxVoterRewardCommission is ValidatorsTest { From f9ec8e88add6cf946d9e8591737cf3de67734a42 Mon Sep 17 00:00:00 2001 From: Pavel Hornak Date: Tue, 17 Mar 2026 12:47:00 +0100 Subject: [PATCH 04/11] test(protocol): add fuzz and epoch-timing tests for voter reward commission Add fuzz tests verifying conservation invariant (commission + voterRewards == totalRewards) across random commission rates and reward amounts, plus tests proving that commission changes during epoch processing use the rate active at processGroup() time rather than epoch start. Also adds Validators fuzz tests for queue/activate lifecycle, max cap enforcement, and cap-lowered-after-queue scenarios. --- .../test-sol/unit/common/EpochManager.t.sol | 263 ++++++++++++++++++ .../governance/validators/Validators.t.sol | 120 ++++++++ 2 files changed, 383 insertions(+) diff --git a/packages/protocol/test-sol/unit/common/EpochManager.t.sol b/packages/protocol/test-sol/unit/common/EpochManager.t.sol index 6f69cbc43f4..4295da998f6 100644 --- a/packages/protocol/test-sol/unit/common/EpochManager.t.sol +++ b/packages/protocol/test-sol/unit/common/EpochManager.t.sol @@ -1166,3 +1166,266 @@ contract EpochManagerTest_voterRewardCommission is EpochManagerTest { ); } } + +contract EpochManagerTest_voterRewardCommission_Fuzz is EpochManagerTest { + function setUp() public override(EpochManagerTest) { + super.setUp(); + setupAndElectValidators(); + } + + /// @notice Conservation invariant: commission + voterRewards == totalEpochRewards + /// for any valid commission rate. + function test_conservesTotalRewardsForAnyCommission(uint256 commissionRate) public { + commissionRate = bound(commissionRate, 1, FIXED1); + uint256 groupEpochRewards = 1000e18; + + validators.setVoterRewardCommission(group, commissionRate); + election.setGroupEpochRewardsBasedOnScore(group, groupEpochRewards); + + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + + uint256 groupBalanceBefore = celoToken.balanceOf(group); + epochManagerContract.processGroup(group, address(0), address(0)); + uint256 groupBalanceAfter = celoToken.balanceOf(group); + + uint256 commissionReceived = groupBalanceAfter - groupBalanceBefore; + uint256 voterRewardsDistributed = election.distributedEpochRewards(group); + + assertEq( + commissionReceived + voterRewardsDistributed, + groupEpochRewards, + "Conservation: commission + voter rewards must equal total epoch rewards" + ); + } + + /// @notice Conservation invariant holds for any epoch reward amount. + function test_conservesTotalRewardsForAnyEpochRewardAmount(uint256 rewardAmount) public { + // Bound to [1, 1e30] — safe for FixidityLib.newFixed() which multiplies by 1e24. + rewardAmount = bound(rewardAmount, 1, 1e30); + uint256 commissionRate = 100000000000000000000000; // 10% + + validators.setVoterRewardCommission(group, commissionRate); + election.setGroupEpochRewardsBasedOnScore(group, rewardAmount); + + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + + uint256 groupBalanceBefore = celoToken.balanceOf(group); + epochManagerContract.processGroup(group, address(0), address(0)); + uint256 groupBalanceAfter = celoToken.balanceOf(group); + + uint256 commissionReceived = groupBalanceAfter - groupBalanceBefore; + uint256 voterRewardsDistributed = election.distributedEpochRewards(group); + + assertEq( + commissionReceived + voterRewardsDistributed, + rewardAmount, + "Conservation: commission + voter rewards must equal total epoch rewards" + ); + } + + /// @notice Conservation invariant holds for any commission rate AND any reward amount. + function test_conservesTotalRewardsForAnyCommissionAndRewards( + uint256 commissionRate, + uint256 rewardAmount + ) public { + commissionRate = bound(commissionRate, 1, FIXED1); + rewardAmount = bound(rewardAmount, 1, 1e30); + + validators.setVoterRewardCommission(group, commissionRate); + election.setGroupEpochRewardsBasedOnScore(group, rewardAmount); + + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + + uint256 groupBalanceBefore = celoToken.balanceOf(group); + epochManagerContract.processGroup(group, address(0), address(0)); + uint256 groupBalanceAfter = celoToken.balanceOf(group); + + uint256 commissionReceived = groupBalanceAfter - groupBalanceBefore; + uint256 voterRewardsDistributed = election.distributedEpochRewards(group); + + assertEq( + commissionReceived + voterRewardsDistributed, + rewardAmount, + "Conservation: commission + voter rewards must equal total epoch rewards" + ); + } + + /// @notice Verify the commission math matches expected FixidityLib calculation. + /// commissionAmount = floor(epochRewards * commissionRate / FIXED1) + function test_commissionAmountMatchesExpectedCalculation( + uint256 commissionRate, + uint256 rewardAmount + ) public { + commissionRate = bound(commissionRate, 1, FIXED1); + rewardAmount = bound(rewardAmount, 1, 1e30); + + validators.setVoterRewardCommission(group, commissionRate); + election.setGroupEpochRewardsBasedOnScore(group, rewardAmount); + + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + + uint256 groupBalanceBefore = celoToken.balanceOf(group); + epochManagerContract.processGroup(group, address(0), address(0)); + uint256 groupBalanceAfter = celoToken.balanceOf(group); + + uint256 commissionReceived = groupBalanceAfter - groupBalanceBefore; + + // Expected: FixidityLib.newFixed(rewardAmount).multiply(wrap(commissionRate)).fromFixed() + // which is: (rewardAmount * FIXED1) * commissionRate / FIXED1 / FIXED1 + // = rewardAmount * commissionRate / FIXED1 + uint256 expectedCommission = (rewardAmount * commissionRate) / FIXED1; + + assertEq( + commissionReceived, + expectedCommission, + "Commission amount must match FixidityLib floor calculation" + ); + } + + /// @notice At 100% commission, group receives all rewards and voters receive nothing. + function test_fullCommissionForAnyRewardAmount(uint256 rewardAmount) public { + rewardAmount = bound(rewardAmount, 1, 1e30); + + validators.setVoterRewardCommission(group, FIXED1); + election.setGroupEpochRewardsBasedOnScore(group, rewardAmount); + + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + + uint256 groupBalanceBefore = celoToken.balanceOf(group); + epochManagerContract.processGroup(group, address(0), address(0)); + uint256 groupBalanceAfter = celoToken.balanceOf(group); + + assertEq( + groupBalanceAfter - groupBalanceBefore, + rewardAmount, + "Group should receive all rewards at 100% commission" + ); + assertEq( + election.distributedEpochRewards(group), + 0, + "Voters should receive nothing at 100% commission" + ); + } +} + +contract EpochManagerTest_voterRewardCommission_DuringEpochProcessing is EpochManagerTest { + uint256 groupEpochRewards = 1000e18; + uint256 tenPercent = 100000000000000000000000; // FixidityLib.newFixedFraction(10, 100) + uint256 fiftyPercent = 500000000000000000000000; // FixidityLib.newFixedFraction(50, 100) + + function setUp() public override(EpochManagerTest) { + super.setUp(); + setupAndElectValidators(); + election.setGroupEpochRewardsBasedOnScore(group, groupEpochRewards); + } + + /// @notice Demonstrates that commission read at processGroup() time uses the + /// value active at that moment — NOT the value at epoch start. + /// A group can activate a new commission between setToProcessGroups() and + /// processGroup() to apply a different rate to already-computed rewards. + function test_usesCommissionActiveAtProcessingTime() public { + // Set initial commission to 10% + validators.setVoterRewardCommission(group, tenPercent); + + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + // Rewards are now computed and stored in processedGroups[group] = 1000e18 + + // --- Simulate group activating a queued commission update mid-processing --- + // In production, this would be updateVoterRewardCommission() called by the group. + // Using the mock's direct setter to simulate the effect. + validators.setVoterRewardCommission(group, fiftyPercent); + + uint256 groupBalanceBefore = celoToken.balanceOf(group); + epochManagerContract.processGroup(group, address(0), address(0)); + uint256 groupBalanceAfter = celoToken.balanceOf(group); + + uint256 commissionReceived = groupBalanceAfter - groupBalanceBefore; + uint256 voterRewardsDistributed = election.distributedEpochRewards(group); + + // Commission should be 50% of 1000e18 = 500e18 (the NEW rate, not the 10% initial) + uint256 expectedCommission = 500e18; + uint256 expectedVoterRewards = 500e18; + + assertEq( + commissionReceived, + expectedCommission, + "Commission should use the rate active at processGroup time (50%), not at epoch start (10%)" + ); + assertEq( + voterRewardsDistributed, + expectedVoterRewards, + "Voter rewards should reflect the commission rate active at processGroup time" + ); + } + + /// @notice A group can reduce its commission to 0 during epoch processing, + /// causing voters to receive full rewards despite commission being set at epoch start. + function test_usesZeroCommissionWhenRemovedDuringProcessing() public { + validators.setVoterRewardCommission(group, fiftyPercent); + + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + + // Group removes commission mid-processing + validators.setVoterRewardCommission(group, 0); + + uint256 groupBalanceBefore = celoToken.balanceOf(group); + epochManagerContract.processGroup(group, address(0), address(0)); + uint256 groupBalanceAfter = celoToken.balanceOf(group); + + assertEq( + groupBalanceAfter, + groupBalanceBefore, + "Group should receive nothing when commission zeroed during processing" + ); + assertEq( + election.distributedEpochRewards(group), + groupEpochRewards, + "Voters should receive full rewards when commission zeroed during processing" + ); + } + + /// @notice Conservation invariant holds even when commission changes mid-processing. + function test_conservesTotalRewardsWhenCommissionChangedDuringProcessing( + uint256 initialRate, + uint256 newRate + ) public { + initialRate = bound(initialRate, 1, FIXED1); + newRate = bound(newRate, 0, FIXED1); + + validators.setVoterRewardCommission(group, initialRate); + + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + + // Change commission mid-processing + validators.setVoterRewardCommission(group, newRate); + + uint256 groupBalanceBefore = celoToken.balanceOf(group); + epochManagerContract.processGroup(group, address(0), address(0)); + uint256 groupBalanceAfter = celoToken.balanceOf(group); + + uint256 commissionReceived = groupBalanceAfter - groupBalanceBefore; + uint256 voterRewardsDistributed = election.distributedEpochRewards(group); + + assertEq( + commissionReceived + voterRewardsDistributed, + groupEpochRewards, + "Conservation must hold even when commission changes during epoch processing" + ); + + // Verify the NEW rate was used, not the initial one + uint256 expectedCommission = (groupEpochRewards * newRate) / FIXED1; + assertEq( + commissionReceived, + expectedCommission, + "Commission should be calculated using the rate active at processGroup time" + ); + } +} diff --git a/packages/protocol/test-sol/unit/governance/validators/Validators.t.sol b/packages/protocol/test-sol/unit/governance/validators/Validators.t.sol index 71a0cd4d808..25b8268e899 100644 --- a/packages/protocol/test-sol/unit/governance/validators/Validators.t.sol +++ b/packages/protocol/test-sol/unit/governance/validators/Validators.t.sol @@ -2825,3 +2825,123 @@ contract ValidatorsTest_GetVoterRewardCommission is ValidatorsTest { assertGt(_block, block.number, "Activation block should be in the future"); } } + +contract ValidatorsTest_VoterRewardCommission_Fuzz is ValidatorsTest { + using FixidityLib for FixidityLib.Fraction; + using SafeMath for uint256; + + function setUp() public { + super.setUp(); + _registerValidatorGroupHelper(group, 1); + } + + /// @notice Any commission in (0, fixed1()] should be queueable. + function test_ShouldQueueAnyValidCommission(uint256 commission) public { + commission = bound(commission, 1, FixidityLib.fixed1().unwrap()); + + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(commission); + + (, uint256 _nextCommission, uint256 _nextBlock) = validators.getVoterRewardCommission(group); + assertEq(_nextCommission, commission, "Queued commission should match input"); + assertEq( + _nextBlock, + commissionUpdateDelay.add(uint256(block.number)), + "Activation block should be block.number + delay" + ); + } + + /// @notice Any commission in (0, fixed1()] should survive the full queue + activate cycle. + function test_ShouldQueueAndActivateAnyValidCommission(uint256 commission) public { + commission = bound(commission, 1, FixidityLib.fixed1().unwrap()); + + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(commission); + + blockTravel(commissionUpdateDelay); + + vm.prank(group); + validators.updateVoterRewardCommission(); + + (uint256 _commission, uint256 _next, uint256 _block) = validators.getVoterRewardCommission( + group + ); + assertEq(_commission, commission, "Active commission should match queued value"); + assertEq(_next, 0, "Pending commission should be cleared"); + assertEq(_block, 0, "Pending block should be cleared"); + } + + /// @notice Any commission above maxVoterRewardCommission should revert at queue time. + function test_ShouldRevertForAnyCommissionAboveMax(uint256 commission, uint256 maxCap) public { + maxCap = bound(maxCap, 1, FixidityLib.fixed1().unwrap().sub(1)); + commission = bound(commission, maxCap.add(1), FixidityLib.fixed1().unwrap()); + + validators.setMaxVoterRewardCommission(maxCap); + + vm.expectRevert("Voter reward commission exceeds max allowed"); + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(commission); + } + + /// @notice Any commission at or below maxVoterRewardCommission should succeed. + function test_ShouldAcceptAnyCommissionAtOrBelowMax(uint256 commission, uint256 maxCap) public { + maxCap = bound(maxCap, 1, FixidityLib.fixed1().unwrap()); + commission = bound(commission, 1, maxCap); + + validators.setMaxVoterRewardCommission(maxCap); + + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(commission); + + (, uint256 _nextCommission, ) = validators.getVoterRewardCommission(group); + assertEq(_nextCommission, commission, "Commission at or below max should be queued"); + } + + /// @notice When governance lowers the cap after queuing, activation should revert + /// if the queued value exceeds the new cap. + function test_ShouldRevertActivationWhenCapLoweredBelowQueued( + uint256 commission, + uint256 initialCap, + uint256 newCap + ) public { + // Set up: initialCap >= commission > newCap > 0 + initialCap = bound(initialCap, 3, FixidityLib.fixed1().unwrap()); + commission = bound(commission, 2, initialCap); + newCap = bound(newCap, 1, commission.sub(1)); + + validators.setMaxVoterRewardCommission(initialCap); + + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(commission); + + // Governance lowers cap + validators.setMaxVoterRewardCommission(newCap); + + blockTravel(commissionUpdateDelay); + + vm.expectRevert("Voter reward commission exceeds max allowed"); + vm.prank(group); + validators.updateVoterRewardCommission(); + } + + /// @notice Any commission above fixed1() should always revert (regardless of max cap). + function test_ShouldRevertForAnyCommissionAbove100Percent(uint256 commission) public { + commission = bound(commission, FixidityLib.fixed1().unwrap().add(1), uint256(-1)); + + vm.expectRevert("Voter reward commission can't be greater than 100%"); + vm.prank(group); + validators.setNextVoterRewardCommissionUpdate(commission); + } + + /// @notice Max voter reward commission can be set to any value in [1, fixed1()]. + function test_ShouldSetAnyValidMaxVoterRewardCommission(uint256 maxCommission) public { + maxCommission = bound(maxCommission, 1, FixidityLib.fixed1().unwrap()); + + validators.setMaxVoterRewardCommission(maxCommission); + assertEq( + validators.maxVoterRewardCommission(), + maxCommission, + "Max commission should match input" + ); + } +} From 0e23fde18a728add22e738a12349f39c1656b70a Mon Sep 17 00:00:00 2001 From: Pavel Hornak Date: Tue, 17 Mar 2026 13:21:33 +0100 Subject: [PATCH 05/11] fix(protocol): clamp voter reward commission to governance cap at distribution time Previously, groups that activated a commission before governance lowered maxVoterRewardCommission would keep collecting at the old (higher) rate. Now _deductVoterRewardCommission clamps the effective rate to the cap, ensuring governance changes take effect immediately for all groups. --- .../contracts-0.8/common/EpochManager.sol | 7 + .../governance/test/IMockValidators.sol | 4 + .../governance/test/MockValidators.sol | 8 +- .../test-sol/unit/common/EpochManager.t.sol | 120 ++++++++++++++++++ 4 files changed, 136 insertions(+), 3 deletions(-) diff --git a/packages/protocol/contracts-0.8/common/EpochManager.sol b/packages/protocol/contracts-0.8/common/EpochManager.sol index df550d998d8..70ab3081e48 100644 --- a/packages/protocol/contracts-0.8/common/EpochManager.sol +++ b/packages/protocol/contracts-0.8/common/EpochManager.sol @@ -726,6 +726,13 @@ contract EpochManager is return 0; } + // Clamp to the governance-set max cap so that previously-activated commissions + // exceeding a later-lowered cap are still bounded at distribution time. + uint256 maxCommission = validators.maxVoterRewardCommission(); + if (maxCommission > 0 && voterRewardCommissionUnwrapped > maxCommission) { + voterRewardCommissionUnwrapped = maxCommission; + } + commissionAmount = FixidityLib .newFixed(epochRewards) .multiply(FixidityLib.wrap(voterRewardCommissionUnwrapped)) diff --git a/packages/protocol/contracts-0.8/governance/test/IMockValidators.sol b/packages/protocol/contracts-0.8/governance/test/IMockValidators.sol index 06f0dc39f67..a8965690a1a 100644 --- a/packages/protocol/contracts-0.8/governance/test/IMockValidators.sol +++ b/packages/protocol/contracts-0.8/governance/test/IMockValidators.sol @@ -67,4 +67,8 @@ interface IMockValidators { function setEpochRewards(address account, uint256 reward) external; function mintedStable() external view returns (uint256); + + function maxVoterRewardCommission() external view returns (uint256); + + function setMaxVoterRewardCommission(uint256 maxCommission) external; } diff --git a/packages/protocol/contracts/governance/test/MockValidators.sol b/packages/protocol/contracts/governance/test/MockValidators.sol index ac90df29721..89b1aa1f00f 100644 --- a/packages/protocol/contracts/governance/test/MockValidators.sol +++ b/packages/protocol/contracts/governance/test/MockValidators.sol @@ -211,12 +211,14 @@ contract MockValidators is IValidators { revert("Method not implemented in mock"); } - function setMaxVoterRewardCommission(uint256) external { - revert("Method not implemented in mock"); + uint256 private _maxVoterRewardCommission; + + function setMaxVoterRewardCommission(uint256 maxCommission) external { + _maxVoterRewardCommission = maxCommission; } function maxVoterRewardCommission() external view returns (uint256) { - return 0; + return _maxVoterRewardCommission; } function resetSlashingMultiplier() external { diff --git a/packages/protocol/test-sol/unit/common/EpochManager.t.sol b/packages/protocol/test-sol/unit/common/EpochManager.t.sol index 4295da998f6..78db404d72f 100644 --- a/packages/protocol/test-sol/unit/common/EpochManager.t.sol +++ b/packages/protocol/test-sol/unit/common/EpochManager.t.sol @@ -1429,3 +1429,123 @@ contract EpochManagerTest_voterRewardCommission_DuringEpochProcessing is EpochMa ); } } + +contract EpochManagerTest_voterRewardCommission_MaxCapClamp is EpochManagerTest { + uint256 groupEpochRewards = 1000e18; + uint256 fiftyPercent = 500000000000000000000000; // 50% + uint256 twentyPercent = 200000000000000000000000; // 20% + uint256 tenPercent = 100000000000000000000000; // 10% + + function setUp() public override(EpochManagerTest) { + super.setUp(); + setupAndElectValidators(); + election.setGroupEpochRewardsBasedOnScore(group, groupEpochRewards); + } + + /// @notice When a group's active commission (50%) exceeds the governance cap (20%), + /// the effective commission is clamped to the cap at distribution time. + function test_clampsCommissionToMaxCapAtDistributionTime() public { + // Group has 50% commission active + validators.setVoterRewardCommission(group, fiftyPercent); + // Governance sets cap to 20% + validators.setMaxVoterRewardCommission(twentyPercent); + + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + + uint256 groupBalanceBefore = celoToken.balanceOf(group); + epochManagerContract.processGroup(group, address(0), address(0)); + uint256 groupBalanceAfter = celoToken.balanceOf(group); + + uint256 commissionReceived = groupBalanceAfter - groupBalanceBefore; + + // Should be clamped to 20%, not the group's 50% + uint256 expectedCommission = 200e18; // 20% of 1000e18 + assertEq( + commissionReceived, + expectedCommission, + "Commission should be clamped to maxVoterRewardCommission" + ); + + // Voters get the remaining 80% + assertEq( + election.distributedEpochRewards(group), + 800e18, + "Voters should receive rewards minus clamped commission" + ); + } + + /// @notice When commission is below the cap, no clamping occurs. + function test_doesNotClampWhenCommissionBelowCap() public { + validators.setVoterRewardCommission(group, tenPercent); + validators.setMaxVoterRewardCommission(twentyPercent); + + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + + uint256 groupBalanceBefore = celoToken.balanceOf(group); + epochManagerContract.processGroup(group, address(0), address(0)); + uint256 groupBalanceAfter = celoToken.balanceOf(group); + + // 10% commission, below 20% cap — no clamping + assertEq( + groupBalanceAfter - groupBalanceBefore, + 100e18, + "Commission below cap should not be clamped" + ); + } + + /// @notice When maxVoterRewardCommission is 0 (no cap), no clamping occurs. + function test_doesNotClampWhenMaxCapIsZero() public { + validators.setVoterRewardCommission(group, fiftyPercent); + // maxVoterRewardCommission defaults to 0 = no cap + + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + + uint256 groupBalanceBefore = celoToken.balanceOf(group); + epochManagerContract.processGroup(group, address(0), address(0)); + uint256 groupBalanceAfter = celoToken.balanceOf(group); + + // No cap = full 50% + assertEq( + groupBalanceAfter - groupBalanceBefore, + 500e18, + "No cap (0) should allow full commission" + ); + } + + /// @notice Conservation invariant holds with clamping — for any commission and cap combo. + function test_conservesTotalRewardsWithClamping(uint256 commissionRate, uint256 maxCap) public { + commissionRate = bound(commissionRate, 1, FIXED1); + maxCap = bound(maxCap, 1, FIXED1); + + validators.setVoterRewardCommission(group, commissionRate); + validators.setMaxVoterRewardCommission(maxCap); + + epochManagerContract.startNextEpochProcess(); + epochManagerContract.setToProcessGroups(); + + uint256 groupBalanceBefore = celoToken.balanceOf(group); + epochManagerContract.processGroup(group, address(0), address(0)); + uint256 groupBalanceAfter = celoToken.balanceOf(group); + + uint256 commissionReceived = groupBalanceAfter - groupBalanceBefore; + uint256 voterRewardsDistributed = election.distributedEpochRewards(group); + + assertEq( + commissionReceived + voterRewardsDistributed, + groupEpochRewards, + "Conservation must hold with max cap clamping" + ); + + // Effective rate should be min(commissionRate, maxCap) + uint256 effectiveRate = commissionRate < maxCap ? commissionRate : maxCap; + uint256 expectedCommission = (groupEpochRewards * effectiveRate) / FIXED1; + assertEq( + commissionReceived, + expectedCommission, + "Commission should use min(groupCommission, maxCap)" + ); + } +} From 98720090ffaa2bdd313d9111fe7a2d008330eb61 Mon Sep 17 00:00:00 2001 From: Pavel Hornak Date: Tue, 17 Mar 2026 14:39:01 +0100 Subject: [PATCH 06/11] docs(protocol): fix misleading NatSpec on voter reward commission economics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous comment incorrectly stated commission CELO was 'NOT accounted for' in the reward budget. Commission is carved from the already-budgeted totalRewardsVoter pool — it redirects part of the voter reward from deferred LockedGold claims to immediate treasury releases, not additional emission. --- .../protocol/contracts-0.8/common/EpochManager.sol | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/protocol/contracts-0.8/common/EpochManager.sol b/packages/protocol/contracts-0.8/common/EpochManager.sol index 70ab3081e48..28a8c9a0d3f 100644 --- a/packages/protocol/contracts-0.8/common/EpochManager.sol +++ b/packages/protocol/contracts-0.8/common/EpochManager.sol @@ -708,12 +708,14 @@ contract EpochManager is * @param group The validator group address. * @param epochRewards The total voter epoch rewards for this group. * @return commissionAmount The amount deducted as commission. - * @dev ECONOMIC NOTE: This releases real CELO from CeloUnreleasedTreasury that is NOT accounted - * for in EpochRewards.calculateTargetEpochRewards(). Voter rewards are normally distributed as - * vote credit inflation (no CELO release), but this commission converts a portion into real CELO. - * The additional treasury outflow per epoch equals the sum of (groupVoterRewards * groupCommission) - * across all elected groups. This is intentional — the commission incentivizes groups but does - * increase the effective CELO emission rate beyond what calculateTargetEpochRewards() plans for. + * @dev ECONOMIC NOTE: Voter rewards are normally distributed as vote credit inflation via + * Election.distributeEpochRewards(), which creates deferred claims on the LockedGold pool + * redeemable when voters revoke and withdraw. This commission converts a portion of the + * already-budgeted totalRewardsVoter into an immediate CELO release from CeloUnreleasedTreasury. + * The total economic cost is unchanged — commission redirects part of the voter reward budget + * from deferred LockedGold claims to immediate treasury releases. The per-epoch treasury outflow + * from commission equals the sum of (groupVoterRewards * groupCommission) across all elected + * groups, bounded by maxVoterRewardCommission. */ function _deductVoterRewardCommission( address group, From 08a0cdbad7153dbfb89eeba9511faadb703c629c Mon Sep 17 00:00:00 2001 From: Pavel Hornak Date: Tue, 17 Mar 2026 14:52:59 +0100 Subject: [PATCH 07/11] docs(protocol): remove inaccurate NatSpec comments in Validators Remove misleading dev note suggesting epoch-processing blocking is needed (the 3-day commissionUpdateDelay makes this moot), and remove false claim that voter reward commission is 'more consequential' than regular commission (both ultimately cost the protocol). --- packages/protocol/contracts-0.8/governance/Validators.sol | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/protocol/contracts-0.8/governance/Validators.sol b/packages/protocol/contracts-0.8/governance/Validators.sol index 58bc4c49102..af1d5fbc198 100644 --- a/packages/protocol/contracts-0.8/governance/Validators.sol +++ b/packages/protocol/contracts-0.8/governance/Validators.sol @@ -506,8 +506,6 @@ contract Validators is /** * @notice Updates a validator group's voter reward commission based on the previously queued * update. - * @dev Note: Consider adding onlyWhenNotBlocked modifier if Validators inherits Blockable - * in the future, to prevent updates during epoch processing. */ function updateVoterRewardCommission() external { address account = getAccounts().validatorSignerToAccount(msg.sender); @@ -521,8 +519,7 @@ contract Validators is ); // Re-check max cap at activation time. Governance may have lowered the cap since the - // update was queued, and unlike regular commission, voter reward commission directly - // releases CELO from treasury — so bypassing the cap is more consequential. + // update was queued. if (maxVoterRewardCommission > 0) { require( group.nextVoterRewardCommission.unwrap() <= maxVoterRewardCommission, From 47b0bb3536e3d48a3a846909befd13bc9a616755 Mon Sep 17 00:00:00 2001 From: Pavel Hornak Date: Tue, 17 Mar 2026 15:01:23 +0100 Subject: [PATCH 08/11] docs(protocol): remove unnecessary implementation comment in finishNextEpochProcess --- packages/protocol/contracts-0.8/common/EpochManager.sol | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/protocol/contracts-0.8/common/EpochManager.sol b/packages/protocol/contracts-0.8/common/EpochManager.sol index 28a8c9a0d3f..1fd45411fc0 100644 --- a/packages/protocol/contracts-0.8/common/EpochManager.sol +++ b/packages/protocol/contracts-0.8/common/EpochManager.sol @@ -392,8 +392,6 @@ contract EpochManager is // checks that group is actually from elected group require(epochRewards > 0, "group not from current elected set"); if (epochRewards != type(uint256).max) { - // Note: Uses in-place subtraction instead of explicit variable names (as in processGroup) - // to avoid stack-too-deep in this function which has more local variables. epochRewards -= _deductVoterRewardCommission(groups[i], epochRewards); if (epochRewards > 0) { election.distributeEpochRewards(groups[i], epochRewards, lessers[i], greaters[i]); From f7e95c51608f22fa223cda4e94a8603b0165df6c Mon Sep 17 00:00:00 2001 From: Pavel Hornak Date: Tue, 17 Mar 2026 15:02:26 +0100 Subject: [PATCH 09/11] docs(protocol): remove opinionated recommended value from maxVoterRewardCommission Governance should decide the initial cap, not the contract NatSpec. --- packages/protocol/contracts-0.8/governance/Validators.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/protocol/contracts-0.8/governance/Validators.sol b/packages/protocol/contracts-0.8/governance/Validators.sol index af1d5fbc198..34a7a846321 100644 --- a/packages/protocol/contracts-0.8/governance/Validators.sol +++ b/packages/protocol/contracts-0.8/governance/Validators.sol @@ -136,7 +136,7 @@ contract Validators is // Cap on voter reward commission to protect voters from excessive commission rates. // Set via governance. A value of 0 means no cap is enforced. - // Recommended initial value: 20% (FixidityLib representation). + // FixidityLib representation uint256 public maxVoterRewardCommission; event MaxGroupSizeSet(uint256 size); @@ -925,7 +925,7 @@ contract Validators is * @notice Sets the maximum voter reward commission that groups can set. * @param maxCommission Fixidity representation of the max commission. * A value of 0 means no cap is enforced. - * Recommended initial value: 20% (FixidityLib representation). + * FixidityLib representation. */ function setMaxVoterRewardCommission(uint256 maxCommission) external onlyOwner { require( From f75e95d1ee8f427290497742913d930be0b94967 Mon Sep 17 00:00:00 2001 From: Pavel Hornak Date: Tue, 17 Mar 2026 15:12:53 +0100 Subject: [PATCH 10/11] fix(protocol): bound fuzz reward amounts to mock treasury balance Fuzz tests used 1e30 as upper bound for reward amounts, but the mock treasury only holds ~3.07e26 CELO (L2_INITIAL_STASH_BALANCE). Large fuzzed rewards caused commission releases to revert with 'Insufficient balance'. --- .../protocol/test-sol/unit/common/EpochManager.t.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/protocol/test-sol/unit/common/EpochManager.t.sol b/packages/protocol/test-sol/unit/common/EpochManager.t.sol index 78db404d72f..d0e1e40fafd 100644 --- a/packages/protocol/test-sol/unit/common/EpochManager.t.sol +++ b/packages/protocol/test-sol/unit/common/EpochManager.t.sol @@ -1201,8 +1201,8 @@ contract EpochManagerTest_voterRewardCommission_Fuzz is EpochManagerTest { /// @notice Conservation invariant holds for any epoch reward amount. function test_conservesTotalRewardsForAnyEpochRewardAmount(uint256 rewardAmount) public { - // Bound to [1, 1e30] — safe for FixidityLib.newFixed() which multiplies by 1e24. - rewardAmount = bound(rewardAmount, 1, 1e30); + // Bound to treasury balance — commission releases real CELO from the mock treasury. + rewardAmount = bound(rewardAmount, 1, L2_INITIAL_STASH_BALANCE); uint256 commissionRate = 100000000000000000000000; // 10% validators.setVoterRewardCommission(group, commissionRate); @@ -1231,7 +1231,7 @@ contract EpochManagerTest_voterRewardCommission_Fuzz is EpochManagerTest { uint256 rewardAmount ) public { commissionRate = bound(commissionRate, 1, FIXED1); - rewardAmount = bound(rewardAmount, 1, 1e30); + rewardAmount = bound(rewardAmount, 1, L2_INITIAL_STASH_BALANCE); validators.setVoterRewardCommission(group, commissionRate); election.setGroupEpochRewardsBasedOnScore(group, rewardAmount); @@ -1260,7 +1260,7 @@ contract EpochManagerTest_voterRewardCommission_Fuzz is EpochManagerTest { uint256 rewardAmount ) public { commissionRate = bound(commissionRate, 1, FIXED1); - rewardAmount = bound(rewardAmount, 1, 1e30); + rewardAmount = bound(rewardAmount, 1, L2_INITIAL_STASH_BALANCE); validators.setVoterRewardCommission(group, commissionRate); election.setGroupEpochRewardsBasedOnScore(group, rewardAmount); @@ -1288,7 +1288,7 @@ contract EpochManagerTest_voterRewardCommission_Fuzz is EpochManagerTest { /// @notice At 100% commission, group receives all rewards and voters receive nothing. function test_fullCommissionForAnyRewardAmount(uint256 rewardAmount) public { - rewardAmount = bound(rewardAmount, 1, 1e30); + rewardAmount = bound(rewardAmount, 1, L2_INITIAL_STASH_BALANCE); validators.setVoterRewardCommission(group, FIXED1); election.setGroupEpochRewardsBasedOnScore(group, rewardAmount); From 72373833b8bdce564b98a4f3211b7d63d8da1e43 Mon Sep 17 00:00:00 2001 From: Pavel Hornak Date: Tue, 17 Mar 2026 15:22:02 +0100 Subject: [PATCH 11/11] fix(protocol): use half treasury balance as fuzz bound to leave room for validator allocation startNextEpochProcess() calls allocateValidatorsRewards() which releases CELO from treasury before processGroup(). Using full L2_INITIAL_STASH_BALANCE as the reward bound means 100% commission can exceed what remains. --- .../protocol/test-sol/unit/common/EpochManager.t.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/protocol/test-sol/unit/common/EpochManager.t.sol b/packages/protocol/test-sol/unit/common/EpochManager.t.sol index d0e1e40fafd..231549524fb 100644 --- a/packages/protocol/test-sol/unit/common/EpochManager.t.sol +++ b/packages/protocol/test-sol/unit/common/EpochManager.t.sol @@ -1201,8 +1201,8 @@ contract EpochManagerTest_voterRewardCommission_Fuzz is EpochManagerTest { /// @notice Conservation invariant holds for any epoch reward amount. function test_conservesTotalRewardsForAnyEpochRewardAmount(uint256 rewardAmount) public { - // Bound to treasury balance — commission releases real CELO from the mock treasury. - rewardAmount = bound(rewardAmount, 1, L2_INITIAL_STASH_BALANCE); + // Bound below treasury balance — allocateValidatorsRewards() consumes part of it first. + rewardAmount = bound(rewardAmount, 1, L2_INITIAL_STASH_BALANCE / 2); uint256 commissionRate = 100000000000000000000000; // 10% validators.setVoterRewardCommission(group, commissionRate); @@ -1231,7 +1231,7 @@ contract EpochManagerTest_voterRewardCommission_Fuzz is EpochManagerTest { uint256 rewardAmount ) public { commissionRate = bound(commissionRate, 1, FIXED1); - rewardAmount = bound(rewardAmount, 1, L2_INITIAL_STASH_BALANCE); + rewardAmount = bound(rewardAmount, 1, L2_INITIAL_STASH_BALANCE / 2); validators.setVoterRewardCommission(group, commissionRate); election.setGroupEpochRewardsBasedOnScore(group, rewardAmount); @@ -1260,7 +1260,7 @@ contract EpochManagerTest_voterRewardCommission_Fuzz is EpochManagerTest { uint256 rewardAmount ) public { commissionRate = bound(commissionRate, 1, FIXED1); - rewardAmount = bound(rewardAmount, 1, L2_INITIAL_STASH_BALANCE); + rewardAmount = bound(rewardAmount, 1, L2_INITIAL_STASH_BALANCE / 2); validators.setVoterRewardCommission(group, commissionRate); election.setGroupEpochRewardsBasedOnScore(group, rewardAmount); @@ -1288,7 +1288,7 @@ contract EpochManagerTest_voterRewardCommission_Fuzz is EpochManagerTest { /// @notice At 100% commission, group receives all rewards and voters receive nothing. function test_fullCommissionForAnyRewardAmount(uint256 rewardAmount) public { - rewardAmount = bound(rewardAmount, 1, L2_INITIAL_STASH_BALANCE); + rewardAmount = bound(rewardAmount, 1, L2_INITIAL_STASH_BALANCE / 2); validators.setVoterRewardCommission(group, FIXED1); election.setGroupEpochRewardsBasedOnScore(group, rewardAmount);