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
20 changes: 20 additions & 0 deletions test/integration/vaults/bad-debt.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,26 @@ describe("Integration: Vault with bad debt", () => {
await expect(dashboard.withdraw(owner, ether("0.1"))).to.emit(stakingVault, "EtherWithdrawn");
});

it("Does not internalize bad debt when vault value covers liabilities", async () => {
const { vaultHub, lido } = ctx.contracts;

const liabilityShares = await dashboard.liabilityShares();
const liabilityValue = await lido.getPooledEthBySharesRoundUp(liabilityShares);

const depositAmount = liabilityValue * 5n;
await dashboard.fund({ value: depositAmount });
await reportVaultDataWithProof(ctx, stakingVault, { waitForNextRefSlot: true });

const totalValueShares = await lido.getSharesByPooledEth(await dashboard.totalValue());
expect(totalValueShares).to.be.greaterThan(
liabilityShares,
"Total value converted to shares should exceed liability shares",
);

const tx = await vaultHub.connect(daoAgent).internalizeBadDebt(stakingVault, liabilityShares);
await expect(tx).to.not.emit(vaultHub, "BadDebtWrittenOffToBeInternalized");
});

it("Recovery via CL rewards", async () => {
const { vaultHub, lazyOracle } = ctx.contracts;

Expand Down
182 changes: 182 additions & 0 deletions test/integration/vaults/vaulthub.enumeration.integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { expect } from "chai";
import { ethers } from "hardhat";

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

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

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

import { Snapshot } from "test/suite";

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

let vaultHub: VaultHub;

let owner1: HardhatEthersSigner;
let owner2: HardhatEthersSigner;
let owner3: HardhatEthersSigner;
let nodeOperator: HardhatEthersSigner;

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

await setupLidoForVaults(ctx);

vaultHub = ctx.contracts.vaultHub;

[owner1, owner2, owner3, nodeOperator] = await ethers.getSigners();
});

let baseVaultsCount: bigint;

beforeEach(async () => {
snapshot = await Snapshot.take();
baseVaultsCount = await vaultHub.vaultsCount();
});
afterEach(async () => await Snapshot.restore(snapshot));
after(async () => await Snapshot.restore(originalSnapshot));

describe("VaultHub: vaultsCount", () => {
it("returns existing count when no vaults are connected", async () => {
expect(await vaultHub.vaultsCount()).to.equal(baseVaultsCount);
});

it("returns 1 when one vault is connected", async () => {
const { dashboard } = await createVaultWithDashboard(
ctx,
ctx.contracts.stakingVaultFactory,
owner1,
nodeOperator,
);

await dashboard.connect(owner1).fund({ value: ether("1") });

expect(await vaultHub.vaultsCount()).to.equal(baseVaultsCount + 1n);
});

it("returns correct count when multiple vaults are connected", async () => {
for (let i = 0; i < 3; i++) {
const owner = [owner1, owner2, owner3][i];
const { dashboard } = await createVaultWithDashboard(
ctx,
ctx.contracts.stakingVaultFactory,
owner,
nodeOperator,
);

await dashboard.connect(owner).fund({ value: ether("1") });
}

expect(await vaultHub.vaultsCount()).to.equal(baseVaultsCount + 3n);
});

it("decreases count when vault is disconnected", async () => {
const vault1Info = await createVaultWithDashboard(ctx, ctx.contracts.stakingVaultFactory, owner1, nodeOperator);
await vault1Info.dashboard.connect(owner1).fund({ value: ether("1") });

const vault2Info = await createVaultWithDashboard(ctx, ctx.contracts.stakingVaultFactory, owner2, nodeOperator);
await vault2Info.dashboard.connect(owner2).fund({ value: ether("1") });

expect(await vaultHub.vaultsCount()).to.equal(baseVaultsCount + 2n);

await vault1Info.dashboard.connect(owner1).voluntaryDisconnect();
await reportVaultDataWithProof(ctx, vault1Info.stakingVault, { waitForNextRefSlot: true });

expect(await vaultHub.vaultsCount()).to.equal(baseVaultsCount + 1n);

const vault3Info = await createVaultWithDashboard(ctx, ctx.contracts.stakingVaultFactory, owner3, nodeOperator);
await vault3Info.dashboard.connect(owner3).fund({ value: ether("1") });

expect(await vaultHub.vaultsCount()).to.equal(baseVaultsCount + 2n);
});
});

describe("vaultByIndex", () => {
it("reverts when index is 0 (reserved index)", async () => {
await expect(vaultHub.vaultByIndex(0)).to.be.revertedWithCustomError(vaultHub, "ZeroArgument");
});

it("reverts when index is out of bounds", async () => {
const { dashboard } = await createVaultWithDashboard(
ctx,
ctx.contracts.stakingVaultFactory,
owner1,
nodeOperator,
);

await dashboard.connect(owner1).fund({ value: ether("1") });

await expect(vaultHub.vaultByIndex(baseVaultsCount + 2n)).to.be.reverted;
await expect(vaultHub.vaultByIndex(baseVaultsCount + 1000n)).to.be.reverted;
});

it("returns correct vault address by index", async () => {
const vaults: StakingVault[] = [];

for (let i = 0; i < 3; i++) {
const owner = [owner1, owner2, owner3][i];
const { stakingVault, dashboard } = await createVaultWithDashboard(
ctx,
ctx.contracts.stakingVaultFactory,
owner,
nodeOperator,
nodeOperator,
);

await dashboard.connect(owner).fund({ value: ether("1") });
vaults.push(stakingVault);
}

for (let i = 0; i < vaults.length; i++) {
const index = baseVaultsCount + BigInt(i + 1);
const vaultAddress = await vaultHub.vaultByIndex(index);
expect(vaultAddress).to.equal(await vaults[i].getAddress());
}
});

it("handles vault index changes after disconnection", async () => {
const existingVaults = await vaultHub.vaultsCount();
const vault1Info = await createVaultWithDashboard(
ctx,
ctx.contracts.stakingVaultFactory,
owner1,
nodeOperator,
nodeOperator,
);
await vault1Info.dashboard.connect(owner1).fund({ value: ether("1") });

const vault2Info = await createVaultWithDashboard(ctx, ctx.contracts.stakingVaultFactory, owner2, nodeOperator);
await vault2Info.dashboard.connect(owner2).fund({ value: ether("1") });

const vault3Info = await createVaultWithDashboard(ctx, ctx.contracts.stakingVaultFactory, owner3, nodeOperator);
await vault3Info.dashboard.connect(owner3).fund({ value: ether("1") });

const vault1Address = await vault1Info.stakingVault.getAddress();
const vault2Address = await vault2Info.stakingVault.getAddress();
const vault3Address = await vault3Info.stakingVault.getAddress();

expect(await vaultHub.vaultByIndex(existingVaults + 1n)).to.equal(vault1Address);
expect(await vaultHub.vaultByIndex(existingVaults + 2n)).to.equal(vault2Address);
expect(await vaultHub.vaultByIndex(existingVaults + 3n)).to.equal(vault3Address);

await vault1Info.dashboard.connect(owner1).voluntaryDisconnect();
await reportVaultDataWithProof(ctx, vault1Info.stakingVault, { waitForNextRefSlot: true });

expect(await vaultHub.vaultsCount()).to.equal(existingVaults + 2n);
expect(await vaultHub.vaultByIndex(existingVaults + 1n)).to.equal(vault3Address);
expect(await vaultHub.vaultByIndex(existingVaults + 2n)).to.equal(vault2Address);
});
});
});
151 changes: 151 additions & 0 deletions test/integration/vaults/vaulthub.force-validator-exit.integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { expect } from "chai";
import { ethers } from "hardhat";

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

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

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

import { Snapshot } from "test/suite";

const SAMPLE_PUBKEY = "0x" + "01".repeat(48);

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

let vaultHub: VaultHub;
let stakingVault: StakingVault;
let dashboard: Dashboard;
let lido: Lido;

let owner: HardhatEthersSigner;
let nodeOperator: HardhatEthersSigner;
let validatorExitOperator: HardhatEthersSigner;
let agentSigner: HardhatEthersSigner;

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

[, owner, nodeOperator, validatorExitOperator] = await ethers.getSigners();
await setupLidoForVaults(ctx);

({ stakingVault, dashboard } = await createVaultWithDashboard(
ctx,
ctx.contracts.stakingVaultFactory,
owner,
nodeOperator,
));

vaultHub = ctx.contracts.vaultHub;
lido = ctx.contracts.lido;
dashboard = dashboard.connect(owner);

agentSigner = await ctx.getSigner("agent");
await vaultHub.connect(agentSigner).grantRole(await vaultHub.VALIDATOR_EXIT_ROLE(), validatorExitOperator.address);
await vaultHub.connect(agentSigner).grantRole(await vaultHub.REDEMPTION_MASTER_ROLE(), agentSigner.address);
});

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

describe("forceValidatorExit access and behavior", () => {
it("reverts when there is no obligations shortfall", async () => {
const fee = await stakingVault.calculateValidatorWithdrawalFee(1n);

expect(await vaultHub.obligationsShortfallValue(stakingVault)).to.equal(0n);

await expect(
vaultHub
.connect(validatorExitOperator)
.forceValidatorExit(stakingVault, SAMPLE_PUBKEY, validatorExitOperator.address, { value: fee }),
).to.be.revertedWithCustomError(vaultHub, "ForcedValidatorExitNotAllowed");
});

it("triggers validator withdrawals when obligations shortfall exists", async () => {
const fee = await stakingVault.calculateValidatorWithdrawalFee(1n);

await dashboard.fund({ value: ether("3") });
await reportVaultDataWithProof(ctx, stakingVault, { waitForNextRefSlot: true });

await updateBalance(stakingVault, 0n);
await reportVaultDataWithProof(ctx, stakingVault, {
totalValue: ether("1"),
cumulativeLidoFees: ether("2"),
waitForNextRefSlot: true,
});

expect(await vaultHub.obligationsShortfallValue(stakingVault)).to.equal(ether("2"));

const tx = vaultHub
.connect(validatorExitOperator)
.forceValidatorExit(stakingVault, SAMPLE_PUBKEY, validatorExitOperator.address, { value: fee });

await expect(tx)
.to.emit(vaultHub, "ForcedValidatorExitTriggered")
.withArgs(stakingVault, SAMPLE_PUBKEY, validatorExitOperator.address);

await expect(tx)
.to.emit(stakingVault, "ValidatorWithdrawalsTriggered")
.withArgs(SAMPLE_PUBKEY, [], 0n, validatorExitOperator.address);
});

it("reduces redemptionShares after forced exit and rebalance", async () => {
const fee = await stakingVault.calculateValidatorWithdrawalFee(1n);

await dashboard.fund({ value: ether("4") });
await reportVaultDataWithProof(ctx, stakingVault, { waitForNextRefSlot: true });

const redemptionValue = ether("2");
const redemptionShares = await lido.getSharesByPooledEth(redemptionValue);

await dashboard.mintShares(owner, redemptionShares);
await vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVault, 0n);

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

const recordBefore = await vaultHub.vaultRecord(stakingVault);
expect(recordBefore.redemptionShares).to.equal(redemptionShares);

await vaultHub
.connect(validatorExitOperator)
.forceValidatorExit(stakingVault, SAMPLE_PUBKEY, validatorExitOperator.address, { value: fee });

const recovered = redemptionValue + ether("1");
await dashboard.fund({ value: recovered });
await reportVaultDataWithProof(ctx, stakingVault, {
totalValue: recovered,
cumulativeLidoFees: 0n,
waitForNextRefSlot: true,
});

const expectedRebalanceValue = await lido.getPooledEthBySharesRoundUp(redemptionShares);

await expect(dashboard.rebalanceVaultWithShares(redemptionShares))
.to.emit(vaultHub, "VaultRebalanced")
.withArgs(stakingVault, redemptionShares, expectedRebalanceValue)
.to.emit(vaultHub, "VaultRedemptionSharesUpdated")
.withArgs(stakingVault, 0n);

const recordAfter = await vaultHub.vaultRecord(stakingVault);
expect(recordAfter.redemptionShares).to.equal(0n);
expect(await vaultHub.obligationsShortfallValue(stakingVault)).to.equal(0n);
});
});
});
Loading
Loading