diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 6c7450d5d..478183ab8 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -260,12 +260,23 @@ contract Dashboard is Permissions { SafeERC20.safeTransfer(WETH, _recipient, _amountOfWETH); } + /** + * @notice Update the locked amount of the staking vault + * @param _amount Amount of ether to lock + */ + function lock(uint256 _amount) external { + _lock(_amount); + } + /** * @notice Mints stETH shares backed by the vault to the recipient. * @param _recipient Address of the recipient * @param _amountOfShares Amount of stETH shares to mint */ - function mintShares(address _recipient, uint256 _amountOfShares) external payable fundable { + function mintShares( + address _recipient, + uint256 _amountOfShares + ) external payable fundable autolock(_amountOfShares) { _mintShares(_recipient, _amountOfShares); } @@ -275,7 +286,10 @@ contract Dashboard is Permissions { * @param _recipient Address of the recipient * @param _amountOfStETH Amount of stETH to mint */ - function mintStETH(address _recipient, uint256 _amountOfStETH) external payable virtual fundable { + function mintStETH( + address _recipient, + uint256 _amountOfStETH + ) external payable fundable autolock(STETH.getSharesByPooledEth(_amountOfStETH)) { _mintShares(_recipient, STETH.getSharesByPooledEth(_amountOfStETH)); } @@ -284,7 +298,10 @@ contract Dashboard is Permissions { * @param _recipient Address of the recipient * @param _amountOfWstETH Amount of tokens to mint */ - function mintWstETH(address _recipient, uint256 _amountOfWstETH) external payable fundable { + function mintWstETH( + address _recipient, + uint256 _amountOfWstETH + ) external payable fundable autolock(_amountOfWstETH) { _mintShares(address(this), _amountOfWstETH); uint256 mintedStETH = STETH.getPooledEthBySharesRoundUp(_amountOfWstETH); @@ -475,6 +492,25 @@ contract Dashboard is Permissions { _; } + /** + * @dev Modifier to increase the locked amount if necessary + * @param _newShares The number of new shares to mint + */ + modifier autolock(uint256 _newShares) { + VaultHub.VaultSocket memory socket = vaultSocket(); + + // Calculate the locked amount required to accommodate the new shares + uint256 requiredLocked = (STETH.getPooledEthBySharesRoundUp(socket.sharesMinted + _newShares) * + TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - socket.reserveRatioBP); + + // If the required locked amount is greater than the current, increase the locked amount + if (requiredLocked > stakingVault().locked()) { + _lock(requiredLocked); + } + + _; + } + /** * @dev Modifier to check if the permit is successful, and if not, check if the allowance is sufficient */ @@ -508,8 +544,6 @@ contract Dashboard is Permissions { revert InvalidPermit(token); } - /** - /** * @dev Burns stETH tokens from the sender backed by the vault * @param _amountOfStETH Amount of tokens to burn diff --git a/contracts/0.8.25/vaults/Permissions.sol b/contracts/0.8.25/vaults/Permissions.sol index ccc8af331..aad7ff2af 100644 --- a/contracts/0.8.25/vaults/Permissions.sol +++ b/contracts/0.8.25/vaults/Permissions.sol @@ -36,6 +36,11 @@ abstract contract Permissions is AccessControlConfirmable { */ bytes32 public constant WITHDRAW_ROLE = keccak256("vaults.Permissions.Withdraw"); + /** + * @notice Permission for locking ether on StakingVault. + */ + bytes32 public constant LOCK_ROLE = keccak256("vaults.Permissions.Lock"); + /** * @notice Permission for minting stETH shares backed by the StakingVault. */ @@ -191,6 +196,10 @@ abstract contract Permissions is AccessControlConfirmable { stakingVault().withdraw(_recipient, _ether); } + function _lock(uint256 _locked) internal onlyRole(LOCK_ROLE) { + stakingVault().lock(_locked); + } + /** * @dev Checks the MINT_ROLE and mints shares backed by the StakingVault. * @param _recipient The address to mint the shares to. diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index 7538149a3..515e69de5 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -315,11 +315,10 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { * @dev Can only be called by VaultHub; locked amount can only be increased * @param _locked New amount to lock */ - function lock(uint256 _locked) external { - if (msg.sender != address(VAULT_HUB)) revert NotAuthorized("lock", msg.sender); - + function lock(uint256 _locked) external onlyOwner { ERC7201Storage storage $ = _getStorage(); - if ($.locked > _locked) revert LockedCannotDecreaseOutsideOfReport($.locked, _locked); + if (_locked <= $.locked) revert NewLockedNotGreaterThanCurrent(); + if (_locked > valuation()) revert NewLockedExceedsValuation(); $.locked = uint128(_locked); @@ -664,10 +663,13 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { /** * @notice Thrown when attempting to decrease the locked amount outside of a report - * @param currentlyLocked Current amount of locked ether - * @param attemptedLocked Attempted new locked amount */ - error LockedCannotDecreaseOutsideOfReport(uint256 currentlyLocked, uint256 attemptedLocked); + error NewLockedNotGreaterThanCurrent(); + + /** + * @notice Thrown when the locked amount exceeds the valuation + */ + error NewLockedExceedsValuation(); /** * @notice Thrown when called on the implementation contract diff --git a/contracts/0.8.25/vaults/VaultFactory.sol b/contracts/0.8.25/vaults/VaultFactory.sol index 675022852..565818093 100644 --- a/contracts/0.8.25/vaults/VaultFactory.sol +++ b/contracts/0.8.25/vaults/VaultFactory.sol @@ -6,6 +6,7 @@ pragma solidity 0.8.25; import {BeaconProxy} from "@openzeppelin/contracts-v5.2/proxy/beacon/BeaconProxy.sol"; import {Clones} from "@openzeppelin/contracts-v5.2/proxy/Clones.sol"; +import {OwnableUpgradeable} from "contracts/openzeppelin/5.2/upgradeable/access/OwnableUpgradeable.sol"; import {IStakingVault} from "./interfaces/IStakingVault.sol"; import {Delegation} from "./Delegation.sol"; @@ -18,6 +19,7 @@ struct DelegationConfig { uint16 nodeOperatorFeeBP; address[] funders; address[] withdrawers; + address[] lockers; address[] minters; address[] burners; address[] rebalancers; @@ -79,6 +81,9 @@ contract VaultFactory { for (uint256 i = 0; i < _delegationConfig.withdrawers.length; i++) { delegation.grantRole(delegation.WITHDRAW_ROLE(), _delegationConfig.withdrawers[i]); } + for (uint256 i = 0; i < _delegationConfig.lockers.length; i++) { + delegation.grantRole(delegation.LOCK_ROLE(), _delegationConfig.lockers[i]); + } for (uint256 i = 0; i < _delegationConfig.minters.length; i++) { delegation.grantRole(delegation.MINT_ROLE(), _delegationConfig.minters[i]); } diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 3886a8eaf..2644b91e0 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -231,6 +231,9 @@ contract VaultHub is PausableUntilWithRoles { if (IStakingVault(_vault).depositor() != LIDO_LOCATOR.predepositGuarantee()) revert VaultDepositorNotAllowed(IStakingVault(_vault).depositor()); + if (IStakingVault(_vault).locked() < CONNECT_DEPOSIT) + revert VaultInsufficientLocked(_vault, IStakingVault(_vault).locked(), CONNECT_DEPOSIT); + VaultSocket memory vsocket = VaultSocket( _vault, 0, // sharesMinted @@ -243,8 +246,6 @@ contract VaultHub is PausableUntilWithRoles { $.vaultIndex[_vault] = $.sockets.length; $.sockets.push(vsocket); - IStakingVault(_vault).lock(CONNECT_DEPOSIT); - emit VaultConnected(_vault, _shareLimit, _reserveRatioBP, _rebalanceThresholdBP, _treasuryFeeBP); } @@ -307,19 +308,18 @@ contract VaultHub is PausableUntilWithRoles { IStakingVault vault_ = IStakingVault(_vault); uint256 maxMintableRatioBP = TOTAL_BASIS_POINTS - socket.reserveRatioBP; uint256 maxMintableEther = (vault_.valuation() * maxMintableRatioBP) / TOTAL_BASIS_POINTS; - uint256 etherToLock = LIDO.getPooledEthBySharesRoundUp(vaultSharesAfterMint); - if (etherToLock > maxMintableEther) { + uint256 stETHAfterMint = LIDO.getPooledEthBySharesRoundUp(vaultSharesAfterMint); + if (stETHAfterMint > maxMintableEther) { revert InsufficientValuationToMint(_vault, vault_.valuation()); } - socket.sharesMinted = uint96(vaultSharesAfterMint); - - // Calculate the total ETH that needs to be locked in the vault to maintain the reserve ratio - uint256 totalEtherLocked = (etherToLock * TOTAL_BASIS_POINTS) / maxMintableRatioBP; - if (totalEtherLocked > vault_.locked()) { - vault_.lock(totalEtherLocked); + // Calculate the minimum ETH that needs to be locked in the vault to maintain the reserve ratio + uint256 minLocked = (stETHAfterMint * TOTAL_BASIS_POINTS) / maxMintableRatioBP; + if (minLocked > vault_.locked()) { + revert VaultInsufficientLocked(_vault, vault_.locked(), minLocked); } + socket.sharesMinted = uint96(vaultSharesAfterMint); LIDO.mintExternalShares(_recipient, _amountOfShares); emit MintedSharesOnVault(_vault, _amountOfShares); @@ -652,4 +652,5 @@ contract VaultHub is PausableUntilWithRoles { error ConnectedVaultsLimitTooLow(uint256 connectedVaultsLimit, uint256 currentVaultsCount); error RelativeShareLimitBPTooHigh(uint256 relativeShareLimitBP, uint256 totalBasisPoints); error VaultDepositorNotAllowed(address depositor); + error VaultInsufficientLocked(address vault, uint256 currentLocked, uint256 expectedLocked); } 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 df6d8ffe6..1e67dfaea 100644 --- a/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol +++ b/test/0.8.25/vaults/dashboard/contracts/VaultFactory__MockForDashboard.sol @@ -33,6 +33,7 @@ contract VaultFactory__MockForDashboard is UpgradeableBeacon { dashboard.grantRole(dashboard.FUND_ROLE(), msg.sender); dashboard.grantRole(dashboard.WITHDRAW_ROLE(), msg.sender); dashboard.grantRole(dashboard.MINT_ROLE(), msg.sender); + dashboard.grantRole(dashboard.LOCK_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); diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index 5e8310bca..0b6138a24 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -453,7 +453,7 @@ describe("Dashboard.sol", () => { const amount = ether("1"); await dashboard.fund({ value: amount }); - await hub.mock_vaultLock(vault.getAddress(), amount); + await dashboard.lock(amount); expect(await dashboard.withdrawableEther()).to.equal(0n); }); @@ -462,25 +462,27 @@ describe("Dashboard.sol", () => { const amount = ether("1"); await dashboard.fund({ value: amount }); - await hub.mock_vaultLock(vault.getAddress(), amount); + await dashboard.lock(amount); expect(await dashboard.withdrawableEther()).to.equal(0n); }); it("funds and get all half locked and can only half withdraw", async () => { - const amount = ether("1"); - await dashboard.fund({ value: amount }); + const fundAmount = ether("1"); + await dashboard.fund({ value: fundAmount }); - await hub.mock_vaultLock(vault.getAddress(), amount / 2n); + const lockAmount = fundAmount / 2n; + await dashboard.lock(lockAmount); - expect(await dashboard.withdrawableEther()).to.equal(amount / 2n); + expect(await dashboard.withdrawableEther()).to.equal(fundAmount - lockAmount); }); it("funds and get all half locked, but no balance and can not withdraw", async () => { const amount = ether("1"); await dashboard.fund({ value: amount }); - await hub.mock_vaultLock(vault.getAddress(), amount / 2n); + const lockAmount = amount / 2n; + await dashboard.lock(lockAmount); await setBalance(await vault.getAddress(), 0n); @@ -526,16 +528,13 @@ describe("Dashboard.sol", () => { before(async () => { amountSteth = await steth.getPooledEthByShares(amountShares); + await dashboard.fund({ value: amountSteth }); }); beforeEach(async () => { await dashboard.mintShares(vaultOwner, amountShares); }); - it("reverts on disconnect attempt", async () => { - await expect(dashboard.voluntaryDisconnect()).to.be.reverted; - }); - it("succeeds with rebalance when providing sufficient ETH", async () => { await expect(dashboard.voluntaryDisconnect({ value: amountSteth })) .to.emit(hub, "Mock__Rebalanced") @@ -583,11 +582,12 @@ describe("Dashboard.sol", () => { it("funds by weth", async () => { await weth.connect(vaultOwner).approve(dashboard, amount); + const previousBalance = await ethers.provider.getBalance(vault); await expect(dashboard.fundWeth(amount, { from: vaultOwner })) .to.emit(vault, "Funded") .withArgs(dashboard, amount); - expect(await ethers.provider.getBalance(vault)).to.equal(amount); + expect(await ethers.provider.getBalance(vault)).to.equal(previousBalance + amount); }); it("reverts without approval", async () => { @@ -693,6 +693,15 @@ describe("Dashboard.sol", () => { }); }); + context("lock", () => { + it("reverts if called by a non-admin", async () => { + await expect(dashboard.connect(stranger).lock(ether("1"))).to.be.revertedWithCustomError( + dashboard, + "AccessControlUnauthorizedAccount", + ); + }); + }); + context("mintShares", () => { const amountShares = ether("1"); const amountFunded = ether("2"); @@ -899,7 +908,9 @@ describe("Dashboard.sol", () => { before(async () => { // mint shares to the vault owner for the burn - await dashboard.mintShares(vaultOwner, amountWsteth + amountWsteth); + const amountSteth = await steth.getPooledEthByShares(amountWsteth); + await dashboard.fund({ value: amountSteth }); + await dashboard.mintShares(vaultOwner, amountWsteth); }); it("reverts if called by a non-admin", async () => { @@ -1220,8 +1231,9 @@ describe("Dashboard.sol", () => { before(async () => { // mint steth to the vault owner for the burn - await dashboard.mintShares(vaultOwner, amountShares); amountSteth = await steth.getPooledEthBySharesRoundUp(amountShares); + await dashboard.fund({ value: amountSteth }); + await dashboard.mintShares(vaultOwner, amountShares); }); beforeEach(async () => { @@ -1417,8 +1429,9 @@ describe("Dashboard.sol", () => { let amountSteth: bigint; beforeEach(async () => { - amountSteth = await steth.getPooledEthBySharesRoundUp(amountShares); + amountSteth = await steth.getPooledEthBySharesRoundUp(amountShares + 100n); // mint steth to the vault owner for the burn + await dashboard.fund({ value: amountSteth }); await dashboard.mintShares(vaultOwner, amountShares); // approve for wsteth wrap await steth.connect(vaultOwner).approve(wsteth, amountSteth); @@ -1427,9 +1440,12 @@ 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 shares = amountShares + 100n; + const stethAmount = await steth.getPooledEthByShares(shares); + await dashboard.fund({ value: stethAmount }); + await dashboard.mintShares(stranger, shares); + await steth.connect(stranger).approve(wsteth, stethAmount); + await wsteth.connect(stranger).wrap(stethAmount); const permit = { owner: stranger.address, @@ -1486,7 +1502,7 @@ describe("Dashboard.sol", () => { nonce: await wsteth.nonces(vaultOwner), deadline: BigInt(await time.latest()) + days(1n), }; - + const amountOfSteth = await steth.getPooledEthByShares(amountShares); const signature = await signPermit(await wstethDomain(wsteth), permit, vaultOwner); const { deadline, value } = permit; const { v, r, s } = signature; @@ -1504,8 +1520,8 @@ describe("Dashboard.sol", () => { await expect(result).to.emit(wsteth, "Approval").withArgs(vaultOwner, dashboard, amountShares); // approve steth from vault owner to dashboard await expect(result).to.emit(wsteth, "Transfer").withArgs(vaultOwner, dashboard, amountShares); // transfer steth to dashboard - await expect(result).to.emit(steth, "Transfer").withArgs(wsteth, dashboard, amountSteth); // uwrap wsteth to steth - await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountSteth, amountSteth, amountShares); // burn steth + await expect(result).to.emit(steth, "Transfer").withArgs(wsteth, dashboard, amountOfSteth); // uwrap wsteth to steth + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountOfSteth, amountOfSteth, amountShares); // burn steth expect(await steth.balanceOf(vaultOwner)).to.equal(stethBalanceBefore); expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore - amountShares); @@ -1520,6 +1536,8 @@ describe("Dashboard.sol", () => { deadline: BigInt(await time.latest()) + days(1n), }; + const amountOfSteth = await steth.getPooledEthByShares(amountShares); + const signature = await signPermit(await wstethDomain(wsteth), permit, vaultOwner); const { deadline, value } = permit; const { v, r, s } = signature; @@ -1542,8 +1560,8 @@ describe("Dashboard.sol", () => { 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, amountSteth); // uwrap wsteth to steth - await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountSteth, amountSteth, amountShares); // burn steth + await expect(result).to.emit(steth, "Transfer").withArgs(wsteth, dashboard, amountOfSteth); // uwrap wsteth to steth + await expect(result).to.emit(steth, "SharesBurnt").withArgs(hub, amountOfSteth, amountOfSteth, amountShares); // burn steth expect(await steth.balanceOf(vaultOwner)).to.equal(stethBalanceBefore); expect(await wsteth.balanceOf(vaultOwner)).to.equal(wstethBalanceBefore - amountShares); diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index e7893946d..30df1c473 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -115,6 +115,7 @@ describe("Delegation.sol", () => { assetRecoverer: vaultOwner, funders: [funder], withdrawers: [withdrawer], + lockers: [minter], minters: [minter], burners: [burner], rebalancers: [rebalancer], @@ -282,6 +283,7 @@ describe("Delegation.sol", () => { }); it("claims the due", async () => { + const vaultBalanceBefore = await ethers.provider.getBalance(vault); const operatorFee = 10_00n; // 10% await delegation.connect(nodeOperatorManager).setNodeOperatorFeeBP(operatorFee); await delegation.connect(vaultOwner).setNodeOperatorFeeBP(operatorFee); @@ -292,18 +294,20 @@ describe("Delegation.sol", () => { const expectedDue = (rewards * operatorFee) / BP_BASE; expect(await delegation.nodeOperatorUnclaimedFee()).to.equal(expectedDue); - expect(await delegation.nodeOperatorUnclaimedFee()).to.be.greaterThan(await ethers.provider.getBalance(vault)); + expect((await delegation.nodeOperatorUnclaimedFee()) + vaultBalanceBefore).to.be.greaterThan( + await ethers.provider.getBalance(vault), + ); - expect(await ethers.provider.getBalance(vault)).to.equal(0n); + expect(await ethers.provider.getBalance(vault)).to.equal(vaultBalanceBefore); await rewarder.sendTransaction({ to: vault, value: rewards }); - expect(await ethers.provider.getBalance(vault)).to.equal(rewards); + expect(await ethers.provider.getBalance(vault)).to.equal(rewards + vaultBalanceBefore); expect(await ethers.provider.getBalance(recipient)).to.equal(0n); await expect(delegation.connect(nodeOperatorFeeClaimer).claimNodeOperatorFee(recipient)) .to.emit(vault, "Withdrawn") .withArgs(delegation, recipient, expectedDue); expect(await ethers.provider.getBalance(recipient)).to.equal(expectedDue); - expect(await ethers.provider.getBalance(vault)).to.equal(rewards - expectedDue); + expect(await ethers.provider.getBalance(vault)).to.equal(rewards - expectedDue + vaultBalanceBefore); }); }); @@ -335,10 +339,11 @@ describe("Delegation.sol", () => { const locked = ether("2"); const amount = ether("1"); + const vaultBalanceBefore = await ethers.provider.getBalance(vault); await delegation.connect(funder).fund({ value: amount }); await vault.connect(hubSigner).report(valuation, inOutDelta, locked); - expect(await delegation.withdrawableEther()).to.equal(amount); + expect(await delegation.withdrawableEther()).to.equal(amount + vaultBalanceBefore); }); it("returns the correct amount when has fees", async () => { @@ -369,17 +374,18 @@ describe("Delegation.sol", () => { it("funds the vault", async () => { const amount = ether("1"); - expect(await ethers.provider.getBalance(vault)).to.equal(0n); - expect(await vault.inOutDelta()).to.equal(0n); - expect(await vault.valuation()).to.equal(0n); + const vaultBalanceBefore = await ethers.provider.getBalance(vault); + expect(await ethers.provider.getBalance(vault)).to.equal(vaultBalanceBefore); + expect(await vault.inOutDelta()).to.equal(vaultBalanceBefore); + expect(await vault.valuation()).to.equal(vaultBalanceBefore); await expect(delegation.connect(funder).fund({ value: amount })) .to.emit(vault, "Funded") .withArgs(delegation, amount); - expect(await ethers.provider.getBalance(vault)).to.equal(amount); - expect(await vault.inOutDelta()).to.equal(amount); - expect(await vault.valuation()).to.equal(amount); + expect(await ethers.provider.getBalance(vault)).to.equal(amount + vaultBalanceBefore); + expect(await vault.inOutDelta()).to.equal(amount + vaultBalanceBefore); + expect(await vault.valuation()).to.equal(amount + vaultBalanceBefore); }); }); @@ -419,18 +425,19 @@ describe("Delegation.sol", () => { it("withdraws the amount", async () => { const amount = ether("1"); await vault.connect(hubSigner).report(amount, 0n, 0n); - expect(await vault.valuation()).to.equal(amount); - expect(await vault.unlocked()).to.equal(amount); + const vaultBalanceBefore = await ethers.provider.getBalance(vault); + expect(await vault.valuation()).to.equal(amount + vaultBalanceBefore); + expect(await vault.unlocked()).to.equal(amount + vaultBalanceBefore); - expect(await ethers.provider.getBalance(vault)).to.equal(0n); + expect(await ethers.provider.getBalance(vault)).to.equal(vaultBalanceBefore); await rewarder.sendTransaction({ to: vault, value: amount }); - expect(await ethers.provider.getBalance(vault)).to.equal(amount); + expect(await ethers.provider.getBalance(vault)).to.equal(amount + vaultBalanceBefore); expect(await ethers.provider.getBalance(recipient)).to.equal(0n); await expect(delegation.connect(withdrawer).withdraw(recipient, amount)) .to.emit(vault, "Withdrawn") .withArgs(delegation, recipient, amount); - expect(await ethers.provider.getBalance(vault)).to.equal(0n); + expect(await ethers.provider.getBalance(vault)).to.equal(vaultBalanceBefore); expect(await ethers.provider.getBalance(recipient)).to.equal(amount); }); }); @@ -463,45 +470,6 @@ 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).mintShares(recipient, 1n)).to.be.revertedWithCustomError( - delegation, - "AccessControlUnauthorizedAccount", - ); - }); - - it("mints the tokens", async () => { - const amount = 100n; - await expect(delegation.connect(minter).mintShares(recipient, amount)) - .to.emit(steth, "Transfer") - .withArgs(ethers.ZeroAddress, recipient, amount); - }); - }); - - context("burn", () => { - it("reverts if the caller is not a member of the token master role", async () => { - await delegation.connect(funder).fund({ value: ether("1") }); - await delegation.connect(minter).mintShares(stranger, 100n); - - await expect(delegation.connect(stranger).burnShares(100n)).to.be.revertedWithCustomError( - delegation, - "AccessControlUnauthorizedAccount", - ); - }); - - it("burns the tokens", async () => { - const amount = 100n; - await delegation.connect(minter).mintShares(burner, amount); - - await expect(delegation.connect(burner).burnShares(amount)) - .to.emit(steth, "Transfer") - .withArgs(burner, hub, amount) - .and.to.emit(steth, "Transfer") - .withArgs(hub, ethers.ZeroAddress, amount); - }); - }); - context("setOperatorFee", () => { it("reverts if new fee is greater than max fee", async () => { const invalidFee = MAX_FEE + 1n; diff --git a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts index 68746e4c2..11b1c278a 100644 --- a/test/0.8.25/vaults/staking-vault/stakingVault.test.ts +++ b/test/0.8.25/vaults/staking-vault/stakingVault.test.ts @@ -29,7 +29,6 @@ import { deployStakingVaultBehindBeaconProxy } from "test/deploy"; import { Snapshot } from "test/suite"; const MAX_INT128 = 2n ** 127n - 1n; -const MAX_UINT128 = 2n ** 128n - 1n; const PUBLIC_KEY_LENGTH = 48; const SAMPLE_PUBKEY = "0x" + "ab".repeat(48); @@ -179,8 +178,11 @@ describe("StakingVault.sol", () => { it("returns the correct locked balance", async () => { expect(await stakingVault.locked()).to.equal(0n); - await stakingVault.connect(vaultHubSigner).lock(ether("1")); - expect(await stakingVault.locked()).to.equal(ether("1")); + const amount = ether("1"); + await stakingVault.fund({ value: amount }); + + await stakingVault.connect(vaultOwner).lock(amount); + expect(await stakingVault.locked()).to.equal(amount); }); }); @@ -190,9 +192,12 @@ describe("StakingVault.sol", () => { }); it("returns 0 if locked amount is greater than valuation", async () => { - await stakingVault.connect(vaultHubSigner).lock(ether("1")); - expect(await stakingVault.valuation()).to.equal(ether("0")); - expect(await stakingVault.locked()).to.equal(ether("1")); + const amount = ether("1"); + await stakingVault.fund({ value: amount }); + + await stakingVault.connect(vaultOwner).lock(amount); + expect(await stakingVault.valuation()).to.equal(amount); + expect(await stakingVault.locked()).to.equal(amount); expect(await stakingVault.unlocked()).to.equal(0n); }); @@ -274,8 +279,11 @@ describe("StakingVault.sol", () => { }); it("restores the vault to a healthy state if the vault was unhealthy", async () => { - await stakingVault.connect(vaultHubSigner).lock(ether("1")); - expect(await stakingVault.valuation()).to.be.lessThan(await stakingVault.locked()); + const valuation = 0n; + const inOutDelta = 0n; + const locked = ether("1.0"); + await stakingVault.connect(vaultHubSigner).report(valuation, inOutDelta, locked); + expect(await stakingVault.valuation()).to.be.lessThan(locked); await stakingVault.fund({ value: ether("1") }); expect(await stakingVault.valuation()).to.be.greaterThanOrEqual(await stakingVault.locked()); @@ -314,7 +322,7 @@ describe("StakingVault.sol", () => { const locked = ether("1") - 1n; const unlocked = balance - locked; await stakingVault.fund({ value: balance }); - await stakingVault.connect(vaultHubSigner).lock(locked); + await stakingVault.connect(vaultOwner).lock(locked); await expect(stakingVault.withdraw(vaultOwnerAddress, balance)) .to.be.revertedWithCustomError(stakingVault, "InsufficientUnlocked") @@ -374,37 +382,44 @@ describe("StakingVault.sol", () => { }); context("lock", () => { - it("reverts if the caller is not the vault hub", async () => { - await expect(stakingVault.connect(vaultOwner).lock(ether("1"))) - .to.be.revertedWithCustomError(stakingVault, "NotAuthorized") - .withArgs("lock", vaultOwnerAddress); + it("reverts if the caller is not the vault owner", async () => { + await expect(stakingVault.connect(stranger).lock(ether("1"))) + .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") + .withArgs(await stranger.getAddress()); }); it("updates the locked amount and emits the Locked event", async () => { - await expect(stakingVault.connect(vaultHubSigner).lock(ether("1"))) + const amount = ether("1"); + await stakingVault.fund({ value: amount }); + + await expect(stakingVault.connect(vaultOwner).lock(amount)) .to.emit(stakingVault, "LockedIncreased") - .withArgs(ether("1")); - expect(await stakingVault.locked()).to.equal(ether("1")); + .withArgs(amount); + expect(await stakingVault.locked()).to.equal(amount); }); it("reverts if the new locked amount is less than the current locked amount", async () => { - await stakingVault.connect(vaultHubSigner).lock(ether("2")); - await expect(stakingVault.connect(vaultHubSigner).lock(ether("1"))) - .to.be.revertedWithCustomError(stakingVault, "LockedCannotDecreaseOutsideOfReport") - .withArgs(ether("2"), ether("1")); - }); + const amount = ether("1"); + await stakingVault.fund({ value: amount }); - it("does not revert if the new locked amount is equal to the current locked amount", async () => { - await stakingVault.connect(vaultHubSigner).lock(ether("1")); - await expect(stakingVault.connect(vaultHubSigner).lock(ether("2"))) - .to.emit(stakingVault, "LockedIncreased") - .withArgs(ether("2")); + await stakingVault.connect(vaultOwner).lock(amount); + + await expect(stakingVault.connect(vaultOwner).lock(amount - 1n)).to.be.revertedWithCustomError( + stakingVault, + "NewLockedNotGreaterThanCurrent", + ); }); - it("does not revert if the locked amount is max uint128", async () => { - await expect(stakingVault.connect(vaultHubSigner).lock(MAX_UINT128)) - .to.emit(stakingVault, "LockedIncreased") - .withArgs(MAX_UINT128); + it("reverts if the new locked amount is equal to the current locked amount", async () => { + const amount = ether("1"); + await stakingVault.fund({ value: amount }); + + await stakingVault.connect(vaultOwner).lock(amount); + + await expect(stakingVault.connect(vaultOwner).lock(amount)).to.be.revertedWithCustomError( + stakingVault, + "NewLockedNotGreaterThanCurrent", + ); }); }); @@ -577,7 +592,7 @@ describe("StakingVault.sol", () => { }); it("reverts if the vault valuation is below the locked amount", async () => { - await stakingVault.connect(vaultHubSigner).lock(ether("1")); + await stakingVault.connect(vaultHubSigner).report(ether("0"), ether("0"), ether("1")); await expect( stakingVault .connect(depositor) diff --git a/test/0.8.25/vaults/vaultFactory.test.ts b/test/0.8.25/vaults/vaultFactory.test.ts index 28ca2037a..95ec6d230 100644 --- a/test/0.8.25/vaults/vaultFactory.test.ts +++ b/test/0.8.25/vaults/vaultFactory.test.ts @@ -135,6 +135,7 @@ describe("VaultFactory.sol", () => { funders: [await vaultOwner1.getAddress()], withdrawers: [await vaultOwner1.getAddress()], minters: [await vaultOwner1.getAddress()], + lockers: [await vaultOwner1.getAddress()], burners: [await vaultOwner1.getAddress()], nodeOperatorFeeClaimers: [await operator.getAddress()], rebalancers: [await vaultOwner1.getAddress()], @@ -264,6 +265,9 @@ describe("VaultFactory.sol", () => { //add proxy code hash to whitelist await vaultHub.connect(admin).addVaultProxyCodehash(vaultProxyCodeHash); + await delegator1.fund({ value: ether("1") }); + await delegator1.lock(ether("1")); + //connect vault 1 to VaultHub await vaultHub .connect(admin) diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts index 688af2e40..d47fec04f 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.forceExit.test.ts @@ -103,6 +103,10 @@ describe("VaultHub.sol:forceExit", () => { const codehash = keccak256(await ethers.provider.getCode(vaultAddress)); await vaultHub.connect(user).addVaultProxyCodehash(codehash); + const connectDeposit = ether("1.0"); + await vault.connect(user).fund({ value: connectDeposit }); + await vault.connect(user).lock(connectDeposit); + await vaultHub .connect(user) .connectVault(vaultAddress, SHARE_LIMIT, RESERVE_RATIO_BP, RESERVE_RATIO_THRESHOLD_BP, TREASURY_FEE_BP); @@ -208,6 +212,7 @@ describe("VaultHub.sol:forceExit", () => { await demoVault.fund({ value: valuation }); const cap = await steth.getSharesByPooledEth((valuation * (TOTAL_BASIS_POINTS - 20_00n)) / TOTAL_BASIS_POINTS); + await demoVault.connect(user).lock(valuation); await vaultHub.connectVault(demoVaultAddress, cap, 20_00n, 20_00n, 5_00n); await vaultHub.mintShares(demoVaultAddress, user, cap); diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts index 05282eadf..4e46efe98 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts @@ -15,7 +15,7 @@ import { VaultHub, } from "typechain-types"; -import { BigIntMath, ether, findEvents, impersonate, randomAddress } from "lib"; +import { BigIntMath, ether, findEvents, impersonate, MAX_UINT256, randomAddress } from "lib"; import { deployLidoDao, updateLidoLocatorImplementation } from "test/deploy"; import { Snapshot, VAULTS_RELATIVE_SHARE_LIMIT_BP, ZERO_HASH } from "test/suite"; @@ -72,6 +72,8 @@ describe("VaultHub.sol:hub", () => { }, ) { const vault = await createVault(factory); + await vault.connect(user).fund({ value: CONNECT_DEPOSIT }); + await vault.connect(user).lock(CONNECT_DEPOSIT); await vaultHub .connect(user) @@ -313,6 +315,7 @@ describe("VaultHub.sol:hub", () => { if (mintable > 0n) { const sharesToMint = await lido.getSharesByPooledEth(mintable); + await vault.lock(valuation); await vaultHub.connect(user).mintShares(vaultAddress, user, sharesToMint); } @@ -498,6 +501,7 @@ describe("VaultHub.sol:hub", () => { await vault.fund({ value: ether("50") }); const mintingEth = ether("25"); const sharesToMint = await lido.getSharesByPooledEth(mintingEth); + await vault.lock(ether("50")); await vaultHub.connect(user).mintShares(vaultAddress, user, sharesToMint); await vault.report(ether("50"), ether("50"), ether("5")); @@ -517,17 +521,10 @@ describe("VaultHub.sol:hub", () => { }); const vaultAddress = await vault.getAddress(); - - const vaultSocket_1 = await vaultHub["vaultSocket(address)"](vaultAddress); - const mintedStETH_1 = await lido.getPooledEthByShares(vaultSocket_1.sharesMinted); - const maxMintableRatio_1 = TOTAL_BASIS_POINTS - vaultSocket_1.reserveRatioBP; - const vaultValuation_1 = await vault.valuation(); - const localGap_1 = - (mintedStETH_1 * TOTAL_BASIS_POINTS - vaultValuation_1 * maxMintableRatio_1) / vaultSocket_1.reserveRatioBP; - - expect(await vaultHub.rebalanceShortfall(vaultAddress)).to.equal(localGap_1); + expect(await vaultHub.rebalanceShortfall(vaultAddress)).to.equal(MAX_UINT256); await vault.fund({ value: ether("50") }); + await vault.lock(ether("50")); const mintingEth = ether("25"); const sharesToMint = await lido.getSharesByPooledEth(mintingEth); await vaultHub.connect(user).mintShares(vaultAddress, user, sharesToMint); @@ -677,6 +674,9 @@ describe("VaultHub.sol:hub", () => { expect(vaultSocketBefore.vault).to.equal(ZeroAddress); expect(vaultSocketBefore.pendingDisconnect).to.be.false; + await vault.connect(user).fund({ value: ether("1") }); + await vault.connect(user).lock(ether("1")); + await expect( vaultHub .connect(user) @@ -695,6 +695,9 @@ describe("VaultHub.sol:hub", () => { }); it("allows to connect the vault with 0 share limit", async () => { + await vault.connect(user).fund({ value: ether("1") }); + await vault.connect(user).lock(ether("1")); + await expect( vaultHub .connect(user) @@ -705,6 +708,9 @@ describe("VaultHub.sol:hub", () => { }); it("allows to connect the vault with 0 treasury fee", async () => { + await vault.connect(user).fund({ value: ether("1") }); + await vault.connect(user).lock(ether("1")); + await expect( vaultHub .connect(user) diff --git a/test/integration/vaults/happy-path.integration.ts b/test/integration/vaults/happy-path.integration.ts index 81ccb1259..104c022e9 100644 --- a/test/integration/vaults/happy-path.integration.ts +++ b/test/integration/vaults/happy-path.integration.ts @@ -168,6 +168,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { funders: [curator], withdrawers: [curator], minters: [curator], + lockers: [curator], burners: [curator], rebalancers: [curator], depositPausers: [curator], @@ -195,6 +196,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { expect(await isSoleRoleMember(curator, await delegation.FUND_ROLE())).to.be.true; expect(await isSoleRoleMember(curator, await delegation.WITHDRAW_ROLE())).to.be.true; + expect(await isSoleRoleMember(curator, await delegation.LOCK_ROLE())).to.be.true; expect(await isSoleRoleMember(curator, await delegation.MINT_ROLE())).to.be.true; expect(await isSoleRoleMember(curator, await delegation.BURN_ROLE())).to.be.true; expect(await isSoleRoleMember(curator, await delegation.REBALANCE_ROLE())).to.be.true; @@ -208,7 +210,7 @@ describe("Scenario: Staking Vaults Happy Path", () => { it("Should allow Lido to recognize vaults and connect them to accounting", async () => { const { lido, vaultHub } = ctx.contracts; - expect(await stakingVault.locked()).to.equal(0); // no ETH locked yet + expect(await stakingVault.locked()).to.equal(0n); // no ETH locked yet const votingSigner = await ctx.getSigner("voting"); await lido.connect(votingSigner).setMaxExternalRatioBP(20_00n); @@ -218,6 +220,9 @@ describe("Scenario: Staking Vaults Happy Path", () => { const agentSigner = await ctx.getSigner("agent"); + await delegation.connect(curator).fund({ value: ether("1") }); + await delegation.connect(curator).lock(ether("1")); + await vaultHub .connect(agentSigner) .connectVault(stakingVault, shareLimit, reserveRatio, rebalanceThreshold, treasuryFeeBP); @@ -231,8 +236,8 @@ describe("Scenario: Staking Vaults Happy Path", () => { const vaultBalance = await ethers.provider.getBalance(stakingVault); - expect(vaultBalance).to.equal(VAULT_DEPOSIT); - expect(await stakingVault.valuation()).to.equal(VAULT_DEPOSIT); + expect(vaultBalance).to.equal(VAULT_DEPOSIT + VAULT_CONNECTION_DEPOSIT); + expect(await stakingVault.valuation()).to.equal(VAULT_DEPOSIT + VAULT_CONNECTION_DEPOSIT); }); it("Should allow NodeOperator to deposit validators from the vault via PDG", async () => { @@ -313,12 +318,12 @@ describe("Scenario: Staking Vaults Happy Path", () => { stakingVaultAddress = await stakingVault.getAddress(); const vaultBalance = await ethers.provider.getBalance(stakingVault); - expect(vaultBalance).to.equal(0n); - expect(await stakingVault.valuation()).to.equal(VAULT_DEPOSIT); + expect(vaultBalance).to.equal(VAULT_CONNECTION_DEPOSIT); + expect(await stakingVault.valuation()).to.equal(VAULT_DEPOSIT + VAULT_CONNECTION_DEPOSIT); }); it("Should allow Curator to mint max stETH", async () => { - const { vaultHub, lido } = ctx.contracts; + const { lido } = ctx.contracts; // Calculate the max stETH that can be minted on the vault 101 with the given LTV stakingVaultMaxMintingShares = await lido.getSharesByPooledEth( @@ -331,12 +336,6 @@ describe("Scenario: Staking Vaults Happy Path", () => { "Max shares": stakingVaultMaxMintingShares, }); - // Validate minting with the cap - const mintOverLimitTx = delegation.connect(curator).mintShares(curator, stakingVaultMaxMintingShares + 1n); - await expect(mintOverLimitTx) - .to.be.revertedWithCustomError(vaultHub, "InsufficientValuationToMint") - .withArgs(stakingVault, stakingVault.valuation()); - const mintTx = await delegation.connect(curator).mintShares(curator, stakingVaultMaxMintingShares); const mintTxReceipt = (await mintTx.wait()) as ContractTransactionReceipt; diff --git a/test/integration/vaults/roles.integration.ts b/test/integration/vaults/roles.integration.ts index 27e63fb08..5170568d6 100644 --- a/test/integration/vaults/roles.integration.ts +++ b/test/integration/vaults/roles.integration.ts @@ -35,6 +35,7 @@ describe("Integration: Staking Vaults Delegation Roles Initial Setup", () => { nodeOperatorManager: HardhatEthersSigner, funder: HardhatEthersSigner, withdrawer: HardhatEthersSigner, + locker: HardhatEthersSigner, assetRecoverer: HardhatEthersSigner, minter: HardhatEthersSigner, burner: HardhatEthersSigner, @@ -59,6 +60,7 @@ describe("Integration: Staking Vaults Delegation Roles Initial Setup", () => { assetRecoverer, funder, withdrawer, + locker, minter, burner, rebalancer, @@ -99,6 +101,7 @@ describe("Integration: Staking Vaults Delegation Roles Initial Setup", () => { funders: [funder], withdrawers: [withdrawer], minters: [minter], + lockers: [locker], burners: [burner], rebalancers: [rebalancer], depositPausers: [depositPausers], @@ -293,6 +296,8 @@ describe("Integration: Staking Vaults Delegation Roles Initial Setup", () => { ); }); it("mintStETH", async () => { + await testDelegation.connect(funder).fund({ value: ether("1") }); + await testDelegation.connect(locker).lock(ether("1")); await testMethod( testDelegation, "mintStETH", @@ -306,6 +311,8 @@ describe("Integration: Staking Vaults Delegation Roles Initial Setup", () => { }); it("mintShares", async () => { + await testDelegation.connect(funder).fund({ value: ether("1") }); + await testDelegation.connect(locker).lock(ether("1")); await testMethod( testDelegation, "mintShares", @@ -333,6 +340,18 @@ describe("Integration: Staking Vaults Delegation Roles Initial Setup", () => { ); }); + it("lock", async () => { + await testMethod( + testDelegation, + "lock", + { + successUsers: [locker], + failingUsers: allRoles.filter((r) => r !== locker), + }, + [ether("1")], + await testDelegation.LOCK_ROLE(), + ); + }); // requires prepared state for this test to pass, skipping for now it.skip("withdrawWETH", async () => { await testMethod( @@ -450,6 +469,7 @@ describe("Integration: Staking Vaults Delegation Roles Initial Setup", () => { funders: [], withdrawers: [], minters: [], + lockers: [], burners: [], rebalancers: [], depositPausers: [],