diff --git a/contracts/0.8.9/WithdrawalVault.sol b/contracts/0.8.9/WithdrawalVault.sol index c5485b785..c47011914 100644 --- a/contracts/0.8.9/WithdrawalVault.sol +++ b/contracts/0.8.9/WithdrawalVault.sol @@ -9,6 +9,9 @@ import "@openzeppelin/contracts-v4.4/token/ERC721/IERC721.sol"; import "@openzeppelin/contracts-v4.4/token/ERC20/utils/SafeERC20.sol"; import {Versioned} from "./utils/Versioned.sol"; +import {AccessControlEnumerable} from "./utils/access/AccessControlEnumerable.sol"; +import {TriggerableWithdrawals} from "./lib/TriggerableWithdrawals.sol"; +import {ILidoLocator} from "../common/interfaces/ILidoLocator.sol"; interface ILido { /** @@ -22,12 +25,14 @@ interface ILido { /** * @title A vault for temporary storage of withdrawals */ -contract WithdrawalVault is Versioned { +contract WithdrawalVault is AccessControlEnumerable, Versioned { using SafeERC20 for IERC20; ILido public immutable LIDO; address public immutable TREASURY; + bytes32 public constant ADD_FULL_WITHDRAWAL_REQUEST_ROLE = keccak256("ADD_FULL_WITHDRAWAL_REQUEST_ROLE"); + // Events /** * Emitted when the ERC20 `token` recovered (i.e. transferred) @@ -42,34 +47,48 @@ contract WithdrawalVault is Versioned { event ERC721Recovered(address indexed requestedBy, address indexed token, uint256 tokenId); // Errors - error LidoZeroAddress(); - error TreasuryZeroAddress(); + error ZeroAddress(); error NotLido(); error NotEnoughEther(uint256 requested, uint256 balance); error ZeroAmount(); + error InsufficientTriggerableWithdrawalFee( + uint256 providedTotalFee, + uint256 requiredTotalFee, + uint256 requestCount + ); + error TriggerableWithdrawalRefundFailed(); /** * @param _lido the Lido token (stETH) address * @param _treasury the Lido treasury address (see ERC20/ERC721-recovery interfaces) */ - constructor(ILido _lido, address _treasury) { - if (address(_lido) == address(0)) { - revert LidoZeroAddress(); - } - if (_treasury == address(0)) { - revert TreasuryZeroAddress(); - } + constructor(address _lido, address _treasury) { + _requireNonZero(_lido); + _requireNonZero(_treasury); - LIDO = _lido; + LIDO = ILido(_lido); TREASURY = _treasury; } - /** - * @notice Initialize the contract explicitly. - * Sets the contract version to '1'. - */ - function initialize() external { - _initializeContractVersionTo(1); + /// @notice Initializes the contract. Can be called only once. + /// @param _admin Lido DAO Aragon agent contract address. + /// @dev Proxy initialization method. + function initialize(address _admin) external { + // Initializations for v0 --> v2 + _checkContractVersion(0); + + _initialize_v2(_admin); + _initializeContractVersionTo(2); + } + + /// @notice Finalizes upgrade to v2 (from v1). Can be called only once. + /// @param _admin Lido DAO Aragon agent contract address. + function finalizeUpgrade_v2(address _admin) external { + // Finalization for v1 --> v2 + _checkContractVersion(1); + + _initialize_v2(_admin); + _updateContractVersion(2); } /** @@ -122,4 +141,52 @@ contract WithdrawalVault is Versioned { _token.transferFrom(address(this), TREASURY, _tokenId); } + + /** + * @dev Adds full withdrawal requests for the provided public keys. + * The validator will fully withdraw and exit its duties as a validator. + * @param pubkeys An array of public keys for the validators requesting full withdrawals. + */ + function addFullWithdrawalRequests( + bytes calldata pubkeys + ) external payable onlyRole(ADD_FULL_WITHDRAWAL_REQUEST_ROLE) { + uint256 prevBalance = address(this).balance - msg.value; + + uint256 minFeePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); + uint256 totalFee = (pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH) * minFeePerRequest; + + if (totalFee > msg.value) { + revert InsufficientTriggerableWithdrawalFee( + msg.value, + totalFee, + pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH + ); + } + + TriggerableWithdrawals.addFullWithdrawalRequests(pubkeys, minFeePerRequest); + + uint256 refund = msg.value - totalFee; + if (refund > 0) { + (bool success, ) = msg.sender.call{value: refund}(""); + + if (!success) { + revert TriggerableWithdrawalRefundFailed(); + } + } + + assert(address(this).balance == prevBalance); + } + + function getWithdrawalRequestFee() external view returns (uint256) { + return TriggerableWithdrawals.getWithdrawalRequestFee(); + } + + function _requireNonZero(address _address) internal pure { + if (_address == address(0)) revert ZeroAddress(); + } + + function _initialize_v2(address _admin) internal { + _requireNonZero(_admin); + _setupRole(DEFAULT_ADMIN_ROLE, _admin); + } } diff --git a/contracts/0.8.9/lib/TriggerableWithdrawals.sol b/contracts/0.8.9/lib/TriggerableWithdrawals.sol new file mode 100644 index 000000000..3bd8425a4 --- /dev/null +++ b/contracts/0.8.9/lib/TriggerableWithdrawals.sol @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.9; +library TriggerableWithdrawals { + address constant WITHDRAWAL_REQUEST = 0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA; + uint256 internal constant WITHDRAWAL_REQUEST_CALLDATA_LENGTH = 56; + uint256 internal constant PUBLIC_KEY_LENGTH = 48; + uint256 internal constant WITHDRAWAL_AMOUNT_LENGTH = 8; + + error MismatchedArrayLengths(uint256 keysCount, uint256 amountsCount); + error InsufficientBalance(uint256 balance, uint256 totalWithdrawalFee); + error InsufficientRequestFee(uint256 feePerRequest, uint256 minFeePerRequest); + + error WithdrawalRequestFeeReadFailed(); + error WithdrawalRequestAdditionFailed(bytes callData); + error NoWithdrawalRequests(); + error PartialWithdrawalRequired(uint256 index); + error InvalidPublicKeyLength(); + + /** + * @dev Adds full withdrawal requests for the provided public keys. + * The validator will fully withdraw and exit its duties as a validator. + * @param pubkeys An array of public keys for the validators requesting full withdrawals. + */ + function addFullWithdrawalRequests(bytes calldata pubkeys, uint256 feePerRequest) internal { + uint256 keysCount = _validateAndCountPubkeys(pubkeys); + feePerRequest = _validateAndAdjustFee(feePerRequest, keysCount); + + bytes memory callData = new bytes(56); + + for (uint256 i = 0; i < keysCount; i++) { + _copyPubkeyToMemory(pubkeys, callData, i); + + (bool success, ) = WITHDRAWAL_REQUEST.call{value: feePerRequest}(callData); + + if (!success) { + revert WithdrawalRequestAdditionFailed(callData); + } + } + } + + /** + * @dev Adds partial withdrawal requests for the provided public keys with corresponding amounts. + * A partial withdrawal is any withdrawal where the amount is greater than zero. + * A full withdrawal is any withdrawal where the amount is zero. + * This allows withdrawal of any balance exceeding 32 ETH (e.g., if a validator has 35 ETH, up to 3 ETH can be withdrawn). + * However, the protocol enforces a minimum balance of 32 ETH per validator, even if a higher amount is requested. + * @param pubkeys An array of public keys for the validators requesting withdrawals. + * @param amounts An array of corresponding withdrawal amounts for each public key. + */ + function addPartialWithdrawalRequests( + bytes calldata pubkeys, + uint64[] calldata amounts, + uint256 feePerRequest + ) internal { + for (uint256 i = 0; i < amounts.length; i++) { + if (amounts[i] == 0) { + revert PartialWithdrawalRequired(i); + } + } + + addWithdrawalRequests(pubkeys, amounts, feePerRequest); + } + + /** + * @dev Adds partial or full withdrawal requests for the provided public keys with corresponding amounts. + * A partial withdrawal is any withdrawal where the amount is greater than zero. + * This allows withdrawal of any balance exceeding 32 ETH (e.g., if a validator has 35 ETH, up to 3 ETH can be withdrawn). + * However, the protocol enforces a minimum balance of 32 ETH per validator, even if a higher amount is requested. + * @param pubkeys An array of public keys for the validators requesting withdrawals. + * @param amounts An array of corresponding withdrawal amounts for each public key. + */ + function addWithdrawalRequests(bytes calldata pubkeys, uint64[] calldata amounts, uint256 feePerRequest) internal { + uint256 keysCount = _validateAndCountPubkeys(pubkeys); + + if (keysCount != amounts.length) { + revert MismatchedArrayLengths(keysCount, amounts.length); + } + + feePerRequest = _validateAndAdjustFee(feePerRequest, keysCount); + + bytes memory callData = new bytes(56); + for (uint256 i = 0; i < keysCount; i++) { + _copyPubkeyToMemory(pubkeys, callData, i); + _copyAmountToMemory(callData, amounts[i]); + + (bool success, ) = WITHDRAWAL_REQUEST.call{value: feePerRequest}(callData); + + if (!success) { + revert WithdrawalRequestAdditionFailed(callData); + } + } + } + + /** + * @dev Retrieves the current withdrawal request fee. + * @return The minimum fee required per withdrawal request. + */ + function getWithdrawalRequestFee() internal view returns (uint256) { + (bool success, bytes memory feeData) = WITHDRAWAL_REQUEST.staticcall(""); + + if (!success) { + revert WithdrawalRequestFeeReadFailed(); + } + + return abi.decode(feeData, (uint256)); + } + + function _copyPubkeyToMemory(bytes calldata pubkeys, bytes memory target, uint256 keyIndex) private pure { + assembly { + calldatacopy(add(target, 32), add(pubkeys.offset, mul(keyIndex, PUBLIC_KEY_LENGTH)), PUBLIC_KEY_LENGTH) + } + } + + function _copyAmountToMemory(bytes memory target, uint64 amount) private pure { + assembly { + mstore(add(target, 80), shl(192, amount)) + } + } + + function _validateAndCountPubkeys(bytes calldata pubkeys) private pure returns (uint256) { + if (pubkeys.length % PUBLIC_KEY_LENGTH != 0) { + revert InvalidPublicKeyLength(); + } + + uint256 keysCount = pubkeys.length / PUBLIC_KEY_LENGTH; + if (keysCount == 0) { + revert NoWithdrawalRequests(); + } + + return keysCount; + } + + function _validateAndAdjustFee(uint256 feePerRequest, uint256 keysCount) private view returns (uint256) { + uint256 minFeePerRequest = getWithdrawalRequestFee(); + + if (feePerRequest == 0) { + feePerRequest = minFeePerRequest; + } + + if (feePerRequest < minFeePerRequest) { + revert InsufficientRequestFee(feePerRequest, minFeePerRequest); + } + + if (address(this).balance < feePerRequest * keysCount) { + revert InsufficientBalance(address(this).balance, feePerRequest * keysCount); + } + + return feePerRequest; + } +} diff --git a/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts b/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts index dab37394b..bd8eff9eb 100644 --- a/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts +++ b/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts @@ -35,6 +35,7 @@ export async function main() { const exitBusOracleAdmin = testnetAdmin; const stakingRouterAdmin = testnetAdmin; const withdrawalQueueAdmin = testnetAdmin; + const withdrawalVaultAdmin = testnetAdmin; // Initialize NodeOperatorsRegistry @@ -108,6 +109,10 @@ export async function main() { { from: deployer }, ); + // Initialize WithdrawalVault + const withdrawalVault = await loadContract("WithdrawalVault", withdrawalVaultAddress); + await makeTx(withdrawalVault, "initialize", [withdrawalVaultAdmin], { from: deployer }); + // Initialize WithdrawalQueue const withdrawalQueue = await loadContract("WithdrawalQueueERC721", withdrawalQueueAddress); await makeTx(withdrawalQueue, "initialize", [withdrawalQueueAdmin], { from: deployer }); diff --git a/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol b/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol new file mode 100644 index 000000000..1ea18a48b --- /dev/null +++ b/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol @@ -0,0 +1,38 @@ +pragma solidity 0.8.9; + +import {TriggerableWithdrawals} from "contracts/0.8.9/lib/TriggerableWithdrawals.sol"; + +contract TriggerableWithdrawals_Harness { + function addFullWithdrawalRequests( + bytes calldata pubkeys, + uint256 feePerRequest + ) external { + TriggerableWithdrawals.addFullWithdrawalRequests(pubkeys, feePerRequest); + } + + function addPartialWithdrawalRequests( + bytes calldata pubkeys, + uint64[] calldata amounts, + uint256 feePerRequest + ) external { + TriggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, feePerRequest); + } + + function addWithdrawalRequests( + bytes calldata pubkeys, + uint64[] calldata amounts, + uint256 feePerRequest + ) external { + TriggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, feePerRequest); + } + + function getWithdrawalRequestFee() external view returns (uint256) { + return TriggerableWithdrawals.getWithdrawalRequestFee(); + } + + function getWithdrawalsContractAddress() public pure returns (address) { + return TriggerableWithdrawals.WITHDRAWAL_REQUEST; + } + + function deposit() external payable {} +} diff --git a/test/0.8.9/contracts/WithdrawalVault__Harness.sol b/test/0.8.9/contracts/WithdrawalVault__Harness.sol new file mode 100644 index 000000000..229e33c9a --- /dev/null +++ b/test/0.8.9/contracts/WithdrawalVault__Harness.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.9; + +import {WithdrawalVault} from "contracts/0.8.9/WithdrawalVault.sol"; + +contract WithdrawalVault__Harness is WithdrawalVault { + constructor(address _lido, address _treasury) WithdrawalVault(_lido, _treasury) { + } + + function harness__initializeContractVersionTo(uint256 _version) external { + _initializeContractVersionTo(_version); + } +} diff --git a/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol b/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol new file mode 100644 index 000000000..25581ff79 --- /dev/null +++ b/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.9; + +/** + * @notice This is an mock of EIP-7002's pre-deploy contract. + */ +contract WithdrawalsPredeployed_Mock { + uint256 public fee; + bool public failOnAddRequest; + bool public failOnGetFee; + + event eip7002MockRequestAdded(bytes request, uint256 fee); + + function setFailOnAddRequest(bool _failOnAddRequest) external { + failOnAddRequest = _failOnAddRequest; + } + + function setFailOnGetFee(bool _failOnGetFee) external { + failOnGetFee = _failOnGetFee; + } + + function setFee(uint256 _fee) external { + require(_fee > 0, "fee must be greater than 0"); + fee = _fee; + } + + fallback(bytes calldata input) external payable returns (bytes memory output){ + if (input.length == 0) { + require(!failOnGetFee, "fail on get fee"); + + output = abi.encode(fee); + return output; + } + + require(!failOnAddRequest, "fail on add request"); + + require(input.length == 56, "Invalid callData length"); + + emit eip7002MockRequestAdded( + input, + msg.value + ); + } +} diff --git a/test/0.8.9/lib/triggerableWithdrawals/eip7002Mock.ts b/test/0.8.9/lib/triggerableWithdrawals/eip7002Mock.ts new file mode 100644 index 000000000..5fd83ae17 --- /dev/null +++ b/test/0.8.9/lib/triggerableWithdrawals/eip7002Mock.ts @@ -0,0 +1,41 @@ +import { expect } from "chai"; +import { ContractTransactionReceipt } from "ethers"; +import { ContractTransactionResponse } from "ethers"; +import { ethers } from "hardhat"; + +import { findEventsWithInterfaces } from "lib"; + +const eip7002MockEventABI = ["event eip7002MockRequestAdded(bytes request, uint256 fee)"]; +const eip7002MockInterface = new ethers.Interface(eip7002MockEventABI); +type Eip7002MockTriggerableWithdrawalEvents = "eip7002MockRequestAdded"; + +export function findEip7002MockEvents( + receipt: ContractTransactionReceipt, + event: Eip7002MockTriggerableWithdrawalEvents, +) { + return findEventsWithInterfaces(receipt!, event, [eip7002MockInterface]); +} + +export function encodeEip7002Payload(pubkey: string, amount: bigint): string { + return `0x${pubkey}${amount.toString(16).padStart(16, "0")}`; +} + +export const testEip7002Mock = async ( + addTriggeranleWithdrawalRequests: () => Promise, + expectedPubkeys: string[], + expectedAmounts: bigint[], + expectedFee: bigint, +) => { + const tx = await addTriggeranleWithdrawalRequests(); + const receipt = await tx.wait(); + + const events = findEip7002MockEvents(receipt!, "eip7002MockRequestAdded"); + expect(events.length).to.equal(expectedPubkeys.length); + + for (let i = 0; i < expectedPubkeys.length; i++) { + expect(events[i].args[0]).to.equal(encodeEip7002Payload(expectedPubkeys[i], expectedAmounts[i])); + expect(events[i].args[1]).to.equal(expectedFee); + } + + return { tx, receipt }; +}; diff --git a/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts b/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts new file mode 100644 index 000000000..5600a7e27 --- /dev/null +++ b/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts @@ -0,0 +1,595 @@ +import { expect } from "chai"; +import { ContractTransactionResponse } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; +import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; + +import { TriggerableWithdrawals_Harness, WithdrawalsPredeployed_Mock } from "typechain-types"; + +import { Snapshot } from "test/suite"; + +import { findEip7002MockEvents, testEip7002Mock } from "./eip7002Mock"; +import { + deployWithdrawalsPredeployedMock, + generateWithdrawalRequestPayload, + withdrawalsPredeployedHardcodedAddress, +} from "./utils"; + +const EMPTY_PUBKEYS = "0x"; + +describe("TriggerableWithdrawals.sol", () => { + let actor: HardhatEthersSigner; + + let withdrawalsPredeployed: WithdrawalsPredeployed_Mock; + let triggerableWithdrawals: TriggerableWithdrawals_Harness; + + let originalState: string; + + async function getWithdrawalCredentialsContractBalance(): Promise { + const contractAddress = await triggerableWithdrawals.getAddress(); + return await ethers.provider.getBalance(contractAddress); + } + + async function getWithdrawalsPredeployedContractBalance(): Promise { + const contractAddress = await withdrawalsPredeployed.getAddress(); + return await ethers.provider.getBalance(contractAddress); + } + + const MAX_UINT64 = (1n << 64n) - 1n; + + before(async () => { + [actor] = await ethers.getSigners(); + + withdrawalsPredeployed = await deployWithdrawalsPredeployedMock(1n); + triggerableWithdrawals = await ethers.deployContract("TriggerableWithdrawals_Harness"); + + expect(await withdrawalsPredeployed.getAddress()).to.equal(withdrawalsPredeployedHardcodedAddress); + + await triggerableWithdrawals.connect(actor).deposit({ value: ethers.parseEther("1") }); + }); + + beforeEach(async () => (originalState = await Snapshot.take())); + + afterEach(async () => await Snapshot.restore(originalState)); + + async function getFee(): Promise { + return await triggerableWithdrawals.getWithdrawalRequestFee(); + } + + context("eip 7002 contract", () => { + it("Should return the address of the EIP 7002 contract", async function () { + expect(await triggerableWithdrawals.getWithdrawalsContractAddress()).to.equal( + withdrawalsPredeployedHardcodedAddress, + ); + }); + }); + + context("get triggerable withdrawal request fee", () => { + it("Should get fee from the EIP 7002 contract", async function () { + await withdrawalsPredeployed.setFee(333n); + expect( + (await triggerableWithdrawals.getWithdrawalRequestFee()) == 333n, + "withdrawal request should use fee from the EIP 7002 contract", + ); + }); + + it("Should revert if fee read fails", async function () { + await withdrawalsPredeployed.setFailOnGetFee(true); + await expect(triggerableWithdrawals.getWithdrawalRequestFee()).to.be.revertedWithCustomError( + triggerableWithdrawals, + "WithdrawalRequestFeeReadFailed", + ); + }); + }); + + context("add triggerable withdrawal requests", () => { + it("Should revert if empty arrays are provided", async function () { + await expect(triggerableWithdrawals.addFullWithdrawalRequests(EMPTY_PUBKEYS, 1n)).to.be.revertedWithCustomError( + triggerableWithdrawals, + "NoWithdrawalRequests", + ); + + await expect( + triggerableWithdrawals.addPartialWithdrawalRequests(EMPTY_PUBKEYS, [], 1n), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "NoWithdrawalRequests"); + + await expect(triggerableWithdrawals.addWithdrawalRequests(EMPTY_PUBKEYS, [], 1n)).to.be.revertedWithCustomError( + triggerableWithdrawals, + "NoWithdrawalRequests", + ); + }); + + it("Should revert if array lengths do not match", async function () { + const requestCount = 2; + const { pubkeysHexString } = generateWithdrawalRequestPayload(requestCount); + const amounts = [1n]; + + const fee = await getFee(); + + await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") + .withArgs(requestCount, amounts.length); + + await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, [], fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") + .withArgs(requestCount, 0); + + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, amounts, fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") + .withArgs(requestCount, amounts.length); + + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, [], fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") + .withArgs(requestCount, 0); + }); + + it("Should revert if not enough fee is sent", async function () { + const { pubkeysHexString } = generateWithdrawalRequestPayload(1); + const amounts = [10n]; + + await withdrawalsPredeployed.setFee(3n); // Set fee to 3 gwei + + // 2. Should revert if fee is less than required + const insufficientFee = 2n; + await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, insufficientFee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientRequestFee") + .withArgs(2n, 3n); + + await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, insufficientFee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientRequestFee") + .withArgs(2n, 3n); + + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, amounts, insufficientFee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientRequestFee") + .withArgs(2n, 3n); + }); + + it("Should revert if pubkey is not 48 bytes", async function () { + // Invalid pubkey (only 2 bytes) + const invalidPubkeyHexString = "0x1234"; + const amounts = [10n]; + + const fee = await getFee(); + + await expect( + triggerableWithdrawals.addFullWithdrawalRequests(invalidPubkeyHexString, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPublicKeyLength"); + + await expect( + triggerableWithdrawals.addPartialWithdrawalRequests(invalidPubkeyHexString, amounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPublicKeyLength"); + + await expect( + triggerableWithdrawals.addWithdrawalRequests(invalidPubkeyHexString, amounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPublicKeyLength"); + }); + + it("Should revert if last pubkey not 48 bytes", async function () { + const validPubey = + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f"; + const invalidPubkey = "1234"; + const pubkeysHexString = `0x${validPubey}${invalidPubkey}`; + + const amounts = [10n]; + + const fee = await getFee(); + + await expect( + triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPublicKeyLength"); + + await expect( + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPublicKeyLength"); + + await expect( + triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, amounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPublicKeyLength"); + }); + + it("Should revert if addition fails at the withdrawal request contract", async function () { + const { pubkeysHexString } = generateWithdrawalRequestPayload(1); + const amounts = [10n]; + + const fee = await getFee(); + + // Set mock to fail on add + await withdrawalsPredeployed.setFailOnAddRequest(true); + + await expect( + triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestAdditionFailed"); + + await expect( + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestAdditionFailed"); + + await expect( + triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, amounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestAdditionFailed"); + }); + + it("Should revert when a full withdrawal amount is included in 'addPartialWithdrawalRequests'", async function () { + const { pubkeysHexString } = generateWithdrawalRequestPayload(2); + const amounts = [1n, 0n]; // Partial and Full withdrawal + const fee = await getFee(); + + await expect( + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "PartialWithdrawalRequired"); + }); + + it("Should revert when balance is less than total withdrawal fee", async function () { + const keysCount = 2; + const fee = 10n; + const balance = 19n; + const expectedMinimalBalance = 20n; + + const { pubkeysHexString, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(keysCount); + + await withdrawalsPredeployed.setFee(fee); + await setBalance(await triggerableWithdrawals.getAddress(), balance); + + await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalance") + .withArgs(balance, expectedMinimalBalance); + + await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalance") + .withArgs(balance, expectedMinimalBalance); + + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalance") + .withArgs(balance, expectedMinimalBalance); + }); + + it("Should revert when fee read fails", async function () { + await withdrawalsPredeployed.setFailOnGetFee(true); + + const { pubkeysHexString, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(2); + const fee = 10n; + + await expect( + triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestFeeReadFailed"); + + await expect( + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestFeeReadFailed"); + + await expect( + triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestFeeReadFailed"); + }); + + it("Should accept withdrawal requests with minimal possible fee when fee not provided", async function () { + const requestCount = 3; + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + const fee = 3n; + const fee_not_provided = 0n; + await withdrawalsPredeployed.setFee(fee); + + await testEip7002Mock( + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee_not_provided), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + + await testEip7002Mock( + () => + triggerableWithdrawals.addPartialWithdrawalRequests( + pubkeysHexString, + partialWithdrawalAmounts, + fee_not_provided, + ), + pubkeys, + partialWithdrawalAmounts, + fee, + ); + + await testEip7002Mock( + () => triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee_not_provided), + pubkeys, + mixedWithdrawalAmounts, + fee, + ); + }); + + it("Should accept withdrawal requests when the provided fee matches the exact required amount", async function () { + const requestCount = 3; + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + const fee = 3n; + await withdrawalsPredeployed.setFee(fee); + + await testEip7002Mock( + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + + await testEip7002Mock( + () => triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), + pubkeys, + partialWithdrawalAmounts, + fee, + ); + + await testEip7002Mock( + () => triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), + pubkeys, + mixedWithdrawalAmounts, + fee, + ); + + // Check extremely high fee + const highFee = ethers.parseEther("10"); + await withdrawalsPredeployed.setFee(highFee); + + await triggerableWithdrawals.connect(actor).deposit({ value: highFee * BigInt(requestCount) * 3n }); + + await testEip7002Mock( + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, highFee), + pubkeys, + fullWithdrawalAmounts, + highFee, + ); + + await testEip7002Mock( + () => triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, highFee), + pubkeys, + partialWithdrawalAmounts, + highFee, + ); + + await testEip7002Mock( + () => triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, highFee), + pubkeys, + mixedWithdrawalAmounts, + highFee, + ); + }); + + it("Should accept withdrawal requests when the provided fee exceeds the required amount", async function () { + const requestCount = 3; + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + await withdrawalsPredeployed.setFee(3n); + const excessFee = 4n; + + await testEip7002Mock( + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, excessFee), + pubkeys, + fullWithdrawalAmounts, + excessFee, + ); + + await testEip7002Mock( + () => + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, excessFee), + pubkeys, + partialWithdrawalAmounts, + excessFee, + ); + + await testEip7002Mock( + () => triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, excessFee), + pubkeys, + mixedWithdrawalAmounts, + excessFee, + ); + + // Check when the provided fee extremely exceeds the required amount + const extremelyHighFee = ethers.parseEther("10"); + await triggerableWithdrawals.connect(actor).deposit({ value: extremelyHighFee * BigInt(requestCount) * 3n }); + + await testEip7002Mock( + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, extremelyHighFee), + pubkeys, + fullWithdrawalAmounts, + extremelyHighFee, + ); + + await testEip7002Mock( + () => + triggerableWithdrawals.addPartialWithdrawalRequests( + pubkeysHexString, + partialWithdrawalAmounts, + extremelyHighFee, + ), + pubkeys, + partialWithdrawalAmounts, + extremelyHighFee, + ); + + await testEip7002Mock( + () => triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, extremelyHighFee), + pubkeys, + mixedWithdrawalAmounts, + extremelyHighFee, + ); + }); + + it("Should correctly deduct the exact fee amount from the contract balance", async function () { + const requestCount = 3; + const { pubkeysHexString, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + const fee = 4n; + const expectedTotalWithdrawalFee = 12n; // fee * requestCount; + + const testFeeDeduction = async (addRequests: () => Promise) => { + const initialBalance = await getWithdrawalCredentialsContractBalance(); + await addRequests(); + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance - expectedTotalWithdrawalFee); + }; + + await testFeeDeduction(() => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee)); + await testFeeDeduction(() => + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), + ); + await testFeeDeduction(() => + triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), + ); + }); + + it("Should transfer the total calculated fee to the EIP-7002 withdrawal contract", async function () { + const requestCount = 3; + const { pubkeysHexString, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + const fee = 3n; + const expectedTotalWithdrawalFee = 9n; // fee * requestCount; + + const testFeeTransfer = async (addRequests: () => Promise) => { + const initialBalance = await getWithdrawalsPredeployedContractBalance(); + await addRequests(); + expect(await getWithdrawalsPredeployedContractBalance()).to.equal(initialBalance + expectedTotalWithdrawalFee); + }; + + await testFeeTransfer(() => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee)); + await testFeeTransfer(() => + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), + ); + await testFeeTransfer(() => + triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), + ); + }); + + it("Should accept full, partial, and mixed withdrawal requests via 'addWithdrawalRequests' function", async function () { + const { pubkeysHexString, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(3); + const fee = await getFee(); + + await triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, fullWithdrawalAmounts, fee); + await triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee); + await triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee); + }); + + it("Should handle maximum uint64 withdrawal amount in partial withdrawal requests", async function () { + const { pubkeysHexString } = generateWithdrawalRequestPayload(1); + const amounts = [MAX_UINT64]; + + await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, 10n); + await triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, amounts, 10n); + }); + + it("Should ensure withdrawal requests are encoded as expected with a 48-byte pubkey and 8-byte amount", async function () { + const requestCount = 16; + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + const fee = 333n; + + const testEncoding = async ( + addRequests: () => Promise, + expectedPubKeys: string[], + expectedAmounts: bigint[], + ) => { + const tx = await addRequests(); + const receipt = await tx.wait(); + + const events = findEip7002MockEvents(receipt!, "eip7002MockRequestAdded"); + expect(events.length).to.equal(requestCount); + + for (let i = 0; i < requestCount; i++) { + const encodedRequest = events[i].args[0]; + // 0x (2 characters) + 48-byte pubkey (96 characters) + 8-byte amount (16 characters) = 114 characters + expect(encodedRequest.length).to.equal(114); + + expect(encodedRequest.slice(0, 2)).to.equal("0x"); + expect(encodedRequest.slice(2, 98)).to.equal(expectedPubKeys[i]); + expect(encodedRequest.slice(98, 114)).to.equal(expectedAmounts[i].toString(16).padStart(16, "0")); + + // double check the amount convertation + expect(BigInt("0x" + encodedRequest.slice(98, 114))).to.equal(expectedAmounts[i]); + } + }; + + await testEncoding( + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), + pubkeys, + fullWithdrawalAmounts, + ); + await testEncoding( + () => triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), + pubkeys, + partialWithdrawalAmounts, + ); + await testEncoding( + () => triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), + pubkeys, + mixedWithdrawalAmounts, + ); + }); + + async function addWithdrawalRequests( + addRequests: () => Promise, + expectedPubkeys: string[], + expectedAmounts: bigint[], + expectedFee: bigint, + expectedTotalWithdrawalFee: bigint, + ) { + const initialBalance = await getWithdrawalCredentialsContractBalance(); + + await testEip7002Mock(addRequests, expectedPubkeys, expectedAmounts, expectedFee); + + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance - expectedTotalWithdrawalFee); + } + + const testCasesForWithdrawalRequests = [ + { requestCount: 1, fee: 0n }, + { requestCount: 1, fee: 100n }, + { requestCount: 1, fee: 100_000_000_000n }, + { requestCount: 3, fee: 0n }, + { requestCount: 3, fee: 1n }, + { requestCount: 7, fee: 3n }, + { requestCount: 10, fee: 0n }, + { requestCount: 10, fee: 100_000_000_000n }, + { requestCount: 100, fee: 0n }, + ]; + + testCasesForWithdrawalRequests.forEach(({ requestCount, fee }) => { + it(`Should successfully add ${requestCount} requests with fee ${fee}`, async () => { + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + const expectedFee = fee == 0n ? await getFee() : fee; + const expectedTotalWithdrawalFee = expectedFee * BigInt(requestCount); + + await addWithdrawalRequests( + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), + pubkeys, + fullWithdrawalAmounts, + expectedFee, + expectedTotalWithdrawalFee, + ); + + await addWithdrawalRequests( + () => triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), + pubkeys, + partialWithdrawalAmounts, + expectedFee, + expectedTotalWithdrawalFee, + ); + + await addWithdrawalRequests( + () => triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), + pubkeys, + mixedWithdrawalAmounts, + expectedFee, + expectedTotalWithdrawalFee, + ); + }); + }); + }); +}); diff --git a/test/0.8.9/lib/triggerableWithdrawals/utils.ts b/test/0.8.9/lib/triggerableWithdrawals/utils.ts new file mode 100644 index 000000000..676cd9ac8 --- /dev/null +++ b/test/0.8.9/lib/triggerableWithdrawals/utils.ts @@ -0,0 +1,57 @@ +import { ethers } from "hardhat"; + +import { WithdrawalsPredeployed_Mock } from "typechain-types"; + +export const withdrawalsPredeployedHardcodedAddress = "0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA"; + +export async function deployWithdrawalsPredeployedMock( + defaultRequestFee: bigint, +): Promise { + const withdrawalsPredeployed = await ethers.deployContract("WithdrawalsPredeployed_Mock"); + const withdrawalsPredeployedAddress = await withdrawalsPredeployed.getAddress(); + + await ethers.provider.send("hardhat_setCode", [ + withdrawalsPredeployedHardcodedAddress, + await ethers.provider.getCode(withdrawalsPredeployedAddress), + ]); + + const contract = await ethers.getContractAt("WithdrawalsPredeployed_Mock", withdrawalsPredeployedHardcodedAddress); + await contract.setFee(defaultRequestFee); + return contract; +} + +function toValidatorPubKey(num: number): string { + if (num < 0 || num > 0xffff) { + throw new Error("Number is out of the 2-byte range (0x0000 - 0xffff)."); + } + + return `${num.toString(16).padStart(4, "0").toLocaleLowerCase().repeat(24)}`; +} + +const convertEthToGwei = (ethAmount: string | number): bigint => { + const ethString = ethAmount.toString(); + const wei = ethers.parseEther(ethString); + return wei / 1_000_000_000n; +}; + +export function generateWithdrawalRequestPayload(numberOfRequests: number) { + const pubkeys: string[] = []; + const fullWithdrawalAmounts: bigint[] = []; + const partialWithdrawalAmounts: bigint[] = []; + const mixedWithdrawalAmounts: bigint[] = []; + + for (let i = 1; i <= numberOfRequests; i++) { + pubkeys.push(toValidatorPubKey(i)); + fullWithdrawalAmounts.push(0n); + partialWithdrawalAmounts.push(convertEthToGwei(i)); + mixedWithdrawalAmounts.push(i % 2 === 0 ? 0n : convertEthToGwei(i)); + } + + return { + pubkeysHexString: `0x${pubkeys.join("")}`, + pubkeys, + fullWithdrawalAmounts, + partialWithdrawalAmounts, + mixedWithdrawalAmounts, + }; +} diff --git a/test/0.8.9/withdrawalVault.test.ts b/test/0.8.9/withdrawalVault.test.ts index c953f23d7..e4bc64f17 100644 --- a/test/0.8.9/withdrawalVault.test.ts +++ b/test/0.8.9/withdrawalVault.test.ts @@ -5,38 +5,59 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; -import { ERC20__Harness, ERC721__Harness, Lido__MockForWithdrawalVault, WithdrawalVault } from "typechain-types"; +import { + ERC20__Harness, + ERC721__Harness, + Lido__MockForWithdrawalVault, + WithdrawalsPredeployed_Mock, + WithdrawalVault__Harness, +} from "typechain-types"; -import { MAX_UINT256, proxify } from "lib"; +import { MAX_UINT256, proxify, streccak } from "lib"; import { Snapshot } from "test/suite"; +import { findEip7002MockEvents, testEip7002Mock } from "./lib/triggerableWithdrawals/eip7002Mock"; +import { + deployWithdrawalsPredeployedMock, + generateWithdrawalRequestPayload, + withdrawalsPredeployedHardcodedAddress, +} from "./lib/triggerableWithdrawals/utils"; + const PETRIFIED_VERSION = MAX_UINT256; +const ADD_FULL_WITHDRAWAL_REQUEST_ROLE = streccak("ADD_FULL_WITHDRAWAL_REQUEST_ROLE"); + describe("WithdrawalVault.sol", () => { let owner: HardhatEthersSigner; - let user: HardhatEthersSigner; let treasury: HardhatEthersSigner; + let validatorsExitBus: HardhatEthersSigner; + let stranger: HardhatEthersSigner; let originalState: string; let lido: Lido__MockForWithdrawalVault; let lidoAddress: string; - let impl: WithdrawalVault; - let vault: WithdrawalVault; + let withdrawalsPredeployed: WithdrawalsPredeployed_Mock; + + let impl: WithdrawalVault__Harness; + let vault: WithdrawalVault__Harness; let vaultAddress: string; before(async () => { - [owner, user, treasury] = await ethers.getSigners(); + [owner, treasury, validatorsExitBus, stranger] = await ethers.getSigners(); + + withdrawalsPredeployed = await deployWithdrawalsPredeployedMock(1n); + + expect(await withdrawalsPredeployed.getAddress()).to.equal(withdrawalsPredeployedHardcodedAddress); lido = await ethers.deployContract("Lido__MockForWithdrawalVault"); lidoAddress = await lido.getAddress(); - impl = await ethers.deployContract("WithdrawalVault", [lidoAddress, treasury.address]); + impl = await ethers.deployContract("WithdrawalVault__Harness", [lidoAddress, treasury.address], owner); [vault] = await proxify({ impl, admin: owner }); - vaultAddress = await vault.getAddress(); }); @@ -48,13 +69,13 @@ describe("WithdrawalVault.sol", () => { it("Reverts if the Lido address is zero", async () => { await expect( ethers.deployContract("WithdrawalVault", [ZeroAddress, treasury.address]), - ).to.be.revertedWithCustomError(vault, "LidoZeroAddress"); + ).to.be.revertedWithCustomError(vault, "ZeroAddress"); }); it("Reverts if the treasury address is zero", async () => { await expect(ethers.deployContract("WithdrawalVault", [lidoAddress, ZeroAddress])).to.be.revertedWithCustomError( vault, - "TreasuryZeroAddress", + "ZeroAddress", ); }); @@ -73,22 +94,102 @@ describe("WithdrawalVault.sol", () => { }); context("initialize", () => { - it("Reverts if the contract is already initialized", async () => { - await vault.initialize(); + it("Should revert if the contract is already initialized", async () => { + await vault.initialize(owner); - await expect(vault.initialize()).to.be.revertedWithCustomError(vault, "NonZeroContractVersionOnInit"); + await expect(vault.initialize(owner)) + .to.be.revertedWithCustomError(vault, "UnexpectedContractVersion") + .withArgs(2, 0); }); it("Initializes the contract", async () => { - await expect(vault.initialize()).to.emit(vault, "ContractVersionSet").withArgs(1); + await expect(vault.initialize(owner)).to.emit(vault, "ContractVersionSet").withArgs(2); + }); + + it("Should revert if admin address is zero", async () => { + await expect(vault.initialize(ZeroAddress)).to.be.revertedWithCustomError(vault, "ZeroAddress"); + }); + + it("Should set admin role during initialization", async () => { + const adminRole = await vault.DEFAULT_ADMIN_ROLE(); + expect(await vault.getRoleMemberCount(adminRole)).to.equal(0); + expect(await vault.hasRole(adminRole, owner)).to.equal(false); + + await vault.initialize(owner); + + expect(await vault.getRoleMemberCount(adminRole)).to.equal(1); + expect(await vault.hasRole(adminRole, owner)).to.equal(true); + expect(await vault.hasRole(adminRole, stranger)).to.equal(false); + }); + }); + + context("finalizeUpgrade_v2()", () => { + it("Should revert with UnexpectedContractVersion error when called on implementation", async () => { + await expect(impl.finalizeUpgrade_v2(owner)) + .to.be.revertedWithCustomError(impl, "UnexpectedContractVersion") + .withArgs(MAX_UINT256, 1); + }); + + it("Should revert with UnexpectedContractVersion error when called on deployed from scratch WithdrawalVaultV2", async () => { + await vault.initialize(owner); + + await expect(vault.finalizeUpgrade_v2(owner)) + .to.be.revertedWithCustomError(impl, "UnexpectedContractVersion") + .withArgs(2, 1); + }); + + context("Simulate upgrade from v1", () => { + beforeEach(async () => { + await vault.harness__initializeContractVersionTo(1); + }); + + it("Should revert if admin address is zero", async () => { + await expect(vault.finalizeUpgrade_v2(ZeroAddress)).to.be.revertedWithCustomError(vault, "ZeroAddress"); + }); + + it("Should set correct contract version", async () => { + expect(await vault.getContractVersion()).to.equal(1); + await vault.finalizeUpgrade_v2(owner); + expect(await vault.getContractVersion()).to.be.equal(2); + }); + + it("Should set admin role during finalization", async () => { + const adminRole = await vault.DEFAULT_ADMIN_ROLE(); + expect(await vault.getRoleMemberCount(adminRole)).to.equal(0); + expect(await vault.hasRole(adminRole, owner)).to.equal(false); + + await vault.finalizeUpgrade_v2(owner); + + expect(await vault.getRoleMemberCount(adminRole)).to.equal(1); + expect(await vault.hasRole(adminRole, owner)).to.equal(true); + expect(await vault.hasRole(adminRole, stranger)).to.equal(false); + }); + }); + }); + + context("Access control", () => { + it("Returns ACL roles", async () => { + expect(await vault.ADD_FULL_WITHDRAWAL_REQUEST_ROLE()).to.equal(ADD_FULL_WITHDRAWAL_REQUEST_ROLE); + }); + + it("Sets up roles", async () => { + await vault.initialize(owner); + + expect(await vault.getRoleMemberCount(ADD_FULL_WITHDRAWAL_REQUEST_ROLE)).to.equal(0); + expect(await vault.hasRole(ADD_FULL_WITHDRAWAL_REQUEST_ROLE, validatorsExitBus)).to.equal(false); + + await vault.connect(owner).grantRole(ADD_FULL_WITHDRAWAL_REQUEST_ROLE, validatorsExitBus); + + expect(await vault.getRoleMemberCount(ADD_FULL_WITHDRAWAL_REQUEST_ROLE)).to.equal(1); + expect(await vault.hasRole(ADD_FULL_WITHDRAWAL_REQUEST_ROLE, validatorsExitBus)).to.equal(true); }); }); context("withdrawWithdrawals", () => { - beforeEach(async () => await vault.initialize()); + beforeEach(async () => await vault.initialize(owner)); it("Reverts if the caller is not Lido", async () => { - await expect(vault.connect(user).withdrawWithdrawals(0)).to.be.revertedWithCustomError(vault, "NotLido"); + await expect(vault.connect(stranger).withdrawWithdrawals(0)).to.be.revertedWithCustomError(vault, "NotLido"); }); it("Reverts if amount is 0", async () => { @@ -168,4 +269,316 @@ describe("WithdrawalVault.sol", () => { expect(await token.ownerOf(1)).to.equal(treasury.address); }); }); + + context("get triggerable withdrawal request fee", () => { + it("Should get fee from the EIP 7002 contract", async function () { + await withdrawalsPredeployed.setFee(333n); + expect( + (await vault.getWithdrawalRequestFee()) == 333n, + "withdrawal request should use fee from the EIP 7002 contract", + ); + }); + + it("Should revert if fee read fails", async function () { + await withdrawalsPredeployed.setFailOnGetFee(true); + await expect(vault.getWithdrawalRequestFee()).to.be.revertedWithCustomError( + vault, + "WithdrawalRequestFeeReadFailed", + ); + }); + }); + + async function getFee(): Promise { + const fee = await vault.getWithdrawalRequestFee(); + + return ethers.parseUnits(fee.toString(), "wei"); + } + + async function getWithdrawalCredentialsContractBalance(): Promise { + const contractAddress = await vault.getAddress(); + return await ethers.provider.getBalance(contractAddress); + } + + async function getWithdrawalsPredeployedContractBalance(): Promise { + const contractAddress = await withdrawalsPredeployed.getAddress(); + return await ethers.provider.getBalance(contractAddress); + } + + context("add triggerable withdrawal requests", () => { + beforeEach(async () => { + await vault.initialize(owner); + await vault.connect(owner).grantRole(ADD_FULL_WITHDRAWAL_REQUEST_ROLE, validatorsExitBus); + }); + + it("Should revert if the caller is not Validator Exit Bus", async () => { + await expect(vault.connect(stranger).addFullWithdrawalRequests("0x1234")).to.be.revertedWithOZAccessControlError( + stranger.address, + ADD_FULL_WITHDRAWAL_REQUEST_ROLE, + ); + }); + + it("Should revert if empty arrays are provided", async function () { + await expect( + vault.connect(validatorsExitBus).addFullWithdrawalRequests("0x", { value: 1n }), + ).to.be.revertedWithCustomError(vault, "NoWithdrawalRequests"); + }); + + it("Should revert if not enough fee is sent", async function () { + const { pubkeysHexString } = generateWithdrawalRequestPayload(1); + + await withdrawalsPredeployed.setFee(3n); // Set fee to 3 gwei + + // 1. Should revert if no fee is sent + await expect(vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString)) + .to.be.revertedWithCustomError(vault, "InsufficientTriggerableWithdrawalFee") + .withArgs(0, 3n, 1); + + // 2. Should revert if fee is less than required + const insufficientFee = 2n; + await expect( + vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: insufficientFee }), + ) + .to.be.revertedWithCustomError(vault, "InsufficientTriggerableWithdrawalFee") + .withArgs(2n, 3n, 1); + }); + + it("Should revert if pubkey is not 48 bytes", async function () { + // Invalid pubkey (only 2 bytes) + const invalidPubkeyHexString = "0x1234"; + + const fee = await getFee(); + + await expect( + vault.connect(validatorsExitBus).addFullWithdrawalRequests(invalidPubkeyHexString, { value: fee }), + ).to.be.revertedWithCustomError(vault, "InvalidPublicKeyLength"); + }); + + it("Should revert if last pubkey not 48 bytes", async function () { + const validPubey = + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f"; + const invalidPubkey = "1234"; + const pubkeysHexString = `0x${validPubey}${invalidPubkey}`; + + const fee = await getFee(); + + await expect( + vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: fee }), + ).to.be.revertedWithCustomError(vault, "InvalidPublicKeyLength"); + }); + + it("Should revert if addition fails at the withdrawal request contract", async function () { + const { pubkeysHexString } = generateWithdrawalRequestPayload(1); + const fee = await getFee(); + + // Set mock to fail on add + await withdrawalsPredeployed.setFailOnAddRequest(true); + + await expect( + vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: fee }), + ).to.be.revertedWithCustomError(vault, "WithdrawalRequestAdditionFailed"); + }); + + it("Should revert when fee read fails", async function () { + await withdrawalsPredeployed.setFailOnGetFee(true); + + const { pubkeysHexString } = generateWithdrawalRequestPayload(2); + const fee = 10n; + + await expect( + vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: fee }), + ).to.be.revertedWithCustomError(vault, "WithdrawalRequestFeeReadFailed"); + }); + + it("Should accept withdrawal requests when the provided fee matches the exact required amount", async function () { + const requestCount = 3; + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); + + const fee = 3n; + await withdrawalsPredeployed.setFee(3n); + const expectedTotalWithdrawalFee = 9n; + + await testEip7002Mock( + () => + vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: expectedTotalWithdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + + // Check extremely high fee + const highFee = ethers.parseEther("10"); + await withdrawalsPredeployed.setFee(highFee); + const expectedLargeTotalWithdrawalFee = ethers.parseEther("30"); + + await testEip7002Mock( + () => + vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: expectedLargeTotalWithdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + highFee, + ); + }); + + it("Should accept withdrawal requests when the provided fee exceeds the required amount", async function () { + const requestCount = 3; + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); + + const fee = 3n; + await withdrawalsPredeployed.setFee(fee); + const withdrawalFee = 9n + 1n; // 3 request * 3 gwei (fee) + 1 gwei (extra fee)= 10 gwei + + await testEip7002Mock( + () => vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: withdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + + // Check when the provided fee extremely exceeds the required amount + const largeWithdrawalFee = ethers.parseEther("10"); + + await testEip7002Mock( + () => + vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: largeWithdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + }); + + it("Should not affect contract balance", async function () { + const requestCount = 3; + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); + + const fee = 3n; + await withdrawalsPredeployed.setFee(fee); + const expectedTotalWithdrawalFee = 9n; // 3 requests * 3 gwei (fee) = 9 gwei + + const initialBalance = await getWithdrawalCredentialsContractBalance(); + + await testEip7002Mock( + () => + vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: expectedTotalWithdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); + + const excessTotalWithdrawalFee = 9n + 1n; // 3 requests * 3 gwei (fee) + 1 gwei (extra fee) = 10 gwei + + await testEip7002Mock( + () => + vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: excessTotalWithdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); + }); + + // ToDo: should return back the excess fee + + it("Should transfer the total calculated fee to the EIP-7002 withdrawal contract", async function () { + const requestCount = 3; + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); + + const fee = 3n; + await withdrawalsPredeployed.setFee(3n); + const expectedTotalWithdrawalFee = 9n; + const excessTotalWithdrawalFee = 9n + 1n; + + let initialBalance = await getWithdrawalsPredeployedContractBalance(); + + await testEip7002Mock( + () => + vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: expectedTotalWithdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + + expect(await getWithdrawalsPredeployedContractBalance()).to.equal(initialBalance + expectedTotalWithdrawalFee); + + initialBalance = await getWithdrawalsPredeployedContractBalance(); + await testEip7002Mock( + () => + vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: excessTotalWithdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + // Only the expected fee should be transferred + expect(await getWithdrawalsPredeployedContractBalance()).to.equal(initialBalance + expectedTotalWithdrawalFee); + }); + + it("Should ensure withdrawal requests are encoded as expected with a 48-byte pubkey and 8-byte amount", async function () { + const requestCount = 16; + const { pubkeysHexString, pubkeys } = generateWithdrawalRequestPayload(requestCount); + const totalWithdrawalFee = 333n; + + const tx = await vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: totalWithdrawalFee }); + + const receipt = await tx.wait(); + + const events = findEip7002MockEvents(receipt!, "eip7002MockRequestAdded"); + expect(events.length).to.equal(requestCount); + + for (let i = 0; i < requestCount; i++) { + const encodedRequest = events[i].args[0]; + // 0x (2 characters) + 48-byte pubkey (96 characters) + 8-byte amount (16 characters) = 114 characters + expect(encodedRequest.length).to.equal(114); + + expect(encodedRequest.slice(0, 2)).to.equal("0x"); + expect(encodedRequest.slice(2, 98)).to.equal(pubkeys[i]); + expect(encodedRequest.slice(98, 114)).to.equal("0".repeat(16)); // Amount is 0 + } + }); + + const testCasesForWithdrawalRequests = [ + { requestCount: 1, extraFee: 0n }, + { requestCount: 1, extraFee: 100n }, + { requestCount: 1, extraFee: 100_000_000_000n }, + { requestCount: 3, extraFee: 0n }, + { requestCount: 3, extraFee: 1n }, + { requestCount: 7, extraFee: 3n }, + { requestCount: 10, extraFee: 0n }, + { requestCount: 10, extraFee: 100_000_000_000n }, + { requestCount: 100, extraFee: 0n }, + ]; + + testCasesForWithdrawalRequests.forEach(({ requestCount, extraFee }) => { + it(`Should successfully add ${requestCount} requests with extra fee ${extraFee}`, async () => { + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); + const expectedFee = await getFee(); + const withdrawalFee = expectedFee * BigInt(requestCount) + extraFee; + + const initialBalance = await getWithdrawalCredentialsContractBalance(); + + await testEip7002Mock( + () => vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: withdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + expectedFee, + ); + + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); + }); + }); + }); });