diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index 0d5d96f4b..3ba389a2d 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -288,12 +288,23 @@ contract VaultHub is PausableUntilWithRoles { /// @notice calculate shares amount to make the vault healthy using rebalance /// @param _vault vault address - /// @return amount of shares or UINT256_MAX if it's impossible to make the vault healthy using rebalance + /// @return shares amount or UINT256_MAX if it's impossible to make the vault healthy using rebalance /// @dev returns 0 if the vault is not connected function healthShortfallShares(address _vault) external view returns (uint256) { return _healthShortfallShares(_vaultConnection(_vault), _vaultRecord(_vault)); } + /// @notice calculate ether amount required to cover obligations shortfall of the vault + /// @param _vault vault address + /// @return ether amount or UINT256_MAX if it's impossible to cover obligations shortfall + /// @dev returns 0 if the vault is not connected + function obligationsShortfallValue(address _vault) external view returns (uint256) { + VaultConnection storage connection = _vaultConnection(_vault); + if (connection.vaultIndex == 0) return 0; + + return _obligationsShortfallValue(_vault, connection, _vaultRecord(_vault)); + } + /// @notice returns the vault's current obligations toward the protocol /// /// Obligations are amounts the vault must cover, in the following priority: @@ -871,8 +882,8 @@ contract VaultHub is PausableUntilWithRoles { /// vault owner from clogging the consensus layer withdrawal queue by front-running and delaying the /// forceful validator exits required for rebalancing the vault. Partial withdrawals only allowed if /// the requested amount of withdrawals is enough to cover the uncovered obligations. - uint256 obligationsShortfall = _obligationsShortfall(_vault, connection, record); - if (obligationsShortfall > 0 && minPartialAmountInGwei * 1e9 < obligationsShortfall) { + uint256 obligationsShortfallAmount = _obligationsShortfallValue(_vault, connection, record); + if (obligationsShortfallAmount > 0 && minPartialAmountInGwei * 1e9 < obligationsShortfallAmount) { revert PartialValidatorWithdrawalNotAllowed(); } } @@ -897,8 +908,8 @@ contract VaultHub is PausableUntilWithRoles { VaultRecord storage record = _vaultRecord(_vault); _requireFreshReport(_vault, record); - uint256 obligationsShortfall = _obligationsShortfall(_vault, connection, record); - if (obligationsShortfall == 0) revert ForcedValidatorExitNotAllowed(); + uint256 obligationsShortfallAmount = _obligationsShortfallValue(_vault, connection, record); + if (obligationsShortfallAmount == 0) revert ForcedValidatorExitNotAllowed(); uint64[] memory amountsInGwei = new uint64[](0); _triggerVaultValidatorWithdrawals(_vault, msg.value, _pubkeys, amountsInGwei, _refundRecipient); @@ -1287,7 +1298,7 @@ contract VaultHub is PausableUntilWithRoles { } /// @return the ether shortfall required to fully cover all outstanding obligations amount of the vault - function _obligationsShortfall( + function _obligationsShortfallValue( address _vault, VaultConnection storage _connection, VaultRecord storage _record diff --git a/contracts/0.8.25/vaults/dashboard/Dashboard.sol b/contracts/0.8.25/vaults/dashboard/Dashboard.sol index 2791e92f4..cf405e192 100644 --- a/contracts/0.8.25/vaults/dashboard/Dashboard.sol +++ b/contracts/0.8.25/vaults/dashboard/Dashboard.sol @@ -161,11 +161,22 @@ contract Dashboard is NodeOperatorFee { /** * @notice Returns the amount of shares to rebalance to restore vault healthiness or to cover redemptions + * @dev returns UINT256_MAX if it's impossible to make the vault healthy using rebalance */ function healthShortfallShares() external view returns (uint256) { return VAULT_HUB.healthShortfallShares(address(_stakingVault())); } + /** + * @notice Returns the amount of ether required to cover obligations shortfall of the vault + * @dev returns UINT256_MAX if it's impossible to cover obligations shortfall + * @dev NB: obligationsShortfallValue includes healthShortfallShares converted to ether and any unsettled Lido fees + * in case they are greater than the minimum beacon deposit + */ + function obligationsShortfallValue() external view returns (uint256) { + return VAULT_HUB.obligationsShortfallValue(address(_stakingVault())); + } + /** * @notice Returns the amount of ether that is locked on the vault only as a reserve. * @dev There is no way to mint stETH for it (it includes connection deposit and slashing reserve) 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 3e84ff8fe..c1f6f6cd6 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts @@ -1,8 +1,9 @@ import { expect } from "chai"; -import { ContractTransactionReceipt, formatEther, ZeroAddress } from "ethers"; +import { ContractTransactionReceipt, ZeroAddress } from "ethers"; import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; +import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; import { ACL, @@ -22,7 +23,6 @@ import { TierParamsStruct } from "typechain-types/contracts/0.8.25/vaults/Operat import { advanceChainTime, - BigIntMath, certainAddress, days, ether, @@ -141,27 +141,6 @@ describe("VaultHub.sol:hub", () => { ); } - async function printRecord(vault: StakingVault__MockForVaultHub) { - const record = await vaultHub.vaultRecord(vault); - console.log("vaultRecord", { - report: { - totalValue: formatEther(record.report.totalValue), - inOutDelta: formatEther(record.report.inOutDelta), - timestamp: record.report.timestamp, - }, - maxLiabilityShares: formatEther(record.maxLiabilityShares), - liabilityShares: formatEther(record.liabilityShares), - inOutDelta: { - value: formatEther(record.inOutDelta[0].value), - valueOnRefSlot: formatEther(record.inOutDelta[0].valueOnRefSlot), - refSlot: record.inOutDelta[0].refSlot, - value2: formatEther(record.inOutDelta[1].value), - valueOnRefSlot2: formatEther(record.inOutDelta[1].valueOnRefSlot), - refSlot2: record.inOutDelta[1].refSlot, - }, - }); - } - before(async () => { [deployer, user, stranger, whale] = await ethers.getSigners(); @@ -345,73 +324,6 @@ describe("VaultHub.sol:hub", () => { expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true); }); - // Looks like fuzzing but it's not [:} - it.skip("returns correct value for various parameters", async () => { - const tbi = (n: number | bigint, min: number = 1) => BigInt(Math.floor(Math.random() * Number(n)) + min); - - for (let i = 0; i < 50; i++) { - const snapshot = await Snapshot.take(); - const forcedRebalanceThresholdBP = tbi(10000); - const reserveRatioBP = BigIntMath.min(forcedRebalanceThresholdBP + tbi(100), TOTAL_BASIS_POINTS - 1n); - - const totalValueEth = tbi(100); - const totalValue = ether(totalValueEth.toString()); - - const mintable = (totalValue * (TOTAL_BASIS_POINTS - reserveRatioBP)) / TOTAL_BASIS_POINTS; - - const isSlashing = Math.random() < 0.5; - const slashed = isSlashing ? ether(tbi(totalValueEth).toString()) : 0n; - const threshold = - ((totalValue - slashed) * (TOTAL_BASIS_POINTS - forcedRebalanceThresholdBP)) / TOTAL_BASIS_POINTS; - const expectedHealthy = threshold >= mintable; - - const { vault } = await createAndConnectVault(vaultFactory, { - shareLimit: ether("100"), // just to bypass the share limit check - reserveRatioBP, - forcedRebalanceThresholdBP, - }); - - await vault.fund({ value: totalValue }); - - await printRecord(vault); - - let sharesToMint = 0n; - if (mintable > 0n) { - sharesToMint = await lido.getSharesByPooledEth(mintable); - await reportVault({ vault }); - await vaultHub.connect(user).mintShares(vault, user, sharesToMint); - await printRecord(vault); - } - - // simulate slashing - await reportVault({ - vault, - totalValue: totalValue - slashed, - inOutDelta: totalValue, - liabilityShares: sharesToMint, - }); - console.log("vaultRecord", await vaultHub.vaultRecord(vault)); - - try { - const actualHealthy = await vaultHub.isVaultHealthy(vault); - expect(actualHealthy).to.equal(expectedHealthy); - } catch (error) { - console.log(`Test failed with parameters: - Rebalance Threshold: ${forcedRebalanceThresholdBP} - Reserve Ratio: ${reserveRatioBP} - Total Value: ${totalValue} ETH - Minted: ${mintable} stETH - Slashed: ${slashed} ETH - Threshold: ${threshold} stETH - Expected Healthy: ${expectedHealthy} - `); - throw error; - } - - await Snapshot.restore(snapshot); - } - }); - it("returns correct value close to the threshold border cases at 1:1 share rate", async () => { const config = { shareLimit: ether("100"), // just to bypass the share limit check @@ -608,13 +520,7 @@ describe("VaultHub.sol:hub", () => { forcedRebalanceThresholdBP: 50_00n, // 50% }); - await reportVault({ vault, totalValue: ether("50"), inOutDelta: ether("50") }); - - const burner = await impersonate(await locator.burner(), ether("1")); - await lido.connect(whale).transfer(burner, ether("1")); - await lido.connect(burner).burnShares(ether("1")); - - expect(await vaultHub.healthShortfallShares(vault)).to.equal(ether("0")); + expect(await vaultHub.healthShortfallShares(vault)).to.equal(0n); }); it("returns 0 when minted small amount of stETH and vault is healthy", async () => { @@ -630,10 +536,6 @@ describe("VaultHub.sol:hub", () => { const sharesToMint = await lido.getSharesByPooledEth(mintingEth); await vaultHub.connect(user).mintShares(vault, user, sharesToMint); - const burner = await impersonate(await locator.burner(), ether("1")); - await lido.connect(whale).transfer(burner, ether("1")); - await lido.connect(burner).burnShares(ether("1")); - expect(await vaultHub.isVaultHealthy(vault)).to.equal(true); expect(await vaultHub.healthShortfallShares(vault)).to.equal(0n); }); @@ -693,6 +595,81 @@ describe("VaultHub.sol:hub", () => { }); }); + context("obligationsShortfallValue", () => { + it("does not revert when vault address is correct", async () => { + const { vault } = await createAndConnectVault(vaultFactory, { + shareLimit: ether("100"), // just to bypass the share limit check + reserveRatioBP: 50_00n, // 50% + forcedRebalanceThresholdBP: 50_00n, // 50% + }); + + await expect(vaultHub.obligationsShortfallValue(vault)).not.to.be.reverted; + }); + + it("does not revert when vault address is ZeroAddress", async () => { + const zeroAddress = ethers.ZeroAddress; + await expect(vaultHub.obligationsShortfallValue(zeroAddress)).not.to.be.reverted; + }); + + it("different cases when vault is healthy, unhealthy and minted > totalValue, and fees are > MIN_BEACON_DEPOSIT", async () => { + const { vault } = await createAndConnectVault(vaultFactory, { + shareLimit: ether("100"), // just to bypass the share limit check + reserveRatioBP: 10_00n, // 10% + forcedRebalanceThresholdBP: 9_00n, // 9% + }); + + await vaultHub.connect(user).fund(vault, { value: ether("1") }); + + await reportVault({ vault, totalValue: ether("2"), inOutDelta: ether("2") }); + + await vaultHub.connect(user).mintShares(vault, user, ether("0.25")); + + await reportVault({ vault, totalValue: ether("0.5") }); // at the threshold + expect(await vaultHub.isVaultHealthy(vault)).to.equal(true); + expect(await vaultHub.obligationsShortfallValue(vault)).to.equal(0n); + + const balanceBefore = await ethers.provider.getBalance(vault); + await setBalance(await vault.getAddress(), 0n); + // below the threshold, but with fees + await reportVault({ vault, totalValue: ether("0.5") - 1n, cumulativeLidoFees: ether("1") }); + expect(await vaultHub.isVaultHealthy(vault)).to.equal(true); + expect(await vaultHub.obligationsShortfallValue(vault)).to.equal(ether("1")); + + await setBalance(await vault.getAddress(), balanceBefore); + await reportVault({ vault, totalValue: 0n }); // minted > totalValue + expect(await vaultHub.isVaultHealthy(vault)).to.equal(false); + expect(await vaultHub.obligationsShortfallValue(vault)).to.equal(MAX_UINT256); + }); + + it("returns correct value for rebalance vault", async () => { + const { vault } = await createAndConnectVault(vaultFactory, { + shareLimit: ether("100"), // just to bypass the share limit check + reserveRatioBP: 50_00n, // 50% + forcedRebalanceThresholdBP: 50_00n, // 50% + }); + + await vaultHub.connect(user).fund(vault, { value: ether("49") }); + expect(await vaultHub.totalValue(vault)).to.equal(ether("50")); + + await reportVault({ vault, totalValue: ether("50") }); + + const mintingEth = ether("25"); + const sharesToMint = await lido.getSharesByPooledEth(mintingEth); + await vaultHub.connect(user).mintShares(vault, user, sharesToMint); + + const burner = await impersonate(await locator.burner(), ether("1")); + await lido.connect(whale).transfer(burner, ether("1")); + await lido.connect(burner).burnShares(ether("1")); + + await reportVault({ vault }); + + const record = await vaultHub.vaultRecord(vault); + const sharesByTotalValue = await lido.getSharesByPooledEth(await vaultHub.totalValue(vault)); + const shortfall = (record.liabilityShares * TOTAL_BASIS_POINTS - sharesByTotalValue * 50_00n) / 50_00n; + expect(await vaultHub.healthShortfallShares(vault)).to.equal(shortfall); + }); + }); + context("connectVault", () => { let vault: StakingVault__MockForVaultHub;