From 9b454a831193fdd3662f2ea1dcb5da18e7da58d0 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Thu, 19 Dec 2024 19:45:01 +0100 Subject: [PATCH 01/70] 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/70] 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/70] 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/70] 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/70] 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/70] 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/70] 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/70] 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/70] 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/70] 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/70] 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/70] 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/70] 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 60ba435a7d5233a6ad7955db808a2c35418f2f29 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Mon, 27 Jan 2025 16:30:16 +0000 Subject: [PATCH 14/70] chore: basic refactoring of staking vault validator management --- contracts/0.8.25/vaults/Dashboard.sol | 12 +- contracts/0.8.25/vaults/StakingVault.sol | 275 +++++++++--------- .../0.8.25/vaults/VaultValidatorsManager.sol | 123 ++++++++ .../vaults/interfaces/IStakingVault.sol | 18 +- contracts/0.8.9/WithdrawalVault.sol | 10 +- .../lib/TriggerableWithdrawals.sol | 15 +- .../StakingVault__HarnessForTestUpgrade.sol | 2 +- .../0.8.25/vaults/dashboard/dashboard.test.ts | 17 +- ...st.ts => staking-vault.accounting.test.ts} | 243 ++++------------ .../staking-vault.validators.test.ts | 216 ++++++++++++++ .../TriggerableWithdrawals_Harness.sol | 2 +- test/deploy/index.ts | 1 + test/deploy/stakingVault.ts | 62 ++++ .../vaults-happy-path.integration.ts | 2 +- 14 files changed, 643 insertions(+), 355 deletions(-) create mode 100644 contracts/0.8.25/vaults/VaultValidatorsManager.sol rename contracts/{0.8.9 => common}/lib/TriggerableWithdrawals.sol (92%) rename test/0.8.25/vaults/staking-vault/{staking-vault.test.ts => staking-vault.accounting.test.ts} (68%) create mode 100644 test/0.8.25/vaults/staking-vault/staking-vault.validators.test.ts create mode 100644 test/deploy/stakingVault.ts diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index f04b1836c..4352d6fbe 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -264,10 +264,10 @@ contract Dashboard is AccessControlEnumerable { /** * @notice Requests the exit of a validator from the staking vault - * @param _validatorPublicKey Public key of the validator to exit + * @param _validatorPublicKeys Public keys of the validators to exit */ - function requestValidatorExit(bytes calldata _validatorPublicKey) external onlyRole(DEFAULT_ADMIN_ROLE) { - _requestValidatorExit(_validatorPublicKey); + function requestValidatorsExit(bytes calldata _validatorPublicKeys) external onlyRole(DEFAULT_ADMIN_ROLE) { + _requestValidatorsExit(_validatorPublicKeys); } /** @@ -468,10 +468,10 @@ contract Dashboard is AccessControlEnumerable { /** * @dev Requests the exit of a validator from the staking vault - * @param _validatorPublicKey Public key of the validator to exit + * @param _validatorPublicKeys Public key of the validator to exit */ - function _requestValidatorExit(bytes calldata _validatorPublicKey) internal { - stakingVault().requestValidatorExit(_validatorPublicKey); + function _requestValidatorsExit(bytes calldata _validatorPublicKeys) internal { + stakingVault().requestValidatorsExit(_validatorPublicKeys); } /** diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 2e1b911c7..442ec4ec2 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -7,8 +7,8 @@ pragma solidity 0.8.25; import {OwnableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/OwnableUpgradeable.sol"; import {VaultHub} from "./VaultHub.sol"; +import {VaultValidatorsManager} from "./VaultValidatorsManager.sol"; -import {IDepositContract} from "../interfaces/IDepositContract.sol"; import {IStakingVault} from "./interfaces/IStakingVault.sol"; /** @@ -32,18 +32,20 @@ import {IStakingVault} from "./interfaces/IStakingVault.sol"; * - Owner: * - `fund()` * - `withdraw()` - * - `requestValidatorExit()` * - `rebalance()` * - `pauseBeaconChainDeposits()` * - `resumeBeaconChainDeposits()` + * - `requestValidatorsExit()` * - Operator: * - `depositToBeaconChain()` + * - `requestValidatorsExit()` * - VaultHub: * - `lock()` * - `report()` * - `rebalance()` * - Anyone: * - Can send ETH directly to the vault (treated as rewards) + * - `requestValidatorsExit()` if the vault is unbalanced for more than EXIT_TIMELOCK_DURATION days * * BeaconProxy * The contract is designed as a beacon proxy implementation, allowing all StakingVault instances @@ -52,7 +54,7 @@ import {IStakingVault} from "./interfaces/IStakingVault.sol"; * deposit contract. * */ -contract StakingVault is IStakingVault, OwnableUpgradeable { +contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeable { /** * @notice ERC-7201 storage namespace for the vault * @dev ERC-7201 namespace is used to prevent upgrade collisions @@ -67,7 +69,9 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { uint128 locked; int128 inOutDelta; address nodeOperator; + /// Status variables bool beaconChainDepositsPaused; + uint256 unbalancedSince; } /** @@ -82,12 +86,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { */ VaultHub private immutable VAULT_HUB; - /** - * @notice Address of `BeaconChainDepositContract` - * Set immutably in the constructor to avoid storage costs - */ - IDepositContract private immutable BEACON_CHAIN_DEPOSIT_CONTRACT; - /** * @notice Storage offset slot for ERC-7201 namespace * The storage namespace is used to prevent upgrade collisions @@ -96,18 +94,24 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { bytes32 private constant ERC721_STORAGE_LOCATION = 0x2ec50241a851d8d3fea472e7057288d4603f7a7f78e6d18a9c12cad84552b100; + /** + * @notice Update constant for exit timelock duration to 3 days + */ + uint256 private constant EXIT_TIMELOCK_DURATION = 3 days; + /** * @notice Constructs the implementation of `StakingVault` * @param _vaultHub Address of `VaultHub` * @param _beaconChainDepositContract Address of `BeaconChainDepositContract` * @dev Fixes `VaultHub` and `BeaconChainDepositContract` addresses in the bytecode of the implementation */ - constructor(address _vaultHub, address _beaconChainDepositContract) { + constructor( + address _vaultHub, + address _beaconChainDepositContract + ) VaultValidatorsManager(_beaconChainDepositContract) { if (_vaultHub == address(0)) revert ZeroArgument("_vaultHub"); - if (_beaconChainDepositContract == address(0)) revert ZeroArgument("_beaconChainDepositContract"); VAULT_HUB = VaultHub(_vaultHub); - BEACON_CHAIN_DEPOSIT_CONTRACT = IDepositContract(_beaconChainDepositContract); // Prevents reinitialization of the implementation _disableInitializers(); @@ -152,14 +156,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { return address(VAULT_HUB); } - /** - * @notice Returns the address of `BeaconChainDepositContract` - * @return Address of `BeaconChainDepositContract` - */ - function depositContract() external view returns (address) { - return address(BEACON_CHAIN_DEPOSIT_CONTRACT); - } - /** * @notice Returns the total valuation of `StakingVault` * @return Total valuation in ether @@ -219,14 +215,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { return _getStorage().report; } - /** - * @notice Returns whether deposits are paused by the vault owner - * @return True if deposits are paused - */ - function beaconChainDepositsPaused() external view returns (bool) { - return _getStorage().beaconChainDepositsPaused; - } - /** * @notice Returns whether `StakingVault` is balanced, i.e. its valuation is greater than the locked amount * @return True if `StakingVault` is balanced @@ -240,11 +228,20 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { return valuation() >= _getStorage().locked; } + /** + * @notice Returns the timestamp when `StakingVault` became unbalanced + * @return Timestamp when `StakingVault` became unbalanced + * @dev If `StakingVault` is balanced, returns 0 + */ + function unbalancedSince() external view returns (uint256) { + return _getStorage().unbalancedSince; + } + /** * @notice Returns the address of the node operator * Node operator is the party responsible for managing the validators. * In the context of this contract, the node operator performs deposits to the beacon chain - * and processes validator exit requests submitted by `owner` through `requestValidatorExit()`. + * and processes validator exit requests submitted by `owner` through `requestValidatorsExit()`. * Node operator address is set in the initialization and can never be changed. * @return Address of the node operator */ @@ -252,15 +249,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { return _getStorage().nodeOperator; } - /** - * @notice Returns the 0x01-type withdrawal credentials for the validators deposited from this `StakingVault` - * All CL rewards are sent to this contract. Only 0x01-type withdrawal credentials are supported for now. - * @return Withdrawal credentials as bytes32 - */ - function withdrawalCredentials() public view returns (bytes32) { - return bytes32((0x01 << 248) + uint160(address(this))); - } - /** * @notice Accepts direct ether transfers * Ether received through direct transfers is not accounted for in `inOutDelta` @@ -279,6 +267,10 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { ERC7201Storage storage $ = _getStorage(); $.inOutDelta += int128(int256(msg.value)); + if (isBalanced()) { + $.unbalancedSince = 0; + } + emit Funded(msg.sender, msg.value); } @@ -308,44 +300,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { emit Withdrawn(msg.sender, _recipient, _ether); } - /** - * @notice Performs a deposit to the beacon chain deposit contract - * @param _deposits Array of deposit structs - * @dev Includes a check to ensure StakingVault is balanced before making deposits - */ - function depositToBeaconChain(Deposit[] calldata _deposits) external { - if (_deposits.length == 0) revert ZeroArgument("_deposits"); - ERC7201Storage storage $ = _getStorage(); - - if (msg.sender != $.nodeOperator) revert NotAuthorized("depositToBeaconChain", msg.sender); - if ($.beaconChainDepositsPaused) revert BeaconChainDepositsArePaused(); - if (!isBalanced()) revert Unbalanced(); - - uint256 totalAmount = 0; - uint256 numberOfDeposits = _deposits.length; - for (uint256 i = 0; i < numberOfDeposits; i++) { - Deposit calldata deposit = _deposits[i]; - BEACON_CHAIN_DEPOSIT_CONTRACT.deposit{value: deposit.amount}( - deposit.pubkey, - bytes.concat(withdrawalCredentials()), - deposit.signature, - deposit.depositDataRoot - ); - totalAmount += deposit.amount; - } - - emit DepositedToBeaconChain(msg.sender, numberOfDeposits, totalAmount); - } - - /** - * @notice Requests validator exit from the beacon chain - * @param _pubkeys Concatenated validator public keys - * @dev Signals the node operator to eject the specified validators from the beacon chain - */ - function requestValidatorExit(bytes calldata _pubkeys) external onlyOwner { - emit ValidatorsExitRequest(msg.sender, _pubkeys); - } - /** * @notice Locks ether in StakingVault * @dev Can only be called by VaultHub; locked amount can only be increased @@ -359,6 +313,10 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { $.locked = uint128(_locked); + if (!isBalanced()) { + $.unbalancedSince = block.timestamp; + } + emit LockedIncreased(_locked); } @@ -401,58 +359,42 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { $.report.inOutDelta = int128(_inOutDelta); $.locked = uint128(_locked); + if (isBalanced()) { + $.unbalancedSince = 0; + } else { + $.unbalancedSince = block.timestamp; + } + emit Reported(_valuation, _inOutDelta, _locked); } + // * * * * * * * * * * * * * * * * * * * * * // + // * * * BEACON CHAIN DEPOSITS LOGIC * * * * // + // * * * * * * * * * * * * * * * * * * * * * // + /** - * @notice Computes the deposit data root for a validator deposit - * @param _pubkey Validator public key, 48 bytes - * @param _withdrawalCredentials Withdrawal credentials, 32 bytes - * @param _signature Signature of the deposit, 96 bytes - * @param _amount Amount of ether to deposit, in wei - * @return Deposit data root as bytes32 - * @dev This function computes the deposit data root according to the deposit contract's specification. - * The deposit data root is check upon deposit to the deposit contract as a protection against malformed deposit data. - * See more: https://etherscan.io/address/0x00000000219ab540356cbb839cbe05303d7705fa#code - * + * @notice Returns the address of `BeaconChainDepositContract` + * @return Address of `BeaconChainDepositContract` */ - function computeDepositDataRoot( - bytes calldata _pubkey, - bytes calldata _withdrawalCredentials, - bytes calldata _signature, - uint256 _amount - ) external view returns (bytes32) { - // Step 1. Convert the deposit amount in wei to gwei in 64-bit bytes - bytes memory amountBE64 = abi.encodePacked(uint64(_amount / 1 gwei)); - - // Step 2. Convert the amount to little-endian format by flipping the bytes 🧠 - bytes memory amountLE64 = new bytes(8); - amountLE64[0] = amountBE64[7]; - amountLE64[1] = amountBE64[6]; - amountLE64[2] = amountBE64[5]; - amountLE64[3] = amountBE64[4]; - amountLE64[4] = amountBE64[3]; - amountLE64[5] = amountBE64[2]; - amountLE64[6] = amountBE64[1]; - amountLE64[7] = amountBE64[0]; - - // Step 3. Compute the root of the pubkey - bytes32 pubkeyRoot = sha256(abi.encodePacked(_pubkey, bytes16(0))); - - // Step 4. Compute the root of the signature - bytes32 sigSlice1Root = sha256(abi.encodePacked(_signature[0:64])); - bytes32 sigSlice2Root = sha256(abi.encodePacked(_signature[64:], bytes32(0))); - bytes32 signatureRoot = sha256(abi.encodePacked(sigSlice1Root, sigSlice2Root)); - - // Step 5. Compute the root-toot-toorootoo of the deposit data - bytes32 depositDataRoot = sha256( - abi.encodePacked( - sha256(abi.encodePacked(pubkeyRoot, _withdrawalCredentials)), - sha256(abi.encodePacked(amountLE64, bytes24(0), signatureRoot)) - ) - ); - - return depositDataRoot; + function depositContract() external view returns (address) { + return _depositContract(); + } + + /** + * @notice Returns the 0x01-type withdrawal credentials for the validators deposited from this `StakingVault` + * All CL rewards are sent to this contract. Only 0x01-type withdrawal credentials are supported for now. + * @return Withdrawal credentials as bytes32 + */ + function withdrawalCredentials() external view returns (bytes32) { + return _withdrawalCredentials(); + } + + /** + * @notice Returns whether deposits are paused by the vault owner + * @return True if deposits are paused + */ + function beaconChainDepositsPaused() external view returns (bool) { + return _getStorage().beaconChainDepositsPaused; } /** @@ -485,12 +427,80 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { emit BeaconChainDepositsResumed(); } + /** + * @notice Performs a deposit to the beacon chain deposit contract + * @param _deposits Array of deposit structs + * @dev Includes a check to ensure StakingVault is balanced before making deposits + */ + function depositToBeaconChain(Deposit[] calldata _deposits) external { + if (_deposits.length == 0) revert ZeroArgument("_deposits"); + ERC7201Storage storage $ = _getStorage(); + + if (msg.sender != $.nodeOperator) revert NotAuthorized("depositToBeaconChain", msg.sender); + if ($.beaconChainDepositsPaused) revert BeaconChainDepositsArePaused(); + if (!isBalanced()) revert Unbalanced(); + + _depositToBeaconChain(_deposits); + } + + /** + * @notice Requests validators exit from the beacon chain + * @param _pubkeys Concatenated validators public keys + * @dev Signals the node operator to eject the specified validators from the beacon chain + */ + function requestValidatorsExit(bytes calldata _pubkeys) external { + ERC7201Storage storage $ = _getStorage(); + + /// @dev in case of balanced vault, validators can be exited only by the vault owner or the node operator + if (isBalanced()) { + if (msg.sender != owner() && msg.sender != $.nodeOperator) { + revert OwnableUnauthorizedAccount(msg.sender); + } + } else { + // If unbalancedSince is 0, this is the first time we're unbalanced + if ($.unbalancedSince == 0) { + $.unbalancedSince = block.timestamp; + } + + // Check if timelock period has elapsed + if (block.timestamp < $.unbalancedSince + EXIT_TIMELOCK_DURATION) { + revert ExitTimelockNotElapsed($.unbalancedSince + EXIT_TIMELOCK_DURATION); + } + } + + emit ValidatorsExitRequest(msg.sender, _pubkeys); + + _requestValidatorsExit(_pubkeys); + } + + /** + * @notice Computes the deposit data root for a validator deposit + * @param _pubkey Validator public key, 48 bytes + * @param _withdrawalCredentials Withdrawal credentials, 32 bytes + * @param _signature Signature of the deposit, 96 bytes + * @param _amount Amount of ether to deposit, in wei + * @return Deposit data root as bytes32 + * @dev This function computes the deposit data root according to the deposit contract's specification. + * The deposit data root is check upon deposit to the deposit contract as a protection against malformed deposit data. + * See more: https://etherscan.io/address/0x00000000219ab540356cbb839cbe05303d7705fa#code + */ + function computeDepositDataRoot( + bytes calldata _pubkey, + bytes calldata _withdrawalCredentials, + bytes calldata _signature, + uint256 _amount + ) external pure returns (bytes32) { + return _computeDepositDataRoot(_pubkey, _withdrawalCredentials, _signature, _amount); + } + function _getStorage() private pure returns (ERC7201Storage storage $) { assembly { $.slot := ERC721_STORAGE_LOCATION } } + /// Events + /** * @notice Emitted when `StakingVault` is funded with ether * @dev Event is not emitted upon direct transfers through `receive()` @@ -508,13 +518,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { */ event Withdrawn(address indexed sender, address indexed recipient, uint256 amount); - /** - * @notice Emitted when ether is deposited to `DepositContract` - * @param sender Address that initiated the deposit - * @param deposits Number of validator deposits made - */ - event DepositedToBeaconChain(address indexed sender, uint256 deposits, uint256 totalAmount); - /** * @notice Emitted when a validator exit request is made * @dev Signals `nodeOperator` to exit the validator @@ -554,11 +557,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { */ event BeaconChainDepositsResumed(); - /** - * @notice Thrown when an invalid zero value is passed - * @param name Name of the argument that was zero - */ - error ZeroArgument(string name); + /// Errors /** * @notice Thrown when trying to withdraw more ether than the balance of `StakingVault` @@ -631,4 +630,10 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @notice Thrown when trying to deposit to beacon chain while deposits are paused */ error BeaconChainDepositsArePaused(); + + /** + * @notice Emitted when the exit timelock has not elapsed + * @param timelockedUntil Timestamp when the exit timelock will end + */ + error ExitTimelockNotElapsed(uint256 timelockedUntil); } diff --git a/contracts/0.8.25/vaults/VaultValidatorsManager.sol b/contracts/0.8.25/vaults/VaultValidatorsManager.sol new file mode 100644 index 000000000..46955c61d --- /dev/null +++ b/contracts/0.8.25/vaults/VaultValidatorsManager.sol @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 + +// See contracts/COMPILERS.md +pragma solidity 0.8.25; + +import {IDepositContract} from "../interfaces/IDepositContract.sol"; +import {IStakingVault} from "./interfaces/IStakingVault.sol"; + +/// @notice VaultValidatorsManager is a contract that manages validators in the vault +/// @author tamtamchik +abstract contract VaultValidatorsManager { + + /** + * @notice Address of `BeaconChainDepositContract` + * Set immutably in the constructor to avoid storage costs + */ + IDepositContract private immutable BEACON_CHAIN_DEPOSIT_CONTRACT; + + constructor(address _beaconChainDepositContract) { + if (_beaconChainDepositContract == address(0)) revert ZeroArgument("_beaconChainDepositContract"); + BEACON_CHAIN_DEPOSIT_CONTRACT = IDepositContract(_beaconChainDepositContract); + } + + /// @notice Returns the address of `BeaconChainDepositContract` + /// @return Address of `BeaconChainDepositContract` + function _depositContract() internal view returns (address) { + return address(BEACON_CHAIN_DEPOSIT_CONTRACT); + } + + /// @notice Returns the 0x01-type withdrawal credentials for the validators deposited from this `StakingVault` + /// All CL rewards are sent to this contract. Only 0x01-type withdrawal credentials are supported for now. + /// @return Withdrawal credentials as bytes32 + function _withdrawalCredentials() internal view returns (bytes32) { + return bytes32((0x01 << 248) + uint160(address(this))); + } + + /// @notice Deposits multiple validators to the beacon chain deposit contract + /// @param _deposits Array of validator deposits containing pubkey, signature and deposit data root + function _depositToBeaconChain(IStakingVault.Deposit[] calldata _deposits) internal { + uint256 totalAmount = 0; + uint256 numberOfDeposits = _deposits.length; + for (uint256 i = 0; i < numberOfDeposits; i++) { + IStakingVault.Deposit calldata deposit = _deposits[i]; + BEACON_CHAIN_DEPOSIT_CONTRACT.deposit{value: deposit.amount}( + deposit.pubkey, + bytes.concat(_withdrawalCredentials()), + deposit.signature, + deposit.depositDataRoot + ); + totalAmount += deposit.amount; + } + + emit DepositedToBeaconChain(msg.sender, numberOfDeposits, totalAmount); + } + + /// @notice Requests validators to exit from the beacon chain + /// @param _pubkeys Concatenated validator public keys to exit + function _requestValidatorsExit(bytes calldata _pubkeys) internal { + // TODO: + } + + /// @notice Computes the deposit data root for a validator deposit + /// @param _pubkey Validator public key, 48 bytes + /// @param _withdrawalCredentials Withdrawal credentials, 32 bytes + /// @param _signature Signature of the deposit, 96 bytes + /// @param _amount Amount of ether to deposit, in wei + /// @return Deposit data root as bytes32 + /// @dev This function computes the deposit data root according to the deposit contract's specification. + /// The deposit data root is check upon deposit to the deposit contract as a protection against malformed deposit data. + /// See more: https://etherscan.io/address/0x00000000219ab540356cbb839cbe05303d7705fa#code + function _computeDepositDataRoot( + bytes calldata _pubkey, + bytes calldata _withdrawalCredentials, + bytes calldata _signature, + uint256 _amount + ) internal pure returns (bytes32) { + // Step 1. Convert the deposit amount in wei to gwei in 64-bit bytes + bytes memory amountBE64 = abi.encodePacked(uint64(_amount / 1 gwei)); + + // Step 2. Convert the amount to little-endian format by flipping the bytes 🧠 + bytes memory amountLE64 = new bytes(8); + amountLE64[0] = amountBE64[7]; + amountLE64[1] = amountBE64[6]; + amountLE64[2] = amountBE64[5]; + amountLE64[3] = amountBE64[4]; + amountLE64[4] = amountBE64[3]; + amountLE64[5] = amountBE64[2]; + amountLE64[6] = amountBE64[1]; + amountLE64[7] = amountBE64[0]; + + // Step 3. Compute the root of the pubkey + bytes32 pubkeyRoot = sha256(abi.encodePacked(_pubkey, bytes16(0))); + + // Step 4. Compute the root of the signature + bytes32 sigSlice1Root = sha256(abi.encodePacked(_signature[0:64])); + bytes32 sigSlice2Root = sha256(abi.encodePacked(_signature[64:], bytes32(0))); + bytes32 signatureRoot = sha256(abi.encodePacked(sigSlice1Root, sigSlice2Root)); + + // Step 5. Compute the root-toot-toorootoo of the deposit data + bytes32 depositDataRoot = sha256( + abi.encodePacked( + sha256(abi.encodePacked(pubkeyRoot, _withdrawalCredentials)), + sha256(abi.encodePacked(amountLE64, bytes24(0), signatureRoot)) + ) + ); + + return depositDataRoot; + } + + /** + * @notice Emitted when ether is deposited to `DepositContract` + * @param sender Address that initiated the deposit + * @param deposits Number of validator deposits made + */ + event DepositedToBeaconChain(address indexed sender, uint256 deposits, uint256 totalAmount); + + /** + * @notice Thrown when an invalid zero value is passed + * @param name Name of the argument that was zero + */ + error ZeroArgument(string name); +} diff --git a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol index 9d7106f99..f37d827d8 100644 --- a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol +++ b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol @@ -31,23 +31,27 @@ interface IStakingVault { function version() external pure returns(uint64); function getInitializedVersion() external view returns (uint64); function vaultHub() external view returns (address); - function depositContract() external view returns (address); + function nodeOperator() external view returns (address); function locked() external view returns (uint256); function valuation() external view returns (uint256); function isBalanced() external view returns (bool); function unlocked() external view returns (uint256); function inOutDelta() external view returns (int256); - function beaconChainDepositsPaused() external view returns (bool); - function withdrawalCredentials() external view returns (bytes32); + function fund() external payable; function withdraw(address _recipient, uint256 _ether) external; - function depositToBeaconChain(Deposit[] calldata _deposits) external; - function requestValidatorExit(bytes calldata _pubkeys) external; + function lock(uint256 _locked) external; function rebalance(uint256 _ether) external; - function pauseBeaconChainDeposits() external; - function resumeBeaconChainDeposits() external; function latestReport() external view returns (Report memory); function report(uint256 _valuation, int256 _inOutDelta, uint256 _locked) external; + + function depositContract() external view returns (address); + function withdrawalCredentials() external view returns (bytes32); + function beaconChainDepositsPaused() external view returns (bool); + function pauseBeaconChainDeposits() external; + function resumeBeaconChainDeposits() external; + function depositToBeaconChain(Deposit[] calldata _deposits) external; + function requestValidatorsExit(bytes calldata _pubkeys) external; } diff --git a/contracts/0.8.9/WithdrawalVault.sol b/contracts/0.8.9/WithdrawalVault.sol index f1f02a2b0..16705f86c 100644 --- a/contracts/0.8.9/WithdrawalVault.sol +++ b/contracts/0.8.9/WithdrawalVault.sol @@ -10,8 +10,8 @@ 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 {TriggerableWithdrawals} from "../common/lib/TriggerableWithdrawals.sol"; +import {ILidoLocator} from "../common/interfaces/ILidoLocator.sol"; interface ILido { /** @@ -149,9 +149,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, @@ -163,7 +163,7 @@ contract WithdrawalVault is AccessControlEnumerable, Versioned { uint256 refund = msg.value - totalFee; if (refund > 0) { - (bool success, ) = msg.sender.call{value: refund}(""); + (bool success,) = msg.sender.call{value: refund}(""); if (!success) { revert TriggerableWithdrawalRefundFailed(); diff --git a/contracts/0.8.9/lib/TriggerableWithdrawals.sol b/contracts/common/lib/TriggerableWithdrawals.sol similarity index 92% rename from contracts/0.8.9/lib/TriggerableWithdrawals.sol rename to contracts/common/lib/TriggerableWithdrawals.sol index a601a5930..34661187e 100644 --- a/contracts/0.8.9/lib/TriggerableWithdrawals.sol +++ b/contracts/common/lib/TriggerableWithdrawals.sol @@ -1,7 +1,10 @@ -// 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; + library TriggerableWithdrawals { address constant WITHDRAWAL_REQUEST = 0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA; uint256 internal constant WITHDRAWAL_REQUEST_CALLDATA_LENGTH = 56; @@ -35,7 +38,7 @@ library TriggerableWithdrawals { for (uint256 i = 0; i < keysCount; i++) { _copyPubkeyToMemory(pubkeys, callData, i); - (bool success, ) = WITHDRAWAL_REQUEST.call{value: feePerRequest}(callData); + (bool success,) = WITHDRAWAL_REQUEST.call{value: feePerRequest}(callData); if (!success) { revert WithdrawalRequestAdditionFailed(callData); @@ -92,7 +95,7 @@ library TriggerableWithdrawals { _copyPubkeyToMemory(pubkeys, callData, i); _copyAmountToMemory(callData, amounts[i]); - (bool success, ) = WITHDRAWAL_REQUEST.call{value: feePerRequest}(callData); + (bool success,) = WITHDRAWAL_REQUEST.call{value: feePerRequest}(callData); if (!success) { revert WithdrawalRequestAdditionFailed(callData); @@ -131,7 +134,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 +157,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); } diff --git a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol index 7c992170c..42a29ee30 100644 --- a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol +++ b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol @@ -96,7 +96,7 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl } function rebalance(uint256 _ether) external {} function report(uint256 _valuation, int256 _inOutDelta, uint256 _locked) external {} - function requestValidatorExit(bytes calldata _pubkeys) external {} + function requestValidatorsExit(bytes calldata _pubkeys) external {} function lock(uint256 _locked) external {} function locked() external view returns (uint256) { diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index b00250895..98720a825 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -556,20 +556,19 @@ describe("Dashboard.sol", () => { }); }); - context("requestValidatorExit", () => { + context("requestValidatorsExit", () => { it("reverts if called by a non-admin", async () => { - const validatorPublicKey = "0x" + randomBytes(48).toString("hex"); - await expect(dashboard.connect(stranger).requestValidatorExit(validatorPublicKey)).to.be.revertedWithCustomError( - dashboard, - "AccessControlUnauthorizedAccount", - ); + const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); + await expect( + dashboard.connect(stranger).requestValidatorsExit(validatorPublicKeys), + ).to.be.revertedWithCustomError(dashboard, "AccessControlUnauthorizedAccount"); }); it("requests the exit of a validator", async () => { - const validatorPublicKey = "0x" + randomBytes(48).toString("hex"); - await expect(dashboard.requestValidatorExit(validatorPublicKey)) + const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); + await expect(dashboard.requestValidatorsExit(validatorPublicKeys)) .to.emit(vault, "ValidatorsExitRequest") - .withArgs(dashboard, validatorPublicKey); + .withArgs(dashboard, validatorPublicKeys); }); }); diff --git a/test/0.8.25/vaults/staking-vault/staking-vault.test.ts b/test/0.8.25/vaults/staking-vault/staking-vault.accounting.test.ts similarity index 68% rename from test/0.8.25/vaults/staking-vault/staking-vault.test.ts rename to test/0.8.25/vaults/staking-vault/staking-vault.accounting.test.ts index 075fd82a3..0562a5f42 100644 --- a/test/0.8.25/vaults/staking-vault/staking-vault.test.ts +++ b/test/0.8.25/vaults/staking-vault/staking-vault.accounting.test.ts @@ -9,20 +9,19 @@ import { DepositContract__MockForStakingVault, EthRejector, StakingVault, - StakingVault__factory, - VaultFactory__MockForStakingVault, VaultHub__MockForStakingVault, } from "typechain-types"; -import { computeDepositDataRoot, de0x, ether, findEvents, impersonate, streccak } from "lib"; +import { de0x, ether, impersonate } from "lib"; +import { deployStakingVaultBehindBeaconProxy } from "test/deploy"; import { Snapshot } from "test/suite"; const MAX_INT128 = 2n ** 127n - 1n; const MAX_UINT128 = 2n ** 128n - 1n; // @TODO: test reentrancy attacks -describe("StakingVault.sol", () => { +describe("StakingVault.sol:Accounting", () => { let vaultOwner: HardhatEthersSigner; let operator: HardhatEthersSigner; let stranger: HardhatEthersSigner; @@ -44,8 +43,9 @@ describe("StakingVault.sol", () => { before(async () => { [vaultOwner, operator, elRewardsSender, stranger] = await ethers.getSigners(); - [stakingVault, vaultHub /* vaultFactory */, , stakingVaultImplementation, depositContract] = - await deployStakingVaultBehindBeaconProxy(); + ({ stakingVault, vaultHub, stakingVaultImplementation, depositContract } = + await deployStakingVaultBehindBeaconProxy(vaultOwner, operator)); + ethRejector = await ethers.deployContract("EthRejector"); vaultOwnerAddress = await vaultOwner.getAddress(); @@ -143,6 +143,30 @@ describe("StakingVault.sol", () => { }); }); + context("isBalanced", () => { + it("returns true if valuation is greater than or equal to locked", async () => { + await stakingVault.connect(vaultHubSigner).report(ether("1"), ether("2"), ether("0")); + expect(await stakingVault.isBalanced()).to.be.true; + }); + + it("returns false if valuation is less than locked", async () => { + await stakingVault.connect(vaultHubSigner).lock(ether("1")); + expect(await stakingVault.isBalanced()).to.be.false; + }); + }); + + context("unbalancedSince", () => { + it("returns the timestamp when the vault became unbalanced", async () => { + await stakingVault.connect(vaultHubSigner).lock(ether("1")); + expect(await stakingVault.unbalancedSince()).to.be.greaterThan(0n); + }); + + it("returns 0 if the vault is balanced", async () => { + await stakingVault.connect(vaultHubSigner).report(ether("1"), ether("2"), ether("0")); + expect(await stakingVault.unbalancedSince()).to.equal(0n); + }); + }); + context("receive", () => { it("reverts if msg.value is zero", async () => { await expect(vaultOwner.sendTransaction({ to: stakingVaultAddress, value: 0n })) @@ -188,6 +212,14 @@ describe("StakingVault.sol", () => { await setBalance(vaultOwnerAddress, bigBalance); await expect(stakingVault.fund({ value: maxInOutDelta })).to.not.be.reverted; }); + + it("restores the vault to a balanced state if the vault was unbalanced", async () => { + await stakingVault.connect(vaultHubSigner).lock(ether("1")); + expect(await stakingVault.isBalanced()).to.be.false; + + await stakingVault.fund({ value: ether("1") }); + expect(await stakingVault.isBalanced()).to.be.true; + }); }); context("withdraw", () => { @@ -279,129 +311,6 @@ describe("StakingVault.sol", () => { }); }); - context("pauseBeaconChainDeposits", () => { - it("reverts if called by a non-owner", async () => { - await expect(stakingVault.connect(stranger).pauseBeaconChainDeposits()) - .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") - .withArgs(await stranger.getAddress()); - }); - - it("reverts if the beacon deposits are already paused", async () => { - await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); - - await expect(stakingVault.connect(vaultOwner).pauseBeaconChainDeposits()).to.be.revertedWithCustomError( - stakingVault, - "BeaconChainDepositsResumeExpected", - ); - }); - - it("allows to pause deposits", async () => { - await expect(stakingVault.connect(vaultOwner).pauseBeaconChainDeposits()).to.emit( - stakingVault, - "BeaconChainDepositsPaused", - ); - expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; - }); - }); - - context("resumeBeaconChainDeposits", () => { - it("reverts if called by a non-owner", async () => { - await expect(stakingVault.connect(stranger).resumeBeaconChainDeposits()) - .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") - .withArgs(await stranger.getAddress()); - }); - - it("reverts if the beacon deposits are already resumed", async () => { - await expect(stakingVault.connect(vaultOwner).resumeBeaconChainDeposits()).to.be.revertedWithCustomError( - stakingVault, - "BeaconChainDepositsPauseExpected", - ); - }); - - it("allows to resume deposits", async () => { - await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); - - await expect(stakingVault.connect(vaultOwner).resumeBeaconChainDeposits()).to.emit( - stakingVault, - "BeaconChainDepositsResumed", - ); - expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; - }); - }); - - context("depositToBeaconChain", () => { - it("reverts if called by a non-operator", async () => { - await expect( - stakingVault - .connect(stranger) - .depositToBeaconChain([ - { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, - ]), - ) - .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") - .withArgs("depositToBeaconChain", stranger); - }); - - it("reverts if the number of deposits is zero", async () => { - await expect(stakingVault.depositToBeaconChain([])) - .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") - .withArgs("_deposits"); - }); - - it("reverts if the vault is not balanced", async () => { - await stakingVault.connect(vaultHubSigner).lock(ether("1")); - await expect( - stakingVault - .connect(operator) - .depositToBeaconChain([ - { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, - ]), - ).to.be.revertedWithCustomError(stakingVault, "Unbalanced"); - }); - - it("reverts if the deposits are paused", async () => { - await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); - await expect( - stakingVault - .connect(operator) - .depositToBeaconChain([ - { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, - ]), - ).to.be.revertedWithCustomError(stakingVault, "BeaconChainDepositsArePaused"); - }); - - it("makes deposits to the beacon chain and emits the DepositedToBeaconChain event", async () => { - await stakingVault.fund({ value: ether("32") }); - - const pubkey = "0x" + "ab".repeat(48); - const signature = "0x" + "ef".repeat(96); - const amount = ether("32"); - const withdrawalCredentials = await stakingVault.withdrawalCredentials(); - const depositDataRoot = computeDepositDataRoot(withdrawalCredentials, pubkey, signature, amount); - - await expect( - stakingVault.connect(operator).depositToBeaconChain([{ pubkey, signature, amount, depositDataRoot }]), - ) - .to.emit(stakingVault, "DepositedToBeaconChain") - .withArgs(operator, 1, amount); - }); - }); - - context("requestValidatorExit", () => { - it("reverts if called by a non-owner", async () => { - await expect(stakingVault.connect(stranger).requestValidatorExit("0x")) - .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") - .withArgs(await stranger.getAddress()); - }); - - it("emits the ValidatorsExitRequest event", async () => { - const pubkey = "0x" + "ab".repeat(48); - await expect(stakingVault.requestValidatorExit(pubkey)) - .to.emit(stakingVault, "ValidatorsExitRequest") - .withArgs(vaultOwnerAddress, pubkey); - }); - }); - context("lock", () => { it("reverts if the caller is not the vault hub", async () => { await expect(stakingVault.connect(vaultOwner).lock(ether("1"))) @@ -435,6 +344,13 @@ describe("StakingVault.sol", () => { .to.emit(stakingVault, "LockedIncreased") .withArgs(MAX_UINT128); }); + + it("updates unbalancedSince if the vault becomes unbalanced", async () => { + expect(await stakingVault.unbalancedSince()).to.equal(0n); + + await stakingVault.connect(vaultHubSigner).lock(ether("1")); + expect(await stakingVault.unbalancedSince()).to.be.greaterThan(0n); + }); }); context("rebalance", () => { @@ -471,17 +387,19 @@ describe("StakingVault.sol", () => { it("can be called by the owner", async () => { await stakingVault.fund({ value: ether("2") }); const inOutDeltaBefore = await stakingVault.inOutDelta(); + await expect(stakingVault.rebalance(ether("1"))) .to.emit(stakingVault, "Withdrawn") .withArgs(vaultOwnerAddress, vaultHubAddress, ether("1")) .to.emit(vaultHub, "Mock__Rebalanced") .withArgs(stakingVaultAddress, ether("1")); + expect(await stakingVault.inOutDelta()).to.equal(inOutDeltaBefore - ether("1")); }); it("can be called by the vault hub when the vault is unbalanced", async () => { await stakingVault.connect(vaultHubSigner).report(ether("1"), ether("0.1"), ether("1.1")); - expect(await stakingVault.isBalanced()).to.equal(false); + expect(await stakingVault.isBalanced()).to.be.false; expect(await stakingVault.inOutDelta()).to.equal(ether("0")); await elRewardsSender.sendTransaction({ to: stakingVaultAddress, value: ether("0.1") }); @@ -505,64 +423,21 @@ describe("StakingVault.sol", () => { await expect(stakingVault.connect(vaultHubSigner).report(ether("1"), ether("2"), ether("3"))) .to.emit(stakingVault, "Reported") .withArgs(ether("1"), ether("2"), ether("3")); + expect(await stakingVault.latestReport()).to.deep.equal([ether("1"), ether("2")]); expect(await stakingVault.locked()).to.equal(ether("3")); }); - }); - context("computeDepositDataRoot", () => { - it("computes the deposit data root", async () => { - // sample tx data: https://etherscan.io/tx/0x02980d44c119b0a8e3ca0d31c288e9f177c76fb4d7ab616563e399dd9c7c6507 - const pubkey = - "0x8d6aa059b52f6b11d07d73805d409feba07dffb6442c4ef6645f7caa4038b1047e072cba21eb766579f8286ccac630b0"; - const withdrawalCredentials = "0x010000000000000000000000b8b5da17a1b7a8ad1cf45a12e1e61d3577052d35"; - const signature = - "0xab95e358d002fd79bc08564a2db057dd5164af173915eba9e3e9da233d404c0eb0058760bc30cb89abbc55cf57f0c5a6018cdb17df73ca39ddc80a323a13c2e7ba942faa86757b26120b3a58dcce5d89e95ea1ee8fa3276ffac0f0ad9313211d"; - const amount = ether("32"); - const expectedDepositDataRoot = "0xb28f86815813d7da8132a2979836b326094a350e7aa301ba611163d4b7ca77be"; - - computeDepositDataRoot(withdrawalCredentials, pubkey, signature, amount); - - expect(await stakingVault.computeDepositDataRoot(pubkey, withdrawalCredentials, signature, amount)).to.equal( - expectedDepositDataRoot, - ); + it("updates unbalancedSince if the vault becomes unbalanced", async () => { + expect(await stakingVault.unbalancedSince()).to.equal(0n); + + // Unbalanced report + await stakingVault.connect(vaultHubSigner).report(ether("1"), ether("0.1"), ether("1.1")); + expect(await stakingVault.unbalancedSince()).to.be.greaterThan(0n); + + // Rebalanced report + await stakingVault.connect(vaultHubSigner).report(ether("3"), ether("2"), ether("1")); + expect(await stakingVault.unbalancedSince()).to.equal(0n); }); }); - - async function deployStakingVaultBehindBeaconProxy(): Promise< - [ - StakingVault, - VaultHub__MockForStakingVault, - VaultFactory__MockForStakingVault, - StakingVault, - DepositContract__MockForStakingVault, - ] - > { - // deploying implementation - const vaultHub_ = await ethers.deployContract("VaultHub__MockForStakingVault"); - const depositContract_ = await ethers.deployContract("DepositContract__MockForStakingVault"); - const stakingVaultImplementation_ = await ethers.deployContract("StakingVault", [ - await vaultHub_.getAddress(), - await depositContract_.getAddress(), - ]); - - // deploying factory/beacon - const vaultFactory_ = await ethers.deployContract("VaultFactory__MockForStakingVault", [ - await stakingVaultImplementation_.getAddress(), - ]); - - // deploying beacon proxy - const vaultCreation = await vaultFactory_ - .createVault(await vaultOwner.getAddress(), await operator.getAddress()) - .then((tx) => tx.wait()); - if (!vaultCreation) throw new Error("Vault creation failed"); - const events = findEvents(vaultCreation, "VaultCreated"); - if (events.length != 1) throw new Error("There should be exactly one VaultCreated event"); - const vaultCreatedEvent = events[0]; - - const stakingVault_ = StakingVault__factory.connect(vaultCreatedEvent.args.vault, vaultOwner); - expect(await stakingVault_.owner()).to.equal(await vaultOwner.getAddress()); - - return [stakingVault_, vaultHub_, vaultFactory_, stakingVaultImplementation_, depositContract_]; - } }); diff --git a/test/0.8.25/vaults/staking-vault/staking-vault.validators.test.ts b/test/0.8.25/vaults/staking-vault/staking-vault.validators.test.ts new file mode 100644 index 000000000..6849f55c7 --- /dev/null +++ b/test/0.8.25/vaults/staking-vault/staking-vault.validators.test.ts @@ -0,0 +1,216 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { StakingVault, VaultHub__MockForStakingVault } from "typechain-types"; + +import { computeDepositDataRoot, ether, impersonate, streccak } from "lib"; + +import { deployStakingVaultBehindBeaconProxy } from "test/deploy"; +import { Snapshot } from "test/suite"; + +describe("StakingVault.sol:ValidatorsManagement", () => { + let vaultOwner: HardhatEthersSigner; + let operator: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + let vaultHubSigner: HardhatEthersSigner; + + let stakingVault: StakingVault; + let vaultHub: VaultHub__MockForStakingVault; + + let vaultOwnerAddress: string; + let vaultHubAddress: string; + let operatorAddress: string; + let originalState: string; + + before(async () => { + [vaultOwner, operator, stranger] = await ethers.getSigners(); + ({ stakingVault, vaultHub } = await deployStakingVaultBehindBeaconProxy(vaultOwner, operator)); + + vaultOwnerAddress = await vaultOwner.getAddress(); + vaultHubAddress = await vaultHub.getAddress(); + operatorAddress = await operator.getAddress(); + + vaultHubSigner = await impersonate(vaultHubAddress, ether("10")); + }); + + beforeEach(async () => { + originalState = await Snapshot.take(); + }); + + afterEach(async () => { + await Snapshot.restore(originalState); + }); + + context("pauseBeaconChainDeposits", () => { + it("reverts if called by a non-owner", async () => { + await expect(stakingVault.connect(stranger).pauseBeaconChainDeposits()) + .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") + .withArgs(await stranger.getAddress()); + }); + + it("reverts if the beacon deposits are already paused", async () => { + await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); + + await expect(stakingVault.connect(vaultOwner).pauseBeaconChainDeposits()).to.be.revertedWithCustomError( + stakingVault, + "BeaconChainDepositsResumeExpected", + ); + }); + + it("allows to pause deposits", async () => { + await expect(stakingVault.connect(vaultOwner).pauseBeaconChainDeposits()).to.emit( + stakingVault, + "BeaconChainDepositsPaused", + ); + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + }); + }); + + context("resumeBeaconChainDeposits", () => { + it("reverts if called by a non-owner", async () => { + await expect(stakingVault.connect(stranger).resumeBeaconChainDeposits()) + .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") + .withArgs(await stranger.getAddress()); + }); + + it("reverts if the beacon deposits are already resumed", async () => { + await expect(stakingVault.connect(vaultOwner).resumeBeaconChainDeposits()).to.be.revertedWithCustomError( + stakingVault, + "BeaconChainDepositsPauseExpected", + ); + }); + + it("allows to resume deposits", async () => { + await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); + + await expect(stakingVault.connect(vaultOwner).resumeBeaconChainDeposits()).to.emit( + stakingVault, + "BeaconChainDepositsResumed", + ); + expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + }); + }); + + context("depositToBeaconChain", () => { + it("reverts if called by a non-operator", async () => { + await expect( + stakingVault + .connect(stranger) + .depositToBeaconChain([ + { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, + ]), + ) + .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") + .withArgs("depositToBeaconChain", stranger); + }); + + it("reverts if the number of deposits is zero", async () => { + await expect(stakingVault.depositToBeaconChain([])) + .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") + .withArgs("_deposits"); + }); + + it("reverts if the vault is not balanced", async () => { + await stakingVault.connect(vaultHubSigner).lock(ether("1")); + await expect( + stakingVault + .connect(operator) + .depositToBeaconChain([ + { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, + ]), + ).to.be.revertedWithCustomError(stakingVault, "Unbalanced"); + }); + + it("reverts if the deposits are paused", async () => { + await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); + await expect( + stakingVault + .connect(operator) + .depositToBeaconChain([ + { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, + ]), + ).to.be.revertedWithCustomError(stakingVault, "BeaconChainDepositsArePaused"); + }); + + it("makes deposits to the beacon chain and emits the DepositedToBeaconChain event", async () => { + await stakingVault.fund({ value: ether("32") }); + + const pubkey = "0x" + "ab".repeat(48); + const signature = "0x" + "ef".repeat(96); + const amount = ether("32"); + const withdrawalCredentials = await stakingVault.withdrawalCredentials(); + const depositDataRoot = computeDepositDataRoot(withdrawalCredentials, pubkey, signature, amount); + + await expect( + stakingVault.connect(operator).depositToBeaconChain([{ pubkey, signature, amount, depositDataRoot }]), + ) + .to.emit(stakingVault, "DepositedToBeaconChain") + .withArgs(operator, 1, amount); + }); + }); + + context("requestValidatorsExit", () => { + context("vault is balanced", () => { + it("reverts if called by a non-owner or non-node operator", async () => { + await expect(stakingVault.connect(stranger).requestValidatorsExit("0x")) + .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") + .withArgs(await stranger.getAddress()); + }); + + it("allows owner to request validators exit", async () => { + const pubkeys = "0x" + "ab".repeat(48); + await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys)) + .to.emit(stakingVault, "ValidatorsExitRequest") + .withArgs(vaultOwnerAddress, pubkeys); + }); + + it("allows node operator to request validators exit", async () => { + await expect(stakingVault.connect(operator).requestValidatorsExit("0x")) + .to.emit(stakingVault, "ValidatorsExitRequest") + .withArgs(operatorAddress, "0x"); + }); + + it("works with multiple pubkeys", async () => { + const pubkeys = "0x" + "ab".repeat(48) + "cd".repeat(48); + await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys)) + .to.emit(stakingVault, "ValidatorsExitRequest") + .withArgs(vaultOwnerAddress, pubkeys); + }); + }); + + context("vault is unbalanced", () => { + beforeEach(async () => { + await stakingVault.connect(vaultHubSigner).report(ether("1"), ether("0.1"), ether("1.1")); + expect(await stakingVault.isBalanced()).to.be.false; + }); + + it("reverts if timelocked", async () => { + await expect(stakingVault.requestValidatorsExit("0x")).to.be.revertedWithCustomError( + stakingVault, + "ExitTimelockNotElapsed", + ); + }); + }); + }); + + context("computeDepositDataRoot", () => { + it("computes the deposit data root", async () => { + // sample tx data: https://etherscan.io/tx/0x02980d44c119b0a8e3ca0d31c288e9f177c76fb4d7ab616563e399dd9c7c6507 + const pubkey = + "0x8d6aa059b52f6b11d07d73805d409feba07dffb6442c4ef6645f7caa4038b1047e072cba21eb766579f8286ccac630b0"; + const withdrawalCredentials = "0x010000000000000000000000b8b5da17a1b7a8ad1cf45a12e1e61d3577052d35"; + const signature = + "0xab95e358d002fd79bc08564a2db057dd5164af173915eba9e3e9da233d404c0eb0058760bc30cb89abbc55cf57f0c5a6018cdb17df73ca39ddc80a323a13c2e7ba942faa86757b26120b3a58dcce5d89e95ea1ee8fa3276ffac0f0ad9313211d"; + const amount = ether("32"); + const expectedDepositDataRoot = "0xb28f86815813d7da8132a2979836b326094a350e7aa301ba611163d4b7ca77be"; + + computeDepositDataRoot(withdrawalCredentials, pubkey, signature, amount); + + expect(await stakingVault.computeDepositDataRoot(pubkey, withdrawalCredentials, signature, amount)).to.equal( + expectedDepositDataRoot, + ); + }); + }); +}); diff --git a/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol b/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol index e298384d4..0ca5fad1a 100644 --- a/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol +++ b/test/0.8.9/contracts/TriggerableWithdrawals_Harness.sol @@ -1,6 +1,6 @@ pragma solidity 0.8.9; -import {TriggerableWithdrawals} from "contracts/0.8.9/lib/TriggerableWithdrawals.sol"; +import {TriggerableWithdrawals} from "contracts/common/lib/TriggerableWithdrawals.sol"; contract TriggerableWithdrawals_Harness { function addFullWithdrawalRequests(bytes calldata pubkeys, uint256 feePerRequest) external { diff --git a/test/deploy/index.ts b/test/deploy/index.ts index d7afaf858..d32a55909 100644 --- a/test/deploy/index.ts +++ b/test/deploy/index.ts @@ -4,3 +4,4 @@ export * from "./locator"; export * from "./dao"; export * from "./hashConsensus"; export * from "./withdrawalQueue"; +export * from "./stakingVault"; diff --git a/test/deploy/stakingVault.ts b/test/deploy/stakingVault.ts new file mode 100644 index 000000000..9a0b2f26b --- /dev/null +++ b/test/deploy/stakingVault.ts @@ -0,0 +1,62 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { + DepositContract__MockForStakingVault, + StakingVault, + StakingVault__factory, + VaultFactory__MockForStakingVault, + VaultHub__MockForStakingVault, +} from "typechain-types"; + +import { findEvents } from "lib"; + +type DeployedStakingVault = { + depositContract: DepositContract__MockForStakingVault; + stakingVault: StakingVault; + stakingVaultImplementation: StakingVault; + vaultHub: VaultHub__MockForStakingVault; + vaultFactory: VaultFactory__MockForStakingVault; +}; + +export async function deployStakingVaultBehindBeaconProxy( + vaultOwner: HardhatEthersSigner, + operator: HardhatEthersSigner, +): Promise { + // deploying implementation + const vaultHub_ = await ethers.deployContract("VaultHub__MockForStakingVault"); + const depositContract_ = await ethers.deployContract("DepositContract__MockForStakingVault"); + const stakingVaultImplementation_ = await ethers.deployContract("StakingVault", [ + await vaultHub_.getAddress(), + await depositContract_.getAddress(), + ]); + + // deploying factory/beacon + const vaultFactory_ = await ethers.deployContract("VaultFactory__MockForStakingVault", [ + await stakingVaultImplementation_.getAddress(), + ]); + + // deploying beacon proxy + const vaultCreation = await vaultFactory_ + .createVault(await vaultOwner.getAddress(), await operator.getAddress()) + .then((tx) => tx.wait()); + if (!vaultCreation) throw new Error("Vault creation failed"); + const events = findEvents(vaultCreation, "VaultCreated"); + + if (events.length != 1) throw new Error("There should be exactly one VaultCreated event"); + const vaultCreatedEvent = events[0]; + + const stakingVault_ = StakingVault__factory.connect(vaultCreatedEvent.args.vault, vaultOwner); + expect(await stakingVault_.owner()).to.equal(await vaultOwner.getAddress()); + expect(await stakingVault_.nodeOperator()).to.equal(await operator.getAddress()); + + return { + depositContract: depositContract_, + stakingVault: stakingVault_, + stakingVaultImplementation: stakingVaultImplementation_, + vaultHub: vaultHub_, + vaultFactory: vaultFactory_, + }; +} diff --git a/test/integration/vaults-happy-path.integration.ts b/test/integration/vaults-happy-path.integration.ts index 31504ce9c..d11c37ae4 100644 --- a/test/integration/vaults-happy-path.integration.ts +++ b/test/integration/vaults-happy-path.integration.ts @@ -376,7 +376,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { it("Should allow Owner to trigger validator exit to cover fees", async () => { // simulate validator exit const secondValidatorKey = pubKeysBatch.slice(Number(PUBKEY_LENGTH), Number(PUBKEY_LENGTH) * 2); - await delegation.connect(owner).requestValidatorExit(secondValidatorKey); + await delegation.connect(owner).requestValidatorsExit(secondValidatorKey); await updateBalance(stakingVaultAddress, VALIDATOR_DEPOSIT_SIZE); const { elapsedProtocolReward, elapsedVaultReward } = await calculateReportParams(); From ade67a7704147877d8e0acdf852fcb24b3877e18 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Tue, 28 Jan 2025 09:41:44 +0100 Subject: [PATCH 15/70] 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 57cad874dae680614ac91101158dace59596a674 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 28 Jan 2025 11:29:03 +0000 Subject: [PATCH 16/70] chore: simplify code --- contracts/0.8.25/vaults/StakingVault.sol | 27 +++++++++++------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 442ec4ec2..1b877eac4 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -451,21 +451,14 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab function requestValidatorsExit(bytes calldata _pubkeys) external { ERC7201Storage storage $ = _getStorage(); - /// @dev in case of balanced vault, validators can be exited only by the vault owner or the node operator - if (isBalanced()) { - if (msg.sender != owner() && msg.sender != $.nodeOperator) { - revert OwnableUnauthorizedAccount(msg.sender); - } - } else { - // If unbalancedSince is 0, this is the first time we're unbalanced - if ($.unbalancedSince == 0) { - $.unbalancedSince = block.timestamp; - } - - // Check if timelock period has elapsed - if (block.timestamp < $.unbalancedSince + EXIT_TIMELOCK_DURATION) { - revert ExitTimelockNotElapsed($.unbalancedSince + EXIT_TIMELOCK_DURATION); - } + // Only owner or node operator can exit validators when vault is balanced + if (isBalanced() && msg.sender != owner() && msg.sender != $.nodeOperator) { + revert OwnableUnauthorizedAccount(msg.sender); + } + + // Ensure timelock period has elapsed + if (block.timestamp < ($.unbalancedSince + EXIT_TIMELOCK_DURATION)) { + revert ExitTimelockNotElapsed($.unbalancedSince + EXIT_TIMELOCK_DURATION); } emit ValidatorsExitRequest(msg.sender, _pubkeys); @@ -493,6 +486,10 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab return _computeDepositDataRoot(_pubkey, _withdrawalCredentials, _signature, _amount); } + // * * * * * * * * * * * * * * * * * * * * * // + // * * * INTERNAL FUNCTIONS * * * * * * * * * // + // * * * * * * * * * * * * * * * * * * * * * // + function _getStorage() private pure returns (ERC7201Storage storage $) { assembly { $.slot := ERC721_STORAGE_LOCATION From 89d583aa37e993cf188c876c0bc17d0a8d0e5f7d Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Tue, 28 Jan 2025 13:09:59 +0100 Subject: [PATCH 17/70] 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 a8a9762b10fd7d2c73efaf775874ee6458e038c2 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 28 Jan 2025 13:17:34 +0000 Subject: [PATCH 18/70] feat: add base layer for triggerable exits --- contracts/0.8.25/vaults/StakingVault.sol | 38 +++++++++++--- .../0.8.25/vaults/VaultValidatorsManager.sol | 49 +++++++++++++++++-- .../vaults/interfaces/IStakingVault.sol | 2 + .../StakingVault__HarnessForTestUpgrade.sol | 1 + test/0.8.25/vaults/vaultFactory.test.ts | 12 ++--- 5 files changed, 82 insertions(+), 20 deletions(-) diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 1b877eac4..ea7008a12 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -36,9 +36,11 @@ import {IStakingVault} from "./interfaces/IStakingVault.sol"; * - `pauseBeaconChainDeposits()` * - `resumeBeaconChainDeposits()` * - `requestValidatorsExit()` + * - `requestValidatorsPartialExit()` * - Operator: * - `depositToBeaconChain()` * - `requestValidatorsExit()` + * - `requestValidatorsPartialExit()` * - VaultHub: * - `lock()` * - `report()` @@ -449,21 +451,28 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab * @dev Signals the node operator to eject the specified validators from the beacon chain */ function requestValidatorsExit(bytes calldata _pubkeys) external { - ERC7201Storage storage $ = _getStorage(); - // Only owner or node operator can exit validators when vault is balanced - if (isBalanced() && msg.sender != owner() && msg.sender != $.nodeOperator) { - revert OwnableUnauthorizedAccount(msg.sender); + if (isBalanced()) { + _onlyOwnerOrNodeOperator(); } // Ensure timelock period has elapsed - if (block.timestamp < ($.unbalancedSince + EXIT_TIMELOCK_DURATION)) { - revert ExitTimelockNotElapsed($.unbalancedSince + EXIT_TIMELOCK_DURATION); + uint256 exitTimelock = _getStorage().unbalancedSince + EXIT_TIMELOCK_DURATION; + if (block.timestamp < exitTimelock) { + revert ExitTimelockNotElapsed(exitTimelock); } + _requestValidatorsExit(_pubkeys); + emit ValidatorsExitRequest(msg.sender, _pubkeys); + } - _requestValidatorsExit(_pubkeys); + function requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external { + _onlyOwnerOrNodeOperator(); + + _requestValidatorsPartialExit(_pubkeys, _amounts); + + emit ValidatorsPartialExitRequest(msg.sender, _pubkeys, _amounts); } /** @@ -496,6 +505,12 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab } } + function _onlyOwnerOrNodeOperator() internal view { + if (msg.sender != owner() && msg.sender != _getStorage().nodeOperator) { + revert OwnableUnauthorizedAccount(msg.sender); + } + } + /// Events /** @@ -523,6 +538,15 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab */ event ValidatorsExitRequest(address indexed sender, bytes pubkey); + /** + * @notice Emitted when a validator partial exit request is made + * @dev Signals `nodeOperator` to exit the validator + * @param sender Address that requested the validator partial exit + * @param pubkey Public key of the validator requested to exit + * @param amounts Amounts of ether requested to exit + */ + event ValidatorsPartialExitRequest(address indexed sender, bytes pubkey, uint64[] amounts); + /** * @notice Emitted when the locked amount is increased * @param locked New amount of locked ether diff --git a/contracts/0.8.25/vaults/VaultValidatorsManager.sol b/contracts/0.8.25/vaults/VaultValidatorsManager.sol index 46955c61d..688c9a750 100644 --- a/contracts/0.8.25/vaults/VaultValidatorsManager.sol +++ b/contracts/0.8.25/vaults/VaultValidatorsManager.sol @@ -4,6 +4,8 @@ // See contracts/COMPILERS.md pragma solidity 0.8.25; +import {TriggerableWithdrawals} from "contracts/common/lib/TriggerableWithdrawals.sol"; + import {IDepositContract} from "../interfaces/IDepositContract.sol"; import {IStakingVault} from "./interfaces/IStakingVault.sol"; @@ -35,8 +37,8 @@ abstract contract VaultValidatorsManager { return bytes32((0x01 << 248) + uint160(address(this))); } - /// @notice Deposits multiple validators to the beacon chain deposit contract - /// @param _deposits Array of validator deposits containing pubkey, signature and deposit data root + /// @notice Deposits validators to the beacon chain deposit contract + /// @param _deposits Array of validator deposits function _depositToBeaconChain(IStakingVault.Deposit[] calldata _deposits) internal { uint256 totalAmount = 0; uint256 numberOfDeposits = _deposits.length; @@ -55,9 +57,40 @@ abstract contract VaultValidatorsManager { } /// @notice Requests validators to exit from the beacon chain - /// @param _pubkeys Concatenated validator public keys to exit + /// @param _pubkeys Concatenated validator public keys function _requestValidatorsExit(bytes calldata _pubkeys) internal { - // TODO: + _validateWithdrawalFee(_pubkeys); + + TriggerableWithdrawals.addFullWithdrawalRequests(_pubkeys, TriggerableWithdrawals.getWithdrawalRequestFee()); + } + + /// @notice Requests partial exit of validators from the beacon chain + /// @param _pubkeys Concatenated validator public keys + /// @param _amounts Array of withdrawal amounts for each validator + function _requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) internal { + _validateWithdrawalFee(_pubkeys); + + TriggerableWithdrawals.addPartialWithdrawalRequests( + _pubkeys, + _amounts, + TriggerableWithdrawals.getWithdrawalRequestFee() + ); + } + + /// @dev Validates that contract has enough balance to pay withdrawal fee + /// @param _pubkeys Concatenated validator public keys + function _validateWithdrawalFee(bytes calldata _pubkeys) private view { + uint256 minFeePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); + uint256 validatorCount = _pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH; + uint256 totalFee = validatorCount * minFeePerRequest; + + if (address(this).balance < totalFee) { + revert InsufficientBalanceForWithdrawalFee( + address(this).balance, + totalFee, + validatorCount + ); + } } /// @notice Computes the deposit data root for a validator deposit @@ -120,4 +153,12 @@ abstract contract VaultValidatorsManager { * @param name Name of the argument that was zero */ error ZeroArgument(string name); + + /** + * @notice Thrown when the balance is insufficient to cover the withdrawal request fee + * @param balance Current balance of the contract + * @param required Required balance to cover the fee + * @param numberOfRequests Number of withdrawal requests + */ + error InsufficientBalanceForWithdrawalFee(uint256 balance, uint256 required, uint256 numberOfRequests); } diff --git a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol index f37d827d8..9197c39d9 100644 --- a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol +++ b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol @@ -53,5 +53,7 @@ interface IStakingVault { function pauseBeaconChainDeposits() external; function resumeBeaconChainDeposits() external; function depositToBeaconChain(Deposit[] calldata _deposits) external; + function requestValidatorsExit(bytes calldata _pubkeys) external; + function requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external; } diff --git a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol index 42a29ee30..6549de794 100644 --- a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol +++ b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol @@ -97,6 +97,7 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl function rebalance(uint256 _ether) external {} function report(uint256 _valuation, int256 _inOutDelta, uint256 _locked) external {} function requestValidatorsExit(bytes calldata _pubkeys) external {} + function requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external {} function lock(uint256 _locked) external {} function locked() external view returns (uint256) { diff --git a/test/0.8.25/vaults/vaultFactory.test.ts b/test/0.8.25/vaults/vaultFactory.test.ts index 7d187d28f..f1deed86e 100644 --- a/test/0.8.25/vaults/vaultFactory.test.ts +++ b/test/0.8.25/vaults/vaultFactory.test.ts @@ -164,9 +164,6 @@ describe("VaultFactory.sol", () => { }); it("works with empty `params`", async () => { - console.log({ - delegationParams, - }); const { tx, vault, @@ -306,18 +303,15 @@ describe("VaultFactory.sol", () => { const version3AfterV2 = await vault3WithNewImpl.getInitializedVersion(); expect(version1Before).to.eq(1); + expect(version1After).to.eq(2); expect(version1AfterV2).to.eq(2); expect(version2Before).to.eq(1); + expect(version2After).to.eq(2); expect(version2AfterV2).to.eq(1); expect(version3After).to.eq(2); - - const v1 = { version: version1After, getInitializedVersion: version1AfterV2 }; - const v2 = { version: version2After, getInitializedVersion: version2AfterV2 }; - const v3 = { version: version3After, getInitializedVersion: version3AfterV2 }; - - console.table([v1, v2, v3]); + expect(version3AfterV2).to.eq(2); }); }); From 2ce0a1c3f4764c8f8f09cdc642a93b8722de3b0f Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 28 Jan 2025 13:37:43 +0000 Subject: [PATCH 19/70] chore: cleanup --- contracts/0.8.25/vaults/Permissions.sol | 6 +++++- contracts/0.8.25/vaults/StakingVault.sol | 10 ++++++++-- .../0.8.25/vaults/VaultValidatorsManager.sol | 6 +++--- .../StakingVault__HarnessForTestUpgrade.sol | 17 ++++++++++------- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index 479894545..afbc83e1c 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -142,7 +142,11 @@ abstract contract Permissions is AccessControlVoteable { } function _requestValidatorExit(bytes calldata _pubkey) internal onlyRole(REQUEST_VALIDATOR_EXIT_ROLE) { - stakingVault().requestValidatorExit(_pubkey); + stakingVault().requestValidatorsExit(_pubkey); + } + + function _requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) internal onlyRole(REQUEST_VALIDATOR_EXIT_ROLE) { + stakingVault().requestValidatorsPartialExit(_pubkeys, _amounts); } function _voluntaryDisconnect() internal onlyRole(VOLUNTARY_DISCONNECT_ROLE) { diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index ea7008a12..718ff566f 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -379,7 +379,7 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab * @return Address of `BeaconChainDepositContract` */ function depositContract() external view returns (address) { - return _depositContract(); + return _getDepositContract(); } /** @@ -388,7 +388,7 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab * @return Withdrawal credentials as bytes32 */ function withdrawalCredentials() external view returns (bytes32) { - return _withdrawalCredentials(); + return _getWithdrawalCredentials(); } /** @@ -467,6 +467,12 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab emit ValidatorsExitRequest(msg.sender, _pubkeys); } + /** + * @notice Requests partial exit of validators from the beacon chain + * @param _pubkeys Concatenated validators public keys + * @param _amounts Amounts of ether to exit + * @dev Signals the node operator to eject the specified validators from the beacon chain + */ function requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external { _onlyOwnerOrNodeOperator(); diff --git a/contracts/0.8.25/vaults/VaultValidatorsManager.sol b/contracts/0.8.25/vaults/VaultValidatorsManager.sol index 688c9a750..d8e146d53 100644 --- a/contracts/0.8.25/vaults/VaultValidatorsManager.sol +++ b/contracts/0.8.25/vaults/VaultValidatorsManager.sol @@ -26,14 +26,14 @@ abstract contract VaultValidatorsManager { /// @notice Returns the address of `BeaconChainDepositContract` /// @return Address of `BeaconChainDepositContract` - function _depositContract() internal view returns (address) { + function _getDepositContract() internal view returns (address) { return address(BEACON_CHAIN_DEPOSIT_CONTRACT); } /// @notice Returns the 0x01-type withdrawal credentials for the validators deposited from this `StakingVault` /// All CL rewards are sent to this contract. Only 0x01-type withdrawal credentials are supported for now. /// @return Withdrawal credentials as bytes32 - function _withdrawalCredentials() internal view returns (bytes32) { + function _getWithdrawalCredentials() internal view returns (bytes32) { return bytes32((0x01 << 248) + uint160(address(this))); } @@ -46,7 +46,7 @@ abstract contract VaultValidatorsManager { IStakingVault.Deposit calldata deposit = _deposits[i]; BEACON_CHAIN_DEPOSIT_CONTRACT.deposit{value: deposit.amount}( deposit.pubkey, - bytes.concat(_withdrawalCredentials()), + bytes.concat(_getWithdrawalCredentials()), deposit.signature, deposit.depositDataRoot ); diff --git a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol index 6549de794..469a28db1 100644 --- a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol +++ b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol @@ -40,7 +40,7 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl function initialize( address _owner, address _nodeOperator, - bytes calldata _params + bytes calldata // _params ) external reinitializer(_version) { if (owner() != address(0)) { revert VaultAlreadyInitialized(); @@ -85,12 +85,15 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl function depositToBeaconChain(Deposit[] calldata _deposits) external {} function fund() external payable {} - function inOutDelta() external view returns (int256) { + + function inOutDelta() external pure returns (int256) { return -1; } - function isBalanced() external view returns (bool) { + + function isBalanced() external pure returns (bool) { return true; } + function nodeOperator() external view returns (address) { return _getVaultStorage().nodeOperator; } @@ -100,14 +103,14 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl function requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external {} function lock(uint256 _locked) external {} - function locked() external view returns (uint256) { + function locked() external pure returns (uint256) { return 0; } - function unlocked() external view returns (uint256) { + function unlocked() external pure returns (uint256) { return 0; } - function valuation() external view returns (uint256) { + function valuation() external pure returns (uint256) { return 0; } @@ -121,7 +124,7 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl return bytes32((0x01 << 248) + uint160(address(this))); } - function beaconChainDepositsPaused() external view returns (bool) { + function beaconChainDepositsPaused() external pure returns (bool) { return false; } From cfadfb437c40c7740aaf0538c0247320d529ac03 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Tue, 28 Jan 2025 15:44:07 +0100 Subject: [PATCH 20/70] 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 21/70] 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 22/70] 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 da8fad2a4a0de4c0392ce677dd842e66e54945e1 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 28 Jan 2025 16:34:03 +0000 Subject: [PATCH 23/70] chore: add fees calculation base --- contracts/0.8.25/vaults/StakingVault.sol | 46 ++++--- .../0.8.25/vaults/VaultValidatorsManager.sol | 118 +++++++++++++----- .../vaults/interfaces/IStakingVault.sol | 5 +- .../common/lib/TriggerableWithdrawals.sol | 4 +- .../StakingVault__HarnessForTestUpgrade.sol | 8 +- .../staking-vault.validators.test.ts | 68 ++++++++-- .../triggerableWithdrawals.test.ts | 6 +- test/deploy/stakingVault.ts | 22 ++++ test/suite/constants.ts | 2 + 9 files changed, 201 insertions(+), 78 deletions(-) diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 718ff566f..a800f51c6 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -93,7 +93,7 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab * The storage namespace is used to prevent upgrade collisions * `keccak256(abi.encode(uint256(keccak256("Lido.Vaults.StakingVault")) - 1)) & ~bytes32(uint256(0xff))` */ - bytes32 private constant ERC721_STORAGE_LOCATION = + bytes32 private constant ERC7201_STORAGE_LOCATION = 0x2ec50241a851d8d3fea472e7057288d4603f7a7f78e6d18a9c12cad84552b100; /** @@ -445,12 +445,23 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab _depositToBeaconChain(_deposits); } + /** + * @notice Returns total fee required for given number of validator keys + * @param _numberOfKeys Number of validator keys + * @return Total fee amount + */ + function calculateExitRequestFee(uint256 _numberOfKeys) external view returns (uint256) { + if (_numberOfKeys == 0) revert ZeroArgument("_numberOfKeys"); + + return _calculateExitRequestFee(_numberOfKeys); + } + /** * @notice Requests validators exit from the beacon chain * @param _pubkeys Concatenated validators public keys * @dev Signals the node operator to eject the specified validators from the beacon chain */ - function requestValidatorsExit(bytes calldata _pubkeys) external { + function requestValidatorsExit(bytes calldata _pubkeys) external payable { // Only owner or node operator can exit validators when vault is balanced if (isBalanced()) { _onlyOwnerOrNodeOperator(); @@ -463,8 +474,6 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab } _requestValidatorsExit(_pubkeys); - - emit ValidatorsExitRequest(msg.sender, _pubkeys); } /** @@ -473,12 +482,10 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab * @param _amounts Amounts of ether to exit * @dev Signals the node operator to eject the specified validators from the beacon chain */ - function requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external { + function requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable { _onlyOwnerOrNodeOperator(); _requestValidatorsPartialExit(_pubkeys, _amounts); - - emit ValidatorsPartialExitRequest(msg.sender, _pubkeys, _amounts); } /** @@ -507,7 +514,7 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab function _getStorage() private pure returns (ERC7201Storage storage $) { assembly { - $.slot := ERC721_STORAGE_LOCATION + $.slot := ERC7201_STORAGE_LOCATION } } @@ -536,23 +543,6 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab */ event Withdrawn(address indexed sender, address indexed recipient, uint256 amount); - /** - * @notice Emitted when a validator exit request is made - * @dev Signals `nodeOperator` to exit the validator - * @param sender Address that requested the validator exit - * @param pubkey Public key of the validator requested to exit - */ - event ValidatorsExitRequest(address indexed sender, bytes pubkey); - - /** - * @notice Emitted when a validator partial exit request is made - * @dev Signals `nodeOperator` to exit the validator - * @param sender Address that requested the validator partial exit - * @param pubkey Public key of the validator requested to exit - * @param amounts Amounts of ether requested to exit - */ - event ValidatorsPartialExitRequest(address indexed sender, bytes pubkey, uint64[] amounts); - /** * @notice Emitted when the locked amount is increased * @param locked New amount of locked ether @@ -586,6 +576,12 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab /// Errors + /** + * @notice Thrown when an invalid zero value is passed + * @param name Name of the argument that was zero + */ + error ZeroArgument(string name); + /** * @notice Thrown when trying to withdraw more ether than the balance of `StakingVault` * @param balance Current balance diff --git a/contracts/0.8.25/vaults/VaultValidatorsManager.sol b/contracts/0.8.25/vaults/VaultValidatorsManager.sol index d8e146d53..6a24e174a 100644 --- a/contracts/0.8.25/vaults/VaultValidatorsManager.sol +++ b/contracts/0.8.25/vaults/VaultValidatorsManager.sol @@ -20,7 +20,8 @@ abstract contract VaultValidatorsManager { IDepositContract private immutable BEACON_CHAIN_DEPOSIT_CONTRACT; constructor(address _beaconChainDepositContract) { - if (_beaconChainDepositContract == address(0)) revert ZeroArgument("_beaconChainDepositContract"); + if (_beaconChainDepositContract == address(0)) revert ZeroBeaconChainDepositContract(); + BEACON_CHAIN_DEPOSIT_CONTRACT = IDepositContract(_beaconChainDepositContract); } @@ -56,41 +57,65 @@ abstract contract VaultValidatorsManager { emit DepositedToBeaconChain(msg.sender, numberOfDeposits, totalAmount); } + /// @notice Calculates the total fee required to request validator exits + /// @param _numberOfKeys Number of validator keys to exit + /// @return totalFee Total fee amount required, calculated as minFeePerRequest * number of keys + /// @dev This fee is required by the withdrawal request contract to process validator exits + function _calculateExitRequestFee(uint256 _numberOfKeys) internal view returns (uint256) { + uint256 minFeePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); + return _numberOfKeys * minFeePerRequest; + } + /// @notice Requests validators to exit from the beacon chain /// @param _pubkeys Concatenated validator public keys function _requestValidatorsExit(bytes calldata _pubkeys) internal { - _validateWithdrawalFee(_pubkeys); + uint256 totalFee = _validateExitFee(_pubkeys); - TriggerableWithdrawals.addFullWithdrawalRequests(_pubkeys, TriggerableWithdrawals.getWithdrawalRequestFee()); + TriggerableWithdrawals.addFullWithdrawalRequests(_pubkeys, totalFee); + + emit ValidatorsExitRequested(msg.sender, _pubkeys); + + _refundExcessExitFee(totalFee); } /// @notice Requests partial exit of validators from the beacon chain /// @param _pubkeys Concatenated validator public keys - /// @param _amounts Array of withdrawal amounts for each validator + /// @param _amounts Array of exit amounts for each validator function _requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) internal { - _validateWithdrawalFee(_pubkeys); + uint256 totalFee = _validateExitFee(_pubkeys); - TriggerableWithdrawals.addPartialWithdrawalRequests( - _pubkeys, - _amounts, - TriggerableWithdrawals.getWithdrawalRequestFee() - ); + TriggerableWithdrawals.addPartialWithdrawalRequests(_pubkeys, _amounts, totalFee); + + emit ValidatorsPartialExitRequested(msg.sender, _pubkeys, _amounts); + + _refundExcessExitFee(totalFee); } - /// @dev Validates that contract has enough balance to pay withdrawal fee + /// @notice Refunds excess fee back to the sender + /// @param _totalFee Total fee required for the exit request + function _refundExcessExitFee(uint256 _totalFee) private { + uint256 excess = msg.value - _totalFee; + + if (excess > 0) { + (bool success,) = msg.sender.call{value: excess}(""); + if (!success) { + revert ExitFeeRefundFailed(msg.sender, excess); + } + + emit ExitFeeRefunded(msg.sender, excess); + } + } + + /// @dev Validates that contract has enough balance to pay exit fee /// @param _pubkeys Concatenated validator public keys - function _validateWithdrawalFee(bytes calldata _pubkeys) private view { - uint256 minFeePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); - uint256 validatorCount = _pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH; - uint256 totalFee = validatorCount * minFeePerRequest; - - if (address(this).balance < totalFee) { - revert InsufficientBalanceForWithdrawalFee( - address(this).balance, - totalFee, - validatorCount - ); + function _validateExitFee(bytes calldata _pubkeys) private view returns (uint256) { + uint256 totalFee = _calculateExitRequestFee(_pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH); + + if (msg.value < totalFee) { + revert InsufficientExitFee(msg.value, totalFee); } + + return totalFee; } /// @notice Computes the deposit data root for a validator deposit @@ -126,8 +151,8 @@ abstract contract VaultValidatorsManager { bytes32 pubkeyRoot = sha256(abi.encodePacked(_pubkey, bytes16(0))); // Step 4. Compute the root of the signature - bytes32 sigSlice1Root = sha256(abi.encodePacked(_signature[0:64])); - bytes32 sigSlice2Root = sha256(abi.encodePacked(_signature[64:], bytes32(0))); + bytes32 sigSlice1Root = sha256(abi.encodePacked(_signature[0 : 64])); + bytes32 sigSlice2Root = sha256(abi.encodePacked(_signature[64 :], bytes32(0))); bytes32 signatureRoot = sha256(abi.encodePacked(sigSlice1Root, sigSlice2Root)); // Step 5. Compute the root-toot-toorootoo of the deposit data @@ -141,6 +166,11 @@ abstract contract VaultValidatorsManager { return depositDataRoot; } + /** + * @notice Thrown when `BeaconChainDepositContract` is not set + */ + error ZeroBeaconChainDepositContract(); + /** * @notice Emitted when ether is deposited to `DepositContract` * @param sender Address that initiated the deposit @@ -149,16 +179,40 @@ abstract contract VaultValidatorsManager { event DepositedToBeaconChain(address indexed sender, uint256 deposits, uint256 totalAmount); /** - * @notice Thrown when an invalid zero value is passed - * @param name Name of the argument that was zero + * @notice Emitted when a validator exit request is made + * @dev Signals `nodeOperator` to exit the validator + * @param sender Address that requested the validator exit + * @param pubkey Public key of the validator requested to exit + */ + event ValidatorsExitRequested(address indexed sender, bytes pubkey); + + /** + * @notice Emitted when a validator partial exit request is made + * @dev Signals `nodeOperator` to exit the validator + * @param sender Address that requested the validator partial exit + * @param pubkey Public key of the validator requested to exit + * @param amounts Amounts of ether requested to exit + */ + event ValidatorsPartialExitRequested(address indexed sender, bytes pubkey, uint64[] amounts); + + /** + * @notice Emitted when an excess fee is refunded back to the sender + * @param sender Address that received the refund + * @param amount Amount of ether refunded + */ + event ExitFeeRefunded(address indexed sender, uint256 amount); + + /** + * @notice Thrown when the balance is insufficient to cover the exit request fee + * @param _passed Amount of ether passed to the function + * @param _required Amount of ether required to cover the fee */ - error ZeroArgument(string name); + error InsufficientExitFee(uint256 _passed, uint256 _required); /** - * @notice Thrown when the balance is insufficient to cover the withdrawal request fee - * @param balance Current balance of the contract - * @param required Required balance to cover the fee - * @param numberOfRequests Number of withdrawal requests + * @notice Thrown when a transfer fails + * @param sender Address that initiated the transfer + * @param amount Amount of ether to transfer */ - error InsufficientBalanceForWithdrawalFee(uint256 balance, uint256 required, uint256 numberOfRequests); + error ExitFeeRefundFailed(address sender, uint256 amount); } diff --git a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol index 9197c39d9..590227c60 100644 --- a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol +++ b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol @@ -54,6 +54,7 @@ interface IStakingVault { function resumeBeaconChainDeposits() external; function depositToBeaconChain(Deposit[] calldata _deposits) external; - function requestValidatorsExit(bytes calldata _pubkeys) external; - function requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external; + function calculateExitRequestFee(uint256 _validatorCount) external view returns (uint256); + function requestValidatorsExit(bytes calldata _pubkeys) external payable; + function requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable; } diff --git a/contracts/common/lib/TriggerableWithdrawals.sol b/contracts/common/lib/TriggerableWithdrawals.sol index 8ddbad00c..1db18f408 100644 --- a/contracts/common/lib/TriggerableWithdrawals.sol +++ b/contracts/common/lib/TriggerableWithdrawals.sol @@ -11,7 +11,7 @@ library TriggerableWithdrawals { uint256 internal constant WITHDRAWAL_AMOUNT_LENGTH = 8; error MismatchedArrayLengths(uint256 keysCount, uint256 amountsCount); - error InsufficientBalanceForWithdrawalFee(uint256 balance, uint256 totalWithdrawalFee); + error InsufficientTotalWithdrawalFee(uint256 balance, uint256 totalWithdrawalFee); error InsufficientRequestFee(uint256 feePerRequest, uint256 minFeePerRequest); error WithdrawalRequestFeeReadFailed(); @@ -146,7 +146,7 @@ library TriggerableWithdrawals { } if (address(this).balance < feePerRequest * keysCount) { - revert InsufficientBalanceForWithdrawalFee(address(this).balance, feePerRequest * keysCount); + revert InsufficientTotalWithdrawalFee(address(this).balance, feePerRequest * keysCount); } return feePerRequest; diff --git a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol index 469a28db1..3e0bc5fdd 100644 --- a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol +++ b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol @@ -99,8 +99,8 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl } function rebalance(uint256 _ether) external {} function report(uint256 _valuation, int256 _inOutDelta, uint256 _locked) external {} - function requestValidatorsExit(bytes calldata _pubkeys) external {} - function requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external {} + function requestValidatorsExit(bytes calldata _pubkeys) external payable {} + function requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable {} function lock(uint256 _locked) external {} function locked() external pure returns (uint256) { @@ -128,6 +128,10 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl return false; } + function calculateExitRequestFee(uint256) external pure returns (uint256) { + return 1; + } + function pauseBeaconChainDeposits() external {} function resumeBeaconChainDeposits() external {} diff --git a/test/0.8.25/vaults/staking-vault/staking-vault.validators.test.ts b/test/0.8.25/vaults/staking-vault/staking-vault.validators.test.ts index 6849f55c7..0747abd37 100644 --- a/test/0.8.25/vaults/staking-vault/staking-vault.validators.test.ts +++ b/test/0.8.25/vaults/staking-vault/staking-vault.validators.test.ts @@ -8,7 +8,9 @@ import { StakingVault, VaultHub__MockForStakingVault } from "typechain-types"; import { computeDepositDataRoot, ether, impersonate, streccak } from "lib"; import { deployStakingVaultBehindBeaconProxy } from "test/deploy"; -import { Snapshot } from "test/suite"; +import { Snapshot, Tracing } from "test/suite"; + +const getValidatorPubkey = (index: number) => "0x" + "ab".repeat(48 * index); describe("StakingVault.sol:ValidatorsManagement", () => { let vaultOwner: HardhatEthersSigner; @@ -151,31 +153,73 @@ describe("StakingVault.sol:ValidatorsManagement", () => { }); }); + context("calculateExitRequestFee", () => { + it("reverts if the number of keys is zero", async () => { + await expect(stakingVault.calculateExitRequestFee(0)) + .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") + .withArgs("_numberOfKeys"); + }); + + it("returns the total fee for given number of validator keys", async () => { + const fee = await stakingVault.calculateExitRequestFee(1); + expect(fee).to.equal(1); + }); + }); + context("requestValidatorsExit", () => { + before(async () => { + Tracing.enable(); + }); + + after(async () => { + Tracing.disable(); + }); + context("vault is balanced", () => { it("reverts if called by a non-owner or non-node operator", async () => { - await expect(stakingVault.connect(stranger).requestValidatorsExit("0x")) + const keys = getValidatorPubkey(1); + await expect(stakingVault.connect(stranger).requestValidatorsExit(keys)) .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") .withArgs(await stranger.getAddress()); }); - it("allows owner to request validators exit", async () => { - const pubkeys = "0x" + "ab".repeat(48); - await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys)) - .to.emit(stakingVault, "ValidatorsExitRequest") + it("reverts if passed fee is less than the required fee", async () => { + const numberOfKeys = 4; + const pubkeys = getValidatorPubkey(numberOfKeys); + const fee = await stakingVault.calculateExitRequestFee(numberOfKeys - 1); + + await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys, { value: fee })) + .to.be.revertedWithCustomError(stakingVault, "InsufficientExitFee") + .withArgs(fee, numberOfKeys); + }); + + it("allows owner to request validators exit providing a fee", async () => { + const numberOfKeys = 1; + const pubkeys = getValidatorPubkey(numberOfKeys); + const fee = await stakingVault.calculateExitRequestFee(numberOfKeys); + + await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys, { value: fee })) + .to.emit(stakingVault, "ValidatorsExitRequested") .withArgs(vaultOwnerAddress, pubkeys); }); it("allows node operator to request validators exit", async () => { - await expect(stakingVault.connect(operator).requestValidatorsExit("0x")) - .to.emit(stakingVault, "ValidatorsExitRequest") - .withArgs(operatorAddress, "0x"); + const numberOfKeys = 1; + const pubkeys = getValidatorPubkey(numberOfKeys); + const fee = await stakingVault.calculateExitRequestFee(numberOfKeys); + + await expect(stakingVault.connect(operator).requestValidatorsExit(pubkeys, { value: fee })) + .to.emit(stakingVault, "ValidatorsExitRequested") + .withArgs(operatorAddress, pubkeys); }); it("works with multiple pubkeys", async () => { - const pubkeys = "0x" + "ab".repeat(48) + "cd".repeat(48); - await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys)) - .to.emit(stakingVault, "ValidatorsExitRequest") + const numberOfKeys = 2; + const pubkeys = getValidatorPubkey(numberOfKeys); + const fee = await stakingVault.calculateExitRequestFee(numberOfKeys); + + await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys, { value: fee })) + .to.emit(stakingVault, "ValidatorsExitRequested") .withArgs(vaultOwnerAddress, pubkeys); }); }); diff --git a/test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts b/test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts index c6d2f3365..a5725514d 100644 --- a/test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts +++ b/test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts @@ -233,15 +233,15 @@ describe("TriggerableWithdrawals.sol", () => { await setBalance(await triggerableWithdrawals.getAddress(), balance); await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee)) - .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalanceForWithdrawalFee") + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientTotalWithdrawalFee") .withArgs(balance, expectedMinimalBalance); await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee)) - .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalanceForWithdrawalFee") + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientTotalWithdrawalFee") .withArgs(balance, expectedMinimalBalance); await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee)) - .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientBalanceForWithdrawalFee") + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientTotalWithdrawalFee") .withArgs(balance, expectedMinimalBalance); }); diff --git a/test/deploy/stakingVault.ts b/test/deploy/stakingVault.ts index 9a0b2f26b..bf5c57ca2 100644 --- a/test/deploy/stakingVault.ts +++ b/test/deploy/stakingVault.ts @@ -5,6 +5,7 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { DepositContract__MockForStakingVault, + EIP7002WithdrawalRequest_Mock, StakingVault, StakingVault__factory, VaultFactory__MockForStakingVault, @@ -13,6 +14,8 @@ import { import { findEvents } from "lib"; +import { EIP7002_PREDEPLOYED_ADDRESS } from "test/suite"; + type DeployedStakingVault = { depositContract: DepositContract__MockForStakingVault; stakingVault: StakingVault; @@ -21,10 +24,29 @@ type DeployedStakingVault = { vaultFactory: VaultFactory__MockForStakingVault; }; +export async function deployWithdrawalsPreDeployedMock( + defaultRequestFee: bigint, +): Promise { + const mock = await ethers.deployContract("EIP7002WithdrawalRequest_Mock"); + const mockAddress = await mock.getAddress(); + const mockCode = await ethers.provider.getCode(mockAddress); + + await ethers.provider.send("hardhat_setCode", [EIP7002_PREDEPLOYED_ADDRESS, mockCode]); + + const contract = await ethers.getContractAt("EIP7002WithdrawalRequest_Mock", EIP7002_PREDEPLOYED_ADDRESS); + + await contract.setFee(defaultRequestFee); + + return contract; +} + export async function deployStakingVaultBehindBeaconProxy( vaultOwner: HardhatEthersSigner, operator: HardhatEthersSigner, ): Promise { + // ERC7002 pre-deployed contract mock (0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA) + await deployWithdrawalsPreDeployedMock(1n); + // deploying implementation const vaultHub_ = await ethers.deployContract("VaultHub__MockForStakingVault"); const depositContract_ = await ethers.deployContract("DepositContract__MockForStakingVault"); diff --git a/test/suite/constants.ts b/test/suite/constants.ts index 6a30c9cad..e99f946ec 100644 --- a/test/suite/constants.ts +++ b/test/suite/constants.ts @@ -9,3 +9,5 @@ export const LIMITER_PRECISION_BASE = BigInt(10 ** 9); export const SHARE_RATE_PRECISION = BigInt(10 ** 27); export const ZERO_HASH = new Uint8Array(32).fill(0); + +export const EIP7002_PREDEPLOYED_ADDRESS = "0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA"; From a7447dff09c07062dbc118ccc6f33f208531c652 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 28 Jan 2025 17:00:24 +0000 Subject: [PATCH 24/70] chore: tests --- contracts/0.8.25/vaults/StakingVault.sol | 5 +- .../0.8.25/vaults/VaultValidatorsManager.sol | 104 +++++++++--------- .../vaults/interfaces/IStakingVault.sol | 2 +- .../StakingVault__HarnessForTestUpgrade.sol | 2 +- .../staking-vault.validators.test.ts | 24 ++-- 5 files changed, 66 insertions(+), 71 deletions(-) diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index a800f51c6..af7ccd36b 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -436,6 +436,7 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab */ function depositToBeaconChain(Deposit[] calldata _deposits) external { if (_deposits.length == 0) revert ZeroArgument("_deposits"); + ERC7201Storage storage $ = _getStorage(); if (msg.sender != $.nodeOperator) revert NotAuthorized("depositToBeaconChain", msg.sender); @@ -450,10 +451,10 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab * @param _numberOfKeys Number of validator keys * @return Total fee amount */ - function calculateExitRequestFee(uint256 _numberOfKeys) external view returns (uint256) { + function calculateTotalExitRequestFee(uint256 _numberOfKeys) external view returns (uint256) { if (_numberOfKeys == 0) revert ZeroArgument("_numberOfKeys"); - return _calculateExitRequestFee(_numberOfKeys); + return _calculateTotalExitRequestFee(_numberOfKeys); } /** diff --git a/contracts/0.8.25/vaults/VaultValidatorsManager.sol b/contracts/0.8.25/vaults/VaultValidatorsManager.sol index 6a24e174a..901c638f5 100644 --- a/contracts/0.8.25/vaults/VaultValidatorsManager.sol +++ b/contracts/0.8.25/vaults/VaultValidatorsManager.sol @@ -9,37 +9,35 @@ import {TriggerableWithdrawals} from "contracts/common/lib/TriggerableWithdrawal import {IDepositContract} from "../interfaces/IDepositContract.sol"; import {IStakingVault} from "./interfaces/IStakingVault.sol"; -/// @notice VaultValidatorsManager is a contract that manages validators in the vault -/// @author tamtamchik +/// @notice Abstract contract that manages validator deposits and exits for staking vaults abstract contract VaultValidatorsManager { - /** - * @notice Address of `BeaconChainDepositContract` - * Set immutably in the constructor to avoid storage costs - */ + /// @notice The Beacon Chain deposit contract used for staking validators IDepositContract private immutable BEACON_CHAIN_DEPOSIT_CONTRACT; + /// @notice Constructor that sets the Beacon Chain deposit contract + /// @param _beaconChainDepositContract Address of the Beacon Chain deposit contract constructor(address _beaconChainDepositContract) { if (_beaconChainDepositContract == address(0)) revert ZeroBeaconChainDepositContract(); BEACON_CHAIN_DEPOSIT_CONTRACT = IDepositContract(_beaconChainDepositContract); } - /// @notice Returns the address of `BeaconChainDepositContract` - /// @return Address of `BeaconChainDepositContract` + /// @notice Returns the address of the Beacon Chain deposit contract + /// @return Address of the Beacon Chain deposit contract function _getDepositContract() internal view returns (address) { return address(BEACON_CHAIN_DEPOSIT_CONTRACT); } - /// @notice Returns the 0x01-type withdrawal credentials for the validators deposited from this `StakingVault` - /// All CL rewards are sent to this contract. Only 0x01-type withdrawal credentials are supported for now. - /// @return Withdrawal credentials as bytes32 + /// @notice Returns the 0x01-type withdrawal credentials for the validators deposited from this contract + /// @dev All consensus layer rewards are sent to this contract. Only 0x01-type withdrawal credentials are supported. + /// @return bytes32 The withdrawal credentials, with 0x01 prefix followed by this contract's address function _getWithdrawalCredentials() internal view returns (bytes32) { return bytes32((0x01 << 248) + uint160(address(this))); } /// @notice Deposits validators to the beacon chain deposit contract - /// @param _deposits Array of validator deposits + /// @param _deposits Array of validator deposits containing pubkey, signature, amount and deposit data root function _depositToBeaconChain(IStakingVault.Deposit[] calldata _deposits) internal { uint256 totalAmount = 0; uint256 numberOfDeposits = _deposits.length; @@ -57,42 +55,43 @@ abstract contract VaultValidatorsManager { emit DepositedToBeaconChain(msg.sender, numberOfDeposits, totalAmount); } - /// @notice Calculates the total fee required to request validator exits - /// @param _numberOfKeys Number of validator keys to exit - /// @return totalFee Total fee amount required, calculated as minFeePerRequest * number of keys - /// @dev This fee is required by the withdrawal request contract to process validator exits - function _calculateExitRequestFee(uint256 _numberOfKeys) internal view returns (uint256) { - uint256 minFeePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); - return _numberOfKeys * minFeePerRequest; + /// @notice Calculates the total exit request fee for a given number of validator keys + /// @param _numberOfKeys Number of validator keys + /// @return Total fee amount + function _calculateTotalExitRequestFee(uint256 _numberOfKeys) internal view returns (uint256) { + return _numberOfKeys * TriggerableWithdrawals.getWithdrawalRequestFee(); } - /// @notice Requests validators to exit from the beacon chain - /// @param _pubkeys Concatenated validator public keys + /// @notice Requests full exit of validators from the beacon chain by submitting their public keys + /// @param _pubkeys Concatenated validator public keys, each 48 bytes long + /// @dev The caller must provide sufficient fee via msg.value to cover the exit request costs function _requestValidatorsExit(bytes calldata _pubkeys) internal { - uint256 totalFee = _validateExitFee(_pubkeys); + (uint256 feePerRequest, uint256 totalFee) = _getAndValidateExitFees(_pubkeys); - TriggerableWithdrawals.addFullWithdrawalRequests(_pubkeys, totalFee); + TriggerableWithdrawals.addFullWithdrawalRequests(_pubkeys, feePerRequest); emit ValidatorsExitRequested(msg.sender, _pubkeys); _refundExcessExitFee(totalFee); } - /// @notice Requests partial exit of validators from the beacon chain - /// @param _pubkeys Concatenated validator public keys - /// @param _amounts Array of exit amounts for each validator + /// @notice Requests partial exit of validators from the beacon chain by submitting their public keys and exit amounts + /// @param _pubkeys Concatenated validator public keys, each 48 bytes long + /// @param _amounts Array of exit amounts in Gwei for each validator, must match number of validators in _pubkeys + /// @dev The caller must provide sufficient fee via msg.value to cover the exit request costs function _requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) internal { - uint256 totalFee = _validateExitFee(_pubkeys); + (uint256 feePerRequest, uint256 totalFee) = _getAndValidateExitFees(_pubkeys); - TriggerableWithdrawals.addPartialWithdrawalRequests(_pubkeys, _amounts, totalFee); + TriggerableWithdrawals.addPartialWithdrawalRequests(_pubkeys, _amounts, feePerRequest); emit ValidatorsPartialExitRequested(msg.sender, _pubkeys, _amounts); _refundExcessExitFee(totalFee); } - /// @notice Refunds excess fee back to the sender - /// @param _totalFee Total fee required for the exit request + /// @notice Refunds excess fee back to the sender if they sent more than required + /// @param _totalFee Total fee required for the exit request that will be kept + /// @dev Sends back any msg.value in excess of _totalFee to msg.sender function _refundExcessExitFee(uint256 _totalFee) private { uint256 excess = msg.value - _totalFee; @@ -106,16 +105,18 @@ abstract contract VaultValidatorsManager { } } - /// @dev Validates that contract has enough balance to pay exit fee - /// @param _pubkeys Concatenated validator public keys - function _validateExitFee(bytes calldata _pubkeys) private view returns (uint256) { - uint256 totalFee = _calculateExitRequestFee(_pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH); + /// @notice Validates that sufficient fee was provided to cover validator exit requests + /// @param _pubkeys Concatenated validator public keys, each 48 bytes long + /// @return feePerRequest Fee per request for the exit request + function _getAndValidateExitFees(bytes calldata _pubkeys) private view returns (uint256 feePerRequest, uint256 totalFee) { + feePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); + totalFee = _pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH * feePerRequest; if (msg.value < totalFee) { revert InsufficientExitFee(msg.value, totalFee); } - return totalFee; + return (feePerRequest, totalFee); } /// @notice Computes the deposit data root for a validator deposit @@ -173,34 +174,35 @@ abstract contract VaultValidatorsManager { /** * @notice Emitted when ether is deposited to `DepositContract` - * @param sender Address that initiated the deposit - * @param deposits Number of validator deposits made + * @param _sender Address that initiated the deposit + * @param _deposits Number of validator deposits made + * @param _totalAmount Total amount of ether deposited */ - event DepositedToBeaconChain(address indexed sender, uint256 deposits, uint256 totalAmount); + event DepositedToBeaconChain(address indexed _sender, uint256 _deposits, uint256 _totalAmount); /** * @notice Emitted when a validator exit request is made * @dev Signals `nodeOperator` to exit the validator - * @param sender Address that requested the validator exit - * @param pubkey Public key of the validator requested to exit + * @param _sender Address that requested the validator exit + * @param _pubkey Public key of the validator requested to exit */ - event ValidatorsExitRequested(address indexed sender, bytes pubkey); + event ValidatorsExitRequested(address indexed _sender, bytes _pubkey); /** * @notice Emitted when a validator partial exit request is made * @dev Signals `nodeOperator` to exit the validator - * @param sender Address that requested the validator partial exit - * @param pubkey Public key of the validator requested to exit - * @param amounts Amounts of ether requested to exit + * @param _sender Address that requested the validator partial exit + * @param _pubkey Public key of the validator requested to exit + * @param _amounts Amounts of ether requested to exit */ - event ValidatorsPartialExitRequested(address indexed sender, bytes pubkey, uint64[] amounts); + event ValidatorsPartialExitRequested(address indexed _sender, bytes _pubkey, uint64[] _amounts); /** * @notice Emitted when an excess fee is refunded back to the sender - * @param sender Address that received the refund - * @param amount Amount of ether refunded + * @param _sender Address that received the refund + * @param _amount Amount of ether refunded */ - event ExitFeeRefunded(address indexed sender, uint256 amount); + event ExitFeeRefunded(address indexed _sender, uint256 _amount); /** * @notice Thrown when the balance is insufficient to cover the exit request fee @@ -211,8 +213,8 @@ abstract contract VaultValidatorsManager { /** * @notice Thrown when a transfer fails - * @param sender Address that initiated the transfer - * @param amount Amount of ether to transfer + * @param _sender Address that initiated the transfer + * @param _amount Amount of ether to transfer */ - error ExitFeeRefundFailed(address sender, uint256 amount); + error ExitFeeRefundFailed(address _sender, uint256 _amount); } diff --git a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol index 590227c60..0455ffac9 100644 --- a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol +++ b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol @@ -54,7 +54,7 @@ interface IStakingVault { function resumeBeaconChainDeposits() external; function depositToBeaconChain(Deposit[] calldata _deposits) external; - function calculateExitRequestFee(uint256 _validatorCount) external view returns (uint256); + function calculateTotalExitRequestFee(uint256 _validatorCount) external view returns (uint256); function requestValidatorsExit(bytes calldata _pubkeys) external payable; function requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable; } diff --git a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol index 3e0bc5fdd..eb885643b 100644 --- a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol +++ b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol @@ -128,7 +128,7 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl return false; } - function calculateExitRequestFee(uint256) external pure returns (uint256) { + function calculateTotalExitRequestFee(uint256) external pure returns (uint256) { return 1; } diff --git a/test/0.8.25/vaults/staking-vault/staking-vault.validators.test.ts b/test/0.8.25/vaults/staking-vault/staking-vault.validators.test.ts index 0747abd37..6633ce129 100644 --- a/test/0.8.25/vaults/staking-vault/staking-vault.validators.test.ts +++ b/test/0.8.25/vaults/staking-vault/staking-vault.validators.test.ts @@ -8,7 +8,7 @@ import { StakingVault, VaultHub__MockForStakingVault } from "typechain-types"; import { computeDepositDataRoot, ether, impersonate, streccak } from "lib"; import { deployStakingVaultBehindBeaconProxy } from "test/deploy"; -import { Snapshot, Tracing } from "test/suite"; +import { Snapshot } from "test/suite"; const getValidatorPubkey = (index: number) => "0x" + "ab".repeat(48 * index); @@ -153,28 +153,20 @@ describe("StakingVault.sol:ValidatorsManagement", () => { }); }); - context("calculateExitRequestFee", () => { + context("calculateTotalExitRequestFee", () => { it("reverts if the number of keys is zero", async () => { - await expect(stakingVault.calculateExitRequestFee(0)) + await expect(stakingVault.calculateTotalExitRequestFee(0)) .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") .withArgs("_numberOfKeys"); }); it("returns the total fee for given number of validator keys", async () => { - const fee = await stakingVault.calculateExitRequestFee(1); + const fee = await stakingVault.calculateTotalExitRequestFee(1); expect(fee).to.equal(1); }); }); context("requestValidatorsExit", () => { - before(async () => { - Tracing.enable(); - }); - - after(async () => { - Tracing.disable(); - }); - context("vault is balanced", () => { it("reverts if called by a non-owner or non-node operator", async () => { const keys = getValidatorPubkey(1); @@ -186,7 +178,7 @@ describe("StakingVault.sol:ValidatorsManagement", () => { it("reverts if passed fee is less than the required fee", async () => { const numberOfKeys = 4; const pubkeys = getValidatorPubkey(numberOfKeys); - const fee = await stakingVault.calculateExitRequestFee(numberOfKeys - 1); + const fee = await stakingVault.calculateTotalExitRequestFee(numberOfKeys - 1); await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys, { value: fee })) .to.be.revertedWithCustomError(stakingVault, "InsufficientExitFee") @@ -196,7 +188,7 @@ describe("StakingVault.sol:ValidatorsManagement", () => { it("allows owner to request validators exit providing a fee", async () => { const numberOfKeys = 1; const pubkeys = getValidatorPubkey(numberOfKeys); - const fee = await stakingVault.calculateExitRequestFee(numberOfKeys); + const fee = await stakingVault.calculateTotalExitRequestFee(numberOfKeys); await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys, { value: fee })) .to.emit(stakingVault, "ValidatorsExitRequested") @@ -206,7 +198,7 @@ describe("StakingVault.sol:ValidatorsManagement", () => { it("allows node operator to request validators exit", async () => { const numberOfKeys = 1; const pubkeys = getValidatorPubkey(numberOfKeys); - const fee = await stakingVault.calculateExitRequestFee(numberOfKeys); + const fee = await stakingVault.calculateTotalExitRequestFee(numberOfKeys); await expect(stakingVault.connect(operator).requestValidatorsExit(pubkeys, { value: fee })) .to.emit(stakingVault, "ValidatorsExitRequested") @@ -216,7 +208,7 @@ describe("StakingVault.sol:ValidatorsManagement", () => { it("works with multiple pubkeys", async () => { const numberOfKeys = 2; const pubkeys = getValidatorPubkey(numberOfKeys); - const fee = await stakingVault.calculateExitRequestFee(numberOfKeys); + const fee = await stakingVault.calculateTotalExitRequestFee(numberOfKeys); await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys, { value: fee })) .to.emit(stakingVault, "ValidatorsExitRequested") From a30cd67f395e4d86e88f5c6b770a404e1c58dbbd Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 29 Jan 2025 08:02:27 +0000 Subject: [PATCH 25/70] chore: renaming --- contracts/0.8.25/vaults/StakingVault.sol | 6 +- ...atorsManager.sol => ValidatorsManager.sol} | 10 +- ...ccounting.test.ts => stakingVault.test.ts} | 126 +++++++++-- ...tors.test.ts => validatorsManager.test.ts} | 207 ++++++++---------- 4 files changed, 217 insertions(+), 132 deletions(-) rename contracts/0.8.25/vaults/{VaultValidatorsManager.sol => ValidatorsManager.sol} (97%) rename test/0.8.25/vaults/staking-vault/{staking-vault.accounting.test.ts => stakingVault.test.ts} (80%) rename test/0.8.25/vaults/staking-vault/{staking-vault.validators.test.ts => validatorsManager.test.ts} (51%) diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index af7ccd36b..0a5fa4f89 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -7,7 +7,7 @@ pragma solidity 0.8.25; import {OwnableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/OwnableUpgradeable.sol"; import {VaultHub} from "./VaultHub.sol"; -import {VaultValidatorsManager} from "./VaultValidatorsManager.sol"; +import {ValidatorsManager} from "./ValidatorsManager.sol"; import {IStakingVault} from "./interfaces/IStakingVault.sol"; @@ -56,7 +56,7 @@ import {IStakingVault} from "./interfaces/IStakingVault.sol"; * deposit contract. * */ -contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeable { +contract StakingVault is IStakingVault, ValidatorsManager, OwnableUpgradeable { /** * @notice ERC-7201 storage namespace for the vault * @dev ERC-7201 namespace is used to prevent upgrade collisions @@ -110,7 +110,7 @@ contract StakingVault is IStakingVault, VaultValidatorsManager, OwnableUpgradeab constructor( address _vaultHub, address _beaconChainDepositContract - ) VaultValidatorsManager(_beaconChainDepositContract) { + ) ValidatorsManager(_beaconChainDepositContract) { if (_vaultHub == address(0)) revert ZeroArgument("_vaultHub"); VAULT_HUB = VaultHub(_vaultHub); diff --git a/contracts/0.8.25/vaults/VaultValidatorsManager.sol b/contracts/0.8.25/vaults/ValidatorsManager.sol similarity index 97% rename from contracts/0.8.25/vaults/VaultValidatorsManager.sol rename to contracts/0.8.25/vaults/ValidatorsManager.sol index 901c638f5..1b7228706 100644 --- a/contracts/0.8.25/vaults/VaultValidatorsManager.sol +++ b/contracts/0.8.25/vaults/ValidatorsManager.sol @@ -10,7 +10,7 @@ import {IDepositContract} from "../interfaces/IDepositContract.sol"; import {IStakingVault} from "./interfaces/IStakingVault.sol"; /// @notice Abstract contract that manages validator deposits and exits for staking vaults -abstract contract VaultValidatorsManager { +abstract contract ValidatorsManager { /// @notice The Beacon Chain deposit contract used for staking validators IDepositContract private immutable BEACON_CHAIN_DEPOSIT_CONTRACT; @@ -184,18 +184,18 @@ abstract contract VaultValidatorsManager { * @notice Emitted when a validator exit request is made * @dev Signals `nodeOperator` to exit the validator * @param _sender Address that requested the validator exit - * @param _pubkey Public key of the validator requested to exit + * @param _pubkeys Public key of the validator requested to exit */ - event ValidatorsExitRequested(address indexed _sender, bytes _pubkey); + event ValidatorsExitRequested(address indexed _sender, bytes _pubkeys); /** * @notice Emitted when a validator partial exit request is made * @dev Signals `nodeOperator` to exit the validator * @param _sender Address that requested the validator partial exit - * @param _pubkey Public key of the validator requested to exit + * @param _pubkeys Public key of the validator requested to exit * @param _amounts Amounts of ether requested to exit */ - event ValidatorsPartialExitRequested(address indexed _sender, bytes _pubkey, uint64[] _amounts); + event ValidatorsPartialExitRequested(address indexed _sender, bytes _pubkeys, uint64[] _amounts); /** * @notice Emitted when an excess fee is refunded back to the sender diff --git a/test/0.8.25/vaults/staking-vault/staking-vault.accounting.test.ts b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts similarity index 80% rename from test/0.8.25/vaults/staking-vault/staking-vault.accounting.test.ts rename to test/0.8.25/vaults/staking-vault/stakingVault.test.ts index 0562a5f42..1e38f8b3b 100644 --- a/test/0.8.25/vaults/staking-vault/staking-vault.accounting.test.ts +++ b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts @@ -12,7 +12,7 @@ import { VaultHub__MockForStakingVault, } from "typechain-types"; -import { de0x, ether, impersonate } from "lib"; +import { computeDepositDataRoot, de0x, ether, impersonate, streccak } from "lib"; import { deployStakingVaultBehindBeaconProxy } from "test/deploy"; import { Snapshot } from "test/suite"; @@ -21,7 +21,7 @@ const MAX_INT128 = 2n ** 127n - 1n; const MAX_UINT128 = 2n ** 128n - 1n; // @TODO: test reentrancy attacks -describe("StakingVault.sol:Accounting", () => { +describe("StakingVault.sol", () => { let vaultOwner: HardhatEthersSigner; let operator: HardhatEthersSigner; let stranger: HardhatEthersSigner; @@ -57,13 +57,9 @@ describe("StakingVault.sol:Accounting", () => { vaultHubSigner = await impersonate(vaultHubAddress, ether("10")); }); - beforeEach(async () => { - originalState = await Snapshot.take(); - }); + beforeEach(async () => (originalState = await Snapshot.take())); - afterEach(async () => { - await Snapshot.restore(originalState); - }); + afterEach(async () => await Snapshot.restore(originalState)); context("constructor", () => { it("sets the vault hub address in the implementation", async () => { @@ -80,12 +76,6 @@ describe("StakingVault.sol:Accounting", () => { .withArgs("_vaultHub"); }); - it("reverts on construction if the deposit contract address is zero", async () => { - await expect(ethers.deployContract("StakingVault", [vaultHubAddress, ZeroAddress])) - .to.be.revertedWithCustomError(stakingVaultImplementation, "ZeroArgument") - .withArgs("_beaconChainDepositContract"); - }); - it("petrifies the implementation by setting the initialized version to 2^64 - 1", async () => { expect(await stakingVaultImplementation.getInitializedVersion()).to.equal(2n ** 64n - 1n); expect(await stakingVaultImplementation.version()).to.equal(1n); @@ -118,6 +108,114 @@ describe("StakingVault.sol:Accounting", () => { }); }); + context("pauseBeaconChainDeposits", () => { + it("reverts if called by a non-owner", async () => { + await expect(stakingVault.connect(stranger).pauseBeaconChainDeposits()) + .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") + .withArgs(await stranger.getAddress()); + }); + + it("reverts if the beacon deposits are already paused", async () => { + await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); + + await expect(stakingVault.connect(vaultOwner).pauseBeaconChainDeposits()).to.be.revertedWithCustomError( + stakingVault, + "BeaconChainDepositsResumeExpected", + ); + }); + + it("allows to pause deposits", async () => { + await expect(stakingVault.connect(vaultOwner).pauseBeaconChainDeposits()).to.emit( + stakingVault, + "BeaconChainDepositsPaused", + ); + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + }); + }); + + context("resumeBeaconChainDeposits", () => { + it("reverts if called by a non-owner", async () => { + await expect(stakingVault.connect(stranger).resumeBeaconChainDeposits()) + .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") + .withArgs(await stranger.getAddress()); + }); + + it("reverts if the beacon deposits are already resumed", async () => { + await expect(stakingVault.connect(vaultOwner).resumeBeaconChainDeposits()).to.be.revertedWithCustomError( + stakingVault, + "BeaconChainDepositsPauseExpected", + ); + }); + + it("allows to resume deposits", async () => { + await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); + + await expect(stakingVault.connect(vaultOwner).resumeBeaconChainDeposits()).to.emit( + stakingVault, + "BeaconChainDepositsResumed", + ); + expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + }); + }); + + context("depositToBeaconChain", () => { + it("reverts if called by a non-operator", async () => { + await expect( + stakingVault + .connect(stranger) + .depositToBeaconChain([ + { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, + ]), + ) + .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") + .withArgs("depositToBeaconChain", stranger); + }); + + it("reverts if the number of deposits is zero", async () => { + await expect(stakingVault.depositToBeaconChain([])) + .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") + .withArgs("_deposits"); + }); + + it("reverts if the vault is not balanced", async () => { + await stakingVault.connect(vaultHubSigner).lock(ether("1")); + await expect( + stakingVault + .connect(operator) + .depositToBeaconChain([ + { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, + ]), + ).to.be.revertedWithCustomError(stakingVault, "Unbalanced"); + }); + + it("reverts if the deposits are paused", async () => { + await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); + await expect( + stakingVault + .connect(operator) + .depositToBeaconChain([ + { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, + ]), + ).to.be.revertedWithCustomError(stakingVault, "BeaconChainDepositsArePaused"); + }); + + it("makes deposits to the beacon chain and emits the DepositedToBeaconChain event", async () => { + await stakingVault.fund({ value: ether("32") }); + + const pubkey = "0x" + "ab".repeat(48); + const signature = "0x" + "ef".repeat(96); + const amount = ether("32"); + const withdrawalCredentials = await stakingVault.withdrawalCredentials(); + const depositDataRoot = computeDepositDataRoot(withdrawalCredentials, pubkey, signature, amount); + + await expect( + stakingVault.connect(operator).depositToBeaconChain([{ pubkey, signature, amount, depositDataRoot }]), + ) + .to.emit(stakingVault, "DepositedToBeaconChain") + .withArgs(operator, 1, amount); + }); + }); + context("unlocked", () => { it("returns the correct unlocked balance", async () => { expect(await stakingVault.unlocked()).to.equal(0n); diff --git a/test/0.8.25/vaults/staking-vault/staking-vault.validators.test.ts b/test/0.8.25/vaults/staking-vault/validatorsManager.test.ts similarity index 51% rename from test/0.8.25/vaults/staking-vault/staking-vault.validators.test.ts rename to test/0.8.25/vaults/staking-vault/validatorsManager.test.ts index 6633ce129..6b065e751 100644 --- a/test/0.8.25/vaults/staking-vault/staking-vault.validators.test.ts +++ b/test/0.8.25/vaults/staking-vault/validatorsManager.test.ts @@ -1,18 +1,27 @@ import { expect } from "chai"; +import { ZeroAddress } from "ethers"; import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { StakingVault, VaultHub__MockForStakingVault } from "typechain-types"; +import { + DepositContract__MockForStakingVault, + EIP7002WithdrawalRequest_Mock, + StakingVault, + VaultHub__MockForStakingVault, +} from "typechain-types"; -import { computeDepositDataRoot, ether, impersonate, streccak } from "lib"; +import { computeDepositDataRoot, de0x, ether, impersonate } from "lib"; import { deployStakingVaultBehindBeaconProxy } from "test/deploy"; -import { Snapshot } from "test/suite"; +import { EIP7002_PREDEPLOYED_ADDRESS, Snapshot } from "test/suite"; -const getValidatorPubkey = (index: number) => "0x" + "ab".repeat(48 * index); +const getPubkey = (index: number) => index.toString(16).padStart(4, "0").toLocaleLowerCase().repeat(24); +const getSignature = (index: number) => index.toString(16).padStart(8, "0").toLocaleLowerCase().repeat(12); -describe("StakingVault.sol:ValidatorsManagement", () => { +const getPubkeys = (num: number) => `0x${Array.from({ length: num }, (_, i) => getPubkey(i + 1)).join("")}`; + +describe("ValidatorsManager.sol", () => { let vaultOwner: HardhatEthersSigner; let operator: HardhatEthersSigner; let stranger: HardhatEthersSigner; @@ -20,154 +29,132 @@ describe("StakingVault.sol:ValidatorsManagement", () => { let stakingVault: StakingVault; let vaultHub: VaultHub__MockForStakingVault; + let depositContract: DepositContract__MockForStakingVault; + let withdrawalRequest: EIP7002WithdrawalRequest_Mock; let vaultOwnerAddress: string; let vaultHubAddress: string; let operatorAddress: string; + let depositContractAddress: string; + let stakingVaultAddress: string; + let originalState: string; before(async () => { [vaultOwner, operator, stranger] = await ethers.getSigners(); - ({ stakingVault, vaultHub } = await deployStakingVaultBehindBeaconProxy(vaultOwner, operator)); + ({ stakingVault, vaultHub, depositContract } = await deployStakingVaultBehindBeaconProxy(vaultOwner, operator)); vaultOwnerAddress = await vaultOwner.getAddress(); vaultHubAddress = await vaultHub.getAddress(); operatorAddress = await operator.getAddress(); + depositContractAddress = await depositContract.getAddress(); + stakingVaultAddress = await stakingVault.getAddress(); - vaultHubSigner = await impersonate(vaultHubAddress, ether("10")); - }); - - beforeEach(async () => { - originalState = await Snapshot.take(); - }); + withdrawalRequest = await ethers.getContractAt("EIP7002WithdrawalRequest_Mock", EIP7002_PREDEPLOYED_ADDRESS); - afterEach(async () => { - await Snapshot.restore(originalState); + vaultHubSigner = await impersonate(vaultHubAddress, ether("10")); }); - context("pauseBeaconChainDeposits", () => { - it("reverts if called by a non-owner", async () => { - await expect(stakingVault.connect(stranger).pauseBeaconChainDeposits()) - .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") - .withArgs(await stranger.getAddress()); - }); + beforeEach(async () => (originalState = await Snapshot.take())); - it("reverts if the beacon deposits are already paused", async () => { - await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); + afterEach(async () => await Snapshot.restore(originalState)); - await expect(stakingVault.connect(vaultOwner).pauseBeaconChainDeposits()).to.be.revertedWithCustomError( + context("constructor", () => { + it("reverts if the deposit contract address is zero", async () => { + await expect(ethers.deployContract("StakingVault", [vaultHubAddress, ZeroAddress])).to.be.revertedWithCustomError( stakingVault, - "BeaconChainDepositsResumeExpected", + "ZeroBeaconChainDepositContract", ); }); - - it("allows to pause deposits", async () => { - await expect(stakingVault.connect(vaultOwner).pauseBeaconChainDeposits()).to.emit( - stakingVault, - "BeaconChainDepositsPaused", - ); - expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; - }); }); - context("resumeBeaconChainDeposits", () => { - it("reverts if called by a non-owner", async () => { - await expect(stakingVault.connect(stranger).resumeBeaconChainDeposits()) - .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") - .withArgs(await stranger.getAddress()); + context("_getDepositContract", () => { + it("returns the deposit contract address", async () => { + expect(await stakingVault.depositContract()).to.equal(depositContractAddress); }); + }); - it("reverts if the beacon deposits are already resumed", async () => { - await expect(stakingVault.connect(vaultOwner).resumeBeaconChainDeposits()).to.be.revertedWithCustomError( - stakingVault, - "BeaconChainDepositsPauseExpected", + context("_withdrawalCredentials", () => { + it("returns the withdrawal credentials", async () => { + expect(await stakingVault.withdrawalCredentials()).to.equal( + ("0x01" + "00".repeat(11) + de0x(stakingVaultAddress)).toLowerCase(), ); }); + }); - it("allows to resume deposits", async () => { - await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); + context("_depositToBeaconChain", () => { + it("makes deposits to the beacon chain and emits the DepositedToBeaconChain event", async () => { + const numberOfKeys = 2; // number because of Array.from + const totalAmount = ether("32") * BigInt(numberOfKeys); + const withdrawalCredentials = await stakingVault.withdrawalCredentials(); - await expect(stakingVault.connect(vaultOwner).resumeBeaconChainDeposits()).to.emit( - stakingVault, - "BeaconChainDepositsResumed", - ); - expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + await stakingVault.fund({ value: totalAmount }); + + const deposits = Array.from({ length: numberOfKeys }, (_, i) => { + const pubkey = `0x${getPubkey(i + 1)}`; + const signature = `0x${getSignature(i + 1)}`; + const amount = ether("32"); + const depositDataRoot = computeDepositDataRoot(withdrawalCredentials, pubkey, signature, amount); + return { pubkey, signature, amount, depositDataRoot }; + }); + + await expect(stakingVault.connect(operator).depositToBeaconChain(deposits)) + .to.emit(stakingVault, "DepositedToBeaconChain") + .withArgs(operator, 2, totalAmount); }); }); - context("depositToBeaconChain", () => { - it("reverts if called by a non-operator", async () => { - await expect( - stakingVault - .connect(stranger) - .depositToBeaconChain([ - { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, - ]), - ) - .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") - .withArgs("depositToBeaconChain", stranger); - }); + context("_calculateTotalExitRequestFee", () => { + it("returns the total fee for given number of validator keys", async () => { + const newFee = 100n; + await withdrawalRequest.setFee(newFee); - it("reverts if the number of deposits is zero", async () => { - await expect(stakingVault.depositToBeaconChain([])) - .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") - .withArgs("_deposits"); - }); + const fee = await stakingVault.calculateTotalExitRequestFee(1n); + expect(fee).to.equal(newFee); - it("reverts if the vault is not balanced", async () => { - await stakingVault.connect(vaultHubSigner).lock(ether("1")); - await expect( - stakingVault - .connect(operator) - .depositToBeaconChain([ - { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, - ]), - ).to.be.revertedWithCustomError(stakingVault, "Unbalanced"); - }); + const feePerRequest = await withdrawalRequest.fee(); + expect(fee).to.equal(feePerRequest); - it("reverts if the deposits are paused", async () => { - await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); - await expect( - stakingVault - .connect(operator) - .depositToBeaconChain([ - { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, - ]), - ).to.be.revertedWithCustomError(stakingVault, "BeaconChainDepositsArePaused"); + const feeForMultipleKeys = await stakingVault.calculateTotalExitRequestFee(2n); + expect(feeForMultipleKeys).to.equal(newFee * 2n); }); + }); - it("makes deposits to the beacon chain and emits the DepositedToBeaconChain event", async () => { - await stakingVault.fund({ value: ether("32") }); - - const pubkey = "0x" + "ab".repeat(48); - const signature = "0x" + "ef".repeat(96); - const amount = ether("32"); - const withdrawalCredentials = await stakingVault.withdrawalCredentials(); - const depositDataRoot = computeDepositDataRoot(withdrawalCredentials, pubkey, signature, amount); + context("_requestValidatorsExit", () => { + it("reverts if passed fee is less than the required fee", async () => { + const numberOfKeys = 4; + const pubkeys = getPubkeys(numberOfKeys); + const fee = await stakingVault.calculateTotalExitRequestFee(numberOfKeys - 1); - await expect( - stakingVault.connect(operator).depositToBeaconChain([{ pubkey, signature, amount, depositDataRoot }]), - ) - .to.emit(stakingVault, "DepositedToBeaconChain") - .withArgs(operator, 1, amount); + await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys, { value: fee })) + .to.be.revertedWithCustomError(stakingVault, "InsufficientExitFee") + .withArgs(fee, numberOfKeys); }); - }); - context("calculateTotalExitRequestFee", () => { - it("reverts if the number of keys is zero", async () => { - await expect(stakingVault.calculateTotalExitRequestFee(0)) - .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") - .withArgs("_numberOfKeys"); + it("allows owner to request validators exit providing a fee", async () => { + const numberOfKeys = 1; + const pubkeys = getPubkeys(numberOfKeys); + const fee = await stakingVault.calculateTotalExitRequestFee(numberOfKeys); + + await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys, { value: fee })) + .to.emit(stakingVault, "ValidatorsExitRequested") + .withArgs(vaultOwnerAddress, pubkeys); }); - it("returns the total fee for given number of validator keys", async () => { - const fee = await stakingVault.calculateTotalExitRequestFee(1); - expect(fee).to.equal(1); + it("refunds the fee if passed fee is greater than the required fee", async () => { + const numberOfKeys = 1; + const pubkeys = getPubkeys(numberOfKeys); + const fee = await stakingVault.calculateTotalExitRequestFee(numberOfKeys); + const overpaid = 100n; + + await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys, { value: fee + overpaid })) + .to.emit(stakingVault, "ValidatorsExitRequested") + .withArgs(vaultOwnerAddress, pubkeys) + .and.to.emit(stakingVault, "ExitFeeRefunded") + .withArgs(vaultOwnerAddress, overpaid); }); - }); - context("requestValidatorsExit", () => { - context("vault is balanced", () => { + context.skip("vault is balanced", () => { it("reverts if called by a non-owner or non-node operator", async () => { const keys = getValidatorPubkey(1); await expect(stakingVault.connect(stranger).requestValidatorsExit(keys)) @@ -216,7 +203,7 @@ describe("StakingVault.sol:ValidatorsManagement", () => { }); }); - context("vault is unbalanced", () => { + context.skip("vault is unbalanced", () => { beforeEach(async () => { await stakingVault.connect(vaultHubSigner).report(ether("1"), ether("0.1"), ether("1.1")); expect(await stakingVault.isBalanced()).to.be.false; From 8bedfe66598531c67fb7b2f34b8c1d7586576036 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 29 Jan 2025 11:51:45 +0000 Subject: [PATCH 26/70] chore: restore request validator exit --- contracts/0.8.25/vaults/Permissions.sol | 13 ++++++++-- contracts/0.8.25/vaults/StakingVault.sol | 18 ++++++++++--- contracts/0.8.25/vaults/ValidatorsManager.sol | 26 ++++++++++++++----- .../vaults/interfaces/IStakingVault.sol | 6 +++-- .../StakingVault__HarnessForTestUpgrade.sol | 6 +++-- 5 files changed, 53 insertions(+), 16 deletions(-) diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index afbc83e1c..b450852ae 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -59,6 +59,11 @@ abstract contract Permissions is AccessControlVoteable { */ bytes32 public constant REQUEST_VALIDATOR_EXIT_ROLE = keccak256("StakingVault.Permissions.RequestValidatorExit"); + /** + * @notice Permission for force validators exit from the StakingVault using EIP-7002 triggerable exit. + */ + bytes32 public constant FORCE_VALIDATORS_EXIT_ROLE = keccak256("StakingVault.Permissions.ForceValidatorsExit"); + /** * @notice Permission for voluntary disconnecting the StakingVault. */ @@ -145,8 +150,12 @@ abstract contract Permissions is AccessControlVoteable { stakingVault().requestValidatorsExit(_pubkey); } - function _requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) internal onlyRole(REQUEST_VALIDATOR_EXIT_ROLE) { - stakingVault().requestValidatorsPartialExit(_pubkeys, _amounts); + function _forceValidatorsExit(bytes calldata _pubkeys) internal onlyRole(FORCE_VALIDATORS_EXIT_ROLE) { + stakingVault().forceValidatorsExit(_pubkeys); + } + + function _forcePartialValidatorsExit(bytes calldata _pubkeys, uint64[] calldata _amounts) internal onlyRole(FORCE_VALIDATORS_EXIT_ROLE) { + stakingVault().forcePartialValidatorsExit(_pubkeys, _amounts); } function _voluntaryDisconnect() internal onlyRole(VOLUNTARY_DISCONNECT_ROLE) { diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 0a5fa4f89..5ff73101c 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -457,12 +457,22 @@ contract StakingVault is IStakingVault, ValidatorsManager, OwnableUpgradeable { return _calculateTotalExitRequestFee(_numberOfKeys); } + /** + * @notice Requests validator exit from the beacon chain + * @param _pubkeys Concatenated validator public keys + * @dev Signals the node operator to eject the specified validators from the beacon chain + */ + function requestValidatorsExit(bytes calldata _pubkeys) external onlyOwner { + _requestValidatorsExit(_pubkeys); + } + + /** * @notice Requests validators exit from the beacon chain * @param _pubkeys Concatenated validators public keys * @dev Signals the node operator to eject the specified validators from the beacon chain */ - function requestValidatorsExit(bytes calldata _pubkeys) external payable { + function forceValidatorsExit(bytes calldata _pubkeys) external payable { // Only owner or node operator can exit validators when vault is balanced if (isBalanced()) { _onlyOwnerOrNodeOperator(); @@ -474,7 +484,7 @@ contract StakingVault is IStakingVault, ValidatorsManager, OwnableUpgradeable { revert ExitTimelockNotElapsed(exitTimelock); } - _requestValidatorsExit(_pubkeys); + _forceValidatorsExit(_pubkeys); } /** @@ -483,10 +493,10 @@ contract StakingVault is IStakingVault, ValidatorsManager, OwnableUpgradeable { * @param _amounts Amounts of ether to exit * @dev Signals the node operator to eject the specified validators from the beacon chain */ - function requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable { + function forcePartialValidatorsExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable { _onlyOwnerOrNodeOperator(); - _requestValidatorsPartialExit(_pubkeys, _amounts); + _forcePartialValidatorsExit(_pubkeys, _amounts); } /** diff --git a/contracts/0.8.25/vaults/ValidatorsManager.sol b/contracts/0.8.25/vaults/ValidatorsManager.sol index 1b7228706..003ed8a1e 100644 --- a/contracts/0.8.25/vaults/ValidatorsManager.sol +++ b/contracts/0.8.25/vaults/ValidatorsManager.sol @@ -62,15 +62,21 @@ abstract contract ValidatorsManager { return _numberOfKeys * TriggerableWithdrawals.getWithdrawalRequestFee(); } + /// @notice Emits the ValidatorsExitRequest event + /// @param _pubkeys Concatenated validator public keys, each 48 bytes long + function _requestValidatorsExit(bytes calldata _pubkeys) internal { + emit ValidatorsExitRequested(msg.sender, _pubkeys); + } + /// @notice Requests full exit of validators from the beacon chain by submitting their public keys /// @param _pubkeys Concatenated validator public keys, each 48 bytes long /// @dev The caller must provide sufficient fee via msg.value to cover the exit request costs - function _requestValidatorsExit(bytes calldata _pubkeys) internal { + function _forceValidatorsExit(bytes calldata _pubkeys) internal { (uint256 feePerRequest, uint256 totalFee) = _getAndValidateExitFees(_pubkeys); TriggerableWithdrawals.addFullWithdrawalRequests(_pubkeys, feePerRequest); - emit ValidatorsExitRequested(msg.sender, _pubkeys); + emit ValidatorsExitForced(msg.sender, _pubkeys); _refundExcessExitFee(totalFee); } @@ -79,12 +85,12 @@ abstract contract ValidatorsManager { /// @param _pubkeys Concatenated validator public keys, each 48 bytes long /// @param _amounts Array of exit amounts in Gwei for each validator, must match number of validators in _pubkeys /// @dev The caller must provide sufficient fee via msg.value to cover the exit request costs - function _requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) internal { + function _forcePartialValidatorsExit(bytes calldata _pubkeys, uint64[] calldata _amounts) internal { (uint256 feePerRequest, uint256 totalFee) = _getAndValidateExitFees(_pubkeys); TriggerableWithdrawals.addPartialWithdrawalRequests(_pubkeys, _amounts, feePerRequest); - emit ValidatorsPartialExitRequested(msg.sender, _pubkeys, _amounts); + emit PartialValidatorsExitForced(msg.sender, _pubkeys, _amounts); _refundExcessExitFee(totalFee); } @@ -189,13 +195,21 @@ abstract contract ValidatorsManager { event ValidatorsExitRequested(address indexed _sender, bytes _pubkeys); /** - * @notice Emitted when a validator partial exit request is made + * @notice Emitted when a validator exit request is forced via EIP-7002 + * @dev Signals `nodeOperator` to exit the validator + * @param _sender Address that requested the validator exit + * @param _pubkeys Public key of the validator requested to exit + */ + event ValidatorsExitForced(address indexed _sender, bytes _pubkeys); + + /** + * @notice Emitted when a validator partial exit request is forced via EIP-7002 * @dev Signals `nodeOperator` to exit the validator * @param _sender Address that requested the validator partial exit * @param _pubkeys Public key of the validator requested to exit * @param _amounts Amounts of ether requested to exit */ - event ValidatorsPartialExitRequested(address indexed _sender, bytes _pubkeys, uint64[] _amounts); + event PartialValidatorsExitForced(address indexed _sender, bytes _pubkeys, uint64[] _amounts); /** * @notice Emitted when an excess fee is refunded back to the sender diff --git a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol index 0455ffac9..134afeddd 100644 --- a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol +++ b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol @@ -54,7 +54,9 @@ interface IStakingVault { function resumeBeaconChainDeposits() external; function depositToBeaconChain(Deposit[] calldata _deposits) external; + function requestValidatorsExit(bytes calldata _pubkeys) external; + function calculateTotalExitRequestFee(uint256 _validatorCount) external view returns (uint256); - function requestValidatorsExit(bytes calldata _pubkeys) external payable; - function requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable; + function forceValidatorsExit(bytes calldata _pubkeys) external payable; + function forcePartialValidatorsExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable; } diff --git a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol index eb885643b..71111c163 100644 --- a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol +++ b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol @@ -99,8 +99,6 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl } function rebalance(uint256 _ether) external {} function report(uint256 _valuation, int256 _inOutDelta, uint256 _locked) external {} - function requestValidatorsExit(bytes calldata _pubkeys) external payable {} - function requestValidatorsPartialExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable {} function lock(uint256 _locked) external {} function locked() external pure returns (uint256) { @@ -135,6 +133,10 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl function pauseBeaconChainDeposits() external {} function resumeBeaconChainDeposits() external {} + function requestValidatorsExit(bytes calldata _pubkeys) external {} + function forceValidatorsExit(bytes calldata _pubkeys) external payable {} + function forcePartialValidatorsExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable {} + error ZeroArgument(string name); error VaultAlreadyInitialized(); } From 6da1d6f7f4fbf2d112e24e1b38798cc61d33e935 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Sat, 1 Feb 2025 11:47:49 +0100 Subject: [PATCH 27/70] 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 9a1a8d8b53b2f41e937f05efac1327fe79cafa24 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Mon, 3 Feb 2025 11:45:51 +0000 Subject: [PATCH 28/70] feat: move to 0x02 wc for vaults --- contracts/0.8.25/vaults/ValidatorsManager.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/0.8.25/vaults/ValidatorsManager.sol b/contracts/0.8.25/vaults/ValidatorsManager.sol index 003ed8a1e..4b3f70464 100644 --- a/contracts/0.8.25/vaults/ValidatorsManager.sol +++ b/contracts/0.8.25/vaults/ValidatorsManager.sol @@ -29,11 +29,11 @@ abstract contract ValidatorsManager { return address(BEACON_CHAIN_DEPOSIT_CONTRACT); } - /// @notice Returns the 0x01-type withdrawal credentials for the validators deposited from this contract - /// @dev All consensus layer rewards are sent to this contract. Only 0x01-type withdrawal credentials are supported. - /// @return bytes32 The withdrawal credentials, with 0x01 prefix followed by this contract's address + /// @notice Returns the 0x02-type withdrawal credentials for the validators deposited from this contract + /// @dev All consensus layer rewards are sent to this contract. Only 0x02-type withdrawal credentials are supported. + /// @return bytes32 The withdrawal credentials, with 0x02 prefix followed by this contract's address function _getWithdrawalCredentials() internal view returns (bytes32) { - return bytes32((0x01 << 248) + uint160(address(this))); + return bytes32((0x02 << 248) + uint160(address(this))); } /// @notice Deposits validators to the beacon chain deposit contract From ab1a048387ee781e421dac9e680efc59d88f5e5e Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Mon, 3 Feb 2025 15:52:10 +0000 Subject: [PATCH 29/70] feat: beacon chain foundation contract --- ...ager.sol => BeaconValidatorController.sol} | 186 +++++++------ contracts/0.8.25/vaults/Dashboard.sol | 9 + contracts/0.8.25/vaults/StakingVault.sol | 27 +- .../beaconValidatorController.test.ts | 250 ++++++++++++++++++ .../BeaconValidatorController__Harness.sol | 48 ++++ .../staking-vault/validatorsManager.test.ts | 239 ----------------- 6 files changed, 412 insertions(+), 347 deletions(-) rename contracts/0.8.25/vaults/{ValidatorsManager.sol => BeaconValidatorController.sol} (55%) create mode 100644 test/0.8.25/vaults/staking-vault/beaconValidatorController.test.ts create mode 100644 test/0.8.25/vaults/staking-vault/contracts/BeaconValidatorController__Harness.sol delete mode 100644 test/0.8.25/vaults/staking-vault/validatorsManager.test.ts diff --git a/contracts/0.8.25/vaults/ValidatorsManager.sol b/contracts/0.8.25/vaults/BeaconValidatorController.sol similarity index 55% rename from contracts/0.8.25/vaults/ValidatorsManager.sol rename to contracts/0.8.25/vaults/BeaconValidatorController.sol index 4b3f70464..5d40d895e 100644 --- a/contracts/0.8.25/vaults/ValidatorsManager.sol +++ b/contracts/0.8.25/vaults/BeaconValidatorController.sol @@ -9,134 +9,134 @@ import {TriggerableWithdrawals} from "contracts/common/lib/TriggerableWithdrawal import {IDepositContract} from "../interfaces/IDepositContract.sol"; import {IStakingVault} from "./interfaces/IStakingVault.sol"; -/// @notice Abstract contract that manages validator deposits and exits for staking vaults -abstract contract ValidatorsManager { +/// @notice Abstract contract that manages validator deposits and withdrawals for staking vaults. +abstract contract BeaconValidatorController { - /// @notice The Beacon Chain deposit contract used for staking validators + /// @notice The Beacon Chain deposit contract used for staking validators. IDepositContract private immutable BEACON_CHAIN_DEPOSIT_CONTRACT; - /// @notice Constructor that sets the Beacon Chain deposit contract - /// @param _beaconChainDepositContract Address of the Beacon Chain deposit contract + /// @notice Constructor that sets the Beacon Chain deposit contract. + /// @param _beaconChainDepositContract Address of the Beacon Chain deposit contract. constructor(address _beaconChainDepositContract) { if (_beaconChainDepositContract == address(0)) revert ZeroBeaconChainDepositContract(); BEACON_CHAIN_DEPOSIT_CONTRACT = IDepositContract(_beaconChainDepositContract); } - /// @notice Returns the address of the Beacon Chain deposit contract - /// @return Address of the Beacon Chain deposit contract - function _getDepositContract() internal view returns (address) { + /// @notice Returns the address of the Beacon Chain deposit contract. + /// @return Address of the Beacon Chain deposit contract. + function _depositContract() internal view returns (address) { return address(BEACON_CHAIN_DEPOSIT_CONTRACT); } /// @notice Returns the 0x02-type withdrawal credentials for the validators deposited from this contract /// @dev All consensus layer rewards are sent to this contract. Only 0x02-type withdrawal credentials are supported. - /// @return bytes32 The withdrawal credentials, with 0x02 prefix followed by this contract's address - function _getWithdrawalCredentials() internal view returns (bytes32) { + /// @return bytes32 The withdrawal credentials, with 0x02 prefix followed by this contract's address. + function _withdrawalCredentials() internal view returns (bytes32) { return bytes32((0x02 << 248) + uint160(address(this))); } - /// @notice Deposits validators to the beacon chain deposit contract - /// @param _deposits Array of validator deposits containing pubkey, signature, amount and deposit data root - function _depositToBeaconChain(IStakingVault.Deposit[] calldata _deposits) internal { + /// @notice Deposits validators to the beacon chain deposit contract. + /// @param _deposits Array of validator deposits containing pubkey, signature, amount and deposit data root. + function _deposit(IStakingVault.Deposit[] calldata _deposits) internal { uint256 totalAmount = 0; uint256 numberOfDeposits = _deposits.length; for (uint256 i = 0; i < numberOfDeposits; i++) { IStakingVault.Deposit calldata deposit = _deposits[i]; BEACON_CHAIN_DEPOSIT_CONTRACT.deposit{value: deposit.amount}( deposit.pubkey, - bytes.concat(_getWithdrawalCredentials()), + bytes.concat(_withdrawalCredentials()), deposit.signature, deposit.depositDataRoot ); totalAmount += deposit.amount; } - emit DepositedToBeaconChain(msg.sender, numberOfDeposits, totalAmount); + emit Deposited(msg.sender, numberOfDeposits, totalAmount); } - /// @notice Calculates the total exit request fee for a given number of validator keys - /// @param _numberOfKeys Number of validator keys - /// @return Total fee amount - function _calculateTotalExitRequestFee(uint256 _numberOfKeys) internal view returns (uint256) { - return _numberOfKeys * TriggerableWithdrawals.getWithdrawalRequestFee(); + /// @notice Calculates the total withdrawal fee for a given number of public keys. + /// @param _keysCount Number of public keys. + /// @return Total fee amount. + function _calculateWithdrawalFee(uint256 _keysCount) internal view returns (uint256) { + return _keysCount * TriggerableWithdrawals.getWithdrawalRequestFee(); } - /// @notice Emits the ValidatorsExitRequest event - /// @param _pubkeys Concatenated validator public keys, each 48 bytes long - function _requestValidatorsExit(bytes calldata _pubkeys) internal { - emit ValidatorsExitRequested(msg.sender, _pubkeys); + /// @notice Emits the `ExitRequested` event for `nodeOperator` to exit validators. + /// @param _pubkeys Concatenated validator public keys, each 48 bytes long. + function _requestExit(bytes calldata _pubkeys) internal { + emit ExitRequested(msg.sender, _pubkeys); } - /// @notice Requests full exit of validators from the beacon chain by submitting their public keys - /// @param _pubkeys Concatenated validator public keys, each 48 bytes long - /// @dev The caller must provide sufficient fee via msg.value to cover the exit request costs - function _forceValidatorsExit(bytes calldata _pubkeys) internal { - (uint256 feePerRequest, uint256 totalFee) = _getAndValidateExitFees(_pubkeys); + /// @notice Requests full withdrawal of validators from the beacon chain by submitting their public keys. + /// @param _pubkeys Concatenated validator public keys, each 48 bytes long. + /// @dev The caller must provide sufficient fee via msg.value to cover the withdrawal request costs. + function _initiateFullWithdrawal(bytes calldata _pubkeys) internal { + (uint256 feePerRequest, uint256 totalFee) = _getAndValidateWithdrawalFees(_pubkeys); TriggerableWithdrawals.addFullWithdrawalRequests(_pubkeys, feePerRequest); - emit ValidatorsExitForced(msg.sender, _pubkeys); + emit WithdrawalInitiated(msg.sender, _pubkeys); - _refundExcessExitFee(totalFee); + _refundExcessFee(totalFee); } - /// @notice Requests partial exit of validators from the beacon chain by submitting their public keys and exit amounts - /// @param _pubkeys Concatenated validator public keys, each 48 bytes long - /// @param _amounts Array of exit amounts in Gwei for each validator, must match number of validators in _pubkeys - /// @dev The caller must provide sufficient fee via msg.value to cover the exit request costs - function _forcePartialValidatorsExit(bytes calldata _pubkeys, uint64[] calldata _amounts) internal { - (uint256 feePerRequest, uint256 totalFee) = _getAndValidateExitFees(_pubkeys); + /// @notice Requests partial withdrawal of validators from the beacon chain by submitting their public keys and withdrawal amounts. + /// @param _pubkeys Concatenated validator public keys, each 48 bytes long. + /// @param _amounts Array of withdrawal amounts in Gwei for each validator, must match number of validators in _pubkeys. + /// @dev The caller must provide sufficient fee via msg.value to cover the withdrawal request costs. + function _initiatePartialWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) internal { + (uint256 feePerRequest, uint256 totalFee) = _getAndValidateWithdrawalFees(_pubkeys); TriggerableWithdrawals.addPartialWithdrawalRequests(_pubkeys, _amounts, feePerRequest); - emit PartialValidatorsExitForced(msg.sender, _pubkeys, _amounts); + emit PartialWithdrawalInitiated(msg.sender, _pubkeys, _amounts); - _refundExcessExitFee(totalFee); + _refundExcessFee(totalFee); } - /// @notice Refunds excess fee back to the sender if they sent more than required - /// @param _totalFee Total fee required for the exit request that will be kept - /// @dev Sends back any msg.value in excess of _totalFee to msg.sender - function _refundExcessExitFee(uint256 _totalFee) private { + /// @notice Refunds excess fee back to the sender if they sent more than required. + /// @param _totalFee Total fee required for the withdrawal request that will be kept. + /// @dev Sends back any msg.value in excess of _totalFee to msg.sender. + function _refundExcessFee(uint256 _totalFee) private { uint256 excess = msg.value - _totalFee; if (excess > 0) { (bool success,) = msg.sender.call{value: excess}(""); if (!success) { - revert ExitFeeRefundFailed(msg.sender, excess); + revert FeeRefundFailed(msg.sender, excess); } - emit ExitFeeRefunded(msg.sender, excess); + emit FeeRefunded(msg.sender, excess); } } - /// @notice Validates that sufficient fee was provided to cover validator exit requests - /// @param _pubkeys Concatenated validator public keys, each 48 bytes long - /// @return feePerRequest Fee per request for the exit request - function _getAndValidateExitFees(bytes calldata _pubkeys) private view returns (uint256 feePerRequest, uint256 totalFee) { + /// @notice Validates that sufficient fee was provided to cover validator withdrawal requests. + /// @param _pubkeys Concatenated validator public keys, each 48 bytes long. + /// @return feePerRequest Fee per request for the withdrawal request. + function _getAndValidateWithdrawalFees(bytes calldata _pubkeys) private view returns (uint256 feePerRequest, uint256 totalFee) { feePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); - totalFee = _pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH * feePerRequest; + totalFee = feePerRequest * _pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH; if (msg.value < totalFee) { - revert InsufficientExitFee(msg.value, totalFee); + revert InsufficientFee(msg.value, totalFee); } return (feePerRequest, totalFee); } - /// @notice Computes the deposit data root for a validator deposit - /// @param _pubkey Validator public key, 48 bytes - /// @param _withdrawalCredentials Withdrawal credentials, 32 bytes - /// @param _signature Signature of the deposit, 96 bytes - /// @param _amount Amount of ether to deposit, in wei - /// @return Deposit data root as bytes32 + /// @notice Computes the deposit data root for a validator deposit. + /// @param _pubkey Validator public key, 48 bytes. + /// @param _withdrawalCreds Withdrawal credentials, 32 bytes. + /// @param _signature Signature of the deposit, 96 bytes. + /// @param _amount Amount of ether to deposit, in wei. + /// @return Deposit data root as bytes32. /// @dev This function computes the deposit data root according to the deposit contract's specification. /// The deposit data root is check upon deposit to the deposit contract as a protection against malformed deposit data. /// See more: https://etherscan.io/address/0x00000000219ab540356cbb839cbe05303d7705fa#code function _computeDepositDataRoot( bytes calldata _pubkey, - bytes calldata _withdrawalCredentials, + bytes calldata _withdrawalCreds, bytes calldata _signature, uint256 _amount ) internal pure returns (bytes32) { @@ -165,7 +165,7 @@ abstract contract ValidatorsManager { // Step 5. Compute the root-toot-toorootoo of the deposit data bytes32 depositDataRoot = sha256( abi.encodePacked( - sha256(abi.encodePacked(pubkeyRoot, _withdrawalCredentials)), + sha256(abi.encodePacked(pubkeyRoot, _withdrawalCreds)), sha256(abi.encodePacked(amountLE64, bytes24(0), signatureRoot)) ) ); @@ -174,61 +174,59 @@ abstract contract ValidatorsManager { } /** - * @notice Thrown when `BeaconChainDepositContract` is not set + * @notice Emitted when ether is deposited to `DepositContract`. + * @param _sender Address that initiated the deposit. + * @param _deposits Number of validator deposits made. + * @param _totalAmount Total amount of ether deposited. */ - error ZeroBeaconChainDepositContract(); + event Deposited(address indexed _sender, uint256 _deposits, uint256 _totalAmount); /** - * @notice Emitted when ether is deposited to `DepositContract` - * @param _sender Address that initiated the deposit - * @param _deposits Number of validator deposits made - * @param _totalAmount Total amount of ether deposited + * @notice Emitted when a validator exit request is made. + * @param _sender Address that requested the validator exit. + * @param _pubkeys Public key of the validator requested to exit. + * @dev Signals `nodeOperator` to exit the validator. */ - event DepositedToBeaconChain(address indexed _sender, uint256 _deposits, uint256 _totalAmount); + event ExitRequested(address indexed _sender, bytes _pubkeys); /** - * @notice Emitted when a validator exit request is made - * @dev Signals `nodeOperator` to exit the validator - * @param _sender Address that requested the validator exit - * @param _pubkeys Public key of the validator requested to exit + * @notice Emitted when a validator withdrawal request is forced via EIP-7002. + * @param _sender Address that requested the validator withdrawal. + * @param _pubkeys Public key of the validator requested to withdraw. */ - event ValidatorsExitRequested(address indexed _sender, bytes _pubkeys); + event WithdrawalInitiated(address indexed _sender, bytes _pubkeys); /** - * @notice Emitted when a validator exit request is forced via EIP-7002 - * @dev Signals `nodeOperator` to exit the validator - * @param _sender Address that requested the validator exit - * @param _pubkeys Public key of the validator requested to exit + * @notice Emitted when a validator partial withdrawal request is forced via EIP-7002. + * @param _sender Address that requested the validator partial withdrawal. + * @param _pubkeys Public key of the validator requested to withdraw. + * @param _amounts Amounts of ether requested to withdraw. */ - event ValidatorsExitForced(address indexed _sender, bytes _pubkeys); + event PartialWithdrawalInitiated(address indexed _sender, bytes _pubkeys, uint64[] _amounts); /** - * @notice Emitted when a validator partial exit request is forced via EIP-7002 - * @dev Signals `nodeOperator` to exit the validator - * @param _sender Address that requested the validator partial exit - * @param _pubkeys Public key of the validator requested to exit - * @param _amounts Amounts of ether requested to exit + * @notice Emitted when an excess fee is refunded back to the sender. + * @param _sender Address that received the refund. + * @param _amount Amount of ether refunded. */ - event PartialValidatorsExitForced(address indexed _sender, bytes _pubkeys, uint64[] _amounts); + event FeeRefunded(address indexed _sender, uint256 _amount); /** - * @notice Emitted when an excess fee is refunded back to the sender - * @param _sender Address that received the refund - * @param _amount Amount of ether refunded + * @notice Thrown when `BeaconChainDepositContract` is not set. */ - event ExitFeeRefunded(address indexed _sender, uint256 _amount); + error ZeroBeaconChainDepositContract(); /** - * @notice Thrown when the balance is insufficient to cover the exit request fee - * @param _passed Amount of ether passed to the function - * @param _required Amount of ether required to cover the fee + * @notice Thrown when the balance is insufficient to cover the withdrawal request fee. + * @param _passed Amount of ether passed to the function. + * @param _required Amount of ether required to cover the fee. */ - error InsufficientExitFee(uint256 _passed, uint256 _required); + error InsufficientFee(uint256 _passed, uint256 _required); /** - * @notice Thrown when a transfer fails - * @param _sender Address that initiated the transfer - * @param _amount Amount of ether to transfer + * @notice Thrown when a transfer fails. + * @param _sender Address that initiated the transfer. + * @param _amount Amount of ether to transfer. */ - error ExitFeeRefundFailed(address _sender, uint256 _amount); + error FeeRefundFailed(address _sender, uint256 _amount); } diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 5d9954957..cd7525aaa 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -399,6 +399,15 @@ contract Dashboard is Permissions { _resumeBeaconChainDeposits(); } + /** + * @notice Requests validators exit for the given validator public keys. + * @param _validatorPublicKeys The public keys of the validators to request exit for. + * @dev This only emits an event requesting the exit, it does not actually initiate the exit. + */ + function requestValidatorsExit(bytes calldata _validatorPublicKeys) external { + _requestValidatorExit(_validatorPublicKeys); + } + // ==================== Role Management Functions ==================== /** diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 5ff73101c..34e221444 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -7,7 +7,7 @@ pragma solidity 0.8.25; import {OwnableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/OwnableUpgradeable.sol"; import {VaultHub} from "./VaultHub.sol"; -import {ValidatorsManager} from "./ValidatorsManager.sol"; +import {BeaconValidatorController} from "./BeaconValidatorController.sol"; import {IStakingVault} from "./interfaces/IStakingVault.sol"; @@ -56,7 +56,7 @@ import {IStakingVault} from "./interfaces/IStakingVault.sol"; * deposit contract. * */ -contract StakingVault is IStakingVault, ValidatorsManager, OwnableUpgradeable { +contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgradeable { /** * @notice ERC-7201 storage namespace for the vault * @dev ERC-7201 namespace is used to prevent upgrade collisions @@ -110,7 +110,7 @@ contract StakingVault is IStakingVault, ValidatorsManager, OwnableUpgradeable { constructor( address _vaultHub, address _beaconChainDepositContract - ) ValidatorsManager(_beaconChainDepositContract) { + ) BeaconValidatorController(_beaconChainDepositContract) { if (_vaultHub == address(0)) revert ZeroArgument("_vaultHub"); VAULT_HUB = VaultHub(_vaultHub); @@ -379,16 +379,16 @@ contract StakingVault is IStakingVault, ValidatorsManager, OwnableUpgradeable { * @return Address of `BeaconChainDepositContract` */ function depositContract() external view returns (address) { - return _getDepositContract(); + return _depositContract(); } /** - * @notice Returns the 0x01-type withdrawal credentials for the validators deposited from this `StakingVault` - * All CL rewards are sent to this contract. Only 0x01-type withdrawal credentials are supported for now. + * @notice Returns the 0x02-type withdrawal credentials for the validators deposited from this `StakingVault` + * All CL rewards are sent to this contract. Only 0x02-type withdrawal credentials are supported for now. * @return Withdrawal credentials as bytes32 */ function withdrawalCredentials() external view returns (bytes32) { - return _getWithdrawalCredentials(); + return _withdrawalCredentials(); } /** @@ -443,7 +443,7 @@ contract StakingVault is IStakingVault, ValidatorsManager, OwnableUpgradeable { if ($.beaconChainDepositsPaused) revert BeaconChainDepositsArePaused(); if (!isBalanced()) revert Unbalanced(); - _depositToBeaconChain(_deposits); + _deposit(_deposits); } /** @@ -454,19 +454,18 @@ contract StakingVault is IStakingVault, ValidatorsManager, OwnableUpgradeable { function calculateTotalExitRequestFee(uint256 _numberOfKeys) external view returns (uint256) { if (_numberOfKeys == 0) revert ZeroArgument("_numberOfKeys"); - return _calculateTotalExitRequestFee(_numberOfKeys); + return _calculateWithdrawalFee(_numberOfKeys); } /** * @notice Requests validator exit from the beacon chain * @param _pubkeys Concatenated validator public keys - * @dev Signals the node operator to eject the specified validators from the beacon chain + * @dev Signals the node operator to eject the specified validators from the beacon chain */ function requestValidatorsExit(bytes calldata _pubkeys) external onlyOwner { - _requestValidatorsExit(_pubkeys); + _requestExit(_pubkeys); } - /** * @notice Requests validators exit from the beacon chain * @param _pubkeys Concatenated validators public keys @@ -484,7 +483,7 @@ contract StakingVault is IStakingVault, ValidatorsManager, OwnableUpgradeable { revert ExitTimelockNotElapsed(exitTimelock); } - _forceValidatorsExit(_pubkeys); + _initiateFullWithdrawal(_pubkeys); } /** @@ -496,7 +495,7 @@ contract StakingVault is IStakingVault, ValidatorsManager, OwnableUpgradeable { function forcePartialValidatorsExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable { _onlyOwnerOrNodeOperator(); - _forcePartialValidatorsExit(_pubkeys, _amounts); + _initiatePartialWithdrawal(_pubkeys, _amounts); } /** diff --git a/test/0.8.25/vaults/staking-vault/beaconValidatorController.test.ts b/test/0.8.25/vaults/staking-vault/beaconValidatorController.test.ts new file mode 100644 index 000000000..2d8afa051 --- /dev/null +++ b/test/0.8.25/vaults/staking-vault/beaconValidatorController.test.ts @@ -0,0 +1,250 @@ +import { expect } from "chai"; +import { ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; +import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; + +import { + BeaconValidatorController__Harness, + DepositContract__MockForStakingVault, + EIP7002WithdrawalRequest_Mock, + EthRejector, +} from "typechain-types"; + +import { computeDepositDataRoot, de0x, ether, impersonate } from "lib"; + +import { deployWithdrawalsPreDeployedMock } from "test/deploy"; +import { Snapshot } from "test/suite"; + +const getPubkey = (index: number) => index.toString(16).padStart(4, "0").toLocaleLowerCase().repeat(24); +const getSignature = (index: number) => index.toString(16).padStart(8, "0").toLocaleLowerCase().repeat(12); + +const getPubkeys = (num: number) => `0x${Array.from({ length: num }, (_, i) => getPubkey(i + 1)).join("")}`; + +describe("BeaconValidatorController.sol", () => { + let owner: HardhatEthersSigner; + let operator: HardhatEthersSigner; + + let controller: BeaconValidatorController__Harness; + let depositContract: DepositContract__MockForStakingVault; + let withdrawalRequest: EIP7002WithdrawalRequest_Mock; + let ethRejector: EthRejector; + + let depositContractAddress: string; + let controllerAddress: string; + + let originalState: string; + + before(async () => { + [owner, operator] = await ethers.getSigners(); + + withdrawalRequest = await deployWithdrawalsPreDeployedMock(1n); + ethRejector = await ethers.deployContract("EthRejector"); + + depositContract = await ethers.deployContract("DepositContract__MockForStakingVault"); + depositContractAddress = await depositContract.getAddress(); + + controller = await ethers.deployContract("BeaconValidatorController__Harness", [depositContractAddress]); + controllerAddress = await controller.getAddress(); + }); + + beforeEach(async () => (originalState = await Snapshot.take())); + + afterEach(async () => await Snapshot.restore(originalState)); + + context("constructor", () => { + it("reverts if the deposit contract address is zero", async () => { + await expect( + ethers.deployContract("BeaconValidatorController__Harness", [ZeroAddress]), + ).to.be.revertedWithCustomError(controller, "ZeroBeaconChainDepositContract"); + }); + }); + + context("_depositContract", () => { + it("returns the deposit contract address", async () => { + expect(await controller.harness__depositContract()).to.equal(depositContractAddress); + }); + }); + + context("_withdrawalCredentials", () => { + it("returns the withdrawal credentials", async () => { + expect(await controller.harness__withdrawalCredentials()).to.equal( + ("0x02" + "00".repeat(11) + de0x(controllerAddress)).toLowerCase(), + ); + }); + }); + + context("_deposit", () => { + it("makes deposits to the beacon chain and emits the Deposited event", async () => { + const numberOfKeys = 2; // number because of Array.from + const totalAmount = ether("32") * BigInt(numberOfKeys); + const withdrawalCredentials = await controller.harness__withdrawalCredentials(); + + // topup the contract with enough ETH to cover the deposits + await setBalance(controllerAddress, ether("32") * BigInt(numberOfKeys)); + + const deposits = Array.from({ length: numberOfKeys }, (_, i) => { + const pubkey = `0x${getPubkey(i + 1)}`; + const signature = `0x${getSignature(i + 1)}`; + const amount = ether("32"); + const depositDataRoot = computeDepositDataRoot(withdrawalCredentials, pubkey, signature, amount); + return { pubkey, signature, amount, depositDataRoot }; + }); + + await expect(controller.connect(operator).harness__deposit(deposits)) + .to.emit(controller, "Deposited") + .withArgs(operator, 2, totalAmount); + }); + }); + + context("_calculateWithdrawalFee", () => { + it("returns the total fee for given number of validator keys", async () => { + const newFee = 100n; + await withdrawalRequest.setFee(newFee); + + const fee = await controller.harness__calculateWithdrawalFee(1n); + expect(fee).to.equal(newFee); + + const feePerRequest = await withdrawalRequest.fee(); + expect(fee).to.equal(feePerRequest); + + const feeForMultipleKeys = await controller.harness__calculateWithdrawalFee(2n); + expect(feeForMultipleKeys).to.equal(newFee * 2n); + }); + }); + + context("_requestExit", () => { + it("emits the ExitRequested event", async () => { + const pubkeys = getPubkeys(2); + await expect(controller.connect(owner).harness__requestExit(pubkeys)) + .to.emit(controller, "ExitRequested") + .withArgs(owner, pubkeys); + }); + }); + + context("_initiateFullWithdrawal", () => { + it("reverts if passed fee is less than the required fee", async () => { + const numberOfKeys = 4; + const pubkeys = getPubkeys(numberOfKeys); + const fee = await controller.harness__calculateWithdrawalFee(numberOfKeys - 1); + + await expect(controller.connect(owner).harness__initiateFullWithdrawal(pubkeys, { value: fee })) + .to.be.revertedWithCustomError(controller, "InsufficientFee") + .withArgs(fee, numberOfKeys); + }); + + it("reverts if the refund fails", async () => { + const numberOfKeys = 1; + const pubkeys = getPubkeys(numberOfKeys); + const fee = await controller.harness__calculateWithdrawalFee(numberOfKeys); + const overpaid = 100n; + + const ethRejectorAddress = await ethRejector.getAddress(); + const ethRejectorSigner = await impersonate(ethRejectorAddress, ether("1")); + + await expect( + controller.connect(ethRejectorSigner).harness__initiateFullWithdrawal(pubkeys, { value: fee + overpaid }), + ) + .to.be.revertedWithCustomError(controller, "FeeRefundFailed") + .withArgs(ethRejectorAddress, overpaid); + }); + + it("initiates full withdrawal providing a fee", async () => { + const numberOfKeys = 1; + const pubkeys = getPubkeys(numberOfKeys); + const fee = await controller.harness__calculateWithdrawalFee(numberOfKeys); + + await expect(controller.connect(owner).harness__initiateFullWithdrawal(pubkeys, { value: fee })) + .to.emit(controller, "WithdrawalInitiated") + .withArgs(owner, pubkeys); + }); + + it("refunds the fee if passed fee is greater than the required fee", async () => { + const numberOfKeys = 1; + const pubkeys = getPubkeys(numberOfKeys); + const fee = await controller.harness__calculateWithdrawalFee(numberOfKeys); + const overpaid = 100n; + + await expect(controller.connect(owner).harness__initiateFullWithdrawal(pubkeys, { value: fee + overpaid })) + .to.emit(controller, "WithdrawalInitiated") + .withArgs(owner, pubkeys) + .and.to.emit(controller, "FeeRefunded") + .withArgs(owner, overpaid); + }); + }); + + context("_initiatePartialWithdrawal", () => { + it("reverts if passed fee is less than the required fee", async () => { + const numberOfKeys = 4; + const pubkeys = getPubkeys(numberOfKeys); + const fee = await controller.harness__calculateWithdrawalFee(numberOfKeys - 1); + + await expect(controller.connect(owner).harness__initiatePartialWithdrawal(pubkeys, [100n, 200n], { value: fee })) + .to.be.revertedWithCustomError(controller, "InsufficientFee") + .withArgs(fee, numberOfKeys); + }); + + it("reverts if the refund fails", async () => { + const numberOfKeys = 2; + const pubkeys = getPubkeys(numberOfKeys); + const fee = await controller.harness__calculateWithdrawalFee(numberOfKeys); + const overpaid = 100n; + + const ethRejectorAddress = await ethRejector.getAddress(); + const ethRejectorSigner = await impersonate(ethRejectorAddress, ether("1")); + + await expect( + controller + .connect(ethRejectorSigner) + .harness__initiatePartialWithdrawal(pubkeys, [100n, 200n], { value: fee + overpaid }), + ) + .to.be.revertedWithCustomError(controller, "FeeRefundFailed") + .withArgs(ethRejectorAddress, overpaid); + }); + + it("initiates partial withdrawal providing a fee", async () => { + const numberOfKeys = 2; + const pubkeys = getPubkeys(numberOfKeys); + const fee = await controller.harness__calculateWithdrawalFee(numberOfKeys); + + await expect(controller.connect(owner).harness__initiatePartialWithdrawal(pubkeys, [100n, 200n], { value: fee })) + .to.emit(controller, "PartialWithdrawalInitiated") + .withArgs(owner, pubkeys, [100n, 200n]); + }); + + it("refunds the fee if passed fee is greater than the required fee", async () => { + const numberOfKeys = 2; + const pubkeys = getPubkeys(numberOfKeys); + const fee = await controller.harness__calculateWithdrawalFee(numberOfKeys); + const overpaid = 100n; + + await expect( + controller.connect(owner).harness__initiatePartialWithdrawal(pubkeys, [100n, 200n], { value: fee + overpaid }), + ) + .to.emit(controller, "PartialWithdrawalInitiated") + .withArgs(owner, pubkeys, [100n, 200n]) + .and.to.emit(controller, "FeeRefunded") + .withArgs(owner, overpaid); + }); + }); + + context("computeDepositDataRoot", () => { + it("computes the deposit data root", async () => { + // sample tx data: https://etherscan.io/tx/0x02980d44c119b0a8e3ca0d31c288e9f177c76fb4d7ab616563e399dd9c7c6507 + const pubkey = + "0x8d6aa059b52f6b11d07d73805d409feba07dffb6442c4ef6645f7caa4038b1047e072cba21eb766579f8286ccac630b0"; + const withdrawalCredentials = "0x010000000000000000000000b8b5da17a1b7a8ad1cf45a12e1e61d3577052d35"; + const signature = + "0xab95e358d002fd79bc08564a2db057dd5164af173915eba9e3e9da233d404c0eb0058760bc30cb89abbc55cf57f0c5a6018cdb17df73ca39ddc80a323a13c2e7ba942faa86757b26120b3a58dcce5d89e95ea1ee8fa3276ffac0f0ad9313211d"; + const amount = ether("32"); + const expectedDepositDataRoot = "0xb28f86815813d7da8132a2979836b326094a350e7aa301ba611163d4b7ca77be"; + + computeDepositDataRoot(withdrawalCredentials, pubkey, signature, amount); + + expect( + await controller.harness__computeDepositDataRoot(pubkey, withdrawalCredentials, signature, amount), + ).to.equal(expectedDepositDataRoot); + }); + }); +}); diff --git a/test/0.8.25/vaults/staking-vault/contracts/BeaconValidatorController__Harness.sol b/test/0.8.25/vaults/staking-vault/contracts/BeaconValidatorController__Harness.sol new file mode 100644 index 000000000..5cc06cde7 --- /dev/null +++ b/test/0.8.25/vaults/staking-vault/contracts/BeaconValidatorController__Harness.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity ^0.8.0; + +import {BeaconValidatorController} from "contracts/0.8.25/vaults/BeaconValidatorController.sol"; +import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.sol"; + +contract BeaconValidatorController__Harness is BeaconValidatorController { + constructor(address _beaconChainDepositContract) BeaconValidatorController(_beaconChainDepositContract) {} + + function harness__depositContract() external view returns (address) { + return _depositContract(); + } + + function harness__withdrawalCredentials() external view returns (bytes32) { + return _withdrawalCredentials(); + } + + function harness__deposit(IStakingVault.Deposit[] calldata _deposits) external { + _deposit(_deposits); + } + + function harness__calculateWithdrawalFee(uint256 _amount) external view returns (uint256) { + return _calculateWithdrawalFee(_amount); + } + + function harness__requestExit(bytes calldata _pubkeys) external { + _requestExit(_pubkeys); + } + + function harness__initiateFullWithdrawal(bytes calldata _pubkeys) external payable { + _initiateFullWithdrawal(_pubkeys); + } + + function harness__initiatePartialWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable { + _initiatePartialWithdrawal(_pubkeys, _amounts); + } + + function harness__computeDepositDataRoot( + bytes calldata _pubkey, + bytes calldata _withdrawalCredentials, + bytes calldata _signature, + uint256 _amount + ) external pure returns (bytes32) { + return _computeDepositDataRoot(_pubkey, _withdrawalCredentials, _signature, _amount); + } +} diff --git a/test/0.8.25/vaults/staking-vault/validatorsManager.test.ts b/test/0.8.25/vaults/staking-vault/validatorsManager.test.ts deleted file mode 100644 index 6b065e751..000000000 --- a/test/0.8.25/vaults/staking-vault/validatorsManager.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { expect } from "chai"; -import { ZeroAddress } from "ethers"; -import { ethers } from "hardhat"; - -import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; - -import { - DepositContract__MockForStakingVault, - EIP7002WithdrawalRequest_Mock, - StakingVault, - VaultHub__MockForStakingVault, -} from "typechain-types"; - -import { computeDepositDataRoot, de0x, ether, impersonate } from "lib"; - -import { deployStakingVaultBehindBeaconProxy } from "test/deploy"; -import { EIP7002_PREDEPLOYED_ADDRESS, Snapshot } from "test/suite"; - -const getPubkey = (index: number) => index.toString(16).padStart(4, "0").toLocaleLowerCase().repeat(24); -const getSignature = (index: number) => index.toString(16).padStart(8, "0").toLocaleLowerCase().repeat(12); - -const getPubkeys = (num: number) => `0x${Array.from({ length: num }, (_, i) => getPubkey(i + 1)).join("")}`; - -describe("ValidatorsManager.sol", () => { - let vaultOwner: HardhatEthersSigner; - let operator: HardhatEthersSigner; - let stranger: HardhatEthersSigner; - let vaultHubSigner: HardhatEthersSigner; - - let stakingVault: StakingVault; - let vaultHub: VaultHub__MockForStakingVault; - let depositContract: DepositContract__MockForStakingVault; - let withdrawalRequest: EIP7002WithdrawalRequest_Mock; - - let vaultOwnerAddress: string; - let vaultHubAddress: string; - let operatorAddress: string; - let depositContractAddress: string; - let stakingVaultAddress: string; - - let originalState: string; - - before(async () => { - [vaultOwner, operator, stranger] = await ethers.getSigners(); - ({ stakingVault, vaultHub, depositContract } = await deployStakingVaultBehindBeaconProxy(vaultOwner, operator)); - - vaultOwnerAddress = await vaultOwner.getAddress(); - vaultHubAddress = await vaultHub.getAddress(); - operatorAddress = await operator.getAddress(); - depositContractAddress = await depositContract.getAddress(); - stakingVaultAddress = await stakingVault.getAddress(); - - withdrawalRequest = await ethers.getContractAt("EIP7002WithdrawalRequest_Mock", EIP7002_PREDEPLOYED_ADDRESS); - - vaultHubSigner = await impersonate(vaultHubAddress, ether("10")); - }); - - beforeEach(async () => (originalState = await Snapshot.take())); - - afterEach(async () => await Snapshot.restore(originalState)); - - context("constructor", () => { - it("reverts if the deposit contract address is zero", async () => { - await expect(ethers.deployContract("StakingVault", [vaultHubAddress, ZeroAddress])).to.be.revertedWithCustomError( - stakingVault, - "ZeroBeaconChainDepositContract", - ); - }); - }); - - context("_getDepositContract", () => { - it("returns the deposit contract address", async () => { - expect(await stakingVault.depositContract()).to.equal(depositContractAddress); - }); - }); - - context("_withdrawalCredentials", () => { - it("returns the withdrawal credentials", async () => { - expect(await stakingVault.withdrawalCredentials()).to.equal( - ("0x01" + "00".repeat(11) + de0x(stakingVaultAddress)).toLowerCase(), - ); - }); - }); - - context("_depositToBeaconChain", () => { - it("makes deposits to the beacon chain and emits the DepositedToBeaconChain event", async () => { - const numberOfKeys = 2; // number because of Array.from - const totalAmount = ether("32") * BigInt(numberOfKeys); - const withdrawalCredentials = await stakingVault.withdrawalCredentials(); - - await stakingVault.fund({ value: totalAmount }); - - const deposits = Array.from({ length: numberOfKeys }, (_, i) => { - const pubkey = `0x${getPubkey(i + 1)}`; - const signature = `0x${getSignature(i + 1)}`; - const amount = ether("32"); - const depositDataRoot = computeDepositDataRoot(withdrawalCredentials, pubkey, signature, amount); - return { pubkey, signature, amount, depositDataRoot }; - }); - - await expect(stakingVault.connect(operator).depositToBeaconChain(deposits)) - .to.emit(stakingVault, "DepositedToBeaconChain") - .withArgs(operator, 2, totalAmount); - }); - }); - - context("_calculateTotalExitRequestFee", () => { - it("returns the total fee for given number of validator keys", async () => { - const newFee = 100n; - await withdrawalRequest.setFee(newFee); - - const fee = await stakingVault.calculateTotalExitRequestFee(1n); - expect(fee).to.equal(newFee); - - const feePerRequest = await withdrawalRequest.fee(); - expect(fee).to.equal(feePerRequest); - - const feeForMultipleKeys = await stakingVault.calculateTotalExitRequestFee(2n); - expect(feeForMultipleKeys).to.equal(newFee * 2n); - }); - }); - - context("_requestValidatorsExit", () => { - it("reverts if passed fee is less than the required fee", async () => { - const numberOfKeys = 4; - const pubkeys = getPubkeys(numberOfKeys); - const fee = await stakingVault.calculateTotalExitRequestFee(numberOfKeys - 1); - - await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys, { value: fee })) - .to.be.revertedWithCustomError(stakingVault, "InsufficientExitFee") - .withArgs(fee, numberOfKeys); - }); - - it("allows owner to request validators exit providing a fee", async () => { - const numberOfKeys = 1; - const pubkeys = getPubkeys(numberOfKeys); - const fee = await stakingVault.calculateTotalExitRequestFee(numberOfKeys); - - await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys, { value: fee })) - .to.emit(stakingVault, "ValidatorsExitRequested") - .withArgs(vaultOwnerAddress, pubkeys); - }); - - it("refunds the fee if passed fee is greater than the required fee", async () => { - const numberOfKeys = 1; - const pubkeys = getPubkeys(numberOfKeys); - const fee = await stakingVault.calculateTotalExitRequestFee(numberOfKeys); - const overpaid = 100n; - - await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys, { value: fee + overpaid })) - .to.emit(stakingVault, "ValidatorsExitRequested") - .withArgs(vaultOwnerAddress, pubkeys) - .and.to.emit(stakingVault, "ExitFeeRefunded") - .withArgs(vaultOwnerAddress, overpaid); - }); - - context.skip("vault is balanced", () => { - it("reverts if called by a non-owner or non-node operator", async () => { - const keys = getValidatorPubkey(1); - await expect(stakingVault.connect(stranger).requestValidatorsExit(keys)) - .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") - .withArgs(await stranger.getAddress()); - }); - - it("reverts if passed fee is less than the required fee", async () => { - const numberOfKeys = 4; - const pubkeys = getValidatorPubkey(numberOfKeys); - const fee = await stakingVault.calculateTotalExitRequestFee(numberOfKeys - 1); - - await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys, { value: fee })) - .to.be.revertedWithCustomError(stakingVault, "InsufficientExitFee") - .withArgs(fee, numberOfKeys); - }); - - it("allows owner to request validators exit providing a fee", async () => { - const numberOfKeys = 1; - const pubkeys = getValidatorPubkey(numberOfKeys); - const fee = await stakingVault.calculateTotalExitRequestFee(numberOfKeys); - - await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys, { value: fee })) - .to.emit(stakingVault, "ValidatorsExitRequested") - .withArgs(vaultOwnerAddress, pubkeys); - }); - - it("allows node operator to request validators exit", async () => { - const numberOfKeys = 1; - const pubkeys = getValidatorPubkey(numberOfKeys); - const fee = await stakingVault.calculateTotalExitRequestFee(numberOfKeys); - - await expect(stakingVault.connect(operator).requestValidatorsExit(pubkeys, { value: fee })) - .to.emit(stakingVault, "ValidatorsExitRequested") - .withArgs(operatorAddress, pubkeys); - }); - - it("works with multiple pubkeys", async () => { - const numberOfKeys = 2; - const pubkeys = getValidatorPubkey(numberOfKeys); - const fee = await stakingVault.calculateTotalExitRequestFee(numberOfKeys); - - await expect(stakingVault.connect(vaultOwner).requestValidatorsExit(pubkeys, { value: fee })) - .to.emit(stakingVault, "ValidatorsExitRequested") - .withArgs(vaultOwnerAddress, pubkeys); - }); - }); - - context.skip("vault is unbalanced", () => { - beforeEach(async () => { - await stakingVault.connect(vaultHubSigner).report(ether("1"), ether("0.1"), ether("1.1")); - expect(await stakingVault.isBalanced()).to.be.false; - }); - - it("reverts if timelocked", async () => { - await expect(stakingVault.requestValidatorsExit("0x")).to.be.revertedWithCustomError( - stakingVault, - "ExitTimelockNotElapsed", - ); - }); - }); - }); - - context("computeDepositDataRoot", () => { - it("computes the deposit data root", async () => { - // sample tx data: https://etherscan.io/tx/0x02980d44c119b0a8e3ca0d31c288e9f177c76fb4d7ab616563e399dd9c7c6507 - const pubkey = - "0x8d6aa059b52f6b11d07d73805d409feba07dffb6442c4ef6645f7caa4038b1047e072cba21eb766579f8286ccac630b0"; - const withdrawalCredentials = "0x010000000000000000000000b8b5da17a1b7a8ad1cf45a12e1e61d3577052d35"; - const signature = - "0xab95e358d002fd79bc08564a2db057dd5164af173915eba9e3e9da233d404c0eb0058760bc30cb89abbc55cf57f0c5a6018cdb17df73ca39ddc80a323a13c2e7ba942faa86757b26120b3a58dcce5d89e95ea1ee8fa3276ffac0f0ad9313211d"; - const amount = ether("32"); - const expectedDepositDataRoot = "0xb28f86815813d7da8132a2979836b326094a350e7aa301ba611163d4b7ca77be"; - - computeDepositDataRoot(withdrawalCredentials, pubkey, signature, amount); - - expect(await stakingVault.computeDepositDataRoot(pubkey, withdrawalCredentials, signature, amount)).to.equal( - expectedDepositDataRoot, - ); - }); - }); -}); From daf96c458bd38abdd7fe88ae860c8cf40feeecde Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Mon, 3 Feb 2025 16:11:25 +0000 Subject: [PATCH 30/70] chore: cleanup staking vault interface --- contracts/0.8.25/vaults/Permissions.sol | 12 +-- contracts/0.8.25/vaults/StakingVault.sol | 78 ++++--------------- .../vaults/interfaces/IStakingVault.sol | 9 +-- .../StakingVault__HarnessForTestUpgrade.sol | 12 +-- 4 files changed, 27 insertions(+), 84 deletions(-) diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index b450852ae..e94ff80d4 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -62,7 +62,7 @@ abstract contract Permissions is AccessControlVoteable { /** * @notice Permission for force validators exit from the StakingVault using EIP-7002 triggerable exit. */ - bytes32 public constant FORCE_VALIDATORS_EXIT_ROLE = keccak256("StakingVault.Permissions.ForceValidatorsExit"); + bytes32 public constant INITIATE_VALIDATOR_WITHDRAWAL_ROLE = keccak256("StakingVault.Permissions.InitiateValidatorWithdrawal"); /** * @notice Permission for voluntary disconnecting the StakingVault. @@ -147,15 +147,15 @@ abstract contract Permissions is AccessControlVoteable { } function _requestValidatorExit(bytes calldata _pubkey) internal onlyRole(REQUEST_VALIDATOR_EXIT_ROLE) { - stakingVault().requestValidatorsExit(_pubkey); + stakingVault().requestValidatorExit(_pubkey); } - function _forceValidatorsExit(bytes calldata _pubkeys) internal onlyRole(FORCE_VALIDATORS_EXIT_ROLE) { - stakingVault().forceValidatorsExit(_pubkeys); + function _initiateFullValidatorsWithdrawal(bytes calldata _pubkeys) internal onlyRole(INITIATE_VALIDATOR_WITHDRAWAL_ROLE) { + stakingVault().initiateFullValidatorWithdrawal(_pubkeys); } - function _forcePartialValidatorsExit(bytes calldata _pubkeys, uint64[] calldata _amounts) internal onlyRole(FORCE_VALIDATORS_EXIT_ROLE) { - stakingVault().forcePartialValidatorsExit(_pubkeys, _amounts); + function _initiatePartialValidatorsWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) internal onlyRole(INITIATE_VALIDATOR_WITHDRAWAL_ROLE) { + stakingVault().initiatePartialValidatorWithdrawal(_pubkeys, _amounts); } function _voluntaryDisconnect() internal onlyRole(VOLUNTARY_DISCONNECT_ROLE) { diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 34e221444..83686579d 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -71,9 +71,7 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad uint128 locked; int128 inOutDelta; address nodeOperator; - /// Status variables bool beaconChainDepositsPaused; - uint256 unbalancedSince; } /** @@ -96,11 +94,6 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad bytes32 private constant ERC7201_STORAGE_LOCATION = 0x2ec50241a851d8d3fea472e7057288d4603f7a7f78e6d18a9c12cad84552b100; - /** - * @notice Update constant for exit timelock duration to 3 days - */ - uint256 private constant EXIT_TIMELOCK_DURATION = 3 days; - /** * @notice Constructs the implementation of `StakingVault` * @param _vaultHub Address of `VaultHub` @@ -217,28 +210,6 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad return _getStorage().report; } - /** - * @notice Returns whether `StakingVault` is balanced, i.e. its valuation is greater than the locked amount - * @return True if `StakingVault` is balanced - * @dev Not to be confused with the ether balance of the contract (`address(this).balance`). - * Semantically, this state has nothing to do with the actual balance of the contract, - * althogh, of course, the balance of the contract is accounted for in its valuation. - * The `isBalanced()` state indicates whether `StakingVault` is in a good shape - * in terms of the balance of its valuation against the locked amount. - */ - function isBalanced() public view returns (bool) { - return valuation() >= _getStorage().locked; - } - - /** - * @notice Returns the timestamp when `StakingVault` became unbalanced - * @return Timestamp when `StakingVault` became unbalanced - * @dev If `StakingVault` is balanced, returns 0 - */ - function unbalancedSince() external view returns (uint256) { - return _getStorage().unbalancedSince; - } - /** * @notice Returns the address of the node operator * Node operator is the party responsible for managing the validators. @@ -269,10 +240,6 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad ERC7201Storage storage $ = _getStorage(); $.inOutDelta += int128(int256(msg.value)); - if (isBalanced()) { - $.unbalancedSince = 0; - } - emit Funded(msg.sender, msg.value); } @@ -282,8 +249,8 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad * @param _ether Amount of ether to withdraw. * @dev Cannot withdraw more than the unlocked amount or the balance of the contract, whichever is less. * @dev Updates inOutDelta to track the net difference between funded and withdrawn ether - * @dev Includes the `isBalanced()` check to ensure `StakingVault` remains balanced after the withdrawal, - * to safeguard against possible reentrancy attacks. + * @dev Includes a check that valuation remains greater than locked amount after withdrawal to ensure + * `StakingVault` stays balanced and prevent reentrancy attacks. */ function withdraw(address _recipient, uint256 _ether) external onlyOwner { if (_recipient == address(0)) revert ZeroArgument("_recipient"); @@ -297,7 +264,8 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad (bool success, ) = _recipient.call{value: _ether}(""); if (!success) revert TransferFailed(_recipient, _ether); - if (!isBalanced()) revert Unbalanced(); + + if (valuation() < $.locked) revert Unbalanced(); emit Withdrawn(msg.sender, _recipient, _ether); } @@ -315,10 +283,6 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad $.locked = uint128(_locked); - if (!isBalanced()) { - $.unbalancedSince = block.timestamp; - } - emit LockedIncreased(_locked); } @@ -334,8 +298,9 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad uint256 _valuation = valuation(); if (_ether > _valuation) revert RebalanceAmountExceedsValuation(_valuation, _ether); - if (owner() == msg.sender || (!isBalanced() && msg.sender == address(VAULT_HUB))) { - ERC7201Storage storage $ = _getStorage(); + ERC7201Storage storage $ = _getStorage(); + if (owner() == msg.sender || (_valuation < $.locked && msg.sender == address(VAULT_HUB))) { + $.inOutDelta -= int128(int256(_ether)); emit Withdrawn(msg.sender, address(VAULT_HUB), _ether); @@ -361,12 +326,6 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad $.report.inOutDelta = int128(_inOutDelta); $.locked = uint128(_locked); - if (isBalanced()) { - $.unbalancedSince = 0; - } else { - $.unbalancedSince = block.timestamp; - } - emit Reported(_valuation, _inOutDelta, _locked); } @@ -441,7 +400,7 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad if (msg.sender != $.nodeOperator) revert NotAuthorized("depositToBeaconChain", msg.sender); if ($.beaconChainDepositsPaused) revert BeaconChainDepositsArePaused(); - if (!isBalanced()) revert Unbalanced(); + if (valuation() < $.locked) revert Unbalanced(); _deposit(_deposits); } @@ -451,7 +410,7 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad * @param _numberOfKeys Number of validator keys * @return Total fee amount */ - function calculateTotalExitRequestFee(uint256 _numberOfKeys) external view returns (uint256) { + function calculateValidatorWithdrawalFee(uint256 _numberOfKeys) external view returns (uint256) { if (_numberOfKeys == 0) revert ZeroArgument("_numberOfKeys"); return _calculateWithdrawalFee(_numberOfKeys); @@ -462,7 +421,7 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad * @param _pubkeys Concatenated validator public keys * @dev Signals the node operator to eject the specified validators from the beacon chain */ - function requestValidatorsExit(bytes calldata _pubkeys) external onlyOwner { + function requestValidatorExit(bytes calldata _pubkeys) external onlyOwner { _requestExit(_pubkeys); } @@ -471,18 +430,8 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad * @param _pubkeys Concatenated validators public keys * @dev Signals the node operator to eject the specified validators from the beacon chain */ - function forceValidatorsExit(bytes calldata _pubkeys) external payable { - // Only owner or node operator can exit validators when vault is balanced - if (isBalanced()) { - _onlyOwnerOrNodeOperator(); - } - - // Ensure timelock period has elapsed - uint256 exitTimelock = _getStorage().unbalancedSince + EXIT_TIMELOCK_DURATION; - if (block.timestamp < exitTimelock) { - revert ExitTimelockNotElapsed(exitTimelock); - } - + function initiateFullValidatorWithdrawal(bytes calldata _pubkeys) external payable { + _onlyOwnerOrNodeOperator(); _initiateFullWithdrawal(_pubkeys); } @@ -492,9 +441,8 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad * @param _amounts Amounts of ether to exit * @dev Signals the node operator to eject the specified validators from the beacon chain */ - function forcePartialValidatorsExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable { + function initiatePartialValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable { _onlyOwnerOrNodeOperator(); - _initiatePartialWithdrawal(_pubkeys, _amounts); } diff --git a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol index 134afeddd..67f44b714 100644 --- a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol +++ b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol @@ -35,7 +35,6 @@ interface IStakingVault { function nodeOperator() external view returns (address); function locked() external view returns (uint256); function valuation() external view returns (uint256); - function isBalanced() external view returns (bool); function unlocked() external view returns (uint256); function inOutDelta() external view returns (int256); @@ -54,9 +53,9 @@ interface IStakingVault { function resumeBeaconChainDeposits() external; function depositToBeaconChain(Deposit[] calldata _deposits) external; - function requestValidatorsExit(bytes calldata _pubkeys) external; + function requestValidatorExit(bytes calldata _pubkeys) external; - function calculateTotalExitRequestFee(uint256 _validatorCount) external view returns (uint256); - function forceValidatorsExit(bytes calldata _pubkeys) external payable; - function forcePartialValidatorsExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable; + function calculateValidatorWithdrawalFee(uint256 _validatorCount) external view returns (uint256); + function initiateFullValidatorWithdrawal(bytes calldata _pubkeys) external payable; + function initiatePartialValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable; } diff --git a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol index 71111c163..8c38a0c73 100644 --- a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol +++ b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol @@ -90,10 +90,6 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl return -1; } - function isBalanced() external pure returns (bool) { - return true; - } - function nodeOperator() external view returns (address) { return _getVaultStorage().nodeOperator; } @@ -126,16 +122,16 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl return false; } - function calculateTotalExitRequestFee(uint256) external pure returns (uint256) { + function calculateValidatorWithdrawalFee(uint256) external pure returns (uint256) { return 1; } function pauseBeaconChainDeposits() external {} function resumeBeaconChainDeposits() external {} - function requestValidatorsExit(bytes calldata _pubkeys) external {} - function forceValidatorsExit(bytes calldata _pubkeys) external payable {} - function forcePartialValidatorsExit(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable {} + function requestValidatorExit(bytes calldata _pubkeys) external {} + function initiateFullValidatorWithdrawal(bytes calldata _pubkeys) external payable {} + function initiatePartialValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable {} error ZeroArgument(string name); error VaultAlreadyInitialized(); From fde279bae7c7be4ade1deff909451d459101278b Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Mon, 3 Feb 2025 18:50:03 +0000 Subject: [PATCH 31/70] test: update staking vault tests --- .../vaults/staking-vault/stakingVault.test.ts | 314 ++++++++++-------- 1 file changed, 168 insertions(+), 146 deletions(-) diff --git a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts index 1e38f8b3b..16cad9d35 100644 --- a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts +++ b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts @@ -88,131 +88,43 @@ describe("StakingVault.sol", () => { }); }); - context("initial state", () => { + context("initial state (getters)", () => { it("returns the correct initial state and constants", async () => { - expect(await stakingVault.version()).to.equal(1n); + expect(await stakingVault.owner()).to.equal(await vaultOwner.getAddress()); + expect(await stakingVault.getInitializedVersion()).to.equal(1n); + expect(await stakingVault.version()).to.equal(1n); expect(await stakingVault.vaultHub()).to.equal(vaultHubAddress); - expect(await stakingVault.depositContract()).to.equal(depositContractAddress); - expect(await stakingVault.owner()).to.equal(await vaultOwner.getAddress()); - expect(await stakingVault.nodeOperator()).to.equal(operator); + expect(await stakingVault.valuation()).to.equal(0n); expect(await stakingVault.locked()).to.equal(0n); expect(await stakingVault.unlocked()).to.equal(0n); expect(await stakingVault.inOutDelta()).to.equal(0n); + expect(await stakingVault.latestReport()).to.deep.equal([0n, 0n]); + expect(await stakingVault.nodeOperator()).to.equal(operator); + + expect(await stakingVault.depositContract()).to.equal(depositContractAddress); expect((await stakingVault.withdrawalCredentials()).toLowerCase()).to.equal( - ("0x01" + "00".repeat(11) + de0x(stakingVaultAddress)).toLowerCase(), + ("0x02" + "00".repeat(11) + de0x(stakingVaultAddress)).toLowerCase(), ); - expect(await stakingVault.valuation()).to.equal(0n); - expect(await stakingVault.isBalanced()).to.be.true; expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; }); }); - context("pauseBeaconChainDeposits", () => { - it("reverts if called by a non-owner", async () => { - await expect(stakingVault.connect(stranger).pauseBeaconChainDeposits()) - .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") - .withArgs(await stranger.getAddress()); - }); - - it("reverts if the beacon deposits are already paused", async () => { - await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); - - await expect(stakingVault.connect(vaultOwner).pauseBeaconChainDeposits()).to.be.revertedWithCustomError( - stakingVault, - "BeaconChainDepositsResumeExpected", - ); - }); - - it("allows to pause deposits", async () => { - await expect(stakingVault.connect(vaultOwner).pauseBeaconChainDeposits()).to.emit( - stakingVault, - "BeaconChainDepositsPaused", - ); - expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; - }); - }); - - context("resumeBeaconChainDeposits", () => { - it("reverts if called by a non-owner", async () => { - await expect(stakingVault.connect(stranger).resumeBeaconChainDeposits()) - .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") - .withArgs(await stranger.getAddress()); - }); - - it("reverts if the beacon deposits are already resumed", async () => { - await expect(stakingVault.connect(vaultOwner).resumeBeaconChainDeposits()).to.be.revertedWithCustomError( - stakingVault, - "BeaconChainDepositsPauseExpected", - ); - }); - - it("allows to resume deposits", async () => { - await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); + context("valuation", () => { + it("returns the correct valuation", async () => { + expect(await stakingVault.valuation()).to.equal(0n); - await expect(stakingVault.connect(vaultOwner).resumeBeaconChainDeposits()).to.emit( - stakingVault, - "BeaconChainDepositsResumed", - ); - expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + await stakingVault.fund({ value: ether("1") }); + expect(await stakingVault.valuation()).to.equal(ether("1")); }); }); - context("depositToBeaconChain", () => { - it("reverts if called by a non-operator", async () => { - await expect( - stakingVault - .connect(stranger) - .depositToBeaconChain([ - { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, - ]), - ) - .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") - .withArgs("depositToBeaconChain", stranger); - }); - - it("reverts if the number of deposits is zero", async () => { - await expect(stakingVault.depositToBeaconChain([])) - .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") - .withArgs("_deposits"); - }); + context("locked", () => { + it("returns the correct locked balance", async () => { + expect(await stakingVault.locked()).to.equal(0n); - it("reverts if the vault is not balanced", async () => { await stakingVault.connect(vaultHubSigner).lock(ether("1")); - await expect( - stakingVault - .connect(operator) - .depositToBeaconChain([ - { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, - ]), - ).to.be.revertedWithCustomError(stakingVault, "Unbalanced"); - }); - - it("reverts if the deposits are paused", async () => { - await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); - await expect( - stakingVault - .connect(operator) - .depositToBeaconChain([ - { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, - ]), - ).to.be.revertedWithCustomError(stakingVault, "BeaconChainDepositsArePaused"); - }); - - it("makes deposits to the beacon chain and emits the DepositedToBeaconChain event", async () => { - await stakingVault.fund({ value: ether("32") }); - - const pubkey = "0x" + "ab".repeat(48); - const signature = "0x" + "ef".repeat(96); - const amount = ether("32"); - const withdrawalCredentials = await stakingVault.withdrawalCredentials(); - const depositDataRoot = computeDepositDataRoot(withdrawalCredentials, pubkey, signature, amount); - - await expect( - stakingVault.connect(operator).depositToBeaconChain([{ pubkey, signature, amount, depositDataRoot }]), - ) - .to.emit(stakingVault, "DepositedToBeaconChain") - .withArgs(operator, 1, amount); + expect(await stakingVault.locked()).to.equal(ether("1")); }); }); @@ -230,6 +142,18 @@ describe("StakingVault.sol", () => { }); }); + context("inOutDelta", () => { + it("returns the correct inOutDelta", async () => { + expect(await stakingVault.inOutDelta()).to.equal(0n); + + await stakingVault.fund({ value: ether("1") }); + expect(await stakingVault.inOutDelta()).to.equal(ether("1")); + + await stakingVault.withdraw(vaultOwnerAddress, ether("1")); + expect(await stakingVault.inOutDelta()).to.equal(0n); + }); + }); + context("latestReport", () => { it("returns zeros initially", async () => { expect(await stakingVault.latestReport()).to.deep.equal([0n, 0n]); @@ -241,27 +165,9 @@ describe("StakingVault.sol", () => { }); }); - context("isBalanced", () => { - it("returns true if valuation is greater than or equal to locked", async () => { - await stakingVault.connect(vaultHubSigner).report(ether("1"), ether("2"), ether("0")); - expect(await stakingVault.isBalanced()).to.be.true; - }); - - it("returns false if valuation is less than locked", async () => { - await stakingVault.connect(vaultHubSigner).lock(ether("1")); - expect(await stakingVault.isBalanced()).to.be.false; - }); - }); - - context("unbalancedSince", () => { - it("returns the timestamp when the vault became unbalanced", async () => { - await stakingVault.connect(vaultHubSigner).lock(ether("1")); - expect(await stakingVault.unbalancedSince()).to.be.greaterThan(0n); - }); - - it("returns 0 if the vault is balanced", async () => { - await stakingVault.connect(vaultHubSigner).report(ether("1"), ether("2"), ether("0")); - expect(await stakingVault.unbalancedSince()).to.equal(0n); + context("nodeOperator", () => { + it("returns the correct node operator", async () => { + expect(await stakingVault.nodeOperator()).to.equal(operator); }); }); @@ -313,10 +219,10 @@ describe("StakingVault.sol", () => { it("restores the vault to a balanced state if the vault was unbalanced", async () => { await stakingVault.connect(vaultHubSigner).lock(ether("1")); - expect(await stakingVault.isBalanced()).to.be.false; + expect(await stakingVault.valuation()).to.be.lessThan(await stakingVault.locked()); await stakingVault.fund({ value: ether("1") }); - expect(await stakingVault.isBalanced()).to.be.true; + expect(await stakingVault.valuation()).to.be.greaterThanOrEqual(await stakingVault.locked()); }); }); @@ -359,6 +265,8 @@ describe("StakingVault.sol", () => { .withArgs(unlocked); }); + it.skip("reverts is vault is unbalanced", async () => {}); + it("does not revert on max int128", async () => { const forGas = ether("10"); const bigBalance = MAX_INT128 + forGas; @@ -442,13 +350,6 @@ describe("StakingVault.sol", () => { .to.emit(stakingVault, "LockedIncreased") .withArgs(MAX_UINT128); }); - - it("updates unbalancedSince if the vault becomes unbalanced", async () => { - expect(await stakingVault.unbalancedSince()).to.equal(0n); - - await stakingVault.connect(vaultHubSigner).lock(ether("1")); - expect(await stakingVault.unbalancedSince()).to.be.greaterThan(0n); - }); }); context("rebalance", () => { @@ -497,7 +398,7 @@ describe("StakingVault.sol", () => { it("can be called by the vault hub when the vault is unbalanced", async () => { await stakingVault.connect(vaultHubSigner).report(ether("1"), ether("0.1"), ether("1.1")); - expect(await stakingVault.isBalanced()).to.be.false; + expect(await stakingVault.valuation()).to.be.lessThan(await stakingVault.locked()); expect(await stakingVault.inOutDelta()).to.equal(ether("0")); await elRewardsSender.sendTransaction({ to: stakingVaultAddress, value: ether("0.1") }); @@ -525,17 +426,138 @@ describe("StakingVault.sol", () => { expect(await stakingVault.latestReport()).to.deep.equal([ether("1"), ether("2")]); expect(await stakingVault.locked()).to.equal(ether("3")); }); + }); - it("updates unbalancedSince if the vault becomes unbalanced", async () => { - expect(await stakingVault.unbalancedSince()).to.equal(0n); + context("depositContract", () => { + it("returns the correct deposit contract address", async () => { + expect(await stakingVault.depositContract()).to.equal(depositContractAddress); + }); + }); - // Unbalanced report - await stakingVault.connect(vaultHubSigner).report(ether("1"), ether("0.1"), ether("1.1")); - expect(await stakingVault.unbalancedSince()).to.be.greaterThan(0n); + context("withdrawalCredentials", () => { + it("returns the correct withdrawal credentials in 0x02 format", async () => { + const withdrawalCredentials = ("0x02" + "00".repeat(11) + de0x(stakingVaultAddress)).toLowerCase(); + expect(await stakingVault.withdrawalCredentials()).to.equal(withdrawalCredentials); + }); + }); + + context("beaconChainDepositsPaused", () => { + it("returns the correct beacon chain deposits paused status", async () => { + expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + + await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + + await stakingVault.connect(vaultOwner).resumeBeaconChainDeposits(); + expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + }); + }); + + context("pauseBeaconChainDeposits", () => { + it("reverts if called by a non-owner", async () => { + await expect(stakingVault.connect(stranger).pauseBeaconChainDeposits()) + .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") + .withArgs(await stranger.getAddress()); + }); + + it("reverts if the beacon deposits are already paused", async () => { + await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); + + await expect(stakingVault.connect(vaultOwner).pauseBeaconChainDeposits()).to.be.revertedWithCustomError( + stakingVault, + "BeaconChainDepositsResumeExpected", + ); + }); + + it("allows to pause deposits", async () => { + await expect(stakingVault.connect(vaultOwner).pauseBeaconChainDeposits()).to.emit( + stakingVault, + "BeaconChainDepositsPaused", + ); + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + }); + }); + + context("resumeBeaconChainDeposits", () => { + it("reverts if called by a non-owner", async () => { + await expect(stakingVault.connect(stranger).resumeBeaconChainDeposits()) + .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") + .withArgs(await stranger.getAddress()); + }); + + it("reverts if the beacon deposits are already resumed", async () => { + await expect(stakingVault.connect(vaultOwner).resumeBeaconChainDeposits()).to.be.revertedWithCustomError( + stakingVault, + "BeaconChainDepositsPauseExpected", + ); + }); - // Rebalanced report - await stakingVault.connect(vaultHubSigner).report(ether("3"), ether("2"), ether("1")); - expect(await stakingVault.unbalancedSince()).to.equal(0n); + it("allows to resume deposits", async () => { + await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); + + await expect(stakingVault.connect(vaultOwner).resumeBeaconChainDeposits()).to.emit( + stakingVault, + "BeaconChainDepositsResumed", + ); + expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + }); + }); + + context("depositToBeaconChain", () => { + it("reverts if called by a non-operator", async () => { + await expect( + stakingVault + .connect(stranger) + .depositToBeaconChain([ + { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, + ]), + ) + .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") + .withArgs("depositToBeaconChain", stranger); + }); + + it("reverts if the number of deposits is zero", async () => { + await expect(stakingVault.depositToBeaconChain([])) + .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") + .withArgs("_deposits"); + }); + + it("reverts if the vault is not balanced", async () => { + await stakingVault.connect(vaultHubSigner).lock(ether("1")); + await expect( + stakingVault + .connect(operator) + .depositToBeaconChain([ + { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, + ]), + ).to.be.revertedWithCustomError(stakingVault, "Unbalanced"); + }); + + it("reverts if the deposits are paused", async () => { + await stakingVault.connect(vaultOwner).pauseBeaconChainDeposits(); + await expect( + stakingVault + .connect(operator) + .depositToBeaconChain([ + { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, + ]), + ).to.be.revertedWithCustomError(stakingVault, "BeaconChainDepositsArePaused"); + }); + + it("makes deposits to the beacon chain and emits the DepositedToBeaconChain event", async () => { + await stakingVault.fund({ value: ether("32") }); + + const pubkey = "0x" + "ab".repeat(48); + const signature = "0x" + "ef".repeat(96); + const amount = ether("32"); + const withdrawalCredentials = await stakingVault.withdrawalCredentials(); + const depositDataRoot = computeDepositDataRoot(withdrawalCredentials, pubkey, signature, amount); + + await expect( + stakingVault.connect(operator).depositToBeaconChain([{ pubkey, signature, amount, depositDataRoot }]), + ) + .to.emit(stakingVault, "Deposited") + .withArgs(operator, 1, amount); }); }); }); From 7f18488228befe7cacdc6727505c5a2a2a94980c Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Mon, 3 Feb 2025 21:17:09 +0000 Subject: [PATCH 32/70] fix: tests --- test/0.8.25/vaults/dashboard/dashboard.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 779a802bb..a1f3ff32e 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -616,7 +616,7 @@ describe("Dashboard.sol", () => { it("requests the exit of a validator", async () => { const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); await expect(dashboard.requestValidatorsExit(validatorPublicKeys)) - .to.emit(vault, "ValidatorsExitRequest") + .to.emit(vault, "ExitRequested") .withArgs(dashboard, validatorPublicKeys); }); }); From 1af1d3a24170acad301fc53c4d328f9229b13f1e Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Tue, 4 Feb 2025 14:38:57 +0100 Subject: [PATCH 33/70] 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 34/70] 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, From 1b11c66efee23b5440ef4c7a24057273fcc2b4c0 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 4 Feb 2025 13:46:24 +0000 Subject: [PATCH 35/70] feat: add initiate withdrawal functions --- .../vaults/BeaconValidatorController.sol | 4 +- contracts/0.8.25/vaults/Dashboard.sol | 2 +- contracts/0.8.25/vaults/StakingVault.sol | 69 +++++---- .../0.8.25/vaults/dashboard/dashboard.test.ts | 11 +- .../beaconValidatorController.test.ts | 6 +- .../vaults/staking-vault/stakingVault.test.ts | 142 +++++++++++++++++- .../vaults-happy-path.integration.ts | 2 +- 7 files changed, 193 insertions(+), 43 deletions(-) diff --git a/contracts/0.8.25/vaults/BeaconValidatorController.sol b/contracts/0.8.25/vaults/BeaconValidatorController.sol index 5d40d895e..2f9bdd741 100644 --- a/contracts/0.8.25/vaults/BeaconValidatorController.sol +++ b/contracts/0.8.25/vaults/BeaconValidatorController.sol @@ -76,7 +76,7 @@ abstract contract BeaconValidatorController { TriggerableWithdrawals.addFullWithdrawalRequests(_pubkeys, feePerRequest); - emit WithdrawalInitiated(msg.sender, _pubkeys); + emit FullWithdrawalInitiated(msg.sender, _pubkeys); _refundExcessFee(totalFee); } @@ -194,7 +194,7 @@ abstract contract BeaconValidatorController { * @param _sender Address that requested the validator withdrawal. * @param _pubkeys Public key of the validator requested to withdraw. */ - event WithdrawalInitiated(address indexed _sender, bytes _pubkeys); + event FullWithdrawalInitiated(address indexed _sender, bytes _pubkeys); /** * @notice Emitted when a validator partial withdrawal request is forced via EIP-7002. diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index c07f81c37..e65876c37 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -459,7 +459,7 @@ contract Dashboard is Permissions { * @param _validatorPublicKeys The public keys of the validators to request exit for. * @dev This only emits an event requesting the exit, it does not actually initiate the exit. */ - function requestValidatorsExit(bytes calldata _validatorPublicKeys) external { + function requestValidatorExit(bytes calldata _validatorPublicKeys) external { _requestValidatorExit(_validatorPublicKeys); } diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 83686579d..f6d06203d 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -35,19 +35,19 @@ import {IStakingVault} from "./interfaces/IStakingVault.sol"; * - `rebalance()` * - `pauseBeaconChainDeposits()` * - `resumeBeaconChainDeposits()` - * - `requestValidatorsExit()` - * - `requestValidatorsPartialExit()` + * - `requestValidatorExit()` + * - `initiateFullValidatorWithdrawal()` + * - `initiatePartialValidatorWithdrawal()` * - Operator: * - `depositToBeaconChain()` - * - `requestValidatorsExit()` - * - `requestValidatorsPartialExit()` + * - `initiateFullValidatorWithdrawal()` + * - `initiatePartialValidatorWithdrawal()` * - VaultHub: * - `lock()` * - `report()` * - `rebalance()` * - Anyone: * - Can send ETH directly to the vault (treated as rewards) - * - `requestValidatorsExit()` if the vault is unbalanced for more than EXIT_TIMELOCK_DURATION days * * BeaconProxy * The contract is designed as a beacon proxy implementation, allowing all StakingVault instances @@ -214,7 +214,7 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad * @notice Returns the address of the node operator * Node operator is the party responsible for managing the validators. * In the context of this contract, the node operator performs deposits to the beacon chain - * and processes validator exit requests submitted by `owner` through `requestValidatorsExit()`. + * and processes validator exit requests submitted by `owner` through `requestValidatorExit()`. * Node operator address is set in the initialization and can never be changed. * @return Address of the node operator */ @@ -334,33 +334,33 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad // * * * * * * * * * * * * * * * * * * * * * // /** - * @notice Returns the address of `BeaconChainDepositContract` - * @return Address of `BeaconChainDepositContract` + * @notice Returns the address of `BeaconChainDepositContract`. + * @return Address of `BeaconChainDepositContract`. */ function depositContract() external view returns (address) { return _depositContract(); } /** - * @notice Returns the 0x02-type withdrawal credentials for the validators deposited from this `StakingVault` + * @notice Returns the 0x02-type withdrawal credentials for the validators deposited from this `StakingVault`. * All CL rewards are sent to this contract. Only 0x02-type withdrawal credentials are supported for now. - * @return Withdrawal credentials as bytes32 + * @return Withdrawal credentials as bytes32. */ function withdrawalCredentials() external view returns (bytes32) { return _withdrawalCredentials(); } /** - * @notice Returns whether deposits are paused by the vault owner - * @return True if deposits are paused + * @notice Returns whether deposits are paused by the vault owner. + * @return True if deposits are paused. */ function beaconChainDepositsPaused() external view returns (bool) { return _getStorage().beaconChainDepositsPaused; } /** - * @notice Pauses deposits to beacon chain - * @dev Can only be called by the vault owner + * @notice Pauses deposits to beacon chain. + * @dev Can only be called by the vault owner. */ function pauseBeaconChainDeposits() external onlyOwner { ERC7201Storage storage $ = _getStorage(); @@ -374,8 +374,8 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad } /** - * @notice Resumes deposits to beacon chain - * @dev Can only be called by the vault owner + * @notice Resumes deposits to beacon chain. + * @dev Can only be called by the vault owner. */ function resumeBeaconChainDeposits() external onlyOwner { ERC7201Storage storage $ = _getStorage(); @@ -389,9 +389,9 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad } /** - * @notice Performs a deposit to the beacon chain deposit contract - * @param _deposits Array of deposit structs - * @dev Includes a check to ensure StakingVault is balanced before making deposits + * @notice Performs a deposit to the beacon chain deposit contract. + * @param _deposits Array of deposit structs. + * @dev Includes a check to ensure StakingVault is balanced before making deposits. */ function depositToBeaconChain(Deposit[] calldata _deposits) external { if (_deposits.length == 0) revert ZeroArgument("_deposits"); @@ -406,7 +406,7 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad } /** - * @notice Returns total fee required for given number of validator keys + * @notice Returns total withdrawal fee required for given number of validator keys. * @param _numberOfKeys Number of validator keys * @return Total fee amount */ @@ -417,31 +417,40 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad } /** - * @notice Requests validator exit from the beacon chain - * @param _pubkeys Concatenated validator public keys - * @dev Signals the node operator to eject the specified validators from the beacon chain + * @notice Requests validator exit from the beacon chain. + * @param _pubkeys Concatenated validator public keys. + * @dev Signals the node operator to eject the specified validators from the beacon chain. */ function requestValidatorExit(bytes calldata _pubkeys) external onlyOwner { + if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); + _requestExit(_pubkeys); } /** - * @notice Requests validators exit from the beacon chain - * @param _pubkeys Concatenated validators public keys - * @dev Signals the node operator to eject the specified validators from the beacon chain + * @notice Initiates a full validator withdrawal from the beacon chain following EIP-7002. + * @param _pubkeys Concatenated validators public keys. + * @dev Keys are expected to be 48 bytes long tightly packed without paddings. + * Only allowed to be called by the owner or the node operator. */ function initiateFullValidatorWithdrawal(bytes calldata _pubkeys) external payable { + if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); + _onlyOwnerOrNodeOperator(); _initiateFullWithdrawal(_pubkeys); } /** - * @notice Requests partial exit of validators from the beacon chain - * @param _pubkeys Concatenated validators public keys - * @param _amounts Amounts of ether to exit - * @dev Signals the node operator to eject the specified validators from the beacon chain + * @notice Initiates a partial validator withdrawal from the beacon chain following EIP-7002. + * @param _pubkeys Concatenated validators public keys. + * @param _amounts Amounts of ether to exit. + * @dev Keys are expected to be 48 bytes long tightly packed without paddings. + * Only allowed to be called by the owner or the node operator. */ function initiatePartialValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable { + if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); + if (_amounts.length == 0) revert ZeroArgument("_amounts"); + _onlyOwnerOrNodeOperator(); _initiatePartialWithdrawal(_pubkeys, _amounts); } diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index a1f3ff32e..fdc2fd074 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -605,17 +605,18 @@ describe("Dashboard.sol", () => { }); }); - context("requestValidatorsExit", () => { + context("requestValidatorExit", () => { it("reverts if called by a non-admin", async () => { const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); - await expect( - dashboard.connect(stranger).requestValidatorsExit(validatorPublicKeys), - ).to.be.revertedWithCustomError(dashboard, "AccessControlUnauthorizedAccount"); + await expect(dashboard.connect(stranger).requestValidatorExit(validatorPublicKeys)).to.be.revertedWithCustomError( + dashboard, + "AccessControlUnauthorizedAccount", + ); }); it("requests the exit of a validator", async () => { const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); - await expect(dashboard.requestValidatorsExit(validatorPublicKeys)) + await expect(dashboard.requestValidatorExit(validatorPublicKeys)) .to.emit(vault, "ExitRequested") .withArgs(dashboard, validatorPublicKeys); }); diff --git a/test/0.8.25/vaults/staking-vault/beaconValidatorController.test.ts b/test/0.8.25/vaults/staking-vault/beaconValidatorController.test.ts index 2d8afa051..84997336e 100644 --- a/test/0.8.25/vaults/staking-vault/beaconValidatorController.test.ts +++ b/test/0.8.25/vaults/staking-vault/beaconValidatorController.test.ts @@ -123,7 +123,7 @@ describe("BeaconValidatorController.sol", () => { }); }); - context("_initiateFullWithdrawal", () => { + context("_initiateWithdrawal", () => { it("reverts if passed fee is less than the required fee", async () => { const numberOfKeys = 4; const pubkeys = getPubkeys(numberOfKeys); @@ -156,7 +156,7 @@ describe("BeaconValidatorController.sol", () => { const fee = await controller.harness__calculateWithdrawalFee(numberOfKeys); await expect(controller.connect(owner).harness__initiateFullWithdrawal(pubkeys, { value: fee })) - .to.emit(controller, "WithdrawalInitiated") + .to.emit(controller, "FullWithdrawalInitiated") .withArgs(owner, pubkeys); }); @@ -167,7 +167,7 @@ describe("BeaconValidatorController.sol", () => { const overpaid = 100n; await expect(controller.connect(owner).harness__initiateFullWithdrawal(pubkeys, { value: fee + overpaid })) - .to.emit(controller, "WithdrawalInitiated") + .to.emit(controller, "FullWithdrawalInitiated") .withArgs(owner, pubkeys) .and.to.emit(controller, "FeeRefunded") .withArgs(owner, overpaid); diff --git a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts index 16cad9d35..9fcb7ef0a 100644 --- a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts +++ b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts @@ -7,6 +7,7 @@ import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; import { DepositContract__MockForStakingVault, + EIP7002WithdrawalRequest_Mock, EthRejector, StakingVault, VaultHub__MockForStakingVault, @@ -14,12 +15,14 @@ import { import { computeDepositDataRoot, de0x, ether, impersonate, streccak } from "lib"; -import { deployStakingVaultBehindBeaconProxy } from "test/deploy"; +import { deployStakingVaultBehindBeaconProxy, deployWithdrawalsPreDeployedMock } from "test/deploy"; import { Snapshot } from "test/suite"; const MAX_INT128 = 2n ** 127n - 1n; const MAX_UINT128 = 2n ** 128n - 1n; +const SAMPLE_PUBKEY = "0x" + "ab".repeat(48); + // @TODO: test reentrancy attacks describe("StakingVault.sol", () => { let vaultOwner: HardhatEthersSigner; @@ -32,6 +35,7 @@ describe("StakingVault.sol", () => { let stakingVaultImplementation: StakingVault; let depositContract: DepositContract__MockForStakingVault; let vaultHub: VaultHub__MockForStakingVault; + let withdrawalRequest: EIP7002WithdrawalRequest_Mock; let ethRejector: EthRejector; let vaultOwnerAddress: string; @@ -46,6 +50,7 @@ describe("StakingVault.sol", () => { ({ stakingVault, vaultHub, stakingVaultImplementation, depositContract } = await deployStakingVaultBehindBeaconProxy(vaultOwner, operator)); + withdrawalRequest = await deployWithdrawalsPreDeployedMock(1n); ethRejector = await ethers.deployContract("EthRejector"); vaultOwnerAddress = await vaultOwner.getAddress(); @@ -560,4 +565,139 @@ describe("StakingVault.sol", () => { .withArgs(operator, 1, amount); }); }); + + context("calculateValidatorWithdrawalFee", () => { + it("reverts if the number of validators is zero", async () => { + await expect(stakingVault.calculateValidatorWithdrawalFee(0)) + .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") + .withArgs("_numberOfKeys"); + }); + + it("returns the correct withdrawal fee", async () => { + await withdrawalRequest.setFee(100n); + expect(await stakingVault.calculateValidatorWithdrawalFee(1)).to.equal(100n); + }); + }); + + context("requestValidatorExit", () => { + it("reverts if called by a non-owner", async () => { + await expect(stakingVault.connect(stranger).requestValidatorExit("0x")) + .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") + .withArgs(stranger); + }); + + it("reverts if the number of validators is zero", async () => { + await expect(stakingVault.connect(vaultOwner).requestValidatorExit("0x")) + .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") + .withArgs("_pubkeys"); + }); + + it("emits the `ExitRequested` event", async () => { + await expect(stakingVault.connect(vaultOwner).requestValidatorExit(SAMPLE_PUBKEY)) + .to.emit(stakingVault, "ExitRequested") + .withArgs(vaultOwner, SAMPLE_PUBKEY); + }); + }); + + context("initiateFullValidatorWithdrawal", () => { + it("reverts if the number of validators is zero", async () => { + await expect(stakingVault.connect(vaultOwner).initiateFullValidatorWithdrawal("0x")) + .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") + .withArgs("_pubkeys"); + }); + + it("reverts if called by a non-owner", async () => { + await expect(stakingVault.connect(stranger).initiateFullValidatorWithdrawal(SAMPLE_PUBKEY)) + .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") + .withArgs(stranger); + }); + + it("makes a full validator withdrawal when called by the owner", async () => { + await expect( + stakingVault.connect(vaultOwner).initiateFullValidatorWithdrawal(SAMPLE_PUBKEY, { value: ether("32") }), + ) + .to.emit(stakingVault, "FullWithdrawalInitiated") + .withArgs(vaultOwner, SAMPLE_PUBKEY); + }); + + it("makes a full validator withdrawal when called by the node operator", async () => { + const fee = await withdrawalRequest.fee(); + const amount = ether("32"); + + await expect(stakingVault.connect(operator).initiateFullValidatorWithdrawal(SAMPLE_PUBKEY, { value: amount })) + .and.to.emit(stakingVault, "FullWithdrawalInitiated") + .withArgs(operator, SAMPLE_PUBKEY) + .and.to.emit(stakingVault, "FeeRefunded") + .withArgs(operator, amount - fee); + }); + }); + + context("initiatePartialValidatorWithdrawal", () => { + it("reverts if the number of validators is zero", async () => { + await expect(stakingVault.connect(vaultOwner).initiatePartialValidatorWithdrawal("0x", [ether("16")])) + .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") + .withArgs("_pubkeys"); + }); + + it("reverts if the number of amounts is zero", async () => { + await expect(stakingVault.connect(vaultOwner).initiatePartialValidatorWithdrawal(SAMPLE_PUBKEY, [])) + .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") + .withArgs("_amounts"); + }); + + it("reverts if called by a non-owner", async () => { + await expect(stakingVault.connect(stranger).initiatePartialValidatorWithdrawal(SAMPLE_PUBKEY, [ether("16")])) + .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") + .withArgs(stranger); + }); + + it("makes a partial validator withdrawal when called by the owner", async () => { + const amount = ether("32"); + const fee = await withdrawalRequest.fee(); + + await expect( + stakingVault + .connect(vaultOwner) + .initiatePartialValidatorWithdrawal(SAMPLE_PUBKEY, [ether("16")], { value: amount }), + ) + .to.emit(stakingVault, "PartialWithdrawalInitiated") + .withArgs(vaultOwner, SAMPLE_PUBKEY, [ether("16")]) + .and.to.emit(stakingVault, "FeeRefunded") + .withArgs(vaultOwner, amount - fee); + }); + + it("makes a partial validator withdrawal when called by the node operator", async () => { + const amount = ether("32"); + const fee = await withdrawalRequest.fee(); + + await expect( + stakingVault + .connect(operator) + .initiatePartialValidatorWithdrawal(SAMPLE_PUBKEY, [ether("16")], { value: amount }), + ) + .and.to.emit(stakingVault, "PartialWithdrawalInitiated") + .withArgs(operator, SAMPLE_PUBKEY, [ether("16")]) + .and.to.emit(stakingVault, "FeeRefunded") + .withArgs(operator, amount - fee); + }); + }); + + context("computeDepositDataRoot", () => { + it("computes the deposit data root", async () => { + // sample tx data: https://etherscan.io/tx/0x02980d44c119b0a8e3ca0d31c288e9f177c76fb4d7ab616563e399dd9c7c6507 + const pubkey = + "0x8d6aa059b52f6b11d07d73805d409feba07dffb6442c4ef6645f7caa4038b1047e072cba21eb766579f8286ccac630b0"; + const withdrawalCredentials = "0x010000000000000000000000b8b5da17a1b7a8ad1cf45a12e1e61d3577052d35"; + const signature = + "0xab95e358d002fd79bc08564a2db057dd5164af173915eba9e3e9da233d404c0eb0058760bc30cb89abbc55cf57f0c5a6018cdb17df73ca39ddc80a323a13c2e7ba942faa86757b26120b3a58dcce5d89e95ea1ee8fa3276ffac0f0ad9313211d"; + const amount = ether("32"); + const expectedDepositDataRoot = "0xb28f86815813d7da8132a2979836b326094a350e7aa301ba611163d4b7ca77be"; + + computeDepositDataRoot(withdrawalCredentials, pubkey, signature, amount); + + expect(await stakingVault.computeDepositDataRoot(pubkey, withdrawalCredentials, signature, amount)).to.equal( + expectedDepositDataRoot, + ); + }); + }); }); diff --git a/test/integration/vaults-happy-path.integration.ts b/test/integration/vaults-happy-path.integration.ts index 0e7282ca1..59df58cb4 100644 --- a/test/integration/vaults-happy-path.integration.ts +++ b/test/integration/vaults-happy-path.integration.ts @@ -371,7 +371,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { it("Should allow Owner to trigger validator exit to cover fees", async () => { // simulate validator exit const secondValidatorKey = pubKeysBatch.slice(Number(PUBKEY_LENGTH), Number(PUBKEY_LENGTH) * 2); - await delegation.connect(curator).requestValidatorsExit(secondValidatorKey); + await delegation.connect(curator).requestValidatorExit(secondValidatorKey); await updateBalance(stakingVaultAddress, VALIDATOR_DEPOSIT_SIZE); const { elapsedProtocolReward, elapsedVaultReward } = await calculateReportParams(); From 9b52efaa8705b65e903d240e8601d76525c92b8d Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 4 Feb 2025 17:03:34 +0000 Subject: [PATCH 36/70] feat: forceValidatorWithdrawals poc --- contracts/0.8.25/vaults/StakingVault.sol | 19 +++ contracts/0.8.25/vaults/VaultHub.sol | 20 +++ .../vaults/interfaces/IStakingVault.sol | 2 + .../contracts/StETH__HarnessForVaultHub.sol | 4 + .../StakingVault__HarnessForTestUpgrade.sol | 2 + .../VaultFactory__MockForStakingVault.sol | 2 +- .../vaults/staking-vault/stakingVault.test.ts | 29 +++- .../vaulthub.forcewithdrawals.test.ts | 145 ++++++++++++++++++ test/deploy/stakingVault.ts | 6 +- 9 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 test/0.8.25/vaults/vaulthub/vaulthub.forcewithdrawals.test.ts diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index f6d06203d..6d65b8bc7 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -455,6 +455,19 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad _initiatePartialWithdrawal(_pubkeys, _amounts); } + /** + * @notice Forces validator withdrawal from the beacon chain in case the vault is unbalanced. + * @param _pubkeys pubkeys of the validators to withdraw. + * @dev Can only be called by the vault hub in case the vault is unbalanced. + */ + function forceValidatorWithdrawal(bytes calldata _pubkeys) external payable override { + if (msg.sender != address(VAULT_HUB)) revert NotAuthorized("forceValidatorWithdrawal", msg.sender); + + _initiateFullWithdrawal(_pubkeys); + + emit ForceValidatorWithdrawal(_pubkeys); + } + /** * @notice Computes the deposit data root for a validator deposit * @param _pubkey Validator public key, 48 bytes @@ -541,6 +554,12 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad */ event BeaconChainDepositsResumed(); + /** + * @notice Emitted when validator withdrawal is forced + * @param pubkeys Concatenated validators public keys. + */ + event ForceValidatorWithdrawal(bytes pubkeys); + /// Errors /** diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 3c8d10b47..974660226 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -334,6 +334,25 @@ abstract contract VaultHub is PausableUntilWithRoles { emit VaultRebalanced(msg.sender, sharesToBurn); } + /// @notice force validator withdrawal from the beacon chain in case the vault is unbalanced + /// @param _vault vault address + /// @param _pubkeys pubkeys of the validators to withdraw + function forceValidatorWithdrawal(address _vault, bytes calldata _pubkeys) external payable { + if (_vault == address(0)) revert ZeroArgument("_vault"); + if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); + + VaultSocket storage socket = _connectedSocket(_vault); + + uint256 threshold = _maxMintableShares(_vault, socket.reserveRatioThresholdBP, socket.shareLimit); + if (socket.sharesMinted <= threshold) { + revert AlreadyBalanced(_vault, socket.sharesMinted, threshold); + } + + IStakingVault(_vault).forceValidatorWithdrawal{value: msg.value}(_pubkeys); + + emit ForceValidatorWithdrawalRequested(_vault, _pubkeys); + } + function _disconnect(address _vault) internal { VaultSocket storage socket = _connectedSocket(_vault); IStakingVault vault_ = IStakingVault(socket.vault); @@ -509,6 +528,7 @@ abstract contract VaultHub is PausableUntilWithRoles { event BurnedSharesOnVault(address indexed vault, uint256 amountOfShares); event VaultRebalanced(address indexed vault, uint256 sharesBurned); event VaultProxyCodehashAdded(bytes32 indexed codehash); + event ForceValidatorWithdrawalRequested(address indexed vault, bytes pubkeys); error StETHMintFailed(address vault); error AlreadyBalanced(address vault, uint256 mintedShares, uint256 rebalancingThresholdInShares); diff --git a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol index 67f44b714..3345771bf 100644 --- a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol +++ b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol @@ -58,4 +58,6 @@ interface IStakingVault { function calculateValidatorWithdrawalFee(uint256 _validatorCount) external view returns (uint256); function initiateFullValidatorWithdrawal(bytes calldata _pubkeys) external payable; function initiatePartialValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable; + + function forceValidatorWithdrawal(bytes calldata _pubkeys) external payable; } diff --git a/test/0.8.25/vaults/contracts/StETH__HarnessForVaultHub.sol b/test/0.8.25/vaults/contracts/StETH__HarnessForVaultHub.sol index 1a5430e1c..0e13cc960 100644 --- a/test/0.8.25/vaults/contracts/StETH__HarnessForVaultHub.sol +++ b/test/0.8.25/vaults/contracts/StETH__HarnessForVaultHub.sol @@ -41,4 +41,8 @@ contract StETH__HarnessForVaultHub is StETH { function harness__mintInitialShares(uint256 _sharesAmount) public { _mintInitialShares(_sharesAmount); } + + function mintExternalShares(address _recipient, uint256 _sharesAmount) public { + _mintShares(_recipient, _sharesAmount); + } } diff --git a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol index 8c38a0c73..ae3f64902 100644 --- a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol +++ b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol @@ -133,6 +133,8 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl function initiateFullValidatorWithdrawal(bytes calldata _pubkeys) external payable {} function initiatePartialValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable {} + function forceValidatorWithdrawal(bytes calldata _pubkeys) external payable {} + error ZeroArgument(string name); error VaultAlreadyInitialized(); } diff --git a/test/0.8.25/vaults/staking-vault/contracts/VaultFactory__MockForStakingVault.sol b/test/0.8.25/vaults/staking-vault/contracts/VaultFactory__MockForStakingVault.sol index f843c98c9..78eae1928 100644 --- a/test/0.8.25/vaults/staking-vault/contracts/VaultFactory__MockForStakingVault.sol +++ b/test/0.8.25/vaults/staking-vault/contracts/VaultFactory__MockForStakingVault.sol @@ -7,7 +7,7 @@ import {UpgradeableBeacon} from "@openzeppelin/contracts-v5.2/proxy/beacon/Upgra import {BeaconProxy} from "@openzeppelin/contracts-v5.2/proxy/beacon/BeaconProxy.sol"; import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.sol"; -contract VaultFactory__MockForStakingVault is UpgradeableBeacon { +contract VaultFactory__Mock is UpgradeableBeacon { event VaultCreated(address indexed vault); constructor(address _stakingVaultImplementation) UpgradeableBeacon(_stakingVaultImplementation, msg.sender) {} diff --git a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts index 01984a054..fde9ef4f4 100644 --- a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts +++ b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts @@ -59,7 +59,7 @@ describe("StakingVault.sol", () => { depositContractAddress = await depositContract.getAddress(); ethRejectorAddress = await ethRejector.getAddress(); - vaultHubSigner = await impersonate(vaultHubAddress, ether("10")); + vaultHubSigner = await impersonate(vaultHubAddress, ether("100")); }); beforeEach(async () => (originalState = await Snapshot.take())); @@ -682,6 +682,33 @@ describe("StakingVault.sol", () => { }); }); + context("forceValidatorWithdrawal", () => { + it("reverts if called by a non-vault hub", async () => { + await expect(stakingVault.connect(stranger).forceValidatorWithdrawal(SAMPLE_PUBKEY)) + .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") + .withArgs("forceValidatorWithdrawal", stranger); + }); + + it("reverts if the passed fee is too high", async () => { + const fee = BigInt(await withdrawalRequest.fee()); + const amount = ether("32"); + + await expect(stakingVault.connect(vaultHubSigner).forceValidatorWithdrawal(SAMPLE_PUBKEY, { value: amount })) + .to.be.revertedWithCustomError(stakingVault, "FeeRefundFailed") + .withArgs(vaultHubSigner, amount - fee); + }); + + it("makes a full validator withdrawal when called by the vault hub", async () => { + const fee = BigInt(await withdrawalRequest.fee()); + + await expect(stakingVault.connect(vaultHubSigner).forceValidatorWithdrawal(SAMPLE_PUBKEY, { value: fee })) + .to.emit(stakingVault, "FullWithdrawalInitiated") + .withArgs(vaultHubSigner, SAMPLE_PUBKEY) + .and.to.emit(stakingVault, "ForceValidatorWithdrawal") + .withArgs(SAMPLE_PUBKEY); + }); + }); + context("computeDepositDataRoot", () => { it("computes the deposit data root", async () => { // sample tx data: https://etherscan.io/tx/0x02980d44c119b0a8e3ca0d31c288e9f177c76fb4d7ab616563e399dd9c7c6507 diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.forcewithdrawals.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.forcewithdrawals.test.ts new file mode 100644 index 000000000..7196bd66b --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/vaulthub.forcewithdrawals.test.ts @@ -0,0 +1,145 @@ +import { expect } from "chai"; +import { ContractTransactionReceipt, keccak256, ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { DepositContract, StakingVault, StETH__HarnessForVaultHub, VaultHub } from "typechain-types"; + +import { impersonate } from "lib"; +import { findEvents } from "lib/event"; +import { ether } from "lib/units"; + +import { deployLidoLocator, deployWithdrawalsPreDeployedMock } from "test/deploy"; +import { Snapshot, Tracing } from "test/suite"; + +const SAMPLE_PUBKEY = "0x" + "01".repeat(48); + +const SHARE_LIMIT = ether("1"); +const RESERVE_RATIO_BP = 10_00n; +const RESERVE_RATIO_THRESHOLD_BP = 8_00n; +const TREASURY_FEE_BP = 5_00n; + +const FEE = 2n; + +describe("VaultHub.sol:forceWithdrawals", () => { + let deployer: HardhatEthersSigner; + let user: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + + let vaultHub: VaultHub; + let vault: StakingVault; + let steth: StETH__HarnessForVaultHub; + let depositContract: DepositContract; + + let vaultAddress: string; + let vaultHubAddress: string; + + let originalState: string; + + before(async () => { + Tracing.enable(); + [deployer, user, stranger] = await ethers.getSigners(); + + await deployWithdrawalsPreDeployedMock(FEE); + + const locator = await deployLidoLocator(); + steth = await ethers.deployContract("StETH__HarnessForVaultHub", [user], { value: ether("100.0") }); + depositContract = await ethers.deployContract("DepositContract"); + + const vaultHubImpl = await ethers.deployContract("Accounting", [locator, steth]); + const proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()]); + + const accounting = await ethers.getContractAt("Accounting", proxy); + await accounting.initialize(deployer); + + vaultHub = await ethers.getContractAt("Accounting", proxy, user); + vaultHubAddress = await vaultHub.getAddress(); + + await accounting.grantRole(await vaultHub.VAULT_MASTER_ROLE(), user); + await accounting.grantRole(await vaultHub.VAULT_REGISTRY_ROLE(), user); + + const stakingVaultImpl = await ethers.deployContract("StakingVault", [ + await vaultHub.getAddress(), + await depositContract.getAddress(), + ]); + + const vaultFactory = await ethers.deployContract("VaultFactory__Mock", [await stakingVaultImpl.getAddress()]); + + const vaultCreationTx = (await vaultFactory + .createVault(await user.getAddress(), await user.getAddress()) + .then((tx) => tx.wait())) as ContractTransactionReceipt; + + const events = findEvents(vaultCreationTx, "VaultCreated"); + const vaultCreatedEvent = events[0]; + + vault = await ethers.getContractAt("StakingVault", vaultCreatedEvent.args.vault, user); + vaultAddress = await vault.getAddress(); + + const codehash = keccak256(await ethers.provider.getCode(vaultAddress)); + await vaultHub.connect(user).addVaultProxyCodehash(codehash); + + await vaultHub + .connect(user) + .connectVault(vaultAddress, SHARE_LIMIT, RESERVE_RATIO_BP, RESERVE_RATIO_THRESHOLD_BP, TREASURY_FEE_BP); + }); + + beforeEach(async () => (originalState = await Snapshot.take())); + + afterEach(async () => await Snapshot.restore(originalState)); + + context("forceValidatorWithdrawal", () => { + it("reverts if the vault is zero address", async () => { + await expect(vaultHub.forceValidatorWithdrawal(ZeroAddress, SAMPLE_PUBKEY)) + .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") + .withArgs("_vault"); + }); + + it("reverts if zero pubkeys", async () => { + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, "0x")).to.be.revertedWithCustomError( + vaultHub, + "ZeroArgument", + ); + }); + + it("reverts if vault is not connected to the hub", async () => { + await expect(vaultHub.forceValidatorWithdrawal(stranger, SAMPLE_PUBKEY)) + .to.be.revertedWithCustomError(vaultHub, "NotConnectedToHub") + .withArgs(stranger.address); + }); + + it("reverts if called for a balanced vault", async () => { + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY)) + .to.be.revertedWithCustomError(vaultHub, "AlreadyBalanced") + .withArgs(vaultAddress, 0n, 0n); + }); + + context("unbalanced vault", () => { + beforeEach(async () => { + const vaultHubSigner = await impersonate(vaultHubAddress, ether("100")); + + await vault.fund({ value: ether("1") }); + await vault.connect(vaultHubSigner).report(ether("1"), ether("1"), ether("1")); + await vaultHub.mintSharesBackedByVault(vaultAddress, user, ether("0.9")); + await vault.connect(vaultHubSigner).report(ether("1"), ether("1"), ether("1.1")); + await vault.connect(vaultHubSigner).report(ether("0.9"), ether("1"), ether("1.1")); // slashing + }); + + it("reverts if fees are insufficient or too high", async () => { + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: 1n })) + .to.be.revertedWithCustomError(vault, "InsufficientFee") + .withArgs(1n, FEE); + + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: FEE + 1n })) + .to.be.revertedWithCustomError(vault, "FeeRefundFailed") + .withArgs(vaultHubAddress, 1n); + }); + + it("initiates force validator withdrawal", async () => { + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: FEE })) + .to.emit(vaultHub, "ForceValidatorWithdrawalRequested") + .withArgs(vaultAddress, SAMPLE_PUBKEY); + }); + }); + }); +}); diff --git a/test/deploy/stakingVault.ts b/test/deploy/stakingVault.ts index f55b9079f..775886ec5 100644 --- a/test/deploy/stakingVault.ts +++ b/test/deploy/stakingVault.ts @@ -8,7 +8,7 @@ import { EIP7002WithdrawalRequest_Mock, StakingVault, StakingVault__factory, - VaultFactory__MockForStakingVault, + VaultFactory__Mock, VaultHub__MockForStakingVault, } from "typechain-types"; @@ -21,7 +21,7 @@ type DeployedStakingVault = { stakingVault: StakingVault; stakingVaultImplementation: StakingVault; vaultHub: VaultHub__MockForStakingVault; - vaultFactory: VaultFactory__MockForStakingVault; + vaultFactory: VaultFactory__Mock; }; export async function deployWithdrawalsPreDeployedMock( @@ -56,7 +56,7 @@ export async function deployStakingVaultBehindBeaconProxy( ]); // deploying factory/beacon - const vaultFactory_ = await ethers.deployContract("VaultFactory__MockForStakingVault", [ + const vaultFactory_ = await ethers.deployContract("VaultFactory__Mock", [ await stakingVaultImplementation_.getAddress(), ]); From f27d244e0328982fcdf8b116a38b4373449d7e0f Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 4 Feb 2025 17:34:25 +0000 Subject: [PATCH 37/70] chore: update permissions and dashboard --- contracts/0.8.25/vaults/Dashboard.sol | 17 +++++++ contracts/0.8.25/vaults/Permissions.sol | 8 +-- contracts/0.8.25/vaults/VaultFactory.sol | 2 + .../VaultFactory__MockForDashboard.sol | 1 + .../0.8.25/vaults/dashboard/dashboard.test.ts | 49 +++++++++++++++++-- test/0.8.25/vaults/vaultFactory.test.ts | 1 + 6 files changed, 71 insertions(+), 7 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index e65876c37..241f25072 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -463,6 +463,23 @@ contract Dashboard is Permissions { _requestValidatorExit(_validatorPublicKeys); } + /** + * @notice Initiates a full validator withdrawal for the given validator public keys. + * @param _validatorPublicKeys The public keys of the validators to initiate withdrawal for. + */ + function initiateFullValidatorWithdrawal(bytes calldata _validatorPublicKeys) external payable { + _initiateFullValidatorWithdrawal(_validatorPublicKeys); + } + + /** + * @notice Initiates a partial validator withdrawal for the given validator public keys and amounts. + * @param _validatorPublicKeys The public keys of the validators to initiate withdrawal for. + * @param _amounts The amounts of the validators to initiate withdrawal for. + */ + function initiatePartialValidatorWithdrawal(bytes calldata _validatorPublicKeys, uint64[] calldata _amounts) external payable { + _initiatePartialValidatorWithdrawal(_validatorPublicKeys, _amounts); + } + // ==================== Role Management Functions ==================== /** diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index 25422f58a..4cfcae3f4 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -150,12 +150,12 @@ abstract contract Permissions is AccessControlVoteable { stakingVault().requestValidatorExit(_pubkey); } - function _initiateFullValidatorsWithdrawal(bytes calldata _pubkeys) internal onlyRole(INITIATE_VALIDATOR_WITHDRAWAL_ROLE) { - stakingVault().initiateFullValidatorWithdrawal(_pubkeys); + function _initiateFullValidatorWithdrawal(bytes calldata _pubkeys) internal onlyRole(INITIATE_VALIDATOR_WITHDRAWAL_ROLE) { + stakingVault().initiateFullValidatorWithdrawal{value: msg.value}(_pubkeys); } - function _initiatePartialValidatorsWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) internal onlyRole(INITIATE_VALIDATOR_WITHDRAWAL_ROLE) { - stakingVault().initiatePartialValidatorWithdrawal(_pubkeys, _amounts); + function _initiatePartialValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) internal onlyRole(INITIATE_VALIDATOR_WITHDRAWAL_ROLE) { + stakingVault().initiatePartialValidatorWithdrawal{value: msg.value}(_pubkeys, _amounts); } function _voluntaryDisconnect() internal onlyRole(VOLUNTARY_DISCONNECT_ROLE) { diff --git a/contracts/0.8.25/vaults/VaultFactory.sol b/contracts/0.8.25/vaults/VaultFactory.sol index b971e51f4..cd4968a89 100644 --- a/contracts/0.8.25/vaults/VaultFactory.sol +++ b/contracts/0.8.25/vaults/VaultFactory.sol @@ -20,6 +20,7 @@ struct DelegationConfig { address depositPauser; address depositResumer; address exitRequester; + address withdrawalInitiator; address disconnecter; address curator; address nodeOperatorManager; @@ -78,6 +79,7 @@ contract VaultFactory { delegation.grantRole(delegation.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), _delegationConfig.depositPauser); delegation.grantRole(delegation.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), _delegationConfig.depositResumer); delegation.grantRole(delegation.REQUEST_VALIDATOR_EXIT_ROLE(), _delegationConfig.exitRequester); + delegation.grantRole(delegation.INITIATE_VALIDATOR_WITHDRAWAL_ROLE(), _delegationConfig.withdrawalInitiator); delegation.grantRole(delegation.VOLUNTARY_DISCONNECT_ROLE(), _delegationConfig.disconnecter); delegation.grantRole(delegation.CURATOR_ROLE(), _delegationConfig.curator); delegation.grantRole(delegation.NODE_OPERATOR_MANAGER_ROLE(), _delegationConfig.nodeOperatorManager); diff --git a/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol b/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol index 2404ca20d..4c0ea63be 100644 --- a/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol +++ b/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol @@ -38,6 +38,7 @@ contract VaultFactory__MockForDashboard is UpgradeableBeacon { dashboard.grantRole(dashboard.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), msg.sender); dashboard.grantRole(dashboard.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), msg.sender); dashboard.grantRole(dashboard.REQUEST_VALIDATOR_EXIT_ROLE(), msg.sender); + dashboard.grantRole(dashboard.INITIATE_VALIDATOR_WITHDRAWAL_ROLE(), msg.sender); dashboard.grantRole(dashboard.VOLUNTARY_DISCONNECT_ROLE(), msg.sender); dashboard.revokeRole(dashboard.DEFAULT_ADMIN_ROLE(), address(this)); diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index fdc2fd074..3e75378a5 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -21,7 +21,7 @@ import { import { certainAddress, days, ether, findEvents, signPermit, stethDomain, wstethDomain } from "lib"; -import { deployLidoLocator } from "test/deploy"; +import { deployLidoLocator, deployWithdrawalsPreDeployedMock } from "test/deploy"; import { Snapshot } from "test/suite"; describe("Dashboard.sol", () => { @@ -49,9 +49,13 @@ describe("Dashboard.sol", () => { const BP_BASE = 10_000n; + const FEE = 10n; // some withdrawal fee for EIP-7002 + before(async () => { [factoryOwner, vaultOwner, nodeOperator, stranger] = await ethers.getSigners(); + await deployWithdrawalsPreDeployedMock(FEE); + steth = await ethers.deployContract("StETHPermit__HarnessForDashboard"); await steth.mock__setTotalShares(ether("1000000")); await steth.mock__setTotalPooledEther(ether("1400000")); @@ -138,6 +142,13 @@ describe("Dashboard.sol", () => { }); }); + context("votingCommittee", () => { + it("returns the array of roles", async () => { + const votingCommittee = await dashboard.votingCommittee(); + expect(votingCommittee).to.deep.equal([ZeroAddress]); + }); + }); + context("initialized state", () => { it("post-initialization state is correct", async () => { // vault state @@ -606,8 +617,8 @@ describe("Dashboard.sol", () => { }); context("requestValidatorExit", () => { + const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); it("reverts if called by a non-admin", async () => { - const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); await expect(dashboard.connect(stranger).requestValidatorExit(validatorPublicKeys)).to.be.revertedWithCustomError( dashboard, "AccessControlUnauthorizedAccount", @@ -615,13 +626,45 @@ describe("Dashboard.sol", () => { }); it("requests the exit of a validator", async () => { - const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); await expect(dashboard.requestValidatorExit(validatorPublicKeys)) .to.emit(vault, "ExitRequested") .withArgs(dashboard, validatorPublicKeys); }); }); + context("initiateFullValidatorWithdrawal", () => { + const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); + + it("reverts if called by a non-admin", async () => { + await expect( + dashboard.connect(stranger).initiateFullValidatorWithdrawal(validatorPublicKeys), + ).to.be.revertedWithCustomError(dashboard, "AccessControlUnauthorizedAccount"); + }); + + it("initiates a full validator withdrawal", async () => { + await expect(dashboard.initiateFullValidatorWithdrawal(validatorPublicKeys, { value: FEE })) + .to.emit(vault, "FullWithdrawalInitiated") + .withArgs(dashboard, validatorPublicKeys); + }); + }); + + context("initiatePartialValidatorWithdrawal", () => { + const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); + const amounts = [ether("0.1")]; + + it("reverts if called by a non-admin", async () => { + await expect( + dashboard.connect(stranger).initiatePartialValidatorWithdrawal(validatorPublicKeys, amounts), + ).to.be.revertedWithCustomError(dashboard, "AccessControlUnauthorizedAccount"); + }); + + it("initiates a partial validator withdrawal", async () => { + await expect(dashboard.initiatePartialValidatorWithdrawal(validatorPublicKeys, amounts, { value: FEE })) + .to.emit(vault, "PartialWithdrawalInitiated") + .withArgs(dashboard, validatorPublicKeys, amounts); + }); + }); + context("mintShares", () => { const amountShares = ether("1"); const amountFunded = ether("2"); diff --git a/test/0.8.25/vaults/vaultFactory.test.ts b/test/0.8.25/vaults/vaultFactory.test.ts index 0240ae0f5..879e1cbc8 100644 --- a/test/0.8.25/vaults/vaultFactory.test.ts +++ b/test/0.8.25/vaults/vaultFactory.test.ts @@ -118,6 +118,7 @@ describe("VaultFactory.sol", () => { depositPauser: await vaultOwner1.getAddress(), depositResumer: await vaultOwner1.getAddress(), exitRequester: await vaultOwner1.getAddress(), + withdrawalInitiator: await vaultOwner1.getAddress(), disconnecter: await vaultOwner1.getAddress(), nodeOperatorManager: await operator.getAddress(), nodeOperatorFeeClaimer: await operator.getAddress(), From 939cbb49f981399cd270ecf359deb6485ba76c4f Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 4 Feb 2025 17:39:18 +0000 Subject: [PATCH 38/70] chore: update dashboard test coverage --- .../0.8.25/vaults/dashboard/dashboard.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 3e75378a5..666239b27 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -1761,4 +1761,47 @@ describe("Dashboard.sol", () => { expect(await vault.beaconChainDepositsPaused()).to.be.false; }); }); + + context("role management", () => { + let assignments: Dashboard.RoleAssignmentStruct[]; + + beforeEach(async () => { + assignments = [ + { role: await dashboard.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), account: vaultOwner.address }, + { role: await dashboard.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), account: vaultOwner.address }, + ]; + }); + + context("grantRoles", () => { + it("reverts when assignments array is empty", async () => { + await expect(dashboard.grantRoles([])).to.be.revertedWithCustomError(dashboard, "ZeroArgument"); + }); + + it("grants roles to multiple accounts", async () => { + await dashboard.grantRoles(assignments); + + for (const assignment of assignments) { + expect(await dashboard.hasRole(assignment.role, assignment.account)).to.be.true; + } + }); + }); + + context("revokeRoles", () => { + beforeEach(async () => { + await dashboard.grantRoles(assignments); + }); + + it("reverts when assignments array is empty", async () => { + await expect(dashboard.revokeRoles([])).to.be.revertedWithCustomError(dashboard, "ZeroArgument"); + }); + + it("revokes roles from multiple accounts", async () => { + await dashboard.revokeRoles(assignments); + + for (const assignment of assignments) { + expect(await dashboard.hasRole(assignment.role, assignment.account)).to.be.false; + } + }); + }); + }); }); From 6722576c7a45d1a35906d04a20d2c2acffd9110c Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 4 Feb 2025 17:48:24 +0000 Subject: [PATCH 39/70] chore: cleanup --- contracts/0.8.25/vaults/StakingVault.sol | 30 ++++-------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 6d65b8bc7..ea78ca7c0 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -46,6 +46,7 @@ import {IStakingVault} from "./interfaces/IStakingVault.sol"; * - `lock()` * - `report()` * - `rebalance()` + * - `forceValidatorWithdrawal()` * - Anyone: * - Can send ETH directly to the vault (treated as rewards) * @@ -329,10 +330,6 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad emit Reported(_valuation, _inOutDelta, _locked); } - // * * * * * * * * * * * * * * * * * * * * * // - // * * * BEACON CHAIN DEPOSITS LOGIC * * * * // - // * * * * * * * * * * * * * * * * * * * * * // - /** * @notice Returns the address of `BeaconChainDepositContract`. * @return Address of `BeaconChainDepositContract`. @@ -464,8 +461,6 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad if (msg.sender != address(VAULT_HUB)) revert NotAuthorized("forceValidatorWithdrawal", msg.sender); _initiateFullWithdrawal(_pubkeys); - - emit ForceValidatorWithdrawal(_pubkeys); } /** @@ -488,24 +483,21 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad return _computeDepositDataRoot(_pubkey, _withdrawalCredentials, _signature, _amount); } - // * * * * * * * * * * * * * * * * * * * * * // - // * * * INTERNAL FUNCTIONS * * * * * * * * * // - // * * * * * * * * * * * * * * * * * * * * * // - function _getStorage() private pure returns (ERC7201Storage storage $) { assembly { $.slot := ERC7201_STORAGE_LOCATION } } + /** + * @notice Ensures the caller is either the owner or the node operator. + */ function _onlyOwnerOrNodeOperator() internal view { if (msg.sender != owner() && msg.sender != _getStorage().nodeOperator) { revert OwnableUnauthorizedAccount(msg.sender); } } - /// Events - /** * @notice Emitted when `StakingVault` is funded with ether * @dev Event is not emitted upon direct transfers through `receive()` @@ -554,14 +546,6 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad */ event BeaconChainDepositsResumed(); - /** - * @notice Emitted when validator withdrawal is forced - * @param pubkeys Concatenated validators public keys. - */ - event ForceValidatorWithdrawal(bytes pubkeys); - - /// Errors - /** * @notice Thrown when an invalid zero value is passed * @param name Name of the argument that was zero @@ -639,10 +623,4 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad * @notice Thrown when trying to deposit to beacon chain while deposits are paused */ error BeaconChainDepositsArePaused(); - - /** - * @notice Emitted when the exit timelock has not elapsed - * @param timelockedUntil Timestamp when the exit timelock will end - */ - error ExitTimelockNotElapsed(uint256 timelockedUntil); } From 748f8c92d8caa9681df5e138918c2428556b6e27 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 5 Feb 2025 08:11:26 +0000 Subject: [PATCH 40/70] chore: fix tests and linter --- contracts/0.8.25/vaults/Dashboard.sol | 18 +++++++++--------- contracts/0.8.25/vaults/VaultHub.sol | 5 ++--- .../vaults/delegation/delegation.test.ts | 3 +++ .../vaults/staking-vault/stakingVault.test.ts | 4 +--- .../vaulthub/vaulthub.forcewithdrawals.test.ts | 2 +- .../vaults-happy-path.integration.ts | 1 + 6 files changed, 17 insertions(+), 16 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 241f25072..807a42ac1 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -456,28 +456,28 @@ contract Dashboard is Permissions { /** * @notice Requests validators exit for the given validator public keys. - * @param _validatorPublicKeys The public keys of the validators to request exit for. + * @param _pubkeys The public keys of the validators to request exit for. * @dev This only emits an event requesting the exit, it does not actually initiate the exit. */ - function requestValidatorExit(bytes calldata _validatorPublicKeys) external { - _requestValidatorExit(_validatorPublicKeys); + function requestValidatorExit(bytes calldata _pubkeys) external { + _requestValidatorExit(_pubkeys); } /** * @notice Initiates a full validator withdrawal for the given validator public keys. - * @param _validatorPublicKeys The public keys of the validators to initiate withdrawal for. + * @param _pubkeys The public keys of the validators to initiate withdrawal for. */ - function initiateFullValidatorWithdrawal(bytes calldata _validatorPublicKeys) external payable { - _initiateFullValidatorWithdrawal(_validatorPublicKeys); + function initiateFullValidatorWithdrawal(bytes calldata _pubkeys) external payable { + _initiateFullValidatorWithdrawal(_pubkeys); } /** * @notice Initiates a partial validator withdrawal for the given validator public keys and amounts. - * @param _validatorPublicKeys The public keys of the validators to initiate withdrawal for. + * @param _pubkeys The public keys of the validators to initiate withdrawal for. * @param _amounts The amounts of the validators to initiate withdrawal for. */ - function initiatePartialValidatorWithdrawal(bytes calldata _validatorPublicKeys, uint64[] calldata _amounts) external payable { - _initiatePartialValidatorWithdrawal(_validatorPublicKeys, _amounts); + function initiatePartialValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable { + _initiatePartialValidatorWithdrawal(_pubkeys, _amounts); } // ==================== Role Management Functions ==================== diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 974660226..3069b0909 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -4,7 +4,6 @@ // See contracts/COMPILERS.md pragma solidity 0.8.25; -import {IBeacon} from "@openzeppelin/contracts-v5.2/proxy/beacon/IBeacon.sol"; import {OwnableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/OwnableUpgradeable.sol"; import {IStakingVault} from "./interfaces/IStakingVault.sol"; @@ -350,7 +349,7 @@ abstract contract VaultHub is PausableUntilWithRoles { IStakingVault(_vault).forceValidatorWithdrawal{value: msg.value}(_pubkeys); - emit ForceValidatorWithdrawalRequested(_vault, _pubkeys); + emit VaultForceWithdrawalInitiated(_vault, _pubkeys); } function _disconnect(address _vault) internal { @@ -528,7 +527,7 @@ abstract contract VaultHub is PausableUntilWithRoles { event BurnedSharesOnVault(address indexed vault, uint256 amountOfShares); event VaultRebalanced(address indexed vault, uint256 sharesBurned); event VaultProxyCodehashAdded(bytes32 indexed codehash); - event ForceValidatorWithdrawalRequested(address indexed vault, bytes pubkeys); + event VaultForceWithdrawalInitiated(address indexed vault, bytes pubkeys); error StETHMintFailed(address vault); error AlreadyBalanced(address vault, uint256 mintedShares, uint256 rebalancingThresholdInShares); diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index 7b4651a2b..b523fd503 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -35,6 +35,7 @@ describe("Delegation.sol", () => { let depositPauser: HardhatEthersSigner; let depositResumer: HardhatEthersSigner; let exitRequester: HardhatEthersSigner; + let withdrawalInitiator: HardhatEthersSigner; let disconnecter: HardhatEthersSigner; let curator: HardhatEthersSigner; let nodeOperatorManager: HardhatEthersSigner; @@ -71,6 +72,7 @@ describe("Delegation.sol", () => { depositPauser, depositResumer, exitRequester, + withdrawalInitiator, disconnecter, curator, nodeOperatorManager, @@ -113,6 +115,7 @@ describe("Delegation.sol", () => { depositPauser, depositResumer, exitRequester, + withdrawalInitiator, disconnecter, curator, nodeOperatorManager, diff --git a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts index fde9ef4f4..6c11f9949 100644 --- a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts +++ b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts @@ -703,9 +703,7 @@ describe("StakingVault.sol", () => { await expect(stakingVault.connect(vaultHubSigner).forceValidatorWithdrawal(SAMPLE_PUBKEY, { value: fee })) .to.emit(stakingVault, "FullWithdrawalInitiated") - .withArgs(vaultHubSigner, SAMPLE_PUBKEY) - .and.to.emit(stakingVault, "ForceValidatorWithdrawal") - .withArgs(SAMPLE_PUBKEY); + .withArgs(vaultHubSigner, SAMPLE_PUBKEY); }); }); diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.forcewithdrawals.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.forcewithdrawals.test.ts index 7196bd66b..740f420c2 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.forcewithdrawals.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.forcewithdrawals.test.ts @@ -137,7 +137,7 @@ describe("VaultHub.sol:forceWithdrawals", () => { it("initiates force validator withdrawal", async () => { await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: FEE })) - .to.emit(vaultHub, "ForceValidatorWithdrawalRequested") + .to.emit(vaultHub, "VaultForceWithdrawalInitiated") .withArgs(vaultAddress, SAMPLE_PUBKEY); }); }); diff --git a/test/integration/vaults-happy-path.integration.ts b/test/integration/vaults-happy-path.integration.ts index 59df58cb4..8662db794 100644 --- a/test/integration/vaults-happy-path.integration.ts +++ b/test/integration/vaults-happy-path.integration.ts @@ -168,6 +168,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { depositPauser: curator, depositResumer: curator, exitRequester: curator, + withdrawalInitiator: curator, disconnecter: curator, nodeOperatorManager: nodeOperator, nodeOperatorFeeClaimer: nodeOperator, From 33f1d5c22bee3eeec470b4f98a45460e23ffda10 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 5 Feb 2025 12:06:14 +0000 Subject: [PATCH 41/70] chore: remove controller --- .../vaults/BeaconValidatorController.sol | 232 ---------------- contracts/0.8.25/vaults/StakingVault.sol | 249 +++++++++++++---- .../0.8.25/vaults/dashboard/dashboard.test.ts | 6 +- .../beaconValidatorController.test.ts | 250 ------------------ .../BeaconValidatorController__Harness.sol | 48 ---- .../vaults/staking-vault/stakingVault.test.ts | 188 ++++++++++--- .../vaulthub.forcewithdrawals.test.ts | 6 +- test/deploy/stakingVault.ts | 3 - 8 files changed, 359 insertions(+), 623 deletions(-) delete mode 100644 contracts/0.8.25/vaults/BeaconValidatorController.sol delete mode 100644 test/0.8.25/vaults/staking-vault/beaconValidatorController.test.ts delete mode 100644 test/0.8.25/vaults/staking-vault/contracts/BeaconValidatorController__Harness.sol diff --git a/contracts/0.8.25/vaults/BeaconValidatorController.sol b/contracts/0.8.25/vaults/BeaconValidatorController.sol deleted file mode 100644 index 2f9bdd741..000000000 --- a/contracts/0.8.25/vaults/BeaconValidatorController.sol +++ /dev/null @@ -1,232 +0,0 @@ -// SPDX-FileCopyrightText: 2025 Lido -// SPDX-License-Identifier: GPL-3.0 - -// See contracts/COMPILERS.md -pragma solidity 0.8.25; - -import {TriggerableWithdrawals} from "contracts/common/lib/TriggerableWithdrawals.sol"; - -import {IDepositContract} from "../interfaces/IDepositContract.sol"; -import {IStakingVault} from "./interfaces/IStakingVault.sol"; - -/// @notice Abstract contract that manages validator deposits and withdrawals for staking vaults. -abstract contract BeaconValidatorController { - - /// @notice The Beacon Chain deposit contract used for staking validators. - IDepositContract private immutable BEACON_CHAIN_DEPOSIT_CONTRACT; - - /// @notice Constructor that sets the Beacon Chain deposit contract. - /// @param _beaconChainDepositContract Address of the Beacon Chain deposit contract. - constructor(address _beaconChainDepositContract) { - if (_beaconChainDepositContract == address(0)) revert ZeroBeaconChainDepositContract(); - - BEACON_CHAIN_DEPOSIT_CONTRACT = IDepositContract(_beaconChainDepositContract); - } - - /// @notice Returns the address of the Beacon Chain deposit contract. - /// @return Address of the Beacon Chain deposit contract. - function _depositContract() internal view returns (address) { - return address(BEACON_CHAIN_DEPOSIT_CONTRACT); - } - - /// @notice Returns the 0x02-type withdrawal credentials for the validators deposited from this contract - /// @dev All consensus layer rewards are sent to this contract. Only 0x02-type withdrawal credentials are supported. - /// @return bytes32 The withdrawal credentials, with 0x02 prefix followed by this contract's address. - function _withdrawalCredentials() internal view returns (bytes32) { - return bytes32((0x02 << 248) + uint160(address(this))); - } - - /// @notice Deposits validators to the beacon chain deposit contract. - /// @param _deposits Array of validator deposits containing pubkey, signature, amount and deposit data root. - function _deposit(IStakingVault.Deposit[] calldata _deposits) internal { - uint256 totalAmount = 0; - uint256 numberOfDeposits = _deposits.length; - for (uint256 i = 0; i < numberOfDeposits; i++) { - IStakingVault.Deposit calldata deposit = _deposits[i]; - BEACON_CHAIN_DEPOSIT_CONTRACT.deposit{value: deposit.amount}( - deposit.pubkey, - bytes.concat(_withdrawalCredentials()), - deposit.signature, - deposit.depositDataRoot - ); - totalAmount += deposit.amount; - } - - emit Deposited(msg.sender, numberOfDeposits, totalAmount); - } - - /// @notice Calculates the total withdrawal fee for a given number of public keys. - /// @param _keysCount Number of public keys. - /// @return Total fee amount. - function _calculateWithdrawalFee(uint256 _keysCount) internal view returns (uint256) { - return _keysCount * TriggerableWithdrawals.getWithdrawalRequestFee(); - } - - /// @notice Emits the `ExitRequested` event for `nodeOperator` to exit validators. - /// @param _pubkeys Concatenated validator public keys, each 48 bytes long. - function _requestExit(bytes calldata _pubkeys) internal { - emit ExitRequested(msg.sender, _pubkeys); - } - - /// @notice Requests full withdrawal of validators from the beacon chain by submitting their public keys. - /// @param _pubkeys Concatenated validator public keys, each 48 bytes long. - /// @dev The caller must provide sufficient fee via msg.value to cover the withdrawal request costs. - function _initiateFullWithdrawal(bytes calldata _pubkeys) internal { - (uint256 feePerRequest, uint256 totalFee) = _getAndValidateWithdrawalFees(_pubkeys); - - TriggerableWithdrawals.addFullWithdrawalRequests(_pubkeys, feePerRequest); - - emit FullWithdrawalInitiated(msg.sender, _pubkeys); - - _refundExcessFee(totalFee); - } - - /// @notice Requests partial withdrawal of validators from the beacon chain by submitting their public keys and withdrawal amounts. - /// @param _pubkeys Concatenated validator public keys, each 48 bytes long. - /// @param _amounts Array of withdrawal amounts in Gwei for each validator, must match number of validators in _pubkeys. - /// @dev The caller must provide sufficient fee via msg.value to cover the withdrawal request costs. - function _initiatePartialWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) internal { - (uint256 feePerRequest, uint256 totalFee) = _getAndValidateWithdrawalFees(_pubkeys); - - TriggerableWithdrawals.addPartialWithdrawalRequests(_pubkeys, _amounts, feePerRequest); - - emit PartialWithdrawalInitiated(msg.sender, _pubkeys, _amounts); - - _refundExcessFee(totalFee); - } - - /// @notice Refunds excess fee back to the sender if they sent more than required. - /// @param _totalFee Total fee required for the withdrawal request that will be kept. - /// @dev Sends back any msg.value in excess of _totalFee to msg.sender. - function _refundExcessFee(uint256 _totalFee) private { - uint256 excess = msg.value - _totalFee; - - if (excess > 0) { - (bool success,) = msg.sender.call{value: excess}(""); - if (!success) { - revert FeeRefundFailed(msg.sender, excess); - } - - emit FeeRefunded(msg.sender, excess); - } - } - - /// @notice Validates that sufficient fee was provided to cover validator withdrawal requests. - /// @param _pubkeys Concatenated validator public keys, each 48 bytes long. - /// @return feePerRequest Fee per request for the withdrawal request. - function _getAndValidateWithdrawalFees(bytes calldata _pubkeys) private view returns (uint256 feePerRequest, uint256 totalFee) { - feePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); - totalFee = feePerRequest * _pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH; - - if (msg.value < totalFee) { - revert InsufficientFee(msg.value, totalFee); - } - - return (feePerRequest, totalFee); - } - - /// @notice Computes the deposit data root for a validator deposit. - /// @param _pubkey Validator public key, 48 bytes. - /// @param _withdrawalCreds Withdrawal credentials, 32 bytes. - /// @param _signature Signature of the deposit, 96 bytes. - /// @param _amount Amount of ether to deposit, in wei. - /// @return Deposit data root as bytes32. - /// @dev This function computes the deposit data root according to the deposit contract's specification. - /// The deposit data root is check upon deposit to the deposit contract as a protection against malformed deposit data. - /// See more: https://etherscan.io/address/0x00000000219ab540356cbb839cbe05303d7705fa#code - function _computeDepositDataRoot( - bytes calldata _pubkey, - bytes calldata _withdrawalCreds, - bytes calldata _signature, - uint256 _amount - ) internal pure returns (bytes32) { - // Step 1. Convert the deposit amount in wei to gwei in 64-bit bytes - bytes memory amountBE64 = abi.encodePacked(uint64(_amount / 1 gwei)); - - // Step 2. Convert the amount to little-endian format by flipping the bytes 🧠 - bytes memory amountLE64 = new bytes(8); - amountLE64[0] = amountBE64[7]; - amountLE64[1] = amountBE64[6]; - amountLE64[2] = amountBE64[5]; - amountLE64[3] = amountBE64[4]; - amountLE64[4] = amountBE64[3]; - amountLE64[5] = amountBE64[2]; - amountLE64[6] = amountBE64[1]; - amountLE64[7] = amountBE64[0]; - - // Step 3. Compute the root of the pubkey - bytes32 pubkeyRoot = sha256(abi.encodePacked(_pubkey, bytes16(0))); - - // Step 4. Compute the root of the signature - bytes32 sigSlice1Root = sha256(abi.encodePacked(_signature[0 : 64])); - bytes32 sigSlice2Root = sha256(abi.encodePacked(_signature[64 :], bytes32(0))); - bytes32 signatureRoot = sha256(abi.encodePacked(sigSlice1Root, sigSlice2Root)); - - // Step 5. Compute the root-toot-toorootoo of the deposit data - bytes32 depositDataRoot = sha256( - abi.encodePacked( - sha256(abi.encodePacked(pubkeyRoot, _withdrawalCreds)), - sha256(abi.encodePacked(amountLE64, bytes24(0), signatureRoot)) - ) - ); - - return depositDataRoot; - } - - /** - * @notice Emitted when ether is deposited to `DepositContract`. - * @param _sender Address that initiated the deposit. - * @param _deposits Number of validator deposits made. - * @param _totalAmount Total amount of ether deposited. - */ - event Deposited(address indexed _sender, uint256 _deposits, uint256 _totalAmount); - - /** - * @notice Emitted when a validator exit request is made. - * @param _sender Address that requested the validator exit. - * @param _pubkeys Public key of the validator requested to exit. - * @dev Signals `nodeOperator` to exit the validator. - */ - event ExitRequested(address indexed _sender, bytes _pubkeys); - - /** - * @notice Emitted when a validator withdrawal request is forced via EIP-7002. - * @param _sender Address that requested the validator withdrawal. - * @param _pubkeys Public key of the validator requested to withdraw. - */ - event FullWithdrawalInitiated(address indexed _sender, bytes _pubkeys); - - /** - * @notice Emitted when a validator partial withdrawal request is forced via EIP-7002. - * @param _sender Address that requested the validator partial withdrawal. - * @param _pubkeys Public key of the validator requested to withdraw. - * @param _amounts Amounts of ether requested to withdraw. - */ - event PartialWithdrawalInitiated(address indexed _sender, bytes _pubkeys, uint64[] _amounts); - - /** - * @notice Emitted when an excess fee is refunded back to the sender. - * @param _sender Address that received the refund. - * @param _amount Amount of ether refunded. - */ - event FeeRefunded(address indexed _sender, uint256 _amount); - - /** - * @notice Thrown when `BeaconChainDepositContract` is not set. - */ - error ZeroBeaconChainDepositContract(); - - /** - * @notice Thrown when the balance is insufficient to cover the withdrawal request fee. - * @param _passed Amount of ether passed to the function. - * @param _required Amount of ether required to cover the fee. - */ - error InsufficientFee(uint256 _passed, uint256 _required); - - /** - * @notice Thrown when a transfer fails. - * @param _sender Address that initiated the transfer. - * @param _amount Amount of ether to transfer. - */ - error FeeRefundFailed(address _sender, uint256 _amount); -} diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index ea78ca7c0..d7e9ea502 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -5,10 +5,11 @@ pragma solidity 0.8.25; import {OwnableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/OwnableUpgradeable.sol"; +import {TriggerableWithdrawals} from "contracts/common/lib/TriggerableWithdrawals.sol"; import {VaultHub} from "./VaultHub.sol"; -import {BeaconValidatorController} from "./BeaconValidatorController.sol"; +import {IDepositContract} from "../interfaces/IDepositContract.sol"; import {IStakingVault} from "./interfaces/IStakingVault.sol"; /** @@ -57,7 +58,7 @@ import {IStakingVault} from "./interfaces/IStakingVault.sol"; * deposit contract. * */ -contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgradeable { +contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @notice ERC-7201 storage namespace for the vault * @dev ERC-7201 namespace is used to prevent upgrade collisions @@ -87,6 +88,12 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad */ VaultHub private immutable VAULT_HUB; + /** + * @notice Address of `BeaconChainDepositContract` + * Set immutably in the constructor to avoid storage costs + */ + IDepositContract private immutable BEACON_CHAIN_DEPOSIT_CONTRACT; + /** * @notice Storage offset slot for ERC-7201 namespace * The storage namespace is used to prevent upgrade collisions @@ -101,13 +108,12 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad * @param _beaconChainDepositContract Address of `BeaconChainDepositContract` * @dev Fixes `VaultHub` and `BeaconChainDepositContract` addresses in the bytecode of the implementation */ - constructor( - address _vaultHub, - address _beaconChainDepositContract - ) BeaconValidatorController(_beaconChainDepositContract) { + constructor(address _vaultHub, address _beaconChainDepositContract) { if (_vaultHub == address(0)) revert ZeroArgument("_vaultHub"); + if (_beaconChainDepositContract == address(0)) revert ZeroArgument("_beaconChainDepositContract"); VAULT_HUB = VaultHub(_vaultHub); + BEACON_CHAIN_DEPOSIT_CONTRACT = IDepositContract(_beaconChainDepositContract); // Prevents reinitialization of the implementation _disableInitializers(); @@ -331,33 +337,33 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad } /** - * @notice Returns the address of `BeaconChainDepositContract`. - * @return Address of `BeaconChainDepositContract`. + * @notice Returns the address of `BeaconChainDepositContract` + * @return Address of `BeaconChainDepositContract` */ function depositContract() external view returns (address) { - return _depositContract(); + return address(BEACON_CHAIN_DEPOSIT_CONTRACT); } /** - * @notice Returns the 0x02-type withdrawal credentials for the validators deposited from this `StakingVault`. - * All CL rewards are sent to this contract. Only 0x02-type withdrawal credentials are supported for now. - * @return Withdrawal credentials as bytes32. + * @notice Returns the 0x02-type withdrawal credentials for the validators deposited from this `StakingVault` + * All consensus layer rewards are sent to this contract. Only 0x02-type withdrawal credentials are supported + * @return Withdrawal credentials as bytes32 */ - function withdrawalCredentials() external view returns (bytes32) { - return _withdrawalCredentials(); + function withdrawalCredentials() public view returns (bytes32) { + return bytes32((0x02 << 248) + uint160(address(this))); } /** - * @notice Returns whether deposits are paused by the vault owner. - * @return True if deposits are paused. + * @notice Returns whether deposits are paused by the vault owner + * @return True if deposits are paused */ function beaconChainDepositsPaused() external view returns (bool) { return _getStorage().beaconChainDepositsPaused; } /** - * @notice Pauses deposits to beacon chain. - * @dev Can only be called by the vault owner. + * @notice Pauses deposits to beacon chain + * @dev Can only be called by the vault owner */ function pauseBeaconChainDeposits() external onlyOwner { ERC7201Storage storage $ = _getStorage(); @@ -371,8 +377,8 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad } /** - * @notice Resumes deposits to beacon chain. - * @dev Can only be called by the vault owner. + * @notice Resumes deposits to beacon chain + * @dev Can only be called by the vault owner */ function resumeBeaconChainDeposits() external onlyOwner { ERC7201Storage storage $ = _getStorage(); @@ -386,9 +392,9 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad } /** - * @notice Performs a deposit to the beacon chain deposit contract. - * @param _deposits Array of deposit structs. - * @dev Includes a check to ensure StakingVault is balanced before making deposits. + * @notice Performs a deposit to the beacon chain deposit contract + * @param _deposits Array of deposit structs + * @dev Includes a check to ensure `StakingVault` is balanced before making deposits */ function depositToBeaconChain(Deposit[] calldata _deposits) external { if (_deposits.length == 0) revert ZeroArgument("_deposits"); @@ -399,68 +405,104 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad if ($.beaconChainDepositsPaused) revert BeaconChainDepositsArePaused(); if (valuation() < $.locked) revert Unbalanced(); - _deposit(_deposits); + uint256 totalAmount = 0; + uint256 numberOfDeposits = _deposits.length; + for (uint256 i = 0; i < numberOfDeposits; i++) { + IStakingVault.Deposit calldata deposit = _deposits[i]; + BEACON_CHAIN_DEPOSIT_CONTRACT.deposit{value: deposit.amount}( + deposit.pubkey, + bytes.concat(withdrawalCredentials()), + deposit.signature, + deposit.depositDataRoot + ); + totalAmount += deposit.amount; + } + + emit DepositedToBeaconChain(msg.sender, numberOfDeposits, totalAmount); } /** - * @notice Returns total withdrawal fee required for given number of validator keys. - * @param _numberOfKeys Number of validator keys + * @notice Calculates the total withdrawal fee required for given number of validator keys + * @param _numberOfKeys Number of validators' public keys * @return Total fee amount */ function calculateValidatorWithdrawalFee(uint256 _numberOfKeys) external view returns (uint256) { if (_numberOfKeys == 0) revert ZeroArgument("_numberOfKeys"); - return _calculateWithdrawalFee(_numberOfKeys); + return _numberOfKeys * TriggerableWithdrawals.getWithdrawalRequestFee(); } /** - * @notice Requests validator exit from the beacon chain. - * @param _pubkeys Concatenated validator public keys. - * @dev Signals the node operator to eject the specified validators from the beacon chain. + * @notice Requests validator exit from the beacon chain by emitting an `ValidatorExitRequested` event + * @param _pubkeys Concatenated validators' public keys + * @dev Signals the node operator to eject the specified validators from the beacon chain */ function requestValidatorExit(bytes calldata _pubkeys) external onlyOwner { if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); - _requestExit(_pubkeys); + emit ValidatorExitRequested(msg.sender, _pubkeys); } /** - * @notice Initiates a full validator withdrawal from the beacon chain following EIP-7002. - * @param _pubkeys Concatenated validators public keys. - * @dev Keys are expected to be 48 bytes long tightly packed without paddings. - * Only allowed to be called by the owner or the node operator. + * @notice Initiates a full validator withdrawal from the beacon chain following EIP-7002 + * @param _pubkeys Concatenated validators public keys + * @dev Keys are expected to be 48 bytes long tightly packed without paddings + * Only allowed to be called by the owner or the node operator + * @dev The caller must provide sufficient fee via msg.value to cover the withdrawal request costs */ function initiateFullValidatorWithdrawal(bytes calldata _pubkeys) external payable { if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); - _onlyOwnerOrNodeOperator(); - _initiateFullWithdrawal(_pubkeys); + + (uint256 feePerRequest, uint256 totalFee) = _getAndValidateWithdrawalFees(_pubkeys); + TriggerableWithdrawals.addFullWithdrawalRequests(_pubkeys, feePerRequest); + + emit FullValidatorWithdrawalInitiated(msg.sender, _pubkeys); + + _refundExcessFee(totalFee); } /** - * @notice Initiates a partial validator withdrawal from the beacon chain following EIP-7002. - * @param _pubkeys Concatenated validators public keys. - * @param _amounts Amounts of ether to exit. - * @dev Keys are expected to be 48 bytes long tightly packed without paddings. - * Only allowed to be called by the owner or the node operator. + * @notice Initiates a partial validator withdrawal from the beacon chain following EIP-7002 + * @param _pubkeys Concatenated validators public keys + * @param _amounts Amounts of ether to exit + * @dev Keys are expected to be 48 bytes long tightly packed without paddings + * Only allowed to be called by the owner or the node operator + * @dev The caller must provide sufficient fee via msg.value to cover the withdrawal request costs */ function initiatePartialValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable { if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); if (_amounts.length == 0) revert ZeroArgument("_amounts"); _onlyOwnerOrNodeOperator(); - _initiatePartialWithdrawal(_pubkeys, _amounts); + + (uint256 feePerRequest, uint256 totalFee) = _getAndValidateWithdrawalFees(_pubkeys); + TriggerableWithdrawals.addPartialWithdrawalRequests(_pubkeys, _amounts, feePerRequest); + + emit PartialValidatorWithdrawalInitiated(msg.sender, _pubkeys, _amounts); + + _refundExcessFee(totalFee); } /** * @notice Forces validator withdrawal from the beacon chain in case the vault is unbalanced. * @param _pubkeys pubkeys of the validators to withdraw. * @dev Can only be called by the vault hub in case the vault is unbalanced. + * @dev The caller must provide exactly the required fee via msg.value to cover the withdrawal request costs. No refunds are provided. */ function forceValidatorWithdrawal(bytes calldata _pubkeys) external payable override { if (msg.sender != address(VAULT_HUB)) revert NotAuthorized("forceValidatorWithdrawal", msg.sender); - _initiateFullWithdrawal(_pubkeys); + uint256 feePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); + uint256 totalFee = feePerRequest * _pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH; + + if (msg.value != totalFee) { + revert InvalidValidatorWithdrawalFee(msg.value, totalFee); + } + + TriggerableWithdrawals.addFullWithdrawalRequests(_pubkeys, feePerRequest); + + emit FullValidatorWithdrawalInitiated(msg.sender, _pubkeys); } /** @@ -480,7 +522,37 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad bytes calldata _signature, uint256 _amount ) external pure returns (bytes32) { - return _computeDepositDataRoot(_pubkey, _withdrawalCredentials, _signature, _amount); + // Step 1. Convert the deposit amount in wei to gwei in 64-bit bytes + bytes memory amountBE64 = abi.encodePacked(uint64(_amount / 1 gwei)); + + // Step 2. Convert the amount to little-endian format by flipping the bytes + bytes memory amountLE64 = new bytes(8); + amountLE64[0] = amountBE64[7]; + amountLE64[1] = amountBE64[6]; + amountLE64[2] = amountBE64[5]; + amountLE64[3] = amountBE64[4]; + amountLE64[4] = amountBE64[3]; + amountLE64[5] = amountBE64[2]; + amountLE64[6] = amountBE64[1]; + amountLE64[7] = amountBE64[0]; + + // Step 3. Compute the root of the pubkey + bytes32 pubkeyRoot = sha256(abi.encodePacked(_pubkey, bytes16(0))); + + // Step 4. Compute the root of the signature + bytes32 sigSlice1Root = sha256(abi.encodePacked(_signature[0 : 64])); + bytes32 sigSlice2Root = sha256(abi.encodePacked(_signature[64 :], bytes32(0))); + bytes32 signatureRoot = sha256(abi.encodePacked(sigSlice1Root, sigSlice2Root)); + + // Step 5. Compute the root-toot-toorootoo of the deposit data + bytes32 depositDataRoot = sha256( + abi.encodePacked( + sha256(abi.encodePacked(pubkeyRoot, _withdrawalCredentials)), + sha256(abi.encodePacked(amountLE64, bytes24(0), signatureRoot)) + ) + ); + + return depositDataRoot; } function _getStorage() private pure returns (ERC7201Storage storage $) { @@ -489,15 +561,44 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad } } - /** - * @notice Ensures the caller is either the owner or the node operator. - */ + /// @notice Ensures the caller is either the owner or the node operator function _onlyOwnerOrNodeOperator() internal view { if (msg.sender != owner() && msg.sender != _getStorage().nodeOperator) { revert OwnableUnauthorizedAccount(msg.sender); } } + /// @notice Validates that sufficient fee was provided to cover validator withdrawal requests + /// @param _pubkeys Concatenated validator public keys, each 48 bytes long + /// @return feePerRequest Fee per request for the withdrawal request + /// @return totalFee Total fee required for the withdrawal request + function _getAndValidateWithdrawalFees(bytes calldata _pubkeys) private view returns (uint256 feePerRequest, uint256 totalFee) { + feePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); + totalFee = feePerRequest * _pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH; + + if (msg.value < totalFee) { + revert InvalidValidatorWithdrawalFee(msg.value, totalFee); + } + + return (feePerRequest, totalFee); + } + + /// @notice Refunds excess fee back to the sender if they sent more than required + /// @param _totalFee Total fee required for the withdrawal request that will be kept + /// @dev Sends back any msg.value in excess of _totalFee to msg.sender + function _refundExcessFee(uint256 _totalFee) private { + uint256 excess = msg.value - _totalFee; + + if (excess > 0) { + (bool success,) = msg.sender.call{value: excess}(""); + if (!success) { + revert ValidatorWithdrawalFeeRefundFailed(msg.sender, excess); + } + + emit ValidatorWithdrawalFeeRefunded(msg.sender, excess); + } + } + /** * @notice Emitted when `StakingVault` is funded with ether * @dev Event is not emitted upon direct transfers through `receive()` @@ -546,6 +647,44 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad */ event BeaconChainDepositsResumed(); + /** + * @notice Emitted when ether is deposited to `DepositContract`. + * @param _sender Address that initiated the deposit. + * @param _deposits Number of validator deposits made. + * @param _totalAmount Total amount of ether deposited. + */ + event DepositedToBeaconChain(address indexed _sender, uint256 _deposits, uint256 _totalAmount); + + /** + * @notice Emitted when a validator exit request is made. + * @param _sender Address that requested the validator exit. + * @param _pubkeys Public key of the validator requested to exit. + * @dev Signals `nodeOperator` to exit the validator. + */ + event ValidatorExitRequested(address indexed _sender, bytes _pubkeys); + + /** + * @notice Emitted when a validator withdrawal request is forced via EIP-7002. + * @param _sender Address that requested the validator withdrawal. + * @param _pubkeys Public key of the validator requested to withdraw. + */ + event FullValidatorWithdrawalInitiated(address indexed _sender, bytes _pubkeys); + + /** + * @notice Emitted when a validator partial withdrawal request is forced via EIP-7002. + * @param _sender Address that requested the validator partial withdrawal. + * @param _pubkeys Public key of the validator requested to withdraw. + * @param _amounts Amounts of ether requested to withdraw. + */ + event PartialValidatorWithdrawalInitiated(address indexed _sender, bytes _pubkeys, uint64[] _amounts); + + /** + * @notice Emitted when an excess fee is refunded back to the sender. + * @param _sender Address that received the refund. + * @param _amount Amount of ether refunded. + */ + event ValidatorWithdrawalFeeRefunded(address indexed _sender, uint256 _amount); + /** * @notice Thrown when an invalid zero value is passed * @param name Name of the argument that was zero @@ -623,4 +762,18 @@ contract StakingVault is IStakingVault, BeaconValidatorController, OwnableUpgrad * @notice Thrown when trying to deposit to beacon chain while deposits are paused */ error BeaconChainDepositsArePaused(); + + /** + * @notice Thrown when the validator withdrawal fee is invalid + * @param _passed Amount of ether passed to the function + * @param _required Amount of ether required to cover the fee + */ + error InvalidValidatorWithdrawalFee(uint256 _passed, uint256 _required); + + /** + * @notice Thrown when a validator withdrawal fee refund fails + * @param _sender Address that initiated the refund + * @param _amount Amount of ether to refund + */ + error ValidatorWithdrawalFeeRefundFailed(address _sender, uint256 _amount); } diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 666239b27..f4f1c8aa0 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -627,7 +627,7 @@ describe("Dashboard.sol", () => { it("requests the exit of a validator", async () => { await expect(dashboard.requestValidatorExit(validatorPublicKeys)) - .to.emit(vault, "ExitRequested") + .to.emit(vault, "ValidatorExitRequested") .withArgs(dashboard, validatorPublicKeys); }); }); @@ -643,7 +643,7 @@ describe("Dashboard.sol", () => { it("initiates a full validator withdrawal", async () => { await expect(dashboard.initiateFullValidatorWithdrawal(validatorPublicKeys, { value: FEE })) - .to.emit(vault, "FullWithdrawalInitiated") + .to.emit(vault, "FullValidatorWithdrawalInitiated") .withArgs(dashboard, validatorPublicKeys); }); }); @@ -660,7 +660,7 @@ describe("Dashboard.sol", () => { it("initiates a partial validator withdrawal", async () => { await expect(dashboard.initiatePartialValidatorWithdrawal(validatorPublicKeys, amounts, { value: FEE })) - .to.emit(vault, "PartialWithdrawalInitiated") + .to.emit(vault, "PartialValidatorWithdrawalInitiated") .withArgs(dashboard, validatorPublicKeys, amounts); }); }); diff --git a/test/0.8.25/vaults/staking-vault/beaconValidatorController.test.ts b/test/0.8.25/vaults/staking-vault/beaconValidatorController.test.ts deleted file mode 100644 index 84997336e..000000000 --- a/test/0.8.25/vaults/staking-vault/beaconValidatorController.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { expect } from "chai"; -import { ZeroAddress } from "ethers"; -import { ethers } from "hardhat"; - -import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; - -import { - BeaconValidatorController__Harness, - DepositContract__MockForStakingVault, - EIP7002WithdrawalRequest_Mock, - EthRejector, -} from "typechain-types"; - -import { computeDepositDataRoot, de0x, ether, impersonate } from "lib"; - -import { deployWithdrawalsPreDeployedMock } from "test/deploy"; -import { Snapshot } from "test/suite"; - -const getPubkey = (index: number) => index.toString(16).padStart(4, "0").toLocaleLowerCase().repeat(24); -const getSignature = (index: number) => index.toString(16).padStart(8, "0").toLocaleLowerCase().repeat(12); - -const getPubkeys = (num: number) => `0x${Array.from({ length: num }, (_, i) => getPubkey(i + 1)).join("")}`; - -describe("BeaconValidatorController.sol", () => { - let owner: HardhatEthersSigner; - let operator: HardhatEthersSigner; - - let controller: BeaconValidatorController__Harness; - let depositContract: DepositContract__MockForStakingVault; - let withdrawalRequest: EIP7002WithdrawalRequest_Mock; - let ethRejector: EthRejector; - - let depositContractAddress: string; - let controllerAddress: string; - - let originalState: string; - - before(async () => { - [owner, operator] = await ethers.getSigners(); - - withdrawalRequest = await deployWithdrawalsPreDeployedMock(1n); - ethRejector = await ethers.deployContract("EthRejector"); - - depositContract = await ethers.deployContract("DepositContract__MockForStakingVault"); - depositContractAddress = await depositContract.getAddress(); - - controller = await ethers.deployContract("BeaconValidatorController__Harness", [depositContractAddress]); - controllerAddress = await controller.getAddress(); - }); - - beforeEach(async () => (originalState = await Snapshot.take())); - - afterEach(async () => await Snapshot.restore(originalState)); - - context("constructor", () => { - it("reverts if the deposit contract address is zero", async () => { - await expect( - ethers.deployContract("BeaconValidatorController__Harness", [ZeroAddress]), - ).to.be.revertedWithCustomError(controller, "ZeroBeaconChainDepositContract"); - }); - }); - - context("_depositContract", () => { - it("returns the deposit contract address", async () => { - expect(await controller.harness__depositContract()).to.equal(depositContractAddress); - }); - }); - - context("_withdrawalCredentials", () => { - it("returns the withdrawal credentials", async () => { - expect(await controller.harness__withdrawalCredentials()).to.equal( - ("0x02" + "00".repeat(11) + de0x(controllerAddress)).toLowerCase(), - ); - }); - }); - - context("_deposit", () => { - it("makes deposits to the beacon chain and emits the Deposited event", async () => { - const numberOfKeys = 2; // number because of Array.from - const totalAmount = ether("32") * BigInt(numberOfKeys); - const withdrawalCredentials = await controller.harness__withdrawalCredentials(); - - // topup the contract with enough ETH to cover the deposits - await setBalance(controllerAddress, ether("32") * BigInt(numberOfKeys)); - - const deposits = Array.from({ length: numberOfKeys }, (_, i) => { - const pubkey = `0x${getPubkey(i + 1)}`; - const signature = `0x${getSignature(i + 1)}`; - const amount = ether("32"); - const depositDataRoot = computeDepositDataRoot(withdrawalCredentials, pubkey, signature, amount); - return { pubkey, signature, amount, depositDataRoot }; - }); - - await expect(controller.connect(operator).harness__deposit(deposits)) - .to.emit(controller, "Deposited") - .withArgs(operator, 2, totalAmount); - }); - }); - - context("_calculateWithdrawalFee", () => { - it("returns the total fee for given number of validator keys", async () => { - const newFee = 100n; - await withdrawalRequest.setFee(newFee); - - const fee = await controller.harness__calculateWithdrawalFee(1n); - expect(fee).to.equal(newFee); - - const feePerRequest = await withdrawalRequest.fee(); - expect(fee).to.equal(feePerRequest); - - const feeForMultipleKeys = await controller.harness__calculateWithdrawalFee(2n); - expect(feeForMultipleKeys).to.equal(newFee * 2n); - }); - }); - - context("_requestExit", () => { - it("emits the ExitRequested event", async () => { - const pubkeys = getPubkeys(2); - await expect(controller.connect(owner).harness__requestExit(pubkeys)) - .to.emit(controller, "ExitRequested") - .withArgs(owner, pubkeys); - }); - }); - - context("_initiateWithdrawal", () => { - it("reverts if passed fee is less than the required fee", async () => { - const numberOfKeys = 4; - const pubkeys = getPubkeys(numberOfKeys); - const fee = await controller.harness__calculateWithdrawalFee(numberOfKeys - 1); - - await expect(controller.connect(owner).harness__initiateFullWithdrawal(pubkeys, { value: fee })) - .to.be.revertedWithCustomError(controller, "InsufficientFee") - .withArgs(fee, numberOfKeys); - }); - - it("reverts if the refund fails", async () => { - const numberOfKeys = 1; - const pubkeys = getPubkeys(numberOfKeys); - const fee = await controller.harness__calculateWithdrawalFee(numberOfKeys); - const overpaid = 100n; - - const ethRejectorAddress = await ethRejector.getAddress(); - const ethRejectorSigner = await impersonate(ethRejectorAddress, ether("1")); - - await expect( - controller.connect(ethRejectorSigner).harness__initiateFullWithdrawal(pubkeys, { value: fee + overpaid }), - ) - .to.be.revertedWithCustomError(controller, "FeeRefundFailed") - .withArgs(ethRejectorAddress, overpaid); - }); - - it("initiates full withdrawal providing a fee", async () => { - const numberOfKeys = 1; - const pubkeys = getPubkeys(numberOfKeys); - const fee = await controller.harness__calculateWithdrawalFee(numberOfKeys); - - await expect(controller.connect(owner).harness__initiateFullWithdrawal(pubkeys, { value: fee })) - .to.emit(controller, "FullWithdrawalInitiated") - .withArgs(owner, pubkeys); - }); - - it("refunds the fee if passed fee is greater than the required fee", async () => { - const numberOfKeys = 1; - const pubkeys = getPubkeys(numberOfKeys); - const fee = await controller.harness__calculateWithdrawalFee(numberOfKeys); - const overpaid = 100n; - - await expect(controller.connect(owner).harness__initiateFullWithdrawal(pubkeys, { value: fee + overpaid })) - .to.emit(controller, "FullWithdrawalInitiated") - .withArgs(owner, pubkeys) - .and.to.emit(controller, "FeeRefunded") - .withArgs(owner, overpaid); - }); - }); - - context("_initiatePartialWithdrawal", () => { - it("reverts if passed fee is less than the required fee", async () => { - const numberOfKeys = 4; - const pubkeys = getPubkeys(numberOfKeys); - const fee = await controller.harness__calculateWithdrawalFee(numberOfKeys - 1); - - await expect(controller.connect(owner).harness__initiatePartialWithdrawal(pubkeys, [100n, 200n], { value: fee })) - .to.be.revertedWithCustomError(controller, "InsufficientFee") - .withArgs(fee, numberOfKeys); - }); - - it("reverts if the refund fails", async () => { - const numberOfKeys = 2; - const pubkeys = getPubkeys(numberOfKeys); - const fee = await controller.harness__calculateWithdrawalFee(numberOfKeys); - const overpaid = 100n; - - const ethRejectorAddress = await ethRejector.getAddress(); - const ethRejectorSigner = await impersonate(ethRejectorAddress, ether("1")); - - await expect( - controller - .connect(ethRejectorSigner) - .harness__initiatePartialWithdrawal(pubkeys, [100n, 200n], { value: fee + overpaid }), - ) - .to.be.revertedWithCustomError(controller, "FeeRefundFailed") - .withArgs(ethRejectorAddress, overpaid); - }); - - it("initiates partial withdrawal providing a fee", async () => { - const numberOfKeys = 2; - const pubkeys = getPubkeys(numberOfKeys); - const fee = await controller.harness__calculateWithdrawalFee(numberOfKeys); - - await expect(controller.connect(owner).harness__initiatePartialWithdrawal(pubkeys, [100n, 200n], { value: fee })) - .to.emit(controller, "PartialWithdrawalInitiated") - .withArgs(owner, pubkeys, [100n, 200n]); - }); - - it("refunds the fee if passed fee is greater than the required fee", async () => { - const numberOfKeys = 2; - const pubkeys = getPubkeys(numberOfKeys); - const fee = await controller.harness__calculateWithdrawalFee(numberOfKeys); - const overpaid = 100n; - - await expect( - controller.connect(owner).harness__initiatePartialWithdrawal(pubkeys, [100n, 200n], { value: fee + overpaid }), - ) - .to.emit(controller, "PartialWithdrawalInitiated") - .withArgs(owner, pubkeys, [100n, 200n]) - .and.to.emit(controller, "FeeRefunded") - .withArgs(owner, overpaid); - }); - }); - - context("computeDepositDataRoot", () => { - it("computes the deposit data root", async () => { - // sample tx data: https://etherscan.io/tx/0x02980d44c119b0a8e3ca0d31c288e9f177c76fb4d7ab616563e399dd9c7c6507 - const pubkey = - "0x8d6aa059b52f6b11d07d73805d409feba07dffb6442c4ef6645f7caa4038b1047e072cba21eb766579f8286ccac630b0"; - const withdrawalCredentials = "0x010000000000000000000000b8b5da17a1b7a8ad1cf45a12e1e61d3577052d35"; - const signature = - "0xab95e358d002fd79bc08564a2db057dd5164af173915eba9e3e9da233d404c0eb0058760bc30cb89abbc55cf57f0c5a6018cdb17df73ca39ddc80a323a13c2e7ba942faa86757b26120b3a58dcce5d89e95ea1ee8fa3276ffac0f0ad9313211d"; - const amount = ether("32"); - const expectedDepositDataRoot = "0xb28f86815813d7da8132a2979836b326094a350e7aa301ba611163d4b7ca77be"; - - computeDepositDataRoot(withdrawalCredentials, pubkey, signature, amount); - - expect( - await controller.harness__computeDepositDataRoot(pubkey, withdrawalCredentials, signature, amount), - ).to.equal(expectedDepositDataRoot); - }); - }); -}); diff --git a/test/0.8.25/vaults/staking-vault/contracts/BeaconValidatorController__Harness.sol b/test/0.8.25/vaults/staking-vault/contracts/BeaconValidatorController__Harness.sol deleted file mode 100644 index 5cc06cde7..000000000 --- a/test/0.8.25/vaults/staking-vault/contracts/BeaconValidatorController__Harness.sol +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -// for testing purposes only - -pragma solidity ^0.8.0; - -import {BeaconValidatorController} from "contracts/0.8.25/vaults/BeaconValidatorController.sol"; -import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.sol"; - -contract BeaconValidatorController__Harness is BeaconValidatorController { - constructor(address _beaconChainDepositContract) BeaconValidatorController(_beaconChainDepositContract) {} - - function harness__depositContract() external view returns (address) { - return _depositContract(); - } - - function harness__withdrawalCredentials() external view returns (bytes32) { - return _withdrawalCredentials(); - } - - function harness__deposit(IStakingVault.Deposit[] calldata _deposits) external { - _deposit(_deposits); - } - - function harness__calculateWithdrawalFee(uint256 _amount) external view returns (uint256) { - return _calculateWithdrawalFee(_amount); - } - - function harness__requestExit(bytes calldata _pubkeys) external { - _requestExit(_pubkeys); - } - - function harness__initiateFullWithdrawal(bytes calldata _pubkeys) external payable { - _initiateFullWithdrawal(_pubkeys); - } - - function harness__initiatePartialWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable { - _initiatePartialWithdrawal(_pubkeys, _amounts); - } - - function harness__computeDepositDataRoot( - bytes calldata _pubkey, - bytes calldata _withdrawalCredentials, - bytes calldata _signature, - uint256 _amount - ) external pure returns (bytes32) { - return _computeDepositDataRoot(_pubkey, _withdrawalCredentials, _signature, _amount); - } -} diff --git a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts index 6c11f9949..c10fdea59 100644 --- a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts +++ b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts @@ -23,6 +23,8 @@ const MAX_UINT128 = 2n ** 128n - 1n; const SAMPLE_PUBKEY = "0x" + "ab".repeat(48); +const getPubkeys = (num: number): string => `0x${Array.from({ length: num }, (_, i) => `0${i}`.repeat(48)).join("")}`; + // @TODO: test reentrancy attacks describe("StakingVault.sol", () => { let vaultOwner: HardhatEthersSigner; @@ -50,6 +52,7 @@ describe("StakingVault.sol", () => { ({ stakingVault, vaultHub, stakingVaultImplementation, depositContract } = await deployStakingVaultBehindBeaconProxy(vaultOwner, operator)); + // ERC7002 pre-deployed contract mock (0x00000961Ef480Eb55e80D19ad83579A64c007002) withdrawalRequest = await deployWithdrawalsPreDeployedMock(1n); ethRejector = await ethers.deployContract("EthRejector"); @@ -81,6 +84,12 @@ describe("StakingVault.sol", () => { .withArgs("_vaultHub"); }); + it("reverts on construction if the deposit contract address is zero", async () => { + await expect(ethers.deployContract("StakingVault", [vaultHubAddress, ZeroAddress])) + .to.be.revertedWithCustomError(stakingVaultImplementation, "ZeroArgument") + .withArgs("_beaconChainDepositContract"); + }); + it("petrifies the implementation by setting the initialized version to 2^64 - 1", async () => { expect(await stakingVaultImplementation.getInitializedVersion()).to.equal(2n ** 64n - 1n); expect(await stakingVaultImplementation.version()).to.equal(1n); @@ -549,7 +558,7 @@ describe("StakingVault.sol", () => { ).to.be.revertedWithCustomError(stakingVault, "BeaconChainDepositsArePaused"); }); - it("makes deposits to the beacon chain and emits the DepositedToBeaconChain event", async () => { + it("makes deposits to the beacon chain and emits the `DepositedToBeaconChain` event", async () => { await stakingVault.fund({ value: ether("32") }); const pubkey = "0x" + "ab".repeat(48); @@ -561,9 +570,30 @@ describe("StakingVault.sol", () => { await expect( stakingVault.connect(operator).depositToBeaconChain([{ pubkey, signature, amount, depositDataRoot }]), ) - .to.emit(stakingVault, "Deposited") + .to.emit(stakingVault, "DepositedToBeaconChain") .withArgs(operator, 1, amount); }); + + it("makes multiple deposits to the beacon chain and emits the `DepositedToBeaconChain` event", async () => { + const numberOfKeys = 2; // number because of Array.from + const totalAmount = ether("32") * BigInt(numberOfKeys); + const withdrawalCredentials = await stakingVault.withdrawalCredentials(); + + // topup the contract with enough ETH to cover the deposits + await setBalance(stakingVaultAddress, ether("32") * BigInt(numberOfKeys)); + + const deposits = Array.from({ length: numberOfKeys }, (_, i) => { + const pubkey = "0x" + `0${i}`.repeat(48); + const signature = "0x" + `0${i}`.repeat(96); + const amount = ether("32"); + const depositDataRoot = computeDepositDataRoot(withdrawalCredentials, pubkey, signature, amount); + return { pubkey, signature, amount, depositDataRoot }; + }); + + await expect(stakingVault.connect(operator).depositToBeaconChain(deposits)) + .to.emit(stakingVault, "DepositedToBeaconChain") + .withArgs(operator, 2, totalAmount); + }); }); context("calculateValidatorWithdrawalFee", () => { @@ -575,8 +605,23 @@ describe("StakingVault.sol", () => { it("returns the correct withdrawal fee", async () => { await withdrawalRequest.setFee(100n); + expect(await stakingVault.calculateValidatorWithdrawalFee(1)).to.equal(100n); }); + + it("returns the total fee for given number of validator keys", async () => { + const newFee = 100n; + await withdrawalRequest.setFee(newFee); + + const fee = await stakingVault.calculateValidatorWithdrawalFee(1n); + expect(fee).to.equal(newFee); + + const feePerRequest = await withdrawalRequest.fee(); + expect(fee).to.equal(feePerRequest); + + const feeForMultipleKeys = await stakingVault.calculateValidatorWithdrawalFee(2n); + expect(feeForMultipleKeys).to.equal(newFee * 2n); + }); }); context("requestValidatorExit", () => { @@ -592,9 +637,9 @@ describe("StakingVault.sol", () => { .withArgs("_pubkeys"); }); - it("emits the `ExitRequested` event", async () => { + it("emits the `ValidatorExitRequested` event", async () => { await expect(stakingVault.connect(vaultOwner).requestValidatorExit(SAMPLE_PUBKEY)) - .to.emit(stakingVault, "ExitRequested") + .to.emit(stakingVault, "ValidatorExitRequested") .withArgs(vaultOwner, SAMPLE_PUBKEY); }); }); @@ -606,29 +651,62 @@ describe("StakingVault.sol", () => { .withArgs("_pubkeys"); }); - it("reverts if called by a non-owner", async () => { + it("reverts if called by a non-owner or the node operator", async () => { await expect(stakingVault.connect(stranger).initiateFullValidatorWithdrawal(SAMPLE_PUBKEY)) .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") .withArgs(stranger); }); - it("makes a full validator withdrawal when called by the owner", async () => { + it("reverts if passed fee is less than the required fee", async () => { + const numberOfKeys = 4; + const pubkeys = getPubkeys(numberOfKeys); + const fee = await stakingVault.calculateValidatorWithdrawalFee(numberOfKeys - 1); + + await expect(stakingVault.connect(vaultOwner).initiateFullValidatorWithdrawal(pubkeys, { value: fee })) + .to.be.revertedWithCustomError(stakingVault, "InvalidValidatorWithdrawalFee") + .withArgs(fee, numberOfKeys); + }); + + // Tests the path where the refund fails because the caller is the contract that does not have the receive ETH function + it("reverts if the refund fails", async () => { + const numberOfKeys = 1; + const pubkeys = getPubkeys(numberOfKeys); + const fee = await stakingVault.calculateValidatorWithdrawalFee(numberOfKeys); + const overpaid = 100n; + + const ethRejectorSigner = await impersonate(ethRejectorAddress, ether("1")); + const { stakingVault: rejector } = await deployStakingVaultBehindBeaconProxy(vaultOwner, ethRejectorSigner); + await expect( - stakingVault.connect(vaultOwner).initiateFullValidatorWithdrawal(SAMPLE_PUBKEY, { value: ether("32") }), + rejector.connect(ethRejectorSigner).initiateFullValidatorWithdrawal(pubkeys, { value: fee + overpaid }), ) - .to.emit(stakingVault, "FullWithdrawalInitiated") - .withArgs(vaultOwner, SAMPLE_PUBKEY); + .to.be.revertedWithCustomError(stakingVault, "ValidatorWithdrawalFeeRefundFailed") + .withArgs(ethRejectorAddress, overpaid); }); - it("makes a full validator withdrawal when called by the node operator", async () => { + it("makes a full validator withdrawal when called by the owner or the node operator", async () => { const fee = BigInt(await withdrawalRequest.fee()); - const amount = ether("32"); - await expect(stakingVault.connect(operator).initiateFullValidatorWithdrawal(SAMPLE_PUBKEY, { value: amount })) - .and.to.emit(stakingVault, "FullWithdrawalInitiated") + await expect(stakingVault.connect(vaultOwner).initiateFullValidatorWithdrawal(SAMPLE_PUBKEY, { value: fee })) + .to.emit(stakingVault, "FullValidatorWithdrawalInitiated") + .withArgs(vaultOwner, SAMPLE_PUBKEY) + .and.not.to.emit(stakingVault, "ValidatorWithdrawalFeeRefunded"); + + await expect(stakingVault.connect(operator).initiateFullValidatorWithdrawal(SAMPLE_PUBKEY, { value: fee })) + .to.emit(stakingVault, "FullValidatorWithdrawalInitiated") .withArgs(operator, SAMPLE_PUBKEY) - .and.to.emit(stakingVault, "FeeRefunded") - .withArgs(operator, amount - fee); + .and.not.to.emit(stakingVault, "ValidatorWithdrawalFeeRefunded"); + }); + + it("makes a full validator withdrawal and refunds the excess fee", async () => { + const fee = BigInt(await withdrawalRequest.fee()); + const amount = ether("32"); + + await expect(stakingVault.connect(vaultOwner).initiateFullValidatorWithdrawal(SAMPLE_PUBKEY, { value: amount })) + .and.to.emit(stakingVault, "FullValidatorWithdrawalInitiated") + .withArgs(vaultOwner, SAMPLE_PUBKEY) + .and.to.emit(stakingVault, "ValidatorWithdrawalFeeRefunded") + .withArgs(vaultOwner, amount - fee); }); }); @@ -645,40 +723,75 @@ describe("StakingVault.sol", () => { .withArgs("_amounts"); }); - it("reverts if called by a non-owner", async () => { + it("reverts if called by a non-owner or the node operator", async () => { await expect(stakingVault.connect(stranger).initiatePartialValidatorWithdrawal(SAMPLE_PUBKEY, [ether("16")])) .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") .withArgs(stranger); }); - it("makes a partial validator withdrawal when called by the owner", async () => { + it("reverts if passed fee is less than the required fee", async () => { + const numberOfKeys = 4; + const pubkeys = getPubkeys(numberOfKeys); + const fee = await stakingVault.calculateValidatorWithdrawalFee(numberOfKeys - 1); + + await expect( + stakingVault.connect(vaultOwner).initiatePartialValidatorWithdrawal(pubkeys, [ether("16")], { value: fee }), + ) + .to.be.revertedWithCustomError(stakingVault, "InvalidValidatorWithdrawalFee") + .withArgs(fee, numberOfKeys); + }); + + it("reverts if the refund fails", async () => { + const numberOfKeys = 1; + const pubkeys = getPubkeys(numberOfKeys); + const fee = await stakingVault.calculateValidatorWithdrawalFee(numberOfKeys); + const overpaid = 100n; + + const ethRejectorSigner = await impersonate(ethRejectorAddress, ether("1")); + const { stakingVault: rejector } = await deployStakingVaultBehindBeaconProxy(vaultOwner, ethRejectorSigner); + + await expect( + rejector + .connect(ethRejectorSigner) + .initiatePartialValidatorWithdrawal(pubkeys, [ether("16")], { value: fee + overpaid }), + ) + .to.be.revertedWithCustomError(stakingVault, "ValidatorWithdrawalFeeRefundFailed") + .withArgs(ethRejectorAddress, overpaid); + }); + + it("makes a partial validator withdrawal when called by the owner or the node operator", async () => { const fee = BigInt(await withdrawalRequest.fee()); - const amount = ether("32"); await expect( stakingVault .connect(vaultOwner) - .initiatePartialValidatorWithdrawal(SAMPLE_PUBKEY, [ether("16")], { value: amount }), + .initiatePartialValidatorWithdrawal(SAMPLE_PUBKEY, [ether("16")], { value: fee }), ) - .to.emit(stakingVault, "PartialWithdrawalInitiated") + .to.emit(stakingVault, "PartialValidatorWithdrawalInitiated") .withArgs(vaultOwner, SAMPLE_PUBKEY, [ether("16")]) - .and.to.emit(stakingVault, "FeeRefunded") - .withArgs(vaultOwner, amount - fee); + .and.not.to.emit(stakingVault, "ValidatorWithdrawalFeeRefunded"); + + await expect( + stakingVault.connect(operator).initiatePartialValidatorWithdrawal(SAMPLE_PUBKEY, [ether("16")], { value: fee }), + ) + .to.emit(stakingVault, "PartialValidatorWithdrawalInitiated") + .withArgs(operator, SAMPLE_PUBKEY, [ether("16")]) + .and.not.to.emit(stakingVault, "ValidatorWithdrawalFeeRefunded"); }); - it("makes a partial validator withdrawal when called by the node operator", async () => { + it("makes a partial validator withdrawal and refunds the excess fee", async () => { const fee = BigInt(await withdrawalRequest.fee()); const amount = ether("32"); await expect( stakingVault - .connect(operator) + .connect(vaultOwner) .initiatePartialValidatorWithdrawal(SAMPLE_PUBKEY, [ether("16")], { value: amount }), ) - .and.to.emit(stakingVault, "PartialWithdrawalInitiated") - .withArgs(operator, SAMPLE_PUBKEY, [ether("16")]) - .and.to.emit(stakingVault, "FeeRefunded") - .withArgs(operator, amount - fee); + .and.to.emit(stakingVault, "PartialValidatorWithdrawalInitiated") + .withArgs(vaultOwner, SAMPLE_PUBKEY, [ether("16")]) + .and.to.emit(stakingVault, "ValidatorWithdrawalFeeRefunded") + .withArgs(vaultOwner, amount - fee); }); }); @@ -689,21 +802,24 @@ describe("StakingVault.sol", () => { .withArgs("forceValidatorWithdrawal", stranger); }); - it("reverts if the passed fee is too high", async () => { + it("reverts if the passed fee is too high or too low", async () => { const fee = BigInt(await withdrawalRequest.fee()); - const amount = ether("32"); - await expect(stakingVault.connect(vaultHubSigner).forceValidatorWithdrawal(SAMPLE_PUBKEY, { value: amount })) - .to.be.revertedWithCustomError(stakingVault, "FeeRefundFailed") - .withArgs(vaultHubSigner, amount - fee); + await expect(stakingVault.connect(vaultHubSigner).forceValidatorWithdrawal(SAMPLE_PUBKEY, { value: fee - 1n })) + .to.be.revertedWithCustomError(stakingVault, "InvalidValidatorWithdrawalFee") + .withArgs(fee - 1n, 1); + + await expect(stakingVault.connect(vaultHubSigner).forceValidatorWithdrawal(SAMPLE_PUBKEY, { value: fee + 1n })) + .to.be.revertedWithCustomError(stakingVault, "InvalidValidatorWithdrawalFee") + .withArgs(fee + 1n, 1); }); it("makes a full validator withdrawal when called by the vault hub", async () => { const fee = BigInt(await withdrawalRequest.fee()); - await expect(stakingVault.connect(vaultHubSigner).forceValidatorWithdrawal(SAMPLE_PUBKEY, { value: fee })) - .to.emit(stakingVault, "FullWithdrawalInitiated") - .withArgs(vaultHubSigner, SAMPLE_PUBKEY); + await expect( + stakingVault.connect(vaultHubSigner).forceValidatorWithdrawal(SAMPLE_PUBKEY, { value: fee }), + ).to.emit(stakingVault, "FullValidatorWithdrawalInitiated"); }); }); diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.forcewithdrawals.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.forcewithdrawals.test.ts index 740f420c2..fe1d1569e 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.forcewithdrawals.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.forcewithdrawals.test.ts @@ -127,12 +127,12 @@ describe("VaultHub.sol:forceWithdrawals", () => { it("reverts if fees are insufficient or too high", async () => { await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: 1n })) - .to.be.revertedWithCustomError(vault, "InsufficientFee") + .to.be.revertedWithCustomError(vault, "InvalidValidatorWithdrawalFee") .withArgs(1n, FEE); await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: FEE + 1n })) - .to.be.revertedWithCustomError(vault, "FeeRefundFailed") - .withArgs(vaultHubAddress, 1n); + .to.be.revertedWithCustomError(vault, "InvalidValidatorWithdrawalFee") + .withArgs(FEE + 1n, FEE); }); it("initiates force validator withdrawal", async () => { diff --git a/test/deploy/stakingVault.ts b/test/deploy/stakingVault.ts index 775886ec5..1df9baf1a 100644 --- a/test/deploy/stakingVault.ts +++ b/test/deploy/stakingVault.ts @@ -44,9 +44,6 @@ export async function deployStakingVaultBehindBeaconProxy( vaultOwner: HardhatEthersSigner, operator: HardhatEthersSigner, ): Promise { - // ERC7002 pre-deployed contract mock (0x00000961Ef480Eb55e80D19ad83579A64c007002) - await deployWithdrawalsPreDeployedMock(1n); - // deploying implementation const vaultHub_ = await ethers.deployContract("VaultHub__MockForStakingVault"); const depositContract_ = await ethers.deployContract("DepositContract__MockForStakingVault"); From dd67b36e41b6e776f8e60bdd8b30e391865b2f43 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 5 Feb 2025 14:04:59 +0000 Subject: [PATCH 42/70] chore: add timelock for force withdrawals --- contracts/0.8.25/vaults/Dashboard.sol | 8 ++ contracts/0.8.25/vaults/VaultHub.sol | 66 +++++++++- .../contracts/StETH__HarnessForVaultHub.sol | 7 ++ .../0.8.25/vaults/dashboard/dashboard.test.ts | 19 +++ ...s.test.ts => vaulthub.withdrawals.test.ts} | 115 ++++++++++++++++-- 5 files changed, 197 insertions(+), 18 deletions(-) rename test/0.8.25/vaults/vaulthub/{vaulthub.forcewithdrawals.test.ts => vaulthub.withdrawals.test.ts} (53%) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 807a42ac1..369f933c2 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -167,6 +167,14 @@ contract Dashboard is Permissions { return stakingVault().valuation(); } + /** + * @notice Returns the force withdrawal unlock time of the vault. + * @return The force withdrawal unlock time as a uint40. + */ + function forceWithdrawalUnlockTime() external view returns (uint40) { + return vaultSocket().forceWithdrawalUnlockTime; + } + /** * @notice Returns the overall capacity of stETH shares that can be minted by the vault bound by valuation and vault share limit. * @return The maximum number of mintable stETH shares not counting already minted ones. diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 3069b0909..a27da3176 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -49,7 +49,10 @@ abstract contract VaultHub is PausableUntilWithRoles { uint16 treasuryFeeBP; /// @notice if true, vault is disconnected and fee is not accrued bool isDisconnected; - // ### we have 104 bits left in this slot + /// @notice timestamp when the vault can force withdraw in case it is unbalanced + /// @dev 0 if the vault is currently balanced + uint40 forceWithdrawalUnlockTime; + // ### we have 64 bits left in this slot } // keccak256(abi.encode(uint256(keccak256("VaultHub")) - 1)) & ~bytes32(uint256(0xff)) @@ -69,6 +72,9 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @notice amount of ETH that is locked on the vault on connect and can be withdrawn on disconnect only uint256 internal constant CONNECT_DEPOSIT = 1 ether; + /// @notice Time-lock for force validator withdrawal + uint256 public constant FORCE_WITHDRAWAL_TIMELOCK = 3 days; + /// @notice Lido stETH contract IStETH public immutable STETH; @@ -83,7 +89,7 @@ abstract contract VaultHub is PausableUntilWithRoles { function __VaultHub_init(address _admin) internal onlyInitializing { __AccessControlEnumerable_init(); // the stone in the elevator - _getVaultHubStorage().sockets.push(VaultSocket(address(0), 0, 0, 0, 0, 0, false)); + _getVaultHubStorage().sockets.push(VaultSocket(address(0), 0, 0, 0, 0, 0, false, 0)); _grantRole(DEFAULT_ADMIN_ROLE, _admin); } @@ -159,7 +165,8 @@ abstract contract VaultHub is PausableUntilWithRoles { uint16(_reserveRatioBP), uint16(_reserveRatioThresholdBP), uint16(_treasuryFeeBP), - false // isDisconnected + false, // isDisconnected + 0 // forceWithdrawalUnlockTime ); $.vaultIndex[_vault] = $.sockets.length; $.sockets.push(vr); @@ -265,6 +272,8 @@ abstract contract VaultHub is PausableUntilWithRoles { STETH.burnExternalShares(_amountOfShares); + _updateUnbalancedState(_vault, socket); + emit BurnedSharesOnVault(_vault, _amountOfShares); } @@ -312,6 +321,8 @@ abstract contract VaultHub is PausableUntilWithRoles { // TODO: add some gas compensation here IStakingVault(_vault).rebalance(amountToRebalance); + + // NB: check _updateUnbalancedState is calculated in rebalance() triggered from the `StakingVault`. } /// @notice rebalances the vault by writing off the amount of ether equal @@ -330,10 +341,24 @@ abstract contract VaultHub is PausableUntilWithRoles { STETH.rebalanceExternalEtherToInternal{value: msg.value}(); + // Check if vault is still unbalanced after rebalance + _updateUnbalancedState(msg.sender, socket); + emit VaultRebalanced(msg.sender, sharesToBurn); } - /// @notice force validator withdrawal from the beacon chain in case the vault is unbalanced + /// @notice checks if the vault can force withdraw + /// @param _vault vault address + /// @return bool whether the vault can force withdraw + function canForceValidatorWithdrawal(address _vault) public view returns (bool) { + uint40 forceWithdrawalUnlockTime = _connectedSocket(_vault).forceWithdrawalUnlockTime; + + if (forceWithdrawalUnlockTime == 0) return false; + + return block.timestamp >= forceWithdrawalUnlockTime; + } + + /// @notice forces validator withdrawal from the beacon chain in case the vault is unbalanced /// @param _vault vault address /// @param _pubkeys pubkeys of the validators to withdraw function forceValidatorWithdrawal(address _vault, bytes calldata _pubkeys) external payable { @@ -347,6 +372,10 @@ abstract contract VaultHub is PausableUntilWithRoles { revert AlreadyBalanced(_vault, socket.sharesMinted, threshold); } + if (!canForceValidatorWithdrawal(_vault)) { + revert ForceWithdrawalTimelockActive(_vault, socket.forceWithdrawalUnlockTime); + } + IStakingVault(_vault).forceValidatorWithdrawal{value: msg.value}(_pubkeys); emit VaultForceWithdrawalInitiated(_vault, _pubkeys); @@ -443,7 +472,7 @@ abstract contract VaultHub is PausableUntilWithRoles { // TODO: optimize potential rewards calculation uint256 potentialRewards = ((chargeableValue * (_postTotalPooledEther * _preTotalShares)) / - (_postTotalSharesNoFees * _preTotalPooledEther) - chargeableValue); + (_postTotalSharesNoFees * _preTotalPooledEther) - chargeableValue); uint256 treasuryFee = (potentialRewards * _socket.treasuryFeeBP) / TOTAL_BASIS_POINTS; treasuryFeeShares = (treasuryFee * _preTotalShares) / _preTotalPooledEther; @@ -466,6 +495,9 @@ abstract contract VaultHub is PausableUntilWithRoles { if (treasuryFeeShares > 0) { socket.sharesMinted += uint96(treasuryFeeShares); } + + _updateUnbalancedState(socket.vault, socket); + IStakingVault(socket.vault).report(_valuations[i], _inOutDeltas[i], _locked[i]); } @@ -485,6 +517,25 @@ abstract contract VaultHub is PausableUntilWithRoles { } } + function _updateUnbalancedState(address _vault, VaultSocket storage _socket) internal { + uint256 threshold = _maxMintableShares(_vault, _socket.reserveRatioThresholdBP, _socket.shareLimit); + bool isUnbalanced = _socket.sharesMinted > threshold; + uint40 currentUnlockTime = _socket.forceWithdrawalUnlockTime; + + if (isUnbalanced) { + if (currentUnlockTime == 0) { + uint40 newUnlockTime = uint40(block.timestamp + FORCE_WITHDRAWAL_TIMELOCK); + _socket.forceWithdrawalUnlockTime = newUnlockTime; + emit VaultBecameUnbalanced(_vault, newUnlockTime); + } + } else { + if (currentUnlockTime != 0) { + _socket.forceWithdrawalUnlockTime = 0; + emit VaultBecameBalanced(_vault); + } + } + } + function _vaultAuth(address _vault, string memory _operation) internal view { if (msg.sender != OwnableUpgradeable(_vault).owner()) revert NotAuthorized(_operation, msg.sender); } @@ -500,7 +551,7 @@ abstract contract VaultHub is PausableUntilWithRoles { /// it does not count shares that is already minted, but does count shareLimit on the vault function _maxMintableShares(address _vault, uint256 _reserveRatio, uint256 _shareLimit) internal view returns (uint256) { uint256 maxStETHMinted = (IStakingVault(_vault).valuation() * (TOTAL_BASIS_POINTS - _reserveRatio)) / - TOTAL_BASIS_POINTS; + TOTAL_BASIS_POINTS; return Math256.min(STETH.getSharesByPooledEth(maxStETHMinted), _shareLimit); } @@ -528,6 +579,8 @@ abstract contract VaultHub is PausableUntilWithRoles { event VaultRebalanced(address indexed vault, uint256 sharesBurned); event VaultProxyCodehashAdded(bytes32 indexed codehash); event VaultForceWithdrawalInitiated(address indexed vault, bytes pubkeys); + event VaultBecameUnbalanced(address indexed vault, uint40 unlockTime); + event VaultBecameBalanced(address indexed vault); error StETHMintFailed(address vault); error AlreadyBalanced(address vault, uint256 mintedShares, uint256 rebalancingThresholdInShares); @@ -548,4 +601,5 @@ abstract contract VaultHub is PausableUntilWithRoles { error AlreadyExists(bytes32 codehash); error NoMintedSharesShouldBeLeft(address vault, uint256 sharesMinted); error VaultProxyNotAllowed(address beacon); + error ForceWithdrawalTimelockActive(address vault, uint256 unlockTime); } diff --git a/test/0.8.25/vaults/contracts/StETH__HarnessForVaultHub.sol b/test/0.8.25/vaults/contracts/StETH__HarnessForVaultHub.sol index 0e13cc960..93376da60 100644 --- a/test/0.8.25/vaults/contracts/StETH__HarnessForVaultHub.sol +++ b/test/0.8.25/vaults/contracts/StETH__HarnessForVaultHub.sol @@ -45,4 +45,11 @@ contract StETH__HarnessForVaultHub is StETH { function mintExternalShares(address _recipient, uint256 _sharesAmount) public { _mintShares(_recipient, _sharesAmount); } + + function rebalanceExternalEtherToInternal() public payable { + require(msg.value != 0, "ZERO_VALUE"); + + totalPooledEther += msg.value; + externalBalance -= msg.value; + } } diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index f4f1c8aa0..fa08b4f2a 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -179,6 +179,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, + forceWithdrawalUnlockTime: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -189,6 +190,7 @@ describe("Dashboard.sol", () => { expect(await dashboard.reserveRatioBP()).to.equal(sockets.reserveRatioBP); expect(await dashboard.thresholdReserveRatioBP()).to.equal(sockets.reserveRatioThresholdBP); expect(await dashboard.treasuryFee()).to.equal(sockets.treasuryFeeBP); + expect(await dashboard.forceWithdrawalUnlockTime()).to.equal(sockets.forceWithdrawalUnlockTime); }); }); @@ -215,7 +217,9 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, + forceWithdrawalUnlockTime: 0n, }; + await hub.mock__setVaultSocket(vault, sockets); await dashboard.fund({ value: 1000n }); @@ -236,7 +240,9 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, + forceWithdrawalUnlockTime: 0n, }; + await hub.mock__setVaultSocket(vault, sockets); await dashboard.fund({ value: 1000n }); @@ -255,7 +261,9 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, + forceWithdrawalUnlockTime: 0n, }; + await hub.mock__setVaultSocket(vault, sockets); await dashboard.fund({ value: 1000n }); @@ -274,7 +282,9 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 0n, treasuryFeeBP: 500n, isDisconnected: false, + forceWithdrawalUnlockTime: 0n, }; + await hub.mock__setVaultSocket(vault, sockets); const funding = 1000n; await dashboard.fund({ value: funding }); @@ -301,7 +311,9 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, + forceWithdrawalUnlockTime: 0n, }; + await hub.mock__setVaultSocket(vault, sockets); const funding = 1000n; @@ -326,7 +338,9 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, + forceWithdrawalUnlockTime: 0n, }; + await hub.mock__setVaultSocket(vault, sockets); const funding = 1000n; @@ -348,7 +362,9 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, + forceWithdrawalUnlockTime: 0n, }; + await hub.mock__setVaultSocket(vault, sockets); const funding = 1000n; const preFundCanMint = await dashboard.projectedNewMintableShares(funding); @@ -368,7 +384,9 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, + forceWithdrawalUnlockTime: 0n, }; + await hub.mock__setVaultSocket(vault, sockets); const funding = 2000n; @@ -391,6 +409,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, + forceWithdrawalUnlockTime: 0n, }; await hub.mock__setVaultSocket(vault, sockets); diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.forcewithdrawals.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts similarity index 53% rename from test/0.8.25/vaults/vaulthub/vaulthub.forcewithdrawals.test.ts rename to test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts index fe1d1569e..ca7ab0ba1 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.forcewithdrawals.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts @@ -6,12 +6,12 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { DepositContract, StakingVault, StETH__HarnessForVaultHub, VaultHub } from "typechain-types"; -import { impersonate } from "lib"; +import { advanceChainTime, getCurrentBlockTimestamp, impersonate } from "lib"; import { findEvents } from "lib/event"; import { ether } from "lib/units"; import { deployLidoLocator, deployWithdrawalsPreDeployedMock } from "test/deploy"; -import { Snapshot, Tracing } from "test/suite"; +import { Snapshot } from "test/suite"; const SAMPLE_PUBKEY = "0x" + "01".repeat(48); @@ -20,9 +20,11 @@ const RESERVE_RATIO_BP = 10_00n; const RESERVE_RATIO_THRESHOLD_BP = 8_00n; const TREASURY_FEE_BP = 5_00n; +const FORCE_WITHDRAWAL_TIMELOCK = BigInt(3 * 24 * 60 * 60); + const FEE = 2n; -describe("VaultHub.sol:forceWithdrawals", () => { +describe("VaultHub.sol:withdrawals", () => { let deployer: HardhatEthersSigner; let user: HardhatEthersSigner; let stranger: HardhatEthersSigner; @@ -35,10 +37,12 @@ describe("VaultHub.sol:forceWithdrawals", () => { let vaultAddress: string; let vaultHubAddress: string; + let vaultSigner: HardhatEthersSigner; + let vaultHubSigner: HardhatEthersSigner; + let originalState: string; before(async () => { - Tracing.enable(); [deployer, user, stranger] = await ethers.getSigners(); await deployWithdrawalsPreDeployedMock(FEE); @@ -82,12 +86,53 @@ describe("VaultHub.sol:forceWithdrawals", () => { await vaultHub .connect(user) .connectVault(vaultAddress, SHARE_LIMIT, RESERVE_RATIO_BP, RESERVE_RATIO_THRESHOLD_BP, TREASURY_FEE_BP); + + vaultHubSigner = await impersonate(vaultHubAddress, ether("100")); + vaultSigner = await impersonate(vaultAddress, ether("100")); }); beforeEach(async () => (originalState = await Snapshot.take())); afterEach(async () => await Snapshot.restore(originalState)); + // Simulate getting in the unbalanced state + const makeVaultUnbalanced = async () => { + await vault.fund({ value: ether("1") }); + await vault.connect(vaultHubSigner).report(ether("1"), ether("1"), ether("1")); + await vaultHub.mintSharesBackedByVault(vaultAddress, user, ether("0.9")); + await vault.connect(vaultHubSigner).report(ether("1"), ether("1"), ether("1.1")); + await vault.connect(vaultHubSigner).report(ether("0.9"), ether("1"), ether("1.1")); // slashing + }; + + // Simulate getting in the unbalanced state and reporting it + const reportUnbalancedVault = async (): Promise => { + await makeVaultUnbalanced(); + + const tx = await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); + const events = await findEvents((await tx.wait()) as ContractTransactionReceipt, "VaultBecameUnbalanced"); + + return events[0].args.unlockTime; + }; + + context("canForceValidatorWithdrawal", () => { + it("returns false if the vault is balanced", async () => { + expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.false; + }); + + it("returns false if the vault is unbalanced and the time is not yet reached", async () => { + await reportUnbalancedVault(); + + expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.false; + }); + + it("returns true if the vault is unbalanced and the time is reached", async () => { + const unbalancedUntil = await reportUnbalancedVault(); + + await advanceChainTime(unbalancedUntil + 1n); + expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.true; + }); + }); + context("forceValidatorWithdrawal", () => { it("reverts if the vault is zero address", async () => { await expect(vaultHub.forceValidatorWithdrawal(ZeroAddress, SAMPLE_PUBKEY)) @@ -115,17 +160,19 @@ describe("VaultHub.sol:forceWithdrawals", () => { }); context("unbalanced vault", () => { - beforeEach(async () => { - const vaultHubSigner = await impersonate(vaultHubAddress, ether("100")); - - await vault.fund({ value: ether("1") }); - await vault.connect(vaultHubSigner).report(ether("1"), ether("1"), ether("1")); - await vaultHub.mintSharesBackedByVault(vaultAddress, user, ether("0.9")); - await vault.connect(vaultHubSigner).report(ether("1"), ether("1"), ether("1.1")); - await vault.connect(vaultHubSigner).report(ether("0.9"), ether("1"), ether("1.1")); // slashing + let unbalancedUntil: bigint; + + beforeEach(async () => (unbalancedUntil = await reportUnbalancedVault())); + + it("reverts if the time is not yet reached", async () => { + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: 1n })) + .to.be.revertedWithCustomError(vaultHub, "ForceWithdrawalTimelockActive") + .withArgs(vaultAddress, unbalancedUntil); }); it("reverts if fees are insufficient or too high", async () => { + await advanceChainTime(unbalancedUntil); + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: 1n })) .to.be.revertedWithCustomError(vault, "InvalidValidatorWithdrawalFee") .withArgs(1n, FEE); @@ -136,10 +183,54 @@ describe("VaultHub.sol:forceWithdrawals", () => { }); it("initiates force validator withdrawal", async () => { + await advanceChainTime(unbalancedUntil - 1n); + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: FEE })) .to.emit(vaultHub, "VaultForceWithdrawalInitiated") .withArgs(vaultAddress, SAMPLE_PUBKEY); }); }); }); + + context("_updateUnbalancedState", () => { + beforeEach(async () => await makeVaultUnbalanced()); + + it("sets the unlock time and emits the event if the vault is unbalanced (via rebalance)", async () => { + const tx = await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); + + // Hacky way to get the unlock time right + const events = await findEvents((await tx.wait()) as ContractTransactionReceipt, "VaultBecameUnbalanced"); + const unbalancedUntil = events[0].args.unlockTime; + + expect(unbalancedUntil).to.be.gte((await getCurrentBlockTimestamp()) + FORCE_WITHDRAWAL_TIMELOCK); + + await expect(tx).to.emit(vaultHub, "VaultBecameUnbalanced").withArgs(vaultAddress, unbalancedUntil); + }); + + it("does not change the unlock time if the vault is already unbalanced and the unlock time is already set", async () => { + await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); // report the vault as unbalanced + + await expect(vaultHub.connect(vaultSigner).rebalance({ value: 1n })).to.not.emit( + vaultHub, + "VaultBecameUnbalanced", + ); + }); + + it("resets the unlock time if the vault becomes balanced", async () => { + await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); // report the vault as unbalanced + + await expect(vaultHub.connect(vaultSigner).rebalance({ value: ether("0.1") })) + .to.emit(vaultHub, "VaultBecameBalanced") + .withArgs(vaultAddress); + }); + + it("does not change the unlock time if the vault is already balanced", async () => { + await vaultHub.connect(vaultSigner).rebalance({ value: ether("0.1") }); // report the vault as balanced + + await expect(vaultHub.connect(vaultSigner).rebalance({ value: ether("0.1") })).to.not.emit( + vaultHub, + "VaultBecameBalanced", + ); + }); + }); }); From ebd830d055017416207d7727c4ef060969c3c6a9 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Thu, 6 Feb 2025 17:56:04 +0000 Subject: [PATCH 43/70] feat: update timelock logic --- contracts/0.8.25/Accounting.sol | 10 +- contracts/0.8.25/vaults/Dashboard.sol | 8 +- contracts/0.8.25/vaults/StakingVault.sol | 1 - contracts/0.8.25/vaults/VaultHub.sol | 88 +++++++----- .../0.8.25/vaults/dashboard/dashboard.test.ts | 22 +-- .../vaulthub/vaulthub.withdrawals.test.ts | 134 ++++++++++++++++-- 6 files changed, 195 insertions(+), 68 deletions(-) diff --git a/contracts/0.8.25/Accounting.sol b/contracts/0.8.25/Accounting.sol index a875110af..86c25b854 100644 --- a/contracts/0.8.25/Accounting.sol +++ b/contracts/0.8.25/Accounting.sol @@ -69,6 +69,8 @@ contract Accounting is VaultHub { uint256 postTotalPooledEther; /// @notice amount of ether to be locked in the vaults uint256[] vaultsLockedEther; + /// @notice amount of ether to be locked in the vaults + uint256[] vaultsThresholdEther; /// @notice amount of shares to be minted as vault fees to the treasury uint256[] vaultsTreasuryFeeShares; /// @notice total amount of shares to be minted as vault fees to the treasury @@ -225,7 +227,12 @@ contract Accounting is VaultHub { // Calculate the amount of ether locked in the vaults to back external balance of stETH // and the amount of shares to mint as fees to the treasury for each vaults - (update.vaultsLockedEther, update.vaultsTreasuryFeeShares, update.totalVaultsTreasuryFeeShares) = + ( + update.vaultsLockedEther, + update.vaultsThresholdEther, + update.vaultsTreasuryFeeShares, + update.totalVaultsTreasuryFeeShares + ) = _calculateVaultsRebase( update.postTotalShares, update.postTotalPooledEther, @@ -339,6 +346,7 @@ contract Accounting is VaultHub { _report.vaultValues, _report.inOutDeltas, _update.vaultsLockedEther, + _update.vaultsThresholdEther, _update.vaultsTreasuryFeeShares ); diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 369f933c2..2e11110ed 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -168,11 +168,11 @@ contract Dashboard is Permissions { } /** - * @notice Returns the force withdrawal unlock time of the vault. - * @return The force withdrawal unlock time as a uint40. + * @notice Returns the time when the vault became unbalanced. + * @return The time when the vault became unbalanced as a uint40. */ - function forceWithdrawalUnlockTime() external view returns (uint40) { - return vaultSocket().forceWithdrawalUnlockTime; + function unbalancedSince() external view returns (uint40) { + return vaultSocket().unbalancedSince; } /** diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index d7e9ea502..298b1c945 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -307,7 +307,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { ERC7201Storage storage $ = _getStorage(); if (owner() == msg.sender || (_valuation < $.locked && msg.sender == address(VAULT_HUB))) { - $.inOutDelta -= int128(int256(_ether)); emit Withdrawn(msg.sender, address(VAULT_HUB), _ether); diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index a27da3176..8159a0dd4 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -49,9 +49,9 @@ abstract contract VaultHub is PausableUntilWithRoles { uint16 treasuryFeeBP; /// @notice if true, vault is disconnected and fee is not accrued bool isDisconnected; - /// @notice timestamp when the vault can force withdraw in case it is unbalanced + /// @notice timestamp when the vault became unbalanced /// @dev 0 if the vault is currently balanced - uint40 forceWithdrawalUnlockTime; + uint40 unbalancedSince; // ### we have 64 bits left in this slot } @@ -73,7 +73,7 @@ abstract contract VaultHub is PausableUntilWithRoles { uint256 internal constant CONNECT_DEPOSIT = 1 ether; /// @notice Time-lock for force validator withdrawal - uint256 public constant FORCE_WITHDRAWAL_TIMELOCK = 3 days; + uint40 public constant FORCE_WITHDRAWAL_TIMELOCK = 3 days; /// @notice Lido stETH contract IStETH public immutable STETH; @@ -166,7 +166,7 @@ abstract contract VaultHub is PausableUntilWithRoles { uint16(_reserveRatioThresholdBP), uint16(_treasuryFeeBP), false, // isDisconnected - 0 // forceWithdrawalUnlockTime + 0 // unbalancedSince ); $.vaultIndex[_vault] = $.sockets.length; $.sockets.push(vr); @@ -233,10 +233,11 @@ abstract contract VaultHub is PausableUntilWithRoles { if (vaultSharesAfterMint > shareLimit) revert ShareLimitExceeded(_vault, shareLimit); uint256 reserveRatioBP = socket.reserveRatioBP; - uint256 maxMintableShares = _maxMintableShares(_vault, reserveRatioBP, shareLimit); + uint256 valuation = IStakingVault(_vault).valuation(); + uint256 maxMintableShares = _maxMintableShares(valuation, reserveRatioBP, shareLimit); if (vaultSharesAfterMint > maxMintableShares) { - revert InsufficientValuationToMint(_vault, IStakingVault(_vault).valuation()); + revert InsufficientValuationToMint(_vault, valuation); } socket.sharesMinted = uint96(vaultSharesAfterMint); @@ -272,7 +273,7 @@ abstract contract VaultHub is PausableUntilWithRoles { STETH.burnExternalShares(_amountOfShares); - _updateUnbalancedState(_vault, socket); + _vaultAssessment(_vault, socket); emit BurnedSharesOnVault(_vault, _amountOfShares); } @@ -293,7 +294,8 @@ abstract contract VaultHub is PausableUntilWithRoles { VaultSocket storage socket = _connectedSocket(_vault); - uint256 threshold = _maxMintableShares(_vault, socket.reserveRatioThresholdBP, socket.shareLimit); + uint256 valuation = IStakingVault(_vault).valuation(); + uint256 threshold = _maxMintableShares(valuation, socket.reserveRatioThresholdBP, socket.shareLimit); uint256 sharesMinted = socket.sharesMinted; if (sharesMinted <= threshold) { // NOTE!: on connect vault is always balanced @@ -316,13 +318,12 @@ abstract contract VaultHub is PausableUntilWithRoles { // reserveRatio = BPS_BASE - maxMintableRatio // X = (mintedStETH * BPS_BASE - vault.valuation() * maxMintableRatio) / reserveRatio - uint256 amountToRebalance = (mintedStETH * TOTAL_BASIS_POINTS - - IStakingVault(_vault).valuation() * maxMintableRatio) / reserveRatioBP; + uint256 amountToRebalance = (mintedStETH * TOTAL_BASIS_POINTS - valuation * maxMintableRatio) / reserveRatioBP; // TODO: add some gas compensation here IStakingVault(_vault).rebalance(amountToRebalance); - // NB: check _updateUnbalancedState is calculated in rebalance() triggered from the `StakingVault`. + // NB: check _updateUnbalancedSince is calculated in rebalance() triggered from the `StakingVault`. } /// @notice rebalances the vault by writing off the amount of ether equal @@ -341,8 +342,7 @@ abstract contract VaultHub is PausableUntilWithRoles { STETH.rebalanceExternalEtherToInternal{value: msg.value}(); - // Check if vault is still unbalanced after rebalance - _updateUnbalancedState(msg.sender, socket); + _vaultAssessment(msg.sender, socket); emit VaultRebalanced(msg.sender, sharesToBurn); } @@ -351,29 +351,31 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @param _vault vault address /// @return bool whether the vault can force withdraw function canForceValidatorWithdrawal(address _vault) public view returns (bool) { - uint40 forceWithdrawalUnlockTime = _connectedSocket(_vault).forceWithdrawalUnlockTime; + uint40 unbalancedSince = _connectedSocket(_vault).unbalancedSince; - if (forceWithdrawalUnlockTime == 0) return false; + if (unbalancedSince == 0) return false; - return block.timestamp >= forceWithdrawalUnlockTime; + return block.timestamp >= unbalancedSince + FORCE_WITHDRAWAL_TIMELOCK; } /// @notice forces validator withdrawal from the beacon chain in case the vault is unbalanced /// @param _vault vault address /// @param _pubkeys pubkeys of the validators to withdraw function forceValidatorWithdrawal(address _vault, bytes calldata _pubkeys) external payable { + if (msg.value == 0) revert ZeroArgument("msg.value"); if (_vault == address(0)) revert ZeroArgument("_vault"); if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); VaultSocket storage socket = _connectedSocket(_vault); - uint256 threshold = _maxMintableShares(_vault, socket.reserveRatioThresholdBP, socket.shareLimit); + uint256 valuation = IStakingVault(_vault).valuation(); + uint256 threshold = _maxMintableShares(valuation, socket.reserveRatioThresholdBP, socket.shareLimit); if (socket.sharesMinted <= threshold) { revert AlreadyBalanced(_vault, socket.sharesMinted, threshold); } if (!canForceValidatorWithdrawal(_vault)) { - revert ForceWithdrawalTimelockActive(_vault, socket.forceWithdrawalUnlockTime); + revert ForceWithdrawalTimelockActive(_vault, socket.unbalancedSince + FORCE_WITHDRAWAL_TIMELOCK); } IStakingVault(_vault).forceValidatorWithdrawal{value: msg.value}(_pubkeys); @@ -403,7 +405,12 @@ abstract contract VaultHub is PausableUntilWithRoles { uint256 _preTotalShares, uint256 _preTotalPooledEther, uint256 _sharesToMintAsFees - ) internal view returns (uint256[] memory lockedEther, uint256[] memory treasuryFeeShares, uint256 totalTreasuryFeeShares) { + ) internal view returns ( + uint256[] memory lockedEther, + uint256[] memory thresholdEther, + uint256[] memory treasuryFeeShares, + uint256 totalTreasuryFeeShares + ) { /// HERE WILL BE ACCOUNTING DRAGON // \||/ @@ -424,6 +431,7 @@ abstract contract VaultHub is PausableUntilWithRoles { treasuryFeeShares = new uint256[](length); lockedEther = new uint256[](length); + thresholdEther = new uint256[](length); for (uint256 i = 0; i < length; ++i) { VaultSocket memory socket = $.sockets[i + 1]; @@ -444,6 +452,9 @@ abstract contract VaultHub is PausableUntilWithRoles { (mintedStETH * TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - socket.reserveRatioBP), CONNECT_DEPOSIT ); + + // Minimum amount of ether that should be in the vault to avoid unbalanced state + thresholdEther[i] = (mintedStETH * TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - socket.reserveRatioThresholdBP); } } } @@ -472,7 +483,7 @@ abstract contract VaultHub is PausableUntilWithRoles { // TODO: optimize potential rewards calculation uint256 potentialRewards = ((chargeableValue * (_postTotalPooledEther * _preTotalShares)) / - (_postTotalSharesNoFees * _preTotalPooledEther) - chargeableValue); + (_postTotalSharesNoFees * _preTotalPooledEther) - chargeableValue); uint256 treasuryFee = (potentialRewards * _socket.treasuryFeeBP) / TOTAL_BASIS_POINTS; treasuryFeeShares = (treasuryFee * _preTotalShares) / _preTotalPooledEther; @@ -482,6 +493,7 @@ abstract contract VaultHub is PausableUntilWithRoles { uint256[] memory _valuations, int256[] memory _inOutDeltas, uint256[] memory _locked, + uint256[] memory _thresholds, uint256[] memory _treasureFeeShares ) internal { VaultHubStorage storage $ = _getVaultHubStorage(); @@ -496,7 +508,8 @@ abstract contract VaultHub is PausableUntilWithRoles { socket.sharesMinted += uint96(treasuryFeeShares); } - _updateUnbalancedState(socket.vault, socket); + _epicrisis(_valuations[i], _thresholds[i], socket); + IStakingVault(socket.vault).report(_valuations[i], _inOutDeltas[i], _locked[i]); } @@ -517,21 +530,25 @@ abstract contract VaultHub is PausableUntilWithRoles { } } - function _updateUnbalancedState(address _vault, VaultSocket storage _socket) internal { - uint256 threshold = _maxMintableShares(_vault, _socket.reserveRatioThresholdBP, _socket.shareLimit); - bool isUnbalanced = _socket.sharesMinted > threshold; - uint40 currentUnlockTime = _socket.forceWithdrawalUnlockTime; + /// @notice Evaluates if vault's valuation meets minimum threshold and marks it as unbalanced if below threshold + function _vaultAssessment(address _vault, VaultSocket storage _socket) internal { + uint256 valuation = IStakingVault(_vault).valuation(); + uint256 threshold = (_socket.sharesMinted * TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - _socket.reserveRatioThresholdBP); + + _epicrisis(valuation, threshold, _socket); + } - if (isUnbalanced) { - if (currentUnlockTime == 0) { - uint40 newUnlockTime = uint40(block.timestamp + FORCE_WITHDRAWAL_TIMELOCK); - _socket.forceWithdrawalUnlockTime = newUnlockTime; - emit VaultBecameUnbalanced(_vault, newUnlockTime); + /// @notice Updates vault's unbalanced state based on if valuation is above/below threshold + function _epicrisis(uint256 _valuation, uint256 _threshold, VaultSocket storage _socket) internal { + if (_valuation < _threshold) { + if (_socket.unbalancedSince == 0) { + _socket.unbalancedSince = uint40(block.timestamp); + emit VaultBecameUnbalanced(address(_socket.vault), _socket.unbalancedSince + FORCE_WITHDRAWAL_TIMELOCK); } } else { - if (currentUnlockTime != 0) { - _socket.forceWithdrawalUnlockTime = 0; - emit VaultBecameBalanced(_vault); + if (_socket.unbalancedSince != 0) { + _socket.unbalancedSince = 0; + emit VaultBecameBalanced(address(_socket.vault)); } } } @@ -549,9 +566,8 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @dev returns total number of stETH shares that is possible to mint on the provided vault with provided reserveRatio /// it does not count shares that is already minted, but does count shareLimit on the vault - function _maxMintableShares(address _vault, uint256 _reserveRatio, uint256 _shareLimit) internal view returns (uint256) { - uint256 maxStETHMinted = (IStakingVault(_vault).valuation() * (TOTAL_BASIS_POINTS - _reserveRatio)) / - TOTAL_BASIS_POINTS; + function _maxMintableShares(uint256 _valuation, uint256 _reserveRatio, uint256 _shareLimit) internal view returns (uint256) { + uint256 maxStETHMinted = (_valuation * (TOTAL_BASIS_POINTS - _reserveRatio)) / TOTAL_BASIS_POINTS; return Math256.min(STETH.getSharesByPooledEth(maxStETHMinted), _shareLimit); } diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index fa08b4f2a..31b4a7da1 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -179,7 +179,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -190,7 +190,7 @@ describe("Dashboard.sol", () => { expect(await dashboard.reserveRatioBP()).to.equal(sockets.reserveRatioBP); expect(await dashboard.thresholdReserveRatioBP()).to.equal(sockets.reserveRatioThresholdBP); expect(await dashboard.treasuryFee()).to.equal(sockets.treasuryFeeBP); - expect(await dashboard.forceWithdrawalUnlockTime()).to.equal(sockets.forceWithdrawalUnlockTime); + expect(await dashboard.unbalancedSince()).to.equal(sockets.unbalancedSince); }); }); @@ -217,7 +217,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -240,7 +240,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -261,7 +261,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -282,7 +282,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 0n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -311,7 +311,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -338,7 +338,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -362,7 +362,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -384,7 +384,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -409,7 +409,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts index ca7ab0ba1..0a926253e 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts @@ -115,6 +115,22 @@ describe("VaultHub.sol:withdrawals", () => { }; context("canForceValidatorWithdrawal", () => { + it("reverts if the vault is not connected to the hub", async () => { + await expect(vaultHub.canForceValidatorWithdrawal(stranger)).to.be.revertedWithCustomError( + vaultHub, + "NotConnectedToHub", + ); + }); + + it("reverts if called on a disconnected vault", async () => { + await vaultHub.connect(user).disconnect(vaultAddress); + + await expect(vaultHub.canForceValidatorWithdrawal(stranger)).to.be.revertedWithCustomError( + vaultHub, + "NotConnectedToHub", + ); + }); + it("returns false if the vault is balanced", async () => { expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.false; }); @@ -127,34 +143,69 @@ describe("VaultHub.sol:withdrawals", () => { it("returns true if the vault is unbalanced and the time is reached", async () => { const unbalancedUntil = await reportUnbalancedVault(); + const future = unbalancedUntil + 1000n; - await advanceChainTime(unbalancedUntil + 1n); + await advanceChainTime(future); + + expect(await getCurrentBlockTimestamp()).to.be.gt(future); + expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.true; + }); + + it("returns correct values for border cases", async () => { + const unbalancedUntil = await reportUnbalancedVault(); + + // 1 second before the unlock time + await advanceChainTime(unbalancedUntil - (await getCurrentBlockTimestamp()) - 1n); + expect(await getCurrentBlockTimestamp()).to.be.lt(unbalancedUntil); + expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.false; + + // exactly the unlock time + await advanceChainTime(1n); + expect(await getCurrentBlockTimestamp()).to.be.eq(unbalancedUntil); + expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.true; + + // 1 second after the unlock time + await advanceChainTime(1n); + expect(await getCurrentBlockTimestamp()).to.be.gt(unbalancedUntil); expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.true; }); }); context("forceValidatorWithdrawal", () => { + it("reverts if msg.value is 0", async () => { + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: 0n })) + .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") + .withArgs("msg.value"); + }); + it("reverts if the vault is zero address", async () => { - await expect(vaultHub.forceValidatorWithdrawal(ZeroAddress, SAMPLE_PUBKEY)) + await expect(vaultHub.forceValidatorWithdrawal(ZeroAddress, SAMPLE_PUBKEY, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") .withArgs("_vault"); }); it("reverts if zero pubkeys", async () => { - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, "0x")).to.be.revertedWithCustomError( - vaultHub, - "ZeroArgument", - ); + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, "0x", { value: 1n })) + .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") + .withArgs("_pubkeys"); }); it("reverts if vault is not connected to the hub", async () => { - await expect(vaultHub.forceValidatorWithdrawal(stranger, SAMPLE_PUBKEY)) + await expect(vaultHub.forceValidatorWithdrawal(stranger, SAMPLE_PUBKEY, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "NotConnectedToHub") .withArgs(stranger.address); }); + it("reverts if called for a disconnected vault", async () => { + await vaultHub.connect(user).disconnect(vaultAddress); + + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: 1n })) + .to.be.revertedWithCustomError(vaultHub, "NotConnectedToHub") + .withArgs(vaultAddress); + }); + it("reverts if called for a balanced vault", async () => { - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY)) + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "AlreadyBalanced") .withArgs(vaultAddress, 0n, 0n); }); @@ -189,48 +240,101 @@ describe("VaultHub.sol:withdrawals", () => { .to.emit(vaultHub, "VaultForceWithdrawalInitiated") .withArgs(vaultAddress, SAMPLE_PUBKEY); }); + + it("initiates force validator withdrawal with multiple pubkeys", async () => { + const numPubkeys = 3; + const pubkeys = "0x" + "ab".repeat(numPubkeys * 48); + await advanceChainTime(unbalancedUntil - 1n); + + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, pubkeys, { value: FEE * BigInt(numPubkeys) })) + .to.emit(vaultHub, "VaultForceWithdrawalInitiated") + .withArgs(vaultAddress, pubkeys); + }); }); }); - context("_updateUnbalancedState", () => { + context("_vaultAssessment & _epicrisis", () => { beforeEach(async () => await makeVaultUnbalanced()); it("sets the unlock time and emits the event if the vault is unbalanced (via rebalance)", async () => { - const tx = await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); - // Hacky way to get the unlock time right + const tx = await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); const events = await findEvents((await tx.wait()) as ContractTransactionReceipt, "VaultBecameUnbalanced"); const unbalancedUntil = events[0].args.unlockTime; expect(unbalancedUntil).to.be.gte((await getCurrentBlockTimestamp()) + FORCE_WITHDRAWAL_TIMELOCK); await expect(tx).to.emit(vaultHub, "VaultBecameUnbalanced").withArgs(vaultAddress, unbalancedUntil); + + expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq( + unbalancedUntil - FORCE_WITHDRAWAL_TIMELOCK, + ); }); it("does not change the unlock time if the vault is already unbalanced and the unlock time is already set", async () => { - await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); // report the vault as unbalanced + // report the vault as unbalanced + const tx = await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); + const events = await findEvents((await tx.wait()) as ContractTransactionReceipt, "VaultBecameUnbalanced"); + const unbalancedUntil = events[0].args.unlockTime; - await expect(vaultHub.connect(vaultSigner).rebalance({ value: 1n })).to.not.emit( + await expect(await vaultHub.connect(vaultSigner).rebalance({ value: 1n })).to.not.emit( vaultHub, "VaultBecameUnbalanced", ); + + expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq( + unbalancedUntil - FORCE_WITHDRAWAL_TIMELOCK, + ); }); it("resets the unlock time if the vault becomes balanced", async () => { - await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); // report the vault as unbalanced + // report the vault as unbalanced + await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); + // report the vault as balanced await expect(vaultHub.connect(vaultSigner).rebalance({ value: ether("0.1") })) .to.emit(vaultHub, "VaultBecameBalanced") .withArgs(vaultAddress); + + expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq(0n); }); it("does not change the unlock time if the vault is already balanced", async () => { - await vaultHub.connect(vaultSigner).rebalance({ value: ether("0.1") }); // report the vault as balanced + // report the vault as balanced + await vaultHub.connect(vaultSigner).rebalance({ value: ether("0.1") }); + // report the vault as balanced again await expect(vaultHub.connect(vaultSigner).rebalance({ value: ether("0.1") })).to.not.emit( vaultHub, "VaultBecameBalanced", ); + + expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq(0n); + }); + + it("maintains the same unbalanced unlock time across multiple rebalance calls while still unbalanced", async () => { + // report the vault as unbalanced + const tx = await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); + const events = await findEvents((await tx.wait()) as ContractTransactionReceipt, "VaultBecameUnbalanced"); + const unbalancedSince = events[0].args.unlockTime - FORCE_WITHDRAWAL_TIMELOCK; + + // Advance time by less than FORCE_WITHDRAWAL_TIMELOCK. + await advanceChainTime(1000n); + + await expect(vaultHub.connect(vaultSigner).rebalance({ value: 1n })).to.not.emit( + vaultHub, + "VaultBecameUnbalanced", + ); + + expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq(unbalancedSince); + + // report the vault as unbalanced again + await expect(vaultHub.connect(vaultSigner).rebalance({ value: 1n })).to.not.emit( + vaultHub, + "VaultBecameUnbalanced", + ); + + expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq(unbalancedSince); }); }); }); From 1c7abcb91fee6062c1d799f4cf43f689b1950853 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Thu, 6 Feb 2025 18:01:10 +0000 Subject: [PATCH 44/70] ci: disable Hardhat / Mainnet tests --- .../workflows/tests-integration-mainnet.yml | 60 ++++++++++--------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/.github/workflows/tests-integration-mainnet.yml b/.github/workflows/tests-integration-mainnet.yml index 508b95efe..14dc01e9f 100644 --- a/.github/workflows/tests-integration-mainnet.yml +++ b/.github/workflows/tests-integration-mainnet.yml @@ -1,30 +1,32 @@ name: Integration Tests -#on: [push] -# -#jobs: -# test_hardhat_integration_fork: -# name: Hardhat / Mainnet -# runs-on: ubuntu-latest -# timeout-minutes: 120 -# -# services: -# hardhat-node: -# image: ghcr.io/lidofinance/hardhat-node:2.22.18 -# ports: -# - 8545:8545 -# env: -# ETH_RPC_URL: "${{ secrets.ETH_RPC_URL }}" -# -# steps: -# - uses: actions/checkout@v4 -# -# - name: Common setup -# uses: ./.github/workflows/setup -# -# - name: Set env -# run: cp .env.example .env -# -# - name: Run integration tests -# run: yarn test:integration:fork:mainnet -# env: -# LOG_LEVEL: debug + +# Temporary do not run automatically +on: workflow_dispatch + +jobs: + test_hardhat_integration_fork: + name: Hardhat / Mainnet + runs-on: ubuntu-latest + timeout-minutes: 120 + + services: + hardhat-node: + image: ghcr.io/lidofinance/hardhat-node:2.22.18 + ports: + - 8545:8545 + env: + ETH_RPC_URL: "${{ secrets.ETH_RPC_URL }}" + + steps: + - uses: actions/checkout@v4 + + - name: Common setup + uses: ./.github/workflows/setup + + - name: Set env + run: cp .env.example .env + + - name: Run integration tests + run: yarn test:integration:fork:mainnet + env: + LOG_LEVEL: debug From 5d3dd3c1865d2755c4989a5d0beed000405aa373 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Thu, 6 Feb 2025 18:10:12 +0000 Subject: [PATCH 45/70] chore: refactor the threshold calculation --- contracts/0.8.25/Accounting.sol | 14 ++++---------- contracts/0.8.25/vaults/VaultHub.sol | 20 +++++++------------- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/contracts/0.8.25/Accounting.sol b/contracts/0.8.25/Accounting.sol index 86c25b854..ea46cae6a 100644 --- a/contracts/0.8.25/Accounting.sol +++ b/contracts/0.8.25/Accounting.sol @@ -69,8 +69,6 @@ contract Accounting is VaultHub { uint256 postTotalPooledEther; /// @notice amount of ether to be locked in the vaults uint256[] vaultsLockedEther; - /// @notice amount of ether to be locked in the vaults - uint256[] vaultsThresholdEther; /// @notice amount of shares to be minted as vault fees to the treasury uint256[] vaultsTreasuryFeeShares; /// @notice total amount of shares to be minted as vault fees to the treasury @@ -227,12 +225,7 @@ contract Accounting is VaultHub { // Calculate the amount of ether locked in the vaults to back external balance of stETH // and the amount of shares to mint as fees to the treasury for each vaults - ( - update.vaultsLockedEther, - update.vaultsThresholdEther, - update.vaultsTreasuryFeeShares, - update.totalVaultsTreasuryFeeShares - ) = + (update.vaultsLockedEther, update.vaultsTreasuryFeeShares, update.totalVaultsTreasuryFeeShares) = _calculateVaultsRebase( update.postTotalShares, update.postTotalPooledEther, @@ -346,8 +339,9 @@ contract Accounting is VaultHub { _report.vaultValues, _report.inOutDeltas, _update.vaultsLockedEther, - _update.vaultsThresholdEther, - _update.vaultsTreasuryFeeShares + _update.vaultsTreasuryFeeShares, + _update.postTotalPooledEther, + _update.postTotalShares ); if (_update.totalVaultsTreasuryFeeShares > 0) { diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 8159a0dd4..f37755bb8 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -405,12 +405,7 @@ abstract contract VaultHub is PausableUntilWithRoles { uint256 _preTotalShares, uint256 _preTotalPooledEther, uint256 _sharesToMintAsFees - ) internal view returns ( - uint256[] memory lockedEther, - uint256[] memory thresholdEther, - uint256[] memory treasuryFeeShares, - uint256 totalTreasuryFeeShares - ) { + ) internal view returns (uint256[] memory lockedEther, uint256[] memory treasuryFeeShares, uint256 totalTreasuryFeeShares) { /// HERE WILL BE ACCOUNTING DRAGON // \||/ @@ -431,7 +426,6 @@ abstract contract VaultHub is PausableUntilWithRoles { treasuryFeeShares = new uint256[](length); lockedEther = new uint256[](length); - thresholdEther = new uint256[](length); for (uint256 i = 0; i < length; ++i) { VaultSocket memory socket = $.sockets[i + 1]; @@ -452,9 +446,6 @@ abstract contract VaultHub is PausableUntilWithRoles { (mintedStETH * TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - socket.reserveRatioBP), CONNECT_DEPOSIT ); - - // Minimum amount of ether that should be in the vault to avoid unbalanced state - thresholdEther[i] = (mintedStETH * TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - socket.reserveRatioThresholdBP); } } } @@ -493,8 +484,9 @@ abstract contract VaultHub is PausableUntilWithRoles { uint256[] memory _valuations, int256[] memory _inOutDeltas, uint256[] memory _locked, - uint256[] memory _thresholds, - uint256[] memory _treasureFeeShares + uint256[] memory _treasureFeeShares, + uint256 _postTotalPooledEther, + uint256 _postTotalShares ) internal { VaultHubStorage storage $ = _getVaultHubStorage(); @@ -508,7 +500,9 @@ abstract contract VaultHub is PausableUntilWithRoles { socket.sharesMinted += uint96(treasuryFeeShares); } - _epicrisis(_valuations[i], _thresholds[i], socket); + uint256 mintedStETH = (socket.sharesMinted * _postTotalPooledEther) / _postTotalShares; //TODO: check rounding + uint256 threshold = (mintedStETH * TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - socket.reserveRatioThresholdBP); + _epicrisis(_valuations[i], threshold, socket); IStakingVault(socket.vault).report(_valuations[i], _inOutDeltas[i], _locked[i]); From c2facaf577b4827d98cc1b4ace83df49e182e6f6 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Fri, 7 Feb 2025 12:29:54 +0000 Subject: [PATCH 46/70] chore: fix assessment --- contracts/0.8.25/interfaces/IStakingRouter.sol | 2 +- contracts/0.8.25/vaults/VaultHub.sol | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/0.8.25/interfaces/IStakingRouter.sol b/contracts/0.8.25/interfaces/IStakingRouter.sol index b50685970..27a6e1e22 100644 --- a/contracts/0.8.25/interfaces/IStakingRouter.sol +++ b/contracts/0.8.25/interfaces/IStakingRouter.sol @@ -16,5 +16,5 @@ interface IStakingRouter { uint256 precisionPoints ); - function reportRewardsMinted(uint256[] memory _stakingModuleIds, uint256[] memory _totalShares) external; + function reportRewardsMinted(uint256[] calldata _stakingModuleIds, uint256[] calldata _totalShares) external; } diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index f37755bb8..b73a509b9 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -500,11 +500,10 @@ abstract contract VaultHub is PausableUntilWithRoles { socket.sharesMinted += uint96(treasuryFeeShares); } - uint256 mintedStETH = (socket.sharesMinted * _postTotalPooledEther) / _postTotalShares; //TODO: check rounding + uint256 mintedStETH = (socket.sharesMinted * _postTotalPooledEther) / _postTotalShares; //TODO: Should use round up? uint256 threshold = (mintedStETH * TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - socket.reserveRatioThresholdBP); _epicrisis(_valuations[i], threshold, socket); - IStakingVault(socket.vault).report(_valuations[i], _inOutDeltas[i], _locked[i]); } @@ -527,7 +526,8 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @notice Evaluates if vault's valuation meets minimum threshold and marks it as unbalanced if below threshold function _vaultAssessment(address _vault, VaultSocket storage _socket) internal { uint256 valuation = IStakingVault(_vault).valuation(); - uint256 threshold = (_socket.sharesMinted * TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - _socket.reserveRatioThresholdBP); + uint256 mintedStETH = STETH.getPooledEthByShares(_socket.sharesMinted); //TODO: Should use round up? + uint256 threshold = (mintedStETH * TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - _socket.reserveRatioThresholdBP); _epicrisis(valuation, threshold, _socket); } From 0b919314fadc822d821af7d85c0a13bceb561701 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Fri, 7 Feb 2025 17:17:12 +0000 Subject: [PATCH 47/70] chore: update dashboarad --- contracts/0.8.25/Accounting.sol | 4 +- contracts/0.8.25/vaults/Dashboard.sol | 41 ++- contracts/0.8.25/vaults/Permissions.sol | 18 +- contracts/0.8.25/vaults/VaultFactory.sol | 8 +- contracts/0.8.25/vaults/VaultHub.sol | 113 ++------- .../vaults/interfaces/IStakingVault.sol | 14 +- .../StakingVault__HarnessForTestUpgrade.sol | 30 +-- .../VaultFactory__MockForDashboard.sol | 4 +- .../0.8.25/vaults/dashboard/dashboard.test.ts | 66 ++--- .../vaults/delegation/delegation.test.ts | 15 +- .../vaulthub/vaulthub.withdrawals.test.ts | 240 ++++-------------- 11 files changed, 156 insertions(+), 397 deletions(-) diff --git a/contracts/0.8.25/Accounting.sol b/contracts/0.8.25/Accounting.sol index ea46cae6a..a875110af 100644 --- a/contracts/0.8.25/Accounting.sol +++ b/contracts/0.8.25/Accounting.sol @@ -339,9 +339,7 @@ contract Accounting is VaultHub { _report.vaultValues, _report.inOutDeltas, _update.vaultsLockedEther, - _update.vaultsTreasuryFeeShares, - _update.postTotalPooledEther, - _update.postTotalShares + _update.vaultsTreasuryFeeShares ); if (_update.totalVaultsTreasuryFeeShares > 0) { diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 2e11110ed..1dfc27a05 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -167,14 +167,6 @@ contract Dashboard is Permissions { return stakingVault().valuation(); } - /** - * @notice Returns the time when the vault became unbalanced. - * @return The time when the vault became unbalanced as a uint40. - */ - function unbalancedSince() external view returns (uint40) { - return vaultSocket().unbalancedSince; - } - /** * @notice Returns the overall capacity of stETH shares that can be minted by the vault bound by valuation and vault share limit. * @return The maximum number of mintable stETH shares not counting already minted ones. @@ -463,29 +455,26 @@ contract Dashboard is Permissions { } /** - * @notice Requests validators exit for the given validator public keys. - * @param _pubkeys The public keys of the validators to request exit for. - * @dev This only emits an event requesting the exit, it does not actually initiate the exit. - */ - function requestValidatorExit(bytes calldata _pubkeys) external { - _requestValidatorExit(_pubkeys); - } - - /** - * @notice Initiates a full validator withdrawal for the given validator public keys. - * @param _pubkeys The public keys of the validators to initiate withdrawal for. + * @notice Signals to node operators that specific validators should exit from the beacon chain. + * It does not directly trigger exits - node operators must monitor for these events and handle the exits manually. + * @param _pubkeys Concatenated validator public keys, each 48 bytes long. + * @dev Emits `ValidatorMarkedForExit` event for each validator public key through the StakingVault + * This is a voluntary exit request - node operators can choose whether to act on it. */ - function initiateFullValidatorWithdrawal(bytes calldata _pubkeys) external payable { - _initiateFullValidatorWithdrawal(_pubkeys); + function markValidatorsForExit(bytes calldata _pubkeys) external { + _markValidatorsForExit(_pubkeys); } /** - * @notice Initiates a partial validator withdrawal for the given validator public keys and amounts. - * @param _pubkeys The public keys of the validators to initiate withdrawal for. - * @param _amounts The amounts of the validators to initiate withdrawal for. + * @notice Requests validator withdrawals via EIP-7002 triggerable exit mechanism. This allows withdrawing either the full + * validator balance or a partial amount from each validator specified. + * @param _pubkeys The concatenated public keys of the validators to request withdrawal for. Each key must be 48 bytes. + * @param _amounts The withdrawal amounts in wei for each validator. Must match the length of _pubkeys. + * @param _refundRecipient The address that will receive any fee refunds. + * @dev Requires payment of withdrawal fee which is calculated based on the number of validators and must be paid in msg.value. */ - function initiatePartialValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable { - _initiatePartialValidatorWithdrawal(_pubkeys, _amounts); + function requestValidatorWithdrawals(bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient) external payable { + _requestValidatorWithdrawals(_pubkeys, _amounts, _refundRecipient); } // ==================== Role Management Functions ==================== diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index 4cfcae3f4..f8e4efbcb 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -55,14 +55,14 @@ abstract contract Permissions is AccessControlVoteable { keccak256("StakingVault.Permissions.ResumeBeaconChainDeposits"); /** - * @notice Permission for requesting validator exit from the StakingVault. + * @notice Permission for marking validators for exit from the StakingVault. */ - bytes32 public constant REQUEST_VALIDATOR_EXIT_ROLE = keccak256("StakingVault.Permissions.RequestValidatorExit"); + bytes32 public constant MARK_VALIDATORS_FOR_EXIT_ROLE = keccak256("StakingVault.Permissions.MarkValidatorsForExit"); /** * @notice Permission for force validators exit from the StakingVault using EIP-7002 triggerable exit. */ - bytes32 public constant INITIATE_VALIDATOR_WITHDRAWAL_ROLE = keccak256("StakingVault.Permissions.InitiateValidatorWithdrawal"); + bytes32 public constant REQUEST_VALIDATOR_WITHDRAWALS_ROLE = keccak256("StakingVault.Permissions.RequestValidatorWithdrawals"); /** * @notice Permission for voluntary disconnecting the StakingVault. @@ -146,16 +146,12 @@ abstract contract Permissions is AccessControlVoteable { stakingVault().resumeBeaconChainDeposits(); } - function _requestValidatorExit(bytes calldata _pubkey) internal onlyRole(REQUEST_VALIDATOR_EXIT_ROLE) { - stakingVault().requestValidatorExit(_pubkey); + function _markValidatorsForExit(bytes calldata _pubkeys) internal onlyRole(MARK_VALIDATORS_FOR_EXIT_ROLE) { + stakingVault().markValidatorsForExit(_pubkeys); } - function _initiateFullValidatorWithdrawal(bytes calldata _pubkeys) internal onlyRole(INITIATE_VALIDATOR_WITHDRAWAL_ROLE) { - stakingVault().initiateFullValidatorWithdrawal{value: msg.value}(_pubkeys); - } - - function _initiatePartialValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) internal onlyRole(INITIATE_VALIDATOR_WITHDRAWAL_ROLE) { - stakingVault().initiatePartialValidatorWithdrawal{value: msg.value}(_pubkeys, _amounts); + function _requestValidatorWithdrawals(bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient) internal onlyRole(REQUEST_VALIDATOR_WITHDRAWALS_ROLE) { + stakingVault().requestValidatorWithdrawals{value: msg.value}(_pubkeys, _amounts, _refundRecipient); } function _voluntaryDisconnect() internal onlyRole(VOLUNTARY_DISCONNECT_ROLE) { diff --git a/contracts/0.8.25/vaults/VaultFactory.sol b/contracts/0.8.25/vaults/VaultFactory.sol index cd4968a89..6691c98e7 100644 --- a/contracts/0.8.25/vaults/VaultFactory.sol +++ b/contracts/0.8.25/vaults/VaultFactory.sol @@ -19,8 +19,8 @@ struct DelegationConfig { address rebalancer; address depositPauser; address depositResumer; - address exitRequester; - address withdrawalInitiator; + address validatorExitRequester; + address validatorWithdrawalRequester; address disconnecter; address curator; address nodeOperatorManager; @@ -78,8 +78,8 @@ contract VaultFactory { delegation.grantRole(delegation.REBALANCE_ROLE(), _delegationConfig.rebalancer); delegation.grantRole(delegation.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), _delegationConfig.depositPauser); delegation.grantRole(delegation.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), _delegationConfig.depositResumer); - delegation.grantRole(delegation.REQUEST_VALIDATOR_EXIT_ROLE(), _delegationConfig.exitRequester); - delegation.grantRole(delegation.INITIATE_VALIDATOR_WITHDRAWAL_ROLE(), _delegationConfig.withdrawalInitiator); + delegation.grantRole(delegation.MARK_VALIDATORS_FOR_EXIT_ROLE(), _delegationConfig.validatorExitRequester); + delegation.grantRole(delegation.REQUEST_VALIDATOR_WITHDRAWALS_ROLE(), _delegationConfig.validatorWithdrawalRequester); delegation.grantRole(delegation.VOLUNTARY_DISCONNECT_ROLE(), _delegationConfig.disconnecter); delegation.grantRole(delegation.CURATOR_ROLE(), _delegationConfig.curator); delegation.grantRole(delegation.NODE_OPERATOR_MANAGER_ROLE(), _delegationConfig.nodeOperatorManager); diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index b73a509b9..be4347296 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -49,10 +49,8 @@ abstract contract VaultHub is PausableUntilWithRoles { uint16 treasuryFeeBP; /// @notice if true, vault is disconnected and fee is not accrued bool isDisconnected; - /// @notice timestamp when the vault became unbalanced - /// @dev 0 if the vault is currently balanced - uint40 unbalancedSince; - // ### we have 64 bits left in this slot + /// @notice unused gap in the slot 2 + /// uint104 _unused_gap_; } // keccak256(abi.encode(uint256(keccak256("VaultHub")) - 1)) & ~bytes32(uint256(0xff)) @@ -72,9 +70,6 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @notice amount of ETH that is locked on the vault on connect and can be withdrawn on disconnect only uint256 internal constant CONNECT_DEPOSIT = 1 ether; - /// @notice Time-lock for force validator withdrawal - uint40 public constant FORCE_WITHDRAWAL_TIMELOCK = 3 days; - /// @notice Lido stETH contract IStETH public immutable STETH; @@ -89,7 +84,7 @@ abstract contract VaultHub is PausableUntilWithRoles { function __VaultHub_init(address _admin) internal onlyInitializing { __AccessControlEnumerable_init(); // the stone in the elevator - _getVaultHubStorage().sockets.push(VaultSocket(address(0), 0, 0, 0, 0, 0, false, 0)); + _getVaultHubStorage().sockets.push(VaultSocket(address(0), 0, 0, 0, 0, 0, false)); _grantRole(DEFAULT_ADMIN_ROLE, _admin); } @@ -165,8 +160,7 @@ abstract contract VaultHub is PausableUntilWithRoles { uint16(_reserveRatioBP), uint16(_reserveRatioThresholdBP), uint16(_treasuryFeeBP), - false, // isDisconnected - 0 // unbalancedSince + false // isDisconnected ); $.vaultIndex[_vault] = $.sockets.length; $.sockets.push(vr); @@ -233,11 +227,10 @@ abstract contract VaultHub is PausableUntilWithRoles { if (vaultSharesAfterMint > shareLimit) revert ShareLimitExceeded(_vault, shareLimit); uint256 reserveRatioBP = socket.reserveRatioBP; - uint256 valuation = IStakingVault(_vault).valuation(); - uint256 maxMintableShares = _maxMintableShares(valuation, reserveRatioBP, shareLimit); + uint256 maxMintableShares = _maxMintableShares(_vault, reserveRatioBP, shareLimit); if (vaultSharesAfterMint > maxMintableShares) { - revert InsufficientValuationToMint(_vault, valuation); + revert InsufficientValuationToMint(_vault, IStakingVault(_vault).valuation()); } socket.sharesMinted = uint96(vaultSharesAfterMint); @@ -273,8 +266,6 @@ abstract contract VaultHub is PausableUntilWithRoles { STETH.burnExternalShares(_amountOfShares); - _vaultAssessment(_vault, socket); - emit BurnedSharesOnVault(_vault, _amountOfShares); } @@ -294,12 +285,11 @@ abstract contract VaultHub is PausableUntilWithRoles { VaultSocket storage socket = _connectedSocket(_vault); - uint256 valuation = IStakingVault(_vault).valuation(); - uint256 threshold = _maxMintableShares(valuation, socket.reserveRatioThresholdBP, socket.shareLimit); + uint256 threshold = _maxMintableShares(_vault, socket.reserveRatioThresholdBP, socket.shareLimit); uint256 sharesMinted = socket.sharesMinted; if (sharesMinted <= threshold) { - // NOTE!: on connect vault is always balanced - revert AlreadyBalanced(_vault, sharesMinted, threshold); + // NOTE!: on connect vault is always healthy + revert AlreadyHealthy(_vault, sharesMinted, threshold); } uint256 mintedStETH = STETH.getPooledEthByShares(sharesMinted); // TODO: fix rounding issue @@ -318,12 +308,11 @@ abstract contract VaultHub is PausableUntilWithRoles { // reserveRatio = BPS_BASE - maxMintableRatio // X = (mintedStETH * BPS_BASE - vault.valuation() * maxMintableRatio) / reserveRatio - uint256 amountToRebalance = (mintedStETH * TOTAL_BASIS_POINTS - valuation * maxMintableRatio) / reserveRatioBP; + uint256 amountToRebalance = (mintedStETH * TOTAL_BASIS_POINTS - + IStakingVault(_vault).valuation() * maxMintableRatio) / reserveRatioBP; // TODO: add some gas compensation here IStakingVault(_vault).rebalance(amountToRebalance); - - // NB: check _updateUnbalancedSince is calculated in rebalance() triggered from the `StakingVault`. } /// @notice rebalances the vault by writing off the amount of ether equal @@ -342,45 +331,31 @@ abstract contract VaultHub is PausableUntilWithRoles { STETH.rebalanceExternalEtherToInternal{value: msg.value}(); - _vaultAssessment(msg.sender, socket); - emit VaultRebalanced(msg.sender, sharesToBurn); } - /// @notice checks if the vault can force withdraw - /// @param _vault vault address - /// @return bool whether the vault can force withdraw - function canForceValidatorWithdrawal(address _vault) public view returns (bool) { - uint40 unbalancedSince = _connectedSocket(_vault).unbalancedSince; - - if (unbalancedSince == 0) return false; - - return block.timestamp >= unbalancedSince + FORCE_WITHDRAWAL_TIMELOCK; - } - - /// @notice forces validator withdrawal from the beacon chain in case the vault is unbalanced + /// @notice forces validator withdrawal from the beacon chain in case the vault is unhealthy /// @param _vault vault address /// @param _pubkeys pubkeys of the validators to withdraw - function forceValidatorWithdrawal(address _vault, bytes calldata _pubkeys) external payable { + /// @param _amounts amounts of the validators to withdraw + /// @param _refundRecepient address of the recipient of the refund + /// TODO: do not pass amounts, but calculate them based on the keys number + function forceValidatorWithdrawals(address _vault, bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecepient) external payable { if (msg.value == 0) revert ZeroArgument("msg.value"); if (_vault == address(0)) revert ZeroArgument("_vault"); if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); + if (_amounts.length == 0) revert ZeroArgument("_amounts"); + if (_refundRecepient == address(0)) revert ZeroArgument("_refundRecepient"); VaultSocket storage socket = _connectedSocket(_vault); - - uint256 valuation = IStakingVault(_vault).valuation(); - uint256 threshold = _maxMintableShares(valuation, socket.reserveRatioThresholdBP, socket.shareLimit); + uint256 threshold = _maxMintableShares(_vault, socket.reserveRatioThresholdBP, socket.shareLimit); if (socket.sharesMinted <= threshold) { - revert AlreadyBalanced(_vault, socket.sharesMinted, threshold); - } - - if (!canForceValidatorWithdrawal(_vault)) { - revert ForceWithdrawalTimelockActive(_vault, socket.unbalancedSince + FORCE_WITHDRAWAL_TIMELOCK); + revert AlreadyHealthy(_vault, socket.sharesMinted, threshold); } - IStakingVault(_vault).forceValidatorWithdrawal{value: msg.value}(_pubkeys); + IStakingVault(_vault).requestValidatorWithdrawals{value: msg.value}(_pubkeys, _amounts, _refundRecepient); - emit VaultForceWithdrawalInitiated(_vault, _pubkeys); + emit VaultForceValidatorWithdrawalsRequested(_vault, _pubkeys, _amounts, _refundRecepient); } function _disconnect(address _vault) internal { @@ -484,9 +459,7 @@ abstract contract VaultHub is PausableUntilWithRoles { uint256[] memory _valuations, int256[] memory _inOutDeltas, uint256[] memory _locked, - uint256[] memory _treasureFeeShares, - uint256 _postTotalPooledEther, - uint256 _postTotalShares + uint256[] memory _treasureFeeShares ) internal { VaultHubStorage storage $ = _getVaultHubStorage(); @@ -500,10 +473,6 @@ abstract contract VaultHub is PausableUntilWithRoles { socket.sharesMinted += uint96(treasuryFeeShares); } - uint256 mintedStETH = (socket.sharesMinted * _postTotalPooledEther) / _postTotalShares; //TODO: Should use round up? - uint256 threshold = (mintedStETH * TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - socket.reserveRatioThresholdBP); - _epicrisis(_valuations[i], threshold, socket); - IStakingVault(socket.vault).report(_valuations[i], _inOutDeltas[i], _locked[i]); } @@ -523,30 +492,6 @@ abstract contract VaultHub is PausableUntilWithRoles { } } - /// @notice Evaluates if vault's valuation meets minimum threshold and marks it as unbalanced if below threshold - function _vaultAssessment(address _vault, VaultSocket storage _socket) internal { - uint256 valuation = IStakingVault(_vault).valuation(); - uint256 mintedStETH = STETH.getPooledEthByShares(_socket.sharesMinted); //TODO: Should use round up? - uint256 threshold = (mintedStETH * TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - _socket.reserveRatioThresholdBP); - - _epicrisis(valuation, threshold, _socket); - } - - /// @notice Updates vault's unbalanced state based on if valuation is above/below threshold - function _epicrisis(uint256 _valuation, uint256 _threshold, VaultSocket storage _socket) internal { - if (_valuation < _threshold) { - if (_socket.unbalancedSince == 0) { - _socket.unbalancedSince = uint40(block.timestamp); - emit VaultBecameUnbalanced(address(_socket.vault), _socket.unbalancedSince + FORCE_WITHDRAWAL_TIMELOCK); - } - } else { - if (_socket.unbalancedSince != 0) { - _socket.unbalancedSince = 0; - emit VaultBecameBalanced(address(_socket.vault)); - } - } - } - function _vaultAuth(address _vault, string memory _operation) internal view { if (msg.sender != OwnableUpgradeable(_vault).owner()) revert NotAuthorized(_operation, msg.sender); } @@ -560,8 +505,9 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @dev returns total number of stETH shares that is possible to mint on the provided vault with provided reserveRatio /// it does not count shares that is already minted, but does count shareLimit on the vault - function _maxMintableShares(uint256 _valuation, uint256 _reserveRatio, uint256 _shareLimit) internal view returns (uint256) { - uint256 maxStETHMinted = (_valuation * (TOTAL_BASIS_POINTS - _reserveRatio)) / TOTAL_BASIS_POINTS; + function _maxMintableShares(address _vault, uint256 _reserveRatio, uint256 _shareLimit) internal view returns (uint256) { + uint256 maxStETHMinted = (IStakingVault(_vault).valuation() * (TOTAL_BASIS_POINTS - _reserveRatio)) / + TOTAL_BASIS_POINTS; return Math256.min(STETH.getSharesByPooledEth(maxStETHMinted), _shareLimit); } @@ -588,12 +534,10 @@ abstract contract VaultHub is PausableUntilWithRoles { event BurnedSharesOnVault(address indexed vault, uint256 amountOfShares); event VaultRebalanced(address indexed vault, uint256 sharesBurned); event VaultProxyCodehashAdded(bytes32 indexed codehash); - event VaultForceWithdrawalInitiated(address indexed vault, bytes pubkeys); - event VaultBecameUnbalanced(address indexed vault, uint40 unlockTime); - event VaultBecameBalanced(address indexed vault); + event VaultForceValidatorWithdrawalsRequested(address indexed vault, bytes pubkeys, uint64[] amounts, address refundRecepient); error StETHMintFailed(address vault); - error AlreadyBalanced(address vault, uint256 mintedShares, uint256 rebalancingThresholdInShares); + error AlreadyHealthy(address vault, uint256 mintedShares, uint256 rebalancingThresholdInShares); error InsufficientSharesToBurn(address vault, uint256 amount); error ShareLimitExceeded(address vault, uint256 capShares); error AlreadyConnected(address vault, uint256 index); @@ -611,5 +555,4 @@ abstract contract VaultHub is PausableUntilWithRoles { error AlreadyExists(bytes32 codehash); error NoMintedSharesShouldBeLeft(address vault, uint256 sharesMinted); error VaultProxyNotAllowed(address beacon); - error ForceWithdrawalTimelockActive(address vault, uint256 unlockTime); } diff --git a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol index 3345771bf..7d2a2cabf 100644 --- a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol +++ b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol @@ -46,18 +46,18 @@ interface IStakingVault { function latestReport() external view returns (Report memory); function report(uint256 _valuation, int256 _inOutDelta, uint256 _locked) external; - function depositContract() external view returns (address); function withdrawalCredentials() external view returns (bytes32); function beaconChainDepositsPaused() external view returns (bool); function pauseBeaconChainDeposits() external; function resumeBeaconChainDeposits() external; function depositToBeaconChain(Deposit[] calldata _deposits) external; - function requestValidatorExit(bytes calldata _pubkeys) external; + function markValidatorsForExit(bytes calldata _pubkeys) external; - function calculateValidatorWithdrawalFee(uint256 _validatorCount) external view returns (uint256); - function initiateFullValidatorWithdrawal(bytes calldata _pubkeys) external payable; - function initiatePartialValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable; - - function forceValidatorWithdrawal(bytes calldata _pubkeys) external payable; + function calculateValidatorWithdrawalsFee(uint256 _keysCount) external view returns (uint256); + function requestValidatorWithdrawals( + bytes calldata _pubkeys, + uint64[] calldata _amounts, + address _refundRecipient + ) external payable; } diff --git a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol index ae3f64902..71033cbb1 100644 --- a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol +++ b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol @@ -19,9 +19,10 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl } uint64 private constant _version = 2; - address public immutable beaconChainDepositContract; VaultHub private immutable VAULT_HUB; + address public immutable DEPOSIT_CONTRACT; + /// keccak256(abi.encode(uint256(keccak256("StakingVault.Vault")) - 1)) & ~bytes32(uint256(0xff)); bytes32 private constant VAULT_STORAGE_LOCATION = 0xe1d42fabaca5dacba3545b34709222773cbdae322fef5b060e1d691bf0169000; @@ -30,7 +31,7 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl if (_vaultHub == address(0)) revert ZeroArgument("_vaultHub"); if (_beaconChainDepositContract == address(0)) revert ZeroArgument("_beaconChainDepositContract"); - beaconChainDepositContract = _beaconChainDepositContract; + DEPOSIT_CONTRACT = _beaconChainDepositContract; VAULT_HUB = VaultHub(_vaultHub); // Prevents reinitialization of the implementation @@ -68,10 +69,6 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl return _version; } - function depositContract() external view returns (address) { - return beaconChainDepositContract; - } - function latestReport() external view returns (IStakingVault.Report memory) { VaultStorage storage $ = _getVaultStorage(); return IStakingVault.Report({valuation: $.report.valuation, inOutDelta: $.report.inOutDelta}); @@ -115,25 +112,28 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl function withdraw(address _recipient, uint256 _ether) external {} function withdrawalCredentials() external view returns (bytes32) { - return bytes32((0x01 << 248) + uint160(address(this))); + return bytes32((0x02 << 248) + uint160(address(this))); } function beaconChainDepositsPaused() external pure returns (bool) { return false; } - function calculateValidatorWithdrawalFee(uint256) external pure returns (uint256) { - return 1; - } - function pauseBeaconChainDeposits() external {} function resumeBeaconChainDeposits() external {} - function requestValidatorExit(bytes calldata _pubkeys) external {} - function initiateFullValidatorWithdrawal(bytes calldata _pubkeys) external payable {} - function initiatePartialValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable {} + function calculateValidatorWithdrawalsFee(uint256) external pure returns (uint256) { + return 1; + } + + function markValidatorsForExit(bytes calldata _pubkeys) external {} + function requestValidatorWithdrawals( + bytes calldata _pubkeys, + uint64[] calldata _amounts, + address _recipient + ) external payable {} - function forceValidatorWithdrawal(bytes calldata _pubkeys) external payable {} + function forceValidatorWithdrawals(bytes calldata _pubkeys) external payable {} error ZeroArgument(string name); error VaultAlreadyInitialized(); diff --git a/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol b/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol index 4c0ea63be..f3bdd03b9 100644 --- a/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol +++ b/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol @@ -37,8 +37,8 @@ contract VaultFactory__MockForDashboard is UpgradeableBeacon { dashboard.grantRole(dashboard.REBALANCE_ROLE(), msg.sender); dashboard.grantRole(dashboard.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), msg.sender); dashboard.grantRole(dashboard.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), msg.sender); - dashboard.grantRole(dashboard.REQUEST_VALIDATOR_EXIT_ROLE(), msg.sender); - dashboard.grantRole(dashboard.INITIATE_VALIDATOR_WITHDRAWAL_ROLE(), msg.sender); + dashboard.grantRole(dashboard.MARK_VALIDATORS_FOR_EXIT_ROLE(), msg.sender); + dashboard.grantRole(dashboard.REQUEST_VALIDATOR_WITHDRAWALS_ROLE(), msg.sender); dashboard.grantRole(dashboard.VOLUNTARY_DISCONNECT_ROLE(), msg.sender); dashboard.revokeRole(dashboard.DEFAULT_ADMIN_ROLE(), address(this)); diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 31b4a7da1..0c478566c 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -179,7 +179,6 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -190,7 +189,6 @@ describe("Dashboard.sol", () => { expect(await dashboard.reserveRatioBP()).to.equal(sockets.reserveRatioBP); expect(await dashboard.thresholdReserveRatioBP()).to.equal(sockets.reserveRatioThresholdBP); expect(await dashboard.treasuryFee()).to.equal(sockets.treasuryFeeBP); - expect(await dashboard.unbalancedSince()).to.equal(sockets.unbalancedSince); }); }); @@ -217,7 +215,6 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -240,7 +237,6 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -261,7 +257,6 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -282,7 +277,6 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 0n, treasuryFeeBP: 500n, isDisconnected: false, - unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -311,7 +305,6 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -338,7 +331,6 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -362,7 +354,6 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -384,7 +375,6 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -409,7 +399,6 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -635,52 +624,49 @@ describe("Dashboard.sol", () => { }); }); - context("requestValidatorExit", () => { - const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); + context("markValidatorsForExit", () => { + const pubkeys = ["01".repeat(48), "02".repeat(48)]; + const pubkeysConcat = `0x${pubkeys.join("")}`; + it("reverts if called by a non-admin", async () => { - await expect(dashboard.connect(stranger).requestValidatorExit(validatorPublicKeys)).to.be.revertedWithCustomError( + await expect(dashboard.connect(stranger).markValidatorsForExit(pubkeysConcat)).to.be.revertedWithCustomError( dashboard, "AccessControlUnauthorizedAccount", ); }); - it("requests the exit of a validator", async () => { - await expect(dashboard.requestValidatorExit(validatorPublicKeys)) - .to.emit(vault, "ValidatorExitRequested") - .withArgs(dashboard, validatorPublicKeys); + it("signals the requested exit of a validator", async () => { + await expect(dashboard.markValidatorsForExit(pubkeysConcat)) + .to.emit(vault, "ValidatorMarkedForExit") + .withArgs(dashboard, `0x${pubkeys[0]}`) + .to.emit(vault, "ValidatorMarkedForExit") + .withArgs(dashboard, `0x${pubkeys[1]}`); }); }); - context("initiateFullValidatorWithdrawal", () => { - const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); - + context("requestValidatorWithdrawals", () => { it("reverts if called by a non-admin", async () => { await expect( - dashboard.connect(stranger).initiateFullValidatorWithdrawal(validatorPublicKeys), + dashboard.connect(stranger).requestValidatorWithdrawals("0x", [0n], vaultOwner), ).to.be.revertedWithCustomError(dashboard, "AccessControlUnauthorizedAccount"); }); - it("initiates a full validator withdrawal", async () => { - await expect(dashboard.initiateFullValidatorWithdrawal(validatorPublicKeys, { value: FEE })) - .to.emit(vault, "FullValidatorWithdrawalInitiated") - .withArgs(dashboard, validatorPublicKeys); - }); - }); - - context("initiatePartialValidatorWithdrawal", () => { - const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); - const amounts = [ether("0.1")]; + it("requests a full validator withdrawal", async () => { + const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); + const amounts = [0n]; // 0 amount means full withdrawal - it("reverts if called by a non-admin", async () => { - await expect( - dashboard.connect(stranger).initiatePartialValidatorWithdrawal(validatorPublicKeys, amounts), - ).to.be.revertedWithCustomError(dashboard, "AccessControlUnauthorizedAccount"); + await expect(dashboard.requestValidatorWithdrawals(validatorPublicKeys, amounts, vaultOwner, { value: FEE })) + .to.emit(vault, "ValidatorWithdrawalsRequested") + .withArgs(dashboard, validatorPublicKeys, amounts, vaultOwner, 0n); }); - it("initiates a partial validator withdrawal", async () => { - await expect(dashboard.initiatePartialValidatorWithdrawal(validatorPublicKeys, amounts, { value: FEE })) - .to.emit(vault, "PartialValidatorWithdrawalInitiated") - .withArgs(dashboard, validatorPublicKeys, amounts); + it("requests a partial validator withdrawal", async () => { + const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); + const amounts = [ether("0.1")]; + + await expect(dashboard.requestValidatorWithdrawals(validatorPublicKeys, amounts, vaultOwner, { value: FEE })) + .to.emit(vault, "ValidatorWithdrawalsRequested") + .withArgs(dashboard, validatorPublicKeys, amounts, vaultOwner, 0n); }); }); diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index b523fd503..6ef53b507 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -34,8 +34,8 @@ describe("Delegation.sol", () => { let rebalancer: HardhatEthersSigner; let depositPauser: HardhatEthersSigner; let depositResumer: HardhatEthersSigner; - let exitRequester: HardhatEthersSigner; - let withdrawalInitiator: HardhatEthersSigner; + let validatorExitRequester: HardhatEthersSigner; + let validatorWithdrawalRequester: HardhatEthersSigner; let disconnecter: HardhatEthersSigner; let curator: HardhatEthersSigner; let nodeOperatorManager: HardhatEthersSigner; @@ -71,8 +71,8 @@ describe("Delegation.sol", () => { rebalancer, depositPauser, depositResumer, - exitRequester, - withdrawalInitiator, + validatorExitRequester, + validatorWithdrawalRequester, disconnecter, curator, nodeOperatorManager, @@ -114,8 +114,8 @@ describe("Delegation.sol", () => { rebalancer, depositPauser, depositResumer, - exitRequester, - withdrawalInitiator, + validatorExitRequester, + validatorWithdrawalRequester, disconnecter, curator, nodeOperatorManager, @@ -205,7 +205,8 @@ describe("Delegation.sol", () => { await assertSoleMember(rebalancer, await delegation.REBALANCE_ROLE()); await assertSoleMember(depositPauser, await delegation.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE()); await assertSoleMember(depositResumer, await delegation.RESUME_BEACON_CHAIN_DEPOSITS_ROLE()); - await assertSoleMember(exitRequester, await delegation.REQUEST_VALIDATOR_EXIT_ROLE()); + await assertSoleMember(validatorExitRequester, await delegation.MARK_VALIDATORS_FOR_EXIT_ROLE()); + await assertSoleMember(validatorWithdrawalRequester, await delegation.REQUEST_VALIDATOR_WITHDRAWALS_ROLE()); await assertSoleMember(disconnecter, await delegation.VOLUNTARY_DISCONNECT_ROLE()); await assertSoleMember(curator, await delegation.CURATOR_ROLE()); await assertSoleMember(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE()); diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts index 0a926253e..046257b0c 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts @@ -6,7 +6,7 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { DepositContract, StakingVault, StETH__HarnessForVaultHub, VaultHub } from "typechain-types"; -import { advanceChainTime, getCurrentBlockTimestamp, impersonate } from "lib"; +import { impersonate } from "lib"; import { findEvents } from "lib/event"; import { ether } from "lib/units"; @@ -20,15 +20,13 @@ const RESERVE_RATIO_BP = 10_00n; const RESERVE_RATIO_THRESHOLD_BP = 8_00n; const TREASURY_FEE_BP = 5_00n; -const FORCE_WITHDRAWAL_TIMELOCK = BigInt(3 * 24 * 60 * 60); - const FEE = 2n; describe("VaultHub.sol:withdrawals", () => { let deployer: HardhatEthersSigner; let user: HardhatEthersSigner; let stranger: HardhatEthersSigner; - + let feeRecipient: HardhatEthersSigner; let vaultHub: VaultHub; let vault: StakingVault; let steth: StETH__HarnessForVaultHub; @@ -37,13 +35,12 @@ describe("VaultHub.sol:withdrawals", () => { let vaultAddress: string; let vaultHubAddress: string; - let vaultSigner: HardhatEthersSigner; let vaultHubSigner: HardhatEthersSigner; let originalState: string; before(async () => { - [deployer, user, stranger] = await ethers.getSigners(); + [deployer, user, stranger, feeRecipient] = await ethers.getSigners(); await deployWithdrawalsPreDeployedMock(FEE); @@ -88,7 +85,6 @@ describe("VaultHub.sol:withdrawals", () => { .connectVault(vaultAddress, SHARE_LIMIT, RESERVE_RATIO_BP, RESERVE_RATIO_THRESHOLD_BP, TREASURY_FEE_BP); vaultHubSigner = await impersonate(vaultHubAddress, ether("100")); - vaultSigner = await impersonate(vaultAddress, ether("100")); }); beforeEach(async () => (originalState = await Snapshot.take())); @@ -104,94 +100,39 @@ describe("VaultHub.sol:withdrawals", () => { await vault.connect(vaultHubSigner).report(ether("0.9"), ether("1"), ether("1.1")); // slashing }; - // Simulate getting in the unbalanced state and reporting it - const reportUnbalancedVault = async (): Promise => { - await makeVaultUnbalanced(); - - const tx = await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); - const events = await findEvents((await tx.wait()) as ContractTransactionReceipt, "VaultBecameUnbalanced"); - - return events[0].args.unlockTime; - }; - - context("canForceValidatorWithdrawal", () => { - it("reverts if the vault is not connected to the hub", async () => { - await expect(vaultHub.canForceValidatorWithdrawal(stranger)).to.be.revertedWithCustomError( - vaultHub, - "NotConnectedToHub", - ); - }); - - it("reverts if called on a disconnected vault", async () => { - await vaultHub.connect(user).disconnect(vaultAddress); - - await expect(vaultHub.canForceValidatorWithdrawal(stranger)).to.be.revertedWithCustomError( - vaultHub, - "NotConnectedToHub", - ); - }); - - it("returns false if the vault is balanced", async () => { - expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.false; - }); - - it("returns false if the vault is unbalanced and the time is not yet reached", async () => { - await reportUnbalancedVault(); - - expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.false; - }); - - it("returns true if the vault is unbalanced and the time is reached", async () => { - const unbalancedUntil = await reportUnbalancedVault(); - const future = unbalancedUntil + 1000n; - - await advanceChainTime(future); - - expect(await getCurrentBlockTimestamp()).to.be.gt(future); - expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.true; - }); - - it("returns correct values for border cases", async () => { - const unbalancedUntil = await reportUnbalancedVault(); - - // 1 second before the unlock time - await advanceChainTime(unbalancedUntil - (await getCurrentBlockTimestamp()) - 1n); - expect(await getCurrentBlockTimestamp()).to.be.lt(unbalancedUntil); - expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.false; - - // exactly the unlock time - await advanceChainTime(1n); - expect(await getCurrentBlockTimestamp()).to.be.eq(unbalancedUntil); - expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.true; - - // 1 second after the unlock time - await advanceChainTime(1n); - expect(await getCurrentBlockTimestamp()).to.be.gt(unbalancedUntil); - expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.true; - }); - }); - - context("forceValidatorWithdrawal", () => { + context("forceValidatorWithdrawals", () => { it("reverts if msg.value is 0", async () => { - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: 0n })) + await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient, { value: 0n })) .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") .withArgs("msg.value"); }); it("reverts if the vault is zero address", async () => { - await expect(vaultHub.forceValidatorWithdrawal(ZeroAddress, SAMPLE_PUBKEY, { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawals(ZeroAddress, SAMPLE_PUBKEY, [0n], feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") .withArgs("_vault"); }); it("reverts if zero pubkeys", async () => { - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, "0x", { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, "0x", [0n], feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") .withArgs("_pubkeys"); }); + it("reverts if zero amounts", async () => { + await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [], feeRecipient, { value: 1n })) + .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") + .withArgs("_amounts"); + }); + + it("reverts if zero refund recipient", async () => { + await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [0n], ZeroAddress, { value: 1n })) + .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") + .withArgs("_refundRecepient"); + }); + it("reverts if vault is not connected to the hub", async () => { - await expect(vaultHub.forceValidatorWithdrawal(stranger, SAMPLE_PUBKEY, { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawals(stranger, SAMPLE_PUBKEY, [0n], feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "NotConnectedToHub") .withArgs(stranger.address); }); @@ -199,142 +140,47 @@ describe("VaultHub.sol:withdrawals", () => { it("reverts if called for a disconnected vault", async () => { await vaultHub.connect(user).disconnect(vaultAddress); - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "NotConnectedToHub") .withArgs(vaultAddress); }); - it("reverts if called for a balanced vault", async () => { - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: 1n })) - .to.be.revertedWithCustomError(vaultHub, "AlreadyBalanced") + it("reverts if called for a healthy vault", async () => { + await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient, { value: 1n })) + .to.be.revertedWithCustomError(vaultHub, "AlreadyHealthy") .withArgs(vaultAddress, 0n, 0n); }); - context("unbalanced vault", () => { - let unbalancedUntil: bigint; - - beforeEach(async () => (unbalancedUntil = await reportUnbalancedVault())); + context("unhealthy vault", () => { + beforeEach(async () => await makeVaultUnbalanced()); - it("reverts if the time is not yet reached", async () => { - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: 1n })) - .to.be.revertedWithCustomError(vaultHub, "ForceWithdrawalTimelockActive") - .withArgs(vaultAddress, unbalancedUntil); - }); - - it("reverts if fees are insufficient or too high", async () => { - await advanceChainTime(unbalancedUntil); - - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: 1n })) - .to.be.revertedWithCustomError(vault, "InvalidValidatorWithdrawalFee") + it("reverts if fees are insufficient", async () => { + await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient, { value: 1n })) + .to.be.revertedWithCustomError(vault, "InsufficientValidatorWithdrawalsFee") .withArgs(1n, FEE); - - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: FEE + 1n })) - .to.be.revertedWithCustomError(vault, "InvalidValidatorWithdrawalFee") - .withArgs(FEE + 1n, FEE); }); it("initiates force validator withdrawal", async () => { - await advanceChainTime(unbalancedUntil - 1n); - - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: FEE })) - .to.emit(vaultHub, "VaultForceWithdrawalInitiated") - .withArgs(vaultAddress, SAMPLE_PUBKEY); + await expect( + vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient, { value: FEE }), + ) + .to.emit(vaultHub, "VaultForceValidatorWithdrawalsRequested") + .withArgs(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient); }); it("initiates force validator withdrawal with multiple pubkeys", async () => { const numPubkeys = 3; const pubkeys = "0x" + "ab".repeat(numPubkeys * 48); - await advanceChainTime(unbalancedUntil - 1n); - - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, pubkeys, { value: FEE * BigInt(numPubkeys) })) - .to.emit(vaultHub, "VaultForceWithdrawalInitiated") - .withArgs(vaultAddress, pubkeys); + const amounts = Array.from({ length: numPubkeys }, () => 0n); + + await expect( + vaultHub.forceValidatorWithdrawals(vaultAddress, pubkeys, amounts, feeRecipient, { + value: FEE * BigInt(numPubkeys), + }), + ) + .to.emit(vaultHub, "VaultForceValidatorWithdrawalsRequested") + .withArgs(vaultAddress, pubkeys, amounts, feeRecipient); }); }); }); - - context("_vaultAssessment & _epicrisis", () => { - beforeEach(async () => await makeVaultUnbalanced()); - - it("sets the unlock time and emits the event if the vault is unbalanced (via rebalance)", async () => { - // Hacky way to get the unlock time right - const tx = await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); - const events = await findEvents((await tx.wait()) as ContractTransactionReceipt, "VaultBecameUnbalanced"); - const unbalancedUntil = events[0].args.unlockTime; - - expect(unbalancedUntil).to.be.gte((await getCurrentBlockTimestamp()) + FORCE_WITHDRAWAL_TIMELOCK); - - await expect(tx).to.emit(vaultHub, "VaultBecameUnbalanced").withArgs(vaultAddress, unbalancedUntil); - - expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq( - unbalancedUntil - FORCE_WITHDRAWAL_TIMELOCK, - ); - }); - - it("does not change the unlock time if the vault is already unbalanced and the unlock time is already set", async () => { - // report the vault as unbalanced - const tx = await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); - const events = await findEvents((await tx.wait()) as ContractTransactionReceipt, "VaultBecameUnbalanced"); - const unbalancedUntil = events[0].args.unlockTime; - - await expect(await vaultHub.connect(vaultSigner).rebalance({ value: 1n })).to.not.emit( - vaultHub, - "VaultBecameUnbalanced", - ); - - expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq( - unbalancedUntil - FORCE_WITHDRAWAL_TIMELOCK, - ); - }); - - it("resets the unlock time if the vault becomes balanced", async () => { - // report the vault as unbalanced - await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); - - // report the vault as balanced - await expect(vaultHub.connect(vaultSigner).rebalance({ value: ether("0.1") })) - .to.emit(vaultHub, "VaultBecameBalanced") - .withArgs(vaultAddress); - - expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq(0n); - }); - - it("does not change the unlock time if the vault is already balanced", async () => { - // report the vault as balanced - await vaultHub.connect(vaultSigner).rebalance({ value: ether("0.1") }); - - // report the vault as balanced again - await expect(vaultHub.connect(vaultSigner).rebalance({ value: ether("0.1") })).to.not.emit( - vaultHub, - "VaultBecameBalanced", - ); - - expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq(0n); - }); - - it("maintains the same unbalanced unlock time across multiple rebalance calls while still unbalanced", async () => { - // report the vault as unbalanced - const tx = await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); - const events = await findEvents((await tx.wait()) as ContractTransactionReceipt, "VaultBecameUnbalanced"); - const unbalancedSince = events[0].args.unlockTime - FORCE_WITHDRAWAL_TIMELOCK; - - // Advance time by less than FORCE_WITHDRAWAL_TIMELOCK. - await advanceChainTime(1000n); - - await expect(vaultHub.connect(vaultSigner).rebalance({ value: 1n })).to.not.emit( - vaultHub, - "VaultBecameUnbalanced", - ); - - expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq(unbalancedSince); - - // report the vault as unbalanced again - await expect(vaultHub.connect(vaultSigner).rebalance({ value: 1n })).to.not.emit( - vaultHub, - "VaultBecameUnbalanced", - ); - - expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq(unbalancedSince); - }); - }); }); From 34bceac157deaed2c64258236cdd5c13e750013e Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Sat, 8 Feb 2025 13:04:23 +0000 Subject: [PATCH 48/70] feat: update staking valult --- contracts/0.8.25/vaults/StakingVault.sol | 221 +++++++++-------------- 1 file changed, 85 insertions(+), 136 deletions(-) diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 298b1c945..1dad617fb 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -36,18 +36,16 @@ import {IStakingVault} from "./interfaces/IStakingVault.sol"; * - `rebalance()` * - `pauseBeaconChainDeposits()` * - `resumeBeaconChainDeposits()` - * - `requestValidatorExit()` - * - `initiateFullValidatorWithdrawal()` - * - `initiatePartialValidatorWithdrawal()` + * - `markValidatorsForExit()` + * - `requestValidatorWithdrawals()` * - Operator: * - `depositToBeaconChain()` - * - `initiateFullValidatorWithdrawal()` - * - `initiatePartialValidatorWithdrawal()` + * - `requestValidatorWithdrawals()` * - VaultHub: * - `lock()` * - `report()` * - `rebalance()` - * - `forceValidatorWithdrawal()` + * - `forceValidatorWithdrawals()` * - Anyone: * - Can send ETH directly to the vault (treated as rewards) * @@ -92,7 +90,17 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @notice Address of `BeaconChainDepositContract` * Set immutably in the constructor to avoid storage costs */ - IDepositContract private immutable BEACON_CHAIN_DEPOSIT_CONTRACT; + IDepositContract public immutable DEPOSIT_CONTRACT; + + /** + * @notice The type of withdrawal credentials for the validators deposited from this `StakingVault`. + */ + uint256 private constant WC_0x02_PREFIX = 0x02 << 248; + + /** + * @notice The length of the public key in bytes + */ + uint256 internal constant PUBLIC_KEY_LENGTH = 48; /** * @notice Storage offset slot for ERC-7201 namespace @@ -113,7 +121,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { if (_beaconChainDepositContract == address(0)) revert ZeroArgument("_beaconChainDepositContract"); VAULT_HUB = VaultHub(_vaultHub); - BEACON_CHAIN_DEPOSIT_CONTRACT = IDepositContract(_beaconChainDepositContract); + DEPOSIT_CONTRACT = IDepositContract(_beaconChainDepositContract); // Prevents reinitialization of the implementation _disableInitializers(); @@ -302,11 +310,12 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { function rebalance(uint256 _ether) external { if (_ether == 0) revert ZeroArgument("_ether"); if (_ether > address(this).balance) revert InsufficientBalance(address(this).balance); - uint256 _valuation = valuation(); - if (_ether > _valuation) revert RebalanceAmountExceedsValuation(_valuation, _ether); + + uint256 valuation_ = valuation(); + if (_ether > valuation_) revert RebalanceAmountExceedsValuation(valuation_, _ether); ERC7201Storage storage $ = _getStorage(); - if (owner() == msg.sender || (_valuation < $.locked && msg.sender == address(VAULT_HUB))) { + if (owner() == msg.sender || (valuation_ < $.locked && msg.sender == address(VAULT_HUB))) { $.inOutDelta -= int128(int256(_ether)); emit Withdrawn(msg.sender, address(VAULT_HUB), _ether); @@ -335,21 +344,13 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { emit Reported(_valuation, _inOutDelta, _locked); } - /** - * @notice Returns the address of `BeaconChainDepositContract` - * @return Address of `BeaconChainDepositContract` - */ - function depositContract() external view returns (address) { - return address(BEACON_CHAIN_DEPOSIT_CONTRACT); - } - /** * @notice Returns the 0x02-type withdrawal credentials for the validators deposited from this `StakingVault` * All consensus layer rewards are sent to this contract. Only 0x02-type withdrawal credentials are supported * @return Withdrawal credentials as bytes32 */ function withdrawalCredentials() public view returns (bytes32) { - return bytes32((0x02 << 248) + uint160(address(this))); + return bytes32(WC_0x02_PREFIX | uint160(address(this))); } /** @@ -408,7 +409,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { uint256 numberOfDeposits = _deposits.length; for (uint256 i = 0; i < numberOfDeposits; i++) { IStakingVault.Deposit calldata deposit = _deposits[i]; - BEACON_CHAIN_DEPOSIT_CONTRACT.deposit{value: deposit.amount}( + DEPOSIT_CONTRACT.deposit{value: deposit.amount}( deposit.pubkey, bytes.concat(withdrawalCredentials()), deposit.signature, @@ -423,85 +424,71 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @notice Calculates the total withdrawal fee required for given number of validator keys * @param _numberOfKeys Number of validators' public keys - * @return Total fee amount + * @return Total fee amount to pass as `msg.value` (wei) + * @dev The fee is only valid for the requests made in the same block. */ - function calculateValidatorWithdrawalFee(uint256 _numberOfKeys) external view returns (uint256) { + function calculateValidatorWithdrawalsFee(uint256 _numberOfKeys) external view returns (uint256) { if (_numberOfKeys == 0) revert ZeroArgument("_numberOfKeys"); return _numberOfKeys * TriggerableWithdrawals.getWithdrawalRequestFee(); } /** - * @notice Requests validator exit from the beacon chain by emitting an `ValidatorExitRequested` event - * @param _pubkeys Concatenated validators' public keys - * @dev Signals the node operator to eject the specified validators from the beacon chain + * @notice Signals to node operators that specific validators should exit from the beacon chain. + * It does not directly trigger exits - node operators must monitor for these events and handle the exits manually. + * @param _pubkeys Concatenated validator public keys, each 48 bytes long. */ - function requestValidatorExit(bytes calldata _pubkeys) external onlyOwner { + function markValidatorsForExit(bytes calldata _pubkeys) external onlyOwner { if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); + if (_pubkeys.length % PUBLIC_KEY_LENGTH != 0) { + revert InvalidValidatorPubkeysLength(); + } - emit ValidatorExitRequested(msg.sender, _pubkeys); + uint256 keysCount = _pubkeys.length / PUBLIC_KEY_LENGTH; + for (uint256 i = 0; i < keysCount; i++) { + emit ValidatorMarkedForExit(msg.sender, bytes(_pubkeys[i * PUBLIC_KEY_LENGTH : (i + 1) * PUBLIC_KEY_LENGTH])); + } } /** - * @notice Initiates a full validator withdrawal from the beacon chain following EIP-7002 - * @param _pubkeys Concatenated validators public keys - * @dev Keys are expected to be 48 bytes long tightly packed without paddings - * Only allowed to be called by the owner or the node operator - * @dev The caller must provide sufficient fee via msg.value to cover the withdrawal request costs + * @notice Requests validator withdrawals from the beacon chain using EIP-7002 triggerable exit. + * @param _pubkeys Concatenated validators public keys, each 48 bytes long. + * @param _amounts Amounts of ether to exit, must match the length of _pubkeys. + * @param _refundRecipient Address to receive the fee refund. + * @dev The caller must provide sufficient fee via msg.value to cover the withdrawal request costs. + * TODO: check if the vault is unbalanced + * TODO: check auth for vo, no and unbalanced then vaulthub */ - function initiateFullValidatorWithdrawal(bytes calldata _pubkeys) external payable { - if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); - _onlyOwnerOrNodeOperator(); - - (uint256 feePerRequest, uint256 totalFee) = _getAndValidateWithdrawalFees(_pubkeys); - TriggerableWithdrawals.addFullWithdrawalRequests(_pubkeys, feePerRequest); + function requestValidatorWithdrawals(bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient) external payable { + uint256 value = msg.value; // cache msg.value to save gas - emit FullValidatorWithdrawalInitiated(msg.sender, _pubkeys); - - _refundExcessFee(totalFee); - } - - /** - * @notice Initiates a partial validator withdrawal from the beacon chain following EIP-7002 - * @param _pubkeys Concatenated validators public keys - * @param _amounts Amounts of ether to exit - * @dev Keys are expected to be 48 bytes long tightly packed without paddings - * Only allowed to be called by the owner or the node operator - * @dev The caller must provide sufficient fee via msg.value to cover the withdrawal request costs - */ - function initiatePartialValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts) external payable { + if (value == 0) revert ZeroArgument("msg.value"); if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); if (_amounts.length == 0) revert ZeroArgument("_amounts"); + if (_refundRecipient == address(0)) revert ZeroArgument("_refundRecipient"); - _onlyOwnerOrNodeOperator(); - - (uint256 feePerRequest, uint256 totalFee) = _getAndValidateWithdrawalFees(_pubkeys); - TriggerableWithdrawals.addPartialWithdrawalRequests(_pubkeys, _amounts, feePerRequest); - - emit PartialValidatorWithdrawalInitiated(msg.sender, _pubkeys, _amounts); + ERC7201Storage storage $ = _getStorage(); + if (msg.sender == $.nodeOperator || msg.sender == owner() || (valuation() < $.locked && msg.sender == address(VAULT_HUB))) { + uint256 feePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); + uint256 totalFee = (feePerRequest * _pubkeys.length) / PUBLIC_KEY_LENGTH; + if (value < totalFee) { + revert InsufficientValidatorWithdrawalsFee(value, totalFee); + } - _refundExcessFee(totalFee); - } + TriggerableWithdrawals.addWithdrawalRequests(_pubkeys, _amounts, feePerRequest); - /** - * @notice Forces validator withdrawal from the beacon chain in case the vault is unbalanced. - * @param _pubkeys pubkeys of the validators to withdraw. - * @dev Can only be called by the vault hub in case the vault is unbalanced. - * @dev The caller must provide exactly the required fee via msg.value to cover the withdrawal request costs. No refunds are provided. - */ - function forceValidatorWithdrawal(bytes calldata _pubkeys) external payable override { - if (msg.sender != address(VAULT_HUB)) revert NotAuthorized("forceValidatorWithdrawal", msg.sender); - - uint256 feePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); - uint256 totalFee = feePerRequest * _pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH; + uint256 excess = msg.value - totalFee; + if (excess > 0) { + (bool success,) = _refundRecipient.call{value: excess}(""); + if (!success) { + revert ValidatorWithdrawalFeeRefundFailed(_refundRecipient, excess); + } + } - if (msg.value != totalFee) { - revert InvalidValidatorWithdrawalFee(msg.value, totalFee); + emit ValidatorWithdrawalsRequested(msg.sender, _pubkeys, _amounts, _refundRecipient, excess); + } else { + revert NotAuthorized("requestValidatorWithdrawals", msg.sender); } - - TriggerableWithdrawals.addFullWithdrawalRequests(_pubkeys, feePerRequest); - - emit FullValidatorWithdrawalInitiated(msg.sender, _pubkeys); } /** @@ -524,7 +511,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { // Step 1. Convert the deposit amount in wei to gwei in 64-bit bytes bytes memory amountBE64 = abi.encodePacked(uint64(_amount / 1 gwei)); - // Step 2. Convert the amount to little-endian format by flipping the bytes + // Step 2. Convert the amount to little-endian format by flipping the bytes 🧠 bytes memory amountLE64 = new bytes(8); amountLE64[0] = amountBE64[7]; amountLE64[1] = amountBE64[6]; @@ -560,44 +547,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { } } - /// @notice Ensures the caller is either the owner or the node operator - function _onlyOwnerOrNodeOperator() internal view { - if (msg.sender != owner() && msg.sender != _getStorage().nodeOperator) { - revert OwnableUnauthorizedAccount(msg.sender); - } - } - - /// @notice Validates that sufficient fee was provided to cover validator withdrawal requests - /// @param _pubkeys Concatenated validator public keys, each 48 bytes long - /// @return feePerRequest Fee per request for the withdrawal request - /// @return totalFee Total fee required for the withdrawal request - function _getAndValidateWithdrawalFees(bytes calldata _pubkeys) private view returns (uint256 feePerRequest, uint256 totalFee) { - feePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); - totalFee = feePerRequest * _pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH; - - if (msg.value < totalFee) { - revert InvalidValidatorWithdrawalFee(msg.value, totalFee); - } - - return (feePerRequest, totalFee); - } - - /// @notice Refunds excess fee back to the sender if they sent more than required - /// @param _totalFee Total fee required for the withdrawal request that will be kept - /// @dev Sends back any msg.value in excess of _totalFee to msg.sender - function _refundExcessFee(uint256 _totalFee) private { - uint256 excess = msg.value - _totalFee; - - if (excess > 0) { - (bool success,) = msg.sender.call{value: excess}(""); - if (!success) { - revert ValidatorWithdrawalFeeRefundFailed(msg.sender, excess); - } - - emit ValidatorWithdrawalFeeRefunded(msg.sender, excess); - } - } - /** * @notice Emitted when `StakingVault` is funded with ether * @dev Event is not emitted upon direct transfers through `receive()` @@ -655,27 +604,22 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { event DepositedToBeaconChain(address indexed _sender, uint256 _deposits, uint256 _totalAmount); /** - * @notice Emitted when a validator exit request is made. - * @param _sender Address that requested the validator exit. - * @param _pubkeys Public key of the validator requested to exit. - * @dev Signals `nodeOperator` to exit the validator. + * @notice Emitted when a validator is marked for exit from the beacon chain + * @param _sender Address that marked the validator for exit + * @param _pubkeys Public key of the validator marked for exit + * @dev Signals to node operators that they should exit this validator from the beacon chain */ - event ValidatorExitRequested(address indexed _sender, bytes _pubkeys); + event ValidatorMarkedForExit(address _sender, bytes _pubkeys); /** - * @notice Emitted when a validator withdrawal request is forced via EIP-7002. - * @param _sender Address that requested the validator withdrawal. - * @param _pubkeys Public key of the validator requested to withdraw. + * @notice Emitted when validator withdrawals are requested via EIP-7002 + * @param _sender Address that requested the withdrawals + * @param _pubkeys Concatenated public keys of the validators to withdraw + * @param _amounts Amounts of ether to withdraw per validator + * @param _refundRecipient Address to receive any excess withdrawal fee + * @param _excess Amount of excess fee refunded to recipient */ - event FullValidatorWithdrawalInitiated(address indexed _sender, bytes _pubkeys); - - /** - * @notice Emitted when a validator partial withdrawal request is forced via EIP-7002. - * @param _sender Address that requested the validator partial withdrawal. - * @param _pubkeys Public key of the validator requested to withdraw. - * @param _amounts Amounts of ether requested to withdraw. - */ - event PartialValidatorWithdrawalInitiated(address indexed _sender, bytes _pubkeys, uint64[] _amounts); + event ValidatorWithdrawalsRequested(address indexed _sender, bytes _pubkeys, uint64[] _amounts, address _refundRecipient, uint256 _excess); /** * @notice Emitted when an excess fee is refunded back to the sender. @@ -763,11 +707,16 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { error BeaconChainDepositsArePaused(); /** - * @notice Thrown when the validator withdrawal fee is invalid + * @notice Thrown when the length of the validator public keys array is invalid + */ + error InvalidValidatorPubkeysLength(); + + /** + * @notice Thrown when the validator withdrawal fee is insufficient * @param _passed Amount of ether passed to the function * @param _required Amount of ether required to cover the fee */ - error InvalidValidatorWithdrawalFee(uint256 _passed, uint256 _required); + error InsufficientValidatorWithdrawalsFee(uint256 _passed, uint256 _required); /** * @notice Thrown when a validator withdrawal fee refund fails From 4895d35873fa3f3f531cd973eea5f534d46d80ed Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Sat, 8 Feb 2025 13:14:22 +0000 Subject: [PATCH 49/70] test: fix --- .../vaults/staking-vault/stakingVault.test.ts | 315 ++++++++++-------- test/0.8.25/vaults/vaultFactory.test.ts | 4 +- .../vaulthub/vaulthub.withdrawals.test.ts | 8 +- .../vaults-happy-path.integration.ts | 11 +- 4 files changed, 181 insertions(+), 157 deletions(-) diff --git a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts index c10fdea59..56a70c16e 100644 --- a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts +++ b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts @@ -23,7 +23,17 @@ const MAX_UINT128 = 2n ** 128n - 1n; const SAMPLE_PUBKEY = "0x" + "ab".repeat(48); -const getPubkeys = (num: number): string => `0x${Array.from({ length: num }, (_, i) => `0${i}`.repeat(48)).join("")}`; +const getPubkeys = (num: number): { pubkeys: string[]; stringified: string } => { + const pubkeys = Array.from({ length: num }, (_, i) => `0x${`${(i + 1).toString().padStart(2, "0")}`.repeat(48)}`); + return { + pubkeys, + stringified: `0x${pubkeys.map(de0x).join("")}`, + }; +}; + +const encodeEip7002Input = (pubkey: string, amount: bigint): string => { + return `${pubkey}${amount.toString(16).padStart(16, "0")}`; +}; // @TODO: test reentrancy attacks describe("StakingVault.sol", () => { @@ -45,6 +55,7 @@ describe("StakingVault.sol", () => { let vaultHubAddress: string; let depositContractAddress: string; let ethRejectorAddress: string; + let originalState: string; before(async () => { @@ -75,7 +86,7 @@ describe("StakingVault.sol", () => { }); it("sets the deposit contract address in the implementation", async () => { - expect(await stakingVaultImplementation.depositContract()).to.equal(depositContractAddress); + expect(await stakingVaultImplementation.DEPOSIT_CONTRACT()).to.equal(depositContractAddress); }); it("reverts on construction if the vault hub address is zero", async () => { @@ -104,8 +115,9 @@ describe("StakingVault.sol", () => { context("initial state (getters)", () => { it("returns the correct initial state and constants", async () => { - expect(await stakingVault.owner()).to.equal(await vaultOwner.getAddress()); + expect(await stakingVault.DEPOSIT_CONTRACT()).to.equal(depositContractAddress); + expect(await stakingVault.owner()).to.equal(await vaultOwner.getAddress()); expect(await stakingVault.getInitializedVersion()).to.equal(1n); expect(await stakingVault.version()).to.equal(1n); expect(await stakingVault.vaultHub()).to.equal(vaultHubAddress); @@ -115,8 +127,6 @@ describe("StakingVault.sol", () => { expect(await stakingVault.inOutDelta()).to.equal(0n); expect(await stakingVault.latestReport()).to.deep.equal([0n, 0n]); expect(await stakingVault.nodeOperator()).to.equal(operator); - - expect(await stakingVault.depositContract()).to.equal(depositContractAddress); expect((await stakingVault.withdrawalCredentials()).toLowerCase()).to.equal( ("0x02" + "00".repeat(11) + de0x(stakingVaultAddress)).toLowerCase(), ); @@ -442,12 +452,6 @@ describe("StakingVault.sol", () => { }); }); - context("depositContract", () => { - it("returns the correct deposit contract address", async () => { - expect(await stakingVault.depositContract()).to.equal(depositContractAddress); - }); - }); - context("withdrawalCredentials", () => { it("returns the correct withdrawal credentials in 0x02 format", async () => { const withdrawalCredentials = ("0x02" + "00".repeat(11) + de0x(stakingVaultAddress)).toLowerCase(); @@ -596,230 +600,251 @@ describe("StakingVault.sol", () => { }); }); - context("calculateValidatorWithdrawalFee", () => { + context("calculateValidatorWithdrawalsFee", () => { it("reverts if the number of validators is zero", async () => { - await expect(stakingVault.calculateValidatorWithdrawalFee(0)) + await expect(stakingVault.calculateValidatorWithdrawalsFee(0)) .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") .withArgs("_numberOfKeys"); }); - it("returns the correct withdrawal fee", async () => { - await withdrawalRequest.setFee(100n); - - expect(await stakingVault.calculateValidatorWithdrawalFee(1)).to.equal(100n); - }); - - it("returns the total fee for given number of validator keys", async () => { + it("calculates the total fee for given number of validator keys", async () => { const newFee = 100n; await withdrawalRequest.setFee(newFee); - const fee = await stakingVault.calculateValidatorWithdrawalFee(1n); + const fee = await stakingVault.calculateValidatorWithdrawalsFee(1n); expect(fee).to.equal(newFee); const feePerRequest = await withdrawalRequest.fee(); expect(fee).to.equal(feePerRequest); - const feeForMultipleKeys = await stakingVault.calculateValidatorWithdrawalFee(2n); + const feeForMultipleKeys = await stakingVault.calculateValidatorWithdrawalsFee(2n); expect(feeForMultipleKeys).to.equal(newFee * 2n); }); }); - context("requestValidatorExit", () => { + context("markValidatorsForExit", () => { it("reverts if called by a non-owner", async () => { - await expect(stakingVault.connect(stranger).requestValidatorExit("0x")) + await expect(stakingVault.connect(stranger).markValidatorsForExit("0x")) .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") .withArgs(stranger); }); it("reverts if the number of validators is zero", async () => { - await expect(stakingVault.connect(vaultOwner).requestValidatorExit("0x")) - .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") - .withArgs("_pubkeys"); - }); - - it("emits the `ValidatorExitRequested` event", async () => { - await expect(stakingVault.connect(vaultOwner).requestValidatorExit(SAMPLE_PUBKEY)) - .to.emit(stakingVault, "ValidatorExitRequested") - .withArgs(vaultOwner, SAMPLE_PUBKEY); - }); - }); - - context("initiateFullValidatorWithdrawal", () => { - it("reverts if the number of validators is zero", async () => { - await expect(stakingVault.connect(vaultOwner).initiateFullValidatorWithdrawal("0x")) + await expect(stakingVault.connect(vaultOwner).markValidatorsForExit("0x")) .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") .withArgs("_pubkeys"); }); - it("reverts if called by a non-owner or the node operator", async () => { - await expect(stakingVault.connect(stranger).initiateFullValidatorWithdrawal(SAMPLE_PUBKEY)) - .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") - .withArgs(stranger); - }); - - it("reverts if passed fee is less than the required fee", async () => { - const numberOfKeys = 4; - const pubkeys = getPubkeys(numberOfKeys); - const fee = await stakingVault.calculateValidatorWithdrawalFee(numberOfKeys - 1); - - await expect(stakingVault.connect(vaultOwner).initiateFullValidatorWithdrawal(pubkeys, { value: fee })) - .to.be.revertedWithCustomError(stakingVault, "InvalidValidatorWithdrawalFee") - .withArgs(fee, numberOfKeys); + it("reverts if the length of the pubkeys is not a multiple of 48", async () => { + await expect( + stakingVault.connect(vaultOwner).markValidatorsForExit("0x" + "ab".repeat(47)), + ).to.be.revertedWithCustomError(stakingVault, "InvalidValidatorPubkeysLength"); }); - // Tests the path where the refund fails because the caller is the contract that does not have the receive ETH function - it("reverts if the refund fails", async () => { - const numberOfKeys = 1; - const pubkeys = getPubkeys(numberOfKeys); - const fee = await stakingVault.calculateValidatorWithdrawalFee(numberOfKeys); - const overpaid = 100n; - - const ethRejectorSigner = await impersonate(ethRejectorAddress, ether("1")); - const { stakingVault: rejector } = await deployStakingVaultBehindBeaconProxy(vaultOwner, ethRejectorSigner); + it("emits the `ValidatorMarkedForExit` event for each validator", async () => { + const numberOfKeys = 2; + const keys = getPubkeys(numberOfKeys); - await expect( - rejector.connect(ethRejectorSigner).initiateFullValidatorWithdrawal(pubkeys, { value: fee + overpaid }), - ) - .to.be.revertedWithCustomError(stakingVault, "ValidatorWithdrawalFeeRefundFailed") - .withArgs(ethRejectorAddress, overpaid); + await expect(stakingVault.connect(vaultOwner).markValidatorsForExit(keys.stringified)) + .to.emit(stakingVault, "ValidatorMarkedForExit") + .withArgs(vaultOwner, keys.pubkeys[0]); }); + }); - it("makes a full validator withdrawal when called by the owner or the node operator", async () => { - const fee = BigInt(await withdrawalRequest.fee()); - - await expect(stakingVault.connect(vaultOwner).initiateFullValidatorWithdrawal(SAMPLE_PUBKEY, { value: fee })) - .to.emit(stakingVault, "FullValidatorWithdrawalInitiated") - .withArgs(vaultOwner, SAMPLE_PUBKEY) - .and.not.to.emit(stakingVault, "ValidatorWithdrawalFeeRefunded"); + context("requestValidatorWithdrawals", () => { + let baseFee: bigint; - await expect(stakingVault.connect(operator).initiateFullValidatorWithdrawal(SAMPLE_PUBKEY, { value: fee })) - .to.emit(stakingVault, "FullValidatorWithdrawalInitiated") - .withArgs(operator, SAMPLE_PUBKEY) - .and.not.to.emit(stakingVault, "ValidatorWithdrawalFeeRefunded"); + before(async () => { + baseFee = BigInt(await withdrawalRequest.fee()); }); - it("makes a full validator withdrawal and refunds the excess fee", async () => { - const fee = BigInt(await withdrawalRequest.fee()); - const amount = ether("32"); - - await expect(stakingVault.connect(vaultOwner).initiateFullValidatorWithdrawal(SAMPLE_PUBKEY, { value: amount })) - .and.to.emit(stakingVault, "FullValidatorWithdrawalInitiated") - .withArgs(vaultOwner, SAMPLE_PUBKEY) - .and.to.emit(stakingVault, "ValidatorWithdrawalFeeRefunded") - .withArgs(vaultOwner, amount - fee); + it("reverts if msg.value is zero", async () => { + await expect(stakingVault.connect(vaultOwner).requestValidatorWithdrawals("0x", [], vaultOwnerAddress)) + .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") + .withArgs("msg.value"); }); - }); - context("initiatePartialValidatorWithdrawal", () => { it("reverts if the number of validators is zero", async () => { - await expect(stakingVault.connect(vaultOwner).initiatePartialValidatorWithdrawal("0x", [ether("16")])) + await expect( + stakingVault.connect(vaultOwner).requestValidatorWithdrawals("0x", [], vaultOwnerAddress, { value: 1n }), + ) .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") .withArgs("_pubkeys"); }); - it("reverts if the number of amounts is zero", async () => { - await expect(stakingVault.connect(vaultOwner).initiatePartialValidatorWithdrawal(SAMPLE_PUBKEY, [])) + it("reverts if the amounts array is empty", async () => { + await expect( + stakingVault + .connect(vaultOwner) + .requestValidatorWithdrawals(SAMPLE_PUBKEY, [], vaultOwnerAddress, { value: 1n }), + ) .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") .withArgs("_amounts"); }); + it("reverts if the refund recipient is the zero address", async () => { + await expect( + stakingVault + .connect(vaultOwner) + .requestValidatorWithdrawals(SAMPLE_PUBKEY, [ether("1")], ZeroAddress, { value: 1n }), + ) + .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") + .withArgs("_refundRecipient"); + }); + it("reverts if called by a non-owner or the node operator", async () => { - await expect(stakingVault.connect(stranger).initiatePartialValidatorWithdrawal(SAMPLE_PUBKEY, [ether("16")])) - .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") - .withArgs(stranger); + await expect( + stakingVault + .connect(stranger) + .requestValidatorWithdrawals(SAMPLE_PUBKEY, [ether("1")], vaultOwnerAddress, { value: 1n }), + ) + .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") + .withArgs("requestValidatorWithdrawals", stranger); }); - it("reverts if passed fee is less than the required fee", async () => { + it("reverts if called by the vault hub on a healthy vault", async () => { + await expect( + stakingVault + .connect(vaultHubSigner) + .requestValidatorWithdrawals(SAMPLE_PUBKEY, [ether("1")], vaultOwnerAddress, { value: 1n }), + ) + .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") + .withArgs("requestValidatorWithdrawals", vaultHubAddress); + }); + + it("reverts if the fee is less than the required fee", async () => { const numberOfKeys = 4; const pubkeys = getPubkeys(numberOfKeys); - const fee = await stakingVault.calculateValidatorWithdrawalFee(numberOfKeys - 1); + const value = baseFee * BigInt(numberOfKeys) - 1n; await expect( - stakingVault.connect(vaultOwner).initiatePartialValidatorWithdrawal(pubkeys, [ether("16")], { value: fee }), + stakingVault + .connect(vaultOwner) + .requestValidatorWithdrawals(pubkeys.stringified, [ether("1")], vaultOwnerAddress, { value }), ) - .to.be.revertedWithCustomError(stakingVault, "InvalidValidatorWithdrawalFee") - .withArgs(fee, numberOfKeys); + .to.be.revertedWithCustomError(stakingVault, "InsufficientValidatorWithdrawalsFee") + .withArgs(value, baseFee * BigInt(numberOfKeys)); }); it("reverts if the refund fails", async () => { const numberOfKeys = 1; - const pubkeys = getPubkeys(numberOfKeys); - const fee = await stakingVault.calculateValidatorWithdrawalFee(numberOfKeys); const overpaid = 100n; - - const ethRejectorSigner = await impersonate(ethRejectorAddress, ether("1")); - const { stakingVault: rejector } = await deployStakingVaultBehindBeaconProxy(vaultOwner, ethRejectorSigner); + const pubkeys = getPubkeys(numberOfKeys); + const value = baseFee * BigInt(numberOfKeys) + overpaid; await expect( - rejector - .connect(ethRejectorSigner) - .initiatePartialValidatorWithdrawal(pubkeys, [ether("16")], { value: fee + overpaid }), + stakingVault + .connect(vaultOwner) + .requestValidatorWithdrawals(pubkeys.stringified, [ether("1")], ethRejectorAddress, { value }), ) .to.be.revertedWithCustomError(stakingVault, "ValidatorWithdrawalFeeRefundFailed") .withArgs(ethRejectorAddress, overpaid); }); - it("makes a partial validator withdrawal when called by the owner or the node operator", async () => { - const fee = BigInt(await withdrawalRequest.fee()); + it("requests a validator withdrawal when called by the owner", async () => { + const value = baseFee; + await expect( + stakingVault.connect(vaultOwner).requestValidatorWithdrawals(SAMPLE_PUBKEY, [0n], vaultOwnerAddress, { value }), + ) + .to.emit(withdrawalRequest, "eip7002MockRequestAdded") + .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, 0n), baseFee) + .to.emit(stakingVault, "ValidatorWithdrawalsRequested") + .withArgs(vaultOwner, SAMPLE_PUBKEY, [0n], vaultOwnerAddress, 0n); + }); + + it("requests a validator withdrawal when called by the node operator", async () => { + await expect( + stakingVault + .connect(operator) + .requestValidatorWithdrawals(SAMPLE_PUBKEY, [0n], vaultOwnerAddress, { value: baseFee }), + ) + .to.emit(withdrawalRequest, "eip7002MockRequestAdded") + .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, 0n), baseFee) + .to.emit(stakingVault, "ValidatorWithdrawalsRequested") + .withArgs(operator, SAMPLE_PUBKEY, [0n], vaultOwnerAddress, 0n); + }); + + it("requests a full validator withdrawal", async () => { await expect( stakingVault .connect(vaultOwner) - .initiatePartialValidatorWithdrawal(SAMPLE_PUBKEY, [ether("16")], { value: fee }), + .requestValidatorWithdrawals(SAMPLE_PUBKEY, [0n], vaultOwnerAddress, { value: baseFee }), ) - .to.emit(stakingVault, "PartialValidatorWithdrawalInitiated") - .withArgs(vaultOwner, SAMPLE_PUBKEY, [ether("16")]) - .and.not.to.emit(stakingVault, "ValidatorWithdrawalFeeRefunded"); + .to.emit(withdrawalRequest, "eip7002MockRequestAdded") + .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, 0n), baseFee) + .to.emit(stakingVault, "ValidatorWithdrawalsRequested") + .withArgs(vaultOwner, SAMPLE_PUBKEY, [0n], vaultOwnerAddress, 0n); + }); + it("requests a partial validator withdrawal", async () => { + const amount = ether("0.1"); await expect( - stakingVault.connect(operator).initiatePartialValidatorWithdrawal(SAMPLE_PUBKEY, [ether("16")], { value: fee }), + stakingVault + .connect(vaultOwner) + .requestValidatorWithdrawals(SAMPLE_PUBKEY, [amount], vaultOwnerAddress, { value: baseFee }), ) - .to.emit(stakingVault, "PartialValidatorWithdrawalInitiated") - .withArgs(operator, SAMPLE_PUBKEY, [ether("16")]) - .and.not.to.emit(stakingVault, "ValidatorWithdrawalFeeRefunded"); + .to.emit(withdrawalRequest, "eip7002MockRequestAdded") + .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, amount), baseFee) + .to.emit(stakingVault, "ValidatorWithdrawalsRequested") + .withArgs(vaultOwner, SAMPLE_PUBKEY, [amount], vaultOwnerAddress, 0); }); - it("makes a partial validator withdrawal and refunds the excess fee", async () => { - const fee = BigInt(await withdrawalRequest.fee()); - const amount = ether("32"); + it("requests a multiple validator withdrawals", async () => { + const numberOfKeys = 2; + const pubkeys = getPubkeys(numberOfKeys); + const value = baseFee * BigInt(numberOfKeys); + const amounts = Array(numberOfKeys) + .fill(0) + .map((_, i) => BigInt(i * 100)); // trigger full and partial withdrawals await expect( stakingVault .connect(vaultOwner) - .initiatePartialValidatorWithdrawal(SAMPLE_PUBKEY, [ether("16")], { value: amount }), + .requestValidatorWithdrawals(pubkeys.stringified, amounts, vaultOwnerAddress, { value }), ) - .and.to.emit(stakingVault, "PartialValidatorWithdrawalInitiated") - .withArgs(vaultOwner, SAMPLE_PUBKEY, [ether("16")]) - .and.to.emit(stakingVault, "ValidatorWithdrawalFeeRefunded") - .withArgs(vaultOwner, amount - fee); + .to.emit(withdrawalRequest, "eip7002MockRequestAdded") + .withArgs(encodeEip7002Input(pubkeys.pubkeys[0], amounts[0]), baseFee) + .to.emit(withdrawalRequest, "eip7002MockRequestAdded") + .withArgs(encodeEip7002Input(pubkeys.pubkeys[1], amounts[1]), baseFee) + .and.to.emit(stakingVault, "ValidatorWithdrawalsRequested") + .withArgs(vaultOwner, pubkeys.stringified, amounts, vaultOwnerAddress, 0n); }); - }); - context("forceValidatorWithdrawal", () => { - it("reverts if called by a non-vault hub", async () => { - await expect(stakingVault.connect(stranger).forceValidatorWithdrawal(SAMPLE_PUBKEY)) - .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") - .withArgs("forceValidatorWithdrawal", stranger); - }); + it("requests a multiple validator withdrawals and refunds the excess fee to the fee recipient", async () => { + const numberOfKeys = 2; + const pubkeys = getPubkeys(numberOfKeys); + const amounts = Array(numberOfKeys).fill(0); // trigger full withdrawals + const valueToRefund = 100n * BigInt(numberOfKeys); + const value = baseFee * BigInt(numberOfKeys) + valueToRefund; - it("reverts if the passed fee is too high or too low", async () => { - const fee = BigInt(await withdrawalRequest.fee()); + const strangerBalanceBefore = await ethers.provider.getBalance(stranger); - await expect(stakingVault.connect(vaultHubSigner).forceValidatorWithdrawal(SAMPLE_PUBKEY, { value: fee - 1n })) - .to.be.revertedWithCustomError(stakingVault, "InvalidValidatorWithdrawalFee") - .withArgs(fee - 1n, 1); + await expect( + stakingVault.connect(vaultOwner).requestValidatorWithdrawals(pubkeys.stringified, amounts, stranger, { value }), + ) + .to.emit(withdrawalRequest, "eip7002MockRequestAdded") + .withArgs(encodeEip7002Input(pubkeys.pubkeys[0], amounts[0]), baseFee) + .to.emit(withdrawalRequest, "eip7002MockRequestAdded") + .withArgs(encodeEip7002Input(pubkeys.pubkeys[1], amounts[1]), baseFee) + .and.to.emit(stakingVault, "ValidatorWithdrawalsRequested") + .withArgs(vaultOwner, pubkeys.stringified, amounts, stranger, valueToRefund); - await expect(stakingVault.connect(vaultHubSigner).forceValidatorWithdrawal(SAMPLE_PUBKEY, { value: fee + 1n })) - .to.be.revertedWithCustomError(stakingVault, "InvalidValidatorWithdrawalFee") - .withArgs(fee + 1n, 1); + const strangerBalanceAfter = await ethers.provider.getBalance(stranger); + expect(strangerBalanceAfter).to.equal(strangerBalanceBefore + valueToRefund); }); - it("makes a full validator withdrawal when called by the vault hub", async () => { - const fee = BigInt(await withdrawalRequest.fee()); + it("requests a validator withdrawal if called by the vault hub on an unhealthy vault", async () => { + await stakingVault.fund({ value: ether("1") }); + await stakingVault.connect(vaultHubSigner).report(ether("0.9"), ether("1"), ether("1.1")); // slashing await expect( - stakingVault.connect(vaultHubSigner).forceValidatorWithdrawal(SAMPLE_PUBKEY, { value: fee }), - ).to.emit(stakingVault, "FullValidatorWithdrawalInitiated"); + stakingVault + .connect(vaultHubSigner) + .requestValidatorWithdrawals(SAMPLE_PUBKEY, [0n], vaultOwnerAddress, { value: 1n }), + ) + .to.emit(withdrawalRequest, "eip7002MockRequestAdded") + .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, 0n), baseFee); }); }); diff --git a/test/0.8.25/vaults/vaultFactory.test.ts b/test/0.8.25/vaults/vaultFactory.test.ts index 879e1cbc8..f0a6ab3c0 100644 --- a/test/0.8.25/vaults/vaultFactory.test.ts +++ b/test/0.8.25/vaults/vaultFactory.test.ts @@ -117,8 +117,8 @@ describe("VaultFactory.sol", () => { rebalancer: await vaultOwner1.getAddress(), depositPauser: await vaultOwner1.getAddress(), depositResumer: await vaultOwner1.getAddress(), - exitRequester: await vaultOwner1.getAddress(), - withdrawalInitiator: await vaultOwner1.getAddress(), + validatorExitRequester: await vaultOwner1.getAddress(), + validatorWithdrawalRequester: await vaultOwner1.getAddress(), disconnecter: await vaultOwner1.getAddress(), nodeOperatorManager: await operator.getAddress(), nodeOperatorFeeClaimer: await operator.getAddress(), diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts index 046257b0c..66970f512 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts @@ -91,12 +91,10 @@ describe("VaultHub.sol:withdrawals", () => { afterEach(async () => await Snapshot.restore(originalState)); - // Simulate getting in the unbalanced state - const makeVaultUnbalanced = async () => { + // Simulate getting in the unhealthy state + const makeVaultUnhealthy = async () => { await vault.fund({ value: ether("1") }); - await vault.connect(vaultHubSigner).report(ether("1"), ether("1"), ether("1")); await vaultHub.mintSharesBackedByVault(vaultAddress, user, ether("0.9")); - await vault.connect(vaultHubSigner).report(ether("1"), ether("1"), ether("1.1")); await vault.connect(vaultHubSigner).report(ether("0.9"), ether("1"), ether("1.1")); // slashing }; @@ -152,7 +150,7 @@ describe("VaultHub.sol:withdrawals", () => { }); context("unhealthy vault", () => { - beforeEach(async () => await makeVaultUnbalanced()); + beforeEach(async () => await makeVaultUnhealthy()); it("reverts if fees are insufficient", async () => { await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient, { value: 1n })) diff --git a/test/integration/vaults-happy-path.integration.ts b/test/integration/vaults-happy-path.integration.ts index 8662db794..056e59995 100644 --- a/test/integration/vaults-happy-path.integration.ts +++ b/test/integration/vaults-happy-path.integration.ts @@ -146,7 +146,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { const _delegation = await ethers.getContractAt("Delegation", delegationAddress); expect(await _stakingVault.vaultHub()).to.equal(ctx.contracts.accounting.address); - expect(await _stakingVault.depositContract()).to.equal(depositContract); + expect(await _stakingVault.DEPOSIT_CONTRACT()).to.equal(depositContract); expect(await _delegation.STETH()).to.equal(ctx.contracts.lido.address); // TODO: check what else should be validated here @@ -167,8 +167,8 @@ describe("Scenario: Staking Vaults Happy Path", () => { rebalancer: curator, depositPauser: curator, depositResumer: curator, - exitRequester: curator, - withdrawalInitiator: curator, + validatorExitRequester: curator, + validatorWithdrawalRequester: curator, disconnecter: curator, nodeOperatorManager: nodeOperator, nodeOperatorFeeClaimer: nodeOperator, @@ -202,7 +202,8 @@ describe("Scenario: Staking Vaults Happy Path", () => { expect(await isSoleRoleMember(curator, await delegation.REBALANCE_ROLE())).to.be.true; expect(await isSoleRoleMember(curator, await delegation.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE())).to.be.true; expect(await isSoleRoleMember(curator, await delegation.RESUME_BEACON_CHAIN_DEPOSITS_ROLE())).to.be.true; - expect(await isSoleRoleMember(curator, await delegation.REQUEST_VALIDATOR_EXIT_ROLE())).to.be.true; + expect(await isSoleRoleMember(curator, await delegation.MARK_VALIDATORS_FOR_EXIT_ROLE())).to.be.true; + expect(await isSoleRoleMember(curator, await delegation.REQUEST_VALIDATOR_WITHDRAWALS_ROLE())).to.be.true; expect(await isSoleRoleMember(curator, await delegation.VOLUNTARY_DISCONNECT_ROLE())).to.be.true; }); @@ -372,7 +373,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { it("Should allow Owner to trigger validator exit to cover fees", async () => { // simulate validator exit const secondValidatorKey = pubKeysBatch.slice(Number(PUBKEY_LENGTH), Number(PUBKEY_LENGTH) * 2); - await delegation.connect(curator).requestValidatorExit(secondValidatorKey); + await delegation.connect(curator).markValidatorsForExit(secondValidatorKey); await updateBalance(stakingVaultAddress, VALIDATOR_DEPOSIT_SIZE); const { elapsedProtocolReward, elapsedVaultReward } = await calculateReportParams(); From a8b41abf493011b01480f28f89046944866be792 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Sat, 8 Feb 2025 13:54:14 +0000 Subject: [PATCH 50/70] chore: add tests --- contracts/0.8.25/vaults/StakingVault.sol | 21 ++++---- .../vaults/staking-vault/stakingVault.test.ts | 50 +++++++++++++++---- 2 files changed, 49 insertions(+), 22 deletions(-) diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 1dad617fb..705bf6e19 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -23,9 +23,9 @@ import {IStakingVault} from "./interfaces/IStakingVault.sol"; * The StakingVault can be used as a backing for minting new stETH if the StakingVault is connected to the VaultHub. * When minting stETH backed by the StakingVault, the VaultHub locks a portion of the StakingVault's valuation, * which cannot be withdrawn by the owner. If the locked amount exceeds the StakingVault's valuation, - * the StakingVault enters the unbalanced state. + * the StakingVault enters the unhealthy state. * In this state, the VaultHub can force-rebalance the StakingVault by withdrawing a portion of the locked amount - * and writing off the locked amount to restore the balanced state. + * and writing off the locked amount to restore the healthy state. * The owner can voluntarily rebalance the StakingVault in any state or by simply * supplying more ether to increase the valuation. * @@ -265,7 +265,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @dev Cannot withdraw more than the unlocked amount or the balance of the contract, whichever is less. * @dev Updates inOutDelta to track the net difference between funded and withdrawn ether * @dev Includes a check that valuation remains greater than locked amount after withdrawal to ensure - * `StakingVault` stays balanced and prevent reentrancy attacks. + * `StakingVault` stays healthy and prevent reentrancy attacks. */ function withdraw(address _recipient, uint256 _ether) external onlyOwner { if (_recipient == address(0)) revert ZeroArgument("_recipient"); @@ -280,7 +280,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { (bool success, ) = _recipient.call{value: _ether}(""); if (!success) revert TransferFailed(_recipient, _ether); - if (valuation() < $.locked) revert Unbalanced(); + if (valuation() < $.locked) revert ValuationBelowLockedAmount(); emit Withdrawn(msg.sender, _recipient, _ether); } @@ -303,7 +303,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @notice Rebalances StakingVault by withdrawing ether to VaultHub - * @dev Can only be called by VaultHub if StakingVault is unbalanced, + * @dev Can only be called by VaultHub if StakingVault is unhealthy, * or by owner at any moment * @param _ether Amount of ether to rebalance */ @@ -394,7 +394,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @notice Performs a deposit to the beacon chain deposit contract * @param _deposits Array of deposit structs - * @dev Includes a check to ensure `StakingVault` is balanced before making deposits + * @dev Includes a check to ensure `StakingVault` is healthy before making deposits */ function depositToBeaconChain(Deposit[] calldata _deposits) external { if (_deposits.length == 0) revert ZeroArgument("_deposits"); @@ -403,7 +403,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { if (msg.sender != $.nodeOperator) revert NotAuthorized("depositToBeaconChain", msg.sender); if ($.beaconChainDepositsPaused) revert BeaconChainDepositsArePaused(); - if (valuation() < $.locked) revert Unbalanced(); + if (valuation() < $.locked) revert ValuationBelowLockedAmount(); uint256 totalAmount = 0; uint256 numberOfDeposits = _deposits.length; @@ -456,8 +456,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @param _amounts Amounts of ether to exit, must match the length of _pubkeys. * @param _refundRecipient Address to receive the fee refund. * @dev The caller must provide sufficient fee via msg.value to cover the withdrawal request costs. - * TODO: check if the vault is unbalanced - * TODO: check auth for vo, no and unbalanced then vaulthub + * TODO: check if the vault is unhealthy */ function requestValidatorWithdrawals(bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient) external payable { uint256 value = msg.value; // cache msg.value to save gas @@ -661,9 +660,9 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { error TransferFailed(address recipient, uint256 amount); /** - * @notice Thrown when the locked amount is greater than the valuation of `StakingVault` + * @notice Thrown when the valuation of the vault falls below the locked amount */ - error Unbalanced(); + error ValuationBelowLockedAmount(); /** * @notice Thrown when an unauthorized address attempts a restricted operation diff --git a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts index 56a70c16e..e4f126d39 100644 --- a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts +++ b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts @@ -1,5 +1,5 @@ import { expect } from "chai"; -import { ZeroAddress } from "ethers"; +import { ContractTransactionReceipt, ZeroAddress } from "ethers"; import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; @@ -13,7 +13,7 @@ import { VaultHub__MockForStakingVault, } from "typechain-types"; -import { computeDepositDataRoot, de0x, ether, impersonate, streccak } from "lib"; +import { computeDepositDataRoot, de0x, ether, impersonate, MAX_UINT256, streccak } from "lib"; import { deployStakingVaultBehindBeaconProxy, deployWithdrawalsPreDeployedMock } from "test/deploy"; import { Snapshot } from "test/suite"; @@ -24,7 +24,11 @@ const MAX_UINT128 = 2n ** 128n - 1n; const SAMPLE_PUBKEY = "0x" + "ab".repeat(48); const getPubkeys = (num: number): { pubkeys: string[]; stringified: string } => { - const pubkeys = Array.from({ length: num }, (_, i) => `0x${`${(i + 1).toString().padStart(2, "0")}`.repeat(48)}`); + const pubkeys = Array.from({ length: num }, (_, i) => { + const paddedIndex = (i + 1).toString().padStart(8, "0"); + return `0x${paddedIndex.repeat(12)}`; + }); + return { pubkeys, stringified: `0x${pubkeys.map(de0x).join("")}`, @@ -241,7 +245,7 @@ describe("StakingVault.sol", () => { await expect(stakingVault.fund({ value: maxInOutDelta })).to.not.be.reverted; }); - it("restores the vault to a balanced state if the vault was unbalanced", async () => { + it("restores the vault to a healthy state if the vault was unhealthy", async () => { await stakingVault.connect(vaultHubSigner).lock(ether("1")); expect(await stakingVault.valuation()).to.be.lessThan(await stakingVault.locked()); @@ -289,7 +293,7 @@ describe("StakingVault.sol", () => { .withArgs(unlocked); }); - it.skip("reverts is vault is unbalanced", async () => {}); + it.skip("reverts if vault is unhealthy", async () => {}); it("does not revert on max int128", async () => { const forGas = ether("10"); @@ -420,7 +424,7 @@ describe("StakingVault.sol", () => { expect(await stakingVault.inOutDelta()).to.equal(inOutDeltaBefore - ether("1")); }); - it("can be called by the vault hub when the vault is unbalanced", async () => { + it("can be called by the vault hub when the vault is unhealthy", async () => { await stakingVault.connect(vaultHubSigner).report(ether("1"), ether("0.1"), ether("1.1")); expect(await stakingVault.valuation()).to.be.lessThan(await stakingVault.locked()); expect(await stakingVault.inOutDelta()).to.equal(ether("0")); @@ -540,7 +544,7 @@ describe("StakingVault.sol", () => { .withArgs("_deposits"); }); - it("reverts if the vault is not balanced", async () => { + it("reverts if the vault valuation is below the locked amount", async () => { await stakingVault.connect(vaultHubSigner).lock(ether("1")); await expect( stakingVault @@ -548,7 +552,7 @@ describe("StakingVault.sol", () => { .depositToBeaconChain([ { pubkey: "0x", signature: "0x", amount: 0, depositDataRoot: streccak("random-root") }, ]), - ).to.be.revertedWithCustomError(stakingVault, "Unbalanced"); + ).to.be.revertedWithCustomError(stakingVault, "ValuationBelowLockedAmount"); }); it("reverts if the deposits are paused", async () => { @@ -607,6 +611,11 @@ describe("StakingVault.sol", () => { .withArgs("_numberOfKeys"); }); + it("works with max uint256", async () => { + const fee = BigInt(await withdrawalRequest.fee()); + expect(await stakingVault.calculateValidatorWithdrawalsFee(MAX_UINT256)).to.equal(BigInt(MAX_UINT256) * fee); + }); + it("calculates the total fee for given number of validator keys", async () => { const newFee = 100n; await withdrawalRequest.setFee(newFee); @@ -641,13 +650,32 @@ describe("StakingVault.sol", () => { ).to.be.revertedWithCustomError(stakingVault, "InvalidValidatorPubkeysLength"); }); - it("emits the `ValidatorMarkedForExit` event for each validator", async () => { + it("emits the `ValidatorMarkedForExit` event for a single validator key", async () => { + await expect(stakingVault.connect(vaultOwner).markValidatorsForExit(SAMPLE_PUBKEY)) + .to.emit(stakingVault, "ValidatorMarkedForExit") + .withArgs(vaultOwner, SAMPLE_PUBKEY); + }); + + it("emits the exact number of `ValidatorMarkedForExit` events as the number of validator keys", async () => { const numberOfKeys = 2; const keys = getPubkeys(numberOfKeys); - await expect(stakingVault.connect(vaultOwner).markValidatorsForExit(keys.stringified)) + const tx = await stakingVault.connect(vaultOwner).markValidatorsForExit(keys.stringified); + await expect(tx.wait()) .to.emit(stakingVault, "ValidatorMarkedForExit") - .withArgs(vaultOwner, keys.pubkeys[0]); + .withArgs(vaultOwner, keys.pubkeys[0]) + .and.emit(stakingVault, "ValidatorMarkedForExit") + .withArgs(vaultOwner, keys.pubkeys[1]); + + const receipt = (await tx.wait()) as ContractTransactionReceipt; + expect(receipt.logs.length).to.equal(numberOfKeys); + }); + + it("handles large number of validator keys", async () => { + const numberOfKeys = 5000; // uses ~16300771 gas (>54% from the 30000000 gas limit) + const keys = getPubkeys(numberOfKeys); + + await stakingVault.connect(vaultOwner).markValidatorsForExit(keys.stringified); }); }); From f0d865e81c3dc66df740cd3c5a98cfa342d6a384 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Sat, 8 Feb 2025 13:56:11 +0000 Subject: [PATCH 51/70] feat: isVaultHealthy function --- contracts/0.8.25/vaults/VaultHub.sol | 7 +++++++ .../vaults/vaulthub/vaulthub.withdrawals.test.ts | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index be4347296..814ab115f 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -124,6 +124,13 @@ abstract contract VaultHub is PausableUntilWithRoles { return $.sockets[$.vaultIndex[_vault]]; } + /// @param _vault vault address + /// @return true if the vault is healthy + function isVaultHealthy(address _vault) external view returns (bool) { + VaultSocket storage socket = _connectedSocket(_vault); + return socket.sharesMinted <= _maxMintableShares(_vault, socket.reserveRatioThresholdBP, socket.shareLimit); + } + /// @notice connects a vault to the hub /// @param _vault vault address /// @param _shareLimit maximum number of stETH shares that can be minted by the vault diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts index 66970f512..a8783dd5b 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts @@ -98,6 +98,17 @@ describe("VaultHub.sol:withdrawals", () => { await vault.connect(vaultHubSigner).report(ether("0.9"), ether("1"), ether("1.1")); // slashing }; + context("isVaultHealthy", () => { + it("returns true if the vault is healthy", async () => { + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.be.true; + }); + + it("returns false if the vault is unhealthy", async () => { + await makeVaultUnhealthy(); + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.be.false; + }); + }); + context("forceValidatorWithdrawals", () => { it("reverts if msg.value is 0", async () => { await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient, { value: 0n })) From 60f3e68413b862a1c1750676dd63d98c24c0174b Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Mon, 10 Feb 2025 13:51:02 +0000 Subject: [PATCH 52/70] feat: revert if partial withdrawals are requested on the unhealthy vault --- contracts/0.8.25/vaults/StakingVault.sol | 20 ++++++++++++++++--- .../vaults/staking-vault/stakingVault.test.ts | 11 ++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 705bf6e19..0b8fdcfca 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -95,7 +95,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @notice The type of withdrawal credentials for the validators deposited from this `StakingVault`. */ - uint256 private constant WC_0x02_PREFIX = 0x02 << 248; + uint256 private constant WC_0X02_PREFIX = 0x02 << 248; /** * @notice The length of the public key in bytes @@ -350,7 +350,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @return Withdrawal credentials as bytes32 */ function withdrawalCredentials() public view returns (bytes32) { - return bytes32(WC_0x02_PREFIX | uint160(address(this))); + return bytes32(WC_0X02_PREFIX | uint160(address(this))); } /** @@ -467,7 +467,16 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { if (_refundRecipient == address(0)) revert ZeroArgument("_refundRecipient"); ERC7201Storage storage $ = _getStorage(); - if (msg.sender == $.nodeOperator || msg.sender == owner() || (valuation() < $.locked && msg.sender == address(VAULT_HUB))) { + bool isHealthy = valuation() >= $.locked; + if (!isHealthy) { + for (uint256 i = 0; i < _amounts.length; i++) { + if (_amounts[i] > 0) { + revert PartialWithdrawalsForbidden(); + } + } + } + + if (msg.sender == $.nodeOperator || msg.sender == owner() || (!isHealthy && msg.sender == address(VAULT_HUB))) { uint256 feePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); uint256 totalFee = (feePerRequest * _pubkeys.length) / PUBLIC_KEY_LENGTH; if (value < totalFee) { @@ -723,4 +732,9 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @param _amount Amount of ether to refund */ error ValidatorWithdrawalFeeRefundFailed(address _sender, uint256 _amount); + + /** + * @notice Thrown when partial withdrawals are forbidden on an unhealthy vault + */ + error PartialWithdrawalsForbidden(); } diff --git a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts index e4f126d39..7a18ec834 100644 --- a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts +++ b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts @@ -874,6 +874,17 @@ describe("StakingVault.sol", () => { .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, 0n), baseFee); }); + + it("reverts if partial withdrawals is called on an unhealthy vault", async () => { + await stakingVault.fund({ value: ether("1") }); + await stakingVault.connect(vaultHubSigner).report(ether("0.9"), ether("1"), ether("1.1")); // slashing + + await expect( + stakingVault + .connect(vaultOwner) + .requestValidatorWithdrawals(SAMPLE_PUBKEY, [ether("1")], vaultOwnerAddress, { value: 1n }), + ).to.be.revertedWithCustomError(stakingVault, "PartialWithdrawalsForbidden"); + }); }); context("computeDepositDataRoot", () => { From a4bd6b8af45e77e0b6e3737f544b1605286e53dd Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 11 Feb 2025 18:19:39 +0000 Subject: [PATCH 53/70] feat: hardhat based logs --- lib/deploy.ts | 11 +- lib/index.ts | 2 - lib/log.ts | 23 --- lib/protocol/helpers/accounting.ts | 19 +- lib/protocol/helpers/nor.ts | 13 +- lib/protocol/helpers/sdvt.ts | 16 +- lib/protocol/helpers/staking.ts | 8 +- lib/protocol/helpers/withdrawal.ts | 18 +- lib/transaction.ts | 42 ---- lib/type.ts | 15 -- tasks/index.ts | 7 +- tasks/logger.ts | 181 ++++++++++++++++++ test/0.8.9/lidoLocator.test.ts | 3 +- test/integration/accounting.integration.ts | 23 +-- test/integration/burn-shares.integration.ts | 11 +- .../protocol-happy-path.integration.ts | 29 +-- 16 files changed, 231 insertions(+), 190 deletions(-) delete mode 100644 lib/transaction.ts delete mode 100644 lib/type.ts create mode 100644 tasks/logger.ts diff --git a/lib/deploy.ts b/lib/deploy.ts index 1b9a1626a..24aa589c6 100644 --- a/lib/deploy.ts +++ b/lib/deploy.ts @@ -5,7 +5,7 @@ import { FactoryOptions } from "hardhat/types"; import { LidoLocator } from "typechain-types"; import { addContractHelperFields, DeployedContract, getContractPath, loadContract, LoadedContract } from "lib/contract"; -import { ConvertibleToString, cy, gr, log, yl } from "lib/log"; +import { ConvertibleToString, cy, log, yl } from "lib/log"; import { incrementGasUsed, Sk, updateObjectInState } from "lib/state-file"; const GAS_PRIORITY_FEE = process.env.GAS_PRIORITY_FEE || null; @@ -36,15 +36,11 @@ export async function makeTx( log.withArguments(`Call: ${yl(contract.name)}[${cy(contract.address)}].${yl(funcName)}`, args); const tx = await contract.getFunction(funcName)(...args, txParams); - log(` Transaction: ${tx.hash} (nonce ${yl(tx.nonce)})...`); const receipt = await tx.wait(); const gasUsed = receipt.gasUsed; incrementGasUsed(gasUsed, withStateFile); - log(` Executed (gas used: ${yl(gasUsed)})`); - log.emptyLine(); - return receipt; } @@ -80,8 +76,6 @@ async function deployContractType2( throw new Error(`Failed to send the deployment transaction for ${artifactName}`); } - log(` Transaction: ${tx.hash} (nonce ${yl(tx.nonce)})`); - const receipt = await tx.wait(); if (!receipt) { throw new Error(`Failed to wait till the transaction ${tx.hash} execution!`); @@ -92,9 +86,6 @@ async function deployContractType2( (contract as DeployedContract).deploymentGasUsed = gasUsed; (contract as DeployedContract).deploymentTx = tx.hash; - log(` Deployed: ${gr(receipt.contractAddress!)} (gas used: ${yl(gasUsed)})`); - log.emptyLine(); - await addContractHelperFields(contract, artifactName); return contract as DeployedContract; diff --git a/lib/index.ts b/lib/index.ts index f1df50e7f..a2dde748d 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -21,6 +21,4 @@ export * from "./signing-keys"; export * from "./state-file"; export * from "./string"; export * from "./time"; -export * from "./transaction"; -export * from "./type"; export * from "./units"; diff --git a/lib/log.ts b/lib/log.ts index 1291fafba..7e053e632 100644 --- a/lib/log.ts +++ b/lib/log.ts @@ -1,8 +1,6 @@ import chalk from "chalk"; import path from "path"; -import { TraceableTransaction } from "./type"; - // @ts-expect-error TS2339: Property 'toJSON' does not exist on type 'BigInt'. BigInt.prototype.toJSON = function () { return this.toString(); @@ -127,24 +125,3 @@ log.debug = (title: string, records: Record) => { Object.keys(records).forEach((label) => _record(` ${label}`, records[label])); log.emptyLine(); }; - -log.traceTransaction = (name: string, tx: TraceableTransaction) => { - const value = tx.value === "0.0" ? "" : `Value: ${yl(tx.value)} ETH`; - const from = `From: ${yl(tx.from)}`; - const to = `To: ${yl(tx.to)}`; - const gasPrice = `Gas price: ${yl(tx.gasPrice)} gwei`; - const gasLimit = `Gas limit: ${yl(tx.gasLimit)}`; - const gasUsed = `Gas used: ${yl(tx.gasUsed)} (${yl(tx.gasUsedPercent)})`; - const block = `Block: ${yl(tx.blockNumber)}`; - const nonce = `Nonce: ${yl(tx.nonce)}`; - - const color = tx.status ? gr : rd; - const status = `${color(name)} ${color(tx.status ? "confirmed" : "failed")}`; - - log(`Transaction sent:`, yl(tx.hash)); - log(` ${from} ${to} ${value}`); - log(` ${gasPrice} ${gasLimit} ${gasUsed}`); - log(` ${block} ${nonce}`); - log(` ${status}`); - log.emptyLine(); -}; diff --git a/lib/protocol/helpers/accounting.ts b/lib/protocol/helpers/accounting.ts index 7ff51943c..d07b33591 100644 --- a/lib/protocol/helpers/accounting.ts +++ b/lib/protocol/helpers/accounting.ts @@ -18,7 +18,6 @@ import { log, ONE_GWEI, streccak, - trace, } from "lib"; import { ProtocolContext } from "../types"; @@ -447,7 +446,7 @@ export const handleOracleReport = async ( "El Rewards Vault Balance": formatEther(elRewardsVaultBalance), }); - const handleReportTx = await lido.connect(accountingOracleAccount).handleOracleReport( + await lido.connect(accountingOracleAccount).handleOracleReport( reportTimestamp, 1n * 24n * 60n * 60n, // 1 day beaconValidators, @@ -458,8 +457,6 @@ export const handleOracleReport = async ( [], 0n, ); - - await trace("lido.handleOracleReport", handleReportTx); } catch (error) { log.error("Error", (error as Error).message ?? "Unknown error during oracle report simulation"); expect(error).to.be.undefined; @@ -629,8 +626,6 @@ export const submitReport = async ( const reportTx = await accountingOracle.connect(submitter).submitReportData(data, oracleVersion); - await trace("accountingOracle.submitReportData", reportTx); - log.debug("Pushed oracle report main data", { "Ref slot": refSlot, "Consensus version": consensusVersion, @@ -640,10 +635,8 @@ export const submitReport = async ( let extraDataTx: ContractTransactionResponse; if (extraDataFormat) { extraDataTx = await accountingOracle.connect(submitter).submitReportExtraDataList(extraDataList); - await trace("accountingOracle.submitReportExtraDataList", extraDataTx); } else { extraDataTx = await accountingOracle.connect(submitter).submitReportExtraDataEmpty(); - await trace("accountingOracle.submitReportExtraDataEmpty", extraDataTx); } const state = await accountingOracle.getProcessingState(); @@ -712,8 +705,7 @@ export const ensureOracleCommitteeMembers = async (ctx: ProtocolContext, minMemb log.warning(`Adding oracle committee member ${count}`); const address = getOracleCommitteeMemberAddress(count); - const addTx = await hashConsensus.connect(agentSigner).addMember(address, minMembersCount); - await trace("hashConsensus.addMember", addTx); + await hashConsensus.connect(agentSigner).addMember(address, minMembersCount); addresses.push(address); @@ -745,9 +737,7 @@ export const ensureHashConsensusInitialEpoch = async (ctx: ProtocolContext) => { const updatedInitialEpoch = (latestBlockTimestamp - genesisTime) / (slotsPerEpoch * secondsPerSlot); const agentSigner = await ctx.getSigner("agent"); - - const tx = await hashConsensus.connect(agentSigner).updateInitialEpoch(updatedInitialEpoch); - await trace("hashConsensus.updateInitialEpoch", tx); + await hashConsensus.connect(agentSigner).updateInitialEpoch(updatedInitialEpoch); log.success("Hash consensus epoch initialized"); } @@ -784,8 +774,7 @@ const reachConsensus = async ( submitter = member; } - const tx = await hashConsensus.connect(member).submitReport(refSlot, reportHash, consensusVersion); - await trace("hashConsensus.submitReport", tx); + await hashConsensus.connect(member).submitReport(refSlot, reportHash, consensusVersion); } const { consensusReport } = await hashConsensus.getConsensusState(); diff --git a/lib/protocol/helpers/nor.ts b/lib/protocol/helpers/nor.ts index c5185d82a..eb86f3a0f 100644 --- a/lib/protocol/helpers/nor.ts +++ b/lib/protocol/helpers/nor.ts @@ -1,7 +1,7 @@ import { expect } from "chai"; import { randomBytes } from "ethers"; -import { certainAddress, log, trace } from "lib"; +import { certainAddress, log } from "lib"; import { ProtocolContext, StakingModuleName } from "../types"; @@ -126,9 +126,7 @@ export const norAddNodeOperator = async ( log.warning(`Adding fake NOR operator ${operatorId}`); const agentSigner = await ctx.getSigner("agent"); - - const addTx = await nor.connect(agentSigner).addNodeOperator(name, rewardAddress); - await trace("nodeOperatorRegistry.addNodeOperator", addTx); + await nor.connect(agentSigner).addNodeOperator(name, rewardAddress); log.debug("Added NOR fake operator", { "Operator ID": operatorId, @@ -160,7 +158,7 @@ export const norAddOperatorKeys = async ( const votingSigner = await ctx.getSigner("voting"); - const addKeysTx = await nor + await nor .connect(votingSigner) .addSigningKeys( operatorId, @@ -168,7 +166,6 @@ export const norAddOperatorKeys = async ( randomBytes(Number(keysToAdd * PUBKEY_LENGTH)), randomBytes(Number(keysToAdd * SIGNATURE_LENGTH)), ); - await trace("nodeOperatorRegistry.addSigningKeys", addKeysTx); const totalKeysAfter = await nor.getTotalSigningKeyCount(operatorId); const unusedKeysAfter = await nor.getUnusedSigningKeyCount(operatorId); @@ -204,9 +201,7 @@ const norSetOperatorStakingLimit = async ( log.warning(`Setting NOR operator ${operatorId} staking limit`); const votingSigner = await ctx.getSigner("voting"); - - const setLimitTx = await nor.connect(votingSigner).setNodeOperatorStakingLimit(operatorId, limit); - await trace("nodeOperatorRegistry.setNodeOperatorStakingLimit", setLimitTx); + await nor.connect(votingSigner).setNodeOperatorStakingLimit(operatorId, limit); log.success(`Set NOR operator ${operatorId} staking limit`); }; diff --git a/lib/protocol/helpers/sdvt.ts b/lib/protocol/helpers/sdvt.ts index cc722e580..35b8fd96f 100644 --- a/lib/protocol/helpers/sdvt.ts +++ b/lib/protocol/helpers/sdvt.ts @@ -1,7 +1,7 @@ import { expect } from "chai"; import { randomBytes } from "ethers"; -import { ether, impersonate, log, streccak, trace } from "lib"; +import { ether, impersonate, log, streccak } from "lib"; import { ProtocolContext } from "../types"; @@ -138,17 +138,14 @@ const sdvtAddNodeOperator = async ( const easyTrackExecutor = await ctx.getSigner("easyTrack"); - const addTx = await sdvt.connect(easyTrackExecutor).addNodeOperator(name, rewardAddress); - await trace("simpleDVT.addNodeOperator", addTx); - - const grantPermissionTx = await acl.connect(easyTrackExecutor).grantPermissionP( + await sdvt.connect(easyTrackExecutor).addNodeOperator(name, rewardAddress); + await acl.connect(easyTrackExecutor).grantPermissionP( managerAddress, sdvt.address, MANAGE_SIGNING_KEYS_ROLE, // See https://legacy-docs.aragon.org/developers/tools/aragonos/reference-aragonos-3#parameter-interpretation for details [1 << (240 + Number(operatorId))], ); - await trace("acl.grantPermissionP", grantPermissionTx); log.debug("Added SDVT fake operator", { "Operator ID": operatorId, @@ -176,8 +173,7 @@ const sdvtAddNodeOperatorKeys = async ( const { rewardAddress } = await sdvt.getNodeOperator(operatorId, false); const actor = await impersonate(rewardAddress, ether("100")); - - const addKeysTx = await sdvt + await sdvt .connect(actor) .addSigningKeys( operatorId, @@ -185,7 +181,6 @@ const sdvtAddNodeOperatorKeys = async ( randomBytes(Number(keysToAdd * PUBKEY_LENGTH)), randomBytes(Number(keysToAdd * SIGNATURE_LENGTH)), ); - await trace("simpleDVT.addSigningKeys", addKeysTx); const totalKeysAfter = await sdvt.getTotalSigningKeyCount(operatorId); const unusedKeysAfter = await sdvt.getUnusedSigningKeyCount(operatorId); @@ -218,6 +213,5 @@ const sdvtSetOperatorStakingLimit = async ( const easyTrackExecutor = await ctx.getSigner("easyTrack"); - const setLimitTx = await sdvt.connect(easyTrackExecutor).setNodeOperatorStakingLimit(operatorId, limit); - await trace("simpleDVT.setNodeOperatorStakingLimit", setLimitTx); + await sdvt.connect(easyTrackExecutor).setNodeOperatorStakingLimit(operatorId, limit); }; diff --git a/lib/protocol/helpers/staking.ts b/lib/protocol/helpers/staking.ts index 39b8b9884..4d7a2874a 100644 --- a/lib/protocol/helpers/staking.ts +++ b/lib/protocol/helpers/staking.ts @@ -1,6 +1,6 @@ import { ZeroAddress } from "ethers"; -import { certainAddress, ether, impersonate, log, trace } from "lib"; +import { certainAddress, ether, impersonate, log } from "lib"; import { ZERO_HASH } from "test/deploy"; @@ -17,8 +17,7 @@ export const unpauseStaking = async (ctx: ProtocolContext) => { log.warning("Unpausing staking contract"); const votingSigner = await ctx.getSigner("voting"); - const tx = await lido.connect(votingSigner).resume(); - await trace("lido.resume", tx); + await lido.connect(votingSigner).resume(); log.success("Staking contract unpaused"); } @@ -35,8 +34,7 @@ export const ensureStakeLimit = async (ctx: ProtocolContext) => { const stakeLimitIncreasePerBlock = ether("20"); // this is an arbitrary value const votingSigner = await ctx.getSigner("voting"); - const tx = await lido.connect(votingSigner).setStakingLimit(maxStakeLimit, stakeLimitIncreasePerBlock); - await trace("lido.setStakingLimit", tx); + await lido.connect(votingSigner).setStakingLimit(maxStakeLimit, stakeLimitIncreasePerBlock); log.success("Staking limit set"); } diff --git a/lib/protocol/helpers/withdrawal.ts b/lib/protocol/helpers/withdrawal.ts index 3066a8a73..4f360238c 100644 --- a/lib/protocol/helpers/withdrawal.ts +++ b/lib/protocol/helpers/withdrawal.ts @@ -1,6 +1,6 @@ import { ZeroAddress } from "ethers"; -import { certainAddress, ether, impersonate, log, trace } from "lib"; +import { certainAddress, ether, impersonate, log } from "lib"; import { ProtocolContext } from "../types"; @@ -19,10 +19,7 @@ export const unpauseWithdrawalQueue = async (ctx: ProtocolContext) => { const agentSignerAddress = await agentSigner.getAddress(); await withdrawalQueue.connect(agentSigner).grantRole(resumeRole, agentSignerAddress); - - const tx = await withdrawalQueue.connect(agentSigner).resume(); - await trace("withdrawalQueue.resume", tx); - + await withdrawalQueue.connect(agentSigner).resume(); await withdrawalQueue.connect(agentSigner).revokeRole(resumeRole, agentSignerAddress); log.success("Unpaused withdrawal queue contract"); @@ -37,8 +34,7 @@ export const finalizeWithdrawalQueue = async (ctx: ProtocolContext) => { const stEthHolderAmount = ether("10000"); // Here sendTransaction is used to validate native way of submitting ETH for stETH - const tx = await stEthHolder.sendTransaction({ to: lido.address, value: stEthHolderAmount }); - await trace("stEthHolder.sendTransaction", tx); + await stEthHolder.sendTransaction({ to: lido.address, value: stEthHolderAmount }); let lastFinalizedRequestId = await withdrawalQueue.getLastFinalizedRequestId(); let lastRequestId = await withdrawalQueue.getLastRequestId(); @@ -54,14 +50,10 @@ export const finalizeWithdrawalQueue = async (ctx: ProtocolContext) => { "Last request ID": lastRequestId, }); - const submitTx = await ctx.contracts.lido.connect(ethHolder).submit(ZeroAddress, { value: ether("10000") }); - - await trace("lido.submit", submitTx); + await ctx.contracts.lido.connect(ethHolder).submit(ZeroAddress, { value: ether("10000") }); } - const submitTx = await ctx.contracts.lido.connect(ethHolder).submit(ZeroAddress, { value: ether("10000") }); - - await trace("lido.submit", submitTx); + await ctx.contracts.lido.connect(ethHolder).submit(ZeroAddress, { value: ether("10000") }); log.success("Finalized withdrawal queue"); }; diff --git a/lib/transaction.ts b/lib/transaction.ts deleted file mode 100644 index 0160a7f39..000000000 --- a/lib/transaction.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - ContractTransactionReceipt, - ContractTransactionResponse, - TransactionReceipt, - TransactionResponse, -} from "ethers"; -import hre, { ethers } from "hardhat"; - -import { log } from "lib"; - -type Transaction = TransactionResponse | ContractTransactionResponse; -type Receipt = TransactionReceipt | ContractTransactionReceipt; - -export const trace = async (name: string, tx: Transaction) => { - const receipt = await tx.wait(); - - if (!receipt) { - log.error("Failed to trace transaction: no receipt!"); - throw new Error(`Failed to trace transaction for ${name}: no receipt!`); - } - - const network = await tx.provider.getNetwork(); - const config = hre.config.networks[network.name]; - const blockGasLimit = "blockGasLimit" in config ? config.blockGasLimit : 30_000_000; - const gasUsedPercent = (Number(receipt.gasUsed) / blockGasLimit) * 100; - - log.traceTransaction(name, { - from: tx.from, - to: tx.to ?? `New contract @ ${receipt.contractAddress}`, - value: ethers.formatEther(tx.value), - gasUsed: ethers.formatUnits(receipt.gasUsed, "wei"), - gasPrice: ethers.formatUnits(receipt.gasPrice, "gwei"), - gasUsedPercent: `${gasUsedPercent.toFixed(2)}%`, - gasLimit: blockGasLimit.toString(), - nonce: tx.nonce, - blockNumber: receipt.blockNumber, - hash: receipt.hash, - status: !!receipt.status, - }); - - return receipt as T; -}; diff --git a/lib/type.ts b/lib/type.ts deleted file mode 100644 index 1660da4ea..000000000 --- a/lib/type.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type ArrayToUnion = A[number]; - -export type TraceableTransaction = { - from: string; - to: string; - value: string; - gasUsed: string; - gasPrice: string; - gasLimit: string; - gasUsedPercent: string; - nonce: number; - blockNumber: number; - hash: string; - status: boolean; -}; diff --git a/tasks/index.ts b/tasks/index.ts index 04b17d7c9..570db57d5 100644 --- a/tasks/index.ts +++ b/tasks/index.ts @@ -1,3 +1,4 @@ -export * from "./verify-contracts"; -export * from "./extract-abis"; -export * from "./solidity-get-source"; +import "./logger"; +import "./solidity-get-source"; +import "./extract-abis"; +import "./verify-contracts"; diff --git a/tasks/logger.ts b/tasks/logger.ts new file mode 100644 index 000000000..087c2385f --- /dev/null +++ b/tasks/logger.ts @@ -0,0 +1,181 @@ +import "hardhat/types/runtime"; +import chalk from "chalk"; +import { formatUnits, Interface, TransactionReceipt, TransactionResponse } from "ethers"; +import { extendEnvironment } from "hardhat/config"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; + +// Custom errors +class NoReceiptError extends Error { + constructor() { + super("Transaction receipt not found"); + } +} + +// Types +interface FunctionDetails { + name?: string; + functionName?: string; +} + +enum TransactionType { + CONTRACT_DEPLOYMENT = "Contract deployment", + ETH_TRANSFER = "ETH transfer", + CONTRACT_CALL = "Contract call", +} + +// Constants +const DEFAULT_BLOCK_GAS_LIMIT = 30_000_000; +const FUNCTION_SIGNATURE_LENGTH = 10; + +// Cache for contract interfaces and function details +const interfaceCache = new Map(); +const functionDetailsCache = new Map(); + +// Helper functions +function formatGasUsage(gasUsed: bigint, blockGasLimit: number): string { + const gasUsedPercent = (Number(gasUsed) * 100) / blockGasLimit; + return `${gasUsed} (${gasUsedPercent.toFixed(2)}%)`; +} + +function formatTransactionLines( + tx: TransactionResponse, + receipt: TransactionReceipt, + txType: string, + name: string | undefined, + functionName: string | undefined, + blockGasLimit: number, + gasPrice: string, +): string[] { + const lines = [ + `Transaction sent: ${chalk.yellow(receipt.hash)}`, + ` From: ${chalk.cyan(tx.from)} To: ${chalk.cyan(tx.to || receipt.contractAddress)}`, + ` Gas price: ${chalk.yellow(gasPrice)} gwei Gas limit: ${chalk.yellow(blockGasLimit)} Gas used: ${chalk.yellow(formatGasUsage(receipt.gasUsed, blockGasLimit))}`, + ` Block: ${chalk.yellow(receipt.blockNumber)} Nonce: ${chalk.yellow(tx.nonce)}`, + ]; + + const color = receipt.status ? chalk.green : chalk.red; + const status = receipt.status ? "confirmed" : "failed"; + + if (txType === TransactionType.CONTRACT_DEPLOYMENT) { + lines.push(` Contract address: ${chalk.cyan(receipt.contractAddress)}`); + lines.push(` ${color(name || "Contract deployment")} ${color(status)}`); + } else if (txType === TransactionType.ETH_TRANSFER) { + lines.push(` ETH transfer: ${chalk.cyan(tx.value)}`); + lines.push(` ${color("ETH transfer")} ${color(status)}`); + } else { + const txName = name && functionName ? `${name}.${functionName}` : functionName || "Contract call"; + lines.push(` ${color(txName)} ${color(status)}`); + } + + return lines; +} + +extendEnvironment((hre: HardhatRuntimeEnvironment) => { + const originalSendTransaction = hre.ethers.provider.send; + + // Wrap the provider's send method to intercept transactions + hre.ethers.provider.send = async function (method: string, params: unknown[]) { + const result = await originalSendTransaction.apply(this, [method, params]); + + // Only log eth_sendTransaction and eth_sendRawTransaction calls + if (method === "eth_sendTransaction" || method === "eth_sendRawTransaction") { + const tx = (await this.getTransaction(result)) as TransactionResponse; + await logTransaction(tx); + } + + return result; + }; + + async function getFunctionDetails(tx: TransactionResponse): Promise { + if (!tx.data || tx.data === "0x" || !tx.to) return {}; + + // Check cache first + const cacheKey = `${tx.to}-${tx.data.slice(0, FUNCTION_SIGNATURE_LENGTH)}`; + if (functionDetailsCache.has(cacheKey)) { + return functionDetailsCache.get(cacheKey)!; + } + + try { + // Try to get contract name and function name from all available artifacts + const allArtifacts = await hre.artifacts.getAllFullyQualifiedNames(); + + for (const artifactName of allArtifacts) { + try { + let iface: Interface; + + // Check interface cache + if (interfaceCache.has(artifactName)) { + iface = interfaceCache.get(artifactName)!; + } else { + const artifact = await hre.artifacts.readArtifact(artifactName); + iface = new Interface(artifact.abi); + interfaceCache.set(artifactName, iface); + } + + const result = iface.parseTransaction({ data: tx.data }); + + if (result) { + const details = { + name: artifactName.split(":").pop() || "", + functionName: result.name, + }; + functionDetailsCache.set(cacheKey, details); + return details; + } + } catch { + continue; // Skip artifacts that can't be parsed + } + } + } catch (error) { + console.warn("Error getting function details:", error); + } + + // Cache and return function signature if we can't decode + const details = { + functionName: tx.data.slice(0, FUNCTION_SIGNATURE_LENGTH), + }; + functionDetailsCache.set(cacheKey, details); + return details; + } + + async function logTransaction(tx: TransactionResponse): Promise { + const receipt = await tx.wait(); + if (!receipt) { + throw new NoReceiptError(); + } + + try { + const network = await tx.provider.getNetwork(); + const config = hre.config.networks[network.name]; + const blockGasLimit = "blockGasLimit" in config ? config.blockGasLimit : DEFAULT_BLOCK_GAS_LIMIT; + + const txType = await getTxType(tx, receipt); + const { name, functionName } = await getFunctionDetails(tx); + const gasPrice = formatUnits(receipt.gasPrice || 0n, "gwei"); + + const lines = formatTransactionLines(tx, receipt, txType, name, functionName, blockGasLimit, gasPrice); + + lines.forEach((line) => console.log(line)); + + return receipt; + } catch (error) { + console.error("Error logging transaction:", error); + return receipt; + } + } + + async function getTxType(tx: TransactionResponse, receipt: TransactionReceipt): Promise { + if (receipt.contractAddress) { + return TransactionType.CONTRACT_DEPLOYMENT; + } + + if (!tx.data || tx.data === "0x") { + return TransactionType.ETH_TRANSFER; + } + + const { name, functionName } = await getFunctionDetails(tx); + return name && functionName ? `${name}.${functionName}` : functionName || TransactionType.CONTRACT_CALL; + } + + return logTransaction; +}); diff --git a/test/0.8.9/lidoLocator.test.ts b/test/0.8.9/lidoLocator.test.ts index f970de0c0..711869b43 100644 --- a/test/0.8.9/lidoLocator.test.ts +++ b/test/0.8.9/lidoLocator.test.ts @@ -4,7 +4,7 @@ import { ethers } from "hardhat"; import { LidoLocator } from "typechain-types"; -import { ArrayToUnion, randomAddress } from "lib"; +import { randomAddress } from "lib"; const services = [ "accountingOracle", @@ -23,6 +23,7 @@ const services = [ "oracleDaemonConfig", ] as const; +type ArrayToUnion = A[number]; type Service = ArrayToUnion; type Config = Record; diff --git a/test/integration/accounting.integration.ts b/test/integration/accounting.integration.ts index 916314310..03d22a5f4 100644 --- a/test/integration/accounting.integration.ts +++ b/test/integration/accounting.integration.ts @@ -5,7 +5,7 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; -import { ether, impersonate, log, ONE_GWEI, trace, updateBalance } from "lib"; +import { ether, impersonate, log, ONE_GWEI, updateBalance } from "lib"; import { getProtocolContext, ProtocolContext } from "lib/protocol"; import { getReportTimeElapsed, report } from "lib/protocol/helpers"; @@ -103,8 +103,7 @@ describe("Accounting", () => { const { lido, wstETH } = ctx.contracts; if (!(await lido.sharesOf(wstETH.address))) { const wstEthSigner = await impersonate(wstETH.address, ether("10001")); - const submitTx = await lido.connect(wstEthSigner).submit(ZeroAddress, { value: ether("10000") }); - await trace("lido.submit", submitTx); + await lido.connect(wstEthSigner).submit(ZeroAddress, { value: ether("10000") }); } } @@ -116,8 +115,7 @@ describe("Accounting", () => { while ((await withdrawalQueue.getLastRequestId()) != (await withdrawalQueue.getLastFinalizedRequestId())) { await report(ctx); - const submitTx = await lido.connect(ethHolder).submit(ZeroAddress, { value: ether("10000") }); - await trace("lido.submit", submitTx); + await lido.connect(ethHolder).submit(ZeroAddress, { value: ether("10000") }); } } @@ -742,8 +740,7 @@ describe("Accounting", () => { const stethOfShares = await lido.getPooledEthByShares(sharesLimit); const wstEthSigner = await impersonate(wstETH.address, ether("1")); - const approveTx = await lido.connect(wstEthSigner).approve(burner.address, stethOfShares); - await trace("lido.approve", approveTx); + await lido.connect(wstEthSigner).approve(burner.address, stethOfShares); const coverShares = sharesLimit / 3n; const noCoverShares = sharesLimit - sharesLimit / 3n; @@ -751,7 +748,7 @@ describe("Accounting", () => { const lidoSigner = await impersonate(lido.address); const burnTx = await burner.connect(lidoSigner).requestBurnShares(wstETH.address, noCoverShares); - const burnTxReceipt = await trace("burner.requestBurnShares", burnTx); + const burnTxReceipt = (await burnTx.wait()) as ContractTransactionReceipt; const sharesBurntEvent = getFirstEvent(burnTxReceipt, "StETHBurnRequested"); expect(sharesBurntEvent.args.amountOfShares).to.equal(noCoverShares, "StETHBurnRequested: amountOfShares mismatch"); @@ -762,10 +759,7 @@ describe("Accounting", () => { ); const burnForCoverTx = await burner.connect(lidoSigner).requestBurnSharesForCover(wstETH.address, coverShares); - const burnForCoverTxReceipt = await trace( - "burner.requestBurnSharesForCover", - burnForCoverTx, - ); + const burnForCoverTxReceipt = (await burnForCoverTx.wait()) as ContractTransactionReceipt; const sharesBurntForCoverEvent = getFirstEvent(burnForCoverTxReceipt, "StETHBurnRequested"); expect(sharesBurntForCoverEvent.args.amountOfShares).to.equal(coverShares); @@ -819,8 +813,7 @@ describe("Accounting", () => { const stethOfShares = await lido.getPooledEthByShares(limitWithExcess); const wstEthSigner = await impersonate(wstETH.address, ether("1")); - const approveTx = await lido.connect(wstEthSigner).approve(burner.address, stethOfShares); - await trace("lido.approve", approveTx); + await lido.connect(wstEthSigner).approve(burner.address, stethOfShares); const coverShares = limit / 3n; const noCoverShares = limit - limit / 3n + excess; @@ -828,7 +821,7 @@ describe("Accounting", () => { const lidoSigner = await impersonate(lido.address); const burnTx = await burner.connect(lidoSigner).requestBurnShares(wstETH.address, noCoverShares); - const burnTxReceipt = await trace("burner.requestBurnShares", burnTx); + const burnTxReceipt = (await burnTx.wait()) as ContractTransactionReceipt; const sharesBurntEvent = getFirstEvent(burnTxReceipt, "StETHBurnRequested"); expect(sharesBurntEvent.args.amountOfShares).to.equal(noCoverShares, "StETHBurnRequested: amountOfShares mismatch"); diff --git a/test/integration/burn-shares.integration.ts b/test/integration/burn-shares.integration.ts index 0effa7dc5..ade3c1830 100644 --- a/test/integration/burn-shares.integration.ts +++ b/test/integration/burn-shares.integration.ts @@ -4,7 +4,7 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { ether, impersonate, log, trace } from "lib"; +import { ether, impersonate, log } from "lib"; import { getProtocolContext, ProtocolContext } from "lib/protocol"; import { finalizeWithdrawalQueue, handleOracleReport } from "lib/protocol/helpers"; @@ -47,8 +47,7 @@ describe("Burn Shares", () => { it("Should allow stranger to submit ETH", async () => { const { lido } = ctx.contracts; - const submitTx = await lido.connect(stranger).submit(ZeroAddress, { value: amount }); - await trace("lido.submit", submitTx); + await lido.connect(stranger).submit(ZeroAddress, { value: amount }); const stEthBefore = await lido.balanceOf(stranger.address); expect(stEthBefore).to.be.approximately(amount, 10n, "Incorrect stETH balance after submit"); @@ -74,12 +73,10 @@ describe("Burn Shares", () => { it("Should burn shares after report", async () => { const { lido, burner } = ctx.contracts; - const approveTx = await lido.connect(stranger).approve(burner.address, ether("1000000")); - await trace("lido.approve", approveTx); + await lido.connect(stranger).approve(burner.address, ether("1000000")); const lidoSigner = await impersonate(lido.address); - const burnTx = await burner.connect(lidoSigner).requestBurnSharesForCover(stranger, sharesToBurn); - await trace("burner.requestBurnSharesForCover", burnTx); + await burner.connect(lidoSigner).requestBurnSharesForCover(stranger, sharesToBurn); const { beaconValidators, beaconBalance } = await lido.getBeaconStat(); diff --git a/test/integration/protocol-happy-path.integration.ts b/test/integration/protocol-happy-path.integration.ts index 3e034d702..5b32f8783 100644 --- a/test/integration/protocol-happy-path.integration.ts +++ b/test/integration/protocol-happy-path.integration.ts @@ -4,7 +4,7 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { batch, ether, impersonate, log, trace, updateBalance } from "lib"; +import { batch, ether, impersonate, log, updateBalance } from "lib"; import { getProtocolContext, ProtocolContext } from "lib/protocol"; import { finalizeWithdrawalQueue, @@ -58,8 +58,7 @@ describe("Protocol Happy Path", () => { const stEthHolderAmount = ether("1000"); // Deposit some eth - const tx = await lido.connect(stEthHolder).submit(ZeroAddress, { value: stEthHolderAmount }); - await trace("lido.submit", tx); + await lido.connect(stEthHolder).submit(ZeroAddress, { value: stEthHolderAmount }); const stEthHolderBalance = await lido.balanceOf(stEthHolder.address); expect(stEthHolderBalance).to.approximately(stEthHolderAmount, 10n, "stETH balance increased"); @@ -73,11 +72,8 @@ describe("Protocol Happy Path", () => { uncountedStETHShares = await lido.sharesOf(withdrawalQueue.address); // Added to facilitate the burner transfers - const approveTx = await lido.connect(stEthHolder).approve(withdrawalQueue.address, 1000n); - await trace("lido.approve", approveTx); - - const requestWithdrawalsTx = await withdrawalQueue.connect(stEthHolder).requestWithdrawals([1000n], stEthHolder); - await trace("withdrawalQueue.requestWithdrawals", requestWithdrawalsTx); + await lido.connect(stEthHolder).approve(withdrawalQueue.address, 1000n); + await withdrawalQueue.connect(stEthHolder).requestWithdrawals([1000n], stEthHolder); expect(lastFinalizedRequestId).to.equal(lastRequestId); }); @@ -129,7 +125,7 @@ describe("Protocol Happy Path", () => { }); const tx = await lido.connect(stranger).submit(ZeroAddress, { value: AMOUNT }); - const receipt = await trace("lido.submit", tx); + const receipt = (await tx.wait()) as ContractTransactionReceipt; expect(receipt).not.to.be.null; @@ -225,7 +221,7 @@ describe("Protocol Happy Path", () => { let expectedBufferedEtherAfterDeposit = bufferedEtherBeforeDeposit; for (const module of stakingModules) { const depositTx = await lido.connect(dsmSigner).deposit(MAX_DEPOSIT, module.id, ZERO_HASH); - const depositReceipt = await trace(`lido.deposit (${module.name})`, depositTx); + const depositReceipt = (await depositTx.wait()) as ContractTransactionReceipt; const unbufferedEvent = ctx.getEvents(depositReceipt, "Unbuffered")[0]; const unbufferedAmount = unbufferedEvent?.args[0] || 0n; const deposits = unbufferedAmount / ether("32"); @@ -428,7 +424,7 @@ describe("Protocol Happy Path", () => { amountWithRewards = balanceBeforeRequest.stETH; const approveTx = await lido.connect(stranger).approve(withdrawalQueue.address, amountWithRewards); - const approveTxReceipt = await trace("lido.approve", approveTx); + const approveTxReceipt = (await approveTx.wait()) as ContractTransactionReceipt; const approveEvent = ctx.getEvents(approveTxReceipt, "Approval")[0]; @@ -444,11 +440,7 @@ describe("Protocol Happy Path", () => { const lastRequestIdBefore = await withdrawalQueue.getLastRequestId(); const withdrawalTx = await withdrawalQueue.connect(stranger).requestWithdrawals([amountWithRewards], stranger); - const withdrawalTxReceipt = await trace( - "withdrawalQueue.requestWithdrawals", - withdrawalTx, - ); - + const withdrawalTxReceipt = (await withdrawalTx.wait()) as ContractTransactionReceipt; const withdrawalEvent = ctx.getEvents(withdrawalTxReceipt, "WithdrawalRequested")[0]; expect(withdrawalEvent?.args.toObject()).to.deep.include( @@ -594,12 +586,11 @@ describe("Protocol Happy Path", () => { expect(claimableEtherBeforeClaim).to.equal(amountWithRewards, "Claimable ether before claim"); const claimTx = await withdrawalQueue.connect(stranger).claimWithdrawals([requestId], hints); - const claimTxReceipt = await trace("withdrawalQueue.claimWithdrawals", claimTx); + const claimTxReceipt = (await claimTx.wait()) as ContractTransactionReceipt; + const claimEvent = ctx.getEvents(claimTxReceipt, "WithdrawalClaimed")[0]; const spentGas = claimTxReceipt.gasUsed * claimTxReceipt.gasPrice; - const claimEvent = ctx.getEvents(claimTxReceipt, "WithdrawalClaimed")[0]; - expect(claimEvent?.args.toObject()).to.deep.include( { requestId, From 3155741c70e0b19811ba77cba1f1c86c9b00bdb6 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 11 Feb 2025 18:36:53 +0000 Subject: [PATCH 54/70] chore: cleanup logs --- lib/protocol/helpers/accounting.ts | 20 ++++++++++--- lib/protocol/helpers/nor.ts | 19 ++++++++++--- lib/protocol/helpers/sdvt.ts | 31 ++++++++++----------- lib/protocol/helpers/staking.ts | 11 ++++---- lib/protocol/helpers/withdrawal.ts | 2 -- lib/protocol/provision.ts | 2 ++ tasks/logger.ts | 5 ++++ test/integration/burn-shares.integration.ts | 13 +-------- 8 files changed, 59 insertions(+), 44 deletions(-) diff --git a/lib/protocol/helpers/accounting.ts b/lib/protocol/helpers/accounting.ts index d07b33591..e8a497a77 100644 --- a/lib/protocol/helpers/accounting.ts +++ b/lib/protocol/helpers/accounting.ts @@ -490,7 +490,13 @@ const getFinalizationBatches = async ( const MAX_REQUESTS_PER_CALL = 1000n; if (availableEth === 0n) { - log.warning("No available ether to request withdrawals"); + log.debug("No available ether to request withdrawals", { + "Share rate": shareRate, + "Available eth": formatEther(availableEth), + "Limited withdrawal vault balance": formatEther(limitedWithdrawalVaultBalance), + "Limited el rewards vault balance": formatEther(limitedElRewardsVaultBalance), + "Reserved buffer": formatEther(reservedBuffer), + }); return []; } @@ -702,9 +708,13 @@ export const ensureOracleCommitteeMembers = async (ctx: ProtocolContext, minMemb let count = addresses.length; while (addresses.length < minMembersCount) { - log.warning(`Adding oracle committee member ${count}`); - const address = getOracleCommitteeMemberAddress(count); + + log.debug(`Adding oracle committee member ${count}`, { + "Min members count": minMembersCount, + "Address": address, + }); + await hashConsensus.connect(agentSigner).addMember(address, minMembersCount); addresses.push(address); @@ -730,7 +740,9 @@ export const ensureHashConsensusInitialEpoch = async (ctx: ProtocolContext) => { const { initialEpoch } = await hashConsensus.getFrameConfig(); if (initialEpoch === HASH_CONSENSUS_FAR_FUTURE_EPOCH) { - log.warning("Initializing hash consensus epoch..."); + log.debug("Initializing hash consensus epoch...", { + "Initial epoch": initialEpoch, + }); const latestBlockTimestamp = await getCurrentBlockTimestamp(); const { genesisTime, secondsPerSlot, slotsPerEpoch } = await hashConsensus.getChainConfig(); diff --git a/lib/protocol/helpers/nor.ts b/lib/protocol/helpers/nor.ts index eb86f3a0f..c350d7011 100644 --- a/lib/protocol/helpers/nor.ts +++ b/lib/protocol/helpers/nor.ts @@ -1,5 +1,5 @@ import { expect } from "chai"; -import { randomBytes } from "ethers"; +import { ethers, randomBytes } from "ethers"; import { certainAddress, log } from "lib"; @@ -123,7 +123,12 @@ export const norAddNodeOperator = async ( const { nor } = ctx.contracts; const { operatorId, name, rewardAddress, managerAddress } = params; - log.warning(`Adding fake NOR operator ${operatorId}`); + log.debug(`Adding fake NOR operator ${operatorId}`, { + "Operator ID": operatorId, + "Name": name, + "Reward address": rewardAddress, + "Manager address": managerAddress, + }); const agentSigner = await ctx.getSigner("agent"); await nor.connect(agentSigner).addNodeOperator(name, rewardAddress); @@ -151,7 +156,10 @@ export const norAddOperatorKeys = async ( const { nor } = ctx.contracts; const { operatorId, keysToAdd } = params; - log.warning(`Adding fake keys to NOR operator ${operatorId}`); + log.debug(`Adding fake keys to NOR operator ${operatorId}`, { + "Operator ID": operatorId, + "Keys to add": keysToAdd, + }); const totalKeysBefore = await nor.getTotalSigningKeyCount(operatorId); const unusedKeysBefore = await nor.getUnusedSigningKeyCount(operatorId); @@ -198,7 +206,10 @@ const norSetOperatorStakingLimit = async ( const { nor } = ctx.contracts; const { operatorId, limit } = params; - log.warning(`Setting NOR operator ${operatorId} staking limit`); + log.debug(`Setting NOR operator ${operatorId} staking limit`, { + "Operator ID": operatorId, + "Limit": ethers.formatEther(limit), + }); const votingSigner = await ctx.getSigner("voting"); await nor.connect(votingSigner).setNodeOperatorStakingLimit(operatorId, limit); diff --git a/lib/protocol/helpers/sdvt.ts b/lib/protocol/helpers/sdvt.ts index 35b8fd96f..7dc99dcd4 100644 --- a/lib/protocol/helpers/sdvt.ts +++ b/lib/protocol/helpers/sdvt.ts @@ -62,7 +62,10 @@ const sdvtEnsureOperatorsHaveMinKeys = async ( const unusedKeysCount = await sdvt.getUnusedSigningKeyCount(operatorId); if (unusedKeysCount < minKeysCount) { - log.warning(`Adding SDVT fake keys to operator ${operatorId}`); + log.debug(`Adding SDVT fake keys to operator ${operatorId}`, { + "Unused keys count": unusedKeysCount, + "Min keys count": minKeysCount, + }); await sdvtAddNodeOperatorKeys(ctx, { operatorId, @@ -102,7 +105,12 @@ const sdvtEnsureMinOperators = async (ctx: ProtocolContext, minOperatorsCount = managerAddress: getOperatorManagerAddress("sdvt", operatorId), }; - log.warning(`Adding SDVT fake operator ${operatorId}`); + log.debug(`Adding SDVT fake operator ${operatorId}`, { + "Operator ID": operatorId, + "Name": operator.name, + "Reward address": operator.rewardAddress, + "Manager address": operator.managerAddress, + }); await sdvtAddNodeOperator(ctx, operator); count++; @@ -147,12 +155,7 @@ const sdvtAddNodeOperator = async ( [1 << (240 + Number(operatorId))], ); - log.debug("Added SDVT fake operator", { - "Operator ID": operatorId, - "Name": name, - "Reward address": rewardAddress, - "Manager address": managerAddress, - }); + log.success(`Added fake SDVT operator ${operatorId}`); }; /** @@ -188,14 +191,7 @@ const sdvtAddNodeOperatorKeys = async ( expect(totalKeysAfter).to.equal(totalKeysBefore + keysToAdd); expect(unusedKeysAfter).to.equal(unusedKeysBefore + keysToAdd); - log.debug("Added SDVT fake signing keys", { - "Operator ID": operatorId, - "Keys to add": keysToAdd, - "Total keys before": totalKeysBefore, - "Total keys after": totalKeysAfter, - "Unused keys before": unusedKeysBefore, - "Unused keys after": unusedKeysAfter, - }); + log.success(`Added fake keys to SDVT operator ${operatorId}`); }; /** @@ -212,6 +208,7 @@ const sdvtSetOperatorStakingLimit = async ( const { operatorId, limit } = params; const easyTrackExecutor = await ctx.getSigner("easyTrack"); - await sdvt.connect(easyTrackExecutor).setNodeOperatorStakingLimit(operatorId, limit); + + log.success(`Set SDVT operator ${operatorId} staking limit`); }; diff --git a/lib/protocol/helpers/staking.ts b/lib/protocol/helpers/staking.ts index 4d7a2874a..03422bef4 100644 --- a/lib/protocol/helpers/staking.ts +++ b/lib/protocol/helpers/staking.ts @@ -1,4 +1,4 @@ -import { ZeroAddress } from "ethers"; +import { ethers, ZeroAddress } from "ethers"; import { certainAddress, ether, impersonate, log } from "lib"; @@ -14,8 +14,6 @@ import { report } from "./accounting"; export const unpauseStaking = async (ctx: ProtocolContext) => { const { lido } = ctx.contracts; if (await lido.isStakingPaused()) { - log.warning("Unpausing staking contract"); - const votingSigner = await ctx.getSigner("voting"); await lido.connect(votingSigner).resume(); @@ -28,11 +26,14 @@ export const ensureStakeLimit = async (ctx: ProtocolContext) => { const stakeLimitInfo = await lido.getStakeLimitFullInfo(); if (!stakeLimitInfo.isStakingLimitSet) { - log.warning("Setting staking limit"); - const maxStakeLimit = ether("150000"); const stakeLimitIncreasePerBlock = ether("20"); // this is an arbitrary value + log.debug("Setting staking limit", { + "Max stake limit": ethers.formatEther(maxStakeLimit), + "Stake limit increase per block": ethers.formatEther(stakeLimitIncreasePerBlock), + }); + const votingSigner = await ctx.getSigner("voting"); await lido.connect(votingSigner).setStakingLimit(maxStakeLimit, stakeLimitIncreasePerBlock); diff --git a/lib/protocol/helpers/withdrawal.ts b/lib/protocol/helpers/withdrawal.ts index 4f360238c..eb10e630b 100644 --- a/lib/protocol/helpers/withdrawal.ts +++ b/lib/protocol/helpers/withdrawal.ts @@ -12,8 +12,6 @@ import { report } from "./accounting"; export const unpauseWithdrawalQueue = async (ctx: ProtocolContext) => { const { withdrawalQueue } = ctx.contracts; if (await withdrawalQueue.isPaused()) { - log.warning("Unpausing withdrawal queue contract"); - const resumeRole = await withdrawalQueue.RESUME_ROLE(); const agentSigner = await ctx.getSigner("agent"); const agentSignerAddress = await agentSigner.getAddress(); diff --git a/lib/protocol/provision.ts b/lib/protocol/provision.ts index e22e1ca75..9457ba39a 100644 --- a/lib/protocol/provision.ts +++ b/lib/protocol/provision.ts @@ -38,4 +38,6 @@ export const provision = async (ctx: ProtocolContext) => { await ensureStakeLimit(ctx); alreadyProvisioned = true; + + log.success("Provisioned"); }; diff --git a/tasks/logger.ts b/tasks/logger.ts index 087c2385f..ccb3a360e 100644 --- a/tasks/logger.ts +++ b/tasks/logger.ts @@ -4,6 +4,8 @@ import { formatUnits, Interface, TransactionReceipt, TransactionResponse } from import { extendEnvironment } from "hardhat/config"; import { HardhatRuntimeEnvironment } from "hardhat/types"; +const LOG_LEVEL = process.env.LOG_LEVEL || "info"; + // Custom errors class NoReceiptError extends Error { constructor() { @@ -71,6 +73,8 @@ function formatTransactionLines( } extendEnvironment((hre: HardhatRuntimeEnvironment) => { + if (LOG_LEVEL != "debug" && LOG_LEVEL != "all") return; + const originalSendTransaction = hre.ethers.provider.send; // Wrap the provider's send method to intercept transactions @@ -156,6 +160,7 @@ extendEnvironment((hre: HardhatRuntimeEnvironment) => { const lines = formatTransactionLines(tx, receipt, txType, name, functionName, blockGasLimit, gasPrice); lines.forEach((line) => console.log(line)); + console.log(); return receipt; } catch (error) { diff --git a/test/integration/burn-shares.integration.ts b/test/integration/burn-shares.integration.ts index ade3c1830..8b670d35b 100644 --- a/test/integration/burn-shares.integration.ts +++ b/test/integration/burn-shares.integration.ts @@ -6,7 +6,7 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { ether, impersonate, log } from "lib"; import { getProtocolContext, ProtocolContext } from "lib/protocol"; -import { finalizeWithdrawalQueue, handleOracleReport } from "lib/protocol/helpers"; +import { handleOracleReport } from "lib/protocol/helpers"; import { bailOnFailure, Snapshot } from "test/suite"; @@ -33,17 +33,6 @@ describe("Burn Shares", () => { after(async () => await Snapshot.restore(snapshot)); - it("Should finalize withdrawal queue", async () => { - const { withdrawalQueue } = ctx.contracts; - - await finalizeWithdrawalQueue(ctx); - - const lastFinalizedRequestId = await withdrawalQueue.getLastFinalizedRequestId(); - const lastRequestId = await withdrawalQueue.getLastRequestId(); - - expect(lastFinalizedRequestId).to.equal(lastRequestId); - }); - it("Should allow stranger to submit ETH", async () => { const { lido } = ctx.contracts; From d4a71e24bab8603f60dea58616caefd53d694a14 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 12 Feb 2025 08:23:01 +0000 Subject: [PATCH 55/70] chore: refactor --- tasks/logger.ts | 254 +++++++++++++++++++++--------------------------- 1 file changed, 113 insertions(+), 141 deletions(-) diff --git a/tasks/logger.ts b/tasks/logger.ts index ccb3a360e..ecd0c75e8 100644 --- a/tasks/logger.ts +++ b/tasks/logger.ts @@ -2,22 +2,14 @@ import "hardhat/types/runtime"; import chalk from "chalk"; import { formatUnits, Interface, TransactionReceipt, TransactionResponse } from "ethers"; import { extendEnvironment } from "hardhat/config"; -import { HardhatRuntimeEnvironment } from "hardhat/types"; +import { HardhatNetworkConfig, HardhatRuntimeEnvironment } from "hardhat/types"; const LOG_LEVEL = process.env.LOG_LEVEL || "info"; +const DEFAULT_BLOCK_GAS_LIMIT = 30_000_000; +const FUNCTION_SIGNATURE_LENGTH = 10; -// Custom errors -class NoReceiptError extends Error { - constructor() { - super("Transaction receipt not found"); - } -} - -// Types -interface FunctionDetails { - name?: string; - functionName?: string; -} +const interfaceCache = new Map(); +const callCache = new Map(); enum TransactionType { CONTRACT_DEPLOYMENT = "Contract deployment", @@ -25,162 +17,142 @@ enum TransactionType { CONTRACT_CALL = "Contract call", } -// Constants -const DEFAULT_BLOCK_GAS_LIMIT = 30_000_000; -const FUNCTION_SIGNATURE_LENGTH = 10; - -// Cache for contract interfaces and function details -const interfaceCache = new Map(); -const functionDetailsCache = new Map(); +type Call = { + contract: string; + function: string; +}; -// Helper functions -function formatGasUsage(gasUsed: bigint, blockGasLimit: number): string { - const gasUsedPercent = (Number(gasUsed) * 100) / blockGasLimit; - return `${gasUsed} (${gasUsedPercent.toFixed(2)}%)`; -} - -function formatTransactionLines( +function outputTransaction( tx: TransactionResponse, + txType: TransactionType, receipt: TransactionReceipt, - txType: string, - name: string | undefined, - functionName: string | undefined, - blockGasLimit: number, + call: Call, + gasLimit: number, gasPrice: string, -): string[] { - const lines = [ - `Transaction sent: ${chalk.yellow(receipt.hash)}`, - ` From: ${chalk.cyan(tx.from)} To: ${chalk.cyan(tx.to || receipt.contractAddress)}`, - ` Gas price: ${chalk.yellow(gasPrice)} gwei Gas limit: ${chalk.yellow(blockGasLimit)} Gas used: ${chalk.yellow(formatGasUsage(receipt.gasUsed, blockGasLimit))}`, - ` Block: ${chalk.yellow(receipt.blockNumber)} Nonce: ${chalk.yellow(tx.nonce)}`, - ]; - - const color = receipt.status ? chalk.green : chalk.red; - const status = receipt.status ? "confirmed" : "failed"; +): void { + const gasUsedPercent = (Number(receipt.gasUsed) * 100) / gasLimit; + + const txHash = chalk.yellow(receipt.hash); + const txFrom = chalk.cyan(tx.from); + const txTo = chalk.cyan(tx.to || receipt.contractAddress); + const txGasPrice = chalk.yellow(gasPrice); + const txGasLimit = chalk.yellow(gasLimit); + const txGasUsed = chalk.yellow(`${receipt.gasUsed} (${gasUsedPercent.toFixed(2)}%)`); + const txBlock = chalk.yellow(receipt.blockNumber); + const txNonce = chalk.yellow(tx.nonce); + const txStatus = receipt.status ? chalk.green("confirmed") : chalk.red("failed"); + const txContract = chalk.cyan(call.contract || "Contract deployment"); + const txFunction = chalk.cyan(call.function || ""); + const txCall = `${txContract}.${txFunction}`; + + console.log(`Transaction sent: ${txHash}`); + console.log(` From: ${txFrom} To: ${txTo}`); + console.log(` Gas price: ${txGasPrice} gwei Gas limit: ${txGasLimit} Gas used: ${txGasUsed}`); + console.log(` Block: ${txBlock} Nonce: ${txNonce}`); if (txType === TransactionType.CONTRACT_DEPLOYMENT) { - lines.push(` Contract address: ${chalk.cyan(receipt.contractAddress)}`); - lines.push(` ${color(name || "Contract deployment")} ${color(status)}`); + console.log(` Contract deployed: ${chalk.cyan(receipt.contractAddress)}`); } else if (txType === TransactionType.ETH_TRANSFER) { - lines.push(` ETH transfer: ${chalk.cyan(tx.value)}`); - lines.push(` ${color("ETH transfer")} ${color(status)}`); + console.log(` ETH transfer: ${chalk.yellow(tx.value)}`); } else { - const txName = name && functionName ? `${name}.${functionName}` : functionName || "Contract call"; - lines.push(` ${color(txName)} ${color(status)}`); + console.log(` ${txCall} ${txStatus}`); } - - return lines; + console.log(); } -extendEnvironment((hre: HardhatRuntimeEnvironment) => { - if (LOG_LEVEL != "debug" && LOG_LEVEL != "all") return; +// Transaction Processing +async function getCall(tx: TransactionResponse, hre: HardhatRuntimeEnvironment): Promise { + if (!tx.data || tx.data === "0x" || !tx.to) return { contract: "", function: "" }; - const originalSendTransaction = hre.ethers.provider.send; + const cacheKey = `${tx.to}-${tx.data.slice(0, FUNCTION_SIGNATURE_LENGTH)}`; + if (callCache.has(cacheKey)) { + return callCache.get(cacheKey)!; + } - // Wrap the provider's send method to intercept transactions - hre.ethers.provider.send = async function (method: string, params: unknown[]) { - const result = await originalSendTransaction.apply(this, [method, params]); + try { + const call = await extractCallDetails(tx, hre); + callCache.set(cacheKey, call); + return call; + } catch (error) { + console.warn("Error getting call details:", error); + const fallbackCall = { contract: tx.data.slice(0, FUNCTION_SIGNATURE_LENGTH), function: "" }; + callCache.set(cacheKey, fallbackCall); + return fallbackCall; + } +} - // Only log eth_sendTransaction and eth_sendRawTransaction calls - if (method === "eth_sendTransaction" || method === "eth_sendRawTransaction") { - const tx = (await this.getTransaction(result)) as TransactionResponse; - await logTransaction(tx); +async function extractCallDetails(tx: TransactionResponse, hre: HardhatRuntimeEnvironment): Promise { + try { + const artifacts = await hre.artifacts.getAllFullyQualifiedNames(); + for (const name of artifacts) { + const iface = await getOrCreateInterface(name, hre); + const result = iface.parseTransaction({ data: tx.data }); + if (result) { + return { + contract: name.split(":").pop() || "", + function: result.name || "", + }; + } } + } catch { + // Ignore errors and return empty call + } - return result; - }; + return { contract: "", function: "" }; +} - async function getFunctionDetails(tx: TransactionResponse): Promise { - if (!tx.data || tx.data === "0x" || !tx.to) return {}; +async function getOrCreateInterface(artifactName: string, hre: HardhatRuntimeEnvironment) { + if (interfaceCache.has(artifactName)) { + return interfaceCache.get(artifactName)!; + } - // Check cache first - const cacheKey = `${tx.to}-${tx.data.slice(0, FUNCTION_SIGNATURE_LENGTH)}`; - if (functionDetailsCache.has(cacheKey)) { - return functionDetailsCache.get(cacheKey)!; - } + const artifact = await hre.artifacts.readArtifact(artifactName); + const iface = new Interface(artifact.abi); + interfaceCache.set(artifactName, iface); + return iface; +} - try { - // Try to get contract name and function name from all available artifacts - const allArtifacts = await hre.artifacts.getAllFullyQualifiedNames(); - - for (const artifactName of allArtifacts) { - try { - let iface: Interface; - - // Check interface cache - if (interfaceCache.has(artifactName)) { - iface = interfaceCache.get(artifactName)!; - } else { - const artifact = await hre.artifacts.readArtifact(artifactName); - iface = new Interface(artifact.abi); - interfaceCache.set(artifactName, iface); - } - - const result = iface.parseTransaction({ data: tx.data }); - - if (result) { - const details = { - name: artifactName.split(":").pop() || "", - functionName: result.name, - }; - functionDetailsCache.set(cacheKey, details); - return details; - } - } catch { - continue; // Skip artifacts that can't be parsed - } - } - } catch (error) { - console.warn("Error getting function details:", error); - } +async function getTxType(tx: TransactionResponse, receipt: TransactionReceipt): Promise { + if (receipt.contractAddress) return TransactionType.CONTRACT_DEPLOYMENT; + if (!tx.data || tx.data === "0x") return TransactionType.ETH_TRANSFER; + return TransactionType.CONTRACT_CALL; +} - // Cache and return function signature if we can't decode - const details = { - functionName: tx.data.slice(0, FUNCTION_SIGNATURE_LENGTH), - }; - functionDetailsCache.set(cacheKey, details); - return details; - } +async function logTransaction(tx: TransactionResponse, hre: HardhatRuntimeEnvironment) { + const receipt = await tx.wait(); + if (!receipt) throw new Error("Transaction receipt not found"); - async function logTransaction(tx: TransactionResponse): Promise { - const receipt = await tx.wait(); - if (!receipt) { - throw new NoReceiptError(); - } + try { + const network = await tx.provider.getNetwork(); + const config = hre.config.networks[network.name] as HardhatNetworkConfig; + const gasLimit = config.blockGasLimit ?? DEFAULT_BLOCK_GAS_LIMIT; - try { - const network = await tx.provider.getNetwork(); - const config = hre.config.networks[network.name]; - const blockGasLimit = "blockGasLimit" in config ? config.blockGasLimit : DEFAULT_BLOCK_GAS_LIMIT; + const txType = await getTxType(tx, receipt); + const call = await getCall(tx, hre); + const gasPrice = formatUnits(receipt.gasPrice || 0n, "gwei"); - const txType = await getTxType(tx, receipt); - const { name, functionName } = await getFunctionDetails(tx); - const gasPrice = formatUnits(receipt.gasPrice || 0n, "gwei"); + outputTransaction(tx, txType, receipt, call, gasLimit, gasPrice); - const lines = formatTransactionLines(tx, receipt, txType, name, functionName, blockGasLimit, gasPrice); + return receipt; + } catch (error) { + console.error("Error logging transaction:", error); + return receipt; + } +} - lines.forEach((line) => console.log(line)); - console.log(); +extendEnvironment((hre: HardhatRuntimeEnvironment) => { + if (LOG_LEVEL != "debug" && LOG_LEVEL != "all") return; - return receipt; - } catch (error) { - console.error("Error logging transaction:", error); - return receipt; - } - } + const originalSendTransaction = hre.ethers.provider.send; - async function getTxType(tx: TransactionResponse, receipt: TransactionReceipt): Promise { - if (receipt.contractAddress) { - return TransactionType.CONTRACT_DEPLOYMENT; - } + hre.ethers.provider.send = async function (method: string, params: unknown[]) { + const result = await originalSendTransaction.apply(this, [method, params]); - if (!tx.data || tx.data === "0x") { - return TransactionType.ETH_TRANSFER; + if (method === "eth_sendTransaction" || method === "eth_sendRawTransaction") { + const tx = (await this.getTransaction(result)) as TransactionResponse; + await logTransaction(tx, hre); } - const { name, functionName } = await getFunctionDetails(tx); - return name && functionName ? `${name}.${functionName}` : functionName || TransactionType.CONTRACT_CALL; - } - - return logTransaction; + return result; + }; }); From fb94485f12c438dea94a4b1da0c77ca4a65bd8f3 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 12 Feb 2025 13:05:54 +0000 Subject: [PATCH 56/70] chore: some documentation --- contracts/0.8.25/vaults/StakingVault.sol | 4 +- docs/vaults/validator-exit-flows.md | 130 +++++++++++++++++++++++ 2 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 docs/vaults/validator-exit-flows.md diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 0b8fdcfca..1e2c5477f 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -470,9 +470,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { bool isHealthy = valuation() >= $.locked; if (!isHealthy) { for (uint256 i = 0; i < _amounts.length; i++) { - if (_amounts[i] > 0) { - revert PartialWithdrawalsForbidden(); - } + if (_amounts[i] > 0) revert PartialWithdrawalsForbidden(); } } diff --git a/docs/vaults/validator-exit-flows.md b/docs/vaults/validator-exit-flows.md new file mode 100644 index 000000000..f512b5b87 --- /dev/null +++ b/docs/vaults/validator-exit-flows.md @@ -0,0 +1,130 @@ +# stVault Validator Exit Flows + +## Abstract + +stVaults enable three validator exit mechanisms: voluntary exits for planned operations, request-based exits using EIP-7002, and force exits for vault rebalancing. Each mechanism serves a specific purpose in maintaining vault operations and protocol health. The stVault contract plays a crucial role in the broader protocol by ensuring efficient validator management and maintaining the health of the vaults. + +## Terminology + +- **stVault (Vault)**: The smart contract managing the vault operations. +- **Vault Owner (VO)**: The owner of the stVault contract. +- **Node Operators (NO)**: Entities responsible for managing the validators. +- **BeaconChain (BC)**: The Ethereum 2.0 beacon chain where validators operate. +- **TriggerableWithdrawals (TW)**: Mechanism for initiating withdrawals using [EIP-7002](https://eips.ethereum.org/EIPS/eip-7002). +- **Vault Hub (Hub)**: Central component for managing vault operations. +- **Lido V2 (Lido)**: Core protocol responsible for maintaining stability of the stETH token. + +### Exit Selection Guide + +| Scenario | Recommended Exit | Rationale | +| ------------------- | ---------------- | -------------------- | +| Planned Maintenance | Voluntary | Flexible timing | +| Urgent Withdrawal | Request-Based | Guaranteed execution | +| Vault Imbalance | Force | Restore health | + +## Voluntary Exit Flow + +The vault owner signals to a node operator to initiate a validator exit, which is then processed at a flexible timing. The stVault contract will only emit an exit signal that the node operators will then process at their discretion. + +> [!NOTE] +> +> - The stVault contract WILL NOT process the exit itself. +> - Can be triggered ONLY by the owner of the stVault contract. + +```mermaid +sequenceDiagram + participant Owner + participant stVault + participant NodeOperators + participant BeaconChain + + Owner->>stVault: Initiates voluntary exit + Note over stVault: Validates pubkeys + stVault->>NodeOperators: Exit signal + Note over NodeOperators: Flexible timing + NodeOperators->>BeaconChain: Process exit + BeaconChain-->>stVault: Returns ETH +``` + +**Purpose:** + +- Planned validator rotations +- Routine maintenance +- Non-urgent exits +- Regular rebalancing + +## Request-Based Exit Flow + +Both the vault owner and the node operators can trigger validator withdrawals using EIP-7002 Triggerable Withdrawals at any time. This process initiates the withdrawal of ETH from the validators controlled by the stVault contract on the beacon chain. Both full and partial withdrawals are supported. Guaranteed execution is ensured through EIP-7002, along with an immediate fee refund. + +> [!NOTE] +> +> - Partial withdrawals are ONLY supported when the vault is in a healthy state. + +```mermaid +sequenceDiagram + participant VO/NO + participant stVault + participant TriggerableWithdrawals + participant BeaconChain + + VO/NO->>stVault: Request + withdrawal fee + stVault->>TriggerableWithdrawals: Trigger withdrawal + withdrawal fee + stVault-->>VO/NO: Returns excess fee + Note over TriggerableWithdrawals: Queued for processing + TriggerableWithdrawals-->>BeaconChain: Process withdrawal + BeaconChain-->>TriggerableWithdrawals: Returns ETH + TriggerableWithdrawals-->>stVault: Returns ETH +``` + +**Purpose:** + +- Guaranteed withdrawals +- Time-sensitive operations +- Partial withdrawals +- Available to owner and operator + +## Force Exit Flow + +A permissionless mechanism used when a vault becomes imbalanced (meaning the vault valuation is below the locked amount). This flow helps restore the vault's health state and get the value for the vault rebalancing. + +> [!NOTE] +> +> - ANYONE can trigger this flow +> - ONLY full withdrawals are supported +> - ONLY available when the vault valuation is below the locked amount + +```mermaid +sequenceDiagram + participant Lido + participant Anyone + participant Hub + participant Vault + participant TriggerableWithdrawals + participant BeaconChain + + Anyone->>Hub: Force exit request + withdrawal fee + Note over Hub: Validates vault unhealthiness + Hub->>Vault: Trigger withdrawal + withdrawal fee + Note over Vault: Validates unhealthiness + Vault->>TriggerableWithdrawals: Trigger withdrawal + withdrawal fee + Vault-->>Anyone: Returns excess fee + Note over TriggerableWithdrawals: Queued for processing + TriggerableWithdrawals->>BeaconChain: Process withdrawal + BeaconChain-->>Vault: Returns ETH + Anyone->>Hub: Rebalance request + Hub->>Vault: Rebalance request + Vault->>Lido: Repay debt + Vault->>Hub: Rebalance processed + Hub->>Hub: Restore vault health +``` + +**Purpose:** + +- Restore vault health state +- Maintain protocol safety + +## External References + +- [stVaults Design](https://hackmd.io/@lido/stVaults-design) +- [EIP-7002: Triggerable Withdrawals](https://eips.ethereum.org/EIPS/eip-7002) From 7bc6c000652651c2e876372e5e9e249132e2b6d5 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 12 Feb 2025 13:51:26 +0000 Subject: [PATCH 57/70] chore: massive renaming --- contracts/0.8.25/vaults/Dashboard.sol | 12 +- contracts/0.8.25/vaults/Permissions.sol | 16 +-- contracts/0.8.25/vaults/StakingVault.sol | 120 ++++++++++-------- contracts/0.8.25/vaults/VaultFactory.sol | 8 +- contracts/0.8.25/vaults/VaultHub.sol | 20 +-- .../vaults/interfaces/IStakingVault.sol | 6 +- .../StakingVault__HarnessForTestUpgrade.sol | 6 +- .../VaultFactory__MockForDashboard.sol | 4 +- .../0.8.25/vaults/dashboard/dashboard.test.ts | 22 ++-- .../vaults/delegation/delegation.test.ts | 16 +-- .../vaults/staking-vault/stakingVault.test.ts | 101 ++++++++------- test/0.8.25/vaults/vaultFactory.test.ts | 4 +- .../vaulthub/vaulthub.withdrawals.test.ts | 14 +- .../vaults-happy-path.integration.ts | 10 +- 14 files changed, 188 insertions(+), 171 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 1dfc27a05..02bff4416 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -458,23 +458,23 @@ contract Dashboard is Permissions { * @notice Signals to node operators that specific validators should exit from the beacon chain. * It does not directly trigger exits - node operators must monitor for these events and handle the exits manually. * @param _pubkeys Concatenated validator public keys, each 48 bytes long. - * @dev Emits `ValidatorMarkedForExit` event for each validator public key through the StakingVault + * @dev Emits `ValidatorExitRequested` event for each validator public key through the StakingVault. * This is a voluntary exit request - node operators can choose whether to act on it. */ - function markValidatorsForExit(bytes calldata _pubkeys) external { - _markValidatorsForExit(_pubkeys); + function requestValidatorExit(bytes calldata _pubkeys) external { + _requestValidatorExit(_pubkeys); } /** - * @notice Requests validator withdrawals via EIP-7002 triggerable exit mechanism. This allows withdrawing either the full + * @notice Triggers validator withdrawals via EIP-7002 triggerable exit mechanism. This allows withdrawing either the full * validator balance or a partial amount from each validator specified. * @param _pubkeys The concatenated public keys of the validators to request withdrawal for. Each key must be 48 bytes. * @param _amounts The withdrawal amounts in wei for each validator. Must match the length of _pubkeys. * @param _refundRecipient The address that will receive any fee refunds. * @dev Requires payment of withdrawal fee which is calculated based on the number of validators and must be paid in msg.value. */ - function requestValidatorWithdrawals(bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient) external payable { - _requestValidatorWithdrawals(_pubkeys, _amounts, _refundRecipient); + function triggerValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient) external payable { + _triggerValidatorWithdrawal(_pubkeys, _amounts, _refundRecipient); } // ==================== Role Management Functions ==================== diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index f8e4efbcb..13438af47 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -55,14 +55,14 @@ abstract contract Permissions is AccessControlVoteable { keccak256("StakingVault.Permissions.ResumeBeaconChainDeposits"); /** - * @notice Permission for marking validators for exit from the StakingVault. + * @notice Permission for requesting validator exit from the StakingVault. */ - bytes32 public constant MARK_VALIDATORS_FOR_EXIT_ROLE = keccak256("StakingVault.Permissions.MarkValidatorsForExit"); + bytes32 public constant REQUEST_VALIDATOR_EXIT_ROLE = keccak256("StakingVault.Permissions.RequestValidatorExit"); /** - * @notice Permission for force validators exit from the StakingVault using EIP-7002 triggerable exit. + * @notice Permission for triggering validator withdrawal from the StakingVault using EIP-7002 triggerable exit. */ - bytes32 public constant REQUEST_VALIDATOR_WITHDRAWALS_ROLE = keccak256("StakingVault.Permissions.RequestValidatorWithdrawals"); + bytes32 public constant TRIGGER_VALIDATOR_WITHDRAWAL_ROLE = keccak256("StakingVault.Permissions.TriggerValidatorWithdrawal"); /** * @notice Permission for voluntary disconnecting the StakingVault. @@ -146,12 +146,12 @@ abstract contract Permissions is AccessControlVoteable { stakingVault().resumeBeaconChainDeposits(); } - function _markValidatorsForExit(bytes calldata _pubkeys) internal onlyRole(MARK_VALIDATORS_FOR_EXIT_ROLE) { - stakingVault().markValidatorsForExit(_pubkeys); + function _requestValidatorExit(bytes calldata _pubkeys) internal onlyRole(REQUEST_VALIDATOR_EXIT_ROLE) { + stakingVault().requestValidatorExit(_pubkeys); } - function _requestValidatorWithdrawals(bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient) internal onlyRole(REQUEST_VALIDATOR_WITHDRAWALS_ROLE) { - stakingVault().requestValidatorWithdrawals{value: msg.value}(_pubkeys, _amounts, _refundRecipient); + function _triggerValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient) internal onlyRole(TRIGGER_VALIDATOR_WITHDRAWAL_ROLE) { + stakingVault().triggerValidatorWithdrawal{value: msg.value}(_pubkeys, _amounts, _refundRecipient); } function _voluntaryDisconnect() internal onlyRole(VOLUNTARY_DISCONNECT_ROLE) { diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 1e2c5477f..0f65c713b 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -23,9 +23,9 @@ import {IStakingVault} from "./interfaces/IStakingVault.sol"; * The StakingVault can be used as a backing for minting new stETH if the StakingVault is connected to the VaultHub. * When minting stETH backed by the StakingVault, the VaultHub locks a portion of the StakingVault's valuation, * which cannot be withdrawn by the owner. If the locked amount exceeds the StakingVault's valuation, - * the StakingVault enters the unhealthy state. + * the StakingVault enters the unbalanced state. * In this state, the VaultHub can force-rebalance the StakingVault by withdrawing a portion of the locked amount - * and writing off the locked amount to restore the healthy state. + * and writing off the locked amount to restore the balanced state. * The owner can voluntarily rebalance the StakingVault in any state or by simply * supplying more ether to increase the valuation. * @@ -36,11 +36,11 @@ import {IStakingVault} from "./interfaces/IStakingVault.sol"; * - `rebalance()` * - `pauseBeaconChainDeposits()` * - `resumeBeaconChainDeposits()` - * - `markValidatorsForExit()` - * - `requestValidatorWithdrawals()` + * - `requestValidatorExit()` + * - `triggerValidatorWithdrawal()` * - Operator: * - `depositToBeaconChain()` - * - `requestValidatorWithdrawals()` + * - `triggerValidatorWithdrawal()` * - VaultHub: * - `lock()` * - `report()` @@ -265,7 +265,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @dev Cannot withdraw more than the unlocked amount or the balance of the contract, whichever is less. * @dev Updates inOutDelta to track the net difference between funded and withdrawn ether * @dev Includes a check that valuation remains greater than locked amount after withdrawal to ensure - * `StakingVault` stays healthy and prevent reentrancy attacks. + * `StakingVault` stays balanced and prevent reentrancy attacks. */ function withdraw(address _recipient, uint256 _ether) external onlyOwner { if (_recipient == address(0)) revert ZeroArgument("_recipient"); @@ -303,7 +303,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @notice Rebalances StakingVault by withdrawing ether to VaultHub - * @dev Can only be called by VaultHub if StakingVault is unhealthy, + * @dev Can only be called by VaultHub if StakingVault is unbalanced, * or by owner at any moment * @param _ether Amount of ether to rebalance */ @@ -394,7 +394,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @notice Performs a deposit to the beacon chain deposit contract * @param _deposits Array of deposit structs - * @dev Includes a check to ensure `StakingVault` is healthy before making deposits + * @dev Includes a check to ensure `StakingVault` is balanced before making deposits */ function depositToBeaconChain(Deposit[] calldata _deposits) external { if (_deposits.length == 0) revert ZeroArgument("_deposits"); @@ -425,78 +425,81 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @notice Calculates the total withdrawal fee required for given number of validator keys * @param _numberOfKeys Number of validators' public keys * @return Total fee amount to pass as `msg.value` (wei) - * @dev The fee is only valid for the requests made in the same block. + * @dev The fee is only valid for the requests made in the same block */ - function calculateValidatorWithdrawalsFee(uint256 _numberOfKeys) external view returns (uint256) { + function calculateValidatorWithdrawalFee(uint256 _numberOfKeys) external view returns (uint256) { if (_numberOfKeys == 0) revert ZeroArgument("_numberOfKeys"); return _numberOfKeys * TriggerableWithdrawals.getWithdrawalRequestFee(); } /** - * @notice Signals to node operators that specific validators should exit from the beacon chain. - * It does not directly trigger exits - node operators must monitor for these events and handle the exits manually. - * @param _pubkeys Concatenated validator public keys, each 48 bytes long. + * @notice Requests node operator to exit validators from the beacon chain + * It does not directly trigger exits - node operators must monitor for these events and handle the exits manually + * @param _pubkeys Concatenated validator public keys, each 48 bytes long */ - function markValidatorsForExit(bytes calldata _pubkeys) external onlyOwner { + function requestValidatorExit(bytes calldata _pubkeys) external onlyOwner { if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); if (_pubkeys.length % PUBLIC_KEY_LENGTH != 0) { - revert InvalidValidatorPubkeysLength(); + revert InvalidPubkeysLength(); } uint256 keysCount = _pubkeys.length / PUBLIC_KEY_LENGTH; for (uint256 i = 0; i < keysCount; i++) { - emit ValidatorMarkedForExit(msg.sender, bytes(_pubkeys[i * PUBLIC_KEY_LENGTH : (i + 1) * PUBLIC_KEY_LENGTH])); + emit ValidatorExitRequested(msg.sender, bytes(_pubkeys[i * PUBLIC_KEY_LENGTH : (i + 1) * PUBLIC_KEY_LENGTH])); } } /** - * @notice Requests validator withdrawals from the beacon chain using EIP-7002 triggerable exit. - * @param _pubkeys Concatenated validators public keys, each 48 bytes long. - * @param _amounts Amounts of ether to exit, must match the length of _pubkeys. - * @param _refundRecipient Address to receive the fee refund. - * @dev The caller must provide sufficient fee via msg.value to cover the withdrawal request costs. - * TODO: check if the vault is unhealthy + * @notice Triggers validator withdrawals from the beacon chain using EIP-7002 triggerable exit + * @param _pubkeys Concatenated validators public keys, each 48 bytes long + * @param _amounts Amounts of ether to exit, must match the length of _pubkeys + * @param _refundRecipient Address to receive the fee refund + * @dev The caller must provide sufficient fee via msg.value to cover the withdrawal request costs */ - function requestValidatorWithdrawals(bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient) external payable { - uint256 value = msg.value; // cache msg.value to save gas + function triggerValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient) external payable { + uint256 value = msg.value; if (value == 0) revert ZeroArgument("msg.value"); if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); if (_amounts.length == 0) revert ZeroArgument("_amounts"); if (_refundRecipient == address(0)) revert ZeroArgument("_refundRecipient"); + if (_pubkeys.length % PUBLIC_KEY_LENGTH != 0) revert InvalidPubkeysLength(); + + uint256 keysCount = _pubkeys.length / PUBLIC_KEY_LENGTH; + if (keysCount != _amounts.length) revert InvalidAmountsLength(); ERC7201Storage storage $ = _getStorage(); - bool isHealthy = valuation() >= $.locked; - if (!isHealthy) { + bool isBalanced = valuation() >= $.locked; + bool isAuthorized = ( + msg.sender == $.nodeOperator || + msg.sender == owner() || + (!isBalanced && msg.sender == address(VAULT_HUB)) + ); + + if (!isAuthorized) revert NotAuthorized("triggerValidatorWithdrawal", msg.sender); + if (!isBalanced) { for (uint256 i = 0; i < _amounts.length; i++) { - if (_amounts[i] > 0) revert PartialWithdrawalsForbidden(); + if (_amounts[i] > 0) revert PartialWithdrawalNotAllowed(); } } - if (msg.sender == $.nodeOperator || msg.sender == owner() || (!isHealthy && msg.sender == address(VAULT_HUB))) { - uint256 feePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); - uint256 totalFee = (feePerRequest * _pubkeys.length) / PUBLIC_KEY_LENGTH; - if (value < totalFee) { - revert InsufficientValidatorWithdrawalsFee(value, totalFee); - } - - TriggerableWithdrawals.addWithdrawalRequests(_pubkeys, _amounts, feePerRequest); + uint256 feePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); + uint256 totalFee = feePerRequest * keysCount; + if (value < totalFee) revert InsufficientValidatorWithdrawalFee(value, totalFee); - uint256 excess = msg.value - totalFee; - if (excess > 0) { - (bool success,) = _refundRecipient.call{value: excess}(""); - if (!success) { - revert ValidatorWithdrawalFeeRefundFailed(_refundRecipient, excess); - } - } + TriggerableWithdrawals.addWithdrawalRequests(_pubkeys, _amounts, feePerRequest); - emit ValidatorWithdrawalsRequested(msg.sender, _pubkeys, _amounts, _refundRecipient, excess); - } else { - revert NotAuthorized("requestValidatorWithdrawals", msg.sender); + uint256 excess = value - totalFee; + if (excess > 0) { + (bool success,) = _refundRecipient.call{value: excess}(""); + if (!success) revert WithdrawalFeeRefundFailed(_refundRecipient, excess); } + + emit ValidatorWithdrawalRequested(msg.sender, _pubkeys, _amounts, _refundRecipient, excess); } + /** * @notice Computes the deposit data root for a validator deposit * @param _pubkey Validator public key, 48 bytes @@ -610,12 +613,12 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { event DepositedToBeaconChain(address indexed _sender, uint256 _deposits, uint256 _totalAmount); /** - * @notice Emitted when a validator is marked for exit from the beacon chain - * @param _sender Address that marked the validator for exit - * @param _pubkeys Public key of the validator marked for exit + * @notice Emitted when vault owner requests node operator to exit validators from the beacon chain + * @param _sender Address that requested the exit + * @param _pubkey Public key of the validator to exit * @dev Signals to node operators that they should exit this validator from the beacon chain */ - event ValidatorMarkedForExit(address _sender, bytes _pubkeys); + event ValidatorExitRequested(address _sender, bytes _pubkey); /** * @notice Emitted when validator withdrawals are requested via EIP-7002 @@ -625,7 +628,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @param _refundRecipient Address to receive any excess withdrawal fee * @param _excess Amount of excess fee refunded to recipient */ - event ValidatorWithdrawalsRequested(address indexed _sender, bytes _pubkeys, uint64[] _amounts, address _refundRecipient, uint256 _excess); + event ValidatorWithdrawalRequested(address indexed _sender, bytes _pubkeys, uint64[] _amounts, address _refundRecipient, uint256 _excess); /** * @notice Emitted when an excess fee is refunded back to the sender. @@ -713,26 +716,31 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { error BeaconChainDepositsArePaused(); /** - * @notice Thrown when the length of the validator public keys array is invalid + * @notice Thrown when the length of the validator public keys is invalid + */ + error InvalidPubkeysLength(); + + /** + * @notice Thrown when the length of the amounts is not equal to the length of the pubkeys */ - error InvalidValidatorPubkeysLength(); + error InvalidAmountsLength(); /** * @notice Thrown when the validator withdrawal fee is insufficient * @param _passed Amount of ether passed to the function * @param _required Amount of ether required to cover the fee */ - error InsufficientValidatorWithdrawalsFee(uint256 _passed, uint256 _required); + error InsufficientValidatorWithdrawalFee(uint256 _passed, uint256 _required); /** * @notice Thrown when a validator withdrawal fee refund fails * @param _sender Address that initiated the refund * @param _amount Amount of ether to refund */ - error ValidatorWithdrawalFeeRefundFailed(address _sender, uint256 _amount); + error WithdrawalFeeRefundFailed(address _sender, uint256 _amount); /** - * @notice Thrown when partial withdrawals are forbidden on an unhealthy vault + * @notice Thrown when partial withdrawals are not allowed on an unbalanced vault */ - error PartialWithdrawalsForbidden(); + error PartialWithdrawalNotAllowed(); } diff --git a/contracts/0.8.25/vaults/VaultFactory.sol b/contracts/0.8.25/vaults/VaultFactory.sol index 6691c98e7..6375c6da7 100644 --- a/contracts/0.8.25/vaults/VaultFactory.sol +++ b/contracts/0.8.25/vaults/VaultFactory.sol @@ -19,8 +19,8 @@ struct DelegationConfig { address rebalancer; address depositPauser; address depositResumer; - address validatorExitRequester; - address validatorWithdrawalRequester; + address exitRequester; + address withdrawalTriggerer; address disconnecter; address curator; address nodeOperatorManager; @@ -78,8 +78,8 @@ contract VaultFactory { delegation.grantRole(delegation.REBALANCE_ROLE(), _delegationConfig.rebalancer); delegation.grantRole(delegation.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), _delegationConfig.depositPauser); delegation.grantRole(delegation.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), _delegationConfig.depositResumer); - delegation.grantRole(delegation.MARK_VALIDATORS_FOR_EXIT_ROLE(), _delegationConfig.validatorExitRequester); - delegation.grantRole(delegation.REQUEST_VALIDATOR_WITHDRAWALS_ROLE(), _delegationConfig.validatorWithdrawalRequester); + delegation.grantRole(delegation.REQUEST_VALIDATOR_EXIT_ROLE(), _delegationConfig.exitRequester); + delegation.grantRole(delegation.TRIGGER_VALIDATOR_WITHDRAWAL_ROLE(), _delegationConfig.withdrawalTriggerer); delegation.grantRole(delegation.VOLUNTARY_DISCONNECT_ROLE(), _delegationConfig.disconnecter); delegation.grantRole(delegation.CURATOR_ROLE(), _delegationConfig.curator); delegation.grantRole(delegation.NODE_OPERATOR_MANAGER_ROLE(), _delegationConfig.nodeOperatorManager); diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 814ab115f..2ea4e2b9c 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -125,8 +125,8 @@ abstract contract VaultHub is PausableUntilWithRoles { } /// @param _vault vault address - /// @return true if the vault is healthy - function isVaultHealthy(address _vault) external view returns (bool) { + /// @return true if the vault is balanced + function isVaultBalanced(address _vault) external view returns (bool) { VaultSocket storage socket = _connectedSocket(_vault); return socket.sharesMinted <= _maxMintableShares(_vault, socket.reserveRatioThresholdBP, socket.shareLimit); } @@ -295,8 +295,8 @@ abstract contract VaultHub is PausableUntilWithRoles { uint256 threshold = _maxMintableShares(_vault, socket.reserveRatioThresholdBP, socket.shareLimit); uint256 sharesMinted = socket.sharesMinted; if (sharesMinted <= threshold) { - // NOTE!: on connect vault is always healthy - revert AlreadyHealthy(_vault, sharesMinted, threshold); + // NOTE!: on connect vault is always balanced + revert AlreadyBalanced(_vault, sharesMinted, threshold); } uint256 mintedStETH = STETH.getPooledEthByShares(sharesMinted); // TODO: fix rounding issue @@ -341,7 +341,7 @@ abstract contract VaultHub is PausableUntilWithRoles { emit VaultRebalanced(msg.sender, sharesToBurn); } - /// @notice forces validator withdrawal from the beacon chain in case the vault is unhealthy + /// @notice forces validator withdrawal from the beacon chain in case the vault is unbalanced /// @param _vault vault address /// @param _pubkeys pubkeys of the validators to withdraw /// @param _amounts amounts of the validators to withdraw @@ -357,12 +357,12 @@ abstract contract VaultHub is PausableUntilWithRoles { VaultSocket storage socket = _connectedSocket(_vault); uint256 threshold = _maxMintableShares(_vault, socket.reserveRatioThresholdBP, socket.shareLimit); if (socket.sharesMinted <= threshold) { - revert AlreadyHealthy(_vault, socket.sharesMinted, threshold); + revert AlreadyBalanced(_vault, socket.sharesMinted, threshold); } - IStakingVault(_vault).requestValidatorWithdrawals{value: msg.value}(_pubkeys, _amounts, _refundRecepient); + IStakingVault(_vault).triggerValidatorWithdrawal{value: msg.value}(_pubkeys, _amounts, _refundRecepient); - emit VaultForceValidatorWithdrawalsRequested(_vault, _pubkeys, _amounts, _refundRecepient); + emit VaultForceWithdrawalTriggered(_vault, _pubkeys, _amounts, _refundRecepient); } function _disconnect(address _vault) internal { @@ -541,10 +541,10 @@ abstract contract VaultHub is PausableUntilWithRoles { event BurnedSharesOnVault(address indexed vault, uint256 amountOfShares); event VaultRebalanced(address indexed vault, uint256 sharesBurned); event VaultProxyCodehashAdded(bytes32 indexed codehash); - event VaultForceValidatorWithdrawalsRequested(address indexed vault, bytes pubkeys, uint64[] amounts, address refundRecepient); + event VaultForceWithdrawalTriggered(address indexed vault, bytes pubkeys, uint64[] amounts, address refundRecepient); error StETHMintFailed(address vault); - error AlreadyHealthy(address vault, uint256 mintedShares, uint256 rebalancingThresholdInShares); + error AlreadyBalanced(address vault, uint256 mintedShares, uint256 rebalancingThresholdInShares); error InsufficientSharesToBurn(address vault, uint256 amount); error ShareLimitExceeded(address vault, uint256 capShares); error AlreadyConnected(address vault, uint256 index); diff --git a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol index 7d2a2cabf..59a12926f 100644 --- a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol +++ b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol @@ -52,10 +52,10 @@ interface IStakingVault { function resumeBeaconChainDeposits() external; function depositToBeaconChain(Deposit[] calldata _deposits) external; - function markValidatorsForExit(bytes calldata _pubkeys) external; + function requestValidatorExit(bytes calldata _pubkeys) external; - function calculateValidatorWithdrawalsFee(uint256 _keysCount) external view returns (uint256); - function requestValidatorWithdrawals( + function calculateValidatorWithdrawalFee(uint256 _keysCount) external view returns (uint256); + function triggerValidatorWithdrawal( bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient diff --git a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol index 71033cbb1..46eda7ad9 100644 --- a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol +++ b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol @@ -122,12 +122,12 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl function pauseBeaconChainDeposits() external {} function resumeBeaconChainDeposits() external {} - function calculateValidatorWithdrawalsFee(uint256) external pure returns (uint256) { + function calculateValidatorWithdrawalFee(uint256) external pure returns (uint256) { return 1; } - function markValidatorsForExit(bytes calldata _pubkeys) external {} - function requestValidatorWithdrawals( + function requestValidatorExit(bytes calldata _pubkeys) external {} + function triggerValidatorWithdrawal( bytes calldata _pubkeys, uint64[] calldata _amounts, address _recipient diff --git a/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol b/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol index f3bdd03b9..54499d031 100644 --- a/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol +++ b/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol @@ -37,8 +37,8 @@ contract VaultFactory__MockForDashboard is UpgradeableBeacon { dashboard.grantRole(dashboard.REBALANCE_ROLE(), msg.sender); dashboard.grantRole(dashboard.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), msg.sender); dashboard.grantRole(dashboard.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), msg.sender); - dashboard.grantRole(dashboard.MARK_VALIDATORS_FOR_EXIT_ROLE(), msg.sender); - dashboard.grantRole(dashboard.REQUEST_VALIDATOR_WITHDRAWALS_ROLE(), msg.sender); + dashboard.grantRole(dashboard.REQUEST_VALIDATOR_EXIT_ROLE(), msg.sender); + dashboard.grantRole(dashboard.TRIGGER_VALIDATOR_WITHDRAWAL_ROLE(), msg.sender); dashboard.grantRole(dashboard.VOLUNTARY_DISCONNECT_ROLE(), msg.sender); dashboard.revokeRole(dashboard.DEFAULT_ADMIN_ROLE(), address(this)); diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 0c478566c..822b6f4ec 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -624,30 +624,30 @@ describe("Dashboard.sol", () => { }); }); - context("markValidatorsForExit", () => { + context("requestValidatorExit", () => { const pubkeys = ["01".repeat(48), "02".repeat(48)]; const pubkeysConcat = `0x${pubkeys.join("")}`; it("reverts if called by a non-admin", async () => { - await expect(dashboard.connect(stranger).markValidatorsForExit(pubkeysConcat)).to.be.revertedWithCustomError( + await expect(dashboard.connect(stranger).requestValidatorExit(pubkeysConcat)).to.be.revertedWithCustomError( dashboard, "AccessControlUnauthorizedAccount", ); }); it("signals the requested exit of a validator", async () => { - await expect(dashboard.markValidatorsForExit(pubkeysConcat)) - .to.emit(vault, "ValidatorMarkedForExit") + await expect(dashboard.requestValidatorExit(pubkeysConcat)) + .to.emit(vault, "ValidatorExitRequested") .withArgs(dashboard, `0x${pubkeys[0]}`) - .to.emit(vault, "ValidatorMarkedForExit") + .to.emit(vault, "ValidatorExitRequested") .withArgs(dashboard, `0x${pubkeys[1]}`); }); }); - context("requestValidatorWithdrawals", () => { + context("triggerValidatorWithdrawal", () => { it("reverts if called by a non-admin", async () => { await expect( - dashboard.connect(stranger).requestValidatorWithdrawals("0x", [0n], vaultOwner), + dashboard.connect(stranger).triggerValidatorWithdrawal("0x", [0n], vaultOwner), ).to.be.revertedWithCustomError(dashboard, "AccessControlUnauthorizedAccount"); }); @@ -655,8 +655,8 @@ describe("Dashboard.sol", () => { const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); const amounts = [0n]; // 0 amount means full withdrawal - await expect(dashboard.requestValidatorWithdrawals(validatorPublicKeys, amounts, vaultOwner, { value: FEE })) - .to.emit(vault, "ValidatorWithdrawalsRequested") + await expect(dashboard.triggerValidatorWithdrawal(validatorPublicKeys, amounts, vaultOwner, { value: FEE })) + .to.emit(vault, "ValidatorWithdrawalRequested") .withArgs(dashboard, validatorPublicKeys, amounts, vaultOwner, 0n); }); @@ -664,8 +664,8 @@ describe("Dashboard.sol", () => { const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); const amounts = [ether("0.1")]; - await expect(dashboard.requestValidatorWithdrawals(validatorPublicKeys, amounts, vaultOwner, { value: FEE })) - .to.emit(vault, "ValidatorWithdrawalsRequested") + await expect(dashboard.triggerValidatorWithdrawal(validatorPublicKeys, amounts, vaultOwner, { value: FEE })) + .to.emit(vault, "ValidatorWithdrawalRequested") .withArgs(dashboard, validatorPublicKeys, amounts, vaultOwner, 0n); }); }); diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index 6ef53b507..9be8ef349 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -34,8 +34,8 @@ describe("Delegation.sol", () => { let rebalancer: HardhatEthersSigner; let depositPauser: HardhatEthersSigner; let depositResumer: HardhatEthersSigner; - let validatorExitRequester: HardhatEthersSigner; - let validatorWithdrawalRequester: HardhatEthersSigner; + let exitRequester: HardhatEthersSigner; + let withdrawalTriggerer: HardhatEthersSigner; let disconnecter: HardhatEthersSigner; let curator: HardhatEthersSigner; let nodeOperatorManager: HardhatEthersSigner; @@ -71,8 +71,8 @@ describe("Delegation.sol", () => { rebalancer, depositPauser, depositResumer, - validatorExitRequester, - validatorWithdrawalRequester, + exitRequester, + withdrawalTriggerer, disconnecter, curator, nodeOperatorManager, @@ -114,8 +114,8 @@ describe("Delegation.sol", () => { rebalancer, depositPauser, depositResumer, - validatorExitRequester, - validatorWithdrawalRequester, + exitRequester, + withdrawalTriggerer, disconnecter, curator, nodeOperatorManager, @@ -205,8 +205,8 @@ describe("Delegation.sol", () => { await assertSoleMember(rebalancer, await delegation.REBALANCE_ROLE()); await assertSoleMember(depositPauser, await delegation.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE()); await assertSoleMember(depositResumer, await delegation.RESUME_BEACON_CHAIN_DEPOSITS_ROLE()); - await assertSoleMember(validatorExitRequester, await delegation.MARK_VALIDATORS_FOR_EXIT_ROLE()); - await assertSoleMember(validatorWithdrawalRequester, await delegation.REQUEST_VALIDATOR_WITHDRAWALS_ROLE()); + await assertSoleMember(exitRequester, await delegation.REQUEST_VALIDATOR_EXIT_ROLE()); + await assertSoleMember(withdrawalTriggerer, await delegation.TRIGGER_VALIDATOR_WITHDRAWAL_ROLE()); await assertSoleMember(disconnecter, await delegation.VOLUNTARY_DISCONNECT_ROLE()); await assertSoleMember(curator, await delegation.CURATOR_ROLE()); await assertSoleMember(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE()); diff --git a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts index 7a18ec834..bc5664ee2 100644 --- a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts +++ b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts @@ -604,67 +604,67 @@ describe("StakingVault.sol", () => { }); }); - context("calculateValidatorWithdrawalsFee", () => { + context("calculateValidatorWithdrawalFee", () => { it("reverts if the number of validators is zero", async () => { - await expect(stakingVault.calculateValidatorWithdrawalsFee(0)) + await expect(stakingVault.calculateValidatorWithdrawalFee(0)) .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") .withArgs("_numberOfKeys"); }); it("works with max uint256", async () => { const fee = BigInt(await withdrawalRequest.fee()); - expect(await stakingVault.calculateValidatorWithdrawalsFee(MAX_UINT256)).to.equal(BigInt(MAX_UINT256) * fee); + expect(await stakingVault.calculateValidatorWithdrawalFee(MAX_UINT256)).to.equal(BigInt(MAX_UINT256) * fee); }); it("calculates the total fee for given number of validator keys", async () => { const newFee = 100n; await withdrawalRequest.setFee(newFee); - const fee = await stakingVault.calculateValidatorWithdrawalsFee(1n); + const fee = await stakingVault.calculateValidatorWithdrawalFee(1n); expect(fee).to.equal(newFee); const feePerRequest = await withdrawalRequest.fee(); expect(fee).to.equal(feePerRequest); - const feeForMultipleKeys = await stakingVault.calculateValidatorWithdrawalsFee(2n); + const feeForMultipleKeys = await stakingVault.calculateValidatorWithdrawalFee(2n); expect(feeForMultipleKeys).to.equal(newFee * 2n); }); }); - context("markValidatorsForExit", () => { + context("requestValidatorExit", () => { it("reverts if called by a non-owner", async () => { - await expect(stakingVault.connect(stranger).markValidatorsForExit("0x")) + await expect(stakingVault.connect(stranger).requestValidatorExit("0x")) .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") .withArgs(stranger); }); it("reverts if the number of validators is zero", async () => { - await expect(stakingVault.connect(vaultOwner).markValidatorsForExit("0x")) + await expect(stakingVault.connect(vaultOwner).requestValidatorExit("0x")) .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") .withArgs("_pubkeys"); }); it("reverts if the length of the pubkeys is not a multiple of 48", async () => { await expect( - stakingVault.connect(vaultOwner).markValidatorsForExit("0x" + "ab".repeat(47)), - ).to.be.revertedWithCustomError(stakingVault, "InvalidValidatorPubkeysLength"); + stakingVault.connect(vaultOwner).requestValidatorExit("0x" + "ab".repeat(47)), + ).to.be.revertedWithCustomError(stakingVault, "InvalidPubkeysLength"); }); - it("emits the `ValidatorMarkedForExit` event for a single validator key", async () => { - await expect(stakingVault.connect(vaultOwner).markValidatorsForExit(SAMPLE_PUBKEY)) - .to.emit(stakingVault, "ValidatorMarkedForExit") + it("emits the `ValidatorExitRequested` event for a single validator key", async () => { + await expect(stakingVault.connect(vaultOwner).requestValidatorExit(SAMPLE_PUBKEY)) + .to.emit(stakingVault, "ValidatorExitRequested") .withArgs(vaultOwner, SAMPLE_PUBKEY); }); - it("emits the exact number of `ValidatorMarkedForExit` events as the number of validator keys", async () => { + it("emits the exact number of `ValidatorExitRequested` events as the number of validator keys", async () => { const numberOfKeys = 2; const keys = getPubkeys(numberOfKeys); - const tx = await stakingVault.connect(vaultOwner).markValidatorsForExit(keys.stringified); + const tx = await stakingVault.connect(vaultOwner).requestValidatorExit(keys.stringified); await expect(tx.wait()) - .to.emit(stakingVault, "ValidatorMarkedForExit") + .to.emit(stakingVault, "ValidatorExitRequested") .withArgs(vaultOwner, keys.pubkeys[0]) - .and.emit(stakingVault, "ValidatorMarkedForExit") + .and.emit(stakingVault, "ValidatorExitRequested") .withArgs(vaultOwner, keys.pubkeys[1]); const receipt = (await tx.wait()) as ContractTransactionReceipt; @@ -675,11 +675,11 @@ describe("StakingVault.sol", () => { const numberOfKeys = 5000; // uses ~16300771 gas (>54% from the 30000000 gas limit) const keys = getPubkeys(numberOfKeys); - await stakingVault.connect(vaultOwner).markValidatorsForExit(keys.stringified); + await stakingVault.connect(vaultOwner).requestValidatorExit(keys.stringified); }); }); - context("requestValidatorWithdrawals", () => { + context("triggerValidatorWithdrawal", () => { let baseFee: bigint; before(async () => { @@ -687,14 +687,14 @@ describe("StakingVault.sol", () => { }); it("reverts if msg.value is zero", async () => { - await expect(stakingVault.connect(vaultOwner).requestValidatorWithdrawals("0x", [], vaultOwnerAddress)) + await expect(stakingVault.connect(vaultOwner).triggerValidatorWithdrawal("0x", [], vaultOwnerAddress)) .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") .withArgs("msg.value"); }); it("reverts if the number of validators is zero", async () => { await expect( - stakingVault.connect(vaultOwner).requestValidatorWithdrawals("0x", [], vaultOwnerAddress, { value: 1n }), + stakingVault.connect(vaultOwner).triggerValidatorWithdrawal("0x", [], vaultOwnerAddress, { value: 1n }), ) .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") .withArgs("_pubkeys"); @@ -704,7 +704,7 @@ describe("StakingVault.sol", () => { await expect( stakingVault .connect(vaultOwner) - .requestValidatorWithdrawals(SAMPLE_PUBKEY, [], vaultOwnerAddress, { value: 1n }), + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [], vaultOwnerAddress, { value: 1n }), ) .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") .withArgs("_amounts"); @@ -714,7 +714,7 @@ describe("StakingVault.sol", () => { await expect( stakingVault .connect(vaultOwner) - .requestValidatorWithdrawals(SAMPLE_PUBKEY, [ether("1")], ZeroAddress, { value: 1n }), + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [ether("1")], ZeroAddress, { value: 1n }), ) .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") .withArgs("_refundRecipient"); @@ -724,33 +724,42 @@ describe("StakingVault.sol", () => { await expect( stakingVault .connect(stranger) - .requestValidatorWithdrawals(SAMPLE_PUBKEY, [ether("1")], vaultOwnerAddress, { value: 1n }), + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [ether("1")], vaultOwnerAddress, { value: 1n }), ) .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") - .withArgs("requestValidatorWithdrawals", stranger); + .withArgs("triggerValidatorWithdrawal", stranger); }); it("reverts if called by the vault hub on a healthy vault", async () => { await expect( stakingVault .connect(vaultHubSigner) - .requestValidatorWithdrawals(SAMPLE_PUBKEY, [ether("1")], vaultOwnerAddress, { value: 1n }), + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [ether("1")], vaultOwnerAddress, { value: 1n }), ) .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") - .withArgs("requestValidatorWithdrawals", vaultHubAddress); + .withArgs("triggerValidatorWithdrawal", vaultHubAddress); + }); + + it("reverts if the amounts array is not the same length as the pubkeys array", async () => { + await expect( + stakingVault + .connect(vaultOwner) + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [ether("1"), ether("2")], vaultOwnerAddress, { value: 1n }), + ).to.be.revertedWithCustomError(stakingVault, "InvalidAmountsLength"); }); it("reverts if the fee is less than the required fee", async () => { const numberOfKeys = 4; const pubkeys = getPubkeys(numberOfKeys); + const amounts = Array(numberOfKeys).fill(ether("1")); const value = baseFee * BigInt(numberOfKeys) - 1n; await expect( stakingVault .connect(vaultOwner) - .requestValidatorWithdrawals(pubkeys.stringified, [ether("1")], vaultOwnerAddress, { value }), + .triggerValidatorWithdrawal(pubkeys.stringified, amounts, vaultOwnerAddress, { value }), ) - .to.be.revertedWithCustomError(stakingVault, "InsufficientValidatorWithdrawalsFee") + .to.be.revertedWithCustomError(stakingVault, "InsufficientValidatorWithdrawalFee") .withArgs(value, baseFee * BigInt(numberOfKeys)); }); @@ -763,9 +772,9 @@ describe("StakingVault.sol", () => { await expect( stakingVault .connect(vaultOwner) - .requestValidatorWithdrawals(pubkeys.stringified, [ether("1")], ethRejectorAddress, { value }), + .triggerValidatorWithdrawal(pubkeys.stringified, [ether("1")], ethRejectorAddress, { value }), ) - .to.be.revertedWithCustomError(stakingVault, "ValidatorWithdrawalFeeRefundFailed") + .to.be.revertedWithCustomError(stakingVault, "WithdrawalFeeRefundFailed") .withArgs(ethRejectorAddress, overpaid); }); @@ -773,11 +782,11 @@ describe("StakingVault.sol", () => { const value = baseFee; await expect( - stakingVault.connect(vaultOwner).requestValidatorWithdrawals(SAMPLE_PUBKEY, [0n], vaultOwnerAddress, { value }), + stakingVault.connect(vaultOwner).triggerValidatorWithdrawal(SAMPLE_PUBKEY, [0n], vaultOwnerAddress, { value }), ) .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, 0n), baseFee) - .to.emit(stakingVault, "ValidatorWithdrawalsRequested") + .to.emit(stakingVault, "ValidatorWithdrawalRequested") .withArgs(vaultOwner, SAMPLE_PUBKEY, [0n], vaultOwnerAddress, 0n); }); @@ -785,11 +794,11 @@ describe("StakingVault.sol", () => { await expect( stakingVault .connect(operator) - .requestValidatorWithdrawals(SAMPLE_PUBKEY, [0n], vaultOwnerAddress, { value: baseFee }), + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [0n], vaultOwnerAddress, { value: baseFee }), ) .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, 0n), baseFee) - .to.emit(stakingVault, "ValidatorWithdrawalsRequested") + .to.emit(stakingVault, "ValidatorWithdrawalRequested") .withArgs(operator, SAMPLE_PUBKEY, [0n], vaultOwnerAddress, 0n); }); @@ -797,11 +806,11 @@ describe("StakingVault.sol", () => { await expect( stakingVault .connect(vaultOwner) - .requestValidatorWithdrawals(SAMPLE_PUBKEY, [0n], vaultOwnerAddress, { value: baseFee }), + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [0n], vaultOwnerAddress, { value: baseFee }), ) .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, 0n), baseFee) - .to.emit(stakingVault, "ValidatorWithdrawalsRequested") + .to.emit(stakingVault, "ValidatorWithdrawalRequested") .withArgs(vaultOwner, SAMPLE_PUBKEY, [0n], vaultOwnerAddress, 0n); }); @@ -810,11 +819,11 @@ describe("StakingVault.sol", () => { await expect( stakingVault .connect(vaultOwner) - .requestValidatorWithdrawals(SAMPLE_PUBKEY, [amount], vaultOwnerAddress, { value: baseFee }), + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [amount], vaultOwnerAddress, { value: baseFee }), ) .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, amount), baseFee) - .to.emit(stakingVault, "ValidatorWithdrawalsRequested") + .to.emit(stakingVault, "ValidatorWithdrawalRequested") .withArgs(vaultOwner, SAMPLE_PUBKEY, [amount], vaultOwnerAddress, 0); }); @@ -829,13 +838,13 @@ describe("StakingVault.sol", () => { await expect( stakingVault .connect(vaultOwner) - .requestValidatorWithdrawals(pubkeys.stringified, amounts, vaultOwnerAddress, { value }), + .triggerValidatorWithdrawal(pubkeys.stringified, amounts, vaultOwnerAddress, { value }), ) .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(pubkeys.pubkeys[0], amounts[0]), baseFee) .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(pubkeys.pubkeys[1], amounts[1]), baseFee) - .and.to.emit(stakingVault, "ValidatorWithdrawalsRequested") + .and.to.emit(stakingVault, "ValidatorWithdrawalRequested") .withArgs(vaultOwner, pubkeys.stringified, amounts, vaultOwnerAddress, 0n); }); @@ -849,13 +858,13 @@ describe("StakingVault.sol", () => { const strangerBalanceBefore = await ethers.provider.getBalance(stranger); await expect( - stakingVault.connect(vaultOwner).requestValidatorWithdrawals(pubkeys.stringified, amounts, stranger, { value }), + stakingVault.connect(vaultOwner).triggerValidatorWithdrawal(pubkeys.stringified, amounts, stranger, { value }), ) .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(pubkeys.pubkeys[0], amounts[0]), baseFee) .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(pubkeys.pubkeys[1], amounts[1]), baseFee) - .and.to.emit(stakingVault, "ValidatorWithdrawalsRequested") + .and.to.emit(stakingVault, "ValidatorWithdrawalRequested") .withArgs(vaultOwner, pubkeys.stringified, amounts, stranger, valueToRefund); const strangerBalanceAfter = await ethers.provider.getBalance(stranger); @@ -869,7 +878,7 @@ describe("StakingVault.sol", () => { await expect( stakingVault .connect(vaultHubSigner) - .requestValidatorWithdrawals(SAMPLE_PUBKEY, [0n], vaultOwnerAddress, { value: 1n }), + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [0n], vaultOwnerAddress, { value: 1n }), ) .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, 0n), baseFee); @@ -882,8 +891,8 @@ describe("StakingVault.sol", () => { await expect( stakingVault .connect(vaultOwner) - .requestValidatorWithdrawals(SAMPLE_PUBKEY, [ether("1")], vaultOwnerAddress, { value: 1n }), - ).to.be.revertedWithCustomError(stakingVault, "PartialWithdrawalsForbidden"); + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [ether("1")], vaultOwnerAddress, { value: 1n }), + ).to.be.revertedWithCustomError(stakingVault, "PartialWithdrawalNotAllowed"); }); }); diff --git a/test/0.8.25/vaults/vaultFactory.test.ts b/test/0.8.25/vaults/vaultFactory.test.ts index f0a6ab3c0..690fe082c 100644 --- a/test/0.8.25/vaults/vaultFactory.test.ts +++ b/test/0.8.25/vaults/vaultFactory.test.ts @@ -117,8 +117,8 @@ describe("VaultFactory.sol", () => { rebalancer: await vaultOwner1.getAddress(), depositPauser: await vaultOwner1.getAddress(), depositResumer: await vaultOwner1.getAddress(), - validatorExitRequester: await vaultOwner1.getAddress(), - validatorWithdrawalRequester: await vaultOwner1.getAddress(), + exitRequester: await vaultOwner1.getAddress(), + withdrawalTriggerer: await vaultOwner1.getAddress(), disconnecter: await vaultOwner1.getAddress(), nodeOperatorManager: await operator.getAddress(), nodeOperatorFeeClaimer: await operator.getAddress(), diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts index a8783dd5b..9544fa81c 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts @@ -98,14 +98,14 @@ describe("VaultHub.sol:withdrawals", () => { await vault.connect(vaultHubSigner).report(ether("0.9"), ether("1"), ether("1.1")); // slashing }; - context("isVaultHealthy", () => { + context("isVaultBalanced", () => { it("returns true if the vault is healthy", async () => { - expect(await vaultHub.isVaultHealthy(vaultAddress)).to.be.true; + expect(await vaultHub.isVaultBalanced(vaultAddress)).to.be.true; }); it("returns false if the vault is unhealthy", async () => { await makeVaultUnhealthy(); - expect(await vaultHub.isVaultHealthy(vaultAddress)).to.be.false; + expect(await vaultHub.isVaultBalanced(vaultAddress)).to.be.false; }); }); @@ -156,7 +156,7 @@ describe("VaultHub.sol:withdrawals", () => { it("reverts if called for a healthy vault", async () => { await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient, { value: 1n })) - .to.be.revertedWithCustomError(vaultHub, "AlreadyHealthy") + .to.be.revertedWithCustomError(vaultHub, "AlreadyBalanced") .withArgs(vaultAddress, 0n, 0n); }); @@ -165,7 +165,7 @@ describe("VaultHub.sol:withdrawals", () => { it("reverts if fees are insufficient", async () => { await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient, { value: 1n })) - .to.be.revertedWithCustomError(vault, "InsufficientValidatorWithdrawalsFee") + .to.be.revertedWithCustomError(vault, "InsufficientValidatorWithdrawalFee") .withArgs(1n, FEE); }); @@ -173,7 +173,7 @@ describe("VaultHub.sol:withdrawals", () => { await expect( vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient, { value: FEE }), ) - .to.emit(vaultHub, "VaultForceValidatorWithdrawalsRequested") + .to.emit(vaultHub, "VaultForceWithdrawalTriggered") .withArgs(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient); }); @@ -187,7 +187,7 @@ describe("VaultHub.sol:withdrawals", () => { value: FEE * BigInt(numPubkeys), }), ) - .to.emit(vaultHub, "VaultForceValidatorWithdrawalsRequested") + .to.emit(vaultHub, "VaultForceWithdrawalTriggered") .withArgs(vaultAddress, pubkeys, amounts, feeRecipient); }); }); diff --git a/test/integration/vaults-happy-path.integration.ts b/test/integration/vaults-happy-path.integration.ts index 056e59995..13f41b55a 100644 --- a/test/integration/vaults-happy-path.integration.ts +++ b/test/integration/vaults-happy-path.integration.ts @@ -167,8 +167,8 @@ describe("Scenario: Staking Vaults Happy Path", () => { rebalancer: curator, depositPauser: curator, depositResumer: curator, - validatorExitRequester: curator, - validatorWithdrawalRequester: curator, + exitRequester: curator, + withdrawalTriggerer: curator, disconnecter: curator, nodeOperatorManager: nodeOperator, nodeOperatorFeeClaimer: nodeOperator, @@ -202,8 +202,8 @@ describe("Scenario: Staking Vaults Happy Path", () => { expect(await isSoleRoleMember(curator, await delegation.REBALANCE_ROLE())).to.be.true; expect(await isSoleRoleMember(curator, await delegation.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE())).to.be.true; expect(await isSoleRoleMember(curator, await delegation.RESUME_BEACON_CHAIN_DEPOSITS_ROLE())).to.be.true; - expect(await isSoleRoleMember(curator, await delegation.MARK_VALIDATORS_FOR_EXIT_ROLE())).to.be.true; - expect(await isSoleRoleMember(curator, await delegation.REQUEST_VALIDATOR_WITHDRAWALS_ROLE())).to.be.true; + expect(await isSoleRoleMember(curator, await delegation.REQUEST_VALIDATOR_EXIT_ROLE())).to.be.true; + expect(await isSoleRoleMember(curator, await delegation.TRIGGER_VALIDATOR_WITHDRAWAL_ROLE())).to.be.true; expect(await isSoleRoleMember(curator, await delegation.VOLUNTARY_DISCONNECT_ROLE())).to.be.true; }); @@ -373,7 +373,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { it("Should allow Owner to trigger validator exit to cover fees", async () => { // simulate validator exit const secondValidatorKey = pubKeysBatch.slice(Number(PUBKEY_LENGTH), Number(PUBKEY_LENGTH) * 2); - await delegation.connect(curator).markValidatorsForExit(secondValidatorKey); + await delegation.connect(curator).requestValidatorExit(secondValidatorKey); await updateBalance(stakingVaultAddress, VALIDATOR_DEPOSIT_SIZE); const { elapsedProtocolReward, elapsedVaultReward } = await calculateReportParams(); From dd28196118649afa933f447ee3720925951dd49f Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 12 Feb 2025 15:22:47 +0000 Subject: [PATCH 58/70] chore: polishing --- contracts/0.8.25/vaults/StakingVault.sol | 13 ++++++- contracts/0.8.25/vaults/VaultHub.sol | 22 ++++++++---- .../vaults/staking-vault/stakingVault.test.ts | 15 +++++++- .../vaulthub/vaulthub.withdrawals.test.ts | 35 +++++++------------ .../negative-rebase.integration.ts | 2 +- 5 files changed, 54 insertions(+), 33 deletions(-) diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 0f65c713b..b519ae7ef 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -100,7 +100,12 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @notice The length of the public key in bytes */ - uint256 internal constant PUBLIC_KEY_LENGTH = 48; + uint256 public constant PUBLIC_KEY_LENGTH = 48; + + /** + * @notice The maximum number of pubkeys per request (to avoid burning too much gas) + */ + uint256 public constant MAX_PUBLIC_KEYS_PER_REQUEST = 5000; /** * @notice Storage offset slot for ERC-7201 namespace @@ -445,6 +450,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { } uint256 keysCount = _pubkeys.length / PUBLIC_KEY_LENGTH; + if (keysCount > MAX_PUBLIC_KEYS_PER_REQUEST) revert TooManyPubkeys(); for (uint256 i = 0; i < keysCount; i++) { emit ValidatorExitRequested(msg.sender, bytes(_pubkeys[i * PUBLIC_KEY_LENGTH : (i + 1) * PUBLIC_KEY_LENGTH])); } @@ -725,6 +731,11 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { */ error InvalidAmountsLength(); + /** + * @notice Thrown when the number of pubkeys is too large + */ + error TooManyPubkeys(); + /** * @notice Thrown when the validator withdrawal fee is insufficient * @param _passed Amount of ether passed to the function diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 2ea4e2b9c..304f49e93 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -69,6 +69,8 @@ abstract contract VaultHub is PausableUntilWithRoles { uint256 internal constant MAX_VAULT_SIZE_BP = 10_00; /// @notice amount of ETH that is locked on the vault on connect and can be withdrawn on disconnect only uint256 internal constant CONNECT_DEPOSIT = 1 ether; + /// @notice length of the validator pubkey in bytes + uint256 internal constant PUBLIC_KEY_LENGTH = 48; /// @notice Lido stETH contract IStETH public immutable STETH; @@ -344,15 +346,17 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @notice forces validator withdrawal from the beacon chain in case the vault is unbalanced /// @param _vault vault address /// @param _pubkeys pubkeys of the validators to withdraw - /// @param _amounts amounts of the validators to withdraw /// @param _refundRecepient address of the recipient of the refund - /// TODO: do not pass amounts, but calculate them based on the keys number - function forceValidatorWithdrawals(address _vault, bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecepient) external payable { + function forceValidatorWithdrawals( + address _vault, + bytes calldata _pubkeys, + address _refundRecepient + ) external payable { if (msg.value == 0) revert ZeroArgument("msg.value"); if (_vault == address(0)) revert ZeroArgument("_vault"); if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); - if (_amounts.length == 0) revert ZeroArgument("_amounts"); if (_refundRecepient == address(0)) revert ZeroArgument("_refundRecepient"); + if (_pubkeys.length % PUBLIC_KEY_LENGTH != 0) revert InvalidPubkeysLength(); VaultSocket storage socket = _connectedSocket(_vault); uint256 threshold = _maxMintableShares(_vault, socket.reserveRatioThresholdBP, socket.shareLimit); @@ -360,9 +364,12 @@ abstract contract VaultHub is PausableUntilWithRoles { revert AlreadyBalanced(_vault, socket.sharesMinted, threshold); } - IStakingVault(_vault).triggerValidatorWithdrawal{value: msg.value}(_pubkeys, _amounts, _refundRecepient); + uint256 numValidators = _pubkeys.length / PUBLIC_KEY_LENGTH; + uint64[] memory amounts = new uint64[](numValidators); + + IStakingVault(_vault).triggerValidatorWithdrawal{value: msg.value}(_pubkeys, amounts, _refundRecepient); - emit VaultForceWithdrawalTriggered(_vault, _pubkeys, _amounts, _refundRecepient); + emit VaultForceWithdrawalTriggered(_vault, _pubkeys, _refundRecepient); } function _disconnect(address _vault) internal { @@ -541,7 +548,7 @@ abstract contract VaultHub is PausableUntilWithRoles { event BurnedSharesOnVault(address indexed vault, uint256 amountOfShares); event VaultRebalanced(address indexed vault, uint256 sharesBurned); event VaultProxyCodehashAdded(bytes32 indexed codehash); - event VaultForceWithdrawalTriggered(address indexed vault, bytes pubkeys, uint64[] amounts, address refundRecepient); + event VaultForceWithdrawalTriggered(address indexed vault, bytes pubkeys, address refundRecepient); error StETHMintFailed(address vault); error AlreadyBalanced(address vault, uint256 mintedShares, uint256 rebalancingThresholdInShares); @@ -562,4 +569,5 @@ abstract contract VaultHub is PausableUntilWithRoles { error AlreadyExists(bytes32 codehash); error NoMintedSharesShouldBeLeft(address vault, uint256 sharesMinted); error VaultProxyNotAllowed(address beacon); + error InvalidPubkeysLength(); } diff --git a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts index bc5664ee2..29f295beb 100644 --- a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts +++ b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts @@ -21,6 +21,9 @@ import { Snapshot } from "test/suite"; const MAX_INT128 = 2n ** 127n - 1n; const MAX_UINT128 = 2n ** 128n - 1n; +const PUBLIC_KEY_LENGTH = 48; +const MAX_PUBLIC_KEYS_PER_REQUEST = 5000; + const SAMPLE_PUBKEY = "0x" + "ab".repeat(48); const getPubkeys = (num: number): { pubkeys: string[]; stringified: string } => { @@ -120,6 +123,8 @@ describe("StakingVault.sol", () => { context("initial state (getters)", () => { it("returns the correct initial state and constants", async () => { expect(await stakingVault.DEPOSIT_CONTRACT()).to.equal(depositContractAddress); + expect(await stakingVault.PUBLIC_KEY_LENGTH()).to.equal(PUBLIC_KEY_LENGTH); + expect(await stakingVault.MAX_PUBLIC_KEYS_PER_REQUEST()).to.equal(MAX_PUBLIC_KEYS_PER_REQUEST); expect(await stakingVault.owner()).to.equal(await vaultOwner.getAddress()); expect(await stakingVault.getInitializedVersion()).to.equal(1n); @@ -650,6 +655,14 @@ describe("StakingVault.sol", () => { ).to.be.revertedWithCustomError(stakingVault, "InvalidPubkeysLength"); }); + it("reverts if the number of validator keys is too large", async () => { + const numberOfKeys = Number(await stakingVault.MAX_PUBLIC_KEYS_PER_REQUEST()) + 1; + const keys = getPubkeys(numberOfKeys); + await expect( + stakingVault.connect(vaultOwner).requestValidatorExit(keys.stringified), + ).to.be.revertedWithCustomError(stakingVault, "TooManyPubkeys"); + }); + it("emits the `ValidatorExitRequested` event for a single validator key", async () => { await expect(stakingVault.connect(vaultOwner).requestValidatorExit(SAMPLE_PUBKEY)) .to.emit(stakingVault, "ValidatorExitRequested") @@ -671,7 +684,7 @@ describe("StakingVault.sol", () => { expect(receipt.logs.length).to.equal(numberOfKeys); }); - it("handles large number of validator keys", async () => { + it("handles up to MAX_PUBLIC_KEYS_PER_REQUEST validator keys", async () => { const numberOfKeys = 5000; // uses ~16300771 gas (>54% from the 30000000 gas limit) const keys = getPubkeys(numberOfKeys); diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts index 9544fa81c..73c05dd26 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts @@ -111,37 +111,31 @@ describe("VaultHub.sol:withdrawals", () => { context("forceValidatorWithdrawals", () => { it("reverts if msg.value is 0", async () => { - await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient, { value: 0n })) + await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 0n })) .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") .withArgs("msg.value"); }); it("reverts if the vault is zero address", async () => { - await expect(vaultHub.forceValidatorWithdrawals(ZeroAddress, SAMPLE_PUBKEY, [0n], feeRecipient, { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawals(ZeroAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") .withArgs("_vault"); }); it("reverts if zero pubkeys", async () => { - await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, "0x", [0n], feeRecipient, { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, "0x", feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") .withArgs("_pubkeys"); }); - it("reverts if zero amounts", async () => { - await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [], feeRecipient, { value: 1n })) - .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") - .withArgs("_amounts"); - }); - it("reverts if zero refund recipient", async () => { - await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [0n], ZeroAddress, { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, ZeroAddress, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") .withArgs("_refundRecepient"); }); it("reverts if vault is not connected to the hub", async () => { - await expect(vaultHub.forceValidatorWithdrawals(stranger, SAMPLE_PUBKEY, [0n], feeRecipient, { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawals(stranger, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "NotConnectedToHub") .withArgs(stranger.address); }); @@ -149,13 +143,13 @@ describe("VaultHub.sol:withdrawals", () => { it("reverts if called for a disconnected vault", async () => { await vaultHub.connect(user).disconnect(vaultAddress); - await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient, { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "NotConnectedToHub") .withArgs(vaultAddress); }); it("reverts if called for a healthy vault", async () => { - await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient, { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "AlreadyBalanced") .withArgs(vaultAddress, 0n, 0n); }); @@ -164,31 +158,26 @@ describe("VaultHub.sol:withdrawals", () => { beforeEach(async () => await makeVaultUnhealthy()); it("reverts if fees are insufficient", async () => { - await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient, { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vault, "InsufficientValidatorWithdrawalFee") .withArgs(1n, FEE); }); it("initiates force validator withdrawal", async () => { - await expect( - vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient, { value: FEE }), - ) + await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: FEE })) .to.emit(vaultHub, "VaultForceWithdrawalTriggered") - .withArgs(vaultAddress, SAMPLE_PUBKEY, [0n], feeRecipient); + .withArgs(vaultAddress, SAMPLE_PUBKEY, feeRecipient); }); it("initiates force validator withdrawal with multiple pubkeys", async () => { const numPubkeys = 3; const pubkeys = "0x" + "ab".repeat(numPubkeys * 48); - const amounts = Array.from({ length: numPubkeys }, () => 0n); await expect( - vaultHub.forceValidatorWithdrawals(vaultAddress, pubkeys, amounts, feeRecipient, { - value: FEE * BigInt(numPubkeys), - }), + vaultHub.forceValidatorWithdrawals(vaultAddress, pubkeys, feeRecipient, { value: FEE * BigInt(numPubkeys) }), ) .to.emit(vaultHub, "VaultForceWithdrawalTriggered") - .withArgs(vaultAddress, pubkeys, amounts, feeRecipient); + .withArgs(vaultAddress, pubkeys, feeRecipient); }); }); }); diff --git a/test/integration/negative-rebase.integration.ts b/test/integration/negative-rebase.integration.ts index 1dfc4c61c..aceca72ad 100644 --- a/test/integration/negative-rebase.integration.ts +++ b/test/integration/negative-rebase.integration.ts @@ -12,7 +12,7 @@ import { Snapshot } from "test/suite"; // TODO: check why it fails on CI, but works locally // e.g. https://github.com/lidofinance/core/actions/runs/12390882454/job/34586841193 -describe.skip("Negative rebase", () => { +describe("Integration: Negative rebase", () => { let ctx: ProtocolContext; let beforeSnapshot: string; let beforeEachSnapshot: string; From b19caf9f02c4354e6cf514e8d025cdf610feb3ec Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 12 Feb 2025 16:22:00 +0000 Subject: [PATCH 59/70] test: partially restore negative rebase --- package.json | 6 +++--- .../negative-rebase.integration.ts | 20 ++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index c446e5373..ba7fde637 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,9 @@ "test:integration": "hardhat test test/integration/**/*.ts", "test:integration:trace": "hardhat test test/integration/**/*.ts --trace --disabletracer", "test:integration:fulltrace": "hardhat test test/integration/**/*.ts --fulltrace --disabletracer", - "test:integration:scratch": "HARDHAT_FORKING_URL= INTEGRATION_WITH_CSM=off INTEGRATION_WITH_SCRATCH_DEPLOY=on hardhat test test/integration/**/*.ts", - "test:integration:scratch:trace": "HARDHAT_FORKING_URL= INTEGRATION_WITH_CSM=off INTEGRATION_WITH_SCRATCH_DEPLOY=on hardhat test test/integration/**/*.ts --trace --disabletracer", - "test:integration:scratch:fulltrace": "HARDHAT_FORKING_URL= INTEGRATION_WITH_CSM=off INTEGRATION_WITH_SCRATCH_DEPLOY=on hardhat test test/integration/**/*.ts --fulltrace --disabletracer", + "test:integration:scratch": "HARDHAT_FORKING_URL= INTEGRATION_WITH_CSM=off hardhat test test/integration/**/*.ts", + "test:integration:scratch:trace": "HARDHAT_FORKING_URL= INTEGRATION_WITH_CSM=off hardhat test test/integration/**/*.ts --trace --disabletracer", + "test:integration:scratch:fulltrace": "HARDHAT_FORKING_URL= INTEGRATION_WITH_CSM=off hardhat test test/integration/**/*.ts --fulltrace --disabletracer", "test:integration:fork:local": "hardhat test test/integration/**/*.ts --network local", "test:integration:fork:mainnet": "hardhat test test/integration/**/*.ts --network mainnet-fork", "test:integration:fork:mainnet:custom": "hardhat test --network mainnet-fork", diff --git a/test/integration/negative-rebase.integration.ts b/test/integration/negative-rebase.integration.ts index aceca72ad..0d4e5f32b 100644 --- a/test/integration/negative-rebase.integration.ts +++ b/test/integration/negative-rebase.integration.ts @@ -10,18 +10,18 @@ import { report } from "lib/protocol/helpers"; import { Snapshot } from "test/suite"; -// TODO: check why it fails on CI, but works locally -// e.g. https://github.com/lidofinance/core/actions/runs/12390882454/job/34586841193 describe("Integration: Negative rebase", () => { let ctx: ProtocolContext; - let beforeSnapshot: string; - let beforeEachSnapshot: string; let ethHolder: HardhatEthersSigner; + let snapshot: string; + let originalState: string; + before(async () => { - beforeSnapshot = await Snapshot.take(); ctx = await getProtocolContext(); + snapshot = await Snapshot.take(); + [ethHolder] = await ethers.getSigners(); await setBalance(ethHolder.address, ether("1000000")); const network = await ethers.provider.getNetwork(); @@ -40,11 +40,11 @@ describe("Integration: Negative rebase", () => { } }); - after(async () => await Snapshot.restore(beforeSnapshot)); + beforeEach(async () => (originalState = await Snapshot.take())); - beforeEach(async () => (beforeEachSnapshot = await Snapshot.take())); + afterEach(async () => await Snapshot.restore(originalState)); - afterEach(async () => await Snapshot.restore(beforeEachSnapshot)); + after(async () => await Snapshot.restore(snapshot)); // Rollback to the initial state pre deployment const exitedValidatorsCount = async () => { const ids = await ctx.contracts.stakingRouter.getStakingModuleIds(); @@ -83,7 +83,9 @@ describe("Integration: Negative rebase", () => { expect(beforeLastReportData.totalExitedValidators).to.be.equal(lastExitedTotal); }); - it("Should store correctly many negative rebases", async () => { + // TODO: check why it fails on CI, but works locally + // e.g. https://github.com/lidofinance/core/actions/runs/12390882454/job/34586841193 + it.skip("Should store correctly many negative rebases", async () => { const { locator, oracleReportSanityChecker } = ctx.contracts; expect((await locator.oracleReportSanityChecker()) == oracleReportSanityChecker.address); From ccd904391ef3d1424c7a984c68d9fecdb3471ae3 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 12 Feb 2025 17:01:42 +0000 Subject: [PATCH 60/70] chore: cleanup --- contracts/0.8.25/vaults/StakingVault.sol | 2 +- contracts/0.8.25/vaults/VaultHub.sol | 2 +- .../StakingVault__HarnessForTestUpgrade.sol | 2 -- .../vaulthub/vaulthub.withdrawals.test.ts | 22 +++++++++---------- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index b519ae7ef..592c9c8e5 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -45,7 +45,7 @@ import {IStakingVault} from "./interfaces/IStakingVault.sol"; * - `lock()` * - `report()` * - `rebalance()` - * - `forceValidatorWithdrawals()` + * - `triggerValidatorWithdrawal()` (only full validator exit when the vault is unbalanced) * - Anyone: * - Can send ETH directly to the vault (treated as rewards) * diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 304f49e93..205214f26 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -347,7 +347,7 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @param _vault vault address /// @param _pubkeys pubkeys of the validators to withdraw /// @param _refundRecepient address of the recipient of the refund - function forceValidatorWithdrawals( + function forceValidatorWithdrawal( address _vault, bytes calldata _pubkeys, address _refundRecepient diff --git a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol index 46eda7ad9..e79f7bb27 100644 --- a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol +++ b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol @@ -133,8 +133,6 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl address _recipient ) external payable {} - function forceValidatorWithdrawals(bytes calldata _pubkeys) external payable {} - error ZeroArgument(string name); error VaultAlreadyInitialized(); } diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts index 73c05dd26..2a2d821e8 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts @@ -109,33 +109,33 @@ describe("VaultHub.sol:withdrawals", () => { }); }); - context("forceValidatorWithdrawals", () => { + context("forceValidatorWithdrawal", () => { it("reverts if msg.value is 0", async () => { - await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 0n })) + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 0n })) .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") .withArgs("msg.value"); }); it("reverts if the vault is zero address", async () => { - await expect(vaultHub.forceValidatorWithdrawals(ZeroAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawal(ZeroAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") .withArgs("_vault"); }); it("reverts if zero pubkeys", async () => { - await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, "0x", feeRecipient, { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, "0x", feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") .withArgs("_pubkeys"); }); it("reverts if zero refund recipient", async () => { - await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, ZeroAddress, { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, ZeroAddress, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") .withArgs("_refundRecepient"); }); it("reverts if vault is not connected to the hub", async () => { - await expect(vaultHub.forceValidatorWithdrawals(stranger, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawal(stranger, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "NotConnectedToHub") .withArgs(stranger.address); }); @@ -143,13 +143,13 @@ describe("VaultHub.sol:withdrawals", () => { it("reverts if called for a disconnected vault", async () => { await vaultHub.connect(user).disconnect(vaultAddress); - await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "NotConnectedToHub") .withArgs(vaultAddress); }); it("reverts if called for a healthy vault", async () => { - await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "AlreadyBalanced") .withArgs(vaultAddress, 0n, 0n); }); @@ -158,13 +158,13 @@ describe("VaultHub.sol:withdrawals", () => { beforeEach(async () => await makeVaultUnhealthy()); it("reverts if fees are insufficient", async () => { - await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vault, "InsufficientValidatorWithdrawalFee") .withArgs(1n, FEE); }); it("initiates force validator withdrawal", async () => { - await expect(vaultHub.forceValidatorWithdrawals(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: FEE })) + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: FEE })) .to.emit(vaultHub, "VaultForceWithdrawalTriggered") .withArgs(vaultAddress, SAMPLE_PUBKEY, feeRecipient); }); @@ -174,7 +174,7 @@ describe("VaultHub.sol:withdrawals", () => { const pubkeys = "0x" + "ab".repeat(numPubkeys * 48); await expect( - vaultHub.forceValidatorWithdrawals(vaultAddress, pubkeys, feeRecipient, { value: FEE * BigInt(numPubkeys) }), + vaultHub.forceValidatorWithdrawal(vaultAddress, pubkeys, feeRecipient, { value: FEE * BigInt(numPubkeys) }), ) .to.emit(vaultHub, "VaultForceWithdrawalTriggered") .withArgs(vaultAddress, pubkeys, feeRecipient); From 32bacb4731c7b2d62735d304fcb9cdc49f8a7f76 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Thu, 13 Feb 2025 11:25:25 +0000 Subject: [PATCH 61/70] fix: event indexing --- contracts/0.8.25/vaults/StakingVault.sol | 4 ++-- .../contracts/VaultFactory__MockForStakingVault.sol | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 592c9c8e5..82db28b7f 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -452,7 +452,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { uint256 keysCount = _pubkeys.length / PUBLIC_KEY_LENGTH; if (keysCount > MAX_PUBLIC_KEYS_PER_REQUEST) revert TooManyPubkeys(); for (uint256 i = 0; i < keysCount; i++) { - emit ValidatorExitRequested(msg.sender, bytes(_pubkeys[i * PUBLIC_KEY_LENGTH : (i + 1) * PUBLIC_KEY_LENGTH])); + emit ValidatorExitRequested(msg.sender, string(_pubkeys[i * PUBLIC_KEY_LENGTH : (i + 1) * PUBLIC_KEY_LENGTH])); } } @@ -624,7 +624,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @param _pubkey Public key of the validator to exit * @dev Signals to node operators that they should exit this validator from the beacon chain */ - event ValidatorExitRequested(address _sender, bytes _pubkey); + event ValidatorExitRequested(address _sender, string indexed _pubkey); /** * @notice Emitted when validator withdrawals are requested via EIP-7002 diff --git a/test/0.8.25/vaults/staking-vault/contracts/VaultFactory__MockForStakingVault.sol b/test/0.8.25/vaults/staking-vault/contracts/VaultFactory__MockForStakingVault.sol index 78eae1928..f843c98c9 100644 --- a/test/0.8.25/vaults/staking-vault/contracts/VaultFactory__MockForStakingVault.sol +++ b/test/0.8.25/vaults/staking-vault/contracts/VaultFactory__MockForStakingVault.sol @@ -7,7 +7,7 @@ import {UpgradeableBeacon} from "@openzeppelin/contracts-v5.2/proxy/beacon/Upgra import {BeaconProxy} from "@openzeppelin/contracts-v5.2/proxy/beacon/BeaconProxy.sol"; import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.sol"; -contract VaultFactory__Mock is UpgradeableBeacon { +contract VaultFactory__MockForStakingVault is UpgradeableBeacon { event VaultCreated(address indexed vault); constructor(address _stakingVaultImplementation) UpgradeableBeacon(_stakingVaultImplementation, msg.sender) {} From 07655e8345028fea7834bc3d617ae99e7088558b Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Thu, 13 Feb 2025 11:26:16 +0000 Subject: [PATCH 62/70] test: update vault hub tests --- .../DepositContract__MockForVaultHub.sol | 17 +++++ .../StakingVault__MockForVaultHub.sol | 68 +++++++++++++++++++ .../VaultFactory__MockForStakingVault.sol | 21 ++++++ ....ts => vaulthub.force-withdrawals.test.ts} | 42 +++++++----- test/deploy/stakingVault.ts | 6 +- 5 files changed, 133 insertions(+), 21 deletions(-) create mode 100644 test/0.8.25/vaults/vaulthub/contracts/DepositContract__MockForVaultHub.sol create mode 100644 test/0.8.25/vaults/vaulthub/contracts/StakingVault__MockForVaultHub.sol create mode 100644 test/0.8.25/vaults/vaulthub/contracts/VaultFactory__MockForStakingVault.sol rename test/0.8.25/vaults/vaulthub/{vaulthub.withdrawals.test.ts => vaulthub.force-withdrawals.test.ts} (87%) diff --git a/test/0.8.25/vaults/vaulthub/contracts/DepositContract__MockForVaultHub.sol b/test/0.8.25/vaults/vaulthub/contracts/DepositContract__MockForVaultHub.sol new file mode 100644 index 000000000..f05300c14 --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/contracts/DepositContract__MockForVaultHub.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity ^0.8.0; + +contract DepositContract__MockForVaultHub { + event DepositEvent(bytes pubkey, bytes withdrawal_credentials, bytes signature, bytes32 deposit_data_root); + + function deposit( + bytes calldata pubkey, // 48 bytes + bytes calldata withdrawal_credentials, // 32 bytes + bytes calldata signature, // 96 bytes + bytes32 deposit_data_root + ) external payable { + emit DepositEvent(pubkey, withdrawal_credentials, signature, deposit_data_root); + } +} diff --git a/test/0.8.25/vaults/vaulthub/contracts/StakingVault__MockForVaultHub.sol b/test/0.8.25/vaults/vaulthub/contracts/StakingVault__MockForVaultHub.sol new file mode 100644 index 000000000..6d668b0d0 --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/contracts/StakingVault__MockForVaultHub.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity ^0.8.0; + +contract StakingVault__MockForVaultHub { + address public vaultHub; + address public depositContract; + + address public owner; + address public nodeOperator; + + uint256 public $locked; + uint256 public $valuation; + int256 public $inOutDelta; + + constructor(address _vaultHub, address _depositContract) { + vaultHub = _vaultHub; + depositContract = _depositContract; + } + + function initialize(address _owner, address _nodeOperator, bytes calldata) external { + owner = _owner; + nodeOperator = _nodeOperator; + } + + function lock(uint256 amount) external { + $locked += amount; + } + + function locked() external view returns (uint256) { + return $locked; + } + + function valuation() external view returns (uint256) { + return $valuation; + } + + function inOutDelta() external view returns (int256) { + return $inOutDelta; + } + + function fund() external payable { + $valuation += msg.value; + $inOutDelta += int256(msg.value); + } + + function withdraw(address, uint256 amount) external { + $valuation -= amount; + $inOutDelta -= int256(amount); + } + + function report(uint256 _valuation, int256 _inOutDelta, uint256 _locked) external { + $valuation = _valuation; + $inOutDelta = _inOutDelta; + $locked = _locked; + } + + function triggerValidatorWithdrawal( + bytes calldata _pubkeys, + uint64[] calldata _amounts, + address _refundRecipient + ) external payable { + emit ValidatorWithdrawalTriggered(_pubkeys, _amounts, _refundRecipient); + } + + event ValidatorWithdrawalTriggered(bytes pubkeys, uint64[] amounts, address refundRecipient); +} diff --git a/test/0.8.25/vaults/vaulthub/contracts/VaultFactory__MockForStakingVault.sol b/test/0.8.25/vaults/vaulthub/contracts/VaultFactory__MockForStakingVault.sol new file mode 100644 index 000000000..b25b30ce2 --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/contracts/VaultFactory__MockForStakingVault.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity ^0.8.0; + +import {UpgradeableBeacon} from "@openzeppelin/contracts-v5.2/proxy/beacon/UpgradeableBeacon.sol"; +import {BeaconProxy} from "@openzeppelin/contracts-v5.2/proxy/beacon/BeaconProxy.sol"; +import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.sol"; + +contract VaultFactory__MockForVaultHub is UpgradeableBeacon { + event VaultCreated(address indexed vault); + + constructor(address _stakingVaultImplementation) UpgradeableBeacon(_stakingVaultImplementation, msg.sender) {} + + function createVault(address _owner, address _operator) external { + IStakingVault vault = IStakingVault(address(new BeaconProxy(address(this), ""))); + vault.initialize(_owner, _operator, ""); + + emit VaultCreated(address(vault)); + } +} diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.force-withdrawals.test.ts similarity index 87% rename from test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts rename to test/0.8.25/vaults/vaulthub/vaulthub.force-withdrawals.test.ts index 2a2d821e8..c9c775945 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.force-withdrawals.test.ts @@ -4,13 +4,18 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { DepositContract, StakingVault, StETH__HarnessForVaultHub, VaultHub } from "typechain-types"; +import { + DepositContract__MockForVaultHub, + StakingVault__MockForVaultHub, + StETH__HarnessForVaultHub, + VaultHub, +} from "typechain-types"; import { impersonate } from "lib"; import { findEvents } from "lib/event"; import { ether } from "lib/units"; -import { deployLidoLocator, deployWithdrawalsPreDeployedMock } from "test/deploy"; +import { deployLidoLocator } from "test/deploy"; import { Snapshot } from "test/suite"; const SAMPLE_PUBKEY = "0x" + "01".repeat(48); @@ -22,15 +27,16 @@ const TREASURY_FEE_BP = 5_00n; const FEE = 2n; -describe("VaultHub.sol:withdrawals", () => { +describe("VaultHub.sol:forceWithdrawals", () => { let deployer: HardhatEthersSigner; let user: HardhatEthersSigner; let stranger: HardhatEthersSigner; let feeRecipient: HardhatEthersSigner; + let vaultHub: VaultHub; - let vault: StakingVault; + let vault: StakingVault__MockForVaultHub; let steth: StETH__HarnessForVaultHub; - let depositContract: DepositContract; + let depositContract: DepositContract__MockForVaultHub; let vaultAddress: string; let vaultHubAddress: string; @@ -42,11 +48,9 @@ describe("VaultHub.sol:withdrawals", () => { before(async () => { [deployer, user, stranger, feeRecipient] = await ethers.getSigners(); - await deployWithdrawalsPreDeployedMock(FEE); - const locator = await deployLidoLocator(); - steth = await ethers.deployContract("StETH__HarnessForVaultHub", [user], { value: ether("100.0") }); - depositContract = await ethers.deployContract("DepositContract"); + steth = await ethers.deployContract("StETH__HarnessForVaultHub", [user], { value: ether("1000.0") }); + depositContract = await ethers.deployContract("DepositContract__MockForVaultHub"); const vaultHubImpl = await ethers.deployContract("Accounting", [locator, steth]); const proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()]); @@ -60,12 +64,14 @@ describe("VaultHub.sol:withdrawals", () => { await accounting.grantRole(await vaultHub.VAULT_MASTER_ROLE(), user); await accounting.grantRole(await vaultHub.VAULT_REGISTRY_ROLE(), user); - const stakingVaultImpl = await ethers.deployContract("StakingVault", [ + const stakingVaultImpl = await ethers.deployContract("StakingVault__MockForVaultHub", [ await vaultHub.getAddress(), await depositContract.getAddress(), ]); - const vaultFactory = await ethers.deployContract("VaultFactory__Mock", [await stakingVaultImpl.getAddress()]); + const vaultFactory = await ethers.deployContract("VaultFactory__MockForVaultHub", [ + await stakingVaultImpl.getAddress(), + ]); const vaultCreationTx = (await vaultFactory .createVault(await user.getAddress(), await user.getAddress()) @@ -74,7 +80,7 @@ describe("VaultHub.sol:withdrawals", () => { const events = findEvents(vaultCreationTx, "VaultCreated"); const vaultCreatedEvent = events[0]; - vault = await ethers.getContractAt("StakingVault", vaultCreatedEvent.args.vault, user); + vault = await ethers.getContractAt("StakingVault__MockForVaultHub", vaultCreatedEvent.args.vault, user); vaultAddress = await vault.getAddress(); const codehash = keccak256(await ethers.provider.getCode(vaultAddress)); @@ -134,6 +140,12 @@ describe("VaultHub.sol:withdrawals", () => { .withArgs("_refundRecepient"); }); + it("reverts if pubkeys are not valid", async () => { + await expect( + vaultHub.forceValidatorWithdrawal(vaultAddress, "0x" + "01".repeat(47), feeRecipient, { value: 1n }), + ).to.be.revertedWithCustomError(vaultHub, "InvalidPubkeysLength"); + }); + it("reverts if vault is not connected to the hub", async () => { await expect(vaultHub.forceValidatorWithdrawal(stranger, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "NotConnectedToHub") @@ -157,12 +169,6 @@ describe("VaultHub.sol:withdrawals", () => { context("unhealthy vault", () => { beforeEach(async () => await makeVaultUnhealthy()); - it("reverts if fees are insufficient", async () => { - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) - .to.be.revertedWithCustomError(vault, "InsufficientValidatorWithdrawalFee") - .withArgs(1n, FEE); - }); - it("initiates force validator withdrawal", async () => { await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: FEE })) .to.emit(vaultHub, "VaultForceWithdrawalTriggered") diff --git a/test/deploy/stakingVault.ts b/test/deploy/stakingVault.ts index 1df9baf1a..8265714be 100644 --- a/test/deploy/stakingVault.ts +++ b/test/deploy/stakingVault.ts @@ -8,7 +8,7 @@ import { EIP7002WithdrawalRequest_Mock, StakingVault, StakingVault__factory, - VaultFactory__Mock, + VaultFactory__MockForStakingVault, VaultHub__MockForStakingVault, } from "typechain-types"; @@ -21,7 +21,7 @@ type DeployedStakingVault = { stakingVault: StakingVault; stakingVaultImplementation: StakingVault; vaultHub: VaultHub__MockForStakingVault; - vaultFactory: VaultFactory__Mock; + vaultFactory: VaultFactory__MockForStakingVault; }; export async function deployWithdrawalsPreDeployedMock( @@ -53,7 +53,7 @@ export async function deployStakingVaultBehindBeaconProxy( ]); // deploying factory/beacon - const vaultFactory_ = await ethers.deployContract("VaultFactory__Mock", [ + const vaultFactory_ = await ethers.deployContract("VaultFactory__MockForStakingVault", [ await stakingVaultImplementation_.getAddress(), ]); From 46b08ec957efeb5734d5452a62a2ad88a92f3c59 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Thu, 13 Feb 2025 13:49:07 +0000 Subject: [PATCH 63/70] test: add tests for hub functions for vaulthub --- contracts/0.8.25/vaults/VaultHub.sol | 75 ++- .../contracts/VaultHub__MockForDashboard.sol | 2 +- .../contracts/VaultHub__MockForDelegation.sol | 2 +- .../vaulthub.force-withdrawals.test.ts | 11 - .../vaults/vaulthub/vaulthub.hub.test.ts | 631 ++++++++++++++++++ 5 files changed, 692 insertions(+), 29 deletions(-) create mode 100644 test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 205214f26..68b60e1a4 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -29,6 +29,10 @@ abstract contract VaultHub is PausableUntilWithRoles { mapping(address => uint256) vaultIndex; /// @notice allowed beacon addresses mapping(bytes32 => bool) vaultProxyCodehash; + /// @notice maximum number of vaults that can be connected to the hub + uint256 maxVaultsCount; + /// @notice maximum size of the single vault relative to Lido TVL in basis points + uint256 maxVaultSizeBP; } struct VaultSocket { @@ -63,10 +67,6 @@ abstract contract VaultHub is PausableUntilWithRoles { bytes32 public constant VAULT_REGISTRY_ROLE = keccak256("Vaults.VaultHub.VaultRegistryRole"); /// @dev basis points base uint256 internal constant TOTAL_BASIS_POINTS = 100_00; - /// @dev maximum number of vaults that can be connected to the hub - uint256 internal constant MAX_VAULTS_COUNT = 500; - /// @dev maximum size of the single vault relative to Lido TVL in basis points - uint256 internal constant MAX_VAULT_SIZE_BP = 10_00; /// @notice amount of ETH that is locked on the vault on connect and can be withdrawn on disconnect only uint256 internal constant CONNECT_DEPOSIT = 1 ether; /// @notice length of the validator pubkey in bytes @@ -85,8 +85,13 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @param _admin admin address to manage the roles function __VaultHub_init(address _admin) internal onlyInitializing { __AccessControlEnumerable_init(); + + VaultHubStorage storage $ = _getVaultHubStorage(); + $.maxVaultsCount = 500; + $.maxVaultSizeBP = 10_00; // 10% + // the stone in the elevator - _getVaultHubStorage().sockets.push(VaultSocket(address(0), 0, 0, 0, 0, 0, false)); + $.sockets.push(VaultSocket(address(0), 0, 0, 0, 0, 0, true)); _grantRole(DEFAULT_ADMIN_ROLE, _admin); } @@ -107,6 +112,16 @@ abstract contract VaultHub is PausableUntilWithRoles { return _getVaultHubStorage().sockets.length - 1; } + /// @notice Returns the current maximum number of vaults that can be connected to the hub + function maxVaultsCount() external view returns (uint256) { + return _getVaultHubStorage().maxVaultsCount; + } + + /// @notice Returns the current maximum size of a single vault relative to Lido TVL in basis points + function maxVaultSizeBP() external view returns (uint256) { + return _getVaultHubStorage().maxVaultSizeBP; + } + /// @param _index index of the vault /// @return vault address function vault(uint256 _index) public view returns (address) { @@ -133,6 +148,26 @@ abstract contract VaultHub is PausableUntilWithRoles { return socket.sharesMinted <= _maxMintableShares(_vault, socket.reserveRatioThresholdBP, socket.shareLimit); } + /// @notice Updates the maximum number of vaults that can be connected to the hub + /// @param _maxVaultsCount new maximum number of vaults + function setMaxVaultsCount(uint256 _maxVaultsCount) external onlyRole(VAULT_REGISTRY_ROLE) { + if (_maxVaultsCount == 0) revert ZeroArgument("_maxVaultsCount"); + if (_maxVaultsCount < vaultsCount()) revert MaxVaultsCountTooLow(_maxVaultsCount, vaultsCount()); + + _getVaultHubStorage().maxVaultsCount = _maxVaultsCount; + emit MaxVaultsCountSet(_maxVaultsCount); + } + + /// @notice Updates the maximum size of a single vault relative to Lido TVL in basis points + /// @param _maxVaultSizeBP new maximum vault size in basis points + function setMaxVaultSizeBP(uint256 _maxVaultSizeBP) external onlyRole(VAULT_REGISTRY_ROLE) { + if (_maxVaultSizeBP == 0) revert ZeroArgument("_maxVaultSizeBP"); + if (_maxVaultSizeBP > TOTAL_BASIS_POINTS) revert MaxVaultSizeBPTooHigh(_maxVaultSizeBP, TOTAL_BASIS_POINTS); + + _getVaultHubStorage().maxVaultSizeBP = _maxVaultSizeBP; + emit MaxVaultSizeBPSet(_maxVaultSizeBP); + } + /// @notice connects a vault to the hub /// @param _vault vault address /// @param _shareLimit maximum number of stETH shares that can be minted by the vault @@ -151,18 +186,19 @@ abstract contract VaultHub is PausableUntilWithRoles { if (_reserveRatioBP == 0) revert ZeroArgument("_reserveRatioBP"); if (_reserveRatioBP > TOTAL_BASIS_POINTS) revert ReserveRatioTooHigh(_vault, _reserveRatioBP, TOTAL_BASIS_POINTS); if (_reserveRatioThresholdBP == 0) revert ZeroArgument("_reserveRatioThresholdBP"); - if (_reserveRatioThresholdBP > _reserveRatioBP) revert ReserveRatioTooHigh(_vault, _reserveRatioThresholdBP, _reserveRatioBP); + if (_reserveRatioThresholdBP > _reserveRatioBP) revert ReserveRatioThresholdTooHigh(_vault, _reserveRatioThresholdBP, _reserveRatioBP); if (_treasuryFeeBP > TOTAL_BASIS_POINTS) revert TreasuryFeeTooHigh(_vault, _treasuryFeeBP, TOTAL_BASIS_POINTS); - if (vaultsCount() == MAX_VAULTS_COUNT) revert TooManyVaults(); - _checkShareLimitUpperBound(_vault, _shareLimit); VaultHubStorage storage $ = _getVaultHubStorage(); + if (vaultsCount() == $.maxVaultsCount) revert TooManyVaults(); + _checkShareLimitUpperBound(_vault, _shareLimit); + if ($.vaultIndex[_vault] != 0) revert AlreadyConnected(_vault, $.vaultIndex[_vault]); bytes32 vaultProxyCodehash = address(_vault).codehash; if (!$.vaultProxyCodehash[vaultProxyCodehash]) revert VaultProxyNotAllowed(_vault); - VaultSocket memory vr = VaultSocket( + VaultSocket memory vsocket = VaultSocket( _vault, 0, // sharesMinted uint96(_shareLimit), @@ -172,11 +208,11 @@ abstract contract VaultHub is PausableUntilWithRoles { false // isDisconnected ); $.vaultIndex[_vault] = $.sockets.length; - $.sockets.push(vr); + $.sockets.push(vsocket); IStakingVault(_vault).lock(CONNECT_DEPOSIT); - emit VaultConnected(_vault, _shareLimit, _reserveRatioBP, _treasuryFeeBP); + emit VaultConnected(_vault, _shareLimit, _reserveRatioBP, _reserveRatioThresholdBP, _treasuryFeeBP); } /// @notice updates share limit for the vault @@ -419,7 +455,7 @@ abstract contract VaultHub is PausableUntilWithRoles { for (uint256 i = 0; i < length; ++i) { VaultSocket memory socket = $.sockets[i + 1]; if (!socket.isDisconnected) { - treasuryFeeShares[i] = _calculateLidoFees( + treasuryFeeShares[i] = _calculateTreasuryFees( socket, _postTotalShares - _sharesToMintAsFees, _postTotalPooledEther, @@ -439,7 +475,8 @@ abstract contract VaultHub is PausableUntilWithRoles { } } - function _calculateLidoFees( + /// @dev impossible to invoke this method under negative rebase + function _calculateTreasuryFees( VaultSocket memory _socket, uint256 _postTotalSharesNoFees, uint256 _postTotalPooledEther, @@ -534,14 +571,15 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @dev check if the share limit is within the upper bound set by MAX_VAULT_SIZE_BP function _checkShareLimitUpperBound(address _vault, uint256 _shareLimit) internal view { - // no vault should be more than 10% (MAX_VAULT_SIZE_BP) of the current Lido TVL - uint256 relativeMaxShareLimitPerVault = (STETH.getTotalShares() * MAX_VAULT_SIZE_BP) / TOTAL_BASIS_POINTS; + // no vault should be more than maxVaultSizeBP of the current Lido TVL + VaultHubStorage storage $ = _getVaultHubStorage(); + uint256 relativeMaxShareLimitPerVault = (STETH.getTotalShares() * $.maxVaultSizeBP) / TOTAL_BASIS_POINTS; if (_shareLimit > relativeMaxShareLimitPerVault) { revert ShareLimitTooHigh(_vault, _shareLimit, relativeMaxShareLimitPerVault); } } - event VaultConnected(address indexed vault, uint256 capShares, uint256 minReserveRatio, uint256 treasuryFeeBP); + event VaultConnected(address indexed vault, uint256 capShares, uint256 minReserveRatio, uint256 reserveRatioThreshold, uint256 treasuryFeeBP); event ShareLimitUpdated(address indexed vault, uint256 newShareLimit); event VaultDisconnected(address indexed vault); event MintedSharesOnVault(address indexed vault, uint256 amountOfShares); @@ -549,6 +587,8 @@ abstract contract VaultHub is PausableUntilWithRoles { event VaultRebalanced(address indexed vault, uint256 sharesBurned); event VaultProxyCodehashAdded(bytes32 indexed codehash); event VaultForceWithdrawalTriggered(address indexed vault, bytes pubkeys, address refundRecepient); + event MaxVaultsCountSet(uint256 maxVaultsCount); + event MaxVaultSizeBPSet(uint256 maxVaultSizeBP); error StETHMintFailed(address vault); error AlreadyBalanced(address vault, uint256 mintedShares, uint256 rebalancingThresholdInShares); @@ -563,6 +603,7 @@ abstract contract VaultHub is PausableUntilWithRoles { error TooManyVaults(); error ShareLimitTooHigh(address vault, uint256 capShares, uint256 maxCapShares); error ReserveRatioTooHigh(address vault, uint256 reserveRatioBP, uint256 maxReserveRatioBP); + error ReserveRatioThresholdTooHigh(address vault, uint256 reserveRatioThresholdBP, uint256 maxReserveRatioBP); error TreasuryFeeTooHigh(address vault, uint256 treasuryFeeBP, uint256 maxTreasuryFeeBP); error ExternalSharesCapReached(address vault, uint256 capShares, uint256 maxMintableExternalShares); error InsufficientValuationToMint(address vault, uint256 valuation); @@ -570,4 +611,6 @@ abstract contract VaultHub is PausableUntilWithRoles { error NoMintedSharesShouldBeLeft(address vault, uint256 sharesMinted); error VaultProxyNotAllowed(address beacon); error InvalidPubkeysLength(); + error MaxVaultSizeBPTooHigh(uint256 maxVaultSizeBP, uint256 totalBasisPoints); + error MaxVaultsCountTooLow(uint256 maxVaultsCount, uint256 currentVaultsCount); } diff --git a/test/0.8.25/vaults/dashboard/contracts/VaultHub__MockForDashboard.sol b/test/0.8.25/vaults/dashboard/contracts/VaultHub__MockForDashboard.sol index 95781fb4a..7d4616f73 100644 --- a/test/0.8.25/vaults/dashboard/contracts/VaultHub__MockForDashboard.sol +++ b/test/0.8.25/vaults/dashboard/contracts/VaultHub__MockForDashboard.sol @@ -37,7 +37,7 @@ contract VaultHub__MockForDashboard { return vaultSockets[vault]; } - function disconnectVault(address vault) external { + function disconnect(address vault) external { emit Mock__VaultDisconnected(vault); } diff --git a/test/0.8.25/vaults/delegation/contracts/VaultHub__MockForDelegation.sol b/test/0.8.25/vaults/delegation/contracts/VaultHub__MockForDelegation.sol index 3a49e852b..47b32356a 100644 --- a/test/0.8.25/vaults/delegation/contracts/VaultHub__MockForDelegation.sol +++ b/test/0.8.25/vaults/delegation/contracts/VaultHub__MockForDelegation.sol @@ -16,7 +16,7 @@ contract VaultHub__MockForDelegation { event Mock__VaultDisconnected(address vault); event Mock__Rebalanced(uint256 amount); - function disconnectVault(address vault) external { + function disconnect(address vault) external { emit Mock__VaultDisconnected(vault); } diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.force-withdrawals.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.force-withdrawals.test.ts index c9c775945..bf22e2ffc 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.force-withdrawals.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.force-withdrawals.test.ts @@ -104,17 +104,6 @@ describe("VaultHub.sol:forceWithdrawals", () => { await vault.connect(vaultHubSigner).report(ether("0.9"), ether("1"), ether("1.1")); // slashing }; - context("isVaultBalanced", () => { - it("returns true if the vault is healthy", async () => { - expect(await vaultHub.isVaultBalanced(vaultAddress)).to.be.true; - }); - - it("returns false if the vault is unhealthy", async () => { - await makeVaultUnhealthy(); - expect(await vaultHub.isVaultBalanced(vaultAddress)).to.be.false; - }); - }); - context("forceValidatorWithdrawal", () => { it("reverts if msg.value is 0", async () => { await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 0n })) diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts new file mode 100644 index 000000000..7ba09dfa1 --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts @@ -0,0 +1,631 @@ +import { expect } from "chai"; +import { ContractTransactionReceipt, keccak256, ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { + DepositContract__MockForVaultHub, + LidoLocator, + StakingVault__MockForVaultHub, + StETH__HarnessForVaultHub, + VaultFactory__MockForVaultHub, + VaultHub, +} from "typechain-types"; + +import { ether, findEvents, randomAddress } from "lib"; + +import { deployLidoLocator } from "test/deploy"; +import { Snapshot, ZERO_HASH } from "test/suite"; + +const ZERO_BYTES32 = "0x" + Buffer.from(ZERO_HASH).toString("hex"); + +const SHARE_LIMIT = ether("1"); +const RESERVE_RATIO_BP = 10_00n; +const RESERVE_RATIO_THRESHOLD_BP = 8_00n; +const TREASURY_FEE_BP = 5_00n; + +const TOTAL_BASIS_POINTS = 100_00n; // 100% +const CONNECT_DEPOSIT = ether("1"); + +describe("VaultHub.sol:hub", () => { + let deployer: HardhatEthersSigner; + let user: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + + let locator: LidoLocator; + let vaultHub: VaultHub; + let depositContract: DepositContract__MockForVaultHub; + let vaultFactory: VaultFactory__MockForVaultHub; + let steth: StETH__HarnessForVaultHub; + + let codehash: string; + + let originalState: string; + + async function createVault(factory: VaultFactory__MockForVaultHub) { + const vaultCreationTx = (await factory + .createVault(await user.getAddress(), await user.getAddress()) + .then((tx) => tx.wait())) as ContractTransactionReceipt; + + const events = findEvents(vaultCreationTx, "VaultCreated"); + const vaultCreatedEvent = events[0]; + + const vault = await ethers.getContractAt("StakingVault__MockForVaultHub", vaultCreatedEvent.args.vault, user); + return vault; + } + + async function connectVault(vault: StakingVault__MockForVaultHub) { + await vaultHub + .connect(user) + .connectVault( + await vault.getAddress(), + SHARE_LIMIT, + RESERVE_RATIO_BP, + RESERVE_RATIO_THRESHOLD_BP, + TREASURY_FEE_BP, + ); + } + + async function createVaultAndConnect(factory: VaultFactory__MockForVaultHub) { + const vault = await createVault(factory); + await connectVault(vault); + return vault; + } + + async function makeVaultBalanced(vault: StakingVault__MockForVaultHub) { + await vault.fund({ value: ether("1") }); + await vaultHub.mintSharesBackedByVault(await vault.getAddress(), user, ether("0.9")); + await vault.report(ether("0.9"), ether("1"), ether("1.1")); // slashing + } + + before(async () => { + [deployer, user, stranger] = await ethers.getSigners(); + + locator = await deployLidoLocator(); + steth = await ethers.deployContract("StETH__HarnessForVaultHub", [user], { value: ether("1000.0") }); + depositContract = await ethers.deployContract("DepositContract__MockForVaultHub"); + + const vaultHubImpl = await ethers.deployContract("Accounting", [locator, steth]); + const proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()]); + + const accounting = await ethers.getContractAt("Accounting", proxy); + await accounting.initialize(deployer); + + vaultHub = await ethers.getContractAt("Accounting", proxy, user); + await accounting.grantRole(await vaultHub.PAUSE_ROLE(), user); + await accounting.grantRole(await vaultHub.RESUME_ROLE(), user); + await accounting.grantRole(await vaultHub.VAULT_MASTER_ROLE(), user); + await accounting.grantRole(await vaultHub.VAULT_REGISTRY_ROLE(), user); + + const stakingVaultImpl = await ethers.deployContract("StakingVault__MockForVaultHub", [ + await vaultHub.getAddress(), + await depositContract.getAddress(), + ]); + + vaultFactory = await ethers.deployContract("VaultFactory__MockForVaultHub", [await stakingVaultImpl.getAddress()]); + const vault = await createVault(vaultFactory); + + codehash = keccak256(await ethers.provider.getCode(await vault.getAddress())); + await vaultHub.connect(user).addVaultProxyCodehash(codehash); + }); + + beforeEach(async () => (originalState = await Snapshot.take())); + + afterEach(async () => await Snapshot.restore(originalState)); + + context("Constants", () => { + it("returns the STETH address", async () => { + expect(await vaultHub.STETH()).to.equal(await steth.getAddress()); + }); + }); + + context("initialState", () => { + it("returns the initial state", async () => { + expect(await vaultHub.vaultsCount()).to.equal(0); + }); + }); + + context("addVaultProxyCodehash", () => { + it("reverts if called by non-VAULT_REGISTRY_ROLE", async () => { + await expect(vaultHub.connect(stranger).addVaultProxyCodehash(ZERO_BYTES32)) + .to.be.revertedWithCustomError(vaultHub, "AccessControlUnauthorizedAccount") + .withArgs(stranger, await vaultHub.VAULT_REGISTRY_ROLE()); + }); + + it("reverts if codehash is zero", async () => { + await expect(vaultHub.connect(user).addVaultProxyCodehash(ZERO_BYTES32)).to.be.revertedWithCustomError( + vaultHub, + "ZeroArgument", + ); + }); + + it("reverts if codehash is already added", async () => { + await expect(vaultHub.connect(user).addVaultProxyCodehash(codehash)) + .to.be.revertedWithCustomError(vaultHub, "AlreadyExists") + .withArgs(codehash); + }); + + it("adds the codehash", async () => { + const newCodehash = codehash.slice(0, -10) + "0000000000"; + await expect(vaultHub.addVaultProxyCodehash(newCodehash)) + .to.emit(vaultHub, "VaultProxyCodehashAdded") + .withArgs(newCodehash); + }); + }); + + context("vaultsCount", () => { + it("returns the number of connected vaults", async () => { + expect(await vaultHub.vaultsCount()).to.equal(0); + + await createVaultAndConnect(vaultFactory); + + expect(await vaultHub.vaultsCount()).to.equal(1); + }); + }); + + context("vault", () => { + it("reverts if index is out of bounds", async () => { + await expect(vaultHub.vault(100n)).to.be.reverted; + }); + + it("returns the vault", async () => { + const vault = await createVaultAndConnect(vaultFactory); + const lastVaultId = (await vaultHub.vaultsCount()) - 1n; + const lastVaultAddress = await vaultHub.vault(lastVaultId); + + expect(lastVaultAddress).to.equal(await vault.getAddress()); + }); + }); + + context("vaultSocket(uint256)", () => { + it("reverts if index is out of bounds", async () => { + await expect(vaultHub["vaultSocket(uint256)"](100n)).to.be.reverted; + }); + + it("returns the vault socket by index", async () => { + const vault = await createVaultAndConnect(vaultFactory); + const lastVaultId = (await vaultHub.vaultsCount()) - 1n; + expect(lastVaultId).to.equal(0n); + + const lastVaultSocket = await vaultHub["vaultSocket(uint256)"](lastVaultId); + + expect(lastVaultSocket.vault).to.equal(await vault.getAddress()); + expect(lastVaultSocket.sharesMinted).to.equal(0n); + expect(lastVaultSocket.shareLimit).to.equal(SHARE_LIMIT); + expect(lastVaultSocket.reserveRatioBP).to.equal(RESERVE_RATIO_BP); + expect(lastVaultSocket.reserveRatioThresholdBP).to.equal(RESERVE_RATIO_THRESHOLD_BP); + expect(lastVaultSocket.treasuryFeeBP).to.equal(TREASURY_FEE_BP); + expect(lastVaultSocket.isDisconnected).to.equal(false); + }); + }); + + context("vaultSocket(address)", () => { + it("returns empty vault socket data if vault was never connected", async () => { + const address = await randomAddress(); + const vaultSocket = await vaultHub["vaultSocket(address)"](address); + + expect(vaultSocket.vault).to.equal(ZeroAddress); + expect(vaultSocket.sharesMinted).to.equal(0n); + expect(vaultSocket.shareLimit).to.equal(0n); + expect(vaultSocket.reserveRatioBP).to.equal(0n); + expect(vaultSocket.reserveRatioThresholdBP).to.equal(0n); + expect(vaultSocket.treasuryFeeBP).to.equal(0n); + expect(vaultSocket.isDisconnected).to.equal(true); + }); + + it("returns the vault socket for a vault that was connected", async () => { + const vault = await createVaultAndConnect(vaultFactory); + const vaultAddress = await vault.getAddress(); + const vaultSocket = await vaultHub["vaultSocket(address)"](vaultAddress); + + expect(vaultSocket.vault).to.equal(vaultAddress); + expect(vaultSocket.sharesMinted).to.equal(0n); + expect(vaultSocket.shareLimit).to.equal(SHARE_LIMIT); + expect(vaultSocket.reserveRatioBP).to.equal(RESERVE_RATIO_BP); + expect(vaultSocket.reserveRatioThresholdBP).to.equal(RESERVE_RATIO_THRESHOLD_BP); + expect(vaultSocket.treasuryFeeBP).to.equal(TREASURY_FEE_BP); + expect(vaultSocket.isDisconnected).to.equal(false); + }); + }); + + context("isVaultBalanced", () => { + let vault: StakingVault__MockForVaultHub; + let vaultAddress: string; + + before(async () => { + vault = await createVaultAndConnect(vaultFactory); + vaultAddress = await vault.getAddress(); + }); + + it("returns true if the vault is healthy", async () => { + expect(await vaultHub.isVaultBalanced(vaultAddress)).to.be.true; + }); + + it("returns false if the vault is unhealthy", async () => { + await makeVaultBalanced(vault); + expect(await vaultHub.isVaultBalanced(vaultAddress)).to.be.false; + }); + }); + + context("maxVaultsCount", () => { + it("returns the maximum number of vaults that can be connected to the hub", async () => { + expect(await vaultHub.maxVaultsCount()).to.equal(500); + }); + }); + + context("maxVaultSizeBP", () => { + it("returns the maximum size of a single vault relative to Lido TVL in basis points", async () => { + expect(await vaultHub.maxVaultSizeBP()).to.equal(10_00); + }); + }); + + context("setMaxVaultsCount", () => { + it("reverts if called by non-VAULT_REGISTRY_ROLE", async () => { + await expect(vaultHub.connect(stranger).setMaxVaultsCount(500)).to.be.revertedWithCustomError( + vaultHub, + "AccessControlUnauthorizedAccount", + ); + }); + + it("reverts if max vaults count is zero", async () => { + await expect(vaultHub.connect(user).setMaxVaultsCount(0)).to.be.revertedWithCustomError(vaultHub, "ZeroArgument"); + }); + + it("reverts if max vaults count is less than the number of connected vaults", async () => { + await createVaultAndConnect(vaultFactory); + await expect(vaultHub.connect(user).setMaxVaultsCount(1)).to.be.revertedWithCustomError( + vaultHub, + "MaxVaultsCountTooLow", + ); + }); + + it("updates the maximum number of vaults that can be connected to the hub", async () => { + await expect(vaultHub.connect(user).setMaxVaultsCount(1)).to.emit(vaultHub, "MaxVaultsCountSet").withArgs(1); + expect(await vaultHub.maxVaultsCount()).to.equal(1); + }); + }); + + context("setMaxVaultSizeBP", () => { + it("reverts if called by non-VAULT_REGISTRY_ROLE", async () => { + await expect(vaultHub.connect(stranger).setMaxVaultSizeBP(10_00)).to.be.revertedWithCustomError( + vaultHub, + "AccessControlUnauthorizedAccount", + ); + }); + + it("reverts if max vault size BP is zero", async () => { + await expect(vaultHub.connect(user).setMaxVaultSizeBP(0)).to.be.revertedWithCustomError(vaultHub, "ZeroArgument"); + }); + + it("reverts if max vault size BP is greater than the total basis points", async () => { + await expect(vaultHub.connect(user).setMaxVaultSizeBP(TOTAL_BASIS_POINTS + 1n)).to.be.revertedWithCustomError( + vaultHub, + "MaxVaultSizeBPTooHigh", + ); + }); + + it("updates the maximum vault size BP", async () => { + await expect(vaultHub.connect(user).setMaxVaultSizeBP(20_00)) + .to.emit(vaultHub, "MaxVaultSizeBPSet") + .withArgs(20_00); + expect(await vaultHub.maxVaultSizeBP()).to.equal(20_00); + }); + }); + + context("connectVault", () => { + let vault: StakingVault__MockForVaultHub; + let vaultAddress: string; + + before(async () => { + vault = await createVault(vaultFactory); + vaultAddress = await vault.getAddress(); + }); + + it("reverts if called by non-VAULT_MASTER_ROLE", async () => { + await expect( + vaultHub + .connect(stranger) + .connectVault(vaultAddress, SHARE_LIMIT, RESERVE_RATIO_BP, RESERVE_RATIO_THRESHOLD_BP, TREASURY_FEE_BP), + ).to.be.revertedWithCustomError(vaultHub, "AccessControlUnauthorizedAccount"); + }); + + it("reverts if vault address is zero", async () => { + await expect( + vaultHub + .connect(user) + .connectVault(ZeroAddress, SHARE_LIMIT, RESERVE_RATIO_BP, RESERVE_RATIO_THRESHOLD_BP, TREASURY_FEE_BP), + ).to.be.revertedWithCustomError(vaultHub, "ZeroArgument"); + }); + + it("reverts if reserve ratio BP is zero", async () => { + await expect( + vaultHub.connect(user).connectVault(vaultAddress, 0n, 0n, RESERVE_RATIO_THRESHOLD_BP, TREASURY_FEE_BP), + ).to.be.revertedWithCustomError(vaultHub, "ZeroArgument"); + }); + + it("reverts if reserve ration is too high", async () => { + const tooHighReserveRatioBP = TOTAL_BASIS_POINTS + 1n; + await expect( + vaultHub + .connect(user) + .connectVault(vaultAddress, SHARE_LIMIT, tooHighReserveRatioBP, RESERVE_RATIO_THRESHOLD_BP, TREASURY_FEE_BP), + ) + .to.be.revertedWithCustomError(vaultHub, "ReserveRatioTooHigh") + .withArgs(vaultAddress, tooHighReserveRatioBP, TOTAL_BASIS_POINTS); + }); + + it("reverts if reserve ratio threshold BP is zero", async () => { + await expect( + vaultHub.connect(user).connectVault(vaultAddress, SHARE_LIMIT, RESERVE_RATIO_BP, 0n, TREASURY_FEE_BP), + ).to.be.revertedWithCustomError(vaultHub, "ZeroArgument"); + }); + + it("reverts if reserve ratio threshold BP is higher than reserve ratio BP", async () => { + await expect( + vaultHub + .connect(user) + .connectVault(vaultAddress, SHARE_LIMIT, RESERVE_RATIO_BP, RESERVE_RATIO_BP + 1n, TREASURY_FEE_BP), + ) + .to.be.revertedWithCustomError(vaultHub, "ReserveRatioThresholdTooHigh") + .withArgs(vaultAddress, RESERVE_RATIO_BP + 1n, RESERVE_RATIO_BP); + }); + + it("reverts if treasury fee is too high", async () => { + const tooHighTreasuryFeeBP = TOTAL_BASIS_POINTS + 1n; + await expect( + vaultHub + .connect(user) + .connectVault(vaultAddress, SHARE_LIMIT, RESERVE_RATIO_BP, RESERVE_RATIO_THRESHOLD_BP, tooHighTreasuryFeeBP), + ).to.be.revertedWithCustomError(vaultHub, "TreasuryFeeTooHigh"); + }); + + it("reverts if max vault size is exceeded", async () => { + await vaultHub.connect(user).setMaxVaultsCount(1); + + await expect( + vaultHub + .connect(user) + .connectVault(vaultAddress, SHARE_LIMIT, RESERVE_RATIO_BP, RESERVE_RATIO_THRESHOLD_BP, TREASURY_FEE_BP), + ).to.be.revertedWithCustomError(vaultHub, "TooManyVaults"); + }); + + it("reverts if vault is already connected", async () => { + const connectedVault = await createVaultAndConnect(vaultFactory); + const connectedVaultAddress = await connectedVault.getAddress(); + + await expect( + vaultHub + .connect(user) + .connectVault( + connectedVaultAddress, + SHARE_LIMIT, + RESERVE_RATIO_BP, + RESERVE_RATIO_THRESHOLD_BP, + TREASURY_FEE_BP, + ), + ).to.be.revertedWithCustomError(vaultHub, "AlreadyConnected"); + }); + + it("reverts if proxy codehash is not added", async () => { + const stakingVault2Impl = await ethers.deployContract("StakingVault__MockForVaultHub", [ + await vaultHub.getAddress(), + await depositContract.getAddress(), + ]); + const vault2Factory = await ethers.deployContract("VaultFactory__MockForVaultHub", [ + await stakingVault2Impl.getAddress(), + ]); + const vault2 = await createVault(vault2Factory); + + await expect( + vaultHub + .connect(user) + .connectVault( + await vault2.getAddress(), + SHARE_LIMIT, + RESERVE_RATIO_BP, + RESERVE_RATIO_THRESHOLD_BP, + TREASURY_FEE_BP, + ), + ).to.be.revertedWithCustomError(vaultHub, "VaultProxyNotAllowed"); + }); + + it("connects the vault", async () => { + const vaultCountBefore = await vaultHub.vaultsCount(); + + const vaultSocketBefore = await vaultHub["vaultSocket(address)"](vaultAddress); + expect(vaultSocketBefore.vault).to.equal(ZeroAddress); + expect(vaultSocketBefore.isDisconnected).to.be.true; + + await expect( + vaultHub + .connect(user) + .connectVault(vaultAddress, SHARE_LIMIT, RESERVE_RATIO_BP, RESERVE_RATIO_THRESHOLD_BP, TREASURY_FEE_BP), + ) + .to.emit(vaultHub, "VaultConnected") + .withArgs(vaultAddress, SHARE_LIMIT, RESERVE_RATIO_BP, RESERVE_RATIO_THRESHOLD_BP, TREASURY_FEE_BP); + + expect(await vaultHub.vaultsCount()).to.equal(vaultCountBefore + 1n); + + const vaultSocketAfter = await vaultHub["vaultSocket(address)"](vaultAddress); + expect(vaultSocketAfter.vault).to.equal(vaultAddress); + expect(vaultSocketAfter.isDisconnected).to.be.false; + + expect(await vault.locked()).to.equal(CONNECT_DEPOSIT); + }); + + it("allows to connect the vault with 0 share limit", async () => { + await expect( + vaultHub + .connect(user) + .connectVault(vaultAddress, 0n, RESERVE_RATIO_BP, RESERVE_RATIO_THRESHOLD_BP, TREASURY_FEE_BP), + ) + .to.emit(vaultHub, "VaultConnected") + .withArgs(vaultAddress, 0n, RESERVE_RATIO_BP, RESERVE_RATIO_THRESHOLD_BP, TREASURY_FEE_BP); + }); + + it("allows to connect the vault with 0 treasury fee", async () => { + await expect( + vaultHub + .connect(user) + .connectVault(vaultAddress, SHARE_LIMIT, RESERVE_RATIO_BP, RESERVE_RATIO_THRESHOLD_BP, 0n), + ) + .to.emit(vaultHub, "VaultConnected") + .withArgs(vaultAddress, SHARE_LIMIT, RESERVE_RATIO_BP, RESERVE_RATIO_THRESHOLD_BP, 0n); + }); + }); + + context("updateShareLimit", () => { + let vault: StakingVault__MockForVaultHub; + let vaultAddress: string; + + before(async () => { + vault = await createVaultAndConnect(vaultFactory); + vaultAddress = await vault.getAddress(); + }); + + it("reverts if called by non-VAULT_MASTER_ROLE", async () => { + await expect( + vaultHub.connect(stranger).updateShareLimit(vaultAddress, SHARE_LIMIT), + ).to.be.revertedWithCustomError(vaultHub, "AccessControlUnauthorizedAccount"); + }); + + it("reverts if vault address is zero", async () => { + await expect(vaultHub.connect(user).updateShareLimit(ZeroAddress, SHARE_LIMIT)).to.be.revertedWithCustomError( + vaultHub, + "ZeroArgument", + ); + }); + + it("reverts if share limit exceeds the maximum vault limit", async () => { + const insaneLimit = ether("1000000000000000000000000"); + const totalShares = await steth.getTotalShares(); + const maxVaultSizeBP = await vaultHub.maxVaultSizeBP(); + const relativeMaxShareLimitPerVault = (totalShares * maxVaultSizeBP) / TOTAL_BASIS_POINTS; + + await expect(vaultHub.connect(user).updateShareLimit(vaultAddress, insaneLimit)) + .to.be.revertedWithCustomError(vaultHub, "ShareLimitTooHigh") + .withArgs(vaultAddress, insaneLimit, relativeMaxShareLimitPerVault); + }); + + it("updates the share limit", async () => { + const newShareLimit = SHARE_LIMIT * 2n; + + await expect(vaultHub.connect(user).updateShareLimit(vaultAddress, newShareLimit)) + .to.emit(vaultHub, "ShareLimitUpdated") + .withArgs(vaultAddress, newShareLimit); + + const vaultSocket = await vaultHub["vaultSocket(address)"](vaultAddress); + expect(vaultSocket.shareLimit).to.equal(newShareLimit); + }); + }); + + context("disconnect", () => { + let vault: StakingVault__MockForVaultHub; + let vaultAddress: string; + + before(async () => { + vault = await createVaultAndConnect(vaultFactory); + vaultAddress = await vault.getAddress(); + }); + + it("reverts if called by non-VAULT_MASTER_ROLE", async () => { + await expect(vaultHub.connect(stranger).disconnect(vaultAddress)).to.be.revertedWithCustomError( + vaultHub, + "AccessControlUnauthorizedAccount", + ); + }); + + it("reverts if vault address is zero", async () => { + await expect(vaultHub.connect(user).disconnect(ZeroAddress)).to.be.revertedWithCustomError( + vaultHub, + "ZeroArgument", + ); + }); + + it("reverts if vault is not connected", async () => { + await expect(vaultHub.connect(user).disconnect(randomAddress())).to.be.revertedWithCustomError( + vaultHub, + "NotConnectedToHub", + ); + }); + + it("reverts if vault has shares minted", async () => { + await vault.fund({ value: ether("1") }); + await vaultHub.connect(user).mintSharesBackedByVault(vaultAddress, user.address, 1n); + + await expect(vaultHub.connect(user).disconnect(vaultAddress)).to.be.revertedWithCustomError( + vaultHub, + "NoMintedSharesShouldBeLeft", + ); + }); + + it("disconnects the vault", async () => { + await expect(vaultHub.connect(user).disconnect(vaultAddress)) + .to.emit(vaultHub, "VaultDisconnected") + .withArgs(vaultAddress); + + const vaultSocket = await vaultHub["vaultSocket(address)"](vaultAddress); + expect(vaultSocket.isDisconnected).to.be.true; + }); + }); + + context("voluntaryDisconnect", () => { + let vault: StakingVault__MockForVaultHub; + let vaultAddress: string; + + before(async () => { + vault = await createVaultAndConnect(vaultFactory); + vaultAddress = await vault.getAddress(); + }); + + it("reverts if minting paused", async () => { + await vaultHub.connect(user).pauseFor(1000n); + + await expect(vaultHub.connect(user).voluntaryDisconnect(vaultAddress)).to.be.revertedWithCustomError( + vaultHub, + "ResumedExpected", + ); + }); + + it("reverts if vault is zero address", async () => { + await expect(vaultHub.connect(user).voluntaryDisconnect(ZeroAddress)).to.be.revertedWithCustomError( + vaultHub, + "ZeroArgument", + ); + }); + + it("reverts if called as non-vault owner", async () => { + await expect(vaultHub.connect(stranger).voluntaryDisconnect(vaultAddress)) + .to.be.revertedWithCustomError(vaultHub, "NotAuthorized") + .withArgs("disconnect", stranger); + }); + + it("reverts if vault is not connected", async () => { + await vaultHub.connect(user).disconnect(vaultAddress); + + await expect(vaultHub.connect(user).voluntaryDisconnect(vaultAddress)) + .to.be.revertedWithCustomError(vaultHub, "NotConnectedToHub") + .withArgs(vaultAddress); + }); + + it("reverts if vault has shares minted", async () => { + await vault.fund({ value: ether("1") }); + await vaultHub.connect(user).mintSharesBackedByVault(vaultAddress, user.address, 1n); + + await expect(vaultHub.connect(user).disconnect(vaultAddress)).to.be.revertedWithCustomError( + vaultHub, + "NoMintedSharesShouldBeLeft", + ); + }); + + it("disconnects the vault", async () => { + await expect(vaultHub.connect(user).disconnect(vaultAddress)) + .to.emit(vaultHub, "VaultDisconnected") + .withArgs(vaultAddress); + + const vaultSocket = await vaultHub["vaultSocket(address)"](vaultAddress); + expect(vaultSocket.isDisconnected).to.be.true; + }); + }); +}); From 3e3c1854ead1002da2504ff0635f6ab511174732 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Thu, 13 Feb 2025 13:51:23 +0000 Subject: [PATCH 64/70] chore: add separate role for hub limits manipulations --- contracts/0.8.25/vaults/VaultHub.sol | 6 ++++-- test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 68b60e1a4..2080c3a4e 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -65,6 +65,8 @@ abstract contract VaultHub is PausableUntilWithRoles { bytes32 public constant VAULT_MASTER_ROLE = keccak256("Vaults.VaultHub.VaultMasterRole"); /// @notice role that allows to add factories and vault implementations to hub bytes32 public constant VAULT_REGISTRY_ROLE = keccak256("Vaults.VaultHub.VaultRegistryRole"); + /// @notice role that allows to update vaults limits + bytes32 public constant VAULT_LIMITS_UPDATER_ROLE = keccak256("Vaults.VaultHub.VaultLimitsUpdaterRole"); /// @dev basis points base uint256 internal constant TOTAL_BASIS_POINTS = 100_00; /// @notice amount of ETH that is locked on the vault on connect and can be withdrawn on disconnect only @@ -150,7 +152,7 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @notice Updates the maximum number of vaults that can be connected to the hub /// @param _maxVaultsCount new maximum number of vaults - function setMaxVaultsCount(uint256 _maxVaultsCount) external onlyRole(VAULT_REGISTRY_ROLE) { + function setMaxVaultsCount(uint256 _maxVaultsCount) external onlyRole(VAULT_LIMITS_UPDATER_ROLE) { if (_maxVaultsCount == 0) revert ZeroArgument("_maxVaultsCount"); if (_maxVaultsCount < vaultsCount()) revert MaxVaultsCountTooLow(_maxVaultsCount, vaultsCount()); @@ -160,7 +162,7 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @notice Updates the maximum size of a single vault relative to Lido TVL in basis points /// @param _maxVaultSizeBP new maximum vault size in basis points - function setMaxVaultSizeBP(uint256 _maxVaultSizeBP) external onlyRole(VAULT_REGISTRY_ROLE) { + function setMaxVaultSizeBP(uint256 _maxVaultSizeBP) external onlyRole(VAULT_LIMITS_UPDATER_ROLE) { if (_maxVaultSizeBP == 0) revert ZeroArgument("_maxVaultSizeBP"); if (_maxVaultSizeBP > TOTAL_BASIS_POINTS) revert MaxVaultSizeBPTooHigh(_maxVaultSizeBP, TOTAL_BASIS_POINTS); diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts index 7ba09dfa1..3b4a25098 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts @@ -95,6 +95,7 @@ describe("VaultHub.sol:hub", () => { vaultHub = await ethers.getContractAt("Accounting", proxy, user); await accounting.grantRole(await vaultHub.PAUSE_ROLE(), user); await accounting.grantRole(await vaultHub.RESUME_ROLE(), user); + await accounting.grantRole(await vaultHub.VAULT_LIMITS_UPDATER_ROLE(), user); await accounting.grantRole(await vaultHub.VAULT_MASTER_ROLE(), user); await accounting.grantRole(await vaultHub.VAULT_REGISTRY_ROLE(), user); @@ -261,7 +262,7 @@ describe("VaultHub.sol:hub", () => { }); context("setMaxVaultsCount", () => { - it("reverts if called by non-VAULT_REGISTRY_ROLE", async () => { + it("reverts if called by non-VAULT_LIMITS_UPDATER_ROLE", async () => { await expect(vaultHub.connect(stranger).setMaxVaultsCount(500)).to.be.revertedWithCustomError( vaultHub, "AccessControlUnauthorizedAccount", @@ -287,7 +288,7 @@ describe("VaultHub.sol:hub", () => { }); context("setMaxVaultSizeBP", () => { - it("reverts if called by non-VAULT_REGISTRY_ROLE", async () => { + it("reverts if called by non-VAULT_LIMITS_UPDATER_ROLE", async () => { await expect(vaultHub.connect(stranger).setMaxVaultSizeBP(10_00)).to.be.revertedWithCustomError( vaultHub, "AccessControlUnauthorizedAccount", From c4835446386f41dc67dcc62d776ca9ac139611cb Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Thu, 13 Feb 2025 18:11:41 +0000 Subject: [PATCH 65/70] chore: comments and naming fixes --- contracts/0.8.25/vaults/Dashboard.sol | 29 ++++---- contracts/0.8.25/vaults/StakingVault.sol | 2 +- contracts/0.8.25/vaults/VaultHub.sol | 72 +++++++++---------- .../vaults/vaulthub/vaulthub.hub.test.ts | 67 +++++++++-------- 4 files changed, 90 insertions(+), 80 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 02bff4416..d8f3316fa 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -441,37 +441,40 @@ contract Dashboard is Permissions { } /** - * @notice Pauses beacon chain deposits on the StakingVault. + * @notice Pauses beacon chain deposits on the StakingVault */ function pauseBeaconChainDeposits() external { _pauseBeaconChainDeposits(); } /** - * @notice Resumes beacon chain deposits on the StakingVault. + * @notice Resumes beacon chain deposits on the StakingVault */ function resumeBeaconChainDeposits() external { _resumeBeaconChainDeposits(); } /** - * @notice Signals to node operators that specific validators should exit from the beacon chain. - * It does not directly trigger exits - node operators must monitor for these events and handle the exits manually. - * @param _pubkeys Concatenated validator public keys, each 48 bytes long. - * @dev Emits `ValidatorExitRequested` event for each validator public key through the StakingVault. - * This is a voluntary exit request - node operators can choose whether to act on it. + * @notice Signals to node operators that specific validators should exit from the beacon chain. It DOES NOT + * directly trigger the exit - node operators must monitor for request events and handle the exits manually + * @param _pubkeys Concatenated validator public keys (48 bytes each) + * @dev Emits `ValidatorExitRequested` event for each validator public key through the `StakingVault` + * This is a voluntary exit request - node operators can choose whether to act on it or not */ function requestValidatorExit(bytes calldata _pubkeys) external { _requestValidatorExit(_pubkeys); } /** - * @notice Triggers validator withdrawals via EIP-7002 triggerable exit mechanism. This allows withdrawing either the full - * validator balance or a partial amount from each validator specified. - * @param _pubkeys The concatenated public keys of the validators to request withdrawal for. Each key must be 48 bytes. - * @param _amounts The withdrawal amounts in wei for each validator. Must match the length of _pubkeys. - * @param _refundRecipient The address that will receive any fee refunds. - * @dev Requires payment of withdrawal fee which is calculated based on the number of validators and must be paid in msg.value. + * @notice Initiates a withdrawal from validator(s) on the beacon chain using EIP-7002 triggerable withdrawals + * Both partial withdrawals (disabled for unbalanced `StakingVault`) and full validator exits are supported + * @param _pubkeys Concatenated validator public keys (48 bytes each) + * @param _amounts Withdrawal amounts in wei for each validator key and must match _pubkeys length + * Set amount to 0 for a full validator exit + * For partial withdrawals, amounts will be capped to maintain the minimum stake of 32 ETH on the validator + * @param _refundRecipient Address to receive any fee refunds, if zero, refunds go to msg.sender + * @dev A withdrawal fee (calculated on block-by-block basis) must be paid via msg.value + * Use `StakingVault.calculateValidatorWithdrawalFee()` to determine the required fee */ function triggerValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient) external payable { _triggerValidatorWithdrawal(_pubkeys, _amounts, _refundRecipient); diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 82db28b7f..0dc5121c5 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -45,7 +45,7 @@ import {IStakingVault} from "./interfaces/IStakingVault.sol"; * - `lock()` * - `report()` * - `rebalance()` - * - `triggerValidatorWithdrawal()` (only full validator exit when the vault is unbalanced) + * - `triggerValidatorWithdrawal()` (partial withdrawals are disabled for unbalanced `StakingVault`) * - Anyone: * - Can send ETH directly to the vault (treated as rewards) * diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 2080c3a4e..03c6f91d3 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -22,17 +22,18 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @custom:storage-location erc7201:VaultHub struct VaultHubStorage { /// @notice vault sockets with vaults connected to the hub - /// @dev first socket is always zero. stone in the elevator + /// @dev first socket is always zero. stone in the elevator VaultSocket[] sockets; /// @notice mapping from vault address to its socket - /// @dev if vault is not connected to the hub, its index is zero + /// @dev if vault is not connected to the hub, its index is zero mapping(address => uint256) vaultIndex; /// @notice allowed beacon addresses mapping(bytes32 => bool) vaultProxyCodehash; - /// @notice maximum number of vaults that can be connected to the hub - uint256 maxVaultsCount; - /// @notice maximum size of the single vault relative to Lido TVL in basis points - uint256 maxVaultSizeBP; + /// @notice limit for the number of vaults that can ever be connected to the vault hub + uint256 connectedVaultsLimit; + /// @notice limit for a single vault share limit relative to Lido TVL in basis points + /// @dev used to enforce an upper bound on individual vault share limits relative to total protocol TVL + uint256 relativeShareLimitBP; } struct VaultSocket { @@ -89,8 +90,8 @@ abstract contract VaultHub is PausableUntilWithRoles { __AccessControlEnumerable_init(); VaultHubStorage storage $ = _getVaultHubStorage(); - $.maxVaultsCount = 500; - $.maxVaultSizeBP = 10_00; // 10% + $.connectedVaultsLimit = 500; + $.relativeShareLimitBP = 10_00; // 10% // the stone in the elevator $.sockets.push(VaultSocket(address(0), 0, 0, 0, 0, 0, true)); @@ -114,14 +115,14 @@ abstract contract VaultHub is PausableUntilWithRoles { return _getVaultHubStorage().sockets.length - 1; } - /// @notice Returns the current maximum number of vaults that can be connected to the hub - function maxVaultsCount() external view returns (uint256) { - return _getVaultHubStorage().maxVaultsCount; + /// @notice Returns the maximum number of vaults that can be connected to the hub + function connectedVaultsLimit() external view returns (uint256) { + return _getVaultHubStorage().connectedVaultsLimit; } - /// @notice Returns the current maximum size of a single vault relative to Lido TVL in basis points - function maxVaultSizeBP() external view returns (uint256) { - return _getVaultHubStorage().maxVaultSizeBP; + /// @notice Returns the maximum allowedshare limit for a single vault relative to Lido TVL in basis points + function relativeShareLimitBP() external view returns (uint256) { + return _getVaultHubStorage().relativeShareLimitBP; } /// @param _index index of the vault @@ -150,24 +151,24 @@ abstract contract VaultHub is PausableUntilWithRoles { return socket.sharesMinted <= _maxMintableShares(_vault, socket.reserveRatioThresholdBP, socket.shareLimit); } - /// @notice Updates the maximum number of vaults that can be connected to the hub - /// @param _maxVaultsCount new maximum number of vaults - function setMaxVaultsCount(uint256 _maxVaultsCount) external onlyRole(VAULT_LIMITS_UPDATER_ROLE) { - if (_maxVaultsCount == 0) revert ZeroArgument("_maxVaultsCount"); - if (_maxVaultsCount < vaultsCount()) revert MaxVaultsCountTooLow(_maxVaultsCount, vaultsCount()); + /// @notice Updates the limit for the number of vaults that can ever be connected to the vault hub + /// @param _connectedVaultsLimit new vaults limit + function setConnectedVaultsLimit(uint256 _connectedVaultsLimit) external onlyRole(VAULT_LIMITS_UPDATER_ROLE) { + if (_connectedVaultsLimit == 0) revert ZeroArgument("_connectedVaultsLimit"); + if (_connectedVaultsLimit < vaultsCount()) revert ConnectedVaultsLimitTooLow(_connectedVaultsLimit, vaultsCount()); - _getVaultHubStorage().maxVaultsCount = _maxVaultsCount; - emit MaxVaultsCountSet(_maxVaultsCount); + _getVaultHubStorage().connectedVaultsLimit = _connectedVaultsLimit; + emit ConnectedVaultsLimitSet(_connectedVaultsLimit); } - /// @notice Updates the maximum size of a single vault relative to Lido TVL in basis points - /// @param _maxVaultSizeBP new maximum vault size in basis points - function setMaxVaultSizeBP(uint256 _maxVaultSizeBP) external onlyRole(VAULT_LIMITS_UPDATER_ROLE) { - if (_maxVaultSizeBP == 0) revert ZeroArgument("_maxVaultSizeBP"); - if (_maxVaultSizeBP > TOTAL_BASIS_POINTS) revert MaxVaultSizeBPTooHigh(_maxVaultSizeBP, TOTAL_BASIS_POINTS); + /// @notice Updates the limit for a single vault share limit relative to Lido TVL in basis points + /// @param _relativeShareLimitBP new relative share limit in basis points + function setRelativeShareLimitBP(uint256 _relativeShareLimitBP) external onlyRole(VAULT_LIMITS_UPDATER_ROLE) { + if (_relativeShareLimitBP == 0) revert ZeroArgument("_relativeShareLimitBP"); + if (_relativeShareLimitBP > TOTAL_BASIS_POINTS) revert RelativeShareLimitBPTooHigh(_relativeShareLimitBP, TOTAL_BASIS_POINTS); - _getVaultHubStorage().maxVaultSizeBP = _maxVaultSizeBP; - emit MaxVaultSizeBPSet(_maxVaultSizeBP); + _getVaultHubStorage().relativeShareLimitBP = _relativeShareLimitBP; + emit RelativeShareLimitBPSet(_relativeShareLimitBP); } /// @notice connects a vault to the hub @@ -192,7 +193,7 @@ abstract contract VaultHub is PausableUntilWithRoles { if (_treasuryFeeBP > TOTAL_BASIS_POINTS) revert TreasuryFeeTooHigh(_vault, _treasuryFeeBP, TOTAL_BASIS_POINTS); VaultHubStorage storage $ = _getVaultHubStorage(); - if (vaultsCount() == $.maxVaultsCount) revert TooManyVaults(); + if (vaultsCount() == $.connectedVaultsLimit) revert TooManyVaults(); _checkShareLimitUpperBound(_vault, _shareLimit); if ($.vaultIndex[_vault] != 0) revert AlreadyConnected(_vault, $.vaultIndex[_vault]); @@ -571,11 +572,10 @@ abstract contract VaultHub is PausableUntilWithRoles { } } - /// @dev check if the share limit is within the upper bound set by MAX_VAULT_SIZE_BP + /// @dev check if the share limit is within the upper bound set by relativeShareLimitBP function _checkShareLimitUpperBound(address _vault, uint256 _shareLimit) internal view { - // no vault should be more than maxVaultSizeBP of the current Lido TVL VaultHubStorage storage $ = _getVaultHubStorage(); - uint256 relativeMaxShareLimitPerVault = (STETH.getTotalShares() * $.maxVaultSizeBP) / TOTAL_BASIS_POINTS; + uint256 relativeMaxShareLimitPerVault = (STETH.getTotalShares() * $.relativeShareLimitBP) / TOTAL_BASIS_POINTS; if (_shareLimit > relativeMaxShareLimitPerVault) { revert ShareLimitTooHigh(_vault, _shareLimit, relativeMaxShareLimitPerVault); } @@ -589,8 +589,8 @@ abstract contract VaultHub is PausableUntilWithRoles { event VaultRebalanced(address indexed vault, uint256 sharesBurned); event VaultProxyCodehashAdded(bytes32 indexed codehash); event VaultForceWithdrawalTriggered(address indexed vault, bytes pubkeys, address refundRecepient); - event MaxVaultsCountSet(uint256 maxVaultsCount); - event MaxVaultSizeBPSet(uint256 maxVaultSizeBP); + event ConnectedVaultsLimitSet(uint256 connectedVaultsLimit); + event RelativeShareLimitBPSet(uint256 relativeShareLimitBP); error StETHMintFailed(address vault); error AlreadyBalanced(address vault, uint256 mintedShares, uint256 rebalancingThresholdInShares); @@ -613,6 +613,6 @@ abstract contract VaultHub is PausableUntilWithRoles { error NoMintedSharesShouldBeLeft(address vault, uint256 sharesMinted); error VaultProxyNotAllowed(address beacon); error InvalidPubkeysLength(); - error MaxVaultSizeBPTooHigh(uint256 maxVaultSizeBP, uint256 totalBasisPoints); - error MaxVaultsCountTooLow(uint256 maxVaultsCount, uint256 currentVaultsCount); + error ConnectedVaultsLimitTooLow(uint256 connectedVaultsLimit, uint256 currentVaultsCount); + error RelativeShareLimitBPTooHigh(uint256 relativeShareLimitBP, uint256 totalBasisPoints); } diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts index 3b4a25098..c1e8cfb9a 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts @@ -249,68 +249,75 @@ describe("VaultHub.sol:hub", () => { }); }); - context("maxVaultsCount", () => { + context("connectedVaultsLimit", () => { it("returns the maximum number of vaults that can be connected to the hub", async () => { - expect(await vaultHub.maxVaultsCount()).to.equal(500); + expect(await vaultHub.connectedVaultsLimit()).to.equal(500); }); }); - context("maxVaultSizeBP", () => { + context("relativeShareLimitBP", () => { it("returns the maximum size of a single vault relative to Lido TVL in basis points", async () => { - expect(await vaultHub.maxVaultSizeBP()).to.equal(10_00); + expect(await vaultHub.relativeShareLimitBP()).to.equal(10_00); }); }); - context("setMaxVaultsCount", () => { + context("setConnectedVaultsLimit", () => { it("reverts if called by non-VAULT_LIMITS_UPDATER_ROLE", async () => { - await expect(vaultHub.connect(stranger).setMaxVaultsCount(500)).to.be.revertedWithCustomError( + await expect(vaultHub.connect(stranger).setConnectedVaultsLimit(500)).to.be.revertedWithCustomError( vaultHub, "AccessControlUnauthorizedAccount", ); }); - it("reverts if max vaults count is zero", async () => { - await expect(vaultHub.connect(user).setMaxVaultsCount(0)).to.be.revertedWithCustomError(vaultHub, "ZeroArgument"); + it("reverts if new vaults limit is zero", async () => { + await expect(vaultHub.connect(user).setConnectedVaultsLimit(0)).to.be.revertedWithCustomError( + vaultHub, + "ZeroArgument", + ); }); - it("reverts if max vaults count is less than the number of connected vaults", async () => { + it("reverts if vaults limit is less than the number of already connected vaults", async () => { await createVaultAndConnect(vaultFactory); - await expect(vaultHub.connect(user).setMaxVaultsCount(1)).to.be.revertedWithCustomError( + await expect(vaultHub.connect(user).setConnectedVaultsLimit(1)).to.be.revertedWithCustomError( vaultHub, - "MaxVaultsCountTooLow", + "ConnectedVaultsLimitTooLow", ); }); it("updates the maximum number of vaults that can be connected to the hub", async () => { - await expect(vaultHub.connect(user).setMaxVaultsCount(1)).to.emit(vaultHub, "MaxVaultsCountSet").withArgs(1); - expect(await vaultHub.maxVaultsCount()).to.equal(1); + await expect(vaultHub.connect(user).setConnectedVaultsLimit(1)) + .to.emit(vaultHub, "ConnectedVaultsLimitSet") + .withArgs(1); + expect(await vaultHub.connectedVaultsLimit()).to.equal(1); }); }); - context("setMaxVaultSizeBP", () => { + context("setRelativeShareLimitBP", () => { it("reverts if called by non-VAULT_LIMITS_UPDATER_ROLE", async () => { - await expect(vaultHub.connect(stranger).setMaxVaultSizeBP(10_00)).to.be.revertedWithCustomError( + await expect(vaultHub.connect(stranger).setRelativeShareLimitBP(10_00)).to.be.revertedWithCustomError( vaultHub, "AccessControlUnauthorizedAccount", ); }); - it("reverts if max vault size BP is zero", async () => { - await expect(vaultHub.connect(user).setMaxVaultSizeBP(0)).to.be.revertedWithCustomError(vaultHub, "ZeroArgument"); - }); - - it("reverts if max vault size BP is greater than the total basis points", async () => { - await expect(vaultHub.connect(user).setMaxVaultSizeBP(TOTAL_BASIS_POINTS + 1n)).to.be.revertedWithCustomError( + it("reverts if new relative share limit is zero", async () => { + await expect(vaultHub.connect(user).setRelativeShareLimitBP(0)).to.be.revertedWithCustomError( vaultHub, - "MaxVaultSizeBPTooHigh", + "ZeroArgument", ); }); - it("updates the maximum vault size BP", async () => { - await expect(vaultHub.connect(user).setMaxVaultSizeBP(20_00)) - .to.emit(vaultHub, "MaxVaultSizeBPSet") + it("reverts if new relative share limit is greater than the total basis points", async () => { + await expect( + vaultHub.connect(user).setRelativeShareLimitBP(TOTAL_BASIS_POINTS + 1n), + ).to.be.revertedWithCustomError(vaultHub, "RelativeShareLimitBPTooHigh"); + }); + + it("updates the relative share limit", async () => { + await expect(vaultHub.connect(user).setRelativeShareLimitBP(20_00)) + .to.emit(vaultHub, "RelativeShareLimitBPSet") .withArgs(20_00); - expect(await vaultHub.maxVaultSizeBP()).to.equal(20_00); + expect(await vaultHub.relativeShareLimitBP()).to.equal(20_00); }); }); @@ -382,7 +389,7 @@ describe("VaultHub.sol:hub", () => { }); it("reverts if max vault size is exceeded", async () => { - await vaultHub.connect(user).setMaxVaultsCount(1); + await vaultHub.connect(user).setConnectedVaultsLimit(1); await expect( vaultHub @@ -501,12 +508,12 @@ describe("VaultHub.sol:hub", () => { it("reverts if share limit exceeds the maximum vault limit", async () => { const insaneLimit = ether("1000000000000000000000000"); const totalShares = await steth.getTotalShares(); - const maxVaultSizeBP = await vaultHub.maxVaultSizeBP(); - const relativeMaxShareLimitPerVault = (totalShares * maxVaultSizeBP) / TOTAL_BASIS_POINTS; + const relativeShareLimitBP = await vaultHub.relativeShareLimitBP(); + const relativeShareLimitPerVault = (totalShares * relativeShareLimitBP) / TOTAL_BASIS_POINTS; await expect(vaultHub.connect(user).updateShareLimit(vaultAddress, insaneLimit)) .to.be.revertedWithCustomError(vaultHub, "ShareLimitTooHigh") - .withArgs(vaultAddress, insaneLimit, relativeMaxShareLimitPerVault); + .withArgs(vaultAddress, insaneLimit, relativeShareLimitPerVault); }); it("updates the share limit", async () => { From 8082977874c218d77c8d7d78a22e40a05418ed91 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Fri, 14 Feb 2025 12:56:46 +0000 Subject: [PATCH 66/70] chore: use msg.sender as refund address in case of zero address --- contracts/0.8.25/vaults/StakingVault.sol | 7 ++- .../vaults/staking-vault/stakingVault.test.ts | 55 ++++++++++++------- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 0dc5121c5..4c4faef4e 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -460,7 +460,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @notice Triggers validator withdrawals from the beacon chain using EIP-7002 triggerable exit * @param _pubkeys Concatenated validators public keys, each 48 bytes long * @param _amounts Amounts of ether to exit, must match the length of _pubkeys - * @param _refundRecipient Address to receive the fee refund + * @param _refundRecipient Address to receive the fee refund, if zero, refunds go to msg.sender * @dev The caller must provide sufficient fee via msg.value to cover the withdrawal request costs */ function triggerValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient) external payable { @@ -469,12 +469,15 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { if (value == 0) revert ZeroArgument("msg.value"); if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); if (_amounts.length == 0) revert ZeroArgument("_amounts"); - if (_refundRecipient == address(0)) revert ZeroArgument("_refundRecipient"); if (_pubkeys.length % PUBLIC_KEY_LENGTH != 0) revert InvalidPubkeysLength(); uint256 keysCount = _pubkeys.length / PUBLIC_KEY_LENGTH; if (keysCount != _amounts.length) revert InvalidAmountsLength(); + if (_refundRecipient == address(0)) { + _refundRecipient = msg.sender; + } + ERC7201Storage storage $ = _getStorage(); bool isBalanced = valuation() >= $.locked; bool isAuthorized = ( diff --git a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts index 29f295beb..af423042a 100644 --- a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts +++ b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts @@ -723,16 +723,6 @@ describe("StakingVault.sol", () => { .withArgs("_amounts"); }); - it("reverts if the refund recipient is the zero address", async () => { - await expect( - stakingVault - .connect(vaultOwner) - .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [ether("1")], ZeroAddress, { value: 1n }), - ) - .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") - .withArgs("_refundRecipient"); - }); - it("reverts if called by a non-owner or the node operator", async () => { await expect( stakingVault @@ -791,6 +781,17 @@ describe("StakingVault.sol", () => { .withArgs(ethRejectorAddress, overpaid); }); + it("reverts if partial withdrawals is called on an unhealthy vault", async () => { + await stakingVault.fund({ value: ether("1") }); + await stakingVault.connect(vaultHubSigner).report(ether("0.9"), ether("1"), ether("1.1")); // slashing + + await expect( + stakingVault + .connect(vaultOwner) + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [ether("1")], vaultOwnerAddress, { value: 1n }), + ).to.be.revertedWithCustomError(stakingVault, "PartialWithdrawalNotAllowed"); + }); + it("requests a validator withdrawal when called by the owner", async () => { const value = baseFee; @@ -840,6 +841,29 @@ describe("StakingVault.sol", () => { .withArgs(vaultOwner, SAMPLE_PUBKEY, [amount], vaultOwnerAddress, 0); }); + it("requests a partial validator withdrawal and refunds the excess fee to the msg.sender if the refund recipient is the zero address", async () => { + const amount = ether("0.1"); + const overpaid = 100n; + const ownerBalanceBefore = await ethers.provider.getBalance(vaultOwner); + + const tx = await stakingVault + .connect(vaultOwner) + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [amount], ZeroAddress, { value: baseFee + overpaid }); + + await expect(tx) + .to.emit(withdrawalRequest, "eip7002MockRequestAdded") + .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, amount), baseFee) + .to.emit(stakingVault, "ValidatorWithdrawalRequested") + .withArgs(vaultOwner, SAMPLE_PUBKEY, [amount], vaultOwnerAddress, overpaid); + + const txReceipt = (await tx.wait()) as ContractTransactionReceipt; + const gasFee = txReceipt.gasPrice * txReceipt.cumulativeGasUsed; + + const ownerBalanceAfter = await ethers.provider.getBalance(vaultOwner); + + expect(ownerBalanceAfter).to.equal(ownerBalanceBefore - baseFee - gasFee); // overpaid is refunded back + }); + it("requests a multiple validator withdrawals", async () => { const numberOfKeys = 2; const pubkeys = getPubkeys(numberOfKeys); @@ -896,17 +920,6 @@ describe("StakingVault.sol", () => { .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, 0n), baseFee); }); - - it("reverts if partial withdrawals is called on an unhealthy vault", async () => { - await stakingVault.fund({ value: ether("1") }); - await stakingVault.connect(vaultHubSigner).report(ether("0.9"), ether("1"), ether("1.1")); // slashing - - await expect( - stakingVault - .connect(vaultOwner) - .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [ether("1")], vaultOwnerAddress, { value: 1n }), - ).to.be.revertedWithCustomError(stakingVault, "PartialWithdrawalNotAllowed"); - }); }); context("computeDepositDataRoot", () => { From 986cfdef3cd286a7e02f5895ab502648578338d1 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Fri, 14 Feb 2025 13:09:07 +0000 Subject: [PATCH 67/70] chore: migrate foundry to stable --- foundry.toml | 2 +- foundry/lib/forge-std | 2 +- test/common/memUtils.t.sol | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/foundry.toml b/foundry.toml index 3ddeddae8..3798d585b 100644 --- a/foundry.toml +++ b/foundry.toml @@ -15,7 +15,7 @@ test = 'test' cache = true # The cache directory if enabled -cache_path = 'foundry/cache' +cache_path = 'foundry/cache' # Only run tests in contracts matching the specified glob pattern match_path = '**/test/**/*.t.sol' diff --git a/foundry/lib/forge-std b/foundry/lib/forge-std index ffa2ee0d9..bf909b22f 160000 --- a/foundry/lib/forge-std +++ b/foundry/lib/forge-std @@ -1 +1 @@ -Subproject commit ffa2ee0d921b4163b7abd0f1122df93ead205805 +Subproject commit bf909b22fa55e244796dfa920c9639fdffa1c545 diff --git a/test/common/memUtils.t.sol b/test/common/memUtils.t.sol index 1e10db057..7fd2c916e 100644 --- a/test/common/memUtils.t.sol +++ b/test/common/memUtils.t.sol @@ -483,6 +483,7 @@ contract MemUtilsTest is Test, MemUtilsTestHelper { assertEq(dst, abi.encodePacked(bytes32(0x2211111111111111111111111111111111111111111111111111111111111111))); } + /// forge-config: default.allow_internal_expect_revert = true function test_copyBytes_RevertsWhenSrcArrayIsOutOfBounds() external { bytes memory src = abi.encodePacked( bytes32(0x1111111111111111111111111111111111111111111111111111111111111111) From 83181a3758e0b8e1e54c122c7e2b007790e79053 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Fri, 14 Feb 2025 13:23:15 +0000 Subject: [PATCH 68/70] docs: cleanup --- docs/vaults/validator-exit-flows.md | 130 ---------------------------- 1 file changed, 130 deletions(-) delete mode 100644 docs/vaults/validator-exit-flows.md diff --git a/docs/vaults/validator-exit-flows.md b/docs/vaults/validator-exit-flows.md deleted file mode 100644 index f512b5b87..000000000 --- a/docs/vaults/validator-exit-flows.md +++ /dev/null @@ -1,130 +0,0 @@ -# stVault Validator Exit Flows - -## Abstract - -stVaults enable three validator exit mechanisms: voluntary exits for planned operations, request-based exits using EIP-7002, and force exits for vault rebalancing. Each mechanism serves a specific purpose in maintaining vault operations and protocol health. The stVault contract plays a crucial role in the broader protocol by ensuring efficient validator management and maintaining the health of the vaults. - -## Terminology - -- **stVault (Vault)**: The smart contract managing the vault operations. -- **Vault Owner (VO)**: The owner of the stVault contract. -- **Node Operators (NO)**: Entities responsible for managing the validators. -- **BeaconChain (BC)**: The Ethereum 2.0 beacon chain where validators operate. -- **TriggerableWithdrawals (TW)**: Mechanism for initiating withdrawals using [EIP-7002](https://eips.ethereum.org/EIPS/eip-7002). -- **Vault Hub (Hub)**: Central component for managing vault operations. -- **Lido V2 (Lido)**: Core protocol responsible for maintaining stability of the stETH token. - -### Exit Selection Guide - -| Scenario | Recommended Exit | Rationale | -| ------------------- | ---------------- | -------------------- | -| Planned Maintenance | Voluntary | Flexible timing | -| Urgent Withdrawal | Request-Based | Guaranteed execution | -| Vault Imbalance | Force | Restore health | - -## Voluntary Exit Flow - -The vault owner signals to a node operator to initiate a validator exit, which is then processed at a flexible timing. The stVault contract will only emit an exit signal that the node operators will then process at their discretion. - -> [!NOTE] -> -> - The stVault contract WILL NOT process the exit itself. -> - Can be triggered ONLY by the owner of the stVault contract. - -```mermaid -sequenceDiagram - participant Owner - participant stVault - participant NodeOperators - participant BeaconChain - - Owner->>stVault: Initiates voluntary exit - Note over stVault: Validates pubkeys - stVault->>NodeOperators: Exit signal - Note over NodeOperators: Flexible timing - NodeOperators->>BeaconChain: Process exit - BeaconChain-->>stVault: Returns ETH -``` - -**Purpose:** - -- Planned validator rotations -- Routine maintenance -- Non-urgent exits -- Regular rebalancing - -## Request-Based Exit Flow - -Both the vault owner and the node operators can trigger validator withdrawals using EIP-7002 Triggerable Withdrawals at any time. This process initiates the withdrawal of ETH from the validators controlled by the stVault contract on the beacon chain. Both full and partial withdrawals are supported. Guaranteed execution is ensured through EIP-7002, along with an immediate fee refund. - -> [!NOTE] -> -> - Partial withdrawals are ONLY supported when the vault is in a healthy state. - -```mermaid -sequenceDiagram - participant VO/NO - participant stVault - participant TriggerableWithdrawals - participant BeaconChain - - VO/NO->>stVault: Request + withdrawal fee - stVault->>TriggerableWithdrawals: Trigger withdrawal + withdrawal fee - stVault-->>VO/NO: Returns excess fee - Note over TriggerableWithdrawals: Queued for processing - TriggerableWithdrawals-->>BeaconChain: Process withdrawal - BeaconChain-->>TriggerableWithdrawals: Returns ETH - TriggerableWithdrawals-->>stVault: Returns ETH -``` - -**Purpose:** - -- Guaranteed withdrawals -- Time-sensitive operations -- Partial withdrawals -- Available to owner and operator - -## Force Exit Flow - -A permissionless mechanism used when a vault becomes imbalanced (meaning the vault valuation is below the locked amount). This flow helps restore the vault's health state and get the value for the vault rebalancing. - -> [!NOTE] -> -> - ANYONE can trigger this flow -> - ONLY full withdrawals are supported -> - ONLY available when the vault valuation is below the locked amount - -```mermaid -sequenceDiagram - participant Lido - participant Anyone - participant Hub - participant Vault - participant TriggerableWithdrawals - participant BeaconChain - - Anyone->>Hub: Force exit request + withdrawal fee - Note over Hub: Validates vault unhealthiness - Hub->>Vault: Trigger withdrawal + withdrawal fee - Note over Vault: Validates unhealthiness - Vault->>TriggerableWithdrawals: Trigger withdrawal + withdrawal fee - Vault-->>Anyone: Returns excess fee - Note over TriggerableWithdrawals: Queued for processing - TriggerableWithdrawals->>BeaconChain: Process withdrawal - BeaconChain-->>Vault: Returns ETH - Anyone->>Hub: Rebalance request - Hub->>Vault: Rebalance request - Vault->>Lido: Repay debt - Vault->>Hub: Rebalance processed - Hub->>Hub: Restore vault health -``` - -**Purpose:** - -- Restore vault health state -- Maintain protocol safety - -## External References - -- [stVaults Design](https://hackmd.io/@lido/stVaults-design) -- [EIP-7002: Triggerable Withdrawals](https://eips.ethereum.org/EIPS/eip-7002) From 48141ca8757240f9a7ed471d975d1eb3d0796ca7 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Fri, 14 Feb 2025 15:20:13 +0000 Subject: [PATCH 69/70] feat: add typecheck to pre-commit --- .husky/pre-commit | 1 + 1 file changed, 1 insertion(+) diff --git a/.husky/pre-commit b/.husky/pre-commit index 372362317..4671385f8 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,2 @@ yarn lint-staged +yarn typecheck From 12beecfe628aa66e3079a94f5c724329af67748f Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Fri, 14 Feb 2025 15:20:26 +0000 Subject: [PATCH 70/70] fix(test): fix vaults happy path --- .../vaults-happy-path.integration.ts | 48 ++++++------------- 1 file changed, 14 insertions(+), 34 deletions(-) diff --git a/test/integration/vaults-happy-path.integration.ts b/test/integration/vaults-happy-path.integration.ts index 13f41b55a..4c5ea44e5 100644 --- a/test/integration/vaults-happy-path.integration.ts +++ b/test/integration/vaults-happy-path.integration.ts @@ -6,7 +6,7 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { Delegation, StakingVault } from "typechain-types"; -import { computeDepositDataRoot, impersonate, log, trace, updateBalance } from "lib"; +import { computeDepositDataRoot, impersonate, log, updateBalance } from "lib"; import { getProtocolContext, ProtocolContext } from "lib/protocol"; import { getReportTimeElapsed, @@ -122,11 +122,8 @@ describe("Scenario: Staking Vaults Happy Path", () => { await lido.connect(ethHolder).submit(ZeroAddress, { value: LIDO_DEPOSIT }); const dsmSigner = await impersonate(depositSecurityModule.address, LIDO_DEPOSIT); - const depositNorTx = await lido.connect(dsmSigner).deposit(MAX_DEPOSIT, CURATED_MODULE_ID, ZERO_HASH); - await trace("lido.deposit", depositNorTx); - - const depositSdvtTx = await lido.connect(dsmSigner).deposit(MAX_DEPOSIT, SIMPLE_DVT_MODULE_ID, ZERO_HASH); - await trace("lido.deposit", depositSdvtTx); + await lido.connect(dsmSigner).deposit(MAX_DEPOSIT, CURATED_MODULE_ID, ZERO_HASH); + await lido.connect(dsmSigner).deposit(MAX_DEPOSIT, SIMPLE_DVT_MODULE_ID, ZERO_HASH); const reportData: Partial = { clDiff: LIDO_DEPOSIT, @@ -178,7 +175,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { "0x", ); - const createVaultTxReceipt = await trace("vaultsFactory.createVault", deployTx); + const createVaultTxReceipt = (await deployTx.wait()) as ContractTransactionReceipt; const createVaultEvents = ctx.getEvents(createVaultTxReceipt, "VaultCreated"); expect(createVaultEvents.length).to.equal(1n); @@ -229,8 +226,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { }); it("Should allow Staker to fund vault via delegation contract", async () => { - const depositTx = await delegation.connect(curator).fund({ value: VAULT_DEPOSIT }); - await trace("delegation.fund", depositTx); + await delegation.connect(curator).fund({ value: VAULT_DEPOSIT }); const vaultBalance = await ethers.provider.getBalance(stakingVault); @@ -260,9 +256,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { }); } - const topUpTx = await stakingVault.connect(nodeOperator).depositToBeaconChain(deposits); - - await trace("stakingVault.depositToBeaconChain", topUpTx); + await stakingVault.connect(nodeOperator).depositToBeaconChain(deposits); stakingVaultBeaconBalance += VAULT_DEPOSIT; stakingVaultAddress = await stakingVault.getAddress(); @@ -293,7 +287,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { .withArgs(stakingVault, stakingVault.valuation()); const mintTx = await delegation.connect(curator).mintShares(curator, stakingVaultMaxMintingShares); - const mintTxReceipt = await trace("delegation.mint", mintTx); + const mintTxReceipt = (await mintTx.wait()) as ContractTransactionReceipt; const mintEvents = ctx.getEvents(mintTxReceipt, "MintedSharesOnVault"); expect(mintEvents.length).to.equal(1n); @@ -352,10 +346,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { const operatorBalanceBefore = await ethers.provider.getBalance(nodeOperator); const claimPerformanceFeesTx = await delegation.connect(nodeOperator).claimNodeOperatorFee(nodeOperator); - const claimPerformanceFeesTxReceipt = await trace( - "delegation.claimNodeOperatorFee", - claimPerformanceFeesTx, - ); + const claimPerformanceFeesTxReceipt = (await claimPerformanceFeesTx.wait()) as ContractTransactionReceipt; const operatorBalanceAfter = await ethers.provider.getBalance(nodeOperator); const gasFee = claimPerformanceFeesTxReceipt.gasPrice * claimPerformanceFeesTxReceipt.cumulativeGasUsed; @@ -400,7 +391,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { const managerBalanceBefore = await ethers.provider.getBalance(curator); const claimEthTx = await delegation.connect(curator).claimCuratorFee(curator); - const { gasUsed, gasPrice } = await trace("delegation.claimCuratorFee", claimEthTx); + const { gasUsed, gasPrice } = (await claimEthTx.wait()) as ContractTransactionReceipt; const managerBalanceAfter = await ethers.provider.getBalance(curator); const vaultBalance = await ethers.provider.getBalance(stakingVaultAddress); @@ -420,13 +411,8 @@ describe("Scenario: Staking Vaults Happy Path", () => { const { lido } = ctx.contracts; // Token master can approve the vault to burn the shares - const approveVaultTx = await lido - .connect(curator) - .approve(delegation, await lido.getPooledEthByShares(stakingVaultMaxMintingShares)); - await trace("lido.approve", approveVaultTx); - - const burnTx = await delegation.connect(curator).burnShares(stakingVaultMaxMintingShares); - await trace("delegation.burn", burnTx); + await lido.connect(curator).approve(delegation, await lido.getPooledEthByShares(stakingVaultMaxMintingShares)); + await delegation.connect(curator).burnShares(stakingVaultMaxMintingShares); const { elapsedProtocolReward, elapsedVaultReward } = await calculateReportParams(); const vaultValue = await addRewards(elapsedVaultReward / 2n); // Half the vault rewards value after validator exit @@ -438,12 +424,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { inOutDeltas: [VAULT_DEPOSIT], } as OracleReportParams; - const { reportTx } = (await report(ctx, params)) as { - reportTx: TransactionResponse; - extraDataTx: TransactionResponse; - }; - - await trace("report", reportTx); + await report(ctx, params); const lockedOnVault = await stakingVault.locked(); expect(lockedOnVault).to.be.gt(0n); // lockedOnVault should be greater than 0, because of the debt @@ -457,15 +438,14 @@ describe("Scenario: Staking Vaults Happy Path", () => { const socket = await accounting["vaultSocket(address)"](stakingVaultAddress); const sharesMinted = await lido.getPooledEthByShares(socket.sharesMinted); - const rebalanceTx = await delegation.connect(curator).rebalanceVault(sharesMinted, { value: sharesMinted }); - await trace("delegation.rebalanceVault", rebalanceTx); + await delegation.connect(curator).rebalanceVault(sharesMinted, { value: sharesMinted }); expect(await stakingVault.locked()).to.equal(VAULT_CONNECTION_DEPOSIT); // 1 ETH locked as a connection fee }); it("Should allow Manager to disconnect vaults from the hub", async () => { const disconnectTx = await delegation.connect(curator).voluntaryDisconnect(); - const disconnectTxReceipt = await trace("delegation.voluntaryDisconnect", disconnectTx); + const disconnectTxReceipt = (await disconnectTx.wait()) as ContractTransactionReceipt; const disconnectEvents = ctx.getEvents(disconnectTxReceipt, "VaultDisconnected"); expect(disconnectEvents.length).to.equal(1n);