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..c2e0d69f8 100644 --- a/contracts/0.4.24/Lido.sol +++ b/contracts/0.4.24/Lido.sol @@ -586,7 +586,7 @@ contract Lido is Versioned, StETHPermit, AragonApp { * @dev can be called only by accounting */ function mintShares(address _recipient, uint256 _amountOfShares) public { - _auth(getLidoLocator().accounting()); + require(msg.sender == getLidoLocator().accounting() || msg.sender == getLidoLocator().vaultHub(), "APP_AUTH_FAILED"); _whenNotStopped(); _mintShares(_recipient, _amountOfShares); @@ -639,7 +639,7 @@ contract Lido is Versioned, StETHPermit, AragonApp { */ function burnExternalShares(uint256 _amountOfShares) external { require(_amountOfShares != 0, "BURN_ZERO_AMOUNT_OF_SHARES"); - _auth(getLidoLocator().accounting()); + _auth(getLidoLocator().vaultHub()); _whenNotStopped(); uint256 externalShares = EXTERNAL_SHARES_POSITION.getStorageUint256(); @@ -663,7 +663,7 @@ contract Lido is Versioned, StETHPermit, AragonApp { */ function rebalanceExternalEtherToInternal() external payable { require(msg.value != 0, "ZERO_VALUE"); - _auth(getLidoLocator().accounting()); + _auth(getLidoLocator().vaultHub()); _whenNotStopped(); uint256 shares = getSharesByPooledEth(msg.value); diff --git a/contracts/0.8.25/Accounting.sol b/contracts/0.8.25/Accounting.sol index a875110af..0da4dec23 100644 --- a/contracts/0.8.25/Accounting.sol +++ b/contracts/0.8.25/Accounting.sol @@ -20,9 +20,7 @@ import {ReportValues} from "contracts/common/interfaces/ReportValues.sol"; /// @notice contract is responsible for handling accounting oracle reports /// calculating all the state changes that is required to apply the report /// and distributing calculated values to relevant parts of the protocol -/// @dev accounting is inherited from VaultHub contract to reduce gas costs and -/// simplify the auth flows, but they are mostly independent -contract Accounting is VaultHub { +contract Accounting { struct Contracts { address accountingOracleAddress; IOracleReportSanityChecker oracleReportSanityChecker; @@ -30,6 +28,7 @@ contract Accounting is VaultHub { IWithdrawalQueue withdrawalQueue; IPostTokenRebaseReceiver postTokenRebaseReceiver; IStakingRouter stakingRouter; + VaultHub vaultHub; } struct PreReportState { @@ -83,6 +82,8 @@ contract Accounting is VaultHub { uint256 precisionPoints; } + error NotAuthorized(string operation, address addr); + /// @notice deposit size in wei (for pre-maxEB accounting) uint256 private constant DEPOSIT_SIZE = 32 ether; @@ -91,20 +92,16 @@ contract Accounting is VaultHub { /// @notice Lido contract ILido public immutable LIDO; + /// @param _lidoLocator Lido Locator contract + /// @param _lido Lido contract constructor( ILidoLocator _lidoLocator, ILido _lido - ) VaultHub(_lido) { + ) { LIDO_LOCATOR = _lidoLocator; LIDO = _lido; } - function initialize(address _admin) external initializer { - if (_admin == address(0)) revert ZeroArgument("_admin"); - - __VaultHub_init(_admin); - } - /// @notice calculates all the state changes that is required to apply the report /// @param _report report values /// @param _withdrawalShareRate maximum share rate used for withdrawal finalization @@ -226,7 +223,7 @@ contract Accounting is VaultHub { // Calculate the amount of ether locked in the vaults to back external balance of stETH // and the amount of shares to mint as fees to the treasury for each vaults (update.vaultsLockedEther, update.vaultsTreasuryFeeShares, update.totalVaultsTreasuryFeeShares) = - _calculateVaultsRebase( + _contracts.vaultHub.calculateVaultsRebase( update.postTotalShares, update.postTotalPooledEther, _pre.totalShares, @@ -335,7 +332,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, @@ -343,7 +341,7 @@ contract Accounting is VaultHub { ); if (_update.totalVaultsTreasuryFeeShares > 0) { - STETH.mintExternalShares(LIDO_LOCATOR.treasury(), _update.totalVaultsTreasuryFeeShares); + LIDO.mintExternalShares(LIDO_LOCATOR.treasury(), _update.totalVaultsTreasuryFeeShares); } _notifyRebaseObserver(_contracts.postTokenRebaseReceiver, _report, _pre, _update); @@ -463,7 +461,8 @@ contract Accounting is VaultHub { address burner, address withdrawalQueue, address postTokenRebaseReceiver, - address stakingRouter + address stakingRouter, + address vaultHub ) = LIDO_LOCATOR.oracleReportComponents(); return @@ -473,7 +472,8 @@ contract Accounting is VaultHub { IBurner(burner), IWithdrawalQueue(withdrawalQueue), IPostTokenRebaseReceiver(postTokenRebaseReceiver), - IStakingRouter(stakingRouter) + IStakingRouter(stakingRouter), + VaultHub(vaultHub) ); } diff --git a/contracts/0.8.25/interfaces/IStakingRouter.sol b/contracts/0.8.25/interfaces/IStakingRouter.sol index b50685970..27a6e1e22 100644 --- a/contracts/0.8.25/interfaces/IStakingRouter.sol +++ b/contracts/0.8.25/interfaces/IStakingRouter.sol @@ -16,5 +16,5 @@ interface IStakingRouter { uint256 precisionPoints ); - function reportRewardsMinted(uint256[] memory _stakingModuleIds, uint256[] memory _totalShares) external; + function reportRewardsMinted(uint256[] calldata _stakingModuleIds, uint256[] calldata _totalShares) external; } diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 2ee30f7d1..6673f3203 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -135,18 +135,18 @@ contract Dashboard is Permissions { /** * @notice Returns the reserve ratio of the vault in basis points - * @return The reserve ratio as a uint16 + * @return The reserve ratio in basis points as a uint16 */ function reserveRatioBP() public view returns (uint16) { return vaultSocket().reserveRatioBP; } /** - * @notice Returns the threshold reserve ratio of the vault in basis points. - * @return The threshold reserve ratio as a uint16. + * @notice Returns the rebalance threshold of the vault in basis points. + * @return The rebalance threshold in basis points as a uint16. */ - function thresholdReserveRatioBP() external view returns (uint16) { - return vaultSocket().reserveRatioThresholdBP; + function rebalanceThresholdBP() external view returns (uint16) { + return vaultSocket().rebalanceThresholdBP; } /** @@ -260,14 +260,6 @@ contract Dashboard is Permissions { SafeERC20.safeTransfer(WETH, _recipient, _amountOfWETH); } - /** - * @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 shares backed by the vault to the recipient. * @param _recipient Address of the recipient @@ -427,6 +419,32 @@ contract Dashboard is Permissions { _resumeBeaconChainDeposits(); } + /** + * @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 requestValidatorExit(bytes calldata _pubkeys) external { + _requestValidatorExit(_pubkeys); + } + + /** + * @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 triggerValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient) external payable { + _triggerValidatorWithdrawal(_pubkeys, _amounts, _refundRecipient); + } + // ==================== Internal Functions ==================== /** diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index aceaec766..70dc6ead0 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -65,6 +65,11 @@ abstract contract Permissions is AccessControlConfirmable { */ 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. */ @@ -218,10 +223,18 @@ abstract contract Permissions is AccessControlConfirmable { /** * @dev Checks the REQUEST_VALIDATOR_EXIT_ROLE and requests validator exit on the StakingVault. - * @param _pubkey The public key of the validator to request exit for. + * @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 _requestValidatorExit(bytes calldata _pubkey) internal onlyRole(REQUEST_VALIDATOR_EXIT_ROLE) { - stakingVault().requestValidatorExit(_pubkey); + function _triggerValidatorWithdrawal(bytes calldata _pubkeys, uint64[] calldata _amounts, address _refundRecipient) internal onlyRole(TRIGGER_VALIDATOR_WITHDRAWAL_ROLE) { + stakingVault().triggerValidatorWithdrawal{value: msg.value}(_pubkeys, _amounts, _refundRecipient); } /** diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index f2fdf5f04..068d2f08f 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,29 +20,37 @@ 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()` + * - `requestValidatorExit()` + * - `triggerValidatorWithdrawal()` * - Operator: * - `depositToBeaconChain()` + * - `triggerValidatorWithdrawal()` * - VaultHub: * - `lock()` * - `report()` * - `rebalance()` + * - `triggerValidatorWithdrawal()` * - Anyone: * - Can send ETH directly to the vault (treated as rewards) * @@ -86,14 +95,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; /** @@ -107,7 +126,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(); @@ -127,16 +146,14 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { } /** - * @notice Returns the highest version that has been initialized - * @return Highest initialized version number as uint64 + * @notice Returns the highest version that has been initialized as uint64 */ function getInitializedVersion() external view returns (uint64) { return _getInitializedVersion(); } /** - * @notice Returns the version of the contract - * @return Version number as uint64 + * @notice Returns the version of the contract as uint64 */ function version() external pure returns (uint64) { return _VERSION; @@ -148,23 +165,13 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @notice Returns the address of `VaultHub` - * @return Address of `VaultHub` */ function vaultHub() external view returns (address) { return address(VAULT_HUB); } /** - * @notice Returns the 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) { @@ -173,8 +180,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { } /** - * @notice Returns the amount of ether locked in `StakingVault`. - * @return Amount of locked ether + * @notice Returns the amount of ether locked in `StakingVault` in ether * @dev Locked amount is updated by `VaultHub` with reports * and can also be increased by `VaultHub` outside of reports */ @@ -183,8 +189,7 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { } /** - * @notice Returns the unlocked amount, which is the valuation minus the locked amount - * @return Amount of unlocked ether + * @notice Returns the unlocked amount of ether, which is the valuation minus the locked ether amount * @dev Unlocked amount is the total amount that can be withdrawn from `StakingVault`, * including ether currently being staked on validators */ @@ -199,7 +204,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @notice Returns the net difference between funded and withdrawn ether. - * @return Delta between funded and withdrawn ether * @dev This counter is only updated via: * - `fund()`, * - `withdraw()`, @@ -214,55 +218,23 @@ 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 performs deposits to the beacon chain * and processes validator exit requests submitted by `owner` through `requestValidatorExit()`. * Node operator address is set in the initialization and can never be changed. - * @return Address of the node operator */ function nodeOperator() external view returns (address) { return _getStorage().nodeOperator; } - /** - * @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` @@ -289,9 +261,8 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @param _recipient Address to receive the withdrawn ether. * @param _ether Amount of ether to withdraw. * @dev Cannot withdraw more than the unlocked amount or the balance of the contract, whichever is less. - * @dev Updates inOutDelta to track the net difference between funded and withdrawn ether - * @dev 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"); @@ -305,47 +276,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"); - ERC7201Storage storage $ = _getStorage(); + if (valuation() < $.locked) revert ValuationBelowLockedAmount(); - if (msg.sender != $.nodeOperator) revert NotAuthorized("depositToBeaconChain", msg.sender); - if ($.beaconChainDepositsPaused) revert BeaconChainDepositsArePaused(); - if (!isBalanced()) revert Unbalanced(); - - uint256 totalAmount = 0; - uint256 numberOfDeposits = _deposits.length; - for (uint256 i = 0; i < numberOfDeposits; i++) { - Deposit calldata deposit = _deposits[i]; - BEACON_CHAIN_DEPOSIT_CONTRACT.deposit{value: deposit.amount}( - deposit.pubkey, - bytes.concat(withdrawalCredentials()), - deposit.signature, - deposit.depositDataRoot - ); - totalAmount += deposit.amount; - } - - emit DepositedToBeaconChain(msg.sender, numberOfDeposits, totalAmount); - } - - /** - * @notice Requests validator exit from the beacon chain - * @param _pubkeys Concatenated validator public keys - * @dev Signals the node operator to eject the specified validators from the beacon chain - */ - function requestValidatorExit(bytes calldata _pubkeys) external onlyOwner { - emit ValidatorsExitRequest(msg.sender, _pubkeys); + emit Withdrawn(msg.sender, _recipient, _ether); } /** @@ -366,18 +300,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); @@ -406,6 +340,164 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { emit Reported(_valuation, _inOutDelta, _locked); } + /** + * @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 withdrawalCredentials() public view returns (bytes32) { + return bytes32(WC_0X02_PREFIX | uint160(address(this))); + } + + /** + * @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 + */ + function pauseBeaconChainDeposits() external onlyOwner { + ERC7201Storage storage $ = _getStorage(); + if ($.beaconChainDepositsPaused) { + revert BeaconChainDepositsResumeExpected(); + } + + $.beaconChainDepositsPaused = true; + + emit BeaconChainDepositsPaused(); + } + + /** + * @notice Resumes deposits to beacon chain + * @dev Can only be called by the vault owner + */ + function resumeBeaconChainDeposits() external onlyOwner { + ERC7201Storage storage $ = _getStorage(); + if (!$.beaconChainDepositsPaused) { + revert BeaconChainDepositsPauseExpected(); + } + + $.beaconChainDepositsPaused = false; + + 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 (msg.sender != $.nodeOperator) revert NotAuthorized("depositToBeaconChain", msg.sender); + if ($.beaconChainDepositsPaused) revert BeaconChainDepositsArePaused(); + if (valuation() < $.locked) revert ValuationBelowLockedAmount(); + + uint256 totalAmount = 0; + uint256 numberOfDeposits = _deposits.length; + bytes memory withdrawalCredentials_ = bytes.concat(withdrawalCredentials()); + for (uint256 i = 0; i < numberOfDeposits; i++) { + IStakingVault.Deposit calldata deposit = _deposits[i]; + DEPOSIT_CONTRACT.deposit{value: deposit.amount}( + deposit.pubkey, + 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 Computes the deposit data root for a validator deposit * @param _pubkey Validator public key, 48 bytes @@ -416,14 +508,13 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @dev This function computes the deposit data root according to the deposit contract's specification. * The deposit data root is check upon deposit to the deposit contract as a protection against malformed deposit data. * See more: https://etherscan.io/address/0x00000000219ab540356cbb839cbe05303d7705fa#code - * */ function computeDepositDataRoot( bytes calldata _pubkey, bytes calldata _withdrawalCredentials, bytes calldata _signature, uint256 _amount - ) external view returns (bytes32) { + ) external pure returns (bytes32) { // Step 1. Convert the deposit amount in wei to gwei in 64-bit bytes bytes memory amountBE64 = abi.encodePacked(uint64(_amount / 1 gwei)); @@ -442,8 +533,8 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { bytes32 pubkeyRoot = sha256(abi.encodePacked(_pubkey, bytes16(0))); // Step 4. Compute the root of the signature - bytes32 sigSlice1Root = sha256(abi.encodePacked(_signature[0:64])); - bytes32 sigSlice2Root = sha256(abi.encodePacked(_signature[64:], bytes32(0))); + bytes32 sigSlice1Root = sha256(abi.encodePacked(_signature[0 : 64])); + bytes32 sigSlice2Root = sha256(abi.encodePacked(_signature[64 :], bytes32(0))); bytes32 signatureRoot = sha256(abi.encodePacked(sigSlice1Root, sigSlice2Root)); // Step 5. Compute the root-toot-toorootoo of the deposit data @@ -457,39 +548,9 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { return depositDataRoot; } - /** - * @notice Pauses deposits to beacon chain - * @dev Can only be called by the vault owner - */ - function pauseBeaconChainDeposits() external onlyOwner { - ERC7201Storage storage $ = _getStorage(); - if ($.beaconChainDepositsPaused) { - revert BeaconChainDepositsResumeExpected(); - } - - $.beaconChainDepositsPaused = true; - - emit BeaconChainDepositsPaused(); - } - - /** - * @notice Resumes deposits to beacon chain - * @dev Can only be called by the vault owner - */ - function resumeBeaconChainDeposits() external onlyOwner { - ERC7201Storage storage $ = _getStorage(); - if (!$.beaconChainDepositsPaused) { - revert BeaconChainDepositsPauseExpected(); - } - - $.beaconChainDepositsPaused = false; - - emit BeaconChainDepositsResumed(); - } - function _getStorage() private pure returns (ERC7201Storage storage $) { assembly { - $.slot := ERC721_STORAGE_LOCATION + $.slot := ERC7201_STORAGE_LOCATION } } @@ -510,21 +571,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 @@ -556,6 +602,33 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { */ event BeaconChainDepositsResumed(); + /** + * @notice Emitted when ether is deposited to `DepositContract`. + * @param _sender Address that initiated the deposit. + * @param _deposits Number of validator deposits made. + * @param _totalAmount Total amount of ether deposited. + */ + event DepositedToBeaconChain(address indexed _sender, uint256 _deposits, uint256 _totalAmount); + + /** + * @notice Emitted when 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 @@ -589,9 +662,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 @@ -633,4 +706,33 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @notice Thrown when trying to deposit to beacon chain while deposits are paused */ error BeaconChainDepositsArePaused(); + + /** + * @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 3a6509931..419b8a8fc 100644 --- a/contracts/0.8.25/vaults/VaultFactory.sol +++ b/contracts/0.8.25/vaults/VaultFactory.sol @@ -23,7 +23,8 @@ struct DelegationConfig { address[] rebalancers; address[] depositPausers; address[] depositResumers; - address[] exitRequesters; + address[] validatorExitRequesters; + address[] validatorWithdrawalTriggerers; address[] disconnecters; address[] curatorFeeSetters; address[] curatorFeeClaimers; @@ -94,8 +95,11 @@ contract VaultFactory { for (uint256 i = 0; i < _delegationConfig.depositResumers.length; i++) { delegation.grantRole(delegation.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), _delegationConfig.depositResumers[i]); } - for (uint256 i = 0; i < _delegationConfig.exitRequesters.length; i++) { - delegation.grantRole(delegation.REQUEST_VALIDATOR_EXIT_ROLE(), _delegationConfig.exitRequesters[i]); + for (uint256 i = 0; i < _delegationConfig.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]); diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index d09516adb..e9034b81e 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"; @@ -19,14 +18,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; @@ -43,14 +42,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)) @@ -63,26 +62,46 @@ 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; + address public immutable accounting; /// @param _stETH Lido stETH contract - constructor(IStETH _stETH) { + /// @param _connectedVaultsLimit Maximum number of vaults that can be connected simultaneously + /// @param _relativeShareLimitBP Maximum share limit relative to TVL in basis points + constructor(IStETH _stETH, address _accounting, uint256 _connectedVaultsLimit, uint256 _relativeShareLimitBP) { + if (_connectedVaultsLimit == 0) revert ZeroArgument("_connectedVaultsLimit"); + if (_relativeShareLimitBP == 0) revert ZeroArgument("_relativeShareLimitBP"); + if (_relativeShareLimitBP > TOTAL_BASIS_POINTS) revert RelativeShareLimitBPTooHigh(_relativeShareLimitBP, TOTAL_BASIS_POINTS); + STETH = _stETH; + accounting = _accounting; + CONNECTED_VAULTS_LIMIT = _connectedVaultsLimit; + RELATIVE_SHARE_LIMIT_BP = _relativeShareLimitBP; _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)); @@ -124,27 +143,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 (_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(); @@ -153,21 +185,21 @@ abstract contract VaultHub is PausableUntilWithRoles { bytes32 vaultProxyCodehash = address(_vault).codehash; if (!$.vaultProxyCodehash[vaultProxyCodehash]) revert VaultProxyNotAllowed(_vault); - 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 @@ -226,20 +258,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); @@ -282,17 +314,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); @@ -334,6 +360,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 _refundRecepient The address that will receive the refund for transaction costs + /// @dev When the vault becomes unhealthy, anyone can force its validators to exit the beacon chain + /// This returns the vault's deposited ETH back to vault's balance and allows to rebalance the vault + function forceValidatorExit( + address _vault, + bytes calldata _pubkeys, + address _refundRecepient + ) external payable { + if (msg.value == 0) revert ZeroArgument("msg.value"); + if (_vault == address(0)) revert ZeroArgument("_vault"); + if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); + if (_refundRecepient == address(0)) revert ZeroArgument("_refundRecepient"); + if (_pubkeys.length % PUBLIC_KEY_LENGTH != 0) revert InvalidPubkeysLength(); + _requireUnhealthy(_vault); + + uint256 numValidators = _pubkeys.length / PUBLIC_KEY_LENGTH; + uint64[] memory amounts = new uint64[](numValidators); + + IStakingVault(_vault).triggerValidatorWithdrawal{value: msg.value}(_pubkeys, amounts, _refundRecepient); + + emit ForceValidatorExitTriggered(_vault, _pubkeys, _refundRecepient); + } + function _disconnect(address _vault) internal { VaultSocket storage socket = _connectedSocket(_vault); IStakingVault vault_ = IStakingVault(socket.vault); @@ -343,20 +395,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 // \||/ @@ -380,8 +432,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, @@ -401,7 +453,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, @@ -431,23 +484,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]); } @@ -455,7 +510,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; @@ -474,44 +529,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 refundRecepient); 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); @@ -523,10 +573,14 @@ 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); } diff --git a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol index 9d7106f99..59a12926f 100644 --- a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol +++ b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol @@ -31,23 +31,33 @@ interface IStakingVault { function version() external pure returns(uint64); function getInitializedVersion() external view returns (uint64); function vaultHub() external view returns (address); - function depositContract() external view returns (address); + function nodeOperator() external view returns (address); function locked() external view returns (uint256); function valuation() external view returns (uint256); - function isBalanced() external view returns (bool); function unlocked() external view returns (uint256); function inOutDelta() external view returns (int256); - function beaconChainDepositsPaused() external view returns (bool); - function withdrawalCredentials() external view returns (bytes32); + function fund() external payable; function withdraw(address _recipient, uint256 _ether) external; - function depositToBeaconChain(Deposit[] calldata _deposits) external; - function requestValidatorExit(bytes calldata _pubkeys) external; + function lock(uint256 _locked) external; function rebalance(uint256 _ether) external; - function pauseBeaconChainDeposits() external; - function resumeBeaconChainDeposits() external; function latestReport() external view returns (Report memory); function report(uint256 _valuation, int256 _inOutDelta, uint256 _locked) external; + + function withdrawalCredentials() external view returns (bytes32); + function beaconChainDepositsPaused() external view returns (bool); + function pauseBeaconChainDeposits() external; + function resumeBeaconChainDeposits() external; + function depositToBeaconChain(Deposit[] calldata _deposits) external; + + function requestValidatorExit(bytes calldata _pubkeys) external; + + function 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 982d7c491..8bf1bfa64 100644 --- a/contracts/0.8.9/LidoLocator.sol +++ b/contracts/0.8.9/LidoLocator.sol @@ -30,6 +30,7 @@ contract LidoLocator is ILidoLocator { address oracleDaemonConfig; address accounting; address wstETH; + address vaultHub; } error ZeroAddress(); @@ -50,6 +51,7 @@ contract LidoLocator is ILidoLocator { address public immutable oracleDaemonConfig; address public immutable accounting; address public immutable wstETH; + address public immutable vaultHub; /** * @notice declare service locations @@ -73,6 +75,7 @@ contract LidoLocator is ILidoLocator { oracleDaemonConfig = _assertNonZero(_config.oracleDaemonConfig); accounting = _assertNonZero(_config.accounting); wstETH = _assertNonZero(_config.wstETH); + vaultHub = _assertNonZero(_config.vaultHub); } function coreComponents() external view returns( @@ -99,6 +102,7 @@ contract LidoLocator is ILidoLocator { address, address, address, + address, address ) { return ( @@ -107,7 +111,8 @@ contract LidoLocator is ILidoLocator { burner, withdrawalQueue, postTokenRebaseReceiver, - stakingRouter + stakingRouter, + vaultHub ); } diff --git a/contracts/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 5e5028bb4..8116d7fe9 100644 --- a/contracts/common/interfaces/ILidoLocator.sol +++ b/contracts/common/interfaces/ILidoLocator.sol @@ -22,7 +22,7 @@ interface ILidoLocator { function oracleDaemonConfig() external view returns(address); function accounting() external view returns (address); function wstETH() external view returns (address); - + function vaultHub() external view returns (address); /// @notice Returns core Lido protocol component addresses in a single call /// @dev This function provides a gas-efficient way to fetch multiple component addresses in a single call function coreComponents() external view returns( @@ -42,6 +42,7 @@ interface ILidoLocator { address burner, address withdrawalQueue, address postTokenRebaseReceiver, - address stakingRouter + address stakingRouter, + address vaultHub ); } diff --git a/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/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 e753f0091..8b308d604 100644 --- a/lib/deploy.ts +++ b/lib/deploy.ts @@ -248,6 +248,7 @@ async function getLocatorConfig(locatorAddress: string) { "oracleDaemonConfig", "accounting", "wstETH", + "vaultHub", ] as (keyof LidoLocator.ConfigStruct)[]; const configPromises = addresses.map((name) => locator[name]()); diff --git a/lib/protocol/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 184928f8d..6c5312ce1 100644 --- a/lib/protocol/helpers/accounting.ts +++ b/lib/protocol/helpers/accounting.ts @@ -678,7 +678,6 @@ const reachConsensus = async ( } const { consensusReport } = await hashConsensus.getConsensusState(); - expect(consensusReport).to.equal(reportHash, "Consensus report hash is incorrect"); return submitter as HardhatEthersSigner; 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/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/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/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/vaults/accounting.test.ts b/test/0.8.25/vaults/accounting.test.ts deleted file mode 100644 index 0f9946b19..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(); - - locator = await deployLidoLocator(); - steth = await ethers.deployContract("StETH__HarnessForVaultHub", [holder], { - value: ether("10.0"), - from: deployer, - }); - - // VaultHub - vaultHubImpl = await ethers.deployContract("Accounting", [locator, steth], { 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 7c992170c..e79f7bb27 100644 --- a/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol +++ b/test/0.8.25/vaults/contracts/StakingVault__HarnessForTestUpgrade.sol @@ -19,9 +19,10 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl } uint64 private constant _version = 2; - address public immutable beaconChainDepositContract; VaultHub private immutable VAULT_HUB; + address public immutable DEPOSIT_CONTRACT; + /// keccak256(abi.encode(uint256(keccak256("StakingVault.Vault")) - 1)) & ~bytes32(uint256(0xff)); bytes32 private constant VAULT_STORAGE_LOCATION = 0xe1d42fabaca5dacba3545b34709222773cbdae322fef5b060e1d691bf0169000; @@ -30,7 +31,7 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl if (_vaultHub == address(0)) revert ZeroArgument("_vaultHub"); if (_beaconChainDepositContract == address(0)) revert ZeroArgument("_beaconChainDepositContract"); - beaconChainDepositContract = _beaconChainDepositContract; + DEPOSIT_CONTRACT = _beaconChainDepositContract; VAULT_HUB = VaultHub(_vaultHub); // Prevents reinitialization of the implementation @@ -40,7 +41,7 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl function initialize( address _owner, address _nodeOperator, - bytes calldata _params + bytes calldata // _params ) external reinitializer(_version) { if (owner() != address(0)) { revert VaultAlreadyInitialized(); @@ -68,10 +69,6 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl return _version; } - function depositContract() external view returns (address) { - return beaconChainDepositContract; - } - function latestReport() external view returns (IStakingVault.Report memory) { VaultStorage storage $ = _getVaultStorage(); return IStakingVault.Report({valuation: $.report.valuation, inOutDelta: $.report.inOutDelta}); @@ -85,28 +82,26 @@ contract StakingVault__HarnessForTestUpgrade is IStakingVault, OwnableUpgradeabl function depositToBeaconChain(Deposit[] calldata _deposits) external {} function fund() external payable {} - function inOutDelta() external view returns (int256) { + + function inOutDelta() external pure returns (int256) { return -1; } - function isBalanced() external view returns (bool) { - return true; - } + function nodeOperator() external view returns (address) { return _getVaultStorage().nodeOperator; } function rebalance(uint256 _ether) external {} 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; } @@ -117,16 +112,27 @@ 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; } function pauseBeaconChainDeposits() external {} 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/dashboard/contracts/VaultFactory__MockForDashboard.sol b/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol index caacda986..df6d8ffe6 100644 --- a/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol +++ b/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol @@ -38,6 +38,7 @@ contract VaultFactory__MockForDashboard is UpgradeableBeacon { dashboard.grantRole(dashboard.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), msg.sender); dashboard.grantRole(dashboard.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), msg.sender); dashboard.grantRole(dashboard.REQUEST_VALIDATOR_EXIT_ROLE(), msg.sender); + dashboard.grantRole(dashboard.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 7e7d02ed8..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,7 +37,7 @@ contract VaultHub__MockForDashboard { return vaultSockets[vault]; } - function disconnectVault(address vault) external { + function disconnect(address vault) external { emit Mock__VaultDisconnected(vault); } diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 25cf13c3d..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", () => { @@ -51,9 +52,13 @@ describe("Dashboard.sol", () => { const BP_BASE = 10_000n; + const FEE = 10n; // some withdrawal fee for EIP-7002 + before(async () => { [factoryOwner, vaultOwner, nodeOperator, stranger] = await ethers.getSigners(); + await deployWithdrawalsPreDeployedMock(FEE); + steth = await ethers.deployContract("StETHPermit__HarnessForDashboard"); await steth.mock__setTotalShares(ether("1000000")); await steth.mock__setTotalPooledEther(ether("1400000")); @@ -143,6 +148,13 @@ describe("Dashboard.sol", () => { }); }); + 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 @@ -170,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); @@ -181,7 +193,7 @@ describe("Dashboard.sol", () => { expect(await dashboard.shareLimit()).to.equal(sockets.shareLimit); expect(await dashboard.sharesMinted()).to.equal(sockets.sharesMinted); expect(await dashboard.reserveRatioBP()).to.equal(sockets.reserveRatioBP); - expect(await dashboard.thresholdReserveRatioBP()).to.equal(sockets.reserveRatioThresholdBP); + expect(await dashboard.rebalanceThresholdBP()).to.equal(sockets.rebalanceThresholdBP); expect(await dashboard.treasuryFeeBP()).to.equal(sockets.treasuryFeeBP); }); }); @@ -206,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 }); @@ -227,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 }); @@ -246,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 }); @@ -265,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 }); @@ -292,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; @@ -317,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; @@ -339,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); @@ -359,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; @@ -382,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); @@ -611,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); }); }); @@ -888,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; @@ -1723,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 5108f8b8e..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,7 +16,7 @@ contract VaultHub__MockForDelegation { event Mock__VaultDisconnected(address vault); event Mock__Rebalanced(uint256 amount); - function disconnectVault(address vault) external { + function disconnect(address vault) external { emit Mock__VaultDisconnected(vault); } diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index 158453916..a028d96a7 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -34,7 +34,8 @@ 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 curatorFeeSetter: HardhatEthersSigner; let curatorFeeClaimer: HardhatEthersSigner; @@ -72,7 +73,8 @@ describe("Delegation.sol", () => { rebalancer, depositPauser, depositResumer, - exitRequester, + validatorExitRequester, + validatorWithdrawalTriggerer, disconnecter, curatorFeeSetter, curatorFeeClaimer, @@ -119,7 +121,8 @@ describe("Delegation.sol", () => { rebalancers: [rebalancer], depositPausers: [depositPauser], depositResumers: [depositResumer], - exitRequesters: [exitRequester], + validatorExitRequesters: [validatorExitRequester], + validatorWithdrawalTriggerers: [validatorWithdrawalTriggerer], disconnecters: [disconnecter], curatorFeeSetters: [curatorFeeSetter], curatorFeeClaimers: [curatorFeeClaimer], @@ -210,7 +213,8 @@ describe("Delegation.sol", () => { await assertSoleMember(rebalancer, await delegation.REBALANCE_ROLE()); await assertSoleMember(depositPauser, await delegation.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE()); await assertSoleMember(depositResumer, await delegation.RESUME_BEACON_CHAIN_DEPOSITS_ROLE()); - await assertSoleMember(exitRequester, await delegation.REQUEST_VALIDATOR_EXIT_ROLE()); + await assertSoleMember(validatorExitRequester, await delegation.REQUEST_VALIDATOR_EXIT_ROLE()); + await assertSoleMember(validatorWithdrawalTriggerer, await delegation.TRIGGER_VALIDATOR_WITHDRAWAL_ROLE()); await assertSoleMember(disconnecter, await delegation.VOLUNTARY_DISCONNECT_ROLE()); await assertSoleMember(curatorFeeSetter, await delegation.CURATOR_FEE_SET_ROLE()); await assertSoleMember(curatorFeeClaimer, await delegation.CURATOR_FEE_CLAIM_ROLE()); diff --git a/test/0.8.25/vaults/permissions/permissions.test.ts b/test/0.8.25/vaults/permissions/permissions.test.ts index 2aa692985..9d2359d2d 100644 --- a/test/0.8.25/vaults/permissions/permissions.test.ts +++ b/test/0.8.25/vaults/permissions/permissions.test.ts @@ -71,7 +71,7 @@ describe("Permissions", () => { // 3. Deploy StakingVault implementation stakingVaultImpl = await ethers.deployContract("StakingVault", [vaultHub, depositContract]); expect(await stakingVaultImpl.vaultHub()).to.equal(vaultHub); - expect(await stakingVaultImpl.depositContract()).to.equal(depositContract); + 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]); @@ -559,9 +559,10 @@ describe("Permissions", () => { context("requestValidatorExit()", () => { it("requests a validator exit", async () => { - await expect(permissions.connect(exitRequester).requestValidatorExit("0xabcdef")) - .to.emit(stakingVault, "ValidatorsExitRequest") - .withArgs(permissions, "0xabcdef"); + 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 () => { 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 54% 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 33a465bfe..22731ba04 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, proxify, 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 () => { @@ -108,26 +128,46 @@ describe("StakingVault.sol", () => { }); }); - context("initial state", () => { + context("initial state (getters)", () => { it("returns the correct initial state and constants", async () => { - expect(await stakingVault.version()).to.equal(1n); + expect(await stakingVault.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); @@ -142,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]); @@ -153,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 })) @@ -198,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", () => { @@ -239,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; @@ -289,6 +357,136 @@ 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("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()) @@ -358,7 +556,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 @@ -366,7 +564,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 () => { @@ -380,7 +578,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); @@ -395,128 +593,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); + }); + + 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); + + 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); }); }); @@ -538,41 +932,4 @@ describe("StakingVault.sol", () => { ); }); }); - - async function deployStakingVaultBehindBeaconProxy(): Promise< - [ - StakingVault, - VaultHub__MockForStakingVault, - VaultFactory__MockForStakingVault, - StakingVault, - DepositContract__MockForStakingVault, - ] - > { - // deploying implementation - const vaultHub_ = await ethers.deployContract("VaultHub__MockForStakingVault"); - const depositContract_ = await ethers.deployContract("DepositContract__MockForStakingVault"); - const stakingVaultImplementation_ = await ethers.deployContract("StakingVault", [ - await vaultHub_.getAddress(), - await depositContract_.getAddress(), - ]); - - // deploying factory/beacon - const vaultFactory_ = await ethers.deployContract("VaultFactory__MockForStakingVault", [ - await stakingVaultImplementation_.getAddress(), - ]); - - // deploying beacon proxy - const vaultCreation = await vaultFactory_ - .createVault(await vaultOwner.getAddress(), await operator.getAddress()) - .then((tx) => tx.wait()); - if (!vaultCreation) throw new Error("Vault creation failed"); - const events = findEvents(vaultCreation, "VaultCreated"); - if (events.length != 1) throw new Error("There should be exactly one VaultCreated event"); - const vaultCreatedEvent = events[0]; - - const stakingVault_ = StakingVault__factory.connect(vaultCreatedEvent.args.vault, vaultOwner); - expect(await stakingVault_.owner()).to.equal(await vaultOwner.getAddress()); - - return [stakingVault_, vaultHub_, vaultFactory_, stakingVaultImplementation_, depositContract_]; - } }); diff --git a/test/0.8.25/vaults/vaultFactory.test.ts b/test/0.8.25/vaults/vaultFactory.test.ts index 626f0a5bd..77657d947 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,6 +15,7 @@ import { StETH__HarnessForVaultHub, UpgradeableBeacon, VaultFactory, + VaultHub, WETH9__MockForVault, WstETH__HarnessForVault, } from "typechain-types"; @@ -24,7 +24,7 @@ import { DelegationConfigStruct } from "typechain-types/contracts/0.8.25/vaults/ import { createVaultProxy, days, ether } from "lib"; import { deployLidoLocator } from "test/deploy"; -import { Snapshot } from "test/suite"; +import { Snapshot, VAULTS_CONNECTED_VAULTS_LIMIT, VAULTS_RELATIVE_SHARE_LIMIT_BP } from "test/suite"; describe("VaultFactory.sol", () => { let deployer: HardhatEthersSigner; @@ -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,9 +102,9 @@ describe("VaultFactory.sol", () => { vaultFactory = await ethers.deployContract("VaultFactory", [beacon, delegation], { from: deployer }); //add VAULT_MASTER_ROLE role to allow admin to connect the Vaults to the vault Hub - await accounting.connect(admin).grantRole(await accounting.VAULT_MASTER_ROLE(), admin); + await vaultHub.connect(admin).grantRole(await vaultHub.VAULT_MASTER_ROLE(), admin); //add VAULT_REGISTRY_ROLE role to allow admin to add factory and vault implementation to the hub - await accounting.connect(admin).grantRole(await accounting.VAULT_REGISTRY_ROLE(), admin); + await vaultHub.connect(admin).grantRole(await vaultHub.VAULT_REGISTRY_ROLE(), admin); //the initialize() function cannot be called on a contract await expect(implOld.initialize(stranger, operator, "0x")).to.revertedWithCustomError( @@ -123,7 +128,8 @@ describe("VaultFactory.sol", () => { rebalancers: [await vaultOwner1.getAddress()], depositPausers: [await vaultOwner1.getAddress()], depositResumers: [await vaultOwner1.getAddress()], - exitRequesters: [await vaultOwner1.getAddress()], + validatorExitRequesters: [await vaultOwner1.getAddress()], + validatorWithdrawalTriggerers: [await vaultOwner1.getAddress()], disconnecters: [await vaultOwner1.getAddress()], }; }); @@ -170,9 +176,6 @@ describe("VaultFactory.sol", () => { context("createVaultWithDelegation", () => { it("works with empty `params`", async () => { - console.log({ - delegationParams, - }); const { tx, vault, @@ -198,19 +201,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, }; @@ -232,34 +235,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(); @@ -279,16 +282,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); @@ -312,18 +315,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); }); }); 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..b25b30ce2 --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/contracts/VaultFactory__MockForStakingVault.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity ^0.8.0; + +import {UpgradeableBeacon} from "@openzeppelin/contracts-v5.2/proxy/beacon/UpgradeableBeacon.sol"; +import {BeaconProxy} from "@openzeppelin/contracts-v5.2/proxy/beacon/BeaconProxy.sol"; +import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.sol"; + +contract VaultFactory__MockForVaultHub is UpgradeableBeacon { + event VaultCreated(address indexed vault); + + constructor(address _stakingVaultImplementation) UpgradeableBeacon(_stakingVaultImplementation, msg.sender) {} + + function createVault(address _owner, address _operator) external { + IStakingVault vault = IStakingVault(address(new BeaconProxy(address(this), ""))); + vault.initialize(_owner, _operator, ""); + + emit VaultCreated(address(vault)); + } +} diff --git a/test/0.8.25/vaults/vaulthub/contracts/VaultHub__Harness.sol b/test/0.8.25/vaults/vaulthub/contracts/VaultHub__Harness.sol new file mode 100644 index 000000000..62a6b59ce --- /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 {ILido as IStETH} from "contracts/0.8.25/interfaces/ILido.sol"; + +contract VaultHub__Harness is VaultHub { + constructor( + address _steth, + uint256 _connectedVaultsLimit, + uint256 _relativeShareLimitBP + ) VaultHub(IStETH(_steth), 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..226b3c01f --- /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("_refundRecepient"); + }); + + 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 d8f3ba6f4..12954f62b 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"; @@ -8,14 +9,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; @@ -24,18 +25,22 @@ describe("VaultHub.sol:pausableUntil", () => { before(async () => { [deployer, user, stranger] = await ethers.getSigners(); - const locator = await deployLidoLocator(); steth = await ethers.deployContract("StETH__HarnessForVaultHub", [user], { value: ether("1.0") }); - const vaultHubImpl = await ethers.deployContract("Accounting", [locator, steth]); + const vaultHubImpl = await ethers.deployContract("VaultHub", [ + steth, + ZeroAddress, + VAULTS_CONNECTED_VAULTS_LIMIT, + VAULTS_RELATIVE_SHARE_LIMIT_BP, + ]); const proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()]); - const accounting = await ethers.getContractAt("Accounting", proxy); - await accounting.initialize(deployer); + vaultHubAdmin = await ethers.getContractAt("VaultHub", proxy); + await vaultHubAdmin.initialize(deployer); - vaultHub = await ethers.getContractAt("Accounting", proxy, user); - await accounting.grantRole(await vaultHub.PAUSE_ROLE(), user); - await accounting.grantRole(await vaultHub.RESUME_ROLE(), user); + vaultHub = await ethers.getContractAt("VaultHub", proxy, user); + await vaultHubAdmin.grantRole(await vaultHub.PAUSE_ROLE(), user); + await vaultHubAdmin.grantRole(await vaultHub.RESUME_ROLE(), user); }); beforeEach(async () => (originalState = await Snapshot.take())); diff --git a/test/0.8.9/accounting.handleOracleReport.test.ts b/test/0.8.9/accounting.handleOracleReport.test.ts index 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 c38818a9c..5bf49672a 100644 --- a/test/0.8.9/contracts/LidoLocator__MockForSanityChecker.sol +++ b/test/0.8.9/contracts/LidoLocator__MockForSanityChecker.sol @@ -24,6 +24,7 @@ contract LidoLocator__MockForSanityChecker is ILidoLocator { address oracleDaemonConfig; address accounting; address wstETH; + address vaultHub; } address public immutable lido; @@ -42,7 +43,7 @@ contract LidoLocator__MockForSanityChecker is ILidoLocator { address public immutable oracleDaemonConfig; address public immutable accounting; address public immutable wstETH; - + address public immutable vaultHub; constructor(ContractAddresses memory addresses) { lido = addresses.lido; depositSecurityModule = addresses.depositSecurityModule; @@ -60,20 +61,26 @@ contract LidoLocator__MockForSanityChecker is ILidoLocator { oracleDaemonConfig = addresses.oracleDaemonConfig; accounting = addresses.accounting; wstETH = addresses.wstETH; + vaultHub = addresses.vaultHub; } function coreComponents() external view returns (address, address, address, address, address, address) { return (elRewardsVault, oracleReportSanityChecker, stakingRouter, treasury, withdrawalQueue, withdrawalVault); } - function oracleReportComponents() external view returns (address, address, address, address, address, address) { + function oracleReportComponents() + external + view + returns (address, address, address, address, address, address, address) + { return ( accountingOracle, oracleReportSanityChecker, burner, withdrawalQueue, postTokenRebaseReceiver, - stakingRouter + stakingRouter, + vaultHub ); } } diff --git a/test/0.8.9/contracts/LidoLocator__MockMutable.sol b/test/0.8.9/contracts/LidoLocator__MockMutable.sol index e102d2a4d..a3a31c1c4 100644 --- a/test/0.8.9/contracts/LidoLocator__MockMutable.sol +++ b/test/0.8.9/contracts/LidoLocator__MockMutable.sol @@ -23,6 +23,7 @@ contract LidoLocator__MockMutable is ILidoLocator { address oracleDaemonConfig; address accounting; address wstETH; + address vaultHub; } error ZeroAddress(); @@ -43,7 +44,7 @@ contract LidoLocator__MockMutable is ILidoLocator { address public immutable oracleDaemonConfig; address public immutable accounting; address public immutable wstETH; - + address public immutable vaultHub; /** * @notice declare service locations * @dev accepts a struct to avoid the "stack-too-deep" error @@ -66,20 +67,26 @@ contract LidoLocator__MockMutable is ILidoLocator { oracleDaemonConfig = _assertNonZero(_config.oracleDaemonConfig); accounting = _assertNonZero(_config.accounting); wstETH = _assertNonZero(_config.wstETH); + vaultHub = _assertNonZero(_config.vaultHub); } function coreComponents() external view returns (address, address, address, address, address, address) { return (elRewardsVault, oracleReportSanityChecker, stakingRouter, treasury, withdrawalQueue, withdrawalVault); } - function oracleReportComponents() external view returns (address, address, address, address, address, address) { + function oracleReportComponents() + external + view + returns (address, address, address, address, address, address, address) + { return ( accountingOracle, oracleReportSanityChecker, burner, withdrawalQueue, postTokenRebaseReceiver, - stakingRouter + stakingRouter, + vaultHub ); } diff --git a/test/0.8.9/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 85f782432..3300358d8 100644 --- a/test/0.8.9/lidoLocator.test.ts +++ b/test/0.8.9/lidoLocator.test.ts @@ -22,6 +22,7 @@ const services = [ "oracleDaemonConfig", "accounting", "wstETH", + "vaultHub", ] as const; type ArrayToUnion = A[number]; @@ -92,6 +93,7 @@ describe("LidoLocator.sol", () => { withdrawalQueue, postTokenRebaseReceiver, stakingRouter, + vaultHub, } = config; expect(await locator.oracleReportComponents()).to.deep.equal([ @@ -101,6 +103,7 @@ describe("LidoLocator.sol", () => { withdrawalQueue, postTokenRebaseReceiver, stakingRouter, + vaultHub, ]); }); }); diff --git a/test/0.8.9/sanityChecker/oracleReportSanityChecker.negative-rebase.test.ts b/test/0.8.9/sanityChecker/oracleReportSanityChecker.negative-rebase.test.ts index 977c25343..340180a2b 100644 --- a/test/0.8.9/sanityChecker/oracleReportSanityChecker.negative-rebase.test.ts +++ b/test/0.8.9/sanityChecker/oracleReportSanityChecker.negative-rebase.test.ts @@ -87,6 +87,7 @@ describe("OracleReportSanityChecker.sol:negative-rebase", () => { oracleDaemonConfig: deployer.address, accounting: await accounting.getAddress(), wstETH: deployer.address, + vaultHub: deployer.address, }, ]); diff --git a/test/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/deploy/index.ts b/test/deploy/index.ts index 281dd47ab..a50a8ac8c 100644 --- a/test/deploy/index.ts +++ b/test/deploy/index.ts @@ -5,3 +5,4 @@ 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 e41e54111..cc7d650b9 100644 --- a/test/deploy/locator.ts +++ b/test/deploy/locator.ts @@ -28,8 +28,9 @@ async function deployDummyLocator(config?: Partial, de validatorsExitBusOracle: certainAddress("dummy-locator:validatorsExitBusOracle"), withdrawalQueue: certainAddress("dummy-locator:withdrawalQueue"), withdrawalVault: certainAddress("dummy-locator:withdrawalVault"), - accounting: certainAddress("dummy-locator:withdrawalVault"), + accounting: certainAddress("dummy-locator:accounting"), wstETH: certainAddress("dummy-locator:wstETH"), + vaultHub: certainAddress("dummy-locator:vaultHub"), ...config, }); @@ -106,6 +107,7 @@ async function getLocatorConfig(locatorAddress: string) { "oracleDaemonConfig", "accounting", "wstETH", + "vaultHub", ] as Partial[]; const configPromises = addresses.map((name) => locator[name]()); diff --git a/test/deploy/stakingVault.ts b/test/deploy/stakingVault.ts new file mode 100644 index 000000000..8265714be --- /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()) + .then((tx) => tx.wait()); + if (!vaultCreation) throw new Error("Vault creation failed"); + const events = findEvents(vaultCreation, "VaultCreated"); + + if (events.length != 1) throw new Error("There should be exactly one VaultCreated event"); + const vaultCreatedEvent = events[0]; + + const stakingVault_ = StakingVault__factory.connect(vaultCreatedEvent.args.vault, vaultOwner); + expect(await stakingVault_.owner()).to.equal(await vaultOwner.getAddress()); + expect(await stakingVault_.nodeOperator()).to.equal(await operator.getAddress()); + + return { + depositContract: depositContract_, + stakingVault: stakingVault_, + stakingVaultImplementation: stakingVaultImplementation_, + vaultHub: vaultHub_, + vaultFactory: vaultFactory_, + }; +} diff --git a/test/integration/accounting.integration.ts b/test/integration/core/accounting.integration.ts similarity index 99% rename from test/integration/accounting.integration.ts rename to test/integration/core/accounting.integration.ts index d132b3d93..4896d9837 100644 --- a/test/integration/accounting.integration.ts +++ b/test/integration/core/accounting.integration.ts @@ -6,8 +6,7 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; import { ether, impersonate, log, ONE_GWEI, updateBalance } from "lib"; -import { getProtocolContext, ProtocolContext } from "lib/protocol"; -import { getReportTimeElapsed, report } from "lib/protocol/helpers"; +import { getProtocolContext, getReportTimeElapsed, ProtocolContext, report } from "lib/protocol"; import { Snapshot } from "test/suite"; import { LIMITER_PRECISION_BASE, MAX_BASIS_POINTS, ONE_DAY, SHARE_RATE_PRECISION } from "test/suite/constants"; diff --git a/test/integration/burn-shares.integration.ts b/test/integration/core/burn-shares.integration.ts similarity index 95% rename from test/integration/burn-shares.integration.ts rename to test/integration/core/burn-shares.integration.ts index e4268a9dd..ea05f3dfb 100644 --- a/test/integration/burn-shares.integration.ts +++ b/test/integration/core/burn-shares.integration.ts @@ -5,8 +5,7 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { ether, impersonate, log } from "lib"; -import { getProtocolContext, ProtocolContext } from "lib/protocol"; -import { handleOracleReport } from "lib/protocol/helpers"; +import { getProtocolContext, handleOracleReport, ProtocolContext } from "lib/protocol"; import { bailOnFailure, Snapshot } from "test/suite"; diff --git a/test/integration/protocol-happy-path.integration.ts b/test/integration/core/happy-path.integration.ts similarity index 99% rename from test/integration/protocol-happy-path.integration.ts rename to test/integration/core/happy-path.integration.ts index 7deed7105..9126cb30c 100644 --- a/test/integration/protocol-happy-path.integration.ts +++ b/test/integration/core/happy-path.integration.ts @@ -5,17 +5,17 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { batch, ether, impersonate, log, updateBalance } from "lib"; -import { getProtocolContext, ProtocolContext } from "lib/protocol"; import { finalizeWithdrawalQueue, + getProtocolContext, norEnsureOperators, OracleReportParams, + ProtocolContext, report, sdvtEnsureOperators, -} from "lib/protocol/helpers"; +} from "lib/protocol"; -import { bailOnFailure, Snapshot } from "test/suite"; -import { MAX_DEPOSIT, ZERO_HASH } from "test/suite/constants"; +import { bailOnFailure, MAX_DEPOSIT, Snapshot, ZERO_HASH } from "test/suite"; const AMOUNT = ether("100"); diff --git a/test/integration/negative-rebase.integration.ts b/test/integration/core/negative-rebase.integration.ts similarity index 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 94% rename from test/integration/vaults-happy-path.integration.ts rename to test/integration/vaults/happy-path.integration.ts index 6f1c82bc9..4bcb17052 100644 --- a/test/integration/vaults-happy-path.integration.ts +++ b/test/integration/vaults/happy-path.integration.ts @@ -6,16 +6,16 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { Delegation, StakingVault } from "typechain-types"; -import { computeDepositDataRoot, days, impersonate, log, updateBalance } from "lib"; -import { getProtocolContext, ProtocolContext } from "lib/protocol"; +import { computeDepositDataRoot, days, ether, impersonate, log, updateBalance } from "lib"; import { + getProtocolContext, getReportTimeElapsed, norEnsureOperators, OracleReportParams, + ProtocolContext, report, sdvtEnsureOperators, -} from "lib/protocol/helpers"; -import { ether } from "lib/units"; +} from "lib/protocol"; import { bailOnFailure, Snapshot } from "test/suite"; import { CURATED_MODULE_ID, MAX_DEPOSIT, ONE_DAY, SIMPLE_DVT_MODULE_ID, ZERO_HASH } from "test/suite/constants"; @@ -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; @@ -142,8 +142,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { const _stakingVault = await ethers.getContractAt("StakingVault", implAddress); const _delegation = await ethers.getContractAt("Delegation", delegationAddress); - expect(await _stakingVault.vaultHub()).to.equal(ctx.contracts.accounting.address); - expect(await _stakingVault.depositContract()).to.equal(depositContract); + expect(await _stakingVault.DEPOSIT_CONTRACT()).to.equal(depositContract); expect(await _delegation.STETH()).to.equal(ctx.contracts.lido.address); // TODO: check what else should be validated here @@ -167,7 +166,8 @@ describe("Scenario: Staking Vaults Happy Path", () => { rebalancers: [curator], depositPausers: [curator], depositResumers: [curator], - exitRequesters: [curator], + validatorExitRequesters: [curator], + validatorWithdrawalTriggerers: [curator], disconnecters: [curator], curatorFeeSetters: [curator], curatorFeeClaimers: [curator], @@ -200,11 +200,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 @@ -216,11 +217,11 @@ describe("Scenario: Staking Vaults Happy Path", () => { const agentSigner = await ctx.getSigner("agent"); - await accounting + await vaultHub .connect(agentSigner) - .connectVault(stakingVault, shareLimit, reserveRatio, 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); }); @@ -266,7 +267,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { }); it("Should allow Token Master to mint max stETH", async () => { - const { accounting, lido } = ctx.contracts; + const { vaultHub, lido } = ctx.contracts; // Calculate the max stETH that can be minted on the vault 101 with the given LTV stakingVaultMaxMintingShares = await lido.getSharesByPooledEth( @@ -282,7 +283,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { // Validate minting with the cap const mintOverLimitTx = delegation.connect(curator).mintShares(curator, stakingVaultMaxMintingShares + 1n); await expect(mintOverLimitTx) - .to.be.revertedWithCustomError(accounting, "InsufficientValuationToMint") + .to.be.revertedWithCustomError(vaultHub, "InsufficientValuationToMint") .withArgs(stakingVault, stakingVault.valuation()); const mintTx = await delegation.connect(curator).mintShares(curator, stakingVaultMaxMintingShares); @@ -432,9 +433,9 @@ 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); await delegation.connect(curator).rebalanceVault(sharesMinted, { value: sharesMinted }); 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