From b6156adc15222ae2667e07d52e6e130e89185dfc Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Thu, 26 Dec 2024 14:04:41 +0700 Subject: [PATCH 01/36] feat: dashboard token recovery --- contracts/0.8.25/vaults/Dashboard.sol | 16 +++++++- .../0.8.25/vaults/dashboard/dashboard.test.ts | 39 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 3bb4c8ddf..c6239f76a 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -409,6 +409,19 @@ contract Dashboard is AccessControlEnumerable { _rebalanceVault(_ether); } + /** + * @notice recovers ERC20 tokens or ether from the vault + * @param _token Address of the token to recover, 0 for ether + */ + function recover(address _token) external payable virtual onlyRole(DEFAULT_ADMIN_ROLE) { + if (_token == address(0)) { + payable(msg.sender).transfer(address(this).balance); + } else { + bool success = IERC20(_token).transfer(msg.sender, IERC20(_token).balanceOf(address(this))); + if (!success) revert("ERC20: Transfer failed"); + } + } + // ==================== Internal Functions ==================== /** @@ -502,7 +515,8 @@ contract Dashboard is AccessControlEnumerable { * @param _valuation custom vault valuation */ function _totalMintableShares(uint256 _valuation) internal view returns (uint256) { - uint256 maxMintableStETH = (_valuation * (TOTAL_BASIS_POINTS - vaultSocket().reserveRatioBP)) / TOTAL_BASIS_POINTS; + uint256 maxMintableStETH = (_valuation * (TOTAL_BASIS_POINTS - vaultSocket().reserveRatioBP)) / + TOTAL_BASIS_POINTS; return Math256.min(STETH.getSharesByPooledEth(maxMintableStETH), vaultSocket().shareLimit); } diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index f678a6c92..ca56322eb 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -992,4 +992,43 @@ describe("Dashboard", () => { .withArgs(amount); }); }); + + context("recover", async () => { + const amount = ether("1"); + + before(async () => { + const wethContract = weth.connect(vaultOwner); + + await wethContract.deposit({ value: amount }); + + await vaultOwner.sendTransaction({ to: dashboard.getAddress(), value: amount }); + await wethContract.transfer(dashboard.getAddress(), amount); + + expect(await ethers.provider.getBalance(dashboard.getAddress())).to.equal(amount); + expect(await wethContract.balanceOf(dashboard.getAddress())).to.equal(amount); + }); + + it("allows only admin to recover", async () => { + await expect(dashboard.connect(stranger).recover(ZeroAddress)).to.be.revertedWithCustomError( + dashboard, + "AccessControlUnauthorizedAccount", + ); + }); + + it("recovers all ether", async () => { + const preBalance = await ethers.provider.getBalance(vaultOwner); + const tx = await dashboard.recover(ZeroAddress); + const { gasUsed, gasPrice } = (await ethers.provider.getTransactionReceipt(tx.hash))!; + + expect(await ethers.provider.getBalance(dashboard.getAddress())).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); + await dashboard.recover(weth.getAddress()); + expect(await weth.balanceOf(dashboard.getAddress())).to.equal(0); + expect(await weth.balanceOf(vaultOwner)).to.equal(preBalance + amount); + }); + }); }); From a11d6b6790091ffeb2d590d9e6038b24dea1c597 Mon Sep 17 00:00:00 2001 From: DiRaiks Date: Thu, 9 Jan 2025 12:47:21 +0300 Subject: [PATCH 02/36] feat: add locator, update burn/mint methods, fixes, tests --- contracts/0.8.25/interfaces/ILido.sol | 2 + contracts/0.8.25/vaults/Dashboard.sol | 112 ++++++++-------- contracts/0.8.25/vaults/Delegation.sol | 11 +- .../vaults/contracts/WETH9__MockForVault.sol | 2 - .../LidoLocator__HarnessForDashboard.sol | 26 ++++ .../0.8.25/vaults/dashboard/dashboard.test.ts | 120 ++++++++++++++---- 6 files changed, 180 insertions(+), 93 deletions(-) create mode 100644 test/0.8.25/vaults/dashboard/contracts/LidoLocator__HarnessForDashboard.sol diff --git a/contracts/0.8.25/interfaces/ILido.sol b/contracts/0.8.25/interfaces/ILido.sol index 14d65ec5a..1e7043510 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 3bb4c8ddf..6d467c359 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -6,18 +6,18 @@ pragma solidity 0.8.25; import {AccessControlEnumerable} from "@openzeppelin/contracts-v5.0.2/access/extensions/AccessControlEnumerable.sol"; import {OwnableUpgradeable} from "contracts/openzeppelin/5.0.2/upgradeable/access/OwnableUpgradeable.sol"; -import {IERC20} from "@openzeppelin/contracts-v5.0.2/token/ERC20/IERC20.sol"; -import {IERC20Permit} from "@openzeppelin/contracts-v5.0.2/token/ERC20/extensions/IERC20Permit.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.0.2/token/ERC20/IERC20.sol"; +import {IERC20Permit} from "@openzeppelin/contracts-v5.0.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"; +import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.sol"; -interface IWeth is IERC20 { - function withdraw(uint) external; +interface IWETH9 is IERC20 { + function withdraw(uint256) external; function deposit() external payable; } @@ -54,7 +54,7 @@ contract Dashboard is AccessControlEnumerable { IWstETH public immutable WSTETH; /// @notice The wrapped ether token contract - IWeth public immutable WETH; + IWETH9 public immutable WETH; /// @notice The underlying `StakingVault` contract IStakingVault public stakingVault; @@ -71,20 +71,18 @@ contract Dashboard is AccessControlEnumerable { } /** - * @notice Constructor sets the stETH token address and the implementation contract address. - * @param _stETH Address of the stETH token contract. + * @notice Constructor sets the stETH, WETH, and WSTETH token addresses. * @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) { - if (_stETH == address(0)) revert ZeroArgument("_stETH"); + constructor(address _weth, address _lidoLocator) { if (_weth == address(0)) revert ZeroArgument("_WETH"); - if (_wstETH == address(0)) revert ZeroArgument("_wstETH"); + if (_lidoLocator == address(0)) revert ZeroArgument("_lidoLocator"); _SELF = address(this); - STETH = IStETH(_stETH); - WETH = IWeth(_weth); - WSTETH = IWstETH(_wstETH); + WETH = IWETH9(_weth); + STETH = IStETH(ILidoLocator(_lidoLocator).lido()); + WSTETH = IWstETH(ILidoLocator(_lidoLocator).wstETH()); } /** @@ -109,6 +107,9 @@ contract Dashboard is AccessControlEnumerable { vaultHub = VaultHub(stakingVault.vaultHub()); _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + // Allow WSTETH to transfer STETH on behalf of the dashboard + STETH.approve(address(WSTETH), type(uint256).max); + emit Initialized(); } @@ -180,11 +181,11 @@ contract Dashboard is AccessControlEnumerable { /** * @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 + * @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 projectedMintableShares(uint256 _etherToFund) external view returns (uint256) { + uint256 _totalShares = _totalMintableShares(stakingVault.valuation() + _etherToFund); uint256 _sharesMinted = vaultSocket().sharesMinted; if (_totalShares < _sharesMinted) return 0; @@ -199,14 +200,11 @@ contract Dashboard is AccessControlEnumerable { 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"); } @@ -230,7 +228,7 @@ contract Dashboard is AccessControlEnumerable { * @notice Funds the staking vault with ether */ function fund() external payable virtual onlyRole(DEFAULT_ADMIN_ROLE) { - _fund(); + _fund(msg.value); } /** @@ -243,8 +241,7 @@ contract Dashboard is AccessControlEnumerable { WETH.transferFrom(msg.sender, address(this), _wethAmount); WETH.withdraw(_wethAmount); - // TODO: find way to use _fund() instead of stakingVault directly - stakingVault.fund{value: _wethAmount}(); + _fund(_wethAmount); } /** @@ -290,16 +287,17 @@ contract Dashboard is AccessControlEnumerable { /** * @notice Mints wstETH tokens backed by the vault to a recipient. Approvals for the passed amounts should be done before. * @param _recipient Address of the recipient - * @param _tokens Amount of tokens to mint + * @param _amountOfWstETH Amount of tokens to mint */ function mintWstETH( address _recipient, - uint256 _tokens + uint256 _amountOfWstETH ) external payable virtual onlyRole(DEFAULT_ADMIN_ROLE) fundAndProceed { - _mint(address(this), _tokens); + _mint(address(this), _amountOfWstETH); + + uint256 stETHAmount = STETH.getPooledEthByShares(_amountOfWstETH); - STETH.approve(address(WSTETH), _tokens); - uint256 wstETHAmount = WSTETH.wrap(_tokens); + uint256 wstETHAmount = WSTETH.wrap(stETHAmount); WSTETH.transfer(_recipient, wstETHAmount); } @@ -308,23 +306,20 @@ contract Dashboard is AccessControlEnumerable { * @param _amountOfShares Amount of shares to burn */ function burn(uint256 _amountOfShares) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { - _burn(_amountOfShares); + _burn(msg.sender, _amountOfShares); } /** * @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 + * @param _amountOfWstETH Amount of wstETH tokens to burn */ - function burnWstETH(uint256 _tokens) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { - WSTETH.transferFrom(msg.sender, address(this), _tokens); - - uint256 stETHAmount = WSTETH.unwrap(_tokens); - - STETH.transfer(address(vaultHub), stETHAmount); + function burnWstETH(uint256 _amountOfWstETH) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { + WSTETH.transferFrom(msg.sender, address(this), _amountOfWstETH); + uint256 stETHAmount = WSTETH.unwrap(_amountOfWstETH); uint256 sharesAmount = STETH.getSharesByPooledEth(stETHAmount); - vaultHub.burnSharesBackedByVault(address(stakingVault), sharesAmount); + _burn(address(this), sharesAmount); } /** @@ -362,11 +357,11 @@ contract Dashboard is AccessControlEnumerable { /** * @notice Burns stETH tokens from the sender backed by the vault using EIP-2612 Permit. - * @param _tokens Amount of stETH tokens to burn + * @param _amountOfShares Amount of shares to burn * @param _permit data required for the stETH.permit() method to set the allowance */ function burnWithPermit( - uint256 _tokens, + uint256 _amountOfShares, PermitInput calldata _permit ) external @@ -374,16 +369,16 @@ contract Dashboard is AccessControlEnumerable { onlyRole(DEFAULT_ADMIN_ROLE) trustlessPermit(address(STETH), msg.sender, address(this), _permit) { - _burn(_tokens); + _burn(msg.sender, _amountOfShares); } /** * @notice Burns wstETH tokens from the sender backed by the vault using EIP-2612 Permit. - * @param _tokens Amount of wstETH tokens to burn + * @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 @@ -391,14 +386,11 @@ contract Dashboard is AccessControlEnumerable { onlyRole(DEFAULT_ADMIN_ROLE) 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); - + WSTETH.transferFrom(msg.sender, address(this), _amountOfWstETH); + uint256 stETHAmount = WSTETH.unwrap(_amountOfWstETH); uint256 sharesAmount = STETH.getSharesByPooledEth(stETHAmount); - vaultHub.burnSharesBackedByVault(address(stakingVault), sharesAmount); + _burn(address(this), sharesAmount); } /** @@ -416,7 +408,7 @@ contract Dashboard is AccessControlEnumerable { */ modifier fundAndProceed() { if (msg.value > 0) { - _fund(); + _fund(msg.value); } _; } @@ -444,8 +436,8 @@ contract Dashboard is AccessControlEnumerable { /** * @dev Funds the staking vault with the ether sent in the transaction */ - function _fund() internal { - stakingVault.fund{value: msg.value}(); + function _fund(uint256 _value) internal { + stakingVault.fund{value: _value}(); } /** @@ -492,8 +484,13 @@ contract Dashboard is AccessControlEnumerable { * @dev Burns stETH tokens from the sender backed by the vault * @param _amountOfShares Amount of tokens to burn */ - function _burn(uint256 _amountOfShares) internal { - STETH.transferSharesFrom(msg.sender, address(vaultHub), _amountOfShares); + function _burn(address _sender, uint256 _amountOfShares) internal { + if (_sender == address(this)) { + STETH.transferShares(address(vaultHub), _amountOfShares); + } else { + STETH.transferSharesFrom(_sender, address(vaultHub), _amountOfShares); + } + vaultHub.burnSharesBackedByVault(address(stakingVault), _amountOfShares); } @@ -502,7 +499,8 @@ contract Dashboard is AccessControlEnumerable { * @param _valuation custom vault valuation */ function _totalMintableShares(uint256 _valuation) internal view returns (uint256) { - uint256 maxMintableStETH = (_valuation * (TOTAL_BASIS_POINTS - vaultSocket().reserveRatioBP)) / TOTAL_BASIS_POINTS; + uint256 maxMintableStETH = (_valuation * (TOTAL_BASIS_POINTS - vaultSocket().reserveRatioBP)) / + TOTAL_BASIS_POINTS; return Math256.min(STETH.getSharesByPooledEth(maxMintableStETH), vaultSocket().shareLimit); } diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index 08429de3c..cef6e1f60 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -115,12 +115,11 @@ contract Delegation is Dashboard { uint256 public voteLifetime; /** - * @notice Initializes the contract with the stETH address. - * @param _stETH The address of the stETH token. + * @notice Initializes the contract with the weth address. * @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: @@ -207,7 +206,7 @@ contract Delegation is Dashboard { * @notice Funds the StakingVault with ether. */ function fund() external payable override onlyRole(STAKER_ROLE) { - _fund(); + _fund(msg.value); } /** @@ -250,7 +249,7 @@ contract Delegation is Dashboard { * @param _amountOfShares The amount of shares to burn. */ function burn(uint256 _amountOfShares) external override onlyRole(TOKEN_MASTER_ROLE) { - _burn(_amountOfShares); + _burn(msg.sender, _amountOfShares); } /** diff --git a/test/0.8.25/vaults/contracts/WETH9__MockForVault.sol b/test/0.8.25/vaults/contracts/WETH9__MockForVault.sol index 20fd45359..7bc2e4684 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/LidoLocator__HarnessForDashboard.sol b/test/0.8.25/vaults/dashboard/contracts/LidoLocator__HarnessForDashboard.sol new file mode 100644 index 000000000..c70af4294 --- /dev/null +++ b/test/0.8.25/vaults/dashboard/contracts/LidoLocator__HarnessForDashboard.sol @@ -0,0 +1,26 @@ +interface ILidoLocator { + function lido() external view returns (address); + + function wstETH() external view returns (address); +} + +contract LidoLocator__HarnessForDashboard is ILidoLocator { + address private immutable LIDO; + address private immutable WSTETH; + + constructor( + address _lido, + address _wstETH + ) { + LIDO = _lido; + WSTETH = _wstETH; + } + + function lido() external view returns (address) { + return LIDO; + } + + function wstETH() external view returns (address) { + return WSTETH; + } +} diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index f678a6c92..719342285 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -10,6 +10,7 @@ import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; import { Dashboard, DepositContract__MockForStakingVault, + LidoLocator__HarnessForDashboard, StakingVault, StETHPermit__HarnessForDashboard, VaultFactory__MockForDashboard, @@ -36,6 +37,7 @@ describe("Dashboard", () => { let vaultImpl: StakingVault; let dashboardImpl: Dashboard; let factory: VaultFactory__MockForDashboard; + let lidoLocator: LidoLocator__HarnessForDashboard; let vault: StakingVault; let dashboard: Dashboard; @@ -54,10 +56,11 @@ describe("Dashboard", () => { weth = await ethers.deployContract("WETH9__MockForVault"); wsteth = await ethers.deployContract("WstETH__HarnessForVault", [steth]); hub = await ethers.deployContract("VaultHub__MockForDashboard", [steth]); + lidoLocator = await ethers.deployContract("LidoLocator__HarnessForDashboard", [steth, 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); @@ -92,26 +95,20 @@ describe("Dashboard", () => { }); 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])) + await expect(ethers.deployContract("Dashboard", [ethers.ZeroAddress, lidoLocator])) .to.be.revertedWithCustomError(dashboard, "ZeroArgument") .withArgs("_WETH"); }); - it("reverts if wstETH is zero address", async () => { - await expect(ethers.deployContract("Dashboard", [steth, weth, ethers.ZeroAddress])) - .to.be.revertedWithCustomError(dashboard, "ZeroArgument") - .withArgs("_wstETH"); - }); - 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); @@ -130,7 +127,7 @@ describe("Dashboard", () => { }); 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(vault)).to.be.revertedWithCustomError(dashboard_, "NonProxyCallsForbidden"); }); @@ -264,7 +261,7 @@ describe("Dashboard", () => { context("getMintableShares", () => { it("returns trivial can mint shares", async () => { - const canMint = await dashboard.getMintableShares(0n); + const canMint = await dashboard.projectedMintableShares(0n); expect(canMint).to.equal(0n); }); @@ -282,13 +279,13 @@ describe("Dashboard", () => { const funding = 1000n; - const preFundCanMint = await dashboard.getMintableShares(funding); + const preFundCanMint = await dashboard.projectedMintableShares(funding); await dashboard.fund({ value: funding }); const availableMintableShares = await dashboard.totalMintableShares(); - const canMint = await dashboard.getMintableShares(0n); + const canMint = await dashboard.projectedMintableShares(0n); expect(canMint).to.equal(availableMintableShares); expect(canMint).to.equal(preFundCanMint); }); @@ -306,11 +303,11 @@ describe("Dashboard", () => { await hub.mock__setVaultSocket(vault, sockets); const funding = 1000n; - const preFundCanMint = await dashboard.getMintableShares(funding); + const preFundCanMint = await dashboard.projectedMintableShares(funding); await dashboard.fund({ value: funding }); - const canMint = await dashboard.getMintableShares(0n); + const canMint = await dashboard.projectedMintableShares(0n); expect(canMint).to.equal(0n); // 1000 - 10% - 900 = 0 expect(canMint).to.equal(preFundCanMint); }); @@ -327,10 +324,10 @@ describe("Dashboard", () => { }; await hub.mock__setVaultSocket(vault, sockets); const funding = 1000n; - const preFundCanMint = await dashboard.getMintableShares(funding); + const preFundCanMint = await dashboard.projectedMintableShares(funding); await dashboard.fund({ value: funding }); - const canMint = await dashboard.getMintableShares(0n); + const canMint = await dashboard.projectedMintableShares(0n); expect(canMint).to.equal(0n); expect(canMint).to.equal(preFundCanMint); }); @@ -348,12 +345,12 @@ describe("Dashboard", () => { await hub.mock__setVaultSocket(vault, sockets); const funding = 2000n; - const preFundCanMint = await dashboard.getMintableShares(funding); + const preFundCanMint = await dashboard.projectedMintableShares(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.projectedMintableShares(0n); expect(canMint).to.equal(sharesFunded - sockets.sharesMinted); expect(canMint).to.equal(preFundCanMint); }); @@ -371,10 +368,10 @@ describe("Dashboard", () => { await hub.mock__setVaultSocket(vault, sockets); const funding = 2000n; - const preFundCanMint = await dashboard.getMintableShares(funding); + const preFundCanMint = await dashboard.projectedMintableShares(funding); await dashboard.fund({ value: funding }); - const canMint = await dashboard.getMintableShares(0n); + const canMint = await dashboard.projectedMintableShares(0n); expect(canMint).to.equal(0n); expect(canMint).to.equal(preFundCanMint); }); @@ -633,7 +630,6 @@ describe("Dashboard", () => { 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 + amount); }); @@ -774,7 +770,7 @@ describe("Dashboard", () => { it("burns stETH with permit", async () => { const permit = { - owner: await vaultOwner.address, + owner: vaultOwner.address, spender: String(dashboard.target), value: amount, nonce: await steth.nonces(vaultOwner), @@ -803,8 +799,8 @@ describe("Dashboard", () => { it("succeeds if has allowance", async () => { const permit = { - owner: await vaultOwner.address, - spender: String(dashboard.target), // invalid spender + owner: vaultOwner.address, + spender: stranger.address, // invalid spender value: amount, nonce: (await steth.nonces(vaultOwner)) + 1n, // invalid nonce deadline: BigInt(await time.latest()) + days(1n), @@ -835,6 +831,74 @@ describe("Dashboard", () => { expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - amount); }); + + 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: String(dashboard.target), + 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).burnWithPermit(amount, 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: String(dashboard.target), + 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).burnWithPermit(amount, 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", () => { From 29ef4ada61b41c84e7aaada81831a003475abbbe Mon Sep 17 00:00:00 2001 From: DiRaiks Date: Thu, 9 Jan 2025 13:03:17 +0300 Subject: [PATCH 03/36] tests: update Delegation constructor --- .../vaults/delegation/delegation.test.ts | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index 5ad7b08ea..55b9955fb 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__HarnessForDashboard, StakingVault, StETH__MockForDelegation, VaultFactory, @@ -35,6 +36,7 @@ describe("Delegation.sol", () => { let rewarder: HardhatEthersSigner; const recipient = certainAddress("some-recipient"); + let lidoLocator: LidoLocator__HarnessForDashboard; let steth: StETH__MockForDelegation; let weth: WETH9__MockForVault; let wsteth: WstETH__HarnessForVault; @@ -56,8 +58,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 ethers.deployContract("LidoLocator__HarnessForDashboard", [steth, 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); @@ -111,32 +114,28 @@ 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); }); }); context("initialize", () => { it("reverts if staking vault is zero address", async () => { - const delegation_ = await ethers.deployContract("Delegation", [steth, weth, wsteth]); + const delegation_ = await ethers.deployContract("Delegation", [weth, lidoLocator]); await expect(delegation_.initialize(ethers.ZeroAddress)) .to.be.revertedWithCustomError(delegation_, "ZeroArgument") @@ -148,7 +147,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(vault)).to.be.revertedWithCustomError(delegation_, "NonProxyCallsForbidden"); }); From b5c839f62a12a7d0c7d7e0ae7d4c1afe73d92751 Mon Sep 17 00:00:00 2001 From: DiRaiks Date: Thu, 9 Jan 2025 13:10:32 +0300 Subject: [PATCH 04/36] tests: add tests for burnWstETHWithPermit --- .../0.8.25/vaults/dashboard/dashboard.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 719342285..afd56146b 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -1030,6 +1030,78 @@ describe("Dashboard", () => { expect(await steth.balanceOf(vaultOwner)).to.equal(stethBalanceBefore); expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore - amount); }); + + 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: await vaultOwner.address, + spender: String(dashboard.target), + 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: await vaultOwner.address, + spender: String(dashboard.target), + 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); + }); }); context("rebalanceVault", () => { From 4b1650576bd5d5e3649df89bd245d503be31d935 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Fri, 10 Jan 2025 18:08:06 +0700 Subject: [PATCH 05/36] fix: add nft recovery --- contracts/0.8.25/vaults/Dashboard.sol | 44 ++++++++++++++++--- .../contracts/ERC721_MockForDashboard.sol | 14 ++++++ .../0.8.25/vaults/dashboard/dashboard.test.ts | 37 ++++++++++++++-- 3 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 test/0.8.25/vaults/dashboard/contracts/ERC721_MockForDashboard.sol diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index c6239f76a..b96f03b07 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -7,6 +7,7 @@ pragma solidity 0.8.25; import {AccessControlEnumerable} from "@openzeppelin/contracts-v5.0.2/access/extensions/AccessControlEnumerable.sol"; import {OwnableUpgradeable} from "contracts/openzeppelin/5.0.2/upgradeable/access/OwnableUpgradeable.sol"; import {IERC20} from "@openzeppelin/contracts-v5.0.2/token/ERC20/IERC20.sol"; +import {IERC721} from "@openzeppelin/contracts-v5.0.2/token/ERC721/IERC721.sol"; import {IERC20Permit} from "@openzeppelin/contracts-v5.0.2/token/ERC20/extensions/IERC20Permit.sol"; import {Math256} from "contracts/common/lib/Math256.sol"; @@ -199,8 +200,6 @@ contract Dashboard is AccessControlEnumerable { return Math256.min(address(stakingVault).balance, stakingVault.unlocked()); } - // TODO: add preview view methods for minting and burning - // ==================== Vault Management Functions ==================== /** @@ -410,16 +409,37 @@ contract Dashboard is AccessControlEnumerable { } /** - * @notice recovers ERC20 tokens or ether from the vault + * @notice recovers ERC20 tokens or ether from the dashboard contract to sender * @param _token Address of the token to recover, 0 for ether */ - function recover(address _token) external payable virtual onlyRole(DEFAULT_ADMIN_ROLE) { + function recoverERC20(address _token) external onlyRole(DEFAULT_ADMIN_ROLE) { + uint256 _amount; + if (_token == address(0)) { - payable(msg.sender).transfer(address(this).balance); + _amount = address(this).balance; + payable(msg.sender).transfer(_amount); } else { - bool success = IERC20(_token).transfer(msg.sender, IERC20(_token).balanceOf(address(this))); + _amount = IERC20(_token).balanceOf(address(this)); + bool success = IERC20(_token).transfer(msg.sender, _amount); if (!success) revert("ERC20: Transfer failed"); } + + emit ERC20Recovered(msg.sender, _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 + */ + function recoverERC721(address _token, uint256 _tokenId) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (_token == address(0)) revert ZeroArgument("_token"); + + emit ERC721Recovered(msg.sender, _token, _tokenId); + + IERC721(_token).transferFrom(address(this), msg.sender, _tokenId); } // ==================== Internal Functions ==================== @@ -533,6 +553,18 @@ contract Dashboard is AccessControlEnumerable { /// @notice Emitted when the contract is initialized event Initialized(); + /// @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 for zero address arguments 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..130ce0f81 --- /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.0.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/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index ca56322eb..14060fe0b 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -1,5 +1,6 @@ import { expect } from "chai"; import { randomBytes } from "crypto"; +import { zeroAddress } from "ethereumjs-util"; import { ZeroAddress } from "ethers"; import { ethers } from "hardhat"; @@ -10,6 +11,7 @@ import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; import { Dashboard, DepositContract__MockForStakingVault, + ERC721_MockForDashboard, StakingVault, StETHPermit__HarnessForDashboard, VaultFactory__MockForDashboard, @@ -30,6 +32,7 @@ describe("Dashboard", () => { let steth: StETHPermit__HarnessForDashboard; let weth: WETH9__MockForVault; + let erc721: ERC721_MockForDashboard; let wsteth: WstETH__HarnessForVault; let hub: VaultHub__MockForDashboard; let depositContract: DepositContract__MockForStakingVault; @@ -54,6 +57,7 @@ describe("Dashboard", () => { 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"); depositContract = await ethers.deployContract("DepositContract__MockForStakingVault"); vaultImpl = await ethers.deployContract("StakingVault", [hub, depositContract]); expect(await vaultImpl.vaultHub()).to.equal(hub); @@ -1009,7 +1013,11 @@ describe("Dashboard", () => { }); it("allows only admin to recover", async () => { - await expect(dashboard.connect(stranger).recover(ZeroAddress)).to.be.revertedWithCustomError( + await expect(dashboard.connect(stranger).recoverERC20(ZeroAddress)).to.be.revertedWithCustomError( + dashboard, + "AccessControlUnauthorizedAccount", + ); + await expect(dashboard.connect(stranger).recoverERC721(erc721.getAddress(), 0)).to.be.revertedWithCustomError( dashboard, "AccessControlUnauthorizedAccount", ); @@ -1017,18 +1025,41 @@ describe("Dashboard", () => { it("recovers all ether", async () => { const preBalance = await ethers.provider.getBalance(vaultOwner); - const tx = await dashboard.recover(ZeroAddress); + const tx = await dashboard.recoverERC20(ZeroAddress); const { gasUsed, gasPrice } = (await ethers.provider.getTransactionReceipt(tx.hash))!; + await expect(tx).to.emit(dashboard, "ERC20Recovered").withArgs(tx.from, zeroAddress(), amount); expect(await ethers.provider.getBalance(dashboard.getAddress())).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); - await dashboard.recover(weth.getAddress()); + const tx = await dashboard.recoverERC20(weth.getAddress()); + + await expect(tx) + .to.emit(dashboard, "ERC20Recovered") + .withArgs(tx.from, await weth.getAddress(), amount); expect(await weth.balanceOf(dashboard.getAddress())).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)).to.be.revertedWithCustomError(dashboard, "ZeroArgument"); + }); + + it("recovers erc721", async () => { + const dashboardAddress = await dashboard.getAddress(); + await erc721.mint(dashboardAddress, 0); + expect(await erc721.ownerOf(0)).to.equal(dashboardAddress); + + const tx = await dashboard.recoverERC721(erc721.getAddress(), 0); + + await expect(tx) + .to.emit(dashboard, "ERC721Recovered") + .withArgs(tx.from, await erc721.getAddress(), 0); + + expect(await erc721.ownerOf(0)).to.equal(vaultOwner.address); + }); }); }); From 41f6125df4cb310e18eaccb4b0e7c9428c55d322 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Fri, 10 Jan 2025 19:07:05 +0700 Subject: [PATCH 06/36] fix(docs): dashboard comment --- contracts/0.8.25/vaults/Dashboard.sol | 7 ++--- .../vaults/contracts/WETH9__MockForVault.sol | 23 +++++++--------- .../LidoLocator__HarnessForDashboard.sol | 26 ------------------- .../0.8.25/vaults/dashboard/dashboard.test.ts | 7 ++--- 4 files changed, 18 insertions(+), 45 deletions(-) delete mode 100644 test/0.8.25/vaults/dashboard/contracts/LidoLocator__HarnessForDashboard.sol diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 6d467c359..7c4aa6779 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -107,7 +107,8 @@ contract Dashboard is AccessControlEnumerable { vaultHub = VaultHub(stakingVault.vaultHub()); _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); - // Allow WSTETH to transfer STETH on behalf of the dashboard + // reduces gas cost for `burnWsteth` + // dashboard will hold STETH during this tx STETH.approve(address(WSTETH), type(uint256).max); emit Initialized(); @@ -277,7 +278,7 @@ contract Dashboard is AccessControlEnumerable { * @param _recipient Address of the recipient * @param _amountOfShares Amount of shares to mint */ - function mint( + function mintShares( address _recipient, uint256 _amountOfShares ) external payable virtual onlyRole(DEFAULT_ADMIN_ROLE) fundAndProceed { @@ -305,7 +306,7 @@ contract Dashboard is AccessControlEnumerable { * @notice Burns stETH shares from the sender backed by the vault * @param _amountOfShares Amount of shares to burn */ - function burn(uint256 _amountOfShares) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { + function burnShares(uint256 _amountOfShares) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { _burn(msg.sender, _amountOfShares); } diff --git a/test/0.8.25/vaults/contracts/WETH9__MockForVault.sol b/test/0.8.25/vaults/contracts/WETH9__MockForVault.sol index 7bc2e4684..736649866 100644 --- a/test/0.8.25/vaults/contracts/WETH9__MockForVault.sol +++ b/test/0.8.25/vaults/contracts/WETH9__MockForVault.sol @@ -4,17 +4,17 @@ pragma solidity 0.4.24; contract WETH9__MockForVault { - string public name = "Wrapped Ether"; - string public symbol = "WETH"; - uint8 public decimals = 18; + string public name = "Wrapped Ether"; + string public symbol = "WETH"; + uint8 public decimals = 18; - event Approval(address indexed src, address indexed guy, uint wad); - event Transfer(address indexed src, address indexed dst, uint wad); - event Deposit(address indexed dst, uint wad); - event Withdrawal(address indexed src, uint wad); + event Approval(address indexed src, address indexed guy, uint wad); + event Transfer(address indexed src, address indexed dst, uint wad); + event Deposit(address indexed dst, uint wad); + event Withdrawal(address indexed src, uint wad); - mapping (address => uint) public balanceOf; - mapping (address => mapping (address => uint)) public allowance; + mapping(address => uint) public balanceOf; + mapping(address => mapping(address => uint)) public allowance; function() external payable { deposit(); @@ -46,10 +46,7 @@ contract WETH9__MockForVault { return transferFrom(msg.sender, dst, wad); } - function transferFrom(address src, address dst, uint wad) - public - returns (bool) - { + function transferFrom(address src, address dst, uint wad) public returns (bool) { require(balanceOf[src] >= wad); if (src != msg.sender && allowance[src][msg.sender] != uint(-1)) { diff --git a/test/0.8.25/vaults/dashboard/contracts/LidoLocator__HarnessForDashboard.sol b/test/0.8.25/vaults/dashboard/contracts/LidoLocator__HarnessForDashboard.sol deleted file mode 100644 index c70af4294..000000000 --- a/test/0.8.25/vaults/dashboard/contracts/LidoLocator__HarnessForDashboard.sol +++ /dev/null @@ -1,26 +0,0 @@ -interface ILidoLocator { - function lido() external view returns (address); - - function wstETH() external view returns (address); -} - -contract LidoLocator__HarnessForDashboard is ILidoLocator { - address private immutable LIDO; - address private immutable WSTETH; - - constructor( - address _lido, - address _wstETH - ) { - LIDO = _lido; - WSTETH = _wstETH; - } - - function lido() external view returns (address) { - return LIDO; - } - - function wstETH() external view returns (address) { - return WSTETH; - } -} diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index afd56146b..ad3174895 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -10,7 +10,7 @@ import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; import { Dashboard, DepositContract__MockForStakingVault, - LidoLocator__HarnessForDashboard, + LidoLocator, StakingVault, StETHPermit__HarnessForDashboard, VaultFactory__MockForDashboard, @@ -21,6 +21,7 @@ import { import { certainAddress, days, ether, findEvents, signPermit, stethDomain, wstethDomain } from "lib"; +import { deployLidoLocator } from "test/deploy"; import { Snapshot } from "test/suite"; describe("Dashboard", () => { @@ -37,7 +38,7 @@ describe("Dashboard", () => { let vaultImpl: StakingVault; let dashboardImpl: Dashboard; let factory: VaultFactory__MockForDashboard; - let lidoLocator: LidoLocator__HarnessForDashboard; + let lidoLocator: LidoLocator; let vault: StakingVault; let dashboard: Dashboard; @@ -56,7 +57,7 @@ describe("Dashboard", () => { weth = await ethers.deployContract("WETH9__MockForVault"); wsteth = await ethers.deployContract("WstETH__HarnessForVault", [steth]); hub = await ethers.deployContract("VaultHub__MockForDashboard", [steth]); - lidoLocator = await ethers.deployContract("LidoLocator__HarnessForDashboard", [steth, wsteth]); + 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); From de3d3b937f4f9d71cc6f9999b083a7497c176f93 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Fri, 10 Jan 2025 19:41:25 +0700 Subject: [PATCH 07/36] fix: update naming for burn/mint --- contracts/0.8.25/vaults/Dashboard.sol | 2 +- contracts/0.8.25/vaults/Delegation.sol | 5 +- .../0.8.25/vaults/dashboard/dashboard.test.ts | 124 +++++++++--------- .../vaults/delegation/delegation.test.ts | 17 +-- 4 files changed, 75 insertions(+), 73 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 7c4aa6779..cf3ba09a5 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -361,7 +361,7 @@ contract Dashboard is AccessControlEnumerable { * @param _amountOfShares Amount of shares to burn * @param _permit data required for the stETH.permit() method to set the allowance */ - function burnWithPermit( + function burnSharesWithPermit( uint256 _amountOfShares, PermitInput calldata _permit ) diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index cef6e1f60..614381d93 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -28,7 +28,6 @@ import {Dashboard} from "./Dashboard.sol"; * The due is the amount of ether that is owed to the Curator or Operator based on the fee. */ contract Delegation is Dashboard { - /** * @notice Maximum fee value; equals to 100%. */ @@ -234,7 +233,7 @@ contract Delegation is Dashboard { * @param _recipient The address to which the shares will be minted. * @param _amountOfShares The amount of shares to mint. */ - function mint( + function mintShares( address _recipient, uint256 _amountOfShares ) external payable override onlyRole(TOKEN_MASTER_ROLE) fundAndProceed { @@ -248,7 +247,7 @@ contract Delegation is Dashboard { * NB: Delegation contract must have ERC-20 approved allowance to burn sender's shares. * @param _amountOfShares The amount of shares to burn. */ - function burn(uint256 _amountOfShares) external override onlyRole(TOKEN_MASTER_ROLE) { + function burnShares(uint256 _amountOfShares) external override onlyRole(TOKEN_MASTER_ROLE) { _burn(msg.sender, _amountOfShares); } diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index ad3174895..b83f53ff6 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -580,7 +580,7 @@ describe("Dashboard", () => { context("mint", () => { 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", ); @@ -588,7 +588,7 @@ describe("Dashboard", () => { it("mints stETH backed by the vault through the vault hub", async () => { const amount = ether("1"); - await expect(dashboard.mint(vaultOwner, amount)) + await expect(dashboard.mintShares(vaultOwner, amount)) .to.emit(steth, "Transfer") .withArgs(ZeroAddress, vaultOwner, amount) .and.to.emit(steth, "TransferShares") @@ -599,7 +599,7 @@ describe("Dashboard", () => { it("funds and mints stETH backed by the vault", async () => { const amount = ether("1"); - await expect(dashboard.mint(vaultOwner, amount, { value: amount })) + await expect(dashboard.mintShares(vaultOwner, amount, { value: amount })) .to.emit(vault, "Funded") .withArgs(dashboard, amount) .to.emit(steth, "Transfer") @@ -638,29 +638,29 @@ describe("Dashboard", () => { context("burn", () => { it("reverts if called by a non-admin", async () => { - await expect(dashboard.connect(stranger).burn(ether("1"))).to.be.revertedWithCustomError( + await expect(dashboard.connect(stranger).burnShares(ether("1"))).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); + const amountShares = ether("1"); + await dashboard.mintShares(vaultOwner, amountShares); + expect(await steth.balanceOf(vaultOwner)).to.equal(amountShares); - await expect(steth.connect(vaultOwner).approve(dashboard, amount)) + await expect(steth.connect(vaultOwner).approve(dashboard, amountShares)) .to.emit(steth, "Approval") - .withArgs(vaultOwner, dashboard, amount); - expect(await steth.allowance(vaultOwner, dashboard)).to.equal(amount); + .withArgs(vaultOwner, dashboard, amountShares); + expect(await steth.allowance(vaultOwner, dashboard)).to.equal(amountShares); - 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, amountShares) .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, amountShares, amountShares, amountShares); expect(await steth.balanceOf(vaultOwner)).to.equal(0); }); }); @@ -670,7 +670,7 @@ describe("Dashboard", () => { before(async () => { // mint steth to the vault owner for the burn - await dashboard.mint(vaultOwner, amount + amount); + await dashboard.mintShares(vaultOwner, amount + amount); }); it("reverts if called by a non-admin", async () => { @@ -708,12 +708,14 @@ describe("Dashboard", () => { }); }); - 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.getPooledEthByShares(amountShares); }); beforeEach(async () => { @@ -725,7 +727,7 @@ describe("Dashboard", () => { const permit = { owner: await vaultOwner.address, spender: String(dashboard.target), - value: amount, + value: amountSteth, nonce: await steth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), }; @@ -735,7 +737,7 @@ describe("Dashboard", () => { const { v, r, s } = signature; await expect( - dashboard.connect(stranger).burnWithPermit(amount, { + dashboard.connect(stranger).burnSharesWithPermit(amountShares, { value, deadline, v, @@ -749,7 +751,7 @@ describe("Dashboard", () => { const permit = { owner: await vaultOwner.address, spender: stranger.address, // invalid spender - value: amount, + value: amountSteth, nonce: await steth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), }; @@ -759,7 +761,7 @@ describe("Dashboard", () => { const { v, r, s } = signature; await expect( - dashboard.connect(vaultOwner).burnWithPermit(amount, { + dashboard.connect(vaultOwner).burnSharesWithPermit(amountShares, { value, deadline, v, @@ -769,11 +771,11 @@ describe("Dashboard", () => { ).to.be.revertedWith("Permit failure"); }); - it("burns stETH with permit", async () => { + it("burns shares with permit", async () => { const permit = { owner: vaultOwner.address, spender: String(dashboard.target), - value: amount, + value: amountSteth, nonce: await steth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), }; @@ -783,7 +785,7 @@ describe("Dashboard", () => { 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).burnSharesWithPermit(amountShares, { value, deadline, v, @@ -791,18 +793,18 @@ describe("Dashboard", () => { 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, amountShares); // approve steth from vault owner to dashboard + await expect(result).to.emit(steth, "Transfer").withArgs(vaultOwner, hub, amountShares); // transfer steth to hub + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountShares, amountShares, amountShares); // burn steth - expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - amount); + expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - amountShares); }); it("succeeds if has allowance", async () => { const permit = { owner: vaultOwner.address, spender: stranger.address, // invalid spender - value: amount, + value: amountShares, nonce: (await steth.nonces(vaultOwner)) + 1n, // invalid nonce deadline: BigInt(await time.latest()) + days(1n), }; @@ -818,19 +820,19 @@ describe("Dashboard", () => { s, }; - await expect(dashboard.connect(vaultOwner).burnWithPermit(amount, permitData)).to.be.revertedWith( + await expect(dashboard.connect(vaultOwner).burnSharesWithPermit(amountShares, permitData)).to.be.revertedWith( "Permit failure", ); - await steth.connect(vaultOwner).approve(dashboard, amount); + await steth.connect(vaultOwner).approve(dashboard, amountShares); const balanceBefore = await steth.balanceOf(vaultOwner); - const result = await dashboard.connect(vaultOwner).burnWithPermit(amount, permitData); + const result = await dashboard.connect(vaultOwner).burnSharesWithPermit(amountShares, 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, amountShares); // transfer steth to hub + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountShares, amountShares, amountShares); // burn steth - expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - amount); + expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - amountShares); }); it("succeeds with rebalanced shares - 1 share = 0.5 steth", async () => { @@ -859,7 +861,7 @@ describe("Dashboard", () => { }; const balanceBefore = await steth.balanceOf(vaultOwner); - const result = await dashboard.connect(vaultOwner).burnWithPermit(amount, permitData); + 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 @@ -893,7 +895,7 @@ describe("Dashboard", () => { }; const balanceBefore = await steth.balanceOf(vaultOwner); - const result = await dashboard.connect(vaultOwner).burnWithPermit(amount, permitData); + 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 @@ -903,22 +905,22 @@ describe("Dashboard", () => { }); context("burnWstETHWithPermit", () => { - const amount = ether("1"); + const amountShares = ether("1"); beforeEach(async () => { // 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, amountShares); // wrap steth to wsteth to get the amount of wsteth for the burn - await wsteth.connect(vaultOwner).wrap(amount); + await wsteth.connect(vaultOwner).wrap(amountShares); }); it("reverts if called by a non-admin", async () => { const permit = { owner: await vaultOwner.address, spender: String(dashboard.target), - value: amount, + value: amountShares, nonce: await wsteth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), }; @@ -928,7 +930,7 @@ describe("Dashboard", () => { const { v, r, s } = signature; await expect( - dashboard.connect(stranger).burnWithPermit(amount, { + dashboard.connect(stranger).burnSharesWithPermit(amountShares, { value, deadline, v, @@ -942,7 +944,7 @@ describe("Dashboard", () => { const permit = { owner: await vaultOwner.address, spender: stranger.address, // invalid spender - value: amount, + value: amountShares, nonce: await wsteth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), }; @@ -952,7 +954,7 @@ describe("Dashboard", () => { const { v, r, s } = signature; await expect( - dashboard.connect(vaultOwner).burnWstETHWithPermit(amount, { + dashboard.connect(vaultOwner).burnWstETHWithPermit(amountShares, { value, deadline, v, @@ -966,7 +968,7 @@ describe("Dashboard", () => { const permit = { owner: await vaultOwner.address, spender: String(dashboard.target), - value: amount, + value: amountShares, nonce: await wsteth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), }; @@ -977,7 +979,7 @@ describe("Dashboard", () => { 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, @@ -985,20 +987,20 @@ describe("Dashboard", () => { 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, amountShares); // uwrap wsteth to steth + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountShares, amountShares, 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, + value: amountShares, nonce: (await wsteth.nonces(vaultOwner)) + 1n, // invalid nonce deadline: BigInt(await time.latest()) + days(1n), }; @@ -1014,22 +1016,22 @@ describe("Dashboard", () => { s, }; - await expect(dashboard.connect(vaultOwner).burnWstETHWithPermit(amount, permitData)).to.be.revertedWith( + await expect(dashboard.connect(vaultOwner).burnWstETHWithPermit(amountShares, permitData)).to.be.revertedWith( "Permit failure", ); - 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, amountShares); // uwrap wsteth to steth + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountShares, amountShares, 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 () => { diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index 55b9955fb..4ee5d63b4 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -7,7 +7,7 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { Delegation, DepositContract__MockForStakingVault, - LidoLocator__HarnessForDashboard, + LidoLocator, StakingVault, StETH__MockForDelegation, VaultFactory, @@ -18,6 +18,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; @@ -36,7 +37,7 @@ describe("Delegation.sol", () => { let rewarder: HardhatEthersSigner; const recipient = certainAddress("some-recipient"); - let lidoLocator: LidoLocator__HarnessForDashboard; + let lidoLocator: LidoLocator; let steth: StETH__MockForDelegation; let weth: WETH9__MockForVault; let wsteth: WstETH__HarnessForVault; @@ -58,7 +59,7 @@ 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 ethers.deployContract("LidoLocator__HarnessForDashboard", [steth, wsteth]); + lidoLocator = await deployLidoLocator({ lido: steth, wstETH: wsteth }); delegationImpl = await ethers.deployContract("Delegation", [weth, lidoLocator]); expect(await delegationImpl.WETH()).to.equal(weth); @@ -432,7 +433,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", ); @@ -440,7 +441,7 @@ describe("Delegation.sol", () => { it("mints the tokens", async () => { const amount = 100n; - await expect(delegation.connect(tokenMaster).mint(recipient, amount)) + await expect(delegation.connect(tokenMaster).mintShares(recipient, amount)) .to.emit(steth, "Transfer") .withArgs(ethers.ZeroAddress, recipient, amount); }); @@ -448,7 +449,7 @@ describe("Delegation.sol", () => { context("burn", () => { it("reverts if the caller is not a member of the token master role", async () => { - await expect(delegation.connect(stranger).burn(100n)).to.be.revertedWithCustomError( + await expect(delegation.connect(stranger).burnShares(100n)).to.be.revertedWithCustomError( delegation, "AccessControlUnauthorizedAccount", ); @@ -456,9 +457,9 @@ describe("Delegation.sol", () => { it("burns the tokens", async () => { const amount = 100n; - await delegation.connect(tokenMaster).mint(tokenMaster, amount); + await delegation.connect(tokenMaster).mintShares(tokenMaster, amount); - await expect(delegation.connect(tokenMaster).burn(amount)) + await expect(delegation.connect(tokenMaster).burnShares(amount)) .to.emit(steth, "Transfer") .withArgs(tokenMaster, hub, amount) .and.to.emit(steth, "Transfer") From 3261a8ce6b44f619d94fb95b26f071fd77368d83 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Fri, 10 Jan 2025 19:45:56 +0700 Subject: [PATCH 08/36] fix(test): check allowance in dashboard --- test/0.8.25/vaults/dashboard/dashboard.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index b83f53ff6..d687eec27 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"; @@ -136,17 +136,23 @@ describe("Dashboard", () => { context("initialized state", () => { it("post-initialization state is correct", async () => { + // vault state expect(await vault.owner()).to.equal(dashboard); expect(await vault.operator()).to.equal(operator); + // dashboard state expect(await dashboard.isInitialized()).to.equal(true); + // dashboard contracts expect(await dashboard.stakingVault()).to.equal(vault); expect(await dashboard.vaultHub()).to.equal(hub); 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(dashboard.getAddress(), wsteth.getAddress())).to.equal(MaxUint256); }); }); From c228afd9ac02cfa17c2bed6ab01745d6214149a1 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Fri, 10 Jan 2025 19:50:17 +0700 Subject: [PATCH 09/36] fix(test): remove extra await --- test/0.8.25/vaults/dashboard/dashboard.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index d687eec27..8c34a4171 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -731,7 +731,7 @@ describe("Dashboard", () => { it("reverts if called by a non-admin", async () => { const permit = { - owner: await vaultOwner.address, + owner: vaultOwner.address, spender: String(dashboard.target), value: amountSteth, nonce: await steth.nonces(vaultOwner), @@ -755,7 +755,7 @@ describe("Dashboard", () => { it("reverts if the permit is invalid", async () => { const permit = { - owner: await vaultOwner.address, + owner: vaultOwner.address, spender: stranger.address, // invalid spender value: amountSteth, nonce: await steth.nonces(vaultOwner), @@ -924,7 +924,7 @@ describe("Dashboard", () => { it("reverts if called by a non-admin", async () => { const permit = { - owner: await vaultOwner.address, + owner: vaultOwner.address, spender: String(dashboard.target), value: amountShares, nonce: await wsteth.nonces(vaultOwner), @@ -948,7 +948,7 @@ describe("Dashboard", () => { it("reverts if the permit is invalid", async () => { const permit = { - owner: await vaultOwner.address, + owner: vaultOwner.address, spender: stranger.address, // invalid spender value: amountShares, nonce: await wsteth.nonces(vaultOwner), @@ -972,7 +972,7 @@ describe("Dashboard", () => { it("burns wstETH with permit", async () => { const permit = { - owner: await vaultOwner.address, + owner: vaultOwner.address, spender: String(dashboard.target), value: amountShares, nonce: await wsteth.nonces(vaultOwner), @@ -1004,7 +1004,7 @@ describe("Dashboard", () => { it("succeeds if has allowance", async () => { const permit = { - owner: await vaultOwner.address, + owner: vaultOwner.address, spender: String(dashboard.target), // invalid spender value: amountShares, nonce: (await wsteth.nonces(vaultOwner)) + 1n, // invalid nonce @@ -1047,7 +1047,7 @@ describe("Dashboard", () => { const stethToBurn = sharesToBurn / 2n; // 1 share = 0.5 steth const permit = { - owner: await vaultOwner.address, + owner: vaultOwner.address, spender: String(dashboard.target), value: sharesToBurn, nonce: await wsteth.nonces(vaultOwner), @@ -1083,7 +1083,7 @@ describe("Dashboard", () => { const stethToBurn = sharesToBurn * 2n; // 1 share = 2 steth const permit = { - owner: await vaultOwner.address, + owner: vaultOwner.address, spender: String(dashboard.target), value: sharesToBurn, nonce: await wsteth.nonces(vaultOwner), From c6cc70f7de673b984907b1af6fd29189f46692ba Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Fri, 10 Jan 2025 19:54:09 +0700 Subject: [PATCH 10/36] fix(test): dashboard address reuse --- .../0.8.25/vaults/dashboard/dashboard.test.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 8c34a4171..d4189dc6f 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -717,11 +717,13 @@ describe("Dashboard", () => { context("burnSharesWithPermit", () => { const amountShares = ether("1"); let amountSteth: bigint; + let dashboardAddress: string; before(async () => { // mint steth to the vault owner for the burn await dashboard.mintShares(vaultOwner, amountShares); amountSteth = await steth.getPooledEthByShares(amountShares); + dashboardAddress = await dashboard.getAddress(); }); beforeEach(async () => { @@ -732,7 +734,7 @@ describe("Dashboard", () => { it("reverts if called by a non-admin", async () => { const permit = { owner: vaultOwner.address, - spender: String(dashboard.target), + spender: dashboardAddress, value: amountSteth, nonce: await steth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), @@ -780,7 +782,7 @@ describe("Dashboard", () => { it("burns shares with permit", async () => { const permit = { owner: vaultOwner.address, - spender: String(dashboard.target), + spender: dashboardAddress, value: amountSteth, nonce: await steth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), @@ -849,7 +851,7 @@ describe("Dashboard", () => { const permit = { owner: vaultOwner.address, - spender: String(dashboard.target), + spender: dashboardAddress, value: stethToBurn, nonce: await steth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), @@ -883,7 +885,7 @@ describe("Dashboard", () => { const permit = { owner: vaultOwner.address, - spender: String(dashboard.target), + spender: dashboardAddress, value: stethToBurn, nonce: await steth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), @@ -912,6 +914,11 @@ describe("Dashboard", () => { context("burnWstETHWithPermit", () => { const amountShares = ether("1"); + let dashboardAddress: string; + + before(async () => { + dashboardAddress = await dashboard.getAddress(); + }); beforeEach(async () => { // mint steth to the vault owner for the burn @@ -925,7 +932,7 @@ describe("Dashboard", () => { it("reverts if called by a non-admin", async () => { const permit = { owner: vaultOwner.address, - spender: String(dashboard.target), + spender: dashboardAddress, value: amountShares, nonce: await wsteth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), @@ -973,7 +980,7 @@ describe("Dashboard", () => { it("burns wstETH with permit", async () => { const permit = { owner: vaultOwner.address, - spender: String(dashboard.target), + spender: dashboardAddress, value: amountShares, nonce: await wsteth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), @@ -1005,7 +1012,7 @@ describe("Dashboard", () => { it("succeeds if has allowance", async () => { const permit = { owner: vaultOwner.address, - spender: String(dashboard.target), // invalid spender + spender: dashboardAddress, // invalid spender value: amountShares, nonce: (await wsteth.nonces(vaultOwner)) + 1n, // invalid nonce deadline: BigInt(await time.latest()) + days(1n), @@ -1048,7 +1055,7 @@ describe("Dashboard", () => { const permit = { owner: vaultOwner.address, - spender: String(dashboard.target), + spender: dashboardAddress, value: sharesToBurn, nonce: await wsteth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), @@ -1084,7 +1091,7 @@ describe("Dashboard", () => { const permit = { owner: vaultOwner.address, - spender: String(dashboard.target), + spender: dashboardAddress, value: sharesToBurn, nonce: await wsteth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), From 739a60d1b213bf169232a437fdf01bf5ad15c8c1 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Fri, 10 Jan 2025 20:36:25 +0700 Subject: [PATCH 11/36] test: dashboard valuation and recieve --- .../0.8.25/vaults/dashboard/dashboard.test.ts | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index d4189dc6f..af78c1112 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -2,6 +2,7 @@ import { expect } from "chai"; import { randomBytes } from "crypto"; import { MaxUint256, ZeroAddress } from "ethers"; import { ethers } from "hardhat"; +import { get } from "http"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { time } from "@nomicfoundation/hardhat-network-helpers"; @@ -42,6 +43,7 @@ describe("Dashboard", () => { let vault: StakingVault; let dashboard: Dashboard; + let dashboardAddress: string; let originalState: string; @@ -82,7 +84,7 @@ describe("Dashboard", () => { 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); }); @@ -179,6 +181,13 @@ describe("Dashboard", () => { }); }); + 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(); @@ -717,13 +726,11 @@ describe("Dashboard", () => { context("burnSharesWithPermit", () => { const amountShares = ether("1"); let amountSteth: bigint; - let dashboardAddress: string; before(async () => { // mint steth to the vault owner for the burn await dashboard.mintShares(vaultOwner, amountShares); amountSteth = await steth.getPooledEthByShares(amountShares); - dashboardAddress = await dashboard.getAddress(); }); beforeEach(async () => { @@ -914,11 +921,6 @@ describe("Dashboard", () => { context("burnWstETHWithPermit", () => { const amountShares = ether("1"); - let dashboardAddress: string; - - before(async () => { - dashboardAddress = await dashboard.getAddress(); - }); beforeEach(async () => { // mint steth to the vault owner for the burn @@ -1144,4 +1146,23 @@ describe("Dashboard", () => { .withArgs(amount); }); }); + + context("fallback behavior", () => { + const amount = ether("1"); + + it("reverts on zero value sent", async () => { + const tx = vaultOwner.sendTransaction({ to: dashboardAddress, value: 0 }); + await expect(tx).to.be.revertedWithCustomError(dashboard, "ZeroArgument"); + }); + + 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 () => { + await vaultOwner.sendTransaction({ to: dashboardAddress, value: amount }); + expect(await ethers.provider.getBalance(dashboardAddress)).to.equal(amount); + }); + }); }); From 839b7265ad4dfb26c0081672c83350f9e39022d8 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Fri, 10 Jan 2025 14:47:37 +0000 Subject: [PATCH 12/36] fix: happy path integration test and linters --- test/0.8.25/vaults/dashboard/dashboard.test.ts | 4 +--- test/integration/vaults-happy-path.integration.ts | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index af78c1112..266524651 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -2,11 +2,9 @@ import { expect } from "chai"; import { randomBytes } from "crypto"; import { MaxUint256, ZeroAddress } from "ethers"; import { ethers } from "hardhat"; -import { get } from "http"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { time } from "@nomicfoundation/hardhat-network-helpers"; -import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; +import { setBalance, time } from "@nomicfoundation/hardhat-network-helpers"; import { Dashboard, diff --git a/test/integration/vaults-happy-path.integration.ts b/test/integration/vaults-happy-path.integration.ts index 6725c6086..897dfac6d 100644 --- a/test/integration/vaults-happy-path.integration.ts +++ b/test/integration/vaults-happy-path.integration.ts @@ -272,12 +272,12 @@ describe("Scenario: Staking Vaults Happy Path", () => { }); // Validate minting with the cap - const mintOverLimitTx = delegation.connect(tokenMaster).mint(tokenMaster, stakingVaultMaxMintingShares + 1n); + const mintOverLimitTx = delegation.connect(tokenMaster).mintShares(tokenMaster, stakingVaultMaxMintingShares + 1n); await expect(mintOverLimitTx) .to.be.revertedWithCustomError(accounting, "InsufficientValuationToMint") .withArgs(stakingVault, stakingVault.valuation()); - const mintTx = await delegation.connect(tokenMaster).mint(tokenMaster, stakingVaultMaxMintingShares); + const mintTx = await delegation.connect(tokenMaster).mintShares(tokenMaster, stakingVaultMaxMintingShares); const mintTxReceipt = await trace("delegation.mint", mintTx); const mintEvents = ctx.getEvents(mintTxReceipt, "MintedSharesOnVault"); @@ -410,7 +410,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { .approve(delegation, await lido.getPooledEthByShares(stakingVaultMaxMintingShares)); await trace("lido.approve", approveVaultTx); - const burnTx = await delegation.connect(tokenMaster).burn(stakingVaultMaxMintingShares); + const burnTx = await delegation.connect(tokenMaster).burnShares(stakingVaultMaxMintingShares); await trace("delegation.burn", burnTx); const { elapsedProtocolReward, elapsedVaultReward } = await calculateReportParams(); From 99f7d9a341a9a4b5b8b678ed2fbe270cccce3c1b Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Fri, 10 Jan 2025 14:57:05 +0000 Subject: [PATCH 13/36] fix: tests --- scripts/scratch/steps/0145-deploy-vaults.ts | 6 ++---- test/0.8.25/vaults/vaultFactory.test.ts | 15 ++++++++++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/scripts/scratch/steps/0145-deploy-vaults.ts b/scripts/scratch/steps/0145-deploy-vaults.ts index aa9a3f210..7726a6619 100644 --- a/scripts/scratch/steps/0145-deploy-vaults.ts +++ b/scripts/scratch/steps/0145-deploy-vaults.ts @@ -11,8 +11,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; @@ -26,9 +25,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/vaultFactory.test.ts b/test/0.8.25/vaults/vaultFactory.test.ts index 8f2955b44..894b17836 100644 --- a/test/0.8.25/vaults/vaultFactory.test.ts +++ b/test/0.8.25/vaults/vaultFactory.test.ts @@ -52,27 +52,32 @@ 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); - implOld = await ethers.deployContract("StakingVault", [accounting, depositContract], { from: deployer }); + implOld = await ethers.deployContract("StakingVault", [accounting, depositContract]); implNew = await ethers.deployContract("StakingVault__HarnessForTestUpgrade", [accounting, depositContract], { from: deployer, }); - delegation = await ethers.deployContract("Delegation", [steth, weth, wsteth], { from: deployer }); - vaultFactory = await ethers.deployContract("VaultFactory", [admin, implOld, delegation], { from: deployer }); + delegation = await ethers.deployContract("Delegation", [weth, locator]); + vaultFactory = await ethers.deployContract("VaultFactory", [admin, implOld, delegation]); //add VAULT_MASTER_ROLE role to allow admin to connect the Vaults to the vault Hub await accounting.connect(admin).grantRole(await accounting.VAULT_MASTER_ROLE(), admin); From 186e2667f1af173e1ae295b487ec44b0cfb78fa8 Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Fri, 10 Jan 2025 15:56:27 +0000 Subject: [PATCH 14/36] test: update tests for dashboard --- contracts/0.8.25/vaults/Dashboard.sol | 16 +----- .../contracts/VaultHub__MockForDashboard.sol | 8 ++- .../0.8.25/vaults/dashboard/dashboard.test.ts | 49 ++++++++++++++----- .../contracts/VaultHub__MockForDelegation.sol | 6 +-- 4 files changed, 47 insertions(+), 32 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index cf3ba09a5..f69c6ba59 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -1,5 +1,5 @@ +// SPDX-FileCopyrightText: 2025 Lido // SPDX-License-Identifier: GPL-3.0 -// SPDX-FileCopyrightText: 2024 Lido // See contracts/COMPILERS.md pragma solidity 0.8.25; @@ -458,20 +458,6 @@ contract Dashboard is AccessControlEnumerable { stakingVault.requestValidatorExit(_validatorPublicKey); } - /** - * @dev Deposits validators to the beacon chain - * @param _numberOfDeposits Number of validator deposits - * @param _pubkeys Concatenated public keys of the validators - * @param _signatures Concatenated signatures of the validators - */ - function _depositToBeaconChain( - uint256 _numberOfDeposits, - bytes calldata _pubkeys, - bytes calldata _signatures - ) internal { - stakingVault.depositToBeaconChain(_numberOfDeposits, _pubkeys, _signatures); - } - /** * @dev Mints stETH tokens backed by the vault to a recipient * @param _recipient Address of the recipient 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..d885fa767 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,14 @@ 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 { steth.mintExternalShares(recipient, amount); + vaultSockets[vault].sharesMinted = uint96(vaultSockets[vault].sharesMinted + amount); } - function burnSharesBackedByVault(address /* vault */, uint256 amount) external { + function burnSharesBackedByVault(address vault, uint256 amount) external { steth.burnExternalShares(amount); + vaultSockets[vault].sharesMinted = uint96(vaultSockets[vault].sharesMinted - amount); } function voluntaryDisconnect(address _vault) external { @@ -54,6 +56,8 @@ contract VaultHub__MockForDashboard { } function rebalance() external payable { + vaultSockets[msg.sender].sharesMinted = 0; + emit Mock__Rebalanced(msg.value); } } diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 266524651..364544f3e 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -59,8 +59,10 @@ describe("Dashboard", () => { hub = await ethers.deployContract("VaultHub__MockForDashboard", [steth]); 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", [weth, lidoLocator]); expect(await dashboardImpl.STETH()).to.equal(steth); expect(await dashboardImpl.WETH()).to.equal(weth); @@ -77,11 +79,13 @@ describe("Dashboard", () => { 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); + dashboardAddress = dashboardCreatedEvents[0].args.dashboard; dashboard = await ethers.getContractAt("Dashboard", dashboardAddress, vaultOwner); expect(await dashboard.stakingVault()).to.equal(vault); @@ -273,7 +277,7 @@ describe("Dashboard", () => { }); }); - context("getMintableShares", () => { + context("projectedMintableShares", () => { it("returns trivial can mint shares", async () => { const canMint = await dashboard.projectedMintableShares(0n); expect(canMint).to.equal(0n); @@ -470,15 +474,38 @@ describe("Dashboard", () => { }); }); - 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()); }); - 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 no debt", () => { + 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", () => { + let amount: bigint; + + beforeEach(async () => { + amount = ether("1"); + await dashboard.mintShares(vaultOwner, amount); + }); + + 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: amount })) + .to.emit(hub, "Mock__Rebalanced") + .withArgs(amount) + .to.emit(hub, "Mock__VaultDisconnected") + .withArgs(vault); + }); }); }); @@ -591,7 +618,7 @@ describe("Dashboard", () => { }); }); - context("mint", () => { + context("mintShares", () => { it("reverts if called by a non-admin", async () => { await expect(dashboard.connect(stranger).mintShares(vaultOwner, ether("1"))).to.be.revertedWithCustomError( dashboard, @@ -599,7 +626,7 @@ describe("Dashboard", () => { ); }); - it("mints stETH backed by the vault through the vault hub", async () => { + it("mints shares backed by the vault through the vault hub", async () => { const amount = ether("1"); await expect(dashboard.mintShares(vaultOwner, amount)) .to.emit(steth, "Transfer") @@ -610,7 +637,7 @@ describe("Dashboard", () => { expect(await steth.balanceOf(vaultOwner)).to.equal(amount); }); - it("funds and mints stETH backed by the vault", async () => { + it("funds and mints shares backed by the vault", async () => { const amount = ether("1"); await expect(dashboard.mintShares(vaultOwner, amount, { value: amount })) .to.emit(vault, "Funded") @@ -649,7 +676,7 @@ describe("Dashboard", () => { }); }); - context("burn", () => { + context("burnShares", () => { it("reverts if called by a non-admin", async () => { await expect(dashboard.connect(stranger).burnShares(ether("1"))).to.be.revertedWithCustomError( dashboard, @@ -657,7 +684,7 @@ describe("Dashboard", () => { ); }); - it("burns stETH backed by the vault", async () => { + it("burns shares backed by the vault", async () => { const amountShares = ether("1"); await dashboard.mintShares(vaultOwner, amountShares); expect(await steth.balanceOf(vaultOwner)).to.equal(amountShares); @@ -682,7 +709,7 @@ describe("Dashboard", () => { const amount = ether("1"); before(async () => { - // mint steth to the vault owner for the burn + // mint shares to the vault owner for the burn await dashboard.mintShares(vaultOwner, amount + amount); }); @@ -693,7 +720,7 @@ describe("Dashboard", () => { ); }); - it("burns wstETH backed by the vault", async () => { + it("burns shares backed by the vault", async () => { // approve for wsteth wrap await steth.connect(vaultOwner).approve(wsteth, amount); // wrap steth to wsteth to get the amount of wsteth for the burn 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 cd50d871b..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,13 +20,11 @@ contract VaultHub__MockForDelegation { emit Mock__VaultDisconnected(vault); } - // solhint-disable-next-line no-unused-vars - function mintSharesBackedByVault(address vault, address recipient, uint256 amount) external { + function mintSharesBackedByVault(address /* vault */, address recipient, uint256 amount) external { steth.mint(recipient, amount); } - // solhint-disable-next-line no-unused-vars - function burnSharesBackedByVault(address vault, uint256 amount) external { + function burnSharesBackedByVault(address /* vault */, uint256 amount) external { steth.burn(amount); } From 0aea721a912209e89a184047551ef4e91c598f3c Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Mon, 13 Jan 2025 10:53:29 +0700 Subject: [PATCH 15/36] fix: reduce dashboard._burn gas --- contracts/0.8.25/vaults/Dashboard.sol | 8 +++----- test/0.8.25/vaults/dashboard/dashboard.test.ts | 3 ++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index f69c6ba59..a0ecd09b6 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -110,6 +110,8 @@ contract Dashboard is AccessControlEnumerable { // reduces gas cost for `burnWsteth` // dashboard will hold STETH during this tx STETH.approve(address(WSTETH), type(uint256).max); + // allows to uncondinitialy use transferFrom in _burn + STETH.approve(address(this), type(uint256).max); emit Initialized(); } @@ -472,11 +474,7 @@ contract Dashboard is AccessControlEnumerable { * @param _amountOfShares Amount of tokens to burn */ function _burn(address _sender, uint256 _amountOfShares) internal { - if (_sender == address(this)) { - STETH.transferShares(address(vaultHub), _amountOfShares); - } else { - STETH.transferSharesFrom(_sender, address(vaultHub), _amountOfShares); - } + STETH.transferSharesFrom(_sender, address(vaultHub), _amountOfShares); vaultHub.burnSharesBackedByVault(address(stakingVault), _amountOfShares); } diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 364544f3e..fb29298fe 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -156,7 +156,8 @@ describe("Dashboard", () => { 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(dashboard.getAddress(), wsteth.getAddress())).to.equal(MaxUint256); + expect(await steth.allowance(dashboardAddress, wsteth.getAddress())).to.equal(MaxUint256); + expect(await steth.allowance(dashboardAddress, dashboardAddress)).to.equal(MaxUint256); }); }); From c4e7ceb61fbc9b04eea8ef5d41756bea0c0a27ad Mon Sep 17 00:00:00 2001 From: Yuri Tkachenko Date: Mon, 13 Jan 2025 14:18:23 +0000 Subject: [PATCH 16/36] chore: simplify burnWstETH --- contracts/0.8.25/vaults/Dashboard.sol | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index a0ecd09b6..002e6743f 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -315,14 +315,13 @@ contract Dashboard is AccessControlEnumerable { /** * @notice Burns wstETH tokens from the sender backed by the vault. Approvals for the passed amounts should be done before. * @param _amountOfWstETH Amount of wstETH tokens to burn + * @dev The _amountOfWstETH = _amountOfShares by design */ function burnWstETH(uint256 _amountOfWstETH) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { WSTETH.transferFrom(msg.sender, address(this), _amountOfWstETH); + WSTETH.unwrap(_amountOfWstETH); - uint256 stETHAmount = WSTETH.unwrap(_amountOfWstETH); - uint256 sharesAmount = STETH.getSharesByPooledEth(stETHAmount); - - _burn(address(this), sharesAmount); + _burn(address(this), _amountOfWstETH); } /** From 95b11fb0338408e2a04a28eb8abdd0b12d9f9980 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Tue, 14 Jan 2025 15:30:06 +0700 Subject: [PATCH 17/36] fix: use eth address convention --- contracts/0.8.25/vaults/Dashboard.sol | 6 +++- .../0.8.25/vaults/dashboard/dashboard.test.ts | 32 +++++++++++-------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 61e798c72..4129b6ff8 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -57,6 +57,9 @@ contract Dashboard is AccessControlEnumerable { /// @notice The wrapped ether token contract IWETH9 public immutable WETH; + /// @notice ETH address convention per EIP-7528 + address public constant ETH = address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); + /// @notice The underlying `StakingVault` contract IStakingVault public stakingVault; @@ -410,8 +413,9 @@ contract Dashboard is AccessControlEnumerable { */ function recoverERC20(address _token) external onlyRole(DEFAULT_ADMIN_ROLE) { uint256 _amount; + if (_token == address(0)) revert ZeroArgument("_token"); - if (_token == address(0)) { + if (_token == ETH) { _amount = address(this).balance; payable(msg.sender).transfer(_amount); } else { diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 4d895b460..bb39aa3b2 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -1184,11 +1184,13 @@ describe("Dashboard", () => { await wethContract.deposit({ value: amount }); - await vaultOwner.sendTransaction({ to: dashboard.getAddress(), value: amount }); - await wethContract.transfer(dashboard.getAddress(), amount); + await vaultOwner.sendTransaction({ to: dashboardAddress, value: amount }); + await wethContract.transfer(dashboardAddress, amount); + await erc721.mint(dashboardAddress, 0); - expect(await ethers.provider.getBalance(dashboard.getAddress())).to.equal(amount); - expect(await wethContract.balanceOf(dashboard.getAddress())).to.equal(amount); + 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 () => { @@ -1202,13 +1204,18 @@ describe("Dashboard", () => { ); }); + it("does not allow zero token address for erc20 recovery", async () => { + await expect(dashboard.recoverERC20(ZeroAddress)).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(ZeroAddress); + const tx = await dashboard.recoverERC20(ethStub); const { gasUsed, gasPrice } = (await ethers.provider.getTransactionReceipt(tx.hash))!; - await expect(tx).to.emit(dashboard, "ERC20Recovered").withArgs(tx.from, zeroAddress(), amount); - expect(await ethers.provider.getBalance(dashboard.getAddress())).to.equal(0); + 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); }); @@ -1219,19 +1226,15 @@ describe("Dashboard", () => { await expect(tx) .to.emit(dashboard, "ERC20Recovered") .withArgs(tx.from, await weth.getAddress(), amount); - expect(await weth.balanceOf(dashboard.getAddress())).to.equal(0); + 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)).to.be.revertedWithCustomError(dashboard, "ZeroArgument"); + await expect(dashboard.recoverERC721(ZeroAddress, 0)).to.be.revertedWithCustomError(dashboard, "ZeroArgument"); }); it("recovers erc721", async () => { - const dashboardAddress = await dashboard.getAddress(); - await erc721.mint(dashboardAddress, 0); - expect(await erc721.ownerOf(0)).to.equal(dashboardAddress); - const tx = await dashboard.recoverERC721(erc721.getAddress(), 0); await expect(tx) @@ -1256,8 +1259,9 @@ describe("Dashboard", () => { }); 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); + expect(await ethers.provider.getBalance(dashboardAddress)).to.equal(amount + preBalance); }); }); }); From b5ce3a4f573b5b6a9da1024e617e6c99d1ab7e63 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Tue, 14 Jan 2025 18:48:31 +0700 Subject: [PATCH 18/36] fix: to lowercase address --- contracts/0.8.25/vaults/Dashboard.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 4129b6ff8..15bc48983 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -58,7 +58,7 @@ contract Dashboard is AccessControlEnumerable { IWETH9 public immutable WETH; /// @notice ETH address convention per EIP-7528 - address public constant ETH = address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); + address public constant ETH = address(0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee); /// @notice The underlying `StakingVault` contract IStakingVault public stakingVault; @@ -409,7 +409,7 @@ contract Dashboard is AccessControlEnumerable { /** * @notice recovers ERC20 tokens or ether from the dashboard contract to sender - * @param _token Address of the token to recover, 0 for ether + * @param _token Address of the token to recover or 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee for ether */ function recoverERC20(address _token) external onlyRole(DEFAULT_ADMIN_ROLE) { uint256 _amount; From fe87ca3fb0b4519b3096bdd343b316335772bd43 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Tue, 14 Jan 2025 18:50:20 +0700 Subject: [PATCH 19/36] fix: revert to checksum --- contracts/0.8.25/vaults/Dashboard.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 15bc48983..d8a385e11 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -58,7 +58,7 @@ contract Dashboard is AccessControlEnumerable { IWETH9 public immutable WETH; /// @notice ETH address convention per EIP-7528 - address public constant ETH = address(0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee); + address public constant ETH = address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); /// @notice The underlying `StakingVault` contract IStakingVault public stakingVault; From fac1f9deed1c533f9facf6e23d25ee294e40e342 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Wed, 15 Jan 2025 20:04:23 +0700 Subject: [PATCH 20/36] feat(vaults): mint/burn steth --- contracts/0.8.25/vaults/Dashboard.sol | 105 ++++-- contracts/0.8.25/vaults/Delegation.sol | 4 +- .../0.8.25/vaults/dashboard/dashboard.test.ts | 333 ++++++++++++++++-- 3 files changed, 375 insertions(+), 67 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index d8a385e11..9a75bd730 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -114,8 +114,6 @@ contract Dashboard is AccessControlEnumerable { // reduces gas cost for `burnWsteth` // dashboard will hold STETH during this tx STETH.approve(address(WSTETH), type(uint256).max); - // allows to uncondinitialy use transferFrom in _burn - STETH.approve(address(this), type(uint256).max); emit Initialized(); } @@ -243,7 +241,8 @@ contract Dashboard is AccessControlEnumerable { * @param _wethAmount Amount of wrapped ether to fund the staking vault with */ function fundByWeth(uint256 _wethAmount) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { - if (WETH.allowance(msg.sender, address(this)) < _wethAmount) revert("ERC20: transfer amount exceeds allowance"); + if (WETH.allowance(msg.sender, address(this)) < _wethAmount) + revert Erc20Error(address(WETH), "Transfer amount exceeds allowance"); WETH.transferFrom(msg.sender, address(this), _wethAmount); WETH.withdraw(_wethAmount); @@ -280,15 +279,27 @@ contract Dashboard is AccessControlEnumerable { } /** - * @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 mintShares( address _recipient, uint256 _amountOfShares ) external payable virtual onlyRole(DEFAULT_ADMIN_ROLE) fundAndProceed { - _mint(_recipient, _amountOfShares); + _mintSharesTo(_recipient, _amountOfShares); + } + + /** + * @notice Mints stETH tokens backed by the vault to the recipient. + * @param _recipient Address of the recipient + * @param _amountOfStETH Amount of stETH to mint + */ + function mintStETH( + address _recipient, + uint256 _amountOfStETH + ) external payable virtual onlyRole(DEFAULT_ADMIN_ROLE) fundAndProceed { + _mintSharesTo(_recipient, STETH.getSharesByPooledEth(_amountOfStETH)); } /** @@ -300,7 +311,7 @@ contract Dashboard is AccessControlEnumerable { address _recipient, uint256 _amountOfWstETH ) external payable virtual onlyRole(DEFAULT_ADMIN_ROLE) fundAndProceed { - _mint(address(this), _amountOfWstETH); + _mintSharesTo(address(this), _amountOfWstETH); uint256 stETHAmount = STETH.getPooledEthByShares(_amountOfWstETH); @@ -310,10 +321,18 @@ contract Dashboard is AccessControlEnumerable { /** * @notice Burns stETH shares from the sender backed by the vault - * @param _amountOfShares Amount of shares to burn + * @param _amountOfShares Amount of stETH shares to burn */ function burnShares(uint256 _amountOfShares) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { - _burn(msg.sender, _amountOfShares); + _burnSharesFrom(msg.sender, _amountOfShares); + } + + /** + * @notice Burns stETH shares from the sender backed by the vault + * @param _amountOfStETH Amount of stETH shares to burn + */ + function burnSteth(uint256 _amountOfStETH) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { + _burnSharesFrom(msg.sender, STETH.getSharesByPooledEth(_amountOfStETH)); } /** @@ -325,13 +344,13 @@ contract Dashboard is AccessControlEnumerable { WSTETH.transferFrom(msg.sender, address(this), _amountOfWstETH); WSTETH.unwrap(_amountOfWstETH); - _burn(address(this), _amountOfWstETH); + _burnSharesFrom(address(this), _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, @@ -358,45 +377,47 @@ contract Dashboard is AccessControlEnumerable { return; } } - revert("Permit failure"); + revert Erc20Error(token, "Permit failure"); } /** - * @notice Burns stETH tokens from the sender backed by the vault using EIP-2612 Permit. - * @param _amountOfShares Amount of shares to burn + * @notice Burns stETH tokens (in shares) backed by the vault from the sender using EIP-2612 Permit. + * @param _amountOfShares Amount of stETH shares to burn * @param _permit data required for the stETH.permit() method to set the allowance */ function burnSharesWithPermit( uint256 _amountOfShares, PermitInput calldata _permit - ) - external - virtual - onlyRole(DEFAULT_ADMIN_ROLE) - trustlessPermit(address(STETH), msg.sender, address(this), _permit) - { - _burn(msg.sender, _amountOfShares); + ) external virtual onlyRole(DEFAULT_ADMIN_ROLE) safePermit(address(STETH), msg.sender, address(this), _permit) { + _burnSharesFrom(msg.sender, _amountOfShares); } /** - * @notice Burns wstETH tokens from the sender backed by the vault using EIP-2612 Permit. + * @notice Burns stETH tokens backed by the vault from the sender using EIP-2612 Permit. + * @param _amountOfStETH Amount of stETH to burn + * @param _permit data required for the stETH.permit() method to set the allowance + */ + function burnStethWithPermit( + uint256 _amountOfStETH, + PermitInput calldata _permit + ) external virtual onlyRole(DEFAULT_ADMIN_ROLE) safePermit(address(STETH), msg.sender, address(this), _permit) { + _burnSharesFrom(msg.sender, STETH.getSharesByPooledEth(_amountOfStETH)); + } + + /** + * @notice Burns wstETH tokens backed by the vault from the sender using EIP-2612 Permit. * @param _amountOfWstETH Amount of wstETH tokens to burn * @param _permit data required for the wstETH.permit() method to set the allowance */ function burnWstETHWithPermit( uint256 _amountOfWstETH, PermitInput calldata _permit - ) - external - virtual - onlyRole(DEFAULT_ADMIN_ROLE) - trustlessPermit(address(WSTETH), msg.sender, address(this), _permit) - { + ) external virtual onlyRole(DEFAULT_ADMIN_ROLE) safePermit(address(WSTETH), msg.sender, address(this), _permit) { WSTETH.transferFrom(msg.sender, address(this), _amountOfWstETH); uint256 stETHAmount = WSTETH.unwrap(_amountOfWstETH); uint256 sharesAmount = STETH.getSharesByPooledEth(stETHAmount); - _burn(address(this), sharesAmount); + _burnSharesFrom(address(this), sharesAmount); } /** @@ -412,16 +433,17 @@ contract Dashboard is AccessControlEnumerable { * @param _token Address of the token to recover or 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee for ether */ function recoverERC20(address _token) external onlyRole(DEFAULT_ADMIN_ROLE) { - uint256 _amount; if (_token == address(0)) revert ZeroArgument("_token"); + uint256 _amount; + if (_token == ETH) { _amount = address(this).balance; payable(msg.sender).transfer(_amount); } else { _amount = IERC20(_token).balanceOf(address(this)); bool success = IERC20(_token).transfer(msg.sender, _amount); - if (!success) revert("ERC20: Transfer failed"); + if (!success) revert Erc20Error(_token, "Transfer failed"); } emit ERC20Recovered(msg.sender, _token, _amount); @@ -437,9 +459,9 @@ contract Dashboard is AccessControlEnumerable { function recoverERC721(address _token, uint256 _tokenId) external onlyRole(DEFAULT_ADMIN_ROLE) { if (_token == address(0)) revert ZeroArgument("_token"); - emit ERC721Recovered(msg.sender, _token, _tokenId); - IERC721(_token).transferFrom(address(this), msg.sender, _tokenId); + + emit ERC721Recovered(msg.sender, _token, _tokenId); } // ==================== Internal Functions ==================== @@ -500,10 +522,10 @@ contract Dashboard is AccessControlEnumerable { /** * @dev Mints stETH tokens backed by the vault to a recipient - * @param _recipient Address of the recipient - * @param _amountOfShares Amount of tokens to mint + * @param _recipient Address of the recipient of shares + * @param _amountOfShares Amount of stETH shares to mint */ - function _mint(address _recipient, uint256 _amountOfShares) internal { + function _mintSharesTo(address _recipient, uint256 _amountOfShares) internal { vaultHub.mintSharesBackedByVault(address(stakingVault), _recipient, _amountOfShares); } @@ -511,8 +533,12 @@ contract Dashboard is AccessControlEnumerable { * @dev Burns stETH tokens from the sender backed by the vault * @param _amountOfShares Amount of tokens to burn */ - function _burn(address _sender, uint256 _amountOfShares) internal { - STETH.transferSharesFrom(_sender, address(vaultHub), _amountOfShares); + function _burnSharesFrom(address _sender, uint256 _amountOfShares) internal { + if (_sender == address(this)) { + STETH.transferShares(address(vaultHub), _amountOfShares); + } else { + STETH.transferSharesFrom(_sender, address(vaultHub), _amountOfShares); + } vaultHub.burnSharesBackedByVault(address(stakingVault), _amountOfShares); } @@ -568,4 +594,7 @@ contract Dashboard is AccessControlEnumerable { /// @notice Error when the contract is already initialized. error AlreadyInitialized(); + + /// @notice Error interacting with an ERC20 token + error Erc20Error(address token, string reason); } diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index 614381d93..36c869f81 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -237,7 +237,7 @@ contract Delegation is Dashboard { address _recipient, uint256 _amountOfShares ) external payable override onlyRole(TOKEN_MASTER_ROLE) fundAndProceed { - _mint(_recipient, _amountOfShares); + _mintSharesTo(_recipient, _amountOfShares); } /** @@ -248,7 +248,7 @@ contract Delegation is Dashboard { * @param _amountOfShares The amount of shares to burn. */ function burnShares(uint256 _amountOfShares) external override onlyRole(TOKEN_MASTER_ROLE) { - _burn(msg.sender, _amountOfShares); + _burnSharesFrom(msg.sender, _amountOfShares); } /** diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index bb39aa3b2..3499e5b06 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -2,6 +2,7 @@ import { expect } from "chai"; import { randomBytes } from "crypto"; import { MaxUint256, ZeroAddress } from "ethers"; import { ethers } from "hardhat"; +import { bigint } from "hardhat/internal/core/params/argumentTypes"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { setBalance, time } from "@nomicfoundation/hardhat-network-helpers"; @@ -54,7 +55,7 @@ describe("Dashboard", () => { 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]); @@ -160,7 +161,6 @@ describe("Dashboard", () => { 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); - expect(await steth.allowance(dashboardAddress, dashboardAddress)).to.equal(MaxUint256); }); }); @@ -492,11 +492,15 @@ describe("Dashboard", () => { }); context("when vault has debt", () => { - let amount: bigint; + const amountShares = ether("1"); + let amountSteth: bigint; + + before(async () => { + amountSteth = await steth.getPooledEthByShares(amountShares); + }); beforeEach(async () => { - amount = ether("1"); - await dashboard.mintShares(vaultOwner, amount); + await dashboard.mintShares(vaultOwner, amountShares); }); it("reverts on disconnect attempt", async () => { @@ -504,9 +508,9 @@ describe("Dashboard", () => { }); it("succeeds with rebalance when providing sufficient ETH", async () => { - await expect(dashboard.voluntaryDisconnect({ value: amount })) + await expect(dashboard.voluntaryDisconnect({ value: amountSteth })) .to.emit(hub, "Mock__Rebalanced") - .withArgs(amount) + .withArgs(amountSteth) .to.emit(hub, "Mock__VaultDisconnected") .withArgs(vault); }); @@ -555,8 +559,9 @@ describe("Dashboard", () => { }); it("reverts without approval", async () => { - await expect(dashboard.fundByWeth(amount, { from: vaultOwner })).to.be.revertedWith( - "ERC20: transfer amount exceeds allowance", + await expect(dashboard.fundByWeth(amount, { from: vaultOwner })).to.be.revertedWithCustomError( + dashboard, + "Erc20Error", ); }); }); @@ -623,6 +628,14 @@ describe("Dashboard", () => { }); 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).mintShares(vaultOwner, ether("1"))).to.be.revertedWithCustomError( dashboard, @@ -631,25 +644,60 @@ describe("Dashboard", () => { }); it("mints shares backed by the vault through the vault hub", async () => { - const amount = ether("1"); - await expect(dashboard.mintShares(vaultOwner, amount)) + 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 shares backed by the vault", async () => { - const amount = ether("1"); - await expect(dashboard.mintShares(vaultOwner, amount, { value: amount })) + 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, 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, amount); + .withArgs(ZeroAddress, vaultOwner, amountShares); }); }); @@ -709,6 +757,41 @@ describe("Dashboard", () => { }); }); + context("burnStETH", () => { + const amount = ether("1"); + let amountShares: bigint; + + beforeEach(async () => { + await dashboard.mintStETH(vaultOwner, amount); + amountShares = await steth.getPooledEthByShares(amount); + }); + + it("reverts if called by a non-admin", async () => { + await expect(dashboard.connect(stranger).burnSteth(amount)).to.be.revertedWithCustomError( + dashboard, + "AccessControlUnauthorizedAccount", + ); + }); + + it("burns steth backed by the vault", async () => { + expect(await steth.balanceOf(vaultOwner)).to.equal(amount); + + await expect(steth.connect(vaultOwner).approve(dashboard, amount)) + .to.emit(steth, "Approval") + .withArgs(vaultOwner, dashboard, amount); + expect(await steth.allowance(vaultOwner, dashboard)).to.equal(amount); + + await expect(dashboard.burnSteth(amount)) + .to.emit(steth, "Transfer") // transfer from owner to hub + .withArgs(vaultOwner, hub, amount) + .and.to.emit(steth, "TransferShares") // transfer shares to hub + .withArgs(vaultOwner, hub, amountShares) + .and.to.emit(steth, "SharesBurnt") // burn + .withArgs(hub, amountShares, amountShares, amountShares); + expect(await steth.balanceOf(vaultOwner)).to.equal(0); + }); + }); + context("burnWstETH", () => { const amount = ether("1"); @@ -812,7 +895,7 @@ describe("Dashboard", () => { r, s, }), - ).to.be.revertedWith("Permit failure"); + ).to.be.revertedWithCustomError(dashboard, "Erc20Error"); }); it("burns shares with permit", async () => { @@ -864,9 +947,9 @@ describe("Dashboard", () => { s, }; - await expect(dashboard.connect(vaultOwner).burnSharesWithPermit(amountShares, permitData)).to.be.revertedWith( - "Permit failure", - ); + await expect( + dashboard.connect(vaultOwner).burnSharesWithPermit(amountShares, permitData), + ).to.be.revertedWithCustomError(dashboard, "Erc20Error"); await steth.connect(vaultOwner).approve(dashboard, amountShares); @@ -948,6 +1031,202 @@ describe("Dashboard", () => { }); }); + 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.getPooledEthByShares(amountShares); + }); + + beforeEach(async () => { + const eip712helper = await ethers.deployContract("EIP712StETH", [steth]); + await steth.initializeEIP712StETH(eip712helper); + }); + + it("reverts if called by a non-admin", 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; + + await expect( + dashboard.connect(stranger).burnStethWithPermit(amountSteth, { + 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), + }; + + const signature = await signPermit(await stethDomain(steth), permit, vaultOwner); + const { deadline, value } = permit; + const { v, r, s } = signature; + + await expect( + dashboard.connect(vaultOwner).burnStethWithPermit(amountSteth, { + value, + deadline, + v, + r, + s, + }), + ).to.be.revertedWithCustomError(dashboard, "Erc20Error"); + }); + + 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).burnStethWithPermit(amountSteth, { + value, + deadline, + v, + r, + s, + }); + + await expect(result).to.emit(steth, "Approval").withArgs(vaultOwner, dashboard, amountShares); // approve steth from vault owner to dashboard + await expect(result).to.emit(steth, "Transfer").withArgs(vaultOwner, hub, amountShares); // transfer steth to hub + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountShares, amountShares, amountShares); // burn steth + + expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - amountShares); + }); + + it("succeeds if has allowance", async () => { + const permit = { + owner: vaultOwner.address, + spender: stranger.address, // invalid spender + value: amountShares, + 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).burnStethWithPermit(amountSteth, permitData), + ).to.be.revertedWithCustomError(dashboard, "Erc20Error"); + + await steth.connect(vaultOwner).approve(dashboard, amountShares); + + const balanceBefore = await steth.balanceOf(vaultOwner); + const result = await dashboard.connect(vaultOwner).burnStethWithPermit(amountSteth, permitData); + + await expect(result).to.emit(steth, "Transfer").withArgs(vaultOwner, hub, amountShares); // transfer steth to hub + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountShares, amountShares, amountShares); // burn steth + + expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - 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: 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 amountShares = ether("1"); @@ -1005,7 +1284,7 @@ describe("Dashboard", () => { r, s, }), - ).to.be.revertedWith("Permit failure"); + ).to.be.revertedWithCustomError(dashboard, "Erc20Error"); }); it("burns wstETH with permit", async () => { @@ -1060,9 +1339,9 @@ describe("Dashboard", () => { s, }; - await expect(dashboard.connect(vaultOwner).burnWstETHWithPermit(amountShares, permitData)).to.be.revertedWith( - "Permit failure", - ); + await expect( + dashboard.connect(vaultOwner).burnWstETHWithPermit(amountShares, permitData), + ).to.be.revertedWithCustomError(dashboard, "Erc20Error"); await wsteth.connect(vaultOwner).approve(dashboard, amountShares); From 710ccac00ffd2ce7fdb8edb2ff7a623b22f52dd4 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Wed, 15 Jan 2025 21:06:15 +0700 Subject: [PATCH 21/36] docs: burn shares permit comment --- contracts/0.8.25/vaults/Dashboard.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 9a75bd730..dd082bd12 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -381,9 +381,9 @@ contract Dashboard is AccessControlEnumerable { } /** - * @notice Burns stETH tokens (in shares) backed by the vault from the sender using EIP-2612 Permit. + * @notice Burns stETH tokens (in shares) backed by the vault from the sender using EIP-2612 Permit (with value in stETH). * @param _amountOfShares Amount of stETH shares to burn - * @param _permit data required for the stETH.permit() method to set the allowance + * @param _permit data required for the stETH.permit() with amount in stETH */ function burnSharesWithPermit( uint256 _amountOfShares, From 77d873953b5af666880945f5938e0ff295063238 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Thu, 16 Jan 2025 17:58:57 +0700 Subject: [PATCH 22/36] fix: use round up --- contracts/0.8.25/vaults/Dashboard.sol | 2 +- .../0.8.25/vaults/dashboard/dashboard.test.ts | 137 +++++++++--------- 2 files changed, 72 insertions(+), 67 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index dd082bd12..a0daa0437 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -313,7 +313,7 @@ contract Dashboard is AccessControlEnumerable { ) external payable virtual onlyRole(DEFAULT_ADMIN_ROLE) fundAndProceed { _mintSharesTo(address(this), _amountOfWstETH); - uint256 stETHAmount = STETH.getPooledEthByShares(_amountOfWstETH); + uint256 stETHAmount = STETH.getPooledEthBySharesRoundUp(_amountOfWstETH); uint256 wstETHAmount = WSTETH.wrap(stETHAmount); WSTETH.transfer(_recipient, wstETHAmount); diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 3499e5b06..f4e81d446 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -702,15 +702,15 @@ describe("Dashboard", () => { }); 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", ); @@ -719,12 +719,12 @@ describe("Dashboard", () => { 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, amount); - await expect(result).to.emit(wsteth, "Transfer").withArgs(ZeroAddress, dashboard, amount); + await expect(result).to.emit(steth, "Transfer").withArgs(dashboard, wsteth, amountSteth); + await expect(result).to.emit(wsteth, "Transfer").withArgs(ZeroAddress, dashboard, amountWsteth); - expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore + amount); + expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore + amountWsteth); }); }); @@ -738,100 +738,102 @@ describe("Dashboard", () => { 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(amountShares); + expect(await steth.balanceOf(vaultOwner)).to.equal(amountSteth); - await expect(steth.connect(vaultOwner).approve(dashboard, amountShares)) + await expect(steth.connect(vaultOwner).approve(dashboard, amountSteth)) .to.emit(steth, "Approval") - .withArgs(vaultOwner, dashboard, amountShares); - expect(await steth.allowance(vaultOwner, dashboard)).to.equal(amountShares); + .withArgs(vaultOwner, dashboard, amountSteth); + expect(await steth.allowance(vaultOwner, dashboard)).to.equal(amountSteth); await expect(dashboard.burnShares(amountShares)) .to.emit(steth, "Transfer") // transfer from owner to hub - .withArgs(vaultOwner, hub, amountShares) + .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, amountShares, amountShares, amountShares); + .withArgs(hub, amountSteth, amountSteth, amountShares); expect(await steth.balanceOf(vaultOwner)).to.equal(0); }); }); context("burnStETH", () => { - const amount = ether("1"); - let amountShares: bigint; + const amountShares = ether("1"); + let amountSteth: bigint; beforeEach(async () => { - await dashboard.mintStETH(vaultOwner, amount); - amountShares = await steth.getPooledEthByShares(amount); + amountSteth = await steth.getPooledEthByShares(amountShares); + await dashboard.mintStETH(vaultOwner, amountSteth); }); it("reverts if called by a non-admin", async () => { - await expect(dashboard.connect(stranger).burnSteth(amount)).to.be.revertedWithCustomError( + 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(amount); + 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.burnSteth(amount)) + await expect(dashboard.burnSteth(amountSteth)) .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, amountShares) .and.to.emit(steth, "SharesBurnt") // burn - .withArgs(hub, amountShares, amountShares, amountShares); + .withArgs(hub, amountSteth, amountSteth, amountShares); expect(await steth.balanceOf(vaultOwner)).to.equal(0); }); }); context("burnWstETH", () => { - const amount = ether("1"); + const amountWsteth = ether("1"); before(async () => { // mint shares to the vault owner for the burn - await dashboard.mintShares(vaultOwner, amount + amount); + 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( + await expect(dashboard.connect(stranger).burnWstETH(amountWsteth)).to.be.revertedWithCustomError( dashboard, "AccessControlUnauthorizedAccount", ); }); 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); }); }); @@ -842,7 +844,7 @@ describe("Dashboard", () => { before(async () => { // mint steth to the vault owner for the burn await dashboard.mintShares(vaultOwner, amountShares); - amountSteth = await steth.getPooledEthByShares(amountShares); + amountSteth = await steth.getPooledEthBySharesRoundUp(amountShares); }); beforeEach(async () => { @@ -920,18 +922,18 @@ describe("Dashboard", () => { s, }); - await expect(result).to.emit(steth, "Approval").withArgs(vaultOwner, dashboard, amountShares); // approve steth from vault owner to dashboard - await expect(result).to.emit(steth, "Transfer").withArgs(vaultOwner, hub, amountShares); // transfer steth to hub - await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountShares, amountShares, amountShares); // 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 - amountShares); + 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: amountShares, + value: amountSteth, nonce: (await steth.nonces(vaultOwner)) + 1n, // invalid nonce deadline: BigInt(await time.latest()) + days(1n), }; @@ -951,15 +953,15 @@ describe("Dashboard", () => { dashboard.connect(vaultOwner).burnSharesWithPermit(amountShares, permitData), ).to.be.revertedWithCustomError(dashboard, "Erc20Error"); - await steth.connect(vaultOwner).approve(dashboard, amountShares); + 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, amountShares); // transfer steth to hub - await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountShares, amountShares, amountShares); // 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 - amountShares); + expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - amountSteth); }); it("succeeds with rebalanced shares - 1 share = 0.5 steth", async () => { @@ -1038,7 +1040,7 @@ describe("Dashboard", () => { before(async () => { // mint steth to the vault owner for the burn await dashboard.mintShares(vaultOwner, amountShares); - amountSteth = await steth.getPooledEthByShares(amountShares); + amountSteth = await steth.getPooledEthBySharesRoundUp(amountShares); }); beforeEach(async () => { @@ -1116,18 +1118,18 @@ describe("Dashboard", () => { s, }); - await expect(result).to.emit(steth, "Approval").withArgs(vaultOwner, dashboard, amountShares); // approve steth from vault owner to dashboard - await expect(result).to.emit(steth, "Transfer").withArgs(vaultOwner, hub, amountShares); // transfer steth to hub - await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountShares, amountShares, amountShares); // 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 - amountShares); + 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: amountShares, + value: amountSteth, nonce: (await steth.nonces(vaultOwner)) + 1n, // invalid nonce deadline: BigInt(await time.latest()) + days(1n), }; @@ -1147,15 +1149,15 @@ describe("Dashboard", () => { dashboard.connect(vaultOwner).burnStethWithPermit(amountSteth, permitData), ).to.be.revertedWithCustomError(dashboard, "Erc20Error"); - await steth.connect(vaultOwner).approve(dashboard, amountShares); + await steth.connect(vaultOwner).approve(dashboard, amountSteth); const balanceBefore = await steth.balanceOf(vaultOwner); const result = await dashboard.connect(vaultOwner).burnStethWithPermit(amountSteth, permitData); - await expect(result).to.emit(steth, "Transfer").withArgs(vaultOwner, hub, amountShares); // transfer steth to hub - await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountShares, amountShares, amountShares); // 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 - amountShares); + expect(await steth.balanceOf(vaultOwner)).to.equal(balanceBefore - amountSteth); }); it("succeeds with rebalanced shares - 1 share = 0.5 steth", async () => { @@ -1229,14 +1231,16 @@ describe("Dashboard", () => { context("burnWstETHWithPermit", () => { 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.mintShares(vaultOwner, amountShares); // approve for wsteth wrap - await steth.connect(vaultOwner).approve(wsteth, amountShares); + 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(amountShares); + await wsteth.connect(vaultOwner).wrap(amountSteth); }); it("reverts if called by a non-admin", async () => { @@ -1302,6 +1306,7 @@ describe("Dashboard", () => { const wstethBalanceBefore = await wsteth.balanceOf(vaultOwner); const stethBalanceBefore = await steth.balanceOf(vaultOwner); + const result = await dashboard.connect(vaultOwner).burnWstETHWithPermit(amountShares, { value, deadline, @@ -1312,8 +1317,8 @@ describe("Dashboard", () => { 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, amountShares); // uwrap wsteth to steth - await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountShares, amountShares, amountShares); // burn steth + 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 - amountShares); @@ -1350,8 +1355,8 @@ describe("Dashboard", () => { const result = await dashboard.connect(vaultOwner).burnWstETHWithPermit(amountShares, permitData); 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, amountShares); // uwrap wsteth to steth - await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountShares, amountShares, amountShares); // burn steth + 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 - amountShares); From 090309575984c9f21095b898c01e18d9cb845fdc Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Thu, 16 Jan 2025 19:19:28 +0700 Subject: [PATCH 23/36] feat: fix burnWsteth --- contracts/0.8.25/vaults/Dashboard.sol | 35 ++++++++++----- .../0.8.25/vaults/dashboard/dashboard.test.ts | 43 ++++++++++++++++++- 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index a0daa0437..9dfe6f730 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -332,7 +332,7 @@ contract Dashboard is AccessControlEnumerable { * @param _amountOfStETH Amount of stETH shares to burn */ function burnSteth(uint256 _amountOfStETH) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { - _burnSharesFrom(msg.sender, STETH.getSharesByPooledEth(_amountOfStETH)); + _burnStETH(_amountOfStETH); } /** @@ -341,10 +341,7 @@ contract Dashboard is AccessControlEnumerable { * @dev The _amountOfWstETH = _amountOfShares by design */ function burnWstETH(uint256 _amountOfWstETH) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { - WSTETH.transferFrom(msg.sender, address(this), _amountOfWstETH); - WSTETH.unwrap(_amountOfWstETH); - - _burnSharesFrom(address(this), _amountOfWstETH); + _burnWstETH(_amountOfWstETH); } /** @@ -401,7 +398,7 @@ contract Dashboard is AccessControlEnumerable { uint256 _amountOfStETH, PermitInput calldata _permit ) external virtual onlyRole(DEFAULT_ADMIN_ROLE) safePermit(address(STETH), msg.sender, address(this), _permit) { - _burnSharesFrom(msg.sender, STETH.getSharesByPooledEth(_amountOfStETH)); + _burnStETH(_amountOfStETH); } /** @@ -413,11 +410,7 @@ contract Dashboard is AccessControlEnumerable { uint256 _amountOfWstETH, PermitInput calldata _permit ) external virtual onlyRole(DEFAULT_ADMIN_ROLE) safePermit(address(WSTETH), msg.sender, address(this), _permit) { - WSTETH.transferFrom(msg.sender, address(this), _amountOfWstETH); - uint256 stETHAmount = WSTETH.unwrap(_amountOfWstETH); - uint256 sharesAmount = STETH.getSharesByPooledEth(stETHAmount); - - _burnSharesFrom(address(this), sharesAmount); + _burnWstETH(_amountOfWstETH); } /** @@ -529,6 +522,26 @@ contract Dashboard is AccessControlEnumerable { vaultHub.mintSharesBackedByVault(address(stakingVault), _recipient, _amountOfShares); } + /** + * @dev Burns stETH tokens from the sender backed by the vault + * @param _amountOfStETH Amount of tokens to burn + */ + function _burnStETH(uint256 _amountOfStETH) internal { + _burnSharesFrom(msg.sender, STETH.getSharesByPooledEth(_amountOfStETH)); + } + + /** + * @dev Burns wstETH tokens from the sender backed by the vault + * @param _amountOfWstETH Amount of tokens to burn + */ + function _burnWstETH(uint256 _amountOfWstETH) internal { + WSTETH.transferFrom(msg.sender, address(this), _amountOfWstETH); + uint256 stETHAmount = WSTETH.unwrap(_amountOfWstETH); + uint256 sharesAmount = STETH.getSharesByPooledEth(stETHAmount); + + _burnSharesFrom(address(this), sharesAmount); + } + /** * @dev Burns stETH tokens from the sender backed by the vault * @param _amountOfShares Amount of tokens to burn diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index f4e81d446..27d7dec98 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -2,7 +2,6 @@ import { expect } from "chai"; import { randomBytes } from "crypto"; import { MaxUint256, ZeroAddress } from "ethers"; import { ethers } from "hardhat"; -import { bigint } from "hardhat/internal/core/params/argumentTypes"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { setBalance, time } from "@nomicfoundation/hardhat-network-helpers"; @@ -835,6 +834,48 @@ describe("Dashboard", () => { expect(await steth.balanceOf(vaultOwner)).to.equal(stethBalanceBefore); 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"); + }); + + for (let weiWsteth = 1n; weiWsteth <= 10n; weiWsteth++) { + it(`burns ${weiWsteth} wei wsteth`, async () => { + const weiStethUp = await steth.getPooledEthBySharesRoundUp(weiWsteth); + const weiStethDown = await steth.getPooledEthByShares(weiWsteth); + // !!! weird + const weiWstethDown = await steth.getSharesByPooledEth(weiStethDown); + + // 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 wsteth.connect(vaultOwner).wrap(weiStethUp); + + const wstethBalanceBefore = await wsteth.balanceOf(vaultOwner); + expect(wstethBalanceBefore).to.equal(weiWsteth); + const stethBalanceBefore = await steth.balanceOf(vaultOwner); + + // approve wsteth to dashboard contract + await wsteth.connect(vaultOwner).approve(dashboard, weiWsteth); + + const result = await dashboard.burnWstETH(weiWsteth); + + await expect(result).to.emit(wsteth, "Transfer").withArgs(vaultOwner, dashboard, weiWsteth); // transfer wsteth to dashboard + 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, weiWsteth); // burn wsteth + + // TODO: weird steth value + //await expect(result).to.emit(steth, "Transfer").withArgs(dashboard, hub, stethRoundDown); + await expect(result).to.emit(steth, "TransferShares").withArgs(dashboard, hub, weiWstethDown); // transfer shares to hub + // TODO: weird everything + // await expect(result) + // .to.emit(steth, "SharesBurnt") + // .withArgs(hub, stethRoundDown, stethRoundDown, weiWstethRoundDown); // burn steth (mocked event data) + + expect(await steth.balanceOf(vaultOwner)).to.equal(stethBalanceBefore); + expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore - weiWsteth); + }); + } }); context("burnSharesWithPermit", () => { From 5300444816c379ec7dc357b2ecf03b7d816f8dbe Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Thu, 16 Jan 2025 19:48:50 +0700 Subject: [PATCH 24/36] feat(test): mint wsteth wei tests --- test/0.8.25/vaults/dashboard/dashboard.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 27d7dec98..251f72cd1 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -725,6 +725,24 @@ describe("Dashboard", () => { expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore + amountWsteth); }); + + it("reverts on zero mint", async () => { + await expect(dashboard.mintWstETH(vaultOwner, 0n)).to.be.revertedWith("wstETH: can't wrap zero stETH"); + }); + + 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(vaultOwner)).to.equal(wstethBalanceBefore + weiWsteth); + }); + } }); context("burnShares", () => { From f24c48e249c7710b93553ec4b6059de8a78139be Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Fri, 17 Jan 2025 18:29:05 +0700 Subject: [PATCH 25/36] test: variable wei/shareRate burnWsteth test --- .../contracts/VaultHub__MockForDashboard.sol | 10 +- .../0.8.25/vaults/dashboard/dashboard.test.ts | 102 +++++++++++------- 2 files changed, 72 insertions(+), 40 deletions(-) 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 d885fa767..9a494969c 100644 --- a/test/0.8.25/vaults/dashboard/contracts/VaultHub__MockForDashboard.sol +++ b/test/0.8.25/vaults/dashboard/contracts/VaultHub__MockForDashboard.sol @@ -46,9 +46,11 @@ contract VaultHub__MockForDashboard { vaultSockets[vault].sharesMinted = uint96(vaultSockets[vault].sharesMinted + amount); } - function burnSharesBackedByVault(address vault, uint256 amount) external { - steth.burnExternalShares(amount); - vaultSockets[vault].sharesMinted = uint96(vaultSockets[vault].sharesMinted - 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 { @@ -60,4 +62,6 @@ contract VaultHub__MockForDashboard { 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 251f72cd1..140bca169 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -857,43 +857,71 @@ describe("Dashboard", () => { await expect(dashboard.burnWstETH(0n)).to.be.revertedWith("wstETH: zero amount unwrap not allowed"); }); - for (let weiWsteth = 1n; weiWsteth <= 10n; weiWsteth++) { - it(`burns ${weiWsteth} wei wsteth`, async () => { - const weiStethUp = await steth.getPooledEthBySharesRoundUp(weiWsteth); - const weiStethDown = await steth.getPooledEthByShares(weiWsteth); - // !!! weird - const weiWstethDown = await steth.getSharesByPooledEth(weiStethDown); - - // 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 wsteth.connect(vaultOwner).wrap(weiStethUp); - - const wstethBalanceBefore = await wsteth.balanceOf(vaultOwner); - expect(wstethBalanceBefore).to.equal(weiWsteth); - const stethBalanceBefore = await steth.balanceOf(vaultOwner); - - // approve wsteth to dashboard contract - await wsteth.connect(vaultOwner).approve(dashboard, weiWsteth); - - const result = await dashboard.burnWstETH(weiWsteth); - - await expect(result).to.emit(wsteth, "Transfer").withArgs(vaultOwner, dashboard, weiWsteth); // transfer wsteth to dashboard - 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, weiWsteth); // burn wsteth - - // TODO: weird steth value - //await expect(result).to.emit(steth, "Transfer").withArgs(dashboard, hub, stethRoundDown); - await expect(result).to.emit(steth, "TransferShares").withArgs(dashboard, hub, weiWstethDown); // transfer shares to hub - // TODO: weird everything - // await expect(result) - // .to.emit(steth, "SharesBurnt") - // .withArgs(hub, stethRoundDown, stethRoundDown, weiWstethRoundDown); // burn steth (mocked event data) - - expect(await steth.balanceOf(vaultOwner)).to.equal(stethBalanceBefore); - expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore - weiWsteth); - }); - } + 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 + if (weiShareDown === 0n) { + 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("burnSharesWithPermit", () => { From 90f64d4e42e2aec6e2c5d42851a1372913e67684 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Fri, 17 Jan 2025 18:36:37 +0700 Subject: [PATCH 26/36] fix: burner event order --- contracts/0.8.9/Burner.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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); } /** From 5a907fcb1c3e4a5c061b164c13022a295ad33c9d Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Fri, 17 Jan 2025 18:40:13 +0700 Subject: [PATCH 27/36] docs: comment --- contracts/0.8.25/vaults/Dashboard.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 9dfe6f730..15efbe584 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -303,7 +303,7 @@ contract Dashboard is AccessControlEnumerable { } /** - * @notice Mints wstETH tokens backed by the vault to a recipient. Approvals for the passed amounts should be done before. + * @notice Mints wstETH tokens backed by the vault to a recipient. * @param _recipient Address of the recipient * @param _amountOfWstETH Amount of tokens to mint */ @@ -320,7 +320,7 @@ contract Dashboard is AccessControlEnumerable { } /** - * @notice Burns stETH shares from the sender backed by the vault + * @notice Burns stETH shares from the sender backed by the vault. Approvals for the passed amounts should be done before. * @param _amountOfShares Amount of stETH shares to burn */ function burnShares(uint256 _amountOfShares) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { @@ -328,7 +328,7 @@ contract Dashboard is AccessControlEnumerable { } /** - * @notice Burns stETH shares from the sender backed by the vault + * @notice Burns stETH shares from the sender backed by the vault. Approvals for the passed amounts should be done before. * @param _amountOfStETH Amount of stETH shares to burn */ function burnSteth(uint256 _amountOfStETH) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { From 5215e35a9afe60c99e50d2c4d2cffaddf7cb6540 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Fri, 17 Jan 2025 18:52:43 +0700 Subject: [PATCH 28/36] docs: add notice --- contracts/0.8.25/vaults/Dashboard.sol | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 15efbe584..18e004b5f 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -336,9 +336,9 @@ contract Dashboard is AccessControlEnumerable { } /** - * @notice Burns wstETH tokens from the sender backed by the vault. Approvals for the passed amounts should be done before. + * @notice Burns wstETH tokens from the sender backed by the vault. Approvals for the passed amounts should be done before. * @param _amountOfWstETH Amount of wstETH tokens to burn - * @dev The _amountOfWstETH = _amountOfShares by design + * @dev Will fail on ~1 wei (depending on current share rate) wstETH due to rounding error inside wstETH */ function burnWstETH(uint256 _amountOfWstETH) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { _burnWstETH(_amountOfWstETH); @@ -405,6 +405,7 @@ contract Dashboard is AccessControlEnumerable { * @notice Burns wstETH tokens backed by the vault from the sender using EIP-2612 Permit. * @param _amountOfWstETH Amount of wstETH tokens to burn * @param _permit data required for the wstETH.permit() method to set the allowance + * @dev Will fail on 1 wei (depending on current share rate) wstETH due to rounding error inside wstETH */ function burnWstETHWithPermit( uint256 _amountOfWstETH, From bea9a49c30db879d5328045bbadfaff0c448d475 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Wed, 22 Jan 2025 15:31:01 +0700 Subject: [PATCH 29/36] test: fix oz version --- .../vaults/dashboard/contracts/ERC721_MockForDashboard.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/0.8.25/vaults/dashboard/contracts/ERC721_MockForDashboard.sol b/test/0.8.25/vaults/dashboard/contracts/ERC721_MockForDashboard.sol index 130ce0f81..5b696e35c 100644 --- a/test/0.8.25/vaults/dashboard/contracts/ERC721_MockForDashboard.sol +++ b/test/0.8.25/vaults/dashboard/contracts/ERC721_MockForDashboard.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.25; -import {ERC721} from "@openzeppelin/contracts-v5.0.2/token/ERC721/ERC721.sol"; +import {ERC721} from "@openzeppelin/contracts-v5.2/token/ERC721/ERC721.sol"; contract ERC721_MockForDashboard is ERC721 { constructor() ERC721("MockERC721", "M721") {} From 616a0f8b931136143483ef19f86e7abe82794c98 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Thu, 23 Jan 2025 12:11:19 +0700 Subject: [PATCH 30/36] fix: use safeERC20 --- contracts/0.8.25/vaults/Dashboard.sol | 177 +++++++++--------- .../contracts/VaultHub__MockForDashboard.sol | 4 + .../0.8.25/vaults/dashboard/dashboard.test.ts | 93 +++++---- 3 files changed, 148 insertions(+), 126 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 591e5a894..6cf7caac1 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -7,6 +7,7 @@ pragma solidity 0.8.25; import {AccessControlEnumerable} from "@openzeppelin/contracts-v5.2/access/extensions/AccessControlEnumerable.sol"; import {OwnableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/OwnableUpgradeable.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"; @@ -106,7 +107,7 @@ contract Dashboard is AccessControlEnumerable { vaultHub = VaultHub(stakingVault().vaultHub()); _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); - // reduces gas cost for `burnWsteth` + // reduces gas cost for `mintWsteth` // dashboard will hold STETH during this tx STETH.approve(address(WSTETH), type(uint256).max); @@ -180,11 +181,11 @@ contract Dashboard is AccessControlEnumerable { } /** - * @notice Returns the maximum number of shares that can be minted with deposited ether. + * @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 projectedMintableShares(uint256 _etherToFund) external view returns (uint256) { + function projectedNewMintableShares(uint256 _etherToFund) external view returns (uint256) { uint256 _totalShares = _totalMintableShares(stakingVault().valuation() + _etherToFund); uint256 _sharesMinted = vaultSocket().sharesMinted; @@ -205,9 +206,7 @@ contract Dashboard is AccessControlEnumerable { /** * @dev Receive function to accept ether */ - 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. @@ -232,17 +231,14 @@ contract Dashboard is AccessControlEnumerable { } /** - * @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 apporved to this contract. + * @param _amountWETH Amount of wrapped ether to fund the staking vault with */ - function fundByWeth(uint256 _wethAmount) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { - if (WETH.allowance(msg.sender, address(this)) < _wethAmount) - revert Erc20Error(address(WETH), "Transfer amount exceeds allowance"); - - WETH.transferFrom(msg.sender, address(this), _wethAmount); - WETH.withdraw(_wethAmount); + function fundByWeth(uint256 _amountWETH) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { + SafeERC20.safeTransferFrom(WETH, msg.sender, address(this), _amountWETH); + WETH.withdraw(_amountWETH); - _fund(_wethAmount); + _fund(_amountWETH); } /** @@ -262,7 +258,7 @@ contract Dashboard is AccessControlEnumerable { function withdrawToWeth(address _recipient, uint256 _ether) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { _withdraw(address(this), _ether); WETH.deposit{value: _ether}(); - WETH.transfer(_recipient, _ether); + SafeERC20.safeTransfer(WETH, _recipient, _ether); } /** @@ -276,67 +272,70 @@ contract Dashboard is AccessControlEnumerable { /** * @notice Mints stETH tokens backed by the vault to the recipient. * @param _recipient Address of the recipient - * @param _amountOfShares Amount of stETH shares to mint + * @param _amountShares Amount of stETH shares to mint */ function mintShares( address _recipient, - uint256 _amountOfShares + uint256 _amountShares ) external payable virtual onlyRole(DEFAULT_ADMIN_ROLE) fundAndProceed { - _mintSharesTo(_recipient, _amountOfShares); + _mintSharesTo(_recipient, _amountShares); } /** * @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 _amountOfStETH Amount of stETH to mint + * @param _amountStETH Amount of stETH to mint */ function mintStETH( address _recipient, - uint256 _amountOfStETH + uint256 _amountStETH ) external payable virtual onlyRole(DEFAULT_ADMIN_ROLE) fundAndProceed { - _mintSharesTo(_recipient, STETH.getSharesByPooledEth(_amountOfStETH)); + _mintSharesTo(_recipient, STETH.getSharesByPooledEth(_amountStETH)); } /** * @notice Mints wstETH tokens backed by the vault to a recipient. * @param _recipient Address of the recipient - * @param _amountOfWstETH Amount of tokens to mint + * @param _amountWstETH Amount of tokens to mint */ function mintWstETH( address _recipient, - uint256 _amountOfWstETH + uint256 _amountWstETH ) external payable virtual onlyRole(DEFAULT_ADMIN_ROLE) fundAndProceed { - _mintSharesTo(address(this), _amountOfWstETH); + _mintSharesTo(address(this), _amountWstETH); - uint256 stETHAmount = STETH.getPooledEthBySharesRoundUp(_amountOfWstETH); + uint256 mintedStETH = STETH.getPooledEthBySharesRoundUp(_amountWstETH); - uint256 wstETHAmount = WSTETH.wrap(stETHAmount); - WSTETH.transfer(_recipient, wstETHAmount); + uint256 wrappedWstETH = WSTETH.wrap(mintedStETH); + WSTETH.transfer(_recipient, wrappedWstETH); } /** - * @notice Burns stETH shares from the sender backed by the vault. Approvals for the passed amounts should be done before. - * @param _amountOfShares Amount of stETH shares to burn + * @notice Burns stETH shares from the sender backed by the vault. Expects corresponding amount of stETH apporved to this contract. + * @param _amountShares Amount of stETH shares to burn */ - function burnShares(uint256 _amountOfShares) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { - _burnSharesFrom(msg.sender, _amountOfShares); + function burnShares(uint256 _amountShares) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { + _burnSharesFrom(msg.sender, _amountShares); } /** - * @notice Burns stETH shares from the sender backed by the vault. Approvals for the passed amounts should be done before. - * @param _amountOfStETH Amount of stETH shares to burn + * @notice Burns stETH shares from the sender backed by the vault. Expects stETH amount apporved to this contract. + * !NB: this will revert with `VaultHub.ZeroArgument("_amountOfShares")` if the amount of stETH is less than 1 share + * @param _amountStETH Amount of stETH shares to burn */ - function burnSteth(uint256 _amountOfStETH) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { - _burnStETH(_amountOfStETH); + function burnSteth(uint256 _amountStETH) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { + _burnStETH(_amountStETH); } /** - * @notice Burns wstETH tokens from the sender backed by the vault. Approvals for the passed amounts should be done before. - * @param _amountOfWstETH Amount of wstETH tokens to burn - * @dev Will fail on ~1 wei (depending on current share rate) wstETH due to rounding error inside wstETH + * @notice Burns wstETH tokens from the sender backed by the vault. Expects wstETH amount apporved to this contract. + * !NB: this will revert with `VaultHub.ZeroArgument("_amountOfShares")` on 1 wei of wstETH due to rounding insie wstETH unwrap method + * @param _amountWstETH Amount of wstETH tokens to burn + */ - function burnWstETH(uint256 _amountOfWstETH) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { - _burnWstETH(_amountOfWstETH); + function burnWstETH(uint256 _amountWstETH) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { + _burnWstETH(_amountWstETH); } /** @@ -369,44 +368,45 @@ contract Dashboard is AccessControlEnumerable { return; } } - revert Erc20Error(token, "Permit failure"); + revert InvalidPermit(token); } /** - * @notice Burns stETH tokens (in shares) backed by the vault from the sender using EIP-2612 Permit (with value in stETH). - * @param _amountOfShares Amount of stETH shares to burn + * @notice Burns stETH tokens (in shares) backed by the vault from the sender using permit (with value in stETH). + * @param _amountShares Amount of stETH shares to burn * @param _permit data required for the stETH.permit() with amount in stETH */ function burnSharesWithPermit( - uint256 _amountOfShares, + uint256 _amountShares, PermitInput calldata _permit ) external virtual onlyRole(DEFAULT_ADMIN_ROLE) safePermit(address(STETH), msg.sender, address(this), _permit) { - _burnSharesFrom(msg.sender, _amountOfShares); + _burnSharesFrom(msg.sender, _amountShares); } /** - * @notice Burns stETH tokens backed by the vault from the sender using EIP-2612 Permit. - * @param _amountOfStETH Amount of stETH to burn + * @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 _amountStETH Amount of stETH to burn * @param _permit data required for the stETH.permit() method to set the allowance */ function burnStethWithPermit( - uint256 _amountOfStETH, + uint256 _amountStETH, PermitInput calldata _permit ) external virtual onlyRole(DEFAULT_ADMIN_ROLE) safePermit(address(STETH), msg.sender, address(this), _permit) { - _burnStETH(_amountOfStETH); + _burnStETH(_amountStETH); } /** * @notice Burns wstETH tokens backed by the vault from the sender using EIP-2612 Permit. - * @param _amountOfWstETH Amount of wstETH tokens to burn + * !NB: this will revert with `VaultHub.ZeroArgument("_amountOfShares")` on 1 wei of wstETH due to rounding inside wstETH unwrap method + * @param _amountWstETH Amount of wstETH tokens to burn * @param _permit data required for the wstETH.permit() method to set the allowance - * @dev Will fail on 1 wei (depending on current share rate) wstETH due to rounding error inside wstETH */ function burnWstETHWithPermit( - uint256 _amountOfWstETH, + uint256 _amountWstETH, PermitInput calldata _permit ) external virtual onlyRole(DEFAULT_ADMIN_ROLE) safePermit(address(WSTETH), msg.sender, address(this), _permit) { - _burnWstETH(_amountOfWstETH); + _burnWstETH(_amountWstETH); } /** @@ -420,22 +420,24 @@ contract Dashboard is AccessControlEnumerable { /** * @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) external onlyRole(DEFAULT_ADMIN_ROLE) { + function recoverERC20(address _token, address _recipient) external onlyRole(DEFAULT_ADMIN_ROLE) { if (_token == address(0)) revert ZeroArgument("_token"); + if (_recipient == address(0)) revert ZeroArgument("_recipient"); uint256 _amount; if (_token == ETH) { _amount = address(this).balance; - payable(msg.sender).transfer(_amount); + (bool success, ) = payable(_recipient).call{value: _amount}(""); + if (!success) revert EthTransferFailed(_recipient, _amount); } else { _amount = IERC20(_token).balanceOf(address(this)); - bool success = IERC20(_token).transfer(msg.sender, _amount); - if (!success) revert Erc20Error(_token, "Transfer failed"); + SafeERC20.safeTransfer(IERC20(_token), _recipient, _amount); } - emit ERC20Recovered(msg.sender, _token, _amount); + emit ERC20Recovered(_recipient, _token, _amount); } /** @@ -444,13 +446,15 @@ contract Dashboard is AccessControlEnumerable { * * @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) external onlyRole(DEFAULT_ADMIN_ROLE) { + 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).transferFrom(address(this), msg.sender, _tokenId); + IERC721(_token).safeTransferFrom(address(this), _recipient, _tokenId); - emit ERC721Recovered(msg.sender, _token, _tokenId); + emit ERC721Recovered(_recipient, _token, _tokenId); } // ==================== Internal Functions ==================== @@ -512,52 +516,44 @@ contract Dashboard is AccessControlEnumerable { /** * @dev Mints stETH tokens backed by the vault to a recipient * @param _recipient Address of the recipient of shares - * @param _amountOfShares Amount of stETH shares to mint + * @param _amountShares Amount of stETH shares to mint */ - function _mintSharesTo(address _recipient, uint256 _amountOfShares) internal { - vaultHub.mintSharesBackedByVault(address(stakingVault()), _recipient, _amountOfShares); - } - - function _depositToBeaconChain( - uint256 _numberOfDeposits, - bytes calldata _pubkeys, - bytes calldata _signatures - ) internal { - stakingVault().depositToBeaconChain(_numberOfDeposits, _pubkeys, _signatures); + function _mintSharesTo(address _recipient, uint256 _amountShares) internal { + vaultHub.mintSharesBackedByVault(address(stakingVault()), _recipient, _amountShares); } /** * @dev Burns stETH tokens from the sender backed by the vault - * @param _amountOfStETH Amount of tokens to burn + * @param _amountStETH Amount of tokens to burn */ - function _burnStETH(uint256 _amountOfStETH) internal { - _burnSharesFrom(msg.sender, STETH.getSharesByPooledEth(_amountOfStETH)); + function _burnStETH(uint256 _amountStETH) internal { + _burnSharesFrom(msg.sender, STETH.getSharesByPooledEth(_amountStETH)); } /** * @dev Burns wstETH tokens from the sender backed by the vault - * @param _amountOfWstETH Amount of tokens to burn + * @param _amountWstETH Amount of tokens to burn */ - function _burnWstETH(uint256 _amountOfWstETH) internal { - WSTETH.transferFrom(msg.sender, address(this), _amountOfWstETH); - uint256 stETHAmount = WSTETH.unwrap(_amountOfWstETH); - uint256 sharesAmount = STETH.getSharesByPooledEth(stETHAmount); + function _burnWstETH(uint256 _amountWstETH) internal { + WSTETH.transferFrom(msg.sender, address(this), _amountWstETH); + uint256 unwrappedStETH = WSTETH.unwrap(_amountWstETH); + uint256 unwrappedShares = STETH.getSharesByPooledEth(unwrappedStETH); - _burnSharesFrom(address(this), sharesAmount); + _burnSharesFrom(address(this), unwrappedShares); } /** * @dev Burns stETH tokens from the sender backed by the vault - * @param _amountOfShares Amount of tokens to burn + * @param _amountShares Amount of tokens to burn */ - function _burnSharesFrom(address _sender, uint256 _amountOfShares) internal { + function _burnSharesFrom(address _sender, uint256 _amountShares) internal { if (_sender == address(this)) { - STETH.transferShares(address(vaultHub), _amountOfShares); + STETH.transferShares(address(vaultHub), _amountShares); } else { - STETH.transferSharesFrom(_sender, address(vaultHub), _amountOfShares); + STETH.transferSharesFrom(_sender, address(vaultHub), _amountShares); } - vaultHub.burnSharesBackedByVault(address(stakingVault()), _amountOfShares); + vaultHub.burnSharesBackedByVault(address(stakingVault()), _amountShares); } /** @@ -622,6 +618,9 @@ contract Dashboard is AccessControlEnumerable { /// @notice Error when the contract is already initialized. error AlreadyInitialized(); - /// @notice Error interacting with an ERC20 token - error Erc20Error(address token, string reason); + /// @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/test/0.8.25/vaults/dashboard/contracts/VaultHub__MockForDashboard.sol b/test/0.8.25/vaults/dashboard/contracts/VaultHub__MockForDashboard.sol index 9a494969c..95781fb4a 100644 --- a/test/0.8.25/vaults/dashboard/contracts/VaultHub__MockForDashboard.sol +++ b/test/0.8.25/vaults/dashboard/contracts/VaultHub__MockForDashboard.sol @@ -42,6 +42,10 @@ contract VaultHub__MockForDashboard { } 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); } diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 540248e26..58a38407e 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -272,9 +272,9 @@ describe("Dashboard.sol", () => { }); }); - context("projectedMintableShares", () => { + context("projectedNewMintableShares", () => { it("returns trivial can mint shares", async () => { - const canMint = await dashboard.projectedMintableShares(0n); + const canMint = await dashboard.projectedNewMintableShares(0n); expect(canMint).to.equal(0n); }); @@ -292,13 +292,13 @@ describe("Dashboard.sol", () => { const funding = 1000n; - const preFundCanMint = await dashboard.projectedMintableShares(funding); + const preFundCanMint = await dashboard.projectedNewMintableShares(funding); await dashboard.fund({ value: funding }); const availableMintableShares = await dashboard.totalMintableShares(); - const canMint = await dashboard.projectedMintableShares(0n); + const canMint = await dashboard.projectedNewMintableShares(0n); expect(canMint).to.equal(availableMintableShares); expect(canMint).to.equal(preFundCanMint); }); @@ -316,11 +316,11 @@ describe("Dashboard.sol", () => { await hub.mock__setVaultSocket(vault, sockets); const funding = 1000n; - const preFundCanMint = await dashboard.projectedMintableShares(funding); + const preFundCanMint = await dashboard.projectedNewMintableShares(funding); await dashboard.fund({ value: funding }); - const canMint = await dashboard.projectedMintableShares(0n); + const canMint = await dashboard.projectedNewMintableShares(0n); expect(canMint).to.equal(0n); // 1000 - 10% - 900 = 0 expect(canMint).to.equal(preFundCanMint); }); @@ -337,10 +337,10 @@ describe("Dashboard.sol", () => { }; await hub.mock__setVaultSocket(vault, sockets); const funding = 1000n; - const preFundCanMint = await dashboard.projectedMintableShares(funding); + const preFundCanMint = await dashboard.projectedNewMintableShares(funding); await dashboard.fund({ value: funding }); - const canMint = await dashboard.projectedMintableShares(0n); + const canMint = await dashboard.projectedNewMintableShares(0n); expect(canMint).to.equal(0n); expect(canMint).to.equal(preFundCanMint); }); @@ -358,12 +358,12 @@ describe("Dashboard.sol", () => { await hub.mock__setVaultSocket(vault, sockets); const funding = 2000n; - const preFundCanMint = await dashboard.projectedMintableShares(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.projectedMintableShares(0n); + const canMint = await dashboard.projectedNewMintableShares(0n); expect(canMint).to.equal(sharesFunded - sockets.sharesMinted); expect(canMint).to.equal(preFundCanMint); }); @@ -381,10 +381,10 @@ describe("Dashboard.sol", () => { await hub.mock__setVaultSocket(vault, sockets); const funding = 2000n; - const preFundCanMint = await dashboard.projectedMintableShares(funding); + const preFundCanMint = await dashboard.projectedNewMintableShares(funding); await dashboard.fund({ value: funding }); - const canMint = await dashboard.projectedMintableShares(0n); + const canMint = await dashboard.projectedNewMintableShares(0n); expect(canMint).to.equal(0n); expect(canMint).to.equal(preFundCanMint); }); @@ -550,10 +550,7 @@ describe("Dashboard.sol", () => { }); it("reverts without approval", async () => { - await expect(dashboard.fundByWeth(amount, { from: vaultOwner })).to.be.revertedWithCustomError( - dashboard, - "Erc20Error", - ); + await expect(dashboard.fundByWeth(amount, { from: vaultOwner })).to.be.revertedWithoutReason(); }); }); @@ -690,6 +687,10 @@ describe("Dashboard.sol", () => { .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", () => { @@ -732,6 +733,7 @@ describe("Dashboard.sol", () => { 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); }); } @@ -800,6 +802,10 @@ describe("Dashboard.sol", () => { .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", () => { @@ -885,7 +891,8 @@ describe("Dashboard.sol", () => { await wstethContract.approve(dashboard, weiShare); // reverts when rounding to zero - if (weiShareDown === 0n) { + // 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)); @@ -976,7 +983,7 @@ describe("Dashboard.sol", () => { r, s, }), - ).to.be.revertedWithCustomError(dashboard, "Erc20Error"); + ).to.be.revertedWithCustomError(dashboard, "InvalidPermit"); }); it("burns shares with permit", async () => { @@ -1030,7 +1037,7 @@ describe("Dashboard.sol", () => { await expect( dashboard.connect(vaultOwner).burnSharesWithPermit(amountShares, permitData), - ).to.be.revertedWithCustomError(dashboard, "Erc20Error"); + ).to.be.revertedWithCustomError(dashboard, "InvalidPermit"); await steth.connect(vaultOwner).approve(dashboard, amountSteth); @@ -1172,7 +1179,7 @@ describe("Dashboard.sol", () => { r, s, }), - ).to.be.revertedWithCustomError(dashboard, "Erc20Error"); + ).to.be.revertedWithCustomError(dashboard, "InvalidPermit"); }); it("burns shares with permit", async () => { @@ -1226,7 +1233,7 @@ describe("Dashboard.sol", () => { await expect( dashboard.connect(vaultOwner).burnStethWithPermit(amountSteth, permitData), - ).to.be.revertedWithCustomError(dashboard, "Erc20Error"); + ).to.be.revertedWithCustomError(dashboard, "InvalidPermit"); await steth.connect(vaultOwner).approve(dashboard, amountSteth); @@ -1367,7 +1374,7 @@ describe("Dashboard.sol", () => { r, s, }), - ).to.be.revertedWithCustomError(dashboard, "Erc20Error"); + ).to.be.revertedWithCustomError(dashboard, "InvalidPermit"); }); it("burns wstETH with permit", async () => { @@ -1425,7 +1432,7 @@ describe("Dashboard.sol", () => { await expect( dashboard.connect(vaultOwner).burnWstETHWithPermit(amountShares, permitData), - ).to.be.revertedWithCustomError(dashboard, "Erc20Error"); + ).to.be.revertedWithCustomError(dashboard, "InvalidPermit"); await wsteth.connect(vaultOwner).approve(dashboard, amountShares); @@ -1557,24 +1564,38 @@ describe("Dashboard.sol", () => { }); it("allows only admin to recover", async () => { - await expect(dashboard.connect(stranger).recoverERC20(ZeroAddress)).to.be.revertedWithCustomError( + await expect(dashboard.connect(stranger).recoverERC20(ZeroAddress, vaultOwner)).to.be.revertedWithCustomError( dashboard, "AccessControlUnauthorizedAccount", ); - await expect(dashboard.connect(stranger).recoverERC721(erc721.getAddress(), 0)).to.be.revertedWithCustomError( + 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)).to.be.revertedWithCustomError( dashboard, - "AccessControlUnauthorizedAccount", + "ZeroArgument", ); + await expect(dashboard.recoverERC20(weth, ZeroAddress)).to.be.revertedWithCustomError(dashboard, "ZeroArgument"); }); - it("does not allow zero token address for erc20 recovery", async () => { - await expect(dashboard.recoverERC20(ZeroAddress)).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); + 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 ether", async () => { const ethStub = await dashboard.ETH(); const preBalance = await ethers.provider.getBalance(vaultOwner); - const tx = await dashboard.recoverERC20(ethStub); + const tx = await dashboard.recoverERC20(ethStub, vaultOwner); const { gasUsed, gasPrice } = (await ethers.provider.getTransactionReceipt(tx.hash))!; await expect(tx).to.emit(dashboard, "ERC20Recovered").withArgs(tx.from, ethStub, amount); @@ -1584,7 +1605,7 @@ describe("Dashboard.sol", () => { it("recovers all weth", async () => { const preBalance = await weth.balanceOf(vaultOwner); - const tx = await dashboard.recoverERC20(weth.getAddress()); + const tx = await dashboard.recoverERC20(weth.getAddress(), vaultOwner); await expect(tx) .to.emit(dashboard, "ERC20Recovered") @@ -1594,11 +1615,14 @@ describe("Dashboard.sol", () => { }); it("does not allow zero token address for erc721 recovery", async () => { - await expect(dashboard.recoverERC721(ZeroAddress, 0)).to.be.revertedWithCustomError(dashboard, "ZeroArgument"); + await expect(dashboard.recoverERC721(ZeroAddress, 0, vaultOwner)).to.be.revertedWithCustomError( + dashboard, + "ZeroArgument", + ); }); it("recovers erc721", async () => { - const tx = await dashboard.recoverERC721(erc721.getAddress(), 0); + const tx = await dashboard.recoverERC721(erc721.getAddress(), 0, vaultOwner); await expect(tx) .to.emit(dashboard, "ERC721Recovered") @@ -1611,11 +1635,6 @@ describe("Dashboard.sol", () => { context("fallback behavior", () => { const amount = ether("1"); - it("reverts on zero value sent", async () => { - const tx = vaultOwner.sendTransaction({ to: dashboardAddress, value: 0 }); - await expect(tx).to.be.revertedWithCustomError(dashboard, "ZeroArgument"); - }); - it("does not allow fallback behavior", async () => { const tx = vaultOwner.sendTransaction({ to: dashboardAddress, data: "0x111111111111", value: amount }); await expect(tx).to.be.revertedWithoutReason(); From c9c7f74110620b719511149f1c8eedc935150176 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Thu, 23 Jan 2025 12:14:17 +0700 Subject: [PATCH 31/36] test: whitespace --- test/0.8.25/vaults/dashboard/dashboard.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index ac8817cf3..c89f2f5cc 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -1646,6 +1646,7 @@ describe("Dashboard.sol", () => { 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( From e470a897ecc9fa83e793f0777c815094a2925674 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Thu, 23 Jan 2025 16:53:32 +0700 Subject: [PATCH 32/36] fix: fund/withdraw naming --- contracts/0.8.25/vaults/Dashboard.sol | 14 ++++---- .../0.8.25/vaults/dashboard/dashboard.test.ts | 36 +++++++++---------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index bd5990e54..8aa42c0b6 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -197,7 +197,7 @@ contract Dashboard is AccessControlEnumerable { * @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()); } @@ -234,7 +234,7 @@ contract Dashboard is AccessControlEnumerable { * @notice Funds the staking vault with wrapped ether. Expects WETH amount apporved to this contract. * @param _amountWETH Amount of wrapped ether to fund the staking vault with */ - function fundByWeth(uint256 _amountWETH) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { + function fundWeth(uint256 _amountWETH) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { SafeERC20.safeTransferFrom(WETH, msg.sender, address(this), _amountWETH); WETH.withdraw(_amountWETH); @@ -253,12 +253,12 @@ contract Dashboard is AccessControlEnumerable { /** * @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 _amountWETH Amount of WETH to withdraw */ - function withdrawToWeth(address _recipient, uint256 _ether) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { - _withdraw(address(this), _ether); - WETH.deposit{value: _ether}(); - SafeERC20.safeTransfer(WETH, _recipient, _ether); + function withdrawWeth(address _recipient, uint256 _amountWETH) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { + _withdraw(address(this), _amountWETH); + WETH.deposit{value: _amountWETH}(); + SafeERC20.safeTransfer(WETH, _recipient, _amountWETH); } /** diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index c89f2f5cc..7afd7cf02 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -390,10 +390,10 @@ describe("Dashboard.sol", () => { }); }); - 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 () => { @@ -401,15 +401,15 @@ describe("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 () => { @@ -418,7 +418,7 @@ describe("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 () => { @@ -427,7 +427,7 @@ describe("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 () => { @@ -436,7 +436,7 @@ describe("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 () => { @@ -447,7 +447,7 @@ describe("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 @@ -526,7 +526,7 @@ describe("Dashboard.sol", () => { }); }); - context("fundByWeth", () => { + context("fundWeth", () => { const amount = ether("1"); beforeEach(async () => { @@ -534,7 +534,7 @@ describe("Dashboard.sol", () => { }); it("reverts if called by a non-admin", async () => { - await expect(dashboard.connect(stranger).fundByWeth(ether("1"))).to.be.revertedWithCustomError( + await expect(dashboard.connect(stranger).fundWeth(ether("1"))).to.be.revertedWithCustomError( dashboard, "AccessControlUnauthorizedAccount", ); @@ -543,14 +543,14 @@ describe("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.revertedWithoutReason(); + await expect(dashboard.fundWeth(amount, { from: vaultOwner })).to.be.revertedWithoutReason(); }); }); @@ -575,11 +575,11 @@ describe("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", ); @@ -589,7 +589,7 @@ describe("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); @@ -720,7 +720,7 @@ describe("Dashboard.sol", () => { }); it("reverts on zero mint", async () => { - await expect(dashboard.mintWstETH(vaultOwner, 0n)).to.be.revertedWith("wstETH: can't wrap zero stETH"); + await expect(dashboard.mintWstETH(vaultOwner, 0n)).to.be.revertedWithCustomError(hub, "ZeroArgument"); }); for (let weiWsteth = 1n; weiWsteth <= 10n; weiWsteth++) { From 34d4519397c83d6e50116dffe55679f3048c0883 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Tue, 28 Jan 2025 22:43:36 +0700 Subject: [PATCH 33/36] fix: dashboard tests --- .../StETHPermit__HarnessForDashboard.sol | 4 -- .../VaultFactory__MockForDashboard.sol | 12 +++++- .../0.8.25/vaults/dashboard/dashboard.test.ts | 40 ++++++++++++++----- .../vaults/delegation/delegation.test.ts | 2 +- 4 files changed, 43 insertions(+), 15 deletions(-) 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/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index f24eb3703..657b62696 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -24,7 +24,7 @@ import { certainAddress, days, ether, findEvents, signPermit, stethDomain, wstet 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; @@ -458,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 () => { @@ -476,7 +477,7 @@ describe.skip("Dashboard.sol", () => { 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", () => { @@ -537,6 +538,9 @@ describe.skip("Dashboard.sol", () => { }); it("reverts if called by a non-admin", async () => { + 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", @@ -744,7 +748,12 @@ describe.skip("Dashboard.sol", () => { context("burnShares", () => { it("reverts if called by a non-admin", async () => { - await expect(dashboard.connect(stranger).burnShares(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", ); @@ -782,6 +791,9 @@ describe.skip("Dashboard.sol", () => { }); 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", @@ -820,6 +832,14 @@ describe.skip("Dashboard.sol", () => { }); it("reverts if called by a non-admin", async () => { + // 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", @@ -1138,15 +1158,17 @@ describe.skip("Dashboard.sol", () => { }); it("reverts if called by a non-admin", async () => { + await steth.mintExternalShares(stranger, amountShares); + const permit = { - owner: vaultOwner.address, + owner: stranger.address, spender: dashboardAddress, value: amountSteth, - nonce: await steth.nonces(vaultOwner), + nonce: await steth.nonces(stranger), deadline: BigInt(await time.latest()) + days(1n), }; - const signature = await signPermit(await stethDomain(steth), permit, vaultOwner); + const signature = await signPermit(await stethDomain(steth), permit, stranger); const { deadline, value } = permit; const { v, r, s } = signature; diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index 7b4651a2b..644d642b5 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -160,7 +160,7 @@ describe("Delegation.sol", () => { it("reverts if wETH is zero address", async () => { await expect(ethers.deployContract("Delegation", [ethers.ZeroAddress, lidoLocator])) .to.be.revertedWithCustomError(delegation, "ZeroArgument") - .withArgs("_wETH"); + .withArgs("_WETH"); }); it("sets the stETH address", async () => { From b7e5536f4f8fca15e6235cb3fd0ec4f5958d65c5 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Wed, 29 Jan 2025 15:03:19 +0700 Subject: [PATCH 34/36] fix: remove onlyRole --- contracts/0.8.25/vaults/Dashboard.sol | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index c2fc2c222..1fc804421 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -291,10 +291,7 @@ contract Dashboard is Permissions { * @param _recipient Address of the recipient * @param _amountStETH Amount of stETH to mint */ - function mintStETH( - address _recipient, - uint256 _amountStETH - ) external payable virtual onlyRole(DEFAULT_ADMIN_ROLE) fundAndProceed { + function mintStETH(address _recipient, uint256 _amountStETH) external payable virtual fundAndProceed { _mintShares(_recipient, STETH.getSharesByPooledEth(_amountStETH)); } @@ -381,7 +378,7 @@ contract Dashboard is Permissions { function burnSharesWithPermit( uint256 _amountShares, PermitInput calldata _permit - ) external virtual onlyRole(DEFAULT_ADMIN_ROLE) safePermit(address(STETH), msg.sender, address(this), _permit) { + ) external virtual safePermit(address(STETH), msg.sender, address(this), _permit) { STETH.transferSharesFrom(msg.sender, address(vaultHub), _amountShares); _burnShares(_amountShares); } From 0c2c1700b6cc80b4f0ac7d482c3a8f6a43bc0b9e Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Wed, 29 Jan 2025 17:14:26 +0700 Subject: [PATCH 35/36] fix: unused imports & naming --- contracts/0.8.25/vaults/Dashboard.sol | 151 ++++++++---------- contracts/0.8.25/vaults/Permissions.sol | 2 +- .../0.8.25/vaults/dashboard/dashboard.test.ts | 77 ++++----- 3 files changed, 111 insertions(+), 119 deletions(-) diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 1fc804421..a00923153 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -5,8 +5,6 @@ pragma solidity 0.8.25; import {Permissions} from "./Permissions.sol"; -import {OwnableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/OwnableUpgradeable.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"; @@ -17,7 +15,6 @@ 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"; -import {IStakingVault} from "contracts/0.8.25/vaults/interfaces/IStakingVault.sol"; interface IWETH9 is IERC20 { function withdraw(uint256) external; @@ -36,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 { /** @@ -87,25 +82,24 @@ contract Dashboard is Permissions { /** * @notice Constructor sets the stETH, WETH, and WSTETH token addresses. - * @param _weth Address of the weth token contract. + * @param _wETH Address of the weth token contract. * @param _lidoLocator Address of the Lido locator contract. */ - constructor(address _weth, address _lidoLocator) Permissions() { - if (_weth == address(0)) revert ZeroArgument("_WETH"); + constructor(address _wETH, address _lidoLocator) Permissions() { + if (_wETH == address(0)) revert ZeroArgument("_wETH"); if (_lidoLocator == address(0)) revert ZeroArgument("_lidoLocator"); - WETH = IWETH9(_weth); + 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` - // dashboard will hold STETH during this tx + // invariant: dashboard does not hold stETH on its balance STETH.approve(address(WSTETH), type(uint256).max); _initialize(_defaultAdmin); @@ -142,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; } @@ -174,8 +168,8 @@ 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()); @@ -238,14 +232,14 @@ contract Dashboard is Permissions { } /** - * @notice Funds the staking vault with wrapped ether. Expects WETH amount apporved to this contract. Auth is perfomed in _fund - * @param _amountWETH 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 fundWeth(uint256 _amountWETH) external { - SafeERC20.safeTransferFrom(WETH, msg.sender, address(this), _amountWETH); - WETH.withdraw(_amountWETH); + function fundWeth(uint256 _amountOfWETH) external { + SafeERC20.safeTransferFrom(WETH, msg.sender, address(this), _amountOfWETH); + WETH.withdraw(_amountOfWETH); - _fund(_amountWETH); + _fund(_amountOfWETH); } /** @@ -260,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 _amountWETH Amount of WETH to withdraw + * @param _amountOfWETH Amount of WETH to withdraw */ - function withdrawWeth(address _recipient, uint256 _amountWETH) external { - _withdraw(address(this), _amountWETH); - WETH.deposit{value: _amountWETH}(); - SafeERC20.safeTransfer(WETH, _recipient, _amountWETH); + function withdrawWETH(address _recipient, uint256 _amountOfWETH) external { + _withdraw(address(this), _amountOfWETH); + WETH.deposit{value: _amountOfWETH}(); + SafeERC20.safeTransfer(WETH, _recipient, _amountOfWETH); } /** @@ -279,62 +273,62 @@ contract Dashboard is Permissions { /** * @notice Mints stETH tokens backed by the vault to the recipient. * @param _recipient Address of the recipient - * @param _amountShares Amount of stETH shares to mint + * @param _amountOfShares Amount of stETH shares to mint */ - function mintShares(address _recipient, uint256 _amountShares) external payable fundAndProceed { - _mintShares(_recipient, _amountShares); + function mintShares(address _recipient, uint256 _amountOfShares) external payable fundAndProceed { + _mintShares(_recipient, _amountOfShares); } /** * @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 _amountStETH Amount of stETH to mint + * @param _amountOfStETH Amount of stETH to mint */ - function mintStETH(address _recipient, uint256 _amountStETH) external payable virtual fundAndProceed { - _mintShares(_recipient, STETH.getSharesByPooledEth(_amountStETH)); + function mintStETH(address _recipient, uint256 _amountOfStETH) external payable virtual fundAndProceed { + _mintShares(_recipient, STETH.getSharesByPooledEth(_amountOfStETH)); } /** * @notice Mints wstETH tokens backed by the vault to a recipient. * @param _recipient Address of the recipient - * @param _amountWstETH Amount of tokens to mint + * @param _amountOfWstETH Amount of tokens to mint */ - function mintWstETH(address _recipient, uint256 _amountWstETH) external payable fundAndProceed { - _mintShares(address(this), _amountWstETH); + function mintWstETH(address _recipient, uint256 _amountOfWstETH) external payable fundAndProceed { + _mintShares(address(this), _amountOfWstETH); - uint256 mintedStETH = STETH.getPooledEthBySharesRoundUp(_amountWstETH); + uint256 mintedStETH = STETH.getPooledEthBySharesRoundUp(_amountOfWstETH); uint256 wrappedWstETH = WSTETH.wrap(mintedStETH); - WSTETH.transfer(_recipient, wrappedWstETH); + SafeERC20.safeTransfer(WSTETH, _recipient, wrappedWstETH); } /** - * @notice Burns stETH shares from the sender backed by the vault. Expects corresponding amount of stETH apporved to this contract. - * @param _amountShares Amount of stETH shares 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 burnShares(uint256 _amountShares) external { - STETH.transferSharesFrom(msg.sender, address(vaultHub), _amountShares); - _burnShares(_amountShares); + function burnShares(uint256 _amountOfShares) external { + STETH.transferSharesFrom(msg.sender, address(vaultHub), _amountOfShares); + _burnShares(_amountOfShares); } /** - * @notice Burns stETH shares from the sender backed by the vault. Expects stETH amount apporved to this contract. + * @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 _amountStETH Amount of stETH shares to burn + * @param _amountOfStETH Amount of stETH shares to burn */ - function burnSteth(uint256 _amountStETH) external { - _burnStETH(_amountStETH); + function burnStETH(uint256 _amountOfStETH) external { + _burnStETH(_amountOfStETH); } /** - * @notice Burns wstETH tokens from the sender backed by the vault. Expects wstETH amount apporved to this contract. - * !NB: this will revert with `VaultHub.ZeroArgument("_amountOfShares")` on 1 wei of wstETH due to rounding insie wstETH unwrap method - * @param _amountWstETH Amount of wstETH tokens to burn + * @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 */ - function burnWstETH(uint256 _amountWstETH) external { - _burnWstETH(_amountWstETH); + function burnWstETH(uint256 _amountOfWstETH) external { + _burnWstETH(_amountOfWstETH); } /** @@ -372,41 +366,41 @@ contract Dashboard is Permissions { /** * @notice Burns stETH tokens (in shares) backed by the vault from the sender using permit (with value in stETH). - * @param _amountShares Amount of stETH shares to burn + * @param _amountOfShares Amount of stETH shares to burn * @param _permit data required for the stETH.permit() with amount in stETH */ function burnSharesWithPermit( - uint256 _amountShares, + uint256 _amountOfShares, PermitInput calldata _permit ) external virtual safePermit(address(STETH), msg.sender, address(this), _permit) { - STETH.transferSharesFrom(msg.sender, address(vaultHub), _amountShares); - _burnShares(_amountShares); + 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 _amountStETH Amount of stETH to burn + * @param _amountOfStETH Amount of stETH to burn * @param _permit data required for the stETH.permit() method to set the allowance */ - function burnStethWithPermit( - uint256 _amountStETH, + function burnStETHWithPermit( + uint256 _amountOfStETH, PermitInput calldata _permit ) external safePermit(address(STETH), msg.sender, address(this), _permit) { - _burnStETH(_amountStETH); + _burnStETH(_amountOfStETH); } /** * @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 _amountWstETH Amount of wstETH tokens to burn + * @param _amountOfWstETH Amount of wstETH tokens to burn * @param _permit data required for the wstETH.permit() method to set the allowance */ function burnWstETHWithPermit( - uint256 _amountWstETH, + uint256 _amountOfWstETH, PermitInput calldata _permit ) external safePermit(address(WSTETH), msg.sender, address(this), _permit) { - _burnWstETH(_amountWstETH); + _burnWstETH(_amountOfWstETH); } /** @@ -422,18 +416,15 @@ contract Dashboard is Permissions { * @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) external onlyRole(DEFAULT_ADMIN_ROLE) { + 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"); - - uint256 _amount; + if (_amount == 0) revert ZeroArgument("_amount"); if (_token == ETH) { - _amount = address(this).balance; (bool success, ) = payable(_recipient).call{value: _amount}(""); if (!success) revert EthTransferFailed(_recipient, _amount); } else { - _amount = IERC20(_token).balanceOf(address(this)); SafeERC20.safeTransfer(IERC20(_token), _recipient, _amount); } @@ -515,21 +506,21 @@ contract Dashboard is Permissions { /** * @dev Burns stETH tokens from the sender backed by the vault - * @param _amountStETH Amount of tokens to burn + * @param _amountOfStETH Amount of tokens to burn */ - function _burnStETH(uint256 _amountStETH) internal { - uint256 _amountShares = STETH.getSharesByPooledEth(_amountStETH); - STETH.transferSharesFrom(msg.sender, address(vaultHub), _amountShares); - _burnShares(_amountShares); + 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 _amountWstETH Amount of tokens to burn + * @param _amountOfWstETH Amount of tokens to burn */ - function _burnWstETH(uint256 _amountWstETH) internal { - WSTETH.transferFrom(msg.sender, address(this), _amountWstETH); - uint256 unwrappedStETH = WSTETH.unwrap(_amountWstETH); + 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); diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index d6fee52b8..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; diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 657b62696..ed0f85440 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -112,7 +112,7 @@ describe("Dashboard.sol", () => { it("reverts if WETH is zero address", async () => { await expect(ethers.deployContract("Dashboard", [ethers.ZeroAddress, lidoLocator])) .to.be.revertedWithCustomError(dashboard, "ZeroArgument") - .withArgs("_WETH"); + .withArgs("_wETH"); }); it("sets the stETH, wETH, and wstETH addresses", async () => { @@ -175,8 +175,8 @@ describe("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); }); }); @@ -586,7 +586,7 @@ describe("Dashboard.sol", () => { const amount = ether("1"); it("reverts if called by a non-admin", async () => { - await expect(dashboard.connect(stranger).withdrawWeth(vaultOwner, ether("1"))).to.be.revertedWithCustomError( + await expect(dashboard.connect(stranger).withdrawWETH(vaultOwner, ether("1"))).to.be.revertedWithCustomError( dashboard, "AccessControlUnauthorizedAccount", ); @@ -596,7 +596,7 @@ describe("Dashboard.sol", () => { await dashboard.fund({ value: amount }); const previousBalance = await ethers.provider.getBalance(stranger); - await expect(dashboard.withdrawWeth(stranger, amount)) + await expect(dashboard.withdrawWETH(stranger, amount)) .to.emit(vault, "Withdrawn") .withArgs(dashboard, dashboard, amount); @@ -794,7 +794,7 @@ describe("Dashboard.sol", () => { await steth.mintExternalShares(stranger, amountShares); await steth.connect(stranger).approve(dashboard, amountSteth); - await expect(dashboard.connect(stranger).burnSteth(amountSteth)).to.be.revertedWithCustomError( + await expect(dashboard.connect(stranger).burnStETH(amountSteth)).to.be.revertedWithCustomError( dashboard, "AccessControlUnauthorizedAccount", ); @@ -808,7 +808,7 @@ describe("Dashboard.sol", () => { .withArgs(vaultOwner, dashboard, amountSteth); expect(await steth.allowance(vaultOwner, dashboard)).to.equal(amountSteth); - await expect(dashboard.burnSteth(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 @@ -819,7 +819,7 @@ describe("Dashboard.sol", () => { }); it("does not allow to burn 1 wei stETH", async () => { - await expect(dashboard.burnSteth(1n)).to.be.revertedWithCustomError(hub, "ZeroArgument"); + await expect(dashboard.burnStETH(1n)).to.be.revertedWithCustomError(hub, "ZeroArgument"); }); }); @@ -962,15 +962,16 @@ describe("Dashboard.sol", () => { }); it("reverts if called by a non-admin", async () => { + await steth.mintExternalShares(stranger, amountShares); const permit = { - owner: vaultOwner.address, + owner: stranger.address, spender: dashboardAddress, value: amountSteth, - nonce: await steth.nonces(vaultOwner), + nonce: await steth.nonces(stranger), deadline: BigInt(await time.latest()) + days(1n), }; - const signature = await signPermit(await stethDomain(steth), permit, vaultOwner); + const signature = await signPermit(await stethDomain(steth), permit, stranger); const { deadline, value } = permit; const { v, r, s } = signature; @@ -1173,7 +1174,7 @@ describe("Dashboard.sol", () => { const { v, r, s } = signature; await expect( - dashboard.connect(stranger).burnStethWithPermit(amountSteth, { + dashboard.connect(stranger).burnStETHWithPermit(amountSteth, { value, deadline, v, @@ -1197,7 +1198,7 @@ describe("Dashboard.sol", () => { const { v, r, s } = signature; await expect( - dashboard.connect(vaultOwner).burnStethWithPermit(amountSteth, { + dashboard.connect(vaultOwner).burnStETHWithPermit(amountSteth, { value, deadline, v, @@ -1221,7 +1222,7 @@ describe("Dashboard.sol", () => { const { v, r, s } = signature; const balanceBefore = await steth.balanceOf(vaultOwner); - const result = await dashboard.connect(vaultOwner).burnStethWithPermit(amountSteth, { + const result = await dashboard.connect(vaultOwner).burnStETHWithPermit(amountSteth, { value, deadline, v, @@ -1257,13 +1258,13 @@ describe("Dashboard.sol", () => { }; await expect( - dashboard.connect(vaultOwner).burnStethWithPermit(amountSteth, permitData), + dashboard.connect(vaultOwner).burnStETHWithPermit(amountSteth, 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).burnStethWithPermit(amountSteth, permitData); + const result = await dashboard.connect(vaultOwner).burnStETHWithPermit(amountSteth, 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 @@ -1297,7 +1298,7 @@ describe("Dashboard.sol", () => { }; const balanceBefore = await steth.balanceOf(vaultOwner); - const result = await dashboard.connect(vaultOwner).burnStethWithPermit(stethToBurn, permitData); + 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 @@ -1331,7 +1332,7 @@ describe("Dashboard.sol", () => { }; const balanceBefore = await steth.balanceOf(vaultOwner); - const result = await dashboard.connect(vaultOwner).burnStethWithPermit(stethToBurn, permitData); + 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 @@ -1355,20 +1356,24 @@ describe("Dashboard.sol", () => { }); 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: vaultOwner.address, + owner: stranger.address, spender: dashboardAddress, value: amountShares, - nonce: await wsteth.nonces(vaultOwner), + 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).burnSharesWithPermit(amountShares, { + dashboard.connect(stranger).burnWstETHWithPermit(amountShares, { value, deadline, v, @@ -1589,7 +1594,7 @@ describe("Dashboard.sol", () => { }); it("allows only admin to recover", async () => { - await expect(dashboard.connect(stranger).recoverERC20(ZeroAddress, vaultOwner)).to.be.revertedWithCustomError( + await expect(dashboard.connect(stranger).recoverERC20(ZeroAddress, vaultOwner, 1n)).to.be.revertedWithCustomError( dashboard, "AccessControlUnauthorizedAccount", ); @@ -1599,28 +1604,24 @@ describe("Dashboard.sol", () => { }); it("does not allow zero token address for erc20 recovery", async () => { - await expect(dashboard.recoverERC20(ZeroAddress, vaultOwner)).to.be.revertedWithCustomError( + 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", ); - await expect(dashboard.recoverERC20(weth, ZeroAddress)).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); - 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 ether", async () => { const ethStub = await dashboard.ETH(); const preBalance = await ethers.provider.getBalance(vaultOwner); - const tx = await dashboard.recoverERC20(ethStub, 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); @@ -1630,7 +1631,7 @@ describe("Dashboard.sol", () => { it("recovers all weth", async () => { const preBalance = await weth.balanceOf(vaultOwner); - const tx = await dashboard.recoverERC20(weth.getAddress(), vaultOwner); + const tx = await dashboard.recoverERC20(weth.getAddress(), vaultOwner, amount); await expect(tx) .to.emit(dashboard, "ERC20Recovered") From 067f38bea9e6be3e78ec33fbe4f7470c4a3eb799 Mon Sep 17 00:00:00 2001 From: Evgeny Taktarov Date: Wed, 29 Jan 2025 17:19:40 +0700 Subject: [PATCH 36/36] test: fix delegation test --- test/0.8.25/vaults/delegation/delegation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index 644d642b5..7b4651a2b 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -160,7 +160,7 @@ describe("Delegation.sol", () => { it("reverts if wETH is zero address", async () => { await expect(ethers.deployContract("Delegation", [ethers.ZeroAddress, lidoLocator])) .to.be.revertedWithCustomError(delegation, "ZeroArgument") - .withArgs("_WETH"); + .withArgs("_wETH"); }); it("sets the stETH address", async () => {