Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions contracts/0.8.25/vaults/VaultHub.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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();
}
}
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions contracts/0.8.25/vaults/dashboard/Dashboard.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
179 changes: 78 additions & 101 deletions test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -22,7 +23,6 @@ import { TierParamsStruct } from "typechain-types/contracts/0.8.25/vaults/Operat

import {
advanceChainTime,
BigIntMath,
certainAddress,
days,
ether,
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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);
});
Expand Down Expand Up @@ -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;

Expand Down