Skip to content

Commit

Permalink
A validator can voluntarily exit (#263)
Browse files Browse the repository at this point in the history
* A validator can voluntarily exit
  • Loading branch information
mtabasco authored Sep 29, 2023
1 parent a0c2914 commit 8324ce8
Show file tree
Hide file tree
Showing 8 changed files with 214 additions and 15 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions contracts/SSVNetwork.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
Expand Down
7 changes: 7 additions & 0 deletions contracts/interfaces/ISSVClusters.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
}
27 changes: 27 additions & 0 deletions contracts/libraries/ValidatorLib.sol
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
22 changes: 8 additions & 14 deletions contracts/modules/SSVClusters.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}
}
9 changes: 8 additions & 1 deletion contracts/test/SSVNetworkUpgrade.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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)
);
Expand Down
3 changes: 3 additions & 0 deletions test/helpers/gas-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export enum GasGroup {
DEPOSIT,
WITHDRAW_CLUSTER_BALANCE,
WITHDRAW_OPERATOR_BALANCE,
VALIDATOR_EXIT,

LIQUIDATE_CLUSTER_4,
LIQUIDATE_CLUSTER_7,
Expand Down Expand Up @@ -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,
Expand Down
154 changes: 154 additions & 0 deletions test/validators/others.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});

0 comments on commit 8324ce8

Please sign in to comment.