From 943945e418b49b7b34b4938b6fea66223dcb1079 Mon Sep 17 00:00:00 2001 From: parsaba Date: Tue, 12 Aug 2025 20:49:30 +0400 Subject: [PATCH] Fix withdrawal manager implementation and tests --- src/core/LiquidTokenManager.sol | 2 +- src/core/WithdrawalManager.sol | 109 +- src/interfaces/IWithdrawalManager.sol | 31 +- test/WithdrawalManager.t.sol | 1861 +++++++++++++++++++++++++ test/common/BaseTest.sol | 112 +- 5 files changed, 2070 insertions(+), 45 deletions(-) create mode 100644 test/WithdrawalManager.t.sol diff --git a/src/core/LiquidTokenManager.sol b/src/core/LiquidTokenManager.sol index 4ab34eef..795d19bc 100644 --- a/src/core/LiquidTokenManager.sol +++ b/src/core/LiquidTokenManager.sol @@ -1362,4 +1362,4 @@ contract LiquidTokenManager is return strategy.underlyingToSharesView(amount); } -} +} \ No newline at end of file diff --git a/src/core/WithdrawalManager.sol b/src/core/WithdrawalManager.sol index 2bbd81d0..ef555b48 100644 --- a/src/core/WithdrawalManager.sol +++ b/src/core/WithdrawalManager.sol @@ -25,6 +25,17 @@ contract WithdrawalManager is IWithdrawalManager, Initializable, AccessControlUp using SafeERC20 for IERC20; using Math for uint256; + // ------------------------------------------------------------------------------ + // Constants + // ------------------------------------------------------------------------------ + + /// @notice Maximum number of assets allowed in a single withdrawal request + uint256 public constant MAX_WITHDRAWAL_ASSETS = 32; + + /// @notice Lock to prevent concurrent redemption completions + uint256 private constant NOT_ENTERED = 1; + uint256 private constant ENTERED = 2; + // ------------------------------------------------------------------------------ // State // ------------------------------------------------------------------------------ @@ -47,6 +58,12 @@ contract WithdrawalManager is IWithdrawalManager, Initializable, AccessControlUp /// @notice The delay between user withdrawal request and ability to withdraw from this contract uint256 public withdrawalDelay; + /// @notice Reentrancy guard for redemption completion + uint256 private _redemptionCompletionStatus; + + /// @notice Tracks if a withdrawal request is currently being processed + mapping(bytes32 => bool) private _processingRequest; + // ------------------------------------------------------------------------------ // Init functions // ------------------------------------------------------------------------------ @@ -79,6 +96,7 @@ contract WithdrawalManager is IWithdrawalManager, Initializable, AccessControlUp stakerNodeCoordinator = init.stakerNodeCoordinator; withdrawalDelay = 14 days; + _redemptionCompletionStatus = NOT_ENTERED; } // ------------------------------------------------------------------------------ @@ -95,11 +113,31 @@ contract WithdrawalManager is IWithdrawalManager, Initializable, AccessControlUp ) external override nonReentrant { if (msg.sender != address(liquidToken)) revert NotLiquidToken(msg.sender); if (sharesDeposited == 0) revert ZeroAmount(); + if (assets.length != amounts.length) revert LengthMismatch(); + if (assets.length == 0) revert ZeroAmount(); + if (assets.length > MAX_WITHDRAWAL_ASSETS) revert ExceedsMaxAssets(); + if (user == address(0)) revert ZeroAddress(); + if (withdrawalRequests[requestId].user != address(0)) revert RequestAlreadyExists(); uint256[] memory elWithdrawableShares = new uint256[](assets.length); + // Check for duplicate assets and validate each asset for (uint256 i = 0; i < assets.length; i++) { + if (address(assets[i]) == address(0)) revert ZeroAddress(); + if (amounts[i] == 0) revert ZeroAmount(); + + // Check for duplicates + for (uint256 j = 0; j < i; j++) { + if (assets[i] == assets[j]) revert DuplicateAsset(address(assets[i])); + } + + // Validate asset is supported + if (!liquidTokenManager.tokenIsSupported(assets[i])) { + revert UnsupportedAsset(assets[i]); + } + elWithdrawableShares[i] = liquidTokenManager.assetUnderlyingToShares(assets[i], amounts[i]); + if (elWithdrawableShares[i] == 0) revert ZeroAmount(); } WithdrawalRequest memory request = WithdrawalRequest({ @@ -128,13 +166,17 @@ contract WithdrawalManager is IWithdrawalManager, Initializable, AccessControlUp /// @inheritdoc IWithdrawalManager function fulfillWithdrawal(bytes32 requestId) external override nonReentrant { - WithdrawalRequest storage request = withdrawalRequests[requestId]; + WithdrawalRequest memory request = withdrawalRequests[requestId]; if (request.user == address(0)) revert InvalidWithdrawalRequest(); if (request.user != msg.sender) revert UnauthorizedAccess(msg.sender); if (block.timestamp <= request.requestTime + withdrawalDelay) revert WithdrawalDelayNotMet(); if (request.canFulfill == false) revert WithdrawalNotReadyToFulfill(); + // Prevent concurrent processing + if (_processingRequest[requestId]) revert RequestBeingProcessed(); + _processingRequest[requestId] = true; + // Build the amounts array from the user's withdrawable shares uint256[] memory amounts = new uint256[](request.assets.length); for (uint256 i = 0; i < request.assets.length; i++) { @@ -149,7 +191,7 @@ contract WithdrawalManager is IWithdrawalManager, Initializable, AccessControlUp // Minor shortfalls shouldn't block withdrawals // Allow for 10bps tolerance to handle rounding differences uint256 tolerance = Math.mulDiv(requestedAmount, 10, 10000, Math.Rounding.Up); - uint256 minAcceptableAmount = requestedAmount - tolerance; + uint256 minAcceptableAmount = requestedAmount > tolerance ? requestedAmount - tolerance : 0; if (availableBalance < minAcceptableAmount) { revert InsufficientBalance(asset, requestedAmount, availableBalance); @@ -165,18 +207,26 @@ contract WithdrawalManager is IWithdrawalManager, Initializable, AccessControlUp address user = request.user; IERC20[] memory assets = request.assets; + // Delete withdrawal request first to prevent reentrancy delete withdrawalRequests[requestId]; + delete _processingRequest[requestId]; + + // Remove from user's request list bytes32[] storage userRequests = userWithdrawalRequests[user]; - for (uint256 i = 0; i < userRequests.length; i++) { + uint256 requestsLength = userRequests.length; + for (uint256 i = 0; i < requestsLength; i++) { if (userRequests[i] == requestId) { - userRequests[i] = userRequests[userRequests.length - 1]; + userRequests[i] = userRequests[requestsLength - 1]; userRequests.pop(); break; } } + // Transfer assets to user for (uint256 i = 0; i < assets.length; i++) { - assets[i].safeTransfer(msg.sender, amounts[i]); + if (amounts[i] > 0) { + assets[i].safeTransfer(user, amounts[i]); + } } emit WithdrawalFulfilled(requestId, user, assets, amounts, block.timestamp); @@ -188,6 +238,7 @@ contract WithdrawalManager is IWithdrawalManager, Initializable, AccessControlUp ILiquidTokenManager.Redemption calldata redemption ) external override { if (msg.sender != address(liquidTokenManager)) revert NotLiquidTokenManager(msg.sender); + if (redemptions[redemptionId].requestIds.length > 0) revert RedemptionAlreadyExists(); // Record the redemption redemptions[redemptionId] = redemption; @@ -199,10 +250,24 @@ contract WithdrawalManager is IWithdrawalManager, Initializable, AccessControlUp IERC20[] calldata receivedAssets, uint256[] calldata receivedElShares ) external override returns (uint256[] memory) { - if (msg.sender != address(liquidTokenManager)) revert NotLiquidTokenManager(msg.sender); - if (receivedElShares.length != receivedAssets.length) revert LengthMismatch(); + // Prevent concurrent redemption completions + if (_redemptionCompletionStatus == ENTERED) revert ReentrantCall(); + _redemptionCompletionStatus = ENTERED; + + if (msg.sender != address(liquidTokenManager)) { + _redemptionCompletionStatus = NOT_ENTERED; + revert NotLiquidTokenManager(msg.sender); + } + if (receivedElShares.length != receivedAssets.length) { + _redemptionCompletionStatus = NOT_ENTERED; + revert LengthMismatch(); + } ILiquidTokenManager.Redemption memory redemption = redemptions[redemptionId]; + if (redemption.requestIds.length == 0) { + _redemptionCompletionStatus = NOT_ENTERED; + revert RedemptionNotFound(redemptionId); + } // Check if this is a user withdrawal by checking if the first requestId corresponds to a user withdrawal request bool isUserWithdrawal = redemption.requestIds.length > 0 && @@ -214,19 +279,34 @@ contract WithdrawalManager is IWithdrawalManager, Initializable, AccessControlUp // Aggregate the total requested shares across all assets // For user withdrawals, we also aggregate the total escrow shares so that we can burn them if (isUserWithdrawal) { + // First pass: calculate total LAT escrow shares + for (uint256 i = 0; i < redemption.requestIds.length; i++) { + bytes32 requestId = redemption.requestIds[i]; + WithdrawalRequest storage request = withdrawalRequests[requestId]; + if (request.user != address(0)) { + latEscrowShares += request.sharesDeposited; + } + } + + // Second pass: aggregate shares per asset for (uint256 i = 0; i < redemption.requestIds.length; i++) { bytes32 requestId = redemption.requestIds[i]; WithdrawalRequest storage request = withdrawalRequests[requestId]; for (uint256 j = 0; j < request.assets.length; j++) { + bool assetFound = false; for (uint256 k = 0; k < receivedAssets.length; k++) { if (address(request.assets[j]) == address(receivedAssets[k])) { uint256 originalShares = request.elWithdrawableShares[j]; redemptionRequestedElShares[k] += originalShares; - latEscrowShares += request.sharesDeposited; + assetFound = true; break; } } + // Track if any assets were not found in receivedAssets + if (!assetFound && request.elWithdrawableShares[j] > 0) { + emit AssetNotReceived(requestId, request.assets[j], request.elWithdrawableShares[j]); + } } } } else { @@ -248,6 +328,8 @@ contract WithdrawalManager is IWithdrawalManager, Initializable, AccessControlUp for (uint256 i = 0; i < redemption.requestIds.length; i++) { bytes32 requestId = redemption.requestIds[i]; WithdrawalRequest storage request = withdrawalRequests[requestId]; + if (request.user == address(0)) continue; // Skip if request was already fulfilled + bool isLastRequest = (i == redemption.requestIds.length - 1); for (uint256 j = 0; j < request.assets.length; j++) { @@ -259,9 +341,11 @@ contract WithdrawalManager is IWithdrawalManager, Initializable, AccessControlUp uint256 newShares; if (totalOriginalShares > 0) { - if (isLastRequest) { - // For the last request, assign remaining shares to ensure exact accounting - newShares = totalReceivedShares - distributedShares[k]; + if (isLastRequest && j == request.assets.length - 1) { + // For the last asset of the last request, assign remaining shares to ensure exact accounting + newShares = totalReceivedShares > distributedShares[k] + ? totalReceivedShares - distributedShares[k] + : 0; } else { // Calculate proportional share: (originalShares * totalReceived) / totalOriginal newShares = Math.mulDiv(originalShares, totalReceivedShares, totalOriginalShares); @@ -298,6 +382,7 @@ contract WithdrawalManager is IWithdrawalManager, Initializable, AccessControlUp liquidToken.debitQueuedAssetElShares(receivedAssets, redemptionRequestedElShares, 0); } + _redemptionCompletionStatus = NOT_ENTERED; return redemptionRequestedElShares; } @@ -347,4 +432,4 @@ contract WithdrawalManager is IWithdrawalManager, Initializable, AccessControlUp return redemption; } -} +} \ No newline at end of file diff --git a/src/interfaces/IWithdrawalManager.sol b/src/interfaces/IWithdrawalManager.sol index 24ee6aba..313a2591 100644 --- a/src/interfaces/IWithdrawalManager.sol +++ b/src/interfaces/IWithdrawalManager.sol @@ -105,6 +105,12 @@ interface IWithdrawalManager { /// @param newDelay Newly updated withdrawal delay value event WithdrawalDelayUpdated(uint256 oldDelay, uint256 newDelay); + /// @notice Emitted when an asset was not received in redemption completion + /// @param requestId The withdrawal request ID + /// @param asset The asset that was not received + /// @param expectedShares The expected shares that were not received + event AssetNotReceived(bytes32 indexed requestId, IERC20 indexed asset, uint256 expectedShares); + // ============================================================================ // CUSTOM ERRORS // ============================================================================ @@ -158,6 +164,29 @@ interface IWithdrawalManager { /// @param available The amount available error InsufficientBalance(IERC20 asset, uint256 required, uint256 available); + /// @notice Error thrown when assets array exceeds maximum allowed + error ExceedsMaxAssets(); + + /// @notice Error thrown when a withdrawal request already exists + error RequestAlreadyExists(); + + /// @notice Error thrown when duplicate assets are provided + /// @param asset The duplicate asset address + error DuplicateAsset(address asset); + + /// @notice Error thrown when an unsupported asset is provided + /// @param asset The unsupported asset + error UnsupportedAsset(IERC20 asset); + + /// @notice Error thrown when a request is being processed + error RequestBeingProcessed(); + + /// @notice Error thrown when a redemption already exists + error RedemptionAlreadyExists(); + + /// @notice Error thrown when a reentrant call is detected + error ReentrantCall(); + // ============================================================================ // FUNCTIONS // ============================================================================ @@ -215,4 +244,4 @@ interface IWithdrawalManager { /// @notice Returns all redemption details for a given redemption ID /// @param redemptionId The ID of the redemption function getRedemption(bytes32 redemptionId) external view returns (ILiquidTokenManager.Redemption memory); -} +} \ No newline at end of file diff --git a/test/WithdrawalManager.t.sol b/test/WithdrawalManager.t.sol new file mode 100644 index 00000000..2684bf5e --- /dev/null +++ b/test/WithdrawalManager.t.sol @@ -0,0 +1,1861 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {Test} from "forge-std/Test.sol"; +import {StdInvariant} from "forge-std/StdInvariant.sol"; +import {console} from "forge-std/console.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {WithdrawalManager} from "../src/core/WithdrawalManager.sol"; +import {IWithdrawalManager} from "../src/interfaces/IWithdrawalManager.sol"; +import {ILiquidTokenManager} from "../src/interfaces/ILiquidTokenManager.sol"; +import {ILiquidToken} from "../src/interfaces/ILiquidToken.sol"; +import {IStakerNodeCoordinator} from "../src/interfaces/IStakerNodeCoordinator.sol"; +import {IDelegationManager} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; +import "./common/BaseTest.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +// Mock contracts +import {MockERC20} from "./mocks/MockERC20.sol"; + +/** + * @title MockRebasingToken + * @notice Enhanced mock rebasing token for comprehensive testing + */ +contract MockRebasingToken is MockERC20 { + uint256 private _totalPooledEther; + uint256 private _totalShares; + + constructor(string memory name, string memory symbol) MockERC20(name, symbol) { + _totalPooledEther = 1e18; + _totalShares = 1e18; + } + + function setConversionRate(uint256 newTotalPooledEther, uint256 newTotalShares) external { + _totalPooledEther = newTotalPooledEther == 0 ? 1e18 : newTotalPooledEther; + _totalShares = newTotalShares == 0 ? 1e18 : newTotalShares; + } + + function getPooledEthByShares(uint256 _sharesAmount) external view returns (uint256) { + if (_totalShares == 0) return _sharesAmount; + return (_sharesAmount * _totalPooledEther) / _totalShares; + } + + function getSharesByPooledEth(uint256 _pooledEthAmount) external view returns (uint256) { + if (_totalPooledEther == 0) return _pooledEthAmount; + return (_pooledEthAmount * _totalShares) / _totalPooledEther; + } + + function sharesOf(address _account) external view returns (uint256) { + if (_totalPooledEther == 0) return balanceOf(_account); + return (balanceOf(_account) * _totalShares) / _totalPooledEther; + } + + function getTotalShares() external view returns (uint256) { + return _totalShares; + } + + function getTotalPooledEther() external view returns (uint256) { + return _totalPooledEther; + } + + function initializeRebasingState(uint256 totalPooledEther, uint256 totalShares) external { + _totalPooledEther = totalPooledEther == 0 ? 1e18 : totalPooledEther; + _totalShares = totalShares == 0 ? 1e18 : totalShares; + } + + function simulateRebase(uint256 newPooledEther) external { + _totalPooledEther = newPooledEther; + } +} + +contract MockMaliciousToken is MockERC20 { + address public attackTarget; + bytes32 public attackRequestId; + + constructor() MockERC20("Malicious", "MAL") {} + + function setAttackTarget(address target, bytes32 requestId) external { + attackTarget = target; + attackRequestId = requestId; + } + + function transfer(address to, uint256 amount) public override returns (bool) { + if (msg.sender == attackTarget) { + // Attempt reentrancy + IWithdrawalManager(attackTarget).fulfillWithdrawal(attackRequestId); + } + return super.transfer(to, amount); + } +} + +/** + * @title ComprehensiveWithdrawalManagerTest + * @notice Comprehensive integration tests for WithdrawalManager + */ +contract ComprehensiveWithdrawalManagerTest is BaseTest { + // ============================================================================= + // ADDITIONAL CONTRACTS FOR WITHDRAWAL MANAGER + // ============================================================================= + + WithdrawalManager public withdrawalManager; + MockRebasingToken public mockStETH; + MockERC20 public mockToken3; + MockMaliciousToken public maliciousToken; + MockStrategy public mockRebasingStrategy; + MockChainlinkFeed public mockStETHFeed; + + // ADD THESE MISSING CONTRACT VARIABLES + MockStrategy public mockStrategy3; + MockChainlinkFeed public mockToken3Feed; + + // Additional addresses for withdrawal manager + address public emergencyAdmin; + address public dustRecipient; + + // Constants + uint256 public constant WITHDRAWAL_AMOUNT = 100e18; + uint256 public constant MIN_WITHDRAWAL_AMOUNT = 1000; + uint256 public constant MAX_TOTAL_WITHDRAWAL_VALUE = 100_000_000e18; + + bytes32 public constant EMERGENCY_ROLE = keccak256("EMERGENCY_ROLE"); + bytes32 public constant REBASING_MANAGER_ROLE = keccak256("REBASING_MANAGER_ROLE"); + bytes32 public constant WITHDRAWAL_MANAGER_ROLE = keccak256("WITHDRAWAL_MANAGER_ROLE"); + + function setUp() public override { + console.log("=== COMPREHENSIVE WITHDRAWAL MANAGER TEST SETUP START ==="); + + // Initialize additional addresses + emergencyAdmin = address(0x999); + dustRecipient = address(0x888); + + // Setup base components step by step to insert WM deployment + super._initializeSelectors(); + super._setupELContracts(); + super._deployMockContracts(); + + // Deploy additional contracts for withdrawal manager testing + _deployAdditionalContracts(); + + super._deployMainContracts(); + super._deployProxies(); + + // Deploy real withdrawal manager using proxy addresses + _deployWithdrawalManager(); + + // Initialize contracts with real WM + super._initializeTokenRegistryOracle(); + super._setupOracleSources(); + _initializeLiquidTokenManager(); // overridden to use real WM + _initializeLiquidToken(); // overridden to use real WM + _initializeStakerNodeCoordinator(); // overridden to use real WM + + // Add tokens and setup balances + super._addTestTokens(); + _addRebasingToken(); // Add rebasing token support + super._setupTestTokens(); + + // Setup integration with real LT/LTM before renouncing roles + _setupRealIntegration(); + + // Renounce roles + super._renounceAllRoles(); + + // Setup test balances + _setupWithdrawalTestBalances(); + + console.log("=== COMPREHENSIVE WITHDRAWAL MANAGER TEST SETUP END ==="); + } + + function _deployAdditionalContracts() internal { + console.log("Deploying additional contracts for withdrawal manager..."); + + // Deploy additional mock token + mockToken3 = new MockERC20("Mock Token 3", "MTK3"); + + // Deploy mock rebasing token (stETH) + mockStETH = new MockRebasingToken("Staked ETH", "stETH"); + mockStETH.initializeRebasingState(1000e18, 1000e18); + + // Deploy malicious token for attack tests + maliciousToken = new MockMaliciousToken(); + + // Additional for rebasing + mockRebasingStrategy = new MockStrategy(strategyManager, IERC20(address(mockStETH))); + mockStETHFeed = new MockChainlinkFeed(int256(100000000), 8); // 1 ETH per stETH + + // COMPONENTS FOR mockToken3 - NOW AS CONTRACT VARIABLES + mockStrategy3 = new MockStrategy(strategyManager, IERC20(address(mockToken3))); + mockToken3Feed = new MockChainlinkFeed(int256(100000000), 8); // 1 ETH per token + + console.log("Additional contracts deployed"); + } + + function _addRebasingToken() internal { + console.log("Adding rebasing token support..."); + + vm.startPrank(admin); + tokenRegistryOracle.configureToken( + address(mockStETH), + BaseTest.SOURCE_TYPE_CHAINLINK, + address(mockStETHFeed), + 0, + address(0), + bytes4(0) + ); + + // CONFIGURATION FOR mockToken3 - NOW USES CONTRACT VARIABLES + tokenRegistryOracle.configureToken( + address(mockToken3), + BaseTest.SOURCE_TYPE_CHAINLINK, + address(mockToken3Feed), + 0, + address(0), + bytes4(0) + ); + vm.stopPrank(); + + vm.startPrank(deployer); + liquidTokenManager.addToken( + IERC20(address(mockStETH)), + 18, + 0, + IStrategy(address(mockRebasingStrategy)), + BaseTest.SOURCE_TYPE_CHAINLINK, + address(mockStETHFeed), + 0, + address(0), + bytes4(0) + ); + + // ADD mockToken3 TO LIQUID TOKEN MANAGER - NOW USES CONTRACT VARIABLES + liquidTokenManager.addToken( + IERC20(address(mockToken3)), + 18, + 0, + IStrategy(address(mockStrategy3)), + BaseTest.SOURCE_TYPE_CHAINLINK, + address(mockToken3Feed), + 0, + address(0), + bytes4(0) + ); + vm.stopPrank(); + + // Mint and approve for users + mockStETH.mint(user1, 1000e18); + mockStETH.mint(user2, 1000e18); + mockToken3.mint(user1, 1000e18); + mockToken3.mint(user2, 1000e18); + + vm.prank(user1); + mockStETH.approve(address(liquidToken), type(uint256).max); + vm.prank(user2); + mockStETH.approve(address(liquidToken), type(uint256).max); + vm.prank(user1); + mockToken3.approve(address(liquidToken), type(uint256).max); + vm.prank(user2); + mockToken3.approve(address(liquidToken), type(uint256).max); + } + // Override initialization functions to use real WM + function _initializeLiquidTokenManager() internal override { + console.log("Initializing LiquidTokenManager with real WM..."); + ILiquidTokenManager.Init memory init = ILiquidTokenManager.Init({ + liquidToken: liquidToken, + strategyManager: strategyManager, + delegationManager: delegationManager, + stakerNodeCoordinator: stakerNodeCoordinator, + tokenRegistryOracle: ITokenRegistryOracle(address(tokenRegistryOracle)), + lstSwapRouter: mockLSTSwapRouter, + withdrawalManager: IWithdrawalManager(address(withdrawalManager)), // Use real WM + initialOwner: deployer, + strategyController: deployer, + priceUpdater: address(tokenRegistryOracle) + }); + + vm.prank(deployer); + liquidTokenManager.initialize(init); + + vm.startPrank(deployer); + liquidTokenManager.grantRole(liquidTokenManager.DEFAULT_ADMIN_ROLE(), address(this)); + liquidTokenManager.grantRole(liquidTokenManager.STRATEGY_CONTROLLER_ROLE(), address(this)); + + if (address(mockLSTSwapRouter) != address(0) && address(mockLSTSwapRouter) != address(0xDEAD)) { + liquidTokenManager.updateLSTSwapRouter(address(mockLSTSwapRouter)); + } + vm.stopPrank(); + } + + function _initializeStakerNodeCoordinator() internal override { + console.log("Initializing StakerNodeCoordinator with real WM..."); + IStakerNodeCoordinator.Init memory init = IStakerNodeCoordinator.Init({ + liquidTokenManager: liquidTokenManager, + withdrawalManager: IWithdrawalManager(address(withdrawalManager)), // Use real WM + strategyManager: strategyManager, + delegationManager: delegationManager, + maxNodes: 10, + initialOwner: deployer, + pauser: pauser, + stakerNodeCreator: deployer, + stakerNodesDelegator: deployer, + stakerNodeImplementation: address(stakerNodeImplementation) + }); + + vm.prank(deployer); + stakerNodeCoordinator.initialize(init); + + vm.startPrank(deployer); + stakerNodeCoordinator.grantRole(stakerNodeCoordinator.DEFAULT_ADMIN_ROLE(), address(this)); + stakerNodeCoordinator.grantRole(stakerNodeCoordinator.STAKER_NODE_CREATOR_ROLE(), address(this)); + stakerNodeCoordinator.grantRole(stakerNodeCoordinator.STAKER_NODES_DELEGATOR_ROLE(), address(this)); + vm.stopPrank(); + } + + function _initializeLiquidToken() internal override { + console.log("Initializing LiquidToken with real WM..."); + ILiquidToken.Init memory init = ILiquidToken.Init({ + name: "Liquid Staking Token", + symbol: "LST", + initialOwner: deployer, + pauser: pauser, + liquidTokenManager: ILiquidTokenManager(address(liquidTokenManager)), + tokenRegistryOracle: ITokenRegistryOracle(address(tokenRegistryOracle)), + withdrawalManager: IWithdrawalManager(address(withdrawalManager)) // Use real WM + }); + + vm.prank(deployer); + liquidToken.initialize(init); + + vm.startPrank(deployer); + liquidToken.grantRole(liquidToken.DEFAULT_ADMIN_ROLE(), address(this)); + liquidToken.grantRole(liquidToken.PAUSER_ROLE(), pauser); + vm.stopPrank(); + } + + function _deployWithdrawalManager() internal { + console.log("Deploying withdrawal manager..."); + + // Validate all addresses before deployment + console.log("Validating addresses..."); + console.log("deployer:", deployer); + console.log("liquidToken:", address(liquidToken)); + console.log("delegationManager:", address(delegationManager)); + console.log("liquidTokenManager:", address(liquidTokenManager)); + console.log("stakerNodeCoordinator:", address(stakerNodeCoordinator)); + + // Check for zero addresses + require(deployer != address(0), "deployer is zero address"); + require(address(liquidToken) != address(0), "liquidToken is zero address"); + require(address(delegationManager) != address(0), "delegationManager is zero address"); + require(address(liquidTokenManager) != address(0), "liquidTokenManager is zero address"); + require(address(stakerNodeCoordinator) != address(0), "stakerNodeCoordinator is zero address"); + + // Deploy implementation + WithdrawalManager implementation = new WithdrawalManager(); + + // Prepare init data + IWithdrawalManager.Init memory init = IWithdrawalManager.Init({ + initialOwner: deployer, + liquidToken: ILiquidToken(address(liquidToken)), + delegationManager: delegationManager, + liquidTokenManager: ILiquidTokenManager(address(liquidTokenManager)), + stakerNodeCoordinator: stakerNodeCoordinator + }); + + bytes memory initData = abi.encodeWithSelector(WithdrawalManager.initialize.selector, init); + + // Deploy proxy + TransparentUpgradeableProxy wmProxy = new TransparentUpgradeableProxy( + address(implementation), + proxyAdminAddress, + initData + ); + + withdrawalManager = WithdrawalManager(address(wmProxy)); + + console.log("Withdrawal manager deployed at:", address(withdrawalManager)); + } + + function _setupRealIntegration() internal { + console.log("Setting up real integration..."); + + vm.startPrank(deployer); + + // Grant necessary roles for integration + withdrawalManager.grantRole(withdrawalManager.DEFAULT_ADMIN_ROLE(), address(this)); + + vm.stopPrank(); + + console.log("Real integration setup complete"); + } + + function _setupWithdrawalTestBalances() internal { + console.log("Setting up test balances..."); + + // Mint tokens to withdrawal manager + testToken.mint(address(withdrawalManager), 1000e18); + testToken2.mint(address(withdrawalManager), 1000e18); + mockToken3.mint(address(withdrawalManager), 1000e18); + mockStETH.mint(address(withdrawalManager), 1000e18); + maliciousToken.mint(address(withdrawalManager), 1000e18); + + // Mint tokens to users for testing + testToken.mint(user1, 1000e18); + testToken.mint(user2, 1000e18); + testToken2.mint(user1, 1000e18); + testToken2.mint(user2, 1000e18); + mockToken3.mint(user1, 1000e18); + mockToken3.mint(user2, 1000e18); + mockStETH.mint(user1, 1000e18); + mockStETH.mint(user2, 1000e18); + + console.log("Test balances setup complete"); + } + + // ============================================================================= + // 1. CORE WITHDRAWAL MANAGER FUNCTIONALITY TESTS + // ============================================================================= + + function test_CreateWithdrawalRequest_Success() public { + // Setup: Create withdrawal request through LiquidToken + uint256 depositAmount = 100e18; + _simulateUserDeposit(user1, address(testToken), depositAmount); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = depositAmount; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + // Verify request was created + IWithdrawalManager.WithdrawalRequest memory request = withdrawalManager.getWithdrawalRequests( + _arrayOf(requestId) + )[0]; + + assertEq(request.user, user1); + assertEq(address(request.assets[0]), address(testToken)); + assertEq(request.requestedAmounts[0], depositAmount); + assertFalse(request.canFulfill); + } + + function test_CreateWithdrawalRequest_ArrayLengthMismatch() public { + IERC20[] memory assets = new IERC20[](2); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + assets[1] = testToken2; + amounts[0] = 100e18; + + vm.expectRevert(abi.encodeWithSelector(IWithdrawalManager.LengthMismatch.selector)); + vm.prank(address(liquidToken)); + withdrawalManager.createWithdrawalRequest(assets, amounts, 100e18, user1, keccak256("test")); + } + + function test_CreateWithdrawalRequest_ZeroAmount() public { + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = 0; + + vm.expectRevert(abi.encodeWithSelector(IWithdrawalManager.ZeroAmount.selector)); + vm.prank(address(liquidToken)); + withdrawalManager.createWithdrawalRequest(assets, amounts, 100e18, user1, keccak256("test")); + } + + function test_CreateWithdrawalRequest_DuplicateAssets() public { + IERC20[] memory assets = new IERC20[](2); + uint256[] memory amounts = new uint256[](2); + assets[0] = testToken; + assets[1] = testToken; // Duplicate + amounts[0] = 100e18; + amounts[1] = 50e18; + + vm.expectRevert(abi.encodeWithSelector(IWithdrawalManager.DuplicateAsset.selector, address(testToken))); + vm.prank(address(liquidToken)); + withdrawalManager.createWithdrawalRequest(assets, amounts, 150e18, user1, keccak256("test")); + } + + function test_CreateWithdrawalRequest_ExceedsMaxAssets() public { + // Create array with more than MAX_WITHDRAWAL_ASSETS (32) + IERC20[] memory assets = new IERC20[](33); + uint256[] memory amounts = new uint256[](33); + + for (uint256 i = 0; i < 33; i++) { + assets[i] = testToken; + amounts[i] = 1e18; + } + + vm.expectRevert(abi.encodeWithSelector(IWithdrawalManager.ExceedsMaxAssets.selector)); + vm.prank(address(liquidToken)); + withdrawalManager.createWithdrawalRequest(assets, amounts, 33e18, user1, keccak256("test")); + } + + function test_CreateWithdrawalRequest_UnauthorizedCaller() public { + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = 100e18; + + vm.expectRevert(abi.encodeWithSelector(IWithdrawalManager.NotLiquidToken.selector, user1)); + vm.prank(user1); + withdrawalManager.createWithdrawalRequest(assets, amounts, 100e18, user1, keccak256("test")); + } + + function test_FulfillWithdrawal_Success() public { + // Setup: Create and complete withdrawal request + uint256 depositAmount = 100e18; + _simulateUserDeposit(user1, address(testToken), depositAmount); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = depositAmount; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + // Complete redemption + vm.warp(block.timestamp + 15 days); + bytes32 redemptionId = keccak256(abi.encode("redemption", requestId)); + _createAndCompleteRedemption(redemptionId, requestId, assets, amounts); + + uint256 balanceBefore = testToken.balanceOf(user1); + + vm.prank(user1); + withdrawalManager.fulfillWithdrawal(requestId); + + uint256 balanceAfter = testToken.balanceOf(user1); + assertEq(balanceAfter - balanceBefore, depositAmount); + } + + function test_FulfillWithdrawal_WithdrawalDelayNotMet() public { + uint256 depositAmount = 100e18; + _simulateUserDeposit(user1, address(testToken), depositAmount); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = depositAmount; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + // Complete redemption + bytes32 redemptionId = keccak256(abi.encode("redemption", requestId)); + _createAndCompleteRedemption(redemptionId, requestId, assets, amounts); + + // Try to fulfill before delay + vm.expectRevert(abi.encodeWithSelector(IWithdrawalManager.WithdrawalDelayNotMet.selector)); + vm.prank(user1); + withdrawalManager.fulfillWithdrawal(requestId); + } + + function test_FulfillWithdrawal_NotReadyToFulfill() public { + uint256 depositAmount = 100e18; + _simulateUserDeposit(user1, address(testToken), depositAmount); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = depositAmount; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + // Wait but don't complete redemption + vm.warp(block.timestamp + 15 days); + + vm.expectRevert(abi.encodeWithSelector(IWithdrawalManager.WithdrawalNotReadyToFulfill.selector)); + vm.prank(user1); + withdrawalManager.fulfillWithdrawal(requestId); + } + + function test_FulfillWithdrawal_UnauthorizedUser() public { + uint256 depositAmount = 100e18; + _simulateUserDeposit(user1, address(testToken), depositAmount); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = depositAmount; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + // Complete redemption and wait + vm.warp(block.timestamp + 15 days); + bytes32 redemptionId = keccak256(abi.encode("redemption", requestId)); + _createAndCompleteRedemption(redemptionId, requestId, assets, amounts); + + // Try to fulfill as different user + vm.expectRevert(abi.encodeWithSelector(IWithdrawalManager.UnauthorizedAccess.selector, user2)); + vm.prank(user2); + withdrawalManager.fulfillWithdrawal(requestId); + } + + function test_FulfillWithdrawal_WithTolerance() public { + console.log("=== TESTING WITHDRAWAL WITH SLASHING/TOLERANCE ==="); + + // Test tolerance mechanism for slight balance mismatches due to slashing + uint256 depositAmount = 100e18; + _simulateUserDeposit(user1, address(testToken), depositAmount); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = depositAmount; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + console.log("User requested withdrawal of:", depositAmount); + + // Simulate slashing: user gets 5 bps less due to slashing + vm.warp(block.timestamp + 15 days); + uint256 slightlyLess = depositAmount - ((depositAmount * 5) / 10000); // 5 bps less + console.log("Amount after slashing:", slightlyLess); + console.log("Slashing amount:", depositAmount - slightlyLess); + + bytes32 redemptionId = keccak256(abi.encode("redemption", requestId)); + + // === REAL-WORLD FLOW SIMULATION === + + // 1. Create redemption with ORIGINAL amounts (what user requested) + ILiquidTokenManager.Redemption memory redemption = _createRedemption(requestId, assets, amounts); + redemption.receiver = address(withdrawalManager); + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCreated(redemptionId, redemption); + + console.log("Created redemption for original amount:", amounts[0]); + + // 2. Credit queued balances with ORIGINAL amounts (what system owes) + uint256[] memory originalSharesToCredit = new uint256[](1); + originalSharesToCredit[0] = liquidTokenManager.assetUnderlyingToShares(assets[0], amounts[0]); + + vm.prank(address(liquidTokenManager)); + liquidToken.creditQueuedAssetElShares(assets, originalSharesToCredit); + + console.log("Credited queued balances with original shares:", originalSharesToCredit[0]); + + // 3. EigenLayer redemption completes with slashing - WM receives LESS than requested + testToken.mint(address(withdrawalManager), slightlyLess); + console.log("Withdrawal Manager received (after slashing):", slightlyLess); + + // 4. System records redemption completion with RECEIVED shares (after slashing) + uint256[] memory receivedShares = new uint256[](1); + receivedShares[0] = liquidTokenManager.assetUnderlyingToShares(assets[0], slightlyLess); + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCompleted(redemptionId, assets, receivedShares); + + console.log("Recorded completion with received shares:", receivedShares[0]); + console.log("Shares difference (slashed):", originalSharesToCredit[0] - receivedShares[0]); + + // 5. Verify withdrawal request shows the slashed amount + IWithdrawalManager.WithdrawalRequest memory request = withdrawalManager.getWithdrawalRequests( + _arrayOf(requestId) + )[0]; + + console.log("Request withdrawable shares:", request.elWithdrawableShares[0]); + console.log("Request can fulfill:", request.canFulfill); + + // The system should record the reduced amount available for withdrawal + assertEq(request.elWithdrawableShares[0], receivedShares[0], "Withdrawable shares should reflect slashing"); + assertTrue(request.canFulfill, "Request should be fulfillable"); + + // 6. User fulfills and gets the slashed amount + uint256 balanceBefore = testToken.balanceOf(user1); + console.log("User balance before fulfillment:", balanceBefore); + + vm.prank(user1); + withdrawalManager.fulfillWithdrawal(requestId); + + uint256 balanceAfter = testToken.balanceOf(user1); + uint256 actualReceived = balanceAfter - balanceBefore; + + console.log("User balance after fulfillment:", balanceAfter); + console.log("User actually received:", actualReceived); + console.log("Expected to receive (slashed amount):", slightlyLess); + + // User should receive the slashed amount, not the original amount + assertEq(actualReceived, slightlyLess, "User should receive slashed amount"); + + console.log("=== SLASHING TOLERANCE TEST COMPLETED SUCCESSFULLY ==="); + } + + // ============================================================================= + // 2. REDEMPTION MANAGEMENT TESTS + // ============================================================================= + + function test_RecordRedemptionCreated_Success() public { + bytes32 redemptionId = keccak256("redemption_test"); + bytes32 requestId = keccak256("request_test"); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = 100e18; + + ILiquidTokenManager.Redemption memory redemption = _createRedemption(requestId, assets, amounts); + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCreated(redemptionId, redemption); + + // Verify redemption was recorded + ILiquidTokenManager.Redemption memory storedRedemption = withdrawalManager.getRedemption(redemptionId); + assertEq(storedRedemption.requestIds[0], requestId); + } + + function test_RecordRedemptionCreated_UnauthorizedCaller() public { + bytes32 redemptionId = keccak256("redemption_test"); + bytes32 requestId = keccak256("request_test"); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = 100e18; + + ILiquidTokenManager.Redemption memory redemption = _createRedemption(requestId, assets, amounts); + + vm.expectRevert(abi.encodeWithSelector(IWithdrawalManager.NotLiquidTokenManager.selector, user1)); + vm.prank(user1); + withdrawalManager.recordRedemptionCreated(redemptionId, redemption); + } + + function test_RecordRedemptionCompleted_Success() public { + // Create withdrawal request first + uint256 depositAmount = 100e18; + _simulateUserDeposit(user1, address(testToken), depositAmount); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = depositAmount; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + // Create redemption + bytes32 redemptionId = keccak256("redemption_test"); + ILiquidTokenManager.Redemption memory redemption = _createRedemption(requestId, assets, amounts); + redemption.receiver = address(withdrawalManager); + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCreated(redemptionId, redemption); + + // *** FIX: Credit queued balances first *** + uint256[] memory sharesToCredit = new uint256[](1); + sharesToCredit[0] = liquidTokenManager.assetUnderlyingToShares(assets[0], amounts[0]); + + vm.prank(address(liquidTokenManager)); + liquidToken.creditQueuedAssetElShares(assets, sharesToCredit); + + // Complete redemption + uint256[] memory receivedShares = new uint256[](1); + receivedShares[0] = liquidTokenManager.assetUnderlyingToShares(assets[0], amounts[0]); + + vm.prank(address(liquidTokenManager)); + uint256[] memory returnedShares = withdrawalManager.recordRedemptionCompleted( + redemptionId, + assets, + receivedShares + ); + + assertEq(returnedShares[0], receivedShares[0]); + + // Verify request is now ready to fulfill + IWithdrawalManager.WithdrawalRequest memory request = withdrawalManager.getWithdrawalRequests( + _arrayOf(requestId) + )[0]; + assertTrue(request.canFulfill); + } + + function test_RecordRedemptionCompleted_WithSlashing() public { + // Create withdrawal request + uint256 depositAmount = 100e18; + _simulateUserDeposit(user1, address(testToken), depositAmount); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = depositAmount; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + // Create redemption + bytes32 redemptionId = keccak256("redemption_test"); + ILiquidTokenManager.Redemption memory redemption = _createRedemption(requestId, assets, amounts); + redemption.receiver = address(withdrawalManager); + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCreated(redemptionId, redemption); + + // *** FIX: Credit queued balances first *** + uint256 originalShares = liquidTokenManager.assetUnderlyingToShares(assets[0], amounts[0]); + uint256[] memory sharesToCredit = new uint256[](1); + sharesToCredit[0] = originalShares; + + vm.prank(address(liquidTokenManager)); + liquidToken.creditQueuedAssetElShares(assets, sharesToCredit); + + // Complete redemption with slashing (90% of original) + uint256[] memory receivedShares = new uint256[](1); + receivedShares[0] = (originalShares * 90) / 100; // 10% slashing + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCompleted(redemptionId, assets, receivedShares); + + // Verify slashing was applied + IWithdrawalManager.WithdrawalRequest memory request = withdrawalManager.getWithdrawalRequests( + _arrayOf(requestId) + )[0]; + + assertLt(request.elWithdrawableShares[0], originalShares); + assertEq(request.elWithdrawableShares[0], receivedShares[0]); + } + + // ============================================================================= + // 3. INTEGRATION WITH LIQUID TOKEN TESTS + // ============================================================================= + + function test_Integration_LT_ShareBurning() public { + // Test that LAT shares are properly burned on fulfillment + uint256 depositAmount = 50e18; + + // User deposits to get shares + uint256 shares = _simulateUserDeposit(user1, address(testToken), depositAmount); + uint256 totalSupplyBefore = liquidToken.totalSupply(); + uint256 userSharesBefore = liquidToken.balanceOf(user1); + + // User initiates withdrawal + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = depositAmount; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + // Verify shares were locked (not burned yet) + uint256 userSharesAfterRequest = liquidToken.balanceOf(user1); + assertEq(userSharesAfterRequest, 0, "Shares should be locked"); + + // Fast forward and complete redemption + vm.warp(block.timestamp + 15 days); + bytes32 redemptionId = keccak256(abi.encode("redemption", requestId)); + _createAndCompleteRedemption(redemptionId, requestId, assets, amounts); + + vm.prank(user1); + withdrawalManager.fulfillWithdrawal(requestId); + + // Verify shares were burned + uint256 totalSupplyAfter = liquidToken.totalSupply(); + assertLt(totalSupplyAfter, totalSupplyBefore, "Total supply should decrease"); + } + + // ============================================================================= + // 4. SLASHING SCENARIO TEST (CF1) + // ============================================================================= + + function test_CFT1_SlashingScenario() public { + // ===== 0. Setup mock assets/strategies ===== + MockRebasingToken stETH = new MockRebasingToken("Mock stETH", "stETH"); + MockERC20 ankrETH = new MockERC20("Mock ankrETH", "ankrETH"); + MockERC20 osETH = new MockERC20("Mock osETH", "osETH"); + MockERC20 lsETH = new MockERC20("Mock lsETH", "lsETH"); + MockERC20 rETH = new MockERC20("Mock rETH", "rETH"); + + MockStrategy stratStETH = new MockStrategy(strategyManager, IERC20(address(stETH))); + MockStrategy stratAnkrETH = new MockStrategy(strategyManager, IERC20(address(ankrETH))); + MockStrategy stratOsETH = new MockStrategy(strategyManager, IERC20(address(osETH))); + MockStrategy stratLsETH = new MockStrategy(strategyManager, IERC20(address(lsETH))); + MockStrategy stratRETH = new MockStrategy(strategyManager, IERC20(address(rETH))); + + MockChainlinkFeed feed = new MockChainlinkFeed(int256(1e8), 8); + + // ===== Configure tokens in Oracle ===== + vm.startPrank(admin); + address[5] memory mockTokens = [ + address(stETH), + address(ankrETH), + address(osETH), + address(lsETH), + address(rETH) + ]; + for (uint i = 0; i < 5; i++) { + tokenRegistryOracle.configureToken( + mockTokens[i], + SOURCE_TYPE_CHAINLINK, + address(feed), + 0, + address(0), + bytes4(0) + ); + } + vm.stopPrank(); + + // ===== Grant deployer permissions ===== + vm.startPrank(admin); + liquidTokenManager.grantRole(liquidTokenManager.DEFAULT_ADMIN_ROLE(), deployer); + liquidTokenManager.grantRole(keccak256("TOKEN_CONFIGURATOR_ROLE"), deployer); + liquidTokenManager.grantRole(liquidTokenManager.STRATEGY_CONTROLLER_ROLE(), deployer); + stakerNodeCoordinator.grantRole(stakerNodeCoordinator.STAKER_NODE_CREATOR_ROLE(), deployer); + stakerNodeCoordinator.grantRole(stakerNodeCoordinator.STAKER_NODES_DELEGATOR_ROLE(), deployer); + vm.stopPrank(); + + // ===== Add tokens ===== + vm.startPrank(deployer); + liquidTokenManager.addToken( + IERC20(address(stETH)), + 18, + 0, + IStrategy(address(stratStETH)), + SOURCE_TYPE_CHAINLINK, + address(feed), + 0, + address(0), + bytes4(0) + ); + liquidTokenManager.addToken( + IERC20(address(ankrETH)), + 18, + 0, + IStrategy(address(stratAnkrETH)), + SOURCE_TYPE_CHAINLINK, + address(feed), + 0, + address(0), + bytes4(0) + ); + liquidTokenManager.addToken( + IERC20(address(osETH)), + 18, + 0, + IStrategy(address(stratOsETH)), + SOURCE_TYPE_CHAINLINK, + address(feed), + 0, + address(0), + bytes4(0) + ); + liquidTokenManager.addToken( + IERC20(address(lsETH)), + 18, + 0, + IStrategy(address(stratLsETH)), + SOURCE_TYPE_CHAINLINK, + address(feed), + 0, + address(0), + bytes4(0) + ); + liquidTokenManager.addToken( + IERC20(address(rETH)), + 18, + 0, + IStrategy(address(stratRETH)), + SOURCE_TYPE_CHAINLINK, + address(feed), + 0, + address(0), + bytes4(0) + ); + + // ===== Create Node ID 0 ===== + stakerNodeCoordinator.createStakerNode(); + vm.stopPrank(); + + // Dynamic IERC20[] array + IERC20[] memory assets = new IERC20[](5); + assets[0] = IERC20(address(stETH)); + assets[1] = IERC20(address(ankrETH)); + assets[2] = IERC20(address(osETH)); + assets[3] = IERC20(address(lsETH)); + assets[4] = IERC20(address(rETH)); + + // ===== PRE-FUND WITHDRAWAL MANAGER ===== + for (uint a = 0; a < assets.length; a++) { + MockERC20(address(assets[a])).mint(address(withdrawalManager), 10 ether); + } + + // ===== Cohort1 deposits ===== + address[5] memory cohort1 = [address(0x101), address(0x102), address(0x103), address(0x104), address(0x105)]; + for (uint u = 0; u < cohort1.length; u++) { + for (uint a = 0; a < assets.length; a++) { + MockERC20(address(assets[a])).mint(cohort1[u], 1 ether); + vm.prank(cohort1[u]); + assets[a].approve(address(liquidToken), type(uint256).max); + IERC20[] memory arr = new IERC20[](1); + arr[0] = assets[a]; + uint256[] memory amts = new uint256[](1); + amts[0] = 1 ether; + vm.prank(cohort1[u]); + liquidToken.deposit(arr, amts, cohort1[u]); + } + } + + // ===== Check balances after deposits ===== + for (uint a = 0; a < assets.length; a++) { + uint256 balance = liquidToken.assetBalances(address(assets[a])); + assertEq(balance, 5 ether, "Each asset should have 5 ETH from 5 users"); + } + + // ===== Apply slashing ===== + console.log("=== APPLYING FIRST SLASHING ==="); + stETH.setConversionRate(95e16, 1e18); + // We'll reduce the actual token balances in the strategies instead + uint256 ankrBalance = ankrETH.balanceOf(address(stratAnkrETH)); + uint256 osBalance = osETH.balanceOf(address(stratOsETH)); + uint256 lsBalance = lsETH.balanceOf(address(stratLsETH)); + uint256 rBalance = rETH.balanceOf(address(stratRETH)); + + // Transfer out slashed amounts to simulate slashing + if (ankrBalance > 0) { + vm.prank(address(stratAnkrETH)); + ankrETH.transfer(address(0xdead), (ankrBalance * 10) / 100); // 10% slashing + } + if (osBalance > 0) { + vm.prank(address(stratOsETH)); + osETH.transfer(address(0xdead), (osBalance * 15) / 100); // 15% slashing + } + if (lsBalance > 0) { + vm.prank(address(stratLsETH)); + lsETH.transfer(address(0xdead), (lsBalance * 50) / 100); // 50% slashing + } + if (rBalance > 0) { + vm.prank(address(stratRETH)); + rETH.transfer(address(0xdead), rBalance); // 100% slashing + } + + // ===== Cohort2 deposits ===== + console.log("=== SECOND COHORT DEPOSITS ==="); + address[5] memory cohort2 = [address(0x201), address(0x202), address(0x203), address(0x204), address(0x205)]; + for (uint u = 0; u < cohort2.length; u++) { + for (uint a = 0; a < assets.length; a++) { + MockERC20(address(assets[a])).mint(cohort2[u], 0.5 ether); + vm.prank(cohort2[u]); + assets[a].approve(address(liquidToken), type(uint256).max); + IERC20[] memory arr = new IERC20[](1); + arr[0] = assets[a]; + uint256[] memory amts = new uint256[](1); + amts[0] = 0.5 ether; + vm.prank(cohort2[u]); + liquidToken.deposit(arr, amts, cohort2[u]); + } + } + + // ===== Cohort1 initiates withdrawals ===== + console.log("=== COHORT1 WITHDRAWAL REQUESTS ==="); + bytes32[] memory reqIds = new bytes32[](cohort1.length); + for (uint u = 0; u < cohort1.length; u++) { + uint256 userShares = liquidToken.balanceOf(cohort1[u]); + if (userShares > 0) { + IERC20[] memory reqAssets = new IERC20[](assets.length); + uint256[] memory amts = new uint256[](assets.length); + for (uint a = 0; a < assets.length; a++) { + uint256 perAssetShares = userShares / assets.length; + amts[a] = liquidToken.calculateAmount(assets[a], perAssetShares); + reqAssets[a] = assets[a]; + } + vm.prank(cohort1[u]); + reqIds[u] = liquidToken.initiateWithdrawal(reqAssets, amts); + } + } + + // ===== WORKAROUND: Skip settlement, go directly to redemption ===== + console.log("=== SKIPPING SETTLEMENT, CREATING REDEMPTION DIRECTLY ==="); + + // Get all valid request IDs + uint256 validCount; + for (uint i = 0; i < reqIds.length; i++) { + if (reqIds[i] != bytes32(0)) validCount++; + } + bytes32[] memory validReqIds = new bytes32[](validCount); + uint256 idx; + for (uint i = 0; i < reqIds.length; i++) { + if (reqIds[i] != bytes32(0)) { + validReqIds[idx++] = reqIds[i]; + } + } + + // ===== Manually credit queued balances (simulating what settlement would do) ===== + vm.startPrank(address(liquidTokenManager)); + for (uint a = 0; a < assets.length; a++) { + IERC20[] memory singleAsset = new IERC20[](1); + uint256[] memory singleAmount = new uint256[](1); + singleAsset[0] = assets[a]; + singleAmount[0] = liquidTokenManager.assetUnderlyingToShares(assets[a], 5 ether); // Convert to shares + liquidToken.creditQueuedAssetElShares(singleAsset, singleAmount); + } + vm.stopPrank(); + + // ===== Check C1 ===== + uint256 C1; + for (uint a = 0; a < assets.length; a++) { + uint256 queued = liquidToken.queuedAssetElShares(address(assets[a])); + console.log("Queued shares for asset", a, ":", queued); + C1 += liquidTokenManager.assetSharesToUnderlying(assets[a], queued); + } + console.log("C1 (total credited to queued balances):", C1); + + // ===== Redemption completion with slashing ===== + console.log("=== REDEMPTION COMPLETION WITH SLASHING ==="); + bytes32 redemptionId = keccak256("mock_redemption"); + uint256[] memory totalShares = new uint256[](assets.length); + for (uint a = 0; a < assets.length; a++) { + totalShares[a] = liquidTokenManager.assetUnderlyingToShares(assets[a], 5 ether); + } + + ILiquidTokenManager.Redemption memory redemption = ILiquidTokenManager.Redemption({ + requestIds: validReqIds, + withdrawalRoots: new bytes32[](0), + assets: assets, + elWithdrawableShares: totalShares, + receiver: address(withdrawalManager) + }); + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCreated(redemptionId, redemption); + + // Calculate received shares after slashing + uint256[] memory recvShares = new uint256[](assets.length); + uint256 totalSlashed; + for (uint a = 0; a < assets.length; a++) { + if (a == 0) recvShares[a] = (totalShares[a] * 95) / 100; + else if (a == 1) recvShares[a] = (totalShares[a] * 90) / 100; + else if (a == 2) recvShares[a] = (totalShares[a] * 85) / 100; + else if (a == 3) recvShares[a] = (totalShares[a] * 50) / 100; + else recvShares[a] = 0; + + uint256 slashedShares = totalShares[a] - recvShares[a]; + totalSlashed += liquidTokenManager.assetSharesToUnderlying(assets[a], slashedShares); + console.log("Asset", a, "- Original shares:", totalShares[a]); + console.log("Received shares:", recvShares[a]); + console.log("Slashed shares:", slashedShares); + } + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCompleted(redemptionId, assets, recvShares); + + uint256 D1 = totalSlashed; + console.log("D1 (total slashed in redemption):", D1); + + // ===== Fulfill withdrawals ===== + console.log("=== FULFILLING WITHDRAWALS ==="); + vm.warp(block.timestamp + 15 days); + + uint256 totalFulfilled; + for (uint u = 0; u < validReqIds.length; u++) { + IWithdrawalManager.WithdrawalRequest memory request = withdrawalManager.getWithdrawalRequests( + _arrayOf(validReqIds[u]) + )[0]; + + uint256 beforeBal; + for (uint a = 0; a < assets.length; a++) { + beforeBal += assets[a].balanceOf(request.user); + } + + vm.prank(request.user); + withdrawalManager.fulfillWithdrawal(validReqIds[u]); + + uint256 afterBal; + for (uint a = 0; a < assets.length; a++) { + afterBal += assets[a].balanceOf(request.user); + } + + uint256 userReceived = afterBal - beforeBal; + totalFulfilled += userReceived; + console.log("User", u, "received:", userReceived); + } + + uint256 D2 = totalFulfilled; + console.log("D2 (total fulfilled to users):", D2); + + // ===== Final CHECK: C1 == D1 + D2 ===== + console.log("Final check - C1:", C1, "D1 + D2:", D1 + D2); + assertEq(C1, D1 + D2, "C1 should equal D1 + D2 for proper accounting"); + } + + // ============================================================================= + // 5. SECURITY TESTS + // ============================================================================= + + function test_Security_ReentrancyProtection() public { + // Setup malicious token attack + uint256 depositAmount = 100e18; + _simulateUserDeposit(user1, address(testToken), depositAmount); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = depositAmount; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + // Setup malicious token to attack + maliciousToken.setAttackTarget(address(withdrawalManager), requestId); + + // Complete redemption and wait + vm.warp(block.timestamp + 15 days); + bytes32 redemptionId = keccak256(abi.encode("redemption", requestId)); + _createAndCompleteRedemption(redemptionId, requestId, assets, amounts); + + // Attack should be blocked by reentrancy guard + vm.prank(user1); + withdrawalManager.fulfillWithdrawal(requestId); + + // Should complete successfully without reentrancy + assertTrue(true, "Reentrancy protection worked"); + } + + function test_Security_ConcurrentRedemptionCompletion() public { + // Test that concurrent redemption completions are handled safely + uint256 depositAmount = 100e18; + _simulateUserDeposit(user1, address(testToken), depositAmount); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = depositAmount; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + bytes32 redemptionId = keccak256("redemption_test"); + ILiquidTokenManager.Redemption memory redemption = _createRedemption(requestId, assets, amounts); + redemption.receiver = address(withdrawalManager); + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCreated(redemptionId, redemption); + + // *** FIX: Credit queued balances first *** + uint256[] memory sharesToCredit = new uint256[](1); + sharesToCredit[0] = liquidTokenManager.assetUnderlyingToShares(assets[0], amounts[0]); + + vm.prank(address(liquidTokenManager)); + liquidToken.creditQueuedAssetElShares(assets, sharesToCredit); + + uint256[] memory receivedShares = new uint256[](1); + receivedShares[0] = liquidTokenManager.assetUnderlyingToShares(assets[0], amounts[0]); + + // First completion should succeed + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCompleted(redemptionId, assets, receivedShares); + + // Second completion should fail (redemption already deleted) + vm.expectRevert(abi.encodeWithSelector(IWithdrawalManager.RedemptionNotFound.selector, redemptionId)); + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCompleted(redemptionId, assets, receivedShares); + } + + // ============================================================================= + // 6. ADMIN FUNCTIONS TESTS + // ============================================================================= + + function test_SetWithdrawalDelay_Success() public { + uint256 newDelay = 10 days; + + vm.prank(deployer); + withdrawalManager.setWithdrawalDelay(newDelay); + + assertEq(withdrawalManager.withdrawalDelay(), newDelay); + } + + function test_SetWithdrawalDelay_InvalidDelay() public { + // Too short + vm.expectRevert(abi.encodeWithSelector(IWithdrawalManager.InvalidWithdrawalDelay.selector, 5 days)); + vm.prank(deployer); + withdrawalManager.setWithdrawalDelay(5 days); + + // Too long + vm.expectRevert(abi.encodeWithSelector(IWithdrawalManager.InvalidWithdrawalDelay.selector, 35 days)); + vm.prank(deployer); + withdrawalManager.setWithdrawalDelay(35 days); + } + + function test_SetWithdrawalDelay_UnauthorizedCaller() public { + vm.expectRevert(); + vm.prank(user1); + withdrawalManager.setWithdrawalDelay(10 days); + } + // ============================================================================= + // 7. FUZZY TESTING - ROUNDING & WEI PRECISION ISSUES + // ============================================================================= + + function testFuzz_WithdrawalRounding_SingleAsset(uint256 depositAmount) public { + // Bound to reasonable range to avoid overflow but test edge cases + depositAmount = bound(depositAmount, 1000, 1000000e18); + + _simulateUserDeposit(user1, address(testToken), depositAmount); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = depositAmount; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + vm.warp(block.timestamp + 15 days); + bytes32 redemptionId = keccak256(abi.encode("redemption", requestId)); + _createAndCompleteRedemption(redemptionId, requestId, assets, amounts); + + uint256 balanceBefore = testToken.balanceOf(user1); + vm.prank(user1); + withdrawalManager.fulfillWithdrawal(requestId); + uint256 balanceAfter = testToken.balanceOf(user1); + + uint256 received = balanceAfter - balanceBefore; + + // Allow for minimal rounding errors (max 2 wei loss) + assertTrue(received >= depositAmount - 2, "Excessive rounding loss"); + assertTrue(received <= depositAmount, "User received more than deposited"); + } + + function testFuzz_SlashingScenarios(uint256 slashingBps) public { + // Test slashing from 0 to 50% (0-5000 bps) + slashingBps = bound(slashingBps, 0, 5000); + + uint256 depositAmount = 100e18; + _simulateUserDeposit(user1, address(testToken), depositAmount); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = depositAmount; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + vm.warp(block.timestamp + 15 days); + + // Calculate slashed amount + uint256 slashedAmount = (depositAmount * slashingBps) / 10000; + uint256 remainingAmount = depositAmount - slashedAmount; + + bytes32 redemptionId = keccak256(abi.encode("redemption", requestId)); + + // Custom redemption flow with slashing + ILiquidTokenManager.Redemption memory redemption = _createRedemption(requestId, assets, amounts); + redemption.receiver = address(withdrawalManager); + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCreated(redemptionId, redemption); + + // Credit original amounts + uint256[] memory originalShares = new uint256[](1); + originalShares[0] = liquidTokenManager.assetUnderlyingToShares(assets[0], amounts[0]); + + vm.prank(address(liquidTokenManager)); + liquidToken.creditQueuedAssetElShares(assets, originalShares); + + // Mint slashed amount to WM + testToken.mint(address(withdrawalManager), remainingAmount); + + // Complete with reduced shares + uint256[] memory receivedShares = new uint256[](1); + receivedShares[0] = liquidTokenManager.assetUnderlyingToShares(assets[0], remainingAmount); + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCompleted(redemptionId, assets, receivedShares); + + uint256 balanceBefore = testToken.balanceOf(user1); + vm.prank(user1); + withdrawalManager.fulfillWithdrawal(requestId); + uint256 balanceAfter = testToken.balanceOf(user1); + + uint256 received = balanceAfter - balanceBefore; + + // Should receive remaining amount with max 2 wei rounding error + assertTrue(received >= remainingAmount - 2, "Slashing calculation error"); + assertTrue(received <= remainingAmount, "User received more than expected after slashing"); + } + + function testFuzz_RebasingTokenRounding(uint256 rebaseRate) public { + // Test rebasing rates from 0.5x to 2x (50% loss to 100% gain) + rebaseRate = bound(rebaseRate, 50e16, 200e16); // 0.5 to 2.0 in 18 decimals + + uint256 depositAmount = 100e18; + + // Setup rebasing token + mockStETH.setConversionRate(rebaseRate, 1e18); + + _simulateUserDeposit(user1, address(mockStETH), depositAmount); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = IERC20(address(mockStETH)); + amounts[0] = depositAmount; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + vm.warp(block.timestamp + 15 days); + bytes32 redemptionId = keccak256(abi.encode("redemption", requestId)); + _createAndCompleteRedemption(redemptionId, requestId, assets, amounts); + + uint256 balanceBefore = mockStETH.balanceOf(user1); + vm.prank(user1); + withdrawalManager.fulfillWithdrawal(requestId); + uint256 balanceAfter = mockStETH.balanceOf(user1); + + uint256 received = balanceAfter - balanceBefore; + + // Allow for rebasing calculation rounding errors + assertTrue(received >= depositAmount - 3, "Excessive rebasing rounding loss"); + } + // ============================================================================= + // 9. PRECISION & EDGE CASE TESTING + // ============================================================================= + + function test_EdgeCase_MinimalAmounts_SafeApproach() public { + // Start with a reasonable base amount and work down + uint256 baseAmount = 1e15; // 0.001 tokens + + uint256[8] memory multipliers = [uint256(1000), 500, 100, 50, 10, 5, 2, 1]; + + for (uint i = 0; i < multipliers.length; i++) { + uint256 amount = baseAmount / multipliers[i]; + + // Skip if amount is zero or would cause zero shares + if (amount == 0) continue; + + // USE THE CORRECT FUNCTION NAME: assetUnderlyingToShares + uint256 expectedShares = liquidTokenManager.assetUnderlyingToShares(testToken, amount); + if (expectedShares == 0) { + console.log("Amount", amount, "would result in zero shares, skipping"); + continue; + } + + console.log("Testing minimal amount:", amount, "Expected shares:", expectedShares); + + _simulateUserDeposit(user1, address(testToken), amount); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = amount; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + vm.warp(block.timestamp + 15 days); + bytes32 redemptionId = keccak256(abi.encode("minimal", requestId, i)); + _createAndCompleteRedemption(redemptionId, requestId, assets, amounts); + + uint256 balanceBefore = testToken.balanceOf(user1); + vm.prank(user1); + withdrawalManager.fulfillWithdrawal(requestId); + uint256 balanceAfter = testToken.balanceOf(user1); + + uint256 received = balanceAfter - balanceBefore; + uint256 loss = amount > received ? amount - received : 0; + + // For minimal amounts, precision loss should be very small + uint256 maxAllowedLoss = amount < 1e12 ? 1 : 2; + + assertTrue( + loss <= maxAllowedLoss, + string( + abi.encodePacked( + "Minimal amount ", + Strings.toString(amount), + " precision loss too high: ", + Strings.toString(loss) + ) + ) + ); + + console.log(" Minimal amount", amount, "received:"); + console.log(received, "loss:", loss); + } + } + + function test_EdgeCase_MaximalAmounts() public { + // Test with very large amounts + uint256 maxAmount = type(uint128).max; // Large but safe amount + + testToken.mint(user1, maxAmount); + testToken.mint(address(liquidToken), maxAmount); // Ensure liquidity + + vm.prank(user1); + testToken.approve(address(liquidToken), maxAmount); + + IERC20[] memory depositAssets = new IERC20[](1); + uint256[] memory depositAmounts = new uint256[](1); + depositAssets[0] = testToken; + depositAmounts[0] = maxAmount; + + vm.prank(user1); + liquidToken.deposit(depositAssets, depositAmounts, user1); + + // Withdraw + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(depositAssets, depositAmounts); + + vm.warp(block.timestamp + 15 days); + bytes32 redemptionId = keccak256(abi.encode("redemption", requestId)); + _createAndCompleteRedemption(redemptionId, requestId, depositAssets, depositAmounts); + + uint256 balanceBefore = testToken.balanceOf(user1); + vm.prank(user1); + withdrawalManager.fulfillWithdrawal(requestId); + uint256 balanceAfter = testToken.balanceOf(user1); + + uint256 received = balanceAfter - balanceBefore; + + // Even with large amounts, precision loss should be minimal + assertTrue(received >= maxAmount - 10, "Large amount lost too much precision"); + } + + function test_EdgeCase_ConcurrentSlashingAndRebasing() public { + uint256 depositAmount = 100e18; + + // Setup rebasing token + mockStETH.setConversionRate(100e16, 1e18); // Start at 1:1 + + _simulateUserDeposit(user1, address(mockStETH), depositAmount); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = IERC20(address(mockStETH)); + amounts[0] = depositAmount; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + // Simulate negative rebase during withdrawal process + mockStETH.setConversionRate(95e16, 1e18); // 5% negative rebase + + vm.warp(block.timestamp + 15 days); + + // Simulate additional slashing during redemption + uint256 slashedAmount = (depositAmount * 95) / 100; // 5% slashing on top of rebase + uint256 finalAmount = (slashedAmount * 95) / 100; // Compound effect + + bytes32 redemptionId = keccak256(abi.encode("redemption", requestId)); + + // Create redemption with original amount + ILiquidTokenManager.Redemption memory redemption = _createRedemption(requestId, assets, amounts); + redemption.receiver = address(withdrawalManager); + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCreated(redemptionId, redemption); + + // Credit original amounts + uint256[] memory originalShares = new uint256[](1); + originalShares[0] = liquidTokenManager.assetUnderlyingToShares(assets[0], amounts[0]); + + vm.prank(address(liquidTokenManager)); + liquidToken.creditQueuedAssetElShares(assets, originalShares); + + // Mint final amount (after both rebase and slashing) + mockStETH.mint(address(withdrawalManager), finalAmount); + + // Complete with final shares + uint256[] memory receivedShares = new uint256[](1); + receivedShares[0] = liquidTokenManager.assetUnderlyingToShares(assets[0], finalAmount); + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCompleted(redemptionId, assets, receivedShares); + + uint256 balanceBefore = mockStETH.balanceOf(user1); + vm.prank(user1); + withdrawalManager.fulfillWithdrawal(requestId); + uint256 balanceAfter = mockStETH.balanceOf(user1); + + uint256 received = balanceAfter - balanceBefore; + + // Should receive final amount with minimal rounding error + assertTrue(received >= finalAmount - 5, "Compound slashing/rebasing error too large"); + assertTrue(received <= finalAmount, "User received more than expected"); + + console.log("Original amount:", depositAmount); + console.log("Final amount after rebase + slashing:", finalAmount); + console.log("User received:", received); + console.log("Total loss:", depositAmount - received); + } + + function test_EdgeCase_MultipleSlashingEvents() public { + uint256 depositAmount = 1000e18; + _simulateUserDeposit(user1, address(testToken), depositAmount); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = depositAmount; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + vm.warp(block.timestamp + 15 days); + + // Simulate multiple slashing events + uint256 currentAmount = depositAmount; + uint256[3] memory slashingPercentages = [uint256(5), 3, 2]; // 5%, 3%, 2% + + for (uint i = 0; i < slashingPercentages.length; i++) { + currentAmount = (currentAmount * (100 - slashingPercentages[i])) / 100; + } + + bytes32 redemptionId = keccak256(abi.encode("redemption", requestId)); + + // Create redemption flow with final amount + ILiquidTokenManager.Redemption memory redemption = _createRedemption(requestId, assets, amounts); + redemption.receiver = address(withdrawalManager); + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCreated(redemptionId, redemption); + + uint256[] memory originalShares = new uint256[](1); + originalShares[0] = liquidTokenManager.assetUnderlyingToShares(assets[0], amounts[0]); + + vm.prank(address(liquidTokenManager)); + liquidToken.creditQueuedAssetElShares(assets, originalShares); + + testToken.mint(address(withdrawalManager), currentAmount); + + uint256[] memory receivedShares = new uint256[](1); + receivedShares[0] = liquidTokenManager.assetUnderlyingToShares(assets[0], currentAmount); + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCompleted(redemptionId, assets, receivedShares); + + uint256 balanceBefore = testToken.balanceOf(user1); + vm.prank(user1); + withdrawalManager.fulfillWithdrawal(requestId); + uint256 balanceAfter = testToken.balanceOf(user1); + + uint256 received = balanceAfter - balanceBefore; + uint256 totalLoss = depositAmount - currentAmount; + + assertTrue(received >= currentAmount - 3, "Multiple slashing events caused excessive rounding error"); + + console.log("Original deposit:", depositAmount); + console.log("After multiple slashing events:", currentAmount); + console.log("Total theoretical loss:", totalLoss); + console.log("User actually received:", received); + console.log("Additional rounding loss:", currentAmount - received); + } + + function test_EdgeCase_PrecisionBoundaries() public { + // Test amounts around precision boundaries + // Pre-calculated division results to avoid Solidity rational constant issues + + uint256 oneThirdToken = 333333333333333333; // 1e18 / 3 = 0.333... tokens + uint256 twoThirdToken = 666666666666666666; // (1e18 * 2) / 3 = 0.666... tokens + uint256 oneSeventhToken = 142857142857142857; // 1e18 / 7 = 0.142857... tokens (repeating) + uint256 veryLargeAmount = 115792089237316195423570985008687907853269984665640564039457584007913129639935; // type(uint256).max / 1e6 + uint256 extremelyLargeAmount = 115792089237316195423570985008687907853269984665640564039457; // type(uint256).max / 1e12 + + uint256[10] memory boundaryAmounts = [ + uint256(1e18 - 1), // 999999999999999999 wei - Just under 1 token + uint256(1e18), // 1000000000000000000 wei - Exactly 1 token + uint256(1e18 + 1), // 1000000000000000001 wei - Just over 1 token + oneThirdToken, // 333333333333333333 wei - 1/3 token (repeating decimal) + twoThirdToken, // 666666666666666666 wei - 2/3 token (repeating decimal) + oneSeventhToken, // 142857142857142857 wei - 1/7 token (long repeating decimal) + uint256(1e18 / 1000), // 1000000000000000 wei - 0.001 token + uint256((1e18 * 999) / 1000), // 999000000000000000 wei - 0.999 token + veryLargeAmount, // Very large amount (max_uint256 / 1M) + extremelyLargeAmount // Extremely large amount (max_uint256 / 1T) + ]; + + for (uint i = 0; i < boundaryAmounts.length; i++) { + uint256 amount = boundaryAmounts[i]; + + // Skip if amount would cause overflow in test setup + if (amount > 1e30) continue; + + _simulateUserDeposit(user2, address(testToken), amount); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = amount; + + vm.prank(user2); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + vm.warp(block.timestamp + 15 days); + bytes32 redemptionId = keccak256(abi.encode("redemption", requestId, i)); + _createAndCompleteRedemption(redemptionId, requestId, assets, amounts); + + uint256 balanceBefore = testToken.balanceOf(user2); + vm.prank(user2); + withdrawalManager.fulfillWithdrawal(requestId); + uint256 balanceAfter = testToken.balanceOf(user2); + + uint256 received = balanceAfter - balanceBefore; + uint256 loss = amount > received ? amount - received : 0; + + // Allow minimal precision loss, scaled by amount size + uint256 maxAllowedLoss = amount < 1e18 ? 2 : (amount / 1e18) * 2; + + assertTrue( + loss <= maxAllowedLoss, + string( + abi.encodePacked( + "Boundary amount ", + Strings.toString(i), + " precision loss too high: ", + Strings.toString(loss) + ) + ) + ); + } + } + // ============================================================================= + // 10. TESTING UTILITIES FOR COMPREHENSIVE SCENARIOS + // ============================================================================= + + function _simulateComplexSlashingScenario( + uint256[] memory deposits, + uint256[] memory slashingBps + ) internal returns (uint256 totalLoss) { + require(deposits.length == slashingBps.length, "Array length mismatch"); + + for (uint i = 0; i < deposits.length; i++) { + uint256 slashedAmount = (deposits[i] * (10000 - slashingBps[i])) / 10000; + totalLoss += deposits[i] - slashedAmount; + + // Simulate individual withdrawal with slashing + _simulateUserDeposit(user1, address(testToken), deposits[i]); + + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = testToken; + amounts[0] = deposits[i]; + + vm.prank(user1); + bytes32 requestId = liquidToken.initiateWithdrawal(assets, amounts); + + vm.warp(block.timestamp + 15 days); + + // Custom redemption with slashing + bytes32 redemptionId = keccak256(abi.encode("redemption", requestId, i)); + ILiquidTokenManager.Redemption memory redemption = _createRedemption(requestId, assets, amounts); + redemption.receiver = address(withdrawalManager); + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCreated(redemptionId, redemption); + + uint256[] memory originalShares = new uint256[](1); + originalShares[0] = liquidTokenManager.assetUnderlyingToShares(assets[0], amounts[0]); + + vm.prank(address(liquidTokenManager)); + liquidToken.creditQueuedAssetElShares(assets, originalShares); + + testToken.mint(address(withdrawalManager), slashedAmount); + + uint256[] memory receivedShares = new uint256[](1); + receivedShares[0] = liquidTokenManager.assetUnderlyingToShares(assets[0], slashedAmount); + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCompleted(redemptionId, assets, receivedShares); + + uint256 balanceBefore = testToken.balanceOf(user1); + vm.prank(user1); + withdrawalManager.fulfillWithdrawal(requestId); + uint256 balanceAfter = testToken.balanceOf(user1); + + uint256 received = balanceAfter - balanceBefore; + + // Verify precision + assertTrue(received >= slashedAmount - 2, "Slashing precision error"); + } + } + + function _generateRandomSlashingPattern(uint256 seed) internal pure returns (uint256[] memory) { + uint256[] memory pattern = new uint256[](5); + uint256 currentSeed = seed; + + for (uint i = 0; i < 5; i++) { + // Generate pseudo-random slashing between 0-10% + pattern[i] = (currentSeed % 1000); // 0-999 bps (0-9.99%) + currentSeed = uint256(keccak256(abi.encode(currentSeed))) % type(uint128).max; + } + + return pattern; + } + // ============================================================================= + // HELPER FUNCTIONS + // ============================================================================= + + function _arrayOf(bytes32 element) internal pure returns (bytes32[] memory) { + bytes32[] memory array = new bytes32[](1); + array[0] = element; + return array; + } + + function _createRedemption( + bytes32 requestId, + IERC20[] memory assets, + uint256[] memory amounts + ) internal pure returns (ILiquidTokenManager.Redemption memory) { + bytes32[] memory requestIds = new bytes32[](1); + bytes32[] memory withdrawalRoots = new bytes32[](1); + uint256[] memory elWithdrawableShares = new uint256[](amounts.length); + + requestIds[0] = requestId; + withdrawalRoots[0] = keccak256("withdrawal_root"); + + // Convert amounts to shares (simplified) + for (uint256 i = 0; i < amounts.length; i++) { + elWithdrawableShares[i] = amounts[i]; // Simplified 1:1 conversion + } + + return + ILiquidTokenManager.Redemption({ + requestIds: requestIds, + withdrawalRoots: withdrawalRoots, + assets: assets, + elWithdrawableShares: elWithdrawableShares, + receiver: address(0) // Will be set by caller + }); + } + + function _createAndCompleteRedemption( + bytes32 redemptionId, + bytes32 requestId, + IERC20[] memory assets, + uint256[] memory amounts + ) internal { + ILiquidTokenManager.Redemption memory redemption = _createRedemption(requestId, assets, amounts); + redemption.receiver = address(withdrawalManager); + + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCreated(redemptionId, redemption); + + // *** CRITICAL FIX: Credit queued balances FIRST *** + // This simulates what the real settlement process would do + uint256[] memory sharesToCredit = new uint256[](amounts.length); + for (uint256 i = 0; i < amounts.length; i++) { + sharesToCredit[i] = liquidTokenManager.assetUnderlyingToShares(assets[i], amounts[i]); + } + + // Credit the queued balances (this is what was missing) + vm.prank(address(liquidTokenManager)); + liquidToken.creditQueuedAssetElShares(assets, sharesToCredit); + + // Mint received amounts to withdrawal manager (simulating redemption completion) + for (uint256 i = 0; i < assets.length; i++) { + MockERC20(address(assets[i])).mint(address(withdrawalManager), amounts[i]); + } + + // Convert amounts to shares for completion + uint256[] memory receivedShares = new uint256[](amounts.length); + for (uint256 i = 0; i < amounts.length; i++) { + receivedShares[i] = liquidTokenManager.assetUnderlyingToShares(assets[i], amounts[i]); + } + + // Now complete the redemption (this will debit the queued balances) + vm.prank(address(liquidTokenManager)); + withdrawalManager.recordRedemptionCompleted(redemptionId, assets, receivedShares); + } +} \ No newline at end of file diff --git a/test/common/BaseTest.sol b/test/common/BaseTest.sol index 0b5b989e..296eef48 100644 --- a/test/common/BaseTest.sol +++ b/test/common/BaseTest.sol @@ -28,8 +28,17 @@ import {ILiquidToken} from "../../src/interfaces/ILiquidToken.sol"; import {ITokenRegistryOracle} from "../../src/interfaces/ITokenRegistryOracle.sol"; import {ILiquidTokenManager} from "../../src/interfaces/ILiquidTokenManager.sol"; import {ILSTSwapRouter} from "../../src/interfaces/ILSTSwapRouter.sol"; +// Import the V1 interface with alias to avoid conflicts +import {IWithdrawalManager as IWithdrawalManagerV1} from "../../src/interfaces/IWithdrawalManager.sol"; import {NetworkAddresses} from "../utils/NetworkAddresses.sol"; +// Mock withdrawal manager for BaseTest +contract MockWithdrawalManager { + // Minimal mock implementation for BaseTest + function initialize(bytes memory) external {} + function createWithdrawalRequest(IERC20[] memory, uint256[] memory, uint256, address, bytes32) external {} +} + contract BaseTest is Test { // Source type constants uint8 constant SOURCE_TYPE_CHAINLINK = 1; @@ -53,6 +62,9 @@ contract BaseTest is Test { StakerNodeCoordinator public stakerNodeCoordinator; StakerNode public stakerNodeImplementation; + // Mock withdrawal manager for BaseTest + MockWithdrawalManager public mockWithdrawalManager; + // Mock LSR contract for testing - Added ILSTSwapRouter public mockLSTSwapRouter; @@ -200,7 +212,7 @@ contract BaseTest is Test { vm.stopPrank(); } - function _renounceAllRoles() private { + function _renounceAllRoles() internal virtual { vm.startPrank(deployer); // LiquidTokenManager @@ -259,7 +271,7 @@ contract BaseTest is Test { mETHToETHSelector = bytes4(keccak256("mETHToETH(uint256)")); } - function _setupELContracts() private { + function _setupELContracts() internal virtual { uint256 chainId = block.chainid; NetworkAddresses.Addresses memory addresses = NetworkAddresses.getAddresses(chainId); @@ -267,8 +279,11 @@ contract BaseTest is Test { delegationManager = IDelegationManager(addresses.delegationManager); } - function _deployMockContracts() private { - // Base test tokens + function _deployMockContracts() internal virtual { + // Deploy mock withdrawal manager first + mockWithdrawalManager = new MockWithdrawalManager(); + + // Base test tokens - Fix: MockERC20 expects 3 parameters (name, symbol, decimals) testToken = new MockERC20("Test Token", "TEST"); testToken2 = new MockERC20("Test Token 2", "TEST2"); mockStrategy = new MockStrategy(strategyManager, IERC20(address(testToken))); @@ -282,7 +297,7 @@ contract BaseTest is Test { mockLSTSwapRouter = ILSTSwapRouter(address(0xDEAD)); // Placeholder address for now } - function _deployMainContracts() private { + function _deployMainContracts() internal virtual { _tokenRegistryOracleImplementation = new TokenRegistryOracle(); _liquidTokenImplementation = new LiquidToken(); _liquidTokenManagerImplementation = new LiquidTokenManager(); @@ -290,7 +305,7 @@ contract BaseTest is Test { stakerNodeImplementation = new StakerNode(); } - function _deployProxies() private { + function _deployProxies() internal virtual { tokenRegistryOracle = TokenRegistryOracle( address(new TransparentUpgradeableProxy(address(_tokenRegistryOracleImplementation), proxyAdminAddress, "")) ); @@ -313,7 +328,7 @@ contract BaseTest is Test { ); } - function _setupOracleSources() private { + function _setupOracleSources() internal virtual { console.log("Setting up oracle sources..."); // Grant TOKEN_CONFIGURATOR_ROLE to admin @@ -359,7 +374,7 @@ contract BaseTest is Test { vm.stopPrank(); } - function _initializeTokenRegistryOracle() private { + function _initializeTokenRegistryOracle() internal virtual { console.log("Initializing TokenRegistryOracle..."); ITokenRegistryOracle.Init memory init = ITokenRegistryOracle.Init({ initialOwner: deployer, @@ -388,15 +403,17 @@ contract BaseTest is Test { vm.stopPrank(); } - function _initializeLiquidTokenManager() private { + function _initializeLiquidTokenManager() internal virtual { console.log("Initializing LiquidTokenManager..."); + // Use mock withdrawal manager instead of address(0) ILiquidTokenManager.Init memory init = ILiquidTokenManager.Init({ liquidToken: liquidToken, strategyManager: strategyManager, delegationManager: delegationManager, stakerNodeCoordinator: stakerNodeCoordinator, tokenRegistryOracle: ITokenRegistryOracle(address(tokenRegistryOracle)), - lstSwapRouter: mockLSTSwapRouter, // Use mock LSR for testing + lstSwapRouter: mockLSTSwapRouter, + withdrawalManager: IWithdrawalManagerV1(address(mockWithdrawalManager)), // Use mock instead of address(0) initialOwner: deployer, strategyController: deployer, priceUpdater: address(tokenRegistryOracle) @@ -418,10 +435,12 @@ contract BaseTest is Test { vm.stopPrank(); } - function _initializeStakerNodeCoordinator() private { + function _initializeStakerNodeCoordinator() internal virtual { console.log("Initializing StakerNodeCoordinator..."); + // Use mock withdrawal manager instead of address(0) IStakerNodeCoordinator.Init memory init = IStakerNodeCoordinator.Init({ liquidTokenManager: liquidTokenManager, + withdrawalManager: IWithdrawalManagerV1(address(mockWithdrawalManager)), // Use mock instead of address(0) strategyManager: strategyManager, delegationManager: delegationManager, maxNodes: 10, @@ -443,15 +462,17 @@ contract BaseTest is Test { vm.stopPrank(); } - function _initializeLiquidToken() private { + function _initializeLiquidToken() internal virtual { console.log("Initializing LiquidToken..."); + // Use mock withdrawal manager instead of address(0) ILiquidToken.Init memory init = ILiquidToken.Init({ name: "Liquid Staking Token", symbol: "LST", initialOwner: deployer, pauser: pauser, liquidTokenManager: ILiquidTokenManager(address(liquidTokenManager)), - tokenRegistryOracle: ITokenRegistryOracle(address(tokenRegistryOracle)) + tokenRegistryOracle: ITokenRegistryOracle(address(tokenRegistryOracle)), + withdrawalManager: IWithdrawalManagerV1(address(mockWithdrawalManager)) // Use mock instead of address(0) }); vm.prank(deployer); @@ -464,7 +485,7 @@ contract BaseTest is Test { vm.stopPrank(); } - function _setupTestTokens() private { + function _setupTestTokens() internal virtual { testToken.mint(user1, 100 ether); testToken.mint(user2, 100 ether); testToken2.mint(user1, 100 ether); @@ -502,7 +523,7 @@ contract BaseTest is Test { /** * @dev Configures a token with Chainlink as the primary price source */ - function _setupChainlinkToken(address token, address feed) internal { + function _setupChainlinkToken(address token, address feed) internal virtual { vm.prank(admin); tokenRegistryOracle.configureToken( token, @@ -517,7 +538,7 @@ contract BaseTest is Test { /** * @dev Configures a token with Protocol rate as the primary price source */ - function _setupProtocolToken(address token, address contract_, bytes4 selector, bool needsArg) internal { + function _setupProtocolToken(address token, address contract_, bytes4 selector, bool needsArg) internal virtual { vm.prank(admin); tokenRegistryOracle.configureToken( token, @@ -532,7 +553,7 @@ contract BaseTest is Test { /** * @dev Configures a token with Curve pool as the primary price source */ - function _setupCurveToken(address token, address curvePool) internal { + function _setupCurveToken(address token, address curvePool) internal virtual { vm.prank(admin); tokenRegistryOracle.configureToken( token, @@ -554,7 +575,7 @@ contract BaseTest is Test { uint8 needsArg, address fallbackSource, bytes4 fallbackFn - ) internal { + ) internal virtual { vm.prank(admin); tokenRegistryOracle.configureToken(token, primaryType, primarySource, needsArg, fallbackSource, fallbackFn); } @@ -562,53 +583,53 @@ contract BaseTest is Test { /** * @dev Gets the price of a token directly from TokenRegistryOracle */ - function _getTokenPrice(address token) internal returns (uint256) { + function _getTokenPrice(address token) internal virtual returns (uint256) { return tokenRegistryOracle.getTokenPrice(token); } // Helper functions for inheriting contracts to use - function _actAsAdmin(function() internal fn) internal { + function _actAsAdmin(function() internal fn) internal virtual { vm.startPrank(admin); fn(); vm.stopPrank(); } - function _actAsDeployer(function() internal fn) internal { + function _actAsDeployer(function() internal fn) internal virtual { vm.startPrank(deployer); fn(); vm.stopPrank(); } - function _actAsUser1(function() internal fn) internal { + function _actAsUser1(function() internal fn) internal virtual { vm.startPrank(user1); fn(); vm.stopPrank(); } - function _actAsUser2(function() internal fn) internal { + function _actAsUser2(function() internal fn) internal virtual { vm.startPrank(user2); fn(); vm.stopPrank(); } // Helper to create a new price source mock for a token - updated to use int256 - function _createMockPriceFeed(int256 price, uint8 decimals) internal returns (MockChainlinkFeed) { + function _createMockPriceFeed(int256 price, uint8 decimals) internal virtual returns (MockChainlinkFeed) { return new MockChainlinkFeed(price, decimals); } - function _createMockProtocolToken(uint256 exchangeRate) internal returns (MockProtocolToken) { + function _createMockProtocolToken(uint256 exchangeRate) internal virtual returns (MockProtocolToken) { MockProtocolToken token = new MockProtocolToken(); token.setExchangeRate(exchangeRate); return token; } - function _createMockCurvePool(uint256 virtualPrice) internal returns (MockCurvePool) { + function _createMockCurvePool(uint256 virtualPrice) internal virtual returns (MockCurvePool) { MockCurvePool pool = new MockCurvePool(); pool.setVirtualPrice(virtualPrice); return pool; } - function _createMockFailingOracle() internal returns (MockFailingOracle) { + function _createMockFailingOracle() internal virtual returns (MockFailingOracle) { return new MockFailingOracle(); } @@ -617,7 +638,7 @@ contract BaseTest is Test { /** * @dev Sets a mock LSR contract for testing swap functionality */ - function _setMockLSTSwapRouter(address mockLSR) internal { + function _setMockLSTSwapRouter(address mockLSR) internal virtual { mockLSTSwapRouter = ILSTSwapRouter(mockLSR); // Update in LiquidTokenManager if it's already initialized @@ -635,7 +656,7 @@ contract BaseTest is Test { IERC20[] memory assetsToSwap, uint256[] memory amountsToSwap, IERC20[] memory assetsToStake - ) internal { + ) internal virtual { // This would be overridden in actual test contracts that set up proper mocks vm.startPrank(deployer); liquidTokenManager.swapAndStakeAssetsToNode(nodeId, assetsToSwap, amountsToSwap, assetsToStake); @@ -645,7 +666,36 @@ contract BaseTest is Test { /** * @dev Helper to get current LSR address */ - function _getLSTSwapRouterAddress() internal view returns (address) { + function _getLSTSwapRouterAddress() internal view virtual returns (address) { return address(liquidTokenManager.lstSwapRouter()); } -} + + // ================= WITHDRAWAL MANAGER INTEGRATION HELPERS ================= + // Note: These helpers are generic and don't depend on a specific interface version + + /** + * @dev Helper to simulate user deposit for withdrawal testing + */ + function _simulateUserDeposit( + address user, + address token, + uint256 amount + ) internal virtual returns (uint256 shares) { + // Mint tokens to user if needed + MockERC20(token).mint(user, amount); + + // Approve LiquidToken + vm.prank(user); + IERC20(token).approve(address(liquidToken), amount); + + // Deposit + IERC20[] memory assets = new IERC20[](1); + uint256[] memory amounts = new uint256[](1); + assets[0] = IERC20(token); + amounts[0] = amount; + + vm.prank(user); + uint256[] memory sharesArray = liquidToken.deposit(assets, amounts, user); + shares = sharesArray[0]; + } +} \ No newline at end of file