From 8324ce818aca070ae7d4d089d78a7c194db543a1 Mon Sep 17 00:00:00 2001
From: Marco Tabasco <matabasco@gmail.com>
Date: Fri, 29 Sep 2023 11:51:55 +0200
Subject: [PATCH] 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