Skip to content
Merged
31 changes: 17 additions & 14 deletions contracts/0.8.25/vaults/VaultHub.sol
Original file line number Diff line number Diff line change
Expand Up @@ -1207,30 +1207,33 @@ contract VaultHub is PausableUntilWithRoles {

uint256 reserveRatioBP = _connection.reserveRatioBP;
uint256 maxMintableRatio = (TOTAL_BASIS_POINTS - reserveRatioBP);
uint256 sharesByTotalValue = _getSharesByPooledEth(totalValue_);
uint256 liability = _getPooledEthBySharesRoundUp(liabilityShares_);

// Impossible to rebalance a vault with bad debt
if (liabilityShares_ >= sharesByTotalValue) {
if (liability > totalValue_) {
return type(uint256).max;
}

// Solve the equation for X:
// LS - liabilityShares, TV - sharesByTotalValue
// L - liability, TV - totalValue
// MR - maxMintableRatio, 100 - TOTAL_BASIS_POINTS, RR - reserveRatio
// X - amount of shares that should be withdrawn (TV - X) and used to repay the debt (LS - X)
// to reduce the LS/TVS ratio back to MR

// (LS - X) / (TV - X) = MR / 100
// (LS - X) * 100 = (TV - X) * MR
// LS * 100 - X * 100 = TV * MR - X * MR
// X * MR - X * 100 = TV * MR - LS * 100
// X * (MR - 100) = TV * MR - LS * 100
// X = (TV * MR - LS * 100) / (MR - 100)
// X = (LS * 100 - TV * MR) / (100 - MR)
// to reduce the L/TV ratio back to MR
// (L - X) / (TV - X) = MR / 100
// (L - X) * 100 = (TV - X) * MR
// L * 100 - X * 100 = TV * MR - X * MR
// X * MR - X * 100 = TV * MR - L * 100
// X * (MR - 100) = TV * MR - L * 100
// X = (TV * MR - L * 100) / (MR - 100)
// X = (L * 100 - TV * MR) / (100 - MR)
// RR = 100 - MR
// X = (LS * 100 - TV * MR) / RR
// X = (L * 100 - TV * MR) / RR
uint256 shortfallEth = (liability * TOTAL_BASIS_POINTS - totalValue_ * maxMintableRatio) / reserveRatioBP;

return (liabilityShares_ * TOTAL_BASIS_POINTS - sharesByTotalValue * maxMintableRatio) / reserveRatioBP;
// Add 10 extra shares to avoid dealing with rounding/precision issues
uint256 shortfallShares = _getSharesByPooledEth(shortfallEth) + 10;

return Math256.min(shortfallShares, liabilityShares_);
}

function _totalValue(VaultRecord storage _record) internal view returns (uint256) {
Expand Down
2 changes: 1 addition & 1 deletion lib/protocol/helpers/vaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,6 @@ export async function calculateLockedValue(
return liability + (reserve > minimalReserve ? reserve : minimalReserve);
}

function ceilDiv(a: bigint, b: bigint): bigint {
export function ceilDiv(a: bigint, b: bigint): bigint {
return (a + b - 1n) / b;
}
26 changes: 20 additions & 6 deletions test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
impersonate,
} from "lib";
import { DISCONNECT_NOT_INITIATED, MAX_UINT256, TOTAL_BASIS_POINTS } from "lib/constants";
import { ceilDiv } from "lib/protocol";

import { deployLidoDao, updateLidoLocatorImplementation } from "test/deploy";
import { Snapshot, VAULTS_MAX_RELATIVE_SHARE_LIMIT_BP } from "test/suite";
Expand Down Expand Up @@ -589,9 +590,16 @@ describe("VaultHub.sol:hub", () => {
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);

const maxMintableRatio = TOTAL_BASIS_POINTS - 50_00n;
const liabilityShares_ = record.liabilityShares;
const liability = await lido.getPooledEthBySharesRoundUp(liabilityShares_);
const totalValue_ = await vaultHub.totalValue(vault);

const shortfallEth = ceilDiv(liability * TOTAL_BASIS_POINTS - totalValue_ * maxMintableRatio, 50_00n);
const shortfallShares = (await lido.getSharesByPooledEth(shortfallEth)) + 10n;

expect(await vaultHub.healthShortfallShares(vault)).to.equal(shortfallShares);
});
});

Expand Down Expand Up @@ -664,9 +672,15 @@ describe("VaultHub.sol:hub", () => {
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);
const maxMintableRatio = TOTAL_BASIS_POINTS - 50_00n;
const liabilityShares_ = record.liabilityShares;
const liability = await lido.getPooledEthBySharesRoundUp(liabilityShares_);
const totalValue_ = await vaultHub.totalValue(vault);

const shortfallEth = ceilDiv(liability * TOTAL_BASIS_POINTS - totalValue_ * maxMintableRatio, 50_00n);
const shortfallShares = (await lido.getSharesByPooledEth(shortfallEth)) + 10n;

expect(await vaultHub.healthShortfallShares(vault)).to.equal(shortfallShares);
});
});

Expand Down
9 changes: 6 additions & 3 deletions test/0.8.25/vaults/vaulthub/vaulthub.vault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { advanceChainTime, days, ether, getCurrentBlockTimestamp, impersonate } from "lib";
import { ONE_GWEI, TOTAL_BASIS_POINTS } from "lib/constants";
import { findEvents } from "lib/event";
import { ceilDiv } from "lib/protocol";

import { deployLidoDao, updateLidoLocatorImplementation } from "test/deploy";
import { Snapshot, VAULTS_MAX_RELATIVE_SHARE_LIMIT_BP } from "test/suite";
Expand Down Expand Up @@ -806,8 +807,10 @@ describe("VaultHub.sol:owner-functions", () => {
// Make vault in bad debt
await vaultHub.connect(vaultOwner).fund(vaultAddress, { value: ether("10") });
await reportVault({ totalValue: ether("11") });
await vaultHub.connect(vaultOwner).mintShares(vaultAddress, vaultOwner, ether("8.5"));
await reportVault({ totalValue: ether("8.5"), liabilityShares: ether("8.5") });
const totalValue = ether("8.5");
const liabilityShares = ether("8.5") + 1n;
await vaultHub.connect(vaultOwner).mintShares(vaultAddress, vaultOwner, liabilityShares);
await reportVault({ totalValue, liabilityShares });

await expect(
vaultHub
Expand Down Expand Up @@ -884,7 +887,7 @@ describe("VaultHub.sol:owner-functions", () => {

const healthShortfallShares = await vaultHub.healthShortfallShares(vaultAddress);
const rebalanceShortfallValue = await lido.getPooledEthBySharesRoundUp(healthShortfallShares);
const amount = rebalanceShortfallValue / ONE_GWEI;
const amount = ceilDiv(rebalanceShortfallValue, ONE_GWEI);

expect(await vaultHub.isVaultHealthy(vaultAddress)).to.be.false;
await expect(
Expand Down
2 changes: 1 addition & 1 deletion test/deploy/vaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ async function createMockStakingVaultAndConnect(
return vault;
}

async function reportVault(
export async function reportVault(
lazyOracle: LazyOracle__MockForVaultHub,
vaultHub: VaultHub,
{
Expand Down
227 changes: 227 additions & 0 deletions test/integration/vaults/vaulthub.shortfall.integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { expect } from "chai";
import { ethers } from "hardhat";

import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";

import { Dashboard, StakingVault, VaultHub } from "typechain-types";

import { impersonate } from "lib";
import { createVaultWithDashboard, getProtocolContext, ProtocolContext, setupLidoForVaults } from "lib/protocol";
import { reportVaultDataWithProof } from "lib/protocol/helpers";
import { ether } from "lib/units";

import { Snapshot } from "test/suite";

describe("Integration: VaultHub ", () => {
let ctx: ProtocolContext;
let snapshot: string;
let originalSnapshot: string;

let owner: HardhatEthersSigner;
let nodeOperator: HardhatEthersSigner;
let agentSigner: HardhatEthersSigner;
let stakingVault: StakingVault;

let vaultHub: VaultHub;
let dashboard: Dashboard;

before(async () => {
ctx = await getProtocolContext();
originalSnapshot = await Snapshot.take();

[, owner, nodeOperator] = await ethers.getSigners();
agentSigner = await ctx.getSigner("agent");
await setupLidoForVaults(ctx);
});

async function setup({ rr, frt }: { rr: bigint; frt: bigint }) {
({ stakingVault, dashboard } = await createVaultWithDashboard(
ctx,
ctx.contracts.stakingVaultFactory,
owner,
nodeOperator,
nodeOperator,
));

dashboard = dashboard.connect(owner);

const dashboardSigner = await impersonate(dashboard, ether("10000"));

await ctx.contracts.operatorGrid.connect(agentSigner).registerGroup(nodeOperator, ether("5000"));
const tier = {
shareLimit: ether("1000"),
reserveRatioBP: rr,
forcedRebalanceThresholdBP: frt,
infraFeeBP: 0,
liquidityFeeBP: 0,
reservationFeeBP: 0,
};

await ctx.contracts.operatorGrid.connect(agentSigner).registerTiers(nodeOperator, [tier]);
const beforeInfo = await ctx.contracts.operatorGrid.vaultTierInfo(stakingVault);
expect(beforeInfo.tierId).to.equal(0n);

const requestedTierId = 1n;
const requestedShareLimit = ether("1000");

// First confirmation from vault owner via Dashboard → returns false (not yet confirmed)
await dashboard.connect(owner).changeTier(requestedTierId, requestedShareLimit);

// Second confirmation from node operator → completes and updates connection
await ctx.contracts.operatorGrid
.connect(nodeOperator)
.changeTier(stakingVault, requestedTierId, requestedShareLimit);

const afterInfo = await ctx.contracts.operatorGrid.vaultTierInfo(stakingVault);
expect(afterInfo.tierId).to.equal(requestedTierId);

vaultHub = ctx.contracts.vaultHub.connect(dashboardSigner);

const connection = await vaultHub.vaultConnection(stakingVault);
expect(connection.shareLimit).to.equal(tier.shareLimit);
expect(connection.reserveRatioBP).to.equal(tier.reserveRatioBP);
expect(connection.forcedRebalanceThresholdBP).to.equal(tier.forcedRebalanceThresholdBP);

return {
stakingVault,
dashboard,
vaultHub,
};
}

beforeEach(async () => (snapshot = await Snapshot.take()));
afterEach(async () => await Snapshot.restore(snapshot));
after(async () => await Snapshot.restore(originalSnapshot));

describe("Shortfall", () => {
it("Works on larger numbers", async () => {
({ stakingVault, dashboard, vaultHub } = await setup({ rr: 2000n, frt: 2000n }));

await vaultHub.fund(stakingVault, { value: ether("1") });
expect(await vaultHub.totalValue(stakingVault)).to.equal(ether("2"));

await dashboard.mintShares(owner, ether("0.689"));

await reportVaultDataWithProof(ctx, stakingVault, {
totalValue: ether("1"),
waitForNextRefSlot: true,
});

expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.false;
const shortfall = await vaultHub.healthShortfallShares(stakingVault);
await dashboard.connect(owner).rebalanceVaultWithShares(shortfall + 1n);
const shortfall2 = await vaultHub.healthShortfallShares(stakingVault);
expect(shortfall2).to.equal(0n);
expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.true;
});

it("Works on max capacity", async () => {
({ stakingVault, dashboard, vaultHub } = await setup({ rr: 1000n, frt: 800n }));
await vaultHub.fund(stakingVault, { value: ether("9") });
expect(await vaultHub.totalValue(stakingVault)).to.equal(ether("10"));

const maxShares = await dashboard.remainingMintingCapacityShares(0);

await dashboard.mintShares(owner, maxShares);

expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.true;

await reportVaultDataWithProof(ctx, stakingVault, {
totalValue: (ether("10") * 95n) / 100n,
waitForNextRefSlot: true,
});

expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.false;
const shortfall = await vaultHub.healthShortfallShares(stakingVault);
await dashboard.connect(owner).rebalanceVaultWithShares(shortfall);
const shortfall2 = await vaultHub.healthShortfallShares(stakingVault);
expect(shortfall2).to.equal(0n);
expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.true;
});

it("Works on small numbers", async () => {
({ stakingVault, dashboard, vaultHub } = await setup({ rr: 2000n, frt: 2000n }));

await vaultHub.fund(stakingVault, { value: ether("1") });
expect(await vaultHub.totalValue(stakingVault)).to.equal(ether("2"));

await dashboard.mintShares(owner, 689n);

await reportVaultDataWithProof(ctx, stakingVault, {
totalValue: 1000n,
waitForNextRefSlot: true,
});

expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.false;
const shortfall = await vaultHub.healthShortfallShares(stakingVault);
await dashboard.connect(owner).rebalanceVaultWithShares(shortfall);
const shortfall2 = await vaultHub.healthShortfallShares(stakingVault);
expect(shortfall2).to.equal(0n);
expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.true;
});

it("Works on really small numbers", async () => {
({ stakingVault, dashboard, vaultHub } = await setup({ rr: 2000n, frt: 2000n }));

await vaultHub.fund(stakingVault, { value: ether("1") });
expect(await vaultHub.totalValue(stakingVault)).to.equal(ether("2"));

await dashboard.mintShares(owner, 1n);

await reportVaultDataWithProof(ctx, stakingVault, {
totalValue: 2n,
waitForNextRefSlot: true,
});

expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.false;
const shortfall = await vaultHub.healthShortfallShares(stakingVault);
expect(shortfall).to.equal(1n);
await dashboard.connect(owner).rebalanceVaultWithShares(shortfall);
const shortfall2 = await vaultHub.healthShortfallShares(stakingVault);
expect(shortfall2).to.equal(0n);
expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.true;
});

it("Works on numbers less than 10", async () => {
({ stakingVault, dashboard, vaultHub } = await setup({ rr: 2000n, frt: 2000n }));

await vaultHub.fund(stakingVault, { value: ether("1") });
expect(await vaultHub.totalValue(stakingVault)).to.equal(ether("2"));

await dashboard.mintShares(owner, 7n);

await reportVaultDataWithProof(ctx, stakingVault, {
totalValue: 10n,
waitForNextRefSlot: true,
});

expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.false;
const shortfall = await vaultHub.healthShortfallShares(stakingVault);
await dashboard.connect(owner).rebalanceVaultWithShares(shortfall);
const shortfall2 = await vaultHub.healthShortfallShares(stakingVault);
expect(shortfall2).to.equal(0n);
expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.true;
});

it("Works on hundreds", async () => {
({ stakingVault, dashboard, vaultHub } = await setup({ rr: 2000n, frt: 2000n }));

await vaultHub.fund(stakingVault, { value: ether("1") });
expect(await vaultHub.totalValue(stakingVault)).to.equal(ether("2"));

await dashboard.mintShares(owner, 70n);

await reportVaultDataWithProof(ctx, stakingVault, {
totalValue: 100n,
waitForNextRefSlot: true,
});

expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.false;
const shortfall = await vaultHub.healthShortfallShares(stakingVault);
await dashboard.connect(owner).rebalanceVaultWithShares(shortfall);
const shortfall2 = await vaultHub.healthShortfallShares(stakingVault);
expect(shortfall2).to.equal(0n);
expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.true;
});
});
});