diff --git a/CHANGELOG.md b/CHANGELOG.md index 166b5261..45173209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,3 +11,6 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed - [22d2859](https://github.com/bloxapp/ssv-network/pull/262/commits/22d2859d8fe6267b09c7a1c9c645df19bdaa03ff) Fix bug in network earnings withdrawals. + +### Added +- [bf0c51d](https://github.com/bloxapp/ssv-network/pull/263/commits/bf0c51d4df191018052d11425c9fcc252de61431) A validator can voluntarily exit. \ No newline at end of file diff --git a/contracts/SSVNetwork.sol b/contracts/SSVNetwork.sol index 25f9d73a..3380af0a 100644 --- a/contracts/SSVNetwork.sol +++ b/contracts/SSVNetwork.sol @@ -216,6 +216,10 @@ contract SSVNetwork is _delegate(SSVStorage.load().ssvContracts[SSVModules.SSV_CLUSTERS]); } + function exitValidator(bytes calldata publicKey, uint64[] calldata operatorIds) external override { + _delegate(SSVStorage.load().ssvContracts[SSVModules.SSV_CLUSTERS]); + } + function updateNetworkFee(uint256 fee) external override onlyOwner { _delegate(SSVStorage.load().ssvContracts[SSVModules.SSV_DAO]); } diff --git a/contracts/interfaces/ISSVClusters.sol b/contracts/interfaces/ISSVClusters.sol index 9910e9fd..88f3e36b 100644 --- a/contracts/interfaces/ISSVClusters.sol +++ b/contracts/interfaces/ISSVClusters.sol @@ -57,6 +57,11 @@ interface ISSVClusters is ISSVNetworkCore { /// @param cluster Cluster where the withdrawal will be made function withdraw(uint64[] memory operatorIds, uint256 tokenAmount, Cluster memory cluster) external; + /// @notice Fires the exit event for a validator + /// @param publicKey The public key of the validator to be exited + /// @param operatorIds Array of IDs of operators managing the validator + function exitValidator(bytes calldata publicKey, uint64[] calldata operatorIds) external; + /** * @dev Emitted when the validator has been added. * @param publicKey The public key of a validator. @@ -81,4 +86,6 @@ interface ISSVClusters is ISSVNetworkCore { event ClusterWithdrawn(address indexed owner, uint64[] operatorIds, uint256 value, Cluster cluster); event ClusterDeposited(address indexed owner, uint64[] operatorIds, uint256 value, Cluster cluster); + + event ValidatorExited(bytes indexed publicKey, uint64[] operatorIds); } diff --git a/contracts/libraries/ValidatorLib.sol b/contracts/libraries/ValidatorLib.sol new file mode 100644 index 00000000..ad98ee5e --- /dev/null +++ b/contracts/libraries/ValidatorLib.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.18; + +import "../interfaces/ISSVNetworkCore.sol"; +import "./SSVStorage.sol"; + +library ValidatorLib { + function validateState( + bytes calldata publicKey, + uint64[] calldata operatorIds, + StorageData storage s + ) internal view returns (bytes32 hashedValidator) { + hashedValidator = keccak256(abi.encodePacked(publicKey, msg.sender)); + bytes32 validatorData = s.validatorPKs[hashedValidator]; + + if (validatorData == bytes32(0)) { + revert ISSVNetworkCore.ValidatorDoesNotExist(); + } + bytes32 mask = ~bytes32(uint256(1)); // All bits set to 1 except LSB + + bytes32 hashedOperatorIds = keccak256(abi.encodePacked(operatorIds)) & mask; // Clear LSB of provided operator ids + if ((validatorData & mask) != hashedOperatorIds) { + // Clear LSB of stored validator data and compare + revert ISSVNetworkCore.IncorrectValidatorState(); + } + } +} diff --git a/contracts/modules/SSVClusters.sol b/contracts/modules/SSVClusters.sol index 8d1df9aa..acef8f0e 100644 --- a/contracts/modules/SSVClusters.sol +++ b/contracts/modules/SSVClusters.sol @@ -6,6 +6,7 @@ import "../libraries/ClusterLib.sol"; import "../libraries/OperatorLib.sol"; import "../libraries/ProtocolLib.sol"; import "../libraries/CoreLib.sol"; +import "../libraries/ValidatorLib.sol"; import "../libraries/SSVStorage.sol"; import "../libraries/SSVStorageProtocol.sol"; @@ -145,20 +146,7 @@ contract SSVClusters is ISSVClusters { ) external override { StorageData storage s = SSVStorage.load(); - bytes32 hashedValidator = keccak256(abi.encodePacked(publicKey, msg.sender)); - - bytes32 mask = ~bytes32(uint256(1)); // All bits set to 1 except LSB - bytes32 validatorData = s.validatorPKs[hashedValidator]; - - if (validatorData == bytes32(0)) { - revert ValidatorDoesNotExist(); - } - - bytes32 hashedOperatorIds = keccak256(abi.encodePacked(operatorIds)) & mask; // Clear LSB of provided operator ids - if ((validatorData & mask) != hashedOperatorIds) { - // Clear LSB of stored validator data and compare - revert IncorrectValidatorState(); - } + bytes32 hashedValidator = ValidatorLib.validateState(publicKey, operatorIds, s); bytes32 hashedCluster = cluster.validateHashedCluster(msg.sender, operatorIds, s); @@ -344,4 +332,10 @@ contract SSVClusters is ISSVClusters { emit ClusterWithdrawn(msg.sender, operatorIds, amount, cluster); } + + function exitValidator(bytes calldata publicKey, uint64[] calldata operatorIds) external override { + ValidatorLib.validateState(publicKey, operatorIds, SSVStorage.load()); + + emit ValidatorExited(publicKey, operatorIds); + } } diff --git a/contracts/test/SSVNetworkUpgrade.sol b/contracts/test/SSVNetworkUpgrade.sol index c859582f..c7288c17 100644 --- a/contracts/test/SSVNetworkUpgrade.sol +++ b/contracts/test/SSVNetworkUpgrade.sol @@ -286,6 +286,13 @@ contract SSVNetworkUpgrade is ); } + function exitValidator(bytes calldata publicKey, uint64[] calldata operatorIds) external override { + _delegateCall( + SSVStorage.load().ssvContracts[SSVModules.SSV_CLUSTERS], + abi.encodeWithSignature("exitValidator(bytes,uint64[]))", publicKey, operatorIds) + ); + } + function updateNetworkFee(uint256 fee) external override onlyOwner { _delegateCall( SSVStorage.load().ssvContracts[SSVModules.SSV_DAO], @@ -336,7 +343,7 @@ contract SSVNetworkUpgrade is } function updateMaximumOperatorFee(uint64 maxFee) external override { - _delegateCall( + _delegateCall( SSVStorage.load().ssvContracts[SSVModules.SSV_DAO], abi.encodeWithSignature("updateMaximumOperatorFee(uint64)", maxFee) ); diff --git a/test/helpers/gas-usage.ts b/test/helpers/gas-usage.ts index d67cd633..7b733092 100644 --- a/test/helpers/gas-usage.ts +++ b/test/helpers/gas-usage.ts @@ -31,6 +31,7 @@ export enum GasGroup { DEPOSIT, WITHDRAW_CLUSTER_BALANCE, WITHDRAW_OPERATOR_BALANCE, + VALIDATOR_EXIT, LIQUIDATE_CLUSTER_4, LIQUIDATE_CLUSTER_7, @@ -81,6 +82,8 @@ const MAX_GAS_PER_GROUP: any = { [GasGroup.DEPOSIT]: 77500, [GasGroup.WITHDRAW_CLUSTER_BALANCE]: 94500, [GasGroup.WITHDRAW_OPERATOR_BALANCE]: 64900, + [GasGroup.VALIDATOR_EXIT]: 41200, + [GasGroup.LIQUIDATE_CLUSTER_4]: 129300, [GasGroup.LIQUIDATE_CLUSTER_7]: 170500, [GasGroup.LIQUIDATE_CLUSTER_10]: 211600, diff --git a/test/validators/others.ts b/test/validators/others.ts new file mode 100644 index 00000000..6e5db6f5 --- /dev/null +++ b/test/validators/others.ts @@ -0,0 +1,154 @@ +// Declare imports +import * as helpers from '../helpers/contract-helpers'; +import { expect } from 'chai'; +import { trackGas, GasGroup } from '../helpers/gas-usage'; + +// Declare globals +let ssvNetworkContract: any, minDepositAmount: any, firstCluster: any; + +describe('Other Validator Tests', () => { + beforeEach(async () => { + // Initialize contract + const metadata = (await helpers.initializeContract()); + ssvNetworkContract = metadata.contract; + + minDepositAmount = (helpers.CONFIG.minimalBlocksBeforeLiquidation + 10) * helpers.CONFIG.minimalOperatorFee * 4; + + // Register operators + await helpers.registerOperators(0, 14, helpers.CONFIG.minimalOperatorFee); + + // Register a validator + // cold register + await helpers.coldRegisterValidator(); + + // first validator + await helpers.DB.ssvToken.connect(helpers.DB.owners[1]).approve(ssvNetworkContract.address, minDepositAmount); + const register = await trackGas(ssvNetworkContract.connect(helpers.DB.owners[1]).registerValidator( + helpers.DataGenerator.publicKey(1), + [1, 2, 3, 4], + helpers.DataGenerator.shares(4), + minDepositAmount, + { + validatorCount: 0, + networkFeeIndex: 0, + index: 0, + balance: 0, + active: true + } + ), [GasGroup.REGISTER_VALIDATOR_NEW_STATE]); + firstCluster = register.eventsByName.ValidatorAdded[0].args; + }); + + it('Exiting a validator emits "ValidatorExited"', async () => { + await expect(ssvNetworkContract.connect(helpers.DB.owners[1]).exitValidator( + helpers.DataGenerator.publicKey(1), + firstCluster.operatorIds, + )).to.emit(ssvNetworkContract, 'ValidatorExited') + .withArgs(helpers.DataGenerator.publicKey(1), firstCluster.operatorIds); + }); + + it('Exiting a validator gas limit', async () => { + await trackGas(ssvNetworkContract.connect(helpers.DB.owners[1]).exitValidator( + helpers.DataGenerator.publicKey(1), + firstCluster.operatorIds, + ), [GasGroup.VALIDATOR_EXIT]); + }); + + it('Exiting one of the validators in a cluster emits "ValidatorExited"', async () => { + await helpers.DB.ssvToken.connect(helpers.DB.owners[1]).approve(ssvNetworkContract.address, minDepositAmount); + await trackGas(ssvNetworkContract.connect(helpers.DB.owners[1]).registerValidator( + helpers.DataGenerator.publicKey(2), + [1, 2, 3, 4], + helpers.DataGenerator.shares(4), + minDepositAmount, + firstCluster.cluster + )); + + await expect(ssvNetworkContract.connect(helpers.DB.owners[1]).exitValidator( + helpers.DataGenerator.publicKey(2), + firstCluster.operatorIds, + )).to.emit(ssvNetworkContract, 'ValidatorExited') + .withArgs(helpers.DataGenerator.publicKey(2), firstCluster.operatorIds); + }); + + it('Exiting a removed validator reverts "ValidatorDoesNotExist"', async () => { + await ssvNetworkContract.connect(helpers.DB.owners[1]).removeValidator( + helpers.DataGenerator.publicKey(1), + firstCluster.operatorIds, + firstCluster.cluster + ); + + await expect(ssvNetworkContract.connect(helpers.DB.owners[1]).exitValidator( + helpers.DataGenerator.publicKey(1), + firstCluster.operatorIds + )).to.be.revertedWithCustomError(ssvNetworkContract, 'ValidatorDoesNotExist'); + }); + + it('Exiting a non-existing validator reverts "ValidatorDoesNotExist"', async () => { + await expect(ssvNetworkContract.connect(helpers.DB.owners[1]).exitValidator( + helpers.DataGenerator.publicKey(12), + firstCluster.operatorIds + )).to.be.revertedWithCustomError(ssvNetworkContract, 'ValidatorDoesNotExist'); + }); + + it('Exiting a validator with empty operator list reverts "IncorrectValidatorState"', async () => { + await expect(ssvNetworkContract.connect(helpers.DB.owners[1]).exitValidator( + helpers.DataGenerator.publicKey(1), + [] + )).to.be.revertedWithCustomError(ssvNetworkContract, 'IncorrectValidatorState'); + }); + + it('Exiting a validator with empty public key reverts "ValidatorDoesNotExist"', async () => { + await expect(ssvNetworkContract.connect(helpers.DB.owners[1]).exitValidator( + '0x', + firstCluster.operatorIds + )).to.be.revertedWithCustomError(ssvNetworkContract, 'ValidatorDoesNotExist'); + }); + + it('Exiting a validator using the wrong account reverts "ValidatorDoesNotExist"', async () => { + await expect(ssvNetworkContract.connect(helpers.DB.owners[2]).exitValidator( + helpers.DataGenerator.publicKey(1), + firstCluster.operatorIds + )).to.be.revertedWithCustomError(ssvNetworkContract, 'ValidatorDoesNotExist'); + }); + + it('Exiting a validator with incorrect operators (unsorted list) reverts with "IncorrectValidatorState"', async () => { + await expect(ssvNetworkContract.connect(helpers.DB.owners[1]).exitValidator( + helpers.DataGenerator.publicKey(1), + [4, 3, 2, 1] + )).to.be.revertedWithCustomError(ssvNetworkContract, 'IncorrectValidatorState'); + }); + + it('Exiting a validator with incorrect operators (too many operators) reverts with "IncorrectValidatorState"', async () => { + minDepositAmount = (helpers.CONFIG.minimalBlocksBeforeLiquidation + 10) * helpers.CONFIG.minimalOperatorFee * 13; + + await helpers.DB.ssvToken.connect(helpers.DB.owners[2]).approve(ssvNetworkContract.address, minDepositAmount); + const register = await trackGas(ssvNetworkContract.connect(helpers.DB.owners[2]).registerValidator( + helpers.DataGenerator.publicKey(2), + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + helpers.DataGenerator.shares(13), + minDepositAmount, + { + validatorCount: 0, + networkFeeIndex: 0, + index: 0, + balance: 0, + active: true + } + )); + const secondCluster = register.eventsByName.ValidatorAdded[0].args; + + await expect(ssvNetworkContract.connect(helpers.DB.owners[2]).exitValidator( + helpers.DataGenerator.publicKey(2), + secondCluster.operatorIds, + )).to.emit(ssvNetworkContract, 'ValidatorExited') + .withArgs(helpers.DataGenerator.publicKey(2), secondCluster.operatorIds); + }); + + it('Exiting a validator with incorrect operators reverts with "IncorrectValidatorState"', async () => { + await expect(ssvNetworkContract.connect(helpers.DB.owners[1]).exitValidator( + helpers.DataGenerator.publicKey(1), + [1, 2, 3, 5] + )).to.be.revertedWithCustomError(ssvNetworkContract, 'IncorrectValidatorState'); + }); +}); \ No newline at end of file