diff --git a/.gitignore b/.gitignore index cf968e92e4..a905ad98d1 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ artifacts #Foundry files out cache +snapshots *.DS_Store diff --git a/script/deploy/devnet/deploy_from_scratch.s.sol b/script/deploy/devnet/deploy_from_scratch.s.sol index 6c646de7e2..9062066d47 100644 --- a/script/deploy/devnet/deploy_from_scratch.s.sol +++ b/script/deploy/devnet/deploy_from_scratch.s.sol @@ -312,7 +312,8 @@ contract DeployFromScratch is Script, Test { REWARDS_COORDINATOR_INIT_PAUSED_STATUS, REWARDS_COORDINATOR_UPDATER, REWARDS_COORDINATOR_ACTIVATION_DELAY, - REWARDS_COORDINATOR_GLOBAL_OPERATOR_COMMISSION_BIPS + REWARDS_COORDINATOR_GLOBAL_OPERATOR_COMMISSION_BIPS, + executorMultisig // feeRecipient ) ); diff --git a/script/deploy/local/deploy_from_scratch.slashing.s.sol b/script/deploy/local/deploy_from_scratch.slashing.s.sol index 2bae06bc72..44ae57285d 100644 --- a/script/deploy/local/deploy_from_scratch.slashing.s.sol +++ b/script/deploy/local/deploy_from_scratch.slashing.s.sol @@ -219,9 +219,6 @@ contract DeployFromScratch is Script, Test { allocationManager = AllocationManager( address(new TransparentUpgradeableProxy(address(emptyContract), address(eigenLayerProxyAdmin), "")) ); - allocationManagerView = AllocationManagerView( - address(new TransparentUpgradeableProxy(address(emptyContract), address(eigenLayerProxyAdmin), "")) - ); permissionController = PermissionController( address(new TransparentUpgradeableProxy(address(emptyContract), address(eigenLayerProxyAdmin), "")) ); @@ -237,6 +234,9 @@ contract DeployFromScratch is Script, Test { eigenPodBeacon = new UpgradeableBeacon(address(eigenPodImplementation)); // Second, deploy the *implementation* contracts, using the *proxy contracts* as inputs + // Deploy AllocationManagerView as a standalone implementation (not a proxy) + allocationManagerView = + new AllocationManagerView(delegation, eigenStrategy, DEALLOCATION_DELAY, ALLOCATION_CONFIGURATION_DELAY); delegationImplementation = new DelegationManager( strategyManager, @@ -327,7 +327,8 @@ contract DeployFromScratch is Script, Test { REWARDS_COORDINATOR_INIT_PAUSED_STATUS, REWARDS_COORDINATOR_UPDATER, REWARDS_COORDINATOR_ACTIVATION_DELAY, - REWARDS_COORDINATOR_DEFAULT_OPERATOR_SPLIT_BIPS + REWARDS_COORDINATOR_DEFAULT_OPERATOR_SPLIT_BIPS, + executorMultisig // feeRecipient ) ); diff --git a/script/releases/TestUtils.sol b/script/releases/TestUtils.sol index b8335406e9..84f2473445 100644 --- a/script/releases/TestUtils.sol +++ b/script/releases/TestUtils.sol @@ -926,7 +926,7 @@ library TestUtils { RewardsCoordinator rewardsCoordinator ) internal { vm.expectRevert(errInit); - rewardsCoordinator.initialize(address(0), 0, address(0), 0, 0); + rewardsCoordinator.initialize(address(0), 0, address(0), 0, 0, address(0)); } function validateStrategyManagerInitialized( diff --git a/script/utils/ExistingDeploymentParser.sol b/script/utils/ExistingDeploymentParser.sol index 8485bcedd0..2227e22e99 100644 --- a/script/utils/ExistingDeploymentParser.sol +++ b/script/utils/ExistingDeploymentParser.sol @@ -522,7 +522,8 @@ contract ExistingDeploymentParser is Script, Logger { 0, // initialPausedStatus address(0), // rewardsUpdater 0, // activationDelay - 0 // defaultSplitBips + 0, // defaultSplitBips + address(0) // feeRecipient ); // DelegationManager cheats.expectRevert(bytes("Initializable: contract is already initialized")); diff --git a/snapshots/Integration_ALM_Multi.json b/snapshots/Integration_ALM_Multi.json deleted file mode 100644 index a2c3359a1a..0000000000 --- a/snapshots/Integration_ALM_Multi.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "gasUsed": "47579" -} \ No newline at end of file diff --git a/src/contracts/core/EmissionsController.sol b/src/contracts/core/EmissionsController.sol index 750c58bd30..90e75dca8a 100644 --- a/src/contracts/core/EmissionsController.sol +++ b/src/contracts/core/EmissionsController.sol @@ -202,8 +202,6 @@ contract EmissionsController is success = _tryCallRewardsCoordinator( abi.encodeCall(IRewardsCoordinator.createAVSRewardsSubmission, (rewardsSubmissions)) ); - } else { - revert InvalidDistributionType(); // Only reachable if the distribution type is `Disabled`. } } else { (success,) = diff --git a/src/contracts/core/RewardsCoordinator.sol b/src/contracts/core/RewardsCoordinator.sol index 03e9f9a32c..ff28813da9 100644 --- a/src/contracts/core/RewardsCoordinator.sol +++ b/src/contracts/core/RewardsCoordinator.sol @@ -39,6 +39,10 @@ contract RewardsCoordinator is _; } + /// ----------------------------------------------------------------------- + /// Initialization + /// ----------------------------------------------------------------------- + /// @dev Sets the immutable variables for the contract constructor( RewardsCoordinatorConstructorParams memory params @@ -67,35 +71,45 @@ contract RewardsCoordinator is uint256 initialPausedStatus, address _rewardsUpdater, uint32 _activationDelay, - uint16 _defaultSplitBips - ) external initializer { + uint16 _defaultSplitBips, + address _feeRecipient + ) external reinitializer(2) { _setPausedStatus(initialPausedStatus); _transferOwnership(initialOwner); _setRewardsUpdater(_rewardsUpdater); _setActivationDelay(_activationDelay); _setDefaultOperatorSplit(_defaultSplitBips); + _setFeeRecipient(_feeRecipient); } - /// - /// EXTERNAL FUNCTIONS - /// + /// ----------------------------------------------------------------------- + /// External Functions + /// ----------------------------------------------------------------------- /// @inheritdoc IRewardsCoordinator function createAVSRewardsSubmission( RewardsSubmission[] calldata rewardsSubmissions ) external onlyWhenNotPaused(PAUSED_AVS_REWARDS_SUBMISSION) nonReentrant { for (uint256 i = 0; i < rewardsSubmissions.length; i++) { - RewardsSubmission calldata rewardsSubmission = rewardsSubmissions[i]; - uint256 nonce = submissionNonce[msg.sender]; - bytes32 rewardsSubmissionHash = keccak256(abi.encode(msg.sender, nonce, rewardsSubmission)); + RewardsSubmission memory rewardsSubmission = rewardsSubmissions[i]; + // First validate the submission. _validateRewardsSubmission(rewardsSubmission); + // Then transfer the full amount to the contract. + rewardsSubmission.token.safeTransferFrom(msg.sender, address(this), rewardsSubmission.amount); + + // Then take the protocol fee (if the submitter is opted in for protocol fees). + rewardsSubmission.amount = _takeProtocolFee(msg.sender, rewardsSubmission.token, rewardsSubmission.amount); + + // Last update storage. + uint256 nonce = submissionNonce[msg.sender]; + bytes32 rewardsSubmissionHash = keccak256(abi.encode(msg.sender, nonce, rewardsSubmission)); + isAVSRewardsSubmissionHash[msg.sender][rewardsSubmissionHash] = true; submissionNonce[msg.sender] = nonce + 1; emit AVSRewardsSubmissionCreated(msg.sender, nonce, rewardsSubmissionHash, rewardsSubmission); - rewardsSubmission.token.safeTransferFrom(msg.sender, address(this), rewardsSubmission.amount); } } @@ -148,14 +162,24 @@ contract RewardsCoordinator is OperatorDirectedRewardsSubmission[] calldata operatorDirectedRewardsSubmissions ) external onlyWhenNotPaused(PAUSED_OPERATOR_DIRECTED_AVS_REWARDS_SUBMISSION) checkCanCall(avs) nonReentrant { for (uint256 i = 0; i < operatorDirectedRewardsSubmissions.length; i++) { - OperatorDirectedRewardsSubmission calldata operatorDirectedRewardsSubmission = + OperatorDirectedRewardsSubmission memory operatorDirectedRewardsSubmission = operatorDirectedRewardsSubmissions[i]; + uint256 nonce = submissionNonce[avs]; - bytes32 operatorDirectedRewardsSubmissionHash = - keccak256(abi.encode(avs, nonce, operatorDirectedRewardsSubmission)); - uint256 totalAmount = _validateOperatorDirectedRewardsSubmission(operatorDirectedRewardsSubmission); + // First validate the operator directed submission. + // Validate the operator directed submission and deduct protocol fees upfront from each `operatorRewards.amount` if applicable. + // This ensures all amounts are net of fees before proceeding, avoiding redundant fee calculations later. + (bytes32 operatorDirectedRewardsSubmissionHash, uint256 amountBeforeFee, uint256 amountAfterFee) = + _validateOperatorDirectedRewardsSubmission(avs, nonce, operatorDirectedRewardsSubmission); + // Then transfer the full amount to the contract. + operatorDirectedRewardsSubmission.token.safeTransferFrom(msg.sender, address(this), amountBeforeFee); + + // Then take the protocol fee (if the submitter is opted in for protocol fees). + _takeOperatorDirectedProtocolFee(operatorDirectedRewardsSubmission.token, amountBeforeFee, amountAfterFee); + + // Last update storage. isOperatorDirectedAVSRewardsSubmissionHash[avs][operatorDirectedRewardsSubmissionHash] = true; submissionNonce[avs] = nonce + 1; @@ -166,7 +190,6 @@ contract RewardsCoordinator is nonce, operatorDirectedRewardsSubmission ); - operatorDirectedRewardsSubmission.token.safeTransferFrom(msg.sender, address(this), totalAmount); } } @@ -182,14 +205,23 @@ contract RewardsCoordinator is { require(allocationManager.isOperatorSet(operatorSet), InvalidOperatorSet()); for (uint256 i = 0; i < operatorDirectedRewardsSubmissions.length; i++) { - OperatorDirectedRewardsSubmission calldata operatorDirectedRewardsSubmission = + OperatorDirectedRewardsSubmission memory operatorDirectedRewardsSubmission = operatorDirectedRewardsSubmissions[i]; + uint256 nonce = submissionNonce[operatorSet.avs]; - bytes32 operatorDirectedRewardsSubmissionHash = - keccak256(abi.encode(operatorSet.avs, nonce, operatorDirectedRewardsSubmission)); + // First validate the operator directed submission. + // Validate the operator directed submission and deduct protocol fees upfront from each `operatorRewards.amount` if applicable. + // This ensures all amounts are net of fees before proceeding, avoiding redundant fee calculations later. + (bytes32 operatorDirectedRewardsSubmissionHash, uint256 amountBeforeFee, uint256 amountAfterFee) = + _validateOperatorDirectedRewardsSubmission(operatorSet.avs, nonce, operatorDirectedRewardsSubmission); - uint256 totalAmount = _validateOperatorDirectedRewardsSubmission(operatorDirectedRewardsSubmission); + // Then transfer the full amount to the contract. + operatorDirectedRewardsSubmission.token.safeTransferFrom(msg.sender, address(this), amountBeforeFee); + // Then take the protocol fee (if the submitter is opted in for protocol fees). + _takeOperatorDirectedProtocolFee(operatorDirectedRewardsSubmission.token, amountBeforeFee, amountAfterFee); + + // Last update storage. isOperatorDirectedOperatorSetRewardsSubmissionHash[operatorSet.avs][operatorDirectedRewardsSubmissionHash] = true; submissionNonce[operatorSet.avs] = nonce + 1; @@ -201,7 +233,6 @@ contract RewardsCoordinator is nonce, operatorDirectedRewardsSubmission ); - operatorDirectedRewardsSubmission.token.safeTransferFrom(msg.sender, address(this), totalAmount); } } @@ -212,12 +243,21 @@ contract RewardsCoordinator is ) external onlyWhenNotPaused(PAUSED_UNIQUE_STAKE_REWARDS_SUBMISSION) checkCanCall(operatorSet.avs) nonReentrant { require(allocationManager.isOperatorSet(operatorSet), InvalidOperatorSet()); for (uint256 i = 0; i < rewardsSubmissions.length; ++i) { - RewardsSubmission calldata rewardsSubmission = rewardsSubmissions[i]; - uint256 nonce = submissionNonce[operatorSet.avs]; - bytes32 rewardsSubmissionHash = keccak256(abi.encode(operatorSet.avs, nonce, rewardsSubmission)); + RewardsSubmission memory rewardsSubmission = rewardsSubmissions[i]; + // First validate the submission. _validateRewardsSubmission(rewardsSubmission); + // Then transfer the full amount to the contract. + rewardsSubmission.token.safeTransferFrom(msg.sender, address(this), rewardsSubmission.amount); + + // Then take the protocol fee (if the submitter is opted in for protocol fees). + rewardsSubmission.amount = _takeProtocolFee(msg.sender, rewardsSubmission.token, rewardsSubmission.amount); + + // Last update storage. + uint256 nonce = submissionNonce[operatorSet.avs]; + bytes32 rewardsSubmissionHash = keccak256(abi.encode(operatorSet.avs, nonce, rewardsSubmission)); + isUniqueStakeRewardsSubmissionHash[operatorSet.avs][rewardsSubmissionHash] = true; submissionNonce[operatorSet.avs] = nonce + 1; @@ -228,7 +268,6 @@ contract RewardsCoordinator is nonce, rewardsSubmission ); - rewardsSubmission.token.safeTransferFrom(msg.sender, address(this), rewardsSubmission.amount); } } @@ -239,12 +278,21 @@ contract RewardsCoordinator is ) external onlyWhenNotPaused(PAUSED_TOTAL_STAKE_REWARDS_SUBMISSION) checkCanCall(operatorSet.avs) nonReentrant { require(allocationManager.isOperatorSet(operatorSet), InvalidOperatorSet()); for (uint256 i = 0; i < rewardsSubmissions.length; ++i) { - RewardsSubmission calldata rewardsSubmission = rewardsSubmissions[i]; - uint256 nonce = submissionNonce[operatorSet.avs]; - bytes32 rewardsSubmissionHash = keccak256(abi.encode(operatorSet.avs, nonce, rewardsSubmission)); + RewardsSubmission memory rewardsSubmission = rewardsSubmissions[i]; + // First validate the submission. _validateRewardsSubmission(rewardsSubmission); + // Then transfer the full amount to the contract. + rewardsSubmission.token.safeTransferFrom(msg.sender, address(this), rewardsSubmission.amount); + + // Then take the protocol fee (if the submitter is opted in for protocol fees). + rewardsSubmission.amount = _takeProtocolFee(msg.sender, rewardsSubmission.token, rewardsSubmission.amount); + + // Last update storage. + uint256 nonce = submissionNonce[operatorSet.avs]; + bytes32 rewardsSubmissionHash = keccak256(abi.encode(operatorSet.avs, nonce, rewardsSubmission)); + isTotalStakeRewardsSubmissionHash[operatorSet.avs][rewardsSubmissionHash] = true; submissionNonce[operatorSet.avs] = nonce + 1; @@ -255,7 +303,6 @@ contract RewardsCoordinator is nonce, rewardsSubmission ); - rewardsSubmission.token.safeTransferFrom(msg.sender, address(this), rewardsSubmission.amount); } } @@ -346,6 +393,13 @@ contract RewardsCoordinator is _setDefaultOperatorSplit(split); } + /// @inheritdoc IRewardsCoordinator + function setFeeRecipient( + address _feeRecipient + ) external onlyOwner { + _setFeeRecipient(_feeRecipient); + } + /// @inheritdoc IRewardsCoordinator function setOperatorAVSSplit( address operator, @@ -386,6 +440,14 @@ contract RewardsCoordinator is emit OperatorSetSplitBipsSet(msg.sender, operator, operatorSet, activatedAt, oldSplit, split); } + /// @inheritdoc IRewardsCoordinator + function setOptInForProtocolFee( + address submitter, + bool optInForProtocolFee + ) external checkCanCall(submitter) { + _setOptInForProtocolFee(submitter, optInForProtocolFee); + } + /// @inheritdoc IRewardsCoordinator function setRewardsUpdater( address _rewardsUpdater @@ -403,9 +465,9 @@ contract RewardsCoordinator is isRewardsForAllSubmitter[_submitter] = _newValue; } - /// - /// INTERNAL FUNCTIONS - /// + /// ----------------------------------------------------------------------- + /// Internal Helper Functions + /// ----------------------------------------------------------------------- /// @notice Internal helper to process reward claims. /// @param claim The RewardsMerkleClaims to be processed. @@ -468,6 +530,14 @@ contract RewardsCoordinator is emit ClaimerForSet(earner, prevClaimer, claimer); } + function _setFeeRecipient( + address _feeRecipient + ) internal { + require(_feeRecipient != address(0), InvalidAddressZero()); + emit FeeRecipientSet(feeRecipient, _feeRecipient); + feeRecipient = _feeRecipient; + } + /// @notice Internal helper to set the operator split. /// @param operatorSplit The split struct for an Operator /// @param split The split in basis points. @@ -492,9 +562,18 @@ contract RewardsCoordinator is operatorSplit.activatedAt = activatedAt; } + function _setOptInForProtocolFee( + address submitter, + bool value + ) internal { + bool prevValue = isOptedInForProtocolFee[submitter]; + emit OptInForProtocolFeeSet(submitter, prevValue, value); + isOptedInForProtocolFee[submitter] = value; + } + /// @notice Common checks for all RewardsSubmissions. function _validateCommonRewardsSubmission( - StrategyAndMultiplier[] calldata strategiesAndMultipliers, + StrategyAndMultiplier[] memory strategiesAndMultipliers, uint32 startTimestamp, uint32 duration ) internal view { @@ -523,7 +602,7 @@ contract RewardsCoordinator is /// @notice Validate a RewardsSubmission. Called from both `createAVSRewardsSubmission` and `createRewardsForAllSubmission` function _validateRewardsSubmission( - RewardsSubmission calldata rewardsSubmission + RewardsSubmission memory rewardsSubmission ) internal view { _validateCommonRewardsSubmission( rewardsSubmission.strategiesAndMultipliers, rewardsSubmission.startTimestamp, rewardsSubmission.duration @@ -536,10 +615,14 @@ contract RewardsCoordinator is /// @notice Validate a OperatorDirectedRewardsSubmission. Called from `createOperatorDirectedAVSRewardsSubmission`. /// @dev Not checking for `MAX_FUTURE_LENGTH` (Since operator-directed reward submissions are strictly retroactive). /// @param submission OperatorDirectedRewardsSubmission to validate. - /// @return total amount to be transferred from the avs to the contract. + /// @return submissionHash The hash of the submission. + /// @return amountBeforeFee The sum of all operator reward amounts before fees are taken. + /// @return amountAfterFee The sum of all operator reward amounts after fees are taken. function _validateOperatorDirectedRewardsSubmission( - OperatorDirectedRewardsSubmission calldata submission - ) internal view returns (uint256) { + address submitter, + uint256 nonce, + OperatorDirectedRewardsSubmission memory submission + ) internal view returns (bytes32 submissionHash, uint256 amountBeforeFee, uint256 amountAfterFee) { _validateCommonRewardsSubmission( submission.strategiesAndMultipliers, submission.startTimestamp, submission.duration ); @@ -547,21 +630,34 @@ contract RewardsCoordinator is require(submission.operatorRewards.length > 0, InputArrayLengthZero()); require(submission.startTimestamp + submission.duration < block.timestamp, SubmissionNotRetroactive()); - uint256 totalAmount = 0; - address currOperatorAddress = address(0); - for (uint256 i = 0; i < submission.operatorRewards.length; ++i) { - OperatorReward calldata operatorReward = submission.operatorRewards[i]; - require(operatorReward.operator != address(0), InvalidAddressZero()); - require(currOperatorAddress < operatorReward.operator, OperatorsNotInAscendingOrder()); - require(operatorReward.amount > 0, AmountIsZero()); + bool feeOn = isOptedInForProtocolFee[submitter]; + + address lastOperator = address(0); + uint256 length = submission.operatorRewards.length; + for (uint256 i = 0; i < length; ++i) { + // Check that each operator is a non-zero address. + require(submission.operatorRewards[i].operator != address(0), InvalidAddressZero()); + // Check that each operator is in ascending order. + require(lastOperator < submission.operatorRewards[i].operator, OperatorsNotInAscendingOrder()); + // Check that each operator reward amount is non-zero. + require(submission.operatorRewards[i].amount > 0, AmountIsZero()); + + // Increment the total amount before fees by the operator reward amount. + amountBeforeFee += submission.operatorRewards[i].amount; + + // Take the protocol fee (if the submitter is opted in for protocol fees). + uint256 feeAmount = submission.operatorRewards[i].amount * PROTOCOL_FEE_BIPS / ONE_HUNDRED_IN_BIPS; + if (feeOn && feeAmount != 0) { + submission.operatorRewards[i].amount -= feeAmount; + } - currOperatorAddress = operatorReward.operator; - totalAmount += operatorReward.amount; + amountAfterFee += submission.operatorRewards[i].amount; + lastOperator = submission.operatorRewards[i].operator; } - require(totalAmount <= MAX_REWARDS_AMOUNT, AmountExceedsMax()); + require(amountAfterFee <= MAX_REWARDS_AMOUNT, AmountExceedsMax()); - return totalAmount; + return (keccak256(abi.encode(submitter, nonce, submission)), amountBeforeFee, amountAfterFee); } function _checkClaim( @@ -671,9 +767,43 @@ contract RewardsCoordinator is } } - /// - /// VIEW FUNCTIONS - /// + /// @notice Internal helper to take protocol fees from a submission. + /// @param submitter The address of the submitter. + /// @param token The token to take the protocol fee from. + /// @param amountBeforeFee The amount before the protocol fee is taken. + /// @return amountAfterFee The amount after the protocol fee is taken. + function _takeProtocolFee( + address submitter, + IERC20 token, + uint256 amountBeforeFee + ) internal returns (uint256 amountAfterFee) { + uint256 feeAmount = amountBeforeFee * PROTOCOL_FEE_BIPS / ONE_HUNDRED_IN_BIPS; + if (isOptedInForProtocolFee[submitter]) { + if (feeAmount != 0) { + token.safeTransfer(feeRecipient, feeAmount); + return amountBeforeFee - feeAmount; + } + } + return amountBeforeFee; + } + + /// @notice Internal helper to take protocol fees from a operator-directed rewards submission. + /// @param token The token to take the protocol fee from. + /// @param amountBeforeFee The amount before the protocol fee is taken. + /// @param amountAfterFee The amount after the protocol fee is taken. + function _takeOperatorDirectedProtocolFee( + IERC20 token, + uint256 amountBeforeFee, + uint256 amountAfterFee + ) internal { + if (amountAfterFee != amountBeforeFee) { + token.safeTransfer(feeRecipient, amountBeforeFee - amountAfterFee); + } + } + + /// ----------------------------------------------------------------------- + /// External View Functions + /// ----------------------------------------------------------------------- /// @inheritdoc IRewardsCoordinator function calculateEarnerLeafHash( diff --git a/src/contracts/core/storage/RewardsCoordinatorStorage.sol b/src/contracts/core/storage/RewardsCoordinatorStorage.sol index 270e18d0f6..038a8324dd 100644 --- a/src/contracts/core/storage/RewardsCoordinatorStorage.sol +++ b/src/contracts/core/storage/RewardsCoordinatorStorage.sol @@ -48,6 +48,9 @@ abstract contract RewardsCoordinatorStorage is IRewardsCoordinator { /// @notice Canonical, virtual beacon chain ETH strategy IStrategy public constant beaconChainETHStrategy = IStrategy(0xbeaC0eeEeeeeEEeEeEEEEeeEEeEeeeEeeEEBEaC0); + /// @notice Protocol fee percentage in basis points (20%). + uint16 internal constant PROTOCOL_FEE_BIPS = 2000; + // Immutables /// @notice The DelegationManager contract for EigenLayer @@ -136,6 +139,12 @@ abstract contract RewardsCoordinatorStorage is IRewardsCoordinator { /// @notice Returns whether a `hash` is a `valid` total stake rewards submission hash for a given `avs`. mapping(address avs => mapping(bytes32 hash => bool valid)) public isTotalStakeRewardsSubmissionHash; + /// @notice Returns whether a `submitter` is opted in for protocol fees. + mapping(address submitter => bool isOptedIn) public isOptedInForProtocolFee; + + /// @notice The address that receives optional protocol fees + address public feeRecipient; + // Construction constructor( IDelegationManager _delegationManager, @@ -164,5 +173,5 @@ abstract contract RewardsCoordinatorStorage is IRewardsCoordinator { /// @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[33] private __gap; + uint256[31] private __gap; } diff --git a/src/contracts/interfaces/IRewardsCoordinator.sol b/src/contracts/interfaces/IRewardsCoordinator.sol index bbf5e7cca3..29e9b1dbe7 100644 --- a/src/contracts/interfaces/IRewardsCoordinator.sol +++ b/src/contracts/interfaces/IRewardsCoordinator.sol @@ -414,6 +414,17 @@ interface IRewardsCoordinatorEvents is IRewardsCoordinatorTypes { IERC20 token, uint256 claimedAmount ); + + /// @notice Emitted when the fee recipient is set. + /// @param oldFeeRecipient The old fee recipient + /// @param newFeeRecipient The new fee recipient + event FeeRecipientSet(address indexed oldFeeRecipient, address indexed newFeeRecipient); + + /// @notice Emitted when the opt in for protocol fee is set. + /// @param submitter The address of the submitter + /// @param oldValue The old value of the opt in for protocol fee + /// @param newValue The new value of the opt in for protocol fee + event OptInForProtocolFeeSet(address indexed submitter, bool indexed oldValue, bool indexed newValue); } /// @title Interface for the `IRewardsCoordinator` contract. @@ -431,7 +442,8 @@ interface IRewardsCoordinator is IRewardsCoordinatorErrors, IRewardsCoordinatorE uint256 initialPausedStatus, address _rewardsUpdater, uint32 _activationDelay, - uint16 _defaultSplitBips + uint16 _defaultSplitBips, + address _feeRecipient ) external; /// @notice Creates a new rewards submission on behalf of an AVS, to be split amongst the @@ -606,6 +618,13 @@ interface IRewardsCoordinator is IRewardsCoordinatorErrors, IRewardsCoordinatorE uint16 split ) external; + /// @notice Sets the fee recipient address which receives optional protocol fees + /// @dev Only callable by the contract owner + /// @param _feeRecipient The address of the new fee recipient + function setFeeRecipient( + address _feeRecipient + ) external; + /// @notice Sets the split for a specific operator for a specific avs /// @param operator The operator who is setting the split /// @param avs The avs for which the split is being set by the operator @@ -643,6 +662,15 @@ interface IRewardsCoordinator is IRewardsCoordinatorErrors, IRewardsCoordinatorE uint16 split ) external; + /// @notice Sets whether the submitter wants to pay the protocol fee on their rewards submissions. + /// @dev Submitters must opt-in to pay the protocol fee to be eligible for rewards. + /// @param submitter The address of the submitter that wants to opt-in or out of the protocol fee. + /// @param optInForProtocolFee Whether the submitter wants to pay the protocol fee. + function setOptInForProtocolFee( + address submitter, + bool optInForProtocolFee + ) external; + /// @notice Sets the permissioned `rewardsUpdater` address which can post new roots /// @dev Only callable by the contract owner /// @param _rewardsUpdater The address of the new rewardsUpdater diff --git a/src/test/unit/ECDSACertificateVerifierUnit.t.sol b/src/test/unit/ECDSACertificateVerifierUnit.t.sol index ea68e54a94..32a24337d9 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(); // SignersNotOrdered or VerificationFailed + cheats.expectRevert(); // SignersNotOrdered or VerificationFailed verifier.verifyCertificate(defaultOperatorSet, cert); } diff --git a/src/test/unit/RewardsCoordinatorUnit.t.sol b/src/test/unit/RewardsCoordinatorUnit.t.sol index 418af9206c..e144d061d6 100644 --- a/src/test/unit/RewardsCoordinatorUnit.t.sol +++ b/src/test/unit/RewardsCoordinatorUnit.t.sol @@ -105,6 +105,7 @@ contract RewardsCoordinatorUnitTests is EigenLayerUnitTestSetup, IRewardsCoordin address defaultClaimer = address(1002); address rewardsForAllSubmitter = address(1003); address defaultAppointee = address(1004); + address feeRecipient = address(1005); function setUp() public virtual override { // Setup @@ -137,7 +138,8 @@ contract RewardsCoordinatorUnitTests is EigenLayerUnitTestSetup, IRewardsCoordin 0, // 0 is initialPausedStatus rewardsUpdater, activationDelay, - defaultSplitBips + defaultSplitBips, + feeRecipient ) ) ) @@ -424,6 +426,61 @@ contract RewardsCoordinatorUnitTests_initializeAndSetters is RewardsCoordinatorU cheats.expectRevert("Ownable: caller is not the owner"); rewardsCoordinator.setRewardsForAllSubmitter(submitter, newValue); } + + function testFuzz_setFeeRecipient(address newFeeRecipient) public { + cheats.startPrank(rewardsCoordinator.owner()); + cheats.expectEmit(true, true, true, true, address(rewardsCoordinator)); + emit FeeRecipientSet(feeRecipient, newFeeRecipient); + rewardsCoordinator.setFeeRecipient(newFeeRecipient); + assertEq(newFeeRecipient, rewardsCoordinator.feeRecipient(), "feeRecipient not set"); + cheats.stopPrank(); + } + + function testFuzz_setFeeRecipient_Revert_WhenNotOwner(address caller, address newFeeRecipient) + public + filterFuzzedAddressInputs(caller) + { + cheats.assume(caller != rewardsCoordinator.owner()); + cheats.prank(caller); + cheats.expectRevert("Ownable: caller is not the owner"); + rewardsCoordinator.setFeeRecipient(newFeeRecipient); + } + + function test_setFeeRecipient_Revert_WhenAddressZero() public { + cheats.prank(rewardsCoordinator.owner()); + cheats.expectRevert(InvalidAddressZero.selector); + rewardsCoordinator.setFeeRecipient(address(0)); + } +} + +contract RewardsCoordinatorUnitTests_setOptInForProtocolFee is RewardsCoordinatorUnitTests { + function testFuzz_setOptInForProtocolFee(address submitter, bool optIn) public filterFuzzedAddressInputs(submitter) { + cheats.assume(submitter != address(0)); + + cheats.startPrank(submitter); + cheats.expectEmit(true, true, true, true, address(rewardsCoordinator)); + emit OptInForProtocolFeeSet(submitter, rewardsCoordinator.isOptedInForProtocolFee(submitter), optIn); + rewardsCoordinator.setOptInForProtocolFee(submitter, optIn); + assertEq(optIn, rewardsCoordinator.isOptedInForProtocolFee(submitter), "isOptedInForProtocolFee not set"); + cheats.stopPrank(); + } + + function testFuzz_setOptInForProtocolFee_UAM(address submitter, bool optIn) public filterFuzzedAddressInputs(submitter) { + cheats.assume(submitter != address(0)); + + // Set UAM + cheats.prank(submitter); + permissionController.setAppointee( + submitter, defaultAppointee, address(rewardsCoordinator), IRewardsCoordinator.setOptInForProtocolFee.selector + ); + + cheats.startPrank(defaultAppointee); + cheats.expectEmit(true, true, true, true, address(rewardsCoordinator)); + emit OptInForProtocolFeeSet(submitter, rewardsCoordinator.isOptedInForProtocolFee(submitter), optIn); + rewardsCoordinator.setOptInForProtocolFee(submitter, optIn); + assertEq(optIn, rewardsCoordinator.isOptedInForProtocolFee(submitter), "isOptedInForProtocolFee not set"); + cheats.stopPrank(); + } } contract RewardsCoordinatorUnitTests_setOperatorAVSSplit is RewardsCoordinatorUnitTests { @@ -1378,6 +1435,117 @@ contract RewardsCoordinatorUnitTests_createAVSRewardsSubmission is RewardsCoordi ); } } + + /// @notice Test fee deduction when submitter opts in for protocol fees + function testFuzz_createAVSRewardsSubmission_WithProtocolFee_OptedIn(address avs, uint startTimestamp, uint duration, uint amount) + public + filterFuzzedAddressInputs(avs) + { + cheats.assume(avs != address(0)); + cheats.prank(rewardsCoordinator.owner()); + + // 1. Bound fuzz inputs to valid ranges and amounts + IERC20 rewardToken = new ERC20PresetFixedSupply("dog wif hat", "MOCK1", mockTokenInitialSupply, avs); + amount = bound(amount, 1, mockTokenInitialSupply); + duration = bound(duration, CALCULATION_INTERVAL_SECONDS, MAX_REWARDS_DURATION); + duration = duration - (duration % CALCULATION_INTERVAL_SECONDS); + startTimestamp = bound( + startTimestamp, + uint(_maxTimestamp(GENESIS_REWARDS_TIMESTAMP, uint32(block.timestamp) - MAX_RETROACTIVE_LENGTH)) + CALCULATION_INTERVAL_SECONDS + - 1, + block.timestamp + uint(MAX_FUTURE_LENGTH) + ); + startTimestamp = startTimestamp - (startTimestamp % CALCULATION_INTERVAL_SECONDS); + + // 2. Opt in for protocol fees + cheats.prank(avs); + rewardsCoordinator.setOptInForProtocolFee(avs, true); + + // 3. Create rewards submission input param + RewardsSubmission[] memory rewardsSubmissions = new RewardsSubmission[](1); + rewardsSubmissions[0] = RewardsSubmission({ + strategiesAndMultipliers: defaultStrategyAndMultipliers, + token: rewardToken, + amount: amount, + startTimestamp: uint32(startTimestamp), + duration: uint32(duration) + }); + + // 4. Calculate expected fee: 20% of amount + uint feeAmount = (amount * 2000) / 10_000; + + // 5. Call createAVSRewardsSubmission() and verify balances + { + uint avsBalanceBefore = rewardToken.balanceOf(avs); + uint rcBalanceBefore = rewardToken.balanceOf(address(rewardsCoordinator)); + uint feeBalanceBefore = rewardToken.balanceOf(feeRecipient); + + cheats.startPrank(avs); + rewardToken.approve(address(rewardsCoordinator), amount); + rewardsCoordinator.createAVSRewardsSubmission(rewardsSubmissions); + cheats.stopPrank(); + + // Verify balances + assertEq(rewardToken.balanceOf(avs), avsBalanceBefore - amount, "AVS balance incorrect"); + assertEq( + rewardToken.balanceOf(address(rewardsCoordinator)), + rcBalanceBefore + amount - feeAmount, + "RewardsCoordinator balance incorrect" + ); + assertEq(rewardToken.balanceOf(feeRecipient), feeBalanceBefore + feeAmount, "Fee recipient balance incorrect"); + } + } + + /// @notice Test no fee deduction when submitter doesn't opt in for protocol fees + function testFuzz_createAVSRewardsSubmission_WithProtocolFee_NotOptedIn(address avs, uint startTimestamp, uint duration, uint amount) + public + filterFuzzedAddressInputs(avs) + { + cheats.assume(avs != address(0)); + cheats.prank(rewardsCoordinator.owner()); + + // 1. Bound fuzz inputs to valid ranges and amounts + IERC20 rewardToken = new ERC20PresetFixedSupply("dog wif hat", "MOCK1", mockTokenInitialSupply, avs); + amount = bound(amount, 1, mockTokenInitialSupply); + duration = bound(duration, CALCULATION_INTERVAL_SECONDS, MAX_REWARDS_DURATION); + duration = duration - (duration % CALCULATION_INTERVAL_SECONDS); + startTimestamp = bound( + startTimestamp, + uint(_maxTimestamp(GENESIS_REWARDS_TIMESTAMP, uint32(block.timestamp) - MAX_RETROACTIVE_LENGTH)) + CALCULATION_INTERVAL_SECONDS + - 1, + block.timestamp + uint(MAX_FUTURE_LENGTH) + ); + startTimestamp = startTimestamp - (startTimestamp % CALCULATION_INTERVAL_SECONDS); + + // 2. Ensure submitter is not opted in (default state) + assertEq(rewardsCoordinator.isOptedInForProtocolFee(avs), false, "Submitter should not be opted in by default"); + + // 3. Create rewards submission and verify balances + RewardsSubmission[] memory rewardsSubmissions = new RewardsSubmission[](1); + rewardsSubmissions[0] = RewardsSubmission({ + strategiesAndMultipliers: defaultStrategyAndMultipliers, + token: rewardToken, + amount: amount, + startTimestamp: uint32(startTimestamp), + duration: uint32(duration) + }); + + { + uint avsBalanceBefore = rewardToken.balanceOf(avs); + uint rcBalanceBefore = rewardToken.balanceOf(address(rewardsCoordinator)); + uint feeBalanceBefore = rewardToken.balanceOf(feeRecipient); + + cheats.startPrank(avs); + rewardToken.approve(address(rewardsCoordinator), amount); + rewardsCoordinator.createAVSRewardsSubmission(rewardsSubmissions); + cheats.stopPrank(); + + // Verify balances - no fee should be taken + assertEq(rewardToken.balanceOf(avs), avsBalanceBefore - amount, "AVS balance incorrect"); + assertEq(rewardToken.balanceOf(address(rewardsCoordinator)), rcBalanceBefore + amount, "RC balance incorrect"); + assertEq(rewardToken.balanceOf(feeRecipient), feeBalanceBefore, "Fee recipient balance should not change"); + } + } } contract RewardsCoordinatorUnitTests_createRewardsForAllSubmission is RewardsCoordinatorUnitTests { @@ -2130,7 +2298,8 @@ contract RewardsCoordinatorUnitTests_createOperatorDirectedAVSRewardsSubmission cheats.prank(rewardsCoordinator.owner()); // 1. Bound fuzz inputs to valid ranges and amounts - amount = bound(amount, 1e38, type(uint).max - 5e18); + // Bound amount to be above MAX_REWARDS_AMOUNT but not so large it causes overflow in arithmetic operations + amount = bound(amount, 1e38, 1e38 + 1e30); IERC20 rewardToken = new ERC20PresetFixedSupply("dog wif hat", "MOCK1", mockTokenInitialSupply, avs); duration = bound(duration, CALCULATION_INTERVAL_SECONDS, MAX_REWARDS_DURATION); duration = duration - (duration % CALCULATION_INTERVAL_SECONDS); @@ -2142,13 +2311,15 @@ contract RewardsCoordinatorUnitTests_createOperatorDirectedAVSRewardsSubmission ); startTimestamp = startTimestamp - (startTimestamp % CALCULATION_INTERVAL_SECONDS); - // 2. Create operator directed rewards submission input param + // 2. Create operator directed rewards submission with single operator to avoid overflow in sum + OperatorReward[] memory largeOperatorRewards = new OperatorReward[](1); + largeOperatorRewards[0] = OperatorReward(defaultOperatorRewards[0].operator, amount); + OperatorDirectedRewardsSubmission[] memory operatorDirectedRewardsSubmissions = new OperatorDirectedRewardsSubmission[](1); - defaultOperatorRewards[0].amount = amount; operatorDirectedRewardsSubmissions[0] = OperatorDirectedRewardsSubmission({ strategiesAndMultipliers: defaultStrategyAndMultipliers, token: rewardToken, - operatorRewards: defaultOperatorRewards, + operatorRewards: largeOperatorRewards, startTimestamp: uint32(startTimestamp), duration: uint32(duration), description: "" @@ -2686,6 +2857,119 @@ contract RewardsCoordinatorUnitTests_createOperatorDirectedAVSRewardsSubmission ); } } + + /// @notice Test fee deduction for operator directed submissions when opted in + function testFuzz_createOperatorDirectedAVSRewardsSubmission_WithProtocolFee_OptedIn(address avs, uint startTimestamp, uint duration) + public + filterFuzzedAddressInputs(avs) + { + cheats.assume(avs != address(0)); + + // 1. Bound fuzz inputs to valid ranges and amounts + IERC20 rewardToken = new ERC20PresetFixedSupply("dog wif hat", "MOCK1", mockTokenInitialSupply, avs); + duration = bound(duration, CALCULATION_INTERVAL_SECONDS, MAX_REWARDS_DURATION); + duration = duration - (duration % CALCULATION_INTERVAL_SECONDS); + startTimestamp = bound( + startTimestamp, + uint(_maxTimestamp(GENESIS_REWARDS_TIMESTAMP, uint32(block.timestamp) - MAX_RETROACTIVE_LENGTH)) + CALCULATION_INTERVAL_SECONDS + - 1, + block.timestamp - duration - 1 + ); + startTimestamp = startTimestamp - (startTimestamp % CALCULATION_INTERVAL_SECONDS); + + // 2. Opt in for protocol fees + cheats.prank(avs); + rewardsCoordinator.setOptInForProtocolFee(avs, true); + + // 3. Create operator directed rewards submission and verify + OperatorDirectedRewardsSubmission[] memory operatorDirectedRewardsSubmissions = new OperatorDirectedRewardsSubmission[](1); + operatorDirectedRewardsSubmissions[0] = OperatorDirectedRewardsSubmission({ + strategiesAndMultipliers: defaultStrategyAndMultipliers, + token: rewardToken, + operatorRewards: defaultOperatorRewards, + startTimestamp: uint32(startTimestamp), + duration: uint32(duration), + description: "" + }); + + uint totalAmount = _getTotalRewardsAmount(defaultOperatorRewards); + uint expectedFee; + { + // Calculate expected fee: 20% of each operator reward + for (uint i = 0; i < defaultOperatorRewards.length; i++) { + expectedFee += (defaultOperatorRewards[i].amount * 2000) / 10_000; + } + } + + { + uint avsBalanceBefore = rewardToken.balanceOf(avs); + uint rcBalanceBefore = rewardToken.balanceOf(address(rewardsCoordinator)); + uint feeBalanceBefore = rewardToken.balanceOf(feeRecipient); + + cheats.startPrank(avs); + rewardToken.approve(address(rewardsCoordinator), totalAmount); + rewardsCoordinator.createOperatorDirectedAVSRewardsSubmission(avs, operatorDirectedRewardsSubmissions); + cheats.stopPrank(); + + // Verify balances + assertEq(rewardToken.balanceOf(avs), avsBalanceBefore - totalAmount, "AVS balance incorrect"); + assertEq( + rewardToken.balanceOf(address(rewardsCoordinator)), rcBalanceBefore + totalAmount - expectedFee, "RC balance incorrect" + ); + assertEq(rewardToken.balanceOf(feeRecipient), feeBalanceBefore + expectedFee, "Fee balance incorrect"); + } + } + + /// @notice Test no fee deduction for operator directed submissions when not opted in + function testFuzz_createOperatorDirectedAVSRewardsSubmission_WithProtocolFee_NotOptedIn(address avs, uint startTimestamp, uint duration) + public + filterFuzzedAddressInputs(avs) + { + cheats.assume(avs != address(0)); + + // 1. Bound fuzz inputs to valid ranges and amounts + IERC20 rewardToken = new ERC20PresetFixedSupply("dog wif hat", "MOCK1", mockTokenInitialSupply, avs); + duration = bound(duration, CALCULATION_INTERVAL_SECONDS, MAX_REWARDS_DURATION); + duration = duration - (duration % CALCULATION_INTERVAL_SECONDS); + startTimestamp = bound( + startTimestamp, + uint(_maxTimestamp(GENESIS_REWARDS_TIMESTAMP, uint32(block.timestamp) - MAX_RETROACTIVE_LENGTH)) + CALCULATION_INTERVAL_SECONDS + - 1, + block.timestamp - duration - 1 + ); + startTimestamp = startTimestamp - (startTimestamp % CALCULATION_INTERVAL_SECONDS); + + // 2. Ensure submitter is not opted in (default state) + assertEq(rewardsCoordinator.isOptedInForProtocolFee(avs), false, "Submitter should not be opted in by default"); + + // 3. Create operator directed rewards submission and verify + OperatorDirectedRewardsSubmission[] memory operatorDirectedRewardsSubmissions = new OperatorDirectedRewardsSubmission[](1); + operatorDirectedRewardsSubmissions[0] = OperatorDirectedRewardsSubmission({ + strategiesAndMultipliers: defaultStrategyAndMultipliers, + token: rewardToken, + operatorRewards: defaultOperatorRewards, + startTimestamp: uint32(startTimestamp), + duration: uint32(duration), + description: "" + }); + + uint totalAmount = _getTotalRewardsAmount(defaultOperatorRewards); + { + uint avsBalanceBefore = rewardToken.balanceOf(avs); + uint rcBalanceBefore = rewardToken.balanceOf(address(rewardsCoordinator)); + uint feeBalanceBefore = rewardToken.balanceOf(feeRecipient); + + cheats.startPrank(avs); + rewardToken.approve(address(rewardsCoordinator), totalAmount); + rewardsCoordinator.createOperatorDirectedAVSRewardsSubmission(avs, operatorDirectedRewardsSubmissions); + cheats.stopPrank(); + + // Verify balances - no fee should be taken + assertEq(rewardToken.balanceOf(avs), avsBalanceBefore - totalAmount, "AVS balance incorrect"); + assertEq(rewardToken.balanceOf(address(rewardsCoordinator)), rcBalanceBefore + totalAmount, "RC balance incorrect"); + assertEq(rewardToken.balanceOf(feeRecipient), feeBalanceBefore, "Fee recipient balance should not change"); + } + } } contract RewardsCoordinatorUnitTests_createOperatorDirectedOperatorSetRewardsSubmission is RewardsCoordinatorUnitTests { @@ -2720,17 +3004,12 @@ contract RewardsCoordinatorUnitTests_createOperatorDirectedOperatorSetRewardsSub } /// @dev Sort to ensure that the array is in ascending order for addresses - function _sortAddressArrayAsc(address[] memory arr) internal pure returns (address[] memory) { - uint l = arr.length; - for (uint i = 0; i < l; i++) { - for (uint j = i + 1; j < l; j++) { - if (arr[i] > arr[j]) { - address temp = arr[i]; - arr[i] = arr[j]; - arr[j] = temp; - } - } + function _sortAddressArrayAsc(address[] memory arr) internal returns (address[] memory) { + uint[] memory casted; + assembly { + casted := arr } + casted = cheats.sort(casted); return arr; } @@ -3024,7 +3303,8 @@ contract RewardsCoordinatorUnitTests_createOperatorDirectedOperatorSetRewardsSub cheats.prank(rewardsCoordinator.owner()); // 1. Bound fuzz inputs to valid ranges and amounts - amount = bound(amount, 1e38, type(uint).max - 5e18); + // Bound amount to be above MAX_REWARDS_AMOUNT but not so large it causes overflow in arithmetic operations + amount = bound(amount, 1e38, 1e38 + 1e30); IERC20 rewardToken = new ERC20PresetFixedSupply("dog wif hat", "MOCK1", mockTokenInitialSupply, avs); duration = bound(duration, CALCULATION_INTERVAL_SECONDS, MAX_REWARDS_DURATION); duration = duration - (duration % CALCULATION_INTERVAL_SECONDS); @@ -3036,13 +3316,15 @@ contract RewardsCoordinatorUnitTests_createOperatorDirectedOperatorSetRewardsSub ); startTimestamp = startTimestamp - (startTimestamp % CALCULATION_INTERVAL_SECONDS); - // 2. Create operator directed rewards submission input param + // 2. Create operator directed rewards submission with single operator to avoid overflow in sum + OperatorReward[] memory largeOperatorRewards = new OperatorReward[](1); + largeOperatorRewards[0] = OperatorReward(defaultOperatorRewards[0].operator, amount); + OperatorDirectedRewardsSubmission[] memory operatorDirectedRewardsSubmissions = new OperatorDirectedRewardsSubmission[](1); - defaultOperatorRewards[0].amount = amount; operatorDirectedRewardsSubmissions[0] = OperatorDirectedRewardsSubmission({ strategiesAndMultipliers: defaultStrategyAndMultipliers, token: rewardToken, - operatorRewards: defaultOperatorRewards, + operatorRewards: largeOperatorRewards, startTimestamp: uint32(startTimestamp), duration: uint32(duration), description: "" @@ -4266,6 +4548,120 @@ contract RewardsCoordinatorUnitTests_createUniqueStakeRewardsSubmission is Rewar "RewardsCoordinator balance not incremented by amount" ); } + + /// @notice Test fee deduction for createUniqueStakeRewardsSubmission when opted in + function testFuzz_createUniqueStakeRewardsSubmission_WithProtocolFee_OptedIn( + address avs, + uint startTimestamp, + uint duration, + uint amount + ) public filterFuzzedAddressInputs(avs) { + cheats.assume(avs != address(0)); + + // Setup operator set + OperatorSet memory operatorSet = OperatorSet(avs, 1); + allocationManagerMock.setIsOperatorSet(operatorSet, true); + + // 1. Bound fuzz inputs to valid ranges and amounts + IERC20 rewardToken = new ERC20PresetFixedSupply("dog wif hat", "MOCK1", mockTokenInitialSupply, avs); + amount = bound(amount, 1, mockTokenInitialSupply); + duration = bound(duration, CALCULATION_INTERVAL_SECONDS, MAX_REWARDS_DURATION); + duration = duration - (duration % CALCULATION_INTERVAL_SECONDS); + startTimestamp = bound( + startTimestamp, + uint(_maxTimestamp(GENESIS_REWARDS_TIMESTAMP, uint32(block.timestamp) - MAX_RETROACTIVE_LENGTH)) + CALCULATION_INTERVAL_SECONDS + - 1, + block.timestamp + uint(MAX_FUTURE_LENGTH) + ); + startTimestamp = startTimestamp - (startTimestamp % CALCULATION_INTERVAL_SECONDS); + + // 2. Opt in for protocol fees + cheats.prank(avs); + rewardsCoordinator.setOptInForProtocolFee(avs, true); + + // 3. Create rewards submission and verify + RewardsSubmission[] memory rewardsSubmissions = new RewardsSubmission[](1); + rewardsSubmissions[0] = RewardsSubmission({ + strategiesAndMultipliers: defaultStrategyAndMultipliers, + token: rewardToken, + amount: amount, + startTimestamp: uint32(startTimestamp), + duration: uint32(duration) + }); + + uint feeAmount = (amount * 2000) / 10_000; + { + uint avsBalanceBefore = rewardToken.balanceOf(avs); + uint rcBalanceBefore = rewardToken.balanceOf(address(rewardsCoordinator)); + uint feeBalanceBefore = rewardToken.balanceOf(feeRecipient); + + cheats.startPrank(avs); + rewardToken.approve(address(rewardsCoordinator), amount); + rewardsCoordinator.createUniqueStakeRewardsSubmission(operatorSet, rewardsSubmissions); + cheats.stopPrank(); + + // Verify balances + assertEq(rewardToken.balanceOf(avs), avsBalanceBefore - amount, "AVS balance incorrect"); + assertEq(rewardToken.balanceOf(address(rewardsCoordinator)), rcBalanceBefore + amount - feeAmount, "RC balance incorrect"); + assertEq(rewardToken.balanceOf(feeRecipient), feeBalanceBefore + feeAmount, "Fee balance incorrect"); + } + } + + /// @notice Test no fee deduction for createUniqueStakeRewardsSubmission when not opted in + function testFuzz_createUniqueStakeRewardsSubmission_WithProtocolFee_NotOptedIn( + address avs, + uint startTimestamp, + uint duration, + uint amount + ) public filterFuzzedAddressInputs(avs) { + cheats.assume(avs != address(0)); + + // Setup operator set + OperatorSet memory operatorSet = OperatorSet(avs, 1); + allocationManagerMock.setIsOperatorSet(operatorSet, true); + + // 1. Bound fuzz inputs to valid ranges and amounts + IERC20 rewardToken = new ERC20PresetFixedSupply("dog wif hat", "MOCK1", mockTokenInitialSupply, avs); + amount = bound(amount, 1, mockTokenInitialSupply); + duration = bound(duration, CALCULATION_INTERVAL_SECONDS, MAX_REWARDS_DURATION); + duration = duration - (duration % CALCULATION_INTERVAL_SECONDS); + startTimestamp = bound( + startTimestamp, + uint(_maxTimestamp(GENESIS_REWARDS_TIMESTAMP, uint32(block.timestamp) - MAX_RETROACTIVE_LENGTH)) + CALCULATION_INTERVAL_SECONDS + - 1, + block.timestamp + uint(MAX_FUTURE_LENGTH) + ); + startTimestamp = startTimestamp - (startTimestamp % CALCULATION_INTERVAL_SECONDS); + + // 2. Ensure submitter is not opted in (default state) + assertEq(rewardsCoordinator.isOptedInForProtocolFee(avs), false, "Submitter should not be opted in by default"); + + // 3. Create rewards submission and verify + RewardsSubmission[] memory rewardsSubmissions = new RewardsSubmission[](1); + rewardsSubmissions[0] = RewardsSubmission({ + strategiesAndMultipliers: defaultStrategyAndMultipliers, + token: rewardToken, + amount: amount, + startTimestamp: uint32(startTimestamp), + duration: uint32(duration) + }); + + { + uint avsBalanceBefore = rewardToken.balanceOf(avs); + uint rcBalanceBefore = rewardToken.balanceOf(address(rewardsCoordinator)); + uint feeBalanceBefore = rewardToken.balanceOf(feeRecipient); + + cheats.startPrank(avs); + rewardToken.approve(address(rewardsCoordinator), amount); + rewardsCoordinator.createUniqueStakeRewardsSubmission(operatorSet, rewardsSubmissions); + cheats.stopPrank(); + + // Verify balances - no fee should be taken + assertEq(rewardToken.balanceOf(avs), avsBalanceBefore - amount, "AVS balance incorrect"); + assertEq(rewardToken.balanceOf(address(rewardsCoordinator)), rcBalanceBefore + amount, "RC balance incorrect"); + assertEq(rewardToken.balanceOf(feeRecipient), feeBalanceBefore, "Fee recipient balance should not change"); + } + } } contract RewardsCoordinatorUnitTests_createTotalStakeRewardsSubmission is RewardsCoordinatorUnitTests { @@ -4905,6 +5301,120 @@ contract RewardsCoordinatorUnitTests_createTotalStakeRewardsSubmission is Reward "RewardsCoordinator balance not incremented by amount" ); } + + /// @notice Test fee deduction for createTotalStakeRewardsSubmission when opted in + function testFuzz_createTotalStakeRewardsSubmission_WithProtocolFee_OptedIn( + address avs, + uint startTimestamp, + uint duration, + uint amount + ) public filterFuzzedAddressInputs(avs) { + cheats.assume(avs != address(0)); + + // Setup operator set + OperatorSet memory operatorSet = OperatorSet(avs, 1); + allocationManagerMock.setIsOperatorSet(operatorSet, true); + + // 1. Bound fuzz inputs to valid ranges and amounts + IERC20 rewardToken = new ERC20PresetFixedSupply("dog wif hat", "MOCK1", mockTokenInitialSupply, avs); + amount = bound(amount, 1, mockTokenInitialSupply); + duration = bound(duration, CALCULATION_INTERVAL_SECONDS, MAX_REWARDS_DURATION); + duration = duration - (duration % CALCULATION_INTERVAL_SECONDS); + startTimestamp = bound( + startTimestamp, + uint(_maxTimestamp(GENESIS_REWARDS_TIMESTAMP, uint32(block.timestamp) - MAX_RETROACTIVE_LENGTH)) + CALCULATION_INTERVAL_SECONDS + - 1, + block.timestamp + uint(MAX_FUTURE_LENGTH) + ); + startTimestamp = startTimestamp - (startTimestamp % CALCULATION_INTERVAL_SECONDS); + + // 2. Opt in for protocol fees + cheats.prank(avs); + rewardsCoordinator.setOptInForProtocolFee(avs, true); + + // 3. Create rewards submission and verify + RewardsSubmission[] memory rewardsSubmissions = new RewardsSubmission[](1); + rewardsSubmissions[0] = RewardsSubmission({ + strategiesAndMultipliers: defaultStrategyAndMultipliers, + token: rewardToken, + amount: amount, + startTimestamp: uint32(startTimestamp), + duration: uint32(duration) + }); + + uint feeAmount = (amount * 2000) / 10_000; + { + uint avsBalanceBefore = rewardToken.balanceOf(avs); + uint rcBalanceBefore = rewardToken.balanceOf(address(rewardsCoordinator)); + uint feeBalanceBefore = rewardToken.balanceOf(feeRecipient); + + cheats.startPrank(avs); + rewardToken.approve(address(rewardsCoordinator), amount); + rewardsCoordinator.createTotalStakeRewardsSubmission(operatorSet, rewardsSubmissions); + cheats.stopPrank(); + + // Verify balances + assertEq(rewardToken.balanceOf(avs), avsBalanceBefore - amount, "AVS balance incorrect"); + assertEq(rewardToken.balanceOf(address(rewardsCoordinator)), rcBalanceBefore + amount - feeAmount, "RC balance incorrect"); + assertEq(rewardToken.balanceOf(feeRecipient), feeBalanceBefore + feeAmount, "Fee balance incorrect"); + } + } + + /// @notice Test no fee deduction for createTotalStakeRewardsSubmission when not opted in + function testFuzz_createTotalStakeRewardsSubmission_WithProtocolFee_NotOptedIn( + address avs, + uint startTimestamp, + uint duration, + uint amount + ) public filterFuzzedAddressInputs(avs) { + cheats.assume(avs != address(0)); + + // Setup operator set + OperatorSet memory operatorSet = OperatorSet(avs, 1); + allocationManagerMock.setIsOperatorSet(operatorSet, true); + + // 1. Bound fuzz inputs to valid ranges and amounts + IERC20 rewardToken = new ERC20PresetFixedSupply("dog wif hat", "MOCK1", mockTokenInitialSupply, avs); + amount = bound(amount, 1, mockTokenInitialSupply); + duration = bound(duration, CALCULATION_INTERVAL_SECONDS, MAX_REWARDS_DURATION); + duration = duration - (duration % CALCULATION_INTERVAL_SECONDS); + startTimestamp = bound( + startTimestamp, + uint(_maxTimestamp(GENESIS_REWARDS_TIMESTAMP, uint32(block.timestamp) - MAX_RETROACTIVE_LENGTH)) + CALCULATION_INTERVAL_SECONDS + - 1, + block.timestamp + uint(MAX_FUTURE_LENGTH) + ); + startTimestamp = startTimestamp - (startTimestamp % CALCULATION_INTERVAL_SECONDS); + + // 2. Ensure submitter is not opted in (default state) + assertEq(rewardsCoordinator.isOptedInForProtocolFee(avs), false, "Submitter should not be opted in by default"); + + // 3. Create rewards submission and verify + RewardsSubmission[] memory rewardsSubmissions = new RewardsSubmission[](1); + rewardsSubmissions[0] = RewardsSubmission({ + strategiesAndMultipliers: defaultStrategyAndMultipliers, + token: rewardToken, + amount: amount, + startTimestamp: uint32(startTimestamp), + duration: uint32(duration) + }); + + { + uint avsBalanceBefore = rewardToken.balanceOf(avs); + uint rcBalanceBefore = rewardToken.balanceOf(address(rewardsCoordinator)); + uint feeBalanceBefore = rewardToken.balanceOf(feeRecipient); + + cheats.startPrank(avs); + rewardToken.approve(address(rewardsCoordinator), amount); + rewardsCoordinator.createTotalStakeRewardsSubmission(operatorSet, rewardsSubmissions); + cheats.stopPrank(); + + // Verify balances - no fee should be taken + assertEq(rewardToken.balanceOf(avs), avsBalanceBefore - amount, "AVS balance incorrect"); + assertEq(rewardToken.balanceOf(address(rewardsCoordinator)), rcBalanceBefore + amount, "RC balance incorrect"); + assertEq(rewardToken.balanceOf(feeRecipient), feeBalanceBefore, "Fee recipient balance should not change"); + } + } } contract RewardsCoordinatorUnitTests_submitRoot is RewardsCoordinatorUnitTests {