diff --git a/.env.example b/.env.example index 6d126f4e1..e6614abca 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,11 @@ LOCAL_STAKING_VAULT_BEACON_ADDRESS= # RPC URL for a separate, non Hardhat Network node (Anvil, Infura, Alchemy, etc.) 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 +FORK_RPC_URL=https://eth.drpc.org + # https://docs.lido.fi/deployed-contracts MAINNET_LOCATOR_ADDRESS=0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb MAINNET_AGENT_ADDRESS=0x3e40D73EB977Dc6a537aF587D48316feE66E9C8c @@ -54,10 +59,6 @@ MAINNET_STAKING_VAULT_BEACON_ADDRESS= HOLESKY_RPC_URL= SEPOLIA_RPC_URL= -# 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 - # Scratch deployment via hardhat variables DEPLOYER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 GENESIS_TIME=1639659600 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/.gitignore b/.gitignore index e2d3e4f66..42b79ba60 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ lib/abi/*.json accounts.json deployed-local.json deployed-hardhat.json +deployed-local-devnet.json # MacOS .DS_Store 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5ca9857d1..71bae54d8 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`). @@ -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/contracts/0.4.24/Lido.sol b/contracts/0.4.24/Lido.sol index 77a9337c9..4164ce2d1 100644 --- a/contracts/0.4.24/Lido.sol +++ b/contracts/0.4.24/Lido.sol @@ -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)); } @@ -639,7 +644,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 +668,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); diff --git a/contracts/0.8.25/Accounting.sol b/contracts/0.8.25/Accounting.sol index 9be24aebc..60ae081ef 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,20 +82,24 @@ 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; + /// @notice Lido Locator contract + ILidoLocator public immutable LIDO_LOCATOR; /// @notice Lido contract ILido public immutable LIDO; - constructor(ILidoLocator _lidoLocator) VaultHub(_lidoLocator) { - LIDO = ILido(_lidoLocator.lido()); - } - - function initialize(address _admin) external initializer { - if (_admin == address(0)) revert ZeroArgument("_admin"); - - __VaultHub_init(_admin); + /// @param _lidoLocator Lido Locator contract + /// @param _lido Lido contract + constructor( + ILidoLocator _lidoLocator, + ILido _lido + ) { + LIDO_LOCATOR = _lidoLocator; + LIDO = _lido; } /// @notice calculates all the state changes that is required to apply the report @@ -220,17 +223,14 @@ 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( - update.postTotalShares, - update.postTotalPooledEther, - _pre.totalShares, - _pre.totalPooledEther, - update.sharesToMintAsFees - ); + (update.vaultsLockedEther, update.vaultsTreasuryFeeShares, update.totalVaultsTreasuryFeeShares) = + _contracts.vaultHub.calculateVaultsRebase( + update.postTotalShares, + update.postTotalPooledEther, + _pre.totalShares, + _pre.totalPooledEther, + update.sharesToMintAsFees + ); update.postTotalPooledEther += (update.totalVaultsTreasuryFeeShares * update.postTotalPooledEther) / @@ -329,7 +329,8 @@ contract Accounting is VaultHub { _update.etherToFinalizeWQ ); - _updateVaults( + // TODO: Remove this once decide on vaults reporting + _contracts.vaultHub.updateVaults( _report.vaultValues, _report.inOutDeltas, _update.vaultsLockedEther, @@ -337,7 +338,7 @@ contract Accounting is VaultHub { ); if (_update.totalVaultsTreasuryFeeShares > 0) { - STETH.mintExternalShares(LIDO_LOCATOR.treasury(), _update.totalVaultsTreasuryFeeShares); + _contracts.vaultHub.mintVaultsTreasuryFeeShares(LIDO_LOCATOR.treasury(), _update.totalVaultsTreasuryFeeShares); } _notifyRebaseObserver(_contracts.postTokenRebaseReceiver, _report, _pre, _update); @@ -457,7 +458,8 @@ contract Accounting is VaultHub { address burner, address withdrawalQueue, address postTokenRebaseReceiver, - address stakingRouter + address stakingRouter, + address vaultHub ) = LIDO_LOCATOR.oracleReportComponents(); return @@ -467,7 +469,8 @@ contract Accounting is VaultHub { IBurner(burner), IWithdrawalQueue(withdrawalQueue), IPostTokenRebaseReceiver(postTokenRebaseReceiver), - IStakingRouter(stakingRouter) + IStakingRouter(stakingRouter), + VaultHub(vaultHub) ); } 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/utils/AccessControlConfirmable.sol b/contracts/0.8.25/utils/AccessControlConfirmable.sol new file mode 100644 index 000000000..a8ea6b43e --- /dev/null +++ b/contracts/0.8.25/utils/AccessControlConfirmable.sol @@ -0,0 +1,174 @@ +// 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 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 AccessControlConfirmable is AccessControlEnumerable { + /** + * @notice Tracks confirmations + * - callData: msg.data of the call (selector + arguments) + * - role: role that confirmed the action + * - expiryTimestamp: timestamp of the confirmation. + */ + mapping(bytes callData => mapping(bytes32 role => uint256 expiryTimestamp)) public confirmations; + + /** + * @notice Minimal confirmation expiry in seconds. + */ + uint256 public constant MIN_CONFIRM_EXPIRY = 1 days; + + /** + * @notice Maximal confirmation expiry in seconds. + */ + uint256 public constant MAX_CONFIRM_EXPIRY = 30 days; + + /** + * @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 `_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 confirmExpiry = MIN_CONFIRM_EXPIRY; + + /** + * @notice Returns the confirmation expiry. + * @return The confirmation expiry in seconds. + */ + function getConfirmExpiry() public view returns (uint256) { + return confirmExpiry; + } + + /** + * @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. expiry 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 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 + * + */ + modifier onlyConfirmed(bytes32[] memory _roles) { + if (_roles.length == 0) revert ZeroConfirmingRoles(); + + uint256 numberOfRoles = _roles.length; + uint256 numberOfConfirms = 0; + bool[] memory deferredConfirms = new bool[](numberOfRoles); + bool isRoleMember = false; + uint256 expiryTimestamp = block.timestamp + confirmExpiry; + + 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, expiryTimestamp, msg.data); + } else if (confirmations[msg.data][role] >= block.timestamp) { + numberOfConfirms++; + } + } + + if (!isRoleMember) revert SenderNotMember(); + + if (numberOfConfirms == numberOfRoles) { + for (uint256 i = 0; i < numberOfRoles; ++i) { + bytes32 role = _roles[i]; + delete confirmations[msg.data][role]; + } + _; + } else { + for (uint256 i = 0; i < numberOfRoles; ++i) { + if (deferredConfirms[i]) { + bytes32 role = _roles[i]; + confirmations[msg.data][role] = expiryTimestamp; + } + } + } + } + + /** + * @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 _newConfirmExpiry The new confirmation expiry in seconds. + */ + function _setConfirmExpiry(uint256 _newConfirmExpiry) internal { + if (_newConfirmExpiry < MIN_CONFIRM_EXPIRY || _newConfirmExpiry > MAX_CONFIRM_EXPIRY) + revert ConfirmExpiryOutOfBounds(); + + uint256 oldConfirmExpiry = confirmExpiry; + confirmExpiry = _newConfirmExpiry; + + emit ConfirmExpirySet(msg.sender, oldConfirmExpiry, _newConfirmExpiry); + } + + /** + * @dev Emitted when the confirmation expiry is set. + * @param oldConfirmExpiry The old confirmation expiry. + * @param newConfirmExpiry The new confirmation expiry. + */ + event ConfirmExpirySet(address indexed sender, uint256 oldConfirmExpiry, uint256 newConfirmExpiry); + + /** + * @dev Emitted when a role member confirms. + * @param member The address of the confirming member. + * @param role The role of the confirming member. + * @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 expiryTimestamp, bytes data); + + /** + * @dev Thrown when attempting to set confirmation expiry out of bounds. + */ + error ConfirmExpiryOutOfBounds(); + + /** + * @dev Thrown when a caller without a required role attempts to confirm. + */ + error SenderNotMember(); + + /** + * @dev Thrown when the roles array is empty. + */ + error ZeroConfirmingRoles(); +} 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 6311be09d..3ff1f4f9b 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -30,20 +30,12 @@ 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 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. */ 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%. */ @@ -85,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"); @@ -95,20 +87,26 @@ 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 _confirmExpiry Confirm expiry in seconds */ - function initialize(address _defaultAdmin) 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); + _initialize(_defaultAdmin, _confirmExpiry); } // ==================== View Functions ==================== - function votingCommittee() external pure returns (bytes32[] memory) { - return _votingCommittee(); + /** + * @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(); } /** @@ -137,25 +135,25 @@ 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; } /** * @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; } @@ -192,7 +190,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()); } @@ -214,7 +212,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) { @@ -252,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 */ @@ -263,19 +261,11 @@ contract Dashboard is Permissions { } /** - * @notice Requests the exit of a validator from the staking vault - * @param _validatorPublicKey Public key of the validator to exit - */ - function requestValidatorExit(bytes calldata _validatorPublicKey) external { - _requestValidatorExit(_validatorPublicKey); - } - - /** - * @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 */ - function mintShares(address _recipient, uint256 _amountOfShares) external payable fundAndProceed { + function mintShares(address _recipient, uint256 _amountOfShares) external payable fundable { _mintShares(_recipient, _amountOfShares); } @@ -285,7 +275,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)); } @@ -294,7 +284,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); @@ -313,9 +303,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); @@ -332,40 +322,7 @@ 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); - } - - /** - * @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 */ @@ -407,7 +364,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); } @@ -430,7 +387,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 */ @@ -484,32 +441,30 @@ 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. + * @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. */ - 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); - } + function requestValidatorExit(bytes calldata _pubkeys) external { + _requestValidatorExit(_pubkeys); } /** - * @notice Mass-revokes multiple roles from multiple accounts. - * @param _assignments An array of role assignments. - * @dev Performs the role admin checks internally. + * @notice Initiates a withdrawal from validator(s) on the beacon chain using EIP-7002 triggerable withdrawals + * 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 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. */ - 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 triggerValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient) external payable { + _triggerValidatorWithdrawal(_pubkeys, _amounts, _refundRecipient); } // ==================== Internal Functions ==================== @@ -517,13 +472,46 @@ 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); } _; } + /** + * @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); + } + /** /** @@ -550,7 +538,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) { diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index a725eaec3..18884561d 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -4,27 +4,14 @@ // 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"; /** * @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 +20,28 @@ contract Delegation is Dashboard { uint256 private constant MAX_FEE_BP = TOTAL_BASIS_POINTS; /** - * @notice Curator role: - * - sets curator fee; - * - claims curator fee; - * - votes on vote lifetime; - * - votes on node operator fee; - * - votes on 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("Vault.Delegation.CuratorRole"); + bytes32 public constant CURATOR_FEE_CLAIM_ROLE = keccak256("vaults.Delegation.CuratorFeeClaimRole"); /** * @notice Node operator manager role: - * - votes on vote lifetime; - * - votes on node operator fee; - * - votes on ownership transfer; - * - assigns NODE_OPERATOR_FEE_CLAIMER_ROLE. + * - confirms confirm expiry; + * - confirms ownership transfer; + * - assigns NODE_OPERATOR_FEE_CONFIRM_ROLE; + * - assigns NODE_OPERATOR_FEE_CLAIM_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. + * @notice Claims node operator fee. */ - bytes32 public constant NODE_OPERATOR_FEE_CLAIMER_ROLE = keccak256("Vault.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%. @@ -92,20 +76,20 @@ 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 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) external override { - _initialize(_defaultAdmin); + 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 // 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); + _setRoleAdmin(NODE_OPERATOR_FEE_CLAIM_ROLE, NODE_OPERATOR_MANAGER_ROLE); } /** @@ -152,13 +136,23 @@ 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 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 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 _newConfirmExpiry The new confirm expiry in seconds. */ - function setVoteLifetime(uint256 _newVoteLifetime) external onlyIfVotedBy(_votingCommittee()) { - _setVoteLifetime(_newVoteLifetime); + function setConfirmExpiry(uint256 _newConfirmExpiry) external onlyConfirmed(_confirmingRoles()) { + _setConfirmExpiry(_newConfirmExpiry); } /** @@ -168,7 +162,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; @@ -181,11 +175,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 onlyConfirmed(_confirmingRoles()) { if (_newNodeOperatorFeeBP + curatorFeeBP > MAX_FEE_BP) revert CombinedFeesExceed100Percent(); if (nodeOperatorUnclaimedFee() > 0) revert NodeOperatorFeeUnclaimed(); uint256 oldNodeOperatorFeeBP = nodeOperatorFeeBP; @@ -198,7 +192,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 +204,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); @@ -254,20 +248,21 @@ 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); } /** - * @notice Returns the committee that can: - * - change the vote lifetime; + * @notice Returns the roles that can: + * - change the confirm expiry; + * - 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] = DEFAULT_ADMIN_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 f5b0cdc8d..5198c2dd3 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 {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"; @@ -17,53 +17,64 @@ import {VaultHub} from "./VaultHub.sol"; * @author Lido * @notice Provides granular permissions for StakingVault operations. */ -abstract contract Permissions is AccessControlVoteable { +abstract contract Permissions is AccessControlConfirmable { + /** + * @notice Struct containing an account and a role for granting/revoking roles. + */ + struct RoleAssignment { + address account; + bytes32 role; + } + /** * @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.PauseDeposits"); /** * @notice Permission for resuming beacon chain deposits on the StakingVault. */ - bytes32 public constant RESUME_BEACON_CHAIN_DEPOSITS_ROLE = - keccak256("StakingVault.Permissions.ResumeBeaconChainDeposits"); + bytes32 public constant RESUME_BEACON_CHAIN_DEPOSITS_ROLE = keccak256("vaults.Permissions.ResumeDeposits"); /** * @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 triggering validator withdrawal from the StakingVault using EIP-7002 triggerable exit. + */ + bytes32 public constant TRIGGER_VALIDATOR_WITHDRAWAL_ROLE = keccak256("vaults.Permissions.TriggerValidatorWithdrawal"); /** * @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 Permission for recover assets from Delegate contracts @@ -95,7 +106,7 @@ abstract contract Permissions is AccessControlVoteable { _SELF = address(this); } - function _initialize(address _defaultAdmin) 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"); @@ -104,58 +115,142 @@ abstract contract Permissions is AccessControlVoteable { vaultHub = VaultHub(stakingVault().vaultHub()); _grantRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); - _setVoteLifetime(7 days); + _setConfirmExpiry(_confirmExpiry); - emit Initialized(); + emit Initialized(_defaultAdmin); } + /** + * @notice Returns the address of the underlying StakingVault. + * @return The address of the StakingVault. + */ function stakingVault() public view returns (IStakingVault) { - bytes memory args = Clones.fetchCloneArgs(address(this)); - address addr; - assembly { - addr := mload(add(args, 32)) + return IStakingVault(_loadStakingVaultAddress()); + } + + // ==================== 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. + * @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"); + + 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. + * @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"); + + for (uint256 i = 0; i < _assignments.length; i++) { + revokeRole(_assignments[i].role, _assignments[i].account); } - return IStakingVault(addr); } - function _votingCommittee() internal pure virtual returns (bytes32[] memory) { + /** + * @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) { - _unsafeWithdraw(_recipient, _ether); + 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); + vaultHub.mintShares(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); + vaultHub.burnShares(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(); } - function _requestValidatorExit(bytes calldata _pubkey) internal onlyRole(REQUEST_VALIDATOR_EXIT_ROLE) { - stakingVault().requestValidatorExit(_pubkey); + /** + * @dev Checks the REQUEST_VALIDATOR_EXIT_ROLE and requests validator exit on the StakingVault. + * @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); } + /** + * @dev Checks the TRIGGER_VALIDATOR_WITHDRAWAL_ROLE and triggers validator withdrawal on the StakingVault using EIP-7002 triggerable exit. + * @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); + } + + /** + * @dev Checks the VOLUNTARY_DISCONNECT_ROLE and voluntarily disconnects the StakingVault. + */ function _voluntaryDisconnect() internal onlyRole(VOLUNTARY_DISCONNECT_ROLE) { vaultHub.voluntaryDisconnect(address(stakingVault())); } @@ -167,18 +262,29 @@ abstract contract Permissions is AccessControlVoteable { return PredepositGuarantee(stakingVault().depositor()).withdrawDisprovenPredeposit(_pubkey, _recipient); } - function _transferStakingVaultOwnership(address _newOwner) internal onlyIfVotedBy(_votingCommittee()) { + /** + * @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()) { OwnableUpgradeable(address(stakingVault())).transferOwnership(_newOwner); } - function _unsafeWithdraw(address _recipient, uint256 _ether) internal { - stakingVault().withdraw(_recipient, _ether); + /** + * @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 { + addr := mload(add(args, 32)) + } } /** * @notice Emitted when the contract is initialized */ - event Initialized(); + event Initialized(address _defaultAdmin); /** * @notice Error when direct calls to the implementation are forbidden diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 768c8801a..940c9a18e 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -5,6 +5,7 @@ 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"; @@ -19,30 +20,38 @@ 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 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 balanced 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: * - `fund()` * - `withdraw()` - * - `requestValidatorExit()` * - `rebalance()` * - `pauseBeaconChainDeposits()` * - `resumeBeaconChainDeposits()` * - `setDepositor()` * - Deposit Guardian: * - `depositToBeaconChain()` + * - Operator: + * - `requestValidatorExit()` + * - `triggerValidatorWithdrawal()` * - VaultHub: * - `lock()` * - `report()` * - `rebalance()` + * - `triggerValidatorWithdrawal()` * - Anyone: * - Can send ETH directly to the vault (treated as rewards) * @@ -89,14 +98,24 @@ 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 public constant PUBLIC_KEY_LENGTH = 48; /** * @notice Storage offset slot for ERC-7201 namespace * 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; /** @@ -110,7 +129,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(); @@ -128,22 +147,22 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { address _depositor, bytes calldata /* _params */ ) external initializer { + if (_nodeOperator == address(0)) revert ZeroArgument("_nodeOperator"); + __Ownable_init(_owner); _getStorage().nodeOperator = _nodeOperator; _getStorage().depositor = _depositor; } /** - * @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; @@ -155,23 +174,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 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 + * @notice Returns the total valuation of `StakingVault` in ether * @dev Valuation = latestReport.valuation + (current inOutDelta - latestReport.inOutDelta) */ function valuation() public view returns (uint256) { @@ -180,8 +189,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 */ @@ -190,8 +198,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 */ @@ -206,7 +213,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()`, @@ -221,41 +227,18 @@ 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; } - /** - * @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 - * @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 address of the node operator * Node operator is the party responsible for managing the validators. * In the context of this contract, the node operator runs vault validators on CL 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; @@ -273,15 +256,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { return _getStorage().depositor; } - /** - * @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` @@ -308,9 +282,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 Includes the `isBalanced()` check to ensure `StakingVault` remains balanced after the withdrawal, - * to safeguard against possible 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"); @@ -324,53 +297,10 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { (bool success, ) = _recipient.call{value: _ether}(""); if (!success) revert TransferFailed(_recipient, _ether); - if (!isBalanced()) revert Unbalanced(); - - 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"); - if (!isBalanced()) revert Unbalanced(); - - ERC7201Storage storage $ = _getStorage(); - if ($.beaconChainDepositsPaused) revert BeaconChainDepositsArePaused(); - if (msg.sender != $.depositor) revert NotAuthorized("depositToBeaconChain", msg.sender); - - uint256 numberOfDeposits = _deposits.length; - - uint256 totalAmount = 0; - - for (uint256 i = 0; i < numberOfDeposits; i++) { - Deposit calldata deposit = _deposits[i]; - - //TODO: check BLS signature - BEACON_CHAIN_DEPOSIT_CONTRACT.deposit{value: deposit.amount}( - deposit.pubkey, - bytes.concat(withdrawalCredentials()), - deposit.signature, - deposit.depositDataRoot - ); - - totalAmount += deposit.amount; - } + if (valuation() < $.locked) revert ValuationBelowLockedAmount(); - 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); + emit Withdrawn(msg.sender, _recipient, _ether); } /** @@ -391,18 +321,18 @@ 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 valuation is less than locked amount * @param _ether Amount of ether to rebalance */ 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); - if (owner() == msg.sender || (!isBalanced() && msg.sender == address(VAULT_HUB))) { - ERC7201Storage storage $ = _getStorage(); + uint256 valuation_ = valuation(); + if (_ether > valuation_) revert RebalanceAmountExceedsValuation(valuation_, _ether); + + 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); @@ -432,35 +362,23 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { } /** - * @notice Sets the depositor - * @param _depositor The address of the deposit guardian - * @dev It can only be changed when vault is not connected to the VaultHub - * + * @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 */ - function setDepositor(address _depositor) external onlyOwner { - if (_depositor == address(0)) revert ZeroArgument("_depositor"); - - if (_depositor == _getStorage().depositor) { - revert DepositorAlreadySet(); - } - - VaultHub.VaultSocket memory socket = VaultHub(VAULT_HUB).vaultSocket(address(this)); - - if (socket.vault == address(this) && !socket.isDisconnected) { - revert DepositorCannotChangeWhenConnected(); - } - - ERC7201Storage storage $ = _getStorage(); - address oldDepositor = $.depositor; - - $.depositor = _depositor; + function withdrawalCredentials() public view returns (bytes32) { + return bytes32(WC_0X02_PREFIX | uint160(address(this))); + } - emit DepositorSet(oldDepositor, _depositor); + /** + * @notice Returns whether 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 + * @dev Can only be called by the vault owner */ function pauseBeaconChainDeposits() external onlyOwner { ERC7201Storage storage $ = _getStorage(); @@ -475,7 +393,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @notice Resumes deposits to beacon chain - * @dev Can only be called by the vault owner + * @dev Can only be called by the vault owner */ function resumeBeaconChainDeposits() external onlyOwner { ERC7201Storage storage $ = _getStorage(); @@ -488,9 +406,153 @@ 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` valuation is not less than locked before making deposits + */ + function depositToBeaconChain(Deposit[] calldata _deposits) external { + if (_deposits.length == 0) revert ZeroArgument("_deposits"); + + ERC7201Storage storage $ = _getStorage(); + if ($.beaconChainDepositsPaused) revert BeaconChainDepositsArePaused(); + if (msg.sender != $.depositor) revert NotAuthorized("depositToBeaconChain", msg.sender); + if (valuation() < $.locked) revert ValuationBelowLockedAmount(); + + uint256 numberOfDeposits = _deposits.length; + uint256 totalAmount = 0; + bytes memory withdrawalCredentials_ = bytes.concat(withdrawalCredentials()); + + for (uint256 i = 0; i < numberOfDeposits; i++) { + Deposit calldata deposit = _deposits[i]; + + //TODO: check BLS signature + + DEPOSIT_CONTRACT.deposit{value: deposit.amount}( + deposit.pubkey, + withdrawalCredentials_, + deposit.signature, + deposit.depositDataRoot + ); + + totalAmount += deposit.amount; + } + + emit DepositedToBeaconChain(msg.sender, numberOfDeposits, totalAmount); + } + + /** + * @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 + */ + function calculateValidatorWithdrawalFee(uint256 _numberOfKeys) external view returns (uint256) { + if (_numberOfKeys == 0) revert ZeroArgument("_numberOfKeys"); + + return _numberOfKeys * TriggerableWithdrawals.getWithdrawalRequestFee(); + } + + /** + * @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 + * @param _pubkeys Concatenated validator public keys, each 48 bytes long + */ + function requestValidatorExit(bytes calldata _pubkeys) external onlyOwner { + if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); + if (_pubkeys.length % PUBLIC_KEY_LENGTH != 0) { + revert InvalidPubkeysLength(); + } + + 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, /* indexed */ pubkey, pubkey); + } + } + + /** + * @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, 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 { + 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(); + + uint256 keysCount = _pubkeys.length / PUBLIC_KEY_LENGTH; + if (keysCount != _amounts.length) revert InvalidAmountsLength(); + + if (_refundRecipient == address(0)) { + _refundRecipient = msg.sender; + } + + ERC7201Storage storage $ = _getStorage(); + 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(); + } + } + + bool isAuthorized = ( + msg.sender == $.nodeOperator || + msg.sender == owner() || + (isValuationBelowLocked && msg.sender == address(VAULT_HUB)) + ); + + if (!isAuthorized) revert NotAuthorized("triggerValidatorWithdrawal", msg.sender); + + uint256 feePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); + uint256 totalFee = feePerRequest * keysCount; + if (msg.value < totalFee) revert InsufficientValidatorWithdrawalFee(msg.value, totalFee); + + TriggerableWithdrawals.addWithdrawalRequests(_pubkeys, _amounts, feePerRequest); + + uint256 excess = msg.value - totalFee; + if (excess > 0) { + (bool success,) = _refundRecipient.call{value: excess}(""); + if (!success) revert WithdrawalFeeRefundFailed(_refundRecipient, excess); + } + + emit ValidatorWithdrawalTriggered(msg.sender, _pubkeys, _amounts, _refundRecipient, excess); + } + + /** + * @notice Sets the depositor + * @param _depositor The address of the deposit guardian + * @dev It can only be changed when vault is not connected to the VaultHub + * + */ + function setDepositor(address _depositor) external onlyOwner { + if (_depositor == address(0)) revert ZeroArgument("_depositor"); + + if (_depositor == _getStorage().depositor) { + revert DepositorAlreadySet(); + } + + VaultHub.VaultSocket memory socket = VaultHub(VAULT_HUB).vaultSocket(address(this)); + + if (socket.vault == address(this) && !socket.pendingDisconnect) { + revert DepositorCannotChangeWhenConnected(); + } + + ERC7201Storage storage $ = _getStorage(); + address oldDepositor = $.depositor; + + $.depositor = _depositor; + + emit DepositorSet(oldDepositor, _depositor); + } + function _getStorage() private pure returns (ERC7201Storage storage $) { assembly { - $.slot := ERC721_STORAGE_LOCATION + $.slot := ERC7201_STORAGE_LOCATION } } @@ -511,21 +573,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 - * @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 the locked amount is increased * @param locked New amount of locked ether @@ -564,6 +611,33 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { */ event DepositorSet(address oldDepositor, address newDepositor); + /** + * @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 vault owner requests node operator to exit validators from the beacon chain + * @param _sender Address that requested the 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, bytes indexed _pubkey, bytes _pubkeyRaw); + + /** + * @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 ValidatorWithdrawalTriggered(address indexed _sender, bytes _pubkeys, uint64[] _amounts, address _refundRecipient, uint256 _excess); + /** * @notice Thrown when an invalid zero value is passed * @param name Name of the argument that was zero @@ -597,9 +671,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 @@ -651,4 +725,33 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @notice Thrown when trying to update depositor for connected vault */ error DepositorAlreadySet(); + + /** + * @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 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 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 WithdrawalFeeRefundFailed(address _sender, uint256 _amount); + + /** + * @notice Thrown when partial withdrawals are not allowed when valuation is below locked + */ + error PartialWithdrawalNotAllowed(); } diff --git a/contracts/0.8.25/vaults/VaultFactory.sol b/contracts/0.8.25/vaults/VaultFactory.sol index f94bea416..cb8ec60c2 100644 --- a/contracts/0.8.25/vaults/VaultFactory.sol +++ b/contracts/0.8.25/vaults/VaultFactory.sol @@ -12,21 +12,24 @@ 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 curator; address nodeOperatorManager; - address nodeOperatorFeeClaimer; address assetRecoverer; + uint256 confirmExpiry; uint16 curatorFeeBP; uint16 nodeOperatorFeeBP; + address[] funders; + address[] withdrawers; + address[] minters; + address[] burners; + address[] rebalancers; + address[] depositPausers; + address[] depositResumers; + address[] validatorExitRequesters; + address[] validatorWithdrawalTriggerers; + address[] disconnecters; + address[] curatorFeeSetters; + address[] curatorFeeClaimers; + address[] nodeOperatorFeeClaimers; } contract VaultFactory { @@ -54,8 +57,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, ""))); @@ -72,34 +73,66 @@ contract VaultFactory { ); // initialize Delegation - delegation.initialize(address(this)); + delegation.initialize(address(this), _delegationConfig.confirmExpiry); - // 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); - 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.grantRole(delegation.CURATOR_ROLE(), _delegationConfig.curator); delegation.grantRole(delegation.NODE_OPERATOR_MANAGER_ROLE(), _delegationConfig.nodeOperatorManager); - delegation.grantRole(delegation.NODE_OPERATOR_FEE_CLAIMER_ROLE(), _delegationConfig.nodeOperatorFeeClaimer); delegation.grantRole(delegation.ASSET_RECOVERY_ROLE(), _delegationConfig.assetRecoverer); - // grant temporary roles to factory - delegation.grantRole(delegation.CURATOR_ROLE(), address(this)); - delegation.grantRole(delegation.NODE_OPERATOR_MANAGER_ROLE(), address(this)); + 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.validatorExitRequesters.length; i++) { + delegation.grantRole(delegation.REQUEST_VALIDATOR_EXIT_ROLE(), _delegationConfig.validatorExitRequesters[i]); + } + for (uint256 i = 0; i < _delegationConfig.validatorWithdrawalTriggerers.length; i++) { + delegation.grantRole(delegation.TRIGGER_VALIDATOR_WITHDRAWAL_ROLE(), _delegationConfig.validatorWithdrawalTriggerers[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)); // 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_MANAGER_ROLE(), address(this)); delegation.revokeRole(delegation.DEFAULT_ADMIN_ROLE(), address(this)); diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 8048bd905..0367e0ae6 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"; @@ -20,14 +19,14 @@ 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 - /// @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; @@ -44,14 +43,14 @@ 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 - bool isDisconnected; - // ### we have 104 bits left in this slot + bool pendingDisconnect; + /// @notice unused gap in the slot 2 + /// uint104 _unused_gap_; } // keccak256(abi.encode(uint256(keccak256("VaultHub")) - 1)) & ~bytes32(uint256(0xff)) @@ -64,28 +63,51 @@ 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 + 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; /// @notice Lido Locator contract ILidoLocator public immutable LIDO_LOCATOR; + /// @notice Accounting contract + address public immutable ACCOUNTING; /// @param _locator Lido Locator contract - constructor(ILidoLocator _locator) { + /// @param _accounting Accounting contract + /// @param _connectedVaultsLimit Maximum number of vaults that can be connected simultaneously + /// @param _relativeShareLimitBP Maximum share limit relative to TVL in basis points + constructor(ILidoLocator _locator, 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); + LIDO_LOCATOR = _locator; STETH = IStETH(_locator.lido()); + ACCOUNTING = _accounting; + CONNECTED_VAULTS_LIMIT = _connectedVaultsLimit; + RELATIVE_SHARE_LIMIT_BP = _relativeShareLimitBP; + _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(); + // the stone in the elevator _getVaultHubStorage().sockets.push(VaultSocket(address(0), 0, 0, 0, 0, 0, false)); @@ -127,29 +149,40 @@ abstract contract VaultHub is PausableUntilWithRoles { return $.sockets[$.vaultIndex[_vault]]; } + /// @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) { + VaultSocket storage socket = _connectedSocket(_vault); + if (socket.sharesMinted == 0) return true; + + return ( + 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 ReserveRatioTooHigh(_vault, _reserveRatioThresholdBP, _reserveRatioBP); + if (_reserveRatioBP > TOTAL_BASIS_POINTS) revert ReserveRatioTooHigh(_vault, _reserveRatioBP, TOTAL_BASIS_POINTS); + 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() == MAX_VAULTS_COUNT) revert TooManyVaults(); + if (vaultsCount() == CONNECTED_VAULTS_LIMIT) revert TooManyVaults(); _checkShareLimitUpperBound(_vault, _shareLimit); VaultHubStorage storage $ = _getVaultHubStorage(); @@ -161,21 +194,21 @@ abstract contract VaultHub is PausableUntilWithRoles { if (IStakingVault(_vault).depositor() != LIDO_LOCATOR.predepositGuarantee()) revert VaultDepositGuardianNotAllowed(IStakingVault(_vault).depositor()); - VaultSocket memory vr = VaultSocket( + VaultSocket memory vsocket = VaultSocket( _vault, 0, // sharesMinted uint96(_shareLimit), uint16(_reserveRatioBP), - uint16(_reserveRatioThresholdBP), + uint16(_rebalanceThresholdBP), uint16(_treasuryFeeBP), - false // isDisconnected + false // pendingDisconnect ); $.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, _rebalanceThresholdBP, _treasuryFeeBP); } /// @notice updates share limit for the vault @@ -221,7 +254,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"); @@ -234,20 +267,20 @@ abstract contract VaultHub is PausableUntilWithRoles { uint256 shareLimit = socket.shareLimit; if (vaultSharesAfterMint > shareLimit) revert ShareLimitExceeded(_vault, shareLimit); - uint256 reserveRatioBP = socket.reserveRatioBP; - uint256 maxMintableShares = _maxMintableShares(_vault, reserveRatioBP, shareLimit); - - if (vaultSharesAfterMint > maxMintableShares) { - revert InsufficientValuationToMint(_vault, IStakingVault(_vault).valuation()); + IStakingVault vault_ = IStakingVault(_vault); + 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); - uint256 totalEtherLocked = (STETH.getPooledEthByShares(vaultSharesAfterMint) * TOTAL_BASIS_POINTS) / - (TOTAL_BASIS_POINTS - reserveRatioBP); - - if (totalEtherLocked > IStakingVault(_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 = (etherToLock * TOTAL_BASIS_POINTS) / maxMintableRatioBP; + if (totalEtherLocked > vault_.locked()) { + vault_.lock(totalEtherLocked); } STETH.mintExternalShares(_recipient, _amountOfShares); @@ -260,7 +293,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"); @@ -279,10 +312,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 @@ -290,17 +323,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"); + _requireUnhealthy(_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); @@ -344,6 +371,32 @@ abstract contract VaultHub is PausableUntilWithRoles { emit VaultRebalanced(msg.sender, sharesToBurn); } + /// @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 _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 _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 (_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, _refundRecipient); + + emit ForceValidatorExitTriggered(_vault, _pubkeys, _refundRecipient); + } + function _disconnect(address _vault) internal { VaultSocket storage socket = _connectedSocket(_vault); IStakingVault vault_ = IStakingVault(socket.vault); @@ -353,24 +406,20 @@ abstract contract VaultHub is PausableUntilWithRoles { revert NoMintedSharesShouldBeLeft(_vault, sharesMinted); } - socket.isDisconnected = true; + socket.pendingDisconnect = true; vault_.report(vault_.valuation(), vault_.inOutDelta(), 0); 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) - { + ) public view returns (uint256[] memory lockedEther, uint256[] memory treasuryFeeShares, uint256 totalTreasuryFeeShares) { /// HERE WILL BE ACCOUNTING DRAGON // \||/ @@ -394,8 +443,8 @@ abstract contract VaultHub is PausableUntilWithRoles { for (uint256 i = 0; i < length; ++i) { VaultSocket memory socket = $.sockets[i + 1]; - if (!socket.isDisconnected) { - treasuryFeeShares[i] = _calculateLidoFees( + if (!socket.pendingDisconnect) { + treasuryFeeShares[i] = _calculateTreasuryFees( socket, _postTotalShares - _sharesToMintAsFees, _postTotalPooledEther, @@ -415,7 +464,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, @@ -446,23 +496,25 @@ abstract 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++) { 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) { socket.sharesMinted += uint96(treasuryFeeShares); } + IStakingVault(socket.vault).report(_valuations[i], _inOutDeltas[i], _locked[i]); } @@ -470,7 +522,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; @@ -482,6 +534,11 @@ abstract contract VaultHub is PausableUntilWithRoles { } } + function mintVaultsTreasuryFeeShares(address _recipient, uint256 _amountOfShares) external { + if (msg.sender != ACCOUNTING) revert NotAuthorized("mintVaultsTreasuryFeeShares", 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); } @@ -489,48 +546,39 @@ 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]; } - /// @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 } } - /// @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 RELATIVE_SHARE_LIMIT_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; + uint256 relativeMaxShareLimitPerVault = (STETH.getTotalShares() * RELATIVE_SHARE_LIMIT_BP) / TOTAL_BASIS_POINTS; if (_shareLimit > relativeMaxShareLimitPerVault) { revert ShareLimitTooHigh(_vault, _shareLimit, relativeMaxShareLimitPerVault); } } - event VaultConnected(address indexed vault, uint256 capShares, uint256 minReserveRatio, uint256 treasuryFeeBP); + function _requireUnhealthy(address _vault) internal view { + if (isVaultHealthy(_vault)) revert AlreadyHealthy(_vault); + } + + 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); 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 refundRecipient); error StETHMintFailed(address vault); - error AlreadyBalanced(address vault, uint256 mintedShares, uint256 rebalancingThresholdInShares); + error AlreadyHealthy(address vault); error InsufficientSharesToBurn(address vault, uint256 amount); error ShareLimitExceeded(address vault, uint256 capShares); error AlreadyConnected(address vault, uint256 index); @@ -542,11 +590,15 @@ abstract contract VaultHub is PausableUntilWithRoles { error TooManyVaults(); error ShareLimitTooHigh(address vault, uint256 capShares, uint256 maxCapShares); error ReserveRatioTooHigh(address vault, uint256 reserveRatioBP, 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); error AlreadyExists(bytes32 codehash); error NoMintedSharesShouldBeLeft(address vault, uint256 sharesMinted); error VaultProxyNotAllowed(address beacon); + error InvalidPubkeysLength(); + error ConnectedVaultsLimitTooLow(uint256 connectedVaultsLimit, uint256 currentVaultsCount); + error RelativeShareLimitBPTooHigh(uint256 relativeShareLimitBP, uint256 totalBasisPoints); error VaultDepositGuardianNotAllowed(address depositGuardian); } diff --git a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol index 1c9498fdf..4f85574db 100644 --- a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol +++ b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol @@ -35,8 +35,6 @@ interface IStakingVault { function vaultHub() external view returns (address); - function depositContract() external view returns (address); - function nodeOperator() external view returns (address); function depositor() external view returns (address); @@ -44,36 +42,37 @@ interface IStakingVault { 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 latestReport() external view returns (Report memory); + + function report(uint256 _valuation, int256 _inOutDelta, uint256 _locked) external; + 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 latestReport() external view returns (Report memory); + function requestValidatorExit(bytes calldata _pubkeys) external; - function report(uint256 _valuation, int256 _inOutDelta, uint256 _locked) external; + function calculateValidatorWithdrawalFee(uint256 _keysCount) external view returns (uint256); + function triggerValidatorWithdrawal( + bytes calldata _pubkeys, + uint64[] calldata _amounts, + address _refundRecipient + ) external payable; } /** diff --git a/contracts/0.8.9/LidoLocator.sol b/contracts/0.8.9/LidoLocator.sol index ac456e429..089e11817 100644 --- a/contracts/0.8.9/LidoLocator.sol +++ b/contracts/0.8.9/LidoLocator.sol @@ -31,10 +31,12 @@ contract LidoLocator is ILidoLocator { address accounting; address predepositGuarantee; address wstETH; + address vaultHub; } error ZeroAddress(); + //solhint-disable immutable-vars-naming address public immutable accountingOracle; address public immutable depositSecurityModule; address public immutable elRewardsVault; @@ -52,6 +54,8 @@ contract LidoLocator is ILidoLocator { address public immutable accounting; address public immutable predepositGuarantee; address public immutable wstETH; + address public immutable vaultHub; + //solhint-enable immutable-vars-naming /** * @notice declare service locations @@ -76,20 +80,30 @@ contract LidoLocator is ILidoLocator { accounting = _assertNonZero(_config.accounting); predepositGuarantee = _assertNonZero(_config.predepositGuarantee); 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/contracts/0.8.9/WithdrawalVault.sol b/contracts/0.8.9/WithdrawalVault.sol index c5485b785..9df5e186f 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 */ @@ -9,6 +9,9 @@ import "@openzeppelin/contracts-v4.4/token/ERC721/IERC721.sol"; import "@openzeppelin/contracts-v4.4/token/ERC20/utils/SafeERC20.sol"; import {Versioned} from "./utils/Versioned.sol"; +import {AccessControlEnumerable} from "./utils/access/AccessControlEnumerable.sol"; +import {TriggerableWithdrawals} from "../common/lib/TriggerableWithdrawals.sol"; +import {ILidoLocator} from "../common/interfaces/ILidoLocator.sol"; interface ILido { /** @@ -22,12 +25,14 @@ interface ILido { /** * @title A vault for temporary storage of withdrawals */ -contract WithdrawalVault is Versioned { +contract WithdrawalVault is AccessControlEnumerable, Versioned { using SafeERC20 for IERC20; ILido public immutable LIDO; address public immutable TREASURY; + bytes32 public constant ADD_FULL_WITHDRAWAL_REQUEST_ROLE = keccak256("ADD_FULL_WITHDRAWAL_REQUEST_ROLE"); + // Events /** * Emitted when the ERC20 `token` recovered (i.e. transferred) @@ -42,34 +47,48 @@ contract WithdrawalVault is Versioned { event ERC721Recovered(address indexed requestedBy, address indexed token, uint256 tokenId); // Errors - error LidoZeroAddress(); - error TreasuryZeroAddress(); + error ZeroAddress(); error NotLido(); error NotEnoughEther(uint256 requested, uint256 balance); error ZeroAmount(); + error InsufficientTriggerableWithdrawalFee( + uint256 providedTotalFee, + uint256 requiredTotalFee, + uint256 requestCount + ); + error TriggerableWithdrawalRefundFailed(); /** * @param _lido the Lido token (stETH) address * @param _treasury the Lido treasury address (see ERC20/ERC721-recovery interfaces) */ - constructor(ILido _lido, address _treasury) { - if (address(_lido) == address(0)) { - revert LidoZeroAddress(); - } - if (_treasury == address(0)) { - revert TreasuryZeroAddress(); - } + constructor(address _lido, address _treasury) { + _onlyNonZeroAddress(_lido); + _onlyNonZeroAddress(_treasury); - LIDO = _lido; + LIDO = ILido(_lido); TREASURY = _treasury; } - /** - * @notice Initialize the contract explicitly. - * Sets the contract version to '1'. - */ - function initialize() external { - _initializeContractVersionTo(1); + /// @notice Initializes the contract. Can be called only once. + /// @param _admin Lido DAO Aragon agent contract address. + /// @dev Proxy initialization method. + function initialize(address _admin) external { + // Initializations for v0 --> v2 + _checkContractVersion(0); + + _initialize_v2(_admin); + _initializeContractVersionTo(2); + } + + /// @notice Finalizes upgrade to v2 (from v1). Can be called only once. + /// @param _admin Lido DAO Aragon agent contract address. + function finalizeUpgrade_v2(address _admin) external { + // Finalization for v1 --> v2 + _checkContractVersion(1); + + _initialize_v2(_admin); + _updateContractVersion(2); } /** @@ -122,4 +141,66 @@ contract WithdrawalVault is Versioned { _token.transferFrom(address(this), TREASURY, _tokenId); } + + /** + * @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 + ) external payable onlyRole(ADD_FULL_WITHDRAWAL_REQUEST_ROLE) { + uint256 prevBalance = address(this).balance - msg.value; + + uint256 minFeePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee(); + uint256 totalFee = (pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH) * minFeePerRequest; + + if (totalFee > msg.value) { + revert InsufficientTriggerableWithdrawalFee( + msg.value, + totalFee, + pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH + ); + } + + TriggerableWithdrawals.addFullWithdrawalRequests(pubkeys, minFeePerRequest); + + uint256 refund = msg.value - totalFee; + if (refund > 0) { + (bool success, ) = msg.sender.call{value: refund}(""); + + if (!success) { + revert TriggerableWithdrawalRefundFailed(); + } + } + + assert(address(this).balance == prevBalance); + } + + /** + * @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(); + } + + function _onlyNonZeroAddress(address _address) internal pure { + if (_address == address(0)) revert ZeroAddress(); + } + + function _initialize_v2(address _admin) internal { + _onlyNonZeroAddress(_admin); + _setupRole(DEFAULT_ADMIN_ROLE, _admin); + } } diff --git a/contracts/common/interfaces/ILidoLocator.sol b/contracts/common/interfaces/ILidoLocator.sol index 6437c4d18..0e75b6bf1 100644 --- a/contracts/common/interfaces/ILidoLocator.sol +++ b/contracts/common/interfaces/ILidoLocator.sol @@ -23,7 +23,7 @@ interface ILidoLocator { function accounting() external view returns (address); function predepositGuarantee() 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( @@ -43,6 +43,7 @@ interface ILidoLocator { address burner, address withdrawalQueue, address postTokenRebaseReceiver, - address stakingRouter + address stakingRouter, + address vaultHub ); } diff --git a/contracts/common/lib/TriggerableWithdrawals.sol b/contracts/common/lib/TriggerableWithdrawals.sol new file mode 100644 index 000000000..0547065e8 --- /dev/null +++ b/contracts/common/lib/TriggerableWithdrawals.sol @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 + +/* 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 = 0x00000961Ef480Eb55e80D19ad83579A64c007002; + + uint256 internal constant PUBLIC_KEY_LENGTH = 48; + uint256 internal constant WITHDRAWAL_AMOUNT_LENGTH = 8; + uint256 internal constant WITHDRAWAL_REQUEST_CALLDATA_LENGTH = 56; + + error WithdrawalFeeReadFailed(); + error WithdrawalFeeInvalidData(); + 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 MismatchedArrayLengths(uint256 keysCount, uint256 amountsCount); + + /** + * @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); + feePerRequest = _validateAndAdjustFee(feePerRequest, keysCount); + + bytes memory callData = new bytes(56); + + for (uint256 i = 0; i < keysCount; i++) { + _copyPubkeyToMemory(pubkeys, callData, i); + + (bool success, ) = WITHDRAWAL_REQUEST.call{value: feePerRequest}(callData); + + if (!success) { + revert WithdrawalRequestAdditionFailed(callData); + } + } + } + + /** + * @dev 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, + uint64[] calldata amounts, + uint256 feePerRequest + ) internal { + for (uint256 i = 0; i < amounts.length; i++) { + if (amounts[i] == 0) { + revert PartialWithdrawalRequired(i); + } + } + + addWithdrawalRequests(pubkeys, amounts, feePerRequest); + } + + /** + * @dev 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); + + if (keysCount != amounts.length) { + revert MismatchedArrayLengths(keysCount, amounts.length); + } + + feePerRequest = _validateAndAdjustFee(feePerRequest, keysCount); + + bytes memory callData = new bytes(56); + for (uint256 i = 0; i < keysCount; i++) { + _copyPubkeyToMemory(pubkeys, callData, i); + _copyAmountToMemory(callData, amounts[i]); + + (bool success, ) = WITHDRAWAL_REQUEST.call{value: feePerRequest}(callData); + + if (!success) { + revert WithdrawalRequestAdditionFailed(callData); + } + } + } + + /** + * @dev Retrieves the current EIP-7002 withdrawal 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 WithdrawalFeeReadFailed(); + } + + if (feeData.length != 32) { + revert WithdrawalFeeInvalidData(); + } + + return abi.decode(feeData, (uint256)); + } + + function _copyPubkeyToMemory(bytes calldata pubkeys, bytes memory target, uint256 keyIndex) private pure { + assembly { + calldatacopy(add(target, 32), add(pubkeys.offset, mul(keyIndex, PUBLIC_KEY_LENGTH)), PUBLIC_KEY_LENGTH) + } + } + + function _copyAmountToMemory(bytes memory target, uint64 amount) private pure { + assembly { + mstore(add(target, 80), shl(192, amount)) + } + } + + function _validateAndCountPubkeys(bytes calldata pubkeys) private pure returns (uint256) { + if (pubkeys.length % PUBLIC_KEY_LENGTH != 0) { + revert MalformedPubkeysArray(); + } + + uint256 keysCount = pubkeys.length / PUBLIC_KEY_LENGTH; + if (keysCount == 0) { + revert NoWithdrawalRequests(); + } + + return keysCount; + } + + function _validateAndAdjustFee(uint256 feePerRequest, uint256 keysCount) private view returns (uint256) { + uint256 minFeePerRequest = getWithdrawalRequestFee(); + + if (feePerRequest == 0) { + feePerRequest = minFeePerRequest; + } + + if (feePerRequest < minFeePerRequest) { + revert InsufficientWithdrawalFee(feePerRequest, minFeePerRequest); + } + + if (address(this).balance < feePerRequest * keysCount) { + revert TotalWithdrawalFeeExceededBalance(address(this).balance, feePerRequest * keysCount); + } + + return feePerRequest; + } +} 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/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/globals.d.ts b/globals.d.ts index 5860e7122..0e6fb6c6a 100644 --- a/globals.d.ts +++ b/globals.d.ts @@ -1,22 +1,22 @@ 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 */ - /* 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" @@ -82,9 +82,17 @@ declare namespace NodeJS { /* for contract sourcecode verification with `hardhat-verify` */ ETHERSCAN_API_KEY?: string; - BLOCKSCOUT_API_KEY?: string; - /* Scratch deploy environment variables */ + /* for local devnet */ + LOCAL_DEVNET_PK?: string; + LOCAL_DEVNET_CHAIN_ID?: string; + LOCAL_DEVNET_EXPLORER_API_URL?: string; + LOCAL_DEVNET_EXPLORER_URL?: string; + + /* scratch deploy environment variables */ NETWORK_STATE_FILE?: string; + + /* hardhat plugins options */ + SKIP_CONTRACT_SIZE?: boolean; } } diff --git a/hardhat.config.ts b/hardhat.config.ts index 460b268f5..551ec62fd 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -22,6 +22,8 @@ import { getHardhatForkingConfig, loadAccounts } from "./hardhat.helpers"; const RPC_URL: string = process.env.RPC_URL || ""; +export const ZERO_PK = "0x0000000000000000000000000000000000000000000000000000000000000000"; + const config: HardhatUserConfig = { defaultNetwork: "hardhat", gasReporter: { @@ -46,6 +48,14 @@ 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 || ZERO_PK], + }, + "mainnet-fork": { + url: process.env.MAINNET_RPC_URL || RPC_URL, + timeout: 20 * 60 * 1000, // 20 minutes + }, "holesky": { url: process.env.HOLESKY_RPC_URL || RPC_URL, chainId: 17000, @@ -60,13 +70,23 @@ const config: HardhatUserConfig = { url: process.env.SEPOLIA_RPC_URL || RPC_URL, chainId: 11155111, }, - "mainnet-fork": { - url: process.env.MAINNET_RPC_URL || RPC_URL, - timeout: 20 * 60 * 1000, // 20 minutes - }, }, etherscan: { - apiKey: process.env.ETHERSCAN_API_KEY || "", + customChains: [ + { + network: "local-devnet", + chainId: parseInt(process.env.LOCAL_DEVNET_CHAIN_ID ?? "32382", 10), + urls: { + apiURL: process.env.LOCAL_DEVNET_EXPLORER_API_URL ?? "", + browserURL: process.env.LOCAL_DEVNET_EXPLORER_URL ?? "", + }, + }, + ], + apiKey: process.env.LOCAL_DEVNET_EXPLORER_API_URL + ? { + "local-devnet": "local-devnet", + } + : process.env.ETHERSCAN_API_KEY || "", }, solidity: { compilers: [ diff --git a/hardhat.helpers.ts b/hardhat.helpers.ts index 47f6533b8..518ce7a36 100644 --- a/hardhat.helpers.ts +++ b/hardhat.helpers.ts @@ -2,16 +2,22 @@ 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_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/deploy.ts b/lib/deploy.ts index 1f0931f15..8b308d604 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; @@ -257,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/index.ts b/lib/index.ts index 6c2aa00a1..5c9dc14ff 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -21,7 +21,5 @@ export * from "./signing-keys"; export * from "./state-file"; export * from "./string"; export * from "./time"; -export * from "./transaction"; -export * from "./type"; export * from "./units"; export * from "./deposit"; 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/context.ts b/lib/protocol/context.ts index cccc9788b..d842e1986 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); } @@ -25,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/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/helpers/accounting.ts b/lib/protocol/helpers/accounting.ts index 4280ed0d7..6c5312ce1 100644 --- a/lib/protocol/helpers/accounting.ts +++ b/lib/protocol/helpers/accounting.ts @@ -17,7 +17,6 @@ import { impersonate, log, ONE_GWEI, - trace, } from "lib"; import { ProtocolContext } from "../types"; @@ -388,8 +387,7 @@ export const handleOracleReport = async ( }); const { timeElapsed } = await getReportTimeElapsed(ctx); - - const handleReportTx = await accounting.connect(accountingOracleAccount).handleOracleReport({ + await accounting.connect(accountingOracleAccount).handleOracleReport({ timestamp: reportTimestamp, timeElapsed, // 1 day clValidators: beaconValidators, @@ -401,8 +399,6 @@ export const handleOracleReport = async ( vaultValues, inOutDeltas, }); - - await trace("accounting.handleOracleReport", handleReportTx); } catch (error) { log.error("Error", (error as Error).message ?? "Unknown error during oracle report simulation"); expect(error).to.be.undefined; @@ -596,9 +592,6 @@ const submitReport = async ( log.debug("Pushed oracle report for reached consensus", data); 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, @@ -608,10 +601,8 @@ 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(); @@ -683,12 +674,10 @@ 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(); - expect(consensusReport).to.equal(reportHash, "Consensus report hash is incorrect"); return submitter as HardhatEthersSigner; @@ -777,8 +766,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); @@ -813,9 +801,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"); } diff --git a/lib/protocol/helpers/nor.ts b/lib/protocol/helpers/nor.ts index c5185d82a..c350d7011 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 { ethers, randomBytes } from "ethers"; -import { certainAddress, log, trace } from "lib"; +import { certainAddress, log } from "lib"; import { ProtocolContext, StakingModuleName } from "../types"; @@ -123,12 +123,15 @@ 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"); - - 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, @@ -153,14 +156,17 @@ 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); const votingSigner = await ctx.getSigner("voting"); - const addKeysTx = await nor + await nor .connect(votingSigner) .addSigningKeys( operatorId, @@ -168,7 +174,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); @@ -201,12 +206,13 @@ 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"); - - 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..7dc99dcd4 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"; @@ -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++; @@ -138,24 +146,16 @@ 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, - "Name": name, - "Reward address": rewardAddress, - "Manager address": managerAddress, - }); + log.success(`Added fake SDVT operator ${operatorId}`); }; /** @@ -176,8 +176,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 +184,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); @@ -193,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}`); }; /** @@ -217,7 +208,7 @@ const sdvtSetOperatorStakingLimit = async ( const { operatorId, limit } = params; const easyTrackExecutor = await ctx.getSigner("easyTrack"); + await sdvt.connect(easyTrackExecutor).setNodeOperatorStakingLimit(operatorId, limit); - const setLimitTx = await sdvt.connect(easyTrackExecutor).setNodeOperatorStakingLimit(operatorId, limit); - await trace("simpleDVT.setNodeOperatorStakingLimit", setLimitTx); + log.success(`Set SDVT operator ${operatorId} staking limit`); }; diff --git a/lib/protocol/helpers/staking.ts b/lib/protocol/helpers/staking.ts index 39b8b9884..03422bef4 100644 --- a/lib/protocol/helpers/staking.ts +++ b/lib/protocol/helpers/staking.ts @@ -1,6 +1,6 @@ -import { ZeroAddress } from "ethers"; +import { ethers, ZeroAddress } from "ethers"; -import { certainAddress, ether, impersonate, log, trace } from "lib"; +import { certainAddress, ether, impersonate, log } from "lib"; import { ZERO_HASH } from "test/deploy"; @@ -14,11 +14,8 @@ 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"); - const tx = await lido.connect(votingSigner).resume(); - await trace("lido.resume", tx); + await lido.connect(votingSigner).resume(); log.success("Staking contract unpaused"); } @@ -29,14 +26,16 @@ 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"); - 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..eb10e630b 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"; @@ -12,17 +12,12 @@ 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(); 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 +32,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 +48,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/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/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/lib/protocol/types.ts b/lib/protocol/types.ts index 8e9c82db7..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 & @@ -151,7 +155,6 @@ export type ProtocolSigners = { export type Signer = keyof ProtocolSigners; export type ProtocolContextFlags = { - onScratch: boolean; withCSM: boolean; }; 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/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/package.json b/package.json index c446e5373..34ea1232e 100644 --- a/package.json +++ b/package.json @@ -23,15 +23,15 @@ "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: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: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": "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": "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", @@ -50,30 +50,29 @@ ] }, "devDependencies": { - "@commitlint/cli": "19.6.1", - "@commitlint/config-conventional": "19.6.0", - "@eslint/compat": "1.2.5", - "@eslint/js": "9.19.0", + "@commitlint/cli": "19.7.1", + "@commitlint/config-conventional": "19.7.1", + "@eslint/compat": "1.2.7", + "@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", "@types/eslint": "9.6.1", - "@types/eslint__js": "8.42.3", "@types/mocha": "10.0.10", - "@types/node": "22.10.10", + "@types/node": "22.13.5", "bigint-conversion": "2.4.3", "chai": "4.5.0", "chalk": "4.1.2", "dotenv": "16.4.7", - "eslint": "9.19.0", + "eslint": "9.21.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-no-only-tests": "3.3.0", "eslint-plugin-prettier": "5.2.3", @@ -81,8 +80,8 @@ "ethereumjs-util": "7.1.5", "ethers": "6.13.5", "glob": "11.0.1", - "globals": "15.14.0", - "hardhat": "2.22.18", + "globals": "15.15.0", + "hardhat": "2.22.19", "hardhat-contract-sizer": "2.10.0", "hardhat-gas-reporter": "1.0.10", "hardhat-ignore-warnings": "0.2.12", @@ -90,7 +89,7 @@ "hardhat-watcher": "2.5.0", "husky": "9.1.7", "lint-staged": "15.4.3", - "prettier": "3.4.2", + "prettier": "3.5.2", "prettier-plugin-solidity": "1.4.2", "solhint": "5.0.5", "solhint-plugin-lido": "0.0.4", @@ -99,7 +98,7 @@ "tsconfig-paths": "4.2.0", "typechain": "8.3.2", "typescript": "5.7.3", - "typescript-eslint": "8.21.0" + "typescript-eslint": "8.25.0" }, "dependencies": { "@aragon/apps-agent": "2.1.0", diff --git a/scripts/defaults/testnet-defaults.json b/scripts/defaults/testnet-defaults.json index 1a2e0426b..06032d496 100644 --- a/scripts/defaults/testnet-defaults.json +++ b/scripts/defaults/testnet-defaults.json @@ -77,6 +77,12 @@ "epochsPerFrame": 12 } }, + "vaultHub": { + "deployParameters": { + "connectedVaultsLimit": 500, + "relativeShareLimitBP": 1000 + } + }, "accountingOracle": { "deployParameters": { "consensusVersion": 2 @@ -90,7 +96,7 @@ }, "validatorsExitBusOracle": { "deployParameters": { - "consensusVersion": 1 + "consensusVersion": 2 } }, "depositSecurityModule": { @@ -138,7 +144,7 @@ }, "simpleDvt": { "deployParameters": { - "stakingModuleTypeId": "simple-dvt-onchain-v1", + "stakingModuleTypeId": "curated-onchain-v1", "stuckPenaltyDelay": 432000 } }, 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..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,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"), 10); 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); diff --git a/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts b/scripts/scratch/steps/0090-deploy-non-aragon-contracts.ts index 1687cd717..dde72ba34 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 vaultHubParams = state[Sk.vaultHub].deployParameters; const burnerParams = state[Sk.burner].deployParameters; const hashConsensusForAccountingParams = state[Sk.hashConsensusForAccountingOracle].deployParameters; const hashConsensusForExitBusParams = state[Sk.hashConsensusForValidatorsExitBusOracle].deployParameters; @@ -143,6 +144,13 @@ export async function main() { lidoAddress, ]); + const vaultHub = await deployBehindOssifiableProxy(Sk.vaultHub, "VaultHub", proxyContractsOwner, deployer, [ + lidoAddress, + accounting.address, + vaultHubParams.connectedVaultsLimit, + vaultHubParams.relativeShareLimitBP, + ]); + // Deploy AccountingOracle const accountingOracle = await deployBehindOssifiableProxy( Sk.accountingOracle, @@ -210,6 +218,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 f16e93c5f..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; @@ -36,7 +36,8 @@ export async function main() { const exitBusOracleAdmin = testnetAdmin; const stakingRouterAdmin = testnetAdmin; const withdrawalQueueAdmin = testnetAdmin; - const accountingAdmin = testnetAdmin; + const withdrawalVaultAdmin = testnetAdmin; + const vaultHubAdmin = testnetAdmin; // Initialize NodeOperatorsRegistry @@ -110,6 +111,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 }); @@ -142,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 b988acdef..4d1115709 100644 --- a/scripts/scratch/steps/0130-grant-roles.ts +++ b/scripts/scratch/steps/0130-grant-roles.ts @@ -1,6 +1,13 @@ import { ethers } from "hardhat"; -import { Accounting, Burner, StakingRouter, ValidatorsExitBusOracle, WithdrawalQueueERC721 } from "typechain-types"; +import { + Burner, + StakingRouter, + ValidatorsExitBusOracle, + VaultHub, + WithdrawalQueueERC721, + WithdrawalVault, +} from "typechain-types"; import { loadContract } from "lib/contract"; import { makeTx } from "lib/deploy"; @@ -19,11 +26,12 @@ 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 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( @@ -81,6 +89,13 @@ export async function main() { from: deployer, }); + // WithdrawalVault + const withdrawalVault = await loadContract("WithdrawalVault", withdrawalVaultAddress); + const fullWithdrawalRequestRole = await withdrawalVault.ADD_FULL_WITHDRAWAL_REQUEST_ROLE(); + await makeTx(withdrawalVault, "grantRole", [fullWithdrawalRequestRole, validatorsExitBusOracleAddress], { + from: deployer, + }); + // Burner const burner = await loadContract("Burner", burnerAddress); // NB: REQUEST_BURN_SHARES_ROLE is already granted to Lido in Burner constructor @@ -94,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 }); } 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) { 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..ecd0c75e8 --- /dev/null +++ b/tasks/logger.ts @@ -0,0 +1,158 @@ +import "hardhat/types/runtime"; +import chalk from "chalk"; +import { formatUnits, Interface, TransactionReceipt, TransactionResponse } from "ethers"; +import { extendEnvironment } from "hardhat/config"; +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; + +const interfaceCache = new Map(); +const callCache = new Map(); + +enum TransactionType { + CONTRACT_DEPLOYMENT = "Contract deployment", + ETH_TRANSFER = "ETH transfer", + CONTRACT_CALL = "Contract call", +} + +type Call = { + contract: string; + function: string; +}; + +function outputTransaction( + tx: TransactionResponse, + txType: TransactionType, + receipt: TransactionReceipt, + call: Call, + gasLimit: number, + gasPrice: string, +): 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) { + console.log(` Contract deployed: ${chalk.cyan(receipt.contractAddress)}`); + } else if (txType === TransactionType.ETH_TRANSFER) { + console.log(` ETH transfer: ${chalk.yellow(tx.value)}`); + } else { + console.log(` ${txCall} ${txStatus}`); + } + console.log(); +} + +// Transaction Processing +async function getCall(tx: TransactionResponse, hre: HardhatRuntimeEnvironment): Promise { + if (!tx.data || tx.data === "0x" || !tx.to) return { contract: "", function: "" }; + + const cacheKey = `${tx.to}-${tx.data.slice(0, FUNCTION_SIGNATURE_LENGTH)}`; + if (callCache.has(cacheKey)) { + return callCache.get(cacheKey)!; + } + + 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; + } +} + +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 { contract: "", function: "" }; +} + +async function getOrCreateInterface(artifactName: string, hre: HardhatRuntimeEnvironment) { + if (interfaceCache.has(artifactName)) { + return interfaceCache.get(artifactName)!; + } + + const artifact = await hre.artifacts.readArtifact(artifactName); + const iface = new Interface(artifact.abi); + interfaceCache.set(artifactName, iface); + return iface; +} + +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; +} + +async function logTransaction(tx: TransactionResponse, hre: HardhatRuntimeEnvironment) { + const receipt = await tx.wait(); + if (!receipt) throw new Error("Transaction receipt not found"); + + try { + const network = await tx.provider.getNetwork(); + const config = hre.config.networks[network.name] as HardhatNetworkConfig; + const gasLimit = 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"); + + outputTransaction(tx, txType, receipt, call, gasLimit, gasPrice); + + return receipt; + } catch (error) { + console.error("Error logging transaction:", error); + return receipt; + } +} + +extendEnvironment((hre: HardhatRuntimeEnvironment) => { + if (LOG_LEVEL != "debug" && LOG_LEVEL != "all") return; + + const originalSendTransaction = hre.ethers.provider.send; + + hre.ethers.provider.send = async function (method: string, params: unknown[]) { + const result = await originalSendTransaction.apply(this, [method, params]); + + if (method === "eth_sendTransaction" || method === "eth_sendRawTransaction") { + const tx = (await this.getTransaction(result)) as TransactionResponse; + await logTransaction(tx, hre); + } + + return result; + }; +}); diff --git a/tasks/verify-contracts.ts b/tasks/verify-contracts.ts index 116917084..3aa99009c 100644 --- a/tasks/verify-contracts.ts +++ b/tasks/verify-contracts.ts @@ -67,6 +67,11 @@ task("verify:deployed", "Verifies deployed contracts based on state file") }); 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; + } + log.splitter(); const contractName = contract.contractName ?? contract.contract.split("/").pop()?.split(".")[0]; 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); }); }); 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..3a97c8ccd --- /dev/null +++ b/test/0.8.25/utils/access-control-confirmable.test.ts @@ -0,0 +1,129 @@ +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.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); + + 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_EXPIRY()).to.equal(days(1n)); + expect(await harness.MAX_CONFIRM_EXPIRY()).to.equal(days(30n)); + }); + }); + + context("getConfirmExpiry()", () => { + it("returns the minimal expiry initially", async () => { + expect(await harness.getConfirmExpiry()).to.equal(await harness.MIN_CONFIRM_EXPIRY()); + }); + }); + + 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("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 expiry is out of bounds", async () => { + await expect(harness.setConfirmExpiry((await harness.MIN_CONFIRM_EXPIRY()) - 1n)).to.be.revertedWithCustomError( + harness, + "ConfirmExpiryOutOfBounds", + ); + + await expect(harness.setConfirmExpiry((await harness.MAX_CONFIRM_EXPIRY()) + 1n)).to.be.revertedWithCustomError( + harness, + "ConfirmExpiryOutOfBounds", + ); + }); + }); + + 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.getConfirmExpiry()); + 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.getConfirmExpiry()); + 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..459ab5d44 --- /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 setConfirmExpiry(uint256 _confirmExpiry) external { + _setConfirmExpiry(_confirmExpiry); + } + + function setNumber(uint256 _number) external onlyConfirmed(confirmingRoles()) { + number = _number; + } + + function decrementWithZeroRoles() external onlyConfirmed(new bytes32[](0)) { + number--; + } +} diff --git a/test/0.8.25/vaults/accounting.test.ts b/test/0.8.25/vaults/accounting.test.ts deleted file mode 100644 index c7cbd6704..000000000 --- a/test/0.8.25/vaults/accounting.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { expect } from "chai"; -import { ZeroAddress } from "ethers"; -import { ethers } from "hardhat"; - -import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; - -import { Accounting, LidoLocator, OssifiableProxy, StETH__HarnessForVaultHub } from "typechain-types"; - -import { ether } from "lib"; - -import { deployLidoLocator } from "test/deploy"; -import { Snapshot } from "test/suite"; - -describe("Accounting.sol", () => { - let deployer: HardhatEthersSigner; - let admin: HardhatEthersSigner; - let user: HardhatEthersSigner; - let holder: HardhatEthersSigner; - let stranger: HardhatEthersSigner; - - let proxy: OssifiableProxy; - let vaultHubImpl: Accounting; - let accounting: Accounting; - let steth: StETH__HarnessForVaultHub; - let locator: LidoLocator; - - let originalState: string; - - before(async () => { - [deployer, admin, user, holder, stranger] = await ethers.getSigners(); - - steth = await ethers.deployContract("StETH__HarnessForVaultHub", [holder], { - value: ether("10.0"), - from: deployer, - }); - locator = await deployLidoLocator({ lido: steth }); - - // VaultHub - vaultHubImpl = await ethers.deployContract("Accounting", [locator], { from: deployer }); - - proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, admin, new Uint8Array()], admin); - - accounting = await ethers.getContractAt("Accounting", proxy, user); - }); - - beforeEach(async () => (originalState = await Snapshot.take())); - - afterEach(async () => await Snapshot.restore(originalState)); - - context("constructor", () => { - it("reverts on impl initialization", async () => { - await expect(vaultHubImpl.initialize(stranger)).to.be.revertedWithCustomError( - vaultHubImpl, - "InvalidInitialization", - ); - }); - it("reverts on `_admin` address is zero", async () => { - await expect(accounting.initialize(ZeroAddress)) - .to.be.revertedWithCustomError(vaultHubImpl, "ZeroArgument") - .withArgs("_admin"); - }); - it("initialization happy path", async () => { - const tx = await accounting.initialize(admin); - - expect(await accounting.vaultsCount()).to.eq(0); - - await expect(tx).to.be.emit(accounting, "Initialized").withArgs(1); - }); - }); -}); diff --git a/test/0.8.25/vaults/contracts/StETH__HarnessForVaultHub.sol b/test/0.8.25/vaults/contracts/StETH__HarnessForVaultHub.sol index 1a5430e1c..93376da60 100644 --- a/test/0.8.25/vaults/contracts/StETH__HarnessForVaultHub.sol +++ b/test/0.8.25/vaults/contracts/StETH__HarnessForVaultHub.sol @@ -41,4 +41,15 @@ contract StETH__HarnessForVaultHub is StETH { function harness__mintInitialShares(uint256 _sharesAmount) public { _mintInitialShares(_sharesAmount); } + + 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/contracts/StakingVault__HarnessForTestUpgrade.sol b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol index 14fcd1d44..22d947afa 100644 --- a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol +++ b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol @@ -20,9 +20,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; @@ -31,7 +32,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 @@ -76,10 +77,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}); @@ -95,14 +92,10 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl function fund() external payable {} - function inOutDelta() external view returns (int256) { + function inOutDelta() external pure returns (int256) { return -1; } - function isBalanced() external view returns (bool) { - return true; - } - function nodeOperator() external view returns (address) { return _getVaultStorage().nodeOperator; } @@ -111,19 +104,16 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl function report(uint256 _valuation, int256 _inOutDelta, uint256 _locked) external {} - function requestValidatorExit(bytes calldata _pubkeys) 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; } @@ -134,10 +124,10 @@ 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 view returns (bool) { + function beaconChainDepositsPaused() external pure returns (bool) { return false; } @@ -145,6 +135,17 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl function resumeBeaconChainDeposits() external {} + function calculateValidatorWithdrawalFee(uint256) external pure returns (uint256) { + return 1; + } + + function requestValidatorExit(bytes calldata _pubkeys) external {} + function triggerValidatorWithdrawal( + bytes calldata _pubkeys, + uint64[] calldata _amounts, + address _recipient + ) external payable {} + error ZeroArgument(string name); error VaultAlreadyInitialized(); } 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/VaultFactory__MockForDashboard.sol b/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol index e695bd2bc..df5833c1d 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); @@ -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.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/contracts/VaultHub__MockForDashboard.sol b/test/0.8.25/vaults/dashboard/contracts/VaultHub__MockForDashboard.sol index 95781fb4a..814b38108 100644 --- a/test/0.8.25/vaults/dashboard/contracts/VaultHub__MockForDashboard.sol +++ b/test/0.8.25/vaults/dashboard/contracts/VaultHub__MockForDashboard.sol @@ -37,11 +37,11 @@ contract VaultHub__MockForDashboard { return vaultSockets[vault]; } - function disconnectVault(address vault) external { + function disconnect(address vault) external { 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/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index ed0f85440..e0fafd653 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -11,6 +11,7 @@ import { DepositContract__MockForStakingVault, ERC721_MockForDashboard, LidoLocator, + Permissions, StakingVault, StETHPermit__HarnessForDashboard, VaultFactory__MockForDashboard, @@ -21,7 +22,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", () => { @@ -45,13 +46,19 @@ describe("Dashboard.sol", () => { let dashboard: Dashboard; let dashboardAddress: string; + const confirmExpiry = days(7n); + let originalState: string; 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")); @@ -125,19 +132,29 @@ 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, confirmExpiry)).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, confirmExpiry)).to.be.revertedWithCustomError( dashboard_, "NonProxyCallsForbidden", ); }); }); + context("confirmingRoles", () => { + it("returns the array of roles", async () => { + const confirmingRoles = await dashboard.confirmingRoles(); + expect(confirmingRoles).to.deep.equal([await dashboard.DEFAULT_ADMIN_ROLE()]); + }); + }); + context("initialized state", () => { it("post-initialization state is correct", async () => { // vault state @@ -165,9 +182,9 @@ describe("Dashboard.sol", () => { sharesMinted: 555n, shareLimit: 1000n, reserveRatioBP: 1000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; await hub.mock__setVaultSocket(vault, sockets); @@ -176,8 +193,8 @@ 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.treasuryFee()).to.equal(sockets.treasuryFeeBP); + expect(await dashboard.rebalanceThresholdBP()).to.equal(sockets.rebalanceThresholdBP); + expect(await dashboard.treasuryFeeBP()).to.equal(sockets.treasuryFeeBP); }); }); @@ -201,10 +218,11 @@ describe("Dashboard.sol", () => { shareLimit: 1000000000n, sharesMinted: 555n, reserveRatioBP: 1000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; + await hub.mock__setVaultSocket(vault, sockets); await dashboard.fund({ value: 1000n }); @@ -222,10 +240,11 @@ describe("Dashboard.sol", () => { shareLimit: 100n, sharesMinted: 0n, reserveRatioBP: 1000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; + await hub.mock__setVaultSocket(vault, sockets); await dashboard.fund({ value: 1000n }); @@ -241,10 +260,11 @@ describe("Dashboard.sol", () => { shareLimit: 1000000000n, sharesMinted: 555n, reserveRatioBP: 10_000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; + await hub.mock__setVaultSocket(vault, sockets); await dashboard.fund({ value: 1000n }); @@ -260,10 +280,11 @@ describe("Dashboard.sol", () => { shareLimit: 10000000n, sharesMinted: 555n, reserveRatioBP: 0n, - reserveRatioThresholdBP: 0n, + rebalanceThresholdBP: 0n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; + await hub.mock__setVaultSocket(vault, sockets); const funding = 1000n; await dashboard.fund({ value: funding }); @@ -287,10 +308,11 @@ describe("Dashboard.sol", () => { shareLimit: 10000000n, sharesMinted: 0n, reserveRatioBP: 1000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; + await hub.mock__setVaultSocket(vault, sockets); const funding = 1000n; @@ -312,10 +334,11 @@ describe("Dashboard.sol", () => { shareLimit: 10000000n, sharesMinted: 900n, reserveRatioBP: 1000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; + await hub.mock__setVaultSocket(vault, sockets); const funding = 1000n; @@ -334,10 +357,11 @@ describe("Dashboard.sol", () => { shareLimit: 10000000n, sharesMinted: 10000n, reserveRatioBP: 1000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; + await hub.mock__setVaultSocket(vault, sockets); const funding = 1000n; const preFundCanMint = await dashboard.projectedNewMintableShares(funding); @@ -354,10 +378,11 @@ describe("Dashboard.sol", () => { shareLimit: 10000000n, sharesMinted: 500n, reserveRatioBP: 1000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; + await hub.mock__setVaultSocket(vault, sockets); const funding = 2000n; @@ -377,9 +402,9 @@ describe("Dashboard.sol", () => { shareLimit: 500n, sharesMinted: 500n, reserveRatioBP: 1000n, - reserveRatioThresholdBP: 800n, + rebalanceThresholdBP: 800n, treasuryFeeBP: 500n, - isDisconnected: false, + pendingDisconnect: false, }; await hub.mock__setVaultSocket(vault, sockets); @@ -460,7 +485,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", ); }); @@ -606,19 +631,48 @@ describe("Dashboard.sol", () => { }); context("requestValidatorExit", () => { + const pubkeys = ["01".repeat(48), "02".repeat(48)]; + const pubkeysConcat = `0x${pubkeys.join("")}`; + 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( + await expect(dashboard.connect(stranger).requestValidatorExit(pubkeysConcat)).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)) - .to.emit(vault, "ValidatorsExitRequest") - .withArgs(dashboard, validatorPublicKey); + it("signals the requested exit of a validator", async () => { + await expect(dashboard.requestValidatorExit(pubkeysConcat)) + .to.emit(vault, "ValidatorExitRequested") + .withArgs(dashboard, `0x${pubkeys[0]}`, `0x${pubkeys[0]}`) + .to.emit(vault, "ValidatorExitRequested") + .withArgs(dashboard, `0x${pubkeys[1]}`, `0x${pubkeys[1]}`); + }); + }); + + context("triggerValidatorWithdrawal", () => { + it("reverts if called by a non-admin", async () => { + await expect( + dashboard.connect(stranger).triggerValidatorWithdrawal("0x", [0n], vaultOwner), + ).to.be.revertedWithCustomError(dashboard, "AccessControlUnauthorizedAccount"); + }); + + it("requests a full validator withdrawal", async () => { + const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); + const amounts = [0n]; // 0 amount means full withdrawal + + await expect(dashboard.triggerValidatorWithdrawal(validatorPublicKeys, amounts, vaultOwner, { value: FEE })) + .to.emit(vault, "ValidatorWithdrawalTriggered") + .withArgs(dashboard, validatorPublicKeys, amounts, vaultOwner, 0n); + }); + + it("requests a partial validator withdrawal", async () => { + const validatorPublicKeys = "0x" + randomBytes(48).toString("hex"); + const amounts = [ether("0.1")]; + + await expect(dashboard.triggerValidatorWithdrawal(validatorPublicKeys, amounts, vaultOwner, { value: FEE })) + .to.emit(vault, "ValidatorWithdrawalTriggered") + .withArgs(dashboard, validatorPublicKeys, amounts, vaultOwner, 0n); }); }); @@ -883,7 +937,7 @@ describe("Dashboard.sol", () => { await steth.mock__setTotalPooledEther(baseTotalEther); await steth.mock__setTotalShares(baseTotalEther); - const wstethContract = await wsteth.connect(vaultOwner); + const wstethContract = wsteth.connect(vaultOwner); const totalEtherStep = baseTotalEther / 10n; const totalEtherMax = baseTotalEther * 2n; @@ -1718,4 +1772,47 @@ describe("Dashboard.sol", () => { expect(await vault.beaconChainDepositsPaused()).to.be.false; }); }); + + context("role management", () => { + let assignments: Permissions.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; + } + }); + }); + }); }); 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..dab32dab8 100644 --- a/test/0.8.25/vaults/delegation/contracts/VaultHub__MockForDelegation.sol +++ b/test/0.8.25/vaults/delegation/contracts/VaultHub__MockForDelegation.sol @@ -16,15 +16,15 @@ 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); } - 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/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index 7fe32938d..d3da33763 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -34,11 +34,14 @@ describe("Delegation.sol", () => { let rebalancer: HardhatEthersSigner; let depositPauser: HardhatEthersSigner; let depositResumer: HardhatEthersSigner; - let exitRequester: HardhatEthersSigner; + let validatorExitRequester: HardhatEthersSigner; + let validatorWithdrawalTriggerer: HardhatEthersSigner; let disconnecter: HardhatEthersSigner; - let curator: HardhatEthersSigner; + let curatorFeeSetter: HardhatEthersSigner; + let curatorFeeClaimer: HardhatEthersSigner; let nodeOperatorManager: HardhatEthersSigner; let nodeOperatorFeeClaimer: HardhatEthersSigner; + let stranger: HardhatEthersSigner; let beaconOwner: HardhatEthersSigner; let hubSigner: HardhatEthersSigner; @@ -70,9 +73,11 @@ describe("Delegation.sol", () => { rebalancer, depositPauser, depositResumer, - exitRequester, + validatorExitRequester, + validatorWithdrawalTriggerer, disconnecter, - curator, + curatorFeeSetter, + curatorFeeClaimer, nodeOperatorManager, nodeOperatorFeeClaimer, stranger, @@ -105,21 +110,24 @@ describe("Delegation.sol", () => { const vaultCreationTx = await factory.connect(vaultOwner).createVaultWithDelegation( { defaultAdmin: vaultOwner, - funder, - withdrawer, - minter, - burner, - rebalancer, - depositPauser, - depositResumer, - exitRequester, - disconnecter, - curator, - assetRecoverer: curator, nodeOperatorManager, - nodeOperatorFeeClaimer, + confirmExpiry: days(7n), curatorFeeBP: 0n, nodeOperatorFeeBP: 0n, + assetRecoverer: vaultOwner, + funders: [funder], + withdrawers: [withdrawer], + minters: [minter], + burners: [burner], + rebalancers: [rebalancer], + depositPausers: [depositPauser], + depositResumers: [depositResumer], + validatorExitRequesters: [validatorExitRequester], + validatorWithdrawalTriggerers: [validatorWithdrawalTriggerer], + disconnecters: [disconnecter], + curatorFeeSetters: [curatorFeeSetter], + curatorFeeClaimers: [curatorFeeClaimer], + nodeOperatorFeeClaimers: [nodeOperatorFeeClaimer], }, "0x", ); @@ -174,13 +182,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", ); @@ -203,11 +214,13 @@ 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.REQUEST_VALIDATOR_EXIT_ROLE()); + await assertSoleMember(validatorWithdrawalTriggerer, await delegation.TRIGGER_VALIDATOR_WITHDRAWAL_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(nodeOperatorFeeClaimer, await delegation.NODE_OPERATOR_FEE_CLAIM_ROLE()); expect(await delegation.curatorFeeBP()).to.equal(0n); expect(await delegation.nodeOperatorFeeBP()).to.equal(0n); @@ -218,41 +231,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("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, - "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 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(curator).setVoteLifetime(newVoteLifetime)) - .to.emit(delegation, "RoleMemberVoted") - .withArgs(curator, await delegation.CURATOR_ROLE(), voteTimestamp, msgData); + await expect(delegation.connect(vaultOwner).setConfirmExpiry(newConfirmExpiry)) + .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.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, "ConfirmExpirySet") + .withArgs(nodeOperatorManager, oldConfirmExpiry, newConfirmExpiry); - expect(await delegation.voteLifetime()).to.equal(newVoteLifetime); + expect(await delegation.getConfirmExpiry()).to.equal(newConfirmExpiry); }); }); @@ -260,25 +273,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"); @@ -293,7 +306,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); @@ -325,7 +338,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"); @@ -363,6 +376,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(curatorFeeSetter).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( @@ -510,13 +562,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 @@ -527,14 +579,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", ); @@ -542,7 +594,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); }); }); @@ -550,7 +602,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), @@ -560,7 +612,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); @@ -572,39 +624,39 @@ 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.getConfirmExpiry()); 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.confirmations(msgData, await delegation.DEFAULT_ADMIN_ROLE())).to.equal(expiryTimestamp); - voteTimestamp = await getNextBlockTimestamp(); + expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmExpiry()); 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.confirmations(keccak256(msgData), role)).to.equal(0n); } }); @@ -612,46 +664,46 @@ 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.getConfirmExpiry()); + + 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.confirmations(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.getConfirmExpiry()); + 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, + // check confirm + expect(await delegation.confirmations(msgData, await delegation.NODE_OPERATOR_MANAGER_ROLE())).to.equal( + expectedExpiryTimestamp, ); - // 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) + // curator has to confirm again + 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) .and.to.emit(delegation, "NodeOperatorFeeBPSet") - .withArgs(curator, previousOperatorFee, newOperatorFee); + .withArgs(vaultOwner, previousOperatorFee, newOperatorFee); // fee is now changed expect(await delegation.nodeOperatorFeeBP()).to.equal(newOperatorFee); }); @@ -661,24 +713,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.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); - voteTimestamp = await getNextBlockTimestamp(); + expiryTimestamp = (await getNextBlockTimestamp()) + (await delegation.getConfirmExpiry()); 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); }); 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..a2cad94e1 --- /dev/null +++ b/test/0.8.25/vaults/permissions/contracts/Permissions__Harness.sol @@ -0,0 +1,65 @@ +// 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 _confirmExpiry) external { + _initialize(_defaultAdmin, _confirmExpiry); + } + + function revertDoubleInitialize(address _defaultAdmin, uint256 _confirmExpiry) external { + _initialize(_defaultAdmin, _confirmExpiry); + _initialize(_defaultAdmin, _confirmExpiry); + } + + function confirmingRoles() external pure returns (bytes32[] memory) { + return _confirmingRoles(); + } + + function fund(uint256 _ether) external payable { + _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 voluntaryDisconnect() external { + _voluntaryDisconnect(); + } + + function transferStakingVaultOwnership(address _newOwner) external { + _transferStakingVaultOwnership(_newOwner); + } + + 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 new file mode 100644 index 000000000..3a45a9ce1 --- /dev/null +++ b/test/0.8.25/vaults/permissions/contracts/VaultFactory__MockPermissions.sol @@ -0,0 +1,188 @@ +// 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 confirmExpiry; + 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; + address public immutable PREDEPOSIT_GUARANTEE; + + /// @param _beacon The address of the beacon contract + /// @param _permissionsImpl The address of the Permissions implementation + constructor(address _beacon, address _permissionsImpl, address _predeposit_guarantee) { + if (_beacon == address(0)) revert ZeroArgument("_beacon"); + if (_permissionsImpl == address(0)) revert ZeroArgument("_permissionsImpl"); + if (_predeposit_guarantee == address(0)) revert ZeroArgument("_predeposit_guarantee"); + + BEACON = _beacon; + PERMISSIONS_IMPL = _permissionsImpl; + PREDEPOSIT_GUARANTEE = _predeposit_guarantee; + } + + /// @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, + PREDEPOSIT_GUARANTEE, + _stakingVaultInitializerExtraParams + ); + + // initialize Permissions + permissions.initialize(address(this), _permissionsConfig.confirmExpiry); + + // 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 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, + PREDEPOSIT_GUARANTEE, + _stakingVaultInitializerExtraParams + ); + + // initialize Permissions + permissions.initialize(address(this), _permissionsConfig.confirmExpiry); + // should revert here + permissions.initialize(address(this), _permissionsConfig.confirmExpiry); + + // 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, + PREDEPOSIT_GUARANTEE, + _stakingVaultInitializerExtraParams + ); + + // should revert here + permissions.initialize(address(0), _permissionsConfig.confirmExpiry); + + // 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..6ee7437ef --- /dev/null +++ b/test/0.8.25/vaults/permissions/contracts/VaultHub__MockPermissions.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity ^0.8.0; + +contract VaultHub__MockPermissions { + 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 mintShares(address _stakingVault, address _recipient, uint256 _shares) external { + emit Mock__SharesMinted(_stakingVault, _recipient, _shares); + } + + function burnShares(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 new file mode 100644 index 000000000..9d2359d2d --- /dev/null +++ b/test/0.8.25/vaults/permissions/permissions.test.ts @@ -0,0 +1,616 @@ +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 { certainAddress, days, ether, findEvents } from "lib"; + +import { Snapshot } from "test/suite"; + +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 stranger: 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; + + let originalState: string; + + before(async () => { + [ + deployer, + defaultAdmin, + nodeOperator, + funder, + withdrawer, + minter, + burner, + rebalancer, + depositPauser, + depositResumer, + exitRequester, + disconnecter, + stranger, + ] = 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.DEPOSIT_CONTRACT()).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, + confirmExpiry: 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); + }); + + 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()); + 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()); + 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, + confirmExpiry: 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, + confirmExpiry: 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 () => { + const pubkeys = "0x" + "beef".repeat(24); + await expect(permissions.connect(exitRequester).requestValidatorExit(pubkeys)) + .to.emit(stakingVault, "ValidatorExitRequested") + .withArgs(permissions, pubkeys, pubkeys); + }); + + 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"); + }); + }); + + async function checkSoleMember(account: HardhatEthersSigner, role: string) { + expect(await permissions.getRoleMemberCount(role)).to.equal(1); + expect(await permissions.getRoleMember(role, 0)).to.equal(account); + } +}); diff --git a/test/0.8.25/vaults/staking-vault/staking-vault.test.ts b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts similarity index 51% rename from test/0.8.25/vaults/staking-vault/staking-vault.test.ts rename to test/0.8.25/vaults/staking-vault/stakingVault.test.ts index fc28ee484..05ac793e9 100644 --- a/test/0.8.25/vaults/staking-vault/staking-vault.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"; @@ -7,20 +7,39 @@ import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; import { DepositContract__MockForStakingVault, + EIP7002WithdrawalRequest_Mock, EthRejector, StakingVault, - StakingVault__factory, - VaultFactory__MockForStakingVault, VaultHub__MockForStakingVault, } from "typechain-types"; -import { computeDepositDataRoot, de0x, ether, findEvents, impersonate, streccak } from "lib"; +import { computeDepositDataRoot, de0x, ether, impersonate, MAX_UINT256, proxify, streccak } from "lib"; +import { deployStakingVaultBehindBeaconProxy, deployWithdrawalsPreDeployedMock } from "test/deploy"; import { Snapshot } from "test/suite"; const MAX_INT128 = 2n ** 127n - 1n; const MAX_UINT128 = 2n ** 128n - 1n; +const PUBLIC_KEY_LENGTH = 48; +const SAMPLE_PUBKEY = "0x" + "ab".repeat(48); + +const getPubkeys = (num: number): { pubkeys: string[]; stringified: string } => { + 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("")}`, + }; +}; + +const encodeEip7002Input = (pubkey: string, amount: bigint): string => { + return `${pubkey}${amount.toString(16).padStart(16, "0")}`; +}; + // @TODO: test reentrancy attacks describe("StakingVault.sol", () => { let vaultOwner: HardhatEthersSigner; @@ -33,6 +52,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; @@ -40,12 +60,16 @@ describe("StakingVault.sol", () => { let vaultHubAddress: string; let depositContractAddress: string; let ethRejectorAddress: string; + let originalState: string; before(async () => { [vaultOwner, operator, elRewardsSender, stranger] = await ethers.getSigners(); - [stakingVault, vaultHub /* vaultFactory */, , stakingVaultImplementation, depositContract] = - await deployStakingVaultBehindBeaconProxy(); + ({ stakingVault, vaultHub, stakingVaultImplementation, depositContract } = + await deployStakingVaultBehindBeaconProxy(vaultOwner, operator)); + + // ERC7002 pre-deployed contract mock (0x00000961Ef480Eb55e80D19ad83579A64c007002) + withdrawalRequest = await deployWithdrawalsPreDeployedMock(1n); ethRejector = await ethers.deployContract("EthRejector"); vaultOwnerAddress = await vaultOwner.getAddress(); @@ -54,16 +78,12 @@ 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(); - }); + 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 () => { @@ -71,7 +91,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 () => { @@ -85,7 +105,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,28 +118,56 @@ describe("StakingVault.sol", () => { stakingVaultImplementation.connect(stranger).initialize(vaultOwner, operator, 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, ZeroAddress, "0x")).to.be.revertedWithCustomError( + stakingVaultImplementation, + "ZeroArgument", + ); + }); }); - 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.DEPOSIT_CONTRACT()).to.equal(depositContractAddress); + expect(await stakingVault.PUBLIC_KEY_LENGTH()).to.equal(PUBLIC_KEY_LENGTH); + + 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.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("valuation", () => { + it("returns the correct valuation", async () => { + expect(await stakingVault.valuation()).to.equal(0n); + + await stakingVault.fund({ value: ether("1") }); + expect(await stakingVault.valuation()).to.equal(ether("1")); + }); + }); + + context("locked", () => { + it("returns the correct locked balance", async () => { + expect(await stakingVault.locked()).to.equal(0n); + + await stakingVault.connect(vaultHubSigner).lock(ether("1")); + expect(await stakingVault.locked()).to.equal(ether("1")); + }); + }); + context("unlocked", () => { it("returns the correct unlocked balance", async () => { expect(await stakingVault.unlocked()).to.equal(0n); @@ -132,6 +182,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]); @@ -143,6 +205,12 @@ describe("StakingVault.sol", () => { }); }); + context("nodeOperator", () => { + it("returns the correct node operator", async () => { + expect(await stakingVault.nodeOperator()).to.equal(operator); + }); + }); + context("receive", () => { it("reverts if msg.value is zero", async () => { await expect(vaultOwner.sendTransaction({ to: stakingVaultAddress, value: 0n })) @@ -188,6 +256,14 @@ describe("StakingVault.sol", () => { await setBalance(vaultOwnerAddress, bigBalance); await expect(stakingVault.fund({ value: maxInOutDelta })).to.not.be.reverted; }); + + 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()); + + await stakingVault.fund({ value: ether("1") }); + expect(await stakingVault.valuation()).to.be.greaterThanOrEqual(await stakingVault.locked()); + }); }); context("withdraw", () => { @@ -229,6 +305,8 @@ describe("StakingVault.sol", () => { .withArgs(unlocked); }); + it.skip("reverts if vault is unhealthy", async () => {}); + it("does not revert on max int128", async () => { const forGas = ether("10"); const bigBalance = MAX_INT128 + forGas; @@ -279,6 +357,140 @@ describe("StakingVault.sol", () => { }); }); + context("lock", () => { + it("reverts if the caller is not the vault hub", async () => { + await expect(stakingVault.connect(vaultOwner).lock(ether("1"))) + .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") + .withArgs("lock", vaultOwnerAddress); + }); + + it("updates the locked amount and emits the Locked event", async () => { + await expect(stakingVault.connect(vaultHubSigner).lock(ether("1"))) + .to.emit(stakingVault, "LockedIncreased") + .withArgs(ether("1")); + expect(await stakingVault.locked()).to.equal(ether("1")); + }); + + it("reverts if the new locked amount is less than the current locked amount", async () => { + await stakingVault.connect(vaultHubSigner).lock(ether("2")); + await expect(stakingVault.connect(vaultHubSigner).lock(ether("1"))) + .to.be.revertedWithCustomError(stakingVault, "LockedCannotDecreaseOutsideOfReport") + .withArgs(ether("2"), ether("1")); + }); + + it("does not revert if the new locked amount is equal to the current locked amount", async () => { + await stakingVault.connect(vaultHubSigner).lock(ether("1")); + await expect(stakingVault.connect(vaultHubSigner).lock(ether("2"))) + .to.emit(stakingVault, "LockedIncreased") + .withArgs(ether("2")); + }); + + it("does not revert if the locked amount is max uint128", async () => { + await expect(stakingVault.connect(vaultHubSigner).lock(MAX_UINT128)) + .to.emit(stakingVault, "LockedIncreased") + .withArgs(MAX_UINT128); + }); + }); + + context("rebalance", () => { + it("reverts if the amount is zero", async () => { + await expect(stakingVault.rebalance(0n)) + .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") + .withArgs("_ether"); + }); + + it("reverts if the amount is greater than the vault's balance", async () => { + expect(await ethers.provider.getBalance(stakingVaultAddress)).to.equal(0n); + await expect(stakingVault.rebalance(1n)) + .to.be.revertedWithCustomError(stakingVault, "InsufficientBalance") + .withArgs(0n); + }); + + it("reverts if the rebalance amount exceeds the valuation", async () => { + await stranger.sendTransaction({ to: stakingVaultAddress, value: ether("1") }); + expect(await stakingVault.valuation()).to.equal(ether("0")); + + await expect(stakingVault.rebalance(ether("1"))) + .to.be.revertedWithCustomError(stakingVault, "RebalanceAmountExceedsValuation") + .withArgs(ether("0"), ether("1")); + }); + + it("reverts if the caller is not the owner or the vault hub", async () => { + await stakingVault.fund({ value: ether("2") }); + + await expect(stakingVault.connect(stranger).rebalance(ether("1"))) + .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") + .withArgs("rebalance", stranger); + }); + + 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 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")); + await elRewardsSender.sendTransaction({ to: stakingVaultAddress, value: ether("0.1") }); + + await expect(stakingVault.connect(vaultHubSigner).rebalance(ether("0.1"))) + .to.emit(stakingVault, "Withdrawn") + .withArgs(vaultHubAddress, vaultHubAddress, ether("0.1")) + .to.emit(vaultHub, "Mock__Rebalanced") + .withArgs(stakingVaultAddress, ether("0.1")); + expect(await stakingVault.inOutDelta()).to.equal(-ether("0.1")); + }); + }); + + context("report", () => { + it("reverts if the caller is not the vault hub", async () => { + await expect(stakingVault.connect(stranger).report(ether("1"), ether("2"), ether("3"))) + .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") + .withArgs("report", stranger); + }); + + it("updates the state and emits the Reported event", async () => { + 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("setDepositGuardian", () => { + // TODO: + }); + + 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()) @@ -348,7 +560,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 @@ -356,7 +568,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 () => { @@ -370,7 +582,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); @@ -385,169 +597,324 @@ describe("StakingVault.sol", () => { .to.emit(stakingVault, "DepositedToBeaconChain") .withArgs(operator, 1, amount); }); + + it("makes multiple deposits to the beacon chain and emits the `DepositedToBeaconChain` event", async () => { + const numberOfKeys = 300; // 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, numberOfKeys, totalAmount); + }); + }); + + 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("works with max uint256", async () => { + const fee = BigInt(await withdrawalRequest.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.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", () => { 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()); + .withArgs(stranger); }); - it("emits the ValidatorsExitRequest event", async () => { - const pubkey = "0x" + "ab".repeat(48); - await expect(stakingVault.requestValidatorExit(pubkey)) - .to.emit(stakingVault, "ValidatorsExitRequest") - .withArgs(vaultOwnerAddress, pubkey); + it("reverts if the number of validators is zero", async () => { + await expect(stakingVault.connect(vaultOwner).requestValidatorExit("0x")) + .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") + .withArgs("_pubkeys"); }); - }); - context("lock", () => { - it("reverts if the caller is not the vault hub", async () => { - await expect(stakingVault.connect(vaultOwner).lock(ether("1"))) - .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") - .withArgs("lock", vaultOwnerAddress); + it("reverts if the length of the pubkeys is not a multiple of 48", async () => { + await expect( + stakingVault.connect(vaultOwner).requestValidatorExit("0x" + "ab".repeat(47)), + ).to.be.revertedWithCustomError(stakingVault, "InvalidPubkeysLength"); }); - it("updates the locked amount and emits the Locked event", async () => { - await expect(stakingVault.connect(vaultHubSigner).lock(ether("1"))) - .to.emit(stakingVault, "LockedIncreased") - .withArgs(ether("1")); - expect(await stakingVault.locked()).to.equal(ether("1")); + 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, SAMPLE_PUBKEY); }); - it("reverts if the new locked amount is less than the current locked amount", async () => { - await stakingVault.connect(vaultHubSigner).lock(ether("2")); - await expect(stakingVault.connect(vaultHubSigner).lock(ether("1"))) - .to.be.revertedWithCustomError(stakingVault, "LockedCannotDecreaseOutsideOfReport") - .withArgs(ether("2"), ether("1")); + 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).requestValidatorExit(keys.stringified); + await expect(tx.wait()) + .to.emit(stakingVault, "ValidatorExitRequested") + .withArgs(vaultOwner, keys.pubkeys[0], keys.pubkeys[0]) + .and.emit(stakingVault, "ValidatorExitRequested") + .withArgs(vaultOwner, keys.pubkeys[1], keys.pubkeys[1]); + + const receipt = (await tx.wait()) as ContractTransactionReceipt; + expect(receipt.logs.length).to.equal(numberOfKeys); }); + }); - it("does not revert if the new locked amount is equal to the current locked amount", async () => { - await stakingVault.connect(vaultHubSigner).lock(ether("1")); - await expect(stakingVault.connect(vaultHubSigner).lock(ether("2"))) - .to.emit(stakingVault, "LockedIncreased") - .withArgs(ether("2")); + context("triggerValidatorWithdrawal", () => { + let baseFee: bigint; + + before(async () => { + baseFee = BigInt(await withdrawalRequest.fee()); }); - it("does not revert if the locked amount is max uint128", async () => { - await expect(stakingVault.connect(vaultHubSigner).lock(MAX_UINT128)) - .to.emit(stakingVault, "LockedIncreased") - .withArgs(MAX_UINT128); + it("reverts if msg.value is zero", async () => { + await expect(stakingVault.connect(vaultOwner).triggerValidatorWithdrawal("0x", [], vaultOwnerAddress)) + .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") + .withArgs("msg.value"); }); - }); - context("rebalance", () => { - it("reverts if the amount is zero", async () => { - await expect(stakingVault.rebalance(0n)) + it("reverts if the number of validators is zero", async () => { + await expect( + stakingVault.connect(vaultOwner).triggerValidatorWithdrawal("0x", [], vaultOwnerAddress, { value: 1n }), + ) .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") - .withArgs("_ether"); + .withArgs("_pubkeys"); }); - it("reverts if the amount is greater than the vault's balance", async () => { - expect(await ethers.provider.getBalance(stakingVaultAddress)).to.equal(0n); - await expect(stakingVault.rebalance(1n)) - .to.be.revertedWithCustomError(stakingVault, "InsufficientBalance") - .withArgs(0n); + it("reverts if the amounts array is empty", async () => { + await expect( + stakingVault + .connect(vaultOwner) + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [], vaultOwnerAddress, { value: 1n }), + ) + .to.be.revertedWithCustomError(stakingVault, "ZeroArgument") + .withArgs("_amounts"); }); - it("reverts if the rebalance amount exceeds the valuation", async () => { - await stranger.sendTransaction({ to: stakingVaultAddress, value: ether("1") }); - expect(await stakingVault.valuation()).to.equal(ether("0")); + it("reverts if called by a non-owner or the node operator", async () => { + await expect( + stakingVault + .connect(stranger) + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [ether("1")], vaultOwnerAddress, { value: 1n }), + ) + .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") + .withArgs("triggerValidatorWithdrawal", stranger); + }); - await expect(stakingVault.rebalance(ether("1"))) - .to.be.revertedWithCustomError(stakingVault, "RebalanceAmountExceedsValuation") - .withArgs(ether("0"), ether("1")); + it("reverts if called by the vault hub on a healthy vault", async () => { + await expect( + stakingVault + .connect(vaultHubSigner) + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [ether("1")], vaultOwnerAddress, { value: 1n }), + ) + .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") + .withArgs("triggerValidatorWithdrawal", vaultHubAddress); }); - it("reverts if the caller is not the owner or the vault hub", async () => { - await stakingVault.fund({ value: ether("2") }); + 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"); + }); - await expect(stakingVault.connect(stranger).rebalance(ether("1"))) - .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") - .withArgs("rebalance", stranger); + 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) + .triggerValidatorWithdrawal(pubkeys.stringified, amounts, vaultOwnerAddress, { value }), + ) + .to.be.revertedWithCustomError(stakingVault, "InsufficientValidatorWithdrawalFee") + .withArgs(value, baseFee * BigInt(numberOfKeys)); }); - 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("reverts if the refund fails", async () => { + const numberOfKeys = 1; + const overpaid = 100n; + const pubkeys = getPubkeys(numberOfKeys); + const value = baseFee * BigInt(numberOfKeys) + overpaid; + + await expect( + stakingVault + .connect(vaultOwner) + .triggerValidatorWithdrawal(pubkeys.stringified, [ether("1")], ethRejectorAddress, { value }), + ) + .to.be.revertedWithCustomError(stakingVault, "WithdrawalFeeRefundFailed") + .withArgs(ethRejectorAddress, overpaid); }); - 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.inOutDelta()).to.equal(ether("0")); - await elRewardsSender.sendTransaction({ to: stakingVaultAddress, value: ether("0.1") }); + 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(vaultHubSigner).rebalance(ether("0.1"))) - .to.emit(stakingVault, "Withdrawn") - .withArgs(vaultHubAddress, vaultHubAddress, ether("0.1")) - .to.emit(vaultHub, "Mock__Rebalanced") - .withArgs(stakingVaultAddress, ether("0.1")); - expect(await stakingVault.inOutDelta()).to.equal(-ether("0.1")); + await expect( + stakingVault + .connect(vaultOwner) + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [ether("1")], vaultOwnerAddress, { value: 1n }), + ).to.be.revertedWithCustomError(stakingVault, "PartialWithdrawalNotAllowed"); }); - }); - context("report", () => { - it("reverts if the caller is not the vault hub", async () => { - await expect(stakingVault.connect(stranger).report(ether("1"), ether("2"), ether("3"))) - .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") - .withArgs("report", stranger); + it("requests a validator withdrawal when called by the owner", async () => { + const value = baseFee; + + await expect( + stakingVault.connect(vaultOwner).triggerValidatorWithdrawal(SAMPLE_PUBKEY, [0n], vaultOwnerAddress, { value }), + ) + .to.emit(withdrawalRequest, "eip7002MockRequestAdded") + .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, 0n), baseFee) + .to.emit(stakingVault, "ValidatorWithdrawalTriggered") + .withArgs(vaultOwner, SAMPLE_PUBKEY, [0n], vaultOwnerAddress, 0n); }); - it("updates the state and emits the Reported event", async () => { - 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")); + it("requests a validator withdrawal when called by the node operator", async () => { + await expect( + stakingVault + .connect(operator) + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [0n], vaultOwnerAddress, { value: baseFee }), + ) + .to.emit(withdrawalRequest, "eip7002MockRequestAdded") + .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, 0n), baseFee) + .to.emit(stakingVault, "ValidatorWithdrawalTriggered") + .withArgs(operator, SAMPLE_PUBKEY, [0n], vaultOwnerAddress, 0n); }); - }); - context("setDepositGuardian", () => { - // TODO: - }); + it("requests a full validator withdrawal", async () => { + await expect( + stakingVault + .connect(vaultOwner) + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [0n], vaultOwnerAddress, { value: baseFee }), + ) + .to.emit(withdrawalRequest, "eip7002MockRequestAdded") + .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, 0n), baseFee) + .to.emit(stakingVault, "ValidatorWithdrawalTriggered") + .withArgs(vaultOwner, SAMPLE_PUBKEY, [0n], vaultOwnerAddress, 0n); + }); + + it("requests a partial validator withdrawal", async () => { + const amount = ether("0.1"); + await expect( + stakingVault + .connect(vaultOwner) + .triggerValidatorWithdrawal(SAMPLE_PUBKEY, [amount], vaultOwnerAddress, { value: baseFee }), + ) + .to.emit(withdrawalRequest, "eip7002MockRequestAdded") + .withArgs(encodeEip7002Input(SAMPLE_PUBKEY, amount), baseFee) + .to.emit(stakingVault, "ValidatorWithdrawalTriggered") + .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, "ValidatorWithdrawalTriggered") + .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); - 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(), 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_]; - } + expect(ownerBalanceAfter).to.equal(ownerBalanceBefore - baseFee - gasFee); // overpaid is refunded back + }); + + it("requests a multiple validator withdrawals", async () => { + const numberOfKeys = 300; + 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) + .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, "ValidatorWithdrawalTriggered") + .withArgs(vaultOwner, pubkeys.stringified, amounts, vaultOwnerAddress, 0n); + }); + + 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; + + const strangerBalanceBefore = await ethers.provider.getBalance(stranger); + + await expect( + 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, "ValidatorWithdrawalTriggered") + .withArgs(vaultOwner, pubkeys.stringified, amounts, stranger, valueToRefund); + + const strangerBalanceAfter = await ethers.provider.getBalance(stranger); + expect(strangerBalanceAfter).to.equal(strangerBalanceBefore + valueToRefund); + }); + + 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) + .triggerValidatorWithdrawal(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 0b5a55ccd..08563e367 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, @@ -16,15 +15,16 @@ import { StETH__HarnessForVaultHub, UpgradeableBeacon, VaultFactory, + VaultHub, WETH9__MockForVault, WstETH__HarnessForVault, } 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"; +import { Snapshot, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP } from "test/suite"; describe("VaultFactory.sol", () => { let deployer: HardhatEthersSigner; @@ -38,8 +38,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,14 +76,19 @@ describe("VaultFactory.sol", () => { depositContract = await ethers.deployContract("DepositContract__MockForBeaconChainDepositor", deployer); // Accounting - accountingImpl = await ethers.deployContract("Accounting", [locator, steth]); - 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, + 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); //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, }); @@ -97,32 +102,36 @@ 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( + await expect(implOld.initialize(stranger, operator, depositContract, "0x")).to.revertedWithCustomError( implOld, "InvalidInitialization", ); delegationParams = { defaultAdmin: await admin.getAddress(), - funder: await vaultOwner1.getAddress(), - withdrawer: await vaultOwner1.getAddress(), - minter: await vaultOwner1.getAddress(), - burner: await vaultOwner1.getAddress(), - curator: await vaultOwner1.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(), + confirmExpiry: 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()], + validatorExitRequesters: [await vaultOwner1.getAddress()], + validatorWithdrawalTriggerers: [await vaultOwner1.getAddress()], + disconnecters: [await vaultOwner1.getAddress()], + assetRecoverer: await vaultOwner1.getAddress(), }; }); @@ -167,17 +176,7 @@ 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, - }); const { tx, vault, @@ -203,19 +202,19 @@ 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 = { shareLimit: 10n, minReserveRatioBP: 500n, - thresholdReserveRatioBP: 20n, + rebalanceThresholdBP: 20n, treasuryFeeBP: 500n, }; const config2 = { shareLimit: 20n, minReserveRatioBP: 200n, - thresholdReserveRatioBP: 20n, + rebalanceThresholdBP: 20n, treasuryFeeBP: 600n, }; @@ -237,34 +236,34 @@ 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(), config1.shareLimit, config1.minReserveRatioBP, - config1.thresholdReserveRatioBP, + 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(), config1.shareLimit, config1.minReserveRatioBP, - config1.thresholdReserveRatioBP, + config1.rebalanceThresholdBP, config1.treasuryFeeBP, ); - const vaultsAfter = await accounting.vaultsCount(); + const vaultsAfter = await vaultHub.vaultsCount(); expect(vaultsAfter).to.eq(1); const version1Before = await vault1.version(); @@ -284,16 +283,16 @@ describe("VaultFactory.sol", () => { //we upgrade implementation - we do not check implementation, just proxy bytecode await expect( - accounting + vaultHub .connect(admin) .connectVault( await vault2.getAddress(), config2.shareLimit, config2.minReserveRatioBP, - config2.thresholdReserveRatioBP, + 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); @@ -303,7 +302,7 @@ describe("VaultFactory.sol", () => { await vault1WithNewImpl.finalizeUpgrade_v2(); //try to initialize the second vault - await expect(vault2WithNewImpl.initialize(admin, operator, "0x")).to.revertedWithCustomError( + await expect(vault2WithNewImpl.initialize(admin, operator, depositContract, "0x")).to.revertedWithCustomError( vault2WithNewImpl, "VaultAlreadyInitialized", ); @@ -317,18 +316,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); }); }); @@ -340,7 +336,7 @@ describe("VaultFactory.sol", () => { const vault1WithNewImpl = await ethers.getContractAt("StakingVault__HarnessForTestUpgrade", vault1, deployer); - await expect(vault1.initialize(ZeroAddress, ZeroAddress, "0x")).to.revertedWithCustomError( + await expect(vault1.initialize(ZeroAddress, ZeroAddress, depositContract, "0x")).to.revertedWithCustomError( vault1WithNewImpl, "VaultAlreadyInitialized", ); @@ -354,7 +350,7 @@ describe("VaultFactory.sol", () => { const vault2WithNewImpl = await ethers.getContractAt("StakingVault__HarnessForTestUpgrade", vault2, deployer); - await expect(vault2.initialize(ZeroAddress, ZeroAddress, "0x")).to.revertedWithCustomError( + await expect(vault2.initialize(ZeroAddress, ZeroAddress, depositContract, "0x")).to.revertedWithCustomError( vault2WithNewImpl, "InvalidInitialization", ); diff --git a/test/0.8.25/vaults/vaultHub.test.ts b/test/0.8.25/vaults/vaultHub.test.ts new file mode 100644 index 000000000..06a0a0c51 --- /dev/null +++ b/test/0.8.25/vaults/vaultHub.test.ts @@ -0,0 +1,68 @@ +import { expect } from "chai"; +import { ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { OssifiableProxy, StETH__Harness, VaultHub } from "typechain-types"; + +import { ether } from "lib"; + +import { Snapshot, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP } from "test/suite"; + +describe("VaultHub.sol", () => { + let admin: HardhatEthersSigner; + let user: HardhatEthersSigner; + let holder: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + + let proxy: OssifiableProxy; + let vaultHubImpl: VaultHub; + let steth: StETH__Harness; + let vaultHub: VaultHub; + + let originalState: string; + + before(async () => { + [admin, user, holder, stranger] = await ethers.getSigners(); + + steth = await ethers.deployContract("StETH__Harness", [holder], { value: ether("10.0") }); + + // VaultHub + 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, user); + }); + + beforeEach(async () => (originalState = await Snapshot.take())); + + afterEach(async () => await Snapshot.restore(originalState)); + + context("constructor", () => { + it("reverts on impl initialization", async () => { + await expect(vaultHubImpl.initialize(stranger)).to.be.revertedWithCustomError( + vaultHubImpl, + "InvalidInitialization", + ); + }); + it("reverts on `_admin` address is zero", async () => { + await expect(vaultHub.initialize(ZeroAddress)) + .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") + .withArgs("_admin"); + }); + it("initialization happy path", async () => { + const tx = await vaultHub.initialize(admin); + + expect(await vaultHub.vaultsCount()).to.eq(0); + + await expect(tx).to.be.emit(vaultHub, "Initialized").withArgs(1); + }); + }); +}); 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..09191527c --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/contracts/StakingVault__MockForVaultHub.sol @@ -0,0 +1,82 @@ +// 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 { + 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/VaultFactory__MockForStakingVault.sol b/test/0.8.25/vaults/vaulthub/contracts/VaultFactory__MockForStakingVault.sol new file mode 100644 index 000000000..32dec416f --- /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, address _depositGuardian) external { + IStakingVault vault = IStakingVault(address(new BeaconProxy(address(this), ""))); + vault.initialize(_owner, _operator, _depositGuardian, ""); + + emit VaultCreated(address(vault)); + } +} 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..b5152a167 --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/contracts/VaultHub__Harness.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity ^0.8.0; + +import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; + +contract VaultHub__Harness is VaultHub { + constructor( + address _locator, + uint256 _connectedVaultsLimit, + uint256 _relativeShareLimitBP + ) VaultHub(ILidoLocator(_locator), address(0), _connectedVaultsLimit, _relativeShareLimitBP) {} + + 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.forceExit.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts new file mode 100644 index 000000000..a7771e14e --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts @@ -0,0 +1,230 @@ +import { expect } from "chai"; +import { ContractTransactionReceipt, keccak256, ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { + DepositContract__MockForVaultHub, + StakingVault__MockForVaultHub, + StETH__HarnessForVaultHub, + VaultFactory__MockForVaultHub, + VaultHub__Harness, +} from "typechain-types"; + +import { impersonate } from "lib"; +import { findEvents } from "lib/event"; +import { ether } from "lib/units"; + +import { Snapshot, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP } 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; + +const FEE = 2n; + +describe("VaultHub.sol:forceExit", () => { + let deployer: HardhatEthersSigner; + let user: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + let feeRecipient: HardhatEthersSigner; + + let vaultHub: VaultHub__Harness; + let vaultFactory: VaultFactory__MockForVaultHub; + let vault: StakingVault__MockForVaultHub; + let steth: StETH__HarnessForVaultHub; + let depositContract: DepositContract__MockForVaultHub; + + let vaultAddress: string; + let vaultHubAddress: string; + + let vaultHubSigner: HardhatEthersSigner; + + let originalState: string; + + before(async () => { + [deployer, user, stranger, feeRecipient] = await ethers.getSigners(); + + steth = await ethers.deployContract("StETH__HarnessForVaultHub", [user], { value: ether("10000.0") }); + depositContract = await ethers.deployContract("DepositContract__MockForVaultHub"); + + const vaultHubImpl = await ethers.deployContract("VaultHub__Harness", [ + steth, + VAULTS_CONNECTED_VAULTS_LIMIT, + VAULTS_RELATIVE_SHARE_LIMIT_BP, + ]); + + const proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()]); + + 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 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(), + await depositContract.getAddress(), + ]); + + vaultFactory = await ethers.deployContract("VaultFactory__MockForVaultHub", [await stakingVaultImpl.getAddress()]); + + const vaultCreationTx = (await vaultFactory + .createVault(user, user) + .then((tx) => tx.wait())) as ContractTransactionReceipt; + + const events = findEvents(vaultCreationTx, "VaultCreated"); + const vaultCreatedEvent = events[0]; + + vault = await ethers.getContractAt("StakingVault__MockForVaultHub", 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); + + vaultHubSigner = await impersonate(vaultHubAddress, ether("100")); + }); + + beforeEach(async () => (originalState = await Snapshot.take())); + + afterEach(async () => await Snapshot.restore(originalState)); + + // Simulate getting in the unhealthy state + const makeVaultUnhealthy = async () => { + await vault.fund({ value: ether("1") }); + await vaultHub.mintShares(vaultAddress, user, ether("0.9")); + await vault.connect(vaultHubSigner).report(ether("0.9"), ether("1"), ether("1.1")); // slashing + }; + + context("forceValidatorExit", () => { + it("reverts if msg.value is 0", async () => { + 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.forceValidatorExit(ZeroAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) + .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") + .withArgs("_vault"); + }); + + it("reverts if zero pubkeys", async () => { + 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.forceValidatorExit(vaultAddress, SAMPLE_PUBKEY, ZeroAddress, { value: 1n })) + .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") + .withArgs("_refundRecipient"); + }); + + it("reverts if pubkeys are not valid", async () => { + await expect( + 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.forceValidatorExit(stranger, SAMPLE_PUBKEY, feeRecipient, { 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.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.forceValidatorExit(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: 1n })) + .to.be.revertedWithCustomError(vaultHub, "AlreadyHealthy") + .withArgs(vaultAddress); + }); + + context("unhealthy vault", () => { + beforeEach(async () => await makeVaultUnhealthy()); + + it("initiates force validator withdrawal", async () => { + await expect(vaultHub.forceValidatorExit(vaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: FEE })) + .to.emit(vaultHub, "ForceValidatorExitTriggered") + .withArgs(vaultAddress, SAMPLE_PUBKEY, feeRecipient); + }); + + it("initiates force validator withdrawal with multiple pubkeys", async () => { + const numPubkeys = 3; + const pubkeys = "0x" + "ab".repeat(numPubkeys * 48); + + await expect( + vaultHub.forceValidatorExit(vaultAddress, pubkeys, feeRecipient, { value: FEE * BigInt(numPubkeys) }), + ) + .to.emit(vaultHub, "ForceValidatorExitTriggered") + .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_00n)) / TOTAL_BASIS_POINTS); + + await vaultHub.connectVault(demoVaultAddress, cap, 20_00n, 20_00n, 5_00n); + await vaultHub.mintShares(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.isVaultHealthy(demoVaultAddress)).to.be.false; + + await expect(vaultHub.forceValidatorExit(demoVaultAddress, SAMPLE_PUBKEY, feeRecipient, { value: FEE })) + .to.emit(vaultHub, "ForceValidatorExitTriggered") + .withArgs(demoVaultAddress, SAMPLE_PUBKEY, feeRecipient); + }); + }); +}); 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..415b15417 --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts @@ -0,0 +1,774 @@ +import { expect } from "chai"; +import { ContractTransactionReceipt, keccak256, ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { + ACL, + DepositContract__MockForVaultHub, + Lido, + LidoLocator, + StakingVault__MockForVaultHub, + VaultFactory__MockForVaultHub, + VaultHub, +} from "typechain-types"; + +import { BigIntMath, ether, findEvents, impersonate, randomAddress } from "lib"; + +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"); + +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"); + +const VAULTS_CONNECTED_VAULTS_LIMIT = 5; // Low limit to test the overflow + +describe("VaultHub.sol:hub", () => { + let deployer: HardhatEthersSigner; + let user: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + let whale: HardhatEthersSigner; + + let locator: LidoLocator; + let vaultHub: VaultHub; + let depositContract: DepositContract__MockForVaultHub; + let vaultFactory: VaultFactory__MockForVaultHub; + let lido: Lido; + let acl: ACL; + + 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 createAndConnectVault( + factory: VaultFactory__MockForVaultHub, + options?: { + shareLimit?: bigint; + reserveRatioBP?: bigint; + rebalanceThresholdBP?: bigint; + treasuryFeeBP?: bigint; + }, + ) { + const vault = await createVault(factory); + + await vaultHub + .connect(user) + .connectVault( + await vault.getAddress(), + options?.shareLimit ?? SHARE_LIMIT, + options?.reserveRatioBP ?? RESERVE_RATIO_BP, + options?.rebalanceThresholdBP ?? RESERVE_RATIO_THRESHOLD_BP, + options?.treasuryFeeBP ?? TREASURY_FEE_BP, + ); + + return vault; + } + + before(async () => { + [deployer, user, stranger, whale] = await ethers.getSigners(); + + ({ 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.connect(whale).submit(deployer, { value: ether("1000.0") }); + + depositContract = await ethers.deployContract("DepositContract__MockForVaultHub"); + + 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 vaultHubAdmin = await ethers.getContractAt("VaultHub", proxy); + await vaultHubAdmin.initialize(deployer); + + 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(), { vaultHub }); + + 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 lido.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 createAndConnectVault(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 createAndConnectVault(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 createAndConnectVault(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.rebalanceThresholdBP).to.equal(RESERVE_RATIO_THRESHOLD_BP); + expect(lastVaultSocket.treasuryFeeBP).to.equal(TREASURY_FEE_BP); + expect(lastVaultSocket.pendingDisconnect).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.rebalanceThresholdBP).to.equal(0n); + expect(vaultSocket.treasuryFeeBP).to.equal(0n); + expect(vaultSocket.pendingDisconnect).to.equal(false); + }); + + it("returns the vault socket for a vault that was connected", async () => { + const vault = await createAndConnectVault(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.rebalanceThresholdBP).to.equal(RESERVE_RATIO_THRESHOLD_BP); + expect(vaultSocket.treasuryFeeBP).to.equal(TREASURY_FEE_BP); + expect(vaultSocket.pendingDisconnect).to.equal(false); + }); + }); + + context("isVaultHealthy", () => { + it("reverts if vault is not connected", async () => { + await expect(vaultHub.isVaultHealthy(randomAddress())).to.be.revertedWithCustomError( + vaultHub, + "NotConnectedToHub", + ); + }); + + 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.isVaultHealthy(vaultAddress)).to.equal(true); + }); + + // 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 rebalanceThresholdBP = tbi(10000); + const reserveRatioBP = BigIntMath.min(rebalanceThresholdBP + 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 - 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, + rebalanceThresholdBP: rebalanceThresholdBP, + }); + + 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.isVaultHealthy(vaultAddress); + try { + expect(actualHealthy).to.equal(expectedHealthy); + } catch (error) { + console.log(`Test failed with parameters: + Rebalance Threshold: ${rebalanceThresholdBP} + Reserve Ratio: ${reserveRatioBP} + Valuation: ${valuation} ETH + Minted: ${mintable} stETH + Slashed: ${slashed} ETH + Threshold: ${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% + rebalanceThresholdBP: 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.isVaultHealthy(vaultAddress)).to.equal(true); + + await vault.report(ether("0.5") + 1n, ether("1"), ether("1")); // above the threshold + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true); + + await vault.report(ether("0.5"), ether("1"), ether("1")); // at the threshold + 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.isVaultHealthy(vaultAddress)).to.equal(false); + }); + + it("returns correct value for different share rates", async () => { + const vault = await createAndConnectVault(vaultFactory, { + shareLimit: ether("100"), // just to bypass the share limit check + reserveRatioBP: 50_00n, // 50% + rebalanceThresholdBP: 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); + + await vault.report(ether("1"), ether("1"), ether("1")); // normal report + 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")); + 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.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.isVaultHealthy(vaultAddress)).to.equal(false); + + await vault.report(report, ether("1"), ether("1")); // at the threshold + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true); + + await vault.report(report + 1n, ether("1"), ether("1")); // above the threshold + expect(await vaultHub.isVaultHealthy(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% + rebalanceThresholdBP: 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.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")); + 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.isVaultHealthy(vaultAddress)).to.equal(false); + + await vault.report(report, ether("1"), ether("1")); // at the threshold + 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.isVaultHealthy(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% + rebalanceThresholdBP: 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.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.isVaultHealthy(vaultAddress)).to.equal(true); + + 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.vaultHub(), 1n); + await vaultHub.connect(user).burnShares(vaultAddress, 1n); + + expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true); // Should be healthy with no shares + }); + }); + + 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 ratio 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 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 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, "RebalanceThresholdTooHigh") + .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 () => { + const vaultsCount = await vaultHub.vaultsCount(); + for (let i = vaultsCount; i < VAULTS_CONNECTED_VAULTS_LIMIT; i++) { + await createAndConnectVault(vaultFactory); + } + + 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 createAndConnectVault(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.pendingDisconnect).to.be.false; + + 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.pendingDisconnect).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 createAndConnectVault(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 lido.getTotalShares(); + const relativeShareLimitBP = VAULTS_RELATIVE_SHARE_LIMIT_BP; + const relativeShareLimitPerVault = (totalShares * relativeShareLimitBP) / TOTAL_BASIS_POINTS; + + await expect(vaultHub.connect(user).updateShareLimit(vaultAddress, insaneLimit)) + .to.be.revertedWithCustomError(vaultHub, "ShareLimitTooHigh") + .withArgs(vaultAddress, insaneLimit, relativeShareLimitPerVault); + }); + + 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 createAndConnectVault(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).mintShares(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.pendingDisconnect).to.be.true; + }); + }); + + context("voluntaryDisconnect", () => { + let vault: StakingVault__MockForVaultHub; + let vaultAddress: string; + + before(async () => { + vault = await createAndConnectVault(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).mintShares(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.pendingDisconnect).to.be.true; + }); + }); +}); 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 e442c775e..aac73c0d0 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"; @@ -9,13 +10,14 @@ 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; let user: HardhatEthersSigner; let stranger: HardhatEthersSigner; + let vaultHubAdmin: VaultHub; let vaultHub: VaultHub; let steth: StETH__HarnessForVaultHub; @@ -27,15 +29,20 @@ 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]); + const vaultHubImpl = await ethers.deployContract("VaultHub", [ + locator, + 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); + 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())); @@ -157,28 +164,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", ); diff --git a/test/0.8.9/accounting.handleOracleReport.test.ts b/test/0.8.9/accounting.handleOracleReport.test.ts index 0e6d23ba0..3f02b9688 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; @@ -72,7 +72,20 @@ 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, accounting, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP], + 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 f1eeed6ba..2c4dc041a 100644 --- a/test/0.8.9/contracts/LidoLocator__MockForSanityChecker.sol +++ b/test/0.8.9/contracts/LidoLocator__MockForSanityChecker.sol @@ -25,6 +25,7 @@ contract LidoLocator__MockForSanityChecker is ILidoLocator { address accounting; address predepositGuarantee; address wstETH; + address vaultHub; } address public immutable lido; @@ -44,7 +45,7 @@ contract LidoLocator__MockForSanityChecker is ILidoLocator { address public immutable accounting; address public immutable predepositGuarantee; address public immutable wstETH; - + address public immutable vaultHub; constructor(ContractAddresses memory addresses) { lido = addresses.lido; depositSecurityModule = addresses.depositSecurityModule; @@ -63,20 +64,26 @@ contract LidoLocator__MockForSanityChecker is ILidoLocator { accounting = addresses.accounting; wstETH = addresses.wstETH; predepositGuarantee = addresses.predepositGuarantee; + 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 e4656ea12..85b71e2fe 100644 --- a/test/0.8.9/contracts/LidoLocator__MockMutable.sol +++ b/test/0.8.9/contracts/LidoLocator__MockMutable.sol @@ -24,6 +24,7 @@ contract LidoLocator__MockMutable is ILidoLocator { address accounting; address predepositGuarantee; address wstETH; + address vaultHub; } error ZeroAddress(); @@ -45,7 +46,7 @@ contract LidoLocator__MockMutable is ILidoLocator { address public immutable accounting; address public immutable predepositGuarantee; address public immutable wstETH; - + address public immutable vaultHub; /** * @notice declare service locations * @dev accepts a struct to avoid the "stack-too-deep" error @@ -69,20 +70,26 @@ contract LidoLocator__MockMutable is ILidoLocator { accounting = _assertNonZero(_config.accounting); wstETH = _assertNonZero(_config.wstETH); predepositGuarantee = _assertNonZero(_config.predepositGuarantee); + 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/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/contracts/WithdrawalVault__Harness.sol b/test/0.8.9/contracts/WithdrawalVault__Harness.sol new file mode 100644 index 000000000..c2fc2a46e --- /dev/null +++ b/test/0.8.9/contracts/WithdrawalVault__Harness.sol @@ -0,0 +1,14 @@ +// 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/lidoLocator.test.ts b/test/0.8.9/lidoLocator.test.ts index 72a2347e3..3300358d8 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", @@ -22,8 +22,10 @@ const services = [ "oracleDaemonConfig", "accounting", "wstETH", + "vaultHub", ] as const; +type ArrayToUnion = A[number]; type Service = ArrayToUnion; type Config = Record & { postTokenRebaseReceiver: string; // can be ZeroAddress @@ -91,6 +93,7 @@ describe("LidoLocator.sol", () => { withdrawalQueue, postTokenRebaseReceiver, stakingRouter, + vaultHub, } = config; expect(await locator.oracleReportComponents()).to.deep.equal([ @@ -100,6 +103,7 @@ describe("LidoLocator.sol", () => { withdrawalQueue, postTokenRebaseReceiver, stakingRouter, + vaultHub, ]); }); }); 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..93d8ae4a2 --- /dev/null +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.accessControl.test.ts @@ -0,0 +1,201 @@ +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 } from "typechain-types"; + +import { CONSENSUS_VERSION, de0x, numberToHex } 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(""); + }; + + before(async () => { + [admin, member1, member2, member3, stranger, account1] = await ethers.getSigners(); + + 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); + }); + + 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/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..6cd0985c7 --- /dev/null +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.gas.test.ts @@ -0,0 +1,244 @@ +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 { 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 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(); + + 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 () => { + 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 tx.wait()) as ContractTransactionReceipt; + await expect(tx).to.emit(oracle, "ProcessingStarted").withArgs(reportFields.refSlot, reportHash); + expect((await oracle.getConsensusReport()).processingStarted).to.equal(true); + + const timestamp = await oracle.getTime(); + + 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, + 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..74f411f6c --- /dev/null +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.happyPath.test.ts @@ -0,0 +1,252 @@ +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(""); + }; + + before(async () => { + [admin, member1, member2, member3, stranger] = await ethers.getSigners(); + + 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); + }); + + 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..da220cfc9 --- /dev/null +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.submitReportData.test.ts @@ -0,0 +1,693 @@ +import { expect } from "chai"; +import { 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 } 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; + 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 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, reportItems }; + }; + + async function getLastRequestedValidatorIndex(moduleId: number, nodeOpId: number) { + return (await oracle.getLastRequestedValidatorIndices(moduleId, [nodeOpId]))[0]; + } + + before(async () => { + [admin, member1, member2, member3, stranger] = await ethers.getSigners(); + + 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); + }); + + 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", () => { + 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 () => { + await 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, + ]); + }); + }); +}); 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/0.8.9/withdrawalVault.test.ts b/test/0.8.9/withdrawalVault.test.ts index c953f23d7..a584e896f 100644 --- a/test/0.8.9/withdrawalVault.test.ts +++ b/test/0.8.9/withdrawalVault.test.ts @@ -5,38 +5,60 @@ 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 { + EIP7002WithdrawalRequest_Mock, + ERC20__Harness, + ERC721__Harness, + Lido__MockForWithdrawalVault, + RefundFailureTester, + WithdrawalVault__Harness, +} from "typechain-types"; -import { MAX_UINT256, proxify } from "lib"; +import { MAX_UINT256, proxify, streccak } from "lib"; import { Snapshot } from "test/suite"; +import { findEip7002MockEvents, testEip7002Mock } from "../common/lib/triggerableWithdrawals/eip7002Mock"; +import { + deployWithdrawalsPredeployedMock, + generateWithdrawalRequestPayload, + withdrawalsPredeployedHardcodedAddress, +} from "../common/lib/triggerableWithdrawals/utils"; + const PETRIFIED_VERSION = MAX_UINT256; +const ADD_FULL_WITHDRAWAL_REQUEST_ROLE = streccak("ADD_FULL_WITHDRAWAL_REQUEST_ROLE"); + describe("WithdrawalVault.sol", () => { let owner: HardhatEthersSigner; - let user: HardhatEthersSigner; let treasury: HardhatEthersSigner; + let validatorsExitBus: HardhatEthersSigner; + let stranger: HardhatEthersSigner; let originalState: string; let lido: Lido__MockForWithdrawalVault; let lidoAddress: string; - let impl: WithdrawalVault; - let vault: WithdrawalVault; + let withdrawalsPredeployed: EIP7002WithdrawalRequest_Mock; + + let impl: WithdrawalVault__Harness; + let vault: WithdrawalVault__Harness; let vaultAddress: string; before(async () => { - [owner, user, treasury] = await ethers.getSigners(); + [owner, treasury, validatorsExitBus, stranger] = await ethers.getSigners(); + + withdrawalsPredeployed = await deployWithdrawalsPredeployedMock(1n); + + expect(await withdrawalsPredeployed.getAddress()).to.equal(withdrawalsPredeployedHardcodedAddress); lido = await ethers.deployContract("Lido__MockForWithdrawalVault"); lidoAddress = await lido.getAddress(); - impl = await ethers.deployContract("WithdrawalVault", [lidoAddress, treasury.address]); + impl = await ethers.deployContract("WithdrawalVault__Harness", [lidoAddress, treasury.address], owner); [vault] = await proxify({ impl, admin: owner }); - vaultAddress = await vault.getAddress(); }); @@ -48,13 +70,13 @@ describe("WithdrawalVault.sol", () => { it("Reverts if the Lido address is zero", async () => { await expect( ethers.deployContract("WithdrawalVault", [ZeroAddress, treasury.address]), - ).to.be.revertedWithCustomError(vault, "LidoZeroAddress"); + ).to.be.revertedWithCustomError(vault, "ZeroAddress"); }); it("Reverts if the treasury address is zero", async () => { await expect(ethers.deployContract("WithdrawalVault", [lidoAddress, ZeroAddress])).to.be.revertedWithCustomError( vault, - "TreasuryZeroAddress", + "ZeroAddress", ); }); @@ -73,22 +95,102 @@ describe("WithdrawalVault.sol", () => { }); context("initialize", () => { - it("Reverts if the contract is already initialized", async () => { - await vault.initialize(); + it("Should revert if the contract is already initialized", async () => { + await vault.initialize(owner); - await expect(vault.initialize()).to.be.revertedWithCustomError(vault, "NonZeroContractVersionOnInit"); + await expect(vault.initialize(owner)) + .to.be.revertedWithCustomError(vault, "UnexpectedContractVersion") + .withArgs(2, 0); }); it("Initializes the contract", async () => { - await expect(vault.initialize()).to.emit(vault, "ContractVersionSet").withArgs(1); + await expect(vault.initialize(owner)).to.emit(vault, "ContractVersionSet").withArgs(2); + }); + + it("Should revert if admin address is zero", async () => { + await expect(vault.initialize(ZeroAddress)).to.be.revertedWithCustomError(vault, "ZeroAddress"); + }); + + it("Should set admin role during initialization", async () => { + const adminRole = await vault.DEFAULT_ADMIN_ROLE(); + expect(await vault.getRoleMemberCount(adminRole)).to.equal(0); + expect(await vault.hasRole(adminRole, owner)).to.equal(false); + + await vault.initialize(owner); + + expect(await vault.getRoleMemberCount(adminRole)).to.equal(1); + expect(await vault.hasRole(adminRole, owner)).to.equal(true); + expect(await vault.hasRole(adminRole, stranger)).to.equal(false); + }); + }); + + context("finalizeUpgrade_v2()", () => { + it("Should revert with UnexpectedContractVersion error when called on implementation", async () => { + await expect(impl.finalizeUpgrade_v2(owner)) + .to.be.revertedWithCustomError(impl, "UnexpectedContractVersion") + .withArgs(MAX_UINT256, 1); + }); + + it("Should revert with UnexpectedContractVersion error when called on deployed from scratch WithdrawalVaultV2", async () => { + await vault.initialize(owner); + + await expect(vault.finalizeUpgrade_v2(owner)) + .to.be.revertedWithCustomError(impl, "UnexpectedContractVersion") + .withArgs(2, 1); + }); + + context("Simulate upgrade from v1", () => { + beforeEach(async () => { + await vault.harness__initializeContractVersionTo(1); + }); + + it("Should revert if admin address is zero", async () => { + await expect(vault.finalizeUpgrade_v2(ZeroAddress)).to.be.revertedWithCustomError(vault, "ZeroAddress"); + }); + + it("Should set correct contract version", async () => { + expect(await vault.getContractVersion()).to.equal(1); + await vault.finalizeUpgrade_v2(owner); + expect(await vault.getContractVersion()).to.be.equal(2); + }); + + it("Should set admin role during finalization", async () => { + const adminRole = await vault.DEFAULT_ADMIN_ROLE(); + expect(await vault.getRoleMemberCount(adminRole)).to.equal(0); + expect(await vault.hasRole(adminRole, owner)).to.equal(false); + + await vault.finalizeUpgrade_v2(owner); + + expect(await vault.getRoleMemberCount(adminRole)).to.equal(1); + expect(await vault.hasRole(adminRole, owner)).to.equal(true); + expect(await vault.hasRole(adminRole, stranger)).to.equal(false); + }); + }); + }); + + context("Access control", () => { + it("Returns ACL roles", async () => { + expect(await vault.ADD_FULL_WITHDRAWAL_REQUEST_ROLE()).to.equal(ADD_FULL_WITHDRAWAL_REQUEST_ROLE); + }); + + it("Sets up roles", async () => { + await vault.initialize(owner); + + expect(await vault.getRoleMemberCount(ADD_FULL_WITHDRAWAL_REQUEST_ROLE)).to.equal(0); + expect(await vault.hasRole(ADD_FULL_WITHDRAWAL_REQUEST_ROLE, validatorsExitBus)).to.equal(false); + + await vault.connect(owner).grantRole(ADD_FULL_WITHDRAWAL_REQUEST_ROLE, validatorsExitBus); + + expect(await vault.getRoleMemberCount(ADD_FULL_WITHDRAWAL_REQUEST_ROLE)).to.equal(1); + expect(await vault.hasRole(ADD_FULL_WITHDRAWAL_REQUEST_ROLE, validatorsExitBus)).to.equal(true); }); }); context("withdrawWithdrawals", () => { - beforeEach(async () => await vault.initialize()); + beforeEach(async () => await vault.initialize(owner)); it("Reverts if the caller is not Lido", async () => { - await expect(vault.connect(user).withdrawWithdrawals(0)).to.be.revertedWithCustomError(vault, "NotLido"); + await expect(vault.connect(stranger).withdrawWithdrawals(0)).to.be.revertedWithCustomError(vault, "NotLido"); }); it("Reverts if amount is 0", async () => { @@ -168,4 +270,393 @@ describe("WithdrawalVault.sol", () => { expect(await token.ownerOf(1)).to.equal(treasury.address); }); }); + + context("get triggerable withdrawal request fee", () => { + it("Should get fee from the EIP 7002 contract", async function () { + await withdrawalsPredeployed.setFee(333n); + expect( + (await vault.getWithdrawalRequestFee()) == 333n, + "withdrawal request should use fee from the EIP 7002 contract", + ); + }); + + it("Should revert if fee read fails", async function () { + await withdrawalsPredeployed.setFailOnGetFee(true); + await expect(vault.getWithdrawalRequestFee()).to.be.revertedWithCustomError(vault, "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 { + const fee = await vault.getWithdrawalRequestFee(); + + return ethers.parseUnits(fee.toString(), "wei"); + } + + async function getWithdrawalCredentialsContractBalance(): Promise { + const contractAddress = await vault.getAddress(); + return await ethers.provider.getBalance(contractAddress); + } + + async function getWithdrawalsPredeployedContractBalance(): Promise { + const contractAddress = await withdrawalsPredeployed.getAddress(); + return await ethers.provider.getBalance(contractAddress); + } + + context("add triggerable withdrawal requests", () => { + beforeEach(async () => { + await vault.initialize(owner); + await vault.connect(owner).grantRole(ADD_FULL_WITHDRAWAL_REQUEST_ROLE, validatorsExitBus); + }); + + it("Should revert if the caller is not Validator Exit Bus", async () => { + await expect(vault.connect(stranger).addFullWithdrawalRequests("0x1234")).to.be.revertedWithOZAccessControlError( + stranger.address, + ADD_FULL_WITHDRAWAL_REQUEST_ROLE, + ); + }); + + it("Should revert if empty arrays are provided", async function () { + await expect( + vault.connect(validatorsExitBus).addFullWithdrawalRequests("0x", { value: 1n }), + ).to.be.revertedWithCustomError(vault, "NoWithdrawalRequests"); + }); + + it("Should revert if not enough fee is sent", async function () { + const { pubkeysHexString } = generateWithdrawalRequestPayload(1); + + await withdrawalsPredeployed.setFee(3n); // Set fee to 3 gwei + + // 1. Should revert if no fee is sent + await expect(vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString)) + .to.be.revertedWithCustomError(vault, "InsufficientTriggerableWithdrawalFee") + .withArgs(0, 3n, 1); + + // 2. Should revert if fee is less than required + const insufficientFee = 2n; + await expect( + vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: insufficientFee }), + ) + .to.be.revertedWithCustomError(vault, "InsufficientTriggerableWithdrawalFee") + .withArgs(2n, 3n, 1); + }); + + it("Should revert if pubkey is not 48 bytes", async function () { + // Invalid pubkey (only 2 bytes) + const invalidPubkeyHexString = "0x1234"; + + const fee = await getFee(); + + await expect( + vault.connect(validatorsExitBus).addFullWithdrawalRequests(invalidPubkeyHexString, { value: fee }), + ).to.be.revertedWithCustomError(vault, "MalformedPubkeysArray"); + }); + + 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, "MalformedPubkeysArray"); + }); + + it("Should revert if addition fails at the withdrawal request contract", async function () { + const { pubkeysHexString } = generateWithdrawalRequestPayload(1); + const fee = await getFee(); + + // Set mock to fail on add + await withdrawalsPredeployed.setFailOnAddRequest(true); + + await expect( + vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: fee }), + ).to.be.revertedWithCustomError(vault, "WithdrawalRequestAdditionFailed"); + }); + + it("Should revert when fee read fails", async function () { + await withdrawalsPredeployed.setFailOnGetFee(true); + + const { pubkeysHexString } = generateWithdrawalRequestPayload(2); + const fee = 10n; + + await expect( + vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: fee }), + ).to.be.revertedWithCustomError(vault, "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, + ]); + 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); + + const fee = 3n; + await withdrawalsPredeployed.setFee(3n); + const expectedTotalWithdrawalFee = 9n; + + await testEip7002Mock( + () => + vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: expectedTotalWithdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + + // Check extremely high fee + const highFee = ethers.parseEther("10"); + await withdrawalsPredeployed.setFee(highFee); + const expectedLargeTotalWithdrawalFee = ethers.parseEther("30"); + + await testEip7002Mock( + () => + vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: expectedLargeTotalWithdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + highFee, + ); + }); + + it("Should accept withdrawal requests when the provided fee exceeds the required amount", async function () { + const requestCount = 3; + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); + + const fee = 3n; + await withdrawalsPredeployed.setFee(fee); + const withdrawalFee = 9n + 1n; // 3 request * 3 gwei (fee) + 1 gwei (extra fee)= 10 gwei + + await testEip7002Mock( + () => vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: withdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + + // Check when the provided fee extremely exceeds the required amount + const largeWithdrawalFee = ethers.parseEther("10"); + + await testEip7002Mock( + () => + vault.connect(validatorsExitBus).addFullWithdrawalRequests(pubkeysHexString, { value: largeWithdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + }); + + it("Should not affect contract balance", async function () { + const requestCount = 3; + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); + + const fee = 3n; + await withdrawalsPredeployed.setFee(fee); + const expectedTotalWithdrawalFee = 9n; // 3 requests * 3 gwei (fee) = 9 gwei + + const initialBalance = await getWithdrawalCredentialsContractBalance(); + + await testEip7002Mock( + () => + vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: expectedTotalWithdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); + + const excessTotalWithdrawalFee = 9n + 1n; // 3 requests * 3 gwei (fee) + 1 gwei (extra fee) = 10 gwei + + await testEip7002Mock( + () => + vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: excessTotalWithdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance); + }); + + 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; + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); + + const fee = 3n; + await withdrawalsPredeployed.setFee(3n); + const expectedTotalWithdrawalFee = 9n; + const excessTotalWithdrawalFee = 9n + 1n; + + let initialBalance = await getWithdrawalsPredeployedContractBalance(); + + await testEip7002Mock( + () => + vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: expectedTotalWithdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + + expect(await getWithdrawalsPredeployedContractBalance()).to.equal(initialBalance + expectedTotalWithdrawalFee); + + initialBalance = await getWithdrawalsPredeployedContractBalance(); + await testEip7002Mock( + () => + vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: excessTotalWithdrawalFee }), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + // Only the expected fee should be transferred + expect(await getWithdrawalsPredeployedContractBalance()).to.equal(initialBalance + expectedTotalWithdrawalFee); + }); + + it("Should ensure withdrawal requests are encoded as expected with a 48-byte pubkey and 8-byte amount", async function () { + const requestCount = 16; + const { pubkeysHexString, pubkeys } = generateWithdrawalRequestPayload(requestCount); + const totalWithdrawalFee = 333n; + + const tx = await vault + .connect(validatorsExitBus) + .addFullWithdrawalRequests(pubkeysHexString, { value: totalWithdrawalFee }); + + const receipt = await tx.wait(); + + const events = findEip7002MockEvents(receipt!, "eip7002MockRequestAdded"); + expect(events.length).to.equal(requestCount); + + for (let i = 0; i < requestCount; i++) { + const encodedRequest = events[i].args[0]; + // 0x (2 characters) + 48-byte pubkey (96 characters) + 8-byte amount (16 characters) = 114 characters + expect(encodedRequest.length).to.equal(114); + + expect(encodedRequest.slice(0, 2)).to.equal("0x"); + expect(encodedRequest.slice(2, 98)).to.equal(pubkeys[i]); + expect(encodedRequest.slice(98, 114)).to.equal("0".repeat(16)); // Amount is 0 + } + }); + + const testCasesForWithdrawalRequests = [ + { requestCount: 1, extraFee: 0n }, + { requestCount: 1, extraFee: 100n }, + { requestCount: 1, extraFee: 100_000_000_000n }, + { requestCount: 3, extraFee: 0n }, + { requestCount: 3, extraFee: 1n }, + { requestCount: 7, extraFee: 3n }, + { requestCount: 10, extraFee: 0n }, + { requestCount: 10, extraFee: 100_000_000_000n }, + { requestCount: 100, extraFee: 0n }, + ]; + + testCasesForWithdrawalRequests.forEach(({ requestCount, extraFee }) => { + it(`Should successfully add ${requestCount} requests with extra fee ${extraFee}`, async () => { + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts } = generateWithdrawalRequestPayload(requestCount); + const expectedFee = await getFee(); + const expectedTotalWithdrawalFee = expectedFee * BigInt(requestCount); + + const initialBalance = await getWithdrawalCredentialsContractBalance(); + const vebInitialBalance = await ethers.provider.getBalance(validatorsExitBus.address); + + 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/contracts/EIP7002WithdrawalRequest_Mock.sol b/test/common/contracts/EIP7002WithdrawalRequest_Mock.sol new file mode 100644 index 000000000..4ed806024 --- /dev/null +++ b/test/common/contracts/EIP7002WithdrawalRequest_Mock.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.9; + +/** + * @notice This is a mock of EIP-7002's pre-deploy contract. + */ +contract EIP7002WithdrawalRequest_Mock { + bytes public fee; + bool public failOnAddRequest; + bool public failOnGetFee; + + event eip7002MockRequestAdded(bytes request, uint256 fee); + + function setFailOnAddRequest(bool _failOnAddRequest) external { + failOnAddRequest = _failOnAddRequest; + } + + function setFailOnGetFee(bool _failOnGetFee) external { + failOnGetFee = _failOnGetFee; + } + + function setFee(uint256 _fee) external { + require(_fee > 0, "fee must be greater than 0"); + fee = abi.encode(_fee); + } + + 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"); + + return fee; + } + + require(!failOnAddRequest, "fail on add request"); + + require(input.length == 56, "Invalid callData length"); + + emit eip7002MockRequestAdded(input, msg.value); + } +} diff --git a/test/common/contracts/TriggerableWithdrawals_Harness.sol b/test/common/contracts/TriggerableWithdrawals_Harness.sol new file mode 100644 index 000000000..a29db8a05 --- /dev/null +++ b/test/common/contracts/TriggerableWithdrawals_Harness.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.9; + +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 { + TriggerableWithdrawals.addFullWithdrawalRequests(pubkeys, feePerRequest); + } + + function addPartialWithdrawalRequests( + bytes calldata pubkeys, + uint64[] calldata amounts, + uint256 feePerRequest + ) external { + TriggerableWithdrawals.addPartialWithdrawalRequests(pubkeys, amounts, feePerRequest); + } + + function addWithdrawalRequests(bytes calldata pubkeys, uint64[] calldata amounts, uint256 feePerRequest) external { + TriggerableWithdrawals.addWithdrawalRequests(pubkeys, amounts, feePerRequest); + } + + function getWithdrawalRequestFee() external view returns (uint256) { + return TriggerableWithdrawals.getWithdrawalRequestFee(); + } + + function getWithdrawalsContractAddress() public pure returns (address) { + return TriggerableWithdrawals.WITHDRAWAL_REQUEST; + } + + function deposit() external payable {} +} diff --git a/test/common/lib/triggerableWithdrawals/eip7002Mock.ts b/test/common/lib/triggerableWithdrawals/eip7002Mock.ts new file mode 100644 index 000000000..a23d7c89e --- /dev/null +++ b/test/common/lib/triggerableWithdrawals/eip7002Mock.ts @@ -0,0 +1,44 @@ +import { expect } from "chai"; +import { ContractTransactionReceipt, 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, +): Promise<{ tx: ContractTransactionResponse; receipt: ContractTransactionReceipt }> => { + 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); + } + + if (!receipt) { + throw new Error("No receipt"); + } + + return { tx, receipt }; +}; diff --git a/test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts b/test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts new file mode 100644 index 000000000..d3f271d81 --- /dev/null +++ b/test/common/lib/triggerableWithdrawals/triggerableWithdrawals.test.ts @@ -0,0 +1,628 @@ +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 { EIP7002WithdrawalRequest_Mock, TriggerableWithdrawals_Harness } from "typechain-types"; + +import { Snapshot } from "test/suite"; + +import { findEip7002MockEvents, testEip7002Mock } from "./eip7002Mock"; +import { + deployWithdrawalsPredeployedMock, + generateWithdrawalRequestPayload, + withdrawalsPredeployedHardcodedAddress, +} from "./utils"; + +const EMPTY_PUBKEYS = "0x"; + +describe("TriggerableWithdrawals.sol", () => { + let actor: HardhatEthersSigner; + + let withdrawalsPredeployed: EIP7002WithdrawalRequest_Mock; + let triggerableWithdrawals: TriggerableWithdrawals_Harness; + + let originalState: string; + + async function getWithdrawalCredentialsContractBalance(): Promise { + const contractAddress = await triggerableWithdrawals.getAddress(); + return await ethers.provider.getBalance(contractAddress); + } + + async function getWithdrawalsPredeployedContractBalance(): Promise { + const contractAddress = await withdrawalsPredeployed.getAddress(); + return await ethers.provider.getBalance(contractAddress); + } + + const MAX_UINT64 = (1n << 64n) - 1n; + + before(async () => { + [actor] = await ethers.getSigners(); + + withdrawalsPredeployed = await deployWithdrawalsPredeployedMock(1n); + triggerableWithdrawals = await ethers.deployContract("TriggerableWithdrawals_Harness"); + + expect(await withdrawalsPredeployed.getAddress()).to.equal(withdrawalsPredeployedHardcodedAddress); + + await triggerableWithdrawals.connect(actor).deposit({ value: ethers.parseEther("1") }); + }); + + beforeEach(async () => (originalState = await Snapshot.take())); + + afterEach(async () => await Snapshot.restore(originalState)); + + async function getFee(): Promise { + return await triggerableWithdrawals.getWithdrawalRequestFee(); + } + + context("eip 7002 contract", () => { + it("Should return the address of the EIP 7002 contract", async function () { + expect(await triggerableWithdrawals.getWithdrawalsContractAddress()).to.equal( + withdrawalsPredeployedHardcodedAddress, + ); + }); + }); + + context("get triggerable withdrawal request fee", () => { + it("Should get fee from the EIP 7002 contract", async function () { + await withdrawalsPredeployed.setFee(333n); + expect( + (await triggerableWithdrawals.getWithdrawalRequestFee()) == 333n, + "withdrawal request should use fee from the EIP 7002 contract", + ); + }); + + it("Should revert if fee read fails", async function () { + await withdrawalsPredeployed.setFailOnGetFee(true); + await expect(triggerableWithdrawals.getWithdrawalRequestFee()).to.be.revertedWithCustomError( + triggerableWithdrawals, + "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", () => { + it("Should revert if empty arrays are provided", async function () { + await expect(triggerableWithdrawals.addFullWithdrawalRequests(EMPTY_PUBKEYS, 1n)).to.be.revertedWithCustomError( + triggerableWithdrawals, + "NoWithdrawalRequests", + ); + + await expect( + triggerableWithdrawals.addPartialWithdrawalRequests(EMPTY_PUBKEYS, [], 1n), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "NoWithdrawalRequests"); + + await expect(triggerableWithdrawals.addWithdrawalRequests(EMPTY_PUBKEYS, [], 1n)).to.be.revertedWithCustomError( + triggerableWithdrawals, + "NoWithdrawalRequests", + ); + }); + + it("Should revert if array lengths do not match", async function () { + const requestCount = 2; + const { pubkeysHexString } = generateWithdrawalRequestPayload(requestCount); + const amounts = [1n]; + + const fee = await getFee(); + + await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") + .withArgs(requestCount, amounts.length); + + await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, [], fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") + .withArgs(requestCount, 0); + + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, amounts, fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") + .withArgs(requestCount, amounts.length); + + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, [], fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "MismatchedArrayLengths") + .withArgs(requestCount, 0); + }); + + it("Should revert if not enough fee is sent", async function () { + const { pubkeysHexString } = generateWithdrawalRequestPayload(1); + const amounts = [10n]; + + await withdrawalsPredeployed.setFee(3n); // Set fee to 3 gwei + + // 2. Should revert if fee is less than required + const insufficientFee = 2n; + await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, insufficientFee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientWithdrawalFee") + .withArgs(2n, 3n); + + await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, insufficientFee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientWithdrawalFee") + .withArgs(2n, 3n); + + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, amounts, insufficientFee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "InsufficientWithdrawalFee") + .withArgs(2n, 3n); + }); + + it("Should revert if pubkey is not 48 bytes", async function () { + // Invalid pubkey (only 2 bytes) + const invalidPubkeyHexString = "0x1234"; + const amounts = [10n]; + + const fee = await getFee(); + + await expect( + triggerableWithdrawals.addFullWithdrawalRequests(invalidPubkeyHexString, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "MalformedPubkeysArray"); + + await expect( + triggerableWithdrawals.addPartialWithdrawalRequests(invalidPubkeyHexString, amounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "MalformedPubkeysArray"); + + await expect( + triggerableWithdrawals.addWithdrawalRequests(invalidPubkeyHexString, amounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "MalformedPubkeysArray"); + }); + + it("Should revert if last pubkey not 48 bytes", async function () { + const validPubey = + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f"; + const invalidPubkey = "1234"; + const pubkeysHexString = `0x${validPubey}${invalidPubkey}`; + + const amounts = [10n]; + + const fee = await getFee(); + + await expect( + triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "MalformedPubkeysArray"); + + await expect( + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "MalformedPubkeysArray"); + + await expect( + triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, amounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "MalformedPubkeysArray"); + }); + + it("Should revert if addition fails at the withdrawal request contract", async function () { + const { pubkeysHexString } = generateWithdrawalRequestPayload(1); + const amounts = [10n]; + + const fee = await getFee(); + + // Set mock to fail on add + await withdrawalsPredeployed.setFailOnAddRequest(true); + + await expect( + triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestAdditionFailed"); + + await expect( + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestAdditionFailed"); + + await expect( + triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, amounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalRequestAdditionFailed"); + }); + + it("Should revert when a full withdrawal amount is included in 'addPartialWithdrawalRequests'", async function () { + const { pubkeysHexString } = generateWithdrawalRequestPayload(2); + const amounts = [1n, 0n]; // Partial and Full withdrawal + const fee = await getFee(); + + await expect( + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "PartialWithdrawalRequired"); + }); + + it("Should revert when balance is less than total withdrawal fee", async function () { + const keysCount = 2; + const fee = 10n; + const balance = 19n; + const expectedMinimalBalance = 20n; + + const { pubkeysHexString, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(keysCount); + + await withdrawalsPredeployed.setFee(fee); + await setBalance(await triggerableWithdrawals.getAddress(), balance); + + await expect(triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "TotalWithdrawalFeeExceededBalance") + .withArgs(balance, expectedMinimalBalance); + + await expect(triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "TotalWithdrawalFeeExceededBalance") + .withArgs(balance, expectedMinimalBalance); + + await expect(triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee)) + .to.be.revertedWithCustomError(triggerableWithdrawals, "TotalWithdrawalFeeExceededBalance") + .withArgs(balance, expectedMinimalBalance); + }); + + it("Should revert when fee read fails", async function () { + await withdrawalsPredeployed.setFailOnGetFee(true); + + const { pubkeysHexString, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(2); + const fee = 10n; + + await expect( + triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalFeeReadFailed"); + + await expect( + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), + ).to.be.revertedWithCustomError(triggerableWithdrawals, "WithdrawalFeeReadFailed"); + + await expect( + triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), + ).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 } = + generateWithdrawalRequestPayload(requestCount); + + const fee = 3n; + const fee_not_provided = 0n; + await withdrawalsPredeployed.setFee(fee); + + await testEip7002Mock( + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee_not_provided), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + + await testEip7002Mock( + () => + triggerableWithdrawals.addPartialWithdrawalRequests( + pubkeysHexString, + partialWithdrawalAmounts, + fee_not_provided, + ), + pubkeys, + partialWithdrawalAmounts, + fee, + ); + + await testEip7002Mock( + () => triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee_not_provided), + pubkeys, + mixedWithdrawalAmounts, + fee, + ); + }); + + it("Should accept withdrawal requests when the provided fee matches the exact required amount", async function () { + const requestCount = 3; + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + const fee = 3n; + await withdrawalsPredeployed.setFee(fee); + + await testEip7002Mock( + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), + pubkeys, + fullWithdrawalAmounts, + fee, + ); + + await testEip7002Mock( + () => triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), + pubkeys, + partialWithdrawalAmounts, + fee, + ); + + await testEip7002Mock( + () => triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), + pubkeys, + mixedWithdrawalAmounts, + fee, + ); + + // Check extremely high fee + const highFee = ethers.parseEther("10"); + await withdrawalsPredeployed.setFee(highFee); + + await triggerableWithdrawals.connect(actor).deposit({ value: highFee * BigInt(requestCount) * 3n }); + + await testEip7002Mock( + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, highFee), + pubkeys, + fullWithdrawalAmounts, + highFee, + ); + + await testEip7002Mock( + () => triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, highFee), + pubkeys, + partialWithdrawalAmounts, + highFee, + ); + + await testEip7002Mock( + () => triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, highFee), + pubkeys, + mixedWithdrawalAmounts, + highFee, + ); + }); + + it("Should accept withdrawal requests when the provided fee exceeds the required amount", async function () { + const requestCount = 3; + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + await withdrawalsPredeployed.setFee(3n); + const excessFee = 4n; + + await testEip7002Mock( + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, excessFee), + pubkeys, + fullWithdrawalAmounts, + excessFee, + ); + + await testEip7002Mock( + () => + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, excessFee), + pubkeys, + partialWithdrawalAmounts, + excessFee, + ); + + await testEip7002Mock( + () => triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, excessFee), + pubkeys, + mixedWithdrawalAmounts, + excessFee, + ); + + // Check when the provided fee extremely exceeds the required amount + const extremelyHighFee = ethers.parseEther("10"); + await triggerableWithdrawals.connect(actor).deposit({ value: extremelyHighFee * BigInt(requestCount) * 3n }); + + await testEip7002Mock( + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, extremelyHighFee), + pubkeys, + fullWithdrawalAmounts, + extremelyHighFee, + ); + + await testEip7002Mock( + () => + triggerableWithdrawals.addPartialWithdrawalRequests( + pubkeysHexString, + partialWithdrawalAmounts, + extremelyHighFee, + ), + pubkeys, + partialWithdrawalAmounts, + extremelyHighFee, + ); + + await testEip7002Mock( + () => triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, extremelyHighFee), + pubkeys, + mixedWithdrawalAmounts, + extremelyHighFee, + ); + }); + + it("Should correctly deduct the exact fee amount from the contract balance", async function () { + const requestCount = 3; + const { pubkeysHexString, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + const fee = 4n; + const expectedTotalWithdrawalFee = 12n; // fee * requestCount; + + const testFeeDeduction = async (addRequests: () => Promise) => { + const initialBalance = await getWithdrawalCredentialsContractBalance(); + await addRequests(); + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance - expectedTotalWithdrawalFee); + }; + + await testFeeDeduction(() => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee)); + await testFeeDeduction(() => + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), + ); + await testFeeDeduction(() => + triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), + ); + }); + + it("Should transfer the total calculated fee to the EIP-7002 withdrawal contract", async function () { + const requestCount = 3; + const { pubkeysHexString, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + const fee = 3n; + const expectedTotalWithdrawalFee = 9n; // fee * requestCount; + + const testFeeTransfer = async (addRequests: () => Promise) => { + const initialBalance = await getWithdrawalsPredeployedContractBalance(); + await addRequests(); + expect(await getWithdrawalsPredeployedContractBalance()).to.equal(initialBalance + expectedTotalWithdrawalFee); + }; + + await testFeeTransfer(() => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee)); + await testFeeTransfer(() => + triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), + ); + await testFeeTransfer(() => + triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), + ); + }); + + it("Should accept full, partial, and mixed withdrawal requests via 'addWithdrawalRequests' function", async function () { + const { pubkeysHexString, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(3); + const fee = await getFee(); + + await triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, fullWithdrawalAmounts, fee); + await triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee); + await triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee); + }); + + it("Should handle maximum uint64 withdrawal amount in partial withdrawal requests", async function () { + const { pubkeysHexString } = generateWithdrawalRequestPayload(1); + const amounts = [MAX_UINT64]; + + await triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, amounts, 10n); + await triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, amounts, 10n); + }); + + it("Should ensure withdrawal requests are encoded as expected with a 48-byte pubkey and 8-byte amount", async function () { + const requestCount = 16; + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + const fee = 333n; + + const testEncoding = async ( + addRequests: () => Promise, + expectedPubKeys: string[], + expectedAmounts: bigint[], + ) => { + const tx = await addRequests(); + const receipt = await tx.wait(); + + const events = findEip7002MockEvents(receipt!, "eip7002MockRequestAdded"); + expect(events.length).to.equal(requestCount); + + for (let i = 0; i < requestCount; i++) { + const encodedRequest = events[i].args[0]; + // 0x (2 characters) + 48-byte pubkey (96 characters) + 8-byte amount (16 characters) = 114 characters + expect(encodedRequest.length).to.equal(114); + + expect(encodedRequest.slice(0, 2)).to.equal("0x"); + expect(encodedRequest.slice(2, 98)).to.equal(expectedPubKeys[i]); + expect(encodedRequest.slice(98, 114)).to.equal(expectedAmounts[i].toString(16).padStart(16, "0")); + + // double check the amount convertation + expect(BigInt("0x" + encodedRequest.slice(98, 114))).to.equal(expectedAmounts[i]); + } + }; + + await testEncoding( + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), + pubkeys, + fullWithdrawalAmounts, + ); + await testEncoding( + () => triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), + pubkeys, + partialWithdrawalAmounts, + ); + await testEncoding( + () => triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), + pubkeys, + mixedWithdrawalAmounts, + ); + }); + + async function addWithdrawalRequests( + addRequests: () => Promise, + expectedPubkeys: string[], + expectedAmounts: bigint[], + expectedFee: bigint, + expectedTotalWithdrawalFee: bigint, + ) { + const initialBalance = await getWithdrawalCredentialsContractBalance(); + + await testEip7002Mock(addRequests, expectedPubkeys, expectedAmounts, expectedFee); + + expect(await getWithdrawalCredentialsContractBalance()).to.equal(initialBalance - expectedTotalWithdrawalFee); + } + + const testCasesForWithdrawalRequests = [ + { requestCount: 1, fee: 0n }, + { requestCount: 1, fee: 100n }, + { requestCount: 1, fee: 100_000_000_000n }, + { requestCount: 3, fee: 0n }, + { requestCount: 3, fee: 1n }, + { requestCount: 7, fee: 3n }, + { requestCount: 10, fee: 0n }, + { requestCount: 10, fee: 100_000_000_000n }, + { requestCount: 100, fee: 0n }, + ]; + + testCasesForWithdrawalRequests.forEach(({ requestCount, fee }) => { + it(`Should successfully add ${requestCount} requests with fee ${fee}`, async () => { + const { pubkeysHexString, pubkeys, fullWithdrawalAmounts, partialWithdrawalAmounts, mixedWithdrawalAmounts } = + generateWithdrawalRequestPayload(requestCount); + + const expectedFee = fee == 0n ? await getFee() : fee; + const expectedTotalWithdrawalFee = expectedFee * BigInt(requestCount); + + await addWithdrawalRequests( + () => triggerableWithdrawals.addFullWithdrawalRequests(pubkeysHexString, fee), + pubkeys, + fullWithdrawalAmounts, + expectedFee, + expectedTotalWithdrawalFee, + ); + + await addWithdrawalRequests( + () => triggerableWithdrawals.addPartialWithdrawalRequests(pubkeysHexString, partialWithdrawalAmounts, fee), + pubkeys, + partialWithdrawalAmounts, + expectedFee, + expectedTotalWithdrawalFee, + ); + + await addWithdrawalRequests( + () => triggerableWithdrawals.addWithdrawalRequests(pubkeysHexString, mixedWithdrawalAmounts, fee), + pubkeys, + mixedWithdrawalAmounts, + expectedFee, + expectedTotalWithdrawalFee, + ); + }); + }); + }); +}); diff --git a/test/common/lib/triggerableWithdrawals/utils.ts b/test/common/lib/triggerableWithdrawals/utils.ts new file mode 100644 index 000000000..678a4a9fb --- /dev/null +++ b/test/common/lib/triggerableWithdrawals/utils.ts @@ -0,0 +1,57 @@ +import { ethers } from "hardhat"; + +import { EIP7002WithdrawalRequest_Mock } from "typechain-types"; + +export const withdrawalsPredeployedHardcodedAddress = "0x00000961Ef480Eb55e80D19ad83579A64c007002"; + +export async function deployWithdrawalsPredeployedMock( + defaultRequestFee: bigint, +): Promise { + const withdrawalsPredeployed = await ethers.deployContract("EIP7002WithdrawalRequest_Mock"); + const withdrawalsPredeployedAddress = await withdrawalsPredeployed.getAddress(); + + await ethers.provider.send("hardhat_setCode", [ + withdrawalsPredeployedHardcodedAddress, + await ethers.provider.getCode(withdrawalsPredeployedAddress), + ]); + + const contract = await ethers.getContractAt("EIP7002WithdrawalRequest_Mock", withdrawalsPredeployedHardcodedAddress); + await contract.setFee(defaultRequestFee); + return contract; +} + +function toValidatorPubKey(num: number): string { + if (num < 0 || num > 0xffff) { + throw new Error("Number is out of the 2-byte range (0x0000 - 0xffff)."); + } + + return `${num.toString(16).padStart(4, "0").toLocaleLowerCase().repeat(24)}`; +} + +const convertEthToGwei = (ethAmount: string | number): bigint => { + const ethString = ethAmount.toString(); + const wei = ethers.parseEther(ethString); + return wei / 1_000_000_000n; +}; + +export function generateWithdrawalRequestPayload(numberOfRequests: number) { + const pubkeys: string[] = []; + const fullWithdrawalAmounts: bigint[] = []; + const partialWithdrawalAmounts: bigint[] = []; + const mixedWithdrawalAmounts: bigint[] = []; + + for (let i = 1; i <= numberOfRequests; i++) { + pubkeys.push(toValidatorPubKey(i)); + fullWithdrawalAmounts.push(0n); + partialWithdrawalAmounts.push(convertEthToGwei(i)); + mixedWithdrawalAmounts.push(i % 2 === 0 ? 0n : convertEthToGwei(i)); + } + + return { + pubkeysHexString: `0x${pubkeys.join("")}`, + pubkeys, + fullWithdrawalAmounts, + partialWithdrawalAmounts, + mixedWithdrawalAmounts, + }; +} diff --git a/test/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) diff --git a/test/deploy/index.ts b/test/deploy/index.ts index d7afaf858..a50a8ac8c 100644 --- a/test/deploy/index.ts +++ b/test/deploy/index.ts @@ -4,3 +4,5 @@ export * from "./locator"; export * from "./dao"; export * from "./hashConsensus"; export * from "./withdrawalQueue"; +export * from "./validatorExitBusOracle"; +export * from "./stakingVault"; diff --git a/test/deploy/locator.ts b/test/deploy/locator.ts index fb2ed3c76..2db3c0301 100644 --- a/test/deploy/locator.ts +++ b/test/deploy/locator.ts @@ -28,9 +28,10 @@ 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"), predepositGuarantee: certainAddress("dummy-locator:predepositGuarantee"), + vaultHub: certainAddress("dummy-locator:vaultHub"), ...config, }); @@ -108,6 +109,7 @@ async function getLocatorConfig(locatorAddress: string) { "accounting", "wstETH", "predepositGuarantee", + "vaultHub", ] as Partial[]; const configPromises = addresses.map((name) => locator[name]()); diff --git a/test/deploy/stakingVault.ts b/test/deploy/stakingVault.ts new file mode 100644 index 000000000..60377c616 --- /dev/null +++ b/test/deploy/stakingVault.ts @@ -0,0 +1,81 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { + DepositContract__MockForStakingVault, + EIP7002WithdrawalRequest_Mock, + StakingVault, + StakingVault__factory, + VaultFactory__MockForStakingVault, + VaultHub__MockForStakingVault, +} from "typechain-types"; + +import { findEvents } from "lib"; + +import { EIP7002_PREDEPLOYED_ADDRESS } from "test/suite"; + +type DeployedStakingVault = { + depositContract: DepositContract__MockForStakingVault; + stakingVault: StakingVault; + stakingVaultImplementation: StakingVault; + vaultHub: VaultHub__MockForStakingVault; + 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 { + // 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(), await depositContract_.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/deploy/validatorExitBusOracle.ts b/test/deploy/validatorExitBusOracle.ts new file mode 100644 index 000000000..9ca2c44de --- /dev/null +++ b/test/deploy/validatorExitBusOracle.ts @@ -0,0 +1,131 @@ +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("Accounting__MockForAccountingOracle"); + const ao = await ethers.deployContract("AccountingOracle__MockForSanityChecker", [ + await lido.getAddress(), + secondsPerSlot, + genesisTime, + ]); + return { ao, lido }; +} + +async function deployOracleReportSanityCheckerForExitBus(lidoLocator: string, admin: string) { + 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( + 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; +} diff --git a/test/integration/accounting.integration.ts b/test/integration/core/accounting.integration.ts similarity index 97% rename from test/integration/accounting.integration.ts rename to test/integration/core/accounting.integration.ts index e7f6ec43f..4896d9837 100644 --- a/test/integration/accounting.integration.ts +++ b/test/integration/core/accounting.integration.ts @@ -5,9 +5,8 @@ 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 { getProtocolContext, ProtocolContext } from "lib/protocol"; -import { getReportTimeElapsed, report } from "lib/protocol/helpers"; +import { ether, impersonate, log, ONE_GWEI, updateBalance } from "lib"; +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"; @@ -98,8 +97,7 @@ describe("Integration: 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") }); } } @@ -111,8 +109,7 @@ describe("Integration: 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") }); } } @@ -737,8 +734,7 @@ describe("Integration: 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; @@ -746,7 +742,7 @@ describe("Integration: 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"); @@ -757,10 +753,7 @@ describe("Integration: 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); @@ -814,8 +807,7 @@ describe("Integration: 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; @@ -823,7 +815,7 @@ describe("Integration: 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/core/burn-shares.integration.ts similarity index 72% rename from test/integration/burn-shares.integration.ts rename to test/integration/core/burn-shares.integration.ts index 28c97f07a..ea05f3dfb 100644 --- a/test/integration/burn-shares.integration.ts +++ b/test/integration/core/burn-shares.integration.ts @@ -4,9 +4,8 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { ether, impersonate, log, trace } from "lib"; -import { getProtocolContext, ProtocolContext } from "lib/protocol"; -import { finalizeWithdrawalQueue, handleOracleReport } from "lib/protocol/helpers"; +import { ether, impersonate, log } from "lib"; +import { getProtocolContext, handleOracleReport, ProtocolContext } from "lib/protocol"; import { bailOnFailure, Snapshot } from "test/suite"; @@ -33,22 +32,10 @@ describe("Scenario: 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; - 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 +61,10 @@ describe("Scenario: 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/core/happy-path.integration.ts similarity index 94% rename from test/integration/protocol-happy-path.integration.ts rename to test/integration/core/happy-path.integration.ts index 892809fc9..9126cb30c 100644 --- a/test/integration/protocol-happy-path.integration.ts +++ b/test/integration/core/happy-path.integration.ts @@ -4,18 +4,18 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { batch, ether, impersonate, log, trace, updateBalance } from "lib"; -import { getProtocolContext, ProtocolContext } from "lib/protocol"; +import { batch, ether, impersonate, log, updateBalance } from "lib"; 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"); @@ -56,8 +56,7 @@ describe("Scenario: 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"); @@ -71,11 +70,8 @@ describe("Scenario: 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); }); @@ -127,7 +123,7 @@ describe("Scenario: 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; @@ -221,7 +217,7 @@ describe("Scenario: 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"); @@ -424,7 +420,7 @@ describe("Scenario: 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]; @@ -440,11 +436,7 @@ describe("Scenario: 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( @@ -590,12 +582,11 @@ describe("Scenario: 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, diff --git a/test/integration/negative-rebase.integration.ts b/test/integration/core/negative-rebase.integration.ts similarity index 84% rename from test/integration/negative-rebase.integration.ts rename to test/integration/core/negative-rebase.integration.ts index 1dfc4c61c..b5887f446 100644 --- a/test/integration/negative-rebase.integration.ts +++ b/test/integration/core/negative-rebase.integration.ts @@ -5,23 +5,22 @@ 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"; -// 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; 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 +39,11 @@ describe.skip("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 +82,9 @@ describe.skip("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); 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 83% rename from test/integration/vaults-happy-path.integration.ts rename to test/integration/vaults/happy-path.integration.ts index 556a87308..5e745981e 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, impersonate, log, trace, 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"; @@ -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; @@ -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, @@ -145,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.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 @@ -159,26 +155,29 @@ describe("Scenario: Staking Vaults Happy Path", () => { const deployTx = await stakingVaultFactory.connect(owner).createVaultWithDelegation( { defaultAdmin: owner, - funder: curator, - withdrawer: curator, - minter: curator, - burner: curator, - curator, - rebalancer: curator, - depositPauser: curator, - depositResumer: curator, - exitRequester: curator, - disconnecter: curator, nodeOperatorManager: nodeOperator, - nodeOperatorFeeClaimer: nodeOperator, assetRecoverer: curator, curatorFeeBP: VAULT_OWNER_FEE, nodeOperatorFeeBP: VAULT_NODE_OPERATOR_FEE, + confirmExpiry: days(7n), + funders: [curator], + withdrawers: [curator], + minters: [curator], + burners: [curator], + rebalancers: [curator], + depositPausers: [curator], + depositResumers: [curator], + validatorExitRequesters: [curator], + validatorWithdrawalTriggerers: [curator], + disconnecters: [curator], + curatorFeeSetters: [curator], + curatorFeeClaimers: [curator], + nodeOperatorFeeClaimers: [nodeOperator], }, "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); @@ -188,13 +187,12 @@ 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_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; @@ -203,11 +201,12 @@ describe("Scenario: Staking Vaults Happy Path", () => { 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.TRIGGER_VALIDATOR_WITHDRAWAL_ROLE())).to.be.true; expect(await isSoleRoleMember(curator, await delegation.VOLUNTARY_DISCONNECT_ROLE())).to.be.true; }); 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 @@ -219,17 +218,16 @@ describe("Scenario: Staking Vaults Happy Path", () => { const agentSigner = await ctx.getSigner("agent"); - await accounting + await vaultHub .connect(agentSigner) - .connectVault(stakingVault, shareLimit, reserveRatio, reserveRatioThreshold, treasuryFeeBP); + .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); }); 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); @@ -259,9 +257,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(); @@ -272,7 +268,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( @@ -288,11 +284,11 @@ 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); - 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); @@ -351,10 +347,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; @@ -399,7 +392,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); @@ -419,13 +412,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 @@ -437,12 +425,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 @@ -451,20 +434,19 @@ describe("Scenario: Staking Vaults Happy Path", () => { }); it("Should allow Manager to rebalance the vault to reduce the debt", async () => { - const { accounting, lido } = ctx.contracts; + const { vaultHub, lido } = ctx.contracts; - const socket = await accounting["vaultSocket(address)"](stakingVaultAddress); + const socket = await vaultHub["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); diff --git a/test/suite/constants.ts b/test/suite/constants.ts index 6a30c9cad..86e4d1642 100644 --- a/test/suite/constants.ts +++ b/test/suite/constants.ts @@ -9,3 +9,8 @@ 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 = "0x00000961Ef480Eb55e80D19ad83579A64c007002"; + +export const VAULTS_RELATIVE_SHARE_LIMIT_BP = 10_00n; +export const VAULTS_CONNECTED_VAULTS_LIMIT = 500; diff --git a/yarn.lock b/yarn.lock index 85a680e23..46eb5e150 100644 --- a/yarn.lock +++ b/yarn.lock @@ -275,12 +275,12 @@ __metadata: languageName: node linkType: hard -"@commitlint/cli@npm:19.6.1": - version: 19.6.1 - resolution: "@commitlint/cli@npm:19.6.1" +"@commitlint/cli@npm:19.7.1": + version: 19.7.1 + resolution: "@commitlint/cli@npm:19.7.1" dependencies: "@commitlint/format": "npm:^19.5.0" - "@commitlint/lint": "npm:^19.6.0" + "@commitlint/lint": "npm:^19.7.1" "@commitlint/load": "npm:^19.6.1" "@commitlint/read": "npm:^19.5.0" "@commitlint/types": "npm:^19.5.0" @@ -288,17 +288,17 @@ __metadata: yargs: "npm:^17.0.0" bin: commitlint: cli.js - checksum: 10c0/fa7a344292f1d25533b195b061bcae0a80434490fae843ad28593c09668f48e9a74906b69f95d26df4152c56c71ab31a0bc169d333e22c6ca53dc54646a2ff19 + checksum: 10c0/bb5e4f004f6b16078cdc7e7d6ff1a53762cefc1265af017ccef4ab789d2c562b75fe316ccc1751da6bc1172393f2427926c863298edda2e4d00c8323f2878f5b languageName: node linkType: hard -"@commitlint/config-conventional@npm:19.6.0": - version: 19.6.0 - resolution: "@commitlint/config-conventional@npm:19.6.0" +"@commitlint/config-conventional@npm:19.7.1": + version: 19.7.1 + resolution: "@commitlint/config-conventional@npm:19.7.1" dependencies: "@commitlint/types": "npm:^19.5.0" conventional-changelog-conventionalcommits: "npm:^7.0.2" - checksum: 10c0/984870138f5d4b947bc2ea8d12fcb8103ef9e6141d0fb50a6e387665495b80b35890d9dc025443a243a53d2a69d7c0bab1d77c5658a6e5a15a3dd7773557fad2 + checksum: 10c0/9de7e5f1e4ac1d995293da12a646936d477c4fc50562de015df26e0b307ebf3fd2632dc8c874ba9d9a81c9540c3189e275fb6fe0b707ae6c9159c013b7dfdb56 languageName: node linkType: hard @@ -343,25 +343,25 @@ __metadata: languageName: node linkType: hard -"@commitlint/is-ignored@npm:^19.6.0": - version: 19.6.0 - resolution: "@commitlint/is-ignored@npm:19.6.0" +"@commitlint/is-ignored@npm:^19.7.1": + version: 19.7.1 + resolution: "@commitlint/is-ignored@npm:19.7.1" dependencies: "@commitlint/types": "npm:^19.5.0" semver: "npm:^7.6.0" - checksum: 10c0/64e3522598f131aefab72e78f2b0d5d78228041fbe14fd9785611bd5a4ff7dfae38288ff87b171ab2ff722342983387b6e568ab4d758f3c97866eb924252e6c5 + checksum: 10c0/8c238002c6c7bb0a50cca2dfc001af2cec2926056e090b840e73c25f8d246ac5d8ff862d51a63900a195479869edca7889fc4c7923ffa2bb85a1475f8c469c43 languageName: node linkType: hard -"@commitlint/lint@npm:^19.6.0": - version: 19.6.0 - resolution: "@commitlint/lint@npm:19.6.0" +"@commitlint/lint@npm:^19.7.1": + version: 19.7.1 + resolution: "@commitlint/lint@npm:19.7.1" dependencies: - "@commitlint/is-ignored": "npm:^19.6.0" + "@commitlint/is-ignored": "npm:^19.7.1" "@commitlint/parse": "npm:^19.5.0" "@commitlint/rules": "npm:^19.6.0" "@commitlint/types": "npm:^19.5.0" - checksum: 10c0/d7e3c6a43d89b2196362dce5abef6665869844455176103f311cab7a92f6b7be60edec4f03d27b946a65ee2ceb8ff16f5955cba1da6ecdeb9efe9f215b16f47f + checksum: 10c0/578e2a955c5d16e34dade2538966b5a0fed6ba4e81fcfb477ad3a62472467f80d84d0d79ec017aa5e6815ed6c71b246d660d9febb64cabb175e39eee426b2f98 languageName: node linkType: hard @@ -493,41 +493,41 @@ __metadata: languageName: node linkType: hard -"@eslint/compat@npm:1.2.5": - version: 1.2.5 - resolution: "@eslint/compat@npm:1.2.5" +"@eslint/compat@npm:1.2.7": + version: 1.2.7 + resolution: "@eslint/compat@npm:1.2.7" peerDependencies: eslint: ^9.10.0 peerDependenciesMeta: eslint: optional: true - checksum: 10c0/c7cd6c623b850e7507fdaf26298b42b07012a65b57f6abbdd1e968eb281756bb94024f162a661ffcc7ad8b2949832aec5078a9fdefa87081e127d392842d0048 + checksum: 10c0/df89a0396750748c3748eb5fc582bd6cb89be6599d88ed1c5cc60ae0d13f77d4bf5fb30fabdb6c9ce16dda35745ef2e6417fa82548cde7d2b3fa5a896da02c8e languageName: node linkType: hard -"@eslint/config-array@npm:^0.19.0": - version: 0.19.0 - resolution: "@eslint/config-array@npm:0.19.0" +"@eslint/config-array@npm:^0.19.2": + version: 0.19.2 + resolution: "@eslint/config-array@npm:0.19.2" dependencies: - "@eslint/object-schema": "npm:^2.1.4" + "@eslint/object-schema": "npm:^2.1.6" debug: "npm:^4.3.1" minimatch: "npm:^3.1.2" - checksum: 10c0/def23c6c67a8f98dc88f1b87e17a5668e5028f5ab9459661aabfe08e08f2acd557474bbaf9ba227be0921ae4db232c62773dbb7739815f8415678eb8f592dbf5 + checksum: 10c0/dd68da9abb32d336233ac4fe0db1e15a0a8d794b6e69abb9e57545d746a97f6f542496ff9db0d7e27fab1438546250d810d90b1904ac67677215b8d8e7573f3d languageName: node linkType: hard -"@eslint/core@npm:^0.10.0": - version: 0.10.0 - resolution: "@eslint/core@npm:0.10.0" +"@eslint/core@npm:^0.12.0": + version: 0.12.0 + resolution: "@eslint/core@npm:0.12.0" dependencies: "@types/json-schema": "npm:^7.0.15" - checksum: 10c0/074018075079b3ed1f14fab9d116f11a8824cdfae3e822badf7ad546962fafe717a31e61459bad8cc59cf7070dc413ea9064ddb75c114f05b05921029cde0a64 + checksum: 10c0/d032af81195bb28dd800c2b9617548c6c2a09b9490da3c5537fd2a1201501666d06492278bb92cfccac1f7ac249e58601dd87f813ec0d6a423ef0880434fa0c3 languageName: node linkType: hard -"@eslint/eslintrc@npm:^3.2.0": - version: 3.2.0 - resolution: "@eslint/eslintrc@npm:3.2.0" +"@eslint/eslintrc@npm:^3.3.0": + version: 3.3.0 + resolution: "@eslint/eslintrc@npm:3.3.0" dependencies: ajv: "npm:^6.12.4" debug: "npm:^4.3.2" @@ -538,31 +538,31 @@ __metadata: js-yaml: "npm:^4.1.0" minimatch: "npm:^3.1.2" strip-json-comments: "npm:^3.1.1" - checksum: 10c0/43867a07ff9884d895d9855edba41acf325ef7664a8df41d957135a81a477ff4df4196f5f74dc3382627e5cc8b7ad6b815c2cea1b58f04a75aced7c43414ab8b + checksum: 10c0/215de990231b31e2fe6458f225d8cea0f5c781d3ecb0b7920703501f8cd21b3101fc5ef2f0d4f9a38865d36647b983e0e8ce8bf12fd2bcdd227fc48a5b1a43be languageName: node linkType: hard -"@eslint/js@npm:9.19.0": - version: 9.19.0 - resolution: "@eslint/js@npm:9.19.0" - checksum: 10c0/45dc544c8803984f80a438b47a8e578fae4f6e15bc8478a703827aaf05e21380b42a43560374ce4dad0d5cb6349e17430fc9ce1686fed2efe5d1ff117939ff90 +"@eslint/js@npm:9.21.0": + version: 9.21.0 + resolution: "@eslint/js@npm:9.21.0" + checksum: 10c0/86c24a2668808995037e3f40c758335df2ae277c553ac0cf84381a1a8698f3099d8a22dd9c388947e6b7f93fcc1142f62406072faaa2b83c43ca79993fc01bb3 languageName: node linkType: hard -"@eslint/object-schema@npm:^2.1.4": - version: 2.1.4 - resolution: "@eslint/object-schema@npm:2.1.4" - checksum: 10c0/e9885532ea70e483fb007bf1275968b05bb15ebaa506d98560c41a41220d33d342e19023d5f2939fed6eb59676c1bda5c847c284b4b55fce521d282004da4dda +"@eslint/object-schema@npm:^2.1.6": + version: 2.1.6 + resolution: "@eslint/object-schema@npm:2.1.6" + checksum: 10c0/b8cdb7edea5bc5f6a96173f8d768d3554a628327af536da2fc6967a93b040f2557114d98dbcdbf389d5a7b290985ad6a9ce5babc547f36fc1fde42e674d11a56 languageName: node linkType: hard -"@eslint/plugin-kit@npm:^0.2.5": - version: 0.2.5 - resolution: "@eslint/plugin-kit@npm:0.2.5" +"@eslint/plugin-kit@npm:^0.2.7": + version: 0.2.7 + resolution: "@eslint/plugin-kit@npm:0.2.7" dependencies: - "@eslint/core": "npm:^0.10.0" + "@eslint/core": "npm:^0.12.0" levn: "npm:^0.4.1" - checksum: 10c0/ba9832b8409af618cf61791805fe201dd62f3c82c783adfcec0f5cd391e68b40beaecb47b9a3209e926dbcab65135f410cae405b69a559197795793399f61176 + checksum: 10c0/0a1aff1ad63e72aca923217e556c6dfd67d7cd121870eb7686355d7d1475d569773528a8b2111b9176f3d91d2ea81f7413c34600e8e5b73d59e005d70780b633 languageName: node linkType: hard @@ -1059,10 +1059,10 @@ __metadata: languageName: node linkType: hard -"@humanwhocodes/retry@npm:^0.4.1": - version: 0.4.1 - resolution: "@humanwhocodes/retry@npm:0.4.1" - checksum: 10c0/be7bb6841c4c01d0b767d9bb1ec1c9359ee61421ce8ba66c249d035c5acdfd080f32d55a5c9e859cdd7868788b8935774f65b2caf24ec0b7bd7bf333791f063b +"@humanwhocodes/retry@npm:^0.4.2": + version: 0.4.2 + resolution: "@humanwhocodes/retry@npm:0.4.2" + checksum: 10c0/0235525d38f243bee3bf8b25ed395fbf957fb51c08adae52787e1325673071abe856c7e18e530922ed2dd3ce12ed82ba01b8cee0279ac52a3315fcdc3a69ef0c languageName: node linkType: hard @@ -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 @@ -2085,7 +2085,7 @@ __metadata: languageName: node linkType: hard -"@types/eslint@npm:*, @types/eslint@npm:9.6.1": +"@types/eslint@npm:9.6.1": version: 9.6.1 resolution: "@types/eslint@npm:9.6.1" dependencies: @@ -2095,15 +2095,6 @@ __metadata: languageName: node linkType: hard -"@types/eslint__js@npm:8.42.3": - version: 8.42.3 - resolution: "@types/eslint__js@npm:8.42.3" - dependencies: - "@types/eslint": "npm:*" - checksum: 10c0/ccc5180b92155929a089ffb03ed62625216dcd5e46dd3197c6f82370ce8b52c7cb9df66c06b0a3017995409e023bc9eafe5a3f009e391960eacefaa1b62d9a56 - languageName: node - linkType: hard - "@types/estree@npm:*, @types/estree@npm:^1.0.6": version: 1.0.6 resolution: "@types/estree@npm:1.0.6" @@ -2165,12 +2156,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:22.10.10": - version: 22.10.10 - resolution: "@types/node@npm:22.10.10" +"@types/node@npm:*, @types/node@npm:22.13.5": + version: 22.13.5 + resolution: "@types/node@npm:22.13.5" dependencies: undici-types: "npm:~6.20.0" - checksum: 10c0/3425772d4513cd5dbdd87c00acda088113c03a97445f84f6a89744c60a66990b56c9d3a7213d09d57b6b944ae8ff45f985565e0c1846726112588e33a22dd12b + checksum: 10c0/a2e7ed7bb0690e439004779baedeb05159c5cc41ef6d81c7a6ebea5303fde4033669e1c0e41ff7453b45fd2fea8dbd55fddfcd052950c7fcae3167c970bca725 languageName: node linkType: hard @@ -2214,9 +2205,9 @@ __metadata: linkType: hard "@types/qs@npm:^6.2.31": - version: 6.9.17 - resolution: "@types/qs@npm:6.9.17" - checksum: 10c0/a183fa0b3464267f8f421e2d66d960815080e8aab12b9aadab60479ba84183b1cdba8f4eff3c06f76675a8e42fe6a3b1313ea76c74f2885c3e25d32499c17d1b + version: 6.9.18 + resolution: "@types/qs@npm:6.9.18" + checksum: 10c0/790b9091348e06dde2c8e4118b5771ab386a8c22a952139a2eb0675360a2070d0b155663bf6f75b23f258fd0a1f7ffc0ba0f059d99a719332c03c40d9e9cd63b languageName: node linkType: hard @@ -2229,115 +2220,115 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.21.0": - version: 8.21.0 - resolution: "@typescript-eslint/eslint-plugin@npm:8.21.0" +"@typescript-eslint/eslint-plugin@npm:8.25.0": + version: 8.25.0 + resolution: "@typescript-eslint/eslint-plugin@npm:8.25.0" dependencies: "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:8.21.0" - "@typescript-eslint/type-utils": "npm:8.21.0" - "@typescript-eslint/utils": "npm:8.21.0" - "@typescript-eslint/visitor-keys": "npm:8.21.0" + "@typescript-eslint/scope-manager": "npm:8.25.0" + "@typescript-eslint/type-utils": "npm:8.25.0" + "@typescript-eslint/utils": "npm:8.25.0" + "@typescript-eslint/visitor-keys": "npm:8.25.0" graphemer: "npm:^1.4.0" ignore: "npm:^5.3.1" natural-compare: "npm:^1.4.0" - ts-api-utils: "npm:^2.0.0" + ts-api-utils: "npm:^2.0.1" peerDependencies: "@typescript-eslint/parser": ^8.0.0 || ^8.0.0-alpha.0 eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.8.0" - checksum: 10c0/4601d21ec35b9fa5cfc1ad0330733ab40d6c6822c7fc15c3584a16f678c9a72e077a1725a950823fe0f499a15f3981795b1ea5d1e7a1be5c7b8296ea9ae6327c + checksum: 10c0/11d63850f5f03b29cd31166f8da111788dc74e46877c2e16a5c488d6c4aa4b6c68c0857b9a396ad920aa7f0f3e7166f4faecbb194c19cd2bb9d3f687c5d2b292 languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.21.0": - version: 8.21.0 - resolution: "@typescript-eslint/parser@npm:8.21.0" +"@typescript-eslint/parser@npm:8.25.0": + version: 8.25.0 + resolution: "@typescript-eslint/parser@npm:8.25.0" dependencies: - "@typescript-eslint/scope-manager": "npm:8.21.0" - "@typescript-eslint/types": "npm:8.21.0" - "@typescript-eslint/typescript-estree": "npm:8.21.0" - "@typescript-eslint/visitor-keys": "npm:8.21.0" + "@typescript-eslint/scope-manager": "npm:8.25.0" + "@typescript-eslint/types": "npm:8.25.0" + "@typescript-eslint/typescript-estree": "npm:8.25.0" + "@typescript-eslint/visitor-keys": "npm:8.25.0" debug: "npm:^4.3.4" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.8.0" - checksum: 10c0/aadebd50ca7aa2d61ad85d890c0d7010f2c293ec4d50a7833ef9674f232f0bc7118faa93a898771fbea50f02d542d687cf3569421b23f72fe6fed6895d5506fc + checksum: 10c0/9a54539ba297791f23093ff42a885cc57d36b26205d7a390e114d1f01cc584ce91ac6ead01819daa46b48f873cac6c829fcf399a436610bdbfa98e5cd78148a2 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.21.0": - version: 8.21.0 - resolution: "@typescript-eslint/scope-manager@npm:8.21.0" +"@typescript-eslint/scope-manager@npm:8.25.0": + version: 8.25.0 + resolution: "@typescript-eslint/scope-manager@npm:8.25.0" dependencies: - "@typescript-eslint/types": "npm:8.21.0" - "@typescript-eslint/visitor-keys": "npm:8.21.0" - checksum: 10c0/ea405e79dc884ea1c76465604db52f9b0941d6cbb0bde6bce1af689ef212f782e214de69d46503c7c47bfc180d763369b7433f1965e3be3c442b417e8c9f8f75 + "@typescript-eslint/types": "npm:8.25.0" + "@typescript-eslint/visitor-keys": "npm:8.25.0" + checksum: 10c0/0a53a07873bdb569be38053ec006009cc8ba6b12c538b6df0935afd18e431cb17da1eb15b0c9cd267ac211c47aaa44fbc8d7ff3b7b44ff711621ff305fa3b355 languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.21.0": - version: 8.21.0 - resolution: "@typescript-eslint/type-utils@npm:8.21.0" +"@typescript-eslint/type-utils@npm:8.25.0": + version: 8.25.0 + resolution: "@typescript-eslint/type-utils@npm:8.25.0" dependencies: - "@typescript-eslint/typescript-estree": "npm:8.21.0" - "@typescript-eslint/utils": "npm:8.21.0" + "@typescript-eslint/typescript-estree": "npm:8.25.0" + "@typescript-eslint/utils": "npm:8.25.0" debug: "npm:^4.3.4" - ts-api-utils: "npm:^2.0.0" + ts-api-utils: "npm:^2.0.1" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.8.0" - checksum: 10c0/617f5dfe83fd9a7c722b27fa4e7f0c84f29baa94f75a4e8e5ccfd5b0a373437f65724e21b9642870fb0960f204b1a7f516a038200a12f8118f21b1bf86315bf3 + checksum: 10c0/b7477a2d239cfd337f7d28641666763cf680a43a8d377a09dc42415f715670d35fbb4e772e103dfe8cd620c377e66bce740106bb3983ee65a739c28fab7325d1 languageName: node linkType: hard -"@typescript-eslint/types@npm:8.21.0": - version: 8.21.0 - resolution: "@typescript-eslint/types@npm:8.21.0" - checksum: 10c0/67dfd300cc614d7b02e94d0dacfb228a7f4c3fd4eede29c43adb9e9fcc16365ae3df8d6165018da3c123dce65545bef03e3e8183f35e9b3a911ffc727e3274c2 +"@typescript-eslint/types@npm:8.25.0": + version: 8.25.0 + resolution: "@typescript-eslint/types@npm:8.25.0" + checksum: 10c0/b39addbee4be4d66e3089c2d01f9f1d69cedc13bff20e4fa9ed0ca5a0e7591d7c6e41ab3763c8c35404f971bc0fbf9f7867dbc2832740e5b63ee0049d60289f5 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.21.0": - version: 8.21.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.21.0" +"@typescript-eslint/typescript-estree@npm:8.25.0": + version: 8.25.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.25.0" dependencies: - "@typescript-eslint/types": "npm:8.21.0" - "@typescript-eslint/visitor-keys": "npm:8.21.0" + "@typescript-eslint/types": "npm:8.25.0" + "@typescript-eslint/visitor-keys": "npm:8.25.0" debug: "npm:^4.3.4" fast-glob: "npm:^3.3.2" is-glob: "npm:^4.0.3" minimatch: "npm:^9.0.4" semver: "npm:^7.6.0" - ts-api-utils: "npm:^2.0.0" + ts-api-utils: "npm:^2.0.1" peerDependencies: typescript: ">=4.8.4 <5.8.0" - checksum: 10c0/0cf5b0382524f4af54fb5ec71ca7e939ec922711f2d77b383740b28dd4b21407b0ab5dded62df6819d01c12c0b354e95667e3c7025a5d27d05b805161ab94855 + checksum: 10c0/fc9de1c4f6ab81fb80b632dedef84d1ecf4c0abdc5f5246698deb6d86d5c6b5d582ef8a44fdef445bf7fbfa6658db516fe875c9d7c984bf4802e3a508b061856 languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.21.0": - version: 8.21.0 - resolution: "@typescript-eslint/utils@npm:8.21.0" +"@typescript-eslint/utils@npm:8.25.0": + version: 8.25.0 + resolution: "@typescript-eslint/utils@npm:8.25.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:8.21.0" - "@typescript-eslint/types": "npm:8.21.0" - "@typescript-eslint/typescript-estree": "npm:8.21.0" + "@typescript-eslint/scope-manager": "npm:8.25.0" + "@typescript-eslint/types": "npm:8.25.0" + "@typescript-eslint/typescript-estree": "npm:8.25.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.8.0" - checksum: 10c0/d8347dbe9176417220aa62902cfc1b2007a9246bb7a8cccdf8590120903eb50ca14cb668efaab4646d086277f2367559985b62230e43ebd8b0723d237eeaa2f2 + checksum: 10c0/cd15c4919f02899fd3975049a0a051a1455332a108c085a3e90ae9872e2cddac7f20a9a2c616f1366fca84274649e836ad6a437c9c5ead0bdabf5a123d12403f languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.21.0": - version: 8.21.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.21.0" +"@typescript-eslint/visitor-keys@npm:8.25.0": + version: 8.25.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.25.0" dependencies: - "@typescript-eslint/types": "npm:8.21.0" + "@typescript-eslint/types": "npm:8.25.0" eslint-visitor-keys: "npm:^4.2.0" - checksum: 10c0/b3f1412f550e35c0d7ae0410db616951116b365167539f9b85710d8bc2b36b322c5e637caee84cc1ae5df8f1d961880250d52ffdef352b31e5bdbef74ba6fea9 + checksum: 10c0/7eb84c5899a25b1eb89d3c3f4be3ff18171f934669c57e2530b6dfa5fdd6eaae60629f3c89d06f4c8075fd1c701de76c0b9194e2922895c661ab6091e48f7db9 languageName: node linkType: hard @@ -2840,13 +2831,13 @@ __metadata: linkType: hard "axios@npm:^1.5.1": - version: 1.7.8 - resolution: "axios@npm:1.7.8" + version: 1.7.9 + resolution: "axios@npm:1.7.9" dependencies: follow-redirects: "npm:^1.15.6" form-data: "npm:^4.0.0" proxy-from-env: "npm:^1.1.0" - checksum: 10c0/23ae2d0105aea9170c34ac9b6f30d9b2ab2fa8b1370205d2f7ce98b9f9510ab420148c13359ee837ea5a4bf2fb028ff225bd2fc92052fb0c478c6b4a836e2d5f + checksum: 10c0/b7a41e24b59fee5f0f26c1fc844b45b17442832eb3a0fb42dd4f1430eb4abc571fe168e67913e8a1d91c993232bd1d1ab03e20e4d1fee8c6147649b576fc1b0b languageName: node linkType: hard @@ -3824,6 +3815,16 @@ __metadata: languageName: node linkType: hard +"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938 + languageName: node + linkType: hard + "call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.6, call-bind@npm:^1.0.7, call-bind@npm:~1.0.2": version: 1.0.7 resolution: "call-bind@npm:1.0.7" @@ -3837,6 +3838,16 @@ __metadata: languageName: node linkType: hard +"call-bound@npm:^1.0.2": + version: 1.0.3 + resolution: "call-bound@npm:1.0.3" + dependencies: + call-bind-apply-helpers: "npm:^1.0.1" + get-intrinsic: "npm:^1.2.6" + checksum: 10c0/45257b8e7621067304b30dbd638e856cac913d31e8e00a80d6cf172911acd057846572d0b256b45e652d515db6601e2974a1b1a040e91b4fc36fb3dd86fa69cf + languageName: node + linkType: hard + "callsites@npm:^3.0.0": version: 3.1.0 resolution: "callsites@npm:3.1.0" @@ -3950,14 +3961,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^5.3.0": - version: 5.3.0 - resolution: "chalk@npm:5.3.0" - checksum: 10c0/8297d436b2c0f95801103ff2ef67268d362021b8210daf8ddbe349695333eb3610a71122172ff3b0272f1ef2cf7cc2c41fdaa4715f52e49ffe04c56340feed09 - languageName: node - linkType: hard - -"chalk@npm:^5.4.1": +"chalk@npm:^5.3.0, chalk@npm:^5.4.1": version: 5.4.1 resolution: "chalk@npm:5.4.1" checksum: 10c0/b23e88132c702f4855ca6d25cb5538b1114343e41472d5263ee8a37cccfccd9c4216d111e1097c6a27830407a1dc81fecdf2a56f2c63033d4dbbd88c10b0dcef @@ -4558,15 +4562,15 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5": - version: 4.3.7 - resolution: "debug@npm:4.3.7" +"debug@npm:4, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.4.0": + version: 4.4.0 + resolution: "debug@npm:4.4.0" dependencies: ms: "npm:^2.1.3" peerDependenciesMeta: supports-color: optional: true - checksum: 10c0/1471db19c3b06d485a622d62f65947a19a23fbd0dd73f7fd3eafb697eec5360cde447fb075919987899b1a2096e85d35d4eb5a4de09a57600ac9cf7e6c8e768b + checksum: 10c0/db94f1a182bf886f57b4755f85b3a74c39b5114b9377b7ab375dc2cfa3454f09490cc6c30f829df3fc8042bc8b8995f6567ce5cd96f3bc3688bd24027197d9de languageName: node linkType: hard @@ -4588,18 +4592,6 @@ __metadata: languageName: node linkType: hard -"debug@npm:^4.4.0": - version: 4.4.0 - resolution: "debug@npm:4.4.0" - dependencies: - ms: "npm:^2.1.3" - peerDependenciesMeta: - supports-color: - optional: true - checksum: 10c0/db94f1a182bf886f57b4755f85b3a74c39b5114b9377b7ab375dc2cfa3454f09490cc6c30f829df3fc8042bc8b8995f6567ce5cd96f3bc3688bd24027197d9de - languageName: node - linkType: hard - "decamelize@npm:^1.1.1": version: 1.2.0 resolution: "decamelize@npm:1.2.0" @@ -4794,6 +4786,17 @@ __metadata: languageName: node linkType: hard +"dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.2.0" + checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031 + languageName: node + linkType: hard + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -4983,12 +4986,10 @@ __metadata: languageName: node linkType: hard -"es-define-property@npm:^1.0.0": - version: 1.0.0 - resolution: "es-define-property@npm:1.0.0" - dependencies: - get-intrinsic: "npm:^1.2.4" - checksum: 10c0/6bf3191feb7ea2ebda48b577f69bdfac7a2b3c9bcf97307f55fd6ef1bbca0b49f0c219a935aca506c993d8c5d8bddd937766cb760cd5e5a1071351f2df9f9aa4 +"es-define-property@npm:^1.0.0, es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c languageName: node linkType: hard @@ -4999,23 +5000,24 @@ __metadata: languageName: node linkType: hard -"es-object-atoms@npm:^1.0.0": - version: 1.0.0 - resolution: "es-object-atoms@npm:1.0.0" +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": + version: 1.1.1 + resolution: "es-object-atoms@npm:1.1.1" dependencies: es-errors: "npm:^1.3.0" - checksum: 10c0/1fed3d102eb27ab8d983337bb7c8b159dd2a1e63ff833ec54eea1311c96d5b08223b433060ba240541ca8adba9eee6b0a60cdbf2f80634b784febc9cc8b687b4 + checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c languageName: node linkType: hard -"es-set-tostringtag@npm:^2.0.3": - version: 2.0.3 - resolution: "es-set-tostringtag@npm:2.0.3" +"es-set-tostringtag@npm:^2.0.3, es-set-tostringtag@npm:^2.1.0": + version: 2.1.0 + resolution: "es-set-tostringtag@npm:2.1.0" dependencies: - get-intrinsic: "npm:^1.2.4" + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.6" has-tostringtag: "npm:^1.0.2" - hasown: "npm:^2.0.1" - checksum: 10c0/f22aff1585eb33569c326323f0b0d175844a1f11618b86e193b386f8be0ea9474cfbe46df39c45d959f7aa8f6c06985dc51dd6bce5401645ec5a74c4ceaa836a + hasown: "npm:^2.0.2" + checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af languageName: node linkType: hard @@ -5141,20 +5143,20 @@ __metadata: languageName: node linkType: hard -"eslint@npm:9.19.0": - version: 9.19.0 - resolution: "eslint@npm:9.19.0" +"eslint@npm:9.21.0": + version: 9.21.0 + resolution: "eslint@npm:9.21.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.2.0" "@eslint-community/regexpp": "npm:^4.12.1" - "@eslint/config-array": "npm:^0.19.0" - "@eslint/core": "npm:^0.10.0" - "@eslint/eslintrc": "npm:^3.2.0" - "@eslint/js": "npm:9.19.0" - "@eslint/plugin-kit": "npm:^0.2.5" + "@eslint/config-array": "npm:^0.19.2" + "@eslint/core": "npm:^0.12.0" + "@eslint/eslintrc": "npm:^3.3.0" + "@eslint/js": "npm:9.21.0" + "@eslint/plugin-kit": "npm:^0.2.7" "@humanfs/node": "npm:^0.16.6" "@humanwhocodes/module-importer": "npm:^1.0.1" - "@humanwhocodes/retry": "npm:^0.4.1" + "@humanwhocodes/retry": "npm:^0.4.2" "@types/estree": "npm:^1.0.6" "@types/json-schema": "npm:^7.0.15" ajv: "npm:^6.12.4" @@ -5186,7 +5188,7 @@ __metadata: optional: true bin: eslint: bin/eslint.js - checksum: 10c0/3b0dfaeff6a831de086884a3e2432f18468fe37c69f35e1a0a9a2833d9994a65b6dd2a524aaee28f361c849035ad9d15e3841029b67d261d0abd62c7de6d51f5 + checksum: 10c0/558edb25b440cd51825d66fed3e84f1081bd6f4cb2cf994e60ece4c5978fa0583e88b75faf187c1fc21688c4ff7072f12bf5f6d1be1e09a4d6af78cff39dc520 languageName: node linkType: hard @@ -6025,14 +6027,15 @@ __metadata: linkType: hard "form-data@npm:^2.2.0": - version: 2.5.2 - resolution: "form-data@npm:2.5.2" + version: 2.5.3 + resolution: "form-data@npm:2.5.3" dependencies: asynckit: "npm:^0.4.0" - combined-stream: "npm:^1.0.6" - mime-types: "npm:^2.1.12" + combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + mime-types: "npm:^2.1.35" safe-buffer: "npm:^5.2.1" - checksum: 10c0/af7cb13fc8423ff95fd59c62d101c84b5458a73e1e426b0bc459afbf5b93b1e447dc6c225ac31c6df59f36b209904a3f1a10b4eb9e7a17e0fe394019749142cc + checksum: 10c0/48b910745d4fcd403f3d6876e33082a334e712199b8c86c4eb82f6da330a59b859943999d793856758c5ff18ca5261ced4d1062235a14543022d986bd21faa7d languageName: node linkType: hard @@ -6242,16 +6245,21 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.3, get-intrinsic@npm:^1.2.4": - version: 1.2.4 - resolution: "get-intrinsic@npm:1.2.4" +"get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.3, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6": + version: 1.3.0 + resolution: "get-intrinsic@npm:1.3.0" dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.1" function-bind: "npm:^1.1.2" - has-proto: "npm:^1.0.1" - has-symbols: "npm:^1.0.3" - hasown: "npm:^2.0.0" - checksum: 10c0/0a9b82c16696ed6da5e39b1267104475c47e3a9bdbe8b509dfe1710946e38a87be70d759f4bb3cda042d76a41ef47fe769660f3b7c0d1f68750299344ffb15b7 + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.1.0" + checksum: 10c0/52c81808af9a8130f581e6a6a83e1ba4a9f703359e7a438d1369a5267a25412322f03dcbd7c549edaef0b6214a0630a28511d7df0130c93cfd380f4fa0b5b66a languageName: node linkType: hard @@ -6262,6 +6270,16 @@ __metadata: languageName: node linkType: hard +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: "npm:^1.0.1" + es-object-atoms: "npm:^1.0.0" + checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c + languageName: node + linkType: hard + "get-stream@npm:^6.0.1": version: 6.0.1 resolution: "get-stream@npm:6.0.1" @@ -6464,10 +6482,10 @@ __metadata: languageName: node linkType: hard -"globals@npm:15.14.0": - version: 15.14.0 - resolution: "globals@npm:15.14.0" - checksum: 10c0/039deb8648bd373b7940c15df9f96ab7508fe92b31bbd39cbd1c1a740bd26db12457aa3e5d211553b234f30e9b1db2fee3683012f543a01a6942c9062857facb +"globals@npm:15.15.0": + version: 15.15.0 + resolution: "globals@npm:15.15.0" + checksum: 10c0/f9ae80996392ca71316495a39bec88ac43ae3525a438b5626cd9d5ce9d5500d0a98a266409605f8cd7241c7acf57c354a48111ea02a767ba4f374b806d6861fe languageName: node linkType: hard @@ -6518,12 +6536,10 @@ __metadata: languageName: node linkType: hard -"gopd@npm:^1.0.1": - version: 1.0.1 - resolution: "gopd@npm:1.0.1" - dependencies: - get-intrinsic: "npm:^1.1.3" - checksum: 10c0/505c05487f7944c552cee72087bf1567debb470d4355b1335f2c262d218ebbff805cd3715448fe29b4b380bae6912561d0467233e4165830efd28da241418c63 +"gopd@npm:^1.0.1, gopd@npm:^1.2.0": + version: 1.2.0 + resolution: "gopd@npm:1.2.0" + checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead languageName: node linkType: hard @@ -6665,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" @@ -6723,7 +6739,7 @@ __metadata: optional: true bin: hardhat: internal/cli/bootstrap.js - checksum: 10c0/cd2fd8972b24d13a342747129e88bfe8bad45432ad88c66c743e81615e1c5db7d656c3e9748c03e517c94f6f6df717c4a14685c82c9f843c9be7c1e0a5f76c49 + checksum: 10c0/bd0024f322787abd62aad6847e06d9988f861fd9bf2620bddd04cfeafada6925e97cc210034d7d00ba6cd9463608467fbf1b98bef380940f2e5c8e8d63bfc8e5 languageName: node linkType: hard @@ -6773,17 +6789,17 @@ __metadata: languageName: node linkType: hard -"has-proto@npm:^1.0.1, has-proto@npm:^1.0.3": +"has-proto@npm:^1.0.3": version: 1.0.3 resolution: "has-proto@npm:1.0.3" checksum: 10c0/35a6989f81e9f8022c2f4027f8b48a552de714938765d019dbea6bb547bd49ce5010a3c7c32ec6ddac6e48fc546166a3583b128f5a7add8b058a6d8b4afec205 languageName: node linkType: hard -"has-symbols@npm:^1.0.2, has-symbols@npm:^1.0.3": - version: 1.0.3 - resolution: "has-symbols@npm:1.0.3" - checksum: 10c0/e6922b4345a3f37069cdfe8600febbca791c94988c01af3394d86ca3360b4b93928bbf395859158f88099cb10b19d98e3bbab7c9ff2c1bd09cf665ee90afa2c3 +"has-symbols@npm:^1.0.2, has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0": + version: 1.1.0 + resolution: "has-symbols@npm:1.1.0" + checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e languageName: node linkType: hard @@ -6824,7 +6840,7 @@ __metadata: languageName: node linkType: hard -"hasown@npm:^2.0.0, hasown@npm:^2.0.1, hasown@npm:^2.0.2": +"hasown@npm:^2.0.0, hasown@npm:^2.0.2": version: 2.0.2 resolution: "hasown@npm:2.0.2" dependencies: @@ -8055,18 +8071,18 @@ __metadata: "@aragon/id": "npm:2.1.1" "@aragon/minime": "npm:1.0.0" "@aragon/os": "npm:4.4.0" - "@commitlint/cli": "npm:19.6.1" - "@commitlint/config-conventional": "npm:19.6.0" - "@eslint/compat": "npm:1.2.5" - "@eslint/js": "npm:9.19.0" + "@commitlint/cli": "npm:19.7.1" + "@commitlint/config-conventional": "npm:19.7.1" + "@eslint/compat": "npm:1.2.7" + "@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" @@ -8074,14 +8090,13 @@ __metadata: "@typechain/hardhat": "npm:9.1.0" "@types/chai": "npm:4.3.20" "@types/eslint": "npm:9.6.1" - "@types/eslint__js": "npm:8.42.3" "@types/mocha": "npm:10.0.10" - "@types/node": "npm:22.10.10" + "@types/node": "npm:22.13.5" bigint-conversion: "npm:2.4.3" chai: "npm:4.5.0" chalk: "npm:4.1.2" dotenv: "npm:16.4.7" - eslint: "npm:9.19.0" + eslint: "npm:9.21.0" eslint-config-prettier: "npm:9.1.0" eslint-plugin-no-only-tests: "npm:3.3.0" eslint-plugin-prettier: "npm:5.2.3" @@ -8089,8 +8104,8 @@ __metadata: ethereumjs-util: "npm:7.1.5" ethers: "npm:6.13.5" glob: "npm:11.0.1" - globals: "npm:15.14.0" - hardhat: "npm:2.22.18" + globals: "npm:15.15.0" + 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" @@ -8099,7 +8114,7 @@ __metadata: husky: "npm:9.1.7" lint-staged: "npm:15.4.3" openzeppelin-solidity: "npm:2.0.0" - prettier: "npm:3.4.2" + prettier: "npm:3.5.2" prettier-plugin-solidity: "npm:1.4.2" solhint: "npm:5.0.5" solhint-plugin-lido: "npm:0.0.4" @@ -8108,7 +8123,7 @@ __metadata: tsconfig-paths: "npm:4.2.0" typechain: "npm:8.3.2" typescript: "npm:5.7.3" - typescript-eslint: "npm:8.21.0" + typescript-eslint: "npm:8.25.0" languageName: unknown linkType: soft @@ -8434,6 +8449,13 @@ __metadata: languageName: node linkType: hard +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f + languageName: node + linkType: hard + "md5.js@npm:^1.3.4": version: 1.3.5 resolution: "md5.js@npm:1.3.5" @@ -8527,7 +8549,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12, mime-types@npm:~2.1.19": +"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35, mime-types@npm:~2.1.19": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -9031,7 +9053,7 @@ __metadata: languageName: node linkType: hard -"object-inspect@npm:^1.13.1, object-inspect@npm:^1.13.3": +"object-inspect@npm:^1.13.3": version: 1.13.3 resolution: "object-inspect@npm:1.13.3" checksum: 10c0/cc3f15213406be89ffdc54b525e115156086796a515410a8d390215915db9f23c8eab485a06f1297402f440a33715fe8f71a528c1dcbad6e1a3bcaf5a46921d4 @@ -9575,12 +9597,12 @@ __metadata: languageName: node linkType: hard -"prettier@npm:3.4.2": - version: 3.4.2 - resolution: "prettier@npm:3.4.2" +"prettier@npm:3.5.2": + version: 3.5.2 + resolution: "prettier@npm:3.5.2" bin: prettier: bin/prettier.cjs - checksum: 10c0/99e076a26ed0aba4ebc043880d0f08bbb8c59a4c6641cdee6cdadf2205bdd87aa1d7823f50c3aea41e015e99878d37c58d7b5f0e663bba0ef047f94e36b96446 + checksum: 10c0/d7b597ed33f39c32ace675896ad187f06a3e48dc8a1e80051b5c5f0dae3586d53981704b8fda5ac3b080e6c2e0e197d239131b953702674f044351621ca5e1ac languageName: node linkType: hard @@ -9698,11 +9720,11 @@ __metadata: linkType: hard "qs@npm:^6.4.0": - version: 6.13.1 - resolution: "qs@npm:6.13.1" + version: 6.14.0 + resolution: "qs@npm:6.14.0" dependencies: - side-channel: "npm:^1.0.6" - checksum: 10c0/5ef527c0d62ffca5501322f0832d800ddc78eeb00da3b906f1b260ca0492721f8cdc13ee4b8fd8ac314a6ec37b948798c7b603ccc167e954088df392092f160c + side-channel: "npm:^1.1.0" + checksum: 10c0/8ea5d91bf34f440598ee389d4a7d95820e3b837d3fd9f433871f7924801becaa0cd3b3b4628d49a7784d06a8aea9bc4554d2b6d8d584e2d221dc06238a42909c languageName: node linkType: hard @@ -10506,15 +10528,51 @@ __metadata: languageName: node linkType: hard -"side-channel@npm:^1.0.4, side-channel@npm:^1.0.6": - version: 1.0.6 - resolution: "side-channel@npm:1.0.6" +"side-channel-list@npm:^1.0.0": + version: 1.0.0 + resolution: "side-channel-list@npm:1.0.0" dependencies: - call-bind: "npm:^1.0.7" es-errors: "npm:^1.3.0" - get-intrinsic: "npm:^1.2.4" - object-inspect: "npm:^1.13.1" - checksum: 10c0/d2afd163dc733cc0a39aa6f7e39bf0c436293510dbccbff446733daeaf295857dbccf94297092ec8c53e2503acac30f0b78830876f0485991d62a90e9cad305f + object-inspect: "npm:^1.13.3" + checksum: 10c0/644f4ac893456c9490ff388bf78aea9d333d5e5bfc64cfb84be8f04bf31ddc111a8d4b83b85d7e7e8a7b845bc185a9ad02c052d20e086983cf59f0be517d9b3d + languageName: node + linkType: hard + +"side-channel-map@npm:^1.0.1": + version: 1.0.1 + resolution: "side-channel-map@npm:1.0.1" + dependencies: + call-bound: "npm:^1.0.2" + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.5" + object-inspect: "npm:^1.13.3" + checksum: 10c0/010584e6444dd8a20b85bc926d934424bd809e1a3af941cace229f7fdcb751aada0fb7164f60c2e22292b7fa3c0ff0bce237081fd4cdbc80de1dc68e95430672 + languageName: node + linkType: hard + +"side-channel-weakmap@npm:^1.0.2": + version: 1.0.2 + resolution: "side-channel-weakmap@npm:1.0.2" + dependencies: + call-bound: "npm:^1.0.2" + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.5" + object-inspect: "npm:^1.13.3" + side-channel-map: "npm:^1.0.1" + checksum: 10c0/71362709ac233e08807ccd980101c3e2d7efe849edc51455030327b059f6c4d292c237f94dc0685031dd11c07dd17a68afde235d6cf2102d949567f98ab58185 + languageName: node + linkType: hard + +"side-channel@npm:^1.0.4, side-channel@npm:^1.1.0": + version: 1.1.0 + resolution: "side-channel@npm:1.1.0" + dependencies: + es-errors: "npm:^1.3.0" + object-inspect: "npm:^1.13.3" + side-channel-list: "npm:^1.0.0" + side-channel-map: "npm:^1.0.1" + side-channel-weakmap: "npm:^1.0.2" + checksum: 10c0/cb20dad41eb032e6c24c0982e1e5a24963a28aa6122b4f05b3f3d6bf8ae7fd5474ef382c8f54a6a3ab86e0cac4d41a23bd64ede3970e5bfb50326ba02a7996e6 languageName: node linkType: hard @@ -11483,12 +11541,12 @@ __metadata: languageName: node linkType: hard -"ts-api-utils@npm:^2.0.0": - version: 2.0.0 - resolution: "ts-api-utils@npm:2.0.0" +"ts-api-utils@npm:^2.0.1": + version: 2.0.1 + resolution: "ts-api-utils@npm:2.0.1" peerDependencies: typescript: ">=4.8.4" - checksum: 10c0/6165e29a5b75bd0218e3cb0f9ee31aa893dbd819c2e46dbb086c841121eb0436ed47c2c18a20cb3463d74fd1fb5af62e2604ba5971cc48e5b38ebbdc56746dfc + checksum: 10c0/23fd56a958b332cac00150a652e4c84730df30571bd2faa1ba6d7b511356d1a61656621492bb6c7f15dd6e18847a1408357a0e406671d358115369a17f5bfedd languageName: node linkType: hard @@ -11750,17 +11808,17 @@ __metadata: languageName: node linkType: hard -"typescript-eslint@npm:8.21.0": - version: 8.21.0 - resolution: "typescript-eslint@npm:8.21.0" +"typescript-eslint@npm:8.25.0": + version: 8.25.0 + resolution: "typescript-eslint@npm:8.25.0" dependencies: - "@typescript-eslint/eslint-plugin": "npm:8.21.0" - "@typescript-eslint/parser": "npm:8.21.0" - "@typescript-eslint/utils": "npm:8.21.0" + "@typescript-eslint/eslint-plugin": "npm:8.25.0" + "@typescript-eslint/parser": "npm:8.25.0" + "@typescript-eslint/utils": "npm:8.25.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.8.0" - checksum: 10c0/44e5c341ad7f0b41dce3b4ca7a4c0a399ebe51a5323d930750db1e308367b4813a620f4c2332a5774a1dccd0047ebbaf993a8b7effd67389e9069b29b5701520 + checksum: 10c0/bdc1165be1bc60311045ca69aa1bff4bbb7feac906c6b7885c4bc859693d8ca1b88840a1ba10b226ca2343c4bd7388b7a36e5c787b0d7f1bab5ababb80e783cc languageName: node linkType: hard