From 13fafcf2b72ad791ec2f35d62ec31c10975728af Mon Sep 17 00:00:00 2001 From: Tobi Demeco <50408393+TDemeco@users.noreply.github.com> Date: Mon, 31 Mar 2025 16:54:23 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20initial=20rewards=20registr?= =?UTF-8?q?y=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds an initial implementation for a rewards registry, which will be the contract in charge of allowing DataHaven validators to claim the rewards they earned for being validators in the previous epoch. The logic behind it is as follows: - Whenever an epoch finishes, the corresponding BEEFY block gets relayed to Ethereum through Snowbridge. This BEEFY block contains, in its `extra` field, the merkle root of the tree that contains as leafs all the message commitments of the messages of corresponding block, one of which is the rewards distribution message. - The rewards distribution message commitment is the root of the merkle tree where each leaf is a tuple of the operator ID and the obtained era points in the finished epoch. In this case, the operator ID is the corresponding validator's Ethereum address. - When the rewards distribution message is received, Snowbridge validates it using the aforementioned BEEFY block and then dispatches it. The dispatch invokes the `callContract` function of the `RewardsAgent` agent, with the corresponding parameters so that this agent calls the `updateRewardsMerkleRoot` function of the `RewardsRegistry` contract with the new rewards distribution message commitment. - After this root is updated, any validator/operator can submit a proof that it is in a leaf of the merkle tree that produced that root, which means it has pending rewards to claim, through the `ServiceManagerBase`'s `claimOperatorRewards` function. - Each operator set of the AVS can have an assigned `RewardsRegistry` contract. Operator sets that do not have an assigned `RewardsRegistry` contract won't be able to received rewards. This PR also adds two separate unit-test suites: one for the added functionality to the `ServiceManagerBase` contract and one specific to the new `RewardsRegistry` contract. > [!CAUTION] The `RewardsAgent` agent is the only one allowed to update the rewards' merkle root, which means if a malicious user could get access to it it could set the pending rewards to be claimed to an arbitrary tree that benefits it. Extreme caution must be taken in the Substrate side so only validated messages are sent to the Ethereum side, as to not allow any users to impersonate being this agent. ### TODO: Ideally, we would use the `RewardsCoordinator` contract from the EigenLayer core to distribute the rewards, but currently that adds a huge overhead for Operators since they'd have to wait for EigenLayer's SideCar to snapshot state and update the distribution root (which happens once a day), generate a proof that they belong to the tree of that distribution root, store it while waiting for the `activationDelay` (currently a week) to pass, and just then they would be able to claim their earned rewards. --------- Co-authored-by: Facundo Farall <37149322+ffarall@users.noreply.github.com> --- contracts/src/interfaces/IRewardsRegistry.sol | 68 ++++ contracts/src/interfaces/IServiceManager.sol | 32 ++ contracts/src/middleware/RewardsRegistry.sol | 111 +++++++ .../src/middleware/RewardsRegistryStorage.sol | 47 +++ .../src/middleware/ServiceManagerBase.sol | 100 ++++-- .../middleware/ServiceManagerBaseStorage.sol | 4 + contracts/test/RewardsRegistry.t.sol | 300 ++++++++++++++++++ .../test/ServiceManagerRewardsRegistry.t.sol | 196 ++++++++++++ contracts/test/mocks/ServiceManagerMock.sol | 27 ++ 9 files changed, 867 insertions(+), 18 deletions(-) create mode 100644 contracts/src/interfaces/IRewardsRegistry.sol create mode 100644 contracts/src/middleware/RewardsRegistry.sol create mode 100644 contracts/src/middleware/RewardsRegistryStorage.sol create mode 100644 contracts/test/RewardsRegistry.t.sol create mode 100644 contracts/test/ServiceManagerRewardsRegistry.t.sol 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 + } }