From 9b454a831193fdd3662f2ea1dcb5da18e7da58d0 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Thu, 19 Dec 2024 19:45:01 +0100 Subject: [PATCH 01/21] feat: add withdrawal credentials lib --- contracts/0.8.9/WithdrawalVault.sol | 49 +++- .../IWithdrawalCredentialsRequests.sol | 11 + .../lib/WithdrawalCredentialsRequests.sol | 72 ++++++ .../WithdrawalCredentials_Harness.sol | 16 ++ .../WithdrawalsPredeployed_Mock.sol | 46 ++++ .../withdrawalCredentials.test.ts | 36 +++ .../withdrawalRequests.behaviour.ts | 217 ++++++++++++++++++ test/0.8.9/withdrawalVault.test.ts | 60 ++++- 8 files changed, 486 insertions(+), 21 deletions(-) create mode 100644 contracts/0.8.9/interfaces/IWithdrawalCredentialsRequests.sol create mode 100644 contracts/0.8.9/lib/WithdrawalCredentialsRequests.sol create mode 100644 test/0.8.9/contracts/WithdrawalCredentials_Harness.sol create mode 100644 test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol create mode 100644 test/0.8.9/lib/withdrawalCredentials/withdrawalCredentials.test.ts create mode 100644 test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behaviour.ts diff --git a/contracts/0.8.9/WithdrawalVault.sol b/contracts/0.8.9/WithdrawalVault.sol index c5485b785..2ba6867ba 100644 --- a/contracts/0.8.9/WithdrawalVault.sol +++ b/contracts/0.8.9/WithdrawalVault.sol @@ -9,6 +9,8 @@ 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 {IWithdrawalCredentialsRequests} from "./interfaces/IWithdrawalCredentialsRequests.sol"; +import {WithdrawalCredentialsRequests} from "./lib/WithdrawalCredentialsRequests.sol"; interface ILido { /** @@ -22,11 +24,13 @@ interface ILido { /** * @title A vault for temporary storage of withdrawals */ -contract WithdrawalVault is Versioned { +contract WithdrawalVault is Versioned, IWithdrawalCredentialsRequests { using SafeERC20 for IERC20; + using WithdrawalCredentialsRequests for *; ILido public immutable LIDO; address public immutable TREASURY; + address public immutable VALIDATORS_EXIT_BUS; // Events /** @@ -42,9 +46,9 @@ contract WithdrawalVault is Versioned { event ERC721Recovered(address indexed requestedBy, address indexed token, uint256 tokenId); // Errors - error LidoZeroAddress(); - error TreasuryZeroAddress(); + error ZeroAddress(); error NotLido(); + error NotValidatorExitBus(); error NotEnoughEther(uint256 requested, uint256 balance); error ZeroAmount(); @@ -52,16 +56,14 @@ contract WithdrawalVault is Versioned { * @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, address _validatorsExitBus) { + _assertNonZero(_lido); + _assertNonZero(_treasury); + _assertNonZero(_validatorsExitBus); - LIDO = _lido; + LIDO = ILido(_lido); TREASURY = _treasury; + VALIDATORS_EXIT_BUS = _validatorsExitBus; } /** @@ -70,6 +72,12 @@ contract WithdrawalVault is Versioned { */ function initialize() external { _initializeContractVersionTo(1); + _updateContractVersion(2); + } + + function finalizeUpgrade_v2() external { + _checkContractVersion(1); + _updateContractVersion(2); } /** @@ -122,4 +130,23 @@ contract WithdrawalVault is Versioned { _token.transferFrom(address(this), TREASURY, _tokenId); } + + function addWithdrawalRequests( + bytes[] calldata pubkeys, + uint64[] calldata amounts + ) external payable { + if(msg.sender != address(VALIDATORS_EXIT_BUS)) { + revert NotValidatorExitBus(); + } + + WithdrawalCredentialsRequests.addWithdrawalRequests(pubkeys, amounts); + } + + function getWithdrawalRequestFee() external view returns (uint256) { + return WithdrawalCredentialsRequests.getWithdrawalRequestFee(); + } + + function _assertNonZero(address _address) internal pure { + if (_address == address(0)) revert ZeroAddress(); + } } diff --git a/contracts/0.8.9/interfaces/IWithdrawalCredentialsRequests.sol b/contracts/0.8.9/interfaces/IWithdrawalCredentialsRequests.sol new file mode 100644 index 000000000..130af0e9c --- /dev/null +++ b/contracts/0.8.9/interfaces/IWithdrawalCredentialsRequests.sol @@ -0,0 +1,11 @@ +interface IWithdrawalCredentialsRequests { + function addWithdrawalRequests( + bytes[] calldata pubkeys, + uint64[] calldata amounts + ) external payable; + + // function addConsolidationRequests( + // bytes[] calldata sourcePubkeys, + // bytes[] calldata targetPubkeys + // ) external payable; +} diff --git a/contracts/0.8.9/lib/WithdrawalCredentialsRequests.sol b/contracts/0.8.9/lib/WithdrawalCredentialsRequests.sol new file mode 100644 index 000000000..502ffa766 --- /dev/null +++ b/contracts/0.8.9/lib/WithdrawalCredentialsRequests.sol @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2023 Lido + +pragma solidity 0.8.9; + +library WithdrawalCredentialsRequests { + address constant WITHDRAWAL_REQUEST = 0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA; + + error InvalidArrayLengths(uint256 lengthA, uint256 lengthB); + error FeeNotEnough(uint256 minFeePerRequest, uint256 requestCount, uint256 msgValue); + error WithdrawalRequestFeeReadFailed(); + + error InvalidPubkeyLength(bytes pubkey); + error WithdrawalRequestAdditionFailed(bytes pubkey, uint256 amount); + + event WithdrawalRequestAdded(bytes pubkey, uint256 amount); + + function addWithdrawalRequests( + bytes[] calldata pubkeys, + uint64[] calldata amounts + ) internal { + uint256 keysCount = pubkeys.length; + if (keysCount != amounts.length || keysCount == 0) { + revert InvalidArrayLengths(keysCount, amounts.length); + } + + uint256 minFeePerRequest = getWithdrawalRequestFee(); + if (minFeePerRequest * keysCount > msg.value) { + revert FeeNotEnough(minFeePerRequest, keysCount, msg.value); + } + + uint256 feePerRequest = msg.value / keysCount; + uint256 unallocatedFee = msg.value % keysCount; + uint256 prevBalance = address(this).balance - msg.value; + + + for (uint256 i = 0; i < keysCount; ++i) { + bytes memory pubkey = pubkeys[i]; + uint64 amount = amounts[i]; + + if(pubkey.length != 48) { + revert InvalidPubkeyLength(pubkey); + } + + uint256 feeToSend = feePerRequest; + + if (i == keysCount - 1) { + feeToSend += unallocatedFee; + } + + bytes memory callData = abi.encodePacked(pubkey, amount); + (bool success, ) = WITHDRAWAL_REQUEST.call{value: feeToSend}(callData); + + if (!success) { + revert WithdrawalRequestAdditionFailed(pubkey, amount); + } + + emit WithdrawalRequestAdded(pubkey, amount); + } + + assert(address(this).balance == prevBalance); + } + + function getWithdrawalRequestFee() internal view returns (uint256) { + (bool success, bytes memory feeData) = WITHDRAWAL_REQUEST.staticcall(""); + + if (!success) { + revert WithdrawalRequestFeeReadFailed(); + } + + return abi.decode(feeData, (uint256)); + } +} diff --git a/test/0.8.9/contracts/WithdrawalCredentials_Harness.sol b/test/0.8.9/contracts/WithdrawalCredentials_Harness.sol new file mode 100644 index 000000000..8bd8450f4 --- /dev/null +++ b/test/0.8.9/contracts/WithdrawalCredentials_Harness.sol @@ -0,0 +1,16 @@ +pragma solidity 0.8.9; + +import {WithdrawalCredentialsRequests} from "contracts/0.8.9/lib/WithdrawalCredentialsRequests.sol"; + +contract WithdrawalCredentials_Harness { + function addWithdrawalRequests( + bytes[] calldata pubkeys, + uint64[] calldata amounts + ) external payable { + WithdrawalCredentialsRequests.addWithdrawalRequests(pubkeys, amounts); + } + + function getWithdrawalRequestFee() external view returns (uint256) { + return WithdrawalCredentialsRequests.getWithdrawalRequestFee(); + } +} 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..9db24d034 --- /dev/null +++ b/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.9; + +contract WithdrawalsPredeployed_Mock { + event WithdrawalRequestedMetadata( + uint256 dataLength + ); + event WithdrawalRequested( + bytes pubKey, + uint64 amount, + uint256 feePaid, + address sender + ); + + uint256 public fee; + bool public failOnAddRequest; + bool public failOnGetFee; + + 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"); + + uint256 currentFee = fee; + output = new bytes(32); + assembly { mstore(add(output, 32), currentFee) } + return output; + } + + require(!failOnAddRequest, "fail on add request"); + + require(input.length == 56, "Invalid callData length"); + } +} diff --git a/test/0.8.9/lib/withdrawalCredentials/withdrawalCredentials.test.ts b/test/0.8.9/lib/withdrawalCredentials/withdrawalCredentials.test.ts new file mode 100644 index 000000000..753cee30f --- /dev/null +++ b/test/0.8.9/lib/withdrawalCredentials/withdrawalCredentials.test.ts @@ -0,0 +1,36 @@ +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { WithdrawalCredentials_Harness, WithdrawalsPredeployed_Mock } from "typechain-types"; + +import { Snapshot } from "test/suite"; + +import { deployWithdrawalsPredeployedMock, tesWithdrawalRequestsBehavior } from "./withdrawalRequests.behaviour"; + +describe("WithdrawalCredentials.sol", () => { + let actor: HardhatEthersSigner; + + let withdrawalsPredeployed: WithdrawalsPredeployed_Mock; + let withdrawalCredentials: WithdrawalCredentials_Harness; + + let originalState: string; + + const getWithdrawalCredentialsContract = () => withdrawalCredentials.connect(actor); + const getWithdrawalsPredeployedContract = () => withdrawalsPredeployed.connect(actor); + + before(async () => { + [actor] = await ethers.getSigners(); + + withdrawalsPredeployed = await deployWithdrawalsPredeployedMock(); + withdrawalCredentials = await ethers.deployContract("WithdrawalCredentials_Harness"); + }); + + beforeEach(async () => (originalState = await Snapshot.take())); + + afterEach(async () => await Snapshot.restore(originalState)); + + context("max", () => { + tesWithdrawalRequestsBehavior(getWithdrawalCredentialsContract, getWithdrawalsPredeployedContract); + }); +}); diff --git a/test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behaviour.ts b/test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behaviour.ts new file mode 100644 index 000000000..34ff98873 --- /dev/null +++ b/test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behaviour.ts @@ -0,0 +1,217 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { WithdrawalCredentials_Harness, WithdrawalsPredeployed_Mock } from "typechain-types"; + +import { findEventsWithInterfaces } from "lib"; + +const withdrawalRequestEventABI = ["event WithdrawalRequestAdded(bytes pubkey, uint256 amount)"]; +const withdrawalRequestEventInterface = new ethers.Interface(withdrawalRequestEventABI); + +const withdrawalsPredeployedHardcodedAddress = "0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA"; + +export async function deployWithdrawalsPredeployedMock(): 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(1n); + 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 `0x${num.toString(16).padStart(4, "0").repeat(24)}`; +} + +const convertEthToGwei = (ethAmount: string | number): bigint => { + const ethString = ethAmount.toString(); + const wei = ethers.parseEther(ethString); + return wei / 1_000_000_000n; +}; + +function generateWithdrawalRequestPayload(numberOfRequests: number) { + const pubkeys: string[] = []; + const amounts: bigint[] = []; + for (let i = 1; i <= numberOfRequests; i++) { + pubkeys.push(toValidatorPubKey(i)); + amounts.push(convertEthToGwei(i)); + } + + return { pubkeys, amounts }; +} + +export function tesWithdrawalRequestsBehavior( + getContract: () => WithdrawalCredentials_Harness, + getWithdrawalsPredeployedContract: () => WithdrawalsPredeployed_Mock, +) { + async function getFee(requestsCount: number): Promise { + const fee = await getContract().getWithdrawalRequestFee(); + + return ethers.parseUnits((fee * BigInt(requestsCount)).toString(), "wei"); + } + + async function getWithdrawalCredentialsContractBalance(): Promise { + const contract = getContract(); + const contractAddress = await contract.getAddress(); + return await ethers.provider.getBalance(contractAddress); + } + + async function addWithdrawalRequests(requestCount: number, extraFee: bigint = 0n) { + const contract = getContract(); + const initialBalance = await getWithdrawalCredentialsContractBalance(); + + const { pubkeys, amounts } = generateWithdrawalRequestPayload(requestCount); + + const fee = (await getFee(pubkeys.length)) + extraFee; + const tx = await contract.addWithdrawalRequests(pubkeys, amounts, { value: fee }); + + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); + + const receipt = await tx.wait(); + + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); + + const events = findEventsWithInterfaces(receipt!, "WithdrawalRequestAdded", [withdrawalRequestEventInterface]); + expect(events.length).to.equal(requestCount); + + for (let i = 0; i < requestCount; i++) { + expect(events[i].args[0]).to.equal(pubkeys[i]); + expect(events[i].args[1]).to.equal(amounts[i]); + } + } + + context("addWithdrawalRequests", async () => { + it("Should revert if array lengths do not match or empty arrays are provided", async function () { + const { pubkeys, amounts } = generateWithdrawalRequestPayload(2); + amounts.pop(); + + expect( + pubkeys.length !== amounts.length, + "Test setup error: pubkeys and amounts arrays should have different lengths.", + ); + + const contract = getContract(); + + const fee = await getFee(pubkeys.length); + await expect(contract.addWithdrawalRequests(pubkeys, amounts, { value: fee })) + .to.be.revertedWithCustomError(contract, "InvalidArrayLengths") + .withArgs(pubkeys.length, amounts.length); + + // Also test empty arrays + await expect(contract.addWithdrawalRequests([], [], { value: fee })) + .to.be.revertedWithCustomError(contract, "InvalidArrayLengths") + .withArgs(0, 0); + }); + + it("Should revert if not enough fee is sent", async function () { + const { pubkeys, amounts } = generateWithdrawalRequestPayload(1); + const contract = getContract(); + + await getWithdrawalsPredeployedContract().setFee(3n); // Set fee to 3 gwei + + // Should revert if no fee is sent + await expect(contract.addWithdrawalRequests(pubkeys, amounts)).to.be.revertedWithCustomError( + contract, + "FeeNotEnough", + ); + + // Should revert if fee is less than required + const insufficientFee = 2n; + await expect( + contract.addWithdrawalRequests(pubkeys, amounts, { value: insufficientFee }), + ).to.be.revertedWithCustomError(contract, "FeeNotEnough"); + }); + + it("Should revert if any pubkey is not 48 bytes", async function () { + // Invalid pubkey (only 2 bytes) + const pubkeys = ["0x1234"]; + const amounts = [100n]; + + const fee = await getFee(pubkeys.length); + const contract = getContract(); + await expect(contract.addWithdrawalRequests(pubkeys, amounts, { value: fee })) + .to.be.revertedWithCustomError(contract, "InvalidPubkeyLength") + .withArgs(pubkeys[0]); + }); + + it("Should revert if addition fails at the withdrawal request contract", async function () { + const { pubkeys, amounts } = generateWithdrawalRequestPayload(1); + const fee = await getFee(pubkeys.length); + + // Set mock to fail on add + await getWithdrawalsPredeployedContract().setFailOnAddRequest(true); + const contract = getContract(); + + await expect(contract.addWithdrawalRequests(pubkeys, amounts, { value: fee })).to.be.revertedWithCustomError( + contract, + "WithdrawalRequestAdditionFailed", + ); + }); + + it("Should accept full and partial withdrawals", async function () { + const { pubkeys, amounts } = generateWithdrawalRequestPayload(2); + amounts[0] = 0n; // Full withdrawal + amounts[1] = 1n; // Partial withdrawal + + const fee = await getFee(pubkeys.length); + const contract = getContract(); + + await contract.addWithdrawalRequests(pubkeys, amounts, { value: fee }); + }); + + it("Should accept exactly required fee without revert", async function () { + const requestCount = 1; + const { pubkeys, amounts } = generateWithdrawalRequestPayload(requestCount); + + const contract = getContract(); + const initialBalance = await getWithdrawalCredentialsContractBalance(); + + await getWithdrawalsPredeployedContract().setFee(3n); + expect((await contract.getWithdrawalRequestFee()) == 3n, "Test setup error: invalid withdrawal request fee."); + const fee = 3n; + + await contract.addWithdrawalRequests(pubkeys, amounts, { value: fee }); + + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); + }); + + it("Should accept exceed fee without revert", async function () { + const requestCount = 1; + const { pubkeys, amounts } = generateWithdrawalRequestPayload(requestCount); + + const contract = getContract(); + const initialBalance = await getWithdrawalCredentialsContractBalance(); + + await getWithdrawalsPredeployedContract().setFee(3n); + expect((await contract.getWithdrawalRequestFee()) == 3n, "Test setup error: invalid withdrawal request fee."); + const fee = 3n + 1n; // 1 request * 3 gwei (fee) + 1 gwei (extra fee)= 4 gwei + + await contract.addWithdrawalRequests(pubkeys, amounts, { value: fee }); + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); + }); + + it("Should successfully add requests and emit events", async function () { + await addWithdrawalRequests(1); + await addWithdrawalRequests(3); + await addWithdrawalRequests(10); + await addWithdrawalRequests(100); + }); + + it("Should successfully add requests with extra fee and not change contract balance", async function () { + await addWithdrawalRequests(1, 100n); + await addWithdrawalRequests(3, 1n); + await addWithdrawalRequests(10, 1_000_000n); + await addWithdrawalRequests(7, 3n); + await addWithdrawalRequests(100, 0n); + }); + }); +} diff --git a/test/0.8.9/withdrawalVault.test.ts b/test/0.8.9/withdrawalVault.test.ts index c953f23d7..9f1d80aa4 100644 --- a/test/0.8.9/withdrawalVault.test.ts +++ b/test/0.8.9/withdrawalVault.test.ts @@ -5,35 +5,54 @@ 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, +} from "typechain-types"; import { MAX_UINT256, proxify } from "lib"; import { Snapshot } from "test/suite"; +import { + deployWithdrawalsPredeployedMock, + tesWithdrawalRequestsBehavior, +} from "./lib/withdrawalCredentials/withdrawalRequests.behaviour"; + const PETRIFIED_VERSION = MAX_UINT256; describe("WithdrawalVault.sol", () => { let owner: HardhatEthersSigner; let user: HardhatEthersSigner; let treasury: HardhatEthersSigner; + let validatorsExitBus: HardhatEthersSigner; let originalState: string; let lido: Lido__MockForWithdrawalVault; let lidoAddress: string; + let withdrawalsPredeployed: WithdrawalsPredeployed_Mock; + let impl: WithdrawalVault; let vault: WithdrawalVault; let vaultAddress: string; + const getWithdrawalCredentialsContract = () => vault.connect(validatorsExitBus); + const getWithdrawalsPredeployedContract = () => withdrawalsPredeployed.connect(user); + before(async () => { - [owner, user, treasury] = await ethers.getSigners(); + [owner, user, treasury, validatorsExitBus] = await ethers.getSigners(); + + withdrawalsPredeployed = await deployWithdrawalsPredeployedMock(); lido = await ethers.deployContract("Lido__MockForWithdrawalVault"); lidoAddress = await lido.getAddress(); - impl = await ethers.deployContract("WithdrawalVault", [lidoAddress, treasury.address]); + impl = await ethers.deployContract("WithdrawalVault", [lidoAddress, treasury.address, validatorsExitBus.address]); [vault] = await proxify({ impl, admin: owner }); @@ -47,20 +66,26 @@ describe("WithdrawalVault.sol", () => { context("Constructor", () => { it("Reverts if the Lido address is zero", async () => { await expect( - ethers.deployContract("WithdrawalVault", [ZeroAddress, treasury.address]), - ).to.be.revertedWithCustomError(vault, "LidoZeroAddress"); + ethers.deployContract("WithdrawalVault", [ZeroAddress, treasury.address, validatorsExitBus.address]), + ).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", - ); + await expect( + ethers.deployContract("WithdrawalVault", [lidoAddress, ZeroAddress, validatorsExitBus.address]), + ).to.be.revertedWithCustomError(vault, "ZeroAddress"); + }); + + it("Reverts if the validator exit buss address is zero", async () => { + await expect( + ethers.deployContract("WithdrawalVault", [lidoAddress, treasury.address, ZeroAddress]), + ).to.be.revertedWithCustomError(vault, "ZeroAddress"); }); it("Sets initial properties", async () => { expect(await vault.LIDO()).to.equal(lidoAddress, "Lido address"); expect(await vault.TREASURY()).to.equal(treasury.address, "Treasury address"); + expect(await vault.VALIDATORS_EXIT_BUS()).to.equal(validatorsExitBus.address, "Validator exit bus address"); }); it("Petrifies the implementation", async () => { @@ -80,7 +105,11 @@ describe("WithdrawalVault.sol", () => { }); it("Initializes the contract", async () => { - await expect(vault.initialize()).to.emit(vault, "ContractVersionSet").withArgs(1); + await expect(vault.initialize()) + .to.emit(vault, "ContractVersionSet") + .withArgs(1) + .and.to.emit(vault, "ContractVersionSet") + .withArgs(2); }); }); @@ -168,4 +197,15 @@ describe("WithdrawalVault.sol", () => { expect(await token.ownerOf(1)).to.equal(treasury.address); }); }); + + context("addWithdrawalRequests", () => { + it("Reverts if the caller is not Validator Exit Bus", async () => { + await expect(vault.connect(user).addWithdrawalRequests(["0x1234"], [0n])).to.be.revertedWithCustomError( + vault, + "NotValidatorExitBus", + ); + }); + + tesWithdrawalRequestsBehavior(getWithdrawalCredentialsContract, getWithdrawalsPredeployedContract); + }); }); From 3bfe5ac02882cbb192aa12c92233a3e5038edca9 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Thu, 19 Dec 2024 19:45:24 +0100 Subject: [PATCH 02/21] feat: split full and partial withdrawals --- contracts/0.8.9/WithdrawalVault.sol | 20 +- .../IWithdrawalCredentialsRequests.sol | 11 - .../lib/WithdrawalCredentialsRequests.sol | 72 ---- contracts/0.8.9/lib/WithdrawalRequests.sol | 122 ++++++ .../WithdrawalCredentials_Harness.sol | 14 +- .../WithdrawalsPredeployed_Mock.sol | 17 +- .../withdrawalCredentials.test.ts | 21 +- .../withdrawalRequests.behavior.ts | 350 ++++++++++++++++++ .../withdrawalRequests.behaviour.ts | 217 ----------- test/0.8.9/withdrawalVault.test.ts | 14 +- 10 files changed, 518 insertions(+), 340 deletions(-) delete mode 100644 contracts/0.8.9/interfaces/IWithdrawalCredentialsRequests.sol delete mode 100644 contracts/0.8.9/lib/WithdrawalCredentialsRequests.sol create mode 100644 contracts/0.8.9/lib/WithdrawalRequests.sol create mode 100644 test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behavior.ts delete mode 100644 test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behaviour.ts diff --git a/contracts/0.8.9/WithdrawalVault.sol b/contracts/0.8.9/WithdrawalVault.sol index 2ba6867ba..bc6d87e76 100644 --- a/contracts/0.8.9/WithdrawalVault.sol +++ b/contracts/0.8.9/WithdrawalVault.sol @@ -9,8 +9,7 @@ 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 {IWithdrawalCredentialsRequests} from "./interfaces/IWithdrawalCredentialsRequests.sol"; -import {WithdrawalCredentialsRequests} from "./lib/WithdrawalCredentialsRequests.sol"; +import {WithdrawalRequests} from "./lib/WithdrawalRequests.sol"; interface ILido { /** @@ -24,9 +23,8 @@ interface ILido { /** * @title A vault for temporary storage of withdrawals */ -contract WithdrawalVault is Versioned, IWithdrawalCredentialsRequests { +contract WithdrawalVault is Versioned { using SafeERC20 for IERC20; - using WithdrawalCredentialsRequests for *; ILido public immutable LIDO; address public immutable TREASURY; @@ -131,19 +129,23 @@ contract WithdrawalVault is Versioned, IWithdrawalCredentialsRequests { _token.transferFrom(address(this), TREASURY, _tokenId); } - function addWithdrawalRequests( - bytes[] calldata pubkeys, - uint64[] calldata amounts + /** + * @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 { if(msg.sender != address(VALIDATORS_EXIT_BUS)) { revert NotValidatorExitBus(); } - WithdrawalCredentialsRequests.addWithdrawalRequests(pubkeys, amounts); + WithdrawalRequests.addFullWithdrawalRequests(pubkeys); } function getWithdrawalRequestFee() external view returns (uint256) { - return WithdrawalCredentialsRequests.getWithdrawalRequestFee(); + return WithdrawalRequests.getWithdrawalRequestFee(); } function _assertNonZero(address _address) internal pure { diff --git a/contracts/0.8.9/interfaces/IWithdrawalCredentialsRequests.sol b/contracts/0.8.9/interfaces/IWithdrawalCredentialsRequests.sol deleted file mode 100644 index 130af0e9c..000000000 --- a/contracts/0.8.9/interfaces/IWithdrawalCredentialsRequests.sol +++ /dev/null @@ -1,11 +0,0 @@ -interface IWithdrawalCredentialsRequests { - function addWithdrawalRequests( - bytes[] calldata pubkeys, - uint64[] calldata amounts - ) external payable; - - // function addConsolidationRequests( - // bytes[] calldata sourcePubkeys, - // bytes[] calldata targetPubkeys - // ) external payable; -} diff --git a/contracts/0.8.9/lib/WithdrawalCredentialsRequests.sol b/contracts/0.8.9/lib/WithdrawalCredentialsRequests.sol deleted file mode 100644 index 502ffa766..000000000 --- a/contracts/0.8.9/lib/WithdrawalCredentialsRequests.sol +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Lido - -pragma solidity 0.8.9; - -library WithdrawalCredentialsRequests { - address constant WITHDRAWAL_REQUEST = 0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA; - - error InvalidArrayLengths(uint256 lengthA, uint256 lengthB); - error FeeNotEnough(uint256 minFeePerRequest, uint256 requestCount, uint256 msgValue); - error WithdrawalRequestFeeReadFailed(); - - error InvalidPubkeyLength(bytes pubkey); - error WithdrawalRequestAdditionFailed(bytes pubkey, uint256 amount); - - event WithdrawalRequestAdded(bytes pubkey, uint256 amount); - - function addWithdrawalRequests( - bytes[] calldata pubkeys, - uint64[] calldata amounts - ) internal { - uint256 keysCount = pubkeys.length; - if (keysCount != amounts.length || keysCount == 0) { - revert InvalidArrayLengths(keysCount, amounts.length); - } - - uint256 minFeePerRequest = getWithdrawalRequestFee(); - if (minFeePerRequest * keysCount > msg.value) { - revert FeeNotEnough(minFeePerRequest, keysCount, msg.value); - } - - uint256 feePerRequest = msg.value / keysCount; - uint256 unallocatedFee = msg.value % keysCount; - uint256 prevBalance = address(this).balance - msg.value; - - - for (uint256 i = 0; i < keysCount; ++i) { - bytes memory pubkey = pubkeys[i]; - uint64 amount = amounts[i]; - - if(pubkey.length != 48) { - revert InvalidPubkeyLength(pubkey); - } - - uint256 feeToSend = feePerRequest; - - if (i == keysCount - 1) { - feeToSend += unallocatedFee; - } - - bytes memory callData = abi.encodePacked(pubkey, amount); - (bool success, ) = WITHDRAWAL_REQUEST.call{value: feeToSend}(callData); - - if (!success) { - revert WithdrawalRequestAdditionFailed(pubkey, amount); - } - - emit WithdrawalRequestAdded(pubkey, amount); - } - - assert(address(this).balance == prevBalance); - } - - function getWithdrawalRequestFee() internal view returns (uint256) { - (bool success, bytes memory feeData) = WITHDRAWAL_REQUEST.staticcall(""); - - if (!success) { - revert WithdrawalRequestFeeReadFailed(); - } - - return abi.decode(feeData, (uint256)); - } -} diff --git a/contracts/0.8.9/lib/WithdrawalRequests.sol b/contracts/0.8.9/lib/WithdrawalRequests.sol new file mode 100644 index 000000000..7973f118d --- /dev/null +++ b/contracts/0.8.9/lib/WithdrawalRequests.sol @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.9; + +library WithdrawalRequests { + address constant WITHDRAWAL_REQUEST = 0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA; + + error MismatchedArrayLengths(uint256 keysCount, uint256 amountsCount); + error FeeNotEnough(uint256 minFeePerRequest, uint256 requestCount, uint256 msgValue); + + error WithdrawalRequestFeeReadFailed(); + error InvalidPubkeyLength(bytes pubkey); + error WithdrawalRequestAdditionFailed(bytes pubkey, uint256 amount); + error NoWithdrawalRequests(); + error PartialWithdrawalRequired(bytes pubkey); + + event WithdrawalRequestAdded(bytes pubkey, uint256 amount); + + /** + * @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 + ) internal { + uint256 keysCount = pubkeys.length; + uint64[] memory amounts = new uint64[](keysCount); + + _addWithdrawalRequests(pubkeys, amounts); + } + + /** + * @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. + * 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 + ) internal { + uint256 keysCount = pubkeys.length; + if (keysCount != amounts.length) { + revert MismatchedArrayLengths(keysCount, amounts.length); + } + + uint64[] memory _amounts = new uint64[](keysCount); + for (uint256 i = 0; i < keysCount; i++) { + if (amounts[i] == 0) { + revert PartialWithdrawalRequired(pubkeys[i]); + } + + _amounts[i] = amounts[i]; + } + + _addWithdrawalRequests(pubkeys, _amounts); + } + + /** + * @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 _addWithdrawalRequests( + bytes[] calldata pubkeys, + uint64[] memory amounts + ) internal { + uint256 keysCount = pubkeys.length; + if (keysCount == 0) { + revert NoWithdrawalRequests(); + } + + uint256 minFeePerRequest = getWithdrawalRequestFee(); + if (minFeePerRequest * keysCount > msg.value) { + revert FeeNotEnough(minFeePerRequest, keysCount, msg.value); + } + + uint256 feePerRequest = msg.value / keysCount; + uint256 unallocatedFee = msg.value % keysCount; + uint256 prevBalance = address(this).balance - msg.value; + + + for (uint256 i = 0; i < keysCount; ++i) { + bytes memory pubkey = pubkeys[i]; + uint64 amount = amounts[i]; + + if(pubkey.length != 48) { + revert InvalidPubkeyLength(pubkey); + } + + uint256 feeToSend = feePerRequest; + + if (i == keysCount - 1) { + feeToSend += unallocatedFee; + } + + bytes memory callData = abi.encodePacked(pubkey, amount); + (bool success, ) = WITHDRAWAL_REQUEST.call{value: feeToSend}(callData); + + if (!success) { + revert WithdrawalRequestAdditionFailed(pubkey, amount); + } + + emit WithdrawalRequestAdded(pubkey, amount); + } + + assert(address(this).balance == prevBalance); + } +} diff --git a/test/0.8.9/contracts/WithdrawalCredentials_Harness.sol b/test/0.8.9/contracts/WithdrawalCredentials_Harness.sol index 8bd8450f4..1450f79e9 100644 --- a/test/0.8.9/contracts/WithdrawalCredentials_Harness.sol +++ b/test/0.8.9/contracts/WithdrawalCredentials_Harness.sol @@ -1,16 +1,22 @@ pragma solidity 0.8.9; -import {WithdrawalCredentialsRequests} from "contracts/0.8.9/lib/WithdrawalCredentialsRequests.sol"; +import {WithdrawalRequests} from "contracts/0.8.9/lib/WithdrawalRequests.sol"; contract WithdrawalCredentials_Harness { - function addWithdrawalRequests( + function addFullWithdrawalRequests( + bytes[] calldata pubkeys + ) external payable { + WithdrawalRequests.addFullWithdrawalRequests(pubkeys); + } + + function addPartialWithdrawalRequests( bytes[] calldata pubkeys, uint64[] calldata amounts ) external payable { - WithdrawalCredentialsRequests.addWithdrawalRequests(pubkeys, amounts); + WithdrawalRequests.addPartialWithdrawalRequests(pubkeys, amounts); } function getWithdrawalRequestFee() external view returns (uint256) { - return WithdrawalCredentialsRequests.getWithdrawalRequestFee(); + return WithdrawalRequests.getWithdrawalRequestFee(); } } diff --git a/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol b/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol index 9db24d034..6c50f7d6a 100644 --- a/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol +++ b/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol @@ -1,17 +1,10 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.9; +/** + * @notice This is an mock of EIP-7002's pre-deploy contract. + */ contract WithdrawalsPredeployed_Mock { - event WithdrawalRequestedMetadata( - uint256 dataLength - ); - event WithdrawalRequested( - bytes pubKey, - uint64 amount, - uint256 feePaid, - address sender - ); - uint256 public fee; bool public failOnAddRequest; bool public failOnGetFee; @@ -33,9 +26,7 @@ contract WithdrawalsPredeployed_Mock { if (input.length == 0) { require(!failOnGetFee, "fail on get fee"); - uint256 currentFee = fee; - output = new bytes(32); - assembly { mstore(add(output, 32), currentFee) } + output = abi.encode(fee); return output; } diff --git a/test/0.8.9/lib/withdrawalCredentials/withdrawalCredentials.test.ts b/test/0.8.9/lib/withdrawalCredentials/withdrawalCredentials.test.ts index 753cee30f..744519a3f 100644 --- a/test/0.8.9/lib/withdrawalCredentials/withdrawalCredentials.test.ts +++ b/test/0.8.9/lib/withdrawalCredentials/withdrawalCredentials.test.ts @@ -6,7 +6,11 @@ import { WithdrawalCredentials_Harness, WithdrawalsPredeployed_Mock } from "type import { Snapshot } from "test/suite"; -import { deployWithdrawalsPredeployedMock, tesWithdrawalRequestsBehavior } from "./withdrawalRequests.behaviour"; +import { + deployWithdrawalsPredeployedMock, + testFullWithdrawalRequestBehavior, + testPartialWithdrawalRequestBehavior, +} from "./withdrawalRequests.behavior"; describe("WithdrawalCredentials.sol", () => { let actor: HardhatEthersSigner; @@ -16,9 +20,6 @@ describe("WithdrawalCredentials.sol", () => { let originalState: string; - const getWithdrawalCredentialsContract = () => withdrawalCredentials.connect(actor); - const getWithdrawalsPredeployedContract = () => withdrawalsPredeployed.connect(actor); - before(async () => { [actor] = await ethers.getSigners(); @@ -30,7 +31,13 @@ describe("WithdrawalCredentials.sol", () => { afterEach(async () => await Snapshot.restore(originalState)); - context("max", () => { - tesWithdrawalRequestsBehavior(getWithdrawalCredentialsContract, getWithdrawalsPredeployedContract); - }); + testFullWithdrawalRequestBehavior( + () => withdrawalCredentials.connect(actor), + () => withdrawalsPredeployed.connect(actor), + ); + + testPartialWithdrawalRequestBehavior( + () => withdrawalCredentials.connect(actor), + () => withdrawalsPredeployed.connect(actor), + ); }); diff --git a/test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behavior.ts b/test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behavior.ts new file mode 100644 index 000000000..7eeafea9f --- /dev/null +++ b/test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behavior.ts @@ -0,0 +1,350 @@ +import { expect } from "chai"; +import { BaseContract } from "ethers"; +import { ethers } from "hardhat"; + +import { WithdrawalCredentials_Harness, WithdrawalsPredeployed_Mock } from "typechain-types"; + +import { findEventsWithInterfaces } from "lib"; + +const withdrawalRequestEventABI = ["event WithdrawalRequestAdded(bytes pubkey, uint256 amount)"]; +const withdrawalRequestEventInterface = new ethers.Interface(withdrawalRequestEventABI); + +const withdrawalsPredeployedHardcodedAddress = "0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA"; + +export async function deployWithdrawalsPredeployedMock(): 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(1n); + 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 `0x${num.toString(16).padStart(4, "0").repeat(24)}`; +} + +const convertEthToGwei = (ethAmount: string | number): bigint => { + const ethString = ethAmount.toString(); + const wei = ethers.parseEther(ethString); + return wei / 1_000_000_000n; +}; + +function generateWithdrawalRequestPayload(numberOfRequests: number) { + const pubkeys: string[] = []; + const amounts: bigint[] = []; + for (let i = 1; i <= numberOfRequests; i++) { + pubkeys.push(toValidatorPubKey(i)); + amounts.push(convertEthToGwei(i)); + } + + return { pubkeys, amounts }; +} + +async function getFee( + contract: Pick, + requestsCount: number, +): Promise { + const fee = await contract.getWithdrawalRequestFee(); + + return ethers.parseUnits((fee * BigInt(requestsCount)).toString(), "wei"); +} + +async function getWithdrawalCredentialsContractBalance(contract: BaseContract): Promise { + const contractAddress = await contract.getAddress(); + return await ethers.provider.getBalance(contractAddress); +} + +export function testFullWithdrawalRequestBehavior( + getContract: () => BaseContract & + Pick, + getWithdrawalsPredeployedContract: () => WithdrawalsPredeployed_Mock, +) { + async function addFullWithdrawalRequests(requestCount: number, extraFee: bigint = 0n) { + const contract = getContract(); + const initialBalance = await getWithdrawalCredentialsContractBalance(contract); + + const { pubkeys } = generateWithdrawalRequestPayload(requestCount); + + const fee = (await getFee(contract, pubkeys.length)) + extraFee; + const tx = await contract.addFullWithdrawalRequests(pubkeys, { value: fee }); + + expect(await getWithdrawalCredentialsContractBalance(contract)).to.equal(initialBalance); + + const receipt = await tx.wait(); + + const events = findEventsWithInterfaces(receipt!, "WithdrawalRequestAdded", [withdrawalRequestEventInterface]); + expect(events.length).to.equal(requestCount); + + for (let i = 0; i < requestCount; i++) { + expect(events[i].args[0]).to.equal(pubkeys[i]); + expect(events[i].args[1]).to.equal(0n); + } + } + + context("addFullWithdrawalRequests", () => { + it("Should revert if empty arrays are provided", async function () { + const contract = getContract(); + + await expect(contract.addFullWithdrawalRequests([], { value: 1n })).to.be.revertedWithCustomError( + contract, + "NoWithdrawalRequests", + ); + }); + + it("Should revert if not enough fee is sent", async function () { + const { pubkeys } = generateWithdrawalRequestPayload(1); + const contract = getContract(); + + await getWithdrawalsPredeployedContract().setFee(3n); // Set fee to 3 gwei + + // Should revert if no fee is sent + await expect(contract.addFullWithdrawalRequests(pubkeys)).to.be.revertedWithCustomError(contract, "FeeNotEnough"); + + // Should revert if fee is less than required + const insufficientFee = 2n; + await expect( + contract.addFullWithdrawalRequests(pubkeys, { value: insufficientFee }), + ).to.be.revertedWithCustomError(contract, "FeeNotEnough"); + }); + + it("Should revert if any pubkey is not 48 bytes", async function () { + // Invalid pubkey (only 2 bytes) + const pubkeys = ["0x1234"]; + + const contract = getContract(); + const fee = await getFee(contract, pubkeys.length); + + await expect(contract.addFullWithdrawalRequests(pubkeys, { value: fee })) + .to.be.revertedWithCustomError(contract, "InvalidPubkeyLength") + .withArgs(pubkeys[0]); + }); + + it("Should revert if addition fails at the withdrawal request contract", async function () { + const { pubkeys } = generateWithdrawalRequestPayload(1); + const contract = getContract(); + + const fee = await getFee(contract, pubkeys.length); + + // Set mock to fail on add + await getWithdrawalsPredeployedContract().setFailOnAddRequest(true); + + await expect(contract.addFullWithdrawalRequests(pubkeys, { value: fee })).to.be.revertedWithCustomError( + contract, + "WithdrawalRequestAdditionFailed", + ); + }); + + it("Should accept exactly required fee without revert", async function () { + const requestCount = 1; + const { pubkeys } = generateWithdrawalRequestPayload(requestCount); + + const contract = getContract(); + const initialBalance = await getWithdrawalCredentialsContractBalance(contract); + + await getWithdrawalsPredeployedContract().setFee(3n); + expect((await contract.getWithdrawalRequestFee()) == 3n, "Test setup error: invalid withdrawal request fee."); + const fee = 3n; + + await contract.addFullWithdrawalRequests(pubkeys, { value: fee }); + + expect(await getWithdrawalCredentialsContractBalance(contract)).to.equal(initialBalance); + }); + + it("Should accept exceed fee without revert", async function () { + const requestCount = 1; + const { pubkeys } = generateWithdrawalRequestPayload(requestCount); + + const contract = getContract(); + const initialBalance = await getWithdrawalCredentialsContractBalance(contract); + + await getWithdrawalsPredeployedContract().setFee(3n); + expect((await contract.getWithdrawalRequestFee()) == 3n, "Test setup error: invalid withdrawal request fee."); + const fee = 3n + 1n; // 1 request * 3 gwei (fee) + 1 gwei (extra fee)= 4 gwei + + await contract.addFullWithdrawalRequests(pubkeys, { value: fee }); + expect(await getWithdrawalCredentialsContractBalance(contract)).to.equal(initialBalance); + }); + + it("Should successfully add requests and emit events", async function () { + await addFullWithdrawalRequests(1); + await addFullWithdrawalRequests(3); + await addFullWithdrawalRequests(10); + await addFullWithdrawalRequests(100); + }); + + it("Should successfully add requests with extra fee and not change contract balance", async function () { + await addFullWithdrawalRequests(1, 100n); + await addFullWithdrawalRequests(3, 1n); + await addFullWithdrawalRequests(10, 1_000_000n); + await addFullWithdrawalRequests(7, 3n); + await addFullWithdrawalRequests(100, 0n); + }); + }); +} + +export function testPartialWithdrawalRequestBehavior( + getContract: () => BaseContract & + Pick, + getWithdrawalsPredeployedContract: () => WithdrawalsPredeployed_Mock, +) { + async function addPartialWithdrawalRequests(requestCount: number, extraFee: bigint = 0n) { + const contract = getContract(); + const initialBalance = await getWithdrawalCredentialsContractBalance(contract); + + const { pubkeys, amounts } = generateWithdrawalRequestPayload(requestCount); + + const fee = (await getFee(contract, pubkeys.length)) + extraFee; + const tx = await contract.addPartialWithdrawalRequests(pubkeys, amounts, { value: fee }); + + expect(await getWithdrawalCredentialsContractBalance(contract)).to.equal(initialBalance); + + const receipt = await tx.wait(); + + const events = findEventsWithInterfaces(receipt!, "WithdrawalRequestAdded", [withdrawalRequestEventInterface]); + expect(events.length).to.equal(requestCount); + + for (let i = 0; i < requestCount; i++) { + expect(events[i].args[0]).to.equal(pubkeys[i]); + expect(events[i].args[1]).to.equal(amounts[i]); + } + } + + context("addPartialWithdrawalRequests", () => { + it("Should revert if array lengths do not match or empty arrays are provided", async function () { + const { pubkeys, amounts } = generateWithdrawalRequestPayload(2); + amounts.pop(); + + expect( + pubkeys.length !== amounts.length, + "Test setup error: pubkeys and amounts arrays should have different lengths.", + ); + + const contract = getContract(); + + const fee = await getFee(contract, pubkeys.length); + await expect(contract.addPartialWithdrawalRequests(pubkeys, amounts, { value: fee })) + .to.be.revertedWithCustomError(contract, "MismatchedArrayLengths") + .withArgs(pubkeys.length, amounts.length); + + // Also test empty arrays + await expect(contract.addPartialWithdrawalRequests([], [], { value: fee })).to.be.revertedWithCustomError( + contract, + "NoWithdrawalRequests", + ); + }); + + it("Should revert if not enough fee is sent", async function () { + const { pubkeys, amounts } = generateWithdrawalRequestPayload(1); + const contract = getContract(); + + await getWithdrawalsPredeployedContract().setFee(3n); // Set fee to 3 gwei + + // Should revert if no fee is sent + await expect(contract.addPartialWithdrawalRequests(pubkeys, amounts)).to.be.revertedWithCustomError( + contract, + "FeeNotEnough", + ); + + // Should revert if fee is less than required + const insufficientFee = 2n; + await expect( + contract.addPartialWithdrawalRequests(pubkeys, amounts, { value: insufficientFee }), + ).to.be.revertedWithCustomError(contract, "FeeNotEnough"); + }); + + it("Should revert if any pubkey is not 48 bytes", async function () { + // Invalid pubkey (only 2 bytes) + const pubkeys = ["0x1234"]; + const amounts = [100n]; + + const contract = getContract(); + const fee = await getFee(contract, pubkeys.length); + + await expect(contract.addPartialWithdrawalRequests(pubkeys, amounts, { value: fee })) + .to.be.revertedWithCustomError(contract, "InvalidPubkeyLength") + .withArgs(pubkeys[0]); + }); + + it("Should revert if addition fails at the withdrawal request contract", async function () { + const { pubkeys, amounts } = generateWithdrawalRequestPayload(1); + const contract = getContract(); + const fee = await getFee(contract, pubkeys.length); + + // Set mock to fail on add + await getWithdrawalsPredeployedContract().setFailOnAddRequest(true); + + await expect( + contract.addPartialWithdrawalRequests(pubkeys, amounts, { value: fee }), + ).to.be.revertedWithCustomError(contract, "WithdrawalRequestAdditionFailed"); + }); + + it("Should revert if full withdrawal requested", async function () { + const { pubkeys, amounts } = generateWithdrawalRequestPayload(2); + amounts[0] = 1n; // Partial withdrawal + amounts[1] = 0n; // Full withdrawal + + const contract = getContract(); + const fee = await getFee(contract, pubkeys.length); + + await expect( + contract.addPartialWithdrawalRequests(pubkeys, amounts, { value: fee }), + ).to.be.revertedWithCustomError(contract, "PartialWithdrawalRequired"); + }); + + it("Should accept exactly required fee without revert", async function () { + const requestCount = 1; + const { pubkeys, amounts } = generateWithdrawalRequestPayload(requestCount); + + const contract = getContract(); + const initialBalance = await getWithdrawalCredentialsContractBalance(contract); + + await getWithdrawalsPredeployedContract().setFee(3n); + expect((await contract.getWithdrawalRequestFee()) == 3n, "Test setup error: invalid withdrawal request fee."); + const fee = 3n; + + await contract.addPartialWithdrawalRequests(pubkeys, amounts, { value: fee }); + + expect(await getWithdrawalCredentialsContractBalance(contract)).to.equal(initialBalance); + }); + + it("Should accept exceed fee without revert", async function () { + const requestCount = 1; + const { pubkeys, amounts } = generateWithdrawalRequestPayload(requestCount); + + const contract = getContract(); + const initialBalance = await getWithdrawalCredentialsContractBalance(contract); + + await getWithdrawalsPredeployedContract().setFee(3n); + expect((await contract.getWithdrawalRequestFee()) == 3n, "Test setup error: invalid withdrawal request fee."); + const fee = 3n + 1n; // 1 request * 3 gwei (fee) + 1 gwei (extra fee)= 4 gwei + + await contract.addPartialWithdrawalRequests(pubkeys, amounts, { value: fee }); + expect(await getWithdrawalCredentialsContractBalance(contract)).to.equal(initialBalance); + }); + + it("Should successfully add requests and emit events", async function () { + await addPartialWithdrawalRequests(1); + await addPartialWithdrawalRequests(3); + await addPartialWithdrawalRequests(10); + await addPartialWithdrawalRequests(100); + }); + + it("Should successfully add requests with extra fee and not change contract balance", async function () { + await addPartialWithdrawalRequests(1, 100n); + await addPartialWithdrawalRequests(3, 1n); + await addPartialWithdrawalRequests(10, 1_000_000n); + await addPartialWithdrawalRequests(7, 3n); + await addPartialWithdrawalRequests(100, 0n); + }); + }); +} diff --git a/test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behaviour.ts b/test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behaviour.ts deleted file mode 100644 index 34ff98873..000000000 --- a/test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behaviour.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { expect } from "chai"; -import { ethers } from "hardhat"; - -import { WithdrawalCredentials_Harness, WithdrawalsPredeployed_Mock } from "typechain-types"; - -import { findEventsWithInterfaces } from "lib"; - -const withdrawalRequestEventABI = ["event WithdrawalRequestAdded(bytes pubkey, uint256 amount)"]; -const withdrawalRequestEventInterface = new ethers.Interface(withdrawalRequestEventABI); - -const withdrawalsPredeployedHardcodedAddress = "0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA"; - -export async function deployWithdrawalsPredeployedMock(): 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(1n); - 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 `0x${num.toString(16).padStart(4, "0").repeat(24)}`; -} - -const convertEthToGwei = (ethAmount: string | number): bigint => { - const ethString = ethAmount.toString(); - const wei = ethers.parseEther(ethString); - return wei / 1_000_000_000n; -}; - -function generateWithdrawalRequestPayload(numberOfRequests: number) { - const pubkeys: string[] = []; - const amounts: bigint[] = []; - for (let i = 1; i <= numberOfRequests; i++) { - pubkeys.push(toValidatorPubKey(i)); - amounts.push(convertEthToGwei(i)); - } - - return { pubkeys, amounts }; -} - -export function tesWithdrawalRequestsBehavior( - getContract: () => WithdrawalCredentials_Harness, - getWithdrawalsPredeployedContract: () => WithdrawalsPredeployed_Mock, -) { - async function getFee(requestsCount: number): Promise { - const fee = await getContract().getWithdrawalRequestFee(); - - return ethers.parseUnits((fee * BigInt(requestsCount)).toString(), "wei"); - } - - async function getWithdrawalCredentialsContractBalance(): Promise { - const contract = getContract(); - const contractAddress = await contract.getAddress(); - return await ethers.provider.getBalance(contractAddress); - } - - async function addWithdrawalRequests(requestCount: number, extraFee: bigint = 0n) { - const contract = getContract(); - const initialBalance = await getWithdrawalCredentialsContractBalance(); - - const { pubkeys, amounts } = generateWithdrawalRequestPayload(requestCount); - - const fee = (await getFee(pubkeys.length)) + extraFee; - const tx = await contract.addWithdrawalRequests(pubkeys, amounts, { value: fee }); - - expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); - - const receipt = await tx.wait(); - - expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); - - const events = findEventsWithInterfaces(receipt!, "WithdrawalRequestAdded", [withdrawalRequestEventInterface]); - expect(events.length).to.equal(requestCount); - - for (let i = 0; i < requestCount; i++) { - expect(events[i].args[0]).to.equal(pubkeys[i]); - expect(events[i].args[1]).to.equal(amounts[i]); - } - } - - context("addWithdrawalRequests", async () => { - it("Should revert if array lengths do not match or empty arrays are provided", async function () { - const { pubkeys, amounts } = generateWithdrawalRequestPayload(2); - amounts.pop(); - - expect( - pubkeys.length !== amounts.length, - "Test setup error: pubkeys and amounts arrays should have different lengths.", - ); - - const contract = getContract(); - - const fee = await getFee(pubkeys.length); - await expect(contract.addWithdrawalRequests(pubkeys, amounts, { value: fee })) - .to.be.revertedWithCustomError(contract, "InvalidArrayLengths") - .withArgs(pubkeys.length, amounts.length); - - // Also test empty arrays - await expect(contract.addWithdrawalRequests([], [], { value: fee })) - .to.be.revertedWithCustomError(contract, "InvalidArrayLengths") - .withArgs(0, 0); - }); - - it("Should revert if not enough fee is sent", async function () { - const { pubkeys, amounts } = generateWithdrawalRequestPayload(1); - const contract = getContract(); - - await getWithdrawalsPredeployedContract().setFee(3n); // Set fee to 3 gwei - - // Should revert if no fee is sent - await expect(contract.addWithdrawalRequests(pubkeys, amounts)).to.be.revertedWithCustomError( - contract, - "FeeNotEnough", - ); - - // Should revert if fee is less than required - const insufficientFee = 2n; - await expect( - contract.addWithdrawalRequests(pubkeys, amounts, { value: insufficientFee }), - ).to.be.revertedWithCustomError(contract, "FeeNotEnough"); - }); - - it("Should revert if any pubkey is not 48 bytes", async function () { - // Invalid pubkey (only 2 bytes) - const pubkeys = ["0x1234"]; - const amounts = [100n]; - - const fee = await getFee(pubkeys.length); - const contract = getContract(); - await expect(contract.addWithdrawalRequests(pubkeys, amounts, { value: fee })) - .to.be.revertedWithCustomError(contract, "InvalidPubkeyLength") - .withArgs(pubkeys[0]); - }); - - it("Should revert if addition fails at the withdrawal request contract", async function () { - const { pubkeys, amounts } = generateWithdrawalRequestPayload(1); - const fee = await getFee(pubkeys.length); - - // Set mock to fail on add - await getWithdrawalsPredeployedContract().setFailOnAddRequest(true); - const contract = getContract(); - - await expect(contract.addWithdrawalRequests(pubkeys, amounts, { value: fee })).to.be.revertedWithCustomError( - contract, - "WithdrawalRequestAdditionFailed", - ); - }); - - it("Should accept full and partial withdrawals", async function () { - const { pubkeys, amounts } = generateWithdrawalRequestPayload(2); - amounts[0] = 0n; // Full withdrawal - amounts[1] = 1n; // Partial withdrawal - - const fee = await getFee(pubkeys.length); - const contract = getContract(); - - await contract.addWithdrawalRequests(pubkeys, amounts, { value: fee }); - }); - - it("Should accept exactly required fee without revert", async function () { - const requestCount = 1; - const { pubkeys, amounts } = generateWithdrawalRequestPayload(requestCount); - - const contract = getContract(); - const initialBalance = await getWithdrawalCredentialsContractBalance(); - - await getWithdrawalsPredeployedContract().setFee(3n); - expect((await contract.getWithdrawalRequestFee()) == 3n, "Test setup error: invalid withdrawal request fee."); - const fee = 3n; - - await contract.addWithdrawalRequests(pubkeys, amounts, { value: fee }); - - expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); - }); - - it("Should accept exceed fee without revert", async function () { - const requestCount = 1; - const { pubkeys, amounts } = generateWithdrawalRequestPayload(requestCount); - - const contract = getContract(); - const initialBalance = await getWithdrawalCredentialsContractBalance(); - - await getWithdrawalsPredeployedContract().setFee(3n); - expect((await contract.getWithdrawalRequestFee()) == 3n, "Test setup error: invalid withdrawal request fee."); - const fee = 3n + 1n; // 1 request * 3 gwei (fee) + 1 gwei (extra fee)= 4 gwei - - await contract.addWithdrawalRequests(pubkeys, amounts, { value: fee }); - expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); - }); - - it("Should successfully add requests and emit events", async function () { - await addWithdrawalRequests(1); - await addWithdrawalRequests(3); - await addWithdrawalRequests(10); - await addWithdrawalRequests(100); - }); - - it("Should successfully add requests with extra fee and not change contract balance", async function () { - await addWithdrawalRequests(1, 100n); - await addWithdrawalRequests(3, 1n); - await addWithdrawalRequests(10, 1_000_000n); - await addWithdrawalRequests(7, 3n); - await addWithdrawalRequests(100, 0n); - }); - }); -} diff --git a/test/0.8.9/withdrawalVault.test.ts b/test/0.8.9/withdrawalVault.test.ts index 9f1d80aa4..818036201 100644 --- a/test/0.8.9/withdrawalVault.test.ts +++ b/test/0.8.9/withdrawalVault.test.ts @@ -19,8 +19,8 @@ import { Snapshot } from "test/suite"; import { deployWithdrawalsPredeployedMock, - tesWithdrawalRequestsBehavior, -} from "./lib/withdrawalCredentials/withdrawalRequests.behaviour"; + testFullWithdrawalRequestBehavior, +} from "./lib/withdrawalCredentials/withdrawalRequests.behavior"; const PETRIFIED_VERSION = MAX_UINT256; @@ -41,9 +41,6 @@ describe("WithdrawalVault.sol", () => { let vault: WithdrawalVault; let vaultAddress: string; - const getWithdrawalCredentialsContract = () => vault.connect(validatorsExitBus); - const getWithdrawalsPredeployedContract = () => withdrawalsPredeployed.connect(user); - before(async () => { [owner, user, treasury, validatorsExitBus] = await ethers.getSigners(); @@ -200,12 +197,15 @@ describe("WithdrawalVault.sol", () => { context("addWithdrawalRequests", () => { it("Reverts if the caller is not Validator Exit Bus", async () => { - await expect(vault.connect(user).addWithdrawalRequests(["0x1234"], [0n])).to.be.revertedWithCustomError( + await expect(vault.connect(user).addFullWithdrawalRequests(["0x1234"])).to.be.revertedWithCustomError( vault, "NotValidatorExitBus", ); }); - tesWithdrawalRequestsBehavior(getWithdrawalCredentialsContract, getWithdrawalsPredeployedContract); + testFullWithdrawalRequestBehavior( + () => vault.connect(validatorsExitBus), + () => withdrawalsPredeployed.connect(user), + ); }); }); From 4420a7cb4e616f94151f4d957c0f9fb1d6653b4b Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Sat, 21 Dec 2024 21:16:52 +0100 Subject: [PATCH 03/21] feat: decouple fee allocation strategy from withdrawal request library --- contracts/0.8.9/WithdrawalVault.sol | 10 +- contracts/0.8.9/lib/WithdrawalRequests.sol | 72 +++- .../WithdrawalCredentials_Harness.sol | 28 +- .../lib/withdrawalCredentials/findEvents.ts | 13 + .../withdrawalCredentials.test.ts | 394 +++++++++++++++++- .../withdrawalRequests.behavior.ts | 329 +-------------- test/0.8.9/withdrawalVault.test.ts | 13 +- 7 files changed, 493 insertions(+), 366 deletions(-) create mode 100644 test/0.8.9/lib/withdrawalCredentials/findEvents.ts diff --git a/contracts/0.8.9/WithdrawalVault.sol b/contracts/0.8.9/WithdrawalVault.sol index bc6d87e76..0c5eaa163 100644 --- a/contracts/0.8.9/WithdrawalVault.sol +++ b/contracts/0.8.9/WithdrawalVault.sol @@ -55,9 +55,9 @@ contract WithdrawalVault is Versioned { * @param _treasury the Lido treasury address (see ERC20/ERC721-recovery interfaces) */ constructor(address _lido, address _treasury, address _validatorsExitBus) { - _assertNonZero(_lido); - _assertNonZero(_treasury); - _assertNonZero(_validatorsExitBus); + _requireNonZero(_lido); + _requireNonZero(_treasury); + _requireNonZero(_validatorsExitBus); LIDO = ILido(_lido); TREASURY = _treasury; @@ -141,14 +141,14 @@ contract WithdrawalVault is Versioned { revert NotValidatorExitBus(); } - WithdrawalRequests.addFullWithdrawalRequests(pubkeys); + WithdrawalRequests.addFullWithdrawalRequests(pubkeys, msg.value); } function getWithdrawalRequestFee() external view returns (uint256) { return WithdrawalRequests.getWithdrawalRequestFee(); } - function _assertNonZero(address _address) internal pure { + function _requireNonZero(address _address) internal pure { if (_address == address(0)) revert ZeroAddress(); } } diff --git a/contracts/0.8.9/lib/WithdrawalRequests.sol b/contracts/0.8.9/lib/WithdrawalRequests.sol index 7973f118d..8d0bc0979 100644 --- a/contracts/0.8.9/lib/WithdrawalRequests.sol +++ b/contracts/0.8.9/lib/WithdrawalRequests.sol @@ -7,7 +7,8 @@ library WithdrawalRequests { address constant WITHDRAWAL_REQUEST = 0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA; error MismatchedArrayLengths(uint256 keysCount, uint256 amountsCount); - error FeeNotEnough(uint256 minFeePerRequest, uint256 requestCount, uint256 msgValue); + error InsufficientBalance(uint256 balance, uint256 totalWithdrawalFee); + error FeeNotEnough(uint256 minFeePerRequest, uint256 requestCount, uint256 providedTotalFee); error WithdrawalRequestFeeReadFailed(); error InvalidPubkeyLength(bytes pubkey); @@ -23,17 +24,17 @@ library WithdrawalRequests { * @param pubkeys An array of public keys for the validators requesting full withdrawals. */ function addFullWithdrawalRequests( - bytes[] calldata pubkeys + bytes[] calldata pubkeys, + uint256 totalWithdrawalFee ) internal { - uint256 keysCount = pubkeys.length; - uint64[] memory amounts = new uint64[](keysCount); - - _addWithdrawalRequests(pubkeys, amounts); + uint64[] memory amounts = new uint64[](pubkeys.length); + _addWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee); } /** * @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. @@ -41,23 +42,35 @@ library WithdrawalRequests { */ function addPartialWithdrawalRequests( bytes[] calldata pubkeys, - uint64[] calldata amounts + uint64[] calldata amounts, + uint256 totalWithdrawalFee ) internal { - uint256 keysCount = pubkeys.length; - if (keysCount != amounts.length) { - revert MismatchedArrayLengths(keysCount, amounts.length); - } + _requireArrayLengthsMatch(pubkeys, amounts); - uint64[] memory _amounts = new uint64[](keysCount); - for (uint256 i = 0; i < keysCount; i++) { + for (uint256 i = 0; i < amounts.length; i++) { if (amounts[i] == 0) { revert PartialWithdrawalRequired(pubkeys[i]); } - - _amounts[i] = amounts[i]; } - _addWithdrawalRequests(pubkeys, _amounts); + _addWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee); + } + + /** + * @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 totalWithdrawalFee + ) internal { + _requireArrayLengthsMatch(pubkeys, amounts); + _addWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee); } /** @@ -76,22 +89,26 @@ library WithdrawalRequests { function _addWithdrawalRequests( bytes[] calldata pubkeys, - uint64[] memory amounts + uint64[] memory amounts, + uint256 totalWithdrawalFee ) internal { uint256 keysCount = pubkeys.length; if (keysCount == 0) { revert NoWithdrawalRequests(); } - uint256 minFeePerRequest = getWithdrawalRequestFee(); - if (minFeePerRequest * keysCount > msg.value) { - revert FeeNotEnough(minFeePerRequest, keysCount, msg.value); + if(address(this).balance < totalWithdrawalFee) { + revert InsufficientBalance(address(this).balance, totalWithdrawalFee); } - uint256 feePerRequest = msg.value / keysCount; - uint256 unallocatedFee = msg.value % keysCount; - uint256 prevBalance = address(this).balance - msg.value; + uint256 minFeePerRequest = getWithdrawalRequestFee(); + if (minFeePerRequest * keysCount > totalWithdrawalFee) { + revert FeeNotEnough(minFeePerRequest, keysCount, totalWithdrawalFee); + } + uint256 feePerRequest = totalWithdrawalFee / keysCount; + uint256 unallocatedFee = totalWithdrawalFee % keysCount; + uint256 prevBalance = address(this).balance - totalWithdrawalFee; for (uint256 i = 0; i < keysCount; ++i) { bytes memory pubkey = pubkeys[i]; @@ -119,4 +136,13 @@ library WithdrawalRequests { assert(address(this).balance == prevBalance); } + + function _requireArrayLengthsMatch( + bytes[] calldata pubkeys, + uint64[] calldata amounts + ) internal pure { + if (pubkeys.length != amounts.length) { + revert MismatchedArrayLengths(pubkeys.length, amounts.length); + } + } } diff --git a/test/0.8.9/contracts/WithdrawalCredentials_Harness.sol b/test/0.8.9/contracts/WithdrawalCredentials_Harness.sol index 1450f79e9..b5e55c299 100644 --- a/test/0.8.9/contracts/WithdrawalCredentials_Harness.sol +++ b/test/0.8.9/contracts/WithdrawalCredentials_Harness.sol @@ -4,19 +4,35 @@ import {WithdrawalRequests} from "contracts/0.8.9/lib/WithdrawalRequests.sol"; contract WithdrawalCredentials_Harness { function addFullWithdrawalRequests( - bytes[] calldata pubkeys - ) external payable { - WithdrawalRequests.addFullWithdrawalRequests(pubkeys); + bytes[] calldata pubkeys, + uint256 totalWithdrawalFee + ) external { + WithdrawalRequests.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee); } function addPartialWithdrawalRequests( bytes[] calldata pubkeys, - uint64[] calldata amounts - ) external payable { - WithdrawalRequests.addPartialWithdrawalRequests(pubkeys, amounts); + uint64[] calldata amounts, + uint256 totalWithdrawalFee + ) external { + WithdrawalRequests.addPartialWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee); + } + + function addWithdrawalRequests( + bytes[] calldata pubkeys, + uint64[] calldata amounts, + uint256 totalWithdrawalFee + ) external { + WithdrawalRequests.addWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee); } function getWithdrawalRequestFee() external view returns (uint256) { return WithdrawalRequests.getWithdrawalRequestFee(); } + + function getWithdrawalsContractAddress() public pure returns (address) { + return WithdrawalRequests.WITHDRAWAL_REQUEST; + } + + function deposit() external payable {} } diff --git a/test/0.8.9/lib/withdrawalCredentials/findEvents.ts b/test/0.8.9/lib/withdrawalCredentials/findEvents.ts new file mode 100644 index 000000000..9ee258139 --- /dev/null +++ b/test/0.8.9/lib/withdrawalCredentials/findEvents.ts @@ -0,0 +1,13 @@ +import { ContractTransactionReceipt } from "ethers"; +import { ethers } from "hardhat"; + +import { findEventsWithInterfaces } from "lib"; + +const withdrawalRequestEventABI = ["event WithdrawalRequestAdded(bytes pubkey, uint256 amount)"]; +const withdrawalRequestEventInterface = new ethers.Interface(withdrawalRequestEventABI); + +type WithdrawalRequestEvents = "WithdrawalRequestAdded"; + +export function findEvents(receipt: ContractTransactionReceipt, event: WithdrawalRequestEvents) { + return findEventsWithInterfaces(receipt!, event, [withdrawalRequestEventInterface]); +} diff --git a/test/0.8.9/lib/withdrawalCredentials/withdrawalCredentials.test.ts b/test/0.8.9/lib/withdrawalCredentials/withdrawalCredentials.test.ts index 744519a3f..2ee973b67 100644 --- a/test/0.8.9/lib/withdrawalCredentials/withdrawalCredentials.test.ts +++ b/test/0.8.9/lib/withdrawalCredentials/withdrawalCredentials.test.ts @@ -1,15 +1,19 @@ +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 { WithdrawalCredentials_Harness, WithdrawalsPredeployed_Mock } from "typechain-types"; import { Snapshot } from "test/suite"; +import { findEvents } from "./findEvents"; import { deployWithdrawalsPredeployedMock, - testFullWithdrawalRequestBehavior, - testPartialWithdrawalRequestBehavior, + generateWithdrawalRequestPayload, + withdrawalsPredeployedHardcodedAddress, } from "./withdrawalRequests.behavior"; describe("WithdrawalCredentials.sol", () => { @@ -20,24 +24,392 @@ describe("WithdrawalCredentials.sol", () => { let originalState: string; + async function getWithdrawalCredentialsContractBalance(): Promise { + const contractAddress = await withdrawalCredentials.getAddress(); + return await ethers.provider.getBalance(contractAddress); + } + + async function getWithdrawalsPredeployedContractBalance(): Promise { + const contractAddress = await withdrawalsPredeployed.getAddress(); + return await ethers.provider.getBalance(contractAddress); + } + before(async () => { [actor] = await ethers.getSigners(); - withdrawalsPredeployed = await deployWithdrawalsPredeployedMock(); + withdrawalsPredeployed = await deployWithdrawalsPredeployedMock(1n); withdrawalCredentials = await ethers.deployContract("WithdrawalCredentials_Harness"); + + expect(await withdrawalsPredeployed.getAddress()).to.equal(withdrawalsPredeployedHardcodedAddress); + + await withdrawalCredentials.connect(actor).deposit({ value: ethers.parseEther("1") }); }); beforeEach(async () => (originalState = await Snapshot.take())); afterEach(async () => await Snapshot.restore(originalState)); - testFullWithdrawalRequestBehavior( - () => withdrawalCredentials.connect(actor), - () => withdrawalsPredeployed.connect(actor), - ); + async function getFee(requestsCount: number): Promise { + const fee = await withdrawalCredentials.getWithdrawalRequestFee(); + + return ethers.parseUnits((fee * BigInt(requestsCount)).toString(), "wei"); + } + + context("eip 7002 contract", () => { + it("Should return the address of the EIP 7002 contract", async function () { + expect(await withdrawalCredentials.getWithdrawalsContractAddress()).to.equal( + withdrawalsPredeployedHardcodedAddress, + ); + }); + }); + + context("get withdrawal request fee", () => { + it("Should get fee from the EIP 7002 contract", async function () { + await withdrawalsPredeployed.setFee(333n); + expect( + (await withdrawalCredentials.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(withdrawalCredentials.getWithdrawalRequestFee()).to.be.revertedWithCustomError( + withdrawalCredentials, + "WithdrawalRequestFeeReadFailed", + ); + }); + }); + + context("add withdrawal requests", () => { + it("Should revert if empty arrays are provided", async function () { + await expect(withdrawalCredentials.addFullWithdrawalRequests([], 1n)).to.be.revertedWithCustomError( + withdrawalCredentials, + "NoWithdrawalRequests", + ); + + await expect(withdrawalCredentials.addPartialWithdrawalRequests([], [], 1n)).to.be.revertedWithCustomError( + withdrawalCredentials, + "NoWithdrawalRequests", + ); + + await expect(withdrawalCredentials.addWithdrawalRequests([], [], 1n)).to.be.revertedWithCustomError( + withdrawalCredentials, + "NoWithdrawalRequests", + ); + }); + + it("Should revert if array lengths do not match", async function () { + const { pubkeys } = generateWithdrawalRequestPayload(2); + const amounts = [1n]; + + const fee = await getFee(pubkeys.length); + + await expect(withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, amounts, fee)) + .to.be.revertedWithCustomError(withdrawalCredentials, "MismatchedArrayLengths") + .withArgs(pubkeys.length, amounts.length); + + await expect(withdrawalCredentials.addWithdrawalRequests(pubkeys, amounts, fee)) + .to.be.revertedWithCustomError(withdrawalCredentials, "MismatchedArrayLengths") + .withArgs(pubkeys.length, amounts.length); + }); + + it("Should revert if not enough fee is sent", async function () { + const { pubkeys } = generateWithdrawalRequestPayload(1); + const amounts = [10n]; + + await withdrawalsPredeployed.setFee(3n); // Set fee to 3 gwei + + // 1. Should revert if no fee is sent + await expect(withdrawalCredentials.addFullWithdrawalRequests(pubkeys, 0n)).to.be.revertedWithCustomError( + withdrawalCredentials, + "FeeNotEnough", + ); + + await expect( + withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, amounts, 0n), + ).to.be.revertedWithCustomError(withdrawalCredentials, "FeeNotEnough"); + + await expect(withdrawalCredentials.addWithdrawalRequests(pubkeys, amounts, 0n)).to.be.revertedWithCustomError( + withdrawalCredentials, + "FeeNotEnough", + ); + + // 2. Should revert if fee is less than required + const insufficientFee = 2n; + await expect( + withdrawalCredentials.addFullWithdrawalRequests(pubkeys, insufficientFee), + ).to.be.revertedWithCustomError(withdrawalCredentials, "FeeNotEnough"); + + await expect( + withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, amounts, insufficientFee), + ).to.be.revertedWithCustomError(withdrawalCredentials, "FeeNotEnough"); + + await expect( + withdrawalCredentials.addWithdrawalRequests(pubkeys, amounts, insufficientFee), + ).to.be.revertedWithCustomError(withdrawalCredentials, "FeeNotEnough"); + }); + + it("Should revert if any pubkey is not 48 bytes", async function () { + // Invalid pubkey (only 2 bytes) + const pubkeys = ["0x1234"]; + const amounts = [10n]; + + const fee = await getFee(pubkeys.length); + + await expect(withdrawalCredentials.addFullWithdrawalRequests(pubkeys, fee)) + .to.be.revertedWithCustomError(withdrawalCredentials, "InvalidPubkeyLength") + .withArgs(pubkeys[0]); + + await expect(withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, amounts, fee)) + .to.be.revertedWithCustomError(withdrawalCredentials, "InvalidPubkeyLength") + .withArgs(pubkeys[0]); + + await expect(withdrawalCredentials.addWithdrawalRequests(pubkeys, amounts, fee)) + .to.be.revertedWithCustomError(withdrawalCredentials, "InvalidPubkeyLength") + .withArgs(pubkeys[0]); + }); + + it("Should revert if addition fails at the withdrawal request contract", async function () { + const { pubkeys } = generateWithdrawalRequestPayload(1); + const amounts = [10n]; + + const fee = await getFee(pubkeys.length); + + // Set mock to fail on add + await withdrawalsPredeployed.setFailOnAddRequest(true); + + await expect(withdrawalCredentials.addFullWithdrawalRequests(pubkeys, fee)).to.be.revertedWithCustomError( + withdrawalCredentials, + "WithdrawalRequestAdditionFailed", + ); + + await expect( + withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, amounts, fee), + ).to.be.revertedWithCustomError(withdrawalCredentials, "WithdrawalRequestAdditionFailed"); + + await expect(withdrawalCredentials.addWithdrawalRequests(pubkeys, amounts, fee)).to.be.revertedWithCustomError( + withdrawalCredentials, + "WithdrawalRequestAdditionFailed", + ); + }); + + it("Should revert if full withdrawal requested in 'addPartialWithdrawalRequests'", async function () { + const { pubkeys } = generateWithdrawalRequestPayload(2); + const amounts = [1n, 0n]; // Partial and Full withdrawal + const fee = await getFee(pubkeys.length); + + await expect( + withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, amounts, fee), + ).to.be.revertedWithCustomError(withdrawalCredentials, "PartialWithdrawalRequired"); + }); - testPartialWithdrawalRequestBehavior( - () => withdrawalCredentials.connect(actor), - () => withdrawalsPredeployed.connect(actor), - ); + it("Should revert if contract balance insufficient'", async function () { + const { pubkeys, partialWithdrawalAmounts, fullWithdrawalAmounts } = generateWithdrawalRequestPayload(2); + const fee = 10n; + const totalWithdrawalFee = 20n; + const balance = 19n; + + await withdrawalsPredeployed.setFee(fee); + await setBalance(await withdrawalCredentials.getAddress(), balance); + + await expect(withdrawalCredentials.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee)) + .to.be.revertedWithCustomError(withdrawalCredentials, "InsufficientBalance") + .withArgs(balance, totalWithdrawalFee); + + await expect( + withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee), + ) + .to.be.revertedWithCustomError(withdrawalCredentials, "InsufficientBalance") + .withArgs(balance, totalWithdrawalFee); + + await expect(withdrawalCredentials.addWithdrawalRequests(pubkeys, fullWithdrawalAmounts, totalWithdrawalFee)) + .to.be.revertedWithCustomError(withdrawalCredentials, "InsufficientBalance") + .withArgs(balance, totalWithdrawalFee); + }); + + it("Should accept exactly required fee without revert", async function () { + const requestCount = 3; + const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + await withdrawalsPredeployed.setFee(3n); + const fee = 9n; + + await withdrawalCredentials.addFullWithdrawalRequests(pubkeys, fee); + await withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); + await withdrawalCredentials.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee); + }); + + it("Should accept exceed fee without revert", async function () { + const requestCount = 3; + const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + await withdrawalsPredeployed.setFee(3n); + const fee = 9n + 1n; // 3 request * 3 gwei (fee) + 1 gwei (extra fee)= 10 gwei + + await withdrawalCredentials.addFullWithdrawalRequests(pubkeys, fee); + await withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); + await withdrawalCredentials.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee); + }); + + it("Should deduct precise fee value from contract balance", async function () { + const requestCount = 3; + const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + await withdrawalsPredeployed.setFee(3n); + const fee = 9n + 1n; // 3 requests * 3 gwei (fee) + 1 gwei (extra fee) = 10 gwei + + const testFeeDeduction = async (addRequests: () => Promise) => { + const initialBalance = await getWithdrawalCredentialsContractBalance(); + await addRequests(); + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance - fee); + }; + + await testFeeDeduction(() => withdrawalCredentials.addFullWithdrawalRequests(pubkeys, fee)); + await testFeeDeduction(() => + withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), + ); + await testFeeDeduction(() => withdrawalCredentials.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee)); + }); + + it("Should send all fee to eip 7002 withdrawal contract", async function () { + const requestCount = 3; + const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + await withdrawalsPredeployed.setFee(3n); + const totalWithdrawalFee = 9n + 1n; + + const testFeeTransfer = async (addRequests: () => Promise) => { + const initialBalance = await getWithdrawalsPredeployedContractBalance(); + await addRequests(); + expect(await getWithdrawalsPredeployedContractBalance()).to.equal(initialBalance + totalWithdrawalFee); + }; + + await testFeeTransfer(() => withdrawalCredentials.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee)); + await testFeeTransfer(() => + withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee), + ); + await testFeeTransfer(() => + withdrawalCredentials.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, totalWithdrawalFee), + ); + }); + + it("should emit a 'WithdrawalRequestAdded' event when a new withdrawal request is added", async function () { + const requestCount = 3; + const { pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + const fee = 10n; + + const testEventsEmit = async ( + addRequests: () => Promise, + expectedPubKeys: string[], + expectedAmounts: bigint[], + ) => { + const tx = await addRequests(); + + const receipt = await tx.wait(); + const events = findEvents(receipt!, "WithdrawalRequestAdded"); + expect(events.length).to.equal(requestCount); + + for (let i = 0; i < requestCount; i++) { + expect(events[i].args[0]).to.equal(expectedPubKeys[i]); + expect(events[i].args[1]).to.equal(expectedAmounts[i]); + } + }; + + await testEventsEmit( + () => withdrawalCredentials.addFullWithdrawalRequests(pubkeys, fee), + pubkeys, + fullWithdrawalAmounts, + ); + await testEventsEmit( + () => withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), + pubkeys, + partialWithdrawalAmounts, + ); + await testEventsEmit( + () => withdrawalCredentials.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee), + pubkeys, + mixedWithdrawalAmounts, + ); + }); + + async function addWithdrawalRequests( + addRequests: () => Promise, + expectedPubkeys: string[], + expectedAmounts: bigint[], + expectedTotalWithdrawalFee: bigint, + ) { + const initialBalance = await getWithdrawalCredentialsContractBalance(); + + const tx = await addRequests(); + + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance - expectedTotalWithdrawalFee); + + const receipt = await tx.wait(); + + const events = findEvents(receipt!, "WithdrawalRequestAdded"); + expect(events.length).to.equal(expectedPubkeys.length); + + for (let i = 0; i < expectedPubkeys.length; i++) { + expect(events[i].args[0]).to.equal(expectedPubkeys[i]); + expect(events[i].args[1]).to.equal(expectedAmounts[i]); + } + } + + const testCasesForWithdrawalRequests = [ + { requestCount: 1, extraFee: 0n }, + { requestCount: 1, extraFee: 100n }, + { requestCount: 3, extraFee: 0n }, + { requestCount: 3, extraFee: 1n }, + { requestCount: 7, extraFee: 3n }, + { requestCount: 10, extraFee: 0n }, + { requestCount: 10, extraFee: 1_000_000n }, + { requestCount: 100, extraFee: 0n }, + ]; + + testCasesForWithdrawalRequests.forEach(({ requestCount, extraFee }) => { + it(`Should successfully add ${requestCount} requests with extra fee ${extraFee} and emit events`, async () => { + const { pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + const totalWithdrawalFee = (await getFee(pubkeys.length)) + extraFee; + + await addWithdrawalRequests( + () => withdrawalCredentials.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee), + pubkeys, + fullWithdrawalAmounts, + totalWithdrawalFee, + ); + + await addWithdrawalRequests( + () => + withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee), + pubkeys, + partialWithdrawalAmounts, + totalWithdrawalFee, + ); + + await addWithdrawalRequests( + () => withdrawalCredentials.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, totalWithdrawalFee), + pubkeys, + mixedWithdrawalAmounts, + totalWithdrawalFee, + ); + }); + }); + + it("Should accept full and partial withdrawals requested", async function () { + const { pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(3); + const fee = await getFee(pubkeys.length); + + await withdrawalCredentials.addWithdrawalRequests(pubkeys, fullWithdrawalAmounts, fee); + await withdrawalCredentials.addWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); + await withdrawalCredentials.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee); + }); + }); }); diff --git a/test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behavior.ts b/test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behavior.ts index 7eeafea9f..105c23e47 100644 --- a/test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behavior.ts +++ b/test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behavior.ts @@ -1,17 +1,12 @@ -import { expect } from "chai"; -import { BaseContract } from "ethers"; import { ethers } from "hardhat"; -import { WithdrawalCredentials_Harness, WithdrawalsPredeployed_Mock } from "typechain-types"; +import { WithdrawalsPredeployed_Mock } from "typechain-types"; -import { findEventsWithInterfaces } from "lib"; +export const withdrawalsPredeployedHardcodedAddress = "0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA"; -const withdrawalRequestEventABI = ["event WithdrawalRequestAdded(bytes pubkey, uint256 amount)"]; -const withdrawalRequestEventInterface = new ethers.Interface(withdrawalRequestEventABI); - -const withdrawalsPredeployedHardcodedAddress = "0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA"; - -export async function deployWithdrawalsPredeployedMock(): Promise { +export async function deployWithdrawalsPredeployedMock( + defaultRequestFee: bigint, +): Promise { const withdrawalsPredeployed = await ethers.deployContract("WithdrawalsPredeployed_Mock"); const withdrawalsPredeployedAddress = await withdrawalsPredeployed.getAddress(); @@ -21,7 +16,7 @@ export async function deployWithdrawalsPredeployedMock(): Promise { return wei / 1_000_000_000n; }; -function generateWithdrawalRequestPayload(numberOfRequests: number) { +export function generateWithdrawalRequestPayload(numberOfRequests: number) { const pubkeys: string[] = []; - const amounts: bigint[] = []; + const fullWithdrawalAmounts: bigint[] = []; + const partialWithdrawalAmounts: bigint[] = []; + const mixedWithdrawalAmounts: bigint[] = []; + for (let i = 1; i <= numberOfRequests; i++) { pubkeys.push(toValidatorPubKey(i)); - amounts.push(convertEthToGwei(i)); - } - - return { pubkeys, amounts }; -} - -async function getFee( - contract: Pick, - requestsCount: number, -): Promise { - const fee = await contract.getWithdrawalRequestFee(); - - return ethers.parseUnits((fee * BigInt(requestsCount)).toString(), "wei"); -} - -async function getWithdrawalCredentialsContractBalance(contract: BaseContract): Promise { - const contractAddress = await contract.getAddress(); - return await ethers.provider.getBalance(contractAddress); -} - -export function testFullWithdrawalRequestBehavior( - getContract: () => BaseContract & - Pick, - getWithdrawalsPredeployedContract: () => WithdrawalsPredeployed_Mock, -) { - async function addFullWithdrawalRequests(requestCount: number, extraFee: bigint = 0n) { - const contract = getContract(); - const initialBalance = await getWithdrawalCredentialsContractBalance(contract); - - const { pubkeys } = generateWithdrawalRequestPayload(requestCount); - - const fee = (await getFee(contract, pubkeys.length)) + extraFee; - const tx = await contract.addFullWithdrawalRequests(pubkeys, { value: fee }); - - expect(await getWithdrawalCredentialsContractBalance(contract)).to.equal(initialBalance); - - const receipt = await tx.wait(); - - const events = findEventsWithInterfaces(receipt!, "WithdrawalRequestAdded", [withdrawalRequestEventInterface]); - expect(events.length).to.equal(requestCount); - - for (let i = 0; i < requestCount; i++) { - expect(events[i].args[0]).to.equal(pubkeys[i]); - expect(events[i].args[1]).to.equal(0n); - } + fullWithdrawalAmounts.push(0n); + partialWithdrawalAmounts.push(convertEthToGwei(i)); + mixedWithdrawalAmounts.push(i % 2 === 0 ? 0n : convertEthToGwei(i)); } - context("addFullWithdrawalRequests", () => { - it("Should revert if empty arrays are provided", async function () { - const contract = getContract(); - - await expect(contract.addFullWithdrawalRequests([], { value: 1n })).to.be.revertedWithCustomError( - contract, - "NoWithdrawalRequests", - ); - }); - - it("Should revert if not enough fee is sent", async function () { - const { pubkeys } = generateWithdrawalRequestPayload(1); - const contract = getContract(); - - await getWithdrawalsPredeployedContract().setFee(3n); // Set fee to 3 gwei - - // Should revert if no fee is sent - await expect(contract.addFullWithdrawalRequests(pubkeys)).to.be.revertedWithCustomError(contract, "FeeNotEnough"); - - // Should revert if fee is less than required - const insufficientFee = 2n; - await expect( - contract.addFullWithdrawalRequests(pubkeys, { value: insufficientFee }), - ).to.be.revertedWithCustomError(contract, "FeeNotEnough"); - }); - - it("Should revert if any pubkey is not 48 bytes", async function () { - // Invalid pubkey (only 2 bytes) - const pubkeys = ["0x1234"]; - - const contract = getContract(); - const fee = await getFee(contract, pubkeys.length); - - await expect(contract.addFullWithdrawalRequests(pubkeys, { value: fee })) - .to.be.revertedWithCustomError(contract, "InvalidPubkeyLength") - .withArgs(pubkeys[0]); - }); - - it("Should revert if addition fails at the withdrawal request contract", async function () { - const { pubkeys } = generateWithdrawalRequestPayload(1); - const contract = getContract(); - - const fee = await getFee(contract, pubkeys.length); - - // Set mock to fail on add - await getWithdrawalsPredeployedContract().setFailOnAddRequest(true); - - await expect(contract.addFullWithdrawalRequests(pubkeys, { value: fee })).to.be.revertedWithCustomError( - contract, - "WithdrawalRequestAdditionFailed", - ); - }); - - it("Should accept exactly required fee without revert", async function () { - const requestCount = 1; - const { pubkeys } = generateWithdrawalRequestPayload(requestCount); - - const contract = getContract(); - const initialBalance = await getWithdrawalCredentialsContractBalance(contract); - - await getWithdrawalsPredeployedContract().setFee(3n); - expect((await contract.getWithdrawalRequestFee()) == 3n, "Test setup error: invalid withdrawal request fee."); - const fee = 3n; - - await contract.addFullWithdrawalRequests(pubkeys, { value: fee }); - - expect(await getWithdrawalCredentialsContractBalance(contract)).to.equal(initialBalance); - }); - - it("Should accept exceed fee without revert", async function () { - const requestCount = 1; - const { pubkeys } = generateWithdrawalRequestPayload(requestCount); - - const contract = getContract(); - const initialBalance = await getWithdrawalCredentialsContractBalance(contract); - - await getWithdrawalsPredeployedContract().setFee(3n); - expect((await contract.getWithdrawalRequestFee()) == 3n, "Test setup error: invalid withdrawal request fee."); - const fee = 3n + 1n; // 1 request * 3 gwei (fee) + 1 gwei (extra fee)= 4 gwei - - await contract.addFullWithdrawalRequests(pubkeys, { value: fee }); - expect(await getWithdrawalCredentialsContractBalance(contract)).to.equal(initialBalance); - }); - - it("Should successfully add requests and emit events", async function () { - await addFullWithdrawalRequests(1); - await addFullWithdrawalRequests(3); - await addFullWithdrawalRequests(10); - await addFullWithdrawalRequests(100); - }); - - it("Should successfully add requests with extra fee and not change contract balance", async function () { - await addFullWithdrawalRequests(1, 100n); - await addFullWithdrawalRequests(3, 1n); - await addFullWithdrawalRequests(10, 1_000_000n); - await addFullWithdrawalRequests(7, 3n); - await addFullWithdrawalRequests(100, 0n); - }); - }); -} - -export function testPartialWithdrawalRequestBehavior( - getContract: () => BaseContract & - Pick, - getWithdrawalsPredeployedContract: () => WithdrawalsPredeployed_Mock, -) { - async function addPartialWithdrawalRequests(requestCount: number, extraFee: bigint = 0n) { - const contract = getContract(); - const initialBalance = await getWithdrawalCredentialsContractBalance(contract); - - const { pubkeys, amounts } = generateWithdrawalRequestPayload(requestCount); - - const fee = (await getFee(contract, pubkeys.length)) + extraFee; - const tx = await contract.addPartialWithdrawalRequests(pubkeys, amounts, { value: fee }); - - expect(await getWithdrawalCredentialsContractBalance(contract)).to.equal(initialBalance); - - const receipt = await tx.wait(); - - const events = findEventsWithInterfaces(receipt!, "WithdrawalRequestAdded", [withdrawalRequestEventInterface]); - expect(events.length).to.equal(requestCount); - - for (let i = 0; i < requestCount; i++) { - expect(events[i].args[0]).to.equal(pubkeys[i]); - expect(events[i].args[1]).to.equal(amounts[i]); - } - } - - context("addPartialWithdrawalRequests", () => { - it("Should revert if array lengths do not match or empty arrays are provided", async function () { - const { pubkeys, amounts } = generateWithdrawalRequestPayload(2); - amounts.pop(); - - expect( - pubkeys.length !== amounts.length, - "Test setup error: pubkeys and amounts arrays should have different lengths.", - ); - - const contract = getContract(); - - const fee = await getFee(contract, pubkeys.length); - await expect(contract.addPartialWithdrawalRequests(pubkeys, amounts, { value: fee })) - .to.be.revertedWithCustomError(contract, "MismatchedArrayLengths") - .withArgs(pubkeys.length, amounts.length); - - // Also test empty arrays - await expect(contract.addPartialWithdrawalRequests([], [], { value: fee })).to.be.revertedWithCustomError( - contract, - "NoWithdrawalRequests", - ); - }); - - it("Should revert if not enough fee is sent", async function () { - const { pubkeys, amounts } = generateWithdrawalRequestPayload(1); - const contract = getContract(); - - await getWithdrawalsPredeployedContract().setFee(3n); // Set fee to 3 gwei - - // Should revert if no fee is sent - await expect(contract.addPartialWithdrawalRequests(pubkeys, amounts)).to.be.revertedWithCustomError( - contract, - "FeeNotEnough", - ); - - // Should revert if fee is less than required - const insufficientFee = 2n; - await expect( - contract.addPartialWithdrawalRequests(pubkeys, amounts, { value: insufficientFee }), - ).to.be.revertedWithCustomError(contract, "FeeNotEnough"); - }); - - it("Should revert if any pubkey is not 48 bytes", async function () { - // Invalid pubkey (only 2 bytes) - const pubkeys = ["0x1234"]; - const amounts = [100n]; - - const contract = getContract(); - const fee = await getFee(contract, pubkeys.length); - - await expect(contract.addPartialWithdrawalRequests(pubkeys, amounts, { value: fee })) - .to.be.revertedWithCustomError(contract, "InvalidPubkeyLength") - .withArgs(pubkeys[0]); - }); - - it("Should revert if addition fails at the withdrawal request contract", async function () { - const { pubkeys, amounts } = generateWithdrawalRequestPayload(1); - const contract = getContract(); - const fee = await getFee(contract, pubkeys.length); - - // Set mock to fail on add - await getWithdrawalsPredeployedContract().setFailOnAddRequest(true); - - await expect( - contract.addPartialWithdrawalRequests(pubkeys, amounts, { value: fee }), - ).to.be.revertedWithCustomError(contract, "WithdrawalRequestAdditionFailed"); - }); - - it("Should revert if full withdrawal requested", async function () { - const { pubkeys, amounts } = generateWithdrawalRequestPayload(2); - amounts[0] = 1n; // Partial withdrawal - amounts[1] = 0n; // Full withdrawal - - const contract = getContract(); - const fee = await getFee(contract, pubkeys.length); - - await expect( - contract.addPartialWithdrawalRequests(pubkeys, amounts, { value: fee }), - ).to.be.revertedWithCustomError(contract, "PartialWithdrawalRequired"); - }); - - it("Should accept exactly required fee without revert", async function () { - const requestCount = 1; - const { pubkeys, amounts } = generateWithdrawalRequestPayload(requestCount); - - const contract = getContract(); - const initialBalance = await getWithdrawalCredentialsContractBalance(contract); - - await getWithdrawalsPredeployedContract().setFee(3n); - expect((await contract.getWithdrawalRequestFee()) == 3n, "Test setup error: invalid withdrawal request fee."); - const fee = 3n; - - await contract.addPartialWithdrawalRequests(pubkeys, amounts, { value: fee }); - - expect(await getWithdrawalCredentialsContractBalance(contract)).to.equal(initialBalance); - }); - - it("Should accept exceed fee without revert", async function () { - const requestCount = 1; - const { pubkeys, amounts } = generateWithdrawalRequestPayload(requestCount); - - const contract = getContract(); - const initialBalance = await getWithdrawalCredentialsContractBalance(contract); - - await getWithdrawalsPredeployedContract().setFee(3n); - expect((await contract.getWithdrawalRequestFee()) == 3n, "Test setup error: invalid withdrawal request fee."); - const fee = 3n + 1n; // 1 request * 3 gwei (fee) + 1 gwei (extra fee)= 4 gwei - - await contract.addPartialWithdrawalRequests(pubkeys, amounts, { value: fee }); - expect(await getWithdrawalCredentialsContractBalance(contract)).to.equal(initialBalance); - }); - - it("Should successfully add requests and emit events", async function () { - await addPartialWithdrawalRequests(1); - await addPartialWithdrawalRequests(3); - await addPartialWithdrawalRequests(10); - await addPartialWithdrawalRequests(100); - }); - - it("Should successfully add requests with extra fee and not change contract balance", async function () { - await addPartialWithdrawalRequests(1, 100n); - await addPartialWithdrawalRequests(3, 1n); - await addPartialWithdrawalRequests(10, 1_000_000n); - await addPartialWithdrawalRequests(7, 3n); - await addPartialWithdrawalRequests(100, 0n); - }); - }); + return { pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts }; } diff --git a/test/0.8.9/withdrawalVault.test.ts b/test/0.8.9/withdrawalVault.test.ts index 818036201..85396970d 100644 --- a/test/0.8.9/withdrawalVault.test.ts +++ b/test/0.8.9/withdrawalVault.test.ts @@ -19,7 +19,7 @@ import { Snapshot } from "test/suite"; import { deployWithdrawalsPredeployedMock, - testFullWithdrawalRequestBehavior, + withdrawalsPredeployedHardcodedAddress, } from "./lib/withdrawalCredentials/withdrawalRequests.behavior"; const PETRIFIED_VERSION = MAX_UINT256; @@ -44,7 +44,9 @@ describe("WithdrawalVault.sol", () => { before(async () => { [owner, user, treasury, validatorsExitBus] = await ethers.getSigners(); - withdrawalsPredeployed = await deployWithdrawalsPredeployedMock(); + withdrawalsPredeployed = await deployWithdrawalsPredeployedMock(1n); + + expect(await withdrawalsPredeployed.getAddress()).to.equal(withdrawalsPredeployedHardcodedAddress); lido = await ethers.deployContract("Lido__MockForWithdrawalVault"); lidoAddress = await lido.getAddress(); @@ -195,7 +197,7 @@ describe("WithdrawalVault.sol", () => { }); }); - context("addWithdrawalRequests", () => { + context("eip 7002 withdrawal requests", () => { it("Reverts if the caller is not Validator Exit Bus", async () => { await expect(vault.connect(user).addFullWithdrawalRequests(["0x1234"])).to.be.revertedWithCustomError( vault, @@ -203,9 +205,6 @@ describe("WithdrawalVault.sol", () => { ); }); - testFullWithdrawalRequestBehavior( - () => vault.connect(validatorsExitBus), - () => withdrawalsPredeployed.connect(user), - ); + // ToDo: add tests... }); }); From 1a394bfaed7d32e48f570011367520caf2579df1 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Mon, 23 Dec 2024 14:17:02 +0100 Subject: [PATCH 04/21] feat: rename triggerable withdrawals lib --- contracts/0.8.9/WithdrawalVault.sol | 6 +- ...equests.sol => TriggerableWithdrawals.sol} | 2 +- ...sol => TriggerableWithdrawals_Harness.sol} | 14 +- .../findEvents.ts | 0 .../triggerableWithdrawals.test.ts} | 152 +++++++++--------- .../utils.ts} | 0 test/0.8.9/withdrawalVault.test.ts | 4 +- 7 files changed, 89 insertions(+), 89 deletions(-) rename contracts/0.8.9/lib/{WithdrawalRequests.sol => TriggerableWithdrawals.sol} (99%) rename test/0.8.9/contracts/{WithdrawalCredentials_Harness.sol => TriggerableWithdrawals_Harness.sol} (56%) rename test/0.8.9/lib/{withdrawalCredentials => triggerableWithdrawals}/findEvents.ts (100%) rename test/0.8.9/lib/{withdrawalCredentials/withdrawalCredentials.test.ts => triggerableWithdrawals/triggerableWithdrawals.test.ts} (61%) rename test/0.8.9/lib/{withdrawalCredentials/withdrawalRequests.behavior.ts => triggerableWithdrawals/utils.ts} (100%) diff --git a/contracts/0.8.9/WithdrawalVault.sol b/contracts/0.8.9/WithdrawalVault.sol index 0c5eaa163..9789bf54a 100644 --- a/contracts/0.8.9/WithdrawalVault.sol +++ b/contracts/0.8.9/WithdrawalVault.sol @@ -9,7 +9,7 @@ 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 {WithdrawalRequests} from "./lib/WithdrawalRequests.sol"; +import {TriggerableWithdrawals} from "./lib/TriggerableWithdrawals.sol"; interface ILido { /** @@ -141,11 +141,11 @@ contract WithdrawalVault is Versioned { revert NotValidatorExitBus(); } - WithdrawalRequests.addFullWithdrawalRequests(pubkeys, msg.value); + TriggerableWithdrawals.addFullWithdrawalRequests(pubkeys, msg.value); } function getWithdrawalRequestFee() external view returns (uint256) { - return WithdrawalRequests.getWithdrawalRequestFee(); + return TriggerableWithdrawals.getWithdrawalRequestFee(); } function _requireNonZero(address _address) internal pure { diff --git a/contracts/0.8.9/lib/WithdrawalRequests.sol b/contracts/0.8.9/lib/TriggerableWithdrawals.sol similarity index 99% rename from contracts/0.8.9/lib/WithdrawalRequests.sol rename to contracts/0.8.9/lib/TriggerableWithdrawals.sol index 8d0bc0979..ab4681983 100644 --- a/contracts/0.8.9/lib/WithdrawalRequests.sol +++ b/contracts/0.8.9/lib/TriggerableWithdrawals.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.9; -library WithdrawalRequests { +library TriggerableWithdrawals { address constant WITHDRAWAL_REQUEST = 0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA; error MismatchedArrayLengths(uint256 keysCount, uint256 amountsCount); diff --git a/test/0.8.9/contracts/WithdrawalCredentials_Harness.sol b/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol similarity index 56% rename from test/0.8.9/contracts/WithdrawalCredentials_Harness.sol rename to test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol index b5e55c299..261f1a8cd 100644 --- a/test/0.8.9/contracts/WithdrawalCredentials_Harness.sol +++ b/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol @@ -1,13 +1,13 @@ pragma solidity 0.8.9; -import {WithdrawalRequests} from "contracts/0.8.9/lib/WithdrawalRequests.sol"; +import {TriggerableWithdrawals} from "contracts/0.8.9/lib/TriggerableWithdrawals.sol"; -contract WithdrawalCredentials_Harness { +contract TriggerableWithdrawals_Harness { function addFullWithdrawalRequests( bytes[] calldata pubkeys, uint256 totalWithdrawalFee ) external { - WithdrawalRequests.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee); + TriggerableWithdrawals.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee); } function addPartialWithdrawalRequests( @@ -15,7 +15,7 @@ contract WithdrawalCredentials_Harness { uint64[] calldata amounts, uint256 totalWithdrawalFee ) external { - WithdrawalRequests.addPartialWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee); + TriggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee); } function addWithdrawalRequests( @@ -23,15 +23,15 @@ contract WithdrawalCredentials_Harness { uint64[] calldata amounts, uint256 totalWithdrawalFee ) external { - WithdrawalRequests.addWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee); + TriggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee); } function getWithdrawalRequestFee() external view returns (uint256) { - return WithdrawalRequests.getWithdrawalRequestFee(); + return TriggerableWithdrawals.getWithdrawalRequestFee(); } function getWithdrawalsContractAddress() public pure returns (address) { - return WithdrawalRequests.WITHDRAWAL_REQUEST; + return TriggerableWithdrawals.WITHDRAWAL_REQUEST; } function deposit() external payable {} diff --git a/test/0.8.9/lib/withdrawalCredentials/findEvents.ts b/test/0.8.9/lib/triggerableWithdrawals/findEvents.ts similarity index 100% rename from test/0.8.9/lib/withdrawalCredentials/findEvents.ts rename to test/0.8.9/lib/triggerableWithdrawals/findEvents.ts diff --git a/test/0.8.9/lib/withdrawalCredentials/withdrawalCredentials.test.ts b/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts similarity index 61% rename from test/0.8.9/lib/withdrawalCredentials/withdrawalCredentials.test.ts rename to test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts index 2ee973b67..ce83a2921 100644 --- a/test/0.8.9/lib/withdrawalCredentials/withdrawalCredentials.test.ts +++ b/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts @@ -5,7 +5,7 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; -import { WithdrawalCredentials_Harness, WithdrawalsPredeployed_Mock } from "typechain-types"; +import { TriggerableWithdrawals_Harness, WithdrawalsPredeployed_Mock } from "typechain-types"; import { Snapshot } from "test/suite"; @@ -14,18 +14,18 @@ import { deployWithdrawalsPredeployedMock, generateWithdrawalRequestPayload, withdrawalsPredeployedHardcodedAddress, -} from "./withdrawalRequests.behavior"; +} from "./utils"; -describe("WithdrawalCredentials.sol", () => { +describe("TriggerableWithdrawals.sol", () => { let actor: HardhatEthersSigner; let withdrawalsPredeployed: WithdrawalsPredeployed_Mock; - let withdrawalCredentials: WithdrawalCredentials_Harness; + let triggerableWithdrawals: TriggerableWithdrawals_Harness; let originalState: string; async function getWithdrawalCredentialsContractBalance(): Promise { - const contractAddress = await withdrawalCredentials.getAddress(); + const contractAddress = await triggerableWithdrawals.getAddress(); return await ethers.provider.getBalance(contractAddress); } @@ -38,11 +38,11 @@ describe("WithdrawalCredentials.sol", () => { [actor] = await ethers.getSigners(); withdrawalsPredeployed = await deployWithdrawalsPredeployedMock(1n); - withdrawalCredentials = await ethers.deployContract("WithdrawalCredentials_Harness"); + triggerableWithdrawals = await ethers.deployContract("TriggerableWithdrawals_Harness"); expect(await withdrawalsPredeployed.getAddress()).to.equal(withdrawalsPredeployedHardcodedAddress); - await withdrawalCredentials.connect(actor).deposit({ value: ethers.parseEther("1") }); + await triggerableWithdrawals.connect(actor).deposit({ value: ethers.parseEther("1") }); }); beforeEach(async () => (originalState = await Snapshot.take())); @@ -50,14 +50,14 @@ describe("WithdrawalCredentials.sol", () => { afterEach(async () => await Snapshot.restore(originalState)); async function getFee(requestsCount: number): Promise { - const fee = await withdrawalCredentials.getWithdrawalRequestFee(); + const fee = await triggerableWithdrawals.getWithdrawalRequestFee(); return ethers.parseUnits((fee * BigInt(requestsCount)).toString(), "wei"); } context("eip 7002 contract", () => { it("Should return the address of the EIP 7002 contract", async function () { - expect(await withdrawalCredentials.getWithdrawalsContractAddress()).to.equal( + expect(await triggerableWithdrawals.getWithdrawalsContractAddress()).to.equal( withdrawalsPredeployedHardcodedAddress, ); }); @@ -67,15 +67,15 @@ describe("WithdrawalCredentials.sol", () => { it("Should get fee from the EIP 7002 contract", async function () { await withdrawalsPredeployed.setFee(333n); expect( - (await withdrawalCredentials.getWithdrawalRequestFee()) == 333n, + (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(withdrawalCredentials.getWithdrawalRequestFee()).to.be.revertedWithCustomError( - withdrawalCredentials, + await expect(triggerableWithdrawals.getWithdrawalRequestFee()).to.be.revertedWithCustomError( + triggerableWithdrawals, "WithdrawalRequestFeeReadFailed", ); }); @@ -83,18 +83,18 @@ describe("WithdrawalCredentials.sol", () => { context("add withdrawal requests", () => { it("Should revert if empty arrays are provided", async function () { - await expect(withdrawalCredentials.addFullWithdrawalRequests([], 1n)).to.be.revertedWithCustomError( - withdrawalCredentials, + await expect(triggerableWithdrawals.addFullWithdrawalRequests([], 1n)).to.be.revertedWithCustomError( + triggerableWithdrawals, "NoWithdrawalRequests", ); - await expect(withdrawalCredentials.addPartialWithdrawalRequests([], [], 1n)).to.be.revertedWithCustomError( - withdrawalCredentials, + await expect(triggerableWithdrawals.addPartialWithdrawalRequests([], [], 1n)).to.be.revertedWithCustomError( + triggerableWithdrawals, "NoWithdrawalRequests", ); - await expect(withdrawalCredentials.addWithdrawalRequests([], [], 1n)).to.be.revertedWithCustomError( - withdrawalCredentials, + await expect(triggerableWithdrawals.addWithdrawalRequests([], [], 1n)).to.be.revertedWithCustomError( + triggerableWithdrawals, "NoWithdrawalRequests", ); }); @@ -105,12 +105,12 @@ describe("WithdrawalCredentials.sol", () => { const fee = await getFee(pubkeys.length); - await expect(withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, amounts, fee)) - .to.be.revertedWithCustomError(withdrawalCredentials, "MismatchedArrayLengths") + await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") .withArgs(pubkeys.length, amounts.length); - await expect(withdrawalCredentials.addWithdrawalRequests(pubkeys, amounts, fee)) - .to.be.revertedWithCustomError(withdrawalCredentials, "MismatchedArrayLengths") + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") .withArgs(pubkeys.length, amounts.length); }); @@ -121,33 +121,33 @@ describe("WithdrawalCredentials.sol", () => { await withdrawalsPredeployed.setFee(3n); // Set fee to 3 gwei // 1. Should revert if no fee is sent - await expect(withdrawalCredentials.addFullWithdrawalRequests(pubkeys, 0n)).to.be.revertedWithCustomError( - withdrawalCredentials, + await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, 0n)).to.be.revertedWithCustomError( + triggerableWithdrawals, "FeeNotEnough", ); await expect( - withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, amounts, 0n), - ).to.be.revertedWithCustomError(withdrawalCredentials, "FeeNotEnough"); + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, 0n), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "FeeNotEnough"); - await expect(withdrawalCredentials.addWithdrawalRequests(pubkeys, amounts, 0n)).to.be.revertedWithCustomError( - withdrawalCredentials, + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, 0n)).to.be.revertedWithCustomError( + triggerableWithdrawals, "FeeNotEnough", ); // 2. Should revert if fee is less than required const insufficientFee = 2n; await expect( - withdrawalCredentials.addFullWithdrawalRequests(pubkeys, insufficientFee), - ).to.be.revertedWithCustomError(withdrawalCredentials, "FeeNotEnough"); + triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, insufficientFee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "FeeNotEnough"); await expect( - withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, amounts, insufficientFee), - ).to.be.revertedWithCustomError(withdrawalCredentials, "FeeNotEnough"); + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, insufficientFee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "FeeNotEnough"); await expect( - withdrawalCredentials.addWithdrawalRequests(pubkeys, amounts, insufficientFee), - ).to.be.revertedWithCustomError(withdrawalCredentials, "FeeNotEnough"); + triggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, insufficientFee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "FeeNotEnough"); }); it("Should revert if any pubkey is not 48 bytes", async function () { @@ -157,16 +157,16 @@ describe("WithdrawalCredentials.sol", () => { const fee = await getFee(pubkeys.length); - await expect(withdrawalCredentials.addFullWithdrawalRequests(pubkeys, fee)) - .to.be.revertedWithCustomError(withdrawalCredentials, "InvalidPubkeyLength") + await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPubkeyLength") .withArgs(pubkeys[0]); - await expect(withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, amounts, fee)) - .to.be.revertedWithCustomError(withdrawalCredentials, "InvalidPubkeyLength") + await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPubkeyLength") .withArgs(pubkeys[0]); - await expect(withdrawalCredentials.addWithdrawalRequests(pubkeys, amounts, fee)) - .to.be.revertedWithCustomError(withdrawalCredentials, "InvalidPubkeyLength") + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPubkeyLength") .withArgs(pubkeys[0]); }); @@ -179,17 +179,17 @@ describe("WithdrawalCredentials.sol", () => { // Set mock to fail on add await withdrawalsPredeployed.setFailOnAddRequest(true); - await expect(withdrawalCredentials.addFullWithdrawalRequests(pubkeys, fee)).to.be.revertedWithCustomError( - withdrawalCredentials, + await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee)).to.be.revertedWithCustomError( + triggerableWithdrawals, "WithdrawalRequestAdditionFailed", ); await expect( - withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, amounts, fee), - ).to.be.revertedWithCustomError(withdrawalCredentials, "WithdrawalRequestAdditionFailed"); + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestAdditionFailed"); - await expect(withdrawalCredentials.addWithdrawalRequests(pubkeys, amounts, fee)).to.be.revertedWithCustomError( - withdrawalCredentials, + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, fee)).to.be.revertedWithCustomError( + triggerableWithdrawals, "WithdrawalRequestAdditionFailed", ); }); @@ -200,8 +200,8 @@ describe("WithdrawalCredentials.sol", () => { const fee = await getFee(pubkeys.length); await expect( - withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, amounts, fee), - ).to.be.revertedWithCustomError(withdrawalCredentials, "PartialWithdrawalRequired"); + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "PartialWithdrawalRequired"); }); it("Should revert if contract balance insufficient'", async function () { @@ -211,20 +211,20 @@ describe("WithdrawalCredentials.sol", () => { const balance = 19n; await withdrawalsPredeployed.setFee(fee); - await setBalance(await withdrawalCredentials.getAddress(), balance); + await setBalance(await triggerableWithdrawals.getAddress(), balance); - await expect(withdrawalCredentials.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee)) - .to.be.revertedWithCustomError(withdrawalCredentials, "InsufficientBalance") + await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalance") .withArgs(balance, totalWithdrawalFee); await expect( - withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee), + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee), ) - .to.be.revertedWithCustomError(withdrawalCredentials, "InsufficientBalance") + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalance") .withArgs(balance, totalWithdrawalFee); - await expect(withdrawalCredentials.addWithdrawalRequests(pubkeys, fullWithdrawalAmounts, totalWithdrawalFee)) - .to.be.revertedWithCustomError(withdrawalCredentials, "InsufficientBalance") + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, fullWithdrawalAmounts, totalWithdrawalFee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalance") .withArgs(balance, totalWithdrawalFee); }); @@ -236,9 +236,9 @@ describe("WithdrawalCredentials.sol", () => { await withdrawalsPredeployed.setFee(3n); const fee = 9n; - await withdrawalCredentials.addFullWithdrawalRequests(pubkeys, fee); - await withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); - await withdrawalCredentials.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee); + await triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee); + await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); + await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee); }); it("Should accept exceed fee without revert", async function () { @@ -249,9 +249,9 @@ describe("WithdrawalCredentials.sol", () => { await withdrawalsPredeployed.setFee(3n); const fee = 9n + 1n; // 3 request * 3 gwei (fee) + 1 gwei (extra fee)= 10 gwei - await withdrawalCredentials.addFullWithdrawalRequests(pubkeys, fee); - await withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); - await withdrawalCredentials.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee); + await triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee); + await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); + await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee); }); it("Should deduct precise fee value from contract balance", async function () { @@ -268,11 +268,11 @@ describe("WithdrawalCredentials.sol", () => { expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance - fee); }; - await testFeeDeduction(() => withdrawalCredentials.addFullWithdrawalRequests(pubkeys, fee)); + await testFeeDeduction(() => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee)); await testFeeDeduction(() => - withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), ); - await testFeeDeduction(() => withdrawalCredentials.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee)); + await testFeeDeduction(() => triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee)); }); it("Should send all fee to eip 7002 withdrawal contract", async function () { @@ -289,12 +289,12 @@ describe("WithdrawalCredentials.sol", () => { expect(await getWithdrawalsPredeployedContractBalance()).to.equal(initialBalance + totalWithdrawalFee); }; - await testFeeTransfer(() => withdrawalCredentials.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee)); + await testFeeTransfer(() => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee)); await testFeeTransfer(() => - withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee), + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee), ); await testFeeTransfer(() => - withdrawalCredentials.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, totalWithdrawalFee), + triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, totalWithdrawalFee), ); }); @@ -322,17 +322,17 @@ describe("WithdrawalCredentials.sol", () => { }; await testEventsEmit( - () => withdrawalCredentials.addFullWithdrawalRequests(pubkeys, fee), + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee), pubkeys, fullWithdrawalAmounts, ); await testEventsEmit( - () => withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), + () => triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), pubkeys, partialWithdrawalAmounts, ); await testEventsEmit( - () => withdrawalCredentials.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee), + () => triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee), pubkeys, mixedWithdrawalAmounts, ); @@ -379,7 +379,7 @@ describe("WithdrawalCredentials.sol", () => { const totalWithdrawalFee = (await getFee(pubkeys.length)) + extraFee; await addWithdrawalRequests( - () => withdrawalCredentials.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee), + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee), pubkeys, fullWithdrawalAmounts, totalWithdrawalFee, @@ -387,14 +387,14 @@ describe("WithdrawalCredentials.sol", () => { await addWithdrawalRequests( () => - withdrawalCredentials.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee), + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee), pubkeys, partialWithdrawalAmounts, totalWithdrawalFee, ); await addWithdrawalRequests( - () => withdrawalCredentials.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, totalWithdrawalFee), + () => triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, totalWithdrawalFee), pubkeys, mixedWithdrawalAmounts, totalWithdrawalFee, @@ -407,9 +407,9 @@ describe("WithdrawalCredentials.sol", () => { generateWithdrawalRequestPayload(3); const fee = await getFee(pubkeys.length); - await withdrawalCredentials.addWithdrawalRequests(pubkeys, fullWithdrawalAmounts, fee); - await withdrawalCredentials.addWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); - await withdrawalCredentials.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee); + await triggerableWithdrawals.addWithdrawalRequests(pubkeys, fullWithdrawalAmounts, fee); + await triggerableWithdrawals.addWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); + await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee); }); }); }); diff --git a/test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behavior.ts b/test/0.8.9/lib/triggerableWithdrawals/utils.ts similarity index 100% rename from test/0.8.9/lib/withdrawalCredentials/withdrawalRequests.behavior.ts rename to test/0.8.9/lib/triggerableWithdrawals/utils.ts diff --git a/test/0.8.9/withdrawalVault.test.ts b/test/0.8.9/withdrawalVault.test.ts index 85396970d..6ac41d8ac 100644 --- a/test/0.8.9/withdrawalVault.test.ts +++ b/test/0.8.9/withdrawalVault.test.ts @@ -20,7 +20,7 @@ import { Snapshot } from "test/suite"; import { deployWithdrawalsPredeployedMock, withdrawalsPredeployedHardcodedAddress, -} from "./lib/withdrawalCredentials/withdrawalRequests.behavior"; +} from "./lib/triggerableWithdrawals/utils"; const PETRIFIED_VERSION = MAX_UINT256; @@ -197,7 +197,7 @@ describe("WithdrawalVault.sol", () => { }); }); - context("eip 7002 withdrawal requests", () => { + context("eip 7002 triggerable withdrawals", () => { it("Reverts if the caller is not Validator Exit Bus", async () => { await expect(vault.connect(user).addFullWithdrawalRequests(["0x1234"])).to.be.revertedWithCustomError( vault, From 5183e89f235746c31300b5cd5542294cbd009de1 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Thu, 26 Dec 2024 14:45:41 +0100 Subject: [PATCH 05/21] feat: add unit tests for triggerable withdrawals lib --- .../WithdrawalsPredeployed_Mock.sol | 7 + .../lib/triggerableWithdrawals/findEvents.ts | 12 +- .../triggerableWithdrawals.test.ts | 223 ++++++++++++++++-- 3 files changed, 216 insertions(+), 26 deletions(-) diff --git a/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol b/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol index 6c50f7d6a..f4b580b14 100644 --- a/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol +++ b/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol @@ -9,6 +9,8 @@ contract WithdrawalsPredeployed_Mock { bool public failOnAddRequest; bool public failOnGetFee; + event eip7002WithdrawalRequestAdded(bytes request, uint256 fee); + function setFailOnAddRequest(bool _failOnAddRequest) external { failOnAddRequest = _failOnAddRequest; } @@ -33,5 +35,10 @@ contract WithdrawalsPredeployed_Mock { require(!failOnAddRequest, "fail on add request"); require(input.length == 56, "Invalid callData length"); + + emit eip7002WithdrawalRequestAdded( + input, + msg.value + ); } } diff --git a/test/0.8.9/lib/triggerableWithdrawals/findEvents.ts b/test/0.8.9/lib/triggerableWithdrawals/findEvents.ts index 9ee258139..82047e8c1 100644 --- a/test/0.8.9/lib/triggerableWithdrawals/findEvents.ts +++ b/test/0.8.9/lib/triggerableWithdrawals/findEvents.ts @@ -5,9 +5,19 @@ import { findEventsWithInterfaces } from "lib"; const withdrawalRequestEventABI = ["event WithdrawalRequestAdded(bytes pubkey, uint256 amount)"]; const withdrawalRequestEventInterface = new ethers.Interface(withdrawalRequestEventABI); - type WithdrawalRequestEvents = "WithdrawalRequestAdded"; export function findEvents(receipt: ContractTransactionReceipt, event: WithdrawalRequestEvents) { return findEventsWithInterfaces(receipt!, event, [withdrawalRequestEventInterface]); } + +const eip7002TriggerableWithdrawalMockEventABI = ["event eip7002WithdrawalRequestAdded(bytes request, uint256 fee)"]; +const eip7002TriggerableWithdrawalMockInterface = new ethers.Interface(eip7002TriggerableWithdrawalMockEventABI); +type Eip7002WithdrawalEvents = "eip7002WithdrawalRequestAdded"; + +export function findEip7002TriggerableWithdrawalMockEvents( + receipt: ContractTransactionReceipt, + event: Eip7002WithdrawalEvents, +) { + return findEventsWithInterfaces(receipt!, event, [eip7002TriggerableWithdrawalMockInterface]); +} diff --git a/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts b/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts index ce83a2921..3ae0aa3ce 100644 --- a/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts +++ b/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts @@ -9,7 +9,7 @@ import { TriggerableWithdrawals_Harness, WithdrawalsPredeployed_Mock } from "typ import { Snapshot } from "test/suite"; -import { findEvents } from "./findEvents"; +import { findEip7002TriggerableWithdrawalMockEvents, findEvents } from "./findEvents"; import { deployWithdrawalsPredeployedMock, generateWithdrawalRequestPayload, @@ -34,6 +34,8 @@ describe("TriggerableWithdrawals.sol", () => { return await ethers.provider.getBalance(contractAddress); } + const MAX_UINT64 = (1n << 64n) - 1n; + before(async () => { [actor] = await ethers.getSigners(); @@ -109,9 +111,25 @@ describe("TriggerableWithdrawals.sol", () => { .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") .withArgs(pubkeys.length, amounts.length); + await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, [], fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") + .withArgs(pubkeys.length, 0); + + await expect(triggerableWithdrawals.addPartialWithdrawalRequests([], amounts, fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") + .withArgs(0, amounts.length); + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, fee)) .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") .withArgs(pubkeys.length, amounts.length); + + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, [], fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") + .withArgs(pubkeys.length, 0); + + await expect(triggerableWithdrawals.addWithdrawalRequests([], amounts, fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") + .withArgs(0, amounts.length); }); it("Should revert if not enough fee is sent", async function () { @@ -194,7 +212,7 @@ describe("TriggerableWithdrawals.sol", () => { ); }); - it("Should revert if full withdrawal requested in 'addPartialWithdrawalRequests'", async function () { + it("Should revert when a full withdrawal amount is included in 'addPartialWithdrawalRequests'", async function () { const { pubkeys } = generateWithdrawalRequestPayload(2); const amounts = [1n, 0n]; // Partial and Full withdrawal const fee = await getFee(pubkeys.length); @@ -204,8 +222,8 @@ describe("TriggerableWithdrawals.sol", () => { ).to.be.revertedWithCustomError(triggerableWithdrawals, "PartialWithdrawalRequired"); }); - it("Should revert if contract balance insufficient'", async function () { - const { pubkeys, partialWithdrawalAmounts, fullWithdrawalAmounts } = generateWithdrawalRequestPayload(2); + it("Should revert when balance is less than total withdrawal fee", async function () { + const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(2); const fee = 10n; const totalWithdrawalFee = 20n; const balance = 19n; @@ -223,25 +241,59 @@ describe("TriggerableWithdrawals.sol", () => { .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalance") .withArgs(balance, totalWithdrawalFee); - await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, fullWithdrawalAmounts, totalWithdrawalFee)) + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, totalWithdrawalFee)) .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalance") .withArgs(balance, totalWithdrawalFee); }); - it("Should accept exactly required fee without revert", async function () { + it("Should revert when fee read fails", async function () { + await withdrawalsPredeployed.setFailOnGetFee(true); + + const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(2); + const fee = 10n; + + await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee)).to.be.revertedWithCustomError( + triggerableWithdrawals, + "WithdrawalRequestFeeReadFailed", + ); + + await expect( + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestFeeReadFailed"); + + await expect( + triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestFeeReadFailed"); + }); + + it("Should accept withdrawal requests when the provided fee matches the exact required amount", async function () { const requestCount = 3; const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); await withdrawalsPredeployed.setFee(3n); - const fee = 9n; + const totalWithdrawalFee = 9n; - await triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee); - await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); - await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee); + await triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee); + await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee); + await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, totalWithdrawalFee); + + // Check extremely high fee + await withdrawalsPredeployed.setFee(ethers.parseEther("10")); + const largeTotalWithdrawalFee = ethers.parseEther("30"); + + await triggerableWithdrawals.connect(actor).deposit({ value: largeTotalWithdrawalFee * BigInt(requestCount) }); + + await triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, largeTotalWithdrawalFee); + await triggerableWithdrawals.addPartialWithdrawalRequests( + pubkeys, + partialWithdrawalAmounts, + largeTotalWithdrawalFee, + ); + await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, largeTotalWithdrawalFee); }); - it("Should accept exceed fee without revert", async function () { + it("Should accept withdrawal requests when the provided fee exceeds the required amount", async function () { const requestCount = 3; const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); @@ -252,9 +304,21 @@ describe("TriggerableWithdrawals.sol", () => { await triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee); await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee); + + // Check when the provided fee extremely exceeds the required amount + const largeTotalWithdrawalFee = ethers.parseEther("10"); + await triggerableWithdrawals.connect(actor).deposit({ value: largeTotalWithdrawalFee * BigInt(requestCount) }); + + await triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, largeTotalWithdrawalFee); + await triggerableWithdrawals.addPartialWithdrawalRequests( + pubkeys, + partialWithdrawalAmounts, + largeTotalWithdrawalFee, + ); + await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, largeTotalWithdrawalFee); }); - it("Should deduct precise fee value from contract balance", async function () { + it("Should correctly deduct the exact fee amount from the contract balance", async function () { const requestCount = 3; const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); @@ -275,7 +339,7 @@ describe("TriggerableWithdrawals.sol", () => { await testFeeDeduction(() => triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee)); }); - it("Should send all fee to eip 7002 withdrawal contract", async function () { + it("Should transfer the total calculated fee to the EIP-7002 withdrawal contract", async function () { const requestCount = 3; const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); @@ -298,7 +362,25 @@ describe("TriggerableWithdrawals.sol", () => { ); }); - it("should emit a 'WithdrawalRequestAdded' event when a new withdrawal request is added", async function () { + it("Should accept full, partial, and mixed withdrawal requests via 'addWithdrawalRequests' function", async function () { + const { pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(3); + const fee = await getFee(pubkeys.length); + + await triggerableWithdrawals.addWithdrawalRequests(pubkeys, fullWithdrawalAmounts, fee); + await triggerableWithdrawals.addWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); + await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee); + }); + + it("Should handle maximum uint64 withdrawal amount in partial withdrawal requests", async function () { + const { pubkeys } = generateWithdrawalRequestPayload(1); + const amounts = [MAX_UINT64]; + + await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, 10n); + await triggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, 10n); + }); + + it("Should emit a 'WithdrawalRequestAdded' event when a new withdrawal request is added", async function () { const requestCount = 3; const { pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); @@ -338,6 +420,95 @@ describe("TriggerableWithdrawals.sol", () => { ); }); + it("Should verify correct fee distribution among requests", async function () { + await withdrawalsPredeployed.setFee(2n); + + const requestCount = 5; + const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + const testFeeDistribution = async (totalWithdrawalFee: bigint, expectedFeePerRequest: bigint[]) => { + const checkEip7002MockEvents = async (addRequests: () => Promise) => { + const tx = await addRequests(); + + const receipt = await tx.wait(); + const events = findEip7002TriggerableWithdrawalMockEvents(receipt!, "eip7002WithdrawalRequestAdded"); + expect(events.length).to.equal(requestCount); + + for (let i = 0; i < requestCount; i++) { + expect(events[i].args[1]).to.equal(expectedFeePerRequest[i]); + } + }; + + await checkEip7002MockEvents(() => + triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee), + ); + + await checkEip7002MockEvents(() => + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee), + ); + + await checkEip7002MockEvents(() => + triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, totalWithdrawalFee), + ); + }; + + await testFeeDistribution(10n, [2n, 2n, 2n, 2n, 2n]); + await testFeeDistribution(11n, [2n, 2n, 2n, 2n, 3n]); + await testFeeDistribution(14n, [2n, 2n, 2n, 2n, 6n]); + await testFeeDistribution(15n, [3n, 3n, 3n, 3n, 3n]); + }); + + it("Should ensure withdrawal requests are encoded as expected with a 48-byte pubkey and 8-byte amount", async function () { + const requestCount = 16; + const { pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + const totalWithdrawalFee = 333n; + + const normalize = (hex: string) => (hex.startsWith("0x") ? hex.slice(2).toLowerCase() : hex.toLowerCase()); + + const testEncoding = async ( + addRequests: () => Promise, + expectedPubKeys: string[], + expectedAmounts: bigint[], + ) => { + const tx = await addRequests(); + + const receipt = await tx.wait(); + + const events = findEip7002TriggerableWithdrawalMockEvents(receipt!, "eip7002WithdrawalRequestAdded"); + 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(normalize(encodedRequest.substring(0, 98))).to.equal(normalize(expectedPubKeys[i])); + expect(normalize(encodedRequest.substring(98, 114))).to.equal( + expectedAmounts[i].toString(16).padStart(16, "0"), + ); + } + }; + + await testEncoding( + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee), + pubkeys, + fullWithdrawalAmounts, + ); + await testEncoding( + () => + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee), + pubkeys, + partialWithdrawalAmounts, + ); + await testEncoding( + () => triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, totalWithdrawalFee), + pubkeys, + mixedWithdrawalAmounts, + ); + }); + async function addWithdrawalRequests( addRequests: () => Promise, expectedPubkeys: string[], @@ -359,16 +530,28 @@ describe("TriggerableWithdrawals.sol", () => { expect(events[i].args[0]).to.equal(expectedPubkeys[i]); expect(events[i].args[1]).to.equal(expectedAmounts[i]); } + + const eip7002TriggerableWithdrawalMockEvents = findEip7002TriggerableWithdrawalMockEvents( + receipt!, + "eip7002WithdrawalRequestAdded", + ); + expect(eip7002TriggerableWithdrawalMockEvents.length).to.equal(expectedPubkeys.length); + for (let i = 0; i < expectedPubkeys.length; i++) { + expect(eip7002TriggerableWithdrawalMockEvents[i].args[0]).to.equal( + expectedPubkeys[i].concat(expectedAmounts[i].toString(16).padStart(16, "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: 1_000_000n }, + { requestCount: 10, extraFee: 100_000_000_000n }, { requestCount: 100, extraFee: 0n }, ]; @@ -401,15 +584,5 @@ describe("TriggerableWithdrawals.sol", () => { ); }); }); - - it("Should accept full and partial withdrawals requested", async function () { - const { pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = - generateWithdrawalRequestPayload(3); - const fee = await getFee(pubkeys.length); - - await triggerableWithdrawals.addWithdrawalRequests(pubkeys, fullWithdrawalAmounts, fee); - await triggerableWithdrawals.addWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); - await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee); - }); }); }); From 2fc90ece48aaba7ec6871c6483b4f15562de7fd2 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Thu, 26 Dec 2024 16:19:06 +0100 Subject: [PATCH 06/21] feat: add unit tests for triggerable withdrawals in the withdrawal vault contract --- .../triggerableWithdrawals.test.ts | 4 +- test/0.8.9/withdrawalVault.test.ts | 268 +++++++++++++++++- 2 files changed, 267 insertions(+), 5 deletions(-) diff --git a/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts b/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts index 3ae0aa3ce..83c57ca26 100644 --- a/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts +++ b/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts @@ -65,7 +65,7 @@ describe("TriggerableWithdrawals.sol", () => { }); }); - context("get withdrawal request fee", () => { + context("get triggerable withdrawal request fee", () => { it("Should get fee from the EIP 7002 contract", async function () { await withdrawalsPredeployed.setFee(333n); expect( @@ -83,7 +83,7 @@ describe("TriggerableWithdrawals.sol", () => { }); }); - context("add withdrawal requests", () => { + context("add triggerable withdrawal requests", () => { it("Should revert if empty arrays are provided", async function () { await expect(triggerableWithdrawals.addFullWithdrawalRequests([], 1n)).to.be.revertedWithCustomError( triggerableWithdrawals, diff --git a/test/0.8.9/withdrawalVault.test.ts b/test/0.8.9/withdrawalVault.test.ts index 6ac41d8ac..9402b7f66 100644 --- a/test/0.8.9/withdrawalVault.test.ts +++ b/test/0.8.9/withdrawalVault.test.ts @@ -17,8 +17,10 @@ import { MAX_UINT256, proxify } from "lib"; import { Snapshot } from "test/suite"; +import { findEip7002TriggerableWithdrawalMockEvents, findEvents } from "./lib/triggerableWithdrawals/findEvents"; import { deployWithdrawalsPredeployedMock, + generateWithdrawalRequestPayload, withdrawalsPredeployedHardcodedAddress, } from "./lib/triggerableWithdrawals/utils"; @@ -197,14 +199,274 @@ describe("WithdrawalVault.sol", () => { }); }); - context("eip 7002 triggerable withdrawals", () => { - it("Reverts if the caller is not Validator Exit Bus", async () => { + 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(requestsCount: number): Promise { + const fee = await vault.getWithdrawalRequestFee(); + + return ethers.parseUnits((fee * BigInt(requestsCount)).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", () => { + it("Should revert if the caller is not Validator Exit Bus", async () => { await expect(vault.connect(user).addFullWithdrawalRequests(["0x1234"])).to.be.revertedWithCustomError( vault, "NotValidatorExitBus", ); }); - // ToDo: add tests... + it("Should revert if empty arrays are provided", async function () { + await expect( + vault.connect(validatorsExitBus).addFullWithdrawalRequests([], { value: 1n }), + ).to.be.revertedWithCustomError(vault, "NoWithdrawalRequests"); + }); + + it("Should revert if not enough fee is sent", async function () { + const { pubkeys } = 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(pubkeys)).to.be.revertedWithCustomError( + vault, + "FeeNotEnough", + ); + + // 2. Should revert if fee is less than required + const insufficientFee = 2n; + await expect( + vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: insufficientFee }), + ).to.be.revertedWithCustomError(vault, "FeeNotEnough"); + }); + + it("Should revert if any pubkey is not 48 bytes", async function () { + // Invalid pubkey (only 2 bytes) + const pubkeys = ["0x1234"]; + + const fee = await getFee(pubkeys.length); + + await expect(vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: fee })) + .to.be.revertedWithCustomError(vault, "InvalidPubkeyLength") + .withArgs(pubkeys[0]); + }); + + it("Should revert if addition fails at the withdrawal request contract", async function () { + const { pubkeys } = generateWithdrawalRequestPayload(1); + const fee = await getFee(pubkeys.length); + + // Set mock to fail on add + await withdrawalsPredeployed.setFailOnAddRequest(true); + + await expect( + vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: fee }), + ).to.be.revertedWithCustomError(vault, "WithdrawalRequestAdditionFailed"); + }); + + it("Should revert when fee read fails", async function () { + await withdrawalsPredeployed.setFailOnGetFee(true); + + const { pubkeys } = generateWithdrawalRequestPayload(2); + const fee = 10n; + + await expect( + vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { 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 { pubkeys } = generateWithdrawalRequestPayload(requestCount); + + await withdrawalsPredeployed.setFee(3n); + const totalWithdrawalFee = 9n; + + await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: totalWithdrawalFee }); + + // Check extremely high fee + await withdrawalsPredeployed.setFee(ethers.parseEther("10")); + const largeTotalWithdrawalFee = ethers.parseEther("30"); + + await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: largeTotalWithdrawalFee }); + }); + + it("Should accept withdrawal requests when the provided fee exceeds the required amount", async function () { + const requestCount = 3; + const { pubkeys } = generateWithdrawalRequestPayload(requestCount); + + await withdrawalsPredeployed.setFee(3n); + const fee = 9n + 1n; // 3 request * 3 gwei (fee) + 1 gwei (extra fee)= 10 gwei + + await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: fee }); + + // Check when the provided fee extremely exceeds the required amount + const largeTotalWithdrawalFee = ethers.parseEther("10"); + + await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: largeTotalWithdrawalFee }); + }); + + it("Should correctly deduct the exact fee amount from the contract balance", async function () { + const requestCount = 3; + const { pubkeys } = generateWithdrawalRequestPayload(requestCount); + + await withdrawalsPredeployed.setFee(3n); + const fee = 9n + 1n; // 3 requests * 3 gwei (fee) + 1 gwei (extra fee) = 10 gwei + + const initialBalance = await getWithdrawalCredentialsContractBalance(); + await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: fee }); + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); + }); + + it("Should transfer the total calculated fee to the EIP-7002 withdrawal contract", async function () { + const requestCount = 3; + const { pubkeys } = generateWithdrawalRequestPayload(requestCount); + + await withdrawalsPredeployed.setFee(3n); + const totalWithdrawalFee = 9n + 1n; + + const initialBalance = await getWithdrawalsPredeployedContractBalance(); + await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: totalWithdrawalFee }); + expect(await getWithdrawalsPredeployedContractBalance()).to.equal(initialBalance + totalWithdrawalFee); + }); + + it("Should emit a 'WithdrawalRequestAdded' event when a new withdrawal request is added", async function () { + const requestCount = 3; + const { pubkeys, fullWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); + const fee = 10n; + + const tx = await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: fee }); + + const receipt = await tx.wait(); + const events = findEvents(receipt!, "WithdrawalRequestAdded"); + expect(events.length).to.equal(requestCount); + + for (let i = 0; i < requestCount; i++) { + expect(events[i].args[0]).to.equal(pubkeys[i]); + expect(events[i].args[1]).to.equal(fullWithdrawalAmounts[i]); + } + }); + + it("Should verify correct fee distribution among requests", async function () { + await withdrawalsPredeployed.setFee(2n); + + const requestCount = 5; + const { pubkeys } = generateWithdrawalRequestPayload(requestCount); + + const testFeeDistribution = async (totalWithdrawalFee: bigint, expectedFeePerRequest: bigint[]) => { + const tx = await vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeys, { value: totalWithdrawalFee }); + + const receipt = await tx.wait(); + const events = findEip7002TriggerableWithdrawalMockEvents(receipt!, "eip7002WithdrawalRequestAdded"); + expect(events.length).to.equal(requestCount); + + for (let i = 0; i < requestCount; i++) { + expect(events[i].args[1]).to.equal(expectedFeePerRequest[i]); + } + }; + + await testFeeDistribution(10n, [2n, 2n, 2n, 2n, 2n]); + await testFeeDistribution(11n, [2n, 2n, 2n, 2n, 3n]); + await testFeeDistribution(14n, [2n, 2n, 2n, 2n, 6n]); + await testFeeDistribution(15n, [3n, 3n, 3n, 3n, 3n]); + }); + + it("Should ensure withdrawal requests are encoded as expected with a 48-byte pubkey and 8-byte amount", async function () { + const requestCount = 16; + const { pubkeys } = generateWithdrawalRequestPayload(requestCount); + const totalWithdrawalFee = 333n; + + const normalize = (hex: string) => (hex.startsWith("0x") ? hex.slice(2).toLowerCase() : hex.toLowerCase()); + + const tx = await vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeys, { value: totalWithdrawalFee }); + + const receipt = await tx.wait(); + + const events = findEip7002TriggerableWithdrawalMockEvents(receipt!, "eip7002WithdrawalRequestAdded"); + 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(normalize(encodedRequest.substring(0, 98))).to.equal(normalize(pubkeys[i])); + expect(normalize(encodedRequest.substring(98, 114))).to.equal("0".repeat(16)); + } + }); + + 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} and emit events`, async () => { + const { pubkeys } = generateWithdrawalRequestPayload(requestCount); + const totalWithdrawalFee = (await getFee(pubkeys.length)) + extraFee; + + const initialBalance = await getWithdrawalCredentialsContractBalance(); + + const tx = await vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeys, { value: totalWithdrawalFee }); + + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); + + const receipt = await tx.wait(); + + const events = findEvents(receipt!, "WithdrawalRequestAdded"); + expect(events.length).to.equal(pubkeys.length); + + for (let i = 0; i < pubkeys.length; i++) { + expect(events[i].args[0]).to.equal(pubkeys[i]); + expect(events[i].args[1]).to.equal(0); + } + + const eip7002TriggerableWithdrawalMockEvents = findEip7002TriggerableWithdrawalMockEvents( + receipt!, + "eip7002WithdrawalRequestAdded", + ); + expect(eip7002TriggerableWithdrawalMockEvents.length).to.equal(pubkeys.length); + for (let i = 0; i < pubkeys.length; i++) { + expect(eip7002TriggerableWithdrawalMockEvents[i].args[0]).to.equal(pubkeys[i].concat("0".repeat(16))); + } + }); + }); }); }); From 5888facad18ad425aba9f36f827790cf35d77e1a Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Fri, 10 Jan 2025 12:24:25 +0100 Subject: [PATCH 07/21] feat: use lido locator instead of direct VEB address --- contracts/0.8.9/WithdrawalVault.sol | 11 ++++++----- test/0.8.9/withdrawalVault.test.ts | 12 ++++++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/contracts/0.8.9/WithdrawalVault.sol b/contracts/0.8.9/WithdrawalVault.sol index 9789bf54a..350d6bd1a 100644 --- a/contracts/0.8.9/WithdrawalVault.sol +++ b/contracts/0.8.9/WithdrawalVault.sol @@ -10,6 +10,7 @@ import "@openzeppelin/contracts-v4.4/token/ERC20/utils/SafeERC20.sol"; import {Versioned} from "./utils/Versioned.sol"; import {TriggerableWithdrawals} from "./lib/TriggerableWithdrawals.sol"; +import { ILidoLocator } from "../common/interfaces/ILidoLocator.sol"; interface ILido { /** @@ -28,7 +29,7 @@ contract WithdrawalVault is Versioned { ILido public immutable LIDO; address public immutable TREASURY; - address public immutable VALIDATORS_EXIT_BUS; + ILidoLocator public immutable LOCATOR; // Events /** @@ -54,14 +55,14 @@ contract WithdrawalVault is Versioned { * @param _lido the Lido token (stETH) address * @param _treasury the Lido treasury address (see ERC20/ERC721-recovery interfaces) */ - constructor(address _lido, address _treasury, address _validatorsExitBus) { + constructor(address _lido, address _treasury, address _locator) { _requireNonZero(_lido); _requireNonZero(_treasury); - _requireNonZero(_validatorsExitBus); + _requireNonZero(_locator); LIDO = ILido(_lido); TREASURY = _treasury; - VALIDATORS_EXIT_BUS = _validatorsExitBus; + LOCATOR = ILidoLocator(_locator); } /** @@ -137,7 +138,7 @@ contract WithdrawalVault is Versioned { function addFullWithdrawalRequests( bytes[] calldata pubkeys ) external payable { - if(msg.sender != address(VALIDATORS_EXIT_BUS)) { + if(msg.sender != LOCATOR.validatorsExitBusOracle()) { revert NotValidatorExitBus(); } diff --git a/test/0.8.9/withdrawalVault.test.ts b/test/0.8.9/withdrawalVault.test.ts index 9402b7f66..3069e0493 100644 --- a/test/0.8.9/withdrawalVault.test.ts +++ b/test/0.8.9/withdrawalVault.test.ts @@ -9,12 +9,14 @@ import { ERC20__Harness, ERC721__Harness, Lido__MockForWithdrawalVault, + LidoLocator, WithdrawalsPredeployed_Mock, WithdrawalVault, } from "typechain-types"; import { MAX_UINT256, proxify } from "lib"; +import { deployLidoLocator } from "test/deploy"; import { Snapshot } from "test/suite"; import { findEip7002TriggerableWithdrawalMockEvents, findEvents } from "./lib/triggerableWithdrawals/findEvents"; @@ -37,6 +39,9 @@ describe("WithdrawalVault.sol", () => { let lido: Lido__MockForWithdrawalVault; let lidoAddress: string; + let locator: LidoLocator; + let locatorAddress: string; + let withdrawalsPredeployed: WithdrawalsPredeployed_Mock; let impl: WithdrawalVault; @@ -53,7 +58,10 @@ describe("WithdrawalVault.sol", () => { lido = await ethers.deployContract("Lido__MockForWithdrawalVault"); lidoAddress = await lido.getAddress(); - impl = await ethers.deployContract("WithdrawalVault", [lidoAddress, treasury.address, validatorsExitBus.address]); + locator = await deployLidoLocator({ lido, validatorsExitBusOracle: validatorsExitBus }); + locatorAddress = await locator.getAddress(); + + impl = await ethers.deployContract("WithdrawalVault", [lidoAddress, treasury.address, locatorAddress]); [vault] = await proxify({ impl, admin: owner }); @@ -86,7 +94,7 @@ describe("WithdrawalVault.sol", () => { it("Sets initial properties", async () => { expect(await vault.LIDO()).to.equal(lidoAddress, "Lido address"); expect(await vault.TREASURY()).to.equal(treasury.address, "Treasury address"); - expect(await vault.VALIDATORS_EXIT_BUS()).to.equal(validatorsExitBus.address, "Validator exit bus address"); + expect(await vault.LOCATOR()).to.equal(locatorAddress, "Validator exit bus address"); }); it("Petrifies the implementation", async () => { From c251b90a7aeef171b419bac4397e58b4f13ea94c Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Tue, 14 Jan 2025 15:13:51 +0100 Subject: [PATCH 08/21] feat: add access control to WithdrawalVault contract Add role ADD_FULL_WITHDRAWAL_REQUEST_ROLE for full withdrawal requests. --- contracts/0.8.9/WithdrawalVault.sol | 45 +++--- .../0120-initialize-non-aragon-contracts.ts | 5 + .../contracts/WithdrawalVault__Harness.sol | 15 ++ test/0.8.9/withdrawalVault.test.ts | 149 +++++++++++++----- 4 files changed, 154 insertions(+), 60 deletions(-) create mode 100644 test/0.8.9/contracts/WithdrawalVault__Harness.sol diff --git a/contracts/0.8.9/WithdrawalVault.sol b/contracts/0.8.9/WithdrawalVault.sol index 350d6bd1a..0e8b7dc06 100644 --- a/contracts/0.8.9/WithdrawalVault.sol +++ b/contracts/0.8.9/WithdrawalVault.sol @@ -9,6 +9,7 @@ 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"; @@ -24,12 +25,13 @@ 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; - ILidoLocator public immutable LOCATOR; + + bytes32 public constant ADD_FULL_WITHDRAWAL_REQUEST_ROLE = keccak256("ADD_FULL_WITHDRAWAL_REQUEST_ROLE"); // Events /** @@ -47,7 +49,6 @@ contract WithdrawalVault is Versioned { // Errors error ZeroAddress(); error NotLido(); - error NotValidatorExitBus(); error NotEnoughEther(uint256 requested, uint256 balance); error ZeroAmount(); @@ -55,27 +56,32 @@ contract WithdrawalVault is Versioned { * @param _lido the Lido token (stETH) address * @param _treasury the Lido treasury address (see ERC20/ERC721-recovery interfaces) */ - constructor(address _lido, address _treasury, address _locator) { + constructor(address _lido, address _treasury) { _requireNonZero(_lido); _requireNonZero(_treasury); - _requireNonZero(_locator); LIDO = ILido(_lido); TREASURY = _treasury; - LOCATOR = ILidoLocator(_locator); } - /** - * @notice Initialize the contract explicitly. - * Sets the contract version to '1'. - */ - function initialize() external { - _initializeContractVersionTo(1); - _updateContractVersion(2); + /// @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); } - function finalizeUpgrade_v2() external { + /// @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); } @@ -137,11 +143,7 @@ contract WithdrawalVault is Versioned { */ function addFullWithdrawalRequests( bytes[] calldata pubkeys - ) external payable { - if(msg.sender != LOCATOR.validatorsExitBusOracle()) { - revert NotValidatorExitBus(); - } - + ) external payable onlyRole(ADD_FULL_WITHDRAWAL_REQUEST_ROLE) { TriggerableWithdrawals.addFullWithdrawalRequests(pubkeys, msg.value); } @@ -152,4 +154,9 @@ contract WithdrawalVault is Versioned { 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/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/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/withdrawalVault.test.ts b/test/0.8.9/withdrawalVault.test.ts index 3069e0493..0ed3542dd 100644 --- a/test/0.8.9/withdrawalVault.test.ts +++ b/test/0.8.9/withdrawalVault.test.ts @@ -9,14 +9,12 @@ import { ERC20__Harness, ERC721__Harness, Lido__MockForWithdrawalVault, - LidoLocator, WithdrawalsPredeployed_Mock, - WithdrawalVault, + WithdrawalVault__Harness, } from "typechain-types"; -import { MAX_UINT256, proxify } from "lib"; +import { MAX_UINT256, proxify, streccak } from "lib"; -import { deployLidoLocator } from "test/deploy"; import { Snapshot } from "test/suite"; import { findEip7002TriggerableWithdrawalMockEvents, findEvents } from "./lib/triggerableWithdrawals/findEvents"; @@ -28,28 +26,27 @@ import { 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 locator: LidoLocator; - let locatorAddress: string; - let withdrawalsPredeployed: WithdrawalsPredeployed_Mock; - let impl: WithdrawalVault; - let vault: WithdrawalVault; + let impl: WithdrawalVault__Harness; + let vault: WithdrawalVault__Harness; let vaultAddress: string; before(async () => { - [owner, user, treasury, validatorsExitBus] = await ethers.getSigners(); + [owner, treasury, validatorsExitBus, stranger] = await ethers.getSigners(); withdrawalsPredeployed = await deployWithdrawalsPredeployedMock(1n); @@ -58,13 +55,9 @@ describe("WithdrawalVault.sol", () => { lido = await ethers.deployContract("Lido__MockForWithdrawalVault"); lidoAddress = await lido.getAddress(); - locator = await deployLidoLocator({ lido, validatorsExitBusOracle: validatorsExitBus }); - locatorAddress = await locator.getAddress(); - - impl = await ethers.deployContract("WithdrawalVault", [lidoAddress, treasury.address, locatorAddress]); + impl = await ethers.deployContract("WithdrawalVault__Harness", [lidoAddress, treasury.address], owner); [vault] = await proxify({ impl, admin: owner }); - vaultAddress = await vault.getAddress(); }); @@ -75,26 +68,20 @@ describe("WithdrawalVault.sol", () => { context("Constructor", () => { it("Reverts if the Lido address is zero", async () => { await expect( - ethers.deployContract("WithdrawalVault", [ZeroAddress, treasury.address, validatorsExitBus.address]), + ethers.deployContract("WithdrawalVault", [ZeroAddress, treasury.address]), ).to.be.revertedWithCustomError(vault, "ZeroAddress"); }); it("Reverts if the treasury address is zero", async () => { - await expect( - ethers.deployContract("WithdrawalVault", [lidoAddress, ZeroAddress, validatorsExitBus.address]), - ).to.be.revertedWithCustomError(vault, "ZeroAddress"); - }); - - it("Reverts if the validator exit buss address is zero", async () => { - await expect( - ethers.deployContract("WithdrawalVault", [lidoAddress, treasury.address, ZeroAddress]), - ).to.be.revertedWithCustomError(vault, "ZeroAddress"); + await expect(ethers.deployContract("WithdrawalVault", [lidoAddress, ZeroAddress])).to.be.revertedWithCustomError( + vault, + "ZeroAddress", + ); }); it("Sets initial properties", async () => { expect(await vault.LIDO()).to.equal(lidoAddress, "Lido address"); expect(await vault.TREASURY()).to.equal(treasury.address, "Treasury address"); - expect(await vault.LOCATOR()).to.equal(locatorAddress, "Validator exit bus address"); }); it("Petrifies the implementation", async () => { @@ -107,26 +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) - .and.to.emit(vault, "ContractVersionSet") - .withArgs(2); + 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 () => { @@ -242,11 +305,15 @@ describe("WithdrawalVault.sol", () => { } 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(user).addFullWithdrawalRequests(["0x1234"])).to.be.revertedWithCustomError( - vault, - "NotValidatorExitBus", - ); + 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 () { From 1b2dd97db2da66e569c4cfc013b5ee255daf1bf4 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Wed, 15 Jan 2025 09:45:39 +0100 Subject: [PATCH 09/21] refactor: remove unnecessary memory allocation Access pubkeys and amounts directly instead of copying them to memory. --- contracts/0.8.9/lib/TriggerableWithdrawals.sol | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/contracts/0.8.9/lib/TriggerableWithdrawals.sol b/contracts/0.8.9/lib/TriggerableWithdrawals.sol index ab4681983..875b7beb7 100644 --- a/contracts/0.8.9/lib/TriggerableWithdrawals.sol +++ b/contracts/0.8.9/lib/TriggerableWithdrawals.sol @@ -111,11 +111,8 @@ library TriggerableWithdrawals { uint256 prevBalance = address(this).balance - totalWithdrawalFee; for (uint256 i = 0; i < keysCount; ++i) { - bytes memory pubkey = pubkeys[i]; - uint64 amount = amounts[i]; - - if(pubkey.length != 48) { - revert InvalidPubkeyLength(pubkey); + if(pubkeys[i].length != 48) { + revert InvalidPubkeyLength(pubkeys[i]); } uint256 feeToSend = feePerRequest; @@ -124,14 +121,14 @@ library TriggerableWithdrawals { feeToSend += unallocatedFee; } - bytes memory callData = abi.encodePacked(pubkey, amount); + bytes memory callData = abi.encodePacked(pubkeys[i], amounts[i]); (bool success, ) = WITHDRAWAL_REQUEST.call{value: feeToSend}(callData); if (!success) { - revert WithdrawalRequestAdditionFailed(pubkey, amount); + revert WithdrawalRequestAdditionFailed(pubkeys[i], amounts[i]); } - emit WithdrawalRequestAdded(pubkey, amount); + emit WithdrawalRequestAdded(pubkeys[i], amounts[i]); } assert(address(this).balance == prevBalance); From d26dddced348163edfc490794638496f8e07a68c Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Fri, 17 Jan 2025 12:06:21 +0100 Subject: [PATCH 10/21] feat: specify fee per request instead of total fee in TW library --- contracts/0.8.9/WithdrawalVault.sol | 20 +- .../0.8.9/lib/TriggerableWithdrawals.sol | 45 ++-- .../TriggerableWithdrawals_Harness.sol | 12 +- .../triggerableWithdrawals.test.ts | 200 ++++++++---------- test/0.8.9/withdrawalVault.test.ts | 98 +++++---- 5 files changed, 186 insertions(+), 189 deletions(-) diff --git a/contracts/0.8.9/WithdrawalVault.sol b/contracts/0.8.9/WithdrawalVault.sol index 0e8b7dc06..f9f060e54 100644 --- a/contracts/0.8.9/WithdrawalVault.sol +++ b/contracts/0.8.9/WithdrawalVault.sol @@ -52,6 +52,8 @@ contract WithdrawalVault is AccessControlEnumerable, Versioned { error NotEnoughEther(uint256 requested, uint256 balance); error ZeroAmount(); + error InsufficientTriggerableWithdrawalFee(uint256 providedTotalFee, uint256 requiredTotalFee, uint256 requestCount); + /** * @param _lido the Lido token (stETH) address * @param _treasury the Lido treasury address (see ERC20/ERC721-recovery interfaces) @@ -144,7 +146,23 @@ contract WithdrawalVault is AccessControlEnumerable, Versioned { function addFullWithdrawalRequests( bytes[] calldata pubkeys ) external payable onlyRole(ADD_FULL_WITHDRAWAL_REQUEST_ROLE) { - TriggerableWithdrawals.addFullWithdrawalRequests(pubkeys, msg.value); + uint256 prevBalance = address(this).balance - msg.value; + + uint256 minFeePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); + uint256 totalFee = pubkeys.length * minFeePerRequest; + + if(totalFee > msg.value) { + revert InsufficientTriggerableWithdrawalFee(msg.value, totalFee, pubkeys.length); + } + + TriggerableWithdrawals.addFullWithdrawalRequests(pubkeys, minFeePerRequest); + + uint256 refund = msg.value - totalFee; + if (refund > 0) { + msg.sender.call{value: refund}(""); + } + + assert(address(this).balance == prevBalance); } function getWithdrawalRequestFee() external view returns (uint256) { diff --git a/contracts/0.8.9/lib/TriggerableWithdrawals.sol b/contracts/0.8.9/lib/TriggerableWithdrawals.sol index 875b7beb7..ff3bd43b4 100644 --- a/contracts/0.8.9/lib/TriggerableWithdrawals.sol +++ b/contracts/0.8.9/lib/TriggerableWithdrawals.sol @@ -8,7 +8,7 @@ library TriggerableWithdrawals { error MismatchedArrayLengths(uint256 keysCount, uint256 amountsCount); error InsufficientBalance(uint256 balance, uint256 totalWithdrawalFee); - error FeeNotEnough(uint256 minFeePerRequest, uint256 requestCount, uint256 providedTotalFee); + error InsufficientRequestFee(uint256 feePerRequest, uint256 minFeePerRequest); error WithdrawalRequestFeeReadFailed(); error InvalidPubkeyLength(bytes pubkey); @@ -25,10 +25,10 @@ library TriggerableWithdrawals { */ function addFullWithdrawalRequests( bytes[] calldata pubkeys, - uint256 totalWithdrawalFee + uint256 feePerRequest ) internal { uint64[] memory amounts = new uint64[](pubkeys.length); - _addWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee); + _addWithdrawalRequests(pubkeys, amounts, feePerRequest); } /** @@ -43,7 +43,7 @@ library TriggerableWithdrawals { function addPartialWithdrawalRequests( bytes[] calldata pubkeys, uint64[] calldata amounts, - uint256 totalWithdrawalFee + uint256 feePerRequest ) internal { _requireArrayLengthsMatch(pubkeys, amounts); @@ -53,7 +53,7 @@ library TriggerableWithdrawals { } } - _addWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee); + _addWithdrawalRequests(pubkeys, amounts, feePerRequest); } /** @@ -67,10 +67,10 @@ library TriggerableWithdrawals { function addWithdrawalRequests( bytes[] calldata pubkeys, uint64[] calldata amounts, - uint256 totalWithdrawalFee + uint256 feePerRequest ) internal { _requireArrayLengthsMatch(pubkeys, amounts); - _addWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee); + _addWithdrawalRequests(pubkeys, amounts, feePerRequest); } /** @@ -90,39 +90,36 @@ library TriggerableWithdrawals { function _addWithdrawalRequests( bytes[] calldata pubkeys, uint64[] memory amounts, - uint256 totalWithdrawalFee + uint256 feePerRequest ) internal { uint256 keysCount = pubkeys.length; if (keysCount == 0) { revert NoWithdrawalRequests(); } - if(address(this).balance < totalWithdrawalFee) { - revert InsufficientBalance(address(this).balance, totalWithdrawalFee); + uint256 minFeePerRequest = getWithdrawalRequestFee(); + + if (feePerRequest == 0) { + feePerRequest = minFeePerRequest; } - uint256 minFeePerRequest = getWithdrawalRequestFee(); - if (minFeePerRequest * keysCount > totalWithdrawalFee) { - revert FeeNotEnough(minFeePerRequest, keysCount, totalWithdrawalFee); + if (feePerRequest < minFeePerRequest) { + revert InsufficientRequestFee(feePerRequest, minFeePerRequest); } - uint256 feePerRequest = totalWithdrawalFee / keysCount; - uint256 unallocatedFee = totalWithdrawalFee % keysCount; - uint256 prevBalance = address(this).balance - totalWithdrawalFee; + uint256 totalWithdrawalFee = feePerRequest * keysCount; + + if(address(this).balance < totalWithdrawalFee) { + revert InsufficientBalance(address(this).balance, totalWithdrawalFee); + } for (uint256 i = 0; i < keysCount; ++i) { if(pubkeys[i].length != 48) { revert InvalidPubkeyLength(pubkeys[i]); } - uint256 feeToSend = feePerRequest; - - if (i == keysCount - 1) { - feeToSend += unallocatedFee; - } - bytes memory callData = abi.encodePacked(pubkeys[i], amounts[i]); - (bool success, ) = WITHDRAWAL_REQUEST.call{value: feeToSend}(callData); + (bool success, ) = WITHDRAWAL_REQUEST.call{value: feePerRequest}(callData); if (!success) { revert WithdrawalRequestAdditionFailed(pubkeys[i], amounts[i]); @@ -130,8 +127,6 @@ library TriggerableWithdrawals { emit WithdrawalRequestAdded(pubkeys[i], amounts[i]); } - - assert(address(this).balance == prevBalance); } function _requireArrayLengthsMatch( diff --git a/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol b/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol index 261f1a8cd..82e4b308f 100644 --- a/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol +++ b/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol @@ -5,25 +5,25 @@ import {TriggerableWithdrawals} from "contracts/0.8.9/lib/TriggerableWithdrawals contract TriggerableWithdrawals_Harness { function addFullWithdrawalRequests( bytes[] calldata pubkeys, - uint256 totalWithdrawalFee + uint256 feePerRequest ) external { - TriggerableWithdrawals.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee); + TriggerableWithdrawals.addFullWithdrawalRequests(pubkeys, feePerRequest); } function addPartialWithdrawalRequests( bytes[] calldata pubkeys, uint64[] calldata amounts, - uint256 totalWithdrawalFee + uint256 feePerRequest ) external { - TriggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee); + TriggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, feePerRequest); } function addWithdrawalRequests( bytes[] calldata pubkeys, uint64[] calldata amounts, - uint256 totalWithdrawalFee + uint256 feePerRequest ) external { - TriggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee); + TriggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, feePerRequest); } function getWithdrawalRequestFee() external view returns (uint256) { diff --git a/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts b/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts index 83c57ca26..af1325180 100644 --- a/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts +++ b/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts @@ -51,10 +51,8 @@ describe("TriggerableWithdrawals.sol", () => { afterEach(async () => await Snapshot.restore(originalState)); - async function getFee(requestsCount: number): Promise { - const fee = await triggerableWithdrawals.getWithdrawalRequestFee(); - - return ethers.parseUnits((fee * BigInt(requestsCount)).toString(), "wei"); + async function getFee(): Promise { + return await triggerableWithdrawals.getWithdrawalRequestFee(); } context("eip 7002 contract", () => { @@ -105,7 +103,7 @@ describe("TriggerableWithdrawals.sol", () => { const { pubkeys } = generateWithdrawalRequestPayload(2); const amounts = [1n]; - const fee = await getFee(pubkeys.length); + const fee = await getFee(); await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, fee)) .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") @@ -138,34 +136,19 @@ describe("TriggerableWithdrawals.sol", () => { await withdrawalsPredeployed.setFee(3n); // Set fee to 3 gwei - // 1. Should revert if no fee is sent - await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, 0n)).to.be.revertedWithCustomError( - triggerableWithdrawals, - "FeeNotEnough", - ); - - await expect( - triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, 0n), - ).to.be.revertedWithCustomError(triggerableWithdrawals, "FeeNotEnough"); - - await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, 0n)).to.be.revertedWithCustomError( - triggerableWithdrawals, - "FeeNotEnough", - ); - // 2. Should revert if fee is less than required const insufficientFee = 2n; - await expect( - triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, insufficientFee), - ).to.be.revertedWithCustomError(triggerableWithdrawals, "FeeNotEnough"); + await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, insufficientFee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientRequestFee") + .withArgs(2n, 3n); - await expect( - triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, insufficientFee), - ).to.be.revertedWithCustomError(triggerableWithdrawals, "FeeNotEnough"); + await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, insufficientFee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientRequestFee") + .withArgs(2n, 3n); - await expect( - triggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, insufficientFee), - ).to.be.revertedWithCustomError(triggerableWithdrawals, "FeeNotEnough"); + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, insufficientFee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientRequestFee") + .withArgs(2n, 3n); }); it("Should revert if any pubkey is not 48 bytes", async function () { @@ -173,7 +156,7 @@ describe("TriggerableWithdrawals.sol", () => { const pubkeys = ["0x1234"]; const amounts = [10n]; - const fee = await getFee(pubkeys.length); + const fee = await getFee(); await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee)) .to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPubkeyLength") @@ -192,7 +175,7 @@ describe("TriggerableWithdrawals.sol", () => { const { pubkeys } = generateWithdrawalRequestPayload(1); const amounts = [10n]; - const fee = await getFee(pubkeys.length); + const fee = await getFee(); // Set mock to fail on add await withdrawalsPredeployed.setFailOnAddRequest(true); @@ -215,7 +198,7 @@ describe("TriggerableWithdrawals.sol", () => { it("Should revert when a full withdrawal amount is included in 'addPartialWithdrawalRequests'", async function () { const { pubkeys } = generateWithdrawalRequestPayload(2); const amounts = [1n, 0n]; // Partial and Full withdrawal - const fee = await getFee(pubkeys.length); + const fee = await getFee(); await expect( triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, fee), @@ -223,27 +206,27 @@ describe("TriggerableWithdrawals.sol", () => { }); it("Should revert when balance is less than total withdrawal fee", async function () { - const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(2); + const keysCount = 2; const fee = 10n; - const totalWithdrawalFee = 20n; const balance = 19n; + const expectedMinimalBalance = 20n; + + const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(keysCount); await withdrawalsPredeployed.setFee(fee); await setBalance(await triggerableWithdrawals.getAddress(), balance); - await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee)) + await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee)) .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalance") - .withArgs(balance, totalWithdrawalFee); + .withArgs(balance, expectedMinimalBalance); - await expect( - triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee), - ) + await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee)) .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalance") - .withArgs(balance, totalWithdrawalFee); + .withArgs(balance, expectedMinimalBalance); - await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, totalWithdrawalFee)) + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee)) .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalance") - .withArgs(balance, totalWithdrawalFee); + .withArgs(balance, expectedMinimalBalance); }); it("Should revert when fee read fails", async function () { @@ -266,31 +249,29 @@ describe("TriggerableWithdrawals.sol", () => { ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestFeeReadFailed"); }); + // ToDo: should accept when fee not defined + it("Should accept withdrawal requests when the provided fee matches the exact required amount", async function () { const requestCount = 3; const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); + const fee = 3n; await withdrawalsPredeployed.setFee(3n); - const totalWithdrawalFee = 9n; - await triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee); - await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee); - await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, totalWithdrawalFee); + await triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee); + await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); + await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee); // Check extremely high fee - await withdrawalsPredeployed.setFee(ethers.parseEther("10")); - const largeTotalWithdrawalFee = ethers.parseEther("30"); + const highFee = ethers.parseEther("10"); + await withdrawalsPredeployed.setFee(highFee); - await triggerableWithdrawals.connect(actor).deposit({ value: largeTotalWithdrawalFee * BigInt(requestCount) }); + await triggerableWithdrawals.connect(actor).deposit({ value: highFee * BigInt(requestCount) * 3n }); - await triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, largeTotalWithdrawalFee); - await triggerableWithdrawals.addPartialWithdrawalRequests( - pubkeys, - partialWithdrawalAmounts, - largeTotalWithdrawalFee, - ); - await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, largeTotalWithdrawalFee); + await triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, highFee); + await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, highFee); + await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, highFee); }); it("Should accept withdrawal requests when the provided fee exceeds the required amount", async function () { @@ -299,23 +280,19 @@ describe("TriggerableWithdrawals.sol", () => { generateWithdrawalRequestPayload(requestCount); await withdrawalsPredeployed.setFee(3n); - const fee = 9n + 1n; // 3 request * 3 gwei (fee) + 1 gwei (extra fee)= 10 gwei + const fee = 4n; await triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee); await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee); // Check when the provided fee extremely exceeds the required amount - const largeTotalWithdrawalFee = ethers.parseEther("10"); - await triggerableWithdrawals.connect(actor).deposit({ value: largeTotalWithdrawalFee * BigInt(requestCount) }); + const largeFee = ethers.parseEther("10"); + await triggerableWithdrawals.connect(actor).deposit({ value: largeFee * BigInt(requestCount) * 3n }); - await triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, largeTotalWithdrawalFee); - await triggerableWithdrawals.addPartialWithdrawalRequests( - pubkeys, - partialWithdrawalAmounts, - largeTotalWithdrawalFee, - ); - await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, largeTotalWithdrawalFee); + await triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, largeFee); + await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, largeFee); + await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, largeFee); }); it("Should correctly deduct the exact fee amount from the contract balance", async function () { @@ -323,13 +300,13 @@ describe("TriggerableWithdrawals.sol", () => { const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); - await withdrawalsPredeployed.setFee(3n); - const fee = 9n + 1n; // 3 requests * 3 gwei (fee) + 1 gwei (extra fee) = 10 gwei + 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 - fee); + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance - expectedTotalWithdrawalFee); }; await testFeeDeduction(() => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee)); @@ -344,28 +321,26 @@ describe("TriggerableWithdrawals.sol", () => { const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); - await withdrawalsPredeployed.setFee(3n); - const totalWithdrawalFee = 9n + 1n; + 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 + totalWithdrawalFee); + expect(await getWithdrawalsPredeployedContractBalance()).to.equal(initialBalance + expectedTotalWithdrawalFee); }; - await testFeeTransfer(() => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee)); + await testFeeTransfer(() => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee)); await testFeeTransfer(() => - triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee), - ); - await testFeeTransfer(() => - triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, totalWithdrawalFee), + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), ); + await testFeeTransfer(() => triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee)); }); it("Should accept full, partial, and mixed withdrawal requests via 'addWithdrawalRequests' function", async function () { const { pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(3); - const fee = await getFee(pubkeys.length); + const fee = await getFee(); await triggerableWithdrawals.addWithdrawalRequests(pubkeys, fullWithdrawalAmounts, fee); await triggerableWithdrawals.addWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); @@ -421,13 +396,11 @@ describe("TriggerableWithdrawals.sol", () => { }); it("Should verify correct fee distribution among requests", async function () { - await withdrawalsPredeployed.setFee(2n); - const requestCount = 5; const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); - const testFeeDistribution = async (totalWithdrawalFee: bigint, expectedFeePerRequest: bigint[]) => { + const testFeeDistribution = async (fee: bigint) => { const checkEip7002MockEvents = async (addRequests: () => Promise) => { const tx = await addRequests(); @@ -436,34 +409,31 @@ describe("TriggerableWithdrawals.sol", () => { expect(events.length).to.equal(requestCount); for (let i = 0; i < requestCount; i++) { - expect(events[i].args[1]).to.equal(expectedFeePerRequest[i]); + expect(events[i].args[1]).to.equal(fee); } }; - await checkEip7002MockEvents(() => - triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee), - ); + await checkEip7002MockEvents(() => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee)); await checkEip7002MockEvents(() => - triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee), + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), ); await checkEip7002MockEvents(() => - triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, totalWithdrawalFee), + triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee), ); }; - await testFeeDistribution(10n, [2n, 2n, 2n, 2n, 2n]); - await testFeeDistribution(11n, [2n, 2n, 2n, 2n, 3n]); - await testFeeDistribution(14n, [2n, 2n, 2n, 2n, 6n]); - await testFeeDistribution(15n, [3n, 3n, 3n, 3n, 3n]); + await testFeeDistribution(1n); + await testFeeDistribution(2n); + await testFeeDistribution(3n); }); it("Should ensure withdrawal requests are encoded as expected with a 48-byte pubkey and 8-byte amount", async function () { const requestCount = 16; const { pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); - const totalWithdrawalFee = 333n; + const fee = 333n; const normalize = (hex: string) => (hex.startsWith("0x") ? hex.slice(2).toLowerCase() : hex.toLowerCase()); @@ -492,18 +462,17 @@ describe("TriggerableWithdrawals.sol", () => { }; await testEncoding( - () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee), + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee), pubkeys, fullWithdrawalAmounts, ); await testEncoding( - () => - triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee), + () => triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), pubkeys, partialWithdrawalAmounts, ); await testEncoding( - () => triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, totalWithdrawalFee), + () => triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee), pubkeys, mixedWithdrawalAmounts, ); @@ -544,43 +513,44 @@ describe("TriggerableWithdrawals.sol", () => { } 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 }, + { 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, extraFee }) => { - it(`Should successfully add ${requestCount} requests with extra fee ${extraFee} and emit events`, async () => { + testCasesForWithdrawalRequests.forEach(({ requestCount, fee }) => { + it(`Should successfully add ${requestCount} requests with fee ${fee} and emit events`, async () => { const { pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); - const totalWithdrawalFee = (await getFee(pubkeys.length)) + extraFee; + + const requestFee = fee == 0n ? await getFee() : fee; + const expectedTotalWithdrawalFee = requestFee * BigInt(requestCount); await addWithdrawalRequests( - () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, totalWithdrawalFee), + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee), pubkeys, fullWithdrawalAmounts, - totalWithdrawalFee, + expectedTotalWithdrawalFee, ); await addWithdrawalRequests( - () => - triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, totalWithdrawalFee), + () => triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), pubkeys, partialWithdrawalAmounts, - totalWithdrawalFee, + expectedTotalWithdrawalFee, ); await addWithdrawalRequests( - () => triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, totalWithdrawalFee), + () => triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee), pubkeys, mixedWithdrawalAmounts, - totalWithdrawalFee, + expectedTotalWithdrawalFee, ); }); }); diff --git a/test/0.8.9/withdrawalVault.test.ts b/test/0.8.9/withdrawalVault.test.ts index 0ed3542dd..d0bf1ab28 100644 --- a/test/0.8.9/withdrawalVault.test.ts +++ b/test/0.8.9/withdrawalVault.test.ts @@ -288,10 +288,10 @@ describe("WithdrawalVault.sol", () => { }); }); - async function getFee(requestsCount: number): Promise { + async function getFee(): Promise { const fee = await vault.getWithdrawalRequestFee(); - return ethers.parseUnits((fee * BigInt(requestsCount)).toString(), "wei"); + return ethers.parseUnits(fee.toString(), "wei"); } async function getWithdrawalCredentialsContractBalance(): Promise { @@ -328,23 +328,22 @@ describe("WithdrawalVault.sol", () => { await withdrawalsPredeployed.setFee(3n); // Set fee to 3 gwei // 1. Should revert if no fee is sent - await expect(vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys)).to.be.revertedWithCustomError( - vault, - "FeeNotEnough", - ); + await expect(vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys)) + .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(pubkeys, { value: insufficientFee }), - ).to.be.revertedWithCustomError(vault, "FeeNotEnough"); + await expect(vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: insufficientFee })) + .to.be.revertedWithCustomError(vault, "InsufficientTriggerableWithdrawalFee") + .withArgs(2n, 3n, 1); }); it("Should revert if any pubkey is not 48 bytes", async function () { // Invalid pubkey (only 2 bytes) const pubkeys = ["0x1234"]; - const fee = await getFee(pubkeys.length); + const fee = await getFee(); await expect(vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: fee })) .to.be.revertedWithCustomError(vault, "InvalidPubkeyLength") @@ -353,7 +352,7 @@ describe("WithdrawalVault.sol", () => { it("Should revert if addition fails at the withdrawal request contract", async function () { const { pubkeys } = generateWithdrawalRequestPayload(1); - const fee = await getFee(pubkeys.length); + const fee = await getFee(); // Set mock to fail on add await withdrawalsPredeployed.setFailOnAddRequest(true); @@ -379,15 +378,17 @@ describe("WithdrawalVault.sol", () => { const { pubkeys } = generateWithdrawalRequestPayload(requestCount); await withdrawalsPredeployed.setFee(3n); - const totalWithdrawalFee = 9n; + const expectedTotalWithdrawalFee = 9n; - await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: totalWithdrawalFee }); + await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: expectedTotalWithdrawalFee }); // Check extremely high fee await withdrawalsPredeployed.setFee(ethers.parseEther("10")); - const largeTotalWithdrawalFee = ethers.parseEther("30"); + const expectedLargeTotalWithdrawalFee = ethers.parseEther("30"); - await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: largeTotalWithdrawalFee }); + await vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeys, { value: expectedLargeTotalWithdrawalFee }); }); it("Should accept withdrawal requests when the provided fee exceeds the required amount", async function () { @@ -405,28 +406,40 @@ describe("WithdrawalVault.sol", () => { await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: largeTotalWithdrawalFee }); }); - it("Should correctly deduct the exact fee amount from the contract balance", async function () { + it("Should not affect contract balance", async function () { const requestCount = 3; const { pubkeys } = generateWithdrawalRequestPayload(requestCount); await withdrawalsPredeployed.setFee(3n); - const fee = 9n + 1n; // 3 requests * 3 gwei (fee) + 1 gwei (extra fee) = 10 gwei + const expectedTotalWithdrawalFee = 9n; // 3 requests * 3 gwei (fee) = 9 gwei const initialBalance = await getWithdrawalCredentialsContractBalance(); - await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: fee }); + await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: expectedTotalWithdrawalFee }); + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); + + const excessTotalWithdrawalFee = 9n + 1n; // 3 requests * 3 gwei (fee) + 1 gwei (extra fee) = 10 gwei + await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: excessTotalWithdrawalFee }); 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 { pubkeys } = generateWithdrawalRequestPayload(requestCount); await withdrawalsPredeployed.setFee(3n); - const totalWithdrawalFee = 9n + 1n; + const expectedTotalWithdrawalFee = 9n; + const excessTotalWithdrawalFee = 9n + 1n; - const initialBalance = await getWithdrawalsPredeployedContractBalance(); - await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: totalWithdrawalFee }); - expect(await getWithdrawalsPredeployedContractBalance()).to.equal(initialBalance + totalWithdrawalFee); + let initialBalance = await getWithdrawalsPredeployedContractBalance(); + await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: expectedTotalWithdrawalFee }); + expect(await getWithdrawalsPredeployedContractBalance()).to.equal(initialBalance + expectedTotalWithdrawalFee); + + initialBalance = await getWithdrawalsPredeployedContractBalance(); + await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: excessTotalWithdrawalFee }); + // Only the expected fee should be transferred + expect(await getWithdrawalsPredeployedContractBalance()).to.equal(initialBalance + expectedTotalWithdrawalFee); }); it("Should emit a 'WithdrawalRequestAdded' event when a new withdrawal request is added", async function () { @@ -447,12 +460,13 @@ describe("WithdrawalVault.sol", () => { }); it("Should verify correct fee distribution among requests", async function () { - await withdrawalsPredeployed.setFee(2n); + const withdrawalFee = 2n; + await withdrawalsPredeployed.setFee(withdrawalFee); const requestCount = 5; const { pubkeys } = generateWithdrawalRequestPayload(requestCount); - const testFeeDistribution = async (totalWithdrawalFee: bigint, expectedFeePerRequest: bigint[]) => { + const testFeeDistribution = async (totalWithdrawalFee: bigint) => { const tx = await vault .connect(validatorsExitBus) .addFullWithdrawalRequests(pubkeys, { value: totalWithdrawalFee }); @@ -462,14 +476,13 @@ describe("WithdrawalVault.sol", () => { expect(events.length).to.equal(requestCount); for (let i = 0; i < requestCount; i++) { - expect(events[i].args[1]).to.equal(expectedFeePerRequest[i]); + expect(events[i].args[1]).to.equal(withdrawalFee); } }; - await testFeeDistribution(10n, [2n, 2n, 2n, 2n, 2n]); - await testFeeDistribution(11n, [2n, 2n, 2n, 2n, 3n]); - await testFeeDistribution(14n, [2n, 2n, 2n, 2n, 6n]); - await testFeeDistribution(15n, [3n, 3n, 3n, 3n, 3n]); + await testFeeDistribution(10n); + await testFeeDistribution(11n); + await testFeeDistribution(14n); }); it("Should ensure withdrawal requests are encoded as expected with a 48-byte pubkey and 8-byte amount", async function () { @@ -499,27 +512,28 @@ describe("WithdrawalVault.sol", () => { }); 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 }, + { 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, extraFee }) => { - it(`Should successfully add ${requestCount} requests with extra fee ${extraFee} and emit events`, async () => { + testCasesForWithdrawalRequests.forEach(({ requestCount, fee }) => { + it(`Should successfully add ${requestCount} requests with extra fee ${fee} and emit events`, async () => { const { pubkeys } = generateWithdrawalRequestPayload(requestCount); - const totalWithdrawalFee = (await getFee(pubkeys.length)) + extraFee; + const requestFee = fee == 0n ? await getFee() : fee; + const expectedTotalWithdrawalFee = requestFee * BigInt(requestCount); const initialBalance = await getWithdrawalCredentialsContractBalance(); const tx = await vault .connect(validatorsExitBus) - .addFullWithdrawalRequests(pubkeys, { value: totalWithdrawalFee }); + .addFullWithdrawalRequests(pubkeys, { value: expectedTotalWithdrawalFee }); expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); From 66ccbcfc7067e1ec43b31c41ce3b90a2060471b6 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Mon, 20 Jan 2025 18:01:44 +0100 Subject: [PATCH 11/21] feat: tightly pack pubkeys pass pubkeys as array of bytes --- contracts/0.8.9/WithdrawalVault.sol | 18 +- .../0.8.9/lib/TriggerableWithdrawals.sol | 125 +++-- .../TriggerableWithdrawals_Harness.sol | 6 +- .../WithdrawalsPredeployed_Mock.sol | 4 +- .../lib/triggerableWithdrawals/eip7002Mock.ts | 41 ++ .../lib/triggerableWithdrawals/findEvents.ts | 23 - .../triggerableWithdrawals.test.ts | 459 ++++++++++-------- .../0.8.9/lib/triggerableWithdrawals/utils.ts | 12 +- test/0.8.9/withdrawalVault.test.ts | 269 +++++----- 9 files changed, 536 insertions(+), 421 deletions(-) create mode 100644 test/0.8.9/lib/triggerableWithdrawals/eip7002Mock.ts delete mode 100644 test/0.8.9/lib/triggerableWithdrawals/findEvents.ts diff --git a/contracts/0.8.9/WithdrawalVault.sol b/contracts/0.8.9/WithdrawalVault.sol index f9f060e54..f1f02a2b0 100644 --- a/contracts/0.8.9/WithdrawalVault.sol +++ b/contracts/0.8.9/WithdrawalVault.sol @@ -51,8 +51,8 @@ contract WithdrawalVault is AccessControlEnumerable, Versioned { 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 @@ -144,22 +144,30 @@ contract WithdrawalVault is AccessControlEnumerable, Versioned { * @param pubkeys An array of public keys for the validators requesting full withdrawals. */ function addFullWithdrawalRequests( - bytes[] calldata pubkeys + 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 * minFeePerRequest; + uint256 totalFee = pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH * minFeePerRequest; if(totalFee > msg.value) { - revert InsufficientTriggerableWithdrawalFee(msg.value, totalFee, pubkeys.length); + revert InsufficientTriggerableWithdrawalFee( + msg.value, + totalFee, + pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH + ); } TriggerableWithdrawals.addFullWithdrawalRequests(pubkeys, minFeePerRequest); uint256 refund = msg.value - totalFee; if (refund > 0) { - msg.sender.call{value: refund}(""); + (bool success, ) = msg.sender.call{value: refund}(""); + + if (!success) { + revert TriggerableWithdrawalRefundFailed(); + } } assert(address(this).balance == prevBalance); diff --git a/contracts/0.8.9/lib/TriggerableWithdrawals.sol b/contracts/0.8.9/lib/TriggerableWithdrawals.sol index ff3bd43b4..a601a5930 100644 --- a/contracts/0.8.9/lib/TriggerableWithdrawals.sol +++ b/contracts/0.8.9/lib/TriggerableWithdrawals.sol @@ -2,21 +2,21 @@ // 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 InvalidPubkeyLength(bytes pubkey); - error WithdrawalRequestAdditionFailed(bytes pubkey, uint256 amount); + error WithdrawalRequestAdditionFailed(bytes callData); error NoWithdrawalRequests(); - error PartialWithdrawalRequired(bytes pubkey); - - event WithdrawalRequestAdded(bytes pubkey, uint256 amount); + error PartialWithdrawalRequired(uint256 index); + error InvalidPublicKeyLength(); /** * @dev Adds full withdrawal requests for the provided public keys. @@ -24,11 +24,23 @@ library TriggerableWithdrawals { * @param pubkeys An array of public keys for the validators requesting full withdrawals. */ function addFullWithdrawalRequests( - bytes[] calldata pubkeys, + bytes calldata pubkeys, uint256 feePerRequest ) internal { - uint64[] memory amounts = new uint64[](pubkeys.length); - _addWithdrawalRequests(pubkeys, amounts, feePerRequest); + 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); + } + } } /** @@ -41,22 +53,20 @@ library TriggerableWithdrawals { * @param amounts An array of corresponding withdrawal amounts for each public key. */ function addPartialWithdrawalRequests( - bytes[] calldata pubkeys, + bytes calldata pubkeys, uint64[] calldata amounts, uint256 feePerRequest ) internal { - _requireArrayLengthsMatch(pubkeys, amounts); - for (uint256 i = 0; i < amounts.length; i++) { if (amounts[i] == 0) { - revert PartialWithdrawalRequired(pubkeys[i]); + revert PartialWithdrawalRequired(i); } } - _addWithdrawalRequests(pubkeys, amounts, feePerRequest); + 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). @@ -65,12 +75,29 @@ library TriggerableWithdrawals { * @param amounts An array of corresponding withdrawal amounts for each public key. */ function addWithdrawalRequests( - bytes[] calldata pubkeys, + bytes calldata pubkeys, uint64[] calldata amounts, uint256 feePerRequest ) internal { - _requireArrayLengthsMatch(pubkeys, amounts); - _addWithdrawalRequests(pubkeys, amounts, feePerRequest); + 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); + } + } } /** @@ -87,16 +114,36 @@ library TriggerableWithdrawals { return abi.decode(feeData, (uint256)); } - function _addWithdrawalRequests( - bytes[] calldata pubkeys, - uint64[] memory amounts, - uint256 feePerRequest - ) internal { - uint256 keysCount = pubkeys.length; + 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) { @@ -107,34 +154,10 @@ library TriggerableWithdrawals { revert InsufficientRequestFee(feePerRequest, minFeePerRequest); } - uint256 totalWithdrawalFee = feePerRequest * keysCount; - - if(address(this).balance < totalWithdrawalFee) { - revert InsufficientBalance(address(this).balance, totalWithdrawalFee); + if(address(this).balance < feePerRequest * keysCount) { + revert InsufficientBalance(address(this).balance, feePerRequest * keysCount); } - for (uint256 i = 0; i < keysCount; ++i) { - if(pubkeys[i].length != 48) { - revert InvalidPubkeyLength(pubkeys[i]); - } - - bytes memory callData = abi.encodePacked(pubkeys[i], amounts[i]); - (bool success, ) = WITHDRAWAL_REQUEST.call{value: feePerRequest}(callData); - - if (!success) { - revert WithdrawalRequestAdditionFailed(pubkeys[i], amounts[i]); - } - - emit WithdrawalRequestAdded(pubkeys[i], amounts[i]); - } - } - - function _requireArrayLengthsMatch( - bytes[] calldata pubkeys, - uint64[] calldata amounts - ) internal pure { - if (pubkeys.length != amounts.length) { - revert MismatchedArrayLengths(pubkeys.length, amounts.length); - } + return feePerRequest; } } diff --git a/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol b/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol index 82e4b308f..1ea18a48b 100644 --- a/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol +++ b/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol @@ -4,14 +4,14 @@ import {TriggerableWithdrawals} from "contracts/0.8.9/lib/TriggerableWithdrawals contract TriggerableWithdrawals_Harness { function addFullWithdrawalRequests( - bytes[] calldata pubkeys, + bytes calldata pubkeys, uint256 feePerRequest ) external { TriggerableWithdrawals.addFullWithdrawalRequests(pubkeys, feePerRequest); } function addPartialWithdrawalRequests( - bytes[] calldata pubkeys, + bytes calldata pubkeys, uint64[] calldata amounts, uint256 feePerRequest ) external { @@ -19,7 +19,7 @@ contract TriggerableWithdrawals_Harness { } function addWithdrawalRequests( - bytes[] calldata pubkeys, + bytes calldata pubkeys, uint64[] calldata amounts, uint256 feePerRequest ) external { diff --git a/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol b/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol index f4b580b14..25581ff79 100644 --- a/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol +++ b/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol @@ -9,7 +9,7 @@ contract WithdrawalsPredeployed_Mock { bool public failOnAddRequest; bool public failOnGetFee; - event eip7002WithdrawalRequestAdded(bytes request, uint256 fee); + event eip7002MockRequestAdded(bytes request, uint256 fee); function setFailOnAddRequest(bool _failOnAddRequest) external { failOnAddRequest = _failOnAddRequest; @@ -36,7 +36,7 @@ contract WithdrawalsPredeployed_Mock { require(input.length == 56, "Invalid callData length"); - emit eip7002WithdrawalRequestAdded( + 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/findEvents.ts b/test/0.8.9/lib/triggerableWithdrawals/findEvents.ts deleted file mode 100644 index 82047e8c1..000000000 --- a/test/0.8.9/lib/triggerableWithdrawals/findEvents.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ContractTransactionReceipt } from "ethers"; -import { ethers } from "hardhat"; - -import { findEventsWithInterfaces } from "lib"; - -const withdrawalRequestEventABI = ["event WithdrawalRequestAdded(bytes pubkey, uint256 amount)"]; -const withdrawalRequestEventInterface = new ethers.Interface(withdrawalRequestEventABI); -type WithdrawalRequestEvents = "WithdrawalRequestAdded"; - -export function findEvents(receipt: ContractTransactionReceipt, event: WithdrawalRequestEvents) { - return findEventsWithInterfaces(receipt!, event, [withdrawalRequestEventInterface]); -} - -const eip7002TriggerableWithdrawalMockEventABI = ["event eip7002WithdrawalRequestAdded(bytes request, uint256 fee)"]; -const eip7002TriggerableWithdrawalMockInterface = new ethers.Interface(eip7002TriggerableWithdrawalMockEventABI); -type Eip7002WithdrawalEvents = "eip7002WithdrawalRequestAdded"; - -export function findEip7002TriggerableWithdrawalMockEvents( - receipt: ContractTransactionReceipt, - event: Eip7002WithdrawalEvents, -) { - return findEventsWithInterfaces(receipt!, event, [eip7002TriggerableWithdrawalMockInterface]); -} diff --git a/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts b/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts index af1325180..5600a7e27 100644 --- a/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts +++ b/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts @@ -9,13 +9,15 @@ import { TriggerableWithdrawals_Harness, WithdrawalsPredeployed_Mock } from "typ import { Snapshot } from "test/suite"; -import { findEip7002TriggerableWithdrawalMockEvents, findEvents } from "./findEvents"; +import { findEip7002MockEvents, testEip7002Mock } from "./eip7002Mock"; import { deployWithdrawalsPredeployedMock, generateWithdrawalRequestPayload, withdrawalsPredeployedHardcodedAddress, } from "./utils"; +const EMPTY_PUBKEYS = "0x"; + describe("TriggerableWithdrawals.sol", () => { let actor: HardhatEthersSigner; @@ -83,96 +85,111 @@ describe("TriggerableWithdrawals.sol", () => { context("add triggerable withdrawal requests", () => { it("Should revert if empty arrays are provided", async function () { - await expect(triggerableWithdrawals.addFullWithdrawalRequests([], 1n)).to.be.revertedWithCustomError( + await expect(triggerableWithdrawals.addFullWithdrawalRequests(EMPTY_PUBKEYS, 1n)).to.be.revertedWithCustomError( triggerableWithdrawals, "NoWithdrawalRequests", ); - await expect(triggerableWithdrawals.addPartialWithdrawalRequests([], [], 1n)).to.be.revertedWithCustomError( - triggerableWithdrawals, - "NoWithdrawalRequests", - ); + await expect( + triggerableWithdrawals.addPartialWithdrawalRequests(EMPTY_PUBKEYS, [], 1n), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "NoWithdrawalRequests"); - await expect(triggerableWithdrawals.addWithdrawalRequests([], [], 1n)).to.be.revertedWithCustomError( + await expect(triggerableWithdrawals.addWithdrawalRequests(EMPTY_PUBKEYS, [], 1n)).to.be.revertedWithCustomError( triggerableWithdrawals, "NoWithdrawalRequests", ); }); it("Should revert if array lengths do not match", async function () { - const { pubkeys } = generateWithdrawalRequestPayload(2); + const requestCount = 2; + const { pubkeysHexString } = generateWithdrawalRequestPayload(requestCount); const amounts = [1n]; const fee = await getFee(); - await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, fee)) + await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, fee)) .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") - .withArgs(pubkeys.length, amounts.length); + .withArgs(requestCount, amounts.length); - await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, [], fee)) + await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, [], fee)) .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") - .withArgs(pubkeys.length, 0); + .withArgs(requestCount, 0); - await expect(triggerableWithdrawals.addPartialWithdrawalRequests([], amounts, fee)) + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, amounts, fee)) .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") - .withArgs(0, amounts.length); + .withArgs(requestCount, amounts.length); - await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, fee)) + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, [], fee)) .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") - .withArgs(pubkeys.length, amounts.length); - - await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, [], fee)) - .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") - .withArgs(pubkeys.length, 0); - - await expect(triggerableWithdrawals.addWithdrawalRequests([], amounts, fee)) - .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") - .withArgs(0, amounts.length); + .withArgs(requestCount, 0); }); it("Should revert if not enough fee is sent", async function () { - const { pubkeys } = generateWithdrawalRequestPayload(1); + 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(pubkeys, insufficientFee)) + await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, insufficientFee)) .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientRequestFee") .withArgs(2n, 3n); - await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, insufficientFee)) + await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, insufficientFee)) .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientRequestFee") .withArgs(2n, 3n); - await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, insufficientFee)) + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, amounts, insufficientFee)) .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientRequestFee") .withArgs(2n, 3n); }); - it("Should revert if any pubkey is not 48 bytes", async function () { + it("Should revert if pubkey is not 48 bytes", async function () { // Invalid pubkey (only 2 bytes) - const pubkeys = ["0x1234"]; + 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(pubkeys, fee)) - .to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPubkeyLength") - .withArgs(pubkeys[0]); + await expect( + triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPublicKeyLength"); - await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, fee)) - .to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPubkeyLength") - .withArgs(pubkeys[0]); + await expect( + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPublicKeyLength"); - await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, fee)) - .to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPubkeyLength") - .withArgs(pubkeys[0]); + 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 { pubkeys } = generateWithdrawalRequestPayload(1); + const { pubkeysHexString } = generateWithdrawalRequestPayload(1); const amounts = [10n]; const fee = await getFee(); @@ -180,28 +197,26 @@ describe("TriggerableWithdrawals.sol", () => { // Set mock to fail on add await withdrawalsPredeployed.setFailOnAddRequest(true); - await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee)).to.be.revertedWithCustomError( - triggerableWithdrawals, - "WithdrawalRequestAdditionFailed", - ); + await expect( + triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestAdditionFailed"); await expect( - triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, fee), + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, fee), ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestAdditionFailed"); - await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, 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 { pubkeys } = generateWithdrawalRequestPayload(2); + const { pubkeysHexString } = generateWithdrawalRequestPayload(2); const amounts = [1n, 0n]; // Partial and Full withdrawal const fee = await getFee(); await expect( - triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, fee), + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, fee), ).to.be.revertedWithCustomError(triggerableWithdrawals, "PartialWithdrawalRequired"); }); @@ -211,20 +226,21 @@ describe("TriggerableWithdrawals.sol", () => { const balance = 19n; const expectedMinimalBalance = 20n; - const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(keysCount); + const { pubkeysHexString, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(keysCount); await withdrawalsPredeployed.setFee(fee); await setBalance(await triggerableWithdrawals.getAddress(), balance); - await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee)) + await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee)) .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalance") .withArgs(balance, expectedMinimalBalance); - await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee)) + await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee)) .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalance") .withArgs(balance, expectedMinimalBalance); - await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee)) + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee)) .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalance") .withArgs(balance, expectedMinimalBalance); }); @@ -232,36 +248,87 @@ describe("TriggerableWithdrawals.sol", () => { it("Should revert when fee read fails", async function () { await withdrawalsPredeployed.setFailOnGetFee(true); - const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(2); + const { pubkeysHexString, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(2); const fee = 10n; - await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee)).to.be.revertedWithCustomError( - triggerableWithdrawals, - "WithdrawalRequestFeeReadFailed", - ); + await expect( + triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestFeeReadFailed"); await expect( - triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestFeeReadFailed"); await expect( - triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee), + triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestFeeReadFailed"); }); - // ToDo: should accept when fee not defined + 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 { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); const fee = 3n; - await withdrawalsPredeployed.setFee(3n); + await withdrawalsPredeployed.setFee(fee); - await triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee); - await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); - await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, 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"); @@ -269,35 +336,92 @@ describe("TriggerableWithdrawals.sol", () => { await triggerableWithdrawals.connect(actor).deposit({ value: highFee * BigInt(requestCount) * 3n }); - await triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, highFee); - await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, highFee); - await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, highFee); + 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 { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); await withdrawalsPredeployed.setFee(3n); - const fee = 4n; + const excessFee = 4n; + + await testEip7002Mock( + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, excessFee), + pubkeys, + fullWithdrawalAmounts, + excessFee, + ); - await triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee); - await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); - await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee); + 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 largeFee = ethers.parseEther("10"); - await triggerableWithdrawals.connect(actor).deposit({ value: largeFee * BigInt(requestCount) * 3n }); + 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 triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, largeFee); - await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, largeFee); - await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, largeFee); + 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 { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + const { pubkeysHexString, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); const fee = 4n; @@ -309,16 +433,18 @@ describe("TriggerableWithdrawals.sol", () => { expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance - expectedTotalWithdrawalFee); }; - await testFeeDeduction(() => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee)); + await testFeeDeduction(() => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee)); + await testFeeDeduction(() => + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), + ); await testFeeDeduction(() => - triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), + triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), ); - await testFeeDeduction(() => triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee)); }); it("Should transfer the total calculated fee to the EIP-7002 withdrawal contract", async function () { const requestCount = 3; - const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + const { pubkeysHexString, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); const fee = 3n; @@ -330,112 +456,39 @@ describe("TriggerableWithdrawals.sol", () => { expect(await getWithdrawalsPredeployedContractBalance()).to.equal(initialBalance + expectedTotalWithdrawalFee); }; - await testFeeTransfer(() => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee)); + await testFeeTransfer(() => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee)); await testFeeTransfer(() => - triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), + ); + await testFeeTransfer(() => + triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), ); - await testFeeTransfer(() => triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee)); }); it("Should accept full, partial, and mixed withdrawal requests via 'addWithdrawalRequests' function", async function () { - const { pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + const { pubkeysHexString, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(3); const fee = await getFee(); - await triggerableWithdrawals.addWithdrawalRequests(pubkeys, fullWithdrawalAmounts, fee); - await triggerableWithdrawals.addWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee); - await triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee); + 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 { pubkeys } = generateWithdrawalRequestPayload(1); + const { pubkeysHexString } = generateWithdrawalRequestPayload(1); const amounts = [MAX_UINT64]; - await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, 10n); - await triggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, 10n); - }); - - it("Should emit a 'WithdrawalRequestAdded' event when a new withdrawal request is added", async function () { - const requestCount = 3; - const { pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = - generateWithdrawalRequestPayload(requestCount); - const fee = 10n; - - const testEventsEmit = async ( - addRequests: () => Promise, - expectedPubKeys: string[], - expectedAmounts: bigint[], - ) => { - const tx = await addRequests(); - - const receipt = await tx.wait(); - const events = findEvents(receipt!, "WithdrawalRequestAdded"); - expect(events.length).to.equal(requestCount); - - for (let i = 0; i < requestCount; i++) { - expect(events[i].args[0]).to.equal(expectedPubKeys[i]); - expect(events[i].args[1]).to.equal(expectedAmounts[i]); - } - }; - - await testEventsEmit( - () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee), - pubkeys, - fullWithdrawalAmounts, - ); - await testEventsEmit( - () => triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), - pubkeys, - partialWithdrawalAmounts, - ); - await testEventsEmit( - () => triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee), - pubkeys, - mixedWithdrawalAmounts, - ); - }); - - it("Should verify correct fee distribution among requests", async function () { - const requestCount = 5; - const { pubkeys, partialWithdrawalAmounts, mixedWithdrawalAmounts } = - generateWithdrawalRequestPayload(requestCount); - - const testFeeDistribution = async (fee: bigint) => { - const checkEip7002MockEvents = async (addRequests: () => Promise) => { - const tx = await addRequests(); - - const receipt = await tx.wait(); - const events = findEip7002TriggerableWithdrawalMockEvents(receipt!, "eip7002WithdrawalRequestAdded"); - expect(events.length).to.equal(requestCount); - - for (let i = 0; i < requestCount; i++) { - expect(events[i].args[1]).to.equal(fee); - } - }; - - await checkEip7002MockEvents(() => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee)); - - await checkEip7002MockEvents(() => - triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), - ); - - await checkEip7002MockEvents(() => - triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee), - ); - }; - - await testFeeDistribution(1n); - await testFeeDistribution(2n); - await testFeeDistribution(3n); + 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 { pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); - const fee = 333n; - const normalize = (hex: string) => (hex.startsWith("0x") ? hex.slice(2).toLowerCase() : hex.toLowerCase()); + const fee = 333n; const testEncoding = async ( addRequests: () => Promise, @@ -443,10 +496,9 @@ describe("TriggerableWithdrawals.sol", () => { expectedAmounts: bigint[], ) => { const tx = await addRequests(); - const receipt = await tx.wait(); - const events = findEip7002TriggerableWithdrawalMockEvents(receipt!, "eip7002WithdrawalRequestAdded"); + const events = findEip7002MockEvents(receipt!, "eip7002MockRequestAdded"); expect(events.length).to.equal(requestCount); for (let i = 0; i < requestCount; i++) { @@ -454,25 +506,27 @@ describe("TriggerableWithdrawals.sol", () => { // 0x (2 characters) + 48-byte pubkey (96 characters) + 8-byte amount (16 characters) = 114 characters expect(encodedRequest.length).to.equal(114); - expect(normalize(encodedRequest.substring(0, 98))).to.equal(normalize(expectedPubKeys[i])); - expect(normalize(encodedRequest.substring(98, 114))).to.equal( - expectedAmounts[i].toString(16).padStart(16, "0"), - ); + 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(pubkeys, fee), + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), pubkeys, fullWithdrawalAmounts, ); await testEncoding( - () => triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), + () => triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), pubkeys, partialWithdrawalAmounts, ); await testEncoding( - () => triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee), + () => triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), pubkeys, mixedWithdrawalAmounts, ); @@ -482,34 +536,14 @@ describe("TriggerableWithdrawals.sol", () => { addRequests: () => Promise, expectedPubkeys: string[], expectedAmounts: bigint[], + expectedFee: bigint, expectedTotalWithdrawalFee: bigint, ) { const initialBalance = await getWithdrawalCredentialsContractBalance(); - const tx = await addRequests(); + await testEip7002Mock(addRequests, expectedPubkeys, expectedAmounts, expectedFee); expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance - expectedTotalWithdrawalFee); - - const receipt = await tx.wait(); - - const events = findEvents(receipt!, "WithdrawalRequestAdded"); - expect(events.length).to.equal(expectedPubkeys.length); - - for (let i = 0; i < expectedPubkeys.length; i++) { - expect(events[i].args[0]).to.equal(expectedPubkeys[i]); - expect(events[i].args[1]).to.equal(expectedAmounts[i]); - } - - const eip7002TriggerableWithdrawalMockEvents = findEip7002TriggerableWithdrawalMockEvents( - receipt!, - "eip7002WithdrawalRequestAdded", - ); - expect(eip7002TriggerableWithdrawalMockEvents.length).to.equal(expectedPubkeys.length); - for (let i = 0; i < expectedPubkeys.length; i++) { - expect(eip7002TriggerableWithdrawalMockEvents[i].args[0]).to.equal( - expectedPubkeys[i].concat(expectedAmounts[i].toString(16).padStart(16, "0")), - ); - } } const testCasesForWithdrawalRequests = [ @@ -525,31 +559,34 @@ describe("TriggerableWithdrawals.sol", () => { ]; testCasesForWithdrawalRequests.forEach(({ requestCount, fee }) => { - it(`Should successfully add ${requestCount} requests with fee ${fee} and emit events`, async () => { - const { pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + it(`Should successfully add ${requestCount} requests with fee ${fee}`, async () => { + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); - const requestFee = fee == 0n ? await getFee() : fee; - const expectedTotalWithdrawalFee = requestFee * BigInt(requestCount); + const expectedFee = fee == 0n ? await getFee() : fee; + const expectedTotalWithdrawalFee = expectedFee * BigInt(requestCount); await addWithdrawalRequests( - () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeys, fee), + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), pubkeys, fullWithdrawalAmounts, + expectedFee, expectedTotalWithdrawalFee, ); await addWithdrawalRequests( - () => triggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, partialWithdrawalAmounts, fee), + () => triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), pubkeys, partialWithdrawalAmounts, + expectedFee, expectedTotalWithdrawalFee, ); await addWithdrawalRequests( - () => triggerableWithdrawals.addWithdrawalRequests(pubkeys, mixedWithdrawalAmounts, fee), + () => 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 index 105c23e47..676cd9ac8 100644 --- a/test/0.8.9/lib/triggerableWithdrawals/utils.ts +++ b/test/0.8.9/lib/triggerableWithdrawals/utils.ts @@ -22,10 +22,10 @@ export async function deployWithdrawalsPredeployedMock( function toValidatorPubKey(num: number): string { if (num < 0 || num > 0xffff) { - throw new Error("Number is out of the 2-byte range (0x0000 - 0xFFFF)."); + throw new Error("Number is out of the 2-byte range (0x0000 - 0xffff)."); } - return `0x${num.toString(16).padStart(4, "0").repeat(24)}`; + return `${num.toString(16).padStart(4, "0").toLocaleLowerCase().repeat(24)}`; } const convertEthToGwei = (ethAmount: string | number): bigint => { @@ -47,5 +47,11 @@ export function generateWithdrawalRequestPayload(numberOfRequests: number) { mixedWithdrawalAmounts.push(i % 2 === 0 ? 0n : convertEthToGwei(i)); } - return { pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts }; + 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 d0bf1ab28..e4bc64f17 100644 --- a/test/0.8.9/withdrawalVault.test.ts +++ b/test/0.8.9/withdrawalVault.test.ts @@ -17,7 +17,7 @@ import { MAX_UINT256, proxify, streccak } from "lib"; import { Snapshot } from "test/suite"; -import { findEip7002TriggerableWithdrawalMockEvents, findEvents } from "./lib/triggerableWithdrawals/findEvents"; +import { findEip7002MockEvents, testEip7002Mock } from "./lib/triggerableWithdrawals/eip7002Mock"; import { deployWithdrawalsPredeployedMock, generateWithdrawalRequestPayload, @@ -311,114 +311,178 @@ describe("WithdrawalVault.sol", () => { }); 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); + 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([], { value: 1n }), + vault.connect(validatorsExitBus).addFullWithdrawalRequests("0x", { value: 1n }), ).to.be.revertedWithCustomError(vault, "NoWithdrawalRequests"); }); it("Should revert if not enough fee is sent", async function () { - const { pubkeys } = generateWithdrawalRequestPayload(1); + 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(pubkeys)) + 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(pubkeys, { value: insufficientFee })) + await expect( + vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: insufficientFee }), + ) .to.be.revertedWithCustomError(vault, "InsufficientTriggerableWithdrawalFee") .withArgs(2n, 3n, 1); }); - it("Should revert if any pubkey is not 48 bytes", async function () { + it("Should revert if pubkey is not 48 bytes", async function () { // Invalid pubkey (only 2 bytes) - const pubkeys = ["0x1234"]; + const invalidPubkeyHexString = "0x1234"; const fee = await getFee(); - await expect(vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: fee })) - .to.be.revertedWithCustomError(vault, "InvalidPubkeyLength") - .withArgs(pubkeys[0]); + 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 { pubkeys } = generateWithdrawalRequestPayload(1); + const { pubkeysHexString } = generateWithdrawalRequestPayload(1); const fee = await getFee(); // Set mock to fail on add await withdrawalsPredeployed.setFailOnAddRequest(true); await expect( - vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: fee }), + 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 { pubkeys } = generateWithdrawalRequestPayload(2); + const { pubkeysHexString } = generateWithdrawalRequestPayload(2); const fee = 10n; await expect( - vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: fee }), + 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 { pubkeys } = generateWithdrawalRequestPayload(requestCount); + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); + const fee = 3n; await withdrawalsPredeployed.setFee(3n); const expectedTotalWithdrawalFee = 9n; - await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: expectedTotalWithdrawalFee }); + await testEip7002Mock( + () => + vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: expectedTotalWithdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); // Check extremely high fee - await withdrawalsPredeployed.setFee(ethers.parseEther("10")); + const highFee = ethers.parseEther("10"); + await withdrawalsPredeployed.setFee(highFee); const expectedLargeTotalWithdrawalFee = ethers.parseEther("30"); - await vault - .connect(validatorsExitBus) - .addFullWithdrawalRequests(pubkeys, { value: expectedLargeTotalWithdrawalFee }); + 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 { pubkeys } = generateWithdrawalRequestPayload(requestCount); + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); - await withdrawalsPredeployed.setFee(3n); - const fee = 9n + 1n; // 3 request * 3 gwei (fee) + 1 gwei (extra fee)= 10 gwei + const fee = 3n; + await withdrawalsPredeployed.setFee(fee); + const withdrawalFee = 9n + 1n; // 3 request * 3 gwei (fee) + 1 gwei (extra fee)= 10 gwei - await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: fee }); + await testEip7002Mock( + () => vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: withdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); // Check when the provided fee extremely exceeds the required amount - const largeTotalWithdrawalFee = ethers.parseEther("10"); - - await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: largeTotalWithdrawalFee }); + 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 { pubkeys } = generateWithdrawalRequestPayload(requestCount); + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); - await withdrawalsPredeployed.setFee(3n); + const fee = 3n; + await withdrawalsPredeployed.setFee(fee); const expectedTotalWithdrawalFee = 9n; // 3 requests * 3 gwei (fee) = 9 gwei const initialBalance = await getWithdrawalCredentialsContractBalance(); - await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: expectedTotalWithdrawalFee }); + + 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 vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: excessTotalWithdrawalFee }); + + await testEip7002Mock( + () => + vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: excessTotalWithdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); }); @@ -426,79 +490,53 @@ describe("WithdrawalVault.sol", () => { it("Should transfer the total calculated fee to the EIP-7002 withdrawal contract", async function () { const requestCount = 3; - const { pubkeys } = generateWithdrawalRequestPayload(requestCount); + 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 vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: expectedTotalWithdrawalFee }); + + await testEip7002Mock( + () => + vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: expectedTotalWithdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + expect(await getWithdrawalsPredeployedContractBalance()).to.equal(initialBalance + expectedTotalWithdrawalFee); initialBalance = await getWithdrawalsPredeployedContractBalance(); - await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: excessTotalWithdrawalFee }); + 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 emit a 'WithdrawalRequestAdded' event when a new withdrawal request is added", async function () { - const requestCount = 3; - const { pubkeys, fullWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); - const fee = 10n; - - const tx = await vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeys, { value: fee }); - - const receipt = await tx.wait(); - const events = findEvents(receipt!, "WithdrawalRequestAdded"); - expect(events.length).to.equal(requestCount); - - for (let i = 0; i < requestCount; i++) { - expect(events[i].args[0]).to.equal(pubkeys[i]); - expect(events[i].args[1]).to.equal(fullWithdrawalAmounts[i]); - } - }); - - it("Should verify correct fee distribution among requests", async function () { - const withdrawalFee = 2n; - await withdrawalsPredeployed.setFee(withdrawalFee); - - const requestCount = 5; - const { pubkeys } = generateWithdrawalRequestPayload(requestCount); - - const testFeeDistribution = async (totalWithdrawalFee: bigint) => { - const tx = await vault - .connect(validatorsExitBus) - .addFullWithdrawalRequests(pubkeys, { value: totalWithdrawalFee }); - - const receipt = await tx.wait(); - const events = findEip7002TriggerableWithdrawalMockEvents(receipt!, "eip7002WithdrawalRequestAdded"); - expect(events.length).to.equal(requestCount); - - for (let i = 0; i < requestCount; i++) { - expect(events[i].args[1]).to.equal(withdrawalFee); - } - }; - - await testFeeDistribution(10n); - await testFeeDistribution(11n); - await testFeeDistribution(14n); - }); - it("Should ensure withdrawal requests are encoded as expected with a 48-byte pubkey and 8-byte amount", async function () { const requestCount = 16; - const { pubkeys } = generateWithdrawalRequestPayload(requestCount); + const { pubkeysHexString, pubkeys } = generateWithdrawalRequestPayload(requestCount); const totalWithdrawalFee = 333n; - const normalize = (hex: string) => (hex.startsWith("0x") ? hex.slice(2).toLowerCase() : hex.toLowerCase()); - const tx = await vault .connect(validatorsExitBus) - .addFullWithdrawalRequests(pubkeys, { value: totalWithdrawalFee }); + .addFullWithdrawalRequests(pubkeysHexString, { value: totalWithdrawalFee }); const receipt = await tx.wait(); - const events = findEip7002TriggerableWithdrawalMockEvents(receipt!, "eip7002WithdrawalRequestAdded"); + const events = findEip7002MockEvents(receipt!, "eip7002MockRequestAdded"); expect(events.length).to.equal(requestCount); for (let i = 0; i < requestCount; i++) { @@ -506,55 +544,40 @@ describe("WithdrawalVault.sol", () => { // 0x (2 characters) + 48-byte pubkey (96 characters) + 8-byte amount (16 characters) = 114 characters expect(encodedRequest.length).to.equal(114); - expect(normalize(encodedRequest.substring(0, 98))).to.equal(normalize(pubkeys[i])); - expect(normalize(encodedRequest.substring(98, 114))).to.equal("0".repeat(16)); + 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, 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 }, + { 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, fee }) => { - it(`Should successfully add ${requestCount} requests with extra fee ${fee} and emit events`, async () => { - const { pubkeys } = generateWithdrawalRequestPayload(requestCount); - const requestFee = fee == 0n ? await getFee() : fee; - const expectedTotalWithdrawalFee = requestFee * BigInt(requestCount); + 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(); - const tx = await vault - .connect(validatorsExitBus) - .addFullWithdrawalRequests(pubkeys, { value: expectedTotalWithdrawalFee }); + await testEip7002Mock( + () => vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: withdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + expectedFee, + ); expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); - - const receipt = await tx.wait(); - - const events = findEvents(receipt!, "WithdrawalRequestAdded"); - expect(events.length).to.equal(pubkeys.length); - - for (let i = 0; i < pubkeys.length; i++) { - expect(events[i].args[0]).to.equal(pubkeys[i]); - expect(events[i].args[1]).to.equal(0); - } - - const eip7002TriggerableWithdrawalMockEvents = findEip7002TriggerableWithdrawalMockEvents( - receipt!, - "eip7002WithdrawalRequestAdded", - ); - expect(eip7002TriggerableWithdrawalMockEvents.length).to.equal(pubkeys.length); - for (let i = 0; i < pubkeys.length; i++) { - expect(eip7002TriggerableWithdrawalMockEvents[i].args[0]).to.equal(pubkeys[i].concat("0".repeat(16))); - } }); }); }); From 0f37e515cb118dc14f1a6499411b341be1d4b98d Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Mon, 27 Jan 2025 11:23:41 +0100 Subject: [PATCH 12/21] refactor: format code --- contracts/0.8.9/WithdrawalVault.sol | 12 +++++++---- .../0.8.9/lib/TriggerableWithdrawals.sol | 21 +++++-------------- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/contracts/0.8.9/WithdrawalVault.sol b/contracts/0.8.9/WithdrawalVault.sol index f1f02a2b0..c47011914 100644 --- a/contracts/0.8.9/WithdrawalVault.sol +++ b/contracts/0.8.9/WithdrawalVault.sol @@ -11,7 +11,7 @@ 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"; +import {ILidoLocator} from "../common/interfaces/ILidoLocator.sol"; interface ILido { /** @@ -51,7 +51,11 @@ contract WithdrawalVault is AccessControlEnumerable, Versioned { error NotLido(); error NotEnoughEther(uint256 requested, uint256 balance); error ZeroAmount(); - error InsufficientTriggerableWithdrawalFee(uint256 providedTotalFee, uint256 requiredTotalFee, uint256 requestCount); + error InsufficientTriggerableWithdrawalFee( + uint256 providedTotalFee, + uint256 requiredTotalFee, + uint256 requestCount + ); error TriggerableWithdrawalRefundFailed(); /** @@ -149,9 +153,9 @@ contract WithdrawalVault is AccessControlEnumerable, Versioned { uint256 prevBalance = address(this).balance - msg.value; uint256 minFeePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); - uint256 totalFee = pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH * minFeePerRequest; + uint256 totalFee = (pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH) * minFeePerRequest; - if(totalFee > msg.value) { + if (totalFee > msg.value) { revert InsufficientTriggerableWithdrawalFee( msg.value, totalFee, diff --git a/contracts/0.8.9/lib/TriggerableWithdrawals.sol b/contracts/0.8.9/lib/TriggerableWithdrawals.sol index a601a5930..3bd8425a4 100644 --- a/contracts/0.8.9/lib/TriggerableWithdrawals.sol +++ b/contracts/0.8.9/lib/TriggerableWithdrawals.sol @@ -23,10 +23,7 @@ library TriggerableWithdrawals { * 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 { + function addFullWithdrawalRequests(bytes calldata pubkeys, uint256 feePerRequest) internal { uint256 keysCount = _validateAndCountPubkeys(pubkeys); feePerRequest = _validateAndAdjustFee(feePerRequest, keysCount); @@ -74,11 +71,7 @@ library TriggerableWithdrawals { * @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 { + function addWithdrawalRequests(bytes calldata pubkeys, uint64[] calldata amounts, uint256 feePerRequest) internal { uint256 keysCount = _validateAndCountPubkeys(pubkeys); if (keysCount != amounts.length) { @@ -116,11 +109,7 @@ library TriggerableWithdrawals { 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 - ) + calldatacopy(add(target, 32), add(pubkeys.offset, mul(keyIndex, PUBLIC_KEY_LENGTH)), PUBLIC_KEY_LENGTH) } } @@ -131,7 +120,7 @@ library TriggerableWithdrawals { } function _validateAndCountPubkeys(bytes calldata pubkeys) private pure returns (uint256) { - if(pubkeys.length % PUBLIC_KEY_LENGTH != 0) { + if (pubkeys.length % PUBLIC_KEY_LENGTH != 0) { revert InvalidPublicKeyLength(); } @@ -154,7 +143,7 @@ library TriggerableWithdrawals { revert InsufficientRequestFee(feePerRequest, minFeePerRequest); } - if(address(this).balance < feePerRequest * keysCount) { + if (address(this).balance < feePerRequest * keysCount) { revert InsufficientBalance(address(this).balance, feePerRequest * keysCount); } From 6f303e572d12c0b138ffce6d0e683ae85f362f3b Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Mon, 27 Jan 2025 18:29:47 +0100 Subject: [PATCH 13/21] refactor: move TriggerableWithdrawals lib from 0.8.9 to common --- contracts/0.8.9/WithdrawalVault.sol | 4 ++-- .../lib/TriggerableWithdrawals.sol | 6 ++++-- test/0.8.9/withdrawalVault.test.ts | 8 ++++---- .../EIP7002WithdrawalRequest_Mock.sol} | 13 ++++++------- .../TriggerableWithdrawals_Harness.sol | 19 +++++++++---------- .../lib/triggerableWithdrawals/eip7002Mock.ts | 0 .../triggerableWithdrawals.test.ts | 4 ++-- .../lib/triggerableWithdrawals/utils.ts | 8 ++++---- 8 files changed, 31 insertions(+), 31 deletions(-) rename contracts/{0.8.9 => common}/lib/TriggerableWithdrawals.sol (97%) rename test/{0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol => common/contracts/EIP7002WithdrawalRequest_Mock.sol} (81%) rename test/{0.8.9 => common}/contracts/TriggerableWithdrawals_Harness.sol (65%) rename test/{0.8.9 => common}/lib/triggerableWithdrawals/eip7002Mock.ts (100%) rename test/{0.8.9 => common}/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts (99%) rename test/{0.8.9 => common}/lib/triggerableWithdrawals/utils.ts (83%) diff --git a/contracts/0.8.9/WithdrawalVault.sol b/contracts/0.8.9/WithdrawalVault.sol index c47011914..5ef5ee8ab 100644 --- a/contracts/0.8.9/WithdrawalVault.sol +++ b/contracts/0.8.9/WithdrawalVault.sol @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2023 Lido +// SPDX-FileCopyrightText: 2025 Lido // SPDX-License-Identifier: GPL-3.0 /* See contracts/COMPILERS.md */ @@ -10,7 +10,7 @@ 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 {TriggerableWithdrawals} from "../common/lib/TriggerableWithdrawals.sol"; import {ILidoLocator} from "../common/interfaces/ILidoLocator.sol"; interface ILido { diff --git a/contracts/0.8.9/lib/TriggerableWithdrawals.sol b/contracts/common/lib/TriggerableWithdrawals.sol similarity index 97% rename from contracts/0.8.9/lib/TriggerableWithdrawals.sol rename to contracts/common/lib/TriggerableWithdrawals.sol index 3bd8425a4..3c1ce0a51 100644 --- a/contracts/0.8.9/lib/TriggerableWithdrawals.sol +++ b/contracts/common/lib/TriggerableWithdrawals.sol @@ -1,7 +1,9 @@ -// SPDX-FileCopyrightText: 2023 Lido +// SPDX-FileCopyrightText: 2025 Lido // SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.9; +/* See contracts/COMPILERS.md */ +// solhint-disable-next-line lido/fixed-compiler-version +pragma solidity >=0.8.9 <0.9.0; library TriggerableWithdrawals { address constant WITHDRAWAL_REQUEST = 0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA; uint256 internal constant WITHDRAWAL_REQUEST_CALLDATA_LENGTH = 56; diff --git a/test/0.8.9/withdrawalVault.test.ts b/test/0.8.9/withdrawalVault.test.ts index e4bc64f17..92eb532c4 100644 --- a/test/0.8.9/withdrawalVault.test.ts +++ b/test/0.8.9/withdrawalVault.test.ts @@ -6,10 +6,10 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; import { + EIP7002WithdrawalRequest_Mock, ERC20__Harness, ERC721__Harness, Lido__MockForWithdrawalVault, - WithdrawalsPredeployed_Mock, WithdrawalVault__Harness, } from "typechain-types"; @@ -17,12 +17,12 @@ import { MAX_UINT256, proxify, streccak } from "lib"; import { Snapshot } from "test/suite"; -import { findEip7002MockEvents, testEip7002Mock } from "./lib/triggerableWithdrawals/eip7002Mock"; +import { findEip7002MockEvents, testEip7002Mock } from "../common/lib/triggerableWithdrawals/eip7002Mock"; import { deployWithdrawalsPredeployedMock, generateWithdrawalRequestPayload, withdrawalsPredeployedHardcodedAddress, -} from "./lib/triggerableWithdrawals/utils"; +} from "../common/lib/triggerableWithdrawals/utils"; const PETRIFIED_VERSION = MAX_UINT256; @@ -39,7 +39,7 @@ describe("WithdrawalVault.sol", () => { let lido: Lido__MockForWithdrawalVault; let lidoAddress: string; - let withdrawalsPredeployed: WithdrawalsPredeployed_Mock; + let withdrawalsPredeployed: EIP7002WithdrawalRequest_Mock; let impl: WithdrawalVault__Harness; let vault: WithdrawalVault__Harness; diff --git a/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol b/test/common/contracts/EIP7002WithdrawalRequest_Mock.sol similarity index 81% rename from test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol rename to test/common/contracts/EIP7002WithdrawalRequest_Mock.sol index 25581ff79..8ea01a81d 100644 --- a/test/0.8.9/contracts/predeployed/WithdrawalsPredeployed_Mock.sol +++ b/test/common/contracts/EIP7002WithdrawalRequest_Mock.sol @@ -1,10 +1,12 @@ // SPDX-License-Identifier: UNLICENSED +// for testing purposes only + pragma solidity 0.8.9; /** - * @notice This is an mock of EIP-7002's pre-deploy contract. + * @notice This is a mock of EIP-7002's pre-deploy contract. */ -contract WithdrawalsPredeployed_Mock { +contract EIP7002WithdrawalRequest_Mock { uint256 public fee; bool public failOnAddRequest; bool public failOnGetFee; @@ -24,7 +26,7 @@ contract WithdrawalsPredeployed_Mock { fee = _fee; } - fallback(bytes calldata input) external payable returns (bytes memory output){ + fallback(bytes calldata input) external payable returns (bytes memory output) { if (input.length == 0) { require(!failOnGetFee, "fail on get fee"); @@ -36,9 +38,6 @@ contract WithdrawalsPredeployed_Mock { require(input.length == 56, "Invalid callData length"); - emit eip7002MockRequestAdded( - input, - msg.value - ); + emit eip7002MockRequestAdded(input, msg.value); } } diff --git a/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol b/test/common/contracts/TriggerableWithdrawals_Harness.sol similarity index 65% rename from test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol rename to test/common/contracts/TriggerableWithdrawals_Harness.sol index 1ea18a48b..a29db8a05 100644 --- a/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol +++ b/test/common/contracts/TriggerableWithdrawals_Harness.sol @@ -1,12 +1,15 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + pragma solidity 0.8.9; -import {TriggerableWithdrawals} from "contracts/0.8.9/lib/TriggerableWithdrawals.sol"; +import {TriggerableWithdrawals} from "contracts/common/lib/TriggerableWithdrawals.sol"; +/** + * @notice This is a harness of TriggerableWithdrawals library. + */ contract TriggerableWithdrawals_Harness { - function addFullWithdrawalRequests( - bytes calldata pubkeys, - uint256 feePerRequest - ) external { + function addFullWithdrawalRequests(bytes calldata pubkeys, uint256 feePerRequest) external { TriggerableWithdrawals.addFullWithdrawalRequests(pubkeys, feePerRequest); } @@ -18,11 +21,7 @@ contract TriggerableWithdrawals_Harness { TriggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, feePerRequest); } - function addWithdrawalRequests( - bytes calldata pubkeys, - uint64[] calldata amounts, - uint256 feePerRequest - ) external { + function addWithdrawalRequests(bytes calldata pubkeys, uint64[] calldata amounts, uint256 feePerRequest) external { TriggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, feePerRequest); } diff --git a/test/0.8.9/lib/triggerableWithdrawals/eip7002Mock.ts b/test/common/lib/triggerableWithdrawals/eip7002Mock.ts similarity index 100% rename from test/0.8.9/lib/triggerableWithdrawals/eip7002Mock.ts rename to test/common/lib/triggerableWithdrawals/eip7002Mock.ts diff --git a/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts b/test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts similarity index 99% rename from test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts rename to test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts index 5600a7e27..07f7214e6 100644 --- a/test/0.8.9/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts +++ b/test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts @@ -5,7 +5,7 @@ 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 { EIP7002WithdrawalRequest_Mock, TriggerableWithdrawals_Harness } from "typechain-types"; import { Snapshot } from "test/suite"; @@ -21,7 +21,7 @@ const EMPTY_PUBKEYS = "0x"; describe("TriggerableWithdrawals.sol", () => { let actor: HardhatEthersSigner; - let withdrawalsPredeployed: WithdrawalsPredeployed_Mock; + let withdrawalsPredeployed: EIP7002WithdrawalRequest_Mock; let triggerableWithdrawals: TriggerableWithdrawals_Harness; let originalState: string; diff --git a/test/0.8.9/lib/triggerableWithdrawals/utils.ts b/test/common/lib/triggerableWithdrawals/utils.ts similarity index 83% rename from test/0.8.9/lib/triggerableWithdrawals/utils.ts rename to test/common/lib/triggerableWithdrawals/utils.ts index 676cd9ac8..d98b8a987 100644 --- a/test/0.8.9/lib/triggerableWithdrawals/utils.ts +++ b/test/common/lib/triggerableWithdrawals/utils.ts @@ -1,13 +1,13 @@ import { ethers } from "hardhat"; -import { WithdrawalsPredeployed_Mock } from "typechain-types"; +import { EIP7002WithdrawalRequest_Mock } from "typechain-types"; export const withdrawalsPredeployedHardcodedAddress = "0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA"; export async function deployWithdrawalsPredeployedMock( defaultRequestFee: bigint, -): Promise { - const withdrawalsPredeployed = await ethers.deployContract("WithdrawalsPredeployed_Mock"); +): Promise { + const withdrawalsPredeployed = await ethers.deployContract("EIP7002WithdrawalRequest_Mock"); const withdrawalsPredeployedAddress = await withdrawalsPredeployed.getAddress(); await ethers.provider.send("hardhat_setCode", [ @@ -15,7 +15,7 @@ export async function deployWithdrawalsPredeployedMock( await ethers.provider.getCode(withdrawalsPredeployedAddress), ]); - const contract = await ethers.getContractAt("WithdrawalsPredeployed_Mock", withdrawalsPredeployedHardcodedAddress); + const contract = await ethers.getContractAt("EIP7002WithdrawalRequest_Mock", withdrawalsPredeployedHardcodedAddress); await contract.setFee(defaultRequestFee); return contract; } From ade67a7704147877d8e0acdf852fcb24b3877e18 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Tue, 28 Jan 2025 09:41:44 +0100 Subject: [PATCH 14/21] refactor: improve naming for address validation utility --- contracts/0.8.9/WithdrawalVault.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/0.8.9/WithdrawalVault.sol b/contracts/0.8.9/WithdrawalVault.sol index 5ef5ee8ab..8aa5d5a09 100644 --- a/contracts/0.8.9/WithdrawalVault.sol +++ b/contracts/0.8.9/WithdrawalVault.sol @@ -63,8 +63,8 @@ contract WithdrawalVault is AccessControlEnumerable, Versioned { * @param _treasury the Lido treasury address (see ERC20/ERC721-recovery interfaces) */ constructor(address _lido, address _treasury) { - _requireNonZero(_lido); - _requireNonZero(_treasury); + _onlyNonZeroAddress(_lido); + _onlyNonZeroAddress(_treasury); LIDO = ILido(_lido); TREASURY = _treasury; @@ -181,12 +181,12 @@ contract WithdrawalVault is AccessControlEnumerable, Versioned { return TriggerableWithdrawals.getWithdrawalRequestFee(); } - function _requireNonZero(address _address) internal pure { + function _onlyNonZeroAddress(address _address) internal pure { if (_address == address(0)) revert ZeroAddress(); } function _initialize_v2(address _admin) internal { - _requireNonZero(_admin); + _onlyNonZeroAddress(_admin); _setupRole(DEFAULT_ADMIN_ROLE, _admin); } } From 89d583aa37e993cf188c876c0bc17d0a8d0e5f7d Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Tue, 28 Jan 2025 13:09:59 +0100 Subject: [PATCH 15/21] test: add unit tests for Withdrawal Vault excess fee refund behavior --- test/0.8.9/contracts/RefundFailureTester.sol | 31 +++++++++ test/0.8.9/withdrawalVault.test.ts | 68 +++++++++++++++++-- .../lib/triggerableWithdrawals/eip7002Mock.ts | 9 ++- 3 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 test/0.8.9/contracts/RefundFailureTester.sol diff --git a/test/0.8.9/contracts/RefundFailureTester.sol b/test/0.8.9/contracts/RefundFailureTester.sol new file mode 100644 index 000000000..0363e87cf --- /dev/null +++ b/test/0.8.9/contracts/RefundFailureTester.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.9; + +interface IWithdrawalVault { + function addFullWithdrawalRequests(bytes calldata pubkeys) external payable; + function getWithdrawalRequestFee() external view returns (uint256); +} + +/** + * @notice This is a contract for testing refund failure in WithdrawalVault contract + */ +contract RefundFailureTester { + IWithdrawalVault private immutable withdrawalVault; + + constructor(address _withdrawalVault) { + withdrawalVault = IWithdrawalVault(_withdrawalVault); + } + + receive() external payable { + revert("Refund failed intentionally"); + } + + function addFullWithdrawalRequests(bytes calldata pubkeys) external payable { + require(msg.value > withdrawalVault.getWithdrawalRequestFee(), "Not enough eth for Refund"); + + // withdrawal vault should fail to refund + withdrawalVault.addFullWithdrawalRequests{value: msg.value}(pubkeys); + } +} diff --git a/test/0.8.9/withdrawalVault.test.ts b/test/0.8.9/withdrawalVault.test.ts index 92eb532c4..dea0118c8 100644 --- a/test/0.8.9/withdrawalVault.test.ts +++ b/test/0.8.9/withdrawalVault.test.ts @@ -10,6 +10,7 @@ import { ERC20__Harness, ERC721__Harness, Lido__MockForWithdrawalVault, + RefundFailureTester, WithdrawalVault__Harness, } from "typechain-types"; @@ -389,6 +390,34 @@ describe("WithdrawalVault.sol", () => { ).to.be.revertedWithCustomError(vault, "WithdrawalRequestFeeReadFailed"); }); + it("should revert if refund failed", async function () { + const refundFailureTester: RefundFailureTester = await ethers.deployContract("RefundFailureTester", [ + vaultAddress, + ]); + const refundFailureTesterAddress = await refundFailureTester.getAddress(); + + await vault.connect(owner).grantRole(ADD_FULL_WITHDRAWAL_REQUEST_ROLE, refundFailureTesterAddress); + + const requestCount = 3; + const { pubkeysHexString } = generateWithdrawalRequestPayload(requestCount); + + const fee = 3n; + await withdrawalsPredeployed.setFee(fee); + const expectedTotalWithdrawalFee = 9n; // 3 requests * 3 gwei (fee) = 9 gwei + + await expect( + refundFailureTester + .connect(stranger) + .addFullWithdrawalRequests(pubkeysHexString, { value: expectedTotalWithdrawalFee + 1n }), + ).to.be.revertedWithCustomError(vault, "TriggerableWithdrawalRefundFailed"); + + await expect( + refundFailureTester + .connect(stranger) + .addFullWithdrawalRequests(pubkeysHexString, { value: expectedTotalWithdrawalFee + ethers.parseEther("1") }), + ).to.be.revertedWithCustomError(vault, "TriggerableWithdrawalRefundFailed"); + }); + 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); @@ -486,7 +515,31 @@ describe("WithdrawalVault.sol", () => { expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); }); - // ToDo: should return back the excess fee + it("Should refund excess fee", 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 excessFee = 1n; + + const vebInitialBalance = await ethers.provider.getBalance(validatorsExitBus.address); + + const { receipt } = await testEip7002Mock( + () => + vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: expectedTotalWithdrawalFee + excessFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + + expect(await ethers.provider.getBalance(validatorsExitBus.address)).to.equal( + vebInitialBalance - expectedTotalWithdrawalFee - receipt.gasUsed * receipt.gasPrice, + ); + }); it("Should transfer the total calculated fee to the EIP-7002 withdrawal contract", async function () { const requestCount = 3; @@ -566,18 +619,25 @@ describe("WithdrawalVault.sol", () => { 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 expectedTotalWithdrawalFee = expectedFee * BigInt(requestCount); const initialBalance = await getWithdrawalCredentialsContractBalance(); + const vebInitialBalance = await ethers.provider.getBalance(validatorsExitBus.address); - await testEip7002Mock( - () => vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: withdrawalFee }), + const { receipt } = await testEip7002Mock( + () => + vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: expectedTotalWithdrawalFee + extraFee }), pubkeys, fullWithdrawalAmounts, expectedFee, ); expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); + expect(await ethers.provider.getBalance(validatorsExitBus.address)).to.equal( + vebInitialBalance - expectedTotalWithdrawalFee - receipt.gasUsed * receipt.gasPrice, + ); }); }); }); diff --git a/test/common/lib/triggerableWithdrawals/eip7002Mock.ts b/test/common/lib/triggerableWithdrawals/eip7002Mock.ts index 5fd83ae17..a23d7c89e 100644 --- a/test/common/lib/triggerableWithdrawals/eip7002Mock.ts +++ b/test/common/lib/triggerableWithdrawals/eip7002Mock.ts @@ -1,6 +1,5 @@ import { expect } from "chai"; -import { ContractTransactionReceipt } from "ethers"; -import { ContractTransactionResponse } from "ethers"; +import { ContractTransactionReceipt, ContractTransactionResponse } from "ethers"; import { ethers } from "hardhat"; import { findEventsWithInterfaces } from "lib"; @@ -25,7 +24,7 @@ export const testEip7002Mock = async ( expectedPubkeys: string[], expectedAmounts: bigint[], expectedFee: bigint, -) => { +): Promise<{ tx: ContractTransactionResponse; receipt: ContractTransactionReceipt }> => { const tx = await addTriggeranleWithdrawalRequests(); const receipt = await tx.wait(); @@ -37,5 +36,9 @@ export const testEip7002Mock = async ( expect(events[i].args[1]).to.equal(expectedFee); } + if (!receipt) { + throw new Error("No receipt"); + } + return { tx, receipt }; }; From cfadfb437c40c7740aaf0538c0247320d529ac03 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Tue, 28 Jan 2025 15:44:07 +0100 Subject: [PATCH 16/21] refactor: improve TriggerableWithdrawals lib methods description --- .../common/lib/TriggerableWithdrawals.sol | 80 +++++++++++++++---- 1 file changed, 64 insertions(+), 16 deletions(-) diff --git a/contracts/common/lib/TriggerableWithdrawals.sol b/contracts/common/lib/TriggerableWithdrawals.sol index 3c1ce0a51..a5e265f5f 100644 --- a/contracts/common/lib/TriggerableWithdrawals.sol +++ b/contracts/common/lib/TriggerableWithdrawals.sol @@ -4,6 +4,11 @@ /* See contracts/COMPILERS.md */ // solhint-disable-next-line lido/fixed-compiler-version pragma solidity >=0.8.9 <0.9.0; + +/** + * @title A lib for EIP-7002: Execution layer triggerable withdrawals. + * Allow validators to trigger withdrawals and exits from their execution layer (0x01) withdrawal credentials. + */ library TriggerableWithdrawals { address constant WITHDRAWAL_REQUEST = 0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA; uint256 internal constant WITHDRAWAL_REQUEST_CALLDATA_LENGTH = 56; @@ -21,9 +26,20 @@ library TriggerableWithdrawals { 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. + * @dev Send EIP-7002 full withdrawal requests for the specified public keys. + * Each request instructs a validator to fully withdraw its stake and exit its duties as a validator. + * + * @param pubkeys A tightly packed array of 48-byte public keys corresponding to validators requesting full withdrawals. + * | ----- public key (48 bytes) ----- || ----- public key (48 bytes) ----- | ... + * + * @param feePerRequest The withdrawal fee for each withdrawal request. + * - Must be greater than or equal to the current minimal withdrawal fee. + * - If set to zero, the current minimal withdrawal fee will be used automatically. + * + * @notice Reverts if: + * - Validation of the public keys fails. + * - The provided fee per request is insufficient. + * - The contract has an insufficient balance to cover the total fees. */ function addFullWithdrawalRequests(bytes calldata pubkeys, uint256 feePerRequest) internal { uint256 keysCount = _validateAndCountPubkeys(pubkeys); @@ -43,13 +59,27 @@ library TriggerableWithdrawals { } /** - * @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. + * @dev Send EIP-7002 partial withdrawal requests for the specified public keys with corresponding amounts. + * Each request instructs a validator to partially withdraw its stake. + * A partial withdrawal is any withdrawal where the amount is greater than zero, + * allows withdrawal of any balance exceeding 32 ETH (e.g., if a validator has 35 ETH, up to 3 ETH can be withdrawn), + * the protocol enforces a minimum balance of 32 ETH per validator, even if a higher amount is requested. + * + * @param pubkeys A tightly packed array of 48-byte public keys corresponding to validators requesting full withdrawals. + * | ----- public key (48 bytes) ----- || ----- public key (48 bytes) ----- | ... + * + * @param amounts An array of corresponding partial withdrawal amounts for each public key. + * + * @param feePerRequest The withdrawal fee for each withdrawal request. + * - Must be greater than or equal to the current minimal withdrawal fee. + * - If set to zero, the current minimal withdrawal fee will be used automatically. + * + * @notice Reverts if: + * - Validation of the public keys fails. + * - The pubkeys and amounts length mismatch. + * - Full withdrawal requested for any pubkeys (withdrawal amount = 0). + * - The provided fee per request is insufficient. + * - The contract has an insufficient balance to cover the total fees. */ function addPartialWithdrawalRequests( bytes calldata pubkeys, @@ -66,12 +96,30 @@ library TriggerableWithdrawals { } /** - * @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. + * @dev Send EIP-7002 partial or full withdrawal requests for the specified public keys with corresponding amounts. + * Each request instructs a validator to partially or fully withdraw its stake. + + * 1. A partial withdrawal is any withdrawal where the amount is greater than zero, + * allows withdrawal of any balance exceeding 32 ETH (e.g., if a validator has 35 ETH, up to 3 ETH can be withdrawn), + * the protocol enforces a minimum balance of 32 ETH per validator, even if a higher amount is requested. + * + * 2. A full withdrawal is a withdrawal where the amount is equal to zero, + * allows to fully withdraw validator stake and exit its duties as a validator. + * + * @param pubkeys A tightly packed array of 48-byte public keys corresponding to validators requesting full withdrawals. + * | ----- public key (48 bytes) ----- || ----- public key (48 bytes) ----- | ... + * + * @param amounts An array of corresponding partial withdrawal amounts for each public key. + * + * @param feePerRequest The withdrawal fee for each withdrawal request. + * - Must be greater than or equal to the current minimal withdrawal fee. + * - If set to zero, the current minimal withdrawal fee will be used automatically. + * + * @notice Reverts if: + * - Validation of the public keys fails. + * - The pubkeys and amounts length mismatch. + * - The provided fee per request is insufficient. + * - The contract has an insufficient balance to cover the total fees. */ function addWithdrawalRequests(bytes calldata pubkeys, uint64[] calldata amounts, uint256 feePerRequest) internal { uint256 keysCount = _validateAndCountPubkeys(pubkeys); From 9f268cf5a3982cb565d71525fbe04e5cfbc64a81 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Tue, 28 Jan 2025 16:46:15 +0100 Subject: [PATCH 17/21] refactor: triggerable withdrawals lib rename errors for clarity --- .../common/lib/TriggerableWithdrawals.sol | 24 +++++++------- test/0.8.9/withdrawalVault.test.ts | 11 +++---- .../triggerableWithdrawals.test.ts | 32 +++++++++---------- 3 files changed, 33 insertions(+), 34 deletions(-) diff --git a/contracts/common/lib/TriggerableWithdrawals.sol b/contracts/common/lib/TriggerableWithdrawals.sol index a5e265f5f..cba619896 100644 --- a/contracts/common/lib/TriggerableWithdrawals.sol +++ b/contracts/common/lib/TriggerableWithdrawals.sol @@ -11,19 +11,21 @@ pragma solidity >=0.8.9 <0.9.0; */ 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; + uint256 internal constant WITHDRAWAL_REQUEST_CALLDATA_LENGTH = 56; - error MismatchedArrayLengths(uint256 keysCount, uint256 amountsCount); - error InsufficientBalance(uint256 balance, uint256 totalWithdrawalFee); - error InsufficientRequestFee(uint256 feePerRequest, uint256 minFeePerRequest); - - error WithdrawalRequestFeeReadFailed(); + error WithdrawalFeeReadFailed(); error WithdrawalRequestAdditionFailed(bytes callData); + + error InsufficientWithdrawalFee(uint256 feePerRequest, uint256 minFeePerRequest); + error TotalWithdrawalFeeExceededBalance(uint256 balance, uint256 totalWithdrawalFee); + error NoWithdrawalRequests(); + error MalformedPubkeysArray(); error PartialWithdrawalRequired(uint256 index); - error InvalidPublicKeyLength(); + error MismatchedArrayLengths(uint256 keysCount, uint256 amountsCount); /** * @dev Send EIP-7002 full withdrawal requests for the specified public keys. @@ -151,7 +153,7 @@ library TriggerableWithdrawals { (bool success, bytes memory feeData) = WITHDRAWAL_REQUEST.staticcall(""); if (!success) { - revert WithdrawalRequestFeeReadFailed(); + revert WithdrawalFeeReadFailed(); } return abi.decode(feeData, (uint256)); @@ -171,7 +173,7 @@ library TriggerableWithdrawals { function _validateAndCountPubkeys(bytes calldata pubkeys) private pure returns (uint256) { if (pubkeys.length % PUBLIC_KEY_LENGTH != 0) { - revert InvalidPublicKeyLength(); + revert MalformedPubkeysArray(); } uint256 keysCount = pubkeys.length / PUBLIC_KEY_LENGTH; @@ -190,11 +192,11 @@ library TriggerableWithdrawals { } if (feePerRequest < minFeePerRequest) { - revert InsufficientRequestFee(feePerRequest, minFeePerRequest); + revert InsufficientWithdrawalFee(feePerRequest, minFeePerRequest); } if (address(this).balance < feePerRequest * keysCount) { - revert InsufficientBalance(address(this).balance, feePerRequest * keysCount); + revert TotalWithdrawalFeeExceededBalance(address(this).balance, feePerRequest * keysCount); } return feePerRequest; diff --git a/test/0.8.9/withdrawalVault.test.ts b/test/0.8.9/withdrawalVault.test.ts index dea0118c8..bfe3e97d2 100644 --- a/test/0.8.9/withdrawalVault.test.ts +++ b/test/0.8.9/withdrawalVault.test.ts @@ -282,10 +282,7 @@ describe("WithdrawalVault.sol", () => { it("Should revert if fee read fails", async function () { await withdrawalsPredeployed.setFailOnGetFee(true); - await expect(vault.getWithdrawalRequestFee()).to.be.revertedWithCustomError( - vault, - "WithdrawalRequestFeeReadFailed", - ); + await expect(vault.getWithdrawalRequestFee()).to.be.revertedWithCustomError(vault, "WithdrawalFeeReadFailed"); }); }); @@ -351,7 +348,7 @@ describe("WithdrawalVault.sol", () => { await expect( vault.connect(validatorsExitBus).addFullWithdrawalRequests(invalidPubkeyHexString, { value: fee }), - ).to.be.revertedWithCustomError(vault, "InvalidPublicKeyLength"); + ).to.be.revertedWithCustomError(vault, "MalformedPubkeysArray"); }); it("Should revert if last pubkey not 48 bytes", async function () { @@ -364,7 +361,7 @@ describe("WithdrawalVault.sol", () => { await expect( vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: fee }), - ).to.be.revertedWithCustomError(vault, "InvalidPublicKeyLength"); + ).to.be.revertedWithCustomError(vault, "MalformedPubkeysArray"); }); it("Should revert if addition fails at the withdrawal request contract", async function () { @@ -387,7 +384,7 @@ describe("WithdrawalVault.sol", () => { await expect( vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: fee }), - ).to.be.revertedWithCustomError(vault, "WithdrawalRequestFeeReadFailed"); + ).to.be.revertedWithCustomError(vault, "WithdrawalFeeReadFailed"); }); it("should revert if refund failed", async function () { diff --git a/test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts b/test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts index 07f7214e6..39b69836e 100644 --- a/test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts +++ b/test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts @@ -78,7 +78,7 @@ describe("TriggerableWithdrawals.sol", () => { await withdrawalsPredeployed.setFailOnGetFee(true); await expect(triggerableWithdrawals.getWithdrawalRequestFee()).to.be.revertedWithCustomError( triggerableWithdrawals, - "WithdrawalRequestFeeReadFailed", + "WithdrawalFeeReadFailed", ); }); }); @@ -133,15 +133,15 @@ describe("TriggerableWithdrawals.sol", () => { // 2. Should revert if fee is less than required const insufficientFee = 2n; await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, insufficientFee)) - .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientRequestFee") + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientWithdrawalFee") .withArgs(2n, 3n); await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, insufficientFee)) - .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientRequestFee") + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientWithdrawalFee") .withArgs(2n, 3n); await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, amounts, insufficientFee)) - .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientRequestFee") + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientWithdrawalFee") .withArgs(2n, 3n); }); @@ -154,15 +154,15 @@ describe("TriggerableWithdrawals.sol", () => { await expect( triggerableWithdrawals.addFullWithdrawalRequests(invalidPubkeyHexString, fee), - ).to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPublicKeyLength"); + ).to.be.revertedWithCustomError(triggerableWithdrawals, "MalformedPubkeysArray"); await expect( triggerableWithdrawals.addPartialWithdrawalRequests(invalidPubkeyHexString, amounts, fee), - ).to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPublicKeyLength"); + ).to.be.revertedWithCustomError(triggerableWithdrawals, "MalformedPubkeysArray"); await expect( triggerableWithdrawals.addWithdrawalRequests(invalidPubkeyHexString, amounts, fee), - ).to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPublicKeyLength"); + ).to.be.revertedWithCustomError(triggerableWithdrawals, "MalformedPubkeysArray"); }); it("Should revert if last pubkey not 48 bytes", async function () { @@ -177,15 +177,15 @@ describe("TriggerableWithdrawals.sol", () => { await expect( triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), - ).to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPublicKeyLength"); + ).to.be.revertedWithCustomError(triggerableWithdrawals, "MalformedPubkeysArray"); await expect( triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, fee), - ).to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPublicKeyLength"); + ).to.be.revertedWithCustomError(triggerableWithdrawals, "MalformedPubkeysArray"); await expect( triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, amounts, fee), - ).to.be.revertedWithCustomError(triggerableWithdrawals, "InvalidPublicKeyLength"); + ).to.be.revertedWithCustomError(triggerableWithdrawals, "MalformedPubkeysArray"); }); it("Should revert if addition fails at the withdrawal request contract", async function () { @@ -233,15 +233,15 @@ describe("TriggerableWithdrawals.sol", () => { await setBalance(await triggerableWithdrawals.getAddress(), balance); await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee)) - .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalance") + .to.be.revertedWithCustomError(triggerableWithdrawals, "TotalWithdrawalFeeExceededBalance") .withArgs(balance, expectedMinimalBalance); await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee)) - .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalance") + .to.be.revertedWithCustomError(triggerableWithdrawals, "TotalWithdrawalFeeExceededBalance") .withArgs(balance, expectedMinimalBalance); await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee)) - .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalance") + .to.be.revertedWithCustomError(triggerableWithdrawals, "TotalWithdrawalFeeExceededBalance") .withArgs(balance, expectedMinimalBalance); }); @@ -254,15 +254,15 @@ describe("TriggerableWithdrawals.sol", () => { await expect( triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), - ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestFeeReadFailed"); + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalFeeReadFailed"); await expect( triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), - ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestFeeReadFailed"); + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalFeeReadFailed"); await expect( triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), - ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestFeeReadFailed"); + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalFeeReadFailed"); }); it("Should accept withdrawal requests with minimal possible fee when fee not provided", async function () { From 811fdf814ee7fb9b68b60a1e2194777e7db88206 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Tue, 28 Jan 2025 17:15:33 +0100 Subject: [PATCH 18/21] refactor: describe full withdrawal method in withdrawal vault --- contracts/0.8.9/WithdrawalVault.sol | 20 ++++++++++++++++--- .../common/lib/TriggerableWithdrawals.sol | 2 +- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/contracts/0.8.9/WithdrawalVault.sol b/contracts/0.8.9/WithdrawalVault.sol index 8aa5d5a09..9df5e186f 100644 --- a/contracts/0.8.9/WithdrawalVault.sol +++ b/contracts/0.8.9/WithdrawalVault.sol @@ -143,9 +143,19 @@ contract WithdrawalVault is AccessControlEnumerable, Versioned { } /** - * @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. + * @dev Submits EIP-7002 full withdrawal requests for the specified public keys. + * Each request instructs a validator to fully withdraw its stake and exit its duties as a validator. + * Refunds any excess fee to the caller after deducting the total fees, + * which are calculated based on the number of public keys and the current minimum fee per withdrawal request. + * + * @param pubkeys A tightly packed array of 48-byte public keys corresponding to validators requesting full withdrawals. + * | ----- public key (48 bytes) ----- || ----- public key (48 bytes) ----- | ... + * + * @notice Reverts if: + * - The caller does not have the `ADD_FULL_WITHDRAWAL_REQUEST_ROLE`. + * - Validation of any of the provided public keys fails. + * - The provided total withdrawal fee is insufficient to cover all requests. + * - Refund of the excess fee fails. */ function addFullWithdrawalRequests( bytes calldata pubkeys @@ -177,6 +187,10 @@ contract WithdrawalVault is AccessControlEnumerable, Versioned { assert(address(this).balance == prevBalance); } + /** + * @dev Retrieves the current EIP-7002 withdrawal fee. + * @return The minimum fee required per withdrawal request. + */ function getWithdrawalRequestFee() external view returns (uint256) { return TriggerableWithdrawals.getWithdrawalRequestFee(); } diff --git a/contracts/common/lib/TriggerableWithdrawals.sol b/contracts/common/lib/TriggerableWithdrawals.sol index cba619896..30b94fdfe 100644 --- a/contracts/common/lib/TriggerableWithdrawals.sol +++ b/contracts/common/lib/TriggerableWithdrawals.sol @@ -146,7 +146,7 @@ library TriggerableWithdrawals { } /** - * @dev Retrieves the current withdrawal request fee. + * @dev Retrieves the current EIP-7002 withdrawal fee. * @return The minimum fee required per withdrawal request. */ function getWithdrawalRequestFee() internal view returns (uint256) { From 6da1d6f7f4fbf2d112e24e1b38798cc61d33e935 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Sat, 1 Feb 2025 11:47:49 +0100 Subject: [PATCH 19/21] feat: grant withdrawal request role to ValidatorsExitBusOracle contract during scratch deploy Grant ADD_FULL_WITHDRAWAL_REQUEST_ROLE to ValidatorsExitBusOracle contract --- scripts/scratch/steps/0130-grant-roles.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/scratch/steps/0130-grant-roles.ts b/scripts/scratch/steps/0130-grant-roles.ts index 2ef6f4f5e..f332bc840 100644 --- a/scripts/scratch/steps/0130-grant-roles.ts +++ b/scripts/scratch/steps/0130-grant-roles.ts @@ -1,6 +1,12 @@ import { ethers } from "hardhat"; -import { Burner, StakingRouter, ValidatorsExitBusOracle, WithdrawalQueueERC721 } from "typechain-types"; +import { + Burner, + StakingRouter, + ValidatorsExitBusOracle, + WithdrawalQueueERC721, + WithdrawalVault, +} from "typechain-types"; import { loadContract } from "lib/contract"; import { makeTx } from "lib/deploy"; @@ -19,6 +25,7 @@ export async function main() { const burnerAddress = state[Sk.burner].address; const stakingRouterAddress = state[Sk.stakingRouter].proxy.address; const withdrawalQueueAddress = state[Sk.withdrawalQueueERC721].proxy.address; + const withdrawalVaultAddress = state[Sk.withdrawalVault].proxy.address; const accountingOracleAddress = state[Sk.accountingOracle].proxy.address; const validatorsExitBusOracleAddress = state[Sk.validatorsExitBusOracle].proxy.address; const depositSecurityModuleAddress = state[Sk.depositSecurityModule].address; @@ -77,6 +84,18 @@ export async function main() { from: deployer, }); + // WithdrawalVault + const withdrawalVault = await loadContract("WithdrawalVault", withdrawalVaultAddress); + + await makeTx( + withdrawalVault, + "grantRole", + [await withdrawalVault.ADD_FULL_WITHDRAWAL_REQUEST_ROLE(), validatorsExitBusOracleAddress], + { + from: deployer, + }, + ); + // Burner const burner = await loadContract("Burner", burnerAddress); // NB: REQUEST_BURN_SHARES_ROLE is already granted to Lido in Burner constructor From 1af1d3a24170acad301fc53c4d328f9229b13f1e Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Tue, 4 Feb 2025 14:38:57 +0100 Subject: [PATCH 20/21] feat: validate withdrawal fee response --- .../common/lib/TriggerableWithdrawals.sol | 5 +++ test/0.8.9/withdrawalVault.test.ts | 21 ++++++++++++ .../EIP7002WithdrawalRequest_Mock.sol | 13 +++++--- .../triggerableWithdrawals.test.ts | 33 +++++++++++++++++++ 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/contracts/common/lib/TriggerableWithdrawals.sol b/contracts/common/lib/TriggerableWithdrawals.sol index 30b94fdfe..79916b1a6 100644 --- a/contracts/common/lib/TriggerableWithdrawals.sol +++ b/contracts/common/lib/TriggerableWithdrawals.sol @@ -17,6 +17,7 @@ library TriggerableWithdrawals { uint256 internal constant WITHDRAWAL_REQUEST_CALLDATA_LENGTH = 56; error WithdrawalFeeReadFailed(); + error WithdrawalFeeInvalidData(); error WithdrawalRequestAdditionFailed(bytes callData); error InsufficientWithdrawalFee(uint256 feePerRequest, uint256 minFeePerRequest); @@ -156,6 +157,10 @@ library TriggerableWithdrawals { revert WithdrawalFeeReadFailed(); } + if (feeData.length != 32) { + revert WithdrawalFeeInvalidData(); + } + return abi.decode(feeData, (uint256)); } diff --git a/test/0.8.9/withdrawalVault.test.ts b/test/0.8.9/withdrawalVault.test.ts index bfe3e97d2..a584e896f 100644 --- a/test/0.8.9/withdrawalVault.test.ts +++ b/test/0.8.9/withdrawalVault.test.ts @@ -284,6 +284,14 @@ describe("WithdrawalVault.sol", () => { await withdrawalsPredeployed.setFailOnGetFee(true); await expect(vault.getWithdrawalRequestFee()).to.be.revertedWithCustomError(vault, "WithdrawalFeeReadFailed"); }); + + ["0x", "0x01", "0x" + "0".repeat(61) + "1", "0x" + "0".repeat(65) + "1"].forEach((unexpectedFee) => { + it(`Shoud revert if unexpected fee value ${unexpectedFee} is returned`, async function () { + await withdrawalsPredeployed.setFeeRaw(unexpectedFee); + + await expect(vault.getWithdrawalRequestFee()).to.be.revertedWithCustomError(vault, "WithdrawalFeeInvalidData"); + }); + }); }); async function getFee(): Promise { @@ -387,6 +395,19 @@ describe("WithdrawalVault.sol", () => { ).to.be.revertedWithCustomError(vault, "WithdrawalFeeReadFailed"); }); + ["0x", "0x01", "0x" + "0".repeat(61) + "1", "0x" + "0".repeat(65) + "1"].forEach((unexpectedFee) => { + it(`Shoud revert if unexpected fee value ${unexpectedFee} is returned`, async function () { + await withdrawalsPredeployed.setFeeRaw(unexpectedFee); + + const { pubkeysHexString } = generateWithdrawalRequestPayload(2); + const fee = 10n; + + await expect( + vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: fee }), + ).to.be.revertedWithCustomError(vault, "WithdrawalFeeInvalidData"); + }); + }); + it("should revert if refund failed", async function () { const refundFailureTester: RefundFailureTester = await ethers.deployContract("RefundFailureTester", [ vaultAddress, diff --git a/test/common/contracts/EIP7002WithdrawalRequest_Mock.sol b/test/common/contracts/EIP7002WithdrawalRequest_Mock.sol index 8ea01a81d..4ed806024 100644 --- a/test/common/contracts/EIP7002WithdrawalRequest_Mock.sol +++ b/test/common/contracts/EIP7002WithdrawalRequest_Mock.sol @@ -7,7 +7,7 @@ pragma solidity 0.8.9; * @notice This is a mock of EIP-7002's pre-deploy contract. */ contract EIP7002WithdrawalRequest_Mock { - uint256 public fee; + bytes public fee; bool public failOnAddRequest; bool public failOnGetFee; @@ -23,15 +23,18 @@ contract EIP7002WithdrawalRequest_Mock { function setFee(uint256 _fee) external { require(_fee > 0, "fee must be greater than 0"); - fee = _fee; + fee = abi.encode(_fee); } - fallback(bytes calldata input) external payable returns (bytes memory output) { + function setFeeRaw(bytes calldata _rawFeeBytes) external { + fee = _rawFeeBytes; + } + + fallback(bytes calldata input) external payable returns (bytes memory) { if (input.length == 0) { require(!failOnGetFee, "fail on get fee"); - output = abi.encode(fee); - return output; + return fee; } require(!failOnAddRequest, "fail on add request"); diff --git a/test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts b/test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts index 39b69836e..d3f271d81 100644 --- a/test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts +++ b/test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts @@ -81,6 +81,17 @@ describe("TriggerableWithdrawals.sol", () => { "WithdrawalFeeReadFailed", ); }); + + ["0x", "0x01", "0x" + "0".repeat(61) + "1", "0x" + "0".repeat(65) + "1"].forEach((unexpectedFee) => { + it(`Shoud revert if unexpected fee value ${unexpectedFee} is returned`, async function () { + await withdrawalsPredeployed.setFeeRaw(unexpectedFee); + + await expect(triggerableWithdrawals.getWithdrawalRequestFee()).to.be.revertedWithCustomError( + triggerableWithdrawals, + "WithdrawalFeeInvalidData", + ); + }); + }); }); context("add triggerable withdrawal requests", () => { @@ -265,6 +276,28 @@ describe("TriggerableWithdrawals.sol", () => { ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalFeeReadFailed"); }); + ["0x", "0x01", "0x" + "0".repeat(61) + "1", "0x" + "0".repeat(65) + "1"].forEach((unexpectedFee) => { + it(`Shoud revert if unexpected fee value ${unexpectedFee} is returned`, async function () { + await withdrawalsPredeployed.setFeeRaw(unexpectedFee); + + const { pubkeysHexString, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(2); + const fee = 10n; + + await expect( + triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalFeeInvalidData"); + + await expect( + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalFeeInvalidData"); + + await expect( + triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalFeeInvalidData"); + }); + }); + it("Should accept withdrawal requests with minimal possible fee when fee not provided", async function () { const requestCount = 3; const { pubkeysHexString, pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = From c27de348951788abcc4f29c7cafa24c58fd633e9 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Tue, 4 Feb 2025 14:40:00 +0100 Subject: [PATCH 21/21] feat: update eip-7002 contract address --- contracts/common/lib/TriggerableWithdrawals.sol | 2 +- test/common/lib/triggerableWithdrawals/utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/common/lib/TriggerableWithdrawals.sol b/contracts/common/lib/TriggerableWithdrawals.sol index 79916b1a6..0547065e8 100644 --- a/contracts/common/lib/TriggerableWithdrawals.sol +++ b/contracts/common/lib/TriggerableWithdrawals.sol @@ -10,7 +10,7 @@ pragma solidity >=0.8.9 <0.9.0; * Allow validators to trigger withdrawals and exits from their execution layer (0x01) withdrawal credentials. */ library TriggerableWithdrawals { - address constant WITHDRAWAL_REQUEST = 0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA; + address constant WITHDRAWAL_REQUEST = 0x00000961Ef480Eb55e80D19ad83579A64c007002; uint256 internal constant PUBLIC_KEY_LENGTH = 48; uint256 internal constant WITHDRAWAL_AMOUNT_LENGTH = 8; diff --git a/test/common/lib/triggerableWithdrawals/utils.ts b/test/common/lib/triggerableWithdrawals/utils.ts index d98b8a987..678a4a9fb 100644 --- a/test/common/lib/triggerableWithdrawals/utils.ts +++ b/test/common/lib/triggerableWithdrawals/utils.ts @@ -2,7 +2,7 @@ import { ethers } from "hardhat"; import { EIP7002WithdrawalRequest_Mock } from "typechain-types"; -export const withdrawalsPredeployedHardcodedAddress = "0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA"; +export const withdrawalsPredeployedHardcodedAddress = "0x00000961Ef480Eb55e80D19ad83579A64c007002"; export async function deployWithdrawalsPredeployedMock( defaultRequestFee: bigint,