Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 120 additions & 146 deletions contracts/0.8.25/vaults/ValidatorConsolidationRequests.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,157 +6,143 @@ pragma solidity 0.8.25;

import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol";
import {Dashboard} from "contracts/0.8.25/vaults/dashboard/Dashboard.sol";
import {NodeOperatorFee} from "contracts/0.8.25/vaults/dashboard/NodeOperatorFee.sol";
import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol";

/**
* @title A contract for EIP-7251: Increase the MAX_EFFECTIVE_BALANCE.
* Allow validators to have larger effective balances, while maintaining the 32 ETH lower bound.
*/
/**
* @title ValidatorConsolidationRequests
* @author kovalgek
* @notice Contract for consolidating validators into staking vaults (EIP-7251)
* and adjusting rewards. Built to work with Vault CLI tooling and to
* support batched execution (EIP-5792).
*
* This contract is strictly for an account that:
* - has its address as withdrawal credentials for pubkeys to consolidate from
* - has the `NODE_OPERATOR_REWARDS_ADJUST_ROLE` role assigned in Dashboard.
*/
contract ValidatorConsolidationRequests {
/// @notice EIP-7251 consolidation requests contract address.
address public constant CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS = 0x0000BBdDc7CE488642fb579F8B00f3a590007251;

uint256 internal constant PUBLIC_KEY_LENGTH = 48;
uint256 internal constant CONSOLIDATION_REQUEST_CALLDATA_LENGTH = PUBLIC_KEY_LENGTH * 2;
uint256 internal constant MINIMUM_VALIDATOR_BALANCE = 16 ether;

/// @notice Lido Locator contract.
ILidoLocator public immutable LIDO_LOCATOR;

/// @notice This contract address.
address public immutable THIS;

/// @param _lidoLocator Lido Locator contract.
constructor(address _lidoLocator) {
if (_lidoLocator == address(0)) revert ZeroArgument("_lidoLocator");
LIDO_LOCATOR = ILidoLocator(_lidoLocator);
THIS = address(this);
}

/**
* @notice Send EIP-7251 consolidation requests for the specified public keys.
* Each request instructs a validator to consolidate its stake to the target validator.
*
* Requirements:
* - The caller must have the `NODE_OPERATOR_REWARDS_ADJUST_ROLE` to perform reward adjustment.
* - The vault into which consolidation occurs must be connected to the vault hub.
* - The function must be called with a non-zero msg.value that is sufficient to cover all consolidation fees.
* - The required amount can be obtained by calling `getConsolidationRequestFee()`, but note that this value is only
* valid for the current block and may change. It is therefore advised to provide a slightly higher amount;
* any excess will be refunded to the `_refundRecipient` address.
* - The `sourcePublicKeys` and `targetPublicKey` must be valid and belong to registered validators.
* - `_adjustmentIncrease` must match the total balance of source validators on the Consensus Layer.
* - A valid Staking Vault contract address must be provided.
*
* Execution Flows:
* This function can be called from a Withdrawal Credentials (WC) account, which may be an EOA, a Gnosis Safe, or another smart contract.
* @notice Return the encoded calls for EIP-7251 consolidation requests and the rewards adjustment increase.
*
* Use case:
* - If your withdrawal credentials are an EOA or multisig and you want to
* consolidate validator balances into staking vaults, call this method to
* generate the encoded consolidation and rewards-adjustment calls.
* These calls can later be submitted via EIP-5792.
* - Rewards adjustment calls can only be executed by an account with the
* `NODE_OPERATOR_REWARDS_ADJUST_ROLE`. The node operator may grant this
* role to the withdrawal credentials account.
*
* Recommendations:
* - It is recommended to call this function via the Vault CLI using WalletConnect signing.
* It performs pre-checks of source and target validator states, verifies their withdrawal
* credential prefixes, calculates current validator balances, generates the request
* calldata using this method, and then submits these call data in batched transactions
* via EIP-5792.
*
* 1. **Externally Owned Account (EOA)**:
* - The EOA owner should invoke this function via a delegate call using account abstraction (EIP-7702 delegation).
* @param _sourcePubkeys An array of tightly packed arrays of 48-byte public keys corresponding to validators
* requesting consolidation.
* | ----- public key (48 bytes) ----- || ----- public key (48 bytes) ----- | ...
*
* 2. **Smart Contract**:
* - The smart contract should invoke this function via delegatecall if such functionality is supported.
* - Alternatively, if the contract is behind a proxy, this functionality can be added via an upgrade.
*
* 3. **Gnosis Safe**:
* a. Build a transaction with delegatecall flag enabled.
* b. Sign the transaction with the minimum number of owners required to meet the Safe's threshold, then execute it.
* @param _targetPubkeys An array of 48-byte public keys corresponding to validators to consolidate to.
* | ----- public key (48 bytes) ----- || ----- public key (48 bytes) ----- | ...
*
* Notes:
* Consolidation requests are asynchronous and handled on the Consensus Layer. The function optimistically
* assumes that the consolidation will succeed and immediately increases the node operator's reward adjustment
* via the Dashboard contract. However, if the consolidation fails, the function does not take
* responsibility for rolling back the adjustment. It is the responsibility of the Node Operator and Vault Owner to call
* `setRewardsAdjustment` on the Dashboard contract to correct the adjustment value in such cases.
* @param _dashboard The address of the dashboard contract.
* @param _allSourceValidatorBalancesWei The total balance (in wei) of all source validators.
* This value is used to adjust the rewards amount before charging the node operator fee.
*
* Additionally, this function assumes that the provided source and target pubkeys are valid, and that the reward
* adjustment value is appropriate. Because of this, it is highly recommended to use the `Vault CLI` tool to interact
* with this function. `Vault CLI` performs pre-checks to ensure the correctness of public keys and the adjustment value,
* and also monitors post-execution state on the CL to verify that the consolidation was successful.
* Node operator fee is applied only on rewards, which are defined as
* "all external ether that appeared in the vault on top of the initially deposited one".
* Without this adjustment, consolidated validator balances would incorrectly
* be included in the rewards base, which would lead to overcharging.
*
* @param _sourcePubkeys An array of tightly packed arrays of 48-byte public keys corresponding to validators requesting consolidation.
* | ----- public key (48 bytes) ----- || ----- public key (48 bytes) ----- | ...
* By passing the sum of all source validator balances, you ensure that these
* balances are excluded from the reward calculation, and the node operator fee
* is charged only on the actual rewards.
*
* @param _targetPubkeys An array of 48-byte public keys corresponding to validators to consolidate to.
* | ----- public key (48 bytes) ----- || ----- public key (48 bytes) ----- | ...
*
* @param _refundRecipient The address to refund the excess consolidation fee to.
* @param _stakingVault The address of the staking vault contract.
* @param _adjustmentIncrease The sum of the balances of the source validators to increase the rewards adjustment by.
* ⚠️ Note: this is not a precise method. It does not account for the future
* rewards that the consolidated validators may earn after this call, so in some
* setups additional correction may be required.
* @return adjustmentIncreaseEncodedCall The encoded call to increase the rewards adjustment
* (or empty if zero sum of source validator balances passed).
* @return consolidationRequestEncodedCalls The encoded calls for the consolidation requests.
*/
function addConsolidationRequests(
function getConsolidationRequestsAndAdjustmentIncreaseEncodedCalls(
bytes[] calldata _sourcePubkeys,
bytes[] calldata _targetPubkeys,
address _refundRecipient,
address _stakingVault,
uint256 _adjustmentIncrease
) external payable onlyDelegateCall {
if (msg.value == 0) revert ZeroArgument("msg.value");
address _dashboard,
uint256 _allSourceValidatorBalancesWei
) external view returns (
bytes memory adjustmentIncreaseEncodedCall,
bytes[] memory consolidationRequestEncodedCalls
) {
if (_sourcePubkeys.length == 0) revert ZeroArgument("sourcePubkeys");
if (_targetPubkeys.length == 0) revert ZeroArgument("targetPubkeys");
if (_stakingVault == address(0)) revert ZeroArgument("stakingVault");

// If the refund recipient is not set, use the sender as the refund recipient
if (_refundRecipient == address(0)) {
_refundRecipient = msg.sender;
}

if (_dashboard == address(0)) revert ZeroArgument("dashboard");
if (_sourcePubkeys.length != _targetPubkeys.length) {
revert MismatchingSourceAndTargetPubkeysCount(_sourcePubkeys.length, _targetPubkeys.length);
}

VaultHub vaultHub = VaultHub(payable(LIDO_LOCATOR.vaultHub()));
VaultHub.VaultConnection memory vaultConnection = vaultHub.vaultConnection(_stakingVault);

if(vaultConnection.vaultIndex == 0 || vaultHub.isPendingDisconnect(_stakingVault)) {
address stakingVault = address(Dashboard(payable(_dashboard)).stakingVault());
if (!vaultHub.isVaultConnected(stakingVault) || vaultHub.isPendingDisconnect(stakingVault)) {
revert VaultNotConnected();
}

uint256 totalSourcePubkeysCount = 0;
for (uint256 i = 0; i < _sourcePubkeys.length; i++) {
totalSourcePubkeysCount += _validateAndCountPubkeys(_sourcePubkeys[i]);
if (_targetPubkeys[i].length != PUBLIC_KEY_LENGTH) {
revert MalformedTargetPubkey();
}
VaultHub.VaultConnection memory vaultConnection = vaultHub.vaultConnection(stakingVault);
if (_dashboard != vaultConnection.owner) {
revert DashboardNotOwnerOfStakingVault();
}

uint256 feePerRequest = _getConsolidationRequestFee();
uint256 totalFee = totalSourcePubkeysCount * feePerRequest;
if (msg.value < totalFee) revert InsufficientValidatorConsolidationFee(msg.value, totalFee);
uint256 consolidationRequestsCount = _validatePubkeysAndCountConsolidationRequests(
_sourcePubkeys,
_targetPubkeys
);

for (uint256 i = 0; i < _sourcePubkeys.length; i++) {
_processConsolidationRequest(
_sourcePubkeys[i],
_targetPubkeys[i],
feePerRequest
);
if (_allSourceValidatorBalancesWei != 0 &&
_allSourceValidatorBalancesWei < consolidationRequestsCount * MINIMUM_VALIDATOR_BALANCE) {
revert InvalidAllSourceValidatorBalancesWei();
}

uint256 excess = msg.value - totalFee;
if (excess > 0) {
(bool success, ) = _refundRecipient.call{value: excess}("");
if (!success) revert ConsolidationFeeRefundFailed(_refundRecipient, excess);
}
consolidationRequestEncodedCalls = _consolidationCalldatas(
_sourcePubkeys,
_targetPubkeys,
consolidationRequestsCount
);

if(_adjustmentIncrease > 0) {
Dashboard(payable(vaultConnection.owner)).increaseRewardsAdjustment(_adjustmentIncrease);
if (_allSourceValidatorBalancesWei > 0) {
adjustmentIncreaseEncodedCall = abi.encodeWithSelector(
NodeOperatorFee.increaseRewardsAdjustment.selector,
_allSourceValidatorBalancesWei
);
}

emit ConsolidationRequestsAdded(msg.sender, _sourcePubkeys, _targetPubkeys, _refundRecipient, excess, _adjustmentIncrease);
}

/**
* @dev Retrieves the current EIP-7251 consolidation fee. This fee is valid only for the current block and may change in subsequent blocks.
* @dev Retrieves the current EIP-7251 consolidation fee. This fee is valid only for the current block and may
* change in subsequent blocks.
* @return The minimum fee required per consolidation request.
*/
function getConsolidationRequestFee() external view returns (uint256) {
return _getConsolidationRequestFee();
}

modifier onlyDelegateCall() {
if(address(this) == THIS) revert NotDelegateCall();
_;
}

function _getConsolidationRequestFee() private view returns (uint256) {
(bool success, bytes memory feeData) = CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS.staticcall("");

Expand All @@ -171,72 +157,60 @@ contract ValidatorConsolidationRequests {
return abi.decode(feeData, (uint256));
}

function _copyPubkeysToMemory(bytes memory _target, uint256 _targetIndex, bytes calldata _source, uint256 _sourceIndex) private pure {
assembly {
calldatacopy(add(_target, add(32, mul(_targetIndex, PUBLIC_KEY_LENGTH))), add(_source.offset, mul(_sourceIndex, PUBLIC_KEY_LENGTH)), PUBLIC_KEY_LENGTH)
function _consolidationCalldatas(
bytes[] calldata _sourcePubkeys,
bytes[] calldata _targetPubkeys,
uint256 _consolidationRequestsCount
) private pure returns (bytes[] memory consolidationRequestEncodedCalls) {
consolidationRequestEncodedCalls = new bytes[](_consolidationRequestsCount);

uint256 k = 0;
for (uint256 i = 0; i < _sourcePubkeys.length; i++) {
uint256 sourcePubkeysCount = _sourcePubkeys[i].length / PUBLIC_KEY_LENGTH;

for (uint256 j = 0; j < sourcePubkeysCount; j++) {
uint256 offset = j * PUBLIC_KEY_LENGTH;
uint256 end = offset + PUBLIC_KEY_LENGTH;

consolidationRequestEncodedCalls[k] = bytes.concat(_sourcePubkeys[i][offset : end], _targetPubkeys[i]);
unchecked { k++; }
}
}
}

function _validateAndCountPubkeys(bytes calldata _pubkeys) private pure returns (uint256) {
function _validateAndCountPubkeysInBatch(bytes calldata _pubkeys) private pure returns (uint256) {
if (_pubkeys.length % PUBLIC_KEY_LENGTH != 0) {
revert MalformedPubkeysArray();
revert MalformedSourcePubkeysArray();
}

uint256 keysCount = _pubkeys.length / PUBLIC_KEY_LENGTH;
if (keysCount == 0) {
revert NoConsolidationRequests();
}

return keysCount;
}

function _processConsolidationRequest(
bytes calldata _sourcePubkeys,
bytes calldata _targetPubkey,
uint256 _feePerRequest
) private {
uint256 sourcePubkeysCount = _validateAndCountPubkeys(_sourcePubkeys);
bytes memory callData = new bytes(CONSOLIDATION_REQUEST_CALLDATA_LENGTH);

for (uint256 j = 0; j < sourcePubkeysCount; j++) {
_copyPubkeysToMemory(callData, 0, _sourcePubkeys, j);
_copyPubkeysToMemory(callData, 1, _targetPubkey, 0);

(bool success, ) = CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS.call{value: _feePerRequest}(callData);
if (!success) {
revert ConsolidationRequestAdditionFailed(callData);
function _validatePubkeysAndCountConsolidationRequests(
bytes[] calldata _sourcePubkeys,
bytes[] calldata _targetPubkeys
) private pure returns (uint256) {
uint256 consolidationRequestsCount = 0;
for (uint256 i = 0; i < _sourcePubkeys.length; i++) {
if (_targetPubkeys[i].length != PUBLIC_KEY_LENGTH) {
revert MalformedTargetPubkey();
}
consolidationRequestsCount += _validateAndCountPubkeysInBatch(_sourcePubkeys[i]);
}
return consolidationRequestsCount;
}

/**
* @notice Emitted when the consolidation requests are added
* @param sender The address of the sender
* @param sourcePubkeys The source public keys
* @param targetPubkeys The target public keys
* @param refundRecipient The address of the refund recipient
* @param excess The excess consolidation fee
* @param adjustmentIncrease The adjustment increase
*/
event ConsolidationRequestsAdded(
address indexed sender,
bytes[] sourcePubkeys,
bytes[] targetPubkeys,
address indexed refundRecipient,
uint256 excess,
uint256 adjustmentIncrease
);

error ConsolidationFeeReadFailed();
error ConsolidationFeeInvalidData();
error ConsolidationFeeRefundFailed(address recipient, uint256 amount);
error ConsolidationRequestAdditionFailed(bytes callData);
error NoConsolidationRequests();
error MalformedPubkeysArray();
error ZeroArgument(string argName);
error MalformedSourcePubkeysArray();
error MalformedTargetPubkey();
error MismatchingSourceAndTargetPubkeysCount(uint256 sourcePubkeysCount, uint256 targetPubkeysCount);
error InsufficientValidatorConsolidationFee(uint256 provided, uint256 required);
error ZeroArgument(string argName);
error VaultNotConnected();
error NotDelegateCall();
error DashboardNotOwnerOfStakingVault();
error NoConsolidationRequests();
error InvalidAllSourceValidatorBalancesWei();
error ConsolidationFeeReadFailed();
error ConsolidationFeeInvalidData();
}
7 changes: 5 additions & 2 deletions test/0.8.25/vaults/consolidation/consolidationHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { BytesLike } from "ethers";

import { SecretKey } from "@chainsafe/blst";

import { ether } from "lib";

export function generateConsolidationRequestPayload(numberOfRequests: number): {
sourcePubkeys: BytesLike[];
targetPubkeys: BytesLike[];
Expand All @@ -12,18 +14,19 @@ export function generateConsolidationRequestPayload(numberOfRequests: number): {
const targetPubkeys: BytesLike[] = [];
let adjustmentIncrease: bigint = 0n;
let totalSourcePubkeysCount = 0;
const numberOfSourcePubkeys = 50;
const numberOfSourcePubkeysMax = 50;
for (let i = 1; i <= numberOfRequests; i++) {
let tempSourcePubkeys: Uint8Array = new Uint8Array();
const numberOfSourcePubkeys = Math.floor(Math.random() * numberOfSourcePubkeysMax) + 1;
totalSourcePubkeysCount += numberOfSourcePubkeys;
for (let j = 1; j <= numberOfSourcePubkeys; j++) {
const publicKey = generateRandomPublicKey(i * j);
tempSourcePubkeys = concatUint8Arrays([tempSourcePubkeys, publicKey]);
adjustmentIncrease += ether("17");
}
sourcePubkeys.push(tempSourcePubkeys);
const publicKey = generateRandomPublicKey(i * numberOfSourcePubkeys + 1);
targetPubkeys.push(publicKey);
adjustmentIncrease += 32n;
}

return {
Expand Down
Loading