From df9ca81b38125581d13a9c57a2b552a2d8ee96e5 Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Fri, 12 Dec 2025 12:49:52 +0300 Subject: [PATCH 1/4] test: add integration tests for sanity checker with bad debt internalization --- .../sanity-checker-bad-debt.integration.ts | 466 ++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 test/integration/vaults/sanity-checker-bad-debt.integration.ts diff --git a/test/integration/vaults/sanity-checker-bad-debt.integration.ts b/test/integration/vaults/sanity-checker-bad-debt.integration.ts new file mode 100644 index 000000000..69e7af139 --- /dev/null +++ b/test/integration/vaults/sanity-checker-bad-debt.integration.ts @@ -0,0 +1,466 @@ +import { expect } from "chai"; +import { ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; +import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; + +import { StakingVault } from "typechain-types"; + +import { advanceChainTime, ether, impersonate, LIMITER_PRECISION_BASE } from "lib"; +import { + createVaultWithDashboard, + getProtocolContext, + ProtocolContext, + removeStakingLimit, + report, + reportVaultDataWithProof, + setupLidoForVaults, + upDefaultTierShareLimit, + waitNextAvailableReportTime, +} from "lib/protocol"; + +import { Snapshot } from "test/suite"; +import { SHARE_RATE_PRECISION } from "test/suite/constants"; + +describe("Integration: Sanity checker with bad debt internalization", () => { + let ctx: ProtocolContext; + let snapshot: string; + let originalSnapshot: string; + + let owner: HardhatEthersSigner; + let nodeOperator: HardhatEthersSigner; + let daoAgent: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + + let stakingVault: StakingVault; + let badDebtShares: bigint; + + // Get shares burn limit from sanity checker when NO changes in pooled Ether are expected + const sharesToBurnToReachRebaseLimit = async () => { + const { lido, oracleReportSanityChecker } = ctx.contracts; + + const rebaseLimit = await oracleReportSanityChecker.getMaxPositiveTokenRebase(); + const rebaseLimitPlus1 = rebaseLimit + LIMITER_PRECISION_BASE; + + const internalShares = (await lido.getTotalShares()) - (await lido.getExternalShares()); + + // Derived from: rebaseLimit = (postShareRate - preShareRate) / preShareRate + return (internalShares * rebaseLimit) / rebaseLimitPlus1; + }; + + // Helper to capture protocol state + const captureState = async () => { + const { lido, vaultHub, burner, elRewardsVault, withdrawalVault } = ctx.contracts; + + const totalPooledEther = await lido.getTotalPooledEther(); + const totalShares = await lido.getTotalShares(); + const externalShares = await lido.getExternalShares(); + const externalEther = await lido.getExternalEther(); + const badDebtToInternalize = await vaultHub.badDebtToInternalize(); + const [coverShares, nonCoverShares] = await burner.getSharesRequestedToBurn(); + const elRewardsVaultBalance = await ethers.provider.getBalance(elRewardsVault); + const withdrawalVaultBalance = await ethers.provider.getBalance(withdrawalVault); + + return { + totalPooledEther, + totalShares, + externalShares, + externalEther, + badDebtToInternalize, + burnerShares: coverShares + nonCoverShares, + elRewardsVaultBalance, + withdrawalVaultBalance, + shareRate: totalShares > 0n ? (totalPooledEther * SHARE_RATE_PRECISION) / totalShares : 0n, + }; + }; + + // Helper to create vault with bad debt + const setupVaultWithBadDebt = async ( + vaultOwner: HardhatEthersSigner, + fundAmount: bigint = ether("10"), + slashTo: bigint = ether("1"), + ) => { + const { stakingVaultFactory, lido } = ctx.contracts; + const { stakingVault: vault, dashboard } = await createVaultWithDashboard( + ctx, + stakingVaultFactory, + vaultOwner, + nodeOperator, + nodeOperator, + ); + + const connectedDashboard = dashboard.connect(vaultOwner); + + // Fund and mint max shares + await connectedDashboard.fund({ value: fundAmount }); + await connectedDashboard.mintShares(vaultOwner, await connectedDashboard.remainingMintingCapacityShares(0n)); + + // Slash to create bad debt + await reportVaultDataWithProof(ctx, vault, { + totalValue: slashTo, + slashingReserve: slashTo, + waitForNextRefSlot: true, + }); + + // Verify bad debt exists + const totalValue = await connectedDashboard.totalValue(); + const liabilityShares = await connectedDashboard.liabilityShares(); + const liabilityValue = await lido.getPooledEthBySharesRoundUp(liabilityShares); + expect(totalValue).to.be.lessThan(liabilityValue, "Vault should have bad debt"); + + // Calculate bad debt amount + const badDebt = liabilityShares - (await lido.getSharesByPooledEth(totalValue)); + + return { vault, badDebtShares: badDebt }; + }; + + // Helper to setup and queue bad debt internalization + const internalizeBadDebt = async (fundAmount: bigint = ether("10"), slashTo: bigint = ether("1")) => { + // Setup vault with bad debt + const setup = await setupVaultWithBadDebt(owner, fundAmount, slashTo); + stakingVault = setup.vault; + badDebtShares = setup.badDebtShares; + + // Grant BAD_DEBT_MASTER_ROLE to daoAgent + const { vaultHub } = ctx.contracts; + await vaultHub.connect(await ctx.getSigner("agent")).grantRole(await vaultHub.BAD_DEBT_MASTER_ROLE(), daoAgent); + + // Queue bad debt for internalization (will be available after next report) + await vaultHub.connect(daoAgent).internalizeBadDebt(stakingVault, badDebtShares); + }; + + before(async () => { + ctx = await getProtocolContext(); + originalSnapshot = await Snapshot.take(); + + [, owner, nodeOperator, , daoAgent, stranger] = await ethers.getSigners(); + + await setupLidoForVaults(ctx); + await upDefaultTierShareLimit(ctx, ether("1000")); + }); + + beforeEach(async () => (snapshot = await Snapshot.take())); + afterEach(async () => await Snapshot.restore(snapshot)); + after(async () => await Snapshot.restore(originalSnapshot)); + + describe("Smoothing rebase with bad debt internalization", () => { + it("No smoothing", async () => { + const { lido, burner, elRewardsVault, withdrawalVault } = ctx.contracts; + + await internalizeBadDebt(); + + const stateBefore = await captureState(); + expect(stateBefore.badDebtToInternalize).to.equal(badDebtShares, "Bad debt should be queued"); + + // Ensure no EL rewards and no withdrawal vault balance + await setBalance(await elRewardsVault.getAddress(), 0n); + await setBalance(await withdrawalVault.getAddress(), 0n); + + // Report with zero CL diff, skip withdrawals, don't report burner + const { reportTx } = await report(ctx, { + clDiff: 0n, + excludeVaultsBalances: true, + skipWithdrawals: true, + reportBurner: false, + waitNextReportTime: true, + }); + + const receipt = await reportTx!.wait(); + + // Verify nothing was burned on burner contract (check SharesBurnt events) + const sharesBurntEvents = ctx.getEvents(receipt!, "SharesBurnt"); + const burnerAddress = await burner.getAddress(); + const burnerSharesBurnt = sharesBurntEvents.filter((e) => e.args.account === burnerAddress); + expect(burnerSharesBurnt.length).to.equal(0, "No shares should be burnt from burner"); + + // Verify bad debt was applied + await expect(reportTx).to.emit(lido, "ExternalBadDebtInternalized").withArgs(badDebtShares); + await expect(reportTx).to.emit(lido, "ExternalSharesBurnt").withArgs(badDebtShares); + + const stateAfter = await captureState(); + + // External shares decreased by bad debt + expect(stateAfter.externalShares).to.equal( + stateBefore.externalShares - badDebtShares, + "External shares should decrease by bad debt amount", + ); + + // Bad debt queue cleared + expect(stateAfter.badDebtToInternalize).to.equal(0n, "Bad debt should be cleared"); + }); + + it("Smoothing due to large rewards", async () => { + const reportWithLargeElRewardsEnsureSmoothing = async () => { + const { lido, elRewardsVault, withdrawalVault } = ctx.contracts; + + const stateBefore = await captureState(); + + // Add large EL rewards (will be limited by smoothing) + const largeRewards = ether("10000"); + await setBalance(await elRewardsVault.getAddress(), largeRewards); + await setBalance(await withdrawalVault.getAddress(), 0n); + + const { reportTx } = await report(ctx, { + clDiff: 0n, + excludeVaultsBalances: false, // Include vault balances to collect rewards + skipWithdrawals: true, + waitNextReportTime: true, + }); + + // Verify bad debt was fully applied + await expect(reportTx).to.emit(lido, "ExternalBadDebtInternalized").withArgs(badDebtShares); + + const stateAfter = await captureState(); + + // Bad debt fully cleared + expect(stateAfter.badDebtToInternalize).to.equal(0n, "Bad debt should be fully cleared"); + expect(stateAfter.externalShares).to.equal( + stateBefore.externalShares - badDebtShares, + "External shares should decrease by full bad debt amount", + ); + + // Smoothing applied: not all rewards collected (some left on vault) + expect(stateAfter.elRewardsVaultBalance).to.be.lt(largeRewards, "Some EL rewards should be collected"); + expect(stateAfter.elRewardsVaultBalance).to.be.gt(0n, "Some EL rewards should remain due to smoothing"); + + return stateAfter; + }; + + const beforeReportSnapshot = await Snapshot.take(); + + // Report with smoothen token rebase with small bad debt + await internalizeBadDebt(ether("10"), ether("1")); // Smaller bad debt + const stateAfter1 = await reportWithLargeElRewardsEnsureSmoothing(); + + await Snapshot.restore(beforeReportSnapshot); + + // Report with smoothen token rebase with larger bad debt + await internalizeBadDebt(ether("20"), ether("1")); // Larger bad debt + const stateAfter2 = await reportWithLargeElRewardsEnsureSmoothing(); + + expect(stateAfter1.shareRate).to.be.gt( + stateAfter2.shareRate, + "Share rate should be higher after less bad debt internalized", + ); + + expect(stateAfter1.elRewardsVaultBalance).to.be.eq( + stateAfter2.elRewardsVaultBalance, + "Smoothing should not be affected by bad debt amount", + ); + }); + + it("Smoothing due to large shares to burn", async () => { + const reportWithLargeSharesToBurnEnsureSmoothing = async () => { + const { lido, burner, accounting } = ctx.contracts; + + // Calculate shares limit and add excess to ensure smoothing kicks in + const sharesLimit = await sharesToBurnToReachRebaseLimit(); + const excess = ether("100"); // Large excess to ensure smoothing + const sharesToRequest = sharesLimit + excess; + + // Ensure whale has enough stETH + const whaleBalance = (await lido.getPooledEthByShares(sharesToRequest)) + ether("100"); + await removeStakingLimit(ctx); + await setBalance(stranger.address, whaleBalance + ether("1")); + await lido.connect(stranger).submit(ZeroAddress, { value: whaleBalance }); + + // Request burn of large amount of shares + await lido.connect(stranger).approve(burner, await lido.getPooledEthByShares(sharesToRequest)); + + const accountingSigner = await impersonate(accounting.address, ether("1")); + await burner.connect(accountingSigner).requestBurnShares(stranger, sharesToRequest); + + const stateBefore = await captureState(); + + // Verify burner has shares to burn + expect(stateBefore.burnerShares).to.be.gte(sharesToRequest, "Burner should have shares to burn"); + + const { reportTx } = await report(ctx, { + clDiff: 0n, + excludeVaultsBalances: true, + skipWithdrawals: true, + waitNextReportTime: true, + }); + + // Verify bad debt was fully applied regardless of burner shares + await expect(reportTx).to.emit(lido, "ExternalBadDebtInternalized").withArgs(badDebtShares); + + const stateAfter = await captureState(); + + // Bad debt fully cleared + expect(stateAfter.badDebtToInternalize).to.equal(0n, "Bad debt should be fully cleared"); + expect(stateAfter.externalShares).to.equal( + stateBefore.externalShares - badDebtShares, + "External shares should decrease by full bad debt amount", + ); + + // Verify smoothing was applied: not all shares were burned (some remain on burner) + expect(stateAfter.burnerShares).to.be.lt(stateBefore.burnerShares, "Some shares should be burned from burner"); + expect(stateAfter.burnerShares).to.be.gt(0n, "Some shares should remain on burner due to smoothing"); + + return stateAfter; + }; + + const beforeReportSnapshot = await Snapshot.take(); + + // Report with smoothen token rebase with small bad debt + await internalizeBadDebt(ether("10"), ether("1")); // Smaller bad debt + const stateAfter1 = await reportWithLargeSharesToBurnEnsureSmoothing(); + + await Snapshot.restore(beforeReportSnapshot); + + // Report with smoothen token rebase with larger bad debt + await internalizeBadDebt(ether("20"), ether("1")); // Larger bad debt + const stateAfter2 = await reportWithLargeSharesToBurnEnsureSmoothing(); + + expect(stateAfter1.shareRate).to.be.gt( + stateAfter2.shareRate, + "Share rate should be higher after less bad debt internalized", + ); + + expect(stateAfter1.burnerShares).to.be.eq( + stateAfter2.burnerShares, + "Smoothing should not be affected by bad debt amount", + ); + }); + }); + + describe("CL balance decrease check with bad debt internalization", () => { + it("Small CL balance decrease", async () => { + const stateBefore = await captureState(); + + // Queue bad debt internalization + await internalizeBadDebt(); + + // Small negative CL diff (within allowed limits) + const smallDecrease = ether("-1"); + + await report(ctx, { + clDiff: smallDecrease, + excludeVaultsBalances: true, + skipWithdrawals: true, + waitNextReportTime: true, + }); + + const stateAfter = await captureState(); + + expect(stateAfter.badDebtToInternalize).to.equal(0n, "Bad debt should be cleared"); + expect(stateAfter.shareRate).to.be.lt(stateBefore.shareRate, "Share rate should decrease"); + }); + + it("Max allowed CL balance decrease", async () => { + // Bad debt internalization does not affect calculation of dynamic slashing limit + // so the report with max allowed CL decrease should still pass with bad debt internalization + + const { oracleReportSanityChecker, lido, stakingRouter } = ctx.contracts; + + // Time travel to 54 days to invalidate all current penalties and get max slashing limits + const DAYS_54_IN_SECONDS = 54n * 24n * 60n * 60n; + await advanceChainTime(DAYS_54_IN_SECONDS); + await report(ctx); + + // Get current protocol state to calculate dynamic slashing limit + const { beaconValidators } = await lido.getBeaconStat(); + const moduleDigests = await stakingRouter.getAllStakingModuleDigests(); + const limits = await oracleReportSanityChecker.getOracleReportLimits(); + + const exitedValidators = moduleDigests.reduce((total, { summary }) => total + summary.totalExitedValidators, 0n); + const activeValidators = beaconValidators - exitedValidators; + + // maxAllowedCLRebaseNegativeSum = initialSlashingAmountPWei * 1e15 * validators + inactivityPenaltiesAmountPWei * 1e15 * validators + const ONE_PWEI = 10n ** 15n; + const maxAllowedNegativeRebase = + limits.initialSlashingAmountPWei * ONE_PWEI * activeValidators + + limits.inactivityPenaltiesAmountPWei * ONE_PWEI * activeValidators; + + // CL decrease exactly at limit minus 1 wei should pass + const clSlashing = -(maxAllowedNegativeRebase - 1n); + + await internalizeBadDebt(); + + const stateBefore = await captureState(); + expect(stateBefore.badDebtToInternalize).to.equal(badDebtShares, "Bad debt should be queued"); + + const { reportTx } = await report(ctx, { + clDiff: clSlashing, + excludeVaultsBalances: true, + skipWithdrawals: true, + waitNextReportTime: true, + }); + + // Report should pass - CL decrease is under the limit + // Bad debt should also be internalized in the same report + await expect(reportTx).to.emit(lido, "ExternalBadDebtInternalized").withArgs(badDebtShares); + + const stateAfter = await captureState(); + expect(stateAfter.badDebtToInternalize).to.equal(0n, "Bad debt should be cleared"); + expect(stateAfter.externalShares).to.equal( + stateBefore.externalShares - badDebtShares, + "External shares should decrease by bad debt amount", + ); + }); + }); + + describe("Annual balance increase check with bad debt internalization", () => { + it("CL balance increase over limit reverts, bad debt does not compensate", async () => { + // Bad debt internalization does not affect CL balance increase check + // so even with bad debt queued, the report exceeding limit should revert + + const { oracleReportSanityChecker, lido, accountingOracle, hashConsensus } = ctx.contracts; + + await internalizeBadDebt(); + await waitNextAvailableReportTime(ctx); + + // Get current protocol state + const { beaconBalance: preCLBalance } = await lido.getBeaconStat(); + const { annualBalanceIncreaseBPLimit } = await oracleReportSanityChecker.getOracleReportLimits(); + const { secondsPerSlot } = await hashConsensus.getChainConfig(); + const { currentFrameRefSlot } = await accountingOracle.getProcessingState(); + const lastRefSlot = await accountingOracle.getLastProcessingRefSlot(); + const slotElapsed = currentFrameRefSlot - lastRefSlot; + + expect(slotElapsed).to.be.gt(0n, "Some slots should have elapsed since last report"); + + // Calculate time elapsed for one frame + const timeElapsed = slotElapsed * secondsPerSlot; + + // Calculate balance increase that exceeds the limit + // The check is: (365 days * 10000 * balanceIncrease / preCLBalance) / timeElapsed > limit + // Solving : balanceIncrease > ((limit + 1) * preCLBalance * timeElapsed - 1) / (365 days * 10000) + const SECONDS_PER_YEAR = 365n * 24n * 60n * 60n; + const MAX_BASIS_POINTS = 10000n; + const maxBalanceIncrease = + ((annualBalanceIncreaseBPLimit + 1n) * preCLBalance * timeElapsed - 1n) / (SECONDS_PER_YEAR * MAX_BASIS_POINTS); + + const stateBefore = await captureState(); + expect(stateBefore.badDebtToInternalize).to.equal(badDebtShares, "Bad debt should be queued"); + + // Report should revert - CL increase exceeds the limit + // Bad debt being queued does NOT compensate for the excess + await expect( + report(ctx, { + clDiff: maxBalanceIncrease + 10n ** 9n, // + 1 gwei to exceed limit + excludeVaultsBalances: true, + skipWithdrawals: true, + waitNextReportTime: false, + }), + ).to.be.revertedWithCustomError(oracleReportSanityChecker, "IncorrectCLBalanceIncrease"); + + // Now report exactly at the limit. Should pass despite bad debt internalization + await report(ctx, { + clDiff: maxBalanceIncrease, + excludeVaultsBalances: true, + skipWithdrawals: true, + waitNextReportTime: false, + }); + + const stateAfter = await captureState(); + expect(stateAfter.badDebtToInternalize).to.equal(0n, "Bad debt should be cleared"); + expect(stateAfter.externalShares).to.equal( + stateBefore.externalShares - badDebtShares, + "External shares should decrease by bad debt amount", + ); + }); + }); +}); From f65f6de902675805c5ed90f63f51de5a9f5cd9a1 Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Sun, 14 Dec 2025 16:58:08 +0300 Subject: [PATCH 2/4] test: add integration tests for withdrawals finalization with bad debt internalization --- lib/protocol/helpers/vaults.ts | 78 ++++ .../sanity-checker-bad-debt.integration.ts | 133 ++----- .../withdrawals-bad-debt.integration.ts | 372 ++++++++++++++++++ 3 files changed, 488 insertions(+), 95 deletions(-) create mode 100644 test/integration/vaults/withdrawals-bad-debt.integration.ts diff --git a/lib/protocol/helpers/vaults.ts b/lib/protocol/helpers/vaults.ts index a6b16593e..cb0423fbb 100644 --- a/lib/protocol/helpers/vaults.ts +++ b/lib/protocol/helpers/vaults.ts @@ -481,6 +481,84 @@ export async function createVaultProxyWithoutConnectingToVaultHub( }; } +/** + * Sets up a staking vault with bad debt by slashing its total value below its liabilities + * @param ctx Protocol context + * @param vaultOwner Vault owner signer + * @param nodeOperator Node operator signer + * @param fundAmount Amount to fund the vault with + * @param slashTo Amount to slash the vault's total value to + * @returns Object containing the staking vault and the amount of bad debt shares + */ +export async function setupVaultWithBadDebt( + ctx: ProtocolContext, + vaultOwner: HardhatEthersSigner, + nodeOperator: HardhatEthersSigner, + fundAmount = ether("10"), + slashTo = ether("1"), +): Promise<{ stakingVault: StakingVault; badDebtShares: bigint }> { + const { stakingVaultFactory, lido } = ctx.contracts; + + // Create vault with dashboard + const { stakingVault, dashboard } = await createVaultWithDashboard( + ctx, + stakingVaultFactory, + vaultOwner, + nodeOperator, + nodeOperator, + ); + + // Fund and mint max shares + await dashboard.connect(vaultOwner).fund({ value: fundAmount }); + + const capacity = await dashboard.remainingMintingCapacityShares(0n); + await dashboard.connect(vaultOwner).mintShares(vaultOwner, capacity); + + // Slash to create bad debt + await reportVaultDataWithProof(ctx, stakingVault, { + totalValue: slashTo, + slashingReserve: slashTo, + waitForNextRefSlot: true, + }); + + // 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, "Vault should have bad debt"); + + // Calculate bad debt amount + const badDebtShares = liabilityShares - (await lido.getSharesByPooledEth(totalValue)); + + return { stakingVault, badDebtShares }; +} + +/** + * Queue bad debt internalization for a staking vault + * @param ctx Protocol context + * @param stakingVault Staking vault to internalize bad debt for + * @param badDebtShares Amount of bad debt shares to internalize + */ +export async function queueBadDebtInternalization( + ctx: ProtocolContext, + stakingVault: StakingVault, + badDebtShares: bigint, +): Promise { + expect(badDebtShares).to.be.gt(0n, "Bad debt shares must be greater than zero"); + + // Grant BAD_DEBT_MASTER_ROLE to daoAgent + const { vaultHub } = ctx.contracts; + const aragonAgent = await ctx.getSigner("agent"); + await vaultHub.connect(aragonAgent).grantRole(await vaultHub.BAD_DEBT_MASTER_ROLE(), aragonAgent); + + // Queue bad debt for internalization + const badDebtToBefore = await vaultHub.badDebtToInternalize(); + await vaultHub.connect(aragonAgent).internalizeBadDebt(stakingVault, badDebtShares); + const badDebtToAfter = await vaultHub.badDebtToInternalize(); + + expect(badDebtToAfter - badDebtToBefore).to.equal(badDebtShares, "Bad debt should be queued"); +} + export const getPubkeys = (num: number): { pubkeys: string[]; stringified: string } => { const pubkeys = Array.from({ length: num }, (_, i) => { const paddedIndex = (i + 1).toString().padStart(8, "0"); diff --git a/test/integration/vaults/sanity-checker-bad-debt.integration.ts b/test/integration/vaults/sanity-checker-bad-debt.integration.ts index 69e7af139..d8c1fb8e3 100644 --- a/test/integration/vaults/sanity-checker-bad-debt.integration.ts +++ b/test/integration/vaults/sanity-checker-bad-debt.integration.ts @@ -5,17 +5,15 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; -import { StakingVault } from "typechain-types"; - import { advanceChainTime, ether, impersonate, LIMITER_PRECISION_BASE } from "lib"; import { - createVaultWithDashboard, getProtocolContext, ProtocolContext, + queueBadDebtInternalization, removeStakingLimit, report, - reportVaultDataWithProof, setupLidoForVaults, + setupVaultWithBadDebt, upDefaultTierShareLimit, waitNextAvailableReportTime, } from "lib/protocol"; @@ -30,12 +28,8 @@ describe("Integration: Sanity checker with bad debt internalization", () => { let owner: HardhatEthersSigner; let nodeOperator: HardhatEthersSigner; - let daoAgent: HardhatEthersSigner; let stranger: HardhatEthersSigner; - let stakingVault: StakingVault; - let badDebtShares: bigint; - // Get shares burn limit from sanity checker when NO changes in pooled Ether are expected const sharesToBurnToReachRebaseLimit = async () => { const { lido, oracleReportSanityChecker } = ctx.contracts; @@ -75,69 +69,16 @@ describe("Integration: Sanity checker with bad debt internalization", () => { }; }; - // Helper to create vault with bad debt - const setupVaultWithBadDebt = async ( - vaultOwner: HardhatEthersSigner, - fundAmount: bigint = ether("10"), - slashTo: bigint = ether("1"), - ) => { - const { stakingVaultFactory, lido } = ctx.contracts; - const { stakingVault: vault, dashboard } = await createVaultWithDashboard( - ctx, - stakingVaultFactory, - vaultOwner, - nodeOperator, - nodeOperator, - ); - - const connectedDashboard = dashboard.connect(vaultOwner); - - // Fund and mint max shares - await connectedDashboard.fund({ value: fundAmount }); - await connectedDashboard.mintShares(vaultOwner, await connectedDashboard.remainingMintingCapacityShares(0n)); - - // Slash to create bad debt - await reportVaultDataWithProof(ctx, vault, { - totalValue: slashTo, - slashingReserve: slashTo, - waitForNextRefSlot: true, - }); - - // Verify bad debt exists - const totalValue = await connectedDashboard.totalValue(); - const liabilityShares = await connectedDashboard.liabilityShares(); - const liabilityValue = await lido.getPooledEthBySharesRoundUp(liabilityShares); - expect(totalValue).to.be.lessThan(liabilityValue, "Vault should have bad debt"); - - // Calculate bad debt amount - const badDebt = liabilityShares - (await lido.getSharesByPooledEth(totalValue)); - - return { vault, badDebtShares: badDebt }; - }; - - // Helper to setup and queue bad debt internalization - const internalizeBadDebt = async (fundAmount: bigint = ether("10"), slashTo: bigint = ether("1")) => { - // Setup vault with bad debt - const setup = await setupVaultWithBadDebt(owner, fundAmount, slashTo); - stakingVault = setup.vault; - badDebtShares = setup.badDebtShares; - - // Grant BAD_DEBT_MASTER_ROLE to daoAgent - const { vaultHub } = ctx.contracts; - await vaultHub.connect(await ctx.getSigner("agent")).grantRole(await vaultHub.BAD_DEBT_MASTER_ROLE(), daoAgent); - - // Queue bad debt for internalization (will be available after next report) - await vaultHub.connect(daoAgent).internalizeBadDebt(stakingVault, badDebtShares); - }; - before(async () => { ctx = await getProtocolContext(); originalSnapshot = await Snapshot.take(); - [, owner, nodeOperator, , daoAgent, stranger] = await ethers.getSigners(); + [, owner, nodeOperator, , , stranger] = await ethers.getSigners(); + const { withdrawalVault } = ctx.contracts; await setupLidoForVaults(ctx); await upDefaultTierShareLimit(ctx, ether("1000")); + await setBalance(await withdrawalVault.getAddress(), 0n); }); beforeEach(async () => (snapshot = await Snapshot.take())); @@ -146,16 +87,12 @@ describe("Integration: Sanity checker with bad debt internalization", () => { describe("Smoothing rebase with bad debt internalization", () => { it("No smoothing", async () => { - const { lido, burner, elRewardsVault, withdrawalVault } = ctx.contracts; - - await internalizeBadDebt(); + const { lido, burner } = ctx.contracts; + const { stakingVault, badDebtShares } = await setupVaultWithBadDebt(ctx, owner, nodeOperator); const stateBefore = await captureState(); - expect(stateBefore.badDebtToInternalize).to.equal(badDebtShares, "Bad debt should be queued"); - // Ensure no EL rewards and no withdrawal vault balance - await setBalance(await elRewardsVault.getAddress(), 0n); - await setBalance(await withdrawalVault.getAddress(), 0n); + await queueBadDebtInternalization(ctx, stakingVault, badDebtShares); // Report with zero CL diff, skip withdrawals, don't report burner const { reportTx } = await report(ctx, { @@ -174,7 +111,7 @@ describe("Integration: Sanity checker with bad debt internalization", () => { const burnerSharesBurnt = sharesBurntEvents.filter((e) => e.args.account === burnerAddress); expect(burnerSharesBurnt.length).to.equal(0, "No shares should be burnt from burner"); - // Verify bad debt was applied + // Verify bad debt was internalized await expect(reportTx).to.emit(lido, "ExternalBadDebtInternalized").withArgs(badDebtShares); await expect(reportTx).to.emit(lido, "ExternalSharesBurnt").withArgs(badDebtShares); @@ -191,15 +128,16 @@ describe("Integration: Sanity checker with bad debt internalization", () => { }); it("Smoothing due to large rewards", async () => { - const reportWithLargeElRewardsEnsureSmoothing = async () => { - const { lido, elRewardsVault, withdrawalVault } = ctx.contracts; + const { stakingVault, badDebtShares } = await setupVaultWithBadDebt(ctx, owner, nodeOperator); + + const reportWithLargeElRewardsEnsureSmoothing = async (badDebtToInternalize: bigint) => { + const { lido, elRewardsVault } = ctx.contracts; const stateBefore = await captureState(); // Add large EL rewards (will be limited by smoothing) const largeRewards = ether("10000"); await setBalance(await elRewardsVault.getAddress(), largeRewards); - await setBalance(await withdrawalVault.getAddress(), 0n); const { reportTx } = await report(ctx, { clDiff: 0n, @@ -209,14 +147,14 @@ describe("Integration: Sanity checker with bad debt internalization", () => { }); // Verify bad debt was fully applied - await expect(reportTx).to.emit(lido, "ExternalBadDebtInternalized").withArgs(badDebtShares); + await expect(reportTx).to.emit(lido, "ExternalBadDebtInternalized").withArgs(badDebtToInternalize); const stateAfter = await captureState(); // Bad debt fully cleared expect(stateAfter.badDebtToInternalize).to.equal(0n, "Bad debt should be fully cleared"); expect(stateAfter.externalShares).to.equal( - stateBefore.externalShares - badDebtShares, + stateBefore.externalShares - badDebtToInternalize, "External shares should decrease by full bad debt amount", ); @@ -229,15 +167,15 @@ describe("Integration: Sanity checker with bad debt internalization", () => { const beforeReportSnapshot = await Snapshot.take(); - // Report with smoothen token rebase with small bad debt - await internalizeBadDebt(ether("10"), ether("1")); // Smaller bad debt - const stateAfter1 = await reportWithLargeElRewardsEnsureSmoothing(); + // Report with smoothen token rebase with small bad debt internalization + await queueBadDebtInternalization(ctx, stakingVault, badDebtShares / 10n); // Internalize part + const stateAfter1 = await reportWithLargeElRewardsEnsureSmoothing(badDebtShares / 10n); await Snapshot.restore(beforeReportSnapshot); - // Report with smoothen token rebase with larger bad debt - await internalizeBadDebt(ether("20"), ether("1")); // Larger bad debt - const stateAfter2 = await reportWithLargeElRewardsEnsureSmoothing(); + // Report with smoothen token rebase with larger bad debt internalization + await queueBadDebtInternalization(ctx, stakingVault, badDebtShares); // Internalize all + const stateAfter2 = await reportWithLargeElRewardsEnsureSmoothing(badDebtShares); expect(stateAfter1.shareRate).to.be.gt( stateAfter2.shareRate, @@ -251,7 +189,9 @@ describe("Integration: Sanity checker with bad debt internalization", () => { }); it("Smoothing due to large shares to burn", async () => { - const reportWithLargeSharesToBurnEnsureSmoothing = async () => { + const { stakingVault, badDebtShares } = await setupVaultWithBadDebt(ctx, owner, nodeOperator); + + const reportWithLargeSharesToBurnEnsureSmoothing = async (badDebtToInternalize: bigint) => { const { lido, burner, accounting } = ctx.contracts; // Calculate shares limit and add excess to ensure smoothing kicks in @@ -284,14 +224,14 @@ describe("Integration: Sanity checker with bad debt internalization", () => { }); // Verify bad debt was fully applied regardless of burner shares - await expect(reportTx).to.emit(lido, "ExternalBadDebtInternalized").withArgs(badDebtShares); + await expect(reportTx).to.emit(lido, "ExternalBadDebtInternalized").withArgs(badDebtToInternalize); const stateAfter = await captureState(); // Bad debt fully cleared expect(stateAfter.badDebtToInternalize).to.equal(0n, "Bad debt should be fully cleared"); expect(stateAfter.externalShares).to.equal( - stateBefore.externalShares - badDebtShares, + stateBefore.externalShares - badDebtToInternalize, "External shares should decrease by full bad debt amount", ); @@ -304,15 +244,15 @@ describe("Integration: Sanity checker with bad debt internalization", () => { const beforeReportSnapshot = await Snapshot.take(); - // Report with smoothen token rebase with small bad debt - await internalizeBadDebt(ether("10"), ether("1")); // Smaller bad debt - const stateAfter1 = await reportWithLargeSharesToBurnEnsureSmoothing(); + // Report with smoothen token rebase with small bad debt internalization + await queueBadDebtInternalization(ctx, stakingVault, badDebtShares / 10n); // Internalize part + const stateAfter1 = await reportWithLargeSharesToBurnEnsureSmoothing(badDebtShares / 10n); await Snapshot.restore(beforeReportSnapshot); - // Report with smoothen token rebase with larger bad debt - await internalizeBadDebt(ether("20"), ether("1")); // Larger bad debt - const stateAfter2 = await reportWithLargeSharesToBurnEnsureSmoothing(); + // Report with smoothen token rebase with larger bad debt internalization + await queueBadDebtInternalization(ctx, stakingVault, badDebtShares); // Internalize all + const stateAfter2 = await reportWithLargeSharesToBurnEnsureSmoothing(badDebtShares); expect(stateAfter1.shareRate).to.be.gt( stateAfter2.shareRate, @@ -331,7 +271,8 @@ describe("Integration: Sanity checker with bad debt internalization", () => { const stateBefore = await captureState(); // Queue bad debt internalization - await internalizeBadDebt(); + const { stakingVault, badDebtShares } = await setupVaultWithBadDebt(ctx, owner, nodeOperator); + await queueBadDebtInternalization(ctx, stakingVault, badDebtShares); // Small negative CL diff (within allowed limits) const smallDecrease = ether("-1"); @@ -377,7 +318,8 @@ describe("Integration: Sanity checker with bad debt internalization", () => { // CL decrease exactly at limit minus 1 wei should pass const clSlashing = -(maxAllowedNegativeRebase - 1n); - await internalizeBadDebt(); + const { stakingVault, badDebtShares } = await setupVaultWithBadDebt(ctx, owner, nodeOperator); + await queueBadDebtInternalization(ctx, stakingVault, badDebtShares); const stateBefore = await captureState(); expect(stateBefore.badDebtToInternalize).to.equal(badDebtShares, "Bad debt should be queued"); @@ -409,7 +351,8 @@ describe("Integration: Sanity checker with bad debt internalization", () => { const { oracleReportSanityChecker, lido, accountingOracle, hashConsensus } = ctx.contracts; - await internalizeBadDebt(); + const { stakingVault, badDebtShares } = await setupVaultWithBadDebt(ctx, owner, nodeOperator); + await queueBadDebtInternalization(ctx, stakingVault, badDebtShares); await waitNextAvailableReportTime(ctx); // Get current protocol state diff --git a/test/integration/vaults/withdrawals-bad-debt.integration.ts b/test/integration/vaults/withdrawals-bad-debt.integration.ts new file mode 100644 index 000000000..e652ccd99 --- /dev/null +++ b/test/integration/vaults/withdrawals-bad-debt.integration.ts @@ -0,0 +1,372 @@ +import { expect } from "chai"; +import { ZeroAddress } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; +import { setBalance } from "@nomicfoundation/hardhat-network-helpers"; + +import { advanceChainTime, ether } from "lib"; +import { LIMITER_PRECISION_BASE } from "lib/constants"; +import { + getProtocolContext, + ProtocolContext, + queueBadDebtInternalization, + removeStakingLimit, + report, + setupLidoForVaults, + setupVaultWithBadDebt, + upDefaultTierShareLimit, +} from "lib/protocol"; + +import { Snapshot } from "test/suite"; +import { SHARE_RATE_PRECISION } from "test/suite/constants"; + +describe("Integration: Withdrawals finalization with bad debt internalization", () => { + let ctx: ProtocolContext; + let snapshot: string; + let originalSnapshot: string; + + let owner: HardhatEthersSigner; + let nodeOperator: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + + // Helper to capture protocol state + const captureState = async () => { + const { lido, vaultHub, burner, elRewardsVault, withdrawalVault, withdrawalQueue } = ctx.contracts; + + const totalPooledEther = await lido.getTotalPooledEther(); + const totalShares = await lido.getTotalShares(); + const externalShares = await lido.getExternalShares(); + const externalEther = await lido.getExternalEther(); + const internalEther = totalPooledEther - externalEther; + const internalShares = totalShares - externalShares; + const unfinalizedSTETH = await withdrawalQueue.unfinalizedStETH(); + const unfinalizedRequestNumber = await withdrawalQueue.unfinalizedRequestNumber(); + const lastFinalizedRequestId = await withdrawalQueue.getLastFinalizedRequestId(); + const badDebtToInternalize = await vaultHub.badDebtToInternalize(); + const [coverShares, nonCoverShares] = await burner.getSharesRequestedToBurn(); + const elRewardsVaultBalance = await ethers.provider.getBalance(elRewardsVault); + const withdrawalVaultBalance = await ethers.provider.getBalance(withdrawalVault); + const withdrawalQueueBalance = await ethers.provider.getBalance(withdrawalQueue); + + return { + totalPooledEther, + totalShares, + externalShares, + externalEther, + internalEther, + internalShares, + badDebtToInternalize, + burnerShares: coverShares + nonCoverShares, + elRewardsVaultBalance, + withdrawalVaultBalance, + withdrawalQueueBalance, + unfinalizedSTETH, + unfinalizedRequestNumber, + lastFinalizedRequestId, + shareRate: totalShares > 0n ? (totalPooledEther * SHARE_RATE_PRECISION) / totalShares : 0n, + }; + }; + + // Helper to calculate the bad debt amount that would trigger smoothen token rebase + const calculateBadDebtToTriggerSmoothing = async () => { + const { lido, oracleReportSanityChecker } = ctx.contracts; + const state = await captureState(); + + // Get shares to finalize for WQ + const sharesToFinalize = await lido.getSharesByPooledEth(state.unfinalizedSTETH); + + // Total shares that need to be burned + const totalSharesToBurn = sharesToFinalize + state.burnerShares; + + // Get rebase limit + const limits = await oracleReportSanityChecker.getOracleReportLimits(); + const maxPositiveTokenRebase = limits.maxPositiveTokenRebase; + const rebaseLimitPlus1 = maxPositiveTokenRebase + LIMITER_PRECISION_BASE; + + // Current share rate (this is batchShareRate in prefinalize) + const currentShareRate = state.shareRate; + + // Calculate maxSharesToBurn for a given badDebtShares amount + const calculateMaxSharesToBurn = (badDebtShares: bigint): bigint => { + // Step 1: Calculate simulatedShareRate (simulation without WQ finalization) + const postInternalShares = state.internalShares + badDebtShares; + const postExternalShares = state.externalShares - badDebtShares; + const postInternalEther = state.internalEther; // postInternalEther = preInternalEther (no WQ finalization in simulation) + const postExternalEther = (postExternalShares * postInternalEther) / postInternalShares; + const postTotalPooledEther = postInternalEther + postExternalEther; + const postTotalShares = postInternalShares + postExternalShares; + const simulatedShareRate = (postTotalPooledEther * SHARE_RATE_PRECISION) / postTotalShares; + + // Step 2: Calculate etherToLock in prefinalize + let etherToLock: bigint; + if (currentShareRate > simulatedShareRate) { + etherToLock = (sharesToFinalize * simulatedShareRate) / SHARE_RATE_PRECISION; + } else { + etherToLock = state.unfinalizedSTETH; + } + + // Step 3: Calculate maxSharesToBurn in smoothenTokenRebase + const currentTotalPooledEther = state.internalEther - etherToLock; + const pooledEtherRate = (currentTotalPooledEther * LIMITER_PRECISION_BASE) / state.internalEther; + const maxSharesToBurn = (state.internalShares * (rebaseLimitPlus1 - pooledEtherRate)) / rebaseLimitPlus1; + + return maxSharesToBurn; + }; + + // Convert ether to shares for calculation + const etherToShares = (etherAmount: bigint): bigint => { + return (etherAmount * state.totalShares) / state.totalPooledEther; + }; + + // Check if smoothening already triggers without bad debt + const maxSharesToBurnWithoutBadDebt = calculateMaxSharesToBurn(0n); + expect(maxSharesToBurnWithoutBadDebt).to.be.gt(0n, "Smoothening already triggers without bad debt"); + + // Binary search to find the threshold + let low = 0n; + let high = state.externalShares; // Bad debt can't exceed external shares + + while (high - low > etherToShares(ether("0.01"))) { + const mid = (low + high) / 2n; + const maxSharesToBurn = calculateMaxSharesToBurn(mid); + + if (totalSharesToBurn > maxSharesToBurn) { + // Smoothening triggers, try lower bad debt + high = mid; + } else { + // Smoothening doesn't trigger, try higher bad debt + low = mid; + } + } + + // Return shares (not ether) since internalizeBadDebt expects shares + return { + minBadDebtToTrigger: high, + maxBadDebtWithoutTrigger: low, + }; + }; + + // Helper to put withdrawal requests in the queue + const requestWithdrawals = async (requestAmount = ether("1000"), requestCount = 10n) => { + const { lido, withdrawalQueue } = ctx.contracts; + const requestsSum = requestAmount * requestCount; + + // Submit enough ETH + await removeStakingLimit(ctx); + await setBalance(stranger.address, requestsSum + ether("1")); // Some extra for gas + await lido.connect(stranger).submit(ZeroAddress, { value: requestsSum }); + + // Approve WQ to spend stETH + await lido.connect(stranger).approve(withdrawalQueue.address, requestsSum); + + // Make withdrawal requests + const requests = Array(parseInt(requestCount.toString())).fill(requestAmount); + await withdrawalQueue.connect(stranger).requestWithdrawals(requests, stranger.address); + }; + + // Helper to report with withdrawals finalization + const finalizeWithdrawals = async () => { + const { withdrawalQueue, oracleReportSanityChecker } = ctx.contracts; + + const stateBefore = await captureState(); + + // Advance time to ensure request can be finalized + const limits = await oracleReportSanityChecker.getOracleReportLimits(); + await advanceChainTime(limits.requestTimestampMargin + 1n); + + // Perform report which will finalize withdrawals + const { reportTx } = await report(ctx, { + clDiff: 0n, + excludeVaultsBalances: false, + skipWithdrawals: false, + waitNextReportTime: true, + }); + + const receipt = await reportTx!.wait(); + + // Verify WithdrawalsFinalized event emitted + await expect(reportTx).to.emit(withdrawalQueue, "WithdrawalsFinalized"); + + // Extract WithdrawalsFinalized event + const events = ctx.getEvents(receipt!, "WithdrawalsFinalized"); + expect(events.length).to.equal(1, "No WithdrawalsFinalized event found"); + const finalizedEvent = events[0]; + + const stateAfter = await captureState(); + + return { reportTx, finalizedEvent, stateBefore, stateAfter }; + }; + + before(async () => { + ctx = await getProtocolContext(); + originalSnapshot = await Snapshot.take(); + + [, owner, nodeOperator, , , stranger] = await ethers.getSigners(); + + await setupLidoForVaults(ctx); + await upDefaultTierShareLimit(ctx, ether("1000")); + + // Make the sanity checker more sensitive to the activation of smoothen token rebase + const maxPositiveTokenRebase = 5000n; + const agent = await ctx.getSigner("agent"); + const { oracleReportSanityChecker } = ctx.contracts; + await oracleReportSanityChecker + .connect(agent) + .grantRole(await oracleReportSanityChecker.MAX_POSITIVE_TOKEN_REBASE_MANAGER_ROLE(), agent); + await oracleReportSanityChecker.connect(agent).setMaxPositiveTokenRebase(maxPositiveTokenRebase); + }); + + beforeEach(async () => (snapshot = await Snapshot.take())); + afterEach(async () => await Snapshot.restore(snapshot)); + after(async () => await Snapshot.restore(originalSnapshot)); + + describe("Withdrawals finalization with bad debt internalization", () => { + it("Should finalize withdrawals even there's bad debt to internalize", async () => { + // Setup staking vault with bad debt and internalize it + const setup = await setupVaultWithBadDebt(ctx, owner, nodeOperator); + await queueBadDebtInternalization(ctx, setup.stakingVault, setup.badDebtShares); + + // Request withdrawals and finalize them + await requestWithdrawals(); + const { stateBefore, stateAfter } = await finalizeWithdrawals(); + + // Verify withdrawals were finalized + expect(stateBefore.unfinalizedRequestNumber).to.be.gt( + stateAfter.unfinalizedRequestNumber, + "Unfinalized request number should decrease after finalization", + ); + expect(stateBefore.unfinalizedSTETH).to.be.gt( + stateAfter.unfinalizedSTETH, + "Unfinalized stETH should decrease after finalization", + ); + + // Verify bad debt was internalized + expect(stateBefore.badDebtToInternalize).to.be.gt(0n, "There should be bad debt to internalize before report"); + expect(stateAfter.badDebtToInternalize).to.equal(0n, "There should be no bad debt to internalize after report"); + }); + + it("Bad debt internalization should affect finalization share rate", async () => { + const beforeReportSnapshot = await Snapshot.take(); + + // 1. Finalize withdrawals without bad debt internalization + await requestWithdrawals(); + const withoutBadDebt = await finalizeWithdrawals(); + + // Restore to before report state + await Snapshot.restore(beforeReportSnapshot); + + // 2. Finalize withdrawals with bad debt internalization + const setup = await setupVaultWithBadDebt(ctx, owner, nodeOperator); + await queueBadDebtInternalization(ctx, setup.stakingVault, setup.badDebtShares); + await requestWithdrawals(); + const withBadDebt = await finalizeWithdrawals(); + + // Second stETH share rate should be lower due to bad debt internalization + expect(withoutBadDebt.stateAfter.shareRate).to.be.gt( + withBadDebt.stateAfter.shareRate, + "Share rate should be higher when no bad debt is internalized", + ); + + const [, , amountOfETHLocked1, sharesToBurn1] = withoutBadDebt.finalizedEvent.args; + const [, , amountOfETHLocked2, sharesToBurn2] = withBadDebt.finalizedEvent.args; + + expect(amountOfETHLocked1).to.be.gt( + amountOfETHLocked2, + "Amount of ETH locked should be higher when no bad debt is internalized", + ); + expect(sharesToBurn1).to.be.eq( + sharesToBurn2, + "Shares to burn should be equal regardless of bad debt internalization", + ); + }); + + it("Verify bad debt smoothing thresholds calculation", async () => { + await requestWithdrawals(); + await setupVaultWithBadDebt(ctx, owner, nodeOperator, ether("200000"), ether("1")); + + const { minBadDebtToTrigger, maxBadDebtWithoutTrigger } = await calculateBadDebtToTriggerSmoothing(); + + expect(minBadDebtToTrigger).to.be.gt(0n, "Minimum bad debt to trigger should be greater than zero"); + expect(maxBadDebtWithoutTrigger).to.be.gt(0n, "Maximum bad debt without trigger should be greater than zero"); + expect(minBadDebtToTrigger).to.be.gt(maxBadDebtWithoutTrigger, "Calculated thresholds are inconsistent"); + }); + + it("Small bad debt internalization should not trigger smoothen token rebase", async () => { + await requestWithdrawals(); + + // Calculate the threshold for smoothening + const setup = await setupVaultWithBadDebt(ctx, owner, nodeOperator, ether("200000"), ether("1")); + const { maxBadDebtWithoutTrigger } = await calculateBadDebtToTriggerSmoothing(); + expect(maxBadDebtWithoutTrigger).to.be.lte(setup.badDebtShares, "Bad debt shares should be sufficient"); + + await queueBadDebtInternalization(ctx, setup.stakingVault, maxBadDebtWithoutTrigger); + const { stateBefore, stateAfter } = await finalizeWithdrawals(); + + expect(stateBefore.burnerShares).to.be.gte( + stateAfter.burnerShares, + "Shares to burn should not increase after finalization", + ); + }); + + it("Big bad debt internalization should trigger smoothen token rebase", async () => { + await requestWithdrawals(); + + // Calculate the threshold for smoothening + const setup = await setupVaultWithBadDebt(ctx, owner, nodeOperator, ether("200000"), ether("1")); + const { minBadDebtToTrigger } = await calculateBadDebtToTriggerSmoothing(); + expect(minBadDebtToTrigger).to.be.lte(setup.badDebtShares, "Bad debt shares should be sufficient"); + + await queueBadDebtInternalization(ctx, setup.stakingVault, minBadDebtToTrigger); + const { stateBefore, stateAfter } = await finalizeWithdrawals(); + + expect(stateBefore.burnerShares).to.be.lt( + stateAfter.burnerShares, + "Shares to burn should increase after finalization", + ); + }); + + it("Smoothen token rebase do not affect finalization", async () => { + await requestWithdrawals(); + + // Calculate the threshold for smoothening + const setup = await setupVaultWithBadDebt(ctx, owner, nodeOperator, ether("200000"), ether("1")); + const { minBadDebtToTrigger } = await calculateBadDebtToTriggerSmoothing(); + expect(minBadDebtToTrigger).to.be.lte(setup.badDebtShares, "Bad debt shares should be sufficient"); + + await queueBadDebtInternalization(ctx, setup.stakingVault, minBadDebtToTrigger); + const { stateBefore, stateAfter } = await finalizeWithdrawals(); + + expect(stateBefore.burnerShares).to.be.lt( + stateAfter.burnerShares, + "Smoothing should have triggered and increased shares to burn", + ); + + // Verify finalized requests have claimable ETH + const { withdrawalQueue } = ctx.contracts; + const ethForWithdrawals = stateAfter.withdrawalQueueBalance - stateBefore.withdrawalQueueBalance; + + const from = stateBefore.lastFinalizedRequestId + 1n; + const to = stateAfter.lastFinalizedRequestId; + expect(from).to.be.lte(to, "No new requests finalized"); + + const lastCheckpointIndex = await withdrawalQueue.getLastCheckpointIndex(); + const requestsCount = Number(to - from + 1n); + const requestIds = Array.from({ length: requestsCount }, (_, i) => from + BigInt(i)); + + const chunkSize = 100; + let totalClaimable = 0n; + + for (let i = 0; i < requestIds.length; i += chunkSize) { + const chunk = requestIds.slice(i, i + chunkSize); + const hints = Array(chunk.length).fill(lastCheckpointIndex); + + const claimableChunk = await withdrawalQueue.getClaimableEther(chunk, hints); + for (const amount of claimableChunk) totalClaimable += amount; + } + + expect(totalClaimable).to.be.gt(0n, "Finalized requests should have claimable ETH"); + expect(totalClaimable).to.be.lte(ethForWithdrawals, "Claimable ETH must not exceed funds moved to WQ"); + }); + }); +}); From 100f98dc12ec8b6457a061bc5e723c5b3a955d4b Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Sun, 14 Dec 2025 17:19:16 +0300 Subject: [PATCH 3/4] test: adjust max positive token rebase sensitivity to pass tests on Mainnet --- test/integration/vaults/withdrawals-bad-debt.integration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/vaults/withdrawals-bad-debt.integration.ts b/test/integration/vaults/withdrawals-bad-debt.integration.ts index e652ccd99..5e7798ff3 100644 --- a/test/integration/vaults/withdrawals-bad-debt.integration.ts +++ b/test/integration/vaults/withdrawals-bad-debt.integration.ts @@ -208,7 +208,7 @@ describe("Integration: Withdrawals finalization with bad debt internalization", await upDefaultTierShareLimit(ctx, ether("1000")); // Make the sanity checker more sensitive to the activation of smoothen token rebase - const maxPositiveTokenRebase = 5000n; + const maxPositiveTokenRebase = 1000n; const agent = await ctx.getSigner("agent"); const { oracleReportSanityChecker } = ctx.contracts; await oracleReportSanityChecker From fa9f27aa906850dd21ffa22d9a4927ec1daa57ec Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Sun, 14 Dec 2025 18:03:36 +0300 Subject: [PATCH 4/4] test: increase default tier share limit to maximum allowed --- .../vaults/withdrawals-bad-debt.integration.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/integration/vaults/withdrawals-bad-debt.integration.ts b/test/integration/vaults/withdrawals-bad-debt.integration.ts index 5e7798ff3..83c83d58e 100644 --- a/test/integration/vaults/withdrawals-bad-debt.integration.ts +++ b/test/integration/vaults/withdrawals-bad-debt.integration.ts @@ -205,12 +205,21 @@ describe("Integration: Withdrawals finalization with bad debt internalization", [, owner, nodeOperator, , , stranger] = await ethers.getSigners(); await setupLidoForVaults(ctx); - await upDefaultTierShareLimit(ctx, ether("1000")); + + const { oracleReportSanityChecker, operatorGrid, lido, vaultHub } = ctx.contracts; + + // Increase default tier share limit to maximum allowed + const TOTAL_BASIS_POINTS = 100_00n; + const totalShares = await lido.getTotalShares(); + const maxRelativeShareLimit = await vaultHub.MAX_RELATIVE_SHARE_LIMIT_BP(); + const existingTierParams = await operatorGrid.tier(await operatorGrid.DEFAULT_TIER_ID()); + const maxLimit = (totalShares * maxRelativeShareLimit) / TOTAL_BASIS_POINTS; + const increaseBy = maxLimit - existingTierParams.shareLimit; + await upDefaultTierShareLimit(ctx, increaseBy); // Make the sanity checker more sensitive to the activation of smoothen token rebase const maxPositiveTokenRebase = 1000n; const agent = await ctx.getSigner("agent"); - const { oracleReportSanityChecker } = ctx.contracts; await oracleReportSanityChecker .connect(agent) .grantRole(await oracleReportSanityChecker.MAX_POSITIVE_TOKEN_REBASE_MANAGER_ROLE(), agent);