From d8d792874c91134b97a3540194a372a0b9b364db Mon Sep 17 00:00:00 2001 From: Tobi Demeco <50408393+TDemeco@users.noreply.github.com> Date: Thu, 27 Mar 2025 12:20:15 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20initial=20slasher=20impleme?= =?UTF-8?q?ntation=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR: - Sets up the slasher infrastructure with the base functionality required (in `ISlasher.sol`, `SlasherBase.sol` and `SlasherBaseStorage.sol`) and adds the tests for it (in `SlasherBase.t.sol`). - Adds an implementation of a more complex slasher (in `IVetoableSlasher.sol` and `VetoableSlasher.sol`) and tests for it (in `VetoableSlasher.t.sol`). - Modifies the `ServiceManagerBase` contract to use the new `VetoableSlasher` contract to manage slashing. - Updates mocks and tests to reflect the newly added functionality. --------- Co-authored-by: Facundo Farall <37149322+ffarall@users.noreply.github.com> --- contracts/src/interfaces/IServiceManager.sol | 6 + contracts/src/interfaces/ISlasher.sol | 39 ++ contracts/src/interfaces/IVetoableSlasher.sol | 86 ++++ .../src/middleware/ServiceManagerBase.sol | 43 +- .../middleware/ServiceManagerBaseStorage.sol | 6 +- contracts/src/middleware/SlasherBase.sol | 61 +++ .../src/middleware/SlasherBaseStorage.sol | 32 ++ contracts/src/middleware/VetoableSlasher.sol | 128 ++++++ contracts/test/ServiceManagerBase.t.sol | 4 + contracts/test/SlasherBase.t.sol | 234 +++++++++++ contracts/test/VetoableSlasher.t.sol | 397 ++++++++++++++++++ contracts/test/mocks/ServiceManagerMock.sol | 12 + contracts/test/mocks/SlasherBaseMock.sol | 37 ++ contracts/test/utils/MockAVSDeployer.sol | 21 + 14 files changed, 1099 insertions(+), 7 deletions(-) create mode 100644 contracts/src/interfaces/ISlasher.sol create mode 100644 contracts/src/interfaces/IVetoableSlasher.sol create mode 100644 contracts/src/middleware/SlasherBase.sol create mode 100644 contracts/src/middleware/SlasherBaseStorage.sol create mode 100644 contracts/src/middleware/VetoableSlasher.sol create mode 100644 contracts/test/SlasherBase.t.sol create mode 100644 contracts/test/VetoableSlasher.t.sol create mode 100644 contracts/test/mocks/SlasherBaseMock.sol diff --git a/contracts/src/interfaces/IServiceManager.sol b/contracts/src/interfaces/IServiceManager.sol index 397fbb3c..1089fd2c 100644 --- a/contracts/src/interfaces/IServiceManager.sol +++ b/contracts/src/interfaces/IServiceManager.sol @@ -106,4 +106,10 @@ interface IServiceManager is IServiceManagerUI, IServiceManagerErrors, IServiceM address operator, uint32[] memory operatorSetIds ) external; + + /** + * @notice Returns the address of the AVS + * @return The address of the AVS + */ + function avs() external view returns (address); } diff --git a/contracts/src/interfaces/ISlasher.sol b/contracts/src/interfaces/ISlasher.sol new file mode 100644 index 00000000..ac26e2a9 --- /dev/null +++ b/contracts/src/interfaces/ISlasher.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import {IAllocationManager} from + "eigenlayer-contracts/src/contracts/interfaces/IAllocationManager.sol"; + +interface ISlasherErrors { + /// @notice Thrown when a caller without slasher privileges attempts a restricted operation + error OnlySlasher(); +} + +interface ISlasherTypes { + /// @notice Structure containing details about a slashing request + struct SlashingRequest { + IAllocationManager.SlashingParams params; + uint256 requestTimestamp; + } +} + +interface ISlasherEvents is ISlasherTypes { + /// @notice Emitted when an operator is successfully slashed + event OperatorSlashed( + uint256 indexed slashingRequestId, + address indexed operator, + uint32 indexed operatorSetId, + uint256[] wadsToSlash, + string description + ); +} + +/// @title ISlasher +/// @notice Base interface containing shared functionality for all slasher implementations +interface ISlasher is ISlasherErrors, ISlasherEvents { + /// @notice Returns the address authorized to create and fulfil slashing requests + function slasher() external view returns (address); + + /// @notice Returns the next slashing request ID + function nextRequestId() external view returns (uint256); +} diff --git a/contracts/src/interfaces/IVetoableSlasher.sol b/contracts/src/interfaces/IVetoableSlasher.sol new file mode 100644 index 00000000..33ea74b2 --- /dev/null +++ b/contracts/src/interfaces/IVetoableSlasher.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import {IAllocationManager} from + "eigenlayer-contracts/src/contracts/interfaces/IAllocationManager.sol"; +import {ISlasher} from "./ISlasher.sol"; + +interface IVetoableSlasherErrors { + /// @notice Thrown when a caller without veto committee privileges attempts a restricted operation + error OnlyVetoCommittee(); + /// @notice Thrown when attempting to veto a slashing request after the veto period has expired + error VetoPeriodPassed(); + /// @notice Thrown when attempting to execute a slashing request before the veto period has ended + error VetoPeriodNotPassed(); + /// @notice Thrown when attempting to interact with a slashing request that has been cancelled + error SlashingRequestIsCancelled(); + /// @notice Thrown when attempting to modify a slashing request that does not exist + error SlashingRequestNotRequested(); +} + +interface IVetoableSlasherTypes { + /// @notice Structure containing details about a vetoable slashing request + struct VetoableSlashingRequest { + IAllocationManager.SlashingParams params; + uint256 requestBlock; + bool isPending; + } +} + +interface IVetoableSlasherEvents { + /// @notice Emitted when a new slashing request is created + event SlashingRequested( + uint256 indexed requestId, + address indexed operator, + uint32 operatorSetId, + uint256[] wadsToSlash, + string description + ); + + /// @notice Emitted when a slashing request is cancelled by the veto committee + event SlashingRequestCancelled( + address indexed operator, uint32 operatorSetId, uint256[] wadsToSlash, string description + ); + + /// @notice Emitted when a slashing request is fulfilled + event SlashingRequestFulfilled( + address indexed operator, uint32 operatorSetId, uint256[] wadsToSlash, string description + ); +} + +/// @title IVetoableSlasher +/// @notice A slashing contract that implements a veto mechanism allowing a designated committee to cancel slashing requests +/// @dev Extends base interfaces and adds a veto period during which slashing requests can be cancelled +interface IVetoableSlasher is + ISlasher, + IVetoableSlasherErrors, + IVetoableSlasherTypes, + IVetoableSlasherEvents +{ + /// @notice Duration of the veto period during which the veto committee can cancel slashing requests + function vetoWindowBlocks() external view returns (uint32); + + /// @notice Address of the committee that has veto power over slashing requests + function vetoCommittee() external view returns (address); + + /// @notice Queues a new slashing request + /// @param params Parameters defining the slashing request including operator and amount + /// @dev Can only be called by the authorized slasher + function queueSlashingRequest( + IAllocationManager.SlashingParams calldata params + ) external; + + /// @notice Cancels a pending slashing request + /// @param requestId The ID of the slashing request to cancel + /// @dev Can only be called by the veto committee during the veto period + function cancelSlashingRequest( + uint256 requestId + ) external; + + /// @notice Executes a slashing request after the veto period has passed + /// @param requestId The ID of the slashing request to fulfil + /// @dev Can only be called by the authorized slasher after the veto period + function fulfilSlashingRequest( + uint256 requestId + ) external; +} diff --git a/contracts/src/middleware/ServiceManagerBase.sol b/contracts/src/middleware/ServiceManagerBase.sol index da52ccd1..71228918 100644 --- a/contracts/src/middleware/ServiceManagerBase.sol +++ b/contracts/src/middleware/ServiceManagerBase.sol @@ -21,7 +21,7 @@ import {IPermissionController} from "eigenlayer-contracts/src/contracts/interfaces/IPermissionController.sol"; import {IServiceManager, IServiceManagerUI} from "../interfaces/IServiceManager.sol"; - +import {IVetoableSlasher} from "../interfaces/IVetoableSlasher.sol"; import {ServiceManagerBaseStorage} from "./ServiceManagerBaseStorage.sol"; /** @@ -56,6 +56,17 @@ abstract contract ServiceManagerBase is ServiceManagerBaseStorage, IAVSRegistrar _setRewardsInitiator(_rewardsInitiator); } + /** + * @notice Sets the slasher contract + * @param slasher The slasher contract address + * @dev Only callable by the owner + */ + function setSlasher( + IVetoableSlasher slasher + ) external virtual onlyOwner { + _slasher = slasher; + } + /** * @notice Updates the metadata URI for the AVS * @param _metadataURI is the metadata URI for the AVS @@ -97,12 +108,27 @@ abstract contract ServiceManagerBase is ServiceManagerBaseStorage, IAVSRegistrar } /** - * Forwards the call to the AllocationManager.slashOperator() function + * Queue a slashing request in the vetoable slasher + * @param params Parameters defining the slashing request + * @dev Can only be called by the owner */ - function slashOperator( + function queueSlashingRequest( IAllocationManager.SlashingParams calldata params ) external virtual onlyOwner { - _allocationManager.slashOperator(address(this), params); + require(address(_slasher) != address(0), "Slasher not set"); + _slasher.queueSlashingRequest(params); + } + + /** + * fulfils a slashing request that has passed the veto period + * @param requestId The ID of the slashing request to fulfil + * @dev Can be called by anyone + */ + function fulfilSlashingRequest( + uint256 requestId + ) external virtual { + require(address(_slasher) != address(0), "Slasher not set"); + _slasher.fulfilSlashingRequest(requestId); } /** @@ -161,9 +187,9 @@ abstract contract ServiceManagerBase is ServiceManagerBaseStorage, IAVSRegistrar /// @inheritdoc IAVSRegistrar function supportsAVS( - address avs + address avsAddress ) external view virtual override returns (bool) { - return avs == address(this); + return avsAddress == this.avs(); } /// @inheritdoc IAVSRegistrar @@ -232,6 +258,11 @@ abstract contract ServiceManagerBase is ServiceManagerBaseStorage, IAVSRegistrar }); } + /// @inheritdoc IServiceManager + function avs() external view virtual returns (address) { + return address(this); + } + /** * @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 diff --git a/contracts/src/middleware/ServiceManagerBaseStorage.sol b/contracts/src/middleware/ServiceManagerBaseStorage.sol index da185082..bc17620d 100644 --- a/contracts/src/middleware/ServiceManagerBaseStorage.sol +++ b/contracts/src/middleware/ServiceManagerBaseStorage.sol @@ -12,6 +12,7 @@ import {IAllocationManager} from import {IPermissionController} from "eigenlayer-contracts/src/contracts/interfaces/IPermissionController.sol"; +import {IVetoableSlasher} from "../interfaces/IVetoableSlasher.sol"; import {IServiceManager} from "../interfaces/IServiceManager.sol"; /** @@ -35,10 +36,13 @@ abstract contract ServiceManagerBaseStorage is IServiceManager, OwnableUpgradeab * */ + /// @notice The slasher contract that handles operator slashing + IVetoableSlasher internal _slasher; + /// @notice The address of the entity that can initiate rewards address public rewardsInitiator; - /// @notice Sets the (immutable) `_avsDirectory`, `_rewardsCoordinator`, `_registryCoordinator`, `_stakeRegistry`, and `_allocationManager` addresses + /// @notice Sets the (immutable) rewardsCoordinator`, `_permissionController`, and `_allocationManager` addresses constructor( IRewardsCoordinator __rewardsCoordinator, IPermissionController __permissionController, diff --git a/contracts/src/middleware/SlasherBase.sol b/contracts/src/middleware/SlasherBase.sol new file mode 100644 index 00000000..1e060fb4 --- /dev/null +++ b/contracts/src/middleware/SlasherBase.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import {SlasherStorage, IServiceManager} from "./SlasherBaseStorage.sol"; +import { + IAllocationManagerTypes, + IAllocationManager +} from "eigenlayer-contracts/src/contracts/interfaces/IAllocationManager.sol"; +import {IStrategy} from "eigenlayer-contracts/src/contracts/interfaces/IStrategy.sol"; + +/// @title SlasherBase +/// @notice Base contract for implementing slashing functionality in an EigenLayer AVS +/// @dev Provides core slashing functionality and interfaces with EigenLayer's AllocationManager +abstract contract SlasherBase is SlasherStorage { + /// @notice Ensures only the authorized slasher can call certain functions + modifier onlySlasher() { + _checkSlasher(msg.sender); + _; + } + + /// @notice Constructs the base slasher contract + /// @param _allocationManager The EigenLayer allocation manager contract + /// @param _serviceManager The service manager that will manage this slasher + constructor( + IAllocationManager _allocationManager, + IServiceManager _serviceManager + ) SlasherStorage(_allocationManager, _serviceManager) {} + + /// @notice Internal function to execute a slashing request + /// @param _requestId The ID of the slashing request to fulfil + /// @param _params Parameters defining the slashing request including operator, strategies, and amounts + /// @dev Calls AllocationManager.slashOperator to perform the actual slashing + function _fulfilSlashingRequest( + uint256 _requestId, + IAllocationManager.SlashingParams memory _params + ) internal virtual { + allocationManager.slashOperator({avs: serviceManager.avs(), params: _params}); + emit OperatorSlashed( + _requestId, + _params.operator, + _params.operatorSetId, + _params.wadsToSlash, + _params.description + ); + } + + /// @notice Internal function to verify if an account is the authorized slasher + /// @param account The address to check + /// @dev Reverts if the account is not the ServiceManager + function _checkSlasher( + address account + ) internal view virtual { + require(account == address(serviceManager), OnlySlasher()); + } + + /// @notice Returns the address of the ServiceManager + /// @return The address of the ServiceManager + function slasher() external view returns (address) { + return address(serviceManager); + } +} diff --git a/contracts/src/middleware/SlasherBaseStorage.sol b/contracts/src/middleware/SlasherBaseStorage.sol new file mode 100644 index 00000000..88ff7067 --- /dev/null +++ b/contracts/src/middleware/SlasherBaseStorage.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import {IAllocationManager} from + "eigenlayer-contracts/src/contracts/interfaces/IAllocationManager.sol"; +import {ISlasher} from "../interfaces/ISlasher.sol"; +import {IServiceManager} from "../interfaces/IServiceManager.sol"; +/// @title SlasherStorage +/// @notice Base storage contract for slashing functionality +/// @dev Provides storage variables and events for slashing operations + +abstract contract SlasherStorage is ISlasher { + /** + * + * CONSTANTS AND IMMUTABLES + * + */ + + /// @notice the AllocationManager that tracks OperatorSets and Slashing in EigenLayer + IAllocationManager public immutable allocationManager; + /// @notice the ServiceManager of the AVS + IServiceManager public immutable serviceManager; + + uint256 public nextRequestId; + + constructor(IAllocationManager _allocationManager, IServiceManager _serviceManager) { + allocationManager = _allocationManager; + serviceManager = _serviceManager; + } + + uint256[49] private __gap; +} diff --git a/contracts/src/middleware/VetoableSlasher.sol b/contracts/src/middleware/VetoableSlasher.sol new file mode 100644 index 00000000..8cc417ac --- /dev/null +++ b/contracts/src/middleware/VetoableSlasher.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import {IStrategy} from "eigenlayer-contracts/src/contracts/interfaces/IStrategy.sol"; +import {IServiceManager} from "../interfaces/IServiceManager.sol"; +import {IAllocationManager} from + "eigenlayer-contracts/src/contracts/interfaces/IAllocationManager.sol"; +import {SlasherBase} from "./SlasherBase.sol"; +import {IVetoableSlasher, IVetoableSlasherTypes} from "../interfaces/IVetoableSlasher.sol"; + +/// @title VetoableSlasher +/// @notice A slashing contract that implements a veto mechanism allowing a designated committee to cancel slashing requests +/// @dev Extends SlasherBase and adds a veto period during which slashing requests can be cancelled +contract VetoableSlasher is IVetoableSlasher, SlasherBase { + /// @inheritdoc IVetoableSlasher + uint32 public immutable override vetoWindowBlocks; + + /// @inheritdoc IVetoableSlasher + address public immutable override vetoCommittee; + + /// @notice Mapping of request IDs to their corresponding slashing request details + mapping(uint256 => IVetoableSlasherTypes.VetoableSlashingRequest) public slashingRequests; + + /// @notice Modifier to restrict function access to only the veto committee + modifier onlyVetoCommittee() { + _checkVetoCommittee(msg.sender); + _; + } + + constructor( + IAllocationManager _allocationManager, + IServiceManager _serviceManager, + address _vetoCommittee, + uint32 _vetoWindowBlocks + ) SlasherBase(_allocationManager, _serviceManager) { + vetoWindowBlocks = _vetoWindowBlocks; + vetoCommittee = _vetoCommittee; + } + + /// @inheritdoc IVetoableSlasher + function queueSlashingRequest( + IAllocationManager.SlashingParams calldata params + ) external override onlySlasher { + _queueSlashingRequest(params); + } + + /// @inheritdoc IVetoableSlasher + function cancelSlashingRequest( + uint256 requestId + ) external override onlyVetoCommittee { + _cancelSlashingRequest(requestId); + } + + /// @inheritdoc IVetoableSlasher + function fulfilSlashingRequest( + uint256 requestId + ) external override { + _fulfilSlashingRequestAndMarkAsCompleted(requestId); + } + + /// @notice Internal function to create and store a new slashing request + /// @param params Parameters defining the slashing request + function _queueSlashingRequest( + IAllocationManager.SlashingParams memory params + ) internal virtual { + uint256 requestId = nextRequestId++; + slashingRequests[requestId] = IVetoableSlasherTypes.VetoableSlashingRequest({ + params: params, + requestBlock: block.number, + isPending: true + }); + + emit SlashingRequested( + requestId, params.operator, params.operatorSetId, params.wadsToSlash, params.description + ); + } + + /// @notice Internal function to mark a slashing request as cancelled + /// @param requestId The ID of the slashing request to cancel + function _cancelSlashingRequest( + uint256 requestId + ) internal virtual { + IVetoableSlasherTypes.VetoableSlashingRequest storage request = slashingRequests[requestId]; + require(block.number < request.requestBlock + vetoWindowBlocks, VetoPeriodPassed()); + require(request.isPending, SlashingRequestNotRequested()); + + emit SlashingRequestCancelled( + request.params.operator, + request.params.operatorSetId, + request.params.wadsToSlash, + request.params.description + ); + + delete slashingRequests[requestId]; + } + + /// @notice Internal function to fullfill a slashing request and mark it as completed + /// @param requestId The ID of the slashing request to fulfil + function _fulfilSlashingRequestAndMarkAsCompleted( + uint256 requestId + ) internal virtual { + IVetoableSlasherTypes.VetoableSlashingRequest storage request = slashingRequests[requestId]; + require(block.number >= request.requestBlock + vetoWindowBlocks, VetoPeriodNotPassed()); + require(request.isPending, SlashingRequestIsCancelled()); + + request.isPending = false; + + _fulfilSlashingRequest(requestId, request.params); + + emit SlashingRequestFulfilled( + request.params.operator, + request.params.operatorSetId, + request.params.wadsToSlash, + request.params.description + ); + + delete slashingRequests[requestId]; + } + + /// @notice Internal function to verify if an account is the veto committee + /// @param account The address to check + /// @dev Reverts if the account is not the veto committee + function _checkVetoCommittee( + address account + ) internal view virtual { + require(account == vetoCommittee, OnlyVetoCommittee()); + } +} diff --git a/contracts/test/ServiceManagerBase.t.sol b/contracts/test/ServiceManagerBase.t.sol index 92d5ea6d..42c484a9 100644 --- a/contracts/test/ServiceManagerBase.t.sol +++ b/contracts/test/ServiceManagerBase.t.sol @@ -61,4 +61,8 @@ contract ServiceManagerBaseTest is MockAVSDeployer { new IAllocationManager.CreateSetParams[](0); ServiceManagerBase(address(serviceManager)).createOperatorSets(emptyParams); } + + function test_returnsAVSAddress() public view { + assertEq(serviceManager.avs(), address(serviceManager)); + } } diff --git a/contracts/test/SlasherBase.t.sol b/contracts/test/SlasherBase.t.sol new file mode 100644 index 00000000..00828259 --- /dev/null +++ b/contracts/test/SlasherBase.t.sol @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.27; + +import {Test, console} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IStrategy} from "eigenlayer-contracts/src/contracts/interfaces/IStrategy.sol"; +import {IRewardsCoordinator} from + "eigenlayer-contracts/src/contracts/interfaces/IRewardsCoordinator.sol"; +import { + IAllocationManagerErrors, + IAllocationManager, + IAllocationManagerTypes +} from "eigenlayer-contracts/src/contracts/interfaces/IAllocationManager.sol"; + +import {MockAVSDeployer} from "./utils/MockAVSDeployer.sol"; +import {IServiceManager} from "../src/interfaces/IServiceManager.sol"; +import {ISlasher, ISlasherErrors, ISlasherEvents} from "../src/interfaces/ISlasher.sol"; +import {SlasherBase} from "../src/middleware/SlasherBase.sol"; +import {SlasherMock} from "./mocks/SlasherBaseMock.sol"; + +contract SlasherBaseTest is MockAVSDeployer { + SlasherMock public slasherContract; + address public nonServiceManagerRole; + + function setUp() public virtual { + _deployMockEigenLayerAndAVS(); + _setUpDefaultStrategiesAndMultipliers(); + + // Set up roles for testing + nonServiceManagerRole = address(0x5678); + + // Deploy the SlasherMock contract, to specifically test the SlasherBase contract + slasherContract = new SlasherMock(allocationManager, serviceManager); + } + + // Test constructor initializes state correctly + function test_constructor() public view { + assertEq( + address(slasherContract.allocationManager()), + address(allocationManager), + "AllocationManager address mismatch" + ); + assertEq( + address(slasherContract.serviceManager()), + address(serviceManager), + "ServiceManager address mismatch" + ); + assertEq(slasherContract.nextRequestId(), 0, "NextRequestId should be initialized to 0"); + } + + // Test that a function with the onlySlasher modifier reverts when called by non-ServiceManager + function test_onlySlasherModifier_nonSlasher() public { + vm.prank(nonServiceManagerRole); + vm.expectRevert(abi.encodeWithSelector(ISlasherErrors.OnlySlasher.selector)); + slasherContract.restrictedFunction(); + } + + // Test that a function with the onlySlasher modifier allows access when called by ServiceManager + function test_onlySlasherModifier_slasher() public { + vm.prank(address(serviceManager)); + // This should not revert + slasherContract.restrictedFunction(); + } + + // Test that fulfilSlashingRequest can be called by anyone now that the onlySlasher modifier has been removed + function test_fulfilSlashingRequest_anyoneCanCall() public { + // Setup mock params + address operator = address(0xabcd); + uint32 operatorSetId = 1; + IStrategy[] memory strategies = new IStrategy[](1); + strategies[0] = strategyMock1; + uint256[] memory wadsToSlash = new uint256[](1); + wadsToSlash[0] = 1e16; + string memory description = "Test slashing by non-ServiceManager"; + + IAllocationManagerTypes.SlashingParams memory params = IAllocationManagerTypes + .SlashingParams({ + operator: operator, + operatorSetId: operatorSetId, + strategies: strategies, + wadsToSlash: wadsToSlash, + description: description + }); + + // Mock the allocationManager.slashOperator call + vm.mockCall( + address(allocationManager), + abi.encodeWithSelector( + IAllocationManager.slashOperator.selector, serviceManager.avs(), params + ), + abi.encode() + ); + + uint256 requestId = 5; + + // A random address should be able to call fulfilSlashingRequest + vm.prank(nonServiceManagerRole); + vm.expectEmit(true, true, true, true); + emit ISlasherEvents.OperatorSlashed( + requestId, operator, operatorSetId, wadsToSlash, description + ); + slasherContract.fulfilSlashingRequest(requestId, params); + } + + // Test the _checkSlasher internal function + function test_checkSlasher() public { + // Should succeed for ServiceManager + vm.prank(address(serviceManager)); + slasherContract.checkSlasher(address(serviceManager)); + + // Should revert for non-ServiceManager + vm.expectRevert(abi.encodeWithSelector(ISlasherErrors.OnlySlasher.selector)); + slasherContract.checkSlasher(nonServiceManagerRole); + } + + // Test the _fulfilSlashingRequest internal function with different parameters + function test_fulfilSlashingRequest_withMultipleStrategies() public { + // Setup mock params with multiple strategies + address operator = address(0xabcd); + uint32 operatorSetId = 1; + IStrategy[] memory strategies = new IStrategy[](2); + strategies[0] = strategyMock1; + strategies[1] = strategyMock2; + uint256[] memory wadsToSlash = new uint256[](2); + wadsToSlash[0] = 1e16; // 1% of the operator's stake + wadsToSlash[1] = 2e16; // 2% of the operator's stake + string memory description = "Multiple strategy slashing"; + + IAllocationManagerTypes.SlashingParams memory params = IAllocationManagerTypes + .SlashingParams({ + operator: operator, + operatorSetId: operatorSetId, + strategies: strategies, + wadsToSlash: wadsToSlash, + description: description + }); + + // Mock the allocationManager.slashOperator call + vm.mockCall( + address(allocationManager), + abi.encodeWithSelector( + IAllocationManager.slashOperator.selector, serviceManager.avs(), params + ), + abi.encode() + ); + + uint256 requestId = 2; + + // ServiceManager should be able to call fulfilSlashingRequest + vm.prank(address(serviceManager)); + vm.expectEmit(true, true, true, true); + emit ISlasherEvents.OperatorSlashed( + requestId, operator, operatorSetId, wadsToSlash, description + ); + slasherContract.fulfilSlashingRequest(requestId, params); + } + + // Test fulfilSlashingRequest with zero wads to slash + function test_fulfilSlashingRequest_zeroWadsToSlash() public { + // Setup mock params with zero wads + address operator = address(0xabcd); + uint32 operatorSetId = 1; + IStrategy[] memory strategies = new IStrategy[](1); + strategies[0] = strategyMock1; + uint256[] memory wadsToSlash = new uint256[](1); + wadsToSlash[0] = 0; // Zero tokens + string memory description = "Zero wad slashing"; + + IAllocationManagerTypes.SlashingParams memory params = IAllocationManagerTypes + .SlashingParams({ + operator: operator, + operatorSetId: operatorSetId, + strategies: strategies, + wadsToSlash: wadsToSlash, + description: description + }); + + // Mock the allocationManager.slashOperator call + vm.mockCall( + address(allocationManager), + abi.encodeWithSelector( + IAllocationManager.slashOperator.selector, serviceManager.avs(), params + ), + abi.encode() + ); + + uint256 requestId = 3; + + // ServiceManager should be able to call fulfilSlashingRequest + vm.prank(address(serviceManager)); + vm.expectEmit(true, true, true, true); + emit ISlasherEvents.OperatorSlashed( + requestId, operator, operatorSetId, wadsToSlash, description + ); + slasherContract.fulfilSlashingRequest(requestId, params); + } + + // Test error handling when allocationManager.slashOperator reverts + function test_fulfilSlashingRequest_allocationManagerReverts() public { + // Setup mock params + address operator = address(0xabcd); + uint32 operatorSetId = 1; + IStrategy[] memory strategies = new IStrategy[](1); + strategies[0] = strategyMock1; + uint256[] memory wadsToSlash = new uint256[](1); + wadsToSlash[0] = 1e16; // 1% of the operator's stake + string memory description = "Revert test"; + + IAllocationManagerTypes.SlashingParams memory params = IAllocationManagerTypes + .SlashingParams({ + operator: operator, + operatorSetId: operatorSetId, + strategies: strategies, + wadsToSlash: wadsToSlash, + description: description + }); + + // Mock the allocationManager.slashOperator call to revert + vm.mockCallRevert( + address(allocationManager), + abi.encodeWithSelector( + IAllocationManager.slashOperator.selector, serviceManager.avs(), params + ), + abi.encodeWithSignature("SomeError()") + ); + + uint256 requestId = 4; + + // ServiceManager should be able to call fulfilSlashingRequest + vm.prank(address(serviceManager)); + vm.expectRevert(abi.encodeWithSignature("SomeError()")); + slasherContract.fulfilSlashingRequest(requestId, params); + } +} diff --git a/contracts/test/VetoableSlasher.t.sol b/contracts/test/VetoableSlasher.t.sol new file mode 100644 index 00000000..8badbfdb --- /dev/null +++ b/contracts/test/VetoableSlasher.t.sol @@ -0,0 +1,397 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.27; + +import {Test, console} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IStrategy} from "eigenlayer-contracts/src/contracts/interfaces/IStrategy.sol"; +import {IRewardsCoordinator} from + "eigenlayer-contracts/src/contracts/interfaces/IRewardsCoordinator.sol"; +import { + IAllocationManagerErrors, + IAllocationManager, + IAllocationManagerTypes +} from "eigenlayer-contracts/src/contracts/interfaces/IAllocationManager.sol"; + +import {MockAVSDeployer} from "./utils/MockAVSDeployer.sol"; +import {IServiceManager} from "../src/interfaces/IServiceManager.sol"; +import {ISlasher, ISlasherErrors, ISlasherEvents} from "../src/interfaces/ISlasher.sol"; +import { + IVetoableSlasher, + IVetoableSlasherTypes, + IVetoableSlasherErrors, + IVetoableSlasherEvents +} from "../src/interfaces/IVetoableSlasher.sol"; +import {SlasherBase} from "../src/middleware/SlasherBase.sol"; +import {VetoableSlasher} from "../src/middleware/VetoableSlasher.sol"; + +contract VetoableSlasherTest is MockAVSDeployer { + address public nonServiceManagerRole; + address public nonVetoCommittee; + + // Events for testing + event SlashingRequested( + uint256 indexed requestId, + address indexed operator, + uint32 indexed operatorSetId, + uint256[] wadsToSlash, + string description + ); + + event SlashingRequestCancelled( + address indexed operator, uint32 operatorSetId, uint256[] wadsToSlash, string description + ); + + event SlashingRequestFulfilled( + address indexed operator, uint32 operatorSetId, uint256[] wadsToSlash, string description + ); + + function setUp() public virtual { + _deployMockEigenLayerAndAVS(); + _setUpDefaultStrategiesAndMultipliers(); + + // Set up roles for testing + nonServiceManagerRole = address(0x5678); + nonVetoCommittee = address(0xdcba); + } + + // Test constructor initializes state correctly + function test_constructor() public view { + assertEq( + address(vetoableSlasher.allocationManager()), + address(allocationManager), + "AllocationManager address mismatch" + ); + assertEq( + address(vetoableSlasher.serviceManager()), + address(serviceManager), + "ServiceManager address mismatch" + ); + assertEq( + vetoableSlasher.vetoCommittee(), vetoCommitteeMember, "Veto committee address mismatch" + ); + assertEq( + vetoableSlasher.vetoWindowBlocks(), vetoWindowBlocks, "Veto window blocks mismatch" + ); + assertEq(vetoableSlasher.nextRequestId(), 0, "NextRequestId should be initialized to 0"); + } + + // Test queueSlashingRequest reverts when called by non-ServiceManager + function test_queueSlashingRequest_nonServiceManager() public { + IAllocationManagerTypes.SlashingParams memory params; + + vm.prank(nonServiceManagerRole); + vm.expectRevert(abi.encodeWithSelector(ISlasherErrors.OnlySlasher.selector)); + vetoableSlasher.queueSlashingRequest(params); + } + + // Test queueSlashingRequest succeeds when called by ServiceManager + function test_queueSlashingRequest_serviceManager() public { + // Setup mock params + address operator = address(0x1111); + uint32 operatorSetId = 1; + IStrategy[] memory strategies = new IStrategy[](1); + strategies[0] = strategyMock1; + uint256[] memory wadsToSlash = new uint256[](1); + wadsToSlash[0] = 1e16; // 1% of the operator's stake + string memory description = "Test slashing"; + + IAllocationManagerTypes.SlashingParams memory params = IAllocationManagerTypes + .SlashingParams({ + operator: operator, + operatorSetId: operatorSetId, + strategies: strategies, + wadsToSlash: wadsToSlash, + description: description + }); + + uint256 requestId = 0; // First request + + vm.prank(address(serviceManager)); + vm.expectEmit(true, true, true, true); + emit IVetoableSlasherEvents.SlashingRequested( + requestId, operator, operatorSetId, wadsToSlash, description + ); + vetoableSlasher.queueSlashingRequest(params); + + // Verify request is stored correctly + ( + IAllocationManagerTypes.SlashingParams memory storedParams, + uint256 requestBlock, + bool isPending + ) = _getSlashingRequest(requestId); + + assertEq(storedParams.operator, operator, "Operator mismatch"); + assertEq(storedParams.operatorSetId, operatorSetId, "OperatorSetId mismatch"); + assertEq(storedParams.wadsToSlash[0], wadsToSlash[0], "WadsToSlash mismatch"); + assertEq(storedParams.description, description, "Description mismatch"); + assertEq(requestBlock, block.number, "Request block mismatch"); + assertEq(isPending, true, "Status mismatch"); + + // Verify nextRequestId is incremented + assertEq(vetoableSlasher.nextRequestId(), 1, "NextRequestId should be incremented"); + } + + // Test cancelSlashingRequest reverts when called by non-veto committee + function test_cancelSlashingRequest_nonVetoCommittee() public { + // First create a request + _createSlashingRequest(); + + vm.prank(nonVetoCommittee); + vm.expectRevert(abi.encodeWithSelector(IVetoableSlasherErrors.OnlyVetoCommittee.selector)); + vetoableSlasher.cancelSlashingRequest(0); + } + + // Test cancelSlashingRequest succeeds when called by veto committee within veto period + function test_cancelSlashingRequest_withinVetoPeriod() public { + // First create a request + uint256 requestId = _createSlashingRequest(); + (IAllocationManagerTypes.SlashingParams memory params,,) = _getSlashingRequest(requestId); + + vm.prank(vetoCommitteeMember); + vm.expectEmit(true, false, false, false); + emit IVetoableSlasherEvents.SlashingRequestCancelled( + params.operator, params.operatorSetId, params.wadsToSlash, params.description + ); + vetoableSlasher.cancelSlashingRequest(requestId); + + // Verify request status is updated + (,, bool isPending) = _getSlashingRequest(requestId); + assertEq(isPending, false, "Status should be Cancelled"); + } + + // Test cancelSlashingRequest reverts when veto period has passed + function test_cancelSlashingRequest_afterVetoPeriod() public { + // First create a request + uint256 requestId = _createSlashingRequest(); + + // Fast forward past veto period + vm.roll(block.number + vetoWindowBlocks + 1); + + vm.prank(vetoCommitteeMember); + vm.expectRevert(abi.encodeWithSelector(IVetoableSlasherErrors.VetoPeriodPassed.selector)); + vetoableSlasher.cancelSlashingRequest(requestId); + } + + // Test cancelSlashingRequest reverts when request is not in Requested state + function test_cancelSlashingRequest_notRequested() public { + // First create a request + uint256 requestId = _createSlashingRequest(); + + // Cancel it once + vm.prank(vetoCommitteeMember); + vetoableSlasher.cancelSlashingRequest(requestId); + + // Try to cancel it again + vm.prank(vetoCommitteeMember); + vm.expectRevert( + abi.encodeWithSelector(IVetoableSlasherErrors.SlashingRequestNotRequested.selector) + ); + vetoableSlasher.cancelSlashingRequest(requestId); + } + + // Test fulfilSlashingRequest reverts before veto period has passed + function test_fulfilSlashingRequest_beforeVetoPeriod() public { + // First create a request + uint256 requestId = _createSlashingRequest(); + + vm.prank(address(serviceManager)); + vm.expectRevert(abi.encodeWithSelector(IVetoableSlasherErrors.VetoPeriodNotPassed.selector)); + vetoableSlasher.fulfilSlashingRequest(requestId); + } + + // Test fulfilSlashingRequest reverts when request is cancelled + function test_fulfilSlashingRequest_cancelled() public { + // First create a request + uint256 requestId = _createSlashingRequest(); + + // Cancel it + vm.prank(vetoCommitteeMember); + vetoableSlasher.cancelSlashingRequest(requestId); + + // Fast forward past veto period + vm.roll(block.number + vetoWindowBlocks + 1); + + vm.prank(address(serviceManager)); + vm.expectRevert( + abi.encodeWithSelector(IVetoableSlasherErrors.SlashingRequestIsCancelled.selector) + ); + vetoableSlasher.fulfilSlashingRequest(requestId); + } + + // Test fulfilSlashingRequest succeeds after veto period has passed + function test_fulfilSlashingRequest_afterVetoPeriod() public { + // First create a request + uint256 requestId = _createSlashingRequest(); + address operator = address(0x1111); + uint32 operatorSetId = 1; + + // Setup the mock for slashing + IAllocationManagerTypes.SlashingParams memory params; + (params,,) = _getSlashingRequest(requestId); + + vm.mockCall( + address(allocationManager), + abi.encodeWithSelector( + IAllocationManager.slashOperator.selector, serviceManager.avs(), params + ), + abi.encode() + ); + + // Fast forward past veto period + vm.roll(block.number + vetoWindowBlocks + 1); + + vm.prank(address(serviceManager)); + vm.expectEmit(true, true, true, true); + emit ISlasherEvents.OperatorSlashed( + requestId, operator, operatorSetId, params.wadsToSlash, params.description + ); + vm.expectEmit(true, true, true, true); + emit SlashingRequestFulfilled( + operator, operatorSetId, params.wadsToSlash, params.description + ); + vetoableSlasher.fulfilSlashingRequest(requestId); + + // Verify request is deleted from storage + ( + IAllocationManagerTypes.SlashingParams memory emptyParams, + uint256 requestBlock, + bool isPending + ) = _getSlashingRequest(requestId); + assertEq( + emptyParams.operator, address(0), "Request should be deleted - operator not zeroed" + ); + assertEq(requestBlock, 0, "Request should be deleted - requestBlock not zeroed"); + assertEq(isPending, false, "Request should be deleted - isPending not false"); + } + + // Test cancelSlashingRequest properly deletes the request from storage + function test_cancelSlashingRequest_deletesFromStorage() public { + // First create a request + uint256 requestId = _createSlashingRequest(); + (IAllocationManagerTypes.SlashingParams memory params,,) = _getSlashingRequest(requestId); + + vm.prank(vetoCommitteeMember); + vm.expectEmit(true, true, true, true); + emit SlashingRequestCancelled( + params.operator, params.operatorSetId, params.wadsToSlash, params.description + ); + vetoableSlasher.cancelSlashingRequest(requestId); + + // Verify request is deleted from storage + ( + IAllocationManagerTypes.SlashingParams memory emptyParams, + uint256 requestBlock, + bool isPending + ) = _getSlashingRequest(requestId); + assertEq( + emptyParams.operator, address(0), "Request should be deleted - operator not zeroed" + ); + assertEq(requestBlock, 0, "Request should be deleted - requestBlock not zeroed"); + assertEq(isPending, false, "Request should be deleted - isPending not false"); + } + + // Test multiple requests flow + function test_multipleRequests() public { + // Create first request + uint256 requestId1 = _createSlashingRequest(); + + // Create second request with different parameters + address operator2 = address(0x2222); + uint32 operatorSetId2 = 2; + IStrategy[] memory strategies2 = new IStrategy[](1); + strategies2[0] = strategyMock2; + uint256[] memory wadsToSlash2 = new uint256[](1); + wadsToSlash2[0] = 2e16; // 2% of the operator's stake + string memory description2 = "Second slashing"; + + IAllocationManagerTypes.SlashingParams memory params2 = IAllocationManagerTypes + .SlashingParams({ + operator: operator2, + operatorSetId: operatorSetId2, + strategies: strategies2, + wadsToSlash: wadsToSlash2, + description: description2 + }); + + uint256 requestId2 = 1; // Second request + + vm.prank(address(serviceManager)); + vetoableSlasher.queueSlashingRequest(params2); + + // Cancel the first request + vm.prank(vetoCommitteeMember); + vetoableSlasher.cancelSlashingRequest(requestId1); + + // Setup the mock for slashing the second request + vm.mockCall( + address(allocationManager), + abi.encodeWithSelector( + IAllocationManager.slashOperator.selector, serviceManager.avs(), params2 + ), + abi.encode() + ); + + // Fast forward past veto period + vm.roll(block.number + vetoWindowBlocks + 1); + + // Try to fulfil the first (cancelled) request - should revert + vm.prank(address(serviceManager)); + vm.expectRevert( + abi.encodeWithSelector(IVetoableSlasherErrors.SlashingRequestIsCancelled.selector) + ); + vetoableSlasher.fulfilSlashingRequest(requestId1); + + // fulfil the second request - should succeed + vm.prank(address(serviceManager)); + vetoableSlasher.fulfilSlashingRequest(requestId2); + + // Verify states + (,, bool isPending1) = _getSlashingRequest(requestId1); + (,, bool isPending2) = _getSlashingRequest(requestId2); + + assertEq(isPending1, false, "Request 1 status should be Cancelled"); + assertEq(isPending2, false, "Request 2 status should be Completed"); + } + + // Helper function to create a standard slashing request + function _createSlashingRequest() internal returns (uint256) { + address operator = address(0x1111); + uint32 operatorSetId = 1; + IStrategy[] memory strategies = new IStrategy[](1); + strategies[0] = strategyMock1; + uint256[] memory wadsToSlash = new uint256[](1); + wadsToSlash[0] = 1e16; // 1% of the operator's stake + string memory description = "Test slashing"; + + IAllocationManagerTypes.SlashingParams memory params = IAllocationManagerTypes + .SlashingParams({ + operator: operator, + operatorSetId: operatorSetId, + strategies: strategies, + wadsToSlash: wadsToSlash, + description: description + }); + + uint256 requestId = vetoableSlasher.nextRequestId(); + + vm.prank(address(serviceManager)); + vetoableSlasher.queueSlashingRequest(params); + + return requestId; + } + + // Helper function to extract SlashingRequest from storage + function _getSlashingRequest( + uint256 requestId + ) + internal + view + returns ( + IAllocationManagerTypes.SlashingParams memory params, + uint256 requestBlock, + bool isPending + ) + { + (params, requestBlock, isPending) = vetoableSlasher.slashingRequests(requestId); + } +} diff --git a/contracts/test/mocks/ServiceManagerMock.sol b/contracts/test/mocks/ServiceManagerMock.sol index 7e1d3681..868740b6 100644 --- a/contracts/test/mocks/ServiceManagerMock.sol +++ b/contracts/test/mocks/ServiceManagerMock.sol @@ -10,6 +10,7 @@ import {IAllocationManager} from import {ServiceManagerBase} from "../../src/middleware/ServiceManagerBase.sol"; import {ServiceManagerBaseStorage} from "../../src/middleware/ServiceManagerBaseStorage.sol"; +import {IVetoableSlasher} from "../../src/interfaces/IVetoableSlasher.sol"; /** * @title Minimal implementation of a ServiceManager-type contract. @@ -31,4 +32,15 @@ contract ServiceManagerMock is ServiceManagerBase { ) public virtual initializer { __ServiceManagerBase_init(initialOwner, rewardsInitiator); } + + /** + * @notice Sets the slasher contract + * @param slasher The slasher contract address + * @dev Only callable by the owner + */ + function setSlasher( + IVetoableSlasher slasher + ) external override onlyOwner { + _slasher = slasher; + } } diff --git a/contracts/test/mocks/SlasherBaseMock.sol b/contracts/test/mocks/SlasherBaseMock.sol new file mode 100644 index 00000000..7ce6ec02 --- /dev/null +++ b/contracts/test/mocks/SlasherBaseMock.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.27; + +import {SlasherBase} from "../../src/middleware/SlasherBase.sol"; +import { + IAllocationManager, + IAllocationManagerTypes +} from "eigenlayer-contracts/src/contracts/interfaces/IAllocationManager.sol"; +import {IServiceManager} from "../../src/interfaces/IServiceManager.sol"; + +// SlasherMock implementation for testing +contract SlasherMock is SlasherBase { + constructor( + IAllocationManager _allocationManager, + IServiceManager _serviceManager + ) SlasherBase(_allocationManager, _serviceManager) {} + + // Expose the internal _fulfilSlashingRequest function for testing + function fulfilSlashingRequest( + uint256 _requestId, + IAllocationManagerTypes.SlashingParams memory _params + ) external { + _fulfilSlashingRequest(_requestId, _params); + } + + // Function with the onlySlasher modifier for testing + function restrictedFunction() external onlySlasher { + // Do nothing, just for testing the modifier + } + + // Expose the internal _checkSlasher function for testing + function checkSlasher( + address account + ) external view { + _checkSlasher(account); + } +} diff --git a/contracts/test/utils/MockAVSDeployer.sol b/contracts/test/utils/MockAVSDeployer.sol index a01e0f3f..6242b6c4 100644 --- a/contracts/test/utils/MockAVSDeployer.sol +++ b/contracts/test/utils/MockAVSDeployer.sol @@ -23,6 +23,8 @@ import {StrategyBase} from "eigenlayer-contracts/src/contracts/strategies/Strate import {ERC20FixedSupply} from "./ERC20FixedSupply.sol"; import {IServiceManager} from "../../src/interfaces/IServiceManager.sol"; +import {VetoableSlasher} from "../../src/middleware/VetoableSlasher.sol"; +import {IVetoableSlasher} from "../../src/interfaces/IVetoableSlasher.sol"; // Mocks import {StrategyManagerMock} from "eigenlayer-contracts/src/test/mocks/StrategyManagerMock.sol"; @@ -46,6 +48,11 @@ contract MockAVSDeployer is Test { // AVS contracts ServiceManagerMock public serviceManager; ServiceManagerMock public serviceManagerImplementation; + VetoableSlasher public vetoableSlasher; + + // Roles and parameters + address public vetoCommitteeMember = address(uint160(uint256(keccak256("vetoCommitteeMember")))); + uint32 public vetoWindowBlocks = 100; // 100 blocks veto window for tests // EigenLayer contracts StrategyManagerMock public strategyManagerMock; @@ -202,6 +209,19 @@ contract MockAVSDeployer is Test { ); cheats.stopPrank(); console.log("ServiceManager implementation deployed"); + + // Deploy and configure the VetoableSlasher + cheats.startPrank(regularDeployer); + vetoableSlasher = new VetoableSlasher( + allocationManager, serviceManager, vetoCommitteeMember, vetoWindowBlocks + ); + cheats.stopPrank(); + + // Set the slasher in the ServiceManager + cheats.prank(avsOwner); + serviceManager.setSlasher(vetoableSlasher); + + console.log("VetoableSlasher deployed and configured"); } function _setUpDefaultStrategiesAndMultipliers() internal virtual { @@ -283,6 +303,7 @@ contract MockAVSDeployer is Test { vm.label(address(allocationManagerImplementation), "AllocationManagerImplementation"); vm.label(address(serviceManager), "ServiceManager"); vm.label(address(serviceManagerImplementation), "ServiceManagerImplementation"); + vm.label(address(vetoableSlasher), "VetoableSlasher"); } /// @dev Sort to ensure that the array is in ascending order for strategies