From d9f4858ae4fef062aad4c31c98240558d2958636 Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Sat, 1 Nov 2025 14:13:56 +0300 Subject: [PATCH 01/20] feat: add configurable withdrawal fee and integrate into finalization --- src/WithdrawalQueue.sol | 221 +++++++++++++----- test/integration/stv-pool.test.sol | 12 +- test/integration/stv-steth-pool.test.sol | 6 +- .../withdrawal-queue/EmergencyExit.test.sol | 21 ++ test/unit/withdrawal-queue/FeeConfig.test.sol | 57 +++++ .../withdrawal-queue/FeeFinalization.test.sol | 111 +++++++++ .../withdrawal-queue/RequestCreation.test.sol | 31 ++- 7 files changed, 382 insertions(+), 77 deletions(-) create mode 100644 test/unit/withdrawal-queue/FeeConfig.test.sol create mode 100644 test/unit/withdrawal-queue/FeeFinalization.test.sol diff --git a/src/WithdrawalQueue.sol b/src/WithdrawalQueue.sol index 5345356..e2bb790 100644 --- a/src/WithdrawalQueue.sol +++ b/src/WithdrawalQueue.sol @@ -35,10 +35,19 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea uint256 public constant E27_PRECISION_BASE = 1e27; uint256 public constant E36_PRECISION_BASE = 1e36; - /// @notice Minimal amount of assets that is possible to withdraw - /// @dev Should be big enough to prevent DoS attacks by placing many small requests - uint256 public constant MIN_WITHDRAWAL_AMOUNT = 1 * 10 ** 14; // 0.0001 ETH - uint256 public constant MAX_WITHDRAWAL_AMOUNT = 10_000 * 10 ** 18; // 10,000 ETH + /// @notice Maximum withdrawal fee that can be applied for a single request + /// @dev High enough to cover fees for finalization tx + /// @dev Low enough to prevent abuse by charging excessive fees + uint256 public constant MAX_WITHDRAWAL_FEE = 0.001 ether; + + /// @notice Minimal value (assets - stETH to rebalance) that is possible to request + /// @dev Prevents placing many small requests + uint256 public constant MIN_WITHDRAWAL_VALUE = 0.001 ether; + + /// @notice Maximum amount of assets that is possible to withdraw in a single request + /// @dev Prevents accumulating too much funds per single request fulfillment in the future + /// @dev To withdraw larger amounts, it's recommended to split it to several requests + uint256 public constant MAX_WITHDRAWAL_ASSETS = 10_000 ether; /// @dev Return value for the `findCheckpointHint` method in case of no result uint256 internal constant NOT_FOUND = 0; @@ -75,8 +84,10 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea uint256 fromRequestId; /// @notice Stv rate at the moment of finalization (1e27 precision) uint256 stvRate; - /// @notice Steth share rate at the moment of finalization (1e27 precision) - uint256 stethShareRate; + /// @notice Steth share rate at the moment of finalization (1e18 precision) + uint128 stethShareRate; + /// @notice Withdrawal fee for the requests in this batch + uint64 withdrawalFee; } /// @notice Output format struct for `getWithdrawalStatus()` / `getWithdrawalStatuses()` methods @@ -120,6 +131,8 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea uint96 lastCheckpointIndex; /// @dev amount of ETH locked on contract for further claiming uint96 totalLockedAssets; + /// @dev withdrawal fee in wei + uint64 withdrawalFee; } // keccak256(abi.encode(uint256(keccak256("pool.storage.WithdrawalQueue")) - 1)) & ~bytes32(uint256(0xff)) @@ -152,11 +165,13 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea event WithdrawalClaimed( uint256 indexed requestId, address indexed owner, address indexed receiver, uint256 amountOfETH ); + event WithdrawalFeeSet(uint256 newFee); event EmergencyExitActivated(uint256 timestamp); error ZeroAddress(); - error RequestAmountTooSmall(uint256 amount); - error RequestAmountTooLarge(uint256 amount); + error RequestValueTooSmall(uint256 amount); + error RequestAssetsTooLarge(uint256 amount); + error WithdrawalFeeTooLarge(uint256 amount); error InvalidRequestId(uint256 requestId); error InvalidRange(uint256 start, uint256 end); error RequestAlreadyClaimed(uint256 requestId); @@ -167,6 +182,7 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea error CantSendValueRecipientMayHaveReverted(); error InvalidHint(uint256 hint); error InvalidEmergencyExitActivation(); + error CantBeSetInEmergencyExitMode(); error NoRequestsToFinalize(); error NotOwner(address _requestor, address _owner); error RebalancingIsNotSupported(); @@ -295,9 +311,12 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea if (_stethSharesToRebalance > 0 && !IS_REBALANCING_SUPPORTED) revert RebalancingIsNotSupported(); uint256 assets = POOL.previewRedeem(_stvToWithdraw); + uint256 value = _stethSharesToRebalance > 0 + ? Math.saturatingSub(assets, _getPooledEthBySharesRoundUp(_stethSharesToRebalance)) + : assets; - if (assets < MIN_WITHDRAWAL_AMOUNT) revert RequestAmountTooSmall(assets); - if (assets > MAX_WITHDRAWAL_AMOUNT) revert RequestAmountTooLarge(assets); + if (value < MIN_WITHDRAWAL_VALUE) revert RequestValueTooSmall(value); + if (assets > MAX_WITHDRAWAL_ASSETS) revert RequestAssetsTooLarge(assets); _transferForWithdrawalQueue(msg.sender, _stvToWithdraw, _stethSharesToRebalance); @@ -335,6 +354,43 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea } } + function _getPooledEthBySharesRoundUp(uint256 _stethShares) internal view returns (uint256 ethAmount) { + ethAmount = STETH.getPooledEthBySharesRoundUp(_stethShares); + } + + // ================================================================================= + // WITHDRAWAL FEES + // ================================================================================= + + /** + * @notice Set the withdrawal fee + * @param _fee The withdrawal fee in wei + * @dev Reverts if `_fee` is greater than `MAX_WITHDRAWAL_FEE` + * @dev 0 by default. Can be set to compensate for gas costs of finalization transactions + */ + function setWithdrawalFee(uint256 _fee) external { + _checkRole(FINALIZE_ROLE, msg.sender); + if (isEmergencyExitActivated()) revert CantBeSetInEmergencyExitMode(); + + _setWithdrawalFee(_fee); + } + + function _setWithdrawalFee(uint256 _fee) internal { + if (_fee > MAX_WITHDRAWAL_FEE) revert WithdrawalFeeTooLarge(_fee); + + _getWithdrawalQueueStorage().withdrawalFee = uint64(_fee); + emit WithdrawalFeeSet(_fee); + } + + /** + * @notice Get the current withdrawal fee + * @return fee The withdrawal fee in wei + * @dev Used to cover gas costs of finalization transactions + */ + function getWithdrawalFee() external view returns (uint256 fee) { + fee = _getWithdrawalQueueStorage().withdrawalFee; + } + // ================================================================================= // FINALIZATION // ================================================================================= @@ -348,7 +404,6 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea * @notice Finalize withdrawal requests * @param _maxRequests The maximum number of requests to finalize * @return finalizedRequests The number of requests that were finalized - * @dev MIN_WITHDRAWAL_AMOUNT is used to prevent DoS attacks by placing many small requests * @dev Reverts if there are no requests to finalize */ function finalize(uint256 _maxRequests) external returns (uint256 finalizedRequests) { @@ -367,6 +422,7 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea if (firstRequestIdToFinalize > lastRequestIdToFinalize) revert NoRequestsToFinalize(); + // Collect necessary data for finalization uint256 currentStvRate = calculateCurrentStvRate(); uint256 currentStethShareRate = calculateCurrentStethShareRate(); uint256 withdrawableValue = DASHBOARD.withdrawableValue(); @@ -377,47 +433,76 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea uint256 totalStvToBurn; uint256 totalStethShares; uint256 totalEthToClaim; + uint256 totalWithdrawalFee; uint256 maxStvToRebalance; - // Finalize all requests in the range + Checkpoint memory checkpoint = Checkpoint({ + fromRequestId: firstRequestIdToFinalize, + stvRate: currentStvRate, + stethShareRate: uint128(currentStethShareRate), + withdrawalFee: $.withdrawalFee + }); + + // Finalize requests one by one until conditions are met for (uint256 i = firstRequestIdToFinalize; i <= lastRequestIdToFinalize; ++i) { - WithdrawalRequest memory request = $.requests[i]; + WithdrawalRequest memory currRequest = $.requests[i]; WithdrawalRequest memory prevRequest = $.requests[i - 1]; - (uint256 stv, uint256 ethToClaim, uint256 stethSharesToRebalance, uint256 stethToRebalance) = - _calcRequestStats(prevRequest, request, currentStvRate, currentStethShareRate); - - uint256 stvToRebalance = - Math.mulDiv(stethToRebalance, E36_PRECISION_BASE, currentStvRate, Math.Rounding.Ceil); - - // Cap stvToRebalance to stv in the request, the rest will be socialized to users - if (stvToRebalance > stv) { - stvToRebalance = stv; - } + // Calculate amounts for the request + // - stv: amount of stv requested to withdraw + // - ethToClaim: amount of ETH that can be claimed for this request, excluding rebalancing and fees + // - stethSharesToRebalance: amount of steth shares to rebalance for this request + // - stethToRebalance: amount of steth corresponding to stethSharesToRebalance at the current rate + // - withdrawalFee: fee to be paid for this request + ( + uint256 stv, + uint256 ethToClaim, + uint256 stethSharesToRebalance, + uint256 stethToRebalance, + uint256 withdrawalFee + ) = _calcRequestAmounts(prevRequest, currRequest, checkpoint); + + // Handle rebalancing if applicable uint256 ethToRebalance; - - // Exceeding stETH (if any) are used to cover rebalancing need without withdrawing ETH from the vault - if (exceedingSteth > stethToRebalance) { - exceedingSteth -= stethToRebalance; - } else { - exceedingSteth = 0; - ethToRebalance = stethToRebalance - exceedingSteth; + uint256 stvToRebalance; + + if (stethToRebalance > 0) { + // Determine how much stv should be burned in exchange for the steth shares + stvToRebalance = Math.mulDiv(stethToRebalance, E36_PRECISION_BASE, currentStvRate, Math.Rounding.Ceil); + + // Cap stvToRebalance to requested stv. The rest (if any) will be socialized to users + // When creating a request, user transfers stv and liability to the withdrawal queue with the necessary reserve + // However, while waiting for finalization in the withdrawal queue, the position may become undercollateralized + // In this case, the loss is shared among all participants + if (stvToRebalance > stv) stvToRebalance = stv; + + // Exceeding minted stETH (if any) are used to cover rebalancing need without withdrawing ETH from the vault + // Thus, Exceeding minted stETH aims to be reduced to 0 + if (exceedingSteth > stethToRebalance) { + exceedingSteth -= stethToRebalance; + } else { + exceedingSteth = 0; + ethToRebalance = stethToRebalance - exceedingSteth; + } } if ( - // stop if insufficient ETH to cover this request - // stop if not enough time has passed since the request was created - // stop if the request was created after the latest report was published, at least one oracle report is required - ethToClaim > withdrawableValue || ethToClaim + ethToRebalance > availableBalance - || request.timestamp + MIN_WITHDRAWAL_DELAY_TIME_IN_SECONDS > block.timestamp - || request.timestamp > latestReportTimestamp + // Stop if insufficient withdrawable ETH to cover claimable ETH for this request + // Stop if insufficient available ETH to cover claimable and rebalancable ETH for this request + // Stop if not enough time has passed since the request was created + // Stop if the request was created after the latest report was published, at least one oracle report is required + (ethToClaim + withdrawalFee) > withdrawableValue + || (ethToClaim + ethToRebalance + withdrawalFee) > availableBalance + || currRequest.timestamp + MIN_WITHDRAWAL_DELAY_TIME_IN_SECONDS > block.timestamp + || currRequest.timestamp > latestReportTimestamp ) { break; } - withdrawableValue -= ethToClaim; - availableBalance -= (ethToClaim + ethToRebalance); + withdrawableValue -= (ethToClaim + withdrawalFee); + availableBalance -= (ethToClaim + withdrawalFee + ethToRebalance); totalEthToClaim += ethToClaim; + totalWithdrawalFee += withdrawalFee; totalStvToBurn += (stv - stvToRebalance); totalStethShares += stethSharesToRebalance; maxStvToRebalance += stvToRebalance; @@ -429,7 +514,9 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea // 1. Withdraw ETH from the vault to cover finalized requests and burn associated stv // Eth to claim or stv to burn could be 0 if all requests are going to be rebalanced // Rebalance cannot be done first because it will withdraw eth without unlocking it - if (totalEthToClaim > 0) DASHBOARD.withdraw(address(this), totalEthToClaim); + if (totalEthToClaim + totalWithdrawalFee > 0) { + DASHBOARD.withdraw(address(this), totalEthToClaim + totalWithdrawalFee); + } if (totalStvToBurn > 0) POOL.burnStvForWithdrawalQueue(totalStvToBurn); // 2. Rebalance steth shares by burning corresponding amount stv. Or socialize the losses if not enough stv @@ -437,6 +524,8 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea // So it may burn less stv than maxStvToRebalance because of new stv rate uint256 totalStvRebalanced; if (totalStethShares > 0) { + assert(IS_REBALANCING_SUPPORTED); + // Stv burning is limited at this point by maxStvToRebalance calculated above // to make sure that only stv of finalized requests is used for rebalancing totalStvRebalanced = POOL.rebalanceMintedStethShares(totalStethShares, maxStvToRebalance); @@ -446,7 +535,8 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea // The rebalancing may burn less stv than maxStvToRebalance because of: // - the changed stv rate after the first step // - accumulated rounding errors in maxStvToRebalance - // It's guaranteed that maxStvToRebalance >= totalStvRebalanced + // + // It's guaranteed by POOL.rebalanceMintedStethShares() that maxStvToRebalance >= totalStvRebalanced uint256 remainingStvForRebalance = maxStvToRebalance - totalStvRebalanced; if (remainingStvForRebalance > 0) { POOL.burnStvForWithdrawalQueue(remainingStvForRebalance); @@ -455,16 +545,20 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea lastFinalizedRequestId = lastFinalizedRequestId + finalizedRequests; - // Create checkpoint with stvRate and stethShareRate + // Store checkpoint with stvRate, stethShareRate and withdrawalFee uint256 lastCheckpointIndex = $.lastCheckpointIndex + 1; - $.checkpoints[lastCheckpointIndex] = Checkpoint({ - fromRequestId: firstRequestIdToFinalize, stvRate: currentStvRate, stethShareRate: currentStethShareRate - }); - + $.checkpoints[lastCheckpointIndex] = checkpoint; $.lastCheckpointIndex = uint96(lastCheckpointIndex); + $.lastFinalizedRequestId = uint96(lastFinalizedRequestId); $.totalLockedAssets += uint96(totalEthToClaim); + // Send withdrawal fee to the caller + if (totalWithdrawalFee > 0) { + (bool success,) = msg.sender.call{value: totalWithdrawalFee}(""); + if (!success) revert CantSendValueRecipientMayHaveReverted(); + } + emit WithdrawalsFinalized( firstRequestIdToFinalize, lastFinalizedRequestId, @@ -493,8 +587,8 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea * @return stvRate Current stv rate of the vault (1e27 precision) */ function calculateCurrentStvRate() public view returns (uint256 stvRate) { - uint256 totalStv = POOL.totalSupply(); // e27 precision - uint256 totalAssets = POOL.totalAssets(); // e18 precision + uint256 totalStv = POOL.totalSupply(); // 1e27 precision + uint256 totalAssets = POOL.totalAssets(); // 1e18 precision if (totalStv == 0) return E27_PRECISION_BASE; stvRate = (totalAssets * E36_PRECISION_BASE) / totalStv; @@ -505,7 +599,7 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea * @return stethShareRate Current stETH share rate (1e27 precision) */ function calculateCurrentStethShareRate() public view returns (uint256 stethShareRate) { - stethShareRate = STETH.getPooledEthBySharesRoundUp(E27_PRECISION_BASE); + stethShareRate = _getPooledEthBySharesRoundUp(E27_PRECISION_BASE); } // ================================================================================= @@ -821,37 +915,49 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea } WithdrawalRequest memory prevRequest = $.requests[_requestId - 1]; - (, claimableEth,,) = _calcRequestStats(prevRequest, _request, checkpoint.stvRate, checkpoint.stethShareRate); + (, claimableEth,,,) = _calcRequestAmounts(prevRequest, _request, checkpoint); } - function _calcRequestStats( + function _calcRequestAmounts( WithdrawalRequest memory _prevRequest, WithdrawalRequest memory _request, - uint256 finalizationStvRate, - uint256 stethShareRate + Checkpoint memory checkpoint ) internal pure - returns (uint256 stv, uint256 assetsToClaim, uint256 stethSharesToRebalance, uint256 assetsToRebalance) + returns ( + uint256 stv, + uint256 assetsToClaim, + uint256 stethSharesToRebalance, + uint256 assetsToRebalance, + uint256 withdrawalFee + ) { stv = _request.cumulativeStv - _prevRequest.cumulativeStv; stethSharesToRebalance = _request.cumulativeStethShares - _prevRequest.cumulativeStethShares; assetsToClaim = _request.cumulativeAssets - _prevRequest.cumulativeAssets; + // Calculate stv rate at the time of request creation uint256 requestStvRate = (assetsToClaim * E36_PRECISION_BASE) / stv; // Apply discount if the request stv rate is above the finalization stv rate - if (requestStvRate > finalizationStvRate) { - assetsToClaim = Math.mulDiv(stv, finalizationStvRate, E36_PRECISION_BASE, Math.Rounding.Floor); + if (requestStvRate > checkpoint.stvRate) { + assetsToClaim = Math.mulDiv(stv, checkpoint.stvRate, E36_PRECISION_BASE, Math.Rounding.Floor); } if (stethSharesToRebalance > 0) { assetsToRebalance = - Math.mulDiv(stethSharesToRebalance, stethShareRate, E27_PRECISION_BASE, Math.Rounding.Ceil); + Math.mulDiv(stethSharesToRebalance, checkpoint.stethShareRate, E27_PRECISION_BASE, Math.Rounding.Ceil); // Decrease assets to claim by the amount of assets to rebalance assetsToClaim = Math.saturatingSub(assetsToClaim, assetsToRebalance); } + + // Apply withdrawal fee + if (checkpoint.withdrawalFee > 0) { + withdrawalFee = Math.min(assetsToClaim, checkpoint.withdrawalFee); + assetsToClaim -= withdrawalFee; + } } // ================================================================================= @@ -953,8 +1059,9 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea revert InvalidEmergencyExitActivation(); } - $.emergencyExitActivationTimestamp = uint40(block.timestamp); + _setWithdrawalFee(MAX_WITHDRAWAL_FEE); + $.emergencyExitActivationTimestamp = uint40(block.timestamp); emit EmergencyExitActivated($.emergencyExitActivationTimestamp); } diff --git a/test/integration/stv-pool.test.sol b/test/integration/stv-pool.test.sol index 9cbecec..7c7be15 100644 --- a/test/integration/stv-pool.test.sol +++ b/test/integration/stv-pool.test.sol @@ -16,7 +16,7 @@ contract StvPoolTest is StvPoolHarness { // Deploy pool system WrapperContext memory ctx = _deployStvPool(false, 0); - // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_AMOUNT) + // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_VALUE) uint256 depositAmount = 0.01 ether; uint256 expectedStv = ctx.pool.previewDeposit(depositAmount); vm.prank(USER1); @@ -50,7 +50,7 @@ contract StvPoolTest is StvPoolHarness { // Deploy pool system WrapperContext memory ctx = _deployStvPool(false, 0); - // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_AMOUNT) + // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_VALUE) uint256 depositAmount = 0.01 ether; uint256 expectedStv = ctx.pool.previewDeposit(depositAmount); vm.prank(USER1); @@ -89,7 +89,7 @@ contract StvPoolTest is StvPoolHarness { // Deploy pool system WrapperContext memory ctx = _deployStvPool(false, 0); - // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_AMOUNT) + // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_VALUE) uint256 depositAmount = 0.01 ether; uint256 expectedStv = ctx.pool.previewDeposit(depositAmount); vm.prank(USER1); @@ -131,8 +131,8 @@ contract StvPoolTest is StvPoolHarness { // Deploy pool system WrapperContext memory ctx = _deployStvPool(false, 0); - // 1) USER1 deposits 0.0001 ether (above MIN_WITHDRAWAL_AMOUNT) - uint256 depositAmount = 0.0001 ether; + // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_VALUE) + uint256 depositAmount = 0.01 ether; uint256 expectedStv = ctx.pool.previewDeposit(depositAmount); vm.prank(USER1); ctx.pool.depositETH{value: depositAmount}(USER1, address(0)); @@ -166,7 +166,7 @@ contract StvPoolTest is StvPoolHarness { // Simulate a +3% vault value report before deposit reportVaultValueChangeNoFees(ctx, 10300); // +3% - // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_AMOUNT) + // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_VALUE) uint256 depositAmount = 0.01 ether; uint256 expectedStv = ctx.pool.previewDeposit(depositAmount); vm.prank(USER1); diff --git a/test/integration/stv-steth-pool.test.sol b/test/integration/stv-steth-pool.test.sol index e15eefd..392970b 100644 --- a/test/integration/stv-steth-pool.test.sol +++ b/test/integration/stv-steth-pool.test.sol @@ -454,7 +454,7 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // // Step 1: User1 deposits // - uint256 user1Deposit = 2 * ctx.withdrawalQueue.MIN_WITHDRAWAL_AMOUNT() * 100; // * 100 to have +1% rewards enough for min withdrawal + uint256 user1Deposit = 2 * ctx.withdrawalQueue.MIN_WITHDRAWAL_VALUE() * 100; // * 100 to have +1% rewards enough for min withdrawal uint256 sharesForDeposit = _calcMaxMintableStShares(ctx, user1Deposit); vm.prank(USER1); @@ -562,14 +562,14 @@ contract StvStETHPoolTest is StvStETHPoolHarness { assertGt(w.balanceOf(USER1), stvFor1Wei, "USER1 stv balance should be greater than stvFor1Wei"); vm.startPrank(USER1); - vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.RequestAmountTooSmall.selector, 1 wei)); + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.RequestValueTooSmall.selector, 1 wei)); ctx.withdrawalQueue.requestWithdrawal(USER1, stvFor1Wei, 0); vm.stopPrank(); // // Step 2.2: User1 withdraws stv with burning stethShares // - uint256 stvForMinWithdrawal = w.previewWithdraw(ctx.withdrawalQueue.MIN_WITHDRAWAL_AMOUNT()); + uint256 stvForMinWithdrawal = w.previewWithdraw(ctx.withdrawalQueue.MIN_WITHDRAWAL_VALUE()); uint256 stethSharesToBurn = w.stethSharesToBurnForStvOf(USER1, stvForMinWithdrawal); vm.startPrank(USER1); diff --git a/test/unit/withdrawal-queue/EmergencyExit.test.sol b/test/unit/withdrawal-queue/EmergencyExit.test.sol index f356574..2bd0d01 100644 --- a/test/unit/withdrawal-queue/EmergencyExit.test.sol +++ b/test/unit/withdrawal-queue/EmergencyExit.test.sol @@ -84,6 +84,27 @@ contract EmergencyExitTest is Test, SetupWithdrawalQueue { assertTrue(withdrawalQueue.isEmergencyExitActivated()); } + function test_EmergencyExit_SetsMaxWithdrawalFee() public { + withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); + + // Make queue stuck + vm.warp(block.timestamp + withdrawalQueue.MAX_ACCEPTABLE_WQ_FINALIZATION_TIME_IN_SECONDS() + 1); + assertTrue(withdrawalQueue.isWithdrawalQueueStuck()); + + // Anyone can activate emergency exit + vm.prank(userAlice); + vm.expectEmit(true, false, false, true); + emit WithdrawalQueue.EmergencyExitActivated(block.timestamp); + + // Initially fee is zero + assertEq(withdrawalQueue.getWithdrawalFee(), 0); + + withdrawalQueue.activateEmergencyExit(); + + // After activation fee is set to max + assertEq(withdrawalQueue.getWithdrawalFee(), withdrawalQueue.MAX_WITHDRAWAL_FEE()); + } + function test_EmergencyExit_RevertWhenNotStuck() public { withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); diff --git a/test/unit/withdrawal-queue/FeeConfig.test.sol b/test/unit/withdrawal-queue/FeeConfig.test.sol new file mode 100644 index 0000000..eef0e38 --- /dev/null +++ b/test/unit/withdrawal-queue/FeeConfig.test.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25; + +import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Test} from "forge-std/Test.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; + +contract FeeConfigTest is Test, SetupWithdrawalQueue { + // Default value + + function test_GetWithdrawalFee_DefaultZero() public view { + assertEq(withdrawalQueue.getWithdrawalFee(), 0); + } + + // Setter + + function test_SetWithdrawalFee_UpdatesValue() public { + uint256 fee = 0.0001 ether; + + vm.prank(finalizeRoleHolder); + withdrawalQueue.setWithdrawalFee(fee); + assertEq(withdrawalQueue.getWithdrawalFee(), fee); + } + + function test_SetWithdrawalFee_RevertAboveMax() public { + uint256 fee = withdrawalQueue.MAX_WITHDRAWAL_FEE() + 1; + + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.WithdrawalFeeTooLarge.selector, fee)); + vm.prank(finalizeRoleHolder); + withdrawalQueue.setWithdrawalFee(fee); + } + + function test_SetWithdrawalFee_MaxValueCanBeSet() public { + uint256 fee = withdrawalQueue.MAX_WITHDRAWAL_FEE(); + + vm.prank(finalizeRoleHolder); + withdrawalQueue.setWithdrawalFee(fee); + assertEq(withdrawalQueue.getWithdrawalFee(), fee); + } + + // Access control + + function test_SetWithdrawalFee_CanBeCalledByFinalizeRole() public { + vm.prank(finalizeRoleHolder); + withdrawalQueue.setWithdrawalFee(0.0001 ether); + } + + function test_SetWithdrawalFee_CantBeCalledStranger() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), withdrawalQueue.FINALIZE_ROLE() + ) + ); + withdrawalQueue.setWithdrawalFee(0.0001 ether); + } +} diff --git a/test/unit/withdrawal-queue/FeeFinalization.test.sol b/test/unit/withdrawal-queue/FeeFinalization.test.sol new file mode 100644 index 0000000..6ce8f38 --- /dev/null +++ b/test/unit/withdrawal-queue/FeeFinalization.test.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25; + +import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {Test} from "forge-std/Test.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; + +contract FeeFinalizationTest is Test, SetupWithdrawalQueue { + function setUp() public override { + super.setUp(); + + vm.deal(address(this), 200_000 ether); + vm.deal(finalizeRoleHolder, 10 ether); + + pool.depositETH{value: 100_000 ether}(address(this), address(0)); + } + + function _setWithdrawalFee(uint256 fee) internal { + vm.prank(finalizeRoleHolder); + withdrawalQueue.setWithdrawalFee(fee); + } + + function test_FinalizeFee_ZeroFeeDoesNotPayFinalizer() public { + uint256 initialBalance = finalizeRoleHolder.balance; + _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); + + assertEq(finalizeRoleHolder.balance, initialBalance); + } + + function test_FinalizeFee_PaysFinalizerWhenSet() public { + uint256 fee = 0.0005 ether; + uint256 initialBalance = finalizeRoleHolder.balance; + + _setWithdrawalFee(fee); + _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); + + assertEq(finalizeRoleHolder.balance, initialBalance + fee); + } + + function test_FinalizeFee_ReducesClaimByFee() public { + uint256 fee = 0.0005 ether; + _setWithdrawalFee(fee); + + uint256 stvToRequest = 10 ** STV_DECIMALS; + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, 0); + uint256 expectedAssets = pool.previewRedeem(stvToRequest); + _finalizeRequests(1); + + uint256 balanceBefore = address(this).balance; + uint256 claimed = withdrawalQueue.claimWithdrawal(address(this), requestId); + + assertEq(claimed, expectedAssets - fee); + assertEq(address(this).balance, balanceBefore + claimed); + } + + function test_FinalizeFee_ReducesClaimableByFee() public { + uint256 fee = 0.0005 ether; + _setWithdrawalFee(fee); + + uint256 stvToRequest = 10 ** STV_DECIMALS; + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, 0); + uint256 expectedAssets = pool.previewRedeem(stvToRequest); + _finalizeRequests(1); + + assertEq(withdrawalQueue.getClaimableEther(requestId), expectedAssets - fee); + } + + function test_FinalizeFee_RequestWithRebalance() public { + uint256 fee = 0.0005 ether; + _setWithdrawalFee(fee); + + uint256 mintedStethShares = 10 ** ASSETS_DECIMALS; + uint256 stvToRequest = 2 * 10 ** STV_DECIMALS; + pool.mintStethShares(mintedStethShares); + + uint256 totalAssets = pool.previewRedeem(stvToRequest); + uint256 assetsToRebalance = pool.STETH().getPooledEthBySharesRoundUp(mintedStethShares); + uint256 expectedClaimable = totalAssets - assetsToRebalance - fee; + assertGt(expectedClaimable, 0); + + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, mintedStethShares); + _finalizeRequests(1); + + assertEq(withdrawalQueue.getClaimableEther(requestId), expectedClaimable); + } + + function test_FinalizeFee_FeeCapsToRemainingAssets() public { + uint256 fee = withdrawalQueue.MAX_WITHDRAWAL_FEE(); + _setWithdrawalFee(fee); + + uint256 stvToRequest = (10 ** STV_DECIMALS / 1 ether) * fee; + uint256 totalAssets = pool.previewRedeem(stvToRequest); + assertEq(totalAssets, fee); + + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, 0); + dashboard.mock_simulateRewards(-int256(1 ether)); + uint256 expectedAssets = pool.previewRedeem(stvToRequest); + + uint256 finalizerBalanceBefore = finalizeRoleHolder.balance; + _finalizeRequests(1); + uint256 finalizerBalanceAfter = finalizeRoleHolder.balance; + + assertGt(finalizerBalanceAfter, finalizerBalanceBefore); + assertLt(finalizerBalanceAfter - finalizerBalanceBefore, fee); + + assertEq(withdrawalQueue.getClaimableEther(requestId), 0); + } + + // Receive ETH for claiming tests + receive() external payable {} +} diff --git a/test/unit/withdrawal-queue/RequestCreation.test.sol b/test/unit/withdrawal-queue/RequestCreation.test.sol index 199b86d..e4e7d84 100644 --- a/test/unit/withdrawal-queue/RequestCreation.test.sol +++ b/test/unit/withdrawal-queue/RequestCreation.test.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; -import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import {Test} from "forge-std/Test.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; contract RequestCreationTest is Test, SetupWithdrawalQueue { @@ -179,20 +179,29 @@ contract RequestCreationTest is Test, SetupWithdrawalQueue { withdrawalQueue.requestWithdrawalBatch(address(this), stvAmounts, stethShares); } - function test_RequestWithdrawal_RevertOnTooSmallAmount() public { - uint256 tinyStvAmount = pool.previewWithdraw(withdrawalQueue.MIN_WITHDRAWAL_AMOUNT()) - 1; + function test_RequestWithdrawal_RevertOnTooSmallValue() public { + uint256 tinyStvAmount = pool.previewWithdraw(withdrawalQueue.MIN_WITHDRAWAL_VALUE()) - 1; uint256 expectedAssets = pool.previewRedeem(tinyStvAmount); - vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.RequestAmountTooSmall.selector, expectedAssets)); + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.RequestValueTooSmall.selector, expectedAssets)); withdrawalQueue.requestWithdrawal(address(this), tinyStvAmount, 0); } + function test_RequestWithdrawal_RevertOnTooSmallValueWithRebalance() public { + uint256 minStvAmount = pool.previewWithdraw(withdrawalQueue.MIN_WITHDRAWAL_VALUE()); + uint256 minMintedShares = 1; + uint256 expectedAssets = pool.previewRedeem(minStvAmount) - steth.getPooledEthBySharesRoundUp(minMintedShares); + + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.RequestValueTooSmall.selector, expectedAssets)); + withdrawalQueue.requestWithdrawal(address(this), minStvAmount, minMintedShares); + } + function test_RequestWithdrawal_RevertOnTooLargeAmount() public { uint256 extraAssetsWei = 10 ** (STV_DECIMALS - ASSETS_DECIMALS); - uint256 hugeStvAmount = pool.previewWithdraw(withdrawalQueue.MAX_WITHDRAWAL_AMOUNT()) + extraAssetsWei; + uint256 hugeStvAmount = pool.previewWithdraw(withdrawalQueue.MAX_WITHDRAWAL_ASSETS()) + extraAssetsWei; uint256 expectedAssets = pool.previewRedeem(hugeStvAmount); - vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.RequestAmountTooLarge.selector, expectedAssets)); + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.RequestAssetsTooLarge.selector, expectedAssets)); withdrawalQueue.requestWithdrawal(address(this), hugeStvAmount, 0); } @@ -218,8 +227,8 @@ contract RequestCreationTest is Test, SetupWithdrawalQueue { } function test_RequestWithdrawal_ExactMinAmount() public { - // Calculate STV amount needed for MIN_WITHDRAWAL_AMOUNT - uint256 minAmount = withdrawalQueue.MIN_WITHDRAWAL_AMOUNT(); + // Calculate STV amount needed for MAX_WITHDRAWAL_ASSETS + uint256 minAmount = withdrawalQueue.MAX_WITHDRAWAL_ASSETS(); uint256 stvAmount = pool.previewWithdraw(minAmount); // This should succeed @@ -228,8 +237,8 @@ contract RequestCreationTest is Test, SetupWithdrawalQueue { } function test_RequestWithdrawal_ExactMaxAmount() public { - // Calculate STV amount needed for MAX_WITHDRAWAL_AMOUNT - uint256 maxAmount = withdrawalQueue.MAX_WITHDRAWAL_AMOUNT(); + // Calculate STV amount needed for MAX_WITHDRAWAL_ASSETS + uint256 maxAmount = withdrawalQueue.MAX_WITHDRAWAL_ASSETS(); uint256 stvAmount = pool.previewWithdraw(maxAmount); // This should succeed From 4c72632e56761697583bd06eba100be786dff74d Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Sat, 1 Nov 2025 14:33:00 +0300 Subject: [PATCH 02/20] docs: fix WithdrawalQueue doc comments --- src/WithdrawalQueue.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/WithdrawalQueue.sol b/src/WithdrawalQueue.sol index e2bb790..10d3ba7 100644 --- a/src/WithdrawalQueue.sol +++ b/src/WithdrawalQueue.sol @@ -90,7 +90,7 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea uint64 withdrawalFee; } - /// @notice Output format struct for `getWithdrawalStatus()` / `getWithdrawalStatuses()` methods + /// @notice Output format struct for `getWithdrawalStatus()` / `getWithdrawalStatusBatch()` methods struct WithdrawalRequestStatus { /// @notice Amount of stv locked for this request uint256 amountOfStv; @@ -405,6 +405,7 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea * @param _maxRequests The maximum number of requests to finalize * @return finalizedRequests The number of requests that were finalized * @dev Reverts if there are no requests to finalize + * @dev In emergency exit mode, anyone can finalize without restrictions */ function finalize(uint256 _maxRequests) external returns (uint256 finalizedRequests) { if (!isEmergencyExitActivated()) { From 3c0aabef7fc6cf37771c0687dd47cc6e5a1ca829 Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Sat, 1 Nov 2025 14:33:21 +0300 Subject: [PATCH 03/20] test: add WithdrawalQueue initialization unit tests --- .../withdrawal-queue/Initialization.test.sol | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 test/unit/withdrawal-queue/Initialization.test.sol diff --git a/test/unit/withdrawal-queue/Initialization.test.sol b/test/unit/withdrawal-queue/Initialization.test.sol new file mode 100644 index 0000000..b3e81c2 --- /dev/null +++ b/test/unit/withdrawal-queue/Initialization.test.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {Test} from "forge-std/Test.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; +import {OssifiableProxy} from "src/proxy/OssifiableProxy.sol"; + +contract InitializationTest is Test { + WithdrawalQueue internal withdrawalQueueProxy; + WithdrawalQueue internal withdrawalQueueImpl; + address internal owner; + address internal finalizeRoleHolder; + + function setUp() public { + owner = makeAddr("owner"); + finalizeRoleHolder = makeAddr("finalizeRoleHolder"); + + withdrawalQueueImpl = new WithdrawalQueue( + makeAddr("pool"), + makeAddr("dashboard"), + makeAddr("vaultHub"), + makeAddr("steth"), + makeAddr("stakingVault"), + makeAddr("lazyOracle"), + 7 days, + 1 days, + true + ); + OssifiableProxy proxy = new OssifiableProxy(address(withdrawalQueueImpl), owner, ""); + withdrawalQueueProxy = WithdrawalQueue(payable(proxy)); + } + + function test_Initialize_RevertOnImplementation() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + withdrawalQueueImpl.initialize(address(0), finalizeRoleHolder); + } + + function test_Initialize_RevertWhenAdminZero() public { + vm.expectRevert(WithdrawalQueue.ZeroAddress.selector); + withdrawalQueueProxy.initialize(address(0), finalizeRoleHolder); + } + + function test_Initialize_RevertWhenCalledTwice() public { + withdrawalQueueProxy.initialize(owner, finalizeRoleHolder); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + withdrawalQueueProxy.initialize(owner, finalizeRoleHolder); + } +} From bb3d9729473e7812c46d85a3c503c13792358359 Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Sat, 1 Nov 2025 14:35:49 +0300 Subject: [PATCH 04/20] test: add WithdrawalQueue tests for pause/resume access control and unfinalized stETH shares --- .../withdrawal-queue/RequestCreation.test.sol | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/unit/withdrawal-queue/RequestCreation.test.sol b/test/unit/withdrawal-queue/RequestCreation.test.sol index e4e7d84..94983c6 100644 --- a/test/unit/withdrawal-queue/RequestCreation.test.sol +++ b/test/unit/withdrawal-queue/RequestCreation.test.sol @@ -2,6 +2,7 @@ pragma solidity >=0.8.25; import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {Test} from "forge-std/Test.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; @@ -214,6 +215,35 @@ contract RequestCreationTest is Test, SetupWithdrawalQueue { withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); } + function test_Pause_RevertWhenCallerUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), withdrawalQueue.PAUSE_ROLE() + ) + ); + withdrawalQueue.pause(); + } + + function test_Resume_RevertWhenCallerUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), withdrawalQueue.RESUME_ROLE() + ) + ); + withdrawalQueue.resume(); + } + + function test_Resume_AllowsRequestsAfterPause() public { + vm.prank(pauseRoleHolder); + withdrawalQueue.pause(); + + vm.prank(resumeRoleHolder); + withdrawalQueue.resume(); + + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); + assertEq(requestId, 1); + } + // Edge cases function test_RequestWithdrawal_ReversOnZeroRecipient() public { @@ -298,6 +328,20 @@ contract RequestCreationTest is Test, SetupWithdrawalQueue { assertEq(withdrawalQueue.unfinalizedAssets(), 0); } + function test_UnfinalizedStats_TrackStethShares() public { + uint256 stvAmount = 2 * 10 ** STV_DECIMALS; + uint256 mintedShares = 10 ** ASSETS_DECIMALS; + + pool.mintStethShares(mintedShares); + withdrawalQueue.requestWithdrawal(address(this), stvAmount, mintedShares); + + assertEq(withdrawalQueue.unfinalizedStethShares(), mintedShares); + + _finalizeRequests(1); + + assertEq(withdrawalQueue.unfinalizedStethShares(), 0); + } + // Receive function to accept ETH refunds receive() external payable {} } From 3f00a97e90d2242335fec3a192d72fc0fe7e5826 Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Sat, 1 Nov 2025 14:44:45 +0300 Subject: [PATCH 05/20] test: add WithdrawalQueue tests for rebalancing-disabled request withdrawal reverts --- .../RebalancingDisabled.test.sol | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 test/unit/withdrawal-queue/RebalancingDisabled.test.sol diff --git a/test/unit/withdrawal-queue/RebalancingDisabled.test.sol b/test/unit/withdrawal-queue/RebalancingDisabled.test.sol new file mode 100644 index 0000000..f7c6430 --- /dev/null +++ b/test/unit/withdrawal-queue/RebalancingDisabled.test.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {StvStETHPool} from "src/StvStETHPool.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; +import {OssifiableProxy} from "src/proxy/OssifiableProxy.sol"; +import {MockDashboard, MockDashboardFactory} from "test/mocks/MockDashboard.sol"; +import {MockLazyOracle} from "test/mocks/MockLazyOracle.sol"; +import {MockStETH} from "test/mocks/MockStETH.sol"; + +contract RebalancingDisabledTest is Test { + WithdrawalQueue internal withdrawalQueue; + + address internal owner; + address internal finalizeRoleHolder; + + function setUp() public { + owner = makeAddr("owner"); + finalizeRoleHolder = makeAddr("finalizeRoleHolder"); + + WithdrawalQueue impl = new WithdrawalQueue( + makeAddr("pool"), + makeAddr("dashboard"), + makeAddr("vaultHub"), + makeAddr("steth"), + makeAddr("stakingVault"), + makeAddr("lazyOracle"), + 7 days, + 1 days, + false + ); + OssifiableProxy proxy = new OssifiableProxy(address(impl), owner, ""); + withdrawalQueue = WithdrawalQueue(payable(proxy)); + } + + function test_RequestWithdrawal_RevertWhenRebalancingDisabled() public { + vm.expectRevert(WithdrawalQueue.RebalancingIsNotSupported.selector); + withdrawalQueue.requestWithdrawal(address(this), 1, 1); + } + + function test_RequestWithdrawalBatch_RevertWhenRebalancingDisabled() public { + uint256[] memory stvAmounts = new uint256[](1); + stvAmounts[0] = 1; + + uint256[] memory stethShares = new uint256[](1); + stethShares[0] = 1; + + vm.expectRevert(WithdrawalQueue.RebalancingIsNotSupported.selector); + withdrawalQueue.requestWithdrawalBatch(address(this), stvAmounts, stethShares); + } +} From 9774a6a33e415b26efef8bffacf28ef50c91b5da Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Sat, 1 Nov 2025 14:45:01 +0300 Subject: [PATCH 06/20] test: add InitialPause tests asserting implementation is paused but proxy is unpaused --- .../withdrawal-queue/InitialPause.test.sol | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 test/unit/withdrawal-queue/InitialPause.test.sol diff --git a/test/unit/withdrawal-queue/InitialPause.test.sol b/test/unit/withdrawal-queue/InitialPause.test.sol new file mode 100644 index 0000000..17759bd --- /dev/null +++ b/test/unit/withdrawal-queue/InitialPause.test.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {Test} from "forge-std/Test.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; +import {OssifiableProxy} from "src/proxy/OssifiableProxy.sol"; + +contract InitialPauseTest is Test { + WithdrawalQueue internal withdrawalQueueProxy; + WithdrawalQueue internal withdrawalQueueImpl; + address internal owner; + address internal finalizeRoleHolder; + + function setUp() public { + owner = makeAddr("owner"); + finalizeRoleHolder = makeAddr("finalizeRoleHolder"); + + withdrawalQueueImpl = new WithdrawalQueue( + makeAddr("pool"), + makeAddr("dashboard"), + makeAddr("vaultHub"), + makeAddr("steth"), + makeAddr("stakingVault"), + makeAddr("lazyOracle"), + 7 days, + 1 days, + true + ); + OssifiableProxy proxy = new OssifiableProxy(address(withdrawalQueueImpl), owner, ""); + withdrawalQueueProxy = WithdrawalQueue(payable(proxy)); + } + + function test_InitialPause_ImplementationIsPaused() public view { + assertTrue(withdrawalQueueImpl.paused()); + } + + function test_InitialPause_ProxyIsNotPaused() public view { + assertFalse(withdrawalQueueProxy.paused()); + } +} From 3f55a17a1a99f1e527ac7ee80f559b31679af56d Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Sat, 1 Nov 2025 14:48:18 +0300 Subject: [PATCH 07/20] test: setWithdrawalFee reverts in emergency exit --- test/unit/withdrawal-queue/FeeConfig.test.sol | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/unit/withdrawal-queue/FeeConfig.test.sol b/test/unit/withdrawal-queue/FeeConfig.test.sol index eef0e38..3af4b1c 100644 --- a/test/unit/withdrawal-queue/FeeConfig.test.sol +++ b/test/unit/withdrawal-queue/FeeConfig.test.sol @@ -7,6 +7,12 @@ import {Test} from "forge-std/Test.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; contract FeeConfigTest is Test, SetupWithdrawalQueue { + function setUp() public override { + super.setUp(); + + pool.depositETH{value: 1_000 ether}(address(this), address(0)); + } + // Default value function test_GetWithdrawalFee_DefaultZero() public view { @@ -54,4 +60,15 @@ contract FeeConfigTest is Test, SetupWithdrawalQueue { ); withdrawalQueue.setWithdrawalFee(0.0001 ether); } + + function test_SetWithdrawalFee_RevertInEmergencyExit() public { + withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); + + vm.warp(block.timestamp + withdrawalQueue.MAX_ACCEPTABLE_WQ_FINALIZATION_TIME_IN_SECONDS() + 1); + withdrawalQueue.activateEmergencyExit(); + + vm.prank(finalizeRoleHolder); + vm.expectRevert(WithdrawalQueue.CantBeSetInEmergencyExitMode.selector); + withdrawalQueue.setWithdrawalFee(0.0001 ether); + } } From 403297e20042a45f3d713d04de752cabe843057b Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Sat, 1 Nov 2025 15:02:20 +0300 Subject: [PATCH 08/20] test: add finalization edge-case tests --- .../withdrawal-queue/Finalization.test.sol | 61 +++++++++++++++++-- .../withdrawal-queue/SetupWithdrawalQueue.sol | 22 +++---- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/test/unit/withdrawal-queue/Finalization.test.sol b/test/unit/withdrawal-queue/Finalization.test.sol index 6965b6f..4357f09 100644 --- a/test/unit/withdrawal-queue/Finalization.test.sol +++ b/test/unit/withdrawal-queue/Finalization.test.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; -import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; -import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Test} from "forge-std/Test.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; contract FinalizationTest is Test, SetupWithdrawalQueue { @@ -132,9 +132,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { // Try to finalize without proper role vm.expectRevert( abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, - userAlice, - withdrawalQueue.FINALIZE_ROLE() + IAccessControl.AccessControlUnauthorizedAccount.selector, userAlice, withdrawalQueue.FINALIZE_ROLE() ) ); vm.prank(userAlice); @@ -306,6 +304,41 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { withdrawalQueue.finalize(1); } + function test_Finalize_RevertWhenFinalizerCannotReceiveFee() public { + uint256 fee = 0.0001 ether; + vm.prank(finalizeRoleHolder); + withdrawalQueue.setWithdrawalFee(fee); + + RevertingFinalizer finalizer = new RevertingFinalizer(withdrawalQueue); + bytes32 finalizeRole = withdrawalQueue.FINALIZE_ROLE(); + + vm.prank(owner); + withdrawalQueue.grantRole(finalizeRole, address(finalizer)); + + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); + assertEq(requestId, 1); + + _warpAndMockOracleReport(); + + vm.expectRevert(WithdrawalQueue.CantSendValueRecipientMayHaveReverted.selector); + finalizer.callFinalize(1); + } + + function test_Finalize_RevertWhenWithdrawableInsufficientButAvailableEnough() public { + uint256 stvToRequest = 10 ** STV_DECIMALS; + uint256 expectedAssets = pool.previewRedeem(stvToRequest); + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, 0); + + assertEq(requestId, 1); + + _warpAndMockOracleReport(); + dashboard.mock_setLocked(pool.totalAssets() - expectedAssets + 1); + + vm.prank(finalizeRoleHolder); + vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); + withdrawalQueue.finalize(1); + } + // Checkpoint Tests function test_Finalize_CreatesCheckpoint() public { @@ -386,3 +419,19 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { assertEq(withdrawalQueue.getClaimableEther(requestId), expectedEth); } } + +contract RevertingFinalizer { + WithdrawalQueue public immutable withdrawalQueue; + + constructor(WithdrawalQueue _withdrawalQueue) { + withdrawalQueue = _withdrawalQueue; + } + + function callFinalize(uint256 maxRequests) external { + withdrawalQueue.finalize(maxRequests); + } + + receive() external payable { + revert("cannot receive"); + } +} diff --git a/test/unit/withdrawal-queue/SetupWithdrawalQueue.sol b/test/unit/withdrawal-queue/SetupWithdrawalQueue.sol index 0c395bb..99202cc 100644 --- a/test/unit/withdrawal-queue/SetupWithdrawalQueue.sol +++ b/test/unit/withdrawal-queue/SetupWithdrawalQueue.sol @@ -2,11 +2,11 @@ pragma solidity >=0.8.25; import {Test} from "forge-std/Test.sol"; -import {OssifiableProxy} from "src/proxy/OssifiableProxy.sol"; -import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; -import {MockLazyOracle} from "test/mocks/MockLazyOracle.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; +import {OssifiableProxy} from "src/proxy/OssifiableProxy.sol"; import {MockDashboard, MockDashboardFactory} from "test/mocks/MockDashboard.sol"; +import {MockLazyOracle} from "test/mocks/MockLazyOracle.sol"; import {MockStETH} from "test/mocks/MockStETH.sol"; abstract contract SetupWithdrawalQueue is Test { @@ -55,12 +55,7 @@ abstract contract SetupWithdrawalQueue is Test { // Deploy StvStETHPool proxy with temporary implementation StvStETHPool tempImpl = new StvStETHPool( - address(dashboard), - false, - reserveRatioGapBP, - address(0), - address(0), - keccak256("test.wq.pool") + address(dashboard), false, reserveRatioGapBP, address(0), address(0), keccak256("test.wq.pool") ); OssifiableProxy poolProxy = new OssifiableProxy(address(tempImpl), owner, ""); pool = StvStETHPool(payable(poolProxy)); @@ -117,9 +112,14 @@ abstract contract SetupWithdrawalQueue is Test { } function _finalizeRequests(uint256 _maxRequests) internal { - lazyOracle.mock__updateLatestReportTimestamp(block.timestamp); - vm.warp(MIN_WITHDRAWAL_DELAY_TIME + 1 + block.timestamp); + _warpAndMockOracleReport(); + vm.prank(finalizeRoleHolder); withdrawalQueue.finalize(_maxRequests); } + + function _warpAndMockOracleReport() internal { + lazyOracle.mock__updateLatestReportTimestamp(block.timestamp); + vm.warp(MIN_WITHDRAWAL_DELAY_TIME + 1 + block.timestamp); + } } From d339cb57fdb09ace4f0d6f31772525190d0c54e3 Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Sat, 1 Nov 2025 15:34:59 +0300 Subject: [PATCH 09/20] test: add SocializedLoss event emission test for rebalance finalization; reorder imports --- test/unit/withdrawal-queue/Rebalance.test.sol | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/test/unit/withdrawal-queue/Rebalance.test.sol b/test/unit/withdrawal-queue/Rebalance.test.sol index 31527ef..18e1170 100644 --- a/test/unit/withdrawal-queue/Rebalance.test.sol +++ b/test/unit/withdrawal-queue/Rebalance.test.sol @@ -1,8 +1,9 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {Test} from "forge-std/Test.sol"; +import {StvStETHPool} from "src/StvStETHPool.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; contract FinalizationTest is Test, SetupWithdrawalQueue { @@ -179,4 +180,26 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { assertEq(pool.totalMintedStethShares(), 0); assertEq(pool.totalExceedingMintedStethShares(), 0); } + + function test_RebalanceFinalization_SocializedLossEmitsEvent() public { + uint256 mintedStethShares = 10 ** ASSETS_DECIMALS; + uint256 stvToRequest = 2 * 10 ** STV_DECIMALS; + + pool.mintStethShares(mintedStethShares); + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, mintedStethShares); + + // Apply large penalty so position becomes undercollateralized + dashboard.mock_simulateRewards(-90_000 ether); + assertGt(steth.getPooledEthBySharesRoundUp(mintedStethShares), pool.previewRedeem(stvToRequest)); + + _warpAndMockOracleReport(); + + vm.expectEmit(true, true, true, false, address(pool)); + emit StvStETHPool.SocializedLoss(0, 0); + + vm.prank(finalizeRoleHolder); + withdrawalQueue.finalize(1); + + assertEq(withdrawalQueue.getClaimableEther(requestId), 0); + } } From 142522ee3883e72e1aea045a8e59d62bdc14d481 Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Sat, 1 Nov 2025 15:39:05 +0300 Subject: [PATCH 10/20] test: improve withdrawal-queue claiming tests and clean up formatting --- lib/aave-v3-origin | 1 + test/unit/withdrawal-queue/Claiming.test.sol | 51 ++++++++++++++----- .../withdrawal-queue/FeeFinalization.test.sol | 1 - 3 files changed, 38 insertions(+), 15 deletions(-) create mode 160000 lib/aave-v3-origin diff --git a/lib/aave-v3-origin b/lib/aave-v3-origin new file mode 160000 index 0000000..6138e1f --- /dev/null +++ b/lib/aave-v3-origin @@ -0,0 +1 @@ +Subproject commit 6138e1fda45884b6547d094a1ddeef43dcab4977 diff --git a/test/unit/withdrawal-queue/Claiming.test.sol b/test/unit/withdrawal-queue/Claiming.test.sol index 451c437..d57aa81 100644 --- a/test/unit/withdrawal-queue/Claiming.test.sol +++ b/test/unit/withdrawal-queue/Claiming.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {Test} from "forge-std/Test.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; contract ClaimingTest is Test, SetupWithdrawalQueue { @@ -64,26 +64,26 @@ contract ClaimingTest is Test, SetupWithdrawalQueue { requestIds[1] = requestId2; requestIds[2] = requestId3; - uint256[] memory hints = withdrawalQueue.findCheckpointHintBatch( - requestIds, - 1, - withdrawalQueue.getLastCheckpointIndex() - ); + uint256[] memory hints = + withdrawalQueue.findCheckpointHintBatch(requestIds, 1, withdrawalQueue.getLastCheckpointIndex()); // Record initial balance and claimable amounts uint256 initialBalance = address(this).balance; uint256 totalClaimable = 0; + uint256[] memory expected = new uint256[](requestIds.length); for (uint256 i = 0; i < requestIds.length; i++) { - totalClaimable += withdrawalQueue.getClaimableEther(requestIds[i]); + expected[i] = withdrawalQueue.getClaimableEther(requestIds[i]); + totalClaimable += expected[i]; } // Batch claim - withdrawalQueue.claimWithdrawalBatch(address(this), requestIds, hints); + uint256[] memory claimed = withdrawalQueue.claimWithdrawalBatch(address(this), requestIds, hints); - // Verify all claims + // Verify all claims and returned amounts for (uint256 i = 0; i < requestIds.length; i++) { assertTrue(withdrawalQueue.getWithdrawalStatus(requestIds[i]).isClaimed); assertEq(withdrawalQueue.getClaimableEther(requestIds[i]), 0); + assertEq(claimed[i], expected[i]); } assertEq(address(this).balance, initialBalance + totalClaimable); } @@ -193,11 +193,8 @@ contract ClaimingTest is Test, SetupWithdrawalQueue { requestIds[0] = _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); requestIds[1] = _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); - uint256[] memory hints = withdrawalQueue.findCheckpointHintBatch( - requestIds, - 1, - withdrawalQueue.getLastCheckpointIndex() - ); + uint256[] memory hints = + withdrawalQueue.findCheckpointHintBatch(requestIds, 1, withdrawalQueue.getLastCheckpointIndex()); uint256 initialBalance = address(this).balance; uint256 totalClaimable; @@ -233,6 +230,32 @@ contract ClaimingTest is Test, SetupWithdrawalQueue { withdrawalQueue.claimWithdrawalBatch(address(this), requestIds, hints); } + function test_ClaimWithdrawals_RevertWithPreviousCheckpointHint() public { + uint256 requestId1 = _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); + uint256 requestId2 = _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); + + uint256[] memory requestIds = new uint256[](2); + requestIds[0] = requestId1; + requestIds[1] = requestId2; + + uint256[] memory correctHints = + withdrawalQueue.findCheckpointHintBatch(requestIds, 1, withdrawalQueue.getLastCheckpointIndex()); + assertEq(correctHints[0], 1); + assertEq(correctHints[1], 2); + + uint256[] memory wrongHints = new uint256[](2); + wrongHints[0] = correctHints[0]; + wrongHints[1] = correctHints[0]; + + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.InvalidHint.selector, wrongHints[1])); + withdrawalQueue.claimWithdrawalBatch(address(this), requestIds, wrongHints); + } + + function test_GetClaimableEther_ReturnsZeroForUnknownRequest() public { + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.InvalidRequestId.selector, 999)); + withdrawalQueue.getClaimableEther(999); + } + function test_GetClaimableEtherBatch_RevertArraysLengthMismatch() public { uint256 requestId = _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); diff --git a/test/unit/withdrawal-queue/FeeFinalization.test.sol b/test/unit/withdrawal-queue/FeeFinalization.test.sol index 6ce8f38..e72e15c 100644 --- a/test/unit/withdrawal-queue/FeeFinalization.test.sol +++ b/test/unit/withdrawal-queue/FeeFinalization.test.sol @@ -94,7 +94,6 @@ contract FeeFinalizationTest is Test, SetupWithdrawalQueue { uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, 0); dashboard.mock_simulateRewards(-int256(1 ether)); - uint256 expectedAssets = pool.previewRedeem(stvToRequest); uint256 finalizerBalanceBefore = finalizeRoleHolder.balance; _finalizeRequests(1); From 3dc38d91239e51640ba9b671087c566f843bb1ea Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Sat, 1 Nov 2025 16:14:29 +0300 Subject: [PATCH 11/20] test: reformat findCheckpointHintBatch calls and reorganize tests --- .../withdrawal-queue/Checkpoints.test.sol | 58 ++++++++++--------- test/unit/withdrawal-queue/Views.test.sol | 21 +++++-- 2 files changed, 47 insertions(+), 32 deletions(-) diff --git a/test/unit/withdrawal-queue/Checkpoints.test.sol b/test/unit/withdrawal-queue/Checkpoints.test.sol index 7daa2bb..32ac296 100644 --- a/test/unit/withdrawal-queue/Checkpoints.test.sol +++ b/test/unit/withdrawal-queue/Checkpoints.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {Test} from "forge-std/Test.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; contract CheckpointsTest is Test, SetupWithdrawalQueue { @@ -72,11 +72,8 @@ contract CheckpointsTest is Test, SetupWithdrawalQueue { uint256[] memory requestIds = new uint256[](1); requestIds[0] = requestId; - uint256[] memory hints = withdrawalQueue.findCheckpointHintBatch( - requestIds, - 1, - withdrawalQueue.getLastCheckpointIndex() - ); + uint256[] memory hints = + withdrawalQueue.findCheckpointHintBatch(requestIds, 1, withdrawalQueue.getLastCheckpointIndex()); assertEq(hints.length, 1); assertEq(hints[0], 1); // Should point to first checkpoint @@ -89,11 +86,8 @@ contract CheckpointsTest is Test, SetupWithdrawalQueue { requestIds[1] = _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); requestIds[2] = _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); - uint256[] memory hints = withdrawalQueue.findCheckpointHintBatch( - requestIds, - 1, - withdrawalQueue.getLastCheckpointIndex() - ); + uint256[] memory hints = + withdrawalQueue.findCheckpointHintBatch(requestIds, 1, withdrawalQueue.getLastCheckpointIndex()); assertEq(hints.length, 3); assertEq(hints[0], 1); // First request → first checkpoint @@ -110,11 +104,8 @@ contract CheckpointsTest is Test, SetupWithdrawalQueue { uint256[] memory requestIds = new uint256[](1); requestIds[0] = requestId2; // Second request not finalized - uint256[] memory hints = withdrawalQueue.findCheckpointHintBatch( - requestIds, - 1, - withdrawalQueue.getLastCheckpointIndex() - ); + uint256[] memory hints = + withdrawalQueue.findCheckpointHintBatch(requestIds, 1, withdrawalQueue.getLastCheckpointIndex()); // Should return NOT_FOUND (0) for unfinalized request assertEq(hints[0], 0); @@ -150,11 +141,8 @@ contract CheckpointsTest is Test, SetupWithdrawalQueue { searchIds[1] = requestIds[2]; // Request 3 searchIds[2] = requestIds[3]; // Request 4 - uint256[] memory hints = withdrawalQueue.findCheckpointHintBatch( - searchIds, - 1, - withdrawalQueue.getLastCheckpointIndex() - ); + uint256[] memory hints = + withdrawalQueue.findCheckpointHintBatch(searchIds, 1, withdrawalQueue.getLastCheckpointIndex()); assertEq(hints[0], 2); // Request 2 → Checkpoint 2 assertEq(hints[1], 3); // Request 3 → Checkpoint 3 @@ -169,11 +157,8 @@ contract CheckpointsTest is Test, SetupWithdrawalQueue { _finalizeRequests(3); // All requests in one checkpoint - uint256[] memory hints = withdrawalQueue.findCheckpointHintBatch( - requestIds, - 1, - withdrawalQueue.getLastCheckpointIndex() - ); + uint256[] memory hints = + withdrawalQueue.findCheckpointHintBatch(requestIds, 1, withdrawalQueue.getLastCheckpointIndex()); // All requests should point to the same checkpoint assertEq(hints[0], 1); @@ -210,6 +195,27 @@ contract CheckpointsTest is Test, SetupWithdrawalQueue { assertEq(hints[0], 0); } + function test_FindCheckpointHint_ReturnsZeroWhenStartGreaterThanEnd() public { + uint256 requestId = _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); + + uint256 lastIndex = withdrawalQueue.getLastCheckpointIndex(); + assertEq(lastIndex, 1); + + uint256 hint = withdrawalQueue.findCheckpointHint(requestId, lastIndex + 1, lastIndex); + assertEq(hint, 0); + } + + function test_FindCheckpointHint_RevertOnRequestBeyondLastId() public { + _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); + + uint256 invalidRequestId = withdrawalQueue.getLastRequestId() + 1; + uint256 startCheckpointIndex = 1; + uint256 endCheckpointIndex = withdrawalQueue.getLastCheckpointIndex(); + + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.InvalidRequestId.selector, invalidRequestId)); + withdrawalQueue.findCheckpointHint(invalidRequestId, startCheckpointIndex, endCheckpointIndex); + } + // Receive ETH for tests receive() external payable {} } diff --git a/test/unit/withdrawal-queue/Views.test.sol b/test/unit/withdrawal-queue/Views.test.sol index 13b3c13..019f02f 100644 --- a/test/unit/withdrawal-queue/Views.test.sol +++ b/test/unit/withdrawal-queue/Views.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {Test} from "forge-std/Test.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; contract ViewsTest is Test, SetupWithdrawalQueue { @@ -72,6 +72,18 @@ contract ViewsTest is Test, SetupWithdrawalQueue { withdrawalQueue.getWithdrawalStatusBatch(ids); } + function test_GetWithdrawalStatusBatch_RevertWhenArrayContainsZero() public { + pool.depositETH{value: 100 ether}(address(this), address(0)); + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); + + uint256[] memory ids = new uint256[](2); + ids[0] = requestId; + ids[1] = 0; + + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.InvalidRequestId.selector, 0)); + withdrawalQueue.getWithdrawalStatusBatch(ids); + } + function test_GetClaimableEther_ViewLifecycle() public { pool.depositETH{value: 100 ether}(address(this), address(0)); uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); @@ -85,11 +97,8 @@ contract ViewsTest is Test, SetupWithdrawalQueue { uint256[] memory requestIds = new uint256[](1); requestIds[0] = requestId; - uint256[] memory hints = withdrawalQueue.findCheckpointHintBatch( - requestIds, - 1, - withdrawalQueue.getLastCheckpointIndex() - ); + uint256[] memory hints = + withdrawalQueue.findCheckpointHintBatch(requestIds, 1, withdrawalQueue.getLastCheckpointIndex()); uint256 claimable = withdrawalQueue.getClaimableEther(requestId); assertGt(claimable, 0); From d8ba010926a7233ef501345abc8856b9674dc7b2 Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Sat, 1 Nov 2025 16:15:43 +0300 Subject: [PATCH 12/20] chore: reformat withdrawal-queue tests --- .../withdrawal-queue/EmergencyExit.test.sol | 2 +- test/unit/withdrawal-queue/HappyPath.test.sol | 22 ++++++------------- .../withdrawal-queue/RequestCreation.test.sol | 2 +- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/test/unit/withdrawal-queue/EmergencyExit.test.sol b/test/unit/withdrawal-queue/EmergencyExit.test.sol index 2bd0d01..5bb3099 100644 --- a/test/unit/withdrawal-queue/EmergencyExit.test.sol +++ b/test/unit/withdrawal-queue/EmergencyExit.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {Test} from "forge-std/Test.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; contract EmergencyExitTest is Test, SetupWithdrawalQueue { diff --git a/test/unit/withdrawal-queue/HappyPath.test.sol b/test/unit/withdrawal-queue/HappyPath.test.sol index bfec36a..f49a952 100644 --- a/test/unit/withdrawal-queue/HappyPath.test.sol +++ b/test/unit/withdrawal-queue/HappyPath.test.sol @@ -4,8 +4,8 @@ pragma solidity >=0.8.25; import {Test} from "forge-std/Test.sol"; import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; -import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; contract WithdrawalQueueHappyPathTest is Test, SetupWithdrawalQueue { function setUp() public override { @@ -36,9 +36,7 @@ contract WithdrawalQueueHappyPathTest is Test, SetupWithdrawalQueue { uint256 firstRequestId = withdrawalQueue.requestWithdrawal(address(this), firstWithdrawStv, 0); // Request 1: Check withdrawal status - WithdrawalQueue.WithdrawalRequestStatus memory firstStatus = withdrawalQueue.getWithdrawalStatus( - firstRequestId - ); + WithdrawalQueue.WithdrawalRequestStatus memory firstStatus = withdrawalQueue.getWithdrawalStatus(firstRequestId); assertEq(firstStatus.amountOfStethShares, 0); assertEq(firstStatus.amountOfAssets, 2 ether); // initial deposit / 5 assertEq(firstStatus.amountOfStv, firstWithdrawStv); @@ -106,25 +104,19 @@ contract WithdrawalQueueHappyPathTest is Test, SetupWithdrawalQueue { assertEq(remainingStv, initialStv - firstWithdrawStv - secondWithdrawStv); // Request 2: Check withdrawal status - WithdrawalQueue.WithdrawalRequestStatus memory secondStatus = withdrawalQueue.getWithdrawalStatus( - secondRequestId - ); + WithdrawalQueue.WithdrawalRequestStatus memory secondStatus = + withdrawalQueue.getWithdrawalStatus(secondRequestId); assertEq(secondStatus.amountOfStv, secondWithdrawStv); assertEq(secondStatus.owner, address(this)); // Request 3: Request another withdrawal with rebalance uint256 thirdWithdrawStv = remainingStv; uint256 thirdSharesToRebalance = mintedSharesRemaining; - uint256 thirdRequestId = withdrawalQueue.requestWithdrawal( - address(this), - thirdWithdrawStv, - thirdSharesToRebalance - ); + uint256 thirdRequestId = + withdrawalQueue.requestWithdrawal(address(this), thirdWithdrawStv, thirdSharesToRebalance); // Request 3: Check withdrawal status - WithdrawalQueue.WithdrawalRequestStatus memory thirdStatus = withdrawalQueue.getWithdrawalStatus( - thirdRequestId - ); + WithdrawalQueue.WithdrawalRequestStatus memory thirdStatus = withdrawalQueue.getWithdrawalStatus(thirdRequestId); assertEq(thirdStatus.amountOfStv, thirdWithdrawStv); assertEq(thirdStatus.amountOfStethShares, thirdSharesToRebalance); assertEq(thirdStatus.owner, address(this)); diff --git a/test/unit/withdrawal-queue/RequestCreation.test.sol b/test/unit/withdrawal-queue/RequestCreation.test.sol index 94983c6..8eea6e6 100644 --- a/test/unit/withdrawal-queue/RequestCreation.test.sol +++ b/test/unit/withdrawal-queue/RequestCreation.test.sol @@ -2,8 +2,8 @@ pragma solidity >=0.8.25; import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; -import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; import {Test} from "forge-std/Test.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; From 5b51f20469d00cc081f5ca61cea3435598ef86fd Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Sat, 1 Nov 2025 16:17:06 +0300 Subject: [PATCH 13/20] chore: reformat unit tests --- test/unit/allow-list/Deposits.test.sol | 2 +- test/unit/allow-list/Management.sol | 4 ++-- test/unit/allow-list/Roles.test.sol | 2 +- test/unit/allow-list/SetupAllowList.sol | 2 +- test/unit/allow-list/Views.test.sol | 2 +- test/unit/distributor/Claiming.test.sol | 3 +-- test/unit/distributor/Constructor.test.sol | 2 +- test/unit/distributor/MerkleRoot.test.sol | 12 +++--------- test/unit/distributor/TokenManagement.test.sol | 13 +++---------- test/unit/ggv-mock.test.sol | 2 +- .../stv-pool/RebalanceUnassignedWithShares.test.sol | 2 +- test/unit/stv-pool/SetupStvPool.sol | 2 +- test/unit/stv-pool/Stv.test.sol | 2 +- test/unit/stv-pool/UnassignedLiability.test.sol | 2 +- test/unit/stv-steth-pool/Assets.test.sol | 2 +- .../unit/stv-steth-pool/BurningStethShares.test.sol | 2 +- test/unit/stv-steth-pool/BurningWsteth.test.sol | 2 +- test/unit/stv-steth-pool/DepositAndMint.test.sol | 2 +- .../stv-steth-pool/ExceedingMintedSteth.test.sol | 2 +- test/unit/stv-steth-pool/ForceRebalance.test.sol | 6 ++---- test/unit/stv-steth-pool/LockCalculations.test.sol | 2 +- .../unit/stv-steth-pool/MintingStethShares.test.sol | 5 ++--- test/unit/stv-steth-pool/MintingWsteth.test.sol | 5 ++--- .../RebalanceMintedStethShares.test.sol | 2 +- test/unit/stv-steth-pool/SetupStvStETHPool.sol | 11 +++-------- test/unit/stv-steth-pool/TransferBlocking.test.sol | 12 ++++-------- test/unit/stv-steth-pool/VaultParameters.test.sol | 5 ++--- 27 files changed, 41 insertions(+), 69 deletions(-) diff --git a/test/unit/allow-list/Deposits.test.sol b/test/unit/allow-list/Deposits.test.sol index 5491a5d..a3746a3 100644 --- a/test/unit/allow-list/Deposits.test.sol +++ b/test/unit/allow-list/Deposits.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupAllowList} from "./SetupAllowList.sol"; +import {Test} from "forge-std/Test.sol"; import {AllowList} from "src/AllowList.sol"; contract AllowListDepositsTest is Test, SetupAllowList { diff --git a/test/unit/allow-list/Management.sol b/test/unit/allow-list/Management.sol index 05931e4..59ead40 100644 --- a/test/unit/allow-list/Management.sol +++ b/test/unit/allow-list/Management.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; -import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; import {SetupAllowList} from "./SetupAllowList.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Test} from "forge-std/Test.sol"; import {AllowList} from "src/AllowList.sol"; contract AllowListManagementTest is Test, SetupAllowList { diff --git a/test/unit/allow-list/Roles.test.sol b/test/unit/allow-list/Roles.test.sol index f43c097..3214b3e 100644 --- a/test/unit/allow-list/Roles.test.sol +++ b/test/unit/allow-list/Roles.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupAllowList} from "./SetupAllowList.sol"; +import {Test} from "forge-std/Test.sol"; contract AllowListRolesTest is Test, SetupAllowList { bytes32 ALLOW_LIST_MANAGER_ROLE; diff --git a/test/unit/allow-list/SetupAllowList.sol b/test/unit/allow-list/SetupAllowList.sol index 0e76404..4dbe16c 100644 --- a/test/unit/allow-list/SetupAllowList.sol +++ b/test/unit/allow-list/SetupAllowList.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Test} from "forge-std/Test.sol"; import {StvPool} from "src/StvPool.sol"; import {MockDashboard, MockDashboardFactory} from "test/mocks/MockDashboard.sol"; import {MockStETH} from "test/mocks/MockStETH.sol"; diff --git a/test/unit/allow-list/Views.test.sol b/test/unit/allow-list/Views.test.sol index 742fd14..cb971ff 100644 --- a/test/unit/allow-list/Views.test.sol +++ b/test/unit/allow-list/Views.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupAllowList} from "./SetupAllowList.sol"; +import {Test} from "forge-std/Test.sol"; contract AllowListViewsTest is Test, SetupAllowList { // ALLOW_LIST_ENABLED diff --git a/test/unit/distributor/Claiming.test.sol b/test/unit/distributor/Claiming.test.sol index 349c620..978762f 100644 --- a/test/unit/distributor/Claiming.test.sol +++ b/test/unit/distributor/Claiming.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupDistributor} from "./SetupDistributor.sol"; +import {Test} from "forge-std/Test.sol"; import {Distributor} from "src/Distributor.sol"; import {MerkleTree} from "test/utils/MerkleTree.sol"; @@ -177,7 +177,6 @@ contract ClaimingTest is Test, SetupDistributor { merkleTree.pushLeaf(_leafData(userBob, address(token1), 200 ether)); merkleTree.pushLeaf(_leafData(userCharlie, address(token2), 300 ether)); - bytes32 root = merkleTree.root(); vm.prank(manager); diff --git a/test/unit/distributor/Constructor.test.sol b/test/unit/distributor/Constructor.test.sol index 48b125c..5894173 100644 --- a/test/unit/distributor/Constructor.test.sol +++ b/test/unit/distributor/Constructor.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupDistributor} from "./SetupDistributor.sol"; +import {Test} from "forge-std/Test.sol"; import {Distributor} from "src/Distributor.sol"; contract ConstructorTest is Test, SetupDistributor { diff --git a/test/unit/distributor/MerkleRoot.test.sol b/test/unit/distributor/MerkleRoot.test.sol index 3b15e6a..a75ceb9 100644 --- a/test/unit/distributor/MerkleRoot.test.sol +++ b/test/unit/distributor/MerkleRoot.test.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupDistributor} from "./SetupDistributor.sol"; -import {Distributor} from "src/Distributor.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Test} from "forge-std/Test.sol"; +import {Distributor} from "src/Distributor.sol"; contract MerkleRootTest is Test, SetupDistributor { function setUp() public override { @@ -21,11 +21,7 @@ contract MerkleRootTest is Test, SetupDistributor { vm.prank(userAlice); vm.expectRevert( - abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, - userAlice, - managerRole - ) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, userAlice, managerRole) ); distributor.setMerkleRoot(newRoot, newCid); } @@ -116,5 +112,3 @@ contract MerkleRootTest is Test, SetupDistributor { } } - - diff --git a/test/unit/distributor/TokenManagement.test.sol b/test/unit/distributor/TokenManagement.test.sol index f18235f..26f994a 100644 --- a/test/unit/distributor/TokenManagement.test.sol +++ b/test/unit/distributor/TokenManagement.test.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupDistributor} from "./SetupDistributor.sol"; -import {Distributor} from "src/Distributor.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Test} from "forge-std/Test.sol"; +import {Distributor} from "src/Distributor.sol"; contract TokenManagementTest is Test, SetupDistributor { function setUp() public override { @@ -14,16 +14,11 @@ contract TokenManagementTest is Test, SetupDistributor { // ==================== Error Cases ==================== function test_AddToken_RevertsIfNotManager() public { - bytes32 managerRole = distributor.MANAGER_ROLE(); vm.prank(userAlice); vm.expectRevert( - abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, - userAlice, - managerRole - ) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, userAlice, managerRole) ); distributor.addToken(address(token1)); } @@ -100,5 +95,3 @@ contract TokenManagementTest is Test, SetupDistributor { } } - - diff --git a/test/unit/ggv-mock.test.sol b/test/unit/ggv-mock.test.sol index 4eb45b5..abf7a6d 100644 --- a/test/unit/ggv-mock.test.sol +++ b/test/unit/ggv-mock.test.sol @@ -3,9 +3,9 @@ pragma solidity >=0.8.25; import {Test} from "forge-std/Test.sol"; -import {GGVVaultMock} from "src/mock/ggv/GGVVaultMock.sol"; import {GGVMockTeller} from "src/mock/ggv/GGVMockTeller.sol"; import {GGVQueueMock} from "src/mock/ggv/GGVQueueMock.sol"; +import {GGVVaultMock} from "src/mock/ggv/GGVVaultMock.sol"; import {MockStETH} from "test/mocks/MockStETH.sol"; import {MockWstETH} from "test/mocks/MockWstETH.sol"; diff --git a/test/unit/stv-pool/RebalanceUnassignedWithShares.test.sol b/test/unit/stv-pool/RebalanceUnassignedWithShares.test.sol index a3545d6..23fd28c 100644 --- a/test/unit/stv-pool/RebalanceUnassignedWithShares.test.sol +++ b/test/unit/stv-pool/RebalanceUnassignedWithShares.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupStvPool} from "./SetupStvPool.sol"; +import {Test} from "forge-std/Test.sol"; import {StvPool} from "src/StvPool.sol"; contract RebalanceUnassignedWithSharesTest is Test, SetupStvPool { diff --git a/test/unit/stv-pool/SetupStvPool.sol b/test/unit/stv-pool/SetupStvPool.sol index db46687..f97dd31 100644 --- a/test/unit/stv-pool/SetupStvPool.sol +++ b/test/unit/stv-pool/SetupStvPool.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Test} from "forge-std/Test.sol"; import {StvPool} from "src/StvPool.sol"; import {MockDashboard, MockDashboardFactory} from "test/mocks/MockDashboard.sol"; import {MockStETH} from "test/mocks/MockStETH.sol"; diff --git a/test/unit/stv-pool/Stv.test.sol b/test/unit/stv-pool/Stv.test.sol index eb2637d..3b89b8e 100644 --- a/test/unit/stv-pool/Stv.test.sol +++ b/test/unit/stv-pool/Stv.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupStvPool} from "./SetupStvPool.sol"; +import {Test} from "forge-std/Test.sol"; contract StvTest is Test, SetupStvPool { uint8 supplyDecimals = 27; diff --git a/test/unit/stv-pool/UnassignedLiability.test.sol b/test/unit/stv-pool/UnassignedLiability.test.sol index bdd55e0..6acbce8 100644 --- a/test/unit/stv-pool/UnassignedLiability.test.sol +++ b/test/unit/stv-pool/UnassignedLiability.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupStvPool} from "./SetupStvPool.sol"; +import {Test} from "forge-std/Test.sol"; import {StvPool} from "src/StvPool.sol"; contract UnassignedLiabilityTest is Test, SetupStvPool { diff --git a/test/unit/stv-steth-pool/Assets.test.sol b/test/unit/stv-steth-pool/Assets.test.sol index 8f5a46d..56c31f9 100644 --- a/test/unit/stv-steth-pool/Assets.test.sol +++ b/test/unit/stv-steth-pool/Assets.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; contract AssetsTest is Test, SetupStvStETHPool { uint8 supplyDecimals = 27; diff --git a/test/unit/stv-steth-pool/BurningStethShares.test.sol b/test/unit/stv-steth-pool/BurningStethShares.test.sol index 2840e74..fc15411 100644 --- a/test/unit/stv-steth-pool/BurningStethShares.test.sol +++ b/test/unit/stv-steth-pool/BurningStethShares.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; contract BurningStethSharesTest is Test, SetupStvStETHPool { diff --git a/test/unit/stv-steth-pool/BurningWsteth.test.sol b/test/unit/stv-steth-pool/BurningWsteth.test.sol index f377031..127dce2 100644 --- a/test/unit/stv-steth-pool/BurningWsteth.test.sol +++ b/test/unit/stv-steth-pool/BurningWsteth.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; contract BurningWstethTest is Test, SetupStvStETHPool { diff --git a/test/unit/stv-steth-pool/DepositAndMint.test.sol b/test/unit/stv-steth-pool/DepositAndMint.test.sol index bc2cf3f..a697a84 100644 --- a/test/unit/stv-steth-pool/DepositAndMint.test.sol +++ b/test/unit/stv-steth-pool/DepositAndMint.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; contract DepositAndMintTest is Test, SetupStvStETHPool { diff --git a/test/unit/stv-steth-pool/ExceedingMintedSteth.test.sol b/test/unit/stv-steth-pool/ExceedingMintedSteth.test.sol index 2a0e343..d624385 100644 --- a/test/unit/stv-steth-pool/ExceedingMintedSteth.test.sol +++ b/test/unit/stv-steth-pool/ExceedingMintedSteth.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; contract ExceedingMintedStethTest is Test, SetupStvStETHPool { uint8 supplyDecimals = 27; diff --git a/test/unit/stv-steth-pool/ForceRebalance.test.sol b/test/unit/stv-steth-pool/ForceRebalance.test.sol index 05f52c0..c113cea 100644 --- a/test/unit/stv-steth-pool/ForceRebalance.test.sol +++ b/test/unit/stv-steth-pool/ForceRebalance.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; @@ -45,9 +45,7 @@ contract ForceRebalanceTest is Test, SetupStvStETHPool { // liability / (assets - x) = (1 - threshold) // x = assets - liability / (1 - threshold) lossToBreachThreshold = - assets - - (mintedSteth * pool.TOTAL_BASIS_POINTS()) / - (pool.TOTAL_BASIS_POINTS() - threshold); + assets - (mintedSteth * pool.TOTAL_BASIS_POINTS()) / (pool.TOTAL_BASIS_POINTS() - threshold); // scale loss to user's share of the pool lossToBreachThreshold = (lossToBreachThreshold * pool.totalAssets()) / assets; diff --git a/test/unit/stv-steth-pool/LockCalculations.test.sol b/test/unit/stv-steth-pool/LockCalculations.test.sol index 2455c1f..7f410e0 100644 --- a/test/unit/stv-steth-pool/LockCalculations.test.sol +++ b/test/unit/stv-steth-pool/LockCalculations.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; diff --git a/test/unit/stv-steth-pool/MintingStethShares.test.sol b/test/unit/stv-steth-pool/MintingStethShares.test.sol index e62936a..16c13d0 100644 --- a/test/unit/stv-steth-pool/MintingStethShares.test.sol +++ b/test/unit/stv-steth-pool/MintingStethShares.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; contract MintingStethSharesTest is Test, SetupStvStETHPool { @@ -72,8 +72,7 @@ contract MintingStethSharesTest is Test, SetupStvStETHPool { function test_MintStethShares_CallsDashboardMintShares() public { // Check that dashboard's mint function is called with correct parameters vm.expectCall( - address(dashboard), - abi.encodeWithSelector(dashboard.mintShares.selector, address(this), stethSharesToMint) + address(dashboard), abi.encodeWithSelector(dashboard.mintShares.selector, address(this), stethSharesToMint) ); pool.mintStethShares(stethSharesToMint); diff --git a/test/unit/stv-steth-pool/MintingWsteth.test.sol b/test/unit/stv-steth-pool/MintingWsteth.test.sol index de21628..a035397 100644 --- a/test/unit/stv-steth-pool/MintingWsteth.test.sol +++ b/test/unit/stv-steth-pool/MintingWsteth.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; contract MintingWstethTest is Test, SetupStvStETHPool { @@ -57,8 +57,7 @@ contract MintingWstethTest is Test, SetupStvStETHPool { function test_MintWsteth_CallsDashboardMintShares() public { // Check that dashboard's mint function is called with correct parameters vm.expectCall( - address(dashboard), - abi.encodeWithSelector(dashboard.mintWstETH.selector, address(this), wstethToMint) + address(dashboard), abi.encodeWithSelector(dashboard.mintWstETH.selector, address(this), wstethToMint) ); pool.mintWsteth(wstethToMint); diff --git a/test/unit/stv-steth-pool/RebalanceMintedStethShares.test.sol b/test/unit/stv-steth-pool/RebalanceMintedStethShares.test.sol index 5acbe47..06a44f3 100644 --- a/test/unit/stv-steth-pool/RebalanceMintedStethShares.test.sol +++ b/test/unit/stv-steth-pool/RebalanceMintedStethShares.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; import {StvPool} from "src/StvPool.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; diff --git a/test/unit/stv-steth-pool/SetupStvStETHPool.sol b/test/unit/stv-steth-pool/SetupStvStETHPool.sol index 0f76d46..d1b8f8a 100644 --- a/test/unit/stv-steth-pool/SetupStvStETHPool.sol +++ b/test/unit/stv-steth-pool/SetupStvStETHPool.sol @@ -1,13 +1,13 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Test} from "forge-std/Test.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; import {MockDashboard, MockDashboardFactory} from "test/mocks/MockDashboard.sol"; -import {MockVaultHub} from "test/mocks/MockVaultHub.sol"; import {MockStETH} from "test/mocks/MockStETH.sol"; +import {MockVaultHub} from "test/mocks/MockVaultHub.sol"; import {MockWstETH} from "test/mocks/MockWstETH.sol"; abstract contract SetupStvStETHPool is Test { @@ -47,12 +47,7 @@ abstract contract SetupStvStETHPool is Test { // Deploy the pool with mock withdrawal queue StvStETHPool poolImpl = new StvStETHPool( - address(dashboard), - false, - reserveRatioGapBP, - withdrawalQueue, - address(0), - keccak256("test.stv.steth.pool") + address(dashboard), false, reserveRatioGapBP, withdrawalQueue, address(0), keccak256("test.stv.steth.pool") ); ERC1967Proxy poolProxy = new ERC1967Proxy(address(poolImpl), ""); diff --git a/test/unit/stv-steth-pool/TransferBlocking.test.sol b/test/unit/stv-steth-pool/TransferBlocking.test.sol index 1135a0e..a216057 100644 --- a/test/unit/stv-steth-pool/TransferBlocking.test.sol +++ b/test/unit/stv-steth-pool/TransferBlocking.test.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.25; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; -import {StvStETHPool} from "src/StvStETHPool.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Test} from "forge-std/Test.sol"; +import {StvStETHPool} from "src/StvStETHPool.sol"; contract TransferBlockingTest is Test, SetupStvStETHPool { uint256 ethToDeposit = 10 ether; @@ -246,12 +246,8 @@ contract TransferBlockingTest is Test, SetupStvStETHPool { // Verify calculation logic uint256 stethAmount = steth.getPooledEthBySharesRoundUp(testShares); - uint256 expectedAssetsToLock = Math.mulDiv( - stethAmount, - totalBasisPoints, - totalBasisPoints - reserveRatio, - Math.Rounding.Ceil - ); + uint256 expectedAssetsToLock = + Math.mulDiv(stethAmount, totalBasisPoints, totalBasisPoints - reserveRatio, Math.Rounding.Ceil); uint256 calculatedAssetsToLock = pool.calcAssetsToLockForStethShares(testShares); assertEq(calculatedAssetsToLock, expectedAssetsToLock); diff --git a/test/unit/stv-steth-pool/VaultParameters.test.sol b/test/unit/stv-steth-pool/VaultParameters.test.sol index 9d38bab..aab84c5 100644 --- a/test/unit/stv-steth-pool/VaultParameters.test.sol +++ b/test/unit/stv-steth-pool/VaultParameters.test.sol @@ -3,9 +3,9 @@ pragma solidity >=0.8.25; import {Test} from "forge-std/Test.sol"; +import {MockVaultHub} from "../../mocks/MockVaultHub.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; -import {MockVaultHub} from "../../mocks/MockVaultHub.sol"; contract VaultParametersTest is Test, SetupStvStETHPool { function test_ReserveRatioBP_ReturnsExpectedValue() public view { @@ -46,8 +46,7 @@ contract VaultParametersTest is Test, SetupStvStETHPool { vm.expectEmit(false, false, false, true); emit StvStETHPool.VaultParametersUpdated( - baseReserveRatioBP + reserveRatioGapBP, - baseForcedThresholdBP + reserveRatioGapBP + baseReserveRatioBP + reserveRatioGapBP, baseForcedThresholdBP + reserveRatioGapBP ); pool.syncVaultParameters(); } From 5504e9bd9732add4f8435db80b9a19f18793fa84 Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Sat, 1 Nov 2025 16:38:06 +0300 Subject: [PATCH 14/20] chore: remove lib/aave-v3-origin git submodule --- lib/aave-v3-origin | 1 - 1 file changed, 1 deletion(-) delete mode 160000 lib/aave-v3-origin diff --git a/lib/aave-v3-origin b/lib/aave-v3-origin deleted file mode 160000 index 6138e1f..0000000 --- a/lib/aave-v3-origin +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6138e1fda45884b6547d094a1ddeef43dcab4977 From 4029f4cdb42c4cf9beeb9bbeaac929e6954a3fdb Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Mon, 3 Nov 2025 11:35:39 +0300 Subject: [PATCH 15/20] feat: block operations when vault is in bad debt --- src/StvPool.sol | 14 ++++ src/StvStETHPool.sol | 1 + test/unit/stv-pool/BadDebt.test.sol | 58 +++++++++++++ test/unit/stv-pool/SetupStvPool.sol | 3 + .../stv-steth-pool/ForceRebalance.test.sol | 5 +- test/unit/withdrawal-queue/BadDebt.test.sol | 81 +++++++++++++++++++ .../withdrawal-queue/SetupWithdrawalQueue.sol | 5 +- 7 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 test/unit/stv-pool/BadDebt.test.sol create mode 100644 test/unit/withdrawal-queue/BadDebt.test.sol diff --git a/src/StvPool.sol b/src/StvPool.sol index e0970bd..0ee9b39 100644 --- a/src/StvPool.sol +++ b/src/StvPool.sol @@ -21,6 +21,7 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList { error InvalidRequestType(); error NotEnoughToRebalance(); error UnassignedLiabilityOnVault(); + error VaultInBadDebt(); bytes32 public constant REQUEST_VALIDATOR_EXIT_ROLE = keccak256("REQUEST_VALIDATOR_EXIT_ROLE"); bytes32 public constant TRIGGER_VALIDATOR_WITHDRAWAL_ROLE = keccak256("TRIGGER_VALIDATOR_WITHDRAWAL_ROLE"); @@ -314,6 +315,14 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList { if (totalUnassignedLiabilityShares() > 0) revert UnassignedLiabilityOnVault(); } + /** + * @dev Checks if the vault is not in bad debt (value < liability) + */ + function _checkNoBadDebt() internal view { + uint256 totalValueInStethShares = _getSharesByPooledEth(VAULT_HUB.totalValue(address(STAKING_VAULT))); + if (totalValueInStethShares < totalLiabilityShares()) revert VaultInBadDebt(); + } + // ================================================================================= // STETH HELPERS // ================================================================================= @@ -346,9 +355,13 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList { * @dev Overridden method from ERC20 to prevent updates if there are unassigned liability */ function _update(address _from, address _to, uint256 _value) internal virtual override { + // Ensure vault is not in bad debt (value < liability) before any transfer + _checkNoBadDebt(); + // In rare scenarios, the vault could have liability shares that are not assigned to any pool users // In such cases, it prevents any transfers until the unassigned liability is rebalanced _checkNoUnassignedLiability(); + super._update(_from, _to, _value); } @@ -374,6 +387,7 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList { */ function burnStvForWithdrawalQueue(uint256 _stv) external { _checkOnlyWithdrawalQueue(); + _checkNoBadDebt(); _checkNoUnassignedLiability(); _burnUnsafe(address(WITHDRAWAL_QUEUE), _stv); } diff --git a/src/StvStETHPool.sol b/src/StvStETHPool.sol index 8472a68..3718793 100644 --- a/src/StvStETHPool.sol +++ b/src/StvStETHPool.sol @@ -671,6 +671,7 @@ contract StvStETHPool is StvPool { returns (uint256 stvToBurn) { _checkNoUnassignedLiability(); + _checkNoBadDebt(); if (_stethShares == 0) revert ZeroArgument(); if (_stethShares > mintedStethSharesOf(_account)) revert InsufficientMintedShares(); diff --git a/test/unit/stv-pool/BadDebt.test.sol b/test/unit/stv-pool/BadDebt.test.sol new file mode 100644 index 0000000..42e6b06 --- /dev/null +++ b/test/unit/stv-pool/BadDebt.test.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25; + +import {SetupStvPool} from "./SetupStvPool.sol"; +import {Test} from "forge-std/Test.sol"; +import {StvPool} from "src/StvPool.sol"; + +contract BadDebtTest is Test, SetupStvPool { + function _simulateBadDebt() internal { + // Deposit some ETH + pool.depositETH{value: 10 ether}(address(this), address(0)); + + // Simulate liability transfer + dashboard.mock_increaseLiability(steth.getSharesByPooledEth(10 ether)); + + // Simulate negative rewards to create bad debt + dashboard.mock_simulateRewards(int256(-2 ether)); + + _assertBadDebt(); + } + + function _getValueAndLiabilityShares() internal view returns (uint256 valueShares, uint256 liabilityShares) { + valueShares = steth.getSharesByPooledEth(vaultHub.totalValue(address(pool.STAKING_VAULT()))); + liabilityShares = pool.totalLiabilityShares(); + } + + function _assertBadDebt() internal view { + (uint256 valueShares, uint256 liabilityShares) = _getValueAndLiabilityShares(); + assertLt(valueShares, liabilityShares); + } + + function _assertNoBadDebt() internal view { + (uint256 valueShares, uint256 liabilityShares) = _getValueAndLiabilityShares(); + assertGe(valueShares, liabilityShares); + } + + // Initial state tests + + function test_InitialState_NoBadDebt() public view { + _assertNoBadDebt(); + } + + // Bad debt tests + + function test_BadDebt_TransfersNotAllowed() public { + _simulateBadDebt(); + + vm.expectRevert(StvPool.VaultInBadDebt.selector); + pool.transfer(address(1), 1 ether); + } + + function test_BadDebt_DepositsNotAllowed() public { + _simulateBadDebt(); + + vm.expectRevert(StvPool.VaultInBadDebt.selector); + pool.depositETH{value: 1 ether}(address(this), address(0)); + } +} diff --git a/test/unit/stv-pool/SetupStvPool.sol b/test/unit/stv-pool/SetupStvPool.sol index f97dd31..ff01c77 100644 --- a/test/unit/stv-pool/SetupStvPool.sol +++ b/test/unit/stv-pool/SetupStvPool.sol @@ -6,10 +6,12 @@ import {Test} from "forge-std/Test.sol"; import {StvPool} from "src/StvPool.sol"; import {MockDashboard, MockDashboardFactory} from "test/mocks/MockDashboard.sol"; import {MockStETH} from "test/mocks/MockStETH.sol"; +import {MockVaultHub} from "test/mocks/MockVaultHub.sol"; abstract contract SetupStvPool is Test { StvPool public pool; MockDashboard public dashboard; + MockVaultHub public vaultHub; MockStETH public steth; address public owner; @@ -31,6 +33,7 @@ abstract contract SetupStvPool is Test { // Deploy mocks dashboard = new MockDashboardFactory().createMockDashboard(owner); steth = dashboard.STETH(); + vaultHub = dashboard.VAULT_HUB(); // Fund the dashboard with 1 ETH dashboard.fund{value: initialDeposit}(); diff --git a/test/unit/stv-steth-pool/ForceRebalance.test.sol b/test/unit/stv-steth-pool/ForceRebalance.test.sol index c113cea..db5443b 100644 --- a/test/unit/stv-steth-pool/ForceRebalance.test.sol +++ b/test/unit/stv-steth-pool/ForceRebalance.test.sol @@ -149,10 +149,7 @@ contract ForceRebalanceTest is Test, SetupStvStETHPool { function test_ForceRebalanceAndSocializeLoss_DoNotRevertIfAccountIsUndercollateralized() public { _mintMaxStethShares(userAlice); - - uint256 totalValue = dashboard.maxLockableValue(); - assertGt(totalValue, 1 ether, "unexpected vault value"); - _simulateLoss(totalValue - 1 ether); + _simulateLoss(4 ether); vm.prank(socializer); pool.forceRebalanceAndSocializeLoss(userAlice); diff --git a/test/unit/withdrawal-queue/BadDebt.test.sol b/test/unit/withdrawal-queue/BadDebt.test.sol new file mode 100644 index 0000000..686f5d8 --- /dev/null +++ b/test/unit/withdrawal-queue/BadDebt.test.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25; + +import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {Test} from "forge-std/Test.sol"; +import {StvPool} from "src/StvPool.sol"; + +contract BadDebtTest is Test, SetupWithdrawalQueue { + function setUp() public virtual override { + super.setUp(); + + // Deposit some ETH and mint max stETH shares for the test contract + pool.depositETH{value: 10 ether}(address(this), address(0)); + pool.mintStethShares(pool.remainingMintingCapacitySharesOf(address(this), 0)); + + // Deposit some ETH and mint max stETH shares for Alice + vm.startPrank(userAlice); + pool.depositETH{value: 10 ether}(userAlice, address(0)); + pool.mintStethShares(pool.remainingMintingCapacitySharesOf(userAlice, 0)); + vm.stopPrank(); + } + + function _simulateBadDebt() internal { + // Simulate negative rewards to create bad debt + uint256 totalAssets = vaultHub.totalValue(address(pool.STAKING_VAULT())); + uint256 liabilitySteth = steth.getPooledEthBySharesRoundUp(pool.totalLiabilityShares()); + uint256 value = totalAssets - liabilitySteth; + + dashboard.mock_simulateRewards(int256(value) * -1 - 10 wei); + + _assertBadDebt(); + } + + function _getValueAndLiabilityShares() internal view returns (uint256 valueShares, uint256 liabilityShares) { + valueShares = steth.getSharesByPooledEth(vaultHub.totalValue(address(pool.STAKING_VAULT()))); + liabilityShares = pool.totalLiabilityShares(); + } + + function _assertBadDebt() internal view { + (uint256 valueShares, uint256 liabilityShares) = _getValueAndLiabilityShares(); + assertLt(valueShares, liabilityShares); + } + + function _assertNoBadDebt() internal view { + (uint256 valueShares, uint256 liabilityShares) = _getValueAndLiabilityShares(); + assertGe(valueShares, liabilityShares); + } + + // Initial state tests + + function test_InitialState_NoBadDebt() public view { + _assertNoBadDebt(); + } + + // Bad debt tests + + function test_BadDebt_RevertInRequestWithdrawals() public { + _simulateBadDebt(); + + uint256 balance = pool.balanceOf(address(this)); + assertGt(balance, 0); + + vm.expectRevert(StvPool.VaultInBadDebt.selector); + withdrawalQueue.requestWithdrawal(address(pool), balance, 0); + } + + function test_BadDebt_RevertOnFinalization() public { + uint256 balance = pool.balanceOf(address(this)); + uint256 liabilityShares = pool.mintedStethSharesOf(address(this)); + assertGt(balance, 0); + + withdrawalQueue.requestWithdrawal(address(pool), balance, liabilityShares); + + _simulateBadDebt(); + _warpAndMockOracleReport(); + + vm.prank(finalizeRoleHolder); + vm.expectRevert(StvPool.VaultInBadDebt.selector); + withdrawalQueue.finalize(1); + } +} diff --git a/test/unit/withdrawal-queue/SetupWithdrawalQueue.sol b/test/unit/withdrawal-queue/SetupWithdrawalQueue.sol index 99202cc..393e2aa 100644 --- a/test/unit/withdrawal-queue/SetupWithdrawalQueue.sol +++ b/test/unit/withdrawal-queue/SetupWithdrawalQueue.sol @@ -8,12 +8,14 @@ import {OssifiableProxy} from "src/proxy/OssifiableProxy.sol"; import {MockDashboard, MockDashboardFactory} from "test/mocks/MockDashboard.sol"; import {MockLazyOracle} from "test/mocks/MockLazyOracle.sol"; import {MockStETH} from "test/mocks/MockStETH.sol"; +import {MockVaultHub} from "test/mocks/MockVaultHub.sol"; abstract contract SetupWithdrawalQueue is Test { WithdrawalQueue public withdrawalQueue; StvStETHPool public pool; MockLazyOracle public lazyOracle; MockDashboard public dashboard; + MockVaultHub public vaultHub; MockStETH public steth; address public owner; @@ -49,6 +51,7 @@ abstract contract SetupWithdrawalQueue is Test { dashboard = new MockDashboardFactory().createMockDashboard(owner); lazyOracle = new MockLazyOracle(); steth = dashboard.STETH(); + vaultHub = dashboard.VAULT_HUB(); // Fund dashboard dashboard.fund{value: initialDeposit}(); @@ -64,7 +67,7 @@ abstract contract SetupWithdrawalQueue is Test { WithdrawalQueue wqImpl = new WithdrawalQueue( address(pool), address(dashboard), - address(dashboard.VAULT_HUB()), + address(vaultHub), address(steth), address(dashboard.STAKING_VAULT()), address(lazyOracle), From d5f61a1b7353c596a0113e4235eeff8ab493be12 Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Mon, 3 Nov 2025 12:20:05 +0300 Subject: [PATCH 16/20] fix(vault-params): enforce strict < relation for forcedRebalanceThresholdBP and cap threshold below reserveRatioBP --- src/StvStETHPool.sol | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/StvStETHPool.sol b/src/StvStETHPool.sol index 8472a68..e3d2746 100644 --- a/src/StvStETHPool.sol +++ b/src/StvStETHPool.sol @@ -488,16 +488,18 @@ contract StvStETHPool is StvPool { IVaultHub.VaultConnection memory connection = DASHBOARD.vaultConnection(); uint256 maxReserveRatioBP = TOTAL_BASIS_POINTS - 1; + uint256 maxForcedRebalanceThresholdBP = maxReserveRatioBP - 1; /// Invariants from the OperatorGrid assert(connection.reserveRatioBP > 0); assert(connection.reserveRatioBP <= maxReserveRatioBP); assert(connection.forcedRebalanceThresholdBP > 0); - assert(connection.forcedRebalanceThresholdBP <= connection.reserveRatioBP); + assert(connection.forcedRebalanceThresholdBP < connection.reserveRatioBP); uint16 newReserveRatioBP = uint16(Math.min(connection.reserveRatioBP + RESERVE_RATIO_GAP_BP, maxReserveRatioBP)); - uint16 newThresholdBP = - uint16(Math.min(connection.forcedRebalanceThresholdBP + RESERVE_RATIO_GAP_BP, maxReserveRatioBP)); + uint16 newThresholdBP = uint16( + Math.min(connection.forcedRebalanceThresholdBP + RESERVE_RATIO_GAP_BP, maxForcedRebalanceThresholdBP) + ); StvStETHPoolStorage storage $ = _getStvStETHPoolStorage(); From d730e77acbe783e9b621d6972e4539ecae8c3466 Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Tue, 4 Nov 2025 13:35:01 +0300 Subject: [PATCH 17/20] fix: use explicit newForcedRebalanceThresholdBP and update equality check/event emit --- src/StvStETHPool.sol | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/StvStETHPool.sol b/src/StvStETHPool.sol index e3d2746..85ccdc9 100644 --- a/src/StvStETHPool.sol +++ b/src/StvStETHPool.sol @@ -497,18 +497,20 @@ contract StvStETHPool is StvPool { assert(connection.forcedRebalanceThresholdBP < connection.reserveRatioBP); uint16 newReserveRatioBP = uint16(Math.min(connection.reserveRatioBP + RESERVE_RATIO_GAP_BP, maxReserveRatioBP)); - uint16 newThresholdBP = uint16( + uint16 newForcedRebalanceThresholdBP = uint16( Math.min(connection.forcedRebalanceThresholdBP + RESERVE_RATIO_GAP_BP, maxForcedRebalanceThresholdBP) ); StvStETHPoolStorage storage $ = _getStvStETHPoolStorage(); - if (newReserveRatioBP == $.reserveRatioBP && newThresholdBP == $.forcedRebalanceThresholdBP) return; + if (newReserveRatioBP == $.reserveRatioBP && newForcedRebalanceThresholdBP == $.forcedRebalanceThresholdBP) { + return; + } $.reserveRatioBP = newReserveRatioBP; - $.forcedRebalanceThresholdBP = newThresholdBP; + $.forcedRebalanceThresholdBP = newForcedRebalanceThresholdBP; - emit VaultParametersUpdated(newReserveRatioBP, newThresholdBP); + emit VaultParametersUpdated(newReserveRatioBP, newForcedRebalanceThresholdBP); } // ================================================================================= From da8e21013ef731ddb97469a96fb217838b43cba2 Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Tue, 4 Nov 2025 13:36:26 +0300 Subject: [PATCH 18/20] style: reformat function signatures in HarnessCore.s.sol and IBoringOnChainQueue.sol --- script/HarnessCore.s.sol | 13 +++++-------- src/interfaces/ggv/IBoringOnChainQueue.sol | 9 +++------ 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/script/HarnessCore.s.sol b/script/HarnessCore.s.sol index 341656b..7c45087 100644 --- a/script/HarnessCore.s.sol +++ b/script/HarnessCore.s.sol @@ -168,14 +168,11 @@ contract HarnessCore is Script { return (true, abi.decode(ret, (address))); } - function _arr6( - string memory a, - string memory b, - string memory c, - string memory d, - string memory e, - string memory f - ) private pure returns (string[] memory r) { + function _arr6(string memory a, string memory b, string memory c, string memory d, string memory e, string memory f) + private + pure + returns (string[] memory r) + { r = new string[](6); r[0] = a; r[1] = b; diff --git a/src/interfaces/ggv/IBoringOnChainQueue.sol b/src/interfaces/ggv/IBoringOnChainQueue.sol index 52e29d3..7df7248 100644 --- a/src/interfaces/ggv/IBoringOnChainQueue.sol +++ b/src/interfaces/ggv/IBoringOnChainQueue.sol @@ -57,12 +57,9 @@ interface IBoringOnChainQueue { uint96 minimumShares ) external; - function requestOnChainWithdraw( - address assetOut, - uint128 amountOfShares, - uint16 discount, - uint24 secondsToDeadline - ) external returns (bytes32 requestId); + function requestOnChainWithdraw(address assetOut, uint128 amountOfShares, uint16 discount, uint24 secondsToDeadline) + external + returns (bytes32 requestId); function cancelOnChainWithdraw(OnChainWithdraw memory request) external returns (bytes32 requestId); function replaceOnChainWithdraw(OnChainWithdraw memory oldRequest, uint16 discount, uint24 secondsToDeadline) external From 5decf3153a7a0ef2aad080015eec8bd7aee80c7a Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Wed, 5 Nov 2025 11:11:06 +0300 Subject: [PATCH 19/20] refactor: replace withdrawal fee with per-request gas cost coverage --- src/WithdrawalQueue.sol | 100 ++++++++++-------- .../withdrawal-queue/EmergencyExit.test.sol | 6 +- test/unit/withdrawal-queue/FeeConfig.test.sol | 74 ------------- .../withdrawal-queue/Finalization.test.sol | 4 +- ...tion.test.sol => GasCostCoverage.test.sol} | 55 +++++----- .../GasCostCoverageConfig.test.sol | 74 +++++++++++++ 6 files changed, 160 insertions(+), 153 deletions(-) delete mode 100644 test/unit/withdrawal-queue/FeeConfig.test.sol rename test/unit/withdrawal-queue/{FeeFinalization.test.sol => GasCostCoverage.test.sol} (66%) create mode 100644 test/unit/withdrawal-queue/GasCostCoverageConfig.test.sol diff --git a/src/WithdrawalQueue.sol b/src/WithdrawalQueue.sol index 10d3ba7..865837e 100644 --- a/src/WithdrawalQueue.sol +++ b/src/WithdrawalQueue.sol @@ -35,10 +35,16 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea uint256 public constant E27_PRECISION_BASE = 1e27; uint256 public constant E36_PRECISION_BASE = 1e36; - /// @notice Maximum withdrawal fee that can be applied for a single request - /// @dev High enough to cover fees for finalization tx - /// @dev Low enough to prevent abuse by charging excessive fees - uint256 public constant MAX_WITHDRAWAL_FEE = 0.001 ether; + /// @notice Maximum gas cost coverage that can be applied for a single request + /// @dev High enough to cover gas costs for finalization tx + /// @dev Low enough to prevent abuse by excessive gas cost coverage + /// + /// Request finalization tx for 1 request consumes ~200k gas + /// Request finalization tx for 10 requests (in batch) consumes ~300k gas + /// Thus, setting max coverage to 0.0005 ether should be sufficient to cover finalization gas costs: + /// - when gas price is up to 2.5 gwei for tx with a single request (0.0005 eth / 200k gas = 2.5 gwei per gas) + /// - when gas price is up to 16.6 gwei for batched tx of 10 requests (10 * 0.0005 eth / 300k gas = 16.6 gwei per gas) + uint256 public constant MAX_GAS_COST_COVERAGE = 0.0005 ether; /// @notice Minimal value (assets - stETH to rebalance) that is possible to request /// @dev Prevents placing many small requests @@ -86,8 +92,8 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea uint256 stvRate; /// @notice Steth share rate at the moment of finalization (1e18 precision) uint128 stethShareRate; - /// @notice Withdrawal fee for the requests in this batch - uint64 withdrawalFee; + /// @notice Gas cost coverage for the requests in this checkpoint + uint64 gasCostCoverage; } /// @notice Output format struct for `getWithdrawalStatus()` / `getWithdrawalStatusBatch()` methods @@ -131,8 +137,8 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea uint96 lastCheckpointIndex; /// @dev amount of ETH locked on contract for further claiming uint96 totalLockedAssets; - /// @dev withdrawal fee in wei - uint64 withdrawalFee; + /// @dev request finalization gas cost coverage in wei + uint64 gasCostCoverage; } // keccak256(abi.encode(uint256(keccak256("pool.storage.WithdrawalQueue")) - 1)) & ~bytes32(uint256(0xff)) @@ -165,13 +171,13 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea event WithdrawalClaimed( uint256 indexed requestId, address indexed owner, address indexed receiver, uint256 amountOfETH ); - event WithdrawalFeeSet(uint256 newFee); + event GasCostCoverageSet(uint256 newCoverage); event EmergencyExitActivated(uint256 timestamp); error ZeroAddress(); error RequestValueTooSmall(uint256 amount); error RequestAssetsTooLarge(uint256 amount); - error WithdrawalFeeTooLarge(uint256 amount); + error GasCostCoverageTooLarge(uint256 amount); error InvalidRequestId(uint256 requestId); error InvalidRange(uint256 start, uint256 end); error RequestAlreadyClaimed(uint256 requestId); @@ -359,36 +365,36 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea } // ================================================================================= - // WITHDRAWAL FEES + // GAS COST COVERAGE // ================================================================================= /** - * @notice Set the withdrawal fee - * @param _fee The withdrawal fee in wei - * @dev Reverts if `_fee` is greater than `MAX_WITHDRAWAL_FEE` - * @dev 0 by default. Can be set to compensate for gas costs of finalization transactions + * @notice Set the gas cost coverage that applies to each request during finalization + * @param _coverage The gas cost coverage per request in wei + * @dev Reverts if `_coverage` is greater than `MAX_GAS_COST_COVERAGE` + * @dev 0 by default. Increasing coverage discourages malicious actors from creating + * excessive requests while compensating finalizers for gas expenses */ - function setWithdrawalFee(uint256 _fee) external { + function setFinalizationGasCostCoverage(uint256 _coverage) external { _checkRole(FINALIZE_ROLE, msg.sender); if (isEmergencyExitActivated()) revert CantBeSetInEmergencyExitMode(); - _setWithdrawalFee(_fee); + _setFinalizationGasCostCoverage(_coverage); } - function _setWithdrawalFee(uint256 _fee) internal { - if (_fee > MAX_WITHDRAWAL_FEE) revert WithdrawalFeeTooLarge(_fee); + function _setFinalizationGasCostCoverage(uint256 _coverage) internal { + if (_coverage > MAX_GAS_COST_COVERAGE) revert GasCostCoverageTooLarge(_coverage); - _getWithdrawalQueueStorage().withdrawalFee = uint64(_fee); - emit WithdrawalFeeSet(_fee); + _getWithdrawalQueueStorage().gasCostCoverage = uint64(_coverage); + emit GasCostCoverageSet(_coverage); } /** - * @notice Get the current withdrawal fee - * @return fee The withdrawal fee in wei - * @dev Used to cover gas costs of finalization transactions + * @notice Get the current gas cost coverage that applies to each request during finalization + * @return coverage The gas cost coverage per request in wei */ - function getWithdrawalFee() external view returns (uint256 fee) { - fee = _getWithdrawalQueueStorage().withdrawalFee; + function getFinalizationGasCostCoverage() external view returns (uint256 coverage) { + coverage = _getWithdrawalQueueStorage().gasCostCoverage; } // ================================================================================= @@ -434,14 +440,14 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea uint256 totalStvToBurn; uint256 totalStethShares; uint256 totalEthToClaim; - uint256 totalWithdrawalFee; + uint256 totalGasCoverage; uint256 maxStvToRebalance; Checkpoint memory checkpoint = Checkpoint({ fromRequestId: firstRequestIdToFinalize, stvRate: currentStvRate, stethShareRate: uint128(currentStethShareRate), - withdrawalFee: $.withdrawalFee + gasCostCoverage: $.gasCostCoverage }); // Finalize requests one by one until conditions are met @@ -454,13 +460,13 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea // - ethToClaim: amount of ETH that can be claimed for this request, excluding rebalancing and fees // - stethSharesToRebalance: amount of steth shares to rebalance for this request // - stethToRebalance: amount of steth corresponding to stethSharesToRebalance at the current rate - // - withdrawalFee: fee to be paid for this request + // - gasCostCoverage: amount of ETH that should be subtracted as gas cost coverage for this request ( uint256 stv, uint256 ethToClaim, uint256 stethSharesToRebalance, uint256 stethToRebalance, - uint256 withdrawalFee + uint256 gasCostCoverage ) = _calcRequestAmounts(prevRequest, currRequest, checkpoint); // Handle rebalancing if applicable @@ -492,18 +498,18 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea // Stop if insufficient available ETH to cover claimable and rebalancable ETH for this request // Stop if not enough time has passed since the request was created // Stop if the request was created after the latest report was published, at least one oracle report is required - (ethToClaim + withdrawalFee) > withdrawableValue - || (ethToClaim + ethToRebalance + withdrawalFee) > availableBalance + (ethToClaim + gasCostCoverage) > withdrawableValue + || (ethToClaim + ethToRebalance + gasCostCoverage) > availableBalance || currRequest.timestamp + MIN_WITHDRAWAL_DELAY_TIME_IN_SECONDS > block.timestamp || currRequest.timestamp > latestReportTimestamp ) { break; } - withdrawableValue -= (ethToClaim + withdrawalFee); - availableBalance -= (ethToClaim + withdrawalFee + ethToRebalance); + withdrawableValue -= (ethToClaim + gasCostCoverage); + availableBalance -= (ethToClaim + gasCostCoverage + ethToRebalance); totalEthToClaim += ethToClaim; - totalWithdrawalFee += withdrawalFee; + totalGasCoverage += gasCostCoverage; totalStvToBurn += (stv - stvToRebalance); totalStethShares += stethSharesToRebalance; maxStvToRebalance += stvToRebalance; @@ -515,8 +521,8 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea // 1. Withdraw ETH from the vault to cover finalized requests and burn associated stv // Eth to claim or stv to burn could be 0 if all requests are going to be rebalanced // Rebalance cannot be done first because it will withdraw eth without unlocking it - if (totalEthToClaim + totalWithdrawalFee > 0) { - DASHBOARD.withdraw(address(this), totalEthToClaim + totalWithdrawalFee); + if (totalEthToClaim + totalGasCoverage > 0) { + DASHBOARD.withdraw(address(this), totalEthToClaim + totalGasCoverage); } if (totalStvToBurn > 0) POOL.burnStvForWithdrawalQueue(totalStvToBurn); @@ -546,7 +552,7 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea lastFinalizedRequestId = lastFinalizedRequestId + finalizedRequests; - // Store checkpoint with stvRate, stethShareRate and withdrawalFee + // Store checkpoint with current stvRate, stethShareRate and gasCostCoverage uint256 lastCheckpointIndex = $.lastCheckpointIndex + 1; $.checkpoints[lastCheckpointIndex] = checkpoint; $.lastCheckpointIndex = uint96(lastCheckpointIndex); @@ -554,9 +560,9 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea $.lastFinalizedRequestId = uint96(lastFinalizedRequestId); $.totalLockedAssets += uint96(totalEthToClaim); - // Send withdrawal fee to the caller - if (totalWithdrawalFee > 0) { - (bool success,) = msg.sender.call{value: totalWithdrawalFee}(""); + // Send gas coverage to the caller + if (totalGasCoverage > 0) { + (bool success,) = msg.sender.call{value: totalGasCoverage}(""); if (!success) revert CantSendValueRecipientMayHaveReverted(); } @@ -931,7 +937,7 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea uint256 assetsToClaim, uint256 stethSharesToRebalance, uint256 assetsToRebalance, - uint256 withdrawalFee + uint256 gasCostCoverage ) { stv = _request.cumulativeStv - _prevRequest.cumulativeStv; @@ -954,10 +960,10 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea assetsToClaim = Math.saturatingSub(assetsToClaim, assetsToRebalance); } - // Apply withdrawal fee - if (checkpoint.withdrawalFee > 0) { - withdrawalFee = Math.min(assetsToClaim, checkpoint.withdrawalFee); - assetsToClaim -= withdrawalFee; + // Apply request finalization gas cost coverage + if (checkpoint.gasCostCoverage > 0) { + gasCostCoverage = Math.min(assetsToClaim, checkpoint.gasCostCoverage); + assetsToClaim -= gasCostCoverage; } } @@ -1060,7 +1066,7 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea revert InvalidEmergencyExitActivation(); } - _setWithdrawalFee(MAX_WITHDRAWAL_FEE); + _setFinalizationGasCostCoverage(MAX_GAS_COST_COVERAGE); $.emergencyExitActivationTimestamp = uint40(block.timestamp); emit EmergencyExitActivated($.emergencyExitActivationTimestamp); diff --git a/test/unit/withdrawal-queue/EmergencyExit.test.sol b/test/unit/withdrawal-queue/EmergencyExit.test.sol index 5bb3099..5dd44f9 100644 --- a/test/unit/withdrawal-queue/EmergencyExit.test.sol +++ b/test/unit/withdrawal-queue/EmergencyExit.test.sol @@ -84,7 +84,7 @@ contract EmergencyExitTest is Test, SetupWithdrawalQueue { assertTrue(withdrawalQueue.isEmergencyExitActivated()); } - function test_EmergencyExit_SetsMaxWithdrawalFee() public { + function test_EmergencyExit_SetsMaxGasCoverage() public { withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); // Make queue stuck @@ -97,12 +97,12 @@ contract EmergencyExitTest is Test, SetupWithdrawalQueue { emit WithdrawalQueue.EmergencyExitActivated(block.timestamp); // Initially fee is zero - assertEq(withdrawalQueue.getWithdrawalFee(), 0); + assertEq(withdrawalQueue.getFinalizationGasCostCoverage(), 0); withdrawalQueue.activateEmergencyExit(); // After activation fee is set to max - assertEq(withdrawalQueue.getWithdrawalFee(), withdrawalQueue.MAX_WITHDRAWAL_FEE()); + assertEq(withdrawalQueue.getFinalizationGasCostCoverage(), withdrawalQueue.MAX_GAS_COST_COVERAGE()); } function test_EmergencyExit_RevertWhenNotStuck() public { diff --git a/test/unit/withdrawal-queue/FeeConfig.test.sol b/test/unit/withdrawal-queue/FeeConfig.test.sol deleted file mode 100644 index 3af4b1c..0000000 --- a/test/unit/withdrawal-queue/FeeConfig.test.sol +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; - -import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; -import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; -import {Test} from "forge-std/Test.sol"; -import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; - -contract FeeConfigTest is Test, SetupWithdrawalQueue { - function setUp() public override { - super.setUp(); - - pool.depositETH{value: 1_000 ether}(address(this), address(0)); - } - - // Default value - - function test_GetWithdrawalFee_DefaultZero() public view { - assertEq(withdrawalQueue.getWithdrawalFee(), 0); - } - - // Setter - - function test_SetWithdrawalFee_UpdatesValue() public { - uint256 fee = 0.0001 ether; - - vm.prank(finalizeRoleHolder); - withdrawalQueue.setWithdrawalFee(fee); - assertEq(withdrawalQueue.getWithdrawalFee(), fee); - } - - function test_SetWithdrawalFee_RevertAboveMax() public { - uint256 fee = withdrawalQueue.MAX_WITHDRAWAL_FEE() + 1; - - vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.WithdrawalFeeTooLarge.selector, fee)); - vm.prank(finalizeRoleHolder); - withdrawalQueue.setWithdrawalFee(fee); - } - - function test_SetWithdrawalFee_MaxValueCanBeSet() public { - uint256 fee = withdrawalQueue.MAX_WITHDRAWAL_FEE(); - - vm.prank(finalizeRoleHolder); - withdrawalQueue.setWithdrawalFee(fee); - assertEq(withdrawalQueue.getWithdrawalFee(), fee); - } - - // Access control - - function test_SetWithdrawalFee_CanBeCalledByFinalizeRole() public { - vm.prank(finalizeRoleHolder); - withdrawalQueue.setWithdrawalFee(0.0001 ether); - } - - function test_SetWithdrawalFee_CantBeCalledStranger() public { - vm.expectRevert( - abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), withdrawalQueue.FINALIZE_ROLE() - ) - ); - withdrawalQueue.setWithdrawalFee(0.0001 ether); - } - - function test_SetWithdrawalFee_RevertInEmergencyExit() public { - withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - - vm.warp(block.timestamp + withdrawalQueue.MAX_ACCEPTABLE_WQ_FINALIZATION_TIME_IN_SECONDS() + 1); - withdrawalQueue.activateEmergencyExit(); - - vm.prank(finalizeRoleHolder); - vm.expectRevert(WithdrawalQueue.CantBeSetInEmergencyExitMode.selector); - withdrawalQueue.setWithdrawalFee(0.0001 ether); - } -} diff --git a/test/unit/withdrawal-queue/Finalization.test.sol b/test/unit/withdrawal-queue/Finalization.test.sol index 4357f09..45beafd 100644 --- a/test/unit/withdrawal-queue/Finalization.test.sol +++ b/test/unit/withdrawal-queue/Finalization.test.sol @@ -305,9 +305,9 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { } function test_Finalize_RevertWhenFinalizerCannotReceiveFee() public { - uint256 fee = 0.0001 ether; + uint256 coverage = 0.0001 ether; vm.prank(finalizeRoleHolder); - withdrawalQueue.setWithdrawalFee(fee); + withdrawalQueue.setFinalizationGasCostCoverage(coverage); RevertingFinalizer finalizer = new RevertingFinalizer(withdrawalQueue); bytes32 finalizeRole = withdrawalQueue.FINALIZE_ROLE(); diff --git a/test/unit/withdrawal-queue/FeeFinalization.test.sol b/test/unit/withdrawal-queue/GasCostCoverage.test.sol similarity index 66% rename from test/unit/withdrawal-queue/FeeFinalization.test.sol rename to test/unit/withdrawal-queue/GasCostCoverage.test.sol index e72e15c..2b48284 100644 --- a/test/unit/withdrawal-queue/FeeFinalization.test.sol +++ b/test/unit/withdrawal-queue/GasCostCoverage.test.sol @@ -5,7 +5,7 @@ import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; import {Test} from "forge-std/Test.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; -contract FeeFinalizationTest is Test, SetupWithdrawalQueue { +contract GasCostCoverageTest is Test, SetupWithdrawalQueue { function setUp() public override { super.setUp(); @@ -15,31 +15,31 @@ contract FeeFinalizationTest is Test, SetupWithdrawalQueue { pool.depositETH{value: 100_000 ether}(address(this), address(0)); } - function _setWithdrawalFee(uint256 fee) internal { + function _setGasCostCoverage(uint256 coverage) internal { vm.prank(finalizeRoleHolder); - withdrawalQueue.setWithdrawalFee(fee); + withdrawalQueue.setFinalizationGasCostCoverage(coverage); } - function test_FinalizeFee_ZeroFeeDoesNotPayFinalizer() public { + function test_FinalizeGasCostCoverage_ZeroCoverageDoesNotPayFinalizer() public { uint256 initialBalance = finalizeRoleHolder.balance; _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); assertEq(finalizeRoleHolder.balance, initialBalance); } - function test_FinalizeFee_PaysFinalizerWhenSet() public { - uint256 fee = 0.0005 ether; + function test_FinalizeGasCostCoverage_PaysFinalizerWhenSet() public { + uint256 coverage = 0.0005 ether; uint256 initialBalance = finalizeRoleHolder.balance; - _setWithdrawalFee(fee); + _setGasCostCoverage(coverage); _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); - assertEq(finalizeRoleHolder.balance, initialBalance + fee); + assertEq(finalizeRoleHolder.balance, initialBalance + coverage); } - function test_FinalizeFee_ReducesClaimByFee() public { - uint256 fee = 0.0005 ether; - _setWithdrawalFee(fee); + function test_FinalizeGasCostCoverage_ReducesClaimByCoverage() public { + uint256 coverage = 0.0005 ether; + _setGasCostCoverage(coverage); uint256 stvToRequest = 10 ** STV_DECIMALS; uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, 0); @@ -49,25 +49,25 @@ contract FeeFinalizationTest is Test, SetupWithdrawalQueue { uint256 balanceBefore = address(this).balance; uint256 claimed = withdrawalQueue.claimWithdrawal(address(this), requestId); - assertEq(claimed, expectedAssets - fee); + assertEq(claimed, expectedAssets - coverage); assertEq(address(this).balance, balanceBefore + claimed); } - function test_FinalizeFee_ReducesClaimableByFee() public { - uint256 fee = 0.0005 ether; - _setWithdrawalFee(fee); + function test_FinalizeGasCostCoverage_ReducesClaimableByCoverage() public { + uint256 coverage = 0.0005 ether; + _setGasCostCoverage(coverage); uint256 stvToRequest = 10 ** STV_DECIMALS; uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, 0); uint256 expectedAssets = pool.previewRedeem(stvToRequest); _finalizeRequests(1); - assertEq(withdrawalQueue.getClaimableEther(requestId), expectedAssets - fee); + assertEq(withdrawalQueue.getClaimableEther(requestId), expectedAssets - coverage); } - function test_FinalizeFee_RequestWithRebalance() public { - uint256 fee = 0.0005 ether; - _setWithdrawalFee(fee); + function test_FinalizeGasCostCoverage_RequestWithRebalance() public { + uint256 coverage = 0.0005 ether; + _setGasCostCoverage(coverage); uint256 mintedStethShares = 10 ** ASSETS_DECIMALS; uint256 stvToRequest = 2 * 10 ** STV_DECIMALS; @@ -75,7 +75,7 @@ contract FeeFinalizationTest is Test, SetupWithdrawalQueue { uint256 totalAssets = pool.previewRedeem(stvToRequest); uint256 assetsToRebalance = pool.STETH().getPooledEthBySharesRoundUp(mintedStethShares); - uint256 expectedClaimable = totalAssets - assetsToRebalance - fee; + uint256 expectedClaimable = totalAssets - assetsToRebalance - coverage; assertGt(expectedClaimable, 0); uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, mintedStethShares); @@ -84,23 +84,24 @@ contract FeeFinalizationTest is Test, SetupWithdrawalQueue { assertEq(withdrawalQueue.getClaimableEther(requestId), expectedClaimable); } - function test_FinalizeFee_FeeCapsToRemainingAssets() public { - uint256 fee = withdrawalQueue.MAX_WITHDRAWAL_FEE(); - _setWithdrawalFee(fee); + function test_FinalizeGasCostCoverage_CoverageCapsToRemainingAssets() public { + uint256 coverage = withdrawalQueue.MAX_GAS_COST_COVERAGE(); + uint256 minValue = withdrawalQueue.MIN_WITHDRAWAL_VALUE(); + _setGasCostCoverage(coverage); - uint256 stvToRequest = (10 ** STV_DECIMALS / 1 ether) * fee; + uint256 stvToRequest = (10 ** STV_DECIMALS / 1 ether) * minValue; uint256 totalAssets = pool.previewRedeem(stvToRequest); - assertEq(totalAssets, fee); + assertEq(totalAssets, minValue); uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, 0); - dashboard.mock_simulateRewards(-int256(1 ether)); + dashboard.mock_simulateRewards(-int256(pool.totalAssets() - 1 ether)); uint256 finalizerBalanceBefore = finalizeRoleHolder.balance; _finalizeRequests(1); uint256 finalizerBalanceAfter = finalizeRoleHolder.balance; assertGt(finalizerBalanceAfter, finalizerBalanceBefore); - assertLt(finalizerBalanceAfter - finalizerBalanceBefore, fee); + assertLt(finalizerBalanceAfter - finalizerBalanceBefore, coverage); assertEq(withdrawalQueue.getClaimableEther(requestId), 0); } diff --git a/test/unit/withdrawal-queue/GasCostCoverageConfig.test.sol b/test/unit/withdrawal-queue/GasCostCoverageConfig.test.sol new file mode 100644 index 0000000..68a502f --- /dev/null +++ b/test/unit/withdrawal-queue/GasCostCoverageConfig.test.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25; + +import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Test} from "forge-std/Test.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; + +contract GasCostCoverageConfigTest is Test, SetupWithdrawalQueue { + function setUp() public override { + super.setUp(); + + pool.depositETH{value: 1_000 ether}(address(this), address(0)); + } + + // Default value + + function test_GetFinalizationGasCostCoverage_DefaultZero() public view { + assertEq(withdrawalQueue.getFinalizationGasCostCoverage(), 0); + } + + // Setter + + function test_SetFinalizationGasCostCoverage_UpdatesValue() public { + uint256 coverage = 0.0001 ether; + + vm.prank(finalizeRoleHolder); + withdrawalQueue.setFinalizationGasCostCoverage(coverage); + assertEq(withdrawalQueue.getFinalizationGasCostCoverage(), coverage); + } + + function test_SetFinalizationGasCostCoverage_RevertAboveMax() public { + uint256 coverage = withdrawalQueue.MAX_GAS_COST_COVERAGE() + 1; + + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.GasCostCoverageTooLarge.selector, coverage)); + vm.prank(finalizeRoleHolder); + withdrawalQueue.setFinalizationGasCostCoverage(coverage); + } + + function test_SetFinalizationGasCostCoverage_MaxValueCanBeSet() public { + uint256 coverage = withdrawalQueue.MAX_GAS_COST_COVERAGE(); + + vm.prank(finalizeRoleHolder); + withdrawalQueue.setFinalizationGasCostCoverage(coverage); + assertEq(withdrawalQueue.getFinalizationGasCostCoverage(), coverage); + } + + // Access control + + function test_SetFinalizationGasCostCoverage_CanBeCalledByFinalizeRole() public { + vm.prank(finalizeRoleHolder); + withdrawalQueue.setFinalizationGasCostCoverage(0.0001 ether); + } + + function test_SetFinalizationGasCostCoverage_CantBeCalledStranger() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), withdrawalQueue.FINALIZE_ROLE() + ) + ); + withdrawalQueue.setFinalizationGasCostCoverage(0.0001 ether); + } + + function test_SetFinalizationGasCostCoverage_RevertInEmergencyExit() public { + withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); + + vm.warp(block.timestamp + withdrawalQueue.MAX_ACCEPTABLE_WQ_FINALIZATION_TIME_IN_SECONDS() + 1); + withdrawalQueue.activateEmergencyExit(); + + vm.prank(finalizeRoleHolder); + vm.expectRevert(WithdrawalQueue.CantBeSetInEmergencyExitMode.selector); + withdrawalQueue.setFinalizationGasCostCoverage(0.0001 ether); + } +} From 5344061e996d636da7a30b544982d9f2f05943d7 Mon Sep 17 00:00:00 2001 From: George Avsetsin Date: Wed, 5 Nov 2025 11:32:10 +0300 Subject: [PATCH 20/20] feat: allow specifying gas-cost coverage recipient in finalize --- src/WithdrawalQueue.sol | 11 +- test/integration/ggv.test.sol | 59 ++++------ test/integration/stv-pool.test.sol | 44 +++++--- test/integration/stv-steth-pool.test.sol | 101 ++++++++++++------ test/unit/withdrawal-queue/BadDebt.test.sol | 2 +- .../withdrawal-queue/EmergencyExit.test.sol | 4 +- .../withdrawal-queue/Finalization.test.sol | 48 ++++----- .../withdrawal-queue/GasCostCoverage.test.sol | 23 ++++ test/unit/withdrawal-queue/HappyPath.test.sol | 2 +- test/unit/withdrawal-queue/Rebalance.test.sol | 4 +- .../withdrawal-queue/SetupWithdrawalQueue.sol | 2 +- test/unit/withdrawal-queue/Views.test.sol | 4 +- 12 files changed, 185 insertions(+), 119 deletions(-) diff --git a/src/WithdrawalQueue.sol b/src/WithdrawalQueue.sol index 865837e..af19e0a 100644 --- a/src/WithdrawalQueue.sol +++ b/src/WithdrawalQueue.sol @@ -409,11 +409,15 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea /** * @notice Finalize withdrawal requests * @param _maxRequests The maximum number of requests to finalize + * @param _gasCostCoverageRecipient The address to receive gas cost coverage * @return finalizedRequests The number of requests that were finalized * @dev Reverts if there are no requests to finalize * @dev In emergency exit mode, anyone can finalize without restrictions */ - function finalize(uint256 _maxRequests) external returns (uint256 finalizedRequests) { + function finalize(uint256 _maxRequests, address _gasCostCoverageRecipient) + external + returns (uint256 finalizedRequests) + { if (!isEmergencyExitActivated()) { _requireNotPaused(); _checkRole(FINALIZE_ROLE, msg.sender); @@ -562,7 +566,10 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea // Send gas coverage to the caller if (totalGasCoverage > 0) { - (bool success,) = msg.sender.call{value: totalGasCoverage}(""); + // Set gas cost coverage recipient to msg.sender if not specified + if (_gasCostCoverageRecipient == address(0)) _gasCostCoverageRecipient = msg.sender; + + (bool success,) = _gasCostCoverageRecipient.call{value: totalGasCoverage}(""); if (!success) revert CantSendValueRecipientMayHaveReverted(); } diff --git a/test/integration/ggv.test.sol b/test/integration/ggv.test.sol index a94b7ae..7af59ce 100644 --- a/test/integration/ggv.test.sol +++ b/test/integration/ggv.test.sol @@ -5,22 +5,22 @@ import {console} from "forge-std/Test.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import {ITellerWithMultiAssetSupport} from "src/interfaces/ggv/ITellerWithMultiAssetSupport.sol"; import {IBoringOnChainQueue} from "src/interfaces/ggv/IBoringOnChainQueue.sol"; import {IBoringSolver} from "src/interfaces/ggv/IBoringSolver.sol"; +import {ITellerWithMultiAssetSupport} from "src/interfaces/ggv/ITellerWithMultiAssetSupport.sol"; import {StvStrategyPoolHarness} from "test/utils/StvStrategyPoolHarness.sol"; -import {GGVStrategy} from "src/strategy/GGVStrategy.sol"; +import {StvStETHPool} from "src/StvStETHPool.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; import {IStrategy} from "src/interfaces/IStrategy.sol"; -import {StvStETHPool} from "src/StvStETHPool.sol"; +import {GGVStrategy} from "src/strategy/GGVStrategy.sol"; import {TableUtils} from "../utils/format/TableUtils.sol"; -import {GGVVaultMock} from "src/mock/ggv/GGVVaultMock.sol"; +import {AllowList} from "src/AllowList.sol"; import {GGVMockTeller} from "src/mock/ggv/GGVMockTeller.sol"; import {GGVQueueMock} from "src/mock/ggv/GGVQueueMock.sol"; -import {AllowList} from "src/AllowList.sol"; +import {GGVVaultMock} from "src/mock/ggv/GGVVaultMock.sol"; interface IAuthority { function setUserRole(address user, uint8 role, bool enabled) external; @@ -97,13 +97,7 @@ contract GGVTest is StvStrategyPoolHarness { user2StrategyCallForwarder = ggvStrategy.getStrategyCallForwarderAddress(USER2); vm.label(user2StrategyCallForwarder, "User2StrategyCallForwarder"); - _log.init( - address(pool), - address(boringVault), - address(steth), - address(wsteth), - address(boringOnChainQueue) - ); + _log.init(address(pool), address(boringVault), address(steth), address(wsteth), address(boringOnChainQueue)); vm.startPrank(ADMIN); steth.submit{value: 10 ether}(ADMIN); @@ -188,18 +182,15 @@ contract GGVTest is StvStrategyPoolHarness { // _log.printUsers("[SCENARIO] After report (increase vault balance)", logUsers, ggvDiscount); -// 3. Request withdrawal (full amount, based on appreciated value) + // 3. Request withdrawal (full amount, based on appreciated value) uint256 totalGgvShares = boringVault.balanceOf(user1StrategyCallForwarder); uint256 withdrawalStethAmount = boringOnChainQueue.previewAssetsOut(address(steth), uint128(totalGgvShares), uint16(ggvDiscount)); console.log("\n[SCENARIO] Requesting withdrawal based on new appreciated assets:", withdrawalStethAmount); - GGVStrategy.GGVParams memory params = GGVStrategy.GGVParams({ - discount: uint16(ggvDiscount), - minimumMint: 0, - secondsToDeadline: type(uint24).max - }); + GGVStrategy.GGVParams memory params = + GGVStrategy.GGVParams({discount: uint16(ggvDiscount), minimumMint: 0, secondsToDeadline: type(uint24).max}); vm.prank(USER1); bytes32 requestId = ggvStrategy.requestExitByStETH(withdrawalStethAmount, abi.encode(params)); @@ -263,20 +254,20 @@ contract GGVTest is StvStrategyPoolHarness { _log.printUsers("After User Claims ETH", logUsers, ggvDiscount); -// // 8. Recover Surplus stETH (если есть) -// uint256 surplusStETH = steth.balanceOf(user1StrategyCallForwarder); -// if (surplusStETH > 0) { -// uint256 stethBalance = steth.sharesOf(user1StrategyCallForwarder); -// uint256 stethDebt = pool.mintedStethSharesOf(user1StrategyCallForwarder); -// uint256 surplusInShares = stethBalance > stethDebt ? stethBalance - stethDebt : 0; -// uint256 maxAmount = steth.getPooledEthByShares(surplusInShares); + // // 8. Recover Surplus stETH (если есть) + // uint256 surplusStETH = steth.balanceOf(user1StrategyCallForwarder); + // if (surplusStETH > 0) { + // uint256 stethBalance = steth.sharesOf(user1StrategyCallForwarder); + // uint256 stethDebt = pool.mintedStethSharesOf(user1StrategyCallForwarder); + // uint256 surplusInShares = stethBalance > stethDebt ? stethBalance - stethDebt : 0; + // uint256 maxAmount = steth.getPooledEthByShares(surplusInShares); -// console.log("\n[SCENARIO] Step 8. Recover Surplus stETH:", maxAmount); -// vm.prank(USER1); -// ggvStrategy.recoverERC20(address(steth), USER1, maxAmount); -// } + // console.log("\n[SCENARIO] Step 8. Recover Surplus stETH:", maxAmount); + // vm.prank(USER1); + // ggvStrategy.recoverERC20(address(steth), USER1, maxAmount); + // } -// _log.printUsers("After Recovery", logUsers); + // _log.printUsers("After Recovery", logUsers); } function _finalizeWQ(uint256 _maxRequest, uint256 vaultProfit) public { @@ -284,11 +275,7 @@ contract GGVTest is StvStrategyPoolHarness { vm.warp(block.timestamp + 1 days); core.applyVaultReport( - address(pool.STAKING_VAULT()), - pool.totalAssets(), - 0, - pool.DASHBOARD().liabilityShares(), - 0 + address(pool.STAKING_VAULT()), pool.totalAssets(), 0, pool.DASHBOARD().liabilityShares(), 0 ); if (vaultProfit != 0) { @@ -298,7 +285,7 @@ contract GGVTest is StvStrategyPoolHarness { } vm.startPrank(NODE_OPERATOR); - uint256 finalizedRequests = pool.WITHDRAWAL_QUEUE().finalize(_maxRequest); + uint256 finalizedRequests = pool.WITHDRAWAL_QUEUE().finalize(_maxRequest, address(0)); vm.stopPrank(); assertEq(finalizedRequests, _maxRequest, "Invalid finalized requests"); diff --git a/test/integration/stv-pool.test.sol b/test/integration/stv-pool.test.sol index 7c7be15..4588dd0 100644 --- a/test/integration/stv-pool.test.sol +++ b/test/integration/stv-pool.test.sol @@ -22,7 +22,9 @@ contract StvPoolTest is StvPoolHarness { vm.prank(USER1); ctx.pool.depositETH{value: depositAmount}(USER1, address(0)); assertEq(ctx.pool.balanceOf(USER1), expectedStv, "minted shares should match previewDeposit"); - assertEq(address(ctx.vault).balance, depositAmount + CONNECT_DEPOSIT, "vault balance should match deposit amount"); + assertEq( + address(ctx.vault).balance, depositAmount + CONNECT_DEPOSIT, "vault balance should match deposit amount" + ); // 2) USER1 immediately requests withdrawal of all their shares vm.prank(USER1); @@ -36,7 +38,7 @@ contract StvPoolTest is StvPoolHarness { // 4) Node Operator finalizes one request vm.prank(NODE_OPERATOR); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); // 5) USER1 claims uint256 userBalanceBefore = USER1.balance; @@ -56,7 +58,9 @@ contract StvPoolTest is StvPoolHarness { vm.prank(USER1); ctx.pool.depositETH{value: depositAmount}(USER1, address(0)); assertEq(ctx.pool.balanceOf(USER1), expectedStv, "minted shares should match previewDeposit"); - assertEq(address(ctx.vault).balance, depositAmount + CONNECT_DEPOSIT, "vault balance should match deposit amount"); + assertEq( + address(ctx.vault).balance, depositAmount + CONNECT_DEPOSIT, "vault balance should match deposit amount" + ); // 2) USER1 immediately requests withdrawal of all their shares vm.prank(USER1); @@ -74,7 +78,7 @@ contract StvPoolTest is StvPoolHarness { // 6) Node Operator finalizes one request vm.prank(NODE_OPERATOR); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); // 7) USER1 claims uint256 userBalanceBefore = USER1.balance; @@ -95,7 +99,9 @@ contract StvPoolTest is StvPoolHarness { vm.prank(USER1); ctx.pool.depositETH{value: depositAmount}(USER1, address(0)); assertEq(ctx.pool.balanceOf(USER1), expectedStv, "minted shares should match previewDeposit"); - assertEq(address(ctx.vault).balance, depositAmount + CONNECT_DEPOSIT, "vault balance should match deposit amount"); + assertEq( + address(ctx.vault).balance, depositAmount + CONNECT_DEPOSIT, "vault balance should match deposit amount" + ); // 2) Apply +3% rewards via vault report BEFORE withdrawal request reportVaultValueChangeNoFees(ctx, 10300); // +3% @@ -103,7 +109,9 @@ contract StvPoolTest is StvPoolHarness { // 3) Now request withdrawal of all USER1 shares // Expected ETH is increased by ~3% compared to initial deposit uint256 expectedEth = ctx.pool.previewRedeem(expectedStv); - assertApproxEqAbs(expectedEth, (depositAmount * 103) / 100, WEI_ROUNDING_TOLERANCE, "expected eth should be ~+3% of deposit"); + assertApproxEqAbs( + expectedEth, (depositAmount * 103) / 100, WEI_ROUNDING_TOLERANCE, "expected eth should be ~+3% of deposit" + ); vm.prank(USER1); uint256 requestId = ctx.withdrawalQueue.requestWithdrawal(USER1, expectedStv, 0); @@ -112,7 +120,7 @@ contract StvPoolTest is StvPoolHarness { // 6) Node Operator finalizes one request vm.prank(NODE_OPERATOR); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); // 7) USER1 claims and receives the increased amount uint256 userBalanceBefore = USER1.balance; @@ -137,7 +145,9 @@ contract StvPoolTest is StvPoolHarness { vm.prank(USER1); ctx.pool.depositETH{value: depositAmount}(USER1, address(0)); assertEq(ctx.pool.balanceOf(USER1), expectedStv, "minted shares should match previewDeposit"); - assertEq(address(ctx.vault).balance, depositAmount + CONNECT_DEPOSIT, "vault balance should match deposit amount"); + assertEq( + address(ctx.vault).balance, depositAmount + CONNECT_DEPOSIT, "vault balance should match deposit amount" + ); // 2) USER1 immediately requests withdrawal of all their shares vm.prank(USER1); @@ -151,12 +161,16 @@ contract StvPoolTest is StvPoolHarness { reportVaultValueChangeNoFees(ctx, 9900); // -1% // After the loss report, totalValue should be less than CONNECT_DEPOSIT - assertLt(ctx.dashboard.totalValue(), CONNECT_DEPOSIT, "totalValue should be less than CONNECT_DEPOSIT after loss report"); + assertLt( + ctx.dashboard.totalValue(), + CONNECT_DEPOSIT, + "totalValue should be less than CONNECT_DEPOSIT after loss report" + ); // Finalization should revert due to insufficient ETH to cover the request vm.prank(NODE_OPERATOR); vm.expectRevert(); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); } function test_withdrawal_request_finalized_after_reward_and_loss_reports() public { @@ -184,7 +198,7 @@ contract StvPoolTest is StvPoolHarness { // 6) Node Operator finalizes one request vm.prank(NODE_OPERATOR); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); // 7) USER1 claims and receives the decreased amount uint256 userBalanceBefore = USER1.balance; @@ -229,7 +243,7 @@ contract StvPoolTest is StvPoolHarness { // 4) Finalize both requests vm.prank(NODE_OPERATOR); - uint256 finalized = ctx.withdrawalQueue.finalize(2); + uint256 finalized = ctx.withdrawalQueue.finalize(2, address(0)); assertEq(finalized, 2, "should finalize both partial requests"); // 5) Claim both and verify total equals sum of previews; user ends with zero shares @@ -277,7 +291,7 @@ contract StvPoolTest is StvPoolHarness { _withdrawFromCL(ctx, firstAssets); vm.prank(NODE_OPERATOR); - uint256 finalized = ctx.withdrawalQueue.finalize(2); + uint256 finalized = ctx.withdrawalQueue.finalize(2, address(0)); assertEq(finalized, 1, "should finalize only the first request due to insufficient withdrawable"); // 5) Claim first, second remains unfinalized @@ -290,7 +304,7 @@ contract StvPoolTest is StvPoolHarness { _withdrawFromCL(ctx, secondAssets); vm.prank(NODE_OPERATOR); - finalized = ctx.withdrawalQueue.finalize(1); + finalized = ctx.withdrawalQueue.finalize(1, address(0)); assertEq(finalized, 1, "second request should now finalize after funding"); // 7) Claim second @@ -348,7 +362,7 @@ contract StvPoolTest is StvPoolHarness { // Finalize vm.prank(NODE_OPERATOR); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); // Claim succeeds uint256 before = USER1.balance; diff --git a/test/integration/stv-steth-pool.test.sol b/test/integration/stv-steth-pool.test.sol index 392970b..6edd832 100644 --- a/test/integration/stv-steth-pool.test.sol +++ b/test/integration/stv-steth-pool.test.sol @@ -3,10 +3,10 @@ pragma solidity >=0.8.25; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; -import {StvStETHPoolHarness} from "test/utils/StvStETHPoolHarness.sol"; -import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; import {IStETH} from "src/interfaces/IStETH.sol"; +import {StvStETHPoolHarness} from "test/utils/StvStETHPoolHarness.sol"; /** * @title StvStETHPoolTest @@ -47,12 +47,14 @@ contract StvStETHPoolTest is StvStETHPoolHarness { "stETH shares for withdrawal should be equal to 0" ); assertEq(ctx.dashboard.liabilityShares(), 0, "Vault's liability shares should be equal to 0"); - assertEq(ctx.dashboard.totalValue(), CONNECT_DEPOSIT + user1Deposit, "Vault's total value should be equal to CONNECT_DEPOSIT + user1Deposit"); + assertEq( + ctx.dashboard.totalValue(), + CONNECT_DEPOSIT + user1Deposit, + "Vault's total value should be equal to CONNECT_DEPOSIT + user1Deposit" + ); assertGt( - ctx.dashboard.remainingMintingCapacityShares(0), - 0, - "Remaining minting capacity should be greater than 0" + ctx.dashboard.remainingMintingCapacityShares(0), 0, "Remaining minting capacity should be greater than 0" ); // @@ -85,7 +87,11 @@ contract StvStETHPoolTest is StvStETHPoolHarness { assertGt( ctx.dashboard.remainingMintingCapacityShares(0), 0, "Remaining minting capacity should be greater than 0" ); - assertEq(stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), 0, "Mintable stETH shares should be equal to 0"); + assertEq( + stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), + 0, + "Mintable stETH shares should be equal to 0" + ); } function test_depositETH_with_max_mintable_amount() public { @@ -96,7 +102,7 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // uint256 user1Deposit = 10_000 wei; uint256 user1StethSharesToMint = _calcMaxMintableStShares(ctx, user1Deposit); - + vm.prank(USER1); stvStETHPool(ctx).depositETHAndMintStethShares{value: user1Deposit}(USER1, address(0), user1StethSharesToMint); @@ -117,7 +123,11 @@ contract StvStETHPoolTest is StvStETHPoolHarness { user1StethSharesToMint, "Vault's liability shares should equal minted shares" ); - assertEq(stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), 0, "No additional mintable shares should remain"); + assertEq( + stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), + 0, + "No additional mintable shares should remain" + ); // // Step 2: User deposits more ETH and mints max for new deposit @@ -240,7 +250,11 @@ contract StvStETHPoolTest is StvStETHPoolHarness { ); // Still remaining capacity is higher due to CONNECT_DEPOSIT assertGt(ctx.dashboard.remainingMintingCapacityShares(0), 0, "Remaining minting capacity should be equal to 0"); - assertEq(stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), 0, "Mintable stETH shares should be equal to 0"); + assertEq( + stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), + 0, + "Mintable stETH shares should be equal to 0" + ); } function test_two_users_mint_full_in_two_steps() public { @@ -365,7 +379,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { user1ExpectedMintableStethShares, "USER1 stSharesForWithdrawal should equal full expected" ); - assertEq(stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), 0, "USER1 remaining mintable should be zero"); + assertEq( + stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), 0, "USER1 remaining mintable should be zero" + ); assertEq( ctx.dashboard.liabilityShares(), user1ExpectedMintableStethShares + user2StSharesPart1, @@ -392,7 +408,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { user2ExpectedMintableStethShares, "USER2 stSharesForWithdrawal should equal full expected" ); - assertEq(stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER2, 0), 0, "USER2 remaining mintable should be zero"); + assertEq( + stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER2, 0), 0, "USER2 remaining mintable should be zero" + ); // Still remaining capacity is higher due to CONNECT_DEPOSIT assertGt( ctx.dashboard.remainingMintingCapacityShares(0), 0, "Remaining minting capacity should be greater than 0" @@ -416,7 +434,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { stvStETHPool(ctx).depositETHAndMintStethShares{value: user1Deposit}(USER1, address(0), user1ExpectedMintable); assertEq(steth.sharesOf(USER1), user1ExpectedMintable, "USER1 stETH shares should equal expected minted"); - assertEq(stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), 0, "USER1 remaining mintable should be zero"); + assertEq( + stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), 0, "USER1 remaining mintable should be zero" + ); assertGt( ctx.dashboard.remainingMintingCapacityShares(0), 0, @@ -494,15 +514,24 @@ contract StvStETHPoolTest is StvStETHPoolHarness { ); assertEq(w.unlockedAssetsOf(USER1, 0), user1Rewards, "USER1 withdrawable eth should be equal to user1Rewards"); - assertEq(w.unlockedAssetsOf(USER1, expectedUser1MintedStShares), w.previewRedeem(w.balanceOf(USER1)), "USER1 withdrawable eth should be equal to user1Deposit + user1Rewards"); + assertEq( + w.unlockedAssetsOf(USER1, expectedUser1MintedStShares), + w.previewRedeem(w.balanceOf(USER1)), + "USER1 withdrawable eth should be equal to user1Deposit + user1Rewards" + ); - assertEq(w.stethSharesToBurnForStvOf(USER1, w.balanceOf(USER1)), expectedUser1MintedStShares, "USER1 stSharesForWithdrawal should be equal to expectedUser1MintedStShares"); + assertEq( + w.stethSharesToBurnForStvOf(USER1, w.balanceOf(USER1)), + expectedUser1MintedStShares, + "USER1 stSharesForWithdrawal should be equal to expectedUser1MintedStShares" + ); uint256 rewardsStv = Math.mulDiv(user1Rewards, w.balanceOf(USER1), user1Deposit + user1Rewards, Math.Rounding.Floor); // TODO: fix fail here assertLe( - w.stethSharesToBurnForStvOf(USER1, rewardsStv), WEI_ROUNDING_TOLERANCE, + w.stethSharesToBurnForStvOf(USER1, rewardsStv), + WEI_ROUNDING_TOLERANCE, "USER1 stSharesForWithdrawal for rewards-only should be ~0" ); assertEq(w.stethSharesToBurnForStvOf(USER1, rewardsStv), 0, "USER1 stSharesForWithdrawal should be equal to 0"); @@ -514,9 +543,7 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // uint256 withdrawableStvWithoutBurning = w.unlockedStvOf(USER1, 0); - assertEq( - withdrawableStvWithoutBurning, rewardsStv, "Withdrawable stv should be equal to rewardsStv" - ); + assertEq(withdrawableStvWithoutBurning, rewardsStv, "Withdrawable stv should be equal to rewardsStv"); vm.prank(USER1); uint256 requestId = ctx.withdrawalQueue.requestWithdrawal(USER1, rewardsStv, 0); @@ -529,7 +556,7 @@ contract StvStETHPoolTest is StvStETHPoolHarness { _advancePastMinDelayAndRefreshReport(ctx, requestId); vm.prank(NODE_OPERATOR); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); status = ctx.withdrawalQueue.getWithdrawalStatus(requestId); assertTrue(status.isFinalized, "Withdrawal request should be finalized"); @@ -598,7 +625,7 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // Finalize and claim the second (min-withdrawal) request _advancePastMinDelayAndRefreshReport(ctx, requestId); vm.prank(NODE_OPERATOR); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); status = ctx.withdrawalQueue.getWithdrawalStatus(requestId); assertTrue(status.isFinalized, "Min-withdrawal request should be finalized"); @@ -633,7 +660,7 @@ contract StvStETHPoolTest is StvStETHPoolHarness { _advancePastMinDelayAndRefreshReport(ctx, requestId3); vm.prank(NODE_OPERATOR); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); WithdrawalQueue.WithdrawalRequestStatus memory st3 = ctx.withdrawalQueue.getWithdrawalStatus(requestId3); assertTrue(st3.isFinalized, "Final full-withdrawal request should be finalized"); @@ -720,11 +747,7 @@ contract StvStETHPoolTest is StvStETHPoolHarness { assertEq(w.mintedStethSharesOf(USER1), user1MintedShares / 2, "USER1 should have half the liability"); uint256 user1RequiredStv = w.calcStvToLockForStethShares(w.mintedStethSharesOf(USER1)); - assertGe( - w.balanceOf(USER1), - user1RequiredStv, - "USER1 should maintain minimum reserve ratio after transfer" - ); + assertGe(w.balanceOf(USER1), user1RequiredStv, "USER1 should maintain minimum reserve ratio after transfer"); _assertUniversalInvariants("Step 4", ctx); @@ -785,7 +808,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { uint256 userDeposit = 100 ether; vm.prank(USER1); - w.depositETHAndMintStethShares{value: userDeposit}(USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit)); + w.depositETHAndMintStethShares{value: userDeposit}( + USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit) + ); vm.startPrank(USER1); vm.expectRevert(StvStETHPool.InsufficientReservedBalance.selector); @@ -829,7 +854,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // User deposits and mints max shares uint256 userDeposit = 100 ether; vm.prank(USER1); - w.depositETHAndMintStethShares{value: userDeposit}(USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit)); + w.depositETHAndMintStethShares{value: userDeposit}( + USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit) + ); // Vault loses 10% value - user now below reserve ratio vm.warp(block.timestamp + 1 days); @@ -876,7 +903,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // User deposits and mints max shares uint256 userDeposit = 100 ether; vm.prank(USER1); - w.depositETHAndMintStethShares{value: userDeposit}(USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit)); + w.depositETHAndMintStethShares{value: userDeposit}( + USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit) + ); // Vault loses value vm.warp(block.timestamp + 1 days); @@ -910,7 +939,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // User deposits and mints max shares uint256 userDeposit = 100 ether; vm.prank(USER1); - w.depositETHAndMintStethShares{value: userDeposit}(USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit)); + w.depositETHAndMintStethShares{value: userDeposit}( + USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit) + ); // Initially can't transfer - at exact reserve ratio vm.startPrank(USER1); @@ -943,7 +974,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // User deposits and mints uint256 userDeposit = 100 ether; vm.prank(USER1); - w.depositETHAndMintStethShares{value: userDeposit}(USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit)); + w.depositETHAndMintStethShares{value: userDeposit}( + USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit) + ); // Vault gains 1% reportVaultValueChangeNoFees(ctx, 100_00 + 100); @@ -989,7 +1022,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // User deposits and mints max uint256 userDeposit = 100 ether; vm.prank(USER1); - w.depositETHAndMintStethShares{value: userDeposit}(USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit)); + w.depositETHAndMintStethShares{value: userDeposit}( + USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit) + ); uint256 initialMinted = w.mintedStethSharesOf(USER1); assertEq(w.remainingMintingCapacitySharesOf(USER1, 0), 0, "No capacity initially"); diff --git a/test/unit/withdrawal-queue/BadDebt.test.sol b/test/unit/withdrawal-queue/BadDebt.test.sol index 686f5d8..da91a9f 100644 --- a/test/unit/withdrawal-queue/BadDebt.test.sol +++ b/test/unit/withdrawal-queue/BadDebt.test.sol @@ -76,6 +76,6 @@ contract BadDebtTest is Test, SetupWithdrawalQueue { vm.prank(finalizeRoleHolder); vm.expectRevert(StvPool.VaultInBadDebt.selector); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } } diff --git a/test/unit/withdrawal-queue/EmergencyExit.test.sol b/test/unit/withdrawal-queue/EmergencyExit.test.sol index 5dd44f9..301a2b8 100644 --- a/test/unit/withdrawal-queue/EmergencyExit.test.sol +++ b/test/unit/withdrawal-queue/EmergencyExit.test.sol @@ -156,7 +156,7 @@ contract EmergencyExitTest is Test, SetupWithdrawalQueue { // Any user can finalize in emergency exit (no FINALIZE_ROLE needed) vm.prank(userBob); - uint256 finalizedCount = withdrawalQueue.finalize(1); + uint256 finalizedCount = withdrawalQueue.finalize(1, address(0)); assertEq(finalizedCount, 1); } @@ -174,7 +174,7 @@ contract EmergencyExitTest is Test, SetupWithdrawalQueue { // Should still be able to finalize in emergency exit vm.prank(userAlice); - uint256 finalizedCount = withdrawalQueue.finalize(1); + uint256 finalizedCount = withdrawalQueue.finalize(1, address(0)); assertEq(finalizedCount, 1); } diff --git a/test/unit/withdrawal-queue/Finalization.test.sol b/test/unit/withdrawal-queue/Finalization.test.sol index 45beafd..423840a 100644 --- a/test/unit/withdrawal-queue/Finalization.test.sol +++ b/test/unit/withdrawal-queue/Finalization.test.sol @@ -33,7 +33,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { // Finalize the request vm.prank(finalizeRoleHolder); - uint256 finalizedCount = withdrawalQueue.finalize(1); + uint256 finalizedCount = withdrawalQueue.finalize(1, address(0)); // Verify finalization succeeded assertEq(finalizedCount, 1); @@ -59,7 +59,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { // Finalize all requests vm.prank(finalizeRoleHolder); - uint256 finalizedCount = withdrawalQueue.finalize(10); // More than needed + uint256 finalizedCount = withdrawalQueue.finalize(10, address(0)); // More than needed // Verify all finalized assertEq(finalizedCount, 3); @@ -83,13 +83,13 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); vm.prank(finalizeRoleHolder); - uint256 finalizedCount = withdrawalQueue.finalize(1); + uint256 finalizedCount = withdrawalQueue.finalize(1, address(0)); assertEq(finalizedCount, 1); assertEq(withdrawalQueue.getLastFinalizedRequestId(), 1); vm.prank(finalizeRoleHolder); - uint256 remainingCount = withdrawalQueue.finalize(10); + uint256 remainingCount = withdrawalQueue.finalize(10, address(0)); assertTrue(remainingCount > 0); } @@ -104,7 +104,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { // Should not finalize because min delay not passed vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } function test_Finalize_RequestAfterReport() public { @@ -121,7 +121,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { // Should not finalize because request was created after last report vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } function test_Finalize_RevertOnlyFinalizeRole() public { @@ -136,7 +136,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { ) ); vm.prank(userAlice); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } function test_Finalize_RevertWhenPaused() public { @@ -150,7 +150,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { vm.prank(finalizeRoleHolder); vm.expectRevert(abi.encodeWithSelector(PausableUpgradeable.EnforcedPause.selector)); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } function test_Finalize_ReturnsZeroWhenWithdrawableInsufficient() public { @@ -166,7 +166,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { // Should not finalize because eth to withdraw is locked vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } function test_Finalize_PartialDueToWithdrawableLimit() public { @@ -184,7 +184,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { dashboard.mock_setLocked(vaultBalance - expectedEthFirst); vm.prank(finalizeRoleHolder); - uint256 finalizedCount = withdrawalQueue.finalize(10); + uint256 finalizedCount = withdrawalQueue.finalize(10, address(0)); assertEq(finalizedCount, 1); assertEq(withdrawalQueue.getLastFinalizedRequestId(), 1); @@ -211,7 +211,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { // Should not finalize because eth to withdraw is locked vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } function test_Finalize_RebalanceWithBlockedButAvailableAssets() public { @@ -231,7 +231,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { dashboard.mock_setLocked(assetsToRebalance); vm.prank(finalizeRoleHolder); - assertEq(withdrawalQueue.finalize(1), 1); + assertEq(withdrawalQueue.finalize(1, address(0)), 1); } function test_Finalize_RebalancePartiallyDueToAvailableBalance() public { @@ -252,7 +252,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { dashboard.mock_setLocked(0); vm.prank(finalizeRoleHolder); - uint256 finalizedCount = withdrawalQueue.finalize(10); + uint256 finalizedCount = withdrawalQueue.finalize(10, address(0)); assertEq(finalizedCount, 1); assertTrue(withdrawalQueue.getWithdrawalStatus(requestId1).isFinalized); @@ -268,13 +268,13 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); - withdrawalQueue.finalize(0); + withdrawalQueue.finalize(0, address(0)); } function test_Finalize_NoRequestsToFinalize() public { vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } function test_Finalize_AlreadyFullyFinalized() public { @@ -284,12 +284,12 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { // First finalization vm.prank(finalizeRoleHolder); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); // Try to finalize again vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } function test_Finalize_RevertWhenReportStale() public { @@ -301,7 +301,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.VaultReportStale.selector); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } function test_Finalize_RevertWhenFinalizerCannotReceiveFee() public { @@ -336,7 +336,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } // Checkpoint Tests @@ -350,7 +350,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); vm.prank(finalizeRoleHolder); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); // Verify checkpoint was created assertEq(withdrawalQueue.getLastCheckpointIndex(), 1); @@ -370,7 +370,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { // Should be able to finalize without role restriction in emergency vm.prank(userAlice); // Any user can call - uint256 finalizedCount = withdrawalQueue.finalize(1); + uint256 finalizedCount = withdrawalQueue.finalize(1, address(0)); assertEq(finalizedCount, 1); } @@ -391,7 +391,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { // Finalize request vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); vm.prank(finalizeRoleHolder); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); // Check finalized request has correct ETH amount unaffected by rewards assertEq(withdrawalQueue.getClaimableEther(requestId), expectedEth); @@ -413,7 +413,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { // Finalize request vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); vm.prank(finalizeRoleHolder); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); // Check finalized request has correct ETH amount unaffected by rewards assertEq(withdrawalQueue.getClaimableEther(requestId), expectedEth); @@ -428,7 +428,7 @@ contract RevertingFinalizer { } function callFinalize(uint256 maxRequests) external { - withdrawalQueue.finalize(maxRequests); + withdrawalQueue.finalize(maxRequests, address(0)); } receive() external payable { diff --git a/test/unit/withdrawal-queue/GasCostCoverage.test.sol b/test/unit/withdrawal-queue/GasCostCoverage.test.sol index 2b48284..1790334 100644 --- a/test/unit/withdrawal-queue/GasCostCoverage.test.sol +++ b/test/unit/withdrawal-queue/GasCostCoverage.test.sol @@ -106,6 +106,29 @@ contract GasCostCoverageTest is Test, SetupWithdrawalQueue { assertEq(withdrawalQueue.getClaimableEther(requestId), 0); } + function test_FinalizeGasCostCoverage_DifferentGasCostRecipient() public { + uint256 coverage = 0.0005 ether; + _setGasCostCoverage(coverage); + + address recipient = makeAddr("finalizerRecipient"); + withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); + + uint256 finalizerBalanceBefore = finalizeRoleHolder.balance; + uint256 recipientBalanceBefore = recipient.balance; + + _warpAndMockOracleReport(); + vm.prank(finalizeRoleHolder); + uint256 finalizedRequests = withdrawalQueue.finalize(1, recipient); + + assertEq(finalizedRequests, 1); + + uint256 finalizerBalanceAfter = finalizeRoleHolder.balance; + uint256 recipientBalanceAfter = recipient.balance; + + assertEq(finalizerBalanceAfter, finalizerBalanceBefore); + assertEq(recipientBalanceAfter - recipientBalanceBefore, coverage); + } + // Receive ETH for claiming tests receive() external payable {} } diff --git a/test/unit/withdrawal-queue/HappyPath.test.sol b/test/unit/withdrawal-queue/HappyPath.test.sol index f49a952..2a4fd12 100644 --- a/test/unit/withdrawal-queue/HappyPath.test.sol +++ b/test/unit/withdrawal-queue/HappyPath.test.sol @@ -128,7 +128,7 @@ contract WithdrawalQueueHappyPathTest is Test, SetupWithdrawalQueue { // Request 2 & 3: Try to finalize both requests without waiting period - should fail vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); - withdrawalQueue.finalize(2); + withdrawalQueue.finalize(2, address(0)); // Request 2 & 3: Finalize both requests after waiting period assertEq(pool.balanceOf(address(withdrawalQueue)), secondWithdrawStv + thirdWithdrawStv); diff --git a/test/unit/withdrawal-queue/Rebalance.test.sol b/test/unit/withdrawal-queue/Rebalance.test.sol index 18e1170..aabaf0c 100644 --- a/test/unit/withdrawal-queue/Rebalance.test.sol +++ b/test/unit/withdrawal-queue/Rebalance.test.sol @@ -67,7 +67,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { mintedStethShares, block.timestamp ); - uint256 finalizedCount = withdrawalQueue.finalize(1); + uint256 finalizedCount = withdrawalQueue.finalize(1, address(0)); // Verify finalization succeeded assertEq(finalizedCount, 1); @@ -198,7 +198,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { emit StvStETHPool.SocializedLoss(0, 0); vm.prank(finalizeRoleHolder); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); assertEq(withdrawalQueue.getClaimableEther(requestId), 0); } diff --git a/test/unit/withdrawal-queue/SetupWithdrawalQueue.sol b/test/unit/withdrawal-queue/SetupWithdrawalQueue.sol index 393e2aa..4769f8d 100644 --- a/test/unit/withdrawal-queue/SetupWithdrawalQueue.sol +++ b/test/unit/withdrawal-queue/SetupWithdrawalQueue.sol @@ -118,7 +118,7 @@ abstract contract SetupWithdrawalQueue is Test { _warpAndMockOracleReport(); vm.prank(finalizeRoleHolder); - withdrawalQueue.finalize(_maxRequests); + withdrawalQueue.finalize(_maxRequests, address(0)); } function _warpAndMockOracleReport() internal { diff --git a/test/unit/withdrawal-queue/Views.test.sol b/test/unit/withdrawal-queue/Views.test.sol index 019f02f..8430f47 100644 --- a/test/unit/withdrawal-queue/Views.test.sol +++ b/test/unit/withdrawal-queue/Views.test.sol @@ -50,7 +50,7 @@ contract ViewsTest is Test, SetupWithdrawalQueue { lazyOracle.mock__updateLatestReportTimestamp(block.timestamp); vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); vm.prank(finalizeRoleHolder); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); WithdrawalQueue.WithdrawalRequestStatus memory statusSingle = withdrawalQueue.getWithdrawalStatus(requestId1); assertTrue(statusSingle.isFinalized); @@ -93,7 +93,7 @@ contract ViewsTest is Test, SetupWithdrawalQueue { lazyOracle.mock__updateLatestReportTimestamp(block.timestamp); vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); vm.prank(finalizeRoleHolder); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); uint256[] memory requestIds = new uint256[](1); requestIds[0] = requestId;