diff --git a/contracts/0.8.25/interfaces/ILido.sol b/contracts/0.8.25/interfaces/ILido.sol index faf58a415..61250972d 100644 --- a/contracts/0.8.25/interfaces/ILido.sol +++ b/contracts/0.8.25/interfaces/ILido.sol @@ -16,6 +16,8 @@ interface ILido is IERC20, IERC20Permit { function transferSharesFrom(address, address, uint256) external returns (uint256); + function transferShares(address, uint256) external returns (uint256); + function rebalanceExternalEtherToInternal() external payable; function getTotalPooledEther() external view returns (uint256); diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 5adb4c61f..a00923153 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -1,24 +1,23 @@ +// SPDX-FileCopyrightText: 2025 Lido // SPDX-License-Identifier: GPL-3.0 -// SPDX-FileCopyrightText: 2024 Lido // See contracts/COMPILERS.md pragma solidity 0.8.25; import {Permissions} from "./Permissions.sol"; -import {OwnableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/OwnableUpgradeable.sol"; -import {IERC20} from "@openzeppelin/contracts-v5.2/token/ERC20/IERC20.sol"; -import {IERC20Permit} from "@openzeppelin/contracts-v5.2/token/ERC20/extensions/IERC20Permit.sol"; -import {Clones} from "@openzeppelin/contracts-v5.2/proxy/Clones.sol"; +import {SafeERC20} from "@openzeppelin/contracts-v5.2/token/ERC20/utils/SafeERC20.sol"; import {Math256} from "contracts/common/lib/Math256.sol"; - import {VaultHub} from "./VaultHub.sol"; -import {IStakingVault} from "./interfaces/IStakingVault.sol"; -import {ILido as IStETH} from "../interfaces/ILido.sol"; +import {IERC20} from "@openzeppelin/contracts-v5.2/token/ERC20/IERC20.sol"; +import {IERC721} from "@openzeppelin/contracts-v5.2/token/ERC721/IERC721.sol"; +import {IERC20Permit} from "@openzeppelin/contracts-v5.2/token/ERC20/extensions/IERC20Permit.sol"; +import {ILido as IStETH} from "contracts/0.8.25/interfaces/ILido.sol"; +import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; -interface IWeth is IERC20 { - function withdraw(uint) external; +interface IWETH9 is IERC20 { + function withdraw(uint256) external; function deposit() external payable; } @@ -34,9 +33,7 @@ interface IWstETH is IERC20, IERC20Permit { * @notice This contract is meant to be used as the owner of `StakingVault`. * This contract improves the vault UX by bundling all functions from the vault and vault hub * in this single contract. It provides administrative functions for managing the staking vault, - * including funding, withdrawing, depositing to the beacon chain, minting, burning, and rebalancing operations. - * All these functions are only callable by the account with the DEFAULT_ADMIN_ROLE. - * TODO: need to add recover methods for ERC20, probably in a separate contract + * including funding, withdrawing, minting, burning, and rebalancing operations. */ contract Dashboard is Permissions { /** @@ -65,7 +62,12 @@ contract Dashboard is Permissions { /** * @notice The wETH token contract */ - IWeth public immutable WETH; + IWETH9 public immutable WETH; + + /** + * @notice ETH address convention per EIP-7528 + */ + address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; /** * @notice Struct containing the permit details. @@ -79,26 +81,27 @@ contract Dashboard is Permissions { } /** - * @notice Constructor sets the stETH token address and the implementation contract address. - * @param _stETH Address of the stETH token contract. - * @param _wETH Address of the wETH token contract. - * @param _wstETH Address of the wstETH token contract. + * @notice Constructor sets the stETH, WETH, and WSTETH token addresses. + * @param _wETH Address of the weth token contract. + * @param _lidoLocator Address of the Lido locator contract. */ - constructor(address _stETH, address _wETH, address _wstETH) Permissions() { - if (_stETH == address(0)) revert ZeroArgument("_stETH"); + constructor(address _wETH, address _lidoLocator) Permissions() { if (_wETH == address(0)) revert ZeroArgument("_wETH"); - if (_wstETH == address(0)) revert ZeroArgument("_wstETH"); + if (_lidoLocator == address(0)) revert ZeroArgument("_lidoLocator"); - STETH = IStETH(_stETH); - WETH = IWeth(_wETH); - WSTETH = IWstETH(_wstETH); + WETH = IWETH9(_wETH); + STETH = IStETH(ILidoLocator(_lidoLocator).lido()); + WSTETH = IWstETH(ILidoLocator(_lidoLocator).wstETH()); } /** - * @notice Initializes the contract with the default admin - * and `vaultHub` address + * @notice Initializes the contract with the default admin role */ function initialize(address _defaultAdmin) external virtual { + // reduces gas cost for `mintWsteth` + // invariant: dashboard does not hold stETH on its balance + STETH.approve(address(WSTETH), type(uint256).max); + _initialize(_defaultAdmin); } @@ -133,18 +136,18 @@ contract Dashboard is Permissions { } /** - * @notice Returns the reserve ratio of the vault + * @notice Returns the reserve ratio of the vault in basis points * @return The reserve ratio as a uint16 */ - function reserveRatio() public view returns (uint16) { + function reserveRatioBP() public view returns (uint16) { return vaultSocket().reserveRatioBP; } /** - * @notice Returns the threshold reserve ratio of the vault. + * @notice Returns the threshold reserve ratio of the vault in basis points. * @return The threshold reserve ratio as a uint16. */ - function thresholdReserveRatio() external view returns (uint16) { + function thresholdReserveRatioBP() external view returns (uint16) { return vaultSocket().reserveRatioThresholdBP; } @@ -165,20 +168,20 @@ contract Dashboard is Permissions { } /** - * @notice Returns the total of shares that can be minted on the vault bound by valuation and vault share limit. - * @return The maximum number of stETH shares as a uint256. + * @notice Returns the overall capacity of stETH shares that can be minted by the vault bound by valuation and vault share limit. + * @return The maximum number of mintable stETH shares not counting already minted ones. */ function totalMintableShares() public view returns (uint256) { return _totalMintableShares(stakingVault().valuation()); } /** - * @notice Returns the maximum number of shares that can be minted with deposited ether. - * @param _ether the amount of ether to be funded, can be zero + * @notice Returns the maximum number of shares that can be minted with funded ether. + * @param _etherToFund the amount of ether to be funded, can be zero * @return the maximum number of shares that can be minted by ether */ - function getMintableShares(uint256 _ether) external view returns (uint256) { - uint256 _totalShares = _totalMintableShares(stakingVault().valuation() + _ether); + function projectedNewMintableShares(uint256 _etherToFund) external view returns (uint256) { + uint256 _totalShares = _totalMintableShares(stakingVault().valuation() + _etherToFund); uint256 _sharesMinted = vaultSocket().sharesMinted; if (_totalShares < _sharesMinted) return 0; @@ -189,21 +192,16 @@ contract Dashboard is Permissions { * @notice Returns the amount of ether that can be withdrawn from the staking vault. * @return The amount of ether that can be withdrawn. */ - function getWithdrawableEther() external view returns (uint256) { + function withdrawableEther() external view returns (uint256) { return Math256.min(address(stakingVault()).balance, stakingVault().unlocked()); } - // TODO: add preview view methods for minting and burning - // ==================== Vault Management Functions ==================== /** * @dev Receive function to accept ether */ - // TODO: Consider the amount of ether on balance of the contract - receive() external payable { - if (msg.value == 0) revert ZeroArgument("msg.value"); - } + receive() external payable {} /** * @notice Transfers ownership of the staking vault to a new owner. @@ -234,16 +232,14 @@ contract Dashboard is Permissions { } /** - * @notice Funds the staking vault with wrapped ether. Approvals for the passed amounts should be done before. - * @param _wethAmount Amount of wrapped ether to fund the staking vault with + * @notice Funds the staking vault with wrapped ether. Expects WETH amount approved to this contract. Auth is performed in _fund + * @param _amountOfWETH Amount of wrapped ether to fund the staking vault with */ - function fundByWeth(uint256 _wethAmount) external { - if (WETH.allowance(msg.sender, address(this)) < _wethAmount) revert("ERC20: transfer amount exceeds allowance"); - - WETH.transferFrom(msg.sender, address(this), _wethAmount); - WETH.withdraw(_wethAmount); + function fundWeth(uint256 _amountOfWETH) external { + SafeERC20.safeTransferFrom(WETH, msg.sender, address(this), _amountOfWETH); + WETH.withdraw(_amountOfWETH); - _fund(_wethAmount); + _fund(_amountOfWETH); } /** @@ -258,12 +254,12 @@ contract Dashboard is Permissions { /** * @notice Withdraws stETH tokens from the staking vault to wrapped ether. * @param _recipient Address of the recipient - * @param _ether Amount of ether to withdraw + * @param _amountOfWETH Amount of WETH to withdraw */ - function withdrawToWeth(address _recipient, uint256 _ether) external { - _withdraw(address(this), _ether); - WETH.deposit{value: _ether}(); - WETH.transfer(_recipient, _ether); + function withdrawWETH(address _recipient, uint256 _amountOfWETH) external { + _withdraw(address(this), _amountOfWETH); + WETH.deposit{value: _amountOfWETH}(); + SafeERC20.safeTransfer(WETH, _recipient, _amountOfWETH); } /** @@ -275,56 +271,70 @@ contract Dashboard is Permissions { } /** - * @notice Mints stETH tokens backed by the vault to a recipient. + * @notice Mints stETH tokens backed by the vault to the recipient. * @param _recipient Address of the recipient - * @param _amountOfShares Amount of shares to mint + * @param _amountOfShares Amount of stETH shares to mint */ - function mint(address _recipient, uint256 _amountOfShares) external payable fundAndProceed { - _mint(_recipient, _amountOfShares); + function mintShares(address _recipient, uint256 _amountOfShares) external payable fundAndProceed { + _mintShares(_recipient, _amountOfShares); } /** - * @notice Mints wstETH tokens backed by the vault to a recipient. Approvals for the passed amounts should be done before. + * @notice Mints stETH tokens backed by the vault to the recipient. + * !NB: this will revert with`VaultHub.ZeroArgument("_amountOfShares")` if the amount of stETH is less than 1 share * @param _recipient Address of the recipient - * @param _tokens Amount of tokens to mint + * @param _amountOfStETH Amount of stETH to mint */ - function mintWstETH(address _recipient, uint256 _tokens) external payable fundAndProceed { - _mint(address(this), _tokens); - - STETH.approve(address(WSTETH), _tokens); - uint256 wstETHAmount = WSTETH.wrap(_tokens); - WSTETH.transfer(_recipient, wstETHAmount); + function mintStETH(address _recipient, uint256 _amountOfStETH) external payable virtual fundAndProceed { + _mintShares(_recipient, STETH.getSharesByPooledEth(_amountOfStETH)); } /** - * @notice Burns stETH shares from the sender backed by the vault - * @param _shares Amount of shares to burn + * @notice Mints wstETH tokens backed by the vault to a recipient. + * @param _recipient Address of the recipient + * @param _amountOfWstETH Amount of tokens to mint */ - function burn(uint256 _shares) external { - STETH.transferSharesFrom(msg.sender, address(vaultHub), _shares); - _burn(_shares); + function mintWstETH(address _recipient, uint256 _amountOfWstETH) external payable fundAndProceed { + _mintShares(address(this), _amountOfWstETH); + + uint256 mintedStETH = STETH.getPooledEthBySharesRoundUp(_amountOfWstETH); + + uint256 wrappedWstETH = WSTETH.wrap(mintedStETH); + SafeERC20.safeTransfer(WSTETH, _recipient, wrappedWstETH); } /** - * @notice Burns wstETH tokens from the sender backed by the vault. Approvals for the passed amounts should be done before. - * @param _tokens Amount of wstETH tokens to burn + * @notice Burns stETH shares from the sender backed by the vault. Expects corresponding amount of stETH approved to this contract. + * @param _amountOfShares Amount of stETH shares to burn */ - function burnWstETH(uint256 _tokens) external { - WSTETH.transferFrom(msg.sender, address(this), _tokens); - - uint256 stETHAmount = WSTETH.unwrap(_tokens); + function burnShares(uint256 _amountOfShares) external { + STETH.transferSharesFrom(msg.sender, address(vaultHub), _amountOfShares); + _burnShares(_amountOfShares); + } - STETH.transfer(address(vaultHub), stETHAmount); + /** + * @notice Burns stETH shares from the sender backed by the vault. Expects stETH amount approved to this contract. + * !NB: this will revert with `VaultHub.ZeroArgument("_amountOfShares")` if the amount of stETH is less than 1 share + * @param _amountOfStETH Amount of stETH shares to burn + */ + function burnStETH(uint256 _amountOfStETH) external { + _burnStETH(_amountOfStETH); + } - uint256 sharesAmount = STETH.getSharesByPooledEth(stETHAmount); + /** + * @notice Burns wstETH tokens from the sender backed by the vault. Expects wstETH amount approved to this contract. + * !NB: this will revert with `VaultHub.ZeroArgument("_amountOfShares")` on 1 wei of wstETH due to rounding inside wstETH unwrap method + * @param _amountOfWstETH Amount of wstETH tokens to burn - _burn(sharesAmount); + */ + function burnWstETH(uint256 _amountOfWstETH) external { + _burnWstETH(_amountOfWstETH); } /** * @dev Modifier to check if the permit is successful, and if not, check if the allowance is sufficient */ - modifier trustlessPermit( + modifier safePermit( address token, address owner, address spender, @@ -351,38 +361,46 @@ contract Dashboard is Permissions { return; } } - revert("Permit failure"); + revert InvalidPermit(token); } /** - * @notice Burns stETH tokens from the sender backed by the vault using EIP-2612 Permit. - * @param _tokens Amount of stETH tokens to burn + * @notice Burns stETH tokens (in shares) backed by the vault from the sender using permit (with value in stETH). + * @param _amountOfShares Amount of stETH shares to burn + * @param _permit data required for the stETH.permit() with amount in stETH + */ + function burnSharesWithPermit( + uint256 _amountOfShares, + PermitInput calldata _permit + ) external virtual safePermit(address(STETH), msg.sender, address(this), _permit) { + STETH.transferSharesFrom(msg.sender, address(vaultHub), _amountOfShares); + _burnShares(_amountOfShares); + } + + /** + * @notice Burns stETH tokens backed by the vault from the sender using permit. + * !NB: this will revert with `VaultHub.ZeroArgument("_amountOfShares")` if the amount of stETH is less than 1 share + * @param _amountOfStETH Amount of stETH to burn * @param _permit data required for the stETH.permit() method to set the allowance */ - function burnWithPermit( - uint256 _tokens, + function burnStETHWithPermit( + uint256 _amountOfStETH, PermitInput calldata _permit - ) external trustlessPermit(address(STETH), msg.sender, address(this), _permit) { - _burn(_tokens); + ) external safePermit(address(STETH), msg.sender, address(this), _permit) { + _burnStETH(_amountOfStETH); } /** - * @notice Burns wstETH tokens from the sender backed by the vault using EIP-2612 Permit. - * @param _tokens Amount of wstETH tokens to burn + * @notice Burns wstETH tokens backed by the vault from the sender using EIP-2612 Permit. + * !NB: this will revert with `VaultHub.ZeroArgument("_amountOfShares")` on 1 wei of wstETH due to rounding inside wstETH unwrap method + * @param _amountOfWstETH Amount of wstETH tokens to burn * @param _permit data required for the wstETH.permit() method to set the allowance */ function burnWstETHWithPermit( - uint256 _tokens, + uint256 _amountOfWstETH, PermitInput calldata _permit - ) external trustlessPermit(address(WSTETH), msg.sender, address(this), _permit) { - WSTETH.transferFrom(msg.sender, address(this), _tokens); - uint256 stETHAmount = WSTETH.unwrap(_tokens); - - STETH.transfer(address(vaultHub), stETHAmount); - - uint256 sharesAmount = STETH.getSharesByPooledEth(stETHAmount); - - _burn(sharesAmount); + ) external safePermit(address(WSTETH), msg.sender, address(this), _permit) { + _burnWstETH(_amountOfWstETH); } /** @@ -393,6 +411,43 @@ contract Dashboard is Permissions { _rebalanceVault(_ether); } + /** + * @notice recovers ERC20 tokens or ether from the dashboard contract to sender + * @param _token Address of the token to recover or 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee for ether + * @param _recipient Address of the recovery recipient + */ + function recoverERC20(address _token, address _recipient, uint256 _amount) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (_token == address(0)) revert ZeroArgument("_token"); + if (_recipient == address(0)) revert ZeroArgument("_recipient"); + if (_amount == 0) revert ZeroArgument("_amount"); + + if (_token == ETH) { + (bool success, ) = payable(_recipient).call{value: _amount}(""); + if (!success) revert EthTransferFailed(_recipient, _amount); + } else { + SafeERC20.safeTransfer(IERC20(_token), _recipient, _amount); + } + + emit ERC20Recovered(_recipient, _token, _amount); + } + + /** + * @notice Transfers a given token_id of an ERC721-compatible NFT (defined by the token contract address) + * from the dashboard contract to sender + * + * @param _token an ERC721-compatible token + * @param _tokenId token id to recover + * @param _recipient Address of the recovery recipient + */ + function recoverERC721(address _token, uint256 _tokenId, address _recipient) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (_token == address(0)) revert ZeroArgument("_token"); + if (_recipient == address(0)) revert ZeroArgument("_recipient"); + + IERC721(_token).safeTransferFrom(address(this), _recipient, _tokenId); + + emit ERC721Recovered(_recipient, _token, _tokenId); + } + /** * @notice Pauses beacon chain deposits on the StakingVault. */ @@ -447,6 +502,31 @@ contract Dashboard is Permissions { _; } + /** + + /** + * @dev Burns stETH tokens from the sender backed by the vault + * @param _amountOfStETH Amount of tokens to burn + */ + function _burnStETH(uint256 _amountOfStETH) internal { + uint256 _amountOfShares = STETH.getSharesByPooledEth(_amountOfStETH); + STETH.transferSharesFrom(msg.sender, address(vaultHub), _amountOfShares); + _burnShares(_amountOfShares); + } + + /** + * @dev Burns wstETH tokens from the sender backed by the vault + * @param _amountOfWstETH Amount of tokens to burn + */ + function _burnWstETH(uint256 _amountOfWstETH) internal { + SafeERC20.safeTransferFrom(WSTETH, msg.sender, address(this), _amountOfWstETH); + uint256 unwrappedStETH = WSTETH.unwrap(_amountOfWstETH); + uint256 unwrappedShares = STETH.getSharesByPooledEth(unwrappedStETH); + + STETH.transferShares(address(vaultHub), unwrappedShares); + _burnShares(unwrappedShares); + } + /** * @dev calculates total shares vault can mint * @param _valuation custom vault valuation @@ -457,12 +537,25 @@ contract Dashboard is Permissions { return Math256.min(STETH.getSharesByPooledEth(maxMintableStETH), vaultSocket().shareLimit); } + // ==================== Events ==================== + + /// @notice Emitted when the ERC20 `token` or Ether is recovered (i.e. transferred) + /// @param to The address of the recovery recipient + /// @param token The address of the recovered ERC20 token (zero address for Ether) + /// @param amount The amount of the token recovered + event ERC20Recovered(address indexed to, address indexed token, uint256 amount); + + /// @notice Emitted when the ERC721-compatible `token` (NFT) recovered (i.e. transferred) + /// @param to The address of the recovery recipient + /// @param token The address of the recovered ERC721 token + /// @param tokenId id of token recovered + event ERC721Recovered(address indexed to, address indexed token, uint256 tokenId); + // ==================== Errors ==================== - /** - * @notice Error when the withdrawable amount is insufficient. - * @param withdrawable The amount that is withdrawable - * @param requested The amount requested to withdraw - */ - error InsufficientWithdrawableAmount(uint256 withdrawable, uint256 requested); + /// @notice Error when provided permit is invalid + error InvalidPermit(address token); + + /// @notice Error when recovery of ETH fails on transfer to recipient + error EthTransferFailed(address recipient, uint256 amount); } diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index 3098b9106..a725eaec3 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -84,11 +84,10 @@ contract Delegation is Dashboard { /** * @notice Constructs the contract. * @dev Stores token addresses in the bytecode to reduce gas costs. - * @param _steth Address of the stETH token contract. * @param _weth Address of the weth token contract. - * @param _wsteth Address of the wstETH token contract. + * @param _lidoLocator Address of the Lido locator contract. */ - constructor(address _steth, address _weth, address _wsteth) Dashboard(_steth, _weth, _wsteth) {} + constructor(address _weth, address _lidoLocator) Dashboard(_weth, _lidoLocator) {} /** * @notice Initializes the contract: diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index 479894545..d2c7b31ea 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0 -// SPDX-FileCopyrightText: 2024 Lido +// SPDX-FileCopyrightText: 2025 Lido // See contracts/COMPILERS.md pragma solidity 0.8.25; @@ -121,11 +121,11 @@ abstract contract Permissions is AccessControlVoteable { _unsafeWithdraw(_recipient, _ether); } - function _mint(address _recipient, uint256 _shares) internal onlyRole(MINT_ROLE) { + function _mintShares(address _recipient, uint256 _shares) internal onlyRole(MINT_ROLE) { vaultHub.mintSharesBackedByVault(address(stakingVault()), _recipient, _shares); } - function _burn(uint256 _shares) internal onlyRole(BURN_ROLE) { + function _burnShares(uint256 _shares) internal onlyRole(BURN_ROLE) { vaultHub.burnSharesBackedByVault(address(stakingVault()), _shares); } diff --git a/contracts/0.8.9/Burner.sol b/contracts/0.8.9/Burner.sol index 9439c4e9a..1715b19c7 100644 --- a/contracts/0.8.9/Burner.sol +++ b/contracts/0.8.9/Burner.sol @@ -244,9 +244,9 @@ contract Burner is IBurner, AccessControlEnumerable { if (_amount == 0) revert ZeroRecoveryAmount(); if (_token == address(LIDO)) revert StETHRecoveryWrongFunc(); - emit ERC20Recovered(msg.sender, _token, _amount); - IERC20(_token).safeTransfer(LOCATOR.treasury(), _amount); + + emit ERC20Recovered(msg.sender, _token, _amount); } /** @@ -259,9 +259,9 @@ contract Burner is IBurner, AccessControlEnumerable { function recoverERC721(address _token, uint256 _tokenId) external { if (_token == address(LIDO)) revert StETHRecoveryWrongFunc(); - emit ERC721Recovered(msg.sender, _token, _tokenId); - IERC721(_token).transferFrom(address(this), LOCATOR.treasury(), _tokenId); + + emit ERC721Recovered(msg.sender, _token, _tokenId); } /** diff --git a/scripts/scratch/steps/0145-deploy-vaults.ts b/scripts/scratch/steps/0145-deploy-vaults.ts index ddd879311..2ec9c35b0 100644 --- a/scripts/scratch/steps/0145-deploy-vaults.ts +++ b/scripts/scratch/steps/0145-deploy-vaults.ts @@ -12,8 +12,7 @@ export async function main() { const state = readNetworkState({ deployer }); const accountingAddress = state[Sk.accounting].proxy.address; - const lidoAddress = state[Sk.appLido].proxy.address; - const wstEthAddress = state[Sk.wstETH].address; + const locatorAddress = state[Sk.lidoLocator].proxy.address; const depositContract = state.chainSpec.depositContract; const wethContract = state.delegation.deployParameters.wethContract; @@ -27,9 +26,8 @@ export async function main() { // Deploy Delegation implementation contract const delegation = await deployWithoutProxy(Sk.delegationImpl, "Delegation", deployer, [ - lidoAddress, wethContract, - wstEthAddress, + locatorAddress, ]); const delegationAddress = await delegation.getAddress(); diff --git a/test/0.8.25/vaults/contracts/WETH9__MockForVault.sol b/test/0.8.25/vaults/contracts/WETH9__MockForVault.sol index 59de959c6..736649866 100644 --- a/test/0.8.25/vaults/contracts/WETH9__MockForVault.sol +++ b/test/0.8.25/vaults/contracts/WETH9__MockForVault.sol @@ -3,8 +3,6 @@ pragma solidity 0.4.24; -import {StETH} from "contracts/0.4.24/StETH.sol"; - contract WETH9__MockForVault { string public name = "Wrapped Ether"; string public symbol = "WETH"; diff --git a/test/0.8.25/vaults/dashboard/contracts/ERC721_MockForDashboard.sol b/test/0.8.25/vaults/dashboard/contracts/ERC721_MockForDashboard.sol new file mode 100644 index 000000000..5b696e35c --- /dev/null +++ b/test/0.8.25/vaults/dashboard/contracts/ERC721_MockForDashboard.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +// for testing purposes only + +pragma solidity 0.8.25; + +import {ERC721} from "@openzeppelin/contracts-v5.2/token/ERC721/ERC721.sol"; + +contract ERC721_MockForDashboard is ERC721 { + constructor() ERC721("MockERC721", "M721") {} + + function mint(address _recipient, uint256 _tokenId) external { + _mint(_recipient, _tokenId); + } +} diff --git a/test/0.8.25/vaults/dashboard/contracts/StETHPermit__HarnessForDashboard.sol b/test/0.8.25/vaults/dashboard/contracts/StETHPermit__HarnessForDashboard.sol index 1c9f309b8..fc415a62f 100644 --- a/test/0.8.25/vaults/dashboard/contracts/StETHPermit__HarnessForDashboard.sol +++ b/test/0.8.25/vaults/dashboard/contracts/StETHPermit__HarnessForDashboard.sol @@ -59,8 +59,4 @@ contract StETHPermit__HarnessForDashboard is StETHPermit { function mock__setTotalShares(uint256 _totalShares) external { totalShares = _totalShares; } - - function mock__getTotalShares() external view returns (uint256) { - return _getTotalShares(); - } } 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 2fe95d1b2..2404ca20d 100644 --- a/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol +++ b/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol @@ -28,8 +28,18 @@ contract VaultFactory__MockForDashboard is UpgradeableBeacon { bytes memory immutableArgs = abi.encode(vault); dashboard = Dashboard(payable(Clones.cloneWithImmutableArgs(dashboardImpl, immutableArgs))); - dashboard.initialize(msg.sender); + dashboard.initialize(address(this)); dashboard.grantRole(dashboard.DEFAULT_ADMIN_ROLE(), msg.sender); + dashboard.grantRole(dashboard.FUND_ROLE(), msg.sender); + dashboard.grantRole(dashboard.WITHDRAW_ROLE(), msg.sender); + dashboard.grantRole(dashboard.MINT_ROLE(), msg.sender); + dashboard.grantRole(dashboard.BURN_ROLE(), msg.sender); + dashboard.grantRole(dashboard.REBALANCE_ROLE(), msg.sender); + dashboard.grantRole(dashboard.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), msg.sender); + dashboard.grantRole(dashboard.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), msg.sender); + dashboard.grantRole(dashboard.REQUEST_VALIDATOR_EXIT_ROLE(), msg.sender); + dashboard.grantRole(dashboard.VOLUNTARY_DISCONNECT_ROLE(), msg.sender); + dashboard.revokeRole(dashboard.DEFAULT_ADMIN_ROLE(), address(this)); vault.initialize(address(dashboard), _operator, ""); 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 d962e0e67..95781fb4a 100644 --- a/test/0.8.25/vaults/dashboard/contracts/VaultHub__MockForDashboard.sol +++ b/test/0.8.25/vaults/dashboard/contracts/VaultHub__MockForDashboard.sol @@ -41,12 +41,20 @@ contract VaultHub__MockForDashboard { emit Mock__VaultDisconnected(vault); } - function mintSharesBackedByVault(address /* vault */, address recipient, uint256 amount) external { + function mintSharesBackedByVault(address vault, address recipient, uint256 amount) external { + if (vault == address(0)) revert ZeroArgument("_vault"); + if (recipient == address(0)) revert ZeroArgument("recipient"); + if (amount == 0) revert ZeroArgument("amount"); + steth.mintExternalShares(recipient, amount); + vaultSockets[vault].sharesMinted = uint96(vaultSockets[vault].sharesMinted + amount); } - function burnSharesBackedByVault(address /* vault */, uint256 amount) external { - steth.burnExternalShares(amount); + function burnSharesBackedByVault(address _vault, uint256 _amountOfShares) external { + if (_vault == address(0)) revert ZeroArgument("_vault"); + if (_amountOfShares == 0) revert ZeroArgument("_amountOfShares"); + steth.burnExternalShares(_amountOfShares); + vaultSockets[_vault].sharesMinted = uint96(vaultSockets[_vault].sharesMinted - _amountOfShares); } function voluntaryDisconnect(address _vault) external { @@ -54,6 +62,10 @@ contract VaultHub__MockForDashboard { } function rebalance() external payable { + vaultSockets[msg.sender].sharesMinted = 0; + emit Mock__Rebalanced(msg.value); } + + error ZeroArgument(string argument); } diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 72243d3be..ed0f85440 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -1,6 +1,6 @@ import { expect } from "chai"; import { randomBytes } from "crypto"; -import { ZeroAddress } from "ethers"; +import { MaxUint256, ZeroAddress } from "ethers"; import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; @@ -9,6 +9,8 @@ import { setBalance, time } from "@nomicfoundation/hardhat-network-helpers"; import { Dashboard, DepositContract__MockForStakingVault, + ERC721_MockForDashboard, + LidoLocator, StakingVault, StETHPermit__HarnessForDashboard, VaultFactory__MockForDashboard, @@ -19,9 +21,10 @@ import { import { certainAddress, days, ether, findEvents, signPermit, stethDomain, wstethDomain } from "lib"; +import { deployLidoLocator } from "test/deploy"; import { Snapshot } from "test/suite"; -describe.skip("Dashboard.sol", () => { +describe("Dashboard.sol", () => { let factoryOwner: HardhatEthersSigner; let vaultOwner: HardhatEthersSigner; let nodeOperator: HardhatEthersSigner; @@ -29,15 +32,18 @@ describe.skip("Dashboard.sol", () => { let steth: StETHPermit__HarnessForDashboard; let weth: WETH9__MockForVault; + let erc721: ERC721_MockForDashboard; let wsteth: WstETH__HarnessForVault; let hub: VaultHub__MockForDashboard; let depositContract: DepositContract__MockForStakingVault; let vaultImpl: StakingVault; let dashboardImpl: Dashboard; let factory: VaultFactory__MockForDashboard; + let lidoLocator: LidoLocator; let vault: StakingVault; let dashboard: Dashboard; + let dashboardAddress: string; let originalState: string; @@ -48,15 +54,19 @@ describe.skip("Dashboard.sol", () => { steth = await ethers.deployContract("StETHPermit__HarnessForDashboard"); await steth.mock__setTotalShares(ether("1000000")); - await steth.mock__setTotalPooledEther(ether("1000000")); + await steth.mock__setTotalPooledEther(ether("1400000")); weth = await ethers.deployContract("WETH9__MockForVault"); wsteth = await ethers.deployContract("WstETH__HarnessForVault", [steth]); hub = await ethers.deployContract("VaultHub__MockForDashboard", [steth]); + erc721 = await ethers.deployContract("ERC721_MockForDashboard"); + lidoLocator = await deployLidoLocator({ lido: steth, wstETH: wsteth }); depositContract = await ethers.deployContract("DepositContract__MockForStakingVault"); + vaultImpl = await ethers.deployContract("StakingVault", [hub, depositContract]); expect(await vaultImpl.vaultHub()).to.equal(hub); - dashboardImpl = await ethers.deployContract("Dashboard", [steth, weth, wsteth]); + + dashboardImpl = await ethers.deployContract("Dashboard", [weth, lidoLocator]); expect(await dashboardImpl.STETH()).to.equal(steth); expect(await dashboardImpl.WETH()).to.equal(weth); expect(await dashboardImpl.WSTETH()).to.equal(wsteth); @@ -72,12 +82,14 @@ describe.skip("Dashboard.sol", () => { const vaultCreatedEvents = findEvents(createVaultReceipt, "VaultCreated"); expect(vaultCreatedEvents.length).to.equal(1); + const vaultAddress = vaultCreatedEvents[0].args.vault; vault = await ethers.getContractAt("StakingVault", vaultAddress, vaultOwner); const dashboardCreatedEvents = findEvents(createVaultReceipt, "DashboardCreated"); expect(dashboardCreatedEvents.length).to.equal(1); - const dashboardAddress = dashboardCreatedEvents[0].args.dashboard; + + dashboardAddress = dashboardCreatedEvents[0].args.dashboard; dashboard = await ethers.getContractAt("Dashboard", dashboardAddress, vaultOwner); expect(await dashboard.stakingVault()).to.equal(vault); }); @@ -91,26 +103,20 @@ describe.skip("Dashboard.sol", () => { }); context("constructor", () => { - it("reverts if stETH is zero address", async () => { - await expect(ethers.deployContract("Dashboard", [ethers.ZeroAddress, weth, wsteth])) + it("reverts if LidoLocator is zero address", async () => { + await expect(ethers.deployContract("Dashboard", [weth, ethers.ZeroAddress])) .to.be.revertedWithCustomError(dashboard, "ZeroArgument") - .withArgs("_stETH"); + .withArgs("_lidoLocator"); }); it("reverts if WETH is zero address", async () => { - await expect(ethers.deployContract("Dashboard", [steth, ethers.ZeroAddress, wsteth])) - .to.be.revertedWithCustomError(dashboard, "ZeroArgument") - .withArgs("_WETH"); - }); - - it("reverts if wstETH is zero address", async () => { - await expect(ethers.deployContract("Dashboard", [steth, weth, ethers.ZeroAddress])) + await expect(ethers.deployContract("Dashboard", [ethers.ZeroAddress, lidoLocator])) .to.be.revertedWithCustomError(dashboard, "ZeroArgument") - .withArgs("_wstETH"); + .withArgs("_wETH"); }); it("sets the stETH, wETH, and wstETH addresses", async () => { - const dashboard_ = await ethers.deployContract("Dashboard", [steth, weth, wsteth]); + const dashboard_ = await ethers.deployContract("Dashboard", [weth, lidoLocator]); expect(await dashboard_.STETH()).to.equal(steth); expect(await dashboard_.WETH()).to.equal(weth); expect(await dashboard_.WSTETH()).to.equal(wsteth); @@ -123,7 +129,7 @@ describe.skip("Dashboard.sol", () => { }); it("reverts if called on the implementation", async () => { - const dashboard_ = await ethers.deployContract("Dashboard", [steth, weth, wsteth]); + const dashboard_ = await ethers.deployContract("Dashboard", [weth, lidoLocator]); await expect(dashboard_.initialize(vaultOwner)).to.be.revertedWithCustomError( dashboard_, @@ -134,6 +140,7 @@ describe.skip("Dashboard.sol", () => { context("initialized state", () => { it("post-initialization state is correct", async () => { + // vault state expect(await vault.owner()).to.equal(dashboard); expect(await vault.nodeOperator()).to.equal(nodeOperator); expect(await dashboard.initialized()).to.equal(true); @@ -142,9 +149,12 @@ describe.skip("Dashboard.sol", () => { expect(await dashboard.STETH()).to.equal(steth); expect(await dashboard.WETH()).to.equal(weth); expect(await dashboard.WSTETH()).to.equal(wsteth); + // dashboard roles expect(await dashboard.hasRole(await dashboard.DEFAULT_ADMIN_ROLE(), vaultOwner)).to.be.true; expect(await dashboard.getRoleMemberCount(await dashboard.DEFAULT_ADMIN_ROLE())).to.equal(1); expect(await dashboard.getRoleMember(await dashboard.DEFAULT_ADMIN_ROLE(), 0)).to.equal(vaultOwner); + // dashboard allowance + expect(await steth.allowance(dashboardAddress, wsteth.getAddress())).to.equal(MaxUint256); }); }); @@ -165,12 +175,19 @@ describe.skip("Dashboard.sol", () => { expect(await dashboard.vaultSocket()).to.deep.equal(Object.values(sockets)); expect(await dashboard.shareLimit()).to.equal(sockets.shareLimit); expect(await dashboard.sharesMinted()).to.equal(sockets.sharesMinted); - expect(await dashboard.reserveRatio()).to.equal(sockets.reserveRatioBP); - expect(await dashboard.thresholdReserveRatio()).to.equal(sockets.reserveRatioThresholdBP); + expect(await dashboard.reserveRatioBP()).to.equal(sockets.reserveRatioBP); + expect(await dashboard.thresholdReserveRatioBP()).to.equal(sockets.reserveRatioThresholdBP); expect(await dashboard.treasuryFee()).to.equal(sockets.treasuryFeeBP); }); }); + context("valuation", () => { + it("returns the correct stETH valuation from vault", async () => { + const valuation = await dashboard.valuation(); + expect(valuation).to.equal(await vault.valuation()); + }); + }); + context("totalMintableShares", () => { it("returns the trivial max mintable shares", async () => { const maxShares = await dashboard.totalMintableShares(); @@ -258,9 +275,9 @@ describe.skip("Dashboard.sol", () => { }); }); - context("getMintableShares", () => { + context("projectedNewMintableShares", () => { it("returns trivial can mint shares", async () => { - const canMint = await dashboard.getMintableShares(0n); + const canMint = await dashboard.projectedNewMintableShares(0n); expect(canMint).to.equal(0n); }); @@ -278,13 +295,13 @@ describe.skip("Dashboard.sol", () => { const funding = 1000n; - const preFundCanMint = await dashboard.getMintableShares(funding); + const preFundCanMint = await dashboard.projectedNewMintableShares(funding); await dashboard.fund({ value: funding }); const availableMintableShares = await dashboard.totalMintableShares(); - const canMint = await dashboard.getMintableShares(0n); + const canMint = await dashboard.projectedNewMintableShares(0n); expect(canMint).to.equal(availableMintableShares); expect(canMint).to.equal(preFundCanMint); }); @@ -302,11 +319,11 @@ describe.skip("Dashboard.sol", () => { await hub.mock__setVaultSocket(vault, sockets); const funding = 1000n; - const preFundCanMint = await dashboard.getMintableShares(funding); + const preFundCanMint = await dashboard.projectedNewMintableShares(funding); await dashboard.fund({ value: funding }); - const canMint = await dashboard.getMintableShares(0n); + const canMint = await dashboard.projectedNewMintableShares(0n); expect(canMint).to.equal(0n); // 1000 - 10% - 900 = 0 expect(canMint).to.equal(preFundCanMint); }); @@ -323,10 +340,10 @@ describe.skip("Dashboard.sol", () => { }; await hub.mock__setVaultSocket(vault, sockets); const funding = 1000n; - const preFundCanMint = await dashboard.getMintableShares(funding); + const preFundCanMint = await dashboard.projectedNewMintableShares(funding); await dashboard.fund({ value: funding }); - const canMint = await dashboard.getMintableShares(0n); + const canMint = await dashboard.projectedNewMintableShares(0n); expect(canMint).to.equal(0n); expect(canMint).to.equal(preFundCanMint); }); @@ -344,12 +361,12 @@ describe.skip("Dashboard.sol", () => { await hub.mock__setVaultSocket(vault, sockets); const funding = 2000n; - const preFundCanMint = await dashboard.getMintableShares(funding); + const preFundCanMint = await dashboard.projectedNewMintableShares(funding); await dashboard.fund({ value: funding }); const sharesFunded = await steth.getSharesByPooledEth((funding * (BP_BASE - sockets.reserveRatioBP)) / BP_BASE); - const canMint = await dashboard.getMintableShares(0n); + const canMint = await dashboard.projectedNewMintableShares(0n); expect(canMint).to.equal(sharesFunded - sockets.sharesMinted); expect(canMint).to.equal(preFundCanMint); }); @@ -367,19 +384,19 @@ describe.skip("Dashboard.sol", () => { await hub.mock__setVaultSocket(vault, sockets); const funding = 2000n; - const preFundCanMint = await dashboard.getMintableShares(funding); + const preFundCanMint = await dashboard.projectedNewMintableShares(funding); await dashboard.fund({ value: funding }); - const canMint = await dashboard.getMintableShares(0n); + const canMint = await dashboard.projectedNewMintableShares(0n); expect(canMint).to.equal(0n); expect(canMint).to.equal(preFundCanMint); }); }); - context("getWithdrawableEther", () => { + context("withdrawableEther", () => { it("returns the trivial amount can withdraw ether", async () => { - const getWithdrawableEther = await dashboard.getWithdrawableEther(); - expect(getWithdrawableEther).to.equal(0n); + const withdrawableEther = await dashboard.withdrawableEther(); + expect(withdrawableEther).to.equal(0n); }); it("funds and returns the correct can withdraw ether", async () => { @@ -387,15 +404,15 @@ describe.skip("Dashboard.sol", () => { await dashboard.fund({ value: amount }); - const getWithdrawableEther = await dashboard.getWithdrawableEther(); - expect(getWithdrawableEther).to.equal(amount); + const withdrawableEther = await dashboard.withdrawableEther(); + expect(withdrawableEther).to.equal(amount); }); it("funds and recieves external but and can only withdraw unlocked", async () => { const amount = ether("1"); await dashboard.fund({ value: amount }); await vaultOwner.sendTransaction({ to: vault.getAddress(), value: amount }); - expect(await dashboard.getWithdrawableEther()).to.equal(amount); + expect(await dashboard.withdrawableEther()).to.equal(amount); }); it("funds and get all ether locked and can not withdraw", async () => { @@ -404,7 +421,7 @@ describe.skip("Dashboard.sol", () => { await hub.mock_vaultLock(vault.getAddress(), amount); - expect(await dashboard.getWithdrawableEther()).to.equal(0n); + expect(await dashboard.withdrawableEther()).to.equal(0n); }); it("funds and get all ether locked and can not withdraw", async () => { @@ -413,7 +430,7 @@ describe.skip("Dashboard.sol", () => { await hub.mock_vaultLock(vault.getAddress(), amount); - expect(await dashboard.getWithdrawableEther()).to.equal(0n); + expect(await dashboard.withdrawableEther()).to.equal(0n); }); it("funds and get all half locked and can only half withdraw", async () => { @@ -422,7 +439,7 @@ describe.skip("Dashboard.sol", () => { await hub.mock_vaultLock(vault.getAddress(), amount / 2n); - expect(await dashboard.getWithdrawableEther()).to.equal(amount / 2n); + expect(await dashboard.withdrawableEther()).to.equal(amount / 2n); }); it("funds and get all half locked, but no balance and can not withdraw", async () => { @@ -433,7 +450,7 @@ describe.skip("Dashboard.sol", () => { await setBalance(await vault.getAddress(), 0n); - expect(await dashboard.getWithdrawableEther()).to.equal(0n); + expect(await dashboard.withdrawableEther()).to.equal(0n); }); // TODO: add more tests when the vault params are change @@ -441,9 +458,10 @@ describe.skip("Dashboard.sol", () => { context("transferStVaultOwnership", () => { it("reverts if called by a non-admin", async () => { - await expect(dashboard.connect(stranger).transferStakingVaultOwnership(vaultOwner)) - .to.be.revertedWithCustomError(dashboard, "AccessControlUnauthorizedAccount") - .withArgs(stranger, await dashboard.DEFAULT_ADMIN_ROLE()); + await expect(dashboard.connect(stranger).transferStakingVaultOwnership(vaultOwner)).to.be.revertedWithCustomError( + dashboard, + "NotACommitteeMember", + ); }); it("assigns a new owner to the staking vault", async () => { @@ -455,15 +473,42 @@ describe.skip("Dashboard.sol", () => { }); }); - context("disconnectFromVaultHub", () => { + context("voluntaryDisconnect", () => { it("reverts if called by a non-admin", async () => { await expect(dashboard.connect(stranger).voluntaryDisconnect()) .to.be.revertedWithCustomError(dashboard, "AccessControlUnauthorizedAccount") - .withArgs(stranger, await dashboard.DEFAULT_ADMIN_ROLE()); + .withArgs(stranger, await dashboard.VOLUNTARY_DISCONNECT_ROLE()); + }); + + context("when vault has no debt", () => { + it("disconnects the staking vault from the vault hub", async () => { + await expect(dashboard.voluntaryDisconnect()).to.emit(hub, "Mock__VaultDisconnected").withArgs(vault); + }); }); - it("disconnects the staking vault from the vault hub", async () => { - await expect(dashboard.voluntaryDisconnect()).to.emit(hub, "Mock__VaultDisconnected").withArgs(vault); + context("when vault has debt", () => { + const amountShares = ether("1"); + let amountSteth: bigint; + + before(async () => { + amountSteth = await steth.getPooledEthByShares(amountShares); + }); + + beforeEach(async () => { + await dashboard.mintShares(vaultOwner, amountShares); + }); + + it("reverts on disconnect attempt", async () => { + await expect(dashboard.voluntaryDisconnect()).to.be.reverted; + }); + + it("succeeds with rebalance when providing sufficient ETH", async () => { + await expect(dashboard.voluntaryDisconnect({ value: amountSteth })) + .to.emit(hub, "Mock__Rebalanced") + .withArgs(amountSteth) + .to.emit(hub, "Mock__VaultDisconnected") + .withArgs(vault); + }); }); }); @@ -485,7 +530,7 @@ describe.skip("Dashboard.sol", () => { }); }); - context("fundByWeth", () => { + context("fundWeth", () => { const amount = ether("1"); beforeEach(async () => { @@ -493,7 +538,10 @@ describe.skip("Dashboard.sol", () => { }); it("reverts if called by a non-admin", async () => { - await expect(dashboard.connect(stranger).fundByWeth(ether("1"))).to.be.revertedWithCustomError( + const strangerWeth = weth.connect(stranger); + await strangerWeth.deposit({ value: amount }); + await strangerWeth.approve(dashboard, amount); + await expect(dashboard.connect(stranger).fundWeth(ether("1"))).to.be.revertedWithCustomError( dashboard, "AccessControlUnauthorizedAccount", ); @@ -502,16 +550,14 @@ describe.skip("Dashboard.sol", () => { it("funds by weth", async () => { await weth.connect(vaultOwner).approve(dashboard, amount); - await expect(dashboard.fundByWeth(amount, { from: vaultOwner })) + await expect(dashboard.fundWeth(amount, { from: vaultOwner })) .to.emit(vault, "Funded") .withArgs(dashboard, amount); expect(await ethers.provider.getBalance(vault)).to.equal(amount); }); it("reverts without approval", async () => { - await expect(dashboard.fundByWeth(amount, { from: vaultOwner })).to.be.revertedWith( - "ERC20: transfer amount exceeds allowance", - ); + await expect(dashboard.fundWeth(amount, { from: vaultOwner })).to.be.revertedWithoutReason(); }); }); @@ -536,11 +582,11 @@ describe.skip("Dashboard.sol", () => { }); }); - context("withdrawToWeth", () => { + context("withdrawWeth", () => { const amount = ether("1"); it("reverts if called by a non-admin", async () => { - await expect(dashboard.connect(stranger).withdrawToWeth(vaultOwner, ether("1"))).to.be.revertedWithCustomError( + await expect(dashboard.connect(stranger).withdrawWETH(vaultOwner, ether("1"))).to.be.revertedWithCustomError( dashboard, "AccessControlUnauthorizedAccount", ); @@ -550,7 +596,7 @@ describe.skip("Dashboard.sol", () => { await dashboard.fund({ value: amount }); const previousBalance = await ethers.provider.getBalance(stranger); - await expect(dashboard.withdrawToWeth(stranger, amount)) + await expect(dashboard.withdrawWETH(stranger, amount)) .to.emit(vault, "Withdrawn") .withArgs(dashboard, dashboard, amount); @@ -576,47 +622,94 @@ describe.skip("Dashboard.sol", () => { }); }); - context("mint", () => { + context("mintShares", () => { + const amountShares = ether("1"); + const amountFunded = ether("2"); + let amountSteth: bigint; + + before(async () => { + amountSteth = await steth.getPooledEthByShares(amountShares); + }); + it("reverts if called by a non-admin", async () => { - await expect(dashboard.connect(stranger).mint(vaultOwner, ether("1"))).to.be.revertedWithCustomError( + await expect(dashboard.connect(stranger).mintShares(vaultOwner, ether("1"))).to.be.revertedWithCustomError( dashboard, "AccessControlUnauthorizedAccount", ); }); - it("mints stETH backed by the vault through the vault hub", async () => { - const amount = ether("1"); - await expect(dashboard.mint(vaultOwner, amount)) + it("mints shares backed by the vault through the vault hub", async () => { + await expect(dashboard.mintShares(vaultOwner, amountShares)) .to.emit(steth, "Transfer") - .withArgs(ZeroAddress, vaultOwner, amount) + .withArgs(ZeroAddress, vaultOwner, amountSteth) .and.to.emit(steth, "TransferShares") - .withArgs(ZeroAddress, vaultOwner, amount); + .withArgs(ZeroAddress, vaultOwner, amountShares); - expect(await steth.balanceOf(vaultOwner)).to.equal(amount); + expect(await steth.balanceOf(vaultOwner)).to.equal(amountSteth); }); - it("funds and mints stETH backed by the vault", async () => { - const amount = ether("1"); - await expect(dashboard.mint(vaultOwner, amount, { value: amount })) + it("funds and mints shares backed by the vault", async () => { + await expect(dashboard.mintShares(vaultOwner, amountShares, { value: amountFunded })) .to.emit(vault, "Funded") - .withArgs(dashboard, amount) + .withArgs(dashboard, amountFunded) + .to.emit(steth, "Transfer") + .withArgs(ZeroAddress, vaultOwner, amountSteth) + .and.to.emit(steth, "TransferShares") + .withArgs(ZeroAddress, vaultOwner, amountShares); + }); + }); + + context("mintSteth", () => { + const amountShares = ether("1"); + const amountFunded = ether("2"); + let amountSteth: bigint; + + before(async () => { + amountSteth = await steth.getPooledEthByShares(amountShares); + }); + + it("reverts if called by a non-admin", async () => { + await expect(dashboard.connect(stranger).mintStETH(vaultOwner, amountSteth)).to.be.revertedWithCustomError( + dashboard, + "AccessControlUnauthorizedAccount", + ); + }); + + it("mints steth backed by the vault through the vault hub", async () => { + await expect(dashboard.mintStETH(vaultOwner, amountSteth)) .to.emit(steth, "Transfer") - .withArgs(ZeroAddress, vaultOwner, amount) + .withArgs(ZeroAddress, vaultOwner, amountSteth) .and.to.emit(steth, "TransferShares") - .withArgs(ZeroAddress, vaultOwner, amount); + .withArgs(ZeroAddress, vaultOwner, amountShares); + + expect(await steth.balanceOf(vaultOwner)).to.equal(amountSteth); + }); + + it("funds and mints shares backed by the vault", async () => { + await expect(dashboard.mintStETH(vaultOwner, amountSteth, { value: amountFunded })) + .to.emit(vault, "Funded") + .withArgs(dashboard, amountFunded) + .and.to.emit(steth, "Transfer") + .withArgs(ZeroAddress, vaultOwner, amountSteth) + .and.to.emit(steth, "TransferShares") + .withArgs(ZeroAddress, vaultOwner, amountShares); + }); + + it("cannot mint less stETH than 1 share", async () => { + await expect(dashboard.mintStETH(vaultOwner, 1n)).to.be.revertedWithCustomError(hub, "ZeroArgument"); }); }); context("mintWstETH", () => { - const amount = ether("1"); + const amountWsteth = ether("1"); + let amountSteth: bigint; before(async () => { - await steth.mock__setTotalPooledEther(ether("1000")); - await steth.mock__setTotalShares(ether("1000")); + amountSteth = await steth.getPooledEthByShares(amountWsteth); }); it("reverts if called by a non-admin", async () => { - await expect(dashboard.connect(stranger).mintWstETH(vaultOwner, amount)).to.be.revertedWithCustomError( + await expect(dashboard.connect(stranger).mintWstETH(vaultOwner, amountWsteth)).to.be.revertedWithCustomError( dashboard, "AccessControlUnauthorizedAccount", ); @@ -625,94 +718,242 @@ describe.skip("Dashboard.sol", () => { it("mints wstETH backed by the vault", async () => { const wstethBalanceBefore = await wsteth.balanceOf(vaultOwner); - const result = await dashboard.mintWstETH(vaultOwner, amount); + const result = await dashboard.mintWstETH(vaultOwner, amountWsteth); + + await expect(result).to.emit(steth, "Transfer").withArgs(dashboard, wsteth, amountSteth); + await expect(result).to.emit(wsteth, "Transfer").withArgs(ZeroAddress, dashboard, amountWsteth); - await expect(result).to.emit(steth, "Transfer").withArgs(dashboard, wsteth, amount); - await expect(result).to.emit(wsteth, "Transfer").withArgs(ZeroAddress, dashboard, amount); - await expect(result).to.emit(steth, "Approval").withArgs(dashboard, wsteth, amount); + expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore + amountWsteth); + }); - expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore + amount); + it("reverts on zero mint", async () => { + await expect(dashboard.mintWstETH(vaultOwner, 0n)).to.be.revertedWithCustomError(hub, "ZeroArgument"); }); + + for (let weiWsteth = 1n; weiWsteth <= 10n; weiWsteth++) { + it(`mints ${weiWsteth} wei wsteth`, async () => { + const weiSteth = await steth.getPooledEthBySharesRoundUp(weiWsteth); + const wstethBalanceBefore = await wsteth.balanceOf(vaultOwner); + + const result = await dashboard.mintWstETH(vaultOwner, weiWsteth); + + await expect(result).to.emit(steth, "Transfer").withArgs(dashboard, wsteth, weiSteth); + await expect(result).to.emit(wsteth, "Transfer").withArgs(ZeroAddress, dashboard, weiWsteth); + + expect(await wsteth.balanceOf(dashboard)).to.equal(0n); + expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore + weiWsteth); + }); + } }); - context("burn", () => { + context("burnShares", () => { it("reverts if called by a non-admin", async () => { - await expect(dashboard.connect(stranger).burn(ether("1"))).to.be.revertedWithCustomError( + const amountShares = ether("1"); + const amountSteth = await steth.getPooledEthByShares(amountShares); + await steth.mintExternalShares(stranger, amountShares); + await steth.connect(stranger).approve(dashboard, amountSteth); + + await expect(dashboard.connect(stranger).burnShares(amountShares)).to.be.revertedWithCustomError( dashboard, "AccessControlUnauthorizedAccount", ); }); - it("burns stETH backed by the vault", async () => { - const amount = ether("1"); - await dashboard.mint(vaultOwner, amount); - expect(await steth.balanceOf(vaultOwner)).to.equal(amount); + it("burns shares backed by the vault", async () => { + const amountShares = ether("1"); + const amountSteth = await steth.getPooledEthByShares(amountShares); + await dashboard.mintShares(vaultOwner, amountShares); + expect(await steth.balanceOf(vaultOwner)).to.equal(amountSteth); - await expect(steth.connect(vaultOwner).approve(dashboard, amount)) + await expect(steth.connect(vaultOwner).approve(dashboard, amountSteth)) .to.emit(steth, "Approval") - .withArgs(vaultOwner, dashboard, amount); - expect(await steth.allowance(vaultOwner, dashboard)).to.equal(amount); + .withArgs(vaultOwner, dashboard, amountSteth); + expect(await steth.allowance(vaultOwner, dashboard)).to.equal(amountSteth); - await expect(dashboard.burn(amount)) + await expect(dashboard.burnShares(amountShares)) .to.emit(steth, "Transfer") // transfer from owner to hub - .withArgs(vaultOwner, hub, amount) + .withArgs(vaultOwner, hub, amountSteth) .and.to.emit(steth, "TransferShares") // transfer shares to hub - .withArgs(vaultOwner, hub, amount) + .withArgs(vaultOwner, hub, amountShares) .and.to.emit(steth, "SharesBurnt") // burn - .withArgs(hub, amount, amount, amount); + .withArgs(hub, amountSteth, amountSteth, amountShares); expect(await steth.balanceOf(vaultOwner)).to.equal(0); }); }); + context("burnStETH", () => { + const amountShares = ether("1"); + let amountSteth: bigint; + + beforeEach(async () => { + amountSteth = await steth.getPooledEthByShares(amountShares); + await dashboard.mintStETH(vaultOwner, amountSteth); + }); + + it("reverts if called by a non-admin", async () => { + await steth.mintExternalShares(stranger, amountShares); + await steth.connect(stranger).approve(dashboard, amountSteth); + + await expect(dashboard.connect(stranger).burnStETH(amountSteth)).to.be.revertedWithCustomError( + dashboard, + "AccessControlUnauthorizedAccount", + ); + }); + + it("burns steth backed by the vault", async () => { + expect(await steth.balanceOf(vaultOwner)).to.equal(amountSteth); + + await expect(steth.connect(vaultOwner).approve(dashboard, amountSteth)) + .to.emit(steth, "Approval") + .withArgs(vaultOwner, dashboard, amountSteth); + expect(await steth.allowance(vaultOwner, dashboard)).to.equal(amountSteth); + + await expect(dashboard.burnStETH(amountSteth)) + .to.emit(steth, "Transfer") // transfer from owner to hub + .withArgs(vaultOwner, hub, amountSteth) + .and.to.emit(steth, "TransferShares") // transfer shares to hub + .withArgs(vaultOwner, hub, amountShares) + .and.to.emit(steth, "SharesBurnt") // burn + .withArgs(hub, amountSteth, amountSteth, amountShares); + expect(await steth.balanceOf(vaultOwner)).to.equal(0); + }); + + it("does not allow to burn 1 wei stETH", async () => { + await expect(dashboard.burnStETH(1n)).to.be.revertedWithCustomError(hub, "ZeroArgument"); + }); + }); + context("burnWstETH", () => { - const amount = ether("1"); + const amountWsteth = ether("1"); before(async () => { - // mint steth to the vault owner for the burn - await dashboard.mint(vaultOwner, amount + amount); + // mint shares to the vault owner for the burn + await dashboard.mintShares(vaultOwner, amountWsteth + amountWsteth); }); it("reverts if called by a non-admin", async () => { - await expect(dashboard.connect(stranger).burnWstETH(amount)).to.be.revertedWithCustomError( + // get steth + await steth.mintExternalShares(stranger, amountWsteth + 1000n); + const amountSteth = await steth.getPooledEthByShares(amountWsteth); + // get wsteth + await steth.connect(stranger).approve(wsteth, amountSteth); + await wsteth.connect(stranger).wrap(amountSteth); + // burn + await wsteth.connect(stranger).approve(dashboard, amountWsteth); + await expect(dashboard.connect(stranger).burnWstETH(amountWsteth)).to.be.revertedWithCustomError( dashboard, "AccessControlUnauthorizedAccount", ); }); - it("burns wstETH backed by the vault", async () => { + it("burns shares backed by the vault", async () => { + const amountSteth = await steth.getPooledEthBySharesRoundUp(amountWsteth); // approve for wsteth wrap - await steth.connect(vaultOwner).approve(wsteth, amount); + await steth.connect(vaultOwner).approve(wsteth, amountSteth); // wrap steth to wsteth to get the amount of wsteth for the burn - await wsteth.connect(vaultOwner).wrap(amount); + await wsteth.connect(vaultOwner).wrap(amountSteth); // user flow const wstethBalanceBefore = await wsteth.balanceOf(vaultOwner); const stethBalanceBefore = await steth.balanceOf(vaultOwner); // approve wsteth to dashboard contract - await wsteth.connect(vaultOwner).approve(dashboard, amount); + await wsteth.connect(vaultOwner).approve(dashboard, amountWsteth); - const result = await dashboard.burnWstETH(amount); + const result = await dashboard.burnWstETH(amountWsteth); - await expect(result).to.emit(wsteth, "Transfer").withArgs(vaultOwner, dashboard, amount); // transfer wsteth to dashboard - await expect(result).to.emit(steth, "Transfer").withArgs(wsteth, dashboard, amount); // unwrap wsteth to steth - await expect(result).to.emit(wsteth, "Transfer").withArgs(dashboard, ZeroAddress, amount); // burn wsteth + await expect(result).to.emit(wsteth, "Transfer").withArgs(vaultOwner, dashboard, amountWsteth); // transfer wsteth to dashboard + await expect(result).to.emit(steth, "Transfer").withArgs(wsteth, dashboard, amountSteth); // unwrap wsteth to steth + await expect(result).to.emit(wsteth, "Transfer").withArgs(dashboard, ZeroAddress, amountWsteth); // burn wsteth - await expect(result).to.emit(steth, "Transfer").withArgs(dashboard, hub, amount); // transfer steth to hub - await expect(result).to.emit(steth, "TransferShares").withArgs(dashboard, hub, amount); // transfer shares to hub - await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amount, amount, amount); // burn steth (mocked event data) + await expect(result).to.emit(steth, "Transfer").withArgs(dashboard, hub, amountSteth); // transfer steth to hub + await expect(result).to.emit(steth, "TransferShares").withArgs(dashboard, hub, amountWsteth); // transfer shares to hub + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountSteth, amountSteth, amountWsteth); // burn steth (mocked event data) expect(await steth.balanceOf(vaultOwner)).to.equal(stethBalanceBefore); - expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore - amount); + expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore - amountWsteth); + }); + + it("reverts on zero burn", async () => { + await expect(dashboard.burnWstETH(0n)).to.be.revertedWith("wstETH: zero amount unwrap not allowed"); + }); + + it(`burns 1-10 wei wsteth with different share rate `, async () => { + const baseTotalEther = ether("1000000"); + await steth.mock__setTotalPooledEther(baseTotalEther); + await steth.mock__setTotalShares(baseTotalEther); + + const wstethContract = await wsteth.connect(vaultOwner); + + const totalEtherStep = baseTotalEther / 10n; + const totalEtherMax = baseTotalEther * 2n; + + for (let totalEther = baseTotalEther; totalEther <= totalEtherMax; totalEther += totalEtherStep) { + for (let weiShare = 1n; weiShare <= 20n; weiShare++) { + await steth.mock__setTotalPooledEther(totalEther); + + // this is only used for correct steth value when wrapping to receive share==wsteth + const weiStethUp = await steth.getPooledEthBySharesRoundUp(weiShare); + // steth value actually used by wsteth inside the contract + const weiStethDown = await steth.getPooledEthByShares(weiShare); + // this share amount that is returned from wsteth on unwrap + // because wsteth eats 1 share due to "rounding" (being a hungry-hungry wei gobler) + const weiShareDown = await steth.getSharesByPooledEth(weiStethDown); + // steth value occuring only in events when rounding down from weiShareDown + const weiStethDownDown = await steth.getPooledEthByShares(weiShareDown); + + // approve for wsteth wrap + await steth.connect(vaultOwner).approve(wsteth, weiStethUp); + // wrap steth to wsteth to get the amount of wsteth for the burn + await wstethContract.wrap(weiStethUp); + + expect(await wsteth.balanceOf(vaultOwner)).to.equal(weiShare); + const stethBalanceBefore = await steth.balanceOf(vaultOwner); + + // approve wsteth to dashboard contract + await wstethContract.approve(dashboard, weiShare); + + // reverts when rounding to zero + // this condition is excessive but illustrative + if (weiShareDown === 0n && weiShare == 1n) { + await expect(dashboard.burnWstETH(weiShare)).to.be.revertedWithCustomError(hub, "ZeroArgument"); + // clean up wsteth + await wstethContract.transfer(stranger, await wstethContract.balanceOf(vaultOwner)); + continue; + } + + const result = await dashboard.burnWstETH(weiShare); + + // transfer wsteth from sender + await expect(result).to.emit(wsteth, "Transfer").withArgs(vaultOwner, dashboard, weiShare); // transfer wsteth to dashboard + // unwrap wsteth to steth + await expect(result).to.emit(steth, "Transfer").withArgs(wsteth, dashboard, weiStethDown); // unwrap wsteth to steth + await expect(result).to.emit(wsteth, "Transfer").withArgs(dashboard, ZeroAddress, weiShare); // burn wsteth + // transfer shares to hub + await expect(result).to.emit(steth, "Transfer").withArgs(dashboard, hub, weiStethDownDown); + await expect(result).to.emit(steth, "TransferShares").withArgs(dashboard, hub, weiShareDown); + // burn shares in the hub + await expect(result) + .to.emit(steth, "SharesBurnt") + .withArgs(hub, weiStethDownDown, weiStethDownDown, weiShareDown); + + expect(await steth.balanceOf(vaultOwner)).to.equal(stethBalanceBefore); + + // no dust left over + expect(await wsteth.balanceOf(vaultOwner)).to.equal(0n); + } + } }); }); - context("burnWithPermit", () => { - const amount = ether("1"); + context("burnSharesWithPermit", () => { + const amountShares = ether("1"); + let amountSteth: bigint; before(async () => { // mint steth to the vault owner for the burn - await dashboard.mint(vaultOwner, amount); + await dashboard.mintShares(vaultOwner, amountShares); + amountSteth = await steth.getPooledEthBySharesRoundUp(amountShares); }); beforeEach(async () => { @@ -721,10 +962,35 @@ describe.skip("Dashboard.sol", () => { }); it("reverts if called by a non-admin", async () => { + await steth.mintExternalShares(stranger, amountShares); const permit = { - owner: await vaultOwner.address, - spender: String(dashboard.target), - value: amount, + owner: stranger.address, + spender: dashboardAddress, + value: amountSteth, + nonce: await steth.nonces(stranger), + deadline: BigInt(await time.latest()) + days(1n), + }; + + const signature = await signPermit(await stethDomain(steth), permit, stranger); + const { deadline, value } = permit; + const { v, r, s } = signature; + + await expect( + dashboard.connect(stranger).burnSharesWithPermit(amountShares, { + value, + deadline, + v, + r, + s, + }), + ).to.be.revertedWithCustomError(dashboard, "AccessControlUnauthorizedAccount"); + }); + + it("reverts if the permit is invalid", async () => { + const permit = { + owner: vaultOwner.address, + spender: stranger.address, // invalid spender + value: amountSteth, nonce: await steth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), }; @@ -734,7 +1000,181 @@ describe.skip("Dashboard.sol", () => { const { v, r, s } = signature; await expect( - dashboard.connect(stranger).burnWithPermit(amount, { + dashboard.connect(vaultOwner).burnSharesWithPermit(amountShares, { + value, + deadline, + v, + r, + s, + }), + ).to.be.revertedWithCustomError(dashboard, "InvalidPermit"); + }); + + it("burns shares with permit", async () => { + const permit = { + owner: vaultOwner.address, + spender: dashboardAddress, + value: amountSteth, + nonce: await steth.nonces(vaultOwner), + deadline: BigInt(await time.latest()) + days(1n), + }; + + const signature = await signPermit(await stethDomain(steth), permit, vaultOwner); + const { deadline, value } = permit; + const { v, r, s } = signature; + + const balanceBefore = await steth.balanceOf(vaultOwner); + const result = await dashboard.connect(vaultOwner).burnSharesWithPermit(amountShares, { + value, + deadline, + v, + r, + s, + }); + + await expect(result).to.emit(steth, "Approval").withArgs(vaultOwner, dashboard, amountSteth); // approve steth from vault owner to dashboard + await expect(result).to.emit(steth, "Transfer").withArgs(vaultOwner, hub, amountSteth); // transfer steth to hub + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountSteth, amountSteth, amountShares); // burn steth + + expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - amountSteth); + }); + + it("succeeds if has allowance", async () => { + const permit = { + owner: vaultOwner.address, + spender: stranger.address, // invalid spender + value: amountSteth, + nonce: (await steth.nonces(vaultOwner)) + 1n, // invalid nonce + deadline: BigInt(await time.latest()) + days(1n), + }; + + const signature = await signPermit(await stethDomain(steth), permit, vaultOwner); + const { deadline, value } = permit; + const { v, r, s } = signature; + const permitData = { + value, + deadline, + v, + r, + s, + }; + + await expect( + dashboard.connect(vaultOwner).burnSharesWithPermit(amountShares, permitData), + ).to.be.revertedWithCustomError(dashboard, "InvalidPermit"); + + await steth.connect(vaultOwner).approve(dashboard, amountSteth); + + const balanceBefore = await steth.balanceOf(vaultOwner); + const result = await dashboard.connect(vaultOwner).burnSharesWithPermit(amountShares, permitData); + + await expect(result).to.emit(steth, "Transfer").withArgs(vaultOwner, hub, amountSteth); // transfer steth to hub + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountSteth, amountSteth, amountShares); // burn steth + + expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - amountSteth); + }); + + it("succeeds with rebalanced shares - 1 share = 0.5 steth", async () => { + await steth.mock__setTotalShares(ether("1000000")); + await steth.mock__setTotalPooledEther(ether("500000")); + const sharesToBurn = ether("1"); + const stethToBurn = sharesToBurn / 2n; // 1 share = 0.5 steth + + const permit = { + owner: vaultOwner.address, + spender: dashboardAddress, + value: stethToBurn, + nonce: await steth.nonces(vaultOwner), + deadline: BigInt(await time.latest()) + days(1n), + }; + + const signature = await signPermit(await stethDomain(steth), permit, vaultOwner); + const { deadline, value } = permit; + const { v, r, s } = signature; + const permitData = { + value, + deadline, + v, + r, + s, + }; + + const balanceBefore = await steth.balanceOf(vaultOwner); + const result = await dashboard.connect(vaultOwner).burnSharesWithPermit(amountShares, permitData); + + await expect(result).to.emit(steth, "Transfer").withArgs(vaultOwner, hub, stethToBurn); // transfer steth to hub + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, stethToBurn, stethToBurn, sharesToBurn); // burn steth + + expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - stethToBurn); + }); + + it("succeeds with rebalanced shares - 1 share = 2 stETH", async () => { + await steth.mock__setTotalShares(ether("500000")); + await steth.mock__setTotalPooledEther(ether("1000000")); + const sharesToBurn = ether("1"); + const stethToBurn = sharesToBurn * 2n; // 1 share = 2 steth + + const permit = { + owner: vaultOwner.address, + spender: dashboardAddress, + value: stethToBurn, + nonce: await steth.nonces(vaultOwner), + deadline: BigInt(await time.latest()) + days(1n), + }; + + const signature = await signPermit(await stethDomain(steth), permit, vaultOwner); + const { deadline, value } = permit; + const { v, r, s } = signature; + const permitData = { + value, + deadline, + v, + r, + s, + }; + + const balanceBefore = await steth.balanceOf(vaultOwner); + const result = await dashboard.connect(vaultOwner).burnSharesWithPermit(amountShares, permitData); + + await expect(result).to.emit(steth, "Transfer").withArgs(vaultOwner, hub, stethToBurn); // transfer steth to hub + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, stethToBurn, stethToBurn, sharesToBurn); // burn steth + + expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - stethToBurn); + }); + }); + + context("burnStETHWithPermit", () => { + const amountShares = ether("1"); + let amountSteth: bigint; + + before(async () => { + // mint steth to the vault owner for the burn + await dashboard.mintShares(vaultOwner, amountShares); + amountSteth = await steth.getPooledEthBySharesRoundUp(amountShares); + }); + + beforeEach(async () => { + const eip712helper = await ethers.deployContract("EIP712StETH", [steth]); + await steth.initializeEIP712StETH(eip712helper); + }); + + it("reverts if called by a non-admin", async () => { + await steth.mintExternalShares(stranger, amountShares); + + const permit = { + owner: stranger.address, + spender: dashboardAddress, + value: amountSteth, + nonce: await steth.nonces(stranger), + deadline: BigInt(await time.latest()) + days(1n), + }; + + const signature = await signPermit(await stethDomain(steth), permit, stranger); + const { deadline, value } = permit; + const { v, r, s } = signature; + + await expect( + dashboard.connect(stranger).burnStETHWithPermit(amountSteth, { value, deadline, v, @@ -746,9 +1186,9 @@ describe.skip("Dashboard.sol", () => { it("reverts if the permit is invalid", async () => { const permit = { - owner: await vaultOwner.address, + owner: vaultOwner.address, spender: stranger.address, // invalid spender - value: amount, + value: amountSteth, nonce: await steth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), }; @@ -758,21 +1198,21 @@ describe.skip("Dashboard.sol", () => { const { v, r, s } = signature; await expect( - dashboard.connect(vaultOwner).burnWithPermit(amount, { + dashboard.connect(vaultOwner).burnStETHWithPermit(amountSteth, { value, deadline, v, r, s, }), - ).to.be.revertedWith("Permit failure"); + ).to.be.revertedWithCustomError(dashboard, "InvalidPermit"); }); - it("burns stETH with permit", async () => { + it("burns shares with permit", async () => { const permit = { - owner: await vaultOwner.address, - spender: String(dashboard.target), - value: amount, + owner: vaultOwner.address, + spender: dashboardAddress, + value: amountSteth, nonce: await steth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), }; @@ -782,7 +1222,7 @@ describe.skip("Dashboard.sol", () => { const { v, r, s } = signature; const balanceBefore = await steth.balanceOf(vaultOwner); - const result = await dashboard.connect(vaultOwner).burnWithPermit(amount, { + const result = await dashboard.connect(vaultOwner).burnStETHWithPermit(amountSteth, { value, deadline, v, @@ -790,18 +1230,18 @@ describe.skip("Dashboard.sol", () => { s, }); - await expect(result).to.emit(steth, "Approval").withArgs(vaultOwner, dashboard, amount); // approve steth from vault owner to dashboard - await expect(result).to.emit(steth, "Transfer").withArgs(vaultOwner, hub, amount); // transfer steth to hub - await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amount, amount, amount); // burn steth + await expect(result).to.emit(steth, "Approval").withArgs(vaultOwner, dashboard, amountSteth); // approve steth from vault owner to dashboard + await expect(result).to.emit(steth, "Transfer").withArgs(vaultOwner, hub, amountSteth); // transfer steth to hub + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountSteth, amountSteth, amountShares); // burn steth - expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - amount); + expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - amountSteth); }); it("succeeds if has allowance", async () => { const permit = { - owner: await vaultOwner.address, - spender: String(dashboard.target), // invalid spender - value: amount, + owner: vaultOwner.address, + spender: stranger.address, // invalid spender + value: amountSteth, nonce: (await steth.nonces(vaultOwner)) + 1n, // invalid nonce deadline: BigInt(await time.latest()) + days(1n), }; @@ -817,49 +1257,123 @@ describe.skip("Dashboard.sol", () => { s, }; - await expect(dashboard.connect(vaultOwner).burnWithPermit(amount, permitData)).to.be.revertedWith( - "Permit failure", - ); + await expect( + dashboard.connect(vaultOwner).burnStETHWithPermit(amountSteth, permitData), + ).to.be.revertedWithCustomError(dashboard, "InvalidPermit"); - await steth.connect(vaultOwner).approve(dashboard, amount); + await steth.connect(vaultOwner).approve(dashboard, amountSteth); const balanceBefore = await steth.balanceOf(vaultOwner); - const result = await dashboard.connect(vaultOwner).burnWithPermit(amount, permitData); + const result = await dashboard.connect(vaultOwner).burnStETHWithPermit(amountSteth, permitData); - await expect(result).to.emit(steth, "Transfer").withArgs(vaultOwner, hub, amount); // transfer steth to hub - await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amount, amount, amount); // burn steth + await expect(result).to.emit(steth, "Transfer").withArgs(vaultOwner, hub, amountSteth); // transfer steth to hub + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountSteth, amountSteth, amountShares); // burn steth - expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - amount); + expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - amountSteth); + }); + + it("succeeds with rebalanced shares - 1 share = 0.5 steth", async () => { + await steth.mock__setTotalShares(ether("1000000")); + await steth.mock__setTotalPooledEther(ether("500000")); + const sharesToBurn = ether("1"); + const stethToBurn = sharesToBurn / 2n; // 1 share = 0.5 steth + + const permit = { + owner: vaultOwner.address, + spender: dashboardAddress, + value: stethToBurn, + nonce: await steth.nonces(vaultOwner), + deadline: BigInt(await time.latest()) + days(1n), + }; + + const signature = await signPermit(await stethDomain(steth), permit, vaultOwner); + const { deadline, value } = permit; + const { v, r, s } = signature; + const permitData = { + value, + deadline, + v, + r, + s, + }; + + const balanceBefore = await steth.balanceOf(vaultOwner); + const result = await dashboard.connect(vaultOwner).burnStETHWithPermit(stethToBurn, permitData); + + await expect(result).to.emit(steth, "Transfer").withArgs(vaultOwner, hub, stethToBurn); // transfer steth to hub + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, stethToBurn, stethToBurn, sharesToBurn); // burn steth + + expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - stethToBurn); + }); + + it("succeeds with rebalanced shares - 1 share = 2 stETH", async () => { + await steth.mock__setTotalShares(ether("500000")); + await steth.mock__setTotalPooledEther(ether("1000000")); + const sharesToBurn = ether("1"); + const stethToBurn = sharesToBurn * 2n; // 1 share = 2 steth + + const permit = { + owner: vaultOwner.address, + spender: dashboardAddress, + value: stethToBurn, + nonce: await steth.nonces(vaultOwner), + deadline: BigInt(await time.latest()) + days(1n), + }; + + const signature = await signPermit(await stethDomain(steth), permit, vaultOwner); + const { deadline, value } = permit; + const { v, r, s } = signature; + const permitData = { + value, + deadline, + v, + r, + s, + }; + + const balanceBefore = await steth.balanceOf(vaultOwner); + const result = await dashboard.connect(vaultOwner).burnStETHWithPermit(stethToBurn, permitData); + + await expect(result).to.emit(steth, "Transfer").withArgs(vaultOwner, hub, stethToBurn); // transfer steth to hub + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, stethToBurn, stethToBurn, sharesToBurn); // burn steth + + expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - stethToBurn); }); }); context("burnWstETHWithPermit", () => { - const amount = ether("1"); + const amountShares = ether("1"); + let amountSteth: bigint; beforeEach(async () => { + amountSteth = await steth.getPooledEthBySharesRoundUp(amountShares); // mint steth to the vault owner for the burn - await dashboard.mint(vaultOwner, amount); + await dashboard.mintShares(vaultOwner, amountShares); // approve for wsteth wrap - await steth.connect(vaultOwner).approve(wsteth, amount); + await steth.connect(vaultOwner).approve(wsteth, amountSteth); // wrap steth to wsteth to get the amount of wsteth for the burn - await wsteth.connect(vaultOwner).wrap(amount); + await wsteth.connect(vaultOwner).wrap(amountSteth); }); it("reverts if called by a non-admin", async () => { + await dashboard.mintShares(stranger, amountShares + 100n); + await steth.connect(stranger).approve(wsteth, amountSteth + 100n); + await wsteth.connect(stranger).wrap(amountSteth + 100n); + const permit = { - owner: await vaultOwner.address, - spender: String(dashboard.target), - value: amount, - nonce: await wsteth.nonces(vaultOwner), + owner: stranger.address, + spender: dashboardAddress, + value: amountShares, + nonce: await wsteth.nonces(stranger), deadline: BigInt(await time.latest()) + days(1n), }; - const signature = await signPermit(await wstethDomain(wsteth), permit, vaultOwner); + const signature = await signPermit(await wstethDomain(wsteth), permit, stranger); const { deadline, value } = permit; const { v, r, s } = signature; await expect( - dashboard.connect(stranger).burnWithPermit(amount, { + dashboard.connect(stranger).burnWstETHWithPermit(amountShares, { value, deadline, v, @@ -871,9 +1385,9 @@ describe.skip("Dashboard.sol", () => { it("reverts if the permit is invalid", async () => { const permit = { - owner: await vaultOwner.address, + owner: vaultOwner.address, spender: stranger.address, // invalid spender - value: amount, + value: amountShares, nonce: await wsteth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), }; @@ -883,21 +1397,21 @@ describe.skip("Dashboard.sol", () => { const { v, r, s } = signature; await expect( - dashboard.connect(vaultOwner).burnWstETHWithPermit(amount, { + dashboard.connect(vaultOwner).burnWstETHWithPermit(amountShares, { value, deadline, v, r, s, }), - ).to.be.revertedWith("Permit failure"); + ).to.be.revertedWithCustomError(dashboard, "InvalidPermit"); }); it("burns wstETH with permit", async () => { const permit = { - owner: await vaultOwner.address, - spender: String(dashboard.target), - value: amount, + owner: vaultOwner.address, + spender: dashboardAddress, + value: amountShares, nonce: await wsteth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), }; @@ -908,7 +1422,8 @@ describe.skip("Dashboard.sol", () => { const wstethBalanceBefore = await wsteth.balanceOf(vaultOwner); const stethBalanceBefore = await steth.balanceOf(vaultOwner); - const result = await dashboard.connect(vaultOwner).burnWstETHWithPermit(amount, { + + const result = await dashboard.connect(vaultOwner).burnWstETHWithPermit(amountShares, { value, deadline, v, @@ -916,20 +1431,20 @@ describe.skip("Dashboard.sol", () => { s, }); - await expect(result).to.emit(wsteth, "Approval").withArgs(vaultOwner, dashboard, amount); // approve steth from vault owner to dashboard - await expect(result).to.emit(wsteth, "Transfer").withArgs(vaultOwner, dashboard, amount); // transfer steth to dashboard - await expect(result).to.emit(steth, "Transfer").withArgs(wsteth, dashboard, amount); // uwrap wsteth to steth - await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amount, amount, amount); // burn steth + await expect(result).to.emit(wsteth, "Approval").withArgs(vaultOwner, dashboard, amountShares); // approve steth from vault owner to dashboard + await expect(result).to.emit(wsteth, "Transfer").withArgs(vaultOwner, dashboard, amountShares); // transfer steth to dashboard + await expect(result).to.emit(steth, "Transfer").withArgs(wsteth, dashboard, amountSteth); // uwrap wsteth to steth + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountSteth, amountSteth, amountShares); // burn steth expect(await steth.balanceOf(vaultOwner)).to.equal(stethBalanceBefore); - expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore - amount); + expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore - amountShares); }); it("succeeds if has allowance", async () => { const permit = { - owner: await vaultOwner.address, - spender: String(dashboard.target), // invalid spender - value: amount, + owner: vaultOwner.address, + spender: dashboardAddress, // invalid spender + value: amountShares, nonce: (await wsteth.nonces(vaultOwner)) + 1n, // invalid nonce deadline: BigInt(await time.latest()) + days(1n), }; @@ -945,22 +1460,94 @@ describe.skip("Dashboard.sol", () => { s, }; - await expect(dashboard.connect(vaultOwner).burnWstETHWithPermit(amount, permitData)).to.be.revertedWith( - "Permit failure", - ); + await expect( + dashboard.connect(vaultOwner).burnWstETHWithPermit(amountShares, permitData), + ).to.be.revertedWithCustomError(dashboard, "InvalidPermit"); - await wsteth.connect(vaultOwner).approve(dashboard, amount); + await wsteth.connect(vaultOwner).approve(dashboard, amountShares); const wstethBalanceBefore = await wsteth.balanceOf(vaultOwner); const stethBalanceBefore = await steth.balanceOf(vaultOwner); - const result = await dashboard.connect(vaultOwner).burnWstETHWithPermit(amount, permitData); + const result = await dashboard.connect(vaultOwner).burnWstETHWithPermit(amountShares, permitData); - await expect(result).to.emit(wsteth, "Transfer").withArgs(vaultOwner, dashboard, amount); // transfer steth to dashboard - await expect(result).to.emit(steth, "Transfer").withArgs(wsteth, dashboard, amount); // uwrap wsteth to steth - await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amount, amount, amount); // burn steth + await expect(result).to.emit(wsteth, "Transfer").withArgs(vaultOwner, dashboard, amountShares); // transfer steth to dashboard + await expect(result).to.emit(steth, "Transfer").withArgs(wsteth, dashboard, amountSteth); // uwrap wsteth to steth + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountSteth, amountSteth, amountShares); // burn steth expect(await steth.balanceOf(vaultOwner)).to.equal(stethBalanceBefore); - expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore - amount); + expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore - amountShares); + }); + + it("succeeds with rebalanced shares - 1 share = 0.5 stETH", async () => { + await steth.mock__setTotalShares(ether("1000000")); + await steth.mock__setTotalPooledEther(ether("500000")); + const sharesToBurn = ether("1"); + const stethToBurn = sharesToBurn / 2n; // 1 share = 0.5 steth + + const permit = { + owner: vaultOwner.address, + spender: dashboardAddress, + value: sharesToBurn, + nonce: await wsteth.nonces(vaultOwner), + deadline: BigInt(await time.latest()) + days(1n), + }; + + const signature = await signPermit(await wstethDomain(wsteth), permit, vaultOwner); + const { deadline, value } = permit; + const { v, r, s } = signature; + + const wstethBalanceBefore = await wsteth.balanceOf(vaultOwner); + const stethBalanceBefore = await steth.balanceOf(vaultOwner); + const result = await dashboard.connect(vaultOwner).burnWstETHWithPermit(sharesToBurn, { + value, + deadline, + v, + r, + s, + }); + + await expect(result).to.emit(wsteth, "Transfer").withArgs(vaultOwner, dashboard, sharesToBurn); // transfer steth to dashboard + await expect(result).to.emit(steth, "Transfer").withArgs(wsteth, dashboard, stethToBurn); // uwrap wsteth to steth + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, stethToBurn, stethToBurn, sharesToBurn); // burn steth + + expect(await steth.balanceOf(vaultOwner)).to.equal(stethBalanceBefore); + expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore - sharesToBurn); + }); + + it("succeeds with rebalanced shares - 1 share = 2 stETH", async () => { + await steth.mock__setTotalShares(ether("500000")); + await steth.mock__setTotalPooledEther(ether("1000000")); + const sharesToBurn = ether("1"); + const stethToBurn = sharesToBurn * 2n; // 1 share = 2 steth + + const permit = { + owner: vaultOwner.address, + spender: dashboardAddress, + value: sharesToBurn, + nonce: await wsteth.nonces(vaultOwner), + deadline: BigInt(await time.latest()) + days(1n), + }; + + const signature = await signPermit(await wstethDomain(wsteth), permit, vaultOwner); + const { deadline, value } = permit; + const { v, r, s } = signature; + + const wstethBalanceBefore = await wsteth.balanceOf(vaultOwner); + const stethBalanceBefore = await steth.balanceOf(vaultOwner); + const result = await dashboard.connect(vaultOwner).burnWstETHWithPermit(sharesToBurn, { + value, + deadline, + v, + r, + s, + }); + + await expect(result).to.emit(wsteth, "Transfer").withArgs(vaultOwner, dashboard, sharesToBurn); // transfer steth to dashboard + await expect(result).to.emit(steth, "Transfer").withArgs(wsteth, dashboard, stethToBurn); // uwrap wsteth to steth + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, stethToBurn, stethToBurn, sharesToBurn); // burn steth + + expect(await steth.balanceOf(vaultOwner)).to.equal(stethBalanceBefore); + expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore - sharesToBurn); }); }); @@ -989,6 +1576,103 @@ describe.skip("Dashboard.sol", () => { }); }); + context("recover", async () => { + const amount = ether("1"); + + before(async () => { + const wethContract = weth.connect(vaultOwner); + + await wethContract.deposit({ value: amount }); + + await vaultOwner.sendTransaction({ to: dashboardAddress, value: amount }); + await wethContract.transfer(dashboardAddress, amount); + await erc721.mint(dashboardAddress, 0); + + expect(await ethers.provider.getBalance(dashboardAddress)).to.equal(amount); + expect(await wethContract.balanceOf(dashboardAddress)).to.equal(amount); + expect(await erc721.ownerOf(0)).to.equal(dashboardAddress); + }); + + it("allows only admin to recover", async () => { + await expect(dashboard.connect(stranger).recoverERC20(ZeroAddress, vaultOwner, 1n)).to.be.revertedWithCustomError( + dashboard, + "AccessControlUnauthorizedAccount", + ); + await expect( + dashboard.connect(stranger).recoverERC721(erc721.getAddress(), 0, vaultOwner), + ).to.be.revertedWithCustomError(dashboard, "AccessControlUnauthorizedAccount"); + }); + + it("does not allow zero token address for erc20 recovery", async () => { + await expect(dashboard.recoverERC20(ZeroAddress, vaultOwner, 1n)).to.be.revertedWithCustomError( + dashboard, + "ZeroArgument", + ); + await expect(dashboard.recoverERC20(weth, ZeroAddress, 1n)).to.be.revertedWithCustomError( + dashboard, + "ZeroArgument", + ); + await expect(dashboard.recoverERC20(weth, vaultOwner, 0n)).to.be.revertedWithCustomError( + dashboard, + "ZeroArgument", + ); + }); + + it("recovers all ether", async () => { + const ethStub = await dashboard.ETH(); + const preBalance = await ethers.provider.getBalance(vaultOwner); + const tx = await dashboard.recoverERC20(ethStub, vaultOwner, amount); + const { gasUsed, gasPrice } = (await ethers.provider.getTransactionReceipt(tx.hash))!; + + await expect(tx).to.emit(dashboard, "ERC20Recovered").withArgs(tx.from, ethStub, amount); + expect(await ethers.provider.getBalance(dashboardAddress)).to.equal(0); + expect(await ethers.provider.getBalance(vaultOwner)).to.equal(preBalance + amount - gasUsed * gasPrice); + }); + + it("recovers all weth", async () => { + const preBalance = await weth.balanceOf(vaultOwner); + const tx = await dashboard.recoverERC20(weth.getAddress(), vaultOwner, amount); + + await expect(tx) + .to.emit(dashboard, "ERC20Recovered") + .withArgs(tx.from, await weth.getAddress(), amount); + expect(await weth.balanceOf(dashboardAddress)).to.equal(0); + expect(await weth.balanceOf(vaultOwner)).to.equal(preBalance + amount); + }); + + it("does not allow zero token address for erc721 recovery", async () => { + await expect(dashboard.recoverERC721(ZeroAddress, 0, vaultOwner)).to.be.revertedWithCustomError( + dashboard, + "ZeroArgument", + ); + }); + + it("recovers erc721", async () => { + const tx = await dashboard.recoverERC721(erc721.getAddress(), 0, vaultOwner); + + await expect(tx) + .to.emit(dashboard, "ERC721Recovered") + .withArgs(tx.from, await erc721.getAddress(), 0); + + expect(await erc721.ownerOf(0)).to.equal(vaultOwner.address); + }); + }); + + context("fallback behavior", () => { + const amount = ether("1"); + + it("does not allow fallback behavior", async () => { + const tx = vaultOwner.sendTransaction({ to: dashboardAddress, data: "0x111111111111", value: amount }); + await expect(tx).to.be.revertedWithoutReason(); + }); + + it("allows ether to be recieved", async () => { + const preBalance = await weth.balanceOf(dashboardAddress); + await vaultOwner.sendTransaction({ to: dashboardAddress, value: amount }); + expect(await ethers.provider.getBalance(dashboardAddress)).to.equal(amount + preBalance); + }); + }); + context("pauseBeaconChainDeposits", () => { it("reverts if the caller is not a curator", async () => { await expect(dashboard.connect(stranger).pauseBeaconChainDeposits()).to.be.revertedWithCustomError( 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 111585c20..3a49e852b 100644 --- a/test/0.8.25/vaults/delegation/contracts/VaultHub__MockForDelegation.sol +++ b/test/0.8.25/vaults/delegation/contracts/VaultHub__MockForDelegation.sol @@ -20,11 +20,11 @@ contract VaultHub__MockForDelegation { emit Mock__VaultDisconnected(vault); } - function mintSharesBackedByVault(address, address recipient, uint256 amount) external { + function mintSharesBackedByVault(address /* vault */, address recipient, uint256 amount) external { steth.mint(recipient, amount); } - function burnSharesBackedByVault(address, uint256 amount) external { + function burnSharesBackedByVault(address /* vault */, uint256 amount) external { steth.burn(amount); } diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index be4e67b05..7b4651a2b 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -7,6 +7,7 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { Delegation, DepositContract__MockForStakingVault, + LidoLocator, StakingVault, StETH__MockForDelegation, UpgradeableBeacon, @@ -18,6 +19,7 @@ import { import { advanceChainTime, certainAddress, days, ether, findEvents, getNextBlockTimestamp, impersonate } from "lib"; +import { deployLidoLocator } from "test/deploy"; import { Snapshot } from "test/suite"; const BP_BASE = 10000n; @@ -43,6 +45,7 @@ describe("Delegation.sol", () => { let rewarder: HardhatEthersSigner; const recipient = certainAddress("some-recipient"); + let lidoLocator: LidoLocator; let steth: StETH__MockForDelegation; let weth: WETH9__MockForVault; let wsteth: WstETH__HarnessForVault; @@ -81,8 +84,9 @@ describe("Delegation.sol", () => { weth = await ethers.deployContract("WETH9__MockForVault"); wsteth = await ethers.deployContract("WstETH__HarnessForVault", [steth]); hub = await ethers.deployContract("VaultHub__MockForDelegation", [steth]); + lidoLocator = await deployLidoLocator({ lido: steth, wstETH: wsteth }); - delegationImpl = await ethers.deployContract("Delegation", [steth, weth, wsteth]); + delegationImpl = await ethers.deployContract("Delegation", [weth, lidoLocator]); expect(await delegationImpl.WETH()).to.equal(weth); expect(await delegationImpl.STETH()).to.equal(steth); expect(await delegationImpl.WSTETH()).to.equal(wsteth); @@ -148,26 +152,22 @@ describe("Delegation.sol", () => { context("constructor", () => { it("reverts if stETH is zero address", async () => { - await expect(ethers.deployContract("Delegation", [ethers.ZeroAddress, weth, wsteth])) + await expect(ethers.deployContract("Delegation", [weth, ethers.ZeroAddress])) .to.be.revertedWithCustomError(delegation, "ZeroArgument") - .withArgs("_stETH"); + .withArgs("_lidoLocator"); }); it("reverts if wETH is zero address", async () => { - await expect(ethers.deployContract("Delegation", [steth, ethers.ZeroAddress, wsteth])) + await expect(ethers.deployContract("Delegation", [ethers.ZeroAddress, lidoLocator])) .to.be.revertedWithCustomError(delegation, "ZeroArgument") .withArgs("_wETH"); }); - it("reverts if wstETH is zero address", async () => { - await expect(ethers.deployContract("Delegation", [steth, weth, ethers.ZeroAddress])) - .to.be.revertedWithCustomError(delegation, "ZeroArgument") - .withArgs("_wstETH"); - }); - it("sets the stETH address", async () => { - const delegation_ = await ethers.deployContract("Delegation", [steth, weth, wsteth]); + const delegation_ = await ethers.deployContract("Delegation", [weth, lidoLocator]); expect(await delegation_.STETH()).to.equal(steth); + expect(await delegation_.WETH()).to.equal(weth); + expect(await delegation_.WSTETH()).to.equal(wsteth); }); }); @@ -177,7 +177,7 @@ describe("Delegation.sol", () => { }); it("reverts if called on the implementation", async () => { - const delegation_ = await ethers.deployContract("Delegation", [steth, weth, wsteth]); + const delegation_ = await ethers.deployContract("Delegation", [weth, lidoLocator]); await expect(delegation_.initialize(vaultOwner)).to.be.revertedWithCustomError( delegation_, @@ -468,7 +468,7 @@ describe("Delegation.sol", () => { context("mint", () => { it("reverts if the caller is not a member of the token master role", async () => { - await expect(delegation.connect(stranger).mint(recipient, 1n)).to.be.revertedWithCustomError( + await expect(delegation.connect(stranger).mintShares(recipient, 1n)).to.be.revertedWithCustomError( delegation, "AccessControlUnauthorizedAccount", ); @@ -476,7 +476,7 @@ describe("Delegation.sol", () => { it("mints the tokens", async () => { const amount = 100n; - await expect(delegation.connect(minter).mint(recipient, amount)) + await expect(delegation.connect(minter).mintShares(recipient, amount)) .to.emit(steth, "Transfer") .withArgs(ethers.ZeroAddress, recipient, amount); }); @@ -485,9 +485,9 @@ describe("Delegation.sol", () => { context("burn", () => { it("reverts if the caller is not a member of the token master role", async () => { await delegation.connect(funder).fund({ value: ether("1") }); - await delegation.connect(minter).mint(stranger, 100n); + await delegation.connect(minter).mintShares(stranger, 100n); - await expect(delegation.connect(stranger).burn(100n)).to.be.revertedWithCustomError( + await expect(delegation.connect(stranger).burnShares(100n)).to.be.revertedWithCustomError( delegation, "AccessControlUnauthorizedAccount", ); @@ -495,9 +495,9 @@ describe("Delegation.sol", () => { it("burns the tokens", async () => { const amount = 100n; - await delegation.connect(minter).mint(burner, amount); + await delegation.connect(minter).mintShares(burner, amount); - await expect(delegation.connect(burner).burn(amount)) + await expect(delegation.connect(burner).burnShares(amount)) .to.emit(steth, "Transfer") .withArgs(burner, hub, amount) .and.to.emit(steth, "Transfer") diff --git a/test/0.8.25/vaults/vaultFactory.test.ts b/test/0.8.25/vaults/vaultFactory.test.ts index 9cdb5c60f..0b5a55ccd 100644 --- a/test/0.8.25/vaults/vaultFactory.test.ts +++ b/test/0.8.25/vaults/vaultFactory.test.ts @@ -61,17 +61,22 @@ describe("VaultFactory.sol", () => { before(async () => { [deployer, admin, holder, operator, stranger, vaultOwner1, vaultOwner2] = await ethers.getSigners(); - locator = await deployLidoLocator(); steth = await ethers.deployContract("StETH__HarnessForVaultHub", [holder], { value: ether("10.0"), from: deployer, }); weth = await ethers.deployContract("WETH9__MockForVault"); wsteth = await ethers.deployContract("WstETH__HarnessForVault", [steth]); + + locator = await deployLidoLocator({ + lido: steth, + wstETH: wsteth, + }); + depositContract = await ethers.deployContract("DepositContract__MockForBeaconChainDepositor", deployer); // Accounting - accountingImpl = await ethers.deployContract("Accounting", [locator, steth], { from: deployer }); + 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); @@ -88,7 +93,7 @@ describe("VaultFactory.sol", () => { vaultBeaconProxy = await ethers.deployContract("BeaconProxy", [beacon, "0x"]); vaultBeaconProxyCode = await ethers.provider.getCode(await vaultBeaconProxy.getAddress()); - delegation = await ethers.deployContract("Delegation", [steth, weth, wsteth], { from: deployer }); + delegation = await ethers.deployContract("Delegation", [weth, locator], { from: deployer }); 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 diff --git a/test/integration/vaults-happy-path.integration.ts b/test/integration/vaults-happy-path.integration.ts index f5d88b15e..59df58cb4 100644 --- a/test/integration/vaults-happy-path.integration.ts +++ b/test/integration/vaults-happy-path.integration.ts @@ -285,12 +285,12 @@ describe("Scenario: Staking Vaults Happy Path", () => { }); // Validate minting with the cap - const mintOverLimitTx = delegation.connect(curator).mint(curator, stakingVaultMaxMintingShares + 1n); + const mintOverLimitTx = delegation.connect(curator).mintShares(curator, stakingVaultMaxMintingShares + 1n); await expect(mintOverLimitTx) .to.be.revertedWithCustomError(accounting, "InsufficientValuationToMint") .withArgs(stakingVault, stakingVault.valuation()); - const mintTx = await delegation.connect(curator).mint(curator, stakingVaultMaxMintingShares); + const mintTx = await delegation.connect(curator).mintShares(curator, stakingVaultMaxMintingShares); const mintTxReceipt = await trace("delegation.mint", mintTx); const mintEvents = ctx.getEvents(mintTxReceipt, "MintedSharesOnVault"); @@ -423,7 +423,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { .approve(delegation, await lido.getPooledEthByShares(stakingVaultMaxMintingShares)); await trace("lido.approve", approveVaultTx); - const burnTx = await delegation.connect(curator).burn(stakingVaultMaxMintingShares); + const burnTx = await delegation.connect(curator).burnShares(stakingVaultMaxMintingShares); await trace("delegation.burn", burnTx); const { elapsedProtocolReward, elapsedVaultReward } = await calculateReportParams();