diff --git a/contracts/src/interfaces/IRewardsRegistry.sol b/contracts/src/interfaces/IRewardsRegistry.sol new file mode 100644 index 00000000..f4666c9e --- /dev/null +++ b/contracts/src/interfaces/IRewardsRegistry.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity >=0.5.0; + +/** + * @title Interface for errors in the RewardsRegistry contract + */ +interface IRewardsRegistryErrors { + /// @notice Thrown when a function is called by an address that is not the AVS. + error OnlyAVS(); + /// @notice Thrown when a function is called by an address that is not the RewardsAgent. + error OnlyRewardsAgent(); + /// @notice Thrown when rewards have already been claimed for the current merkle root. + error RewardsAlreadyClaimed(); + /// @notice Thrown when a provided merkle proof is invalid. + error InvalidMerkleProof(); + /// @notice Thrown when rewards transfer fails. + error RewardsTransferFailed(); + /// @notice Thrown when the rewards merkle root is not set. + error RewardsMerkleRootNotSet(); +} + +/** + * @title Interface for events in the RewardsRegistry contract + */ +interface IRewardsRegistryEvents { + /** + * @notice Emitted when a new merkle root is set + * @param oldRoot The previous merkle root + * @param newRoot The new merkle root + */ + event RewardsMerkleRootUpdated(bytes32 oldRoot, bytes32 newRoot); + + /** + * @notice Emitted when rewards are claimed + * @param operatorAddress Address of the operator receiving rewards + * @param points Points earned by the operator + * @param rewardsAmount Amount of rewards transferred + */ + event RewardsClaimed(address indexed operatorAddress, uint256 points, uint256 rewardsAmount); +} + +/** + * @title Interface for the RewardsRegistry contract + * @notice Contract for managing operator rewards through a Merkle root verification process + */ +interface IRewardsRegistry is IRewardsRegistryErrors, IRewardsRegistryEvents { + /** + * @notice Update the rewards merkle root + * @param newMerkleRoot New merkle root to be set + * @dev Only callable by the rewards agent + */ + function updateRewardsMerkleRoot( + bytes32 newMerkleRoot + ) external; + + /** + * @notice Claim rewards for an operator + * @param operatorAddress Address of the operator to receive rewards + * @param operatorPoints Points earned by the operator + * @param proof Merkle proof to validate the operator's rewards + * @dev Only callable by the AVS (Service Manager) + */ + function claimRewards( + address operatorAddress, + uint256 operatorPoints, + bytes32[] calldata proof + ) external; +} diff --git a/contracts/src/interfaces/IServiceManager.sol b/contracts/src/interfaces/IServiceManager.sol index 1089fd2c..c777013b 100644 --- a/contracts/src/interfaces/IServiceManager.sol +++ b/contracts/src/interfaces/IServiceManager.sol @@ -10,6 +10,7 @@ import {IAllocationManager} from "eigenlayer-contracts/src/contracts/interfaces/IAllocationManager.sol"; import {IStrategy} from "eigenlayer-contracts/src/contracts/interfaces/IStrategy.sol"; import {IAVSRegistrar} from "eigenlayer-contracts/src/contracts/interfaces/IAVSRegistrar.sol"; +import {IRewardsRegistry} from "./IRewardsRegistry.sol"; interface IServiceManagerErrors { /// @notice Thrown when a function is called by an address that is not the RegistryCoordinator. @@ -20,6 +21,10 @@ interface IServiceManagerErrors { error OnlyStakeRegistry(); /// @notice Thrown when a slashing proposal delay has not been met yet. error DelayPeriodNotPassed(); + /// @notice Thrown when the operator set does not have a rewards registry set. + error NoRewardsRegistryForOperatorSet(); + /// @notice Thrown when the operator is not part of the specified operator set. + error OperatorNotInOperatorSet(); } interface IServiceManagerEvents { @@ -29,6 +34,13 @@ interface IServiceManagerEvents { * @param newRewardsInitiator The new rewards initiator address. */ event RewardsInitiatorUpdated(address prevRewardsInitiator, address newRewardsInitiator); + + /** + * @notice Emitted when a rewards registry is set for an operator set. + * @param operatorSetId The ID of the operator set. + * @param rewardsRegistry The address of the rewards registry. + */ + event RewardsRegistrySet(uint32 indexed operatorSetId, address indexed rewardsRegistry); } interface IServiceManager is IServiceManagerUI, IServiceManagerErrors, IServiceManagerEvents { @@ -112,4 +124,24 @@ interface IServiceManager is IServiceManagerUI, IServiceManagerErrors, IServiceM * @return The address of the AVS */ function avs() external view returns (address); + + /** + * @notice Sets the rewards registry for an operator set + * @param operatorSetId The ID of the operator set + * @param rewardsRegistry The address of the rewards registry + * @dev Only callable by the owner + */ + function setRewardsRegistry(uint32 operatorSetId, IRewardsRegistry rewardsRegistry) external; + + /** + * @notice Claim rewards for an operator from the specified operator set + * @param operatorSetId The ID of the operator set + * @param operatorPoints Points earned by the operator + * @param proof Merkle proof to validate the operator's rewards + */ + function claimOperatorRewards( + uint32 operatorSetId, + uint256 operatorPoints, + bytes32[] calldata proof + ) external; } diff --git a/contracts/src/middleware/RewardsRegistry.sol b/contracts/src/middleware/RewardsRegistry.sol new file mode 100644 index 00000000..202ca023 --- /dev/null +++ b/contracts/src/middleware/RewardsRegistry.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; +import {RewardsRegistryStorage} from "./RewardsRegistryStorage.sol"; + +/** + * @title RewardsRegistry + * @notice Contract for managing operator rewards through a Merkle root verification process + */ +contract RewardsRegistry is RewardsRegistryStorage { + /** + * @notice Constructor to set up the rewards registry + * @param _avs Address of the AVS (Service Manager) + * @param _rewardsAgent Address of the rewards agent contract + */ + constructor(address _avs, address _rewardsAgent) RewardsRegistryStorage(_avs, _rewardsAgent) {} + + /** + * @notice Modifier to restrict function access to the rewards agent only + */ + modifier onlyRewardsAgent() { + if (msg.sender != rewardsAgent) { + revert OnlyRewardsAgent(); + } + _; + } + + /** + * @notice Modifier to restrict function access to the AVS only + */ + modifier onlyAVS() { + if (msg.sender != avs) { + revert OnlyAVS(); + } + _; + } + + /** + * @notice Update the rewards merkle root + * @param newMerkleRoot New merkle root to be set + * @dev Only callable by the rewards agent + */ + function updateRewardsMerkleRoot( + bytes32 newMerkleRoot + ) external override onlyRewardsAgent { + bytes32 oldRoot = lastRewardsMerkleRoot; + lastRewardsMerkleRoot = newMerkleRoot; + emit RewardsMerkleRootUpdated(oldRoot, newMerkleRoot); + } + + /** + * @notice Update the rewards agent address + * @param _rewardsAgent New rewards agent address + * @dev Only callable by the AVS + */ + function setRewardsAgent( + address _rewardsAgent + ) external onlyAVS { + rewardsAgent = _rewardsAgent; + } + + /** + * @notice Claim rewards for an operator + * @param operatorAddress Address of the operator to receive rewards + * @param operatorPoints Points earned by the operator + * @param proof Merkle proof to validate the operator's rewards + * @dev Only callable by the AVS (Service Manager) + */ + function claimRewards( + address operatorAddress, + uint256 operatorPoints, + bytes32[] calldata proof + ) external override onlyAVS { + // Check that the lastRewardsMerkleRoot is not the default value + if (lastRewardsMerkleRoot == bytes32(0)) { + revert RewardsMerkleRootNotSet(); + } + + // Check if operator has already claimed for this merkle root + if (operatorToLastClaimedRoot[operatorAddress] == lastRewardsMerkleRoot) { + revert RewardsAlreadyClaimed(); + } + + // Verify the merkle proof + bytes32 leaf = keccak256(abi.encode(operatorAddress, operatorPoints)); + if (!MerkleProof.verify(proof, lastRewardsMerkleRoot, leaf)) { + revert InvalidMerkleProof(); + } + + // Calculate rewards - currently 1 point = 1 wei (placeholder) + // TODO: Update the reward calculation formula with the proper relationship + uint256 rewardsAmount = operatorPoints; + + // Update the operator's last claimed root + operatorToLastClaimedRoot[operatorAddress] = lastRewardsMerkleRoot; + + // Transfer rewards to the operator + (bool success,) = operatorAddress.call{value: rewardsAmount}(""); + if (!success) { + revert RewardsTransferFailed(); + } + + emit RewardsClaimed(operatorAddress, operatorPoints, rewardsAmount); + } + + /** + * @notice Function to receive ETH + */ + receive() external payable {} +} diff --git a/contracts/src/middleware/RewardsRegistryStorage.sol b/contracts/src/middleware/RewardsRegistryStorage.sol new file mode 100644 index 00000000..b3e33535 --- /dev/null +++ b/contracts/src/middleware/RewardsRegistryStorage.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import {IRewardsRegistry} from "../interfaces/IRewardsRegistry.sol"; + +/** + * @title Storage variables for the RewardsRegistry contract + * @notice This storage contract is separate from the logic to simplify the upgrade process + */ +abstract contract RewardsRegistryStorage is IRewardsRegistry { + /** + * + * IMMUTABLES + * + */ + + /// @notice Address of the AVS (Service Manager) + address public immutable avs; + + /** + * + * STATE VARIABLES + * + */ + + /// @notice Address of the rewards agent contract + address public rewardsAgent; + + /// @notice Last rewards merkle root + bytes32 public lastRewardsMerkleRoot; + + /// @notice Mapping from operator ID to the last claimed merkle root + mapping(address => bytes32) public operatorToLastClaimedRoot; + + /** + * @notice Constructor to set up the immutable AVS address + * @param _avs Address of the AVS (Service Manager) + * @param _rewardsAgent Address of the rewards agent contract + */ + constructor(address _avs, address _rewardsAgent) { + avs = _avs; + rewardsAgent = _rewardsAgent; + } + + // storage gap for upgradeability + uint256[49] private __GAP; +} diff --git a/contracts/src/middleware/ServiceManagerBase.sol b/contracts/src/middleware/ServiceManagerBase.sol index 71228918..d948366e 100644 --- a/contracts/src/middleware/ServiceManagerBase.sol +++ b/contracts/src/middleware/ServiceManagerBase.sol @@ -21,6 +21,7 @@ import {IPermissionController} from "eigenlayer-contracts/src/contracts/interfaces/IPermissionController.sol"; import {IServiceManager, IServiceManagerUI} from "../interfaces/IServiceManager.sol"; +import {IRewardsRegistry} from "../interfaces/IRewardsRegistry.sol"; import {IVetoableSlasher} from "../interfaces/IVetoableSlasher.sol"; import {ServiceManagerBaseStorage} from "./ServiceManagerBaseStorage.sol"; @@ -263,6 +264,55 @@ abstract contract ServiceManagerBase is ServiceManagerBaseStorage, IAVSRegistrar return address(this); } + /** + * @notice Sets the rewards initiator address + * @param newRewardsInitiator The new rewards initiator address + * @dev only callable by the owner + */ + function setRewardsInitiator( + address newRewardsInitiator + ) external virtual onlyOwner { + _setRewardsInitiator(newRewardsInitiator); + } + + /** + * @notice Sets the rewards registry for an operator set + * @param operatorSetId The ID of the operator set + * @param rewardsRegistry The address of the rewards registry + * @dev Only callable by the owner + */ + function setRewardsRegistry( + uint32 operatorSetId, + IRewardsRegistry rewardsRegistry + ) external virtual override onlyOwner { + operatorSetToRewardsRegistry[operatorSetId] = rewardsRegistry; + emit RewardsRegistrySet(operatorSetId, address(rewardsRegistry)); + } + + /** + * @notice Claim rewards for an operator from the specified operator set + * @param operatorSetId The ID of the operator set + * @param operatorPoints Points earned by the operator + * @param proof Merkle proof to validate the operator's rewards + */ + function claimOperatorRewards( + uint32 operatorSetId, + uint256 operatorPoints, + bytes32[] calldata proof + ) external virtual override { + // Get the rewards registry for this operator set + IRewardsRegistry rewardsRegistry = operatorSetToRewardsRegistry[operatorSetId]; + if (address(rewardsRegistry) == address(0)) { + revert NoRewardsRegistryForOperatorSet(); + } + + // Ensure the operator is part of the operator set + _ensureOperatorIsPartOfOperatorSet(msg.sender, operatorSetId); + + // Forward the claim to the rewards registry + rewardsRegistry.claimRewards(msg.sender, operatorPoints, proof); + } + /** * @notice Forwards a call to Eigenlayer's RewardsCoordinator contract to set the address of the entity that can call `processClaim` on behalf of this contract. * @param claimer The address of the entity that can call `processClaim` on behalf of the earner @@ -274,24 +324,6 @@ abstract contract ServiceManagerBase is ServiceManagerBaseStorage, IAVSRegistrar _rewardsCoordinator.setClaimerFor(claimer); } - /** - * @notice Sets the rewards initiator address - * @param newRewardsInitiator The new rewards initiator address - * @dev only callable by the owner - */ - function setRewardsInitiator( - address newRewardsInitiator - ) external onlyOwner { - _setRewardsInitiator(newRewardsInitiator); - } - - function _setRewardsInitiator( - address newRewardsInitiator - ) internal { - emit RewardsInitiatorUpdated(rewardsInitiator, newRewardsInitiator); - rewardsInitiator = newRewardsInitiator; - } - /** * @notice Returns the list of strategies that the AVS supports for restaking * @dev This function is intended to be called off-chain @@ -343,6 +375,38 @@ abstract contract ServiceManagerBase is ServiceManagerBaseStorage, IAVSRegistrar IRewardsCoordinator.RewardsSubmission[] calldata rewardsSubmissions ) external virtual override {} + /** + * @notice Ensure the operator is part of the operator set + * @param operator The operator address + * @param operatorSetId The operator set ID + * @dev Reverts if the operator is not part of the operator set + */ + function _ensureOperatorIsPartOfOperatorSet( + address operator, + uint32 operatorSetId + ) internal view virtual { + // Make sure the operator is part of the received operator + OperatorSet memory operatorSet = OperatorSet({avs: address(this), id: operatorSetId}); + if (!_allocationManager.isMemberOfOperatorSet(operator, operatorSet)) { + revert OperatorNotInOperatorSet(); + } + } + + /** + * @dev Internal function to handle setting a rewards initiator + * @param _rewardsInitiator The new rewards initiator + */ + function _setRewardsInitiator( + address _rewardsInitiator + ) internal { + address prevRewardsInitiator = rewardsInitiator; + rewardsInitiator = _rewardsInitiator; + emit RewardsInitiatorUpdated(prevRewardsInitiator, _rewardsInitiator); + } + + /** + * @dev Verifies that the caller is the appointed rewardsInitiator + */ function _checkRewardsInitiator() internal view { require(msg.sender == rewardsInitiator, OnlyRewardsInitiator()); } diff --git a/contracts/src/middleware/ServiceManagerBaseStorage.sol b/contracts/src/middleware/ServiceManagerBaseStorage.sol index bc17620d..a90662fb 100644 --- a/contracts/src/middleware/ServiceManagerBaseStorage.sol +++ b/contracts/src/middleware/ServiceManagerBaseStorage.sol @@ -14,6 +14,7 @@ import {IPermissionController} from import {IVetoableSlasher} from "../interfaces/IVetoableSlasher.sol"; import {IServiceManager} from "../interfaces/IServiceManager.sol"; +import {IRewardsRegistry} from "../interfaces/IRewardsRegistry.sol"; /** * @title Storage variables for the `ServiceManagerBase` contract. @@ -42,6 +43,9 @@ abstract contract ServiceManagerBaseStorage is IServiceManager, OwnableUpgradeab /// @notice The address of the entity that can initiate rewards address public rewardsInitiator; + /// @notice Mapping from operator set ID to its respective RewardsRegistry + mapping(uint32 => IRewardsRegistry) public operatorSetToRewardsRegistry; + /// @notice Sets the (immutable) rewardsCoordinator`, `_permissionController`, and `_allocationManager` addresses constructor( IRewardsCoordinator __rewardsCoordinator, diff --git a/contracts/test/RewardsRegistry.t.sol b/contracts/test/RewardsRegistry.t.sol new file mode 100644 index 00000000..33f0a24d --- /dev/null +++ b/contracts/test/RewardsRegistry.t.sol @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test, console, stdError} from "forge-std/Test.sol"; +import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; + +import {MockAVSDeployer} from "./utils/MockAVSDeployer.sol"; +import {RewardsRegistry} from "../src/middleware/RewardsRegistry.sol"; +import {IRewardsRegistry, IRewardsRegistryErrors} from "../src/interfaces/IRewardsRegistry.sol"; + +contract RewardsRegistryTest is MockAVSDeployer { + // Contract instances + RewardsRegistry public rewardsRegistry; + + // Test addresses + address public rewardsAgent; + address public nonRewardsAgent; + address public operatorAddress; + + // Test data + bytes32 public merkleRoot; + bytes32 public newMerkleRoot; + uint256 public operatorPoints; + bytes32[] public validProof; + bytes32[] public invalidProof; + + // Events + event RewardsMerkleRootUpdated(bytes32 oldRoot, bytes32 newRoot); + event RewardsClaimed(address indexed operatorAddress, uint256 points, uint256 rewardsAmount); + + function setUp() public { + _deployMockEigenLayerAndAVS(); + + // Set up test addresses + rewardsAgent = address(0x1234); + nonRewardsAgent = address(0x5678); + operatorAddress = address(0xABCD); + + // Deploy the RewardsRegistry contract + rewardsRegistry = new RewardsRegistry(address(serviceManager), rewardsAgent); + + // Set up test data + operatorPoints = 100; + + // For testing MerkleProof verification, we'll use the simplest case: + // A binary tree with just our target leaf and a sibling leaf + // Our leaf (the one we want to prove exists in the tree) + bytes32 leaf = keccak256(abi.encode(operatorAddress, operatorPoints)); + + // Sibling leaf (another element in the Merkle tree) + bytes32 siblingLeaf = keccak256(abi.encodePacked("sibling")); + + // Sort leaves to follow the canonical order used by most Merkle tree libraries + (bytes32 leftLeaf, bytes32 rightLeaf) = + leaf < siblingLeaf ? (leaf, siblingLeaf) : (siblingLeaf, leaf); + + // Calculate parent node (this will be the Merkle root for our simple tree) + merkleRoot = keccak256(abi.encodePacked(leftLeaf, rightLeaf)); + + // The proof to verify our leaf is just the sibling leaf + validProof = new bytes32[](1); + validProof[0] = siblingLeaf; + + // For tests that need a second Merkle root + bytes32 newSiblingLeaf = keccak256(abi.encodePacked("new sibling")); + (leftLeaf, rightLeaf) = + leaf < newSiblingLeaf ? (leaf, newSiblingLeaf) : (newSiblingLeaf, leaf); + newMerkleRoot = keccak256(abi.encodePacked(leftLeaf, rightLeaf)); + + // An invalid proof + invalidProof = new bytes32[](1); + invalidProof[0] = keccak256(abi.encodePacked("wrong sibling")); + } + + // Helper to test our proof construction + function test_verifyProofConstruction() public view { + bytes32 leaf = keccak256(abi.encode(operatorAddress, operatorPoints)); + bool result = MerkleProof.verify(validProof, merkleRoot, leaf); + assertTrue(result, "Proof verification should succeed"); + } + + /** + * + * Constructor Tests * + * + */ + function test_constructor() public view { + assertEq( + rewardsRegistry.avs(), address(serviceManager), "AVS address should be set correctly" + ); + assertEq( + rewardsRegistry.rewardsAgent(), + rewardsAgent, + "Rewards agent address should be set correctly" + ); + } + + /** + * + * updateRewardsMerkleRoot Tests * + * + */ + function test_updateRewardsMerkleRoot() public { + vm.prank(rewardsAgent); + + vm.expectEmit(true, true, true, true); + emit RewardsMerkleRootUpdated(bytes32(0), merkleRoot); + + rewardsRegistry.updateRewardsMerkleRoot(merkleRoot); + + assertEq( + rewardsRegistry.lastRewardsMerkleRoot(), merkleRoot, "Merkle root should be updated" + ); + } + + function test_updateRewardsMerkleRoot_NotRewardsAgent() public { + vm.prank(nonRewardsAgent); + + vm.expectRevert(abi.encodeWithSelector(IRewardsRegistryErrors.OnlyRewardsAgent.selector)); + + rewardsRegistry.updateRewardsMerkleRoot(merkleRoot); + } + + function test_updateRewardsMerkleRoot_EmitEvent() public { + // First update + vm.prank(rewardsAgent); + rewardsRegistry.updateRewardsMerkleRoot(merkleRoot); + + // Second update with expectation of emitting event with correct old and new roots + vm.prank(rewardsAgent); + + vm.expectEmit(true, true, true, true); + emit RewardsMerkleRootUpdated(merkleRoot, newMerkleRoot); + + rewardsRegistry.updateRewardsMerkleRoot(newMerkleRoot); + } + + /** + * + * setRewardsAgent Tests * + * + */ + function test_setRewardsAgent() public { + address newRewardsAgent = address(0x9876); + + vm.prank(address(serviceManager)); + rewardsRegistry.setRewardsAgent(newRewardsAgent); + + assertEq(rewardsRegistry.rewardsAgent(), newRewardsAgent, "Rewards agent should be updated"); + } + + function test_setRewardsAgent_NotAVS() public { + vm.prank(nonRewardsAgent); + + vm.expectRevert(abi.encodeWithSelector(IRewardsRegistryErrors.OnlyAVS.selector)); + + rewardsRegistry.setRewardsAgent(address(0x9876)); + } + + /** + * + * claimRewards Tests * + * + */ + function test_claimRewards() public { + // First update merkle root + vm.prank(rewardsAgent); + rewardsRegistry.updateRewardsMerkleRoot(merkleRoot); + + // Add ETH to contract for rewards + vm.deal(address(rewardsRegistry), 1000 ether); + + uint256 initialBalance = operatorAddress.balance; + + vm.prank(address(serviceManager)); + + vm.expectEmit(true, true, true, true); + emit RewardsClaimed(operatorAddress, operatorPoints, operatorPoints); + + rewardsRegistry.claimRewards(operatorAddress, operatorPoints, validProof); + + // Verify state changes + assertEq( + rewardsRegistry.operatorToLastClaimedRoot(operatorAddress), + merkleRoot, + "Operator's last claimed root should be updated" + ); + assertEq( + operatorAddress.balance, + initialBalance + operatorPoints, + "Operator should receive correct rewards" + ); + } + + function test_claimRewards_NotAVS() public { + vm.prank(rewardsAgent); + rewardsRegistry.updateRewardsMerkleRoot(merkleRoot); + + vm.prank(nonRewardsAgent); + + vm.expectRevert(abi.encodeWithSelector(IRewardsRegistryErrors.OnlyAVS.selector)); + + rewardsRegistry.claimRewards(operatorAddress, operatorPoints, validProof); + } + + function test_claimRewards_AlreadyClaimed() public { + // First update merkle root + vm.prank(rewardsAgent); + rewardsRegistry.updateRewardsMerkleRoot(merkleRoot); + + // Add ETH to contract for rewards + vm.deal(address(rewardsRegistry), 1000 ether); + + // First claim succeeds + vm.prank(address(serviceManager)); + rewardsRegistry.claimRewards(operatorAddress, operatorPoints, validProof); + + // Second claim fails + vm.prank(address(serviceManager)); + vm.expectRevert( + abi.encodeWithSelector(IRewardsRegistryErrors.RewardsAlreadyClaimed.selector) + ); + rewardsRegistry.claimRewards(operatorAddress, operatorPoints, validProof); + } + + function test_claimRewards_InvalidProof() public { + vm.prank(rewardsAgent); + rewardsRegistry.updateRewardsMerkleRoot(merkleRoot); + + vm.prank(address(serviceManager)); + vm.expectRevert(abi.encodeWithSelector(IRewardsRegistryErrors.InvalidMerkleProof.selector)); + rewardsRegistry.claimRewards(operatorAddress, operatorPoints, invalidProof); + } + + function test_claimRewards_NoMerkleRoot() public { + // lastRewardsMerkleRoot is not set + vm.prank(address(serviceManager)); + vm.expectRevert( + abi.encodeWithSelector(IRewardsRegistryErrors.RewardsMerkleRootNotSet.selector) + ); + rewardsRegistry.claimRewards(operatorAddress, operatorPoints, validProof); + } + + function test_claimRewards_DifferentRoot() public { + // First merkle root + vm.prank(rewardsAgent); + rewardsRegistry.updateRewardsMerkleRoot(merkleRoot); + + // Add ETH to contract for rewards + vm.deal(address(rewardsRegistry), 1000 ether); + + // First claim succeeds + vm.prank(address(serviceManager)); + rewardsRegistry.claimRewards(operatorAddress, operatorPoints, validProof); + + // Update to new merkle root + vm.prank(rewardsAgent); + rewardsRegistry.updateRewardsMerkleRoot(newMerkleRoot); + + // Create a new valid proof for the new root + bytes32[] memory newProof = new bytes32[](1); + bytes32 newSiblingLeaf = keccak256(abi.encodePacked("new sibling")); + newProof[0] = newSiblingLeaf; + + // Operator can claim again with new merkle root + vm.prank(address(serviceManager)); + rewardsRegistry.claimRewards(operatorAddress, operatorPoints, newProof); + + assertEq( + rewardsRegistry.operatorToLastClaimedRoot(operatorAddress), + newMerkleRoot, + "Operator's last claimed root should be updated to new root" + ); + } + + function test_claimRewards_InsufficientBalance() public { + // Set merkle root + vm.prank(rewardsAgent); + rewardsRegistry.updateRewardsMerkleRoot(merkleRoot); + + // No ETH in contract for rewards - ensure contract has 0 balance + vm.deal(address(rewardsRegistry), 0); + + vm.prank(address(serviceManager)); + vm.expectRevert( + abi.encodeWithSelector(IRewardsRegistryErrors.RewardsTransferFailed.selector) + ); + rewardsRegistry.claimRewards(operatorAddress, operatorPoints, validProof); + } + + function test_receive() public { + // Test that the contract can receive ETH + uint256 amount = 1 ether; + vm.deal(address(this), amount); + + (bool success,) = address(rewardsRegistry).call{value: amount}(""); + assertTrue(success, "Contract should be able to receive ETH"); + assertEq(address(rewardsRegistry).balance, amount, "Contract balance should increase"); + } +} diff --git a/contracts/test/ServiceManagerRewardsRegistry.t.sol b/contracts/test/ServiceManagerRewardsRegistry.t.sol new file mode 100644 index 00000000..ad38922f --- /dev/null +++ b/contracts/test/ServiceManagerRewardsRegistry.t.sol @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test, console, stdError} from "forge-std/Test.sol"; +import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; + +import {MockAVSDeployer} from "./utils/MockAVSDeployer.sol"; +import {RewardsRegistry} from "../src/middleware/RewardsRegistry.sol"; +import {IRewardsRegistry, IRewardsRegistryErrors} from "../src/interfaces/IRewardsRegistry.sol"; +import {ServiceManagerMock} from "./mocks/ServiceManagerMock.sol"; +import {IServiceManager, IServiceManagerErrors} from "../src/interfaces/IServiceManager.sol"; + +contract ServiceManagerRewardsRegistryTest is MockAVSDeployer { + // Contract instances + RewardsRegistry public rewardsRegistry; + + // Test addresses + address public rewardsAgent; + address public operatorAddress; + address public nonOperatorAddress; + + // Test data + uint32 public operatorSetId; + bytes32 public merkleRoot; + uint256 public operatorPoints; + bytes32[] public validProof; + + // Events + event RewardsRegistrySet(uint32 indexed operatorSetId, address indexed rewardsRegistry); + event RewardsClaimed(address indexed operatorAddress, uint256 points, uint256 rewardsAmount); + + function setUp() public { + _deployMockEigenLayerAndAVS(); + + // Set up test addresses + rewardsAgent = address(0x1234); + operatorAddress = address(0xABCD); + nonOperatorAddress = address(0x5678); + + // Deploy the RewardsRegistry contract + rewardsRegistry = new RewardsRegistry(address(serviceManager), rewardsAgent); + + // Configure test data + operatorSetId = 1; + operatorPoints = 100; + + // Create a merkle tree where we know what the root should be based on our leaf + bytes32 leaf = keccak256(abi.encode(operatorAddress, operatorPoints)); + bytes32 siblingLeaf = keccak256(abi.encodePacked("sibling")); + (bytes32 leftLeaf, bytes32 rightLeaf) = + leaf < siblingLeaf ? (leaf, siblingLeaf) : (siblingLeaf, leaf); + merkleRoot = keccak256(abi.encodePacked(leftLeaf, rightLeaf)); + validProof = new bytes32[](1); + validProof[0] = siblingLeaf; + + // Set up the rewards registry for the operator set + vm.prank(avsOwner); + serviceManager.setRewardsRegistry(operatorSetId, IRewardsRegistry(address(rewardsRegistry))); + + // Set the merkle root + vm.prank(rewardsAgent); + rewardsRegistry.updateRewardsMerkleRoot(merkleRoot); + + // Add funds to the registry for rewards + vm.deal(address(rewardsRegistry), 1000 ether); + } + + function test_setRewardsRegistry() public { + uint32 newOperatorSetId = 2; + RewardsRegistry newRewardsRegistry = + new RewardsRegistry(address(serviceManager), rewardsAgent); + + vm.prank(avsOwner); + vm.expectEmit(true, true, true, true); + emit RewardsRegistrySet(newOperatorSetId, address(newRewardsRegistry)); + + serviceManager.setRewardsRegistry( + newOperatorSetId, IRewardsRegistry(address(newRewardsRegistry)) + ); + + assertEq( + address(serviceManager.getOperatorSetRewardsRegistry(newOperatorSetId)), + address(newRewardsRegistry), + "Rewards registry should be set correctly" + ); + } + + function test_setRewardsRegistry_NotOwner() public { + uint32 newOperatorSetId = 2; + RewardsRegistry newRewardsRegistry = + new RewardsRegistry(address(serviceManager), rewardsAgent); + + vm.prank(nonOperatorAddress); + vm.expectRevert(bytes("Ownable: caller is not the owner")); + + serviceManager.setRewardsRegistry( + newOperatorSetId, IRewardsRegistry(address(newRewardsRegistry)) + ); + } + + function test_claimOperatorRewards() public { + uint256 initialBalance = operatorAddress.balance; + + vm.prank(operatorAddress); + vm.expectEmit(true, true, true, true); + emit RewardsClaimed(operatorAddress, operatorPoints, operatorPoints); + + serviceManager.claimOperatorRewards(operatorSetId, operatorPoints, validProof); + + assertEq( + operatorAddress.balance, + initialBalance + operatorPoints, + "Operator should receive correct rewards" + ); + } + + function test_claimOperatorRewards_NoRewardsRegistry() public { + uint32 invalidSetId = 999; + + vm.prank(operatorAddress); + vm.expectRevert( + abi.encodeWithSelector(IServiceManagerErrors.NoRewardsRegistryForOperatorSet.selector) + ); + + serviceManager.claimOperatorRewards(invalidSetId, operatorPoints, validProof); + } + + function test_claimOperatorRewards_AlreadyClaimed() public { + // First claim + vm.prank(operatorAddress); + serviceManager.claimOperatorRewards(operatorSetId, operatorPoints, validProof); + + // Second claim should fail + vm.prank(operatorAddress); + vm.expectRevert( + abi.encodeWithSelector(IRewardsRegistryErrors.RewardsAlreadyClaimed.selector) + ); + + serviceManager.claimOperatorRewards(operatorSetId, operatorPoints, validProof); + } + + function test_integration_multipleOperatorSets() public { + // Set up a second operator set with a different registry + uint32 secondOperatorSetId = 2; + RewardsRegistry secondRegistry = new RewardsRegistry(address(serviceManager), rewardsAgent); + + // Set up the second registry + vm.prank(avsOwner); + serviceManager.setRewardsRegistry( + secondOperatorSetId, IRewardsRegistry(address(secondRegistry)) + ); + + // Create a different merkle root for the second registry + bytes32 secondLeaf = keccak256(abi.encode(operatorAddress, operatorPoints)); + bytes32 secondSiblingLeaf = keccak256(abi.encodePacked("second sibling")); + (bytes32 leftLeaf, bytes32 rightLeaf) = secondLeaf < secondSiblingLeaf + ? (secondLeaf, secondSiblingLeaf) + : (secondSiblingLeaf, secondLeaf); + bytes32 secondMerkleRoot = keccak256(abi.encodePacked(leftLeaf, rightLeaf)); + + // Set the merkle root in the second registry + vm.prank(rewardsAgent); + secondRegistry.updateRewardsMerkleRoot(secondMerkleRoot); + + // Fund the second registry + vm.deal(address(secondRegistry), 1000 ether); + + // Create proof for second registry + bytes32[] memory secondProof = new bytes32[](1); + secondProof[0] = secondSiblingLeaf; + + // Claim from first registry + uint256 initialBalance = operatorAddress.balance; + vm.prank(operatorAddress); + serviceManager.claimOperatorRewards(operatorSetId, operatorPoints, validProof); + + // Verify balance after first claim + assertEq( + operatorAddress.balance, + initialBalance + operatorPoints, + "Operator should receive correct rewards from first registry" + ); + + // Claim from second registry + uint256 balanceAfterFirstClaim = operatorAddress.balance; + vm.prank(operatorAddress); + serviceManager.claimOperatorRewards(secondOperatorSetId, operatorPoints, secondProof); + + // Verify balance after second claim + assertEq( + operatorAddress.balance, + balanceAfterFirstClaim + operatorPoints, + "Operator should receive correct rewards from second registry" + ); + } +} diff --git a/contracts/test/mocks/ServiceManagerMock.sol b/contracts/test/mocks/ServiceManagerMock.sol index 868740b6..abc9b7c7 100644 --- a/contracts/test/mocks/ServiceManagerMock.sol +++ b/contracts/test/mocks/ServiceManagerMock.sol @@ -7,6 +7,9 @@ import {IPermissionController} from "eigenlayer-contracts/src/contracts/interfaces/IPermissionController.sol"; import {IAllocationManager} from "eigenlayer-contracts/src/contracts/interfaces/IAllocationManager.sol"; +import {IRewardsRegistry} from "../../src/interfaces/IRewardsRegistry.sol"; +import {ISignatureUtilsMixinTypes} from + "eigenlayer-contracts/src/contracts/interfaces/ISignatureUtilsMixin.sol"; import {ServiceManagerBase} from "../../src/middleware/ServiceManagerBase.sol"; import {ServiceManagerBaseStorage} from "../../src/middleware/ServiceManagerBaseStorage.sol"; @@ -43,4 +46,28 @@ contract ServiceManagerMock is ServiceManagerBase { ) external override onlyOwner { _slasher = slasher; } + + /** + * @notice Get the rewards registry for an operator set (exposing for testing) + * @param operatorSetId The ID of the operator set + * @return The rewards registry for the operator set + */ + function getOperatorSetRewardsRegistry( + uint32 operatorSetId + ) external view returns (IRewardsRegistry) { + return operatorSetToRewardsRegistry[operatorSetId]; + } + + /** + * @notice Override the internal _ensureOperatorIsPartOfOperatorSet function to simplify testing + * @param operator The operator address + * @param operatorSetId The operator set ID + * @dev This should be removed once the AllocationManagerMock is updated to be able to handle operator sets + */ + function _ensureOperatorIsPartOfOperatorSet( + address operator, + uint32 operatorSetId + ) internal view override { + // No-op for testing + } }