Skip to content
Draft
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
63 changes: 27 additions & 36 deletions contracts/0.8.25/vaults/VaultHub.sol
Original file line number Diff line number Diff line change
Expand Up @@ -1167,46 +1167,34 @@ contract VaultHub is PausableUntilWithRoles {
VaultConnection storage _connection,
VaultRecord storage _record
) internal view returns (uint256) {
uint256 totalValue_ = _totalValue(_record);
uint256 liabilityShares_ = _record.liabilityShares;

bool isHealthy = !_isThresholdBreached(
totalValue_,
liabilityShares_,
_connection.forcedRebalanceThresholdBP
return _minimumRebalanceShares(
_getSharesByPooledEth(_totalValue(_record)),
_record.liabilityShares,
_connection.reserveRatioBP
);
}

// Health vault do not need to rebalance
if (isHealthy) {
return 0;
}

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

// Impossible to rebalance a vault with bad debt
if (liabilityShares_ >= sharesByTotalValue) {
function _minimumRebalanceShares(
uint256 _totalValueShares,
uint256 _liabilityShares,
uint256 _reserveRatioBP
) internal pure returns (uint256) {
// bad debt: no amount of rebalancing will help
if (_liabilityShares >= _totalValueShares) {
return type(uint256).max;
}

// Solve the equation for X:
// LS - liabilityShares, TV - sharesByTotalValue
// 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)
// RR = 100 - MR
// X = (LS * 100 - TV * MR) / RR
// healthy vault
uint256 mintRatio = TOTAL_BASIS_POINTS - _reserveRatioBP;
if (_liabilityShares * TOTAL_BASIS_POINTS <= _totalValueShares * mintRatio) {
return 0;
}

return (liabilityShares_ * TOTAL_BASIS_POINTS - sharesByTotalValue * maxMintableRatio) / reserveRatioBP;
// unhealthy but rebalanceable vault
return
(_liabilityShares * TOTAL_BASIS_POINTS - _totalValueShares * mintRatio + mintRatio + _reserveRatioBP - 1) /
_reserveRatioBP;

}

function _totalValue(VaultRecord storage _record) internal view returns (uint256) {
Expand Down Expand Up @@ -1266,8 +1254,11 @@ contract VaultHub is PausableUntilWithRoles {
uint256 _vaultLiabilityShares,
uint256 _thresholdBP
) internal view returns (bool) {
uint256 liability = _getPooledEthBySharesRoundUp(_vaultLiabilityShares);
return liability > _vaultTotalValue * (TOTAL_BASIS_POINTS - _thresholdBP) / TOTAL_BASIS_POINTS;
return _minimumRebalanceShares(
_getSharesByPooledEth(_vaultTotalValue),
_vaultLiabilityShares,
_thresholdBP
) > 0;
}

/// @return the total amount of ether needed to fully cover all outstanding obligations of the vault, including:
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
166 changes: 166 additions & 0 deletions test/integration/vaults/vaulthub.shortfall.integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
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 {
calculateLockedValue,

Check warning on line 10 in test/integration/vaults/vaulthub.shortfall.integration.ts

View workflow job for this annotation

GitHub Actions / ESLint

'calculateLockedValue' is defined but never used
createVaultWithDashboard,
getProtocolContext,
ProtocolContext,
setupLidoForVaults,
} from "lib/protocol";
import { reportVaultDataWithProof, setStakingLimit } from "lib/protocol/helpers";

Check warning on line 16 in test/integration/vaults/vaulthub.shortfall.integration.ts

View workflow job for this annotation

GitHub Actions / ESLint

'setStakingLimit' is defined but never used
import { ether } from "lib/units";

import { Snapshot } from "test/suite";

describe.only("Integration: VaultHub ", () => {

Check warning on line 21 in test/integration/vaults/vaulthub.shortfall.integration.ts

View workflow job for this annotation

GitHub Actions / ESLint

describe.only not permitted
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.vaultInfo(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.vaultInfo(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);
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);
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);
console.log(shortfall);
await dashboard.connect(owner).rebalanceVaultWithShares(shortfall);
const shortfall2 = await vaultHub.healthShortfallShares(stakingVault);
console.log(shortfall2);
expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.true;
});
});
});
Loading