From 17c32c207f35d422ad13a046e0036b4c3bec20e5 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Tue, 4 Nov 2025 14:54:30 +0500 Subject: [PATCH 01/12] test: unskip and update some tests --- .../core/accounting.integration.ts | 54 +++++++++++-- .../vaults/obligations.integration.ts | 80 ++----------------- test/integration/vaults/roles.integration.ts | 4 +- 3 files changed, 56 insertions(+), 82 deletions(-) diff --git a/test/integration/core/accounting.integration.ts b/test/integration/core/accounting.integration.ts index 3ac861a43c..1b1c22d879 100644 --- a/test/integration/core/accounting.integration.ts +++ b/test/integration/core/accounting.integration.ts @@ -6,7 +6,14 @@ import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; import { ether, impersonate, log, ONE_GWEI, updateBalance } from "lib"; import { LIMITER_PRECISION_BASE } from "lib/constants"; -import { finalizeWQViaSubmit, getProtocolContext, getReportTimeElapsed, ProtocolContext, report } from "lib/protocol"; +import { + finalizeWQViaSubmit, + getProtocolContext, + getReportTimeElapsed, + OracleReportParams, + ProtocolContext, + report, +} from "lib/protocol"; import { Snapshot } from "test/suite"; import { MAX_BASIS_POINTS, ONE_DAY, SHARE_RATE_PRECISION } from "test/suite/constants"; @@ -97,9 +104,8 @@ describe("Integration: Accounting", () => { } } - // TODO: remove or fix and make it more meaningful for both scratch and mainnet limits - it.skip("Should reverts report on sanity checks", async () => { - const { oracleReportSanityChecker } = ctx.contracts; + it("reverts if the CL increase balance is incorrect", async () => { + const { oracleReportSanityChecker, withdrawalVault } = ctx.contracts; const maxCLRebaseViaLimiter = await rebaseLimitWei(); console.debug({ maxCLRebaseViaLimiter }); @@ -107,13 +113,51 @@ describe("Integration: Accounting", () => { // Expected annual limit to shot first const rebaseAmount = maxCLRebaseViaLimiter - 1n; - const params = { clDiff: rebaseAmount, excludeVaultsBalances: true }; + const params: Partial = { + clDiff: rebaseAmount, + excludeVaultsBalances: true, + withdrawalVaultBalance: await ethers.provider.getBalance(withdrawalVault), + }; await expect(report(ctx, params)).to.be.revertedWithCustomError( oracleReportSanityChecker, "IncorrectCLBalanceIncrease(uint256)", ); }); + it("reverts if the withdrawal vault balance is greater than reported", async () => { + const { oracleReportSanityChecker, withdrawalVault } = ctx.contracts; + + const balance = await ethers.provider.getBalance(withdrawalVault); + + const params: Partial = { + excludeVaultsBalances: false, + withdrawalVaultBalance: balance + 1n, + reportWithdrawalsVault: true, + }; + + await expect(report(ctx, params)).to.be.revertedWithCustomError( + oracleReportSanityChecker, + "IncorrectWithdrawalsVaultBalance(uint256)", + ); + }); + + it("reverts if the withdrawal vault balance is greater than reported", async () => { + const { oracleReportSanityChecker, withdrawalVault } = ctx.contracts; + + const balance = await ethers.provider.getBalance(withdrawalVault); + + const params: Partial = { + excludeVaultsBalances: false, + withdrawalVaultBalance: balance + 1n, + reportWithdrawalsVault: true, + }; + + await expect(report(ctx, params)).to.be.revertedWithCustomError( + oracleReportSanityChecker, + "IncorrectWithdrawalsVaultBalance(uint256)", + ); + }); + it("Should account correctly with no CL rebase", async () => { const { lido, accountingOracle } = ctx.contracts; diff --git a/test/integration/vaults/obligations.integration.ts b/test/integration/vaults/obligations.integration.ts index 405941cfc0..353f5ba822 100644 --- a/test/integration/vaults/obligations.integration.ts +++ b/test/integration/vaults/obligations.integration.ts @@ -686,8 +686,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") }); @@ -699,8 +698,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(stakingVaultAddress, ether("1"), 0); + .to.be.revertedWithCustomError(vaultHub, "NoUnsettledLidoFeesShouldBeLeft") + .withArgs(stakingVaultAddress, ether("1.1")); expect(obligations.cumulativeLidoFees).to.equal(ether("1.1")); expect(await ethers.provider.getBalance(stakingVaultAddress)).to.equal(ether("1")); @@ -711,8 +710,8 @@ describe("Integration: Vault redemptions and fees obligations", () => { await dashboard.fund({ value: ether("0.1") }); await expect(dashboard.voluntaryDisconnect()) - .to.emit(vaultHub, "VaultObligationsSettled") - .withArgs(stakingVaultAddress, 0n, ether("1.1"), 0n, 0n, ether("1.1")) + .to.emit(vaultHub, "LidoFeesSettled") + .withArgs(stakingVaultAddress, ether("1.1"), ether("1.1"), ether("1.1")) .to.emit(vaultHub, "VaultDisconnectInitiated") .withArgs(stakingVaultAddress); }); @@ -733,74 +732,5 @@ describe("Integration: Vault redemptions and fees obligations", () => { expect(await ethers.provider.getBalance(stakingVaultAddress)).to.equal(ether("0.1")); expect(await vaultHub.totalValue(stakingVaultAddress)).to.equal(ether("0.1")); }); - - it("Reverts disconnect process when balance is not enough to cover the exit fees", async () => { - expect(await vaultHub.totalValue(stakingVaultAddress)).to.equal(ether("1")); - await reportVaultDataWithProof(ctx, stakingVault, { cumulativeLidoFees: ether("1") }); - - const totalValue = await vaultHub.totalValue(stakingVaultAddress); - 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(stakingVaultAddress, 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(stakingVaultAddress); - - // successfully disconnect - await dashboard.voluntaryDisconnect(); - - // adding 1 ether to cover the exit fees - await owner.sendTransaction({ to: stakingVaultAddress, 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(stakingVaultAddress, 0n, ether("0.1"), 0n, 0n, ether("1.1")) - .to.emit(vaultHub, "VaultDisconnectCompleted") - .withArgs(stakingVaultAddress); - - // 0.9 ether should be left in the vault - expect(await ethers.provider.getBalance(stakingVaultAddress)).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(stakingVaultAddress); - - // 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(stakingVaultAddress, 0n, ether("0.1"), 0n, 0n, ether("1.1")) - .to.emit(vaultHub, "VaultDisconnectCompleted") - .withArgs(stakingVaultAddress); - - // 0.9 ether should be left in the vault - expect(await ethers.provider.getBalance(stakingVaultAddress)).to.equal(ether("0.9")); - }); }); }); diff --git a/test/integration/vaults/roles.integration.ts b/test/integration/vaults/roles.integration.ts index c6160e2bad..afc3449072 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( From 6785bae5c855ff90fa0158b7a3b9d753eb3d9d4f Mon Sep 17 00:00:00 2001 From: failingtwice Date: Thu, 6 Nov 2025 12:10:33 +0500 Subject: [PATCH 02/12] test: force-disconnect integration tests --- lib/protocol/helpers/vaults.ts | 2 +- .../vaults/vaulthub.force-disconnect.ts | 124 ++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 test/integration/vaults/vaulthub.force-disconnect.ts diff --git a/lib/protocol/helpers/vaults.ts b/lib/protocol/helpers/vaults.ts index 742993ec45..089f4c64ec 100644 --- a/lib/protocol/helpers/vaults.ts +++ b/lib/protocol/helpers/vaults.ts @@ -280,7 +280,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/integration/vaults/vaulthub.force-disconnect.ts b/test/integration/vaults/vaulthub.force-disconnect.ts new file mode 100644 index 0000000000..89c41201f1 --- /dev/null +++ b/test/integration/vaults/vaulthub.force-disconnect.ts @@ -0,0 +1,124 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { Dashboard, StakingVault, VaultHub } from "typechain-types"; + +import { BigIntMath, days } from "lib"; +import { + changeTier, + createVaultWithDashboard, + getProtocolContext, + ProtocolContext, + reportVaultDataWithProof, + setupLidoForVaults, + setUpOperatorGrid, +} from "lib/protocol"; +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 () => { + ctx = await getProtocolContext(); + originalSnapshot = await Snapshot.take(); + + await setupLidoForVaults(ctx); + [, owner, nodeOperator, vaultMaster] = await ethers.getSigners(); + + 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); + + // Grant VAULT_MASTER_ROLE to vaultMaster + await vaultHub.connect(await ctx.getSigner("agent")).grantRole(await vaultHub.VAULT_MASTER_ROLE(), vaultMaster); + + agentSigner = await ctx.getSigner("agent"); + + // set maximum fee rate per second to 1 ether to allow rapid fee increases + await ctx.contracts.lazyOracle.connect(agentSigner).updateSanityParams(days(30n), 1000n, 1000000000000000000n); + }); + + beforeEach(async () => (snapshot = await Snapshot.take())); + afterEach(async () => await Snapshot.restore(snapshot)); + after(async () => await Snapshot.restore(originalSnapshot)); + + describe("VAULT_MASTER_ROLE can force disconnect", () => { + 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, unsettled fees < balance + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("1"), + cumulativeLidoFees: feesToSettle, // Create some unsettled fees + }); + + // 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()); + + // Action: VAULT_MASTER_ROLE calls disconnect(vault) + const tx = await vaultHub.connect(vaultMaster).disconnect(stakingVault); + await expect(tx).to.emit(vaultHub, "VaultDisconnectInitiated").withArgs(stakingVault); + + // Expected: 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)); + + 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); + }); + } + }); +}); From 87a365298f14891c4201d7f38cfda2053f9c4cb1 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Fri, 7 Nov 2025 13:15:34 +0500 Subject: [PATCH 03/12] test: fixing --- .../vaults/vaulthub.force-disconnect.ts | 188 +++++++++++++++++- 1 file changed, 187 insertions(+), 1 deletion(-) diff --git a/test/integration/vaults/vaulthub.force-disconnect.ts b/test/integration/vaults/vaulthub.force-disconnect.ts index 89c41201f1..b5a927705c 100644 --- a/test/integration/vaults/vaulthub.force-disconnect.ts +++ b/test/integration/vaults/vaulthub.force-disconnect.ts @@ -5,7 +5,7 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { Dashboard, StakingVault, VaultHub } from "typechain-types"; -import { BigIntMath, days } from "lib"; +import { BigIntMath, days, DISCONNECT_NOT_INITIATED } from "lib"; import { changeTier, createVaultWithDashboard, @@ -121,4 +121,190 @@ describe("Integration: VaultHub Force Disconnect", () => { }); } }); + + describe("Force disconnect aborted by slashing reserve", () => { + it("aborts disconnect when slashing reserve appears", async () => { + // Setup: Initiate disconnect + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("1"), + liabilityShares: 0n, + }); + + 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); + }); + }); + + // describe("Force disconnect aborted by socialized bad debt", () => { + // let stakingVaultB: StakingVault; + // let dashboardB: Dashboard; + + // beforeEach(async () => { + // // Create second vault for bad debt scenario + // ({ stakingVault: stakingVaultB, dashboard: dashboardB } = await createVaultWithDashboard( + // ctx, + // ctx.contracts.stakingVaultFactory, + // owner, + // nodeOperator, + // )); + + // dashboardB = dashboardB.connect(owner); + // await changeTier(ctx, dashboardB, owner, nodeOperator); + + // // Grant BAD_DEBT_MASTER_ROLE + // await vaultHub.connect(agentSigner).grantRole(await vaultHub.BAD_DEBT_MASTER_ROLE(), agentSigner); + // }); + + // it("aborts disconnect when bad debt is socialized to vault", async () => { + // // Setup Vault B: Create bad debt + // await dashboardB.fund({ value: ether("10") }); + // await reportVaultDataWithProof(ctx, stakingVaultB, { + // totalValue: ether("11"), + // liabilityShares: 0n, + // }); + // await dashboardB.mintStETH(owner, ether("5")); + // await reportVaultDataWithProof(ctx, stakingVaultB, { + // totalValue: ether("1"), // Slashed from 11 to 1 + // slashingReserve: ether("1"), + // waitForNextRefSlot: true, + // }); + + // // Verify Vault B has bad debt + // const liabilitySharesB = await vaultHub.liabilityShares(stakingVaultB); + // const totalValueB = await vaultHub.totalValue(stakingVaultB); + // const liabilityValueB = await ctx.contracts.lido.getPooledEthBySharesRoundUp(liabilitySharesB); + // expect(liabilityValueB).to.be.greaterThan(totalValueB); + + // // Fund Vault A to accept bad debt + // // Setup Vault A: Clean and pending disconnect + // const reportTx = await reportVaultDataWithProof(ctx, stakingVault, { + // totalValue: ether("1"), + // liabilityShares: 0n, + // }); + // await reportTx.wait(); + // console.log("report timestamp", await ctx.contracts.lazyOracle.latestReportTimestamp()); + // await vaultHub.connect(vaultMaster).disconnect(stakingVault); + // expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.true; + // expect(await vaultHub.liabilityShares(stakingVault)).to.equal(0n); + // await dashboard.fund({ value: ether("10") }); + + // // Calculate bad debt shares + // const badDebtShares = liabilitySharesB - (await ctx.contracts.lido.getSharesByPooledEth(totalValueB)); + + // expect(await vaultHub.isVaultConnected(stakingVault)).to.be.true; + // expect(await vaultHub.isVaultConnected(stakingVaultB)).to.be.true; + // console.log("socialize bad debt timestamp", await ctx.contracts.lazyOracle.latestReportTimestamp()); + // await vaultHub.connect(agentSigner).socializeBadDebt(stakingVaultB, stakingVault, badDebtShares); + + // // Verify Vault A now has liability + // expect(await vaultHub.liabilityShares(stakingVault)).to.be.greaterThan(0n); + + // // Action: Oracle reports vault A + // const tx = await reportVaultDataWithProof(ctx, stakingVault, { + // totalValue: ether("11"), + // waitForNextRefSlot: true, + // }); + + // // Expected: Disconnect aborted due to liability + // await expect(tx).to.emit(vaultHub, "VaultDisconnectAborted"); + + // // Verify disconnect was cancelled + // expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.false; + // const connection = await vaultHub.vaultConnection(stakingVault); + // expect(connection.disconnectInitiatedTs).to.equal(0n); + + // // Verify vault remains connected + // expect(await vaultHub.isVaultConnected(stakingVault)).to.be.true; + // }); + // }); + + describe("Force disconnect blocks new minting after initiation", () => { + // 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; + // // Action: Attempt to mint after disconnect initiated + // // Expected: Should revert + // await expect(dashboard.mintStETH(owner, ether("1"))).to.be.revertedWithCustomError( + // vaultHub, + // "VaultDisconnectPending", + // ); + // }); + }); + + describe("Voluntary disconnect requires full fee settlement", () => { + 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, + }); + + // 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; + }); + + // it("reverts voluntary disconnect when fees cannot be fully settled", async () => { + // // Setup: Create unsettled fees greater than vault balance + // const unsettledFees = ether("5"); + // await reportVaultDataWithProof(ctx, stakingVault, { + // totalValue: ether("1"), + // cumulativeLidoFees: unsettledFees, + // }); + + // // Drain vault so it can't pay fees + // const currentBalance = await ethers.provider.getBalance(stakingVault); + // if (currentBalance > 0n) { + // await dashboard.recoverFeeLeftover(); + // } + // }); + }); }); From 594ef0c01524b0de75f131b8636650079c31f86a Mon Sep 17 00:00:00 2001 From: failingtwice Date: Mon, 10 Nov 2025 19:17:02 +0500 Subject: [PATCH 04/12] test: force disconnect integrations --- .../vaults/vaulthub.force-disconnect.ts | 493 ++++++++++-------- 1 file changed, 265 insertions(+), 228 deletions(-) diff --git a/test/integration/vaults/vaulthub.force-disconnect.ts b/test/integration/vaults/vaulthub.force-disconnect.ts index b5a927705c..8acacb5349 100644 --- a/test/integration/vaults/vaulthub.force-disconnect.ts +++ b/test/integration/vaults/vaulthub.force-disconnect.ts @@ -5,21 +5,24 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { Dashboard, StakingVault, VaultHub } from "typechain-types"; -import { BigIntMath, days, DISCONNECT_NOT_INITIATED } from "lib"; +import { BigIntMath, days, 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", () => { +describe("Integration: VaultHub:force-disconnect", () => { let ctx: ProtocolContext; let snapshot: string; let originalSnapshot: string; @@ -33,12 +36,11 @@ describe("Integration: VaultHub Force Disconnect", () => { let vaultHub: VaultHub; before(async () => { - ctx = await getProtocolContext(); originalSnapshot = await Snapshot.take(); - - await setupLidoForVaults(ctx); [, 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( @@ -52,13 +54,9 @@ describe("Integration: VaultHub Force Disconnect", () => { vaultHub = ctx.contracts.vaultHub; await changeTier(ctx, dashboard, owner, nodeOperator); + await vaultHub.connect(agentSigner).grantRole(await vaultHub.VAULT_MASTER_ROLE(), vaultMaster); - // Grant VAULT_MASTER_ROLE to vaultMaster - await vaultHub.connect(await ctx.getSigner("agent")).grantRole(await vaultHub.VAULT_MASTER_ROLE(), vaultMaster); - - agentSigner = await ctx.getSigner("agent"); - - // set maximum fee rate per second to 1 ether to allow rapid fee increases + // loosen sanity checks to bypass fee increase rate limit await ctx.contracts.lazyOracle.connect(agentSigner).updateSanityParams(days(30n), 1000n, 1000000000000000000n); }); @@ -66,245 +64,284 @@ describe("Integration: VaultHub Force Disconnect", () => { afterEach(async () => await Snapshot.restore(snapshot)); after(async () => await Snapshot.restore(originalSnapshot)); - describe("VAULT_MASTER_ROLE can force disconnect", () => { - 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, unsettled fees < balance - await reportVaultDataWithProof(ctx, stakingVault, { - totalValue: ether("1"), - cumulativeLidoFees: feesToSettle, // Create some unsettled fees - }); - - // 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()); - - // Action: VAULT_MASTER_ROLE calls disconnect(vault) - const tx = await vaultHub.connect(vaultMaster).disconnect(stakingVault); - await expect(tx).to.emit(vaultHub, "VaultDisconnectInitiated").withArgs(stakingVault); - - // Expected: 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)); - - 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); - }); - } - }); + 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; - describe("Force disconnect aborted by slashing reserve", () => { - it("aborts disconnect when slashing reserve appears", async () => { - // Setup: Initiate disconnect + // Setup: Connected vault with liabilityShares = 0, fresh report await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("1"), - liabilityShares: 0n, + cumulativeLidoFees: feesToSettle, // Assign unsettled fees to the vault }); - 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); + // 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); - // Action: Oracle reports with slashing reserve - const slashingReserve = ether("0.5"); - const tx = await reportVaultDataWithProof(ctx, stakingVault, { - totalValue: ether("1"), - liabilityShares: 0n, - slashingReserve, - }); + 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); - // Expected: Disconnect aborted - await expect(tx).to.emit(vaultHub, "VaultDisconnectAborted").withArgs(stakingVault, slashingReserve); + // 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); - // Verify disconnect was cancelled - const connectionAfter = await vaultHub.vaultConnection(stakingVault); - expect(connectionAfter.disconnectInitiatedTs).to.equal(DISCONNECT_NOT_INITIATED); + // 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); + }); + } - // 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 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); }); - // describe("Force disconnect aborted by socialized bad debt", () => { - // let stakingVaultB: StakingVault; - // let dashboardB: Dashboard; - - // beforeEach(async () => { - // // Create second vault for bad debt scenario - // ({ stakingVault: stakingVaultB, dashboard: dashboardB } = await createVaultWithDashboard( - // ctx, - // ctx.contracts.stakingVaultFactory, - // owner, - // nodeOperator, - // )); - - // dashboardB = dashboardB.connect(owner); - // await changeTier(ctx, dashboardB, owner, nodeOperator); - - // // Grant BAD_DEBT_MASTER_ROLE - // await vaultHub.connect(agentSigner).grantRole(await vaultHub.BAD_DEBT_MASTER_ROLE(), agentSigner); - // }); - - // it("aborts disconnect when bad debt is socialized to vault", async () => { - // // Setup Vault B: Create bad debt - // await dashboardB.fund({ value: ether("10") }); - // await reportVaultDataWithProof(ctx, stakingVaultB, { - // totalValue: ether("11"), - // liabilityShares: 0n, - // }); - // await dashboardB.mintStETH(owner, ether("5")); - // await reportVaultDataWithProof(ctx, stakingVaultB, { - // totalValue: ether("1"), // Slashed from 11 to 1 - // slashingReserve: ether("1"), - // waitForNextRefSlot: true, - // }); - - // // Verify Vault B has bad debt - // const liabilitySharesB = await vaultHub.liabilityShares(stakingVaultB); - // const totalValueB = await vaultHub.totalValue(stakingVaultB); - // const liabilityValueB = await ctx.contracts.lido.getPooledEthBySharesRoundUp(liabilitySharesB); - // expect(liabilityValueB).to.be.greaterThan(totalValueB); - - // // Fund Vault A to accept bad debt - // // Setup Vault A: Clean and pending disconnect - // const reportTx = await reportVaultDataWithProof(ctx, stakingVault, { - // totalValue: ether("1"), - // liabilityShares: 0n, - // }); - // await reportTx.wait(); - // console.log("report timestamp", await ctx.contracts.lazyOracle.latestReportTimestamp()); - // await vaultHub.connect(vaultMaster).disconnect(stakingVault); - // expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.true; - // expect(await vaultHub.liabilityShares(stakingVault)).to.equal(0n); - // await dashboard.fund({ value: ether("10") }); - - // // Calculate bad debt shares - // const badDebtShares = liabilitySharesB - (await ctx.contracts.lido.getSharesByPooledEth(totalValueB)); - - // expect(await vaultHub.isVaultConnected(stakingVault)).to.be.true; - // expect(await vaultHub.isVaultConnected(stakingVaultB)).to.be.true; - // console.log("socialize bad debt timestamp", await ctx.contracts.lazyOracle.latestReportTimestamp()); - // await vaultHub.connect(agentSigner).socializeBadDebt(stakingVaultB, stakingVault, badDebtShares); - - // // Verify Vault A now has liability - // expect(await vaultHub.liabilityShares(stakingVault)).to.be.greaterThan(0n); - - // // Action: Oracle reports vault A - // const tx = await reportVaultDataWithProof(ctx, stakingVault, { - // totalValue: ether("11"), - // waitForNextRefSlot: true, - // }); - - // // Expected: Disconnect aborted due to liability - // await expect(tx).to.emit(vaultHub, "VaultDisconnectAborted"); - - // // Verify disconnect was cancelled - // expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.false; - // const connection = await vaultHub.vaultConnection(stakingVault); - // expect(connection.disconnectInitiatedTs).to.equal(0n); - - // // Verify vault remains connected - // expect(await vaultHub.isVaultConnected(stakingVault)).to.be.true; - // }); - // }); - - describe("Force disconnect blocks new minting after initiation", () => { - // 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; - // // Action: Attempt to mint after disconnect initiated - // // Expected: Should revert - // await expect(dashboard.mintStETH(owner, ether("1"))).to.be.revertedWithCustomError( - // vaultHub, - // "VaultDisconnectPending", - // ); - // }); + 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; }); - describe("Voluntary disconnect requires full fee settlement", () => { - 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, - }); + 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", + ); + }); - // Ensure vault has enough balance to settle fees - await dashboard.fund({ value: ether("1") }); + 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, + }); - const vaultBalance = await ethers.provider.getBalance(stakingVault); - expect(vaultBalance).to.be.greaterThanOrEqual(unsettledFees); + // Ensure vault has enough balance to settle fees + await dashboard.fund({ value: ether("1") }); - const treasuryBalanceBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + const vaultBalance = await ethers.provider.getBalance(stakingVault); + expect(vaultBalance).to.be.greaterThanOrEqual(unsettledFees); - // Action: Voluntary disconnect - const tx = await dashboard.voluntaryDisconnect(); - await expect(tx).to.emit(vaultHub, "VaultDisconnectInitiated").withArgs(stakingVault); + const treasuryBalanceBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); - // Expected: All fees settled - const treasuryBalanceAfter = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); - expect(treasuryBalanceAfter - treasuryBalanceBefore).to.equal(unsettledFees); + // Action: Voluntary disconnect + const tx = await dashboard.voluntaryDisconnect(); + await expect(tx).to.emit(vaultHub, "VaultDisconnectInitiated").withArgs(stakingVault); - // Verify no unsettled fees remain - const obligations = await vaultHub.obligations(stakingVault); - expect(obligations.feesToSettle).to.equal(0n); + // Expected: All fees settled + const treasuryBalanceAfter = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + expect(treasuryBalanceAfter - treasuryBalanceBefore).to.equal(unsettledFees); - // Verify disconnect was initiated - expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.true; + // 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, }); - // it("reverts voluntary disconnect when fees cannot be fully settled", async () => { - // // Setup: Create unsettled fees greater than vault balance - // const unsettledFees = ether("5"); - // await reportVaultDataWithProof(ctx, stakingVault, { - // totalValue: ether("1"), - // cumulativeLidoFees: unsettledFees, - // }); - - // // Drain vault so it can't pay fees - // const currentBalance = await ethers.provider.getBalance(stakingVault); - // if (currentBalance > 0n) { - // await dashboard.recoverFeeLeftover(); - // } - // }); + // 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", + ); }); }); From 05e32b3754fbc665c9a2df3cd1041f619a3d9195 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Tue, 11 Nov 2025 16:08:58 +0500 Subject: [PATCH 05/12] test: add vaulthub fee integration tests --- test/integration/vaults/vaulthub.fees.ts | 861 +++++++++++++++++++++++ 1 file changed, 861 insertions(+) create mode 100644 test/integration/vaults/vaulthub.fees.ts diff --git a/test/integration/vaults/vaulthub.fees.ts b/test/integration/vaults/vaulthub.fees.ts new file mode 100644 index 0000000000..046497861f --- /dev/null +++ b/test/integration/vaults/vaulthub.fees.ts @@ -0,0 +1,861 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { Dashboard, StakingVault, VaultHub } from "typechain-types"; + +import { days } from "lib"; +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); + + // loosen sanity checks to bypass fee increase rate limit + await ctx.contracts.lazyOracle.connect(agentSigner).updateSanityParams(days(30n), 1000n, 1000000000000000000n); + }); + + 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"), + }); + + 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"), + }); + + // 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"), + }); + + 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: locked = liability + reserve = 24 + (24 * 0.2) = 24 + 4.8 = 28.8 ETH (rounded up to 29) + // Available for fees = 31 - 29 = 2 ETH + await dashboard.mintStETH(owner, ether("24")); + + // Report with 5 ETH unsettled fees (more than the 2 ETH available) + 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")); + + // Verify locked calculation + const locked = await vaultHub.locked(stakingVault); + expect(locked).to.be.greaterThan(ether("28")); // Should be ~29 ETH locked + + // Calculate settleable amount (should be ~2 ETH: 31 total - ~29 locked) + const settleableValue = await vaultHub.settleableLidoFeesValue(stakingVault); + expect(settleableValue).to.be.lessThan(ether("3")); // Less than 3 ETH available + expect(settleableValue).to.be.greaterThan(ether("1")); // More than 1 ETH available + + // Action: settleLidoFees (only settles what's available) + const treasuryBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + + const tx = await vaultHub.settleLidoFees(stakingVault); + await tx.wait(); + + 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("5")); + + // Verify fees remain unsettled + const obligationsAfter = await vaultHub.obligations(stakingVault); + expect(obligationsAfter.feesToSettle).to.equal(ether("5") - actualSettled); + expect(obligationsAfter.feesToSettle).to.be.greaterThan(ether("2")); // More than 2 ETH remains + + // Deposits remain paused (remaining fees >= 1 ETH) + 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 to lock more of the vault + // With 20% reserve ratio: locked = minimalReserve(1) + liability(10) + reserve(2) = ~13 ETH + // Unlocked = 16 - 13 = ~3 ETH + await dashboard.mintStETH(owner, ether("10")); + + // Set redemption shares representing 2 ETH (reserves 2 ETH from unlocked balance) + // This leaves only ~1 ETH available for fee settlement + 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 (much 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")); + + // Calculate settleable: Should be limited by redemptions (less than total fees) + const settleableValue = await vaultHub.settleableLidoFeesValue(stakingVault); + expect(settleableValue).to.be.lessThan(ether("5")); // Less than total fees (limited by redemptions + locked) + expect(settleableValue).to.be.greaterThan(ether("1")); // Reasonable amount available + + // Action: Settle fees (only settles what's available after redemptions) + const treasuryBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + + const tx = await vaultHub.settleLidoFees(stakingVault); + await tx.wait(); + + const treasuryAfter = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + const actualSettled = treasuryAfter - treasuryBefore; + + // Expected: Settled exactly settleableValue (much less than total fees due to redemptions) + expect(actualSettled).to.equal(settleableValue); + expect(actualSettled).to.be.lessThan(ether("5")); + + // Verify fees remain unsettled (constrained by redemptions) + const obligationsAfter = await vaultHub.obligations(stakingVault); + expect(obligationsAfter.feesToSettle).to.equal(ether("5") - actualSettled); + expect(obligationsAfter.feesToSettle).to.be.greaterThan(0n); // Some fees remain + + // 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"), + }); + + // 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 availableBalance = await stakingVault.availableBalance(); + expect(availableBalance).to.be.lessThan(ether("1.5")); + + // 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"), + }); + + 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")); + + // This verifies that the Lido fee settlement requirement is satisfied + // (voluntary disconnect may still fail on other requirements like node operator fees) + }); + + 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 + const tx = await vaultHub.connect(vaultMaster).disconnect(stakingVault); + await expect(tx).to.emit(vaultHub, "VaultDisconnectInitiated").withArgs(stakingVault); + + // Expected: Settles what it can (up to available balance) + const treasuryAfter = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + const settled = treasuryAfter - treasuryBefore; + expect(settled).to.be.lessThanOrEqual(vaultBalance); + expect(settled).to.be.lessThan(largeFees); + + // Disconnect still initiated despite unsettled fees + expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.true; + }); + + it("allows fee settlement during disconnect process", async () => { + // Setup: Vault with 5 ETH balance and 2 ETH unsettled fees + await dashboard.fund({ value: ether("5") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("6"), + cumulativeLidoFees: ether("2"), + }); + + const treasuryBeforeDisconnect = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + + // Initiate disconnect (settles all available fees - all 2 ETH should be settled) + await vaultHub.connect(vaultMaster).disconnect(stakingVault); + expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.true; + + const treasuryAfterDisconnect = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); + const settledDuringDisconnect = treasuryAfterDisconnect - treasuryBeforeDisconnect; + + // Verify all 2 ETH fees were settled during disconnect (balance was sufficient) + expect(settledDuringDisconnect).to.equal(ether("2")); + + // Verify no fees remain after disconnect + const obligationsAfterDisconnect = await vaultHub.obligations(stakingVault); + expect(obligationsAfterDisconnect.feesToSettle).to.equal(0n); + + // Disconnect process continues (and can complete on next report since all obligations are met) + expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.true; + + // Complete the disconnect with next report + const disconnectTx = await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("4"), // 6 - 2 settled fees + cumulativeLidoFees: ether("2"), + waitForNextRefSlot: true, + }); + + await expect(disconnectTx).to.emit(vaultHub, "VaultDisconnectCompleted").withArgs(stakingVault); + expect(await vaultHub.isVaultConnected(stakingVault)).to.be.false; + }); + }); + + describe("Bad debt scenarios", () => { + it("handles large unpaid fees with 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 capacityBefore = await vaultHub.totalMintingCapacityShares(stakingVault, 0); + const maxLockableValueBefore = await vaultHub.maxLockableValue(stakingVault); + expect(maxLockableValueBefore).to.equal(ether("51")); + + // Report unsettled fees = 10 ETH + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("51"), + cumulativeLidoFees: ether("10"), + waitForNextRefSlot: true, + }); + + // Check minting capacity with fees + const capacityAfter = await vaultHub.totalMintingCapacityShares(stakingVault, 0); + const maxLockableValueAfter = await vaultHub.maxLockableValue(stakingVault); + + // Expected: maxLockableValue reduced by unsettled fees + expect(maxLockableValueAfter).to.equal(ether("41")); // 51 - 10 + expect(capacityAfter).to.be.lessThan(capacityBefore); + + // Verify the reduction matches the fee amount + const capacityReduction = capacityBefore - capacityAfter; + const expectedReduction = await ctx.contracts.lido.getSharesByPooledEth(ether("10")); + // Should be approximately equal (accounting for reserve ratio) + expect(capacityReduction).to.be.greaterThan(expectedReduction / 2n); + }); + + 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"), + }); + + const capacityWithFees = await vaultHub.totalMintingCapacityShares(stakingVault, 0); + + // 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 capacityAfterSettlement = await vaultHub.totalMintingCapacityShares(stakingVault, 0); + const maxLockableAfter = await vaultHub.maxLockableValue(stakingVault); + + // Expected: maxLockableValue should equal totalValue (no more unsettled fees) + expect(maxLockableAfter).to.equal(ether("18")); + + // Capacity relative to maxLockable should be better or equal + // (unsettled fees no longer reducing it) + expect(capacityAfterSettlement).to.be.greaterThanOrEqual(capacityWithFees); + }); + }); + + 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"), + }); + + // 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; + + // Check settleable amount (should be ~1.5 ETH since we have ~1.6 ETH available) + const settleableValue = await vaultHub.settleableLidoFeesValue(stakingVault); + expect(settleableValue).to.be.greaterThan(ether("1.4")); + expect(settleableValue).to.be.lessThanOrEqual(ether("1.6")); + + // Settle fees + await vaultHub.settleLidoFees(stakingVault); + + const remainingFees = ether("1.5") - settleableValue; + + // Expected: Remaining fees < 1 ETH, so deposits should resume + expect(remainingFees).to.be.lessThan(ether("1")); + expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + }); + + 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"), + }); + + // Check minting capacity before settlement + const capacityBefore = await dashboard.totalMintingCapacityShares(); + const remainingBefore = await dashboard.remainingMintingCapacityShares(0); + + // Settle fees + await vaultHub.settleLidoFees(stakingVault); + + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("16"), // 21 - 5 settled + cumulativeLidoFees: ether("5"), + waitForNextRefSlot: true, + }); + + // Check minting capacity after settlement + const capacityAfter = await dashboard.totalMintingCapacityShares(); + const remainingAfter = await dashboard.remainingMintingCapacityShares(0); + + // Expected: Minting capacity improved relative to total value + expect(capacityAfter).to.be.greaterThan(capacityBefore); + expect(remainingAfter).to.be.greaterThan(remainingBefore); + }); + }); + + 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 + expect(obligations.sharesToBurn).to.be.greaterThan(0n); + 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 operation is separate from fee settlement + expect(obligationsAfter.sharesToBurn).to.be.lessThan(obligations.sharesToBurn); + }); + }); + + 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"), + }); + + 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 0.999 ETH fees + await dashboard.fund({ value: ether("10") }); + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("10.999"), + cumulativeLidoFees: ether("0.999"), + }); + + const obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(ether("0.999")); + + // Expected: Deposits NOT paused (below threshold) + expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + }); + + it("settles zero 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"), + }); + + 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"), + }); + + 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")); + + // With locked liabilities, only partial settlement possible + settleableValue = await vaultHub.settleableLidoFeesValue(stakingVault); + expect(settleableValue).to.be.lessThan(ether("2")); + expect(settleableValue).to.be.greaterThan(0n); + + 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); + expect(secondSettlement).to.be.lessThan(ether("2")); + + obligations = await vaultHub.obligations(stakingVault); + expect(obligations.feesToSettle).to.equal(ether("2") - secondSettlement); + expect(obligations.feesToSettle).to.be.greaterThan(0n); + + // Verify multiple settlements occurred + const totalSettled = firstSettlement + secondSettlement; + expect(totalSettled).to.equal(ether("3") + secondSettlement); + expect(totalSettled).to.be.greaterThan(ether("3")); + }); + }); +}); From 25cbb5791c0fc6bde5fbb4df1b30d25fdd31bf03 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Tue, 18 Nov 2025 17:22:45 +0500 Subject: [PATCH 06/12] test: remove duplicate case --- test/integration/core/accounting.integration.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/test/integration/core/accounting.integration.ts b/test/integration/core/accounting.integration.ts index 1b1c22d879..7a16b58309 100644 --- a/test/integration/core/accounting.integration.ts +++ b/test/integration/core/accounting.integration.ts @@ -141,23 +141,6 @@ describe("Integration: Accounting", () => { ); }); - it("reverts if the withdrawal vault balance is greater than reported", async () => { - const { oracleReportSanityChecker, withdrawalVault } = ctx.contracts; - - const balance = await ethers.provider.getBalance(withdrawalVault); - - const params: Partial = { - excludeVaultsBalances: false, - withdrawalVaultBalance: balance + 1n, - reportWithdrawalsVault: true, - }; - - await expect(report(ctx, params)).to.be.revertedWithCustomError( - oracleReportSanityChecker, - "IncorrectWithdrawalsVaultBalance(uint256)", - ); - }); - it("Should account correctly with no CL rebase", async () => { const { lido, accountingOracle } = ctx.contracts; From 2f397e25f53b1148a539148e5d0874c3c6da78b5 Mon Sep 17 00:00:00 2001 From: failingtwice Date: Tue, 18 Nov 2025 18:21:20 +0500 Subject: [PATCH 07/12] test: make tests more precise --- test/integration/vaults/vaulthub.fees.ts | 160 +++++++++++------------ 1 file changed, 80 insertions(+), 80 deletions(-) diff --git a/test/integration/vaults/vaulthub.fees.ts b/test/integration/vaults/vaulthub.fees.ts index 046497861f..64742626db 100644 --- a/test/integration/vaults/vaulthub.fees.ts +++ b/test/integration/vaults/vaulthub.fees.ts @@ -244,11 +244,12 @@ describe("Integration: VaultHub:fees", () => { }); // Mint 24 ETH worth of stETH - // With 20% reserve ratio: locked = liability + reserve = 24 + (24 * 0.2) = 24 + 4.8 = 28.8 ETH (rounded up to 29) - // Available for fees = 31 - 29 = 2 ETH + // With 20% reserve ratio: + // reserve = ceilDiv(liability * 2000, 8000) + // locked = liability + reserve await dashboard.mintStETH(owner, ether("24")); - // Report with 5 ETH unsettled fees (more than the 2 ETH available) + // Report with 5 ETH unsettled fees await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("31"), cumulativeLidoFees: ether("5"), @@ -258,14 +259,14 @@ describe("Integration: VaultHub:fees", () => { const obligations = await vaultHub.obligations(stakingVault); expect(obligations.feesToSettle).to.equal(ether("5")); - // Verify locked calculation const locked = await vaultHub.locked(stakingVault); - expect(locked).to.be.greaterThan(ether("28")); // Should be ~29 ETH locked - // Calculate settleable amount (should be ~2 ETH: 31 total - ~29 locked) + // Calculate expected settleable amount: totalValue - locked + const totalValue = ether("31"); + const expectedSettleable = totalValue - locked; + const settleableValue = await vaultHub.settleableLidoFeesValue(stakingVault); - expect(settleableValue).to.be.lessThan(ether("3")); // Less than 3 ETH available - expect(settleableValue).to.be.greaterThan(ether("1")); // More than 1 ETH available + expect(settleableValue).to.equal(expectedSettleable); // Action: settleLidoFees (only settles what's available) const treasuryBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); @@ -278,12 +279,11 @@ describe("Integration: VaultHub:fees", () => { // Expected: Only partial fees settled (exactly settleableValue) expect(actualSettled).to.equal(settleableValue); - expect(actualSettled).to.be.lessThan(ether("5")); - // Verify fees remain unsettled + // Verify remaining unsettled fees + const expectedRemaining = ether("5") - actualSettled; const obligationsAfter = await vaultHub.obligations(stakingVault); - expect(obligationsAfter.feesToSettle).to.equal(ether("5") - actualSettled); - expect(obligationsAfter.feesToSettle).to.be.greaterThan(ether("2")); // More than 2 ETH remains + expect(obligationsAfter.feesToSettle).to.equal(expectedRemaining); // Deposits remain paused (remaining fees >= 1 ETH) expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; @@ -296,13 +296,13 @@ describe("Integration: VaultHub:fees", () => { totalValue: ether("16"), // includes CONNECT_DEPOSIT }); - // Mint 10 ETH worth of stETH to lock more of the vault - // With 20% reserve ratio: locked = minimalReserve(1) + liability(10) + reserve(2) = ~13 ETH - // Unlocked = 16 - 13 = ~3 ETH + // 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 2 ETH from unlocked balance) - // This leaves only ~1 ETH available for fee settlement + // 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; @@ -314,7 +314,7 @@ describe("Integration: VaultHub:fees", () => { const record = await vaultHub.vaultRecord(stakingVault); expect(record.redemptionShares).to.equal(redemptionSharesAmount); - // Report unsettled fees = 5 ETH (much more than available after redemptions) + // Report unsettled fees = 5 ETH (more than available after redemptions) await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("16"), cumulativeLidoFees: ether("5"), @@ -324,28 +324,24 @@ describe("Integration: VaultHub:fees", () => { const obligations = await vaultHub.obligations(stakingVault); expect(obligations.feesToSettle).to.equal(ether("5")); - // Calculate settleable: Should be limited by redemptions (less than total fees) + // Get the actual settleable amount from the contract const settleableValue = await vaultHub.settleableLidoFeesValue(stakingVault); - expect(settleableValue).to.be.lessThan(ether("5")); // Less than total fees (limited by redemptions + locked) - expect(settleableValue).to.be.greaterThan(ether("1")); // Reasonable amount available // Action: Settle fees (only settles what's available after redemptions) const treasuryBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); - const tx = await vaultHub.settleLidoFees(stakingVault); - await tx.wait(); + await vaultHub.settleLidoFees(stakingVault); const treasuryAfter = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); const actualSettled = treasuryAfter - treasuryBefore; - // Expected: Settled exactly settleableValue (much less than total fees due to redemptions) + // Expected: Settled exactly settleableValue expect(actualSettled).to.equal(settleableValue); - expect(actualSettled).to.be.lessThan(ether("5")); - // Verify fees remain unsettled (constrained by redemptions) + // Verify remaining unsettled fees + const expectedRemaining = ether("5") - actualSettled; const obligationsAfter = await vaultHub.obligations(stakingVault); - expect(obligationsAfter.feesToSettle).to.equal(ether("5") - actualSettled); - expect(obligationsAfter.feesToSettle).to.be.greaterThan(0n); // Some fees remain + expect(obligationsAfter.feesToSettle).to.equal(expectedRemaining); // Verify redemption shares are still reserved const recordAfter = await vaultHub.vaultRecord(stakingVault); @@ -395,8 +391,11 @@ describe("Integration: VaultHub:fees", () => { waitForNextRefSlot: true, }); - const availableBalance = await stakingVault.availableBalance(); - expect(availableBalance).to.be.lessThan(ether("1.5")); + 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( @@ -432,9 +431,6 @@ describe("Integration: VaultHub:fees", () => { expect(record.cumulativeLidoFees).to.equal(record.settledLidoFees); expect(record.cumulativeLidoFees).to.equal(ether("0.5")); expect(record.settledLidoFees).to.equal(ether("0.5")); - - // This verifies that the Lido fee settlement requirement is satisfied - // (voluntary disconnect may still fail on other requirements like node operator fees) }); it("allows force disconnect even with large unpaid fees", async () => { @@ -453,15 +449,17 @@ describe("Integration: VaultHub:fees", () => { const treasuryBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); - // Action: Force disconnect by VAULT_MASTER + // 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: Settles what it can (up to available balance) + // Expected: Some amount was settled during disconnect const treasuryAfter = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); const settled = treasuryAfter - treasuryBefore; - expect(settled).to.be.lessThanOrEqual(vaultBalance); - expect(settled).to.be.lessThan(largeFees); + + // 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; @@ -553,7 +551,6 @@ describe("Integration: VaultHub:fees", () => { }); // Check minting capacity without fees - const capacityBefore = await vaultHub.totalMintingCapacityShares(stakingVault, 0); const maxLockableValueBefore = await vaultHub.maxLockableValue(stakingVault); expect(maxLockableValueBefore).to.equal(ether("51")); @@ -565,18 +562,14 @@ describe("Integration: VaultHub:fees", () => { }); // Check minting capacity with fees - const capacityAfter = await vaultHub.totalMintingCapacityShares(stakingVault, 0); const maxLockableValueAfter = await vaultHub.maxLockableValue(stakingVault); // Expected: maxLockableValue reduced by unsettled fees expect(maxLockableValueAfter).to.equal(ether("41")); // 51 - 10 - expect(capacityAfter).to.be.lessThan(capacityBefore); - // Verify the reduction matches the fee amount - const capacityReduction = capacityBefore - capacityAfter; - const expectedReduction = await ctx.contracts.lido.getSharesByPooledEth(ether("10")); - // Should be approximately equal (accounting for reserve ratio) - expect(capacityReduction).to.be.greaterThan(expectedReduction / 2n); + // 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 () => { @@ -587,7 +580,9 @@ describe("Integration: VaultHub:fees", () => { cumulativeLidoFees: ether("3"), }); - const capacityWithFees = await vaultHub.totalMintingCapacityShares(stakingVault, 0); + // 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); @@ -599,15 +594,14 @@ describe("Integration: VaultHub:fees", () => { waitForNextRefSlot: true, }); - const capacityAfterSettlement = await vaultHub.totalMintingCapacityShares(stakingVault, 0); const maxLockableAfter = await vaultHub.maxLockableValue(stakingVault); + const obligationsAfter = await vaultHub.obligations(stakingVault); // Expected: maxLockableValue should equal totalValue (no more unsettled fees) expect(maxLockableAfter).to.equal(ether("18")); - // Capacity relative to maxLockable should be better or equal - // (unsettled fees no longer reducing it) - expect(capacityAfterSettlement).to.be.greaterThanOrEqual(capacityWithFees); + // Verify all fees have been settled + expect(obligationsAfter.feesToSettle).to.equal(0n); }); }); @@ -654,19 +648,21 @@ describe("Integration: VaultHub:fees", () => { // Verify deposits are paused initially (fees >= 1 ETH) expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; - // Check settleable amount (should be ~1.5 ETH since we have ~1.6 ETH available) + // Get the actual settleable amount const settleableValue = await vaultHub.settleableLidoFeesValue(stakingVault); - expect(settleableValue).to.be.greaterThan(ether("1.4")); - expect(settleableValue).to.be.lessThanOrEqual(ether("1.6")); // Settle fees await vaultHub.settleLidoFees(stakingVault); const remainingFees = ether("1.5") - settleableValue; - // Expected: Remaining fees < 1 ETH, so deposits should resume - expect(remainingFees).to.be.lessThan(ether("1")); - expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + // 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 () => { @@ -677,9 +673,9 @@ describe("Integration: VaultHub:fees", () => { cumulativeLidoFees: ether("5"), }); - // Check minting capacity before settlement - const capacityBefore = await dashboard.totalMintingCapacityShares(); - const remainingBefore = await dashboard.remainingMintingCapacityShares(0); + // Verify fees before settlement + const obligationsBefore = await vaultHub.obligations(stakingVault); + expect(obligationsBefore.feesToSettle).to.equal(ether("5")); // Settle fees await vaultHub.settleLidoFees(stakingVault); @@ -690,13 +686,12 @@ describe("Integration: VaultHub:fees", () => { waitForNextRefSlot: true, }); - // Check minting capacity after settlement - const capacityAfter = await dashboard.totalMintingCapacityShares(); - const remainingAfter = await dashboard.remainingMintingCapacityShares(0); + // Verify state after settlement + const obligationsAfter = await vaultHub.obligations(stakingVault); + expect(obligationsAfter.feesToSettle).to.equal(0n); // All fees settled - // Expected: Minting capacity improved relative to total value - expect(capacityAfter).to.be.greaterThan(capacityBefore); - expect(remainingAfter).to.be.greaterThan(remainingBefore); + const maxLockableAfter = await vaultHub.maxLockableValue(stakingVault); + expect(maxLockableAfter).to.equal(ether("16")); // No unsettled fees reducing maxLockable }); }); @@ -722,7 +717,8 @@ describe("Integration: VaultHub:fees", () => { const obligations = await vaultHub.obligations(stakingVault); // Verify bad health (need to rebalance shares) and unsettled fees - expect(obligations.sharesToBurn).to.be.greaterThan(0n); + 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) @@ -741,8 +737,10 @@ describe("Integration: VaultHub:fees", () => { // Deposits still paused (fees >= 1 ETH) expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; - // Verify rebalance operation is separate from fee settlement - expect(obligationsAfter.sharesToBurn).to.be.lessThan(obligations.sharesToBurn); + // 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 }); }); @@ -763,21 +761,21 @@ describe("Integration: VaultHub:fees", () => { }); it("handles fees just below 1 ETH threshold", async () => { - // Setup: Report 0.999 ETH fees + // Setup: Report 1 ETH - 1 wei fees await dashboard.fund({ value: ether("10") }); await reportVaultDataWithProof(ctx, stakingVault, { - totalValue: ether("10.999"), - cumulativeLidoFees: ether("0.999"), + totalValue: ether("10"), + cumulativeLidoFees: ether("1") - 1n, }); const obligations = await vaultHub.obligations(stakingVault); - expect(obligations.feesToSettle).to.equal(ether("0.999")); + expect(obligations.feesToSettle).to.equal(ether("1") - 1n); // Expected: Deposits NOT paused (below threshold) expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; }); - it("settles zero fees when balance is exactly zero", async () => { + 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 @@ -835,10 +833,13 @@ describe("Integration: VaultHub:fees", () => { obligations = await vaultHub.obligations(stakingVault); expect(obligations.feesToSettle).to.equal(ether("2")); - // With locked liabilities, only partial settlement possible + // 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.be.lessThan(ether("2")); - expect(settleableValue).to.be.greaterThan(0n); + expect(settleableValue).to.equal(expectedSettleable); const treasuryBefore2 = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); await vaultHub.settleLidoFees(stakingVault); @@ -846,16 +847,15 @@ describe("Integration: VaultHub:fees", () => { const secondSettlement = treasuryAfter2 - treasuryBefore2; expect(secondSettlement).to.equal(settleableValue); - expect(secondSettlement).to.be.lessThan(ether("2")); + // Verify remaining unsettled fees + const expectedRemaining = ether("2") - secondSettlement; obligations = await vaultHub.obligations(stakingVault); - expect(obligations.feesToSettle).to.equal(ether("2") - secondSettlement); - expect(obligations.feesToSettle).to.be.greaterThan(0n); + expect(obligations.feesToSettle).to.equal(expectedRemaining); // Verify multiple settlements occurred const totalSettled = firstSettlement + secondSettlement; expect(totalSettled).to.equal(ether("3") + secondSettlement); - expect(totalSettled).to.be.greaterThan(ether("3")); }); }); }); From e808104ce246fe872c93a4c84c837f01a8190bef Mon Sep 17 00:00:00 2001 From: failingtwice Date: Tue, 25 Nov 2025 14:48:22 +0500 Subject: [PATCH 08/12] fix: realistic sanity check --- test/integration/vaults/vaulthub.fees.ts | 17 +++++++++++++---- .../vaults/vaulthub.force-disconnect.ts | 7 +++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/test/integration/vaults/vaulthub.fees.ts b/test/integration/vaults/vaulthub.fees.ts index 64742626db..d3af1ced84 100644 --- a/test/integration/vaults/vaulthub.fees.ts +++ b/test/integration/vaults/vaulthub.fees.ts @@ -5,7 +5,6 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { Dashboard, StakingVault, VaultHub } from "typechain-types"; -import { days } from "lib"; import { changeTier, createVaultWithDashboard, @@ -53,9 +52,6 @@ describe("Integration: VaultHub:fees", () => { await changeTier(ctx, dashboard, owner, nodeOperator); await vaultHub.connect(agentSigner).grantRole(await vaultHub.VAULT_MASTER_ROLE(), vaultMaster); - - // loosen sanity checks to bypass fee increase rate limit - await ctx.contracts.lazyOracle.connect(agentSigner).updateSanityParams(days(30n), 1000n, 1000000000000000000n); }); beforeEach(async () => (snapshot = await Snapshot.take())); @@ -160,6 +156,7 @@ describe("Integration: VaultHub:fees", () => { await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("12"), cumulativeLidoFees: ether("2"), + waitForNextRefSlot: true, }); const record = await vaultHub.vaultRecord(stakingVault); @@ -186,6 +183,7 @@ describe("Integration: VaultHub:fees", () => { await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("13"), cumulativeLidoFees: ether("3"), + waitForNextRefSlot: true, }); // Verify unsettled fees @@ -222,6 +220,7 @@ describe("Integration: VaultHub:fees", () => { await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("12"), cumulativeLidoFees: ether("2"), + waitForNextRefSlot: true, }); const treasuryBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); @@ -368,6 +367,7 @@ describe("Integration: VaultHub:fees", () => { await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("1"), // Only CONNECT_DEPOSIT (locked) cumulativeLidoFees: ether("2"), + waitForNextRefSlot: true, }); // Verify fees exist @@ -410,6 +410,7 @@ describe("Integration: VaultHub:fees", () => { await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("6"), cumulativeLidoFees: ether("0.5"), + waitForNextRefSlot: true, }); let obligations = await vaultHub.obligations(stakingVault); @@ -471,6 +472,7 @@ describe("Integration: VaultHub:fees", () => { await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("6"), cumulativeLidoFees: ether("2"), + waitForNextRefSlot: true, }); const treasuryBeforeDisconnect = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); @@ -578,6 +580,7 @@ describe("Integration: VaultHub:fees", () => { await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("21"), cumulativeLidoFees: ether("3"), + waitForNextRefSlot: true, }); // Verify fees are unsettled before settlement @@ -612,6 +615,7 @@ describe("Integration: VaultHub:fees", () => { await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("11"), cumulativeLidoFees: ether("2"), + waitForNextRefSlot: true, }); // Verify deposits are paused @@ -671,6 +675,7 @@ describe("Integration: VaultHub:fees", () => { await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("21"), cumulativeLidoFees: ether("5"), + waitForNextRefSlot: true, }); // Verify fees before settlement @@ -751,6 +756,7 @@ describe("Integration: VaultHub:fees", () => { await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("11"), cumulativeLidoFees: ether("1.0"), + waitForNextRefSlot: true, }); const obligations = await vaultHub.obligations(stakingVault); @@ -766,6 +772,7 @@ describe("Integration: VaultHub:fees", () => { await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("10"), cumulativeLidoFees: ether("1") - 1n, + waitForNextRefSlot: true, }); const obligations = await vaultHub.obligations(stakingVault); @@ -780,6 +787,7 @@ describe("Integration: VaultHub:fees", () => { await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("1"), // Only locked CONNECT_DEPOSIT cumulativeLidoFees: ether("2"), + waitForNextRefSlot: true, }); const settleableValue = await vaultHub.settleableLidoFeesValue(stakingVault); @@ -798,6 +806,7 @@ describe("Integration: VaultHub:fees", () => { await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("16"), cumulativeLidoFees: ether("3"), + waitForNextRefSlot: true, }); let obligations = await vaultHub.obligations(stakingVault); diff --git a/test/integration/vaults/vaulthub.force-disconnect.ts b/test/integration/vaults/vaulthub.force-disconnect.ts index 8acacb5349..7c778b7b95 100644 --- a/test/integration/vaults/vaulthub.force-disconnect.ts +++ b/test/integration/vaults/vaulthub.force-disconnect.ts @@ -5,7 +5,7 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { Dashboard, StakingVault, VaultHub } from "typechain-types"; -import { BigIntMath, days, DISCONNECT_NOT_INITIATED, impersonate } from "lib"; +import { BigIntMath, DISCONNECT_NOT_INITIATED, impersonate } from "lib"; import { changeTier, createVaultsReportTree, @@ -55,9 +55,6 @@ describe("Integration: VaultHub:force-disconnect", () => { await changeTier(ctx, dashboard, owner, nodeOperator); await vaultHub.connect(agentSigner).grantRole(await vaultHub.VAULT_MASTER_ROLE(), vaultMaster); - - // loosen sanity checks to bypass fee increase rate limit - await ctx.contracts.lazyOracle.connect(agentSigner).updateSanityParams(days(30n), 1000n, 1000000000000000000n); }); beforeEach(async () => (snapshot = await Snapshot.take())); @@ -73,6 +70,7 @@ describe("Integration: VaultHub:force-disconnect", () => { await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("1"), cumulativeLidoFees: feesToSettle, // Assign unsettled fees to the vault + waitForNextRefSlot: true, }); // Verify initial state @@ -289,6 +287,7 @@ describe("Integration: VaultHub:force-disconnect", () => { await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("1"), cumulativeLidoFees: unsettledFees, + waitForNextRefSlot: true, }); // Ensure vault has enough balance to settle fees From 9e14dd4a791ee82225eed9b3e8295cc1d034597a Mon Sep 17 00:00:00 2001 From: failingtwice Date: Wed, 26 Nov 2025 12:26:50 +0500 Subject: [PATCH 09/12] fix: minor improvements --- test/integration/vaults/vaulthub.fees.ts | 155 +++++++++++++++-------- 1 file changed, 103 insertions(+), 52 deletions(-) diff --git a/test/integration/vaults/vaulthub.fees.ts b/test/integration/vaults/vaulthub.fees.ts index d3af1ced84..06de059149 100644 --- a/test/integration/vaults/vaulthub.fees.ts +++ b/test/integration/vaults/vaulthub.fees.ts @@ -18,6 +18,8 @@ import { ether } from "lib/units"; import { Snapshot } from "test/suite"; +const STETH_ROUNDING_MARGIN = 10n; + describe("Integration: VaultHub:fees", () => { let ctx: ProtocolContext; let snapshot: string; @@ -270,8 +272,7 @@ describe("Integration: VaultHub:fees", () => { // Action: settleLidoFees (only settles what's available) const treasuryBefore = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); - const tx = await vaultHub.settleLidoFees(stakingVault); - await tx.wait(); + await vaultHub.settleLidoFees(stakingVault); const treasuryAfter = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); const actualSettled = treasuryAfter - treasuryBefore; @@ -288,6 +289,88 @@ describe("Integration: VaultHub:fees", () => { 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") }); @@ -465,49 +548,10 @@ describe("Integration: VaultHub:fees", () => { // Disconnect still initiated despite unsettled fees expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.true; }); - - it("allows fee settlement during disconnect process", async () => { - // Setup: Vault with 5 ETH balance and 2 ETH unsettled fees - await dashboard.fund({ value: ether("5") }); - await reportVaultDataWithProof(ctx, stakingVault, { - totalValue: ether("6"), - cumulativeLidoFees: ether("2"), - waitForNextRefSlot: true, - }); - - const treasuryBeforeDisconnect = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); - - // Initiate disconnect (settles all available fees - all 2 ETH should be settled) - await vaultHub.connect(vaultMaster).disconnect(stakingVault); - expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.true; - - const treasuryAfterDisconnect = await ethers.provider.getBalance(await ctx.contracts.locator.treasury()); - const settledDuringDisconnect = treasuryAfterDisconnect - treasuryBeforeDisconnect; - - // Verify all 2 ETH fees were settled during disconnect (balance was sufficient) - expect(settledDuringDisconnect).to.equal(ether("2")); - - // Verify no fees remain after disconnect - const obligationsAfterDisconnect = await vaultHub.obligations(stakingVault); - expect(obligationsAfterDisconnect.feesToSettle).to.equal(0n); - - // Disconnect process continues (and can complete on next report since all obligations are met) - expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.true; - - // Complete the disconnect with next report - const disconnectTx = await reportVaultDataWithProof(ctx, stakingVault, { - totalValue: ether("4"), // 6 - 2 settled fees - cumulativeLidoFees: ether("2"), - waitForNextRefSlot: true, - }); - - await expect(disconnectTx).to.emit(vaultHub, "VaultDisconnectCompleted").withArgs(stakingVault); - expect(await vaultHub.isVaultConnected(stakingVault)).to.be.false; - }); }); describe("Bad debt scenarios", () => { - it("handles large unpaid fees with bad debt", async () => { + it("revert settling if bad debt", async () => { // Setup: Create bad debt situation await dashboard.fund({ value: ether("10") }); await reportVaultDataWithProof(ctx, stakingVault, { @@ -553,8 +597,10 @@ describe("Integration: VaultHub:fees", () => { }); // Check minting capacity without fees - const maxLockableValueBefore = await vaultHub.maxLockableValue(stakingVault); - expect(maxLockableValueBefore).to.equal(ether("51")); + 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.be.closeTo(ether("40.8"), STETH_ROUNDING_MARGIN); // Report unsettled fees = 10 ETH await reportVaultDataWithProof(ctx, stakingVault, { @@ -564,10 +610,12 @@ describe("Integration: VaultHub:fees", () => { }); // Check minting capacity with fees - const maxLockableValueAfter = await vaultHub.maxLockableValue(stakingVault); + const mintingCapacitySharesAfter = await vaultHub.totalMintingCapacityShares(stakingVault, 0); + const mintingCapacityAfter = await ctx.contracts.lido.getPooledEthByShares(mintingCapacitySharesAfter); - // Expected: maxLockableValue reduced by unsettled fees - expect(maxLockableValueAfter).to.equal(ether("41")); // 51 - 10 + // Expected: minting capacity reduced by unsettled fees + // (51 - 10) * 0.8 = 41 * 0.8 = 32.8 ETH + expect(mintingCapacityAfter).to.be.closeTo(ether("32.8"), STETH_ROUNDING_MARGIN); // Verify obligations are tracked const obligations = await vaultHub.obligations(stakingVault); @@ -597,11 +645,12 @@ describe("Integration: VaultHub:fees", () => { waitForNextRefSlot: true, }); - const maxLockableAfter = await vaultHub.maxLockableValue(stakingVault); + const mintingCapacitySharesAfter = await vaultHub.totalMintingCapacityShares(stakingVault, 0); + const mintingCapacityAfter = await ctx.contracts.lido.getPooledEthByShares(mintingCapacitySharesAfter); const obligationsAfter = await vaultHub.obligations(stakingVault); - // Expected: maxLockableValue should equal totalValue (no more unsettled fees) - expect(maxLockableAfter).to.equal(ether("18")); + // Expected: minting capacity = totalValue * 0.8 = 18 * 0.8 = 14.4 ETH (no more unsettled fees) + expect(mintingCapacityAfter).to.be.closeTo(ether("14.4"), STETH_ROUNDING_MARGIN); // Verify all fees have been settled expect(obligationsAfter.feesToSettle).to.equal(0n); @@ -695,8 +744,10 @@ describe("Integration: VaultHub:fees", () => { const obligationsAfter = await vaultHub.obligations(stakingVault); expect(obligationsAfter.feesToSettle).to.equal(0n); // All fees settled - const maxLockableAfter = await vaultHub.maxLockableValue(stakingVault); - expect(maxLockableAfter).to.equal(ether("16")); // No unsettled fees reducing maxLockable + 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.be.closeTo(ether("12.8"), STETH_ROUNDING_MARGIN); }); }); From e03e3ad6bfa923b758fe5031682b28123e4da8fb Mon Sep 17 00:00:00 2001 From: failingtwice Date: Mon, 1 Dec 2025 15:47:09 +0500 Subject: [PATCH 10/12] Merge branch 'feat/vaults' of https://github.com/lidofinance/core into test-batch --- .env.example | 5 + .github/workflows/coverage.yml | 2 +- .github/workflows/tests-integration-hoodi.yml | 29 + .../workflows/tests-integration-mainnet.yml | 4 +- .../workflows/tests-integration-scratch.yml | 2 +- .../tests-integration-upgrade-template.yml | 5 +- .github/workflows/verify-state-hoodi.yml | 39 + contracts/0.4.24/Lido.sol | 4 +- .../0.4.24/nos/NodeOperatorsRegistry.sol | 5 - contracts/0.8.25/vaults/LazyOracle.sol | 11 +- contracts/0.8.25/vaults/OperatorGrid.sol | 36 +- contracts/0.8.25/vaults/VaultHub.sol | 37 +- .../0.8.25/vaults/dashboard/Permissions.sol | 14 + contracts/0.8.9/Accounting.sol | 22 +- contracts/0.8.9/TokenRateNotifier.sol | 146 +++ .../0.8.9/interfaces/ITokenRatePusher.sol | 11 + contracts/common/interfaces/IVaultHub.sol | 1 + contracts/upgrade/V3Addresses.sol | 101 +- contracts/upgrade/V3Template.sol | 85 +- .../utils => upgrade}/V3TemporaryAdmin.sol | 131 ++- contracts/upgrade/V3VoteScript.sol | 211 +++- deployed-hoodi.json | 31 +- deployed-mainnet.json | 17 +- lib/account.ts | 6 +- lib/config-schemas.ts | 18 +- lib/constants.ts | 1 + lib/deploy.ts | 2 + lib/log.ts | 9 +- lib/protocol/discover.ts | 5 +- lib/protocol/helpers/accounting.ts | 12 +- lib/protocol/helpers/nor-sdvt.ts | 65 +- lib/protocol/helpers/operatorGrid.ts | 32 + lib/protocol/helpers/share-rate.ts | 34 +- lib/protocol/helpers/vaults.ts | 110 ++- lib/protocol/helpers/withdrawal.ts | 13 +- lib/protocol/networks.ts | 15 +- lib/protocol/provision.ts | 3 + lib/state-file.ts | 9 +- package.json | 3 +- scripts/dao-hoodi-v3-patch-1.sh | 4 +- scripts/dao-hoodi-v3-patch-2.sh | 16 + scripts/dao-hoodi-v3-patch-3.sh | 16 + scripts/dao-hoodi-v3-patch-4.sh | 16 + scripts/scratch/deploy-params-testnet.toml | 14 +- scripts/scratch/steps/0130-grant-roles.ts | 10 - .../upgrade/steps-upgrade-hoodi-patch-2.json | 3 + .../upgrade/steps-upgrade-hoodi-patch-3.json | 3 + .../upgrade/steps-upgrade-hoodi-patch-4.json | 3 + scripts/upgrade/steps/0000-check-env.ts | 4 + .../upgrade/steps/0100-deploy-v3-contracts.ts | 36 +- .../steps/0100-upgrade-hoodi-to-v3-rc2.ts | 2 +- .../steps/0100-upgrade-hoodi-to-v3-rc3.ts | 115 +++ .../steps/0100-upgrade-hoodi-to-v3-rc4.ts | 62 ++ .../steps/0100-upgrade-hoodi-to-v3-rc5.ts | 110 +++ .../0200-deploy-v3-upgrading-contracts.ts | 23 +- .../upgrade/steps/deploy-easy-track-mock.ts | 38 - scripts/upgrade/upgrade-params-hoodi.toml | 23 +- scripts/upgrade/upgrade-params-mainnet.toml | 49 +- scripts/utils/upgrade.ts | 21 +- tasks/validate-configs.ts | 49 +- tasks/verify-contracts.ts | 7 +- test/0.4.24/lido/lido.staking-limit.test.ts | 15 + test/0.4.24/nor/nor.aux.test.ts | 8 +- .../0.8.25/vaults/dashboard/dashboard.test.ts | 2 +- .../vaults/operatorGrid/operatorGrid.test.ts | 150 ++- .../vaults/permissions/permissions.test.ts | 14 + .../vaults/vaulthub/vaulthub.hub.test.ts | 85 +- .../accounting.handleOracleReport.test.ts | 10 +- .../contracts/Lido__MockForAccounting.sol | 5 + .../oracle/VaultHub__MockForAccReport.sol | 20 +- .../core/accounting.integration.ts | 909 +++++++----------- .../core/burn-shares.integration.ts | 24 +- .../core/dsm-keys-unvetting.integration.ts | 93 +- .../core/happy-path.integration.ts | 42 +- .../core/staking-limits.integration.ts | 11 - .../core/staking-module.integration.ts | 13 +- .../vaults/bad-debt.integration.ts | 494 +++++++++- .../vaults/disconnected.integration.ts | 8 +- .../vaults/obligations.integration.ts | 459 ++++++--- .../vaults/operator.grid.integration.ts | 95 +- .../pausable-beacon-deposits.integration.ts | 1 + test/integration/vaults/roles.integration.ts | 17 + .../vaults/scenario/happy-path.integration.ts | 1 - .../lazyOracle.bootstrap.integration.ts | 10 +- .../vaults/unhealthy.vault.integration.ts | 184 ++++ .../vaults/vaulthub.minting.integration.ts | 2 +- .../vaults/vaulthub.shortfall.integration.ts | 105 +- .../vaulthub.slashing-reserve.integration.ts | 52 +- test/suite/constants.ts | 1 + 89 files changed, 3443 insertions(+), 1297 deletions(-) create mode 100644 .github/workflows/tests-integration-hoodi.yml create mode 100644 .github/workflows/verify-state-hoodi.yml create mode 100644 contracts/0.8.9/TokenRateNotifier.sol create mode 100644 contracts/0.8.9/interfaces/ITokenRatePusher.sol rename contracts/{0.8.25/utils => upgrade}/V3TemporaryAdmin.sol (63%) create mode 100755 scripts/dao-hoodi-v3-patch-2.sh create mode 100755 scripts/dao-hoodi-v3-patch-3.sh create mode 100755 scripts/dao-hoodi-v3-patch-4.sh create mode 100644 scripts/upgrade/steps-upgrade-hoodi-patch-2.json create mode 100644 scripts/upgrade/steps-upgrade-hoodi-patch-3.json create mode 100644 scripts/upgrade/steps-upgrade-hoodi-patch-4.json create mode 100644 scripts/upgrade/steps/0100-upgrade-hoodi-to-v3-rc3.ts create mode 100644 scripts/upgrade/steps/0100-upgrade-hoodi-to-v3-rc4.ts create mode 100644 scripts/upgrade/steps/0100-upgrade-hoodi-to-v3-rc5.ts delete mode 100644 scripts/upgrade/steps/deploy-easy-track-mock.ts create mode 100644 test/integration/vaults/unhealthy.vault.integration.ts diff --git a/.env.example b/.env.example index 7cd4b67df4..eb8733fb1a 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,11 @@ MAINNET_STAKING_VAULT_FACTORY_ADDRESS= MAINNET_STAKING_VAULT_BEACON_ADDRESS= MAINNET_VALIDATOR_CONSOLIDATION_REQUESTS_ADDRESS= +HOODI_LOCATOR_ADDRESS=0xe2EF9536DAAAEBFf5b1c130957AB3E80056b06D8 +HOODI_AGENT_ADDRESS=0x0534aA41907c9631fae990960bCC72d75fA7cfeD +HOODI_VOTING_ADDRESS=0x49B3512c44891bef83F8967d075121Bd1b07a01B +HOODI_EASY_TRACK_EXECUTOR_ADDRESS=0x79a20FD0FA36453B2F45eAbab19bfef43575Ba9E + SEPOLIA_RPC_URL= HOODI_RPC_URL= diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 2c001b466d..add3c1495f 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -7,7 +7,7 @@ on: jobs: coverage: - name: Hardhat + name: Hardhat/Unit Test runs-on: ubuntu-latest env: NODE_OPTIONS: --max_old_space_size=6400 diff --git a/.github/workflows/tests-integration-hoodi.yml b/.github/workflows/tests-integration-hoodi.yml new file mode 100644 index 0000000000..596205edad --- /dev/null +++ b/.github/workflows/tests-integration-hoodi.yml @@ -0,0 +1,29 @@ +name: Integration Tests + +on: [push] + +jobs: + test_hardhat_integration_fork: + name: Hardhat / Hoodi + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + NODE_OPTIONS: --max_old_space_size=7200 + SKIP_GAS_REPORT: true + SKIP_CONTRACT_SIZE: true + SKIP_INTERFACES_CHECK: true + + steps: + - uses: actions/checkout@v4 + + - name: Common setup + uses: ./.github/workflows/setup + + - name: Run integration tests + run: yarn test:integration + env: + RPC_URL: "${{ secrets.HOODI_RPC_URL }}" + LOG_LEVEL: debug + NETWORK_STATE_FILE: deployed-hoodi.json + # TODO: enable csm integration tests later (CSM is deployed on hoodis) + INTEGRATION_WITH_CSM: "off" diff --git a/.github/workflows/tests-integration-mainnet.yml b/.github/workflows/tests-integration-mainnet.yml index c8d3aeb461..af7937deb0 100644 --- a/.github/workflows/tests-integration-mainnet.yml +++ b/.github/workflows/tests-integration-mainnet.yml @@ -1,5 +1,4 @@ -name: Integration Tests On Upgrade -# For local testing of this scenario use ./scripts/dao-upgrade-and-test-on-fork.sh +name: Integration Tests on: [push] @@ -40,6 +39,7 @@ jobs: DEPLOYER: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" # first acc of default mnemonic "test test ..." GAS_PRIORITY_FEE: 1 GAS_MAX_FEE: 100 + GAS_LIMIT: 30000000 NETWORK_STATE_FILE: deployed-mainnet-upgrade.json UPGRADE_PARAMETERS_FILE: scripts/upgrade/upgrade-params-mainnet.toml diff --git a/.github/workflows/tests-integration-scratch.yml b/.github/workflows/tests-integration-scratch.yml index f4430c12b0..cce5f5fd9c 100644 --- a/.github/workflows/tests-integration-scratch.yml +++ b/.github/workflows/tests-integration-scratch.yml @@ -1,4 +1,4 @@ -name: Integration Tests +name: Integration Tests Scratch on: [push] diff --git a/.github/workflows/tests-integration-upgrade-template.yml b/.github/workflows/tests-integration-upgrade-template.yml index 677f8462af..04a133a6fc 100644 --- a/.github/workflows/tests-integration-upgrade-template.yml +++ b/.github/workflows/tests-integration-upgrade-template.yml @@ -1,10 +1,10 @@ -name: Integration Test For Upgrade Template +name: Integration Test (Upgrade) on: [push] jobs: test_hardhat_integration_fork_template: - name: Hardhat / Upgrade Template + name: Hardhat / Mainnet runs-on: ubuntu-latest timeout-minutes: 120 env: @@ -26,3 +26,4 @@ jobs: env: RPC_URL: "${{ secrets.ETH_RPC_URL }}" UPGRADE_PARAMETERS_FILE: scripts/upgrade/upgrade-params-mainnet.toml + GAS_LIMIT: 30000000 diff --git a/.github/workflows/verify-state-hoodi.yml b/.github/workflows/verify-state-hoodi.yml new file mode 100644 index 0000000000..b4cb8551b2 --- /dev/null +++ b/.github/workflows/verify-state-hoodi.yml @@ -0,0 +1,39 @@ +name: Verify State On Hoodi + +on: + workflow_dispatch: + push: + branches: + - "feat/ci-hoodi-state" + schedule: + - cron: "0 0 * * *" # runs every day at midnight UTC + +jobs: + run_state_mate: + name: Run state-mate on hoodi + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + STATE_MATE_BRANCH: "feat/v3-hoodi" + HOODI_REMOTE_RPC_URL: "https://hoodi.drpc.org" + + steps: + - name: Checkout state-mate + uses: actions/checkout@v4 + with: + repository: lidofinance/state-mate + ref: ${{ env.STATE_MATE_BRANCH }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: yarn install + + - name: Run state-mate + run: yarn start configs/hoodi/lidov3-testnet.yaml diff --git a/contracts/0.4.24/Lido.sol b/contracts/0.4.24/Lido.sol index 5c24dca228..8fff6b8891 100644 --- a/contracts/0.4.24/Lido.sol +++ b/contracts/0.4.24/Lido.sol @@ -357,7 +357,7 @@ contract Lido is Versioned, StETHPermit, AragonApp { * * @dev Reverts if: * - `_maxStakeLimit` == 0 - * - `_maxStakeLimit` >= 2^96 + * - `_maxStakeLimit` >= 2^95 (1/2 of uint96) * - `_maxStakeLimit` < `_stakeLimitIncreasePerBlock` * - `_maxStakeLimit` / `_stakeLimitIncreasePerBlock` >= 2^32 (only if `_stakeLimitIncreasePerBlock` != 0) * @@ -367,6 +367,8 @@ contract Lido is Versioned, StETHPermit, AragonApp { function setStakingLimit(uint256 _maxStakeLimit, uint256 _stakeLimitIncreasePerBlock) external { _auth(STAKING_CONTROL_ROLE); + require(_maxStakeLimit <= uint96(-1) / 2, "TOO_LARGE_MAX_STAKE_LIMIT"); + STAKING_STATE_POSITION.setStorageStakeLimitStruct( STAKING_STATE_POSITION.getStorageStakeLimitStruct().setStakingLimit( _maxStakeLimit, diff --git a/contracts/0.4.24/nos/NodeOperatorsRegistry.sol b/contracts/0.4.24/nos/NodeOperatorsRegistry.sol index 9cc6db589c..a375b09352 100644 --- a/contracts/0.4.24/nos/NodeOperatorsRegistry.sol +++ b/contracts/0.4.24/nos/NodeOperatorsRegistry.sol @@ -276,11 +276,6 @@ contract NodeOperatorsRegistry is AragonApp, Versioned { _initialize_v4(_exitDeadlineThresholdInSeconds); } - /// @notice Overrides default AragonApp behaviour to disallow recovery - function transferToVault(address /* _token */) external { - revert("NOT_SUPPORTED"); - } - /// @notice Add node operator named `name` with reward address `rewardAddress` and staking limit = 0 validators /// @param _name Human-readable name /// @param _rewardAddress Ethereum 1 address which receives stETH rewards for this operator diff --git a/contracts/0.8.25/vaults/LazyOracle.sol b/contracts/0.8.25/vaults/LazyOracle.sol index 3843a7809a..a964d81635 100644 --- a/contracts/0.8.25/vaults/LazyOracle.sol +++ b/contracts/0.8.25/vaults/LazyOracle.sol @@ -266,16 +266,17 @@ contract LazyOracle is ILazyOracle, AccessControlEnumerableUpgradeable { } /** - * @notice batch method to mass check the validator stages in PredepositGuarantee contract + * @notice batch method to mass check the validator statuses in PredepositGuarantee contract * @param _pubkeys the array of validator's pubkeys to check + * @return batch array of IPredepositGuarantee.ValidatorStatus structs */ - function batchValidatorStages( + function batchValidatorStatuses( bytes[] calldata _pubkeys - ) external view returns (IPredepositGuarantee.ValidatorStage[] memory batch) { - batch = new IPredepositGuarantee.ValidatorStage[](_pubkeys.length); + ) external view returns (IPredepositGuarantee.ValidatorStatus[] memory batch) { + batch = new IPredepositGuarantee.ValidatorStatus[](_pubkeys.length); for (uint256 i = 0; i < _pubkeys.length; i++) { - batch[i] = predepositGuarantee().validatorStatus(_pubkeys[i]).stage; + batch[i] = predepositGuarantee().validatorStatus(_pubkeys[i]); } } diff --git a/contracts/0.8.25/vaults/OperatorGrid.sol b/contracts/0.8.25/vaults/OperatorGrid.sol index c967871fab..19b086a02d 100644 --- a/contracts/0.8.25/vaults/OperatorGrid.sol +++ b/contracts/0.8.25/vaults/OperatorGrid.sol @@ -186,7 +186,7 @@ contract OperatorGrid is AccessControlEnumerableUpgradeable, Confirmable2Address $.tiers.push( Tier({ operator: DEFAULT_TIER_OPERATOR, - shareLimit: uint96(_defaultTierParams.shareLimit), + shareLimit: SafeCast.toUint96(_defaultTierParams.shareLimit), reserveRatioBP: uint16(_defaultTierParams.reserveRatioBP), forcedRebalanceThresholdBP: uint16(_defaultTierParams.forcedRebalanceThresholdBP), infraFeeBP: uint16(_defaultTierParams.infraFeeBP), @@ -287,7 +287,7 @@ contract OperatorGrid is AccessControlEnumerableUpgradeable, Confirmable2Address Tier memory tier_ = Tier({ operator: _nodeOperator, - shareLimit: uint96(_tiers[i].shareLimit), + shareLimit: SafeCast.toUint96(_tiers[i].shareLimit), reserveRatioBP: uint16(_tiers[i].reserveRatioBP), forcedRebalanceThresholdBP: uint16(_tiers[i].forcedRebalanceThresholdBP), infraFeeBP: uint16(_tiers[i].infraFeeBP), @@ -301,12 +301,12 @@ contract OperatorGrid is AccessControlEnumerableUpgradeable, Confirmable2Address emit TierAdded( _nodeOperator, tierId, - uint96(tier_.shareLimit), - uint16(tier_.reserveRatioBP), - uint16(tier_.forcedRebalanceThresholdBP), - uint16(tier_.infraFeeBP), - uint16(tier_.liquidityFeeBP), - uint16(tier_.reservationFeeBP) + tier_.shareLimit, + tier_.reserveRatioBP, + tier_.forcedRebalanceThresholdBP, + tier_.infraFeeBP, + tier_.liquidityFeeBP, + tier_.reservationFeeBP ); tierId++; @@ -356,7 +356,7 @@ contract OperatorGrid is AccessControlEnumerableUpgradeable, Confirmable2Address Tier storage tier_ = $.tiers[_tierIds[i]]; - tier_.shareLimit = uint96(_tierParams[i].shareLimit); + tier_.shareLimit = SafeCast.toUint96(_tierParams[i].shareLimit); tier_.reserveRatioBP = uint16(_tierParams[i].reserveRatioBP); tier_.forcedRebalanceThresholdBP = uint16(_tierParams[i].forcedRebalanceThresholdBP); tier_.infraFeeBP = uint16(_tierParams[i].infraFeeBP); @@ -454,11 +454,15 @@ contract OperatorGrid is AccessControlEnumerableUpgradeable, Confirmable2Address revert RequestedShareLimitTooHigh(_requestedShareLimit, requestedTier.shareLimit); } - address vaultOwner = vaultHub.vaultConnection(_vault).owner; + uint256 vaultLiabilityShares = vaultHub.liabilityShares(_vault); + if (_requestedShareLimit < vaultLiabilityShares) { + revert RequestedShareLimitTooLow(_requestedShareLimit, vaultLiabilityShares); + } + + address vaultOwner = vaultHub.vaultConnection(_vault).owner; // store the caller's confirmation; only proceed if the required number of confirmations is met. if (!_collectAndCheckConfirmations(msg.data, vaultOwner, nodeOperator)) return false; - uint256 vaultLiabilityShares = vaultHub.liabilityShares(_vault); // check if tier limit is exceeded if (requestedTier.liabilityShares + vaultLiabilityShares > requestedTier.shareLimit) revert TierLimitExceeded(); @@ -548,6 +552,12 @@ contract OperatorGrid is AccessControlEnumerableUpgradeable, Confirmable2Address if (_requestedShareLimit > tierShareLimit) revert RequestedShareLimitTooHigh(_requestedShareLimit, tierShareLimit); if (_requestedShareLimit == vaultConnection.shareLimit) revert ShareLimitAlreadySet(); + uint256 vaultLiabilityShares = vaultHub.liabilityShares(_vault); + + if (_requestedShareLimit < vaultLiabilityShares) { + revert RequestedShareLimitTooLow(_requestedShareLimit, vaultLiabilityShares); + } + // store the caller's confirmation; only proceed if the required number of confirmations is met. if (!_collectAndCheckConfirmations(msg.data, vaultOwner, nodeOperator)) return false; @@ -787,8 +797,9 @@ contract OperatorGrid is AccessControlEnumerableUpgradeable, Confirmable2Address revert ReserveRatioTooHigh(_tierId, _reserveRatioBP, MAX_RESERVE_RATIO_BP); if (_forcedRebalanceThresholdBP == 0) revert ZeroArgument("_forcedRebalanceThresholdBP"); - if (_forcedRebalanceThresholdBP >= _reserveRatioBP) + if (_forcedRebalanceThresholdBP + 10 >= _reserveRatioBP) { revert ForcedRebalanceThresholdTooHigh(_tierId, _forcedRebalanceThresholdBP, _reserveRatioBP); + } if (_infraFeeBP > MAX_FEE_BP) revert InfraFeeTooHigh(_tierId, _infraFeeBP, MAX_FEE_BP); @@ -885,6 +896,7 @@ contract OperatorGrid is AccessControlEnumerableUpgradeable, Confirmable2Address error ReservationFeeTooHigh(uint256 tierId, uint256 reservationFeeBP, uint256 maxReservationFeeBP); error ArrayLengthMismatch(); error RequestedShareLimitTooHigh(uint256 requestedShareLimit, uint256 tierShareLimit); + error RequestedShareLimitTooLow(uint256 requestedSHareLimit, uint256 vaultShares); error VaultNotConnected(); error VaultAlreadySyncedWithTier(); error ShareLimitAlreadySet(); diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index af8757a86f..4571053a3a 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -357,9 +357,12 @@ contract VaultHub is PausableUntilWithRoles { } /// @notice amount of bad debt to be internalized to become the protocol loss - /// @return the number of shares to internalize as bad debt during the oracle report - /// @dev the value is lagging increases that was done after the current refSlot to the next one function badDebtToInternalize() external view returns (uint256) { + return _storage().badDebtToInternalize.value; + } + + /// @notice amount of bad debt to be internalized to become the protocol loss (that was actual on the last refSlot) + function badDebtToInternalizeForLastRefSlot() external view returns (uint256) { return _storage().badDebtToInternalize.getValueForLastRefSlot(CONSENSUS_CONTRACT); } @@ -432,7 +435,6 @@ contract VaultHub is PausableUntilWithRoles { } /// @notice updates the vault's connection parameters - /// @dev Reverts if the vault is not healthy as of latest report /// @param _vault vault address /// @param _shareLimit new share limit /// @param _reserveRatioBP new reserve ratio @@ -440,6 +442,7 @@ contract VaultHub is PausableUntilWithRoles { /// @param _infraFeeBP new infra fee /// @param _liquidityFeeBP new liquidity fee /// @param _reservationFeeBP new reservation fee + /// @dev reverts if the vault's minting capacity will be exceeded with new reserve parameters /// @dev requires the fresh report function updateConnection( address _vault, @@ -453,16 +456,22 @@ contract VaultHub is PausableUntilWithRoles { _requireSender(address(_operatorGrid())); _requireSaneShareLimit(_shareLimit); - VaultConnection storage connection = _checkConnection(_vault); - VaultRecord storage record = _vaultRecord(_vault); + VaultConnection storage connection = _vaultConnection(_vault); + _requireConnected(connection, _vault); + VaultRecord storage record = _vaultRecord(_vault); _requireFreshReport(_vault, record); - uint256 totalValue_ = _totalValue(record); - uint256 liabilityShares_ = record.liabilityShares; + if ( + _reserveRatioBP != connection.reserveRatioBP || + _forcedRebalanceThresholdBP != connection.forcedRebalanceThresholdBP + ) { + uint256 totalValue_ = _totalValue(record); + uint256 liabilityShares_ = record.liabilityShares; - if (_isThresholdBreached(totalValue_, liabilityShares_, _reserveRatioBP)) { - revert VaultMintingCapacityExceeded(_vault, totalValue_, liabilityShares_, _reserveRatioBP); + if (_isThresholdBreached(totalValue_, liabilityShares_, _reserveRatioBP)) { + revert VaultMintingCapacityExceeded(_vault, totalValue_, liabilityShares_, _reserveRatioBP); + } } // special event for the Oracle to track fee calculation @@ -1227,6 +1236,9 @@ contract VaultHub is PausableUntilWithRoles { return type(uint256).max; } + // if not healthy and low in debt, please rebalance the whole amount + if (liabilityShares_ <= 100) return liabilityShares_; + // Solve the equation for X: // L - liability, TV - totalValue // MR - maxMintableRatio, 100 - TOTAL_BASIS_POINTS, RR - reserveRatio @@ -1242,10 +1254,11 @@ contract VaultHub is PausableUntilWithRoles { // X = (L * 100 - TV * MR) / (100 - MR) // RR = 100 - MR // X = (L * 100 - TV * MR) / RR - uint256 shortfallEth = (liability * TOTAL_BASIS_POINTS - totalValue_ * maxMintableRatio) / reserveRatioBP; + uint256 shortfallEth = Math256.ceilDiv(liability * TOTAL_BASIS_POINTS - totalValue_ * maxMintableRatio, + reserveRatioBP); - // Add 10 extra shares to avoid dealing with rounding/precision issues - uint256 shortfallShares = _getSharesByPooledEth(shortfallEth) + 10; + // Add 100 extra shares to avoid dealing with rounding/precision issues + uint256 shortfallShares = _getSharesByPooledEth(shortfallEth) + 100; return Math256.min(shortfallShares, liabilityShares_); } diff --git a/contracts/0.8.25/vaults/dashboard/Permissions.sol b/contracts/0.8.25/vaults/dashboard/Permissions.sol index 59a4be5b63..5b562b3fba 100644 --- a/contracts/0.8.25/vaults/dashboard/Permissions.sol +++ b/contracts/0.8.25/vaults/dashboard/Permissions.sol @@ -5,6 +5,8 @@ pragma solidity 0.8.25; import {Clones} from "@openzeppelin/contracts-v5.2/proxy/Clones.sol"; +import {AccessControl} from "@openzeppelin/contracts-v5.2/access/AccessControl.sol"; +import {IAccessControl} from "@openzeppelin/contracts-v5.2/access/IAccessControl.sol"; import {AccessControlConfirmable} from "contracts/0.8.25/utils/AccessControlConfirmable.sol"; import {ILidoLocator} from "contracts/common/interfaces/ILidoLocator.sol"; @@ -176,6 +178,13 @@ abstract contract Permissions is AccessControlConfirmable { } } + /** + * @notice Role renouncement is disabled to avoid accidental access loss. + */ + function renounceRole(bytes32, address) public pure override(AccessControl, IAccessControl) { + revert RoleRenouncementDisabled(); + } + /** * @dev A custom modifier that checks if the caller has a role or the admin role for a given role. * @param _role The role to check. @@ -370,4 +379,9 @@ abstract contract Permissions is AccessControlConfirmable { * @notice Error thrown for when a given address cannot be zero */ error ZeroAddress(); + + /** + * @notice Error thrown when attempting to renounce a role. + */ + error RoleRenouncementDisabled(); } diff --git a/contracts/0.8.9/Accounting.sol b/contracts/0.8.9/Accounting.sol index f801ead667..975b428e0f 100644 --- a/contracts/0.8.9/Accounting.sol +++ b/contracts/0.8.9/Accounting.sol @@ -117,7 +117,7 @@ contract Accounting { ) external view returns (CalculatedValues memory update) { Contracts memory contracts = _loadOracleReportContracts(); - PreReportState memory pre = _snapshotPreReportState(contracts); + PreReportState memory pre = _snapshotPreReportState(contracts, true); return _simulateOracleReport(contracts, pre, _report); } @@ -128,19 +128,26 @@ contract Accounting { Contracts memory contracts = _loadOracleReportContracts(); if (msg.sender != contracts.accountingOracle) revert NotAuthorized("handleOracleReport", msg.sender); - PreReportState memory pre = _snapshotPreReportState(contracts); + PreReportState memory pre = _snapshotPreReportState(contracts, false); CalculatedValues memory update = _simulateOracleReport(contracts, pre, _report); _applyOracleReportContext(contracts, _report, pre, update); } /// @dev reads the current state of the protocol to the memory - function _snapshotPreReportState(Contracts memory _contracts) internal view returns (PreReportState memory pre) { + function _snapshotPreReportState(Contracts memory _contracts, bool isSimulation) internal view returns (PreReportState memory pre) { (pre.depositedValidators, pre.clValidators, pre.clBalance) = LIDO.getBeaconStat(); pre.totalPooledEther = LIDO.getTotalPooledEther(); pre.totalShares = LIDO.getTotalShares(); pre.externalShares = LIDO.getExternalShares(); pre.externalEther = LIDO.getExternalEther(); - pre.badDebtToInternalize = _contracts.vaultHub.badDebtToInternalize(); + + if (isSimulation) { + // for simulation we specifically fetch the current value, because during the refSlot `LastRefSlot` method + // will return the previous refSlot value, but Oracle use simulation to gather the current refSlot info + pre.badDebtToInternalize = _contracts.vaultHub.badDebtToInternalize(); + } else { + pre.badDebtToInternalize = _contracts.vaultHub.badDebtToInternalizeForLastRefSlot(); + } } /// @dev calculates all the state changes that is required to apply the report @@ -359,6 +366,9 @@ contract Accounting { ); if (_update.sharesToMintAsFees > 0) { + // this is a final action that changes share rate. + // so all transfers after this mint will reflect the actual postShareRate + LIDO.mintShares(address(this), _update.sharesToMintAsFees); _distributeFee(_update.feeDistribution); // important to have this callback last for modules to have updated state _contracts.stakingRouter.reportRewardsMinted( @@ -434,13 +444,13 @@ contract Accounting { for (uint256 i; i < length; ++i) { uint256 moduleShares = sharesToMint[i]; if (moduleShares > 0) { - LIDO.mintShares(recipients[i], moduleShares); + LIDO.transferShares(recipients[i], moduleShares); } } uint256 treasuryShares = _feeDistribution.treasurySharesToMint; if (treasuryShares > 0) { // zero is an edge case when all fees goes to modules - LIDO.mintShares(LIDO_LOCATOR.treasury(), treasuryShares); + LIDO.transferShares(LIDO_LOCATOR.treasury(), treasuryShares); } } diff --git a/contracts/0.8.9/TokenRateNotifier.sol b/contracts/0.8.9/TokenRateNotifier.sol new file mode 100644 index 0000000000..e5443e03cf --- /dev/null +++ b/contracts/0.8.9/TokenRateNotifier.sol @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.9; + +import {Ownable} from "@openzeppelin/contracts-v4.4/access/Ownable.sol"; +import {ERC165Checker} from "@openzeppelin/contracts-v4.4/utils/introspection/ERC165Checker.sol"; +import {ITokenRatePusher} from "./interfaces/ITokenRatePusher.sol"; +import {IPostTokenRebaseReceiver} from "./interfaces/IPostTokenRebaseReceiver.sol"; + + +/// @author kovalgek +/// @notice Notifies all `observers` when rebase event occurs. +contract TokenRateNotifier is Ownable, IPostTokenRebaseReceiver { + using ERC165Checker for address; + + /// @notice Address of lido core protocol accounting contract that is allowed to call handlePostTokenRebase. + address public immutable TOKEN_RATE_PROVIDER; + + /// @notice Maximum amount of observers to be supported. + uint256 public constant MAX_OBSERVERS_COUNT = 32; + + /// @notice A value that indicates that value was not found. + uint256 public constant INDEX_NOT_FOUND = type(uint256).max; + + /// @notice An interface that each observer should support. + bytes4 public constant REQUIRED_INTERFACE = type(ITokenRatePusher).interfaceId; + + /// @notice All observers. + address[] public observers; + + /// @param initialOwner_ initial owner + /// @param tokenRateProvider_ Address of token rate provider contract that is allowed to call handlePostTokenRebase. + constructor(address initialOwner_, address tokenRateProvider_) { + if (initialOwner_ == address(0)) { + revert ErrorZeroAddressOwner(); + } + if (tokenRateProvider_ == address(0)) { + revert ErrorZeroAddressTokenRateProvider(); + } + _transferOwnership(initialOwner_); + TOKEN_RATE_PROVIDER = tokenRateProvider_; + } + + /// @notice Add a `observer_` to the back of array + /// @param observer_ observer address + function addObserver(address observer_) external onlyOwner { + if (observer_ == address(0)) { + revert ErrorZeroAddressObserver(); + } + if (!observer_.supportsInterface(REQUIRED_INTERFACE)) { + revert ErrorBadObserverInterface(); + } + if (observers.length >= MAX_OBSERVERS_COUNT) { + revert ErrorMaxObserversCountExceeded(); + } + if (_observerIndex(observer_) != INDEX_NOT_FOUND) { + revert ErrorAddExistedObserver(); + } + + observers.push(observer_); + emit ObserverAdded(observer_); + } + + /// @notice Remove an observer from the array + /// @param observer_ observer address to remove + function removeObserver(address observer_) external onlyOwner { + uint256 observerIndexToRemove = _observerIndex(observer_); + + if (observerIndexToRemove == INDEX_NOT_FOUND) { + revert ErrorNoObserverToRemove(); + } + if (observerIndexToRemove != observers.length - 1) { + observers[observerIndexToRemove] = observers[observers.length - 1]; + } + observers.pop(); + + emit ObserverRemoved(observer_); + } + + /// @inheritdoc IPostTokenRebaseReceiver + /// @dev Parameters aren't used because all required data further components fetch by themselves. + /// Allowed to called by Lido contract. See Lido._completeTokenRebase. + function handlePostTokenRebase( + uint256, /* reportTimestamp */ + uint256, /* timeElapsed */ + uint256, /* preTotalShares */ + uint256, /* preTotalEther */ + uint256, /* postTotalShares */ + uint256, /* postTotalEther */ + uint256 /* sharesMintedAsFees */ + ) external { + if (msg.sender != TOKEN_RATE_PROVIDER) { + revert ErrorNotAuthorizedRebaseCaller(); + } + uint256 cachedObserversLength = observers.length; + for (uint256 obIndex = 0; obIndex < cachedObserversLength; obIndex++) { + // solhint-disable-next-line no-empty-blocks + try ITokenRatePusher(observers[obIndex]).pushTokenRate() {} + catch (bytes memory lowLevelRevertData) { + /// @dev This check is required to prevent incorrect gas estimation of the method. + /// Without it, Ethereum nodes that use binary search for gas estimation may + /// return an invalid value when the pushTokenRate() reverts because of the + /// "out of gas" error. Here we assume that the pushTokenRate() method doesn't + /// have reverts with empty error data except "out of gas". + if (lowLevelRevertData.length == 0) revert ErrorTokenRateNotifierRevertedWithNoData(); + emit PushTokenRateFailed( + observers[obIndex], + lowLevelRevertData + ); + } + } + } + + /// @notice Observer length + /// @return Added `observers` count + function observersLength() external view returns (uint256) { + return observers.length; + } + + /// @notice `observer_` index in `observers` array. + /// @return An index of `observer_` or `INDEX_NOT_FOUND` if it wasn't found. + function _observerIndex(address observer_) internal view returns (uint256) { + uint256 cachedObserversLength = observers.length; + for (uint256 obIndex = 0; obIndex < cachedObserversLength; obIndex++) { + if (observers[obIndex] == observer_) { + return obIndex; + } + } + return INDEX_NOT_FOUND; + } + + event PushTokenRateFailed(address indexed observer, bytes lowLevelRevertData); + event ObserverAdded(address indexed observer); + event ObserverRemoved(address indexed observer); + + error ErrorTokenRateNotifierRevertedWithNoData(); + error ErrorZeroAddressObserver(); + error ErrorBadObserverInterface(); + error ErrorMaxObserversCountExceeded(); + error ErrorNoObserverToRemove(); + error ErrorZeroAddressOwner(); + error ErrorZeroAddressTokenRateProvider(); + error ErrorNotAuthorizedRebaseCaller(); + error ErrorAddExistedObserver(); +} diff --git a/contracts/0.8.9/interfaces/ITokenRatePusher.sol b/contracts/0.8.9/interfaces/ITokenRatePusher.sol new file mode 100644 index 0000000000..bf9886637e --- /dev/null +++ b/contracts/0.8.9/interfaces/ITokenRatePusher.sol @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2024 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.9; + +/// @author kovalgek +/// @notice An interface for entity that pushes token rate. +interface ITokenRatePusher { + /// @notice Pushes token rate to L2 by depositing zero token amount. + function pushTokenRate() external; +} diff --git a/contracts/common/interfaces/IVaultHub.sol b/contracts/common/interfaces/IVaultHub.sol index 750e5fa4ab..03c167a855 100644 --- a/contracts/common/interfaces/IVaultHub.sol +++ b/contracts/common/interfaces/IVaultHub.sol @@ -8,6 +8,7 @@ pragma solidity >=0.5.0; interface IVaultHub { function badDebtToInternalize() external view returns (uint256); + function badDebtToInternalizeForLastRefSlot() external view returns (uint256); function decreaseInternalizedBadDebt(uint256 _amountOfShares) external; } diff --git a/contracts/upgrade/V3Addresses.sol b/contracts/upgrade/V3Addresses.sol index 79175df67e..d25d9222eb 100644 --- a/contracts/upgrade/V3Addresses.sol +++ b/contracts/upgrade/V3Addresses.sol @@ -44,11 +44,13 @@ contract V3Addresses { address oldLocatorImpl; address oldLidoImpl; address oldAccountingOracleImpl; + address oldTokenRateNotifier; // New implementations address newLocatorImpl; address newLidoImpl; address newAccountingOracleImpl; + address newTokenRateNotifier; // New fancy proxy and blueprint contracts address upgradeableBeacon; @@ -56,9 +58,6 @@ contract V3Addresses { address dashboardImpl; address gateSealForVaults; - // EasyTrack addresses - address vaultsAdapter; - // Existing proxies and contracts address kernel; address agent; @@ -67,6 +66,22 @@ contract V3Addresses { address voting; address dualGovernance; address acl; + address resealManager; + + // EasyTrack addresses + address easyTrack; + address vaultsAdapter; + + // EasyTrack new factories + address etfAlterTiersInOperatorGrid; + address etfRegisterGroupsInOperatorGrid; + address etfRegisterTiersInOperatorGrid; + address etfUpdateGroupsShareLimitInOperatorGrid; + address etfSetJailStatusInOperatorGrid; + address etfUpdateVaultsFeesInOperatorGrid; + address etfForceValidatorExitsInVaultHub; + address etfSetLiabilitySharesTargetInVaultHub; + address etfSocializeBadDebtInVaultHub; } string public constant CURATED_MODULE_NAME = "curated-onchain-v1"; @@ -80,6 +95,7 @@ contract V3Addresses { address public immutable OLD_BURNER; address public immutable OLD_ACCOUNTING_ORACLE_IMPL; address public immutable OLD_LIDO_IMPL; + address public immutable OLD_TOKEN_RATE_NOTIFIER; // // -------- Upgraded contracts -------- @@ -92,6 +108,7 @@ contract V3Addresses { address public immutable ORACLE_REPORT_SANITY_CHECKER; address public immutable NEW_LIDO_IMPL; address public immutable NEW_ACCOUNTING_ORACLE_IMPL; + address public immutable NEW_TOKEN_RATE_NOTIFIER; // // -------- New V3 contracts -------- @@ -110,9 +127,24 @@ contract V3Addresses { // // -------- EasyTrack addresses -------- // - address public immutable VAULTS_ADAPTER; + + address public immutable EASY_TRACK; + address public immutable EVM_SCRIPT_EXECUTOR; + address public immutable VAULTS_ADAPTER; + + // ETF = EasyTrack Factory + address public immutable ETF_ALTER_TIERS_IN_OPERATOR_GRID; + address public immutable ETF_REGISTER_GROUPS_IN_OPERATOR_GRID; + address public immutable ETF_REGISTER_TIERS_IN_OPERATOR_GRID; + address public immutable ETF_SET_JAIL_STATUS_IN_OPERATOR_GRID; + address public immutable ETF_SET_LIABILITY_SHARES_TARGET_IN_VAULT_HUB; + address public immutable ETF_SOCIALIZE_BAD_DEBT_IN_VAULT_HUB; + address public immutable ETF_UPDATE_GROUPS_SHARE_LIMIT_IN_OPERATOR_GRID; + address public immutable ETF_UPDATE_VAULTS_FEES_IN_OPERATOR_GRID; + address public immutable ETF_FORCE_VALIDATOR_EXITS_IN_VAULT_HUB; + // // -------- Unchanged contracts -------- // @@ -131,6 +163,7 @@ contract V3Addresses { address public immutable SIMPLE_DVT; address public immutable CSM_ACCOUNTING; address public immutable ORACLE_DAEMON_CONFIG; + address public immutable RESEAL_MANAGER; constructor( V3AddressesParams memory params @@ -139,6 +172,10 @@ contract V3Addresses { revert NewAndOldLocatorImplementationsMustBeDifferent(); } + if (params.oldTokenRateNotifier == params.newTokenRateNotifier) { + revert OldAndNewTokenRateNotifiersMustBeDifferent(); + } + // // Set directly from passed parameters // @@ -147,10 +184,12 @@ contract V3Addresses { OLD_LOCATOR_IMPL = params.oldLocatorImpl; OLD_ACCOUNTING_ORACLE_IMPL = params.oldAccountingOracleImpl; OLD_LIDO_IMPL = params.oldLidoImpl; + OLD_TOKEN_RATE_NOTIFIER = params.oldTokenRateNotifier; LOCATOR = params.locator; NEW_LOCATOR_IMPL = params.newLocatorImpl; NEW_LIDO_IMPL = params.newLidoImpl; NEW_ACCOUNTING_ORACLE_IMPL = params.newAccountingOracleImpl; + NEW_TOKEN_RATE_NOTIFIER = params.newTokenRateNotifier; KERNEL = params.kernel; AGENT = params.agent; ARAGON_APP_LIDO_REPO = params.aragonAppLidoRepo; @@ -162,7 +201,19 @@ contract V3Addresses { DASHBOARD_IMPL = params.dashboardImpl; GATE_SEAL = params.gateSealForVaults; EVM_SCRIPT_EXECUTOR = IVaultsAdapter(params.vaultsAdapter).evmScriptExecutor(); + + EASY_TRACK = params.easyTrack; VAULTS_ADAPTER = params.vaultsAdapter; + ETF_ALTER_TIERS_IN_OPERATOR_GRID = params.etfAlterTiersInOperatorGrid; + ETF_REGISTER_GROUPS_IN_OPERATOR_GRID = params.etfRegisterGroupsInOperatorGrid; + ETF_REGISTER_TIERS_IN_OPERATOR_GRID = params.etfRegisterTiersInOperatorGrid; + ETF_SET_JAIL_STATUS_IN_OPERATOR_GRID = params.etfSetJailStatusInOperatorGrid; + ETF_SET_LIABILITY_SHARES_TARGET_IN_VAULT_HUB = params.etfSetLiabilitySharesTargetInVaultHub; + ETF_SOCIALIZE_BAD_DEBT_IN_VAULT_HUB = params.etfSocializeBadDebtInVaultHub; + ETF_UPDATE_GROUPS_SHARE_LIMIT_IN_OPERATOR_GRID = params.etfUpdateGroupsShareLimitInOperatorGrid; + ETF_UPDATE_VAULTS_FEES_IN_OPERATOR_GRID = params.etfUpdateVaultsFeesInOperatorGrid; + ETF_FORCE_VALIDATOR_EXITS_IN_VAULT_HUB = params.etfForceValidatorExitsInVaultHub; + // // Discovered via other contracts // @@ -187,26 +238,46 @@ contract V3Addresses { WITHDRAWAL_QUEUE = newLocatorImpl.withdrawalQueue(); WSTETH = newLocatorImpl.wstETH(); ORACLE_DAEMON_CONFIG = newLocatorImpl.oracleDaemonConfig(); + RESEAL_MANAGER = params.resealManager; { // Retrieve contracts with burner allowances to migrate: NOR, SDVT and CSM ACCOUNTING + bytes32 curatedHash = _hash(CURATED_MODULE_NAME); + bytes32 simpleDvtHash = _hash(SIMPLE_DVT_MODULE_NAME); + bytes32 csmHash = _hash(CSM_MODULE_NAME); + + address nodeOperatorsRegistry; + address simpleDvt; + address csmAccounting; + IStakingRouter.StakingModule[] memory stakingModules = IStakingRouter(STAKING_ROUTER).getStakingModules(); - IStakingRouter.StakingModule memory curated = stakingModules[0]; - if (_hash(curated.name) != _hash(CURATED_MODULE_NAME)) revert IncorrectStakingModuleName(curated.name); - NODE_OPERATORS_REGISTRY = curated.stakingModuleAddress; - IStakingRouter.StakingModule memory simpleDvt = stakingModules[1]; - if (_hash(simpleDvt.name) != _hash(SIMPLE_DVT_MODULE_NAME)) revert IncorrectStakingModuleName(simpleDvt.name); - SIMPLE_DVT = simpleDvt.stakingModuleAddress; - IStakingRouter.StakingModule memory csm = stakingModules[2]; - if (_hash(csm.name) != _hash(CSM_MODULE_NAME)) revert IncorrectStakingModuleName(csm.name); - CSM_ACCOUNTING = ICSModule(csm.stakingModuleAddress).accounting(); + + for (uint256 i = 0; i < stakingModules.length; i++) { + bytes32 nameHash = _hash(stakingModules[i].name); + if (nameHash == curatedHash) { + nodeOperatorsRegistry = stakingModules[i].stakingModuleAddress; + } else if (nameHash == simpleDvtHash) { + simpleDvt = stakingModules[i].stakingModuleAddress; + } else if (nameHash == csmHash) { + csmAccounting = ICSModule(stakingModules[i].stakingModuleAddress).accounting(); + } + } + + if (nodeOperatorsRegistry == address(0)) revert StakingModuleNotFound(CURATED_MODULE_NAME); + if (simpleDvt == address(0)) revert StakingModuleNotFound(SIMPLE_DVT_MODULE_NAME); + if (csmAccounting == address(0)) revert StakingModuleNotFound(CSM_MODULE_NAME); + + NODE_OPERATORS_REGISTRY = nodeOperatorsRegistry; + SIMPLE_DVT = simpleDvt; + CSM_ACCOUNTING = csmAccounting; } } function _hash(string memory input) internal pure returns (bytes32) { - return keccak256(abi.encodePacked(input)); + return keccak256(bytes(input)); } error NewAndOldLocatorImplementationsMustBeDifferent(); - error IncorrectStakingModuleName(string name); + error OldAndNewTokenRateNotifiersMustBeDifferent(); + error StakingModuleNotFound(string moduleName); } diff --git a/contracts/upgrade/V3Template.sol b/contracts/upgrade/V3Template.sol index bdc54b46f0..a4f6f30dd3 100644 --- a/contracts/upgrade/V3Template.sol +++ b/contracts/upgrade/V3Template.sol @@ -13,7 +13,6 @@ import {IOssifiableProxy} from "contracts/common/interfaces/IOssifiableProxy.sol import {ILido} from "contracts/common/interfaces/ILido.sol"; import {VaultHub} from "contracts/0.8.25/vaults/VaultHub.sol"; -import {LazyOracle} from "contracts/0.8.25/vaults/LazyOracle.sol"; import {VaultFactory} from "contracts/0.8.25/vaults/VaultFactory.sol"; import {OperatorGrid} from "contracts/0.8.25/vaults/OperatorGrid.sol"; import {PausableUntilWithRoles} from "contracts/0.8.25/utils/PausableUntilWithRoles.sol"; @@ -24,6 +23,10 @@ interface IBaseOracle is IAccessControlEnumerable, IVersioned { function getConsensusContract() external view returns (address); } +interface IEasyTrack { + function getEVMScriptFactories() external view returns (address[] memory); +} + interface IStakingRouter is IAccessControlEnumerable { function REPORT_REWARDS_MINTED_ROLE() external view returns (bytes32); } @@ -65,6 +68,16 @@ interface IOracleReportSanityChecker is IAccessControlEnumerable { function INITIAL_SLASHING_AND_PENALTIES_MANAGER_ROLE() external view returns (bytes32); } +interface ITokenRateNotifier { + function owner() external view returns (address); + function observers(uint256 index) external view returns (address); + function observersLength() external view returns (uint256); +} + +interface ILazyOracle { + function UPDATE_SANITY_PARAMS_ROLE() external view returns (bytes32); +} + /** * @title Lido V3 Upgrade Template @@ -210,6 +223,7 @@ contract V3Template is V3Addresses { _assertFinalACL(); + _checkTokenRateNotifierMigratedCorrectly(); _checkBurnerMigratedCorrectly(); if (VaultFactory(VAULT_FACTORY).BEACON() != UPGRADEABLE_BEACON) { @@ -244,12 +258,11 @@ contract V3Template is V3Addresses { _assertProxyAdmin(IOssifiableProxy(VAULT_HUB), AGENT); _assertSingleOZRoleHolder(VAULT_HUB, DEFAULT_ADMIN_ROLE, AGENT); - _assertSingleOZRoleHolder(VAULT_HUB, VaultHub(VAULT_HUB).VAULT_MASTER_ROLE(), AGENT); - _assertTwoOZRoleHolders(VAULT_HUB, VaultHub(VAULT_HUB).REDEMPTION_MASTER_ROLE(), AGENT, VAULTS_ADAPTER); - + _assertSingleOZRoleHolder(VAULT_HUB, VaultHub(VAULT_HUB).VAULT_MASTER_ROLE(), AGENT); _assertSingleOZRoleHolder(VAULT_HUB, VaultHub(VAULT_HUB).VALIDATOR_EXIT_ROLE(), VAULTS_ADAPTER); _assertSingleOZRoleHolder(VAULT_HUB, VaultHub(VAULT_HUB).BAD_DEBT_MASTER_ROLE(), VAULTS_ADAPTER); - _assertSingleOZRoleHolder(VAULT_HUB, PausableUntilWithRoles(VAULT_HUB).PAUSE_ROLE(), GATE_SEAL); + _assertTwoOZRoleHolders(VAULT_HUB, PausableUntilWithRoles(VAULT_HUB).PAUSE_ROLE(), GATE_SEAL, RESEAL_MANAGER); + _assertSingleOZRoleHolder(VAULT_HUB, PausableUntilWithRoles(VAULT_HUB).RESUME_ROLE(), RESEAL_MANAGER); // OperatorGrid _assertProxyAdmin(IOssifiableProxy(OPERATOR_GRID), AGENT); @@ -259,7 +272,7 @@ contract V3Template is V3Addresses { // LazyOracle _assertProxyAdmin(IOssifiableProxy(LAZY_ORACLE), AGENT); _assertSingleOZRoleHolder(LAZY_ORACLE, DEFAULT_ADMIN_ROLE, AGENT); - _assertSingleOZRoleHolder(LAZY_ORACLE, LazyOracle(LAZY_ORACLE).UPDATE_SANITY_PARAMS_ROLE(), AGENT); + _assertZeroOZRoleHolders(LAZY_ORACLE, ILazyOracle(LAZY_ORACLE).UPDATE_SANITY_PARAMS_ROLE()); // AccountingOracle _assertSingleOZRoleHolder(ACCOUNTING_ORACLE, DEFAULT_ADMIN_ROLE, AGENT); @@ -291,11 +304,62 @@ contract V3Template is V3Addresses { // PredepositGuarantee _assertProxyAdmin(IOssifiableProxy(PREDEPOSIT_GUARANTEE), AGENT); _assertSingleOZRoleHolder(PREDEPOSIT_GUARANTEE, DEFAULT_ADMIN_ROLE, AGENT); - _assertSingleOZRoleHolder(PREDEPOSIT_GUARANTEE, PausableUntilWithRoles(PREDEPOSIT_GUARANTEE).PAUSE_ROLE(), GATE_SEAL); - + _assertTwoOZRoleHolders(PREDEPOSIT_GUARANTEE, PausableUntilWithRoles(PREDEPOSIT_GUARANTEE).PAUSE_ROLE(), GATE_SEAL, RESEAL_MANAGER); + _assertSingleOZRoleHolder(PREDEPOSIT_GUARANTEE, PausableUntilWithRoles(PREDEPOSIT_GUARANTEE).RESUME_ROLE(), RESEAL_MANAGER); + // StakingRouter bytes32 reportRewardsMintedRole = IStakingRouter(STAKING_ROUTER).REPORT_REWARDS_MINTED_ROLE(); _assertSingleOZRoleHolder(STAKING_ROUTER, reportRewardsMintedRole, ACCOUNTING); + + _assertEasyTrackFactoriesAdded(); + } + + function _assertEasyTrackFactoriesAdded() internal view { + IEasyTrack easyTrack = IEasyTrack(EASY_TRACK); + address[] memory factories = easyTrack.getEVMScriptFactories(); + + // The expected order of the last 9 EasyTrack factories + address[9] memory expectedFactories = [ + ETF_ALTER_TIERS_IN_OPERATOR_GRID, + ETF_REGISTER_GROUPS_IN_OPERATOR_GRID, + ETF_REGISTER_TIERS_IN_OPERATOR_GRID, + ETF_UPDATE_GROUPS_SHARE_LIMIT_IN_OPERATOR_GRID, + ETF_SET_JAIL_STATUS_IN_OPERATOR_GRID, + ETF_UPDATE_VAULTS_FEES_IN_OPERATOR_GRID, + ETF_FORCE_VALIDATOR_EXITS_IN_VAULT_HUB, + ETF_SET_LIABILITY_SHARES_TARGET_IN_VAULT_HUB, + ETF_SOCIALIZE_BAD_DEBT_IN_VAULT_HUB + ]; + + uint256 numFactories = factories.length; + if (numFactories < expectedFactories.length) { + revert UnexpectedEasyTrackFactories(); + } + + for (uint256 i = 0; i < expectedFactories.length; ++i) { + if (factories[numFactories - expectedFactories.length + i] != expectedFactories[i]) { + revert UnexpectedEasyTrackFactories(); + } + } + } + + function _checkTokenRateNotifierMigratedCorrectly() internal view { + ITokenRateNotifier oldNotifier = ITokenRateNotifier(OLD_TOKEN_RATE_NOTIFIER); + ITokenRateNotifier newNotifier = ITokenRateNotifier(NEW_TOKEN_RATE_NOTIFIER); + + if (newNotifier.owner() != AGENT) { + revert IncorrectTokenRateNotifierOwnerMigration(NEW_TOKEN_RATE_NOTIFIER, AGENT); + } + + if (oldNotifier.observersLength() != newNotifier.observersLength()) { + revert IncorrectTokenRateNotifierObserversLengthMigration(); + } + + for (uint256 i = 0; i < oldNotifier.observersLength(); i++) { + if (oldNotifier.observers(i) != newNotifier.observers(i)) { + revert IncorrectTokenRateNotifierObserversMigration(); + } + } } function _checkBurnerMigratedCorrectly() internal view { @@ -430,7 +494,6 @@ contract V3Template is V3Addresses { error IncorrectOZAccessControlRoleHolders(address contractAddress, bytes32 role); error NonZeroRoleHolders(address contractAddress, bytes32 role); error IncorrectAragonAppImplementation(address repo, address implementation); - error StartAndFinishMustBeInSameBlock(); error StartAndFinishMustBeInSameTx(); error StartAlreadyCalledInThisTx(); error Expired(); @@ -442,4 +505,8 @@ contract V3Template is V3Addresses { error IncorrectUpgradeableBeaconOwner(address beacon, address owner); error IncorrectUpgradeableBeaconImplementation(address beacon, address implementation); error TotalSharesOrPooledEtherChanged(); + error UnexpectedEasyTrackFactories(); + error IncorrectTokenRateNotifierOwnerMigration(address notifier, address owner); + error IncorrectTokenRateNotifierObserversLengthMigration(); + error IncorrectTokenRateNotifierObserversMigration(); } diff --git a/contracts/0.8.25/utils/V3TemporaryAdmin.sol b/contracts/upgrade/V3TemporaryAdmin.sol similarity index 63% rename from contracts/0.8.25/utils/V3TemporaryAdmin.sol rename to contracts/upgrade/V3TemporaryAdmin.sol index f64d3d02d9..bd80bd5ff0 100644 --- a/contracts/0.8.25/utils/V3TemporaryAdmin.sol +++ b/contracts/upgrade/V3TemporaryAdmin.sol @@ -8,17 +8,13 @@ import {IAccessControl} from "@openzeppelin/contracts-v4.4/access/AccessControl. interface IVaultHub { function VAULT_MASTER_ROLE() external view returns (bytes32); - function REDEMPTION_MASTER_ROLE() external view returns (bytes32); function VALIDATOR_EXIT_ROLE() external view returns (bytes32); function BAD_DEBT_MASTER_ROLE() external view returns (bytes32); } interface IPausableUntilWithRoles { function PAUSE_ROLE() external view returns (bytes32); -} - -interface ILazyOracle { - function UPDATE_SANITY_PARAMS_ROLE() external view returns (bytes32); + function RESUME_ROLE() external view returns (bytes32); } interface IOperatorGrid { @@ -29,10 +25,6 @@ interface IBurner { function REQUEST_BURN_SHARES_ROLE() external view returns (bytes32); } -interface IUpgradeableBeacon { - function implementation() external view returns (address); -} - interface IStakingRouter { struct StakingModule { uint24 id; @@ -61,6 +53,13 @@ interface IVaultsAdapter { function evmScriptExecutor() external view returns (address); } +interface ITokenRateNotifier { + function observers(uint256 index) external view returns (address); + function observersLength() external view returns (uint256); + function addObserver(address observer) external; + function transferOwnership(address newOwner) external; +} + interface ILidoLocator { function vaultHub() external view returns (address); function predepositGuarantee() external view returns (address); @@ -70,6 +69,7 @@ interface ILidoLocator { function accounting() external view returns (address); function stakingRouter() external view returns (address); function vaultFactory() external view returns (address); + function postTokenRebaseReceiver() external view returns (address); } /** @@ -80,16 +80,15 @@ interface ILidoLocator { */ contract V3TemporaryAdmin { bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00; + string public constant CSM_MODULE_NAME = "Community Staking"; address public immutable AGENT; - bool public immutable IS_HOODI; bool public isSetupComplete; - constructor(address _agent, bool _isHoodi) { + constructor(address _agent) { if (_agent == address(0)) revert ZeroAddress(); AGENT = _agent; - IS_HOODI = _isHoodi; } /** @@ -99,27 +98,34 @@ contract V3TemporaryAdmin { */ function getCsmAccountingAddress(address _stakingRouter) public view returns (address) { if (_stakingRouter == address(0)) revert ZeroStakingRouter(); - IStakingRouter.StakingModule[] memory stakingModules = IStakingRouter(_stakingRouter).getStakingModules(); - // Find the Community Staking module (index 2 or 3 on Hoodi) - if (stakingModules.length <= 2) revert CsmModuleNotFound(); - - IStakingRouter.StakingModule memory csm = stakingModules[IS_HOODI ? 3 : 2]; - if (keccak256(bytes(csm.name)) != keccak256(bytes("Community Staking"))) { - revert CsmModuleNotFound(); + bytes32 csmModuleNameHash = keccak256(bytes(CSM_MODULE_NAME)); + for (uint256 i = 0; i < stakingModules.length; i++) { + if (keccak256(bytes(stakingModules[i].name)) == csmModuleNameHash) { + return ICSModule(stakingModules[i].stakingModuleAddress).accounting(); + } } - return ICSModule(csm.stakingModuleAddress).accounting(); + revert CsmModuleNotFound(); } /** * @notice Complete setup for all contracts - grants all roles and transfers admin to agent * @dev This is the main external function that should be called after deployment * @param _lidoLocatorImpl The new LidoLocator implementation address - * @param _vaultsAdapter The vaults' adapter address from easyTrack + * @param _vaultsAdapter The vaults' adapter address for EasyTrack + * @param _gateSeal The GateSeal contract address + * @param _resealManager The ResealManager for extra pause/resume roles + * @param _oldTokenRateNotifier The old TokenRateNotifier contract address */ - function completeSetup(address _lidoLocatorImpl, address _vaultsAdapter, address _gateSeal) external { + function completeSetup( + address _lidoLocatorImpl, + address _vaultsAdapter, + address _gateSeal, + address _resealManager, + address _oldTokenRateNotifier + ) external { if (isSetupComplete) revert SetupAlreadyCompleted(); if (_lidoLocatorImpl == address(0)) revert ZeroLidoLocator(); if (_vaultsAdapter == address(0)) revert ZeroVaultsAdapter(); @@ -130,35 +136,49 @@ contract V3TemporaryAdmin { address csmAccounting = getCsmAccountingAddress(locator.stakingRouter()); - _setupPredepositGuarantee(locator.predepositGuarantee(), _gateSeal); - _setupLazyOracle(locator.lazyOracle()); - _setupOperatorGrid(locator.operatorGrid(), IVaultsAdapter(_vaultsAdapter).evmScriptExecutor(), _vaultsAdapter); - _setupBurner(locator.burner(), locator.accounting(), csmAccounting); - _setupVaultHub(locator.vaultHub(), _vaultsAdapter, _gateSeal); - } + address vaultHub = locator.vaultHub(); + address operatorGrid = locator.operatorGrid(); + address burner = locator.burner(); + address predepositGuarantee = locator.predepositGuarantee(); + address tokenRateNotifier = locator.postTokenRebaseReceiver(); + + _setupPredepositGuarantee(predepositGuarantee, _gateSeal, _resealManager); + _setupOperatorGrid(operatorGrid, IVaultsAdapter(_vaultsAdapter).evmScriptExecutor(), _vaultsAdapter); + _setupBurner(burner, locator.accounting(), csmAccounting); + _setupVaultHub(vaultHub, _vaultsAdapter, _gateSeal, _resealManager); + _migrateTokenRateNotifier(_oldTokenRateNotifier, tokenRateNotifier); + emit SetupCompleted(vaultHub, operatorGrid, burner, predepositGuarantee, tokenRateNotifier); + } /** * @notice Setup VaultHub with all required roles and transfer admin to agent * @param _vaultHub The VaultHub contract address * @param _vaultsAdapter The vaults' adapter address + * @param _gateSeal The GateSeal contract address + * @param _resealManager The ResealManager contract address that can pause and resume */ - function _setupVaultHub(address _vaultHub, address _vaultsAdapter, address _gateSeal) private { + function _setupVaultHub( + address _vaultHub, + address _vaultsAdapter, + address _gateSeal, + address _resealManager + ) private { // Get roles from the contract bytes32 pauseRole = IPausableUntilWithRoles(_vaultHub).PAUSE_ROLE(); + bytes32 resumeRole = IPausableUntilWithRoles(_vaultHub).RESUME_ROLE(); bytes32 vaultMasterRole = IVaultHub(_vaultHub).VAULT_MASTER_ROLE(); - bytes32 redemptionMasterRole = IVaultHub(_vaultHub).REDEMPTION_MASTER_ROLE(); bytes32 validatorExitRole = IVaultHub(_vaultHub).VALIDATOR_EXIT_ROLE(); bytes32 badDebtMasterRole = IVaultHub(_vaultHub).BAD_DEBT_MASTER_ROLE(); IAccessControl(_vaultHub).grantRole(pauseRole, _gateSeal); + IAccessControl(_vaultHub).grantRole(pauseRole, _resealManager); + IAccessControl(_vaultHub).grantRole(resumeRole, _resealManager); IAccessControl(_vaultHub).grantRole(vaultMasterRole, AGENT); - IAccessControl(_vaultHub).grantRole(redemptionMasterRole, AGENT); IAccessControl(_vaultHub).grantRole(validatorExitRole, _vaultsAdapter); IAccessControl(_vaultHub).grantRole(badDebtMasterRole, _vaultsAdapter); - IAccessControl(_vaultHub).grantRole(redemptionMasterRole, _vaultsAdapter); _transferAdminToAgent(_vaultHub); } @@ -166,21 +186,22 @@ contract V3TemporaryAdmin { /** * @notice Setup PredepositGuarantee with PAUSE_ROLE for gateSeal and transfer admin to agent * @param _predepositGuarantee The PredepositGuarantee contract address + * @param _gateSeal The GateSeal contract address + * @param _resealManager The ResealManager contract address that can pause and resume */ - function _setupPredepositGuarantee(address _predepositGuarantee, address _gateSeal) private { + function _setupPredepositGuarantee( + address _predepositGuarantee, + address _gateSeal, + address _resealManager + ) private { bytes32 pauseRole = IPausableUntilWithRoles(_predepositGuarantee).PAUSE_ROLE(); + bytes32 resumeRole = IPausableUntilWithRoles(_predepositGuarantee).RESUME_ROLE(); + IAccessControl(_predepositGuarantee).grantRole(pauseRole, _gateSeal); - _transferAdminToAgent(_predepositGuarantee); - } + IAccessControl(_predepositGuarantee).grantRole(pauseRole, _resealManager); + IAccessControl(_predepositGuarantee).grantRole(resumeRole, _resealManager); - /** - * @notice Setup LazyOracle with required roles and transfer admin to agent - * @param _lazyOracle The LazyOracle contract address - */ - function _setupLazyOracle(address _lazyOracle) private { - bytes32 updateSanityParamsRole = ILazyOracle(_lazyOracle).UPDATE_SANITY_PARAMS_ROLE(); - IAccessControl(_lazyOracle).grantRole(updateSanityParamsRole, AGENT); - _transferAdminToAgent(_lazyOracle); + _transferAdminToAgent(_predepositGuarantee); } /** @@ -217,6 +238,21 @@ contract V3TemporaryAdmin { _transferAdminToAgent(_burner); } + function _migrateTokenRateNotifier(address _oldTokenRateNotifier, address _newTokenRateNotifier) private { + ITokenRateNotifier oldNotifier = ITokenRateNotifier(_oldTokenRateNotifier); + ITokenRateNotifier newNotifier = ITokenRateNotifier(_newTokenRateNotifier); + + assert(newNotifier.observersLength() == 0); + uint256 observersLength = oldNotifier.observersLength(); + + for (uint256 i = 0; i < observersLength; i++) { + address observer = oldNotifier.observers(i); + newNotifier.addObserver(observer); + } + + newNotifier.transferOwnership(AGENT); + } + function _transferAdminToAgent(address _contract) private { IAccessControl(_contract).grantRole(DEFAULT_ADMIN_ROLE, AGENT); IAccessControl(_contract).renounceRole(DEFAULT_ADMIN_ROLE, address(this)); @@ -225,8 +261,15 @@ contract V3TemporaryAdmin { error ZeroAddress(); error ZeroLidoLocator(); error ZeroStakingRouter(); - error ZeroEvmScriptExecutor(); error ZeroVaultsAdapter(); error CsmModuleNotFound(); error SetupAlreadyCompleted(); + + event SetupCompleted( + address vaultHub, + address operatorGrid, + address burner, + address predepositGuarantee, + address newTokenRateNotifier + ); } diff --git a/contracts/upgrade/V3VoteScript.sol b/contracts/upgrade/V3VoteScript.sol index 7d84e99328..9ca04bc16e 100644 --- a/contracts/upgrade/V3VoteScript.sol +++ b/contracts/upgrade/V3VoteScript.sol @@ -9,6 +9,18 @@ import {IOssifiableProxy} from "contracts/common/interfaces/IOssifiableProxy.sol import {OmnibusBase} from "./utils/OmnibusBase.sol"; import {V3Template} from "./V3Template.sol"; +import {OperatorGrid} from "contracts/0.8.25/vaults/OperatorGrid.sol"; + +interface ITimeConstraints { + function checkTimeAfterTimestampAndEmit(uint40 timestamp) external; + function checkTimeBeforeTimestampAndEmit(uint40 timestamp) external; + function checkTimeWithinDayTimeAndEmit(uint32 startDayTime, uint32 endDayTime) external; +} + +interface IEasyTrack { + function addEVMScriptFactory(address _evmScriptFactory, bytes memory _permissions) external; +} + interface IKernel { function setApp(bytes32 _namespace, bytes32 _appId, address _app) external; function APP_BASES_NAMESPACE() external view returns (bytes32); @@ -23,6 +35,14 @@ interface IStakingRouter { function REPORT_REWARDS_MINTED_ROLE() external view returns (bytes32); } +interface IVaultsAdapter { + function setVaultJailStatus(address _vault, bool _isInJail) external; + function updateVaultFees(address _vault, uint16 _infrastructureFeeBP, uint16 _liquidityFeeBP, uint16 _reservationFeeBP) external; + function forceValidatorExit(address _vault, bytes calldata _pubkeys, address _feeRecipient) external payable; + function setLiabilitySharesTarget(address _vault, uint256 _liabilitySharesTarget) external; + function socializeBadDebt(address _debtVault, address _acceptorVault, uint256 _shares) external; +} + /// @title V3VoteScript /// @notice Script for upgrading Lido protocol components contract V3VoteScript is OmnibusBase { @@ -30,12 +50,19 @@ contract V3VoteScript is OmnibusBase { struct ScriptParams { address upgradeTemplate; bytes32 lidoAppId; + address timeConstraints; } + // + // Execution window + // + uint32 public constant ENABLED_DAY_SPAN_START = 50400; // 14:00 + uint32 public constant ENABLED_DAY_SPAN_END = 82800; // 23:00 + // // Constants // - uint256 public constant VOTE_ITEMS_COUNT = 17; + uint256 public constant VOTE_ITEMS_COUNT = 18; // // Immutables @@ -55,8 +82,138 @@ contract V3VoteScript is OmnibusBase { params = _params; } - function getVotingVoteItems() public pure override returns (VoteItem[] memory votingVoteItems) { - votingVoteItems = new VoteItem[](0); + function getVotingVoteItems() public view override returns (VoteItem[] memory votingVoteItems) { + votingVoteItems = new VoteItem[](9); + address easyTrack = TEMPLATE.EASY_TRACK(); + address operatorGrid = TEMPLATE.OPERATOR_GRID(); + address vaultsAdapter = TEMPLATE.VAULTS_ADAPTER(); + votingVoteItems[0] = VoteItem({ + description: "2. Add AlterTiersInOperatorGrid factory to EasyTrack (permissions: operatorGrid, alterTiers)", // 1 is reserved for DG submission item + call: ScriptCall({ + to: easyTrack, + data: abi.encodeCall(IEasyTrack.addEVMScriptFactory, ( + TEMPLATE.ETF_ALTER_TIERS_IN_OPERATOR_GRID(), + bytes.concat( + bytes20(operatorGrid), + bytes4(OperatorGrid.alterTiers.selector) + ) + )) + }) + }); + + votingVoteItems[1] = VoteItem({ + description: "3. Add RegisterGroupsInOperatorGrid factory to EasyTrack (permissions: operatorGrid, registerGroup + registerTiers)", + call: ScriptCall({ + to: easyTrack, + data: abi.encodeCall(IEasyTrack.addEVMScriptFactory, ( + TEMPLATE.ETF_REGISTER_GROUPS_IN_OPERATOR_GRID(), + bytes.concat( + bytes20(operatorGrid), + bytes4(OperatorGrid.registerGroup.selector), + bytes20(operatorGrid), + bytes4(OperatorGrid.registerTiers.selector) + ) + )) + }) + }); + + votingVoteItems[2] = VoteItem({ + description: "4. Add RegisterTiersInOperatorGrid factory to EasyTrack (permissions: operatorGrid, registerTiers)", + call: ScriptCall({ + to: easyTrack, + data: abi.encodeCall(IEasyTrack.addEVMScriptFactory, ( + TEMPLATE.ETF_REGISTER_TIERS_IN_OPERATOR_GRID(), + bytes.concat( + bytes20(operatorGrid), + bytes4(OperatorGrid.registerTiers.selector) + ) + )) + }) + }); + + votingVoteItems[3] = VoteItem({ + description: "5. Add UpdateGroupsShareLimitInOperatorGrid factory to EasyTrack (permissions: operatorGrid, updateGroupShareLimit)", + call: ScriptCall({ + to: easyTrack, + data: abi.encodeCall(IEasyTrack.addEVMScriptFactory, ( + TEMPLATE.ETF_UPDATE_GROUPS_SHARE_LIMIT_IN_OPERATOR_GRID(), + bytes.concat( + bytes20(operatorGrid), + bytes4(OperatorGrid.updateGroupShareLimit.selector) + ) + )) + }) + }); + + votingVoteItems[4] = VoteItem({ + description: "6. Add SetJailStatusInOperatorGrid factory to EasyTrack (permissions: vaultsAdapter, setVaultJailStatus)", + call: ScriptCall({ + to: easyTrack, + data: abi.encodeCall(IEasyTrack.addEVMScriptFactory, ( + TEMPLATE.ETF_SET_JAIL_STATUS_IN_OPERATOR_GRID(), + bytes.concat( + bytes20(vaultsAdapter), + bytes4(IVaultsAdapter.setVaultJailStatus.selector) + ) + )) + }) + }); + + votingVoteItems[5] = VoteItem({ + description: "7. Add UpdateVaultsFeesInOperatorGrid factory to EasyTrack (permissions: vaultsAdapter, updateVaultFees)", + call: ScriptCall({ + to: easyTrack, + data: abi.encodeCall(IEasyTrack.addEVMScriptFactory, ( + TEMPLATE.ETF_UPDATE_VAULTS_FEES_IN_OPERATOR_GRID(), + bytes.concat( + bytes20(vaultsAdapter), + bytes4(IVaultsAdapter.updateVaultFees.selector) + ) + )) + }) + }); + + votingVoteItems[6] = VoteItem({ + description: "8. Add ForceValidatorExitsInVaultHub factory to EasyTrack (permissions: vaultsAdapter, forceValidatorExit)", + call: ScriptCall({ + to: easyTrack, + data: abi.encodeCall(IEasyTrack.addEVMScriptFactory, ( + TEMPLATE.ETF_FORCE_VALIDATOR_EXITS_IN_VAULT_HUB(), + bytes.concat( + bytes20(vaultsAdapter), + bytes4(IVaultsAdapter.forceValidatorExit.selector) + ) + )) + }) + }); + + votingVoteItems[7] = VoteItem({ + description: "9. Add SetLiabilitySharesTargetInVaultHub factory to EasyTrack (permissions: vaultsAdapter, setLiabilitySharesTarget)", + call: ScriptCall({ + to: easyTrack, + data: abi.encodeCall(IEasyTrack.addEVMScriptFactory, ( + TEMPLATE.ETF_SET_LIABILITY_SHARES_TARGET_IN_VAULT_HUB(), + bytes.concat( + bytes20(vaultsAdapter), + bytes4(IVaultsAdapter.setLiabilitySharesTarget.selector) + ) + )) + }) + }); + + votingVoteItems[8] = VoteItem({ + description: "10. Add SocializeBadDebtInVaultHub factory to EasyTrack (permissions: vaultsAdapter, socializeBadDebt)", + call: ScriptCall({ + to: easyTrack, + data: abi.encodeCall(IEasyTrack.addEVMScriptFactory, ( + TEMPLATE.ETF_SOCIALIZE_BAD_DEBT_IN_VAULT_HUB(), + bytes.concat( + bytes20(vaultsAdapter), + bytes4(IVaultsAdapter.socializeBadDebt.selector) + ) + )) + }) + }); } function getVoteItems() public view override returns (VoteItem[] memory voteItems) { @@ -64,17 +221,31 @@ contract V3VoteScript is OmnibusBase { uint256 index = 0; voteItems[index++] = VoteItem({ - description: "1. Call UpgradeTemplateV3.startUpgrade", + description: "1. Check DG voting enactment is within daily time window (14:00 UTC - 23:00 UTC)", + call: ScriptCall({ + to: params.timeConstraints, + data: abi.encodeCall( + ITimeConstraints.checkTimeWithinDayTimeAndEmit, + ( + ENABLED_DAY_SPAN_START, + ENABLED_DAY_SPAN_END + ) + ) + }) + }); + + voteItems[index++] = VoteItem({ + description: "2. Call UpgradeTemplateV3.startUpgrade", call: _forwardCall(TEMPLATE.AGENT(), params.upgradeTemplate, abi.encodeCall(V3Template.startUpgrade, ())) }); voteItems[index++] = VoteItem({ - description: "2. Upgrade LidoLocator implementation", + description: "3. Upgrade LidoLocator implementation", call: _forwardCall(TEMPLATE.AGENT(), TEMPLATE.LOCATOR(), abi.encodeCall(IOssifiableProxy.proxy__upgradeTo, (TEMPLATE.NEW_LOCATOR_IMPL()))) }); voteItems[index++] = VoteItem({ - description: "3. Grant Aragon APP_MANAGER_ROLE to the AGENT", + description: "4. Grant Aragon APP_MANAGER_ROLE to the AGENT", call: _forwardCall( TEMPLATE.AGENT(), TEMPLATE.ACL(), @@ -88,7 +259,7 @@ contract V3VoteScript is OmnibusBase { }); voteItems[index++] = VoteItem({ - description: "4. Set Lido implementation in Kernel", + description: "5. Set Lido implementation in Kernel", call: _forwardCall( TEMPLATE.AGENT(), TEMPLATE.KERNEL(), @@ -97,7 +268,7 @@ contract V3VoteScript is OmnibusBase { }); voteItems[index++] = VoteItem({ - description: "5. Revoke Aragon APP_MANAGER_ROLE from the AGENT", + description: "6. Revoke Aragon APP_MANAGER_ROLE from the AGENT", call: _forwardCall( TEMPLATE.AGENT(), TEMPLATE.ACL(), @@ -112,7 +283,7 @@ contract V3VoteScript is OmnibusBase { bytes32 requestBurnSharesRole = IBurner(TEMPLATE.OLD_BURNER()).REQUEST_BURN_SHARES_ROLE(); voteItems[index++] = VoteItem({ - description: "6. Revoke REQUEST_BURN_SHARES_ROLE from Lido", + description: "7. Revoke REQUEST_BURN_SHARES_ROLE from Lido", call: _forwardCall( TEMPLATE.AGENT(), TEMPLATE.OLD_BURNER(), @@ -121,7 +292,7 @@ contract V3VoteScript is OmnibusBase { }); voteItems[index++] = VoteItem({ - description: "7. Revoke REQUEST_BURN_SHARES_ROLE from Curated staking module", + description: "8. Revoke REQUEST_BURN_SHARES_ROLE from Curated staking module", call: _forwardCall( TEMPLATE.AGENT(), TEMPLATE.OLD_BURNER(), @@ -130,7 +301,7 @@ contract V3VoteScript is OmnibusBase { }); voteItems[index++] = VoteItem({ - description: "8. Revoke REQUEST_BURN_SHARES_ROLE from SimpleDVT", + description: "9. Revoke REQUEST_BURN_SHARES_ROLE from SimpleDVT", call: _forwardCall( TEMPLATE.AGENT(), TEMPLATE.OLD_BURNER(), @@ -139,7 +310,7 @@ contract V3VoteScript is OmnibusBase { }); voteItems[index++] = VoteItem({ - description: "9. Revoke REQUEST_BURN_SHARES_ROLE from Community Staking Accounting", + description: "10. Revoke REQUEST_BURN_SHARES_ROLE from Community Staking Accounting", call: _forwardCall( TEMPLATE.AGENT(), TEMPLATE.OLD_BURNER(), @@ -148,7 +319,7 @@ contract V3VoteScript is OmnibusBase { }); voteItems[index++] = VoteItem({ - description: "10. Upgrade AccountingOracle implementation", + description: "11. Upgrade AccountingOracle implementation", call: _forwardCall( TEMPLATE.AGENT(), TEMPLATE.ACCOUNTING_ORACLE(), @@ -158,7 +329,7 @@ contract V3VoteScript is OmnibusBase { bytes32 reportRewardsMintedRole = IStakingRouter(TEMPLATE.STAKING_ROUTER()).REPORT_REWARDS_MINTED_ROLE(); voteItems[index++] = VoteItem({ - description: "11. Revoke REPORT_REWARDS_MINTED_ROLE from Lido", + description: "12. Revoke REPORT_REWARDS_MINTED_ROLE from Lido", call: _forwardCall( TEMPLATE.AGENT(), TEMPLATE.STAKING_ROUTER(), @@ -167,7 +338,7 @@ contract V3VoteScript is OmnibusBase { }); voteItems[index++] = VoteItem({ - description: "12. Grant REPORT_REWARDS_MINTED_ROLE to Accounting", + description: "13. Grant REPORT_REWARDS_MINTED_ROLE to Accounting", call: _forwardCall( TEMPLATE.AGENT(), TEMPLATE.STAKING_ROUTER(), @@ -178,7 +349,7 @@ contract V3VoteScript is OmnibusBase { bytes32 configManagerRole = IOracleDaemonConfig(TEMPLATE.ORACLE_DAEMON_CONFIG()).CONFIG_MANAGER_ROLE(); voteItems[index++] = VoteItem({ - description: "13. Grant OracleDaemonConfig's CONFIG_MANAGER_ROLE to Agent", + description: "14. Grant OracleDaemonConfig's CONFIG_MANAGER_ROLE to Agent", call: _forwardCall( TEMPLATE.AGENT(), TEMPLATE.ORACLE_DAEMON_CONFIG(), @@ -187,7 +358,7 @@ contract V3VoteScript is OmnibusBase { }); voteItems[index++] = VoteItem({ - description: "14. Set SLASHING_RESERVE_WE_RIGHT_SHIFT to 0x2000 at OracleDaemonConfig", + description: "15. Set SLASHING_RESERVE_WE_RIGHT_SHIFT to 0x2000 at OracleDaemonConfig", call: _forwardCall( TEMPLATE.AGENT(), TEMPLATE.ORACLE_DAEMON_CONFIG(), @@ -196,7 +367,7 @@ contract V3VoteScript is OmnibusBase { }); voteItems[index++] = VoteItem({ - description: "15. Set SLASHING_RESERVE_WE_LEFT_SHIFT to 0x2000 at OracleDaemonConfig", + description: "16. Set SLASHING_RESERVE_WE_LEFT_SHIFT to 0x2000 at OracleDaemonConfig", call: _forwardCall( TEMPLATE.AGENT(), TEMPLATE.ORACLE_DAEMON_CONFIG(), @@ -205,7 +376,7 @@ contract V3VoteScript is OmnibusBase { }); voteItems[index++] = VoteItem({ - description: "16. Revoke OracleDaemonConfig's CONFIG_MANAGER_ROLE from Agent", + description: "17. Revoke OracleDaemonConfig's CONFIG_MANAGER_ROLE from Agent", call: _forwardCall( TEMPLATE.AGENT(), TEMPLATE.ORACLE_DAEMON_CONFIG(), @@ -214,7 +385,7 @@ contract V3VoteScript is OmnibusBase { }); voteItems[index++] = VoteItem({ - description: "17. Call UpgradeTemplateV3.finishUpgrade", + description: "18. Call UpgradeTemplateV3.finishUpgrade", call: _forwardCall(TEMPLATE.AGENT(), params.upgradeTemplate, abi.encodeCall(V3Template.finishUpgrade, ())) }); diff --git a/deployed-hoodi.json b/deployed-hoodi.json index 6dd58d84d4..8b14dc15c5 100644 --- a/deployed-hoodi.json +++ b/deployed-hoodi.json @@ -11,7 +11,7 @@ }, "implementation": { "contract": "contracts/0.8.9/Accounting.sol", - "address": "0x0bF902fb783Fbf8af0bC011C76D2F7d318a50c74", + "address": "0xd7eb46d18a07F78ed07201E1C7F7A4933967da6D", "constructorArgs": ["0xe2EF9536DAAAEBFf5b1c130957AB3E80056b06D8", "0x3508A952176b3c15387C97BE809eaffB1982176a"] } }, @@ -133,7 +133,7 @@ "app:lido": { "implementation": { "contract": "contracts/0.4.24/Lido.sol", - "address": "0x35A4f9c9c2B1f81bDe7Eaa1f23b6465D3d741EEF", + "address": "0x4f9143Dba1f1BbFa535528254592f3396E229e53", "constructorArgs": [] }, "aragonApp": { @@ -359,7 +359,7 @@ }, "dashboardImpl": { "contract": "contracts/0.8.25/vaults/dashboard/Dashboard.sol", - "address": "0x7CA203e3b7341341A4a83086780137eb283A9338", + "address": "0x38131D5548Be57A34937521fe427a23f49e1e2d4", "constructorArgs": [ "0x3508A952176b3c15387C97BE809eaffB1982176a", "0x7E99eE3C66636DE415D2d7C880938F2f40f94De4", @@ -514,7 +514,7 @@ }, "implementation": { "contract": "contracts/0.8.25/vaults/LazyOracle.sol", - "address": "0x100200eE0067B4Aa95c7Ae0433d8467F5Ca74E78", + "address": "0x4e3b9fd9F713e5dbA86255febD4c402794135095", "constructorArgs": ["0xe2EF9536DAAAEBFf5b1c130957AB3E80056b06D8"] } }, @@ -558,7 +558,7 @@ }, "implementation": { "contract": "contracts/0.8.9/LidoLocator.sol", - "address": "0x8717971067D6FeeB631d750B09Ba66a6B4E01BA3", + "address": "0x751A4AA1A29Bc0C0E587aa04c3EABF0797F9B1A4", "constructorArgs": [ { "accountingOracle": "0xcb883B1bD0a41512b42D2dB267F2A2cd919FB216", @@ -566,7 +566,7 @@ "elRewardsVault": "0x9b108015fe433F173696Af3Aa0CF7CDb3E104258", "lido": "0x3508A952176b3c15387C97BE809eaffB1982176a", "oracleReportSanityChecker": "0x53417BA942bC86492bAF46FAbA8769f246422388", - "postTokenRebaseReceiver": "0x0000000000000000000000000000000000000000", + "postTokenRebaseReceiver": "0x9c53d0075eA00ad77dDAd1b71E67bb97AaBC1e3D", "burner": "0xb2c99cd38a2636a6281a849C8de938B3eF4A7C3D", "stakingRouter": "0xCc820558B39ee15C7C45B59390B503b83fb499A8", "treasury": "0x0534aA41907c9631fae990960bCC72d75fA7cfeD", @@ -580,7 +580,7 @@ "predepositGuarantee": "0xa5F55f3402beA2B14AE15Dae1b6811457D43581d", "wstETH": "0x7E99eE3C66636DE415D2d7C880938F2f40f94De4", "vaultHub": "0x4C9fFC325392090F789255b9948Ab1659b797964", - "vaultFactory": "0xf0Cf0c852Bb2b41eF8171399a71be79aa67e6295", + "vaultFactory": "0x7Ba269a03eeD86f2f54CB04CA3b4b7626636Df4E", "lazyOracle": "0xf41491C79C30e8f4862d3F4A5b790171adB8e04A", "operatorGrid": "0x501e678182bB5dF3f733281521D3f3D1aDe69917" } @@ -631,7 +631,7 @@ }, "implementation": { "contract": "contracts/0.8.25/vaults/OperatorGrid.sol", - "address": "0xab35DA5722B188E3b67896873F88e2dFA905c944", + "address": "0x4c29A1D1e14BaD29650c8B91Ad91c5AEdF5fd811", "constructorArgs": ["0xe2EF9536DAAAEBFf5b1c130957AB3E80056b06D8"] } }, @@ -732,12 +732,12 @@ }, "stakingVaultFactory": { "contract": "contracts/0.8.25/vaults/VaultFactory.sol", - "address": "0xf0Cf0c852Bb2b41eF8171399a71be79aa67e6295", + "address": "0x7Ba269a03eeD86f2f54CB04CA3b4b7626636Df4E", "constructorArgs": [ "0xe2EF9536DAAAEBFf5b1c130957AB3E80056b06D8", "0xb3e6a8B6A752d3bb905A1B3Ef12bbdeE77E8160e", - "0x7CA203e3b7341341A4a83086780137eb283A9338", - "0x67Fc99587B4Cd6FA16E26FF4782711f79055d7ad" + "0x38131D5548Be57A34937521fe427a23f49e1e2d4", + "0x1d10DB6a66EF8D2A6f6D36Ad4dc7092Ef7C12569" ] }, "stakingVaultImplementation": { @@ -745,6 +745,13 @@ "address": "0xE96BE4FB723e68e7b96244b7399C64a58bcD0062", "constructorArgs": ["0x00000000219ab540356cBB839Cbe05303d7705Fa"] }, + "tokenRebaseNotifierV3": { + "implementation": { + "contract": "contracts/0.8.9/TokenRateNotifier.sol", + "address": "0x9c53d0075eA00ad77dDAd1b71E67bb97AaBC1e3D", + "constructorArgs": ["0x0534aA41907c9631fae990960bCC72d75fA7cfeD", "0x9b5b78D1C9A3238bF24662067e34c57c83E8c354"] + } + }, "triggerableWithdrawalsGateway": { "contract": "contracts/0.8.9/TriggerableWithdrawalsGateway.sol", "address": "0x6679090D92b08a2a686eF8614feECD8cDFE209db", @@ -856,7 +863,7 @@ }, "implementation": { "contract": "contracts/0.8.25/vaults/VaultHub.sol", - "address": "0x36CdDa6Ff2cb8a83a0F328aA2Bf1B5200377FAf9", + "address": "0x932d97b59B8846796469C129AB1b83c2a855a3F7", "constructorArgs": [ "0xe2EF9536DAAAEBFf5b1c130957AB3E80056b06D8", "0x3508A952176b3c15387C97BE809eaffB1982176a", diff --git a/deployed-mainnet.json b/deployed-mainnet.json index e3b1689834..c09c5edbf3 100644 --- a/deployed-mainnet.json +++ b/deployed-mainnet.json @@ -296,6 +296,9 @@ "deployTx": "0x9d76786f639bd18365f10c087444761db5dafd0edc85c5c1a3e90219f2d1331d", "constructorArgs": [] }, + "easyTrack": { + "address": "0xF0211b7660680B49De1A7E9f25C65660F0a13Fea" + }, "easyTrackEVMScriptExecutor": { "address": "0xFE5986E06210aC1eCC1aDCafc0cc7f8D63B3F977" }, @@ -328,6 +331,9 @@ "sealingCommittee": "0x8772E3a2D86B9347A2688f9bc1808A6d8917760C", "address": "0xA6BC802fAa064414AA62117B4a53D27fFfF741F1" }, + "resealManager": { + "address": "0x7914b5a1539b97Bd0bbd155757F25FD79A522d24" + }, "hashConsensusForAccountingOracle": { "address": "0xD624B08C83bAECF0807Dd2c6880C3154a5F0B288", "contract": "contracts/0.8.9/oracle/HashConsensus.sol", @@ -534,11 +540,6 @@ "constructorArgs": [12, 1606824023, "0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb"] } }, - "vaultsAdapter": { - "contract": "contracts/upgrade/mocks/VaultsAdapterMock.sol", - "address": "0x9c2778e5eb0ccEbF062e8eDB4a3A9dB535D26910", - "constructorArgs": ["0xFE5986E06210aC1eCC1aDCafc0cc7f8D63B3F977"] - }, "vestingParams": { "unvestedTokensAmount": "363197500000000000000000000", "holders": { @@ -649,5 +650,11 @@ "contract": "contracts/0.6.12/WstETH.sol", "deployTx": "0xaf2c1a501d2b290ef1e84ddcfc7beb3406f8ece2c46dee14e212e8233654ff05", "constructorArgs": ["0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"] + }, + "tokenRebaseNotifier": { + "address": "0xe6793B9e4FbA7DE0ee833F9D02bba7DB5EB27823", + "contract": "contracts/0.8.9/TokenRateNotifier.sol", + "deployTx": "0xcbffe641fe89657368a79e7482e90ec8517b4ac6f27ad6301f52d0e9db97887d", + "constructorArgs": ["0xBb01c09DE4Bf967AbaB418450c3e18Df46358322", "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84"] } } diff --git a/lib/account.ts b/lib/account.ts index 8abde7886d..ee953f375c 100644 --- a/lib/account.ts +++ b/lib/account.ts @@ -24,7 +24,11 @@ export async function impersonate(address: string | Addressable, balance?: bigin return ethers.getSigner(address); } -export async function updateBalance(address: string, balance: bigint): Promise { +export async function updateBalance(address: string | Addressable, balance: bigint): Promise { + if (typeof address !== "string") { + address = await address.getAddress(); + } + const networkName = await getNetworkName(); await ethers.provider.send(`${networkName}_setBalance`, [address, "0x" + bigintToHex(balance)]); diff --git a/lib/config-schemas.ts b/lib/config-schemas.ts index fd6e7f0218..5282a41137 100644 --- a/lib/config-schemas.ts +++ b/lib/config-schemas.ts @@ -81,10 +81,18 @@ const TriggerableWithdrawalsGatewaySchema = z.object({ // Easy track schema const EasyTrackSchema = z.object({ - trustedCaller: EthereumAddressSchema, - initialValidatorExitFeeLimit: BigIntStringSchema, - maxGroupShareLimit: BigIntStringSchema, - maxDefaultTierShareLimit: NonNegativeIntSchema, + VaultsAdapter: EthereumAddressSchema, + newFactories: z.object({ + AlterTiersInOperatorGrid: EthereumAddressSchema, + RegisterGroupsInOperatorGrid: EthereumAddressSchema, + RegisterTiersInOperatorGrid: EthereumAddressSchema, + SetJailStatusInOperatorGrid: EthereumAddressSchema, + SetLiabilitySharesTargetInVaultHub: EthereumAddressSchema, + SocializeBadDebtInVaultHub: EthereumAddressSchema, + ForceValidatorExitsInVaultHub: EthereumAddressSchema, + UpdateGroupsShareLimitInOperatorGrid: EthereumAddressSchema, + UpdateVaultsFeesInOperatorGrid: EthereumAddressSchema, + }), }); // Oracle versions schema @@ -96,6 +104,7 @@ const OracleVersionsSchema = z.object({ const V3VoteScriptSchema = z.object({ expiryTimestamp: NonNegativeIntSchema, initialMaxExternalRatioBP: BasisPointsSchema, + timeConstraintsContract: EthereumAddressSchema, }); // Aragon app versions schema @@ -109,7 +118,6 @@ export const UpgradeParametersSchema = z.object({ chainSpec: ChainSpecSchema.extend({ genesisTime: z.number().int(), depositContract: EthereumAddressSchema, - isHoodi: z.boolean(), }), gateSealForVaults: z.object({ sealDuration: PositiveIntSchema, diff --git a/lib/constants.ts b/lib/constants.ts index 4a2b497677..2c5cd8723f 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1,4 +1,5 @@ export const MAX_UINT256 = 2n ** 256n - 1n; +export const MAX_UINT96 = 2n ** 96n - 1n; export const MAX_INT104 = 2n ** 103n - 1n; export const INITIAL_STETH_HOLDER = "0x000000000000000000000000000000000000dEaD"; diff --git a/lib/deploy.ts b/lib/deploy.ts index 03b5ac1272..703e6de4ce 100644 --- a/lib/deploy.ts +++ b/lib/deploy.ts @@ -12,6 +12,7 @@ import { keysOf } from "./protocol/types"; const GAS_PRIORITY_FEE = process.env.GAS_PRIORITY_FEE || null; const GAS_MAX_FEE = process.env.GAS_MAX_FEE || null; +const GAS_LIMIT = process.env.GAS_LIMIT || null; const PROXY_CONTRACT_NAME = "OssifiableProxy"; @@ -57,6 +58,7 @@ async function getDeployTxParams(deployer: string) { type: 2, maxPriorityFeePerGas: ethers.parseUnits(String(GAS_PRIORITY_FEE), "gwei"), maxFeePerGas: ethers.parseUnits(String(GAS_MAX_FEE), "gwei"), + gasLimit: GAS_LIMIT, }; } else { throw new Error('Must specify gas ENV vars: "GAS_PRIORITY_FEE" and "GAS_MAX_FEE" in gwei (like just "3")'); diff --git a/lib/log.ts b/lib/log.ts index 9d58779040..be8298214a 100644 --- a/lib/log.ts +++ b/lib/log.ts @@ -87,7 +87,6 @@ const _title = (title: string) => { }; const _record = (label: string, value: ConvertibleToString) => { - if (!shouldLog("debug")) return; log(`${chalk.grey(label)}: ${yl(value.toString())}`); }; @@ -179,3 +178,11 @@ log.debug = (title: string, records: Record = {}) = Object.keys(records).forEach((label) => _record(` ${label}`, records[label])); log.emptyLine(); }; + +log.info = (title: string, records: Record = {}) => { + if (!shouldLog("info")) return; + + _title(title); + Object.keys(records).forEach((label) => _record(` ${label}`, records[label])); + log.emptyLine(); +}; diff --git a/lib/protocol/discover.ts b/lib/protocol/discover.ts index 8c4184c920..3580876608 100644 --- a/lib/protocol/discover.ts +++ b/lib/protocol/discover.ts @@ -144,13 +144,16 @@ const getAragonContracts = async (lido: LoadedContract, config: ProtocolNe * Load staking modules contracts registered in the staking router. */ const getStakingModules = async (stakingRouter: LoadedContract, config: ProtocolNetworkConfig) => { - const [nor, sdvt, csm] = await stakingRouter.getStakingModules(); + const modules = await stakingRouter.getStakingModules(); + const nor = modules[0]; + const sdvt = modules.find((m) => m.name === "SimpleDVT")!; const promises: { [key: string]: Promise> } = { nor: loadContract("NodeOperatorsRegistry", config.get("nor") || nor.stakingModuleAddress), sdvt: loadContract("NodeOperatorsRegistry", config.get("sdvt") || sdvt.stakingModuleAddress), }; + const csm = modules.find((m) => m.name === "Community Staking"); if (csm) { promises.csm = loadContract("IStakingModule", config.get("csm") || csm.stakingModuleAddress); } diff --git a/lib/protocol/helpers/accounting.ts b/lib/protocol/helpers/accounting.ts index 4c75bfea6b..0e34ab8309 100644 --- a/lib/protocol/helpers/accounting.ts +++ b/lib/protocol/helpers/accounting.ts @@ -45,6 +45,7 @@ export type OracleReportParams = { numExitedValidatorsByStakingModule?: bigint[]; reportElVault?: boolean; reportWithdrawalsVault?: boolean; + reportBurner?: boolean; vaultsDataTreeRoot?: string; vaultsDataTreeCid?: string; silent?: boolean; @@ -84,6 +85,7 @@ export const report = async ( numExitedValidatorsByStakingModule = [], reportElVault = true, reportWithdrawalsVault = true, + reportBurner = true, vaultsDataTreeRoot = ZERO_BYTES32, vaultsDataTreeCid = "", }: OracleReportParams = {}, @@ -124,13 +126,13 @@ export const report = async ( withdrawalVaultBalance = reportWithdrawalsVault ? withdrawalVaultBalance : 0n; elRewardsVaultBalance = reportElVault ? elRewardsVaultBalance : 0n; - if (sharesRequestedToBurn === null) { + if (sharesRequestedToBurn === null && reportBurner) { const [coverShares, nonCoverShares] = await burner.getSharesRequestedToBurn(); sharesRequestedToBurn = coverShares + nonCoverShares; } log.debug("Burner", { - "Shares Requested To Burn": sharesRequestedToBurn, + "Shares Requested To Burn": sharesRequestedToBurn ?? "0", "Withdrawal vault": formatEther(withdrawalVaultBalance), "ElRewards vault": formatEther(elRewardsVaultBalance), }); @@ -184,7 +186,7 @@ export const report = async ( numExitedValidatorsByStakingModule, withdrawalVaultBalance, elRewardsVaultBalance, - sharesRequestedToBurn, + sharesRequestedToBurn: sharesRequestedToBurn ?? 0n, withdrawalFinalizationBatches, simulatedShareRate, isBunkerMode, @@ -344,7 +346,7 @@ type SimulateReportResult = { /** * Simulate oracle report to get the expected result. */ -const simulateReport = async ( +export const simulateReport = async ( ctx: ProtocolContext, { refSlot, beaconValidators, clBalance, withdrawalVaultBalance, elRewardsVaultBalance }: SimulateReportParams, ): Promise => { @@ -363,7 +365,7 @@ const simulateReport = async ( const reportValues: ReportValuesStruct = { timestamp: reportTimestamp, - timeElapsed: (await getReportTimeElapsed(ctx)).timeElapsed, + timeElapsed: /* 1 day */ 86_400n, clValidators: beaconValidators, clBalance, withdrawalVaultBalance, diff --git a/lib/protocol/helpers/nor-sdvt.ts b/lib/protocol/helpers/nor-sdvt.ts index 12e46701c8..2617a10926 100644 --- a/lib/protocol/helpers/nor-sdvt.ts +++ b/lib/protocol/helpers/nor-sdvt.ts @@ -1,9 +1,9 @@ import { expect } from "chai"; -import { CallExceptionError, ethers } from "ethers"; +import { ethers } from "ethers"; import { NodeOperatorsRegistry } from "typechain-types"; -import { certainAddress, log } from "lib"; +import { certainAddress, ether, impersonate, log } from "lib"; import { LoadedContract } from "lib/protocol/types"; import { ProtocolContext, StakingModuleName } from "../types"; @@ -129,6 +129,7 @@ export const norSdvtAddNodeOperator = async ( rewardAddress: string; }, ) => { + const { acl } = ctx.contracts; const { name, rewardAddress } = params; log.debug(`Adding fake NOR operator`, { @@ -138,9 +139,21 @@ export const norSdvtAddNodeOperator = async ( const operatorId = await module.getNodeOperatorsCount(); - const managerSigner = await ctx.getSigner("agent"); + const role = await module.MANAGE_NODE_OPERATOR_ROLE(); + const managerSigner = await impersonate(await acl.getPermissionManager(module.address, role), ether("100")); + + const hasPermission = await acl["hasPermission(address,address,bytes32)"](managerSigner, module.address, role); + + if (!hasPermission) { + await acl.connect(managerSigner).grantPermission(managerSigner, module.address, role); + } + await module.connect(managerSigner).addNodeOperator(name, rewardAddress); + if (!hasPermission) { + await acl.connect(managerSigner).revokePermission(managerSigner, module.address, role); + } + log.debug("Added NOR fake operator", { "Operator ID": operatorId, "Name": name, @@ -161,6 +174,7 @@ export const norSdvtAddOperatorKeys = async ( keysToAdd: bigint; }, ) => { + const { acl } = ctx.contracts; const { operatorId, keysToAdd } = params; log.debug(`Adding fake keys to NOR operator ${operatorId}`, { @@ -171,14 +185,23 @@ export const norSdvtAddOperatorKeys = async ( const totalKeysBefore = await module.getTotalSigningKeyCount(operatorId); const unusedKeysBefore = await module.getUnusedSigningKeyCount(operatorId); - const agentSigner = await ctx.getSigner("agent"); - const agentAddress = await agentSigner.getAddress(); + const managerSigner = await impersonate( + await acl.getPermissionManager(module.address, await module.MANAGE_SIGNING_KEYS()), + ether("100"), + ); const role = await module.MANAGE_SIGNING_KEYS(); - await ctx.contracts.acl.connect(agentSigner).grantPermission(agentAddress, module.address, role); + const hasPermission = await acl["hasPermission(address,address,bytes32)"](managerSigner, module.address, role); + if (!hasPermission) { + await acl.connect(managerSigner).grantPermission(managerSigner, module.address, role); + } + await module - .connect(agentSigner) + .connect(managerSigner) .addSigningKeys(operatorId, keysToAdd, randomPubkeys(Number(keysToAdd)), randomSignatures(Number(keysToAdd))); - await ctx.contracts.acl.connect(agentSigner).revokePermission(agentAddress, module.address, role); + + if (!hasPermission) { + await acl.connect(managerSigner).revokePermission(managerSigner, module.address, role); + } const totalKeysAfter = await module.getTotalSigningKeyCount(operatorId); const unusedKeysAfter = await module.getUnusedSigningKeyCount(operatorId); @@ -207,6 +230,7 @@ export const norSdvtSetOperatorStakingLimit = async ( limit: bigint; }, ) => { + const { acl } = ctx.contracts; const { operatorId, limit } = params; log.debug(`Setting NOR operator ${operatorId} staking limit`, { @@ -214,17 +238,20 @@ export const norSdvtSetOperatorStakingLimit = async ( "Limit": ethers.formatEther(limit), }); - try { - // For SDVT scratch deployment and for NOR - const agentSigner = await ctx.getSigner("agent"); - await module.connect(agentSigner).setNodeOperatorStakingLimit(operatorId, limit); - } catch (error: unknown) { - if ((error as CallExceptionError).message.includes("APP_AUTH_FAILED")) { - const easyTrackSigner = await ctx.getSigner("easyTrack"); - await module.connect(easyTrackSigner).setNodeOperatorStakingLimit(operatorId, limit); - } else { - throw error; - } + const managerSigner = await impersonate( + await acl.getPermissionManager(module.address, await module.MANAGE_SIGNING_KEYS()), + ether("100"), + ); + const role = await module.SET_NODE_OPERATOR_LIMIT_ROLE(); + const hasPermission = await acl["hasPermission(address,address,bytes32)"](managerSigner, module.address, role); + if (!hasPermission) { + await acl.connect(managerSigner).grantPermission(managerSigner, module.address, role); + } + + await module.connect(managerSigner).setNodeOperatorStakingLimit(operatorId, limit); + + if (!hasPermission) { + await acl.connect(managerSigner).revokePermission(managerSigner, module.address, role); } }; diff --git a/lib/protocol/helpers/operatorGrid.ts b/lib/protocol/helpers/operatorGrid.ts index f3ae7896ab..539f32b93e 100644 --- a/lib/protocol/helpers/operatorGrid.ts +++ b/lib/protocol/helpers/operatorGrid.ts @@ -16,6 +16,38 @@ export const DEFAULT_TIER_PARAMS: TierParamsStruct = { reservationFeeBP: 0n, }; +export async function upDefaultTierShareLimit(ctx: ProtocolContext, increaseBy: bigint) { + const { operatorGrid } = ctx.contracts; + const agentSigner = await ctx.getSigner("agent"); + await grantRegistryRoleIfNotGranted(ctx, agentSigner); + + const existingTierParams = await operatorGrid.tier(await operatorGrid.DEFAULT_TIER_ID()); + + await operatorGrid.connect(agentSigner).alterTiers( + [await operatorGrid.DEFAULT_TIER_ID()], + [ + { + shareLimit: existingTierParams.shareLimit + increaseBy, + reserveRatioBP: existingTierParams.reserveRatioBP, + forcedRebalanceThresholdBP: existingTierParams.forcedRebalanceThresholdBP, + infraFeeBP: existingTierParams.infraFeeBP, + liquidityFeeBP: existingTierParams.liquidityFeeBP, + reservationFeeBP: existingTierParams.reservationFeeBP, + }, + ], + ); +} + +export async function resetDefaultTierShareLimit(ctx: ProtocolContext) { + const { operatorGrid } = ctx.contracts; + const agentSigner = await ctx.getSigner("agent"); + await grantRegistryRoleIfNotGranted(ctx, agentSigner); + + await operatorGrid + .connect(agentSigner) + .alterTiers([await operatorGrid.DEFAULT_TIER_ID()], [{ ...DEFAULT_TIER_PARAMS, shareLimit: 0n }]); +} + export async function grantRegistryRoleIfNotGranted(ctx: ProtocolContext, signer: HardhatEthersSigner) { const { operatorGrid } = ctx.contracts; const agentSigner = await ctx.getSigner("agent"); diff --git a/lib/protocol/helpers/share-rate.ts b/lib/protocol/helpers/share-rate.ts index 1506708a63..88a56495f2 100644 --- a/lib/protocol/helpers/share-rate.ts +++ b/lib/protocol/helpers/share-rate.ts @@ -1,13 +1,12 @@ import { ZeroAddress } from "ethers"; -import { certainAddress, ether, impersonate, log } from "lib"; +import { certainAddress, ether, getCurrentBlockTimestamp, impersonate, log } from "lib"; import { SHARE_RATE_PRECISION } from "test/suite"; import { ProtocolContext } from "../types"; import { report } from "./accounting"; -import { removeStakingLimit } from "./staking"; const DEPOSIT = 10000; const MIN_BURN = 1; @@ -24,25 +23,21 @@ function logShareRate(shareRate: bigint): number { return Number(shareRate) / Number(SHARE_RATE_PRECISION); } -async function increaseTotalPooledEther(ctx: ProtocolContext, etherAmount: bigint) { - const { lido, locator } = ctx.contracts; - // Impersonate whale and burner accounts - const whaleAddress = certainAddress("shareRate:eth:whale"); - const burnerAddress = await locator.burner(); - const [whale, burner] = await Promise.all([impersonate(whaleAddress, BIG_BAG), impersonate(burnerAddress, BIG_BAG)]); - - // Whale submits deposit - await removeStakingLimit(ctx); - await lido.connect(whale).submit(ZeroAddress, { value: etherAmount }); +async function changeInternalEther(ctx: ProtocolContext, internalEtherDelta: bigint) { + const { lido, accounting } = ctx.contracts; - const sharesToBurn = await lido.getSharesByPooledEth(etherAmount); + const accountingSigner = await impersonate(accounting, ether("1")); - // Whale transfers shares to burner, burner burns shares - await lido.connect(whale).transferShares(burner, sharesToBurn); - await lido.connect(burner).burnShares(sharesToBurn); + const { beaconValidators, beaconBalance } = await lido.getBeaconStat(); - // Report accounting - await report(ctx, { clDiff: 0n }); + await lido + .connect(accountingSigner) + .processClStateUpdate( + await getCurrentBlockTimestamp(), + beaconValidators, + beaconValidators, + beaconBalance + internalEtherDelta, + ); } export const ensureExactShareRate = async (ctx: ProtocolContext, targetShareRate: bigint) => { @@ -54,7 +49,8 @@ export const ensureExactShareRate = async (ctx: ProtocolContext, targetShareRate } const etherAmount = (totalShares * targetShareRate) / SHARE_RATE_PRECISION - totalPooledEther; - await increaseTotalPooledEther(ctx, etherAmount); + + await changeInternalEther(ctx, etherAmount); const [totalPooledEtherAfter, totalSharesAfter] = await Promise.all([ lido.getTotalPooledEther(), diff --git a/lib/protocol/helpers/vaults.ts b/lib/protocol/helpers/vaults.ts index 089f4c64ec..89abb6bcbc 100644 --- a/lib/protocol/helpers/vaults.ts +++ b/lib/protocol/helpers/vaults.ts @@ -24,6 +24,7 @@ import { generateTopUp, getCurrentBlockTimestamp, impersonate, + log, prepareLocalMerkleTree, TOTAL_BASIS_POINTS, Validator, @@ -199,19 +200,20 @@ export async function autofillRoles( */ export async function setupLidoForVaults(ctx: ProtocolContext) { const { lido, acl } = ctx.contracts; - const agentSigner = await ctx.getSigner("agent"); - const role = await lido.STAKING_CONTROL_ROLE(); - const agentAddress = await agentSigner.getAddress(); - - await acl.connect(agentSigner).grantPermission(agentAddress, lido.address, role); - await lido.connect(agentSigner).setMaxExternalRatioBP(20_00n); - await acl.connect(agentSigner).revokePermission(agentAddress, lido.address, role); - - if (!ctx.isScratch) { - // we need a report to initialize LazyOracle timestamp after the upgrade - // if we are running tests in the mainnet fork environment - await report(ctx); + + if ((await lido.getMaxExternalRatioBP()) < 20_00n) { + const agentSigner = await ctx.getSigner("agent"); + const role = await lido.STAKING_CONTROL_ROLE(); + const agentAddress = await agentSigner.getAddress(); + await acl.connect(agentSigner).grantPermission(agentAddress, lido.address, role); + await lido.connect(agentSigner).setMaxExternalRatioBP(20_00n); + await acl.connect(agentSigner).revokePermission(agentAddress, lido.address, role); + log.success("Setting max external ratio to 20%"); } + + // we need a report to initialize LazyOracle timestamp after the upgrade + // if we are running tests in the mainnet fork environment + await report(ctx); } export type VaultReportItem = { @@ -291,6 +293,90 @@ export async function reportVaultDataWithProof( ); } +/** + * Report data for multiple vaults in a single Merkle tree + * This is useful when you need to ensure all vaults have fresh reports at the same time + * + * @param ctx Protocol context + * @param stakingVaults Array of StakingVault contracts to report + * @param params Parameters for the report. If arrays are provided, they must match the length of stakingVaults + */ +export async function reportVaultsDataWithProof( + ctx: ProtocolContext, + stakingVaults: StakingVault[], + params: { + totalValue?: bigint | bigint[]; + cumulativeLidoFees?: bigint | bigint[]; + liabilityShares?: bigint | bigint[]; + maxLiabilityShares?: bigint | bigint[]; + slashingReserve?: bigint | bigint[]; + reportTimestamp?: bigint; + reportRefSlot?: bigint; + updateReportData?: boolean; + waitForNextRefSlot?: boolean; + } = {}, +) { + const { vaultHub, locator, lazyOracle, hashConsensus } = ctx.contracts; + + if (params.waitForNextRefSlot) { + await waitNextAvailableReportTime(ctx); + } + + // Helper to get value from array or single value + const getValue = (param: T | T[] | undefined, index: number, defaultValue: T): T => { + if (param === undefined) return defaultValue; + return Array.isArray(param) ? param[index] : param; + }; + + // Build vault reports for all vaults + const vaultReports: VaultReportItem[] = await Promise.all( + stakingVaults.map(async (vault, index) => ({ + vault: await vault.getAddress(), + totalValue: getValue(params.totalValue, index, await vaultHub.totalValue(vault)), + cumulativeLidoFees: getValue(params.cumulativeLidoFees, index, 0n), + liabilityShares: getValue(params.liabilityShares, index, await vaultHub.liabilityShares(vault)), + maxLiabilityShares: getValue( + params.maxLiabilityShares, + index, + (await vaultHub.vaultRecord(vault)).maxLiabilityShares, + ), + slashingReserve: getValue(params.slashingReserve, index, 0n), + })), + ); + + // Create single Merkle tree for all vaults + const reportTree = createVaultsReportTree(vaultReports); + + // Update report data once for all vaults + if (params.updateReportData ?? true) { + const reportTimestampArg = params.reportTimestamp ?? (await getCurrentBlockTimestamp()); + const reportRefSlotArg = params.reportRefSlot ?? (await hashConsensus.getCurrentFrame()).refSlot; + + const accountingSigner = await impersonate(await locator.accountingOracle(), ether("100")); + await lazyOracle + .connect(accountingSigner) + .updateReportData(reportTimestampArg, reportRefSlotArg, reportTree.root, ""); + } + + // Update each vault data with its proof from the common tree + const txs = []; + for (let i = 0; i < stakingVaults.length; i++) { + const vaultReport = vaultReports[i]; + const tx = await lazyOracle.updateVaultData( + vaultReport.vault, + vaultReport.totalValue, + vaultReport.cumulativeLidoFees, + vaultReport.liabilityShares, + vaultReport.maxLiabilityShares, + vaultReport.slashingReserve, + reportTree.getProof(i), + ); + txs.push(tx); + } + + return txs; +} + interface CreateVaultResponse { tx: ContractTransactionResponse; proxy: PinnedBeaconProxy; diff --git a/lib/protocol/helpers/withdrawal.ts b/lib/protocol/helpers/withdrawal.ts index ed39081a97..85ed02250d 100644 --- a/lib/protocol/helpers/withdrawal.ts +++ b/lib/protocol/helpers/withdrawal.ts @@ -1,6 +1,6 @@ import { ZeroAddress } from "ethers"; -import { advanceChainTime, certainAddress, ether, impersonate, log } from "lib"; +import { certainAddress, ether, impersonate, log } from "lib"; import { LIMITER_PRECISION_BASE } from "lib/constants"; import { ProtocolContext } from "../types"; @@ -59,17 +59,8 @@ export const finalizeWQViaSubmit = async (ctx: ProtocolContext) => { const lastRequestId = await withdrawalQueue.getLastRequestId(); while (lastRequestId != (await withdrawalQueue.getLastFinalizedRequestId())) { + await lido.connect(ethHolder).submit(ZeroAddress, { value: ethToSubmit }); await report(ctx, { clDiff: 0n, reportElVault: false }); - try { - await lido.connect(ethHolder).submit(ZeroAddress, { value: ethToSubmit }); - } catch (e: unknown) { - const errMsg = e instanceof Error ? e.message : String(e); - if (errMsg.includes("STAKE_LIMIT")) { - await advanceChainTime(2n * 24n * 60n * 60n); - continue; - } - throw e; - } } await setStakingLimit( diff --git a/lib/protocol/networks.ts b/lib/protocol/networks.ts index 1aae864f9a..222c602d30 100644 --- a/lib/protocol/networks.ts +++ b/lib/protocol/networks.ts @@ -124,13 +124,17 @@ async function getForkingNetworkConfig(): Promise { locator: state[Sk.lidoLocator].proxy.address, agentAddress: state[Sk.appAgent].proxy.address, votingAddress: state[Sk.appVoting].proxy.address, - easyTrackAddress: state["easyTrackEVMScriptExecutor"].address, + easyTrackAddress: state[Sk.easyTrackEVMScriptExecutor]?.address, stakingVaultFactory: state[Sk.stakingVaultFactory]?.address, stakingVaultBeacon: state[Sk.stakingVaultBeacon]?.address, operatorGrid: state[Sk.operatorGrid]?.proxy.address, validatorConsolidationRequests: state[Sk.validatorConsolidationRequests]?.address, }; - return new ProtocolNetworkConfig(getPrefixedEnv("MAINNET", defaultEnv), defaults, "state-network-config"); + + const chainId = state[Sk.chainId]; + const prefix = chainId === 1 ? "MAINNET" : chainId === 11155111 ? "SEPOLIA" : chainId === 560048 ? "HOODI" : ""; + + return new ProtocolNetworkConfig(getPrefixedEnv(prefix, defaultEnv), defaults, "state-network-config"); } export async function getNetworkConfig(network: string): Promise { @@ -144,8 +148,6 @@ export async function getNetworkConfig(network: string): Promise { await ensureSomeOddShareRate(ctx); } + await upDefaultTierShareLimit(ctx, ether("250")); + alreadyProvisioned = true; log.success("Provisioned"); diff --git a/lib/state-file.ts b/lib/state-file.ts index 57e71b4fec..22b40843e9 100644 --- a/lib/state-file.ts +++ b/lib/state-file.ts @@ -70,6 +70,7 @@ export enum Sk { gateSealV3 = "gateSealV3", gateSealFactory = "gateSealFactory", gateSealTW = "gateSealTW", + resealManager = "resealManager", stakingRouter = "stakingRouter", burner = "burner", executionLayerRewardsVault = "executionLayerRewardsVault", @@ -87,11 +88,13 @@ export enum Sk { wstETH = "wstETH", lidoLocator = "lidoLocator", chainSpec = "chainSpec", + chainId = "chainId", scratchDeployGasUsed = "scratchDeployGasUsed", minFirstAllocationStrategy = "minFirstAllocationStrategy", accounting = "accounting", vaultHub = "vaultHub", tokenRebaseNotifier = "tokenRebaseNotifier", + tokenRebaseNotifierV3 = "tokenRebaseNotifierV3", // Triggerable withdrawals validatorExitDelayVerifier = "validatorExitDelayVerifier", triggerableWithdrawalsGateway = "triggerableWithdrawalsGateway", @@ -112,7 +115,9 @@ export enum Sk { dgDualGovernance = "dg:dualGovernance", dgEmergencyProtectedTimelock = "dg:emergencyProtectedTimelock", // Easy Track + easyTrack = "easyTrack", vaultsAdapter = "vaultsAdapter", + easyTrackEVMScriptExecutor = "easyTrackEVMScriptExecutor", } export function getAddress(contractKey: Sk, state: DeploymentState): string { @@ -158,6 +163,7 @@ export function getAddress(contractKey: Sk, state: DeploymentState): string { case Sk.executionLayerRewardsVault: case Sk.gateSeal: case Sk.gateSealV3: + case Sk.resealManager: case Sk.hashConsensusForAccountingOracle: case Sk.hashConsensusForValidatorsExitBusOracle: case Sk.ldo: @@ -169,13 +175,14 @@ export function getAddress(contractKey: Sk, state: DeploymentState): string { case Sk.wstETH: case Sk.depositContract: case Sk.tokenRebaseNotifier: + case Sk.tokenRebaseNotifierV3: case Sk.validatorExitDelayVerifier: case Sk.triggerableWithdrawalsGateway: case Sk.stakingVaultFactory: case Sk.minFirstAllocationStrategy: case Sk.validatorConsolidationRequests: case Sk.v3VoteScript: - case Sk.vaultsAdapter: + case Sk.easyTrack: case Sk.gateSealFactory: return state[contractKey].address; default: diff --git a/package.json b/package.json index 2a64e59b45..abb8044fea 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "check": "yarn lint && yarn format && yarn typecheck && yarn validate:configs", "test": "hardhat test test/**/*.test.ts --parallel", "upgrade:deploy": "STEPS_FILE=upgrade/steps-deploy.json yarn hardhat --network custom run scripts/utils/migrate.ts", + "upgrade:deploy:mainnet": "STEPS_FILE=upgrade/steps-deploy.json yarn hardhat --network mainnet run scripts/utils/migrate.ts", "upgrade:mock-voting": "STEPS_FILE=upgrade/steps-mock-voting.json yarn hardhat --network custom run scripts/utils/migrate.ts", "test:forge": "forge test", "test:coverage": "COVERAGE=unit hardhat coverage", @@ -32,7 +33,7 @@ "test:integration": "MODE=forking hardhat test test/integration/**/*.ts", "test:integration:trace": "MODE=forking hardhat test test/integration/**/*.ts --trace --disabletracer", "test:integration:fulltrace": "MODE=forking hardhat test test/integration/**/*.ts --fulltrace --disabletracer", - "test:integration:upgrade": "yarn test:integration:upgrade:helper test/integration/**/*.ts", + "test:integration:upgrade": "STEPS_FILE=upgrade/steps-upgrade-for-tests.json yarn test:integration:upgrade:helper test/integration/**/*.ts", "test:integration:upgrade:helper": "cp deployed-mainnet.json deployed-mainnet-upgrade.json && NETWORK_STATE_FILE=deployed-mainnet-upgrade.json UPGRADE_PARAMETERS_FILE=scripts/upgrade/upgrade-params-mainnet.toml MODE=forking UPGRADE=true GAS_PRIORITY_FEE=1 GAS_MAX_FEE=100 DEPLOYER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 hardhat test --trace --disabletracer", "test:integration:upgrade-hoodi": "yarn test:integration:upgrade:helper-hoodi test/integration/**/*.ts", "test:integration:upgrade:helper-hoodi": "cp deployed-hoodi.json deployed-hoodi-upgrade.json && NETWORK_STATE_FILE=deployed-hoodi-upgrade.json UPGRADE_PARAMETERS_FILE=scripts/upgrade/upgrade-params-hoodi.toml MODE=forking UPGRADE=true GAS_PRIORITY_FEE=1 GAS_MAX_FEE=100 DEPLOYER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 hardhat test --trace --disabletracer", diff --git a/scripts/dao-hoodi-v3-patch-1.sh b/scripts/dao-hoodi-v3-patch-1.sh index 609b931c31..401d11ef89 100755 --- a/scripts/dao-hoodi-v3-patch-1.sh +++ b/scripts/dao-hoodi-v3-patch-1.sh @@ -5,11 +5,9 @@ set -o pipefail export NETWORK=${NETWORK:="hoodi"} # if defined use the value set to default otherwise export RPC_URL=${RPC_URL:="http://127.0.0.1:8545"} # if defined use the value set to default otherwise -export DEPLOYER=${DEPLOYER:="0x26EDb7f0f223A25EE390aCCccb577F3a31edDfC5"} # first acc of default mnemonic "test test ..." +export DEPLOYER=${DEPLOYER:="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"} # first acc of default mnemonic "test test ..." export GAS_PRIORITY_FEE=1 export GAS_MAX_FEE=100 -# https://github.com/eth-clients/hoodi?tab=readme-ov-file#metadata -export GENESIS_TIME=1742213400 export NETWORK_STATE_FILE=${NETWORK_STATE_FILE:="deployed-hoodi.json"} export STEPS_FILE=upgrade/steps-upgrade-hoodi-patch-1.json diff --git a/scripts/dao-hoodi-v3-patch-2.sh b/scripts/dao-hoodi-v3-patch-2.sh new file mode 100755 index 0000000000..8f92384e01 --- /dev/null +++ b/scripts/dao-hoodi-v3-patch-2.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e +u +set -o pipefail + +export NETWORK=${NETWORK:="hoodi"} # if defined use the value set to default otherwise +export RPC_URL=${RPC_URL:="http://127.0.0.1:8545"} # if defined use the value set to default otherwise + +export DEPLOYER=${DEPLOYER:="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"} # first acc of default mnemonic "test test ..." +export GAS_PRIORITY_FEE=1 +export GAS_MAX_FEE=100 + +export NETWORK_STATE_FILE=${NETWORK_STATE_FILE:="deployed-hoodi.json"} +export STEPS_FILE=upgrade/steps-upgrade-hoodi-patch-2.json +export UPGRADE_PARAMETERS_FILE=scripts/upgrade/upgrade-params-hoodi.toml + +yarn hardhat --network $NETWORK run --no-compile scripts/utils/migrate.ts diff --git a/scripts/dao-hoodi-v3-patch-3.sh b/scripts/dao-hoodi-v3-patch-3.sh new file mode 100755 index 0000000000..07a6e18f12 --- /dev/null +++ b/scripts/dao-hoodi-v3-patch-3.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e +u +set -o pipefail + +export NETWORK=${NETWORK:="hoodi"} # if defined use the value set to default otherwise +export RPC_URL=${RPC_URL:="http://127.0.0.1:8545"} # if defined use the value set to default otherwise + +export DEPLOYER=${DEPLOYER:="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"} # first acc of default mnemonic "test test ..." +export GAS_PRIORITY_FEE=1 +export GAS_MAX_FEE=100 + +export NETWORK_STATE_FILE=${NETWORK_STATE_FILE:="deployed-hoodi.json"} +export STEPS_FILE=upgrade/steps-upgrade-hoodi-patch-3.json +export UPGRADE_PARAMETERS_FILE=scripts/upgrade/upgrade-params-hoodi.toml + +yarn hardhat --network $NETWORK run --no-compile scripts/utils/migrate.ts diff --git a/scripts/dao-hoodi-v3-patch-4.sh b/scripts/dao-hoodi-v3-patch-4.sh new file mode 100755 index 0000000000..fa37703237 --- /dev/null +++ b/scripts/dao-hoodi-v3-patch-4.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e +u +set -o pipefail + +export NETWORK=${NETWORK:="hoodi"} # if defined use the value set to default otherwise +export RPC_URL=${RPC_URL:="http://127.0.0.1:8545"} # if defined use the value set to default otherwise + +export DEPLOYER=${DEPLOYER:="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"} # first acc of default mnemonic "test test ..." +export GAS_PRIORITY_FEE=1 +export GAS_MAX_FEE=100 + +export NETWORK_STATE_FILE=${NETWORK_STATE_FILE:="deployed-hoodi.json"} +export STEPS_FILE=upgrade/steps-upgrade-hoodi-patch-4.json +export UPGRADE_PARAMETERS_FILE=scripts/upgrade/upgrade-params-hoodi.toml + +yarn hardhat --network $NETWORK run --no-compile scripts/utils/migrate.ts diff --git a/scripts/scratch/deploy-params-testnet.toml b/scripts/scratch/deploy-params-testnet.toml index d9af0429d5..7f7047967b 100644 --- a/scripts/scratch/deploy-params-testnet.toml +++ b/scripts/scratch/deploy-params-testnet.toml @@ -69,7 +69,7 @@ epochsPerFrame = 12 # Epochs per consensus frame # Vault hub configuration for managing staking vaults [vaultHub] -maxRelativeShareLimitBP = 3000 # Maximum relative share limit in basis points (30%) +maxRelativeShareLimitBP = 1000 # Maximum relative share limit in basis points (30%) # Lazy oracle configuration for delayed reward calculations [lazyOracle] @@ -179,9 +179,9 @@ changeSlot = 0 # Slot number when the change takes effect [operatorGrid] # Default tier parameters for operator classification and fee structure [operatorGrid.defaultTierParams] -shareLimitInEther = "250" # Share limit per tier in ETH -reserveRatioBP = 2000 # Reserve ratio in basis points (20%) -forcedRebalanceThresholdBP = 1800 # Threshold for forced rebalancing in basis points (18%) -infraFeeBP = 500 # Infrastructure fee in basis points (5%) -liquidityFeeBP = 400 # Liquidity provision fee in basis points (4%) -reservationFeeBP = 100 # Reservation fee in basis points (1%) +shareLimitInEther = "0" # Share limit per tier in ETH +reserveRatioBP = 5000 # Reserve ratio in basis points (20%) +forcedRebalanceThresholdBP = 4975 # Threshold for forced rebalancing in basis points (18%) +infraFeeBP = 100 # Infrastructure fee in basis points (5%) +liquidityFeeBP = 650 # Liquidity provision fee in basis points (4%) +reservationFeeBP = 0 # Reservation fee in basis points (1%) diff --git a/scripts/scratch/steps/0130-grant-roles.ts b/scripts/scratch/steps/0130-grant-roles.ts index cc4d96c4cf..b3614a5aff 100644 --- a/scripts/scratch/steps/0130-grant-roles.ts +++ b/scripts/scratch/steps/0130-grant-roles.ts @@ -2,7 +2,6 @@ import { ethers } from "hardhat"; import { Burner, - LazyOracle, OperatorGrid, StakingRouter, TriggerableWithdrawalsGateway, @@ -35,7 +34,6 @@ export async function main() { const vaultHubAddress = state[Sk.vaultHub].proxy.address; const operatorGridAddress = state[Sk.operatorGrid].proxy.address; const triggerableWithdrawalsGatewayAddress = state[Sk.triggerableWithdrawalsGateway].address; - const lazyOracleAddress = state[Sk.lazyOracle].proxy.address; const validatorExitDelayVerifierAddress = state[Sk.validatorExitDelayVerifier].address; // StakingRouter @@ -140,9 +138,6 @@ export async function main() { await makeTx(vaultHub, "grantRole", [await vaultHub.VAULT_MASTER_ROLE(), agentAddress], { from: deployer, }); - await makeTx(vaultHub, "grantRole", [await vaultHub.REDEMPTION_MASTER_ROLE(), agentAddress], { - from: deployer, - }); await makeTx(vaultHub, "grantRole", [await vaultHub.VALIDATOR_EXIT_ROLE(), agentAddress], { from: deployer, }); @@ -152,9 +147,4 @@ export async function main() { await makeTx(operatorGrid, "grantRole", [await operatorGrid.REGISTRY_ROLE(), agentAddress], { from: deployer, }); - - // LazyOracle - const lazyOracle = await loadContract("LazyOracle", lazyOracleAddress); - const updateSanityParamsRole = await lazyOracle.UPDATE_SANITY_PARAMS_ROLE(); - await makeTx(lazyOracle, "grantRole", [updateSanityParamsRole, agentAddress], { from: deployer }); } diff --git a/scripts/upgrade/steps-upgrade-hoodi-patch-2.json b/scripts/upgrade/steps-upgrade-hoodi-patch-2.json new file mode 100644 index 0000000000..185aa8ad76 --- /dev/null +++ b/scripts/upgrade/steps-upgrade-hoodi-patch-2.json @@ -0,0 +1,3 @@ +{ + "steps": ["upgrade/steps/0000-check-env", "upgrade/steps/0100-upgrade-hoodi-to-v3-rc3"] +} diff --git a/scripts/upgrade/steps-upgrade-hoodi-patch-3.json b/scripts/upgrade/steps-upgrade-hoodi-patch-3.json new file mode 100644 index 0000000000..051caeff90 --- /dev/null +++ b/scripts/upgrade/steps-upgrade-hoodi-patch-3.json @@ -0,0 +1,3 @@ +{ + "steps": ["upgrade/steps/0000-check-env", "upgrade/steps/0100-upgrade-hoodi-to-v3-rc4"] +} diff --git a/scripts/upgrade/steps-upgrade-hoodi-patch-4.json b/scripts/upgrade/steps-upgrade-hoodi-patch-4.json new file mode 100644 index 0000000000..2a8d10abec --- /dev/null +++ b/scripts/upgrade/steps-upgrade-hoodi-patch-4.json @@ -0,0 +1,3 @@ +{ + "steps": ["upgrade/steps/0000-check-env", "upgrade/steps/0100-upgrade-hoodi-to-v3-rc5"] +} diff --git a/scripts/upgrade/steps/0000-check-env.ts b/scripts/upgrade/steps/0000-check-env.ts index c5159f9a3a..dac0c4ec0f 100644 --- a/scripts/upgrade/steps/0000-check-env.ts +++ b/scripts/upgrade/steps/0000-check-env.ts @@ -20,6 +20,10 @@ export async function main() { throw new Error("Env variable GAS_MAX_FEE is not set"); } + if (!process.env.GAS_LIMIT) { + throw new Error("Env variable GAS_LIMIT is not set"); + } + if (process.env.MODE === "scratch" && !process.env.GENESIS_TIME) { throw new Error("Env variable GENESIS_TIME is not set"); } diff --git a/scripts/upgrade/steps/0100-deploy-v3-contracts.ts b/scripts/upgrade/steps/0100-deploy-v3-contracts.ts index d8ad95d76d..891a3e6f54 100644 --- a/scripts/upgrade/steps/0100-deploy-v3-contracts.ts +++ b/scripts/upgrade/steps/0100-deploy-v3-contracts.ts @@ -1,4 +1,3 @@ -import { ZeroAddress } from "ethers"; import { ethers } from "hardhat"; import { readUpgradeParameters } from "scripts/utils/upgrade"; @@ -35,22 +34,20 @@ export async function main() { const depositContract = state.chainSpec.depositContractAddress; const hashConsensusAddress = state[Sk.hashConsensusForAccountingOracle].address; const pdgDeployParams = parameters.predepositGuarantee; + const resealManagerAddress = state[Sk.resealManager].address; const proxyContractsOwner = agentAddress; const locatorAddress = state[Sk.lidoLocator].proxy.address; const wstethAddress = state[Sk.wstETH].address; + const oldTokenRateNotifierAddress = state[Sk.tokenRebaseNotifier].address; const locator = await loadContract("LidoLocator", locatorAddress); - const vaultsAdapterAddress = getAddress(Sk.vaultsAdapter, state); // // Deploy V3TemporaryAdmin // - const v3TemporaryAdmin = await deployWithoutProxy(Sk.v3TemporaryAdmin, "V3TemporaryAdmin", deployer, [ - agentAddress, - parameters.chainSpec.isHoodi, - ]); + const v3TemporaryAdmin = await deployWithoutProxy(Sk.v3TemporaryAdmin, "V3TemporaryAdmin", deployer, [agentAddress]); // // Deploy Lido new implementation @@ -67,6 +64,19 @@ export async function main() { lidoAddress, ]); + // + // Deploy new TokenRateNotifier + // + + const newTokenRateNotifier = await deployWithoutProxy(Sk.tokenRebaseNotifierV3, "TokenRateNotifier", deployer, [ + v3TemporaryAdmin.address, + accounting.address, + ]); + + updateObjectInState(Sk.tokenRebaseNotifierV3, { + address: newTokenRateNotifier.address, + }); + // // Deploy AccountingOracle new implementation // @@ -110,7 +120,7 @@ export async function main() { // Prepare initialization data for LazyOracle.initialize(address admin, uint256 quarantinePeriod, uint256 maxRewardRatioBP, uint256 maxLidoFeeRatePerSecond) const lazyOracleInterface = await ethers.getContractFactory("LazyOracle"); const lazyOracleInitData = lazyOracleInterface.interface.encodeFunctionData("initialize", [ - v3TemporaryAdmin.address, + agentAddress, lazyOracleParams.quarantinePeriod, lazyOracleParams.maxRewardRatioBP, lazyOracleParams.maxLidoFeeRatePerSecond, @@ -163,7 +173,7 @@ export async function main() { "VaultHub", proxyContractsOwner, deployer, - [locatorAddress, lidoAddress, hashConsensusAddress, vaultHubParams.relativeShareLimitBP], + [locatorAddress, lidoAddress, hashConsensusAddress, vaultHubParams.maxRelativeShareLimitBP], null, // implementation true, // withStateFile undefined, // signerOrOptions @@ -310,7 +320,7 @@ export async function main() { elRewardsVault: await locator.elRewardsVault(), lido: lidoAddress, oracleReportSanityChecker: newSanityChecker.address, - postTokenRebaseReceiver: ZeroAddress, + postTokenRebaseReceiver: newTokenRateNotifier.address, burner: burner.address, stakingRouter: await locator.stakingRouter(), treasury: treasuryAddress, @@ -382,7 +392,13 @@ export async function main() { await makeTx( v3TemporaryAdminContract, "completeSetup", - [lidoLocatorImpl.address, vaultsAdapterAddress, gateSealAddress], + [ + lidoLocatorImpl.address, + parameters.easyTrack.VaultsAdapter, + gateSealAddress, + resealManagerAddress, + oldTokenRateNotifierAddress, + ], { from: deployer, }, diff --git a/scripts/upgrade/steps/0100-upgrade-hoodi-to-v3-rc2.ts b/scripts/upgrade/steps/0100-upgrade-hoodi-to-v3-rc2.ts index 5b40c1e8f4..3096a7067c 100644 --- a/scripts/upgrade/steps/0100-upgrade-hoodi-to-v3-rc2.ts +++ b/scripts/upgrade/steps/0100-upgrade-hoodi-to-v3-rc2.ts @@ -73,7 +73,7 @@ export async function main(): Promise { locatorAddress, lidoAddress, hashConsensusAddress, - vaultHubParams.relativeShareLimitBP, + vaultHubParams.maxRelativeShareLimitBP, ]); const newVaultHubAddress = state[Sk.vaultHub].implementation.address; console.log("New VaultHub implementation address", newVaultHubAddress); diff --git a/scripts/upgrade/steps/0100-upgrade-hoodi-to-v3-rc3.ts b/scripts/upgrade/steps/0100-upgrade-hoodi-to-v3-rc3.ts new file mode 100644 index 0000000000..04bf18628a --- /dev/null +++ b/scripts/upgrade/steps/0100-upgrade-hoodi-to-v3-rc3.ts @@ -0,0 +1,115 @@ +import assert from "assert"; +import { ethers } from "hardhat"; +import { readUpgradeParameters } from "scripts/utils/upgrade"; + +import { LidoLocator } from "typechain-types"; + +import { deployImplementation, deployWithoutProxy, loadContract, readNetworkState, Sk } from "lib"; + +export async function main(): Promise { + const deployer = (await ethers.provider.getSigner()).address; + assert.equal(process.env.DEPLOYER, deployer); + + const parameters = readUpgradeParameters(); + const state = readNetworkState(); + + // + // Extract necessary addresses and parameters from the state + // + const vaultHubParams = parameters.vaultHub; + + const locatorAddress = state[Sk.lidoLocator].proxy.address; + const lidoAddress = state[Sk.appLido].proxy.address; + const wstethAddress = state[Sk.wstETH].address; + const vaultHubProxyAddress = state[Sk.vaultHub].proxy.address; + const hashConsensusAddress = state[Sk.hashConsensusForAccountingOracle].address; + const beaconAddress = state[Sk.stakingVaultBeacon].address; + const previousFactoryAddress = state[Sk.stakingVaultFactory].address; + + const locator = await loadContract("LidoLocator", locatorAddress); + + // + // New Lido implementation + // + await deployImplementation(Sk.appLido, "Lido", deployer); + const newLidoAddress = state[Sk.appLido].implementation.address; + console.log("New Lido implementation address", newLidoAddress); + + // + // New LazyOracle implementation + // + await deployImplementation(Sk.lazyOracle, "LazyOracle", deployer, [locatorAddress]); + const newLazyOracleAddress = state[Sk.lazyOracle].implementation.address; + console.log("New LazyOracle implementation address", newLazyOracleAddress); + + // + // New OperatorGrid implementation + // + await deployImplementation(Sk.operatorGrid, "OperatorGrid", deployer, [locatorAddress]); + const newOperatorGridAddress = state[Sk.operatorGrid].implementation.address; + console.log("New OperatorGrid implementation address", newOperatorGridAddress); + + // + // New VaultHub implementation + // + await deployImplementation(Sk.vaultHub, "VaultHub", deployer, [ + locatorAddress, + lidoAddress, + hashConsensusAddress, + vaultHubParams.maxRelativeShareLimitBP, + ]); + const newVaultHubAddress = state[Sk.vaultHub].implementation.address; + console.log("New VaultHub implementation address", newVaultHubAddress); + + // + // New Dashboard implementation + // + const dashboardImpl = await deployWithoutProxy(Sk.dashboardImpl, "Dashboard", deployer, [ + lidoAddress, + wstethAddress, + vaultHubProxyAddress, + locatorAddress, + ]); + const dashboardImplAddress = await dashboardImpl.getAddress(); + console.log("New Dashboard implementation address", dashboardImplAddress); + + // + // New VaultFactory implementation + // + const vaultFactory = await deployWithoutProxy(Sk.stakingVaultFactory, "VaultFactory", deployer, [ + locatorAddress, + beaconAddress, + dashboardImplAddress, + previousFactoryAddress, + ]); + const newVaultFactoryAddress = await vaultFactory.getAddress(); + console.log("New VaultFactory implementation address", newVaultFactoryAddress); + + const locatorConfig: LidoLocator.ConfigStruct = { + accountingOracle: await locator.accountingOracle(), + depositSecurityModule: await locator.depositSecurityModule(), + elRewardsVault: await locator.elRewardsVault(), + lido: lidoAddress, + oracleReportSanityChecker: await locator.oracleReportSanityChecker(), + postTokenRebaseReceiver: ethers.ZeroAddress, + burner: await locator.burner(), + stakingRouter: await locator.stakingRouter(), + treasury: await locator.treasury(), + validatorsExitBusOracle: await locator.validatorsExitBusOracle(), + withdrawalQueue: await locator.withdrawalQueue(), + withdrawalVault: await locator.withdrawalVault(), + oracleDaemonConfig: await locator.oracleDaemonConfig(), + validatorExitDelayVerifier: await locator.validatorExitDelayVerifier(), + triggerableWithdrawalsGateway: await locator.triggerableWithdrawalsGateway(), + accounting: await locator.accounting(), + predepositGuarantee: await locator.predepositGuarantee(), + wstETH: wstethAddress, + vaultHub: vaultHubProxyAddress, + vaultFactory: newVaultFactoryAddress, + lazyOracle: await locator.lazyOracle(), + operatorGrid: await locator.operatorGrid(), + }; + const lidoLocatorImpl = await deployImplementation(Sk.lidoLocator, "LidoLocator", deployer, [locatorConfig]); + const newLocatorAddress = await lidoLocatorImpl.getAddress(); + console.log("New LidoLocator implementation address", newLocatorAddress); +} diff --git a/scripts/upgrade/steps/0100-upgrade-hoodi-to-v3-rc4.ts b/scripts/upgrade/steps/0100-upgrade-hoodi-to-v3-rc4.ts new file mode 100644 index 0000000000..3bcb7919c1 --- /dev/null +++ b/scripts/upgrade/steps/0100-upgrade-hoodi-to-v3-rc4.ts @@ -0,0 +1,62 @@ +import assert from "assert"; +import { ethers } from "hardhat"; +import { readUpgradeParameters } from "scripts/utils/upgrade"; + +import { deployImplementation, readNetworkState, Sk } from "lib"; + +export async function main(): Promise { + const deployer = (await ethers.provider.getSigner()).address; + assert.equal(process.env.DEPLOYER, deployer); + + const parameters = readUpgradeParameters(); + const state = readNetworkState(); + + // + // Extract necessary addresses and parameters from the state + // + const locatorAddress = state[Sk.lidoLocator].proxy.address; + const lidoAddress = state[Sk.appLido].proxy.address; + const hashConsensusAddress = state[Sk.hashConsensusForAccountingOracle].address; + + const vaultHubParams = parameters.vaultHub; + + // + // New Lido implementation + // + await deployImplementation(Sk.appLido, "Lido", deployer); + const newLidoImplAddress = state[Sk.appLido].implementation.address; + console.log("New Lido implementation address", newLidoImplAddress); + + // + // New LazyOracle implementation + // + await deployImplementation(Sk.lazyOracle, "LazyOracle", deployer, [locatorAddress]); + const newLazyOracleImplAddress = state[Sk.lazyOracle].implementation.address; + console.log("New LazyOracle implementation address", newLazyOracleImplAddress); + + // + // New OperatorGrid implementation + // + await deployImplementation(Sk.operatorGrid, "OperatorGrid", deployer, [locatorAddress]); + const newOperatorGridImplAddress = state[Sk.operatorGrid].implementation.address; + console.log("New OperatorGrid implementation address", newOperatorGridImplAddress); + + // + // New VaultHub implementation + // + await deployImplementation(Sk.vaultHub, "VaultHub", deployer, [ + locatorAddress, + lidoAddress, + hashConsensusAddress, + vaultHubParams.relativeShareLimitBP, + ]); + const newVaultHubAddress = state[Sk.vaultHub].implementation.address; + console.log("New VaultHub implementation address", newVaultHubAddress); + + // + // New Accounting implementation + // + await deployImplementation(Sk.accounting, "Accounting", deployer, [locatorAddress, lidoAddress]); + const newAccountingImplAddress = state[Sk.accounting].implementation.address; + console.log("New Accounting implementation address", newAccountingImplAddress); +} diff --git a/scripts/upgrade/steps/0100-upgrade-hoodi-to-v3-rc5.ts b/scripts/upgrade/steps/0100-upgrade-hoodi-to-v3-rc5.ts new file mode 100644 index 0000000000..6562b48993 --- /dev/null +++ b/scripts/upgrade/steps/0100-upgrade-hoodi-to-v3-rc5.ts @@ -0,0 +1,110 @@ +import assert from "assert"; +import { ethers } from "hardhat"; + +import { LidoLocator } from "typechain-types"; + +import { deployImplementation, deployWithoutProxy, loadContract, readNetworkState, Sk } from "lib"; + +export async function main(): Promise { + const deployer = (await ethers.provider.getSigner()).address; + assert.equal(process.env.DEPLOYER, deployer); + + const state = readNetworkState(); + + // + // Extract necessary addresses and parameters from the state + // + const locatorAddress = state[Sk.lidoLocator].proxy.address; + const lidoAddress = state[Sk.appLido].proxy.address; + const wstethAddress = state[Sk.wstETH].address; + const vaultHubProxyAddress = state[Sk.vaultHub].proxy.address; + const accountingProxyAddress = state[Sk.accounting].proxy.address; + const agentAddress = state[Sk.appAgent].proxy.address; + + const beaconAddress = state[Sk.stakingVaultBeacon].address; + const previousFactoryAddress = state[Sk.stakingVaultFactory].address; + + const locator = await loadContract("LidoLocator", locatorAddress); + + // + // New OperatorGrid implementation + // + const newOperatorGridImpl = await deployImplementation(Sk.operatorGrid, "OperatorGrid", deployer, [locatorAddress]); + const newOperatorGridImplAddress = await newOperatorGridImpl.getAddress(); + console.log("New OperatorGrid implementation address", newOperatorGridImplAddress); + + // + // New Accounting implementation + // + const newAccountingImpl = await deployImplementation(Sk.accounting, "Accounting", deployer, [ + locatorAddress, + lidoAddress, + ]); + const newAccountingImplAddress = await newAccountingImpl.getAddress(); + console.log("New Accounting implementation address", newAccountingImplAddress); + + // + // New Dashboard implementation + // + const dashboardImpl = await deployWithoutProxy(Sk.dashboardImpl, "Dashboard", deployer, [ + lidoAddress, + wstethAddress, + vaultHubProxyAddress, + locatorAddress, + ]); + const dashboardImplAddress = await dashboardImpl.getAddress(); + console.log("New Dashboard implementation address", dashboardImplAddress); + + // + // New VaultFactory implementation + // + const vaultFactory = await deployWithoutProxy(Sk.stakingVaultFactory, "VaultFactory", deployer, [ + locatorAddress, + beaconAddress, + dashboardImplAddress, + previousFactoryAddress, + ]); + const newVaultFactoryAddress = await vaultFactory.getAddress(); + console.log("New VaultFactory implementation address", newVaultFactoryAddress); + + // + // TokenRateNotifier implementation + // + const newTokenRateNotifier = await deployImplementation(Sk.tokenRebaseNotifierV3, "TokenRateNotifier", deployer, [ + agentAddress, + accountingProxyAddress, + ]); + const newTokenRateNotifierAddress = await newTokenRateNotifier.getAddress(); + console.log("TokenRateNotifier address", newTokenRateNotifierAddress); + + // + // New LidoLocator implementation + // + const locatorConfig: LidoLocator.ConfigStruct = { + accountingOracle: await locator.accountingOracle(), + depositSecurityModule: await locator.depositSecurityModule(), + elRewardsVault: await locator.elRewardsVault(), + lido: await locator.lido(), + oracleReportSanityChecker: await locator.oracleReportSanityChecker(), + postTokenRebaseReceiver: newTokenRateNotifierAddress, + burner: await locator.burner(), + stakingRouter: await locator.stakingRouter(), + treasury: await locator.treasury(), + validatorsExitBusOracle: await locator.validatorsExitBusOracle(), + withdrawalQueue: await locator.withdrawalQueue(), + withdrawalVault: await locator.withdrawalVault(), + oracleDaemonConfig: await locator.oracleDaemonConfig(), + validatorExitDelayVerifier: await locator.validatorExitDelayVerifier(), + triggerableWithdrawalsGateway: await locator.triggerableWithdrawalsGateway(), + accounting: await locator.accounting(), + predepositGuarantee: await locator.predepositGuarantee(), + wstETH: await locator.wstETH(), + vaultHub: await locator.vaultHub(), + vaultFactory: newVaultFactoryAddress, + lazyOracle: await locator.lazyOracle(), + operatorGrid: await locator.operatorGrid(), + }; + const lidoLocatorImpl = await deployImplementation(Sk.lidoLocator, "LidoLocator", deployer, [locatorConfig]); + const newLocatorAddress = await lidoLocatorImpl.getAddress(); + console.log("New LidoLocator implementation address", newLocatorAddress); +} diff --git a/scripts/upgrade/steps/0200-deploy-v3-upgrading-contracts.ts b/scripts/upgrade/steps/0200-deploy-v3-upgrading-contracts.ts index c0fb9728d3..37313e8455 100644 --- a/scripts/upgrade/steps/0200-deploy-v3-upgrading-contracts.ts +++ b/scripts/upgrade/steps/0200-deploy-v3-upgrading-contracts.ts @@ -27,11 +27,13 @@ export async function main() { oldLocatorImplementation, lidoImplementation, await accountingOracle.proxy__getImplementation(), + getAddress(Sk.tokenRebaseNotifier, state), // New implementations state[Sk.lidoLocator].implementation.address, state[Sk.appLido].implementation.address, state[Sk.accountingOracle].implementation.address, + getAddress(Sk.tokenRebaseNotifierV3, state), // New fancy proxy and blueprint contracts state[Sk.stakingVaultBeacon].address, @@ -39,9 +41,6 @@ export async function main() { state[Sk.dashboardImpl].address, getAddress(Sk.gateSealV3, state), - // EasyTrack addresses - getAddress(Sk.vaultsAdapter, state), - // Existing proxies and contracts getAddress(Sk.aragonKernel, state), getAddress(Sk.appAgent, state), @@ -50,6 +49,22 @@ export async function main() { getAddress(Sk.appVoting, state), getAddress(Sk.dgDualGovernance, state), getAddress(Sk.aragonAcl, state), + getAddress(Sk.resealManager, state), + + // EasyTrack addresses + getAddress(Sk.easyTrack, state), + parameters.easyTrack.VaultsAdapter, + + // EasyTrack new factories + parameters.easyTrack.newFactories.AlterTiersInOperatorGrid, + parameters.easyTrack.newFactories.RegisterGroupsInOperatorGrid, + parameters.easyTrack.newFactories.RegisterTiersInOperatorGrid, + parameters.easyTrack.newFactories.UpdateGroupsShareLimitInOperatorGrid, + parameters.easyTrack.newFactories.SetJailStatusInOperatorGrid, + parameters.easyTrack.newFactories.UpdateVaultsFeesInOperatorGrid, + parameters.easyTrack.newFactories.ForceValidatorExitsInVaultHub, + parameters.easyTrack.newFactories.SetLiabilitySharesTargetInVaultHub, + parameters.easyTrack.newFactories.SocializeBadDebtInVaultHub, ]; const template = await deployWithoutProxy(Sk.v3Template, "V3Template", deployer, [ @@ -59,6 +74,6 @@ export async function main() { ]); await deployWithoutProxy(Sk.v3VoteScript, "V3VoteScript", deployer, [ - [template.address, state[Sk.appLido].aragonApp.id], + [template.address, state[Sk.appLido].aragonApp.id, parameters.v3VoteScript.timeConstraintsContract], ]); } diff --git a/scripts/upgrade/steps/deploy-easy-track-mock.ts b/scripts/upgrade/steps/deploy-easy-track-mock.ts deleted file mode 100644 index 304014f48d..0000000000 --- a/scripts/upgrade/steps/deploy-easy-track-mock.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ethers } from "hardhat"; - -import { VaultsAdapterMock } from "typechain-types"; - -import { loadContract } from "lib/contract"; -import { deployWithoutProxy } from "lib/deploy"; -import { log } from "lib/log"; -import { Sk } from "lib/state-file"; - -const EVM_SCRIPT_EXECUTOR = process.env.EVM_SCRIPT_EXECUTOR as string; -if (!EVM_SCRIPT_EXECUTOR) { - throw new Error("EVM_SCRIPT_EXECUTOR environment variable is not set"); -} - -export async function main() { - const deployer = (await ethers.provider.getSigner()).address; - - const vaultsAdapterMock_ = await deployWithoutProxy(Sk.vaultsAdapter, "VaultsAdapterMock", deployer, [ - EVM_SCRIPT_EXECUTOR, - ]); - await vaultsAdapterMock_.waitForDeployment(); - - log.success("Deployed VaultsAdapterMock", vaultsAdapterMock_.address); - - const vaultsAdapterMock = await loadContract("VaultsAdapterMock", vaultsAdapterMock_.address); - - // Check that there is a contract at vaultsAdapterMock.evmScriptExecutor - const evmScriptExecutorAddress = await vaultsAdapterMock.evmScriptExecutor(); - const code = await ethers.provider.getCode(evmScriptExecutorAddress); - if (code === "0x") { - throw new Error(`No contract found at vaultsAdapterMock.evmScriptExecutor address: ${evmScriptExecutorAddress}`); - } -} - -main().catch((error) => { - log.error(error); - process.exitCode = 1; -}); diff --git a/scripts/upgrade/upgrade-params-hoodi.toml b/scripts/upgrade/upgrade-params-hoodi.toml index e4b482da22..a2576fffab 100644 --- a/scripts/upgrade/upgrade-params-hoodi.toml +++ b/scripts/upgrade/upgrade-params-hoodi.toml @@ -7,7 +7,6 @@ slotsPerEpoch = 32 # Number of slots per epoch in Ethereum co secondsPerSlot = 12 # Duration of each slot in seconds genesisTime = 1742213400 # Ethereum Hoodi testnet genesis timestamp depositContract = "0x00000000219ab540356cBB839Cbe05303d7705Fa" # Ethereum Hoodi testnet deposit contract -isHoodi = true # Gate seal configuration for vault operations [gateSealForVaults] @@ -20,6 +19,20 @@ initialValidatorExitFeeLimit = "100000000000000000" # Max validato # Phase 1: Pilot (starts with Lido V3 upgrade enacted) - https://research.lido.fi/t/lido-v3-design-implementation-proposal/10665 maxGroupShareLimit = "50000000000000000000000" # Max share limit which can be set for group by EasyTrack maxDefaultTierShareLimit = 0 # Max share limit which can be set for default tier by EasyTrack +VaultsAdapter = "0x0000000000000000000000000000000000000000" # TODO: update upon deployment + +# Must be deployed before the V3 upgrading contracts +[easyTrack.newFactories] +# TODO: update upon deployment +AlterTiersInOperatorGrid = "0x00000000000000000000000000000000000fAc01" +RegisterGroupsInOperatorGrid = "0x00000000000000000000000000000000000fAc02" +RegisterTiersInOperatorGrid = "0x00000000000000000000000000000000000FAc03" +SetJailStatusInOperatorGrid = "0x00000000000000000000000000000000000faC04" +SetLiabilitySharesTargetInVaultHub = "0x00000000000000000000000000000000000FAc05" +SocializeBadDebtInVaultHub = "0x00000000000000000000000000000000000Fac06" +ForceValidatorExitsInVaultHub = "0x00000000000000000000000000000000000faC07" +UpdateGroupsShareLimitInOperatorGrid = "0x00000000000000000000000000000000000FAc08" +UpdateVaultsFeesInOperatorGrid = "0x00000000000000000000000000000000000fac09" # Vault hub configuration for managing staking vaults [vaultHub] @@ -55,3 +68,11 @@ isMigrationAllowed = true # Must be on for the upgrade to work (for # Oracle and consensus version configuration [oracleVersions] ao_consensus_version = 5 # Accounting Oracle consensus version + +[v3VoteScript] +# Expiry timestamp after which the upgrade transaction will revert +# Format: Unix timestamp (seconds since epoch) +# The upgrade transaction must be executed before this deadline +expiryTimestamp = 1765324800 # December 10, 2025 00:00:00 UTC +# Initial maximum external ratio in basis points for Lido v3 +initialMaxExternalRatioBP = 300 # 3% value set upon upgrade for the initial phase diff --git a/scripts/upgrade/upgrade-params-mainnet.toml b/scripts/upgrade/upgrade-params-mainnet.toml index 19bf1e8697..f40dab949e 100644 --- a/scripts/upgrade/upgrade-params-mainnet.toml +++ b/scripts/upgrade/upgrade-params-mainnet.toml @@ -8,7 +8,6 @@ slotsPerEpoch = 32 # Number of slots per epoch in Ethereum co secondsPerSlot = 12 # Duration of each slot in seconds genesisTime = 1606824023 # Ethereum mainnet genesis timestamp depositContract = "0x00000000219ab540356cBB839Cbe05303d7705Fa" # Official ETH2 deposit contract -isHoodi = false # Gate seal configuration for vault operations [gateSealForVaults] @@ -16,15 +15,24 @@ sealDuration = 1209600 # 14 days sealingCommittee = "0x8772E3a2D86B9347A2688f9bc1808A6d8917760C" [easyTrack] -trustedCaller = "0x0000000000000000000000000000000000000000" # Address of stVaults EasyTrack committee # TODO: update the placeholder -initialValidatorExitFeeLimit = "100000000000000000" # Max validator exit fee which can be spend for single key exit request -# Phase 1: Pilot (starts with Lido V3 upgrade enacted) - https://research.lido.fi/t/lido-v3-design-implementation-proposal/10665 -maxGroupShareLimit = "250000000000000000000000" # Max share limit which can be set for group by EasyTrack -maxDefaultTierShareLimit = 0 # Max share limit which can be set for default tier by EasyTrack +VaultsAdapter = "0x4CC57Fcb95429CBdC596824a40A92D1245FDb92b" # TODO: update upon deployment + +# Must be deployed before the V3 upgrading contracts +[easyTrack.newFactories] +# TODO: update upon deployment +AlterTiersInOperatorGrid = "0x00000000000000000000000000000000000fAc01" +RegisterGroupsInOperatorGrid = "0x00000000000000000000000000000000000fAc02" +RegisterTiersInOperatorGrid = "0x00000000000000000000000000000000000FAc03" +SetJailStatusInOperatorGrid = "0x00000000000000000000000000000000000faC04" +SetLiabilitySharesTargetInVaultHub = "0x00000000000000000000000000000000000FAc05" +SocializeBadDebtInVaultHub = "0x00000000000000000000000000000000000Fac06" +ForceValidatorExitsInVaultHub = "0x00000000000000000000000000000000000faC07" +UpdateGroupsShareLimitInOperatorGrid = "0x00000000000000000000000000000000000FAc08" +UpdateVaultsFeesInOperatorGrid = "0x00000000000000000000000000000000000fac09" # Vault hub configuration for managing staking vaults [vaultHub] -relativeShareLimitBP = 3000 # Relative share limit in basis points (30%) +maxRelativeShareLimitBP = 1000 # 10%, Absolute Max possible shareLimit of a vault relative to Lido TVL (in basis points) # Lazy oracle configuration for delayed reward calculations - https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-32.md [lazyOracle] @@ -34,36 +42,41 @@ maxLidoFeeRatePerSecond = "180000000000000000" # Maximum Lido fee rate per secon # Predeposit guarantee configuration for validator deposit guarantees [predepositGuarantee] -genesisForkVersion = "0x00000000" # Ethereum mainnet genesis fork version -gIndex = "0x0000000000000000000000000000000000000000000000000096000000000028" # Generalized index for state verification -gIndexAfterChange = "0x0000000000000000000000000000000000000000000000000096000000000028" +genesisForkVersion = "0x00000000" # Ethereum mainnet genesis fork version: https://github.com/ethereum/consensus-specs/blob/01b53691dcc36d37a5ad8994b3a32d8de69fb1aa/configs/mainnet.yaml#L30 +gIndex = "0x0000000000000000000000000000000000000000000000000096000000000028" # Generalized index for state verification: https://research.lido.fi/t/lip-27-ensuring-compatibility-with-ethereum-s-pectra-upgrade/9444#p-20086-update-gindexes-5 +gIndexAfterChange = "0x0000000000000000000000000000000000000000000000000096000000000028" # Required for hardfork upgrades, the same for now changeSlot = 0 # Slot number when the change takes effect # Operator grid configuration for managing staking operators [operatorGrid] # Default tier parameters for operator classification and fee structure +# https://research.lido.fi/t/lido-v3-design-implementation-proposal/10665#p-22926-rollout-plan-9 [operatorGrid.defaultTierParams] -shareLimitInEther = "250" # Share limit per tier in ETH -reserveRatioBP = 2000 # Reserve ratio in basis points (20%) -forcedRebalanceThresholdBP = 1800 # Threshold for forced rebalancing in basis points (18%) -infraFeeBP = 500 # Infrastructure fee in basis points (5%) -liquidityFeeBP = 400 # Liquidity provision fee in basis points (4%) -reservationFeeBP = 100 # Reservation fee in basis points (1%) +shareLimitInEther = "0" # Share limit per tier in ETH +reserveRatioBP = 5000 # Reserve ratio in basis points (50%) +forcedRebalanceThresholdBP = 4975 # Threshold for forced rebalancing in basis points (49.75%) +# https://research.lido.fi/t/default-risk-assessment-framework-and-fees-parameters-for-lido-v3-stvaults/10504#p-22550-table-3-fees-9 +infraFeeBP = 100 # Infrastructure fee in basis points (1%) +liquidityFeeBP = 650 # Liquidity provision fee in basis points (6.5%) +reservationFeeBP = 0 # Reservation fee in basis points (0%) [burner] isMigrationAllowed = true # Must be on for the upgrade to work (for scratch is it false) # Oracle and consensus version configuration [oracleVersions] -ao_consensus_version = 4 # Accounting Oracle consensus version +ao_consensus_version = 5 # Accounting Oracle consensus version: https://etherscan.io/address/0x852deD011285fe67063a08005c71a85690503Cee#readProxyContract#F16 [v3VoteScript] # Expiry timestamp after which the upgrade transaction will revert # Format: Unix timestamp (seconds since epoch) # The upgrade transaction must be executed before this deadline -expiryTimestamp = 1765324800 # December 10, 2025 00:00:00 UTC +expiryTimestamp = 1768435200 # January 15, 2026 0:00:00 UTC # Initial maximum external ratio in basis points for Lido v3 +# https://research.lido.fi/t/lido-v3-design-implementation-proposal/10665#p-22926-rollout-plan-9 initialMaxExternalRatioBP = 300 # 3% value set upon upgrade for the initial phase # Sources (todo) # - https://research.lido.fi/t/default-risk-assessment-framework-and-fees-parameters-for-lido-v3-stvaults/10504 + +timeConstraintsContract = "0x2a30F5aC03187674553024296bed35Aa49749DDa" # Predeployed TimeConstraints contract on mainnet diff --git a/scripts/utils/upgrade.ts b/scripts/utils/upgrade.ts index 0a34415739..df1dba865c 100644 --- a/scripts/utils/upgrade.ts +++ b/scripts/utils/upgrade.ts @@ -1,4 +1,4 @@ -import { TransactionReceipt } from "ethers"; +import { TransactionReceipt, TransactionResponse } from "ethers"; import fs from "fs"; import * as toml from "@iarna/toml"; @@ -12,6 +12,8 @@ import { loadContract } from "lib/contract"; import { findEventsWithInterfaces } from "lib/event"; import { DeploymentState, getAddress, Sk } from "lib/state-file"; +import { ONE_HOUR } from "test/suite"; + const FUSAKA_TX_LIMIT = 2n ** 24n; // 16M = 16_777_216 const UPGRADE_PARAMETERS_FILE = process.env.UPGRADE_PARAMETERS_FILE; @@ -91,8 +93,21 @@ export async function mockDGAragonVoting( log.success("Proposal scheduled: gas used", scheduleReceipt.gasUsed); await advanceChainTime(afterScheduleDelay); - const proposalExecutedTx = await timelock.connect(deployer).execute(proposalId); - const proposalExecutedReceipt = (await proposalExecutedTx.wait())!; + let proposalExecutedTx: TransactionResponse; + let revertedDueToTimeConstraints: boolean = true; + let attempts: number = 0; + + while (revertedDueToTimeConstraints && attempts < 24) { + try { + proposalExecutedTx = await timelock.connect(deployer).execute(proposalId); + revertedDueToTimeConstraints = false; + } catch { + await advanceChainTime(ONE_HOUR); + attempts++; + } + } + + const proposalExecutedReceipt = (await proposalExecutedTx!.wait())!; log.success("Proposal executed: gas used", proposalExecutedReceipt.gasUsed); if (proposalExecutedReceipt.gasUsed > FUSAKA_TX_LIMIT) { diff --git a/tasks/validate-configs.ts b/tasks/validate-configs.ts index 04d5df0295..e8542d052e 100644 --- a/tasks/validate-configs.ts +++ b/tasks/validate-configs.ts @@ -85,53 +85,25 @@ const EXPECTED_MISSING_IN_SCRATCH = [ path: "chainSpec.depositContract", reason: "Deposit contract address is set via environment variables in scratch deployment", }, - { - path: "chainSpec.isHoodi", - reason: "Scratch is on fork", - }, - { - path: "gateSealForVaults.address", - reason: "Gate seal configuration differs between upgrade and scratch contexts", - }, { path: "gateSealForVaults.sealingCommittee", - reason: "Gate seal configuration differs between upgrade and scratch contexts", + reason: "There are no GateSeals in scratch deployment", }, { path: "gateSealForVaults.sealDuration", - reason: "Gate seal configuration differs between upgrade and scratch contexts", - }, - { - path: "easyTrack.vaultsAdapter", - reason: "EasyTrack configuration is upgrade-specific", - }, - { - path: "easyTrack.trustedCaller", - reason: "EasyTrack configuration is upgrade-specific", - }, - { - path: "easyTrack.initialValidatorExitFeeLimit", - reason: "EasyTrack configuration is upgrade-specific", + reason: "There are no GateSeals in scratch deployment", }, { - path: "easyTrack.maxGroupShareLimit", - reason: "EasyTrack configuration is upgrade-specific", + path: "easyTrack.newFactories", + reason: "EasyTrack is missing in scratch deployment", }, { - path: "easyTrack.maxDefaultTierShareLimit", - reason: "EasyTrack configuration is upgrade-specific", + path: "easyTrack.VaultsAdapter", + reason: "EasyTrack is missing in scratch deployment", }, { path: "predepositGuarantee.genesisForkVersion", - reason: "Genesis fork version is upgrade-specific configuration", - }, - { - path: "delegation.wethContract", - reason: "Delegation is upgrade-specific configuration", - }, - { - path: "oracleVersions.vebo_consensus_version", - reason: "Oracle versions are upgrade-specific configuration", + reason: "Genesis fork version is taken from the network-state file during scratch deployment", //TODO: fix it }, { path: "oracleVersions.ao_consensus_version", @@ -145,12 +117,15 @@ const EXPECTED_MISSING_IN_SCRATCH = [ path: "v3VoteScript.initialMaxExternalRatioBP", reason: "V3 vote script initial max external ratio BP is upgrade-specific configuration", }, + { + path: "v3VoteScript.timeConstraintsContract", + reason: "V3 vote script time constraints contract is upgrade-specific configuration", + }, ]; // Special mappings where the same concept has different names const PATH_MAPPINGS: Record = { - "vaultHub.relativeShareLimitBP": "vaultHub.maxRelativeShareLimitBP", - "aragonAppVersions": "appVersions", // Handle different naming + aragonAppVersions: "appVersions", // Handle different naming }; function getNestedValue(obj: unknown, path: string): unknown { diff --git a/tasks/verify-contracts.ts b/tasks/verify-contracts.ts index 632f8b1b36..33317191b9 100644 --- a/tasks/verify-contracts.ts +++ b/tasks/verify-contracts.ts @@ -33,6 +33,7 @@ const errors = [] as string[]; task("verify:deployed", "Verifies deployed contracts based on state file") .addOptionalParam("file", "Path to network state file") + .addOptionalParam("only", "Comma-separated list of paths to contracts to verify") .setAction(async (taskArgs: TaskArguments, hre: HardhatRuntimeEnvironment) => { try { const network = hre.network.name; @@ -44,10 +45,12 @@ task("verify:deployed", "Verifies deployed contracts based on state file") const networkStateFilePath = path.resolve("./", networkStateFile); const data = await fs.readFile(networkStateFilePath, "utf8"); const networkState = JSON.parse(data) as NetworkState; + const onlyContracts = taskArgs.only?.split(",") ?? []; const deployedContracts = Object.values(networkState) - .filter((contract): contract is Contract => typeof contract === "object") - .flatMap(getDeployedContract); + .filter((c): c is Contract => typeof c === "object") + .flatMap(getDeployedContract) + .filter((c) => (onlyContracts.length > 0 ? onlyContracts.includes(c.contract ?? "") : true)); // Not using Promise.all to avoid logging messages out of order for (const contract of deployedContracts) { diff --git a/test/0.4.24/lido/lido.staking-limit.test.ts b/test/0.4.24/lido/lido.staking-limit.test.ts index c5a8f76ac5..1e5c0714df 100644 --- a/test/0.4.24/lido/lido.staking-limit.test.ts +++ b/test/0.4.24/lido/lido.staking-limit.test.ts @@ -84,6 +84,21 @@ describe("Lido.sol:staking-limit", () => { expect(await lido.getCurrentStakeLimit()).to.equal(maxStakeLimit); }); + it("Works with the maximum possible max stake limit", async () => { + const maxAllowed = (2n ** 96n - 1n) / 2n; + const bigStakeLimitIncreasePerBlock = maxAllowed / 7200n; + await expect(lido.setStakingLimit(maxAllowed, bigStakeLimitIncreasePerBlock)) + .to.emit(lido, "StakingLimitSet") + .withArgs(maxAllowed, bigStakeLimitIncreasePerBlock); + }); + + it("Reverts if the max stake limit is too large", async () => { + const nonAllowed = (2n ** 96n - 1n) / 2n + 1n; + await expect(lido.setStakingLimit(nonAllowed, stakeLimitIncreasePerBlock)).to.be.revertedWith( + "TOO_LARGE_MAX_STAKE_LIMIT", + ); + }); + it("Reverts if the caller is unauthorized", async () => { await expect( lido.connect(stranger).setStakingLimit(maxStakeLimit, stakeLimitIncreasePerBlock), diff --git a/test/0.4.24/nor/nor.aux.test.ts b/test/0.4.24/nor/nor.aux.test.ts index 73ab5f3e04..7410dcfb3c 100644 --- a/test/0.4.24/nor/nor.aux.test.ts +++ b/test/0.4.24/nor/nor.aux.test.ts @@ -1,5 +1,5 @@ import { expect } from "chai"; -import { encodeBytes32String, ZeroAddress } from "ethers"; +import { encodeBytes32String } from "ethers"; import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; @@ -304,10 +304,4 @@ describe("NodeOperatorsRegistry.sol:auxiliary", () => { .withArgs(nonce + 1n); }); }); - - context("transferToVault", () => { - it("Reverts always", async () => { - await expect(nor.transferToVault(ZeroAddress)).to.be.revertedWith("NOT_SUPPORTED"); - }); - }); }); diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index a72f632e6c..f3f73e516a 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -1495,7 +1495,7 @@ describe("Dashboard.sol", () => { it("requests a tier change", async () => { await setup({ owner: dashboard }); - await expect(dashboard.changeTier(1, 100n)).to.emit(operatorGrid, "RoleMemberConfirmed"); + await expect(dashboard.changeTier(1, 555n)).to.emit(operatorGrid, "RoleMemberConfirmed"); }); }); diff --git a/test/0.8.25/vaults/operatorGrid/operatorGrid.test.ts b/test/0.8.25/vaults/operatorGrid/operatorGrid.test.ts index 69326c0550..f9d6510612 100644 --- a/test/0.8.25/vaults/operatorGrid/operatorGrid.test.ts +++ b/test/0.8.25/vaults/operatorGrid/operatorGrid.test.ts @@ -26,6 +26,7 @@ import { impersonate, MAX_FEE_BP, MAX_RESERVE_RATIO_BP, + MAX_UINT96, } from "lib"; import { deployLidoLocator, updateLidoLocatorImplementation } from "test/deploy"; @@ -202,6 +203,7 @@ describe("OperatorGrid.sol", () => { "InvalidInitialization", ); }); + it("reverts on `_admin` address is zero", async () => { const operatorGridProxy = await ethers.deployContract( "OssifiableProxy", @@ -221,6 +223,7 @@ describe("OperatorGrid.sol", () => { .to.be.revertedWithCustomError(operatorGridImpl, "ZeroArgument") .withArgs("_admin"); }); + it("reverts on invalid `_defaultTierParams`", async () => { const operatorGridProxy = await ethers.deployContract( "OssifiableProxy", @@ -230,7 +233,7 @@ describe("OperatorGrid.sol", () => { const operatorGridLocal = await ethers.getContractAt("OperatorGrid", operatorGridProxy, deployer); const defaultTierParams = { shareLimit: DEFAULT_TIER_SHARE_LIMIT, - reserveRatioBP: RESERVE_RATIO, + reserveRatioBP: RESERVE_RATIO + 10, forcedRebalanceThresholdBP: RESERVE_RATIO, infraFeeBP: INFRA_FEE, liquidityFeeBP: LIQUIDITY_FEE, @@ -238,7 +241,27 @@ describe("OperatorGrid.sol", () => { }; await expect(operatorGridLocal.initialize(stranger, defaultTierParams)) .to.be.revertedWithCustomError(operatorGridLocal, "ForcedRebalanceThresholdTooHigh") - .withArgs("0", RESERVE_RATIO, RESERVE_RATIO); + .withArgs("0", RESERVE_RATIO, RESERVE_RATIO + 10); + }); + + it("reverts on default tier share limit is greater than uint96 max", async () => { + const operatorGridProxy = await ethers.deployContract( + "OssifiableProxy", + [operatorGridImpl, deployer, new Uint8Array()], + deployer, + ); + const operatorGridLocal = await ethers.getContractAt("OperatorGrid", operatorGridProxy, deployer); + const defaultTierParams = { + shareLimit: MAX_UINT96 + 1n, + reserveRatioBP: RESERVE_RATIO, + forcedRebalanceThresholdBP: FORCED_REBALANCE_THRESHOLD, + infraFeeBP: INFRA_FEE, + liquidityFeeBP: LIQUIDITY_FEE, + reservationFeeBP: RESERVATION_FEE, + }; + await expect(operatorGridLocal.initialize(stranger, defaultTierParams)) + .to.be.revertedWithCustomError(operatorGridLocal, "SafeCastOverflowedUintDowncast") + .withArgs(96, MAX_UINT96 + 1n); }); }); @@ -267,6 +290,12 @@ describe("OperatorGrid.sol", () => { ); }); + it("reverts if share limit is greater than uint96 max", async function () { + await expect(operatorGrid.registerGroup(nodeOperator1, MAX_UINT96 + 1n)) + .to.be.revertedWithCustomError(operatorGrid, "SafeCastOverflowedUintDowncast") + .withArgs(96, MAX_UINT96 + 1n); + }); + it("reverts on updateGroupShareLimit when _nodeOperator address is zero", async function () { await expect(operatorGrid.updateGroupShareLimit(ZeroAddress, 1000)).to.be.revertedWithCustomError( operatorGrid, @@ -280,6 +309,14 @@ describe("OperatorGrid.sol", () => { ).to.be.revertedWithCustomError(operatorGrid, "GroupNotExists"); }); + it("reverts on updateGroupShareLimit when _shareLimit is greater than uint96 max", async function () { + const groupOperator = certainAddress("new-operator-group"); + await operatorGrid.registerGroup(groupOperator, MAX_UINT96); + await expect(operatorGrid.updateGroupShareLimit(groupOperator, MAX_UINT96 + 1n)) + .to.be.revertedWithCustomError(operatorGrid, "SafeCastOverflowedUintDowncast") + .withArgs(96, MAX_UINT96 + 1n); + }); + it("add a new group", async function () { const groupOperator = certainAddress("new-operator-group"); const shareLimit = 2001; @@ -418,10 +455,27 @@ describe("OperatorGrid.sol", () => { .withArgs("_nodeOperator"); }); - it("works", async function () { - await expect(operatorGrid.alterTiers([0], [tiers[0]])) - .to.emit(operatorGrid, "TierUpdated") - .withArgs(0, tierShareLimit, reserveRatio, forcedRebalanceThreshold, infraFee, liquidityFee, reservationFee); + it("reverts if tier share limit is greater than uint96 max", async function () { + await operatorGrid.registerGroup(groupOperator, MAX_UINT96); + await expect(operatorGrid.registerTiers(groupOperator, [{ ...tiers[0], shareLimit: MAX_UINT96 + 1n }])) + .to.be.revertedWithCustomError(operatorGrid, "SafeCastOverflowedUintDowncast") + .withArgs(96, MAX_UINT96 + 1n); + }); + + it("registerTiers works", async function () { + await operatorGrid.registerGroup(groupOperator, MAX_UINT96); + await expect(operatorGrid.registerTiers(groupOperator, tiers)) + .to.emit(operatorGrid, "TierAdded") + .withArgs( + groupOperator, + await operatorGrid.tiersCount(), + tierShareLimit, + reserveRatio, + forcedRebalanceThreshold, + infraFee, + liquidityFee, + reservationFee, + ); }); it("tierCount - works", async function () { @@ -526,6 +580,14 @@ describe("OperatorGrid.sol", () => { ); }); + it("alterTiers - reverts if share limit is greater than uint96 max", async function () { + await operatorGrid.registerGroup(nodeOperator1, MAX_UINT96); + await operatorGrid.registerTiers(nodeOperator1, tiers); + await expect(operatorGrid.alterTiers([0], [{ ...tiers[0], shareLimit: MAX_UINT96 + 1n }])) + .to.be.revertedWithCustomError(operatorGrid, "SafeCastOverflowedUintDowncast") + .withArgs(96, MAX_UINT96 + 1n); + }); + it("alterTiers - updates multiple tiers at once", async function () { await operatorGrid.registerGroup(nodeOperator1, 1000); await operatorGrid.registerTiers(nodeOperator1, [ @@ -688,6 +750,30 @@ describe("OperatorGrid.sol", () => { ).to.be.revertedWithCustomError(operatorGrid, "RequestedShareLimitTooHigh"); }); + it("reverts if requested share limit is less than vault liability shares", async function () { + const shareLimit = 1000; + await operatorGrid.registerGroup(nodeOperator1, shareLimit + 1); + await operatorGrid.registerTiers(nodeOperator1, [ + { + shareLimit: shareLimit, + reserveRatioBP: 2000, + forcedRebalanceThresholdBP: 1800, + infraFeeBP: 500, + liquidityFeeBP: 400, + reservationFeeBP: 100, + }, + ]); + + await vaultHub.mock__setVaultRecord(vault_NO1_V1, { + ...record, + liabilityShares: shareLimit, + }); + + await expect( + operatorGrid.connect(nodeOperator1).changeTier(vault_NO1_V1, 1, shareLimit - 1), + ).to.be.revertedWithCustomError(operatorGrid, "RequestedShareLimitTooLow"); + }); + it("Cannot change tier to the default tier from non-default tier", async function () { // First change to non-default tier await operatorGrid.registerGroup(nodeOperator1, 1000); @@ -770,11 +856,11 @@ describe("OperatorGrid.sol", () => { }); it("reverts if TierLimitExceeded", async function () { - const shareLimit = 1000; - await operatorGrid.registerGroup(nodeOperator1, 1000); + const tierShareLimit = 1002; + await operatorGrid.registerGroup(nodeOperator1, tierShareLimit + 1); await operatorGrid.registerTiers(nodeOperator1, [ { - shareLimit: shareLimit, + shareLimit: tierShareLimit, reserveRatioBP: 2000, forcedRebalanceThresholdBP: 1800, infraFeeBP: 500, @@ -783,10 +869,9 @@ describe("OperatorGrid.sol", () => { }, ]); - //just for test - update sharesMinted for vaultHub socket const _liabilityShares = 1001; await vaultHub.mock__setVaultConnection(vault_NO1_V1, { - shareLimit: shareLimit, + shareLimit: 1000, reserveRatioBP: 2000, forcedRebalanceThresholdBP: 1800, infraFeeBP: 500, @@ -806,10 +891,34 @@ describe("OperatorGrid.sol", () => { //and update tier sharesMinted await operatorGrid.connect(vaultHubAsSigner).onMintedShares(vault_NO1_V1, _liabilityShares, false); - await operatorGrid.connect(vaultOwner).changeTier(vault_NO1_V1, 1, shareLimit); - await expect( - operatorGrid.connect(nodeOperator1).changeTier(vault_NO1_V1, 1, shareLimit), - ).to.be.revertedWithCustomError(operatorGrid, "TierLimitExceeded"); + await operatorGrid.connect(vaultOwner).changeTier(vault_NO1_V1, 1, _liabilityShares); + await operatorGrid.connect(nodeOperator1).changeTier(vault_NO1_V1, 1, _liabilityShares); + + await vaultHub.mock__setVaultConnection(vault_NO1_V2, { + shareLimit: 1000, + reserveRatioBP: 2000, + forcedRebalanceThresholdBP: 1800, + infraFeeBP: 500, + liquidityFeeBP: 400, + reservationFeeBP: 100, + owner: vaultOwner, + vaultIndex: 1, + disconnectInitiatedTs: DISCONNECT_NOT_INITIATED, + beaconChainDepositsPauseIntent: false, + }); + + await vaultHub.mock__setVaultRecord(vault_NO1_V2, { + ...record, + liabilityShares: 2, + }); + + await operatorGrid.connect(vaultHubAsSigner).onMintedShares(vault_NO1_V2, 2, false); + + await operatorGrid.connect(vaultOwner).changeTier(vault_NO1_V2, 1, 1000); + await expect(operatorGrid.connect(nodeOperator1).changeTier(vault_NO1_V2, 1, 1000)).to.be.revertedWithCustomError( + operatorGrid, + "TierLimitExceeded", + ); }); it("reverts if GroupLimitExceeded", async function () { @@ -2349,6 +2458,17 @@ describe("OperatorGrid.sol", () => { ).to.be.revertedWithCustomError(operatorGrid, "ShareLimitAlreadySet"); }); + it("reverts when requested share limit is less than current liability shares", async () => { + const connection = createVaultConnection(vaultOwner.address, 1000n); + await vaultHub.mock__setVaultConnection(vault_NO1_V1, connection); + const cleanRecord = { ...record, liabilityShares: 600n }; + await vaultHub.mock__setVaultRecord(vault_NO1_V1, cleanRecord); + + await expect( + operatorGrid.connect(vaultOwner).updateVaultShareLimit(vault_NO1_V1, 400), + ).to.be.revertedWithCustomError(operatorGrid, "RequestedShareLimitTooLow"); + }); + it("requires confirmation from both vault owner and node operator for increasing share limit", async () => { // First, move vault to tier 1 const connection = createVaultConnection(vaultOwner.address, 500n); diff --git a/test/0.8.25/vaults/permissions/permissions.test.ts b/test/0.8.25/vaults/permissions/permissions.test.ts index 3763bfea12..1092ad4c04 100644 --- a/test/0.8.25/vaults/permissions/permissions.test.ts +++ b/test/0.8.25/vaults/permissions/permissions.test.ts @@ -386,6 +386,20 @@ describe("Permissions", () => { }); }); + context("renounceRole()", () => { + it("reverts if called by a stranger", async () => { + await expect( + permissions.connect(stranger).renounceRole(await permissions.DEFAULT_ADMIN_ROLE(), stranger), + ).to.be.revertedWithCustomError(permissions, "RoleRenouncementDisabled"); + }); + + it("reverts if called by the role holder", async () => { + await expect( + permissions.connect(defaultAdmin).renounceRole(await permissions.DEFAULT_ADMIN_ROLE(), defaultAdmin), + ).to.be.revertedWithCustomError(permissions, "RoleRenouncementDisabled"); + }); + }); + context("revokeRoles()", () => { it("mass-revokes roles", async () => { const [ diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts index ec8ee70f7c..8acd5d0807 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.hub.test.ts @@ -597,7 +597,7 @@ describe("VaultHub.sol:hub", () => { const totalValue_ = await vaultHub.totalValue(vault); const shortfallEth = ceilDiv(liability * TOTAL_BASIS_POINTS - totalValue_ * maxMintableRatio, 50_00n); - const shortfallShares = (await lido.getSharesByPooledEth(shortfallEth)) + 10n; + const shortfallShares = (await lido.getSharesByPooledEth(shortfallEth)) + 100n; expect(await vaultHub.healthShortfallShares(vault)).to.equal(shortfallShares); }); @@ -652,7 +652,7 @@ describe("VaultHub.sol:hub", () => { it("returns correct value for rebalance vault", async () => { const { vault } = await createAndConnectVault(vaultFactory, { shareLimit: ether("100"), // just to bypass the share limit check - reserveRatioBP: 50_00n, // 50% + reserveRatioBP: 50_00n, // 50%s forcedRebalanceThresholdBP: 50_00n, // 50% }); @@ -678,7 +678,7 @@ describe("VaultHub.sol:hub", () => { const totalValue_ = await vaultHub.totalValue(vault); const shortfallEth = ceilDiv(liability * TOTAL_BASIS_POINTS - totalValue_ * maxMintableRatio, 50_00n); - const shortfallShares = (await lido.getSharesByPooledEth(shortfallEth)) + 10n; + const shortfallShares = (await lido.getSharesByPooledEth(shortfallEth)) + 100n; expect(await vaultHub.healthShortfallShares(vault)).to.equal(shortfallShares); }); @@ -996,6 +996,85 @@ describe("VaultHub.sol:hub", () => { newReservationFeeBP, ); }); + + it("reverts if minting capacity would be breached", async () => { + const { vault } = await createAndConnectVault(vaultFactory); + + await vaultHub.connect(user).fund(vault, { value: ether("1") }); + await vaultHub.connect(user).mintShares(vault, user.address, 1n); + + await expect( + vaultHub.connect(operatorGridSigner).updateConnection( + vault, + SHARE_LIMIT, + 10000n, // 100% reserve ratio + FORCED_REBALANCE_THRESHOLD_BP, + INFRA_FEE_BP, + LIQUIDITY_FEE_BP, + RESERVATION_FEE_BP, + ), + ).to.be.revertedWithCustomError(vaultHub, "VaultMintingCapacityExceeded"); + }); + + context("for unhealthy vaults", () => { + let vault: StakingVault__MockForVaultHub; + + before(async () => { + ({ vault } = await createAndConnectVault(vaultFactory, { + infraFeeBP: INFRA_FEE_BP, + liquidityFeeBP: LIQUIDITY_FEE_BP, + reservationFeeBP: RESERVATION_FEE_BP, + })); + + await vaultHub.connect(user).fund(vault, { value: ether("1") }); + await vaultHub.connect(user).mintShares(vault, user.address, ether("0.9")); + await reportVault({ vault, totalValue: ether("0.9") }); + + expect(await vaultHub.isVaultHealthy(vault)).to.be.false; + }); + + it("reverts if minting capacity would be breached (by forced rebalance threshold)", async () => { + await expect( + vaultHub.connect(operatorGridSigner).updateConnection( + vault, + SHARE_LIMIT, + RESERVE_RATIO_BP, + 10000n, // 100% forced rebalance threshold + INFRA_FEE_BP, + LIQUIDITY_FEE_BP, + RESERVATION_FEE_BP, + ), + ).to.be.revertedWithCustomError(vaultHub, "VaultMintingCapacityExceeded"); + }); + + it("allows to set share limit and fees even on the unhealthy vault", async () => { + await expect( + vaultHub + .connect(operatorGridSigner) + .updateConnection( + vault, + SHARE_LIMIT + 1n, + RESERVE_RATIO_BP, + FORCED_REBALANCE_THRESHOLD_BP, + INFRA_FEE_BP + 1n, + LIQUIDITY_FEE_BP + 1n, + RESERVATION_FEE_BP + 1n, + ), + ) + .to.to.emit(vaultHub, "VaultConnectionUpdated") + .withArgs(vault, user.address, SHARE_LIMIT + 1n, RESERVE_RATIO_BP, FORCED_REBALANCE_THRESHOLD_BP) + .and.to.emit(vaultHub, "VaultFeesUpdated") + .withArgs( + vault, + INFRA_FEE_BP, + LIQUIDITY_FEE_BP, + RESERVATION_FEE_BP, + INFRA_FEE_BP + 1n, + LIQUIDITY_FEE_BP + 1n, + RESERVATION_FEE_BP + 1n, + ); + }); + }); }); context("disconnect", () => { diff --git a/test/0.8.9/accounting.handleOracleReport.test.ts b/test/0.8.9/accounting.handleOracleReport.test.ts index a8ab747de4..4615d2293b 100644 --- a/test/0.8.9/accounting.handleOracleReport.test.ts +++ b/test/0.8.9/accounting.handleOracleReport.test.ts @@ -378,10 +378,12 @@ describe("Accounting.sol:report", () => { }), ), ) + .and.to.emit(lido, "TransferShares") + .withArgs(ZeroAddress, accounting, expectedSharesToMint) .to.emit(lido, "TransferShares") - .withArgs(ZeroAddress, stakingModule.address, expectedModuleRewardInShares) + .withArgs(accounting, stakingModule.address, expectedModuleRewardInShares) .and.to.emit(lido, "TransferShares") - .withArgs(ZeroAddress, await locator.treasury(), expectedTreasuryCutInShares) + .withArgs(accounting, await locator.treasury(), expectedTreasuryCutInShares) .and.to.emit(stakingRouter, "Mock__MintedRewardsReported"); }); @@ -420,7 +422,9 @@ describe("Accounting.sol:report", () => { ), ) .and.to.emit(lido, "TransferShares") - .withArgs(ZeroAddress, await locator.treasury(), expectedTreasuryCutInShares) + .withArgs(ZeroAddress, accounting, expectedSharesToMint) + .and.to.emit(lido, "TransferShares") + .withArgs(accounting, await locator.treasury(), expectedTreasuryCutInShares) .and.to.emit(stakingRouter, "Mock__MintedRewardsReported"); }); diff --git a/test/0.8.9/contracts/Lido__MockForAccounting.sol b/test/0.8.9/contracts/Lido__MockForAccounting.sol index 63662f1ae9..7e8209971d 100644 --- a/test/0.8.9/contracts/Lido__MockForAccounting.sol +++ b/test/0.8.9/contracts/Lido__MockForAccounting.sol @@ -114,4 +114,9 @@ contract Lido__MockForAccounting { function mintShares(address _recipient, uint256 _sharesAmount) external { emit TransferShares(address(0), _recipient, _sharesAmount); } + + function transferShares(address _recipient, uint256 _amount) external returns (uint256) { + emit TransferShares(msg.sender, _recipient, _amount); + return _amount; + } } diff --git a/test/0.8.9/oracle/VaultHub__MockForAccReport.sol b/test/0.8.9/oracle/VaultHub__MockForAccReport.sol index 42059f47fb..e4eb7262ee 100644 --- a/test/0.8.9/oracle/VaultHub__MockForAccReport.sol +++ b/test/0.8.9/oracle/VaultHub__MockForAccReport.sol @@ -2,18 +2,28 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.9; -contract VaultHub__MockForAccountingReport { - uint256 public badDebtToInternalize; +import {IVaultHub} from "contracts/common/interfaces/IVaultHub.sol"; + +contract VaultHub__MockForAccountingReport is IVaultHub { + uint256 private badDebtToInternalize_; function mock__badDebtToInternalize() external view returns (uint256) { - return badDebtToInternalize; + return badDebtToInternalize_; } function setBadDebtToInternalize(uint256 _badDebt) external { - badDebtToInternalize = _badDebt; + badDebtToInternalize_ = _badDebt; } function decreaseInternalizedBadDebt(uint256 _badDebt) external { - badDebtToInternalize -= _badDebt; + badDebtToInternalize_ -= _badDebt; + } + + function badDebtToInternalize() external view override returns (uint256) { + return badDebtToInternalize_; + } + + function badDebtToInternalizeForLastRefSlot() external view override returns (uint256) { + return badDebtToInternalize_; } } diff --git a/test/integration/core/accounting.integration.ts b/test/integration/core/accounting.integration.ts index 7a16b58309..c95db36d39 100644 --- a/test/integration/core/accounting.integration.ts +++ b/test/integration/core/accounting.integration.ts @@ -4,14 +4,14 @@ import { ethers } from "hardhat"; import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; -import { ether, impersonate, log, ONE_GWEI, updateBalance } from "lib"; +import { ether, impersonate, ONE_GWEI, updateBalance } from "lib"; import { LIMITER_PRECISION_BASE } from "lib/constants"; import { - finalizeWQViaSubmit, getProtocolContext, getReportTimeElapsed, OracleReportParams, ProtocolContext, + removeStakingLimit, report, } from "lib/protocol"; @@ -26,8 +26,9 @@ describe("Integration: Accounting", () => { before(async () => { ctx = await getProtocolContext(); - snapshot = await Snapshot.take(); + + await report(ctx); }); beforeEach(async () => (originalState = await Snapshot.take())); @@ -38,7 +39,6 @@ describe("Integration: Accounting", () => { const getFirstEvent = (receipt: ContractTransactionReceipt, eventName: string) => { const events = ctx.getEvents(receipt, eventName); - expect(events.length).to.be.greaterThan(0); return events[0]; }; @@ -58,24 +58,27 @@ describe("Integration: Accounting", () => { const { oracleReportSanityChecker, lido } = ctx.contracts; const maxPositiveTokeRebase = await oracleReportSanityChecker.getMaxPositiveTokenRebase(); - const totalPooledEther = await lido.getTotalPooledEther(); + const internalEther = (await lido.getTotalPooledEther()) - (await lido.getExternalEther()); expect(maxPositiveTokeRebase).to.be.greaterThanOrEqual(0); - expect(totalPooledEther).to.be.greaterThanOrEqual(0); - return (maxPositiveTokeRebase * totalPooledEther) / LIMITER_PRECISION_BASE; + return (maxPositiveTokeRebase * internalEther) / LIMITER_PRECISION_BASE; }; - const getWithdrawalParams = (tx: ContractTransactionReceipt) => { - const withdrawalsFinalized = ctx.getEvents(tx, "WithdrawalsFinalized"); - const amountOfETHLocked = withdrawalsFinalized.length > 0 ? withdrawalsFinalized[0].args.amountOfETHLocked : 0n; - const sharesToBurn = withdrawalsFinalized.length > 0 ? withdrawalsFinalized[0].args.sharesToBurn : 0n; + function getWithdrawalParamsFromEvent(tx: ContractTransactionReceipt): { + amountOfETHLocked: bigint; + sharesBurntAmount: bigint; + sharesToBurn: bigint; + } { + const withdrawalsFinalized = getFirstEvent(tx, "WithdrawalsFinalized")?.args; + const amountOfETHLocked = withdrawalsFinalized?.amountOfETHLocked ?? 0n; + const sharesToBurn = withdrawalsFinalized?.sharesToBurn ?? 0n; - const sharesBurnt = ctx.getEvents(tx, "SharesBurnt"); - const sharesBurntAmount = sharesBurnt.length > 0 ? sharesBurnt[0].args.sharesAmount : 0n; + const sharesBurnt = getFirstEvent(tx, "SharesBurnt")?.args; + const sharesBurntAmount = sharesBurnt?.sharesAmount ?? 0n; return { amountOfETHLocked, sharesBurntAmount, sharesToBurn }; - }; + } const sharesRateFromEvent = (tx: ContractTransactionReceipt) => { const tokenRebasedEvent = getFirstEvent(tx, "TokenRebased"); @@ -88,40 +91,150 @@ describe("Integration: Accounting", () => { }; // Get shares burn limit from oracle report sanity checker contract when NO changes in pooled Ether are expected - const sharesBurnLimitNoPooledEtherChanges = async () => { - const rebaseLimit = await ctx.contracts.oracleReportSanityChecker.getMaxPositiveTokenRebase(); + async function sharesToBurnToReachRebaseLimit() { + const { lido, oracleReportSanityChecker } = ctx.contracts; + + const rebaseLimit = await oracleReportSanityChecker.getMaxPositiveTokenRebase(); const rebaseLimitPlus1 = rebaseLimit + LIMITER_PRECISION_BASE; - return ((await ctx.contracts.lido.getTotalShares()) * rebaseLimit) / rebaseLimitPlus1; - }; + const internalShares = (await lido.getTotalShares()) - (await lido.getExternalShares()); + + // Derived from: + // rebaseLimit = (postShareRate - preShareRate) / preShareRate + return (internalShares * rebaseLimit) / rebaseLimitPlus1; + } + + async function readState() { + const { lido, accountingOracle, elRewardsVault, withdrawalVault, burner } = ctx.contracts; + + const lastProcessingRefSlot = await accountingOracle.getLastProcessingRefSlot(); + const totalELRewardsCollected = await lido.getTotalELRewardsCollected(); + const internalEther = (await lido.getTotalPooledEther()) - (await lido.getExternalEther()); + const internalShares = (await lido.getTotalShares()) - (await lido.getExternalShares()); + const lidoBalance = await ethers.provider.getBalance(lido); + const elRewardsVaultBalance = await ethers.provider.getBalance(elRewardsVault); + const withdrawalVaultBalance = await ethers.provider.getBalance(withdrawalVault); + const burnerShares = await lido.sharesOf(burner); + + return { + lastProcessingRefSlot, + totalELRewardsCollected, + internalEther, + internalShares, + lidoBalance, + elRewardsVaultBalance, + withdrawalVaultBalance, + burnerShares, + }; + } + + async function expectStateChanges( + beforeState: Awaited>, + expectedDelta: Partial>>, + ) { + const { + lastProcessingRefSlot, + totalELRewardsCollected, + internalEther, + internalShares, + lidoBalance, + elRewardsVaultBalance, + withdrawalVaultBalance, + burnerShares, + } = await readState(); + + expect(lastProcessingRefSlot).to.be.greaterThan( + beforeState.lastProcessingRefSlot, + "Last processing ref slot mismatch", + ); + + expect(totalELRewardsCollected).to.equal( + beforeState.totalELRewardsCollected + (expectedDelta.totalELRewardsCollected ?? 0n), + "Total EL rewards collected mismatch", + ); + expect(internalEther).to.equal( + beforeState.internalEther + (expectedDelta.internalEther ?? 0n), + "Internal ether mismatch", + ); + expect(lidoBalance).to.equal(beforeState.lidoBalance + (expectedDelta.lidoBalance ?? 0n), "Lido balance mismatch"); + expect(elRewardsVaultBalance).to.equal( + beforeState.elRewardsVaultBalance + (expectedDelta.elRewardsVaultBalance ?? 0n), + "El rewards vault balance mismatch", + ); + expect(withdrawalVaultBalance).to.equal( + beforeState.withdrawalVaultBalance + (expectedDelta.withdrawalVaultBalance ?? 0n), + "Withdrawal vault balance mismatch", + ); + expect(burnerShares).to.equal( + beforeState.burnerShares + (expectedDelta.burnerShares ?? 0n), + "Burner shares mismatch", + ); + expect(internalShares).to.equal( + beforeState.internalShares + (expectedDelta.internalShares ?? 0n), + "Internal shares mismatch", + ); + } + + async function expectTransferFeesEvents( + reportTxReceipt: ContractTransactionReceipt, + noRewards: boolean = false, + ): Promise { + const { stakingRouter } = ctx.contracts; + + const stakingModulesCount = await stakingRouter.getStakingModulesCount(); + + const numberOfCSMModules = (await stakingRouter.getStakingModules()).filter( + (module) => module.name === "Community Staking", + ).length; + + const { amountOfETHLocked } = getWithdrawalParamsFromEvent(reportTxReceipt); + const hasWithdrawals = amountOfETHLocked !== 0n; + + const transferSharesEvents = ctx.getEvents(reportTxReceipt, "TransferShares"); + const expectedRewardsDistributionEventsCount = noRewards + ? 0n + : BigInt(stakingModulesCount) + BigInt(numberOfCSMModules) + 2n; + const expectedWithdrawalsTransferEventCount = hasWithdrawals ? 1n : 0n; + expect(transferSharesEvents.length).to.equal( + expectedWithdrawalsTransferEventCount + expectedRewardsDistributionEventsCount, + "Expected transfer of shares to treasury, WQ and staking modules", + ); + + const mintedSharesSum = transferSharesEvents + .slice(hasWithdrawals ? 1 : 0) // skip burner if withdrawals processed + .filter(({ args }) => args.from === ZeroAddress) // only minted shares + .reduce((acc, { args }) => acc + args.sharesValue, 0n); + + const tokenRebasedEvent = getFirstEvent(reportTxReceipt, "TokenRebased"); + expect(tokenRebasedEvent.args.sharesMintedAsFees).to.equal(mintedSharesSum); + + return mintedSharesSum; + } // Ensure the whale account has enough shares, e.g. on scratch deployments - async function ensureWhaleHasFunds() { + async function ensureWhaleHasFunds(amount: bigint) { const { lido, wstETH } = ctx.contracts; - if (!(await lido.sharesOf(wstETH.address))) { + const wstEthBalance = await lido.sharesOf(wstETH); + if (wstEthBalance < amount) { + await removeStakingLimit(ctx); const wstEthSigner = await impersonate(wstETH.address, ether("10001")); await lido.connect(wstEthSigner).submit(ZeroAddress, { value: ether("10000") }); } } - it("reverts if the CL increase balance is incorrect", async () => { - const { oracleReportSanityChecker, withdrawalVault } = ctx.contracts; - - const maxCLRebaseViaLimiter = await rebaseLimitWei(); - console.debug({ maxCLRebaseViaLimiter }); + it("Should revert report on sanity checks if CL rebase is too large", async () => { + const { oracleReportSanityChecker } = ctx.contracts; - // Expected annual limit to shot first - const rebaseAmount = maxCLRebaseViaLimiter - 1n; + const maxCLRebaseViaLimiter = (await rebaseLimitWei()) + 1n; - const params: Partial = { - clDiff: rebaseAmount, - excludeVaultsBalances: true, - withdrawalVaultBalance: await ethers.provider.getBalance(withdrawalVault), - }; - await expect(report(ctx, params)).to.be.revertedWithCustomError( - oracleReportSanityChecker, - "IncorrectCLBalanceIncrease(uint256)", - ); + await expect( + report(ctx, { + clDiff: maxCLRebaseViaLimiter, + excludeVaultsBalances: true, + reportBurner: false, + skipWithdrawals: true, + }), + ).to.be.revertedWithCustomError(oracleReportSanityChecker, "IncorrectCLBalanceIncrease(uint256)"); }); it("reverts if the withdrawal vault balance is greater than reported", async () => { @@ -142,95 +255,56 @@ describe("Integration: Accounting", () => { }); it("Should account correctly with no CL rebase", async () => { - const { lido, accountingOracle } = ctx.contracts; - - const lastProcessingRefSlotBefore = await accountingOracle.getLastProcessingRefSlot(); - const totalELRewardsCollectedBefore = await lido.getTotalELRewardsCollected(); - const totalPooledEtherBefore = await lido.getTotalPooledEther(); - const totalSharesBefore = await lido.getTotalShares(); - const ethBalanceBefore = await ethers.provider.getBalance(lido.address); + const beforeState = await readState(); // Report - const params = { clDiff: 0n, excludeVaultsBalances: true }; - const { reportTx } = (await report(ctx, params)) as { - reportTx: TransactionResponse; - extraDataTx: TransactionResponse; - }; - - const reportTxReceipt = (await reportTx.wait()) as ContractTransactionReceipt; - const { amountOfETHLocked, sharesBurntAmount } = getWithdrawalParams(reportTxReceipt); - - const lastProcessingRefSlotAfter = await accountingOracle.getLastProcessingRefSlot(); - expect(lastProcessingRefSlotBefore).to.be.lessThan( - lastProcessingRefSlotAfter, - "LastProcessingRefSlot should be updated", - ); + const { reportTx } = await report(ctx, { clDiff: 0n, excludeVaultsBalances: true }); - const totalELRewardsCollectedAfter = await lido.getTotalELRewardsCollected(); - expect(totalELRewardsCollectedBefore).to.equal(totalELRewardsCollectedAfter); + const reportTxReceipt = (await reportTx!.wait())!; + const { amountOfETHLocked, sharesBurntAmount } = getWithdrawalParamsFromEvent(reportTxReceipt); - const totalPooledEtherAfter = await lido.getTotalPooledEther(); - expect(totalPooledEtherBefore).to.equal(totalPooledEtherAfter + amountOfETHLocked); - - const totalSharesAfter = await lido.getTotalShares(); - expect(totalSharesBefore).to.equal(totalSharesAfter + sharesBurntAmount); + await expectStateChanges(beforeState, { + totalELRewardsCollected: 0n, + internalEther: amountOfETHLocked * -1n, + internalShares: sharesBurntAmount * -1n, + lidoBalance: amountOfETHLocked * -1n, + }); const tokenRebasedEvent = ctx.getEvents(reportTxReceipt, "TokenRebased"); const { sharesRateBefore, sharesRateAfter } = shareRateFromEvent(tokenRebasedEvent[0]); expect(sharesRateBefore).to.be.lessThanOrEqual(sharesRateAfter); - - const ethBalanceAfter = await ethers.provider.getBalance(lido.address); - expect(ethBalanceBefore).to.equal(ethBalanceAfter + amountOfETHLocked); }); it("Should account correctly with negative CL rebase", async () => { - const { lido, accountingOracle } = ctx.contracts; + const CL_REBASE_AMOUNT = ether("-100"); - const REBASE_AMOUNT = ether("-100"); // it can be countered with withdrawal queue extra APR - - const lastProcessingRefSlotBefore = await accountingOracle.getLastProcessingRefSlot(); - const totalELRewardsCollectedBefore = await lido.getTotalELRewardsCollected(); - const totalPooledEtherBefore = await lido.getTotalPooledEther(); - const totalSharesBefore = await lido.getTotalShares(); + const beforeState = await readState(); // Report - const params = { clDiff: REBASE_AMOUNT, excludeVaultsBalances: true, skipWithdrawals: true }; - const { reportTx } = (await report(ctx, params)) as { - reportTx: TransactionResponse; - extraDataTx: TransactionResponse; - }; - - const reportTxReceipt = (await reportTx.wait()) as ContractTransactionReceipt; - const { amountOfETHLocked, sharesBurntAmount } = getWithdrawalParams(reportTxReceipt); - - const lastProcessingRefSlotAfter = await accountingOracle.getLastProcessingRefSlot(); - expect(lastProcessingRefSlotBefore).to.be.lessThan( - lastProcessingRefSlotAfter, - "LastProcessingRefSlot should be updated", - ); - - const totalELRewardsCollectedAfter = await lido.getTotalELRewardsCollected(); - expect(totalELRewardsCollectedBefore).to.equal(totalELRewardsCollectedAfter); - - const totalPooledEtherAfter = await lido.getTotalPooledEther(); - expect(totalPooledEtherBefore + REBASE_AMOUNT).to.equal(totalPooledEtherAfter + amountOfETHLocked); - - const totalSharesAfter = await lido.getTotalShares(); - expect(totalSharesBefore).to.equal(totalSharesAfter + sharesBurntAmount); + const params = { clDiff: CL_REBASE_AMOUNT, excludeVaultsBalances: true, skipWithdrawals: true }; + const { reportTx } = await report(ctx, params); + const reportTxReceipt = (await reportTx!.wait())!; + const { amountOfETHLocked, sharesBurntAmount } = getWithdrawalParamsFromEvent(reportTxReceipt); + + await expectStateChanges(beforeState, { + totalELRewardsCollected: 0n, + internalEther: amountOfETHLocked * -1n + CL_REBASE_AMOUNT, + internalShares: sharesBurntAmount * -1n, + lidoBalance: amountOfETHLocked * -1n, + }); const tokenRebasedEvent = ctx.getEvents(reportTxReceipt, "TokenRebased"); const { sharesRateBefore, sharesRateAfter } = shareRateFromEvent(tokenRebasedEvent[0]); expect(sharesRateAfter).to.be.lessThan(sharesRateBefore); const ethDistributedEvent = ctx.getEvents(reportTxReceipt, "ETHDistributed"); - expect(ethDistributedEvent[0].args.preCLBalance + REBASE_AMOUNT).to.equal( - ethDistributedEvent[0].args.postCLBalance, - "ETHDistributed: CL balance differs from expected", + expect(ethDistributedEvent[0].args.preCLBalance).to.equal( + ethDistributedEvent[0].args.postCLBalance - CL_REBASE_AMOUNT, ); }); - it.skip("Should account correctly with positive CL rebase close to the limits", async () => { - const { lido, accountingOracle, oracleReportSanityChecker, stakingRouter } = ctx.contracts; + it("Should account correctly with positive CL rebase close to the limits", async () => { + const { lido, oracleReportSanityChecker } = ctx.contracts; const { annualBalanceIncreaseBPLimit } = await oracleReportSanityChecker.getOracleReportLimits(); const { beaconBalance } = await lido.getBeaconStat(); @@ -247,10 +321,7 @@ describe("Integration: Accounting", () => { // At this point, rebaseAmount represents a positive CL rebase that is // just slightly below the maximum allowed daily increase, testing the system's // behavior near its operational limits - const lastProcessingRefSlotBefore = await accountingOracle.getLastProcessingRefSlot(); - const totalELRewardsCollectedBefore = await lido.getTotalELRewardsCollected(); - const totalPooledEtherBefore = await lido.getTotalPooledEther(); - const totalSharesBefore = await lido.getTotalShares(); + const beforeState = await readState(); // Report const params = { clDiff: rebaseAmount, excludeVaultsBalances: true }; @@ -261,67 +332,18 @@ describe("Integration: Accounting", () => { }; const reportTxReceipt = (await reportTx.wait()) as ContractTransactionReceipt; - const { amountOfETHLocked, sharesBurntAmount } = getWithdrawalParams(reportTxReceipt); - - const lastProcessingRefSlotAfter = await accountingOracle.getLastProcessingRefSlot(); - expect(lastProcessingRefSlotBefore).to.be.lessThan( - lastProcessingRefSlotAfter, - "LastProcessingRefSlot should be updated", - ); - - const totalELRewardsCollectedAfter = await lido.getTotalELRewardsCollected(); - expect(totalELRewardsCollectedBefore).to.equal(totalELRewardsCollectedAfter); - - const totalPooledEtherAfter = await lido.getTotalPooledEther(); - expect(totalPooledEtherBefore + rebaseAmount).to.equal(totalPooledEtherAfter + amountOfETHLocked); - - const hasWithdrawals = amountOfETHLocked != 0; - const stakingModulesCount = await stakingRouter.getStakingModulesCount(); - const transferSharesEvents = ctx.getEvents(reportTxReceipt, "TransferShares"); - - const feeDistributionTransfer = ctx.flags.withCSM ? 1n : 0n; - - // Magic numbers here: 2 – burner and treasury, 1 – only treasury - expect(transferSharesEvents.length).to.equal( - (hasWithdrawals ? 2n : 1n) + stakingModulesCount + feeDistributionTransfer, - "Expected transfer of shares to DAO and staking modules", - ); - - log.debug("Staking modules count", { stakingModulesCount }); - - const mintedSharesSum = transferSharesEvents - .slice(hasWithdrawals ? 1 : 0) // skip burner if withdrawals processed - .filter(({ args }) => args.from === ZeroAddress) // only minted shares - .reduce((acc, { args }) => acc + args.sharesValue, 0n); - - // TODO: check math, why it's not equal? - // let stakingModulesSharesAsFees = 0n; - // for (let i = 1; i <= stakingModulesCount; i++) { - // const transferSharesEvent = transferSharesEvents[i + (hasWithdrawals ? 1 : 0)]; - // const stakingModuleSharesAsFees = transferSharesEvent?.args?.sharesValue || 0n; - // stakingModulesSharesAsFees += stakingModuleSharesAsFees; - // } - - // const treasurySharesAsFees = transferSharesEvents[transferSharesEvents.length - 1 - Number(feeDistributionTransfer)]; - // expect(stakingModulesSharesAsFees).to.approximately( - // treasurySharesAsFees.args.sharesValue, - // 100, - // "Shares minted to DAO and staking modules mismatch", - // ); + const { amountOfETHLocked, sharesBurntAmount } = getWithdrawalParamsFromEvent(reportTxReceipt); - const tokenRebasedEvent = ctx.getEvents(reportTxReceipt, "TokenRebased"); - expect(tokenRebasedEvent[0].args.sharesMintedAsFees).to.equal( - mintedSharesSum, - "TokenRebased: sharesMintedAsFee mismatch", - ); + const mintedSharesSum = await expectTransferFeesEvents(reportTxReceipt); - const totalSharesAfter = await lido.getTotalShares(); - expect(totalSharesBefore + mintedSharesSum).to.equal( - totalSharesAfter + sharesBurntAmount, - "TotalShares change mismatch", - ); + await expectStateChanges(beforeState, { + totalELRewardsCollected: 0n, + internalEther: amountOfETHLocked * -1n + rebaseAmount, + internalShares: sharesBurntAmount * -1n + mintedSharesSum, + lidoBalance: amountOfETHLocked * -1n, + }); - const { sharesRateBefore, sharesRateAfter } = shareRateFromEvent(tokenRebasedEvent[0]); + const [sharesRateBefore, sharesRateAfter] = sharesRateFromEvent(reportTxReceipt); expect(sharesRateAfter).to.be.greaterThan(sharesRateBefore, "Shares rate has not increased"); const ethDistributedEvent = ctx.getEvents(reportTxReceipt, "ETHDistributed"); @@ -332,13 +354,7 @@ describe("Integration: Accounting", () => { }); it("Should account correctly if no EL rewards", async () => { - const { lido, accountingOracle } = ctx.contracts; - - const lastProcessingRefSlotBefore = await accountingOracle.getLastProcessingRefSlot(); - const totalELRewardsCollectedBefore = await lido.getTotalELRewardsCollected(); - const totalPooledEtherBefore = await lido.getTotalPooledEther(); - const totalSharesBefore = await lido.getTotalShares(); - const ethBalanceBefore = await ethers.provider.getBalance(lido.address); + const beforeState = await readState(); const params = { clDiff: 0n, excludeVaultsBalances: true }; const { reportTx } = (await report(ctx, params)) as { @@ -347,43 +363,28 @@ describe("Integration: Accounting", () => { }; const reportTxReceipt = (await reportTx.wait()) as ContractTransactionReceipt; - const { amountOfETHLocked, sharesBurntAmount } = getWithdrawalParams(reportTxReceipt); - - const lastProcessingRefSlotAfter = await accountingOracle.getLastProcessingRefSlot(); - expect(lastProcessingRefSlotBefore).to.be.lessThan( - lastProcessingRefSlotAfter, - "LastProcessingRefSlot should be updated", - ); - - const totalELRewardsCollectedAfter = await lido.getTotalELRewardsCollected(); - expect(totalELRewardsCollectedBefore).to.equal(totalELRewardsCollectedAfter); - - const totalPooledEtherAfter = await lido.getTotalPooledEther(); - expect(totalPooledEtherBefore).to.equal(totalPooledEtherAfter + amountOfETHLocked); - - const totalSharesAfter = await lido.getTotalShares(); - expect(totalSharesBefore).to.equal(totalSharesAfter + sharesBurntAmount); + const { amountOfETHLocked, sharesBurntAmount } = getWithdrawalParamsFromEvent(reportTxReceipt); - const ethBalanceAfter = await ethers.provider.getBalance(lido.address); - expect(ethBalanceBefore).to.equal(ethBalanceAfter + amountOfETHLocked); + await expectStateChanges(beforeState, { + totalELRewardsCollected: 0n, + internalEther: amountOfETHLocked * -1n, + internalShares: sharesBurntAmount * -1n, + lidoBalance: amountOfETHLocked * -1n, + }); expect(ctx.getEvents(reportTxReceipt, "WithdrawalsReceived")).to.be.empty; expect(ctx.getEvents(reportTxReceipt, "ELRewardsReceived")).to.be.empty; }); it("Should account correctly normal EL rewards", async () => { - const { lido, accountingOracle, elRewardsVault } = ctx.contracts; + const { elRewardsVault } = ctx.contracts; await updateBalance(elRewardsVault.address, ether("1")); const elRewards = await ethers.provider.getBalance(elRewardsVault.address); expect(elRewards).to.be.greaterThan(0, "Expected EL vault to be non-empty"); - const lastProcessingRefSlotBefore = await accountingOracle.getLastProcessingRefSlot(); - const totalELRewardsCollectedBefore = await lido.getTotalELRewardsCollected(); - const totalPooledEtherBefore = await lido.getTotalPooledEther(); - const totalSharesBefore = await lido.getTotalShares(); - const lidoBalanceBefore = await ethers.provider.getBalance(lido.address); + const beforeState = await readState(); const params = { clDiff: 0n, reportElVault: true, reportWithdrawalsVault: false }; const { reportTx } = (await report(ctx, params)) as { @@ -392,45 +393,27 @@ describe("Integration: Accounting", () => { }; const reportTxReceipt = (await reportTx.wait()) as ContractTransactionReceipt; - const { amountOfETHLocked, sharesBurntAmount } = getWithdrawalParams(reportTxReceipt); + const { amountOfETHLocked, sharesBurntAmount } = getWithdrawalParamsFromEvent(reportTxReceipt); - const lastProcessingRefSlotAfter = await accountingOracle.getLastProcessingRefSlot(); - expect(lastProcessingRefSlotBefore).to.be.lessThan( - lastProcessingRefSlotAfter, - "LastProcessingRefSlot should be updated", - ); - - const totalELRewardsCollectedAfter = await lido.getTotalELRewardsCollected(); - expect(totalELRewardsCollectedBefore + elRewards).to.equal(totalELRewardsCollectedAfter); + await expectStateChanges(beforeState, { + totalELRewardsCollected: elRewards, + internalEther: amountOfETHLocked * -1n + elRewards, + internalShares: sharesBurntAmount * -1n, + lidoBalance: amountOfETHLocked * -1n + elRewards, + elRewardsVaultBalance: elRewards * -1n, + }); const elRewardsReceivedEvent = getFirstEvent(reportTxReceipt, "ELRewardsReceived"); expect(elRewardsReceivedEvent.args.amount).to.equal(elRewards); - - const totalPooledEtherAfter = await lido.getTotalPooledEther(); - expect(totalPooledEtherBefore + elRewards).to.equal(totalPooledEtherAfter + amountOfETHLocked); - - const totalSharesAfter = await lido.getTotalShares(); - expect(totalSharesBefore).to.equal(totalSharesAfter + sharesBurntAmount); - - const lidoBalanceAfter = await ethers.provider.getBalance(lido.address); - expect(lidoBalanceBefore + elRewards).to.equal(lidoBalanceAfter + amountOfETHLocked); - - const elVaultBalanceAfter = await ethers.provider.getBalance(elRewardsVault.address); - expect(elVaultBalanceAfter).to.equal(0, "Expected EL vault to be empty"); }); it("Should account correctly EL rewards at limits", async () => { - const { lido, accountingOracle, elRewardsVault } = ctx.contracts; + const { elRewardsVault } = ctx.contracts; const elRewards = await rebaseLimitWei(); - await impersonate(elRewardsVault.address, elRewards); - const lastProcessingRefSlotBefore = await accountingOracle.getLastProcessingRefSlot(); - const totalELRewardsCollectedBefore = await lido.getTotalELRewardsCollected(); - const totalPooledEtherBefore = await lido.getTotalPooledEther(); - const totalSharesBefore = await lido.getTotalShares(); - const lidoBalanceBefore = await ethers.provider.getBalance(lido.address); + const beforeState = await readState(); // Report const params = { clDiff: 0n, reportElVault: true, reportWithdrawalsVault: false }; @@ -440,35 +423,23 @@ describe("Integration: Accounting", () => { }; const reportTxReceipt = (await reportTx.wait()) as ContractTransactionReceipt; - const { amountOfETHLocked, sharesBurntAmount } = getWithdrawalParams(reportTxReceipt); - - const lastProcessingRefSlotAfter = await accountingOracle.getLastProcessingRefSlot(); - expect(lastProcessingRefSlotBefore).to.be.lessThan( - lastProcessingRefSlotAfter, - "LastProcessingRefSlot should be updated", - ); - - const totalELRewardsCollectedAfter = await lido.getTotalELRewardsCollected(); - expect(totalELRewardsCollectedBefore + elRewards).to.equal(totalELRewardsCollectedAfter); - - const elRewardsReceivedEvent = await ctx.getEvents(reportTxReceipt, "ELRewardsReceived")[0]; + const { amountOfETHLocked, sharesBurntAmount, sharesToBurn } = getWithdrawalParamsFromEvent(reportTxReceipt); + + await expectStateChanges(beforeState, { + totalELRewardsCollected: elRewards, + internalEther: amountOfETHLocked * -1n + elRewards, + internalShares: sharesBurntAmount * -1n, + lidoBalance: amountOfETHLocked * -1n + elRewards, + elRewardsVaultBalance: elRewards * -1n, + burnerShares: sharesToBurn - sharesBurntAmount, + }); + + const elRewardsReceivedEvent = ctx.getEvents(reportTxReceipt, "ELRewardsReceived")[0]; expect(elRewardsReceivedEvent.args.amount).to.equal(elRewards); - - const totalPooledEtherAfter = await lido.getTotalPooledEther(); - expect(totalPooledEtherBefore + elRewards).to.equal(totalPooledEtherAfter + amountOfETHLocked); - - const totalSharesAfter = await lido.getTotalShares(); - expect(totalSharesBefore).to.equal(totalSharesAfter + sharesBurntAmount); - - const lidoBalanceAfter = await ethers.provider.getBalance(lido.address); - expect(lidoBalanceBefore + elRewards).to.equal(lidoBalanceAfter + amountOfETHLocked); - - const elVaultBalanceAfter = await ethers.provider.getBalance(elRewardsVault.address); - expect(elVaultBalanceAfter).to.equal(0, "Expected EL vault to be empty"); }); it("Should account correctly EL rewards above limits", async () => { - const { lido, accountingOracle, elRewardsVault } = ctx.contracts; + const { elRewardsVault } = ctx.contracts; const rewardsExcess = ether("10"); const expectedRewards = await rebaseLimitWei(); @@ -476,11 +447,7 @@ describe("Integration: Accounting", () => { await impersonate(elRewardsVault.address, elRewards); - const lastProcessingRefSlotBefore = await accountingOracle.getLastProcessingRefSlot(); - const totalELRewardsCollectedBefore = await lido.getTotalELRewardsCollected(); - const totalPooledEtherBefore = await lido.getTotalPooledEther(); - const totalSharesBefore = await lido.getTotalShares(); - const lidoBalanceBefore = await ethers.provider.getBalance(lido.address); + const beforeState = await readState(); const params = { clDiff: 0n, reportElVault: true, reportWithdrawalsVault: false }; const { reportTx } = (await report(ctx, params)) as { @@ -489,44 +456,23 @@ describe("Integration: Accounting", () => { }; const reportTxReceipt = (await reportTx.wait()) as ContractTransactionReceipt; - const { amountOfETHLocked, sharesBurntAmount } = getWithdrawalParams(reportTxReceipt); - - const lastProcessingRefSlotAfter = await accountingOracle.getLastProcessingRefSlot(); - expect(lastProcessingRefSlotBefore).to.be.lessThan( - lastProcessingRefSlotAfter, - "LastProcessingRefSlot should be updated", - ); + const { amountOfETHLocked, sharesBurntAmount, sharesToBurn } = getWithdrawalParamsFromEvent(reportTxReceipt); - const totalELRewardsCollectedAfter = await lido.getTotalELRewardsCollected(); - expect(totalELRewardsCollectedBefore + expectedRewards).to.equal( - totalELRewardsCollectedAfter, - "TotalELRewardsCollected change mismatch", - ); + await expectStateChanges(beforeState, { + totalELRewardsCollected: expectedRewards, + internalEther: expectedRewards - amountOfETHLocked, + internalShares: 0n - sharesBurntAmount, + lidoBalance: expectedRewards - amountOfETHLocked, + elRewardsVaultBalance: 0n - expectedRewards, + burnerShares: sharesToBurn - sharesBurntAmount, + }); const elRewardsReceivedEvent = getFirstEvent(reportTxReceipt, "ELRewardsReceived"); expect(elRewardsReceivedEvent.args.amount).to.equal(expectedRewards); - - const totalPooledEtherAfter = await lido.getTotalPooledEther(); - expect(totalPooledEtherBefore + expectedRewards).to.equal(totalPooledEtherAfter + amountOfETHLocked); - - const totalSharesAfter = await lido.getTotalShares(); - expect(totalSharesBefore).to.equal(totalSharesAfter + sharesBurntAmount); - - const lidoBalanceAfter = await ethers.provider.getBalance(lido.address); - expect(lidoBalanceBefore + expectedRewards).equal(lidoBalanceAfter + amountOfETHLocked); - - const elVaultBalanceAfter = await ethers.provider.getBalance(elRewardsVault.address); - expect(elVaultBalanceAfter).to.equal(rewardsExcess, "Expected EL vault to be filled with excess rewards"); }); - it("Should account correctly with no withdrawals", async () => { - const { lido, accountingOracle } = ctx.contracts; - - const lastProcessingRefSlotBefore = await accountingOracle.getLastProcessingRefSlot(); - const totalELRewardsCollectedBefore = await lido.getTotalELRewardsCollected(); - const totalPooledEtherBefore = await lido.getTotalPooledEther(); - const totalSharesBefore = await lido.getTotalShares(); - const lidoBalanceBefore = await ethers.provider.getBalance(lido.address); + it("Should account correctly with no elRewards and no withdrawals accounted for", async () => { + const beforeState = await readState(); // Report const params = { clDiff: 0n, excludeVaultsBalances: true }; @@ -536,41 +482,24 @@ describe("Integration: Accounting", () => { }; const reportTxReceipt = (await reportTx.wait()) as ContractTransactionReceipt; - const { amountOfETHLocked, sharesBurntAmount } = getWithdrawalParams(reportTxReceipt); - - const lastProcessingRefSlotAfter = await accountingOracle.getLastProcessingRefSlot(); - expect(lastProcessingRefSlotBefore).to.be.lessThan( - lastProcessingRefSlotAfter, - "LastProcessingRefSlot should be updated", - ); - - const totalELRewardsCollectedAfter = await lido.getTotalELRewardsCollected(); - expect(totalELRewardsCollectedBefore).to.equal(totalELRewardsCollectedAfter); + const { amountOfETHLocked, sharesBurntAmount } = getWithdrawalParamsFromEvent(reportTxReceipt); - const totalPooledEtherAfter = await lido.getTotalPooledEther(); - expect(totalPooledEtherBefore).to.equal(totalPooledEtherAfter + amountOfETHLocked); + await expectStateChanges(beforeState, { + internalEther: 0n - amountOfETHLocked, + internalShares: 0n - sharesBurntAmount, + lidoBalance: 0n - amountOfETHLocked, + }); - const totalSharesAfter = await lido.getTotalShares(); - expect(totalSharesBefore).to.equal(totalSharesAfter + sharesBurntAmount); - - const lidoBalanceAfter = await ethers.provider.getBalance(lido.address); - expect(lidoBalanceBefore).to.equal(lidoBalanceAfter + amountOfETHLocked); - - expect(ctx.getEvents(reportTxReceipt, "WithdrawalsReceived").length).be.equal(0); - expect(ctx.getEvents(reportTxReceipt, "ELRewardsReceived").length).be.equal(0); + expect(ctx.getEvents(reportTxReceipt, "WithdrawalsReceived")).to.be.empty; + expect(ctx.getEvents(reportTxReceipt, "ELRewardsReceived")).to.be.empty; }); - it.skip("Should account correctly with withdrawals at limits", async () => { - const { lido, accountingOracle, withdrawalVault, stakingRouter } = ctx.contracts; - + it("Should account correctly with withdrawals at limits", async () => { + const { withdrawalVault } = ctx.contracts; const withdrawals = await rebaseLimitWei(); - await impersonate(withdrawalVault.address, withdrawals); - const lastProcessingRefSlotBefore = await accountingOracle.getLastProcessingRefSlot(); - const totalELRewardsCollectedBefore = await lido.getTotalELRewardsCollected(); - const totalPooledEtherBefore = await lido.getTotalPooledEther(); - const totalSharesBefore = await lido.getTotalShares(); + const beforeState = await readState(); // Report const params = { clDiff: 0n, reportElVault: false, reportWithdrawalsVault: true }; @@ -580,75 +509,27 @@ describe("Integration: Accounting", () => { }; const reportTxReceipt = (await reportTx.wait()) as ContractTransactionReceipt; - const { amountOfETHLocked, sharesBurntAmount } = getWithdrawalParams(reportTxReceipt); - - const lastProcessingRefSlotAfter = await accountingOracle.getLastProcessingRefSlot(); - expect(lastProcessingRefSlotBefore).to.be.lessThan( - lastProcessingRefSlotAfter, - "LastProcessingRefSlot should be updated", - ); - - const totalELRewardsCollectedAfter = await lido.getTotalELRewardsCollected(); - expect(totalELRewardsCollectedBefore).to.equal(totalELRewardsCollectedAfter); - - const totalPooledEtherAfter = await lido.getTotalPooledEther(); - expect(totalPooledEtherBefore + withdrawals).to.equal( - totalPooledEtherAfter + amountOfETHLocked, - "TotalPooledEther change mismatch", - ); - - const hasWithdrawals = amountOfETHLocked != 0; - const stakingModulesCount = await stakingRouter.getStakingModulesCount(); - const transferSharesEvents = ctx.getEvents(reportTxReceipt, "TransferShares"); - - const feeDistributionTransfer = ctx.flags.withCSM ? 1n : 0n; - - // Magic numbers here: 2 – burner and treasury, 1 – only treasury - expect(transferSharesEvents.length).to.equal( - (hasWithdrawals ? 2n : 1n) + stakingModulesCount + feeDistributionTransfer, - "Expected transfer of shares to DAO and staking modules", - ); - - log.debug("Staking modules count", { stakingModulesCount }); + const { amountOfETHLocked, sharesBurntAmount, sharesToBurn } = getWithdrawalParamsFromEvent(reportTxReceipt); - const mintedSharesSum = transferSharesEvents - .slice(hasWithdrawals ? 1 : 0) // skip burner if withdrawals processed - .filter(({ args }) => args.from === ZeroAddress) // only minted shares - .reduce((acc, { args }) => acc + args.sharesValue, 0n); + const mintedSharesSum = await expectTransferFeesEvents(reportTxReceipt); - // TODO: check math, why it's not equal? - // let stakingModulesSharesAsFees = 0n; - // for (let i = 1; i <= stakingModulesCount; i++) { - // const transferSharesEvent = transferSharesEvents[i + (hasWithdrawals ? 1 : 0)]; - // const stakingModuleSharesAsFees = transferSharesEvent?.args?.sharesValue || 0n; - // stakingModulesSharesAsFees += stakingModuleSharesAsFees; - // } - - // const treasurySharesAsFees = transferSharesEvents[transferSharesEvents.length - 1 - Number(feeDistributionTransfer)]; - // expect(stakingModulesSharesAsFees).to.approximately( - // treasurySharesAsFees.args.sharesValue, - // 100, - // "Shares minted to DAO and staking modules mismatch", - // ); - - const tokenRebasedEvent = getFirstEvent(reportTxReceipt, "TokenRebased"); - expect(tokenRebasedEvent.args.sharesMintedAsFees).to.equal(mintedSharesSum); - - const totalSharesAfter = await lido.getTotalShares(); - expect(totalSharesBefore + mintedSharesSum).to.equal(totalSharesAfter + sharesBurntAmount); + await expectStateChanges(beforeState, { + internalEther: withdrawals - amountOfETHLocked, + internalShares: mintedSharesSum - sharesBurntAmount, + lidoBalance: withdrawals - amountOfETHLocked, + withdrawalVaultBalance: 0n - withdrawals, + burnerShares: sharesToBurn - sharesBurntAmount, + }); const [sharesRateBefore, sharesRateAfter] = sharesRateFromEvent(reportTxReceipt); expect(sharesRateAfter).to.be.greaterThan(sharesRateBefore); const withdrawalsReceivedEvent = ctx.getEvents(reportTxReceipt, "WithdrawalsReceived")[0]; expect(withdrawalsReceivedEvent.args.amount).to.equal(withdrawals); - - const withdrawalVaultBalanceAfter = await ethers.provider.getBalance(withdrawalVault.address); - expect(withdrawalVaultBalanceAfter).to.equal(0, "Expected withdrawals vault to be empty"); }); - it.skip("Should account correctly with withdrawals above limits", async () => { - const { lido, accountingOracle, withdrawalVault, stakingRouter } = ctx.contracts; + it("Should account correctly with withdrawals above limits", async () => { + const { withdrawalVault } = ctx.contracts; const expectedWithdrawals = await rebaseLimitWei(); const withdrawalsExcess = ether("10"); @@ -656,10 +537,7 @@ describe("Integration: Accounting", () => { await impersonate(withdrawalVault.address, withdrawals); - const lastProcessingRefSlotBefore = await accountingOracle.getLastProcessingRefSlot(); - const totalELRewardsCollectedBefore = await lido.getTotalELRewardsCollected(); - const totalPooledEtherBefore = await lido.getTotalPooledEther(); - const totalSharesBefore = await lido.getTotalShares(); + const beforeState = await readState(); const params = { clDiff: 0n, reportElVault: false, reportWithdrawalsVault: true }; const { reportTx } = (await report(ctx, params)) as { @@ -668,235 +546,107 @@ describe("Integration: Accounting", () => { }; const reportTxReceipt = (await reportTx.wait()) as ContractTransactionReceipt; - const { amountOfETHLocked, sharesBurntAmount } = getWithdrawalParams(reportTxReceipt); - - const lastProcessingRefSlotAfter = await accountingOracle.getLastProcessingRefSlot(); - expect(lastProcessingRefSlotBefore).to.be.lessThan( - lastProcessingRefSlotAfter, - "LastProcessingRefSlot should be updated", - ); - - const totalELRewardsCollectedAfter = await lido.getTotalELRewardsCollected(); - expect(totalELRewardsCollectedBefore).to.equal(totalELRewardsCollectedAfter); - - const totalPooledEtherAfter = await lido.getTotalPooledEther(); - expect(totalPooledEtherBefore + expectedWithdrawals).to.equal(totalPooledEtherAfter + amountOfETHLocked); + const { amountOfETHLocked, sharesBurntAmount, sharesToBurn } = getWithdrawalParamsFromEvent(reportTxReceipt); - const hasWithdrawals = amountOfETHLocked != 0; - const stakingModulesCount = await stakingRouter.getStakingModulesCount(); - const transferSharesEvents = ctx.getEvents(reportTxReceipt, "TransferShares"); - const feeDistributionTransfer = ctx.flags.withCSM ? 1n : 0n; - - // Magic numbers here: 2 – burner and treasury, 1 – only treasury - expect(transferSharesEvents.length).to.equal( - (hasWithdrawals ? 2n : 1n) + stakingModulesCount + feeDistributionTransfer, - "Expected transfer of shares to DAO and staking modules", - ); - - log.debug("Staking modules count", { stakingModulesCount }); - - const mintedSharesSum = transferSharesEvents - .slice(hasWithdrawals ? 1 : 0) // skip burner if withdrawals processed - .filter(({ args }) => args.from === ZeroAddress) // only minted shares - .reduce((acc, { args }) => acc + args.sharesValue, 0n); - - // TODO: check math, why it's not equal? - // let stakingModulesSharesAsFees = 0n; - // for (let i = 1; i <= stakingModulesCount; i++) { - // const transferSharesEvent = transferSharesEvents[i + (hasWithdrawals ? 1 : 0)]; - // const stakingModuleSharesAsFees = transferSharesEvent?.args?.sharesValue || 0n; - // stakingModulesSharesAsFees += stakingModuleSharesAsFees; - // } - - // const treasurySharesAsFees = transferSharesEvents[transferSharesEvents.length - 1 - Number(feeDistributionTransfer)]; - // expect(stakingModulesSharesAsFees).to.approximately( - // treasurySharesAsFees.args.sharesValue, - // 100, - // "Shares minted to DAO and staking modules mismatch", - // ); - - const tokenRebasedEvent = getFirstEvent(reportTxReceipt, "TokenRebased"); - expect(tokenRebasedEvent.args.sharesMintedAsFees).to.equal(mintedSharesSum); + const mintedSharesSum = await expectTransferFeesEvents(reportTxReceipt); - const totalSharesAfter = await lido.getTotalShares(); - expect(totalSharesBefore + mintedSharesSum).to.equal(totalSharesAfter + sharesBurntAmount); + await expectStateChanges(beforeState, { + internalEther: expectedWithdrawals - amountOfETHLocked, + internalShares: mintedSharesSum - sharesBurntAmount, + lidoBalance: expectedWithdrawals - amountOfETHLocked, + withdrawalVaultBalance: 0n - expectedWithdrawals, + burnerShares: sharesToBurn - sharesBurntAmount, + }); const [sharesRateBefore, sharesRateAfter] = sharesRateFromEvent(reportTxReceipt); expect(sharesRateAfter).to.be.greaterThan(sharesRateBefore); const withdrawalsReceivedEvent = getFirstEvent(reportTxReceipt, "WithdrawalsReceived"); expect(withdrawalsReceivedEvent.args.amount).to.equal(expectedWithdrawals); - - const withdrawalVaultBalanceAfter = await ethers.provider.getBalance(withdrawalVault.address); - expect(withdrawalVaultBalanceAfter).to.equal( - withdrawalsExcess, - "Expected withdrawal vault to be filled with excess rewards", - ); }); it("Should account correctly shares burn at limits", async () => { - const { lido, burner, wstETH, accounting } = ctx.contracts; - - const sharesLimit = await sharesBurnLimitNoPooledEtherChanges(); - const initialBurnerBalance = await lido.sharesOf(burner.address); + const { lido, burner, wstETH: whale, accounting } = ctx.contracts; + await ensureWhaleHasFunds(ether("10000")); - await ensureWhaleHasFunds(); + const sharesLimit = await sharesToBurnToReachRebaseLimit(); + const initialBurnerBalance = await lido.sharesOf(burner); - expect(await lido.sharesOf(wstETH.address)).to.be.greaterThan(sharesLimit, "Not enough shares on whale account"); - - const stethOfShares = await lido.getPooledEthByShares(sharesLimit); - - const wstEthSigner = await impersonate(wstETH.address, ether("1")); - await lido.connect(wstEthSigner).approve(burner.address, stethOfShares); + const whaleSigner = await impersonate(whale, ether("1")); + await lido.connect(whaleSigner).approve(burner, await lido.getPooledEthByShares(sharesLimit)); const coverShares = sharesLimit / 3n; const noCoverShares = sharesLimit - sharesLimit / 3n; const accountingSigner = await impersonate(accounting.address, ether("1")); + await expect(burner.connect(accountingSigner).requestBurnShares(whale, noCoverShares)) + .to.emit(burner, "StETHBurnRequested") + .withArgs(false, accountingSigner, await lido.getPooledEthByShares(noCoverShares), noCoverShares); - const burnTx = await burner.connect(accountingSigner).requestBurnShares(wstETH.address, noCoverShares); - const burnTxReceipt = (await burnTx.wait()) as ContractTransactionReceipt; - const sharesBurntEvent = getFirstEvent(burnTxReceipt, "StETHBurnRequested"); + await expect(burner.connect(accountingSigner).requestBurnSharesForCover(whale, coverShares)) + .to.emit(burner, "StETHBurnRequested") + .withArgs(true, accountingSigner, await lido.getPooledEthByShares(coverShares), coverShares); - expect(sharesBurntEvent.args.amountOfShares).to.equal(noCoverShares, "StETHBurnRequested: amountOfShares mismatch"); - expect(sharesBurntEvent.args.isCover, "StETHBurnRequested: isCover mismatch").to.be.false; - expect(await lido.sharesOf(burner.address)).to.equal( - noCoverShares + initialBurnerBalance, - "Burner shares mismatch", - ); + expect(await lido.sharesOf(burner)).to.equal(sharesLimit + initialBurnerBalance, "Burner shares mismatch"); - const burnForCoverTx = await burner - .connect(accountingSigner) - .requestBurnSharesForCover(wstETH.address, coverShares); - const burnForCoverTxReceipt = (await burnForCoverTx.wait()) as ContractTransactionReceipt; - const sharesBurntForCoverEvent = getFirstEvent(burnForCoverTxReceipt, "StETHBurnRequested"); - - expect(sharesBurntForCoverEvent.args.amountOfShares).to.equal(coverShares); - expect(sharesBurntForCoverEvent.args.isCover, "StETHBurnRequested: isCover mismatch").to.be.true; - - const burnerShares = await lido.sharesOf(burner.address); - expect(burnerShares).to.equal(sharesLimit + initialBurnerBalance, "Burner shares mismatch"); - - const totalSharesBefore = await lido.getTotalShares(); + const stateBefore = await readState(); // Report - const params = { clDiff: 0n, excludeVaultsBalances: true }; - const { reportTx } = (await report(ctx, params)) as { - reportTx: TransactionResponse; - extraDataTx: TransactionResponse; - }; + const { reportTx } = await report(ctx, { clDiff: 0n, excludeVaultsBalances: true }); + const reportTxReceipt = (await reportTx!.wait()) as ContractTransactionReceipt; - const reportTxReceipt = (await reportTx.wait()) as ContractTransactionReceipt; - const { sharesBurntAmount, sharesToBurn } = getWithdrawalParams(reportTxReceipt); + const { sharesBurntAmount, sharesToBurn, amountOfETHLocked } = getWithdrawalParamsFromEvent(reportTxReceipt); - const burntDueToWithdrawals = sharesToBurn - (await lido.sharesOf(burner.address)) + initialBurnerBalance; + await expectStateChanges(stateBefore, { + internalShares: -1n * sharesBurntAmount, + burnerShares: -1n * sharesLimit, + internalEther: -1n * amountOfETHLocked, + lidoBalance: -1n * amountOfETHLocked, + }); + + const burntDueToWithdrawals = sharesToBurn - (await lido.sharesOf(burner)) + initialBurnerBalance; expect(burntDueToWithdrawals).to.be.greaterThanOrEqual(0); expect(sharesBurntAmount - burntDueToWithdrawals).to.equal(sharesLimit, "SharesBurnt: sharesAmount mismatch"); const [sharesRateBefore, sharesRateAfter] = sharesRateFromEvent(reportTxReceipt); expect(sharesRateAfter).to.be.greaterThan(sharesRateBefore, "Shares rate has not increased"); - expect(totalSharesBefore - sharesLimit).to.equal( - (await lido.getTotalShares()) + burntDueToWithdrawals, - "TotalShares change mismatch", - ); }); - it("Should account correctly shares burn above limits", async () => { - const { lido, burner, wstETH, accounting } = ctx.contracts; + it("Should account correctly shares burn above limits (no withdrawals)", async () => { + const { lido, burner, wstETH: whale, accounting } = ctx.contracts; - await finalizeWQViaSubmit(ctx); + await ensureWhaleHasFunds(ether("10000")); - await ensureWhaleHasFunds(); - - const limit = await sharesBurnLimitNoPooledEtherChanges(); + const limit = await sharesToBurnToReachRebaseLimit(); const excess = 42n; const limitWithExcess = limit + excess; - const initialBurnerBalance = await lido.sharesOf(burner.address); - expect(await lido.sharesOf(wstETH.address)).to.be.greaterThan(limitWithExcess, "Not enough shares on wstETH"); - - const stethOfShares = await lido.getPooledEthByShares(limitWithExcess); - - const wstEthSigner = await impersonate(wstETH.address, ether("1")); - await lido.connect(wstEthSigner).approve(burner.address, stethOfShares); - - const coverShares = limit / 3n; - const noCoverShares = limit - limit / 3n + excess; - - const accountingSigner = await impersonate(accounting.address, ether("1")); - - const burnTx = await burner.connect(accountingSigner).requestBurnShares(wstETH.address, noCoverShares); - const burnTxReceipt = (await burnTx.wait()) as ContractTransactionReceipt; - const sharesBurntEvent = getFirstEvent(burnTxReceipt, "StETHBurnRequested"); - - expect(sharesBurntEvent.args.amountOfShares).to.equal(noCoverShares, "StETHBurnRequested: amountOfShares mismatch"); - expect(sharesBurntEvent.args.isCover, "StETHBurnRequested: isCover mismatch").to.be.false; - expect(await lido.sharesOf(burner.address)).to.equal( - noCoverShares + initialBurnerBalance, - "Burner shares mismatch", - ); + expect(await lido.sharesOf(burner)).to.equal(0, "Burner balance mismatch"); - const burnForCoverRequest = await burner - .connect(accountingSigner) - .requestBurnSharesForCover(wstETH.address, coverShares); - const burnForCoverRequestReceipt = (await burnForCoverRequest.wait()) as ContractTransactionReceipt; - const sharesBurntForCoverEvent = getFirstEvent(burnForCoverRequestReceipt, "StETHBurnRequested"); + // request to burn limit+ shares + const whaleSigner = await impersonate(whale, ether("1")); + await lido.connect(whaleSigner).approve(burner, await lido.getPooledEthByShares(limitWithExcess)); + const accountingSigner = await impersonate(accounting, ether("1")); + await expect(burner.connect(accountingSigner).requestBurnShares(whale, limitWithExcess)) + .to.emit(burner, "StETHBurnRequested") + .withArgs(false, accountingSigner, await lido.getPooledEthByShares(limitWithExcess), limitWithExcess); - expect(sharesBurntForCoverEvent.args.amountOfShares).to.equal( - coverShares, - "StETHBurnRequested: amountOfShares mismatch", - ); - expect(sharesBurntForCoverEvent.args.isCover, "StETHBurnRequested: isCover mismatch").to.be.true; - expect(await lido.sharesOf(burner.address)).to.equal( - limitWithExcess + initialBurnerBalance, - "Burner shares mismatch", - ); + const stateBefore = await readState(); - const totalSharesBefore = await lido.getTotalShares(); + const limit2 = await sharesToBurnToReachRebaseLimit(); + expect(limit2).to.equal(limit); // Report - const params = { clDiff: 0n, excludeVaultsBalances: true }; - const { reportTx } = (await report(ctx, params)) as { - reportTx: TransactionResponse; - extraDataTx: TransactionResponse; - }; - - const reportTxReceipt = (await reportTx.wait()) as ContractTransactionReceipt; - const { sharesBurntAmount, sharesToBurn } = getWithdrawalParams(reportTxReceipt); - const burnerShares = await lido.sharesOf(burner.address); - const burntDueToWithdrawals = sharesToBurn - burnerShares + initialBurnerBalance + excess; - expect(burntDueToWithdrawals).to.be.greaterThanOrEqual(0); - expect(sharesBurntAmount - burntDueToWithdrawals).to.equal(limit, "SharesBurnt: sharesAmount mismatch"); - - const [sharesRateBefore, sharesRateAfter] = sharesRateFromEvent(reportTxReceipt); - expect(sharesRateAfter).to.be.greaterThan(sharesRateBefore, "Shares rate has not increased"); - - const totalSharesAfter = await lido.getTotalShares(); - expect(totalSharesBefore - limit).to.equal(totalSharesAfter + burntDueToWithdrawals, "TotalShares change mismatch"); + await report(ctx, { clDiff: 0n, excludeVaultsBalances: true, skipWithdrawals: true }); - const extraShares = await lido.sharesOf(burner.address); - expect(extraShares).to.be.greaterThanOrEqual(excess, "Expected burner to have excess shares"); - - // Second report - const secondReportParams = { clDiff: 0n, excludeVaultsBalances: true }; - const { reportTx: secondReportTx } = (await report(ctx, secondReportParams)) as { - reportTx: TransactionResponse; - extraDataTx: TransactionResponse; - }; - - const secondReportTxReceipt = (await secondReportTx.wait()) as ContractTransactionReceipt; - - const withdrawalParams = getWithdrawalParams(secondReportTxReceipt); - expect(withdrawalParams.sharesBurntAmount).to.equal(extraShares, "SharesBurnt: sharesAmount mismatch"); - - const burnerSharesAfter = await lido.sharesOf(burner.address); - expect(burnerSharesAfter).to.equal(0, "Expected burner to have no shares"); + await expectStateChanges(stateBefore, { + internalShares: -1n * limit, + burnerShares: -1n * limit, + }); }); it("Should account correctly overfill both vaults", async () => { - const { lido, withdrawalVault, elRewardsVault } = ctx.contracts; - - await finalizeWQViaSubmit(ctx); + const { withdrawalVault, elRewardsVault } = ctx.contracts; const limit = await rebaseLimitWei(); const excess = limit / 2n; // 2nd report will take two halves of the excess of the limit size @@ -905,15 +655,14 @@ describe("Integration: Accounting", () => { await setBalance(withdrawalVault.address, limitWithExcess); await setBalance(elRewardsVault.address, limitWithExcess); - const totalELRewardsCollectedBefore = await lido.getTotalELRewardsCollected(); - const totalPooledEtherBefore = await lido.getTotalPooledEther(); - const ethBalanceBefore = await ethers.provider.getBalance(lido.address); + const beforeState = await readState(); let elVaultExcess = 0n; let amountOfETHLocked = 0n; let updatedLimit = 0n; + let mintedSharesSum = 0n; { - const params = { clDiff: 0n, reportElVault: true, reportWithdrawalsVault: true }; + const params = { clDiff: 0n, reportElVault: true, reportWithdrawalsVault: true, skipWithdrawals: true }; const { reportTx } = (await report(ctx, params)) as { reportTx: TransactionResponse; extraDataTx: TransactionResponse; @@ -923,7 +672,7 @@ describe("Integration: Accounting", () => { updatedLimit = await rebaseLimitWei(); elVaultExcess = limitWithExcess - (updatedLimit - excess); - amountOfETHLocked = getWithdrawalParams(reportTxReceipt).amountOfETHLocked; + amountOfETHLocked = getWithdrawalParamsFromEvent(reportTxReceipt).amountOfETHLocked; expect(await ethers.provider.getBalance(withdrawalVault.address)).to.equal( excess, @@ -936,9 +685,11 @@ describe("Integration: Accounting", () => { const elRewardsVaultBalance = await ethers.provider.getBalance(elRewardsVault.address); expect(elRewardsVaultBalance).to.equal(limitWithExcess, "Expected EL vault to be kept unchanged"); expect(ctx.getEvents(reportTxReceipt, "ELRewardsReceived")).to.be.empty; + + mintedSharesSum += await expectTransferFeesEvents(reportTxReceipt); } { - const params = { clDiff: 0n, reportElVault: true, reportWithdrawalsVault: true }; + const params = { clDiff: 0n, reportElVault: true, reportWithdrawalsVault: true, skipWithdrawals: true }; const { reportTx } = (await report(ctx, params)) as { reportTx: TransactionResponse; extraDataTx: TransactionResponse; @@ -956,9 +707,11 @@ describe("Integration: Accounting", () => { const elRewardsEvent = getFirstEvent(reportTxReceipt, "ELRewardsReceived"); expect(elRewardsEvent.args.amount).to.equal(updatedLimit - excess, "ELRewardsReceived: amount mismatch"); + + mintedSharesSum += await expectTransferFeesEvents(reportTxReceipt); } { - const params = { clDiff: 0n, reportElVault: true, reportWithdrawalsVault: true }; + const params = { clDiff: 0n, reportElVault: true, reportWithdrawalsVault: true, skipWithdrawals: true }; const { reportTx } = (await report(ctx, params)) as { reportTx: TransactionResponse; extraDataTx: TransactionResponse; @@ -973,20 +726,16 @@ describe("Integration: Accounting", () => { const rewardsEvent = getFirstEvent(reportTxReceipt, "ELRewardsReceived"); expect(rewardsEvent.args.amount).to.equal(elVaultExcess, "ELRewardsReceived: amount mismatch"); - const totalELRewardsCollected = totalELRewardsCollectedBefore + limitWithExcess; - const totalELRewardsCollectedAfter = await lido.getTotalELRewardsCollected(); - expect(totalELRewardsCollected).to.equal(totalELRewardsCollectedAfter, "TotalELRewardsCollected change mismatch"); - - const expectedTotalPooledEther = totalPooledEtherBefore + limitWithExcess * 2n; - const totalPooledEtherAfter = await lido.getTotalPooledEther(); - expect(expectedTotalPooledEther).to.equal( - totalPooledEtherAfter + amountOfETHLocked, - "TotalPooledEther change mismatch", - ); - - const expectedEthBalance = ethBalanceBefore + limitWithExcess * 2n; - const ethBalanceAfter = await ethers.provider.getBalance(lido.address); - expect(expectedEthBalance).to.equal(ethBalanceAfter + amountOfETHLocked, "Lido ETH balance change mismatch"); + mintedSharesSum += await expectTransferFeesEvents(reportTxReceipt, true); } + + await expectStateChanges(beforeState, { + totalELRewardsCollected: limitWithExcess, + internalEther: limitWithExcess * 2n - amountOfETHLocked, + lidoBalance: limitWithExcess * 2n - amountOfETHLocked, + elRewardsVaultBalance: 0n - limitWithExcess, + withdrawalVaultBalance: 0n - limitWithExcess, + internalShares: mintedSharesSum, + }); }); }); diff --git a/test/integration/core/burn-shares.integration.ts b/test/integration/core/burn-shares.integration.ts index c829f3da55..1960e547c6 100644 --- a/test/integration/core/burn-shares.integration.ts +++ b/test/integration/core/burn-shares.integration.ts @@ -17,8 +17,8 @@ describe("Scenario: Burn Shares", () => { const amount = ether("1"); let sharesToBurn: bigint; - let totalEth: bigint; - let totalShares: bigint; + let internalEth: bigint; + let internalShares: bigint; before(async () => { ctx = await getProtocolContext(); @@ -41,13 +41,13 @@ describe("Scenario: Burn Shares", () => { expect(stEthBefore).to.be.approximately(amount, 10n, "Incorrect stETH balance after submit"); sharesToBurn = await lido.sharesOf(stranger.address); - totalEth = await lido.totalSupply(); - totalShares = await lido.getTotalShares(); + internalEth = (await lido.totalSupply()) - (await lido.getExternalEther()); + internalShares = (await lido.getTotalShares()) - (await lido.getExternalShares()); log.debug("Shares state before", { "Stranger shares": sharesToBurn, - "Total ETH": ethers.formatEther(totalEth), - "Total shares": totalShares, + "Total ETH": ethers.formatEther(internalEth), + "Total shares": internalShares, }); }); @@ -79,17 +79,17 @@ describe("Scenario: Burn Shares", () => { }); const sharesToBurnAfter = await lido.sharesOf(stranger.address); - const totalEthAfter = await lido.totalSupply(); - const totalSharesAfter = await lido.getTotalShares(); + const internalEthAfter = (await lido.totalSupply()) - (await lido.getExternalEther()); + const internalSharesAfter = (await lido.getTotalShares()) - (await lido.getExternalShares()); log.debug("Shares state after", { "Stranger shares": sharesToBurnAfter, - "Total ETH": ethers.formatEther(totalEthAfter), - "Total shares": totalSharesAfter, + "Total ETH": ethers.formatEther(internalEthAfter), + "Total shares": internalSharesAfter, }); expect(sharesToBurnAfter).to.equal(0n, "Incorrect shares balance after burn"); - expect(totalEthAfter).to.equal(totalEth, "Incorrect total ETH supply after burn"); - expect(totalSharesAfter).to.equal(totalShares - sharesToBurn, "Incorrect total shares after burn"); + expect(internalEthAfter).to.equal(internalEth, "Incorrect total ETH supply after burn"); + expect(internalSharesAfter).to.equal(internalShares - sharesToBurn, "Incorrect total shares after burn"); }); }); diff --git a/test/integration/core/dsm-keys-unvetting.integration.ts b/test/integration/core/dsm-keys-unvetting.integration.ts index b43ee93b6e..61d6bf23cf 100644 --- a/test/integration/core/dsm-keys-unvetting.integration.ts +++ b/test/integration/core/dsm-keys-unvetting.integration.ts @@ -105,7 +105,7 @@ describe("Integration: DSM keys unvetting", () => { }); it("Should allow stranger to unvet keys with valid guardian signature", async () => { - const { nor } = ctx.contracts; + const { nor, stakingRouter } = ctx.contracts; // Create new guardian with known (arbitrary) private key const guardian = new ethers.Wallet(GUARDIAN_PRIVATE_KEY).address; @@ -118,12 +118,26 @@ describe("Integration: DSM keys unvetting", () => { const operatorId = 0n; const blockNumber = await time.latestBlock(); const blockHash = (await ethers.provider.getBlock(blockNumber))!.hash!; - const nonce = await ctx.contracts.stakingRouter.getStakingModuleNonce(stakingModuleId); - // Get node operator state before unvetting - const nodeOperatorBefore = await nor.getNodeOperator(operatorId, true); - const totalVettedValidatorsBefore = nodeOperatorBefore.totalVettedValidators; - const vettedSigningKeysCount = totalVettedValidatorsBefore - 2n; + // eslint-disable-next-line prefer-const + let { totalVettedValidators, totalDepositedValidators, totalAddedValidators } = await nor.getNodeOperator( + operatorId, + true, + ); + + // Add more keys if needed + if (totalAddedValidators === totalDepositedValidators) { + await norSdvtAddOperatorKeys(ctx, nor, { operatorId, keysToAdd: 2n }); + totalAddedValidators += 2n; + } + + // Set more limit if needed + if (totalVettedValidators === totalDepositedValidators) { + await norSdvtSetOperatorStakingLimit(ctx, nor, { operatorId, limit: totalVettedValidators + 2n }); + totalVettedValidators += 2n; + } + + const vettedSigningKeysCount = totalVettedValidators - 2n; // Pack operator IDs into bytes (8 bytes per ID) const nodeOperatorIds = ethers.solidityPacked(["uint64"], [operatorId]); @@ -131,6 +145,7 @@ describe("Integration: DSM keys unvetting", () => { // Pack vetted signing keys counts into bytes (16 bytes per count) const vettedSigningKeysCounts = ethers.solidityPacked(["uint128"], [vettedSigningKeysCount]); + const nonce = await stakingRouter.getStakingModuleNonce(stakingModuleId); // Generate valid guardian signature const unvetMessage = new DSMUnvetMessage( blockNumber, @@ -144,11 +159,8 @@ describe("Integration: DSM keys unvetting", () => { const sig = await unvetMessage.sign(GUARDIAN_PRIVATE_KEY); // Get node operator state before unvetting - expect(totalVettedValidatorsBefore).to.be.not.equal(vettedSigningKeysCount); - const totalVettedValidatorsAfter = BigIntMath.max( - vettedSigningKeysCount, - nodeOperatorBefore.totalDepositedValidators, - ); + expect(totalVettedValidators).to.be.not.equal(vettedSigningKeysCount); + const totalVettedValidatorsAfter = BigIntMath.max(vettedSigningKeysCount, totalDepositedValidators); // Unvet signing keys const tx = await dsm @@ -168,7 +180,7 @@ describe("Integration: DSM keys unvetting", () => { }); it("Should allow guardian to unvet signing keys directly", async () => { - const { nor } = ctx.contracts; + const { nor, stakingRouter } = ctx.contracts; // Create new guardian with known (arbitrary)private key const guardian = new ethers.Wallet(GUARDIAN_PRIVATE_KEY).address; @@ -179,24 +191,33 @@ describe("Integration: DSM keys unvetting", () => { const operatorId = 0n; // Get node operator state before unvetting - const nodeOperatorBefore = await nor.getNodeOperator(operatorId, true); - const totalDepositedValidatorsBefore = nodeOperatorBefore.totalDepositedValidators; - expect(totalDepositedValidatorsBefore).to.be.gte(1n); - const totalVettedValidatorsBefore = nodeOperatorBefore.totalVettedValidators; + // eslint-disable-next-line prefer-const + let { totalDepositedValidators, totalVettedValidators, totalAddedValidators } = await nor.getNodeOperator( + operatorId, + true, + ); + + // Add more keys if needed + if (totalAddedValidators === totalDepositedValidators) { + await norSdvtAddOperatorKeys(ctx, nor, { operatorId, keysToAdd: 3n }); + totalAddedValidators += 3n; + } + + // Set more limit if needed + if (totalVettedValidators === totalDepositedValidators) { + await norSdvtSetOperatorStakingLimit(ctx, nor, { operatorId, limit: totalVettedValidators + 3n }); + totalVettedValidators += 3n; + } // Prepare unvet parameters const stakingModuleId = 1; - const vettedSigningKeysCount = totalVettedValidatorsBefore - 3n; + const vettedSigningKeysCount = totalVettedValidators - 3n; const blockNumber = await time.latestBlock(); const blockHash = (await ethers.provider.getBlock(blockNumber))!.hash!; - const nonce = await ctx.contracts.stakingRouter.getStakingModuleNonce(stakingModuleId); + const nonce = await stakingRouter.getStakingModuleNonce(stakingModuleId); // Get node operator state before unvetting - const totalVettedValidatorsAfter = Math.max( - Number(vettedSigningKeysCount), - Number(nodeOperatorBefore.totalDepositedValidators), - ); - expect(totalDepositedValidatorsBefore).to.be.gte(1n); + const totalVettedValidatorsAfter = Math.max(Number(vettedSigningKeysCount), Number(totalDepositedValidators)); // Pack operator IDs into bytes (8 bytes per ID) const nodeOperatorIds = ethers.solidityPacked(["uint64"], [operatorId]); @@ -205,23 +226,19 @@ describe("Integration: DSM keys unvetting", () => { const vettedSigningKeysCounts = ethers.solidityPacked(["uint128"], [vettedSigningKeysCount]); // Guardian should be able to unvet directly without signature - const tx = await dsm - .connect(guardianSigner) - .unvetSigningKeys(blockNumber, blockHash, stakingModuleId, nonce, nodeOperatorIds, vettedSigningKeysCounts, { - r: ZeroHash, - vs: ZeroHash, - }); - - // Check events - const receipt = await tx.wait(); - const unvetEvents = findEventsWithInterfaces(receipt!, "VettedSigningKeysCountChanged", [nor.interface]); - expect(unvetEvents.length).to.equal(1); - expect(unvetEvents[0].args.nodeOperatorId).to.equal(operatorId); - expect(unvetEvents[0].args.approvedValidatorsCount).to.equal(totalVettedValidatorsAfter); - + await expect( + dsm + .connect(guardianSigner) + .unvetSigningKeys(blockNumber, blockHash, stakingModuleId, nonce, nodeOperatorIds, vettedSigningKeysCounts, { + r: ZeroHash, + vs: ZeroHash, + }), + ) + .to.emit(nor, "VettedSigningKeysCountChanged") + .withArgs(operatorId, totalVettedValidatorsAfter); // Verify node operator state after unvetting const nodeOperatorAfter = await nor.getNodeOperator(operatorId, true); - expect(nodeOperatorAfter.totalDepositedValidators).to.equal(totalDepositedValidatorsBefore); + expect(nodeOperatorAfter.totalDepositedValidators).to.equal(totalDepositedValidators); expect(nodeOperatorAfter.totalVettedValidators).to.equal(totalVettedValidatorsAfter); }); diff --git a/test/integration/core/happy-path.integration.ts b/test/integration/core/happy-path.integration.ts index bba53f3bac..436edc679f 100644 --- a/test/integration/core/happy-path.integration.ts +++ b/test/integration/core/happy-path.integration.ts @@ -11,7 +11,9 @@ import { norSdvtEnsureOperators, OracleReportParams, ProtocolContext, + removeStakingLimit, report, + setStakingLimit, } from "lib/protocol"; import { bailOnFailure, MAX_DEPOSIT, Snapshot, ZERO_HASH } from "test/suite"; @@ -39,6 +41,9 @@ describe("Scenario: Protocol Happy Path", () => { await updateBalance(stEthHolder.address, ether("100000000")); snapshot = await Snapshot.take(); + + await removeStakingLimit(ctx); + await setStakingLimit(ctx, ether("200000"), ether("20")); }); after(async () => await Snapshot.restore(snapshot)); @@ -247,7 +252,7 @@ describe("Scenario: Protocol Happy Path", () => { }); it("Should rebase correctly", async () => { - const { lido, withdrawalQueue, locator, burner, nor, sdvt } = ctx.contracts; + const { lido, withdrawalQueue, locator, burner, nor, sdvt, stakingRouter, csm, accounting } = ctx.contracts; const treasuryAddress = await locator.treasury(); const strangerBalancesBeforeRebase = await getBalances(stranger); @@ -317,25 +322,20 @@ describe("Scenario: Protocol Happy Path", () => { const transferEvents = ctx.getEvents(reportTxReceipt, "Transfer"); const transferSharesEvents = ctx.getEvents(reportTxReceipt, "TransferShares"); - let toBurnerTransfer, - toNorTransfer, - toSdvtTransfer, - toTreasuryTransfer, - toTreasuryTransferShares: LogDescriptionExtended | undefined; - let numExpectedTransferEvents = 3; + let toBurnerTransfer, toNorTransfer, toSdvtTransfer: LogDescriptionExtended | undefined; + let numExpectedTransferEvents = Number(await stakingRouter.getStakingModulesCount()) + 2; // +1 for the treasury if (wereWithdrawalsFinalized) { - numExpectedTransferEvents += 1; - [toBurnerTransfer, toNorTransfer, toSdvtTransfer] = transferEvents; + numExpectedTransferEvents += 1; // +1 for the burner transfer + [toBurnerTransfer, , toNorTransfer, toSdvtTransfer] = transferEvents; } else { - [toNorTransfer, toSdvtTransfer] = transferEvents; + [, toNorTransfer, toSdvtTransfer] = transferEvents; } - if (ctx.flags.withCSM) { - toTreasuryTransfer = transferEvents[numExpectedTransferEvents]; - toTreasuryTransferShares = transferSharesEvents[numExpectedTransferEvents]; - numExpectedTransferEvents += 2; - } else { - toTreasuryTransfer = transferEvents[numExpectedTransferEvents - 1]; - toTreasuryTransferShares = transferSharesEvents[numExpectedTransferEvents - 1]; + const toTreasuryTransfer = transferEvents[numExpectedTransferEvents - 1]; + const toTreasuryTransferShares = transferSharesEvents[numExpectedTransferEvents - 1]; + + if (csm !== undefined) { + // +1 for the CSM internal transfer + numExpectedTransferEvents += 1; } expect(transferEvents.length).to.equal(numExpectedTransferEvents, "Transfer events count"); @@ -352,7 +352,7 @@ describe("Scenario: Protocol Happy Path", () => { expect(toNorTransfer?.args.toObject()).to.include( { - from: ZeroAddress, + from: accounting.address, to: nor.address, }, "Transfer to NOR", @@ -360,7 +360,7 @@ describe("Scenario: Protocol Happy Path", () => { expect(toSdvtTransfer?.args.toObject()).to.include( { - from: ZeroAddress, + from: accounting.address, to: sdvt.address, }, "Transfer to SDVT", @@ -368,14 +368,14 @@ describe("Scenario: Protocol Happy Path", () => { expect(toTreasuryTransfer?.args.toObject()).to.include( { - from: ZeroAddress, + from: accounting.address, to: treasuryAddress, }, "Transfer to Treasury", ); expect(toTreasuryTransferShares?.args.toObject()).to.include( { - from: ZeroAddress, + from: accounting.address, to: treasuryAddress, }, "Transfer shares to Treasury", diff --git a/test/integration/core/staking-limits.integration.ts b/test/integration/core/staking-limits.integration.ts index 42a43d49de..63b98adeaf 100644 --- a/test/integration/core/staking-limits.integration.ts +++ b/test/integration/core/staking-limits.integration.ts @@ -47,17 +47,6 @@ describe("Staking limits", () => { after(async () => await Snapshot.restore(snapshot)); - it("Should have expected staking limit info", async () => { - const info = await lido.getStakeLimitFullInfo(); - - expect(info.isStakingPaused_).to.be.false; - expect(info.isStakingLimitSet).to.be.true; - expect(info.currentStakeLimit).to.be.lte(ether("150000")); - expect(info.currentStakeLimit).to.be.gt(0); - expect(info.maxStakeLimit).to.equal(ether("150000")); - expect(info.prevStakeLimit).to.be.lte(ether("150000")); - }); - it("Should have staking not paused initially", async () => { expect(await lido.isStakingPaused()).to.be.false; }); diff --git a/test/integration/core/staking-module.integration.ts b/test/integration/core/staking-module.integration.ts index b4a6cc47e4..d72bff82b3 100644 --- a/test/integration/core/staking-module.integration.ts +++ b/test/integration/core/staking-module.integration.ts @@ -10,8 +10,6 @@ import { randomPubkeys, randomSignatures } from "lib/protocol/helpers/staking-mo import { Snapshot } from "test/suite"; -const MAINNET_SDVT_ADDRESS = "0xaE7B191A31f627b4eB1d4DaC64eaB9976995b433".toLowerCase(); - describe("Integration: Staking module", () => { let ctx: ProtocolContext; let stranger: HardhatEthersSigner; @@ -33,13 +31,6 @@ describe("Integration: Staking module", () => { after(async () => await Snapshot.restore(snapshot)); - async function getSdvtNoManagerSigner() { - if (ctx.contracts.sdvt.address.toLowerCase() === MAINNET_SDVT_ADDRESS) { - return await ctx.getSigner("easyTrack"); - } - return await ctx.getSigner("agent"); - } - async function testUpdateTargetValidatorsLimits(module: LoadedContract, addNodeOperatorSigner: HardhatEthersSigner) { const agentSigner = await ctx.getSigner("agent"); const rewardAddress = certainAddress("rewardAddress"); @@ -243,14 +234,14 @@ describe("Integration: Staking module", () => { it("should test SDVT update target validators limits", async () => { await testUpdateTargetValidatorsLimits( ctx.contracts.sdvt as unknown as LoadedContract, - await getSdvtNoManagerSigner(), + ctx.isScratch ? await ctx.getSigner("agent") : await ctx.getSigner("easyTrack"), ); }); it("should test SDVT decrease vetted signing keys count", async () => { await testDecreaseVettedSigningKeysCount( ctx.contracts.sdvt as unknown as LoadedContract, - await getSdvtNoManagerSigner(), + ctx.isScratch ? await ctx.getSigner("agent") : await ctx.getSigner("easyTrack"), ); }); }); diff --git a/test/integration/vaults/bad-debt.integration.ts b/test/integration/vaults/bad-debt.integration.ts index ab82e1a34f..fcfdfb5d9f 100644 --- a/test/integration/vaults/bad-debt.integration.ts +++ b/test/integration/vaults/bad-debt.integration.ts @@ -5,19 +5,21 @@ import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { Dashboard, StakingVault } from "typechain-types"; -import { MAX_UINT256 } from "lib"; +import { advanceChainTime, days, getCurrentBlockTimestamp, MAX_UINT256, SECONDS_PER_SLOT } from "lib"; import { - changeTier, createVaultWithDashboard, - DEFAULT_TIER_PARAMS, getProtocolContext, + getReportTimeElapsed, ProtocolContext, report, reportVaultDataWithProof, + reportVaultsDataWithProof, + resetDefaultTierShareLimit, setupLidoForVaults, - setUpOperatorGrid, + upDefaultTierShareLimit, waitNextAvailableReportTime, } from "lib/protocol"; +import { simulateReport } from "lib/protocol/helpers/accounting"; import { ether } from "lib/units"; import { Snapshot } from "test/suite"; @@ -50,6 +52,8 @@ describe("Integration: Vault with bad debt", () => { nodeOperator, )); + await upDefaultTierShareLimit(ctx, ether("1000")); + dashboard = dashboard.connect(owner); // Going to bad debt @@ -78,6 +82,131 @@ describe("Integration: Vault with bad debt", () => { afterEach(async () => await Snapshot.restore(snapshot)); after(async () => await Snapshot.restore(originalSnapshot)); + describe("Bad Debt Detection", () => { + it("Detect bad debt condition", async () => { + const { vaultHub, lido } = ctx.contracts; + + // Verify bad debt exists + const totalValue = await dashboard.totalValue(); + const liabilityShares = await dashboard.liabilityShares(); + const liabilityValue = await lido.getPooledEthBySharesRoundUp(liabilityShares); + + expect(totalValue).to.be.lessThan(liabilityValue, "Total value should be less than liability value"); + + // Check isVaultHealthy + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.equal(false, "Vault should be unhealthy"); + + // Check healthShortfallShares + expect(await vaultHub.healthShortfallShares(stakingVault)).to.be.equal( + MAX_UINT256, + "healthShortfallShares should be MAX_UINT256", + ); + + // Check obligationsShortfallValue + expect(await vaultHub.obligationsShortfallValue(stakingVault)).to.be.equal( + MAX_UINT256, + "obligationsShortfallValue should be MAX_UINT256", + ); + }); + + it("Bad debt prevents normal operations (mint, withdraw, or disconnect)", async () => { + const { vaultHub } = ctx.contracts; + + // Verify vault is unhealthy + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.equal(false); + + // Try to mint - should fail with ExceedsMintingCapacity (no lockable value available) + await expect(dashboard.mintShares(owner, 1000n)).to.be.revertedWithCustomError( + dashboard, + "ExceedsMintingCapacity", + ); + + // Try to withdraw - should fail with ExceedsWithdrawable (withdrawable is 0) + await expect(dashboard.withdraw(owner, ether("0.1"))).to.be.revertedWithCustomError( + dashboard, + "ExceedsWithdrawable", + ); + + // Try to disconnect - should fail with NoLiabilitySharesShouldBeLeft + await expect(dashboard.voluntaryDisconnect()).to.be.revertedWithCustomError( + vaultHub, + "NoLiabilitySharesShouldBeLeft", + ); + }); + }); + + describe("Cover Bad Debt", () => { + it("Owner covers bad debt with direct deposit", async () => { + const { vaultHub, lido } = ctx.contracts; + + // Calculate bad debt amount + const liabilityShares = await dashboard.liabilityShares(); + const totalValue = await dashboard.totalValue(); + const liabilityValue = await lido.getPooledEthBySharesRoundUp(liabilityShares); + + // Verify vault is unhealthy before recovery + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.equal(false); + + // Owner deposits to cover bad debt + // Use 2x the liability value to ensure we're above health threshold + const depositAmount = liabilityValue * 3n - totalValue; + await dashboard.fund({ value: depositAmount }); + + // Bring fresh report + await reportVaultDataWithProof(ctx, stakingVault, { waitForNextRefSlot: true }); + + // Verify vault is now healthy + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.equal(true, "Vault should be healthy after deposit"); + + // Verify healthShortfallShares is no longer MAX_UINT256 + expect(await vaultHub.healthShortfallShares(stakingVault)).to.not.equal( + MAX_UINT256, + "healthShortfallShares should not be MAX_UINT256", + ); + + // Mint should work - use actual minting capacity + const mintingCapacity = await dashboard.remainingMintingCapacityShares(0n); + expect(mintingCapacity).to.be.greaterThan(0n, "Should have minting capacity"); + const sharesToMint = mintingCapacity / 10n; // Mint 10% of capacity + await expect(dashboard.mintShares(owner, sharesToMint)).to.emit(vaultHub, "MintedSharesOnVault"); + + // Withdraw should work + const withdrawableValue = await vaultHub.withdrawableValue(stakingVault); + expect(withdrawableValue).to.be.greaterThan(ether("0.1"), "Should have withdrawable value"); + await expect(dashboard.withdraw(owner, ether("0.1"))).to.emit(stakingVault, "EtherWithdrawn"); + }); + + it("Recovery via CL rewards", async () => { + const { vaultHub, lazyOracle } = ctx.contracts; + + const totalValue = await dashboard.totalValue(); + + // Verify vault is unhealthy before recovery + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.equal(false); + + // Increase totalValue by 100% each time - simulate CL rewards accumulation + const agentSigner = await ctx.getSigner("agent"); + await lazyOracle.connect(agentSigner).grantRole(await lazyOracle.UPDATE_SANITY_PARAMS_ROLE(), agentSigner); + await lazyOracle.connect(agentSigner).updateSanityParams(days(30n), 10500n, ether("0.01")); + + let newTotalValue = totalValue; + for (let i = 0; i < 5; i++) { + newTotalValue = newTotalValue * 2n; + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: newTotalValue, + waitForNextRefSlot: true, + }); + } + + // Verify vault is now healthy + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.equal(true, "Vault should be healthy after CL rewards"); + + // Verify healthShortfallShares is no longer MAX_UINT256 + const healthShortfall = await vaultHub.healthShortfallShares(stakingVault); + expect(healthShortfall).to.not.equal(MAX_UINT256, "healthShortfallShares should not be MAX_UINT256"); + }); + }); + describe("Socialization", () => { let acceptorStakingVault: StakingVault; let acceptorDashboard: Dashboard; @@ -90,7 +219,6 @@ describe("Integration: Vault with bad debt", () => { stakingVaultFactory, otherOwner, nodeOperator, - nodeOperator, )); }); @@ -186,39 +314,170 @@ describe("Integration: Vault with bad debt", () => { await acceptorDashboard.connect(otherOwner).fund({ value: ether("10") }); const { vaultHub, lido } = ctx.contracts; - await setUpOperatorGrid( + // Reset the default tier share limit to 0 + await resetDefaultTierShareLimit(ctx); + + const badDebtShares = + (await dashboard.liabilityShares()) - (await lido.getSharesByPooledEth(await dashboard.totalValue())); + + await expect(vaultHub.connect(daoAgent).socializeBadDebt(stakingVault, acceptorStakingVault, badDebtShares)) + .to.emit(vaultHub, "BadDebtSocialized") + .withArgs(stakingVault, acceptorStakingVault, badDebtShares); + }); + + it("Socialization requires fresh reports", async () => { + await acceptorDashboard.connect(otherOwner).fund({ value: ether("10") }); + const { vaultHub, lido } = ctx.contracts; + + const badDebtShares = + (await dashboard.liabilityShares()) - (await lido.getSharesByPooledEth(await dashboard.totalValue())); + + // Advance time to make report stale + await advanceChainTime(await vaultHub.REPORT_FRESHNESS_DELTA()); + + // Try to socialize with stale report - should fail + await expect(vaultHub.connect(daoAgent).socializeBadDebt(stakingVault, acceptorStakingVault, badDebtShares)) + .to.be.revertedWithCustomError(vaultHub, "VaultReportStale") + .withArgs(stakingVault); + }); + + it("Socialization only between same node operator", async () => { + const { stakingVaultFactory, vaultHub, lido } = ctx.contracts; + const [, , , , , differentOperator] = await ethers.getSigners(); + + // Create acceptor vault with different node operator + const { stakingVault: differentOpVault, dashboard: differentOpDashboard } = await createVaultWithDashboard( ctx, - [nodeOperator], - [{ noShareLimit: await acceptorDashboard.liabilityShares(), tiers: [DEFAULT_TIER_PARAMS] }], + stakingVaultFactory, + otherOwner, + differentOperator, + differentOperator, ); - await changeTier(ctx, acceptorDashboard, otherOwner, nodeOperator); + + await differentOpDashboard.connect(otherOwner).fund({ value: ether("10") }); const badDebtShares = (await dashboard.liabilityShares()) - (await lido.getSharesByPooledEth(await dashboard.totalValue())); + // Try to socialize between different operators - should fail + await expect( + vaultHub.connect(daoAgent).socializeBadDebt(stakingVault, differentOpVault, badDebtShares), + ).to.be.revertedWithCustomError(vaultHub, "BadDebtSocializationNotAllowed"); + + // Verify socialization works with same operator + await acceptorDashboard.connect(otherOwner).fund({ value: ether("10") }); await expect(vaultHub.connect(daoAgent).socializeBadDebt(stakingVault, acceptorStakingVault, badDebtShares)) .to.emit(vaultHub, "BadDebtSocialized") .withArgs(stakingVault, acceptorStakingVault, badDebtShares); }); + + it("Multi-vault bad debt socialization scenario", async () => { + const { stakingVaultFactory, vaultHub, lido } = ctx.contracts; + + // Create vault B and C (acceptors, same operator) + const { stakingVault: vaultB, dashboard: dashboardB } = await createVaultWithDashboard( + ctx, + stakingVaultFactory, + otherOwner, + nodeOperator, + ); + + const { stakingVault: vaultC, dashboard: dashboardC } = await createVaultWithDashboard( + ctx, + stakingVaultFactory, + otherOwner, + nodeOperator, + ); + + // Fund acceptor vaults with limited capacity + const donorLiabilitySharesBefore = await dashboard.liabilityShares(); + await dashboardB.connect(otherOwner).fund({ value: donorLiabilitySharesBefore / 4n }); + await dashboardC.connect(otherOwner).fund({ value: donorLiabilitySharesBefore }); + + const totalBadDebtShares = + (await dashboard.liabilityShares()) - (await lido.getSharesByPooledEth(await dashboard.totalValue())); + + const vaultBLiabilitySharesBefore = await dashboardB.liabilityShares(); + const vaultCLiabilitySharesBefore = await dashboardC.liabilityShares(); + + // Calculate capacity for each acceptor (approximately half of total value in shares) + const vaultBCapacity = await lido.getSharesByPooledEth(await dashboardB.totalValue()); + const vaultCCapacity = await lido.getSharesByPooledEth(await dashboardC.totalValue()); + + // First socialization: transfer to vault B + await expect(vaultHub.connect(daoAgent).socializeBadDebt(stakingVault, vaultB, totalBadDebtShares)).to.emit( + vaultHub, + "BadDebtSocialized", + ); + + const donorLiabilitySharesAfterFirst = await dashboard.liabilityShares(); + const vaultBLiabilitySharesAfterFirst = await dashboardB.liabilityShares(); + + // Verify first transfer + expect(donorLiabilitySharesAfterFirst).to.be.lessThan(donorLiabilitySharesBefore); + expect(vaultBLiabilitySharesAfterFirst).to.be.greaterThan(vaultBLiabilitySharesBefore); + expect(vaultBLiabilitySharesAfterFirst).to.be.lessThanOrEqual(vaultBCapacity, "Vault B shouldn't has bad debt"); + + // Second socialization: transfer remaining to vault C + const remainingBadDebt = + (await dashboard.liabilityShares()) - (await lido.getSharesByPooledEth(await dashboard.totalValue())); + + await expect(vaultHub.connect(daoAgent).socializeBadDebt(stakingVault, vaultC, remainingBadDebt)).to.emit( + vaultHub, + "BadDebtSocialized", + ); + + const donorLiabilitySharesAfterSecond = await dashboard.liabilityShares(); + const vaultCLiabilitySharesAfterSecond = await dashboardC.liabilityShares(); + + // Verify second transfer + expect(donorLiabilitySharesAfterSecond).to.be.lessThan(donorLiabilitySharesAfterFirst); + expect(vaultCLiabilitySharesAfterSecond).to.be.greaterThan(vaultCLiabilitySharesBefore); + expect(vaultCLiabilitySharesAfterSecond).to.be.lessThanOrEqual(vaultCCapacity, "Vault C shouldn't has bad debt"); + + // Verify vault A is fully recovered from bad debt + const finalBadDebt = + (await dashboard.liabilityShares()) - (await lido.getSharesByPooledEth(await dashboard.totalValue())); + + expect(finalBadDebt).to.equal(0n, "Donor vault should be fully recovered"); + + // Verify all liability shares are properly tracked + const totalLiabilityAfter = + (await dashboard.liabilityShares()) + + (await dashboardB.liabilityShares()) + + (await dashboardC.liabilityShares()); + + const totalLiabilityBefore = + donorLiabilitySharesBefore + vaultBLiabilitySharesBefore + vaultCLiabilitySharesBefore; + + expect(totalLiabilityAfter).to.equal(totalLiabilityBefore, "Total liability shares should be conserved"); + }); }); describe("Internalization", () => { - it("Vault's bad debt can be internalized", async () => { + it("Full bad debt internalization", async () => { const { vaultHub, lido } = ctx.contracts; const badDebtShares = (await dashboard.liabilityShares()) - (await lido.getSharesByPooledEth(await dashboard.totalValue())); + const liabilitySharesBefore = await dashboard.liabilityShares(); + await expect(vaultHub.connect(daoAgent).internalizeBadDebt(stakingVault, badDebtShares)) .to.emit(vaultHub, "BadDebtWrittenOffToBeInternalized") .withArgs(stakingVault, badDebtShares); + expect(await dashboard.liabilityShares()).to.be.equal( + liabilitySharesBefore - badDebtShares, + "Liability shares reduced by internalized amount", + ); + expect(await dashboard.liabilityShares()).to.be.lessThanOrEqual( await lido.getSharesByPooledEth(await dashboard.totalValue()), "No bad debt in vault", ); - expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.equal(false); + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.equal(false, "Vault still unhealthy"); await waitNextAvailableReportTime(ctx); expect(await vaultHub.badDebtToInternalize()).to.be.equal(badDebtShares); @@ -232,5 +491,218 @@ describe("Integration: Vault with bad debt", () => { expect(await vaultHub.badDebtToInternalize()).to.be.equal(0n); }); + + it("Partial bad debt internalization", async () => { + const { vaultHub, lido } = ctx.contracts; + + const badDebtShares = + (await dashboard.liabilityShares()) - (await lido.getSharesByPooledEth(await dashboard.totalValue())); + + const partialAmount = badDebtShares / 2n; + const liabilitySharesBefore = await dashboard.liabilityShares(); + + await expect(vaultHub.connect(daoAgent).internalizeBadDebt(stakingVault, partialAmount)) + .to.emit(vaultHub, "BadDebtWrittenOffToBeInternalized") + .withArgs(stakingVault, partialAmount); + + expect(await dashboard.liabilityShares()).to.be.equal( + liabilitySharesBefore - partialAmount, + "Liability shares reduced by partial amount", + ); + + const remainingBadDebt = + (await dashboard.liabilityShares()) - (await lido.getSharesByPooledEth(await dashboard.totalValue())); + expect(remainingBadDebt).to.be.greaterThan(0n, "Still has bad debt"); + + await waitNextAvailableReportTime(ctx); + expect(await vaultHub.badDebtToInternalize()).to.be.equal(partialAmount); + + const { reportTx } = await report(ctx, { waitNextReportTime: false }); + await expect(reportTx) + .to.emit(lido, "ExternalBadDebtInternalized") + .withArgs(partialAmount) + .to.emit(lido, "ExternalSharesBurnt") + .withArgs(partialAmount); + + expect(await vaultHub.badDebtToInternalize()).to.be.equal(0n); + }); + + it("Internalized debt settled on AccountingOracle report", async () => { + const { vaultHub, lido } = ctx.contracts; + + const badDebtShares = + (await dashboard.liabilityShares()) - (await lido.getSharesByPooledEth(await dashboard.totalValue())); + + // First internalize the bad debt + await vaultHub.connect(daoAgent).internalizeBadDebt(stakingVault, badDebtShares); + + await waitNextAvailableReportTime(ctx); + expect(await vaultHub.badDebtToInternalize()).to.be.equal(badDebtShares, "Bad debt accumulated"); + + const totalSharesBefore = await lido.getTotalShares(); + const externalSharesBefore = await lido.getExternalShares(); + + // Trigger oracle report + const { reportTx } = await report(ctx, { waitNextReportTime: false }); + + await expect(reportTx) + .to.emit(lido, "ExternalBadDebtInternalized") + .withArgs(badDebtShares) + .to.emit(lido, "ExternalSharesBurnt") + .withArgs(badDebtShares); + + // Get the exact amount of fees minted from TokenRebased event + const receipt = await reportTx!.wait(); + const rebasedEvent = ctx.getEvents(receipt!, "TokenRebased")[0]; + const sharesMintedAsFees = rebasedEvent.args.sharesMintedAsFees; + + expect(await vaultHub.badDebtToInternalize()).to.be.equal(0n, "Bad debt reset to 0"); + expect(await lido.getExternalShares()).to.be.equal( + externalSharesBefore - badDebtShares, + "External shares decreased", + ); + // Total shares increase only by minted fees (bad debt is transferred from external to internal, not creating new shares) + expect(await lido.getTotalShares()).to.be.equal( + totalSharesBefore + sharesMintedAsFees, + "Total shares increased exactly by minted fees", + ); + }); + + it("BadDebtToInternalize lags to next refSlot", async () => { + const { vaultHub, lido, hashConsensus } = ctx.contracts; + + const badDebtShares = + (await dashboard.liabilityShares()) - (await lido.getSharesByPooledEth(await dashboard.totalValue())); + + const currentRefSlot = (await hashConsensus.getCurrentFrame()).refSlot; + + // Internalize bad debt in current refSlot + await vaultHub.connect(daoAgent).internalizeBadDebt(stakingVault, badDebtShares); + + // Immediately check badDebtToInternalize - should return 0 because increase applies to next refSlot + expect(await vaultHub.badDebtToInternalizeForLastRefSlot()).to.be.equal(0n, "Returns 0 for current refSlot"); + + // Wait for next refSlot + await waitNextAvailableReportTime(ctx); + + const newRefSlot = (await hashConsensus.getCurrentFrame()).refSlot; + expect(newRefSlot).to.be.greaterThan(currentRefSlot, "Advanced to next refSlot"); + + // Now badDebtToInternalize should show the internalized amount + expect(await vaultHub.badDebtToInternalizeForLastRefSlot()).to.be.equal( + badDebtShares, + "Returns internalized amount", + ); + }); + + it("Multiple internalizations accumulate", async () => { + const { vaultHub, lido, stakingVaultFactory } = ctx.contracts; + + // Create a second vault with bad debt + const [, , , , , otherOwner2] = await ethers.getSigners(); + const { stakingVault: vault2, dashboard: dashboard2 } = await createVaultWithDashboard( + ctx, + stakingVaultFactory, + otherOwner2, + nodeOperator, + nodeOperator, + ); + + // Setup bad debt for vault2 + await dashboard2.connect(otherOwner2).fund({ value: ether("10") }); + await dashboard2 + .connect(otherOwner2) + .mintShares(otherOwner2, await dashboard2.remainingMintingCapacityShares(0n)); + + // Bring fresh reports for both vaults + await reportVaultsDataWithProof(ctx, [stakingVault, vault2], { + totalValue: [ether("1"), ether("1")], + slashingReserve: [ether("1"), ether("1")], + waitForNextRefSlot: true, + }); + + const badDebtShares1 = + (await dashboard.liabilityShares()) - (await lido.getSharesByPooledEth(await dashboard.totalValue())); + const badDebtShares2 = + (await dashboard2.liabilityShares()) - (await lido.getSharesByPooledEth(await dashboard2.totalValue())); + + const amountA = badDebtShares1 / 2n; + const amountB = badDebtShares2 / 3n; + + // Internalize from both vaults + await vaultHub.connect(daoAgent).internalizeBadDebt(stakingVault, amountA); + await vaultHub.connect(daoAgent).internalizeBadDebt(vault2, amountB); + + // Wait for next refSlot + await waitNextAvailableReportTime(ctx); + + // Check accumulated amount + expect(await vaultHub.badDebtToInternalize()).to.be.equal(amountA + amountB, "Amounts accumulated"); + + const { reportTx } = await report(ctx, { waitNextReportTime: false }); + await expect(reportTx) + .to.emit(lido, "ExternalBadDebtInternalized") + .withArgs(amountA + amountB) + .to.emit(lido, "ExternalSharesBurnt") + .withArgs(amountA + amountB); + + expect(await vaultHub.badDebtToInternalize()).to.be.equal(0n); + }); + + it("Internalization requires fresh report", async () => { + const { vaultHub, lido } = ctx.contracts; + + const badDebtShares = + (await dashboard.liabilityShares()) - (await lido.getSharesByPooledEth(await dashboard.totalValue())); + + // Advance time to make report stale + await advanceChainTime(await vaultHub.REPORT_FRESHNESS_DELTA()); + + // Try to internalize with stale report - should fail + await expect(vaultHub.connect(daoAgent).internalizeBadDebt(stakingVault, badDebtShares)) + .to.be.revertedWithCustomError(vaultHub, "VaultReportStale") + .withArgs(stakingVault); + }); + + it("Internalization is reflected in the next report(both simulation and real report)", async () => { + const { vaultHub, lido, hashConsensus } = ctx.contracts; + + const badDebtShares = + (await dashboard.liabilityShares()) - (await lido.getSharesByPooledEth(await dashboard.totalValue())); + + await vaultHub.connect(daoAgent).internalizeBadDebt(stakingVault, badDebtShares); + + const { reportProcessingDeadlineSlot: nextRefSlot } = await hashConsensus.getCurrentFrame(); + const { time, nextFrameStart } = await getReportTimeElapsed(ctx); + + await advanceChainTime(nextFrameStart - time); // Advance to the next frame start exactly + expect(await getCurrentBlockTimestamp()).to.be.equal(nextFrameStart, "We landed exactly in the refSlotBlock"); + expect(await vaultHub.badDebtToInternalizeForLastRefSlot()).to.be.equal(0, "Bad debt to internalize is still 0"); + expect(await vaultHub.badDebtToInternalize()).to.be.equal(badDebtShares, "Bad debt to internalize is the same"); + + // simulate the report at the refSlot (like the Oracle would do) + const { beaconValidators, beaconBalance } = await lido.getBeaconStat(); + const simulationAtRefSlot = await simulateReport(ctx, { + refSlot: nextRefSlot, + beaconValidators, + clBalance: beaconBalance, + withdrawalVaultBalance: 0n, + elRewardsVaultBalance: 0n, + }); + + await advanceChainTime(SECONDS_PER_SLOT); + expect(await vaultHub.badDebtToInternalize()).to.be.equal(badDebtShares); + + // simulate the report after refSlot (like it happens during the report processing) + expect( + await simulateReport(ctx, { + refSlot: (await hashConsensus.getCurrentFrame()).refSlot, + beaconValidators, + clBalance: beaconBalance, + withdrawalVaultBalance: 0n, + elRewardsVaultBalance: 0n, + }), + ).to.be.deep.equal(simulationAtRefSlot, "Simulation after refSlot should work the same"); + }); }); }); diff --git a/test/integration/vaults/disconnected.integration.ts b/test/integration/vaults/disconnected.integration.ts index 3d49e3ebef..57bdb2e6c3 100644 --- a/test/integration/vaults/disconnected.integration.ts +++ b/test/integration/vaults/disconnected.integration.ts @@ -181,7 +181,9 @@ describe("Integration: Actions with vault disconnected from hub", () => { }, ]); - await expect(operatorGrid.connect(owner).changeTier(stakingVault, 1n, 1000n)).to.be.revertedWithCustomError( + const tierId = (await operatorGrid.group(nodeOperator)).tierIds[0]; + + await expect(operatorGrid.connect(owner).changeTier(stakingVault, tierId, 1000n)).to.be.revertedWithCustomError( operatorGrid, "VaultNotConnected", ); @@ -189,13 +191,13 @@ describe("Integration: Actions with vault disconnected from hub", () => { const nodeOperatorRoleAsAddress = ethers.zeroPadValue(nodeOperator.address, 32); const msgData = operatorGrid.interface.encodeFunctionData("changeTier", [ await stakingVault.getAddress(), - 1n, + tierId, 1000n, ]); const confirmTimestamp = await getNextBlockTimestamp(); const expiryTimestamp = confirmTimestamp + (await operatorGrid.getConfirmExpiry()); - await expect(operatorGrid.connect(nodeOperator).changeTier(stakingVault, 1n, 1000n)) + await expect(operatorGrid.connect(nodeOperator).changeTier(stakingVault, tierId, 1000n)) .to.emit(operatorGrid, "RoleMemberConfirmed") .withArgs(nodeOperator, nodeOperatorRoleAsAddress, confirmTimestamp, expiryTimestamp, msgData); }); diff --git a/test/integration/vaults/obligations.integration.ts b/test/integration/vaults/obligations.integration.ts index 353f5ba822..6d851a4aad 100644 --- a/test/integration/vaults/obligations.integration.ts +++ b/test/integration/vaults/obligations.integration.ts @@ -2,17 +2,17 @@ import { expect } from "chai"; import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; import { Dashboard, LazyOracle, Lido, StakingVault, VaultHub } from "typechain-types"; -import { days, ether } from "lib"; +import { days, ether, updateBalance } from "lib"; import { createVaultWithDashboard, getProtocolContext, ProtocolContext, reportVaultDataWithProof, setupLidoForVaults, + upDefaultTierShareLimit, } from "lib/protocol"; import { Snapshot } from "test/suite"; @@ -28,7 +28,6 @@ describe("Integration: Vault redemptions and fees obligations", () => { let stakingVault: StakingVault; let dashboard: Dashboard; - let stakingVaultAddress: string; let treasuryAddress: string; let owner: HardhatEthersSigner; @@ -55,22 +54,22 @@ describe("Integration: Vault redemptions and fees obligations", () => { ctx.contracts.stakingVaultFactory, owner, nodeOperator, - nodeOperator, - [], )); - stakingVaultAddress = await stakingVault.getAddress(); - treasuryAddress = await ctx.contracts.locator.treasury(); - + // Register node operator group with sufficient share limit agentSigner = await ctx.getSigner("agent"); + treasuryAddress = await ctx.contracts.locator.treasury(); // set maximum fee rate per second to 1 ether to allow rapid fee increases + await lazyOracle.connect(agentSigner).grantRole(await lazyOracle.UPDATE_SANITY_PARAMS_ROLE(), agentSigner); await lazyOracle.connect(agentSigner).updateSanityParams(days(30n), 1000n, 1000000000000000000n); + await upDefaultTierShareLimit(ctx, ether("1")); await vaultHub.connect(agentSigner).grantRole(await vaultHub.REDEMPTION_MASTER_ROLE(), redemptionMaster); + await vaultHub.connect(agentSigner).grantRole(await vaultHub.REDEMPTION_MASTER_ROLE(), agentSigner); await vaultHub.connect(agentSigner).grantRole(await vaultHub.VALIDATOR_EXIT_ROLE(), validatorExit); - await reportVaultDataWithProof(ctx, stakingVault); + await reportVaultDataWithProof(ctx, stakingVault, { waitForNextRefSlot: true }); }); after(async () => await Snapshot.restore(originalSnapshot)); @@ -81,15 +80,15 @@ describe("Integration: Vault redemptions and fees obligations", () => { context("Redemptions", () => { it("Does not accrue when vault has no liabilities", async () => { - const recordBefore = await vaultHub.vaultRecord(stakingVaultAddress); + const recordBefore = await vaultHub.vaultRecord(stakingVault); expect(recordBefore.redemptionShares).to.equal(0n); expect(recordBefore.liabilityShares).to.equal(0n); - await expect(vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVaultAddress, 0n)) + await expect(vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVault, 0n)) .to.emit(vaultHub, "VaultRedemptionSharesUpdated") - .withArgs(stakingVaultAddress, 0); + .withArgs(stakingVault, 0); - const recordAfter = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfter = await vaultHub.vaultRecord(stakingVault); expect(recordAfter.redemptionShares).to.equal(0n); expect(recordAfter.liabilityShares).to.equal(0n); }); @@ -98,25 +97,25 @@ describe("Integration: Vault redemptions and fees obligations", () => { await dashboard.fund({ value: ether("1") }); await dashboard.mintShares(stranger, 2n); - const recordBefore = await vaultHub.vaultRecord(stakingVaultAddress); + const recordBefore = await vaultHub.vaultRecord(stakingVault); expect(recordBefore.redemptionShares).to.equal(0n); expect(recordBefore.liabilityShares).to.equal(2n); // Add redemption shares - await expect(vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVaultAddress, 1n)) + await expect(vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVault, 1n)) .to.emit(vaultHub, "VaultRedemptionSharesUpdated") - .withArgs(stakingVaultAddress, 1n); + .withArgs(stakingVault, 1n); - const recordAfterDecreased = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfterDecreased = await vaultHub.vaultRecord(stakingVault); expect(recordAfterDecreased.redemptionShares).to.equal(1n); expect(recordAfterDecreased.liabilityShares).to.equal(2n); // Remove the redemption shares - await expect(vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVaultAddress, 2n)) + await expect(vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVault, 2n)) .to.emit(vaultHub, "VaultRedemptionSharesUpdated") - .withArgs(stakingVaultAddress, 0n); + .withArgs(stakingVault, 0n); - const recordAfterRemoved = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfterRemoved = await vaultHub.vaultRecord(stakingVault); expect(recordAfterRemoved.redemptionShares).to.equal(0n); expect(recordAfterRemoved.liabilityShares).to.equal(2n); }); @@ -130,50 +129,56 @@ describe("Integration: Vault redemptions and fees obligations", () => { redemptionValue = await lido.getPooledEthBySharesRoundUp(redemptionShares); if (redemptionValue < ether("1")) redemptionShares += 1n; - await dashboard.fund({ value: redemptionValue }); + await dashboard.fund({ value: redemptionValue + ether("1") }); await dashboard.mintShares(stranger, redemptionShares); }); it("when vault has no balance (all on CL)", async () => { - const recordBefore = await vaultHub.vaultRecord(stakingVaultAddress); + const recordBefore = await vaultHub.vaultRecord(stakingVault); expect(recordBefore.redemptionShares).to.equal(0n); expect(recordBefore.liabilityShares).to.equal(redemptionShares); - await setBalance(await stakingVault.getAddress(), 0n); // simulate all balance on CL + await updateBalance(stakingVault, 0n); // simulate all balance on CL - await expect(vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVaultAddress, 0n)) + await expect(vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVault, 0n)) .to.emit(vaultHub, "VaultRedemptionSharesUpdated") - .withArgs(stakingVaultAddress, redemptionShares) + .withArgs(stakingVault, redemptionShares) .to.emit(stakingVault, "BeaconChainDepositsPaused"); - const recordAfter = await vaultHub.vaultRecord(stakingVaultAddress); + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + + const recordAfter = await vaultHub.vaultRecord(stakingVault); expect(recordAfter.redemptionShares).to.equal(redemptionShares); expect(recordAfter.liabilityShares).to.equal(redemptionShares); }); it("when vault can cover them with balance", async () => { - const recordBefore = await vaultHub.vaultRecord(stakingVaultAddress); + const recordBefore = await vaultHub.vaultRecord(stakingVault); expect(recordBefore.redemptionShares).to.equal(0n); expect(recordBefore.liabilityShares).to.equal(redemptionShares); - await expect(vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVaultAddress, 0n)) + await expect(vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVault, 0n)) .to.emit(vaultHub, "VaultRedemptionSharesUpdated") - .withArgs(stakingVaultAddress, redemptionShares) + .withArgs(stakingVault, redemptionShares) .to.emit(stakingVault, "BeaconChainDepositsPaused"); - const recordAfter = await vaultHub.vaultRecord(stakingVaultAddress); + expect(await stakingVault.beaconChainDepositsPaused()).to.be.true; + + const recordAfter = await vaultHub.vaultRecord(stakingVault); expect(recordAfter.redemptionShares).to.equal(redemptionShares); expect(recordAfter.liabilityShares).to.equal(redemptionShares); // cover the redemptions with balance - await expect(vaultHub.connect(agentSigner).forceRebalance(stakingVaultAddress)) + await expect(vaultHub.connect(agentSigner).forceRebalance(stakingVault)) .to.emit(vaultHub, "VaultRebalanced") - .withArgs(stakingVaultAddress, redemptionShares, await lido.getPooledEthBySharesRoundUp(redemptionShares)) + .withArgs(stakingVault, redemptionShares, await lido.getPooledEthBySharesRoundUp(redemptionShares)) .to.emit(vaultHub, "VaultRedemptionSharesUpdated") - .withArgs(stakingVaultAddress, 0n) + .withArgs(stakingVault, 0n) .to.emit(stakingVault, "BeaconChainDepositsResumed"); - const recordAfterForceRebalance = await vaultHub.vaultRecord(stakingVaultAddress); + expect(await stakingVault.beaconChainDepositsPaused()).to.be.false; + + const recordAfterForceRebalance = await vaultHub.vaultRecord(stakingVault); expect(recordAfterForceRebalance.redemptionShares).to.equal(0n); expect(recordAfterForceRebalance.liabilityShares).to.equal(0n); }); @@ -188,11 +193,11 @@ describe("Integration: Vault redemptions and fees obligations", () => { await dashboard.fund({ value: ether("2") }); await dashboard.mintShares(owner, redemptionShares); - await vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVaultAddress, 0n); + await vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVault, 0n); }); it("On shares burned", async () => { - const recordBefore = await vaultHub.vaultRecord(stakingVaultAddress); + const recordBefore = await vaultHub.vaultRecord(stakingVault); expect(recordBefore.redemptionShares).to.equal(redemptionShares); expect(await lido.sharesOf(owner)).to.equal(redemptionShares); @@ -204,46 +209,46 @@ describe("Integration: Vault redemptions and fees obligations", () => { await expect(dashboard.burnShares(sharesToBurn)) .to.emit(vaultHub, "VaultRedemptionSharesUpdated") - .withArgs(stakingVaultAddress, expectedRedemptions); + .withArgs(stakingVault, expectedRedemptions); - const recordAfter = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfter = await vaultHub.vaultRecord(stakingVault); expect(recordAfter.redemptionShares).to.equal(expectedRedemptions); }); it("On vault rebalanced", async () => { - const recordBefore = await vaultHub.vaultRecord(stakingVaultAddress); + const recordBefore = await vaultHub.vaultRecord(stakingVault); expect(recordBefore.redemptionShares).to.equal(redemptionShares); const rebalanceShares = redemptionShares / 2n; await expect(dashboard.rebalanceVaultWithShares(rebalanceShares)) .to.emit(vaultHub, "VaultRebalanced") - .withArgs(stakingVaultAddress, rebalanceShares, await lido.getPooledEthBySharesRoundUp(rebalanceShares)) + .withArgs(stakingVault, rebalanceShares, await lido.getPooledEthBySharesRoundUp(rebalanceShares)) .to.emit(vaultHub, "VaultRedemptionSharesUpdated") - .withArgs(stakingVaultAddress, rebalanceShares); + .withArgs(stakingVault, rebalanceShares); - const recordAfter = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfter = await vaultHub.vaultRecord(stakingVault); expect(recordAfter.redemptionShares).to.equal(rebalanceShares); }); it("On force rebalance", async () => { - const recordBefore = await vaultHub.vaultRecord(stakingVaultAddress); + const recordBefore = await vaultHub.vaultRecord(stakingVault); expect(recordBefore.redemptionShares).to.equal(redemptionShares); - await expect(vaultHub.forceRebalance(stakingVaultAddress)) + await expect(vaultHub.forceRebalance(stakingVault)) .to.emit(vaultHub, "VaultRebalanced") - .withArgs(stakingVaultAddress, redemptionShares, await lido.getPooledEthBySharesRoundUp(redemptionShares)) + .withArgs(stakingVault, redemptionShares, await lido.getPooledEthBySharesRoundUp(redemptionShares)) .to.emit(vaultHub, "VaultRedemptionSharesUpdated") - .withArgs(stakingVaultAddress, 0n); + .withArgs(stakingVault, 0n); - const recordAfter = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfter = await vaultHub.vaultRecord(stakingVault); expect(recordAfter.redemptionShares).to.equal(0n); }); it("Does not increase on new minting", async () => { await dashboard.fund({ value: ether("2") }); - await dashboard.mintShares(stranger, ether("1")); + await dashboard.mintShares(stranger, await dashboard.remainingMintingCapacityShares(0n)); - const recordAfter = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfter = await vaultHub.vaultRecord(stakingVault); expect(recordAfter.redemptionShares).to.equal(redemptionShares); }); }); @@ -256,43 +261,121 @@ describe("Integration: Vault redemptions and fees obligations", () => { await dashboard.fund({ value: ether("2") }); await dashboard.mintShares(stranger, redemptionShares); - await vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVaultAddress, 0n); + await vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVault, 0n); }); it("Allows to partially settle redemptions with force rebalance", async () => { const vaultBalance = ether("0.7"); - await setBalance(stakingVaultAddress, vaultBalance); + await updateBalance(stakingVault, vaultBalance); const sharesToRebalance = await lido.getSharesByPooledEth(vaultBalance); - const recordBefore = await vaultHub.vaultRecord(stakingVaultAddress); + const recordBefore = await vaultHub.vaultRecord(stakingVault); expect(recordBefore.redemptionShares).to.equal(redemptionShares); const expectedRedemptions = redemptionShares - sharesToRebalance; - await expect(vaultHub.forceRebalance(stakingVaultAddress)) + await expect(vaultHub.forceRebalance(stakingVault)) .to.emit(vaultHub, "VaultRebalanced") - .withArgs(stakingVaultAddress, sharesToRebalance, await lido.getPooledEthBySharesRoundUp(sharesToRebalance)) + .withArgs(stakingVault, sharesToRebalance, await lido.getPooledEthBySharesRoundUp(sharesToRebalance)) .to.emit(vaultHub, "VaultRedemptionSharesUpdated") - .withArgs(stakingVaultAddress, expectedRedemptions); + .withArgs(stakingVault, expectedRedemptions); - const recordAfter = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfter = await vaultHub.vaultRecord(stakingVault); expect(recordAfter.redemptionShares).to.equal(expectedRedemptions); }); it("Allows to fully settle redemptions with force rebalance", async () => { - const recordBefore = await vaultHub.vaultRecord(stakingVaultAddress); + const recordBefore = await vaultHub.vaultRecord(stakingVault); expect(recordBefore.redemptionShares).to.equal(redemptionShares); - await expect(vaultHub.forceRebalance(stakingVaultAddress)) + await expect(vaultHub.forceRebalance(stakingVault)) .to.emit(vaultHub, "VaultRebalanced") - .withArgs(stakingVaultAddress, redemptionShares, await lido.getPooledEthBySharesRoundUp(redemptionShares)) + .withArgs(stakingVault, redemptionShares, await lido.getPooledEthBySharesRoundUp(redemptionShares)) .to.emit(vaultHub, "VaultRedemptionSharesUpdated") - .withArgs(stakingVaultAddress, 0n); + .withArgs(stakingVault, 0n); + + const recordAfter = await vaultHub.vaultRecord(stakingVault); + expect(recordAfter.redemptionShares).to.equal(0n); + }); + }); + + context("Slashing scenarios", () => { + it("Handles slashing when redemptionShares > healthShortfallShares", async () => { + const initialTotalValue = ether("10"); + await dashboard.fund({ value: initialTotalValue }); + const redemptionShares = await dashboard.remainingMintingCapacityShares(0n); + await dashboard.mintShares(stranger, redemptionShares); - const recordAfter = await vaultHub.vaultRecord(stakingVaultAddress); + await vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVault, 0n); + + const recordBefore = await vaultHub.vaultRecord(stakingVault); + expect(recordBefore.redemptionShares).to.equal(redemptionShares); + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.true; + + // Simulate slashing + const targetTotalValue = (initialTotalValue * 90n) / 100n; + await updateBalance(stakingVault, targetTotalValue); + await reportVaultDataWithProof(ctx, stakingVault, { totalValue: targetTotalValue, waitForNextRefSlot: true }); + + // Vault should become unhealthy after slashing + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.false; + + // Check health shortfall is less than redemptions (scenario requirement) + const healthShortfallShares = await vaultHub.healthShortfallShares(stakingVault); + expect(healthShortfallShares).to.be.lessThan(redemptionShares); + + await expect(vaultHub.forceRebalance(stakingVault)) + .to.emit(vaultHub, "VaultRebalanced") + .to.emit(vaultHub, "VaultRedemptionSharesUpdated"); + + const recordAfter = await vaultHub.vaultRecord(stakingVault); + + // Check that rebalanced more than the health shortfall + expect(redemptionShares - recordAfter.liabilityShares).to.be.greaterThan(healthShortfallShares); + + // Redemptions should be fully covered expect(recordAfter.redemptionShares).to.equal(0n); }); + + it("Handles slashing when healthShortfallShares > redemptionShares", async () => { + const initialTotalValue = ether("10"); + await dashboard.fund({ value: initialTotalValue }); + const liabilityShares = ((await lido.getSharesByPooledEth(initialTotalValue)) * 50n) / 100n; + await dashboard.mintShares(stranger, liabilityShares); + + const redemptionShares = (initialTotalValue * 10n) / 100n; + await vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVault, liabilityShares - redemptionShares); + + const recordBefore = await vaultHub.vaultRecord(stakingVault); + expect(recordBefore.redemptionShares).to.equal(redemptionShares); + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.true; + + // Simulate slashing + const targetTotalValue = (initialTotalValue * 20n) / 100n; + await updateBalance(stakingVault, targetTotalValue); + await reportVaultDataWithProof(ctx, stakingVault, { totalValue: targetTotalValue, waitForNextRefSlot: true }); + + // Vault should become unhealthy after slashing + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.false; + + // Check health shortfall is greater than redemptions (scenario requirement) + const healthShortfallShares = await vaultHub.healthShortfallShares(stakingVault); + expect(healthShortfallShares).to.be.greaterThan(0n); + expect(healthShortfallShares).to.be.greaterThan(redemptionShares); + + await expect(vaultHub.forceRebalance(stakingVault)) + .to.emit(vaultHub, "VaultRebalanced") + .to.emit(vaultHub, "VaultRedemptionSharesUpdated"); + + const recordAfter = await vaultHub.vaultRecord(stakingVault); + + // Check that rebalanced more than redemption shares + expect(liabilityShares - recordAfter.liabilityShares).to.be.greaterThan(redemptionShares); + + // Redemptions are fully covered + expect(recordAfter.redemptionShares).to.be.equal(0n); + }); }); // https://github.com/lidofinance/core/issues/1219 @@ -302,14 +385,14 @@ describe("Integration: Vault redemptions and fees obligations", () => { const maxMintableShares = await dashboard.totalMintingCapacityShares(); await dashboard.mintShares(stranger, maxMintableShares); - await expect(vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVaultAddress, 0n)) + await expect(vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVault, 0n)) .to.emit(vaultHub, "VaultRedemptionSharesUpdated") - .withArgs(stakingVaultAddress, maxMintableShares) + .withArgs(stakingVault, maxMintableShares) .to.emit(stakingVault, "BeaconChainDepositsPaused"); - const totalValue = await vaultHub.totalValue(stakingVaultAddress); + const totalValue = await vaultHub.totalValue(stakingVault); expect(totalValue).to.equal(ether("11")); - expect(await vaultHub.locked(stakingVaultAddress)).to.be.closeTo(ether("11"), 2n); + expect(await vaultHub.locked(stakingVault)).to.be.closeTo(ether("11"), 2n); const slashingAmount = ether("5"); await reportVaultDataWithProof(ctx, stakingVault, { @@ -317,33 +400,33 @@ describe("Integration: Vault redemptions and fees obligations", () => { waitForNextRefSlot: false, }); - await setBalance(stakingVaultAddress, totalValue + ether("5")); // simulate the vault has more balance than the total value + await updateBalance(stakingVault, totalValue + ether("5")); // simulate the vault has more balance than the total value - const recordBefore = await vaultHub.vaultRecord(stakingVaultAddress); + const recordBefore = await vaultHub.vaultRecord(stakingVault); const redemptionShares = recordBefore.redemptionShares; const expectedRebalance = await lido.getPooledEthBySharesRoundUp(redemptionShares); - await expect(vaultHub.forceRebalance(stakingVaultAddress)) + await expect(vaultHub.forceRebalance(stakingVault)) .to.emit(vaultHub, "VaultRebalanced") - .withArgs(stakingVaultAddress, redemptionShares, expectedRebalance) + .withArgs(stakingVault, redemptionShares, expectedRebalance) .to.emit(vaultHub, "VaultRedemptionSharesUpdated") - .withArgs(stakingVaultAddress, 0n) + .withArgs(stakingVault, 0n) .to.emit(stakingVault, "BeaconChainDepositsResumed"); - const recordAfter = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfter = await vaultHub.vaultRecord(stakingVault); expect(recordAfter.redemptionShares).to.equal(0n); expect(recordAfter.liabilityShares).to.equal(0n); - expect(await vaultHub.locked(stakingVaultAddress)).to.be.closeTo(ether("11"), 2n); + expect(await vaultHub.locked(stakingVault)).to.be.closeTo(ether("11"), 2n); await reportVaultDataWithProof(ctx, stakingVault, { waitForNextRefSlot: true, totalValue: totalValue - expectedRebalance, }); - const recordAfterReport = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfterReport = await vaultHub.vaultRecord(stakingVault); expect(recordAfterReport.redemptionShares).to.equal(0n); expect(recordAfterReport.liabilityShares).to.equal(0n); - expect(await vaultHub.locked(stakingVaultAddress)).to.equal(ether("1")); // minimal reserve + expect(await vaultHub.locked(stakingVault)).to.equal(ether("1")); // minimal reserve }); }); @@ -351,14 +434,14 @@ describe("Integration: Vault redemptions and fees obligations", () => { it("Reverts if accrued fees are less than the cumulative fees", async () => { const cumulativeLidoFees = ether("1.1"); - const recordBefore = await vaultHub.vaultRecord(stakingVaultAddress); + const recordBefore = await vaultHub.vaultRecord(stakingVault); expect(recordBefore.cumulativeLidoFees).to.equal(0n); expect(recordBefore.settledLidoFees).to.equal(0n); // Report the vault data with accrued lido fees await reportVaultDataWithProof(ctx, stakingVault, { cumulativeLidoFees }); - const recordAfter = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfter = await vaultHub.vaultRecord(stakingVault); expect(recordAfter.cumulativeLidoFees).to.equal(cumulativeLidoFees); expect(recordAfter.settledLidoFees).to.equal(0n); @@ -371,9 +454,9 @@ describe("Integration: Vault redemptions and fees obligations", () => { it("Updates on the vault report for vault with no balance", async () => { const cumulativeLidoFees = ether("1"); - await setBalance(stakingVaultAddress, 0); // dirty hack to make the vault balance 0 + await updateBalance(stakingVault, 0n); // dirty hack to make the vault balance 0 - const recordBefore = await vaultHub.vaultRecord(stakingVaultAddress); + const recordBefore = await vaultHub.vaultRecord(stakingVault); expect(recordBefore.cumulativeLidoFees).to.equal(0n); expect(recordBefore.settledLidoFees).to.equal(0n); @@ -383,7 +466,7 @@ describe("Integration: Vault redemptions and fees obligations", () => { "VaultReportApplied", ); - const recordAfter = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfter = await vaultHub.vaultRecord(stakingVault); expect(recordAfter.cumulativeLidoFees).to.equal(cumulativeLidoFees); expect(recordAfter.settledLidoFees).to.equal(0n); }); @@ -393,7 +476,7 @@ describe("Integration: Vault redemptions and fees obligations", () => { await dashboard.fund({ value: ether("2") }); - const recordBefore = await vaultHub.vaultRecord(stakingVaultAddress); + const recordBefore = await vaultHub.vaultRecord(stakingVault); expect(recordBefore.cumulativeLidoFees).to.equal(0n); expect(recordBefore.settledLidoFees).to.equal(0n); @@ -403,14 +486,14 @@ describe("Integration: Vault redemptions and fees obligations", () => { .to.emit(stakingVault, "BeaconChainDepositsPaused"); // Pay the fees to the treasury - await expect(vaultHub.settleLidoFees(stakingVaultAddress)) + await expect(vaultHub.settleLidoFees(stakingVault)) .to.emit(vaultHub, "LidoFeesSettled") - .withArgs(stakingVaultAddress, cumulativeLidoFees, cumulativeLidoFees, cumulativeLidoFees) + .withArgs(stakingVault, cumulativeLidoFees, cumulativeLidoFees, cumulativeLidoFees) .to.emit(stakingVault, "EtherWithdrawn") .withArgs(treasuryAddress, cumulativeLidoFees) .to.emit(stakingVault, "BeaconChainDepositsResumed"); - const recordAfter = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfter = await vaultHub.vaultRecord(stakingVault); expect(recordAfter.cumulativeLidoFees).to.equal(cumulativeLidoFees); expect(recordAfter.settledLidoFees).to.equal(cumulativeLidoFees); }); @@ -422,7 +505,7 @@ describe("Integration: Vault redemptions and fees obligations", () => { await dashboard.fund({ value: funding }); - const recordBefore = await vaultHub.vaultRecord(stakingVaultAddress); + const recordBefore = await vaultHub.vaultRecord(stakingVault); expect(recordBefore.cumulativeLidoFees).to.equal(0n); expect(recordBefore.settledLidoFees).to.equal(0n); @@ -431,19 +514,19 @@ describe("Integration: Vault redemptions and fees obligations", () => { .to.emit(vaultHub, "VaultReportApplied") .to.emit(stakingVault, "BeaconChainDepositsPaused"); - const recordAfterReport = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfterReport = await vaultHub.vaultRecord(stakingVault); expect(recordAfterReport.cumulativeLidoFees).to.equal(cumulativeLidoFees); expect(recordAfterReport.settledLidoFees).to.equal(0n); // Pay the fees to the treasury - await expect(vaultHub.settleLidoFees(stakingVaultAddress)) + await expect(vaultHub.settleLidoFees(stakingVault)) .to.emit(vaultHub, "LidoFeesSettled") - .withArgs(stakingVaultAddress, funding, cumulativeLidoFees, funding) + .withArgs(stakingVault, funding, cumulativeLidoFees, funding) .to.emit(stakingVault, "EtherWithdrawn") .withArgs(treasuryAddress, funding) .to.emit(stakingVault, "BeaconChainDepositsResumed"); - const recordAfterSettlement = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfterSettlement = await vaultHub.vaultRecord(stakingVault); expect(recordAfterSettlement.cumulativeLidoFees).to.equal(cumulativeLidoFees); expect(recordAfterSettlement.settledLidoFees).to.equal(funding); }); @@ -459,14 +542,14 @@ describe("Integration: Vault redemptions and fees obligations", () => { .to.emit(vaultHub, "VaultReportApplied") .to.emit(stakingVault, "BeaconChainDepositsPaused"); - const recordAfterFirstReport = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfterFirstReport = await vaultHub.vaultRecord(stakingVault); expect(recordAfterFirstReport.cumulativeLidoFees).to.equal(cumulativeLidoFees); expect(recordAfterFirstReport.settledLidoFees).to.equal(0n); // Pay the fees to the treasury partially - await expect(vaultHub.settleLidoFees(stakingVaultAddress)) + await expect(vaultHub.settleLidoFees(stakingVault)) .to.emit(vaultHub, "LidoFeesSettled") - .withArgs(stakingVaultAddress, initialFunding, cumulativeLidoFees, initialFunding) + .withArgs(stakingVault, initialFunding, cumulativeLidoFees, initialFunding) .to.emit(stakingVault, "EtherWithdrawn") .withArgs(treasuryAddress, initialFunding) .to.emit(stakingVault, "BeaconChainDepositsResumed"); @@ -481,7 +564,7 @@ describe("Integration: Vault redemptions and fees obligations", () => { "VaultReportApplied", ); - const recordAfterSecondReport = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfterSecondReport = await vaultHub.vaultRecord(stakingVault); expect(recordAfterSecondReport.cumulativeLidoFees).to.equal(cumulativeLidoFees); expect(recordAfterSecondReport.settledLidoFees).to.equal(initialFunding); @@ -489,13 +572,13 @@ describe("Integration: Vault redemptions and fees obligations", () => { await dashboard.fund({ value: fundingToSettle }); // Pay the fees to the treasury - await expect(vaultHub.settleLidoFees(stakingVaultAddress)) + await expect(vaultHub.settleLidoFees(stakingVault)) .to.emit(vaultHub, "LidoFeesSettled") - .withArgs(stakingVaultAddress, fundingToSettle, cumulativeLidoFees, cumulativeLidoFees) + .withArgs(stakingVault, fundingToSettle, cumulativeLidoFees, cumulativeLidoFees) .to.emit(stakingVault, "EtherWithdrawn") .withArgs(treasuryAddress, fundingToSettle); - const recordAfter = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfter = await vaultHub.vaultRecord(stakingVault); expect(recordAfter.cumulativeLidoFees).to.equal(cumulativeLidoFees); expect(recordAfter.settledLidoFees).to.equal(cumulativeLidoFees); }); @@ -503,17 +586,17 @@ describe("Integration: Vault redemptions and fees obligations", () => { it("Withdraws some fees to the treasury when the vault is forced disconnecting", async () => { await reportVaultDataWithProof(ctx, stakingVault, { cumulativeLidoFees: ether("0.1") }); - await expect(vaultHub.connect(agentSigner).disconnect(stakingVaultAddress)) + await expect(vaultHub.connect(agentSigner).disconnect(stakingVault)) .to.emit(vaultHub, "LidoFeesSettled") - .withArgs(stakingVaultAddress, ether("0.1"), ether("0.1"), ether("0.1")); + .withArgs(stakingVault, ether("0.1"), ether("0.1"), ether("0.1")); }); it("Withdraws some fees to the treasury when the vault is forced disconnecting capped by balance", async () => { await reportVaultDataWithProof(ctx, stakingVault, { cumulativeLidoFees: ether("1.1") }); - await expect(vaultHub.connect(agentSigner).disconnect(stakingVaultAddress)) + await expect(vaultHub.connect(agentSigner).disconnect(stakingVault)) .to.emit(vaultHub, "LidoFeesSettled") - .withArgs(stakingVaultAddress, ether("1"), ether("1.1"), ether("1")); + .withArgs(stakingVault, ether("1"), ether("1.1"), ether("1")); }); context("Settlement", () => { @@ -528,41 +611,41 @@ describe("Integration: Vault redemptions and fees obligations", () => { await dashboard.mintShares(stranger, redemptionShares); await reportVaultDataWithProof(ctx, stakingVault, { cumulativeLidoFees }); - ({ cumulativeLidoFees } = await vaultHub.vaultRecord(stakingVaultAddress)); + ({ cumulativeLidoFees } = await vaultHub.vaultRecord(stakingVault)); }); it("Reduces the unsettled fees when redemptions are set", async () => { - await vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVaultAddress, 0n); + await vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVault, 0n); const redemptionValue = await lido.getPooledEthBySharesRoundUp(redemptionShares); - await setBalance(stakingVaultAddress, redemptionValue + 1n); + await updateBalance(stakingVault, redemptionValue + 1n); - await expect(vaultHub.settleLidoFees(stakingVaultAddress)) + await expect(vaultHub.settleLidoFees(stakingVault)) .to.emit(vaultHub, "LidoFeesSettled") - .withArgs(stakingVaultAddress, 1n, cumulativeLidoFees, 1n); + .withArgs(stakingVault, 1n, cumulativeLidoFees, 1n); - const recordAfter = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfter = await vaultHub.vaultRecord(stakingVault); expect(recordAfter.redemptionShares).to.equal(redemptionShares); expect(recordAfter.cumulativeLidoFees).to.equal(cumulativeLidoFees); expect(recordAfter.settledLidoFees).to.equal(1n); }); it("Does not make the vault unhealthy", async () => { - const feesToSettle = await vaultHub.settleableLidoFeesValue(stakingVaultAddress); + const feesToSettle = await vaultHub.settleableLidoFeesValue(stakingVault); // make sure the vault has enough balance to pay all the fees - const vaultBalance = await ethers.provider.getBalance(stakingVaultAddress); + const vaultBalance = await ethers.provider.getBalance(stakingVault); expect(vaultBalance).to.be.greaterThan(cumulativeLidoFees); - expect(await vaultHub.isVaultHealthy(stakingVaultAddress)).to.be.true; + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.true; - await expect(vaultHub.settleLidoFees(stakingVaultAddress)) + await expect(vaultHub.settleLidoFees(stakingVault)) .to.emit(vaultHub, "LidoFeesSettled") - .withArgs(stakingVaultAddress, feesToSettle, cumulativeLidoFees, feesToSettle); + .withArgs(stakingVault, feesToSettle, cumulativeLidoFees, feesToSettle); - expect(await vaultHub.isVaultHealthy(stakingVaultAddress)).to.be.true; + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.true; - const recordAfter = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfter = await vaultHub.vaultRecord(stakingVault); expect(recordAfter.cumulativeLidoFees).to.equal(cumulativeLidoFees); expect(recordAfter.settledLidoFees).to.equal(feesToSettle); }); @@ -574,14 +657,14 @@ describe("Integration: Vault redemptions and fees obligations", () => { await reportVaultDataWithProof(ctx, stakingVault, { totalValue: ether("10"), cumulativeLidoFees }); - const totalValue = await vaultHub.totalValue(stakingVaultAddress); - await setBalance(stakingVaultAddress, totalValue + ether("5")); // simulate the vault has more balance than the total value + const totalValue = await vaultHub.totalValue(stakingVault); + await updateBalance(stakingVault, totalValue + ether("5")); // simulate the vault has more balance than the total value - await expect(vaultHub.settleLidoFees(stakingVaultAddress)) + await expect(vaultHub.settleLidoFees(stakingVault)) .to.emit(vaultHub, "LidoFeesSettled") - .withArgs(stakingVaultAddress, ether("1"), ether("2"), ether("1")); + .withArgs(stakingVault, ether("1"), ether("2"), ether("1")); - const recordAfter = await vaultHub.vaultRecord(stakingVaultAddress); + const recordAfter = await vaultHub.vaultRecord(stakingVault); expect(recordAfter.cumulativeLidoFees).to.equal(cumulativeLidoFees); expect(recordAfter.settledLidoFees).to.equal(ether("1")); }); @@ -593,10 +676,10 @@ describe("Integration: Vault redemptions and fees obligations", () => { beforeEach(async () => { await dashboard.fund({ value: ether("1") }); - const balanceBefore = await ethers.provider.getBalance(stakingVaultAddress); - await setBalance(stakingVaultAddress, 0); + const balanceBefore = await ethers.provider.getBalance(stakingVault); + await updateBalance(stakingVault, 0n); await reportVaultDataWithProof(ctx, stakingVault, { cumulativeLidoFees }); - await setBalance(stakingVaultAddress, balanceBefore); + await updateBalance(stakingVault, balanceBefore); }); it("Reverts when trying to mint more than total value minus unsettled Lido fees", async () => { @@ -609,7 +692,7 @@ describe("Integration: Vault redemptions and fees obligations", () => { await expect(dashboard.mintShares(stranger, mintableShares)).to.emit(vaultHub, "MintedSharesOnVault"); - expect(await vaultHub.liabilityShares(stakingVaultAddress)).to.equal(mintableShares); + expect(await vaultHub.liabilityShares(stakingVault)).to.equal(mintableShares); }); it("Does not take redemptions obligation into account", async () => { @@ -619,7 +702,7 @@ describe("Integration: Vault redemptions and fees obligations", () => { // Add 1/2 of the mintable ether to the vault as withdrawals obligation, so if withdrawals obligation is taken // into account, the user will not be able to mint anything from this moment await dashboard.mintShares(stranger, sharesToMint); - await vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVaultAddress, 0n); + await vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVault, 0n); await expect(dashboard.mintShares(stranger, mintableShares - sharesToMint)).to.emit( vaultHub, @@ -632,57 +715,56 @@ describe("Integration: Vault redemptions and fees obligations", () => { let redemptionShares: bigint; beforeEach(async () => { - redemptionShares = ether("1"); - const value = await lido.getPooledEthBySharesRoundUp(redemptionShares); + const redemptionValue = ether("1"); + redemptionShares = await lido.getSharesByPooledEth(redemptionValue); - await dashboard.fund({ value }); + await dashboard.fund({ value: redemptionValue }); await dashboard.mintShares(stranger, redemptionShares); - await vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVaultAddress, 0n); + await vaultHub.connect(agentSigner).setLiabilitySharesTarget(stakingVault, 0n); }); - it("Reverts when trying to withdraw more than available balance", async () => { - // simulate deposit to Beacon chain -1 ether - const withdrawableValue = await vaultHub.withdrawableValue(stakingVaultAddress); + it("Reverts when trying to withdraw redemption shares", async () => { + const withdrawableValue = await vaultHub.withdrawableValue(stakingVault); expect(withdrawableValue).to.equal(0n); - await expect(dashboard.withdraw(stranger, withdrawableValue + 1n)) + await expect(dashboard.withdraw(stranger, 1n)) .to.be.revertedWithCustomError(dashboard, "ExceedsWithdrawable") - .withArgs(withdrawableValue + 1n, withdrawableValue); + .withArgs(1n, 0n); }); it("Works when trying to withdraw all the withdrawable balance", async () => { - const totalValue = await vaultHub.totalValue(stakingVaultAddress); - const locked = await vaultHub.locked(stakingVaultAddress); + const totalValue = await vaultHub.totalValue(stakingVault); + const locked = await vaultHub.locked(stakingVault); expect(totalValue).to.equal(locked); - let withdrawableValue = await vaultHub.withdrawableValue(stakingVaultAddress); + let withdrawableValue = await vaultHub.withdrawableValue(stakingVault); expect(withdrawableValue).to.equal(0n); const overfunding = ether("0.1"); await dashboard.fund({ value: overfunding }); - expect(await vaultHub.withdrawableValue(stakingVaultAddress)).to.equal(overfunding); + expect(await vaultHub.withdrawableValue(stakingVault)).to.equal(overfunding); await expect(dashboard.withdraw(stranger, overfunding)) .to.emit(stakingVault, "EtherWithdrawn") .withArgs(stranger, overfunding); - withdrawableValue = await vaultHub.withdrawableValue(stakingVaultAddress); + withdrawableValue = await vaultHub.withdrawableValue(stakingVault); expect(withdrawableValue).to.equal(0n); await expect(dashboard.rebalanceVaultWithShares(redemptionShares)) .to.emit(vaultHub, "VaultRebalanced") - .withArgs(stakingVaultAddress, redemptionShares, await lido.getPooledEthBySharesRoundUp(redemptionShares)) + .withArgs(stakingVault, redemptionShares, await lido.getPooledEthBySharesRoundUp(redemptionShares)) .to.emit(vaultHub, "VaultRedemptionSharesUpdated") - .withArgs(stakingVaultAddress, 0n); + .withArgs(stakingVault, 0n); - expect(await vaultHub.liabilityShares(stakingVaultAddress)).to.equal(0n); + expect(await vaultHub.liabilityShares(stakingVault)).to.equal(0n); // report the vault data to unlock the locked value await reportVaultDataWithProof(ctx, stakingVault); - expect(await vaultHub.locked(stakingVaultAddress)).to.equal(ether("1")); // connection deposit - expect(await vaultHub.totalValue(stakingVaultAddress)).to.equal(ether("1")); + expect(await vaultHub.locked(stakingVault)).to.equal(ether("1")); // connection deposit + expect(await vaultHub.totalValue(stakingVault)).to.equal(ether("1")); }); }); @@ -690,19 +772,19 @@ describe("Integration: Vault redemptions and fees obligations", () => { it("Reverts when trying to disconnect with unsettled obligations", async () => { await reportVaultDataWithProof(ctx, stakingVault, { cumulativeLidoFees: ether("1.1") }); - const obligations = await vaultHub.vaultRecord(stakingVaultAddress); + const obligations = await vaultHub.vaultRecord(stakingVault); // 1 ether of the connection deposit will be settled to the treasury expect(obligations.cumulativeLidoFees).to.equal(ether("1.1")); - expect(await ethers.provider.getBalance(stakingVaultAddress)).to.equal(ether("1")); + expect(await ethers.provider.getBalance(stakingVault)).to.equal(ether("1")); // will revert because of the unsettled obligations event trying to settle using the connection deposit await expect(dashboard.voluntaryDisconnect()) - .to.be.revertedWithCustomError(vaultHub, "NoUnsettledLidoFeesShouldBeLeft") - .withArgs(stakingVaultAddress, ether("1.1")); + .to.be.revertedWithCustomError(vaultHub, "UnsettledObligationsExceedsAllowance") + .withArgs(stakingVault, ether("1"), 0); expect(obligations.cumulativeLidoFees).to.equal(ether("1.1")); - expect(await ethers.provider.getBalance(stakingVaultAddress)).to.equal(ether("1")); + expect(await ethers.provider.getBalance(stakingVault)).to.equal(ether("1")); }); it("Allows to disconnect when all obligations are settled", async () => { @@ -710,10 +792,10 @@ describe("Integration: Vault redemptions and fees obligations", () => { await dashboard.fund({ value: ether("0.1") }); await expect(dashboard.voluntaryDisconnect()) - .to.emit(vaultHub, "LidoFeesSettled") - .withArgs(stakingVaultAddress, ether("1.1"), ether("1.1"), ether("1.1")) + .to.emit(vaultHub, "VaultObligationsSettled") + .withArgs(stakingVault, 0n, ether("1.1"), 0n, 0n, ether("1.1")) .to.emit(vaultHub, "VaultDisconnectInitiated") - .withArgs(stakingVaultAddress); + .withArgs(stakingVault); }); it("Allows to fund after disconnect initiated", async () => { @@ -722,15 +804,84 @@ describe("Integration: Vault redemptions and fees obligations", () => { await expect(dashboard.voluntaryDisconnect()) .to.emit(vaultHub, "VaultDisconnectInitiated") - .withArgs(stakingVaultAddress); + .withArgs(stakingVault); - expect(await ethers.provider.getBalance(stakingVaultAddress)).to.equal(0n); - expect(await vaultHub.totalValue(stakingVaultAddress)).to.equal(0n); + expect(await ethers.provider.getBalance(stakingVault)).to.equal(0n); + expect(await vaultHub.totalValue(stakingVault)).to.equal(0n); await dashboard.fund({ value: ether("0.1") }); - expect(await ethers.provider.getBalance(stakingVaultAddress)).to.equal(ether("0.1")); - expect(await vaultHub.totalValue(stakingVaultAddress)).to.equal(ether("0.1")); + expect(await ethers.provider.getBalance(stakingVault)).to.equal(ether("0.1")); + expect(await vaultHub.totalValue(stakingVault)).to.equal(ether("0.1")); + }); + + it("Reverts disconnect process when balance is not enough to cover the exit fees", async () => { + expect(await vaultHub.totalValue(stakingVault)).to.equal(ether("1")); + await reportVaultDataWithProof(ctx, stakingVault, { cumulativeLidoFees: ether("1") }); + + 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); + + // 0.9 ether should be left in the vault + expect(await ethers.provider.getBalance(stakingVault)).to.equal(ether("0.9")); }); }); }); diff --git a/test/integration/vaults/operator.grid.integration.ts b/test/integration/vaults/operator.grid.integration.ts index 5ceb06b32c..fe214ed198 100644 --- a/test/integration/vaults/operator.grid.integration.ts +++ b/test/integration/vaults/operator.grid.integration.ts @@ -97,7 +97,7 @@ describe("Integration: OperatorGrid", () => { const beforeInfo = await operatorGrid.vaultTierInfo(stakingVault); expect(beforeInfo.tierId).to.equal(0n); - const requestedTierId = 1n; + const requestedTierId = (await operatorGrid.group(nodeOperator)).tierIds[0]; const requestedShareLimit = ether("1000"); // First confirmation from vault owner via Dashboard → returns false (not yet confirmed) @@ -132,7 +132,7 @@ describe("Integration: OperatorGrid", () => { }, ]); - const tierId = 1n; + const tierId = (await operatorGrid.group(nodeOperator)).tierIds[0]; const initialLimit = ether("1200"); // Confirm change tier into tier 1 @@ -197,8 +197,9 @@ describe("Integration: OperatorGrid", () => { ]); // Move to tier 1 first - await dashboard.changeTier(1n, ether("1000")); - await operatorGrid.connect(nodeOperator).changeTier(stakingVault, 1n, ether("1000")); + const tierId = (await operatorGrid.group(nodeOperator)).tierIds[0]; + await dashboard.changeTier(tierId, ether("1000")); + await operatorGrid.connect(nodeOperator).changeTier(stakingVault, tierId, ether("1000")); // Try to change to default tier (0) → should revert await expect( @@ -229,8 +230,9 @@ describe("Integration: OperatorGrid", () => { ]); // Change tier to 1 with initial limit 1000 - await dashboard.changeTier(1n, ether("1000")); - await operatorGrid.connect(nodeOperator).changeTier(stakingVault, 1n, ether("1000")); + const tierId = (await operatorGrid.group(nodeOperator)).tierIds[0]; + await dashboard.changeTier(tierId, ether("1000")); + await operatorGrid.connect(nodeOperator).changeTier(stakingVault, tierId, ether("1000")); // Try to increase to 1200 → first confirmation by owner via Dashboard returns false const increaseTo = ether("1200"); @@ -299,7 +301,26 @@ describe("Integration: OperatorGrid", () => { agentSigner = await ctx.getSigner("agent"); }); - it("changing tier doesn't affect jail status", async () => { + it("Vault in jail can't mint stETH", async () => { + // Put vault in jail before disconnecting + await operatorGrid.connect(agentSigner).setVaultJailStatus(stakingVault, true); + expect(await operatorGrid.isVaultInJail(stakingVault)).to.be.true; + + // Verify vault is jailed and can't mint normally + await expect(dashboard.mintShares(owner, 100n)).to.be.revertedWithCustomError(operatorGrid, "VaultInJail"); + }); + + it("Vault can mint again after being removed from jail", async () => { + await operatorGrid.connect(agentSigner).setVaultJailStatus(stakingVault, true); + expect(await operatorGrid.isVaultInJail(stakingVault)).to.be.true; + await expect(dashboard.mintShares(owner, 100n)).to.be.revertedWithCustomError(operatorGrid, "VaultInJail"); + + await operatorGrid.connect(agentSigner).setVaultJailStatus(stakingVault, false); + expect(await operatorGrid.isVaultInJail(stakingVault)).to.be.false; + await expect(dashboard.mintShares(owner, 100n)).to.not.be.reverted; + }); + + it("Changing tier doesn't affect jail status", async () => { // Register a group and tiers for tier changing await operatorGrid.connect(agentSigner).registerGroup(nodeOperator, ether("5000")); await operatorGrid.connect(agentSigner).registerTiers(nodeOperator, [ @@ -333,12 +354,13 @@ describe("Integration: OperatorGrid", () => { expect(initialVaultInfo.tierId).to.equal(0); // Should be default tier // Change tier from default (0) to tier 1 - await operatorGrid.connect(nodeOperator).changeTier(stakingVault, 1, ether("1000")); - await dashboard.connect(owner).changeTier(1, ether("1000")); + const tierId = (await operatorGrid.group(nodeOperator)).tierIds[0]; + await operatorGrid.connect(nodeOperator).changeTier(stakingVault, tierId, ether("1000")); + await dashboard.connect(owner).changeTier(tierId, ether("1000")); // Verify tier changed const updatedVaultInfo = await operatorGrid.vaultTierInfo(stakingVault); - expect(updatedVaultInfo.tierId).to.equal(1); + expect(updatedVaultInfo.tierId).to.equal(tierId); // Verify jail status is preserved after tier change expect(await operatorGrid.isVaultInJail(stakingVault)).to.be.true; @@ -347,7 +369,7 @@ describe("Integration: OperatorGrid", () => { await expect(dashboard.mintShares(owner, 100n)).to.be.revertedWithCustomError(operatorGrid, "VaultInJail"); }); - it("disconnect and connect back preserves jail status", async () => { + it("Disconnect and connect back preserves jail status", async () => { // Put vault in jail before disconnecting await operatorGrid.connect(agentSigner).setVaultJailStatus(stakingVault, true); expect(await operatorGrid.isVaultInJail(stakingVault)).to.be.true; @@ -398,5 +420,56 @@ describe("Integration: OperatorGrid", () => { await dashboard.connect(owner).fund({ value: ether("2") }); await expect(dashboard.mintShares(owner, 100n)).to.be.revertedWithCustomError(operatorGrid, "VaultInJail"); }); + + it("Jail blocks only minting and allows other operations", async () => { + const { lido } = ctx.contracts; + + // Setup: Mint some shares first to prepare for testing burns and rebalances + await dashboard.mintShares(owner, ether("1")); + const initialLiabilityShares = await vaultHub.vaultRecord(stakingVault).then((r) => r.liabilityShares); + expect(initialLiabilityShares).to.be.gt(0n); + + // Put vault in jail + await operatorGrid.connect(agentSigner).setVaultJailStatus(stakingVault, true); + expect(await operatorGrid.isVaultInJail(stakingVault)).to.be.true; + + // 1. Verify minting is blocked + await expect(dashboard.mintShares(owner, 100n)).to.be.revertedWithCustomError(operatorGrid, "VaultInJail"); + + // 2. Verify burning is NOT blocked + const sharesToBurn = ether("0.1"); + await lido.connect(owner).approve(dashboard, 10n * sharesToBurn); + await expect(dashboard.connect(owner).burnShares(sharesToBurn)).to.not.be.reverted; + + // 3. Verify withdrawals are NOT blocked + const withdrawAmount = ether("0.1"); + await expect(dashboard.withdraw(owner, withdrawAmount)).to.not.be.reverted; + + // 4. Verify rebalancing is NOT blocked + // Add more funds to enable rebalancing + await dashboard.fund({ value: ether("2") }); + const sharesToRebalance = await vaultHub.vaultRecord(stakingVault).then((r) => r.liabilityShares); + await expect(dashboard.rebalanceVaultWithShares(sharesToRebalance)).to.not.be.reverted; + + // 5. Verify lazy reports are NOT blocked + await expect(reportVaultDataWithProof(ctx, stakingVault, { totalValue: await dashboard.totalValue() })).to.not.be + .reverted; + + // 6. Verify disconnect is NOT blocked by jail status + // Ensure fresh report first + await reportVaultDataWithProof(ctx, stakingVault, { + waitForNextRefSlot: true, + totalValue: await dashboard.totalValue(), + }); + await expect(dashboard.connect(owner).voluntaryDisconnect()).to.not.be.reverted; + + // Verify disconnect was initiated successfully + expect(await vaultHub.isPendingDisconnect(stakingVault)).to.be.true; + + // Verify disconnect is completed + await expect(reportVaultDataWithProof(ctx, stakingVault)) + .to.emit(vaultHub, "VaultDisconnectCompleted") + .withArgs(stakingVault); + }); }); }); diff --git a/test/integration/vaults/pausable-beacon-deposits.integration.ts b/test/integration/vaults/pausable-beacon-deposits.integration.ts index 1a42702595..cfecff7402 100644 --- a/test/integration/vaults/pausable-beacon-deposits.integration.ts +++ b/test/integration/vaults/pausable-beacon-deposits.integration.ts @@ -61,6 +61,7 @@ describe("Integration: Vault hub beacon deposits pause flows", () => { agentSigner = await ctx.getSigner("agent"); // set maximum fee rate per second to 1 ether to allow rapid fee increases + await lazyOracle.connect(agentSigner).grantRole(await lazyOracle.UPDATE_SANITY_PARAMS_ROLE(), agentSigner); await lazyOracle.connect(agentSigner).updateSanityParams(days(30n), 1000n, 1000000000000000000n); await vaultHub.connect(agentSigner).grantRole(await vaultHub.REDEMPTION_MASTER_ROLE(), redemptionMaster); diff --git a/test/integration/vaults/roles.integration.ts b/test/integration/vaults/roles.integration.ts index afc3449072..06965263b8 100644 --- a/test/integration/vaults/roles.integration.ts +++ b/test/integration/vaults/roles.integration.ts @@ -18,6 +18,7 @@ import { VaultRoles, } from "lib/protocol"; import { vaultRoleKeys } from "lib/protocol/helpers/vaults"; +import { getChainIdFromState } from "lib/protocol/networks"; import { Snapshot } from "test/suite"; @@ -388,6 +389,22 @@ describe("Integration: Staking Vaults Dashboard Roles Initial Setup", () => { await dashboard.VAULT_CONFIGURATION_ROLE(), ); }); + + describe("renounceRole()", () => { + for (const role of vaultRoleKeys) { + it(`reverts if called for role ${role}`, async function () { + if (getChainIdFromState() === 560048) this.skip(); + + const roleMethods = getRoleMethods(dashboard); + const roleId = await roleMethods[role]; + const caller = roles[role]; + await expect(dashboard.connect(caller).renounceRole(roleId, caller)).to.be.revertedWithCustomError( + dashboard, + "RoleRenouncementDisabled", + ); + }); + } + }); }); }); diff --git a/test/integration/vaults/scenario/happy-path.integration.ts b/test/integration/vaults/scenario/happy-path.integration.ts index c72aa78b3b..322c15cf2f 100644 --- a/test/integration/vaults/scenario/happy-path.integration.ts +++ b/test/integration/vaults/scenario/happy-path.integration.ts @@ -181,7 +181,6 @@ describe("Scenario: Staking Vaults Happy Path", () => { expect(await ethers.provider.getBalance(stakingVaultAddress)).to.equal(ether("1")); // has locked value cause of connection deposit - expect(await vaultHub.vaultsCount()).to.equal(1n); expect(await vaultHub.locked(stakingVaultAddress)).to.equal(VAULT_CONNECTION_DEPOSIT); }); diff --git a/test/integration/vaults/scenario/lazyOracle.bootstrap.integration.ts b/test/integration/vaults/scenario/lazyOracle.bootstrap.integration.ts index 96d8721b12..882aaf23b5 100644 --- a/test/integration/vaults/scenario/lazyOracle.bootstrap.integration.ts +++ b/test/integration/vaults/scenario/lazyOracle.bootstrap.integration.ts @@ -29,15 +29,15 @@ describe("Scenario: Lazy Oracle after mainnet upgrade before the first report", after(async () => await Snapshot.restore(snapshot)); - it("Vault report is not fresh on upgrade (skipped on scratch)", async function () { + // TODO: move to acceptance? + it("Vault report is not fresh on upgrade", async function () { const { stakingVaultFactory, vaultHub, lazyOracle } = ctx.contracts; - if (ctx.isScratch) { + + if ((await lazyOracle.latestReportData()).timestamp != 0n) { + console.log("LazyOracle report is not fresh on upgrade"); this.skip(); } - // if fails here then snapshot restoring is broken somewhere - expect(await lazyOracle.latestReportData()).to.be.deep.equal([0n, 0n, "", ""], "LazyOracle should have no report"); - const { stakingVault } = await createVaultWithDashboard( ctx, stakingVaultFactory, diff --git a/test/integration/vaults/unhealthy.vault.integration.ts b/test/integration/vaults/unhealthy.vault.integration.ts new file mode 100644 index 0000000000..158cbaee99 --- /dev/null +++ b/test/integration/vaults/unhealthy.vault.integration.ts @@ -0,0 +1,184 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; +import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; + +import { Dashboard, StakingVault } from "typechain-types"; + +import { + createVaultWithDashboard, + getProtocolContext, + ProtocolContext, + reportVaultDataWithProof, + setupLidoForVaults, +} from "lib/protocol"; +import { ether } from "lib/units"; + +import { Snapshot } from "test/suite"; + +describe("Integration: Unhealthy vault", () => { + let ctx: ProtocolContext; + let snapshot: string; + let originalSnapshot: string; + + let owner: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + let nodeOperator: HardhatEthersSigner; + let stakingVault: StakingVault; + let dashboard: Dashboard; + + before(async () => { + ctx = await getProtocolContext(); + const { stakingVaultFactory, vaultHub } = ctx.contracts; + originalSnapshot = await Snapshot.take(); + + [, owner, nodeOperator, stranger] = await ethers.getSigners(); + await setupLidoForVaults(ctx); + + ({ stakingVault, dashboard } = await createVaultWithDashboard( + ctx, + stakingVaultFactory, + owner, + nodeOperator, + nodeOperator, + )); + + dashboard = dashboard.connect(owner); + + // Going to unhealthy state + await dashboard.fund({ value: ether("9") }); // TV = 10 ETH + await dashboard.mintShares(owner, await dashboard.remainingMintingCapacityShares(0n)); + + // Slash 1 ETH + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: ether("9"), + slashingReserve: ether("1"), + waitForNextRefSlot: true, + }); + + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.false; + }); + + beforeEach(async () => (snapshot = await Snapshot.take())); + afterEach(async () => await Snapshot.restore(snapshot)); + after(async () => await Snapshot.restore(originalSnapshot)); + + describe("Force rebalance", () => { + it("Anyone can force rebalance unhealthy vault", async () => { + const { vaultHub, lido } = ctx.contracts; + + const recordBefore = await vaultHub.vaultRecord(stakingVault); + const obligationsBefore = await vaultHub.obligations(stakingVault); + + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.false; + expect(obligationsBefore.sharesToBurn).to.be.gt(0n); + + // Shares to rebalance should be equal to obligationsBefore.sharesToBurn because it is full rebalance scenario + const expectedShares = obligationsBefore.sharesToBurn; + + await expect(vaultHub.connect(stranger).forceRebalance(stakingVault)) + .to.emit(vaultHub, "VaultRebalanced") + .withArgs(stakingVault, expectedShares, await lido.getPooledEthBySharesRoundUp(expectedShares)); + + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.true; + + const recordAfter = await vaultHub.vaultRecord(stakingVault); + expect(recordAfter.liabilityShares).to.be.equal(recordBefore.liabilityShares - expectedShares); + }); + + it("Force rebalance with insufficient balance", async () => { + const { vaultHub, lido } = ctx.contracts; + + // Set vault balance to 0.1 ETH + const availableBalance = ether("0.1"); + await setBalance(await stakingVault.getAddress(), availableBalance); + + const recordBefore = await vaultHub.vaultRecord(stakingVault); + const obligationsBefore = await vaultHub.obligations(stakingVault); + + // Verify vault is unhealthy + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.false; + expect(obligationsBefore.sharesToBurn).to.be.gt(0n); + + // Shares to rebalance should be equal to available balance because it is insufficient balance scenario + const expectedShares = await lido.getSharesByPooledEth(availableBalance); + + // Force rebalance with partial balance + await expect(vaultHub.connect(stranger).forceRebalance(stakingVault)) + .to.emit(vaultHub, "VaultRebalanced") + .withArgs(stakingVault, expectedShares, await lido.getPooledEthBySharesRoundUp(expectedShares)); + + // Vault should still be unhealthy because we could only partially rebalance + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.false; + + const recordAfter = await vaultHub.vaultRecord(stakingVault); + expect(recordAfter.liabilityShares).to.be.equal(recordBefore.liabilityShares - expectedShares); + + // Verify available balance is decreased by the amount of shares burned + const availableBalanceAfter = await ethers.provider.getBalance(await stakingVault.getAddress()); + expect(availableBalanceAfter).to.be.equal( + availableBalance - (await lido.getPooledEthBySharesRoundUp(expectedShares)), + ); + }); + + it("Force rebalance reverts when no funds", async () => { + const { vaultHub } = ctx.contracts; + + // Set vault balance to 0 + await setBalance(await stakingVault.getAddress(), 0n); + + await expect(vaultHub.connect(stranger).forceRebalance(stakingVault)) + .to.be.revertedWithCustomError(vaultHub, "NoFundsForForceRebalance") + .withArgs(stakingVault); + }); + + it("Force rebalance reverts when no reason (Healthy vault)", async () => { + const { vaultHub, stakingVaultFactory } = ctx.contracts; + + // Create a healthy vault + const { stakingVault: healthyVault, dashboard: healthyDashboard } = await createVaultWithDashboard( + ctx, + stakingVaultFactory, + owner, + nodeOperator, + nodeOperator, + ); + + await healthyDashboard.connect(owner).fund({ value: ether("10") }); + await healthyDashboard.connect(owner).mintShares(owner, ether("1")); + + expect(await vaultHub.isVaultHealthy(healthyVault)).to.be.true; + + await expect(vaultHub.connect(stranger).forceRebalance(healthyVault)) + .to.be.revertedWithCustomError(vaultHub, "NoReasonForForceRebalance") + .withArgs(healthyVault); + }); + + it("Force rebalance does not settle Lido fees", async () => { + const { vaultHub } = ctx.contracts; + + // Report with 1 ETH of Lido fees + await reportVaultDataWithProof(ctx, stakingVault, { + slashingReserve: ether("1"), + cumulativeLidoFees: ether("1"), + waitForNextRefSlot: true, + }); + + // Get initial state before force rebalance + const recordBefore = await vaultHub.vaultRecord(stakingVault); + expect(recordBefore.settledLidoFees).to.equal(0n); + expect(recordBefore.cumulativeLidoFees).to.equal(ether("1")); + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.false; + + // Force rebalance + await vaultHub.connect(stranger).forceRebalance(stakingVault); + + // Check that fees were NOT settled - settledLidoFees should remain unchanged + const recordAfter = await vaultHub.vaultRecord(stakingVault); + expect(recordAfter.settledLidoFees).to.equal(0n); + expect(recordAfter.cumulativeLidoFees).to.equal(ether("1")); + expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.true; + }); + }); +}); diff --git a/test/integration/vaults/vaulthub.minting.integration.ts b/test/integration/vaults/vaulthub.minting.integration.ts index 405d3c20c7..213333daa1 100644 --- a/test/integration/vaults/vaulthub.minting.integration.ts +++ b/test/integration/vaults/vaulthub.minting.integration.ts @@ -140,7 +140,7 @@ describe("Integration: VaultHub ", () => { const shares = ether("1"); const stakingLimitBeforeAll = await lido.getCurrentStakeLimit(); - for (let i = 0n; i < 500n; i++) { + for (let i = 0n; i < 10n; i++) { const stakingLimitBefore = await lido.getCurrentStakeLimit(); await vaultHub.mintShares(stakingVault, vaultHub, shares + i); diff --git a/test/integration/vaults/vaulthub.shortfall.integration.ts b/test/integration/vaults/vaulthub.shortfall.integration.ts index 3223b7a7d3..60e4e47ca3 100644 --- a/test/integration/vaults/vaulthub.shortfall.integration.ts +++ b/test/integration/vaults/vaulthub.shortfall.integration.ts @@ -3,8 +3,6 @@ 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 { ensureExactShareRate, reportVaultDataWithProof } from "lib/protocol/helpers"; @@ -20,10 +18,6 @@ describe("Integration: VaultHub Shortfall", () => { let owner: HardhatEthersSigner; let nodeOperator: HardhatEthersSigner; let agentSigner: HardhatEthersSigner; - let stakingVault: StakingVault; - - let vaultHub: VaultHub; - let dashboard: Dashboard; before(async () => { ctx = await getProtocolContext(); @@ -32,22 +26,21 @@ describe("Integration: VaultHub Shortfall", () => { [, owner, nodeOperator] = await ethers.getSigners(); agentSigner = await ctx.getSigner("agent"); await setupLidoForVaults(ctx); + + await ensureExactShareRate(ctx, (12737625930792815n * SHARE_RATE_PRECISION) / 10000000000000000n); }); async function setup({ rr, frt }: { rr: bigint; frt: bigint }) { - ({ stakingVault, dashboard } = await createVaultWithDashboard( + const { stakingVaultFactory, operatorGrid, vaultHub } = ctx.contracts; + const { stakingVault, dashboard } = await createVaultWithDashboard( ctx, - ctx.contracts.stakingVaultFactory, + 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")); + await operatorGrid.connect(agentSigner).registerGroup(nodeOperator, ether("5000")); const tier = { shareLimit: ether("1000"), reserveRatioBP: rr, @@ -57,35 +50,33 @@ describe("Integration: VaultHub Shortfall", () => { reservationFeeBP: 0, }; - await ctx.contracts.operatorGrid.connect(agentSigner).registerTiers(nodeOperator, [tier]); - const beforeInfo = await ctx.contracts.operatorGrid.vaultTierInfo(stakingVault); + await operatorGrid.connect(agentSigner).registerTiers(nodeOperator, [tier]); + const beforeInfo = await operatorGrid.vaultTierInfo(stakingVault); expect(beforeInfo.tierId).to.equal(0n); - const requestedTierId = 1n; + const requestedTierId = (await operatorGrid.group(nodeOperator)).tierIds[0]; 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); + await operatorGrid.connect(nodeOperator).changeTier(stakingVault, requestedTierId, requestedShareLimit); - const afterInfo = await ctx.contracts.operatorGrid.vaultTierInfo(stakingVault); + const afterInfo = await 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); + const dashboardSigner = await impersonate(dashboard, ether("10000")); + return { stakingVault, - dashboard, - vaultHub, + dashboard: dashboard.connect(owner), + vaultHub: vaultHub.connect(dashboardSigner), }; } @@ -95,7 +86,7 @@ describe("Integration: VaultHub Shortfall", () => { describe("Shortfall", () => { it("Works on larger numbers", async () => { - ({ stakingVault, dashboard, vaultHub } = await setup({ rr: 2000n, frt: 1999n })); + const { stakingVault, dashboard, vaultHub } = await setup({ rr: 2000n, frt: 1989n }); await vaultHub.fund(stakingVault, { value: ether("1") }); expect(await vaultHub.totalValue(stakingVault)).to.equal(ether("2")); @@ -116,7 +107,7 @@ describe("Integration: VaultHub Shortfall", () => { }); it("Works on max capacity", async () => { - ({ stakingVault, dashboard, vaultHub } = await setup({ rr: 1000n, frt: 800n })); + const { 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")); @@ -139,13 +130,36 @@ describe("Integration: VaultHub Shortfall", () => { expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.true; }); - it("Works on small numbers", async () => { - ({ stakingVault, dashboard, vaultHub } = await setup({ rr: 2000n, frt: 1999n })); + it("Works on (TV=1000, LS=689, rr=2000 frt=1999) and shareRate 1.162518454795922", async () => { + await ensureExactShareRate(ctx, (1162518454795922n * SHARE_RATE_PRECISION) / 1000000000000000n); + const { stakingVault, dashboard, vaultHub } = await setup({ rr: 2000n, frt: 1989n }); await vaultHub.fund(stakingVault, { value: ether("1") }); expect(await vaultHub.totalValue(stakingVault)).to.equal(ether("2")); - await dashboard.mintShares(owner, 689n); + await dashboard.mintShares(owner, 699n); + + 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 (TV=1000, LS=235, rr=2000 frt=1989) and shareRate 1.162518454795922", async () => { + await ensureExactShareRate(ctx, (1162518454795922n * SHARE_RATE_PRECISION) / 1000000000000000n); + const { stakingVault, dashboard, vaultHub } = await setup({ rr: 2000n, frt: 1989n }); + + await vaultHub.fund(stakingVault, { value: ether("1") }); + expect(await vaultHub.totalValue(stakingVault)).to.equal(ether("2")); + + await dashboard.mintShares(owner, 699n); await reportVaultDataWithProof(ctx, stakingVault, { totalValue: 1000n, @@ -161,7 +175,7 @@ describe("Integration: VaultHub Shortfall", () => { }); it("Works on really small numbers", async () => { - ({ stakingVault, dashboard, vaultHub } = await setup({ rr: 2000n, frt: 1999n })); + const { stakingVault, dashboard, vaultHub } = await setup({ rr: 2000n, frt: 1989n }); await vaultHub.fund(stakingVault, { value: ether("1") }); expect(await vaultHub.totalValue(stakingVault)).to.equal(ether("2")); @@ -183,7 +197,7 @@ describe("Integration: VaultHub Shortfall", () => { }); it("Works on numbers less than 10", async () => { - ({ stakingVault, dashboard, vaultHub } = await setup({ rr: 2000n, frt: 1999n })); + const { stakingVault, dashboard, vaultHub } = await setup({ rr: 2000n, frt: 1989n }); await vaultHub.fund(stakingVault, { value: ether("1") }); expect(await vaultHub.totalValue(stakingVault)).to.equal(ether("2")); @@ -204,7 +218,7 @@ describe("Integration: VaultHub Shortfall", () => { }); it("Works on hundreds", async () => { - ({ stakingVault, dashboard, vaultHub } = await setup({ rr: 2000n, frt: 1999n })); + const { stakingVault, dashboard, vaultHub } = await setup({ rr: 2000n, frt: 1989n }); await vaultHub.fund(stakingVault, { value: ether("1") }); expect(await vaultHub.totalValue(stakingVault)).to.equal(ether("2")); @@ -226,7 +240,8 @@ describe("Integration: VaultHub Shortfall", () => { it("Works on (TV=22, LS=11, rr=frt=499) and shareRate 1.90909", async () => { await ensureExactShareRate(ctx, (190909n * SHARE_RATE_PRECISION) / 100000n); - ({ stakingVault, dashboard, vaultHub } = await setup({ rr: 500n, frt: 499n })); + + const { stakingVault, dashboard, vaultHub } = await setup({ rr: 500n, frt: 489n }); await vaultHub.fund(stakingVault, { value: ether("1") }); expect(await vaultHub.totalValue(stakingVault)).to.equal(ether("2")); @@ -245,5 +260,27 @@ describe("Integration: VaultHub Shortfall", () => { expect(await vaultHub.isVaultHealthy(stakingVault)).to.be.true; expect(shortfall2).to.equal(0n); }); + + it("Works on (TV=15, LS=12, rr=772 frt=769) and shareRate 1.125", async () => { + await ensureExactShareRate(ctx, (112500n * SHARE_RATE_PRECISION) / 100000n); + const { stakingVault, dashboard, vaultHub } = await setup({ rr: 772n, frt: 761n }); + + await vaultHub.fund(stakingVault, { value: ether("1") }); + expect(await vaultHub.totalValue(stakingVault)).to.equal(ether("2")); + + await dashboard.mintShares(owner, 12n); + + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: 15n, + 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(await vaultHub.isVaultHealthy(stakingVault)).to.be.true; + expect(shortfall2).to.equal(0n); + }); }); }); diff --git a/test/integration/vaults/vaulthub.slashing-reserve.integration.ts b/test/integration/vaults/vaulthub.slashing-reserve.integration.ts index 23ca147a6f..e615832d33 100644 --- a/test/integration/vaults/vaulthub.slashing-reserve.integration.ts +++ b/test/integration/vaults/vaulthub.slashing-reserve.integration.ts @@ -55,6 +55,54 @@ describe("Scenario: Vault Report Slashing Reserve", () => { afterEach(async () => await Snapshot.restore(snapshot)); + it("Report with non-zero slashing reserve updates the minimal reserve", async () => { + const { vaultHub } = ctx.contracts; + + await reportVaultDataWithProof(ctx, stakingVault, { slashingReserve: ether("2") }); + + // check minimal reserve in the record + const record = await vaultHub.vaultRecord(stakingVault); + expect(record.minimalReserve).to.be.equal(ether("2")); + + // check locked amount + expect(await vaultHub.locked(stakingVault)).to.be.equal(ether("2")); + }); + + it("Report with slashing reserve no more than CONNECT_DEPOSIT resets the minimal reserve to CONNECT_DEPOSIT", async () => { + const { vaultHub } = ctx.contracts; + const CONNECT_DEPOSIT = ether("1"); + + const largeSlashingReserve = ether("2"); + await reportVaultDataWithProof(ctx, stakingVault, { slashingReserve: largeSlashingReserve }); + + let record = await vaultHub.vaultRecord(stakingVault); + expect(record.minimalReserve).to.be.equal(largeSlashingReserve); + expect(await vaultHub.locked(stakingVault)).to.be.equal(largeSlashingReserve); + + // 1. report with zero slashing reserve + await reportVaultDataWithProof(ctx, stakingVault, { slashingReserve: ether("0") }); + + record = await vaultHub.vaultRecord(stakingVault); + expect(record.minimalReserve).to.be.equal(CONNECT_DEPOSIT); + expect(await vaultHub.locked(stakingVault)).to.be.equal(CONNECT_DEPOSIT); + + // 2. report with slashing reserve less than CONNECT_DEPOSIT + await reportVaultDataWithProof(ctx, stakingVault, { slashingReserve: largeSlashingReserve }); + await reportVaultDataWithProof(ctx, stakingVault, { slashingReserve: CONNECT_DEPOSIT / 2n }); + + record = await vaultHub.vaultRecord(stakingVault); + expect(record.minimalReserve).to.be.equal(CONNECT_DEPOSIT); + expect(await vaultHub.locked(stakingVault)).to.be.equal(CONNECT_DEPOSIT); + + // 3. report with slashing reserve equal to CONNECT_DEPOSIT + await reportVaultDataWithProof(ctx, stakingVault, { slashingReserve: largeSlashingReserve }); + await reportVaultDataWithProof(ctx, stakingVault, { slashingReserve: CONNECT_DEPOSIT }); + + record = await vaultHub.vaultRecord(stakingVault); + expect(record.minimalReserve).to.be.equal(CONNECT_DEPOSIT); + expect(await vaultHub.locked(stakingVault)).to.be.equal(CONNECT_DEPOSIT); + }); + it("You cannot withdraw reported slashing reserve", async () => { const { vaultHub } = ctx.contracts; @@ -73,8 +121,4 @@ describe("Scenario: Vault Report Slashing Reserve", () => { "ExceedsMintingCapacity", ); }); - - it("You cannot disconnect if slashing reserve is not zero", async () => {}); - - it("Pending disconnect is aborted if slashing reserve is not zero", async () => {}); }); diff --git a/test/suite/constants.ts b/test/suite/constants.ts index 51bca83798..8d621fd694 100644 --- a/test/suite/constants.ts +++ b/test/suite/constants.ts @@ -1,3 +1,4 @@ +export const ONE_HOUR = 60n * 60n; export const ONE_DAY = 24n * 60n * 60n; export const MAX_BASIS_POINTS = 100_00n; From 1ea3fcb60d61a18e92385edff73e2e445367e00a Mon Sep 17 00:00:00 2001 From: failingtwice Date: Mon, 1 Dec 2025 17:08:20 +0500 Subject: [PATCH 11/12] feat: equal steth approx assertion --- test/hooks/assertion/equalStETH.ts | 58 ++++++++++++++++++++++++ test/hooks/index.ts | 1 + test/integration/vaults/vaulthub.fees.ts | 10 ++-- 3 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 test/hooks/assertion/equalStETH.ts 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/vaulthub.fees.ts b/test/integration/vaults/vaulthub.fees.ts index 06de059149..0206d00066 100644 --- a/test/integration/vaults/vaulthub.fees.ts +++ b/test/integration/vaults/vaulthub.fees.ts @@ -18,8 +18,6 @@ import { ether } from "lib/units"; import { Snapshot } from "test/suite"; -const STETH_ROUNDING_MARGIN = 10n; - describe("Integration: VaultHub:fees", () => { let ctx: ProtocolContext; let snapshot: string; @@ -600,7 +598,7 @@ describe("Integration: VaultHub: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.be.closeTo(ether("40.8"), STETH_ROUNDING_MARGIN); + expect(mintingCapacityBefore).to.equalStETH(ether("40.8")); // Report unsettled fees = 10 ETH await reportVaultDataWithProof(ctx, stakingVault, { @@ -615,7 +613,7 @@ describe("Integration: VaultHub:fees", () => { // Expected: minting capacity reduced by unsettled fees // (51 - 10) * 0.8 = 41 * 0.8 = 32.8 ETH - expect(mintingCapacityAfter).to.be.closeTo(ether("32.8"), STETH_ROUNDING_MARGIN); + expect(mintingCapacityAfter).to.equalStETH(ether("32.8")); // Verify obligations are tracked const obligations = await vaultHub.obligations(stakingVault); @@ -650,7 +648,7 @@ describe("Integration: VaultHub:fees", () => { 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.be.closeTo(ether("14.4"), STETH_ROUNDING_MARGIN); + expect(mintingCapacityAfter).to.equalStETH(ether("14.4")); // Verify all fees have been settled expect(obligationsAfter.feesToSettle).to.equal(0n); @@ -747,7 +745,7 @@ describe("Integration: VaultHub:fees", () => { 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.be.closeTo(ether("12.8"), STETH_ROUNDING_MARGIN); + expect(mintingCapacityAfter).to.equalStETH(ether("12.8")); }); }); From b0a7bec4411cccea3f6239f75d7075b6bb13f522 Mon Sep 17 00:00:00 2001 From: Aleksei Potapkin Date: Tue, 2 Dec 2025 19:27:03 +0200 Subject: [PATCH 12/12] test: fix disconnect flow in obligation tests --- .../vaults/obligations.integration.ts | 75 +++---------------- 1 file changed, 11 insertions(+), 64 deletions(-) diff --git a/test/integration/vaults/obligations.integration.ts b/test/integration/vaults/obligations.integration.ts index a13d52a01f..e44cf42686 100644 --- a/test/integration/vaults/obligations.integration.ts +++ b/test/integration/vaults/obligations.integration.ts @@ -783,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")); @@ -792,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); }); @@ -825,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; }); }); });