From 483b442747e63dbcb69f61f179ddb8fae2c7d7eb Mon Sep 17 00:00:00 2001 From: Eddort Date: Wed, 11 Dec 2024 14:28:30 +0100 Subject: [PATCH 001/184] feat: initial deploy to local devnet --- hardhat.config.ts | 18 +++++++++++++++++- package.json | 2 +- tasks/verify-contracts.ts | 4 ++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/hardhat.config.ts b/hardhat.config.ts index 64c0a3e6f..1e4341ab4 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -54,6 +54,10 @@ const config: HardhatUserConfig = { "local": { url: process.env.LOCAL_RPC_URL || RPC_URL, }, + "local-devnet": { + url: process.env.LOCAL_RPC_URL || RPC_URL, + accounts: [process.env.LOCAL_DEVNET_PK || ""], + }, "mainnet-fork": { url: process.env.MAINNET_RPC_URL || RPC_URL, timeout: 20 * 60 * 1000, // 20 minutes @@ -80,7 +84,19 @@ const config: HardhatUserConfig = { }, }, etherscan: { - apiKey: process.env.ETHERSCAN_API_KEY || "", + customChains: [ + { + network: "local-devnet", + chainId: 32382, + urls: { + apiURL: "http://localhost:3080/api", + browserURL: "http://localhost:3080", + }, + }, + ], + apiKey: { + "local-devnet": "local-devnet", + }, }, solidity: { compilers: [ diff --git a/package.json b/package.json index bb806bd40..d7b481ea6 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "typecheck": "tsc --noEmit", "prepare": "husky", "abis:extract": "hardhat abis:extract", - "verify:deployed": "hardhat verify:deployed --no-compile" + "verify:deployed": "hardhat verify:deployed" }, "lint-staged": { "./**/*.ts": [ diff --git a/tasks/verify-contracts.ts b/tasks/verify-contracts.ts index 3dd4e03a4..0a546c15d 100644 --- a/tasks/verify-contracts.ts +++ b/tasks/verify-contracts.ts @@ -58,6 +58,10 @@ task("verify:deployed", "Verifies deployed contracts based on state file").setAc ); async function verifyContract(contract: DeployedContract, hre: HardhatRuntimeEnvironment) { + if (!contract.contract) { + // TODO: In the case of state processing on the local devnet there are skips, we need to find the cause + return + } const contractName = contract.contract.split("/").pop()?.split(".")[0]; const verificationParams = { address: contract.address, From e5aa190f013e636691a7c8569cd41e13b58336c3 Mon Sep 17 00:00:00 2001 From: Eddort Date: Wed, 11 Dec 2024 15:07:32 +0100 Subject: [PATCH 002/184] feat: speed up scratch deployment for local-devnet --- lib/deploy.ts | 9 +- lib/nonce-manager.ts | 16 ++ .../scratch/steps/0020-deploy-aragon-env.ts | 26 ++-- .../0030-deploy-template-and-app-bases.ts | 19 +-- .../0120-initialize-non-aragon-contracts.ts | 137 +++++++++++------- scripts/scratch/steps/0130-grant-roles.ts | 90 +++++++----- .../steps/0140-plug-staking-modules.ts | 65 +++++---- scripts/scratch/steps/0150-transfer-roles.ts | 28 ++-- 8 files changed, 239 insertions(+), 151 deletions(-) create mode 100644 lib/nonce-manager.ts diff --git a/lib/deploy.ts b/lib/deploy.ts index 1b9a1626a..4f9014f59 100644 --- a/lib/deploy.ts +++ b/lib/deploy.ts @@ -6,6 +6,7 @@ import { LidoLocator } from "typechain-types"; import { addContractHelperFields, DeployedContract, getContractPath, loadContract, LoadedContract } from "lib/contract"; import { ConvertibleToString, cy, gr, log, yl } from "lib/log"; +import { getNonceManagerWithDeployer } from "lib/nonce-manager"; import { incrementGasUsed, Sk, updateObjectInState } from "lib/state-file"; const GAS_PRIORITY_FEE = process.env.GAS_PRIORITY_FEE || null; @@ -33,9 +34,11 @@ export async function makeTx( txParams: TxParams, withStateFile = true, ): Promise { + const contractWithNonceManager = contract.connect(await getNonceManagerWithDeployer()); + log.withArguments(`Call: ${yl(contract.name)}[${cy(contract.address)}].${yl(funcName)}`, args); - const tx = await contract.getFunction(funcName)(...args, txParams); + const tx = await contractWithNonceManager.getFunction(funcName)(...args, txParams); log(` Transaction: ${tx.hash} (nonce ${yl(tx.nonce)})...`); const receipt = await tx.wait(); @@ -74,7 +77,9 @@ async function deployContractType2( ): Promise { const txParams = await getDeployTxParams(deployer); const factory = (await ethers.getContractFactory(artifactName, signerOrOptions)) as ContractFactory; - const contract = await factory.deploy(...constructorArgs, txParams); + const factoryWithNonceManager = factory.connect(await getNonceManagerWithDeployer()); + + const contract = await factoryWithNonceManager.deploy(...constructorArgs, txParams); const tx = contract.deploymentTransaction(); if (!tx) { throw new Error(`Failed to send the deployment transaction for ${artifactName}`); diff --git a/lib/nonce-manager.ts b/lib/nonce-manager.ts new file mode 100644 index 000000000..135471b50 --- /dev/null +++ b/lib/nonce-manager.ts @@ -0,0 +1,16 @@ +import { NonceManager } from "ethers"; +import { ethers } from "hardhat"; + +let cachedNonceManager: NonceManager; + +export const getNonceManagerWithDeployer = async () => { + if (cachedNonceManager) { + return cachedNonceManager; + } + const [deployer] = await ethers.getSigners(); + + const nonceManager = new ethers.NonceManager(deployer); + cachedNonceManager = nonceManager; + + return nonceManager; +}; diff --git a/scripts/scratch/steps/0020-deploy-aragon-env.ts b/scripts/scratch/steps/0020-deploy-aragon-env.ts index 7d3996216..3fc873372 100644 --- a/scripts/scratch/steps/0020-deploy-aragon-env.ts +++ b/scripts/scratch/steps/0020-deploy-aragon-env.ts @@ -94,13 +94,12 @@ export async function main() { if (daoFactoryAddress) { log(`Using pre-deployed DAOFactory: ${cy(state[Sk.daoFactory].address)}`); } else { - const kernelBase = await deployImplementation(Sk.aragonKernel, "Kernel", deployer, [true]); - const aclBase = await deployImplementation(Sk.aragonAcl, "ACL", deployer); - const evmScriptRegistryFactory = await deployWithoutProxy( - Sk.evmScriptRegistryFactory, - "EVMScriptRegistryFactory", - deployer, - ); + const [kernelBase, aclBase, evmScriptRegistryFactory] = await Promise.all([ + deployImplementation(Sk.aragonKernel, "Kernel", deployer, [true]), + deployImplementation(Sk.aragonAcl, "ACL", deployer), + deployWithoutProxy(Sk.evmScriptRegistryFactory, "EVMScriptRegistryFactory", deployer), + ]); + const daoFactoryArgs = [kernelBase.address, aclBase.address, evmScriptRegistryFactory.address]; daoFactoryAddress = (await deployWithoutProxy(Sk.daoFactory, "DAOFactory", deployer, daoFactoryArgs)).address; } @@ -108,13 +107,12 @@ export async function main() { // Deploy APM registry factory log.header(`APM registry factory`); - const apmRegistryBase = await deployImplementation(Sk.aragonApmRegistry, "APMRegistry", deployer); - const apmRepoBase = await deployWithoutProxy(Sk.aragonRepoBase, "Repo", deployer); - const ensSubdomainRegistrarBase = await deployImplementation( - Sk.ensSubdomainRegistrar, - "ENSSubdomainRegistrar", - deployer, - ); + const [apmRegistryBase, apmRepoBase, ensSubdomainRegistrarBase] = await Promise.all([ + deployImplementation(Sk.aragonApmRegistry, "APMRegistry", deployer), + deployWithoutProxy(Sk.aragonRepoBase, "Repo", deployer), + deployImplementation(Sk.ensSubdomainRegistrar, "ENSSubdomainRegistrar", deployer), + ]); + const apmRegistryFactory = await deployWithoutProxy(Sk.apmRegistryFactory, "APMRegistryFactory", deployer, [ daoFactory.address, diff --git a/scripts/scratch/steps/0030-deploy-template-and-app-bases.ts b/scripts/scratch/steps/0030-deploy-template-and-app-bases.ts index 87a761880..6ef4ec3b6 100644 --- a/scripts/scratch/steps/0030-deploy-template-and-app-bases.ts +++ b/scripts/scratch/steps/0030-deploy-template-and-app-bases.ts @@ -7,15 +7,16 @@ export async function main() { const deployer = (await ethers.provider.getSigner()).address; const state = readNetworkState({ deployer }); - // Deploy Aragon app implementations - await deployImplementation(Sk.appAgent, "Agent", deployer); - await deployImplementation(Sk.appFinance, "Finance", deployer); - await deployImplementation(Sk.appTokenManager, "TokenManager", deployer); - await deployImplementation(Sk.appVoting, "Voting", deployer); - - // Deploy Lido-specific app implementations - await deployImplementation(Sk.appLido, "Lido", deployer); - await deployImplementation(Sk.appOracle, "LegacyOracle", deployer); + await Promise.all([ + // Deploy Aragon app implementations + deployImplementation(Sk.appAgent, "Agent", deployer), + deployImplementation(Sk.appFinance, "Finance", deployer), + deployImplementation(Sk.appTokenManager, "TokenManager", deployer), + deployImplementation(Sk.appVoting, "Voting", deployer), + // Deploy Lido-specific app implementations + deployImplementation(Sk.appLido, "Lido", deployer), + deployImplementation(Sk.appOracle, "LegacyOracle", deployer), + ]); const minFirstAllocationStrategy = await deployWithoutProxy( Sk.minFirstAllocationStrategy, diff --git a/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts b/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts index dab37394b..a2c3af96f 100644 --- a/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts +++ b/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts @@ -1,3 +1,4 @@ +import { ContractTransactionReceipt } from "ethers"; import { ethers } from "hardhat"; import { loadContract } from "lib/contract"; @@ -36,6 +37,8 @@ export async function main() { const stakingRouterAdmin = testnetAdmin; const withdrawalQueueAdmin = testnetAdmin; + const waitTransactionsGroup: Promise[] = []; + // Initialize NodeOperatorsRegistry // https://github.com/ethereum/solidity-examples/blob/master/docs/bytes/Bytes.md#description @@ -43,90 +46,110 @@ export async function main() { "0x" + ethers.AbiCoder.defaultAbiCoder().encode(["string"], [stakingModuleTypeId]).slice(-64); const nodeOperatorsRegistry = await loadContract("NodeOperatorsRegistry", nodeOperatorsRegistryAddress); - await makeTx( - nodeOperatorsRegistry, - "initialize", - [ - lidoLocatorAddress, - encodeStakingModuleTypeId(nodeOperatorsRegistryParams.stakingModuleTypeId), - nodeOperatorsRegistryParams.stuckPenaltyDelay, - ], - { from: deployer }, + waitTransactionsGroup.push( + makeTx( + nodeOperatorsRegistry, + "initialize", + [ + lidoLocatorAddress, + encodeStakingModuleTypeId(nodeOperatorsRegistryParams.stakingModuleTypeId), + nodeOperatorsRegistryParams.stuckPenaltyDelay, + ], + { from: deployer }, + ), ); const simpleDvtRegistry = await loadContract("NodeOperatorsRegistry", simpleDvtRegistryAddress); - await makeTx( - simpleDvtRegistry, - "initialize", - [ - lidoLocatorAddress, - encodeStakingModuleTypeId(simpleDvtRegistryParams.stakingModuleTypeId), - simpleDvtRegistryParams.stuckPenaltyDelay, - ], - { from: deployer }, + waitTransactionsGroup.push( + makeTx( + simpleDvtRegistry, + "initialize", + [ + lidoLocatorAddress, + encodeStakingModuleTypeId(simpleDvtRegistryParams.stakingModuleTypeId), + simpleDvtRegistryParams.stuckPenaltyDelay, + ], + { from: deployer }, + ), ); // Initialize Lido const bootstrapInitBalance = 10n; // wei const lido = await loadContract("Lido", lidoAddress); - await makeTx(lido, "initialize", [lidoLocatorAddress, eip712StETHAddress], { - value: bootstrapInitBalance, - from: deployer, - }); + waitTransactionsGroup.push( + makeTx(lido, "initialize", [lidoLocatorAddress, eip712StETHAddress], { + value: bootstrapInitBalance, + from: deployer, + }), + ); // Initialize LegacyOracle const legacyOracle = await loadContract("LegacyOracle", legacyOracleAddress); - await makeTx(legacyOracle, "initialize", [lidoLocatorAddress, hashConsensusForAccountingAddress], { from: deployer }); + waitTransactionsGroup.push( + makeTx(legacyOracle, "initialize", [lidoLocatorAddress, hashConsensusForAccountingAddress], { + from: deployer, + }), + ); const zeroLastProcessingRefSlot = 0; // Initialize AccountingOracle const accountingOracle = await loadContract("AccountingOracle", accountingOracleAddress); - await makeTx( - accountingOracle, - "initializeWithoutMigration", - [ - accountingOracleAdmin, - hashConsensusForAccountingAddress, - accountingOracleParams.consensusVersion, - zeroLastProcessingRefSlot, - ], - { from: deployer }, + waitTransactionsGroup.push( + makeTx( + accountingOracle, + "initializeWithoutMigration", + [ + accountingOracleAdmin, + hashConsensusForAccountingAddress, + accountingOracleParams.consensusVersion, + zeroLastProcessingRefSlot, + ], + { from: deployer }, + ), ); // Initialize ValidatorsExitBusOracle const validatorsExitBusOracle = await loadContract("ValidatorsExitBusOracle", ValidatorsExitBusOracleAddress); - await makeTx( - validatorsExitBusOracle, - "initialize", - [ - exitBusOracleAdmin, - hashConsensusForValidatorsExitBusOracleAddress, - validatorsExitBusOracleParams.consensusVersion, - zeroLastProcessingRefSlot, - ], - { from: deployer }, + waitTransactionsGroup.push( + makeTx( + validatorsExitBusOracle, + "initialize", + [ + exitBusOracleAdmin, + hashConsensusForValidatorsExitBusOracleAddress, + validatorsExitBusOracleParams.consensusVersion, + zeroLastProcessingRefSlot, + ], + { from: deployer }, + ), ); // Initialize WithdrawalQueue const withdrawalQueue = await loadContract("WithdrawalQueueERC721", withdrawalQueueAddress); - await makeTx(withdrawalQueue, "initialize", [withdrawalQueueAdmin], { from: deployer }); + waitTransactionsGroup.push(makeTx(withdrawalQueue, "initialize", [withdrawalQueueAdmin], { from: deployer })); // Set WithdrawalQueue base URI if provided const withdrawalQueueBaseUri = state["withdrawalQueueERC721"].deployParameters.baseUri; if (withdrawalQueueBaseUri !== null && withdrawalQueueBaseUri !== "") { const MANAGE_TOKEN_URI_ROLE = await withdrawalQueue.getFunction("MANAGE_TOKEN_URI_ROLE")(); - await makeTx(withdrawalQueue, "grantRole", [MANAGE_TOKEN_URI_ROLE, deployer], { from: deployer }); - await makeTx(withdrawalQueue, "setBaseURI", [withdrawalQueueBaseUri], { from: deployer }); - await makeTx(withdrawalQueue, "renounceRole", [MANAGE_TOKEN_URI_ROLE, deployer], { from: deployer }); + waitTransactionsGroup.push( + makeTx(withdrawalQueue, "grantRole", [MANAGE_TOKEN_URI_ROLE, deployer], { from: deployer }), + ); + waitTransactionsGroup.push(makeTx(withdrawalQueue, "setBaseURI", [withdrawalQueueBaseUri], { from: deployer })); + waitTransactionsGroup.push( + makeTx(withdrawalQueue, "renounceRole", [MANAGE_TOKEN_URI_ROLE, deployer], { from: deployer }), + ); } // Initialize StakingRouter const withdrawalCredentials = `0x010000000000000000000000${withdrawalVaultAddress.slice(2)}`; const stakingRouter = await loadContract("StakingRouter", stakingRouterAddress); - await makeTx(stakingRouter, "initialize", [stakingRouterAdmin, lidoAddress, withdrawalCredentials], { - from: deployer, - }); + waitTransactionsGroup.push( + makeTx(stakingRouter, "initialize", [stakingRouterAdmin, lidoAddress, withdrawalCredentials], { + from: deployer, + }), + ); // Set OracleDaemonConfig parameters const oracleDaemonConfig = await loadContract("OracleDaemonConfig", oracleDaemonConfigAddress); @@ -134,9 +157,15 @@ export async function main() { await makeTx(oracleDaemonConfig, "grantRole", [CONFIG_MANAGER_ROLE, testnetAdmin], { from: testnetAdmin }); // Set each parameter in the OracleDaemonConfig - for (const [key, value] of Object.entries(state.oracleDaemonConfig.deployParameters)) { - await makeTx(oracleDaemonConfig, "set", [key, en0x(value as number)], { from: deployer }); - } + const txPromises = Object.entries(state.oracleDaemonConfig.deployParameters).map(([key, value]) => { + return makeTx(oracleDaemonConfig, "set", [key, en0x(value as number)], { from: deployer }); + }); + + await Promise.all(txPromises); + + waitTransactionsGroup.push( + makeTx(oracleDaemonConfig, "renounceRole", [CONFIG_MANAGER_ROLE, testnetAdmin], { from: testnetAdmin }), + ); - await makeTx(oracleDaemonConfig, "renounceRole", [CONFIG_MANAGER_ROLE, testnetAdmin], { from: testnetAdmin }); + await Promise.all(waitTransactionsGroup); } diff --git a/scripts/scratch/steps/0130-grant-roles.ts b/scripts/scratch/steps/0130-grant-roles.ts index 2ef6f4f5e..545ff603e 100644 --- a/scripts/scratch/steps/0130-grant-roles.ts +++ b/scripts/scratch/steps/0130-grant-roles.ts @@ -1,3 +1,4 @@ +import { ContractTransactionReceipt } from "ethers"; import { ethers } from "hardhat"; import { Burner, StakingRouter, ValidatorsExitBusOracle, WithdrawalQueueERC721 } from "typechain-types"; @@ -23,26 +24,33 @@ export async function main() { const validatorsExitBusOracleAddress = state[Sk.validatorsExitBusOracle].proxy.address; const depositSecurityModuleAddress = state[Sk.depositSecurityModule].address; + const waitTransactionsGroup: Promise[] = []; + // StakingRouter const stakingRouter = await loadContract("StakingRouter", stakingRouterAddress); - await makeTx( - stakingRouter, - "grantRole", - [await stakingRouter.STAKING_MODULE_UNVETTING_ROLE(), depositSecurityModuleAddress], - { from: deployer }, + waitTransactionsGroup.push( + makeTx( + stakingRouter, + "grantRole", + [await stakingRouter.STAKING_MODULE_UNVETTING_ROLE(), depositSecurityModuleAddress], + { from: deployer }, + ), + ); + waitTransactionsGroup.push( + makeTx(stakingRouter, "grantRole", [await stakingRouter.REPORT_EXITED_VALIDATORS_ROLE(), accountingOracleAddress], { + from: deployer, + }), + ); + waitTransactionsGroup.push( + makeTx(stakingRouter, "grantRole", [await stakingRouter.REPORT_REWARDS_MINTED_ROLE(), lidoAddress], { + from: deployer, + }), ); - await makeTx( - stakingRouter, - "grantRole", - [await stakingRouter.REPORT_EXITED_VALIDATORS_ROLE(), accountingOracleAddress], - { from: deployer }, + waitTransactionsGroup.push( + makeTx(stakingRouter, "grantRole", [await stakingRouter.STAKING_MODULE_MANAGE_ROLE(), agentAddress], { + from: deployer, + }), ); - await makeTx(stakingRouter, "grantRole", [await stakingRouter.REPORT_REWARDS_MINTED_ROLE(), lidoAddress], { - from: deployer, - }); - await makeTx(stakingRouter, "grantRole", [await stakingRouter.STAKING_MODULE_MANAGE_ROLE(), agentAddress], { - from: deployer, - }); // ValidatorsExitBusOracle if (gateSealAddress) { @@ -50,9 +58,11 @@ export async function main() { "ValidatorsExitBusOracle", validatorsExitBusOracleAddress, ); - await makeTx(validatorsExitBusOracle, "grantRole", [await validatorsExitBusOracle.PAUSE_ROLE(), gateSealAddress], { - from: deployer, - }); + waitTransactionsGroup.push( + makeTx(validatorsExitBusOracle, "grantRole", [await validatorsExitBusOracle.PAUSE_ROLE(), gateSealAddress], { + from: deployer, + }), + ); } else { log(`GateSeal is not specified or deployed: skipping assigning PAUSE_ROLE of validatorsExitBusOracle`); log.emptyLine(); @@ -61,29 +71,41 @@ export async function main() { // WithdrawalQueue const withdrawalQueue = await loadContract("WithdrawalQueueERC721", withdrawalQueueAddress); if (gateSealAddress) { - await makeTx(withdrawalQueue, "grantRole", [await withdrawalQueue.PAUSE_ROLE(), gateSealAddress], { - from: deployer, - }); + waitTransactionsGroup.push( + makeTx(withdrawalQueue, "grantRole", [await withdrawalQueue.PAUSE_ROLE(), gateSealAddress], { + from: deployer, + }), + ); } else { log(`GateSeal is not specified or deployed: skipping assigning PAUSE_ROLE of withdrawalQueue`); log.emptyLine(); } - await makeTx(withdrawalQueue, "grantRole", [await withdrawalQueue.FINALIZE_ROLE(), lidoAddress], { - from: deployer, - }); + waitTransactionsGroup.push( + makeTx(withdrawalQueue, "grantRole", [await withdrawalQueue.FINALIZE_ROLE(), lidoAddress], { + from: deployer, + }), + ); - await makeTx(withdrawalQueue, "grantRole", [await withdrawalQueue.ORACLE_ROLE(), accountingOracleAddress], { - from: deployer, - }); + waitTransactionsGroup.push( + makeTx(withdrawalQueue, "grantRole", [await withdrawalQueue.ORACLE_ROLE(), accountingOracleAddress], { + from: deployer, + }), + ); // Burner const burner = await loadContract("Burner", burnerAddress); // NB: REQUEST_BURN_SHARES_ROLE is already granted to Lido in Burner constructor - await makeTx(burner, "grantRole", [await burner.REQUEST_BURN_SHARES_ROLE(), nodeOperatorsRegistryAddress], { - from: deployer, - }); - await makeTx(burner, "grantRole", [await burner.REQUEST_BURN_SHARES_ROLE(), simpleDvtApp], { - from: deployer, - }); + waitTransactionsGroup.push( + makeTx(burner, "grantRole", [await burner.REQUEST_BURN_SHARES_ROLE(), nodeOperatorsRegistryAddress], { + from: deployer, + }), + ); + waitTransactionsGroup.push( + makeTx(burner, "grantRole", [await burner.REQUEST_BURN_SHARES_ROLE(), simpleDvtApp], { + from: deployer, + }), + ); + + await Promise.all(waitTransactionsGroup); } diff --git a/scripts/scratch/steps/0140-plug-staking-modules.ts b/scripts/scratch/steps/0140-plug-staking-modules.ts index f1c8b5f23..2dbf2a213 100644 --- a/scripts/scratch/steps/0140-plug-staking-modules.ts +++ b/scripts/scratch/steps/0140-plug-staking-modules.ts @@ -1,3 +1,4 @@ +import { ContractTransactionReceipt } from "ethers"; import { ethers } from "hardhat"; import { loadContract } from "lib/contract"; @@ -31,40 +32,48 @@ export async function main() { // Grant STAKING_MODULE_MANAGE_ROLE to deployer await makeTx(stakingRouter, "grantRole", [STAKING_MODULE_MANAGE_ROLE, deployer], { from: deployer }); + const waitTransactionsGroup: Promise[] = []; + // Add staking module to StakingRouter - await makeTx( - stakingRouter, - "addStakingModule", - [ - state.nodeOperatorsRegistry.deployParameters.stakingModuleTypeId, - state[Sk.appNodeOperatorsRegistry].proxy.address, - NOR_STAKING_MODULE_STAKE_SHARE_LIMIT_BP, - NOR_STAKING_MODULE_PRIORITY_EXIT_SHARE_THRESHOLD_BP, - NOR_STAKING_MODULE_MODULE_FEE_BP, - NOR_STAKING_MODULE_TREASURY_FEE_BP, - NOR_STAKING_MODULE_MAX_DEPOSITS_PER_BLOCK, - NOR_STAKING_MODULE_MIN_DEPOSIT_BLOCK_DISTANCE, - ], - { from: deployer }, + waitTransactionsGroup.push( + makeTx( + stakingRouter, + "addStakingModule", + [ + state.nodeOperatorsRegistry.deployParameters.stakingModuleTypeId, + state[Sk.appNodeOperatorsRegistry].proxy.address, + NOR_STAKING_MODULE_STAKE_SHARE_LIMIT_BP, + NOR_STAKING_MODULE_PRIORITY_EXIT_SHARE_THRESHOLD_BP, + NOR_STAKING_MODULE_MODULE_FEE_BP, + NOR_STAKING_MODULE_TREASURY_FEE_BP, + NOR_STAKING_MODULE_MAX_DEPOSITS_PER_BLOCK, + NOR_STAKING_MODULE_MIN_DEPOSIT_BLOCK_DISTANCE, + ], + { from: deployer }, + ), ); // Add simple DVT module to StakingRouter - await makeTx( - stakingRouter, - "addStakingModule", - [ - state.simpleDvt.deployParameters.stakingModuleTypeId, - state[Sk.appSimpleDvt].proxy.address, - SDVT_STAKING_MODULE_TARGET_SHARE_BP, - SDVT_STAKING_MODULE_PRIORITY_EXIT_SHARE_THRESHOLD_BP, - SDVT_STAKING_MODULE_MODULE_FEE_BP, - SDVT_STAKING_MODULE_TREASURY_FEE_BP, - SDVT_STAKING_MODULE_MAX_DEPOSITS_PER_BLOCK, - SDVT_STAKING_MODULE_MIN_DEPOSIT_BLOCK_DISTANCE, - ], - { from: deployer }, + waitTransactionsGroup.push( + makeTx( + stakingRouter, + "addStakingModule", + [ + state.simpleDvt.deployParameters.stakingModuleTypeId, + state[Sk.appSimpleDvt].proxy.address, + SDVT_STAKING_MODULE_TARGET_SHARE_BP, + SDVT_STAKING_MODULE_PRIORITY_EXIT_SHARE_THRESHOLD_BP, + SDVT_STAKING_MODULE_MODULE_FEE_BP, + SDVT_STAKING_MODULE_TREASURY_FEE_BP, + SDVT_STAKING_MODULE_MAX_DEPOSITS_PER_BLOCK, + SDVT_STAKING_MODULE_MIN_DEPOSIT_BLOCK_DISTANCE, + ], + { from: deployer }, + ), ); + await Promise.all(waitTransactionsGroup); + // Renounce STAKING_MODULE_MANAGE_ROLE from deployer await makeTx(stakingRouter, "renounceRole", [STAKING_MODULE_MANAGE_ROLE, deployer], { from: deployer }); } diff --git a/scripts/scratch/steps/0150-transfer-roles.ts b/scripts/scratch/steps/0150-transfer-roles.ts index e7804196d..ed7bd5c6c 100644 --- a/scripts/scratch/steps/0150-transfer-roles.ts +++ b/scripts/scratch/steps/0150-transfer-roles.ts @@ -25,11 +25,16 @@ export async function main() { { name: "OracleReportSanityChecker", address: state.oracleReportSanityChecker.address }, ]; - for (const contract of ozAdminTransfers) { - const contractInstance = await loadContract(contract.name, contract.address); - await makeTx(contractInstance, "grantRole", [DEFAULT_ADMIN_ROLE, agent], { from: deployer }); - await makeTx(contractInstance, "renounceRole", [DEFAULT_ADMIN_ROLE, deployer], { from: deployer }); - } + const contractInstances = await Promise.all( + ozAdminTransfers.map((contract) => loadContract(contract.name, contract.address)), + ); + + await Promise.all( + contractInstances.map(async (contractInstance) => { + await makeTx(contractInstance, "grantRole", [DEFAULT_ADMIN_ROLE, agent], { from: deployer }); + await makeTx(contractInstance, "renounceRole", [DEFAULT_ADMIN_ROLE, deployer], { from: deployer }); + }), + ); // Change admin for OssifiableProxy contracts const ossifiableProxyAdminChanges = [ @@ -40,12 +45,15 @@ export async function main() { state.withdrawalQueueERC721.proxy.address, ]; - for (const proxyAddress of ossifiableProxyAdminChanges) { - const proxy = await loadContract("OssifiableProxy", proxyAddress); - await makeTx(proxy, "proxy__changeAdmin", [agent], { from: deployer }); - } + // Parallel execution of proxy admin changes + await Promise.all( + ossifiableProxyAdminChanges.map(async (proxyAddress) => { + const proxy = await loadContract("OssifiableProxy", proxyAddress); + return makeTx(proxy, "proxy__changeAdmin", [agent], { from: deployer }); + }), + ); - // Change DepositSecurityModule admin if not using predefined address + // Change DepositSecurityModule admin if not using a predefined address if (state[Sk.depositSecurityModule].deployParameters.usePredefinedAddressInstead === null) { const depositSecurityModule = await loadContract("DepositSecurityModule", state.depositSecurityModule.address); await makeTx(depositSecurityModule, "setOwner", [agent], { from: deployer }); From ee5592683db23d60ea672020b6e755b297072245 Mon Sep 17 00:00:00 2001 From: Eddort Date: Wed, 11 Dec 2024 15:09:10 +0100 Subject: [PATCH 003/184] refactor: add deployed-local-devnet.json to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 488417b46..0059a30a1 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ lib/abi/*.json accounts.json deployed-local.json deployed-hardhat.json +deployed-local-devnet.json # MacOS .DS_Store From ce8c2683707f6956ac13dfa1f54aa8078082505a Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Thu, 12 Dec 2024 11:23:33 +0000 Subject: [PATCH 004/184] chore: add default pk for local devnet --- .env.example | 3 +++ globals.d.ts | 3 +++ hardhat.config.ts | 4 +++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index b654199fd..6f43447a5 100644 --- a/.env.example +++ b/.env.example @@ -61,3 +61,6 @@ GAS_MAX_FEE=100 # Etherscan API key for verifying contracts ETHERSCAN_API_KEY= + +# Local devnet private key +LOCAL_DEVNET_PK=0x0000000000000000000000000000000000000000000000000000000000000000 diff --git a/globals.d.ts b/globals.d.ts index 77a941088..ba0652a07 100644 --- a/globals.d.ts +++ b/globals.d.ts @@ -74,5 +74,8 @@ declare namespace NodeJS { /* for contract sourcecode verification with `hardhat-verify` */ ETHERSCAN_API_KEY?: string; + + /* for local devnet */ + LOCAL_DEVNET_PK?: string; } } diff --git a/hardhat.config.ts b/hardhat.config.ts index 1e4341ab4..26d09c671 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -27,6 +27,8 @@ const HARDHAT_FORKING_URL = process.env.HARDHAT_FORKING_URL || ""; const INTEGRATION_WITH_SCRATCH_DEPLOY = process.env.INTEGRATION_WITH_SCRATCH_DEPLOY || "off"; +export const ZERO_PK = "0x0000000000000000000000000000000000000000000000000000000000000000"; + /* Determines the forking configuration for Hardhat */ function getHardhatForkingConfig() { if (INTEGRATION_WITH_SCRATCH_DEPLOY === "on" || !HARDHAT_FORKING_URL) { @@ -56,7 +58,7 @@ const config: HardhatUserConfig = { }, "local-devnet": { url: process.env.LOCAL_RPC_URL || RPC_URL, - accounts: [process.env.LOCAL_DEVNET_PK || ""], + accounts: [process.env.LOCAL_DEVNET_PK || ZERO_PK], }, "mainnet-fork": { url: process.env.MAINNET_RPC_URL || RPC_URL, From 2983d01afc8ebc08d2522a305071fc274e6bf013 Mon Sep 17 00:00:00 2001 From: Eddort Date: Tue, 17 Dec 2024 16:07:03 +0100 Subject: [PATCH 005/184] feat: decrease voting time for devnet --- scripts/scratch/deployed-testnet-defaults.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/scratch/deployed-testnet-defaults.json b/scripts/scratch/deployed-testnet-defaults.json index 0557202c3..42ea56cea 100644 --- a/scripts/scratch/deployed-testnet-defaults.json +++ b/scripts/scratch/deployed-testnet-defaults.json @@ -33,8 +33,8 @@ "voting": { "minSupportRequired": "500000000000000000", "minAcceptanceQuorum": "50000000000000000", - "voteDuration": 900, - "objectionPhaseDuration": 300 + "voteDuration": 60, + "objectionPhaseDuration": 30 }, "fee": { "totalPercent": 10, @@ -52,6 +52,7 @@ "0xCD1f9954330AF39a74Fd6e7B25781B4c24ee373f": "820000000000000000000000", "0xaa6bfBCD634EE744CB8FE522b29ADD23124593D3": "60000000000000000000000", "0xBA59A84C6440E8cccfdb5448877E26F1A431Fc8B": "60000000000000000000000", + "0x8943545177806ED17B9F23F0a21ee5948eCaa776": "820000000000000000000000", "lido-aragon-agent-placeholder": "60000000000000000000000" }, "start": 0, From e879318b5e1d9d579fe40cdfaee0649f248138f1 Mon Sep 17 00:00:00 2001 From: Eddort Date: Thu, 19 Dec 2024 11:12:27 +0100 Subject: [PATCH 006/184] fix: consensusVersion in VEBO defaults --- scripts/scratch/deployed-testnet-defaults.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/scratch/deployed-testnet-defaults.json b/scripts/scratch/deployed-testnet-defaults.json index 42ea56cea..527dc8901 100644 --- a/scripts/scratch/deployed-testnet-defaults.json +++ b/scripts/scratch/deployed-testnet-defaults.json @@ -90,7 +90,7 @@ }, "validatorsExitBusOracle": { "deployParameters": { - "consensusVersion": 1 + "consensusVersion": 2 } }, "depositSecurityModule": { From 9b454a831193fdd3662f2ea1dcb5da18e7da58d0 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Thu, 19 Dec 2024 19:45:01 +0100 Subject: [PATCH 007/184] 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 008/184] 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 ce1e4488abf93bc3d414d901905c07c10f63bc10 Mon Sep 17 00:00:00 2001 From: Eddort Date: Fri, 20 Dec 2024 00:15:40 +0100 Subject: [PATCH 009/184] feat: update default params for devnet --- scripts/scratch/deployed-testnet-defaults.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/scratch/deployed-testnet-defaults.json b/scripts/scratch/deployed-testnet-defaults.json index 527dc8901..bfe6ee50b 100644 --- a/scripts/scratch/deployed-testnet-defaults.json +++ b/scripts/scratch/deployed-testnet-defaults.json @@ -34,7 +34,7 @@ "minSupportRequired": "500000000000000000", "minAcceptanceQuorum": "50000000000000000", "voteDuration": 60, - "objectionPhaseDuration": 30 + "objectionPhaseDuration": 5 }, "fee": { "totalPercent": 10, @@ -53,6 +53,7 @@ "0xaa6bfBCD634EE744CB8FE522b29ADD23124593D3": "60000000000000000000000", "0xBA59A84C6440E8cccfdb5448877E26F1A431Fc8B": "60000000000000000000000", "0x8943545177806ED17B9F23F0a21ee5948eCaa776": "820000000000000000000000", + "0xE25583099BA105D9ec0A67f5Ae86D90e50036425": "820000000000000000000000", "lido-aragon-agent-placeholder": "60000000000000000000000" }, "start": 0, From e622b3b3b0884fd30026021e13c27d384e788c54 Mon Sep 17 00:00:00 2001 From: Eddort Date: Sun, 22 Dec 2024 15:39:57 +0100 Subject: [PATCH 010/184] feat: custom SLOTS_PER_EPOCH support --- .../scratch/steps/0000-populate-deploy-artifact-from-env.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/scratch/steps/0000-populate-deploy-artifact-from-env.ts b/scripts/scratch/steps/0000-populate-deploy-artifact-from-env.ts index c73a89681..6ec2a5b14 100644 --- a/scripts/scratch/steps/0000-populate-deploy-artifact-from-env.ts +++ b/scripts/scratch/steps/0000-populate-deploy-artifact-from-env.ts @@ -17,6 +17,7 @@ export async function main() { const deployer = ethers.getAddress(getEnvVariable("DEPLOYER")); const gateSealFactoryAddress = getEnvVariable("GATE_SEAL_FACTORY", ""); const genesisTime = parseInt(getEnvVariable("GENESIS_TIME")); + const slotsPerEpoch = parseInt(getEnvVariable("SLOTS_PER_EPOCH") ?? 32); const depositContractAddress = getEnvVariable("DEPOSIT_CONTRACT", ""); const withdrawalQueueBaseUri = getEnvVariable("WITHDRAWAL_QUEUE_BASE_URI", ""); const dsmPredefinedAddress = getEnvVariable("DSM_PREDEFINED_ADDRESS", ""); @@ -29,7 +30,7 @@ export async function main() { state.deployer = deployer; // Update state with new values from environment variables - state.chainSpec = { ...state.chainSpec, genesisTime }; + state.chainSpec = { ...state.chainSpec, genesisTime, slotsPerEpoch }; if (depositContractAddress) { state.chainSpec.depositContract = ethers.getAddress(depositContractAddress); From 4420a7cb4e616f94151f4d957c0f9fb1d6653b4b Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Sat, 21 Dec 2024 21:16:52 +0100 Subject: [PATCH 011/184] 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 012/184] 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 6c8bab8543295115608b61f43d8df8f055c5b4e5 Mon Sep 17 00:00:00 2001 From: Anna Mukharram Date: Tue, 24 Dec 2024 17:38:03 +0400 Subject: [PATCH 013/184] fix: change the module type on curated-onchain-v1 for SimpleDVT in the scratch deploy --- deployed-holesky.json | 2 +- scripts/scratch/deployed-testnet-defaults.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deployed-holesky.json b/deployed-holesky.json index 861c4e705..61174a4e0 100644 --- a/deployed-holesky.json +++ b/deployed-holesky.json @@ -175,7 +175,7 @@ "app:simple-dvt": { "stakingRouterModuleParams": { "moduleName": "SimpleDVT", - "moduleType": "simple-dvt-onchain-v1", + "moduleType": "curated-onchain-v1", "targetShare": 50, "moduleFee": 800, "treasuryFee": 200, diff --git a/scripts/scratch/deployed-testnet-defaults.json b/scripts/scratch/deployed-testnet-defaults.json index bfe6ee50b..fa3a8601d 100644 --- a/scripts/scratch/deployed-testnet-defaults.json +++ b/scripts/scratch/deployed-testnet-defaults.json @@ -139,7 +139,7 @@ }, "simpleDvt": { "deployParameters": { - "stakingModuleTypeId": "simple-dvt-onchain-v1", + "stakingModuleTypeId": "curated-onchain-v1", "stuckPenaltyDelay": 432000 } }, From 5183e89f235746c31300b5cd5542294cbd009de1 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Thu, 26 Dec 2024 14:45:41 +0100 Subject: [PATCH 014/184] 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 015/184] 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 cf98121b9e5cfe4ac9cc67e6b082f22476ab4a55 Mon Sep 17 00:00:00 2001 From: Anna Mukharram Date: Thu, 9 Jan 2025 12:33:58 +0400 Subject: [PATCH 016/184] fix: vebo access control and deploy tests --- ...ator-exit-bus-oracle.accessControl.test.ts | 205 ++++++++++++++++++ .../validator-exit-bus-oracle.deploy.test.ts | 81 +++++++ test/deploy/index.ts | 1 + test/deploy/validatorExitBusOracle.ts | 120 ++++++++++ 4 files changed, 407 insertions(+) create mode 100644 test/0.8.9/oracle/validator-exit-bus-oracle.accessControl.test.ts create mode 100644 test/0.8.9/oracle/validator-exit-bus-oracle.deploy.test.ts create mode 100644 test/deploy/validatorExitBusOracle.ts diff --git a/test/0.8.9/oracle/validator-exit-bus-oracle.accessControl.test.ts b/test/0.8.9/oracle/validator-exit-bus-oracle.accessControl.test.ts new file mode 100644 index 000000000..e7463a013 --- /dev/null +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.accessControl.test.ts @@ -0,0 +1,205 @@ +import { expect } from "chai"; +import { ContractTransactionResponse, ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { HashConsensus__Harness, ValidatorsExitBus__Harness, ValidatorsExitBusOracle } from "typechain-types"; + +import { CONSENSUS_VERSION, de0x, numberToHex, SECONDS_PER_SLOT } from "lib"; + +import { DATA_FORMAT_LIST, deployVEBO, initVEBO } from "test/deploy"; +import { Snapshot } from "test/suite"; + +const PUBKEYS = [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", +]; + +describe("ValidatorsExitBusOracle.sol:accessControl", () => { + let consensus: HashConsensus__Harness; + let oracle: ValidatorsExitBus__Harness; + let admin: HardhatEthersSigner; + let originalState: string; + + let initTx: ContractTransactionResponse; + let oracleVersion: bigint; + let exitRequests: ExitRequest[]; + let reportFields: ReportFields; + let reportItems: ReturnType; + let reportHash: string; + + let member1: HardhatEthersSigner; + let member2: HardhatEthersSigner; + let member3: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + let account1: HardhatEthersSigner; + + interface ExitRequest { + moduleId: number; + nodeOpId: number; + valIndex: number; + valPubkey: string; + } + + interface ReportFields { + consensusVersion: bigint; + refSlot: bigint; + requestsCount: number; + dataFormat: number; + data: string; + } + + const calcValidatorsExitBusReportDataHash = (items: ReturnType) => { + const data = ethers.AbiCoder.defaultAbiCoder().encode(["(uint256,uint256,uint256,uint256,bytes)"], [items]); + return ethers.keccak256(data); + }; + + const getValidatorsExitBusReportDataItems = (r: ReportFields) => { + return [r.consensusVersion, r.refSlot, r.requestsCount, r.dataFormat, r.data]; + }; + + const encodeExitRequestHex = ({ moduleId, nodeOpId, valIndex, valPubkey }: ExitRequest) => { + const pubkeyHex = de0x(valPubkey); + expect(pubkeyHex.length).to.equal(48 * 2); + return numberToHex(moduleId, 3) + numberToHex(nodeOpId, 5) + numberToHex(valIndex, 8) + pubkeyHex; + }; + + const encodeExitRequestsDataList = (requests: ExitRequest[]) => { + return "0x" + requests.map(encodeExitRequestHex).join(""); + }; + + const deploy = async () => { + const deployed = await deployVEBO(admin.address); + oracle = deployed.oracle; + consensus = deployed.consensus; + + initTx = await initVEBO({ admin: admin.address, oracle, consensus, resumeAfterDeploy: true }); + + oracleVersion = await oracle.getContractVersion(); + + await consensus.addMember(member1, 1); + await consensus.addMember(member2, 2); + await consensus.addMember(member3, 2); + + const { refSlot } = await consensus.getCurrentFrame(); + exitRequests = [ + { moduleId: 1, nodeOpId: 0, valIndex: 0, valPubkey: PUBKEYS[0] }, + { moduleId: 1, nodeOpId: 0, valIndex: 2, valPubkey: PUBKEYS[1] }, + { moduleId: 2, nodeOpId: 0, valIndex: 1, valPubkey: PUBKEYS[2] }, + ]; + + reportFields = { + consensusVersion: CONSENSUS_VERSION, + dataFormat: DATA_FORMAT_LIST, + refSlot: refSlot, + requestsCount: exitRequests.length, + data: encodeExitRequestsDataList(exitRequests), + }; + + reportItems = getValidatorsExitBusReportDataItems(reportFields); + reportHash = calcValidatorsExitBusReportDataHash(reportItems); + + await consensus.connect(member1).submitReport(refSlot, reportHash, CONSENSUS_VERSION); + await consensus.connect(member3).submitReport(refSlot, reportHash, CONSENSUS_VERSION); + }; + + before(async () => { + [admin, member1, member2, member3, stranger, account1] = await ethers.getSigners(); + + await deploy(); + }); + + beforeEach(async () => (originalState = await Snapshot.take())); + + afterEach(async () => await Snapshot.restore(originalState)); + + context("deploying", () => { + it("deploying accounting oracle", async () => { + expect(oracle).to.be.not.null; + expect(consensus).to.be.not.null; + expect(initTx).to.be.not.null; + expect(oracleVersion).to.be.not.null; + expect(exitRequests).to.be.not.null; + expect(reportFields).to.be.not.null; + expect(reportItems).to.be.not.null; + expect(reportHash).to.be.not.null; + }); + }); + + context("DEFAULT_ADMIN_ROLE", () => { + context("Admin is set at initialize", () => { + it("should set admin at initialize", async () => { + const DEFAULT_ADMIN_ROLE = await oracle.DEFAULT_ADMIN_ROLE(); + await expect(initTx).to.emit(oracle, "RoleGranted").withArgs(DEFAULT_ADMIN_ROLE, admin, admin); + }); + it("should revert without admin address", async () => { + await expect( + oracle.initialize(ZeroAddress, await consensus.getAddress(), CONSENSUS_VERSION, 0), + ).to.be.revertedWithCustomError(oracle, "AdminCannotBeZero"); + }); + }); + }); + + context("PAUSE_ROLE", () => { + it("should revert without PAUSE_ROLE role", async () => { + await expect(oracle.connect(stranger).pauseFor(0)).to.be.revertedWithOZAccessControlError( + await stranger.getAddress(), + await oracle.PAUSE_ROLE(), + ); + }); + + it("should allow calling from a possessor of PAUSE_ROLE role", async () => { + await oracle.grantRole(await oracle.PAUSE_ROLE(), account1); + + const tx = await oracle.connect(account1).pauseFor(9999); + await expect(tx).to.emit(oracle, "Paused").withArgs(9999); + }); + }); + + context("RESUME_ROLE", () => { + it("should revert without RESUME_ROLE role", async () => { + await oracle.connect(admin).pauseFor(9999); + + await expect(oracle.connect(stranger).resume()).to.be.revertedWithOZAccessControlError( + await stranger.getAddress(), + await oracle.RESUME_ROLE(), + ); + }); + + it("should allow calling from a possessor of RESUME_ROLE role", async () => { + await oracle.pauseFor(9999, { from: admin }); + await oracle.grantRole(await oracle.RESUME_ROLE(), account1); + + const tx = await oracle.connect(account1).resume(); + await expect(tx).to.emit(oracle, "Resumed").withArgs(); + }); + }); + + context("SUBMIT_DATA_ROLE", () => { + context("_checkMsgSenderIsAllowedToSubmitData", () => { + it("should revert from not consensus member without SUBMIT_DATA_ROLE role", async () => { + await expect( + oracle.connect(stranger).submitReportData(reportFields, oracleVersion), + ).to.be.revertedWithCustomError(oracle, "SenderNotAllowed"); + }); + + it("should allow calling from a possessor of SUBMIT_DATA_ROLE role", async () => { + await oracle.grantRole(await oracle.SUBMIT_DATA_ROLE(), account1); + const deadline = (await oracle.getConsensusReport()).processingDeadlineTime; + await consensus.setTime(deadline); + + const tx = await oracle.connect(account1).submitReportData(reportFields, oracleVersion); + + await expect(tx).to.emit(oracle, "ProcessingStarted").withArgs(reportFields.refSlot, reportHash); + }); + + it("should allow calling from a member", async () => { + const tx = await oracle.connect(member2).submitReportData(reportFields, oracleVersion); + + await expect(tx).to.emit(oracle, "ProcessingStarted").withArgs(reportFields.refSlot, reportHash); + }); + }); + }); +}); diff --git a/test/0.8.9/oracle/validator-exit-bus-oracle.deploy.test.ts b/test/0.8.9/oracle/validator-exit-bus-oracle.deploy.test.ts new file mode 100644 index 000000000..48dee32af --- /dev/null +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.deploy.test.ts @@ -0,0 +1,81 @@ +import { expect } from "chai"; +import { ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { HashConsensus__Harness, ValidatorsExitBus__Harness, ValidatorsExitBusOracle } from "typechain-types"; + +import { CONSENSUS_VERSION, SECONDS_PER_SLOT } from "lib"; + +import { deployVEBO, initVEBO } from "test/deploy"; + +describe("ValidatorsExitBusOracle.sol:deploy", () => { + context("Deployment and initial configuration", () => { + let admin: HardhatEthersSigner; + let defaultOracle: ValidatorsExitBusOracle; + + before(async () => { + [admin] = await ethers.getSigners(); + defaultOracle = (await deployVEBO(admin.address)).oracle; + }); + + it("initialize reverts if admin address is zero", async () => { + const deployed = await deployVEBO(admin.address); + + await expect( + deployed.oracle.initialize(ZeroAddress, await deployed.consensus.getAddress(), CONSENSUS_VERSION, 0), + ).to.be.revertedWithCustomError(defaultOracle, "AdminCannotBeZero"); + }); + + it("reverts when slotsPerSecond is zero", async () => { + await expect(deployVEBO(admin.address, { secondsPerSlot: 0n })).to.be.revertedWithCustomError( + defaultOracle, + "SecondsPerSlotCannotBeZero", + ); + }); + + context("deployment and init finishes successfully (default setup)", async () => { + let consensus: HashConsensus__Harness; + let oracle: ValidatorsExitBus__Harness; + + before(async () => { + const deployed = await deployVEBO(admin.address); + await initVEBO({ + admin: admin.address, + oracle: deployed.oracle, + consensus: deployed.consensus, + }); + + consensus = deployed.consensus; + oracle = deployed.oracle; + }); + + it("mock time-travellable setup is correct", async () => { + const time1 = await consensus.getTime(); + expect(await oracle.getTime()).to.equal(time1); + + await consensus.advanceTimeBy(SECONDS_PER_SLOT); + + const time2 = await consensus.getTime(); + expect(time2).to.equal(time1 + SECONDS_PER_SLOT); + expect(await oracle.getTime()).to.equal(time2); + }); + + it("initial configuration is correct", async () => { + expect(await oracle.getConsensusContract()).to.equal(await consensus.getAddress()); + expect(await oracle.getConsensusVersion()).to.equal(CONSENSUS_VERSION); + expect(await oracle.SECONDS_PER_SLOT()).to.equal(SECONDS_PER_SLOT); + expect(await oracle.isPaused()).to.equal(true); + }); + + it("pause/resume operations work", async () => { + expect(await oracle.isPaused()).to.equal(true); + await oracle.resume(); + expect(await oracle.isPaused()).to.equal(false); + await oracle.pauseFor(123); + expect(await oracle.isPaused()).to.equal(true); + }); + }); + }); +}); diff --git a/test/deploy/index.ts b/test/deploy/index.ts index d7afaf858..281dd47ab 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 "./validatorExitBusOracle"; diff --git a/test/deploy/validatorExitBusOracle.ts b/test/deploy/validatorExitBusOracle.ts new file mode 100644 index 000000000..1b5e0e280 --- /dev/null +++ b/test/deploy/validatorExitBusOracle.ts @@ -0,0 +1,120 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HashConsensus__Harness, ReportProcessor__Mock, ValidatorsExitBusOracle } from "typechain-types"; + +import { + CONSENSUS_VERSION, + EPOCHS_PER_FRAME, + GENESIS_TIME, + INITIAL_EPOCH, + SECONDS_PER_SLOT, + SLOTS_PER_EPOCH, +} from "lib"; + +import { deployHashConsensus } from "./hashConsensus"; +import { deployLidoLocator, updateLidoLocatorImplementation } from "./locator"; + +export const DATA_FORMAT_LIST = 1; + +async function deployMockAccountingOracle(secondsPerSlot = SECONDS_PER_SLOT, genesisTime = GENESIS_TIME) { + const lido = await ethers.deployContract("Lido__MockForAccountingOracle"); + const ao = await ethers.deployContract("AccountingOracle__MockForSanityChecker", [ + await lido.getAddress(), + secondsPerSlot, + genesisTime, + ]); + return { ao, lido }; +} + +async function deployOracleReportSanityCheckerForExitBus(lidoLocator: string, admin: string) { + const maxValidatorExitRequestsPerReport = 2000; + const limitsList = [0, 0, 0, 0, maxValidatorExitRequestsPerReport, 0, 0, 0, 0, 0, 0, 0]; + + return await ethers.deployContract("OracleReportSanityChecker", [lidoLocator, admin, limitsList]); +} + +export async function deployVEBO( + admin: string, + { + epochsPerFrame = EPOCHS_PER_FRAME, + secondsPerSlot = SECONDS_PER_SLOT, + slotsPerEpoch = SLOTS_PER_EPOCH, + genesisTime = GENESIS_TIME, + initialEpoch = INITIAL_EPOCH, + } = {}, +) { + const locator = await deployLidoLocator(); + const locatorAddr = await locator.getAddress(); + + const oracle = await ethers.deployContract("ValidatorsExitBus__Harness", [secondsPerSlot, genesisTime, locatorAddr]); + + const { consensus } = await deployHashConsensus(admin, { + reportProcessor: oracle as unknown as ReportProcessor__Mock, + epochsPerFrame, + secondsPerSlot, + genesisTime, + }); + + const { ao, lido } = await deployMockAccountingOracle(secondsPerSlot, genesisTime); + + await updateLidoLocatorImplementation(locatorAddr, { + lido: await lido.getAddress(), + accountingOracle: await ao.getAddress(), + }); + + const oracleReportSanityChecker = await deployOracleReportSanityCheckerForExitBus(locatorAddr, admin); + + await updateLidoLocatorImplementation(locatorAddr, { + validatorsExitBusOracle: await oracle.getAddress(), + oracleReportSanityChecker: await oracleReportSanityChecker.getAddress(), + }); + + await consensus.setTime(genesisTime + initialEpoch * slotsPerEpoch * secondsPerSlot); + + return { + locatorAddr, + oracle, + consensus, + oracleReportSanityChecker, + }; +} + +interface VEBOConfig { + admin: string; + oracle: ValidatorsExitBusOracle; + consensus: HashConsensus__Harness; + dataSubmitter?: string; + consensusVersion?: bigint; + lastProcessingRefSlot?: number; + resumeAfterDeploy?: boolean; +} + +export async function initVEBO({ + admin, + oracle, + consensus, + dataSubmitter = undefined, + consensusVersion = CONSENSUS_VERSION, + lastProcessingRefSlot = 0, + resumeAfterDeploy = false, +}: VEBOConfig) { + const initTx = await oracle.initialize(admin, await consensus.getAddress(), consensusVersion, lastProcessingRefSlot); + + await oracle.grantRole(await oracle.MANAGE_CONSENSUS_CONTRACT_ROLE(), admin); + await oracle.grantRole(await oracle.MANAGE_CONSENSUS_VERSION_ROLE(), admin); + await oracle.grantRole(await oracle.PAUSE_ROLE(), admin); + await oracle.grantRole(await oracle.RESUME_ROLE(), admin); + + if (dataSubmitter) { + await oracle.grantRole(await oracle.SUBMIT_DATA_ROLE(), dataSubmitter); + } + + expect(await oracle.DATA_FORMAT_LIST()).to.equal(DATA_FORMAT_LIST); + + if (resumeAfterDeploy) { + await oracle.resume(); + } + + return initTx; +} From 517076ef8d725fe8bbd30a5d5b91ef390ac76b1a Mon Sep 17 00:00:00 2001 From: Anna Mukharram Date: Fri, 10 Jan 2025 04:01:57 +0400 Subject: [PATCH 017/184] feat: vebo gas, submitReportData, happyPath tests --- ...ator-exit-bus-oracle.accessControl.test.ts | 4 +- .../validator-exit-bus-oracle.gas.test.ts | 243 +++++++++++++++ ...alidator-exit-bus-oracle.happyPath.test.ts | 256 ++++++++++++++++ ...r-exit-bus-oracle.submitReportData.test.ts | 278 ++++++++++++++++++ 4 files changed, 779 insertions(+), 2 deletions(-) create mode 100644 test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts create mode 100644 test/0.8.9/oracle/validator-exit-bus-oracle.happyPath.test.ts create mode 100644 test/0.8.9/oracle/validator-exit-bus-oracle.submitReportData.test.ts diff --git a/test/0.8.9/oracle/validator-exit-bus-oracle.accessControl.test.ts b/test/0.8.9/oracle/validator-exit-bus-oracle.accessControl.test.ts index e7463a013..53c0e1e29 100644 --- a/test/0.8.9/oracle/validator-exit-bus-oracle.accessControl.test.ts +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.accessControl.test.ts @@ -4,9 +4,9 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { HashConsensus__Harness, ValidatorsExitBus__Harness, ValidatorsExitBusOracle } from "typechain-types"; +import { HashConsensus__Harness, ValidatorsExitBus__Harness } from "typechain-types"; -import { CONSENSUS_VERSION, de0x, numberToHex, SECONDS_PER_SLOT } from "lib"; +import { CONSENSUS_VERSION, de0x, numberToHex } from "lib"; import { DATA_FORMAT_LIST, deployVEBO, initVEBO } from "test/deploy"; import { Snapshot } from "test/suite"; diff --git a/test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts b/test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts new file mode 100644 index 000000000..44a7080ba --- /dev/null +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts @@ -0,0 +1,243 @@ +import { expect } from "chai"; +import { ContractTransactionReceipt, ZeroHash } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { HashConsensus__Harness, ValidatorsExitBus__Harness } from "typechain-types"; + +import { trace } from "lib"; +import { CONSENSUS_VERSION, de0x, numberToHex } from "lib"; + +import { + computeTimestampAtSlot, + DATA_FORMAT_LIST, + deployVEBO, + initVEBO, + SECONDS_PER_FRAME, + SLOTS_PER_FRAME, +} from "test/deploy"; +import { Snapshot } from "test/suite"; + +const PUBKEYS = [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", +]; + +describe("ValidatorsExitBusOracle.sol:gas", () => { + let consensus: HashConsensus__Harness; + let oracle: ValidatorsExitBus__Harness; + let admin: HardhatEthersSigner; + + let oracleVersion: bigint; + + let member1: HardhatEthersSigner; + let member2: HardhatEthersSigner; + let member3: HardhatEthersSigner; + + const NUM_MODULES = 5; + const NODE_OPS_PER_MODULE = 100; + + let nextValIndex = 1; + + interface ExitRequest { + moduleId: number; + nodeOpId: number; + valIndex: number; + valPubkey: string; + } + + interface ReportFields { + consensusVersion: bigint; + refSlot: bigint; + requestsCount: number; + dataFormat: number; + data: string; + } + + const calcValidatorsExitBusReportDataHash = (items: ReturnType) => { + const data = ethers.AbiCoder.defaultAbiCoder().encode(["(uint256,uint256,uint256,uint256,bytes)"], [items]); + return ethers.keccak256(data); + }; + + const getValidatorsExitBusReportDataItems = (r: ReportFields) => { + return [r.consensusVersion, r.refSlot, r.requestsCount, r.dataFormat, r.data]; + }; + + const encodeExitRequestHex = ({ moduleId, nodeOpId, valIndex, valPubkey }: ExitRequest) => { + const pubkeyHex = de0x(valPubkey); + expect(pubkeyHex.length).to.equal(48 * 2); + return numberToHex(moduleId, 3) + numberToHex(nodeOpId, 5) + numberToHex(valIndex, 8) + pubkeyHex; + }; + + const encodeExitRequestsDataList = (requests: ExitRequest[]) => { + return "0x" + requests.map(encodeExitRequestHex).join(""); + }; + + const deploy = async () => { + const deployed = await deployVEBO(admin.address); + oracle = deployed.oracle; + consensus = deployed.consensus; + + await initVEBO({ + admin: admin.address, + oracle, + consensus, + resumeAfterDeploy: true, + }); + + oracleVersion = await oracle.getContractVersion(); + + await consensus.addMember(member1, 1); + await consensus.addMember(member2, 2); + await consensus.addMember(member3, 2); + }; + + const triggerConsensusOnHash = async (hash: string) => { + const { refSlot } = await consensus.getCurrentFrame(); + await consensus.connect(member1).submitReport(refSlot, hash, CONSENSUS_VERSION); + await consensus.connect(member3).submitReport(refSlot, hash, CONSENSUS_VERSION); + expect((await consensus.getConsensusState()).consensusReport).to.equal(hash); + }; + + const generateExitRequests = (totalRequests: number) => { + const requestsPerModule = Math.max(1, Math.floor(totalRequests / NUM_MODULES)); + const requestsPerNodeOp = Math.max(1, Math.floor(requestsPerModule / NODE_OPS_PER_MODULE)); + + const requests = []; + + for (let i = 0; i < totalRequests; ++i) { + const moduleId = Math.floor(i / requestsPerModule); + const nodeOpId = Math.floor((i - moduleId * requestsPerModule) / requestsPerNodeOp); + const valIndex = nextValIndex++; + const valPubkey = PUBKEYS[valIndex % PUBKEYS.length]; + requests.push({ moduleId: moduleId + 1, nodeOpId, valIndex, valPubkey }); + } + + return { requests, requestsPerModule, requestsPerNodeOp }; + }; + + const gasUsages: { totalRequests: number; requestsPerModule: number; requestsPerNodeOp: number; gasUsed: number }[] = + []; + + before(async () => { + [admin, member1, member2, member3] = await ethers.getSigners(); + await deploy(); + }); + + after(async () => { + gasUsages.forEach(({ totalRequests, requestsPerModule, requestsPerNodeOp, gasUsed }) => + console.log( + `${totalRequests} requests (per module ${requestsPerModule}, ` + + `per node op ${requestsPerNodeOp}): total gas ${gasUsed}, ` + + `gas per request: ${Math.round(gasUsed / totalRequests)}`, + ), + ); + }); + + for (const totalRequests of [10, 50, 100, 1000, 2000]) { + context(`Total requests: ${totalRequests}`, () => { + let exitRequests: { requests: ExitRequest[]; requestsPerModule: number; requestsPerNodeOp: number }; + let reportFields: ReportFields; + let reportItems: ReturnType; + let reportHash: string; + let originalState: string; + + before(async () => { + originalState = await Snapshot.take(); + }); + + after(async () => await Snapshot.restore(originalState)); + + it("initially, consensus report is not being processed", async () => { + const { refSlot } = await consensus.getCurrentFrame(); + + const report = await oracle.getConsensusReport(); + expect(refSlot).to.above(report.refSlot); + + const procState = await oracle.getProcessingState(); + expect(procState.dataHash, ZeroHash); + expect(procState.dataSubmitted).to.equal(false); + }); + + it("committee reaches consensus on a report hash", async () => { + const { refSlot } = await consensus.getCurrentFrame(); + + exitRequests = generateExitRequests(totalRequests); + + reportFields = { + consensusVersion: CONSENSUS_VERSION, + refSlot: refSlot, + requestsCount: exitRequests.requests.length, + dataFormat: DATA_FORMAT_LIST, + data: encodeExitRequestsDataList(exitRequests.requests), + }; + + reportItems = getValidatorsExitBusReportDataItems(reportFields); + reportHash = calcValidatorsExitBusReportDataHash(reportItems); + + await triggerConsensusOnHash(reportHash); + }); + + it("oracle gets the report hash", async () => { + const report = await oracle.getConsensusReport(); + expect(report.hash).to.equal(reportHash); + expect(report.refSlot).to.equal(reportFields.refSlot); + expect(report.processingDeadlineTime).to.equal(computeTimestampAtSlot(report.refSlot + SLOTS_PER_FRAME)); + expect(report.processingStarted).to.equal(false); + + const procState = await oracle.getProcessingState(); + expect(procState.dataHash).to.equal(reportHash); + expect(procState.dataSubmitted).to.equal(false); + expect(procState.dataFormat).to.equal(0); + expect(procState.requestsCount).to.equal(0); + expect(procState.requestsSubmitted).to.equal(0); + }); + + it("some time passes", async () => { + await consensus.advanceTimeBy(SECONDS_PER_FRAME / 3n); + }); + + it(`a committee member submits the report data, exit requests are emitted`, async () => { + const tx = await oracle.connect(member1).submitReportData(reportFields, oracleVersion); + const receipt = await trace("oracle.submit", tx); + await expect(tx).to.emit(oracle, "ProcessingStarted").withArgs(reportFields.refSlot, reportHash); + expect((await oracle.getConsensusReport()).processingStarted).to.equal(true); + + const timestamp = await oracle.getTime(); + + for (const request of exitRequests.requests) { + await expect(tx) + .to.emit(oracle, "ValidatorExitRequest") + .withArgs(request.moduleId, request.nodeOpId, request.valIndex, request.valPubkey, timestamp); + } + const { gasUsed } = receipt; + gasUsages.push({ + totalRequests, + requestsPerModule: exitRequests.requestsPerModule, + requestsPerNodeOp: exitRequests.requestsPerNodeOp, + gasUsed: Number(gasUsed), + }); + }); + + it(`reports are marked as processed`, async () => { + const procState = await oracle.getProcessingState(); + expect(procState.dataHash).to.equal(reportHash); + expect(procState.dataSubmitted).to.equal(true); + expect(procState.dataFormat).to.equal(DATA_FORMAT_LIST); + expect(procState.requestsCount).to.equal(exitRequests.requests.length); + expect(procState.requestsSubmitted).to.equal(exitRequests.requests.length); + }); + + it("some time passes", async () => { + const prevFrame = await consensus.getCurrentFrame(); + await consensus.advanceTimeBy(SECONDS_PER_FRAME - SECONDS_PER_FRAME / 3n); + const newFrame = await consensus.getCurrentFrame(); + expect(newFrame.refSlot).to.above(prevFrame.refSlot); + }); + }); + } +}); diff --git a/test/0.8.9/oracle/validator-exit-bus-oracle.happyPath.test.ts b/test/0.8.9/oracle/validator-exit-bus-oracle.happyPath.test.ts new file mode 100644 index 000000000..615050cf4 --- /dev/null +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.happyPath.test.ts @@ -0,0 +1,256 @@ +import { expect } from "chai"; +import { ZeroHash } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { HashConsensus__Harness, ValidatorsExitBus__Harness } from "typechain-types"; + +import { CONSENSUS_VERSION, de0x, numberToHex } from "lib"; + +import { + computeTimestampAtSlot, + DATA_FORMAT_LIST, + deployVEBO, + initVEBO, + SECONDS_PER_FRAME, + SLOTS_PER_FRAME, +} from "test/deploy"; + +const PUBKEYS = [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", +]; + +describe("ValidatorsExitBusOracle.sol:happyPath", () => { + let consensus: HashConsensus__Harness; + let oracle: ValidatorsExitBus__Harness; + let admin: HardhatEthersSigner; + + let oracleVersion: bigint; + let exitRequests: ExitRequest[]; + let reportFields: ReportFields; + let reportItems: ReturnType; + let reportHash: string; + + let member1: HardhatEthersSigner; + let member2: HardhatEthersSigner; + let member3: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + + const LAST_PROCESSING_REF_SLOT = 1; + + interface ExitRequest { + moduleId: number; + nodeOpId: number; + valIndex: number; + valPubkey: string; + } + + interface ReportFields { + consensusVersion: bigint; + refSlot: bigint; + requestsCount: number; + dataFormat: number; + data: string; + } + + const calcValidatorsExitBusReportDataHash = (items: ReturnType) => { + const data = ethers.AbiCoder.defaultAbiCoder().encode(["(uint256,uint256,uint256,uint256,bytes)"], [items]); + return ethers.keccak256(data); + }; + + const getValidatorsExitBusReportDataItems = (r: ReportFields) => { + return [r.consensusVersion, r.refSlot, r.requestsCount, r.dataFormat, r.data]; + }; + + const encodeExitRequestHex = ({ moduleId, nodeOpId, valIndex, valPubkey }: ExitRequest) => { + const pubkeyHex = de0x(valPubkey); + expect(pubkeyHex.length).to.equal(48 * 2); + return numberToHex(moduleId, 3) + numberToHex(nodeOpId, 5) + numberToHex(valIndex, 8) + pubkeyHex; + }; + + const encodeExitRequestsDataList = (requests: ExitRequest[]) => { + return "0x" + requests.map(encodeExitRequestHex).join(""); + }; + + const deploy = async () => { + const deployed = await deployVEBO(admin.address); + oracle = deployed.oracle; + consensus = deployed.consensus; + + await initVEBO({ + admin: admin.address, + oracle, + consensus, + resumeAfterDeploy: true, + lastProcessingRefSlot: LAST_PROCESSING_REF_SLOT, + }); + + oracleVersion = await oracle.getContractVersion(); + + await consensus.addMember(member1, 1); + await consensus.addMember(member2, 2); + await consensus.addMember(member3, 2); + }; + + before(async () => { + [admin, member1, member2, member3, stranger] = await ethers.getSigners(); + + await deploy(); + }); + + const triggerConsensusOnHash = async (hash: string) => { + const { refSlot } = await consensus.getCurrentFrame(); + await consensus.connect(member1).submitReport(refSlot, hash, CONSENSUS_VERSION); + await consensus.connect(member3).submitReport(refSlot, hash, CONSENSUS_VERSION); + expect((await consensus.getConsensusState()).consensusReport).to.equal(hash); + }; + + it("initially, consensus report is empty and is not being processed", async () => { + const report = await oracle.getConsensusReport(); + expect(report.hash).to.equal(ZeroHash); + + expect(report.processingDeadlineTime).to.equal(0); + expect(report.processingStarted).to.equal(false); + + const frame = await consensus.getCurrentFrame(); + const procState = await oracle.getProcessingState(); + + expect(procState.currentFrameRefSlot).to.equal(frame.refSlot); + expect(procState.dataHash).to.equal(ZeroHash); + expect(procState.processingDeadlineTime).to.equal(0); + expect(procState.dataSubmitted).to.equal(false); + expect(procState.dataFormat).to.equal(0); + expect(procState.requestsCount).to.equal(0); + expect(procState.requestsSubmitted).to.equal(0); + }); + + it("reference slot of the empty initial consensus report is set to the last processing slot passed to the initialize function", async () => { + const report = await oracle.getConsensusReport(); + expect(report.refSlot).to.equal(LAST_PROCESSING_REF_SLOT); + }); + + it("committee reaches consensus on a report hash", async () => { + const { refSlot } = await consensus.getCurrentFrame(); + + exitRequests = [ + { moduleId: 1, nodeOpId: 0, valIndex: 0, valPubkey: PUBKEYS[0] }, + { moduleId: 1, nodeOpId: 0, valIndex: 2, valPubkey: PUBKEYS[1] }, + { moduleId: 2, nodeOpId: 0, valIndex: 1, valPubkey: PUBKEYS[2] }, + ]; + + reportFields = { + consensusVersion: CONSENSUS_VERSION, + refSlot: refSlot, + requestsCount: exitRequests.length, + dataFormat: DATA_FORMAT_LIST, + data: encodeExitRequestsDataList(exitRequests), + }; + + reportItems = getValidatorsExitBusReportDataItems(reportFields); + reportHash = calcValidatorsExitBusReportDataHash(reportItems); + + await triggerConsensusOnHash(reportHash); + }); + + it("oracle gets the report hash", async () => { + const report = await oracle.getConsensusReport(); + expect(report.hash).to.equal(reportHash); + expect(report.refSlot).to.equal(reportFields.refSlot); + expect(report.processingDeadlineTime).to.equal(computeTimestampAtSlot(report.refSlot + SLOTS_PER_FRAME)); + + expect(report.processingStarted).to.equal(false); + + const frame = await consensus.getCurrentFrame(); + const procState = await oracle.getProcessingState(); + + expect(procState.currentFrameRefSlot).to.equal(frame.refSlot); + expect(procState.dataHash).to.equal(reportHash); + expect(procState.processingDeadlineTime).to.equal(computeTimestampAtSlot(frame.reportProcessingDeadlineSlot)); + expect(procState.dataSubmitted).to.equal(false); + expect(procState.dataFormat).to.equal(0); + expect(procState.requestsCount).to.equal(0); + expect(procState.requestsSubmitted).to.equal(0); + }); + + it("some time passes", async () => { + await consensus.advanceTimeBy(SECONDS_PER_FRAME / 3n); + }); + + it("non-member cannot submit the data", async () => { + await expect(oracle.connect(stranger).submitReportData(reportFields, oracleVersion)).to.be.revertedWithCustomError( + oracle, + "SenderNotAllowed", + ); + }); + + it("the data cannot be submitted passing a different contract version", async () => { + await expect(oracle.connect(member1).submitReportData(reportFields, oracleVersion - 1n)) + .to.be.revertedWithCustomError(oracle, "UnexpectedContractVersion") + .withArgs(oracleVersion, oracleVersion - 1n); + }); + + it("the data cannot be submitted passing a different consensus version", async () => { + const invalidReport = { ...reportFields, consensusVersion: CONSENSUS_VERSION + 1n }; + await expect(oracle.connect(member1).submitReportData(invalidReport, oracleVersion)) + .to.be.revertedWithCustomError(oracle, "UnexpectedConsensusVersion") + .withArgs(CONSENSUS_VERSION, CONSENSUS_VERSION + 1n); + }); + + it("a data not matching the consensus hash cannot be submitted", async () => { + const invalidReport = { ...reportFields, requestsCount: reportFields.requestsCount + 1 }; + const invalidReportItems = getValidatorsExitBusReportDataItems(invalidReport); + const invalidReportHash = calcValidatorsExitBusReportDataHash(invalidReportItems); + + await expect(oracle.connect(member1).submitReportData(invalidReport, oracleVersion)) + .to.be.revertedWithCustomError(oracle, "UnexpectedDataHash") + .withArgs(reportHash, invalidReportHash); + }); + + it("a committee member submits the report data, exit requests are emitted", async () => { + const tx = await oracle.connect(member1).submitReportData(reportFields, oracleVersion); + + await expect(tx).to.emit(oracle, "ProcessingStarted").withArgs(reportFields.refSlot, reportHash); + expect((await oracle.getConsensusReport()).processingStarted).to.equal(true); + + const timestamp = await oracle.getTime(); + + for (const request of exitRequests) { + await expect(tx) + .to.emit(oracle, "ValidatorExitRequest") + .withArgs(request.moduleId, request.nodeOpId, request.valIndex, request.valPubkey, timestamp); + } + }); + + it("reports are marked as processed", async () => { + const frame = await consensus.getCurrentFrame(); + const procState = await oracle.getProcessingState(); + + expect(procState.currentFrameRefSlot).to.equal(frame.refSlot); + expect(procState.dataHash).to.equal(reportHash); + expect(procState.processingDeadlineTime).to.equal(computeTimestampAtSlot(frame.reportProcessingDeadlineSlot)); + expect(procState.dataSubmitted).to.equal(true); + expect(procState.dataFormat).to.equal(DATA_FORMAT_LIST); + expect(procState.requestsCount).to.equal(exitRequests.length); + expect(procState.requestsSubmitted).to.equal(exitRequests.length); + }); + + it("last requested validator indices are updated", async () => { + const indices1 = await oracle.getLastRequestedValidatorIndices(1n, [0n, 1n, 2n]); + const indices2 = await oracle.getLastRequestedValidatorIndices(2n, [0n, 1n, 2n]); + + expect([...indices1]).to.have.ordered.members([2n, -1n, -1n]); + expect([...indices2]).to.have.ordered.members([1n, -1n, -1n]); + }); + + it("no data can be submitted for the same reference slot again", async () => { + await expect(oracle.connect(member2).submitReportData(reportFields, oracleVersion)).to.be.revertedWithCustomError( + oracle, + "RefSlotAlreadyProcessing", + ); + }); +}); diff --git a/test/0.8.9/oracle/validator-exit-bus-oracle.submitReportData.test.ts b/test/0.8.9/oracle/validator-exit-bus-oracle.submitReportData.test.ts new file mode 100644 index 000000000..176233135 --- /dev/null +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.submitReportData.test.ts @@ -0,0 +1,278 @@ +import { expect } from "chai"; +import { ContractTransactionReceipt, ZeroHash } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { HashConsensus__Harness, OracleReportSanityChecker, ValidatorsExitBus__Harness } from "typechain-types"; + +import { CONSENSUS_VERSION, de0x, numberToHex } from "lib"; + +import { + computeTimestampAtSlot, + DATA_FORMAT_LIST, + deployVEBO, + initVEBO, + SECONDS_PER_FRAME, + SLOTS_PER_FRAME, +} from "test/deploy"; +import { Snapshot } from "test/suite"; + +const PUBKEYS = [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", +]; +const HASH_1 = "0x1111111111111111111111111111111111111111111111111111111111111111"; + +describe("ValidatorsExitBusOracle.sol:submitReportData", () => { + let consensus: HashConsensus__Harness; + let oracle: ValidatorsExitBus__Harness; + let admin: HardhatEthersSigner; + let oracleReportSanityChecker: OracleReportSanityChecker; + + let oracleVersion: bigint; + + let member1: HardhatEthersSigner; + let member2: HardhatEthersSigner; + let member3: HardhatEthersSigner; + + const LAST_PROCESSING_REF_SLOT = 1; + + interface ExitRequest { + moduleId: number; + nodeOpId: number; + valIndex: number; + valPubkey: string; + } + + interface ReportFields { + consensusVersion: bigint; + refSlot: bigint; + requestsCount: number; + dataFormat: number; + data: string; + } + + const calcValidatorsExitBusReportDataHash = (items: ReturnType) => { + const data = ethers.AbiCoder.defaultAbiCoder().encode(["(uint256,uint256,uint256,uint256,bytes)"], [items]); + return ethers.keccak256(data); + }; + + const getValidatorsExitBusReportDataItems = (r: ReportFields) => { + return [r.consensusVersion, r.refSlot, r.requestsCount, r.dataFormat, r.data]; + }; + + const encodeExitRequestHex = ({ moduleId, nodeOpId, valIndex, valPubkey }: ExitRequest) => { + const pubkeyHex = de0x(valPubkey); + expect(pubkeyHex.length).to.equal(48 * 2); + return numberToHex(moduleId, 3) + numberToHex(nodeOpId, 5) + numberToHex(valIndex, 8) + pubkeyHex; + }; + + const encodeExitRequestsDataList = (requests: ExitRequest[]) => { + return "0x" + requests.map(encodeExitRequestHex).join(""); + }; + + const triggerConsensusOnHash = async (hash: string) => { + const { refSlot } = await consensus.getCurrentFrame(); + await consensus.connect(member1).submitReport(refSlot, hash, CONSENSUS_VERSION); + await consensus.connect(member3).submitReport(refSlot, hash, CONSENSUS_VERSION); + expect((await consensus.getConsensusState()).consensusReport).to.equal(hash); + }; + + const prepareReportAndSubmitHash = async ( + requests = [{ moduleId: 5, nodeOpId: 1, valIndex: 10, valPubkey: PUBKEYS[2] }], + options = { reportFields: {} }, + ) => { + const { refSlot } = await consensus.getCurrentFrame(); + + const reportData = { + consensusVersion: CONSENSUS_VERSION, + dataFormat: DATA_FORMAT_LIST, + refSlot, + requestsCount: requests.length, + data: encodeExitRequestsDataList(requests), + ...options.reportFields, + }; + + const reportItems = getValidatorsExitBusReportDataItems(reportData); + const reportHash = calcValidatorsExitBusReportDataHash(reportItems); + + await triggerConsensusOnHash(reportHash); + + return { reportData, reportHash }; + }; + + const deploy = async () => { + const deployed = await deployVEBO(admin.address); + oracle = deployed.oracle; + consensus = deployed.consensus; + oracleReportSanityChecker = deployed.oracleReportSanityChecker; + + await initVEBO({ + admin: admin.address, + oracle, + consensus, + resumeAfterDeploy: true, + lastProcessingRefSlot: LAST_PROCESSING_REF_SLOT, + }); + + oracleVersion = await oracle.getContractVersion(); + + await consensus.addMember(member1, 1); + await consensus.addMember(member2, 2); + await consensus.addMember(member3, 2); + }; + + before(async () => { + [admin, member1, member2, member3] = await ethers.getSigners(); + + await deploy(); + }); + + context("discarded report prevents data submit", () => { + let reportData: ReportFields; + let reportHash: string; + let originalState: string; + + before(async () => { + originalState = await Snapshot.take(); + }); + + after(async () => await Snapshot.restore(originalState)); + + it("report is discarded", async () => { + ({ reportData, reportHash } = await prepareReportAndSubmitHash()); + const { refSlot } = await consensus.getCurrentFrame(); + + // change of mind + const tx = await consensus.connect(member3).submitReport(refSlot, HASH_1, CONSENSUS_VERSION); + + await expect(tx).to.emit(oracle, "ReportDiscarded").withArgs(refSlot, reportHash); + }); + + it("processing state reverts to pre-report state ", async () => { + const state = await oracle.getProcessingState(); + expect(state.dataHash).to.equal(ZeroHash); + expect(state.dataSubmitted).to.equal(false); + expect(state.dataFormat).to.equal(0); + expect(state.requestsCount).to.equal(0); + expect(state.requestsSubmitted).to.equal(0); + }); + + it("reverts on trying to submit the discarded report", async () => { + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion)) + .to.be.revertedWithCustomError(oracle, "UnexpectedDataHash") + .withArgs(ZeroHash, reportHash); + }); + }); + + context("_handleConsensusReportData", () => { + let originalState: string; + + beforeEach(async () => { + originalState = await Snapshot.take(); + await consensus.advanceTimeToNextFrameStart(); + }); + + afterEach(async () => await Snapshot.restore(originalState)); + + context("enforces data format", () => { + it("dataFormat = 0 reverts", async () => { + const dataFormatUnsupported = 0; + const { reportData } = await prepareReportAndSubmitHash( + [{ moduleId: 5, nodeOpId: 3, valIndex: 0, valPubkey: PUBKEYS[0] }], + { reportFields: { dataFormat: dataFormatUnsupported } }, + ); + + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion)) + .to.be.revertedWithCustomError(oracle, "UnsupportedRequestsDataFormat") + .withArgs(dataFormatUnsupported); + }); + + it("dataFormat = 2 reverts", async () => { + const dataFormatUnsupported = 2; + const { reportData } = await prepareReportAndSubmitHash( + [{ moduleId: 5, nodeOpId: 3, valIndex: 0, valPubkey: PUBKEYS[0] }], + { reportFields: { dataFormat: dataFormatUnsupported } }, + ); + + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion)) + .to.be.revertedWithCustomError(oracle, "UnsupportedRequestsDataFormat") + .withArgs(dataFormatUnsupported); + }); + + it("dataFormat = 1 pass", async () => { + const { reportData } = await prepareReportAndSubmitHash([ + { moduleId: 5, nodeOpId: 3, valIndex: 0, valPubkey: PUBKEYS[0] }, + ]); + await oracle.connect(member1).submitReportData(reportData, oracleVersion); + }); + }); + + context("enforces data length", () => { + it("reverts if there is more data than expected", async () => { + const { refSlot } = await consensus.getCurrentFrame(); + const exitRequests = [{ moduleId: 5, nodeOpId: 3, valIndex: 0, valPubkey: PUBKEYS[0] }]; + const { reportData } = await prepareReportAndSubmitHash(exitRequests, { + reportFields: { data: encodeExitRequestsDataList(exitRequests) + "aaaaaaaaaaaaaaaaaa", refSlot }, + }); + + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion)).to.be.revertedWithCustomError( + oracle, + "InvalidRequestsDataLength", + ); + }); + + it("reverts if there is less data than expected", async () => { + const { refSlot } = await consensus.getCurrentFrame(); + const exitRequests = [{ moduleId: 5, nodeOpId: 3, valIndex: 0, valPubkey: PUBKEYS[0] }]; + const data = encodeExitRequestsDataList(exitRequests); + + const { reportData } = await prepareReportAndSubmitHash(exitRequests, { + reportFields: { + data: data.slice(0, data.length - 18), + refSlot, + }, + }); + + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion)).to.be.revertedWithCustomError( + oracle, + "InvalidRequestsDataLength", + ); + }); + + it("pass if there is exact amount of data", async () => { + const { reportData } = await prepareReportAndSubmitHash([ + { moduleId: 5, nodeOpId: 3, valIndex: 0, valPubkey: PUBKEYS[0] }, + ]); + await oracle.connect(member1).submitReportData(reportData, oracleVersion); + }); + }); + + context("invokes sanity check", () => { + // it("reverts if request limit is reached", async () => { + // const exitRequestsLimit = 1; + // await oracleReportSanityChecker.setMaxExitRequestsPerOracleReport(exitRequestsLimit); + // const report = await prepareReportAndSubmitHash([ + // { moduleId: 5, nodeOpId: 3, valIndex: 2, valPubkey: PUBKEYS[2] }, + // { moduleId: 5, nodeOpId: 3, valIndex: 2, valPubkey: PUBKEYS[3] }, + // ]); + // await assert.reverts( + // oracle.submitReportData(report, oracleVersion, { from: member1 }), + // `IncorrectNumberOfExitRequestsPerReport(${exitRequestsLimit})`, + // ); + // }); + // it("pass if requests amount equals to limit", async () => { + // const exitRequestsLimit = 1; + // await oracleReportSanityChecker.setMaxExitRequestsPerOracleReport(exitRequestsLimit); + // const report = await prepareReportAndSubmitHash([ + // { moduleId: 5, nodeOpId: 3, valIndex: 2, valPubkey: PUBKEYS[2] }, + // ]); + // await oracle.submitReportData(report, oracleVersion, { from: member1 }); + // }); + }); + }); +}); From 8fa4443a1d542483fc96bcde163f0709d4f6bbe4 Mon Sep 17 00:00:00 2001 From: Anna Mukharram Date: Fri, 10 Jan 2025 14:50:55 +0400 Subject: [PATCH 018/184] fix: submitReportData method tests --- ...r-exit-bus-oracle.submitReportData.test.ts | 481 ++++++++++++++++-- 1 file changed, 450 insertions(+), 31 deletions(-) diff --git a/test/0.8.9/oracle/validator-exit-bus-oracle.submitReportData.test.ts b/test/0.8.9/oracle/validator-exit-bus-oracle.submitReportData.test.ts index 176233135..5a79f8ab1 100644 --- a/test/0.8.9/oracle/validator-exit-bus-oracle.submitReportData.test.ts +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.submitReportData.test.ts @@ -1,5 +1,5 @@ import { expect } from "chai"; -import { ContractTransactionReceipt, ZeroHash } from "ethers"; +import { ZeroHash } from "ethers"; import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; @@ -8,14 +8,7 @@ import { HashConsensus__Harness, OracleReportSanityChecker, ValidatorsExitBus__H import { CONSENSUS_VERSION, de0x, numberToHex } from "lib"; -import { - computeTimestampAtSlot, - DATA_FORMAT_LIST, - deployVEBO, - initVEBO, - SECONDS_PER_FRAME, - SLOTS_PER_FRAME, -} from "test/deploy"; +import { computeTimestampAtSlot, DATA_FORMAT_LIST, deployVEBO, initVEBO } from "test/deploy"; import { Snapshot } from "test/suite"; const PUBKEYS = [ @@ -38,6 +31,7 @@ describe("ValidatorsExitBusOracle.sol:submitReportData", () => { let member1: HardhatEthersSigner; let member2: HardhatEthersSigner; let member3: HardhatEthersSigner; + let stranger: HardhatEthersSigner; const LAST_PROCESSING_REF_SLOT = 1; @@ -102,9 +96,13 @@ describe("ValidatorsExitBusOracle.sol:submitReportData", () => { await triggerConsensusOnHash(reportHash); - return { reportData, reportHash }; + return { reportData, reportHash, reportItems }; }; + async function getLastRequestedValidatorIndex(moduleId: number, nodeOpId: number) { + return (await oracle.getLastRequestedValidatorIndices(moduleId, [nodeOpId]))[0]; + } + const deploy = async () => { const deployed = await deployVEBO(admin.address); oracle = deployed.oracle; @@ -127,7 +125,7 @@ describe("ValidatorsExitBusOracle.sol:submitReportData", () => { }; before(async () => { - [admin, member1, member2, member3] = await ethers.getSigners(); + [admin, member1, member2, member3, stranger] = await ethers.getSigners(); await deploy(); }); @@ -253,26 +251,447 @@ describe("ValidatorsExitBusOracle.sol:submitReportData", () => { }); context("invokes sanity check", () => { - // it("reverts if request limit is reached", async () => { - // const exitRequestsLimit = 1; - // await oracleReportSanityChecker.setMaxExitRequestsPerOracleReport(exitRequestsLimit); - // const report = await prepareReportAndSubmitHash([ - // { moduleId: 5, nodeOpId: 3, valIndex: 2, valPubkey: PUBKEYS[2] }, - // { moduleId: 5, nodeOpId: 3, valIndex: 2, valPubkey: PUBKEYS[3] }, - // ]); - // await assert.reverts( - // oracle.submitReportData(report, oracleVersion, { from: member1 }), - // `IncorrectNumberOfExitRequestsPerReport(${exitRequestsLimit})`, - // ); - // }); - // it("pass if requests amount equals to limit", async () => { - // const exitRequestsLimit = 1; - // await oracleReportSanityChecker.setMaxExitRequestsPerOracleReport(exitRequestsLimit); - // const report = await prepareReportAndSubmitHash([ - // { moduleId: 5, nodeOpId: 3, valIndex: 2, valPubkey: PUBKEYS[2] }, - // ]); - // await oracle.submitReportData(report, oracleVersion, { from: member1 }); - // }); + before(async () => { + await oracleReportSanityChecker.grantRole( + await oracleReportSanityChecker.MAX_VALIDATOR_EXIT_REQUESTS_PER_REPORT_ROLE(), + admin.address, + ); + }); + + it("reverts if request limit is reached", async () => { + const exitRequestsLimit = 1; + await oracleReportSanityChecker.connect(admin).setMaxExitRequestsPerOracleReport(exitRequestsLimit); + const { reportData } = await prepareReportAndSubmitHash([ + { moduleId: 5, nodeOpId: 3, valIndex: 2, valPubkey: PUBKEYS[2] }, + { moduleId: 5, nodeOpId: 3, valIndex: 2, valPubkey: PUBKEYS[3] }, + ]); + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion)) + .to.be.revertedWithCustomError(oracleReportSanityChecker, "IncorrectNumberOfExitRequestsPerReport") + .withArgs(exitRequestsLimit); + }); + it("pass if requests amount equals to limit", async () => { + const exitRequestsLimit = 1; + await oracleReportSanityChecker.connect(admin).setMaxExitRequestsPerOracleReport(exitRequestsLimit); + const { reportData } = await prepareReportAndSubmitHash([ + { moduleId: 5, nodeOpId: 3, valIndex: 2, valPubkey: PUBKEYS[2] }, + ]); + await oracle.connect(member1).submitReportData(reportData, oracleVersion); + }); + }); + + context("validates data.requestsCount field with given data", () => { + it("reverts if requestsCount does not match with encoded data size", async () => { + const { reportData } = await prepareReportAndSubmitHash( + [{ moduleId: 5, nodeOpId: 3, valIndex: 0, valPubkey: PUBKEYS[0] }], + { reportFields: { requestsCount: 2 } }, + ); + + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion)).to.be.revertedWithCustomError( + oracle, + "UnexpectedRequestsDataLength", + ); + }); + }); + + it("reverts if moduleId equals zero", async () => { + const { reportData } = await prepareReportAndSubmitHash([ + { moduleId: 0, nodeOpId: 3, valIndex: 0, valPubkey: PUBKEYS[0] }, + ]); + + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion)).to.be.revertedWithCustomError( + oracle, + "InvalidRequestsData", + ); + }); + + it("emits ValidatorExitRequest events", async () => { + const requests = [ + { moduleId: 4, nodeOpId: 2, valIndex: 2, valPubkey: PUBKEYS[2] }, + { moduleId: 5, nodeOpId: 3, valIndex: 2, valPubkey: PUBKEYS[3] }, + ]; + const { reportData } = await prepareReportAndSubmitHash(requests); + const tx = await oracle.connect(member1).submitReportData(reportData, oracleVersion); + const timestamp = await consensus.getTime(); + + await expect(tx) + .to.emit(oracle, "ValidatorExitRequest") + .withArgs(requests[0].moduleId, requests[0].nodeOpId, requests[0].valIndex, requests[0].valPubkey, timestamp); + + await expect(tx) + .to.emit(oracle, "ValidatorExitRequest") + .withArgs(requests[1].moduleId, requests[1].nodeOpId, requests[1].valIndex, requests[1].valPubkey, timestamp); + }); + + it("updates processing state", async () => { + const storageBefore = await oracle.getDataProcessingState(); + expect(storageBefore.refSlot).to.equal(0); + expect(storageBefore.requestsCount).to.equal(0); + + expect(storageBefore.requestsProcessed).to.equal(0); + expect(storageBefore.dataFormat).to.equal(0); + + const { refSlot } = await consensus.getCurrentFrame(); + const requests = [ + { moduleId: 4, nodeOpId: 3, valIndex: 2, valPubkey: PUBKEYS[2] }, + { moduleId: 5, nodeOpId: 3, valIndex: 2, valPubkey: PUBKEYS[3] }, + ]; + const { reportData } = await prepareReportAndSubmitHash(requests); + await oracle.connect(member1).submitReportData(reportData, oracleVersion); + + const storageAfter = await oracle.getDataProcessingState(); + expect(storageAfter.refSlot).to.equal(refSlot); + expect(storageAfter.requestsCount).to.equal(requests.length); + expect(storageAfter.requestsProcessed).to.equal(requests.length); + expect(storageAfter.dataFormat).to.equal(DATA_FORMAT_LIST); + }); + + it("updates total requests processed count", async () => { + let currentCount = 0; + const countStep0 = await oracle.getTotalRequestsProcessed(); + expect(countStep0).to.equal(currentCount); + + // Step 1 — process 1 item + const requestsStep1 = [{ moduleId: 3, nodeOpId: 1, valIndex: 2, valPubkey: PUBKEYS[1] }]; + const { reportData: reportStep1 } = await prepareReportAndSubmitHash(requestsStep1); + await oracle.connect(member1).submitReportData(reportStep1, oracleVersion); + const countStep1 = await oracle.getTotalRequestsProcessed(); + currentCount += requestsStep1.length; + expect(countStep1).to.equal(currentCount); + + // Step 2 — process 2 items + await consensus.advanceTimeToNextFrameStart(); + const requestsStep2 = [ + { moduleId: 4, nodeOpId: 2, valIndex: 2, valPubkey: PUBKEYS[2] }, + { moduleId: 5, nodeOpId: 3, valIndex: 2, valPubkey: PUBKEYS[3] }, + ]; + const { reportData: reportStep2 } = await prepareReportAndSubmitHash(requestsStep2); + await oracle.connect(member1).submitReportData(reportStep2, oracleVersion); + const countStep2 = await oracle.getTotalRequestsProcessed(); + currentCount += requestsStep2.length; + expect(countStep2).to.equal(currentCount); + + // // Step 3 — process no items + await consensus.advanceTimeToNextFrameStart(); + const requestsStep3: ExitRequest[] = []; + const { reportData: reportStep3 } = await prepareReportAndSubmitHash(requestsStep3); + await oracle.connect(member1).submitReportData(reportStep3, oracleVersion); + const countStep3 = await oracle.getTotalRequestsProcessed(); + currentCount += requestsStep3.length; + expect(countStep3).to.equal(currentCount); + }); + }); + + context(`requires validator indices for the same node operator to increase`, () => { + let originalState: string; + + before(async () => { + originalState = await Snapshot.take(); + await consensus.advanceTimeToNextFrameStart(); + }); + + after(async () => await Snapshot.restore(originalState)); + + it(`requesting NO 5-3 to exit validator 0`, async () => { + await consensus.advanceTimeToNextFrameStart(); + const { reportData } = await prepareReportAndSubmitHash([ + { moduleId: 5, nodeOpId: 3, valIndex: 0, valPubkey: PUBKEYS[0] }, + ]); + await oracle.connect(member1).submitReportData(reportData, oracleVersion); + expect(await getLastRequestedValidatorIndex(5, 3)).to.equal(0); + }); + + it(`cannot request NO 5-3 to exit validator 0 again`, async () => { + await consensus.advanceTimeToNextFrameStart(); + const { reportData } = await prepareReportAndSubmitHash([ + { moduleId: 5, nodeOpId: 3, valIndex: 0, valPubkey: PUBKEYS[0] }, + ]); + + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion)) + .to.be.revertedWithCustomError(oracle, "NodeOpValidatorIndexMustIncrease") + .withArgs(5, 3, 0, 0); + }); + + it(`requesting NO 5-3 to exit validator 1`, async () => { + await consensus.advanceTimeToNextFrameStart(); + const { reportData } = await prepareReportAndSubmitHash([ + { moduleId: 5, nodeOpId: 3, valIndex: 1, valPubkey: PUBKEYS[1] }, + ]); + await oracle.connect(member1).submitReportData(reportData, oracleVersion, { from: member1 }); + expect(await getLastRequestedValidatorIndex(5, 3)).to.equal(1); + }); + + it(`cannot request NO 5-3 to exit validator 1 again`, async () => { + await consensus.advanceTimeToNextFrameStart(); + const { reportData } = await prepareReportAndSubmitHash([ + { moduleId: 5, nodeOpId: 3, valIndex: 1, valPubkey: PUBKEYS[1] }, + ]); + + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion)) + .to.be.revertedWithCustomError(oracle, "NodeOpValidatorIndexMustIncrease") + .withArgs(5, 3, 1, 1); + }); + + it(`cannot request NO 5-3 to exit validator 0 again`, async () => { + await consensus.advanceTimeToNextFrameStart(); + const { reportData } = await prepareReportAndSubmitHash([ + { moduleId: 5, nodeOpId: 3, valIndex: 0, valPubkey: PUBKEYS[0] }, + ]); + + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion)) + .to.be.revertedWithCustomError(oracle, "NodeOpValidatorIndexMustIncrease") + .withArgs(5, 3, 1, 0); + }); + + it(`cannot request NO 5-3 to exit validator 1 again (multiple requests)`, async () => { + await consensus.advanceTimeToNextFrameStart(); + const { reportData } = await prepareReportAndSubmitHash([ + { moduleId: 5, nodeOpId: 1, valIndex: 10, valPubkey: PUBKEYS[0] }, + { moduleId: 5, nodeOpId: 3, valIndex: 1, valPubkey: PUBKEYS[0] }, + ]); + + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion)) + .to.be.revertedWithCustomError(oracle, "NodeOpValidatorIndexMustIncrease") + .withArgs(5, 3, 1, 1); + }); + + it(`cannot request NO 5-3 to exit validator 1 again (multiple requests, case 2)`, async () => { + await consensus.advanceTimeToNextFrameStart(); + const { reportData } = await prepareReportAndSubmitHash([ + { moduleId: 5, nodeOpId: 1, valIndex: 10, valPubkey: PUBKEYS[2] }, + { moduleId: 5, nodeOpId: 3, valIndex: 1, valPubkey: PUBKEYS[3] }, + { moduleId: 5, nodeOpId: 3, valIndex: 2, valPubkey: PUBKEYS[4] }, + ]); + + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion)) + .to.be.revertedWithCustomError(oracle, "NodeOpValidatorIndexMustIncrease") + .withArgs(5, 3, 1, 1); + }); + + it(`cannot request NO 5-3 to exit validator 2 two times per request`, async () => { + await consensus.advanceTimeToNextFrameStart(); + const { reportData } = await prepareReportAndSubmitHash([ + { moduleId: 5, nodeOpId: 3, valIndex: 2, valPubkey: PUBKEYS[2] }, + { moduleId: 5, nodeOpId: 3, valIndex: 2, valPubkey: PUBKEYS[3] }, + ]); + + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion)).to.be.revertedWithCustomError( + oracle, + "InvalidRequestsDataSortOrder", + ); + }); + }); + + context(`only consensus member or SUBMIT_DATA_ROLE can submit report on unpaused contract`, () => { + let originalState: string; + + beforeEach(async () => { + originalState = await Snapshot.take(); + await consensus.advanceTimeToNextFrameStart(); + }); + + afterEach(async () => await Snapshot.restore(originalState)); + + it("reverts on stranger", async () => { + const { reportData } = await prepareReportAndSubmitHash(); + + await expect(oracle.connect(stranger).submitReportData(reportData, oracleVersion)).to.be.revertedWithCustomError( + oracle, + "SenderNotAllowed", + ); + }); + + it("SUBMIT_DATA_ROLE is allowed", async () => { + oracle.grantRole(await oracle.SUBMIT_DATA_ROLE(), stranger, { from: admin }); + await consensus.advanceTimeToNextFrameStart(); + const { reportData } = await prepareReportAndSubmitHash(); + await oracle.connect(stranger).submitReportData(reportData, oracleVersion); + }); + + it("consensus member is allowed", async () => { + expect(await consensus.getIsMember(member1)).to.equal(true); + await consensus.advanceTimeToNextFrameStart(); + const { reportData } = await prepareReportAndSubmitHash(); + await oracle.connect(member1).submitReportData(reportData, oracleVersion); + }); + + it("reverts on paused contract", async () => { + await consensus.advanceTimeToNextFrameStart(); + const PAUSE_INFINITELY = await oracle.PAUSE_INFINITELY(); + await oracle.pauseFor(PAUSE_INFINITELY, { from: admin }); + const { reportData } = await prepareReportAndSubmitHash(); + + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion)).to.be.revertedWithCustomError( + oracle, + "ResumedExpected", + ); + }); + }); + + context("invokes internal baseOracle checks", () => { + let originalState: string; + + beforeEach(async () => { + originalState = await Snapshot.take(); + await consensus.advanceTimeToNextFrameStart(); + }); + + afterEach(async () => await Snapshot.restore(originalState)); + + it(`reverts on contract version mismatch`, async () => { + const { reportData } = await prepareReportAndSubmitHash(); + + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion + 1n)) + .to.be.revertedWithCustomError(oracle, "UnexpectedContractVersion") + .withArgs(oracleVersion, oracleVersion + 1n); + }); + + it("reverts on hash mismatch", async () => { + const requests = [{ moduleId: 5, nodeOpId: 1, valIndex: 10, valPubkey: PUBKEYS[2] }]; + const { reportHash: actualReportHash } = await prepareReportAndSubmitHash(requests); + const newRequests = [{ moduleId: 5, nodeOpId: 1, valIndex: 10, valPubkey: PUBKEYS[1] }]; + + const { refSlot } = await consensus.getCurrentFrame(); + // change pubkey + const reportData = { + consensusVersion: CONSENSUS_VERSION, + dataFormat: DATA_FORMAT_LIST, + refSlot, + requestsCount: newRequests.length, + data: encodeExitRequestsDataList(newRequests), + }; + + const reportItems = getValidatorsExitBusReportDataItems(reportData); + const changedReportHash = calcValidatorsExitBusReportDataHash(reportItems); + + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion)) + .to.be.revertedWithCustomError(oracle, "UnexpectedDataHash") + .withArgs(actualReportHash, changedReportHash); + }); + + it("reverts on processing deadline miss", async () => { + const { reportData } = await prepareReportAndSubmitHash(); + const deadline = (await oracle.getConsensusReport()).processingDeadlineTime.toString(10); + await consensus.advanceTimeToNextFrameStart(); + + await expect(oracle.connect(member1).submitReportData(reportData, oracleVersion)) + .to.be.revertedWithCustomError(oracle, "ProcessingDeadlineMissed") + .withArgs(deadline); + }); + }); + + context("getTotalRequestsProcessed reflects report history", () => { + let originalState: string; + + before(async () => { + originalState = await Snapshot.take(); + await consensus.advanceTimeToNextFrameStart(); + }); + + after(async () => await Snapshot.restore(originalState)); + + let requestCount = 0; + + it("should be zero at init", async () => { + requestCount = 0; + expect(await oracle.getTotalRequestsProcessed()).to.equal(requestCount); + }); + + it("should increase after report", async () => { + const { reportData } = await prepareReportAndSubmitHash([ + { moduleId: 5, nodeOpId: 3, valIndex: 0, valPubkey: PUBKEYS[0] }, + ]); + await oracle.connect(member1).submitReportData(reportData, oracleVersion, { from: member1 }); + requestCount += 1; + expect(await oracle.getTotalRequestsProcessed()).to.equal(requestCount); + }); + + it("should double increase for two exits", async () => { + await consensus.advanceTimeToNextFrameStart(); + const { reportData } = await prepareReportAndSubmitHash([ + { moduleId: 5, nodeOpId: 1, valIndex: 10, valPubkey: PUBKEYS[0] }, + { moduleId: 5, nodeOpId: 3, valIndex: 1, valPubkey: PUBKEYS[0] }, + ]); + await oracle.connect(member1).submitReportData(reportData, oracleVersion); + requestCount += 2; + expect(await oracle.getTotalRequestsProcessed()).to.equal(requestCount); + }); + + it("should not change on empty report", async () => { + await consensus.advanceTimeToNextFrameStart(); + const { reportData } = await prepareReportAndSubmitHash([]); + await oracle.connect(member1).submitReportData(reportData, oracleVersion); + expect(await oracle.getTotalRequestsProcessed()).to.equal(requestCount); + }); + }); + + context("getProcessingState reflects state change", () => { + let originalState: string; + before(async () => { + originalState = await Snapshot.take(); + await consensus.advanceTimeToNextFrameStart(); + }); + after(async () => await Snapshot.restore(originalState)); + + let report: ReportFields; + let hash: string; + + it("has correct defaults on init", async () => { + const state = await oracle.getProcessingState(); + expect(Object.values(state)).to.deep.equal([ + (await consensus.getCurrentFrame()).refSlot, + 0, + ZeroHash, + false, + 0, + 0, + 0, + ]); + }); + + it("consensus report submitted", async () => { + ({ reportData: report, reportHash: hash } = await prepareReportAndSubmitHash([ + { moduleId: 5, nodeOpId: 1, valIndex: 10, valPubkey: PUBKEYS[2] }, + { moduleId: 5, nodeOpId: 3, valIndex: 1, valPubkey: PUBKEYS[3] }, + ])); + const state = await oracle.getProcessingState(); + + expect(Object.values(state)).to.deep.equal([ + (await consensus.getCurrentFrame()).refSlot, + computeTimestampAtSlot((await consensus.getCurrentFrame()).reportProcessingDeadlineSlot), + hash, + false, + 0, + 0, + 0, + ]); + }); + + it("report is processed", async () => { + await oracle.connect(member1).submitReportData(report, oracleVersion); + const state = await oracle.getProcessingState(); + expect(Object.values(state)).to.deep.equal([ + (await consensus.getCurrentFrame()).refSlot, + computeTimestampAtSlot((await consensus.getCurrentFrame()).reportProcessingDeadlineSlot), + hash, + true, + DATA_FORMAT_LIST, + 2, + 2, + ]); + }); + + it("at next frame state resets", async () => { + await consensus.advanceTimeToNextFrameStart(); + const state = await oracle.getProcessingState(); + expect(Object.values(state)).to.deep.equal([ + (await consensus.getCurrentFrame()).refSlot, + 0, + ZeroHash, + false, + 0, + 0, + 0, + ]); }); }); }); From 5888facad18ad425aba9f36f827790cf35d77e1a Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Fri, 10 Jan 2025 12:24:25 +0100 Subject: [PATCH 019/184] 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 020/184] 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 021/184] 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 022/184] 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 023/184] 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 024/184] 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 025/184] 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 026/184] 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 027/184] 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 028/184] 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 029/184] 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 030/184] 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 031/184] 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 032/184] 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 033/184] 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 034/184] 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 035/184] 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 036/184] 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 037/184] 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 038/184] 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 23a4fa97fda413b8aab7cc1215a4d37f4c0a0020 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Thu, 30 Jan 2025 16:29:59 +0500 Subject: [PATCH 039/184] fix: remove unsafeWithdraw --- contracts/0.8.25/vaults/Delegation.sol | 2 +- contracts/0.8.25/vaults/Permissions.sol | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index a725eaec3..e544ff146 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -254,7 +254,7 @@ contract Delegation is Dashboard { if (_recipient == address(0)) revert ZeroArgument("_recipient"); if (_fee == 0) revert ZeroArgument("_fee"); - super._unsafeWithdraw(_recipient, _fee); + stakingVault().withdraw(_recipient, _fee); } /** diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index d2c7b31ea..dac80ccbd 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -118,7 +118,7 @@ abstract contract Permissions is AccessControlVoteable { } function _withdraw(address _recipient, uint256 _ether) internal virtual onlyRole(WITHDRAW_ROLE) { - _unsafeWithdraw(_recipient, _ether); + stakingVault().withdraw(_recipient, _ether); } function _mintShares(address _recipient, uint256 _shares) internal onlyRole(MINT_ROLE) { @@ -153,10 +153,6 @@ abstract contract Permissions is AccessControlVoteable { OwnableUpgradeable(address(stakingVault())).transferOwnership(_newOwner); } - function _unsafeWithdraw(address _recipient, uint256 _ether) internal { - stakingVault().withdraw(_recipient, _ether); - } - /** * @notice Emitted when the contract is initialized */ From 19755bbcfebbee6d19751244693908bdea9ad8c8 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Thu, 30 Jan 2025 16:38:37 +0500 Subject: [PATCH 040/184] feat: move mass-role management to permissions --- contracts/0.8.25/vaults/Dashboard.sol | 36 ------------------------- contracts/0.8.25/vaults/Permissions.sol | 36 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index a00923153..c0d382c31 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -36,14 +36,6 @@ interface IWstETH is IERC20, IERC20Permit { * including funding, withdrawing, minting, burning, and rebalancing operations. */ contract Dashboard is Permissions { - /** - * @notice Struct containing an account and a role for granting/revoking roles. - */ - struct RoleAssignment { - address account; - bytes32 role; - } - /** * @notice Total basis points for fee calculations; equals to 100%. */ @@ -462,34 +454,6 @@ contract Dashboard is Permissions { _resumeBeaconChainDeposits(); } - // ==================== Role Management Functions ==================== - - /** - * @notice Mass-grants multiple roles to multiple accounts. - * @param _assignments An array of role assignments. - * @dev Performs the role admin checks internally. - */ - function grantRoles(RoleAssignment[] memory _assignments) external { - if (_assignments.length == 0) revert ZeroArgument("_assignments"); - - for (uint256 i = 0; i < _assignments.length; i++) { - grantRole(_assignments[i].role, _assignments[i].account); - } - } - - /** - * @notice Mass-revokes multiple roles from multiple accounts. - * @param _assignments An array of role assignments. - * @dev Performs the role admin checks internally. - */ - function revokeRoles(RoleAssignment[] memory _assignments) external { - if (_assignments.length == 0) revert ZeroArgument("_assignments"); - - for (uint256 i = 0; i < _assignments.length; i++) { - revokeRole(_assignments[i].role, _assignments[i].account); - } - } - // ==================== Internal Functions ==================== /** diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index dac80ccbd..2d6b33755 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -17,6 +17,14 @@ import {VaultHub} from "./VaultHub.sol"; * @notice Provides granular permissions for StakingVault operations. */ abstract contract Permissions is AccessControlVoteable { + /** + * @notice Struct containing an account and a role for granting/revoking roles. + */ + struct RoleAssignment { + address account; + bytes32 role; + } + /** * @notice Permission for funding the StakingVault. */ @@ -107,6 +115,34 @@ abstract contract Permissions is AccessControlVoteable { return IStakingVault(addr); } + // ==================== Role Management Functions ==================== + + /** + * @notice Mass-grants multiple roles to multiple accounts. + * @param _assignments An array of role assignments. + * @dev Performs the role admin checks internally. + */ + function grantRoles(RoleAssignment[] memory _assignments) external { + if (_assignments.length == 0) revert ZeroArgument("_assignments"); + + for (uint256 i = 0; i < _assignments.length; i++) { + grantRole(_assignments[i].role, _assignments[i].account); + } + } + + /** + * @notice Mass-revokes multiple roles from multiple accounts. + * @param _assignments An array of role assignments. + * @dev Performs the role admin checks internally. + */ + function revokeRoles(RoleAssignment[] memory _assignments) external { + if (_assignments.length == 0) revert ZeroArgument("_assignments"); + + for (uint256 i = 0; i < _assignments.length; i++) { + revokeRole(_assignments[i].role, _assignments[i].account); + } + } + function _votingCommittee() internal pure virtual returns (bytes32[] memory) { bytes32[] memory roles = new bytes32[](1); roles[0] = DEFAULT_ADMIN_ROLE; From fdb7a08c8a6106001b59b21cfff91162a09d689d Mon Sep 17 00:00:00 2001 From: failingtwice Date: Thu, 30 Jan 2025 16:40:05 +0500 Subject: [PATCH 041/184] fix: rename optional fund modifier --- contracts/0.8.25/vaults/Dashboard.sol | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index c0d382c31..a9a869965 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -206,7 +206,7 @@ contract Dashboard is Permissions { /** * @notice Disconnects the staking vault from the vault hub. */ - function voluntaryDisconnect() external payable fundAndProceed { + function voluntaryDisconnect() external payable fundable { uint256 shares = vaultHub.vaultSocket(address(stakingVault())).sharesMinted; if (shares > 0) { @@ -267,7 +267,7 @@ contract Dashboard is Permissions { * @param _recipient Address of the recipient * @param _amountOfShares Amount of stETH shares to mint */ - function mintShares(address _recipient, uint256 _amountOfShares) external payable fundAndProceed { + function mintShares(address _recipient, uint256 _amountOfShares) external payable fundable { _mintShares(_recipient, _amountOfShares); } @@ -277,7 +277,7 @@ contract Dashboard is Permissions { * @param _recipient Address of the recipient * @param _amountOfStETH Amount of stETH to mint */ - function mintStETH(address _recipient, uint256 _amountOfStETH) external payable virtual fundAndProceed { + function mintStETH(address _recipient, uint256 _amountOfStETH) external payable virtual fundable { _mintShares(_recipient, STETH.getSharesByPooledEth(_amountOfStETH)); } @@ -286,7 +286,7 @@ contract Dashboard is Permissions { * @param _recipient Address of the recipient * @param _amountOfWstETH Amount of tokens to mint */ - function mintWstETH(address _recipient, uint256 _amountOfWstETH) external payable fundAndProceed { + function mintWstETH(address _recipient, uint256 _amountOfWstETH) external payable fundable { _mintShares(address(this), _amountOfWstETH); uint256 mintedStETH = STETH.getPooledEthBySharesRoundUp(_amountOfWstETH); @@ -399,7 +399,7 @@ contract Dashboard is Permissions { * @notice Rebalances the vault by transferring ether * @param _ether Amount of ether to rebalance */ - function rebalanceVault(uint256 _ether) external payable fundAndProceed { + function rebalanceVault(uint256 _ether) external payable fundable { _rebalanceVault(_ether); } @@ -459,7 +459,7 @@ contract Dashboard is Permissions { /** * @dev Modifier to fund the staking vault if msg.value > 0 */ - modifier fundAndProceed() { + modifier fundable() { if (msg.value > 0) { _fund(msg.value); } From 0ab9aa7d54329ecde43ca10d70189599dbf2d539 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Fri, 31 Jan 2025 12:59:07 +0500 Subject: [PATCH 042/184] feat: hooray! renaming! --- .../AccessControlMutuallyConfirmable.sol | 151 ++++++++++++++++++ .../0.8.25/utils/AccessControlVoteable.sol | 150 ----------------- contracts/0.8.25/vaults/Dashboard.sol | 4 +- contracts/0.8.25/vaults/Delegation.sol | 47 +++--- contracts/0.8.25/vaults/Permissions.sol | 10 +- 5 files changed, 182 insertions(+), 180 deletions(-) create mode 100644 contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol delete mode 100644 contracts/0.8.25/utils/AccessControlVoteable.sol diff --git a/contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol b/contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol new file mode 100644 index 000000000..f74534334 --- /dev/null +++ b/contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 + +// See contracts/COMPILERS.md +pragma solidity 0.8.25; + +import {AccessControlEnumerable} from "@openzeppelin/contracts-v5.2/access/extensions/AccessControlEnumerable.sol"; + +/** + * @title AccessControlMutuallyConfirmable + * @author Lido + * @notice An extension of AccessControlEnumerable that allows exectuing functions by mutual confirmation. + * @dev This contract extends AccessControlEnumerable and adds a confirmation mechanism in the form of a modifier. + */ +abstract contract AccessControlMutuallyConfirmable is AccessControlEnumerable { + /** + * @notice Tracks confirmations + * - callId: unique identifier for the call, derived as `keccak256(msg.data)` + * - role: role that confirmed the action + * - timestamp: timestamp of the confirmation. + */ + mapping(bytes32 callId => mapping(bytes32 role => uint256 timestamp)) public confirmations; + + /** + * @notice Confirmation lifetime in seconds; after this period, the confirmation expires and no longer counts. + */ + uint256 public confirmLifetime; + + /** + * @dev Restricts execution of the function unless confirmed by all specified roles. + * Confirmation, in this context, is a call to the same function with the same arguments. + * + * The confirmation process works as follows: + * 1. When a role member calls the function: + * - Their confirmation is counted immediately + * - If not enough confirmations exist, their confirmation is recorded + * - If they're not a member of any of the specified roles, the call reverts + * + * 2. Confirmation counting: + * - Counts the current caller's confirmations if they're a member of any of the specified roles + * - Counts existing confirmations that are not expired, i.e. lifetime is not exceeded + * + * 3. Execution: + * - If all members of the specified roles have confirmed, executes the function + * - On successful execution, clears all confirmations for this call + * - If not enough confirmations, stores the current confirmations + * - Thus, if the caller has all the roles, the function is executed immediately + * + * 4. Gas Optimization: + * - Confirmations are stored in a deferred manner using a memory array + * - Confirmation storage writes only occur if the function cannot be executed immediately + * - This prevents unnecessary storage writes when all confirmations are present, + * because the confirmations are cleared anyway after the function is executed, + * - i.e. this optimization is beneficial for the deciding caller and + * saves 1 storage write for each role the deciding caller has + * + * @param _roles Array of role identifiers that must confirm the call in order to execute it + * + * @notice Confirmations past their lifetime are not counted and must be recast + * @notice Only members of the specified roles can submit confirmations + * @notice The order of confirmations does not matter + * + */ + modifier onlyMutuallyConfirmed(bytes32[] memory _roles) { + if (confirmLifetime == 0) revert ConfirmLifetimeNotSet(); + + bytes32 callId = keccak256(msg.data); + uint256 numberOfRoles = _roles.length; + uint256 confirmValidSince = block.timestamp - confirmLifetime; + uint256 numberOfConfirms = 0; + bool[] memory deferredConfirms = new bool[](numberOfRoles); + bool isRoleMember = false; + + for (uint256 i = 0; i < numberOfRoles; ++i) { + bytes32 role = _roles[i]; + + if (super.hasRole(role, msg.sender)) { + isRoleMember = true; + numberOfConfirms++; + deferredConfirms[i] = true; + + emit RoleMemberConfirmed(msg.sender, role, block.timestamp, msg.data); + } else if (confirmations[callId][role] >= confirmValidSince) { + numberOfConfirms++; + } + } + + if (!isRoleMember) revert SenderNotMember(); + + if (numberOfConfirms == numberOfRoles) { + for (uint256 i = 0; i < numberOfRoles; ++i) { + bytes32 role = _roles[i]; + delete confirmations[callId][role]; + } + _; + } else { + for (uint256 i = 0; i < numberOfRoles; ++i) { + if (deferredConfirms[i]) { + bytes32 role = _roles[i]; + confirmations[callId][role] = block.timestamp; + } + } + } + } + + /** + * @notice Sets the confirmation lifetime. + * Confirmation lifetime is a period during which the confirmation is counted. Once the period is over, + * the confirmation is considered expired, no longer counts and must be recasted for the confirmation to go through. + * @param _newConfirmLifetime The new confirmation lifetime in seconds. + */ + function _setConfirmLifetime(uint256 _newConfirmLifetime) internal { + if (_newConfirmLifetime == 0) revert ConfirmLifetimeCannotBeZero(); + + uint256 oldConfirmLifetime = confirmLifetime; + confirmLifetime = _newConfirmLifetime; + + emit ConfirmLifetimeSet(msg.sender, oldConfirmLifetime, _newConfirmLifetime); + } + + /** + * @dev Emitted when the confirmation lifetime is set. + * @param oldConfirmLifetime The old confirmation lifetime. + * @param newConfirmLifetime The new confirmation lifetime. + */ + event ConfirmLifetimeSet(address indexed sender, uint256 oldConfirmLifetime, uint256 newConfirmLifetime); + + /** + * @dev Emitted when a role member confirms. + * @param member The address of the confirming member. + * @param role The role of the confirming member. + * @param timestamp The timestamp of the confirmation. + * @param data The msg.data of the confirmation (selector + arguments). + */ + event RoleMemberConfirmed(address indexed member, bytes32 indexed role, uint256 timestamp, bytes data); + + /** + * @dev Thrown when attempting to set confirmation lifetime to zero. + */ + error ConfirmLifetimeCannotBeZero(); + + /** + * @dev Thrown when attempting to confirm when the confirmation lifetime is not set. + */ + error ConfirmLifetimeNotSet(); + + /** + * @dev Thrown when a caller without a required role attempts to confirm. + */ + error SenderNotMember(); +} diff --git a/contracts/0.8.25/utils/AccessControlVoteable.sol b/contracts/0.8.25/utils/AccessControlVoteable.sol deleted file mode 100644 index b078dea5b..000000000 --- a/contracts/0.8.25/utils/AccessControlVoteable.sol +++ /dev/null @@ -1,150 +0,0 @@ -// SPDX-FileCopyrightText: 2025 Lido -// SPDX-License-Identifier: GPL-3.0 - -// See contracts/COMPILERS.md -pragma solidity 0.8.25; - -import {AccessControlEnumerable} from "@openzeppelin/contracts-v5.2/access/extensions/AccessControlEnumerable.sol"; - -abstract contract AccessControlVoteable is AccessControlEnumerable { - /** - * @notice Tracks committee votes - * - callId: unique identifier for the call, derived as `keccak256(msg.data)` - * - role: role that voted - * - voteTimestamp: timestamp of the vote. - * The term "voting" refers to the entire voting process through which vote-restricted actions are performed. - * The term "vote" refers to a single individual vote cast by a committee member. - */ - mapping(bytes32 callId => mapping(bytes32 role => uint256 voteTimestamp)) public votings; - - /** - * @notice Vote lifetime in seconds; after this period, the vote expires and no longer counts. - */ - uint256 public voteLifetime; - - /** - * @dev Modifier that implements a mechanism for multi-role committee approval. - * Each unique function call (identified by msg.data: selector + arguments) requires - * approval from all committee role members within a specified time window. - * - * The voting process works as follows: - * 1. When a committee member calls the function: - * - Their vote is counted immediately - * - If not enough votes exist, their vote is recorded - * - If they're not a committee member, the call reverts - * - * 2. Vote counting: - * - Counts the current caller's votes if they're a committee member - * - Counts existing votes that are within the voting period - * - All votes must occur within the same voting period window - * - * 3. Execution: - * - If all committee members have voted within the period, executes the function - * - On successful execution, clears all voting state for this call - * - If not enough votes, stores the current votes - * - Thus, if the caller has all the roles, the function is executed immediately - * - * 4. Gas Optimization: - * - Votes are stored in a deferred manner using a memory array - * - Vote storage writes only occur if the function cannot be executed immediately - * - This prevents unnecessary storage writes when all votes are present, - * because the votes are cleared anyway after the function is executed, - * - i.e. this optimization is beneficial for the deciding caller and - * saves 1 storage write for each role the deciding caller has - * - * @param _committee Array of role identifiers that form the voting committee - * - * @notice Votes expire after the voting period and must be recast - * @notice All committee members must vote within the same voting period - * @notice Only committee members can initiate votes - * - * @custom:security-note Each unique function call (including parameters) requires its own set of votes - */ - modifier onlyIfVotedBy(bytes32[] memory _committee) { - if (voteLifetime == 0) revert VoteLifetimeNotSet(); - - bytes32 callId = keccak256(msg.data); - uint256 committeeSize = _committee.length; - uint256 votingStart = block.timestamp - voteLifetime; - uint256 voteTally = 0; - bool[] memory deferredVotes = new bool[](committeeSize); - bool isCommitteeMember = false; - - for (uint256 i = 0; i < committeeSize; ++i) { - bytes32 role = _committee[i]; - - if (super.hasRole(role, msg.sender)) { - isCommitteeMember = true; - voteTally++; - deferredVotes[i] = true; - - emit RoleMemberVoted(msg.sender, role, block.timestamp, msg.data); - } else if (votings[callId][role] >= votingStart) { - voteTally++; - } - } - - if (!isCommitteeMember) revert NotACommitteeMember(); - - if (voteTally == committeeSize) { - for (uint256 i = 0; i < committeeSize; ++i) { - bytes32 role = _committee[i]; - delete votings[callId][role]; - } - _; - } else { - for (uint256 i = 0; i < committeeSize; ++i) { - if (deferredVotes[i]) { - bytes32 role = _committee[i]; - votings[callId][role] = block.timestamp; - } - } - } - } - - /** - * @notice Sets the vote lifetime. - * Vote lifetime is a period during which the vote is counted. Once the period is over, - * the vote is considered expired, no longer counts and must be recasted for the voting to go through. - * @param _newVoteLifetime The new vote lifetime in seconds. - */ - function _setVoteLifetime(uint256 _newVoteLifetime) internal { - if (_newVoteLifetime == 0) revert VoteLifetimeCannotBeZero(); - - uint256 oldVoteLifetime = voteLifetime; - voteLifetime = _newVoteLifetime; - - emit VoteLifetimeSet(msg.sender, oldVoteLifetime, _newVoteLifetime); - } - - /** - * @dev Emitted when the vote lifetime is set. - * @param oldVoteLifetime The old vote lifetime. - * @param newVoteLifetime The new vote lifetime. - */ - event VoteLifetimeSet(address indexed sender, uint256 oldVoteLifetime, uint256 newVoteLifetime); - - /** - * @dev Emitted when a committee member votes. - * @param member The address of the voting member. - * @param role The role of the voting member. - * @param timestamp The timestamp of the vote. - * @param data The msg.data of the vote. - */ - event RoleMemberVoted(address indexed member, bytes32 indexed role, uint256 timestamp, bytes data); - - /** - * @dev Thrown when attempting to set vote lifetime to zero. - */ - error VoteLifetimeCannotBeZero(); - - /** - * @dev Thrown when attempting to vote when the vote lifetime is zero. - */ - error VoteLifetimeNotSet(); - - /** - * @dev Thrown when a caller without a required role attempts to vote. - */ - error NotACommitteeMember(); -} diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index a9a869965..908473ff7 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -99,8 +99,8 @@ contract Dashboard is Permissions { // ==================== View Functions ==================== - function votingCommittee() external pure returns (bytes32[] memory) { - return _votingCommittee(); + function confirmingRoles() external pure returns (bytes32[] memory) { + return _confirmingRoles(); } /** diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index e544ff146..26e942495 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -36,9 +36,9 @@ contract Delegation is Dashboard { * @notice Curator role: * - sets curator fee; * - claims curator fee; - * - votes on vote lifetime; - * - votes on node operator fee; - * - votes on ownership transfer; + * - confirms confirm lifetime; + * - confirms node operator fee; + * - confirms ownership transfer; * - pauses deposits to beacon chain; * - resumes deposits to beacon chain. */ @@ -46,9 +46,9 @@ contract Delegation is Dashboard { /** * @notice Node operator manager role: - * - votes on vote lifetime; - * - votes on node operator fee; - * - votes on ownership transfer; + * - confirms confirm lifetime; + * - confirms node operator fee; + * - confirms ownership transfer; * - assigns NODE_OPERATOR_FEE_CLAIMER_ROLE. */ bytes32 public constant NODE_OPERATOR_MANAGER_ROLE = keccak256("Vault.Delegation.NodeOperatorManagerRole"); @@ -92,7 +92,7 @@ contract Delegation is Dashboard { /** * @notice Initializes the contract: * - sets up the roles; - * - sets the vote lifetime to 7 days (can be changed later by CURATOR_ROLE and NODE_OPERATOR_MANAGER_ROLE). + * - sets the confirm lifetime to 7 days (can be changed later by CURATOR_ROLE and NODE_OPERATOR_MANAGER_ROLE). * @dev The msg.sender here is VaultFactory. The VaultFactory is temporarily granted * DEFAULT_ADMIN_ROLE AND NODE_OPERATOR_MANAGER_ROLE to be able to set initial fees and roles in VaultFactory. * All the roles are revoked from VaultFactory by the end of the initialization. @@ -152,13 +152,13 @@ contract Delegation is Dashboard { } /** - * @notice Sets the vote lifetime. - * Vote lifetime is a period during which the vote is counted. Once the period is over, - * the vote is considered expired, no longer counts and must be recasted for the voting to go through. - * @param _newVoteLifetime The new vote lifetime in seconds. + * @notice Sets the confirm lifetime. + * Confirm lifetime is a period during which the confirm is counted. Once the period is over, + * the confirm is considered expired, no longer counts and must be recasted. + * @param _newConfirmLifetime The new confirm lifetime in seconds. */ - function setVoteLifetime(uint256 _newVoteLifetime) external onlyIfVotedBy(_votingCommittee()) { - _setVoteLifetime(_newVoteLifetime); + function setConfirmLifetime(uint256 _newConfirmLifetime) external onlyMutuallyConfirmed(_confirmingRoles()) { + _setConfirmLifetime(_newConfirmLifetime); } /** @@ -181,11 +181,11 @@ contract Delegation is Dashboard { * @notice Sets the node operator fee. * The node operator fee is the percentage (in basis points) of node operator's share of the StakingVault rewards. * The node operator fee combined with the curator fee cannot exceed 100%. - * Note that the function reverts if the node operator fee is unclaimed and all the votes must be recasted to execute it again, - * which is why the deciding voter must make sure that `nodeOperatorUnclaimedFee()` is 0 before calling this function. + * Note that the function reverts if the node operator fee is unclaimed and all the confirms must be recasted to execute it again, + * which is why the deciding confirm must make sure that `nodeOperatorUnclaimedFee()` is 0 before calling this function. * @param _newNodeOperatorFeeBP The new node operator fee in basis points. */ - function setNodeOperatorFeeBP(uint256 _newNodeOperatorFeeBP) external onlyIfVotedBy(_votingCommittee()) { + function setNodeOperatorFeeBP(uint256 _newNodeOperatorFeeBP) external onlyMutuallyConfirmed(_confirmingRoles()) { if (_newNodeOperatorFeeBP + curatorFeeBP > MAX_FEE_BP) revert CombinedFeesExceed100Percent(); if (nodeOperatorUnclaimedFee() > 0) revert NodeOperatorFeeUnclaimed(); uint256 oldNodeOperatorFeeBP = nodeOperatorFeeBP; @@ -258,16 +258,17 @@ contract Delegation is Dashboard { } /** - * @notice Returns the committee that can: - * - change the vote lifetime; + * @notice Returns the roles that can: + * - change the confirm lifetime; + * - set the curator fee; * - set the node operator fee; * - transfer the ownership of the StakingVault. - * @return committee is an array of roles that form the voting committee. + * @return roles is an array of roles that form the confirming roles. */ - function _votingCommittee() internal pure override returns (bytes32[] memory committee) { - committee = new bytes32[](2); - committee[0] = CURATOR_ROLE; - committee[1] = NODE_OPERATOR_MANAGER_ROLE; + function _confirmingRoles() internal pure override returns (bytes32[] memory roles) { + roles = new bytes32[](2); + roles[0] = CURATOR_ROLE; + roles[1] = NODE_OPERATOR_MANAGER_ROLE; } /** diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index 2d6b33755..553d5d39e 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -5,7 +5,7 @@ pragma solidity 0.8.25; import {Clones} from "@openzeppelin/contracts-v5.2/proxy/Clones.sol"; -import {AccessControlVoteable} from "contracts/0.8.25/utils/AccessControlVoteable.sol"; +import {AccessControlMutuallyConfirmable} from "contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol"; import {OwnableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/OwnableUpgradeable.sol"; import {IStakingVault} from "./interfaces/IStakingVault.sol"; @@ -16,7 +16,7 @@ import {VaultHub} from "./VaultHub.sol"; * @author Lido * @notice Provides granular permissions for StakingVault operations. */ -abstract contract Permissions is AccessControlVoteable { +abstract contract Permissions is AccessControlMutuallyConfirmable { /** * @notice Struct containing an account and a role for granting/revoking roles. */ @@ -101,7 +101,7 @@ abstract contract Permissions is AccessControlVoteable { vaultHub = VaultHub(stakingVault().vaultHub()); _grantRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); - _setVoteLifetime(7 days); + _setConfirmLifetime(7 days); emit Initialized(); } @@ -143,7 +143,7 @@ abstract contract Permissions is AccessControlVoteable { } } - function _votingCommittee() internal pure virtual returns (bytes32[] memory) { + function _confirmingRoles() internal pure virtual returns (bytes32[] memory) { bytes32[] memory roles = new bytes32[](1); roles[0] = DEFAULT_ADMIN_ROLE; return roles; @@ -185,7 +185,7 @@ abstract contract Permissions is AccessControlVoteable { vaultHub.voluntaryDisconnect(address(stakingVault())); } - function _transferStakingVaultOwnership(address _newOwner) internal onlyIfVotedBy(_votingCommittee()) { + function _transferStakingVaultOwnership(address _newOwner) internal onlyMutuallyConfirmed(_confirmingRoles()) { OwnableUpgradeable(address(stakingVault())).transferOwnership(_newOwner); } From 8c43b1329da2c739341dfcffee63582256243314 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Fri, 31 Jan 2025 13:01:37 +0500 Subject: [PATCH 043/184] feat: update role ids --- contracts/0.8.25/vaults/Delegation.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index 26e942495..4c879e953 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -42,7 +42,7 @@ contract Delegation is Dashboard { * - pauses deposits to beacon chain; * - resumes deposits to beacon chain. */ - bytes32 public constant CURATOR_ROLE = keccak256("Vault.Delegation.CuratorRole"); + bytes32 public constant CURATOR_ROLE = keccak256("vaults.Delegation.CuratorRole"); /** * @notice Node operator manager role: @@ -51,13 +51,13 @@ contract Delegation is Dashboard { * - confirms ownership transfer; * - assigns NODE_OPERATOR_FEE_CLAIMER_ROLE. */ - bytes32 public constant NODE_OPERATOR_MANAGER_ROLE = keccak256("Vault.Delegation.NodeOperatorManagerRole"); + bytes32 public constant NODE_OPERATOR_MANAGER_ROLE = keccak256("vaults.Delegation.NodeOperatorManagerRole"); /** * @notice Node operator fee claimer role: * - claims node operator fee. */ - bytes32 public constant NODE_OPERATOR_FEE_CLAIMER_ROLE = keccak256("Vault.Delegation.NodeOperatorFeeClaimerRole"); + bytes32 public constant NODE_OPERATOR_FEE_CLAIMER_ROLE = keccak256("vaults.Delegation.NodeOperatorFeeClaimerRole"); /** * @notice Curator fee in basis points; combined with node operator fee cannot exceed 100%. From 3403a81d32a06f7f28985935f6a6fc4d049fe30d Mon Sep 17 00:00:00 2001 From: failingtwice Date: Fri, 31 Jan 2025 13:02:46 +0500 Subject: [PATCH 044/184] fix(Permissions): update role ids --- contracts/0.8.25/vaults/Permissions.sol | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index 553d5d39e..caa79ecea 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -28,49 +28,48 @@ abstract contract Permissions is AccessControlMutuallyConfirmable { /** * @notice Permission for funding the StakingVault. */ - bytes32 public constant FUND_ROLE = keccak256("StakingVault.Permissions.Fund"); + bytes32 public constant FUND_ROLE = keccak256("vaults.Permissions.Fund"); /** * @notice Permission for withdrawing funds from the StakingVault. */ - bytes32 public constant WITHDRAW_ROLE = keccak256("StakingVault.Permissions.Withdraw"); + bytes32 public constant WITHDRAW_ROLE = keccak256("vaults.Permissions.Withdraw"); /** * @notice Permission for minting stETH shares backed by the StakingVault. */ - bytes32 public constant MINT_ROLE = keccak256("StakingVault.Permissions.Mint"); + bytes32 public constant MINT_ROLE = keccak256("vaults.Permissions.Mint"); /** * @notice Permission for burning stETH shares backed by the StakingVault. */ - bytes32 public constant BURN_ROLE = keccak256("StakingVault.Permissions.Burn"); + bytes32 public constant BURN_ROLE = keccak256("vaults.Permissions.Burn"); /** * @notice Permission for rebalancing the StakingVault. */ - bytes32 public constant REBALANCE_ROLE = keccak256("StakingVault.Permissions.Rebalance"); + bytes32 public constant REBALANCE_ROLE = keccak256("vaults.Permissions.Rebalance"); /** * @notice Permission for pausing beacon chain deposits on the StakingVault. */ - bytes32 public constant PAUSE_BEACON_CHAIN_DEPOSITS_ROLE = - keccak256("StakingVault.Permissions.PauseBeaconChainDeposits"); + bytes32 public constant PAUSE_BEACON_CHAIN_DEPOSITS_ROLE = keccak256("vaults.Permissions.PauseBeaconChainDeposits"); /** * @notice Permission for resuming beacon chain deposits on the StakingVault. */ bytes32 public constant RESUME_BEACON_CHAIN_DEPOSITS_ROLE = - keccak256("StakingVault.Permissions.ResumeBeaconChainDeposits"); + keccak256("vaults.Permissions.ResumeBeaconChainDeposits"); /** * @notice Permission for requesting validator exit from the StakingVault. */ - bytes32 public constant REQUEST_VALIDATOR_EXIT_ROLE = keccak256("StakingVault.Permissions.RequestValidatorExit"); + bytes32 public constant REQUEST_VALIDATOR_EXIT_ROLE = keccak256("vaults.Permissions.RequestValidatorExit"); /** * @notice Permission for voluntary disconnecting the StakingVault. */ - bytes32 public constant VOLUNTARY_DISCONNECT_ROLE = keccak256("StakingVault.Permissions.VoluntaryDisconnect"); + bytes32 public constant VOLUNTARY_DISCONNECT_ROLE = keccak256("vaults.Permissions.VoluntaryDisconnect"); /** * @notice Address of the implementation contract From 18f184bee4112bbf71765fc4cef3d01ab72b73e4 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Fri, 31 Jan 2025 13:05:29 +0500 Subject: [PATCH 045/184] refactor(Permissions): hide assembly in an internal func --- contracts/0.8.25/vaults/Permissions.sol | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index caa79ecea..2c4a6073a 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -106,12 +106,7 @@ abstract contract Permissions is AccessControlMutuallyConfirmable { } function stakingVault() public view returns (IStakingVault) { - bytes memory args = Clones.fetchCloneArgs(address(this)); - address addr; - assembly { - addr := mload(add(args, 32)) - } - return IStakingVault(addr); + return IStakingVault(_loadStakingVaultAddress()); } // ==================== Role Management Functions ==================== @@ -188,6 +183,13 @@ abstract contract Permissions is AccessControlMutuallyConfirmable { OwnableUpgradeable(address(stakingVault())).transferOwnership(_newOwner); } + function _loadStakingVaultAddress() internal view returns (address addr) { + bytes memory args = Clones.fetchCloneArgs(address(this)); + assembly { + addr := mload(add(args, 32)) + } + } + /** * @notice Emitted when the contract is initialized */ From 98390095fb51061fcc21a33ea78029fe59d4816c Mon Sep 17 00:00:00 2001 From: failingtwice Date: Fri, 31 Jan 2025 13:06:43 +0500 Subject: [PATCH 046/184] feat: log default admin in initialized event --- contracts/0.8.25/vaults/Permissions.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index 2c4a6073a..6672b909c 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -102,7 +102,7 @@ abstract contract Permissions is AccessControlMutuallyConfirmable { _setConfirmLifetime(7 days); - emit Initialized(); + emit Initialized(_defaultAdmin); } function stakingVault() public view returns (IStakingVault) { @@ -193,7 +193,7 @@ abstract contract Permissions is AccessControlMutuallyConfirmable { /** * @notice Emitted when the contract is initialized */ - event Initialized(); + event Initialized(address _defaultAdmin); /** * @notice Error when direct calls to the implementation are forbidden From adeb088227113879cda8f1f7f266c1f28a14ffb4 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Fri, 31 Jan 2025 13:15:45 +0500 Subject: [PATCH 047/184] feat: pass confirm lifetime as init param --- contracts/0.8.25/vaults/Dashboard.sol | 4 ++-- contracts/0.8.25/vaults/Delegation.sol | 4 ++-- contracts/0.8.25/vaults/Permissions.sol | 4 ++-- contracts/0.8.25/vaults/VaultFactory.sol | 3 ++- .../dashboard/contracts/VaultFactory__MockForDashboard.sol | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 908473ff7..abb47d3e6 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -89,12 +89,12 @@ contract Dashboard is Permissions { /** * @notice Initializes the contract with the default admin role */ - function initialize(address _defaultAdmin) external virtual { + function initialize(address _defaultAdmin, uint256 _confirmLifetime) external virtual { // reduces gas cost for `mintWsteth` // invariant: dashboard does not hold stETH on its balance STETH.approve(address(WSTETH), type(uint256).max); - _initialize(_defaultAdmin); + _initialize(_defaultAdmin, _confirmLifetime); } // ==================== View Functions ==================== diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index 4c879e953..ea6715415 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -97,8 +97,8 @@ contract Delegation is Dashboard { * DEFAULT_ADMIN_ROLE AND NODE_OPERATOR_MANAGER_ROLE to be able to set initial fees and roles in VaultFactory. * All the roles are revoked from VaultFactory by the end of the initialization. */ - function initialize(address _defaultAdmin) external override { - _initialize(_defaultAdmin); + function initialize(address _defaultAdmin, uint256 _confirmLifetime) external override { + _initialize(_defaultAdmin, _confirmLifetime); // the next line implies that the msg.sender is an operator // however, the msg.sender is the VaultFactory, and the role will be revoked diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index 6672b909c..419e63428 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -91,7 +91,7 @@ abstract contract Permissions is AccessControlMutuallyConfirmable { _SELF = address(this); } - function _initialize(address _defaultAdmin) internal { + function _initialize(address _defaultAdmin, uint256 _confirmLifetime) internal { if (initialized) revert AlreadyInitialized(); if (address(this) == _SELF) revert NonProxyCallsForbidden(); if (_defaultAdmin == address(0)) revert ZeroArgument("_defaultAdmin"); @@ -100,7 +100,7 @@ abstract contract Permissions is AccessControlMutuallyConfirmable { vaultHub = VaultHub(stakingVault().vaultHub()); _grantRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); - _setConfirmLifetime(7 days); + _setConfirmLifetime(_confirmLifetime); emit Initialized(_defaultAdmin); } diff --git a/contracts/0.8.25/vaults/VaultFactory.sol b/contracts/0.8.25/vaults/VaultFactory.sol index b971e51f4..65b0c2bbb 100644 --- a/contracts/0.8.25/vaults/VaultFactory.sol +++ b/contracts/0.8.25/vaults/VaultFactory.sol @@ -26,6 +26,7 @@ struct DelegationConfig { address nodeOperatorFeeClaimer; uint16 curatorFeeBP; uint16 nodeOperatorFeeBP; + uint256 confirmLifetime; } contract VaultFactory { @@ -66,7 +67,7 @@ contract VaultFactory { ); // initialize Delegation - delegation.initialize(address(this)); + delegation.initialize(address(this), _delegationConfig.confirmLifetime); // setup roles delegation.grantRole(delegation.DEFAULT_ADMIN_ROLE(), _delegationConfig.defaultAdmin); 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..caacda986 100644 --- a/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol +++ b/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol @@ -28,7 +28,7 @@ contract VaultFactory__MockForDashboard is UpgradeableBeacon { bytes memory immutableArgs = abi.encode(vault); dashboard = Dashboard(payable(Clones.cloneWithImmutableArgs(dashboardImpl, immutableArgs))); - dashboard.initialize(address(this)); + dashboard.initialize(address(this), 7 days); dashboard.grantRole(dashboard.DEFAULT_ADMIN_ROLE(), msg.sender); dashboard.grantRole(dashboard.FUND_ROLE(), msg.sender); dashboard.grantRole(dashboard.WITHDRAW_ROLE(), msg.sender); From 34c2e6243413837615498a1ab8e1b37a54e3eeb8 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Fri, 31 Jan 2025 13:38:50 +0500 Subject: [PATCH 048/184] feat: don't use msg.sender in init --- contracts/0.8.25/vaults/Delegation.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index ea6715415..feafb9cf9 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -103,7 +103,7 @@ contract Delegation is Dashboard { // the next line implies that the msg.sender is an operator // however, the msg.sender is the VaultFactory, and the role will be revoked // at the end of the initialization - _grantRole(NODE_OPERATOR_MANAGER_ROLE, msg.sender); + _grantRole(NODE_OPERATOR_MANAGER_ROLE, _defaultAdmin); _setRoleAdmin(NODE_OPERATOR_MANAGER_ROLE, NODE_OPERATOR_MANAGER_ROLE); _setRoleAdmin(NODE_OPERATOR_FEE_CLAIMER_ROLE, NODE_OPERATOR_MANAGER_ROLE); } From 6da1d6f7f4fbf2d112e24e1b38798cc61d33e935 Mon Sep 17 00:00:00 2001 From: Maksim Kuraian Date: Sat, 1 Feb 2025 11:47:49 +0100 Subject: [PATCH 049/184] 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 038e7224afcc693c3528b8890c718af835863c62 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Mon, 3 Feb 2025 16:07:08 +0500 Subject: [PATCH 050/184] fix: modifier order --- contracts/0.8.25/vaults/Dashboard.sol | 66 ++++++++++++++------------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index abb47d3e6..245423cda 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -323,38 +323,7 @@ contract Dashboard is Permissions { _burnWstETH(_amountOfWstETH); } - /** - * @dev Modifier to check if the permit is successful, and if not, check if the allowance is sufficient - */ - modifier safePermit( - address token, - address owner, - address spender, - PermitInput calldata permitInput - ) { - // Try permit() before allowance check to advance nonce if possible - try - IERC20Permit(token).permit( - owner, - spender, - permitInput.value, - permitInput.deadline, - permitInput.v, - permitInput.r, - permitInput.s - ) - { - _; - return; - } catch { - // Permit potentially got frontran. Continue anyways if allowance is sufficient. - if (IERC20(token).allowance(owner, spender) >= permitInput.value) { - _; - return; - } - } - revert InvalidPermit(token); - } + // TODO: move down /** * @notice Burns stETH tokens (in shares) backed by the vault from the sender using permit (with value in stETH). @@ -466,6 +435,39 @@ contract Dashboard is Permissions { _; } + /** + * @dev Modifier to check if the permit is successful, and if not, check if the allowance is sufficient + */ + modifier safePermit( + address token, + address owner, + address spender, + PermitInput calldata permitInput + ) { + // Try permit() before allowance check to advance nonce if possible + try + IERC20Permit(token).permit( + owner, + spender, + permitInput.value, + permitInput.deadline, + permitInput.v, + permitInput.r, + permitInput.s + ) + { + _; + return; + } catch { + // Permit potentially got frontran. Continue anyways if allowance is sufficient. + if (IERC20(token).allowance(owner, spender) >= permitInput.value) { + _; + return; + } + } + revert InvalidPermit(token); + } + /** /** From b16075caa3b4b40b41e658866a39b59e39eb7369 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Mon, 3 Feb 2025 16:07:18 +0500 Subject: [PATCH 051/184] test: permissions setup --- .../contracts/Permissions__Harness.sol | 56 +++++++ .../VaultFactory__MockPermissions.sol | 98 +++++++++++++ .../contracts/VaultHub__MockPermissions.sol | 10 ++ .../vaults/permissions/permissions.test.ts | 138 ++++++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 test/0.8.25/vaults/permissions/contracts/Permissions__Harness.sol create mode 100644 test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions.sol create mode 100644 test/0.8.25/vaults/permissions/contracts/VaultHub__MockPermissions.sol create mode 100644 test/0.8.25/vaults/permissions/permissions.test.ts diff --git a/test/0.8.25/vaults/permissions/contracts/Permissions__Harness.sol b/test/0.8.25/vaults/permissions/contracts/Permissions__Harness.sol new file mode 100644 index 000000000..d73cbb826 --- /dev/null +++ b/test/0.8.25/vaults/permissions/contracts/Permissions__Harness.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity ^0.8.0; + +import {Permissions} from "contracts/0.8.25/vaults/Permissions.sol"; + +contract Permissions__Harness is Permissions { + function initialize(address _defaultAdmin, uint256 _confirmLifetime) external { + _initialize(_defaultAdmin, _confirmLifetime); + } + + function confirmingRoles() external pure returns (bytes32[] memory) { + return _confirmingRoles(); + } + + function fund(uint256 _ether) external { + _fund(_ether); + } + + function withdraw(address _recipient, uint256 _ether) external { + _withdraw(_recipient, _ether); + } + + function mintShares(address _recipient, uint256 _shares) external { + _mintShares(_recipient, _shares); + } + + function burnShares(uint256 _shares) external { + _burnShares(_shares); + } + + function rebalanceVault(uint256 _ether) external { + _rebalanceVault(_ether); + } + + function pauseBeaconChainDeposits() external { + _pauseBeaconChainDeposits(); + } + + function resumeBeaconChainDeposits() external { + _resumeBeaconChainDeposits(); + } + + function requestValidatorExit(bytes calldata _pubkey) external { + _requestValidatorExit(_pubkey); + } + + function transferStakingVaultOwnership(address _newOwner) external { + _transferStakingVaultOwnership(_newOwner); + } + + function setConfirmLifetime(uint256 _newConfirmLifetime) external { + _setConfirmLifetime(_newConfirmLifetime); + } +} diff --git a/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions.sol b/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions.sol new file mode 100644 index 000000000..ba372a73c --- /dev/null +++ b/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity ^0.8.0; + +import {BeaconProxy} from "@openzeppelin/contracts-v5.2/proxy/beacon/BeaconProxy.sol"; +import {Clones} from "@openzeppelin/contracts-v5.2/proxy/Clones.sol"; + +import {Permissions__Harness} from "./Permissions__Harness.sol"; +import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.sol"; + +struct PermissionsConfig { + address defaultAdmin; + address nodeOperator; + uint256 confirmLifetime; + address funder; + address withdrawer; + address minter; + address burner; + address rebalancer; + address depositPauser; + address depositResumer; + address exitRequester; + address disconnecter; +} + +contract VaultFactory__MockPermissions { + address public immutable BEACON; + address public immutable PERMISSIONS_IMPL; + + /// @param _beacon The address of the beacon contract + /// @param _permissionsImpl The address of the Permissions implementation + constructor(address _beacon, address _permissionsImpl) { + if (_beacon == address(0)) revert ZeroArgument("_beacon"); + if (_permissionsImpl == address(0)) revert ZeroArgument("_permissionsImpl"); + + BEACON = _beacon; + PERMISSIONS_IMPL = _permissionsImpl; + } + + /// @notice Creates a new StakingVault and Permissions contracts + /// @param _permissionsConfig The params of permissions initialization + /// @param _stakingVaultInitializerExtraParams The params of vault initialization + function createVaultWithPermissions( + PermissionsConfig calldata _permissionsConfig, + bytes calldata _stakingVaultInitializerExtraParams + ) external returns (IStakingVault vault, Permissions__Harness permissions) { + // create StakingVault + vault = IStakingVault(address(new BeaconProxy(BEACON, ""))); + + // create Permissions + bytes memory immutableArgs = abi.encode(vault); + permissions = Permissions__Harness(payable(Clones.cloneWithImmutableArgs(PERMISSIONS_IMPL, immutableArgs))); + + // initialize StakingVault + vault.initialize(address(permissions), _permissionsConfig.nodeOperator, _stakingVaultInitializerExtraParams); + + // initialize Permissions + permissions.initialize(address(this), _permissionsConfig.confirmLifetime); + + // setup roles + permissions.grantRole(permissions.DEFAULT_ADMIN_ROLE(), _permissionsConfig.defaultAdmin); + permissions.grantRole(permissions.FUND_ROLE(), _permissionsConfig.funder); + permissions.grantRole(permissions.WITHDRAW_ROLE(), _permissionsConfig.withdrawer); + permissions.grantRole(permissions.MINT_ROLE(), _permissionsConfig.minter); + permissions.grantRole(permissions.BURN_ROLE(), _permissionsConfig.burner); + permissions.grantRole(permissions.REBALANCE_ROLE(), _permissionsConfig.rebalancer); + permissions.grantRole(permissions.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), _permissionsConfig.depositPauser); + permissions.grantRole(permissions.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), _permissionsConfig.depositResumer); + permissions.grantRole(permissions.REQUEST_VALIDATOR_EXIT_ROLE(), _permissionsConfig.exitRequester); + permissions.grantRole(permissions.VOLUNTARY_DISCONNECT_ROLE(), _permissionsConfig.disconnecter); + + permissions.revokeRole(permissions.DEFAULT_ADMIN_ROLE(), address(this)); + + emit VaultCreated(address(permissions), address(vault)); + emit PermissionsCreated(_permissionsConfig.defaultAdmin, address(permissions)); + } + + /** + * @notice Event emitted on a Vault creation + * @param owner The address of the Vault owner + * @param vault The address of the created Vault + */ + event VaultCreated(address indexed owner, address indexed vault); + + /** + * @notice Event emitted on a Permissions creation + * @param admin The address of the Permissions admin + * @param permissions The address of the created Permissions + */ + event PermissionsCreated(address indexed admin, address indexed permissions); + + /** + * @notice Error thrown for when a given value cannot be zero + * @param argument Name of the argument + */ + error ZeroArgument(string argument); +} diff --git a/test/0.8.25/vaults/permissions/contracts/VaultHub__MockPermissions.sol b/test/0.8.25/vaults/permissions/contracts/VaultHub__MockPermissions.sol new file mode 100644 index 000000000..f68a3f5a3 --- /dev/null +++ b/test/0.8.25/vaults/permissions/contracts/VaultHub__MockPermissions.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity ^0.8.0; + +contract VaultHub__MockPermissions { + function hello() external pure returns (string memory) { + return "hello"; + } +} diff --git a/test/0.8.25/vaults/permissions/permissions.test.ts b/test/0.8.25/vaults/permissions/permissions.test.ts new file mode 100644 index 000000000..868ff179e --- /dev/null +++ b/test/0.8.25/vaults/permissions/permissions.test.ts @@ -0,0 +1,138 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { + DepositContract__MockForStakingVault, + Permissions__Harness, + Permissions__Harness__factory, + StakingVault, + StakingVault__factory, + UpgradeableBeacon, + VaultFactory__MockPermissions, + VaultHub__MockPermissions, +} from "typechain-types"; +import { PermissionsConfigStruct } from "typechain-types/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions"; + +import { days, findEvents } from "lib"; + +describe("Permissions", () => { + let deployer: HardhatEthersSigner; + let defaultAdmin: HardhatEthersSigner; + let nodeOperator: HardhatEthersSigner; + let funder: HardhatEthersSigner; + let withdrawer: HardhatEthersSigner; + let minter: HardhatEthersSigner; + let burner: HardhatEthersSigner; + let rebalancer: HardhatEthersSigner; + let depositPauser: HardhatEthersSigner; + let depositResumer: HardhatEthersSigner; + let exitRequester: HardhatEthersSigner; + let disconnecter: HardhatEthersSigner; + + let depositContract: DepositContract__MockForStakingVault; + let permissionsImpl: Permissions__Harness; + let stakingVaultImpl: StakingVault; + let vaultHub: VaultHub__MockPermissions; + let beacon: UpgradeableBeacon; + let vaultFactory: VaultFactory__MockPermissions; + let stakingVault: StakingVault; + let permissions: Permissions__Harness; + + before(async () => { + [ + deployer, + defaultAdmin, + nodeOperator, + funder, + withdrawer, + minter, + burner, + rebalancer, + depositPauser, + depositResumer, + exitRequester, + disconnecter, + ] = await ethers.getSigners(); + + // 1. Deploy DepositContract + depositContract = await ethers.deployContract("DepositContract__MockForStakingVault"); + + // 2. Deploy VaultHub + vaultHub = await ethers.deployContract("VaultHub__MockPermissions"); + + // 3. Deploy StakingVault implementation + stakingVaultImpl = await ethers.deployContract("StakingVault", [vaultHub, depositContract]); + expect(await stakingVaultImpl.vaultHub()).to.equal(vaultHub); + expect(await stakingVaultImpl.depositContract()).to.equal(depositContract); + + // 4. Deploy Beacon and use StakingVault implementation as initial implementation + beacon = await ethers.deployContract("UpgradeableBeacon", [stakingVaultImpl, deployer]); + + // 5. Deploy Permissions implementation + permissionsImpl = await ethers.deployContract("Permissions__Harness"); + + // 6. Deploy VaultFactory and use Beacon and Permissions implementations + vaultFactory = await ethers.deployContract("VaultFactory__MockPermissions", [beacon, permissionsImpl]); + + // 7. Create StakingVault and Permissions proxies using VaultFactory + const vaultCreationTx = await vaultFactory.connect(deployer).createVaultWithPermissions( + { + defaultAdmin, + nodeOperator, + confirmLifetime: days(7n), + funder, + withdrawer, + minter, + burner, + rebalancer, + depositPauser, + depositResumer, + exitRequester, + disconnecter, + } as PermissionsConfigStruct, + "0x", + ); + const vaultCreationReceipt = await vaultCreationTx.wait(); + if (!vaultCreationReceipt) throw new Error("Vault creation failed"); + + // 8. Get StakingVault's proxy address from the event and wrap it in StakingVault interface + const vaultCreatedEvents = findEvents(vaultCreationReceipt, "VaultCreated"); + if (vaultCreatedEvents.length != 1) throw new Error("There should be exactly one VaultCreated event"); + const vaultCreatedEvent = vaultCreatedEvents[0]; + + stakingVault = StakingVault__factory.connect(vaultCreatedEvent.args.vault, defaultAdmin); + + // 9. Get Permissions' proxy address from the event and wrap it in Permissions interface + const permissionsCreatedEvents = findEvents(vaultCreationReceipt, "PermissionsCreated"); + if (permissionsCreatedEvents.length != 1) throw new Error("There should be exactly one PermissionsCreated event"); + const permissionsCreatedEvent = permissionsCreatedEvents[0]; + + permissions = Permissions__Harness__factory.connect(permissionsCreatedEvent.args.permissions, defaultAdmin); + + // 10. Check that StakingVault is initialized properly + expect(await stakingVault.owner()).to.equal(permissions); + expect(await stakingVault.nodeOperator()).to.equal(nodeOperator); + + // 11. Check events + expect(vaultCreatedEvent.args.owner).to.equal(permissions); + expect(permissionsCreatedEvent.args.admin).to.equal(defaultAdmin); + }); + + context("initial permissions", () => { + it("should have the correct roles", async () => { + await checkSoleMember(defaultAdmin, await permissions.DEFAULT_ADMIN_ROLE()); + await checkSoleMember(funder, await permissions.FUND_ROLE()); + await checkSoleMember(withdrawer, await permissions.WITHDRAW_ROLE()); + await checkSoleMember(minter, await permissions.MINT_ROLE()); + await checkSoleMember(burner, await permissions.BURN_ROLE()); + await checkSoleMember(rebalancer, await permissions.REBALANCE_ROLE()); + }); + }); + + async function checkSoleMember(account: HardhatEthersSigner, role: string) { + expect(await permissions.getRoleMemberCount(role)).to.equal(1); + expect(await permissions.getRoleMember(role, 0)).to.equal(account); + } +}); From 9a1a8d8b53b2f41e937f05efac1327fe79cafa24 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Mon, 3 Feb 2025 11:45:51 +0000 Subject: [PATCH 052/184] 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 3e82d3a356838c881b3a1ed13608b22851d8e012 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Mon, 3 Feb 2025 18:00:00 +0500 Subject: [PATCH 053/184] feat: store expiry instead of cast timestamp --- .../0.8.25/utils/AccessControlMutuallyConfirmable.sol | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol b/contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol index f74534334..1ca3c8043 100644 --- a/contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol +++ b/contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol @@ -19,7 +19,7 @@ abstract contract AccessControlMutuallyConfirmable is AccessControlEnumerable { * - role: role that confirmed the action * - timestamp: timestamp of the confirmation. */ - mapping(bytes32 callId => mapping(bytes32 role => uint256 timestamp)) public confirmations; + mapping(bytes32 callId => mapping(bytes32 role => uint256 expiryTimestamp)) public confirmations; /** * @notice Confirmation lifetime in seconds; after this period, the confirmation expires and no longer counts. @@ -66,7 +66,6 @@ abstract contract AccessControlMutuallyConfirmable is AccessControlEnumerable { bytes32 callId = keccak256(msg.data); uint256 numberOfRoles = _roles.length; - uint256 confirmValidSince = block.timestamp - confirmLifetime; uint256 numberOfConfirms = 0; bool[] memory deferredConfirms = new bool[](numberOfRoles); bool isRoleMember = false; @@ -80,7 +79,7 @@ abstract contract AccessControlMutuallyConfirmable is AccessControlEnumerable { deferredConfirms[i] = true; emit RoleMemberConfirmed(msg.sender, role, block.timestamp, msg.data); - } else if (confirmations[callId][role] >= confirmValidSince) { + } else if (confirmations[callId][role] >= block.timestamp) { numberOfConfirms++; } } @@ -97,7 +96,7 @@ abstract contract AccessControlMutuallyConfirmable is AccessControlEnumerable { for (uint256 i = 0; i < numberOfRoles; ++i) { if (deferredConfirms[i]) { bytes32 role = _roles[i]; - confirmations[callId][role] = block.timestamp; + confirmations[callId][role] = block.timestamp + confirmLifetime; } } } From 62a8caa766a36141edb98d59b9dea693707a5592 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Mon, 3 Feb 2025 18:29:15 +0500 Subject: [PATCH 054/184] fix: use raw data for mapping key --- .../0.8.25/utils/AccessControlMutuallyConfirmable.sol | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol b/contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol index 1ca3c8043..7377f3746 100644 --- a/contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol +++ b/contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol @@ -19,7 +19,7 @@ abstract contract AccessControlMutuallyConfirmable is AccessControlEnumerable { * - role: role that confirmed the action * - timestamp: timestamp of the confirmation. */ - mapping(bytes32 callId => mapping(bytes32 role => uint256 expiryTimestamp)) public confirmations; + mapping(bytes callData => mapping(bytes32 role => uint256 expiryTimestamp)) public confirmations; /** * @notice Confirmation lifetime in seconds; after this period, the confirmation expires and no longer counts. @@ -64,7 +64,6 @@ abstract contract AccessControlMutuallyConfirmable is AccessControlEnumerable { modifier onlyMutuallyConfirmed(bytes32[] memory _roles) { if (confirmLifetime == 0) revert ConfirmLifetimeNotSet(); - bytes32 callId = keccak256(msg.data); uint256 numberOfRoles = _roles.length; uint256 numberOfConfirms = 0; bool[] memory deferredConfirms = new bool[](numberOfRoles); @@ -79,7 +78,7 @@ abstract contract AccessControlMutuallyConfirmable is AccessControlEnumerable { deferredConfirms[i] = true; emit RoleMemberConfirmed(msg.sender, role, block.timestamp, msg.data); - } else if (confirmations[callId][role] >= block.timestamp) { + } else if (confirmations[msg.data][role] >= block.timestamp) { numberOfConfirms++; } } @@ -89,14 +88,14 @@ abstract contract AccessControlMutuallyConfirmable is AccessControlEnumerable { if (numberOfConfirms == numberOfRoles) { for (uint256 i = 0; i < numberOfRoles; ++i) { bytes32 role = _roles[i]; - delete confirmations[callId][role]; + delete confirmations[msg.data][role]; } _; } else { for (uint256 i = 0; i < numberOfRoles; ++i) { if (deferredConfirms[i]) { bytes32 role = _roles[i]; - confirmations[callId][role] = block.timestamp + confirmLifetime; + confirmations[msg.data][role] = block.timestamp + confirmLifetime; } } } From b9dc9c3d8fd821b7df456a5a5f451680573d552a Mon Sep 17 00:00:00 2001 From: failingtwice Date: Mon, 3 Feb 2025 18:31:52 +0500 Subject: [PATCH 055/184] fix: revert if roles array empty --- contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol b/contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol index 7377f3746..6f84110e5 100644 --- a/contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol +++ b/contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol @@ -62,6 +62,7 @@ abstract contract AccessControlMutuallyConfirmable is AccessControlEnumerable { * */ modifier onlyMutuallyConfirmed(bytes32[] memory _roles) { + if (_roles.length == 0) revert ZeroConfirmingRoles(); if (confirmLifetime == 0) revert ConfirmLifetimeNotSet(); uint256 numberOfRoles = _roles.length; @@ -146,4 +147,9 @@ abstract contract AccessControlMutuallyConfirmable is AccessControlEnumerable { * @dev Thrown when a caller without a required role attempts to confirm. */ error SenderNotMember(); + + /** + * @dev Thrown when the roles array is empty. + */ + error ZeroConfirmingRoles(); } From ab1a048387ee781e421dac9e680efc59d88f5e5e Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Mon, 3 Feb 2025 15:52:10 +0000 Subject: [PATCH 056/184] 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 057/184] 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 058/184] 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 059/184] 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 060/184] 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 061/184] 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 062/184] 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 063/184] 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 064/184] 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 065/184] 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 066/184] 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 067/184] 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 068/184] 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 069/184] 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 070/184] 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 071/184] 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 072/184] 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 073/184] 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 074/184] 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 075/184] 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 076/184] 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 077/184] 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 078/184] 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 2488ef7c2eab3dcf0a2e9a6ecff426c084d71b49 Mon Sep 17 00:00:00 2001 From: Eddort Date: Sun, 9 Feb 2025 16:32:29 +0100 Subject: [PATCH 079/184] feat: add block explorer verify params to hh config --- hardhat.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hardhat.config.ts b/hardhat.config.ts index 26d09c671..6f2cd13bd 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -89,10 +89,10 @@ const config: HardhatUserConfig = { customChains: [ { network: "local-devnet", - chainId: 32382, + chainId: process.env.LOCAL_DENVET_CHAIN_ID ?? 32382, urls: { - apiURL: "http://localhost:3080/api", - browserURL: "http://localhost:3080", + apiURL: process.env.LOCAL_DENVET_EXPLORER_API_URL, + browserURL: process.env.LOCAL_DENVET_EXPLORER_URL, }, }, ], From 0bfc88a1ecba28c60ebf0726c1e31ff7e11a373a Mon Sep 17 00:00:00 2001 From: DiRaiks Date: Mon, 10 Feb 2025 11:55:38 +0300 Subject: [PATCH 080/184] feat: override withdrawableEther in Delegation contract --- contracts/0.8.25/vaults/Dashboard.sol | 2 +- contracts/0.8.25/vaults/Delegation.sol | 12 ++++++ .../vaults/delegation/delegation.test.ts | 39 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index a00923153..cc64e48bf 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -192,7 +192,7 @@ contract Dashboard is Permissions { * @notice Returns the amount of ether that can be withdrawn from the staking vault. * @return The amount of ether that can be withdrawn. */ - function withdrawableEther() external view returns (uint256) { + function withdrawableEther() external view virtual returns (uint256) { return Math256.min(address(stakingVault()).balance, stakingVault().unlocked()); } diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index a725eaec3..32fbcb68e 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -4,6 +4,8 @@ // See contracts/COMPILERS.md pragma solidity 0.8.25; +import {Math256} from "contracts/common/lib/Math256.sol"; + import {IStakingVault} from "./interfaces/IStakingVault.sol"; import {Dashboard} from "./Dashboard.sol"; @@ -151,6 +153,16 @@ contract Delegation is Dashboard { return reserved > valuation ? 0 : valuation - reserved; } + /** + * @notice Returns the amount of ether that can be withdrawn from the staking vault. + * @dev This is the amount of ether that is not locked in the StakingVault and not reserved for curator and node operator fees. + * @dev This method overrides the Dashboard's withdrawableEther() method + * @return The amount of ether that can be withdrawn. + */ + function withdrawableEther() external view override returns (uint256) { + return Math256.min(address(stakingVault()).balance, unreserved()); + } + /** * @notice Sets the vote lifetime. * Vote lifetime is a period during which the vote is counted. Once the period is over, diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index 7b4651a2b..b1e9383d6 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -362,6 +362,45 @@ describe("Delegation.sol", () => { }); }); + context("withdrawableEther", () => { + it("returns the correct amount", async () => { + const amount = ether("1"); + await delegation.connect(funder).fund({ value: amount }); + expect(await delegation.withdrawableEther()).to.equal(amount); + }); + + it("returns the correct amount when balance is less than unreserved", async () => { + const valuation = ether("3"); + const inOutDelta = 0n; + const locked = ether("2"); + + const amount = ether("1"); + await delegation.connect(funder).fund({ value: amount }); + await vault.connect(hubSigner).report(valuation, inOutDelta, locked); + + expect(await delegation.withdrawableEther()).to.equal(amount); + }); + + it("returns the correct amount when has fees", async () => { + const amount = ether("6"); + const valuation = ether("3"); + const inOutDelta = ether("1"); + const locked = ether("2"); + + const curatorFeeBP = 1000; // 10% + const operatorFeeBP = 1000; // 10% + await delegation.connect(vaultOwner).setCuratorFeeBP(curatorFeeBP); + await delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(operatorFeeBP); + + await delegation.connect(funder).fund({ value: amount }); + + await vault.connect(hubSigner).report(valuation, inOutDelta, locked); + const unreserved = await delegation.unreserved(); + + expect(await delegation.withdrawableEther()).to.equal(unreserved); + }); + }); + context("fund", () => { it("reverts if the caller is not a member of the staker role", async () => { await expect(delegation.connect(stranger).fund()).to.be.revertedWithCustomError( From 60f3e68413b862a1c1750676dd63d98c24c0174b Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Mon, 10 Feb 2025 13:51:02 +0000 Subject: [PATCH 081/184] 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 fce5c97a8165c473ed3bc347f7808465342b0526 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Tue, 11 Feb 2025 13:59:11 +0500 Subject: [PATCH 082/184] fix: rename access control confirmable --- ...olMutuallyConfirmable.sol => AccessControlConfirmable.sol} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename contracts/0.8.25/utils/{AccessControlMutuallyConfirmable.sol => AccessControlConfirmable.sol} (98%) diff --git a/contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol b/contracts/0.8.25/utils/AccessControlConfirmable.sol similarity index 98% rename from contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol rename to contracts/0.8.25/utils/AccessControlConfirmable.sol index 6f84110e5..328aede81 100644 --- a/contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol +++ b/contracts/0.8.25/utils/AccessControlConfirmable.sol @@ -7,12 +7,12 @@ pragma solidity 0.8.25; import {AccessControlEnumerable} from "@openzeppelin/contracts-v5.2/access/extensions/AccessControlEnumerable.sol"; /** - * @title AccessControlMutuallyConfirmable + * @title AccessControlConfirmable * @author Lido * @notice An extension of AccessControlEnumerable that allows exectuing functions by mutual confirmation. * @dev This contract extends AccessControlEnumerable and adds a confirmation mechanism in the form of a modifier. */ -abstract contract AccessControlMutuallyConfirmable is AccessControlEnumerable { +abstract contract AccessControlConfirmable is AccessControlEnumerable { /** * @notice Tracks confirmations * - callId: unique identifier for the call, derived as `keccak256(msg.data)` From 7e1f108567e532345c01e0a2d81da28e6ffacbf6 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Tue, 11 Feb 2025 19:10:49 +0500 Subject: [PATCH 083/184] fix: tailing renaming --- contracts/0.8.25/utils/AccessControlConfirmable.sol | 2 +- contracts/0.8.25/vaults/Delegation.sol | 4 ++-- contracts/0.8.25/vaults/Permissions.sol | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/0.8.25/utils/AccessControlConfirmable.sol b/contracts/0.8.25/utils/AccessControlConfirmable.sol index 328aede81..ea1036f55 100644 --- a/contracts/0.8.25/utils/AccessControlConfirmable.sol +++ b/contracts/0.8.25/utils/AccessControlConfirmable.sol @@ -61,7 +61,7 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { * @notice The order of confirmations does not matter * */ - modifier onlyMutuallyConfirmed(bytes32[] memory _roles) { + modifier onlyConfirmed(bytes32[] memory _roles) { if (_roles.length == 0) revert ZeroConfirmingRoles(); if (confirmLifetime == 0) revert ConfirmLifetimeNotSet(); diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index feafb9cf9..abe159ec4 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -157,7 +157,7 @@ contract Delegation is Dashboard { * the confirm is considered expired, no longer counts and must be recasted. * @param _newConfirmLifetime The new confirm lifetime in seconds. */ - function setConfirmLifetime(uint256 _newConfirmLifetime) external onlyMutuallyConfirmed(_confirmingRoles()) { + function setConfirmLifetime(uint256 _newConfirmLifetime) external onlyConfirmed(_confirmingRoles()) { _setConfirmLifetime(_newConfirmLifetime); } @@ -185,7 +185,7 @@ contract Delegation is Dashboard { * which is why the deciding confirm must make sure that `nodeOperatorUnclaimedFee()` is 0 before calling this function. * @param _newNodeOperatorFeeBP The new node operator fee in basis points. */ - function setNodeOperatorFeeBP(uint256 _newNodeOperatorFeeBP) external onlyMutuallyConfirmed(_confirmingRoles()) { + function setNodeOperatorFeeBP(uint256 _newNodeOperatorFeeBP) external onlyConfirmed(_confirmingRoles()) { if (_newNodeOperatorFeeBP + curatorFeeBP > MAX_FEE_BP) revert CombinedFeesExceed100Percent(); if (nodeOperatorUnclaimedFee() > 0) revert NodeOperatorFeeUnclaimed(); uint256 oldNodeOperatorFeeBP = nodeOperatorFeeBP; diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index 419e63428..d4f9e1328 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -5,7 +5,7 @@ pragma solidity 0.8.25; import {Clones} from "@openzeppelin/contracts-v5.2/proxy/Clones.sol"; -import {AccessControlMutuallyConfirmable} from "contracts/0.8.25/utils/AccessControlMutuallyConfirmable.sol"; +import {AccessControlConfirmable} from "contracts/0.8.25/utils/AccessControlConfirmable.sol"; import {OwnableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/OwnableUpgradeable.sol"; import {IStakingVault} from "./interfaces/IStakingVault.sol"; @@ -16,7 +16,7 @@ import {VaultHub} from "./VaultHub.sol"; * @author Lido * @notice Provides granular permissions for StakingVault operations. */ -abstract contract Permissions is AccessControlMutuallyConfirmable { +abstract contract Permissions is AccessControlConfirmable { /** * @notice Struct containing an account and a role for granting/revoking roles. */ @@ -179,7 +179,7 @@ abstract contract Permissions is AccessControlMutuallyConfirmable { vaultHub.voluntaryDisconnect(address(stakingVault())); } - function _transferStakingVaultOwnership(address _newOwner) internal onlyMutuallyConfirmed(_confirmingRoles()) { + function _transferStakingVaultOwnership(address _newOwner) internal onlyConfirmed(_confirmingRoles()) { OwnableUpgradeable(address(stakingVault())).transferOwnership(_newOwner); } From a4bd6b8af45e77e0b6e3737f544b1605286e53dd Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 11 Feb 2025 18:19:39 +0000 Subject: [PATCH 084/184] 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 085/184] 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 268854531141267cf716475d27ee73933af40e93 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 12 Feb 2025 12:45:25 +0500 Subject: [PATCH 086/184] feat: add more role granularity --- contracts/0.8.25/vaults/Delegation.sol | 56 ++++++++++-------------- contracts/0.8.25/vaults/VaultFactory.sol | 27 +++++++----- 2 files changed, 38 insertions(+), 45 deletions(-) diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index abe159ec4..f6b331ef7 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -10,21 +10,6 @@ import {Dashboard} from "./Dashboard.sol"; /** * @title Delegation * @notice This contract is a contract-owner of StakingVault and includes an additional delegation layer. - * - * The delegation hierarchy is as follows: - * - DEFAULT_ADMIN_ROLE is the underlying owner of StakingVault; - * - NODE_OPERATOR_MANAGER_ROLE is the node operator manager of StakingVault; and itself is the role admin, - * and the DEFAULT_ADMIN_ROLE cannot assign NODE_OPERATOR_MANAGER_ROLE; - * - NODE_OPERATOR_FEE_CLAIMER_ROLE is the role that can claim node operator fee; is assigned by NODE_OPERATOR_MANAGER_ROLE; - * - * Additionally, the following roles are assigned by DEFAULT_ADMIN_ROLE: - * - CURATOR_ROLE is the curator of StakingVault and perfoms some operations on behalf of DEFAULT_ADMIN_ROLE; - * - FUND_WITHDRAW_ROLE funds and withdraws from the StakingVault; - * - MINT_BURN_ROLE mints and burns shares of stETH backed by the StakingVault; - * - * The curator and node operator have their respective fees. - * The feeBP is the percentage (in basis points) of the StakingVault rewards. - * The unclaimed fee is the amount of ether that is owed to the curator or node operator based on the feeBP. */ contract Delegation is Dashboard { /** @@ -33,31 +18,33 @@ contract Delegation is Dashboard { uint256 private constant MAX_FEE_BP = TOTAL_BASIS_POINTS; /** - * @notice Curator role: - * - sets curator fee; - * - claims curator fee; - * - confirms confirm lifetime; - * - confirms node operator fee; - * - confirms ownership transfer; - * - pauses deposits to beacon chain; - * - resumes deposits to beacon chain. + * @notice Sets curator fee. + */ + bytes32 public constant CURATOR_FEE_SET_ROLE = keccak256("vaults.Delegation.CuratorFeeSetRole"); + + /** + * @notice Claims curator fee. */ - bytes32 public constant CURATOR_ROLE = keccak256("vaults.Delegation.CuratorRole"); + bytes32 public constant CURATOR_FEE_CLAIM_ROLE = keccak256("vaults.Delegation.CuratorFeeClaimRole"); /** * @notice Node operator manager role: * - confirms confirm lifetime; - * - confirms node operator fee; * - confirms ownership transfer; - * - assigns NODE_OPERATOR_FEE_CLAIMER_ROLE. + * - assigns NODE_OPERATOR_FEE_CONFIRM_ROLE; + * - assigns NODE_OPERATOR_FEE_CLAIM_ROLE. */ bytes32 public constant NODE_OPERATOR_MANAGER_ROLE = keccak256("vaults.Delegation.NodeOperatorManagerRole"); /** - * @notice Node operator fee claimer role: - * - claims node operator fee. + * @notice Confirms node operator fee. + */ + bytes32 public constant NODE_OPERATOR_FEE_CONFIRM_ROLE = keccak256("vaults.Delegation.NodeOperatorFeeConfirmRole"); + + /** + * @notice Claims node operator fee. */ - bytes32 public constant NODE_OPERATOR_FEE_CLAIMER_ROLE = keccak256("vaults.Delegation.NodeOperatorFeeClaimerRole"); + bytes32 public constant NODE_OPERATOR_FEE_CLAIM_ROLE = keccak256("vaults.Delegation.NodeOperatorFeeClaimRole"); /** * @notice Curator fee in basis points; combined with node operator fee cannot exceed 100%. @@ -105,7 +92,8 @@ contract Delegation is Dashboard { // at the end of the initialization _grantRole(NODE_OPERATOR_MANAGER_ROLE, _defaultAdmin); _setRoleAdmin(NODE_OPERATOR_MANAGER_ROLE, NODE_OPERATOR_MANAGER_ROLE); - _setRoleAdmin(NODE_OPERATOR_FEE_CLAIMER_ROLE, NODE_OPERATOR_MANAGER_ROLE); + _setRoleAdmin(NODE_OPERATOR_FEE_CONFIRM_ROLE, NODE_OPERATOR_MANAGER_ROLE); + _setRoleAdmin(NODE_OPERATOR_FEE_CLAIM_ROLE, NODE_OPERATOR_MANAGER_ROLE); } /** @@ -168,7 +156,7 @@ contract Delegation is Dashboard { * The function will revert if the curator fee is unclaimed. * @param _newCuratorFeeBP The new curator fee in basis points. */ - function setCuratorFeeBP(uint256 _newCuratorFeeBP) external onlyRole(DEFAULT_ADMIN_ROLE) { + function setCuratorFeeBP(uint256 _newCuratorFeeBP) external onlyRole(CURATOR_FEE_SET_ROLE) { if (_newCuratorFeeBP + nodeOperatorFeeBP > MAX_FEE_BP) revert CombinedFeesExceed100Percent(); if (curatorUnclaimedFee() > 0) revert CuratorFeeUnclaimed(); uint256 oldCuratorFeeBP = curatorFeeBP; @@ -198,7 +186,7 @@ contract Delegation is Dashboard { * @notice Claims the curator fee. * @param _recipient The address to which the curator fee will be sent. */ - function claimCuratorFee(address _recipient) external onlyRole(CURATOR_ROLE) { + function claimCuratorFee(address _recipient) external onlyRole(CURATOR_FEE_CLAIM_ROLE) { uint256 fee = curatorUnclaimedFee(); curatorFeeClaimedReport = stakingVault().latestReport(); _claimFee(_recipient, fee); @@ -210,7 +198,7 @@ contract Delegation is Dashboard { * although NODE_OPERATOR_MANAGER_ROLE is the admin role for NODE_OPERATOR_FEE_CLAIMER_ROLE. * @param _recipient The address to which the node operator fee will be sent. */ - function claimNodeOperatorFee(address _recipient) external onlyRole(NODE_OPERATOR_FEE_CLAIMER_ROLE) { + function claimNodeOperatorFee(address _recipient) external onlyRole(NODE_OPERATOR_FEE_CLAIM_ROLE) { uint256 fee = nodeOperatorUnclaimedFee(); nodeOperatorFeeClaimedReport = stakingVault().latestReport(); _claimFee(_recipient, fee); @@ -267,7 +255,7 @@ contract Delegation is Dashboard { */ function _confirmingRoles() internal pure override returns (bytes32[] memory roles) { roles = new bytes32[](2); - roles[0] = CURATOR_ROLE; + roles[0] = DEFAULT_ADMIN_ROLE; roles[1] = NODE_OPERATOR_MANAGER_ROLE; } diff --git a/contracts/0.8.25/vaults/VaultFactory.sol b/contracts/0.8.25/vaults/VaultFactory.sol index 65b0c2bbb..209a4ea4f 100644 --- a/contracts/0.8.25/vaults/VaultFactory.sol +++ b/contracts/0.8.25/vaults/VaultFactory.sol @@ -21,9 +21,11 @@ struct DelegationConfig { address depositResumer; address exitRequester; address disconnecter; - address curator; + address curatorFeeSetter; + address curatorFeeClaimer; address nodeOperatorManager; - address nodeOperatorFeeClaimer; + address nodeOperatorFeeConfirm; + address nodeOperatorFeeClaim; uint16 curatorFeeBP; uint16 nodeOperatorFeeBP; uint256 confirmLifetime; @@ -50,8 +52,6 @@ contract VaultFactory { DelegationConfig calldata _delegationConfig, bytes calldata _stakingVaultInitializerExtraParams ) external returns (IStakingVault vault, Delegation delegation) { - if (_delegationConfig.curator == address(0)) revert ZeroArgument("curator"); - // create StakingVault vault = IStakingVault(address(new BeaconProxy(BEACON, ""))); @@ -69,7 +69,8 @@ contract VaultFactory { // initialize Delegation delegation.initialize(address(this), _delegationConfig.confirmLifetime); - // setup roles + // setup roles from config + // basic permissions to the staking vault delegation.grantRole(delegation.DEFAULT_ADMIN_ROLE(), _delegationConfig.defaultAdmin); delegation.grantRole(delegation.FUND_ROLE(), _delegationConfig.funder); delegation.grantRole(delegation.WITHDRAW_ROLE(), _delegationConfig.withdrawer); @@ -80,20 +81,24 @@ contract VaultFactory { delegation.grantRole(delegation.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), _delegationConfig.depositResumer); delegation.grantRole(delegation.REQUEST_VALIDATOR_EXIT_ROLE(), _delegationConfig.exitRequester); delegation.grantRole(delegation.VOLUNTARY_DISCONNECT_ROLE(), _delegationConfig.disconnecter); - delegation.grantRole(delegation.CURATOR_ROLE(), _delegationConfig.curator); + // delegation roles + delegation.grantRole(delegation.CURATOR_FEE_SET_ROLE(), _delegationConfig.curatorFeeSetter); + delegation.grantRole(delegation.CURATOR_FEE_CLAIM_ROLE(), _delegationConfig.curatorFeeClaimer); delegation.grantRole(delegation.NODE_OPERATOR_MANAGER_ROLE(), _delegationConfig.nodeOperatorManager); - delegation.grantRole(delegation.NODE_OPERATOR_FEE_CLAIMER_ROLE(), _delegationConfig.nodeOperatorFeeClaimer); + delegation.grantRole(delegation.NODE_OPERATOR_FEE_CLAIM_ROLE(), _delegationConfig.nodeOperatorFeeClaim); + delegation.grantRole(delegation.NODE_OPERATOR_FEE_CONFIRM_ROLE(), _delegationConfig.nodeOperatorFeeConfirm); - // grant temporary roles to factory - delegation.grantRole(delegation.CURATOR_ROLE(), address(this)); - delegation.grantRole(delegation.NODE_OPERATOR_MANAGER_ROLE(), address(this)); + // grant temporary roles to factory for setting fees + delegation.grantRole(delegation.CURATOR_FEE_SET_ROLE(), address(this)); + delegation.grantRole(delegation.NODE_OPERATOR_FEE_CONFIRM_ROLE(), address(this)); // set fees delegation.setCuratorFeeBP(_delegationConfig.curatorFeeBP); delegation.setNodeOperatorFeeBP(_delegationConfig.nodeOperatorFeeBP); // revoke temporary roles from factory - delegation.revokeRole(delegation.CURATOR_ROLE(), address(this)); + delegation.revokeRole(delegation.CURATOR_FEE_SET_ROLE(), address(this)); + delegation.revokeRole(delegation.NODE_OPERATOR_FEE_CONFIRM_ROLE(), address(this)); delegation.revokeRole(delegation.NODE_OPERATOR_MANAGER_ROLE(), address(this)); delegation.revokeRole(delegation.DEFAULT_ADMIN_ROLE(), address(this)); From 927703ff1c88c5fdeaa9a93d6b357503418d59d9 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 12 Feb 2025 12:50:30 +0500 Subject: [PATCH 087/184] feat: add getter for timestamp --- contracts/0.8.25/utils/AccessControlConfirmable.sol | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/contracts/0.8.25/utils/AccessControlConfirmable.sol b/contracts/0.8.25/utils/AccessControlConfirmable.sol index ea1036f55..4ec521085 100644 --- a/contracts/0.8.25/utils/AccessControlConfirmable.sol +++ b/contracts/0.8.25/utils/AccessControlConfirmable.sol @@ -26,6 +26,16 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { */ uint256 public confirmLifetime; + /** + * @notice Returns the expiry timestamp of the confirmation for a given call and role. + * @param _callData The call data of the function. + * @param _role The role that confirmed the call. + * @return The expiry timestamp of the confirmation. + */ + function confirmationExpiryTimestamp(bytes calldata _callData, bytes32 _role) public view returns (uint256) { + return confirmations[_callData][_role]; + } + /** * @dev Restricts execution of the function unless confirmed by all specified roles. * Confirmation, in this context, is a call to the same function with the same arguments. From 7b382286a50f59382b1b3de024e46383cab950d5 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 12 Feb 2025 12:53:46 +0500 Subject: [PATCH 088/184] fix: add a behavior comment --- contracts/0.8.25/vaults/Permissions.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index d4f9e1328..f90b30689 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -115,6 +115,7 @@ abstract contract Permissions is AccessControlConfirmable { * @notice Mass-grants multiple roles to multiple accounts. * @param _assignments An array of role assignments. * @dev Performs the role admin checks internally. + * @dev If an account is already a member of a role, doesn't revert, emits no events. */ function grantRoles(RoleAssignment[] memory _assignments) external { if (_assignments.length == 0) revert ZeroArgument("_assignments"); From 214af41cc6ea0f3d6d9879f4829658b4bec06123 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 12 Feb 2025 12:57:08 +0500 Subject: [PATCH 089/184] fix: shorten name to fit the line --- contracts/0.8.25/vaults/Permissions.sol | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index f90b30689..99b8952eb 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -53,13 +53,12 @@ abstract contract Permissions is AccessControlConfirmable { /** * @notice Permission for pausing beacon chain deposits on the StakingVault. */ - bytes32 public constant PAUSE_BEACON_CHAIN_DEPOSITS_ROLE = keccak256("vaults.Permissions.PauseBeaconChainDeposits"); + bytes32 public constant PAUSE_BEACON_CHAIN_DEPOSITS_ROLE = keccak256("vaults.Permissions.PauseDeposits"); /** * @notice Permission for resuming beacon chain deposits on the StakingVault. */ - bytes32 public constant RESUME_BEACON_CHAIN_DEPOSITS_ROLE = - keccak256("vaults.Permissions.ResumeBeaconChainDeposits"); + bytes32 public constant RESUME_BEACON_CHAIN_DEPOSITS_ROLE = keccak256("vaults.Permissions.ResumeDeposits"); /** * @notice Permission for requesting validator exit from the StakingVault. From 6bb0fcf9b59dba1f53ccc4317e173d856cb60226 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 12 Feb 2025 12:58:57 +0500 Subject: [PATCH 090/184] feat: add comment --- contracts/0.8.25/vaults/Permissions.sol | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index 99b8952eb..2e7671892 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -104,6 +104,10 @@ abstract contract Permissions is AccessControlConfirmable { emit Initialized(_defaultAdmin); } + /** + * @notice Returns the address of the underlying StakingVault. + * @return The address of the StakingVault. + */ function stakingVault() public view returns (IStakingVault) { return IStakingVault(_loadStakingVaultAddress()); } From fae844323ee9c3ac44b5cb3890eba1b6ee13cbaa Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 12 Feb 2025 12:59:14 +0500 Subject: [PATCH 091/184] feat: add behavior comment --- contracts/0.8.25/vaults/Permissions.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index 2e7671892..1f13d3dc0 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -132,6 +132,7 @@ abstract contract Permissions is AccessControlConfirmable { * @notice Mass-revokes multiple roles from multiple accounts. * @param _assignments An array of role assignments. * @dev Performs the role admin checks internally. + * @dev If an account is not a member of a role, doesn't revert, emits no events. */ function revokeRoles(RoleAssignment[] memory _assignments) external { if (_assignments.length == 0) revert ZeroArgument("_assignments"); From d4a71e24bab8603f60dea58616caefd53d694a14 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 12 Feb 2025 08:23:01 +0000 Subject: [PATCH 092/184] 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 f627871fac3fae40eda4c28158c99dd925b4d34c Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 12 Feb 2025 13:46:47 +0500 Subject: [PATCH 093/184] feat(Permissions): cover with comments --- contracts/0.8.25/vaults/Permissions.sol | 55 +++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index 1f13d3dc0..c94357c63 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -142,52 +142,107 @@ abstract contract Permissions is AccessControlConfirmable { } } + /** + * @dev Returns an array of roles that need to confirm the call + * used for the `onlyConfirmed` modifier. + * At this level, only the DEFAULT_ADMIN_ROLE is needed to confirm the call + * but in inherited contracts, the function can be overridden to add more roles, + * which are introduced further in the inheritance chain. + * @return The roles that need to confirm the call. + */ function _confirmingRoles() internal pure virtual returns (bytes32[] memory) { bytes32[] memory roles = new bytes32[](1); roles[0] = DEFAULT_ADMIN_ROLE; return roles; } + /** + * @dev Checks the FUND_ROLE and funds the StakingVault. + * @param _ether The amount of ether to fund the StakingVault with. + */ function _fund(uint256 _ether) internal onlyRole(FUND_ROLE) { stakingVault().fund{value: _ether}(); } + /** + * @dev Checks the WITHDRAW_ROLE and withdraws funds from the StakingVault. + * @param _recipient The address to withdraw the funds to. + * @param _ether The amount of ether to withdraw from the StakingVault. + * @dev The zero checks for recipient and ether are performed in the StakingVault contract. + */ function _withdraw(address _recipient, uint256 _ether) internal virtual onlyRole(WITHDRAW_ROLE) { stakingVault().withdraw(_recipient, _ether); } + /** + * @dev Checks the MINT_ROLE and mints shares backed by the StakingVault. + * @param _recipient The address to mint the shares to. + * @param _shares The amount of shares to mint. + * @dev The zero checks for parameters are performed in the VaultHub contract. + */ function _mintShares(address _recipient, uint256 _shares) internal onlyRole(MINT_ROLE) { vaultHub.mintSharesBackedByVault(address(stakingVault()), _recipient, _shares); } + /** + * @dev Checks the BURN_ROLE and burns shares backed by the StakingVault. + * @param _shares The amount of shares to burn. + * @dev The zero check for parameters is performed in the VaultHub contract. + */ function _burnShares(uint256 _shares) internal onlyRole(BURN_ROLE) { vaultHub.burnSharesBackedByVault(address(stakingVault()), _shares); } + /** + * @dev Checks the REBALANCE_ROLE and rebalances the StakingVault. + * @param _ether The amount of ether to rebalance the StakingVault with. + * @dev The zero check for parameters is performed in the StakingVault contract. + */ function _rebalanceVault(uint256 _ether) internal onlyRole(REBALANCE_ROLE) { stakingVault().rebalance(_ether); } + /** + * @dev Checks the PAUSE_BEACON_CHAIN_DEPOSITS_ROLE and pauses beacon chain deposits on the StakingVault. + */ function _pauseBeaconChainDeposits() internal onlyRole(PAUSE_BEACON_CHAIN_DEPOSITS_ROLE) { stakingVault().pauseBeaconChainDeposits(); } + /** + * @dev Checks the RESUME_BEACON_CHAIN_DEPOSITS_ROLE and resumes beacon chain deposits on the StakingVault. + */ function _resumeBeaconChainDeposits() internal onlyRole(RESUME_BEACON_CHAIN_DEPOSITS_ROLE) { stakingVault().resumeBeaconChainDeposits(); } + /** + * @dev Checks the REQUEST_VALIDATOR_EXIT_ROLE and requests validator exit on the StakingVault. + * @param _pubkey The public key of the validator to request exit for. + */ function _requestValidatorExit(bytes calldata _pubkey) internal onlyRole(REQUEST_VALIDATOR_EXIT_ROLE) { stakingVault().requestValidatorExit(_pubkey); } + /** + * @dev Checks the VOLUNTARY_DISCONNECT_ROLE and voluntarily disconnects the StakingVault. + */ function _voluntaryDisconnect() internal onlyRole(VOLUNTARY_DISCONNECT_ROLE) { vaultHub.voluntaryDisconnect(address(stakingVault())); } + /** + * @dev Checks the DEFAULT_ADMIN_ROLE and transfers the StakingVault ownership. + * @param _newOwner The address to transfer the StakingVault ownership to. + */ function _transferStakingVaultOwnership(address _newOwner) internal onlyConfirmed(_confirmingRoles()) { OwnableUpgradeable(address(stakingVault())).transferOwnership(_newOwner); } + /** + * @dev Loads the address of the underlying StakingVault. + * @return addr The address of the StakingVault. + */ function _loadStakingVaultAddress() internal view returns (address addr) { bytes memory args = Clones.fetchCloneArgs(address(this)); assembly { From 5d905492eca18ac4a2c74c32082ad5d02fd57036 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 12 Feb 2025 13:53:25 +0500 Subject: [PATCH 094/184] fix: update some naming --- contracts/0.8.25/vaults/Dashboard.sol | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 245423cda..1fdfd9d97 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -30,9 +30,9 @@ interface IWstETH is IERC20, IERC20Permit { /** * @title Dashboard - * @notice This contract is meant to be used as the owner of `StakingVault`. - * This contract improves the vault UX by bundling all functions from the vault and vault hub - * in this single contract. It provides administrative functions for managing the staking vault, + * @notice This contract is a UX-layer for `StakingVault`. + * This contract improves the vault UX by bundling all functions from the StakingVault and VaultHub + * in this single contract. It provides administrative functions for managing the StakingVault, * including funding, withdrawing, minting, burning, and rebalancing operations. */ contract Dashboard is Permissions { @@ -99,6 +99,10 @@ contract Dashboard is Permissions { // ==================== View Functions ==================== + /** + * @notice Returns the roles that need to confirm multi-role operations. + * @return The roles that need to confirm the call. + */ function confirmingRoles() external pure returns (bytes32[] memory) { return _confirmingRoles(); } @@ -147,7 +151,7 @@ contract Dashboard is Permissions { * @notice Returns the treasury fee basis points. * @return The treasury fee in basis points as a uint16. */ - function treasuryFee() external view returns (uint16) { + function treasuryFeeBP() external view returns (uint16) { return vaultSocket().treasuryFeeBP; } From 9e6ad2163e7dc13d368931e7088d9953c8a0000f Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 12 Feb 2025 17:49:35 +0500 Subject: [PATCH 095/184] fix(VaultFactory): role names --- contracts/0.8.25/vaults/VaultFactory.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/0.8.25/vaults/VaultFactory.sol b/contracts/0.8.25/vaults/VaultFactory.sol index 209a4ea4f..bb85c2d51 100644 --- a/contracts/0.8.25/vaults/VaultFactory.sol +++ b/contracts/0.8.25/vaults/VaultFactory.sol @@ -24,8 +24,8 @@ struct DelegationConfig { address curatorFeeSetter; address curatorFeeClaimer; address nodeOperatorManager; - address nodeOperatorFeeConfirm; - address nodeOperatorFeeClaim; + address nodeOperatorFeeConfirmer; + address nodeOperatorFeeClaimer; uint16 curatorFeeBP; uint16 nodeOperatorFeeBP; uint256 confirmLifetime; @@ -85,8 +85,8 @@ contract VaultFactory { delegation.grantRole(delegation.CURATOR_FEE_SET_ROLE(), _delegationConfig.curatorFeeSetter); delegation.grantRole(delegation.CURATOR_FEE_CLAIM_ROLE(), _delegationConfig.curatorFeeClaimer); delegation.grantRole(delegation.NODE_OPERATOR_MANAGER_ROLE(), _delegationConfig.nodeOperatorManager); - delegation.grantRole(delegation.NODE_OPERATOR_FEE_CLAIM_ROLE(), _delegationConfig.nodeOperatorFeeClaim); - delegation.grantRole(delegation.NODE_OPERATOR_FEE_CONFIRM_ROLE(), _delegationConfig.nodeOperatorFeeConfirm); + delegation.grantRole(delegation.NODE_OPERATOR_FEE_CLAIM_ROLE(), _delegationConfig.nodeOperatorFeeClaimer); + delegation.grantRole(delegation.NODE_OPERATOR_FEE_CONFIRM_ROLE(), _delegationConfig.nodeOperatorFeeConfirmer); // grant temporary roles to factory for setting fees delegation.grantRole(delegation.CURATOR_FEE_SET_ROLE(), address(this)); From 48ddb15bf6619b8473964cce00a0e26bbd95cfa1 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 12 Feb 2025 17:50:07 +0500 Subject: [PATCH 096/184] fix(ACLConfirmable): log expiry timestamp --- contracts/0.8.25/utils/AccessControlConfirmable.sol | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/contracts/0.8.25/utils/AccessControlConfirmable.sol b/contracts/0.8.25/utils/AccessControlConfirmable.sol index 4ec521085..36786f143 100644 --- a/contracts/0.8.25/utils/AccessControlConfirmable.sol +++ b/contracts/0.8.25/utils/AccessControlConfirmable.sol @@ -79,6 +79,7 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { uint256 numberOfConfirms = 0; bool[] memory deferredConfirms = new bool[](numberOfRoles); bool isRoleMember = false; + uint256 expiryTimestamp = block.timestamp + confirmLifetime; for (uint256 i = 0; i < numberOfRoles; ++i) { bytes32 role = _roles[i]; @@ -88,7 +89,7 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { numberOfConfirms++; deferredConfirms[i] = true; - emit RoleMemberConfirmed(msg.sender, role, block.timestamp, msg.data); + emit RoleMemberConfirmed(msg.sender, role, expiryTimestamp, msg.data); } else if (confirmations[msg.data][role] >= block.timestamp) { numberOfConfirms++; } @@ -106,7 +107,7 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { for (uint256 i = 0; i < numberOfRoles; ++i) { if (deferredConfirms[i]) { bytes32 role = _roles[i]; - confirmations[msg.data][role] = block.timestamp + confirmLifetime; + confirmations[msg.data][role] = expiryTimestamp; } } } @@ -138,10 +139,10 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { * @dev Emitted when a role member confirms. * @param member The address of the confirming member. * @param role The role of the confirming member. - * @param timestamp The timestamp of the confirmation. + * @param expiryTimestamp The timestamp of the confirmation. * @param data The msg.data of the confirmation (selector + arguments). */ - event RoleMemberConfirmed(address indexed member, bytes32 indexed role, uint256 timestamp, bytes data); + event RoleMemberConfirmed(address indexed member, bytes32 indexed role, uint256 expiryTimestamp, bytes data); /** * @dev Thrown when attempting to set confirmation lifetime to zero. From d5b5f0b825bc1f50e4e4066fff90749bec98d036 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 12 Feb 2025 17:51:05 +0500 Subject: [PATCH 097/184] fix(tests): update delegation tests --- .../vaults/delegation/delegation.test.ts | 199 ++++++++++-------- 1 file changed, 108 insertions(+), 91 deletions(-) diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index 7b4651a2b..97eab50fc 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -36,9 +36,12 @@ describe("Delegation.sol", () => { let depositResumer: HardhatEthersSigner; let exitRequester: HardhatEthersSigner; let disconnecter: HardhatEthersSigner; - let curator: HardhatEthersSigner; + let curatorFeeSetter: HardhatEthersSigner; + let curatorFeeClaimer: HardhatEthersSigner; let nodeOperatorManager: HardhatEthersSigner; + let nodeOperatorFeeConfirmer: HardhatEthersSigner; let nodeOperatorFeeClaimer: HardhatEthersSigner; + let stranger: HardhatEthersSigner; let beaconOwner: HardhatEthersSigner; let hubSigner: HardhatEthersSigner; @@ -72,8 +75,10 @@ describe("Delegation.sol", () => { depositResumer, exitRequester, disconnecter, - curator, + curatorFeeSetter, + curatorFeeClaimer, nodeOperatorManager, + nodeOperatorFeeConfirmer, nodeOperatorFeeClaimer, stranger, beaconOwner, @@ -114,11 +119,14 @@ describe("Delegation.sol", () => { depositResumer, exitRequester, disconnecter, - curator, + curatorFeeSetter, + curatorFeeClaimer, nodeOperatorManager, + nodeOperatorFeeConfirmer, nodeOperatorFeeClaimer, curatorFeeBP: 0n, nodeOperatorFeeBP: 0n, + confirmLifetime: days(7n), }, "0x", ); @@ -173,13 +181,16 @@ describe("Delegation.sol", () => { context("initialize", () => { it("reverts if already initialized", async () => { - await expect(delegation.initialize(vaultOwner)).to.be.revertedWithCustomError(delegation, "AlreadyInitialized"); + await expect(delegation.initialize(vaultOwner, days(7n))).to.be.revertedWithCustomError( + delegation, + "AlreadyInitialized", + ); }); it("reverts if called on the implementation", async () => { const delegation_ = await ethers.deployContract("Delegation", [weth, lidoLocator]); - await expect(delegation_.initialize(vaultOwner)).to.be.revertedWithCustomError( + await expect(delegation_.initialize(vaultOwner, days(7n))).to.be.revertedWithCustomError( delegation_, "NonProxyCallsForbidden", ); @@ -204,9 +215,11 @@ describe("Delegation.sol", () => { await assertSoleMember(depositResumer, await delegation.RESUME_BEACON_CHAIN_DEPOSITS_ROLE()); await assertSoleMember(exitRequester, await delegation.REQUEST_VALIDATOR_EXIT_ROLE()); await assertSoleMember(disconnecter, await delegation.VOLUNTARY_DISCONNECT_ROLE()); - await assertSoleMember(curator, await delegation.CURATOR_ROLE()); + await assertSoleMember(curatorFeeSetter, await delegation.CURATOR_FEE_SET_ROLE()); + await assertSoleMember(curatorFeeClaimer, await delegation.CURATOR_FEE_CLAIM_ROLE()); await assertSoleMember(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE()); - await assertSoleMember(nodeOperatorFeeClaimer, await delegation.NODE_OPERATOR_FEE_CLAIMER_ROLE()); + await assertSoleMember(nodeOperatorFeeConfirmer, await delegation.NODE_OPERATOR_FEE_CONFIRM_ROLE()); + await assertSoleMember(nodeOperatorFeeClaimer, await delegation.NODE_OPERATOR_FEE_CLAIM_ROLE()); expect(await delegation.curatorFeeBP()).to.equal(0n); expect(await delegation.nodeOperatorFeeBP()).to.equal(0n); @@ -217,41 +230,41 @@ describe("Delegation.sol", () => { }); }); - context("votingCommittee", () => { + context("confirmingRoles", () => { it("returns the correct roles", async () => { - expect(await delegation.votingCommittee()).to.deep.equal([ - await delegation.CURATOR_ROLE(), + expect(await delegation.confirmingRoles()).to.deep.equal([ + await delegation.DEFAULT_ADMIN_ROLE(), await delegation.NODE_OPERATOR_MANAGER_ROLE(), ]); }); }); - context("setVoteLifetime", () => { - it("reverts if the caller is not a member of the vote lifetime committee", async () => { - await expect(delegation.connect(stranger).setVoteLifetime(days(10n))).to.be.revertedWithCustomError( + context("setConfirmLifetime", () => { + it("reverts if the caller is not a member of the confirm lifetime committee", async () => { + await expect(delegation.connect(stranger).setConfirmLifetime(days(10n))).to.be.revertedWithCustomError( delegation, - "NotACommitteeMember", + "SenderNotMember", ); }); - it("sets the new vote lifetime", async () => { - const oldVoteLifetime = await delegation.voteLifetime(); - const newVoteLifetime = days(10n); - const msgData = delegation.interface.encodeFunctionData("setVoteLifetime", [newVoteLifetime]); - let voteTimestamp = await getNextBlockTimestamp(); + it("sets the new confirm lifetime", async () => { + const oldConfirmLifetime = await delegation.confirmLifetime(); + const newConfirmLifetime = days(10n); + const msgData = delegation.interface.encodeFunctionData("setConfirmLifetime", [newConfirmLifetime]); + let confirmTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); - await expect(delegation.connect(curator).setVoteLifetime(newVoteLifetime)) - .to.emit(delegation, "RoleMemberVoted") - .withArgs(curator, await delegation.CURATOR_ROLE(), voteTimestamp, msgData); + await expect(delegation.connect(vaultOwner).setConfirmLifetime(newConfirmLifetime)) + .to.emit(delegation, "RoleMemberConfirmed") + .withArgs(vaultOwner, await delegation.DEFAULT_ADMIN_ROLE(), confirmTimestamp, msgData); - voteTimestamp = await getNextBlockTimestamp(); - await expect(delegation.connect(nodeOperatorManager).setVoteLifetime(newVoteLifetime)) - .to.emit(delegation, "RoleMemberVoted") - .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), voteTimestamp, msgData) - .and.to.emit(delegation, "VoteLifetimeSet") - .withArgs(nodeOperatorManager, oldVoteLifetime, newVoteLifetime); + confirmTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); + await expect(delegation.connect(nodeOperatorManager).setConfirmLifetime(newConfirmLifetime)) + .to.emit(delegation, "RoleMemberConfirmed") + .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), confirmTimestamp, msgData) + .and.to.emit(delegation, "ConfirmLifetimeSet") + .withArgs(nodeOperatorManager, oldConfirmLifetime, newConfirmLifetime); - expect(await delegation.voteLifetime()).to.equal(newVoteLifetime); + expect(await delegation.confirmLifetime()).to.equal(newConfirmLifetime); }); }); @@ -259,25 +272,25 @@ describe("Delegation.sol", () => { it("reverts if the caller is not a member of the curator due claim role", async () => { await expect(delegation.connect(stranger).claimCuratorFee(stranger)) .to.be.revertedWithCustomError(delegation, "AccessControlUnauthorizedAccount") - .withArgs(stranger, await delegation.CURATOR_ROLE()); + .withArgs(stranger, await delegation.CURATOR_FEE_CLAIM_ROLE()); }); it("reverts if the recipient is the zero address", async () => { - await expect(delegation.connect(curator).claimCuratorFee(ethers.ZeroAddress)) + await expect(delegation.connect(curatorFeeClaimer).claimCuratorFee(ethers.ZeroAddress)) .to.be.revertedWithCustomError(delegation, "ZeroArgument") .withArgs("_recipient"); }); - it("reverts if the due is zero", async () => { + it("reverts if the fee is zero", async () => { expect(await delegation.curatorUnclaimedFee()).to.equal(0n); - await expect(delegation.connect(curator).claimCuratorFee(stranger)) + await expect(delegation.connect(curatorFeeClaimer).claimCuratorFee(stranger)) .to.be.revertedWithCustomError(delegation, "ZeroArgument") .withArgs("_fee"); }); - it("claims the due", async () => { + it("claims the fee", async () => { const curatorFee = 10_00n; // 10% - await delegation.connect(vaultOwner).setCuratorFeeBP(curatorFee); + await delegation.connect(curatorFeeSetter).setCuratorFeeBP(curatorFee); expect(await delegation.curatorFeeBP()).to.equal(curatorFee); const rewards = ether("1"); @@ -292,7 +305,7 @@ describe("Delegation.sol", () => { expect(await ethers.provider.getBalance(vault)).to.equal(rewards); expect(await ethers.provider.getBalance(recipient)).to.equal(0n); - await expect(delegation.connect(curator).claimCuratorFee(recipient)) + await expect(delegation.connect(curatorFeeClaimer).claimCuratorFee(recipient)) .to.emit(vault, "Withdrawn") .withArgs(delegation, recipient, expectedDue); expect(await ethers.provider.getBalance(recipient)).to.equal(expectedDue); @@ -324,7 +337,7 @@ describe("Delegation.sol", () => { it("claims the due", async () => { const operatorFee = 10_00n; // 10% await delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(operatorFee); - await delegation.connect(curator).setNodeOperatorFeeBP(operatorFee); + await delegation.connect(vaultOwner).setNodeOperatorFeeBP(operatorFee); expect(await delegation.nodeOperatorFeeBP()).to.equal(operatorFee); const rewards = ether("1"); @@ -509,13 +522,13 @@ describe("Delegation.sol", () => { it("reverts if caller is not curator", async () => { await expect(delegation.connect(stranger).setCuratorFeeBP(1000n)) .to.be.revertedWithCustomError(delegation, "AccessControlUnauthorizedAccount") - .withArgs(stranger, await delegation.DEFAULT_ADMIN_ROLE()); + .withArgs(stranger, await delegation.CURATOR_FEE_SET_ROLE()); }); it("reverts if curator fee is not zero", async () => { // set the curator fee to 5% const newCuratorFee = 500n; - await delegation.connect(vaultOwner).setCuratorFeeBP(newCuratorFee); + await delegation.connect(curatorFeeSetter).setCuratorFeeBP(newCuratorFee); expect(await delegation.curatorFeeBP()).to.equal(newCuratorFee); // bring rewards @@ -526,14 +539,14 @@ describe("Delegation.sol", () => { expect(await delegation.curatorUnclaimedFee()).to.equal((totalRewards * newCuratorFee) / BP_BASE); // attempt to change the performance fee to 6% - await expect(delegation.connect(vaultOwner).setCuratorFeeBP(600n)).to.be.revertedWithCustomError( + await expect(delegation.connect(curatorFeeSetter).setCuratorFeeBP(600n)).to.be.revertedWithCustomError( delegation, "CuratorFeeUnclaimed", ); }); it("reverts if new fee is greater than max fee", async () => { - await expect(delegation.connect(vaultOwner).setCuratorFeeBP(MAX_FEE + 1n)).to.be.revertedWithCustomError( + await expect(delegation.connect(curatorFeeSetter).setCuratorFeeBP(MAX_FEE + 1n)).to.be.revertedWithCustomError( delegation, "CombinedFeesExceed100Percent", ); @@ -541,7 +554,7 @@ describe("Delegation.sol", () => { it("sets the curator fee", async () => { const newCuratorFee = 1000n; - await delegation.connect(vaultOwner).setCuratorFeeBP(newCuratorFee); + await delegation.connect(curatorFeeSetter).setCuratorFeeBP(newCuratorFee); expect(await delegation.curatorFeeBP()).to.equal(newCuratorFee); }); }); @@ -549,7 +562,7 @@ describe("Delegation.sol", () => { context("setOperatorFee", () => { it("reverts if new fee is greater than max fee", async () => { const invalidFee = MAX_FEE + 1n; - await delegation.connect(curator).setNodeOperatorFeeBP(invalidFee); + await delegation.connect(vaultOwner).setNodeOperatorFeeBP(invalidFee); await expect( delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(invalidFee), @@ -559,7 +572,7 @@ describe("Delegation.sol", () => { it("reverts if performance due is not zero", async () => { // set the performance fee to 5% const newOperatorFee = 500n; - await delegation.connect(curator).setNodeOperatorFeeBP(newOperatorFee); + await delegation.connect(vaultOwner).setNodeOperatorFeeBP(newOperatorFee); await delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(newOperatorFee); expect(await delegation.nodeOperatorFeeBP()).to.equal(newOperatorFee); @@ -571,39 +584,41 @@ describe("Delegation.sol", () => { expect(await delegation.nodeOperatorUnclaimedFee()).to.equal((totalRewards * newOperatorFee) / BP_BASE); // attempt to change the performance fee to 6% - await delegation.connect(curator).setNodeOperatorFeeBP(600n); + await delegation.connect(vaultOwner).setNodeOperatorFeeBP(600n); await expect(delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(600n)).to.be.revertedWithCustomError( delegation, "NodeOperatorFeeUnclaimed", ); }); - it("requires both curator and operator to set the operator fee and emits the RoleMemberVoted event", async () => { + it("requires both default admin and operator manager to set the operator fee and emits the RoleMemberConfirmed event", async () => { const previousOperatorFee = await delegation.nodeOperatorFeeBP(); const newOperatorFee = 1000n; - let voteTimestamp = await getNextBlockTimestamp(); + let expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); const msgData = delegation.interface.encodeFunctionData("setNodeOperatorFeeBP", [newOperatorFee]); - await expect(delegation.connect(curator).setNodeOperatorFeeBP(newOperatorFee)) - .to.emit(delegation, "RoleMemberVoted") - .withArgs(curator, await delegation.CURATOR_ROLE(), voteTimestamp, msgData); + await expect(delegation.connect(vaultOwner).setNodeOperatorFeeBP(newOperatorFee)) + .to.emit(delegation, "RoleMemberConfirmed") + .withArgs(vaultOwner, await delegation.DEFAULT_ADMIN_ROLE(), expiryTimestamp, msgData); // fee is unchanged expect(await delegation.nodeOperatorFeeBP()).to.equal(previousOperatorFee); - // check vote - expect(await delegation.votings(keccak256(msgData), await delegation.CURATOR_ROLE())).to.equal(voteTimestamp); + // check confirm + expect(await delegation.confirmationExpiryTimestamp(msgData, await delegation.DEFAULT_ADMIN_ROLE())).to.equal( + expiryTimestamp, + ); - voteTimestamp = await getNextBlockTimestamp(); + expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); await expect(delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(newOperatorFee)) - .to.emit(delegation, "RoleMemberVoted") - .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), voteTimestamp, msgData) + .to.emit(delegation, "RoleMemberConfirmed") + .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), expiryTimestamp, msgData) .and.to.emit(delegation, "NodeOperatorFeeBPSet") .withArgs(nodeOperatorManager, previousOperatorFee, newOperatorFee); expect(await delegation.nodeOperatorFeeBP()).to.equal(newOperatorFee); - // resets the votes - for (const role of await delegation.votingCommittee()) { - expect(await delegation.votings(keccak256(msgData), role)).to.equal(0n); + // resets the confirms + for (const role of await delegation.confirmingRoles()) { + expect(await delegation.confirmationExpiryTimestamp(keccak256(msgData), role)).to.equal(0n); } }); @@ -611,46 +626,48 @@ describe("Delegation.sol", () => { const newOperatorFee = 1000n; await expect(delegation.connect(stranger).setNodeOperatorFeeBP(newOperatorFee)).to.be.revertedWithCustomError( delegation, - "NotACommitteeMember", + "SenderNotMember", ); }); - it("doesn't execute if an earlier vote has expired", async () => { + it("doesn't execute if an earlier confirm has expired", async () => { const previousOperatorFee = await delegation.nodeOperatorFeeBP(); const newOperatorFee = 1000n; const msgData = delegation.interface.encodeFunctionData("setNodeOperatorFeeBP", [newOperatorFee]); - const callId = keccak256(msgData); - let voteTimestamp = await getNextBlockTimestamp(); - await expect(delegation.connect(curator).setNodeOperatorFeeBP(newOperatorFee)) - .to.emit(delegation, "RoleMemberVoted") - .withArgs(curator, await delegation.CURATOR_ROLE(), voteTimestamp, msgData); + let expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); + + await expect(delegation.connect(vaultOwner).setNodeOperatorFeeBP(newOperatorFee)) + .to.emit(delegation, "RoleMemberConfirmed") + .withArgs(vaultOwner, await delegation.DEFAULT_ADMIN_ROLE(), expiryTimestamp, msgData); // fee is unchanged expect(await delegation.nodeOperatorFeeBP()).to.equal(previousOperatorFee); - // check vote - expect(await delegation.votings(callId, await delegation.CURATOR_ROLE())).to.equal(voteTimestamp); + // check confirm + expect(await delegation.confirmationExpiryTimestamp(msgData, await delegation.DEFAULT_ADMIN_ROLE())).to.equal( + expiryTimestamp, + ); // move time forward await advanceChainTime(days(7n) + 1n); - const expectedVoteTimestamp = await getNextBlockTimestamp(); - expect(expectedVoteTimestamp).to.be.greaterThan(voteTimestamp + days(7n)); + const expectedExpiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); + expect(expectedExpiryTimestamp).to.be.greaterThan(expiryTimestamp + days(7n)); await expect(delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(newOperatorFee)) - .to.emit(delegation, "RoleMemberVoted") - .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), expectedVoteTimestamp, msgData); + .to.emit(delegation, "RoleMemberConfirmed") + .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), expectedExpiryTimestamp, msgData); // fee is still unchanged expect(await delegation.nodeOperatorFeeBP()).to.equal(previousOperatorFee); - // check vote - expect(await delegation.votings(callId, await delegation.NODE_OPERATOR_MANAGER_ROLE())).to.equal( - expectedVoteTimestamp, - ); - - // curator has to vote again - voteTimestamp = await getNextBlockTimestamp(); - await expect(delegation.connect(curator).setNodeOperatorFeeBP(newOperatorFee)) - .to.emit(delegation, "RoleMemberVoted") - .withArgs(curator, await delegation.CURATOR_ROLE(), voteTimestamp, msgData) + // check confirm + expect( + await delegation.confirmationExpiryTimestamp(msgData, await delegation.NODE_OPERATOR_MANAGER_ROLE()), + ).to.equal(expectedExpiryTimestamp); + + // curator has to confirm again + expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); + await expect(delegation.connect(vaultOwner).setNodeOperatorFeeBP(newOperatorFee)) + .to.emit(delegation, "RoleMemberConfirmed") + .withArgs(vaultOwner, await delegation.DEFAULT_ADMIN_ROLE(), expiryTimestamp, msgData) .and.to.emit(delegation, "NodeOperatorFeeBPSet") - .withArgs(curator, previousOperatorFee, newOperatorFee); + .withArgs(vaultOwner, previousOperatorFee, newOperatorFee); // fee is now changed expect(await delegation.nodeOperatorFeeBP()).to.equal(newOperatorFee); }); @@ -660,24 +677,24 @@ describe("Delegation.sol", () => { it("reverts if the caller is not a member of the transfer committee", async () => { await expect(delegation.connect(stranger).transferStakingVaultOwnership(recipient)).to.be.revertedWithCustomError( delegation, - "NotACommitteeMember", + "SenderNotMember", ); }); - it("requires both curator and operator to transfer ownership and emits the RoleMemberVoted event", async () => { + it("requires both curator and operator to transfer ownership and emits the RoleMemberConfirmd event", async () => { const newOwner = certainAddress("newOwner"); const msgData = delegation.interface.encodeFunctionData("transferStakingVaultOwnership", [newOwner]); - let voteTimestamp = await getNextBlockTimestamp(); - await expect(delegation.connect(curator).transferStakingVaultOwnership(newOwner)) - .to.emit(delegation, "RoleMemberVoted") - .withArgs(curator, await delegation.CURATOR_ROLE(), voteTimestamp, msgData); + let expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); + await expect(delegation.connect(vaultOwner).transferStakingVaultOwnership(newOwner)) + .to.emit(delegation, "RoleMemberConfirmed") + .withArgs(vaultOwner, await delegation.DEFAULT_ADMIN_ROLE(), expiryTimestamp, msgData); // owner is unchanged expect(await vault.owner()).to.equal(delegation); - voteTimestamp = await getNextBlockTimestamp(); + expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); await expect(delegation.connect(nodeOperatorManager).transferStakingVaultOwnership(newOwner)) - .to.emit(delegation, "RoleMemberVoted") - .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), voteTimestamp, msgData); + .to.emit(delegation, "RoleMemberConfirmed") + .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), expiryTimestamp, msgData); // owner changed expect(await vault.owner()).to.equal(newOwner); }); From fb94485f12c438dea94a4b1da0c77ca4a65bd8f3 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 12 Feb 2025 13:05:54 +0000 Subject: [PATCH 098/184] 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 099/184] 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 100/184] 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 101/184] 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 102/184] 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 a286dc1b8a56627e53eaf6867943af9bf8d682d9 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Thu, 13 Feb 2025 13:18:04 +0500 Subject: [PATCH 103/184] fix: prevent 0 lifetime situations --- .../0.8.25/utils/AccessControlConfirmable.sol | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/contracts/0.8.25/utils/AccessControlConfirmable.sol b/contracts/0.8.25/utils/AccessControlConfirmable.sol index 36786f143..5e9b3aee6 100644 --- a/contracts/0.8.25/utils/AccessControlConfirmable.sol +++ b/contracts/0.8.25/utils/AccessControlConfirmable.sol @@ -15,25 +15,27 @@ import {AccessControlEnumerable} from "@openzeppelin/contracts-v5.2/access/exten abstract contract AccessControlConfirmable is AccessControlEnumerable { /** * @notice Tracks confirmations - * - callId: unique identifier for the call, derived as `keccak256(msg.data)` + * - callData: msg.data of the call (selector + arguments) * - role: role that confirmed the action - * - timestamp: timestamp of the confirmation. + * - expiryTimestamp: timestamp of the confirmation. */ mapping(bytes callData => mapping(bytes32 role => uint256 expiryTimestamp)) public confirmations; /** * @notice Confirmation lifetime in seconds; after this period, the confirmation expires and no longer counts. + * @dev We cannot set this to 0 because this means that all confirmations have to be in the same block, + * which can never be guaranteed. And, more importantly, if the `_setLifetime` is restricted by + * the `onlyConfirmed` modifier, the confirmation lifetime will be tricky to change. + * This is why this variable is private, set to a default value of 1 day and cannot be set to 0. */ - uint256 public confirmLifetime; + uint256 private confirmLifetime = 1 days; /** - * @notice Returns the expiry timestamp of the confirmation for a given call and role. - * @param _callData The call data of the function. - * @param _role The role that confirmed the call. - * @return The expiry timestamp of the confirmation. + * @notice Returns the confirmation lifetime. + * @return The confirmation lifetime in seconds. */ - function confirmationExpiryTimestamp(bytes calldata _callData, bytes32 _role) public view returns (uint256) { - return confirmations[_callData][_role]; + function getConfirmLifetime() public view returns (uint256) { + return confirmLifetime; } /** @@ -73,7 +75,6 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { */ modifier onlyConfirmed(bytes32[] memory _roles) { if (_roles.length == 0) revert ZeroConfirmingRoles(); - if (confirmLifetime == 0) revert ConfirmLifetimeNotSet(); uint256 numberOfRoles = _roles.length; uint256 numberOfConfirms = 0; @@ -114,9 +115,10 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { } /** - * @notice Sets the confirmation lifetime. - * Confirmation lifetime is a period during which the confirmation is counted. Once the period is over, - * the confirmation is considered expired, no longer counts and must be recasted for the confirmation to go through. + * @dev Sets the confirmation lifetime. + * Confirmation lifetime is a period during which the confirmation is counted. Once expired, + * the confirmation no longer counts and must be recasted for the confirmation to go through. + * @dev Does not retroactively apply to existing confirmations. * @param _newConfirmLifetime The new confirmation lifetime in seconds. */ function _setConfirmLifetime(uint256 _newConfirmLifetime) internal { From 0c2f6abee11aced90f73b60ea9a508b3391d3960 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Thu, 13 Feb 2025 14:58:28 +0500 Subject: [PATCH 104/184] feat: add lifetime bounds --- .../0.8.25/utils/AccessControlConfirmable.sol | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/contracts/0.8.25/utils/AccessControlConfirmable.sol b/contracts/0.8.25/utils/AccessControlConfirmable.sol index 5e9b3aee6..1f80d37da 100644 --- a/contracts/0.8.25/utils/AccessControlConfirmable.sol +++ b/contracts/0.8.25/utils/AccessControlConfirmable.sol @@ -21,6 +21,16 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { */ mapping(bytes callData => mapping(bytes32 role => uint256 expiryTimestamp)) public confirmations; + /** + * @notice Minimal confirmation lifetime in seconds. + */ + uint256 public constant MIN_CONFIRM_LIFETIME = 1 days; + + /** + * @notice Maximal confirmation lifetime in seconds. + */ + uint256 public constant MAX_CONFIRM_LIFETIME = 30 days; + /** * @notice Confirmation lifetime in seconds; after this period, the confirmation expires and no longer counts. * @dev We cannot set this to 0 because this means that all confirmations have to be in the same block, @@ -28,7 +38,7 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { * the `onlyConfirmed` modifier, the confirmation lifetime will be tricky to change. * This is why this variable is private, set to a default value of 1 day and cannot be set to 0. */ - uint256 private confirmLifetime = 1 days; + uint256 private confirmLifetime = MIN_CONFIRM_LIFETIME; /** * @notice Returns the confirmation lifetime. @@ -122,7 +132,8 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { * @param _newConfirmLifetime The new confirmation lifetime in seconds. */ function _setConfirmLifetime(uint256 _newConfirmLifetime) internal { - if (_newConfirmLifetime == 0) revert ConfirmLifetimeCannotBeZero(); + if (_newConfirmLifetime < MIN_CONFIRM_LIFETIME || _newConfirmLifetime > MAX_CONFIRM_LIFETIME) + revert ConfirmLifetimeOutOfBounds(); uint256 oldConfirmLifetime = confirmLifetime; confirmLifetime = _newConfirmLifetime; @@ -147,14 +158,9 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { event RoleMemberConfirmed(address indexed member, bytes32 indexed role, uint256 expiryTimestamp, bytes data); /** - * @dev Thrown when attempting to set confirmation lifetime to zero. - */ - error ConfirmLifetimeCannotBeZero(); - - /** - * @dev Thrown when attempting to confirm when the confirmation lifetime is not set. + * @dev Thrown when attempting to set confirmation lifetime out of bounds. */ - error ConfirmLifetimeNotSet(); + error ConfirmLifetimeOutOfBounds(); /** * @dev Thrown when a caller without a required role attempts to confirm. From a09e964df6308148383d9b2f3d2c03ec5a04b985 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Thu, 13 Feb 2025 15:00:07 +0500 Subject: [PATCH 105/184] test(ACLConfirmable): full coverage --- .../utils/access-control-confirmable.test.ts | 127 ++++++++++++++++++ .../AccessControlConfirmable__Harness.sol | 36 +++++ 2 files changed, 163 insertions(+) create mode 100644 test/0.8.25/utils/access-control-confirmable.test.ts create mode 100644 test/0.8.25/utils/contracts/AccessControlConfirmable__Harness.sol diff --git a/test/0.8.25/utils/access-control-confirmable.test.ts b/test/0.8.25/utils/access-control-confirmable.test.ts new file mode 100644 index 000000000..7b0e2357d --- /dev/null +++ b/test/0.8.25/utils/access-control-confirmable.test.ts @@ -0,0 +1,127 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { AccessControlConfirmable__Harness } from "typechain-types"; + +import { advanceChainTime, days, getNextBlockTimestamp } from "lib"; + +describe("AccessControlConfirmable.sol", () => { + let harness: AccessControlConfirmable__Harness; + let admin: HardhatEthersSigner; + let role1Member: HardhatEthersSigner; + let role2Member: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + + before(async () => { + [admin, stranger, role1Member, role2Member] = await ethers.getSigners(); + + harness = await ethers.deployContract("AccessControlConfirmable__Harness", [admin], admin); + expect(await harness.getConfirmLifetime()).to.equal(await harness.MIN_CONFIRM_LIFETIME()); + expect(await harness.hasRole(await harness.DEFAULT_ADMIN_ROLE(), admin)).to.be.true; + expect(await harness.getRoleMemberCount(await harness.DEFAULT_ADMIN_ROLE())).to.equal(1); + + await harness.grantRole(await harness.ROLE_1(), role1Member); + expect(await harness.hasRole(await harness.ROLE_1(), role1Member)).to.be.true; + expect(await harness.getRoleMemberCount(await harness.ROLE_1())).to.equal(1); + + await harness.grantRole(await harness.ROLE_2(), role2Member); + expect(await harness.hasRole(await harness.ROLE_2(), role2Member)).to.be.true; + expect(await harness.getRoleMemberCount(await harness.ROLE_2())).to.equal(1); + }); + + context("constants", () => { + it("returns the correct constants", async () => { + expect(await harness.MIN_CONFIRM_LIFETIME()).to.equal(days(1n)); + expect(await harness.MAX_CONFIRM_LIFETIME()).to.equal(days(30n)); + }); + }); + + context("getConfirmLifetime()", () => { + it("returns the minimal lifetime initially", async () => { + expect(await harness.getConfirmLifetime()).to.equal(await harness.MIN_CONFIRM_LIFETIME()); + }); + }); + + context("confirmingRoles()", () => { + it("should return the correct roles", async () => { + expect(await harness.confirmingRoles()).to.deep.equal([await harness.ROLE_1(), await harness.ROLE_2()]); + }); + }); + + context("setConfirmLifetime()", () => { + it("sets the confirm lifetime", async () => { + const oldLifetime = await harness.getConfirmLifetime(); + const newLifetime = days(14n); + await expect(harness.setConfirmLifetime(newLifetime)) + .to.emit(harness, "ConfirmLifetimeSet") + .withArgs(admin, oldLifetime, newLifetime); + expect(await harness.getConfirmLifetime()).to.equal(newLifetime); + }); + + it("reverts if the new lifetime is out of bounds", async () => { + await expect( + harness.setConfirmLifetime((await harness.MIN_CONFIRM_LIFETIME()) - 1n), + ).to.be.revertedWithCustomError(harness, "ConfirmLifetimeOutOfBounds"); + + await expect( + harness.setConfirmLifetime((await harness.MAX_CONFIRM_LIFETIME()) + 1n), + ).to.be.revertedWithCustomError(harness, "ConfirmLifetimeOutOfBounds"); + }); + }); + + context("setNumber()", () => { + it("reverts if the sender does not have the role", async () => { + for (const role of await harness.confirmingRoles()) { + expect(await harness.hasRole(role, stranger)).to.be.false; + await expect(harness.connect(stranger).setNumber(1)).to.be.revertedWithCustomError(harness, "SenderNotMember"); + } + }); + + it("sets the number", async () => { + const oldNumber = await harness.number(); + const newNumber = oldNumber + 1n; + // nothing happens + await harness.connect(role1Member).setNumber(newNumber); + expect(await harness.number()).to.equal(oldNumber); + + // confirm + await harness.connect(role2Member).setNumber(newNumber); + expect(await harness.number()).to.equal(newNumber); + }); + + it("doesn't execute if the confirmation has expired", async () => { + const oldNumber = await harness.number(); + const newNumber = 1; + const expiryTimestamp = (await getNextBlockTimestamp()) + (await harness.getConfirmLifetime()); + const msgData = harness.interface.encodeFunctionData("setNumber", [newNumber]); + + await expect(harness.connect(role1Member).setNumber(newNumber)) + .to.emit(harness, "RoleMemberConfirmed") + .withArgs(role1Member, await harness.ROLE_1(), expiryTimestamp, msgData); + expect(await harness.confirmations(msgData, await harness.ROLE_1())).to.equal(expiryTimestamp); + // still old number + expect(await harness.number()).to.equal(oldNumber); + + await advanceChainTime(expiryTimestamp + 1n); + + const newExpiryTimestamp = (await getNextBlockTimestamp()) + (await harness.getConfirmLifetime()); + await expect(harness.connect(role2Member).setNumber(newNumber)) + .to.emit(harness, "RoleMemberConfirmed") + .withArgs(role2Member, await harness.ROLE_2(), newExpiryTimestamp, msgData); + expect(await harness.confirmations(msgData, await harness.ROLE_2())).to.equal(newExpiryTimestamp); + // still old number + expect(await harness.number()).to.equal(oldNumber); + }); + }); + + context("decrementWithZeroRoles()", () => { + it("reverts if there are no confirming roles", async () => { + await expect(harness.connect(stranger).decrementWithZeroRoles()).to.be.revertedWithCustomError( + harness, + "ZeroConfirmingRoles", + ); + }); + }); +}); diff --git a/test/0.8.25/utils/contracts/AccessControlConfirmable__Harness.sol b/test/0.8.25/utils/contracts/AccessControlConfirmable__Harness.sol new file mode 100644 index 000000000..3a37e5988 --- /dev/null +++ b/test/0.8.25/utils/contracts/AccessControlConfirmable__Harness.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.25; + +import {AccessControlConfirmable} from "contracts/0.8.25/utils/AccessControlConfirmable.sol"; + +contract AccessControlConfirmable__Harness is AccessControlConfirmable { + bytes32 public constant ROLE_1 = keccak256("ROLE_1"); + bytes32 public constant ROLE_2 = keccak256("ROLE_2"); + + uint256 public number; + + constructor(address _admin) { + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + } + + function confirmingRoles() public pure returns (bytes32[] memory) { + bytes32[] memory roles = new bytes32[](2); + roles[0] = ROLE_1; + roles[1] = ROLE_2; + return roles; + } + + function setConfirmLifetime(uint256 _confirmLifetime) external { + _setConfirmLifetime(_confirmLifetime); + } + + function setNumber(uint256 _number) external onlyConfirmed(confirmingRoles()) { + number = _number; + } + + function decrementWithZeroRoles() external onlyConfirmed(new bytes32[](0)) { + number--; + } +} From 32bacb4731c7b2d62735d304fcb9cdc49f8a7f76 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Thu, 13 Feb 2025 11:25:25 +0000 Subject: [PATCH 106/184] 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 107/184] 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 108/184] 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 109/184] 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 110/184] 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 fdfe70b58ea3ced7a46f621ee82f7c1a455dd38b Mon Sep 17 00:00:00 2001 From: failingtwice Date: Fri, 14 Feb 2025 15:34:01 +0500 Subject: [PATCH 111/184] test(Permissions): full coverage --- .../contracts/Permissions__Harness.sol | 11 +- .../VaultFactory__MockPermissions.sol | 72 +++ .../contracts/VaultHub__MockPermissions.sol | 21 +- .../vaults/permissions/permissions.test.ts | 481 +++++++++++++++++- 4 files changed, 580 insertions(+), 5 deletions(-) diff --git a/test/0.8.25/vaults/permissions/contracts/Permissions__Harness.sol b/test/0.8.25/vaults/permissions/contracts/Permissions__Harness.sol index d73cbb826..390097bfb 100644 --- a/test/0.8.25/vaults/permissions/contracts/Permissions__Harness.sol +++ b/test/0.8.25/vaults/permissions/contracts/Permissions__Harness.sol @@ -10,11 +10,16 @@ contract Permissions__Harness is Permissions { _initialize(_defaultAdmin, _confirmLifetime); } + function revertDoubleInitialize(address _defaultAdmin, uint256 _confirmLifetime) external { + _initialize(_defaultAdmin, _confirmLifetime); + _initialize(_defaultAdmin, _confirmLifetime); + } + function confirmingRoles() external pure returns (bytes32[] memory) { return _confirmingRoles(); } - function fund(uint256 _ether) external { + function fund(uint256 _ether) external payable { _fund(_ether); } @@ -46,6 +51,10 @@ contract Permissions__Harness is Permissions { _requestValidatorExit(_pubkey); } + function voluntaryDisconnect() external { + _voluntaryDisconnect(); + } + function transferStakingVaultOwnership(address _newOwner) external { _transferStakingVaultOwnership(_newOwner); } diff --git a/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions.sol b/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions.sol index ba372a73c..61371970d 100644 --- a/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions.sol +++ b/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions.sol @@ -76,6 +76,78 @@ contract VaultFactory__MockPermissions { emit PermissionsCreated(_permissionsConfig.defaultAdmin, address(permissions)); } + function revertCreateVaultWithPermissionsWithDoubleInitialize( + PermissionsConfig calldata _permissionsConfig, + bytes calldata _stakingVaultInitializerExtraParams + ) external returns (IStakingVault vault, Permissions__Harness permissions) { + // create StakingVault + vault = IStakingVault(address(new BeaconProxy(BEACON, ""))); + + // create Permissions + bytes memory immutableArgs = abi.encode(vault); + permissions = Permissions__Harness(payable(Clones.cloneWithImmutableArgs(PERMISSIONS_IMPL, immutableArgs))); + + // initialize StakingVault + vault.initialize(address(permissions), _permissionsConfig.nodeOperator, _stakingVaultInitializerExtraParams); + + // initialize Permissions + permissions.initialize(address(this), _permissionsConfig.confirmLifetime); + // should revert here + permissions.initialize(address(this), _permissionsConfig.confirmLifetime); + + // setup roles + permissions.grantRole(permissions.DEFAULT_ADMIN_ROLE(), _permissionsConfig.defaultAdmin); + permissions.grantRole(permissions.FUND_ROLE(), _permissionsConfig.funder); + permissions.grantRole(permissions.WITHDRAW_ROLE(), _permissionsConfig.withdrawer); + permissions.grantRole(permissions.MINT_ROLE(), _permissionsConfig.minter); + permissions.grantRole(permissions.BURN_ROLE(), _permissionsConfig.burner); + permissions.grantRole(permissions.REBALANCE_ROLE(), _permissionsConfig.rebalancer); + permissions.grantRole(permissions.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), _permissionsConfig.depositPauser); + permissions.grantRole(permissions.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), _permissionsConfig.depositResumer); + permissions.grantRole(permissions.REQUEST_VALIDATOR_EXIT_ROLE(), _permissionsConfig.exitRequester); + permissions.grantRole(permissions.VOLUNTARY_DISCONNECT_ROLE(), _permissionsConfig.disconnecter); + + permissions.revokeRole(permissions.DEFAULT_ADMIN_ROLE(), address(this)); + + emit VaultCreated(address(permissions), address(vault)); + emit PermissionsCreated(_permissionsConfig.defaultAdmin, address(permissions)); + } + + function revertCreateVaultWithPermissionsWithZeroDefaultAdmin( + PermissionsConfig calldata _permissionsConfig, + bytes calldata _stakingVaultInitializerExtraParams + ) external returns (IStakingVault vault, Permissions__Harness permissions) { + // create StakingVault + vault = IStakingVault(address(new BeaconProxy(BEACON, ""))); + + // create Permissions + bytes memory immutableArgs = abi.encode(vault); + permissions = Permissions__Harness(payable(Clones.cloneWithImmutableArgs(PERMISSIONS_IMPL, immutableArgs))); + + // initialize StakingVault + vault.initialize(address(permissions), _permissionsConfig.nodeOperator, _stakingVaultInitializerExtraParams); + + // should revert here + permissions.initialize(address(0), _permissionsConfig.confirmLifetime); + + // setup roles + permissions.grantRole(permissions.DEFAULT_ADMIN_ROLE(), _permissionsConfig.defaultAdmin); + permissions.grantRole(permissions.FUND_ROLE(), _permissionsConfig.funder); + permissions.grantRole(permissions.WITHDRAW_ROLE(), _permissionsConfig.withdrawer); + permissions.grantRole(permissions.MINT_ROLE(), _permissionsConfig.minter); + permissions.grantRole(permissions.BURN_ROLE(), _permissionsConfig.burner); + permissions.grantRole(permissions.REBALANCE_ROLE(), _permissionsConfig.rebalancer); + permissions.grantRole(permissions.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), _permissionsConfig.depositPauser); + permissions.grantRole(permissions.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), _permissionsConfig.depositResumer); + permissions.grantRole(permissions.REQUEST_VALIDATOR_EXIT_ROLE(), _permissionsConfig.exitRequester); + permissions.grantRole(permissions.VOLUNTARY_DISCONNECT_ROLE(), _permissionsConfig.disconnecter); + + permissions.revokeRole(permissions.DEFAULT_ADMIN_ROLE(), address(this)); + + emit VaultCreated(address(permissions), address(vault)); + emit PermissionsCreated(_permissionsConfig.defaultAdmin, address(permissions)); + } + /** * @notice Event emitted on a Vault creation * @param owner The address of the Vault owner diff --git a/test/0.8.25/vaults/permissions/contracts/VaultHub__MockPermissions.sol b/test/0.8.25/vaults/permissions/contracts/VaultHub__MockPermissions.sol index f68a3f5a3..0322752b0 100644 --- a/test/0.8.25/vaults/permissions/contracts/VaultHub__MockPermissions.sol +++ b/test/0.8.25/vaults/permissions/contracts/VaultHub__MockPermissions.sol @@ -4,7 +4,24 @@ pragma solidity ^0.8.0; contract VaultHub__MockPermissions { - function hello() external pure returns (string memory) { - return "hello"; + event Mock__SharesMinted(address indexed _stakingVault, address indexed _recipient, uint256 _shares); + event Mock__SharesBurned(address indexed _stakingVault, uint256 _shares); + event Mock__Rebalanced(uint256 _ether); + event Mock__VoluntaryDisconnect(address indexed _stakingVault); + + function mintSharesBackedByVault(address _stakingVault, address _recipient, uint256 _shares) external { + emit Mock__SharesMinted(_stakingVault, _recipient, _shares); + } + + function burnSharesBackedByVault(address _stakingVault, uint256 _shares) external { + emit Mock__SharesBurned(_stakingVault, _shares); + } + + function rebalance() external payable { + emit Mock__Rebalanced(msg.value); + } + + function voluntaryDisconnect(address _stakingVault) external { + emit Mock__VoluntaryDisconnect(_stakingVault); } } diff --git a/test/0.8.25/vaults/permissions/permissions.test.ts b/test/0.8.25/vaults/permissions/permissions.test.ts index 868ff179e..84cd5909e 100644 --- a/test/0.8.25/vaults/permissions/permissions.test.ts +++ b/test/0.8.25/vaults/permissions/permissions.test.ts @@ -15,7 +15,9 @@ import { } from "typechain-types"; import { PermissionsConfigStruct } from "typechain-types/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions"; -import { days, findEvents } from "lib"; +import { certainAddress, days, ether, findEvents } from "lib"; + +import { Snapshot } from "test/suite"; describe("Permissions", () => { let deployer: HardhatEthersSigner; @@ -30,6 +32,7 @@ describe("Permissions", () => { let depositResumer: HardhatEthersSigner; let exitRequester: HardhatEthersSigner; let disconnecter: HardhatEthersSigner; + let stranger: HardhatEthersSigner; let depositContract: DepositContract__MockForStakingVault; let permissionsImpl: Permissions__Harness; @@ -40,6 +43,8 @@ describe("Permissions", () => { let stakingVault: StakingVault; let permissions: Permissions__Harness; + let originalState: string; + before(async () => { [ deployer, @@ -54,6 +59,7 @@ describe("Permissions", () => { depositResumer, exitRequester, disconnecter, + stranger, ] = await ethers.getSigners(); // 1. Deploy DepositContract @@ -120,7 +126,15 @@ describe("Permissions", () => { expect(permissionsCreatedEvent.args.admin).to.equal(defaultAdmin); }); - context("initial permissions", () => { + beforeEach(async () => { + originalState = await Snapshot.take(); + }); + + afterEach(async () => { + await Snapshot.restore(originalState); + }); + + context("initial state", () => { it("should have the correct roles", async () => { await checkSoleMember(defaultAdmin, await permissions.DEFAULT_ADMIN_ROLE()); await checkSoleMember(funder, await permissions.FUND_ROLE()); @@ -128,6 +142,469 @@ describe("Permissions", () => { await checkSoleMember(minter, await permissions.MINT_ROLE()); await checkSoleMember(burner, await permissions.BURN_ROLE()); await checkSoleMember(rebalancer, await permissions.REBALANCE_ROLE()); + await checkSoleMember(depositPauser, await permissions.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE()); + await checkSoleMember(depositResumer, await permissions.RESUME_BEACON_CHAIN_DEPOSITS_ROLE()); + await checkSoleMember(exitRequester, await permissions.REQUEST_VALIDATOR_EXIT_ROLE()); + await checkSoleMember(disconnecter, await permissions.VOLUNTARY_DISCONNECT_ROLE()); + }); + }); + + context("initialize()", () => { + it("reverts if called twice", async () => { + await expect( + vaultFactory.connect(deployer).revertCreateVaultWithPermissionsWithDoubleInitialize( + { + defaultAdmin, + nodeOperator, + confirmLifetime: days(7n), + funder, + withdrawer, + minter, + burner, + rebalancer, + depositPauser, + depositResumer, + exitRequester, + disconnecter, + } as PermissionsConfigStruct, + "0x", + ), + ).to.be.revertedWithCustomError(permissions, "AlreadyInitialized"); + }); + + it("reverts if called on the implementation", async () => { + const newImplementation = await ethers.deployContract("Permissions__Harness"); + await expect(newImplementation.initialize(defaultAdmin, days(7n))).to.be.revertedWithCustomError( + permissions, + "NonProxyCallsForbidden", + ); + }); + + it("reverts if zero address is passed as default admin", async () => { + await expect( + vaultFactory.connect(deployer).revertCreateVaultWithPermissionsWithZeroDefaultAdmin( + { + defaultAdmin, + nodeOperator, + confirmLifetime: days(7n), + funder, + withdrawer, + minter, + burner, + rebalancer, + depositPauser, + depositResumer, + exitRequester, + disconnecter, + } as PermissionsConfigStruct, + "0x", + ), + ) + .to.be.revertedWithCustomError(permissions, "ZeroArgument") + .withArgs("_defaultAdmin"); + }); + }); + + context("stakingVault()", () => { + it("returns the correct staking vault", async () => { + expect(await permissions.stakingVault()).to.equal(stakingVault); + }); + }); + + context("grantRoles()", () => { + it("mass-grants roles", async () => { + const [ + fundRole, + withdrawRole, + mintRole, + burnRole, + rebalanceRole, + pauseDepositRole, + resumeDepositRole, + exitRequesterRole, + disconnectRole, + ] = await Promise.all([ + permissions.FUND_ROLE(), + permissions.WITHDRAW_ROLE(), + permissions.MINT_ROLE(), + permissions.BURN_ROLE(), + permissions.REBALANCE_ROLE(), + permissions.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), + permissions.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), + permissions.REQUEST_VALIDATOR_EXIT_ROLE(), + permissions.VOLUNTARY_DISCONNECT_ROLE(), + ]); + + const [ + anotherMinter, + anotherFunder, + anotherWithdrawer, + anotherBurner, + anotherRebalancer, + anotherDepositPauser, + anotherDepositResumer, + anotherExitRequester, + anotherDisconnecter, + ] = [ + certainAddress("another-minter"), + certainAddress("another-funder"), + certainAddress("another-withdrawer"), + certainAddress("another-burner"), + certainAddress("another-rebalancer"), + certainAddress("another-deposit-pauser"), + certainAddress("another-deposit-resumer"), + certainAddress("another-exit-requester"), + certainAddress("another-disconnecter"), + ]; + + const assignments = [ + { role: fundRole, account: anotherFunder }, + { role: withdrawRole, account: anotherWithdrawer }, + { role: mintRole, account: anotherMinter }, + { role: burnRole, account: anotherBurner }, + { role: rebalanceRole, account: anotherRebalancer }, + { role: pauseDepositRole, account: anotherDepositPauser }, + { role: resumeDepositRole, account: anotherDepositResumer }, + { role: exitRequesterRole, account: anotherExitRequester }, + { role: disconnectRole, account: anotherDisconnecter }, + ]; + + await expect(permissions.connect(defaultAdmin).grantRoles(assignments)) + .to.emit(permissions, "RoleGranted") + .withArgs(fundRole, anotherFunder, defaultAdmin) + .and.to.emit(permissions, "RoleGranted") + .withArgs(withdrawRole, anotherWithdrawer, defaultAdmin) + .and.to.emit(permissions, "RoleGranted") + .withArgs(mintRole, anotherMinter, defaultAdmin) + .and.to.emit(permissions, "RoleGranted") + .withArgs(burnRole, anotherBurner, defaultAdmin) + .and.to.emit(permissions, "RoleGranted") + .withArgs(rebalanceRole, anotherRebalancer, defaultAdmin) + .and.to.emit(permissions, "RoleGranted") + .withArgs(pauseDepositRole, anotherDepositPauser, defaultAdmin) + .and.to.emit(permissions, "RoleGranted") + .withArgs(resumeDepositRole, anotherDepositResumer, defaultAdmin) + .and.to.emit(permissions, "RoleGranted") + .withArgs(exitRequesterRole, anotherExitRequester, defaultAdmin) + .and.to.emit(permissions, "RoleGranted") + .withArgs(disconnectRole, anotherDisconnecter, defaultAdmin); + + for (const assignment of assignments) { + expect(await permissions.hasRole(assignment.role, assignment.account)).to.be.true; + expect(await permissions.getRoleMemberCount(assignment.role)).to.equal(2); + } + }); + + it("emits only one RoleGranted event per unique role-account pair", async () => { + const anotherMinter = certainAddress("another-minter"); + + const tx = await permissions.connect(defaultAdmin).grantRoles([ + { role: await permissions.MINT_ROLE(), account: anotherMinter }, + { role: await permissions.MINT_ROLE(), account: anotherMinter }, + ]); + + const receipt = await tx.wait(); + if (!receipt) throw new Error("Transaction failed"); + + const events = findEvents(receipt, "RoleGranted"); + expect(events.length).to.equal(1); + expect(events[0].args.role).to.equal(await permissions.MINT_ROLE()); + expect(events[0].args.account).to.equal(anotherMinter); + + expect(await permissions.hasRole(await permissions.MINT_ROLE(), anotherMinter)).to.be.true; + }); + + it("reverts if there are no assignments", async () => { + await expect(permissions.connect(defaultAdmin).grantRoles([])) + .to.be.revertedWithCustomError(permissions, "ZeroArgument") + .withArgs("_assignments"); + }); + }); + + context("revokeRoles()", () => { + it("mass-revokes roles", async () => { + const [ + fundRole, + withdrawRole, + mintRole, + burnRole, + rebalanceRole, + pauseDepositRole, + resumeDepositRole, + exitRequesterRole, + disconnectRole, + ] = await Promise.all([ + permissions.FUND_ROLE(), + permissions.WITHDRAW_ROLE(), + permissions.MINT_ROLE(), + permissions.BURN_ROLE(), + permissions.REBALANCE_ROLE(), + permissions.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), + permissions.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), + permissions.REQUEST_VALIDATOR_EXIT_ROLE(), + permissions.VOLUNTARY_DISCONNECT_ROLE(), + ]); + + const assignments = [ + { role: fundRole, account: funder }, + { role: withdrawRole, account: withdrawer }, + { role: mintRole, account: minter }, + { role: burnRole, account: burner }, + { role: rebalanceRole, account: rebalancer }, + { role: pauseDepositRole, account: depositPauser }, + { role: resumeDepositRole, account: depositResumer }, + { role: exitRequesterRole, account: exitRequester }, + { role: disconnectRole, account: disconnecter }, + ]; + + await expect(permissions.connect(defaultAdmin).revokeRoles(assignments)) + .to.emit(permissions, "RoleRevoked") + .withArgs(fundRole, funder, defaultAdmin) + .and.to.emit(permissions, "RoleRevoked") + .withArgs(withdrawRole, withdrawer, defaultAdmin) + .and.to.emit(permissions, "RoleRevoked") + .withArgs(mintRole, minter, defaultAdmin) + .and.to.emit(permissions, "RoleRevoked") + .withArgs(burnRole, burner, defaultAdmin) + .and.to.emit(permissions, "RoleRevoked") + .withArgs(rebalanceRole, rebalancer, defaultAdmin) + .and.to.emit(permissions, "RoleRevoked") + .withArgs(pauseDepositRole, depositPauser, defaultAdmin) + .and.to.emit(permissions, "RoleRevoked") + .withArgs(resumeDepositRole, depositResumer, defaultAdmin) + .and.to.emit(permissions, "RoleRevoked") + .withArgs(exitRequesterRole, exitRequester, defaultAdmin) + .and.to.emit(permissions, "RoleRevoked") + .withArgs(disconnectRole, disconnecter, defaultAdmin); + + for (const assignment of assignments) { + expect(await permissions.hasRole(assignment.role, assignment.account)).to.be.false; + expect(await permissions.getRoleMemberCount(assignment.role)).to.equal(0); + } + }); + + it("emits only one RoleRevoked event per unique role-account pair", async () => { + const tx = await permissions.connect(defaultAdmin).revokeRoles([ + { role: await permissions.MINT_ROLE(), account: minter }, + { role: await permissions.MINT_ROLE(), account: minter }, + ]); + + const receipt = await tx.wait(); + if (!receipt) throw new Error("Transaction failed"); + + const events = findEvents(receipt, "RoleRevoked"); + expect(events.length).to.equal(1); + expect(events[0].args.role).to.equal(await permissions.MINT_ROLE()); + expect(events[0].args.account).to.equal(minter); + + expect(await permissions.hasRole(await permissions.MINT_ROLE(), minter)).to.be.false; + }); + + it("reverts if there are no assignments", async () => { + await expect(permissions.connect(defaultAdmin).revokeRoles([])) + .to.be.revertedWithCustomError(permissions, "ZeroArgument") + .withArgs("_assignments"); + }); + }); + + context("confirmingRoles()", () => { + it("returns the correct roles", async () => { + expect(await permissions.confirmingRoles()).to.deep.equal([await permissions.DEFAULT_ADMIN_ROLE()]); + }); + }); + + context("fund()", () => { + it("funds the StakingVault", async () => { + const prevBalance = await ethers.provider.getBalance(stakingVault); + const fundAmount = ether("1"); + await expect(permissions.connect(funder).fund(fundAmount, { value: fundAmount })) + .to.emit(stakingVault, "Funded") + .withArgs(permissions, fundAmount); + + expect(await ethers.provider.getBalance(stakingVault)).to.equal(prevBalance + fundAmount); + }); + + it("reverts if the caller is not a member of the fund role", async () => { + expect(await permissions.hasRole(await permissions.FUND_ROLE(), stranger)).to.be.false; + + await expect(permissions.connect(stranger).fund(ether("1"), { value: ether("1") })) + .to.be.revertedWithCustomError(permissions, "AccessControlUnauthorizedAccount") + .withArgs(stranger, await permissions.FUND_ROLE()); + }); + }); + + context("withdraw()", () => { + it("withdraws the StakingVault", async () => { + const fundAmount = ether("1"); + await permissions.connect(funder).fund(fundAmount, { value: fundAmount }); + + const withdrawAmount = fundAmount; + const prevBalance = await ethers.provider.getBalance(stakingVault); + await expect(permissions.connect(withdrawer).withdraw(withdrawer, withdrawAmount)) + .to.emit(stakingVault, "Withdrawn") + .withArgs(permissions, withdrawer, withdrawAmount); + + expect(await ethers.provider.getBalance(stakingVault)).to.equal(prevBalance - withdrawAmount); + }); + + it("reverts if the caller is not a member of the withdraw role", async () => { + expect(await permissions.hasRole(await permissions.WITHDRAW_ROLE(), stranger)).to.be.false; + + await expect(permissions.connect(stranger).withdraw(stranger, ether("1"))) + .to.be.revertedWithCustomError(permissions, "AccessControlUnauthorizedAccount") + .withArgs(stranger, await permissions.WITHDRAW_ROLE()); + }); + }); + + context("mintShares()", () => { + it("emits mock event on the mock vault hub", async () => { + const mintAmount = ether("1"); + await expect(permissions.connect(minter).mintShares(minter, mintAmount)) + .to.emit(vaultHub, "Mock__SharesMinted") + .withArgs(stakingVault, minter, mintAmount); + }); + + it("reverts if the caller is not a member of the mint role", async () => { + expect(await permissions.hasRole(await permissions.MINT_ROLE(), stranger)).to.be.false; + + await expect(permissions.connect(stranger).mintShares(stranger, ether("1"))) + .to.be.revertedWithCustomError(permissions, "AccessControlUnauthorizedAccount") + .withArgs(stranger, await permissions.MINT_ROLE()); + }); + }); + + context("burnShares()", () => { + it("emits mock event on the mock vault hub", async () => { + const burnAmount = ether("1"); + await expect(permissions.connect(burner).burnShares(burnAmount)) + .to.emit(vaultHub, "Mock__SharesBurned") + .withArgs(stakingVault, burnAmount); + }); + + it("reverts if the caller is not a member of the burn role", async () => { + expect(await permissions.hasRole(await permissions.BURN_ROLE(), stranger)).to.be.false; + + await expect(permissions.connect(stranger).burnShares(ether("1"))) + .to.be.revertedWithCustomError(permissions, "AccessControlUnauthorizedAccount") + .withArgs(stranger, await permissions.BURN_ROLE()); + }); + }); + + context("rebalanceVault()", () => { + it("rebalances the StakingVault", async () => { + expect(await stakingVault.vaultHub()).to.equal(vaultHub); + const fundAmount = ether("1"); + await permissions.connect(funder).fund(fundAmount, { value: fundAmount }); + + const rebalanceAmount = fundAmount; + const prevBalance = await ethers.provider.getBalance(stakingVault); + await expect(permissions.connect(rebalancer).rebalanceVault(rebalanceAmount)) + .to.emit(vaultHub, "Mock__Rebalanced") + .withArgs(rebalanceAmount); + + expect(await ethers.provider.getBalance(stakingVault)).to.equal(prevBalance - rebalanceAmount); + }); + + it("reverts if the caller is not a member of the rebalance role", async () => { + expect(await permissions.hasRole(await permissions.REBALANCE_ROLE(), stranger)).to.be.false; + + await expect(permissions.connect(stranger).rebalanceVault(ether("1"))) + .to.be.revertedWithCustomError(permissions, "AccessControlUnauthorizedAccount") + .withArgs(stranger, await permissions.REBALANCE_ROLE()); + }); + }); + + context("pauseBeaconChainDeposits()", () => { + it("pauses the BeaconChainDeposits", async () => { + await expect(permissions.connect(depositPauser).pauseBeaconChainDeposits()).to.emit( + stakingVault, + "BeaconChainDepositsPaused", + ); + + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + }); + + it("reverts if the caller is not a member of the pause deposit role", async () => { + expect(await permissions.hasRole(await permissions.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), stranger)).to.be.false; + + await expect(permissions.connect(stranger).pauseBeaconChainDeposits()) + .to.be.revertedWithCustomError(permissions, "AccessControlUnauthorizedAccount") + .withArgs(stranger, await permissions.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE()); + + expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + }); + }); + + context("resumeBeaconChainDeposits()", () => { + it("resumes the BeaconChainDeposits", async () => { + await permissions.connect(depositPauser).pauseBeaconChainDeposits(); + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + + await expect(permissions.connect(depositResumer).resumeBeaconChainDeposits()).to.emit( + stakingVault, + "BeaconChainDepositsResumed", + ); + + expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + }); + + it("reverts if the caller is not a member of the resume deposit role", async () => { + expect(await permissions.hasRole(await permissions.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), stranger)).to.be.false; + + await expect(permissions.connect(stranger).resumeBeaconChainDeposits()) + .to.be.revertedWithCustomError(permissions, "AccessControlUnauthorizedAccount") + .withArgs(stranger, await permissions.RESUME_BEACON_CHAIN_DEPOSITS_ROLE()); + }); + }); + + context("requestValidatorExit()", () => { + it("requests a validator exit", async () => { + await expect(permissions.connect(exitRequester).requestValidatorExit("0xabcdef")) + .to.emit(stakingVault, "ValidatorsExitRequest") + .withArgs(permissions, "0xabcdef"); + }); + + it("reverts if the caller is not a member of the request exit role", async () => { + expect(await permissions.hasRole(await permissions.REQUEST_VALIDATOR_EXIT_ROLE(), stranger)).to.be.false; + + await expect(permissions.connect(stranger).requestValidatorExit("0xabcdef")) + .to.be.revertedWithCustomError(permissions, "AccessControlUnauthorizedAccount") + .withArgs(stranger, await permissions.REQUEST_VALIDATOR_EXIT_ROLE()); + }); + }); + + context("voluntaryDisconnect()", () => { + it("voluntarily disconnects the StakingVault", async () => { + await expect(permissions.connect(disconnecter).voluntaryDisconnect()) + .to.emit(vaultHub, "Mock__VoluntaryDisconnect") + .withArgs(stakingVault); + }); + + it("reverts if the caller is not a member of the disconnect role", async () => { + expect(await permissions.hasRole(await permissions.VOLUNTARY_DISCONNECT_ROLE(), stranger)).to.be.false; + + await expect(permissions.connect(stranger).voluntaryDisconnect()) + .to.be.revertedWithCustomError(permissions, "AccessControlUnauthorizedAccount") + .withArgs(stranger, await permissions.VOLUNTARY_DISCONNECT_ROLE()); + }); + }); + + context("transferStakingVaultOwnership()", () => { + it("transfers the StakingVault ownership", async () => { + const newOwner = certainAddress("new-owner"); + await expect(permissions.connect(defaultAdmin).transferStakingVaultOwnership(newOwner)) + .to.emit(stakingVault, "OwnershipTransferred") + .withArgs(permissions, newOwner); + + expect(await stakingVault.owner()).to.equal(newOwner); + }); + + it("reverts if the caller is not a member of the default admin role", async () => { + expect(await permissions.hasRole(await permissions.DEFAULT_ADMIN_ROLE(), stranger)).to.be.false; + + await expect( + permissions.connect(stranger).transferStakingVaultOwnership(certainAddress("new-owner")), + ).to.be.revertedWithCustomError(permissions, "SenderNotMember"); }); }); From de8c97959d4fce1c2e21700236f5910a9ebb1820 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Fri, 14 Feb 2025 17:33:22 +0500 Subject: [PATCH 112/184] fix(tests): update dashboard tests --- test/0.8.25/vaults/dashboard/dashboard.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index ed0f85440..3b7eaad4e 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -45,6 +45,8 @@ describe("Dashboard.sol", () => { let dashboard: Dashboard; let dashboardAddress: string; + const confirmLifetime = days(7n); + let originalState: string; const BP_BASE = 10_000n; @@ -125,13 +127,16 @@ describe("Dashboard.sol", () => { context("initialize", () => { it("reverts if already initialized", async () => { - await expect(dashboard.initialize(vaultOwner)).to.be.revertedWithCustomError(dashboard, "AlreadyInitialized"); + await expect(dashboard.initialize(vaultOwner, confirmLifetime)).to.be.revertedWithCustomError( + dashboard, + "AlreadyInitialized", + ); }); it("reverts if called on the implementation", async () => { const dashboard_ = await ethers.deployContract("Dashboard", [weth, lidoLocator]); - await expect(dashboard_.initialize(vaultOwner)).to.be.revertedWithCustomError( + await expect(dashboard_.initialize(vaultOwner, confirmLifetime)).to.be.revertedWithCustomError( dashboard_, "NonProxyCallsForbidden", ); @@ -177,7 +182,7 @@ describe("Dashboard.sol", () => { expect(await dashboard.sharesMinted()).to.equal(sockets.sharesMinted); 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.treasuryFeeBP()).to.equal(sockets.treasuryFeeBP); }); }); @@ -460,7 +465,7 @@ describe("Dashboard.sol", () => { it("reverts if called by a non-admin", async () => { await expect(dashboard.connect(stranger).transferStakingVaultOwnership(vaultOwner)).to.be.revertedWithCustomError( dashboard, - "NotACommitteeMember", + "SenderNotMember", ); }); From 6963f651df180c7f7f1b89d230e805d7c93f810f Mon Sep 17 00:00:00 2001 From: failingtwice Date: Fri, 14 Feb 2025 17:33:37 +0500 Subject: [PATCH 113/184] fix(Dashboard): update comments --- contracts/0.8.25/vaults/Dashboard.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 1fdfd9d97..ad057142f 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -87,7 +87,9 @@ contract Dashboard is Permissions { } /** - * @notice Initializes the contract with the default admin role + * @notice Initializes the contract + * @param _defaultAdmin Address of the default admin + * @param _confirmLifetime Confirm lifetime in seconds */ function initialize(address _defaultAdmin, uint256 _confirmLifetime) external virtual { // reduces gas cost for `mintWsteth` @@ -327,8 +329,6 @@ contract Dashboard is Permissions { _burnWstETH(_amountOfWstETH); } - // TODO: move down - /** * @notice Burns stETH tokens (in shares) backed by the vault from the sender using permit (with value in stETH). * @param _amountOfShares Amount of stETH shares to burn From e2b9b3b5f068d380c6b3c4279e8b33d3726c1093 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Fri, 14 Feb 2025 17:37:22 +0500 Subject: [PATCH 114/184] fix(Delegation): update tests --- .../vaults/delegation/delegation.test.ts | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index c808279cd..93ea9bb4e 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -248,23 +248,23 @@ describe("Delegation.sol", () => { }); it("sets the new confirm lifetime", async () => { - const oldConfirmLifetime = await delegation.confirmLifetime(); + const oldConfirmLifetime = await delegation.getConfirmLifetime(); const newConfirmLifetime = days(10n); const msgData = delegation.interface.encodeFunctionData("setConfirmLifetime", [newConfirmLifetime]); - let confirmTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); + let confirmTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); await expect(delegation.connect(vaultOwner).setConfirmLifetime(newConfirmLifetime)) .to.emit(delegation, "RoleMemberConfirmed") .withArgs(vaultOwner, await delegation.DEFAULT_ADMIN_ROLE(), confirmTimestamp, msgData); - confirmTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); + confirmTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); await expect(delegation.connect(nodeOperatorManager).setConfirmLifetime(newConfirmLifetime)) .to.emit(delegation, "RoleMemberConfirmed") .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), confirmTimestamp, msgData) .and.to.emit(delegation, "ConfirmLifetimeSet") .withArgs(nodeOperatorManager, oldConfirmLifetime, newConfirmLifetime); - expect(await delegation.confirmLifetime()).to.equal(newConfirmLifetime); + expect(await delegation.getConfirmLifetime()).to.equal(newConfirmLifetime); }); }); @@ -402,7 +402,7 @@ describe("Delegation.sol", () => { const curatorFeeBP = 1000; // 10% const operatorFeeBP = 1000; // 10% - await delegation.connect(vaultOwner).setCuratorFeeBP(curatorFeeBP); + await delegation.connect(curatorFeeSetter).setCuratorFeeBP(curatorFeeBP); await delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(operatorFeeBP); await delegation.connect(funder).fund({ value: amount }); @@ -633,7 +633,7 @@ describe("Delegation.sol", () => { it("requires both default admin and operator manager to set the operator fee and emits the RoleMemberConfirmed event", async () => { const previousOperatorFee = await delegation.nodeOperatorFeeBP(); const newOperatorFee = 1000n; - let expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); + let expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); const msgData = delegation.interface.encodeFunctionData("setNodeOperatorFeeBP", [newOperatorFee]); await expect(delegation.connect(vaultOwner).setNodeOperatorFeeBP(newOperatorFee)) @@ -642,11 +642,9 @@ describe("Delegation.sol", () => { // fee is unchanged expect(await delegation.nodeOperatorFeeBP()).to.equal(previousOperatorFee); // check confirm - expect(await delegation.confirmationExpiryTimestamp(msgData, await delegation.DEFAULT_ADMIN_ROLE())).to.equal( - expiryTimestamp, - ); + expect(await delegation.confirmations(msgData, await delegation.DEFAULT_ADMIN_ROLE())).to.equal(expiryTimestamp); - expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); + expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); await expect(delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(newOperatorFee)) .to.emit(delegation, "RoleMemberConfirmed") .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), expiryTimestamp, msgData) @@ -657,7 +655,7 @@ describe("Delegation.sol", () => { // resets the confirms for (const role of await delegation.confirmingRoles()) { - expect(await delegation.confirmationExpiryTimestamp(keccak256(msgData), role)).to.equal(0n); + expect(await delegation.confirmations(keccak256(msgData), role)).to.equal(0n); } }); @@ -673,7 +671,7 @@ describe("Delegation.sol", () => { const previousOperatorFee = await delegation.nodeOperatorFeeBP(); const newOperatorFee = 1000n; const msgData = delegation.interface.encodeFunctionData("setNodeOperatorFeeBP", [newOperatorFee]); - let expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); + let expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); await expect(delegation.connect(vaultOwner).setNodeOperatorFeeBP(newOperatorFee)) .to.emit(delegation, "RoleMemberConfirmed") @@ -681,13 +679,11 @@ describe("Delegation.sol", () => { // fee is unchanged expect(await delegation.nodeOperatorFeeBP()).to.equal(previousOperatorFee); // check confirm - expect(await delegation.confirmationExpiryTimestamp(msgData, await delegation.DEFAULT_ADMIN_ROLE())).to.equal( - expiryTimestamp, - ); + expect(await delegation.confirmations(msgData, await delegation.DEFAULT_ADMIN_ROLE())).to.equal(expiryTimestamp); // move time forward await advanceChainTime(days(7n) + 1n); - const expectedExpiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); + const expectedExpiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); expect(expectedExpiryTimestamp).to.be.greaterThan(expiryTimestamp + days(7n)); await expect(delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(newOperatorFee)) .to.emit(delegation, "RoleMemberConfirmed") @@ -696,12 +692,12 @@ describe("Delegation.sol", () => { // fee is still unchanged expect(await delegation.nodeOperatorFeeBP()).to.equal(previousOperatorFee); // check confirm - expect( - await delegation.confirmationExpiryTimestamp(msgData, await delegation.NODE_OPERATOR_MANAGER_ROLE()), - ).to.equal(expectedExpiryTimestamp); + expect(await delegation.confirmations(msgData, await delegation.NODE_OPERATOR_MANAGER_ROLE())).to.equal( + expectedExpiryTimestamp, + ); // curator has to confirm again - expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); + expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); await expect(delegation.connect(vaultOwner).setNodeOperatorFeeBP(newOperatorFee)) .to.emit(delegation, "RoleMemberConfirmed") .withArgs(vaultOwner, await delegation.DEFAULT_ADMIN_ROLE(), expiryTimestamp, msgData) @@ -723,14 +719,14 @@ describe("Delegation.sol", () => { it("requires both curator and operator to transfer ownership and emits the RoleMemberConfirmd event", async () => { const newOwner = certainAddress("newOwner"); const msgData = delegation.interface.encodeFunctionData("transferStakingVaultOwnership", [newOwner]); - let expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); + let expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); await expect(delegation.connect(vaultOwner).transferStakingVaultOwnership(newOwner)) .to.emit(delegation, "RoleMemberConfirmed") .withArgs(vaultOwner, await delegation.DEFAULT_ADMIN_ROLE(), expiryTimestamp, msgData); // owner is unchanged expect(await vault.owner()).to.equal(delegation); - expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.confirmLifetime()); + expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); await expect(delegation.connect(nodeOperatorManager).transferStakingVaultOwnership(newOwner)) .to.emit(delegation, "RoleMemberConfirmed") .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), expiryTimestamp, msgData); From 8082977874c218d77c8d7d78a22e40a05418ed91 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Fri, 14 Feb 2025 12:56:46 +0000 Subject: [PATCH 115/184] 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 116/184] 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 4c4002a9c7d5213cc139044a1a6c4e94a217d4ea Mon Sep 17 00:00:00 2001 From: failingtwice Date: Fri, 14 Feb 2025 18:12:08 +0500 Subject: [PATCH 117/184] test(Factory): remove curator check --- test/0.8.25/vaults/vaultFactory.test.ts | 18 +++++++----------- .../vaults-happy-path.integration.ts | 15 +++++++++------ 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/test/0.8.25/vaults/vaultFactory.test.ts b/test/0.8.25/vaults/vaultFactory.test.ts index 0b5a55ccd..a595103c4 100644 --- a/test/0.8.25/vaults/vaultFactory.test.ts +++ b/test/0.8.25/vaults/vaultFactory.test.ts @@ -21,7 +21,7 @@ import { } from "typechain-types"; import { DelegationConfigStruct } from "typechain-types/contracts/0.8.25/vaults/VaultFactory"; -import { createVaultProxy, ether } from "lib"; +import { createVaultProxy, days, ether } from "lib"; import { deployLidoLocator } from "test/deploy"; import { Snapshot } from "test/suite"; @@ -113,14 +113,17 @@ describe("VaultFactory.sol", () => { withdrawer: await vaultOwner1.getAddress(), minter: await vaultOwner1.getAddress(), burner: await vaultOwner1.getAddress(), - curator: await vaultOwner1.getAddress(), + curatorFeeSetter: await vaultOwner1.getAddress(), + curatorFeeClaimer: await vaultOwner1.getAddress(), + nodeOperatorManager: await operator.getAddress(), + nodeOperatorFeeConfirmer: await operator.getAddress(), + nodeOperatorFeeClaimer: await operator.getAddress(), rebalancer: await vaultOwner1.getAddress(), depositPauser: await vaultOwner1.getAddress(), depositResumer: await vaultOwner1.getAddress(), exitRequester: await vaultOwner1.getAddress(), disconnecter: await vaultOwner1.getAddress(), - nodeOperatorManager: await operator.getAddress(), - nodeOperatorFeeClaimer: await operator.getAddress(), + confirmLifetime: days(7n), curatorFeeBP: 100n, nodeOperatorFeeBP: 200n, }; @@ -167,13 +170,6 @@ describe("VaultFactory.sol", () => { }); context("createVaultWithDelegation", () => { - it("reverts if `curator` is zero address", async () => { - const params = { ...delegationParams, curator: ZeroAddress }; - await expect(createVaultProxy(vaultOwner1, vaultFactory, params)) - .to.revertedWithCustomError(vaultFactory, "ZeroArgument") - .withArgs("curator"); - }); - it("works with empty `params`", async () => { console.log({ delegationParams, diff --git a/test/integration/vaults-happy-path.integration.ts b/test/integration/vaults-happy-path.integration.ts index 59df58cb4..5287ac3f6 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, days, impersonate, log, trace, updateBalance } from "lib"; import { getProtocolContext, ProtocolContext } from "lib/protocol"; import { getReportTimeElapsed, @@ -163,16 +163,19 @@ describe("Scenario: Staking Vaults Happy Path", () => { withdrawer: curator, minter: curator, burner: curator, - curator, rebalancer: curator, depositPauser: curator, depositResumer: curator, exitRequester: curator, disconnecter: curator, + curatorFeeSetter: curator, + curatorFeeClaimer: curator, nodeOperatorManager: nodeOperator, + nodeOperatorFeeConfirmer: nodeOperator, nodeOperatorFeeClaimer: nodeOperator, curatorFeeBP: VAULT_OWNER_FEE, nodeOperatorFeeBP: VAULT_NODE_OPERATOR_FEE, + confirmLifetime: days(7n), }, "0x", ); @@ -187,13 +190,13 @@ describe("Scenario: Staking Vaults Happy Path", () => { expect(await isSoleRoleMember(owner, await delegation.DEFAULT_ADMIN_ROLE())).to.be.true; - expect(await isSoleRoleMember(curator, await delegation.CURATOR_ROLE())).to.be.true; + expect(await isSoleRoleMember(curator, await delegation.CURATOR_FEE_SET_ROLE())).to.be.true; + expect(await isSoleRoleMember(curator, await delegation.CURATOR_FEE_CLAIM_ROLE())).to.be.true; expect(await isSoleRoleMember(nodeOperator, await delegation.NODE_OPERATOR_MANAGER_ROLE())).to.be.true; + expect(await isSoleRoleMember(nodeOperator, await delegation.NODE_OPERATOR_FEE_CLAIM_ROLE())).to.be.true; + expect(await isSoleRoleMember(nodeOperator, await delegation.NODE_OPERATOR_FEE_CONFIRM_ROLE())).to.be.true; - expect(await isSoleRoleMember(nodeOperator, await delegation.NODE_OPERATOR_FEE_CLAIMER_ROLE())).to.be.true; - - expect(await isSoleRoleMember(curator, await delegation.CURATOR_ROLE())).to.be.true; expect(await isSoleRoleMember(curator, await delegation.FUND_ROLE())).to.be.true; expect(await isSoleRoleMember(curator, await delegation.WITHDRAW_ROLE())).to.be.true; expect(await isSoleRoleMember(curator, await delegation.MINT_ROLE())).to.be.true; From 48d80c038b82b3bb68ab4b82b7fe87ae361d5123 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Fri, 14 Feb 2025 18:19:25 +0500 Subject: [PATCH 118/184] feat: revert if node operator is zero address on init --- contracts/0.8.25/vaults/StakingVault.sol | 2 ++ .../vaults/staking-vault/staking-vault.test.ts | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 2e1b911c7..f2fdf5f04 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -120,6 +120,8 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @param - Additional initialization parameters */ function initialize(address _owner, address _nodeOperator, bytes calldata /* _params */) external initializer { + if (_nodeOperator == address(0)) revert ZeroArgument("_nodeOperator"); + __Ownable_init(_owner); _getStorage().nodeOperator = _nodeOperator; } diff --git a/test/0.8.25/vaults/staking-vault/staking-vault.test.ts b/test/0.8.25/vaults/staking-vault/staking-vault.test.ts index 075fd82a3..33a465bfe 100644 --- a/test/0.8.25/vaults/staking-vault/staking-vault.test.ts +++ b/test/0.8.25/vaults/staking-vault/staking-vault.test.ts @@ -14,7 +14,7 @@ import { VaultHub__MockForStakingVault, } from "typechain-types"; -import { computeDepositDataRoot, de0x, ether, findEvents, impersonate, streccak } from "lib"; +import { computeDepositDataRoot, de0x, ether, findEvents, impersonate, proxify, streccak } from "lib"; import { Snapshot } from "test/suite"; @@ -85,7 +85,9 @@ describe("StakingVault.sol", () => { .to.be.revertedWithCustomError(stakingVaultImplementation, "ZeroArgument") .withArgs("_beaconChainDepositContract"); }); + }); + context("initialize", () => { 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); @@ -96,6 +98,14 @@ describe("StakingVault.sol", () => { stakingVaultImplementation.connect(stranger).initialize(vaultOwner, operator, "0x"), ).to.be.revertedWithCustomError(stakingVaultImplementation, "InvalidInitialization"); }); + + it("reverts if the node operator is zero address", async () => { + const [vault_] = await proxify({ impl: stakingVaultImplementation, admin: vaultOwner }); + await expect(vault_.initialize(vaultOwner, ZeroAddress, "0x")).to.be.revertedWithCustomError( + stakingVaultImplementation, + "ZeroArgument", + ); + }); }); context("initial state", () => { From 83181a3758e0b8e1e54c122c7e2b007790e79053 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Fri, 14 Feb 2025 13:23:15 +0000 Subject: [PATCH 119/184] 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 120/184] 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 121/184] 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); From 087b03e714a4dc26972c3dd21f0a3a05c288785a Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Fri, 14 Feb 2025 15:36:32 +0000 Subject: [PATCH 122/184] fix(test): vebo gas test --- test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts b/test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts index 44a7080ba..b0c79a11a 100644 --- a/test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts @@ -6,7 +6,6 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { HashConsensus__Harness, ValidatorsExitBus__Harness } from "typechain-types"; -import { trace } from "lib"; import { CONSENSUS_VERSION, de0x, numberToHex } from "lib"; import { @@ -203,7 +202,7 @@ describe("ValidatorsExitBusOracle.sol:gas", () => { it(`a committee member submits the report data, exit requests are emitted`, async () => { const tx = await oracle.connect(member1).submitReportData(reportFields, oracleVersion); - const receipt = await trace("oracle.submit", tx); + const receipt = (await tx.wait()) as ContractTransactionReceipt; await expect(tx).to.emit(oracle, "ProcessingStarted").withArgs(reportFields.refSlot, reportHash); expect((await oracle.getConsensusReport()).processingStarted).to.equal(true); From 014df012c53f14d71ccd6c27f2f8efa38fa8b04e Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Fri, 14 Feb 2025 16:11:51 +0000 Subject: [PATCH 123/184] test: speed up gas validation test --- .../validator-exit-bus-oracle.gas.test.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts b/test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts index b0c79a11a..c92fc799c 100644 --- a/test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts @@ -145,9 +145,7 @@ describe("ValidatorsExitBusOracle.sol:gas", () => { let reportHash: string; let originalState: string; - before(async () => { - originalState = await Snapshot.take(); - }); + before(async () => (originalState = await Snapshot.take())); after(async () => await Snapshot.restore(originalState)); @@ -208,12 +206,19 @@ describe("ValidatorsExitBusOracle.sol:gas", () => { const timestamp = await oracle.getTime(); - for (const request of exitRequests.requests) { - await expect(tx) - .to.emit(oracle, "ValidatorExitRequest") - .withArgs(request.moduleId, request.nodeOpId, request.valIndex, request.valPubkey, timestamp); - } + const evFirst = exitRequests.requests[0]; + const evLast = exitRequests.requests[exitRequests.requests.length - 1]; + + await expect(tx) + .to.emit(oracle, "ValidatorExitRequest") + .withArgs(evFirst.moduleId, evFirst.nodeOpId, evFirst.valIndex, evFirst.valPubkey, timestamp); + + await expect(tx) + .to.emit(oracle, "ValidatorExitRequest") + .withArgs(evLast.moduleId, evLast.nodeOpId, evLast.valIndex, evLast.valPubkey, timestamp); + const { gasUsed } = receipt; + gasUsages.push({ totalRequests, requestsPerModule: exitRequests.requestsPerModule, From 9bc4f779c06421f84cf3a7af91f390bf0bc35db5 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Mon, 17 Feb 2025 12:35:23 +0500 Subject: [PATCH 124/184] fix: minor fixes --- contracts/0.8.25/vaults/Dashboard.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 52adf0ec8..5d83f3e11 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -77,7 +77,7 @@ contract Dashboard is Permissions { * @param _wETH Address of the weth token contract. * @param _lidoLocator Address of the Lido locator contract. */ - constructor(address _wETH, address _lidoLocator) Permissions() { + constructor(address _wETH, address _lidoLocator) { if (_wETH == address(0)) revert ZeroArgument("_wETH"); if (_lidoLocator == address(0)) revert ZeroArgument("_lidoLocator"); @@ -250,7 +250,7 @@ contract Dashboard is Permissions { } /** - * @notice Withdraws stETH tokens from the staking vault to wrapped ether. + * @notice Withdraws wETH tokens from the staking vault to wrapped ether. * @param _recipient Address of the recipient * @param _amountOfWETH Amount of WETH to withdraw */ From a494579e5548acfd4de0010b2caf5fca86ffa77a Mon Sep 17 00:00:00 2001 From: failingtwice Date: Mon, 17 Feb 2025 12:40:55 +0500 Subject: [PATCH 125/184] fix: update comments --- contracts/0.8.25/vaults/Dashboard.sol | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 5d83f3e11..b14e05cff 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -269,7 +269,7 @@ contract Dashboard is Permissions { } /** - * @notice Mints stETH tokens backed by the vault to the recipient. + * @notice Mints stETH shares backed by the vault to the recipient. * @param _recipient Address of the recipient * @param _amountOfShares Amount of stETH shares to mint */ @@ -311,9 +311,9 @@ contract Dashboard is Permissions { } /** - * @notice Burns stETH shares from the sender backed by the vault. Expects stETH amount approved to this contract. + * @notice Burns stETH tokens from the sender backed by the vault. Expects stETH amount approved to this contract. * !NB: this will revert with `VaultHub.ZeroArgument("_amountOfShares")` if the amount of stETH is less than 1 share - * @param _amountOfStETH Amount of stETH shares to burn + * @param _amountOfStETH Amount of stETH tokens to burn */ function burnStETH(uint256 _amountOfStETH) external { _burnStETH(_amountOfStETH); @@ -330,7 +330,7 @@ contract Dashboard is Permissions { } /** - * @notice Burns stETH tokens (in shares) backed by the vault from the sender using permit (with value in stETH). + * @notice Burns stETH shares backed by the vault from the sender using permit (with value in stETH). * @param _amountOfShares Amount of stETH shares to burn * @param _permit data required for the stETH.permit() with amount in stETH */ @@ -377,7 +377,7 @@ contract Dashboard is Permissions { } /** - * @notice recovers ERC20 tokens or ether from the dashboard contract to sender + * @notice Recovers ERC20 tokens or ether from the dashboard contract to sender * @param _token Address of the token to recover or 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee for ether * @param _recipient Address of the recovery recipient */ @@ -498,7 +498,7 @@ contract Dashboard is Permissions { } /** - * @dev calculates total shares vault can mint + * @dev Calculates total shares vault can mint * @param _valuation custom vault valuation */ function _totalMintableShares(uint256 _valuation) internal view returns (uint256) { From 8ffcf2bdd0e2b5c65b8500f33c2c1302edaa31af Mon Sep 17 00:00:00 2001 From: Eddort Date: Mon, 17 Feb 2025 14:00:28 +0100 Subject: [PATCH 126/184] fix: hardhat config types --- hardhat.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hardhat.config.ts b/hardhat.config.ts index 3e0131071..004293836 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -66,10 +66,10 @@ const config: HardhatUserConfig = { customChains: [ { network: "local-devnet", - chainId: process.env.LOCAL_DENVET_CHAIN_ID ?? 32382, + chainId: parseInt(process.env.LOCAL_DENVET_CHAIN_ID ?? "32382", 10), urls: { - apiURL: process.env.LOCAL_DENVET_EXPLORER_API_URL, - browserURL: process.env.LOCAL_DENVET_EXPLORER_URL, + apiURL: process.env.LOCAL_DENVET_EXPLORER_API_URL ?? "", + browserURL: process.env.LOCAL_DENVET_EXPLORER_URL ?? "", }, }, ], From 72a46285740a9b9d74c6bc6638ab733ccc0bc300 Mon Sep 17 00:00:00 2001 From: Eddort Date: Mon, 17 Feb 2025 14:01:50 +0100 Subject: [PATCH 127/184] Revert "feat: speed up scratch deployment for local-devnet" This reverts commit e5aa190f013e636691a7c8569cd41e13b58336c3. --- lib/deploy.ts | 9 +- lib/nonce-manager.ts | 16 -- .../scratch/steps/0020-deploy-aragon-env.ts | 26 ++-- .../0030-deploy-template-and-app-bases.ts | 19 ++- .../0120-initialize-non-aragon-contracts.ts | 137 +++++++----------- scripts/scratch/steps/0130-grant-roles.ts | 90 +++++------- .../steps/0140-plug-staking-modules.ts | 65 ++++----- scripts/scratch/steps/0150-transfer-roles.ts | 28 ++-- 8 files changed, 151 insertions(+), 239 deletions(-) delete mode 100644 lib/nonce-manager.ts diff --git a/lib/deploy.ts b/lib/deploy.ts index 4f9014f59..1b9a1626a 100644 --- a/lib/deploy.ts +++ b/lib/deploy.ts @@ -6,7 +6,6 @@ import { LidoLocator } from "typechain-types"; import { addContractHelperFields, DeployedContract, getContractPath, loadContract, LoadedContract } from "lib/contract"; import { ConvertibleToString, cy, gr, log, yl } from "lib/log"; -import { getNonceManagerWithDeployer } from "lib/nonce-manager"; import { incrementGasUsed, Sk, updateObjectInState } from "lib/state-file"; const GAS_PRIORITY_FEE = process.env.GAS_PRIORITY_FEE || null; @@ -34,11 +33,9 @@ export async function makeTx( txParams: TxParams, withStateFile = true, ): Promise { - const contractWithNonceManager = contract.connect(await getNonceManagerWithDeployer()); - log.withArguments(`Call: ${yl(contract.name)}[${cy(contract.address)}].${yl(funcName)}`, args); - const tx = await contractWithNonceManager.getFunction(funcName)(...args, txParams); + const tx = await contract.getFunction(funcName)(...args, txParams); log(` Transaction: ${tx.hash} (nonce ${yl(tx.nonce)})...`); const receipt = await tx.wait(); @@ -77,9 +74,7 @@ async function deployContractType2( ): Promise { const txParams = await getDeployTxParams(deployer); const factory = (await ethers.getContractFactory(artifactName, signerOrOptions)) as ContractFactory; - const factoryWithNonceManager = factory.connect(await getNonceManagerWithDeployer()); - - const contract = await factoryWithNonceManager.deploy(...constructorArgs, txParams); + const contract = await factory.deploy(...constructorArgs, txParams); const tx = contract.deploymentTransaction(); if (!tx) { throw new Error(`Failed to send the deployment transaction for ${artifactName}`); diff --git a/lib/nonce-manager.ts b/lib/nonce-manager.ts deleted file mode 100644 index 135471b50..000000000 --- a/lib/nonce-manager.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NonceManager } from "ethers"; -import { ethers } from "hardhat"; - -let cachedNonceManager: NonceManager; - -export const getNonceManagerWithDeployer = async () => { - if (cachedNonceManager) { - return cachedNonceManager; - } - const [deployer] = await ethers.getSigners(); - - const nonceManager = new ethers.NonceManager(deployer); - cachedNonceManager = nonceManager; - - return nonceManager; -}; diff --git a/scripts/scratch/steps/0020-deploy-aragon-env.ts b/scripts/scratch/steps/0020-deploy-aragon-env.ts index 3fc873372..7d3996216 100644 --- a/scripts/scratch/steps/0020-deploy-aragon-env.ts +++ b/scripts/scratch/steps/0020-deploy-aragon-env.ts @@ -94,12 +94,13 @@ export async function main() { if (daoFactoryAddress) { log(`Using pre-deployed DAOFactory: ${cy(state[Sk.daoFactory].address)}`); } else { - const [kernelBase, aclBase, evmScriptRegistryFactory] = await Promise.all([ - deployImplementation(Sk.aragonKernel, "Kernel", deployer, [true]), - deployImplementation(Sk.aragonAcl, "ACL", deployer), - deployWithoutProxy(Sk.evmScriptRegistryFactory, "EVMScriptRegistryFactory", deployer), - ]); - + const kernelBase = await deployImplementation(Sk.aragonKernel, "Kernel", deployer, [true]); + const aclBase = await deployImplementation(Sk.aragonAcl, "ACL", deployer); + const evmScriptRegistryFactory = await deployWithoutProxy( + Sk.evmScriptRegistryFactory, + "EVMScriptRegistryFactory", + deployer, + ); const daoFactoryArgs = [kernelBase.address, aclBase.address, evmScriptRegistryFactory.address]; daoFactoryAddress = (await deployWithoutProxy(Sk.daoFactory, "DAOFactory", deployer, daoFactoryArgs)).address; } @@ -107,12 +108,13 @@ export async function main() { // Deploy APM registry factory log.header(`APM registry factory`); - const [apmRegistryBase, apmRepoBase, ensSubdomainRegistrarBase] = await Promise.all([ - deployImplementation(Sk.aragonApmRegistry, "APMRegistry", deployer), - deployWithoutProxy(Sk.aragonRepoBase, "Repo", deployer), - deployImplementation(Sk.ensSubdomainRegistrar, "ENSSubdomainRegistrar", deployer), - ]); - + const apmRegistryBase = await deployImplementation(Sk.aragonApmRegistry, "APMRegistry", deployer); + const apmRepoBase = await deployWithoutProxy(Sk.aragonRepoBase, "Repo", deployer); + const ensSubdomainRegistrarBase = await deployImplementation( + Sk.ensSubdomainRegistrar, + "ENSSubdomainRegistrar", + deployer, + ); const apmRegistryFactory = await deployWithoutProxy(Sk.apmRegistryFactory, "APMRegistryFactory", deployer, [ daoFactory.address, diff --git a/scripts/scratch/steps/0030-deploy-template-and-app-bases.ts b/scripts/scratch/steps/0030-deploy-template-and-app-bases.ts index 6ef4ec3b6..87a761880 100644 --- a/scripts/scratch/steps/0030-deploy-template-and-app-bases.ts +++ b/scripts/scratch/steps/0030-deploy-template-and-app-bases.ts @@ -7,16 +7,15 @@ export async function main() { const deployer = (await ethers.provider.getSigner()).address; const state = readNetworkState({ deployer }); - await Promise.all([ - // Deploy Aragon app implementations - deployImplementation(Sk.appAgent, "Agent", deployer), - deployImplementation(Sk.appFinance, "Finance", deployer), - deployImplementation(Sk.appTokenManager, "TokenManager", deployer), - deployImplementation(Sk.appVoting, "Voting", deployer), - // Deploy Lido-specific app implementations - deployImplementation(Sk.appLido, "Lido", deployer), - deployImplementation(Sk.appOracle, "LegacyOracle", deployer), - ]); + // Deploy Aragon app implementations + await deployImplementation(Sk.appAgent, "Agent", deployer); + await deployImplementation(Sk.appFinance, "Finance", deployer); + await deployImplementation(Sk.appTokenManager, "TokenManager", deployer); + await deployImplementation(Sk.appVoting, "Voting", deployer); + + // Deploy Lido-specific app implementations + await deployImplementation(Sk.appLido, "Lido", deployer); + await deployImplementation(Sk.appOracle, "LegacyOracle", deployer); const minFirstAllocationStrategy = await deployWithoutProxy( Sk.minFirstAllocationStrategy, diff --git a/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts b/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts index a2c3af96f..dab37394b 100644 --- a/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts +++ b/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts @@ -1,4 +1,3 @@ -import { ContractTransactionReceipt } from "ethers"; import { ethers } from "hardhat"; import { loadContract } from "lib/contract"; @@ -37,8 +36,6 @@ export async function main() { const stakingRouterAdmin = testnetAdmin; const withdrawalQueueAdmin = testnetAdmin; - const waitTransactionsGroup: Promise[] = []; - // Initialize NodeOperatorsRegistry // https://github.com/ethereum/solidity-examples/blob/master/docs/bytes/Bytes.md#description @@ -46,110 +43,90 @@ export async function main() { "0x" + ethers.AbiCoder.defaultAbiCoder().encode(["string"], [stakingModuleTypeId]).slice(-64); const nodeOperatorsRegistry = await loadContract("NodeOperatorsRegistry", nodeOperatorsRegistryAddress); - waitTransactionsGroup.push( - makeTx( - nodeOperatorsRegistry, - "initialize", - [ - lidoLocatorAddress, - encodeStakingModuleTypeId(nodeOperatorsRegistryParams.stakingModuleTypeId), - nodeOperatorsRegistryParams.stuckPenaltyDelay, - ], - { from: deployer }, - ), + await makeTx( + nodeOperatorsRegistry, + "initialize", + [ + lidoLocatorAddress, + encodeStakingModuleTypeId(nodeOperatorsRegistryParams.stakingModuleTypeId), + nodeOperatorsRegistryParams.stuckPenaltyDelay, + ], + { from: deployer }, ); const simpleDvtRegistry = await loadContract("NodeOperatorsRegistry", simpleDvtRegistryAddress); - waitTransactionsGroup.push( - makeTx( - simpleDvtRegistry, - "initialize", - [ - lidoLocatorAddress, - encodeStakingModuleTypeId(simpleDvtRegistryParams.stakingModuleTypeId), - simpleDvtRegistryParams.stuckPenaltyDelay, - ], - { from: deployer }, - ), + await makeTx( + simpleDvtRegistry, + "initialize", + [ + lidoLocatorAddress, + encodeStakingModuleTypeId(simpleDvtRegistryParams.stakingModuleTypeId), + simpleDvtRegistryParams.stuckPenaltyDelay, + ], + { from: deployer }, ); // Initialize Lido const bootstrapInitBalance = 10n; // wei const lido = await loadContract("Lido", lidoAddress); - waitTransactionsGroup.push( - makeTx(lido, "initialize", [lidoLocatorAddress, eip712StETHAddress], { - value: bootstrapInitBalance, - from: deployer, - }), - ); + await makeTx(lido, "initialize", [lidoLocatorAddress, eip712StETHAddress], { + value: bootstrapInitBalance, + from: deployer, + }); // Initialize LegacyOracle const legacyOracle = await loadContract("LegacyOracle", legacyOracleAddress); - waitTransactionsGroup.push( - makeTx(legacyOracle, "initialize", [lidoLocatorAddress, hashConsensusForAccountingAddress], { - from: deployer, - }), - ); + await makeTx(legacyOracle, "initialize", [lidoLocatorAddress, hashConsensusForAccountingAddress], { from: deployer }); const zeroLastProcessingRefSlot = 0; // Initialize AccountingOracle const accountingOracle = await loadContract("AccountingOracle", accountingOracleAddress); - waitTransactionsGroup.push( - makeTx( - accountingOracle, - "initializeWithoutMigration", - [ - accountingOracleAdmin, - hashConsensusForAccountingAddress, - accountingOracleParams.consensusVersion, - zeroLastProcessingRefSlot, - ], - { from: deployer }, - ), + await makeTx( + accountingOracle, + "initializeWithoutMigration", + [ + accountingOracleAdmin, + hashConsensusForAccountingAddress, + accountingOracleParams.consensusVersion, + zeroLastProcessingRefSlot, + ], + { from: deployer }, ); // Initialize ValidatorsExitBusOracle const validatorsExitBusOracle = await loadContract("ValidatorsExitBusOracle", ValidatorsExitBusOracleAddress); - waitTransactionsGroup.push( - makeTx( - validatorsExitBusOracle, - "initialize", - [ - exitBusOracleAdmin, - hashConsensusForValidatorsExitBusOracleAddress, - validatorsExitBusOracleParams.consensusVersion, - zeroLastProcessingRefSlot, - ], - { from: deployer }, - ), + await makeTx( + validatorsExitBusOracle, + "initialize", + [ + exitBusOracleAdmin, + hashConsensusForValidatorsExitBusOracleAddress, + validatorsExitBusOracleParams.consensusVersion, + zeroLastProcessingRefSlot, + ], + { from: deployer }, ); // Initialize WithdrawalQueue const withdrawalQueue = await loadContract("WithdrawalQueueERC721", withdrawalQueueAddress); - waitTransactionsGroup.push(makeTx(withdrawalQueue, "initialize", [withdrawalQueueAdmin], { from: deployer })); + await makeTx(withdrawalQueue, "initialize", [withdrawalQueueAdmin], { from: deployer }); // Set WithdrawalQueue base URI if provided const withdrawalQueueBaseUri = state["withdrawalQueueERC721"].deployParameters.baseUri; if (withdrawalQueueBaseUri !== null && withdrawalQueueBaseUri !== "") { const MANAGE_TOKEN_URI_ROLE = await withdrawalQueue.getFunction("MANAGE_TOKEN_URI_ROLE")(); - waitTransactionsGroup.push( - makeTx(withdrawalQueue, "grantRole", [MANAGE_TOKEN_URI_ROLE, deployer], { from: deployer }), - ); - waitTransactionsGroup.push(makeTx(withdrawalQueue, "setBaseURI", [withdrawalQueueBaseUri], { from: deployer })); - waitTransactionsGroup.push( - makeTx(withdrawalQueue, "renounceRole", [MANAGE_TOKEN_URI_ROLE, deployer], { from: deployer }), - ); + await makeTx(withdrawalQueue, "grantRole", [MANAGE_TOKEN_URI_ROLE, deployer], { from: deployer }); + await makeTx(withdrawalQueue, "setBaseURI", [withdrawalQueueBaseUri], { from: deployer }); + await makeTx(withdrawalQueue, "renounceRole", [MANAGE_TOKEN_URI_ROLE, deployer], { from: deployer }); } // Initialize StakingRouter const withdrawalCredentials = `0x010000000000000000000000${withdrawalVaultAddress.slice(2)}`; const stakingRouter = await loadContract("StakingRouter", stakingRouterAddress); - waitTransactionsGroup.push( - makeTx(stakingRouter, "initialize", [stakingRouterAdmin, lidoAddress, withdrawalCredentials], { - from: deployer, - }), - ); + await makeTx(stakingRouter, "initialize", [stakingRouterAdmin, lidoAddress, withdrawalCredentials], { + from: deployer, + }); // Set OracleDaemonConfig parameters const oracleDaemonConfig = await loadContract("OracleDaemonConfig", oracleDaemonConfigAddress); @@ -157,15 +134,9 @@ export async function main() { await makeTx(oracleDaemonConfig, "grantRole", [CONFIG_MANAGER_ROLE, testnetAdmin], { from: testnetAdmin }); // Set each parameter in the OracleDaemonConfig - const txPromises = Object.entries(state.oracleDaemonConfig.deployParameters).map(([key, value]) => { - return makeTx(oracleDaemonConfig, "set", [key, en0x(value as number)], { from: deployer }); - }); - - await Promise.all(txPromises); - - waitTransactionsGroup.push( - makeTx(oracleDaemonConfig, "renounceRole", [CONFIG_MANAGER_ROLE, testnetAdmin], { from: testnetAdmin }), - ); + for (const [key, value] of Object.entries(state.oracleDaemonConfig.deployParameters)) { + await makeTx(oracleDaemonConfig, "set", [key, en0x(value as number)], { from: deployer }); + } - await Promise.all(waitTransactionsGroup); + await makeTx(oracleDaemonConfig, "renounceRole", [CONFIG_MANAGER_ROLE, testnetAdmin], { from: testnetAdmin }); } diff --git a/scripts/scratch/steps/0130-grant-roles.ts b/scripts/scratch/steps/0130-grant-roles.ts index 545ff603e..2ef6f4f5e 100644 --- a/scripts/scratch/steps/0130-grant-roles.ts +++ b/scripts/scratch/steps/0130-grant-roles.ts @@ -1,4 +1,3 @@ -import { ContractTransactionReceipt } from "ethers"; import { ethers } from "hardhat"; import { Burner, StakingRouter, ValidatorsExitBusOracle, WithdrawalQueueERC721 } from "typechain-types"; @@ -24,33 +23,26 @@ export async function main() { const validatorsExitBusOracleAddress = state[Sk.validatorsExitBusOracle].proxy.address; const depositSecurityModuleAddress = state[Sk.depositSecurityModule].address; - const waitTransactionsGroup: Promise[] = []; - // StakingRouter const stakingRouter = await loadContract("StakingRouter", stakingRouterAddress); - waitTransactionsGroup.push( - makeTx( - stakingRouter, - "grantRole", - [await stakingRouter.STAKING_MODULE_UNVETTING_ROLE(), depositSecurityModuleAddress], - { from: deployer }, - ), - ); - waitTransactionsGroup.push( - makeTx(stakingRouter, "grantRole", [await stakingRouter.REPORT_EXITED_VALIDATORS_ROLE(), accountingOracleAddress], { - from: deployer, - }), - ); - waitTransactionsGroup.push( - makeTx(stakingRouter, "grantRole", [await stakingRouter.REPORT_REWARDS_MINTED_ROLE(), lidoAddress], { - from: deployer, - }), + await makeTx( + stakingRouter, + "grantRole", + [await stakingRouter.STAKING_MODULE_UNVETTING_ROLE(), depositSecurityModuleAddress], + { from: deployer }, ); - waitTransactionsGroup.push( - makeTx(stakingRouter, "grantRole", [await stakingRouter.STAKING_MODULE_MANAGE_ROLE(), agentAddress], { - from: deployer, - }), + await makeTx( + stakingRouter, + "grantRole", + [await stakingRouter.REPORT_EXITED_VALIDATORS_ROLE(), accountingOracleAddress], + { from: deployer }, ); + await makeTx(stakingRouter, "grantRole", [await stakingRouter.REPORT_REWARDS_MINTED_ROLE(), lidoAddress], { + from: deployer, + }); + await makeTx(stakingRouter, "grantRole", [await stakingRouter.STAKING_MODULE_MANAGE_ROLE(), agentAddress], { + from: deployer, + }); // ValidatorsExitBusOracle if (gateSealAddress) { @@ -58,11 +50,9 @@ export async function main() { "ValidatorsExitBusOracle", validatorsExitBusOracleAddress, ); - waitTransactionsGroup.push( - makeTx(validatorsExitBusOracle, "grantRole", [await validatorsExitBusOracle.PAUSE_ROLE(), gateSealAddress], { - from: deployer, - }), - ); + await makeTx(validatorsExitBusOracle, "grantRole", [await validatorsExitBusOracle.PAUSE_ROLE(), gateSealAddress], { + from: deployer, + }); } else { log(`GateSeal is not specified or deployed: skipping assigning PAUSE_ROLE of validatorsExitBusOracle`); log.emptyLine(); @@ -71,41 +61,29 @@ export async function main() { // WithdrawalQueue const withdrawalQueue = await loadContract("WithdrawalQueueERC721", withdrawalQueueAddress); if (gateSealAddress) { - waitTransactionsGroup.push( - makeTx(withdrawalQueue, "grantRole", [await withdrawalQueue.PAUSE_ROLE(), gateSealAddress], { - from: deployer, - }), - ); + await makeTx(withdrawalQueue, "grantRole", [await withdrawalQueue.PAUSE_ROLE(), gateSealAddress], { + from: deployer, + }); } else { log(`GateSeal is not specified or deployed: skipping assigning PAUSE_ROLE of withdrawalQueue`); log.emptyLine(); } - waitTransactionsGroup.push( - makeTx(withdrawalQueue, "grantRole", [await withdrawalQueue.FINALIZE_ROLE(), lidoAddress], { - from: deployer, - }), - ); + await makeTx(withdrawalQueue, "grantRole", [await withdrawalQueue.FINALIZE_ROLE(), lidoAddress], { + from: deployer, + }); - waitTransactionsGroup.push( - makeTx(withdrawalQueue, "grantRole", [await withdrawalQueue.ORACLE_ROLE(), accountingOracleAddress], { - from: deployer, - }), - ); + await makeTx(withdrawalQueue, "grantRole", [await withdrawalQueue.ORACLE_ROLE(), accountingOracleAddress], { + from: deployer, + }); // Burner const burner = await loadContract("Burner", burnerAddress); // NB: REQUEST_BURN_SHARES_ROLE is already granted to Lido in Burner constructor - waitTransactionsGroup.push( - makeTx(burner, "grantRole", [await burner.REQUEST_BURN_SHARES_ROLE(), nodeOperatorsRegistryAddress], { - from: deployer, - }), - ); - waitTransactionsGroup.push( - makeTx(burner, "grantRole", [await burner.REQUEST_BURN_SHARES_ROLE(), simpleDvtApp], { - from: deployer, - }), - ); - - await Promise.all(waitTransactionsGroup); + await makeTx(burner, "grantRole", [await burner.REQUEST_BURN_SHARES_ROLE(), nodeOperatorsRegistryAddress], { + from: deployer, + }); + await makeTx(burner, "grantRole", [await burner.REQUEST_BURN_SHARES_ROLE(), simpleDvtApp], { + from: deployer, + }); } diff --git a/scripts/scratch/steps/0140-plug-staking-modules.ts b/scripts/scratch/steps/0140-plug-staking-modules.ts index 2dbf2a213..f1c8b5f23 100644 --- a/scripts/scratch/steps/0140-plug-staking-modules.ts +++ b/scripts/scratch/steps/0140-plug-staking-modules.ts @@ -1,4 +1,3 @@ -import { ContractTransactionReceipt } from "ethers"; import { ethers } from "hardhat"; import { loadContract } from "lib/contract"; @@ -32,48 +31,40 @@ export async function main() { // Grant STAKING_MODULE_MANAGE_ROLE to deployer await makeTx(stakingRouter, "grantRole", [STAKING_MODULE_MANAGE_ROLE, deployer], { from: deployer }); - const waitTransactionsGroup: Promise[] = []; - // Add staking module to StakingRouter - waitTransactionsGroup.push( - makeTx( - stakingRouter, - "addStakingModule", - [ - state.nodeOperatorsRegistry.deployParameters.stakingModuleTypeId, - state[Sk.appNodeOperatorsRegistry].proxy.address, - NOR_STAKING_MODULE_STAKE_SHARE_LIMIT_BP, - NOR_STAKING_MODULE_PRIORITY_EXIT_SHARE_THRESHOLD_BP, - NOR_STAKING_MODULE_MODULE_FEE_BP, - NOR_STAKING_MODULE_TREASURY_FEE_BP, - NOR_STAKING_MODULE_MAX_DEPOSITS_PER_BLOCK, - NOR_STAKING_MODULE_MIN_DEPOSIT_BLOCK_DISTANCE, - ], - { from: deployer }, - ), + await makeTx( + stakingRouter, + "addStakingModule", + [ + state.nodeOperatorsRegistry.deployParameters.stakingModuleTypeId, + state[Sk.appNodeOperatorsRegistry].proxy.address, + NOR_STAKING_MODULE_STAKE_SHARE_LIMIT_BP, + NOR_STAKING_MODULE_PRIORITY_EXIT_SHARE_THRESHOLD_BP, + NOR_STAKING_MODULE_MODULE_FEE_BP, + NOR_STAKING_MODULE_TREASURY_FEE_BP, + NOR_STAKING_MODULE_MAX_DEPOSITS_PER_BLOCK, + NOR_STAKING_MODULE_MIN_DEPOSIT_BLOCK_DISTANCE, + ], + { from: deployer }, ); // Add simple DVT module to StakingRouter - waitTransactionsGroup.push( - makeTx( - stakingRouter, - "addStakingModule", - [ - state.simpleDvt.deployParameters.stakingModuleTypeId, - state[Sk.appSimpleDvt].proxy.address, - SDVT_STAKING_MODULE_TARGET_SHARE_BP, - SDVT_STAKING_MODULE_PRIORITY_EXIT_SHARE_THRESHOLD_BP, - SDVT_STAKING_MODULE_MODULE_FEE_BP, - SDVT_STAKING_MODULE_TREASURY_FEE_BP, - SDVT_STAKING_MODULE_MAX_DEPOSITS_PER_BLOCK, - SDVT_STAKING_MODULE_MIN_DEPOSIT_BLOCK_DISTANCE, - ], - { from: deployer }, - ), + await makeTx( + stakingRouter, + "addStakingModule", + [ + state.simpleDvt.deployParameters.stakingModuleTypeId, + state[Sk.appSimpleDvt].proxy.address, + SDVT_STAKING_MODULE_TARGET_SHARE_BP, + SDVT_STAKING_MODULE_PRIORITY_EXIT_SHARE_THRESHOLD_BP, + SDVT_STAKING_MODULE_MODULE_FEE_BP, + SDVT_STAKING_MODULE_TREASURY_FEE_BP, + SDVT_STAKING_MODULE_MAX_DEPOSITS_PER_BLOCK, + SDVT_STAKING_MODULE_MIN_DEPOSIT_BLOCK_DISTANCE, + ], + { from: deployer }, ); - await Promise.all(waitTransactionsGroup); - // Renounce STAKING_MODULE_MANAGE_ROLE from deployer await makeTx(stakingRouter, "renounceRole", [STAKING_MODULE_MANAGE_ROLE, deployer], { from: deployer }); } diff --git a/scripts/scratch/steps/0150-transfer-roles.ts b/scripts/scratch/steps/0150-transfer-roles.ts index ed7bd5c6c..e7804196d 100644 --- a/scripts/scratch/steps/0150-transfer-roles.ts +++ b/scripts/scratch/steps/0150-transfer-roles.ts @@ -25,16 +25,11 @@ export async function main() { { name: "OracleReportSanityChecker", address: state.oracleReportSanityChecker.address }, ]; - const contractInstances = await Promise.all( - ozAdminTransfers.map((contract) => loadContract(contract.name, contract.address)), - ); - - await Promise.all( - contractInstances.map(async (contractInstance) => { - await makeTx(contractInstance, "grantRole", [DEFAULT_ADMIN_ROLE, agent], { from: deployer }); - await makeTx(contractInstance, "renounceRole", [DEFAULT_ADMIN_ROLE, deployer], { from: deployer }); - }), - ); + for (const contract of ozAdminTransfers) { + const contractInstance = await loadContract(contract.name, contract.address); + await makeTx(contractInstance, "grantRole", [DEFAULT_ADMIN_ROLE, agent], { from: deployer }); + await makeTx(contractInstance, "renounceRole", [DEFAULT_ADMIN_ROLE, deployer], { from: deployer }); + } // Change admin for OssifiableProxy contracts const ossifiableProxyAdminChanges = [ @@ -45,15 +40,12 @@ export async function main() { state.withdrawalQueueERC721.proxy.address, ]; - // Parallel execution of proxy admin changes - await Promise.all( - ossifiableProxyAdminChanges.map(async (proxyAddress) => { - const proxy = await loadContract("OssifiableProxy", proxyAddress); - return makeTx(proxy, "proxy__changeAdmin", [agent], { from: deployer }); - }), - ); + for (const proxyAddress of ossifiableProxyAdminChanges) { + const proxy = await loadContract("OssifiableProxy", proxyAddress); + await makeTx(proxy, "proxy__changeAdmin", [agent], { from: deployer }); + } - // Change DepositSecurityModule admin if not using a predefined address + // Change DepositSecurityModule admin if not using predefined address if (state[Sk.depositSecurityModule].deployParameters.usePredefinedAddressInstead === null) { const depositSecurityModule = await loadContract("DepositSecurityModule", state.depositSecurityModule.address); await makeTx(depositSecurityModule, "setOwner", [agent], { from: deployer }); From 6e7c60e7beeef671d9f6247b35b67ce30c292db4 Mon Sep 17 00:00:00 2001 From: Eddort Date: Mon, 17 Feb 2025 14:05:43 +0100 Subject: [PATCH 128/184] fix: integration tests --- scripts/scratch/steps/0000-populate-deploy-artifact-from-env.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/scratch/steps/0000-populate-deploy-artifact-from-env.ts b/scripts/scratch/steps/0000-populate-deploy-artifact-from-env.ts index 6ec2a5b14..50b4e0346 100644 --- a/scripts/scratch/steps/0000-populate-deploy-artifact-from-env.ts +++ b/scripts/scratch/steps/0000-populate-deploy-artifact-from-env.ts @@ -17,7 +17,7 @@ export async function main() { const deployer = ethers.getAddress(getEnvVariable("DEPLOYER")); const gateSealFactoryAddress = getEnvVariable("GATE_SEAL_FACTORY", ""); const genesisTime = parseInt(getEnvVariable("GENESIS_TIME")); - const slotsPerEpoch = parseInt(getEnvVariable("SLOTS_PER_EPOCH") ?? 32); + const slotsPerEpoch = parseInt(getEnvVariable("SLOTS_PER_EPOCH", "32"), 10); const depositContractAddress = getEnvVariable("DEPOSIT_CONTRACT", ""); const withdrawalQueueBaseUri = getEnvVariable("WITHDRAWAL_QUEUE_BASE_URI", ""); const dsmPredefinedAddress = getEnvVariable("DSM_PREDEFINED_ADDRESS", ""); From d1fc7ec4734ddff2d454451cc46cc38b1c84d1ea Mon Sep 17 00:00:00 2001 From: Eddort Date: Mon, 17 Feb 2025 14:20:54 +0100 Subject: [PATCH 129/184] fix: IDepositContract format --- test/0.8.9/ISepoliaDepositContract.sol | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test/0.8.9/ISepoliaDepositContract.sol b/test/0.8.9/ISepoliaDepositContract.sol index 0855a3b57..2576315d3 100644 --- a/test/0.8.9/ISepoliaDepositContract.sol +++ b/test/0.8.9/ISepoliaDepositContract.sol @@ -7,18 +7,18 @@ pragma solidity 0.8.9; import "@openzeppelin/contracts-v4.4/token/ERC20/IERC20.sol"; interface IDepositContract { - event DepositEvent(bytes pubkey, bytes withdrawal_credentials, bytes amount, bytes signature, bytes index); + event DepositEvent(bytes pubkey, bytes withdrawal_credentials, bytes amount, bytes signature, bytes index); - function deposit( - bytes calldata pubkey, - bytes calldata withdrawal_credentials, - bytes calldata signature, - bytes32 deposit_data_root - ) external payable; + function deposit( + bytes calldata pubkey, + bytes calldata withdrawal_credentials, + bytes calldata signature, + bytes32 deposit_data_root + ) external payable; - function get_deposit_root() external view returns (bytes32); + function get_deposit_root() external view returns (bytes32); - function get_deposit_count() external view returns (bytes memory); + function get_deposit_count() external view returns (bytes memory); } interface ISepoliaDepositContract is IDepositContract, IERC20 {} From f22a7a921c94bfc188b8cd540f1ff983dbe4cc7b Mon Sep 17 00:00:00 2001 From: Eddort Date: Tue, 18 Feb 2025 00:03:49 +0100 Subject: [PATCH 130/184] refactor: reset defaults --- scripts/scratch/deployed-testnet-defaults.json | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scripts/scratch/deployed-testnet-defaults.json b/scripts/scratch/deployed-testnet-defaults.json index fa3a8601d..56a73f72b 100644 --- a/scripts/scratch/deployed-testnet-defaults.json +++ b/scripts/scratch/deployed-testnet-defaults.json @@ -33,8 +33,8 @@ "voting": { "minSupportRequired": "500000000000000000", "minAcceptanceQuorum": "50000000000000000", - "voteDuration": 60, - "objectionPhaseDuration": 5 + "voteDuration": 900, + "objectionPhaseDuration": 300 }, "fee": { "totalPercent": 10, @@ -52,8 +52,6 @@ "0xCD1f9954330AF39a74Fd6e7B25781B4c24ee373f": "820000000000000000000000", "0xaa6bfBCD634EE744CB8FE522b29ADD23124593D3": "60000000000000000000000", "0xBA59A84C6440E8cccfdb5448877E26F1A431Fc8B": "60000000000000000000000", - "0x8943545177806ED17B9F23F0a21ee5948eCaa776": "820000000000000000000000", - "0xE25583099BA105D9ec0A67f5Ae86D90e50036425": "820000000000000000000000", "lido-aragon-agent-placeholder": "60000000000000000000000" }, "start": 0, From 5d853cd35661024c48d4303c7338b46315e15aac Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 18 Feb 2025 10:25:03 +0000 Subject: [PATCH 131/184] fix(test): 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 59df58cb4..8e19bae85 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, @@ -177,7 +174,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); @@ -227,8 +224,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); @@ -258,9 +254,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(); @@ -291,7 +285,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); @@ -350,10 +344,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; @@ -398,7 +389,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); @@ -418,13 +409,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 @@ -436,12 +422,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 @@ -455,15 +436,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); From fd9b0b70a0fb79148a821fc1f9d4cc9400f6c50d Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 18 Feb 2025 10:45:51 +0000 Subject: [PATCH 132/184] fix(test): vebo tests --- ...ator-exit-bus-oracle.accessControl.test.ts | 10 ++--- .../validator-exit-bus-oracle.gas.test.ts | 37 +++++++++---------- ...alidator-exit-bus-oracle.happyPath.test.ts | 10 ++--- ...r-exit-bus-oracle.submitReportData.test.ts | 10 ++--- test/deploy/validatorExitBusOracle.ts | 21 ++++++++--- 5 files changed, 42 insertions(+), 46 deletions(-) diff --git a/test/0.8.9/oracle/validator-exit-bus-oracle.accessControl.test.ts b/test/0.8.9/oracle/validator-exit-bus-oracle.accessControl.test.ts index 53c0e1e29..93d8ae4a2 100644 --- a/test/0.8.9/oracle/validator-exit-bus-oracle.accessControl.test.ts +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.accessControl.test.ts @@ -70,7 +70,9 @@ describe("ValidatorsExitBusOracle.sol:accessControl", () => { return "0x" + requests.map(encodeExitRequestHex).join(""); }; - const deploy = async () => { + before(async () => { + [admin, member1, member2, member3, stranger, account1] = await ethers.getSigners(); + const deployed = await deployVEBO(admin.address); oracle = deployed.oracle; consensus = deployed.consensus; @@ -103,12 +105,6 @@ describe("ValidatorsExitBusOracle.sol:accessControl", () => { await consensus.connect(member1).submitReport(refSlot, reportHash, CONSENSUS_VERSION); await consensus.connect(member3).submitReport(refSlot, reportHash, CONSENSUS_VERSION); - }; - - before(async () => { - [admin, member1, member2, member3, stranger, account1] = await ethers.getSigners(); - - await deploy(); }); beforeEach(async () => (originalState = await Snapshot.take())); diff --git a/test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts b/test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts index c92fc799c..6cd0985c7 100644 --- a/test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts @@ -76,25 +76,6 @@ describe("ValidatorsExitBusOracle.sol:gas", () => { return "0x" + requests.map(encodeExitRequestHex).join(""); }; - const deploy = async () => { - const deployed = await deployVEBO(admin.address); - oracle = deployed.oracle; - consensus = deployed.consensus; - - await initVEBO({ - admin: admin.address, - oracle, - consensus, - resumeAfterDeploy: true, - }); - - oracleVersion = await oracle.getContractVersion(); - - await consensus.addMember(member1, 1); - await consensus.addMember(member2, 2); - await consensus.addMember(member3, 2); - }; - const triggerConsensusOnHash = async (hash: string) => { const { refSlot } = await consensus.getCurrentFrame(); await consensus.connect(member1).submitReport(refSlot, hash, CONSENSUS_VERSION); @@ -124,7 +105,23 @@ describe("ValidatorsExitBusOracle.sol:gas", () => { before(async () => { [admin, member1, member2, member3] = await ethers.getSigners(); - await deploy(); + + const deployed = await deployVEBO(admin.address); + oracle = deployed.oracle; + consensus = deployed.consensus; + + await initVEBO({ + admin: admin.address, + oracle, + consensus, + resumeAfterDeploy: true, + }); + + oracleVersion = await oracle.getContractVersion(); + + await consensus.addMember(member1, 1); + await consensus.addMember(member2, 2); + await consensus.addMember(member3, 2); }); after(async () => { diff --git a/test/0.8.9/oracle/validator-exit-bus-oracle.happyPath.test.ts b/test/0.8.9/oracle/validator-exit-bus-oracle.happyPath.test.ts index 615050cf4..74f411f6c 100644 --- a/test/0.8.9/oracle/validator-exit-bus-oracle.happyPath.test.ts +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.happyPath.test.ts @@ -77,7 +77,9 @@ describe("ValidatorsExitBusOracle.sol:happyPath", () => { return "0x" + requests.map(encodeExitRequestHex).join(""); }; - const deploy = async () => { + before(async () => { + [admin, member1, member2, member3, stranger] = await ethers.getSigners(); + const deployed = await deployVEBO(admin.address); oracle = deployed.oracle; consensus = deployed.consensus; @@ -95,12 +97,6 @@ describe("ValidatorsExitBusOracle.sol:happyPath", () => { await consensus.addMember(member1, 1); await consensus.addMember(member2, 2); await consensus.addMember(member3, 2); - }; - - before(async () => { - [admin, member1, member2, member3, stranger] = await ethers.getSigners(); - - await deploy(); }); const triggerConsensusOnHash = async (hash: string) => { diff --git a/test/0.8.9/oracle/validator-exit-bus-oracle.submitReportData.test.ts b/test/0.8.9/oracle/validator-exit-bus-oracle.submitReportData.test.ts index a5f7fd628..da220cfc9 100644 --- a/test/0.8.9/oracle/validator-exit-bus-oracle.submitReportData.test.ts +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.submitReportData.test.ts @@ -103,7 +103,9 @@ describe("ValidatorsExitBusOracle.sol:submitReportData", () => { return (await oracle.getLastRequestedValidatorIndices(moduleId, [nodeOpId]))[0]; } - const deploy = async () => { + before(async () => { + [admin, member1, member2, member3, stranger] = await ethers.getSigners(); + const deployed = await deployVEBO(admin.address); oracle = deployed.oracle; consensus = deployed.consensus; @@ -122,12 +124,6 @@ describe("ValidatorsExitBusOracle.sol:submitReportData", () => { await consensus.addMember(member1, 1); await consensus.addMember(member2, 2); await consensus.addMember(member3, 2); - }; - - before(async () => { - [admin, member1, member2, member3, stranger] = await ethers.getSigners(); - - await deploy(); }); context("discarded report prevents data submit", () => { diff --git a/test/deploy/validatorExitBusOracle.ts b/test/deploy/validatorExitBusOracle.ts index 1b5e0e280..9ca2c44de 100644 --- a/test/deploy/validatorExitBusOracle.ts +++ b/test/deploy/validatorExitBusOracle.ts @@ -18,7 +18,7 @@ import { deployLidoLocator, updateLidoLocatorImplementation } from "./locator"; export const DATA_FORMAT_LIST = 1; async function deployMockAccountingOracle(secondsPerSlot = SECONDS_PER_SLOT, genesisTime = GENESIS_TIME) { - const lido = await ethers.deployContract("Lido__MockForAccountingOracle"); + const lido = await ethers.deployContract("Accounting__MockForAccountingOracle"); const ao = await ethers.deployContract("AccountingOracle__MockForSanityChecker", [ await lido.getAddress(), secondsPerSlot, @@ -28,10 +28,21 @@ async function deployMockAccountingOracle(secondsPerSlot = SECONDS_PER_SLOT, gen } async function deployOracleReportSanityCheckerForExitBus(lidoLocator: string, admin: string) { - const maxValidatorExitRequestsPerReport = 2000; - const limitsList = [0, 0, 0, 0, maxValidatorExitRequestsPerReport, 0, 0, 0, 0, 0, 0, 0]; - - return await ethers.deployContract("OracleReportSanityChecker", [lidoLocator, admin, limitsList]); + return await ethers.getContractFactory("OracleReportSanityChecker").then((f) => + f.deploy(lidoLocator, admin, { + exitedValidatorsPerDayLimit: 0n, + appearedValidatorsPerDayLimit: 0n, + annualBalanceIncreaseBPLimit: 0n, + maxValidatorExitRequestsPerReport: 2000, + maxItemsPerExtraDataTransaction: 0n, + maxNodeOperatorsPerExtraDataItem: 0n, + requestTimestampMargin: 0n, + maxPositiveTokenRebase: 0n, + initialSlashingAmountPWei: 0n, + inactivityPenaltiesAmountPWei: 0n, + clBalanceOraclesErrorUpperBPLimit: 0n, + }), + ); } export async function deployVEBO( From f4d128f40df590608a3ba4d8d8dd5436f84d1bbb Mon Sep 17 00:00:00 2001 From: Eddort Date: Tue, 18 Feb 2025 13:17:00 +0100 Subject: [PATCH 133/184] refactor: env vars --- globals.d.ts | 3 +++ hardhat.config.ts | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/globals.d.ts b/globals.d.ts index ba0652a07..f58fe5b10 100644 --- a/globals.d.ts +++ b/globals.d.ts @@ -77,5 +77,8 @@ declare namespace NodeJS { /* for local devnet */ LOCAL_DEVNET_PK?: string; + LOCAL_DEVNET_CHAIN_ID?: string; + LOCAL_DEVNET_EXPLORER_API_URL?: string; + LOCAL_DEVNET_EXPLORER_URL?: string; } } diff --git a/hardhat.config.ts b/hardhat.config.ts index 004293836..4f672bd73 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -66,10 +66,10 @@ const config: HardhatUserConfig = { customChains: [ { network: "local-devnet", - chainId: parseInt(process.env.LOCAL_DENVET_CHAIN_ID ?? "32382", 10), + chainId: parseInt(process.env.LOCAL_DEVNET_CHAIN_ID ?? "32382", 10), urls: { - apiURL: process.env.LOCAL_DENVET_EXPLORER_API_URL ?? "", - browserURL: process.env.LOCAL_DENVET_EXPLORER_URL ?? "", + apiURL: process.env.LOCAL_DEVNET_EXPLORER_API_URL ?? "", + browserURL: process.env.LOCAL_DEVNET_EXPLORER_URL ?? "", }, }, ], From 9492d8f21ee15743a2e5a05cfc9012801498b121 Mon Sep 17 00:00:00 2001 From: Eddort Date: Tue, 18 Feb 2025 13:29:12 +0100 Subject: [PATCH 134/184] refactor: env example --- .env.example | 3 --- 1 file changed, 3 deletions(-) diff --git a/.env.example b/.env.example index 205f21808..f21c1c63a 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,3 @@ GAS_MAX_FEE=100 # Etherscan API key for verifying contracts ETHERSCAN_API_KEY= - -# Local devnet private key -LOCAL_DEVNET_PK=0x0000000000000000000000000000000000000000000000000000000000000000 From 1f76bae2b07812ed3d9a998167c3c2159f5cf51c Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 18 Feb 2025 13:29:52 +0000 Subject: [PATCH 135/184] test: add review scenario test --- contracts/0.8.25/vaults/StakingVault.sol | 15 ++-- contracts/0.8.25/vaults/VaultHub.sol | 3 + package.json | 1 + .../StakingVault__MockForVaultHub.sol | 14 ++++ .../vaulthub/contracts/VaultHub__Harness.sol | 34 ++++++++++ .../vaulthub.force-withdrawals.test.ts | 68 ++++++++++++++++--- 6 files changed, 118 insertions(+), 17 deletions(-) create mode 100644 test/0.8.25/vaults/vaulthub/contracts/VaultHub__Harness.sol diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 4c4faef4e..0c65868bf 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -479,19 +479,21 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { } ERC7201Storage storage $ = _getStorage(); + + // If the vault is unbalanced, block partial withdrawals because they can front-run blocking the full exit bool isBalanced = valuation() >= $.locked; + if (!isBalanced) { + for (uint256 i = 0; i < _amounts.length; i++) { + if (_amounts[i] > 0) revert PartialWithdrawalNotAllowed(); + } + } + 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 PartialWithdrawalNotAllowed(); - } - } uint256 feePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); uint256 totalFee = feePerRequest * keysCount; @@ -508,7 +510,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { 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 diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 03c6f91d3..83ce364fb 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -382,6 +382,9 @@ abstract contract VaultHub is PausableUntilWithRoles { emit VaultRebalanced(msg.sender, sharesToBurn); } + /// THIS IS A LAST RESORT MECHANISM, THAT SHOULD BE AVOIDED BY THE VAULT OPERATORS AT ALL COSTS + /// In case of the unbalanced vault, ANYONE can force any validator belonging to the vault to withdraw from the + /// beacon chain to get all the vault deposited ETH back to the vault balance and rebalance the vault /// @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 diff --git a/package.json b/package.json index ba7fde637..0213338dd 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:trace": "hardhat test test/**/*.test.ts --trace --disabletracer", "test:fulltrace": "hardhat test test/**/*.test.ts --fulltrace --disabletracer", "test:watch": "SKIP_GAS_REPORT=true SKIP_CONTRACT_SIZE=true hardhat watch test", + "test:watch:trace": "SKIP_GAS_REPORT=true SKIP_CONTRACT_SIZE=true hardhat watch test --trace --disabletracer", "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", diff --git a/test/0.8.25/vaults/vaulthub/contracts/StakingVault__MockForVaultHub.sol b/test/0.8.25/vaults/vaulthub/contracts/StakingVault__MockForVaultHub.sol index 6d668b0d0..09191527c 100644 --- a/test/0.8.25/vaults/vaulthub/contracts/StakingVault__MockForVaultHub.sol +++ b/test/0.8.25/vaults/vaulthub/contracts/StakingVault__MockForVaultHub.sol @@ -61,8 +61,22 @@ contract StakingVault__MockForVaultHub { uint64[] calldata _amounts, address _refundRecipient ) external payable { + if ($valuation > $locked) { + revert Mock__HealthyVault(); + } + emit ValidatorWithdrawalTriggered(_pubkeys, _amounts, _refundRecipient); } + function mock__decreaseValuation(uint256 amount) external { + $valuation -= amount; + } + + function mock__increaseValuation(uint256 amount) external { + $valuation += amount; + } + event ValidatorWithdrawalTriggered(bytes pubkeys, uint64[] amounts, address refundRecipient); + + error Mock__HealthyVault(); } diff --git a/test/0.8.25/vaults/vaulthub/contracts/VaultHub__Harness.sol b/test/0.8.25/vaults/vaulthub/contracts/VaultHub__Harness.sol new file mode 100644 index 000000000..0bf941041 --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/contracts/VaultHub__Harness.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity ^0.8.0; + +import {Accounting} from "contracts/0.8.25/Accounting.sol"; + +import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; + +contract VaultHub__Harness is Accounting { + constructor(address _locator, address _steth) Accounting(ILidoLocator(_locator), ILido(_steth)) {} + + function mock__calculateVaultsRebase( + uint256 _postTotalShares, + uint256 _postTotalPooledEther, + uint256 _preTotalShares, + uint256 _preTotalPooledEther, + uint256 _sharesToMintAsFees + ) + external + view + returns (uint256[] memory lockedEther, uint256[] memory treasuryFeeShares, uint256 totalTreasuryFeeShares) + { + return + _calculateVaultsRebase( + _postTotalShares, + _postTotalPooledEther, + _preTotalShares, + _preTotalPooledEther, + _sharesToMintAsFees + ); + } +} 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 bf22e2ffc..9100edbc1 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 @@ -8,7 +8,8 @@ import { DepositContract__MockForVaultHub, StakingVault__MockForVaultHub, StETH__HarnessForVaultHub, - VaultHub, + VaultFactory__MockForVaultHub, + VaultHub__Harness, } from "typechain-types"; import { impersonate } from "lib"; @@ -21,6 +22,7 @@ import { Snapshot } from "test/suite"; const SAMPLE_PUBKEY = "0x" + "01".repeat(48); const SHARE_LIMIT = ether("1"); +const TOTAL_BASIS_POINTS = 10_000n; const RESERVE_RATIO_BP = 10_00n; const RESERVE_RATIO_THRESHOLD_BP = 8_00n; const TREASURY_FEE_BP = 5_00n; @@ -33,7 +35,8 @@ describe("VaultHub.sol:forceWithdrawals", () => { let stranger: HardhatEthersSigner; let feeRecipient: HardhatEthersSigner; - let vaultHub: VaultHub; + let vaultHub: VaultHub__Harness; + let vaultFactory: VaultFactory__MockForVaultHub; let vault: StakingVault__MockForVaultHub; let steth: StETH__HarnessForVaultHub; let depositContract: DepositContract__MockForVaultHub; @@ -49,16 +52,16 @@ describe("VaultHub.sol:forceWithdrawals", () => { [deployer, user, stranger, feeRecipient] = await ethers.getSigners(); const locator = await deployLidoLocator(); - steth = await ethers.deployContract("StETH__HarnessForVaultHub", [user], { value: ether("1000.0") }); + steth = await ethers.deployContract("StETH__HarnessForVaultHub", [user], { value: ether("10000.0") }); depositContract = await ethers.deployContract("DepositContract__MockForVaultHub"); - const vaultHubImpl = await ethers.deployContract("Accounting", [locator, steth]); + const vaultHubImpl = await ethers.deployContract("VaultHub__Harness", [locator, steth]); const proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()]); - const accounting = await ethers.getContractAt("Accounting", proxy); + const accounting = await ethers.getContractAt("VaultHub__Harness", proxy); await accounting.initialize(deployer); - vaultHub = await ethers.getContractAt("Accounting", proxy, user); + vaultHub = await ethers.getContractAt("VaultHub__Harness", proxy, user); vaultHubAddress = await vaultHub.getAddress(); await accounting.grantRole(await vaultHub.VAULT_MASTER_ROLE(), user); @@ -69,12 +72,10 @@ describe("VaultHub.sol:forceWithdrawals", () => { await depositContract.getAddress(), ]); - const vaultFactory = await ethers.deployContract("VaultFactory__MockForVaultHub", [ - await stakingVaultImpl.getAddress(), - ]); + vaultFactory = await ethers.deployContract("VaultFactory__MockForVaultHub", [await stakingVaultImpl.getAddress()]); const vaultCreationTx = (await vaultFactory - .createVault(await user.getAddress(), await user.getAddress()) + .createVault(user, user) .then((tx) => tx.wait())) as ContractTransactionReceipt; const events = findEvents(vaultCreationTx, "VaultCreated"); @@ -175,5 +176,52 @@ describe("VaultHub.sol:forceWithdrawals", () => { .withArgs(vaultAddress, pubkeys, feeRecipient); }); }); + + // https://github.com/lidofinance/core/pull/933#discussion_r1954876831 + it("works for a synthetic example", async () => { + const vaultCreationTx = (await vaultFactory + .createVault(user, user) + .then((tx) => tx.wait())) as ContractTransactionReceipt; + + const events = findEvents(vaultCreationTx, "VaultCreated"); + const demoVaultAddress = events[0].args.vault; + + const demoVault = await ethers.getContractAt("StakingVault__MockForVaultHub", demoVaultAddress, user); + + const valuation = ether("100"); + await demoVault.fund({ value: valuation }); + const cap = await steth.getSharesByPooledEth((valuation * (TOTAL_BASIS_POINTS - 20_01n)) / TOTAL_BASIS_POINTS); + + await vaultHub.connectVault(demoVaultAddress, cap, 20_00n, 20_00n, 5_00n); + await vaultHub.mintSharesBackedByVault(demoVaultAddress, user, cap); + + expect((await vaultHub["vaultSocket(address)"](demoVaultAddress)).sharesMinted).to.equal(cap); + + // decrease valuation to trigger rebase + const penalty = ether("1"); + await demoVault.mock__decreaseValuation(penalty); + + const rebase = await vaultHub.mock__calculateVaultsRebase( + await steth.getTotalShares(), + await steth.getTotalPooledEther(), + await steth.getTotalShares(), + await steth.getTotalPooledEther(), + 0n, + ); + + const totalMintedShares = (await vaultHub["vaultSocket(address)"](demoVaultAddress)).sharesMinted; + const mintedSteth = (totalMintedShares * (await steth.getTotalPooledEther())) / (await steth.getTotalShares()); + const lockedEtherPredicted = (mintedSteth * TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - 20_00n); + + expect(lockedEtherPredicted).to.equal(rebase.lockedEther[1]); + + await demoVault.report(valuation - penalty, valuation, rebase.lockedEther[1]); + + expect(await vaultHub.isVaultBalanced(demoVaultAddress)).to.be.false; + + await expect(vaultHub.forceValidatorWithdrawal(demoVaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: FEE })) + .to.emit(vaultHub, "VaultForceWithdrawalTriggered") + .withArgs(demoVaultAddress, SAMPLE_PUBKEY, feeRecipient); + }); }); }); From 5e7e304d18367c8ffcf20b27c7d87bccefdff56b Mon Sep 17 00:00:00 2001 From: Eddort Date: Tue, 18 Feb 2025 17:26:36 +0100 Subject: [PATCH 136/184] fix: initialize WithdrawalVault --- .../scratch/steps/0120-initialize-non-aragon-contracts.ts | 5 +++++ 1 file changed, 5 insertions(+) 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 }); From b403164e2aafadc00968234116c2cce1e9d95f36 Mon Sep 17 00:00:00 2001 From: Eddort Date: Tue, 18 Feb 2025 17:26:58 +0100 Subject: [PATCH 137/184] fix: hh etherscan config --- hardhat.config.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/hardhat.config.ts b/hardhat.config.ts index 4f672bd73..df23c8a24 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -73,9 +73,11 @@ const config: HardhatUserConfig = { }, }, ], - apiKey: { - "local-devnet": "local-devnet", - }, + apiKey: process.env.LOCAL_DEVNET_EXPLORER_API_URL + ? { + "local-devnet": "local-devnet", + } + : process.env.ETHERSCAN_API_KEY || "", }, solidity: { compilers: [ From 60890703cb5cb4b9319d6ba8ad3999f27518b87d Mon Sep 17 00:00:00 2001 From: Eddort Date: Tue, 18 Feb 2025 17:32:10 +0100 Subject: [PATCH 138/184] fix: wv initialize --- scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts b/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts index bd8eff9eb..5cbe15d03 100644 --- a/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts +++ b/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts @@ -35,7 +35,6 @@ export async function main() { const exitBusOracleAdmin = testnetAdmin; const stakingRouterAdmin = testnetAdmin; const withdrawalQueueAdmin = testnetAdmin; - const withdrawalVaultAdmin = testnetAdmin; // Initialize NodeOperatorsRegistry @@ -111,7 +110,7 @@ export async function main() { // Initialize WithdrawalVault const withdrawalVault = await loadContract("WithdrawalVault", withdrawalVaultAddress); - await makeTx(withdrawalVault, "initialize", [withdrawalVaultAdmin], { from: deployer }); + await makeTx(withdrawalVault, "initialize", [], { from: deployer }); // Initialize WithdrawalQueue const withdrawalQueue = await loadContract("WithdrawalQueueERC721", withdrawalQueueAddress); From 74e8478374a0edded7b4f37900a4e1d7f92143ab Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 19 Feb 2025 13:55:07 +0500 Subject: [PATCH 139/184] fix: comment more details --- contracts/0.8.25/vaults/Dashboard.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index b14e05cff..6a1f50365 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -30,7 +30,7 @@ interface IWstETH is IERC20, IERC20Permit { /** * @title Dashboard - * @notice This contract is a UX-layer for `StakingVault`. + * @notice This contract is a UX-layer for StakingVault and meant to be used as its owner. * This contract improves the vault UX by bundling all functions from the StakingVault and VaultHub * in this single contract. It provides administrative functions for managing the StakingVault, * including funding, withdrawing, minting, burning, and rebalancing operations. From 515504f9f82f878062e46b3c8b5bdd815009e4e6 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 19 Feb 2025 13:58:21 +0500 Subject: [PATCH 140/184] fix: remove unused role --- contracts/0.8.25/vaults/Delegation.sol | 8 +------- contracts/0.8.25/vaults/VaultFactory.sol | 4 ---- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index a417b702c..877c574b2 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -38,11 +38,6 @@ contract Delegation is Dashboard { */ bytes32 public constant NODE_OPERATOR_MANAGER_ROLE = keccak256("vaults.Delegation.NodeOperatorManagerRole"); - /** - * @notice Confirms node operator fee. - */ - bytes32 public constant NODE_OPERATOR_FEE_CONFIRM_ROLE = keccak256("vaults.Delegation.NodeOperatorFeeConfirmRole"); - /** * @notice Claims node operator fee. */ @@ -81,7 +76,7 @@ contract Delegation is Dashboard { /** * @notice Initializes the contract: * - sets up the roles; - * - sets the confirm lifetime to 7 days (can be changed later by CURATOR_ROLE and NODE_OPERATOR_MANAGER_ROLE). + * - sets the confirm lifetime to 7 days (can be changed later by DEFAULT_ADMIN_ROLE and NODE_OPERATOR_MANAGER_ROLE). * @dev The msg.sender here is VaultFactory. The VaultFactory is temporarily granted * DEFAULT_ADMIN_ROLE AND NODE_OPERATOR_MANAGER_ROLE to be able to set initial fees and roles in VaultFactory. * All the roles are revoked from VaultFactory by the end of the initialization. @@ -94,7 +89,6 @@ contract Delegation is Dashboard { // at the end of the initialization _grantRole(NODE_OPERATOR_MANAGER_ROLE, _defaultAdmin); _setRoleAdmin(NODE_OPERATOR_MANAGER_ROLE, NODE_OPERATOR_MANAGER_ROLE); - _setRoleAdmin(NODE_OPERATOR_FEE_CONFIRM_ROLE, NODE_OPERATOR_MANAGER_ROLE); _setRoleAdmin(NODE_OPERATOR_FEE_CLAIM_ROLE, NODE_OPERATOR_MANAGER_ROLE); } diff --git a/contracts/0.8.25/vaults/VaultFactory.sol b/contracts/0.8.25/vaults/VaultFactory.sol index bb85c2d51..e075c7787 100644 --- a/contracts/0.8.25/vaults/VaultFactory.sol +++ b/contracts/0.8.25/vaults/VaultFactory.sol @@ -24,7 +24,6 @@ struct DelegationConfig { address curatorFeeSetter; address curatorFeeClaimer; address nodeOperatorManager; - address nodeOperatorFeeConfirmer; address nodeOperatorFeeClaimer; uint16 curatorFeeBP; uint16 nodeOperatorFeeBP; @@ -86,11 +85,9 @@ contract VaultFactory { delegation.grantRole(delegation.CURATOR_FEE_CLAIM_ROLE(), _delegationConfig.curatorFeeClaimer); delegation.grantRole(delegation.NODE_OPERATOR_MANAGER_ROLE(), _delegationConfig.nodeOperatorManager); delegation.grantRole(delegation.NODE_OPERATOR_FEE_CLAIM_ROLE(), _delegationConfig.nodeOperatorFeeClaimer); - delegation.grantRole(delegation.NODE_OPERATOR_FEE_CONFIRM_ROLE(), _delegationConfig.nodeOperatorFeeConfirmer); // grant temporary roles to factory for setting fees delegation.grantRole(delegation.CURATOR_FEE_SET_ROLE(), address(this)); - delegation.grantRole(delegation.NODE_OPERATOR_FEE_CONFIRM_ROLE(), address(this)); // set fees delegation.setCuratorFeeBP(_delegationConfig.curatorFeeBP); @@ -98,7 +95,6 @@ contract VaultFactory { // revoke temporary roles from factory delegation.revokeRole(delegation.CURATOR_FEE_SET_ROLE(), address(this)); - delegation.revokeRole(delegation.NODE_OPERATOR_FEE_CONFIRM_ROLE(), address(this)); delegation.revokeRole(delegation.NODE_OPERATOR_MANAGER_ROLE(), address(this)); delegation.revokeRole(delegation.DEFAULT_ADMIN_ROLE(), address(this)); From 0751a1f3ad0c685f763965f4a42ccc059ab45fba Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 19 Feb 2025 14:19:54 +0500 Subject: [PATCH 141/184] feat(VaultFactory): pass role members array --- contracts/0.8.25/vaults/VaultFactory.sol | 79 +++++++++++++------ .../vaults/delegation/delegation.test.ts | 30 +++---- test/0.8.25/vaults/vaultFactory.test.ts | 25 +++--- .../vaults-happy-path.integration.ts | 26 +++--- 4 files changed, 90 insertions(+), 70 deletions(-) diff --git a/contracts/0.8.25/vaults/VaultFactory.sol b/contracts/0.8.25/vaults/VaultFactory.sol index e075c7787..7198b2d82 100644 --- a/contracts/0.8.25/vaults/VaultFactory.sol +++ b/contracts/0.8.25/vaults/VaultFactory.sol @@ -12,22 +12,22 @@ import {Delegation} from "./Delegation.sol"; struct DelegationConfig { address defaultAdmin; - address funder; - address withdrawer; - address minter; - address burner; - address rebalancer; - address depositPauser; - address depositResumer; - address exitRequester; - address disconnecter; - address curatorFeeSetter; - address curatorFeeClaimer; address nodeOperatorManager; - address nodeOperatorFeeClaimer; + uint256 confirmLifetime; uint16 curatorFeeBP; uint16 nodeOperatorFeeBP; - uint256 confirmLifetime; + address[] funders; + address[] withdrawers; + address[] minters; + address[] burners; + address[] rebalancers; + address[] depositPausers; + address[] depositResumers; + address[] exitRequesters; + address[] disconnecters; + address[] curatorFeeSetters; + address[] curatorFeeClaimers; + address[] nodeOperatorFeeClaimers; } contract VaultFactory { @@ -71,20 +71,47 @@ contract VaultFactory { // setup roles from config // basic permissions to the staking vault delegation.grantRole(delegation.DEFAULT_ADMIN_ROLE(), _delegationConfig.defaultAdmin); - delegation.grantRole(delegation.FUND_ROLE(), _delegationConfig.funder); - delegation.grantRole(delegation.WITHDRAW_ROLE(), _delegationConfig.withdrawer); - delegation.grantRole(delegation.MINT_ROLE(), _delegationConfig.minter); - delegation.grantRole(delegation.BURN_ROLE(), _delegationConfig.burner); - 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.VOLUNTARY_DISCONNECT_ROLE(), _delegationConfig.disconnecter); - // delegation roles - delegation.grantRole(delegation.CURATOR_FEE_SET_ROLE(), _delegationConfig.curatorFeeSetter); - delegation.grantRole(delegation.CURATOR_FEE_CLAIM_ROLE(), _delegationConfig.curatorFeeClaimer); delegation.grantRole(delegation.NODE_OPERATOR_MANAGER_ROLE(), _delegationConfig.nodeOperatorManager); - delegation.grantRole(delegation.NODE_OPERATOR_FEE_CLAIM_ROLE(), _delegationConfig.nodeOperatorFeeClaimer); + + for (uint256 i = 0; i < _delegationConfig.funders.length; i++) { + delegation.grantRole(delegation.FUND_ROLE(), _delegationConfig.funders[i]); + } + for (uint256 i = 0; i < _delegationConfig.withdrawers.length; i++) { + delegation.grantRole(delegation.WITHDRAW_ROLE(), _delegationConfig.withdrawers[i]); + } + for (uint256 i = 0; i < _delegationConfig.minters.length; i++) { + delegation.grantRole(delegation.MINT_ROLE(), _delegationConfig.minters[i]); + } + for (uint256 i = 0; i < _delegationConfig.burners.length; i++) { + delegation.grantRole(delegation.BURN_ROLE(), _delegationConfig.burners[i]); + } + for (uint256 i = 0; i < _delegationConfig.rebalancers.length; i++) { + delegation.grantRole(delegation.REBALANCE_ROLE(), _delegationConfig.rebalancers[i]); + } + for (uint256 i = 0; i < _delegationConfig.depositPausers.length; i++) { + delegation.grantRole(delegation.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), _delegationConfig.depositPausers[i]); + } + for (uint256 i = 0; i < _delegationConfig.depositResumers.length; i++) { + delegation.grantRole(delegation.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), _delegationConfig.depositResumers[i]); + } + for (uint256 i = 0; i < _delegationConfig.exitRequesters.length; i++) { + delegation.grantRole(delegation.REQUEST_VALIDATOR_EXIT_ROLE(), _delegationConfig.exitRequesters[i]); + } + for (uint256 i = 0; i < _delegationConfig.disconnecters.length; i++) { + delegation.grantRole(delegation.VOLUNTARY_DISCONNECT_ROLE(), _delegationConfig.disconnecters[i]); + } + for (uint256 i = 0; i < _delegationConfig.curatorFeeSetters.length; i++) { + delegation.grantRole(delegation.CURATOR_FEE_SET_ROLE(), _delegationConfig.curatorFeeSetters[i]); + } + for (uint256 i = 0; i < _delegationConfig.curatorFeeClaimers.length; i++) { + delegation.grantRole(delegation.CURATOR_FEE_CLAIM_ROLE(), _delegationConfig.curatorFeeClaimers[i]); + } + for (uint256 i = 0; i < _delegationConfig.nodeOperatorFeeClaimers.length; i++) { + delegation.grantRole( + delegation.NODE_OPERATOR_FEE_CLAIM_ROLE(), + _delegationConfig.nodeOperatorFeeClaimers[i] + ); + } // grant temporary roles to factory for setting fees delegation.grantRole(delegation.CURATOR_FEE_SET_ROLE(), address(this)); diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index 93ea9bb4e..262ac660e 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -39,7 +39,6 @@ describe("Delegation.sol", () => { let curatorFeeSetter: HardhatEthersSigner; let curatorFeeClaimer: HardhatEthersSigner; let nodeOperatorManager: HardhatEthersSigner; - let nodeOperatorFeeConfirmer: HardhatEthersSigner; let nodeOperatorFeeClaimer: HardhatEthersSigner; let stranger: HardhatEthersSigner; @@ -78,7 +77,6 @@ describe("Delegation.sol", () => { curatorFeeSetter, curatorFeeClaimer, nodeOperatorManager, - nodeOperatorFeeConfirmer, nodeOperatorFeeClaimer, stranger, beaconOwner, @@ -110,23 +108,22 @@ describe("Delegation.sol", () => { const vaultCreationTx = await factory.connect(vaultOwner).createVaultWithDelegation( { defaultAdmin: vaultOwner, - funder, - withdrawer, - minter, - burner, - rebalancer, - depositPauser, - depositResumer, - exitRequester, - disconnecter, - curatorFeeSetter, - curatorFeeClaimer, nodeOperatorManager, - nodeOperatorFeeConfirmer, - nodeOperatorFeeClaimer, + confirmLifetime: days(7n), curatorFeeBP: 0n, nodeOperatorFeeBP: 0n, - confirmLifetime: days(7n), + funders: [funder], + withdrawers: [withdrawer], + minters: [minter], + burners: [burner], + rebalancers: [rebalancer], + depositPausers: [depositPauser], + depositResumers: [depositResumer], + exitRequesters: [exitRequester], + disconnecters: [disconnecter], + curatorFeeSetters: [curatorFeeSetter], + curatorFeeClaimers: [curatorFeeClaimer], + nodeOperatorFeeClaimers: [nodeOperatorFeeClaimer], }, "0x", ); @@ -218,7 +215,6 @@ describe("Delegation.sol", () => { await assertSoleMember(curatorFeeSetter, await delegation.CURATOR_FEE_SET_ROLE()); await assertSoleMember(curatorFeeClaimer, await delegation.CURATOR_FEE_CLAIM_ROLE()); await assertSoleMember(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE()); - await assertSoleMember(nodeOperatorFeeConfirmer, await delegation.NODE_OPERATOR_FEE_CONFIRM_ROLE()); await assertSoleMember(nodeOperatorFeeClaimer, await delegation.NODE_OPERATOR_FEE_CLAIM_ROLE()); expect(await delegation.curatorFeeBP()).to.equal(0n); diff --git a/test/0.8.25/vaults/vaultFactory.test.ts b/test/0.8.25/vaults/vaultFactory.test.ts index a595103c4..ea356854a 100644 --- a/test/0.8.25/vaults/vaultFactory.test.ts +++ b/test/0.8.25/vaults/vaultFactory.test.ts @@ -109,23 +109,22 @@ describe("VaultFactory.sol", () => { delegationParams = { defaultAdmin: await admin.getAddress(), - funder: await vaultOwner1.getAddress(), - withdrawer: await vaultOwner1.getAddress(), - minter: await vaultOwner1.getAddress(), - burner: await vaultOwner1.getAddress(), - curatorFeeSetter: await vaultOwner1.getAddress(), - curatorFeeClaimer: await vaultOwner1.getAddress(), nodeOperatorManager: await operator.getAddress(), - nodeOperatorFeeConfirmer: await operator.getAddress(), - nodeOperatorFeeClaimer: await operator.getAddress(), - rebalancer: await vaultOwner1.getAddress(), - depositPauser: await vaultOwner1.getAddress(), - depositResumer: await vaultOwner1.getAddress(), - exitRequester: await vaultOwner1.getAddress(), - disconnecter: await vaultOwner1.getAddress(), confirmLifetime: days(7n), curatorFeeBP: 100n, nodeOperatorFeeBP: 200n, + funders: [await vaultOwner1.getAddress()], + withdrawers: [await vaultOwner1.getAddress()], + minters: [await vaultOwner1.getAddress()], + burners: [await vaultOwner1.getAddress()], + curatorFeeSetters: [await vaultOwner1.getAddress()], + curatorFeeClaimers: [await vaultOwner1.getAddress()], + nodeOperatorFeeClaimers: [await operator.getAddress()], + rebalancers: [await vaultOwner1.getAddress()], + depositPausers: [await vaultOwner1.getAddress()], + depositResumers: [await vaultOwner1.getAddress()], + exitRequesters: [await vaultOwner1.getAddress()], + disconnecters: [await vaultOwner1.getAddress()], }; }); diff --git a/test/integration/vaults-happy-path.integration.ts b/test/integration/vaults-happy-path.integration.ts index 5287ac3f6..8df4ea3f2 100644 --- a/test/integration/vaults-happy-path.integration.ts +++ b/test/integration/vaults-happy-path.integration.ts @@ -159,23 +159,22 @@ describe("Scenario: Staking Vaults Happy Path", () => { const deployTx = await stakingVaultFactory.connect(owner).createVaultWithDelegation( { defaultAdmin: owner, - funder: curator, - withdrawer: curator, - minter: curator, - burner: curator, - rebalancer: curator, - depositPauser: curator, - depositResumer: curator, - exitRequester: curator, - disconnecter: curator, - curatorFeeSetter: curator, - curatorFeeClaimer: curator, nodeOperatorManager: nodeOperator, - nodeOperatorFeeConfirmer: nodeOperator, - nodeOperatorFeeClaimer: nodeOperator, curatorFeeBP: VAULT_OWNER_FEE, nodeOperatorFeeBP: VAULT_NODE_OPERATOR_FEE, confirmLifetime: days(7n), + funders: [curator], + withdrawers: [curator], + minters: [curator], + burners: [curator], + rebalancers: [curator], + depositPausers: [curator], + depositResumers: [curator], + exitRequesters: [curator], + disconnecters: [curator], + curatorFeeSetters: [curator], + curatorFeeClaimers: [curator], + nodeOperatorFeeClaimers: [nodeOperator], }, "0x", ); @@ -195,7 +194,6 @@ describe("Scenario: Staking Vaults Happy Path", () => { expect(await isSoleRoleMember(nodeOperator, await delegation.NODE_OPERATOR_MANAGER_ROLE())).to.be.true; expect(await isSoleRoleMember(nodeOperator, await delegation.NODE_OPERATOR_FEE_CLAIM_ROLE())).to.be.true; - expect(await isSoleRoleMember(nodeOperator, await delegation.NODE_OPERATOR_FEE_CONFIRM_ROLE())).to.be.true; expect(await isSoleRoleMember(curator, await delegation.FUND_ROLE())).to.be.true; expect(await isSoleRoleMember(curator, await delegation.WITHDRAW_ROLE())).to.be.true; From 4c7ff7a71f257cd0238e34a09a797de2e819ed5d Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 19 Feb 2025 14:29:14 +0500 Subject: [PATCH 142/184] fix: update comment --- contracts/0.8.25/vaults/Permissions.sol | 2 +- foundry/lib/forge-std | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index c94357c63..51b9f767b 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -232,7 +232,7 @@ abstract contract Permissions is AccessControlConfirmable { } /** - * @dev Checks the DEFAULT_ADMIN_ROLE and transfers the StakingVault ownership. + * @dev Checks the confirming roles and transfers the StakingVault ownership. * @param _newOwner The address to transfer the StakingVault ownership to. */ function _transferStakingVaultOwnership(address _newOwner) internal onlyConfirmed(_confirmingRoles()) { diff --git a/foundry/lib/forge-std b/foundry/lib/forge-std index bf909b22f..8f24d6b04 160000 --- a/foundry/lib/forge-std +++ b/foundry/lib/forge-std @@ -1 +1 @@ -Subproject commit bf909b22fa55e244796dfa920c9639fdffa1c545 +Subproject commit 8f24d6b04c92975e0795b5868aa0d783251cdeaa From a09aa975349496b28f08829e2280128404ad0375 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 19 Feb 2025 14:53:47 +0500 Subject: [PATCH 143/184] =?UTF-8?q?feat:=20rename=20=F0=9F=A7=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0.8.25/utils/AccessControlConfirmable.sol | 60 +++++++++---------- contracts/0.8.25/vaults/Dashboard.sol | 6 +- contracts/0.8.25/vaults/Delegation.sol | 20 +++---- contracts/0.8.25/vaults/Permissions.sol | 4 +- contracts/0.8.25/vaults/VaultFactory.sol | 4 +- .../utils/access-control-confirmable.test.ts | 48 ++++++++------- .../AccessControlConfirmable__Harness.sol | 4 +- .../0.8.25/vaults/dashboard/dashboard.test.ts | 6 +- .../vaults/delegation/delegation.test.ts | 44 +++++++------- .../contracts/Permissions__Harness.sol | 14 ++--- .../VaultFactory__MockPermissions.sol | 10 ++-- .../vaults/permissions/permissions.test.ts | 6 +- test/0.8.25/vaults/vaultFactory.test.ts | 2 +- .../vaults-happy-path.integration.ts | 2 +- 14 files changed, 116 insertions(+), 114 deletions(-) diff --git a/contracts/0.8.25/utils/AccessControlConfirmable.sol b/contracts/0.8.25/utils/AccessControlConfirmable.sol index 1f80d37da..a8ea6b43e 100644 --- a/contracts/0.8.25/utils/AccessControlConfirmable.sol +++ b/contracts/0.8.25/utils/AccessControlConfirmable.sol @@ -22,30 +22,30 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { mapping(bytes callData => mapping(bytes32 role => uint256 expiryTimestamp)) public confirmations; /** - * @notice Minimal confirmation lifetime in seconds. + * @notice Minimal confirmation expiry in seconds. */ - uint256 public constant MIN_CONFIRM_LIFETIME = 1 days; + uint256 public constant MIN_CONFIRM_EXPIRY = 1 days; /** - * @notice Maximal confirmation lifetime in seconds. + * @notice Maximal confirmation expiry in seconds. */ - uint256 public constant MAX_CONFIRM_LIFETIME = 30 days; + uint256 public constant MAX_CONFIRM_EXPIRY = 30 days; /** - * @notice Confirmation lifetime in seconds; after this period, the confirmation expires and no longer counts. + * @notice Confirmation expiry in seconds; after this period, the confirmation expires and no longer counts. * @dev We cannot set this to 0 because this means that all confirmations have to be in the same block, - * which can never be guaranteed. And, more importantly, if the `_setLifetime` is restricted by - * the `onlyConfirmed` modifier, the confirmation lifetime will be tricky to change. + * which can never be guaranteed. And, more importantly, if the `_setConfirmExpiry` is restricted by + * the `onlyConfirmed` modifier, the confirmation expiry will be tricky to change. * This is why this variable is private, set to a default value of 1 day and cannot be set to 0. */ - uint256 private confirmLifetime = MIN_CONFIRM_LIFETIME; + uint256 private confirmExpiry = MIN_CONFIRM_EXPIRY; /** - * @notice Returns the confirmation lifetime. - * @return The confirmation lifetime in seconds. + * @notice Returns the confirmation expiry. + * @return The confirmation expiry in seconds. */ - function getConfirmLifetime() public view returns (uint256) { - return confirmLifetime; + function getConfirmExpiry() public view returns (uint256) { + return confirmExpiry; } /** @@ -60,7 +60,7 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { * * 2. Confirmation counting: * - Counts the current caller's confirmations if they're a member of any of the specified roles - * - Counts existing confirmations that are not expired, i.e. lifetime is not exceeded + * - Counts existing confirmations that are not expired, i.e. expiry is not exceeded * * 3. Execution: * - If all members of the specified roles have confirmed, executes the function @@ -78,7 +78,7 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { * * @param _roles Array of role identifiers that must confirm the call in order to execute it * - * @notice Confirmations past their lifetime are not counted and must be recast + * @notice Confirmations past their expiry are not counted and must be recast * @notice Only members of the specified roles can submit confirmations * @notice The order of confirmations does not matter * @@ -90,7 +90,7 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { uint256 numberOfConfirms = 0; bool[] memory deferredConfirms = new bool[](numberOfRoles); bool isRoleMember = false; - uint256 expiryTimestamp = block.timestamp + confirmLifetime; + uint256 expiryTimestamp = block.timestamp + confirmExpiry; for (uint256 i = 0; i < numberOfRoles; ++i) { bytes32 role = _roles[i]; @@ -125,28 +125,28 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { } /** - * @dev Sets the confirmation lifetime. - * Confirmation lifetime is a period during which the confirmation is counted. Once expired, + * @dev Sets the confirmation expiry. + * Confirmation expiry is a period during which the confirmation is counted. Once expired, * the confirmation no longer counts and must be recasted for the confirmation to go through. * @dev Does not retroactively apply to existing confirmations. - * @param _newConfirmLifetime The new confirmation lifetime in seconds. + * @param _newConfirmExpiry The new confirmation expiry in seconds. */ - function _setConfirmLifetime(uint256 _newConfirmLifetime) internal { - if (_newConfirmLifetime < MIN_CONFIRM_LIFETIME || _newConfirmLifetime > MAX_CONFIRM_LIFETIME) - revert ConfirmLifetimeOutOfBounds(); + function _setConfirmExpiry(uint256 _newConfirmExpiry) internal { + if (_newConfirmExpiry < MIN_CONFIRM_EXPIRY || _newConfirmExpiry > MAX_CONFIRM_EXPIRY) + revert ConfirmExpiryOutOfBounds(); - uint256 oldConfirmLifetime = confirmLifetime; - confirmLifetime = _newConfirmLifetime; + uint256 oldConfirmExpiry = confirmExpiry; + confirmExpiry = _newConfirmExpiry; - emit ConfirmLifetimeSet(msg.sender, oldConfirmLifetime, _newConfirmLifetime); + emit ConfirmExpirySet(msg.sender, oldConfirmExpiry, _newConfirmExpiry); } /** - * @dev Emitted when the confirmation lifetime is set. - * @param oldConfirmLifetime The old confirmation lifetime. - * @param newConfirmLifetime The new confirmation lifetime. + * @dev Emitted when the confirmation expiry is set. + * @param oldConfirmExpiry The old confirmation expiry. + * @param newConfirmExpiry The new confirmation expiry. */ - event ConfirmLifetimeSet(address indexed sender, uint256 oldConfirmLifetime, uint256 newConfirmLifetime); + event ConfirmExpirySet(address indexed sender, uint256 oldConfirmExpiry, uint256 newConfirmExpiry); /** * @dev Emitted when a role member confirms. @@ -158,9 +158,9 @@ abstract contract AccessControlConfirmable is AccessControlEnumerable { event RoleMemberConfirmed(address indexed member, bytes32 indexed role, uint256 expiryTimestamp, bytes data); /** - * @dev Thrown when attempting to set confirmation lifetime out of bounds. + * @dev Thrown when attempting to set confirmation expiry out of bounds. */ - error ConfirmLifetimeOutOfBounds(); + error ConfirmExpiryOutOfBounds(); /** * @dev Thrown when a caller without a required role attempts to confirm. diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 6a1f50365..2ee30f7d1 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -89,14 +89,14 @@ contract Dashboard is Permissions { /** * @notice Initializes the contract * @param _defaultAdmin Address of the default admin - * @param _confirmLifetime Confirm lifetime in seconds + * @param _confirmExpiry Confirm expiry in seconds */ - function initialize(address _defaultAdmin, uint256 _confirmLifetime) external virtual { + function initialize(address _defaultAdmin, uint256 _confirmExpiry) external virtual { // reduces gas cost for `mintWsteth` // invariant: dashboard does not hold stETH on its balance STETH.approve(address(WSTETH), type(uint256).max); - _initialize(_defaultAdmin, _confirmLifetime); + _initialize(_defaultAdmin, _confirmExpiry); } // ==================== View Functions ==================== diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index 877c574b2..18884561d 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -31,7 +31,7 @@ contract Delegation is Dashboard { /** * @notice Node operator manager role: - * - confirms confirm lifetime; + * - confirms confirm expiry; * - confirms ownership transfer; * - assigns NODE_OPERATOR_FEE_CONFIRM_ROLE; * - assigns NODE_OPERATOR_FEE_CLAIM_ROLE. @@ -76,13 +76,13 @@ contract Delegation is Dashboard { /** * @notice Initializes the contract: * - sets up the roles; - * - sets the confirm lifetime to 7 days (can be changed later by DEFAULT_ADMIN_ROLE and NODE_OPERATOR_MANAGER_ROLE). + * - sets the confirm expiry to 7 days (can be changed later by DEFAULT_ADMIN_ROLE and NODE_OPERATOR_MANAGER_ROLE). * @dev The msg.sender here is VaultFactory. The VaultFactory is temporarily granted * DEFAULT_ADMIN_ROLE AND NODE_OPERATOR_MANAGER_ROLE to be able to set initial fees and roles in VaultFactory. * All the roles are revoked from VaultFactory by the end of the initialization. */ - function initialize(address _defaultAdmin, uint256 _confirmLifetime) external override { - _initialize(_defaultAdmin, _confirmLifetime); + function initialize(address _defaultAdmin, uint256 _confirmExpiry) external override { + _initialize(_defaultAdmin, _confirmExpiry); // the next line implies that the msg.sender is an operator // however, the msg.sender is the VaultFactory, and the role will be revoked @@ -146,13 +146,13 @@ contract Delegation is Dashboard { } /** - * @notice Sets the confirm lifetime. - * Confirm lifetime is a period during which the confirm is counted. Once the period is over, + * @notice Sets the confirm expiry. + * Confirm expiry is a period during which the confirm is counted. Once the period is over, * the confirm is considered expired, no longer counts and must be recasted. - * @param _newConfirmLifetime The new confirm lifetime in seconds. + * @param _newConfirmExpiry The new confirm expiry in seconds. */ - function setConfirmLifetime(uint256 _newConfirmLifetime) external onlyConfirmed(_confirmingRoles()) { - _setConfirmLifetime(_newConfirmLifetime); + function setConfirmExpiry(uint256 _newConfirmExpiry) external onlyConfirmed(_confirmingRoles()) { + _setConfirmExpiry(_newConfirmExpiry); } /** @@ -253,7 +253,7 @@ contract Delegation is Dashboard { /** * @notice Returns the roles that can: - * - change the confirm lifetime; + * - change the confirm expiry; * - set the curator fee; * - set the node operator fee; * - transfer the ownership of the StakingVault. diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index 51b9f767b..e55604359 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -90,7 +90,7 @@ abstract contract Permissions is AccessControlConfirmable { _SELF = address(this); } - function _initialize(address _defaultAdmin, uint256 _confirmLifetime) internal { + function _initialize(address _defaultAdmin, uint256 _confirmExpiry) internal { if (initialized) revert AlreadyInitialized(); if (address(this) == _SELF) revert NonProxyCallsForbidden(); if (_defaultAdmin == address(0)) revert ZeroArgument("_defaultAdmin"); @@ -99,7 +99,7 @@ abstract contract Permissions is AccessControlConfirmable { vaultHub = VaultHub(stakingVault().vaultHub()); _grantRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); - _setConfirmLifetime(_confirmLifetime); + _setConfirmExpiry(_confirmExpiry); emit Initialized(_defaultAdmin); } diff --git a/contracts/0.8.25/vaults/VaultFactory.sol b/contracts/0.8.25/vaults/VaultFactory.sol index 7198b2d82..3a6509931 100644 --- a/contracts/0.8.25/vaults/VaultFactory.sol +++ b/contracts/0.8.25/vaults/VaultFactory.sol @@ -13,7 +13,7 @@ import {Delegation} from "./Delegation.sol"; struct DelegationConfig { address defaultAdmin; address nodeOperatorManager; - uint256 confirmLifetime; + uint256 confirmExpiry; uint16 curatorFeeBP; uint16 nodeOperatorFeeBP; address[] funders; @@ -66,7 +66,7 @@ contract VaultFactory { ); // initialize Delegation - delegation.initialize(address(this), _delegationConfig.confirmLifetime); + delegation.initialize(address(this), _delegationConfig.confirmExpiry); // setup roles from config // basic permissions to the staking vault diff --git a/test/0.8.25/utils/access-control-confirmable.test.ts b/test/0.8.25/utils/access-control-confirmable.test.ts index 7b0e2357d..3a97c8ccd 100644 --- a/test/0.8.25/utils/access-control-confirmable.test.ts +++ b/test/0.8.25/utils/access-control-confirmable.test.ts @@ -18,7 +18,7 @@ describe("AccessControlConfirmable.sol", () => { [admin, stranger, role1Member, role2Member] = await ethers.getSigners(); harness = await ethers.deployContract("AccessControlConfirmable__Harness", [admin], admin); - expect(await harness.getConfirmLifetime()).to.equal(await harness.MIN_CONFIRM_LIFETIME()); + expect(await harness.getConfirmExpiry()).to.equal(await harness.MIN_CONFIRM_EXPIRY()); expect(await harness.hasRole(await harness.DEFAULT_ADMIN_ROLE(), admin)).to.be.true; expect(await harness.getRoleMemberCount(await harness.DEFAULT_ADMIN_ROLE())).to.equal(1); @@ -33,14 +33,14 @@ describe("AccessControlConfirmable.sol", () => { context("constants", () => { it("returns the correct constants", async () => { - expect(await harness.MIN_CONFIRM_LIFETIME()).to.equal(days(1n)); - expect(await harness.MAX_CONFIRM_LIFETIME()).to.equal(days(30n)); + expect(await harness.MIN_CONFIRM_EXPIRY()).to.equal(days(1n)); + expect(await harness.MAX_CONFIRM_EXPIRY()).to.equal(days(30n)); }); }); - context("getConfirmLifetime()", () => { - it("returns the minimal lifetime initially", async () => { - expect(await harness.getConfirmLifetime()).to.equal(await harness.MIN_CONFIRM_LIFETIME()); + context("getConfirmExpiry()", () => { + it("returns the minimal expiry initially", async () => { + expect(await harness.getConfirmExpiry()).to.equal(await harness.MIN_CONFIRM_EXPIRY()); }); }); @@ -50,24 +50,26 @@ describe("AccessControlConfirmable.sol", () => { }); }); - context("setConfirmLifetime()", () => { - it("sets the confirm lifetime", async () => { - const oldLifetime = await harness.getConfirmLifetime(); - const newLifetime = days(14n); - await expect(harness.setConfirmLifetime(newLifetime)) - .to.emit(harness, "ConfirmLifetimeSet") - .withArgs(admin, oldLifetime, newLifetime); - expect(await harness.getConfirmLifetime()).to.equal(newLifetime); + context("setConfirmExpiry()", () => { + it("sets the confirm expiry", async () => { + const oldExpiry = await harness.getConfirmExpiry(); + const newExpiry = days(14n); + await expect(harness.setConfirmExpiry(newExpiry)) + .to.emit(harness, "ConfirmExpirySet") + .withArgs(admin, oldExpiry, newExpiry); + expect(await harness.getConfirmExpiry()).to.equal(newExpiry); }); - it("reverts if the new lifetime is out of bounds", async () => { - await expect( - harness.setConfirmLifetime((await harness.MIN_CONFIRM_LIFETIME()) - 1n), - ).to.be.revertedWithCustomError(harness, "ConfirmLifetimeOutOfBounds"); + it("reverts if the new expiry is out of bounds", async () => { + await expect(harness.setConfirmExpiry((await harness.MIN_CONFIRM_EXPIRY()) - 1n)).to.be.revertedWithCustomError( + harness, + "ConfirmExpiryOutOfBounds", + ); - await expect( - harness.setConfirmLifetime((await harness.MAX_CONFIRM_LIFETIME()) + 1n), - ).to.be.revertedWithCustomError(harness, "ConfirmLifetimeOutOfBounds"); + await expect(harness.setConfirmExpiry((await harness.MAX_CONFIRM_EXPIRY()) + 1n)).to.be.revertedWithCustomError( + harness, + "ConfirmExpiryOutOfBounds", + ); }); }); @@ -94,7 +96,7 @@ describe("AccessControlConfirmable.sol", () => { it("doesn't execute if the confirmation has expired", async () => { const oldNumber = await harness.number(); const newNumber = 1; - const expiryTimestamp = (await getNextBlockTimestamp()) + (await harness.getConfirmLifetime()); + const expiryTimestamp = (await getNextBlockTimestamp()) + (await harness.getConfirmExpiry()); const msgData = harness.interface.encodeFunctionData("setNumber", [newNumber]); await expect(harness.connect(role1Member).setNumber(newNumber)) @@ -106,7 +108,7 @@ describe("AccessControlConfirmable.sol", () => { await advanceChainTime(expiryTimestamp + 1n); - const newExpiryTimestamp = (await getNextBlockTimestamp()) + (await harness.getConfirmLifetime()); + const newExpiryTimestamp = (await getNextBlockTimestamp()) + (await harness.getConfirmExpiry()); await expect(harness.connect(role2Member).setNumber(newNumber)) .to.emit(harness, "RoleMemberConfirmed") .withArgs(role2Member, await harness.ROLE_2(), newExpiryTimestamp, msgData); diff --git a/test/0.8.25/utils/contracts/AccessControlConfirmable__Harness.sol b/test/0.8.25/utils/contracts/AccessControlConfirmable__Harness.sol index 3a37e5988..459ab5d44 100644 --- a/test/0.8.25/utils/contracts/AccessControlConfirmable__Harness.sol +++ b/test/0.8.25/utils/contracts/AccessControlConfirmable__Harness.sol @@ -22,8 +22,8 @@ contract AccessControlConfirmable__Harness is AccessControlConfirmable { return roles; } - function setConfirmLifetime(uint256 _confirmLifetime) external { - _setConfirmLifetime(_confirmLifetime); + function setConfirmExpiry(uint256 _confirmExpiry) external { + _setConfirmExpiry(_confirmExpiry); } function setNumber(uint256 _number) external onlyConfirmed(confirmingRoles()) { diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 3b7eaad4e..25cf13c3d 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -45,7 +45,7 @@ describe("Dashboard.sol", () => { let dashboard: Dashboard; let dashboardAddress: string; - const confirmLifetime = days(7n); + const confirmExpiry = days(7n); let originalState: string; @@ -127,7 +127,7 @@ describe("Dashboard.sol", () => { context("initialize", () => { it("reverts if already initialized", async () => { - await expect(dashboard.initialize(vaultOwner, confirmLifetime)).to.be.revertedWithCustomError( + await expect(dashboard.initialize(vaultOwner, confirmExpiry)).to.be.revertedWithCustomError( dashboard, "AlreadyInitialized", ); @@ -136,7 +136,7 @@ describe("Dashboard.sol", () => { it("reverts if called on the implementation", async () => { const dashboard_ = await ethers.deployContract("Dashboard", [weth, lidoLocator]); - await expect(dashboard_.initialize(vaultOwner, confirmLifetime)).to.be.revertedWithCustomError( + await expect(dashboard_.initialize(vaultOwner, confirmExpiry)).to.be.revertedWithCustomError( dashboard_, "NonProxyCallsForbidden", ); diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index 262ac660e..158453916 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -109,7 +109,7 @@ describe("Delegation.sol", () => { { defaultAdmin: vaultOwner, nodeOperatorManager, - confirmLifetime: days(7n), + confirmExpiry: days(7n), curatorFeeBP: 0n, nodeOperatorFeeBP: 0n, funders: [funder], @@ -235,32 +235,32 @@ describe("Delegation.sol", () => { }); }); - context("setConfirmLifetime", () => { - it("reverts if the caller is not a member of the confirm lifetime committee", async () => { - await expect(delegation.connect(stranger).setConfirmLifetime(days(10n))).to.be.revertedWithCustomError( + context("setConfirmExpiry", () => { + it("reverts if the caller is not a member of the confirm expiry committee", async () => { + await expect(delegation.connect(stranger).setConfirmExpiry(days(10n))).to.be.revertedWithCustomError( delegation, "SenderNotMember", ); }); - it("sets the new confirm lifetime", async () => { - const oldConfirmLifetime = await delegation.getConfirmLifetime(); - const newConfirmLifetime = days(10n); - const msgData = delegation.interface.encodeFunctionData("setConfirmLifetime", [newConfirmLifetime]); - let confirmTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); + it("sets the new confirm expiry", async () => { + const oldConfirmExpiry = await delegation.getConfirmExpiry(); + const newConfirmExpiry = days(10n); + const msgData = delegation.interface.encodeFunctionData("setConfirmExpiry", [newConfirmExpiry]); + let confirmTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmExpiry()); - await expect(delegation.connect(vaultOwner).setConfirmLifetime(newConfirmLifetime)) + await expect(delegation.connect(vaultOwner).setConfirmExpiry(newConfirmExpiry)) .to.emit(delegation, "RoleMemberConfirmed") .withArgs(vaultOwner, await delegation.DEFAULT_ADMIN_ROLE(), confirmTimestamp, msgData); - confirmTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); - await expect(delegation.connect(nodeOperatorManager).setConfirmLifetime(newConfirmLifetime)) + confirmTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmExpiry()); + await expect(delegation.connect(nodeOperatorManager).setConfirmExpiry(newConfirmExpiry)) .to.emit(delegation, "RoleMemberConfirmed") .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), confirmTimestamp, msgData) - .and.to.emit(delegation, "ConfirmLifetimeSet") - .withArgs(nodeOperatorManager, oldConfirmLifetime, newConfirmLifetime); + .and.to.emit(delegation, "ConfirmExpirySet") + .withArgs(nodeOperatorManager, oldConfirmExpiry, newConfirmExpiry); - expect(await delegation.getConfirmLifetime()).to.equal(newConfirmLifetime); + expect(await delegation.getConfirmExpiry()).to.equal(newConfirmExpiry); }); }); @@ -629,7 +629,7 @@ describe("Delegation.sol", () => { it("requires both default admin and operator manager to set the operator fee and emits the RoleMemberConfirmed event", async () => { const previousOperatorFee = await delegation.nodeOperatorFeeBP(); const newOperatorFee = 1000n; - let expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); + let expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmExpiry()); const msgData = delegation.interface.encodeFunctionData("setNodeOperatorFeeBP", [newOperatorFee]); await expect(delegation.connect(vaultOwner).setNodeOperatorFeeBP(newOperatorFee)) @@ -640,7 +640,7 @@ describe("Delegation.sol", () => { // check confirm expect(await delegation.confirmations(msgData, await delegation.DEFAULT_ADMIN_ROLE())).to.equal(expiryTimestamp); - expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); + expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmExpiry()); await expect(delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(newOperatorFee)) .to.emit(delegation, "RoleMemberConfirmed") .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), expiryTimestamp, msgData) @@ -667,7 +667,7 @@ describe("Delegation.sol", () => { const previousOperatorFee = await delegation.nodeOperatorFeeBP(); const newOperatorFee = 1000n; const msgData = delegation.interface.encodeFunctionData("setNodeOperatorFeeBP", [newOperatorFee]); - let expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); + let expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmExpiry()); await expect(delegation.connect(vaultOwner).setNodeOperatorFeeBP(newOperatorFee)) .to.emit(delegation, "RoleMemberConfirmed") @@ -679,7 +679,7 @@ describe("Delegation.sol", () => { // move time forward await advanceChainTime(days(7n) + 1n); - const expectedExpiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); + const expectedExpiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmExpiry()); expect(expectedExpiryTimestamp).to.be.greaterThan(expiryTimestamp + days(7n)); await expect(delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(newOperatorFee)) .to.emit(delegation, "RoleMemberConfirmed") @@ -693,7 +693,7 @@ describe("Delegation.sol", () => { ); // curator has to confirm again - expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); + expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmExpiry()); await expect(delegation.connect(vaultOwner).setNodeOperatorFeeBP(newOperatorFee)) .to.emit(delegation, "RoleMemberConfirmed") .withArgs(vaultOwner, await delegation.DEFAULT_ADMIN_ROLE(), expiryTimestamp, msgData) @@ -715,14 +715,14 @@ describe("Delegation.sol", () => { it("requires both curator and operator to transfer ownership and emits the RoleMemberConfirmd event", async () => { const newOwner = certainAddress("newOwner"); const msgData = delegation.interface.encodeFunctionData("transferStakingVaultOwnership", [newOwner]); - let expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); + let expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmExpiry()); await expect(delegation.connect(vaultOwner).transferStakingVaultOwnership(newOwner)) .to.emit(delegation, "RoleMemberConfirmed") .withArgs(vaultOwner, await delegation.DEFAULT_ADMIN_ROLE(), expiryTimestamp, msgData); // owner is unchanged expect(await vault.owner()).to.equal(delegation); - expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmLifetime()); + expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmExpiry()); await expect(delegation.connect(nodeOperatorManager).transferStakingVaultOwnership(newOwner)) .to.emit(delegation, "RoleMemberConfirmed") .withArgs(nodeOperatorManager, await delegation.NODE_OPERATOR_MANAGER_ROLE(), expiryTimestamp, msgData); diff --git a/test/0.8.25/vaults/permissions/contracts/Permissions__Harness.sol b/test/0.8.25/vaults/permissions/contracts/Permissions__Harness.sol index 390097bfb..a2cad94e1 100644 --- a/test/0.8.25/vaults/permissions/contracts/Permissions__Harness.sol +++ b/test/0.8.25/vaults/permissions/contracts/Permissions__Harness.sol @@ -6,13 +6,13 @@ pragma solidity ^0.8.0; import {Permissions} from "contracts/0.8.25/vaults/Permissions.sol"; contract Permissions__Harness is Permissions { - function initialize(address _defaultAdmin, uint256 _confirmLifetime) external { - _initialize(_defaultAdmin, _confirmLifetime); + function initialize(address _defaultAdmin, uint256 _confirmExpiry) external { + _initialize(_defaultAdmin, _confirmExpiry); } - function revertDoubleInitialize(address _defaultAdmin, uint256 _confirmLifetime) external { - _initialize(_defaultAdmin, _confirmLifetime); - _initialize(_defaultAdmin, _confirmLifetime); + function revertDoubleInitialize(address _defaultAdmin, uint256 _confirmExpiry) external { + _initialize(_defaultAdmin, _confirmExpiry); + _initialize(_defaultAdmin, _confirmExpiry); } function confirmingRoles() external pure returns (bytes32[] memory) { @@ -59,7 +59,7 @@ contract Permissions__Harness is Permissions { _transferStakingVaultOwnership(_newOwner); } - function setConfirmLifetime(uint256 _newConfirmLifetime) external { - _setConfirmLifetime(_newConfirmLifetime); + function setConfirmExpiry(uint256 _newConfirmExpiry) external { + _setConfirmExpiry(_newConfirmExpiry); } } diff --git a/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions.sol b/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions.sol index 61371970d..fe0994484 100644 --- a/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions.sol +++ b/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions.sol @@ -12,7 +12,7 @@ import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.so struct PermissionsConfig { address defaultAdmin; address nodeOperator; - uint256 confirmLifetime; + uint256 confirmExpiry; address funder; address withdrawer; address minter; @@ -56,7 +56,7 @@ contract VaultFactory__MockPermissions { vault.initialize(address(permissions), _permissionsConfig.nodeOperator, _stakingVaultInitializerExtraParams); // initialize Permissions - permissions.initialize(address(this), _permissionsConfig.confirmLifetime); + permissions.initialize(address(this), _permissionsConfig.confirmExpiry); // setup roles permissions.grantRole(permissions.DEFAULT_ADMIN_ROLE(), _permissionsConfig.defaultAdmin); @@ -91,9 +91,9 @@ contract VaultFactory__MockPermissions { vault.initialize(address(permissions), _permissionsConfig.nodeOperator, _stakingVaultInitializerExtraParams); // initialize Permissions - permissions.initialize(address(this), _permissionsConfig.confirmLifetime); + permissions.initialize(address(this), _permissionsConfig.confirmExpiry); // should revert here - permissions.initialize(address(this), _permissionsConfig.confirmLifetime); + permissions.initialize(address(this), _permissionsConfig.confirmExpiry); // setup roles permissions.grantRole(permissions.DEFAULT_ADMIN_ROLE(), _permissionsConfig.defaultAdmin); @@ -128,7 +128,7 @@ contract VaultFactory__MockPermissions { vault.initialize(address(permissions), _permissionsConfig.nodeOperator, _stakingVaultInitializerExtraParams); // should revert here - permissions.initialize(address(0), _permissionsConfig.confirmLifetime); + permissions.initialize(address(0), _permissionsConfig.confirmExpiry); // setup roles permissions.grantRole(permissions.DEFAULT_ADMIN_ROLE(), _permissionsConfig.defaultAdmin); diff --git a/test/0.8.25/vaults/permissions/permissions.test.ts b/test/0.8.25/vaults/permissions/permissions.test.ts index 84cd5909e..2aa692985 100644 --- a/test/0.8.25/vaults/permissions/permissions.test.ts +++ b/test/0.8.25/vaults/permissions/permissions.test.ts @@ -87,7 +87,7 @@ describe("Permissions", () => { { defaultAdmin, nodeOperator, - confirmLifetime: days(7n), + confirmExpiry: days(7n), funder, withdrawer, minter, @@ -156,7 +156,7 @@ describe("Permissions", () => { { defaultAdmin, nodeOperator, - confirmLifetime: days(7n), + confirmExpiry: days(7n), funder, withdrawer, minter, @@ -186,7 +186,7 @@ describe("Permissions", () => { { defaultAdmin, nodeOperator, - confirmLifetime: days(7n), + confirmExpiry: days(7n), funder, withdrawer, minter, diff --git a/test/0.8.25/vaults/vaultFactory.test.ts b/test/0.8.25/vaults/vaultFactory.test.ts index ea356854a..626f0a5bd 100644 --- a/test/0.8.25/vaults/vaultFactory.test.ts +++ b/test/0.8.25/vaults/vaultFactory.test.ts @@ -110,7 +110,7 @@ describe("VaultFactory.sol", () => { delegationParams = { defaultAdmin: await admin.getAddress(), nodeOperatorManager: await operator.getAddress(), - confirmLifetime: days(7n), + confirmExpiry: days(7n), curatorFeeBP: 100n, nodeOperatorFeeBP: 200n, funders: [await vaultOwner1.getAddress()], diff --git a/test/integration/vaults-happy-path.integration.ts b/test/integration/vaults-happy-path.integration.ts index 7e7bf69b7..6f1c82bc9 100644 --- a/test/integration/vaults-happy-path.integration.ts +++ b/test/integration/vaults-happy-path.integration.ts @@ -159,7 +159,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { nodeOperatorManager: nodeOperator, curatorFeeBP: VAULT_OWNER_FEE, nodeOperatorFeeBP: VAULT_NODE_OPERATOR_FEE, - confirmLifetime: days(7n), + confirmExpiry: days(7n), funders: [curator], withdrawers: [curator], minters: [curator], From 2a004a17c0eee8703e4e78499ba39e73e76ba328 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 19 Feb 2025 19:35:49 +0500 Subject: [PATCH 144/184] fix(VaultHub): rename mint/burn --- contracts/0.8.25/vaults/Permissions.sol | 4 ++-- contracts/0.8.25/vaults/VaultHub.sol | 8 ++++---- .../vaults/contracts/VaultHub__MockForVault.sol | 4 ++-- .../contracts/VaultHub__MockForDashboard.sol | 4 ++-- .../contracts/VaultHub__MockForDelegation.sol | 4 ++-- .../contracts/VaultHub__MockPermissions.sol | 4 ++-- .../vaults/vaulthub/vaulthub.pausable.test.ts | 15 ++++++--------- 7 files changed, 20 insertions(+), 23 deletions(-) diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index e55604359..aceaec766 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -181,7 +181,7 @@ abstract contract Permissions is AccessControlConfirmable { * @dev The zero checks for parameters are performed in the VaultHub contract. */ function _mintShares(address _recipient, uint256 _shares) internal onlyRole(MINT_ROLE) { - vaultHub.mintSharesBackedByVault(address(stakingVault()), _recipient, _shares); + vaultHub.mintShares(address(stakingVault()), _recipient, _shares); } /** @@ -190,7 +190,7 @@ abstract contract Permissions is AccessControlConfirmable { * @dev The zero check for parameters is performed in the VaultHub contract. */ function _burnShares(uint256 _shares) internal onlyRole(BURN_ROLE) { - vaultHub.burnSharesBackedByVault(address(stakingVault()), _shares); + vaultHub.burnShares(address(stakingVault()), _shares); } /** diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 3c8d10b47..d09516adb 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -213,7 +213,7 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @param _recipient address of the receiver /// @param _amountOfShares amount of stETH shares to mint /// @dev msg.sender should be vault's owner - function mintSharesBackedByVault(address _vault, address _recipient, uint256 _amountOfShares) external whenResumed { + function mintShares(address _vault, address _recipient, uint256 _amountOfShares) external whenResumed { if (_vault == address(0)) revert ZeroArgument("_vault"); if (_recipient == address(0)) revert ZeroArgument("_recipient"); if (_amountOfShares == 0) revert ZeroArgument("_amountOfShares"); @@ -252,7 +252,7 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @param _amountOfShares amount of shares to burn /// @dev msg.sender should be vault's owner /// @dev VaultHub must have all the stETH on its balance - function burnSharesBackedByVault(address _vault, uint256 _amountOfShares) public whenResumed { + function burnShares(address _vault, uint256 _amountOfShares) public whenResumed { if (_vault == address(0)) revert ZeroArgument("_vault"); if (_amountOfShares == 0) revert ZeroArgument("_amountOfShares"); _vaultAuth(_vault, "burn"); @@ -271,10 +271,10 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @notice separate burn function for EOA vault owners; requires vaultHub to be approved to transfer stETH /// @dev msg.sender should be vault's owner - function transferAndBurnSharesBackedByVault(address _vault, uint256 _amountOfShares) external { + function transferAndBurnShares(address _vault, uint256 _amountOfShares) external { STETH.transferSharesFrom(msg.sender, address(this), _amountOfShares); - burnSharesBackedByVault(_vault, _amountOfShares); + burnShares(_vault, _amountOfShares); } /// @notice force rebalance of the vault to have sufficient reserve ratio diff --git a/test/0.8.25/vaults/contracts/VaultHub__MockForVault.sol b/test/0.8.25/vaults/contracts/VaultHub__MockForVault.sol index 430e52de7..4daf8c990 100644 --- a/test/0.8.25/vaults/contracts/VaultHub__MockForVault.sol +++ b/test/0.8.25/vaults/contracts/VaultHub__MockForVault.sol @@ -4,9 +4,9 @@ pragma solidity 0.8.25; contract VaultHub__MockForVault { - function mintSharesBackedByVault(address _recipient, uint256 _amountOfShares) external returns (uint256 locked) {} + function mintShares(address _recipient, uint256 _amountOfShares) external returns (uint256 locked) {} - function burnSharesBackedByVault(uint256 _amountOfShares) external {} + function burnShares(uint256 _amountOfShares) external {} function rebalance() external payable {} } 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..7e7d02ed8 100644 --- a/test/0.8.25/vaults/dashboard/contracts/VaultHub__MockForDashboard.sol +++ b/test/0.8.25/vaults/dashboard/contracts/VaultHub__MockForDashboard.sol @@ -41,7 +41,7 @@ contract VaultHub__MockForDashboard { emit Mock__VaultDisconnected(vault); } - function mintSharesBackedByVault(address vault, address recipient, uint256 amount) external { + function mintShares(address vault, address recipient, uint256 amount) external { if (vault == address(0)) revert ZeroArgument("_vault"); if (recipient == address(0)) revert ZeroArgument("recipient"); if (amount == 0) revert ZeroArgument("amount"); @@ -50,7 +50,7 @@ contract VaultHub__MockForDashboard { vaultSockets[vault].sharesMinted = uint96(vaultSockets[vault].sharesMinted + amount); } - function burnSharesBackedByVault(address _vault, uint256 _amountOfShares) external { + function burnShares(address _vault, uint256 _amountOfShares) external { if (_vault == address(0)) revert ZeroArgument("_vault"); if (_amountOfShares == 0) revert ZeroArgument("_amountOfShares"); steth.burnExternalShares(_amountOfShares); 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..5108f8b8e 100644 --- a/test/0.8.25/vaults/delegation/contracts/VaultHub__MockForDelegation.sol +++ b/test/0.8.25/vaults/delegation/contracts/VaultHub__MockForDelegation.sol @@ -20,11 +20,11 @@ contract VaultHub__MockForDelegation { emit Mock__VaultDisconnected(vault); } - function mintSharesBackedByVault(address /* vault */, address recipient, uint256 amount) external { + function mintShares(address /* vault */, address recipient, uint256 amount) external { steth.mint(recipient, amount); } - function burnSharesBackedByVault(address /* vault */, uint256 amount) external { + function burnShares(address /* vault */, uint256 amount) external { steth.burn(amount); } diff --git a/test/0.8.25/vaults/permissions/contracts/VaultHub__MockPermissions.sol b/test/0.8.25/vaults/permissions/contracts/VaultHub__MockPermissions.sol index 0322752b0..6ee7437ef 100644 --- a/test/0.8.25/vaults/permissions/contracts/VaultHub__MockPermissions.sol +++ b/test/0.8.25/vaults/permissions/contracts/VaultHub__MockPermissions.sol @@ -9,11 +9,11 @@ contract VaultHub__MockPermissions { event Mock__Rebalanced(uint256 _ether); event Mock__VoluntaryDisconnect(address indexed _stakingVault); - function mintSharesBackedByVault(address _stakingVault, address _recipient, uint256 _shares) external { + function mintShares(address _stakingVault, address _recipient, uint256 _shares) external { emit Mock__SharesMinted(_stakingVault, _recipient, _shares); } - function burnSharesBackedByVault(address _stakingVault, uint256 _shares) external { + function burnShares(address _stakingVault, uint256 _shares) external { emit Mock__SharesBurned(_stakingVault, _shares); } diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts index feb145fa0..d8f3ba6f4 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts @@ -157,28 +157,25 @@ describe("VaultHub.sol:pausableUntil", () => { await expect(vaultHub.voluntaryDisconnect(user)).to.be.revertedWithCustomError(vaultHub, "ResumedExpected"); }); - it("reverts mintSharesBackedByVault() if paused", async () => { - await expect(vaultHub.mintSharesBackedByVault(stranger, user, 1000n)).to.be.revertedWithCustomError( + it("reverts mintShares() if paused", async () => { + await expect(vaultHub.mintShares(stranger, user, 1000n)).to.be.revertedWithCustomError( vaultHub, "ResumedExpected", ); }); - it("reverts burnSharesBackedByVault() if paused", async () => { - await expect(vaultHub.burnSharesBackedByVault(stranger, 1000n)).to.be.revertedWithCustomError( - vaultHub, - "ResumedExpected", - ); + it("reverts burnShares() if paused", async () => { + await expect(vaultHub.burnShares(stranger, 1000n)).to.be.revertedWithCustomError(vaultHub, "ResumedExpected"); }); it("reverts rebalance() if paused", async () => { await expect(vaultHub.rebalance()).to.be.revertedWithCustomError(vaultHub, "ResumedExpected"); }); - it("reverts transferAndBurnSharesBackedByVault() if paused", async () => { + it("reverts transferAndBurnShares() if paused", async () => { await steth.connect(user).approve(vaultHub, 1000n); - await expect(vaultHub.transferAndBurnSharesBackedByVault(stranger, 1000n)).to.be.revertedWithCustomError( + await expect(vaultHub.transferAndBurnShares(stranger, 1000n)).to.be.revertedWithCustomError( vaultHub, "ResumedExpected", ); From bb5faf29b0fb6aaf28a5a16174905928372d4dd2 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 19 Feb 2025 16:25:15 +0100 Subject: [PATCH 145/184] chore: apply suggestions from code review Co-authored-by: Eugene Mamin --- contracts/0.8.25/vaults/Dashboard.sol | 2 +- contracts/0.8.25/vaults/StakingVault.sol | 6 +++--- contracts/0.8.25/vaults/VaultHub.sol | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index e7934f04a..049b26d43 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -421,7 +421,7 @@ contract Dashboard is Permissions { /** * @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 + * directly trigger the exit - node operators must monitor for request events and handle the exits * @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 diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index d82115c1e..c24e9dc07 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -361,7 +361,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { } /** - * @notice Returns whether deposits are paused by the vault owner + * @notice Returns whether deposits are paused * @return True if deposits are paused */ function beaconChainDepositsPaused() external view returns (bool) { @@ -401,7 +401,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` valuation is not less than locked before making deposits */ function depositToBeaconChain(Deposit[] calldata _deposits) external { if (_deposits.length == 0) revert ZeroArgument("_deposits"); @@ -442,7 +442,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @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 + * It does not directly trigger exits - node operators must monitor for these events and handle the exits * @param _pubkeys Concatenated validator public keys, each 48 bytes long */ function requestValidatorExit(bytes calldata _pubkeys) external onlyOwner { diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index ccd48f47d..292e15a67 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -382,7 +382,6 @@ abstract contract VaultHub is PausableUntilWithRoles { emit VaultRebalanced(msg.sender, sharesToBurn); } - /// THIS IS A LAST RESORT MECHANISM, THAT SHOULD BE AVOIDED BY THE VAULT OPERATORS AT ALL COSTS /// In case of the unbalanced vault, ANYONE can force any validator belonging to the vault to withdraw from the /// beacon chain to get all the vault deposited ETH back to the vault balance and rebalance the vault /// @notice forces validator withdrawal from the beacon chain in case the vault is unbalanced From c6640ba7f2b35b4d6c1255e5e8423c706ae8a1ef Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 19 Feb 2025 15:41:20 +0000 Subject: [PATCH 146/184] chore: apply suggestions from code review --- contracts/0.8.25/vaults/Dashboard.sol | 28 +++++++++---------- contracts/0.8.25/vaults/StakingVault.sol | 27 +++++++++--------- .../0.8.25/vaults/dashboard/dashboard.test.ts | 4 +-- .../vaults/staking-vault/stakingVault.test.ts | 14 +++++----- 4 files changed, 36 insertions(+), 37 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 049b26d43..96ba5660c 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -406,14 +406,14 @@ 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(); @@ -421,10 +421,10 @@ contract Dashboard is Permissions { /** * @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 - * @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 + * directly trigger the exit - node operators must monitor for request events and handle the exits. + * @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); @@ -432,14 +432,14 @@ contract Dashboard is Permissions { /** * @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 + * 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 c24e9dc07..1fd87b3ca 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. * @@ -45,7 +45,7 @@ import {IStakingVault} from "./interfaces/IStakingVault.sol"; * - `lock()` * - `report()` * - `rebalance()` - * - `triggerValidatorWithdrawal()` (partial withdrawals are disabled for unbalanced `StakingVault`) + * - `triggerValidatorWithdrawal()` * - Anyone: * - Can send ETH directly to the vault (treated as rewards) * @@ -271,8 +271,8 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @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 a check that valuation remains greater than locked amount after withdrawal to ensure - * `StakingVault` stays balanced and prevent reentrancy attacks. + * @dev Checks that valuation remains greater than locked amount after withdrawal to maintain + * `StakingVault` health and prevent reentrancy attacks. */ function withdraw(address _recipient, uint256 _ether) external onlyOwner { if (_recipient == address(0)) revert ZeroArgument("_recipient"); @@ -310,8 +310,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, - * or by owner at any moment + * @dev Can only be called by VaultHub if StakingVault is unhealthy, or by owner at any moment * @param _ether Amount of ether to rebalance */ function rebalance(uint256 _ether) external { @@ -481,10 +480,9 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { } ERC7201Storage storage $ = _getStorage(); - - // If the vault is unbalanced, block partial withdrawals because they can front-run blocking the full exit - bool isBalanced = valuation() >= $.locked; - if (!isBalanced) { + bool isValuationBelowLocked = valuation() < $.locked; + if (isValuationBelowLocked) { + // Block partial withdrawals to prevent front-running force withdrawals for (uint256 i = 0; i < _amounts.length; i++) { if (_amounts[i] > 0) revert PartialWithdrawalNotAllowed(); } @@ -493,8 +491,9 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { bool isAuthorized = ( msg.sender == $.nodeOperator || msg.sender == owner() || - (!isBalanced && msg.sender == address(VAULT_HUB)) + (!isValuationBelowLocked && msg.sender == address(VAULT_HUB)) ); + if (!isAuthorized) revert NotAuthorized("triggerValidatorWithdrawal", msg.sender); uint256 feePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); @@ -509,7 +508,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { if (!success) revert WithdrawalFeeRefundFailed(_refundRecipient, excess); } - emit ValidatorWithdrawalRequested(msg.sender, _pubkeys, _amounts, _refundRecipient, excess); + emit ValidatorWithdrawalTriggered(msg.sender, _pubkeys, _amounts, _refundRecipient, excess); } /** @@ -640,7 +639,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 ValidatorWithdrawalRequested(address indexed _sender, bytes _pubkeys, uint64[] _amounts, address _refundRecipient, uint256 _excess); + event ValidatorWithdrawalTriggered(address indexed _sender, bytes _pubkeys, uint64[] _amounts, address _refundRecipient, uint256 _excess); /** * @notice Emitted when an excess fee is refunded back to the sender. diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index f1c5ee990..b9dc44474 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -662,7 +662,7 @@ describe("Dashboard.sol", () => { const amounts = [0n]; // 0 amount means full withdrawal await expect(dashboard.triggerValidatorWithdrawal(validatorPublicKeys, amounts, vaultOwner, { value: FEE })) - .to.emit(vault, "ValidatorWithdrawalRequested") + .to.emit(vault, "ValidatorWithdrawalTriggered") .withArgs(dashboard, validatorPublicKeys, amounts, vaultOwner, 0n); }); @@ -671,7 +671,7 @@ describe("Dashboard.sol", () => { const amounts = [ether("0.1")]; await expect(dashboard.triggerValidatorWithdrawal(validatorPublicKeys, amounts, vaultOwner, { value: FEE })) - .to.emit(vault, "ValidatorWithdrawalRequested") + .to.emit(vault, "ValidatorWithdrawalTriggered") .withArgs(dashboard, validatorPublicKeys, amounts, vaultOwner, 0n); }); }); 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 f5fbb9b59..03faa30a1 100644 --- a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts +++ b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts @@ -810,7 +810,7 @@ describe("StakingVault.sol", () => { ) .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, 0n), baseFee) - .to.emit(stakingVault, "ValidatorWithdrawalRequested") + .to.emit(stakingVault, "ValidatorWithdrawalTriggered") .withArgs(vaultOwner, SAMPLE_PUBKEY, [0n], vaultOwnerAddress, 0n); }); @@ -822,7 +822,7 @@ describe("StakingVault.sol", () => { ) .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, 0n), baseFee) - .to.emit(stakingVault, "ValidatorWithdrawalRequested") + .to.emit(stakingVault, "ValidatorWithdrawalTriggered") .withArgs(operator, SAMPLE_PUBKEY, [0n], vaultOwnerAddress, 0n); }); @@ -834,7 +834,7 @@ describe("StakingVault.sol", () => { ) .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, 0n), baseFee) - .to.emit(stakingVault, "ValidatorWithdrawalRequested") + .to.emit(stakingVault, "ValidatorWithdrawalTriggered") .withArgs(vaultOwner, SAMPLE_PUBKEY, [0n], vaultOwnerAddress, 0n); }); @@ -847,7 +847,7 @@ describe("StakingVault.sol", () => { ) .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, amount), baseFee) - .to.emit(stakingVault, "ValidatorWithdrawalRequested") + .to.emit(stakingVault, "ValidatorWithdrawalTriggered") .withArgs(vaultOwner, SAMPLE_PUBKEY, [amount], vaultOwnerAddress, 0); }); @@ -863,7 +863,7 @@ describe("StakingVault.sol", () => { await expect(tx) .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, amount), baseFee) - .to.emit(stakingVault, "ValidatorWithdrawalRequested") + .to.emit(stakingVault, "ValidatorWithdrawalTriggered") .withArgs(vaultOwner, SAMPLE_PUBKEY, [amount], vaultOwnerAddress, overpaid); const txReceipt = (await tx.wait()) as ContractTransactionReceipt; @@ -891,7 +891,7 @@ describe("StakingVault.sol", () => { .withArgs(encodeEip7002Input(pubkeys.pubkeys[0], amounts[0]), baseFee) .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(pubkeys.pubkeys[1], amounts[1]), baseFee) - .and.to.emit(stakingVault, "ValidatorWithdrawalRequested") + .and.to.emit(stakingVault, "ValidatorWithdrawalTriggered") .withArgs(vaultOwner, pubkeys.stringified, amounts, vaultOwnerAddress, 0n); }); @@ -911,7 +911,7 @@ describe("StakingVault.sol", () => { .withArgs(encodeEip7002Input(pubkeys.pubkeys[0], amounts[0]), baseFee) .to.emit(withdrawalRequest, "eip7002MockRequestAdded") .withArgs(encodeEip7002Input(pubkeys.pubkeys[1], amounts[1]), baseFee) - .and.to.emit(stakingVault, "ValidatorWithdrawalRequested") + .and.to.emit(stakingVault, "ValidatorWithdrawalTriggered") .withArgs(vaultOwner, pubkeys.stringified, amounts, stranger, valueToRefund); const strangerBalanceAfter = await ethers.provider.getBalance(stranger); From 1b9132ec42aac8bfcf727db5b3933f50a7d9b32f Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Thu, 20 Feb 2025 13:27:32 +0000 Subject: [PATCH 147/184] chore: move vault limits to immutable --- contracts/0.8.25/Accounting.sol | 10 +- contracts/0.8.25/vaults/StakingVault.sol | 2 +- contracts/0.8.25/vaults/VaultHub.sol | 68 ++++---------- scripts/defaults/testnet-defaults.json | 6 ++ .../steps/0090-deploy-non-aragon-contracts.ts | 3 + test/0.8.25/vaults/accounting.test.ts | 9 +- test/0.8.25/vaults/vaultFactory.test.ts | 10 +- .../vaulthub/contracts/VaultHub__Harness.sol | 7 +- .../vaulthub.force-withdrawals.test.ts | 10 +- .../vaults/vaulthub/vaulthub.hub.test.ts | 92 +++---------------- .../vaults/vaulthub/vaulthub.pausable.test.ts | 10 +- .../accounting.handleOracleReport.test.ts | 8 +- test/suite/constants.ts | 3 + 13 files changed, 97 insertions(+), 141 deletions(-) diff --git a/contracts/0.8.25/Accounting.sol b/contracts/0.8.25/Accounting.sol index a875110af..321a4d1d1 100644 --- a/contracts/0.8.25/Accounting.sol +++ b/contracts/0.8.25/Accounting.sol @@ -91,10 +91,16 @@ contract Accounting is VaultHub { /// @notice Lido contract ILido public immutable LIDO; + /// @param _lidoLocator Lido Locator contract + /// @param _lido Lido contract + /// @param _connectedVaultsLimit Maximum number of active vaults that can be connected to the hub + /// @param _relativeShareLimitBP Maximum share limit for a single vault relative to Lido TVL in basis points constructor( ILidoLocator _lidoLocator, - ILido _lido - ) VaultHub(_lido) { + ILido _lido, + uint256 _connectedVaultsLimit, + uint256 _relativeShareLimitBP + ) VaultHub(_lido, _connectedVaultsLimit, _relativeShareLimitBP) { LIDO_LOCATOR = _lidoLocator; LIDO = _lido; } diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 1fd87b3ca..0078c290f 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -491,7 +491,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { bool isAuthorized = ( msg.sender == $.nodeOperator || msg.sender == owner() || - (!isValuationBelowLocked && msg.sender == address(VAULT_HUB)) + (isValuationBelowLocked && msg.sender == address(VAULT_HUB)) ); if (!isAuthorized) revert NotAuthorized("triggerValidatorWithdrawal", msg.sender); diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 292e15a67..9678c431f 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -29,11 +29,6 @@ abstract contract VaultHub is PausableUntilWithRoles { mapping(address => uint256) vaultIndex; /// @notice allowed beacon addresses mapping(bytes32 => bool) vaultProxyCodehash; - /// @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 { @@ -66,8 +61,6 @@ 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 @@ -75,12 +68,25 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @notice length of the validator pubkey in bytes uint256 internal constant PUBLIC_KEY_LENGTH = 48; + /// @notice limit for the number of vaults that can ever be connected to the vault hub + uint256 private immutable CONNECTED_VAULTS_LIMIT; + /// @notice limit for a single vault share limit relative to Lido TVL in basis points + uint256 private immutable RELATIVE_SHARE_LIMIT_BP; + /// @notice Lido stETH contract IStETH public immutable STETH; /// @param _stETH Lido stETH contract - constructor(IStETH _stETH) { + /// @param _connectedVaultsLimit Maximum number of vaults that can be connected + /// @param _relativeShareLimitBP Maximum share limit relative to TVL in basis points + constructor(IStETH _stETH, uint256 _connectedVaultsLimit, uint256 _relativeShareLimitBP) { + if (_connectedVaultsLimit == 0) revert ZeroArgument("_connectedVaultsLimit"); + if (_relativeShareLimitBP == 0) revert ZeroArgument("_relativeShareLimitBP"); + if (_relativeShareLimitBP > TOTAL_BASIS_POINTS) revert RelativeShareLimitBPTooHigh(_relativeShareLimitBP, TOTAL_BASIS_POINTS); + STETH = _stETH; + CONNECTED_VAULTS_LIMIT = _connectedVaultsLimit; + RELATIVE_SHARE_LIMIT_BP = _relativeShareLimitBP; _disableInitializers(); } @@ -89,12 +95,8 @@ abstract contract VaultHub is PausableUntilWithRoles { function __VaultHub_init(address _admin) internal onlyInitializing { __AccessControlEnumerable_init(); - VaultHubStorage storage $ = _getVaultHubStorage(); - $.connectedVaultsLimit = 500; - $.relativeShareLimitBP = 10_00; // 10% - // the stone in the elevator - $.sockets.push(VaultSocket(address(0), 0, 0, 0, 0, 0, true)); + _getVaultHubStorage().sockets.push(VaultSocket(address(0), 0, 0, 0, 0, 0, true)); _grantRole(DEFAULT_ADMIN_ROLE, _admin); } @@ -115,16 +117,6 @@ abstract contract VaultHub is PausableUntilWithRoles { return _getVaultHubStorage().sockets.length - 1; } - /// @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 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 /// @return vault address function vault(uint256 _index) public view returns (address) { @@ -151,26 +143,6 @@ abstract contract VaultHub is PausableUntilWithRoles { return socket.sharesMinted <= _maxMintableShares(_vault, socket.reserveRatioThresholdBP, socket.shareLimit); } - /// @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().connectedVaultsLimit = _connectedVaultsLimit; - emit ConnectedVaultsLimitSet(_connectedVaultsLimit); - } - - /// @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().relativeShareLimitBP = _relativeShareLimitBP; - emit RelativeShareLimitBPSet(_relativeShareLimitBP); - } - /// @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 @@ -191,11 +163,10 @@ abstract contract VaultHub is PausableUntilWithRoles { if (_reserveRatioThresholdBP == 0) revert ZeroArgument("_reserveRatioThresholdBP"); if (_reserveRatioThresholdBP > _reserveRatioBP) revert ReserveRatioThresholdTooHigh(_vault, _reserveRatioThresholdBP, _reserveRatioBP); if (_treasuryFeeBP > TOTAL_BASIS_POINTS) revert TreasuryFeeTooHigh(_vault, _treasuryFeeBP, TOTAL_BASIS_POINTS); - - VaultHubStorage storage $ = _getVaultHubStorage(); - if (vaultsCount() == $.connectedVaultsLimit) revert TooManyVaults(); + if (vaultsCount() == CONNECTED_VAULTS_LIMIT) revert TooManyVaults(); _checkShareLimitUpperBound(_vault, _shareLimit); + VaultHubStorage storage $ = _getVaultHubStorage(); if ($.vaultIndex[_vault] != 0) revert AlreadyConnected(_vault, $.vaultIndex[_vault]); bytes32 vaultProxyCodehash = address(_vault).codehash; @@ -576,8 +547,7 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @dev check if the share limit is within the upper bound set by relativeShareLimitBP function _checkShareLimitUpperBound(address _vault, uint256 _shareLimit) internal view { - VaultHubStorage storage $ = _getVaultHubStorage(); - uint256 relativeMaxShareLimitPerVault = (STETH.getTotalShares() * $.relativeShareLimitBP) / TOTAL_BASIS_POINTS; + uint256 relativeMaxShareLimitPerVault = (STETH.getTotalShares() * RELATIVE_SHARE_LIMIT_BP) / TOTAL_BASIS_POINTS; if (_shareLimit > relativeMaxShareLimitPerVault) { revert ShareLimitTooHigh(_vault, _shareLimit, relativeMaxShareLimitPerVault); } @@ -591,8 +561,6 @@ 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 ConnectedVaultsLimitSet(uint256 connectedVaultsLimit); - event RelativeShareLimitBPSet(uint256 relativeShareLimitBP); error StETHMintFailed(address vault); error AlreadyBalanced(address vault, uint256 mintedShares, uint256 rebalancingThresholdInShares); diff --git a/scripts/defaults/testnet-defaults.json b/scripts/defaults/testnet-defaults.json index 1a2e0426b..ccecdebb0 100644 --- a/scripts/defaults/testnet-defaults.json +++ b/scripts/defaults/testnet-defaults.json @@ -77,6 +77,12 @@ "epochsPerFrame": 12 } }, + "accounting": { + "deployParameters": { + "connectedVaultsLimit": 500, + "relativeShareLimitBP": 1000 + } + }, "accountingOracle": { "deployParameters": { "consensusVersion": 2 diff --git a/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts b/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts index 1687cd717..fe2450ffb 100644 --- a/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts +++ b/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts @@ -24,6 +24,7 @@ export async function main() { const treasuryAddress = state[Sk.appAgent].proxy.address; const chainSpec = state[Sk.chainSpec]; const depositSecurityModuleParams = state[Sk.depositSecurityModule].deployParameters; + const accountingParams = state[Sk.accounting].deployParameters; const burnerParams = state[Sk.burner].deployParameters; const hashConsensusForAccountingParams = state[Sk.hashConsensusForAccountingOracle].deployParameters; const hashConsensusForExitBusParams = state[Sk.hashConsensusForValidatorsExitBusOracle].deployParameters; @@ -141,6 +142,8 @@ export async function main() { const accounting = await deployBehindOssifiableProxy(Sk.accounting, "Accounting", proxyContractsOwner, deployer, [ locator.address, lidoAddress, + accountingParams.connectedVaultsLimit, + accountingParams.relativeShareLimitBP, ]); // Deploy AccountingOracle diff --git a/test/0.8.25/vaults/accounting.test.ts b/test/0.8.25/vaults/accounting.test.ts index 0f9946b19..1b993bfa6 100644 --- a/test/0.8.25/vaults/accounting.test.ts +++ b/test/0.8.25/vaults/accounting.test.ts @@ -9,7 +9,7 @@ import { Accounting, LidoLocator, OssifiableProxy, StETH__HarnessForVaultHub } f import { ether } from "lib"; import { deployLidoLocator } from "test/deploy"; -import { Snapshot } from "test/suite"; +import { Snapshot, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP } from "test/suite"; describe("Accounting.sol", () => { let deployer: HardhatEthersSigner; @@ -36,7 +36,12 @@ describe("Accounting.sol", () => { }); // VaultHub - vaultHubImpl = await ethers.deployContract("Accounting", [locator, steth], { from: deployer }); + vaultHubImpl = await ethers.deployContract("Accounting", [ + locator, + steth, + VAULTS_CONNECTED_VAULTS_LIMIT, + VAULTS_RELATIVE_SHARE_LIMIT_BP, + ]); proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, admin, new Uint8Array()], admin); diff --git a/test/0.8.25/vaults/vaultFactory.test.ts b/test/0.8.25/vaults/vaultFactory.test.ts index 0babf421a..c2941069d 100644 --- a/test/0.8.25/vaults/vaultFactory.test.ts +++ b/test/0.8.25/vaults/vaultFactory.test.ts @@ -24,7 +24,7 @@ import { DelegationConfigStruct } from "typechain-types/contracts/0.8.25/vaults/ import { createVaultProxy, days, ether } from "lib"; import { deployLidoLocator } from "test/deploy"; -import { Snapshot } from "test/suite"; +import { Snapshot, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP } from "test/suite"; describe("VaultFactory.sol", () => { let deployer: HardhatEthersSigner; @@ -76,7 +76,13 @@ describe("VaultFactory.sol", () => { depositContract = await ethers.deployContract("DepositContract__MockForBeaconChainDepositor", deployer); // Accounting - accountingImpl = await ethers.deployContract("Accounting", [locator, steth]); + accountingImpl = await ethers.deployContract("Accounting", [ + locator, + steth, + VAULTS_CONNECTED_VAULTS_LIMIT, + VAULTS_RELATIVE_SHARE_LIMIT_BP, + ]); + proxy = await ethers.deployContract("OssifiableProxy", [accountingImpl, admin, new Uint8Array()], admin); accounting = await ethers.getContractAt("Accounting", proxy, deployer); await accounting.initialize(admin); diff --git a/test/0.8.25/vaults/vaulthub/contracts/VaultHub__Harness.sol b/test/0.8.25/vaults/vaulthub/contracts/VaultHub__Harness.sol index 0bf941041..67e5af5ba 100644 --- a/test/0.8.25/vaults/vaulthub/contracts/VaultHub__Harness.sol +++ b/test/0.8.25/vaults/vaulthub/contracts/VaultHub__Harness.sol @@ -9,7 +9,12 @@ import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; contract VaultHub__Harness is Accounting { - constructor(address _locator, address _steth) Accounting(ILidoLocator(_locator), ILido(_steth)) {} + constructor( + address _locator, + address _steth, + uint256 _connectedVaultsLimit, + uint256 _relativeShareLimitBP + ) Accounting(ILidoLocator(_locator), ILido(_steth), _connectedVaultsLimit, _relativeShareLimitBP) {} function mock__calculateVaultsRebase( uint256 _postTotalShares, 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 5ff8d82e7..a3403e134 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 @@ -17,7 +17,7 @@ import { findEvents } from "lib/event"; import { ether } from "lib/units"; import { deployLidoLocator } from "test/deploy"; -import { Snapshot } from "test/suite"; +import { Snapshot, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP } from "test/suite"; const SAMPLE_PUBKEY = "0x" + "01".repeat(48); @@ -55,7 +55,13 @@ describe("VaultHub.sol:forceWithdrawals", () => { steth = await ethers.deployContract("StETH__HarnessForVaultHub", [user], { value: ether("10000.0") }); depositContract = await ethers.deployContract("DepositContract__MockForVaultHub"); - const vaultHubImpl = await ethers.deployContract("VaultHub__Harness", [locator, steth]); + const vaultHubImpl = await ethers.deployContract("VaultHub__Harness", [ + locator, + steth, + VAULTS_CONNECTED_VAULTS_LIMIT, + VAULTS_RELATIVE_SHARE_LIMIT_BP, + ]); + const proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()]); const accounting = await ethers.getContractAt("VaultHub__Harness", proxy); 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 0774b0acc..b906c9168 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts @@ -16,7 +16,7 @@ import { import { ether, findEvents, randomAddress } from "lib"; import { deployLidoLocator } from "test/deploy"; -import { Snapshot, ZERO_HASH } from "test/suite"; +import { Snapshot, VAULTS_RELATIVE_SHARE_LIMIT_BP, ZERO_HASH } from "test/suite"; const ZERO_BYTES32 = "0x" + Buffer.from(ZERO_HASH).toString("hex"); @@ -28,6 +28,8 @@ const TREASURY_FEE_BP = 5_00n; const TOTAL_BASIS_POINTS = 100_00n; // 100% const CONNECT_DEPOSIT = ether("1"); +const VAULTS_CONNECTED_VAULTS_LIMIT = 5; // Low limit to test the overflow + describe("VaultHub.sol:hub", () => { let deployer: HardhatEthersSigner; let user: HardhatEthersSigner; @@ -86,7 +88,13 @@ describe("VaultHub.sol:hub", () => { 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 vaultHubImpl = await ethers.deployContract("Accounting", [ + locator, + steth, + VAULTS_CONNECTED_VAULTS_LIMIT, + VAULTS_RELATIVE_SHARE_LIMIT_BP, + ]); + const proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()]); const accounting = await ethers.getContractAt("Accounting", proxy); @@ -95,7 +103,6 @@ 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); @@ -249,78 +256,6 @@ describe("VaultHub.sol:hub", () => { }); }); - context("connectedVaultsLimit", () => { - it("returns the maximum number of vaults that can be connected to the hub", async () => { - expect(await vaultHub.connectedVaultsLimit()).to.equal(500); - }); - }); - - context("relativeShareLimitBP", () => { - it("returns the maximum size of a single vault relative to Lido TVL in basis points", async () => { - expect(await vaultHub.relativeShareLimitBP()).to.equal(10_00); - }); - }); - - context("setConnectedVaultsLimit", () => { - it("reverts if called by non-VAULT_LIMITS_UPDATER_ROLE", async () => { - await expect(vaultHub.connect(stranger).setConnectedVaultsLimit(500)).to.be.revertedWithCustomError( - vaultHub, - "AccessControlUnauthorizedAccount", - ); - }); - - it("reverts if new vaults limit is zero", async () => { - await expect(vaultHub.connect(user).setConnectedVaultsLimit(0)).to.be.revertedWithCustomError( - vaultHub, - "ZeroArgument", - ); - }); - - it("reverts if vaults limit is less than the number of already connected vaults", async () => { - await createVaultAndConnect(vaultFactory); - await expect(vaultHub.connect(user).setConnectedVaultsLimit(1)).to.be.revertedWithCustomError( - vaultHub, - "ConnectedVaultsLimitTooLow", - ); - }); - - it("updates the maximum number of vaults that can be connected to the hub", async () => { - await expect(vaultHub.connect(user).setConnectedVaultsLimit(1)) - .to.emit(vaultHub, "ConnectedVaultsLimitSet") - .withArgs(1); - expect(await vaultHub.connectedVaultsLimit()).to.equal(1); - }); - }); - - context("setRelativeShareLimitBP", () => { - it("reverts if called by non-VAULT_LIMITS_UPDATER_ROLE", async () => { - await expect(vaultHub.connect(stranger).setRelativeShareLimitBP(10_00)).to.be.revertedWithCustomError( - vaultHub, - "AccessControlUnauthorizedAccount", - ); - }); - - it("reverts if new relative share limit is zero", async () => { - await expect(vaultHub.connect(user).setRelativeShareLimitBP(0)).to.be.revertedWithCustomError( - vaultHub, - "ZeroArgument", - ); - }); - - 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.relativeShareLimitBP()).to.equal(20_00); - }); - }); - context("connectVault", () => { let vault: StakingVault__MockForVaultHub; let vaultAddress: string; @@ -389,7 +324,10 @@ describe("VaultHub.sol:hub", () => { }); it("reverts if max vault size is exceeded", async () => { - await vaultHub.connect(user).setConnectedVaultsLimit(1); + const vaultsCount = await vaultHub.vaultsCount(); + for (let i = vaultsCount; i < VAULTS_CONNECTED_VAULTS_LIMIT; i++) { + await createVaultAndConnect(vaultFactory); + } await expect( vaultHub @@ -508,7 +446,7 @@ 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 relativeShareLimitBP = await vaultHub.relativeShareLimitBP(); + const relativeShareLimitBP = VAULTS_RELATIVE_SHARE_LIMIT_BP; const relativeShareLimitPerVault = (totalShares * relativeShareLimitBP) / TOTAL_BASIS_POINTS; await expect(vaultHub.connect(user).updateShareLimit(vaultAddress, insaneLimit)) diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts index d8f3ba6f4..37615b30b 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts @@ -9,7 +9,7 @@ import { StETH__HarnessForVaultHub, VaultHub } from "typechain-types"; import { ether, MAX_UINT256 } from "lib"; import { deployLidoLocator } from "test/deploy"; -import { Snapshot } from "test/suite"; +import { Snapshot, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP } from "test/suite"; describe("VaultHub.sol:pausableUntil", () => { let deployer: HardhatEthersSigner; @@ -27,7 +27,13 @@ describe("VaultHub.sol:pausableUntil", () => { const locator = await deployLidoLocator(); steth = await ethers.deployContract("StETH__HarnessForVaultHub", [user], { value: ether("1.0") }); - const vaultHubImpl = await ethers.deployContract("Accounting", [locator, steth]); + const vaultHubImpl = await ethers.deployContract("Accounting", [ + locator, + steth, + VAULTS_CONNECTED_VAULTS_LIMIT, + VAULTS_RELATIVE_SHARE_LIMIT_BP, + ]); + const proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()]); const accounting = await ethers.getContractAt("Accounting", proxy); diff --git a/test/0.8.9/accounting.handleOracleReport.test.ts b/test/0.8.9/accounting.handleOracleReport.test.ts index 0e6d23ba0..7d4680eb8 100644 --- a/test/0.8.9/accounting.handleOracleReport.test.ts +++ b/test/0.8.9/accounting.handleOracleReport.test.ts @@ -25,7 +25,7 @@ import { ReportValuesStruct } from "typechain-types/contracts/0.8.9/oracle/Accou import { certainAddress, ether, impersonate } from "lib"; import { deployLidoLocator, updateLidoLocatorImplementation } from "test/deploy"; - +import { VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP } from "test/suite"; describe("Accounting.sol:report", () => { let deployer: HardhatEthersSigner; @@ -64,7 +64,11 @@ describe("Accounting.sol:report", () => { deployer, ); - const accountingImpl = await ethers.deployContract("Accounting", [locator, lido], deployer); + const accountingImpl = await ethers.deployContract( + "Accounting", + [locator, lido, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP], + deployer, + ); const accountingProxy = await ethers.deployContract( "OssifiableProxy", [accountingImpl, deployer, new Uint8Array()], diff --git a/test/suite/constants.ts b/test/suite/constants.ts index 72ddd152a..86e4d1642 100644 --- a/test/suite/constants.ts +++ b/test/suite/constants.ts @@ -11,3 +11,6 @@ export const SHARE_RATE_PRECISION = BigInt(10 ** 27); export const ZERO_HASH = new Uint8Array(32).fill(0); export const EIP7002_PREDEPLOYED_ADDRESS = "0x00000961Ef480Eb55e80D19ad83579A64c007002"; + +export const VAULTS_RELATIVE_SHARE_LIMIT_BP = 10_00n; +export const VAULTS_CONNECTED_VAULTS_LIMIT = 500; From cc21f4f2fdaba646f713c3e68723a066a9964b7b Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Thu, 20 Feb 2025 17:33:17 +0000 Subject: [PATCH 148/184] feat: vaultHealthRatio --- contracts/0.8.25/vaults/VaultHub.sol | 27 ++- test/0.8.25/vaults/accounting.test.ts | 12 +- .../0.8.25/vaults/dashboard/dashboard.test.ts | 20 +- .../vaulthub.force-withdrawals.test.ts | 4 +- .../vaults/vaulthub/vaulthub.hub.test.ts | 225 ++++++++++++++---- 5 files changed, 205 insertions(+), 83 deletions(-) diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 9678c431f..bb04db1ff 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -48,7 +48,7 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @notice treasury fee in basis points uint16 treasuryFeeBP; /// @notice if true, vault is disconnected and fee is not accrued - bool isDisconnected; + bool pendingDisconnect; /// @notice unused gap in the slot 2 /// uint104 _unused_gap_; } @@ -96,7 +96,7 @@ abstract contract VaultHub is PausableUntilWithRoles { __AccessControlEnumerable_init(); // the stone in the elevator - _getVaultHubStorage().sockets.push(VaultSocket(address(0), 0, 0, 0, 0, 0, true)); + _getVaultHubStorage().sockets.push(VaultSocket(address(0), 0, 0, 0, 0, 0, false)); _grantRole(DEFAULT_ADMIN_ROLE, _admin); } @@ -136,11 +136,16 @@ abstract contract VaultHub is PausableUntilWithRoles { return $.sockets[$.vaultIndex[_vault]]; } + /// @notice returns the health ratio of the vault /// @param _vault vault address - /// @return true if the vault is balanced - function isVaultBalanced(address _vault) external view returns (bool) { + /// @return health ratio in basis points + function vaultHealthRatio(address _vault) external view returns (uint256) { VaultSocket storage socket = _connectedSocket(_vault); - return socket.sharesMinted <= _maxMintableShares(_vault, socket.reserveRatioThresholdBP, socket.shareLimit); + if (socket.sharesMinted == 0) return type(uint256).max; // infinite health + + uint256 valuation = IStakingVault(_vault).valuation(); + uint256 mintedStETH = STETH.getPooledEthByShares(socket.sharesMinted); + return (valuation * (TOTAL_BASIS_POINTS - socket.reserveRatioThresholdBP)) / mintedStETH; } /// @notice connects a vault to the hub @@ -179,7 +184,7 @@ abstract contract VaultHub is PausableUntilWithRoles { uint16(_reserveRatioBP), uint16(_reserveRatioThresholdBP), uint16(_treasuryFeeBP), - false // isDisconnected + false // pendingDisconnect ); $.vaultIndex[_vault] = $.sockets.length; $.sockets.push(vsocket); @@ -393,7 +398,7 @@ abstract contract VaultHub is PausableUntilWithRoles { revert NoMintedSharesShouldBeLeft(_vault, sharesMinted); } - socket.isDisconnected = true; + socket.pendingDisconnect = true; vault_.report(vault_.valuation(), vault_.inOutDelta(), 0); @@ -430,7 +435,7 @@ abstract contract VaultHub is PausableUntilWithRoles { for (uint256 i = 0; i < length; ++i) { VaultSocket memory socket = $.sockets[i + 1]; - if (!socket.isDisconnected) { + if (!socket.pendingDisconnect) { treasuryFeeShares[i] = _calculateTreasuryFees( socket, _postTotalShares - _sharesToMintAsFees, @@ -493,7 +498,7 @@ abstract contract VaultHub is PausableUntilWithRoles { for (uint256 i = 0; i < _valuations.length; i++) { VaultSocket storage socket = $.sockets[i + 1]; - if (socket.isDisconnected) continue; // we skip disconnected vaults + if (socket.pendingDisconnect) continue; // we skip disconnected vaults uint256 treasuryFeeShares = _treasureFeeShares[i]; if (treasuryFeeShares > 0) { @@ -507,7 +512,7 @@ abstract contract VaultHub is PausableUntilWithRoles { for (uint256 i = 1; i < length; i++) { VaultSocket storage socket = $.sockets[i]; - if (socket.isDisconnected) { + if (socket.pendingDisconnect) { // remove disconnected vault from the list VaultSocket memory lastSocket = $.sockets[length - 1]; $.sockets[i] = lastSocket; @@ -526,7 +531,7 @@ abstract contract VaultHub is PausableUntilWithRoles { function _connectedSocket(address _vault) internal view returns (VaultSocket storage) { VaultHubStorage storage $ = _getVaultHubStorage(); uint256 index = $.vaultIndex[_vault]; - if (index == 0 || $.sockets[index].isDisconnected) revert NotConnectedToHub(_vault); + if (index == 0 || $.sockets[index].pendingDisconnect) revert NotConnectedToHub(_vault); return $.sockets[index]; } diff --git a/test/0.8.25/vaults/accounting.test.ts b/test/0.8.25/vaults/accounting.test.ts index 1b993bfa6..2b44169ff 100644 --- a/test/0.8.25/vaults/accounting.test.ts +++ b/test/0.8.25/vaults/accounting.test.ts @@ -4,7 +4,7 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { Accounting, LidoLocator, OssifiableProxy, StETH__HarnessForVaultHub } from "typechain-types"; +import { Accounting, LidoLocator, OssifiableProxy, StETH__Harness } from "typechain-types"; import { ether } from "lib"; @@ -12,7 +12,6 @@ import { deployLidoLocator } from "test/deploy"; import { Snapshot, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP } from "test/suite"; describe("Accounting.sol", () => { - let deployer: HardhatEthersSigner; let admin: HardhatEthersSigner; let user: HardhatEthersSigner; let holder: HardhatEthersSigner; @@ -21,19 +20,16 @@ describe("Accounting.sol", () => { let proxy: OssifiableProxy; let vaultHubImpl: Accounting; let accounting: Accounting; - let steth: StETH__HarnessForVaultHub; + let steth: StETH__Harness; let locator: LidoLocator; let originalState: string; before(async () => { - [deployer, admin, user, holder, stranger] = await ethers.getSigners(); + [admin, user, holder, stranger] = await ethers.getSigners(); locator = await deployLidoLocator(); - steth = await ethers.deployContract("StETH__HarnessForVaultHub", [holder], { - value: ether("10.0"), - from: deployer, - }); + steth = await ethers.deployContract("StETH__Harness", [holder], { value: ether("10.0") }); // VaultHub vaultHubImpl = await ethers.deployContract("Accounting", [ diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index b9dc44474..7d84f665f 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -184,7 +184,7 @@ describe("Dashboard.sol", () => { reserveRatioBP: 1000n, reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; await hub.mock__setVaultSocket(vault, sockets); @@ -220,7 +220,7 @@ describe("Dashboard.sol", () => { reserveRatioBP: 1000n, reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; await hub.mock__setVaultSocket(vault, sockets); @@ -242,7 +242,7 @@ describe("Dashboard.sol", () => { reserveRatioBP: 1000n, reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; await hub.mock__setVaultSocket(vault, sockets); @@ -262,7 +262,7 @@ describe("Dashboard.sol", () => { reserveRatioBP: 10_000n, reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; await hub.mock__setVaultSocket(vault, sockets); @@ -282,7 +282,7 @@ describe("Dashboard.sol", () => { reserveRatioBP: 0n, reserveRatioThresholdBP: 0n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; await hub.mock__setVaultSocket(vault, sockets); @@ -310,7 +310,7 @@ describe("Dashboard.sol", () => { reserveRatioBP: 1000n, reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; await hub.mock__setVaultSocket(vault, sockets); @@ -336,7 +336,7 @@ describe("Dashboard.sol", () => { reserveRatioBP: 1000n, reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; await hub.mock__setVaultSocket(vault, sockets); @@ -359,7 +359,7 @@ describe("Dashboard.sol", () => { reserveRatioBP: 1000n, reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; await hub.mock__setVaultSocket(vault, sockets); @@ -380,7 +380,7 @@ describe("Dashboard.sol", () => { reserveRatioBP: 1000n, reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; await hub.mock__setVaultSocket(vault, sockets); @@ -404,7 +404,7 @@ describe("Dashboard.sol", () => { reserveRatioBP: 1000n, reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; await hub.mock__setVaultSocket(vault, sockets); 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 a3403e134..bfa69881d 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 @@ -196,7 +196,7 @@ describe("VaultHub.sol:forceWithdrawals", () => { const valuation = ether("100"); await demoVault.fund({ value: valuation }); - const cap = await steth.getSharesByPooledEth((valuation * (TOTAL_BASIS_POINTS - 20_01n)) / TOTAL_BASIS_POINTS); + const cap = await steth.getSharesByPooledEth((valuation * (TOTAL_BASIS_POINTS - 20_00n)) / TOTAL_BASIS_POINTS); await vaultHub.connectVault(demoVaultAddress, cap, 20_00n, 20_00n, 5_00n); await vaultHub.mintShares(demoVaultAddress, user, cap); @@ -223,7 +223,7 @@ describe("VaultHub.sol:forceWithdrawals", () => { await demoVault.report(valuation - penalty, valuation, rebase.lockedEther[1]); - expect(await vaultHub.isVaultBalanced(demoVaultAddress)).to.be.false; + expect(await vaultHub.vaultHealthRatio(demoVault)).to.be.lt(TOTAL_BASIS_POINTS); // < 100% await expect(vaultHub.forceValidatorWithdrawal(demoVaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: FEE })) .to.emit(vaultHub, "VaultForceWithdrawalTriggered") 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 b906c9168..b0b33ffc2 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts @@ -5,18 +5,19 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { + ACL, DepositContract__MockForVaultHub, + Lido, LidoLocator, StakingVault__MockForVaultHub, - StETH__HarnessForVaultHub, VaultFactory__MockForVaultHub, VaultHub, } from "typechain-types"; -import { ether, findEvents, randomAddress } from "lib"; +import { BigIntMath, ether, findEvents, MAX_UINT256, randomAddress } from "lib"; -import { deployLidoLocator } from "test/deploy"; -import { Snapshot, VAULTS_RELATIVE_SHARE_LIMIT_BP, ZERO_HASH } from "test/suite"; +import { deployLidoDao, updateLidoLocatorImplementation } from "test/deploy"; +import { Snapshot, Tracing, VAULTS_RELATIVE_SHARE_LIMIT_BP, ZERO_HASH } from "test/suite"; const ZERO_BYTES32 = "0x" + Buffer.from(ZERO_HASH).toString("hex"); @@ -39,7 +40,8 @@ describe("VaultHub.sol:hub", () => { let vaultHub: VaultHub; let depositContract: DepositContract__MockForVaultHub; let vaultFactory: VaultFactory__MockForVaultHub; - let steth: StETH__HarnessForVaultHub; + let lido: Lido; + let acl: ACL; let codehash: string; @@ -57,40 +59,49 @@ describe("VaultHub.sol:hub", () => { return vault; } - async function connectVault(vault: StakingVault__MockForVaultHub) { + async function createAndConnectVault( + factory: VaultFactory__MockForVaultHub, + options?: { + shareLimit?: bigint; + reserveRatioBP?: bigint; + reserveRatioThresholdBP?: bigint; + treasuryFeeBP?: bigint; + }, + ) { + const vault = await createVault(factory); + await vaultHub .connect(user) .connectVault( await vault.getAddress(), - SHARE_LIMIT, - RESERVE_RATIO_BP, - RESERVE_RATIO_THRESHOLD_BP, - TREASURY_FEE_BP, + options?.shareLimit ?? SHARE_LIMIT, + options?.reserveRatioBP ?? RESERVE_RATIO_BP, + options?.reserveRatioThresholdBP ?? RESERVE_RATIO_THRESHOLD_BP, + options?.treasuryFeeBP ?? 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.mintShares(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") }); + ({ lido, acl } = await deployLidoDao({ rootAccount: deployer, initialized: true })); + locator = await ethers.getContractAt("LidoLocator", await lido.getLidoLocator(), deployer); + + await acl.createPermission(user, lido, await lido.RESUME_ROLE(), deployer); + await acl.createPermission(user, lido, await lido.STAKING_CONTROL_ROLE(), deployer); + + await lido.connect(user).resume(); + await lido.connect(user).setMaxExternalRatioBP(TOTAL_BASIS_POINTS); + + await lido.submit(deployer, { value: ether("1000.0") }); + depositContract = await ethers.deployContract("DepositContract__MockForVaultHub"); const vaultHubImpl = await ethers.deployContract("Accounting", [ locator, - steth, + lido, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP, ]); @@ -106,6 +117,8 @@ describe("VaultHub.sol:hub", () => { await accounting.grantRole(await vaultHub.VAULT_MASTER_ROLE(), user); await accounting.grantRole(await vaultHub.VAULT_REGISTRY_ROLE(), user); + await updateLidoLocatorImplementation(await locator.getAddress(), { accounting }); + const stakingVaultImpl = await ethers.deployContract("StakingVault__MockForVaultHub", [ await vaultHub.getAddress(), await depositContract.getAddress(), @@ -124,7 +137,7 @@ describe("VaultHub.sol:hub", () => { context("Constants", () => { it("returns the STETH address", async () => { - expect(await vaultHub.STETH()).to.equal(await steth.getAddress()); + expect(await vaultHub.STETH()).to.equal(await lido.getAddress()); }); }); @@ -166,7 +179,7 @@ describe("VaultHub.sol:hub", () => { it("returns the number of connected vaults", async () => { expect(await vaultHub.vaultsCount()).to.equal(0); - await createVaultAndConnect(vaultFactory); + await createAndConnectVault(vaultFactory); expect(await vaultHub.vaultsCount()).to.equal(1); }); @@ -178,7 +191,7 @@ describe("VaultHub.sol:hub", () => { }); it("returns the vault", async () => { - const vault = await createVaultAndConnect(vaultFactory); + const vault = await createAndConnectVault(vaultFactory); const lastVaultId = (await vaultHub.vaultsCount()) - 1n; const lastVaultAddress = await vaultHub.vault(lastVaultId); @@ -192,7 +205,7 @@ describe("VaultHub.sol:hub", () => { }); it("returns the vault socket by index", async () => { - const vault = await createVaultAndConnect(vaultFactory); + const vault = await createAndConnectVault(vaultFactory); const lastVaultId = (await vaultHub.vaultsCount()) - 1n; expect(lastVaultId).to.equal(0n); @@ -204,7 +217,7 @@ describe("VaultHub.sol:hub", () => { 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); + expect(lastVaultSocket.pendingDisconnect).to.equal(false); }); }); @@ -219,11 +232,11 @@ describe("VaultHub.sol:hub", () => { expect(vaultSocket.reserveRatioBP).to.equal(0n); expect(vaultSocket.reserveRatioThresholdBP).to.equal(0n); expect(vaultSocket.treasuryFeeBP).to.equal(0n); - expect(vaultSocket.isDisconnected).to.equal(true); + expect(vaultSocket.pendingDisconnect).to.equal(false); }); it("returns the vault socket for a vault that was connected", async () => { - const vault = await createVaultAndConnect(vaultFactory); + const vault = await createAndConnectVault(vaultFactory); const vaultAddress = await vault.getAddress(); const vaultSocket = await vaultHub["vaultSocket(address)"](vaultAddress); @@ -233,26 +246,134 @@ describe("VaultHub.sol:hub", () => { 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); + expect(vaultSocket.pendingDisconnect).to.equal(false); }); }); - context("isVaultBalanced", () => { - let vault: StakingVault__MockForVaultHub; - let vaultAddress: string; + context("vaultHealthRatio", () => { + before(() => Tracing.enable()); - before(async () => { - vault = await createVaultAndConnect(vaultFactory); - vaultAddress = await vault.getAddress(); + it("reverts if vault is not connected", async () => { + await expect(vaultHub.vaultHealthRatio(randomAddress())).to.be.revertedWithCustomError( + vaultHub, + "NotConnectedToHub", + ); }); - it("returns true if the vault is healthy", async () => { - expect(await vaultHub.isVaultBalanced(vaultAddress)).to.be.true; + it("returns the MAX_UINT256 if the vault has no shares minted", async () => { + const vault = await createAndConnectVault(vaultFactory); + const vaultAddress = await vault.getAddress(); + + await vault.fund({ value: ether("1") }); + + expect(await vaultHub.vaultHealthRatio(vaultAddress)).to.equal(MAX_UINT256); + }); + + context("health ratio calculations", () => { + const marks = [10_00n, 50_00n, 100_00n]; // 10%, 50%, 100% LTV + const runs = [ + { + reserveRatio: 50_00n, // 50% + reserveRatioThreshold: 40_00n, // 40% + valuation: ether(Math.floor(Math.random() * 100 + 1).toString()), + }, + { + reserveRatio: 10_00n, // 10% + reserveRatioThreshold: 8_00n, // 8% + valuation: ether(Math.floor(Math.random() * 100 + 1).toString()), + }, + { + reserveRatio: 2_00n, // 2% + reserveRatioThreshold: 1_00n, // 1% + valuation: ether(Math.floor(Math.random() * 100 + 1).toString()), + }, + ]; + + for (const run of runs) { + for (const ltvRatio of marks) { + const cap = (run.valuation * (TOTAL_BASIS_POINTS - run.reserveRatio)) / TOTAL_BASIS_POINTS; + const mintedStETH = (cap * ltvRatio) / TOTAL_BASIS_POINTS; + const expectedHealthRatio = (run.valuation * (TOTAL_BASIS_POINTS - run.reserveRatioThreshold)) / mintedStETH; + const title = + `${ethers.formatEther(mintedStETH)}/${ethers.formatEther(run.valuation)} ETH vault (` + + `RR: ${run.reserveRatio / 100n}%, ` + + `RRT: ${run.reserveRatioThreshold / 100n}%) => ` + + `${expectedHealthRatio / 100n}%`; + + it(`calculates health ratio correctly for ${title}`, async () => { + const vault = await createAndConnectVault(vaultFactory, { + shareLimit: ether("100"), + reserveRatioBP: run.reserveRatio, + reserveRatioThresholdBP: run.reserveRatioThreshold, + }); + + await vault.fund({ value: run.valuation }); + expect(await vault.valuation()).to.equal(run.valuation); + + const sharesToMint = await lido.getSharesByPooledEth(mintedStETH); + await vaultHub.mintShares(await vault.getAddress(), user, sharesToMint); + + const healthRatio = await vaultHub.vaultHealthRatio(await vault.getAddress()); + expect(healthRatio).to.equal(expectedHealthRatio); + }); + } + } }); - it("returns false if the vault is unhealthy", async () => { - await makeVaultBalanced(vault); - expect(await vaultHub.isVaultBalanced(vaultAddress)).to.be.false; + context("health ratio calculations for unhealthy vaults", () => { + const runs = [ + { + reserveRatio: 50_00n, // 50% + reserveRatioThreshold: 40_00n, // 40% + valuation: ether(Math.floor(Math.random() * 100 + 1).toString()), + }, + { + reserveRatio: 10_00n, // 10% + reserveRatioThreshold: 8_00n, // 8% + valuation: ether(Math.floor(Math.random() * 100 + 1).toString()), + }, + { + reserveRatio: 2_00n, // 2% + reserveRatioThreshold: 1_00n, // 1% + valuation: ether(Math.floor(Math.random() * 100 + 1).toString()), + }, + ]; + + for (const run of runs) { + const drops = [1n, run.reserveRatioThreshold, run.reserveRatio]; + + for (const drop of drops) { + const cap = (run.valuation * (TOTAL_BASIS_POINTS - run.reserveRatio)) / TOTAL_BASIS_POINTS; + const slashedStETH = (run.valuation * drop) / TOTAL_BASIS_POINTS; + const expectedHealthRatio = + ((run.valuation - slashedStETH) * (TOTAL_BASIS_POINTS - run.reserveRatioThreshold)) / cap; + + const title = + `${ethers.formatEther(cap)}/${ethers.formatEther(run.valuation - slashedStETH)} ETH vault (` + + `RR: ${run.reserveRatio / 100n}%, ` + + `RRT: ${run.reserveRatioThreshold / 100n}%) => ` + + `${expectedHealthRatio / 100n}%`; + + it(`calculates health ratio correctly for ${title}`, async () => { + const vault = await createAndConnectVault(vaultFactory, { + shareLimit: ether("100"), + reserveRatioBP: run.reserveRatio, + reserveRatioThresholdBP: run.reserveRatioThreshold, + }); + + await vault.fund({ value: run.valuation }); + expect(await vault.valuation()).to.equal(run.valuation); + + const sharesToMint = await lido.getSharesByPooledEth(cap); + await vaultHub.mintShares(await vault.getAddress(), user, sharesToMint); + + await vault.report(run.valuation - slashedStETH, run.valuation, BigIntMath.max(cap, ether("1"))); + + const healthRatio = await vaultHub.vaultHealthRatio(await vault.getAddress()); + expect(healthRatio).to.equal(expectedHealthRatio); + }); + } + } }); }); @@ -326,7 +447,7 @@ describe("VaultHub.sol:hub", () => { it("reverts if max vault size is exceeded", async () => { const vaultsCount = await vaultHub.vaultsCount(); for (let i = vaultsCount; i < VAULTS_CONNECTED_VAULTS_LIMIT; i++) { - await createVaultAndConnect(vaultFactory); + await createAndConnectVault(vaultFactory); } await expect( @@ -337,7 +458,7 @@ describe("VaultHub.sol:hub", () => { }); it("reverts if vault is already connected", async () => { - const connectedVault = await createVaultAndConnect(vaultFactory); + const connectedVault = await createAndConnectVault(vaultFactory); const connectedVaultAddress = await connectedVault.getAddress(); await expect( @@ -381,7 +502,7 @@ describe("VaultHub.sol:hub", () => { const vaultSocketBefore = await vaultHub["vaultSocket(address)"](vaultAddress); expect(vaultSocketBefore.vault).to.equal(ZeroAddress); - expect(vaultSocketBefore.isDisconnected).to.be.true; + expect(vaultSocketBefore.pendingDisconnect).to.be.false; await expect( vaultHub @@ -395,7 +516,7 @@ describe("VaultHub.sol:hub", () => { const vaultSocketAfter = await vaultHub["vaultSocket(address)"](vaultAddress); expect(vaultSocketAfter.vault).to.equal(vaultAddress); - expect(vaultSocketAfter.isDisconnected).to.be.false; + expect(vaultSocketAfter.pendingDisconnect).to.be.false; expect(await vault.locked()).to.equal(CONNECT_DEPOSIT); }); @@ -426,7 +547,7 @@ describe("VaultHub.sol:hub", () => { let vaultAddress: string; before(async () => { - vault = await createVaultAndConnect(vaultFactory); + vault = await createAndConnectVault(vaultFactory); vaultAddress = await vault.getAddress(); }); @@ -445,7 +566,7 @@ 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 totalShares = await lido.getTotalShares(); const relativeShareLimitBP = VAULTS_RELATIVE_SHARE_LIMIT_BP; const relativeShareLimitPerVault = (totalShares * relativeShareLimitBP) / TOTAL_BASIS_POINTS; @@ -471,7 +592,7 @@ describe("VaultHub.sol:hub", () => { let vaultAddress: string; before(async () => { - vault = await createVaultAndConnect(vaultFactory); + vault = await createAndConnectVault(vaultFactory); vaultAddress = await vault.getAddress(); }); @@ -512,7 +633,7 @@ describe("VaultHub.sol:hub", () => { .withArgs(vaultAddress); const vaultSocket = await vaultHub["vaultSocket(address)"](vaultAddress); - expect(vaultSocket.isDisconnected).to.be.true; + expect(vaultSocket.pendingDisconnect).to.be.true; }); }); @@ -521,7 +642,7 @@ describe("VaultHub.sol:hub", () => { let vaultAddress: string; before(async () => { - vault = await createVaultAndConnect(vaultFactory); + vault = await createAndConnectVault(vaultFactory); vaultAddress = await vault.getAddress(); }); @@ -571,7 +692,7 @@ describe("VaultHub.sol:hub", () => { .withArgs(vaultAddress); const vaultSocket = await vaultHub["vaultSocket(address)"](vaultAddress); - expect(vaultSocket.isDisconnected).to.be.true; + expect(vaultSocket.pendingDisconnect).to.be.true; }); }); }); From ca5999522def50be232471be7d621543c1b2a6f3 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Thu, 20 Feb 2025 17:38:55 +0000 Subject: [PATCH 149/184] feat: rename to healthy --- contracts/0.8.25/vaults/VaultHub.sol | 44 ++++++++----------- ...als.test.ts => vaulthub.forceExit.test.ts} | 32 +++++++------- 2 files changed, 35 insertions(+), 41 deletions(-) rename test/0.8.25/vaults/vaulthub/{vaulthub.force-withdrawals.test.ts => vaulthub.forceExit.test.ts} (84%) diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index bb04db1ff..88bbea80e 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -42,8 +42,7 @@ abstract contract VaultHub is PausableUntilWithRoles { uint96 shareLimit; /// @notice minimal share of ether that is reserved for each stETH minted uint16 reserveRatioBP; - /// @notice if vault's reserve decreases to this threshold ratio, - /// it should be force rebalanced + /// @notice if vault's reserve decreases to this threshold ratio, it should be force rebalanced uint16 reserveRatioThresholdBP; /// @notice treasury fee in basis points uint16 treasuryFeeBP; @@ -139,9 +138,9 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @notice returns the health ratio of the vault /// @param _vault vault address /// @return health ratio in basis points - function vaultHealthRatio(address _vault) external view returns (uint256) { + function vaultHealthRatio(address _vault) public view returns (uint256) { VaultSocket storage socket = _connectedSocket(_vault); - if (socket.sharesMinted == 0) return type(uint256).max; // infinite health + if (socket.sharesMinted == 0) return type(uint256).max; uint256 valuation = IStakingVault(_vault).valuation(); uint256 mintedStETH = STETH.getPooledEthByShares(socket.sharesMinted); @@ -306,17 +305,11 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @dev permissionless if the vault's min reserve ratio is broken function forceRebalance(address _vault) external { if (_vault == address(0)) revert ZeroArgument("_vault"); + _onlyUnhealthy(_vault); VaultSocket storage socket = _connectedSocket(_vault); - 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); - } - - uint256 mintedStETH = STETH.getPooledEthByShares(sharesMinted); // TODO: fix rounding issue + uint256 mintedStETH = STETH.getPooledEthByShares(socket.sharesMinted); // TODO: fix rounding issue uint256 reserveRatioBP = socket.reserveRatioBP; uint256 maxMintableRatio = (TOTAL_BASIS_POINTS - reserveRatioBP); @@ -358,13 +351,13 @@ abstract contract VaultHub is PausableUntilWithRoles { emit VaultRebalanced(msg.sender, sharesToBurn); } - /// In case of the unbalanced vault, ANYONE can force any validator belonging to the vault to withdraw from the - /// beacon chain to get all the vault deposited ETH back to the vault balance and rebalance the vault - /// @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 _refundRecepient address of the recipient of the refund - function forceValidatorWithdrawal( + /// @notice Forces validator exit from the beacon chain when vault health ratio is below 100% + /// @param _vault The address of the vault to exit validators from + /// @param _pubkeys The public keys of the validators to exit + /// @param _refundRecepient The address that will receive the refund for transaction costs + /// @dev When a vault's health ratio drops below 100%, anyone can force its validators to exit the beacon chain + /// This returns the vault's deposited ETH back to vault's balance and allows to rebalance the vault + function forceValidatorExit( address _vault, bytes calldata _pubkeys, address _refundRecepient @@ -375,11 +368,7 @@ abstract contract VaultHub is PausableUntilWithRoles { 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); - if (socket.sharesMinted <= threshold) { - revert AlreadyBalanced(_vault, socket.sharesMinted, threshold); - } + _onlyUnhealthy(_vault); uint256 numValidators = _pubkeys.length / PUBLIC_KEY_LENGTH; uint64[] memory amounts = new uint64[](numValidators); @@ -558,6 +547,11 @@ abstract contract VaultHub is PausableUntilWithRoles { } } + function _onlyUnhealthy(address _vault) internal view { + uint256 healthRatio = vaultHealthRatio(_vault); + if (healthRatio >= TOTAL_BASIS_POINTS) revert AlreadyHealthy(_vault, healthRatio); + } + 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); @@ -568,7 +562,7 @@ abstract contract VaultHub is PausableUntilWithRoles { event VaultForceWithdrawalTriggered(address indexed vault, bytes pubkeys, address refundRecepient); error StETHMintFailed(address vault); - error AlreadyBalanced(address vault, uint256 mintedShares, uint256 rebalancingThresholdInShares); + error AlreadyHealthy(address vault, uint256 healthRatio); error InsufficientSharesToBurn(address vault, uint256 amount); error ShareLimitExceeded(address vault, uint256 capShares); error AlreadyConnected(address vault, uint256 index); diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.force-withdrawals.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts similarity index 84% rename from test/0.8.25/vaults/vaulthub/vaulthub.force-withdrawals.test.ts rename to test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts index bfa69881d..ab1c1d09a 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.force-withdrawals.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts @@ -12,7 +12,7 @@ import { VaultHub__Harness, } from "typechain-types"; -import { impersonate } from "lib"; +import { impersonate, MAX_UINT256 } from "lib"; import { findEvents } from "lib/event"; import { ether } from "lib/units"; @@ -29,7 +29,7 @@ const TREASURY_FEE_BP = 5_00n; const FEE = 2n; -describe("VaultHub.sol:forceWithdrawals", () => { +describe("VaultHub.sol:forceExit", () => { let deployer: HardhatEthersSigner; let user: HardhatEthersSigner; let stranger: HardhatEthersSigner; @@ -111,39 +111,39 @@ describe("VaultHub.sol:forceWithdrawals", () => { await vault.connect(vaultHubSigner).report(ether("0.9"), ether("1"), ether("1.1")); // slashing }; - context("forceValidatorWithdrawal", () => { + context("forceValidatorExit", () => { it("reverts if msg.value is 0", async () => { - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 0n })) + await expect(vaultHub.forceValidatorExit(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.forceValidatorWithdrawal(ZeroAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) + await expect(vaultHub.forceValidatorExit(ZeroAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") .withArgs("_vault"); }); it("reverts if zero pubkeys", async () => { - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, "0x", feeRecipient, { value: 1n })) + await expect(vaultHub.forceValidatorExit(vaultAddress, "0x", feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") .withArgs("_pubkeys"); }); it("reverts if zero refund recipient", async () => { - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, ZeroAddress, { value: 1n })) + await expect(vaultHub.forceValidatorExit(vaultAddress, SAMPLE_PUBKEY, ZeroAddress, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") .withArgs("_refundRecepient"); }); it("reverts if pubkeys are not valid", async () => { await expect( - vaultHub.forceValidatorWithdrawal(vaultAddress, "0x" + "01".repeat(47), feeRecipient, { value: 1n }), + vaultHub.forceValidatorExit(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 })) + await expect(vaultHub.forceValidatorExit(stranger, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "NotConnectedToHub") .withArgs(stranger.address); }); @@ -151,22 +151,22 @@ describe("VaultHub.sol:forceWithdrawals", () => { it("reverts if called for a disconnected vault", async () => { await vaultHub.connect(user).disconnect(vaultAddress); - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) + await expect(vaultHub.forceValidatorExit(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.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) - .to.be.revertedWithCustomError(vaultHub, "AlreadyBalanced") - .withArgs(vaultAddress, 0n, 0n); + await expect(vaultHub.forceValidatorExit(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) + .to.be.revertedWithCustomError(vaultHub, "AlreadyHealthy") + .withArgs(vaultAddress, MAX_UINT256); }); context("unhealthy vault", () => { beforeEach(async () => await makeVaultUnhealthy()); it("initiates force validator withdrawal", async () => { - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: FEE })) + await expect(vaultHub.forceValidatorExit(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: FEE })) .to.emit(vaultHub, "VaultForceWithdrawalTriggered") .withArgs(vaultAddress, SAMPLE_PUBKEY, feeRecipient); }); @@ -176,7 +176,7 @@ describe("VaultHub.sol:forceWithdrawals", () => { const pubkeys = "0x" + "ab".repeat(numPubkeys * 48); await expect( - vaultHub.forceValidatorWithdrawal(vaultAddress, pubkeys, feeRecipient, { value: FEE * BigInt(numPubkeys) }), + vaultHub.forceValidatorExit(vaultAddress, pubkeys, feeRecipient, { value: FEE * BigInt(numPubkeys) }), ) .to.emit(vaultHub, "VaultForceWithdrawalTriggered") .withArgs(vaultAddress, pubkeys, feeRecipient); @@ -225,7 +225,7 @@ describe("VaultHub.sol:forceWithdrawals", () => { expect(await vaultHub.vaultHealthRatio(demoVault)).to.be.lt(TOTAL_BASIS_POINTS); // < 100% - await expect(vaultHub.forceValidatorWithdrawal(demoVaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: FEE })) + await expect(vaultHub.forceValidatorExit(demoVaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: FEE })) .to.emit(vaultHub, "VaultForceWithdrawalTriggered") .withArgs(demoVaultAddress, SAMPLE_PUBKEY, feeRecipient); }); From f146a28c5bd8840cf54327bae618153af5ba1240 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Thu, 20 Feb 2025 18:16:01 +0000 Subject: [PATCH 150/184] fix: event name --- contracts/0.8.25/vaults/VaultHub.sol | 4 ++-- test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 88bbea80e..755532703 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -375,7 +375,7 @@ abstract contract VaultHub is PausableUntilWithRoles { IStakingVault(_vault).triggerValidatorWithdrawal{value: msg.value}(_pubkeys, amounts, _refundRecepient); - emit VaultForceWithdrawalTriggered(_vault, _pubkeys, _refundRecepient); + emit ForceValidatorExitTriggered(_vault, _pubkeys, _refundRecepient); } function _disconnect(address _vault) internal { @@ -559,7 +559,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, address refundRecepient); + event ForceValidatorExitTriggered(address indexed vault, bytes pubkeys, address refundRecepient); error StETHMintFailed(address vault); error AlreadyHealthy(address vault, uint256 healthRatio); diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts index ab1c1d09a..67193ce3d 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts @@ -167,7 +167,7 @@ describe("VaultHub.sol:forceExit", () => { it("initiates force validator withdrawal", async () => { await expect(vaultHub.forceValidatorExit(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: FEE })) - .to.emit(vaultHub, "VaultForceWithdrawalTriggered") + .to.emit(vaultHub, "ForceValidatorExitTriggered") .withArgs(vaultAddress, SAMPLE_PUBKEY, feeRecipient); }); @@ -178,7 +178,7 @@ describe("VaultHub.sol:forceExit", () => { await expect( vaultHub.forceValidatorExit(vaultAddress, pubkeys, feeRecipient, { value: FEE * BigInt(numPubkeys) }), ) - .to.emit(vaultHub, "VaultForceWithdrawalTriggered") + .to.emit(vaultHub, "ForceValidatorExitTriggered") .withArgs(vaultAddress, pubkeys, feeRecipient); }); }); @@ -226,7 +226,7 @@ describe("VaultHub.sol:forceExit", () => { expect(await vaultHub.vaultHealthRatio(demoVault)).to.be.lt(TOTAL_BASIS_POINTS); // < 100% await expect(vaultHub.forceValidatorExit(demoVaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: FEE })) - .to.emit(vaultHub, "VaultForceWithdrawalTriggered") + .to.emit(vaultHub, "ForceValidatorExitTriggered") .withArgs(demoVaultAddress, SAMPLE_PUBKEY, feeRecipient); }); }); From bb9274befbfe221a33c79de78165f7ab62a0c79e Mon Sep 17 00:00:00 2001 From: DevEloperate Date: Thu, 20 Feb 2025 21:54:10 +0300 Subject: [PATCH 151/184] Update broken link CONTRIBUTING.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a93d16690..cf71b365f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -143,7 +143,7 @@ integration tests follows the `*.integration.ts` postfix, for example, `myScenar Foundry's Solidity tests are specifically used for fuzzing library contracts or functions that perform complex calculations or byte manipulation. These Solidity tests are located under `/tests` and organized into appropriate subdirectories. The naming conventions follow -Foundry's [documentation](https://book.getfoundry.sh/tutorials/best-practices#general-test-guidance): +Foundry's [documentation](https://book.getfoundry.sh/guides/best-practices#general-test-guidance): - For tests, use the `.t.sol` postfix (e.g., `MyContract.t.sol`). - For scripts, use the `.s.sol` postfix (e.g., `MyScript.s.sol`). From da3a8318596b901840f5742a75203f59aad52c30 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Fri, 21 Feb 2025 12:05:50 +0000 Subject: [PATCH 152/184] chore: cleanup --- contracts/0.8.25/vaults/VaultHub.sol | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 755532703..0e3bdcd0e 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -249,19 +249,20 @@ abstract contract VaultHub is PausableUntilWithRoles { uint256 shareLimit = socket.shareLimit; if (vaultSharesAfterMint > shareLimit) revert ShareLimitExceeded(_vault, shareLimit); + IStakingVault vault = IStakingVault(_vault); uint256 reserveRatioBP = socket.reserveRatioBP; - uint256 maxMintableShares = _maxMintableShares(_vault, reserveRatioBP, shareLimit); - - if (vaultSharesAfterMint > maxMintableShares) { - revert InsufficientValuationToMint(_vault, IStakingVault(_vault).valuation()); + uint256 valuation = vault.valuation(); + uint256 stETHCapacity = (valuation * (TOTAL_BASIS_POINTS - socket.reserveRatioBP)) / TOTAL_BASIS_POINTS; + uint256 sharesCapacity = Math256.min(STETH.getSharesByPooledEth(stETHCapacity), socket.shareLimit); + if (vaultSharesAfterMint > sharesCapacity) { + revert InsufficientValuationToMint(_vault, valuation); } socket.sharesMinted = uint96(vaultSharesAfterMint); - uint256 totalEtherLocked = (STETH.getPooledEthByShares(vaultSharesAfterMint) * TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - reserveRatioBP); - if (totalEtherLocked > IStakingVault(_vault).locked()) { + if (totalEtherLocked > vault.locked()) { IStakingVault(_vault).lock(totalEtherLocked); } @@ -524,15 +525,6 @@ abstract contract VaultHub is PausableUntilWithRoles { return $.sockets[index]; } - /// @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; - - return Math256.min(STETH.getSharesByPooledEth(maxStETHMinted), _shareLimit); - } - function _getVaultHubStorage() private pure returns (VaultHubStorage storage $) { assembly { $.slot := VAULT_HUB_STORAGE_LOCATION From c7e4a5efdd32d13cb9780ae0be27d6c9c1ba1cbb Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Fri, 21 Feb 2025 17:45:39 +0000 Subject: [PATCH 153/184] chore: refactoring --- contracts/0.8.25/vaults/VaultHub.sol | 51 ++++++++++++++-------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 0e3bdcd0e..e4bc907a9 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -135,16 +135,19 @@ abstract contract VaultHub is PausableUntilWithRoles { return $.sockets[$.vaultIndex[_vault]]; } - /// @notice returns the health ratio of the vault + /// @notice checks if the vault is healthy by comparing its valuation against minted shares + /// @dev A vault is considered healthy if it has no shares minted, or if its valuation minus required reserves + /// is sufficient to cover the current value of minted shares. The required reserves are determined by + /// the reserve ratio threshold. /// @param _vault vault address - /// @return health ratio in basis points - function vaultHealthRatio(address _vault) public view returns (uint256) { + /// @return true if vault is healthy, false otherwise + function isHealthy(address _vault) public view returns (bool) { VaultSocket storage socket = _connectedSocket(_vault); - if (socket.sharesMinted == 0) return type(uint256).max; + if (socket.sharesMinted == 0) return true; - uint256 valuation = IStakingVault(_vault).valuation(); - uint256 mintedStETH = STETH.getPooledEthByShares(socket.sharesMinted); - return (valuation * (TOTAL_BASIS_POINTS - socket.reserveRatioThresholdBP)) / mintedStETH; + return ( + IStakingVault(_vault).valuation() * (TOTAL_BASIS_POINTS - socket.reserveRatioThresholdBP) / TOTAL_BASIS_POINTS + ) >= STETH.getPooledEthBySharesRoundUp(socket.sharesMinted); } /// @notice connects a vault to the hub @@ -249,21 +252,21 @@ abstract contract VaultHub is PausableUntilWithRoles { uint256 shareLimit = socket.shareLimit; if (vaultSharesAfterMint > shareLimit) revert ShareLimitExceeded(_vault, shareLimit); - IStakingVault vault = IStakingVault(_vault); - uint256 reserveRatioBP = socket.reserveRatioBP; - uint256 valuation = vault.valuation(); - uint256 stETHCapacity = (valuation * (TOTAL_BASIS_POINTS - socket.reserveRatioBP)) / TOTAL_BASIS_POINTS; - uint256 sharesCapacity = Math256.min(STETH.getSharesByPooledEth(stETHCapacity), socket.shareLimit); - if (vaultSharesAfterMint > sharesCapacity) { - revert InsufficientValuationToMint(_vault, valuation); + IStakingVault vault_ = IStakingVault(_vault); + uint256 mintingBasisPoints = TOTAL_BASIS_POINTS - socket.reserveRatioBP; + uint256 mintingCapacity = (vault_.valuation() * mintingBasisPoints) / TOTAL_BASIS_POINTS; + uint256 ethRequiredForMint = STETH.getPooledEthByShares(vaultSharesAfterMint); + + if (ethRequiredForMint > mintingCapacity) { + revert InsufficientValuationToMint(_vault, vault_.valuation()); } socket.sharesMinted = uint96(vaultSharesAfterMint); - uint256 totalEtherLocked = (STETH.getPooledEthByShares(vaultSharesAfterMint) * TOTAL_BASIS_POINTS) / - (TOTAL_BASIS_POINTS - reserveRatioBP); - if (totalEtherLocked > vault.locked()) { - IStakingVault(_vault).lock(totalEtherLocked); + // Calculate the total ETH that needs to be locked in the vault to maintain the reserve ratio + uint256 totalEtherLocked = (ethRequiredForMint * TOTAL_BASIS_POINTS) / mintingBasisPoints; + if (totalEtherLocked > vault_.locked()) { + vault_.lock(totalEtherLocked); } STETH.mintExternalShares(_recipient, _amountOfShares); @@ -306,7 +309,7 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @dev permissionless if the vault's min reserve ratio is broken function forceRebalance(address _vault) external { if (_vault == address(0)) revert ZeroArgument("_vault"); - _onlyUnhealthy(_vault); + _requireUnhealthy(_vault); VaultSocket storage socket = _connectedSocket(_vault); @@ -368,8 +371,7 @@ abstract contract VaultHub is PausableUntilWithRoles { if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); if (_refundRecepient == address(0)) revert ZeroArgument("_refundRecepient"); if (_pubkeys.length % PUBLIC_KEY_LENGTH != 0) revert InvalidPubkeysLength(); - - _onlyUnhealthy(_vault); + _requireUnhealthy(_vault); uint256 numValidators = _pubkeys.length / PUBLIC_KEY_LENGTH; uint64[] memory amounts = new uint64[](numValidators); @@ -539,9 +541,8 @@ abstract contract VaultHub is PausableUntilWithRoles { } } - function _onlyUnhealthy(address _vault) internal view { - uint256 healthRatio = vaultHealthRatio(_vault); - if (healthRatio >= TOTAL_BASIS_POINTS) revert AlreadyHealthy(_vault, healthRatio); + function _requireUnhealthy(address _vault) internal view { + if (isHealthy(_vault)) revert AlreadyHealthy(_vault); } event VaultConnected(address indexed vault, uint256 capShares, uint256 minReserveRatio, uint256 reserveRatioThreshold, uint256 treasuryFeeBP); @@ -554,7 +555,7 @@ abstract contract VaultHub is PausableUntilWithRoles { event ForceValidatorExitTriggered(address indexed vault, bytes pubkeys, address refundRecepient); error StETHMintFailed(address vault); - error AlreadyHealthy(address vault, uint256 healthRatio); + error AlreadyHealthy(address vault); error InsufficientSharesToBurn(address vault, uint256 amount); error ShareLimitExceeded(address vault, uint256 capShares); error AlreadyConnected(address vault, uint256 index); From 145d4efb219c18340fe26faa6919db530f9b255b Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Fri, 21 Feb 2025 17:45:56 +0000 Subject: [PATCH 154/184] test: vaulthub healthy --- .../vaulthub/vaulthub.forceExit.test.ts | 6 +- .../vaults/vaulthub/vaulthub.hub.test.ts | 237 +++++++++--------- 2 files changed, 125 insertions(+), 118 deletions(-) diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts index 67193ce3d..6f3e93735 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts @@ -12,7 +12,7 @@ import { VaultHub__Harness, } from "typechain-types"; -import { impersonate, MAX_UINT256 } from "lib"; +import { impersonate } from "lib"; import { findEvents } from "lib/event"; import { ether } from "lib/units"; @@ -159,7 +159,7 @@ describe("VaultHub.sol:forceExit", () => { it("reverts if called for a healthy vault", async () => { await expect(vaultHub.forceValidatorExit(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "AlreadyHealthy") - .withArgs(vaultAddress, MAX_UINT256); + .withArgs(vaultAddress); }); context("unhealthy vault", () => { @@ -223,7 +223,7 @@ describe("VaultHub.sol:forceExit", () => { await demoVault.report(valuation - penalty, valuation, rebase.lockedEther[1]); - expect(await vaultHub.vaultHealthRatio(demoVault)).to.be.lt(TOTAL_BASIS_POINTS); // < 100% + expect(await vaultHub.isHealthy(demoVaultAddress)).to.be.false; await expect(vaultHub.forceValidatorExit(demoVaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: FEE })) .to.emit(vaultHub, "ForceValidatorExitTriggered") 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 b0b33ffc2..740aa860b 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts @@ -14,7 +14,7 @@ import { VaultHub, } from "typechain-types"; -import { BigIntMath, ether, findEvents, MAX_UINT256, randomAddress } from "lib"; +import { BigIntMath, ether, findEvents, impersonate, randomAddress } from "lib"; import { deployLidoDao, updateLidoLocatorImplementation } from "test/deploy"; import { Snapshot, Tracing, VAULTS_RELATIVE_SHARE_LIMIT_BP, ZERO_HASH } from "test/suite"; @@ -35,6 +35,7 @@ describe("VaultHub.sol:hub", () => { let deployer: HardhatEthersSigner; let user: HardhatEthersSigner; let stranger: HardhatEthersSigner; + let whale: HardhatEthersSigner; let locator: LidoLocator; let vaultHub: VaultHub; @@ -84,7 +85,7 @@ describe("VaultHub.sol:hub", () => { } before(async () => { - [deployer, user, stranger] = await ethers.getSigners(); + [deployer, user, stranger, whale] = await ethers.getSigners(); ({ lido, acl } = await deployLidoDao({ rootAccount: deployer, initialized: true })); locator = await ethers.getContractAt("LidoLocator", await lido.getLidoLocator(), deployer); @@ -95,7 +96,7 @@ describe("VaultHub.sol:hub", () => { await lido.connect(user).resume(); await lido.connect(user).setMaxExternalRatioBP(TOTAL_BASIS_POINTS); - await lido.submit(deployer, { value: ether("1000.0") }); + await lido.connect(whale).submit(deployer, { value: ether("1000.0") }); depositContract = await ethers.deployContract("DepositContract__MockForVaultHub"); @@ -250,131 +251,137 @@ describe("VaultHub.sol:hub", () => { }); }); - context("vaultHealthRatio", () => { - before(() => Tracing.enable()); - + context("isHealthy", () => { it("reverts if vault is not connected", async () => { - await expect(vaultHub.vaultHealthRatio(randomAddress())).to.be.revertedWithCustomError( - vaultHub, - "NotConnectedToHub", - ); + await expect(vaultHub.isHealthy(randomAddress())).to.be.revertedWithCustomError(vaultHub, "NotConnectedToHub"); }); - it("returns the MAX_UINT256 if the vault has no shares minted", async () => { + it("returns true if the vault has no shares minted", async () => { const vault = await createAndConnectVault(vaultFactory); const vaultAddress = await vault.getAddress(); await vault.fund({ value: ether("1") }); - expect(await vaultHub.vaultHealthRatio(vaultAddress)).to.equal(MAX_UINT256); - }); - - context("health ratio calculations", () => { - const marks = [10_00n, 50_00n, 100_00n]; // 10%, 50%, 100% LTV - const runs = [ - { - reserveRatio: 50_00n, // 50% - reserveRatioThreshold: 40_00n, // 40% - valuation: ether(Math.floor(Math.random() * 100 + 1).toString()), - }, - { - reserveRatio: 10_00n, // 10% - reserveRatioThreshold: 8_00n, // 8% - valuation: ether(Math.floor(Math.random() * 100 + 1).toString()), - }, - { - reserveRatio: 2_00n, // 2% - reserveRatioThreshold: 1_00n, // 1% - valuation: ether(Math.floor(Math.random() * 100 + 1).toString()), - }, - ]; - - for (const run of runs) { - for (const ltvRatio of marks) { - const cap = (run.valuation * (TOTAL_BASIS_POINTS - run.reserveRatio)) / TOTAL_BASIS_POINTS; - const mintedStETH = (cap * ltvRatio) / TOTAL_BASIS_POINTS; - const expectedHealthRatio = (run.valuation * (TOTAL_BASIS_POINTS - run.reserveRatioThreshold)) / mintedStETH; - const title = - `${ethers.formatEther(mintedStETH)}/${ethers.formatEther(run.valuation)} ETH vault (` + - `RR: ${run.reserveRatio / 100n}%, ` + - `RRT: ${run.reserveRatioThreshold / 100n}%) => ` + - `${expectedHealthRatio / 100n}%`; - - it(`calculates health ratio correctly for ${title}`, async () => { - const vault = await createAndConnectVault(vaultFactory, { - shareLimit: ether("100"), - reserveRatioBP: run.reserveRatio, - reserveRatioThresholdBP: run.reserveRatioThreshold, - }); - - await vault.fund({ value: run.valuation }); - expect(await vault.valuation()).to.equal(run.valuation); - - const sharesToMint = await lido.getSharesByPooledEth(mintedStETH); - await vaultHub.mintShares(await vault.getAddress(), user, sharesToMint); - - const healthRatio = await vaultHub.vaultHealthRatio(await vault.getAddress()); - expect(healthRatio).to.equal(expectedHealthRatio); - }); - } - } + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); }); - context("health ratio calculations for unhealthy vaults", () => { - const runs = [ - { - reserveRatio: 50_00n, // 50% - reserveRatioThreshold: 40_00n, // 40% - valuation: ether(Math.floor(Math.random() * 100 + 1).toString()), - }, - { - reserveRatio: 10_00n, // 10% - reserveRatioThreshold: 8_00n, // 8% - valuation: ether(Math.floor(Math.random() * 100 + 1).toString()), - }, - { - reserveRatio: 2_00n, // 2% - reserveRatioThreshold: 1_00n, // 1% - valuation: ether(Math.floor(Math.random() * 100 + 1).toString()), - }, - ]; - - for (const run of runs) { - const drops = [1n, run.reserveRatioThreshold, run.reserveRatio]; - - for (const drop of drops) { - const cap = (run.valuation * (TOTAL_BASIS_POINTS - run.reserveRatio)) / TOTAL_BASIS_POINTS; - const slashedStETH = (run.valuation * drop) / TOTAL_BASIS_POINTS; - const expectedHealthRatio = - ((run.valuation - slashedStETH) * (TOTAL_BASIS_POINTS - run.reserveRatioThreshold)) / cap; - - const title = - `${ethers.formatEther(cap)}/${ethers.formatEther(run.valuation - slashedStETH)} ETH vault (` + - `RR: ${run.reserveRatio / 100n}%, ` + - `RRT: ${run.reserveRatioThreshold / 100n}%) => ` + - `${expectedHealthRatio / 100n}%`; - - it(`calculates health ratio correctly for ${title}`, async () => { - const vault = await createAndConnectVault(vaultFactory, { - shareLimit: ether("100"), - reserveRatioBP: run.reserveRatio, - reserveRatioThresholdBP: run.reserveRatioThreshold, - }); - - await vault.fund({ value: run.valuation }); - expect(await vault.valuation()).to.equal(run.valuation); - - const sharesToMint = await lido.getSharesByPooledEth(cap); - await vaultHub.mintShares(await vault.getAddress(), user, sharesToMint); - - await vault.report(run.valuation - slashedStETH, run.valuation, BigIntMath.max(cap, ether("1"))); - - const healthRatio = await vaultHub.vaultHealthRatio(await vault.getAddress()); - expect(healthRatio).to.equal(expectedHealthRatio); - }); + // Looks like fuzzing but it's not [:} + it("returns correct value for various parameters", async () => { + const tbi = (n: number | bigint, min: number = 0) => BigInt(Math.floor(Math.random() * Number(n)) + min); + + for (let i = 0; i < 50; i++) { + const snapshot = await Snapshot.take(); + const reserveRatioThresholdBP = tbi(10000); + const reserveRatioBP = BigIntMath.min(reserveRatioThresholdBP + tbi(1000), TOTAL_BASIS_POINTS); + + const valuationEth = tbi(100); + const valuation = ether(valuationEth.toString()); + + const mintable = (valuation * (TOTAL_BASIS_POINTS - reserveRatioBP)) / TOTAL_BASIS_POINTS; + + const isSlashing = Math.random() < 0.5; + const slashed = isSlashing ? ether(tbi(valuationEth).toString()) : 0n; + const treashold = ((valuation - slashed) * (TOTAL_BASIS_POINTS - reserveRatioThresholdBP)) / TOTAL_BASIS_POINTS; + const expectedHealthy = treashold >= mintable; + + const vault = await createAndConnectVault(vaultFactory, { + shareLimit: ether("100"), // just to bypass the share limit check + reserveRatioBP: reserveRatioBP, + reserveRatioThresholdBP: reserveRatioThresholdBP, + }); + + const vaultAddress = await vault.getAddress(); + + await vault.fund({ value: valuation }); + + if (mintable > 0n) { + const sharesToMint = await lido.getSharesByPooledEth(mintable); + await vaultHub.connect(user).mintShares(vaultAddress, user, sharesToMint); + } + + await vault.report(valuation - slashed, valuation, BigIntMath.max(mintable, ether("1"))); + + const actualHealthy = await vaultHub.isHealthy(vaultAddress); + try { + expect(actualHealthy).to.equal(expectedHealthy); + } catch (error) { + console.log(`Test failed with parameters: + Reserve Ratio Threshold: ${Number(reserveRatioThresholdBP) / 100}% + Reserve Ratio: ${Number(reserveRatioBP) / 100}% + Valuation: ${ethers.formatEther(valuation)} ETH + Minted: ${ethers.formatEther(mintable)} stETH + Slashed: ${ethers.formatEther(slashed)} ETH + Threshold: ${ethers.formatEther(treashold)} stETH + Expected Healthy: ${expectedHealthy} + `); + throw error; } + + await Snapshot.restore(snapshot); } }); + + it("returns correct value close to the threshold border cases", async () => { + const vault = await createAndConnectVault(vaultFactory, { + shareLimit: ether("100"), // just to bypass the share limit check + reserveRatioBP: 50_00n, // 50% + reserveRatioThresholdBP: 50_00n, // 50% + }); + + const vaultAddress = await vault.getAddress(); + + await vault.fund({ value: ether("1") }); + await vaultHub.connect(user).mintShares(vaultAddress, user, ether("0.25")); + + await vault.report(ether("1"), ether("1"), ether("1")); // normal report + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + + await vault.report(ether("0.5") + 1n, ether("1"), ether("1")); // above the threshold + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + + await vault.report(ether("0.5"), ether("1"), ether("1")); // at the threshold + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + + await vault.report(ether("0.5") - 1n, ether("1"), ether("1")); // below the threshold + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); + }); + + it("returns correct value for different share rates", async () => { + Tracing.enable(); + const vault = await createAndConnectVault(vaultFactory, { + shareLimit: ether("100"), // just to bypass the share limit check + reserveRatioBP: 50_00n, // 50% + reserveRatioThresholdBP: 50_00n, // 50% + }); + + const vaultAddress = await vault.getAddress(); + + await vault.fund({ value: ether("1") }); + const mintingEth = ether("0.5"); + const sharesToMint = await lido.getSharesByPooledEth(mintingEth); + await vaultHub.connect(user).mintShares(vaultAddress, user, sharesToMint); + + // Burn some shares to make share rate fractional + const burner = await impersonate(await locator.burner(), ether("1")); + await lido.connect(whale).transfer(burner, ether("100")); + await lido.connect(burner).burnShares(ether("100")); + + await vault.report(ether("1"), ether("1"), ether("1")); // normal report + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); // old valuation is not enough + + const mintedEthChange = await lido.getPooledEthBySharesRoundUp(sharesToMint); + const diff = mintedEthChange - mintingEth; + const report = ether("1") + diff * 2n; // 2x because the 50% reserve ratio + + await vault.report(report - 1n, ether("1"), ether("1")); // below the threshold + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); + + await vault.report(report, ether("1"), ether("1")); // at the threshold + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + + await vault.report(report + 1n, ether("1"), ether("1")); // above the threshold + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + }); }); context("connectVault", () => { From 91eb54cda15e4189eb2ab8afd67fe0d86a6fb6cb Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Mon, 24 Feb 2025 13:18:20 +0000 Subject: [PATCH 155/184] feat: small refactoring --- contracts/0.8.25/vaults/VaultHub.sol | 11 ++- .../vaults/vaulthub/vaulthub.hub.test.ts | 88 ++++++++++++++++--- 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index e4bc907a9..20befc96e 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -253,18 +253,17 @@ abstract contract VaultHub is PausableUntilWithRoles { if (vaultSharesAfterMint > shareLimit) revert ShareLimitExceeded(_vault, shareLimit); IStakingVault vault_ = IStakingVault(_vault); - uint256 mintingBasisPoints = TOTAL_BASIS_POINTS - socket.reserveRatioBP; - uint256 mintingCapacity = (vault_.valuation() * mintingBasisPoints) / TOTAL_BASIS_POINTS; - uint256 ethRequiredForMint = STETH.getPooledEthByShares(vaultSharesAfterMint); - - if (ethRequiredForMint > mintingCapacity) { + uint256 maxMintableRatioBP = TOTAL_BASIS_POINTS - socket.reserveRatioBP; + uint256 maxMintableEther = (vault_.valuation() * maxMintableRatioBP) / TOTAL_BASIS_POINTS; + uint256 etherToLock = STETH.getPooledEthBySharesRoundUp(vaultSharesAfterMint); + if (etherToLock > maxMintableEther) { revert InsufficientValuationToMint(_vault, vault_.valuation()); } socket.sharesMinted = uint96(vaultSharesAfterMint); // Calculate the total ETH that needs to be locked in the vault to maintain the reserve ratio - uint256 totalEtherLocked = (ethRequiredForMint * TOTAL_BASIS_POINTS) / mintingBasisPoints; + uint256 totalEtherLocked = (etherToLock * TOTAL_BASIS_POINTS) / maxMintableRatioBP; if (totalEtherLocked > vault_.locked()) { vault_.lock(totalEtherLocked); } 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 740aa860b..1ea075cd3 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts @@ -17,7 +17,7 @@ import { import { BigIntMath, ether, findEvents, impersonate, randomAddress } from "lib"; import { deployLidoDao, updateLidoLocatorImplementation } from "test/deploy"; -import { Snapshot, Tracing, VAULTS_RELATIVE_SHARE_LIMIT_BP, ZERO_HASH } from "test/suite"; +import { Snapshot, VAULTS_RELATIVE_SHARE_LIMIT_BP, ZERO_HASH } from "test/suite"; const ZERO_BYTES32 = "0x" + Buffer.from(ZERO_HASH).toString("hex"); @@ -306,12 +306,12 @@ describe("VaultHub.sol:hub", () => { expect(actualHealthy).to.equal(expectedHealthy); } catch (error) { console.log(`Test failed with parameters: - Reserve Ratio Threshold: ${Number(reserveRatioThresholdBP) / 100}% - Reserve Ratio: ${Number(reserveRatioBP) / 100}% - Valuation: ${ethers.formatEther(valuation)} ETH - Minted: ${ethers.formatEther(mintable)} stETH - Slashed: ${ethers.formatEther(slashed)} ETH - Threshold: ${ethers.formatEther(treashold)} stETH + Reserve Ratio Threshold: ${reserveRatioThresholdBP} + Reserve Ratio: ${reserveRatioBP} + Valuation: ${valuation} ETH + Minted: ${mintable} stETH + Slashed: ${slashed} ETH + Threshold: ${treashold} stETH Expected Healthy: ${expectedHealthy} `); throw error; @@ -347,7 +347,6 @@ describe("VaultHub.sol:hub", () => { }); it("returns correct value for different share rates", async () => { - Tracing.enable(); const vault = await createAndConnectVault(vaultFactory, { shareLimit: ether("100"), // just to bypass the share limit check reserveRatioBP: 50_00n, // 50% @@ -361,6 +360,9 @@ describe("VaultHub.sol:hub", () => { const sharesToMint = await lido.getSharesByPooledEth(mintingEth); await vaultHub.connect(user).mintShares(vaultAddress, user, sharesToMint); + await vault.report(ether("1"), ether("1"), ether("1")); // normal report + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); // valuation is enough + // Burn some shares to make share rate fractional const burner = await impersonate(await locator.burner(), ether("1")); await lido.connect(whale).transfer(burner, ether("100")); @@ -369,9 +371,9 @@ describe("VaultHub.sol:hub", () => { await vault.report(ether("1"), ether("1"), ether("1")); // normal report expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); // old valuation is not enough - const mintedEthChange = await lido.getPooledEthBySharesRoundUp(sharesToMint); - const diff = mintedEthChange - mintingEth; - const report = ether("1") + diff * 2n; // 2x because the 50% reserve ratio + const lockedEth = await lido.getPooledEthBySharesRoundUp(sharesToMint); + // For 50% reserve ratio, we need valuation to be 2x of locked ETH to be healthy + const report = lockedEth * 2n; await vault.report(report - 1n, ether("1"), ether("1")); // below the threshold expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); @@ -382,6 +384,70 @@ describe("VaultHub.sol:hub", () => { await vault.report(report + 1n, ether("1"), ether("1")); // above the threshold expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); }); + + it("returns correct value for smallest possible reserve ratio", async () => { + const vault = await createAndConnectVault(vaultFactory, { + shareLimit: ether("100"), // just to bypass the share limit check + reserveRatioBP: 1n, // 0.01% + reserveRatioThresholdBP: 1n, // 0.01% + }); + + const vaultAddress = await vault.getAddress(); + + await vault.fund({ value: ether("1") }); + + const mintingEth = ether("0.9999"); // 99.99% of the valuation + const sharesToMint = await lido.getSharesByPooledEth(mintingEth); + await vaultHub.connect(user).mintShares(vaultAddress, user, sharesToMint); + + await vault.report(ether("1"), ether("1"), ether("1")); // normal report + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); // valuation is enough + + // Burn some shares to make share rate fractional + const burner = await impersonate(await locator.burner(), ether("1")); + await lido.connect(whale).transfer(burner, ether("100")); + await lido.connect(burner).burnShares(ether("100")); + + const lockedEth = await lido.getPooledEthBySharesRoundUp(sharesToMint); + // if lockedEth is 99.99% of the valuation we need to report 100.00% of the valuation to be healthy + const report = (lockedEth * 10000n) / 9999n; + + await vault.report(report - 1n, ether("1"), ether("1")); // below the threshold + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); + + await vault.report(report, ether("1"), ether("1")); // at the threshold + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); // XXX: rounding issue, should be true + + await vault.report(report + 1n, ether("1"), ether("1")); // above the threshold + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + }); + + it("returns correct value for minimal shares amounts", async () => { + const vault = await createAndConnectVault(vaultFactory, { + shareLimit: ether("100"), + reserveRatioBP: 50_00n, // 50% + reserveRatioThresholdBP: 50_00n, // 50% + }); + + const vaultAddress = await vault.getAddress(); + + await vault.fund({ value: ether("1") }); + await vaultHub.connect(user).mintShares(vaultAddress, user, 1n); + + await vault.report(ether("1"), ether("1"), ether("1")); + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + + await vault.report(2n, ether("1"), ether("1")); // Minimal valuation to be healthy with 1 share (50% reserve ratio) + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + + await vault.report(1n, ether("1"), ether("1")); // Below minimal required valuation + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); + + await lido.connect(user).transferShares(await locator.accounting(), 1n); + await vaultHub.connect(user).burnShares(vaultAddress, 1n); + + expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); // Should be healthy with no shares + }); }); context("connectVault", () => { From b4c79b613d0a8eb9bf25b5137ae556a6861dbdd2 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Mon, 24 Feb 2025 13:37:22 +0000 Subject: [PATCH 156/184] chore: better naming --- contracts/0.8.25/vaults/VaultHub.sol | 4 +- .../vaulthub/vaulthub.forceExit.test.ts | 2 +- .../vaults/vaulthub/vaulthub.hub.test.ts | 45 ++++++++++--------- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 20befc96e..079b7c195 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -141,7 +141,7 @@ abstract contract VaultHub is PausableUntilWithRoles { /// the reserve ratio threshold. /// @param _vault vault address /// @return true if vault is healthy, false otherwise - function isHealthy(address _vault) public view returns (bool) { + function isVaultHealthy(address _vault) public view returns (bool) { VaultSocket storage socket = _connectedSocket(_vault); if (socket.sharesMinted == 0) return true; @@ -541,7 +541,7 @@ abstract contract VaultHub is PausableUntilWithRoles { } function _requireUnhealthy(address _vault) internal view { - if (isHealthy(_vault)) revert AlreadyHealthy(_vault); + if (isVaultHealthy(_vault)) revert AlreadyHealthy(_vault); } event VaultConnected(address indexed vault, uint256 capShares, uint256 minReserveRatio, uint256 reserveRatioThreshold, uint256 treasuryFeeBP); diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts index 6f3e93735..70031f158 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts @@ -223,7 +223,7 @@ describe("VaultHub.sol:forceExit", () => { await demoVault.report(valuation - penalty, valuation, rebase.lockedEther[1]); - expect(await vaultHub.isHealthy(demoVaultAddress)).to.be.false; + expect(await vaultHub.isVaultHealthy(demoVaultAddress)).to.be.false; await expect(vaultHub.forceValidatorExit(demoVaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: FEE })) .to.emit(vaultHub, "ForceValidatorExitTriggered") 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 1ea075cd3..f995ab26a 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts @@ -251,9 +251,12 @@ describe("VaultHub.sol:hub", () => { }); }); - context("isHealthy", () => { + context("isVaultHealthy", () => { it("reverts if vault is not connected", async () => { - await expect(vaultHub.isHealthy(randomAddress())).to.be.revertedWithCustomError(vaultHub, "NotConnectedToHub"); + await expect(vaultHub.isVaultHealthy(randomAddress())).to.be.revertedWithCustomError( + vaultHub, + "NotConnectedToHub", + ); }); it("returns true if the vault has no shares minted", async () => { @@ -262,7 +265,7 @@ describe("VaultHub.sol:hub", () => { await vault.fund({ value: ether("1") }); - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true); }); // Looks like fuzzing but it's not [:} @@ -301,7 +304,7 @@ describe("VaultHub.sol:hub", () => { await vault.report(valuation - slashed, valuation, BigIntMath.max(mintable, ether("1"))); - const actualHealthy = await vaultHub.isHealthy(vaultAddress); + const actualHealthy = await vaultHub.isVaultHealthy(vaultAddress); try { expect(actualHealthy).to.equal(expectedHealthy); } catch (error) { @@ -334,16 +337,16 @@ describe("VaultHub.sol:hub", () => { await vaultHub.connect(user).mintShares(vaultAddress, user, ether("0.25")); await vault.report(ether("1"), ether("1"), ether("1")); // normal report - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true); await vault.report(ether("0.5") + 1n, ether("1"), ether("1")); // above the threshold - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true); await vault.report(ether("0.5"), ether("1"), ether("1")); // at the threshold - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true); await vault.report(ether("0.5") - 1n, ether("1"), ether("1")); // below the threshold - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(false); }); it("returns correct value for different share rates", async () => { @@ -361,7 +364,7 @@ describe("VaultHub.sol:hub", () => { await vaultHub.connect(user).mintShares(vaultAddress, user, sharesToMint); await vault.report(ether("1"), ether("1"), ether("1")); // normal report - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); // valuation is enough + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true); // valuation is enough // Burn some shares to make share rate fractional const burner = await impersonate(await locator.burner(), ether("1")); @@ -369,20 +372,20 @@ describe("VaultHub.sol:hub", () => { await lido.connect(burner).burnShares(ether("100")); await vault.report(ether("1"), ether("1"), ether("1")); // normal report - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); // old valuation is not enough + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(false); // old valuation is not enough const lockedEth = await lido.getPooledEthBySharesRoundUp(sharesToMint); // For 50% reserve ratio, we need valuation to be 2x of locked ETH to be healthy const report = lockedEth * 2n; await vault.report(report - 1n, ether("1"), ether("1")); // below the threshold - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(false); await vault.report(report, ether("1"), ether("1")); // at the threshold - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true); await vault.report(report + 1n, ether("1"), ether("1")); // above the threshold - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true); }); it("returns correct value for smallest possible reserve ratio", async () => { @@ -401,7 +404,7 @@ describe("VaultHub.sol:hub", () => { await vaultHub.connect(user).mintShares(vaultAddress, user, sharesToMint); await vault.report(ether("1"), ether("1"), ether("1")); // normal report - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); // valuation is enough + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true); // valuation is enough // Burn some shares to make share rate fractional const burner = await impersonate(await locator.burner(), ether("1")); @@ -413,13 +416,13 @@ describe("VaultHub.sol:hub", () => { const report = (lockedEth * 10000n) / 9999n; await vault.report(report - 1n, ether("1"), ether("1")); // below the threshold - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(false); await vault.report(report, ether("1"), ether("1")); // at the threshold - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); // XXX: rounding issue, should be true + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(false); // XXX: rounding issue, should be true await vault.report(report + 1n, ether("1"), ether("1")); // above the threshold - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true); }); it("returns correct value for minimal shares amounts", async () => { @@ -435,18 +438,18 @@ describe("VaultHub.sol:hub", () => { await vaultHub.connect(user).mintShares(vaultAddress, user, 1n); await vault.report(ether("1"), ether("1"), ether("1")); - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true); await vault.report(2n, ether("1"), ether("1")); // Minimal valuation to be healthy with 1 share (50% reserve ratio) - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true); await vault.report(1n, ether("1"), ether("1")); // Below minimal required valuation - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(false); + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(false); await lido.connect(user).transferShares(await locator.accounting(), 1n); await vaultHub.connect(user).burnShares(vaultAddress, 1n); - expect(await vaultHub.isHealthy(vaultAddress)).to.equal(true); // Should be healthy with no shares + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true); // Should be healthy with no shares }); }); From 77e12d597c8f2c4fbe87170627abe83154bd1227 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Mon, 24 Feb 2025 16:44:29 +0000 Subject: [PATCH 157/184] fix: forking URL interference with unit tests --- .env.example | 2 +- CONTRIBUTING.md | 2 +- globals.d.ts | 9 ++++++--- hardhat.helpers.ts | 23 +++++++++++++++-------- lib/protocol/context.ts | 8 +++++--- package.json | 12 ++++++------ 6 files changed, 34 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index f21c1c63a..f61f0a1ae 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,7 @@ MAINNET_RPC_URL=http://localhost:8545 # RPC URL for Hardhat Network forking, required for running tests on mainnet fork with tracing (Infura, Alchemy, etc.) # https://hardhat.org/hardhat-network/docs/guides/forking-other-networks#forking-other-networks -HARDHAT_FORKING_URL=https://eth.drpc.org +FORK_RPC_URL=https://eth.drpc.org # https://docs.lido.fi/deployed-contracts MAINNET_LOCATOR_ADDRESS=0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a93d16690..9be9d39b6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -327,7 +327,7 @@ This is the most common method for running integration tests. It uses an instanc mainnet environment, allowing you to run integration tests with trace logging. > [!NOTE] -> Ensure that `HARDHAT_FORKING_URL` is set to Ethereum Mainnet RPC and `MAINNET_*` environment variables are set in the +> Ensure that `FORK_RPC_URL` is set to Ethereum Mainnet RPC and `MAINNET_*` environment variables are set in the > `.env` file (refer to `.env.example` for guidance). Otherwise, the tests will run against the Scratch deployment. ```bash diff --git a/globals.d.ts b/globals.d.ts index 77a941088..8d6f719ea 100644 --- a/globals.d.ts +++ b/globals.d.ts @@ -1,14 +1,17 @@ declare namespace NodeJS { export interface ProcessEnv { - /* iternal logging verbosity (used in scratch deploy / integration tests) */ + /* internal logging verbosity (used in scratch deploy / integration tests) */ LOG_LEVEL?: "all" | "debug" | "info" | "warn" | "error" | "none"; // default: "info" /** * Flags for changing the behavior of the Hardhat Network */ - /* RPC URL for Hardhat Network forking, required for running tests on mainnet fork with tracing */ - HARDHAT_FORKING_URL?: string; + /* Test execution mode: 'scratch' for fresh network, 'fork' for forked network */ + MODE?: "scratch" | "forking"; // default: "scratch" + + /* URL of the network to fork from */ + FORK_RPC_URL?: string; // default: "https://eth.drpc.org" /** * Flags for changing the behavior of the integration tests diff --git a/hardhat.helpers.ts b/hardhat.helpers.ts index 47f6533b8..eaa0cd4d0 100644 --- a/hardhat.helpers.ts +++ b/hardhat.helpers.ts @@ -2,16 +2,23 @@ import { existsSync, readFileSync } from "node:fs"; /* Determines the forking configuration for Hardhat */ export function getHardhatForkingConfig() { - const forkingUrl = process.env.HARDHAT_FORKING_URL || ""; + const mode = process.env.MODE || "scratch"; - if (!forkingUrl) { - // Scratch deploy, need to disable CSM - process.env.INTEGRATION_ON_SCRATCH = "on"; - process.env.INTEGRATION_WITH_CSM = "off"; - return undefined; - } + switch (mode) { + case "scratch": + process.env.INTEGRATION_ON_SCRATCH = "on"; + process.env.INTEGRATION_WITH_CSM = "off"; + return undefined; + + case "forking": + if (!process.env.FORK_RPC_URL) { + throw new Error("FORK_RPC_URL must be set when MODE=forking"); + } + return { url: process.env.FORK_RPC_URL }; - return { url: forkingUrl }; + default: + throw new Error("MODE must be either 'scratch' or 'forking'"); + } } // TODO: this plaintext accounts.json private keys management is a subject diff --git a/lib/protocol/context.ts b/lib/protocol/context.ts index f6151f803..4df02b732 100644 --- a/lib/protocol/context.ts +++ b/lib/protocol/context.ts @@ -4,7 +4,6 @@ import hre from "hardhat"; import { deployScratchProtocol, deployUpgrade, ether, findEventsWithInterfaces, impersonate, log } from "lib"; import { discover } from "./discover"; -import { isNonForkingHardhatNetwork } from "./networks"; import { provision } from "./provision"; import { ProtocolContext, ProtocolContextFlags, ProtocolSigners, Signer } from "./types"; @@ -14,8 +13,11 @@ const getSigner = async (signer: Signer, balance = ether("100"), signers: Protoc }; export const getProtocolContext = async (): Promise => { - if (isNonForkingHardhatNetwork()) { - await deployScratchProtocol(hre.network.name); + if (hre.network.name === "hardhat") { + const networkConfig = hre.config.networks[hre.network.name]; + if (!networkConfig.forking?.enabled) { + await deployScratchProtocol(hre.network.name); + } } else { await deployUpgrade(hre.network.name); } diff --git a/package.json b/package.json index d802c899b..4e12747da 100644 --- a/package.json +++ b/package.json @@ -22,12 +22,12 @@ "test:trace": "hardhat test test/**/*.test.ts --trace --disabletracer", "test:fulltrace": "hardhat test test/**/*.test.ts --fulltrace --disabletracer", "test:watch": "hardhat watch", - "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": "MODE=forking hardhat test test/integration/**/*.ts", + "test:integration:trace": "MODE=forking hardhat test test/integration/**/*.ts --trace --disabletracer", + "test:integration:fulltrace": "MODE=forking hardhat test test/integration/**/*.ts --fulltrace --disabletracer", + "test:integration:scratch": "MODE=scratch hardhat test test/integration/**/*.ts", + "test:integration:scratch:trace": "MODE=scratch hardhat test test/integration/**/*.ts --trace --disabletracer", + "test:integration:scratch:fulltrace": "MODE=scratch 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", From 0c30c460f0624619d68fde44265689ea5ba13761 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Mon, 24 Feb 2025 17:02:18 +0000 Subject: [PATCH 158/184] chore: cleanup unused env --- lib/protocol/context.ts | 3 +-- lib/protocol/types.ts | 1 - package.json | 6 +++--- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/protocol/context.ts b/lib/protocol/context.ts index 4df02b732..831c366f2 100644 --- a/lib/protocol/context.ts +++ b/lib/protocol/context.ts @@ -27,12 +27,11 @@ export const getProtocolContext = async (): Promise => { // By default, all flags are "on" const flags = { - onScratch: process.env.INTEGRATION_ON_SCRATCH === "on", withCSM: process.env.INTEGRATION_WITH_CSM !== "off", } as ProtocolContextFlags; log.debug("Protocol context flags", { - "On scratch": flags.onScratch, + "With CSM": flags.withCSM, }); const context = { diff --git a/lib/protocol/types.ts b/lib/protocol/types.ts index f528b7124..ce2223804 100644 --- a/lib/protocol/types.ts +++ b/lib/protocol/types.ts @@ -134,7 +134,6 @@ export type ProtocolSigners = { export type Signer = keyof ProtocolSigners; export type ProtocolContextFlags = { - onScratch: boolean; withCSM: boolean; }; diff --git a/package.json b/package.json index 4e12747da..a0b832875 100644 --- a/package.json +++ b/package.json @@ -28,9 +28,9 @@ "test:integration:scratch": "MODE=scratch hardhat test test/integration/**/*.ts", "test:integration:scratch:trace": "MODE=scratch hardhat test test/integration/**/*.ts --trace --disabletracer", "test:integration:scratch:fulltrace": "MODE=scratch 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", + "test:integration:fork:local": "MODE=scratch hardhat test test/integration/**/*.ts --network local", + "test:integration:fork:mainnet": "MODE=forking hardhat test test/integration/**/*.ts --network mainnet-fork", + "test:integration:fork:mainnet:custom": "MODE=forking hardhat test --network mainnet-fork", "typecheck": "tsc --noEmit", "prepare": "husky", "abis:extract": "hardhat abis:extract", From fe9f6777688ed1007694ac031c13183e08e61d0c Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Mon, 24 Feb 2025 17:08:17 +0000 Subject: [PATCH 159/184] chore: one more cleanup --- globals.d.ts | 3 --- hardhat.helpers.ts | 1 - 2 files changed, 4 deletions(-) diff --git a/globals.d.ts b/globals.d.ts index 2525e0d44..7f4233969 100644 --- a/globals.d.ts +++ b/globals.d.ts @@ -17,9 +17,6 @@ declare namespace NodeJS { * Flags for changing the behavior of the integration tests */ - /* if "on" the integration tests will deploy the contracts to the empty Hardhat Network node using scratch deploy */ - INTEGRATION_ON_SCRATCH?: "on" | "off"; // default: "off" - /* if "on" the integration tests will assume CSM module is present in the StakingRouter, and adjust accordingly */ INTEGRATION_WITH_CSM?: "on" | "off"; // default: "off" diff --git a/hardhat.helpers.ts b/hardhat.helpers.ts index eaa0cd4d0..518ce7a36 100644 --- a/hardhat.helpers.ts +++ b/hardhat.helpers.ts @@ -6,7 +6,6 @@ export function getHardhatForkingConfig() { switch (mode) { case "scratch": - process.env.INTEGRATION_ON_SCRATCH = "on"; process.env.INTEGRATION_WITH_CSM = "off"; return undefined; From 586b0e2c25eebdadda7f72599b5aec0698cf5db9 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 25 Feb 2025 14:26:43 +0000 Subject: [PATCH 160/184] fix: events --- contracts/0.8.25/vaults/StakingVault.sol | 8 +++++--- test/0.8.25/vaults/dashboard/dashboard.test.ts | 4 ++-- test/0.8.25/vaults/permissions/permissions.test.ts | 2 +- test/0.8.25/vaults/staking-vault/stakingVault.test.ts | 6 +++--- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 0078c290f..34310605e 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -453,7 +453,8 @@ 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, string(_pubkeys[i * PUBLIC_KEY_LENGTH : (i + 1) * PUBLIC_KEY_LENGTH])); + bytes memory pubkey = _pubkeys[i * PUBLIC_KEY_LENGTH : (i + 1) * PUBLIC_KEY_LENGTH]; + emit ValidatorExitRequested(msg.sender, pubkey, pubkey); } } @@ -626,10 +627,11 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @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 + * @param _pubkey Indexed public key of the validator to exit + * @param _pubkeyRaw Raw 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, string indexed _pubkey); + event ValidatorExitRequested(address _sender, bytes indexed _pubkey, bytes _pubkeyRaw); /** * @notice Emitted when validator withdrawals are requested via EIP-7002 diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 7d84f665f..6bf64cf27 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -644,9 +644,9 @@ describe("Dashboard.sol", () => { it("signals the requested exit of a validator", async () => { await expect(dashboard.requestValidatorExit(pubkeysConcat)) .to.emit(vault, "ValidatorExitRequested") - .withArgs(dashboard, `0x${pubkeys[0]}`) + .withArgs(dashboard, `0x${pubkeys[0]}`, `0x${pubkeys[0]}`) .to.emit(vault, "ValidatorExitRequested") - .withArgs(dashboard, `0x${pubkeys[1]}`); + .withArgs(dashboard, `0x${pubkeys[1]}`, `0x${pubkeys[1]}`); }); }); diff --git a/test/0.8.25/vaults/permissions/permissions.test.ts b/test/0.8.25/vaults/permissions/permissions.test.ts index b5a997e10..9d2359d2d 100644 --- a/test/0.8.25/vaults/permissions/permissions.test.ts +++ b/test/0.8.25/vaults/permissions/permissions.test.ts @@ -562,7 +562,7 @@ describe("Permissions", () => { const pubkeys = "0x" + "beef".repeat(24); await expect(permissions.connect(exitRequester).requestValidatorExit(pubkeys)) .to.emit(stakingVault, "ValidatorExitRequested") - .withArgs(permissions, pubkeys); + .withArgs(permissions, pubkeys, pubkeys); }); it("reverts if the caller is not a member of the request exit role", async () => { 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 03faa30a1..b30a9c9bc 100644 --- a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts +++ b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts @@ -676,7 +676,7 @@ describe("StakingVault.sol", () => { 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); + .withArgs(vaultOwner, SAMPLE_PUBKEY, SAMPLE_PUBKEY); }); it("emits the exact number of `ValidatorExitRequested` events as the number of validator keys", async () => { @@ -686,9 +686,9 @@ describe("StakingVault.sol", () => { const tx = await stakingVault.connect(vaultOwner).requestValidatorExit(keys.stringified); await expect(tx.wait()) .to.emit(stakingVault, "ValidatorExitRequested") - .withArgs(vaultOwner, keys.pubkeys[0]) + .withArgs(vaultOwner, keys.pubkeys[0], keys.pubkeys[0]) .and.emit(stakingVault, "ValidatorExitRequested") - .withArgs(vaultOwner, keys.pubkeys[1]); + .withArgs(vaultOwner, keys.pubkeys[1], keys.pubkeys[1]); const receipt = (await tx.wait()) as ContractTransactionReceipt; expect(receipt.logs.length).to.equal(numberOfKeys); From 984e948b1bca809057cbb3fd6b817fc2d4f1af18 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 25 Feb 2025 14:39:37 +0000 Subject: [PATCH 161/184] chore: hardhat 2.22.19 --- .../workflows/tests-integration-mainnet.yml | 2 +- .../workflows/tests-integration-scratch.yml | 2 +- package.json | 10 +- yarn.lock | 146 +++++++++--------- 4 files changed, 80 insertions(+), 80 deletions(-) diff --git a/.github/workflows/tests-integration-mainnet.yml b/.github/workflows/tests-integration-mainnet.yml index ec401d90e..6e8de6971 100644 --- a/.github/workflows/tests-integration-mainnet.yml +++ b/.github/workflows/tests-integration-mainnet.yml @@ -16,7 +16,7 @@ jobs: services: hardhat-node: - image: ghcr.io/lidofinance/hardhat-node:2.22.18 + image: ghcr.io/lidofinance/hardhat-node:2.22.19 ports: - 8545:8545 env: diff --git a/.github/workflows/tests-integration-scratch.yml b/.github/workflows/tests-integration-scratch.yml index 317b6ea4a..80d035ada 100644 --- a/.github/workflows/tests-integration-scratch.yml +++ b/.github/workflows/tests-integration-scratch.yml @@ -10,7 +10,7 @@ jobs: services: hardhat-node: - image: ghcr.io/lidofinance/hardhat-node:2.22.18-scratch + image: ghcr.io/lidofinance/hardhat-node:2.22.19-scratch ports: - 8555:8545 diff --git a/package.json b/package.json index 30692ce08..34ea1232e 100644 --- a/package.json +++ b/package.json @@ -56,12 +56,12 @@ "@eslint/js": "9.21.0", "@nomicfoundation/hardhat-chai-matchers": "2.0.8", "@nomicfoundation/hardhat-ethers": "3.0.8", - "@nomicfoundation/hardhat-ignition": "0.15.9", - "@nomicfoundation/hardhat-ignition-ethers": "0.15.9", + "@nomicfoundation/hardhat-ignition": "0.15.10", + "@nomicfoundation/hardhat-ignition-ethers": "0.15.10", "@nomicfoundation/hardhat-network-helpers": "1.0.12", "@nomicfoundation/hardhat-toolbox": "5.0.0", - "@nomicfoundation/hardhat-verify": "2.0.12", - "@nomicfoundation/ignition-core": "0.15.9", + "@nomicfoundation/hardhat-verify": "2.0.13", + "@nomicfoundation/ignition-core": "0.15.10", "@typechain/ethers-v6": "0.5.1", "@typechain/hardhat": "9.1.0", "@types/chai": "4.3.20", @@ -81,7 +81,7 @@ "ethers": "6.13.5", "glob": "11.0.1", "globals": "15.15.0", - "hardhat": "2.22.18", + "hardhat": "2.22.19", "hardhat-contract-sizer": "2.10.0", "hardhat-gas-reporter": "1.0.10", "hardhat-ignore-warnings": "0.2.12", diff --git a/yarn.lock b/yarn.lock index 83f0b95ed..46eb5e150 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1250,67 +1250,67 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/edr-darwin-arm64@npm:0.7.0": - version: 0.7.0 - resolution: "@nomicfoundation/edr-darwin-arm64@npm:0.7.0" - checksum: 10c0/7a643fe1c2a1e907699e0b2469672f9d88510c399bd6ef893e480b601189da6daf654e73537bb811f160a397a28ce1b4fe0e36ba763919ac7ee0922a62d09d51 +"@nomicfoundation/edr-darwin-arm64@npm:0.8.0": + version: 0.8.0 + resolution: "@nomicfoundation/edr-darwin-arm64@npm:0.8.0" + checksum: 10c0/f8bdede09ba5db53f0e55b9fde132c188e09c15faef473675465e0ead97ae0c5c562d820415bb1fe4a46cb29f28cfd2a5bf492229a2f64815f9d000b85e26f84 languageName: node linkType: hard -"@nomicfoundation/edr-darwin-x64@npm:0.7.0": - version: 0.7.0 - resolution: "@nomicfoundation/edr-darwin-x64@npm:0.7.0" - checksum: 10c0/c33a0320fc4f4e27ef6718a678cfc6ff9fe5b03d3fc604cb503a7291e5f9999da1b4e45ebeff77e24031c4dd53e6defecb3a0d475c9f51d60ea6f48e78f74d8e +"@nomicfoundation/edr-darwin-x64@npm:0.8.0": + version: 0.8.0 + resolution: "@nomicfoundation/edr-darwin-x64@npm:0.8.0" + checksum: 10c0/2601d21267d18421f5ded3ca673064bd7ee680fa3340ecfb868ed4b21566eb61f6eed1cc684e3c5df4ade9ec2bc218df19c7e50b8882c17ab2f27fede241881c languageName: node linkType: hard -"@nomicfoundation/edr-linux-arm64-gnu@npm:0.7.0": - version: 0.7.0 - resolution: "@nomicfoundation/edr-linux-arm64-gnu@npm:0.7.0" - checksum: 10c0/8347524cecca3a41ecb6e05581f386ccc6d7e831d4080eca5723724c4307c30ee787a944c70028360cb280a7f61d4967c152ff7b319ccfe08eadf1583a15d018 +"@nomicfoundation/edr-linux-arm64-gnu@npm:0.8.0": + version: 0.8.0 + resolution: "@nomicfoundation/edr-linux-arm64-gnu@npm:0.8.0" + checksum: 10c0/8e20e330d2b812a47ee9634eeab494b2730dee9f4cc663dea543fd905d7fcedae4b9ac60cd62a0f8f13311e43d97d8201872177a997cd7e01bf41b8ebcac355a languageName: node linkType: hard -"@nomicfoundation/edr-linux-arm64-musl@npm:0.7.0": - version: 0.7.0 - resolution: "@nomicfoundation/edr-linux-arm64-musl@npm:0.7.0" - checksum: 10c0/ace6d7691058250341dc0d0a2915c2020cc563ab70627f816e06abca7f0181e93941e5099d4a7ca0e6f8f225caff8be2c6563ad7ab8eeaf9124cb2cc53b9d9ac +"@nomicfoundation/edr-linux-arm64-musl@npm:0.8.0": + version: 0.8.0 + resolution: "@nomicfoundation/edr-linux-arm64-musl@npm:0.8.0" + checksum: 10c0/3065ef7e47e8518fa052fd6f263cd314b0b077248beb79734d35e8896a071313ddf8111a081275fca6d9be3d4c9d709dd643e2aa6b870ba52b85c0dbb255898c languageName: node linkType: hard -"@nomicfoundation/edr-linux-x64-gnu@npm:0.7.0": - version: 0.7.0 - resolution: "@nomicfoundation/edr-linux-x64-gnu@npm:0.7.0" - checksum: 10c0/11a0eb76a628772ec28fe000b3014e83081f216b0f89568eb42f46c1d3d6ee10015d897857f372087e95651aeeea5cf525c161070f2068bd5e4cf3ccdd4b0201 +"@nomicfoundation/edr-linux-x64-gnu@npm:0.8.0": + version: 0.8.0 + resolution: "@nomicfoundation/edr-linux-x64-gnu@npm:0.8.0" + checksum: 10c0/eedbf9b751264dccdcd9817d8b592facf32c6fc7036b8c0736fce8dffba86c32eddde5f3354aa7692224f2e9d1f9b6a594ad16d428887517b8325e4d0982c0ed languageName: node linkType: hard -"@nomicfoundation/edr-linux-x64-musl@npm:0.7.0": - version: 0.7.0 - resolution: "@nomicfoundation/edr-linux-x64-musl@npm:0.7.0" - checksum: 10c0/5559718b3ec00b9f6c9a6cfa6c60540b8f277728482db46183aa907d60f169bc7c8908551b5790c8bad2b0d618ade5ede15b94bdd209660cf1ce707b1fe99fd6 +"@nomicfoundation/edr-linux-x64-musl@npm:0.8.0": + version: 0.8.0 + resolution: "@nomicfoundation/edr-linux-x64-musl@npm:0.8.0" + checksum: 10c0/748e674b95e4b5ef354ea86f712520a3a81d58ff69c03467051a3f3e8c4ba3e830e5581af54be8c4d0c3790565a15c04b9a1efe1a2179d9f9416a5e093f3fbc9 languageName: node linkType: hard -"@nomicfoundation/edr-win32-x64-msvc@npm:0.7.0": - version: 0.7.0 - resolution: "@nomicfoundation/edr-win32-x64-msvc@npm:0.7.0" - checksum: 10c0/19c10fa99245397556bf70971cc7d68544dc4a63ec7cc087fd09b2541729ec57d03166592837394b0fad903fbb20b1428ec67eed29926227155aa5630a249306 +"@nomicfoundation/edr-win32-x64-msvc@npm:0.8.0": + version: 0.8.0 + resolution: "@nomicfoundation/edr-win32-x64-msvc@npm:0.8.0" + checksum: 10c0/0cecbe7093b4f4f4215db4944191a6199105da30edc87427d0eede70b2139b77748664cd3a94d0c87b7658532b8bd5e0b37f3e0f7bc0e894650b16d82b289125 languageName: node linkType: hard -"@nomicfoundation/edr@npm:^0.7.0": - version: 0.7.0 - resolution: "@nomicfoundation/edr@npm:0.7.0" +"@nomicfoundation/edr@npm:^0.8.0": + version: 0.8.0 + resolution: "@nomicfoundation/edr@npm:0.8.0" dependencies: - "@nomicfoundation/edr-darwin-arm64": "npm:0.7.0" - "@nomicfoundation/edr-darwin-x64": "npm:0.7.0" - "@nomicfoundation/edr-linux-arm64-gnu": "npm:0.7.0" - "@nomicfoundation/edr-linux-arm64-musl": "npm:0.7.0" - "@nomicfoundation/edr-linux-x64-gnu": "npm:0.7.0" - "@nomicfoundation/edr-linux-x64-musl": "npm:0.7.0" - "@nomicfoundation/edr-win32-x64-msvc": "npm:0.7.0" - checksum: 10c0/7dc0ae7533a9b57bfdee5275e08d160ff01cba1496cc7341a2782706b40f43e5c448ea0790b47dd1cf2712fa08295f271329109ed2313d9c7ff074ca3ae303e0 + "@nomicfoundation/edr-darwin-arm64": "npm:0.8.0" + "@nomicfoundation/edr-darwin-x64": "npm:0.8.0" + "@nomicfoundation/edr-linux-arm64-gnu": "npm:0.8.0" + "@nomicfoundation/edr-linux-arm64-musl": "npm:0.8.0" + "@nomicfoundation/edr-linux-x64-gnu": "npm:0.8.0" + "@nomicfoundation/edr-linux-x64-musl": "npm:0.8.0" + "@nomicfoundation/edr-win32-x64-msvc": "npm:0.8.0" + checksum: 10c0/da24b58d30b8438739124087e8c13d44e516e1526bfce46d10ea12a25dd527d458f1818f2aa3fcbb75ffc3bdd93e9bba7eb12a77f876002a347a6eb20cd871fa languageName: node linkType: hard @@ -1394,25 +1394,25 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/hardhat-ignition-ethers@npm:0.15.9": - version: 0.15.9 - resolution: "@nomicfoundation/hardhat-ignition-ethers@npm:0.15.9" +"@nomicfoundation/hardhat-ignition-ethers@npm:0.15.10": + version: 0.15.10 + resolution: "@nomicfoundation/hardhat-ignition-ethers@npm:0.15.10" peerDependencies: "@nomicfoundation/hardhat-ethers": ^3.0.4 - "@nomicfoundation/hardhat-ignition": ^0.15.9 - "@nomicfoundation/ignition-core": ^0.15.9 + "@nomicfoundation/hardhat-ignition": ^0.15.10 + "@nomicfoundation/ignition-core": ^0.15.10 ethers: ^6.7.0 hardhat: ^2.18.0 - checksum: 10c0/3e5ebe4b0eeea2ddefeaac3ef8db474399cf9688547ef8e39780cb7af3bbb4fb2db9e73ec665f071bb7203cb667e7a9587c86b94c8bdd6346630a263c57b3056 + checksum: 10c0/bce58dbd0dec9eeb3bf58007febe73cdb5c58424094c029c5aae6e5c3885e919e1ce8b31f97a8ac366c76461c2dca2c5dff1e9c661c58465fc27db4d72903bef languageName: node linkType: hard -"@nomicfoundation/hardhat-ignition@npm:0.15.9": - version: 0.15.9 - resolution: "@nomicfoundation/hardhat-ignition@npm:0.15.9" +"@nomicfoundation/hardhat-ignition@npm:0.15.10": + version: 0.15.10 + resolution: "@nomicfoundation/hardhat-ignition@npm:0.15.10" dependencies: - "@nomicfoundation/ignition-core": "npm:^0.15.9" - "@nomicfoundation/ignition-ui": "npm:^0.15.9" + "@nomicfoundation/ignition-core": "npm:^0.15.10" + "@nomicfoundation/ignition-ui": "npm:^0.15.10" chalk: "npm:^4.0.0" debug: "npm:^4.3.2" fs-extra: "npm:^10.0.0" @@ -1421,7 +1421,7 @@ __metadata: peerDependencies: "@nomicfoundation/hardhat-verify": ^2.0.1 hardhat: ^2.18.0 - checksum: 10c0/b8d6b3f92a0183d6d3bb7b3f9919860ba001dc8d0995d74ad1a324110b93d4dfbdbfb685e8a4a3bec6da5870750325d63ebe014653a7248366adac02ff142841 + checksum: 10c0/574faad7a6d96e15f68b7b52aee19144718d698ec8e17ecec8b416745ef97307e544f7c33f45d829f67980060c672f2f8628293ae95f7873aa325193544598f9 languageName: node linkType: hard @@ -1462,9 +1462,9 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/hardhat-verify@npm:2.0.12": - version: 2.0.12 - resolution: "@nomicfoundation/hardhat-verify@npm:2.0.12" +"@nomicfoundation/hardhat-verify@npm:2.0.13": + version: 2.0.13 + resolution: "@nomicfoundation/hardhat-verify@npm:2.0.13" dependencies: "@ethersproject/abi": "npm:^5.1.2" "@ethersproject/address": "npm:^5.0.2" @@ -1477,13 +1477,13 @@ __metadata: undici: "npm:^5.14.0" peerDependencies: hardhat: ^2.0.4 - checksum: 10c0/551f11346480175362023807b4cebbdacc5627db70e2b4fb0afa04d8ec2c26c3b05d2e74821503e881ba745ec6e2c3a678af74206364099ec14e584a811b2564 + checksum: 10c0/391b35211646ed9efd91b88229c09c8baaa688caaf4388e077b73230b36cd7f86b04639625b0e8ebdc070166f49494c3bd32834c31ca4800db0936ca6db96ee2 languageName: node linkType: hard -"@nomicfoundation/ignition-core@npm:0.15.9, @nomicfoundation/ignition-core@npm:^0.15.9": - version: 0.15.9 - resolution: "@nomicfoundation/ignition-core@npm:0.15.9" +"@nomicfoundation/ignition-core@npm:0.15.10, @nomicfoundation/ignition-core@npm:^0.15.10": + version: 0.15.10 + resolution: "@nomicfoundation/ignition-core@npm:0.15.10" dependencies: "@ethersproject/address": "npm:5.6.1" "@nomicfoundation/solidity-analyzer": "npm:^0.1.1" @@ -1494,14 +1494,14 @@ __metadata: immer: "npm:10.0.2" lodash: "npm:4.17.21" ndjson: "npm:2.0.0" - checksum: 10c0/fe02e3f4a981ef338e3acf75cf2e05535c2aba21f4c5b5831b1430fcaa7bbb42b16bd8ac4bb0b9f036d0b9eb1aede5fa57890f0c3863c4ae173d45ac3e484ed8 + checksum: 10c0/d36d6bac290ed6a8bc223d2ad57f7a722b580782e10f56c3cababeca2f890b48183e10a69154ce2ea14b9e0050c9a38e2bc992a70d43c737763a1df2b0954de6 languageName: node linkType: hard -"@nomicfoundation/ignition-ui@npm:^0.15.9": - version: 0.15.9 - resolution: "@nomicfoundation/ignition-ui@npm:0.15.9" - checksum: 10c0/88097576c4186bfdf365f4864463386e7a345be1f8c0b8eebe589267e782735f8cec55e1c5af6c0f0872ba111d79616422552dc7e26c643d01b1768a2b0fb129 +"@nomicfoundation/ignition-ui@npm:^0.15.10": + version: 0.15.10 + resolution: "@nomicfoundation/ignition-ui@npm:0.15.10" + checksum: 10c0/f72b03a8a737432e06b0c1bcd4e38409292305a55f8f496ccf5618e7512a81e7758f211f91d0d55e2e8a45bc553b3a4a4e5b6f2f316f28526593e79645836bb7 languageName: node linkType: hard @@ -6681,13 +6681,13 @@ __metadata: languageName: node linkType: hard -"hardhat@npm:2.22.18": - version: 2.22.18 - resolution: "hardhat@npm:2.22.18" +"hardhat@npm:2.22.19": + version: 2.22.19 + resolution: "hardhat@npm:2.22.19" dependencies: "@ethersproject/abi": "npm:^5.1.2" "@metamask/eth-sig-util": "npm:^4.0.0" - "@nomicfoundation/edr": "npm:^0.7.0" + "@nomicfoundation/edr": "npm:^0.8.0" "@nomicfoundation/ethereumjs-common": "npm:4.0.4" "@nomicfoundation/ethereumjs-tx": "npm:5.0.4" "@nomicfoundation/ethereumjs-util": "npm:9.0.4" @@ -6739,7 +6739,7 @@ __metadata: optional: true bin: hardhat: internal/cli/bootstrap.js - checksum: 10c0/cd2fd8972b24d13a342747129e88bfe8bad45432ad88c66c743e81615e1c5db7d656c3e9748c03e517c94f6f6df717c4a14685c82c9f843c9be7c1e0a5f76c49 + checksum: 10c0/bd0024f322787abd62aad6847e06d9988f861fd9bf2620bddd04cfeafada6925e97cc210034d7d00ba6cd9463608467fbf1b98bef380940f2e5c8e8d63bfc8e5 languageName: node linkType: hard @@ -8077,12 +8077,12 @@ __metadata: "@eslint/js": "npm:9.21.0" "@nomicfoundation/hardhat-chai-matchers": "npm:2.0.8" "@nomicfoundation/hardhat-ethers": "npm:3.0.8" - "@nomicfoundation/hardhat-ignition": "npm:0.15.9" - "@nomicfoundation/hardhat-ignition-ethers": "npm:0.15.9" + "@nomicfoundation/hardhat-ignition": "npm:0.15.10" + "@nomicfoundation/hardhat-ignition-ethers": "npm:0.15.10" "@nomicfoundation/hardhat-network-helpers": "npm:1.0.12" "@nomicfoundation/hardhat-toolbox": "npm:5.0.0" - "@nomicfoundation/hardhat-verify": "npm:2.0.12" - "@nomicfoundation/ignition-core": "npm:0.15.9" + "@nomicfoundation/hardhat-verify": "npm:2.0.13" + "@nomicfoundation/ignition-core": "npm:0.15.10" "@openzeppelin/contracts": "npm:3.4.0" "@openzeppelin/contracts-v4.4": "npm:@openzeppelin/contracts@4.4.1" "@openzeppelin/contracts-v5.2": "npm:@openzeppelin/contracts@5.2.0" @@ -8105,7 +8105,7 @@ __metadata: ethers: "npm:6.13.5" glob: "npm:11.0.1" globals: "npm:15.15.0" - hardhat: "npm:2.22.18" + hardhat: "npm:2.22.19" hardhat-contract-sizer: "npm:2.10.0" hardhat-gas-reporter: "npm:1.0.10" hardhat-ignore-warnings: "npm:0.2.12" From 868690c07a83202f50345cbafe54cff9db8aef70 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Tue, 25 Feb 2025 14:51:53 +0000 Subject: [PATCH 162/184] feat: remove sanity check for max submitted keys --- contracts/0.8.25/vaults/StakingVault.sol | 11 ----------- .../vaults/staking-vault/stakingVault.test.ts | 18 ------------------ 2 files changed, 29 deletions(-) diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 34310605e..4ac2e05d0 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -102,11 +102,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { */ 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 * The storage namespace is used to prevent upgrade collisions @@ -451,7 +446,6 @@ 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++) { bytes memory pubkey = _pubkeys[i * PUBLIC_KEY_LENGTH : (i + 1) * PUBLIC_KEY_LENGTH]; emit ValidatorExitRequested(msg.sender, pubkey, pubkey); @@ -738,11 +732,6 @@ 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/test/0.8.25/vaults/staking-vault/stakingVault.test.ts b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts index b30a9c9bc..9ce182c89 100644 --- a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts +++ b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts @@ -22,8 +22,6 @@ 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 } => { @@ -134,7 +132,6 @@ describe("StakingVault.sol", () => { 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); @@ -665,14 +662,6 @@ 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") @@ -693,13 +682,6 @@ describe("StakingVault.sol", () => { const receipt = (await tx.wait()) as ContractTransactionReceipt; expect(receipt.logs.length).to.equal(numberOfKeys); }); - - 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); - - await stakingVault.connect(vaultOwner).requestValidatorExit(keys.stringified); - }); }); context("triggerValidatorWithdrawal", () => { From d4f3a62baa6e24060fba5d15f3589c1d26863271 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 26 Feb 2025 16:21:52 +0100 Subject: [PATCH 163/184] chore: apply suggestions from code review Co-authored-by: Eugene Mamin --- contracts/0.8.25/vaults/Dashboard.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 96ba5660c..a094c2552 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -432,14 +432,14 @@ contract Dashboard is Permissions { /** * @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. + * Both partial withdrawals (disabled for unhealthy `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. + * @dev A withdrawal fee must be paid via msg.value. + * Use `StakingVault.calculateValidatorWithdrawalFee()` to determine the required fee for the current block. */ function triggerValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient) external payable { _triggerValidatorWithdrawal(_pubkeys, _amounts, _refundRecipient); From 081444b77aa005d938570669e6b230e9f4d7cc97 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 26 Feb 2025 16:58:50 +0000 Subject: [PATCH 164/184] chore: fix comments --- contracts/0.8.25/vaults/Dashboard.sol | 2 +- contracts/0.8.25/vaults/Permissions.sol | 6 +- contracts/0.8.25/vaults/StakingVault.sol | 73 ++++++++----------- contracts/0.8.25/vaults/VaultHub.sol | 12 ++- .../vaults/staking-vault/stakingVault.test.ts | 6 +- 5 files changed, 40 insertions(+), 59 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index a094c2552..ea4c3934d 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -436,7 +436,7 @@ contract Dashboard is Permissions { * @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. + * For partial withdrawals, amounts will be trimmed to keep MIN_ACTIVATION_BALANCE on the validator to avoid deactivation * @param _refundRecipient Address to receive any fee refunds, if zero, refunds go to msg.sender. * @dev A withdrawal fee must be paid via msg.value. * Use `StakingVault.calculateValidatorWithdrawalFee()` to determine the required fee for the current block. diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index 36db6d733..70dc6ead0 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -223,7 +223,7 @@ abstract contract Permissions is AccessControlConfirmable { /** * @dev Checks the REQUEST_VALIDATOR_EXIT_ROLE and requests validator exit on the StakingVault. - * @param _pubkeys The public keys of the validators to request exit for. + * @dev The zero check for _pubkeys is performed in the StakingVault contract. */ function _requestValidatorExit(bytes calldata _pubkeys) internal onlyRole(REQUEST_VALIDATOR_EXIT_ROLE) { stakingVault().requestValidatorExit(_pubkeys); @@ -231,9 +231,7 @@ abstract contract Permissions is AccessControlConfirmable { /** * @dev Checks the TRIGGER_VALIDATOR_WITHDRAWAL_ROLE and triggers validator withdrawal on the StakingVault using EIP-7002 triggerable exit. - * @param _pubkeys The public keys of the validators to trigger withdrawal for. - * @param _amounts The amounts of ether to trigger withdrawal for. - * @param _refundRecipient The address to refund the excess ether to. + * @dev The zero checks for parameters are performed in the StakingVault contract. */ function _triggerValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient) internal onlyRole(TRIGGER_VALIDATOR_WITHDRAWAL_ROLE) { stakingVault().triggerValidatorWithdrawal{value: msg.value}(_pubkeys, _amounts, _refundRecipient); diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 4ac2e05d0..068d2f08f 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -20,14 +20,19 @@ import {IStakingVault} from "./interfaces/IStakingVault.sol"; * StakingVault is a private staking pool that enables staking with a designated node operator. * Each StakingVault includes an accounting system that tracks its valuation via reports. * - * 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. - * 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. - * The owner can voluntarily rebalance the StakingVault in any state or by simply - * supplying more ether to increase the valuation. + * The StakingVault can be used as a backing for minting new stETH through integration with the VaultHub. + * When minting stETH backed by the StakingVault, the VaultHub designates a portion of the StakingVault's + * valuation as locked, which cannot be withdrawn by the owner. This locked portion represents the + * backing for the minted stETH. + * + * If the locked amount exceeds the StakingVault's current valuation, the VaultHub has the ability to + * rebalance the StakingVault. This rebalancing process involves withdrawing a portion of the staked amount + * and adjusting the locked amount to align with the current valuation. + * + * The owner may proactively maintain the vault's backing ratio by either: + * - Voluntarily rebalancing the StakingVault at any time + * - Adding more ether to increase the valuation + * - Triggering validator withdrawals to increase the valuation * * Access * - Owner: @@ -141,16 +146,14 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { } /** - * @notice Returns the highest version that has been initialized - * @return Highest initialized version number as uint64 + * @notice Returns the highest version that has been initialized as uint64 */ function getInitializedVersion() external view returns (uint64) { return _getInitializedVersion(); } /** - * @notice Returns the version of the contract - * @return Version number as uint64 + * @notice Returns the version of the contract as uint64 */ function version() external pure returns (uint64) { return _VERSION; @@ -162,15 +165,13 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @notice Returns the address of `VaultHub` - * @return Address of `VaultHub` */ function vaultHub() external view returns (address) { return address(VAULT_HUB); } /** - * @notice Returns the total valuation of `StakingVault` - * @return Total valuation in ether + * @notice Returns the total valuation of `StakingVault` in ether * @dev Valuation = latestReport.valuation + (current inOutDelta - latestReport.inOutDelta) */ function valuation() public view returns (uint256) { @@ -179,8 +180,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { } /** - * @notice Returns the amount of ether locked in `StakingVault`. - * @return Amount of locked ether + * @notice Returns the amount of ether locked in `StakingVault` in ether * @dev Locked amount is updated by `VaultHub` with reports * and can also be increased by `VaultHub` outside of reports */ @@ -189,8 +189,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { } /** - * @notice Returns the unlocked amount, which is the valuation minus the locked amount - * @return Amount of unlocked ether + * @notice Returns the unlocked amount of ether, which is the valuation minus the locked ether amount * @dev Unlocked amount is the total amount that can be withdrawn from `StakingVault`, * including ether currently being staked on validators */ @@ -205,7 +204,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @notice Returns the net difference between funded and withdrawn ether. - * @return Delta between funded and withdrawn ether * @dev This counter is only updated via: * - `fund()`, * - `withdraw()`, @@ -220,8 +218,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { } /** - * @notice Returns the latest report data for the vault - * @return Report struct containing valuation and inOutDelta from last report + * @notice Returns the latest report data for the vault (valuation and inOutDelta) */ function latestReport() external view returns (IStakingVault.Report memory) { return _getStorage().report; @@ -233,7 +230,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * 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()`. * Node operator address is set in the initialization and can never be changed. - * @return Address of the node operator */ function nodeOperator() external view returns (address) { return _getStorage().nodeOperator; @@ -265,9 +261,8 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @param _recipient Address to receive the withdrawn ether. * @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 Checks that valuation remains greater than locked amount after withdrawal to maintain - * `StakingVault` health and prevent reentrancy attacks. + * @dev Updates inOutDelta to track the net difference between funded and withdrawn ether. + * @dev Checks that valuation remains greater or equal than locked amount and prevents reentrancy attacks. */ function withdraw(address _recipient, uint256 _ether) external onlyOwner { if (_recipient == address(0)) revert ZeroArgument("_recipient"); @@ -305,7 +300,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, or by owner at any moment + * @dev Can only be called by VaultHub if StakingVault valuation is less than locked amount * @param _ether Amount of ether to rebalance */ function rebalance(uint256 _ether) external { @@ -348,7 +343,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @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(WC_0X02_PREFIX | uint160(address(this))); @@ -356,7 +350,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @notice Returns whether deposits are paused - * @return True if deposits are paused */ function beaconChainDepositsPaused() external view returns (bool) { return _getStorage().beaconChainDepositsPaused; @@ -408,11 +401,12 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { uint256 totalAmount = 0; uint256 numberOfDeposits = _deposits.length; + bytes memory withdrawalCredentials_ = bytes.concat(withdrawalCredentials()); for (uint256 i = 0; i < numberOfDeposits; i++) { IStakingVault.Deposit calldata deposit = _deposits[i]; DEPOSIT_CONTRACT.deposit{value: deposit.amount}( deposit.pubkey, - bytes.concat(withdrawalCredentials()), + withdrawalCredentials_, deposit.signature, deposit.depositDataRoot ); @@ -448,7 +442,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { uint256 keysCount = _pubkeys.length / PUBLIC_KEY_LENGTH; for (uint256 i = 0; i < keysCount; i++) { bytes memory pubkey = _pubkeys[i * PUBLIC_KEY_LENGTH : (i + 1) * PUBLIC_KEY_LENGTH]; - emit ValidatorExitRequested(msg.sender, pubkey, pubkey); + emit ValidatorExitRequested(msg.sender, /* indexed */ pubkey, pubkey); } } @@ -460,9 +454,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @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 { - uint256 value = msg.value; - - if (value == 0) revert ZeroArgument("msg.value"); + if (msg.value == 0) revert ZeroArgument("msg.value"); if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); if (_amounts.length == 0) revert ZeroArgument("_amounts"); if (_pubkeys.length % PUBLIC_KEY_LENGTH != 0) revert InvalidPubkeysLength(); @@ -493,11 +485,11 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { uint256 feePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); uint256 totalFee = feePerRequest * keysCount; - if (value < totalFee) revert InsufficientValidatorWithdrawalFee(value, totalFee); + if (msg.value < totalFee) revert InsufficientValidatorWithdrawalFee(msg.value, totalFee); TriggerableWithdrawals.addWithdrawalRequests(_pubkeys, _amounts, feePerRequest); - uint256 excess = value - totalFee; + uint256 excess = msg.value - totalFee; if (excess > 0) { (bool success,) = _refundRecipient.call{value: excess}(""); if (!success) revert WithdrawalFeeRefundFailed(_refundRecipient, excess); @@ -637,13 +629,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { */ event ValidatorWithdrawalTriggered(address indexed _sender, bytes _pubkeys, uint64[] _amounts, address _refundRecipient, uint256 _excess); - /** - * @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 @@ -747,7 +732,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { error WithdrawalFeeRefundFailed(address _sender, uint256 _amount); /** - * @notice Thrown when partial withdrawals are not allowed on an unbalanced vault + * @notice Thrown when partial withdrawals are not allowed when valuation is below locked */ error PartialWithdrawalNotAllowed(); } diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 079b7c195..f9c517c2b 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -76,7 +76,7 @@ abstract contract VaultHub is PausableUntilWithRoles { IStETH public immutable STETH; /// @param _stETH Lido stETH contract - /// @param _connectedVaultsLimit Maximum number of vaults that can be connected + /// @param _connectedVaultsLimit Maximum number of vaults that can be connected simultaneously /// @param _relativeShareLimitBP Maximum share limit relative to TVL in basis points constructor(IStETH _stETH, uint256 _connectedVaultsLimit, uint256 _relativeShareLimitBP) { if (_connectedVaultsLimit == 0) revert ZeroArgument("_connectedVaultsLimit"); @@ -136,9 +136,7 @@ abstract contract VaultHub is PausableUntilWithRoles { } /// @notice checks if the vault is healthy by comparing its valuation against minted shares - /// @dev A vault is considered healthy if it has no shares minted, or if its valuation minus required reserves - /// is sufficient to cover the current value of minted shares. The required reserves are determined by - /// the reserve ratio threshold. + /// @dev A vault is considered healthy when its valuation is sufficient to cover the current value of minted shares /// @param _vault vault address /// @return true if vault is healthy, false otherwise function isVaultHealthy(address _vault) public view returns (bool) { @@ -354,11 +352,11 @@ abstract contract VaultHub is PausableUntilWithRoles { emit VaultRebalanced(msg.sender, sharesToBurn); } - /// @notice Forces validator exit from the beacon chain when vault health ratio is below 100% + /// @notice Forces validator exit from the beacon chain when vault is unhealthy /// @param _vault The address of the vault to exit validators from /// @param _pubkeys The public keys of the validators to exit /// @param _refundRecepient The address that will receive the refund for transaction costs - /// @dev When a vault's health ratio drops below 100%, anyone can force its validators to exit the beacon chain + /// @dev When the vault becomes unhealthy, anyone can force its validators to exit the beacon chain /// This returns the vault's deposited ETH back to vault's balance and allows to rebalance the vault function forceValidatorExit( address _vault, @@ -532,7 +530,7 @@ abstract contract VaultHub is PausableUntilWithRoles { } } - /// @dev check if the share limit is within the upper bound set by relativeShareLimitBP + /// @dev check if the share limit is within the upper bound set by RELATIVE_SHARE_LIMIT_BP function _checkShareLimitUpperBound(address _vault, uint256 _shareLimit) internal view { uint256 relativeMaxShareLimitPerVault = (STETH.getTotalShares() * RELATIVE_SHARE_LIMIT_BP) / TOTAL_BASIS_POINTS; if (_shareLimit > relativeMaxShareLimitPerVault) { 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 9ce182c89..22731ba04 100644 --- a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts +++ b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts @@ -595,7 +595,7 @@ describe("StakingVault.sol", () => { }); it("makes multiple deposits to the beacon chain and emits the `DepositedToBeaconChain` event", async () => { - const numberOfKeys = 2; // number because of Array.from + const numberOfKeys = 300; // number because of Array.from const totalAmount = ether("32") * BigInt(numberOfKeys); const withdrawalCredentials = await stakingVault.withdrawalCredentials(); @@ -612,7 +612,7 @@ describe("StakingVault.sol", () => { await expect(stakingVault.connect(operator).depositToBeaconChain(deposits)) .to.emit(stakingVault, "DepositedToBeaconChain") - .withArgs(operator, 2, totalAmount); + .withArgs(operator, numberOfKeys, totalAmount); }); }); @@ -857,7 +857,7 @@ describe("StakingVault.sol", () => { }); it("requests a multiple validator withdrawals", async () => { - const numberOfKeys = 2; + const numberOfKeys = 300; const pubkeys = getPubkeys(numberOfKeys); const value = baseFee * BigInt(numberOfKeys); const amounts = Array(numberOfKeys) From aa92ca378d9a8f7e85b288cffee3d08ecb52c042 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Wed, 26 Feb 2025 17:14:49 +0000 Subject: [PATCH 165/184] =?UTF-8?q?refactor:=20reserveRatioThresholdBP=20?= =?UTF-8?q?=E2=87=92=20rebalanceThresholdBP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/0.8.25/vaults/Dashboard.sol | 10 +++--- contracts/0.8.25/vaults/VaultHub.sol | 28 +++++++-------- .../0.8.25/vaults/dashboard/dashboard.test.ts | 22 ++++++------ test/0.8.25/vaults/vaultFactory.test.ts | 10 +++--- .../vaults/vaulthub/vaulthub.hub.test.ts | 36 +++++++++---------- .../vaults-happy-path.integration.ts | 4 +-- 6 files changed, 55 insertions(+), 55 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index ea4c3934d..6673f3203 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -135,18 +135,18 @@ contract Dashboard is Permissions { /** * @notice Returns the reserve ratio of the vault in basis points - * @return The reserve ratio as a uint16 + * @return The reserve ratio in basis points as a uint16 */ function reserveRatioBP() public view returns (uint16) { return vaultSocket().reserveRatioBP; } /** - * @notice Returns the threshold reserve ratio of the vault in basis points. - * @return The threshold reserve ratio as a uint16. + * @notice Returns the rebalance threshold of the vault in basis points. + * @return The rebalance threshold in basis points as a uint16. */ - function thresholdReserveRatioBP() external view returns (uint16) { - return vaultSocket().reserveRatioThresholdBP; + function rebalanceThresholdBP() external view returns (uint16) { + return vaultSocket().rebalanceThresholdBP; } /** diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index f9c517c2b..fd2df762e 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -42,8 +42,8 @@ abstract contract VaultHub is PausableUntilWithRoles { uint96 shareLimit; /// @notice minimal share of ether that is reserved for each stETH minted uint16 reserveRatioBP; - /// @notice if vault's reserve decreases to this threshold ratio, it should be force rebalanced - uint16 reserveRatioThresholdBP; + /// @notice if vault's reserve decreases to this threshold, it should be force rebalanced + uint16 rebalanceThresholdBP; /// @notice treasury fee in basis points uint16 treasuryFeeBP; /// @notice if true, vault is disconnected and fee is not accrued @@ -135,8 +135,8 @@ abstract contract VaultHub is PausableUntilWithRoles { return $.sockets[$.vaultIndex[_vault]]; } - /// @notice checks if the vault is healthy by comparing its valuation against minted shares - /// @dev A vault is considered healthy when its valuation is sufficient to cover the current value of minted shares + /// @notice checks if the vault is healthy by comparing its projected valuation after applying rebalance threshold + /// against the current value of minted shares /// @param _vault vault address /// @return true if vault is healthy, false otherwise function isVaultHealthy(address _vault) public view returns (bool) { @@ -144,29 +144,29 @@ abstract contract VaultHub is PausableUntilWithRoles { if (socket.sharesMinted == 0) return true; return ( - IStakingVault(_vault).valuation() * (TOTAL_BASIS_POINTS - socket.reserveRatioThresholdBP) / TOTAL_BASIS_POINTS + IStakingVault(_vault).valuation() * (TOTAL_BASIS_POINTS - socket.rebalanceThresholdBP) / TOTAL_BASIS_POINTS ) >= STETH.getPooledEthBySharesRoundUp(socket.sharesMinted); } /// @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 - /// @param _reserveRatioBP minimum Reserve ratio in basis points - /// @param _reserveRatioThresholdBP reserve ratio that makes possible to force rebalance on the vault (in basis points) + /// @param _reserveRatioBP minimum reserve ratio in basis points + /// @param _rebalanceThresholdBP threshold to force rebalance on the vault in basis points /// @param _treasuryFeeBP treasury fee in basis points /// @dev msg.sender must have VAULT_MASTER_ROLE function connectVault( address _vault, uint256 _shareLimit, uint256 _reserveRatioBP, - uint256 _reserveRatioThresholdBP, + uint256 _rebalanceThresholdBP, uint256 _treasuryFeeBP ) external onlyRole(VAULT_MASTER_ROLE) { if (_vault == address(0)) revert ZeroArgument("_vault"); 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 ReserveRatioThresholdTooHigh(_vault, _reserveRatioThresholdBP, _reserveRatioBP); + if (_rebalanceThresholdBP == 0) revert ZeroArgument("_rebalanceThresholdBP"); + if (_rebalanceThresholdBP > _reserveRatioBP) revert RebalanceThresholdTooHigh(_vault, _rebalanceThresholdBP, _reserveRatioBP); if (_treasuryFeeBP > TOTAL_BASIS_POINTS) revert TreasuryFeeTooHigh(_vault, _treasuryFeeBP, TOTAL_BASIS_POINTS); if (vaultsCount() == CONNECTED_VAULTS_LIMIT) revert TooManyVaults(); _checkShareLimitUpperBound(_vault, _shareLimit); @@ -182,7 +182,7 @@ abstract contract VaultHub is PausableUntilWithRoles { 0, // sharesMinted uint96(_shareLimit), uint16(_reserveRatioBP), - uint16(_reserveRatioThresholdBP), + uint16(_rebalanceThresholdBP), uint16(_treasuryFeeBP), false // pendingDisconnect ); @@ -191,7 +191,7 @@ abstract contract VaultHub is PausableUntilWithRoles { IStakingVault(_vault).lock(CONNECT_DEPOSIT); - emit VaultConnected(_vault, _shareLimit, _reserveRatioBP, _reserveRatioThresholdBP, _treasuryFeeBP); + emit VaultConnected(_vault, _shareLimit, _reserveRatioBP, _rebalanceThresholdBP, _treasuryFeeBP); } /// @notice updates share limit for the vault @@ -542,7 +542,7 @@ abstract contract VaultHub is PausableUntilWithRoles { if (isVaultHealthy(_vault)) revert AlreadyHealthy(_vault); } - event VaultConnected(address indexed vault, uint256 capShares, uint256 minReserveRatio, uint256 reserveRatioThreshold, uint256 treasuryFeeBP); + event VaultConnected(address indexed vault, uint256 capShares, uint256 minReserveRatio, uint256 rebalanceThreshold, uint256 treasuryFeeBP); event ShareLimitUpdated(address indexed vault, uint256 newShareLimit); event VaultDisconnected(address indexed vault); event MintedSharesOnVault(address indexed vault, uint256 amountOfShares); @@ -564,7 +564,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 RebalanceThresholdTooHigh(address vault, uint256 rebalanceThresholdBP, uint256 maxRebalanceThresholdBP); error TreasuryFeeTooHigh(address vault, uint256 treasuryFeeBP, uint256 maxTreasuryFeeBP); error ExternalSharesCapReached(address vault, uint256 capShares, uint256 maxMintableExternalShares); error InsufficientValuationToMint(address vault, uint256 valuation); diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 6bf64cf27..e0fafd653 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -182,7 +182,7 @@ describe("Dashboard.sol", () => { sharesMinted: 555n, shareLimit: 1000n, reserveRatioBP: 1000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, pendingDisconnect: false, }; @@ -193,7 +193,7 @@ describe("Dashboard.sol", () => { expect(await dashboard.shareLimit()).to.equal(sockets.shareLimit); expect(await dashboard.sharesMinted()).to.equal(sockets.sharesMinted); expect(await dashboard.reserveRatioBP()).to.equal(sockets.reserveRatioBP); - expect(await dashboard.thresholdReserveRatioBP()).to.equal(sockets.reserveRatioThresholdBP); + expect(await dashboard.rebalanceThresholdBP()).to.equal(sockets.rebalanceThresholdBP); expect(await dashboard.treasuryFeeBP()).to.equal(sockets.treasuryFeeBP); }); }); @@ -218,7 +218,7 @@ describe("Dashboard.sol", () => { shareLimit: 1000000000n, sharesMinted: 555n, reserveRatioBP: 1000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, pendingDisconnect: false, }; @@ -240,7 +240,7 @@ describe("Dashboard.sol", () => { shareLimit: 100n, sharesMinted: 0n, reserveRatioBP: 1000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, pendingDisconnect: false, }; @@ -260,7 +260,7 @@ describe("Dashboard.sol", () => { shareLimit: 1000000000n, sharesMinted: 555n, reserveRatioBP: 10_000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, pendingDisconnect: false, }; @@ -280,7 +280,7 @@ describe("Dashboard.sol", () => { shareLimit: 10000000n, sharesMinted: 555n, reserveRatioBP: 0n, - reserveRatioThresholdBP: 0n, + rebalanceThresholdBP: 0n, treasuryFeeBP: 500n, pendingDisconnect: false, }; @@ -308,7 +308,7 @@ describe("Dashboard.sol", () => { shareLimit: 10000000n, sharesMinted: 0n, reserveRatioBP: 1000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, pendingDisconnect: false, }; @@ -334,7 +334,7 @@ describe("Dashboard.sol", () => { shareLimit: 10000000n, sharesMinted: 900n, reserveRatioBP: 1000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, pendingDisconnect: false, }; @@ -357,7 +357,7 @@ describe("Dashboard.sol", () => { shareLimit: 10000000n, sharesMinted: 10000n, reserveRatioBP: 1000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, pendingDisconnect: false, }; @@ -378,7 +378,7 @@ describe("Dashboard.sol", () => { shareLimit: 10000000n, sharesMinted: 500n, reserveRatioBP: 1000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, pendingDisconnect: false, }; @@ -402,7 +402,7 @@ describe("Dashboard.sol", () => { shareLimit: 500n, sharesMinted: 500n, reserveRatioBP: 1000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, pendingDisconnect: false, }; diff --git a/test/0.8.25/vaults/vaultFactory.test.ts b/test/0.8.25/vaults/vaultFactory.test.ts index c2941069d..800817bc8 100644 --- a/test/0.8.25/vaults/vaultFactory.test.ts +++ b/test/0.8.25/vaults/vaultFactory.test.ts @@ -208,13 +208,13 @@ describe("VaultFactory.sol", () => { const config1 = { shareLimit: 10n, minReserveRatioBP: 500n, - thresholdReserveRatioBP: 20n, + rebalanceThresholdBP: 20n, treasuryFeeBP: 500n, }; const config2 = { shareLimit: 20n, minReserveRatioBP: 200n, - thresholdReserveRatioBP: 20n, + rebalanceThresholdBP: 20n, treasuryFeeBP: 600n, }; @@ -242,7 +242,7 @@ describe("VaultFactory.sol", () => { await vault1.getAddress(), config1.shareLimit, config1.minReserveRatioBP, - config1.thresholdReserveRatioBP, + config1.rebalanceThresholdBP, config1.treasuryFeeBP, ), ).to.revertedWithCustomError(accounting, "VaultProxyNotAllowed"); @@ -259,7 +259,7 @@ describe("VaultFactory.sol", () => { await vault1.getAddress(), config1.shareLimit, config1.minReserveRatioBP, - config1.thresholdReserveRatioBP, + config1.rebalanceThresholdBP, config1.treasuryFeeBP, ); @@ -289,7 +289,7 @@ describe("VaultFactory.sol", () => { await vault2.getAddress(), config2.shareLimit, config2.minReserveRatioBP, - config2.thresholdReserveRatioBP, + config2.rebalanceThresholdBP, config2.treasuryFeeBP, ), ).to.not.revertedWithCustomError(accounting, "VaultProxyNotAllowed"); 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 f995ab26a..a4a49e2f8 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts @@ -65,7 +65,7 @@ describe("VaultHub.sol:hub", () => { options?: { shareLimit?: bigint; reserveRatioBP?: bigint; - reserveRatioThresholdBP?: bigint; + rebalanceThresholdBP?: bigint; treasuryFeeBP?: bigint; }, ) { @@ -77,7 +77,7 @@ describe("VaultHub.sol:hub", () => { await vault.getAddress(), options?.shareLimit ?? SHARE_LIMIT, options?.reserveRatioBP ?? RESERVE_RATIO_BP, - options?.reserveRatioThresholdBP ?? RESERVE_RATIO_THRESHOLD_BP, + options?.rebalanceThresholdBP ?? RESERVE_RATIO_THRESHOLD_BP, options?.treasuryFeeBP ?? TREASURY_FEE_BP, ); @@ -216,7 +216,7 @@ describe("VaultHub.sol:hub", () => { 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.rebalanceThresholdBP).to.equal(RESERVE_RATIO_THRESHOLD_BP); expect(lastVaultSocket.treasuryFeeBP).to.equal(TREASURY_FEE_BP); expect(lastVaultSocket.pendingDisconnect).to.equal(false); }); @@ -231,7 +231,7 @@ describe("VaultHub.sol:hub", () => { 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.rebalanceThresholdBP).to.equal(0n); expect(vaultSocket.treasuryFeeBP).to.equal(0n); expect(vaultSocket.pendingDisconnect).to.equal(false); }); @@ -245,7 +245,7 @@ describe("VaultHub.sol:hub", () => { 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.rebalanceThresholdBP).to.equal(RESERVE_RATIO_THRESHOLD_BP); expect(vaultSocket.treasuryFeeBP).to.equal(TREASURY_FEE_BP); expect(vaultSocket.pendingDisconnect).to.equal(false); }); @@ -274,8 +274,8 @@ describe("VaultHub.sol:hub", () => { for (let i = 0; i < 50; i++) { const snapshot = await Snapshot.take(); - const reserveRatioThresholdBP = tbi(10000); - const reserveRatioBP = BigIntMath.min(reserveRatioThresholdBP + tbi(1000), TOTAL_BASIS_POINTS); + const rebalanceThresholdBP = tbi(10000); + const reserveRatioBP = BigIntMath.min(rebalanceThresholdBP + tbi(1000), TOTAL_BASIS_POINTS); const valuationEth = tbi(100); const valuation = ether(valuationEth.toString()); @@ -284,13 +284,13 @@ describe("VaultHub.sol:hub", () => { const isSlashing = Math.random() < 0.5; const slashed = isSlashing ? ether(tbi(valuationEth).toString()) : 0n; - const treashold = ((valuation - slashed) * (TOTAL_BASIS_POINTS - reserveRatioThresholdBP)) / TOTAL_BASIS_POINTS; + const treashold = ((valuation - slashed) * (TOTAL_BASIS_POINTS - rebalanceThresholdBP)) / TOTAL_BASIS_POINTS; const expectedHealthy = treashold >= mintable; const vault = await createAndConnectVault(vaultFactory, { shareLimit: ether("100"), // just to bypass the share limit check reserveRatioBP: reserveRatioBP, - reserveRatioThresholdBP: reserveRatioThresholdBP, + rebalanceThresholdBP: rebalanceThresholdBP, }); const vaultAddress = await vault.getAddress(); @@ -309,7 +309,7 @@ describe("VaultHub.sol:hub", () => { expect(actualHealthy).to.equal(expectedHealthy); } catch (error) { console.log(`Test failed with parameters: - Reserve Ratio Threshold: ${reserveRatioThresholdBP} + Rebalance Threshold: ${rebalanceThresholdBP} Reserve Ratio: ${reserveRatioBP} Valuation: ${valuation} ETH Minted: ${mintable} stETH @@ -328,7 +328,7 @@ describe("VaultHub.sol:hub", () => { const vault = await createAndConnectVault(vaultFactory, { shareLimit: ether("100"), // just to bypass the share limit check reserveRatioBP: 50_00n, // 50% - reserveRatioThresholdBP: 50_00n, // 50% + rebalanceThresholdBP: 50_00n, // 50% }); const vaultAddress = await vault.getAddress(); @@ -353,7 +353,7 @@ describe("VaultHub.sol:hub", () => { const vault = await createAndConnectVault(vaultFactory, { shareLimit: ether("100"), // just to bypass the share limit check reserveRatioBP: 50_00n, // 50% - reserveRatioThresholdBP: 50_00n, // 50% + rebalanceThresholdBP: 50_00n, // 50% }); const vaultAddress = await vault.getAddress(); @@ -392,7 +392,7 @@ describe("VaultHub.sol:hub", () => { const vault = await createAndConnectVault(vaultFactory, { shareLimit: ether("100"), // just to bypass the share limit check reserveRatioBP: 1n, // 0.01% - reserveRatioThresholdBP: 1n, // 0.01% + rebalanceThresholdBP: 1n, // 0.01% }); const vaultAddress = await vault.getAddress(); @@ -429,7 +429,7 @@ describe("VaultHub.sol:hub", () => { const vault = await createAndConnectVault(vaultFactory, { shareLimit: ether("100"), reserveRatioBP: 50_00n, // 50% - reserveRatioThresholdBP: 50_00n, // 50% + rebalanceThresholdBP: 50_00n, // 50% }); const vaultAddress = await vault.getAddress(); @@ -484,7 +484,7 @@ describe("VaultHub.sol:hub", () => { ).to.be.revertedWithCustomError(vaultHub, "ZeroArgument"); }); - it("reverts if reserve ration is too high", async () => { + it("reverts if reserve ratio is too high", async () => { const tooHighReserveRatioBP = TOTAL_BASIS_POINTS + 1n; await expect( vaultHub @@ -495,19 +495,19 @@ describe("VaultHub.sol:hub", () => { .withArgs(vaultAddress, tooHighReserveRatioBP, TOTAL_BASIS_POINTS); }); - it("reverts if reserve ratio threshold BP is zero", async () => { + it("reverts if rebalance 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 () => { + it("reverts if rebalance 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") + .to.be.revertedWithCustomError(vaultHub, "RebalanceThresholdTooHigh") .withArgs(vaultAddress, RESERVE_RATIO_BP + 1n, RESERVE_RATIO_BP); }); diff --git a/test/integration/vaults-happy-path.integration.ts b/test/integration/vaults-happy-path.integration.ts index e5c872014..34d5bb8b9 100644 --- a/test/integration/vaults-happy-path.integration.ts +++ b/test/integration/vaults-happy-path.integration.ts @@ -49,7 +49,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { let depositContract: string; const reserveRatio = 10_00n; // 10% of ETH allocation as reserve - const reserveRatioThreshold = 8_00n; // 8% of reserve ratio + const rebalanceThreshold = 8_00n; // 8% is a threshold to force rebalance on the vault const mintableRatio = TOTAL_BASIS_POINTS - reserveRatio; // 90% LTV let delegation: Delegation; @@ -220,7 +220,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { await accounting .connect(agentSigner) - .connectVault(stakingVault, shareLimit, reserveRatio, reserveRatioThreshold, treasuryFeeBP); + .connectVault(stakingVault, shareLimit, reserveRatio, rebalanceThreshold, treasuryFeeBP); expect(await accounting.vaultsCount()).to.equal(1n); expect(await stakingVault.locked()).to.equal(VAULT_CONNECTION_DEPOSIT); From df3956c0ee1af769d36b03270b1a2a3b05b47358 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Thu, 27 Feb 2025 10:58:15 +0000 Subject: [PATCH 166/184] chore: split integration tests --- lib/protocol/index.ts | 2 ++ test/integration/{ => core}/accounting.integration.ts | 3 +-- test/integration/{ => core}/burn-shares.integration.ts | 3 +-- .../happy-path.integration.ts} | 8 ++++---- .../integration/{ => core}/negative-rebase.integration.ts | 3 +-- test/integration/{ => core}/second-opinion.integration.ts | 3 +-- .../happy-path.integration.ts} | 8 ++++---- 7 files changed, 14 insertions(+), 16 deletions(-) rename test/integration/{ => core}/accounting.integration.ts (99%) rename test/integration/{ => core}/burn-shares.integration.ts (95%) rename test/integration/{protocol-happy-path.integration.ts => core/happy-path.integration.ts} (99%) rename test/integration/{ => core}/negative-rebase.integration.ts (97%) rename test/integration/{ => core}/second-opinion.integration.ts (98%) rename test/integration/{vaults-happy-path.integration.ts => vaults/happy-path.integration.ts} (98%) diff --git a/lib/protocol/index.ts b/lib/protocol/index.ts index 4a5fe3563..062c1a0b1 100644 --- a/lib/protocol/index.ts +++ b/lib/protocol/index.ts @@ -1,2 +1,4 @@ export { getProtocolContext } from "./context"; export type { ProtocolContext, ProtocolSigners, ProtocolContracts } from "./types"; + +export * from "./helpers"; diff --git a/test/integration/accounting.integration.ts b/test/integration/core/accounting.integration.ts similarity index 99% rename from test/integration/accounting.integration.ts rename to test/integration/core/accounting.integration.ts index d132b3d93..4896d9837 100644 --- a/test/integration/accounting.integration.ts +++ b/test/integration/core/accounting.integration.ts @@ -6,8 +6,7 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; import { ether, impersonate, log, ONE_GWEI, updateBalance } from "lib"; -import { getProtocolContext, ProtocolContext } from "lib/protocol"; -import { getReportTimeElapsed, report } from "lib/protocol/helpers"; +import { getProtocolContext, getReportTimeElapsed, ProtocolContext, report } from "lib/protocol"; import { Snapshot } from "test/suite"; import { LIMITER_PRECISION_BASE, MAX_BASIS_POINTS, ONE_DAY, SHARE_RATE_PRECISION } from "test/suite/constants"; diff --git a/test/integration/burn-shares.integration.ts b/test/integration/core/burn-shares.integration.ts similarity index 95% rename from test/integration/burn-shares.integration.ts rename to test/integration/core/burn-shares.integration.ts index e4268a9dd..ea05f3dfb 100644 --- a/test/integration/burn-shares.integration.ts +++ b/test/integration/core/burn-shares.integration.ts @@ -5,8 +5,7 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { ether, impersonate, log } from "lib"; -import { getProtocolContext, ProtocolContext } from "lib/protocol"; -import { handleOracleReport } from "lib/protocol/helpers"; +import { getProtocolContext, handleOracleReport, ProtocolContext } from "lib/protocol"; import { bailOnFailure, Snapshot } from "test/suite"; diff --git a/test/integration/protocol-happy-path.integration.ts b/test/integration/core/happy-path.integration.ts similarity index 99% rename from test/integration/protocol-happy-path.integration.ts rename to test/integration/core/happy-path.integration.ts index 7deed7105..9126cb30c 100644 --- a/test/integration/protocol-happy-path.integration.ts +++ b/test/integration/core/happy-path.integration.ts @@ -5,17 +5,17 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { batch, ether, impersonate, log, updateBalance } from "lib"; -import { getProtocolContext, ProtocolContext } from "lib/protocol"; import { finalizeWithdrawalQueue, + getProtocolContext, norEnsureOperators, OracleReportParams, + ProtocolContext, report, sdvtEnsureOperators, -} from "lib/protocol/helpers"; +} from "lib/protocol"; -import { bailOnFailure, Snapshot } from "test/suite"; -import { MAX_DEPOSIT, ZERO_HASH } from "test/suite/constants"; +import { bailOnFailure, MAX_DEPOSIT, Snapshot, ZERO_HASH } from "test/suite"; const AMOUNT = ether("100"); diff --git a/test/integration/negative-rebase.integration.ts b/test/integration/core/negative-rebase.integration.ts similarity index 97% rename from test/integration/negative-rebase.integration.ts rename to test/integration/core/negative-rebase.integration.ts index 0d4e5f32b..b5887f446 100644 --- a/test/integration/negative-rebase.integration.ts +++ b/test/integration/core/negative-rebase.integration.ts @@ -5,8 +5,7 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; import { ether } from "lib"; -import { getProtocolContext, ProtocolContext } from "lib/protocol"; -import { report } from "lib/protocol/helpers"; +import { getProtocolContext, ProtocolContext, report } from "lib/protocol"; import { Snapshot } from "test/suite"; diff --git a/test/integration/second-opinion.integration.ts b/test/integration/core/second-opinion.integration.ts similarity index 98% rename from test/integration/second-opinion.integration.ts rename to test/integration/core/second-opinion.integration.ts index b795feeed..919ef4a0a 100644 --- a/test/integration/second-opinion.integration.ts +++ b/test/integration/core/second-opinion.integration.ts @@ -4,8 +4,7 @@ import { ethers } from "hardhat"; import { SecondOpinionOracle__Mock } from "typechain-types"; import { ether, impersonate, log, ONE_GWEI } from "lib"; -import { getProtocolContext, ProtocolContext } from "lib/protocol"; -import { report } from "lib/protocol/helpers"; +import { getProtocolContext, ProtocolContext, report } from "lib/protocol"; import { bailOnFailure, Snapshot } from "test/suite"; diff --git a/test/integration/vaults-happy-path.integration.ts b/test/integration/vaults/happy-path.integration.ts similarity index 98% rename from test/integration/vaults-happy-path.integration.ts rename to test/integration/vaults/happy-path.integration.ts index 34d5bb8b9..fa144058e 100644 --- a/test/integration/vaults-happy-path.integration.ts +++ b/test/integration/vaults/happy-path.integration.ts @@ -6,16 +6,16 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { Delegation, StakingVault } from "typechain-types"; -import { computeDepositDataRoot, days, impersonate, log, updateBalance } from "lib"; -import { getProtocolContext, ProtocolContext } from "lib/protocol"; +import { computeDepositDataRoot, days, ether, impersonate, log, updateBalance } from "lib"; import { + getProtocolContext, getReportTimeElapsed, norEnsureOperators, OracleReportParams, + ProtocolContext, report, sdvtEnsureOperators, -} from "lib/protocol/helpers"; -import { ether } from "lib/units"; +} from "lib/protocol"; import { bailOnFailure, Snapshot } from "test/suite"; import { CURATED_MODULE_ID, MAX_DEPOSIT, ONE_DAY, SIMPLE_DVT_MODULE_ID, ZERO_HASH } from "test/suite/constants"; From dbd62ce5d99c86a480ee8161c93be883afc54aff Mon Sep 17 00:00:00 2001 From: VP Date: Thu, 20 Feb 2025 21:27:12 +0100 Subject: [PATCH 167/184] feat: detach Accounting from VaultHub --- contracts/0.8.25/Accounting.sol | 42 ++++++++---------- contracts/0.8.25/vaults/VaultHub.sol | 12 ++++-- contracts/0.8.9/LidoLocator.sol | 7 ++- contracts/common/interfaces/ILidoLocator.sol | 5 ++- test/0.8.25/vaults/vaultFactory.test.ts | 43 ++++++++----------- .../{accounting.test.ts => vaultHub.test.ts} | 26 +++++------ .../vaults/vaulthub/vaulthub.pausable.test.ts | 21 +++------ .../accounting.handleOracleReport.test.ts | 11 ++++- .../LidoLocator__MockForSanityChecker.sol | 13 ++++-- .../contracts/LidoLocator__MockMutable.sol | 13 ++++-- test/0.8.9/lidoLocator.test.ts | 3 ++ ...eportSanityChecker.negative-rebase.test.ts | 1 + test/deploy/locator.ts | 4 +- 13 files changed, 109 insertions(+), 92 deletions(-) rename test/0.8.25/vaults/{accounting.test.ts => vaultHub.test.ts} (66%) diff --git a/contracts/0.8.25/Accounting.sol b/contracts/0.8.25/Accounting.sol index 321a4d1d1..cf14c5600 100644 --- a/contracts/0.8.25/Accounting.sol +++ b/contracts/0.8.25/Accounting.sol @@ -20,9 +20,7 @@ import {ReportValues} from "contracts/common/interfaces/ReportValues.sol"; /// @notice contract is responsible for handling accounting oracle reports /// calculating all the state changes that is required to apply the report /// and distributing calculated values to relevant parts of the protocol -/// @dev accounting is inherited from VaultHub contract to reduce gas costs and -/// simplify the auth flows, but they are mostly independent -contract Accounting is VaultHub { +contract Accounting { struct Contracts { address accountingOracleAddress; IOracleReportSanityChecker oracleReportSanityChecker; @@ -30,6 +28,7 @@ contract Accounting is VaultHub { IWithdrawalQueue withdrawalQueue; IPostTokenRebaseReceiver postTokenRebaseReceiver; IStakingRouter stakingRouter; + VaultHub vaultHub; } struct PreReportState { @@ -83,6 +82,8 @@ contract Accounting is VaultHub { uint256 precisionPoints; } + error NotAuthorized(string operation, address addr); + /// @notice deposit size in wei (for pre-maxEB accounting) uint256 private constant DEPOSIT_SIZE = 32 ether; @@ -93,24 +94,14 @@ contract Accounting is VaultHub { /// @param _lidoLocator Lido Locator contract /// @param _lido Lido contract - /// @param _connectedVaultsLimit Maximum number of active vaults that can be connected to the hub - /// @param _relativeShareLimitBP Maximum share limit for a single vault relative to Lido TVL in basis points constructor( ILidoLocator _lidoLocator, ILido _lido, - uint256 _connectedVaultsLimit, - uint256 _relativeShareLimitBP - ) VaultHub(_lido, _connectedVaultsLimit, _relativeShareLimitBP) { + ) { LIDO_LOCATOR = _lidoLocator; LIDO = _lido; } - function initialize(address _admin) external initializer { - if (_admin == address(0)) revert ZeroArgument("_admin"); - - __VaultHub_init(_admin); - } - /// @notice calculates all the state changes that is required to apply the report /// @param _report report values /// @param _withdrawalShareRate maximum share rate used for withdrawal finalization @@ -232,7 +223,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.vaultsTreasuryFeeShares, update.totalVaultsTreasuryFeeShares) = - _calculateVaultsRebase( + _contracts.vaultHub.calculateVaultsRebase( update.postTotalShares, update.postTotalPooledEther, _pre.totalShares, @@ -341,15 +332,16 @@ contract Accounting is VaultHub { _update.etherToFinalizeWQ ); - _updateVaults( - _report.vaultValues, - _report.inOutDeltas, - _update.vaultsLockedEther, - _update.vaultsTreasuryFeeShares - ); + // TODO: Remove this once decide on vaults reporting + // _updateVaults( + // _report.vaultValues, + // _report.inOutDeltas, + // _update.vaultsLockedEther, + // _update.vaultsTreasuryFeeShares + // ); if (_update.totalVaultsTreasuryFeeShares > 0) { - STETH.mintExternalShares(LIDO_LOCATOR.treasury(), _update.totalVaultsTreasuryFeeShares); + LIDO.mintExternalShares(LIDO_LOCATOR.treasury(), _update.totalVaultsTreasuryFeeShares); } _notifyRebaseObserver(_contracts.postTokenRebaseReceiver, _report, _pre, _update); @@ -469,7 +461,8 @@ contract Accounting is VaultHub { address burner, address withdrawalQueue, address postTokenRebaseReceiver, - address stakingRouter + address stakingRouter, + address vaultHub ) = LIDO_LOCATOR.oracleReportComponents(); return @@ -479,7 +472,8 @@ contract Accounting is VaultHub { IBurner(burner), IWithdrawalQueue(withdrawalQueue), IPostTokenRebaseReceiver(postTokenRebaseReceiver), - IStakingRouter(stakingRouter) + IStakingRouter(stakingRouter), + VaultHub(vaultHub) ); } diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index fd2df762e..8544bd5ce 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -18,7 +18,7 @@ import {Math256} from "contracts/common/lib/Math256.sol"; /// It also allows to force rebalance of the vaults /// Also, it passes the report from the accounting oracle to the vaults and charges fees /// @author folkyatina -abstract contract VaultHub is PausableUntilWithRoles { +contract VaultHub is PausableUntilWithRoles { /// @custom:storage-location erc7201:VaultHub struct VaultHubStorage { /// @notice vault sockets with vaults connected to the hub @@ -90,6 +90,12 @@ abstract contract VaultHub is PausableUntilWithRoles { _disableInitializers(); } + function initialize(address _admin) external initializer { + if (_admin == address(0)) revert ZeroArgument("_admin"); + + __VaultHub_init(_admin); + } + /// @param _admin admin address to manage the roles function __VaultHub_init(address _admin) internal onlyInitializing { __AccessControlEnumerable_init(); @@ -394,13 +400,13 @@ abstract contract VaultHub is PausableUntilWithRoles { emit VaultDisconnected(_vault); } - function _calculateVaultsRebase( + function calculateVaultsRebase( uint256 _postTotalShares, uint256 _postTotalPooledEther, uint256 _preTotalShares, uint256 _preTotalPooledEther, uint256 _sharesToMintAsFees - ) internal view returns (uint256[] memory lockedEther, uint256[] memory treasuryFeeShares, uint256 totalTreasuryFeeShares) { + ) external view returns (uint256[] memory lockedEther, uint256[] memory treasuryFeeShares, uint256 totalTreasuryFeeShares) { /// HERE WILL BE ACCOUNTING DRAGON // \||/ diff --git a/contracts/0.8.9/LidoLocator.sol b/contracts/0.8.9/LidoLocator.sol index 982d7c491..8bf1bfa64 100644 --- a/contracts/0.8.9/LidoLocator.sol +++ b/contracts/0.8.9/LidoLocator.sol @@ -30,6 +30,7 @@ contract LidoLocator is ILidoLocator { address oracleDaemonConfig; address accounting; address wstETH; + address vaultHub; } error ZeroAddress(); @@ -50,6 +51,7 @@ contract LidoLocator is ILidoLocator { address public immutable oracleDaemonConfig; address public immutable accounting; address public immutable wstETH; + address public immutable vaultHub; /** * @notice declare service locations @@ -73,6 +75,7 @@ contract LidoLocator is ILidoLocator { oracleDaemonConfig = _assertNonZero(_config.oracleDaemonConfig); accounting = _assertNonZero(_config.accounting); wstETH = _assertNonZero(_config.wstETH); + vaultHub = _assertNonZero(_config.vaultHub); } function coreComponents() external view returns( @@ -99,6 +102,7 @@ contract LidoLocator is ILidoLocator { address, address, address, + address, address ) { return ( @@ -107,7 +111,8 @@ contract LidoLocator is ILidoLocator { burner, withdrawalQueue, postTokenRebaseReceiver, - stakingRouter + stakingRouter, + vaultHub ); } diff --git a/contracts/common/interfaces/ILidoLocator.sol b/contracts/common/interfaces/ILidoLocator.sol index 5e5028bb4..8116d7fe9 100644 --- a/contracts/common/interfaces/ILidoLocator.sol +++ b/contracts/common/interfaces/ILidoLocator.sol @@ -22,7 +22,7 @@ interface ILidoLocator { function oracleDaemonConfig() external view returns(address); function accounting() external view returns (address); function wstETH() external view returns (address); - + function vaultHub() external view returns (address); /// @notice Returns core Lido protocol component addresses in a single call /// @dev This function provides a gas-efficient way to fetch multiple component addresses in a single call function coreComponents() external view returns( @@ -42,6 +42,7 @@ interface ILidoLocator { address burner, address withdrawalQueue, address postTokenRebaseReceiver, - address stakingRouter + address stakingRouter, + address vaultHub ); } diff --git a/test/0.8.25/vaults/vaultFactory.test.ts b/test/0.8.25/vaults/vaultFactory.test.ts index 800817bc8..006bbb54b 100644 --- a/test/0.8.25/vaults/vaultFactory.test.ts +++ b/test/0.8.25/vaults/vaultFactory.test.ts @@ -5,7 +5,6 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { - Accounting, BeaconProxy, Delegation, DepositContract__MockForBeaconChainDepositor, @@ -38,8 +37,8 @@ describe("VaultFactory.sol", () => { let depositContract: DepositContract__MockForBeaconChainDepositor; let proxy: OssifiableProxy; let beacon: UpgradeableBeacon; - let accountingImpl: Accounting; - let accounting: Accounting; + let vaultHubImpl: VaultHub; + let vaultHub: VaultHub; let implOld: StakingVault; let implNew: StakingVault__HarnessForTestUpgrade; let delegation: Delegation; @@ -76,20 +75,14 @@ describe("VaultFactory.sol", () => { depositContract = await ethers.deployContract("DepositContract__MockForBeaconChainDepositor", deployer); // Accounting - accountingImpl = await ethers.deployContract("Accounting", [ - locator, - steth, - VAULTS_CONNECTED_VAULTS_LIMIT, - VAULTS_RELATIVE_SHARE_LIMIT_BP, - ]); - - proxy = await ethers.deployContract("OssifiableProxy", [accountingImpl, admin, new Uint8Array()], admin); - accounting = await ethers.getContractAt("Accounting", proxy, deployer); - await accounting.initialize(admin); + vaultHubImpl = await ethers.deployContract("VaultHub", [steth, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP]); + proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, admin, new Uint8Array()], admin); + vaultHub = await ethers.getContractAt("VaultHub", proxy, deployer); + await vaultHub.initialize(admin); //vault implementation - implOld = await ethers.deployContract("StakingVault", [accounting, depositContract], { from: deployer }); - implNew = await ethers.deployContract("StakingVault__HarnessForTestUpgrade", [accounting, depositContract], { + implOld = await ethers.deployContract("StakingVault", [vaultHub, depositContract], { from: deployer }); + implNew = await ethers.deployContract("StakingVault__HarnessForTestUpgrade", [vaultHub, depositContract], { from: deployer, }); @@ -103,9 +96,9 @@ describe("VaultFactory.sol", () => { vaultFactory = await ethers.deployContract("VaultFactory", [beacon, delegation], { from: deployer }); //add VAULT_MASTER_ROLE role to allow admin to connect the Vaults to the vault Hub - await accounting.connect(admin).grantRole(await accounting.VAULT_MASTER_ROLE(), admin); + await vaultHub.connect(admin).grantRole(await vaultHub.VAULT_MASTER_ROLE(), admin); //add VAULT_REGISTRY_ROLE role to allow admin to add factory and vault implementation to the hub - await accounting.connect(admin).grantRole(await accounting.VAULT_REGISTRY_ROLE(), admin); + await vaultHub.connect(admin).grantRole(await vaultHub.VAULT_REGISTRY_ROLE(), admin); //the initialize() function cannot be called on a contract await expect(implOld.initialize(stranger, operator, "0x")).to.revertedWithCustomError( @@ -202,7 +195,7 @@ describe("VaultFactory.sol", () => { context("connect", () => { it("connect ", async () => { - const vaultsBefore = await accounting.vaultsCount(); + const vaultsBefore = await vaultHub.vaultsCount(); expect(vaultsBefore).to.eq(0); const config1 = { @@ -236,7 +229,7 @@ describe("VaultFactory.sol", () => { //attempting to add a vault without adding a proxy bytecode to the allowed list await expect( - accounting + vaultHub .connect(admin) .connectVault( await vault1.getAddress(), @@ -245,15 +238,15 @@ describe("VaultFactory.sol", () => { config1.rebalanceThresholdBP, config1.treasuryFeeBP, ), - ).to.revertedWithCustomError(accounting, "VaultProxyNotAllowed"); + ).to.revertedWithCustomError(vaultHub, "VaultProxyNotAllowed"); const vaultProxyCodeHash = keccak256(vaultBeaconProxyCode); //add proxy code hash to whitelist - await accounting.connect(admin).addVaultProxyCodehash(vaultProxyCodeHash); + await vaultHub.connect(admin).addVaultProxyCodehash(vaultProxyCodeHash); //connect vault 1 to VaultHub - await accounting + await vaultHub .connect(admin) .connectVault( await vault1.getAddress(), @@ -263,7 +256,7 @@ describe("VaultFactory.sol", () => { config1.treasuryFeeBP, ); - const vaultsAfter = await accounting.vaultsCount(); + const vaultsAfter = await vaultHub.vaultsCount(); expect(vaultsAfter).to.eq(1); const version1Before = await vault1.version(); @@ -283,7 +276,7 @@ describe("VaultFactory.sol", () => { //we upgrade implementation - we do not check implementation, just proxy bytecode await expect( - accounting + vaultHub .connect(admin) .connectVault( await vault2.getAddress(), @@ -292,7 +285,7 @@ describe("VaultFactory.sol", () => { config2.rebalanceThresholdBP, config2.treasuryFeeBP, ), - ).to.not.revertedWithCustomError(accounting, "VaultProxyNotAllowed"); + ).to.not.revertedWithCustomError(vaultHub, "VaultProxyNotAllowed"); const vault1WithNewImpl = await ethers.getContractAt("StakingVault__HarnessForTestUpgrade", vault1, deployer); const vault2WithNewImpl = await ethers.getContractAt("StakingVault__HarnessForTestUpgrade", vault2, deployer); diff --git a/test/0.8.25/vaults/accounting.test.ts b/test/0.8.25/vaults/vaultHub.test.ts similarity index 66% rename from test/0.8.25/vaults/accounting.test.ts rename to test/0.8.25/vaults/vaultHub.test.ts index 2b44169ff..4cbffe82b 100644 --- a/test/0.8.25/vaults/accounting.test.ts +++ b/test/0.8.25/vaults/vaultHub.test.ts @@ -4,36 +4,32 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { Accounting, LidoLocator, OssifiableProxy, StETH__Harness } from "typechain-types"; +import { OssifiableProxy, StETH__Harness, VaultHub } from "typechain-types"; import { ether } from "lib"; -import { deployLidoLocator } from "test/deploy"; import { Snapshot, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP } from "test/suite"; -describe("Accounting.sol", () => { +describe("VaultHub.sol", () => { let admin: HardhatEthersSigner; let user: HardhatEthersSigner; let holder: HardhatEthersSigner; let stranger: HardhatEthersSigner; let proxy: OssifiableProxy; - let vaultHubImpl: Accounting; - let accounting: Accounting; + let vaultHubImpl: VaultHub; let steth: StETH__Harness; - let locator: LidoLocator; + let vaultHub: VaultHub; let originalState: string; before(async () => { [admin, user, holder, stranger] = await ethers.getSigners(); - locator = await deployLidoLocator(); steth = await ethers.deployContract("StETH__Harness", [holder], { value: ether("10.0") }); // VaultHub - vaultHubImpl = await ethers.deployContract("Accounting", [ - locator, + vaultHubImpl = await ethers.deployContract("VaultHub", [ steth, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP, @@ -41,7 +37,7 @@ describe("Accounting.sol", () => { proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, admin, new Uint8Array()], admin); - accounting = await ethers.getContractAt("Accounting", proxy, user); + vaultHub = await ethers.getContractAt("VaultHub", proxy, user); }); beforeEach(async () => (originalState = await Snapshot.take())); @@ -56,16 +52,16 @@ describe("Accounting.sol", () => { ); }); it("reverts on `_admin` address is zero", async () => { - await expect(accounting.initialize(ZeroAddress)) - .to.be.revertedWithCustomError(vaultHubImpl, "ZeroArgument") + await expect(vaultHub.initialize(ZeroAddress)) + .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") .withArgs("_admin"); }); it("initialization happy path", async () => { - const tx = await accounting.initialize(admin); + const tx = await vaultHub.initialize(admin); - expect(await accounting.vaultsCount()).to.eq(0); + expect(await vaultHub.vaultsCount()).to.eq(0); - await expect(tx).to.be.emit(accounting, "Initialized").withArgs(1); + await expect(tx).to.be.emit(vaultHub, "Initialized").withArgs(1); }); }); }); diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts index 37615b30b..745e28c70 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts @@ -8,7 +8,6 @@ import { StETH__HarnessForVaultHub, VaultHub } from "typechain-types"; import { ether, MAX_UINT256 } from "lib"; -import { deployLidoLocator } from "test/deploy"; import { Snapshot, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP } from "test/suite"; describe("VaultHub.sol:pausableUntil", () => { @@ -16,6 +15,7 @@ describe("VaultHub.sol:pausableUntil", () => { let user: HardhatEthersSigner; let stranger: HardhatEthersSigner; + let vaultHubAdmin: VaultHub; let vaultHub: VaultHub; let steth: StETH__HarnessForVaultHub; @@ -24,24 +24,17 @@ describe("VaultHub.sol:pausableUntil", () => { before(async () => { [deployer, user, stranger] = await ethers.getSigners(); - const locator = await deployLidoLocator(); steth = await ethers.deployContract("StETH__HarnessForVaultHub", [user], { value: ether("1.0") }); - const vaultHubImpl = await ethers.deployContract("Accounting", [ - locator, - steth, - VAULTS_CONNECTED_VAULTS_LIMIT, - VAULTS_RELATIVE_SHARE_LIMIT_BP, - ]); - + const vaultHubImpl = await ethers.deployContract("VaultHub", [steth, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP]); const proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()]); - const accounting = await ethers.getContractAt("Accounting", proxy); - await accounting.initialize(deployer); + vaultHubAdmin = await ethers.getContractAt("VaultHub", proxy); + await vaultHubAdmin.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); + vaultHub = await ethers.getContractAt("VaultHub", proxy, user); + await vaultHubAdmin.grantRole(await vaultHub.PAUSE_ROLE(), user); + await vaultHubAdmin.grantRole(await vaultHub.RESUME_ROLE(), user); }); beforeEach(async () => (originalState = await Snapshot.take())); diff --git a/test/0.8.9/accounting.handleOracleReport.test.ts b/test/0.8.9/accounting.handleOracleReport.test.ts index 7d4680eb8..c08aadbf9 100644 --- a/test/0.8.9/accounting.handleOracleReport.test.ts +++ b/test/0.8.9/accounting.handleOracleReport.test.ts @@ -76,7 +76,16 @@ describe("Accounting.sol:report", () => { ); accounting = await ethers.getContractAt("Accounting", accountingProxy, deployer); await updateLidoLocatorImplementation(await locator.getAddress(), { accounting }); - await accounting.initialize(deployer); + + const vaultHubImpl = await ethers.deployContract("VaultHub", [lido], deployer); + const vaultHubProxy = await ethers.deployContract( + "OssifiableProxy", + [vaultHubImpl, deployer, new Uint8Array()], + deployer, + ); + const vaultHub = await ethers.getContractAt("VaultHub", vaultHubProxy, deployer); + await updateLidoLocatorImplementation(await locator.getAddress(), { vaultHub }); + await vaultHub.initialize(deployer); const accountingOracleSigner = await impersonate(await locator.accountingOracle(), ether("100.0")); accounting = accounting.connect(accountingOracleSigner); diff --git a/test/0.8.9/contracts/LidoLocator__MockForSanityChecker.sol b/test/0.8.9/contracts/LidoLocator__MockForSanityChecker.sol index c38818a9c..5bf49672a 100644 --- a/test/0.8.9/contracts/LidoLocator__MockForSanityChecker.sol +++ b/test/0.8.9/contracts/LidoLocator__MockForSanityChecker.sol @@ -24,6 +24,7 @@ contract LidoLocator__MockForSanityChecker is ILidoLocator { address oracleDaemonConfig; address accounting; address wstETH; + address vaultHub; } address public immutable lido; @@ -42,7 +43,7 @@ contract LidoLocator__MockForSanityChecker is ILidoLocator { address public immutable oracleDaemonConfig; address public immutable accounting; address public immutable wstETH; - + address public immutable vaultHub; constructor(ContractAddresses memory addresses) { lido = addresses.lido; depositSecurityModule = addresses.depositSecurityModule; @@ -60,20 +61,26 @@ contract LidoLocator__MockForSanityChecker is ILidoLocator { oracleDaemonConfig = addresses.oracleDaemonConfig; accounting = addresses.accounting; wstETH = addresses.wstETH; + vaultHub = addresses.vaultHub; } function coreComponents() external view returns (address, address, address, address, address, address) { return (elRewardsVault, oracleReportSanityChecker, stakingRouter, treasury, withdrawalQueue, withdrawalVault); } - function oracleReportComponents() external view returns (address, address, address, address, address, address) { + function oracleReportComponents() + external + view + returns (address, address, address, address, address, address, address) + { return ( accountingOracle, oracleReportSanityChecker, burner, withdrawalQueue, postTokenRebaseReceiver, - stakingRouter + stakingRouter, + vaultHub ); } } diff --git a/test/0.8.9/contracts/LidoLocator__MockMutable.sol b/test/0.8.9/contracts/LidoLocator__MockMutable.sol index e102d2a4d..a3a31c1c4 100644 --- a/test/0.8.9/contracts/LidoLocator__MockMutable.sol +++ b/test/0.8.9/contracts/LidoLocator__MockMutable.sol @@ -23,6 +23,7 @@ contract LidoLocator__MockMutable is ILidoLocator { address oracleDaemonConfig; address accounting; address wstETH; + address vaultHub; } error ZeroAddress(); @@ -43,7 +44,7 @@ contract LidoLocator__MockMutable is ILidoLocator { address public immutable oracleDaemonConfig; address public immutable accounting; address public immutable wstETH; - + address public immutable vaultHub; /** * @notice declare service locations * @dev accepts a struct to avoid the "stack-too-deep" error @@ -66,20 +67,26 @@ contract LidoLocator__MockMutable is ILidoLocator { oracleDaemonConfig = _assertNonZero(_config.oracleDaemonConfig); accounting = _assertNonZero(_config.accounting); wstETH = _assertNonZero(_config.wstETH); + vaultHub = _assertNonZero(_config.vaultHub); } function coreComponents() external view returns (address, address, address, address, address, address) { return (elRewardsVault, oracleReportSanityChecker, stakingRouter, treasury, withdrawalQueue, withdrawalVault); } - function oracleReportComponents() external view returns (address, address, address, address, address, address) { + function oracleReportComponents() + external + view + returns (address, address, address, address, address, address, address) + { return ( accountingOracle, oracleReportSanityChecker, burner, withdrawalQueue, postTokenRebaseReceiver, - stakingRouter + stakingRouter, + vaultHub ); } diff --git a/test/0.8.9/lidoLocator.test.ts b/test/0.8.9/lidoLocator.test.ts index 85f782432..3300358d8 100644 --- a/test/0.8.9/lidoLocator.test.ts +++ b/test/0.8.9/lidoLocator.test.ts @@ -22,6 +22,7 @@ const services = [ "oracleDaemonConfig", "accounting", "wstETH", + "vaultHub", ] as const; type ArrayToUnion = A[number]; @@ -92,6 +93,7 @@ describe("LidoLocator.sol", () => { withdrawalQueue, postTokenRebaseReceiver, stakingRouter, + vaultHub, } = config; expect(await locator.oracleReportComponents()).to.deep.equal([ @@ -101,6 +103,7 @@ describe("LidoLocator.sol", () => { withdrawalQueue, postTokenRebaseReceiver, stakingRouter, + vaultHub, ]); }); }); diff --git a/test/0.8.9/sanityChecker/oracleReportSanityChecker.negative-rebase.test.ts b/test/0.8.9/sanityChecker/oracleReportSanityChecker.negative-rebase.test.ts index 977c25343..340180a2b 100644 --- a/test/0.8.9/sanityChecker/oracleReportSanityChecker.negative-rebase.test.ts +++ b/test/0.8.9/sanityChecker/oracleReportSanityChecker.negative-rebase.test.ts @@ -87,6 +87,7 @@ describe("OracleReportSanityChecker.sol:negative-rebase", () => { oracleDaemonConfig: deployer.address, accounting: await accounting.getAddress(), wstETH: deployer.address, + vaultHub: deployer.address, }, ]); diff --git a/test/deploy/locator.ts b/test/deploy/locator.ts index e41e54111..cc7d650b9 100644 --- a/test/deploy/locator.ts +++ b/test/deploy/locator.ts @@ -28,8 +28,9 @@ async function deployDummyLocator(config?: Partial, de validatorsExitBusOracle: certainAddress("dummy-locator:validatorsExitBusOracle"), withdrawalQueue: certainAddress("dummy-locator:withdrawalQueue"), withdrawalVault: certainAddress("dummy-locator:withdrawalVault"), - accounting: certainAddress("dummy-locator:withdrawalVault"), + accounting: certainAddress("dummy-locator:accounting"), wstETH: certainAddress("dummy-locator:wstETH"), + vaultHub: certainAddress("dummy-locator:vaultHub"), ...config, }); @@ -106,6 +107,7 @@ async function getLocatorConfig(locatorAddress: string) { "oracleDaemonConfig", "accounting", "wstETH", + "vaultHub", ] as Partial[]; const configPromises = addresses.map((name) => locator[name]()); From e2ed0f42133f8be9f4c9696f776dc90c1bfa5e44 Mon Sep 17 00:00:00 2001 From: VP Date: Thu, 20 Feb 2025 21:40:04 +0100 Subject: [PATCH 168/184] test: fix import --- test/0.8.25/vaults/vaultFactory.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/0.8.25/vaults/vaultFactory.test.ts b/test/0.8.25/vaults/vaultFactory.test.ts index 006bbb54b..482fddd34 100644 --- a/test/0.8.25/vaults/vaultFactory.test.ts +++ b/test/0.8.25/vaults/vaultFactory.test.ts @@ -15,6 +15,7 @@ import { StETH__HarnessForVaultHub, UpgradeableBeacon, VaultFactory, + VaultHub, WETH9__MockForVault, WstETH__HarnessForVault, } from "typechain-types"; From 83f029a2d6fc4b84f9c4ff348de6299e1bb32be9 Mon Sep 17 00:00:00 2001 From: VP Date: Thu, 20 Feb 2025 21:40:17 +0100 Subject: [PATCH 169/184] feat: fix deployment --- lib/state-file.ts | 1 + .../steps/0090-deploy-non-aragon-contracts.ts | 5 +++++ .../0120-initialize-non-aragon-contracts.ts | 10 ++++----- scripts/scratch/steps/0130-grant-roles.ts | 12 +++++----- scripts/scratch/steps/0145-deploy-vaults.ts | 22 +++++++++---------- 5 files changed, 28 insertions(+), 22 deletions(-) diff --git a/lib/state-file.ts b/lib/state-file.ts index 474910b08..53057802e 100644 --- a/lib/state-file.ts +++ b/lib/state-file.ts @@ -87,6 +87,7 @@ export enum Sk { scratchDeployGasUsed = "scratchDeployGasUsed", minFirstAllocationStrategy = "minFirstAllocationStrategy", accounting = "accounting", + vaultHub = "vaultHub", tokenRebaseNotifier = "tokenRebaseNotifier", // Vaults stakingVaultImpl = "stakingVaultImpl", diff --git a/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts b/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts index fe2450ffb..c11d0a1fe 100644 --- a/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts +++ b/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts @@ -146,6 +146,10 @@ export async function main() { accountingParams.relativeShareLimitBP, ]); + const vaultHub = await deployBehindOssifiableProxy(Sk.vaultHub, "VaultHub", proxyContractsOwner, deployer, [ + lidoAddress, + ]); + // Deploy AccountingOracle const accountingOracle = await deployBehindOssifiableProxy( Sk.accountingOracle, @@ -213,6 +217,7 @@ export async function main() { oracleDaemonConfig.address, accounting.address, wstETH.address, + vaultHub.address, ]; await updateProxyImplementation(Sk.lidoLocator, "LidoLocator", locator.address, proxyContractsOwner, [locatorConfig]); } diff --git a/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts b/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts index 5caf33576..b2834c0df 100644 --- a/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts +++ b/scripts/scratch/steps/0120-initialize-non-aragon-contracts.ts @@ -28,7 +28,7 @@ export async function main() { const eip712StETHAddress = state[Sk.eip712StETH].address; const withdrawalVaultAddress = state[Sk.withdrawalVault].proxy.address; const oracleDaemonConfigAddress = state[Sk.oracleDaemonConfig].address; - const accountingAddress = state[Sk.accounting].proxy.address; + const vaultHubAddress = state[Sk.vaultHub].proxy.address; // Set admin addresses (using deployer for testnet) const testnetAdmin = deployer; @@ -37,7 +37,7 @@ export async function main() { const stakingRouterAdmin = testnetAdmin; const withdrawalQueueAdmin = testnetAdmin; const withdrawalVaultAdmin = testnetAdmin; - const accountingAdmin = testnetAdmin; + const vaultHubAdmin = testnetAdmin; // Initialize NodeOperatorsRegistry @@ -147,7 +147,7 @@ export async function main() { await makeTx(oracleDaemonConfig, "renounceRole", [CONFIG_MANAGER_ROLE, testnetAdmin], { from: testnetAdmin }); - // Initialize Accounting - const accounting = await loadContract("Accounting", accountingAddress); - await makeTx(accounting, "initialize", [accountingAdmin], { from: deployer }); + // Initialize VaultHub + const vaultHub = await loadContract("VaultHub", vaultHubAddress); + await makeTx(vaultHub, "initialize", [vaultHubAdmin], { from: deployer }); } diff --git a/scripts/scratch/steps/0130-grant-roles.ts b/scripts/scratch/steps/0130-grant-roles.ts index a68bb8999..4d1115709 100644 --- a/scripts/scratch/steps/0130-grant-roles.ts +++ b/scripts/scratch/steps/0130-grant-roles.ts @@ -1,10 +1,10 @@ import { ethers } from "hardhat"; import { - Accounting, Burner, StakingRouter, ValidatorsExitBusOracle, + VaultHub, WithdrawalQueueERC721, WithdrawalVault, } from "typechain-types"; @@ -31,7 +31,7 @@ export async function main() { const accountingAddress = state[Sk.accounting].proxy.address; const validatorsExitBusOracleAddress = state[Sk.validatorsExitBusOracle].proxy.address; const depositSecurityModuleAddress = state[Sk.depositSecurityModule].address; - + const vaultHubAddress = state[Sk.vaultHub].proxy.address; // StakingRouter const stakingRouter = await loadContract("StakingRouter", stakingRouterAddress); await makeTx( @@ -109,12 +109,12 @@ export async function main() { from: deployer, }); - // Accounting - const accounting = await loadContract("Accounting", accountingAddress); - await makeTx(accounting, "grantRole", [await accounting.VAULT_MASTER_ROLE(), agentAddress], { + // VaultHub + const vaultHub = await loadContract("VaultHub", vaultHubAddress); + await makeTx(vaultHub, "grantRole", [await vaultHub.VAULT_MASTER_ROLE(), agentAddress], { from: deployer, }); - await makeTx(accounting, "grantRole", [await accounting.VAULT_REGISTRY_ROLE(), deployer], { + await makeTx(vaultHub, "grantRole", [await vaultHub.VAULT_REGISTRY_ROLE(), deployer], { from: deployer, }); } diff --git a/scripts/scratch/steps/0145-deploy-vaults.ts b/scripts/scratch/steps/0145-deploy-vaults.ts index 9cdf4fbad..84c18a5fb 100644 --- a/scripts/scratch/steps/0145-deploy-vaults.ts +++ b/scripts/scratch/steps/0145-deploy-vaults.ts @@ -1,7 +1,7 @@ import { keccak256 } from "ethers"; import { ethers } from "hardhat"; -import { Accounting } from "typechain-types"; +import { VaultHub } from "typechain-types"; import { loadContract, makeTx } from "lib"; import { deployWithoutProxy } from "lib/deploy"; @@ -11,7 +11,7 @@ export async function main() { const deployer = (await ethers.provider.getSigner()).address; const state = readNetworkState({ deployer }); - const accountingAddress = state[Sk.accounting].proxy.address; + const vaultHubAddress = state[Sk.vaultHub].proxy.address; const locatorAddress = state[Sk.lidoLocator].proxy.address; const depositContract = state.chainSpec.depositContract; @@ -19,7 +19,7 @@ export async function main() { // Deploy StakingVault implementation contract const imp = await deployWithoutProxy(Sk.stakingVaultImpl, "StakingVault", deployer, [ - accountingAddress, + vaultHubAddress, depositContract, ]); const impAddress = await imp.getAddress(); @@ -50,17 +50,17 @@ export async function main() { console.log("Factory address", await factory.getAddress()); // Add VaultFactory and Vault implementation to the Accounting contract - const accounting = await loadContract("Accounting", accountingAddress); + const vaultHub = await loadContract("VaultHub", vaultHubAddress); // Grant roles for the Accounting contract - const vaultMasterRole = await accounting.VAULT_MASTER_ROLE(); - const vaultRegistryRole = await accounting.VAULT_REGISTRY_ROLE(); + const vaultMasterRole = await vaultHub.VAULT_MASTER_ROLE(); + const vaultRegistryRole = await vaultHub.VAULT_REGISTRY_ROLE(); - await makeTx(accounting, "grantRole", [vaultMasterRole, deployer], { from: deployer }); - await makeTx(accounting, "grantRole", [vaultRegistryRole, deployer], { from: deployer }); + await makeTx(vaultHub, "grantRole", [vaultMasterRole, deployer], { from: deployer }); + await makeTx(vaultHub, "grantRole", [vaultRegistryRole, deployer], { from: deployer }); - await makeTx(accounting, "addVaultProxyCodehash", [vaultBeaconProxyCodeHash], { from: deployer }); + await makeTx(vaultHub, "addVaultProxyCodehash", [vaultBeaconProxyCodeHash], { from: deployer }); - await makeTx(accounting, "renounceRole", [vaultMasterRole, deployer], { from: deployer }); - await makeTx(accounting, "renounceRole", [vaultRegistryRole, deployer], { from: deployer }); + await makeTx(vaultHub, "renounceRole", [vaultMasterRole, deployer], { from: deployer }); + await makeTx(vaultHub, "renounceRole", [vaultRegistryRole, deployer], { from: deployer }); } From dd4d813a99efb1c7e6b1a50bb7d995defa35cd46 Mon Sep 17 00:00:00 2001 From: VP Date: Fri, 21 Feb 2025 09:53:35 +0100 Subject: [PATCH 170/184] feat: fix VaultHub deployment --- lib/deploy.ts | 1 + lib/protocol/discover.ts | 6 ++++-- lib/protocol/types.ts | 4 ++++ scripts/scratch/steps/0150-transfer-roles.ts | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/deploy.ts b/lib/deploy.ts index e753f0091..8b308d604 100644 --- a/lib/deploy.ts +++ b/lib/deploy.ts @@ -248,6 +248,7 @@ async function getLocatorConfig(locatorAddress: string) { "oracleDaemonConfig", "accounting", "wstETH", + "vaultHub", ] as (keyof LidoLocator.ConfigStruct)[]; const configPromises = addresses.map((name) => locator[name]()); diff --git a/lib/protocol/discover.ts b/lib/protocol/discover.ts index 3032020f5..63234c329 100644 --- a/lib/protocol/discover.ts +++ b/lib/protocol/discover.ts @@ -158,10 +158,11 @@ const getWstEthContract = async ( /** * Load all required vaults contracts. */ -const getVaultsContracts = async (config: ProtocolNetworkConfig) => { +const getVaultsContracts = async (config: ProtocolNetworkConfig, locator: LoadedContract) => { return (await batch({ stakingVaultFactory: loadContract("VaultFactory", config.get("stakingVaultFactory")), stakingVaultBeacon: loadContract("UpgradeableBeacon", config.get("stakingVaultBeacon")), + vaultHub: loadContract("VaultHub", config.get("vaultHub") || (await locator.vaultHub())), })) as VaultsContracts; }; @@ -177,7 +178,7 @@ export async function discover() { ...(await getStakingModules(foundationContracts.stakingRouter, networkConfig)), ...(await getHashConsensusContract(foundationContracts.accountingOracle, networkConfig)), ...(await getWstEthContract(foundationContracts.withdrawalQueue, networkConfig)), - ...(await getVaultsContracts(networkConfig)), + ...(await getVaultsContracts(networkConfig, locator)), } as ProtocolContracts; log.debug("Contracts discovered", { @@ -204,6 +205,7 @@ export async function discover() { // Vaults "Staking Vault Factory": contracts.stakingVaultFactory.address, "Staking Vault Beacon": contracts.stakingVaultBeacon.address, + "Vault Hub": contracts.vaultHub.address, }); const signers = { diff --git a/lib/protocol/types.ts b/lib/protocol/types.ts index bbb168f12..9fbd533a3 100644 --- a/lib/protocol/types.ts +++ b/lib/protocol/types.ts @@ -21,6 +21,7 @@ import { UpgradeableBeacon, ValidatorsExitBusOracle, VaultFactory, + VaultHub, WithdrawalQueueERC721, WithdrawalVault, WstETH, @@ -58,6 +59,7 @@ export type ProtocolNetworkItems = { // vaults stakingVaultFactory: string; stakingVaultBeacon: string; + vaultHub: string; }; export interface ContractTypes { @@ -82,6 +84,7 @@ export interface ContractTypes { WstETH: WstETH; VaultFactory: VaultFactory; UpgradeableBeacon: UpgradeableBeacon; + VaultHub: VaultHub; } export type ContractName = keyof ContractTypes; @@ -133,6 +136,7 @@ export type WstETHContracts = { export type VaultsContracts = { stakingVaultFactory: LoadedContract; stakingVaultBeacon: LoadedContract; + vaultHub: LoadedContract; }; export type ProtocolContracts = { locator: LoadedContract } & CoreContracts & diff --git a/scripts/scratch/steps/0150-transfer-roles.ts b/scripts/scratch/steps/0150-transfer-roles.ts index 39e2e8759..0b7a05df0 100644 --- a/scripts/scratch/steps/0150-transfer-roles.ts +++ b/scripts/scratch/steps/0150-transfer-roles.ts @@ -23,7 +23,7 @@ export async function main() { { name: "WithdrawalQueueERC721", address: state.withdrawalQueueERC721.proxy.address }, { name: "OracleDaemonConfig", address: state.oracleDaemonConfig.address }, { name: "OracleReportSanityChecker", address: state.oracleReportSanityChecker.address }, - { name: "Accounting", address: state.accounting.proxy.address }, + { name: "VaultHub", address: state.vaultHub.proxy.address }, ]; for (const contract of ozAdminTransfers) { From 9c8d7d78ed7cb69c388188922465ee6e69c8680c Mon Sep 17 00:00:00 2001 From: VP Date: Fri, 21 Feb 2025 09:54:00 +0100 Subject: [PATCH 171/184] feat: fix auth from VaultHub & Accounting to Lido --- contracts/0.4.24/Lido.sol | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/contracts/0.4.24/Lido.sol b/contracts/0.4.24/Lido.sol index 77a9337c9..e907b2743 100644 --- a/contracts/0.4.24/Lido.sol +++ b/contracts/0.4.24/Lido.sol @@ -586,7 +586,7 @@ contract Lido is Versioned, StETHPermit, AragonApp { * @dev can be called only by accounting */ function mintShares(address _recipient, uint256 _amountOfShares) public { - _auth(getLidoLocator().accounting()); + _authBoth(getLidoLocator().accounting(), getLidoLocator().vaultHub()); _whenNotStopped(); _mintShares(_recipient, _amountOfShares); @@ -639,7 +639,7 @@ contract Lido is Versioned, StETHPermit, AragonApp { */ function burnExternalShares(uint256 _amountOfShares) external { require(_amountOfShares != 0, "BURN_ZERO_AMOUNT_OF_SHARES"); - _auth(getLidoLocator().accounting()); + _auth(getLidoLocator().vaultHub()); _whenNotStopped(); uint256 externalShares = EXTERNAL_SHARES_POSITION.getStorageUint256(); @@ -663,7 +663,7 @@ contract Lido is Versioned, StETHPermit, AragonApp { */ function rebalanceExternalEtherToInternal() external payable { require(msg.value != 0, "ZERO_VALUE"); - _auth(getLidoLocator().accounting()); + _auth(getLidoLocator().vaultHub()); _whenNotStopped(); uint256 shares = getSharesByPooledEth(msg.value); @@ -1028,6 +1028,10 @@ contract Lido is Versioned, StETHPermit, AragonApp { require(msg.sender == _address, "APP_AUTH_FAILED"); } + function _authBoth(address _address1, address _address2) internal view { + require(msg.sender == _address1 || msg.sender == _address2, "APP_AUTH_FAILED"); + } + function _stakingRouter() internal view returns (IStakingRouter) { return IStakingRouter(getLidoLocator().stakingRouter()); } From 00e8d0eebe9ce9d0a0aa91f74238bc8689ad22e2 Mon Sep 17 00:00:00 2001 From: VP Date: Fri, 21 Feb 2025 10:02:02 +0100 Subject: [PATCH 172/184] test: comment unusable tests --- .../vaults/happy-path.integration.ts | 157 +++++++++--------- 1 file changed, 80 insertions(+), 77 deletions(-) diff --git a/test/integration/vaults/happy-path.integration.ts b/test/integration/vaults/happy-path.integration.ts index fa144058e..a4de0d617 100644 --- a/test/integration/vaults/happy-path.integration.ts +++ b/test/integration/vaults/happy-path.integration.ts @@ -1,5 +1,5 @@ import { expect } from "chai"; -import { ContractTransactionReceipt, hexlify, TransactionResponse, ZeroAddress } from "ethers"; +import { ContractTransactionReceipt, hexlify, ZeroAddress } from "ethers"; import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; @@ -142,8 +142,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { const _stakingVault = await ethers.getContractAt("StakingVault", implAddress); const _delegation = await ethers.getContractAt("Delegation", delegationAddress); - expect(await _stakingVault.vaultHub()).to.equal(ctx.contracts.accounting.address); - expect(await _stakingVault.DEPOSIT_CONTRACT()).to.equal(depositContract); + expect(await _stakingVault.depositContract()).to.equal(depositContract); expect(await _delegation.STETH()).to.equal(ctx.contracts.lido.address); // TODO: check what else should be validated here @@ -206,7 +205,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { }); it("Should allow Lido to recognize vaults and connect them to accounting", async () => { - const { lido, accounting } = ctx.contracts; + const { lido, vaultHub } = ctx.contracts; expect(await stakingVault.locked()).to.equal(0); // no ETH locked yet @@ -218,11 +217,11 @@ describe("Scenario: Staking Vaults Happy Path", () => { const agentSigner = await ctx.getSigner("agent"); - await accounting + await vaultHub .connect(agentSigner) .connectVault(stakingVault, shareLimit, reserveRatio, rebalanceThreshold, treasuryFeeBP); - expect(await accounting.vaultsCount()).to.equal(1n); + expect(await vaultHub.vaultsCount()).to.equal(1n); expect(await stakingVault.locked()).to.equal(VAULT_CONNECTION_DEPOSIT); }); @@ -268,7 +267,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { }); it("Should allow Token Master to mint max stETH", async () => { - const { accounting, lido } = ctx.contracts; + const { vaultHub, lido } = ctx.contracts; // Calculate the max stETH that can be minted on the vault 101 with the given LTV stakingVaultMaxMintingShares = await lido.getSharesByPooledEth( @@ -284,7 +283,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { // Validate minting with the cap const mintOverLimitTx = delegation.connect(curator).mintShares(curator, stakingVaultMaxMintingShares + 1n); await expect(mintOverLimitTx) - .to.be.revertedWithCustomError(accounting, "InsufficientValuationToMint") + .to.be.revertedWithCustomError(vaultHub, "InsufficientValuationToMint") .withArgs(stakingVault, stakingVault.valuation()); const mintTx = await delegation.connect(curator).mintShares(curator, stakingVaultMaxMintingShares); @@ -307,60 +306,62 @@ describe("Scenario: Staking Vaults Happy Path", () => { }); }); - it("Should rebase simulating 3% APR", async () => { - const { elapsedProtocolReward, elapsedVaultReward } = await calculateReportParams(); - const vaultValue = await addRewards(elapsedVaultReward); + // TODO: This test is not working, because of the accounting logic for vaults has been changed + // it("Should rebase simulating 3% APR", async () => { + // const { elapsedProtocolReward, elapsedVaultReward } = await calculateReportParams(); + // const vaultValue = await addRewards(elapsedVaultReward); - const params = { - clDiff: elapsedProtocolReward, - excludeVaultsBalances: true, - vaultValues: [vaultValue], - inOutDeltas: [VAULT_DEPOSIT], - } as OracleReportParams; + // const params = { + // clDiff: elapsedProtocolReward, + // excludeVaultsBalances: true, + // vaultValues: [vaultValue], + // inOutDeltas: [VAULT_DEPOSIT], + // } as OracleReportParams; - const { reportTx } = (await report(ctx, params)) as { - reportTx: TransactionResponse; - extraDataTx: TransactionResponse; - }; - const reportTxReceipt = (await reportTx.wait()) as ContractTransactionReceipt; + // const { reportTx } = (await report(ctx, params)) as { + // reportTx: TransactionResponse; + // extraDataTx: TransactionResponse; + // }; + // const reportTxReceipt = (await reportTx.wait()) as ContractTransactionReceipt; - const errorReportingEvent = ctx.getEvents(reportTxReceipt, "OnReportFailed", [stakingVault.interface]); - expect(errorReportingEvent.length).to.equal(0n); + // const errorReportingEvent = ctx.getEvents(reportTxReceipt, "OnReportFailed", [stakingVault.interface]); + // expect(errorReportingEvent.length).to.equal(0n); - const vaultReportedEvent = ctx.getEvents(reportTxReceipt, "Reported", [stakingVault.interface]); - expect(vaultReportedEvent.length).to.equal(1n); + // const vaultReportedEvent = ctx.getEvents(reportTxReceipt, "Reported", [stakingVault.interface]); + // expect(vaultReportedEvent.length).to.equal(1n); - expect(vaultReportedEvent[0].args?.valuation).to.equal(vaultValue); - expect(vaultReportedEvent[0].args?.inOutDelta).to.equal(VAULT_DEPOSIT); - // TODO: add assertions or locked values and rewards + // expect(vaultReportedEvent[0].args?.valuation).to.equal(vaultValue); + // expect(vaultReportedEvent[0].args?.inOutDelta).to.equal(VAULT_DEPOSIT); + // TODO: add assertions or locked values and rewards - expect(await delegation.curatorUnclaimedFee()).to.be.gt(0n); - expect(await delegation.nodeOperatorUnclaimedFee()).to.be.gt(0n); - }); + // expect(await delegation.curatorUnclaimedFee()).to.be.gt(0n); + // expect(await delegation.nodeOperatorUnclaimedFee()).to.be.gt(0n); + // }); - it("Should allow Operator to claim performance fees", async () => { - const performanceFee = await delegation.nodeOperatorUnclaimedFee(); - log.debug("Staking Vault stats", { - "Staking Vault performance fee": ethers.formatEther(performanceFee), - }); + // TODO: As reporting for vaults is not implemented yet, we can't test this + // it("Should allow Operator to claim performance fees", async () => { + // const performanceFee = await delegation.nodeOperatorUnclaimedFee(); + // log.debug("Staking Vault stats", { + // "Staking Vault performance fee": ethers.formatEther(performanceFee), + // }); - const operatorBalanceBefore = await ethers.provider.getBalance(nodeOperator); + // const operatorBalanceBefore = await ethers.provider.getBalance(nodeOperator); - const claimPerformanceFeesTx = await delegation.connect(nodeOperator).claimNodeOperatorFee(nodeOperator); - const claimPerformanceFeesTxReceipt = (await claimPerformanceFeesTx.wait()) as ContractTransactionReceipt; + // const claimPerformanceFeesTx = await delegation.connect(nodeOperator).claimNodeOperatorFee(nodeOperator); + // const claimPerformanceFeesTxReceipt = (await claimPerformanceFeesTx.wait()) as ContractTransactionReceipt; - const operatorBalanceAfter = await ethers.provider.getBalance(nodeOperator); - const gasFee = claimPerformanceFeesTxReceipt.gasPrice * claimPerformanceFeesTxReceipt.cumulativeGasUsed; + // const operatorBalanceAfter = await ethers.provider.getBalance(nodeOperator); + // const gasFee = claimPerformanceFeesTxReceipt.gasPrice * claimPerformanceFeesTxReceipt.cumulativeGasUsed; - log.debug("Operator's StETH balance", { - "Balance before": ethers.formatEther(operatorBalanceBefore), - "Balance after": ethers.formatEther(operatorBalanceAfter), - "Gas used": claimPerformanceFeesTxReceipt.cumulativeGasUsed, - "Gas fees": ethers.formatEther(gasFee), - }); + // log.debug("Operator's StETH balance", { + // "Balance before": ethers.formatEther(operatorBalanceBefore), + // "Balance after": ethers.formatEther(operatorBalanceAfter), + // "Gas used": claimPerformanceFeesTxReceipt.cumulativeGasUsed, + // "Gas fees": ethers.formatEther(gasFee), + // }); - expect(operatorBalanceAfter).to.equal(operatorBalanceBefore + performanceFee - gasFee); - }); + // expect(operatorBalanceAfter).to.equal(operatorBalanceBefore + performanceFee - gasFee); + // }); it("Should allow Owner to trigger validator exit to cover fees", async () => { // simulate validator exit @@ -381,32 +382,33 @@ describe("Scenario: Staking Vaults Happy Path", () => { await report(ctx, params); }); - it("Should allow Manager to claim manager rewards in ETH after rebase with exited validator", async () => { - const feesToClaim = await delegation.curatorUnclaimedFee(); + // TODO: As reporting for vaults is not implemented yet, we can't test this + // it("Should allow Manager to claim manager rewards in ETH after rebase with exited validator", async () => { + // const feesToClaim = await delegation.curatorUnclaimedFee(); - log.debug("Staking Vault stats after operator exit", { - "Staking Vault management fee": ethers.formatEther(feesToClaim), - "Staking Vault balance": ethers.formatEther(await ethers.provider.getBalance(stakingVaultAddress)), - }); + // log.debug("Staking Vault stats after operator exit", { + // "Staking Vault management fee": ethers.formatEther(feesToClaim), + // "Staking Vault balance": ethers.formatEther(await ethers.provider.getBalance(stakingVaultAddress)), + // }); - const managerBalanceBefore = await ethers.provider.getBalance(curator); + // const managerBalanceBefore = await ethers.provider.getBalance(curator); - const claimEthTx = await delegation.connect(curator).claimCuratorFee(curator); - const { gasUsed, gasPrice } = (await claimEthTx.wait()) as ContractTransactionReceipt; + // const claimEthTx = await delegation.connect(curator).claimCuratorFee(curator); + // const { gasUsed, gasPrice } = (await claimEthTx.wait()) as ContractTransactionReceipt; - const managerBalanceAfter = await ethers.provider.getBalance(curator); - const vaultBalance = await ethers.provider.getBalance(stakingVaultAddress); + // const managerBalanceAfter = await ethers.provider.getBalance(curator); + // const vaultBalance = await ethers.provider.getBalance(stakingVaultAddress); - log.debug("Balances after owner fee claim", { - "Manager's ETH balance before": ethers.formatEther(managerBalanceBefore), - "Manager's ETH balance after": ethers.formatEther(managerBalanceAfter), - "Manager's ETH balance diff": ethers.formatEther(managerBalanceAfter - managerBalanceBefore), - "Staking Vault owner fee": ethers.formatEther(feesToClaim), - "Staking Vault balance": ethers.formatEther(vaultBalance), - }); + // log.debug("Balances after owner fee claim", { + // "Manager's ETH balance before": ethers.formatEther(managerBalanceBefore), + // "Manager's ETH balance after": ethers.formatEther(managerBalanceAfter), + // "Manager's ETH balance diff": ethers.formatEther(managerBalanceAfter - managerBalanceBefore), + // "Staking Vault owner fee": ethers.formatEther(feesToClaim), + // "Staking Vault balance": ethers.formatEther(vaultBalance), + // }); - expect(managerBalanceAfter).to.equal(managerBalanceBefore + feesToClaim - gasUsed * gasPrice); - }); + // expect(managerBalanceAfter).to.equal(managerBalanceBefore + feesToClaim - gasUsed * gasPrice); + // }); it("Should allow Token Master to burn shares to repay debt", async () => { const { lido } = ctx.contracts; @@ -433,16 +435,17 @@ describe("Scenario: Staking Vaults Happy Path", () => { // TODO: add more checks here }); - it("Should allow Manager to rebalance the vault to reduce the debt", async () => { - const { accounting, lido } = ctx.contracts; + // TODO: As reporting for vaults is not implemented yet, we can't test this + // it("Should allow Manager to rebalance the vault to reduce the debt", async () => { + // const { vaultHub, lido } = ctx.contracts; - const socket = await accounting["vaultSocket(address)"](stakingVaultAddress); - const sharesMinted = await lido.getPooledEthByShares(socket.sharesMinted); + // const socket = await vaultHub["vaultSocket(address)"](stakingVaultAddress); + // const sharesMinted = await lido.getPooledEthByShares(socket.sharesMinted); - await delegation.connect(curator).rebalanceVault(sharesMinted, { value: sharesMinted }); + // await delegation.connect(curator).rebalanceVault(sharesMinted, { value: sharesMinted }); - expect(await stakingVault.locked()).to.equal(VAULT_CONNECTION_DEPOSIT); // 1 ETH locked as a connection fee - }); + // 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(); From d2ddc683ae4fbbe3aa5627291fbbe368044d624f Mon Sep 17 00:00:00 2001 From: VP Date: Fri, 21 Feb 2025 12:37:09 +0100 Subject: [PATCH 173/184] test: change actor to VaultHub instead of Accounting --- test/0.4.24/lido/lido.externalShares.test.ts | 58 ++++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/test/0.4.24/lido/lido.externalShares.test.ts b/test/0.4.24/lido/lido.externalShares.test.ts index 735e4bdd5..58a347c86 100644 --- a/test/0.4.24/lido/lido.externalShares.test.ts +++ b/test/0.4.24/lido/lido.externalShares.test.ts @@ -17,7 +17,7 @@ describe("Lido.sol:externalShares", () => { let deployer: HardhatEthersSigner; let user: HardhatEthersSigner; let whale: HardhatEthersSigner; - let accountingSigner: HardhatEthersSigner; + let vaultHubSigner: HardhatEthersSigner; let lido: Lido; let acl: ACL; @@ -43,7 +43,7 @@ describe("Lido.sol:externalShares", () => { const locatorAddress = await lido.getLidoLocator(); locator = await ethers.getContractAt("LidoLocator", locatorAddress, deployer); - accountingSigner = await impersonate(await locator.accounting(), ether("1")); + vaultHubSigner = await impersonate(await locator.vaultHub(), ether("1")); // Add some ether to the protocol await lido.connect(whale).submit(ZeroAddress, { value: ether("1000") }); @@ -105,7 +105,7 @@ describe("Lido.sol:externalShares", () => { // Add some external ether to protocol const amountToMint = (await lido.getMaxMintableExternalShares()) - 1n; - await lido.connect(accountingSigner).mintExternalShares(whale, amountToMint); + await lido.connect(vaultHubSigner).mintExternalShares(whale, amountToMint); expect(await lido.getExternalShares()).to.equal(amountToMint); }); @@ -130,7 +130,7 @@ describe("Lido.sol:externalShares", () => { it("Returns zero after minting max available amount", async () => { const amountToMint = await lido.getMaxMintableExternalShares(); - await lido.connect(accountingSigner).mintExternalShares(whale, amountToMint); + await lido.connect(vaultHubSigner).mintExternalShares(whale, amountToMint); expect(await lido.getMaxMintableExternalShares()).to.equal(0n); }); @@ -180,7 +180,7 @@ describe("Lido.sol:externalShares", () => { await lido.setMaxExternalRatioBP(maxExternalRatioBP); const maxAvailable = await lido.getMaxMintableExternalShares(); - await expect(lido.connect(accountingSigner).mintExternalShares(whale, maxAvailable + 1n)).to.be.revertedWith( + await expect(lido.connect(vaultHubSigner).mintExternalShares(whale, maxAvailable + 1n)).to.be.revertedWith( "EXTERNAL_BALANCE_LIMIT_EXCEEDED", ); }); @@ -189,7 +189,7 @@ describe("Lido.sol:externalShares", () => { await lido.stop(); await lido.setMaxExternalRatioBP(maxExternalRatioBP); - await expect(lido.connect(accountingSigner).mintExternalShares(whale, 1n)).to.be.revertedWith( + await expect(lido.connect(vaultHubSigner).mintExternalShares(whale, 1n)).to.be.revertedWith( "CONTRACT_IS_STOPPED", ); }); @@ -202,7 +202,7 @@ describe("Lido.sol:externalShares", () => { const sharesToMint = 1n; const etherToMint = await lido.getPooledEthByShares(sharesToMint); - await expect(lido.connect(accountingSigner).mintExternalShares(whale, sharesToMint)) + await expect(lido.connect(vaultHubSigner).mintExternalShares(whale, sharesToMint)) .to.emit(lido, "Transfer") .withArgs(ZeroAddress, whale, etherToMint) .to.emit(lido, "TransferShares") @@ -227,22 +227,22 @@ describe("Lido.sol:externalShares", () => { }); it("if external balance is too small", async () => { - await expect(lido.connect(accountingSigner).burnExternalShares(1n)).to.be.revertedWith("EXT_SHARES_TOO_SMALL"); + await expect(lido.connect(vaultHubSigner).burnExternalShares(1n)).to.be.revertedWith("EXT_SHARES_TOO_SMALL"); }); it("if protocol is stopped", async () => { await lido.stop(); - await expect(lido.connect(accountingSigner).burnExternalShares(1n)).to.be.revertedWith("CONTRACT_IS_STOPPED"); + await expect(lido.connect(vaultHubSigner).burnExternalShares(1n)).to.be.revertedWith("CONTRACT_IS_STOPPED"); }); it("if trying to burn more than minted", async () => { await lido.setMaxExternalRatioBP(maxExternalRatioBP); const amount = 100n; - await lido.connect(accountingSigner).mintExternalShares(whale, amount); + await lido.connect(vaultHubSigner).mintExternalShares(whale, amount); - await expect(lido.connect(accountingSigner).burnExternalShares(amount + 1n)).to.be.revertedWith( + await expect(lido.connect(vaultHubSigner).burnExternalShares(amount + 1n)).to.be.revertedWith( "EXT_SHARES_TOO_SMALL", ); }); @@ -253,18 +253,18 @@ describe("Lido.sol:externalShares", () => { await lido.setMaxExternalRatioBP(maxExternalRatioBP); const amountToMint = await lido.getMaxMintableExternalShares(); - await lido.connect(accountingSigner).mintExternalShares(accountingSigner.address, amountToMint); + await lido.connect(vaultHubSigner).mintExternalShares(vaultHubSigner.address, amountToMint); // Now burn them const stethAmount = await lido.getPooledEthByShares(amountToMint); - await expect(lido.connect(accountingSigner).burnExternalShares(amountToMint)) + await expect(lido.connect(vaultHubSigner).burnExternalShares(amountToMint)) .to.emit(lido, "Transfer") - .withArgs(accountingSigner.address, ZeroAddress, stethAmount) + .withArgs(vaultHubSigner.address, ZeroAddress, stethAmount) .to.emit(lido, "TransferShares") - .withArgs(accountingSigner.address, ZeroAddress, amountToMint) + .withArgs(vaultHubSigner.address, ZeroAddress, amountToMint) .to.emit(lido, "ExternalSharesBurned") - .withArgs(accountingSigner.address, amountToMint, stethAmount); + .withArgs(vaultHubSigner.address, amountToMint, stethAmount); // Verify external balance was reduced const externalEther = await lido.getExternalEther(); @@ -275,15 +275,15 @@ describe("Lido.sol:externalShares", () => { await lido.setMaxExternalRatioBP(maxExternalRatioBP); // Multiple mints - await lido.connect(accountingSigner).mintExternalShares(accountingSigner.address, 100n); - await lido.connect(accountingSigner).mintExternalShares(accountingSigner.address, 200n); + await lido.connect(vaultHubSigner).mintExternalShares(vaultHubSigner.address, 100n); + await lido.connect(vaultHubSigner).mintExternalShares(vaultHubSigner.address, 200n); // Burn partial amount - await lido.connect(accountingSigner).burnExternalShares(150n); + await lido.connect(vaultHubSigner).burnExternalShares(150n); expect(await lido.getExternalShares()).to.equal(150n); // Burn remaining - await lido.connect(accountingSigner).burnExternalShares(150n); + await lido.connect(vaultHubSigner).burnExternalShares(150n); expect(await lido.getExternalShares()).to.equal(0n); }); }); @@ -302,7 +302,7 @@ describe("Lido.sol:externalShares", () => { it("Reverts if amount of ether is greater than minted shares", async () => { await expect( lido - .connect(accountingSigner) + .connect(vaultHubSigner) .rebalanceExternalEtherToInternal({ value: await lido.getPooledEthBySharesRoundUp(1n) }), ).to.be.revertedWith("EXT_SHARES_TOO_SMALL"); }); @@ -311,13 +311,13 @@ describe("Lido.sol:externalShares", () => { await lido.setMaxExternalRatioBP(maxExternalRatioBP); const amountToMint = await lido.getMaxMintableExternalShares(); - await lido.connect(accountingSigner).mintExternalShares(accountingSigner.address, amountToMint); + await lido.connect(vaultHubSigner).mintExternalShares(vaultHubSigner.address, amountToMint); const bufferedEtherBefore = await lido.getBufferedEther(); const etherToRebalance = await lido.getPooledEthBySharesRoundUp(1n); - await lido.connect(accountingSigner).rebalanceExternalEtherToInternal({ + await lido.connect(vaultHubSigner).rebalanceExternalEtherToInternal({ value: etherToRebalance, }); @@ -332,15 +332,15 @@ describe("Lido.sol:externalShares", () => { }); it("Can mint and burn without precision loss", async () => { - await lido.connect(accountingSigner).mintExternalShares(accountingSigner, 1n); // 1 wei - await lido.connect(accountingSigner).mintExternalShares(accountingSigner, 1n); // 2 wei - await lido.connect(accountingSigner).mintExternalShares(accountingSigner, 1n); // 3 wei - await lido.connect(accountingSigner).mintExternalShares(accountingSigner, 1n); // 4 wei + await lido.connect(vaultHubSigner).mintExternalShares(vaultHubSigner, 1n); // 1 wei + await lido.connect(vaultHubSigner).mintExternalShares(vaultHubSigner, 1n); // 2 wei + await lido.connect(vaultHubSigner).mintExternalShares(vaultHubSigner, 1n); // 3 wei + await lido.connect(vaultHubSigner).mintExternalShares(vaultHubSigner, 1n); // 4 wei - await expect(lido.connect(accountingSigner).burnExternalShares(4n)).not.to.be.reverted; // 4 * 1.5 = 6 wei + await expect(lido.connect(vaultHubSigner).burnExternalShares(4n)).not.to.be.reverted; // 4 * 1.5 = 6 wei expect(await lido.getExternalEther()).to.equal(0n); expect(await lido.getExternalShares()).to.equal(0n); - expect(await lido.sharesOf(accountingSigner)).to.equal(0n); + expect(await lido.sharesOf(vaultHubSigner)).to.equal(0n); }); }); From 3acdc4fcaa6090e8d683ad47f4ea58487e105e34 Mon Sep 17 00:00:00 2001 From: VP Date: Tue, 25 Feb 2025 18:17:39 +0100 Subject: [PATCH 174/184] feat: put back vaults accounting --- contracts/0.8.25/Accounting.sol | 12 +- contracts/0.8.25/vaults/VaultHub.sol | 9 +- .../steps/0090-deploy-non-aragon-contracts.ts | 1 + test/0.8.25/vaults/vaultFactory.test.ts | 2 +- test/0.8.25/vaults/vaultHub.test.ts | 1 + .../vaults/vaulthub/vaulthub.pausable.test.ts | 3 +- .../accounting.handleOracleReport.test.ts | 2 +- .../vaults/happy-path.integration.ts | 144 +++++++++--------- 8 files changed, 88 insertions(+), 86 deletions(-) diff --git a/contracts/0.8.25/Accounting.sol b/contracts/0.8.25/Accounting.sol index cf14c5600..4e07fb1c3 100644 --- a/contracts/0.8.25/Accounting.sol +++ b/contracts/0.8.25/Accounting.sol @@ -333,12 +333,12 @@ contract Accounting { ); // TODO: Remove this once decide on vaults reporting - // _updateVaults( - // _report.vaultValues, - // _report.inOutDeltas, - // _update.vaultsLockedEther, - // _update.vaultsTreasuryFeeShares - // ); + _contracts.vaultHub.updateVaults( + _report.vaultValues, + _report.inOutDeltas, + _update.vaultsLockedEther, + _update.vaultsTreasuryFeeShares + ); if (_update.totalVaultsTreasuryFeeShares > 0) { LIDO.mintExternalShares(LIDO_LOCATOR.treasury(), _update.totalVaultsTreasuryFeeShares); diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 8544bd5ce..0f8c8f705 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -74,16 +74,18 @@ contract VaultHub is PausableUntilWithRoles { /// @notice Lido stETH contract IStETH public immutable STETH; + address public immutable accounting; /// @param _stETH Lido stETH contract /// @param _connectedVaultsLimit Maximum number of vaults that can be connected simultaneously /// @param _relativeShareLimitBP Maximum share limit relative to TVL in basis points - constructor(IStETH _stETH, uint256 _connectedVaultsLimit, uint256 _relativeShareLimitBP) { + constructor(IStETH _stETH, address _accounting, uint256 _connectedVaultsLimit, uint256 _relativeShareLimitBP) { if (_connectedVaultsLimit == 0) revert ZeroArgument("_connectedVaultsLimit"); if (_relativeShareLimitBP == 0) revert ZeroArgument("_relativeShareLimitBP"); if (_relativeShareLimitBP > TOTAL_BASIS_POINTS) revert RelativeShareLimitBPTooHigh(_relativeShareLimitBP, TOTAL_BASIS_POINTS); STETH = _stETH; + accounting = _accounting; CONNECTED_VAULTS_LIMIT = _connectedVaultsLimit; RELATIVE_SHARE_LIMIT_BP = _relativeShareLimitBP; @@ -482,12 +484,13 @@ contract VaultHub is PausableUntilWithRoles { treasuryFeeShares = (treasuryFee * _preTotalShares) / _preTotalPooledEther; } - function _updateVaults( + function updateVaults( uint256[] memory _valuations, int256[] memory _inOutDeltas, uint256[] memory _locked, uint256[] memory _treasureFeeShares - ) internal { + ) external { + if (msg.sender != accounting) revert NotAuthorized("updateVaults", msg.sender); VaultHubStorage storage $ = _getVaultHubStorage(); for (uint256 i = 0; i < _valuations.length; i++) { diff --git a/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts b/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts index c11d0a1fe..2473609f2 100644 --- a/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts +++ b/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts @@ -148,6 +148,7 @@ export async function main() { const vaultHub = await deployBehindOssifiableProxy(Sk.vaultHub, "VaultHub", proxyContractsOwner, deployer, [ lidoAddress, + accounting.address, ]); // Deploy AccountingOracle diff --git a/test/0.8.25/vaults/vaultFactory.test.ts b/test/0.8.25/vaults/vaultFactory.test.ts index 482fddd34..1d7ca6b40 100644 --- a/test/0.8.25/vaults/vaultFactory.test.ts +++ b/test/0.8.25/vaults/vaultFactory.test.ts @@ -76,7 +76,7 @@ describe("VaultFactory.sol", () => { depositContract = await ethers.deployContract("DepositContract__MockForBeaconChainDepositor", deployer); // Accounting - vaultHubImpl = await ethers.deployContract("VaultHub", [steth, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP]); + vaultHubImpl = await ethers.deployContract("VaultHub", [steth, ZeroAddress, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP]); proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, admin, new Uint8Array()], admin); vaultHub = await ethers.getContractAt("VaultHub", proxy, deployer); await vaultHub.initialize(admin); diff --git a/test/0.8.25/vaults/vaultHub.test.ts b/test/0.8.25/vaults/vaultHub.test.ts index 4cbffe82b..06a0a0c51 100644 --- a/test/0.8.25/vaults/vaultHub.test.ts +++ b/test/0.8.25/vaults/vaultHub.test.ts @@ -31,6 +31,7 @@ describe("VaultHub.sol", () => { // VaultHub vaultHubImpl = await ethers.deployContract("VaultHub", [ steth, + ZeroAddress, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP, ]); diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts index 745e28c70..ee870e0f5 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts @@ -1,4 +1,5 @@ import { expect } from "chai"; +import { ZeroAddress } from "ethers"; import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; @@ -26,7 +27,7 @@ describe("VaultHub.sol:pausableUntil", () => { steth = await ethers.deployContract("StETH__HarnessForVaultHub", [user], { value: ether("1.0") }); - const vaultHubImpl = await ethers.deployContract("VaultHub", [steth, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP]); + const vaultHubImpl = await ethers.deployContract("VaultHub", [steth, ZeroAddress, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP]); const proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()]); vaultHubAdmin = await ethers.getContractAt("VaultHub", proxy); diff --git a/test/0.8.9/accounting.handleOracleReport.test.ts b/test/0.8.9/accounting.handleOracleReport.test.ts index c08aadbf9..1e0e003f5 100644 --- a/test/0.8.9/accounting.handleOracleReport.test.ts +++ b/test/0.8.9/accounting.handleOracleReport.test.ts @@ -77,7 +77,7 @@ describe("Accounting.sol:report", () => { accounting = await ethers.getContractAt("Accounting", accountingProxy, deployer); await updateLidoLocatorImplementation(await locator.getAddress(), { accounting }); - const vaultHubImpl = await ethers.deployContract("VaultHub", [lido], deployer); + const vaultHubImpl = await ethers.deployContract("VaultHub", [lido, accounting], deployer); const vaultHubProxy = await ethers.deployContract( "OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()], diff --git a/test/integration/vaults/happy-path.integration.ts b/test/integration/vaults/happy-path.integration.ts index a4de0d617..6569474cc 100644 --- a/test/integration/vaults/happy-path.integration.ts +++ b/test/integration/vaults/happy-path.integration.ts @@ -1,5 +1,5 @@ import { expect } from "chai"; -import { ContractTransactionReceipt, hexlify, ZeroAddress } from "ethers"; +import { ContractTransactionReceipt, hexlify, TransactionResponse, ZeroAddress } from "ethers"; import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; @@ -306,62 +306,60 @@ describe("Scenario: Staking Vaults Happy Path", () => { }); }); - // TODO: This test is not working, because of the accounting logic for vaults has been changed - // it("Should rebase simulating 3% APR", async () => { - // const { elapsedProtocolReward, elapsedVaultReward } = await calculateReportParams(); - // const vaultValue = await addRewards(elapsedVaultReward); + it("Should rebase simulating 3% APR", async () => { + const { elapsedProtocolReward, elapsedVaultReward } = await calculateReportParams(); + const vaultValue = await addRewards(elapsedVaultReward); - // const params = { - // clDiff: elapsedProtocolReward, - // excludeVaultsBalances: true, - // vaultValues: [vaultValue], - // inOutDeltas: [VAULT_DEPOSIT], - // } as OracleReportParams; + const params = { + clDiff: elapsedProtocolReward, + excludeVaultsBalances: true, + vaultValues: [vaultValue], + inOutDeltas: [VAULT_DEPOSIT], + } as OracleReportParams; - // const { reportTx } = (await report(ctx, params)) as { - // reportTx: TransactionResponse; - // extraDataTx: TransactionResponse; - // }; - // const reportTxReceipt = (await reportTx.wait()) as ContractTransactionReceipt; + const { reportTx } = (await report(ctx, params)) as { + reportTx: TransactionResponse; + extraDataTx: TransactionResponse; + }; + const reportTxReceipt = (await reportTx.wait()) as ContractTransactionReceipt; - // const errorReportingEvent = ctx.getEvents(reportTxReceipt, "OnReportFailed", [stakingVault.interface]); - // expect(errorReportingEvent.length).to.equal(0n); + const errorReportingEvent = ctx.getEvents(reportTxReceipt, "OnReportFailed", [stakingVault.interface]); + expect(errorReportingEvent.length).to.equal(0n); - // const vaultReportedEvent = ctx.getEvents(reportTxReceipt, "Reported", [stakingVault.interface]); - // expect(vaultReportedEvent.length).to.equal(1n); + const vaultReportedEvent = ctx.getEvents(reportTxReceipt, "Reported", [stakingVault.interface]); + expect(vaultReportedEvent.length).to.equal(1n); - // expect(vaultReportedEvent[0].args?.valuation).to.equal(vaultValue); - // expect(vaultReportedEvent[0].args?.inOutDelta).to.equal(VAULT_DEPOSIT); - // TODO: add assertions or locked values and rewards + expect(vaultReportedEvent[0].args?.valuation).to.equal(vaultValue); + expect(vaultReportedEvent[0].args?.inOutDelta).to.equal(VAULT_DEPOSIT); + // TODO: add assertions or locked values and rewards - // expect(await delegation.curatorUnclaimedFee()).to.be.gt(0n); - // expect(await delegation.nodeOperatorUnclaimedFee()).to.be.gt(0n); - // }); + expect(await delegation.curatorUnclaimedFee()).to.be.gt(0n); + expect(await delegation.nodeOperatorUnclaimedFee()).to.be.gt(0n); + }); - // TODO: As reporting for vaults is not implemented yet, we can't test this - // it("Should allow Operator to claim performance fees", async () => { - // const performanceFee = await delegation.nodeOperatorUnclaimedFee(); - // log.debug("Staking Vault stats", { - // "Staking Vault performance fee": ethers.formatEther(performanceFee), - // }); + it("Should allow Operator to claim performance fees", async () => { + const performanceFee = await delegation.nodeOperatorUnclaimedFee(); + log.debug("Staking Vault stats", { + "Staking Vault performance fee": ethers.formatEther(performanceFee), + }); - // const operatorBalanceBefore = await ethers.provider.getBalance(nodeOperator); + const operatorBalanceBefore = await ethers.provider.getBalance(nodeOperator); - // const claimPerformanceFeesTx = await delegation.connect(nodeOperator).claimNodeOperatorFee(nodeOperator); - // const claimPerformanceFeesTxReceipt = (await claimPerformanceFeesTx.wait()) as ContractTransactionReceipt; + const claimPerformanceFeesTx = await delegation.connect(nodeOperator).claimNodeOperatorFee(nodeOperator); + const claimPerformanceFeesTxReceipt = (await claimPerformanceFeesTx.wait()) as ContractTransactionReceipt; - // const operatorBalanceAfter = await ethers.provider.getBalance(nodeOperator); - // const gasFee = claimPerformanceFeesTxReceipt.gasPrice * claimPerformanceFeesTxReceipt.cumulativeGasUsed; + const operatorBalanceAfter = await ethers.provider.getBalance(nodeOperator); + const gasFee = claimPerformanceFeesTxReceipt.gasPrice * claimPerformanceFeesTxReceipt.cumulativeGasUsed; - // log.debug("Operator's StETH balance", { - // "Balance before": ethers.formatEther(operatorBalanceBefore), - // "Balance after": ethers.formatEther(operatorBalanceAfter), - // "Gas used": claimPerformanceFeesTxReceipt.cumulativeGasUsed, - // "Gas fees": ethers.formatEther(gasFee), - // }); + log.debug("Operator's StETH balance", { + "Balance before": ethers.formatEther(operatorBalanceBefore), + "Balance after": ethers.formatEther(operatorBalanceAfter), + "Gas used": claimPerformanceFeesTxReceipt.cumulativeGasUsed, + "Gas fees": ethers.formatEther(gasFee), + }); - // expect(operatorBalanceAfter).to.equal(operatorBalanceBefore + performanceFee - gasFee); - // }); + expect(operatorBalanceAfter).to.equal(operatorBalanceBefore + performanceFee - gasFee); + }); it("Should allow Owner to trigger validator exit to cover fees", async () => { // simulate validator exit @@ -382,33 +380,32 @@ describe("Scenario: Staking Vaults Happy Path", () => { await report(ctx, params); }); - // TODO: As reporting for vaults is not implemented yet, we can't test this - // it("Should allow Manager to claim manager rewards in ETH after rebase with exited validator", async () => { - // const feesToClaim = await delegation.curatorUnclaimedFee(); + it("Should allow Manager to claim manager rewards in ETH after rebase with exited validator", async () => { + const feesToClaim = await delegation.curatorUnclaimedFee(); - // log.debug("Staking Vault stats after operator exit", { - // "Staking Vault management fee": ethers.formatEther(feesToClaim), - // "Staking Vault balance": ethers.formatEther(await ethers.provider.getBalance(stakingVaultAddress)), - // }); + log.debug("Staking Vault stats after operator exit", { + "Staking Vault management fee": ethers.formatEther(feesToClaim), + "Staking Vault balance": ethers.formatEther(await ethers.provider.getBalance(stakingVaultAddress)), + }); - // const managerBalanceBefore = await ethers.provider.getBalance(curator); + const managerBalanceBefore = await ethers.provider.getBalance(curator); - // const claimEthTx = await delegation.connect(curator).claimCuratorFee(curator); - // const { gasUsed, gasPrice } = (await claimEthTx.wait()) as ContractTransactionReceipt; + const claimEthTx = await delegation.connect(curator).claimCuratorFee(curator); + const { gasUsed, gasPrice } = (await claimEthTx.wait()) as ContractTransactionReceipt; - // const managerBalanceAfter = await ethers.provider.getBalance(curator); - // const vaultBalance = await ethers.provider.getBalance(stakingVaultAddress); + const managerBalanceAfter = await ethers.provider.getBalance(curator); + const vaultBalance = await ethers.provider.getBalance(stakingVaultAddress); - // log.debug("Balances after owner fee claim", { - // "Manager's ETH balance before": ethers.formatEther(managerBalanceBefore), - // "Manager's ETH balance after": ethers.formatEther(managerBalanceAfter), - // "Manager's ETH balance diff": ethers.formatEther(managerBalanceAfter - managerBalanceBefore), - // "Staking Vault owner fee": ethers.formatEther(feesToClaim), - // "Staking Vault balance": ethers.formatEther(vaultBalance), - // }); + log.debug("Balances after owner fee claim", { + "Manager's ETH balance before": ethers.formatEther(managerBalanceBefore), + "Manager's ETH balance after": ethers.formatEther(managerBalanceAfter), + "Manager's ETH balance diff": ethers.formatEther(managerBalanceAfter - managerBalanceBefore), + "Staking Vault owner fee": ethers.formatEther(feesToClaim), + "Staking Vault balance": ethers.formatEther(vaultBalance), + }); - // expect(managerBalanceAfter).to.equal(managerBalanceBefore + feesToClaim - gasUsed * gasPrice); - // }); + expect(managerBalanceAfter).to.equal(managerBalanceBefore + feesToClaim - gasUsed * gasPrice); + }); it("Should allow Token Master to burn shares to repay debt", async () => { const { lido } = ctx.contracts; @@ -435,17 +432,16 @@ describe("Scenario: Staking Vaults Happy Path", () => { // TODO: add more checks here }); - // TODO: As reporting for vaults is not implemented yet, we can't test this - // it("Should allow Manager to rebalance the vault to reduce the debt", async () => { - // const { vaultHub, lido } = ctx.contracts; + it("Should allow Manager to rebalance the vault to reduce the debt", async () => { + const { vaultHub, lido } = ctx.contracts; - // const socket = await vaultHub["vaultSocket(address)"](stakingVaultAddress); - // const sharesMinted = await lido.getPooledEthByShares(socket.sharesMinted); + const socket = await vaultHub["vaultSocket(address)"](stakingVaultAddress); + const sharesMinted = await lido.getPooledEthByShares(socket.sharesMinted); - // await delegation.connect(curator).rebalanceVault(sharesMinted, { value: sharesMinted }); + await delegation.connect(curator).rebalanceVault(sharesMinted, { value: sharesMinted }); - // expect(await stakingVault.locked()).to.equal(VAULT_CONNECTION_DEPOSIT); // 1 ETH locked as a connection fee - // }); + 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(); From 23769917c6430750d56ab8f0d5aa95288d4015d7 Mon Sep 17 00:00:00 2001 From: VP Date: Thu, 27 Feb 2025 14:50:40 +0100 Subject: [PATCH 175/184] fix: related contract changes --- contracts/0.8.25/Accounting.sol | 2 +- contracts/0.8.25/vaults/VaultHub.sol | 2 +- .../vaulthub/contracts/VaultHub__Harness.sol | 13 ++++------ .../vaulthub/vaulthub.forceExit.test.ts | 11 ++++----- .../vaults/vaulthub/vaulthub.hub.test.ts | 24 +++++++++---------- .../accounting.handleOracleReport.test.ts | 12 +++++----- .../vaults/happy-path.integration.ts | 2 +- 7 files changed, 30 insertions(+), 36 deletions(-) diff --git a/contracts/0.8.25/Accounting.sol b/contracts/0.8.25/Accounting.sol index 4e07fb1c3..0da4dec23 100644 --- a/contracts/0.8.25/Accounting.sol +++ b/contracts/0.8.25/Accounting.sol @@ -96,7 +96,7 @@ contract Accounting { /// @param _lido Lido contract constructor( ILidoLocator _lidoLocator, - ILido _lido, + ILido _lido ) { LIDO_LOCATOR = _lidoLocator; LIDO = _lido; diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 0f8c8f705..e9034b81e 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -408,7 +408,7 @@ contract VaultHub is PausableUntilWithRoles { uint256 _preTotalShares, uint256 _preTotalPooledEther, uint256 _sharesToMintAsFees - ) external view returns (uint256[] memory lockedEther, uint256[] memory treasuryFeeShares, uint256 totalTreasuryFeeShares) { + ) public view returns (uint256[] memory lockedEther, uint256[] memory treasuryFeeShares, uint256 totalTreasuryFeeShares) { /// HERE WILL BE ACCOUNTING DRAGON // \||/ diff --git a/test/0.8.25/vaults/vaulthub/contracts/VaultHub__Harness.sol b/test/0.8.25/vaults/vaulthub/contracts/VaultHub__Harness.sol index 67e5af5ba..62a6b59ce 100644 --- a/test/0.8.25/vaults/vaulthub/contracts/VaultHub__Harness.sol +++ b/test/0.8.25/vaults/vaulthub/contracts/VaultHub__Harness.sol @@ -3,18 +3,15 @@ pragma solidity ^0.8.0; -import {Accounting} from "contracts/0.8.25/Accounting.sol"; +import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; +import {ILido as IStETH} from "contracts/0.8.25/interfaces/ILido.sol"; -import {ILido} from "contracts/0.8.25/interfaces/ILido.sol"; -import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; - -contract VaultHub__Harness is Accounting { +contract VaultHub__Harness is VaultHub { constructor( - address _locator, address _steth, uint256 _connectedVaultsLimit, uint256 _relativeShareLimitBP - ) Accounting(ILidoLocator(_locator), ILido(_steth), _connectedVaultsLimit, _relativeShareLimitBP) {} + ) VaultHub(IStETH(_steth), address(0), _connectedVaultsLimit, _relativeShareLimitBP) {} function mock__calculateVaultsRebase( uint256 _postTotalShares, @@ -28,7 +25,7 @@ contract VaultHub__Harness is Accounting { returns (uint256[] memory lockedEther, uint256[] memory treasuryFeeShares, uint256 totalTreasuryFeeShares) { return - _calculateVaultsRebase( + calculateVaultsRebase( _postTotalShares, _postTotalPooledEther, _preTotalShares, diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts index 70031f158..226b3c01f 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts @@ -16,7 +16,6 @@ import { impersonate } from "lib"; import { findEvents } from "lib/event"; import { ether } from "lib/units"; -import { deployLidoLocator } from "test/deploy"; import { Snapshot, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP } from "test/suite"; const SAMPLE_PUBKEY = "0x" + "01".repeat(48); @@ -51,12 +50,10 @@ describe("VaultHub.sol:forceExit", () => { before(async () => { [deployer, user, stranger, feeRecipient] = await ethers.getSigners(); - const locator = await deployLidoLocator(); steth = await ethers.deployContract("StETH__HarnessForVaultHub", [user], { value: ether("10000.0") }); depositContract = await ethers.deployContract("DepositContract__MockForVaultHub"); const vaultHubImpl = await ethers.deployContract("VaultHub__Harness", [ - locator, steth, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP, @@ -64,14 +61,14 @@ describe("VaultHub.sol:forceExit", () => { const proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()]); - const accounting = await ethers.getContractAt("VaultHub__Harness", proxy); - await accounting.initialize(deployer); + const vaultHubAdmin = await ethers.getContractAt("VaultHub__Harness", proxy); + await vaultHubAdmin.initialize(deployer); vaultHub = await ethers.getContractAt("VaultHub__Harness", proxy, user); vaultHubAddress = await vaultHub.getAddress(); - await accounting.grantRole(await vaultHub.VAULT_MASTER_ROLE(), user); - await accounting.grantRole(await vaultHub.VAULT_REGISTRY_ROLE(), user); + await vaultHubAdmin.grantRole(await vaultHub.VAULT_MASTER_ROLE(), user); + await vaultHubAdmin.grantRole(await vaultHub.VAULT_REGISTRY_ROLE(), user); const stakingVaultImpl = await ethers.deployContract("StakingVault__MockForVaultHub", [ await vaultHub.getAddress(), 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 a4a49e2f8..b135f63fc 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts @@ -16,7 +16,7 @@ import { import { BigIntMath, ether, findEvents, impersonate, randomAddress } from "lib"; -import { deployLidoDao, updateLidoLocatorImplementation } from "test/deploy"; +import { deployLidoDao } from "test/deploy"; import { Snapshot, VAULTS_RELATIVE_SHARE_LIMIT_BP, ZERO_HASH } from "test/suite"; const ZERO_BYTES32 = "0x" + Buffer.from(ZERO_HASH).toString("hex"); @@ -100,25 +100,25 @@ describe("VaultHub.sol:hub", () => { depositContract = await ethers.deployContract("DepositContract__MockForVaultHub"); - const vaultHubImpl = await ethers.deployContract("Accounting", [ - locator, + const vaultHubImpl = await ethers.deployContract("VaultHub", [ lido, + ZeroAddress, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP, ]); const proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()]); - const accounting = await ethers.getContractAt("Accounting", proxy); - await accounting.initialize(deployer); + const vaultHubAdmin = await ethers.getContractAt("VaultHub", proxy); + await vaultHubAdmin.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); + vaultHub = await ethers.getContractAt("VaultHub", proxy, user); + await vaultHubAdmin.grantRole(await vaultHub.PAUSE_ROLE(), user); + await vaultHubAdmin.grantRole(await vaultHub.RESUME_ROLE(), user); + await vaultHubAdmin.grantRole(await vaultHub.VAULT_MASTER_ROLE(), user); + await vaultHubAdmin.grantRole(await vaultHub.VAULT_REGISTRY_ROLE(), user); - await updateLidoLocatorImplementation(await locator.getAddress(), { accounting }); + // await updateLidoLocatorImplementation(await locator.getAddress(), { accounting }); const stakingVaultImpl = await ethers.deployContract("StakingVault__MockForVaultHub", [ await vaultHub.getAddress(), @@ -446,7 +446,7 @@ describe("VaultHub.sol:hub", () => { await vault.report(1n, ether("1"), ether("1")); // Below minimal required valuation expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(false); - await lido.connect(user).transferShares(await locator.accounting(), 1n); + await lido.connect(user).transferShares(await locator.vaultHub(), 1n); await vaultHub.connect(user).burnShares(vaultAddress, 1n); expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true); // Should be healthy with no shares diff --git a/test/0.8.9/accounting.handleOracleReport.test.ts b/test/0.8.9/accounting.handleOracleReport.test.ts index 1e0e003f5..3f02b9688 100644 --- a/test/0.8.9/accounting.handleOracleReport.test.ts +++ b/test/0.8.9/accounting.handleOracleReport.test.ts @@ -64,11 +64,7 @@ describe("Accounting.sol:report", () => { deployer, ); - const accountingImpl = await ethers.deployContract( - "Accounting", - [locator, lido, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP], - deployer, - ); + const accountingImpl = await ethers.deployContract("Accounting", [locator, lido], deployer); const accountingProxy = await ethers.deployContract( "OssifiableProxy", [accountingImpl, deployer, new Uint8Array()], @@ -77,7 +73,11 @@ describe("Accounting.sol:report", () => { accounting = await ethers.getContractAt("Accounting", accountingProxy, deployer); await updateLidoLocatorImplementation(await locator.getAddress(), { accounting }); - const vaultHubImpl = await ethers.deployContract("VaultHub", [lido, accounting], deployer); + const vaultHubImpl = await ethers.deployContract( + "VaultHub", + [lido, accounting, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP], + deployer, + ); const vaultHubProxy = await ethers.deployContract( "OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()], diff --git a/test/integration/vaults/happy-path.integration.ts b/test/integration/vaults/happy-path.integration.ts index 6569474cc..4bcb17052 100644 --- a/test/integration/vaults/happy-path.integration.ts +++ b/test/integration/vaults/happy-path.integration.ts @@ -142,7 +142,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { const _stakingVault = await ethers.getContractAt("StakingVault", implAddress); const _delegation = await ethers.getContractAt("Delegation", delegationAddress); - 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 From 336da89bd2a5d141623cd384803e174066365bb8 Mon Sep 17 00:00:00 2001 From: VP Date: Thu, 27 Feb 2025 15:06:44 +0100 Subject: [PATCH 176/184] fix: improve gas cost --- contracts/0.4.24/Lido.sol | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/contracts/0.4.24/Lido.sol b/contracts/0.4.24/Lido.sol index e907b2743..c2e0d69f8 100644 --- a/contracts/0.4.24/Lido.sol +++ b/contracts/0.4.24/Lido.sol @@ -586,7 +586,7 @@ contract Lido is Versioned, StETHPermit, AragonApp { * @dev can be called only by accounting */ function mintShares(address _recipient, uint256 _amountOfShares) public { - _authBoth(getLidoLocator().accounting(), getLidoLocator().vaultHub()); + require(msg.sender == getLidoLocator().accounting() || msg.sender == getLidoLocator().vaultHub(), "APP_AUTH_FAILED"); _whenNotStopped(); _mintShares(_recipient, _amountOfShares); @@ -1028,10 +1028,6 @@ contract Lido is Versioned, StETHPermit, AragonApp { require(msg.sender == _address, "APP_AUTH_FAILED"); } - function _authBoth(address _address1, address _address2) internal view { - require(msg.sender == _address1 || msg.sender == _address2, "APP_AUTH_FAILED"); - } - function _stakingRouter() internal view returns (IStakingRouter) { return IStakingRouter(getLidoLocator().stakingRouter()); } From 6d85cbb269ac00493d617ce34126ff6c21374062 Mon Sep 17 00:00:00 2001 From: VP Date: Thu, 27 Feb 2025 15:07:07 +0100 Subject: [PATCH 177/184] fix: tests for vaultHub --- test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 b135f63fc..415b15417 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts @@ -16,7 +16,7 @@ import { import { BigIntMath, ether, findEvents, impersonate, randomAddress } from "lib"; -import { deployLidoDao } from "test/deploy"; +import { deployLidoDao, updateLidoLocatorImplementation } from "test/deploy"; import { Snapshot, VAULTS_RELATIVE_SHARE_LIMIT_BP, ZERO_HASH } from "test/suite"; const ZERO_BYTES32 = "0x" + Buffer.from(ZERO_HASH).toString("hex"); @@ -118,7 +118,7 @@ describe("VaultHub.sol:hub", () => { await vaultHubAdmin.grantRole(await vaultHub.VAULT_MASTER_ROLE(), user); await vaultHubAdmin.grantRole(await vaultHub.VAULT_REGISTRY_ROLE(), user); - // await updateLidoLocatorImplementation(await locator.getAddress(), { accounting }); + await updateLidoLocatorImplementation(await locator.getAddress(), { vaultHub }); const stakingVaultImpl = await ethers.deployContract("StakingVault__MockForVaultHub", [ await vaultHub.getAddress(), From cebfa5a3c144d0aa3181cf6ff2d37666afc78713 Mon Sep 17 00:00:00 2001 From: VP Date: Thu, 27 Feb 2025 15:13:42 +0100 Subject: [PATCH 178/184] fix: deployment --- scripts/defaults/testnet-defaults.json | 2 +- scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/defaults/testnet-defaults.json b/scripts/defaults/testnet-defaults.json index 082fd8ce1..06032d496 100644 --- a/scripts/defaults/testnet-defaults.json +++ b/scripts/defaults/testnet-defaults.json @@ -77,7 +77,7 @@ "epochsPerFrame": 12 } }, - "accounting": { + "vaultHub": { "deployParameters": { "connectedVaultsLimit": 500, "relativeShareLimitBP": 1000 diff --git a/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts b/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts index 2473609f2..dde72ba34 100644 --- a/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts +++ b/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts @@ -24,7 +24,7 @@ export async function main() { const treasuryAddress = state[Sk.appAgent].proxy.address; const chainSpec = state[Sk.chainSpec]; const depositSecurityModuleParams = state[Sk.depositSecurityModule].deployParameters; - const accountingParams = state[Sk.accounting].deployParameters; + const vaultHubParams = state[Sk.vaultHub].deployParameters; const burnerParams = state[Sk.burner].deployParameters; const hashConsensusForAccountingParams = state[Sk.hashConsensusForAccountingOracle].deployParameters; const hashConsensusForExitBusParams = state[Sk.hashConsensusForValidatorsExitBusOracle].deployParameters; @@ -142,13 +142,13 @@ export async function main() { const accounting = await deployBehindOssifiableProxy(Sk.accounting, "Accounting", proxyContractsOwner, deployer, [ locator.address, lidoAddress, - accountingParams.connectedVaultsLimit, - accountingParams.relativeShareLimitBP, ]); const vaultHub = await deployBehindOssifiableProxy(Sk.vaultHub, "VaultHub", proxyContractsOwner, deployer, [ lidoAddress, accounting.address, + vaultHubParams.connectedVaultsLimit, + vaultHubParams.relativeShareLimitBP, ]); // Deploy AccountingOracle From 7774e51ad55953403e59ced8e132441fd0ec23b8 Mon Sep 17 00:00:00 2001 From: VP Date: Thu, 27 Feb 2025 16:23:40 +0100 Subject: [PATCH 179/184] feat: separate external mint shares --- contracts/0.4.24/Lido.sol | 11 ++++++++--- contracts/0.8.25/Accounting.sol | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/contracts/0.4.24/Lido.sol b/contracts/0.4.24/Lido.sol index c2e0d69f8..4164ce2d1 100644 --- a/contracts/0.4.24/Lido.sol +++ b/contracts/0.4.24/Lido.sol @@ -586,7 +586,7 @@ contract Lido is Versioned, StETHPermit, AragonApp { * @dev can be called only by accounting */ function mintShares(address _recipient, uint256 _amountOfShares) public { - require(msg.sender == getLidoLocator().accounting() || msg.sender == getLidoLocator().vaultHub(), "APP_AUTH_FAILED"); + _auth(getLidoLocator().accounting()); _whenNotStopped(); _mintShares(_recipient, _amountOfShares); @@ -614,12 +614,14 @@ contract Lido is Versioned, StETHPermit, AragonApp { * @notice Mint shares backed by external ether sources * @param _recipient Address to receive the minted shares * @param _amountOfShares Amount of shares to mint - * @dev Can be called only by accounting (authentication in mintShares method). + * @dev Can be called only by VaultHub * NB: Reverts if the the external balance limit is exceeded. */ function mintExternalShares(address _recipient, uint256 _amountOfShares) external { require(_recipient != address(0), "MINT_RECEIVER_ZERO_ADDRESS"); require(_amountOfShares != 0, "MINT_ZERO_AMOUNT_OF_SHARES"); + _auth(getLidoLocator().vaultHub()); + _whenNotStopped(); uint256 newExternalShares = EXTERNAL_SHARES_POSITION.getStorageUint256().add(_amountOfShares); uint256 maxMintableExternalShares = _getMaxMintableExternalShares(); @@ -628,7 +630,10 @@ contract Lido is Versioned, StETHPermit, AragonApp { EXTERNAL_SHARES_POSITION.setStorageUint256(newExternalShares); - mintShares(_recipient, _amountOfShares); + _mintShares(_recipient, _amountOfShares); + // emit event after minting shares because we are always having the net new ether under the hood + // for vaults we have new locked ether and for fees we have a part of rewards + _emitTransferAfterMintingShares(_recipient, _amountOfShares); emit ExternalSharesMinted(_recipient, _amountOfShares, getPooledEthByShares(_amountOfShares)); } diff --git a/contracts/0.8.25/Accounting.sol b/contracts/0.8.25/Accounting.sol index 0da4dec23..572dac40f 100644 --- a/contracts/0.8.25/Accounting.sol +++ b/contracts/0.8.25/Accounting.sol @@ -341,7 +341,7 @@ contract Accounting { ); if (_update.totalVaultsTreasuryFeeShares > 0) { - LIDO.mintExternalShares(LIDO_LOCATOR.treasury(), _update.totalVaultsTreasuryFeeShares); + LIDO.mintShares(LIDO_LOCATOR.treasury(), _update.totalVaultsTreasuryFeeShares); } _notifyRebaseObserver(_contracts.postTokenRebaseReceiver, _report, _pre, _update); From 02925107cf4e4f6197151ebf892560e0ba19e26e Mon Sep 17 00:00:00 2001 From: VP Date: Thu, 27 Feb 2025 16:23:54 +0100 Subject: [PATCH 180/184] fix: naming --- contracts/0.8.25/vaults/VaultHub.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index e9034b81e..8d3d9cd90 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -74,7 +74,7 @@ contract VaultHub is PausableUntilWithRoles { /// @notice Lido stETH contract IStETH public immutable STETH; - address public immutable accounting; + address public immutable ACCOUNTING; /// @param _stETH Lido stETH contract /// @param _connectedVaultsLimit Maximum number of vaults that can be connected simultaneously @@ -85,7 +85,7 @@ contract VaultHub is PausableUntilWithRoles { if (_relativeShareLimitBP > TOTAL_BASIS_POINTS) revert RelativeShareLimitBPTooHigh(_relativeShareLimitBP, TOTAL_BASIS_POINTS); STETH = _stETH; - accounting = _accounting; + ACCOUNTING = _accounting; CONNECTED_VAULTS_LIMIT = _connectedVaultsLimit; RELATIVE_SHARE_LIMIT_BP = _relativeShareLimitBP; @@ -490,7 +490,7 @@ contract VaultHub is PausableUntilWithRoles { uint256[] memory _locked, uint256[] memory _treasureFeeShares ) external { - if (msg.sender != accounting) revert NotAuthorized("updateVaults", msg.sender); + if (msg.sender != ACCOUNTING) revert NotAuthorized("updateVaults", msg.sender); VaultHubStorage storage $ = _getVaultHubStorage(); for (uint256 i = 0; i < _valuations.length; i++) { From 0827f4a96ccbbde5be51dee52d98dddf575ed145 Mon Sep 17 00:00:00 2001 From: VP Date: Thu, 27 Feb 2025 17:38:37 +0100 Subject: [PATCH 181/184] feat: route mint external shares through the VaultHub --- contracts/0.8.25/Accounting.sol | 2 +- contracts/0.8.25/vaults/VaultHub.sol | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/contracts/0.8.25/Accounting.sol b/contracts/0.8.25/Accounting.sol index 572dac40f..7ffc86ba8 100644 --- a/contracts/0.8.25/Accounting.sol +++ b/contracts/0.8.25/Accounting.sol @@ -341,7 +341,7 @@ contract Accounting { ); if (_update.totalVaultsTreasuryFeeShares > 0) { - LIDO.mintShares(LIDO_LOCATOR.treasury(), _update.totalVaultsTreasuryFeeShares); + _contracts.vaultHub.mintExternalSharesForFees(LIDO_LOCATOR.treasury(), _update.totalVaultsTreasuryFeeShares); } _notifyRebaseObserver(_contracts.postTokenRebaseReceiver, _report, _pre, _update); diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 8d3d9cd90..62813696f 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -522,6 +522,11 @@ contract VaultHub is PausableUntilWithRoles { } } + function mintExternalSharesForFees(address _recipient, uint256 _amountOfShares) external { + if (msg.sender != ACCOUNTING) revert NotAuthorized("mintExternalSharesForFees", msg.sender); + STETH.mintExternalShares(_recipient, _amountOfShares); + } + function _vaultAuth(address _vault, string memory _operation) internal view { if (msg.sender != OwnableUpgradeable(_vault).owner()) revert NotAuthorized(_operation, msg.sender); } From f5797631986134651a3ec90d6e89a48cd69216df Mon Sep 17 00:00:00 2001 From: VP Date: Thu, 27 Feb 2025 18:39:33 +0100 Subject: [PATCH 182/184] feat: renaming --- contracts/0.8.25/Accounting.sol | 2 +- contracts/0.8.25/vaults/VaultHub.sol | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/0.8.25/Accounting.sol b/contracts/0.8.25/Accounting.sol index 7ffc86ba8..592112a5e 100644 --- a/contracts/0.8.25/Accounting.sol +++ b/contracts/0.8.25/Accounting.sol @@ -341,7 +341,7 @@ contract Accounting { ); if (_update.totalVaultsTreasuryFeeShares > 0) { - _contracts.vaultHub.mintExternalSharesForFees(LIDO_LOCATOR.treasury(), _update.totalVaultsTreasuryFeeShares); + _contracts.vaultHub.mintVaultsTreasuryFeeShares(LIDO_LOCATOR.treasury(), _update.totalVaultsTreasuryFeeShares); } _notifyRebaseObserver(_contracts.postTokenRebaseReceiver, _report, _pre, _update); diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 62813696f..bd3d6339c 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -522,8 +522,8 @@ contract VaultHub is PausableUntilWithRoles { } } - function mintExternalSharesForFees(address _recipient, uint256 _amountOfShares) external { - if (msg.sender != ACCOUNTING) revert NotAuthorized("mintExternalSharesForFees", msg.sender); + function mintVaultsTreasuryFeeShares(address _recipient, uint256 _amountOfShares) external { + if (msg.sender != ACCOUNTING) revert NotAuthorized("mintVaultsTreasuryFeeShares", msg.sender); STETH.mintExternalShares(_recipient, _amountOfShares); } From 80aeeb103e09df3388159405b44c713538fc0dae Mon Sep 17 00:00:00 2001 From: Aleksei Potapkin Date: Fri, 28 Feb 2025 15:23:23 +0200 Subject: [PATCH 183/184] chore: fix typo --- contracts/0.8.25/vaults/VaultHub.sol | 12 ++++++------ .../vaults/vaulthub/vaulthub.forceExit.test.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index bd3d6339c..604a89c1f 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -363,27 +363,27 @@ contract VaultHub is PausableUntilWithRoles { /// @notice Forces validator exit from the beacon chain when vault is unhealthy /// @param _vault The address of the vault to exit validators from /// @param _pubkeys The public keys of the validators to exit - /// @param _refundRecepient The address that will receive the refund for transaction costs + /// @param _refundRecipient The address that will receive the refund for transaction costs /// @dev When the vault becomes unhealthy, anyone can force its validators to exit the beacon chain /// This returns the vault's deposited ETH back to vault's balance and allows to rebalance the vault function forceValidatorExit( address _vault, bytes calldata _pubkeys, - address _refundRecepient + address _refundRecipient ) 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 (_refundRecepient == address(0)) revert ZeroArgument("_refundRecepient"); + if (_refundRecipient == address(0)) revert ZeroArgument("_refundRecipient"); if (_pubkeys.length % PUBLIC_KEY_LENGTH != 0) revert InvalidPubkeysLength(); _requireUnhealthy(_vault); uint256 numValidators = _pubkeys.length / PUBLIC_KEY_LENGTH; uint64[] memory amounts = new uint64[](numValidators); - IStakingVault(_vault).triggerValidatorWithdrawal{value: msg.value}(_pubkeys, amounts, _refundRecepient); + IStakingVault(_vault).triggerValidatorWithdrawal{value: msg.value}(_pubkeys, amounts, _refundRecipient); - emit ForceValidatorExitTriggered(_vault, _pubkeys, _refundRecepient); + emit ForceValidatorExitTriggered(_vault, _pubkeys, _refundRecipient); } function _disconnect(address _vault) internal { @@ -563,7 +563,7 @@ contract VaultHub is PausableUntilWithRoles { event BurnedSharesOnVault(address indexed vault, uint256 amountOfShares); event VaultRebalanced(address indexed vault, uint256 sharesBurned); event VaultProxyCodehashAdded(bytes32 indexed codehash); - event ForceValidatorExitTriggered(address indexed vault, bytes pubkeys, address refundRecepient); + event ForceValidatorExitTriggered(address indexed vault, bytes pubkeys, address refundRecipient); error StETHMintFailed(address vault); error AlreadyHealthy(address vault); diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts index 226b3c01f..a7771e14e 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts @@ -130,7 +130,7 @@ describe("VaultHub.sol:forceExit", () => { it("reverts if zero refund recipient", async () => { await expect(vaultHub.forceValidatorExit(vaultAddress, SAMPLE_PUBKEY, ZeroAddress, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") - .withArgs("_refundRecepient"); + .withArgs("_refundRecipient"); }); it("reverts if pubkeys are not valid", async () => { From 130b59ee07e8e782afdda2f9359c4033ee00438d Mon Sep 17 00:00:00 2001 From: Aleksei Potapkin Date: Fri, 28 Feb 2025 15:31:59 +0200 Subject: [PATCH 184/184] chore: disable solhint for LidoLocator immutables --- contracts/0.8.9/LidoLocator.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contracts/0.8.9/LidoLocator.sol b/contracts/0.8.9/LidoLocator.sol index 8bf1bfa64..2ce2f6113 100644 --- a/contracts/0.8.9/LidoLocator.sol +++ b/contracts/0.8.9/LidoLocator.sol @@ -35,6 +35,7 @@ contract LidoLocator is ILidoLocator { error ZeroAddress(); + //solhint-disable immutable-vars-naming address public immutable accountingOracle; address public immutable depositSecurityModule; address public immutable elRewardsVault; @@ -52,6 +53,7 @@ contract LidoLocator is ILidoLocator { address public immutable accounting; address public immutable wstETH; address public immutable vaultHub; + //solhint-enable immutable-vars-naming /** * @notice declare service locations