From f12106ecb3cbf6abf4fcc42481437b1608a3752a Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:02:57 -0500 Subject: [PATCH 01/62] wip --- src/contracts/core/EmissionsController.sol | 136 ++++++++++++++++++ .../interfaces/IEmissionsController.sol | 9 +- 2 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 src/contracts/core/EmissionsController.sol diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol new file mode 100644 index 0000000000..a8e5ca39f4 --- /dev/null +++ b/src/contracts/core/EmissionsController.sol @@ -0,0 +1,136 @@ +// 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 "./storage/EmissionsControllerStorage.sol"; + +contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsControllerStorage { + /// @dev Modifier that checks if the caller is the incentive council. + modifier onlyIncentiveCouncil() { + // Check if the caller is the incentive council. + require(msg.sender == incentiveCouncil, CallerIsNotIncentiveCouncil()); + _; + } + + /// ----------------------------------------------------------------------- + /// Initialization + /// ----------------------------------------------------------------------- + constructor( + uint256 inflationRate, + uint256 startTime, + uint256 cooldownSeconds + ) EmissionsControllerStorage(inflationRate, startTime, cooldownSeconds) { + _disableInitializers(); + } + + /// @inheritdoc IEmissionsController + function initialize( + address initialOwner, + address initialIncentiveCouncil + ) external override initializer { + // Set the initial owner. + _transferOwnership(initialOwner); + // Set the initial incentive council. + _setIncentiveCouncil(initialIncentiveCouncil); + } + + /// ----------------------------------------------------------------------- + /// Permissionless Trigger + /// ----------------------------------------------------------------------- + + /// @inheritdoc IEmissionsController + function pressButton() external override {} + + /// ----------------------------------------------------------------------- + /// 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) { + // Get the total number of distributions (also next available distribution id). + distributionId = getTotalDistributions(); + // Append the distribution to the distributions array. + _distributions.push(distribution); + // Emit an event for the new distribution. + emit DistributionAdded(distributionId, getCurrentEpoch(), distribution); + } + + /// @inheritdoc IEmissionsController + function updateDistribution( + uint256 distributionId, + Distribution calldata distribution + ) external override onlyIncentiveCouncil {} + + /// @inheritdoc IEmissionsController + function removeDistribution( + uint256 distributionId + ) external override onlyIncentiveCouncil {} + + /// ----------------------------------------------------------------------- + /// View + /// ----------------------------------------------------------------------- + + /// @inheritdoc IEmissionsController + function getCurrentEpoch() public view returns (uint256) { + return block.timestamp - EMISSIONS_START_TIME % EMISSIONS_EPOCH_LENGTH; + } + + /// @inheritdoc IEmissionsController + function isButtonPressable() external view returns (bool) { + return _epochTriggered[getCurrentEpoch()]; + } + + /// @inheritdoc IEmissionsController + function nextButtonPressTime() external view returns (uint256) { + return EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH * (getCurrentEpoch() + 1); + } + + /// @inheritdoc IEmissionsController + function getTotalDistributions() public view returns (uint256) { + return _distributions.length; + } + + /// @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/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index 1c35b2b77b..4f3837808c 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -38,17 +38,20 @@ interface IEmissionsControllerTypes { interface IEmissionsControllerEvents is IEmissionsControllerTypes { /// @notice Emitted when a distribution is updated. /// @param distributionId The id of the distribution. + /// @param epoch The epoch the distribution was updated. /// @param distribution The distribution. - event DistributionUpdated(uint256 indexed distributionId, Distribution distribution); + event DistributionUpdated(uint256 indexed distributionId, uint256 indexed epoch, Distribution distribution); /// @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. /// @param distributionId The id of the distribution. - event DistributionRemoved(uint256 indexed distributionId); + /// @param epoch The epoch the distribution was removed. + event DistributionRemoved(uint256 indexed distributionId, uint256 indexed epoch); /// @notice Emitted when the Incentive Council address is updated. /// @param incentiveCouncil The new Incentive Council address. From 196d782eecc36e25662591306afe027719dea451 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:03:02 -0500 Subject: [PATCH 02/62] test --- src/test/unit/EmissionsControllerUnit.t.sol | 126 ++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/test/unit/EmissionsControllerUnit.t.sol diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol new file mode 100644 index 0000000000..e62c5316f0 --- /dev/null +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -0,0 +1,126 @@ +// 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; + uint EMISSIONS_START_TIME = block.timestamp; + 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(EMISSIONS_INFLATION_RATE, EMISSIONS_START_TIME, EMISSIONS_EPOCH_LENGTH)), + address(eigenLayerProxyAdmin), + abi.encodeWithSelector(EmissionsController.initialize.selector, owner, incentiveCouncil) + ) + ) + ); + } +} + +/// ----------------------------------------------------------------------- +/// 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); + } +} + +/// ----------------------------------------------------------------------- +/// Permissionless Trigger +/// ----------------------------------------------------------------------- + +contract EmissionsControllerUnitTests_pressButton is EmissionsControllerUnitTests {} + +/// ----------------------------------------------------------------------- +/// Owner Functions +/// ----------------------------------------------------------------------- + +contract EmissionsControllerUnitTests_setIncentiveCouncil is EmissionsControllerUnitTests { + function testFuzz_setIncentiveCouncil_OnlyOwner(address notOwner) public { + cheats.assume(notOwner != owner); + 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 testFuzz_addDistribution_OnlyIncentiveCouncil(address notIncentiveCouncil, Distribution memory distribution) public { + cheats.assume(notIncentiveCouncil != incentiveCouncil); + cheats.prank(notIncentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.CallerIsNotIncentiveCouncil.selector); + emissionsController.addDistribution(distribution); + } + + function testFuzz_addDistribution_Correctness(uint weight, DistributionType distributionType) public { + uint nextDistributionId = emissionsController.getTotalDistributions(); + Distribution memory addedDistribution = + Distribution({weight: weight, distributionType: distributionType, encodedRewardsSubmission: ""}); + + cheats.expectEmit(true, true, true, true); + emit DistributionAdded(nextDistributionId, 0, 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)); + } +} + +contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerUnitTests {} + +contract EmissionsControllerUnitTests_removeDistribution is EmissionsControllerUnitTests {} + +/// ----------------------------------------------------------------------- +/// View Functions +/// ----------------------------------------------------------------------- + +contract EmissionsControllerUnitTests_getCurrentEpoch is EmissionsControllerUnitTests {} + +contract EmissionsControllerUnitTests_isButtonPressable is EmissionsControllerUnitTests {} + +contract EmissionsControllerUnitTests_nextButtonPressTime is EmissionsControllerUnitTests {} + +contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControllerUnitTests {} + +contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnitTests {} + +contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUnitTests {} From bdb91ccc45e4054baead990e12dc7edee94fc0c1 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:01:24 -0500 Subject: [PATCH 03/62] gap --- .../storage/EmissionsControllerStorage.sol | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/contracts/core/storage/EmissionsControllerStorage.sol diff --git a/src/contracts/core/storage/EmissionsControllerStorage.sol b/src/contracts/core/storage/EmissionsControllerStorage.sol new file mode 100644 index 0000000000..1065bb0893 --- /dev/null +++ b/src/contracts/core/storage/EmissionsControllerStorage.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import "../../interfaces/IEmissionsController.sol"; + +abstract contract EmissionsControllerStorage is IEmissionsController { + // Immutables + + /// @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 + + /// @inheritdoc IEmissionsController + address public incentiveCouncil; + + /// @dev Returns an append-only array of distributions. + Distribution[] internal _distributions; + /// @dev Mapping from epoch to whether it has been triggered. + mapping(uint256 epoch => bool triggered) internal _epochTriggered; + + // Construction + + constructor( + uint256 inflationRate, + uint256 startTime, + uint256 cooldownSeconds + ) { + EMISSIONS_INFLATION_RATE = inflationRate; + EMISSIONS_START_TIME = startTime; + EMISSIONS_EPOCH_LENGTH = cooldownSeconds; + } + + /** + * @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[46] private __gap; +} From 9eafb1bb5a7d03906528e2b74afdbb0f0e4b5703 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:39:15 -0500 Subject: [PATCH 04/62] fix epoch calc --- src/contracts/core/EmissionsController.sol | 3 ++- src/test/unit/EmissionsControllerUnit.t.sol | 26 +++++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index a8e5ca39f4..d10a6dfe2e 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -96,7 +96,8 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont /// @inheritdoc IEmissionsController function getCurrentEpoch() public view returns (uint256) { - return block.timestamp - EMISSIONS_START_TIME % EMISSIONS_EPOCH_LENGTH; + if (block.timestamp < EMISSIONS_START_TIME) return type(uint256).max; + return (block.timestamp - EMISSIONS_START_TIME) / EMISSIONS_EPOCH_LENGTH; } /// @inheritdoc IEmissionsController diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index e62c5316f0..22a0226e1b 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -80,14 +80,19 @@ contract EmissionsControllerUnitTests_setIncentiveCouncil is EmissionsController /// ----------------------------------------------------------------------- contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnitTests { - function testFuzz_addDistribution_OnlyIncentiveCouncil(address notIncentiveCouncil, Distribution memory distribution) public { + function testFuzz_addDistribution_OnlyIncentiveCouncil(address notIncentiveCouncil) public { cheats.assume(notIncentiveCouncil != incentiveCouncil); cheats.prank(notIncentiveCouncil); cheats.expectRevert(IEmissionsControllerErrors.CallerIsNotIncentiveCouncil.selector); - emissionsController.addDistribution(distribution); + emissionsController.addDistribution( + Distribution({weight: 10_000, distributionType: DistributionType.RewardsForAllEarners, encodedRewardsSubmission: ""}) + ); } - function testFuzz_addDistribution_Correctness(uint weight, DistributionType distributionType) public { + function testFuzz_addDistribution_Correctness(uint weight, uint8 distributionTypeUint8) public { + DistributionType distributionType = + DistributionType(bound(uint8(distributionTypeUint8), uint8(type(DistributionType).min), uint8(type(DistributionType).max))); + uint nextDistributionId = emissionsController.getTotalDistributions(); Distribution memory addedDistribution = Distribution({weight: weight, distributionType: distributionType, encodedRewardsSubmission: ""}); @@ -113,7 +118,20 @@ contract EmissionsControllerUnitTests_removeDistribution is EmissionsControllerU /// View Functions /// ----------------------------------------------------------------------- -contract EmissionsControllerUnitTests_getCurrentEpoch is EmissionsControllerUnitTests {} +contract EmissionsControllerUnitTests_getCurrentEpoch is EmissionsControllerUnitTests { + function test_getCurrentEpoch_ZeroAtStart() public { + assertEq(emissionsController.getCurrentEpoch(), 0); + } + + function test_getCurrentEpoch_MonotonicallyIncreasing() public { + 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 {} From 500eb8d470567c265cdd6a26d407e5fffe3d31b8 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:45:25 -0500 Subject: [PATCH 05/62] docs --- src/contracts/core/EmissionsController.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index d10a6dfe2e..b3c472ce28 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -96,7 +96,9 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont /// @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; } From f4ad93391a23318e0900c93b591416b632d0329d Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:48:01 -0500 Subject: [PATCH 06/62] rebase fixes --- .../interfaces/IEmissionsController.sol | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index 4f3837808c..a29fab2e5e 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -4,9 +4,9 @@ pragma solidity >=0.5.0; /// @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(); +} /// @title IEmissionsControllerTypes /// @notice Types for the IEmissionsController contract. @@ -56,10 +56,6 @@ interface IEmissionsControllerEvents is IEmissionsControllerTypes { /// @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 @@ -79,15 +75,17 @@ 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. function initialize( + address initialOwner, address incentiveCouncil ) external; @@ -150,6 +148,14 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// View /// ----------------------------------------------------------------------- + /// @notice Returns the current Incentive Council address. + /// @return The Incentive Council address. + function incentiveCouncil() external view returns (address); + + /// @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); @@ -158,9 +164,9 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// @return The next button press time. function nextButtonPressTime() 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 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. @@ -169,11 +175,9 @@ 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); } From b80bdfd7007f9dde747b53d9381faf35ba68daa5 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:48:34 -0500 Subject: [PATCH 07/62] fmt --- src/contracts/core/storage/EmissionsControllerStorage.sol | 8 +++----- src/contracts/interfaces/IEmissionsController.sol | 5 ++++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/contracts/core/storage/EmissionsControllerStorage.sol b/src/contracts/core/storage/EmissionsControllerStorage.sol index 1065bb0893..4ac6860494 100644 --- a/src/contracts/core/storage/EmissionsControllerStorage.sol +++ b/src/contracts/core/storage/EmissionsControllerStorage.sol @@ -35,10 +35,8 @@ abstract contract EmissionsControllerStorage is IEmissionsController { EMISSIONS_EPOCH_LENGTH = cooldownSeconds; } - /** - * @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 - */ + /// @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[46] private __gap; } diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index a29fab2e5e..4833cb659f 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -179,5 +179,8 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// @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(uint256 start, uint256 length) external view returns (Distribution[] memory); + function getDistributions( + uint256 start, + uint256 length + ) external view returns (Distribution[] memory); } From 94bbff327bdae0c5c7fae47ae62db2d49f324c00 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:37:18 -0500 Subject: [PATCH 08/62] wip --- src/contracts/core/EmissionsController.sol | 74 ++++++++++++++++++- .../storage/EmissionsControllerStorage.sol | 10 ++- .../interfaces/IEmissionsController.sol | 23 ++++++ src/test/unit/EmissionsControllerUnit.t.sol | 10 ++- 4 files changed, 110 insertions(+), 7 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index b3c472ce28..bf1a3933ce 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -71,24 +71,92 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont function addDistribution( Distribution calldata distribution ) external override onlyIncentiveCouncil returns (uint256 distributionId) { + uint256 currentEpoch = getCurrentEpoch(); + + // Question: How should be handle added distributions before the first epoch? + + // Check if the distribution is disabled. + if (distribution.distributionType == DistributionType.Disabled) { + revert CannotAddDisabledDistribution(); + } + // 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); // Get the total number of distributions (also next available distribution id). distributionId = getTotalDistributions(); // Append the distribution to the distributions array. _distributions.push(distribution); // Emit an event for the new distribution. - emit DistributionAdded(distributionId, getCurrentEpoch(), distribution); + emit DistributionAdded(distributionId, currentEpoch, distribution); } /// @inheritdoc IEmissionsController function updateDistribution( uint256 distributionId, Distribution calldata distribution - ) external override onlyIncentiveCouncil {} + ) external override onlyIncentiveCouncil { + uint256 currentEpoch = getCurrentEpoch(); + + // Check if the distribution is disabled. + if (distribution.distributionType == DistributionType.Disabled) { + revert CannotUpdateDisabledDistribution(); + } + + // Check if the distribution is disabled. + _checkDisabled(_distributions[distributionId]); + // 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); + // Update the distribution in the distributions array. + _distributions[distributionId] = distribution; + // Emit an event for the updated distribution. + emit DistributionUpdated(distributionId, currentEpoch, distribution); + } /// @inheritdoc IEmissionsController function removeDistribution( uint256 distributionId - ) external override onlyIncentiveCouncil {} + ) external override onlyIncentiveCouncil { + // Check if the distribution is already disabled. + _checkDisabled(_distributions[distributionId]); + // Set the distribution type to disabled. + // Prevents further updates to the distribution. + _distributions[distributionId].distributionType = DistributionType.Disabled; + // Emit an event for the removed distribution. + emit DistributionRemoved(distributionId, getCurrentEpoch()); + } + + /// ----------------------------------------------------------------------- + /// Internal Helpers + /// ----------------------------------------------------------------------- + + function _checkDisabled( + Distribution storage distribution + ) internal view { + // Check if the distribution is disabled. + if (distribution.distributionType == DistributionType.Disabled) { + revert DistributionIsDisabled(); + } + } + + function _checkDistribution( + Distribution calldata distribution, + uint256 currentEpoch + ) internal view { + // Check if the start epoch is in the future. + // Prevents updating a distribution to a past or current epoch. + if (distribution.startEpoch <= currentEpoch) { + revert StartEpochMustBeInTheFuture(); + } + + // Check if the new total weight of all distributions exceeds max total weight. + // Prevents distributing more supply than inflation rate allows. + if (distribution.weight + totalWeight > MAX_TOTAL_WEIGHT) { + revert TotalWeightExceedsMax(); + } + } /// ----------------------------------------------------------------------- /// View diff --git a/src/contracts/core/storage/EmissionsControllerStorage.sol b/src/contracts/core/storage/EmissionsControllerStorage.sol index 4ac6860494..a3006a74fd 100644 --- a/src/contracts/core/storage/EmissionsControllerStorage.sol +++ b/src/contracts/core/storage/EmissionsControllerStorage.sol @@ -4,6 +4,11 @@ pragma solidity ^0.8.27; import "../../interfaces/IEmissionsController.sol"; abstract contract EmissionsControllerStorage is IEmissionsController { + // Constants + + /// @inheritdoc IEmissionsController + uint256 public constant MAX_TOTAL_WEIGHT = 10_000; + // Immutables /// @inheritdoc IEmissionsController @@ -17,7 +22,8 @@ abstract contract EmissionsControllerStorage is IEmissionsController { /// @inheritdoc IEmissionsController address public incentiveCouncil; - + /// @inheritdoc IEmissionsController + uint256 public totalWeight; /// @dev Returns an append-only array of distributions. Distribution[] internal _distributions; /// @dev Mapping from epoch to whether it has been triggered. @@ -38,5 +44,5 @@ abstract contract EmissionsControllerStorage is IEmissionsController { /// @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[46] private __gap; + uint256[45] private __gap; } diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index 4833cb659f..dbdac01fb5 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -6,6 +6,16 @@ pragma solidity >=0.5.0; interface IEmissionsControllerErrors { /// @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 the distribution is disabled. + error DistributionIsDisabled(); + /// @dev Thrown when attempting to add a disabled distribution. + error CannotAddDisabledDistribution(); + /// @dev Thrown when attempting to update a disabled distribution. + error CannotUpdateDisabledDistribution(); } /// @title IEmissionsControllerTypes @@ -14,6 +24,7 @@ interface IEmissionsControllerTypes { /// @notice Distribution types as defined in the ELIP. /// @dev Ref: "Distribution Submission types may include: createRewardsForAllEarners, createOperatorSetTotalStakeRewardsSubmission, createOperatorSetUniqueStakeRewardsSubmission, EigenDA Distribution, Manual Distribution." enum DistributionType { + Disabled, RewardsForAllEarners, OperatorSetTotalStake, OperatorSetUniqueStake, @@ -26,6 +37,10 @@ interface IEmissionsControllerTypes { struct Distribution { /// The bips denominated weight of the distribution. uint256 weight; + /// The epoch the distribution was last triggered. + uint256 startEpoch; + /// The number of epochs to repeat the distribution. + uint256 stopEpoch; /// The type of distribution. DistributionType distributionType; /// The encoded rewards submission (either `RewardsSubmission` or `OperatorDirectedRewardsSubmission`). @@ -65,6 +80,10 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// ----------------------------------------------------------------------- /// Constants /// ----------------------------------------------------------------------- + /// @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); @@ -152,6 +171,10 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// @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 (uint256); + /// @notice Returns the current epoch. /// @return The current epoch. function getCurrentEpoch() external view returns (uint256); diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 22a0226e1b..0fc0115cca 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -85,7 +85,13 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit cheats.prank(notIncentiveCouncil); cheats.expectRevert(IEmissionsControllerErrors.CallerIsNotIncentiveCouncil.selector); emissionsController.addDistribution( - Distribution({weight: 10_000, distributionType: DistributionType.RewardsForAllEarners, encodedRewardsSubmission: ""}) + Distribution({ + weight: 10_000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) ); } @@ -95,7 +101,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit uint nextDistributionId = emissionsController.getTotalDistributions(); Distribution memory addedDistribution = - Distribution({weight: weight, distributionType: distributionType, encodedRewardsSubmission: ""}); + Distribution({weight: weight, startEpoch: 0, stopEpoch: 0, distributionType: distributionType, encodedRewardsSubmission: ""}); cheats.expectEmit(true, true, true, true); emit DistributionAdded(nextDistributionId, 0, addedDistribution); From 47202074242411d16c4dd9e3aab2ba9896a4ac04 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:01:46 -0500 Subject: [PATCH 09/62] add mocks --- src/test/mocks/EigenMock.sol | 14 ++++++++++++++ src/test/mocks/RewardsCoordinatorMock.sol | 7 +++++++ 2 files changed, 21 insertions(+) create mode 100644 src/test/mocks/EigenMock.sol create mode 100644 src/test/mocks/RewardsCoordinatorMock.sol diff --git a/src/test/mocks/EigenMock.sol b/src/test/mocks/EigenMock.sol new file mode 100644 index 0000000000..8d05e0442a --- /dev/null +++ b/src/test/mocks/EigenMock.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 EigenMock is Test, ERC20 { + constructor() ERC20("EIGEN", "EIGEN") {} + + function mint(address to, uint amount) external { + _mint(to, amount); + } +} + diff --git a/src/test/mocks/RewardsCoordinatorMock.sol b/src/test/mocks/RewardsCoordinatorMock.sol new file mode 100644 index 0000000000..38fcb0632d --- /dev/null +++ b/src/test/mocks/RewardsCoordinatorMock.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.9; + +import "forge-std/Test.sol"; + +contract RewardsCoordinatorMock is Test {} + From 538f1c9509eb89151ac6a6c28b9c86ce39a3c682 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:02:07 -0500 Subject: [PATCH 10/62] update constructor --- src/contracts/core/EmissionsController.sol | 6 ++++-- .../core/storage/EmissionsControllerStorage.sol | 17 +++++++++++++++-- .../interfaces/IEmissionsController.sol | 13 +++++++++++++ src/test/unit/EmissionsControllerUnit.t.sol | 10 +++++++++- src/test/utils/EigenLayerUnitTestSetup.sol | 8 ++++++++ 5 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index bf1a3933ce..8be230e2ae 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -17,10 +17,12 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont /// Initialization /// ----------------------------------------------------------------------- constructor( + IEigen eigen, + IRewardsCoordinator rewardsCoordinator, uint256 inflationRate, uint256 startTime, uint256 cooldownSeconds - ) EmissionsControllerStorage(inflationRate, startTime, cooldownSeconds) { + ) EmissionsControllerStorage(eigen, rewardsCoordinator, inflationRate, startTime, cooldownSeconds) { _disableInitializers(); } @@ -172,7 +174,7 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont /// @inheritdoc IEmissionsController function isButtonPressable() external view returns (bool) { - return _epochTriggered[getCurrentEpoch()]; + return !_epochTriggered[getCurrentEpoch()]; } /// @inheritdoc IEmissionsController diff --git a/src/contracts/core/storage/EmissionsControllerStorage.sol b/src/contracts/core/storage/EmissionsControllerStorage.sol index a3006a74fd..af1de56747 100644 --- a/src/contracts/core/storage/EmissionsControllerStorage.sol +++ b/src/contracts/core/storage/EmissionsControllerStorage.sol @@ -2,6 +2,9 @@ pragma solidity ^0.8.27; import "../../interfaces/IEmissionsController.sol"; +import "../../interfaces/IRewardsCoordinator.sol"; +import "../../interfaces/IEigen.sol"; +import "../../libraries/OperatorSetLib.sol"; abstract contract EmissionsControllerStorage is IEmissionsController { // Constants @@ -11,6 +14,11 @@ abstract contract EmissionsControllerStorage is IEmissionsController { // Immutables + /// @dev The EIGEN token that will be minted for emissions. + IEigen public immutable override EIGEN; + /// @dev The RewardsCoordinator contract for submitting rewards. + IRewardsCoordinator public immutable override REWARDS_COORDINATOR; + /// @inheritdoc IEmissionsController uint256 public immutable EMISSIONS_INFLATION_RATE; /// @inheritdoc IEmissionsController @@ -32,13 +40,18 @@ abstract contract EmissionsControllerStorage is IEmissionsController { // Construction constructor( + IEigen eigen, + IRewardsCoordinator rewardsCoordinator, uint256 inflationRate, uint256 startTime, - uint256 cooldownSeconds + uint256 epochLength ) { + EIGEN = eigen; + REWARDS_COORDINATOR = rewardsCoordinator; + EMISSIONS_INFLATION_RATE = inflationRate; EMISSIONS_START_TIME = startTime; - EMISSIONS_EPOCH_LENGTH = cooldownSeconds; + EMISSIONS_EPOCH_LENGTH = epochLength; } /// @dev This empty reserved space is put in place to allow future versions to add new diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index dbdac01fb5..a1c530db9a 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -1,6 +1,9 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity >=0.5.0; +import "./IRewardsCoordinator.sol"; +import "./IEigen.sol"; + /// @title IEmissionsControllerErrors /// @notice Errors for the IEmissionsController contract. interface IEmissionsControllerErrors { @@ -16,6 +19,8 @@ interface IEmissionsControllerErrors { error CannotAddDisabledDistribution(); /// @dev Thrown when attempting to update a disabled distribution. error CannotUpdateDisabledDistribution(); + /// @dev Thrown when the button has already been pressed for the current epoch. + error ButtonAlreadyPressed(); } /// @title IEmissionsControllerTypes @@ -80,6 +85,14 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// ----------------------------------------------------------------------- /// Constants /// ----------------------------------------------------------------------- + /// @notice The EIGEN token address. + /// @dev Immutable/constant variable that requires an upgrade to modify. + function EIGEN() external view returns (IEigen); + + /// @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); diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 0fc0115cca..d50f14da43 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -21,7 +21,15 @@ contract EmissionsControllerUnitTests is EigenLayerUnitTestSetup, IEmissionsCont emissionsController = EmissionsController( address( new TransparentUpgradeableProxy( - address(new EmissionsController(EMISSIONS_INFLATION_RATE, EMISSIONS_START_TIME, EMISSIONS_EPOCH_LENGTH)), + address( + new EmissionsController( + IEigen(address(eigenMock)), + IRewardsCoordinator(address(rewardsCoordinatorMock)), + EMISSIONS_INFLATION_RATE, + EMISSIONS_START_TIME, + EMISSIONS_EPOCH_LENGTH + ) + ), address(eigenLayerProxyAdmin), abi.encodeWithSelector(EmissionsController.initialize.selector, owner, incentiveCouncil) ) diff --git a/src/test/utils/EigenLayerUnitTestSetup.sol b/src/test/utils/EigenLayerUnitTestSetup.sol index f1fbdc02ea..67c63ad9bb 100644 --- a/src/test/utils/EigenLayerUnitTestSetup.sol +++ b/src/test/utils/EigenLayerUnitTestSetup.sol @@ -16,6 +16,8 @@ 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/RewardsCoordinatorMock.sol"; import "src/test/mocks/EmptyContract.sol"; import "src/test/utils/ArrayLib.sol"; @@ -43,6 +45,8 @@ abstract contract EigenLayerUnitTestSetup is Test { StrategyManagerMock strategyManagerMock; DelegationManagerMock delegationManagerMock; EigenPodManagerMock eigenPodManagerMock; + EigenMock eigenMock; + RewardsCoordinatorMock rewardsCoordinatorMock; EmptyContract emptyContract; mapping(address => bool) public isExcludedFuzzAddress; @@ -81,6 +85,8 @@ 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)))); + eigenMock = EigenMock(payable(address(new EigenMock()))); + rewardsCoordinatorMock = RewardsCoordinatorMock(payable(address(new RewardsCoordinatorMock()))); emptyContract = new EmptyContract(); isExcludedFuzzAddress[address(0)] = true; @@ -92,5 +98,7 @@ abstract contract EigenLayerUnitTestSetup is Test { isExcludedFuzzAddress[address(strategyManagerMock)] = true; isExcludedFuzzAddress[address(delegationManagerMock)] = true; isExcludedFuzzAddress[address(eigenPodManagerMock)] = true; + isExcludedFuzzAddress[address(eigenMock)] = true; + isExcludedFuzzAddress[address(rewardsCoordinatorMock)] = true; } } From 025a741037e994b446a0f6e490d0dc2642460430 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:50:18 -0500 Subject: [PATCH 11/62] passing --- src/contracts/core/EmissionsController.sol | 2 +- src/test/unit/EmissionsControllerUnit.t.sol | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 8be230e2ae..9e35162d14 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -149,7 +149,7 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont ) internal view { // Check if the start epoch is in the future. // Prevents updating a distribution to a past or current epoch. - if (distribution.startEpoch <= currentEpoch) { + if (distribution.startEpoch == currentEpoch) { revert StartEpochMustBeInTheFuture(); } diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index d50f14da43..d694164abf 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -9,7 +9,7 @@ contract EmissionsControllerUnitTests is EigenLayerUnitTestSetup, IEmissionsCont using ArrayLib for *; uint EMISSIONS_INFLATION_RATE = 50; - uint EMISSIONS_START_TIME = block.timestamp; + uint EMISSIONS_START_TIME = block.timestamp + 1 weeks; uint EMISSIONS_EPOCH_LENGTH = 1 weeks; address owner = address(0x1); @@ -104,6 +104,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit } function testFuzz_addDistribution_Correctness(uint weight, uint8 distributionTypeUint8) public { + weight = bound(weight, 0, 10_000); DistributionType distributionType = DistributionType(bound(uint8(distributionTypeUint8), uint8(type(DistributionType).min), uint8(type(DistributionType).max))); @@ -112,7 +113,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit Distribution({weight: weight, startEpoch: 0, stopEpoch: 0, distributionType: distributionType, encodedRewardsSubmission: ""}); cheats.expectEmit(true, true, true, true); - emit DistributionAdded(nextDistributionId, 0, addedDistribution); + emit DistributionAdded(nextDistributionId, type(uint256).max, addedDistribution); cheats.prank(incentiveCouncil); uint distributionId = emissionsController.addDistribution(addedDistribution); From 4b27fe8fafc334af638dd8787a79ec1babe9427c Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:55:05 -0500 Subject: [PATCH 12/62] passing --- src/contracts/core/EmissionsController.sol | 6 +++--- src/contracts/interfaces/IEmissionsController.sol | 2 +- src/test/unit/EmissionsControllerUnit.t.sol | 6 ++++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 9e35162d14..d7f3d5d4d8 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -100,12 +100,12 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont ) external override onlyIncentiveCouncil { uint256 currentEpoch = getCurrentEpoch(); - // Check if the distribution is disabled. + // Check if the distribution (from calldata) is disabled. if (distribution.distributionType == DistributionType.Disabled) { - revert CannotUpdateDisabledDistribution(); + revert CannotDisableDistributionViaUpdate(); } - // Check if the distribution is disabled. + // Check if the distribution (in storage) is disabled. _checkDisabled(_distributions[distributionId]); // Asserts the following: // - The start epoch is in the future. diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index a1c530db9a..cfbcc94eb4 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -18,7 +18,7 @@ interface IEmissionsControllerErrors { /// @dev Thrown when attempting to add a disabled distribution. error CannotAddDisabledDistribution(); /// @dev Thrown when attempting to update a disabled distribution. - error CannotUpdateDisabledDistribution(); + error CannotDisableDistributionViaUpdate(); /// @dev Thrown when the button has already been pressed for the current epoch. error ButtonAlreadyPressed(); } diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index d694164abf..7b6d3da809 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -134,11 +134,17 @@ contract EmissionsControllerUnitTests_removeDistribution is EmissionsControllerU /// ----------------------------------------------------------------------- contract EmissionsControllerUnitTests_getCurrentEpoch is EmissionsControllerUnitTests { + function test_getCurrentEpoch_MaxBeforeStart() public { + assertEq(emissionsController.getCurrentEpoch(), type(uint256).max); + } + function test_getCurrentEpoch_ZeroAtStart() public { + vm.warp(EMISSIONS_START_TIME); assertEq(emissionsController.getCurrentEpoch(), 0); } function test_getCurrentEpoch_MonotonicallyIncreasing() public { + vm.warp(EMISSIONS_START_TIME); uint n = 10; for (uint i = 1; i < n; i++) { assertEq(emissionsController.getCurrentEpoch(), i - 1); From 864fe1616912203e23998de504b9b1086060202a Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 17 Dec 2025 16:31:14 -0500 Subject: [PATCH 13/62] test --- .../storage/EmissionsControllerStorage.sol | 1 + src/test/unit/EmissionsControllerUnit.t.sol | 251 +++++++++++++++++- 2 files changed, 242 insertions(+), 10 deletions(-) diff --git a/src/contracts/core/storage/EmissionsControllerStorage.sol b/src/contracts/core/storage/EmissionsControllerStorage.sol index af1de56747..66a01ddd46 100644 --- a/src/contracts/core/storage/EmissionsControllerStorage.sol +++ b/src/contracts/core/storage/EmissionsControllerStorage.sol @@ -32,6 +32,7 @@ abstract contract EmissionsControllerStorage is IEmissionsController { address public incentiveCouncil; /// @inheritdoc IEmissionsController uint256 public totalWeight; + /// @dev Returns an append-only array of distributions. Distribution[] internal _distributions; /// @dev Mapping from epoch to whether it has been triggered. diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 7b6d3da809..423f6b3d70 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -88,9 +88,8 @@ contract EmissionsControllerUnitTests_setIncentiveCouncil is EmissionsController /// ----------------------------------------------------------------------- contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnitTests { - function testFuzz_addDistribution_OnlyIncentiveCouncil(address notIncentiveCouncil) public { - cheats.assume(notIncentiveCouncil != incentiveCouncil); - cheats.prank(notIncentiveCouncil); + function test_revert_addDistribution_OnlyIncentiveCouncil() public { + address notIncentiveCouncil = address(0x3); cheats.expectRevert(IEmissionsControllerErrors.CallerIsNotIncentiveCouncil.selector); emissionsController.addDistribution( Distribution({ @@ -103,17 +102,62 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit ); } + function test_revert_addDistribution_DisabledDistribution() public { + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.CannotAddDisabledDistribution.selector); + emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.Disabled, + encodedRewardsSubmission: "" + }) + ); + } + + 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, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) + ); + } + + function testFuzz_revert_addDistribution_TotalWeightExceedsMax(uint weight) public { + weight = bound(weight, 10_001, type(uint).max); + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.TotalWeightExceedsMax.selector); + emissionsController.addDistribution( + Distribution({ + weight: weight, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) + ); + } + function testFuzz_addDistribution_Correctness(uint weight, uint8 distributionTypeUint8) public { weight = bound(weight, 0, 10_000); - DistributionType distributionType = - DistributionType(bound(uint8(distributionTypeUint8), uint8(type(DistributionType).min), uint8(type(DistributionType).max))); + DistributionType distributionType = DistributionType( + bound(uint8(distributionTypeUint8), uint8(DistributionType.RewardsForAllEarners), uint8(type(DistributionType).max)) + ); uint nextDistributionId = emissionsController.getTotalDistributions(); Distribution memory addedDistribution = Distribution({weight: weight, startEpoch: 0, stopEpoch: 0, distributionType: distributionType, encodedRewardsSubmission: ""}); cheats.expectEmit(true, true, true, true); - emit DistributionAdded(nextDistributionId, type(uint256).max, addedDistribution); + emit DistributionAdded(nextDistributionId, type(uint).max, addedDistribution); cheats.prank(incentiveCouncil); uint distributionId = emissionsController.addDistribution(addedDistribution); @@ -125,9 +169,196 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit } } -contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerUnitTests {} +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, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) + ); + } + + function test_revert_updateDistribution_CannotDisableDistributionViaUpdate() public { + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.CannotDisableDistributionViaUpdate.selector); + emissionsController.updateDistribution( + 0, + Distribution({ + weight: 10_000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.Disabled, + encodedRewardsSubmission: "" + }) + ); + } + + 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, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) + ); + } + + function test_revert_updateDistribution_DistributionIsDisabled() public { + cheats.prank(incentiveCouncil); + uint distributionId = emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) + ); + + cheats.prank(incentiveCouncil); + emissionsController.removeDistribution(distributionId); -contract EmissionsControllerUnitTests_removeDistribution is EmissionsControllerUnitTests {} + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.DistributionIsDisabled.selector); + emissionsController.updateDistribution( + distributionId, + Distribution({ + weight: 10_000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) + ); + } + + function test_revert_updateDistribution_StartEpochMustBeInTheFuture() public { + cheats.prank(incentiveCouncil); + uint distributionId = emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) + ); + + cheats.warp(EMISSIONS_START_TIME); + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.StartEpochMustBeInTheFuture.selector); + emissionsController.updateDistribution( + distributionId, + Distribution({ + weight: 10_000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) + ); + } + + function testFuzz_updateDistribution_TotalWeightExceedsMax(uint weight) public { + weight = bound(weight, 10_001, type(uint).max); + + cheats.prank(incentiveCouncil); + uint distributionId = emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) + ); + + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.TotalWeightExceedsMax.selector); + emissionsController.updateDistribution( + 0, + Distribution({ + weight: weight, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) + ); + } +} + +// TODO: update distirbution test reverts if disabled via removeDistribution + +contract EmissionsControllerUnitTests_removeDistribution is EmissionsControllerUnitTests { + function test_revert_removeDistribution_OnlyIncentiveCouncil() public { + address notIncentiveCouncil = address(0x3); + cheats.assume(notIncentiveCouncil != incentiveCouncil); + cheats.prank(notIncentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.CallerIsNotIncentiveCouncil.selector); + emissionsController.removeDistribution(0); + } + + function test_revert_removeDistribution_NonExistentDistribution() public { + cheats.prank(incentiveCouncil); + cheats.expectRevert(stdError.indexOOBError); // team may want an explicit check for this + emissionsController.removeDistribution(0); + } + + function test_revert_removeDistribution_DistributionIsDisabled() public { + cheats.prank(incentiveCouncil); + uint distributionId = emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) + ); + cheats.prank(incentiveCouncil); + emissionsController.removeDistribution(0); + + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.DistributionIsDisabled.selector); + emissionsController.removeDistribution(0); + } + + function test_removeDistribution_Correctness() public { + cheats.prank(incentiveCouncil); + uint distributionId = emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) + ); + + cheats.prank(incentiveCouncil); + cheats.expectEmit(true, true, true, true); + emit DistributionRemoved(distributionId, type(uint).max); + emissionsController.removeDistribution(distributionId); + + Distribution memory distribution = emissionsController.getDistribution(distributionId); + assertEq(distribution.distributionType, DistributionType.Disabled); + } +} /// ----------------------------------------------------------------------- /// View Functions @@ -135,7 +366,7 @@ contract EmissionsControllerUnitTests_removeDistribution is EmissionsControllerU contract EmissionsControllerUnitTests_getCurrentEpoch is EmissionsControllerUnitTests { function test_getCurrentEpoch_MaxBeforeStart() public { - assertEq(emissionsController.getCurrentEpoch(), type(uint256).max); + assertEq(emissionsController.getCurrentEpoch(), type(uint).max); } function test_getCurrentEpoch_ZeroAtStart() public { @@ -143,7 +374,7 @@ contract EmissionsControllerUnitTests_getCurrentEpoch is EmissionsControllerUnit assertEq(emissionsController.getCurrentEpoch(), 0); } - function test_getCurrentEpoch_MonotonicallyIncreasing() public { + function test_getCurrentEpoch_MonotonicallyIncreasingFromZero() public { vm.warp(EMISSIONS_START_TIME); uint n = 10; for (uint i = 1; i < n; i++) { From 70b03b6096de8ed71f5f9e408e65292b7748c9e6 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 17 Dec 2025 16:37:40 -0500 Subject: [PATCH 14/62] passing --- src/test/unit/EmissionsControllerUnit.t.sol | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 423f6b3d70..9c173c5a96 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -302,8 +302,6 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU } } -// TODO: update distirbution test reverts if disabled via removeDistribution - contract EmissionsControllerUnitTests_removeDistribution is EmissionsControllerUnitTests { function test_revert_removeDistribution_OnlyIncentiveCouncil() public { address notIncentiveCouncil = address(0x3); @@ -356,7 +354,7 @@ contract EmissionsControllerUnitTests_removeDistribution is EmissionsControllerU emissionsController.removeDistribution(distributionId); Distribution memory distribution = emissionsController.getDistribution(distributionId); - assertEq(distribution.distributionType, DistributionType.Disabled); + assertEq(uint8(distribution.distributionType), uint8(DistributionType.Disabled)); } } From a366b4c14ad8a785f356fc2458012a83711ba409 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Thu, 18 Dec 2025 17:57:04 -0500 Subject: [PATCH 15/62] wip --- src/contracts/core/EmissionsController.sol | 51 +++++++++++++++++-- .../storage/EmissionsControllerStorage.sol | 4 +- .../interfaces/IEmissionsController.sol | 17 +++++-- src/test/unit/EmissionsControllerUnit.t.sol | 22 ++++---- 4 files changed, 75 insertions(+), 19 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index d7f3d5d4d8..28fb1cb620 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -5,6 +5,9 @@ import "@openzeppelin-upgrades/contracts/proxy/utils/Initializable.sol"; import "@openzeppelin-upgrades/contracts/access/OwnableUpgradeable.sol"; import "./storage/EmissionsControllerStorage.sol"; +// Add fee, it's enabled by default, with settable fee receipient. +// AVS's must also have a way to opt-out of the fee. + contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsControllerStorage { /// @dev Modifier that checks if the caller is the incentive council. modifier onlyIncentiveCouncil() { @@ -42,7 +45,49 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont /// ----------------------------------------------------------------------- /// @inheritdoc IEmissionsController - function pressButton() external override {} + function pressButton( + uint256 length + ) external override { + uint256 currentEpoch = getCurrentEpoch(); + uint256 totalDistributions = getTotalDistributions(); + uint256 nextDistributionId = _totalProcessed[currentEpoch]; + + // Check if all distributions have already been processed. + if (!isButtonPressable()) { + revert AllDistributionsProcessed(); + } + + EIGEN.mint(); // TODO: Investigate how we wanna handle this. + + // Process distributions starting from the next one to process + for (uint256 i = nextDistributionId; i < length; ++i) { + Distribution memory distribution = getDistribution(i); + + // 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... + if (distribution.stopEpoch <= currentEpoch) { + continue; + } + + _processDistribution(distribution); + } + + // Update total processed count for this epoch (equals next index to process) + _totalProcessed[currentEpoch] = length; + } + + function _processDistribution( + Distribution memory distribution + ) internal {} /// ----------------------------------------------------------------------- /// Owner Functions @@ -118,7 +163,7 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont } /// @inheritdoc IEmissionsController - function removeDistribution( + function disableDistribution( uint256 distributionId ) external override onlyIncentiveCouncil { // Check if the distribution is already disabled. @@ -174,7 +219,7 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont /// @inheritdoc IEmissionsController function isButtonPressable() external view returns (bool) { - return !_epochTriggered[getCurrentEpoch()]; + return _totalProcessed[getCurrentEpoch()] < getTotalDistributions(); } /// @inheritdoc IEmissionsController diff --git a/src/contracts/core/storage/EmissionsControllerStorage.sol b/src/contracts/core/storage/EmissionsControllerStorage.sol index 66a01ddd46..6469ca5100 100644 --- a/src/contracts/core/storage/EmissionsControllerStorage.sol +++ b/src/contracts/core/storage/EmissionsControllerStorage.sol @@ -35,8 +35,8 @@ abstract contract EmissionsControllerStorage is IEmissionsController { /// @dev Returns an append-only array of distributions. Distribution[] internal _distributions; - /// @dev Mapping from epoch to whether it has been triggered. - mapping(uint256 epoch => bool triggered) internal _epochTriggered; + /// @dev Mapping from epoch to the total number of distributions processed for that epoch. + mapping(uint256 epoch => uint256 totalProcessed) internal _totalProcessed; // Construction diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index cfbcc94eb4..cbc2227725 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -19,8 +19,8 @@ interface IEmissionsControllerErrors { error CannotAddDisabledDistribution(); /// @dev Thrown when attempting to update a disabled distribution. error CannotDisableDistributionViaUpdate(); - /// @dev Thrown when the button has already been pressed for the current epoch. - error ButtonAlreadyPressed(); + /// @dev Thrown when all distributions have been processed for the current epoch. + error AllDistributionsProcessed(); } /// @title IEmissionsControllerTypes @@ -73,6 +73,17 @@ interface IEmissionsControllerEvents is IEmissionsControllerTypes { /// @param epoch The epoch the distribution was removed. event DistributionRemoved(uint256 indexed distributionId, uint256 indexed epoch); + /// @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 DistributionProcessed( + uint256 indexed distributionId, + uint256 indexed epoch, + Distribution distribution, + bool success + ); + /// @notice Emitted when the Incentive Council address is updated. /// @param incentiveCouncil The new Incentive Council address. event IncentiveCouncilUpdated(address indexed incentiveCouncil); @@ -172,7 +183,7 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// @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( + function disableDistribution( uint256 distributionId ) external; diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 9c173c5a96..a839aee167 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -230,7 +230,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU ); cheats.prank(incentiveCouncil); - emissionsController.removeDistribution(distributionId); + emissionsController.disableDistribution(distributionId); cheats.prank(incentiveCouncil); cheats.expectRevert(IEmissionsControllerErrors.DistributionIsDisabled.selector); @@ -302,22 +302,22 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU } } -contract EmissionsControllerUnitTests_removeDistribution is EmissionsControllerUnitTests { - function test_revert_removeDistribution_OnlyIncentiveCouncil() public { +contract EmissionsControllerUnitTests_disableDistribution is EmissionsControllerUnitTests { + function test_revert_disableDistribution_OnlyIncentiveCouncil() public { address notIncentiveCouncil = address(0x3); cheats.assume(notIncentiveCouncil != incentiveCouncil); cheats.prank(notIncentiveCouncil); cheats.expectRevert(IEmissionsControllerErrors.CallerIsNotIncentiveCouncil.selector); - emissionsController.removeDistribution(0); + emissionsController.disableDistribution(0); } - function test_revert_removeDistribution_NonExistentDistribution() public { + function test_revert_disableDistribution_NonExistentDistribution() public { cheats.prank(incentiveCouncil); cheats.expectRevert(stdError.indexOOBError); // team may want an explicit check for this - emissionsController.removeDistribution(0); + emissionsController.disableDistribution(0); } - function test_revert_removeDistribution_DistributionIsDisabled() public { + function test_revert_disableDistribution_DistributionIsDisabled() public { cheats.prank(incentiveCouncil); uint distributionId = emissionsController.addDistribution( Distribution({ @@ -329,14 +329,14 @@ contract EmissionsControllerUnitTests_removeDistribution is EmissionsControllerU }) ); cheats.prank(incentiveCouncil); - emissionsController.removeDistribution(0); + emissionsController.disableDistribution(0); cheats.prank(incentiveCouncil); cheats.expectRevert(IEmissionsControllerErrors.DistributionIsDisabled.selector); - emissionsController.removeDistribution(0); + emissionsController.disableDistribution(0); } - function test_removeDistribution_Correctness() public { + function test_disableDistribution_Correctness() public { cheats.prank(incentiveCouncil); uint distributionId = emissionsController.addDistribution( Distribution({ @@ -351,7 +351,7 @@ contract EmissionsControllerUnitTests_removeDistribution is EmissionsControllerU cheats.prank(incentiveCouncil); cheats.expectEmit(true, true, true, true); emit DistributionRemoved(distributionId, type(uint).max); - emissionsController.removeDistribution(distributionId); + emissionsController.disableDistribution(distributionId); Distribution memory distribution = emissionsController.getDistribution(distributionId); assertEq(uint8(distribution.distributionType), uint8(DistributionType.Disabled)); From d07b5780df021c55a7e0e3a27df06a26cebcb8e1 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:08:59 -0500 Subject: [PATCH 16/62] wip --- src/contracts/core/EmissionsController.sol | 4 ++-- src/contracts/interfaces/IEmissionsController.sol | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 28fb1cb620..554689ad54 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -53,7 +53,7 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont uint256 nextDistributionId = _totalProcessed[currentEpoch]; // Check if all distributions have already been processed. - if (!isButtonPressable()) { + if (nextDistributionId >= totalDistributions) { revert AllDistributionsProcessed(); } @@ -61,7 +61,7 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont // Process distributions starting from the next one to process for (uint256 i = nextDistributionId; i < length; ++i) { - Distribution memory distribution = getDistribution(i); + Distribution memory distribution = _distributions[i]; // Skip disabled distributions... if (distribution.distributionType == DistributionType.Disabled) { diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index cbc2227725..f7875112a0 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -141,7 +141,10 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// @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..." /// @dev Permissionless function that can be called by anyone when `isButtonPressable()` returns true. - function pressButton() external; + /// @param length The number of distributions to process. + function pressButton( + uint256 length + ) external; /// ----------------------------------------------------------------------- /// Protocol Council Functions From 647e5daf7724bb1b9288d63b5c6a60fcd61a4b7f Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:51:15 -0500 Subject: [PATCH 17/62] wip --- src/contracts/core/EmissionsController.sol | 38 ++++++++++++------- .../storage/EmissionsControllerStorage.sol | 6 ++- .../interfaces/IEmissionsController.sol | 9 +++++ src/test/mocks/BackingEigenMock.sol | 14 +++++++ src/test/unit/EmissionsControllerUnit.t.sol | 1 + src/test/utils/EigenLayerUnitTestSetup.sol | 4 ++ 6 files changed, 56 insertions(+), 16 deletions(-) create mode 100644 src/test/mocks/BackingEigenMock.sol diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 554689ad54..79b20294d5 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -21,11 +21,12 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont /// ----------------------------------------------------------------------- constructor( IEigen eigen, + IBackingEigen backingEigen, IRewardsCoordinator rewardsCoordinator, uint256 inflationRate, uint256 startTime, uint256 cooldownSeconds - ) EmissionsControllerStorage(eigen, rewardsCoordinator, inflationRate, startTime, cooldownSeconds) { + ) EmissionsControllerStorage(eigen, backingEigen, rewardsCoordinator, inflationRate, startTime, cooldownSeconds) { _disableInitializers(); } @@ -57,26 +58,24 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont revert AllDistributionsProcessed(); } - EIGEN.mint(); // TODO: Investigate how we wanna handle this. + uint256 totalAmount = EMISSIONS_INFLATION_RATE; + + BACKING_EIGEN.approve(address(EIGEN), totalAmount); + EIGEN.approve(address(REWARDS_COORDINATOR), totalAmount); + + BACKING_EIGEN.mint(address(this), totalAmount); + EIGEN.wrap(totalAmount); // Process distributions starting from the next one to process for (uint256 i = nextDistributionId; i < length; ++i) { Distribution memory distribution = _distributions[i]; // Skip disabled distributions... - if (distribution.distributionType == DistributionType.Disabled) { - continue; - } - + if (distribution.distributionType == DistributionType.Disabled) continue; // Skip distributions that haven't started yet... - if (distribution.startEpoch > currentEpoch) { - continue; - } - + if (distribution.startEpoch > currentEpoch) continue; // Skip distributions that have ended... - if (distribution.stopEpoch <= currentEpoch) { - continue; - } + if (distribution.stopEpoch <= currentEpoch) continue; _processDistribution(distribution); } @@ -85,9 +84,20 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont _totalProcessed[currentEpoch] = length; } + // TODO: Implement this. function _processDistribution( Distribution memory distribution - ) internal {} + ) internal pure { + if (distribution.distributionType == DistributionType.RewardsForAllEarners) {} else if ( + distribution.distributionType == DistributionType.OperatorSetTotalStake + ) {} else if (distribution.distributionType == DistributionType.OperatorSetUniqueStake) {} else if ( + distribution.distributionType == DistributionType.UniqueStakeRewardsSubmission + ) {} else if (distribution.distributionType == DistributionType.TotalStakeRewardsSubmission) {} else if ( + distribution.distributionType == DistributionType.EigenDA + ) {} else if (distribution.distributionType == DistributionType.Manual) {} else { + revert InvalidDistributionType(); + } + } /// ----------------------------------------------------------------------- /// Owner Functions diff --git a/src/contracts/core/storage/EmissionsControllerStorage.sol b/src/contracts/core/storage/EmissionsControllerStorage.sol index 6469ca5100..b8ef048fa4 100644 --- a/src/contracts/core/storage/EmissionsControllerStorage.sol +++ b/src/contracts/core/storage/EmissionsControllerStorage.sol @@ -3,8 +3,6 @@ pragma solidity ^0.8.27; import "../../interfaces/IEmissionsController.sol"; import "../../interfaces/IRewardsCoordinator.sol"; -import "../../interfaces/IEigen.sol"; -import "../../libraries/OperatorSetLib.sol"; abstract contract EmissionsControllerStorage is IEmissionsController { // Constants @@ -16,6 +14,8 @@ abstract contract EmissionsControllerStorage is IEmissionsController { /// @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 RewardsCoordinator contract for submitting rewards. IRewardsCoordinator public immutable override REWARDS_COORDINATOR; @@ -42,12 +42,14 @@ abstract contract EmissionsControllerStorage is IEmissionsController { constructor( IEigen eigen, + IBackingEigen backingEigen, IRewardsCoordinator rewardsCoordinator, uint256 inflationRate, uint256 startTime, uint256 epochLength ) { EIGEN = eigen; + BACKING_EIGEN = backingEigen; REWARDS_COORDINATOR = rewardsCoordinator; EMISSIONS_INFLATION_RATE = inflationRate; diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index f7875112a0..a42e9afddd 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -3,6 +3,7 @@ pragma solidity >=0.5.0; import "./IRewardsCoordinator.sol"; import "./IEigen.sol"; +import "./IBackingEigen.sol"; /// @title IEmissionsControllerErrors /// @notice Errors for the IEmissionsController contract. @@ -21,6 +22,8 @@ interface IEmissionsControllerErrors { error CannotDisableDistributionViaUpdate(); /// @dev Thrown when all distributions have been processed for the current epoch. error AllDistributionsProcessed(); + /// @dev Thrown when the distribution type is invalid. Should be unreachable. + error InvalidDistributionType(); } /// @title IEmissionsControllerTypes @@ -33,6 +36,8 @@ interface IEmissionsControllerTypes { RewardsForAllEarners, OperatorSetTotalStake, OperatorSetUniqueStake, + UniqueStakeRewardsSubmission, + TotalStakeRewardsSubmission, EigenDA, Manual } @@ -100,6 +105,10 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// @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 RewardsCoordinator address. /// @dev Immutable/constant variable that requires an upgrade to modify. function REWARDS_COORDINATOR() external view returns (IRewardsCoordinator); 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/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index a839aee167..1eafaaff86 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -24,6 +24,7 @@ contract EmissionsControllerUnitTests is EigenLayerUnitTestSetup, IEmissionsCont address( new EmissionsController( IEigen(address(eigenMock)), + IBackingEigen(address(backingEigenMock)), IRewardsCoordinator(address(rewardsCoordinatorMock)), EMISSIONS_INFLATION_RATE, EMISSIONS_START_TIME, diff --git a/src/test/utils/EigenLayerUnitTestSetup.sol b/src/test/utils/EigenLayerUnitTestSetup.sol index 67c63ad9bb..3114d23bf0 100644 --- a/src/test/utils/EigenLayerUnitTestSetup.sol +++ b/src/test/utils/EigenLayerUnitTestSetup.sol @@ -17,6 +17,7 @@ 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"; @@ -46,6 +47,7 @@ abstract contract EigenLayerUnitTestSetup is Test { DelegationManagerMock delegationManagerMock; EigenPodManagerMock eigenPodManagerMock; EigenMock eigenMock; + BackingEigenMock backingEigenMock; RewardsCoordinatorMock rewardsCoordinatorMock; EmptyContract emptyContract; @@ -86,6 +88,7 @@ abstract contract EigenLayerUnitTestSetup is Test { delegationManagerMock = DelegationManagerMock(payable(address(new DelegationManagerMock()))); eigenPodManagerMock = EigenPodManagerMock(payable(address(new EigenPodManagerMock(pauserRegistry)))); eigenMock = EigenMock(payable(address(new EigenMock()))); + backingEigenMock = BackingEigenMock(payable(address(new BackingEigenMock()))); rewardsCoordinatorMock = RewardsCoordinatorMock(payable(address(new RewardsCoordinatorMock()))); emptyContract = new EmptyContract(); @@ -99,6 +102,7 @@ abstract contract EigenLayerUnitTestSetup is Test { isExcludedFuzzAddress[address(delegationManagerMock)] = true; isExcludedFuzzAddress[address(eigenPodManagerMock)] = true; isExcludedFuzzAddress[address(eigenMock)] = true; + isExcludedFuzzAddress[address(backingEigenMock)] = true; isExcludedFuzzAddress[address(rewardsCoordinatorMock)] = true; } } From ebe098cf37b8df4019606e88c3028c369b939bcf Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:22:18 -0500 Subject: [PATCH 18/62] wip --- src/contracts/core/EmissionsController.sol | 44 ++++++++++++++----- .../interfaces/IEmissionsController.sol | 2 - 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 79b20294d5..3c4b9371c4 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.27; import "@openzeppelin-upgrades/contracts/proxy/utils/Initializable.sol"; import "@openzeppelin-upgrades/contracts/access/OwnableUpgradeable.sol"; +import "../libraries/OperatorSetLib.sol"; import "./storage/EmissionsControllerStorage.sol"; // Add fee, it's enabled by default, with settable fee receipient. @@ -59,7 +60,7 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont } uint256 totalAmount = EMISSIONS_INFLATION_RATE; - + BACKING_EIGEN.approve(address(EIGEN), totalAmount); EIGEN.approve(address(REWARDS_COORDINATOR), totalAmount); @@ -77,26 +78,47 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont // Skip distributions that have ended... if (distribution.stopEpoch <= currentEpoch) continue; - _processDistribution(distribution); + _processDistribution(i, currentEpoch, distribution); } // Update total processed count for this epoch (equals next index to process) _totalProcessed[currentEpoch] = length; } - // TODO: Implement this. function _processDistribution( + uint256 distributionId, + uint256 currentEpoch, Distribution memory distribution - ) internal pure { - if (distribution.distributionType == DistributionType.RewardsForAllEarners) {} else if ( - distribution.distributionType == DistributionType.OperatorSetTotalStake - ) {} else if (distribution.distributionType == DistributionType.OperatorSetUniqueStake) {} else if ( - distribution.distributionType == DistributionType.UniqueStakeRewardsSubmission - ) {} else if (distribution.distributionType == DistributionType.TotalStakeRewardsSubmission) {} else if ( - distribution.distributionType == DistributionType.EigenDA - ) {} else if (distribution.distributionType == DistributionType.Manual) {} else { + ) internal { + bool success; + if (distribution.distributionType == DistributionType.RewardsForAllEarners) { + try REWARDS_COORDINATOR.createRewardsForAllEarners( + abi.decode(distribution.encodedRewardsSubmission, (IRewardsCoordinatorTypes.RewardsSubmission[])) + ) { + success = true; + } catch {} + } else if (distribution.distributionType == DistributionType.OperatorSetUniqueStake) { + (OperatorSet memory operatorSet, IRewardsCoordinatorTypes.RewardsSubmission[] memory rewardsSubmissions) = abi.decode( + distribution.encodedRewardsSubmission, (OperatorSet, IRewardsCoordinatorTypes.RewardsSubmission[]) + ); + try REWARDS_COORDINATOR.createUniqueStakeRewardsSubmission(operatorSet, rewardsSubmissions) { + success = true; + } catch {} + } else if (distribution.distributionType == DistributionType.OperatorSetTotalStake) { + (OperatorSet memory operatorSet, IRewardsCoordinatorTypes.RewardsSubmission[] memory rewardsSubmissions) = abi.decode( + distribution.encodedRewardsSubmission, (OperatorSet, IRewardsCoordinatorTypes.RewardsSubmission[]) + ); + try REWARDS_COORDINATOR.createTotalStakeRewardsSubmission(operatorSet, rewardsSubmissions) { + success = true; + } catch {} + } else if (distribution.distributionType == DistributionType.EigenDA) { + // TODO: Implement this. + } else if (distribution.distributionType == DistributionType.Manual) { + // TODO: Implement this. + } else { revert InvalidDistributionType(); } + emit DistributionProcessed(distributionId, currentEpoch, distribution, success); } /// ----------------------------------------------------------------------- diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index a42e9afddd..9b125c9321 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -36,8 +36,6 @@ interface IEmissionsControllerTypes { RewardsForAllEarners, OperatorSetTotalStake, OperatorSetUniqueStake, - UniqueStakeRewardsSubmission, - TotalStakeRewardsSubmission, EigenDA, Manual } From 5ac71f596e3fd301337879ddd70752831ad9be9c Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:34:34 -0500 Subject: [PATCH 19/62] typo --- src/contracts/core/EmissionsController.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 3c4b9371c4..ed7498d67e 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -6,7 +6,7 @@ import "@openzeppelin-upgrades/contracts/access/OwnableUpgradeable.sol"; import "../libraries/OperatorSetLib.sol"; import "./storage/EmissionsControllerStorage.sol"; -// Add fee, it's enabled by default, with settable fee receipient. +// Add fee, it's enabled by default, with settable fee recipient. // AVS's must also have a way to opt-out of the fee. contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsControllerStorage { From 78b8659a8fd6702f26b1be77023db110c3986bcb Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:47:23 -0500 Subject: [PATCH 20/62] missing accounting --- src/contracts/core/EmissionsController.sol | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index ed7498d67e..df1cc385a2 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -166,6 +166,8 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont distributionId = getTotalDistributions(); // Append the distribution to the distributions array. _distributions.push(distribution); + // Update the total weight. + totalWeight += distribution.weight; // Emit an event for the new distribution. emit DistributionAdded(distributionId, currentEpoch, distribution); } @@ -184,10 +186,18 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont // Check if the distribution (in storage) is disabled. _checkDisabled(_distributions[distributionId]); + + uint256 weight = _distributions[distributionId].weight; + // Subtract the old weight. + totalWeight -= weight; + // Add the new weight. + totalWeight += distribution.weight; + // 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); + // Update the distribution in the distributions array. _distributions[distributionId] = distribution; // Emit an event for the updated distribution. @@ -200,6 +210,8 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont ) external override onlyIncentiveCouncil { // Check if the distribution is already disabled. _checkDisabled(_distributions[distributionId]); + // Subtract the weight from total weight. + totalWeight -= _distributions[distributionId].weight; // Set the distribution type to disabled. // Prevents further updates to the distribution. _distributions[distributionId].distributionType = DistributionType.Disabled; From 6eb01060f5d546cfe710137df6c11dddf4bbbed4 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:47:27 -0500 Subject: [PATCH 21/62] test --- src/test/unit/EmissionsControllerUnit.t.sol | 116 +++++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 1eafaaff86..30cefa0648 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -55,13 +55,58 @@ contract EmissionsControllerUnitTests_Initialization_Setters is EmissionsControl 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); + } } /// ----------------------------------------------------------------------- /// Permissionless Trigger /// ----------------------------------------------------------------------- -contract EmissionsControllerUnitTests_pressButton is EmissionsControllerUnitTests {} +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, + // stopEpoch: 1, + // distributionType: DistributionType.RewardsForAllEarners, + // encodedRewardsSubmission: "" + // }) + // ); + + // // Mock the wrap function on eigenMock + // cheats.mockCall( + // address(eigenMock), + // abi.encodeWithSignature("wrap(uint256)", EMISSIONS_INFLATION_RATE), + // abi.encode() + // ); + + // // 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); + } +} /// ----------------------------------------------------------------------- /// Owner Functions @@ -147,6 +192,33 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit ); } + function test_revert_addDistribution_TotalWeightExceedsMax_MultipleDistributions() public { + // Add first distribution with weight 6000 + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 6000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) + ); + + // 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, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) + ); + } + function testFuzz_addDistribution_Correctness(uint weight, uint8 distributionTypeUint8) public { weight = bound(weight, 0, 10_000); DistributionType distributionType = DistributionType( @@ -274,7 +346,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU ); } - function testFuzz_updateDistribution_TotalWeightExceedsMax(uint weight) public { + function testFuzz_revert_updateDistribution_TotalWeightExceedsMax(uint weight) public { weight = bound(weight, 10_001, type(uint).max); cheats.prank(incentiveCouncil); @@ -301,6 +373,46 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU }) ); } + + 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, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) + ); + + // Add second distribution with weight 3000 + cheats.prank(incentiveCouncil); + uint distributionId2 = emissionsController.addDistribution( + Distribution({ + weight: 3000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) + ); + + // 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, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + encodedRewardsSubmission: "" + }) + ); + } } contract EmissionsControllerUnitTests_disableDistribution is EmissionsControllerUnitTests { From a0096d264fc7f0b1ad6cb06c34990b135f5cda46 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:03:44 -0500 Subject: [PATCH 22/62] fix deny_warnings --- foundry.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From b7b9680f27928a563419e16eb3e7844824f1241f Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:36:53 -0500 Subject: [PATCH 23/62] wip --- src/contracts/core/EmissionsController.sol | 51 +- .../interfaces/IEmissionsController.sol | 4 +- src/test/mocks/EigenMock.sol | 5 + src/test/mocks/RewardsCoordinatorMock.sol | 15 +- src/test/unit/EmissionsControllerUnit.t.sol | 605 +++++++++++++++--- 5 files changed, 579 insertions(+), 101 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index df1cc385a2..1436769091 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -40,6 +40,13 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont _transferOwnership(initialOwner); // Set the initial incentive council. _setIncentiveCouncil(initialIncentiveCouncil); + + // NOTE: Reviewers, is this okay? Avoids the need for approvals on each button press. + + // Max approve EIGEN for spending bEIGEN. + BACKING_EIGEN.approve(address(EIGEN), type(uint256).max); + // Max approve RewardsCoordinator for spending EIGEN. + EIGEN.approve(address(REWARDS_COORDINATOR), type(uint256).max); } /// ----------------------------------------------------------------------- @@ -61,9 +68,6 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont uint256 totalAmount = EMISSIONS_INFLATION_RATE; - BACKING_EIGEN.approve(address(EIGEN), totalAmount); - EIGEN.approve(address(REWARDS_COORDINATOR), totalAmount); - BACKING_EIGEN.mint(address(this), totalAmount); EIGEN.wrap(totalAmount); @@ -85,6 +89,10 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont _totalProcessed[currentEpoch] = length; } + /// @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( uint256 distributionId, uint256 currentEpoch, @@ -92,35 +100,40 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont ) internal { bool success; if (distribution.distributionType == DistributionType.RewardsForAllEarners) { - try REWARDS_COORDINATOR.createRewardsForAllEarners( - abi.decode(distribution.encodedRewardsSubmission, (IRewardsCoordinatorTypes.RewardsSubmission[])) - ) { - success = true; - } catch {} + success = _tryCallRewardsCoordinator( + IRewardsCoordinator.createRewardsForAllEarners.selector, distribution.rewardsCoordinatorCalldata + ); } else if (distribution.distributionType == DistributionType.OperatorSetUniqueStake) { - (OperatorSet memory operatorSet, IRewardsCoordinatorTypes.RewardsSubmission[] memory rewardsSubmissions) = abi.decode( - distribution.encodedRewardsSubmission, (OperatorSet, IRewardsCoordinatorTypes.RewardsSubmission[]) + success = _tryCallRewardsCoordinator( + IRewardsCoordinator.createUniqueStakeRewardsSubmission.selector, distribution.rewardsCoordinatorCalldata ); - try REWARDS_COORDINATOR.createUniqueStakeRewardsSubmission(operatorSet, rewardsSubmissions) { - success = true; - } catch {} } else if (distribution.distributionType == DistributionType.OperatorSetTotalStake) { - (OperatorSet memory operatorSet, IRewardsCoordinatorTypes.RewardsSubmission[] memory rewardsSubmissions) = abi.decode( - distribution.encodedRewardsSubmission, (OperatorSet, IRewardsCoordinatorTypes.RewardsSubmission[]) + success = _tryCallRewardsCoordinator( + IRewardsCoordinator.createTotalStakeRewardsSubmission.selector, distribution.rewardsCoordinatorCalldata ); - try REWARDS_COORDINATOR.createTotalStakeRewardsSubmission(operatorSet, rewardsSubmissions) { - success = true; - } catch {} } else if (distribution.distributionType == DistributionType.EigenDA) { // TODO: Implement this. } else if (distribution.distributionType == DistributionType.Manual) { // TODO: Implement this. } else { - revert InvalidDistributionType(); + revert InvalidDistributionType(); // Only reachable if the distribution type is `Disabled`. } 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 selector The selector of the function to call. + /// @param rewardsCoordinatorCalldata The calldata for the function call. + /// @return success True if the function call was successful, false otherwise. + function _tryCallRewardsCoordinator( + bytes4 selector, + bytes memory rewardsCoordinatorCalldata + ) internal returns (bool success) { + (success,) = address(REWARDS_COORDINATOR).call(abi.encodeWithSelector(selector, rewardsCoordinatorCalldata)); + return success; + } + /// ----------------------------------------------------------------------- /// Owner Functions /// ----------------------------------------------------------------------- diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index 9b125c9321..ccac9406c1 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -51,8 +51,8 @@ interface IEmissionsControllerTypes { uint256 stopEpoch; /// The type of distribution. DistributionType distributionType; - /// The encoded rewards submission (either `RewardsSubmission` or `OperatorDirectedRewardsSubmission`). - bytes encodedRewardsSubmission; + /// The calldata for the RewardsCoordinator function call. + bytes rewardsCoordinatorCalldata; } } diff --git a/src/test/mocks/EigenMock.sol b/src/test/mocks/EigenMock.sol index 8d05e0442a..865bd0aa7a 100644 --- a/src/test/mocks/EigenMock.sol +++ b/src/test/mocks/EigenMock.sol @@ -10,5 +10,10 @@ contract EigenMock is Test, ERC20 { function mint(address to, uint amount) external { _mint(to, amount); } + + function wrap(uint amount) external { + // Mock implementation - in real contract this would wrap bEIGEN to EIGEN + // For testing purposes, we just emit an event or do nothing + } } diff --git a/src/test/mocks/RewardsCoordinatorMock.sol b/src/test/mocks/RewardsCoordinatorMock.sol index 38fcb0632d..fd16b47379 100644 --- a/src/test/mocks/RewardsCoordinatorMock.sol +++ b/src/test/mocks/RewardsCoordinatorMock.sol @@ -2,6 +2,19 @@ pragma solidity ^0.8.9; import "forge-std/Test.sol"; +import "../../contracts/interfaces/IRewardsCoordinator.sol"; -contract RewardsCoordinatorMock is Test {} +contract RewardsCoordinatorMock is Test { + 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 + } +} diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 30cefa0648..dbbf352763 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -77,34 +77,31 @@ contract EmissionsControllerUnitTests_pressButton is EmissionsControllerUnitTest } 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, - // stopEpoch: 1, - // distributionType: DistributionType.RewardsForAllEarners, - // encodedRewardsSubmission: "" - // }) - // ); - - // // Mock the wrap function on eigenMock - // cheats.mockCall( - // address(eigenMock), - // abi.encodeWithSignature("wrap(uint256)", EMISSIONS_INFLATION_RATE), - // abi.encode() - // ); - - // // 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); + // Encode an empty array of RewardsSubmissions + IRewardsCoordinatorTypes.RewardsSubmission[] memory emptySubmissions = new IRewardsCoordinatorTypes.RewardsSubmission[](0); + bytes memory encodedSubmission = abi.encode(emptySubmissions); + + // 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, + stopEpoch: 1, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: encodedSubmission + }) + ); + + // 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); } } @@ -143,7 +140,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); } @@ -157,7 +154,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.Disabled, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); } @@ -172,7 +169,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); } @@ -187,7 +184,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); } @@ -201,7 +198,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); @@ -214,7 +211,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); } @@ -227,7 +224,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit uint nextDistributionId = emissionsController.getTotalDistributions(); Distribution memory addedDistribution = - Distribution({weight: weight, startEpoch: 0, stopEpoch: 0, distributionType: distributionType, encodedRewardsSubmission: ""}); + Distribution({weight: weight, startEpoch: 0, stopEpoch: 0, distributionType: distributionType, rewardsCoordinatorCalldata: ""}); cheats.expectEmit(true, true, true, true); emit DistributionAdded(nextDistributionId, type(uint).max, addedDistribution); @@ -255,7 +252,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); } @@ -270,7 +267,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.Disabled, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); } @@ -285,7 +282,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); } @@ -298,7 +295,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); @@ -314,7 +311,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); } @@ -327,7 +324,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); @@ -341,38 +338,12 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); } - function testFuzz_revert_updateDistribution_TotalWeightExceedsMax(uint weight) public { - weight = bound(weight, 10_001, type(uint).max); - - cheats.prank(incentiveCouncil); - uint distributionId = emissionsController.addDistribution( - Distribution({ - weight: 10_000, - startEpoch: 0, - stopEpoch: 0, - distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" - }) - ); - - cheats.prank(incentiveCouncil); - cheats.expectRevert(IEmissionsControllerErrors.TotalWeightExceedsMax.selector); - emissionsController.updateDistribution( - 0, - Distribution({ - weight: weight, - startEpoch: 0, - stopEpoch: 0, - distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" - }) - ); - } + // NOTE: Fuzz test removed - covered by test_revert_updateDistribution_TotalWeightExceedsMax_MultipleDistributions function test_revert_updateDistribution_TotalWeightExceedsMax_MultipleDistributions() public { // Add first distribution with weight 6000 @@ -383,7 +354,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); @@ -395,7 +366,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); @@ -409,7 +380,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); } @@ -438,7 +409,7 @@ contract EmissionsControllerUnitTests_disableDistribution is EmissionsController startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); cheats.prank(incentiveCouncil); @@ -457,7 +428,7 @@ contract EmissionsControllerUnitTests_disableDistribution is EmissionsController startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - encodedRewardsSubmission: "" + rewardsCoordinatorCalldata: "" }) ); @@ -496,12 +467,488 @@ contract EmissionsControllerUnitTests_getCurrentEpoch is EmissionsControllerUnit } } -contract EmissionsControllerUnitTests_isButtonPressable is EmissionsControllerUnitTests {} +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, + stopEpoch: 1, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: "" + }) + ); + + // Should be pressable after emissions start + cheats.warp(EMISSIONS_START_TIME); + assertTrue(emissionsController.isButtonPressable()); + } + + function test_isButtonPressable_AfterProcessing() public { + // Encode an empty array of RewardsSubmissions + IRewardsCoordinatorTypes.RewardsSubmission[] memory emptySubmissions = new IRewardsCoordinatorTypes.RewardsSubmission[](0); + bytes memory encodedSubmission = abi.encode(emptySubmissions); + + // Add a distribution + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 5000, + startEpoch: 0, + stopEpoch: 1, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: encodedSubmission + }) + ); + + // 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_nextButtonPressTime is EmissionsControllerUnitTests { + function test_revert_nextButtonPressTime_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.nextButtonPressTime(); + } + + function test_nextButtonPressTime_AtStart() public { + // At emissions start (epoch 0) + cheats.warp(EMISSIONS_START_TIME); + assertEq(emissionsController.nextButtonPressTime(), EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH); + } + + function test_nextButtonPressTime_AfterMultipleEpochs() public { + // Warp to epoch 5 + cheats.warp(EMISSIONS_START_TIME + 5 * EMISSIONS_EPOCH_LENGTH); + assertEq(emissionsController.getCurrentEpoch(), 5); + assertEq(emissionsController.nextButtonPressTime(), EMISSIONS_START_TIME + 6 * EMISSIONS_EPOCH_LENGTH); + } + + function testFuzz_nextButtonPressTime_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.nextButtonPressTime(), EMISSIONS_START_TIME + (currentEpoch + 1) * 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, + stopEpoch: 1, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: "" + }) + ); + assertEq(emissionsController.getTotalDistributions(), 1); + + // Add second distribution + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 2000, + startEpoch: 0, + stopEpoch: 1, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: "" + }) + ); + assertEq(emissionsController.getTotalDistributions(), 2); + } + + function test_getTotalDistributions_AfterDisabling() public { + // Add distributions + cheats.prank(incentiveCouncil); + uint distributionId1 = emissionsController.addDistribution( + Distribution({ + weight: 3000, + startEpoch: 0, + stopEpoch: 1, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: "" + }) + ); + + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 2000, + startEpoch: 0, + stopEpoch: 1, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: "" + }) + ); + + assertEq(emissionsController.getTotalDistributions(), 2); + + // Disable one distribution - total count should not change + cheats.prank(incentiveCouncil); + emissionsController.disableDistribution(distributionId1); + 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, + stopEpoch: 1, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: "" + }) + ); + } + + 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, + stopEpoch: 10, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: "" + }) + ); + + // Retrieve and verify + Distribution memory retrieved = emissionsController.getDistribution(distributionId); + assertEq(retrieved.weight, 5000); + assertEq(retrieved.startEpoch, 0); + assertEq(retrieved.stopEpoch, 10); + assertEq(uint8(retrieved.distributionType), uint8(DistributionType.RewardsForAllEarners)); + } + + function test_getDistribution_MultipleDistributions() public { + // Add multiple distributions with different parameters + cheats.prank(incentiveCouncil); + uint distributionId0 = emissionsController.addDistribution( + Distribution({ + weight: 3000, + startEpoch: 0, + stopEpoch: 5, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: "" + }) + ); + + cheats.prank(incentiveCouncil); + uint distributionId1 = emissionsController.addDistribution( + Distribution({ + weight: 4000, + startEpoch: 1, + stopEpoch: 8, + distributionType: DistributionType.OperatorSetTotalStake, + rewardsCoordinatorCalldata: "" + }) + ); + + // Verify first distribution + Distribution memory retrieved0 = emissionsController.getDistribution(distributionId0); + assertEq(retrieved0.weight, 3000); + assertEq(retrieved0.startEpoch, 0); + assertEq(retrieved0.stopEpoch, 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.stopEpoch, 8); + assertEq(uint8(retrieved1.distributionType), uint8(DistributionType.OperatorSetTotalStake)); + } + + function test_getDistribution_AfterUpdate() public { + // Add a distribution + cheats.prank(incentiveCouncil); + uint distributionId = emissionsController.addDistribution( + Distribution({ + weight: 3000, + startEpoch: 0, + stopEpoch: 5, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: "" + }) + ); + + // Update the distribution (new weight 4000 is still within limits) + cheats.prank(incentiveCouncil); + emissionsController.updateDistribution( + distributionId, + Distribution({ + weight: 4000, + startEpoch: 2, + stopEpoch: 15, + distributionType: DistributionType.OperatorSetUniqueStake, + rewardsCoordinatorCalldata: "" + }) + ); + + // Verify updated values + Distribution memory retrieved = emissionsController.getDistribution(distributionId); + assertEq(retrieved.weight, 4000); + assertEq(retrieved.startEpoch, 2); + assertEq(retrieved.stopEpoch, 15); + assertEq(uint8(retrieved.distributionType), uint8(DistributionType.OperatorSetUniqueStake)); + } + + function test_getDistribution_AfterDisable() public { + // Add a distribution + cheats.prank(incentiveCouncil); + uint distributionId = emissionsController.addDistribution( + Distribution({ + weight: 5000, + startEpoch: 0, + stopEpoch: 10, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: "" + }) + ); + + // Disable the distribution + cheats.prank(incentiveCouncil); + emissionsController.disableDistribution(distributionId); + + // Verify it's disabled + Distribution memory retrieved = emissionsController.getDistribution(distributionId); + assertEq(uint8(retrieved.distributionType), uint8(DistributionType.Disabled)); + } +} + +contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUnitTests { + function test_revert_getDistributions_OutOfBounds() public { + // Add 2 distributions + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 3000, + startEpoch: 0, + stopEpoch: 5, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: "" + }) + ); + + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 2000, + startEpoch: 0, + stopEpoch: 5, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: "" + }) + ); + + // 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 { + // Add multiple distributions + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 3000, + startEpoch: 0, + stopEpoch: 5, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: "" + }) + ); + + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 4000, + startEpoch: 1, + stopEpoch: 8, + distributionType: DistributionType.OperatorSetTotalStake, + rewardsCoordinatorCalldata: "" + }) + ); + + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 2000, + startEpoch: 2, + stopEpoch: 10, + distributionType: DistributionType.OperatorSetUniqueStake, + rewardsCoordinatorCalldata: "" + }) + ); + + // 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: 100 * (i + 1), + startEpoch: 0, + stopEpoch: 5, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: "" + }) + ); + } + + // 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, + stopEpoch: 5, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: "" + }) + ); + + // Get 0 length + Distribution[] memory distributions = emissionsController.getDistributions(0, 0); + assertEq(distributions.length, 0); + } + + function test_getDistributions_SingleElement() public { + // Add multiple distributions + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 3000, + startEpoch: 0, + stopEpoch: 5, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: "" + }) + ); -contract EmissionsControllerUnitTests_nextButtonPressTime is EmissionsControllerUnitTests {} + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 4000, + startEpoch: 1, + stopEpoch: 8, + distributionType: DistributionType.OperatorSetTotalStake, + rewardsCoordinatorCalldata: "" + }) + ); -contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControllerUnitTests {} + // Get single element at index 1 + Distribution[] memory distributions = emissionsController.getDistributions(1, 1); -contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnitTests {} + 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: 100 * (i + 1), + startEpoch: 0, + stopEpoch: 5, + distributionType: DistributionType.RewardsForAllEarners, + rewardsCoordinatorCalldata: "" + }) + ); + } + + // Get subset + Distribution[] memory distributions = emissionsController.getDistributions(start, length); -contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUnitTests {} + assertEq(distributions.length, length); + + // Verify each element + for (uint i = 0; i < length; i++) { + assertEq(distributions[i].weight, 100 * (start + i + 1)); + } + } +} From cb98b0008ef0f6c4ed35c0d4bc349544a4c56260 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:47:01 -0500 Subject: [PATCH 24/62] rename --- src/contracts/core/EmissionsController.sol | 16 +-- .../interfaces/IEmissionsController.sol | 2 +- src/test/unit/EmissionsControllerUnit.t.sol | 99 ++++++++----------- 3 files changed, 53 insertions(+), 64 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 1436769091..9f9fb75345 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -101,20 +101,22 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont bool success; if (distribution.distributionType == DistributionType.RewardsForAllEarners) { success = _tryCallRewardsCoordinator( - IRewardsCoordinator.createRewardsForAllEarners.selector, distribution.rewardsCoordinatorCalldata + IRewardsCoordinator.createRewardsForAllEarners.selector, distribution.rewardsCalldata ); } else if (distribution.distributionType == DistributionType.OperatorSetUniqueStake) { success = _tryCallRewardsCoordinator( - IRewardsCoordinator.createUniqueStakeRewardsSubmission.selector, distribution.rewardsCoordinatorCalldata + IRewardsCoordinator.createUniqueStakeRewardsSubmission.selector, distribution.rewardsCalldata ); } else if (distribution.distributionType == DistributionType.OperatorSetTotalStake) { success = _tryCallRewardsCoordinator( - IRewardsCoordinator.createTotalStakeRewardsSubmission.selector, distribution.rewardsCoordinatorCalldata + IRewardsCoordinator.createTotalStakeRewardsSubmission.selector, distribution.rewardsCalldata ); } else if (distribution.distributionType == DistributionType.EigenDA) { // TODO: Implement this. } else if (distribution.distributionType == DistributionType.Manual) { - // TODO: Implement this. + // We use call to prevent further distributions from being processed if the mint fails. + (success,) = + address(BACKING_EIGEN).call(abi.encodeWithSelector(IBackingEigen.mint.selector, distribution.rewardsCalldata)); } else { revert InvalidDistributionType(); // Only reachable if the distribution type is `Disabled`. } @@ -124,13 +126,13 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont /// @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 selector The selector of the function to call. - /// @param rewardsCoordinatorCalldata The calldata for the function call. + /// @param rewardsCalldata The calldata for the function call. /// @return success True if the function call was successful, false otherwise. function _tryCallRewardsCoordinator( bytes4 selector, - bytes memory rewardsCoordinatorCalldata + bytes memory rewardsCalldata ) internal returns (bool success) { - (success,) = address(REWARDS_COORDINATOR).call(abi.encodeWithSelector(selector, rewardsCoordinatorCalldata)); + (success,) = address(REWARDS_COORDINATOR).call(abi.encodeWithSelector(selector, rewardsCalldata)); return success; } diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index ccac9406c1..a06e74d995 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -52,7 +52,7 @@ interface IEmissionsControllerTypes { /// The type of distribution. DistributionType distributionType; /// The calldata for the RewardsCoordinator function call. - bytes rewardsCoordinatorCalldata; + bytes rewardsCalldata; } } diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index dbbf352763..83f589ef9e 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -89,7 +89,7 @@ contract EmissionsControllerUnitTests_pressButton is EmissionsControllerUnitTest startEpoch: 0, stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: encodedSubmission + rewardsCalldata: encodedSubmission }) ); @@ -140,7 +140,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); } @@ -149,13 +149,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit cheats.prank(incentiveCouncil); cheats.expectRevert(IEmissionsControllerErrors.CannotAddDisabledDistribution.selector); emissionsController.addDistribution( - Distribution({ - weight: 10_000, - startEpoch: 0, - stopEpoch: 0, - distributionType: DistributionType.Disabled, - rewardsCoordinatorCalldata: "" - }) + Distribution({weight: 10_000, startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.Disabled, rewardsCalldata: ""}) ); } @@ -169,7 +163,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); } @@ -184,7 +178,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); } @@ -198,7 +192,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -211,7 +205,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); } @@ -224,7 +218,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit uint nextDistributionId = emissionsController.getTotalDistributions(); Distribution memory addedDistribution = - Distribution({weight: weight, startEpoch: 0, stopEpoch: 0, distributionType: distributionType, rewardsCoordinatorCalldata: ""}); + Distribution({weight: weight, startEpoch: 0, stopEpoch: 0, distributionType: distributionType, rewardsCalldata: ""}); cheats.expectEmit(true, true, true, true); emit DistributionAdded(nextDistributionId, type(uint).max, addedDistribution); @@ -252,7 +246,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); } @@ -261,14 +255,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU cheats.prank(incentiveCouncil); cheats.expectRevert(IEmissionsControllerErrors.CannotDisableDistributionViaUpdate.selector); emissionsController.updateDistribution( - 0, - Distribution({ - weight: 10_000, - startEpoch: 0, - stopEpoch: 0, - distributionType: DistributionType.Disabled, - rewardsCoordinatorCalldata: "" - }) + 0, Distribution({weight: 10_000, startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.Disabled, rewardsCalldata: ""}) ); } @@ -282,7 +269,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); } @@ -295,7 +282,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -311,7 +298,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); } @@ -324,7 +311,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -338,7 +325,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); } @@ -354,7 +341,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -366,7 +353,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -380,7 +367,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); } @@ -409,7 +396,7 @@ contract EmissionsControllerUnitTests_disableDistribution is EmissionsController startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); cheats.prank(incentiveCouncil); @@ -428,7 +415,7 @@ contract EmissionsControllerUnitTests_disableDistribution is EmissionsController startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -486,7 +473,7 @@ contract EmissionsControllerUnitTests_isButtonPressable is EmissionsControllerUn startEpoch: 0, stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -508,7 +495,7 @@ contract EmissionsControllerUnitTests_isButtonPressable is EmissionsControllerUn startEpoch: 0, stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: encodedSubmission + rewardsCalldata: encodedSubmission }) ); @@ -572,7 +559,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll startEpoch: 0, stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); assertEq(emissionsController.getTotalDistributions(), 1); @@ -585,7 +572,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll startEpoch: 0, stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); assertEq(emissionsController.getTotalDistributions(), 2); @@ -600,7 +587,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll startEpoch: 0, stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -611,7 +598,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll startEpoch: 0, stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -634,7 +621,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll startEpoch: 0, stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); } @@ -658,7 +645,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 10, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -679,7 +666,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -690,7 +677,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit startEpoch: 1, stopEpoch: 8, distributionType: DistributionType.OperatorSetTotalStake, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -718,7 +705,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -731,7 +718,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit startEpoch: 2, stopEpoch: 15, distributionType: DistributionType.OperatorSetUniqueStake, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -752,7 +739,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 10, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -776,7 +763,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -787,7 +774,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -808,7 +795,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -819,7 +806,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 1, stopEpoch: 8, distributionType: DistributionType.OperatorSetTotalStake, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -830,7 +817,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 2, stopEpoch: 10, distributionType: DistributionType.OperatorSetUniqueStake, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -853,7 +840,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); } @@ -876,7 +863,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -894,7 +881,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -905,7 +892,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 1, stopEpoch: 8, distributionType: DistributionType.OperatorSetTotalStake, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); @@ -936,7 +923,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCoordinatorCalldata: "" + rewardsCalldata: "" }) ); } From c7ffd46e1e697c34432f796f7505f073079b3ae8 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:56:54 -0500 Subject: [PATCH 25/62] fmt --- src/contracts/core/EmissionsController.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 9f9fb75345..e90c286f6f 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -115,8 +115,8 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont // TODO: Implement this. } else if (distribution.distributionType == DistributionType.Manual) { // We use call to prevent further distributions from being processed if the mint fails. - (success,) = - address(BACKING_EIGEN).call(abi.encodeWithSelector(IBackingEigen.mint.selector, distribution.rewardsCalldata)); + (success,) = address(BACKING_EIGEN) + .call(abi.encodeWithSelector(IBackingEigen.mint.selector, distribution.rewardsCalldata)); } else { revert InvalidDistributionType(); // Only reachable if the distribution type is `Disabled`. } From 1804c3e93e6f9880fbcf1d5bc38343e2a0c0528c Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Fri, 19 Dec 2025 17:01:48 -0500 Subject: [PATCH 26/62] todo --- src/contracts/core/EmissionsController.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index e90c286f6f..a4361741bf 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -112,7 +112,9 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont IRewardsCoordinator.createTotalStakeRewardsSubmission.selector, distribution.rewardsCalldata ); } else if (distribution.distributionType == DistributionType.EigenDA) { - // TODO: Implement this. + success = _tryCallRewardsCoordinator( + IRewardsCoordinator.createAVSRewardsSubmission.selector, distribution.rewardsCalldata + ); } else if (distribution.distributionType == DistributionType.Manual) { // We use call to prevent further distributions from being processed if the mint fails. (success,) = address(BACKING_EIGEN) From fba7fd11e9b15717f194e78f6eca6c4d59908969 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Fri, 19 Dec 2025 17:06:44 -0500 Subject: [PATCH 27/62] passing --- src/test/unit/EmissionsControllerUnit.t.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 83f589ef9e..fdd43165cf 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -112,6 +112,7 @@ contract EmissionsControllerUnitTests_pressButton is EmissionsControllerUnitTest 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); From 812cbefbc2b1fd20950071278bbe01246c8b526f Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Mon, 22 Dec 2025 12:13:25 -0500 Subject: [PATCH 28/62] wip --- src/contracts/core/EmissionsController.sol | 47 +++-- .../interfaces/IEmissionsController.sol | 17 +- src/test/unit/EmissionsControllerUnit.t.sol | 183 ++++++++++++------ 3 files changed, 168 insertions(+), 79 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index a4361741bf..122066986a 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -68,6 +68,7 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont uint256 totalAmount = EMISSIONS_INFLATION_RATE; + // TODO: Should we only mint if the supply is actually distributed? BACKING_EIGEN.mint(address(this), totalAmount); EIGEN.wrap(totalAmount); @@ -98,44 +99,57 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont uint256 currentEpoch, Distribution memory distribution ) internal { + // Update the start timestamp and duration of the rewards submissions. + IRewardsCoordinator.RewardsSubmission[] memory rewardsSubmissions = distribution.rewardsSubmissions; + for (uint256 i = 0; i < rewardsSubmissions.length; ++i) { + rewardsSubmissions[i].startTimestamp = uint32(lastTimeButtonPressable()); // Start of this epoch. + rewardsSubmissions[i].duration = uint32(EMISSIONS_EPOCH_LENGTH); // QUESTION: Should this be configurable? + } + + // Dispatch the `RewardsCoordinator` call based on the distribution type. bool success; if (distribution.distributionType == DistributionType.RewardsForAllEarners) { success = _tryCallRewardsCoordinator( - IRewardsCoordinator.createRewardsForAllEarners.selector, distribution.rewardsCalldata + abi.encodeCall(IRewardsCoordinator.createRewardsForAllEarners, (rewardsSubmissions)) ); } else if (distribution.distributionType == DistributionType.OperatorSetUniqueStake) { success = _tryCallRewardsCoordinator( - IRewardsCoordinator.createUniqueStakeRewardsSubmission.selector, distribution.rewardsCalldata + abi.encodeCall( + IRewardsCoordinator.createUniqueStakeRewardsSubmission, + (distribution.operatorSet, rewardsSubmissions) + ) ); } else if (distribution.distributionType == DistributionType.OperatorSetTotalStake) { success = _tryCallRewardsCoordinator( - IRewardsCoordinator.createTotalStakeRewardsSubmission.selector, distribution.rewardsCalldata + abi.encodeCall( + IRewardsCoordinator.createTotalStakeRewardsSubmission, + (distribution.operatorSet, rewardsSubmissions) + ) ); } else if (distribution.distributionType == DistributionType.EigenDA) { success = _tryCallRewardsCoordinator( - IRewardsCoordinator.createAVSRewardsSubmission.selector, distribution.rewardsCalldata + abi.encodeCall(IRewardsCoordinator.createAVSRewardsSubmission, (rewardsSubmissions)) ); } else if (distribution.distributionType == DistributionType.Manual) { // We use call to prevent further distributions from being processed if the mint fails. - (success,) = address(BACKING_EIGEN) - .call(abi.encodeWithSelector(IBackingEigen.mint.selector, distribution.rewardsCalldata)); + (success,) = + address(BACKING_EIGEN).call(abi.encodeWithSelector(IBackingEigen.mint.selector, rewardsSubmissions)); } else { revert InvalidDistributionType(); // Only reachable if the distribution type is `Disabled`. } + + // 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 selector The selector of the function to call. - /// @param rewardsCalldata The calldata for the function call. + /// @param abiEncodedCall The ABI encoded call to the RewardsCoordinator. /// @return success True if the function call was successful, false otherwise. function _tryCallRewardsCoordinator( - bytes4 selector, - bytes memory rewardsCalldata + bytes memory abiEncodedCall ) internal returns (bool success) { - (success,) = address(REWARDS_COORDINATOR).call(abi.encodeWithSelector(selector, rewardsCalldata)); - return success; + (success,) = address(REWARDS_COORDINATOR).call(abiEncodedCall); } /// ----------------------------------------------------------------------- @@ -175,6 +189,8 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont if (distribution.distributionType == DistributionType.Disabled) { revert CannotAddDisabledDistribution(); } + + // operatorSet only non-zero if distribution type is OperatorSetTotalStake or OperatorSetUniqueStake. // Asserts the following: // - The start epoch is in the future. // - The total weight of all distributions does not exceed the max total weight. @@ -284,10 +300,15 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont } /// @inheritdoc IEmissionsController - function nextButtonPressTime() external view returns (uint256) { + 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) { return _distributions.length; diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index a06e74d995..8378c69284 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -51,8 +51,11 @@ interface IEmissionsControllerTypes { uint256 stopEpoch; /// The type of distribution. DistributionType distributionType; - /// The calldata for the RewardsCoordinator function call. - bytes rewardsCalldata; + + /// The operator set (Required depending on the distribution type). // TODO: more context + OperatorSet operatorSet; + /// The rewards submissions. + IRewardsCoordinator.RewardsSubmission[] rewardsSubmissions; } } @@ -217,9 +220,13 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// @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 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. diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index fdd43165cf..e131641a29 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -37,6 +37,14 @@ contract EmissionsControllerUnitTests is EigenLayerUnitTestSetup, IEmissionsCont ) ); } + + function emptyOperatorSet() public pure returns (OperatorSet memory) { + return OperatorSet({avs: address(0), id: 0}); + } + + function emptyRewardsSubmissions() public pure returns (IRewardsCoordinatorTypes.RewardsSubmission[] memory) { + return new IRewardsCoordinatorTypes.RewardsSubmission[](0); + } } /// ----------------------------------------------------------------------- @@ -77,10 +85,6 @@ contract EmissionsControllerUnitTests_pressButton is EmissionsControllerUnitTest } function test_revert_pressButton_AllDistributionsProcessed() public { - // Encode an empty array of RewardsSubmissions - IRewardsCoordinatorTypes.RewardsSubmission[] memory emptySubmissions = new IRewardsCoordinatorTypes.RewardsSubmission[](0); - bytes memory encodedSubmission = abi.encode(emptySubmissions); - // Add a distribution first (before emissions start, so startEpoch 0 is in the future) cheats.prank(incentiveCouncil); emissionsController.addDistribution( @@ -89,7 +93,8 @@ contract EmissionsControllerUnitTests_pressButton is EmissionsControllerUnitTest startEpoch: 0, stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: encodedSubmission + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -141,7 +146,8 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); } @@ -150,7 +156,14 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit cheats.prank(incentiveCouncil); cheats.expectRevert(IEmissionsControllerErrors.CannotAddDisabledDistribution.selector); emissionsController.addDistribution( - Distribution({weight: 10_000, startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.Disabled, rewardsCalldata: ""}) + Distribution({ + weight: 10_000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.Disabled, + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() + }) ); } @@ -164,7 +177,8 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); } @@ -179,7 +193,8 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); } @@ -193,7 +208,8 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -206,7 +222,8 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); } @@ -218,8 +235,14 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit ); uint nextDistributionId = emissionsController.getTotalDistributions(); - Distribution memory addedDistribution = - Distribution({weight: weight, startEpoch: 0, stopEpoch: 0, distributionType: distributionType, rewardsCalldata: ""}); + Distribution memory addedDistribution = Distribution({ + weight: weight, + startEpoch: 0, + stopEpoch: 0, + distributionType: distributionType, + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() + }); cheats.expectEmit(true, true, true, true); emit DistributionAdded(nextDistributionId, type(uint).max, addedDistribution); @@ -247,7 +270,8 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); } @@ -256,7 +280,15 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU cheats.prank(incentiveCouncil); cheats.expectRevert(IEmissionsControllerErrors.CannotDisableDistributionViaUpdate.selector); emissionsController.updateDistribution( - 0, Distribution({weight: 10_000, startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.Disabled, rewardsCalldata: ""}) + 0, + Distribution({ + weight: 10_000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.Disabled, + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() + }) ); } @@ -270,7 +302,8 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); } @@ -283,7 +316,8 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -299,7 +333,8 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); } @@ -312,7 +347,8 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -326,7 +362,8 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); } @@ -342,7 +379,8 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -354,7 +392,8 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -368,7 +407,8 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); } @@ -397,7 +437,8 @@ contract EmissionsControllerUnitTests_disableDistribution is EmissionsController startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); cheats.prank(incentiveCouncil); @@ -416,7 +457,8 @@ contract EmissionsControllerUnitTests_disableDistribution is EmissionsController startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -474,7 +516,8 @@ contract EmissionsControllerUnitTests_isButtonPressable is EmissionsControllerUn startEpoch: 0, stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -484,10 +527,6 @@ contract EmissionsControllerUnitTests_isButtonPressable is EmissionsControllerUn } function test_isButtonPressable_AfterProcessing() public { - // Encode an empty array of RewardsSubmissions - IRewardsCoordinatorTypes.RewardsSubmission[] memory emptySubmissions = new IRewardsCoordinatorTypes.RewardsSubmission[](0); - bytes memory encodedSubmission = abi.encode(emptySubmissions); - // Add a distribution cheats.prank(incentiveCouncil); emissionsController.addDistribution( @@ -496,7 +535,8 @@ contract EmissionsControllerUnitTests_isButtonPressable is EmissionsControllerUn startEpoch: 0, stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: encodedSubmission + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -512,28 +552,28 @@ contract EmissionsControllerUnitTests_isButtonPressable is EmissionsControllerUn } } -contract EmissionsControllerUnitTests_nextButtonPressTime is EmissionsControllerUnitTests { - function test_revert_nextButtonPressTime_BeforeStart() public { +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.nextButtonPressTime(); + emissionsController.nextTimeButtonPressable(); } - function test_nextButtonPressTime_AtStart() public { + function test_nextTimeButtonPressable_AtStart() public { // At emissions start (epoch 0) cheats.warp(EMISSIONS_START_TIME); - assertEq(emissionsController.nextButtonPressTime(), EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH); + assertEq(emissionsController.nextTimeButtonPressable(), EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH); } - function test_nextButtonPressTime_AfterMultipleEpochs() public { + function test_nextTimeButtonPressable_AfterMultipleEpochs() public { // Warp to epoch 5 cheats.warp(EMISSIONS_START_TIME + 5 * EMISSIONS_EPOCH_LENGTH); assertEq(emissionsController.getCurrentEpoch(), 5); - assertEq(emissionsController.nextButtonPressTime(), EMISSIONS_START_TIME + 6 * EMISSIONS_EPOCH_LENGTH); + assertEq(emissionsController.nextTimeButtonPressable(), EMISSIONS_START_TIME + 6 * EMISSIONS_EPOCH_LENGTH); } - function testFuzz_nextButtonPressTime_Correctness(uint numEpochs) public { + function testFuzz_nextTimeButtonPressable_Correctness(uint numEpochs) public { numEpochs = bound(numEpochs, 0, 1000); // Warp to arbitrary epoch @@ -542,7 +582,7 @@ contract EmissionsControllerUnitTests_nextButtonPressTime is EmissionsController assertEq(currentEpoch, numEpochs); // Next button press time should be start of next epoch - assertEq(emissionsController.nextButtonPressTime(), EMISSIONS_START_TIME + (currentEpoch + 1) * EMISSIONS_EPOCH_LENGTH); + assertEq(emissionsController.nextTimeButtonPressable(), EMISSIONS_START_TIME + (currentEpoch + 1) * EMISSIONS_EPOCH_LENGTH); } } @@ -560,7 +600,8 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll startEpoch: 0, stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); assertEq(emissionsController.getTotalDistributions(), 1); @@ -573,7 +614,8 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll startEpoch: 0, stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); assertEq(emissionsController.getTotalDistributions(), 2); @@ -588,7 +630,8 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll startEpoch: 0, stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -599,7 +642,8 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll startEpoch: 0, stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -622,7 +666,8 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll startEpoch: 0, stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); } @@ -646,7 +691,8 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 10, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -667,7 +713,8 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -678,7 +725,8 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit startEpoch: 1, stopEpoch: 8, distributionType: DistributionType.OperatorSetTotalStake, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -706,7 +754,8 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -719,7 +768,8 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit startEpoch: 2, stopEpoch: 15, distributionType: DistributionType.OperatorSetUniqueStake, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -740,7 +790,8 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit startEpoch: 0, stopEpoch: 10, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -764,7 +815,8 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -775,7 +827,8 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -796,7 +849,8 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -807,7 +861,8 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 1, stopEpoch: 8, distributionType: DistributionType.OperatorSetTotalStake, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -818,7 +873,8 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 2, stopEpoch: 10, distributionType: DistributionType.OperatorSetUniqueStake, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -841,7 +897,8 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); } @@ -864,7 +921,8 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -882,7 +940,8 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -893,7 +952,8 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 1, stopEpoch: 8, distributionType: DistributionType.OperatorSetTotalStake, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -924,7 +984,8 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, - rewardsCalldata: "" + operatorSet: emptyOperatorSet(), + rewardsSubmissions: emptyRewardsSubmissions() }) ); } From fa6de9da8cc12fa15bb9592f1646a764986e6fd2 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Mon, 22 Dec 2025 12:40:36 -0500 Subject: [PATCH 29/62] wip --- src/contracts/core/EmissionsController.sol | 28 +++- .../interfaces/IEmissionsController.sol | 2 + src/test/mocks/EigenMock.sol | 12 +- src/test/unit/EmissionsControllerUnit.t.sol | 142 +++++++++++++----- src/test/utils/EigenLayerUnitTestSetup.sol | 2 +- 5 files changed, 133 insertions(+), 53 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 122066986a..a35aaae834 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -12,8 +12,7 @@ import "./storage/EmissionsControllerStorage.sol"; contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsControllerStorage { /// @dev Modifier that checks if the caller is the incentive council. modifier onlyIncentiveCouncil() { - // Check if the caller is the incentive council. - require(msg.sender == incentiveCouncil, CallerIsNotIncentiveCouncil()); + _checkOnlyIncentiveCouncil(); _; } @@ -68,7 +67,8 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont uint256 totalAmount = EMISSIONS_INFLATION_RATE; - // TODO: Should we only mint if the supply is actually distributed? + // QUESTION: Should we only mint if the supply is actually distributed? + // QUESTION: If not, what do we do with excess supply? BACKING_EIGEN.mint(address(this), totalAmount); EIGEN.wrap(totalAmount); @@ -99,11 +99,18 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont uint256 currentEpoch, Distribution memory distribution ) internal { - // Update the start timestamp and duration of the rewards submissions. + // Calculate the total amount of emissions for the distribution. + uint256 totalAmount = EMISSIONS_INFLATION_RATE * distribution.weight / MAX_TOTAL_WEIGHT; + // Calculate the amount per submission (total amount divided by the number of submissions). + uint256 amountPerSubmission = totalAmount / distribution.rewardsSubmissions.length; + + // Update the rewards submissions start timestamp, duration, and amount. IRewardsCoordinator.RewardsSubmission[] memory rewardsSubmissions = distribution.rewardsSubmissions; for (uint256 i = 0; i < rewardsSubmissions.length; ++i) { - rewardsSubmissions[i].startTimestamp = uint32(lastTimeButtonPressable()); // Start of this epoch. + uint256 startTimestamp = EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH * currentEpoch; + rewardsSubmissions[i].startTimestamp = uint32(startTimestamp); // Start of this epoch. rewardsSubmissions[i].duration = uint32(EMISSIONS_EPOCH_LENGTH); // QUESTION: Should this be configurable? + rewardsSubmissions[i].amount = amountPerSubmission; // QUESTION: Should multiple rewards submissions be allowed? If not, each submission will have a constant amount. } // Dispatch the `RewardsCoordinator` call based on the distribution type. @@ -256,6 +263,11 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont /// Internal Helpers /// ----------------------------------------------------------------------- + function _checkOnlyIncentiveCouncil() internal view { + // Check if the caller is the incentive council. + if (msg.sender != incentiveCouncil) revert CallerIsNotIncentiveCouncil(); + } + function _checkDisabled( Distribution storage distribution ) internal view { @@ -280,6 +292,12 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont if (distribution.weight + totalWeight > MAX_TOTAL_WEIGHT) { revert TotalWeightExceedsMax(); } + + // Check if rewards submissions array is empty for non-Manual distributions. + // Manual distributions handle rewards differently and don't require submissions. + if (distribution.distributionType != DistributionType.Manual && distribution.rewardsSubmissions.length == 0) { + revert RewardsSubmissionsCannotBeEmpty(); + } } /// ----------------------------------------------------------------------- diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index 8378c69284..291f0666cf 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -24,6 +24,8 @@ interface IEmissionsControllerErrors { error AllDistributionsProcessed(); /// @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(); } /// @title IEmissionsControllerTypes diff --git a/src/test/mocks/EigenMock.sol b/src/test/mocks/EigenMock.sol index 865bd0aa7a..0af75641c9 100644 --- a/src/test/mocks/EigenMock.sol +++ b/src/test/mocks/EigenMock.sol @@ -3,17 +3,17 @@ 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 { - constructor() ERC20("EIGEN", "EIGEN") {} + BackingEigenMock backingEigen; - function mint(address to, uint amount) external { - _mint(to, amount); + constructor(BackingEigenMock _backingEigen) ERC20("EIGEN", "EIGEN") { + backingEigen = _backingEigen; } function wrap(uint amount) external { - // Mock implementation - in real contract this would wrap bEIGEN to EIGEN - // For testing purposes, we just emit an event or do nothing + backingEigen.transferFrom(msg.sender, address(this), amount); + _mint(msg.sender, amount); } } - diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index e131641a29..ae22016ffb 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -45,6 +45,18 @@ contract EmissionsControllerUnitTests is EigenLayerUnitTestSetup, IEmissionsCont function emptyRewardsSubmissions() public pure returns (IRewardsCoordinatorTypes.RewardsSubmission[] memory) { return new IRewardsCoordinatorTypes.RewardsSubmission[](0); } + + function defaultRewardsSubmission() public pure returns (IRewardsCoordinatorTypes.RewardsSubmission[] memory) { + IRewardsCoordinatorTypes.RewardsSubmission[] memory submissions = new IRewardsCoordinatorTypes.RewardsSubmission[](1); + submissions[0] = IRewardsCoordinatorTypes.RewardsSubmission({ + strategiesAndMultipliers: new IRewardsCoordinatorTypes.StrategyAndMultiplier[](0), + token: IERC20(address(0)), + amount: 0, + startTimestamp: 0, + duration: 0 + }); + return submissions; + } } /// ----------------------------------------------------------------------- @@ -94,7 +106,7 @@ contract EmissionsControllerUnitTests_pressButton is EmissionsControllerUnitTest stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -147,7 +159,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); } @@ -162,6 +174,21 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.Disabled, operatorSet: emptyOperatorSet(), + rewardsSubmissions: defaultRewardsSubmission() + }) + ); + } + + function test_revert_addDistribution_RewardsSubmissionsCannotBeEmpty() public { + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.RewardsSubmissionsCannotBeEmpty.selector); + emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -178,7 +205,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); } @@ -194,7 +221,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); } @@ -209,7 +236,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -223,7 +250,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); } @@ -235,13 +262,18 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit ); uint nextDistributionId = emissionsController.getTotalDistributions(); + + // Use defaultRewardsSubmission for non-Manual types, empty for Manual + IRewardsCoordinatorTypes.RewardsSubmission[] memory rewardsSubmissions = + distributionType == DistributionType.Manual ? emptyRewardsSubmissions() : defaultRewardsSubmission(); + Distribution memory addedDistribution = Distribution({ weight: weight, startEpoch: 0, stopEpoch: 0, distributionType: distributionType, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: rewardsSubmissions }); cheats.expectEmit(true, true, true, true); @@ -271,7 +303,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); } @@ -287,7 +319,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.Disabled, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); } @@ -303,7 +335,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); } @@ -317,7 +349,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -334,7 +366,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); } @@ -348,7 +380,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -363,6 +395,34 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), + rewardsSubmissions: defaultRewardsSubmission() + }) + ); + } + + function test_revert_updateDistribution_RewardsSubmissionsCannotBeEmpty() public { + cheats.prank(incentiveCouncil); + uint distributionId = emissionsController.addDistribution( + Distribution({ + weight: 5000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + rewardsSubmissions: defaultRewardsSubmission() + }) + ); + + cheats.prank(incentiveCouncil); + cheats.expectRevert(IEmissionsControllerErrors.RewardsSubmissionsCannotBeEmpty.selector); + emissionsController.updateDistribution( + distributionId, + Distribution({ + weight: 5000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), rewardsSubmissions: emptyRewardsSubmissions() }) ); @@ -380,7 +440,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -393,7 +453,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -408,7 +468,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); } @@ -438,7 +498,7 @@ contract EmissionsControllerUnitTests_disableDistribution is EmissionsController stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); cheats.prank(incentiveCouncil); @@ -458,7 +518,7 @@ contract EmissionsControllerUnitTests_disableDistribution is EmissionsController stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -517,7 +577,7 @@ contract EmissionsControllerUnitTests_isButtonPressable is EmissionsControllerUn stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -536,7 +596,7 @@ contract EmissionsControllerUnitTests_isButtonPressable is EmissionsControllerUn stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -601,7 +661,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); assertEq(emissionsController.getTotalDistributions(), 1); @@ -615,7 +675,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); assertEq(emissionsController.getTotalDistributions(), 2); @@ -631,7 +691,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -643,7 +703,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -667,7 +727,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); } @@ -692,7 +752,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 10, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -714,7 +774,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -726,7 +786,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 8, distributionType: DistributionType.OperatorSetTotalStake, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -755,7 +815,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -769,7 +829,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 15, distributionType: DistributionType.OperatorSetUniqueStake, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -791,7 +851,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 10, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -816,7 +876,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -828,7 +888,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -850,7 +910,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -862,7 +922,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 8, distributionType: DistributionType.OperatorSetTotalStake, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -874,7 +934,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 10, distributionType: DistributionType.OperatorSetUniqueStake, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -898,7 +958,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); } @@ -922,7 +982,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -941,7 +1001,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -953,7 +1013,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 8, distributionType: DistributionType.OperatorSetTotalStake, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); @@ -985,7 +1045,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + rewardsSubmissions: defaultRewardsSubmission() }) ); } diff --git a/src/test/utils/EigenLayerUnitTestSetup.sol b/src/test/utils/EigenLayerUnitTestSetup.sol index 3114d23bf0..069af29ccd 100644 --- a/src/test/utils/EigenLayerUnitTestSetup.sol +++ b/src/test/utils/EigenLayerUnitTestSetup.sol @@ -87,8 +87,8 @@ 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)))); - eigenMock = EigenMock(payable(address(new EigenMock()))); backingEigenMock = BackingEigenMock(payable(address(new BackingEigenMock()))); + eigenMock = EigenMock(payable(address(new EigenMock(backingEigenMock)))); rewardsCoordinatorMock = RewardsCoordinatorMock(payable(address(new RewardsCoordinatorMock()))); emptyContract = new EmptyContract(); From 9ebc04b429335c7768562b60c3ebcdd4e7f31510 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:01:32 -0500 Subject: [PATCH 30/62] wip --- src/contracts/core/EmissionsController.sol | 31 +++-- .../interfaces/IEmissionsController.sol | 77 ++++++++---- src/test/unit/EmissionsControllerUnit.t.sol | 114 +++++++++--------- 3 files changed, 128 insertions(+), 94 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index a35aaae834..baa361a713 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -65,12 +65,13 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont revert AllDistributionsProcessed(); } - uint256 totalAmount = EMISSIONS_INFLATION_RATE; + // Calculate the start timestamp for the distribution (equivalent to `lastTimeButtonPressable()`). + uint256 startTimestamp = EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH * currentEpoch; // QUESTION: Should we only mint if the supply is actually distributed? // QUESTION: If not, what do we do with excess supply? - BACKING_EIGEN.mint(address(this), totalAmount); - EIGEN.wrap(totalAmount); + BACKING_EIGEN.mint(address(this), EMISSIONS_INFLATION_RATE); + EIGEN.wrap(EMISSIONS_INFLATION_RATE); // Process distributions starting from the next one to process for (uint256 i = nextDistributionId; i < length; ++i) { @@ -83,7 +84,7 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont // Skip distributions that have ended... if (distribution.stopEpoch <= currentEpoch) continue; - _processDistribution(i, currentEpoch, distribution); + _processDistribution(i, currentEpoch, startTimestamp, distribution); } // Update total processed count for this epoch (equals next index to process) @@ -97,20 +98,25 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont function _processDistribution( uint256 distributionId, uint256 currentEpoch, + uint256 startTimestamp, Distribution memory distribution ) internal { // Calculate the total amount of emissions for the distribution. uint256 totalAmount = EMISSIONS_INFLATION_RATE * distribution.weight / MAX_TOTAL_WEIGHT; // Calculate the amount per submission (total amount divided by the number of submissions). - uint256 amountPerSubmission = totalAmount / distribution.rewardsSubmissions.length; + uint256 amountPerSubmission = totalAmount / distribution.partialRewardsSubmissions.length; // Update the rewards submissions start timestamp, duration, and amount. - IRewardsCoordinator.RewardsSubmission[] memory rewardsSubmissions = distribution.rewardsSubmissions; + IRewardsCoordinator.RewardsSubmission[] memory rewardsSubmissions = + new IRewardsCoordinator.RewardsSubmission[](distribution.partialRewardsSubmissions.length); for (uint256 i = 0; i < rewardsSubmissions.length; ++i) { - uint256 startTimestamp = EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH * currentEpoch; - rewardsSubmissions[i].startTimestamp = uint32(startTimestamp); // Start of this epoch. - rewardsSubmissions[i].duration = uint32(EMISSIONS_EPOCH_LENGTH); // QUESTION: Should this be configurable? - rewardsSubmissions[i].amount = amountPerSubmission; // QUESTION: Should multiple rewards submissions be allowed? If not, each submission will have a constant amount. + rewardsSubmissions[i] = IRewardsCoordinatorTypes.RewardsSubmission({ + strategiesAndMultipliers: distribution.partialRewardsSubmissions[i].strategiesAndMultipliers, + token: distribution.partialRewardsSubmissions[i].token, + amount: amountPerSubmission, + startTimestamp: uint32(startTimestamp), // QUESTION: Should this be configurable? + duration: uint32(EMISSIONS_EPOCH_LENGTH) // QUESTION: Should multiple rewards submissions be allowed? If not, each submission will have a constant amount. + }); } // Dispatch the `RewardsCoordinator` call based on the distribution type. @@ -295,7 +301,10 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont // Check if rewards submissions array is empty for non-Manual distributions. // Manual distributions handle rewards differently and don't require submissions. - if (distribution.distributionType != DistributionType.Manual && distribution.rewardsSubmissions.length == 0) { + if ( + distribution.distributionType != DistributionType.Manual + && distribution.partialRewardsSubmissions.length == 0 + ) { revert RewardsSubmissionsCannotBeEmpty(); } } diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index 291f0666cf..dc2594d6b1 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -31,8 +31,14 @@ interface IEmissionsControllerErrors { /// @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, @@ -42,8 +48,10 @@ 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 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; @@ -53,11 +61,16 @@ interface IEmissionsControllerTypes { uint256 stopEpoch; /// The type of distribution. DistributionType distributionType; - /// The operator set (Required depending on the distribution type). // TODO: more context OperatorSet operatorSet; /// The rewards submissions. - IRewardsCoordinator.RewardsSubmission[] rewardsSubmissions; + PartialRewardsSubmission[] partialRewardsSubmissions; + } + + // TODO: natspec + struct PartialRewardsSubmission { + IRewardsCoordinator.StrategyAndMultiplier[] strategiesAndMultipliers; + IERC20 token; } } @@ -98,8 +111,10 @@ interface IEmissionsControllerEvents is IEmissionsControllerTypes { } /// @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." +/// @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 { /// ----------------------------------------------------------------------- /// Constants @@ -148,12 +163,14 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// 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 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. - /// @param length The number of distributions to process. + /// @param length The number of distributions to process (upper bound for the loop). function pressButton( uint256 length ) external; @@ -163,8 +180,10 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// ----------------------------------------------------------------------- /// @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 @@ -174,30 +193,38 @@ 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. + /// Cannot update distributions that are already disabled. + /// Cannot use this function to disable a distribution (must use disableDistribution). /// @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. + /// @notice Disables a distribution permanently. + /// @dev The distribution remains in storage but is marked as Disabled and skipped during processing. + /// Only the Incentive Council can call this function. + /// The distribution's weight is subtracted from totalWeight, freeing up allocation capacity. + /// This action is permanent - disabled distributions cannot be re-enabled or updated. + /// The distribution will be skipped in all future epochs regardless of startEpoch/stopEpoch. + /// @param distributionId The id of the distribution to disable. function disableDistribution( uint256 distributionId ) external; diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index ae22016ffb..5c7ec47dc2 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -42,18 +42,16 @@ contract EmissionsControllerUnitTests is EigenLayerUnitTestSetup, IEmissionsCont return OperatorSet({avs: address(0), id: 0}); } - function emptyRewardsSubmissions() public pure returns (IRewardsCoordinatorTypes.RewardsSubmission[] memory) { - return new IRewardsCoordinatorTypes.RewardsSubmission[](0); + function emptySubmission() public pure returns (IEmissionsControllerTypes.PartialRewardsSubmission[] memory) { + return new IEmissionsControllerTypes.PartialRewardsSubmission[](0); } - function defaultRewardsSubmission() public pure returns (IRewardsCoordinatorTypes.RewardsSubmission[] memory) { - IRewardsCoordinatorTypes.RewardsSubmission[] memory submissions = new IRewardsCoordinatorTypes.RewardsSubmission[](1); - submissions[0] = IRewardsCoordinatorTypes.RewardsSubmission({ + function defaultSubmission() public pure returns (IEmissionsControllerTypes.PartialRewardsSubmission[] memory) { + IEmissionsControllerTypes.PartialRewardsSubmission[] memory submissions = + new IEmissionsControllerTypes.PartialRewardsSubmission[](1); + submissions[0] = IEmissionsControllerTypes.PartialRewardsSubmission({ strategiesAndMultipliers: new IRewardsCoordinatorTypes.StrategyAndMultiplier[](0), - token: IERC20(address(0)), - amount: 0, - startTimestamp: 0, - duration: 0 + token: IERC20(address(0)) }); return submissions; } @@ -106,7 +104,7 @@ contract EmissionsControllerUnitTests_pressButton is EmissionsControllerUnitTest stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -159,7 +157,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); } @@ -174,7 +172,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.Disabled, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); } @@ -189,7 +187,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + partialRewardsSubmissions: emptySubmission() }) ); } @@ -205,7 +203,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); } @@ -221,7 +219,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); } @@ -236,7 +234,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -250,7 +248,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); } @@ -263,9 +261,9 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit uint nextDistributionId = emissionsController.getTotalDistributions(); - // Use defaultRewardsSubmission for non-Manual types, empty for Manual - IRewardsCoordinatorTypes.RewardsSubmission[] memory rewardsSubmissions = - distributionType == DistributionType.Manual ? emptyRewardsSubmissions() : defaultRewardsSubmission(); + // Use defaultSubmission for non-Manual types, empty for Manual + IEmissionsControllerTypes.PartialRewardsSubmission[] memory partialRewardsSubmissions = + distributionType == DistributionType.Manual ? emptySubmission() : defaultSubmission(); Distribution memory addedDistribution = Distribution({ weight: weight, @@ -273,7 +271,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: distributionType, operatorSet: emptyOperatorSet(), - rewardsSubmissions: rewardsSubmissions + partialRewardsSubmissions: partialRewardsSubmissions }); cheats.expectEmit(true, true, true, true); @@ -303,7 +301,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); } @@ -319,7 +317,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.Disabled, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); } @@ -335,7 +333,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); } @@ -349,7 +347,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -366,7 +364,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); } @@ -380,7 +378,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -395,7 +393,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); } @@ -409,7 +407,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -423,7 +421,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: emptyRewardsSubmissions() + partialRewardsSubmissions: emptySubmission() }) ); } @@ -440,7 +438,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -453,7 +451,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -468,7 +466,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); } @@ -498,7 +496,7 @@ contract EmissionsControllerUnitTests_disableDistribution is EmissionsController stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); cheats.prank(incentiveCouncil); @@ -518,7 +516,7 @@ contract EmissionsControllerUnitTests_disableDistribution is EmissionsController stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -577,7 +575,7 @@ contract EmissionsControllerUnitTests_isButtonPressable is EmissionsControllerUn stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -596,7 +594,7 @@ contract EmissionsControllerUnitTests_isButtonPressable is EmissionsControllerUn stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -661,7 +659,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); assertEq(emissionsController.getTotalDistributions(), 1); @@ -675,7 +673,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); assertEq(emissionsController.getTotalDistributions(), 2); @@ -691,7 +689,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -703,7 +701,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -727,7 +725,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); } @@ -752,7 +750,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 10, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -774,7 +772,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -786,7 +784,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 8, distributionType: DistributionType.OperatorSetTotalStake, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -815,7 +813,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -829,7 +827,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 15, distributionType: DistributionType.OperatorSetUniqueStake, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -851,7 +849,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 10, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -876,7 +874,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -888,7 +886,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -910,7 +908,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -922,7 +920,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 8, distributionType: DistributionType.OperatorSetTotalStake, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -934,7 +932,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 10, distributionType: DistributionType.OperatorSetUniqueStake, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -958,7 +956,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); } @@ -982,7 +980,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -1001,7 +999,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -1013,7 +1011,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 8, distributionType: DistributionType.OperatorSetTotalStake, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); @@ -1045,7 +1043,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - rewardsSubmissions: defaultRewardsSubmission() + partialRewardsSubmissions: defaultSubmission() }) ); } From cbc9bfa76834ec424cf46e394e17989d632595dc Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:06:55 -0500 Subject: [PATCH 31/62] docs --- src/contracts/interfaces/IEmissionsController.sol | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index dc2594d6b1..419a8f3bad 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -61,15 +61,20 @@ interface IEmissionsControllerTypes { uint256 stopEpoch; /// The type of distribution. DistributionType distributionType; - /// The operator set (Required depending on the distribution type). // TODO: more context + /// The operator set (Required only for OperatorSetTotalStake and OperatorSetUniqueStake distribution types). OperatorSet operatorSet; /// The rewards submissions. PartialRewardsSubmission[] partialRewardsSubmissions; } - // TODO: natspec + /// @notice A partial rewards submission that will be converted to a full RewardsSubmission during processing. + /// @dev This struct contains the static parameters for a rewards submission (strategies and token). + /// During epoch processing, these are augmented with dynamic parameters (amount, startTimestamp, duration) + /// to create complete RewardsSubmission objects for the RewardsCoordinator. struct PartialRewardsSubmission { + /// The strategies and their respective multipliers for distributing rewards. IRewardsCoordinator.StrategyAndMultiplier[] strategiesAndMultipliers; + /// The ERC20 token to be distributed as rewards. IERC20 token; } } From 51a61798a8615557b3333cd1e88bf367ec3bc648 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:33:06 -0500 Subject: [PATCH 32/62] wip --- src/contracts/core/EmissionsController.sol | 29 ++++++++++++------- .../storage/EmissionsControllerStorage.sol | 4 +-- .../interfaces/IEmissionsController.sol | 8 +++++ 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index baa361a713..b7e4d14152 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -58,21 +58,27 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont ) external override { uint256 currentEpoch = getCurrentEpoch(); uint256 totalDistributions = getTotalDistributions(); - uint256 nextDistributionId = _totalProcessed[currentEpoch]; + uint256 nextDistributionId = _epochs[currentEpoch].totalProcessed; // Check if all distributions have already been processed. if (nextDistributionId >= totalDistributions) { revert AllDistributionsProcessed(); } + // Mint the total amount of bEIGEN/EIGEN needed for all distributions. + if (!_epochs[currentEpoch].minted) { + // 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; - // QUESTION: Should we only mint if the supply is actually distributed? - // QUESTION: If not, what do we do with excess supply? - BACKING_EIGEN.mint(address(this), EMISSIONS_INFLATION_RATE); - EIGEN.wrap(EMISSIONS_INFLATION_RATE); - // Process distributions starting from the next one to process for (uint256 i = nextDistributionId; i < length; ++i) { Distribution memory distribution = _distributions[i]; @@ -87,8 +93,10 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont _processDistribution(i, currentEpoch, startTimestamp, distribution); } + // QUESTION: Should be burn excess supply that wasn't distributed (e.g. if the distribution reverts)? + // Update total processed count for this epoch (equals next index to process) - _totalProcessed[currentEpoch] = length; + _epochs[currentEpoch].totalProcessed = uint248(length); } /// @dev Internal helper that processes a distribution. @@ -114,8 +122,8 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont strategiesAndMultipliers: distribution.partialRewardsSubmissions[i].strategiesAndMultipliers, token: distribution.partialRewardsSubmissions[i].token, amount: amountPerSubmission, - startTimestamp: uint32(startTimestamp), // QUESTION: Should this be configurable? - duration: uint32(EMISSIONS_EPOCH_LENGTH) // QUESTION: Should multiple rewards submissions be allowed? If not, each submission will have a constant amount. + startTimestamp: uint32(startTimestamp), + duration: uint32(EMISSIONS_EPOCH_LENGTH) // QUESTION: Should this be configurable? }); } @@ -210,6 +218,7 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont _checkDistribution(distribution, currentEpoch); // Get the total number of distributions (also next available distribution id). distributionId = getTotalDistributions(); + // Append the distribution to the distributions array. _distributions.push(distribution); // Update the total weight. @@ -323,7 +332,7 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont /// @inheritdoc IEmissionsController function isButtonPressable() external view returns (bool) { - return _totalProcessed[getCurrentEpoch()] < getTotalDistributions(); + return _epochs[getCurrentEpoch()].totalProcessed < getTotalDistributions(); } /// @inheritdoc IEmissionsController diff --git a/src/contracts/core/storage/EmissionsControllerStorage.sol b/src/contracts/core/storage/EmissionsControllerStorage.sol index b8ef048fa4..984215bf35 100644 --- a/src/contracts/core/storage/EmissionsControllerStorage.sol +++ b/src/contracts/core/storage/EmissionsControllerStorage.sol @@ -35,8 +35,8 @@ abstract contract EmissionsControllerStorage is IEmissionsController { /// @dev Returns an append-only array of distributions. Distribution[] internal _distributions; - /// @dev Mapping from epoch to the total number of distributions processed for that epoch. - mapping(uint256 epoch => uint256 totalProcessed) internal _totalProcessed; + /// @dev Mapping from epoch number to epoch metadata. + mapping(uint256 epoch => Epoch) internal _epochs; // Construction diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index 419a8f3bad..da83c1d390 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -48,6 +48,14 @@ interface IEmissionsControllerTypes { Manual } + /// @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. + uint248 totalProcessed; + } + /// @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. From fa055ced1712fbe243a97fe38a06a308f5531d09 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:53:55 -0500 Subject: [PATCH 33/62] fixes --- src/contracts/core/EmissionsController.sol | 27 +++++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index b7e4d14152..cc39cf7543 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -3,13 +3,19 @@ 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 "../libraries/OperatorSetLib.sol"; import "./storage/EmissionsControllerStorage.sol"; // Add fee, it's enabled by default, with settable fee recipient. // AVS's must also have a way to opt-out of the fee. -contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsControllerStorage { +contract EmissionsController is + Initializable, + OwnableUpgradeable, + ReentrancyGuardUpgradeable, + EmissionsControllerStorage +{ /// @dev Modifier that checks if the caller is the incentive council. modifier onlyIncentiveCouncil() { _checkOnlyIncentiveCouncil(); @@ -52,10 +58,12 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont /// Permissionless Trigger /// ----------------------------------------------------------------------- + // QUESTION: Should this be pausable? + /// @inheritdoc IEmissionsController function pressButton( uint256 length - ) external override { + ) external override nonReentrant { uint256 currentEpoch = getCurrentEpoch(); uint256 totalDistributions = getTotalDistributions(); uint256 nextDistributionId = _epochs[currentEpoch].totalProcessed; @@ -78,9 +86,14 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont // 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 last distribution index. + if (lastIndex > totalDistributions) lastIndex = totalDistributions - 1; - // Process distributions starting from the next one to process - for (uint256 i = nextDistributionId; i < length; ++i) { + // Process distributions starting from the next one to process... + for (uint256 i = nextDistributionId; i < lastIndex; ++i) { Distribution memory distribution = _distributions[i]; // Skip disabled distributions... @@ -111,8 +124,10 @@ contract EmissionsController is Initializable, OwnableUpgradeable, EmissionsCont ) internal { // Calculate the total amount of emissions for the distribution. uint256 totalAmount = EMISSIONS_INFLATION_RATE * distribution.weight / MAX_TOTAL_WEIGHT; - // Calculate the amount per submission (total amount divided by the number of submissions). - uint256 amountPerSubmission = totalAmount / distribution.partialRewardsSubmissions.length; + // Calculate the amount per submission. Use 1 if Manual, otherwise divide equally among submissions. + uint256 amountPerSubmission = (distribution.distributionType == DistributionType.Manual) + ? 1 + : totalAmount / distribution.partialRewardsSubmissions.length; // Update the rewards submissions start timestamp, duration, and amount. IRewardsCoordinator.RewardsSubmission[] memory rewardsSubmissions = From e8a8701c056b814dd6369561f9003486dcf5c6c8 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:02:22 -0500 Subject: [PATCH 34/62] fix --- src/contracts/core/EmissionsController.sol | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index cc39cf7543..cf8ce9e2f3 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -313,8 +313,11 @@ contract EmissionsController is ) internal view { // Check if the start epoch is in the future. // Prevents updating a distribution to a past or current epoch. - if (distribution.startEpoch == currentEpoch) { - revert StartEpochMustBeInTheFuture(); + if (currentEpoch != type(uint256).max) { + // After emissions start - require future epochs only + if (distribution.startEpoch <= currentEpoch) { + revert StartEpochMustBeInTheFuture(); + } } // Check if the new total weight of all distributions exceeds max total weight. From 8c0ae9d45dc4c976db5bcb55f74e9e3735de4ac8 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:04:16 -0500 Subject: [PATCH 35/62] fix --- src/contracts/core/EmissionsController.sol | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index cf8ce9e2f3..e24a975bec 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -45,13 +45,6 @@ contract EmissionsController is _transferOwnership(initialOwner); // Set the initial incentive council. _setIncentiveCouncil(initialIncentiveCouncil); - - // NOTE: Reviewers, is this okay? Avoids the need for approvals on each button press. - - // Max approve EIGEN for spending bEIGEN. - BACKING_EIGEN.approve(address(EIGEN), type(uint256).max); - // Max approve RewardsCoordinator for spending EIGEN. - EIGEN.approve(address(REWARDS_COORDINATOR), type(uint256).max); } /// ----------------------------------------------------------------------- @@ -75,6 +68,12 @@ contract EmissionsController is // Mint the total amount of bEIGEN/EIGEN needed for all distributions. if (!_epochs[currentEpoch].minted) { + + // 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. From 0acec59ccedbfc4cec079b71f2ae49e8b8a34bb7 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:20:50 -0500 Subject: [PATCH 36/62] fixes --- src/contracts/core/EmissionsController.sol | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index e24a975bec..e92c57ac66 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -68,7 +68,6 @@ contract EmissionsController is // Mint the total amount of bEIGEN/EIGEN needed for all distributions. if (!_epochs[currentEpoch].minted) { - // Max approve EIGEN for spending bEIGEN. BACKING_EIGEN.approve(address(EIGEN), EMISSIONS_INFLATION_RATE); // Max approve RewardsCoordinator for spending EIGEN. @@ -88,8 +87,8 @@ contract EmissionsController is // Calculate the last index to process. uint256 lastIndex = nextDistributionId + length; - // If length exceeds total distributions, set last index to last distribution index. - if (lastIndex > totalDistributions) lastIndex = totalDistributions - 1; + // 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 i = nextDistributionId; i < lastIndex; ++i) { @@ -107,8 +106,8 @@ contract EmissionsController is // QUESTION: Should be burn excess supply that wasn't distributed (e.g. if the distribution reverts)? - // Update total processed count for this epoch (equals next index to process) - _epochs[currentEpoch].totalProcessed = uint248(length); + // Update total processed count for this epoch. + _epochs[currentEpoch].totalProcessed = uint248(lastIndex); } /// @dev Internal helper that processes a distribution. @@ -218,8 +217,6 @@ contract EmissionsController is ) external override onlyIncentiveCouncil returns (uint256 distributionId) { uint256 currentEpoch = getCurrentEpoch(); - // Question: How should be handle added distributions before the first epoch? - // Check if the distribution is disabled. if (distribution.distributionType == DistributionType.Disabled) { revert CannotAddDisabledDistribution(); From 56a5a197b1f68044ef8b25612b1e481ae9a2c8d1 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:51:41 -0500 Subject: [PATCH 37/62] docs --- src/contracts/core/EmissionsController.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index e92c57ac66..a6e1e55a7f 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -68,6 +68,8 @@ contract EmissionsController is // 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. From 64bf038af066ed67ff4ad9856ca9ab62c9ef9aab Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:57:08 -0500 Subject: [PATCH 38/62] optimize - saves 3 slots --- src/contracts/interfaces/IEmissionsController.sol | 10 +++++----- src/test/unit/EmissionsControllerUnit.t.sol | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index da83c1d390..515bd1d03b 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -62,11 +62,11 @@ interface IEmissionsControllerTypes { /// Active distributions are processed sequentially. struct Distribution { /// The bips denominated weight of the distribution. - uint256 weight; - /// The epoch the distribution was last triggered. - uint256 startEpoch; - /// The number of epochs to repeat the distribution. - uint256 stopEpoch; + uint64 weight; + /// The epoch the distribution was last triggered (). + uint64 startEpoch; + /// The number of epochs to repeat the distribution (1256 years supported). + uint64 stopEpoch; /// The type of distribution. DistributionType distributionType; /// The operator set (Required only for OperatorSetTotalStake and OperatorSetUniqueStake distribution types). diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 5c7ec47dc2..308e93011c 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -214,7 +214,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit cheats.expectRevert(IEmissionsControllerErrors.TotalWeightExceedsMax.selector); emissionsController.addDistribution( Distribution({ - weight: weight, + weight: uint64(weight), startEpoch: 0, stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, @@ -266,7 +266,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit distributionType == DistributionType.Manual ? emptySubmission() : defaultSubmission(); Distribution memory addedDistribution = Distribution({ - weight: weight, + weight: uint64(weight), startEpoch: 0, stopEpoch: 0, distributionType: distributionType, @@ -951,7 +951,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni cheats.prank(incentiveCouncil); emissionsController.addDistribution( Distribution({ - weight: 100 * (i + 1), + weight: uint64(100 * (i + 1)), startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, @@ -1038,7 +1038,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni cheats.prank(incentiveCouncil); emissionsController.addDistribution( Distribution({ - weight: 100 * (i + 1), + weight: uint64(100 * (i + 1)), startEpoch: 0, stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, From d9c9d3b5b251b80f13cf54c4638b5c8b2b96f057 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:29:47 -0500 Subject: [PATCH 39/62] cleanup --- src/contracts/core/EmissionsController.sol | 10 +- .../interfaces/IEmissionsController.sol | 13 +- src/test/unit/EmissionsControllerUnit.t.sol | 112 +++++++++--------- 3 files changed, 60 insertions(+), 75 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index a6e1e55a7f..88739aeff4 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -127,15 +127,15 @@ contract EmissionsController is // Calculate the amount per submission. Use 1 if Manual, otherwise divide equally among submissions. uint256 amountPerSubmission = (distribution.distributionType == DistributionType.Manual) ? 1 - : totalAmount / distribution.partialRewardsSubmissions.length; + : totalAmount / distribution.strategiesAndMultipliers.length; // Update the rewards submissions start timestamp, duration, and amount. IRewardsCoordinator.RewardsSubmission[] memory rewardsSubmissions = - new IRewardsCoordinator.RewardsSubmission[](distribution.partialRewardsSubmissions.length); + new IRewardsCoordinator.RewardsSubmission[](distribution.strategiesAndMultipliers.length); for (uint256 i = 0; i < rewardsSubmissions.length; ++i) { rewardsSubmissions[i] = IRewardsCoordinatorTypes.RewardsSubmission({ - strategiesAndMultipliers: distribution.partialRewardsSubmissions[i].strategiesAndMultipliers, - token: distribution.partialRewardsSubmissions[i].token, + strategiesAndMultipliers: distribution.strategiesAndMultipliers[i], + token: EIGEN, amount: amountPerSubmission, startTimestamp: uint32(startTimestamp), duration: uint32(EMISSIONS_EPOCH_LENGTH) // QUESTION: Should this be configurable? @@ -328,7 +328,7 @@ contract EmissionsController is // Manual distributions handle rewards differently and don't require submissions. if ( distribution.distributionType != DistributionType.Manual - && distribution.partialRewardsSubmissions.length == 0 + && distribution.strategiesAndMultipliers.length == 0 ) { revert RewardsSubmissionsCannotBeEmpty(); } diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index 515bd1d03b..3143ca8a54 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -71,19 +71,8 @@ interface IEmissionsControllerTypes { DistributionType distributionType; /// The operator set (Required only for OperatorSetTotalStake and OperatorSetUniqueStake distribution types). OperatorSet operatorSet; - /// The rewards submissions. - PartialRewardsSubmission[] partialRewardsSubmissions; - } - - /// @notice A partial rewards submission that will be converted to a full RewardsSubmission during processing. - /// @dev This struct contains the static parameters for a rewards submission (strategies and token). - /// During epoch processing, these are augmented with dynamic parameters (amount, startTimestamp, duration) - /// to create complete RewardsSubmission objects for the RewardsCoordinator. - struct PartialRewardsSubmission { /// The strategies and their respective multipliers for distributing rewards. - IRewardsCoordinator.StrategyAndMultiplier[] strategiesAndMultipliers; - /// The ERC20 token to be distributed as rewards. - IERC20 token; + IRewardsCoordinatorTypes.StrategyAndMultiplier[][] strategiesAndMultipliers; } } diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 308e93011c..0d8c077d92 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -42,17 +42,13 @@ contract EmissionsControllerUnitTests is EigenLayerUnitTestSetup, IEmissionsCont return OperatorSet({avs: address(0), id: 0}); } - function emptySubmission() public pure returns (IEmissionsControllerTypes.PartialRewardsSubmission[] memory) { - return new IEmissionsControllerTypes.PartialRewardsSubmission[](0); + function emptyStrategiesAndMultipliers() public pure returns (IRewardsCoordinatorTypes.StrategyAndMultiplier[][] memory) { + return new IRewardsCoordinatorTypes.StrategyAndMultiplier[][](0); } - function defaultSubmission() public pure returns (IEmissionsControllerTypes.PartialRewardsSubmission[] memory) { - IEmissionsControllerTypes.PartialRewardsSubmission[] memory submissions = - new IEmissionsControllerTypes.PartialRewardsSubmission[](1); - submissions[0] = IEmissionsControllerTypes.PartialRewardsSubmission({ - strategiesAndMultipliers: new IRewardsCoordinatorTypes.StrategyAndMultiplier[](0), - token: IERC20(address(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; } } @@ -104,7 +100,7 @@ contract EmissionsControllerUnitTests_pressButton is EmissionsControllerUnitTest stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -157,7 +153,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); } @@ -172,7 +168,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.Disabled, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); } @@ -187,7 +183,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: emptySubmission() + strategiesAndMultipliers: emptyStrategiesAndMultipliers() }) ); } @@ -203,7 +199,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); } @@ -219,7 +215,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); } @@ -234,7 +230,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -248,7 +244,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); } @@ -261,9 +257,9 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit uint nextDistributionId = emissionsController.getTotalDistributions(); - // Use defaultSubmission for non-Manual types, empty for Manual - IEmissionsControllerTypes.PartialRewardsSubmission[] memory partialRewardsSubmissions = - distributionType == DistributionType.Manual ? emptySubmission() : defaultSubmission(); + // 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), @@ -271,7 +267,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit stopEpoch: 0, distributionType: distributionType, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: partialRewardsSubmissions + strategiesAndMultipliers: strategiesAndMultipliers }); cheats.expectEmit(true, true, true, true); @@ -301,7 +297,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); } @@ -317,7 +313,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.Disabled, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); } @@ -333,7 +329,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); } @@ -347,7 +343,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -364,7 +360,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); } @@ -378,7 +374,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -393,7 +389,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); } @@ -407,7 +403,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -421,7 +417,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: emptySubmission() + strategiesAndMultipliers: emptyStrategiesAndMultipliers() }) ); } @@ -438,7 +434,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -451,7 +447,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -466,7 +462,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); } @@ -496,7 +492,7 @@ contract EmissionsControllerUnitTests_disableDistribution is EmissionsController stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); cheats.prank(incentiveCouncil); @@ -516,7 +512,7 @@ contract EmissionsControllerUnitTests_disableDistribution is EmissionsController stopEpoch: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -575,7 +571,7 @@ contract EmissionsControllerUnitTests_isButtonPressable is EmissionsControllerUn stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -594,7 +590,7 @@ contract EmissionsControllerUnitTests_isButtonPressable is EmissionsControllerUn stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -659,7 +655,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); assertEq(emissionsController.getTotalDistributions(), 1); @@ -673,7 +669,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); assertEq(emissionsController.getTotalDistributions(), 2); @@ -689,7 +685,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -701,7 +697,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -725,7 +721,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); } @@ -750,7 +746,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 10, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -772,7 +768,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -784,7 +780,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 8, distributionType: DistributionType.OperatorSetTotalStake, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -813,7 +809,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -827,7 +823,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 15, distributionType: DistributionType.OperatorSetUniqueStake, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -849,7 +845,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit stopEpoch: 10, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -874,7 +870,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -886,7 +882,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -908,7 +904,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -920,7 +916,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 8, distributionType: DistributionType.OperatorSetTotalStake, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -932,7 +928,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 10, distributionType: DistributionType.OperatorSetUniqueStake, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -956,7 +952,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); } @@ -980,7 +976,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -999,7 +995,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -1011,7 +1007,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 8, distributionType: DistributionType.OperatorSetTotalStake, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); @@ -1043,7 +1039,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni stopEpoch: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), - partialRewardsSubmissions: defaultSubmission() + strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); } From 67a6482d07f212a395b10fc88dd31fe3ef399d6f Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:07:58 -0500 Subject: [PATCH 40/62] passing --- src/contracts/interfaces/IEmissionsController.sol | 2 +- src/test/unit/EmissionsControllerUnit.t.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index 3143ca8a54..77ebdddd7f 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -65,7 +65,7 @@ interface IEmissionsControllerTypes { uint64 weight; /// The epoch the distribution was last triggered (). uint64 startEpoch; - /// The number of epochs to repeat the distribution (1256 years supported). + /// The number of epochs to repeat the distribution. uint64 stopEpoch; /// The type of distribution. DistributionType distributionType; diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 0d8c077d92..f9eb230e2c 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -205,7 +205,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit } function testFuzz_revert_addDistribution_TotalWeightExceedsMax(uint weight) public { - weight = bound(weight, 10_001, type(uint).max); + weight = bound(weight, 10_001, type(uint64).max); cheats.prank(incentiveCouncil); cheats.expectRevert(IEmissionsControllerErrors.TotalWeightExceedsMax.selector); emissionsController.addDistribution( From 28963df1a8834b01663df575ea93c1fe3a53b629 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:46:45 -0500 Subject: [PATCH 41/62] review --- src/contracts/core/EmissionsController.sol | 94 ++++++++++--------- .../interfaces/IEmissionsController.sol | 4 +- 2 files changed, 50 insertions(+), 48 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 88739aeff4..bd4ea30c4c 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -124,54 +124,56 @@ contract EmissionsController is ) internal { // Calculate the total amount of emissions for the distribution. uint256 totalAmount = EMISSIONS_INFLATION_RATE * distribution.weight / MAX_TOTAL_WEIGHT; - // Calculate the amount per submission. Use 1 if Manual, otherwise divide equally among submissions. - uint256 amountPerSubmission = (distribution.distributionType == DistributionType.Manual) - ? 1 - : totalAmount / distribution.strategiesAndMultipliers.length; - - // Update the rewards submissions start timestamp, duration, and amount. - IRewardsCoordinator.RewardsSubmission[] memory rewardsSubmissions = - new IRewardsCoordinator.RewardsSubmission[](distribution.strategiesAndMultipliers.length); - 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) // QUESTION: Should this be configurable? - }); - } - - // Dispatch the `RewardsCoordinator` call based on the distribution type. + // Success flag for the distribution. bool success; - 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 if (distribution.distributionType == DistributionType.Manual) { - // We use call to prevent further distributions from being processed if the mint fails. - (success,) = - address(BACKING_EIGEN).call(abi.encodeWithSelector(IBackingEigen.mint.selector, rewardsSubmissions)); + + if (distribution.distributionType != DistributionType.Manual) { + // Calculate the amount per submission. + uint256 amountPerSubmission = totalAmount / distribution.strategiesAndMultipliers.length; + // Update the rewards submissions start timestamp, duration, and amount. + IRewardsCoordinator.RewardsSubmission[] memory rewardsSubmissions = + new IRewardsCoordinator.RewardsSubmission[](distribution.strategiesAndMultipliers.length); + 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 { - revert InvalidDistributionType(); // Only reachable if the distribution type is `Disabled`. + (success,) = address(BACKING_EIGEN) + .call(abi.encodeWithSelector(IBackingEigen.mint.selector, incentiveCouncil, totalAmount)); + + // Question: Should we also wrap the bEIGEN into EIGEN? } // Emit an event for the processed distribution. diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index 77ebdddd7f..77b6eda371 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -63,9 +63,9 @@ interface IEmissionsControllerTypes { struct Distribution { /// The bips denominated weight of the distribution. uint64 weight; - /// The epoch the distribution was last triggered (). + /// The configured start epoch. uint64 startEpoch; - /// The number of epochs to repeat the distribution. + /// The number of epochs to repeat the distribution (0 for infinite). uint64 stopEpoch; /// The type of distribution. DistributionType distributionType; From 7f05bafd0487752548cfcc7e9fff06948b4c7f95 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Tue, 6 Jan 2026 19:09:06 -0500 Subject: [PATCH 42/62] flakey test --- src/test/unit/ECDSACertificateVerifierUnit.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); } From b2c392b8aa9c291348a664e181cb1e87c242a328 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Tue, 6 Jan 2026 19:42:56 -0500 Subject: [PATCH 43/62] review --- src/contracts/core/EmissionsController.sol | 38 ++++++++------ .../storage/EmissionsControllerStorage.sol | 3 ++ .../interfaces/IEmissionsController.sol | 11 +++- src/test/unit/EmissionsControllerUnit.t.sol | 50 ++++++++++++++++++- 4 files changed, 84 insertions(+), 18 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index bd4ea30c4c..eade441112 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -4,18 +4,20 @@ 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"; -// Add fee, it's enabled by default, with settable fee recipient. -// AVS's must also have a way to opt-out of the fee. - 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(); @@ -29,34 +31,46 @@ contract EmissionsController is IEigen eigen, IBackingEigen backingEigen, IRewardsCoordinator rewardsCoordinator, + IPauserRegistry pauserRegistry, uint256 inflationRate, uint256 startTime, uint256 cooldownSeconds - ) EmissionsControllerStorage(eigen, backingEigen, rewardsCoordinator, inflationRate, startTime, cooldownSeconds) { + ) + EmissionsControllerStorage(eigen, backingEigen, rewardsCoordinator, inflationRate, startTime, cooldownSeconds) + Pausable(pauserRegistry) + { _disableInitializers(); } /// @inheritdoc IEmissionsController function initialize( address initialOwner, - address initialIncentiveCouncil + 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 /// ----------------------------------------------------------------------- - // QUESTION: Should this be pausable? + /// @inheritdoc IEmissionsController + function sweep() external override nonReentrant onlyWhenNotPaused(PAUSED_TOKEN_FLOWS) { + if (!isButtonPressable()) { + IERC20(EIGEN).safeTransfer(incentiveCouncil, EIGEN.balanceOf(address(this))); + } + } /// @inheritdoc IEmissionsController function pressButton( uint256 length - ) external override nonReentrant { + ) external override nonReentrant onlyWhenNotPaused(PAUSED_TOKEN_FLOWS) { uint256 currentEpoch = getCurrentEpoch(); uint256 totalDistributions = getTotalDistributions(); uint256 nextDistributionId = _epochs[currentEpoch].totalProcessed; @@ -106,8 +120,6 @@ contract EmissionsController is _processDistribution(i, currentEpoch, startTimestamp, distribution); } - // QUESTION: Should be burn excess supply that wasn't distributed (e.g. if the distribution reverts)? - // Update total processed count for this epoch. _epochs[currentEpoch].totalProcessed = uint248(lastIndex); } @@ -170,10 +182,8 @@ contract EmissionsController is revert InvalidDistributionType(); // Only reachable if the distribution type is `Disabled`. } } else { - (success,) = address(BACKING_EIGEN) - .call(abi.encodeWithSelector(IBackingEigen.mint.selector, incentiveCouncil, totalAmount)); - - // Question: Should we also wrap the bEIGEN into EIGEN? + (success,) = + address(EIGEN).call(abi.encodeWithSelector(IERC20.transfer.selector, incentiveCouncil, totalAmount)); } // Emit an event for the processed distribution. @@ -349,7 +359,7 @@ contract EmissionsController is } /// @inheritdoc IEmissionsController - function isButtonPressable() external view returns (bool) { + function isButtonPressable() public view returns (bool) { return _epochs[getCurrentEpoch()].totalProcessed < getTotalDistributions(); } diff --git a/src/contracts/core/storage/EmissionsControllerStorage.sol b/src/contracts/core/storage/EmissionsControllerStorage.sol index 984215bf35..c28041a9d3 100644 --- a/src/contracts/core/storage/EmissionsControllerStorage.sol +++ b/src/contracts/core/storage/EmissionsControllerStorage.sol @@ -10,6 +10,9 @@ abstract contract EmissionsControllerStorage is IEmissionsController { /// @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. diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index 77b6eda371..8beca792ca 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -4,6 +4,7 @@ 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. @@ -117,7 +118,7 @@ interface IEmissionsControllerEvents is IEmissionsControllerTypes { /// @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 { +interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControllerEvents, IPausable { /// ----------------------------------------------------------------------- /// Constants /// ----------------------------------------------------------------------- @@ -156,15 +157,21 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// @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 initialOwner, - address incentiveCouncil + address incentiveCouncil, + uint256 initialPausedStatus ) external; /// ----------------------------------------------------------------------- /// Permissionless Trigger /// ----------------------------------------------------------------------- + /// @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. diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index f9eb230e2c..ee66d80502 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -26,13 +26,14 @@ contract EmissionsControllerUnitTests is EigenLayerUnitTestSetup, IEmissionsCont IEigen(address(eigenMock)), IBackingEigen(address(backingEigenMock)), IRewardsCoordinator(address(rewardsCoordinatorMock)), + IPauserRegistry(address(pauserRegistry)), EMISSIONS_INFLATION_RATE, EMISSIONS_START_TIME, EMISSIONS_EPOCH_LENGTH ) ), address(eigenLayerProxyAdmin), - abi.encodeWithSelector(EmissionsController.initialize.selector, owner, incentiveCouncil) + abi.encodeWithSelector(EmissionsController.initialize.selector, owner, incentiveCouncil, 0) ) ) ); @@ -72,7 +73,7 @@ contract EmissionsControllerUnitTests_Initialization_Setters is EmissionsControl function test_revert_initialize_AlreadyInitialized() public { cheats.expectRevert("Initializable: contract is already initialized"); - emissionsController.initialize(owner, incentiveCouncil); + emissionsController.initialize(owner, incentiveCouncil, 0); } } @@ -1055,3 +1056,48 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni } } } + +/// ----------------------------------------------------------------------- +/// 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, + stopEpoch: 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(); + } +} From 615c4cc09bd1d091625de672464be932067fcc3b Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 7 Jan 2026 16:59:44 -0500 Subject: [PATCH 44/62] fix: total weight check --- src/contracts/core/EmissionsController.sol | 41 +++++++++++++++------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index eade441112..c8c20b095d 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -229,6 +229,8 @@ contract EmissionsController is function addDistribution( Distribution calldata distribution ) external override onlyIncentiveCouncil returns (uint256 distributionId) { + // Checks + uint256 currentEpoch = getCurrentEpoch(); // Check if the distribution is disabled. @@ -236,20 +238,24 @@ contract EmissionsController is revert CannotAddDisabledDistribution(); } - // operatorSet only non-zero if distribution type is OperatorSetTotalStake or OperatorSetUniqueStake. + 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); - // Get the total number of distributions (also next available distribution id). - distributionId = getTotalDistributions(); + _checkDistribution(distribution, currentEpoch, totalWeightBefore); + // Effects + + // Update the total weight. + totalWeight = totalWeightBefore + distribution.weight; // Append the distribution to the distributions array. _distributions.push(distribution); - // Update the total weight. - totalWeight += distribution.weight; // Emit an event for the new distribution. emit DistributionAdded(distributionId, currentEpoch, distribution); + + // Return distribution id (also next available distribution id). + return getTotalDistributions(); } /// @inheritdoc IEmissionsController @@ -257,6 +263,8 @@ contract EmissionsController is uint256 distributionId, Distribution calldata distribution ) external override onlyIncentiveCouncil { + // Checks + uint256 currentEpoch = getCurrentEpoch(); // Check if the distribution (from calldata) is disabled. @@ -267,17 +275,18 @@ contract EmissionsController is // Check if the distribution (in storage) is disabled. _checkDisabled(_distributions[distributionId]); + uint256 totalWeightBefore = totalWeight; uint256 weight = _distributions[distributionId].weight; - // Subtract the old weight. - totalWeight -= weight; - // Add the new weight. - totalWeight += distribution.weight; // 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); + _checkDistribution(distribution, currentEpoch, totalWeightBefore - weight); + + // Effects + // Add the new weight. + totalWeight = totalWeightBefore - weight + distribution.weight; // Update the distribution in the distributions array. _distributions[distributionId] = distribution; // Emit an event for the updated distribution. @@ -288,8 +297,13 @@ contract EmissionsController is function disableDistribution( uint256 distributionId ) external override onlyIncentiveCouncil { + // Checks + // Check if the distribution is already disabled. _checkDisabled(_distributions[distributionId]); + + // Effects + // Subtract the weight from total weight. totalWeight -= _distributions[distributionId].weight; // Set the distribution type to disabled. @@ -319,7 +333,8 @@ contract EmissionsController is function _checkDistribution( Distribution calldata distribution, - uint256 currentEpoch + uint256 currentEpoch, + uint256 totalWeightBefore ) internal view { // Check if the start epoch is in the future. // Prevents updating a distribution to a past or current epoch. @@ -332,7 +347,7 @@ contract EmissionsController is // Check if the new total weight of all distributions exceeds max total weight. // Prevents distributing more supply than inflation rate allows. - if (distribution.weight + totalWeight > MAX_TOTAL_WEIGHT) { + if (distribution.weight + totalWeightBefore > MAX_TOTAL_WEIGHT) { revert TotalWeightExceedsMax(); } From b0a9d70ff9eb1e9dd7ef16b0163ee5317d193be6 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:01:43 -0500 Subject: [PATCH 45/62] gap --- src/contracts/core/storage/EmissionsControllerStorage.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contracts/core/storage/EmissionsControllerStorage.sol b/src/contracts/core/storage/EmissionsControllerStorage.sol index c28041a9d3..baf010c3ce 100644 --- a/src/contracts/core/storage/EmissionsControllerStorage.sol +++ b/src/contracts/core/storage/EmissionsControllerStorage.sol @@ -63,5 +63,5 @@ abstract contract EmissionsControllerStorage is IEmissionsController { /// @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[45] private __gap; + uint256[46] private __gap; } From a2e1d98384a40da692cc24d24d1c11ac1351c752 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:08:22 -0500 Subject: [PATCH 46/62] add event --- src/contracts/core/EmissionsController.sol | 8 +++++--- src/contracts/interfaces/IEmissionsController.sol | 5 +++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index c8c20b095d..84fce226e8 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -62,8 +62,10 @@ contract EmissionsController is /// @inheritdoc IEmissionsController function sweep() external override nonReentrant onlyWhenNotPaused(PAUSED_TOKEN_FLOWS) { - if (!isButtonPressable()) { - IERC20(EIGEN).safeTransfer(incentiveCouncil, EIGEN.balanceOf(address(this))); + uint256 amount = EIGEN.balanceOf(address(this)); + if (!isButtonPressable() && amount != 0) { + IERC20(EIGEN).safeTransfer(incentiveCouncil, amount); + emit Swept(incentiveCouncil, amount); } } @@ -335,7 +337,7 @@ contract EmissionsController is Distribution calldata distribution, uint256 currentEpoch, uint256 totalWeightBefore - ) internal view { + ) internal pure { // Check if the start epoch is in the future. // Prevents updating a distribution to a past or current epoch. if (currentEpoch != type(uint256).max) { diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index 8beca792ca..f7c2495b8a 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -80,6 +80,11 @@ interface IEmissionsControllerTypes { /// @title IEmissionsControllerEvents /// @notice Events for the IEmissionsController contract. interface IEmissionsControllerEvents is IEmissionsControllerTypes { + /// @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 updated. /// @param distributionId The id of the distribution. /// @param epoch The epoch the distribution was updated. From 1716838ec0c3ee20fc469b81a1cd1771ee11659d Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:14:15 -0500 Subject: [PATCH 47/62] fix --- src/contracts/core/EmissionsController.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 84fce226e8..314a721a47 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -249,15 +249,15 @@ contract EmissionsController is // Effects + // Update return value to the next available distribution id. + distributionId = getTotalDistributions(); + // Update the total weight. totalWeight = 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); - - // Return distribution id (also next available distribution id). - return getTotalDistributions(); } /// @inheritdoc IEmissionsController From 2c68008be60849ee6b2e210db013cdae5761580c Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:15:49 -0500 Subject: [PATCH 48/62] regression test --- src/test/unit/EmissionsControllerUnit.t.sol | 51 +++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index ee66d80502..099ee292dd 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -467,6 +467,57 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU }) ); } + + // Regression test for bug where _checkDistribution was called after updating totalWeight, + // causing valid weight updates to incorrectly revert due to double-counting the new weight + function test_updateDistribution_DoesNotDoubleCountNewWeight() public { + // Add first distribution with weight 6000 + cheats.prank(incentiveCouncil); + uint distributionId1 = emissionsController.addDistribution( + Distribution({ + weight: 6000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Add second distribution with weight 1000 (total = 7000) + cheats.prank(incentiveCouncil); + uint distributionId2 = emissionsController.addDistribution( + Distribution({ + weight: 1000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Update second distribution to weight 4000 (total should be 10000, which is valid) + // Without the fix, this would incorrectly check: 4000 + (7000 - 1000 + 4000) = 14000 > 10000 + // With the fix, this correctly checks: 4000 + (7000 - 1000) = 10000 <= 10000 + cheats.prank(incentiveCouncil); + emissionsController.updateDistribution( + distributionId2, + Distribution({ + weight: 4000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Verify the update succeeded + Distribution memory updated = emissionsController.getDistribution(distributionId2); + assertEq(updated.weight, 4000); + assertEq(emissionsController.totalWeight(), 10_000); + } } contract EmissionsControllerUnitTests_disableDistribution is EmissionsControllerUnitTests { From f5e2258db6138e35d4104920f5cd3f079b21a992 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:22:19 -0500 Subject: [PATCH 49/62] remove disableDistribution, use update instead to disable + reenable --- src/contracts/core/EmissionsController.sol | 38 ---- .../interfaces/IEmissionsController.sol | 44 ++--- src/test/unit/EmissionsControllerUnit.t.sol | 162 ------------------ 3 files changed, 13 insertions(+), 231 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 314a721a47..7ccee4c4c9 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -268,15 +268,6 @@ contract EmissionsController is // Checks uint256 currentEpoch = getCurrentEpoch(); - - // Check if the distribution (from calldata) is disabled. - if (distribution.distributionType == DistributionType.Disabled) { - revert CannotDisableDistributionViaUpdate(); - } - - // Check if the distribution (in storage) is disabled. - _checkDisabled(_distributions[distributionId]); - uint256 totalWeightBefore = totalWeight; uint256 weight = _distributions[distributionId].weight; @@ -295,26 +286,6 @@ contract EmissionsController is emit DistributionUpdated(distributionId, currentEpoch, distribution); } - /// @inheritdoc IEmissionsController - function disableDistribution( - uint256 distributionId - ) external override onlyIncentiveCouncil { - // Checks - - // Check if the distribution is already disabled. - _checkDisabled(_distributions[distributionId]); - - // Effects - - // Subtract the weight from total weight. - totalWeight -= _distributions[distributionId].weight; - // Set the distribution type to disabled. - // Prevents further updates to the distribution. - _distributions[distributionId].distributionType = DistributionType.Disabled; - // Emit an event for the removed distribution. - emit DistributionRemoved(distributionId, getCurrentEpoch()); - } - /// ----------------------------------------------------------------------- /// Internal Helpers /// ----------------------------------------------------------------------- @@ -324,15 +295,6 @@ contract EmissionsController is if (msg.sender != incentiveCouncil) revert CallerIsNotIncentiveCouncil(); } - function _checkDisabled( - Distribution storage distribution - ) internal view { - // Check if the distribution is disabled. - if (distribution.distributionType == DistributionType.Disabled) { - revert DistributionIsDisabled(); - } - } - function _checkDistribution( Distribution calldata distribution, uint256 currentEpoch, diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index f7c2495b8a..a317160cb1 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -15,8 +15,6 @@ interface IEmissionsControllerErrors { error StartEpochMustBeInTheFuture(); /// @dev Thrown when the total weight of all distributions exceeds the max total weight (100%). error TotalWeightExceedsMax(); - /// @dev Thrown when the distribution is disabled. - error DistributionIsDisabled(); /// @dev Thrown when attempting to add a disabled distribution. error CannotAddDisabledDistribution(); /// @dev Thrown when attempting to update a disabled distribution. @@ -85,11 +83,16 @@ interface IEmissionsControllerEvents is IEmissionsControllerTypes { /// @param amount The amount of EIGEN swept. event Swept(address indexed incentiveCouncil, uint256 amount); - /// @notice Emitted when a distribution is updated. + /// @notice Emitted when a distribution is processed. /// @param distributionId The id of the distribution. - /// @param epoch The epoch the distribution was updated. + /// @param epoch The epoch the distribution was processed. /// @param distribution The distribution. - event DistributionUpdated(uint256 indexed distributionId, uint256 indexed epoch, 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. @@ -97,21 +100,11 @@ interface IEmissionsControllerEvents is IEmissionsControllerTypes { /// @param distribution The distribution. event DistributionAdded(uint256 indexed distributionId, uint256 indexed epoch, Distribution distribution); - /// @notice Emitted when a distribution is removed. - /// @param distributionId The id of the distribution. - /// @param epoch The epoch the distribution was removed. - event DistributionRemoved(uint256 indexed distributionId, uint256 indexed epoch); - - /// @notice Emitted when a distribution is processed. + /// @notice Emitted when a distribution is updated. /// @param distributionId The id of the distribution. - /// @param epoch The epoch the distribution was processed. + /// @param epoch The epoch the distribution was updated. /// @param distribution The distribution. - event DistributionProcessed( - uint256 indexed distributionId, - uint256 indexed epoch, - Distribution distribution, - bool success - ); + 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. @@ -223,8 +216,8 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// @dev Only the Incentive Council can call this function. /// Replaces the entire distribution at the given ID with the new distribution. /// Adjusts totalWeight by subtracting the old weight and adding the new weight. - /// Cannot update distributions that are already disabled. - /// Cannot use this function to disable a distribution (must use disableDistribution). + /// 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 parameters. function updateDistribution( @@ -232,17 +225,6 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll Distribution calldata distribution ) external; - /// @notice Disables a distribution permanently. - /// @dev The distribution remains in storage but is marked as Disabled and skipped during processing. - /// Only the Incentive Council can call this function. - /// The distribution's weight is subtracted from totalWeight, freeing up allocation capacity. - /// This action is permanent - disabled distributions cannot be re-enabled or updated. - /// The distribution will be skipped in all future epochs regardless of startEpoch/stopEpoch. - /// @param distributionId The id of the distribution to disable. - function disableDistribution( - uint256 distributionId - ) external; - /// ----------------------------------------------------------------------- /// View /// ----------------------------------------------------------------------- diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 099ee292dd..926355e83d 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -303,22 +303,6 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU ); } - function test_revert_updateDistribution_CannotDisableDistributionViaUpdate() public { - cheats.prank(incentiveCouncil); - cheats.expectRevert(IEmissionsControllerErrors.CannotDisableDistributionViaUpdate.selector); - emissionsController.updateDistribution( - 0, - Distribution({ - weight: 10_000, - startEpoch: 0, - stopEpoch: 0, - distributionType: DistributionType.Disabled, - 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 @@ -335,37 +319,6 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU ); } - function test_revert_updateDistribution_DistributionIsDisabled() public { - cheats.prank(incentiveCouncil); - uint distributionId = emissionsController.addDistribution( - Distribution({ - weight: 10_000, - startEpoch: 0, - stopEpoch: 0, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - cheats.prank(incentiveCouncil); - emissionsController.disableDistribution(distributionId); - - cheats.prank(incentiveCouncil); - cheats.expectRevert(IEmissionsControllerErrors.DistributionIsDisabled.selector); - emissionsController.updateDistribution( - distributionId, - Distribution({ - weight: 10_000, - startEpoch: 0, - stopEpoch: 0, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - } - function test_revert_updateDistribution_StartEpochMustBeInTheFuture() public { cheats.prank(incentiveCouncil); uint distributionId = emissionsController.addDistribution( @@ -520,64 +473,6 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU } } -contract EmissionsControllerUnitTests_disableDistribution is EmissionsControllerUnitTests { - function test_revert_disableDistribution_OnlyIncentiveCouncil() public { - address notIncentiveCouncil = address(0x3); - cheats.assume(notIncentiveCouncil != incentiveCouncil); - cheats.prank(notIncentiveCouncil); - cheats.expectRevert(IEmissionsControllerErrors.CallerIsNotIncentiveCouncil.selector); - emissionsController.disableDistribution(0); - } - - function test_revert_disableDistribution_NonExistentDistribution() public { - cheats.prank(incentiveCouncil); - cheats.expectRevert(stdError.indexOOBError); // team may want an explicit check for this - emissionsController.disableDistribution(0); - } - - function test_revert_disableDistribution_DistributionIsDisabled() public { - cheats.prank(incentiveCouncil); - uint distributionId = emissionsController.addDistribution( - Distribution({ - weight: 10_000, - startEpoch: 0, - stopEpoch: 0, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - cheats.prank(incentiveCouncil); - emissionsController.disableDistribution(0); - - cheats.prank(incentiveCouncil); - cheats.expectRevert(IEmissionsControllerErrors.DistributionIsDisabled.selector); - emissionsController.disableDistribution(0); - } - - function test_disableDistribution_Correctness() public { - cheats.prank(incentiveCouncil); - uint distributionId = emissionsController.addDistribution( - Distribution({ - weight: 10_000, - startEpoch: 0, - stopEpoch: 0, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - cheats.prank(incentiveCouncil); - cheats.expectEmit(true, true, true, true); - emit DistributionRemoved(distributionId, type(uint).max); - emissionsController.disableDistribution(distributionId); - - Distribution memory distribution = emissionsController.getDistribution(distributionId); - assertEq(uint8(distribution.distributionType), uint8(DistributionType.Disabled)); - } -} - /// ----------------------------------------------------------------------- /// View Functions /// ----------------------------------------------------------------------- @@ -727,40 +622,6 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll assertEq(emissionsController.getTotalDistributions(), 2); } - function test_getTotalDistributions_AfterDisabling() public { - // Add distributions - cheats.prank(incentiveCouncil); - uint distributionId1 = emissionsController.addDistribution( - Distribution({ - weight: 3000, - startEpoch: 0, - stopEpoch: 1, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - cheats.prank(incentiveCouncil); - emissionsController.addDistribution( - Distribution({ - weight: 2000, - startEpoch: 0, - stopEpoch: 1, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - assertEq(emissionsController.getTotalDistributions(), 2); - - // Disable one distribution - total count should not change - cheats.prank(incentiveCouncil); - emissionsController.disableDistribution(distributionId1); - assertEq(emissionsController.getTotalDistributions(), 2); - } - function testFuzz_getTotalDistributions_Correctness(uint8 count) public { count = uint8(bound(count, 0, 50)); // Reasonable upper bound for gas @@ -886,29 +747,6 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit assertEq(retrieved.stopEpoch, 15); assertEq(uint8(retrieved.distributionType), uint8(DistributionType.OperatorSetUniqueStake)); } - - function test_getDistribution_AfterDisable() public { - // Add a distribution - cheats.prank(incentiveCouncil); - uint distributionId = emissionsController.addDistribution( - Distribution({ - weight: 5000, - startEpoch: 0, - stopEpoch: 10, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - // Disable the distribution - cheats.prank(incentiveCouncil); - emissionsController.disableDistribution(distributionId); - - // Verify it's disabled - Distribution memory retrieved = emissionsController.getDistribution(distributionId); - assertEq(uint8(retrieved.distributionType), uint8(DistributionType.Disabled)); - } } contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUnitTests { From de09d42d10e0313419bb001fc505ad9558ac63d2 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:25:14 -0500 Subject: [PATCH 50/62] update denominator --- src/contracts/core/EmissionsController.sol | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 7ccee4c4c9..f43e9546c1 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -108,6 +108,8 @@ contract EmissionsController is // If length exceeds total distributions, set last index to total distributions (exclusive upper bound). if (lastIndex > totalDistributions) lastIndex = totalDistributions; + uint256 totalWeightBefore = totalWeight; + // Process distributions starting from the next one to process... for (uint256 i = nextDistributionId; i < lastIndex; ++i) { Distribution memory distribution = _distributions[i]; @@ -119,7 +121,7 @@ contract EmissionsController is // Skip distributions that have ended... if (distribution.stopEpoch <= currentEpoch) continue; - _processDistribution(i, currentEpoch, startTimestamp, distribution); + _processDistribution(i, currentEpoch, startTimestamp, totalWeightBefore, distribution); } // Update total processed count for this epoch. @@ -134,10 +136,11 @@ contract EmissionsController is uint256 distributionId, uint256 currentEpoch, uint256 startTimestamp, + uint256 totalWeightBefore, Distribution memory distribution ) internal { // Calculate the total amount of emissions for the distribution. - uint256 totalAmount = EMISSIONS_INFLATION_RATE * distribution.weight / MAX_TOTAL_WEIGHT; + uint256 totalAmount = EMISSIONS_INFLATION_RATE * distribution.weight / totalWeightBefore; // Success flag for the distribution. bool success; From 435d39558357809bfeb36c7c1e5e603a491aa6f1 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:07:41 -0500 Subject: [PATCH 51/62] validate operator sets --- src/contracts/core/EmissionsController.sol | 17 +++++++++++++++-- .../core/storage/EmissionsControllerStorage.sol | 4 ++++ .../interfaces/IEmissionsController.sol | 6 ++++++ src/test/unit/EmissionsControllerUnit.t.sol | 1 + 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index f43e9546c1..7e84ce1070 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -30,13 +30,16 @@ contract EmissionsController is constructor( IEigen eigen, IBackingEigen backingEigen, + IAllocationManager allocationManager, IRewardsCoordinator rewardsCoordinator, IPauserRegistry pauserRegistry, uint256 inflationRate, uint256 startTime, uint256 cooldownSeconds ) - EmissionsControllerStorage(eigen, backingEigen, rewardsCoordinator, inflationRate, startTime, cooldownSeconds) + EmissionsControllerStorage( + eigen, backingEigen, allocationManager, rewardsCoordinator, inflationRate, startTime, cooldownSeconds + ) Pausable(pauserRegistry) { _disableInitializers(); @@ -302,7 +305,17 @@ contract EmissionsController is Distribution calldata distribution, uint256 currentEpoch, uint256 totalWeightBefore - ) internal pure { + ) internal view { + // Check if the operator set is registered (for OperatorSetTotalStake or OperatorSetUniqueStake distributions). + if ( + distribution.distributionType == DistributionType.OperatorSetTotalStake + || distribution.distributionType == DistributionType.OperatorSetUniqueStake + ) { + if (!ALLOCATION_MANAGER.isOperatorSet(distribution.operatorSet)) { + revert 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) { diff --git a/src/contracts/core/storage/EmissionsControllerStorage.sol b/src/contracts/core/storage/EmissionsControllerStorage.sol index baf010c3ce..062b82153a 100644 --- a/src/contracts/core/storage/EmissionsControllerStorage.sol +++ b/src/contracts/core/storage/EmissionsControllerStorage.sol @@ -19,6 +19,8 @@ abstract contract EmissionsControllerStorage is IEmissionsController { 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; @@ -46,6 +48,7 @@ abstract contract EmissionsControllerStorage is IEmissionsController { constructor( IEigen eigen, IBackingEigen backingEigen, + IAllocationManager allocationManager, IRewardsCoordinator rewardsCoordinator, uint256 inflationRate, uint256 startTime, @@ -53,6 +56,7 @@ abstract contract EmissionsControllerStorage is IEmissionsController { ) { EIGEN = eigen; BACKING_EIGEN = backingEigen; + ALLOCATION_MANAGER = allocationManager; REWARDS_COORDINATOR = rewardsCoordinator; EMISSIONS_INFLATION_RATE = inflationRate; diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index a317160cb1..d6fb8f15cd 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -25,6 +25,8 @@ interface IEmissionsControllerErrors { 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(); } /// @title IEmissionsControllerTypes @@ -128,6 +130,10 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// @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); diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 926355e83d..3128032acd 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -25,6 +25,7 @@ contract EmissionsControllerUnitTests is EigenLayerUnitTestSetup, IEmissionsCont new EmissionsController( IEigen(address(eigenMock)), IBackingEigen(address(backingEigenMock)), + IAllocationManager(address(allocationManagerMock)), IRewardsCoordinator(address(rewardsCoordinatorMock)), IPauserRegistry(address(pauserRegistry)), EMISSIONS_INFLATION_RATE, From dfe2f7185ca505254edb1ea5d66dabfef12e909b Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:10:13 -0500 Subject: [PATCH 52/62] passing --- src/test/unit/EmissionsControllerUnit.t.sol | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 3128032acd..47b0739efc 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -673,6 +673,8 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit } function test_getDistribution_MultipleDistributions() public { + allocationManagerMock.setIsOperatorSet(emptyOperatorSet(), true); + // Add multiple distributions with different parameters cheats.prank(incentiveCouncil); uint distributionId0 = emissionsController.addDistribution( @@ -714,6 +716,8 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit } function test_getDistribution_AfterUpdate() public { + allocationManagerMock.setIsOperatorSet(emptyOperatorSet(), true); + // Add a distribution cheats.prank(incentiveCouncil); uint distributionId = emissionsController.addDistribution( @@ -786,6 +790,8 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni } function test_getDistributions_All() public { + allocationManagerMock.setIsOperatorSet(emptyOperatorSet(), true); + // Add multiple distributions cheats.prank(incentiveCouncil); emissionsController.addDistribution( @@ -877,6 +883,8 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni } function test_getDistributions_SingleElement() public { + allocationManagerMock.setIsOperatorSet(emptyOperatorSet(), true); + // Add multiple distributions cheats.prank(incentiveCouncil); emissionsController.addDistribution( From 403dd40a05e10ebaba63f765c83281e281a2e4d2 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:17:45 -0500 Subject: [PATCH 53/62] passing --- src/test/unit/EmissionsControllerUnit.t.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 47b0739efc..60bfd45712 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -272,6 +272,8 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit strategiesAndMultipliers: strategiesAndMultipliers }); + allocationManagerMock.setIsOperatorSet(addedDistribution.operatorSet, true); + cheats.expectEmit(true, true, true, true); emit DistributionAdded(nextDistributionId, type(uint).max, addedDistribution); cheats.prank(incentiveCouncil); From 71e5a82be9fad758805dc4ef73aa4f9ad0811643 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:28:16 -0500 Subject: [PATCH 54/62] prevent button press/mints before epoch 0 --- src/contracts/core/EmissionsController.sol | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 7e84ce1070..787fbb255b 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -80,6 +80,12 @@ contract EmissionsController is uint256 totalDistributions = getTotalDistributions(); uint256 nextDistributionId = _epochs[currentEpoch].totalProcessed; + // Check if emissions have not started yet. + // Prevents minting EIGEN before the first epoch has started. + if (currentEpoch == type(uint256).max) { + revert EmissionsNotStarted(); + } + // Check if all distributions have already been processed. if (nextDistributionId >= totalDistributions) { revert AllDistributionsProcessed(); From d9805dc9f855714e1c57f495554c1d28755f3e6b Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:40:19 -0500 Subject: [PATCH 55/62] pending weight accounting --- src/contracts/core/EmissionsController.sol | 41 +++++++++++-------- .../storage/EmissionsControllerStorage.sol | 8 +++- .../interfaces/IEmissionsController.sol | 14 ++++++- 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 787fbb255b..0e5f861980 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -107,6 +107,11 @@ contract EmissionsController is // Mark the epoch as minted. _epochs[currentEpoch].minted = true; + + // Commit pending weight changes to totalWeight for the new epoch. + // This ensures that weight modifications made during the previous epoch + // (via addDistribution/updateDistribution) take effect starting from this epoch. + totalWeight = pendingTotalWeight; } // Calculate the start timestamp for the distribution (equivalent to `lastTimeButtonPressable()`). @@ -117,11 +122,9 @@ contract EmissionsController is // If length exceeds total distributions, set last index to total distributions (exclusive upper bound). if (lastIndex > totalDistributions) lastIndex = totalDistributions; - uint256 totalWeightBefore = totalWeight; - // Process distributions starting from the next one to process... - for (uint256 i = nextDistributionId; i < lastIndex; ++i) { - Distribution memory distribution = _distributions[i]; + for (uint256 distributionId = nextDistributionId; distributionId < lastIndex; ++distributionId) { + Distribution memory distribution = _distributions[distributionId]; // Skip disabled distributions... if (distribution.distributionType == DistributionType.Disabled) continue; @@ -130,7 +133,12 @@ contract EmissionsController is // Skip distributions that have ended... if (distribution.stopEpoch <= currentEpoch) continue; - _processDistribution(i, currentEpoch, startTimestamp, totalWeightBefore, distribution); + _processDistribution({ + distribution: distribution, + distributionId: distributionId, + currentEpoch: currentEpoch, + startTimestamp: startTimestamp + }); } // Update total processed count for this epoch. @@ -142,14 +150,13 @@ contract EmissionsController is /// @param currentEpoch The current epoch. /// @param distribution The distribution (from storage). function _processDistribution( + Distribution memory distribution, uint256 distributionId, uint256 currentEpoch, - uint256 startTimestamp, - uint256 totalWeightBefore, - Distribution memory distribution + uint256 startTimestamp ) internal { // Calculate the total amount of emissions for the distribution. - uint256 totalAmount = EMISSIONS_INFLATION_RATE * distribution.weight / totalWeightBefore; + uint256 totalAmount = EMISSIONS_INFLATION_RATE * distribution.weight / MAX_TOTAL_WEIGHT; // Success flag for the distribution. bool success; @@ -252,20 +259,20 @@ contract EmissionsController is revert CannotAddDisabledDistribution(); } - uint256 totalWeightBefore = totalWeight; + uint256 pendingTotalWeightBefore = pendingTotalWeight; // 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); + _checkDistribution(distribution, currentEpoch, pendingTotalWeightBefore); // Effects // Update return value to the next available distribution id. distributionId = getTotalDistributions(); - // Update the total weight. - totalWeight = totalWeightBefore + distribution.weight; + // Update the pending total weight (will be committed when pressButton starts a new epoch). + pendingTotalWeight = uint16(pendingTotalWeightBefore + distribution.weight); // Append the distribution to the distributions array. _distributions.push(distribution); // Emit an event for the new distribution. @@ -280,18 +287,18 @@ contract EmissionsController is // Checks uint256 currentEpoch = getCurrentEpoch(); - uint256 totalWeightBefore = totalWeight; + uint256 pendingTotalWeightBefore = pendingTotalWeight; uint256 weight = _distributions[distributionId].weight; // 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); + _checkDistribution(distribution, currentEpoch, pendingTotalWeightBefore - weight); // Effects - // Add the new weight. - totalWeight = totalWeightBefore - weight + distribution.weight; + // Update the pending total weight (will be committed when pressButton starts a new epoch). + pendingTotalWeight = uint16(pendingTotalWeightBefore - weight + distribution.weight); // Update the distribution in the distributions array. _distributions[distributionId] = distribution; // Emit an event for the updated distribution. diff --git a/src/contracts/core/storage/EmissionsControllerStorage.sol b/src/contracts/core/storage/EmissionsControllerStorage.sol index 062b82153a..3a895bab3b 100644 --- a/src/contracts/core/storage/EmissionsControllerStorage.sol +++ b/src/contracts/core/storage/EmissionsControllerStorage.sol @@ -33,10 +33,14 @@ abstract contract EmissionsControllerStorage is IEmissionsController { // Mutatables + // Slot 0 (Packed) + /// @inheritdoc IEmissionsController address public incentiveCouncil; /// @inheritdoc IEmissionsController - uint256 public totalWeight; + uint16 public totalWeight; + /// @inheritdoc IEmissionsController + uint16 public pendingTotalWeight; /// @dev Returns an append-only array of distributions. Distribution[] internal _distributions; @@ -67,5 +71,5 @@ abstract contract EmissionsControllerStorage is IEmissionsController { /// @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[46] private __gap; + uint256[47] private __gap; } diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index d6fb8f15cd..b129b961ad 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -27,6 +27,8 @@ interface IEmissionsControllerErrors { error RewardsSubmissionsCannotBeEmpty(); /// @dev Thrown when the operator set is not registered. error OperatorSetNotRegistered(); + /// @dev Thrown when emissions have not started yet. + error EmissionsNotStarted(); } /// @title IEmissionsControllerTypes @@ -239,9 +241,17 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// @return The Incentive Council address. function incentiveCouncil() external view returns (address); - /// @notice Returns the total weight of all distributions. + /// @notice Returns the total weight of all distributions for the current epoch. + /// @dev This weight is used to calculate distribution amounts in pressButton. + /// It is updated at the start of each new epoch from pendingTotalWeight. /// @return The total weight of all distributions. - function totalWeight() external view returns (uint256); + function totalWeight() external view returns (uint16); + + /// @notice Returns the pending total weight that will take effect in the next epoch. + /// @dev This weight is modified when distributions are added or updated. + /// It gets committed to totalWeight when pressButton starts a new epoch. + /// @return The pending total weight of all distributions. + function pendingTotalWeight() external view returns (uint16); /// @notice Returns the current epoch. /// @return The current epoch. From 900ca735af5fb7cfc527c6f1cae19a86868c382f Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:40:41 -0500 Subject: [PATCH 56/62] more coverage --- src/test/unit/EmissionsControllerUnit.t.sol | 591 ++++++++++++++++++++ 1 file changed, 591 insertions(+) diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 60bfd45712..608081743b 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -116,6 +116,493 @@ contract EmissionsControllerUnitTests_pressButton is EmissionsControllerUnitTest 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, + stopEpoch: 1, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Before emissions start, pressing button should revert + cheats.expectRevert(IEmissionsControllerErrors.EmissionsNotStarted.selector); + emissionsController.pressButton(1); + } + + function test_pressButton_CommitsPendingTotalWeightOnFirstCall() public { + // Add distribution with weight 6000 before emissions start + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 6000, + startEpoch: 0, + stopEpoch: 1, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Verify initial state: totalWeight = 0, pendingTotalWeight = 6000 + assertEq(emissionsController.totalWeight(), 0); + assertEq(emissionsController.pendingTotalWeight(), 6000); + + // Warp to emissions start (epoch 0) + cheats.warp(EMISSIONS_START_TIME); + + // Press button for the first time - should commit pendingTotalWeight to totalWeight + emissionsController.pressButton(1); + + // Verify totalWeight was committed from pendingTotalWeight + assertEq(emissionsController.totalWeight(), 6000); + assertEq(emissionsController.pendingTotalWeight(), 6000); + } + + function test_pressButton_TotalWeightStableWithinEpoch() public { + // Add first distribution with weight 6000 + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 6000, + startEpoch: 0, + stopEpoch: 2, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Warp to epoch 0 + cheats.warp(EMISSIONS_START_TIME); + + // Press button - commits weight to 6000 + emissionsController.pressButton(1); + assertEq(emissionsController.totalWeight(), 6000); + assertEq(emissionsController.pendingTotalWeight(), 6000); + + // Add another distribution during epoch 0 (for future epoch 1) + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 4000, + startEpoch: 1, + stopEpoch: 2, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // totalWeight should remain 6000, pendingTotalWeight should be 10000 + assertEq(emissionsController.totalWeight(), 6000); + assertEq(emissionsController.pendingTotalWeight(), 10_000); + + // Press button again in same epoch to process the new distribution + // The new distribution will be skipped (startEpoch: 1 > currentEpoch: 0) + // but totalWeight should remain stable (not re-committed) + emissionsController.pressButton(1); + + // totalWeight still stable at 6000, pendingTotalWeight still 10000 + assertEq(emissionsController.totalWeight(), 6000); + assertEq(emissionsController.pendingTotalWeight(), 10_000); + + // Now all distributions have been processed for epoch 0 + cheats.expectRevert(IEmissionsControllerErrors.AllDistributionsProcessed.selector); + emissionsController.pressButton(1); + } + + function test_pressButton_CommitsWeightOncePerEpoch() public { + // Add distribution with weight 6000 + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 6000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Add second distribution with weight 2000 + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 2000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Warp to epoch 0 + cheats.warp(EMISSIONS_START_TIME); + + // Press button with length=1 to process only first distribution + emissionsController.pressButton(1); + + // Weight should be committed to 8000 + assertEq(emissionsController.totalWeight(), 8000); + assertEq(emissionsController.pendingTotalWeight(), 8000); + + // Press button again to process second distribution + emissionsController.pressButton(1); + + // Weight should still be 8000 (not committed again) + assertEq(emissionsController.totalWeight(), 8000); + assertEq(emissionsController.pendingTotalWeight(), 8000); + } + + function test_pressButton_CommitsWeightCorrectlyAcrossEpochs() public { + // Add distribution with weight 6000 for epoch 0 + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 6000, + startEpoch: 0, + stopEpoch: 2, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // Warp to epoch 0 + cheats.warp(EMISSIONS_START_TIME); + + // Press button - commits weight to 6000 + emissionsController.pressButton(1); + assertEq(emissionsController.totalWeight(), 6000); + assertEq(emissionsController.pendingTotalWeight(), 6000); + + // Add another distribution during epoch 0 (for future epoch) + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 4000, + startEpoch: 1, + stopEpoch: 2, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // pendingTotalWeight should now be 10000 + assertEq(emissionsController.totalWeight(), 6000); + assertEq(emissionsController.pendingTotalWeight(), 10_000); + + // Warp to epoch 1 + cheats.warp(EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH); + + // Press button - should commit new weight (10000) + emissionsController.pressButton(2); + + // Both should now be 10000 + assertEq(emissionsController.totalWeight(), 10_000); + assertEq(emissionsController.pendingTotalWeight(), 10_000); + + // Update a distribution during epoch 1 (for future epoch) + cheats.prank(incentiveCouncil); + emissionsController.updateDistribution( + 0, + Distribution({ + weight: 3000, // Changed from 6000 to 3000 + startEpoch: 2, + stopEpoch: 3, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // pendingTotalWeight should be 7000 (10000 - 6000 + 3000) + assertEq(emissionsController.totalWeight(), 10_000); + assertEq(emissionsController.pendingTotalWeight(), 7000); + + // Warp to epoch 2 + cheats.warp(EMISSIONS_START_TIME + 2 * EMISSIONS_EPOCH_LENGTH); + + // Press button - should commit new weight (7000) + emissionsController.pressButton(2); + + // Both should now be 7000 + assertEq(emissionsController.totalWeight(), 7000); + assertEq(emissionsController.pendingTotalWeight(), 7000); + } + + function test_pressButton_WeightCommitmentWithZeroInitialWeight() public { + // Don't add any distributions initially + assertEq(emissionsController.totalWeight(), 0); + assertEq(emissionsController.pendingTotalWeight(), 0); + + // Warp to epoch 0 - can't press button with no distributions + cheats.warp(EMISSIONS_START_TIME); + cheats.expectRevert(IEmissionsControllerErrors.AllDistributionsProcessed.selector); + emissionsController.pressButton(0); + + // Still both 0 + assertEq(emissionsController.totalWeight(), 0); + assertEq(emissionsController.pendingTotalWeight(), 0); + + // Add distribution during epoch 0 (for future epoch 1) + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 5000, + startEpoch: 1, + stopEpoch: 2, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // pendingTotalWeight updated, totalWeight remains 0 + assertEq(emissionsController.totalWeight(), 0); + assertEq(emissionsController.pendingTotalWeight(), 5000); + + // Warp to epoch 1 + cheats.warp(EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH); + + // Press button - should commit weight + emissionsController.pressButton(1); + + // Both should now be 5000 + assertEq(emissionsController.totalWeight(), 5000); + assertEq(emissionsController.pendingTotalWeight(), 5000); + } +} + +contract EmissionsControllerUnitTests_sweep is EmissionsControllerUnitTests { + function test_sweep_TransfersTokensToIncentiveCouncil() public { + // Add distribution + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 10_000, + startEpoch: 0, + stopEpoch: 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, + stopEpoch: 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, + stopEpoch: 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, + stopEpoch: 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, + stopEpoch: 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, + stopEpoch: 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, + stopEpoch: 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, + stopEpoch: 1, + distributionType: DistributionType.OperatorSetTotalStake, + operatorSet: unregisteredOpSet, + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + } } /// ----------------------------------------------------------------------- @@ -472,7 +959,76 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU // Verify the update succeeded Distribution memory updated = emissionsController.getDistribution(distributionId2); assertEq(updated.weight, 4000); + // After add/update, pendingTotalWeight is updated, not totalWeight + // totalWeight is only updated when pressButton starts a new epoch + assertEq(emissionsController.pendingTotalWeight(), 10_000); + } + + function test_weightCommitment_OnlyUpdatedWhenPressingButton() public { + // Initially both weights should be 0 + assertEq(emissionsController.totalWeight(), 0); + assertEq(emissionsController.pendingTotalWeight(), 0); + + // Add first distribution with weight 6000 + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 6000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // pendingTotalWeight should be updated, but totalWeight should remain 0 + assertEq(emissionsController.totalWeight(), 0); + assertEq(emissionsController.pendingTotalWeight(), 6000); + + // Add second distribution with weight 4000 + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 4000, + startEpoch: 0, + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // pendingTotalWeight should be updated to 10000, totalWeight still 0 + assertEq(emissionsController.totalWeight(), 0); + assertEq(emissionsController.pendingTotalWeight(), 10_000); + + // Warp to emissions start time (epoch 0) + cheats.warp(EMISSIONS_START_TIME); + + // Press button for the first time - this should commit pendingTotalWeight to totalWeight + emissionsController.pressButton(2); + + // Now both should be 10000 + assertEq(emissionsController.totalWeight(), 10_000); + assertEq(emissionsController.pendingTotalWeight(), 10_000); + + // Add another distribution in the same epoch with weight 0 (to test mid-epoch changes) + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( + Distribution({ + weight: 0, + startEpoch: 1, // Future epoch + stopEpoch: 0, + distributionType: DistributionType.RewardsForAllEarners, + operatorSet: emptyOperatorSet(), + strategiesAndMultipliers: defaultStrategiesAndMultipliers() + }) + ); + + // totalWeight should remain 10000, pendingTotalWeight unchanged (weight is 0) assertEq(emissionsController.totalWeight(), 10_000); + assertEq(emissionsController.pendingTotalWeight(), 10_000); } } @@ -590,6 +1146,41 @@ contract EmissionsControllerUnitTests_nextTimeButtonPressable is EmissionsContro } } +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); From 3ab690ed80f7c6a7d5f62790d368656f786ca2e9 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:23:03 -0500 Subject: [PATCH 57/62] remove pending total weight in favor or restricting adds/updates until processed --- src/contracts/core/EmissionsController.sol | 59 ++- .../storage/EmissionsControllerStorage.sol | 2 - .../interfaces/IEmissionsController.sol | 16 +- src/test/unit/EmissionsControllerUnit.t.sol | 417 ++---------------- 4 files changed, 94 insertions(+), 400 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 0e5f861980..b768973673 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -77,8 +77,6 @@ contract EmissionsController is uint256 length ) external override nonReentrant onlyWhenNotPaused(PAUSED_TOKEN_FLOWS) { uint256 currentEpoch = getCurrentEpoch(); - uint256 totalDistributions = getTotalDistributions(); - uint256 nextDistributionId = _epochs[currentEpoch].totalProcessed; // Check if emissions have not started yet. // Prevents minting EIGEN before the first epoch has started. @@ -86,6 +84,9 @@ contract EmissionsController is revert EmissionsNotStarted(); } + uint256 totalDistributions = getTotalDistributions(); + uint256 nextDistributionId = _epochs[currentEpoch].totalProcessed; + // Check if all distributions have already been processed. if (nextDistributionId >= totalDistributions) { revert AllDistributionsProcessed(); @@ -107,11 +108,6 @@ contract EmissionsController is // Mark the epoch as minted. _epochs[currentEpoch].minted = true; - - // Commit pending weight changes to totalWeight for the new epoch. - // This ensures that weight modifications made during the previous epoch - // (via addDistribution/updateDistribution) take effect starting from this epoch. - totalWeight = pendingTotalWeight; } // Calculate the start timestamp for the distribution (equivalent to `lastTimeButtonPressable()`). @@ -142,7 +138,7 @@ contract EmissionsController is } // Update total processed count for this epoch. - _epochs[currentEpoch].totalProcessed = uint248(lastIndex); + _epochs[currentEpoch].totalProcessed = uint64(lastIndex); } /// @dev Internal helper that processes a distribution. @@ -259,20 +255,28 @@ contract EmissionsController is revert CannotAddDisabledDistribution(); } - uint256 pendingTotalWeightBefore = pendingTotalWeight; + // 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, pendingTotalWeightBefore); + _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. - distributionId = getTotalDistributions(); + // Use the current length of the distributions array as the new id. + distributionId = _distributions.length; - // Update the pending total weight (will be committed when pressButton starts a new epoch). - pendingTotalWeight = uint16(pendingTotalWeightBefore + distribution.weight); + // 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. @@ -287,18 +291,22 @@ contract EmissionsController is // Checks uint256 currentEpoch = getCurrentEpoch(); - uint256 pendingTotalWeightBefore = pendingTotalWeight; + 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, pendingTotalWeightBefore - weight); + _checkDistribution(distribution, currentEpoch, totalWeightBefore - weight); // Effects // Update the pending total weight (will be committed when pressButton starts a new epoch). - pendingTotalWeight = uint16(pendingTotalWeightBefore - weight + distribution.weight); + totalWeight = uint16(totalWeightBefore - weight + distribution.weight); // Update the distribution in the distributions array. _distributions[distributionId] = distribution; // Emit an event for the updated distribution. @@ -314,6 +322,17 @@ contract EmissionsController is if (msg.sender != incentiveCouncil) revert 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. + if (currentEpoch != type(uint256).max && _epochs[currentEpoch].totalProcessed < getTotalDistributions()) { + revert NotAllDistributionsProcessed(); + } + } + function _checkDistribution( Distribution calldata distribution, uint256 currentEpoch, @@ -383,7 +402,13 @@ contract EmissionsController is /// @inheritdoc IEmissionsController function getTotalDistributions() public view returns (uint256) { - return _distributions.length; + 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 diff --git a/src/contracts/core/storage/EmissionsControllerStorage.sol b/src/contracts/core/storage/EmissionsControllerStorage.sol index 3a895bab3b..f556f0886a 100644 --- a/src/contracts/core/storage/EmissionsControllerStorage.sol +++ b/src/contracts/core/storage/EmissionsControllerStorage.sol @@ -39,8 +39,6 @@ abstract contract EmissionsControllerStorage is IEmissionsController { address public incentiveCouncil; /// @inheritdoc IEmissionsController uint16 public totalWeight; - /// @inheritdoc IEmissionsController - uint16 public pendingTotalWeight; /// @dev Returns an append-only array of distributions. Distribution[] internal _distributions; diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index b129b961ad..fdbcdb74b6 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -21,6 +21,8 @@ interface IEmissionsControllerErrors { error CannotDisableDistributionViaUpdate(); /// @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. @@ -56,7 +58,9 @@ interface IEmissionsControllerTypes { /// Whether the epoch has been minted. bool minted; /// The total number of distributions processed for the epoch. - uint248 totalProcessed; + 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. @@ -241,18 +245,10 @@ interface IEmissionsController is IEmissionsControllerErrors, IEmissionsControll /// @return The Incentive Council address. function incentiveCouncil() external view returns (address); - /// @notice Returns the total weight of all distributions for the current epoch. - /// @dev This weight is used to calculate distribution amounts in pressButton. - /// It is updated at the start of each new epoch from pendingTotalWeight. + /// @notice Returns the total weight of all distributions. /// @return The total weight of all distributions. function totalWeight() external view returns (uint16); - /// @notice Returns the pending total weight that will take effect in the next epoch. - /// @dev This weight is modified when distributions are added or updated. - /// It gets committed to totalWeight when pressButton starts a new epoch. - /// @return The pending total weight of all distributions. - function pendingTotalWeight() external view returns (uint16); - /// @notice Returns the current epoch. /// @return The current epoch. function getCurrentEpoch() external view returns (uint256); diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 608081743b..34162a08ca 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -135,253 +135,6 @@ contract EmissionsControllerUnitTests_pressButton is EmissionsControllerUnitTest cheats.expectRevert(IEmissionsControllerErrors.EmissionsNotStarted.selector); emissionsController.pressButton(1); } - - function test_pressButton_CommitsPendingTotalWeightOnFirstCall() public { - // Add distribution with weight 6000 before emissions start - cheats.prank(incentiveCouncil); - emissionsController.addDistribution( - Distribution({ - weight: 6000, - startEpoch: 0, - stopEpoch: 1, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - // Verify initial state: totalWeight = 0, pendingTotalWeight = 6000 - assertEq(emissionsController.totalWeight(), 0); - assertEq(emissionsController.pendingTotalWeight(), 6000); - - // Warp to emissions start (epoch 0) - cheats.warp(EMISSIONS_START_TIME); - - // Press button for the first time - should commit pendingTotalWeight to totalWeight - emissionsController.pressButton(1); - - // Verify totalWeight was committed from pendingTotalWeight - assertEq(emissionsController.totalWeight(), 6000); - assertEq(emissionsController.pendingTotalWeight(), 6000); - } - - function test_pressButton_TotalWeightStableWithinEpoch() public { - // Add first distribution with weight 6000 - cheats.prank(incentiveCouncil); - emissionsController.addDistribution( - Distribution({ - weight: 6000, - startEpoch: 0, - stopEpoch: 2, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - // Warp to epoch 0 - cheats.warp(EMISSIONS_START_TIME); - - // Press button - commits weight to 6000 - emissionsController.pressButton(1); - assertEq(emissionsController.totalWeight(), 6000); - assertEq(emissionsController.pendingTotalWeight(), 6000); - - // Add another distribution during epoch 0 (for future epoch 1) - cheats.prank(incentiveCouncil); - emissionsController.addDistribution( - Distribution({ - weight: 4000, - startEpoch: 1, - stopEpoch: 2, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - // totalWeight should remain 6000, pendingTotalWeight should be 10000 - assertEq(emissionsController.totalWeight(), 6000); - assertEq(emissionsController.pendingTotalWeight(), 10_000); - - // Press button again in same epoch to process the new distribution - // The new distribution will be skipped (startEpoch: 1 > currentEpoch: 0) - // but totalWeight should remain stable (not re-committed) - emissionsController.pressButton(1); - - // totalWeight still stable at 6000, pendingTotalWeight still 10000 - assertEq(emissionsController.totalWeight(), 6000); - assertEq(emissionsController.pendingTotalWeight(), 10_000); - - // Now all distributions have been processed for epoch 0 - cheats.expectRevert(IEmissionsControllerErrors.AllDistributionsProcessed.selector); - emissionsController.pressButton(1); - } - - function test_pressButton_CommitsWeightOncePerEpoch() public { - // Add distribution with weight 6000 - cheats.prank(incentiveCouncil); - emissionsController.addDistribution( - Distribution({ - weight: 6000, - startEpoch: 0, - stopEpoch: 0, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - // Add second distribution with weight 2000 - cheats.prank(incentiveCouncil); - emissionsController.addDistribution( - Distribution({ - weight: 2000, - startEpoch: 0, - stopEpoch: 0, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - // Warp to epoch 0 - cheats.warp(EMISSIONS_START_TIME); - - // Press button with length=1 to process only first distribution - emissionsController.pressButton(1); - - // Weight should be committed to 8000 - assertEq(emissionsController.totalWeight(), 8000); - assertEq(emissionsController.pendingTotalWeight(), 8000); - - // Press button again to process second distribution - emissionsController.pressButton(1); - - // Weight should still be 8000 (not committed again) - assertEq(emissionsController.totalWeight(), 8000); - assertEq(emissionsController.pendingTotalWeight(), 8000); - } - - function test_pressButton_CommitsWeightCorrectlyAcrossEpochs() public { - // Add distribution with weight 6000 for epoch 0 - cheats.prank(incentiveCouncil); - emissionsController.addDistribution( - Distribution({ - weight: 6000, - startEpoch: 0, - stopEpoch: 2, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - // Warp to epoch 0 - cheats.warp(EMISSIONS_START_TIME); - - // Press button - commits weight to 6000 - emissionsController.pressButton(1); - assertEq(emissionsController.totalWeight(), 6000); - assertEq(emissionsController.pendingTotalWeight(), 6000); - - // Add another distribution during epoch 0 (for future epoch) - cheats.prank(incentiveCouncil); - emissionsController.addDistribution( - Distribution({ - weight: 4000, - startEpoch: 1, - stopEpoch: 2, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - // pendingTotalWeight should now be 10000 - assertEq(emissionsController.totalWeight(), 6000); - assertEq(emissionsController.pendingTotalWeight(), 10_000); - - // Warp to epoch 1 - cheats.warp(EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH); - - // Press button - should commit new weight (10000) - emissionsController.pressButton(2); - - // Both should now be 10000 - assertEq(emissionsController.totalWeight(), 10_000); - assertEq(emissionsController.pendingTotalWeight(), 10_000); - - // Update a distribution during epoch 1 (for future epoch) - cheats.prank(incentiveCouncil); - emissionsController.updateDistribution( - 0, - Distribution({ - weight: 3000, // Changed from 6000 to 3000 - startEpoch: 2, - stopEpoch: 3, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - // pendingTotalWeight should be 7000 (10000 - 6000 + 3000) - assertEq(emissionsController.totalWeight(), 10_000); - assertEq(emissionsController.pendingTotalWeight(), 7000); - - // Warp to epoch 2 - cheats.warp(EMISSIONS_START_TIME + 2 * EMISSIONS_EPOCH_LENGTH); - - // Press button - should commit new weight (7000) - emissionsController.pressButton(2); - - // Both should now be 7000 - assertEq(emissionsController.totalWeight(), 7000); - assertEq(emissionsController.pendingTotalWeight(), 7000); - } - - function test_pressButton_WeightCommitmentWithZeroInitialWeight() public { - // Don't add any distributions initially - assertEq(emissionsController.totalWeight(), 0); - assertEq(emissionsController.pendingTotalWeight(), 0); - - // Warp to epoch 0 - can't press button with no distributions - cheats.warp(EMISSIONS_START_TIME); - cheats.expectRevert(IEmissionsControllerErrors.AllDistributionsProcessed.selector); - emissionsController.pressButton(0); - - // Still both 0 - assertEq(emissionsController.totalWeight(), 0); - assertEq(emissionsController.pendingTotalWeight(), 0); - - // Add distribution during epoch 0 (for future epoch 1) - cheats.prank(incentiveCouncil); - emissionsController.addDistribution( - Distribution({ - weight: 5000, - startEpoch: 1, - stopEpoch: 2, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - // pendingTotalWeight updated, totalWeight remains 0 - assertEq(emissionsController.totalWeight(), 0); - assertEq(emissionsController.pendingTotalWeight(), 5000); - - // Warp to epoch 1 - cheats.warp(EMISSIONS_START_TIME + EMISSIONS_EPOCH_LENGTH); - - // Press button - should commit weight - emissionsController.pressButton(1); - - // Both should now be 5000 - assertEq(emissionsController.totalWeight(), 5000); - assertEq(emissionsController.pendingTotalWeight(), 5000); - } } contract EmissionsControllerUnitTests_sweep is EmissionsControllerUnitTests { @@ -772,46 +525,51 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit assertEq(distribution.weight, weight); assertEq(uint8(distribution.distributionType), uint8(distributionType)); } -} -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, + function test_revert_addDistribution_NotAllDistributionsProcessed() public { + // Add first distribution before emissions start + cheats.prank(incentiveCouncil); + emissionsController.addDistribution( Distribution({ - weight: 10_000, + weight: 5000, startEpoch: 0, - stopEpoch: 0, + stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); - } - function test_revert_updateDistribution_NonExistentDistribution() public { + // 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(stdError.indexOOBError); // team may want an explicit check for this - emissionsController.updateDistribution( - 0, + cheats.expectRevert(IEmissionsControllerErrors.NotAllDistributionsProcessed.selector); + emissionsController.addDistribution( Distribution({ - weight: 10_000, - startEpoch: 0, - stopEpoch: 0, + weight: 3000, + startEpoch: 1, + stopEpoch: 2, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); } +} - function test_revert_updateDistribution_StartEpochMustBeInTheFuture() public { - cheats.prank(incentiveCouncil); - uint distributionId = emissionsController.addDistribution( +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, @@ -821,12 +579,13 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); + } - cheats.warp(EMISSIONS_START_TIME); + function test_revert_updateDistribution_NonExistentDistribution() public { cheats.prank(incentiveCouncil); - cheats.expectRevert(IEmissionsControllerErrors.StartEpochMustBeInTheFuture.selector); + cheats.expectRevert(stdError.indexOOBError); // team may want an explicit check for this emissionsController.updateDistribution( - distributionId, + 0, Distribution({ weight: 10_000, startEpoch: 0, @@ -911,124 +670,40 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU ); } - // Regression test for bug where _checkDistribution was called after updating totalWeight, - // causing valid weight updates to incorrectly revert due to double-counting the new weight - function test_updateDistribution_DoesNotDoubleCountNewWeight() public { - // Add first distribution with weight 6000 - cheats.prank(incentiveCouncil); - uint distributionId1 = emissionsController.addDistribution( - Distribution({ - weight: 6000, - startEpoch: 0, - stopEpoch: 0, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - // Add second distribution with weight 1000 (total = 7000) - cheats.prank(incentiveCouncil); - uint distributionId2 = emissionsController.addDistribution( - Distribution({ - weight: 1000, - startEpoch: 0, - stopEpoch: 0, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - // Update second distribution to weight 4000 (total should be 10000, which is valid) - // Without the fix, this would incorrectly check: 4000 + (7000 - 1000 + 4000) = 14000 > 10000 - // With the fix, this correctly checks: 4000 + (7000 - 1000) = 10000 <= 10000 + function test_revert_updateDistribution_NotAllDistributionsProcessed() public { + // Add first distribution before emissions start cheats.prank(incentiveCouncil); - emissionsController.updateDistribution( - distributionId2, - Distribution({ - weight: 4000, - startEpoch: 0, - stopEpoch: 0, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - // Verify the update succeeded - Distribution memory updated = emissionsController.getDistribution(distributionId2); - assertEq(updated.weight, 4000); - // After add/update, pendingTotalWeight is updated, not totalWeight - // totalWeight is only updated when pressButton starts a new epoch - assertEq(emissionsController.pendingTotalWeight(), 10_000); - } - - function test_weightCommitment_OnlyUpdatedWhenPressingButton() public { - // Initially both weights should be 0 - assertEq(emissionsController.totalWeight(), 0); - assertEq(emissionsController.pendingTotalWeight(), 0); - - // Add first distribution with weight 6000 - cheats.prank(incentiveCouncil); - emissionsController.addDistribution( - Distribution({ - weight: 6000, - startEpoch: 0, - stopEpoch: 0, - distributionType: DistributionType.RewardsForAllEarners, - operatorSet: emptyOperatorSet(), - strategiesAndMultipliers: defaultStrategiesAndMultipliers() - }) - ); - - // pendingTotalWeight should be updated, but totalWeight should remain 0 - assertEq(emissionsController.totalWeight(), 0); - assertEq(emissionsController.pendingTotalWeight(), 6000); - - // Add second distribution with weight 4000 - cheats.prank(incentiveCouncil); - emissionsController.addDistribution( + uint distributionId = emissionsController.addDistribution( Distribution({ - weight: 4000, + weight: 5000, startEpoch: 0, - stopEpoch: 0, + stopEpoch: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); - // pendingTotalWeight should be updated to 10000, totalWeight still 0 - assertEq(emissionsController.totalWeight(), 0); - assertEq(emissionsController.pendingTotalWeight(), 10_000); - - // Warp to emissions start time (epoch 0) + // Warp to after emissions start (epoch 0) cheats.warp(EMISSIONS_START_TIME); - // Press button for the first time - this should commit pendingTotalWeight to totalWeight - emissionsController.pressButton(2); - - // Now both should be 10000 - assertEq(emissionsController.totalWeight(), 10_000); - assertEq(emissionsController.pendingTotalWeight(), 10_000); + // Now there's 1 distribution but 0 processed, button is pressable + assertTrue(emissionsController.isButtonPressable()); - // Add another distribution in the same epoch with weight 0 (to test mid-epoch changes) + // Attempt to update the distribution before processing it should revert cheats.prank(incentiveCouncil); - emissionsController.addDistribution( + cheats.expectRevert(IEmissionsControllerErrors.NotAllDistributionsProcessed.selector); + emissionsController.updateDistribution( + distributionId, Distribution({ - weight: 0, - startEpoch: 1, // Future epoch - stopEpoch: 0, + weight: 3000, + startEpoch: 1, + stopEpoch: 2, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() }) ); - - // totalWeight should remain 10000, pendingTotalWeight unchanged (weight is 0) - assertEq(emissionsController.totalWeight(), 10_000); - assertEq(emissionsController.pendingTotalWeight(), 10_000); } } From 1a6e45598aab5849fd00e601b57b323d4d330eba Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:11:05 -0500 Subject: [PATCH 58/62] stopEpoch -> totalEpochs better ux, simpler checks --- src/contracts/core/EmissionsController.sol | 6 +- .../interfaces/IEmissionsController.sol | 4 +- src/test/unit/EmissionsControllerUnit.t.sol | 108 +++++++++--------- 3 files changed, 60 insertions(+), 58 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index b768973673..15d84aaa91 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -126,8 +126,10 @@ contract EmissionsController is if (distribution.distributionType == DistributionType.Disabled) continue; // Skip distributions that haven't started yet... if (distribution.startEpoch > currentEpoch) continue; - // Skip distributions that have ended... - if (distribution.stopEpoch <= currentEpoch) continue; + // Skip distributions that have ended (0 means infinite)... + if (distribution.totalEpochs != 0) { + if (currentEpoch > distribution.startEpoch + distribution.totalEpochs) continue; + } _processDistribution({ distribution: distribution, diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index fdbcdb74b6..b6e67206bf 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -72,8 +72,8 @@ interface IEmissionsControllerTypes { uint64 weight; /// The configured start epoch. uint64 startEpoch; - /// The number of epochs to repeat the distribution (0 for infinite). - uint64 stopEpoch; + /// 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 operator set (Required only for OperatorSetTotalStake and OperatorSetUniqueStake distribution types). diff --git a/src/test/unit/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 34162a08ca..1abd924e2f 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -99,7 +99,7 @@ contract EmissionsControllerUnitTests_pressButton is EmissionsControllerUnitTest Distribution({ weight: 10_000, startEpoch: 0, - stopEpoch: 1, + totalEpochs: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -124,7 +124,7 @@ contract EmissionsControllerUnitTests_pressButton is EmissionsControllerUnitTest Distribution({ weight: 10_000, startEpoch: 0, - stopEpoch: 1, + totalEpochs: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -145,7 +145,7 @@ contract EmissionsControllerUnitTests_sweep is EmissionsControllerUnitTests { Distribution({ weight: 10_000, startEpoch: 0, - stopEpoch: 1, + totalEpochs: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -181,7 +181,7 @@ contract EmissionsControllerUnitTests_sweep is EmissionsControllerUnitTests { Distribution({ weight: 10_000, startEpoch: 0, - stopEpoch: 1, + totalEpochs: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -233,7 +233,7 @@ contract EmissionsControllerUnitTests_pressButton_DistributionTypes is Emissions Distribution({ weight: 10_000, startEpoch: 0, - stopEpoch: 1, + totalEpochs: 1, distributionType: DistributionType.OperatorSetUniqueStake, operatorSet: testOperatorSet, strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -258,7 +258,7 @@ contract EmissionsControllerUnitTests_pressButton_DistributionTypes is Emissions Distribution({ weight: 10_000, startEpoch: 0, - stopEpoch: 1, + totalEpochs: 1, distributionType: DistributionType.OperatorSetTotalStake, operatorSet: testOperatorSet, strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -279,7 +279,7 @@ contract EmissionsControllerUnitTests_pressButton_DistributionTypes is Emissions Distribution({ weight: 10_000, startEpoch: 0, - stopEpoch: 1, + totalEpochs: 1, distributionType: DistributionType.EigenDA, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -300,7 +300,7 @@ contract EmissionsControllerUnitTests_pressButton_DistributionTypes is Emissions Distribution({ weight: 10_000, startEpoch: 0, - stopEpoch: 1, + totalEpochs: 1, distributionType: DistributionType.Manual, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: emptyStrategiesAndMultipliers() // Manual doesn't need submissions @@ -329,7 +329,7 @@ contract EmissionsControllerUnitTests_pressButton_DistributionTypes is Emissions Distribution({ weight: 10_000, startEpoch: 0, - stopEpoch: 1, + totalEpochs: 1, distributionType: DistributionType.OperatorSetUniqueStake, operatorSet: unregisteredOpSet, strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -349,7 +349,7 @@ contract EmissionsControllerUnitTests_pressButton_DistributionTypes is Emissions Distribution({ weight: 10_000, startEpoch: 0, - stopEpoch: 1, + totalEpochs: 1, distributionType: DistributionType.OperatorSetTotalStake, operatorSet: unregisteredOpSet, strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -392,7 +392,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit Distribution({ weight: 10_000, startEpoch: 0, - stopEpoch: 0, + totalEpochs: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -407,7 +407,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit Distribution({ weight: 10_000, startEpoch: 0, - stopEpoch: 0, + totalEpochs: 0, distributionType: DistributionType.Disabled, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -422,7 +422,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit Distribution({ weight: 10_000, startEpoch: 0, - stopEpoch: 0, + totalEpochs: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: emptyStrategiesAndMultipliers() @@ -438,7 +438,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit Distribution({ weight: 10_000, startEpoch: 0, - stopEpoch: 0, + totalEpochs: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -454,7 +454,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit Distribution({ weight: uint64(weight), startEpoch: 0, - stopEpoch: 0, + totalEpochs: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -469,7 +469,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit Distribution({ weight: 6000, startEpoch: 0, - stopEpoch: 0, + totalEpochs: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -483,7 +483,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit Distribution({ weight: 5000, startEpoch: 0, - stopEpoch: 0, + totalEpochs: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -506,7 +506,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit Distribution memory addedDistribution = Distribution({ weight: uint64(weight), startEpoch: 0, - stopEpoch: 0, + totalEpochs: 0, distributionType: distributionType, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: strategiesAndMultipliers @@ -533,7 +533,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit Distribution({ weight: 5000, startEpoch: 0, - stopEpoch: 1, + totalEpochs: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -553,7 +553,7 @@ contract EmissionsControllerUnitTests_addDistribution is EmissionsControllerUnit Distribution({ weight: 3000, startEpoch: 1, - stopEpoch: 2, + totalEpochs: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -573,7 +573,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU Distribution({ weight: 10_000, startEpoch: 0, - stopEpoch: 0, + totalEpochs: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -589,7 +589,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU Distribution({ weight: 10_000, startEpoch: 0, - stopEpoch: 0, + totalEpochs: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -603,7 +603,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU Distribution({ weight: 5000, startEpoch: 0, - stopEpoch: 0, + totalEpochs: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -617,7 +617,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU Distribution({ weight: 5000, startEpoch: 0, - stopEpoch: 0, + totalEpochs: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: emptyStrategiesAndMultipliers() @@ -634,7 +634,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU Distribution({ weight: 6000, startEpoch: 0, - stopEpoch: 0, + totalEpochs: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -647,7 +647,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU Distribution({ weight: 3000, startEpoch: 0, - stopEpoch: 0, + totalEpochs: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -662,7 +662,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU Distribution({ weight: 5000, startEpoch: 0, - stopEpoch: 0, + totalEpochs: 0, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -677,7 +677,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU Distribution({ weight: 5000, startEpoch: 0, - stopEpoch: 1, + totalEpochs: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -698,7 +698,7 @@ contract EmissionsControllerUnitTests_updateDistribution is EmissionsControllerU Distribution({ weight: 3000, startEpoch: 1, - stopEpoch: 2, + totalEpochs: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -749,7 +749,7 @@ contract EmissionsControllerUnitTests_isButtonPressable is EmissionsControllerUn Distribution({ weight: 5000, startEpoch: 0, - stopEpoch: 1, + totalEpochs: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -768,7 +768,7 @@ contract EmissionsControllerUnitTests_isButtonPressable is EmissionsControllerUn Distribution({ weight: 5000, startEpoch: 0, - stopEpoch: 1, + totalEpochs: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -868,7 +868,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll Distribution({ weight: 3000, startEpoch: 0, - stopEpoch: 1, + totalEpochs: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -882,7 +882,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll Distribution({ weight: 2000, startEpoch: 0, - stopEpoch: 1, + totalEpochs: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -900,7 +900,7 @@ contract EmissionsControllerUnitTests_getTotalDistributions is EmissionsControll Distribution({ weight: 100, startEpoch: 0, - stopEpoch: 1, + totalEpochs: 1, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -925,7 +925,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit Distribution({ weight: 5000, startEpoch: 0, - stopEpoch: 10, + totalEpochs: 10, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -936,7 +936,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit Distribution memory retrieved = emissionsController.getDistribution(distributionId); assertEq(retrieved.weight, 5000); assertEq(retrieved.startEpoch, 0); - assertEq(retrieved.stopEpoch, 10); + assertEq(retrieved.totalEpochs, 10); assertEq(uint8(retrieved.distributionType), uint8(DistributionType.RewardsForAllEarners)); } @@ -949,7 +949,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit Distribution({ weight: 3000, startEpoch: 0, - stopEpoch: 5, + totalEpochs: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -961,7 +961,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit Distribution({ weight: 4000, startEpoch: 1, - stopEpoch: 8, + totalEpochs: 7, distributionType: DistributionType.OperatorSetTotalStake, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -972,14 +972,14 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit Distribution memory retrieved0 = emissionsController.getDistribution(distributionId0); assertEq(retrieved0.weight, 3000); assertEq(retrieved0.startEpoch, 0); - assertEq(retrieved0.stopEpoch, 5); + 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.stopEpoch, 8); + assertEq(retrieved1.totalEpochs, 7); assertEq(uint8(retrieved1.distributionType), uint8(DistributionType.OperatorSetTotalStake)); } @@ -992,7 +992,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit Distribution({ weight: 3000, startEpoch: 0, - stopEpoch: 5, + totalEpochs: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -1006,7 +1006,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit Distribution({ weight: 4000, startEpoch: 2, - stopEpoch: 15, + totalEpochs: 13, distributionType: DistributionType.OperatorSetUniqueStake, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -1017,7 +1017,7 @@ contract EmissionsControllerUnitTests_getDistribution is EmissionsControllerUnit Distribution memory retrieved = emissionsController.getDistribution(distributionId); assertEq(retrieved.weight, 4000); assertEq(retrieved.startEpoch, 2); - assertEq(retrieved.stopEpoch, 15); + assertEq(retrieved.totalEpochs, 13); assertEq(uint8(retrieved.distributionType), uint8(DistributionType.OperatorSetUniqueStake)); } } @@ -1030,7 +1030,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni Distribution({ weight: 3000, startEpoch: 0, - stopEpoch: 5, + totalEpochs: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -1042,7 +1042,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni Distribution({ weight: 2000, startEpoch: 0, - stopEpoch: 5, + totalEpochs: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -1066,7 +1066,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni Distribution({ weight: 3000, startEpoch: 0, - stopEpoch: 5, + totalEpochs: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -1078,7 +1078,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni Distribution({ weight: 4000, startEpoch: 1, - stopEpoch: 8, + totalEpochs: 7, distributionType: DistributionType.OperatorSetTotalStake, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -1090,7 +1090,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni Distribution({ weight: 2000, startEpoch: 2, - stopEpoch: 10, + totalEpochs: 8, distributionType: DistributionType.OperatorSetUniqueStake, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -1114,7 +1114,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni Distribution({ weight: uint64(100 * (i + 1)), startEpoch: 0, - stopEpoch: 5, + totalEpochs: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -1138,7 +1138,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni Distribution({ weight: 3000, startEpoch: 0, - stopEpoch: 5, + totalEpochs: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -1159,7 +1159,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni Distribution({ weight: 3000, startEpoch: 0, - stopEpoch: 5, + totalEpochs: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -1171,7 +1171,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni Distribution({ weight: 4000, startEpoch: 1, - stopEpoch: 8, + totalEpochs: 7, distributionType: DistributionType.OperatorSetTotalStake, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -1203,7 +1203,7 @@ contract EmissionsControllerUnitTests_getDistributions is EmissionsControllerUni Distribution({ weight: uint64(100 * (i + 1)), startEpoch: 0, - stopEpoch: 5, + totalEpochs: 5, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() @@ -1235,7 +1235,7 @@ contract EmissionsControllerUnitTests_Pausable is EmissionsControllerUnitTests { Distribution({ weight: 5000, startEpoch: 0, - stopEpoch: 10, + totalEpochs: 10, distributionType: DistributionType.RewardsForAllEarners, operatorSet: emptyOperatorSet(), strategiesAndMultipliers: defaultStrategiesAndMultipliers() From 90fe467f99533584dcf72202d129c31a3ee973f1 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:39:29 -0500 Subject: [PATCH 59/62] prevent reverts when processing --- src/contracts/core/EmissionsController.sol | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 15d84aaa91..968aa25b34 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -158,12 +158,21 @@ contract EmissionsController is // 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 / distribution.strategiesAndMultipliers.length; + uint256 amountPerSubmission = totalAmount / strategiesAndMultipliersLength; + // Update the rewards submissions start timestamp, duration, and amount. IRewardsCoordinator.RewardsSubmission[] memory rewardsSubmissions = - new IRewardsCoordinator.RewardsSubmission[](distribution.strategiesAndMultipliers.length); + new IRewardsCoordinator.RewardsSubmission[](strategiesAndMultipliersLength); for (uint256 i = 0; i < rewardsSubmissions.length; ++i) { rewardsSubmissions[i] = IRewardsCoordinatorTypes.RewardsSubmission({ strategiesAndMultipliers: distribution.strategiesAndMultipliers[i], From 9be8bddcbe3565b89bd13ed4c8a5553c811e5d63 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:40:43 -0500 Subject: [PATCH 60/62] unused error --- src/contracts/interfaces/IEmissionsController.sol | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index b6e67206bf..39ebdc014a 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -17,8 +17,6 @@ interface IEmissionsControllerErrors { error TotalWeightExceedsMax(); /// @dev Thrown when attempting to add a disabled distribution. error CannotAddDisabledDistribution(); - /// @dev Thrown when attempting to update a disabled distribution. - error CannotDisableDistributionViaUpdate(); /// @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. From ee43371cc0b924b036c7c8511d88303a71a7cc6a Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:54:47 -0500 Subject: [PATCH 61/62] setup check --- .../core/storage/EmissionsControllerStorage.sol | 11 +++++++++++ src/contracts/interfaces/IEmissionsController.sol | 4 ++++ src/test/mocks/RewardsCoordinatorMock.sol | 8 ++++++++ src/test/unit/EmissionsControllerUnit.t.sol | 4 +++- 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/contracts/core/storage/EmissionsControllerStorage.sol b/src/contracts/core/storage/EmissionsControllerStorage.sol index f556f0886a..275f1ba00b 100644 --- a/src/contracts/core/storage/EmissionsControllerStorage.sol +++ b/src/contracts/core/storage/EmissionsControllerStorage.sol @@ -56,6 +56,17 @@ abstract contract EmissionsControllerStorage is IEmissionsController { 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; diff --git a/src/contracts/interfaces/IEmissionsController.sol b/src/contracts/interfaces/IEmissionsController.sol index 39ebdc014a..27aa6c9c34 100644 --- a/src/contracts/interfaces/IEmissionsController.sol +++ b/src/contracts/interfaces/IEmissionsController.sol @@ -29,6 +29,10 @@ interface IEmissionsControllerErrors { 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 diff --git a/src/test/mocks/RewardsCoordinatorMock.sol b/src/test/mocks/RewardsCoordinatorMock.sol index fd16b47379..e9c439f1e6 100644 --- a/src/test/mocks/RewardsCoordinatorMock.sol +++ b/src/test/mocks/RewardsCoordinatorMock.sol @@ -5,6 +5,10 @@ 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 } @@ -16,5 +20,9 @@ contract RewardsCoordinatorMock is Test { 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/EmissionsControllerUnit.t.sol b/src/test/unit/EmissionsControllerUnit.t.sol index 1abd924e2f..634ae4eb6c 100644 --- a/src/test/unit/EmissionsControllerUnit.t.sol +++ b/src/test/unit/EmissionsControllerUnit.t.sol @@ -9,7 +9,9 @@ contract EmissionsControllerUnitTests is EigenLayerUnitTestSetup, IEmissionsCont using ArrayLib for *; uint EMISSIONS_INFLATION_RATE = 50; - uint EMISSIONS_START_TIME = block.timestamp + 1 weeks; + // 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); From a3d26bd2d1cbe5137bd25b7ea09a693575ff2918 Mon Sep 17 00:00:00 2001 From: "clandestine.eth" <96172957+0xClandestine@users.noreply.github.com> Date: Fri, 9 Jan 2026 12:41:05 -0500 Subject: [PATCH 62/62] use require statements --- src/contracts/core/EmissionsController.sol | 44 ++++++++-------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 968aa25b34..750c58bd30 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -80,17 +80,13 @@ contract EmissionsController is // Check if emissions have not started yet. // Prevents minting EIGEN before the first epoch has started. - if (currentEpoch == type(uint256).max) { - revert EmissionsNotStarted(); - } + require(currentEpoch != type(uint256).max, EmissionsNotStarted()); uint256 totalDistributions = getTotalDistributions(); uint256 nextDistributionId = _epochs[currentEpoch].totalProcessed; // Check if all distributions have already been processed. - if (nextDistributionId >= totalDistributions) { - revert AllDistributionsProcessed(); - } + require(nextDistributionId < totalDistributions, AllDistributionsProcessed()); // Mint the total amount of bEIGEN/EIGEN needed for all distributions. if (!_epochs[currentEpoch].minted) { @@ -262,9 +258,7 @@ contract EmissionsController is uint256 currentEpoch = getCurrentEpoch(); // Check if the distribution is disabled. - if (distribution.distributionType == DistributionType.Disabled) { - revert CannotAddDisabledDistribution(); - } + 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. @@ -330,7 +324,7 @@ contract EmissionsController is function _checkOnlyIncentiveCouncil() internal view { // Check if the caller is the incentive council. - if (msg.sender != incentiveCouncil) revert CallerIsNotIncentiveCouncil(); + require(msg.sender == incentiveCouncil, CallerIsNotIncentiveCouncil()); } function _checkAllDistributionsProcessed( @@ -339,9 +333,10 @@ contract EmissionsController is // Check if all distributions have been processed for the current epoch. // Same logic found in `isButtonPressable()`. // Skip check if emissions haven't started yet. - if (currentEpoch != type(uint256).max && _epochs[currentEpoch].totalProcessed < getTotalDistributions()) { - revert NotAllDistributionsProcessed(); - } + require( + currentEpoch == type(uint256).max || _epochs[currentEpoch].totalProcessed >= getTotalDistributions(), + NotAllDistributionsProcessed() + ); } function _checkDistribution( @@ -354,34 +349,27 @@ contract EmissionsController is distribution.distributionType == DistributionType.OperatorSetTotalStake || distribution.distributionType == DistributionType.OperatorSetUniqueStake ) { - if (!ALLOCATION_MANAGER.isOperatorSet(distribution.operatorSet)) { - revert OperatorSetNotRegistered(); - } + 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 - if (distribution.startEpoch <= currentEpoch) { - revert StartEpochMustBeInTheFuture(); - } + 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. - if (distribution.weight + totalWeightBefore > MAX_TOTAL_WEIGHT) { - revert TotalWeightExceedsMax(); - } + 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. - if ( - distribution.distributionType != DistributionType.Manual - && distribution.strategiesAndMultipliers.length == 0 - ) { - revert RewardsSubmissionsCannotBeEmpty(); - } + require( + distribution.distributionType == DistributionType.Manual + || distribution.strategiesAndMultipliers.length > 0, + RewardsSubmissionsCannotBeEmpty() + ); } /// -----------------------------------------------------------------------