diff --git a/lib/protocol/helpers/vaults.ts b/lib/protocol/helpers/vaults.ts index c5427b933e..89abb6bcbc 100644 --- a/lib/protocol/helpers/vaults.ts +++ b/lib/protocol/helpers/vaults.ts @@ -282,7 +282,7 @@ export async function reportVaultDataWithProof( .updateReportData(reportTimestampArg, reportRefSlotArg, reportTree.root, ""); } - return await lazyOracle.updateVaultData( + return lazyOracle.updateVaultData( await stakingVault.getAddress(), vaultReport.totalValue, vaultReport.cumulativeLidoFees, diff --git a/test/hooks/assertion/equalStETH.ts b/test/hooks/assertion/equalStETH.ts new file mode 100644 index 0000000000..c957f0e71c --- /dev/null +++ b/test/hooks/assertion/equalStETH.ts @@ -0,0 +1,58 @@ +/** + * Custom Chai assertion for stETH value comparisons with fixed rounding margin. + * The file will be auto-included in the test suite by the chai setup, no need to import it. + */ +import { Assertion, util } from "chai"; + +const STETH_ROUNDING_MARGIN = 5n; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + export namespace Chai { + interface Assertion { + /** + * Asserts that the actual value is equal to the expected value within the stETH rounding margin. + * This uses a fixed margin of 5 wei to account for stETH share rounding. + * + * @param {bigint} expected - The expected value in wei. + * + * @example + * expect(mintingCapacity).to.equalStETH(ether("32.8")); + */ + equalStETH(expected: bigint): Assertion; + } + } +} + +Assertion.addMethod("equalStETH", function (expected: bigint) { + const actual = util.flag(this, "object") as bigint; + + // Check if both values are bigints + this.assert( + typeof actual === "bigint", + "expected #{this} to be a bigint", + "expected #{this} not to be a bigint", + expected, + actual, + ); + + this.assert( + typeof expected === "bigint", + "expected value must be a bigint", + "expected value must be a bigint", + expected, + actual, + ); + + // Calculate the absolute difference + const diff = actual > expected ? actual - expected : expected - actual; + + // Assert the difference is within the margin + this.assert( + diff <= STETH_ROUNDING_MARGIN, + `expected #{act} to equal #{exp} ± ${STETH_ROUNDING_MARGIN} wei (stETH rounding margin), but difference was ${diff} wei`, + `expected #{act} not to equal #{exp} ± ${STETH_ROUNDING_MARGIN} wei (stETH rounding margin)`, + expected, + actual, + ); +}); diff --git a/test/hooks/index.ts b/test/hooks/index.ts index c5aefcd6e9..5137c0912d 100644 --- a/test/hooks/index.ts +++ b/test/hooks/index.ts @@ -2,6 +2,7 @@ import * as Mocha from "mocha"; import { mine } from "@nomicfoundation/hardhat-network-helpers"; +import "./assertion/equalStETH"; import "./assertion/revertedWithOZAccessControlError"; // Increase number of stack frames shown in error messages diff --git a/test/integration/vaults/obligations.integration.ts b/test/integration/vaults/obligations.integration.ts index 537adebe81..e44cf42686 100644 --- a/test/integration/vaults/obligations.integration.ts +++ b/test/integration/vaults/obligations.integration.ts @@ -771,8 +771,7 @@ describe("Integration: Vault redemptions and fees obligations", () => { }); }); - // TODO: Need to fix the disconnect flow first - context.skip("Disconnect flow", () => { + context("Disconnect flow", () => { it("Reverts when trying to disconnect with unsettled obligations", async () => { await reportVaultDataWithProof(ctx, stakingVault, { cumulativeLidoFees: ether("1.1") }); @@ -784,8 +783,8 @@ describe("Integration: Vault redemptions and fees obligations", () => { // will revert because of the unsettled obligations event trying to settle using the connection deposit await expect(dashboard.voluntaryDisconnect()) - .to.be.revertedWithCustomError(vaultHub, "UnsettledObligationsExceedsAllowance") - .withArgs(stakingVault, ether("1"), 0); + .to.be.revertedWithCustomError(vaultHub, "NoUnsettledLidoFeesShouldBeLeft") + .withArgs(stakingVault, ether("1.1")); expect(obligations.cumulativeLidoFees).to.equal(ether("1.1")); expect(await ethers.provider.getBalance(stakingVault)).to.equal(ether("1")); @@ -793,11 +792,13 @@ describe("Integration: Vault redemptions and fees obligations", () => { it("Allows to disconnect when all obligations are settled", async () => { await reportVaultDataWithProof(ctx, stakingVault, { cumulativeLidoFees: ether("1.1") }); - await dashboard.fund({ value: ether("0.1") }); + await dashboard.fund({ value: ether("1.1") }); + + await expect(vaultHub.settleLidoFees(stakingVault)) + .to.emit(vaultHub, "LidoFeesSettled") + .withArgs(stakingVault, ether("1.1"), ether("1.1"), ether("1.1")); await expect(dashboard.voluntaryDisconnect()) - .to.emit(vaultHub, "VaultObligationsSettled") - .withArgs(stakingVault, 0n, ether("1.1"), 0n, 0n, ether("1.1")) .to.emit(vaultHub, "VaultDisconnectInitiated") .withArgs(stakingVault); }); @@ -826,66 +827,11 @@ describe("Integration: Vault redemptions and fees obligations", () => { const totalValue = await vaultHub.totalValue(stakingVault); await dashboard.voluntaryDisconnect(); - // take the last fees from the post disconnect report (1.1 ether because fees are cumulative) - await expect(reportVaultDataWithProof(ctx, stakingVault, { totalValue, cumulativeLidoFees: ether("1.1") })) - .to.be.revertedWithCustomError(vaultHub, "UnsettledObligationsExceedsAllowance") - .withArgs(stakingVault, ether("0.1"), 0); - }); - - it("Should take last fees from the post disconnect report with direct transfer", async () => { - // 1 ether of the connection deposit will be settled to the treasury - await reportVaultDataWithProof(ctx, stakingVault, { cumulativeLidoFees: ether("1") }); - - const totalValueOnRefSlot = await vaultHub.totalValue(stakingVault); - - // successfully disconnect - await dashboard.voluntaryDisconnect(); - - // adding 1 ether to cover the exit fees - await owner.sendTransaction({ to: stakingVault, value: ether("1") }); - - // take the last fees from the post disconnect report (1.1 ether because fees are cumulative) - await expect( - await reportVaultDataWithProof(ctx, stakingVault, { - totalValue: totalValueOnRefSlot, - cumulativeLidoFees: ether("1.1"), - }), - ) - .to.emit(vaultHub, "VaultObligationsSettled") - .withArgs(stakingVault, 0n, ether("0.1"), 0n, 0n, ether("1.1")) - .to.emit(vaultHub, "VaultDisconnectCompleted") - .withArgs(stakingVault); - - // 0.9 ether should be left in the vault - expect(await ethers.provider.getBalance(stakingVault)).to.equal(ether("0.9")); - }); - - it("Should take last fees from the post disconnect report with fund", async () => { - // 1 ether of the connection deposit will be settled to the treasury - await reportVaultDataWithProof(ctx, stakingVault, { cumulativeLidoFees: ether("1") }); - - const totalValueOnRefSlot = await vaultHub.totalValue(stakingVault); - - // successfully disconnect - await dashboard.voluntaryDisconnect(); - - // adding 1 ether to cover the exit fees - await dashboard.fund({ value: ether("1") }); - - // take the last fees from the post disconnect report (1.1 ether because fees are cumulative) - await expect( - await reportVaultDataWithProof(ctx, stakingVault, { - totalValue: totalValueOnRefSlot, - cumulativeLidoFees: ether("1.1"), - }), - ) - .to.emit(vaultHub, "VaultObligationsSettled") - .withArgs(stakingVault, 0n, ether("0.1"), 0n, 0n, ether("1.1")) - .to.emit(vaultHub, "VaultDisconnectCompleted") - .withArgs(stakingVault); + // we forgive the last fees + await expect(reportVaultDataWithProof(ctx, stakingVault, { totalValue, cumulativeLidoFees: ether("1.1") })).not.to + .be.reverted; - // 0.9 ether should be left in the vault - expect(await ethers.provider.getBalance(stakingVault)).to.equal(ether("0.9")); + expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.false; }); }); }); diff --git a/test/integration/vaults/roles.integration.ts b/test/integration/vaults/roles.integration.ts index 4279eb3015..9cf6251cf4 100644 --- a/test/integration/vaults/roles.integration.ts +++ b/test/integration/vaults/roles.integration.ts @@ -74,7 +74,7 @@ describe("Integration: Staking Vaults Dashboard Roles Initial Setup", () => { } }); - describe.skip("Verify ACL for methods that require only role", () => { + describe("Verify ACL for methods that require only role", () => { describe("Dashboard methods", () => { it("setNodeOperatorFeeRecipient", async () => { await testGrantingRole( @@ -98,7 +98,7 @@ describe("Integration: Staking Vaults Dashboard Roles Initial Setup", () => { } }); - describe.skip("Verify ACL for methods that require only role", () => { + describe("Verify ACL for methods that require only role", () => { describe("Dashboard methods", () => { it("setNodeOperatorFeeRecipient", async () => { await testGrantingRole( diff --git a/test/integration/vaults/vaulthub.fees.ts b/test/integration/vaults/vaulthub.fees.ts new file mode 100644 index 0000000000..0206d00066 --- /dev/null +++ b/test/integration/vaults/vaulthub.fees.ts @@ -0,0 +1,919 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { Dashboard, StakingVault, VaultHub } from "typechain-types"; + +import { + changeTier, + createVaultWithDashboard, + getProtocolContext, + ProtocolContext, + reportVaultDataWithProof, + setupLidoForVaults, + setUpOperatorGrid, +} from "lib/protocol"; +import { ether } from "lib/units"; + +import { Snapshot } from "test/suite"; + +describe("Integration: VaultHub:fees", () => { + let ctx: ProtocolContext; + let snapshot: string; + let originalSnapshot: string; + + let agentSigner: HardhatEthersSigner; + let owner: HardhatEthersSigner; + let nodeOperator: HardhatEthersSigner; + let vaultMaster: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + let stakingVault: StakingVault; + let dashboard: Dashboard; + let vaultHub: VaultHub; + + before(async () => { + originalSnapshot = await Snapshot.take(); + [, owner, nodeOperator, vaultMaster, stranger] = await ethers.getSigners(); + ctx = await getProtocolContext(); + agentSigner = await ctx.getSigner("agent"); + await setupLidoForVaults(ctx); + await setUpOperatorGrid(ctx, [nodeOperator]); + + ({ stakingVault, dashboard } = await createVaultWithDashboard( + ctx, + ctx.contracts.stakingVaultFactory, + owner, + nodeOperator, + )); + + dashboard = dashboard.connect(owner); + vaultHub = ctx.contracts.vaultHub; + + await changeTier(ctx, dashboard, owner, nodeOperator); + await vaultHub.connect(agentSigner).grantRole(await vaultHub.VAULT_MASTER_ROLE(), vaultMaster); + }); + + beforeEach(async () => (snapshot = await Snapshot.take())); + afterEach(async () => await Snapshot.restore(snapshot)); + after(async () => await Snapshot.restore(originalSnapshot)); + + describe("Unpaid fees accumulation", () => { + it("accumulates unpaid fees over multiple oracle reports", async () => { + // Initial report with totalValue = 10 ETH + await dashboard.fund({ value: ether("10") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("10"), + cumulativeLidoFees: 0n, + }); + + // Verify no unsettled fees initially + let obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(0n); + + // First report: accumulate 0.5 ETH fees + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("10.5"), + cumulativeLidoFees: ether("0.5"), + waitForNextRefSlot: true, + }); + + obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(ether("0.5")); + + // Second report: accumulate another 0.3 ETH (total 0.8 ETH) + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("10.8"), + cumulativeLidoFees: ether("0.8"), + waitForNextRefSlot: true, + }); + + obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(ether("0.8")); + + // Third report: accumulate another 0.3 ETH (total 1.1 ETH, crosses threshold) + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("11.1"), + cumulativeLidoFees: ether("1.1"), + waitForNextRefSlot: true, + }); + + obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(ether("1.1")); + + // Verify deposits are paused after crossing 1 ETH threshold + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + }); + + it("pauses beacon deposits when unsettled fees reach 1 ETH", async () => { + // Setup: Vault with 10 ETH + await dashboard.fund({ value: ether("10") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("10"), + cumulativeLidoFees: 0n, + }); + + // Verify deposits are not paused initially + expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + + // Report with 0.5 ETH fees (below threshold) + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("10.5"), + cumulativeLidoFees: ether("0.5"), + waitForNextRefSlot: true, + }); + + // Deposits should still be active (< 1 ETH threshold) + expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + + // Report with 1e18-1 ETH fees (below threshold) + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("10.5"), + cumulativeLidoFees: ether("1") - 1n, + waitForNextRefSlot: true, + }); + + // Deposits should still be active (< 1 ETH threshold) + expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + + // Report with 1.0 ETH fees (at threshold) + const tx = await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("11"), + cumulativeLidoFees: ether("1.0"), + waitForNextRefSlot: true, + }); + + // Expected: Deposits are paused + await expect(tx).to.emit(stakingVault, "BeaconChainDepositsPaused"); + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + }); + }); + + describe("Oracle fee reporting", () => { + it("reverts when oracle attempts to decrease cumulative fees", async () => { + // Setup: Report with 2 ETH cumulative fees + await dashboard.fund({ value: ether("10") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("12"), + cumulativeLidoFees: ether("2"), + waitForNextRefSlot: true, + }); + + const record = await vaultHub.vaultRecord(stakingVault); + expect(record.cumulativeLidoFees).to.equal(ether("2")); + + // Action: Try to report with lower cumulative fees (1.5 ETH) + // Expected: Should revert because cumulative fees can only increase + await expect( + reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("11.5"), + cumulativeLidoFees: ether("1.5"), + waitForNextRefSlot: true, + }), + ) + .to.be.revertedWithCustomError(ctx.contracts.lazyOracle, "CumulativeLidoFeesTooLow") + .withArgs(ether("1.5"), ether("2")); + }); + }); + + describe("Fee settlement", () => { + it("settles fees when balance becomes available", async () => { + // Setup: Vault with unsettled fees = 3 ETH + await dashboard.fund({ value: ether("10") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("13"), + cumulativeLidoFees: ether("3"), + waitForNextRefSlot: true, + }); + + // Verify unsettled fees + let obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(ether("3")); + + // Verify deposits are paused (fees >= 1 ETH) + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + + // Action: Settle fees (balance is available) + const treasuryBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + + const tx = await vaultHub.settleLidoFees(stakingVault); + + // Expected: Fees settled + await expect(tx).to.emit(vaultHub, "LidoFeesSettled").withArgs(stakingVault, ether("3"), ether("3"), ether("3")); + + // Verify treasury received fees + const treasuryAfter = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + expect(treasuryAfter - treasuryBefore).to.equal(ether("3")); + + // Verify no unsettled fees remain + obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(0n); + + // Verify deposits are resumed + await expect(tx).to.emit(stakingVault, "BeaconChainDepositsResumed"); + expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + }); + + it("can be called by anyone (permissionless)", async () => { + // Setup: Vault with unsettled fees + await dashboard.fund({ value: ether("10") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("12"), + cumulativeLidoFees: ether("2"), + waitForNextRefSlot: true, + }); + + const treasuryBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + + // Action: Stranger (not owner, not operator) settles fees + const tx = await vaultHub.connect(stranger).settleLidoFees(stakingVault); + + // Expected: Success + await expect(tx).to.emit(vaultHub, "LidoFeesSettled").withArgs(stakingVault, ether("2"), ether("2"), ether("2")); + + const treasuryAfter = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + expect(treasuryAfter - treasuryBefore).to.equal(ether("2")); + }); + + it("respects locked balance", async () => { + // Setup: Fund vault with 30 ETH (total 31 with CONNECT_DEPOSIT) + await dashboard.fund({ value: ether("30") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("31"), // includes CONNECT_DEPOSIT + }); + + // Mint 24 ETH worth of stETH + // With 20% reserve ratio: + // reserve = ceilDiv(liability * 2000, 8000) + // locked = liability + reserve + await dashboard.mintStETH(owner, ether("24")); + + // Report with 5 ETH unsettled fees + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("31"), + cumulativeLidoFees: ether("5"), + waitForNextRefSlot: true, + }); + + const obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(ether("5")); + + const locked = await vaultHub.locked(stakingVault); + + // Calculate expected settleable amount: totalValue - locked + const totalValue = ether("31"); + const expectedSettleable = totalValue - locked; + + const settleableValue = await vaultHub.settleableLidoFeesValue(stakingVault); + expect(settleableValue).to.equal(expectedSettleable); + + // Action: settleLidoFees (only settles what's available) + const treasuryBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + + await vaultHub.settleLidoFees(stakingVault); + + const treasuryAfter = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + const actualSettled = treasuryAfter - treasuryBefore; + + // Expected: Only partial fees settled (exactly settleableValue) + expect(actualSettled).to.equal(settleableValue); + + // Verify remaining unsettled fees + const expectedRemaining = ether("5") - actualSettled; + const obligationsAfter = await vaultHub.obligations(stakingVault); + expect(obligationsAfter.feesToSettle).to.equal(expectedRemaining); + + // Deposits remain paused (remaining fees >= 1 ETH) + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + }); + + it("respects both locked balance and redemptions", async () => { + // Setup: Fund vault with 40 ETH (total 41 with CONNECT_DEPOSIT) + await dashboard.fund({ value: ether("40") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("41"), // includes CONNECT_DEPOSIT + }); + + // Mint 24 ETH worth of stETH to create locked balance + // With 20% reserve ratio: + // liability = 24 ETH worth of shares + // reserve ≈ ceilDiv(liability * 2000, 8000) ≈ 6 ETH + // locked ≈ liability + reserve + CONNECT_DEPOSIT ≈ 31 ETH + await dashboard.mintStETH(owner, ether("24")); + + const locked = await vaultHub.locked(stakingVault); + + // Set redemption shares representing 3 ETH (reserves ETH from unlocked balance) + const liabilityShares = await vaultHub.liabilityShares(stakingVault); + const redemptionSharesAmount = await ctx.contracts.lido.getSharesByPooledEth(ether("3")); + const targetLiabilityShares = liabilityShares - redemptionSharesAmount; + const redemptionMasterRole = await vaultHub.REDEMPTION_MASTER_ROLE(); + await vaultHub.connect(agentSigner).grantRole(redemptionMasterRole, agentSigner); + await vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVault, targetLiabilityShares); + + // Verify redemption shares are set + const record = await vaultHub.vaultRecord(stakingVault); + expect(record.redemptionShares).to.equal(redemptionSharesAmount); + + const redemptionValue = await ctx.contracts.lido.getPooledEthByShares(redemptionSharesAmount); + + // Report unsettled fees = 12 ETH (more than available after locked + redemptions) + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("41"), + cumulativeLidoFees: ether("12"), + waitForNextRefSlot: true, + }); + + const obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(ether("12")); + + // Get the actual settleable amount from the contract + const settleableValue = await vaultHub.settleableLidoFeesValue(stakingVault); + + // Settleable should be less than total fees due to locked balance and redemption constraints + expect(settleableValue).to.be.lessThan(ether("12")); + + // Action: Settle fees (only settles what's available after locked and redemptions) + const treasuryBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + + await vaultHub.settleLidoFees(stakingVault); + + const treasuryAfter = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + const actualSettled = treasuryAfter - treasuryBefore; + + // Expected: Only partial fees settled (exactly settleableValue) + expect(actualSettled).to.equal(settleableValue); + expect(actualSettled).to.be.lessThan(ether("12")); // Less than total fees + + // Verify remaining unsettled fees + const expectedRemaining = ether("12") - actualSettled; + const obligationsAfter = await vaultHub.obligations(stakingVault); + expect(obligationsAfter.feesToSettle).to.equal(expectedRemaining); + + // Verify redemption shares are still reserved after settlement + // This proves redemptions are constraining available balance along with locked funds + const recordAfter = await vaultHub.vaultRecord(stakingVault); + expect(recordAfter.redemptionShares).to.equal(redemptionSharesAmount); + + // Verify partial settlement occurred - not all fees could be settled + expect(expectedRemaining).to.be.greaterThan(0); + expect(actualSettled).to.be.greaterThan(0); // Some fees were settled + + // The combination of locked balance and redemptions constrained the settlement + expect(locked).to.be.greaterThan(0); // There is locked balance + expect(redemptionValue).to.be.greaterThan(0); // There are redemptions + + // Deposits remain paused as long as remaining fees exist + if (expectedRemaining >= ether("1")) { + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + } + }); + + it("respects redemptions", async () => { + // Setup: Fund vault with 15 ETH (total 16 with CONNECT_DEPOSIT) + await dashboard.fund({ value: ether("15") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("16"), // includes CONNECT_DEPOSIT + }); + + // Mint 10 ETH worth of stETH + // With 20% reserve ratio: + // reserve = ceilDiv(liability * 2000, 8000) + // locked = liability + max(reserve, minimalReserve) + await dashboard.mintStETH(owner, ether("10")); + + // Set redemption shares representing 2 ETH (reserves ETH from unlocked balance) + const liabilityShares = await vaultHub.liabilityShares(stakingVault); + const redemptionSharesAmount = await ctx.contracts.lido.getSharesByPooledEth(ether("2")); + const targetLiabilityShares = liabilityShares - redemptionSharesAmount; + const redemptionMasterRole = await vaultHub.REDEMPTION_MASTER_ROLE(); + await vaultHub.connect(agentSigner).grantRole(redemptionMasterRole, agentSigner); + await vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVault, targetLiabilityShares); + + // Verify redemption shares are set + const record = await vaultHub.vaultRecord(stakingVault); + expect(record.redemptionShares).to.equal(redemptionSharesAmount); + + // Report unsettled fees = 5 ETH (more than available after redemptions) + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("16"), + cumulativeLidoFees: ether("5"), + waitForNextRefSlot: true, + }); + + const obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(ether("5")); + + // Get the actual settleable amount from the contract + const settleableValue = await vaultHub.settleableLidoFeesValue(stakingVault); + + // Action: Settle fees (only settles what's available after redemptions) + const treasuryBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + + await vaultHub.settleLidoFees(stakingVault); + + const treasuryAfter = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + const actualSettled = treasuryAfter - treasuryBefore; + + // Expected: Settled exactly settleableValue + expect(actualSettled).to.equal(settleableValue); + + // Verify remaining unsettled fees + const expectedRemaining = ether("5") - actualSettled; + const obligationsAfter = await vaultHub.obligations(stakingVault); + expect(obligationsAfter.feesToSettle).to.equal(expectedRemaining); + + // Verify redemption shares are still reserved + const recordAfter = await vaultHub.vaultRecord(stakingVault); + expect(recordAfter.redemptionShares).to.equal(redemptionSharesAmount); + }); + + it("reverts when there are no unsettled fees", async () => { + // Setup: Vault with no fees + await dashboard.fund({ value: ether("10") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("11"), + cumulativeLidoFees: 0n, + }); + + // Action: Try to settle fees when there are none + await expect(vaultHub.settleLidoFees(stakingVault)).to.be.revertedWithCustomError( + vaultHub, + "NoUnsettledLidoFeesToSettle", + ); + }); + + it("reverts when there are no funds to settle", async () => { + // Setup: Report fees but with no available balance + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("1"), // Only CONNECT_DEPOSIT (locked) + cumulativeLidoFees: ether("2"), + waitForNextRefSlot: true, + }); + + // Verify fees exist + const obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(ether("2")); + + // Action: Try to settle fees without available funds + await expect(vaultHub.settleLidoFees(stakingVault)).to.be.revertedWithCustomError( + vaultHub, + "NoFundsToSettleLidoFees", + ); + }); + }); + + describe("Disconnect with unpaid fees", () => { + it("blocks voluntary disconnect when fees cannot be fully settled", async () => { + // Setup: Vault with unsettled fees > available balance + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("1"), // Only CONNECT_DEPOSIT + cumulativeLidoFees: ether("1.5"), + waitForNextRefSlot: true, + }); + + const obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(ether("1.5")); + + const settleableValue = await vaultHub.settleableLidoFeesValue(stakingVault); + expect(settleableValue).to.equal(0n); // No funds available to settle + + // Action: Attempt voluntary disconnect + await expect(dashboard.voluntaryDisconnect()).to.be.revertedWithCustomError( + vaultHub, + "NoUnsettledLidoFeesShouldBeLeft", + ); + }); + + it("requires settling all Lido fees before voluntary disconnect", async () => { + // Setup: Vault with unsettled Lido fees + await dashboard.fund({ value: ether("5") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("6"), + cumulativeLidoFees: ether("0.5"), + waitForNextRefSlot: true, + }); + + let obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(ether("0.5")); + + // Settle the Lido fees + const treasuryBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + await vaultHub.settleLidoFees(stakingVault); + + const treasuryAfter = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + expect(treasuryAfter - treasuryBefore).to.equal(ether("0.5")); + + // Verify Lido fees are now settled + obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(0n); + + // Verify the record shows fees are fully settled + const record = await vaultHub.vaultRecord(stakingVault); + expect(record.cumulativeLidoFees).to.equal(record.settledLidoFees); + expect(record.cumulativeLidoFees).to.equal(ether("0.5")); + expect(record.settledLidoFees).to.equal(ether("0.5")); + }); + + it("allows force disconnect even with large unpaid fees", async () => { + // Setup: Report large fees that exceed balance + const vaultBalance = await ethers.provider.getBalance(stakingVault); + const largeFees = vaultBalance * 2n; // Fees > balance + + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("1"), + cumulativeLidoFees: largeFees, + waitForNextRefSlot: true, + }); + + const obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(largeFees); + + const treasuryBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + + // Action: Force disconnect by VAULT_MASTER (will settle what it can) + const tx = await vaultHub.connect(vaultMaster).disconnect(stakingVault); + await expect(tx).to.emit(vaultHub, "VaultDisconnectInitiated").withArgs(stakingVault); + + // Expected: Some amount was settled during disconnect + const treasuryAfter = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + const settled = treasuryAfter - treasuryBefore; + + // Verify the settled amount matches what was actually settled + const obligationsAfter = await vaultHub.obligations(stakingVault); + expect(obligationsAfter.feesToSettle).to.equal(largeFees - settled); + + // Disconnect still initiated despite unsettled fees + expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.true; + }); + }); + + describe("Bad debt scenarios", () => { + it("revert settling if bad debt", async () => { + // Setup: Create bad debt situation + await dashboard.fund({ value: ether("10") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("11"), + }); + + // Mint stETH + await dashboard.mintStETH(owner, ether("8")); + + // Report slashing that creates bad debt + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("2"), // Slashed from 11 to 2 + cumulativeLidoFees: ether("5"), // Large unpaid fees + slashingReserve: ether("1"), + waitForNextRefSlot: true, + }); + + // Check obligations + const obligations = await vaultHub.obligations(stakingVault); + const obligationsShortfall = await vaultHub.obligationsShortfallValue(stakingVault); + + // Expected: Bad debt dominates + expect(obligations.sharesToBurn).to.equal(ethers.MaxUint256); + expect(obligationsShortfall).to.equal(ethers.MaxUint256); + + // Fees cannot be settled until bad debt resolved + await expect(vaultHub.settleLidoFees(stakingVault)).to.be.revertedWithCustomError( + vaultHub, + "NoFundsToSettleLidoFees", + ); + + // Deposits paused due to multiple reasons + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + }); + }); + + describe("Minting capacity impact", () => { + it("reduces minting capacity by unsettled fees amount", async () => { + // Setup: Vault with 50 ETH total value + await dashboard.fund({ value: ether("50") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("51"), // includes CONNECT_DEPOSIT + }); + + // Check minting capacity without fees + const mintingCapacitySharesBefore = await vaultHub.totalMintingCapacityShares(stakingVault, 0); + const mintingCapacityBefore = await ctx.contracts.lido.getPooledEthByShares(mintingCapacitySharesBefore); + // With 20% reserve ratio: capacity = totalValue * 0.8 = 51 * 0.8 ≈ 40.8 ETH + expect(mintingCapacityBefore).to.equalStETH(ether("40.8")); + + // Report unsettled fees = 10 ETH + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("51"), + cumulativeLidoFees: ether("10"), + waitForNextRefSlot: true, + }); + + // Check minting capacity with fees + const mintingCapacitySharesAfter = await vaultHub.totalMintingCapacityShares(stakingVault, 0); + const mintingCapacityAfter = await ctx.contracts.lido.getPooledEthByShares(mintingCapacitySharesAfter); + + // Expected: minting capacity reduced by unsettled fees + // (51 - 10) * 0.8 = 41 * 0.8 = 32.8 ETH + expect(mintingCapacityAfter).to.equalStETH(ether("32.8")); + + // Verify obligations are tracked + const obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(ether("10")); + }); + + it("restores minting capacity proportion after fees are settled", async () => { + // Setup: Vault with fees (using smaller amounts to avoid sanity check limits) + await dashboard.fund({ value: ether("20") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("21"), + cumulativeLidoFees: ether("3"), + waitForNextRefSlot: true, + }); + + // Verify fees are unsettled before settlement + const obligationsBefore = await vaultHub.obligations(stakingVault); + expect(obligationsBefore.feesToSettle).to.equal(ether("3")); + + // Settle fees + await vaultHub.settleLidoFees(stakingVault); + + // Check capacity after settlement (cumulative fees stay the same, totalValue decreases) + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("18"), // reduced by settled fees + cumulativeLidoFees: ether("3"), // cumulative stays same (we only update settledLidoFees) + waitForNextRefSlot: true, + }); + + const mintingCapacitySharesAfter = await vaultHub.totalMintingCapacityShares(stakingVault, 0); + const mintingCapacityAfter = await ctx.contracts.lido.getPooledEthByShares(mintingCapacitySharesAfter); + const obligationsAfter = await vaultHub.obligations(stakingVault); + + // Expected: minting capacity = totalValue * 0.8 = 18 * 0.8 = 14.4 ETH (no more unsettled fees) + expect(mintingCapacityAfter).to.equalStETH(ether("14.4")); + + // Verify all fees have been settled + expect(obligationsAfter.feesToSettle).to.equal(0n); + }); + }); + + describe("Operations unlock after settlement", () => { + it("resumes beacon deposits after settling fees", async () => { + // Setup: Vault with unsettled fees >= 1 ETH + await dashboard.fund({ value: ether("10") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("11"), + cumulativeLidoFees: ether("2"), + waitForNextRefSlot: true, + }); + + // Verify deposits are paused + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + + // Action: Settle fees + const tx = await vaultHub.settleLidoFees(stakingVault); + + // Expected: Deposits resumed + await expect(tx).to.emit(stakingVault, "BeaconChainDepositsResumed"); + expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + }); + + it("allows deposits again after fees settled below 1 ETH", async () => { + // Setup: Vault with 10 ETH + await dashboard.fund({ value: ether("10") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("11"), + }); + + // Mint 7 ETH to lock the vault + // With 20% reserve: locked = minimalReserve(1) + liability(7) + reserve(1.4) = ~9.4 ETH + // Unlocked = 11 - 9.4 = ~1.6 ETH available + await dashboard.mintStETH(owner, ether("7")); + + // Report 1.5 ETH fees (slightly less than available) + // After settlement, remaining fees will be ~0 ETH (well below 1 ETH threshold) + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("11"), + cumulativeLidoFees: ether("1.5"), + waitForNextRefSlot: true, + }); + + // Verify deposits are paused initially (fees >= 1 ETH) + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + + // Get the actual settleable amount + const settleableValue = await vaultHub.settleableLidoFeesValue(stakingVault); + + // Settle fees + await vaultHub.settleLidoFees(stakingVault); + + const remainingFees = ether("1.5") - settleableValue; + + // Verify remaining fees and deposit status + const obligationsAfter = await vaultHub.obligations(stakingVault); + expect(obligationsAfter.feesToSettle).to.equal(remainingFees); + + // Deposits should resume only if remaining fees < 1 ETH + const depositsResumed = remainingFees < ether("1"); + expect(await stakingVault.beaconChainDepositsPaused()).to.equal(!depositsResumed); + }); + + it("unblocks minting after full fee settlement", async () => { + // Setup: Vault with unsettled fees that restrict minting + await dashboard.fund({ value: ether("20") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("21"), + cumulativeLidoFees: ether("5"), + waitForNextRefSlot: true, + }); + + // Verify fees before settlement + const obligationsBefore = await vaultHub.obligations(stakingVault); + expect(obligationsBefore.feesToSettle).to.equal(ether("5")); + + // Settle fees + await vaultHub.settleLidoFees(stakingVault); + + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("16"), // 21 - 5 settled + cumulativeLidoFees: ether("5"), + waitForNextRefSlot: true, + }); + + // Verify state after settlement + const obligationsAfter = await vaultHub.obligations(stakingVault); + expect(obligationsAfter.feesToSettle).to.equal(0n); // All fees settled + + const mintingCapacitySharesAfter = await vaultHub.totalMintingCapacityShares(stakingVault, 0); + const mintingCapacityAfter = await ctx.contracts.lido.getPooledEthByShares(mintingCapacitySharesAfter); + // Minting capacity = totalValue * 0.8 = 16 * 0.8 = 12.8 ETH (no unsettled fees) + expect(mintingCapacityAfter).to.equalStETH(ether("12.8")); + }); + }); + + describe("Force rebalance interaction", () => { + it("does not settle fees during force rebalance", async () => { + // Setup: Vault with both bad health and unsettled fees + await dashboard.fund({ value: ether("20") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("21"), + }); + + // Mint 10 ETH stETH to create liability + await dashboard.mintStETH(owner, ether("10")); + + // Report slashing and fees + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("10"), // Heavily slashed from 21 to 10 + cumulativeLidoFees: ether("1.5"), + waitForNextRefSlot: true, + }); + + const treasuryBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + const obligations = await vaultHub.obligations(stakingVault); + + // Verify bad health (need to rebalance shares) and unsettled fees + const healthShortfallShares = await vaultHub.healthShortfallShares(stakingVault); + expect(obligations.sharesToBurn).to.equal(healthShortfallShares); + expect(obligations.feesToSettle).to.equal(ether("1.5")); + + // Action: forceRebalance (rebalances shares, not fees) + const tx = await vaultHub.forceRebalance(stakingVault); + await expect(tx).to.emit(vaultHub, "VaultRebalanced"); + + const treasuryAfter = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + + // Expected: Treasury unchanged (fees not settled by forceRebalance) + expect(treasuryAfter).to.equal(treasuryBefore); + + // Fees remain unsettled after rebalance + const obligationsAfter = await vaultHub.obligations(stakingVault); + expect(obligationsAfter.feesToSettle).to.equal(ether("1.5")); + + // Deposits still paused (fees >= 1 ETH) + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + + // Verify rebalance reduced the shares to burn + const healthShortfallAfter = await vaultHub.healthShortfallShares(stakingVault); + expect(obligationsAfter.sharesToBurn).to.equal(healthShortfallAfter); + expect(healthShortfallAfter).to.equal(0n); // Health restored after rebalance + }); + }); + + describe("Edge cases", () => { + it("handles fees exactly at 1 ETH threshold", async () => { + // Setup: Report exactly 1 ETH fees + await dashboard.fund({ value: ether("10") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("11"), + cumulativeLidoFees: ether("1.0"), + waitForNextRefSlot: true, + }); + + const obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(ether("1.0")); + + // Expected: Deposits paused at exactly 1 ETH + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + }); + + it("handles fees just below 1 ETH threshold", async () => { + // Setup: Report 1 ETH - 1 wei fees + await dashboard.fund({ value: ether("10") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("10"), + cumulativeLidoFees: ether("1") - 1n, + waitForNextRefSlot: true, + }); + + const obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(ether("1") - 1n); + + // Expected: Deposits NOT paused (below threshold) + expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + }); + + it("does not settle fees when balance is exactly zero", async () => { + // Setup: Report fees with no balance + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("1"), // Only locked CONNECT_DEPOSIT + cumulativeLidoFees: ether("2"), + waitForNextRefSlot: true, + }); + + const settleableValue = await vaultHub.settleableLidoFeesValue(stakingVault); + expect(settleableValue).to.equal(0n); + + // Cannot settle when no funds available + await expect(vaultHub.settleLidoFees(stakingVault)).to.be.revertedWithCustomError( + vaultHub, + "NoFundsToSettleLidoFees", + ); + }); + + it("handles multiple partial settlements until fully settled", async () => { + // Setup: Fund vault with 15 ETH (total 16 with CONNECT_DEPOSIT) + await dashboard.fund({ value: ether("15") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("16"), + cumulativeLidoFees: ether("3"), + waitForNextRefSlot: true, + }); + + let obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(ether("3")); + + // Mint 10 ETH to lock most of the vault + // With 20% reserve: locked = minimalReserve(1) + liability(10) + reserve(2) = ~13 ETH + // Unlocked = 16 - 13 = ~3 ETH, but with 3 ETH fees, very little available for settlement + await dashboard.mintStETH(owner, ether("10")); + + // First partial settlement - with current setup all 3 ETH is settleable + // (balance is sufficient even with liabilities) + let settleableValue = await vaultHub.settleableLidoFeesValue(stakingVault); + expect(settleableValue).to.equal(ether("3")); + + const treasuryBefore1 = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + await vaultHub.settleLidoFees(stakingVault); + const treasuryAfter1 = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + + const firstSettlement = treasuryAfter1 - treasuryBefore1; + expect(firstSettlement).to.equal(ether("3")); + + obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(0n); + + // Report more fees accumulating after first settlement + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("13"), // 16 - 3 settled + cumulativeLidoFees: ether("5"), // 2 ETH new fees + waitForNextRefSlot: true, + }); + + obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(ether("2")); + + // Calculate exact settleable amount based on locked balance + const locked = await vaultHub.locked(stakingVault); + const totalValue = ether("13"); + const expectedSettleable = totalValue - locked; + + settleableValue = await vaultHub.settleableLidoFeesValue(stakingVault); + expect(settleableValue).to.equal(expectedSettleable); + + const treasuryBefore2 = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + await vaultHub.settleLidoFees(stakingVault); + const treasuryAfter2 = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + + const secondSettlement = treasuryAfter2 - treasuryBefore2; + expect(secondSettlement).to.equal(settleableValue); + + // Verify remaining unsettled fees + const expectedRemaining = ether("2") - secondSettlement; + obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(expectedRemaining); + + // Verify multiple settlements occurred + const totalSettled = firstSettlement + secondSettlement; + expect(totalSettled).to.equal(ether("3") + secondSettlement); + }); + }); +}); diff --git a/test/integration/vaults/vaulthub.force-disconnect.ts b/test/integration/vaults/vaulthub.force-disconnect.ts new file mode 100644 index 0000000000..7c778b7b95 --- /dev/null +++ b/test/integration/vaults/vaulthub.force-disconnect.ts @@ -0,0 +1,346 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { Dashboard, StakingVault, VaultHub } from "typechain-types"; + +import { BigIntMath, DISCONNECT_NOT_INITIATED, impersonate } from "lib"; +import { + changeTier, + createVaultsReportTree, + createVaultWithDashboard, + getProtocolContext, + ProtocolContext, + reportVaultDataWithProof, + setupLidoForVaults, + setUpOperatorGrid, + waitNextAvailableReportTime, +} from "lib/protocol"; +import { getCurrentBlockTimestamp } from "lib/time"; +import { ether } from "lib/units"; + +import { Snapshot } from "test/suite"; + +describe("Integration: VaultHub:force-disconnect", () => { + let ctx: ProtocolContext; + let snapshot: string; + let originalSnapshot: string; + + let agentSigner: HardhatEthersSigner; + let owner: HardhatEthersSigner; + let nodeOperator: HardhatEthersSigner; + let vaultMaster: HardhatEthersSigner; + let stakingVault: StakingVault; + let dashboard: Dashboard; + let vaultHub: VaultHub; + + before(async () => { + originalSnapshot = await Snapshot.take(); + [, owner, nodeOperator, vaultMaster] = await ethers.getSigners(); + ctx = await getProtocolContext(); + agentSigner = await ctx.getSigner("agent"); + await setupLidoForVaults(ctx); + await setUpOperatorGrid(ctx, [nodeOperator]); + + ({ stakingVault, dashboard } = await createVaultWithDashboard( + ctx, + ctx.contracts.stakingVaultFactory, + owner, + nodeOperator, + )); + + dashboard = dashboard.connect(owner); + vaultHub = ctx.contracts.vaultHub; + + await changeTier(ctx, dashboard, owner, nodeOperator); + await vaultHub.connect(agentSigner).grantRole(await vaultHub.VAULT_MASTER_ROLE(), vaultMaster); + }); + + beforeEach(async () => (snapshot = await Snapshot.take())); + afterEach(async () => await Snapshot.restore(snapshot)); + after(async () => await Snapshot.restore(originalSnapshot)); + + for (const feePercent of [50n, 100n, 150n]) { + it(`disconnects when fee is ${feePercent}% of the vault balance`, async () => { + const vaultBalance = await ethers.provider.getBalance(stakingVault); + const feesToSettle = (vaultBalance * feePercent) / 100n; + + // Setup: Connected vault with liabilityShares = 0, fresh report + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("1"), + cumulativeLidoFees: feesToSettle, // Assign unsettled fees to the vault + waitForNextRefSlot: true, + }); + + // Verify initial state + expect(await vaultHub.liabilityShares(stakingVault)).to.equal(0n); + expect(await vaultHub.isReportFresh(stakingVault)).to.be.true; + const unsettledFees = await vaultHub.obligations(stakingVault); + expect(unsettledFees.feesToSettle).to.be.equal(feesToSettle); + + const treasuryBalanceBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + + // VAULT_MASTER_ROLE calls disconnect(vault) + const tx = await vaultHub.connect(vaultMaster).disconnect(stakingVault); + await expect(tx).to.emit(vaultHub, "VaultDisconnectInitiated").withArgs(stakingVault); + + // Verify disconnectInitiatedTs = block.timestamp + const receipt = await tx.wait(); + const blockTimestamp = (await ethers.provider.getBlock(receipt!.blockNumber))?.timestamp || 0n; + const connection = await vaultHub.vaultConnection(stakingVault); + expect(connection.disconnectInitiatedTs).to.equal(BigInt(blockTimestamp)); + + // Verify fees are settled to the treasury + const settleAmount = BigIntMath.min(vaultBalance, feesToSettle); + const treasuryAfter = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + expect(treasuryAfter).to.be.equal(treasuryBalanceBefore + settleAmount); + expect(await ethers.provider.getBalance(await stakingVault.getAddress())).to.be.equal( + vaultBalance - settleAmount, + ); + + // Verify vault is pending disconnect + expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.true; + expect(await vaultHub.isVaultConnected(stakingVault)).to.be.true; + + await expect( + reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("1"), cumulativeLidoFees: feesToSettle }), + ) + .to.emit(vaultHub, "VaultDisconnectCompleted") + .withArgs(stakingVault); + + // Expected: vault is disconnected + expect((await ctx.contracts.operatorGrid.vaultTierInfo(stakingVault)).tierId).to.be.equal(0n); + expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.false; + expect(await vaultHub.isVaultConnected(stakingVault)).to.be.false; + expect(await vaultHub.locked(stakingVault)).to.be.equal(0n); + }); + } + + it("aborts disconnect when slashing reserve is reported", async () => { + // Refresh report + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("1"), + liabilityShares: 0n, + }); + + // Initiate disconnect + await vaultHub.connect(vaultMaster).disconnect(stakingVault); + expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.true; + const connectionBefore = await vaultHub.vaultConnection(stakingVault); + const disconnectTs = connectionBefore.disconnectInitiatedTs; + expect(disconnectTs).to.be.greaterThan(0n); + + // Action: Oracle reports with slashing reserve + const slashingReserve = ether("0.5"); + const tx = await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("1"), + liabilityShares: 0n, + slashingReserve, + }); + + // Expected: Disconnect aborted + await expect(tx).to.emit(vaultHub, "VaultDisconnectAborted").withArgs(stakingVault, slashingReserve); + + // Verify disconnect was cancelled + const connectionAfter = await vaultHub.vaultConnection(stakingVault); + expect(connectionAfter.disconnectInitiatedTs).to.equal(DISCONNECT_NOT_INITIATED); + expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.false; + + // Verify vault remains connected + expect(await vaultHub.isVaultConnected(stakingVault)).to.be.true; + expect((await ctx.contracts.operatorGrid.vaultTierInfo(stakingVault)).tierId).to.be.greaterThan(0n); + }); + + it("aborts disconnect when bad debt is socialized to the vault", async () => { + const acceptingVault = stakingVault; + + const vault = await createVaultWithDashboard(ctx, ctx.contracts.stakingVaultFactory, owner, nodeOperator); + const badDebtVault = vault.stakingVault; + const badDebtDashboard = vault.dashboard.connect(owner); + await changeTier(ctx, badDebtDashboard, owner, nodeOperator); + + // Grant BAD_DEBT_MASTER_ROLE + await vaultHub.connect(agentSigner).grantRole(await vaultHub.BAD_DEBT_MASTER_ROLE(), agentSigner); + + // Create bad debt + await badDebtDashboard.fund({ value: ether("10") }); + await reportVaultDataWithProof(ctx, badDebtVault, { + totalValue: ether("11"), + liabilityShares: 0n, + }); + await badDebtDashboard.mintStETH(owner, ether("5")); + await reportVaultDataWithProof(ctx, badDebtVault, { + totalValue: ether("1"), // Slashed from 11 to 1 + slashingReserve: ether("10"), + waitForNextRefSlot: true, + }); + + // Verify bad debt + const badDebtVaultLiabilityShares = await vaultHub.liabilityShares(badDebtVault); + const badDebtVaultTotalValue = await vaultHub.totalValue(badDebtVault); + const badDebtVaultLiabilityValue = + await ctx.contracts.lido.getPooledEthBySharesRoundUp(badDebtVaultLiabilityShares); + expect(badDebtVaultLiabilityValue).to.be.greaterThan(badDebtVaultTotalValue); + + // Fund accepting vault to have capacity to accept bad debt + await dashboard.fund({ value: ether("10") }); + + // Report both vaults together in the same Merkle tree + const { lazyOracle, hashConsensus, locator } = ctx.contracts; + + await waitNextAvailableReportTime(ctx); + + const [acceptingVaultRecord, badDebtVaultRecord] = await Promise.all([ + vaultHub.vaultRecord(acceptingVault), + vaultHub.vaultRecord(badDebtVault), + ]); + + const acceptingVaultReport = { + vault: await acceptingVault.getAddress(), + totalValue: ether("11"), + cumulativeLidoFees: 0n, + liabilityShares: 0n, + maxLiabilityShares: acceptingVaultRecord.maxLiabilityShares, + slashingReserve: 0n, + }; + + const badDebtVaultReport = { + vault: await badDebtVault.getAddress(), + totalValue: ether("1"), + cumulativeLidoFees: badDebtVaultRecord.cumulativeLidoFees, + liabilityShares: badDebtVaultRecord.liabilityShares, + maxLiabilityShares: badDebtVaultRecord.maxLiabilityShares, + slashingReserve: ether("10"), + }; + + const reportTree = createVaultsReportTree([acceptingVaultReport, badDebtVaultReport]); + const reportTimestamp = await getCurrentBlockTimestamp(); + const reportRefSlot = (await hashConsensus.getCurrentFrame()).refSlot; + + const accountingSigner = await impersonate(await locator.accountingOracle(), ether("100")); + await lazyOracle.connect(accountingSigner).updateReportData(reportTimestamp, reportRefSlot, reportTree.root, ""); + + // Report Accepting Vault + await lazyOracle.updateVaultData( + acceptingVaultReport.vault, + acceptingVaultReport.totalValue, + acceptingVaultReport.cumulativeLidoFees, + acceptingVaultReport.liabilityShares, + acceptingVaultReport.maxLiabilityShares, + acceptingVaultReport.slashingReserve, + reportTree.getProof(0), + ); + + // Report Bad Debt Vault + await lazyOracle.updateVaultData( + badDebtVaultReport.vault, + badDebtVaultReport.totalValue, + badDebtVaultReport.cumulativeLidoFees, + badDebtVaultReport.liabilityShares, + badDebtVaultReport.maxLiabilityShares, + badDebtVaultReport.slashingReserve, + reportTree.getProof(1), + ); + + // Initiate disconnect on accepting vault + await vaultHub.connect(vaultMaster).disconnect(acceptingVault); + + // Calculate and socialize bad debt + const badDebtShares = + badDebtVaultLiabilityShares - (await ctx.contracts.lido.getSharesByPooledEth(badDebtVaultTotalValue)); + await vaultHub.connect(agentSigner).socializeBadDebt(badDebtVault, acceptingVault, badDebtShares); + + // Verify accepting vault now has liability + const tx = await reportVaultDataWithProof(ctx, acceptingVault, { + totalValue: ether("11"), + waitForNextRefSlot: true, + liabilityShares: badDebtShares, + }); + expect(await vaultHub.liabilityShares(acceptingVault)).to.be.equal(badDebtShares); + + // Verify disconnect aborted + await expect(tx).to.emit(vaultHub, "VaultDisconnectAborted"); + expect(await vaultHub.isPendingDisconnect(acceptingVault)).to.be.false; + const connection = await vaultHub.vaultConnection(acceptingVault); + expect(connection.disconnectInitiatedTs).to.equal(DISCONNECT_NOT_INITIATED); + expect(await vaultHub.isVaultConnected(acceptingVault)).to.be.true; + }); + + it("prevents minting after disconnect is initiated", async () => { + // Setup: Fund vault and initiate disconnect + await dashboard.fund({ value: ether("10") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("11"), + }); + await vaultHub.connect(vaultMaster).disconnect(stakingVault); + expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.true; + await expect(dashboard.mintStETH(owner, ether("1"))).to.be.revertedWithCustomError( + vaultHub, + "VaultIsDisconnecting", + ); + }); + + it("settles all fees when doing voluntary disconnect", async () => { + // Setup: Create unsettled fees + const unsettledFees = ether("0.5"); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("1"), + cumulativeLidoFees: unsettledFees, + waitForNextRefSlot: true, + }); + + // Ensure vault has enough balance to settle fees + await dashboard.fund({ value: ether("1") }); + + const vaultBalance = await ethers.provider.getBalance(stakingVault); + expect(vaultBalance).to.be.greaterThanOrEqual(unsettledFees); + + const treasuryBalanceBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + + // Action: Voluntary disconnect + const tx = await dashboard.voluntaryDisconnect(); + await expect(tx).to.emit(vaultHub, "VaultDisconnectInitiated").withArgs(stakingVault); + + // Expected: All fees settled + const treasuryBalanceAfter = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + expect(treasuryBalanceAfter - treasuryBalanceBefore).to.equal(unsettledFees); + + // Verify no unsettled fees remain + const obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(0n); + + // Verify disconnect was initiated + expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.true; + + // Disconnect with report + const reportTx = await reportVaultDataWithProof(ctx, stakingVault, { cumulativeLidoFees: unsettledFees }); + await expect(reportTx).to.emit(vaultHub, "VaultDisconnectCompleted").withArgs(stakingVault); + expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.false; + expect(await vaultHub.isVaultConnected(stakingVault)).to.be.false; + expect(await vaultHub.locked(stakingVault)).to.be.equal(0n); + expect((await ctx.contracts.operatorGrid.vaultTierInfo(stakingVault)).tierId).to.be.equal(0n); + }); + + it("reverts voluntary disconnect when fees cannot be fully settled", async () => { + // Report fees that exceed the remaining balance + // Vault now has only 1 ETH (CONNECT_DEPOSIT) but needs 1.5 ETH for fees + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("1"), + cumulativeLidoFees: ether("1.5"), + waitForNextRefSlot: true, + }); + + // Verify: balance (1 ETH) < unsettled fees (1.5 ETH) + const availableBalance = await stakingVault.availableBalance(); + const unsettledFees = ether("1.5"); + expect(availableBalance).to.be.lessThan(unsettledFees); + + // Action: Attempt voluntary disconnect + // Expected: Should revert because fees cannot be fully settled + await expect(dashboard.voluntaryDisconnect()).to.be.revertedWithCustomError( + vaultHub, + "NoUnsettledLidoFeesShouldBeLeft", + ); + }); +});