diff --git a/packages/protocol/contracts-0.8/common/EpochManager.sol b/packages/protocol/contracts-0.8/common/EpochManager.sol index 30f9e19b927..1fd45411fc0 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, 0, 4); } /** @@ -682,6 +701,52 @@ 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. + * @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, + uint256 epochRewards + ) internal returns (uint256 commissionAmount) { + IValidators validators = getValidators(); + (uint256 voterRewardCommissionUnwrapped, , ) = validators.getVoterRewardCommission(group); + + if (voterRewardCommissionUnwrapped == 0) { + 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)) + .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..34a7a846321 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. + // 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,70 @@ 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. + */ + function updateVoterRewardCommission() external { + address account = getAccounts().validatorSignerToAccount(msg.sender); + require(isValidatorGroup(account), "Not a validator group"); + ValidatorGroup storage group = groups[account]; + + 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. + if (maxVoterRewardCommission > 0) { + require( + group.nextVoterRewardCommission.unwrap() <= maxVoterRewardCommission, + "Voter reward commission exceeds max allowed" + ); + } + + 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 +622,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 +879,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, 4, 1, 0); } /** @@ -821,6 +921,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. + * 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..a8965690a1a 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; @@ -61,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/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..89b1aa1f00f 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,24 @@ 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"); + } + + uint256 private _maxVoterRewardCommission; + + function setMaxVoterRewardCommission(uint256 maxCommission) external { + _maxVoterRewardCommission = maxCommission; + } + + function maxVoterRewardCommission() external view returns (uint256) { + return _maxVoterRewardCommission; + } + 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..231549524fb 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,562 @@ 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" + ); + } +} + +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 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); + 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, L2_INITIAL_STASH_BALANCE / 2); + + 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, L2_INITIAL_STASH_BALANCE / 2); + + 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, L2_INITIAL_STASH_BALANCE / 2); + + 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" + ); + } +} + +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)" + ); + } +} 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..25b8268e899 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,445 @@ 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(); + } + + 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 { + 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"); + } +} + +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" + ); + } +}