From 8324ce818aca070ae7d4d089d78a7c194db543a1 Mon Sep 17 00:00:00 2001 From: Marco Tabasco Date: Fri, 29 Sep 2023 11:51:55 +0200 Subject: [PATCH 1/2] A validator can voluntarily exit (#263) * A validator can voluntarily exit --- CHANGELOG.md | 3 + contracts/SSVNetwork.sol | 4 + contracts/interfaces/ISSVClusters.sol | 7 ++ contracts/libraries/ValidatorLib.sol | 27 +++++ contracts/modules/SSVClusters.sol | 22 ++-- contracts/test/SSVNetworkUpgrade.sol | 9 +- test/helpers/gas-usage.ts | 3 + test/validators/others.ts | 154 ++++++++++++++++++++++++++ 8 files changed, 214 insertions(+), 15 deletions(-) create mode 100644 contracts/libraries/ValidatorLib.sol create mode 100644 test/validators/others.ts 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 From 3da66d3140ab8457555e85b421431264d4e54329 Mon Sep 17 00:00:00 2001 From: andrew-blox <102898824+andrew-blox@users.noreply.github.com> Date: Fri, 29 Sep 2023 12:57:18 +0300 Subject: [PATCH 2/2] Update local-dev.md (#260) * Update local-dev.md --- docs/local-dev.md | 19 ++++++++++--------- docs/tasks.md | 4 ++-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/local-dev.md b/docs/local-dev.md index fcfc4fbc..d159e826 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -12,15 +12,16 @@ Execute the steps to set up all tools needed. Copy [.env.example](../.env.example) to `.env` and edit to suit. - `[NETWORK]_ETH_NODE_URL` RPC URL of the node - `[NETWORK]_OWNER_PRIVATE_KEY` Private key of the deployer account, without 0x prefix -- `GAS_PRICE` example 30000000000 -- `GAS` example 8000000 -- `ETHERSCAN_KEY` etherescan API key to verify deployed contracts -- `SSVTOKEN_ADDRESS` SSV Token contract address to be used in custom networks. Keep it empty to deploy a mocked SSV token. -- `MINIMUM_BLOCKS_BEFORE_LIQUIDATION` a number of blocks before the cluster enters into a liquidatable state. Example: 214800 = 30 days -- `OPERATOR_MAX_FEE_INCREASE` the fee increase limit in percentage with this format: 100% = 10000, 10% = 1000 - using 10000 to represent 2 digit precision -- `DECLARE_OPERATOR_FEE_PERIOD` the period in which an operator can declare a fee change (seconds) -- `EXECUTE_OPERATOR_FEE_PERIOD` the period in which an operator fee change can be executed (seconds) -- `VALIDATORS_PER_OPERATOR_LIMIT` the number of validators an operator can manage +- `GAS_PRICE` Example 30000000000 +- `GAS` Example 8000000 +- `ETHERSCAN_KEY` Etherescan API key to verify deployed contracts +- `SSV_TOKEN_ADDRESS` SSV Token contract address to be used in custom networks. Keep it empty to deploy a mocked SSV token. +- `MINIMUM_BLOCKS_BEFORE_LIQUIDATION` A number of blocks before the cluster enters into a liquidatable state. Example: 214800 = 30 days +- `OPERATOR_MAX_FEE_INCREASE` The fee increase limit in percentage with this format: 100% = 10000, 10% = 1000 - using 10000 to represent 2 digit precision +- `DECLARE_OPERATOR_FEE_PERIOD` The period in which an operator can declare a fee change (seconds) +- `EXECUTE_OPERATOR_FEE_PERIOD` The period in which an operator fee change can be executed (seconds) +- `VALIDATORS_PER_OPERATOR_LIMIT` The number of validators an operator can manage +- `MINIMUM_LIQUIDATION_COLLATERAL` The lowest number in wei a cluster can have before its liquidatable #### Network configuration diff --git a/docs/tasks.md b/docs/tasks.md index 2379a796..240817a4 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -80,7 +80,7 @@ POSITIONAL ARGUMENTS: params Function parameters Example: -npx hardhat --network goerli_testnet upgrade:proxy --proxyAddress 0x1234... --contract SSVNetworkV2 --initFunction initializev2 --params param1 param2 +npx hardhat --network goerli_testnet upgrade:proxy --proxy-address 0x1234... --contract SSVNetworkV2 --init-function initializev2 param1 param2 ``` ### Update a module @@ -100,7 +100,7 @@ OPTIONS: Example: Update 'SSVOperators' module contract in the SSVNetwork -npx hardhat --network goerli_testnet update:module --module SSVOperators --attach-module true --proxyAddress 0x1234... +npx hardhat --network goerli_testnet update:module --module SSVOperators --attach-module true --proxy-address 0x1234... ``` ### Upgrade a library