Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 70 additions & 3 deletions packages/protocol/contracts-0.8/common/EpochManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -376,7 +392,12 @@ 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]);
// 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]);
}
}

delete processedGroups[groups[i]];
Expand Down Expand Up @@ -604,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);
}

/**
Expand Down Expand Up @@ -682,6 +703,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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard commission lookup against non-group addresses

processGroup/finishNextEpochProcess now always call validators.getVoterRewardCommission(group), but Validators.getVoterRewardCommission reverts unless isValidatorGroup(account) is true. A group can still appear in processedGroups from last-epoch membership and then be deregistered before reward processing (for configs where the empty-group duration allows it), which makes this lookup revert and blocks epoch finalization. Before this change, reward processing for such a group would not hard-fail on validator-group existence, so this introduces a liveness risk tied to deregistration timing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Tolerate non-registered groups during reward processing

_deductVoterRewardCommission unconditionally calls validators.getVoterRewardCommission(group), but that getter reverts unless the address is currently a validator group (Validators.sol enforces isValidatorGroup). Here group is taken from last-epoch membership, so if a group was elected last epoch and deregisters before rewards are processed (e.g., when empty-group duration is configured low enough), processGroup/finishNextEpochProcess will revert and epoch finalization can be blocked.

Useful? React with 👍 / 👎.


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.
*/
Expand Down
121 changes: 120 additions & 1 deletion packages/protocol/contracts-0.8/governance/Validators.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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");
Expand Down Expand Up @@ -452,6 +469,73 @@ 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];

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;
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.
Expand Down Expand Up @@ -541,6 +625,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.
Expand Down Expand Up @@ -779,7 +882,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);
}

/**
Expand Down Expand Up @@ -821,6 +924,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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
29 changes: 29 additions & 0 deletions packages/protocol/contracts/governance/test/MockValidators.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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");
}
Expand Down
Loading
Loading