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 new file mode 100644 index 000000000..d8c1fb8e3 --- /dev/null +++ b/test/integration/vaults/sanity-checker-bad-debt.integration.ts @@ -0,0 +1,409 @@ +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, impersonate, LIMITER_PRECISION_BASE } from "lib"; +import { + getProtocolContext, + ProtocolContext, + queueBadDebtInternalization, + removeStakingLimit, + report, + setupLidoForVaults, + setupVaultWithBadDebt, + 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 stranger: HardhatEthersSigner; + + // 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, + }; + }; + + before(async () => { + ctx = await getProtocolContext(); + originalSnapshot = await Snapshot.take(); + + [, 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())); + 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 } = ctx.contracts; + + const { stakingVault, badDebtShares } = await setupVaultWithBadDebt(ctx, owner, nodeOperator); + const stateBefore = await captureState(); + + await queueBadDebtInternalization(ctx, stakingVault, badDebtShares); + + // 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 internalized + 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 { 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); + + 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(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 - badDebtToInternalize, + "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 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 internalization + await queueBadDebtInternalization(ctx, stakingVault, badDebtShares); // Internalize all + const stateAfter2 = await reportWithLargeElRewardsEnsureSmoothing(badDebtShares); + + 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 { 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 + 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(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 - badDebtToInternalize, + "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 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 internalization + await queueBadDebtInternalization(ctx, stakingVault, badDebtShares); // Internalize all + const stateAfter2 = await reportWithLargeSharesToBurnEnsureSmoothing(badDebtShares); + + 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 + const { stakingVault, badDebtShares } = await setupVaultWithBadDebt(ctx, owner, nodeOperator); + await queueBadDebtInternalization(ctx, stakingVault, badDebtShares); + + // 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); + + 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"); + + 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; + + const { stakingVault, badDebtShares } = await setupVaultWithBadDebt(ctx, owner, nodeOperator); + await queueBadDebtInternalization(ctx, stakingVault, badDebtShares); + 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", + ); + }); + }); +}); 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..83c83d58e --- /dev/null +++ b/test/integration/vaults/withdrawals-bad-debt.integration.ts @@ -0,0 +1,381 @@ +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); + + 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"); + 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"); + }); + }); +});