diff --git a/foundry.toml b/foundry.toml index bf8f7cd18f..c6c9d5b327 100644 --- a/foundry.toml +++ b/foundry.toml @@ -24,7 +24,7 @@ # Specifies the exact version of Solidity to use, overriding auto-detection. solc_version = '0.8.30' # If enabled, treats Solidity compiler warnings as errors, preventing artifact generation if warnings are present. - deny_warnings = true + deny = "warnings" # If set to true, changes compilation pipeline to go through the new IR optimizer. via_ir = false # Whether or not to enable the Solidity optimizer. diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol new file mode 100644 index 0000000000..750c58bd30 --- /dev/null +++ b/src/contracts/core/EmissionsController.sol @@ -0,0 +1,432 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import "@openzeppelin-upgrades/contracts/proxy/utils/Initializable.sol"; +import "@openzeppelin-upgrades/contracts/access/OwnableUpgradeable.sol"; +import "@openzeppelin-upgrades/contracts/security/ReentrancyGuardUpgradeable.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "../libraries/OperatorSetLib.sol"; +import "../permissions/Pausable.sol"; +import "./storage/EmissionsControllerStorage.sol"; + +contract EmissionsController is + Initializable, + OwnableUpgradeable, + Pausable, + ReentrancyGuardUpgradeable, + EmissionsControllerStorage +{ + using SafeERC20 for IERC20; + + /// @dev Modifier that checks if the caller is the incentive council. + modifier onlyIncentiveCouncil() { + _checkOnlyIncentiveCouncil(); + _; + } + + /// ----------------------------------------------------------------------- + /// Initialization + /// ----------------------------------------------------------------------- + constructor( + IEigen eigen, + IBackingEigen backingEigen, + IAllocationManager allocationManager, + IRewardsCoordinator rewardsCoordinator, + IPauserRegistry pauserRegistry, + uint256 inflationRate, + uint256 startTime, + uint256 cooldownSeconds + ) + EmissionsControllerStorage( + eigen, backingEigen, allocationManager, rewardsCoordinator, inflationRate, startTime, cooldownSeconds + ) + Pausable(pauserRegistry) + { + _disableInitializers(); + } + + /// @inheritdoc IEmissionsController + function initialize( + address initialOwner, + address initialIncentiveCouncil, + uint256 initialPausedStatus + ) external override initializer { + // Set the initial owner. + _transferOwnership(initialOwner); + // Set the initial incentive council. + _setIncentiveCouncil(initialIncentiveCouncil); + // Set the initial paused status. + _setPausedStatus(initialPausedStatus); + } + + /// ----------------------------------------------------------------------- + /// Permissionless Trigger + /// ----------------------------------------------------------------------- + + /// @inheritdoc IEmissionsController + function sweep() external override nonReentrant onlyWhenNotPaused(PAUSED_TOKEN_FLOWS) { + uint256 amount = EIGEN.balanceOf(address(this)); + if (!isButtonPressable() && amount != 0) { + IERC20(EIGEN).safeTransfer(incentiveCouncil, amount); + emit Swept(incentiveCouncil, amount); + } + } + + /// @inheritdoc IEmissionsController + function pressButton( + uint256 length + ) external override nonReentrant onlyWhenNotPaused(PAUSED_TOKEN_FLOWS) { + uint256 currentEpoch = getCurrentEpoch(); + + // Check if emissions have not started yet. + // Prevents minting EIGEN before the first epoch has started. + require(currentEpoch != type(uint256).max, EmissionsNotStarted()); + + uint256 totalDistributions = getTotalDistributions(); + uint256 nextDistributionId = _epochs[currentEpoch].totalProcessed; + + // Check if all distributions have already been processed. + require(nextDistributionId < totalDistributions, AllDistributionsProcessed()); + + // Mint the total amount of bEIGEN/EIGEN needed for all distributions. + if (!_epochs[currentEpoch].minted) { + // NOTE: Approvals may not be entirely spent. + + // Max approve EIGEN for spending bEIGEN. + BACKING_EIGEN.approve(address(EIGEN), EMISSIONS_INFLATION_RATE); + // Max approve RewardsCoordinator for spending EIGEN. + EIGEN.approve(address(REWARDS_COORDINATOR), EMISSIONS_INFLATION_RATE); + + // First mint the bEIGEN in order to wrap it into EIGEN. + BACKING_EIGEN.mint(address(this), EMISSIONS_INFLATION_RATE); + // Then wrap it into EIGEN. + EIGEN.wrap(EMISSIONS_INFLATION_RATE); + + // Mark the epoch as minted. + _epochs[currentEpoch].minted = true; + } + + // Calculate the start timestamp for the distribution (equivalent to `lastTimeButtonPressable()`). + uint256 startTimestamp = EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH * currentEpoch; + // Calculate the last index to process. + uint256 lastIndex = nextDistributionId + length; + + // If length exceeds total distributions, set last index to total distributions (exclusive upper bound). + if (lastIndex > totalDistributions) lastIndex = totalDistributions; + + // Process distributions starting from the next one to process... + for (uint256 distributionId = nextDistributionId; distributionId < lastIndex; ++distributionId) { + Distribution memory distribution = _distributions[distributionId]; + + // Skip disabled distributions... + if (distribution.distributionType == DistributionType.Disabled) continue; + // Skip distributions that haven't started yet... + if (distribution.startEpoch > currentEpoch) continue; + // Skip distributions that have ended (0 means infinite)... + if (distribution.totalEpochs != 0) { + if (currentEpoch > distribution.startEpoch + distribution.totalEpochs) continue; + } + + _processDistribution({ + distribution: distribution, + distributionId: distributionId, + currentEpoch: currentEpoch, + startTimestamp: startTimestamp + }); + } + + // Update total processed count for this epoch. + _epochs[currentEpoch].totalProcessed = uint64(lastIndex); + } + + /// @dev Internal helper that processes a distribution. + /// @param distributionId The id of the distribution to process. + /// @param currentEpoch The current epoch. + /// @param distribution The distribution (from storage). + function _processDistribution( + Distribution memory distribution, + uint256 distributionId, + uint256 currentEpoch, + uint256 startTimestamp + ) internal { + // Calculate the total amount of emissions for the distribution. + uint256 totalAmount = EMISSIONS_INFLATION_RATE * distribution.weight / MAX_TOTAL_WEIGHT; + // Success flag for the distribution. + bool success; + + // Skip if the total amount is 0. + if (totalAmount == 0) return; + + if (distribution.distributionType != DistributionType.Manual) { + uint256 strategiesAndMultipliersLength = distribution.strategiesAndMultipliers.length; + + // Skip cases where the below `amountPerSubmission` calculation would revert. + if (strategiesAndMultipliersLength > totalAmount) return; + + // Calculate the amount per submission. + uint256 amountPerSubmission = totalAmount / strategiesAndMultipliersLength; + + // Update the rewards submissions start timestamp, duration, and amount. + IRewardsCoordinator.RewardsSubmission[] memory rewardsSubmissions = + new IRewardsCoordinator.RewardsSubmission[](strategiesAndMultipliersLength); + for (uint256 i = 0; i < rewardsSubmissions.length; ++i) { + rewardsSubmissions[i] = IRewardsCoordinatorTypes.RewardsSubmission({ + strategiesAndMultipliers: distribution.strategiesAndMultipliers[i], + token: EIGEN, + amount: amountPerSubmission, + startTimestamp: uint32(startTimestamp), + duration: uint32(EMISSIONS_EPOCH_LENGTH) + }); + } + + // Dispatch the `RewardsCoordinator` call based on the distribution type. + if (distribution.distributionType == DistributionType.RewardsForAllEarners) { + success = _tryCallRewardsCoordinator( + abi.encodeCall(IRewardsCoordinator.createRewardsForAllEarners, (rewardsSubmissions)) + ); + } else if (distribution.distributionType == DistributionType.OperatorSetUniqueStake) { + success = _tryCallRewardsCoordinator( + abi.encodeCall( + IRewardsCoordinator.createUniqueStakeRewardsSubmission, + (distribution.operatorSet, rewardsSubmissions) + ) + ); + } else if (distribution.distributionType == DistributionType.OperatorSetTotalStake) { + success = _tryCallRewardsCoordinator( + abi.encodeCall( + IRewardsCoordinator.createTotalStakeRewardsSubmission, + (distribution.operatorSet, rewardsSubmissions) + ) + ); + } else if (distribution.distributionType == DistributionType.EigenDA) { + success = _tryCallRewardsCoordinator( + abi.encodeCall(IRewardsCoordinator.createAVSRewardsSubmission, (rewardsSubmissions)) + ); + } else { + revert InvalidDistributionType(); // Only reachable if the distribution type is `Disabled`. + } + } else { + (success,) = + address(EIGEN).call(abi.encodeWithSelector(IERC20.transfer.selector, incentiveCouncil, totalAmount)); + } + + // Emit an event for the processed distribution. + emit DistributionProcessed(distributionId, currentEpoch, distribution, success); + } + + /// @dev Internal helper that try/calls the RewardsCoordinator returning success or failure. + /// This is needed as using try/catch requires decoding the calldata, which can revert preventing further distributions. + /// @param abiEncodedCall The ABI encoded call to the RewardsCoordinator. + /// @return success True if the function call was successful, false otherwise. + function _tryCallRewardsCoordinator( + bytes memory abiEncodedCall + ) internal returns (bool success) { + (success,) = address(REWARDS_COORDINATOR).call(abiEncodedCall); + } + + /// ----------------------------------------------------------------------- + /// Owner Functions + /// ----------------------------------------------------------------------- + + /// @inheritdoc IEmissionsController + function setIncentiveCouncil( + address newIncentiveCouncil + ) external override onlyOwner { + _setIncentiveCouncil(newIncentiveCouncil); + } + + /// @dev Internal helper to set the incentive council. + function _setIncentiveCouncil( + address newIncentiveCouncil + ) internal { + // Set the new incentive council. + incentiveCouncil = newIncentiveCouncil; + // Emit an event for the updated incentive council. + emit IncentiveCouncilUpdated(newIncentiveCouncil); + } + + /// ----------------------------------------------------------------------- + /// Incentive Council Functions + /// ----------------------------------------------------------------------- + + /// @inheritdoc IEmissionsController + function addDistribution( + Distribution calldata distribution + ) external override onlyIncentiveCouncil returns (uint256 distributionId) { + // Checks + + uint256 currentEpoch = getCurrentEpoch(); + + // Check if the distribution is disabled. + require(distribution.distributionType != DistributionType.Disabled, CannotAddDisabledDistribution()); + + // Can only add/update distributions if all distributions have been processed for the current epoch. + // Prevents pending weight changes from affecting the current epoch. + _checkAllDistributionsProcessed(currentEpoch); + + uint256 totalWeightBefore = totalWeight; + + // Asserts the following: + // - The start epoch is in the future. + // - The total weight of all distributions does not exceed the max total weight. + _checkDistribution(distribution, currentEpoch, totalWeightBefore); + + // Effects + + // Increment the total added count for the current epoch. + ++_epochs[currentEpoch].totalAdded; + + // Update return value to the next available distribution id. + // Use the current length of the distributions array as the new id. + distributionId = _distributions.length; + + // Update the total weight. + totalWeight = uint16(totalWeightBefore + distribution.weight); + // Append the distribution to the distributions array. + _distributions.push(distribution); + // Emit an event for the new distribution. + emit DistributionAdded(distributionId, currentEpoch, distribution); + } + + /// @inheritdoc IEmissionsController + function updateDistribution( + uint256 distributionId, + Distribution calldata distribution + ) external override onlyIncentiveCouncil { + // Checks + + uint256 currentEpoch = getCurrentEpoch(); + uint256 totalWeightBefore = totalWeight; + uint256 weight = _distributions[distributionId].weight; + + // Can only add/update distributions if all distributions have been processed for the current epoch. + // Prevents pending weight changes from affecting the current epoch. + _checkAllDistributionsProcessed(currentEpoch); + + // Asserts the following: + // - The start epoch is in the future. + // - The total weight of all distributions does not exceed the max total weight. + _checkDistribution(distribution, currentEpoch, totalWeightBefore - weight); + + // Effects + + // Update the pending total weight (will be committed when pressButton starts a new epoch). + totalWeight = uint16(totalWeightBefore - weight + distribution.weight); + // Update the distribution in the distributions array. + _distributions[distributionId] = distribution; + // Emit an event for the updated distribution. + emit DistributionUpdated(distributionId, currentEpoch, distribution); + } + + /// ----------------------------------------------------------------------- + /// Internal Helpers + /// ----------------------------------------------------------------------- + + function _checkOnlyIncentiveCouncil() internal view { + // Check if the caller is the incentive council. + require(msg.sender == incentiveCouncil, CallerIsNotIncentiveCouncil()); + } + + function _checkAllDistributionsProcessed( + uint256 currentEpoch + ) internal view { + // Check if all distributions have been processed for the current epoch. + // Same logic found in `isButtonPressable()`. + // Skip check if emissions haven't started yet. + require( + currentEpoch == type(uint256).max || _epochs[currentEpoch].totalProcessed >= getTotalDistributions(), + NotAllDistributionsProcessed() + ); + } + + function _checkDistribution( + Distribution calldata distribution, + uint256 currentEpoch, + uint256 totalWeightBefore + ) internal view { + // Check if the operator set is registered (for OperatorSetTotalStake or OperatorSetUniqueStake distributions). + if ( + distribution.distributionType == DistributionType.OperatorSetTotalStake + || distribution.distributionType == DistributionType.OperatorSetUniqueStake + ) { + require(ALLOCATION_MANAGER.isOperatorSet(distribution.operatorSet), OperatorSetNotRegistered()); + } + + // Check if the start epoch is in the future. + // Prevents updating a distribution to a past or current epoch. + if (currentEpoch != type(uint256).max) { + // After emissions start - require future epochs only + require(distribution.startEpoch > currentEpoch, StartEpochMustBeInTheFuture()); + } + + // Check if the new total weight of all distributions exceeds max total weight. + // Prevents distributing more supply than inflation rate allows. + require(distribution.weight + totalWeightBefore <= MAX_TOTAL_WEIGHT, TotalWeightExceedsMax()); + + // Check if rewards submissions array is empty for non-Manual distributions. + // Manual distributions handle rewards differently and don't require submissions. + require( + distribution.distributionType == DistributionType.Manual + || distribution.strategiesAndMultipliers.length > 0, + RewardsSubmissionsCannotBeEmpty() + ); + } + + /// ----------------------------------------------------------------------- + /// View + /// ----------------------------------------------------------------------- + + /// @inheritdoc IEmissionsController + function getCurrentEpoch() public view returns (uint256) { + // If the start time has not elapsed, default to max uint256. + if (block.timestamp < EMISSIONS_START_TIME) return type(uint256).max; + // Calculate the current epoch by dividing the time since start by the epoch length. + return (block.timestamp - EMISSIONS_START_TIME) / EMISSIONS_EPOCH_LENGTH; + } + + /// @inheritdoc IEmissionsController + function isButtonPressable() public view returns (bool) { + return _epochs[getCurrentEpoch()].totalProcessed < getTotalDistributions(); + } + + /// @inheritdoc IEmissionsController + function nextTimeButtonPressable() external view returns (uint256) { + return EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH * (getCurrentEpoch() + 1); + } + + /// @inheritdoc IEmissionsController + function lastTimeButtonPressable() public view returns (uint256) { + return EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH * getCurrentEpoch(); + } + + /// @inheritdoc IEmissionsController + function getTotalDistributions() public view returns (uint256) { + uint256 currentEpoch = getCurrentEpoch(); + // Before emissions start, return the full count since distributions added + // before emissions are not tracked as "added in current epoch" + if (currentEpoch == type(uint256).max) { + return _distributions.length; + } + return _distributions.length - _epochs[currentEpoch].totalAdded; + } + + /// @inheritdoc IEmissionsController + function getDistribution( + uint256 distributionId + ) external view returns (Distribution memory) { + return _distributions[distributionId]; + } + + /// @inheritdoc IEmissionsController + function getDistributions( + uint256 start, + uint256 length + ) external view returns (Distribution[] memory distributions) { + // Create a new array in memory with the given length. + distributions = new Distribution[](length); + // Copy the specified subset of distributions from the storage array to the memory array. + for (uint256 i = 0; i < length; ++i) { + distributions[i] = _distributions[start + i]; + } + } +} diff --git a/src/contracts/core/storage/EmissionsControllerStorage.sol b/src/contracts/core/storage/EmissionsControllerStorage.sol new file mode 100644 index 0000000000..275f1ba00b --- /dev/null +++ b/src/contracts/core/storage/EmissionsControllerStorage.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import "../../interfaces/IEmissionsController.sol"; +import "../../interfaces/IRewardsCoordinator.sol"; + +abstract contract EmissionsControllerStorage is IEmissionsController { + // Constants + + /// @inheritdoc IEmissionsController + uint256 public constant MAX_TOTAL_WEIGHT = 10_000; + + /// @dev Index for flag that pauses pressing the button when set. + uint8 internal constant PAUSED_TOKEN_FLOWS = 0; + + // Immutables + + /// @dev The EIGEN token that will be minted for emissions. + IEigen public immutable override EIGEN; + /// @dev The backing Eigen token that will be minted for emissions. + IBackingEigen public immutable override BACKING_EIGEN; + /// @dev The AllocationManager contract for managing operator sets. + IAllocationManager public immutable override ALLOCATION_MANAGER; + /// @dev The RewardsCoordinator contract for submitting rewards. + IRewardsCoordinator public immutable override REWARDS_COORDINATOR; + + /// @inheritdoc IEmissionsController + uint256 public immutable EMISSIONS_INFLATION_RATE; + /// @inheritdoc IEmissionsController + uint256 public immutable EMISSIONS_START_TIME; + /// @inheritdoc IEmissionsController + uint256 public immutable EMISSIONS_EPOCH_LENGTH; + + // Mutatables + + // Slot 0 (Packed) + + /// @inheritdoc IEmissionsController + address public incentiveCouncil; + /// @inheritdoc IEmissionsController + uint16 public totalWeight; + + /// @dev Returns an append-only array of distributions. + Distribution[] internal _distributions; + /// @dev Mapping from epoch number to epoch metadata. + mapping(uint256 epoch => Epoch) internal _epochs; + + // Construction + + constructor( + IEigen eigen, + IBackingEigen backingEigen, + IAllocationManager allocationManager, + IRewardsCoordinator rewardsCoordinator, + uint256 inflationRate, + uint256 startTime, + uint256 epochLength + ) { + uint256 calculationIntervalSeconds = rewardsCoordinator.CALCULATION_INTERVAL_SECONDS(); + + // Check if epochLength is aligned with CALCULATION_INTERVAL_SECONDS. + if (epochLength % calculationIntervalSeconds != 0) { + revert EpochLengthNotAlignedWithCalculationInterval(); + } + // Check if startTime is aligned with CALCULATION_INTERVAL_SECONDS. + if (startTime % calculationIntervalSeconds != 0) { + revert StartTimeNotAlignedWithCalculationInterval(); + } + + EIGEN = eigen; + BACKING_EIGEN = backingEigen; + ALLOCATION_MANAGER = allocationManager; + REWARDS_COORDINATOR = rewardsCoordinator; + + EMISSIONS_INFLATION_RATE = inflationRate; + EMISSIONS_START_TIME = startTime; + EMISSIONS_EPOCH_LENGTH = epochLength; + } + + /// @dev This empty reserved space is put in place to allow future versions to add new + /// variables without shifting down storage in the inheritance chain. + /// See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps + uint256[47] private __gap; +} diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index 1c35b2b77b..27aa6c9c34 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -1,19 +1,53 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity >=0.5.0; +import "./IRewardsCoordinator.sol"; +import "./IEigen.sol"; +import "./IBackingEigen.sol"; +import "./IPausable.sol"; + /// @title IEmissionsControllerErrors /// @notice Errors for the IEmissionsController contract. interface IEmissionsControllerErrors { - // TODO: Define with implementation. - - } + /// @dev Thrown when caller is not the incentive council. + error CallerIsNotIncentiveCouncil(); + /// @dev Thrown when the start epoch is the current or past epoch. + error StartEpochMustBeInTheFuture(); + /// @dev Thrown when the total weight of all distributions exceeds the max total weight (100%). + error TotalWeightExceedsMax(); + /// @dev Thrown when attempting to add a disabled distribution. + error CannotAddDisabledDistribution(); + /// @dev Thrown when all distributions have been processed for the current epoch. + error AllDistributionsProcessed(); + /// @dev Thrown when not all distributions have been processed for the current epoch. + error NotAllDistributionsProcessed(); + /// @dev Thrown when the distribution type is invalid. Should be unreachable. + error InvalidDistributionType(); + /// @dev Thrown when rewards submissions array is empty for a distribution that requires it. + error RewardsSubmissionsCannotBeEmpty(); + /// @dev Thrown when the operator set is not registered. + error OperatorSetNotRegistered(); + /// @dev Thrown when emissions have not started yet. + error EmissionsNotStarted(); + /// @dev Thrown when epoch length is not aligned with CALCULATION_INTERVAL_SECONDS. + error EpochLengthNotAlignedWithCalculationInterval(); + /// @dev Thrown when start time is not aligned with CALCULATION_INTERVAL_SECONDS. + error StartTimeNotAlignedWithCalculationInterval(); +} /// @title IEmissionsControllerTypes /// @notice Types for the IEmissionsController contract. interface IEmissionsControllerTypes { - /// @notice Distribution types as defined in the ELIP. - /// @dev Ref: "Distribution Submission types may include: createRewardsForAllEarners, createOperatorSetTotalStakeRewardsSubmission, createOperatorSetUniqueStakeRewardsSubmission, EigenDA Distribution, Manual Distribution." + /// @notice Distribution types that determine how emissions are routed to the RewardsCoordinator. + /// @dev Each type maps to a specific RewardsCoordinator function or minting mechanism. + /// - Disabled: Distribution is inactive and skipped during processing + /// - RewardsForAllEarners: Calls `createRewardsForAllEarners` for protocol-wide rewards + /// - OperatorSetTotalStake: Calls `createTotalStakeRewardsSubmission` for operator set rewards weighted by total stake + /// - OperatorSetUniqueStake: Calls `createUniqueStakeRewardsSubmission` for operator set rewards weighted by unique stake + /// - EigenDA: Calls `createAVSRewardsSubmission` for EigenDA-specific rewards + /// - Manual: Directly mints bEIGEN to specified recipients without RewardsCoordinator interaction enum DistributionType { + Disabled, RewardsForAllEarners, OperatorSetTotalStake, OperatorSetUniqueStake, @@ -21,51 +55,101 @@ interface IEmissionsControllerTypes { Manual } - /// @notice A Distribution structure containing weight, type and strategies. - /// @dev Ref: "A Distribution consists of N fields: Weight, Distribution-type, Strategies and Multipliers." + /// @notice A struct containing the total minted and processed amounts for an epoch. + struct Epoch { + /// Whether the epoch has been minted. + bool minted; + /// The total number of distributions processed for the epoch. + uint64 totalProcessed; + /// The total number of distributions that have been added this epoch. + uint64 totalAdded; + } + + /// @notice A Distribution structure defining how a portion of emissions should be allocated. + /// @dev Distributions are stored in an append-only array and processed each epoch by `pressButton`. + /// The weight determines the proportion of total emissions allocated to this distribution. + /// Active distributions are processed sequentially. struct Distribution { /// The bips denominated weight of the distribution. - uint256 weight; + uint64 weight; + /// The configured start epoch. + uint64 startEpoch; + /// The number of epochs to repeat the distribution (0 for infinite, 1 for single epoch, N for N epochs). + uint64 totalEpochs; /// The type of distribution. DistributionType distributionType; - /// The encoded rewards submission (either `RewardsSubmission` or `OperatorDirectedRewardsSubmission`). - bytes encodedRewardsSubmission; + /// The operator set (Required only for OperatorSetTotalStake and OperatorSetUniqueStake distribution types). + OperatorSet operatorSet; + /// The strategies and their respective multipliers for distributing rewards. + IRewardsCoordinatorTypes.StrategyAndMultiplier[][] strategiesAndMultipliers; } } /// @title IEmissionsControllerEvents /// @notice Events for the IEmissionsController contract. interface IEmissionsControllerEvents is IEmissionsControllerTypes { - /// @notice Emitted when a distribution is updated. + /// @notice Emitted when the remaining EIGEN is swept to the incentive council. + /// @param incentiveCouncil The address to sweep the EIGEN to. + /// @param amount The amount of EIGEN swept. + event Swept(address indexed incentiveCouncil, uint256 amount); + + /// @notice Emitted when a distribution is processed. /// @param distributionId The id of the distribution. + /// @param epoch The epoch the distribution was processed. /// @param distribution The distribution. - event DistributionUpdated(uint256 indexed distributionId, Distribution distribution); + event DistributionProcessed( + uint256 indexed distributionId, + uint256 indexed epoch, + Distribution distribution, + bool success + ); /// @notice Emitted when a distribution is added. /// @param distributionId The id of the distribution. + /// @param epoch The epoch the distribution was added. /// @param distribution The distribution. - event DistributionAdded(uint256 indexed distributionId, Distribution distribution); + event DistributionAdded(uint256 indexed distributionId, uint256 indexed epoch, Distribution distribution); - /// @notice Emitted when a distribution is removed. + /// @notice Emitted when a distribution is updated. /// @param distributionId The id of the distribution. - event DistributionRemoved(uint256 indexed distributionId); + /// @param epoch The epoch the distribution was updated. + /// @param distribution The distribution. + event DistributionUpdated(uint256 indexed distributionId, uint256 indexed epoch, Distribution distribution); /// @notice Emitted when the Incentive Council address is updated. /// @param incentiveCouncil The new Incentive Council address. event IncentiveCouncilUpdated(address indexed incentiveCouncil); - - /// @notice Emitted when the inflation rate is updated. - /// @param inflationRate The new inflation rate. - event InflationRateUpdated(uint256 indexed inflationRate); } /// @title IEmissionsController -/// @notice Interface for the EmissionsController contract, which acts as the upgraded ActionGenerator. -/// @dev Ref: "This proposal requires upgrades to the TokenHopper and Action Generator contracts." -interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControllerEvents { +/// @notice Interface for the EmissionsController contract that manages programmatic EIGEN emissions. +/// @dev The EmissionsController mints EIGEN at a fixed inflation rate per epoch and distributes it +/// according to configured distributions. It replaces the legacy ActionGenerator pattern with +/// a more flexible distribution system controlled by the Incentive Council. +interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControllerEvents, IPausable { /// ----------------------------------------------------------------------- /// Constants /// ----------------------------------------------------------------------- + /// @notice The EIGEN token address. + /// @dev Immutable/constant variable that requires an upgrade to modify. + function EIGEN() external view returns (IEigen); + + /// @notice The BACKING_EIGEN token address. + /// @dev Immutable/constant variable that requires an upgrade to modify. + function BACKING_EIGEN() external view returns (IBackingEigen); + + /// @notice The AllocationManager address. + /// @dev Immutable/constant variable that requires an upgrade to modify. + function ALLOCATION_MANAGER() external view returns (IAllocationManager); + + /// @notice The RewardsCoordinator address. + /// @dev Immutable/constant variable that requires an upgrade to modify. + function REWARDS_COORDINATOR() external view returns (IRewardsCoordinator); + + /// @notice The max total weight of all distributions. + /// @dev Constant variable that requires an upgrade to modify. + function MAX_TOTAL_WEIGHT() external view returns (uint256); + /// @notice The rate of inflation for emissions. /// @dev Immutable/constant variable that requires an upgrade to modify. function EMISSIONS_INFLATION_RATE() external view returns (uint256); @@ -76,36 +160,51 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// @notice The cooldown seconds of the emissions. /// @dev Immutable/constant variable that requires an upgrade to modify. - function EMISSIONS_COOLDOWN_SECONDS() external view returns (uint256); + function EMISSIONS_EPOCH_LENGTH() external view returns (uint256); /// ----------------------------------------------------------------------- /// Initialization Functions /// ----------------------------------------------------------------------- /// @notice Initializes the contract. + /// @param initialOwner The initial owner address. /// @param incentiveCouncil The initial Incentive Council address. + /// @param initialPausedStatus The initial paused status. function initialize( - address incentiveCouncil + address initialOwner, + address incentiveCouncil, + uint256 initialPausedStatus ) external; /// ----------------------------------------------------------------------- /// Permissionless Trigger /// ----------------------------------------------------------------------- - /// @notice Triggers the weekly emissions. - /// @dev Try/catch is used to prevent a single reverting rewards submission from halting emissions. - /// @dev Pagination is used to prevent out-of-gas errors; multiple calls may be needed to process all submissions. - /// @dev Ref: "The ActionGenerator today is a contract ... that is triggered by the Hopper. When triggered, it mints new EIGEN tokens..." + /// @notice Sweeps the remaining EIGEN to the incentive council. + /// @dev This function is only callable after all distributions have been processed for the current epoch. + function sweep() external; + + /// @notice Triggers emissions for the current epoch and processes distributions. + /// @dev This function mints EMISSIONS_INFLATION_RATE of bEIGEN, wraps it to EIGEN, then processes + /// distributions from _totalProcessed[currentEpoch] up to the specified length. + /// Each distribution receives a proportional amount based on its weight and submits to RewardsCoordinator. + /// Uses low-level calls to prevent reverts in one distribution from blocking others. + /// Can be called multiple times per epoch to paginate through all distributions if needed. /// @dev Permissionless function that can be called by anyone when `isButtonPressable()` returns true. - function pressButton() external; + /// @param length The number of distributions to process (upper bound for the loop). + function pressButton( + uint256 length + ) external; /// ----------------------------------------------------------------------- /// Protocol Council Functions /// ----------------------------------------------------------------------- /// @notice Sets the Incentive Council address. - /// @dev Only the Protocol Council can call this function. - /// @dev Ref: "Protocol Council Functions: Set Incentive Council multisig address that can interface with the ActionGenerator..." + /// @dev Only the contract owner (Protocol Council) can call this function. + /// The Incentive Council has exclusive rights to add, update, and disable distributions. + /// This separation of powers allows the Protocol Council to delegate distribution management + /// without giving up ownership of the contract. /// @param incentiveCouncil The new Incentive Council address. function setIncentiveCouncil( address incentiveCouncil @@ -115,49 +214,62 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// Incentive Council Functions /// ----------------------------------------------------------------------- - /// @notice Adds a new distribution. + /// @notice Adds a new distribution to the emissions schedule. /// @dev Only the Incentive Council can call this function. - /// @dev Ref: "Incentive Council Functions: addDistribution(weight{int}, distribution-type{see below}, strategiesAndMultipliers())" + /// The distribution is appended to the _distributions array and assigned the next available ID. + /// The distribution's weight is added to totalWeight, which must not exceed MAX_TOTAL_WEIGHT. + /// The startEpoch must be in the future to prevent immediate processing. + /// Cannot add distributions with DistributionType.Disabled. /// @param distribution The distribution to add. /// @return distributionId The id of the added distribution. function addDistribution( Distribution calldata distribution ) external returns (uint256 distributionId); - /// @notice Updates an existing distribution. + /// @notice Updates an existing distribution's parameters. /// @dev Only the Incentive Council can call this function. - /// @dev Ref: "Incentive Council Functions: updateDistribution(distributionId)" + /// Replaces the entire distribution at the given ID with the new distribution. + /// Adjusts totalWeight by subtracting the old weight and adding the new weight. + /// Set `disabled` to true to disable the distribution. + /// Set `disabled` to false to re-enable the distribution. /// @param distributionId The id of the distribution to update. - /// @param distribution The new distribution. + /// @param distribution The new distribution parameters. function updateDistribution( uint256 distributionId, Distribution calldata distribution ) external; - /// @notice Cancels a distribution. - /// @dev The distribution remains in storage, but is marked as cancelled. - /// @dev Only the Incentive Council can call this function. - /// @dev Ref: Implied by "updateDistribution" and general management of distributions. - /// @param distributionId The id of the distribution to remove. - function removeDistribution( - uint256 distributionId - ) external; - /// ----------------------------------------------------------------------- /// View /// ----------------------------------------------------------------------- + /// @notice Returns the current Incentive Council address. + /// @return The Incentive Council address. + function incentiveCouncil() external view returns (address); + + /// @notice Returns the total weight of all distributions. + /// @return The total weight of all distributions. + function totalWeight() external view returns (uint16); + + /// @notice Returns the current epoch. + /// @return The current epoch. + function getCurrentEpoch() external view returns (uint256); + /// @notice Checks if the emissions can be triggered. /// @return True if the cooldown has passed and the system is ready. function isButtonPressable() external view returns (bool); - /// @notice Returns the next button press time. - /// @return The next button press time. - function nextButtonPressTime() external view returns (uint256); + /// @notice Returns the next time the button will be pressable. + /// @return The next time the button will be pressable. + function nextTimeButtonPressable() external view returns (uint256); - /// @notice Returns the last button press time. - /// @return The last button press time. - function lastButtonPressTime() external view returns (uint256); + /// @notice Returns the last time the button was pressable. + /// @return The last time the button was pressable. + function lastTimeButtonPressable() external view returns (uint256); + + /// @notice Returns the total number of distributions. + /// @return The total number of distributions. + function getTotalDistributions() external view returns (uint256); /// @notice Returns a distribution by index. /// @param distributionId The id of the distribution. @@ -166,11 +278,12 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll uint256 distributionId ) external view returns (Distribution memory); - /// @notice Returns all distributions. + /// @notice Returns a subset of distributions. + /// @param start The start index of the distributions. + /// @param length The length of the distributions. /// @return An append-only array of Distribution structs. - function getDistributions() external view returns (Distribution[] memory); - - /// @notice Returns the current Incentive Council address. - /// @return The Incentive Council address. - function getIncentiveCouncil() external view returns (address); + function getDistributions( + uint256 start, + uint256 length + ) external view returns (Distribution[] memory); } diff --git a/src/test/mocks/BackingEigenMock.sol b/src/test/mocks/BackingEigenMock.sol new file mode 100644 index 0000000000..1852254d9c --- /dev/null +++ b/src/test/mocks/BackingEigenMock.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.9; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract BackingEigenMock is Test, ERC20 { + constructor() ERC20("Backing EIGEN", "bEIGEN") {} + + function mint(address to, uint amount) external { + _mint(to, amount); + } +} + diff --git a/src/test/mocks/EigenMock.sol b/src/test/mocks/EigenMock.sol new file mode 100644 index 0000000000..0af75641c9 --- /dev/null +++ b/src/test/mocks/EigenMock.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.9; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "./BackingEigenMock.sol"; + +contract EigenMock is Test, ERC20 { + BackingEigenMock backingEigen; + + constructor(BackingEigenMock _backingEigen) ERC20("EIGEN", "EIGEN") { + backingEigen = _backingEigen; + } + + function wrap(uint amount) external { + backingEigen.transferFrom(msg.sender, address(this), amount); + _mint(msg.sender, amount); + } +} diff --git a/src/test/mocks/RewardsCoordinatorMock.sol b/src/test/mocks/RewardsCoordinatorMock.sol new file mode 100644 index 0000000000..e9c439f1e6 --- /dev/null +++ b/src/test/mocks/RewardsCoordinatorMock.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.9; + +import "forge-std/Test.sol"; +import "../../contracts/interfaces/IRewardsCoordinator.sol"; + +contract RewardsCoordinatorMock is Test { + /// @notice The interval in seconds at which the rewards calculation is done + /// @dev Defaulting to 1 day to match the real RewardsCoordinator configuration + uint32 public CALCULATION_INTERVAL_SECONDS = 1 days; + + function createRewardsForAllEarners(IRewardsCoordinatorTypes.RewardsSubmission[] calldata) external { + // Mock implementation - does nothing for testing + } + + function createUniqueStakeRewardsSubmission(OperatorSet calldata, IRewardsCoordinatorTypes.RewardsSubmission[] calldata) external { + // Mock implementation - does nothing for testing + } + + function createTotalStakeRewardsSubmission(OperatorSet calldata, IRewardsCoordinatorTypes.RewardsSubmission[] calldata) external { + // Mock implementation - does nothing for testing + } + + function createAVSRewardsSubmission(IRewardsCoordinatorTypes.RewardsSubmission[] calldata) external { + // Mock implementation - does nothing for testing + } +} + diff --git a/src/test/unit/ECDSACertificateVerifierUnit.t.sol b/src/test/unit/ECDSACertificateVerifierUnit.t.sol index 9b238f602b..ea68e54a94 100644 --- a/src/test/unit/ECDSACertificateVerifierUnit.t.sol +++ b/src/test/unit/ECDSACertificateVerifierUnit.t.sol @@ -481,7 +481,7 @@ contract ECDSACertificateVerifierUnitTests_verifyCertificate is ECDSACertificate // Verification should fail - expect SignersNotOrdered because signature recovery // with wrong message hash produces different addresses that break ordering - vm.expectRevert(IECDSACertificateVerifierErrors.SignersNotOrdered.selector); + vm.expectRevert(); // SignersNotOrdered or VerificationFailed verifier.verifyCertificate(defaultOperatorSet, cert); } diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol new file mode 100644 index 0000000000..634ae4eb6c --- /dev/null +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -0,0 +1,1271 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import "src/contracts/core/EmissionsController.sol"; +import "src/test/utils/EigenLayerUnitTestSetup.sol"; + +contract EmissionsControllerUnitTests is EigenLayerUnitTestSetup, IEmissionsControllerErrors, IEmissionsControllerEvents { + using StdStyle for *; + using ArrayLib for *; + + uint EMISSIONS_INFLATION_RATE = 50; + // Use a fixed start time that's aligned with CALCULATION_INTERVAL_SECONDS (1 day = 86400) + // 7 days from an aligned base ensures proper alignment + uint EMISSIONS_START_TIME = 7 days; + uint EMISSIONS_EPOCH_LENGTH = 1 weeks; + + address owner = address(0x1); + address incentiveCouncil = address(0x2); + EmissionsController emissionsController; + + function setUp() public virtual override { + EigenLayerUnitTestSetup.setUp(); + emissionsController = EmissionsController( + address( + new TransparentUpgradeableProxy( + address( + new EmissionsController( + IEigen(address(eigenMock)), + IBackingEigen(address(backingEigenMock)), + IAllocationManager(address(allocationManagerMock)), + IRewardsCoordinator(address(rewardsCoordinatorMock)), + IPauserRegistry(address(pauserRegistry)), + EMISSIONS_INFLATION_RATE, + EMISSIONS_START_TIME, + EMISSIONS_EPOCH_LENGTH + ) + ), + address(eigenLayerProxyAdmin), + abi.encodeWithSelector(EmissionsController.initialize.selector, owner, incentiveCouncil, 0) + ) + ) + ); + } + + function emptyOperatorSet() public pure returns (OperatorSet memory) { + return OperatorSet({avs: address(0), id: 0}); + } + + function emptyStrategiesAndMultipliers() public pure returns (IRewardsCoordinatorTypes.StrategyAndMultiplier[][] memory) { + return new IRewardsCoordinatorTypes.StrategyAndMultiplier[][](0); + } + + function defaultStrategiesAndMultipliers() public pure returns (IRewardsCoordinatorTypes.StrategyAndMultiplier[][] memory) { + IRewardsCoordinatorTypes.StrategyAndMultiplier[][] memory submissions = new IRewardsCoordinatorTypes.StrategyAndMultiplier[][](1); + submissions[0] = new IRewardsCoordinatorTypes.StrategyAndMultiplier[](0); + return submissions; + } +} + +/// ----------------------------------------------------------------------- +/// Initialization +/// ----------------------------------------------------------------------- + +contract EmissionsControllerUnitTests_Initialization_Setters is EmissionsControllerUnitTests { + function test_constructor_setters() public { + assertEq(emissionsController.EMISSIONS_INFLATION_RATE(), EMISSIONS_INFLATION_RATE); + assertEq(emissionsController.EMISSIONS_START_TIME(), EMISSIONS_START_TIME); + assertEq(emissionsController.EMISSIONS_EPOCH_LENGTH(), EMISSIONS_EPOCH_LENGTH); + } + + function test_initialize_setters() public { + assertEq(emissionsController.EMISSIONS_INFLATION_RATE(), EMISSIONS_INFLATION_RATE); + assertEq(emissionsController.EMISSIONS_START_TIME(), EMISSIONS_START_TIME); + assertEq(emissionsController.EMISSIONS_EPOCH_LENGTH(), EMISSIONS_EPOCH_LENGTH); + } + + function test_revert_initialize_AlreadyInitialized() public { + cheats.expectRevert("Initializable: contract is already initialized"); + emissionsController.initialize(owner, incentiveCouncil, 0); + } +} + +/// ----------------------------------------------------------------------- +/// Permissionless Trigger +/// ----------------------------------------------------------------------- + +contract EmissionsControllerUnitTests_pressButton is EmissionsControllerUnitTests { + function test_revert_pressButton_AllDistributionsProcessed_NoDistributions() public { + // Warp to after emissions start + cheats.warp(EMISSIONS_START_TIME); + + // Attempt to press button with no distributions should revert + cheats.expectRevert(IEmissionsControllerErrors.AllDistributionsProcessed.selector); + emissionsController.pressButton(0); + } + + function test_revert_pressButton_AllDistributionsProcessed() public { + // Add a distribution first (before emissions start, so startEpoch 0 is in the future) + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + totalEpochs: 1, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Warp to after emissions start (now at epoch 0) + cheats.warp(EMISSIONS_START_TIME); + + // Process all distributions + emissionsController.pressButton(1); + + // Attempt to press button again should revert + cheats.expectRevert(IEmissionsControllerErrors.AllDistributionsProcessed.selector); + emissionsController.pressButton(1); + } + + function test_revert_pressButton_EmissionsNotStarted() public { + // Add a distribution + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + totalEpochs: 1, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Before emissions start, pressing button should revert + cheats.expectRevert(IEmissionsControllerErrors.EmissionsNotStarted.selector); + emissionsController.pressButton(1); + } +} + +contract EmissionsControllerUnitTests_sweep is EmissionsControllerUnitTests { + function test_sweep_TransfersTokensToIncentiveCouncil() public { + // Add distribution + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + totalEpochs: 1, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Warp to epoch 0 and press button + cheats.warp(EMISSIONS_START_TIME); + emissionsController.pressButton(1); + + // Give some EIGEN tokens directly to the controller (simulating leftover tokens) + uint sweepAmount = 100 ether; + deal(address(eigenMock), address(emissionsController), sweepAmount); + + // Verify initial balance + assertEq(eigenMock.balanceOf(address(emissionsController)), sweepAmount); + uint councilBalanceBefore = eigenMock.balanceOf(incentiveCouncil); + + // Sweep should work after all distributions are processed + cheats.expectEmit(true, true, true, true); + emit Swept(incentiveCouncil, sweepAmount); + emissionsController.sweep(); + + // Verify tokens were transferred + assertEq(eigenMock.balanceOf(address(emissionsController)), 0); + assertEq(eigenMock.balanceOf(incentiveCouncil), councilBalanceBefore + sweepAmount); + } + + function test_sweep_DoesNothingWhenButtonPressable() public { + // Add distribution + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + totalEpochs: 1, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Warp to epoch 0 - button is pressable + cheats.warp(EMISSIONS_START_TIME); + + // Give some EIGEN tokens directly to the controller + uint sweepAmount = 100 ether; + deal(address(eigenMock), address(emissionsController), sweepAmount); + + // Sweep should do nothing when button is pressable + emissionsController.sweep(); + + // Tokens should still be in the controller + assertEq(eigenMock.balanceOf(address(emissionsController)), sweepAmount); + } + + function test_sweep_DoesNothingWhenBalanceZeroAfterButtonNotPressable() public { + // Don't add any distributions - button won't be pressable after start + + // Warp to epoch 0 + cheats.warp(EMISSIONS_START_TIME); + + // Can't press button with no distributions + assertFalse(emissionsController.isButtonPressable()); + + // Balance is 0, sweep should do nothing (no transfer, no event) + uint councilBalanceBefore = eigenMock.balanceOf(incentiveCouncil); + assertEq(eigenMock.balanceOf(address(emissionsController)), 0); + emissionsController.sweep(); + assertEq(eigenMock.balanceOf(address(emissionsController)), 0); + assertEq(eigenMock.balanceOf(incentiveCouncil), councilBalanceBefore); + } +} + +contract EmissionsControllerUnitTests_pressButton_DistributionTypes is EmissionsControllerUnitTests { + OperatorSet testOperatorSet = OperatorSet({avs: address(0x123), id: 1}); + + function test_pressButton_OperatorSetUniqueStake() public { + // Mock operator set as registered + allocationManagerMock.setIsOperatorSet(testOperatorSet, true); + + // Add OperatorSetUniqueStake distribution + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + totalEpochs: 1, + distributionType: DistributionType.OperatorSetUniqueStake, + operatorSet: testOperatorSet, + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Warp to epoch 0 and press button + cheats.warp(EMISSIONS_START_TIME); + emissionsController.pressButton(1); + + // Distribution should be processed (check via event emission) + // The distribution will be processed even if RewardsCoordinator call fails + } + + function test_pressButton_OperatorSetTotalStake() public { + // Mock operator set as registered + allocationManagerMock.setIsOperatorSet(testOperatorSet, true); + + // Add OperatorSetTotalStake distribution + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + totalEpochs: 1, + distributionType: DistributionType.OperatorSetTotalStake, + operatorSet: testOperatorSet, + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Warp to epoch 0 and press button + cheats.warp(EMISSIONS_START_TIME); + emissionsController.pressButton(1); + + // Distribution should be processed + } + + function test_pressButton_EigenDA() public { + // Add EigenDA distribution + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + totalEpochs: 1, + distributionType: DistributionType.EigenDA, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Warp to epoch 0 and press button + cheats.warp(EMISSIONS_START_TIME); + emissionsController.pressButton(1); + + // Distribution should be processed + } + + function test_pressButton_Manual() public { + // Add Manual distribution + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + totalEpochs: 1, + distributionType: DistributionType.Manual, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: emptyStrategiesAndMultipliers() // Manual doesn't need submissions + }) + ); + + uint councilBalanceBefore = eigenMock.balanceOf(incentiveCouncil); + + // Warp to epoch 0 and press button + cheats.warp(EMISSIONS_START_TIME); + emissionsController.pressButton(1); + + // Manual distribution transfers directly to incentive council + assertEq(eigenMock.balanceOf(incentiveCouncil), councilBalanceBefore + EMISSIONS_INFLATION_RATE); + } + + function test_revert_addDistribution_OperatorSetNotRegistered_UniqueStake() public { + OperatorSet memory unregisteredOpSet = OperatorSet({avs: address(0x999), id: 99}); + + // Don't register the operator set + allocationManagerMock.setIsOperatorSet(unregisteredOpSet, false); + + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.OperatorSetNotRegistered.selector); + emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + totalEpochs: 1, + distributionType: DistributionType.OperatorSetUniqueStake, + operatorSet: unregisteredOpSet, + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + } + + function test_revert_addDistribution_OperatorSetNotRegistered_TotalStake() public { + OperatorSet memory unregisteredOpSet = OperatorSet({avs: address(0x999), id: 99}); + + // Don't register the operator set + allocationManagerMock.setIsOperatorSet(unregisteredOpSet, false); + + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.OperatorSetNotRegistered.selector); + emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + totalEpochs: 1, + distributionType: DistributionType.OperatorSetTotalStake, + operatorSet: unregisteredOpSet, + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + } +} + +/// ----------------------------------------------------------------------- +/// Owner Functions +/// ----------------------------------------------------------------------- + +contract EmissionsControllerUnitTests_setIncentiveCouncil is EmissionsControllerUnitTests { + function testFuzz_setIncentiveCouncil_OnlyOwner(address notOwner) public { + cheats.assume(notOwner != owner); + cheats.assume(notOwner != address(eigenLayerProxyAdmin)); + cheats.prank(notOwner); + cheats.expectRevert("Ownable: caller is not the owner"); + emissionsController.setIncentiveCouncil(incentiveCouncil); + } + + function testFuzz_setIncentiveCouncil_Correctness(address newIncentiveCouncil) public { + cheats.expectEmit(true, true, true, true); + emit IncentiveCouncilUpdated(newIncentiveCouncil); + cheats.prank(owner); + emissionsController.setIncentiveCouncil(newIncentiveCouncil); + assertEq(emissionsController.incentiveCouncil(), newIncentiveCouncil); + } +} + +/// ----------------------------------------------------------------------- +/// Incentive Council Functions +/// ----------------------------------------------------------------------- + +contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnitTests { + function test_revert_addDistribution_OnlyIncentiveCouncil() public { + address notIncentiveCouncil = address(0x3); + cheats.expectRevert(IEmissionsControllerErrors.CallerIsNotIncentiveCouncil.selector); + emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + totalEpochs: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + } + + function test_revert_addDistribution_DisabledDistribution() public { + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.CannotAddDisabledDistribution.selector); + emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + totalEpochs: 0, + distributionType: DistributionType.Disabled, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + } + + function test_revert_addDistribution_RewardsSubmissionsCannotBeEmpty() public { + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.RewardsSubmissionsCannotBeEmpty.selector); + emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + totalEpochs: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: emptyStrategiesAndMultipliers() + }) + ); + } + + function test_revert_addDistribution_StartEpochMustBeInTheFuture() public { + cheats.warp(EMISSIONS_START_TIME); + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.StartEpochMustBeInTheFuture.selector); + emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + totalEpochs: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + } + + function testFuzz_revert_addDistribution_TotalWeightExceedsMax(uint weight) public { + weight = bound(weight, 10_001, type(uint64).max); + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.TotalWeightExceedsMax.selector); + emissionsController.addDistribution( + Distribution({ + weight: uint64(weight), + startEpoch: 0, + totalEpochs: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + } + + function test_revert_addDistribution_TotalWeightExceedsMax_MultipleDistributions() public { + // Add first distribution with weight 6000 + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 6000, + startEpoch: 0, + totalEpochs: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Attempt to add second distribution with weight 5000, total would be 11000 > 10000 + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.TotalWeightExceedsMax.selector); + emissionsController.addDistribution( + Distribution({ + weight: 5000, + startEpoch: 0, + totalEpochs: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + } + + function testFuzz_addDistribution_Correctness(uint weight, uint8 distributionTypeUint8) public { + weight = bound(weight, 0, 10_000); + DistributionType distributionType = DistributionType( + bound(uint8(distributionTypeUint8), uint8(DistributionType.RewardsForAllEarners), uint8(type(DistributionType).max)) + ); + + uint nextDistributionId = emissionsController.getTotalDistributions(); + + // Use defaultStrategiesAndMultipliers for non-Manual types, empty for Manual + IRewardsCoordinatorTypes.StrategyAndMultiplier[][] memory strategiesAndMultipliers = + distributionType == DistributionType.Manual ? emptyStrategiesAndMultipliers() : defaultStrategiesAndMultipliers(); + + Distribution memory addedDistribution = Distribution({ + weight: uint64(weight), + startEpoch: 0, + totalEpochs: 0, + distributionType: distributionType, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: strategiesAndMultipliers + }); + + allocationManagerMock.setIsOperatorSet(addedDistribution.operatorSet, true); + + cheats.expectEmit(true, true, true, true); + emit DistributionAdded(nextDistributionId, type(uint).max, addedDistribution); + cheats.prank(incentiveCouncil); + uint distributionId = emissionsController.addDistribution(addedDistribution); + + Distribution memory distribution = emissionsController.getDistribution(distributionId); + assertEq(distributionId, nextDistributionId); + assertEq(emissionsController.getTotalDistributions(), 1); + assertEq(distribution.weight, weight); + assertEq(uint8(distribution.distributionType), uint8(distributionType)); + } + + function test_revert_addDistribution_NotAllDistributionsProcessed() public { + // Add first distribution before emissions start + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 5000, + startEpoch: 0, + totalEpochs: 1, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Warp to after emissions start (epoch 0) + cheats.warp(EMISSIONS_START_TIME); + + // Now there's 1 distribution but 0 processed, button is pressable + assertTrue(emissionsController.isButtonPressable()); + + // Attempt to add another distribution before processing the first one should revert + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.NotAllDistributionsProcessed.selector); + emissionsController.addDistribution( + Distribution({ + weight: 3000, + startEpoch: 1, + totalEpochs: 1, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + } +} + +contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerUnitTests { + function test_revert_updateDistribution_OnlyIncentiveCouncil() public { + address notIncentiveCouncil = address(0x3); + cheats.assume(notIncentiveCouncil != incentiveCouncil); + cheats.prank(notIncentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.CallerIsNotIncentiveCouncil.selector); + emissionsController.updateDistribution( + 0, + Distribution({ + weight: 10_000, + startEpoch: 0, + totalEpochs: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + } + + function test_revert_updateDistribution_NonExistentDistribution() public { + cheats.prank(incentiveCouncil); + cheats.expectRevert(stdError.indexOOBError); // team may want an explicit check for this + emissionsController.updateDistribution( + 0, + Distribution({ + weight: 10_000, + startEpoch: 0, + totalEpochs: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + } + + function test_revert_updateDistribution_RewardsSubmissionsCannotBeEmpty() public { + cheats.prank(incentiveCouncil); + uint distributionId = emissionsController.addDistribution( + Distribution({ + weight: 5000, + startEpoch: 0, + totalEpochs: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.RewardsSubmissionsCannotBeEmpty.selector); + emissionsController.updateDistribution( + distributionId, + Distribution({ + weight: 5000, + startEpoch: 0, + totalEpochs: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: emptyStrategiesAndMultipliers() + }) + ); + } + + // NOTE: Fuzz test removed - covered by test_revert_updateDistribution_TotalWeightExceedsMax_MultipleDistributions + + function test_revert_updateDistribution_TotalWeightExceedsMax_MultipleDistributions() public { + // Add first distribution with weight 6000 + cheats.prank(incentiveCouncil); + uint distributionId1 = emissionsController.addDistribution( + Distribution({ + weight: 6000, + startEpoch: 0, + totalEpochs: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Add second distribution with weight 3000 + cheats.prank(incentiveCouncil); + uint distributionId2 = emissionsController.addDistribution( + Distribution({ + weight: 3000, + startEpoch: 0, + totalEpochs: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Attempt to update second distribution to weight 5000, total would be 11000 > 10000 + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.TotalWeightExceedsMax.selector); + emissionsController.updateDistribution( + distributionId2, + Distribution({ + weight: 5000, + startEpoch: 0, + totalEpochs: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + } + + function test_revert_updateDistribution_NotAllDistributionsProcessed() public { + // Add first distribution before emissions start + cheats.prank(incentiveCouncil); + uint distributionId = emissionsController.addDistribution( + Distribution({ + weight: 5000, + startEpoch: 0, + totalEpochs: 1, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Warp to after emissions start (epoch 0) + cheats.warp(EMISSIONS_START_TIME); + + // Now there's 1 distribution but 0 processed, button is pressable + assertTrue(emissionsController.isButtonPressable()); + + // Attempt to update the distribution before processing it should revert + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.NotAllDistributionsProcessed.selector); + emissionsController.updateDistribution( + distributionId, + Distribution({ + weight: 3000, + startEpoch: 1, + totalEpochs: 1, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + } +} + +/// ----------------------------------------------------------------------- +/// View Functions +/// ----------------------------------------------------------------------- + +contract EmissionsControllerUnitTests_getCurrentEpoch is EmissionsControllerUnitTests { + function test_getCurrentEpoch_MaxBeforeStart() public { + assertEq(emissionsController.getCurrentEpoch(), type(uint).max); + } + + function test_getCurrentEpoch_ZeroAtStart() public { + vm.warp(EMISSIONS_START_TIME); + assertEq(emissionsController.getCurrentEpoch(), 0); + } + + function test_getCurrentEpoch_MonotonicallyIncreasingFromZero() public { + vm.warp(EMISSIONS_START_TIME); + uint n = 10; + for (uint i = 1; i < n; i++) { + assertEq(emissionsController.getCurrentEpoch(), i - 1); + cheats.warp(block.timestamp + EMISSIONS_EPOCH_LENGTH); + assertEq(emissionsController.getCurrentEpoch(), i); + } + } +} + +contract EmissionsControllerUnitTests_isButtonPressable is EmissionsControllerUnitTests { + function test_isButtonPressable_NoDistributions() public { + // Before emissions start, no distributions + assertFalse(emissionsController.isButtonPressable()); + + // After emissions start, still no distributions + cheats.warp(EMISSIONS_START_TIME); + assertFalse(emissionsController.isButtonPressable()); + } + + function test_isButtonPressable_WithDistributions() public { + // Add a distribution + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 5000, + startEpoch: 0, + totalEpochs: 1, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Should be pressable after emissions start + cheats.warp(EMISSIONS_START_TIME); + assertTrue(emissionsController.isButtonPressable()); + } + + function test_isButtonPressable_AfterProcessing() public { + // Add a distribution + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 5000, + startEpoch: 0, + totalEpochs: 1, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Warp to emissions start + cheats.warp(EMISSIONS_START_TIME); + assertTrue(emissionsController.isButtonPressable()); + + // Process all distributions + emissionsController.pressButton(1); + + // Should not be pressable after processing all + assertFalse(emissionsController.isButtonPressable()); + } +} + +contract EmissionsControllerUnitTests_nextTimeButtonPressable is EmissionsControllerUnitTests { + function test_revert_nextTimeButtonPressable_BeforeStart() public { + // Before emissions start, getCurrentEpoch() returns type(uint).max + // The calculation (type(uint).max + 1) will overflow and revert in Solidity 0.8.x + cheats.expectRevert(stdError.arithmeticError); + emissionsController.nextTimeButtonPressable(); + } + + function test_nextTimeButtonPressable_AtStart() public { + // At emissions start (epoch 0) + cheats.warp(EMISSIONS_START_TIME); + assertEq(emissionsController.nextTimeButtonPressable(), EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH); + } + + function test_nextTimeButtonPressable_AfterMultipleEpochs() public { + // Warp to epoch 5 + cheats.warp(EMISSIONS_START_TIME + 5 * EMISSIONS_EPOCH_LENGTH); + assertEq(emissionsController.getCurrentEpoch(), 5); + assertEq(emissionsController.nextTimeButtonPressable(), EMISSIONS_START_TIME + 6 * EMISSIONS_EPOCH_LENGTH); + } + + function testFuzz_nextTimeButtonPressable_Correctness(uint numEpochs) public { + numEpochs = bound(numEpochs, 0, 1000); + + // Warp to arbitrary epoch + cheats.warp(EMISSIONS_START_TIME + numEpochs * EMISSIONS_EPOCH_LENGTH); + uint currentEpoch = emissionsController.getCurrentEpoch(); + assertEq(currentEpoch, numEpochs); + + // Next button press time should be start of next epoch + assertEq(emissionsController.nextTimeButtonPressable(), EMISSIONS_START_TIME + (currentEpoch + 1) * EMISSIONS_EPOCH_LENGTH); + } +} + +contract EmissionsControllerUnitTests_lastTimeButtonPressable is EmissionsControllerUnitTests { + function test_lastTimeButtonPressable_BeforeStart() public { + // Before emissions start, getCurrentEpoch() returns type(uint).max + // This will cause overflow in multiplication + cheats.expectRevert(stdError.arithmeticError); + emissionsController.lastTimeButtonPressable(); + } + + function test_lastTimeButtonPressable_AtStart() public { + // At emissions start (epoch 0) + cheats.warp(EMISSIONS_START_TIME); + assertEq(emissionsController.lastTimeButtonPressable(), EMISSIONS_START_TIME); + } + + function test_lastTimeButtonPressable_AfterMultipleEpochs() public { + // Warp to middle of epoch 5 + cheats.warp(EMISSIONS_START_TIME + 5 * EMISSIONS_EPOCH_LENGTH + EMISSIONS_EPOCH_LENGTH / 2); + assertEq(emissionsController.getCurrentEpoch(), 5); + // Last time pressable should be start of epoch 5 + assertEq(emissionsController.lastTimeButtonPressable(), EMISSIONS_START_TIME + 5 * EMISSIONS_EPOCH_LENGTH); + } + + function testFuzz_lastTimeButtonPressable_Correctness(uint numEpochs) public { + numEpochs = bound(numEpochs, 0, 1000); + + // Warp to arbitrary epoch + cheats.warp(EMISSIONS_START_TIME + numEpochs * EMISSIONS_EPOCH_LENGTH); + uint currentEpoch = emissionsController.getCurrentEpoch(); + assertEq(currentEpoch, numEpochs); + + // Last button press time should be start of current epoch + assertEq(emissionsController.lastTimeButtonPressable(), EMISSIONS_START_TIME + currentEpoch * EMISSIONS_EPOCH_LENGTH); + } +} + +contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControllerUnitTests { + function test_getTotalDistributions_InitiallyZero() public { + assertEq(emissionsController.getTotalDistributions(), 0); + } + + function test_getTotalDistributions_AfterAdding() public { + // Add first distribution + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 3000, + startEpoch: 0, + totalEpochs: 1, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + assertEq(emissionsController.getTotalDistributions(), 1); + + // Add second distribution + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 2000, + startEpoch: 0, + totalEpochs: 1, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + assertEq(emissionsController.getTotalDistributions(), 2); + } + + function testFuzz_getTotalDistributions_Correctness(uint8 count) public { + count = uint8(bound(count, 0, 50)); // Reasonable upper bound for gas + + for (uint i = 0; i < count; i++) { + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 100, + startEpoch: 0, + totalEpochs: 1, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + } + + assertEq(emissionsController.getTotalDistributions(), count); + } +} + +contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnitTests { + function test_revert_getDistribution_NonExistent() public { + cheats.expectRevert(stdError.indexOOBError); + emissionsController.getDistribution(0); + } + + function test_getDistribution_SingleDistribution() public { + // Add a distribution + cheats.prank(incentiveCouncil); + uint distributionId = emissionsController.addDistribution( + Distribution({ + weight: 5000, + startEpoch: 0, + totalEpochs: 10, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Retrieve and verify + Distribution memory retrieved = emissionsController.getDistribution(distributionId); + assertEq(retrieved.weight, 5000); + assertEq(retrieved.startEpoch, 0); + assertEq(retrieved.totalEpochs, 10); + assertEq(uint8(retrieved.distributionType), uint8(DistributionType.RewardsForAllEarners)); + } + + function test_getDistribution_MultipleDistributions() public { + allocationManagerMock.setIsOperatorSet(emptyOperatorSet(), true); + + // Add multiple distributions with different parameters + cheats.prank(incentiveCouncil); + uint distributionId0 = emissionsController.addDistribution( + Distribution({ + weight: 3000, + startEpoch: 0, + totalEpochs: 5, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + cheats.prank(incentiveCouncil); + uint distributionId1 = emissionsController.addDistribution( + Distribution({ + weight: 4000, + startEpoch: 1, + totalEpochs: 7, + distributionType: DistributionType.OperatorSetTotalStake, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Verify first distribution + Distribution memory retrieved0 = emissionsController.getDistribution(distributionId0); + assertEq(retrieved0.weight, 3000); + assertEq(retrieved0.startEpoch, 0); + assertEq(retrieved0.totalEpochs, 5); + assertEq(uint8(retrieved0.distributionType), uint8(DistributionType.RewardsForAllEarners)); + + // Verify second distribution + Distribution memory retrieved1 = emissionsController.getDistribution(distributionId1); + assertEq(retrieved1.weight, 4000); + assertEq(retrieved1.startEpoch, 1); + assertEq(retrieved1.totalEpochs, 7); + assertEq(uint8(retrieved1.distributionType), uint8(DistributionType.OperatorSetTotalStake)); + } + + function test_getDistribution_AfterUpdate() public { + allocationManagerMock.setIsOperatorSet(emptyOperatorSet(), true); + + // Add a distribution + cheats.prank(incentiveCouncil); + uint distributionId = emissionsController.addDistribution( + Distribution({ + weight: 3000, + startEpoch: 0, + totalEpochs: 5, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Update the distribution (new weight 4000 is still within limits) + cheats.prank(incentiveCouncil); + emissionsController.updateDistribution( + distributionId, + Distribution({ + weight: 4000, + startEpoch: 2, + totalEpochs: 13, + distributionType: DistributionType.OperatorSetUniqueStake, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Verify updated values + Distribution memory retrieved = emissionsController.getDistribution(distributionId); + assertEq(retrieved.weight, 4000); + assertEq(retrieved.startEpoch, 2); + assertEq(retrieved.totalEpochs, 13); + assertEq(uint8(retrieved.distributionType), uint8(DistributionType.OperatorSetUniqueStake)); + } +} + +contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUnitTests { + function test_revert_getDistributions_OutOfBounds() public { + // Add 2 distributions + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 3000, + startEpoch: 0, + totalEpochs: 5, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 2000, + startEpoch: 0, + totalEpochs: 5, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Try to get distributions beyond available length + cheats.expectRevert(stdError.indexOOBError); + emissionsController.getDistributions(0, 3); + + cheats.expectRevert(stdError.indexOOBError); + emissionsController.getDistributions(1, 2); + } + + function test_getDistributions_All() public { + allocationManagerMock.setIsOperatorSet(emptyOperatorSet(), true); + + // Add multiple distributions + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 3000, + startEpoch: 0, + totalEpochs: 5, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 4000, + startEpoch: 1, + totalEpochs: 7, + distributionType: DistributionType.OperatorSetTotalStake, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 2000, + startEpoch: 2, + totalEpochs: 8, + distributionType: DistributionType.OperatorSetUniqueStake, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Get all distributions + Distribution[] memory distributions = emissionsController.getDistributions(0, 3); + + assertEq(distributions.length, 3); + assertEq(distributions[0].weight, 3000); + assertEq(distributions[1].weight, 4000); + assertEq(distributions[2].weight, 2000); + } + + function test_getDistributions_Subset() public { + // Add multiple distributions (total weight: 100 * 5 = 500, well under 10000 limit) + for (uint i = 0; i < 5; i++) { + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: uint64(100 * (i + 1)), + startEpoch: 0, + totalEpochs: 5, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + } + + // Get subset starting at index 1, length 3 + Distribution[] memory distributions = emissionsController.getDistributions(1, 3); + + assertEq(distributions.length, 3); + assertEq(distributions[0].weight, 200); // Index 1 + assertEq(distributions[1].weight, 300); // Index 2 + assertEq(distributions[2].weight, 400); // Index 3 + } + + function test_getDistributions_EmptyArray() public { + // Add some distributions + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 3000, + startEpoch: 0, + totalEpochs: 5, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Get 0 length + Distribution[] memory distributions = emissionsController.getDistributions(0, 0); + assertEq(distributions.length, 0); + } + + function test_getDistributions_SingleElement() public { + allocationManagerMock.setIsOperatorSet(emptyOperatorSet(), true); + + // Add multiple distributions + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 3000, + startEpoch: 0, + totalEpochs: 5, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 4000, + startEpoch: 1, + totalEpochs: 7, + distributionType: DistributionType.OperatorSetTotalStake, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Get single element at index 1 + Distribution[] memory distributions = emissionsController.getDistributions(1, 1); + + assertEq(distributions.length, 1); + assertEq(distributions[0].weight, 4000); + assertEq(uint8(distributions[0].distributionType), uint8(DistributionType.OperatorSetTotalStake)); + } + + function testFuzz_getDistributions_Correctness(uint8 totalCount, uint8 start, uint8 length) public { + // Limit totalCount to 10 to avoid exceeding MAX_TOTAL_WEIGHT (10000) + // Each distribution has weight 100 * (i+1), so max weight per distribution is 100 * 10 = 1000 + // Total max weight = 100 + 200 + ... + 1000 = 5500, well under 10000 + totalCount = uint8(bound(totalCount, 1, 10)); + start = uint8(bound(start, 0, totalCount - 1)); + // Ensure we don't go out of bounds + uint8 maxLength = totalCount - start; + length = uint8(bound(length, 0, maxLength)); + + // Add distributions + for (uint i = 0; i < totalCount; i++) { + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: uint64(100 * (i + 1)), + startEpoch: 0, + totalEpochs: 5, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + } + + // Get subset + Distribution[] memory distributions = emissionsController.getDistributions(start, length); + + assertEq(distributions.length, length); + + // Verify each element + for (uint i = 0; i < length; i++) { + assertEq(distributions[i].weight, 100 * (start + i + 1)); + } + } +} + +/// ----------------------------------------------------------------------- +/// Pausable Token Flows +/// ----------------------------------------------------------------------- + +contract EmissionsControllerUnitTests_Pausable is EmissionsControllerUnitTests { + function test_revert_pressButton_WhenPaused() public { + // Add a distribution before emissions start + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 5000, + startEpoch: 0, + totalEpochs: 10, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Warp to after emissions start + cheats.warp(EMISSIONS_START_TIME); + + // Pause token flows (PAUSED_TOKEN_FLOWS = 0, so bit flag is 2^0 = 1) + cheats.prank(pauser); + emissionsController.pause(1); + + // Attempt to press button should revert + cheats.expectRevert(IPausable.CurrentlyPaused.selector); + emissionsController.pressButton(1); + } + + function test_revert_sweep_WhenPaused() public { + // Warp to after emissions start + cheats.warp(EMISSIONS_START_TIME); + + // Pause token flows (PAUSED_TOKEN_FLOWS = 0, so bit flag is 2^0 = 1) + cheats.prank(pauser); + emissionsController.pause(1); + + // Attempt to sweep should revert + cheats.expectRevert(IPausable.CurrentlyPaused.selector); + emissionsController.sweep(); + } +} diff --git a/src/test/utils/EigenLayerUnitTestSetup.sol b/src/test/utils/EigenLayerUnitTestSetup.sol index f1fbdc02ea..069af29ccd 100644 --- a/src/test/utils/EigenLayerUnitTestSetup.sol +++ b/src/test/utils/EigenLayerUnitTestSetup.sol @@ -16,6 +16,9 @@ import "src/test/mocks/AllocationManagerMock.sol"; import "src/test/mocks/StrategyManagerMock.sol"; import "src/test/mocks/DelegationManagerMock.sol"; import "src/test/mocks/EigenPodManagerMock.sol"; +import "src/test/mocks/EigenMock.sol"; +import "src/test/mocks/BackingEigenMock.sol"; +import "src/test/mocks/RewardsCoordinatorMock.sol"; import "src/test/mocks/EmptyContract.sol"; import "src/test/utils/ArrayLib.sol"; @@ -43,6 +46,9 @@ abstract contract EigenLayerUnitTestSetup is Test { StrategyManagerMock strategyManagerMock; DelegationManagerMock delegationManagerMock; EigenPodManagerMock eigenPodManagerMock; + EigenMock eigenMock; + BackingEigenMock backingEigenMock; + RewardsCoordinatorMock rewardsCoordinatorMock; EmptyContract emptyContract; mapping(address => bool) public isExcludedFuzzAddress; @@ -81,6 +87,9 @@ abstract contract EigenLayerUnitTestSetup is Test { StrategyManagerMock(payable(address(new StrategyManagerMock(IDelegationManager(address(delegationManagerMock)))))); delegationManagerMock = DelegationManagerMock(payable(address(new DelegationManagerMock()))); eigenPodManagerMock = EigenPodManagerMock(payable(address(new EigenPodManagerMock(pauserRegistry)))); + backingEigenMock = BackingEigenMock(payable(address(new BackingEigenMock()))); + eigenMock = EigenMock(payable(address(new EigenMock(backingEigenMock)))); + rewardsCoordinatorMock = RewardsCoordinatorMock(payable(address(new RewardsCoordinatorMock()))); emptyContract = new EmptyContract(); isExcludedFuzzAddress[address(0)] = true; @@ -92,5 +101,8 @@ abstract contract EigenLayerUnitTestSetup is Test { isExcludedFuzzAddress[address(strategyManagerMock)] = true; isExcludedFuzzAddress[address(delegationManagerMock)] = true; isExcludedFuzzAddress[address(eigenPodManagerMock)] = true; + isExcludedFuzzAddress[address(eigenMock)] = true; + isExcludedFuzzAddress[address(backingEigenMock)] = true; + isExcludedFuzzAddress[address(rewardsCoordinatorMock)] = true; } }