Skip to content

Commit 06ad2c1

Browse files
authored
Merge pull request #1487 from lidofinance/feat/expose-shortfall
feat: obligationsShortfallValue
2 parents 8cf2746 + cb5a960 commit 06ad2c1

File tree

3 files changed

+106
-107
lines changed

3 files changed

+106
-107
lines changed

contracts/0.8.25/vaults/VaultHub.sol

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -288,12 +288,23 @@ contract VaultHub is PausableUntilWithRoles {
288288

289289
/// @notice calculate shares amount to make the vault healthy using rebalance
290290
/// @param _vault vault address
291-
/// @return amount of shares or UINT256_MAX if it's impossible to make the vault healthy using rebalance
291+
/// @return shares amount or UINT256_MAX if it's impossible to make the vault healthy using rebalance
292292
/// @dev returns 0 if the vault is not connected
293293
function healthShortfallShares(address _vault) external view returns (uint256) {
294294
return _healthShortfallShares(_vaultConnection(_vault), _vaultRecord(_vault));
295295
}
296296

297+
/// @notice calculate ether amount required to cover obligations shortfall of the vault
298+
/// @param _vault vault address
299+
/// @return ether amount or UINT256_MAX if it's impossible to cover obligations shortfall
300+
/// @dev returns 0 if the vault is not connected
301+
function obligationsShortfallValue(address _vault) external view returns (uint256) {
302+
VaultConnection storage connection = _vaultConnection(_vault);
303+
if (connection.vaultIndex == 0) return 0;
304+
305+
return _obligationsShortfallValue(_vault, connection, _vaultRecord(_vault));
306+
}
307+
297308
/// @notice returns the vault's current obligations toward the protocol
298309
///
299310
/// Obligations are amounts the vault must cover, in the following priority:
@@ -871,8 +882,8 @@ contract VaultHub is PausableUntilWithRoles {
871882
/// vault owner from clogging the consensus layer withdrawal queue by front-running and delaying the
872883
/// forceful validator exits required for rebalancing the vault. Partial withdrawals only allowed if
873884
/// the requested amount of withdrawals is enough to cover the uncovered obligations.
874-
uint256 obligationsShortfall = _obligationsShortfall(_vault, connection, record);
875-
if (obligationsShortfall > 0 && minPartialAmountInGwei * 1e9 < obligationsShortfall) {
885+
uint256 obligationsShortfallAmount = _obligationsShortfallValue(_vault, connection, record);
886+
if (obligationsShortfallAmount > 0 && minPartialAmountInGwei * 1e9 < obligationsShortfallAmount) {
876887
revert PartialValidatorWithdrawalNotAllowed();
877888
}
878889
}
@@ -897,8 +908,8 @@ contract VaultHub is PausableUntilWithRoles {
897908
VaultRecord storage record = _vaultRecord(_vault);
898909
_requireFreshReport(_vault, record);
899910

900-
uint256 obligationsShortfall = _obligationsShortfall(_vault, connection, record);
901-
if (obligationsShortfall == 0) revert ForcedValidatorExitNotAllowed();
911+
uint256 obligationsShortfallAmount = _obligationsShortfallValue(_vault, connection, record);
912+
if (obligationsShortfallAmount == 0) revert ForcedValidatorExitNotAllowed();
902913

903914
uint64[] memory amountsInGwei = new uint64[](0);
904915
_triggerVaultValidatorWithdrawals(_vault, msg.value, _pubkeys, amountsInGwei, _refundRecipient);
@@ -1287,7 +1298,7 @@ contract VaultHub is PausableUntilWithRoles {
12871298
}
12881299

12891300
/// @return the ether shortfall required to fully cover all outstanding obligations amount of the vault
1290-
function _obligationsShortfall(
1301+
function _obligationsShortfallValue(
12911302
address _vault,
12921303
VaultConnection storage _connection,
12931304
VaultRecord storage _record

contracts/0.8.25/vaults/dashboard/Dashboard.sol

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,22 @@ contract Dashboard is NodeOperatorFee {
161161

162162
/**
163163
* @notice Returns the amount of shares to rebalance to restore vault healthiness or to cover redemptions
164+
* @dev returns UINT256_MAX if it's impossible to make the vault healthy using rebalance
164165
*/
165166
function healthShortfallShares() external view returns (uint256) {
166167
return VAULT_HUB.healthShortfallShares(address(_stakingVault()));
167168
}
168169

170+
/**
171+
* @notice Returns the amount of ether required to cover obligations shortfall of the vault
172+
* @dev returns UINT256_MAX if it's impossible to cover obligations shortfall
173+
* @dev NB: obligationsShortfallValue includes healthShortfallShares converted to ether and any unsettled Lido fees
174+
* in case they are greater than the minimum beacon deposit
175+
*/
176+
function obligationsShortfallValue() external view returns (uint256) {
177+
return VAULT_HUB.obligationsShortfallValue(address(_stakingVault()));
178+
}
179+
169180
/**
170181
* @notice Returns the amount of ether that is locked on the vault only as a reserve.
171182
* @dev There is no way to mint stETH for it (it includes connection deposit and slashing reserve)

test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts

Lines changed: 78 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { expect } from "chai";
2-
import { ContractTransactionReceipt, formatEther, ZeroAddress } from "ethers";
2+
import { ContractTransactionReceipt, ZeroAddress } from "ethers";
33
import { ethers } from "hardhat";
44

55
import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
6+
import { setBalance } from "@nomicfoundation/hardhat-network-helpers";
67

78
import {
89
ACL,
@@ -22,7 +23,6 @@ import { TierParamsStruct } from "typechain-types/contracts/0.8.25/vaults/Operat
2223

2324
import {
2425
advanceChainTime,
25-
BigIntMath,
2626
certainAddress,
2727
days,
2828
ether,
@@ -141,27 +141,6 @@ describe("VaultHub.sol:hub", () => {
141141
);
142142
}
143143

144-
async function printRecord(vault: StakingVault__MockForVaultHub) {
145-
const record = await vaultHub.vaultRecord(vault);
146-
console.log("vaultRecord", {
147-
report: {
148-
totalValue: formatEther(record.report.totalValue),
149-
inOutDelta: formatEther(record.report.inOutDelta),
150-
timestamp: record.report.timestamp,
151-
},
152-
maxLiabilityShares: formatEther(record.maxLiabilityShares),
153-
liabilityShares: formatEther(record.liabilityShares),
154-
inOutDelta: {
155-
value: formatEther(record.inOutDelta[0].value),
156-
valueOnRefSlot: formatEther(record.inOutDelta[0].valueOnRefSlot),
157-
refSlot: record.inOutDelta[0].refSlot,
158-
value2: formatEther(record.inOutDelta[1].value),
159-
valueOnRefSlot2: formatEther(record.inOutDelta[1].valueOnRefSlot),
160-
refSlot2: record.inOutDelta[1].refSlot,
161-
},
162-
});
163-
}
164-
165144
before(async () => {
166145
[deployer, user, stranger, whale] = await ethers.getSigners();
167146

@@ -345,73 +324,6 @@ describe("VaultHub.sol:hub", () => {
345324
expect(await vaultHub.isVaultHealthy(vaultAddress)).to.equal(true);
346325
});
347326

348-
// Looks like fuzzing but it's not [:}
349-
it.skip("returns correct value for various parameters", async () => {
350-
const tbi = (n: number | bigint, min: number = 1) => BigInt(Math.floor(Math.random() * Number(n)) + min);
351-
352-
for (let i = 0; i < 50; i++) {
353-
const snapshot = await Snapshot.take();
354-
const forcedRebalanceThresholdBP = tbi(10000);
355-
const reserveRatioBP = BigIntMath.min(forcedRebalanceThresholdBP + tbi(100), TOTAL_BASIS_POINTS - 1n);
356-
357-
const totalValueEth = tbi(100);
358-
const totalValue = ether(totalValueEth.toString());
359-
360-
const mintable = (totalValue * (TOTAL_BASIS_POINTS - reserveRatioBP)) / TOTAL_BASIS_POINTS;
361-
362-
const isSlashing = Math.random() < 0.5;
363-
const slashed = isSlashing ? ether(tbi(totalValueEth).toString()) : 0n;
364-
const threshold =
365-
((totalValue - slashed) * (TOTAL_BASIS_POINTS - forcedRebalanceThresholdBP)) / TOTAL_BASIS_POINTS;
366-
const expectedHealthy = threshold >= mintable;
367-
368-
const { vault } = await createAndConnectVault(vaultFactory, {
369-
shareLimit: ether("100"), // just to bypass the share limit check
370-
reserveRatioBP,
371-
forcedRebalanceThresholdBP,
372-
});
373-
374-
await vault.fund({ value: totalValue });
375-
376-
await printRecord(vault);
377-
378-
let sharesToMint = 0n;
379-
if (mintable > 0n) {
380-
sharesToMint = await lido.getSharesByPooledEth(mintable);
381-
await reportVault({ vault });
382-
await vaultHub.connect(user).mintShares(vault, user, sharesToMint);
383-
await printRecord(vault);
384-
}
385-
386-
// simulate slashing
387-
await reportVault({
388-
vault,
389-
totalValue: totalValue - slashed,
390-
inOutDelta: totalValue,
391-
liabilityShares: sharesToMint,
392-
});
393-
console.log("vaultRecord", await vaultHub.vaultRecord(vault));
394-
395-
try {
396-
const actualHealthy = await vaultHub.isVaultHealthy(vault);
397-
expect(actualHealthy).to.equal(expectedHealthy);
398-
} catch (error) {
399-
console.log(`Test failed with parameters:
400-
Rebalance Threshold: ${forcedRebalanceThresholdBP}
401-
Reserve Ratio: ${reserveRatioBP}
402-
Total Value: ${totalValue} ETH
403-
Minted: ${mintable} stETH
404-
Slashed: ${slashed} ETH
405-
Threshold: ${threshold} stETH
406-
Expected Healthy: ${expectedHealthy}
407-
`);
408-
throw error;
409-
}
410-
411-
await Snapshot.restore(snapshot);
412-
}
413-
});
414-
415327
it("returns correct value close to the threshold border cases at 1:1 share rate", async () => {
416328
const config = {
417329
shareLimit: ether("100"), // just to bypass the share limit check
@@ -608,13 +520,7 @@ describe("VaultHub.sol:hub", () => {
608520
forcedRebalanceThresholdBP: 50_00n, // 50%
609521
});
610522

611-
await reportVault({ vault, totalValue: ether("50"), inOutDelta: ether("50") });
612-
613-
const burner = await impersonate(await locator.burner(), ether("1"));
614-
await lido.connect(whale).transfer(burner, ether("1"));
615-
await lido.connect(burner).burnShares(ether("1"));
616-
617-
expect(await vaultHub.healthShortfallShares(vault)).to.equal(ether("0"));
523+
expect(await vaultHub.healthShortfallShares(vault)).to.equal(0n);
618524
});
619525

620526
it("returns 0 when minted small amount of stETH and vault is healthy", async () => {
@@ -630,10 +536,6 @@ describe("VaultHub.sol:hub", () => {
630536
const sharesToMint = await lido.getSharesByPooledEth(mintingEth);
631537
await vaultHub.connect(user).mintShares(vault, user, sharesToMint);
632538

633-
const burner = await impersonate(await locator.burner(), ether("1"));
634-
await lido.connect(whale).transfer(burner, ether("1"));
635-
await lido.connect(burner).burnShares(ether("1"));
636-
637539
expect(await vaultHub.isVaultHealthy(vault)).to.equal(true);
638540
expect(await vaultHub.healthShortfallShares(vault)).to.equal(0n);
639541
});
@@ -693,6 +595,81 @@ describe("VaultHub.sol:hub", () => {
693595
});
694596
});
695597

598+
context("obligationsShortfallValue", () => {
599+
it("does not revert when vault address is correct", async () => {
600+
const { vault } = await createAndConnectVault(vaultFactory, {
601+
shareLimit: ether("100"), // just to bypass the share limit check
602+
reserveRatioBP: 50_00n, // 50%
603+
forcedRebalanceThresholdBP: 50_00n, // 50%
604+
});
605+
606+
await expect(vaultHub.obligationsShortfallValue(vault)).not.to.be.reverted;
607+
});
608+
609+
it("does not revert when vault address is ZeroAddress", async () => {
610+
const zeroAddress = ethers.ZeroAddress;
611+
await expect(vaultHub.obligationsShortfallValue(zeroAddress)).not.to.be.reverted;
612+
});
613+
614+
it("different cases when vault is healthy, unhealthy and minted > totalValue, and fees are > MIN_BEACON_DEPOSIT", async () => {
615+
const { vault } = await createAndConnectVault(vaultFactory, {
616+
shareLimit: ether("100"), // just to bypass the share limit check
617+
reserveRatioBP: 10_00n, // 10%
618+
forcedRebalanceThresholdBP: 9_00n, // 9%
619+
});
620+
621+
await vaultHub.connect(user).fund(vault, { value: ether("1") });
622+
623+
await reportVault({ vault, totalValue: ether("2"), inOutDelta: ether("2") });
624+
625+
await vaultHub.connect(user).mintShares(vault, user, ether("0.25"));
626+
627+
await reportVault({ vault, totalValue: ether("0.5") }); // at the threshold
628+
expect(await vaultHub.isVaultHealthy(vault)).to.equal(true);
629+
expect(await vaultHub.obligationsShortfallValue(vault)).to.equal(0n);
630+
631+
const balanceBefore = await ethers.provider.getBalance(vault);
632+
await setBalance(await vault.getAddress(), 0n);
633+
// below the threshold, but with fees
634+
await reportVault({ vault, totalValue: ether("0.5") - 1n, cumulativeLidoFees: ether("1") });
635+
expect(await vaultHub.isVaultHealthy(vault)).to.equal(true);
636+
expect(await vaultHub.obligationsShortfallValue(vault)).to.equal(ether("1"));
637+
638+
await setBalance(await vault.getAddress(), balanceBefore);
639+
await reportVault({ vault, totalValue: 0n }); // minted > totalValue
640+
expect(await vaultHub.isVaultHealthy(vault)).to.equal(false);
641+
expect(await vaultHub.obligationsShortfallValue(vault)).to.equal(MAX_UINT256);
642+
});
643+
644+
it("returns correct value for rebalance vault", async () => {
645+
const { vault } = await createAndConnectVault(vaultFactory, {
646+
shareLimit: ether("100"), // just to bypass the share limit check
647+
reserveRatioBP: 50_00n, // 50%
648+
forcedRebalanceThresholdBP: 50_00n, // 50%
649+
});
650+
651+
await vaultHub.connect(user).fund(vault, { value: ether("49") });
652+
expect(await vaultHub.totalValue(vault)).to.equal(ether("50"));
653+
654+
await reportVault({ vault, totalValue: ether("50") });
655+
656+
const mintingEth = ether("25");
657+
const sharesToMint = await lido.getSharesByPooledEth(mintingEth);
658+
await vaultHub.connect(user).mintShares(vault, user, sharesToMint);
659+
660+
const burner = await impersonate(await locator.burner(), ether("1"));
661+
await lido.connect(whale).transfer(burner, ether("1"));
662+
await lido.connect(burner).burnShares(ether("1"));
663+
664+
await reportVault({ vault });
665+
666+
const record = await vaultHub.vaultRecord(vault);
667+
const sharesByTotalValue = await lido.getSharesByPooledEth(await vaultHub.totalValue(vault));
668+
const shortfall = (record.liabilityShares * TOTAL_BASIS_POINTS - sharesByTotalValue * 50_00n) / 50_00n;
669+
expect(await vaultHub.healthShortfallShares(vault)).to.equal(shortfall);
670+
});
671+
});
672+
696673
context("connectVault", () => {
697674
let vault: StakingVault__MockForVaultHub;
698675

0 commit comments

Comments
 (0)