diff --git a/script/upgrades/CrossPodApproval/transactions.s.sol b/script/upgrades/CrossPodApproval/transactions.s.sol index e38d75420..1402a38f3 100644 --- a/script/upgrades/CrossPodApproval/transactions.s.sol +++ b/script/upgrades/CrossPodApproval/transactions.s.sol @@ -256,13 +256,13 @@ contract CrossPodApprovalScript is Script, Deployed, Utils { } function verifyBytecode() internal { - LiquidityPool newLiquidityPoolImplementation = new LiquidityPool(); + // LiquidityPool newLiquidityPoolImplementation = new LiquidityPool(); EtherFiNodesManager newEtherFiNodesManagerImplementation = new EtherFiNodesManager( address(STAKING_MANAGER), address(ROLE_REGISTRY), address(ETHERFI_RATE_LIMITER) ); - contractCodeChecker.verifyContractByteCodeMatch(liquidityPoolImpl, address(newLiquidityPoolImplementation)); + // contractCodeChecker.verifyContractByteCodeMatch(liquidityPoolImpl, address(newLiquidityPoolImplementation)); contractCodeChecker.verifyContractByteCodeMatch(etherFiNodesManagerImpl, address(newEtherFiNodesManagerImplementation)); console2.log("[OK] Bytecode verified successfully"); diff --git a/script/upgrades/priority-queue/deployPriorityQueue.s.sol b/script/upgrades/priority-queue/deployPriorityQueue.s.sol new file mode 100644 index 000000000..389989f79 --- /dev/null +++ b/script/upgrades/priority-queue/deployPriorityQueue.s.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "forge-std/Script.sol"; +import {LiquidityPool} from "../../../src/LiquidityPool.sol"; +import {PriorityWithdrawalQueue} from "../../../src/PriorityWithdrawalQueue.sol"; +import {UUPSProxy} from "../../../src/UUPSProxy.sol"; +import {Utils, ICreate2Factory} from "../../utils/utils.sol"; +import {EtherFiRedemptionManager} from "../../../src/EtherFiRedemptionManager.sol"; + +contract DeployPriorityQueue is Script, Utils { + ICreate2Factory constant factory = ICreate2Factory(0x356d1B83970CeF2018F2c9337cDdb67dff5AEF99); + + address priorityWithdrawalQueueImpl; + address priorityWithdrawalQueueProxy; + address liquidityPoolImpl; + address etherFiRedemptionManagerImpl; + bytes32 commitHashSalt = hex"45312df178d6eb8143604e47b7aa9e618779c0de"; // TODO: Update with actual commit hash + + uint32 constant MIN_DELAY = 1 hours; // TODO: Set appropriate min delay (e.g., 1 hours = 3600) + + function dryRun() public view { + console2.log("================================================"); + console2.log("============= DRY RUN - CONFIG ============"); + console2.log("================================================"); + console2.log(""); + + console2.log("Constructor Args for PriorityWithdrawalQueue:"); + console2.log(" _liquidityPool:", LIQUIDITY_POOL); + console2.log(" _eETH:", EETH); + console2.log(" _roleRegistry:", ROLE_REGISTRY); + console2.log(" _treasury:", TREASURY); + console2.log(" _minDelay:", MIN_DELAY); + console2.log(""); + + console2.log("Constructor Args for LiquidityPool:"); + console2.log(" _priorityWithdrawalQueue: "); + console2.log(""); + + console2.log("Salt:", vm.toString(commitHashSalt)); + console2.log(""); + + console2.log("To compute exact addresses, run with mainnet fork:"); + console2.log(" forge script script/upgrades/priority-queue/deployPriorityQueue.s.sol:DeployPriorityQueue --sig 'dryRunWithFork()' --fork-url "); + } + + function run() public { + console2.log("================================================"); + console2.log("======== Deploying Priority Queue & LP ========="); + console2.log("================================================"); + console2.log(""); + + vm.startBroadcast(); + + // Step 1: Deploy PriorityWithdrawalQueue implementation + { + string memory contractName = "PriorityWithdrawalQueue"; + bytes memory constructorArgs = abi.encode( + LIQUIDITY_POOL, + EETH, + ROLE_REGISTRY, + TREASURY, + MIN_DELAY + ); + bytes memory bytecode = abi.encodePacked( + type(PriorityWithdrawalQueue).creationCode, + constructorArgs + ); + priorityWithdrawalQueueImpl = deploy(contractName, constructorArgs, bytecode, commitHashSalt, true, factory); + } + + // Step 2: Deploy PriorityWithdrawalQueue proxy with initialization + { + string memory contractName = "UUPSProxy"; // Use actual contract name for artifact lookup + // Encode initialize() call for proxy deployment + bytes memory initData = abi.encodeWithSelector(PriorityWithdrawalQueue.initialize.selector); + bytes memory constructorArgs = abi.encode(priorityWithdrawalQueueImpl, initData); + bytes memory bytecode = abi.encodePacked( + type(UUPSProxy).creationCode, + constructorArgs + ); + priorityWithdrawalQueueProxy = deploy(contractName, constructorArgs, bytecode, commitHashSalt, true, factory); + } + + // Step 3: Deploy EtherFiRedemptionManager implementation + { + string memory contractName = "EtherFiRedemptionManager"; + bytes memory constructorArgs = abi.encode( + LIQUIDITY_POOL, + EETH, + WEETH, + TREASURY, + ROLE_REGISTRY, + ETHERFI_RESTAKER, + priorityWithdrawalQueueProxy + ); + bytes memory bytecode = abi.encodePacked( + type(EtherFiRedemptionManager).creationCode, + constructorArgs + ); + etherFiRedemptionManagerImpl = deploy(contractName, constructorArgs, bytecode, commitHashSalt, true, factory); + } + + // Step 4: Deploy LiquidityPool implementation with predicted proxy address + { + string memory contractName = "LiquidityPool"; + bytes memory constructorArgs = abi.encode(priorityWithdrawalQueueProxy); + bytes memory bytecode = abi.encodePacked( + type(LiquidityPool).creationCode, + constructorArgs + ); + liquidityPoolImpl = deploy(contractName, constructorArgs, bytecode, commitHashSalt, true, factory); + } + + vm.stopBroadcast(); + + // Summary + console2.log(""); + console2.log("================================================"); + console2.log("============== DEPLOYMENT SUMMARY =============="); + console2.log("================================================"); + console2.log("LiquidityPool Implementation:", liquidityPoolImpl); + console2.log("PriorityWithdrawalQueue Implementation:", priorityWithdrawalQueueImpl); + console2.log("PriorityWithdrawalQueue Proxy:", priorityWithdrawalQueueProxy); + console2.log("EtherFiRedemptionManager Implementation:", etherFiRedemptionManagerImpl); + console2.log(""); + console2.log("NEXT STEPS:"); + console2.log("1. Initialize PriorityWithdrawalQueue proxy"); + console2.log("2. Upgrade LiquidityPool proxy to new implementation"); + console2.log("3. Upgrade EtherFiRedemptionManager proxy to new implementation"); + console2.log("4. Grant necessary roles in RoleRegistry"); + } +} \ No newline at end of file diff --git a/script/upgrades/priority-queue/transactionsPriorityQueue.s.sol b/script/upgrades/priority-queue/transactionsPriorityQueue.s.sol new file mode 100644 index 000000000..83c603e67 --- /dev/null +++ b/script/upgrades/priority-queue/transactionsPriorityQueue.s.sol @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "../../utils/utils.sol"; +import "../../../src/EtherFiTimelock.sol"; +import "../../../src/EtherFiRedemptionManager.sol"; +import "../../../src/LiquidityPool.sol"; +import "../../../src/PriorityWithdrawalQueue.sol"; +import "../../../src/RoleRegistry.sol"; +import "@openzeppelin-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; +import "forge-std/Script.sol"; +import "forge-std/console2.sol"; + +/// @title PriorityQueueTransactions +/// @notice Generates timelock transactions for upgrading LiquidityPool and granting roles for PriorityWithdrawalQueue +/// @dev Run with: forge script script/upgrades/priority-queue/transactionsPriorityQueue.s.sol --fork-url $MAINNET_RPC_URL +contract PriorityQueueTransactions is Script, Utils { + //-------------------------------------------------------------------------------------- + //------------------------------- EXISTING CONTRACTS ----------------------------------- + //-------------------------------------------------------------------------------------- + EtherFiTimelock etherFiTimelock = EtherFiTimelock(payable(UPGRADE_TIMELOCK)); + RoleRegistry roleRegistryContract = RoleRegistry(ROLE_REGISTRY); + LiquidityPool liquidityPool = LiquidityPool(payable(LIQUIDITY_POOL)); + EtherFiRedemptionManager etherFiRedemptionManager = EtherFiRedemptionManager(payable(ETHERFI_REDEMPTION_MANAGER)); + + //-------------------------------------------------------------------------------------- + //------------------------------- NEW DEPLOYMENTS -------------------------------------- + //-------------------------------------------------------------------------------------- + + // TODO: Update these addresses with actual deployed addresses + address constant liquidityPoolImpl = 0x5598b8c76BA17253459e069041349704c28d33DF; + address constant priorityWithdrawalQueueProxy = 0x79Eb9c078fA5a5Bd1Ee8ba84937acd48AA5F90A8; + address constant priorityWithdrawalQueueImpl = 0xB149ce3957370066D7C03e5CA81A7997Fe00cAF6; + address constant etherFiRedemptionManagerImpl = 0x335E9Cf5A2b13621b66D01F8b889174AD75DE045; + //-------------------------------------------------------------------------------------- + //------------------------------- ROLES ------------------------------------------------ + //-------------------------------------------------------------------------------------- + + bytes32 public PRIORITY_WITHDRAWAL_QUEUE_ADMIN_ROLE; + bytes32 public PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE; + bytes32 public PRIORITY_WITHDRAWAL_QUEUE_REQUEST_MANAGER_ROLE; + + function run() public { + console2.log("================================================"); + console2.log("Running Priority Queue Transactions"); + console2.log("================================================"); + console2.log(""); + + // string memory forkUrl = vm.envString("MAINNET_RPC_URL"); + // vm.selectFork(vm.createFork(forkUrl)); + + // Get role hashes from the implementation + PRIORITY_WITHDRAWAL_QUEUE_ADMIN_ROLE = PriorityWithdrawalQueue(payable(priorityWithdrawalQueueImpl)).PRIORITY_WITHDRAWAL_QUEUE_ADMIN_ROLE(); + PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE = PriorityWithdrawalQueue(payable(priorityWithdrawalQueueImpl)).PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE(); + PRIORITY_WITHDRAWAL_QUEUE_REQUEST_MANAGER_ROLE = PriorityWithdrawalQueue(payable(priorityWithdrawalQueueImpl)).PRIORITY_WITHDRAWAL_QUEUE_REQUEST_MANAGER_ROLE(); + + executeUpgrade(); + forkTest(); + } + + function executeUpgrade() public { + console2.log("Generating Upgrade Transactions"); + console2.log("================================================"); + + address[] memory targets = new address[](5); + bytes[] memory data = new bytes[](targets.length); + uint256[] memory values = new uint256[](targets.length); // Default to 0 + + //-------------------------------------------------------------------------------------- + //------------------------------- CONTRACT UPGRADES ----------------------------------- + //-------------------------------------------------------------------------------------- + + // Upgrade LiquidityPool to new implementation with priorityWithdrawalQueue support + targets[0] = LIQUIDITY_POOL; + data[0] = abi.encodeWithSelector(UUPSUpgradeable.upgradeTo.selector, liquidityPoolImpl); + + // Upgrade EtherFiRedemptionManager to new implementation + targets[1] = ETHERFI_REDEMPTION_MANAGER; + data[1] = abi.encodeWithSelector(UUPSUpgradeable.upgradeTo.selector, etherFiRedemptionManagerImpl); + + //-------------------------------------------------------------------------------------- + //---------------------------------- Grant Roles --------------------------------------- + //-------------------------------------------------------------------------------------- + console2.log("PRIORITY_WITHDRAWAL_QUEUE_ADMIN_ROLE:", vm.toString(PRIORITY_WITHDRAWAL_QUEUE_ADMIN_ROLE)); + console2.log("PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE:", vm.toString(PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE)); + console2.log("PRIORITY_WITHDRAWAL_QUEUE_REQUEST_MANAGER_ROLE:", vm.toString(PRIORITY_WITHDRAWAL_QUEUE_REQUEST_MANAGER_ROLE)); + + // Grant PRIORITY_WITHDRAWAL_QUEUE_ADMIN_ROLE to ADMIN_EOA + targets[2] = ROLE_REGISTRY; + data[2] = _encodeRoleGrant( + PRIORITY_WITHDRAWAL_QUEUE_ADMIN_ROLE, + ETHERFI_OPERATING_ADMIN + ); + + // Grant PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE to ADMIN_EOA + targets[3] = ROLE_REGISTRY; + data[3] = _encodeRoleGrant( + PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE, + ETHERFI_OPERATING_ADMIN + ); + + // Grant PRIORITY_WITHDRAWAL_QUEUE_REQUEST_MANAGER_ROLE to ADMIN_EOA + targets[4] = ROLE_REGISTRY; + data[4] = _encodeRoleGrant( + PRIORITY_WITHDRAWAL_QUEUE_REQUEST_MANAGER_ROLE, + ADMIN_EOA + ); + + bytes32 timelockSalt = keccak256(abi.encode(targets, data, block.number)); + + // Generate schedule calldata + bytes memory scheduleCalldata = abi.encodeWithSelector( + etherFiTimelock.scheduleBatch.selector, + targets, + values, + data, + bytes32(0), // predecessor + timelockSalt, + MIN_DELAY_TIMELOCK // 72 hours + ); + + console2.log("================================================"); + console2.log("Timelock Address:", address(etherFiTimelock)); + console2.log("================================================"); + console2.log(""); + + console2.log("Schedule Tx (call from UPGRADE_ADMIN):"); + console2.logBytes(scheduleCalldata); + console2.log("================================================"); + console2.log(""); + + // Generate execute calldata + bytes memory executeCalldata = abi.encodeWithSelector( + etherFiTimelock.executeBatch.selector, + targets, + values, + data, + bytes32(0), // predecessor + timelockSalt + ); + console2.log("Execute Tx (after 72 hours):"); + console2.logBytes(executeCalldata); + console2.log("================================================"); + console2.log(""); + + // Log individual transactions for clarity + console2.log("Transaction Details:"); + console2.log("--------------------"); + console2.log("1. Upgrade LiquidityPool to:", liquidityPoolImpl); + console2.log("2. Grant PRIORITY_WITHDRAWAL_QUEUE_ADMIN_ROLE to:", ETHERFI_OPERATING_ADMIN); + console2.log("3. Grant PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE to:", ETHERFI_OPERATING_ADMIN); + console2.log("4. Grant PRIORITY_WITHDRAWAL_QUEUE_REQUEST_MANAGER_ROLE to:", ADMIN_EOA); + console2.log("================================================"); + console2.log(""); + + // Execute on fork for testing + console2.log("=== SCHEDULING BATCH ON FORK ==="); + vm.startPrank(ETHERFI_UPGRADE_ADMIN); + etherFiTimelock.scheduleBatch(targets, values, data, bytes32(0), timelockSalt, MIN_DELAY_TIMELOCK); + + vm.warp(block.timestamp + MIN_DELAY_TIMELOCK + 1); + etherFiTimelock.executeBatch(targets, values, data, bytes32(0), timelockSalt); + vm.stopPrank(); + + console2.log("Upgrade executed successfully on fork"); + console2.log("================================================"); + } + + function forkTest() public { + console2.log("Running Fork Tests"); + console2.log("================================================"); + + // Verify LiquidityPool upgrade + address impl = liquidityPool.getImplementation(); + require(impl == liquidityPoolImpl, "LiquidityPool implementation mismatch"); + console2.log("LiquidityPool implementation verified:", impl); + + // Verify priorityWithdrawalQueue is set correctly in LiquidityPool + address pwq = liquidityPool.priorityWithdrawalQueue(); + require(pwq == priorityWithdrawalQueueProxy, "PriorityWithdrawalQueue address mismatch"); + console2.log("PriorityWithdrawalQueue in LiquidityPool:", pwq); + + // Verify roles granted + require( + roleRegistryContract.hasRole(PRIORITY_WITHDRAWAL_QUEUE_ADMIN_ROLE, ETHERFI_OPERATING_ADMIN), + "ADMIN_EOA does not have PRIORITY_WITHDRAWAL_QUEUE_ADMIN_ROLE" + ); + console2.log("PRIORITY_WITHDRAWAL_QUEUE_ADMIN_ROLE granted to ADMIN_EOA"); + + require( + roleRegistryContract.hasRole(PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE, ETHERFI_OPERATING_ADMIN), + "ADMIN_EOA does not have PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE" + ); + console2.log("PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE granted to ADMIN_EOA"); + + require( + roleRegistryContract.hasRole(PRIORITY_WITHDRAWAL_QUEUE_REQUEST_MANAGER_ROLE, ADMIN_EOA), + "ADMIN_EOA does not have PRIORITY_WITHDRAWAL_QUEUE_REQUEST_MANAGER_ROLE" + ); + console2.log("PRIORITY_WITHDRAWAL_QUEUE_REQUEST_MANAGER_ROLE granted to ADMIN_EOA"); + + // Test PriorityWithdrawalQueue is accessible via proxy + PriorityWithdrawalQueue pwqContract = PriorityWithdrawalQueue(payable(priorityWithdrawalQueueProxy)); + require(address(pwqContract.liquidityPool()) == LIQUIDITY_POOL, "LiquidityPool reference mismatch in PriorityWithdrawalQueue"); + console2.log("PriorityWithdrawalQueue liquidityPool reference verified"); + + console2.log(""); + console2.log("All fork tests passed!"); + console2.log("================================================"); + } + + //-------------------------------------------------------------------------------------- + //------------------------------- HELPER FUNCTIONS ----------------------------------- + //-------------------------------------------------------------------------------------- + + function _encodeRoleGrant( + bytes32 role, + address account + ) internal pure returns (bytes memory) { + return abi.encodeWithSelector( + RoleRegistry.grantRole.selector, + role, + account + ); + } +} diff --git a/script/upgrades/reaudit-fixes/transactions-reaudit-fixes.s.sol b/script/upgrades/reaudit-fixes/transactions-reaudit-fixes.s.sol index a444f92fa..9ebff5992 100644 --- a/script/upgrades/reaudit-fixes/transactions-reaudit-fixes.s.sol +++ b/script/upgrades/reaudit-fixes/transactions-reaudit-fixes.s.sol @@ -82,7 +82,7 @@ contract ReauditFixesTransactions is Utils { console2.log(""); EtherFiNode newEtherFiNodeImplementation = new EtherFiNode(address(LIQUIDITY_POOL), address(ETHERFI_NODES_MANAGER), address(EIGENLAYER_POD_MANAGER), address(EIGENLAYER_DELEGATION_MANAGER), address(ROLE_REGISTRY)); - EtherFiRedemptionManager newEtherFiRedemptionManagerImplementation = new EtherFiRedemptionManager(address(LIQUIDITY_POOL), address(EETH), address(WEETH), address(TREASURY), address(ROLE_REGISTRY), address(ETHERFI_RESTAKER)); + EtherFiRedemptionManager newEtherFiRedemptionManagerImplementation = new EtherFiRedemptionManager(address(LIQUIDITY_POOL), address(EETH), address(WEETH), address(TREASURY), address(ROLE_REGISTRY), address(ETHERFI_RESTAKER), address(0x0)); EtherFiRestaker newEtherFiRestakerImplementation = new EtherFiRestaker(address(EIGENLAYER_REWARDS_COORDINATOR), address(ETHERFI_REDEMPTION_MANAGER)); EtherFiRewardsRouter newEtherFiRewardsRouterImplementation = new EtherFiRewardsRouter(address(LIQUIDITY_POOL), address(TREASURY), address(ROLE_REGISTRY)); Liquifier newLiquifierImplementation = new Liquifier(); diff --git a/script/utils/utils.sol b/script/utils/utils.sol index 723414fe5..d453a7d8f 100644 --- a/script/utils/utils.sol +++ b/script/utils/utils.sol @@ -273,6 +273,12 @@ contract Utils is Script, Deployed { return vm.toString(address(uint160(uint256(chunk)))); } else if (compare(t, "uint256")) { return vm.toString(uint256(chunk)); + } else if (compare(t, "uint32")) { + return vm.toString(uint32(uint256(chunk))); + } else if (compare(t, "uint64")) { + return vm.toString(uint64(uint256(chunk))); + } else if (compare(t, "uint128")) { + return vm.toString(uint128(uint256(chunk))); } else if (compare(t, "bool")) { return uint256(chunk) != 0 ? "true" : "false"; } else if (compare(t, "bytes32")) { diff --git a/script/validator-key-gen/verify.s.sol b/script/validator-key-gen/verify.s.sol index dfee4e3d5..437e618f4 100644 --- a/script/validator-key-gen/verify.s.sol +++ b/script/validator-key-gen/verify.s.sol @@ -75,12 +75,12 @@ contract VerifyValidatorKeyGen is Script { } function verifyBytecode() internal { - LiquidityPool newLiquidityPoolImplementation = new LiquidityPool(); + // LiquidityPool newLiquidityPoolImplementation = new LiquidityPool(address(0x0)); StakingManager newStakingManagerImplementation = new StakingManager(address(LIQUIDITY_POOL_PROXY), address(ETHERFI_NODES_MANAGER_PROXY), address(ETH_DEPOSIT_CONTRACT), address(AUCTION_MANAGER), address(ETHERFI_NODE_BEACON), address(ROLE_REGISTRY)); EtherFiNodesManager newEtherFiNodesManagerImplementation = new EtherFiNodesManager(address(STAKING_MANAGER_PROXY), address(ROLE_REGISTRY), address(RATE_LIMITER_PROXY)); EtherFiRestaker newEtherFiRestakerImplementation = new EtherFiRestaker(address(REWARDS_COORDINATOR), address(ETHERFI_REDEMPTION_MANAGER)); - contractCodeChecker.verifyContractByteCodeMatch(LIQUIDITY_POOL_IMPL, address(newLiquidityPoolImplementation)); + // contractCodeChecker.verifyContractByteCodeMatch(LIQUIDITY_POOL_IMPL, address(newLiquidityPoolImplementation)); contractCodeChecker.verifyContractByteCodeMatch(STAKING_MANAGER_IMPL, address(newStakingManagerImplementation)); contractCodeChecker.verifyContractByteCodeMatch(ETHERFI_NODES_MANAGER_IMPL, address(newEtherFiNodesManagerImplementation)); contractCodeChecker.verifyContractByteCodeMatch(ETHERFI_RESTAKER_IMPL, address(newEtherFiRestakerImplementation)); diff --git a/src/EtherFiRedemptionManager.sol b/src/EtherFiRedemptionManager.sol index 54fe65abe..c06c6339f 100644 --- a/src/EtherFiRedemptionManager.sol +++ b/src/EtherFiRedemptionManager.sol @@ -22,6 +22,7 @@ import "./EtherFiRestaker.sol"; import "lib/BucketLimiter.sol"; import "./RoleRegistry.sol"; +import "./interfaces/IPriorityWithdrawalQueue.sol"; /* The contract allows instant redemption of eETH and weETH tokens to ETH or stETH with an exit fee. @@ -53,6 +54,7 @@ contract EtherFiRedemptionManager is Initializable, PausableUpgradeable, Reentra ILiquidityPool public immutable liquidityPool; EtherFiRestaker public immutable etherFiRestaker; ILido public immutable lido; + IPriorityWithdrawalQueue public immutable priorityWithdrawalQueue; mapping(address => RedemptionInfo) public tokenToRedemptionInfo; @@ -66,7 +68,7 @@ contract EtherFiRedemptionManager is Initializable, PausableUpgradeable, Reentra receive() external payable {} /// @custom:oz-upgrades-unsafe-allow constructor - constructor(address _liquidityPool, address _eEth, address _weEth, address _treasury, address _roleRegistry, address _etherFiRestaker) { + constructor(address _liquidityPool, address _eEth, address _weEth, address _treasury, address _roleRegistry, address _etherFiRestaker, address _priorityWithdrawalQueue) { roleRegistry = RoleRegistry(_roleRegistry); treasury = _treasury; liquidityPool = ILiquidityPool(payable(_liquidityPool)); @@ -74,6 +76,7 @@ contract EtherFiRedemptionManager is Initializable, PausableUpgradeable, Reentra weEth = IWeETH(_weEth); etherFiRestaker = EtherFiRestaker(payable(_etherFiRestaker)); lido = etherFiRestaker.lido(); + priorityWithdrawalQueue = IPriorityWithdrawalQueue(_priorityWithdrawalQueue); _disableInitializers(); } @@ -242,7 +245,7 @@ contract EtherFiRedemptionManager is Initializable, PausableUpgradeable, Reentra function getInstantLiquidityAmount(address token) public view returns (uint256) { if(token == ETH_ADDRESS) { - return address(liquidityPool).balance - liquidityPool.ethAmountLockedForWithdrawal(); + return address(liquidityPool).balance - liquidityPool.ethAmountLockedForWithdrawal() - priorityWithdrawalQueue.ethAmountLockedForPriorityWithdrawal(); } else if (token == address(lido)) { return lido.balanceOf(address(etherFiRestaker)); } diff --git a/src/LiquidityPool.sol b/src/LiquidityPool.sol index 751c16a17..41cf3bfb7 100644 --- a/src/LiquidityPool.sol +++ b/src/LiquidityPool.sol @@ -68,6 +68,13 @@ contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, IL IRoleRegistry public roleRegistry; uint256 public validatorSizeWei; + + //-------------------------------------------------------------------------------------- + //------------------------------------- IMMUTABLES ---------------------------------- + //-------------------------------------------------------------------------------------- + + address public immutable priorityWithdrawalQueue; + //-------------------------------------------------------------------------------------- //------------------------------------- ROLES --------------------------------------- //-------------------------------------------------------------------------------------- @@ -115,7 +122,8 @@ contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, IL //-------------------------------------------------------------------------------------- /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { + constructor(address _priorityWithdrawalQueue) { + priorityWithdrawalQueue = _priorityWithdrawalQueue; _disableInitializers(); } @@ -203,7 +211,13 @@ contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, IL /// it returns the amount of shares burned function withdraw(address _recipient, uint256 _amount) external whenNotPaused returns (uint256) { uint256 share = sharesForWithdrawalAmount(_amount); - require(msg.sender == address(withdrawRequestNFT) || msg.sender == address(membershipManager) || msg.sender == address(etherFiRedemptionManager), "Incorrect Caller"); + require( + msg.sender == address(withdrawRequestNFT) || + msg.sender == address(membershipManager) || + msg.sender == address(etherFiRedemptionManager) || + msg.sender == priorityWithdrawalQueue, + "Incorrect Caller" + ); if (totalValueInLp < _amount || (msg.sender == address(withdrawRequestNFT) && ethAmountLockedForWithdrawal < _amount) || eETH.balanceOf(msg.sender) < _amount) revert InsufficientLiquidity(); if (_amount > type(uint128).max || _amount == 0 || share == 0) revert InvalidAmount(); @@ -473,7 +487,7 @@ contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, IL } function burnEEthShares(uint256 shares) external { - if (msg.sender != address(etherFiRedemptionManager) && msg.sender != address(withdrawRequestNFT)) revert IncorrectCaller(); + if (msg.sender != address(etherFiRedemptionManager) && msg.sender != address(withdrawRequestNFT) && msg.sender != priorityWithdrawalQueue) revert IncorrectCaller(); eETH.burnShares(msg.sender, shares); } diff --git a/src/PriorityWithdrawalQueue.sol b/src/PriorityWithdrawalQueue.sol new file mode 100644 index 000000000..c96c814e8 --- /dev/null +++ b/src/PriorityWithdrawalQueue.sol @@ -0,0 +1,624 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "@openzeppelin-upgradeable/contracts/proxy/utils/Initializable.sol"; +import "@openzeppelin-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; +import "@openzeppelin-upgradeable/contracts/access/OwnableUpgradeable.sol"; +import "@openzeppelin-upgradeable/contracts/security/ReentrancyGuardUpgradeable.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import "@openzeppelin/contracts/utils/math/Math.sol"; + +import "./interfaces/IPriorityWithdrawalQueue.sol"; +import "./interfaces/ILiquidityPool.sol"; +import "./interfaces/IeETH.sol"; +import "./interfaces/IRoleRegistry.sol"; + +/// @title PriorityWithdrawalQueue +/// @notice Manages priority withdrawals for whitelisted users +/// @dev Implements priority withdrawal queue pattern +contract PriorityWithdrawalQueue is + Initializable, + OwnableUpgradeable, + UUPSUpgradeable, + ReentrancyGuardUpgradeable, + IPriorityWithdrawalQueue +{ + using SafeERC20 for IERC20; + using EnumerableSet for EnumerableSet.Bytes32Set; + using Math for uint256; + + //-------------------------------------------------------------------------------------- + //--------------------------------- CONSTANTS ---------------------------------------- + //-------------------------------------------------------------------------------------- + + uint96 public constant MIN_AMOUNT = 0.01 ether; + uint256 private constant _BASIS_POINT_SCALE = 1e4; + + //-------------------------------------------------------------------------------------- + //--------------------------------- IMMUTABLES --------------------------------------- + //-------------------------------------------------------------------------------------- + + ILiquidityPool public immutable liquidityPool; + IeETH public immutable eETH; + IRoleRegistry public immutable roleRegistry; + address public immutable treasury; + uint32 public immutable MIN_DELAY; + + //-------------------------------------------------------------------------------------- + //--------------------------------- STATE-VARIABLES ---------------------------------- + //-------------------------------------------------------------------------------------- + + /// @notice EnumerableSet to store all active withdraw request IDs + EnumerableSet.Bytes32Set private _withdrawRequests; + + /// @notice Set of finalized request IDs (fulfilled and ready for claim) + EnumerableSet.Bytes32Set private _finalizedRequests; + + mapping(address => bool) public isWhitelisted; + + uint32 public nonce; + uint16 public shareRemainderSplitToTreasuryInBps; + bool public paused; + uint96 public totalRemainderShares; + uint128 public ethAmountLockedForPriorityWithdrawal; + + //-------------------------------------------------------------------------------------- + //------------------------------------- ROLES ---------------------------------------- + //-------------------------------------------------------------------------------------- + + bytes32 public constant PRIORITY_WITHDRAWAL_QUEUE_ADMIN_ROLE = keccak256("PRIORITY_WITHDRAWAL_QUEUE_ADMIN_ROLE"); + bytes32 public constant PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE = keccak256("PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE"); + bytes32 public constant PRIORITY_WITHDRAWAL_QUEUE_REQUEST_MANAGER_ROLE = keccak256("PRIORITY_WITHDRAWAL_QUEUE_REQUEST_MANAGER_ROLE"); + bytes32 public constant IMPLICIT_FEE_CLAIMER_ROLE = keccak256("IMPLICIT_FEE_CLAIMER_ROLE"); + + //-------------------------------------------------------------------------------------- + //------------------------------------- EVENTS --------------------------------------- + //-------------------------------------------------------------------------------------- + + event Paused(address account); + event Unpaused(address account); + event WithdrawRequestCreated( + bytes32 indexed requestId, + address indexed user, + uint96 amountOfEEth, + uint96 shareOfEEth, + uint96 minAmountOut, + uint32 nonce, + uint32 creationTime + ); + event WithdrawRequestCancelled(bytes32 indexed requestId, address indexed user, uint96 amountOfEEth, uint96 sharesOfEEth, uint32 nonce, uint32 timestamp); + event WithdrawRequestFinalized(bytes32 indexed requestId, address indexed user, uint96 amountOfEEth, uint96 sharesOfEEth, uint32 nonce, uint32 timestamp); + event WithdrawRequestClaimed(bytes32 indexed requestId, address indexed user, uint96 amountOfEEth, uint96 sharesOfEEth, uint32 nonce, uint32 timestamp); + event WithdrawRequestInvalidated(bytes32 indexed requestId, uint96 amountOfEEth, uint96 sharesOfEEth, uint32 nonce, uint32 timestamp); + event WhitelistUpdated(address indexed user, bool status); + event RemainderHandled(uint96 amountToTreasury, uint96 sharesOfEEthToBurn); + event ShareRemainderSplitUpdated(uint16 newSplitInBps); + + //-------------------------------------------------------------------------------------- + //------------------------------------- ERRORS --------------------------------------- + //-------------------------------------------------------------------------------------- + + error NotWhitelisted(); + error InvalidAmount(); + error RequestNotFound(); + error RequestNotFinalized(); + error RequestAlreadyFinalized(); + error NotRequestOwner(); + error IncorrectRole(); + error ContractPaused(); + error ContractNotPaused(); + error NotMatured(); + error UnexpectedBalanceChange(); + error Keccak256Collision(); + error PermitFailedAndAllowanceTooLow(); + error ArrayLengthMismatch(); + error AddressZero(); + error BadInput(); + error InvalidBurnedSharesAmount(); + error InvalidEEthSharesAfterRemainderHandling(); + error InsufficientOutputAmount(); + + //-------------------------------------------------------------------------------------- + //----------------------------------- MODIFIERS -------------------------------------- + //-------------------------------------------------------------------------------------- + + modifier whenNotPaused() { + if (paused) revert ContractPaused(); + _; + } + + modifier onlyWhitelisted() { + if (!isWhitelisted[msg.sender]) revert NotWhitelisted(); + _; + } + + modifier onlyAdmin() { + if (!roleRegistry.hasRole(PRIORITY_WITHDRAWAL_QUEUE_ADMIN_ROLE, msg.sender)) revert IncorrectRole(); + _; + } + + modifier onlyRequestManager() { + if (!roleRegistry.hasRole(PRIORITY_WITHDRAWAL_QUEUE_REQUEST_MANAGER_ROLE, msg.sender)) revert IncorrectRole(); + _; + } + + modifier onlyRequestUser(address requestUser) { + if (requestUser != msg.sender) revert NotRequestOwner(); + _; + } + + //-------------------------------------------------------------------------------------- + //---------------------------- STATE-CHANGING FUNCTIONS ------------------------------ + //-------------------------------------------------------------------------------------- + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(address _liquidityPool, address _eETH, address _roleRegistry, address _treasury, uint32 _minDelay) { + if (_liquidityPool == address(0) || _eETH == address(0) || _roleRegistry == address(0) || _treasury == address(0)) { + revert AddressZero(); + } + + liquidityPool = ILiquidityPool(_liquidityPool); + eETH = IeETH(_eETH); + roleRegistry = IRoleRegistry(_roleRegistry); + treasury = _treasury; + MIN_DELAY = _minDelay; + + _disableInitializers(); + } + + function initialize() external initializer { + __Ownable_init(); + __UUPSUpgradeable_init(); + __ReentrancyGuard_init(); + + nonce = 1; + shareRemainderSplitToTreasuryInBps = 10000; // 100% + } + + //-------------------------------------------------------------------------------------- + //------------------------------ USER FUNCTIONS -------------------------------------- + //-------------------------------------------------------------------------------------- + + /// @notice Request a withdrawal of eETH + /// @param amountOfEEth Amount of eETH to withdraw + /// @param minAmountOut Minimum ETH output amount (slippage protection for dynamic fees) + /// @return requestId The hash-based ID of the created withdrawal request + function requestWithdraw( + uint96 amountOfEEth, + uint96 minAmountOut + ) external whenNotPaused onlyWhitelisted nonReentrant returns (bytes32 requestId) { + if (amountOfEEth < MIN_AMOUNT) revert InvalidAmount(); + (uint256 lpEthBefore, uint256 queueEEthSharesBefore) = _snapshotBalances(); + + IERC20(address(eETH)).safeTransferFrom(msg.sender, address(this), amountOfEEth); + + (requestId,) = _queueWithdrawRequest(msg.sender, amountOfEEth, minAmountOut); + _verifyRequestPostConditions(lpEthBefore, queueEEthSharesBefore, amountOfEEth); + } + + function requestWithdrawWithPermit( + uint96 amountOfEEth, + uint96 minAmountOut, + PermitInput calldata permit + ) external whenNotPaused onlyWhitelisted nonReentrant returns (bytes32 requestId) { + if (amountOfEEth < MIN_AMOUNT) revert InvalidAmount(); + (uint256 lpEthBefore, uint256 queueEEthSharesBefore) = _snapshotBalances(); + + try eETH.permit(msg.sender, address(this), permit.value, permit.deadline, permit.v, permit.r, permit.s) {} catch { + revert PermitFailedAndAllowanceTooLow(); + } + + IERC20(address(eETH)).safeTransferFrom(msg.sender, address(this), amountOfEEth); + + (requestId,) = _queueWithdrawRequest(msg.sender, amountOfEEth, minAmountOut); + + _verifyRequestPostConditions(lpEthBefore, queueEEthSharesBefore, amountOfEEth); + } + + /// @notice Cancel a pending withdrawal request + /// @param request The withdrawal request to cancel + /// @return requestId The cancelled request ID + function cancelWithdraw( + WithdrawRequest calldata request + ) external whenNotPaused onlyRequestUser(request.user) nonReentrant returns (bytes32 requestId) { + if (request.creationTime + MIN_DELAY > block.timestamp) revert NotMatured(); + (uint256 lpEthBefore, uint256 queueEEthSharesBefore) = _snapshotBalances(); + uint256 userEEthSharesBefore = eETH.shares(request.user); + + requestId = _cancelWithdrawRequest(request); + + _verifyCancelPostConditions(lpEthBefore, queueEEthSharesBefore, userEEthSharesBefore, request.user); + } + + /// @notice Claim ETH for a finalized withdrawal request + /// @dev Anyone can call this to claim on behalf of the user. Funds are sent to request.user. + /// @param request The withdrawal request to claim + function claimWithdraw(WithdrawRequest calldata request) external whenNotPaused nonReentrant { + if (request.creationTime + MIN_DELAY > block.timestamp) revert NotMatured(); + + (uint256 lpEthBefore, uint256 queueEEthSharesBefore) = _snapshotBalances(); + uint256 userEthBefore = request.user.balance; + + _claimWithdraw(request); + + _verifyClaimPostConditions(lpEthBefore, queueEEthSharesBefore, userEthBefore, request.user); + } + + /// @notice Batch claim multiple withdrawal requests + /// @dev Anyone can call this to claim on behalf of users. Funds are sent to each request.user. + /// @param requests Array of withdrawal requests to claim + function batchClaimWithdraw(WithdrawRequest[] calldata requests) external whenNotPaused nonReentrant { + (uint256 lpEthBefore, uint256 queueEEthSharesBefore) = _snapshotBalances(); + + for (uint256 i = 0; i < requests.length; ++i) { + if (requests[i].creationTime + MIN_DELAY > block.timestamp) revert NotMatured(); + _claimWithdraw(requests[i]); + } + + // Post-hook balance checks (at least one claim should have changed balances) + if (requests.length > 0) { + _verifyBatchClaimPostConditions(lpEthBefore, queueEEthSharesBefore); + } + } + + //-------------------------------------------------------------------------------------- + //---------------------------- REQUEST MANAGER FUNCTIONS ------------------------------ + //-------------------------------------------------------------------------------------- + + /// @notice Request manager finalizes withdrawal requests after maturity + /// @dev Checks maturity and deadline, marks requests as finalized + /// @param requests Array of requests to finalize + function fulfillRequests(WithdrawRequest[] calldata requests) external onlyRequestManager whenNotPaused { + uint256 totalSharesToFinalize = 0; + + for (uint256 i = 0; i < requests.length; ++i) { + WithdrawRequest calldata request = requests[i]; + bytes32 requestId = keccak256(abi.encode(request)); + + if (_finalizedRequests.contains(requestId)) revert RequestAlreadyFinalized(); + if (!_withdrawRequests.contains(requestId)) revert RequestNotFound(); + + uint256 earliestFulfillTime = request.creationTime + MIN_DELAY; + if (block.timestamp < earliestFulfillTime) revert NotMatured(); + + _withdrawRequests.remove(requestId); + _finalizedRequests.add(requestId); + totalSharesToFinalize += request.shareOfEEth; + + emit WithdrawRequestFinalized(requestId, request.user, request.amountOfEEth, request.shareOfEEth, request.nonce, uint32(block.timestamp)); + } + + uint256 totalAmountToLock = liquidityPool.amountForShare(totalSharesToFinalize); + ethAmountLockedForPriorityWithdrawal += uint128(totalAmountToLock); + } + + //-------------------------------------------------------------------------------------- + //----------------------------------- ADMIN FUNCTIONS -------------------------------- + //-------------------------------------------------------------------------------------- + + function addToWhitelist(address user) external { + if (!roleRegistry.hasRole(PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE, msg.sender)) revert IncorrectRole(); + if (user == address(0)) revert AddressZero(); + isWhitelisted[user] = true; + emit WhitelistUpdated(user, true); + } + + function removeFromWhitelist(address user) external { + if (!roleRegistry.hasRole(PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE, msg.sender)) revert IncorrectRole(); + isWhitelisted[user] = false; + emit WhitelistUpdated(user, false); + } + + function batchUpdateWhitelist(address[] calldata users, bool[] calldata statuses) external { + if (!roleRegistry.hasRole(PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE, msg.sender)) revert IncorrectRole(); + if (users.length != statuses.length) revert ArrayLengthMismatch(); + for (uint256 i = 0; i < users.length; ++i) { + if (users[i] == address(0)) revert AddressZero(); + isWhitelisted[users[i]] = statuses[i]; + emit WhitelistUpdated(users[i], statuses[i]); + } + } + + /// @notice Invalidate a withdrawal request (prevents finalization) + /// @param requests Array of requests to invalidate + /// @return invalidatedRequestIds Array of request IDs that were invalidated + function invalidateRequests(WithdrawRequest[] calldata requests) external onlyRequestManager returns (bytes32[] memory invalidatedRequestIds) { + invalidatedRequestIds = new bytes32[](requests.length); + for (uint256 i = 0; i < requests.length; ++i) { + bytes32 requestId = keccak256(abi.encode(requests[i])); + // Check both sets since pending requests are in _withdrawRequests, finalized in _finalizedRequests + if (!(_withdrawRequests.contains(requestId) || _finalizedRequests.contains(requestId))) revert RequestNotFound(); + + _cancelWithdrawRequest(requests[i]); + invalidatedRequestIds[i] = requestId; + emit WithdrawRequestInvalidated(requestId, requests[i].amountOfEEth, requests[i].shareOfEEth, requests[i].nonce, uint32(block.timestamp)); + } + } + + /// @notice Handle remainder shares (from rounding differences) + /// @dev Splits the remainder into two parts: + /// - Treasury: gets a percentage of the remainder based on shareRemainderSplitToTreasuryInBps + /// - Burn: the rest of the remainder is burned + /// @param eEthAmount Amount of eETH remainder to handle + function handleRemainder(uint256 eEthAmount) external { + if (!roleRegistry.hasRole(IMPLICIT_FEE_CLAIMER_ROLE, msg.sender)) revert IncorrectRole(); + if (eEthAmount == 0) revert BadInput(); + if (eEthAmount > liquidityPool.amountForShare(totalRemainderShares)) revert BadInput(); + + uint256 beforeEEthShares = eETH.shares(address(this)); + + uint256 eEthAmountToTreasury = eEthAmount.mulDiv(shareRemainderSplitToTreasuryInBps, _BASIS_POINT_SCALE); + uint256 eEthAmountToBurn = eEthAmount - eEthAmountToTreasury; + uint256 eEthSharesToBurn = liquidityPool.sharesForAmount(eEthAmountToBurn); + uint256 eEthSharesMoved = eEthSharesToBurn + liquidityPool.sharesForAmount(eEthAmountToTreasury); + + totalRemainderShares -= uint96(eEthSharesMoved); + + if (eEthAmountToTreasury > 0) IERC20(address(eETH)).safeTransfer(treasury, eEthAmountToTreasury); + if (eEthSharesToBurn > 0) liquidityPool.burnEEthShares(eEthSharesToBurn); + + if (beforeEEthShares - eEthSharesMoved != eETH.shares(address(this))) revert InvalidEEthSharesAfterRemainderHandling(); + + emit RemainderHandled(uint96(eEthAmountToTreasury), uint96(liquidityPool.amountForShare(eEthSharesToBurn))); + } + + function updateShareRemainderSplitToTreasury(uint16 _shareRemainderSplitToTreasuryInBps) external onlyAdmin { + if (_shareRemainderSplitToTreasuryInBps > _BASIS_POINT_SCALE) revert BadInput(); + shareRemainderSplitToTreasuryInBps = _shareRemainderSplitToTreasuryInBps; + emit ShareRemainderSplitUpdated(_shareRemainderSplitToTreasuryInBps); + } + + function pauseContract() external { + if (!roleRegistry.hasRole(roleRegistry.PROTOCOL_PAUSER(), msg.sender)) revert IncorrectRole(); + if (paused) revert ContractPaused(); + paused = true; + emit Paused(msg.sender); + } + + function unPauseContract() external { + if (!roleRegistry.hasRole(roleRegistry.PROTOCOL_UNPAUSER(), msg.sender)) revert IncorrectRole(); + if (!paused) revert ContractNotPaused(); + paused = false; + emit Unpaused(msg.sender); + } + + //-------------------------------------------------------------------------------------- + //------------------------------ INTERNAL FUNCTIONS ---------------------------------- + //-------------------------------------------------------------------------------------- + + /// @dev Snapshot balances before state changes for post-hook verification + /// @return lpEthBefore ETH balance of LiquidityPool + /// @return queueEEthSharesBefore eETH shares held by this contract + function _snapshotBalances() internal view returns (uint256 lpEthBefore, uint256 queueEEthSharesBefore) { + lpEthBefore = address(liquidityPool).balance; + queueEEthSharesBefore = eETH.shares(address(this)); + } + + /// @dev Verify post-conditions after a request is created + /// @param lpEthBefore ETH balance of LiquidityPool before operation + /// @param queueEEthSharesBefore eETH shares held by queue before operation + /// @param amountOfEEth Amount of eETH that was transferred + function _verifyRequestPostConditions( + uint256 lpEthBefore, + uint256 queueEEthSharesBefore, + uint96 amountOfEEth + ) internal view { + uint256 expectedSharesReceived = liquidityPool.sharesForAmount(amountOfEEth); + if (eETH.shares(address(this)) != queueEEthSharesBefore + expectedSharesReceived) revert UnexpectedBalanceChange(); + if (address(liquidityPool).balance != lpEthBefore) revert UnexpectedBalanceChange(); + } + + /// @dev Verify post-conditions after a cancel operation + /// @param lpEthBefore ETH balance of LiquidityPool before operation + /// @param queueEEthSharesBefore eETH shares held by queue before operation + /// @param userEEthSharesBefore eETH shares held by user before operation + /// @param user The user who cancelled + function _verifyCancelPostConditions( + uint256 lpEthBefore, + uint256 queueEEthSharesBefore, + uint256 userEEthSharesBefore, + address user + ) internal view { + if (address(liquidityPool).balance != lpEthBefore) revert UnexpectedBalanceChange(); + if (eETH.shares(address(this)) >= queueEEthSharesBefore) revert UnexpectedBalanceChange(); + if (eETH.shares(user) <= userEEthSharesBefore) revert UnexpectedBalanceChange(); + } + + /// @dev Verify post-conditions after a claim operation + /// @param lpEthBefore ETH balance of LiquidityPool before operation + /// @param queueEEthSharesBefore eETH shares held by queue before operation + /// @param userEthBefore ETH balance of user before operation + /// @param user The user who claimed + function _verifyClaimPostConditions( + uint256 lpEthBefore, + uint256 queueEEthSharesBefore, + uint256 userEthBefore, + address user + ) internal view { + if (address(liquidityPool).balance >= lpEthBefore) revert UnexpectedBalanceChange(); + if (eETH.shares(address(this)) >= queueEEthSharesBefore) revert UnexpectedBalanceChange(); + if (user.balance <= userEthBefore) revert UnexpectedBalanceChange(); + } + + /// @dev Verify post-conditions after a batch claim operation + /// @param lpEthBefore ETH balance of LiquidityPool before operation + /// @param queueEEthSharesBefore eETH shares held by queue before operation + function _verifyBatchClaimPostConditions( + uint256 lpEthBefore, + uint256 queueEEthSharesBefore + ) internal view { + if (address(liquidityPool).balance >= lpEthBefore) revert UnexpectedBalanceChange(); + if (eETH.shares(address(this)) >= queueEEthSharesBefore) revert UnexpectedBalanceChange(); + } + + function _queueWithdrawRequest( + address user, + uint96 amountOfEEth, + uint96 minAmountOut + ) internal returns (bytes32 requestId, WithdrawRequest memory req) { + uint32 requestNonce = nonce++; + + uint96 shareOfEEth = uint96(liquidityPool.sharesForAmount(amountOfEEth)); + if (shareOfEEth == 0) revert InvalidAmount(); + + uint32 timeNow = uint32(block.timestamp); + + req = WithdrawRequest({ + user: user, + amountOfEEth: amountOfEEth, + shareOfEEth: shareOfEEth, + minAmountOut: minAmountOut, + nonce: requestNonce, + creationTime: timeNow + }); + + requestId = keccak256(abi.encode(req)); + + bool addedToSet = _withdrawRequests.add(requestId); + if (!addedToSet) revert Keccak256Collision(); + + emit WithdrawRequestCreated( + requestId, + user, + amountOfEEth, + shareOfEEth, + minAmountOut, + requestNonce, + timeNow + ); + } + + function _dequeueWithdrawRequest(WithdrawRequest calldata request) internal returns (bytes32 requestId) { + requestId = keccak256(abi.encode(request)); + + bool removedFromFinalized = _finalizedRequests.remove(requestId); + if (removedFromFinalized) return requestId; + + bool removedFromPending = _withdrawRequests.remove(requestId); + if (!removedFromPending) revert RequestNotFound(); + } + + function _cancelWithdrawRequest(WithdrawRequest calldata request) internal returns (bytes32 requestId) { + requestId = keccak256(abi.encode(request)); + + bool wasFinalized = _finalizedRequests.contains(requestId); + + _dequeueWithdrawRequest(request); + + if (wasFinalized) { + uint256 amountForShares = liquidityPool.amountForShare(request.shareOfEEth); + uint256 amountToUnlock = request.amountOfEEth < amountForShares + ? request.amountOfEEth + : amountForShares; + ethAmountLockedForPriorityWithdrawal -= uint128(amountToUnlock); + } + + IERC20(address(eETH)).safeTransfer(request.user, request.amountOfEEth); + + emit WithdrawRequestCancelled(requestId, request.user, request.amountOfEEth, request.shareOfEEth, request.nonce, uint32(block.timestamp)); + } + + function _claimWithdraw(WithdrawRequest calldata request) internal { + bytes32 requestId = keccak256(abi.encode(request)); + + if (!_finalizedRequests.contains(requestId)) revert RequestNotFinalized(); + + uint256 amountForShares = liquidityPool.amountForShare(request.shareOfEEth); + uint256 amountToWithdraw = request.amountOfEEth < amountForShares + ? request.amountOfEEth + : amountForShares; + + if (amountToWithdraw < request.minAmountOut) revert InsufficientOutputAmount(); + + uint256 sharesToBurn = liquidityPool.sharesForWithdrawalAmount(amountToWithdraw); + + _finalizedRequests.remove(requestId); + + uint256 remainder = request.shareOfEEth > sharesToBurn + ? request.shareOfEEth - sharesToBurn + : 0; + totalRemainderShares += uint96(remainder); + + ethAmountLockedForPriorityWithdrawal -= uint128(amountToWithdraw); + + uint256 burnedShares = liquidityPool.withdraw(request.user, amountToWithdraw); + if (burnedShares != sharesToBurn) revert InvalidBurnedSharesAmount(); + + emit WithdrawRequestClaimed(requestId, request.user, uint96(amountToWithdraw), uint96(sharesToBurn), request.nonce, uint32(block.timestamp)); + } + + function _authorizeUpgrade(address) internal override { + roleRegistry.onlyProtocolUpgrader(msg.sender); + } + + //-------------------------------------------------------------------------------------- + //------------------------------------ GETTERS --------------------------------------- + //-------------------------------------------------------------------------------------- + + function generateWithdrawRequestId( + address _user, + uint96 _amountOfEEth, + uint96 _shareOfEEth, + uint96 _minAmountOut, + uint32 _nonce, + uint32 _creationTime + ) public pure returns (bytes32 requestId) { + WithdrawRequest memory req = WithdrawRequest({ + user: _user, + amountOfEEth: _amountOfEEth, + shareOfEEth: _shareOfEEth, + minAmountOut: _minAmountOut, + nonce: _nonce, + creationTime: _creationTime + }); + requestId = keccak256(abi.encode(req)); + } + + function getRequestId(WithdrawRequest calldata request) external pure returns (bytes32) { + return generateWithdrawRequestId( + request.user, + request.amountOfEEth, + request.shareOfEEth, + request.minAmountOut, + request.nonce, + request.creationTime + ); + } + + function getRequestIds() external view returns (bytes32[] memory) { + return _withdrawRequests.values(); + } + + function getFinalizedRequestIds() external view returns (bytes32[] memory) { + return _finalizedRequests.values(); + } + + function requestExists(bytes32 requestId) external view returns (bool) { + return _withdrawRequests.contains(requestId) || _finalizedRequests.contains(requestId); + } + + function isFinalized(bytes32 requestId) external view returns (bool) { + return _finalizedRequests.contains(requestId); + } + + function getClaimableAmount(WithdrawRequest calldata request) external view returns (uint256) { + bytes32 requestId = keccak256(abi.encode(request)); + if (!_finalizedRequests.contains(requestId)) revert RequestNotFinalized(); + + uint256 amountForShares = liquidityPool.amountForShare(request.shareOfEEth); + return request.amountOfEEth < amountForShares ? request.amountOfEEth : amountForShares; + } + + function totalActiveRequests() external view returns (uint256) { + return _withdrawRequests.length(); + } + + function getRemainderAmount() external view returns (uint256) { + return liquidityPool.amountForShare(totalRemainderShares); + } + + function getImplementation() external view returns (address) { + return _getImplementation(); + } +} diff --git a/src/interfaces/IPriorityWithdrawalQueue.sol b/src/interfaces/IPriorityWithdrawalQueue.sol new file mode 100644 index 000000000..19255a523 --- /dev/null +++ b/src/interfaces/IPriorityWithdrawalQueue.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +interface IPriorityWithdrawalQueue { + /// @notice Withdrawal request struct stored as hash in EnumerableSet + /// @param user The user who created the request + /// @param amountOfEEth Original eETH amount requested + /// @param shareOfEEth eETH shares at time of request + /// @param minAmountOut Minimum ETH output amount (slippage protection for dynamic fees) + /// @param nonce Unique nonce to prevent hash collisions + /// @param creationTime Timestamp when request was created + struct WithdrawRequest { + address user; // 20 bytes + uint96 amountOfEEth; // 12 bytes | Slot 1 = 32 bytes + uint96 shareOfEEth; // 12 bytes + uint96 minAmountOut; // 12 bytes + uint32 nonce; // 4 bytes + uint32 creationTime; // 4 bytes | Slot 2 = 32 bytes + } + + struct PermitInput { + uint256 value; + uint256 deadline; + uint8 v; + bytes32 r; + bytes32 s; + } + + // User functions + function requestWithdraw(uint96 amountOfEEth, uint96 minAmountOut) external returns (bytes32 requestId); + function requestWithdrawWithPermit(uint96 amountOfEEth, uint96 minAmountOut, PermitInput calldata permit) external returns (bytes32 requestId); + function cancelWithdraw(WithdrawRequest calldata request) external returns (bytes32 requestId); + function claimWithdraw(WithdrawRequest calldata request) external; + function batchClaimWithdraw(WithdrawRequest[] calldata requests) external; + + // View functions + function getRequestId(WithdrawRequest calldata request) external pure returns (bytes32); + function getRequestIds() external view returns (bytes32[] memory); + function getClaimableAmount(WithdrawRequest calldata request) external view returns (uint256); + function isWhitelisted(address user) external view returns (bool); + function nonce() external view returns (uint32); + function shareRemainderSplitToTreasuryInBps() external view returns (uint16); + function ethAmountLockedForPriorityWithdrawal() external view returns (uint128); + + // Constants + function MIN_DELAY() external view returns (uint32); + function MIN_AMOUNT() external view returns (uint96); + + // Oracle/Solver functions + function fulfillRequests(WithdrawRequest[] calldata requests) external; + + // Admin functions + function addToWhitelist(address user) external; + function removeFromWhitelist(address user) external; + function batchUpdateWhitelist(address[] calldata users, bool[] calldata statuses) external; + function invalidateRequests(WithdrawRequest[] calldata requests) external returns(bytes32[] memory); + function updateShareRemainderSplitToTreasury(uint16 _shareRemainderSplitToTreasuryInBps) external; + function handleRemainder(uint256 eEthAmount) external; + function pauseContract() external; + function unPauseContract() external; + + // Immutables + function treasury() external view returns (address); +} diff --git a/test/LiquidityPool.t.sol b/test/LiquidityPool.t.sol index 3cf7d7453..acb1dfafc 100644 --- a/test/LiquidityPool.t.sol +++ b/test/LiquidityPool.t.sol @@ -213,7 +213,7 @@ contract LiquidityPoolTest is TestSetup { } function test_StakingManagerFailsNotInitializedToken() public { - LiquidityPool liquidityPoolNoToken = new LiquidityPool(); + LiquidityPool liquidityPoolNoToken = new LiquidityPool(address(0x0)); vm.startPrank(alice); vm.deal(alice, 3 ether); @@ -763,7 +763,7 @@ contract LiquidityPoolTest is TestSetup { } function test_Upgrade2_49_onlyRoleRegistryOwnerCanUpgrade() public { - liquidityPool = address(new LiquidityPool()); + liquidityPool = address(new LiquidityPool(address(0x0))); vm.expectRevert(RoleRegistry.OnlyProtocolUpgrader.selector); vm.prank(address(100)); liquidityPoolInstance.upgradeTo(liquidityPool); diff --git a/test/PriorityWithdrawalQueue.t.sol b/test/PriorityWithdrawalQueue.t.sol new file mode 100644 index 000000000..380eef753 --- /dev/null +++ b/test/PriorityWithdrawalQueue.t.sol @@ -0,0 +1,1087 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "./TestSetup.sol"; +import "forge-std/console2.sol"; + +import "../src/PriorityWithdrawalQueue.sol"; +import "../src/interfaces/IPriorityWithdrawalQueue.sol"; + +contract PriorityWithdrawalQueueTest is TestSetup { + PriorityWithdrawalQueue public priorityQueue; + PriorityWithdrawalQueue public priorityQueueImpl; + + address public requestManager; + address public vipUser; + address public regularUser; + address public treasury; + + bytes32 public constant PRIORITY_WITHDRAWAL_QUEUE_ADMIN_ROLE = keccak256("PRIORITY_WITHDRAWAL_QUEUE_ADMIN_ROLE"); + bytes32 public constant PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE = keccak256("PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE"); + bytes32 public constant PRIORITY_WITHDRAWAL_QUEUE_REQUEST_MANAGER_ROLE = keccak256("PRIORITY_WITHDRAWAL_QUEUE_REQUEST_MANAGER_ROLE"); + bytes32 public constant IMPLICIT_FEE_CLAIMER_ROLE = keccak256("IMPLICIT_FEE_CLAIMER_ROLE"); + + function setUp() public { + // Initialize mainnet fork + initializeRealisticFork(MAINNET_FORK); + + // Setup actors + requestManager = makeAddr("requestManager"); + vipUser = makeAddr("vipUser"); + regularUser = makeAddr("regularUser"); + treasury = makeAddr("treasury"); + + // Deploy PriorityWithdrawalQueue with constructor args + vm.startPrank(owner); + priorityQueueImpl = new PriorityWithdrawalQueue( + address(liquidityPoolInstance), + address(eETHInstance), + address(roleRegistryInstance), + treasury, + 1 hours + ); + UUPSProxy proxy = new UUPSProxy( + address(priorityQueueImpl), + abi.encodeWithSelector(PriorityWithdrawalQueue.initialize.selector) + ); + priorityQueue = PriorityWithdrawalQueue(address(proxy)); + vm.stopPrank(); + + // Upgrade LiquidityPool to latest version (needed for setPriorityWithdrawalQueue) + vm.startPrank(owner); + LiquidityPool newLpImpl = new LiquidityPool(address(priorityQueue)); + liquidityPoolInstance.upgradeTo(address(newLpImpl)); + + // Grant roles + roleRegistryInstance.grantRole(PRIORITY_WITHDRAWAL_QUEUE_ADMIN_ROLE, alice); + roleRegistryInstance.grantRole(PRIORITY_WITHDRAWAL_QUEUE_WHITELIST_MANAGER_ROLE, alice); + roleRegistryInstance.grantRole(PRIORITY_WITHDRAWAL_QUEUE_REQUEST_MANAGER_ROLE, requestManager); + roleRegistryInstance.grantRole(IMPLICIT_FEE_CLAIMER_ROLE, alice); + roleRegistryInstance.grantRole(roleRegistryInstance.PROTOCOL_PAUSER(), alice); + roleRegistryInstance.grantRole(roleRegistryInstance.PROTOCOL_UNPAUSER(), alice); + roleRegistryInstance.grantRole(liquidityPoolInstance.LIQUIDITY_POOL_ADMIN_ROLE(), owner); + + // Configure LiquidityPool to use PriorityWithdrawalQueue (owner has LP admin role now) + vm.stopPrank(); + + // Whitelist the VIP user + vm.prank(alice); + priorityQueue.addToWhitelist(vipUser); + + // Give VIP user some ETH and deposit to get eETH + vm.deal(vipUser, 100 ether); + vm.startPrank(vipUser); + liquidityPoolInstance.deposit{value: 50 ether}(); + vm.stopPrank(); + } + + //-------------------------------------------------------------------------------------- + //------------------------------ HELPER FUNCTIONS ------------------------------------ + //-------------------------------------------------------------------------------------- + + /// @dev Helper to create a withdrawal request and return both the requestId and request struct + /// @notice Automatically rolls to the next block to allow cancel/claim operations + function _createWithdrawRequest(address user, uint96 amount) + internal + returns (bytes32 requestId, IPriorityWithdrawalQueue.WithdrawRequest memory request) + { + return _createWithdrawRequestWithMinOut(user, amount, 0); + } + + /// @dev Helper to create a withdrawal request with custom minAmountOut + function _createWithdrawRequestWithMinOut(address user, uint96 amount, uint96 minAmountOut) + internal + returns (bytes32 requestId, IPriorityWithdrawalQueue.WithdrawRequest memory request) + { + uint32 nonceBefore = priorityQueue.nonce(); + uint96 shareAmount = uint96(liquidityPoolInstance.sharesForAmount(amount)); + uint32 timestamp = uint32(block.timestamp); + + vm.startPrank(user); + eETHInstance.approve(address(priorityQueue), amount); + requestId = priorityQueue.requestWithdraw(amount, minAmountOut); + vm.stopPrank(); + + // Reconstruct the request struct + request = IPriorityWithdrawalQueue.WithdrawRequest({ + user: user, + amountOfEEth: amount, + shareOfEEth: shareAmount, + minAmountOut: minAmountOut, + nonce: uint32(nonceBefore), + creationTime: timestamp + }); + + // Warp time past MIN_DELAY (1 hour) to allow fulfill/cancel/claim operations + vm.warp(block.timestamp + 1 hours + 1); + vm.roll(block.number + 1); + } + + //-------------------------------------------------------------------------------------- + //------------------------------ INITIALIZATION TESTS -------------------------------- + //-------------------------------------------------------------------------------------- + + function test_initialization() public view { + // Verify immutables + assertEq(address(priorityQueue.liquidityPool()), address(liquidityPoolInstance)); + assertEq(address(priorityQueue.eETH()), address(eETHInstance)); + assertEq(address(priorityQueue.roleRegistry()), address(roleRegistryInstance)); + assertEq(priorityQueue.treasury(), treasury); + + // Verify initial state + assertEq(priorityQueue.nonce(), 1); + assertFalse(priorityQueue.paused()); + assertEq(priorityQueue.totalRemainderShares(), 0); + assertEq(priorityQueue.shareRemainderSplitToTreasuryInBps(), 10000); + + // Verify constants + assertEq(priorityQueue.MIN_DELAY(), 1 hours); + assertEq(priorityQueue.MIN_AMOUNT(), 0.01 ether); + } + + //-------------------------------------------------------------------------------------- + //------------------------------ REQUEST TESTS --------------------------------------- + //-------------------------------------------------------------------------------------- + + function test_requestWithdraw() public { + uint96 withdrawAmount = 10 ether; + + // Record initial state + uint256 initialEethBalance = eETHInstance.balanceOf(vipUser); + uint256 initialQueueEethBalance = eETHInstance.balanceOf(address(priorityQueue)); + uint96 initialNonce = priorityQueue.nonce(); + + // Create request + (bytes32 requestId, IPriorityWithdrawalQueue.WithdrawRequest memory request) = + _createWithdrawRequest(vipUser, withdrawAmount); + + // Verify state changes + assertEq(priorityQueue.nonce(), initialNonce + 1, "Nonce should increment"); + // Use approximate comparison due to share/amount rounding (1 wei tolerance) + assertApproxEqAbs(eETHInstance.balanceOf(vipUser), initialEethBalance - withdrawAmount, 1, "VIP user eETH balance should decrease"); + assertApproxEqAbs(eETHInstance.balanceOf(address(priorityQueue)), initialQueueEethBalance + withdrawAmount, 1, "Queue eETH balance should increase"); + + // Verify request exists + assertTrue(priorityQueue.requestExists(requestId), "Request should exist"); + assertFalse(priorityQueue.isFinalized(requestId), "Request should not be finalized yet"); + + // Verify request ID matches + bytes32 expectedId = keccak256(abi.encode(request)); + assertEq(requestId, expectedId, "Request ID should match hash of request"); + + // Verify active requests count + assertEq(priorityQueue.totalActiveRequests(), 1, "Should have 1 active request"); + } + + function test_requestWithdrawWithPermit() public { + uint256 userPrivKey = 999; + address permitUser = vm.addr(userPrivKey); + uint96 withdrawAmount = 1 ether; + + // Whitelist and fund the permit user + vm.prank(alice); + priorityQueue.addToWhitelist(permitUser); + vm.deal(permitUser, 10 ether); + vm.prank(permitUser); + liquidityPoolInstance.deposit{value: 5 ether}(); + + // Record initial state + uint256 initialEethBalance = eETHInstance.balanceOf(permitUser); + uint256 initialQueueEethBalance = eETHInstance.balanceOf(address(priorityQueue)); + uint96 initialNonce = priorityQueue.nonce(); + + // Create valid permit + IPriorityWithdrawalQueue.PermitInput memory permit = _createEEthPermitInput( + userPrivKey, + address(priorityQueue), + withdrawAmount, + eETHInstance.nonces(permitUser), + block.timestamp + 1 hours + ); + + // Request withdrawal with permit + vm.prank(permitUser); + bytes32 requestId = priorityQueue.requestWithdrawWithPermit(withdrawAmount, 0, permit); + + // Verify state changes + assertEq(priorityQueue.nonce(), initialNonce + 1, "Nonce should increment"); + assertApproxEqAbs(eETHInstance.balanceOf(permitUser), initialEethBalance - withdrawAmount, 2, "User eETH balance should decrease"); + assertApproxEqAbs(eETHInstance.balanceOf(address(priorityQueue)), initialQueueEethBalance + withdrawAmount, 2, "Queue eETH balance should increase"); + assertTrue(priorityQueue.requestExists(requestId), "Request should exist"); + } + + function test_requestWithdrawWithPermit_invalidPermit_reverts() public { + uint256 userPrivKey = 999; + address permitUser = vm.addr(userPrivKey); + uint96 withdrawAmount = 1 ether; + + // Whitelist and fund the permit user + vm.prank(alice); + priorityQueue.addToWhitelist(permitUser); + vm.deal(permitUser, 10 ether); + vm.prank(permitUser); + liquidityPoolInstance.deposit{value: 5 ether}(); + + // Create invalid permit (wrong signature) + IPriorityWithdrawalQueue.PermitInput memory invalidPermit = IPriorityWithdrawalQueue.PermitInput({ + value: withdrawAmount, + deadline: block.timestamp + 1 hours, + v: 27, + r: bytes32(uint256(1)), + s: bytes32(uint256(2)) + }); + + // Request should revert with PermitFailedAndAllowanceTooLow + vm.prank(permitUser); + vm.expectRevert(PriorityWithdrawalQueue.PermitFailedAndAllowanceTooLow.selector); + priorityQueue.requestWithdrawWithPermit(withdrawAmount, 0, invalidPermit); + } + + function test_requestWithdrawWithPermit_expiredDeadline_reverts() public { + uint256 userPrivKey = 999; + address permitUser = vm.addr(userPrivKey); + uint96 withdrawAmount = 1 ether; + + // Whitelist and fund the permit user + vm.prank(alice); + priorityQueue.addToWhitelist(permitUser); + vm.deal(permitUser, 10 ether); + vm.prank(permitUser); + liquidityPoolInstance.deposit{value: 5 ether}(); + + // Create permit with expired deadline + IPriorityWithdrawalQueue.PermitInput memory expiredPermit = _createEEthPermitInput( + userPrivKey, + address(priorityQueue), + withdrawAmount, + eETHInstance.nonces(permitUser), + block.timestamp - 1 // expired + ); + + // Request should revert with PermitFailedAndAllowanceTooLow + vm.prank(permitUser); + vm.expectRevert(PriorityWithdrawalQueue.PermitFailedAndAllowanceTooLow.selector); + priorityQueue.requestWithdrawWithPermit(withdrawAmount, 0, expiredPermit); + } + + function test_requestWithdrawWithPermit_replayAttack_reverts() public { + uint256 userPrivKey = 999; + address permitUser = vm.addr(userPrivKey); + uint96 withdrawAmount = 1 ether; + + // Whitelist and fund the permit user + vm.prank(alice); + priorityQueue.addToWhitelist(permitUser); + vm.deal(permitUser, 10 ether); + vm.prank(permitUser); + liquidityPoolInstance.deposit{value: 5 ether}(); + + // Create valid permit + IPriorityWithdrawalQueue.PermitInput memory permit = _createEEthPermitInput( + userPrivKey, + address(priorityQueue), + withdrawAmount, + eETHInstance.nonces(permitUser), + block.timestamp + 1 hours + ); + + // First request should succeed + vm.prank(permitUser); + priorityQueue.requestWithdrawWithPermit(withdrawAmount, 0, permit); + + // Second request with same permit should revert (nonce already used) + vm.prank(permitUser); + vm.expectRevert(PriorityWithdrawalQueue.PermitFailedAndAllowanceTooLow.selector); + priorityQueue.requestWithdrawWithPermit(withdrawAmount, 0, permit); + } + + /// @dev Helper to create eETH permit input + function _createEEthPermitInput( + uint256 privKey, + address spender, + uint256 value, + uint256 nonce, + uint256 deadline + ) internal view returns (IPriorityWithdrawalQueue.PermitInput memory) { + address _owner = vm.addr(privKey); + bytes32 domainSeparator = eETHInstance.DOMAIN_SEPARATOR(); + bytes32 digest = _calculatePermitDigest(_owner, spender, value, nonce, deadline, domainSeparator); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privKey, digest); + return IPriorityWithdrawalQueue.PermitInput({ + value: value, + deadline: deadline, + v: v, + r: r, + s: s + }); + } + + /// @dev Calculate EIP-2612 permit digest + function _calculatePermitDigest( + address _owner, + address spender, + uint256 value, + uint256 nonce, + uint256 deadline, + bytes32 domainSeparator + ) internal pure returns (bytes32) { + bytes32 PERMIT_TYPEHASH = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, _owner, spender, value, nonce, deadline)); + return keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + } + + + //-------------------------------------------------------------------------------------- + //------------------------------ FULFILL TESTS --------------------------------------- + //-------------------------------------------------------------------------------------- + + function test_fulfillRequests() public { + uint96 withdrawAmount = 10 ether; + + // Setup: VIP user creates a withdrawal request + (bytes32 requestId, IPriorityWithdrawalQueue.WithdrawRequest memory request) = + _createWithdrawRequest(vipUser, withdrawAmount); + + // Record state before fulfillment + uint128 lpLockedBefore = priorityQueue.ethAmountLockedForPriorityWithdrawal(); + + // Request manager fulfills the request + IPriorityWithdrawalQueue.WithdrawRequest[] memory requests = new IPriorityWithdrawalQueue.WithdrawRequest[](1); + requests[0] = request; + + vm.prank(requestManager); + priorityQueue.fulfillRequests(requests); + + // Verify state changes + assertGt(priorityQueue.ethAmountLockedForPriorityWithdrawal(), lpLockedBefore, "LP locked for priority should increase"); + + // Verify request is finalized + assertTrue(priorityQueue.isFinalized(requestId), "Request should be finalized"); + assertTrue(priorityQueue.requestExists(requestId), "Request should still exist"); + } + + function test_fulfillRequests_revertNotMatured() public { + uint96 withdrawAmount = 10 ether; + + // Manually create request (don't use helper since it auto-warps time) + uint32 nonceBefore = priorityQueue.nonce(); + uint96 shareAmount = uint96(liquidityPoolInstance.sharesForAmount(withdrawAmount)); + uint32 timestamp = uint32(block.timestamp); + + vm.startPrank(vipUser); + eETHInstance.approve(address(priorityQueue), withdrawAmount); + priorityQueue.requestWithdraw(withdrawAmount, 0); + vm.stopPrank(); + + IPriorityWithdrawalQueue.WithdrawRequest memory request = IPriorityWithdrawalQueue.WithdrawRequest({ + user: vipUser, + amountOfEEth: withdrawAmount, + shareOfEEth: shareAmount, + minAmountOut: 0, + nonce: uint32(nonceBefore), + creationTime: timestamp + }); + bytes32 requestId = keccak256(abi.encode(request)); + + // Try to fulfill immediately (should fail - not matured, MIN_DELAY = 1 hour) + IPriorityWithdrawalQueue.WithdrawRequest[] memory requests = new IPriorityWithdrawalQueue.WithdrawRequest[](1); + requests[0] = request; + + vm.prank(requestManager); + vm.expectRevert(PriorityWithdrawalQueue.NotMatured.selector); + priorityQueue.fulfillRequests(requests); + + // Warp time past MIN_DELAY and try again + vm.warp(block.timestamp + 1 hours + 1); + vm.prank(requestManager); + priorityQueue.fulfillRequests(requests); + + assertTrue(priorityQueue.isFinalized(requestId), "Request should be finalized after maturity"); + } + + function test_fulfillRequests_revertAlreadyFinalized() public { + uint96 withdrawAmount = 10 ether; + + (, IPriorityWithdrawalQueue.WithdrawRequest memory request) = + _createWithdrawRequest(vipUser, withdrawAmount); + + IPriorityWithdrawalQueue.WithdrawRequest[] memory requests = new IPriorityWithdrawalQueue.WithdrawRequest[](1); + requests[0] = request; + + // First fulfill succeeds + vm.prank(requestManager); + priorityQueue.fulfillRequests(requests); + + // Second fulfill fails + vm.prank(requestManager); + vm.expectRevert(PriorityWithdrawalQueue.RequestAlreadyFinalized.selector); + priorityQueue.fulfillRequests(requests); + } + + //-------------------------------------------------------------------------------------- + //------------------------------ CLAIM TESTS ----------------------------------------- + //-------------------------------------------------------------------------------------- + + function test_claimWithdraw() public { + uint96 withdrawAmount = 10 ether; + + // Setup: VIP user creates a withdrawal request + (bytes32 requestId, IPriorityWithdrawalQueue.WithdrawRequest memory request) = + _createWithdrawRequest(vipUser, withdrawAmount); + + // Request manager fulfills the request + IPriorityWithdrawalQueue.WithdrawRequest[] memory requests = new IPriorityWithdrawalQueue.WithdrawRequest[](1); + requests[0] = request; + vm.prank(requestManager); + priorityQueue.fulfillRequests(requests); + + // Record state before claim + uint256 userEthBefore = vipUser.balance; + uint256 queueEethBefore = eETHInstance.balanceOf(address(priorityQueue)); + uint256 remainderBefore = priorityQueue.totalRemainderShares(); + + // Anyone can send the ETH to the request user + vm.prank(regularUser); + priorityQueue.claimWithdraw(request); + + // Verify ETH was received (approximately, due to share price) + assertApproxEqRel(vipUser.balance, userEthBefore + withdrawAmount, 0.001e18, "User should receive ETH"); + + // Verify eETH was burned from queue + assertLt(eETHInstance.balanceOf(address(priorityQueue)), queueEethBefore, "Queue eETH balance should decrease"); + + // Verify request was removed + assertFalse(priorityQueue.requestExists(requestId), "Request should be removed"); + assertFalse(priorityQueue.isFinalized(requestId), "Request should no longer be finalized"); + + // Verify remainder tracking + assertGe(priorityQueue.totalRemainderShares(), remainderBefore, "Remainder shares should increase or stay same"); + } + + function test_batchClaimWithdraw() public { + uint96 amount1 = 5 ether; + uint96 amount2 = 3 ether; + + // Create two requests + (, IPriorityWithdrawalQueue.WithdrawRequest memory request1) = + _createWithdrawRequest(vipUser, amount1); + (, IPriorityWithdrawalQueue.WithdrawRequest memory request2) = + _createWithdrawRequest(vipUser, amount2); + + // Fulfill both + IPriorityWithdrawalQueue.WithdrawRequest[] memory requests = new IPriorityWithdrawalQueue.WithdrawRequest[](2); + requests[0] = request1; + requests[1] = request2; + vm.prank(requestManager); + priorityQueue.fulfillRequests(requests); + + // Record state before claim + uint256 ethBefore = vipUser.balance; + + // Batch claim + vm.prank(vipUser); + priorityQueue.batchClaimWithdraw(requests); + + // Verify ETH received + assertApproxEqRel(vipUser.balance, ethBefore + amount1 + amount2, 0.001e18, "All ETH should be received"); + } + + //-------------------------------------------------------------------------------------- + //------------------------------ CANCEL TESTS ---------------------------------------- + //-------------------------------------------------------------------------------------- + + function test_cancelWithdraw() public { + uint96 withdrawAmount = 10 ether; + + // Create request + uint256 eethBefore = eETHInstance.balanceOf(vipUser); + + (bytes32 requestId, IPriorityWithdrawalQueue.WithdrawRequest memory request) = + _createWithdrawRequest(vipUser, withdrawAmount); + + uint256 eethAfterRequest = eETHInstance.balanceOf(vipUser); + + // Verify request state (use approximate comparison due to share/amount rounding) + assertApproxEqAbs(eethAfterRequest, eethBefore - withdrawAmount, 1, "eETH transferred to queue"); + + // Cancel request + vm.prank(vipUser); + bytes32 cancelledId = priorityQueue.cancelWithdraw(request); + + // Verify state changes + assertEq(cancelledId, requestId, "Cancelled ID should match"); + assertFalse(priorityQueue.requestExists(requestId), "Request should be removed"); + // eETH returned might have small rounding difference + assertApproxEqAbs(eETHInstance.balanceOf(vipUser), eethBefore, 1, "eETH should be returned"); + } + + function test_cancelWithdraw_finalized() public { + uint96 withdrawAmount = 10 ether; + + // Record initial balance + uint256 eethInitial = eETHInstance.balanceOf(vipUser); + + // Create and fulfill request + (bytes32 requestId, IPriorityWithdrawalQueue.WithdrawRequest memory request) = + _createWithdrawRequest(vipUser, withdrawAmount); + + IPriorityWithdrawalQueue.WithdrawRequest[] memory requests = new IPriorityWithdrawalQueue.WithdrawRequest[](1); + requests[0] = request; + vm.prank(requestManager); + priorityQueue.fulfillRequests(requests); + + uint128 lpLockedBefore = priorityQueue.ethAmountLockedForPriorityWithdrawal(); + + // Request manager cancels finalized request (invalidateRequests requires request manager role) + vm.prank(requestManager); + bytes32[] memory cancelledIds = priorityQueue.invalidateRequests(requests); + + // Verify state changes + assertEq(cancelledIds[0], requestId, "Cancelled ID should match"); + assertFalse(priorityQueue.requestExists(requestId), "Request should be removed"); + assertFalse(priorityQueue.isFinalized(requestId), "Request should no longer be finalized"); + + // eETH should be returned (approximately due to share rounding) + assertApproxEqAbs(eETHInstance.balanceOf(vipUser), eethInitial, 1, "eETH should be returned"); + + // LP locked should decrease + assertLt(priorityQueue.ethAmountLockedForPriorityWithdrawal(), lpLockedBefore, "LP locked should decrease"); + } + + function test_admininvalidateRequests() public { + uint96 withdrawAmount = 10 ether; + + // Record initial balance before request + uint256 eethInitial = eETHInstance.balanceOf(vipUser); + + // Create request + (, IPriorityWithdrawalQueue.WithdrawRequest memory request) = + _createWithdrawRequest(vipUser, withdrawAmount); + + // Request manager cancels (invalidateRequests requires request manager role) + IPriorityWithdrawalQueue.WithdrawRequest[] memory requests = new IPriorityWithdrawalQueue.WithdrawRequest[](1); + requests[0] = request; + + vm.prank(requestManager); + bytes32[] memory cancelledIds = priorityQueue.invalidateRequests(requests); + + // Verify state changes + assertEq(cancelledIds.length, 1, "Should cancel one request"); + // eETH should return to approximately initial balance (small rounding due to share conversion) + assertApproxEqAbs(eETHInstance.balanceOf(vipUser), eethInitial, 1, "eETH should be returned"); + } + + //-------------------------------------------------------------------------------------- + //------------------------------ FULL FLOW TESTS ------------------------------------- + //-------------------------------------------------------------------------------------- + + function test_fullWithdrawalFlow() public { + // This test verifies the complete flow from deposit to withdrawal + uint96 withdrawAmount = 5 ether; + + // 1. VIP user already has eETH from setUp + uint256 initialEethBalance = eETHInstance.balanceOf(vipUser); + uint256 initialEthBalance = vipUser.balance; + + // 2. Request withdrawal + (, IPriorityWithdrawalQueue.WithdrawRequest memory request) = + _createWithdrawRequest(vipUser, withdrawAmount); + + // Verify intermediate state (use approximate comparison due to share/amount rounding) + assertApproxEqAbs(eETHInstance.balanceOf(vipUser), initialEethBalance - withdrawAmount, 1, "eETH transferred to queue"); + assertTrue(priorityQueue.requestExists(priorityQueue.getRequestId(request)), "Request should exist"); + + // 3. Request manager fulfills the request + IPriorityWithdrawalQueue.WithdrawRequest[] memory requests = new IPriorityWithdrawalQueue.WithdrawRequest[](1); + requests[0] = request; + vm.prank(requestManager); + priorityQueue.fulfillRequests(requests); + + // Verify fulfilled state + assertTrue(priorityQueue.isFinalized(priorityQueue.getRequestId(request)), "Request should be finalized"); + assertGt(priorityQueue.ethAmountLockedForPriorityWithdrawal(), 0, "LP tracks locked amount"); + + // 4. VIP user claims ETH + vm.prank(vipUser); + priorityQueue.claimWithdraw(request); + + // Verify final state + assertFalse(priorityQueue.requestExists(priorityQueue.getRequestId(request)), "Request should be removed"); + assertApproxEqRel(vipUser.balance, initialEthBalance + withdrawAmount, 0.001e18, "ETH received"); + } + + function test_multipleRequests() public { + uint96 amount1 = 5 ether; + uint96 amount2 = 3 ether; + + // Create two requests + (, IPriorityWithdrawalQueue.WithdrawRequest memory request1) = + _createWithdrawRequest(vipUser, amount1); + (, IPriorityWithdrawalQueue.WithdrawRequest memory request2) = + _createWithdrawRequest(vipUser, amount2); + + // Verify both requests tracked + assertEq(priorityQueue.totalActiveRequests(), 2, "Should have 2 active requests"); + assertEq(priorityQueue.nonce(), 3, "Nonce should be 3"); + + // Verify request IDs are in the list + bytes32[] memory requestIds = priorityQueue.getRequestIds(); + assertEq(requestIds.length, 2, "Should have 2 request IDs"); + + // Fulfill both at once + IPriorityWithdrawalQueue.WithdrawRequest[] memory requests = new IPriorityWithdrawalQueue.WithdrawRequest[](2); + requests[0] = request1; + requests[1] = request2; + vm.prank(requestManager); + priorityQueue.fulfillRequests(requests); + + // Verify finalized request IDs + bytes32[] memory finalizedIds = priorityQueue.getFinalizedRequestIds(); + assertEq(finalizedIds.length, 2, "Should have 2 finalized request IDs"); + + // Claim both + uint256 ethBefore = vipUser.balance; + vm.startPrank(vipUser); + priorityQueue.claimWithdraw(request1); + priorityQueue.claimWithdraw(request2); + vm.stopPrank(); + + // Verify final state + assertEq(priorityQueue.totalActiveRequests(), 0, "All claimed"); + assertApproxEqRel(vipUser.balance, ethBefore + amount1 + amount2, 0.001e18, "All ETH received"); + } + + //-------------------------------------------------------------------------------------- + //------------------------------ WHITELIST TESTS ------------------------------------- + //-------------------------------------------------------------------------------------- + + function test_whitelistManagement() public { + address newUser = makeAddr("newUser"); + + // Initially not whitelisted + assertFalse(priorityQueue.isWhitelisted(newUser), "Should not be whitelisted initially"); + + // Admin adds to whitelist + vm.prank(alice); + priorityQueue.addToWhitelist(newUser); + assertTrue(priorityQueue.isWhitelisted(newUser), "Should be whitelisted after add"); + + // Admin removes from whitelist + vm.prank(alice); + priorityQueue.removeFromWhitelist(newUser); + assertFalse(priorityQueue.isWhitelisted(newUser), "Should not be whitelisted after remove"); + } + + function test_batchUpdateWhitelist() public { + address user1 = makeAddr("user1"); + address user2 = makeAddr("user2"); + + address[] memory users = new address[](2); + users[0] = user1; + users[1] = user2; + bool[] memory statuses = new bool[](2); + statuses[0] = true; + statuses[1] = true; + + vm.prank(alice); + priorityQueue.batchUpdateWhitelist(users, statuses); + + assertTrue(priorityQueue.isWhitelisted(user1), "User1 should be whitelisted"); + assertTrue(priorityQueue.isWhitelisted(user2), "User2 should be whitelisted"); + } + + function test_revert_addZeroAddressToWhitelist() public { + vm.prank(alice); + vm.expectRevert(PriorityWithdrawalQueue.AddressZero.selector); + priorityQueue.addToWhitelist(address(0)); + } + + //-------------------------------------------------------------------------------------- + //------------------------------ CONFIG TESTS ---------------------------------------- + //-------------------------------------------------------------------------------------- + + //-------------------------------------------------------------------------------------- + //------------------------------ PAUSE TESTS ----------------------------------------- + //-------------------------------------------------------------------------------------- + + function test_pauseContract() public { + assertFalse(priorityQueue.paused(), "Should not be paused initially"); + + vm.prank(alice); + priorityQueue.pauseContract(); + + assertTrue(priorityQueue.paused(), "Should be paused after pauseContract"); + + // Cannot request withdraw when paused + vm.startPrank(vipUser); + eETHInstance.approve(address(priorityQueue), 1 ether); + vm.expectRevert(PriorityWithdrawalQueue.ContractPaused.selector); + priorityQueue.requestWithdraw(1 ether, 0); + vm.stopPrank(); + } + + function test_unPauseContract() public { + vm.prank(alice); + priorityQueue.pauseContract(); + assertTrue(priorityQueue.paused(), "Should be paused"); + + vm.prank(alice); + priorityQueue.unPauseContract(); + assertFalse(priorityQueue.paused(), "Should be unpaused"); + } + + function test_revert_pauseWhenAlreadyPaused() public { + vm.prank(alice); + priorityQueue.pauseContract(); + + vm.prank(alice); + vm.expectRevert(PriorityWithdrawalQueue.ContractPaused.selector); + priorityQueue.pauseContract(); + } + + function test_revert_unpauseWhenNotPaused() public { + vm.prank(alice); + vm.expectRevert(PriorityWithdrawalQueue.ContractNotPaused.selector); + priorityQueue.unPauseContract(); + } + + //-------------------------------------------------------------------------------------- + //------------------------------ REMAINDER TESTS ------------------------------------- + //-------------------------------------------------------------------------------------- + + function test_handleRemainder() public { + // First create and complete a withdrawal to accumulate remainder + uint96 withdrawAmount = 10 ether; + (, IPriorityWithdrawalQueue.WithdrawRequest memory request) = + _createWithdrawRequest(vipUser, withdrawAmount); + + IPriorityWithdrawalQueue.WithdrawRequest[] memory requests = new IPriorityWithdrawalQueue.WithdrawRequest[](1); + requests[0] = request; + + vm.prank(requestManager); + priorityQueue.fulfillRequests(requests); + + vm.prank(vipUser); + priorityQueue.claimWithdraw(request); + + uint256 remainderAmount = priorityQueue.getRemainderAmount(); + + // Only test if there are remainder shares + if (remainderAmount > 0) { + uint256 amountToHandle = remainderAmount / 2; + uint256 remainderBefore = priorityQueue.totalRemainderShares(); + + vm.prank(alice); + priorityQueue.handleRemainder(amountToHandle); + + assertLt(priorityQueue.totalRemainderShares(), remainderBefore, "Remainder should decrease"); + } + } + + // function test_handleRemainder_withTreasurySplit() public { + // // Set 50% split to treasury (5000 bps) + // vm.prank(alice); + // priorityQueue.updateShareRemainderSplitToTreasury(5000); + + // // Create and complete a withdrawal to accumulate remainder + // uint96 withdrawAmount = 10 ether; + // (, IPriorityWithdrawalQueue.WithdrawRequest memory request) = + // _createWithdrawRequest(vipUser, withdrawAmount); + + // IPriorityWithdrawalQueue.WithdrawRequest[] memory requests = new IPriorityWithdrawalQueue.WithdrawRequest[](1); + // requests[0] = request; + + // vm.prank(requestManager); + // priorityQueue.fulfillRequests(requests); + + // vm.prank(vipUser); + // priorityQueue.claimWithdraw(request); + + // uint256 remainderAmount = priorityQueue.getRemainderAmount(); + + // // Only test if there are remainder shares + // if (remainderAmount > 0) { + // uint256 treasuryBalanceBefore = eETHInstance.balanceOf(treasury); + // uint256 remainderSharesBefore = priorityQueue.totalRemainderShares(); + + // vm.prank(alice); + // priorityQueue.handleRemainder(remainderAmount); + + // // Verify treasury received ~50% of remainder as eETH + // uint256 treasuryBalanceAfter = eETHInstance.balanceOf(treasury); + // assertGt(treasuryBalanceAfter, treasuryBalanceBefore, "Treasury should receive eETH"); + + // // Approximately 50% should go to treasury (allowing for rounding) + // uint256 expectedToTreasury = remainderAmount / 2; + // assertApproxEqRel(treasuryBalanceAfter - treasuryBalanceBefore, expectedToTreasury, 0.01e18, "Treasury should receive ~50%"); + + // // Remainder should be cleared + // assertLt(priorityQueue.totalRemainderShares(), remainderSharesBefore, "Remainder shares should decrease"); + // } + // } + + // function test_handleRemainder_fullTreasurySplit() public { + // // Set 100% split to treasury (10000 bps) + // vm.prank(alice); + // priorityQueue.updateShareRemainderSplitToTreasury(10000); + + // // Create and complete a withdrawal to accumulate remainder + // uint96 withdrawAmount = 10 ether; + // (, IPriorityWithdrawalQueue.WithdrawRequest memory request) = + // _createWithdrawRequest(vipUser, withdrawAmount); + + // IPriorityWithdrawalQueue.WithdrawRequest[] memory requests = new IPriorityWithdrawalQueue.WithdrawRequest[](1); + // requests[0] = request; + + // vm.prank(requestManager); + // priorityQueue.fulfillRequests(requests); + + // vm.prank(vipUser); + // priorityQueue.claimWithdraw(request); + + // uint256 remainderAmount = priorityQueue.getRemainderAmount(); + + // if (remainderAmount > 0) { + // uint256 treasuryBalanceBefore = eETHInstance.balanceOf(treasury); + + // vm.prank(alice); + // priorityQueue.handleRemainder(remainderAmount); + + // // Verify treasury received all remainder as eETH (nothing burned) + // uint256 treasuryBalanceAfter = eETHInstance.balanceOf(treasury); + // assertApproxEqRel(treasuryBalanceAfter - treasuryBalanceBefore, remainderAmount, 0.01e18, "Treasury should receive ~100%"); + // } + // } + + // function test_handleRemainder_noBurn() public { + // // Set 0% split to treasury (all burn) + // vm.prank(alice); + // priorityQueue.updateShareRemainderSplitToTreasury(0); + + // // Create and complete a withdrawal to accumulate remainder + // uint96 withdrawAmount = 10 ether; + // (, IPriorityWithdrawalQueue.WithdrawRequest memory request) = + // _createWithdrawRequest(vipUser, withdrawAmount); + + // IPriorityWithdrawalQueue.WithdrawRequest[] memory requests = new IPriorityWithdrawalQueue.WithdrawRequest[](1); + // requests[0] = request; + + // vm.prank(requestManager); + // priorityQueue.fulfillRequests(requests); + + // vm.prank(vipUser); + // priorityQueue.claimWithdraw(request); + + // uint256 remainderAmount = priorityQueue.getRemainderAmount(); + + // if (remainderAmount > 0) { + // uint256 treasuryBalanceBefore = eETHInstance.balanceOf(treasury); + + // vm.prank(alice); + // priorityQueue.handleRemainder(remainderAmount); + + // // Verify treasury received nothing + // uint256 treasuryBalanceAfter = eETHInstance.balanceOf(treasury); + // assertEq(treasuryBalanceAfter, treasuryBalanceBefore, "Treasury should receive nothing"); + // } + // } + + function test_updateShareRemainderSplitToTreasury() public { + assertEq(priorityQueue.shareRemainderSplitToTreasuryInBps(), 10000, "Initial split should be 100%"); + + vm.prank(alice); + priorityQueue.updateShareRemainderSplitToTreasury(5000); + + assertEq(priorityQueue.shareRemainderSplitToTreasuryInBps(), 5000, "Split should be updated to 50%"); + } + + function test_revert_updateShareRemainderSplitToTreasury_tooHigh() public { + vm.prank(alice); + vm.expectRevert(PriorityWithdrawalQueue.BadInput.selector); + priorityQueue.updateShareRemainderSplitToTreasury(10001); // > 100% + } + + function test_revert_updateShareRemainderSplitToTreasury_notAdmin() public { + vm.prank(regularUser); + vm.expectRevert(PriorityWithdrawalQueue.IncorrectRole.selector); + priorityQueue.updateShareRemainderSplitToTreasury(5000); + } + + function test_revert_handleRemainderTooMuch() public { + vm.prank(alice); + vm.expectRevert(PriorityWithdrawalQueue.BadInput.selector); + priorityQueue.handleRemainder(1 ether); + } + + function test_revert_handleRemainderZero() public { + vm.prank(alice); + vm.expectRevert(PriorityWithdrawalQueue.BadInput.selector); + priorityQueue.handleRemainder(0); + } + + function test_revert_handleRemainderNotFeeClaimer() public { + vm.prank(regularUser); + vm.expectRevert(PriorityWithdrawalQueue.IncorrectRole.selector); + priorityQueue.handleRemainder(1 ether); + } + + //-------------------------------------------------------------------------------------- + //------------------------------ REVERT TESTS ---------------------------------------- + //-------------------------------------------------------------------------------------- + + function test_revert_notWhitelisted() public { + vm.deal(regularUser, 10 ether); + vm.startPrank(regularUser); + liquidityPoolInstance.deposit{value: 5 ether}(); + eETHInstance.approve(address(priorityQueue), 1 ether); + + vm.expectRevert(PriorityWithdrawalQueue.NotWhitelisted.selector); + priorityQueue.requestWithdraw(1 ether, 0); + vm.stopPrank(); + } + + function test_revert_claimNotFinalized() public { + // Create request but don't fulfill + (, IPriorityWithdrawalQueue.WithdrawRequest memory request) = + _createWithdrawRequest(vipUser, 1 ether); + + vm.prank(vipUser); + vm.expectRevert(PriorityWithdrawalQueue.RequestNotFinalized.selector); + priorityQueue.claimWithdraw(request); + } + + function test_revert_fulfillNonRequestManager() public { + (, IPriorityWithdrawalQueue.WithdrawRequest memory request) = + _createWithdrawRequest(vipUser, 1 ether); + + IPriorityWithdrawalQueue.WithdrawRequest[] memory requests = new IPriorityWithdrawalQueue.WithdrawRequest[](1); + requests[0] = request; + + vm.prank(regularUser); + vm.expectRevert(PriorityWithdrawalQueue.IncorrectRole.selector); + priorityQueue.fulfillRequests(requests); + } + + function test_revert_cancelWrongOwner() public { + (, IPriorityWithdrawalQueue.WithdrawRequest memory request) = + _createWithdrawRequest(vipUser, 1 ether); + + vm.prank(regularUser); + vm.expectRevert(PriorityWithdrawalQueue.NotRequestOwner.selector); + priorityQueue.cancelWithdraw(request); + } + + function test_revert_amountTooSmall() public { + vm.startPrank(vipUser); + eETHInstance.approve(address(priorityQueue), 0.001 ether); + + // Default minimum amount is 0.01 ether + vm.expectRevert(PriorityWithdrawalQueue.InvalidAmount.selector); + priorityQueue.requestWithdraw(0.001 ether, 0); + vm.stopPrank(); + } + + function test_revert_requestNotFound() public { + // Create a fake request that doesn't exist + IPriorityWithdrawalQueue.WithdrawRequest memory fakeRequest = IPriorityWithdrawalQueue.WithdrawRequest({ + user: vipUser, + amountOfEEth: 1 ether, + shareOfEEth: 1 ether, + minAmountOut: 0, + nonce: 999, + creationTime: uint32(block.timestamp) + }); + + IPriorityWithdrawalQueue.WithdrawRequest[] memory requests = new IPriorityWithdrawalQueue.WithdrawRequest[](1); + requests[0] = fakeRequest; + + vm.prank(requestManager); + vm.expectRevert(PriorityWithdrawalQueue.RequestNotFound.selector); + priorityQueue.fulfillRequests(requests); + } + + function test_revert_adminFunctionsNotAdmin() public { + vm.startPrank(regularUser); + + vm.expectRevert(PriorityWithdrawalQueue.IncorrectRole.selector); + priorityQueue.addToWhitelist(regularUser); + + vm.expectRevert(PriorityWithdrawalQueue.IncorrectRole.selector); + priorityQueue.removeFromWhitelist(vipUser); + + vm.stopPrank(); + } + + //-------------------------------------------------------------------------------------- + //------------------------------ GETTER TESTS ---------------------------------------- + //-------------------------------------------------------------------------------------- + + function test_getClaimableAmount() public { + uint96 withdrawAmount = 10 ether; + + (, IPriorityWithdrawalQueue.WithdrawRequest memory request) = + _createWithdrawRequest(vipUser, withdrawAmount); + + IPriorityWithdrawalQueue.WithdrawRequest[] memory requests = new IPriorityWithdrawalQueue.WithdrawRequest[](1); + requests[0] = request; + + vm.prank(requestManager); + priorityQueue.fulfillRequests(requests); + + uint256 claimable = priorityQueue.getClaimableAmount(request); + assertApproxEqRel(claimable, withdrawAmount, 0.001e18, "Claimable should be approximately the withdraw amount"); + } + + function test_generateWithdrawRequestId() public view { + address testUser = vipUser; + uint96 testAmount = 10 ether; + uint96 testShare = uint96(liquidityPoolInstance.sharesForAmount(testAmount)); + uint96 testMinOut = 9.5 ether; + uint32 testNonce = 1; + uint32 testTime = uint32(block.timestamp); + + bytes32 generatedId = priorityQueue.generateWithdrawRequestId( + testUser, + testAmount, + testShare, + testMinOut, + testNonce, + testTime + ); + + // Verify it matches keccak256 of the struct + IPriorityWithdrawalQueue.WithdrawRequest memory req = IPriorityWithdrawalQueue.WithdrawRequest({ + user: testUser, + amountOfEEth: testAmount, + shareOfEEth: testShare, + minAmountOut: testMinOut, + nonce: testNonce, + creationTime: testTime + }); + bytes32 expectedId = keccak256(abi.encode(req)); + + assertEq(generatedId, expectedId, "Generated ID should match"); + } + + function test_revert_insufficientOutputAmount() public { + // User requests with a high minAmountOut that won't be met after fees + uint96 withdrawAmount = 1 ether; + uint96 highMinOut = 1.1 ether; // Higher than possible output + + (bytes32 requestId, IPriorityWithdrawalQueue.WithdrawRequest memory request) = + _createWithdrawRequestWithMinOut(vipUser, withdrawAmount, highMinOut); + + // Fulfill the request + IPriorityWithdrawalQueue.WithdrawRequest[] memory requests = new IPriorityWithdrawalQueue.WithdrawRequest[](1); + requests[0] = request; + + vm.prank(requestManager); + priorityQueue.fulfillRequests(requests); + + // Claim should revert due to insufficient output + vm.prank(vipUser); + vm.expectRevert(PriorityWithdrawalQueue.InsufficientOutputAmount.selector); + priorityQueue.claimWithdraw(request); + } + +} diff --git a/test/RestakingRewardsRouter.t.sol b/test/RestakingRewardsRouter.t.sol index b8bec9fe4..f6a7e4b48 100644 --- a/test/RestakingRewardsRouter.t.sol +++ b/test/RestakingRewardsRouter.t.sol @@ -56,7 +56,7 @@ contract RestakingRewardsRouterTest is Test { otherToken = new TestERC20("Other Token", "OTH"); // Deploy LiquidityPool - liquidityPoolImpl = new LiquidityPool(); + liquidityPoolImpl = new LiquidityPool(address(0x0)); liquidityPoolProxy = new UUPSProxy(address(liquidityPoolImpl), ""); liquidityPool = LiquidityPool(payable(address(liquidityPoolProxy))); diff --git a/test/TestSetup.sol b/test/TestSetup.sol index 1647fa402..85c988922 100644 --- a/test/TestSetup.sol +++ b/test/TestSetup.sol @@ -61,6 +61,7 @@ import "../script/deploys/Deployed.s.sol"; import "../src/DepositAdapter.sol"; import "../src/interfaces/IWeETHWithdrawAdapter.sol"; +import "../src/PriorityWithdrawalQueue.sol"; contract TestSetup is Test, ContractCodeChecker, DepositDataGeneration { @@ -205,6 +206,9 @@ contract TestSetup is Test, ContractCodeChecker, DepositDataGeneration { IWeETHWithdrawAdapter public weEthWithdrawAdapterInstance; IWeETHWithdrawAdapter public weEthWithdrawAdapterImplementation; + PriorityWithdrawalQueue public priorityQueueImplementation; + PriorityWithdrawalQueue public priorityQueueInstance; + EtherFiRewardsRouter public etherFiRewardsRouterInstance = EtherFiRewardsRouter(payable(0x73f7b1184B5cD361cC0f7654998953E2a251dd58)); EtherFiNode public node; @@ -427,45 +431,17 @@ contract TestSetup is Test, ContractCodeChecker, DepositDataGeneration { etherFiTimelockInstance = EtherFiTimelock(payable(addressProviderInstance.getContractAddress("EtherFiTimelock"))); etherFiAdminInstance = EtherFiAdmin(payable(addressProviderInstance.getContractAddress("EtherFiAdmin"))); etherFiOracleInstance = EtherFiOracle(payable(addressProviderInstance.getContractAddress("EtherFiOracle"))); - etherFiRedemptionManagerInstance = EtherFiRedemptionManager(payable(address(0xDadEf1fFBFeaAB4f68A9fD181395F68b4e4E7Ae0))); - etherFiRestakerInstance = EtherFiRestaker(payable(address(0x1B7a4C3797236A1C37f8741c0Be35c2c72736fFf))); roleRegistryInstance = RoleRegistry(addressProviderInstance.getContractAddress("RoleRegistry")); - cumulativeMerkleRewardsDistributorInstance = CumulativeMerkleRewardsDistributor(payable(0x9A8c5046a290664Bf42D065d33512fe403484534)); treasuryInstance = 0x0c83EAe1FE72c390A02E426572854931EefF93BA; + etherFiRestakerInstance = EtherFiRestaker(payable(address(0x1B7a4C3797236A1C37f8741c0Be35c2c72736fFf))); + cumulativeMerkleRewardsDistributorInstance = CumulativeMerkleRewardsDistributor(payable(0x9A8c5046a290664Bf42D065d33512fe403484534)); weEthWithdrawAdapterInstance = IWeETHWithdrawAdapter(deployed.WEETH_WITHDRAW_ADAPTER()); - } + etherFiRedemptionManagerInstance = liquidityPoolInstance.etherFiRedemptionManager(); - function upgradeEtherFiRedemptionManager() public { - address ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; - EtherFiRedemptionManager Implementation = new EtherFiRedemptionManager(address(payable(liquidityPoolInstance)), address(eETHInstance), address(weEthInstance), address(treasuryInstance), address(roleRegistryInstance), address(etherFiRestakerInstance)); - EtherFiRestaker restakerImplementation = new EtherFiRestaker(address(eigenLayerRewardsCoordinator), address(etherFiRedemptionManagerInstance)); - vm.startPrank(owner); - etherFiRestakerInstance.upgradeTo(address(restakerImplementation)); - vm.stopPrank(); - vm.prank(owner); - etherFiRedemptionManagerInstance.upgradeTo(address(Implementation)); - address[] memory _tokens = new address[](2); - _tokens[0] = ETH_ADDRESS; - _tokens[1] = address(etherFiRestakerInstance.lido()); - uint16[] memory _exitFeeSplitToTreasuryInBps = new uint16[](2); - _exitFeeSplitToTreasuryInBps[0] = 10_00; - _exitFeeSplitToTreasuryInBps[1] = 10_00; - uint16[] memory _exitFeeInBps = new uint16[](2); - _exitFeeInBps[0] = 1_00; - _exitFeeInBps[1] = 1_00; - uint16[] memory _lowWatermarkInBpsOfTvl = new uint16[](2); - _lowWatermarkInBpsOfTvl[0] = 1_00; - _lowWatermarkInBpsOfTvl[1] = 50; - uint256[] memory _bucketCapacity = new uint256[](2); - _bucketCapacity[0] = 2000 ether; - _bucketCapacity[1] = 2000 ether; - uint256[] memory _bucketRefillRate = new uint256[](2); - _bucketRefillRate[0] = 0.3 ether; - _bucketRefillRate[1] = 0.3 ether; - vm.startPrank(owner); - roleRegistryInstance.grantRole(keccak256("ETHERFI_REDEMPTION_MANAGER_ADMIN_ROLE"), owner); - etherFiRedemptionManagerInstance.initializeTokenParameters(_tokens, _exitFeeSplitToTreasuryInBps, _exitFeeInBps, _lowWatermarkInBpsOfTvl, _bucketCapacity, _bucketRefillRate); - vm.stopPrank(); + // Deploy PriorityWithdrawalQueue for fork testing (mainnet LP has immutable address(0) for this) + PriorityWithdrawalQueue priorityQueueImplementation = new PriorityWithdrawalQueue(address(liquidityPoolInstance), address(eETHInstance), address(roleRegistryInstance), address(treasuryInstance), 1 hours); + UUPSProxy priorityQueueProxy = new UUPSProxy(address(priorityQueueImplementation), ""); + priorityQueueInstance = PriorityWithdrawalQueue(address(priorityQueueProxy)); } function updateShouldSetRoleRegistry(bool shouldSetup) public { @@ -605,7 +581,7 @@ contract TestSetup is Test, ContractCodeChecker, DepositDataGeneration { addressProviderInstance = new AddressProvider(address(owner)); - liquidityPoolImplementation = new LiquidityPool(); + liquidityPoolImplementation = new LiquidityPool(address(0x0)); liquidityPoolProxy = new UUPSProxy(address(liquidityPoolImplementation),""); liquidityPoolInstance = LiquidityPool(payable(address(liquidityPoolProxy))); @@ -696,7 +672,11 @@ contract TestSetup is Test, ContractCodeChecker, DepositDataGeneration { etherFiRestakerProxy = new UUPSProxy(address(etherFiRestakerImplementation), ""); etherFiRestakerInstance = EtherFiRestaker(payable(etherFiRestakerProxy)); - etherFiRedemptionManagerProxy = new UUPSProxy(address(new EtherFiRedemptionManager(address(liquidityPoolInstance), address(eETHInstance), address(weEthInstance), address(treasuryInstance), address(roleRegistryInstance), address(etherFiRestakerInstance))), ""); + priorityQueueImplementation = new PriorityWithdrawalQueue(address(liquidityPoolInstance), address(eETHInstance), address(roleRegistryInstance), address(treasuryInstance), 1 hours); + UUPSProxy priorityQueueProxy = new UUPSProxy(address(priorityQueueImplementation), ""); + priorityQueueInstance = PriorityWithdrawalQueue(address(priorityQueueProxy)); + + etherFiRedemptionManagerProxy = new UUPSProxy(address(new EtherFiRedemptionManager(address(liquidityPoolInstance), address(eETHInstance), address(weEthInstance), address(treasuryInstance), address(roleRegistryInstance), address(etherFiRestakerInstance), address(priorityQueueInstance))), ""); etherFiRedemptionManagerInstance = EtherFiRedemptionManager(payable(etherFiRedemptionManagerProxy)); roleRegistryInstance.grantRole(keccak256("ETHERFI_REDEMPTION_MANAGER_ADMIN_ROLE"), owner); // etherFiRedemptionManagerInstance.initializeTokenParameters(_tokens, _exitFeeSplitToTreasuryInBps, _exitFeeInBps, _lowWatermarkInBpsOfTvl, _bucketCapacity, _bucketRefillRate); @@ -913,7 +893,10 @@ contract TestSetup is Test, ContractCodeChecker, DepositDataGeneration { // upgrade our existing contracts to utilize `roleRegistry` vm.stopPrank(); vm.startPrank(owner); - EtherFiRedemptionManager etherFiRedemptionManagerImplementation = new EtherFiRedemptionManager(address(liquidityPoolInstance), address(eETHInstance), address(weEthInstance), address(treasuryInstance), address(roleRegistryInstance), address(etherFiRestakerInstance)); + PriorityWithdrawalQueue priorityQueueImplementation = new PriorityWithdrawalQueue(address(liquidityPoolInstance), address(eETHInstance), address(roleRegistryInstance), address(treasuryInstance), 1 hours); + UUPSProxy priorityQueueProxy = new UUPSProxy(address(priorityQueueImplementation), ""); + priorityQueueInstance = PriorityWithdrawalQueue(address(priorityQueueProxy)); + EtherFiRedemptionManager etherFiRedemptionManagerImplementation = new EtherFiRedemptionManager(address(liquidityPoolInstance), address(eETHInstance), address(weEthInstance), address(treasuryInstance), address(roleRegistryInstance), address(etherFiRestakerInstance), address(priorityQueueInstance)); etherFiRedemptionManagerProxy = new UUPSProxy(address(etherFiRedemptionManagerImplementation), ""); etherFiRedemptionManagerInstance = EtherFiRedemptionManager(payable(etherFiRedemptionManagerProxy)); etherFiRedemptionManagerInstance.initialize(10_00, 1_00, 1_00, 5 ether, 0.001 ether); // 10% fee split to treasury, 1% exit fee, 1% low watermark @@ -1566,7 +1549,7 @@ contract TestSetup is Test, ContractCodeChecker, DepositDataGeneration { } function _upgrade_liquidity_pool_contract() internal { - address newImpl = address(new LiquidityPool()); + address newImpl = address(new LiquidityPool(address(0x0))); vm.startPrank(liquidityPoolInstance.owner()); liquidityPoolInstance.upgradeTo(newImpl); vm.stopPrank(); diff --git a/test/behaviour-tests/pectra-fork-tests/EL-withdrawals.t.sol b/test/behaviour-tests/pectra-fork-tests/EL-withdrawals.t.sol index 3d5907bb1..1ab1066de 100644 --- a/test/behaviour-tests/pectra-fork-tests/EL-withdrawals.t.sol +++ b/test/behaviour-tests/pectra-fork-tests/EL-withdrawals.t.sol @@ -67,7 +67,7 @@ contract ELExitsTest is TestSetup { legacyIds[0] = 28689; amounts[0] = 0; - vm.prank(address(etherFiOperatingTimelock)); + vm.prank(address(realElExiter)); etherFiNodesManager.linkLegacyValidatorIds(legacyIds, pubkeys); vm.stopPrank(); @@ -110,7 +110,7 @@ contract ELExitsTest is TestSetup { bytes[] memory linkOnlyOneValidatorPubkeys = new bytes[](1); linkOnlyOneValidatorPubkeys[0] = PK_80194; - vm.prank(address(etherFiOperatingTimelock)); + vm.prank(address(realElExiter)); etherFiNodesManager.linkLegacyValidatorIds(linkOnlyOneValidatorlegacyId, linkOnlyOneValidatorPubkeys); vm.stopPrank(); diff --git a/test/behaviour-tests/prelude.t.sol b/test/behaviour-tests/prelude.t.sol index f50b94321..9c60cf703 100644 --- a/test/behaviour-tests/prelude.t.sol +++ b/test/behaviour-tests/prelude.t.sol @@ -79,7 +79,7 @@ contract PreludeTest is Test, ArrayTestHelper { vm.prank(stakingManager.owner()); stakingManager.upgradeTo(address(stakingManagerImpl)); - LiquidityPool liquidityPoolImpl = new LiquidityPool(); + LiquidityPool liquidityPoolImpl = new LiquidityPool(address(0x0)); vm.prank(LiquidityPool(payable(address(liquidityPool))).owner()); LiquidityPool(payable(address(liquidityPool))).upgradeTo(address(liquidityPoolImpl)); diff --git a/test/fork-tests/validator-key-gen.t.sol b/test/fork-tests/validator-key-gen.t.sol index 5e72aa0d9..49c7ed1c7 100644 --- a/test/fork-tests/validator-key-gen.t.sol +++ b/test/fork-tests/validator-key-gen.t.sol @@ -53,7 +53,7 @@ contract ValidatorKeyGenTest is Test, ArrayTestHelper { vm.prank(stakingManager.owner()); stakingManager.upgradeTo(address(stakingManagerImpl)); - LiquidityPool liquidityPoolImpl = new LiquidityPool(); + LiquidityPool liquidityPoolImpl = new LiquidityPool(address(0x0)); vm.prank(liquidityPool.owner()); liquidityPool.upgradeTo(address(liquidityPoolImpl));