diff --git a/.gitmodules b/.gitmodules index f0b88a65..5a4ca170 100644 --- a/.gitmodules +++ b/.gitmodules @@ -16,3 +16,7 @@ [submodule "lib/chainlink-brownie-contracts"] path = lib/chainlink-brownie-contracts url = https://github.com/smartcontractkit/chainlink-brownie-contracts +[submodule "lib/v3-core"] + path = lib/v3-core + url = https://github.com/Uniswap/v3-core.git + branch = 0.8 diff --git a/lib/v3-core b/lib/v3-core new file mode 160000 index 00000000..6562c52e --- /dev/null +++ b/lib/v3-core @@ -0,0 +1 @@ +Subproject commit 6562c52e8f75f0c10f9deaf44861847585fc8129 diff --git a/remappings.txt b/remappings.txt index e378162c..ea7ac473 100644 --- a/remappings.txt +++ b/remappings.txt @@ -8,4 +8,5 @@ lib/eigenlayer-contracts/=lib/eigenlayer-contracts/ @openzeppelin-upgradeable/contracts/=lib/eigenlayer-contracts/lib/openzeppelin-contracts-upgradeable-v4.9.0/contracts/ @openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol=lib/eigenlayer-contracts/lib/openzeppelin-contracts-v4.9.0/contracts/proxy/transparent/TransparentUpgradeableProxy.sol @openzeppelin/contracts/proxy/beacon/IBeacon.sol=lib/eigenlayer-contracts/lib/openzeppelin-contracts-v4.9.0/contracts/proxy/beacon/IBeacon.sol -@chainlink/contracts/src/interfaces/feeds/=lib/foundry-chainlink-toolkit/src/interfaces/feeds/ \ No newline at end of file +@chainlink/contracts/src/interfaces/feeds/=lib/foundry-chainlink-toolkit/src/interfaces/feeds/ +@uniswap/v3-core/=lib/v3-core/ diff --git a/script/deploy/holesky/Deploy.s.sol b/script/deploy/holesky/Deploy.s.sol index 477bcdd6..f7cc1ae4 100644 --- a/script/deploy/holesky/Deploy.s.sol +++ b/script/deploy/holesky/Deploy.s.sol @@ -1,3 +1,8 @@ + /* +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +TO BE DEPRECATED +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; @@ -20,9 +25,12 @@ import {TokenRegistryOracle} from "../../../src/utils/TokenRegistryOracle.sol"; import {ITokenRegistryOracle} from "../../../src/interfaces/ITokenRegistryOracle.sol"; import {LiquidTokenManager} from "../../../src/core/LiquidTokenManager.sol"; import {ILiquidTokenManager} from "../../../src/interfaces/ILiquidTokenManager.sol"; +import {ILSTSwapRouter} from "../../../src/interfaces/ILSTSwapRouter.sol"; import {StakerNode} from "../../../src/core/StakerNode.sol"; import {StakerNodeCoordinator} from "../../../src/core/StakerNodeCoordinator.sol"; import {IStakerNodeCoordinator} from "../../../src/interfaces/IStakerNodeCoordinator.sol"; +import {WithdrawalManager} from "../../../src/core/WithdrawalManager.sol"; +import {IWithdrawalManager} from "../../../src/interfaces/IWithdrawalManager.sol"; /// @dev To load env file: // source .env @@ -89,6 +97,7 @@ contract Deploy is Script, Test { TokenRegistryOracle tokenRegistryOracleImpl; LiquidTokenManager liquidTokenManagerImpl; StakerNodeCoordinator stakerNodeCoordinatorImpl; + WithdrawalManager withdrawalManagerImpl; StakerNode stakerNodeImpl; // Proxy contracts @@ -96,6 +105,7 @@ contract Deploy is Script, Test { TokenRegistryOracle tokenRegistryOracle; LiquidTokenManager liquidTokenManager; StakerNodeCoordinator stakerNodeCoordinator; + WithdrawalManager withdrawalManager; // Deployment blocks and timestamps uint256 public proxyAdminDeployBlock; @@ -109,6 +119,8 @@ contract Deploy is Script, Test { uint256 public liquidTokenManagerImplDeployTimestamp; uint256 public stakerNodeCoordinatorImplDeployBlock; uint256 public stakerNodeCoordinatorImplDeployTimestamp; + uint256 public withdrawalManagerImplDeployBlock; + uint256 public withdrawalManagerImplDeployTimestamp; uint256 public stakerNodeImplDeployBlock; uint256 public stakerNodeImplDeployTimestamp; @@ -120,6 +132,8 @@ contract Deploy is Script, Test { uint256 public liquidTokenManagerProxyDeployTimestamp; uint256 public stakerNodeCoordinatorProxyDeployBlock; uint256 public stakerNodeCoordinatorProxyDeployTimestamp; + uint256 public withdrawalManagerProxyDeployBlock; + uint256 public withdrawalManagerProxyDeployTimestamp; // Initialization timestamps and blocks uint256 public tokenRegistryOracleInitBlock; @@ -130,6 +144,8 @@ contract Deploy is Script, Test { uint256 public liquidTokenManagerInitTimestamp; uint256 public stakerNodeCoordinatorInitBlock; uint256 public stakerNodeCoordinatorInitTimestamp; + uint256 public withdrawalManagerInitBlock; + uint256 public withdrawalManagerInitTimestamp; uint256 public oracleSalt; function run(string memory deployConfigFileName) external { @@ -156,7 +172,7 @@ contract Deploy is Script, Test { } // Helper function to count array entries - function _countTokens(string memory deployConfigData) internal returns (uint256) { + function _countTokens(string memory deployConfigData) internal view returns (uint256) { uint256 i = 0; while (true) { string memory prefix = string.concat(".tokens[", vm.toString(i), "].addresses.token"); @@ -263,6 +279,10 @@ contract Deploy is Script, Test { stakerNodeCoordinatorImplDeployTimestamp = block.timestamp; stakerNodeCoordinatorImpl = new StakerNodeCoordinator(); + withdrawalManagerImplDeployBlock = block.number; + withdrawalManagerImplDeployTimestamp = block.timestamp; + withdrawalManagerImpl = new WithdrawalManager(); + stakerNodeImplDeployBlock = block.number; stakerNodeImplDeployTimestamp = block.timestamp; stakerNodeImpl = new StakerNode(); @@ -278,7 +298,7 @@ contract Deploy is Script, Test { liquidTokenManagerProxyDeployBlock = block.number; liquidTokenManagerProxyDeployTimestamp = block.timestamp; liquidTokenManager = LiquidTokenManager( - address(new TransparentUpgradeableProxy(address(liquidTokenManagerImpl), address(proxyAdmin), "")) + payable(address(new TransparentUpgradeableProxy(address(liquidTokenManagerImpl), address(proxyAdmin), ""))) ); stakerNodeCoordinatorProxyDeployBlock = block.number; @@ -292,6 +312,12 @@ contract Deploy is Script, Test { liquidToken = LiquidToken( address(new TransparentUpgradeableProxy(address(liquidTokenImpl), address(proxyAdmin), "")) ); + + withdrawalManagerProxyDeployBlock = block.number; + withdrawalManagerProxyDeployTimestamp = block.timestamp; + withdrawalManager = WithdrawalManager( + address(new TransparentUpgradeableProxy(address(withdrawalManagerImpl), address(proxyAdmin), "")) + ); } function initializeProxies() internal { @@ -299,6 +325,7 @@ contract Deploy is Script, Test { _initializeLiquidTokenManager(); _initializeStakerNodeCoordinator(); _initializeLiquidToken(); + _initializeWithdrawalManager(); } function _initializeTokenRegistryOracle() internal { @@ -327,6 +354,8 @@ contract Deploy is Script, Test { delegationManager: IDelegationManager(delegationManager), stakerNodeCoordinator: stakerNodeCoordinator, tokenRegistryOracle: ITokenRegistryOracle(address(tokenRegistryOracle)), + withdrawalManager: withdrawalManager, + lstSwapRouter: ILSTSwapRouter(address(0)), // Will be set later initialOwner: msg.sender, // burner, will transfer to admin strategyController: admin, priceUpdater: address(tokenRegistryOracle) @@ -340,6 +369,7 @@ contract Deploy is Script, Test { stakerNodeCoordinator.initialize( IStakerNodeCoordinator.Init({ liquidTokenManager: liquidTokenManager, + withdrawalManager: withdrawalManager, strategyManager: IStrategyManager(strategyManager), delegationManager: IDelegationManager(delegationManager), maxNodes: STAKER_NODE_COORDINATOR_MAX_NODES, @@ -362,7 +392,22 @@ contract Deploy is Script, Test { initialOwner: admin, pauser: pauser, liquidTokenManager: ILiquidTokenManager(address(liquidTokenManager)), - tokenRegistryOracle: ITokenRegistryOracle(address(tokenRegistryOracle)) + tokenRegistryOracle: ITokenRegistryOracle(address(tokenRegistryOracle)), + withdrawalManager: withdrawalManager + }) + ); + } + + function _initializeWithdrawalManager() internal { + withdrawalManagerInitBlock = block.number; + withdrawalManagerInitTimestamp = block.timestamp; + withdrawalManager.initialize( + IWithdrawalManager.Init({ + initialOwner: admin, + delegationManager: IDelegationManager(delegationManager), + liquidToken: liquidToken, + liquidTokenManager: liquidTokenManager, + stakerNodeCoordinator: stakerNodeCoordinator }) ); } @@ -460,6 +505,12 @@ contract Deploy is Script, Test { _getImplementationFromProxy(address(tokenRegistryOracle)) == address(tokenRegistryOracleImpl), "TokenRegistryOracle proxy implementation mismatch" ); + + // WithdrawalManager + require( + _getImplementationFromProxy(address(withdrawalManager)) == address(withdrawalManagerImpl), + "WithdrawalManager proxy implementation mismatch" + ); } function _verifyContractConnections() internal view { @@ -511,6 +562,24 @@ contract Deploy is Script, Test { "TokenRegistryOracle: wrong liquidTokenManager" ); + // WithdrawalManager + require( + address(withdrawalManager.delegationManager()) == address(delegationManager), + "WithdrawalManager: wrong delegationManager" + ); + require( + address(withdrawalManager.liquidToken()) == address(liquidToken), + "WithdrawalManager: wrong liquidToken" + ); + require( + address(withdrawalManager.liquidTokenManager()) == address(liquidTokenManager), + "WithdrawalManager: wrong liquidTokenManager" + ); + require( + address(withdrawalManager.stakerNodeCoordinator()) == address(stakerNodeCoordinator), + "WithdrawalManager: wrong stakerNodeCoordinator" + ); + // Assets and strategies IERC20[] memory registeredTokens = liquidTokenManager.getSupportedTokens(); TokenConfig[] memory addableTokens = tokens; @@ -594,11 +663,13 @@ contract Deploy is Script, Test { tokenRegistryOracle.hasRole(tokenRegistryOracle.RATE_UPDATER_ROLE(), priceUpdater), "Rate Updater role not assigned to priceUpdater in TokenRegistryOracle" ); - require( tokenRegistryOracle.hasRole(tokenRegistryOracle.RATE_UPDATER_ROLE(), address(liquidToken)), "Rate Updater role not assigned to LiquidToken in TokenRegistryOracle" ); + + // WithdrawalManager + require(withdrawalManager.hasRole(adminRole, admin), "Admin role not assigned in WithdrawalManager"); } function writeDeploymentOutput() internal { @@ -663,9 +734,20 @@ contract Deploy is Script, Test { tokenRegistryOracleImplDeployTimestamp * 1000 ); + // WithdrawalManager implementation + string memory withdrawalManagerImpl_obj = "withdrawalManager"; + vm.serializeAddress(withdrawalManagerImpl_obj, "address", address(withdrawalManagerImpl)); + vm.serializeUint(withdrawalManagerImpl_obj, "block", withdrawalManagerImplDeployBlock); + string memory withdrawalManagerImpl_output = vm.serializeUint( + withdrawalManagerImpl_obj, + "timestamp", + withdrawalManagerImplDeployTimestamp * 1000 + ); + // Combine all implementation objects vm.serializeString(implementation, "liquidTokenManager", liquidTokenManagerImpl_output); vm.serializeString(implementation, "stakerNodeCoordinator", stakerNodeCoordinatorImpl_output); + vm.serializeString(implementation, "withdrawalManager", withdrawalManagerImpl_output); vm.serializeString(implementation, "stakerNode", stakerNodeImpl_output); string memory implementation_output = vm.serializeString( implementation, @@ -706,10 +788,21 @@ contract Deploy is Script, Test { tokenRegistryOracleProxyDeployTimestamp * 1000 ); + // WithdrawalManager proxy + string memory withdrawalManager_obj = "withdrawalManager"; + vm.serializeAddress(withdrawalManager_obj, "address", address(withdrawalManager)); + vm.serializeUint(withdrawalManager_obj, "block", withdrawalManagerProxyDeployBlock); + string memory withdrawalManager_output = vm.serializeUint( + withdrawalManager_obj, + "timestamp", + withdrawalManagerProxyDeployTimestamp * 1000 + ); + // Combine all proxy objects vm.serializeString(proxy, "liquidTokenManager", liquidTokenManager_output); vm.serializeString(proxy, "stakerNodeCoordinator", stakerNodeCoordinator_output); - string memory proxy_output = vm.serializeString(proxy, "tokenRegistryOracle", tokenRegistryOracle_output); + vm.serializeString(proxy, "tokenRegistryOracle", tokenRegistryOracle_output); + string memory proxy_output = vm.serializeString(proxy, "withdrawalManager", withdrawalManager_output); // Combine implementation and proxy under contractDeployments vm.serializeString(contractDeployments, "implementation", implementation_output); @@ -769,3 +862,5 @@ contract Deploy is Script, Test { ); } } + +*/ diff --git a/script/deploy/local/Deploy.s.sol b/script/deploy/local/Deploy.s.sol index 3676f6ec..c608736e 100644 --- a/script/deploy/local/Deploy.s.sol +++ b/script/deploy/local/Deploy.s.sol @@ -1,3 +1,8 @@ + /* +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +TO BE DEPRECATED +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; @@ -20,9 +25,12 @@ import {TokenRegistryOracle} from "../../../src/utils/TokenRegistryOracle.sol"; import {ITokenRegistryOracle} from "../../../src/interfaces/ITokenRegistryOracle.sol"; import {LiquidTokenManager} from "../../../src/core/LiquidTokenManager.sol"; import {ILiquidTokenManager} from "../../../src/interfaces/ILiquidTokenManager.sol"; +import {ILSTSwapRouter} from "../../../src/interfaces/ILSTSwapRouter.sol"; import {StakerNode} from "../../../src/core/StakerNode.sol"; import {StakerNodeCoordinator} from "../../../src/core/StakerNodeCoordinator.sol"; import {IStakerNodeCoordinator} from "../../../src/interfaces/IStakerNodeCoordinator.sol"; +import {WithdrawalManager} from "../../../src/core/WithdrawalManager.sol"; +import {IWithdrawalManager} from "../../../src/interfaces/IWithdrawalManager.sol"; /// @dev To load env file: // source .env @@ -89,6 +97,7 @@ contract Deploy is Script, Test { TokenRegistryOracle tokenRegistryOracleImpl; LiquidTokenManager liquidTokenManagerImpl; StakerNodeCoordinator stakerNodeCoordinatorImpl; + WithdrawalManager withdrawalManagerImpl; StakerNode stakerNodeImpl; // Proxy contracts @@ -96,6 +105,7 @@ contract Deploy is Script, Test { TokenRegistryOracle tokenRegistryOracle; LiquidTokenManager liquidTokenManager; StakerNodeCoordinator stakerNodeCoordinator; + WithdrawalManager withdrawalManager; // Deployment blocks and timestamps uint256 public proxyAdminDeployBlock; @@ -109,6 +119,8 @@ contract Deploy is Script, Test { uint256 public liquidTokenManagerImplDeployTimestamp; uint256 public stakerNodeCoordinatorImplDeployBlock; uint256 public stakerNodeCoordinatorImplDeployTimestamp; + uint256 public withdrawalManagerImplDeployBlock; + uint256 public withdrawalManagerImplDeployTimestamp; uint256 public stakerNodeImplDeployBlock; uint256 public stakerNodeImplDeployTimestamp; @@ -120,6 +132,8 @@ contract Deploy is Script, Test { uint256 public liquidTokenManagerProxyDeployTimestamp; uint256 public stakerNodeCoordinatorProxyDeployBlock; uint256 public stakerNodeCoordinatorProxyDeployTimestamp; + uint256 public withdrawalManagerProxyDeployBlock; + uint256 public withdrawalManagerProxyDeployTimestamp; // Initialization timestamps and blocks uint256 public tokenRegistryOracleInitBlock; @@ -130,6 +144,8 @@ contract Deploy is Script, Test { uint256 public liquidTokenManagerInitTimestamp; uint256 public stakerNodeCoordinatorInitBlock; uint256 public stakerNodeCoordinatorInitTimestamp; + uint256 public withdrawalManagerInitBlock; + uint256 public withdrawalManagerInitTimestamp; uint256 public oracleSalt; function run(string memory deployConfigFileName, string memory chain) external { @@ -156,7 +172,7 @@ contract Deploy is Script, Test { } // Helper function to count array entries - function _countTokens(string memory deployConfigData) internal returns (uint256) { + function _countTokens(string memory deployConfigData) internal view returns (uint256) { uint256 i = 0; while (true) { string memory prefix = string.concat(".tokens[", vm.toString(i), "].addresses.token"); @@ -263,6 +279,10 @@ contract Deploy is Script, Test { stakerNodeCoordinatorImplDeployTimestamp = block.timestamp; stakerNodeCoordinatorImpl = new StakerNodeCoordinator(); + withdrawalManagerImplDeployBlock = block.number; + withdrawalManagerImplDeployTimestamp = block.timestamp; + withdrawalManagerImpl = new WithdrawalManager(); + stakerNodeImplDeployBlock = block.number; stakerNodeImplDeployTimestamp = block.timestamp; stakerNodeImpl = new StakerNode(); @@ -278,7 +298,7 @@ contract Deploy is Script, Test { liquidTokenManagerProxyDeployBlock = block.number; liquidTokenManagerProxyDeployTimestamp = block.timestamp; liquidTokenManager = LiquidTokenManager( - address(new TransparentUpgradeableProxy(address(liquidTokenManagerImpl), address(proxyAdmin), "")) + payable(address(new TransparentUpgradeableProxy(address(liquidTokenManagerImpl), address(proxyAdmin), ""))) ); stakerNodeCoordinatorProxyDeployBlock = block.number; @@ -292,6 +312,12 @@ contract Deploy is Script, Test { liquidToken = LiquidToken( address(new TransparentUpgradeableProxy(address(liquidTokenImpl), address(proxyAdmin), "")) ); + + withdrawalManagerProxyDeployBlock = block.number; + withdrawalManagerProxyDeployTimestamp = block.timestamp; + withdrawalManager = WithdrawalManager( + address(new TransparentUpgradeableProxy(address(withdrawalManagerImpl), address(proxyAdmin), "")) + ); } function initializeProxies() internal { @@ -299,6 +325,7 @@ contract Deploy is Script, Test { _initializeLiquidTokenManager(); _initializeStakerNodeCoordinator(); _initializeLiquidToken(); + _initializeWithdrawalManager(); } function _initializeTokenRegistryOracle() internal { @@ -327,6 +354,8 @@ contract Deploy is Script, Test { delegationManager: IDelegationManager(delegationManager), stakerNodeCoordinator: stakerNodeCoordinator, tokenRegistryOracle: ITokenRegistryOracle(address(tokenRegistryOracle)), + withdrawalManager: withdrawalManager, + lstSwapRouter: ILSTSwapRouter(address(0)), // Will be set later initialOwner: msg.sender, // burner, will transfer to admin strategyController: admin, priceUpdater: address(tokenRegistryOracle) @@ -340,6 +369,7 @@ contract Deploy is Script, Test { stakerNodeCoordinator.initialize( IStakerNodeCoordinator.Init({ liquidTokenManager: liquidTokenManager, + withdrawalManager: withdrawalManager, strategyManager: IStrategyManager(strategyManager), delegationManager: IDelegationManager(delegationManager), maxNodes: STAKER_NODE_COORDINATOR_MAX_NODES, @@ -362,7 +392,22 @@ contract Deploy is Script, Test { initialOwner: admin, pauser: pauser, liquidTokenManager: ILiquidTokenManager(address(liquidTokenManager)), - tokenRegistryOracle: ITokenRegistryOracle(address(tokenRegistryOracle)) + tokenRegistryOracle: ITokenRegistryOracle(address(tokenRegistryOracle)), + withdrawalManager: withdrawalManager + }) + ); + } + + function _initializeWithdrawalManager() internal { + withdrawalManagerInitBlock = block.number; + withdrawalManagerInitTimestamp = block.timestamp; + withdrawalManager.initialize( + IWithdrawalManager.Init({ + initialOwner: admin, + delegationManager: IDelegationManager(delegationManager), + liquidToken: liquidToken, + liquidTokenManager: liquidTokenManager, + stakerNodeCoordinator: stakerNodeCoordinator }) ); } @@ -460,6 +505,12 @@ contract Deploy is Script, Test { _getImplementationFromProxy(address(tokenRegistryOracle)) == address(tokenRegistryOracleImpl), "TokenRegistryOracle proxy implementation mismatch" ); + + // WithdrawalManager + require( + _getImplementationFromProxy(address(withdrawalManager)) == address(withdrawalManagerImpl), + "WithdrawalManager proxy implementation mismatch" + ); } function _verifyContractConnections() internal view { @@ -511,6 +562,24 @@ contract Deploy is Script, Test { "TokenRegistryOracle: wrong liquidTokenManager" ); + // WithdrawalManager + require( + address(withdrawalManager.delegationManager()) == address(delegationManager), + "WithdrawalManager: wrong delegationManager" + ); + require( + address(withdrawalManager.liquidToken()) == address(liquidToken), + "WithdrawalManager: wrong liquidToken" + ); + require( + address(withdrawalManager.liquidTokenManager()) == address(liquidTokenManager), + "WithdrawalManager: wrong liquidTokenManager" + ); + require( + address(withdrawalManager.stakerNodeCoordinator()) == address(stakerNodeCoordinator), + "WithdrawalManager: wrong stakerNodeCoordinator" + ); + // Assets and strategies IERC20[] memory registeredTokens = liquidTokenManager.getSupportedTokens(); TokenConfig[] memory addableTokens = tokens; @@ -594,11 +663,13 @@ contract Deploy is Script, Test { tokenRegistryOracle.hasRole(tokenRegistryOracle.RATE_UPDATER_ROLE(), priceUpdater), "Rate Updater role not assigned to priceUpdater in TokenRegistryOracle" ); - require( tokenRegistryOracle.hasRole(tokenRegistryOracle.RATE_UPDATER_ROLE(), address(liquidToken)), "Rate Updater role not assigned to LiquidToken in TokenRegistryOracle" ); + + // WithdrawalManager + require(withdrawalManager.hasRole(adminRole, admin), "Admin role not assigned in WithdrawalManager"); } function writeDeploymentOutput() internal { @@ -663,9 +734,20 @@ contract Deploy is Script, Test { tokenRegistryOracleImplDeployTimestamp * 1000 ); + // WithdrawalManager implementation + string memory withdrawalManagerImpl_obj = "withdrawalManager"; + vm.serializeAddress(withdrawalManagerImpl_obj, "address", address(withdrawalManagerImpl)); + vm.serializeUint(withdrawalManagerImpl_obj, "block", withdrawalManagerImplDeployBlock); + string memory withdrawalManagerImpl_output = vm.serializeUint( + withdrawalManagerImpl_obj, + "timestamp", + withdrawalManagerImplDeployTimestamp * 1000 + ); + // Combine all implementation objects vm.serializeString(implementation, "liquidTokenManager", liquidTokenManagerImpl_output); vm.serializeString(implementation, "stakerNodeCoordinator", stakerNodeCoordinatorImpl_output); + vm.serializeString(implementation, "withdrawalManager", withdrawalManagerImpl_output); vm.serializeString(implementation, "stakerNode", stakerNodeImpl_output); string memory implementation_output = vm.serializeString( implementation, @@ -706,10 +788,21 @@ contract Deploy is Script, Test { tokenRegistryOracleProxyDeployTimestamp * 1000 ); + // WithdrawalManager proxy + string memory withdrawalManager_obj = "withdrawalManager"; + vm.serializeAddress(withdrawalManager_obj, "address", address(withdrawalManager)); + vm.serializeUint(withdrawalManager_obj, "block", withdrawalManagerProxyDeployBlock); + string memory withdrawalManager_output = vm.serializeUint( + withdrawalManager_obj, + "timestamp", + withdrawalManagerProxyDeployTimestamp * 1000 + ); + // Combine all proxy objects vm.serializeString(proxy, "liquidTokenManager", liquidTokenManager_output); vm.serializeString(proxy, "stakerNodeCoordinator", stakerNodeCoordinator_output); - string memory proxy_output = vm.serializeString(proxy, "tokenRegistryOracle", tokenRegistryOracle_output); + vm.serializeString(proxy, "tokenRegistryOracle", tokenRegistryOracle_output); + string memory proxy_output = vm.serializeString(proxy, "withdrawalManager", withdrawalManager_output); // Combine implementation and proxy under contractDeployments vm.serializeString(contractDeployments, "implementation", implementation_output); @@ -769,3 +862,5 @@ contract Deploy is Script, Test { ); } } + +*/ diff --git a/script/deploy/mainnet/Deploy.s.sol b/script/deploy/mainnet/Deploy.s.sol index 2c13ef88..8924dd25 100644 --- a/script/deploy/mainnet/Deploy.s.sol +++ b/script/deploy/mainnet/Deploy.s.sol @@ -1,3 +1,8 @@ + /* +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +TO BE DEPRECATED +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; @@ -20,9 +25,12 @@ import {TokenRegistryOracle} from "../../../src/utils/TokenRegistryOracle.sol"; import {ITokenRegistryOracle} from "../../../src/interfaces/ITokenRegistryOracle.sol"; import {LiquidTokenManager} from "../../../src/core/LiquidTokenManager.sol"; import {ILiquidTokenManager} from "../../../src/interfaces/ILiquidTokenManager.sol"; +import {ILSTSwapRouter} from "../../../src/interfaces/ILSTSwapRouter.sol"; import {StakerNode} from "../../../src/core/StakerNode.sol"; import {StakerNodeCoordinator} from "../../../src/core/StakerNodeCoordinator.sol"; import {IStakerNodeCoordinator} from "../../../src/interfaces/IStakerNodeCoordinator.sol"; +import {WithdrawalManager} from "../../../src/core/WithdrawalManager.sol"; +import {IWithdrawalManager} from "../../../src/interfaces/IWithdrawalManager.sol"; /// @dev To load env file: // source .env @@ -89,6 +97,7 @@ contract Deploy is Script, Test { TokenRegistryOracle tokenRegistryOracleImpl; LiquidTokenManager liquidTokenManagerImpl; StakerNodeCoordinator stakerNodeCoordinatorImpl; + WithdrawalManager withdrawalManagerImpl; StakerNode stakerNodeImpl; // Proxy contracts @@ -96,6 +105,7 @@ contract Deploy is Script, Test { TokenRegistryOracle tokenRegistryOracle; LiquidTokenManager liquidTokenManager; StakerNodeCoordinator stakerNodeCoordinator; + WithdrawalManager withdrawalManager; // Deployment blocks and timestamps uint256 public proxyAdminDeployBlock; @@ -109,6 +119,8 @@ contract Deploy is Script, Test { uint256 public liquidTokenManagerImplDeployTimestamp; uint256 public stakerNodeCoordinatorImplDeployBlock; uint256 public stakerNodeCoordinatorImplDeployTimestamp; + uint256 public withdrawalManagerImplDeployBlock; + uint256 public withdrawalManagerImplDeployTimestamp; uint256 public stakerNodeImplDeployBlock; uint256 public stakerNodeImplDeployTimestamp; @@ -120,6 +132,8 @@ contract Deploy is Script, Test { uint256 public liquidTokenManagerProxyDeployTimestamp; uint256 public stakerNodeCoordinatorProxyDeployBlock; uint256 public stakerNodeCoordinatorProxyDeployTimestamp; + uint256 public withdrawalManagerProxyDeployBlock; + uint256 public withdrawalManagerProxyDeployTimestamp; // Initialization timestamps and blocks uint256 public tokenRegistryOracleInitBlock; @@ -130,6 +144,8 @@ contract Deploy is Script, Test { uint256 public liquidTokenManagerInitTimestamp; uint256 public stakerNodeCoordinatorInitBlock; uint256 public stakerNodeCoordinatorInitTimestamp; + uint256 public withdrawalManagerInitBlock; + uint256 public withdrawalManagerInitTimestamp; uint256 public oracleSalt; function run(string memory deployConfigFileName) external { @@ -156,7 +172,7 @@ contract Deploy is Script, Test { } // Helper function to count array entries - function _countTokens(string memory deployConfigData) internal returns (uint256) { + function _countTokens(string memory deployConfigData) internal view returns (uint256) { uint256 i = 0; while (true) { string memory prefix = string.concat(".tokens[", vm.toString(i), "].addresses.token"); @@ -263,6 +279,10 @@ contract Deploy is Script, Test { stakerNodeCoordinatorImplDeployTimestamp = block.timestamp; stakerNodeCoordinatorImpl = new StakerNodeCoordinator(); + withdrawalManagerImplDeployBlock = block.number; + withdrawalManagerImplDeployTimestamp = block.timestamp; + withdrawalManagerImpl = new WithdrawalManager(); + stakerNodeImplDeployBlock = block.number; stakerNodeImplDeployTimestamp = block.timestamp; stakerNodeImpl = new StakerNode(); @@ -278,7 +298,7 @@ contract Deploy is Script, Test { liquidTokenManagerProxyDeployBlock = block.number; liquidTokenManagerProxyDeployTimestamp = block.timestamp; liquidTokenManager = LiquidTokenManager( - address(new TransparentUpgradeableProxy(address(liquidTokenManagerImpl), address(proxyAdmin), "")) + payable(address(new TransparentUpgradeableProxy(address(liquidTokenManagerImpl), address(proxyAdmin), ""))) ); stakerNodeCoordinatorProxyDeployBlock = block.number; @@ -292,6 +312,12 @@ contract Deploy is Script, Test { liquidToken = LiquidToken( address(new TransparentUpgradeableProxy(address(liquidTokenImpl), address(proxyAdmin), "")) ); + + withdrawalManagerProxyDeployBlock = block.number; + withdrawalManagerProxyDeployTimestamp = block.timestamp; + withdrawalManager = WithdrawalManager( + address(new TransparentUpgradeableProxy(address(withdrawalManagerImpl), address(proxyAdmin), "")) + ); } function initializeProxies() internal { @@ -299,6 +325,7 @@ contract Deploy is Script, Test { _initializeLiquidTokenManager(); _initializeStakerNodeCoordinator(); _initializeLiquidToken(); + _initializeWithdrawalManager(); } function _initializeTokenRegistryOracle() internal { @@ -327,6 +354,8 @@ contract Deploy is Script, Test { delegationManager: IDelegationManager(delegationManager), stakerNodeCoordinator: stakerNodeCoordinator, tokenRegistryOracle: ITokenRegistryOracle(address(tokenRegistryOracle)), + withdrawalManager: withdrawalManager, + lstSwapRouter: ILSTSwapRouter(address(0)), // Will be set later initialOwner: msg.sender, // burner, will transfer to admin strategyController: admin, priceUpdater: address(tokenRegistryOracle) @@ -340,6 +369,7 @@ contract Deploy is Script, Test { stakerNodeCoordinator.initialize( IStakerNodeCoordinator.Init({ liquidTokenManager: liquidTokenManager, + withdrawalManager: withdrawalManager, strategyManager: IStrategyManager(strategyManager), delegationManager: IDelegationManager(delegationManager), maxNodes: STAKER_NODE_COORDINATOR_MAX_NODES, @@ -362,7 +392,22 @@ contract Deploy is Script, Test { initialOwner: admin, pauser: pauser, liquidTokenManager: ILiquidTokenManager(address(liquidTokenManager)), - tokenRegistryOracle: ITokenRegistryOracle(address(tokenRegistryOracle)) + tokenRegistryOracle: ITokenRegistryOracle(address(tokenRegistryOracle)), + withdrawalManager: withdrawalManager + }) + ); + } + + function _initializeWithdrawalManager() internal { + withdrawalManagerInitBlock = block.number; + withdrawalManagerInitTimestamp = block.timestamp; + withdrawalManager.initialize( + IWithdrawalManager.Init({ + initialOwner: admin, + delegationManager: IDelegationManager(delegationManager), + liquidToken: liquidToken, + liquidTokenManager: liquidTokenManager, + stakerNodeCoordinator: stakerNodeCoordinator }) ); } @@ -460,6 +505,12 @@ contract Deploy is Script, Test { _getImplementationFromProxy(address(tokenRegistryOracle)) == address(tokenRegistryOracleImpl), "TokenRegistryOracle proxy implementation mismatch" ); + + // WithdrawalManager + require( + _getImplementationFromProxy(address(withdrawalManager)) == address(withdrawalManagerImpl), + "WithdrawalManager proxy implementation mismatch" + ); } function _verifyContractConnections() internal view { @@ -511,6 +562,24 @@ contract Deploy is Script, Test { "TokenRegistryOracle: wrong liquidTokenManager" ); + // WithdrawalManager + require( + address(withdrawalManager.delegationManager()) == address(delegationManager), + "WithdrawalManager: wrong delegationManager" + ); + require( + address(withdrawalManager.liquidToken()) == address(liquidToken), + "WithdrawalManager: wrong liquidToken" + ); + require( + address(withdrawalManager.liquidTokenManager()) == address(liquidTokenManager), + "WithdrawalManager: wrong liquidTokenManager" + ); + require( + address(withdrawalManager.stakerNodeCoordinator()) == address(stakerNodeCoordinator), + "WithdrawalManager: wrong stakerNodeCoordinator" + ); + // Assets and strategies IERC20[] memory registeredTokens = liquidTokenManager.getSupportedTokens(); TokenConfig[] memory addableTokens = tokens; @@ -594,11 +663,13 @@ contract Deploy is Script, Test { tokenRegistryOracle.hasRole(tokenRegistryOracle.RATE_UPDATER_ROLE(), priceUpdater), "Rate Updater role not assigned to priceUpdater in TokenRegistryOracle" ); - require( tokenRegistryOracle.hasRole(tokenRegistryOracle.RATE_UPDATER_ROLE(), address(liquidToken)), "Rate Updater role not assigned to LiquidToken in TokenRegistryOracle" ); + + // WithdrawalManager + require(withdrawalManager.hasRole(adminRole, admin), "Admin role not assigned in WithdrawalManager"); } function writeDeploymentOutput() internal { @@ -663,9 +734,20 @@ contract Deploy is Script, Test { tokenRegistryOracleImplDeployTimestamp * 1000 ); + // WithdrawalManager implementation + string memory withdrawalManagerImpl_obj = "withdrawalManager"; + vm.serializeAddress(withdrawalManagerImpl_obj, "address", address(withdrawalManagerImpl)); + vm.serializeUint(withdrawalManagerImpl_obj, "block", withdrawalManagerImplDeployBlock); + string memory withdrawalManagerImpl_output = vm.serializeUint( + withdrawalManagerImpl_obj, + "timestamp", + withdrawalManagerImplDeployTimestamp * 1000 + ); + // Combine all implementation objects vm.serializeString(implementation, "liquidTokenManager", liquidTokenManagerImpl_output); vm.serializeString(implementation, "stakerNodeCoordinator", stakerNodeCoordinatorImpl_output); + vm.serializeString(implementation, "withdrawalManager", withdrawalManagerImpl_output); vm.serializeString(implementation, "stakerNode", stakerNodeImpl_output); string memory implementation_output = vm.serializeString( implementation, @@ -706,10 +788,21 @@ contract Deploy is Script, Test { tokenRegistryOracleProxyDeployTimestamp * 1000 ); + // WithdrawalManager proxy + string memory withdrawalManager_obj = "withdrawalManager"; + vm.serializeAddress(withdrawalManager_obj, "address", address(withdrawalManager)); + vm.serializeUint(withdrawalManager_obj, "block", withdrawalManagerProxyDeployBlock); + string memory withdrawalManager_output = vm.serializeUint( + withdrawalManager_obj, + "timestamp", + withdrawalManagerProxyDeployTimestamp * 1000 + ); + // Combine all proxy objects vm.serializeString(proxy, "liquidTokenManager", liquidTokenManager_output); vm.serializeString(proxy, "stakerNodeCoordinator", stakerNodeCoordinator_output); - string memory proxy_output = vm.serializeString(proxy, "tokenRegistryOracle", tokenRegistryOracle_output); + vm.serializeString(proxy, "tokenRegistryOracle", tokenRegistryOracle_output); + string memory proxy_output = vm.serializeString(proxy, "withdrawalManager", withdrawalManager_output); // Combine implementation and proxy under contractDeployments vm.serializeString(contractDeployments, "implementation", implementation_output); @@ -769,3 +862,5 @@ contract Deploy is Script, Test { ); } } + +*/ diff --git a/script/tasks/LTM_DelegateNodes.s.sol b/script/tasks/LTM_DelegateNodes.s.sol index 07374ad9..64b8481a 100644 --- a/script/tasks/LTM_DelegateNodes.s.sol +++ b/script/tasks/LTM_DelegateNodes.s.sol @@ -29,11 +29,9 @@ contract DelegateNodes is Script, Test { string memory configPath = string(bytes(string.concat("script/outputs", configFileName))); string memory config = vm.readFile(configPath); - address liquidTokenManageraddress = stdJson.readAddress( - config, - ".contractDeployments.proxy.liquidTokenManager.address" + LiquidTokenManager liquidTokenManager = LiquidTokenManager( + payable(stdJson.readAddress(config, ".contractDeployments.proxy.liquidTokenManager.address")) ); - LiquidTokenManager liquidTokenManager = LiquidTokenManager(liquidTokenManageraddress); // Create default signatures and salts if empty arrays are provided ISignatureUtilsMixinTypes.SignatureWithExpiry[] memory signatures; diff --git a/script/tasks/LTM_StakeAssetsToNode.s.sol b/script/tasks/LTM_StakeAssetsToNode.s.sol index 46ff56ec..5bff3369 100644 --- a/script/tasks/LTM_StakeAssetsToNode.s.sol +++ b/script/tasks/LTM_StakeAssetsToNode.s.sol @@ -32,7 +32,8 @@ contract StakeAssetsToNode is Script, Test { config, ".contractDeployments.proxy.liquidTokenManager.address" ); - LiquidTokenManager liquidTokenManager = LiquidTokenManager(liquidTokenManageraddress); + + LiquidTokenManager liquidTokenManager = LiquidTokenManager(payable(liquidTokenManageraddress)); vm.startBroadcast(); liquidTokenManager.stakeAssetsToNode(nodeId, assets, amounts); diff --git a/script/tasks/LTM_StakeAssetsToNodes.s.sol b/script/tasks/LTM_StakeAssetsToNodes.s.sol index 00c6adb3..13c5f7ad 100644 --- a/script/tasks/LTM_StakeAssetsToNodes.s.sol +++ b/script/tasks/LTM_StakeAssetsToNodes.s.sol @@ -25,7 +25,7 @@ contract StakeAssetsToNodes is Script, Test { config, ".contractDeployments.proxy.liquidTokenManager.address" ); - LiquidTokenManager liquidTokenManager = LiquidTokenManager(liquidTokenManageraddress); + LiquidTokenManager liquidTokenManager = LiquidTokenManager(payable(liquidTokenManageraddress)); vm.startBroadcast(); liquidTokenManager.stakeAssetsToNodes(allocations); diff --git a/src/core/LiquidToken.sol b/src/core/LiquidToken.sol index f58583b6..02578b05 100644 --- a/src/core/LiquidToken.sol +++ b/src/core/LiquidToken.sol @@ -12,6 +12,9 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ILiquidToken} from "../interfaces/ILiquidToken.sol"; import {ILiquidTokenManager} from "../interfaces/ILiquidTokenManager.sol"; import {ITokenRegistryOracle} from "../interfaces/ITokenRegistryOracle.sol"; +import {IWithdrawalManager} from "../interfaces/IWithdrawalManager.sol"; +import {IRewardsManager} from "../interfaces/IRewardsManager.sol"; +import {IStrategy} from "@eigenlayer/contracts/interfaces/IStrategy.sol"; /** * @title LiquidToken @@ -33,22 +36,22 @@ contract LiquidToken is /// @notice Role identifier for pausing the contract bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); - /// @notice LAT contracts + /// @notice v1 LAT contracts ILiquidTokenManager public liquidTokenManager; ITokenRegistryOracle public tokenRegistryOracle; /// @notice Mapping of assets to their corresponding unstaked balances (held in this contract) mapping(address => uint256) public assetBalances; - /// @notice Mapping of tokens to their corresponding queued balances (unused in V1) - mapping(address => uint256) public queuedAssetBalances; + /// @notice Mapping of tokens to their corresponding queued withdrawable shares (post-slashing) + mapping(address => uint256) public queuedAssetElShares; - /** - * @dev OUT OF SCOPE FOR V1 - mapping(bytes32 => WithdrawalRequest) public withdrawalRequests; - mapping(address => bytes32[]) public userWithdrawalRequests; + /// @notice v2 LAT contracts + IWithdrawalManager public withdrawalManager; + IRewardsManager public rewardsManager; + + /// @notice Mapping of user addresses to their corresponding withdrawal nonces mapping(address => uint256) private _withdrawalNonce; - */ // ------------------------------------------------------------------------------ // Init functions @@ -70,7 +73,9 @@ contract LiquidToken is address(init.initialOwner) == address(0) || address(init.pauser) == address(0) || address(init.liquidTokenManager) == address(0) || - address(init.tokenRegistryOracle) == address(0) + address(init.tokenRegistryOracle) == address(0) || + address(init.withdrawalManager) == address(0) || + address(init.rewardsManager) == address(0) ) { revert ZeroAddress(); } @@ -80,6 +85,8 @@ contract LiquidToken is liquidTokenManager = init.liquidTokenManager; tokenRegistryOracle = init.tokenRegistryOracle; + withdrawalManager = init.withdrawalManager; + rewardsManager = init.rewardsManager; } // ------------------------------------------------------------------------------ @@ -163,158 +170,123 @@ contract LiquidToken is return sharesArray; } - /// @dev OUT OF SCOPE FOR V1 - /** - function requestWithdrawal( - IERC20Upgradeable[] memory withdrawAssets, - uint256[] memory shareAmounts - ) external nonReentrant whenNotPaused { - if (withdrawAssets.length != shareAmounts.length) - revert ArrayLengthMismatch(); + /// @inheritdoc ILiquidToken + function initiateWithdrawal( + IERC20[] memory assets, + uint256[] memory amounts + ) external nonReentrant whenNotPaused returns (bytes32) { + if (assets.length != amounts.length) revert ArrayLengthMismatch(); - uint256 len = withdrawAssets.length; - address sender = msg.sender; - uint256 totalShares; + // Check if we have enough funds from staked (pre-slashing) and unstaked balances + // Here we make a UX decision to check pre-slashing `depositShares` on EL, which means caller can ask for the same amount they deposited, and the fn takes care of the actual accounting + // This removes the burden from the caller and from the manager (when calling `settleUserWithdrawals`) to track slashing on the LAT + if (!_previewWithdrawal(assets, amounts)) revert InvalidWithdrawalRequest(); - unchecked { - for (uint256 i = 0; i < len; i++) { - if ( - !liquidTokenManager.tokenIsSupported( - IERC20(address(withdrawAssets[i])) - ) - ) revert UnsupportedAsset(withdrawAssets[i]); - if (shareAmounts[i] == 0) revert ZeroAmount(); - totalShares += shareAmounts[i]; - } + // Calculate the amount of LAT shares to receive from the user in exchange for the withdrawal request with the right to fulfill after a period delay + // We "charge" the user the equivalent at pre-slashing LAT price, to maintain fair pricing regardless of slashing + uint256 totalShares = 0; + uint256[] memory elWithdrawableShares = new uint256[](assets.length); + for (uint256 i = 0; i < assets.length; i++) { + elWithdrawableShares[i] = liquidTokenManager.getWithdrawableAssetAmount(assets[i], amounts[i], true); + if (elWithdrawableShares[i] == 0) revert ZeroAmount(); + + totalShares += calculateSharesNoSlashing(assets[i], amounts[i]); // Charge user at pre-slashing LAT price } - if (balanceOf(sender) < totalShares) - revert InsufficientBalance( - IERC20Upgradeable(address(this)), - totalShares, - balanceOf(sender) - ); + if (totalShares == 0) revert ZeroAmount(); + + if (balanceOf(msg.sender) < totalShares) + revert InsufficientBalance(IERC20(address(this)), totalShares, balanceOf(msg.sender)); bytes32 requestId = keccak256( - abi.encodePacked( - sender, - withdrawAssets, - shareAmounts, - block.timestamp, - block.number, - tx.gasprice, - address(this), - _withdrawalNonce[sender]++ - ) + abi.encodePacked(msg.sender, assets, amounts, block.timestamp, _withdrawalNonce[msg.sender]) ); + _withdrawalNonce[msg.sender] += 1; - if (withdrawalRequests[requestId].user != address(0)) { - revert DuplicateRequestId(requestId); - } + // Receive escrow LAT shares to be burned after withdrawal completion + _transfer(msg.sender, address(this), totalShares); - WithdrawalRequest memory request = WithdrawalRequest({ - user: sender, - assets: withdrawAssets, - shareAmounts: shareAmounts, - requestTime: block.timestamp, - fulfilled: false - }); - - withdrawalRequests[requestId] = request; - userWithdrawalRequests[sender].push(requestId); - - _transfer(sender, address(this), totalShares); - - emit WithdrawalRequested( - requestId, - sender, - withdrawAssets, - shareAmounts, - block.timestamp + // Create a withdrawal request for the user + withdrawalManager.createWithdrawalRequest( + assets, + amounts, + elWithdrawableShares, + totalShares, + msg.sender, + requestId ); + + return requestId; } - */ - /// @dev OUT OF SCOPE FOR V1 - /** - function fulfillWithdrawal(bytes32 requestId) external nonReentrant { - WithdrawalRequest storage request = withdrawalRequests[requestId]; + /// @inheritdoc ILiquidToken + function previewWithdrawal(IERC20[] memory assets, uint256[] memory amounts) external view override returns (bool) { + if (assets.length != amounts.length) revert ArrayLengthMismatch(); + return _previewWithdrawal(assets, amounts); + } - if (request.user != msg.sender) revert InvalidWithdrawalRequest(); - if (block.timestamp < request.requestTime + WITHDRAWAL_DELAY) - revert WithdrawalDelayNotMet(); - if (request.fulfilled) revert WithdrawalAlreadyFulfilled(); + /// @inheritdoc ILiquidToken + function creditQueuedAssetElShares(IERC20[] calldata assets, uint256[] calldata shares) external whenNotPaused { + if (msg.sender != address(liquidTokenManager) && msg.sender != address(withdrawalManager)) + revert UnauthorizedAccess(msg.sender); - request.fulfilled = true; - uint256[] memory amounts = new uint256[](request.assets.length); - uint256 totalShares = 0; + if (assets.length != shares.length) revert ArrayLengthMismatch(); - for (uint256 i = 0; i < request.assets.length; i++) { - amounts[i] = calculateAmount( - request.assets[i], - request.shareAmounts[i] - ); - totalShares += request.shareAmounts[i]; + for (uint256 i = 0; i < assets.length; i++) { + queuedAssetElShares[address(assets[i])] += shares[i]; } + } - for (uint256 i = 0; i < amounts.length; i++) { - uint256 amount = amounts[i]; - IERC20 asset = IERC20(address(request.assets[i])); - - // Check the contract's actual token balance - if (asset.balanceOf(address(this)) < amount) { - revert InsufficientBalance( - request.assets[i], - asset.balanceOf(address(this)), - amount - ); - } - - // Transfer the amount back to the user - asset.safeTransfer(msg.sender, amount); + /// @inheritdoc ILiquidToken + function debitQueuedAssetElShares( + IERC20[] calldata assets, + uint256[] calldata shares, + uint256 latSharesToBurn + ) external whenNotPaused { + if (msg.sender != address(liquidTokenManager) && msg.sender != address(withdrawalManager)) + revert UnauthorizedAccess(msg.sender); - // Reduce the asset balances for the asset - // Note: Make sure that whenever this contract receives funds from EL withdrawal, `queuedAssetBalances` is debited and `assetBalances` is credited - assetBalances[address(asset)] -= amount; + if (assets.length != shares.length) revert ArrayLengthMismatch(); - if (assetBalances[address(asset)] > asset.balanceOf(address(this))) - revert AssetBalanceOutOfSync( - request.assets[i], - assetBalances[address(asset)], - asset.balanceOf(address(this)) - ); + if (latSharesToBurn > 0 && balanceOf(address(this)) < latSharesToBurn) { + revert InsufficientBalance(IERC20(address(this)), latSharesToBurn, balanceOf(address(this))); } - // Burn the shares that were transferred to the contract during the withdrawal request - _burn(address(this), totalShares); + for (uint256 i = 0; i < assets.length; i++) { + queuedAssetElShares[address(assets[i])] -= shares[i]; + } - emit WithdrawalFulfilled( - requestId, - msg.sender, - request.assets, - amounts, - block.timestamp - ); + if (latSharesToBurn > 0) { + _burn(address(this), latSharesToBurn); + } } - */ /// @inheritdoc ILiquidToken - function creditQueuedAssetBalances(IERC20[] calldata assets, uint256[] calldata amounts) external whenNotPaused { - if (msg.sender != address(liquidTokenManager)) revert NotLiquidTokenManager(msg.sender); + function creditAssetBalances(IERC20[] calldata assets, uint256[] calldata amounts) external whenNotPaused { + if (msg.sender != address(liquidTokenManager) && msg.sender != address(rewardsManager)) + revert UnauthorizedAccess(msg.sender); if (assets.length != amounts.length) revert ArrayLengthMismatch(); for (uint256 i = 0; i < assets.length; i++) { - queuedAssetBalances[address(assets[i])] += amounts[i]; + assetBalances[address(assets[i])] += amounts[i]; } } /// @inheritdoc ILiquidToken - function transferAssets(IERC20[] calldata assetsToRetrieve, uint256[] calldata amounts) external whenNotPaused { + function transferAssets( + IERC20[] calldata assetsToRetrieve, + uint256[] calldata amounts, + address receiver + ) external whenNotPaused { if (msg.sender != address(liquidTokenManager)) revert NotLiquidTokenManager(msg.sender); if (assetsToRetrieve.length != amounts.length) revert ArrayLengthMismatch(); + // Only `LiquidTokenManager` and `WithdrawalManager` can receive funds from this contract + if (receiver != address(liquidTokenManager) && receiver != address(withdrawalManager)) + revert InvalidReceiver(receiver); + for (uint256 i = 0; i < assetsToRetrieve.length; i++) { IERC20 asset = assetsToRetrieve[i]; uint256 amount = amounts[i]; @@ -325,7 +297,7 @@ contract LiquidToken is revert InsufficientBalance(asset, assetBalances[address(asset)], amount); assetBalances[address(asset)] -= amount; - asset.safeTransfer(address(liquidTokenManager), amount); + asset.safeTransfer(receiver, amount); if (assetBalances[address(asset)] > asset.balanceOf(address(this))) revert AssetBalanceOutOfSync( @@ -350,49 +322,39 @@ contract LiquidToken is return liquidTokenManager.convertFromUnitOfAccount(asset, amountInUnitOfAccount); } + /// @notice Calculate shares at pre-slashing LAT price + /// @param asset The asset to calculate shares for + /// @param amount The amount of the asset + /// @return shares The number of LAT shares at pre-slashing price + function calculateSharesNoSlashing(IERC20 asset, uint256 amount) public view returns (uint256) { + uint256 assetAmountInUnitOfAccount = liquidTokenManager.convertToUnitOfAccount(asset, amount); + return _convertToSharesNoSlashing(assetAmountInUnitOfAccount); + } + // ------------------------------------------------------------------------------ // Getter functions // ------------------------------------------------------------------------------ - /** - * @dev OUT OF SCOPE FOR V1 - */ - /** - function getUserWithdrawalRequests( - address user - ) external view returns (bytes32[] memory) { - return userWithdrawalRequests[user]; - } - */ - - /** - * @dev OUT OF SCOPE FOR V1 - */ - /** - function getWithdrawalRequest( - bytes32 requestId - ) external view returns (WithdrawalRequest memory) { - return withdrawalRequests[requestId]; - } - */ - /// @inheritdoc ILiquidToken function totalAssets() public view returns (uint256) { IERC20[] memory supportedTokens = liquidTokenManager.getSupportedTokens(); uint256 total = 0; for (uint256 i = 0; i < supportedTokens.length; i++) { - // Unstaked Asset Balances + // Unstaked asset balances total += liquidTokenManager.convertToUnitOfAccount(supportedTokens[i], _balanceAsset(supportedTokens[i])); - // Queued Asset Balances + // Queued asset balances total += liquidTokenManager.convertToUnitOfAccount( supportedTokens[i], _balanceQueuedAsset(supportedTokens[i]) ); - // Staked Withdrawable Asset Balances - total += liquidTokenManager.getWithdrawableAssetBalance(supportedTokens[i]); + // Staked withdrawable asset balances + total += liquidTokenManager.convertToUnitOfAccount( + supportedTokens[i], + liquidTokenManager.getWithdrawableAssetBalance(supportedTokens[i], false) // After any slashing + ); } return total; @@ -446,6 +408,46 @@ contract LiquidToken is return (shares * totalAsset) / supply; } + /// @dev Called by `calculateSharesNoSlashing` + /// @dev Calculate shares using pre-slashing total assets + function _convertToSharesNoSlashing(uint256 amount) internal view returns (uint256) { + uint256 supply = totalSupply(); + uint256 totalAssetPreSlashing = _totalAssetsNoSlashing(); + + // Check for totalAssets being 0 to avoid division by zero + if (supply == 0 || totalAssetPreSlashing == 0) { + return amount; + } + + return (amount * supply) / totalAssetPreSlashing; + } + + /// @dev Called by `_convertToSharesNoSlashing` + /// @dev Calculate total assets as if no slashing occurred + function _totalAssetsNoSlashing() internal view returns (uint256) { + IERC20[] memory supportedTokens = liquidTokenManager.getSupportedTokens(); + + uint256 total = 0; + for (uint256 i = 0; i < supportedTokens.length; i++) { + // Unstaked asset balances + total += liquidTokenManager.convertToUnitOfAccount(supportedTokens[i], _balanceAsset(supportedTokens[i])); + + // Queued asset balances + total += liquidTokenManager.convertToUnitOfAccount( + supportedTokens[i], + _balanceQueuedAsset(supportedTokens[i]) + ); + + // Pre-slashing staked balances + total += liquidTokenManager.convertToUnitOfAccount( + supportedTokens[i], + liquidTokenManager.getDepositAssetBalance(supportedTokens[i], false) // Pre-slashing + ); + } + + return total; + } + /// @dev Called by `balanceAssets` and `totalAssets` function _balanceAsset(IERC20 asset) internal view returns (uint256) { return assetBalances[address(asset)]; @@ -453,7 +455,27 @@ contract LiquidToken is /// @dev Called by `balanceQueuedAssets` and `totalAssets` function _balanceQueuedAsset(IERC20 asset) internal view returns (uint256) { - return queuedAssetBalances[address(asset)]; + uint256 shares = queuedAssetElShares[address(asset)]; + if (shares == 0) return 0; + + return liquidTokenManager.assetSharesToUnderlying(asset, shares); + } + + /// @dev Called by `initiateWithdrawal` and `previewWithdrawal` + function _previewWithdrawal(IERC20[] memory assets, uint256[] memory amounts) internal view returns (bool) { + bool isPossible = true; + for (uint256 i = 0; i < assets.length; i++) { + IERC20 asset = assets[i]; + if ( + (!liquidTokenManager.tokenIsSupported(assets[i])) || + (amounts[i] == 0) || + (assetBalances[address(asset)] + liquidTokenManager.getDepositAssetBalance(asset, false)) < amounts[i] // Preview with pre-slashing balances + ) { + isPossible = false; + break; + } + } + return isPossible; } // ------------------------------------------------------------------------------ diff --git a/src/core/LiquidTokenManager.sol b/src/core/LiquidTokenManager.sol index f00dc2ab..d5810133 100644 --- a/src/core/LiquidTokenManager.sol +++ b/src/core/LiquidTokenManager.sol @@ -6,6 +6,7 @@ import {AccessControlUpgradeable} from "@openzeppelin-upgradeable/contracts/acce import {ReentrancyGuardUpgradeable} from "@openzeppelin-upgradeable/contracts/security/ReentrancyGuardUpgradeable.sol"; import {IStrategyManager} from "@eigenlayer/contracts/interfaces/IStrategyManager.sol"; import {IDelegationManager} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; +import {IDelegationManagerTypes} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; import {IStrategy} from "@eigenlayer/contracts/interfaces/IStrategy.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; @@ -18,6 +19,7 @@ import {ILiquidTokenManager} from "../interfaces/ILiquidTokenManager.sol"; import {IStakerNode} from "../interfaces/IStakerNode.sol"; import {IStakerNodeCoordinator} from "../interfaces/IStakerNodeCoordinator.sol"; import {ITokenRegistryOracle} from "../interfaces/ITokenRegistryOracle.sol"; +import {IWithdrawalManager} from "../interfaces/IWithdrawalManager.sol"; /// @title LiquidTokenManager /// @notice Manages liquid tokens and their staking to EigenLayer strategies @@ -40,14 +42,11 @@ contract LiquidTokenManager is /// @notice Role identifier for asset price update operations bytes32 public constant PRICE_UPDATER_ROLE = keccak256("PRICE_UPDATER_ROLE"); - /// @notice Number of decimal places used for price representation - uint256 public constant PRICE_DECIMALS = 18; - /// @notice EigenLayer contracts IStrategyManager public strategyManager; IDelegationManager public delegationManager; - /// @notice LAT contracts + /// @notice v1 LAT contracts ILiquidToken public liquidToken; IStakerNodeCoordinator public stakerNodeCoordinator; ITokenRegistryOracle public tokenRegistryOracle; @@ -64,6 +63,12 @@ contract LiquidTokenManager is /// @notice Array of supported token addresses IERC20[] public supportedTokens; + /// @notice v2 contracts + IWithdrawalManager public withdrawalManager; + + /// @notice Total redemptions created + uint256 private _redemptionNonce; + // ------------------------------------------------------------------------------ // Init functions // ------------------------------------------------------------------------------ @@ -84,7 +89,8 @@ contract LiquidTokenManager is address(init.liquidToken) == address(0) || address(init.initialOwner) == address(0) || address(init.priceUpdater) == address(0) || - address(init.tokenRegistryOracle) == address(0) + address(init.tokenRegistryOracle) == address(0) || + address(init.withdrawalManager) == address(0) ) { revert ZeroAddress(); } @@ -98,6 +104,7 @@ contract LiquidTokenManager is strategyManager = init.strategyManager; delegationManager = init.delegationManager; tokenRegistryOracle = init.tokenRegistryOracle; + withdrawalManager = init.withdrawalManager; } // ------------------------------------------------------------------------------ @@ -128,7 +135,7 @@ contract LiquidTokenManager is // Price source validation and configuration bool isNative = (primaryType == 0 && primarySource == address(0)); - if (!isNative && (primaryType < 1 || primaryType > 3)) revert InvalidPriceSource(); + if (!isNative && (primaryType < 1 || primaryType > 5)) revert InvalidPriceSource(); if (!isNative && primarySource == address(0)) revert InvalidPriceSource(); if (!isNative) { tokenRegistryOracle.configureToken( @@ -186,7 +193,7 @@ contract LiquidTokenManager is unchecked { for (uint256 i = 0; i < len; i++) { - uint256 stakedWithdrawableBalance = getWithdrawableAssetBalanceNode(token, nodes[i].getId()); + uint256 stakedWithdrawableBalance = getWithdrawableAssetBalanceNode(token, nodes[i].getId(), true); if (stakedWithdrawableBalance > 0) { revert TokenInUse(token); } @@ -317,7 +324,7 @@ contract LiquidTokenManager is } // Bring unstaked assets in from `LiquidToken` - liquidToken.transferAssets(assets, amounts); + liquidToken.transferAssets(assets, amounts, address(this)); IERC20[] memory depositAssets = new IERC20[](assetsLength); uint256[] memory depositAmounts = new uint256[](amountsLength); @@ -325,11 +332,13 @@ contract LiquidTokenManager is // Transfer assets to node for (uint256 i = 0; i < assetsLength; i++) { depositAssets[i] = assets[i]; - depositAmounts[i] = amounts[i]; - assets[i].safeTransfer(address(node), amounts[i]); + uint256 balance = assets[i].balanceOf(address(this)); + depositAmounts[i] = balance < amounts[i] ? balance : amounts[i]; + + assets[i].safeTransfer(address(node), depositAmounts[i]); } - emit AssetsStakedToNode(nodeId, assets, amounts, msg.sender); + emit AssetsStakedToNode(nodeId, depositAssets, depositAmounts, msg.sender); // Call for node to deposit assets into EigenLayer node.depositAssets(depositAssets, depositAmounts, strategiesForNode); @@ -337,49 +346,840 @@ contract LiquidTokenManager is emit AssetsDepositedToEigenlayer(depositAssets, depositAmounts, strategiesForNode, address(node)); } - /// @dev OUT OF SCOPE FOR V1 /** - function undelegateNodes( - uint256[] calldata nodeIds - ) external onlyRole(STRATEGY_CONTROLLER_ROLE) { + /// @notice FUNCTIONS RELATED TO `swapAndStakeAssetsToNodes` + /// @notice OUT OF SCOPE FOR V2 + + /// @inheritdoc ILiquidTokenManager + function swapAndStakeAssetsToNodes( + NodeAllocationWithSwap[] calldata allocationsWithSwaps + ) external onlyRole(STRATEGY_CONTROLLER_ROLE) nonReentrant { + for (uint256 i = 0; i < allocationsWithSwaps.length; i++) { + NodeAllocationWithSwap memory allocationWithSwap = allocationsWithSwaps[i]; + _swapAndStakeAssetsToNode( + allocationWithSwap.nodeId, + allocationWithSwap.assetsToSwap, + allocationWithSwap.amountsToSwap, + allocationWithSwap.assetsToStake + ); + } + } + + /// @inheritdoc ILiquidTokenManager + function swapAndStakeAssetsToNode( + uint256 nodeId, + IERC20[] memory assetsToSwap, + uint256[] memory amountsToSwap, + IERC20[] memory assetsToStake + ) external onlyRole(STRATEGY_CONTROLLER_ROLE) nonReentrant { + _swapAndStakeAssetsToNode(nodeId, assetsToSwap, amountsToSwap, assetsToStake); + } + + /// @dev Called by `swapAndStakeAssetsToNode` and `swapAndStakeAssetsToNodes` + /// @dev Flow: LTM >> DEX >> LTM (using LSR for routing data) + function _swapAndStakeAssetsToNode( + uint256 nodeId, + IERC20[] memory assetsToSwap, + uint256[] memory amountsToSwap, + IERC20[] memory assetsToStake + ) internal { + uint256 assetsLength = assetsToStake.length; + + if (assetsLength != assetsToSwap.length) { + revert LengthMismatch(assetsLength, assetsToSwap.length); + } + if (assetsLength != amountsToSwap.length) { + revert LengthMismatch(assetsLength, amountsToSwap.length); + } + + // Validate that ETH is not used as direct tokenIn or tokenOut (only as bridge asset) + for (uint256 i = 0; i < assetsLength; i++) { + if (address(assetsToSwap[i]) == _ETH_ADDRESS) { + revert ETHNotSupportedAsDirectToken(address(assetsToSwap[i])); + } + if (address(assetsToStake[i]) == _ETH_ADDRESS) { + revert ETHNotSupportedAsDirectToken(address(assetsToStake[i])); + } + } + + IStakerNode node = stakerNodeCoordinator.getNodeById(nodeId); + + // Find EigenLayer strategies for the given assets + IStrategy[] memory strategiesForNode = new IStrategy[](assetsLength); + for (uint256 i = 0; i < assetsLength; i++) { + IERC20 asset = assetsToStake[i]; // using `assetsToStake` not `assetsToSwap` + if (amountsToSwap[i] == 0) { + revert InvalidStakingAmount(amountsToSwap[i]); + } + IStrategy strategy = tokenStrategies[asset]; + if (address(strategy) == address(0)) { + revert StrategyNotFound(address(asset)); + } + strategiesForNode[i] = strategy; + } + + // Bring unstaked assets in from `LiquidToken` + liquidToken.transferAssets(assetsToSwap, amountsToSwap, address(this)); + + uint256[] memory amountsToStake = new uint256[](assetsLength); + + // Swap using LSR - for every `tokenIn`, swap to corresponding `tokenOut` + for (uint256 i = 0; i < assetsLength; i++) { + address tokenIn = address(assetsToSwap[i]); + address tokenOut = address(assetsToStake[i]); + uint256 amountIn = amountsToSwap[i]; + + if (tokenIn == tokenOut) { + // No swap needed, direct stake + amountsToStake[i] = amountIn; + } else { + // Get swap plan from LSR + (, ILSTSwapRouter.MultiStepExecutionPlan memory plan) = lstSwapRouter.getCompleteMultiStepPlan( + tokenIn, + tokenOut, + amountIn, + address(this) // LTM is the recipient + ); + + // Execute the swap plan step by step + uint256 actualAmountOut = _executeLsrSwapPlan(tokenOut, plan); + amountsToStake[i] = actualAmountOut; + + emit SwapExecuted(tokenIn, tokenOut, amountIn, actualAmountOut, nodeId); + } + } + + IERC20[] memory depositAssets = new IERC20[](assetsLength); + uint256[] memory depositAmounts = new uint256[](assetsLength); + + // Transfer assets to node + for (uint256 i = 0; i < assetsLength; i++) { + depositAssets[i] = assetsToStake[i]; + depositAmounts[i] = amountsToStake[i]; + assetsToStake[i].safeTransfer(address(node), amountsToStake[i]); + } + + emit AssetsSwappedAndStakedToNode( + nodeId, + assetsToSwap, + amountsToSwap, + assetsToStake, + amountsToStake, + msg.sender + ); + + // Call for node to deposit assets into EigenLayer + node.depositAssets(depositAssets, depositAmounts, strategiesForNode); + + emit AssetsDepositedToEigenlayer(depositAssets, depositAmounts, strategiesForNode, address(node)); + } + + /// @dev Executes a swap plan from LSR following LTM >> DEX >> LTM flow + /// @param tokenOut Output token address + /// @param plan Execution plan from LSR + /// @return actualAmountOut The actual amount received from the swap + function _executeLsrSwapPlan( + address tokenOut, + ILSTSwapRouter.MultiStepExecutionPlan memory plan + ) internal returns (uint256 actualAmountOut) { + require(plan.steps.length > 0, "Empty swap plan"); + require(address(lstSwapRouter) != address(0), "LSR not configured"); + + // Track balances before and after + uint256 initialBalance = tokenOut == _ETH_ADDRESS + ? address(this).balance + : IERC20(tokenOut).balanceOf(address(this)); + + // Execute each step in the plan + for (uint256 i = 0; i < plan.steps.length; i++) { + ILSTSwapRouter.SwapStep memory step = plan.steps[i]; + + // Approve the target DEX to spend our tokens + if (step.tokenIn != _ETH_ADDRESS) { + IERC20(step.tokenIn).safeApprove(step.target, 0); + IERC20(step.tokenIn).safeApprove(step.target, step.amountIn); + } + + // Execute the swap on the DEX + (bool success, bytes memory returnData) = step.target.call{value: step.value}(step.data); + + if (!success) { + // Decode revert reason if possible + if (returnData.length > 0) { + assembly { + let returnDataSize := mload(returnData) + revert(add(32, returnData), returnDataSize) + } + } else { + revert("Swap execution failed"); + } + } + + // Reset approval + if (step.tokenIn != _ETH_ADDRESS) { + IERC20(step.tokenIn).safeApprove(step.target, 0); + } + } + + // Calculate actual output amount + uint256 finalBalance = tokenOut == _ETH_ADDRESS + ? address(this).balance + : IERC20(tokenOut).balanceOf(address(this)); + + actualAmountOut = finalBalance - initialBalance; + + // Validate we received at least the minimum expected + ILSTSwapRouter.SwapStep memory lastStep = plan.steps[plan.steps.length - 1]; + require(actualAmountOut >= lastStep.minAmountOut, "Insufficient output amount"); + + return actualAmountOut; + } + */ + + /** + /// @notice FUNCTIONS RELATED TO `undelegateNodes` + /// @notice OUT OF SCOPE FOR V2 + + /// @inheritdoc ILiquidTokenManager + function undelegateNodes(uint256[] calldata nodeIds) external override onlyRole(STRATEGY_CONTROLLER_ROLE) { // Fetch and add all asset balances from the node to queued balances for (uint256 i = 0; i < nodeIds.length; i++) { - IStakerNode node = stakerNodeCoordinator.getNodeById((nodeIds[i])); + _createRedemptionNodeUndelegation(nodeIds[i]); + } + } - // Convert supportedTokens to IERC20Upgradeable array - IERC20Upgradeable[] - memory upgradeableTokens = new IERC20Upgradeable[]( - supportedTokens.length - ); - for (uint256 j = 0; j < supportedTokens.length; j++) { - upgradeableTokens[j] = IERC20Upgradeable( - address(supportedTokens[j]) + /// @dev Called by `undelegateNodes` + function _createRedemptionNodeUndelegation(uint256 nodeId) private { + IStakerNode node = stakerNodeCoordinator.getNodeById(nodeId); + uint256 nonce = delegationManager.cumulativeWithdrawalsQueued(address(node)); + address delegatedTo = node.getOperatorDelegation(); + + // Find strategies and deposit shares + (IStrategy[] memory redemptionStrategies, uint256[] memory redemptionShares) = strategyManager.getDeposits( + address(node) + ); + + // Find withdrawable shares + (uint256[] memory redemptionElWithdrawableShares, ) = delegationManager.getWithdrawableShares( + address(node), + redemptionStrategies + ); + + // Undelegate node on EL and return withdrawal info + ( + bytes32[] memory withdrawalRoots, + IDelegationManagerTypes.Withdrawal[] memory withdrawals, + IERC20[] memory redemptionAssets + ) = _processNodeForUndelegation(nodeId, node, delegatedTo, redemptionStrategies, redemptionShares, nonce); + + // Credit queued asset shares with total withdrawable shares + liquidToken.creditQueuedAssetElShares(redemptionAssets, redemptionElWithdrawableShares); + + bytes32[] memory requestIds = new bytes32[](1); + requestIds[0] = keccak256(abi.encode(redemptionAssets, redemptionShares, block.timestamp, _redemptionNonce)); + + emit RedemptionCreatedForNodeUndelegation( + _createRedemption( + requestIds, + withdrawalRoots, + redemptionAssets, + redemptionElWithdrawableShares, + address(liquidToken) + ), + requestIds[0], + withdrawalRoots, + withdrawals, + redemptionAssets, + nodeId + ); + } + /// @dev Called by `_createRedemptionNodeUndelegation` + function _processNodeForUndelegation( + uint256 nodeId, + IStakerNode node, + address delegatedTo, + IStrategy[] memory redemptionStrategies, + uint256[] memory redemptionShares, + uint256 nonce + ) + internal + returns ( + bytes32[] memory withdrawalRoots, + IDelegationManagerTypes.Withdrawal[] memory withdrawals, + IERC20[] memory redemptionAssets + ) + { + // Undelegate node from EL Operator + withdrawalRoots = node.undelegate(); + emit NodeUndelegated(nodeId, delegatedTo); + + // Construct withdrawal structs + withdrawals = new IDelegationManagerTypes.Withdrawal[](withdrawalRoots.length); + redemptionAssets = new IERC20[](withdrawalRoots.length); // We can use a 1D array since every withdrawal corresponds to only 1 asset + + // The order of strategies in `withdrawalRoots[]` is the same as that of `redemptionStrategies[]` + for (uint256 i = 0; i < withdrawalRoots.length; i++) { + IStrategy[] memory requestStrategies = new IStrategy[](1); + requestStrategies[0] = redemptionStrategies[i]; + + redemptionAssets[i] = strategyTokens[redemptionStrategies[i]]; + + uint256[] memory requestScaledShares = new uint256[](1); + requestScaledShares[0] = _scaleSharesForNodeAsset(nodeId, redemptionAssets[i], redemptionShares[i]); + + IDelegationManagerTypes.Withdrawal memory withdrawal = IDelegationManagerTypes.Withdrawal({ + staker: address(node), + delegatedTo: node.getOperatorDelegation(), + withdrawer: address(node), + nonce: nonce++, + startBlock: uint32(block.number), + strategies: requestStrategies, + scaledShares: requestScaledShares + }); + + // Make sure our withdrawal struct is the same as what EL computed + if (withdrawalRoots[i] != keccak256(abi.encode(withdrawal))) revert InvalidWithdrawalRoot(); + + withdrawals[i] = withdrawal; + } + + return (withdrawalRoots, withdrawals, redemptionAssets); + } + */ + + /** + /// @notice FUNCTIONS RELATED TO `withdrawNodeAssets` + /// @notice OUT OF SCOPE FOR V2 + + /// @inheritdoc ILiquidTokenManager + function withdrawNodeAssets( + uint256[] calldata nodeIds, + IERC20[][] calldata assets, + uint256[][] calldata elDepositShares + ) external override nonReentrant onlyRole(STRATEGY_CONTROLLER_ROLE) { + if (assets.length != nodeIds.length) revert LengthMismatch(assets.length, nodeIds.length); + if (elDepositShares.length != nodeIds.length) revert LengthMismatch(elDepositShares.length, nodeIds.length); + + _createRedemptionRebalancing(nodeIds, assets, elDepositShares); + } + + /// @dev Called by `withdrawNodeAssets` + function _createRedemptionRebalancing( + uint256[] calldata nodeIds, + IERC20[][] calldata nodeAssets, + uint256[][] calldata nodeElDepositShares + ) internal { + bytes32[] memory withdrawalRoots = new bytes32[](nodeIds.length); + IDelegationManagerTypes.Withdrawal[] memory withdrawals = new IDelegationManagerTypes.Withdrawal[]( + nodeIds.length + ); + bytes32[] memory requestIds = new bytes32[](nodeIds.length); + + IERC20[] memory redemptionAssets = new IERC20[](supportedTokens.length); + uint256[] memory redemptionElWithdrawableShares = new uint256[](supportedTokens.length); + uint256 uniqueTokenCount = 0; + + for (uint256 i = 0; i < nodeIds.length; i++) { + uniqueTokenCount = _processNodeAssetsForRedemption( + nodeIds[i], + nodeAssets[i], + nodeElDepositShares[i], + redemptionAssets, + redemptionElWithdrawableShares, + uniqueTokenCount + ); + + // Call for EL withdrawals on staker node + (withdrawalRoots[i], withdrawals[i]) = _createELWithdrawal( + nodeIds[i], + nodeAssets[i], + nodeElDepositShares[i] + ); + + requestIds[i] = keccak256( + abi.encode(nodeAssets[i], nodeElDepositShares[i], block.timestamp, i, _redemptionNonce) + ); + } + + // Credit queued asset shares with total withdrawable shares + // Here we specifically factor in any slashing of staked funds in order to maintain accurate values for AUM calc + // If there is any additional slashing after this (during EL withdrawal queue period), we handle it in redemption completion + liquidToken.creditQueuedAssetElShares(redemptionAssets, redemptionElWithdrawableShares); + + emit RedemptionCreatedForRebalancing( + _createRedemption( + requestIds, + withdrawalRoots, + redemptionAssets, + redemptionElWithdrawableShares, + address(liquidToken) + ), + requestIds, + withdrawalRoots, + withdrawals, + nodeAssets, + nodeIds + ); + } + + function _processNodeAssetsForRedemption( + uint256 nodeId, + IERC20[] calldata assets, + uint256[] calldata elDepositShares, + IERC20[] memory redemptionAssets, + uint256[] memory redemptionElWithdrawableShares, + uint256 currentUniqueTokenCount + ) internal view returns (uint256) { + uint256 uniqueTokenCount = currentUniqueTokenCount; + + for (uint256 j = 0; j < assets.length; j++) { + if (elDepositShares[j] == 0) { + revert ZeroAmount(); + } + + uint256 depositShares = getDepositAssetBalanceNode(assets[j], nodeId, true); + // EL deposits for the asset must exist and cannot be less than proposed clawback amount + if (depositShares == 0 || depositShares < elDepositShares[j]) { + revert InsufficientBalance(assets[j], elDepositShares[j], depositShares); + } + + uint256 withdrawableShares = getWithdrawableAssetBalanceNode(assets[j], nodeId, true); + + bool found = false; + for (uint256 k = 0; k < uniqueTokenCount; k++) { + if (redemptionAssets[k] == assets[j]) { + redemptionElWithdrawableShares[k] += (elDepositShares[j] * withdrawableShares) / depositShares; // Factor in any slashing + found = true; + break; + } + } + if (!found) { + redemptionAssets[uniqueTokenCount] = assets[j]; + redemptionElWithdrawableShares[uniqueTokenCount] = + (elDepositShares[j] * withdrawableShares) / + depositShares; // Factor in any slashing + uniqueTokenCount++; + } + } + + return uniqueTokenCount; + } + */ + + /// @inheritdoc ILiquidTokenManager + function settleUserWithdrawals( + UserWithdrawalsSettlement calldata settlement + ) external override nonReentrant onlyRole(STRATEGY_CONTROLLER_ROLE) { + if (settlement.elAssets.length != settlement.nodeIds.length) + revert LengthMismatch(settlement.elAssets.length, settlement.nodeIds.length); + if (settlement.elDepositShares.length != settlement.nodeIds.length) + revert LengthMismatch(settlement.elDepositShares.length, settlement.nodeIds.length); + + // Check if all associated withdrawal requests actually get fulfilled from the input amounts + (IERC20[] memory redemptionAssets, uint256[] memory redemptionElWithdrawableShares) = _verifyAllRequestsSettle( + settlement + ); + + // Create a redemption for the settlement by withdrawing from staker nodes + _createRedemptionUserWithdrawals(settlement, redemptionAssets, redemptionElWithdrawableShares); + } + + /// @notice Checks if the cumulative amounts per asset once drawn would actually settle ALL user withdrawal requests + /// @dev Called by `settleUserWithdrawals` + function _verifyAllRequestsSettle( + UserWithdrawalsSettlement calldata settlement + ) internal returns (IERC20[] memory, uint256[] memory) { + // Get all associated withdrawal requests (reverts for any invalid request id) + IWithdrawalManager.WithdrawalRequest[] memory withdrawalRequests = withdrawalManager.getWithdrawalRequests( + settlement.requestIds + ); + + uint256 uniqueTokenCount; + IERC20[] memory redemptionAssets = new IERC20[](supportedTokens.length); + uint256[] memory redemptionElDepositShares = new uint256[](supportedTokens.length); + + // Aggregate cumulative amounts that need to be settled, across all withdrawal requests, + (uniqueTokenCount, redemptionAssets, redemptionElDepositShares) = _processWithdrawalRequests( + withdrawalRequests + ); + + // Track the proposed amounts to be clawed back from nodes + uint256[] memory proposedRedemptionElDepositShares = new uint256[](uniqueTokenCount); + + // Track the withdrawable amounts after slashing, for internal accounting + // This allows correct AUM calc, where queued balances are checked, slashing is included + uint256[] memory redemptionElWithdrawableShares = new uint256[](uniqueTokenCount); + + for (uint256 i = 0; i < settlement.nodeIds.length; i++) { + for (uint256 j = 0; j < settlement.elAssets[i].length; j++) { + IERC20 token = settlement.elAssets[i][j]; + + if (settlement.elDepositShares[i][j] == 0) { + revert ZeroAmount(); + } + + uint256 depositShares = getDepositAssetBalanceNode(token, settlement.nodeIds[i], true); + // EL deposits for the asset must exist and cannot be less than proposed clawback amount + if (depositShares == 0 || depositShares < settlement.elDepositShares[i][j]) { + revert InsufficientBalance(token, settlement.elDepositShares[i][j], depositShares); + } + + uint256 withdrawableShares = getWithdrawableAssetBalanceNode(token, settlement.nodeIds[i], true); + + for (uint256 k = 0; k < uniqueTokenCount; k++) { + if (redemptionAssets[k] == token) { + proposedRedemptionElDepositShares[k] += settlement.elDepositShares[i][j]; + redemptionElWithdrawableShares[k] += + (settlement.elDepositShares[i][j] * withdrawableShares) / + depositShares; // Factor in any slashing + break; + } + } + } + } + + // Verify that the cumulative deposit shares required are equal to the proposed values, hence settling all requests + // We are not concerned with slashing here, hence we use the EL `depositShares` -- slashing loss will be passed on after withdrawal completion + // We allow 50 bps margin of error for rounding + for (uint256 i = 0; i < uniqueTokenCount; i++) { + uint256 upperMargin = Math.mulDiv(redemptionElDepositShares[i], 50, 10000, Math.Rounding.Up); + uint256 lowerMargin = Math.mulDiv(redemptionElDepositShares[i], 50, 10000, Math.Rounding.Down); + + uint256 maxAllowed = redemptionElDepositShares[i] + upperMargin; + uint256 minAllowed = redemptionElDepositShares[i] - lowerMargin; + + if ( + proposedRedemptionElDepositShares[i] > maxAllowed || proposedRedemptionElDepositShares[i] < minAllowed + ) { + revert RequestsDoNotSettle( + address(redemptionAssets[i]), + proposedRedemptionElDepositShares[i], + redemptionElDepositShares[i] ); } + } + + // Trim arrays to actual sizes + assembly { + mstore(redemptionAssets, uniqueTokenCount) + } + + // Credit queued asset shares with total withdrawable amounts, post slashing + // As noted above, here we specifically factor in any slashing to maintain accurate AUM calc + // If there is any additional slashing after this (during EL withdrawal queue period), we handle it in redemption completion + liquidToken.creditQueuedAssetElShares(redemptionAssets, redemptionElWithdrawableShares); + + return (redemptionAssets, redemptionElWithdrawableShares); + } + + /// @dev Called by `_verifyAllRequestsSettle` + function _processWithdrawalRequests( + IWithdrawalManager.WithdrawalRequest[] memory withdrawalRequests + ) + internal + view + returns (uint256 uniqueTokenCount, IERC20[] memory redemptionAssets, uint256[] memory redemptionElDepositShares) + { + redemptionAssets = new IERC20[](supportedTokens.length); + redemptionElDepositShares = new uint256[](supportedTokens.length); + uniqueTokenCount = 0; + + for (uint256 i = 0; i < withdrawalRequests.length; i++) { + IWithdrawalManager.WithdrawalRequest memory request = withdrawalRequests[i]; + for (uint256 j = 0; j < request.assets.length; j++) { + IERC20 token = request.assets[j]; + bool found = false; + for (uint256 k = 0; k < uniqueTokenCount; k++) { + if (redemptionAssets[k] == token) { + redemptionElDepositShares[k] += _getDepositAssetAmount( + token, + request.elWithdrawableShares[j], + true + ); + found = true; + break; + } + } + if (!found) { + redemptionAssets[uniqueTokenCount] = token; + redemptionElDepositShares[uniqueTokenCount] = _getDepositAssetAmount( + token, + request.elWithdrawableShares[j], + true + ); + uniqueTokenCount++; + } + } + } + } + + /// @notice Creates a redemption for the unstaked funds portion of a user withdrawals settlement + /// @dev Called by `settleUserWithdrawals` + function _createRedemptionUserWithdrawals( + UserWithdrawalsSettlement calldata settlement, + IERC20[] memory redemptionAssets, + uint256[] memory redemptionElWithdrawableShares + ) internal { + bytes32[] memory withdrawalRoots = new bytes32[](settlement.nodeIds.length); + IDelegationManagerTypes.Withdrawal[] memory withdrawals = new IDelegationManagerTypes.Withdrawal[]( + settlement.nodeIds.length + ); + + // Call for EL withdrawals on staker nodes with the unscaled deposit shares + for (uint256 i = 0; i < settlement.nodeIds.length; i++) { + (withdrawalRoots[i], withdrawals[i]) = _createELWithdrawal( + settlement.nodeIds[i], + settlement.elAssets[i], + settlement.elDepositShares[i] + ); + } + + emit RedemptionCreatedForUserWithdrawals( + _createRedemption( + settlement.requestIds, + withdrawalRoots, + redemptionAssets, + redemptionElWithdrawableShares, + address(withdrawalManager) + ), + settlement.requestIds, + withdrawalRoots, + withdrawals, + settlement.elAssets, + settlement.nodeIds + ); + } - liquidToken.creditQueuedAssetBalances( - upgradeableTokens, - _getAllStakedAssetBalancesNode(node) + /// @dev Called by `_createRedemptionRebalancing` & `_createRedemptionUserWithdrawals` + /// @dev When EL withdrawal is to be completed, the `withdrawal` and `assets` need to be provided, hence we store this data + function _createELWithdrawal( + uint256 nodeId, + IERC20[] memory assets, + uint256[] memory shares + ) private returns (bytes32, IDelegationManagerTypes.Withdrawal memory) { + if (assets.length != shares.length) revert LengthMismatch(assets.length, shares.length); + + // Build the Withdrawal struct + IStakerNode node = stakerNodeCoordinator.getNodeById(nodeId); + IStrategy[] memory strategies = getTokensStrategies(assets); + address staker = address(node); + uint256 nonce = delegationManager.cumulativeWithdrawalsQueued(staker); + address delegatedTo = node.getOperatorDelegation(); + uint256[] memory scaledShares = _scaleSharesForNode(nodeId, assets, shares); + + IDelegationManagerTypes.Withdrawal memory withdrawal = IDelegationManagerTypes.Withdrawal({ + staker: staker, + delegatedTo: delegatedTo, + withdrawer: staker, + nonce: nonce, + startBlock: uint32(block.number), + strategies: strategies, + scaledShares: scaledShares + }); + + // Request withdrawal on EL + bytes32 withdrawalRoot = node.withdrawAssets(strategies, shares); + + // Make sure our withdrawal struct is the same as what EL computed + if (withdrawalRoot != keccak256(abi.encode(withdrawal))) revert InvalidWithdrawalRoot(); + + return (withdrawalRoot, withdrawal); + } + + /// @dev Called by `_createRedemptionNodeUndelegation`, `_createRedemptionRebalancing` & `_createRedemptionUserWithdrawals` + function _createRedemption( + bytes32[] memory requestIds, + bytes32[] memory withdrawalRoots, + IERC20[] memory assets, + uint256[] memory elWithdrawableShares, + address receiver + ) private returns (bytes32) { + bytes32 redemptionId = keccak256(abi.encode(requestIds, withdrawalRoots, block.timestamp, _redemptionNonce)); + _redemptionNonce += 1; + + Redemption memory redemption = Redemption({ + requestIds: requestIds, + withdrawalRoots: withdrawalRoots, + assets: assets, + elWithdrawableShares: elWithdrawableShares, + receiver: receiver + }); + + // Update `WithdrawalManager` with the new redemption + withdrawalManager.recordRedemptionCreated(redemptionId, redemption); + + return redemptionId; + } + + /// @inheritdoc ILiquidTokenManager + function completeRedemption( + bytes32 redemptionId, + uint256[] calldata nodeIds, + IDelegationManagerTypes.Withdrawal[][] calldata withdrawals, + IERC20[][][] calldata assets + ) external override nonReentrant onlyRole(STRATEGY_CONTROLLER_ROLE) { + if (withdrawals.length != nodeIds.length) revert LengthMismatch(withdrawals.length, nodeIds.length); + if (assets.length != nodeIds.length) revert LengthMismatch(withdrawals.length, nodeIds.length); + + // Reverts for invalid `redemptionId` + Redemption memory redemption = withdrawalManager.getRedemption(redemptionId); + + address receiver = redemption.receiver; + if (receiver != address(withdrawalManager) && receiver != address(liquidToken)) + revert InvalidReceiver(receiver); + + // Check if the exact set of withdrawals concerned the redemption have been provided + // Partial completion of a redemption is not accepted + // Withdrawals that weren't part of the original redemption are not accepted + _validateRedemption(redemption.withdrawalRoots, withdrawals); + + // Track unique tokens received from completion of all withdrawals across all nodes + IERC20[] memory receivedTokens = new IERC20[](supportedTokens.length); + uint256 uniqueTokenCount = 0; + + for (uint256 k = 0; k < nodeIds.length; k++) { + uniqueTokenCount = _completeELWithdrawals( + nodeIds[k], + withdrawals[k], + assets[k], + receivedTokens, + uniqueTokenCount ); + } + + // Keep track of the actual amounts received + // This may differ from the original requested shares in the `Withdrawal` struct due to slashing + uint256[] memory receivedAmounts = new uint256[](supportedTokens.length); + uint256[] memory receivedElShares = new uint256[](supportedTokens.length); + + // Transfer all withdrawn assets to `receiver`, either `LiquidToken` or `WithdrawalManager` + for (uint256 i = 0; i < uniqueTokenCount; i++) { + IERC20 token = receivedTokens[i]; + uint256 balanceBefore = token.balanceOf(address(this)); + + if (balanceBefore > 0) { + uint256 receiverBalanceBefore = token.balanceOf(receiver); + token.safeTransfer(receiver, balanceBefore); + uint256 receiverBalanceAfter = token.balanceOf(receiver); + + // Calculate actual net amount transferred + uint256 netTransferredAmount = receiverBalanceAfter - receiverBalanceBefore; + receivedAmounts[i] = netTransferredAmount; + receivedElShares[i] = tokenStrategies[token].underlyingToSharesView(netTransferredAmount); + } else { + receivedAmounts[i] = 0; + receivedElShares[i] = 0; + } + } - node.undelegate(); + // Update Withdrawal Manager and retrieve the original requested shares + uint256[] memory requestedElShares = withdrawalManager.recordRedemptionCompleted( + // Accounting for any slashing during withdrawal queue period handled here + redemptionId, + receivedTokens, + receivedElShares + ); + + // If receiver is `LiquidToken`, we follow the debit done in `recordRedemptionCreated` with a corresponding credit to asset balances + if (receiver == address(liquidToken)) { + liquidToken.creditAssetBalances(receivedTokens, receivedAmounts); } + + emit RedemptionCompleted(redemptionId, receivedTokens, requestedElShares, receivedAmounts); } - function _getAllStakedAssetBalancesNode( - IStakerNode node - ) internal view returns (uint256[] memory) { - uint256[] memory balances = new uint256[](supportedTokens.length); - for (uint256 i = 0; i < supportedTokens.length; i++) { - IStrategy strategy = tokenStrategies[supportedTokens[i]]; - if (address(strategy) == address(0)) { - revert StrategyNotFound(address(supportedTokens[i])); + function _validateRedemption( + bytes32[] memory redemptionWithdrawalRoots, + IDelegationManagerTypes.Withdrawal[][] calldata withdrawals + ) internal pure { + uint256 totalWithdrawals = 0; + for (uint256 j = 0; j < withdrawals.length; j++) { + totalWithdrawals += withdrawals[j].length; + } + + bytes32[] memory allWithdrawalHashes = new bytes32[](totalWithdrawals); + uint256 index = 0; + for (uint256 j = 0; j < withdrawals.length; j++) { + for (uint256 k = 0; k < withdrawals[j].length; k++) { + allWithdrawalHashes[index++] = keccak256(abi.encode(withdrawals[j][k])); + } + } + + for (uint256 i = 0; i < redemptionWithdrawalRoots.length; i++) { + bool found = false; + for (uint256 h = 0; h < allWithdrawalHashes.length; h++) { + if (allWithdrawalHashes[h] == redemptionWithdrawalRoots[i]) { + found = true; + break; + } } - balances[i] = strategy.userUnderlyingView(address(node)); + if (!found) revert WithdrawalMissing(redemptionWithdrawalRoots[i]); } - return balances; } - */ + + /// @dev Called by `completeRedemption` + function _completeELWithdrawals( + uint256 nodeId, + IDelegationManagerTypes.Withdrawal[] calldata withdrawals, + IERC20[][] calldata assets, + IERC20[] memory uniqueTokens, + uint256 uniqueTokenCount + ) private returns (uint256) { + IStakerNode node = stakerNodeCoordinator.getNodeById(nodeId); + IERC20[] memory receivedTokens = node.completeWithdrawals(withdrawals, assets); + + // Track received tokens + for (uint256 j = 0; j < receivedTokens.length; j++) { + IERC20 token = receivedTokens[j]; + + bool found = false; + for (uint256 k = 0; k < uniqueTokenCount; k++) { + if (uniqueTokens[k] == token) { + found = true; + break; + } + } + + if (!found) { + uniqueTokens[uniqueTokenCount++] = token; + } + } + + return uniqueTokenCount; + } + + /// @dev Called by `_createELWithdrawal` + function _scaleSharesForNode( + uint256 nodeId, + IERC20[] memory assets, + uint256[] memory shares + ) internal view returns (uint256[] memory) { + address nodeAddress = address(stakerNodeCoordinator.getNodeById(nodeId)); + uint256[] memory scaledShares = new uint256[](assets.length); + + for (uint256 i = 0; i < assets.length; i++) { + scaledShares[i] = shares[i].mulDiv( + delegationManager.depositScalingFactor(nodeAddress, tokenStrategies[assets[i]]), + 1e18 + ); + } + + return scaledShares; + } + + /// @dev Called by `_createRedemptionNodeUndelegation`, + function _scaleSharesForNodeAsset(uint256 nodeId, IERC20 asset, uint256 shares) internal view returns (uint256) { + address nodeAddress = address(stakerNodeCoordinator.getNodeById(nodeId)); + + uint256 scaledSharesAsset = 0; + + scaledSharesAsset = shares.mulDiv( + delegationManager.depositScalingFactor(nodeAddress, tokenStrategies[asset]), + 1e18 + ); + + return scaledSharesAsset; + } // ------------------------------------------------------------------------------ // Getter functions @@ -416,6 +1216,24 @@ contract LiquidTokenManager is return strategy; } + /// @inheritdoc ILiquidTokenManager + function getTokensStrategies(IERC20[] memory assets) public view returns (IStrategy[] memory) { + IStrategy[] memory strategies = new IStrategy[](assets.length); + for (uint256 i = 0; i < assets.length; i++) { + IERC20 asset = assets[i]; + if (address(asset) == address(0)) revert ZeroAddress(); + + IStrategy strategy = tokenStrategies[asset]; + if (address(strategy) == address(0)) { + revert StrategyNotFound(address(asset)); + } + + strategies[i] = strategy; + } + + return strategies; + } + /// @inheritdoc ILiquidTokenManager function getStrategyToken(IStrategy strategy) external view returns (IERC20) { if (address(strategy) == address(0)) revert ZeroAddress(); @@ -430,7 +1248,7 @@ contract LiquidTokenManager is } /// @inheritdoc ILiquidTokenManager - function getDepositAssetBalance(IERC20 asset) external view returns (uint256) { + function getDepositAssetBalance(IERC20 asset, bool inElShares) external view returns (uint256) { IStrategy strategy = tokenStrategies[asset]; if (address(strategy) == address(0)) { revert StrategyNotFound(address(asset)); @@ -439,14 +1257,14 @@ contract LiquidTokenManager is IStakerNode[] memory nodes = stakerNodeCoordinator.getAllNodes(); uint256 totalBalance = 0; for (uint256 i = 0; i < nodes.length; i++) { - totalBalance += _getDepositAssetBalanceNode(asset, nodes[i]); + totalBalance += _getDepositAssetBalanceNode(asset, nodes[i], inElShares); } return totalBalance; } /// @inheritdoc ILiquidTokenManager - function getDepositAssetBalanceNode(IERC20 asset, uint256 nodeId) public view returns (uint256) { + function getDepositAssetBalanceNode(IERC20 asset, uint256 nodeId, bool inElShares) public view returns (uint256) { IStrategy strategy = tokenStrategies[asset]; if (address(strategy) == address(0)) { revert StrategyNotFound(address(asset)); @@ -454,34 +1272,24 @@ contract LiquidTokenManager is IStakerNode node = stakerNodeCoordinator.getNodeById(nodeId); - return _getDepositAssetBalanceNode(asset, node); + return _getDepositAssetBalanceNode(asset, node, inElShares); } /// @dev Called by `getDepositAssetBalance` and `getDepositAssetBalanceNode` - function _getDepositAssetBalanceNode(IERC20 asset, IStakerNode node) internal view returns (uint256) { + function _getDepositAssetBalanceNode( + IERC20 asset, + IStakerNode node, + bool inElShares + ) internal view returns (uint256) { IStrategy strategy = tokenStrategies[asset]; if (address(strategy) == address(0)) { revert StrategyNotFound(address(asset)); } - return strategy.userUnderlyingView(address(node)); // Converts EL shares to underlying asset value - } - - /// @notice Gets the staked deposits balance (`depositShares`) of all assets for a specific node - /// @dev Currently unused - function _getAllDepositAssetBalanceNode(IStakerNode node) internal view returns (uint256[] memory) { - uint256[] memory balances = new uint256[](supportedTokens.length); - for (uint256 i = 0; i < supportedTokens.length; i++) { - IStrategy strategy = tokenStrategies[supportedTokens[i]]; - if (address(strategy) == address(0)) { - revert StrategyNotFound(address(supportedTokens[i])); - } - balances[i] = strategy.userUnderlyingView(address(node)); // Converts EL shares to underlying asset value - } - return balances; + return inElShares ? strategy.shares(address(node)) : strategy.userUnderlyingView(address(node)); } /// @inheritdoc ILiquidTokenManager - function getWithdrawableAssetBalance(IERC20 asset) external view returns (uint256) { + function getWithdrawableAssetBalance(IERC20 asset, bool inElShares) external view returns (uint256) { IStrategy strategy = tokenStrategies[asset]; if (address(strategy) == address(0)) { revert StrategyNotFound(address(asset)); @@ -490,14 +1298,18 @@ contract LiquidTokenManager is IStakerNode[] memory nodes = stakerNodeCoordinator.getAllNodes(); uint256 totalBalance = 0; for (uint256 i = 0; i < nodes.length; i++) { - totalBalance += _getWithdrawableAssetBalanceNode(asset, nodes[i]); + totalBalance += _getWithdrawableAssetBalanceNode(asset, nodes[i], inElShares); } return totalBalance; } /// @inheritdoc ILiquidTokenManager - function getWithdrawableAssetBalanceNode(IERC20 asset, uint256 nodeId) public view returns (uint256) { + function getWithdrawableAssetBalanceNode( + IERC20 asset, + uint256 nodeId, + bool inElShares + ) public view returns (uint256) { IStrategy strategy = tokenStrategies[asset]; if (address(strategy) == address(0)) { revert StrategyNotFound(address(asset)); @@ -505,11 +1317,15 @@ contract LiquidTokenManager is IStakerNode node = stakerNodeCoordinator.getNodeById(nodeId); - return _getWithdrawableAssetBalanceNode(asset, node); + return _getWithdrawableAssetBalanceNode(asset, node, inElShares); } /// @dev Called by `getWithdrawableAssetBalance` and `getWithdrawableAssetBalanceNode` - function _getWithdrawableAssetBalanceNode(IERC20 asset, IStakerNode node) internal view returns (uint256) { + function _getWithdrawableAssetBalanceNode( + IERC20 asset, + IStakerNode node, + bool inElShares + ) internal view returns (uint256) { IStrategy strategy = tokenStrategies[asset]; if (address(strategy) == address(0)) { revert StrategyNotFound(address(asset)); @@ -524,7 +1340,54 @@ contract LiquidTokenManager is return 0; } - return strategy.sharesToUnderlyingView(withdrawableShares[0]); // Converts EL shares to underlying asset value + return inElShares ? withdrawableShares[0] : strategy.sharesToUnderlyingView(withdrawableShares[0]); + } + + /// @inheritdoc ILiquidTokenManager + function getWithdrawableAssetAmount(IERC20 asset, uint256 amount, bool inElShares) external view returns (uint256) { + IStrategy strategy = tokenStrategies[asset]; + if (address(strategy) == address(0)) { + revert StrategyNotFound(address(asset)); + } + + IStakerNode[] memory nodes = stakerNodeCoordinator.getAllNodes(); + + uint256 totalDepositBalance = 0; + uint256 totalWithdrawableBalance = 0; + for (uint256 i = 0; i < nodes.length; i++) { + totalDepositBalance += _getDepositAssetBalanceNode(asset, nodes[i], inElShares); + totalWithdrawableBalance += _getWithdrawableAssetBalanceNode(asset, nodes[i], inElShares); + } + + if (totalDepositBalance == 0 || totalWithdrawableBalance == 0) return 0; + + return amount.mulDiv(totalWithdrawableBalance, totalDepositBalance); // Withdrawable portion after any slashing + } + + /// @dev Called by `_processWithdrawalRequests` + /// @dev Converts a given withdrawable shares/amount value into corresponding EL deposit shares/equivalent amount + function _getDepositAssetAmount( + IERC20 asset, + uint256 withdrawableAmount, + bool inElShares + ) internal view returns (uint256) { + IStrategy strategy = tokenStrategies[asset]; + if (address(strategy) == address(0)) { + revert StrategyNotFound(address(asset)); + } + + IStakerNode[] memory nodes = stakerNodeCoordinator.getAllNodes(); + + uint256 totalDepositBalance = 0; + uint256 totalWithdrawableBalance = 0; + for (uint256 i = 0; i < nodes.length; i++) { + totalDepositBalance += _getDepositAssetBalanceNode(asset, nodes[i], inElShares); + totalWithdrawableBalance += _getWithdrawableAssetBalanceNode(asset, nodes[i], inElShares); + } + + if (totalDepositBalance == 0 || totalWithdrawableBalance == 0) return 0; + + return withdrawableAmount.mulDiv(totalDepositBalance, totalWithdrawableBalance); // Deposit portion undoing any slashing } /// @inheritdoc ILiquidTokenManager @@ -553,4 +1416,24 @@ contract LiquidTokenManager is if (address(strategy) == address(0)) return false; return address(strategyTokens[strategy]) != address(0); } -} + + /// @inheritdoc ILiquidTokenManager + function assetSharesToUnderlying(IERC20 asset, uint256 amount) external view returns (uint256) { + IStrategy strategy = tokenStrategies[asset]; + if (address(strategy) == address(0)) { + revert StrategyNotFound(address(asset)); + } + + return strategy.sharesToUnderlyingView(amount); + } + + /// @inheritdoc ILiquidTokenManager + function assetUnderlyingToShares(IERC20 asset, uint256 amount) external view returns (uint256) { + IStrategy strategy = tokenStrategies[asset]; + if (address(strategy) == address(0)) { + revert StrategyNotFound(address(asset)); + } + + return strategy.underlyingToSharesView(amount); + } +} \ No newline at end of file diff --git a/src/core/RewardsManager.sol b/src/core/RewardsManager.sol new file mode 100644 index 00000000..00a34352 --- /dev/null +++ b/src/core/RewardsManager.sol @@ -0,0 +1,346 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {Initializable} from "@openzeppelin-upgradeable/contracts/proxy/utils/Initializable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin-upgradeable/contracts/access/AccessControlUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin-upgradeable/contracts/security/ReentrancyGuardUpgradeable.sol"; +import {PausableUpgradeable} from "@openzeppelin-upgradeable/contracts/security/PausableUpgradeable.sol"; +import {EnumerableSetUpgradeable} from "@openzeppelin-upgradeable/contracts/utils/structs/EnumerableSetUpgradeable.sol"; +import {IRewardsCoordinator} from "@eigenlayer/contracts/interfaces/IRewardsCoordinator.sol"; +import {IRewardsCoordinatorTypes} from "@eigenlayer/contracts/interfaces/IRewardsCoordinator.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {IRewardsManager} from "../interfaces/IRewardsManager.sol"; +import {ILiquidToken} from "../interfaces/ILiquidToken.sol"; +import {ILiquidTokenManager} from "../interfaces/ILiquidTokenManager.sol"; + +/// @title RewardsManager +/// @notice Manages reward claims from EigenLayer and distributes them to the Liquid Token +/// @dev Handles both supported and unsupported tokens, transferring all value to LAT holders +contract RewardsManager is + IRewardsManager, + Initializable, + AccessControlUpgradeable, + ReentrancyGuardUpgradeable, + PausableUpgradeable +{ + using SafeERC20 for IERC20; + using Math for uint256; + using EnumerableSetUpgradeable for EnumerableSetUpgradeable.AddressSet; + + // ------------------------------------------------------------------------------ + // State + // ------------------------------------------------------------------------------ + + /// @notice Role identifier for pausing the contract + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + + /// @notice EigenLayer contracts + IRewardsCoordinator public rewardsCoordinator; + + /// @notice LAT contracts + ILiquidToken public liquidToken; + ILiquidTokenManager public liquidTokenManager; + + /// @notice Unsupported rewards which remain in the contract until swapped out + mapping(address => uint256) public unsupportedAssetBalances; + EnumerableSetUpgradeable.AddressSet private _unsupportedAssets; + + /// @notice Claimer for earners on EL + mapping(address => bool) public isClaimerFor; + EnumerableSetUpgradeable.AddressSet private _claimerForSet; + + // ------------------------------------------------------------------------------ + // Init functions + // ------------------------------------------------------------------------------ + + /// @dev Disables initializers for the implementation contract + constructor() { + _disableInitializers(); + } + + /// @inheritdoc IRewardsManager + function initialize(Init memory init) public initializer { + __AccessControl_init(); + __ReentrancyGuard_init(); + __Pausable_init(); + + if ( + address(init.initialOwner) == address(0) || + address(init.pauser) == address(0) || + address(init.liquidToken) == address(0) || + address(init.liquidTokenManager) == address(0) || + address(init.rewardsCoordinator) == address(0) + ) { + revert ZeroAddress(); + } + + _grantRole(DEFAULT_ADMIN_ROLE, init.initialOwner); + _grantRole(PAUSER_ROLE, init.pauser); + + liquidToken = init.liquidToken; + liquidTokenManager = init.liquidTokenManager; + rewardsCoordinator = init.rewardsCoordinator; + } + + // ------------------------------------------------------------------------------ + // Core functions + // ------------------------------------------------------------------------------ + + /// @inheritdoc IRewardsManager + function updateClaimerFor(address earner) external override nonReentrant { + _verifyAndUpdateClaimerFor(earner); + } + + /// @inheritdoc IRewardsManager + function processClaim( + IRewardsCoordinatorTypes.RewardsMerkleClaim calldata claim + ) external override nonReentrant whenNotPaused { + _processClaim(claim); + } + + /// @inheritdoc IRewardsManager + function processClaims( + IRewardsCoordinatorTypes.RewardsMerkleClaim[] calldata claims + ) external override nonReentrant whenNotPaused { + require(claims.length <= 50, "Too many claims"); // Prevent gas exhaustion + + for (uint256 i = 0; i < claims.length; i++) { + _processClaim(claims[i]); + } + } + + /// @notice For rewards that are in unsupported assets, swaps into supported assets and transfers over to LT + /// @dev OUT OF SCOPE FOR V2 + /// function swapAndTransferRewards() external override nonReentrant whenNotPaused onlyRole(DEFAULT_ADMIN_ROLE) {} + + /// @inheritdoc IRewardsManager + function pause() external onlyRole(PAUSER_ROLE) { + _pause(); + } + + /// @inheritdoc IRewardsManager + function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) { + _unpause(); + } + + // ------------------------------------------------------------------------------ + // Getter functions + // ------------------------------------------------------------------------------ + + /// @inheritdoc IRewardsManager + function balanceAssets(IERC20[] calldata assetList) public view returns (uint256[] memory) { + uint256[] memory balances = new uint256[](assetList.length); + for (uint256 i = 0; i < assetList.length; i++) { + balances[i] = _balanceAsset(assetList[i]); + } + return balances; + } + + /// @inheritdoc IRewardsManager + function claimerFor() external view returns (address[] memory) { + return _claimerForSet.values(); + } + + /// @inheritdoc IRewardsManager + function claimerForLength() external view returns (uint256) { + return _claimerForSet.length(); + } + + /// @inheritdoc IRewardsManager + function unsupportedAssets() external view returns (address[] memory) { + return _unsupportedAssets.values(); + } + + function unsupportedAssetsLength() external view returns (uint256) { + return _unsupportedAssets.length(); + } + + // ------------------------------------------------------------------------------ + // Internal functions + // ------------------------------------------------------------------------------ + + /// @dev Called by `processClaim` and `processClaims` + function _processClaim(IRewardsCoordinatorTypes.RewardsMerkleClaim calldata claim) internal { + address earner = claim.earnerLeaf.earner; + if (!_verifyAndUpdateClaimerFor(earner)) revert NotClaimerFor(earner); + + // Get unique tokens from the claim + IERC20[] memory uniqueTokens = _getUniqueTokensFromClaim(claim); + + // Categorize tokens into supported and unsupported + IERC20[] memory allSupportedAssets = liquidTokenManager.getSupportedTokens(); + + IERC20[] memory supportedTokens = new IERC20[](uniqueTokens.length); + IERC20[] memory unsupportedTokens = new IERC20[](uniqueTokens.length); + uint256 supportedCount = 0; + uint256 unsupportedCount = 0; + + for (uint256 i = 0; i < uniqueTokens.length; i++) { + IERC20 currentToken = uniqueTokens[i]; + bool isSupported = _isTokenSupported(currentToken, allSupportedAssets); + + if (isSupported) { + supportedTokens[supportedCount] = currentToken; + supportedCount++; + } else { + unsupportedTokens[unsupportedCount] = currentToken; + unsupportedCount++; + } + } + + // Resize arrays to actual counts + supportedTokens = _resizeTokenArray(supportedTokens, supportedCount); + unsupportedTokens = _resizeTokenArray(unsupportedTokens, unsupportedCount); + + // Process the claim on EigenLayer + rewardsCoordinator.processClaim(claim, address(this)); + + // Update balances for unsupported tokens + // These tokens will stay in the contract until `swapAndTransferRewards` is called + uint256[] memory unsupportedAmounts = _setAssetBalances(unsupportedTokens); + + // Transfer all supported assets to `LiquidToken` + uint256[] memory netTransferredAmounts = _transferRewards(supportedTokens); + + emit RewardsClaimed( + claim.rootIndex, + earner, + supportedTokens, + netTransferredAmounts, + unsupportedTokens, + unsupportedAmounts + ); + } + + /// @dev Verify and update claimer status for an earner + /// @dev Called by `_processClaim` + function _verifyAndUpdateClaimerFor(address earner) internal returns (bool) { + if (address(earner) == address(0)) revert ZeroAddress(); + + // Check on EL if this contract is set as a claimer for the earner + bool isClaimerOnEl = (rewardsCoordinator.claimerFor(earner) == address(this)); + + // Update claimer storage vars if required + if (isClaimerOnEl) { + if (!isClaimerFor[earner]) { + isClaimerFor[earner] = true; + _claimerForSet.add(earner); + emit ClaimerForAdded(earner); + } + } else { + if (isClaimerFor[earner]) { + isClaimerFor[earner] = false; + _claimerForSet.remove(earner); + emit ClaimerForRemoved(earner); + } + } + + return isClaimerOnEl; + } + + /// @dev Get unique tokens from claim to avoid processing duplicates + /// @dev Called by `_processClaim` + function _getUniqueTokensFromClaim( + IRewardsCoordinatorTypes.RewardsMerkleClaim calldata claim + ) internal pure returns (IERC20[] memory) { + IERC20[] memory tempTokens = new IERC20[](claim.tokenLeaves.length); + uint256 uniqueCount = 0; + + for (uint256 i = 0; i < claim.tokenLeaves.length; i++) { + IERC20 currentToken = claim.tokenLeaves[i].token; + bool isDuplicate = false; + + // Check if token already exists in our temp array + for (uint256 j = 0; j < uniqueCount; j++) { + if (tempTokens[j] == currentToken) { + isDuplicate = true; + break; + } + } + + if (!isDuplicate) { + tempTokens[uniqueCount] = currentToken; + uniqueCount++; + } + } + + return _resizeTokenArray(tempTokens, uniqueCount); + } + + /// @dev Helper to check if token is supported + /// @dev Called by `_processClaim` + function _isTokenSupported(IERC20 token, IERC20[] memory supportedTokens) internal pure returns (bool) { + for (uint256 i = 0; i < supportedTokens.length; i++) { + if (supportedTokens[i] == token) { + return true; + } + } + return false; + } + + /// @dev Resize token array to actual size + /// @dev Called by `_processClaim` + function _resizeTokenArray(IERC20[] memory arr, uint256 newSize) internal pure returns (IERC20[] memory) { + IERC20[] memory resized = new IERC20[](newSize); + for (uint256 i = 0; i < newSize; i++) { + resized[i] = arr[i]; + } + return resized; + } + + /// @dev Called by `_processClaim` + function _setAssetBalances(IERC20[] memory assets) internal returns (uint256[] memory) { + uint256[] memory amounts = new uint256[](assets.length); + for (uint256 i = 0; i < assets.length; i++) { + IERC20 asset = assets[i]; + uint256 oldBalance = unsupportedAssetBalances[address(asset)]; + uint256 newBalance = asset.balanceOf(address(this)); + + if (!_unsupportedAssets.contains(address(asset)) && newBalance > 0) { + // Asset doesn't exist in set yet + _unsupportedAssets.add(address(asset)); + } + if (oldBalance != newBalance) { + unsupportedAssetBalances[address(asset)] = newBalance; + amounts[i] = newBalance; + emit UnsupportedAssetBalanceUpdated(address(asset), oldBalance, newBalance); + } + } + + return amounts; + } + + /// @dev Called by `_processClaim` + function _transferRewards(IERC20[] memory assets) internal returns (uint256[] memory) { + // Transfer to `LiquidToken` and calculate actual net amounts received + uint256[] memory netTransferredAmounts = new uint256[](assets.length); + + for (uint256 i = 0; i < assets.length; i++) { + IERC20 asset = assets[i]; + uint256 rewardsManagerBalanceBefore = asset.balanceOf(address(this)); + + if (rewardsManagerBalanceBefore > 0) { + uint256 liquidTokenBalanceBefore = asset.balanceOf(address(liquidToken)); + assets[i].safeTransfer(address(liquidToken), rewardsManagerBalanceBefore); + uint256 liquidTokenBalanceAfter = assets[i].balanceOf(address(liquidToken)); + netTransferredAmounts[i] = liquidTokenBalanceAfter - liquidTokenBalanceBefore; + } else { + netTransferredAmounts[i] = 0; + } + } + + // Credit `LiquidToken` asset balances with the actual net amounts recieved + liquidToken.creditAssetBalances(assets, netTransferredAmounts); + + return netTransferredAmounts; + } + + /// @dev Get balance of unsupported asset + /// @dev Called by `balanceAssets` + function _balanceAsset(IERC20 asset) internal view returns (uint256) { + return unsupportedAssetBalances[address(asset)]; + } +} diff --git a/src/core/StakerNode.sol b/src/core/StakerNode.sol index 538a6e2b..cbb964a3 100644 --- a/src/core/StakerNode.sol +++ b/src/core/StakerNode.sol @@ -9,7 +9,9 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol import {ISignatureUtilsMixinTypes} from "@eigenlayer/contracts/interfaces/ISignatureUtilsMixin.sol"; import {IStrategyManager} from "@eigenlayer/contracts/interfaces/IStrategyManager.sol"; import {IDelegationManager} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; +import {IDelegationManagerTypes} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; import {IStrategy} from "@eigenlayer/contracts/interfaces/IStrategy.sol"; +import {IRewardsCoordinator} from "@eigenlayer/contracts/interfaces/IRewardsCoordinator.sol"; import {IStakerNode} from "../interfaces/IStakerNode.sol"; import {IStakerNodeCoordinator} from "../interfaces/IStakerNodeCoordinator.sol"; @@ -32,7 +34,7 @@ contract StakerNode is IStakerNode, Initializable, ReentrancyGuardUpgradeable { /// @notice Role identifier for delegation operations bytes32 public constant STAKER_NODES_DELEGATOR_ROLE = keccak256("STAKER_NODES_DELEGATOR_ROLE"); - /// @notice LAT contracts + /// @notice v1 LAT contracts IStakerNodeCoordinator public coordinator; uint256 public id; @@ -55,6 +57,18 @@ contract StakerNode is IStakerNode, Initializable, ReentrancyGuardUpgradeable { operatorDelegation = address(0); } + /// @inheritdoc IStakerNode + function initializeV2() public reinitializer(2) { + _delegateRewardsClaiming(); + } + + /// @dev Called by `initializeV2` + function _delegateRewardsClaiming() internal { + IRewardsCoordinator rewardsCoordinator = coordinator.rewardsCoordinator(); + address rewardsManager = address(coordinator.rewardsManager()); + rewardsCoordinator.setClaimerFor(rewardsManager); + } + // ------------------------------------------------------------------------------ // Core functions // ------------------------------------------------------------------------------ @@ -90,9 +104,11 @@ contract StakerNode is IStakerNode, Initializable, ReentrancyGuardUpgradeable { unchecked { for (uint256 i = 0; i < assetsLength; i++) { IERC20 asset = assets[i]; - uint256 amount = amounts[i]; IStrategy strategy = strategies[i]; + uint256 balance = assets[i].balanceOf(address(this)); + uint256 amount = balance < amounts[i] ? balance : amounts[i]; + asset.forceApprove(address(strategyManager), amount); // Call EigenLayer contract to deposit asset @@ -103,24 +119,76 @@ contract StakerNode is IStakerNode, Initializable, ReentrancyGuardUpgradeable { } } - /// @dev OUT OF SCOPE FOR V1 - /** - function undelegate() - public - override - onlyRole(STAKER_NODES_DELEGATOR_ROLE) - { - address currentOperator = operatorDelegation; - if (currentOperator == address(0)) revert NodeIsNotDelegated(); + /// @inheritdoc IStakerNode + function withdrawAssets( + IStrategy[] calldata strategies, + uint256[] calldata shareAmounts + ) external override onlyRole(LIQUID_TOKEN_MANAGER_ROLE) returns (bytes32) { + if (operatorDelegation == address(0)) revert NodeIsNotDelegated(); + if (strategies.length != shareAmounts.length) revert LengthMismatch(strategies.length, shareAmounts.length); + + IDelegationManagerTypes.QueuedWithdrawalParams[] + memory requestParams = new IDelegationManagerTypes.QueuedWithdrawalParams[](1); + + requestParams[0] = IDelegationManagerTypes.QueuedWithdrawalParams({ + strategies: strategies, + depositShares: shareAmounts, + __deprecated_withdrawer: address(this) + }); + + IDelegationManager delegationManager = coordinator.delegationManager(); + return delegationManager.queueWithdrawals(requestParams)[0]; + } + + /// @inheritdoc IStakerNode + function completeWithdrawals( + IDelegationManagerTypes.Withdrawal[] calldata withdrawals, + IERC20[][] calldata tokens + ) external override onlyRole(LIQUID_TOKEN_MANAGER_ROLE) returns (IERC20[] memory) { + uint256 arrayLength = withdrawals.length; + bool[] memory receiveAsTokensArray = new bool[](arrayLength); + + for (uint256 i = 0; i < arrayLength; i++) { + receiveAsTokensArray[i] = true; + } + + IDelegationManager delegationManager = coordinator.delegationManager(); + delegationManager.completeQueuedWithdrawals(withdrawals, tokens, receiveAsTokensArray); + + uint256 totalTokenCount = 0; + for (uint256 i = 0; i < tokens.length; i++) { + totalTokenCount += tokens[i].length; + } + + IERC20[] memory receivedTokens = new IERC20[](totalTokenCount); + uint256 uniqueCount = 0; + + for (uint256 i = 0; i < tokens.length; i++) { + for (uint256 j = 0; j < tokens[i].length; j++) { + IERC20 token = tokens[i][j]; + uint256 balance = token.balanceOf(address(this)); + if (balance > 0) { + token.safeTransfer(msg.sender, balance); + receivedTokens[uniqueCount++] = token; + } + } + } + + return receivedTokens; + } + + /// @inheritdoc IStakerNode + function undelegate() external override onlyRole(STAKER_NODES_DELEGATOR_ROLE) returns (bytes32[] memory) { + if (operatorDelegation == address(0)) revert NodeIsNotDelegated(); IDelegationManager delegationManager = coordinator.delegationManager(); - delegationManager.undelegate(address(this)); + bytes32[] memory withdrawalRoots = delegationManager.undelegate(address(this)); - emit UndelegatedFromOperator(currentOperator); + emit UndelegatedFromOperator(operatorDelegation); operatorDelegation = address(0); + return withdrawalRoots; } - */ // ------------------------------------------------------------------------------ // Getter functions diff --git a/src/core/StakerNodeCoordinator.sol b/src/core/StakerNodeCoordinator.sol index e85b41db..7c4e241f 100644 --- a/src/core/StakerNodeCoordinator.sol +++ b/src/core/StakerNodeCoordinator.sol @@ -6,10 +6,13 @@ import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol" import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import {IStrategyManager} from "@eigenlayer/contracts/interfaces/IStrategyManager.sol"; import {IDelegationManager} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; +import {IRewardsCoordinator} from "@eigenlayer/contracts/interfaces/IRewardsCoordinator.sol"; import {IStakerNodeCoordinator} from "../interfaces/IStakerNodeCoordinator.sol"; import {IStakerNode} from "../interfaces/IStakerNode.sol"; import {ILiquidTokenManager} from "../interfaces/ILiquidTokenManager.sol"; +import {IWithdrawalManager} from "../interfaces/IWithdrawalManager.sol"; +import {IRewardsManager} from "../interfaces/IRewardsManager.sol"; /** * @title StakerNodeCoordinator @@ -31,12 +34,17 @@ contract StakerNodeCoordinator is IStakerNodeCoordinator, AccessControlUpgradeab IStrategyManager public override strategyManager; IDelegationManager public override delegationManager; - /// @notice OZ and LAT contracts + /// @notice OZ and v1 LAT contracts UpgradeableBeacon public upgradeableBeacon; IStakerNode[] private stakerNodes; uint256 public override maxNodes; + /// @notice v2 contracts + IWithdrawalManager public override withdrawalManager; + IRewardsManager public override rewardsManager; + IRewardsCoordinator public override rewardsCoordinator; + // ------------------------------------------------------------------------------ // Init functions // ------------------------------------------------------------------------------ @@ -57,8 +65,11 @@ contract StakerNodeCoordinator is IStakerNodeCoordinator, AccessControlUpgradeab address(init.stakerNodeCreator) == address(0) || address(init.stakerNodesDelegator) == address(0) || address(init.liquidTokenManager) == address(0) || + address(init.withdrawalManager) == address(0) || + address(init.rewardsManager) == address(0) || address(init.strategyManager) == address(0) || - address(init.delegationManager) == address(0) + address(init.delegationManager) == address(0) || + address(init.rewardsCoordinator) == address(0) ) { revert ZeroAddress(); } @@ -72,8 +83,11 @@ contract StakerNodeCoordinator is IStakerNodeCoordinator, AccessControlUpgradeab _grantRole(STAKER_NODES_DELEGATOR_ROLE, init.stakerNodesDelegator); liquidTokenManager = init.liquidTokenManager; + withdrawalManager = init.withdrawalManager; + rewardsManager = init.rewardsManager; strategyManager = init.strategyManager; delegationManager = init.delegationManager; + rewardsCoordinator = init.rewardsCoordinator; maxNodes = init.maxNodes; _registerStakerNodeImplementation(init.stakerNodeImplementation); } @@ -98,6 +112,31 @@ contract StakerNodeCoordinator is IStakerNodeCoordinator, AccessControlUpgradeab // Core functions // ------------------------------------------------------------------------------ + /// @inheritdoc IStakerNodeCoordinator + function createStakerNodes( + uint256 number + ) + public + override + notZeroAddress(address(upgradeableBeacon)) + onlyRole(STAKER_NODE_CREATOR_ROLE) + returns (IStakerNode[] memory) + { + uint256 nodeId = stakerNodes.length; + + if (nodeId + number > maxNodes) { + revert TooManyStakerNodes(maxNodes); + } + + IStakerNode[] memory nodes = new IStakerNode[](number); + + for (uint256 i = 0; i < number; i++) { + nodes[i] = _createStakerNode(); + } + + return nodes; + } + /// @inheritdoc IStakerNodeCoordinator function createStakerNode() public @@ -106,6 +145,10 @@ contract StakerNodeCoordinator is IStakerNodeCoordinator, AccessControlUpgradeab onlyRole(STAKER_NODE_CREATOR_ROLE) returns (IStakerNode) { + return _createStakerNode(); + } + + function _createStakerNode() internal returns (IStakerNode) { uint256 nodeId = stakerNodes.length; if (nodeId >= maxNodes) { diff --git a/src/core/WithdrawalManager.sol b/src/core/WithdrawalManager.sol new file mode 100644 index 00000000..0ad575c5 --- /dev/null +++ b/src/core/WithdrawalManager.sol @@ -0,0 +1,411 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {Initializable} from "@openzeppelin-upgradeable/contracts/proxy/utils/Initializable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin-upgradeable/contracts/access/AccessControlUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin-upgradeable/contracts/security/ReentrancyGuardUpgradeable.sol"; +import {IStrategyManager} from "@eigenlayer/contracts/interfaces/IStrategyManager.sol"; +import {IDelegationManager} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; +import {IDelegationManagerTypes} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; +import {IStrategy} from "@eigenlayer/contracts/interfaces/IStrategy.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {IWithdrawalManager} from "../interfaces/IWithdrawalManager.sol"; +import {ILiquidToken} from "../interfaces/ILiquidToken.sol"; +import {ILiquidTokenManager} from "../interfaces/ILiquidTokenManager.sol"; +import {IStakerNode} from "../interfaces/IStakerNode.sol"; +import {IStakerNodeCoordinator} from "../interfaces/IStakerNodeCoordinator.sol"; + +/// @title WithdrawalManager +/// @notice Manages withdrawals between staker nodes and users +/// @dev Implements IWithdrawalManager and uses OpenZeppelin's upgradeable contracts +contract WithdrawalManager is IWithdrawalManager, Initializable, AccessControlUpgradeable, ReentrancyGuardUpgradeable { + using SafeERC20 for IERC20; + using Math for uint256; + + // ------------------------------------------------------------------------------ + // Constants + // ------------------------------------------------------------------------------ + + /// @notice Maximum number of assets allowed in a single withdrawal request + uint256 public constant MAX_WITHDRAWAL_ASSETS = 32; + + /// @notice Lock to prevent concurrent redemption completions + uint256 private constant NOT_ENTERED = 1; + uint256 private constant ENTERED = 2; + + // ------------------------------------------------------------------------------ + // State + // ------------------------------------------------------------------------------ + + /// @notice EigenLayer contracts + IDelegationManager public delegationManager; + + /// @notice LAT contracts + ILiquidToken public liquidToken; + ILiquidTokenManager public liquidTokenManager; + IStakerNodeCoordinator public stakerNodeCoordinator; + + /// @notice User Withdrawals + mapping(bytes32 => WithdrawalRequest) public withdrawalRequests; + mapping(address => bytes32[]) public userWithdrawalRequests; + + /// @notice Redemptions + mapping(bytes32 => ILiquidTokenManager.Redemption) public redemptions; + + /// @notice The delay between user withdrawal request and ability to withdraw from this contract + uint256 public withdrawalDelay; + + /// @notice Reentrancy guard for redemption completion + uint256 private _redemptionCompletionStatus; + + /// @notice Tracks if a withdrawal request is currently being processed + mapping(bytes32 => bool) private _processingRequest; + + // ------------------------------------------------------------------------------ + // Init functions + // ------------------------------------------------------------------------------ + + /// @dev Disables initializers for the implementation contract + constructor() { + _disableInitializers(); + } + + /// @inheritdoc IWithdrawalManager + function initialize(Init memory init) public initializer { + __AccessControl_init(); + __ReentrancyGuard_init(); + + if ( + address(init.initialOwner) == address(0) || + address(init.liquidToken) == address(0) || + address(init.delegationManager) == address(0) || + address(init.liquidTokenManager) == address(0) || + address(init.stakerNodeCoordinator) == address(0) + ) { + revert ZeroAddress(); + } + + _grantRole(DEFAULT_ADMIN_ROLE, init.initialOwner); + + delegationManager = init.delegationManager; + liquidToken = init.liquidToken; + liquidTokenManager = init.liquidTokenManager; + stakerNodeCoordinator = init.stakerNodeCoordinator; + + withdrawalDelay = 14 days; + _redemptionCompletionStatus = NOT_ENTERED; + } + + // ------------------------------------------------------------------------------ + // Core functions + // ------------------------------------------------------------------------------ + + /// @inheritdoc IWithdrawalManager + function createWithdrawalRequest( + IERC20[] memory assets, + uint256[] memory amounts, + uint256[] memory elWithdrawableShares, + uint256 sharesDeposited, + address user, + bytes32 requestId + ) external override nonReentrant { + if (msg.sender != address(liquidToken)) revert NotLiquidToken(msg.sender); + if (assets.length > MAX_WITHDRAWAL_ASSETS) revert ExceedsMaxAssets(); + if (withdrawalRequests[requestId].user != address(0)) revert RequestAlreadyExists(); + + WithdrawalRequest memory request = WithdrawalRequest({ + user: user, + assets: assets, + requestedAmounts: amounts, + elWithdrawableShares: elWithdrawableShares, + sharesDeposited: sharesDeposited, + requestTime: block.timestamp, + canFulfill: false + }); + + withdrawalRequests[requestId] = request; + userWithdrawalRequests[user].push(requestId); + + emit WithdrawalInitiated( + requestId, + user, + assets, + amounts, + elWithdrawableShares, + sharesDeposited, + block.timestamp + ); + } + + /// @inheritdoc IWithdrawalManager + function fulfillWithdrawal(bytes32 requestId) external override nonReentrant { + WithdrawalRequest memory request = withdrawalRequests[requestId]; + + if (request.user == address(0)) revert InvalidWithdrawalRequest(); + if (request.user != msg.sender) revert UnauthorizedAccess(msg.sender); + if (block.timestamp <= request.requestTime + withdrawalDelay) revert WithdrawalDelayNotMet(); + if (request.canFulfill == false) revert WithdrawalNotReadyToFulfill(); + + // Prevent concurrent processing + if (_processingRequest[requestId]) revert RequestBeingProcessed(); + _processingRequest[requestId] = true; + + // Build the amounts array from the user's withdrawable shares + uint256[] memory amounts = new uint256[](request.assets.length); + for (uint256 i = 0; i < request.assets.length; i++) { + IERC20 asset = request.assets[i]; + uint256 requestedAmount = liquidTokenManager.assetSharesToUnderlying( + asset, + request.elWithdrawableShares[i] + ); + uint256 availableBalance = asset.balanceOf(address(this)); + + if (availableBalance < requestedAmount) { + // Minor shortfalls shouldn't block withdrawals + // Allow for 10bps tolerance to handle rounding differences + uint256 tolerance = Math.mulDiv(requestedAmount, 10, 10000, Math.Rounding.Up); + uint256 minAcceptableAmount = requestedAmount > tolerance ? requestedAmount - tolerance : 0; + + if (availableBalance < minAcceptableAmount) { + revert InsufficientBalance(asset, requestedAmount, availableBalance); + } + + // Use available balance if within tolerance + amounts[i] = availableBalance; + } else { + amounts[i] = requestedAmount; + } + } + + address user = request.user; + IERC20[] memory assets = request.assets; + + // Delete withdrawal request first to prevent reentrancy + delete withdrawalRequests[requestId]; + delete _processingRequest[requestId]; + + // Remove from user's request list + bytes32[] storage userRequests = userWithdrawalRequests[user]; + uint256 requestsLength = userRequests.length; + for (uint256 i = 0; i < requestsLength; i++) { + if (userRequests[i] == requestId) { + userRequests[i] = userRequests[requestsLength - 1]; + userRequests.pop(); + break; + } + } + + // Transfer assets to user + for (uint256 i = 0; i < assets.length; i++) { + if (amounts[i] > 0) { + assets[i].safeTransfer(user, amounts[i]); + } + } + + emit WithdrawalFulfilled(requestId, user, assets, amounts, block.timestamp); + } + + /// @inheritdoc IWithdrawalManager + function recordRedemptionCreated( + bytes32 redemptionId, + ILiquidTokenManager.Redemption calldata redemption + ) external override { + if (msg.sender != address(liquidTokenManager)) revert NotLiquidTokenManager(msg.sender); + if (redemptions[redemptionId].requestIds.length > 0) revert RedemptionAlreadyExists(); + + // Record the redemption + redemptions[redemptionId] = redemption; + } + + /// @inheritdoc IWithdrawalManager + function recordRedemptionCompleted( + bytes32 redemptionId, + IERC20[] calldata receivedAssets, + uint256[] calldata receivedElShares + ) external override returns (uint256[] memory) { + // Prevent concurrent redemption completions + if (_redemptionCompletionStatus == ENTERED) revert ReentrantCall(); + _redemptionCompletionStatus = ENTERED; + + if (msg.sender != address(liquidTokenManager)) { + _redemptionCompletionStatus = NOT_ENTERED; + revert NotLiquidTokenManager(msg.sender); + } + if (receivedElShares.length != receivedAssets.length) { + _redemptionCompletionStatus = NOT_ENTERED; + revert LengthMismatch(); + } + + ILiquidTokenManager.Redemption memory redemption = redemptions[redemptionId]; + if (redemption.requestIds.length == 0) { + _redemptionCompletionStatus = NOT_ENTERED; + revert RedemptionNotFound(redemptionId); + } + + // Check if this is a user withdrawal by checking if the first requestId corresponds to a user withdrawal request + bool isUserWithdrawal = redemption.requestIds.length > 0 && + withdrawalRequests[redemption.requestIds[0]].user != address(0); + + uint256[] memory redemptionRequestedElShares = new uint256[](receivedAssets.length); + uint256 latEscrowShares = 0; + + // Aggregate the total requested shares across all assets + // For user withdrawals, we also aggregate the total escrow shares so that we can burn them + if (isUserWithdrawal) { + // First pass: calculate total LAT escrow shares + for (uint256 i = 0; i < redemption.requestIds.length; i++) { + bytes32 requestId = redemption.requestIds[i]; + WithdrawalRequest storage request = withdrawalRequests[requestId]; + if (request.user != address(0)) { + latEscrowShares += request.sharesDeposited; + } + } + + // Second pass: aggregate shares per asset + for (uint256 i = 0; i < redemption.requestIds.length; i++) { + bytes32 requestId = redemption.requestIds[i]; + WithdrawalRequest storage request = withdrawalRequests[requestId]; + + for (uint256 j = 0; j < request.assets.length; j++) { + bool assetFound = false; + for (uint256 k = 0; k < receivedAssets.length; k++) { + if (address(request.assets[j]) == address(receivedAssets[k])) { + uint256 originalShares = request.elWithdrawableShares[j]; + redemptionRequestedElShares[k] += originalShares; + assetFound = true; + break; + } + } + // Track if any assets were not found in receivedAssets + if (!assetFound && request.elWithdrawableShares[j] > 0) { + emit AssetNotReceived(requestId, request.assets[j], request.elWithdrawableShares[j]); + } + } + } + } else { + for (uint256 i = 0; i < redemption.assets.length; i++) { + for (uint256 k = 0; k < receivedAssets.length; k++) { + if (address(redemption.assets[i]) == address(receivedAssets[k])) { + redemptionRequestedElShares[k] = redemption.elWithdrawableShares[i]; + break; + } + } + } + } + + // For user withdrawals, we apply any slashing that may have occurred during the withdrawal queue period + if (isUserWithdrawal) { + // Track distributed shares per asset to handle rounding and prevent any shares being "missed" + uint256[] memory distributedShares = new uint256[](receivedAssets.length); + + for (uint256 i = 0; i < redemption.requestIds.length; i++) { + bytes32 requestId = redemption.requestIds[i]; + WithdrawalRequest storage request = withdrawalRequests[requestId]; + if (request.user == address(0)) continue; // Skip if request was already fulfilled + + bool isLastRequest = (i == redemption.requestIds.length - 1); + + for (uint256 j = 0; j < request.assets.length; j++) { + for (uint256 k = 0; k < receivedAssets.length; k++) { + if (address(request.assets[j]) == address(receivedAssets[k])) { + uint256 originalShares = request.elWithdrawableShares[j]; + uint256 totalOriginalShares = redemptionRequestedElShares[k]; + uint256 totalReceivedShares = receivedElShares[k]; + + uint256 newShares; + if (totalOriginalShares > 0) { + if (isLastRequest && j == request.assets.length - 1) { + // For the last asset of the last request, assign remaining shares to ensure exact accounting + newShares = totalReceivedShares > distributedShares[k] + ? totalReceivedShares - distributedShares[k] + : 0; + } else { + // Calculate proportional share: (originalShares * totalReceived) / totalOriginal + newShares = Math.mulDiv(originalShares, totalReceivedShares, totalOriginalShares); + distributedShares[k] += newShares; + } + } else { + newShares = 0; + } + + request.elWithdrawableShares[j] = newShares; + + if (newShares < originalShares) { + emit UserSlashed(requestId, request.user, request.assets[j], originalShares, newShares); + } + break; + } + } + } + + // Mark withdrawal as ready to fulfill + request.canFulfill = true; + } + } + + // Delete the redemption + delete redemptions[redemptionId]; + + // Clear the queued shares accounting -- here we don't apply the latest slashing as it was not part of the original credit + // If the redemption is for rebalancing or undelegation, a corresponding credit to asset balances will be done in `completeRedemption` + // If the redemption is for user withdrawals, we burn the escrow shares deposited by the user + if (isUserWithdrawal) { + liquidToken.debitQueuedAssetElShares(receivedAssets, redemptionRequestedElShares, latEscrowShares); + } else { + liquidToken.debitQueuedAssetElShares(receivedAssets, redemptionRequestedElShares, 0); + } + + _redemptionCompletionStatus = NOT_ENTERED; + return redemptionRequestedElShares; + } + + /// @inheritdoc IWithdrawalManager + function setWithdrawalDelay(uint256 newDelay) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (newDelay < 7 days || newDelay > 30 days) { + revert InvalidWithdrawalDelay(newDelay); + } + + uint256 oldDelay = withdrawalDelay; + withdrawalDelay = newDelay; + + emit WithdrawalDelayUpdated(oldDelay, newDelay); + } + + // ------------------------------------------------------------------------------ + // Getter functions + // ------------------------------------------------------------------------------ + + /// @inheritdoc IWithdrawalManager + function getUserWithdrawalRequests(address user) external view override returns (bytes32[] memory) { + return userWithdrawalRequests[user]; + } + + /// @inheritdoc IWithdrawalManager + function getWithdrawalRequests( + bytes32[] calldata requestIds + ) external view override returns (WithdrawalRequest[] memory) { + uint256 arrayLength = requestIds.length; + WithdrawalRequest[] memory requests = new WithdrawalRequest[](arrayLength); + + for (uint256 i = 0; i < arrayLength; i++) { + WithdrawalRequest memory request = withdrawalRequests[requestIds[i]]; + if (request.user == address(0)) revert WithdrawalRequestNotFound(requestIds[i]); + requests[i] = request; + } + + return requests; + } + + /// @inheritdoc IWithdrawalManager + function getRedemption( + bytes32 redemptionId + ) external view override returns (ILiquidTokenManager.Redemption memory) { + ILiquidTokenManager.Redemption memory redemption = redemptions[redemptionId]; + if (redemption.requestIds.length == 0) revert RedemptionNotFound(redemptionId); + + return redemption; + } +} diff --git a/src/interfaces/ICurvePool.sol b/src/interfaces/ICurvePool.sol new file mode 100644 index 00000000..6cfa0b74 --- /dev/null +++ b/src/interfaces/ICurvePool.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface ICurvePool { + function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) external payable returns (uint256); + + function exchange_underlying(int128 i, int128 j, uint256 dx, uint256 min_dy) external payable returns (uint256); + + function get_dy(int128 i, int128 j, uint256 amount) external view returns (uint256); + function get_dy_underlying(int128 i, int128 j, uint256 dx) external returns (uint256 out); +} diff --git a/src/interfaces/ILSTSwapRouter.sol b/src/interfaces/ILSTSwapRouter.sol new file mode 100644 index 00000000..8b2bd199 --- /dev/null +++ b/src/interfaces/ILSTSwapRouter.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +interface ILSTSwapRouter { + enum Protocol { + UniswapV3, // 0 + Curve, // 1 + DirectMint, // 2 + MultiHop, // 3 + MultiStep // 4 + } + + struct SwapStep { + address tokenIn; + address tokenOut; + uint256 amountIn; + uint256 minAmountOut; + address target; + bytes data; + uint256 value; + Protocol protocol; + } + + struct MultiStepExecutionPlan { + SwapStep[] steps; + uint256 expectedFinalAmount; + } + + function getCompleteMultiStepPlan( + address tokenIn, + address tokenOut, + uint256 amountIn, + address recipient + ) external returns (uint256 totalQuotedAmount, MultiStepExecutionPlan memory plan); +} diff --git a/src/interfaces/ILiquidToken.sol b/src/interfaces/ILiquidToken.sol index ee7da3bd..9e71db49 100644 --- a/src/interfaces/ILiquidToken.sol +++ b/src/interfaces/ILiquidToken.sol @@ -4,6 +4,8 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ILiquidTokenManager} from "../interfaces/ILiquidTokenManager.sol"; import {ITokenRegistryOracle} from "../interfaces/ITokenRegistryOracle.sol"; +import {IWithdrawalManager} from "../interfaces/IWithdrawalManager.sol"; +import {IRewardsManager} from "../interfaces/IRewardsManager.sol"; /// @title ILiquidToken Interface /// @notice Interface for the LiquidToken contract @@ -18,21 +20,12 @@ interface ILiquidToken { string symbol; ILiquidTokenManager liquidTokenManager; ITokenRegistryOracle tokenRegistryOracle; + IWithdrawalManager withdrawalManager; + IRewardsManager rewardsManager; address initialOwner; address pauser; } - /// @dev OUT OF SCOPE FOR V1 - /** - struct WithdrawalRequest { - address user; - IERC20[] assets; - uint256[] shareAmounts; - uint256 requestTime; - bool fulfilled; - } - */ - // ============================================================================ // EVENTS // ============================================================================ @@ -49,28 +42,6 @@ interface ILiquidToken { uint256 shares ); - /// @dev OUT OF SCOPE FOR V1 - /** - event WithdrawalRequested( - bytes32 indexed requestId, - address indexed user, - IERC20[] assets, - uint256[] shareAmounts, - uint256 timestamp - ); - */ - - /// @dev OUT OF SCOPE FOR V1 - /** - event WithdrawalFulfilled( - bytes32 indexed requestId, - address indexed user, - IERC20[] assets, - uint256[] shareAmounts, - uint256 timestamp - ); - */ - /// @notice Emitted when an asset is transferred event AssetTransferred( IERC20 indexed asset, @@ -98,13 +69,8 @@ interface ILiquidToken { /// @notice Error for unauthorized access by non-LiquidTokenManager error NotLiquidTokenManager(address sender); - /// @dev OUT OF SCOPE FOR V1 - /** - error InvalidWithdrawalRequest(); - error WithdrawalDelayNotMet(); - error WithdrawalAlreadyFulfilled(); - error DuplicateRequestId(bytes32 requestId); - */ + /// @notice Error for unauthorized access + error UnauthorizedAccess(address sender); /// @notice Error for mismatched array lengths error ArrayLengthMismatch(); @@ -127,6 +93,12 @@ interface ILiquidToken { /// @notice Specific token has invalid price error AssetPriceInvalid(address token); + /// @notice Error for invalid funds recepient + error InvalidReceiver(address receiver); + + /// @notice Error for invalid withdrawal request + error InvalidWithdrawalRequest(); + // ============================================================================ // FUNCTIONS // ============================================================================ @@ -139,35 +111,47 @@ interface ILiquidToken { /// @param assets The ERC20 assets to deposit /// @param amounts The amounts of the respective assets to deposit /// @param receiver The address to receive the minted shares - /// @return sharesArray The array of shares minted for each asset function deposit( IERC20[] calldata assets, uint256[] calldata amounts, address receiver ) external returns (uint256[] memory); - /// @dev Out OF SCOPE FOR V1 - /** - function requestWithdrawal( - IERC20[] memory withdrawAssets, - uint256[] memory shareAmounts - ) external; - */ + /// @notice Allows users to initiate a withdrawal request against their shares + /// @param assets The ERC20 assets to withdraw + /// @param amounts The amount of tokens to withdraw for each asset + function initiateWithdrawal(IERC20[] memory assets, uint256[] memory amounts) external returns (bytes32); - /// @dev Out OF SCOPE FOR V1 - /** - function fulfillWithdrawal(bytes32 requestId) external; - */ + /// @notice Previews a withdrawal request and returns whether or not it would be successful + /// @param assets The ERC20 assets to withdraw + /// @param amounts The amount of tokens to withdraw for each asset + function previewWithdrawal(IERC20[] memory assets, uint256[] memory amounts) external view returns (bool); + + /// @notice Credits queued balances for a given set of assets denoted in their corresponding EL shares + /// @param assets The assets to credit + /// @param amounts The credit amounts expressed in EL shares + function creditQueuedAssetElShares(IERC20[] calldata assets, uint256[] calldata amounts) external; + + /// @notice Debits queued balances for a given set of assets denoted in their corresponding EL shares + /// @param assets The assets to debit + /// @param amounts The debit amounts expressed in EL shares + /// @param latSharesToBurn Escrow LAT shares to burn along with this debit (is non-zero only for user withdrawal fulfilment) + function debitQueuedAssetElShares( + IERC20[] calldata assets, + uint256[] calldata amounts, + uint256 latSharesToBurn + ) external; - /// @notice Credits queued balances for a given set of assets + /// @notice Credits asset balances for a given set of assets /// @param assets The assets to credit /// @param amounts The credit amounts expressed in native token - function creditQueuedAssetBalances(IERC20[] calldata assets, uint256[] calldata amounts) external; + function creditAssetBalances(IERC20[] calldata assets, uint256[] calldata amounts) external; /// @notice Allows the LiquidTokenManager to transfer assets from this contract /// @param assetsToRetrieve The ERC20 assets to transfer /// @param amounts The amounts of each asset to transfer - function transferAssets(IERC20[] calldata assetsToRetrieve, uint256[] calldata amounts) external; + /// @param receiver The receiver of the funds, either `LiquidTokenManager` or `WithdrawalManager` + function transferAssets(IERC20[] calldata assetsToRetrieve, uint256[] calldata amounts, address receiver) external; /// @notice Calculates the number of shares that correspond to a given amount of an asset /// @param asset The ERC20 asset @@ -185,19 +169,6 @@ interface ILiquidToken { /// @return The total value of assets in the unit of account function totalAssets() external view returns (uint256); - /// @dev Out OF SCOPE FOR V1 - /** - function getUserWithdrawalRequests(address user) - external - view - returns (bytes32[] memory); - */ - - /// @dev Out OF SCOPE FOR V1 - /** - function getWithdrawalRequest(bytes32 requestId) external view returns (WithdrawalRequest memory); - */ - /// @notice Returns the unstaked balances for a set of assets /// @param assetList The list of assets to get balances for /// @return An array of asset balances diff --git a/src/interfaces/ILiquidTokenManager.sol b/src/interfaces/ILiquidTokenManager.sol index 7f58785c..7ab95011 100644 --- a/src/interfaces/ILiquidTokenManager.sol +++ b/src/interfaces/ILiquidTokenManager.sol @@ -3,13 +3,15 @@ pragma solidity ^0.8.27; import {IStrategyManager} from "@eigenlayer/contracts/interfaces/IStrategyManager.sol"; import {IDelegationManager} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; +import {IDelegationManagerTypes} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; import {IStrategy} from "@eigenlayer/contracts/interfaces/IStrategy.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ISignatureUtilsMixinTypes} from "@eigenlayer/contracts/interfaces/ISignatureUtilsMixin.sol"; - +import {IWETH} from "../interfaces/IWETH.sol"; import {ILiquidToken} from "./ILiquidToken.sol"; import {IStakerNodeCoordinator} from "./IStakerNodeCoordinator.sol"; import {ITokenRegistryOracle} from "./ITokenRegistryOracle.sol"; +import {IWithdrawalManager} from "./IWithdrawalManager.sol"; /// @title ILiquidTokenManager Interface /// @notice Interface for the LiquidTokenManager contract @@ -25,6 +27,7 @@ interface ILiquidTokenManager { IDelegationManager delegationManager; IStakerNodeCoordinator stakerNodeCoordinator; ITokenRegistryOracle tokenRegistryOracle; + IWithdrawalManager withdrawalManager; address initialOwner; address strategyController; address priceUpdater; @@ -32,21 +35,66 @@ interface ILiquidTokenManager { /// @notice Supported token information /// @param decimals The number of decimals for the token - /// @param pricePerUnit The price per unit of the token - /// @param volatilityThreshold The allowed change ratio for price update, in 1e18. Must be 0 (disabled) or >= 0.01 * 1e18 and <= 1 * 1e18. + /// @param pricePerUnit The price per unit of the token in the unit of account (18 decimals) + /// @param volatilityThreshold The allowed change ratio for price update, in 1e18. Must be 0 (disabled) or >= 0.01 * 1e18 and <= 1 * 1e18 struct TokenInfo { uint256 decimals; uint256 pricePerUnit; uint256 volatilityThreshold; } - /// @notice Represents an allocation of assets to a node + /// @notice Represents an allocation of assets to a node for staking + /// @param nodeId The ID of the staker node to allocate assets to + /// @param assets Array of token addresses to stake + /// @param amounts Array of amounts to stake for each asset struct NodeAllocation { uint256 nodeId; IERC20[] assets; uint256[] amounts; } + /// @notice Represents allocation of assets to a node for staking, including a set of swaps swap + /// @param nodeId The ID of the staker node to allocate assets to + /// @param assetsToSwap Array of input tokens to swap from + /// @param amountsToSwap Array of amounts to swap + /// @param assetsToStake Array of output tokens to receive and stake + struct NodeAllocationWithSwap { + uint256 nodeId; + IERC20[] assetsToSwap; + uint256[] amountsToSwap; + IERC20[] assetsToStake; + } + + /// @notice Represents an intent to make a certain amount of funds available by calling staker node withdrawals + /// @dev Redemptions are made by the manager to: + /// i. settle a set of user withdrawal requests + /// ii. partially withdraw a set of assets from nodes + /// iii. undelegate nodes from Operators (and hence withdraw all assets) + /// @param requestIds Array of request IDs associated with this redemption + /// @param withdrawalRoots Array of withdrawal roots from EigenLayer withdrawals + /// @param receiver Contract that will receive the withdrawn funds (`LiquidToken` or `WithdrawalManager`) + /// @param assets Array of token addresses being withdrawn + /// @param elWithdrawableShares Array of EL shares withdrawable per asset (after any slashing) + struct Redemption { + bytes32[] requestIds; + bytes32[] withdrawalRoots; + IERC20[] assets; + uint256[] elWithdrawableShares; + address receiver; + } + + /// @notice Parameters for settling user withdrawals + /// @param requestIds The request IDs of the user withdrawal requests to be fulfilled + /// @param nodeIds The node IDs from which funds will be withdrawn + /// @param elAssets The array of assets to be withdrawn for a given node from EigenLayer + /// @param elDepositShares The EL deposit shares for `elAssets` (unscaled, pre-slashing shares) + struct UserWithdrawalsSettlement { + bytes32[] requestIds; + uint256[] nodeIds; + IERC20[][] elAssets; + uint256[][] elDepositShares; + } + // ============================================================================ // EVENTS // ============================================================================ @@ -95,6 +143,58 @@ interface ILiquidTokenManager { /// @notice Emitted when a token is removed from the registry event TokenRemoved(IERC20 indexed token, address indexed remover); + + + + + /// @notice Emitted when a swap is executed + event SwapExecuted( + address indexed tokenIn, + address indexed tokenOut, + uint256 amountIn, + uint256 amountOut, + uint256 indexed nodeId + ); + + /// @notice Emitted when a redemption is created due to node undelegation + /// @dev `assets` is a 1D array since each withdrawal will have only 1 corresponding asset + event RedemptionCreatedForNodeUndelegation( + bytes32 redemptionId, + bytes32 requestId, + bytes32[] withdrawalRoots, + IDelegationManagerTypes.Withdrawal[] withdrawals, + IERC20[] assets, + uint256 nodeId + ); + + /// @notice Emitted when a redemption is created to settle user withdrawals + event RedemptionCreatedForUserWithdrawals( + bytes32 redemptionId, + bytes32[] requestIds, + bytes32[] withdrawalRoots, + IDelegationManagerTypes.Withdrawal[] withdrawals, + IERC20[][] assets, + uint256[] nodeIds + ); + + /// @notice Emitted when a redemption is created for rebalancing + event RedemptionCreatedForRebalancing( + bytes32 redemptionId, + bytes32[] requestIds, + bytes32[] withdrawalRoots, + IDelegationManagerTypes.Withdrawal[] withdrawals, + IERC20[][] assets, + uint256[] nodeIds + ); + + /// @notice Emitted when a redemption is successfuly completed + event RedemptionCompleted( + bytes32 indexed redemptionId, + IERC20[] assets, + uint256[] requestedElShares, + uint256[] receivedAmounts + ); + // ============================================================================ // CUSTOM ERRORS // ============================================================================ @@ -102,9 +202,18 @@ interface ILiquidTokenManager { /// @notice Error for zero address error ZeroAddress(); + /// @notice Error for zero amount + error ZeroAmount(); + /// @notice Error for invalid staking amount error InvalidStakingAmount(uint256 amount); + /// @notice Error thrown when an operation requires more tokens than are available + error InsufficientBalance(IERC20 asset, uint256 required, uint256 available); + + /// @notice Error thrown when funds would be sent to an invalid receiver + error InvalidReceiver(address receiver); + /// @notice Error when asset already exists error TokenExists(address asset); @@ -150,6 +259,18 @@ interface ILiquidTokenManager { /// @notice Error thrown when a price update fails due to change exceeding the volatility threshold error VolatilityThresholdHit(IERC20 token, uint256 changeRatio); + /// @notice Error thrown when ETH is used as tokenIn or tokenOut (only allowed as bridge asset) + error ETHNotSupportedAsDirectToken(address token); + + /// @notice Error thrown when a withdrawal root doesn't match the expected value + error InvalidWithdrawalRoot(); + + /// @notice Error thrown when redemption amounts for user withdrawal settlement are not enough up to make the withdrawal requests whole + error RequestsDoNotSettle(address asset, uint256 expectedAmount, uint256 requestAmount); + + /// @notice Error thrown when a withdrawal is missing when attempting redemption completion + error WithdrawalMissing(bytes32 withdrawalRoot); + // ============================================================================ // FUNCTIONS // ============================================================================ @@ -158,6 +279,8 @@ interface ILiquidTokenManager { /// @param init Initialization parameters function initialize(Init memory init) external; + + /// @notice Adds a new token to the registry and configures its price sources /// @param token Address of the token to add /// @param decimals Number of decimals for the token @@ -216,13 +339,73 @@ interface ILiquidTokenManager { /// @param allocations Array of NodeAllocation structs containing staking information function stakeAssetsToNodes(NodeAllocation[] calldata allocations) external; - /// @dev Out OF SCOPE FOR V1 /** - function undelegateNodes( - uint256[] calldata nodeIds + /// @notice OUT OF SCOPE FOR V2 + + /// @notice Swaps multiple assets and stakes them to multiple nodes + /// @param allocationsWithSwaps Array of node allocations with swap instructions + function swapAndStakeAssetsToNodes(NodeAllocationWithSwap[] calldata allocationsWithSwaps) external; + + /// @notice Swaps assets and stakes them to a single node + /// @param nodeId The node ID to stake to + /// @param assetsToSwap Array of input tokens to swap from + /// @param amountsToSwap Array of amounts to swap + /// @param assetsToStake Array of output tokens to receive and stake + function swapAndStakeAssetsToNode( + uint256 nodeId, + IERC20[] memory assetsToSwap, + uint256[] memory amountsToSwap, + IERC20[] memory assetsToStake + ) external; + + + /// @notice Undelegates a set of staker nodes from their operators and creates a set of redemptions + /// @dev A separate redemption is created for each node, since undelegating a node on EL queues one withdrawal per strategy + /// @dev On completing a redemption created from undelegation, the funds are transferred to `LiquidToken` + /// @dev Caller should index the `RedemptionCreatedForNodeUndelegation` event to have the required data for redemption completion + /// @param nodeIds The IDs of the staker nodes + function undelegateNodes(uint256[] calldata nodeIds) external; + + /// @notice Allows rebalancing of funds by partially withdrawing assets from nodes and creating a redemption + /// @dev On completing the redemption, the funds are transferred to `LiquidToken` + /// @dev Caller should index the `RedemptionCreatedForRebalancing` event to have the required data for redemption completion + /// @dev Strategies are always withdrawn into their respective assets, they are never converted + /// @param nodeIds The ID of the nodes to withdraw from + /// @param assets The array of assets to withdraw for each node + /// @param elDepositShares The EL deposit shares for `assets` (unscaled, pre-slashing shares) + function withdrawNodeAssets( + uint256[] calldata nodeIds, + IERC20[][] calldata assets, + uint256[][] calldata elDepositShares ) external; */ + /// @notice Enables a set of user withdrawal requests to be fulfillable after 14 days by the respective users + /// @dev This function only uses staked balances from EigenLayer to ensure fair slashing distribution + /// @dev This function accepts a settlement only if it will actually allocate enough EL shares per token to settle ALL user withdrawal requests + /// @dev A redemption is created and on completion, funds are transferred to `WithdrawalManager` + /// @dev Caller should index the `RedemptionCreatedForUserWithdrawals` event to have the required data for redemption completion + /// @dev All users share the same post-slashing conversion rate, ensuring fair loss distribution + /// @param settlement The proposed settlement + function settleUserWithdrawals(UserWithdrawalsSettlement calldata settlement) external; + + /// @notice Completes withdrawals on EigenLayer for a given redemption and transfers funds to the `receiver` of the redemption + /// @dev The caller must make sure every `withdrawals[i][]` aligns with the corresponding `nodeIds[i]` + /// @dev The caller must make sure every `assets[i][j][]` aligns with the corresponding `withdrawals[i][]` + /// @dev The burden is on the caller to keep track of (node, withdrawal, asset) pairs via corresponding events emitted during redemption creation + /// @dev A redemption can never be partially completed, ie. if any withdrawal is missing from the input, the fn will revert + /// @dev Fn will revert if a withdrawal that wasn't part of the redemption is provided as input + /// @param redemptionId The ID of the redemption to complete + /// @param nodeIds The set of all node IDs concerned with the redemption + /// @param withdrawals The set of EL Withdrawal structs concerned with the redemption per node ID + /// @param assets The set of assets redeemed by the corresponding EL withdrawals + function completeRedemption( + bytes32 redemptionId, + uint256[] calldata nodeIds, + IDelegationManagerTypes.Withdrawal[][] calldata withdrawals, + IERC20[][][] calldata assets + ) external; + /// @notice Retrieves the list of supported tokens /// @return An array of addresses of supported tokens function getSupportedTokens() external view returns (IERC20[] memory); @@ -237,34 +420,54 @@ interface ILiquidTokenManager { /// @return IStrategy Interface for the corresponding strategy function getTokenStrategy(IERC20 asset) external view returns (IStrategy); + /// @notice Returns the set of strategies for a given set of assets + /// @param assets Set of assets to get the strategies for + /// @return IStrategy Interfaces for the corresponding set of strategies + function getTokensStrategies(IERC20[] memory assets) external view returns (IStrategy[] memory); + /// @notice Returns the token for a given strategy /// @param strategy Strategy to get the token for /// @return IERC20 Interface for the corresponding token function getStrategyToken(IStrategy strategy) external view returns (IERC20); /// @notice Gets the staked deposits balance of an asset for all nodes - /// @dev This corresponds to the asset value of `depositShares` which does not factor in any slashing + /// @dev This corresponds to the value of `depositShares` which does not factor in any slashing /// @param asset The asset to check the balance for + /// @param inElShares Whether to return EL shares (true) or underlying amount (false) /// @return The total staked balance of the asset across all nodes - function getDepositAssetBalance(IERC20 asset) external view returns (uint256); + function getDepositAssetBalance(IERC20 asset, bool inElShares) external view returns (uint256); /// @notice Gets the staked deposits balance of an asset for a specific node - /// @dev This corresponds to the asset value of `depositShares` which does not factor in any slashing + /// @dev This corresponds to the value of `depositShares` which does not factor in any slashing /// @param asset The asset to check the balance for /// @param nodeId The ID of the node + /// @param inElShares Whether to return EL shares (true) or underlying amount (false) /// @return The staked balance of the asset for the specific node - function getDepositAssetBalanceNode(IERC20 asset, uint256 nodeId) external view returns (uint256); + function getDepositAssetBalanceNode(IERC20 asset, uint256 nodeId, bool inElShares) external view returns (uint256); /// @notice Gets the withdrawable balance of an asset for all nodes - /// @dev This corresponds to the asset value of `withdrawableShares` which is `depositShares` minus slashing if any + /// @dev This corresponds to the value of `withdrawableShares` which is `depositShares` minus slashing if any /// @param asset The asset token address - function getWithdrawableAssetBalance(IERC20 asset) external view returns (uint256); + /// @param inElShares Whether to return EL shares (true) or underlying amount (false) + function getWithdrawableAssetBalance(IERC20 asset, bool inElShares) external view returns (uint256); /// @notice Gets the withdrawable balance of an asset for a specific node - /// @dev This corresponds to the asset value of `withdrawableShares` which is `depositShares` minus slashing if any + /// @dev This corresponds to the value of `withdrawableShares` which is `depositShares` minus slashing if any /// @param asset The asset token address /// @param nodeId The ID of the node - function getWithdrawableAssetBalanceNode(IERC20 asset, uint256 nodeId) external view returns (uint256); + /// @param inElShares Whether to return EL shares (true) or underlying amount (false) + function getWithdrawableAssetBalanceNode( + IERC20 asset, + uint256 nodeId, + bool inElShares + ) external view returns (uint256); + + /// @notice Gets the withdrawable balance (after slashing) of an asset for a given amount + /// @dev This checks the balances across all nodes and factors in slashing across the system + /// @param asset The asset token address + /// @param amount The amount of asset to calculate corresponding withdrawable amount + /// @param inElShares Whether to return EL shares (true) or underlying amount (false) + function getWithdrawableAssetAmount(IERC20 asset, uint256 amount, bool inElShares) external view returns (uint256); /// @notice Checks if a token is supported /// @param token Address of the token to check @@ -288,6 +491,18 @@ interface ILiquidTokenManager { /// @return True if the strategy is supported function isStrategySupported(IStrategy strategy) external view returns (bool); + /// @notice Convert a given amount EL shares of a token to its underlying value + /// @param asset Address of the token + /// @param amount Amount of shares + /// @return Underlying asset value + function assetSharesToUnderlying(IERC20 asset, uint256 amount) external view returns (uint256); + + /// @notice Convert a given amount of a token to EL shares amount + /// @param asset Address of the token + /// @param amount Amount of token + /// @return EL shares amount + function assetUnderlyingToShares(IERC20 asset, uint256 amount) external view returns (uint256); + /// @notice Returns the token registry oracle contract /// @return The ITokenRegistryOracle interface function tokenRegistryOracle() external view returns (ITokenRegistryOracle); @@ -307,4 +522,6 @@ interface ILiquidTokenManager { /// @notice Returns the LiquidToken contract /// @return The ILiquidToken interface function liquidToken() external view returns (ILiquidToken); -} + + +} \ No newline at end of file diff --git a/src/interfaces/IRewardsManager.sol b/src/interfaces/IRewardsManager.sol new file mode 100644 index 00000000..aef1bc4d --- /dev/null +++ b/src/interfaces/IRewardsManager.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {IRewardsCoordinatorTypes} from "@eigenlayer/contracts/interfaces/IRewardsCoordinator.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {IRewardsCoordinator} from "@eigenlayer/contracts/interfaces/IRewardsCoordinator.sol"; +import {ILiquidToken} from "../interfaces/ILiquidToken.sol"; +import {ILiquidTokenManager} from "../interfaces/ILiquidTokenManager.sol"; + +/// @title IRewardsManager Interface +/// @notice Interface for the RewardsManager contract +interface IRewardsManager { + // ============================================================================ + // STRUCTS + // ============================================================================ + + /// @notice Initialization parameters for RewardsManager + struct Init { + IRewardsCoordinator rewardsCoordinator; + ILiquidToken liquidToken; + ILiquidTokenManager liquidTokenManager; + address initialOwner; + address pauser; + } + + // ============================================================================ + // EVENTS + // ============================================================================ + + /// @notice Emitted when unsupported asset balance is updated + event UnsupportedAssetBalanceUpdated(address indexed asset, uint256 oldBalance, uint256 newBalance); + + /// @notice Emitted when rewards are claimed + /// @dev These values tell us the actual tokens realized by the LAT after a process claim procedure + /// @dev The values may differ from the corresponding EL event due to rounding/transfer loss or unexpected token transfers to this contract + /// @dev We are only concerned with actual value accrued to LAT, exact EL data can be found via corresponding EL events + event RewardsClaimed( + uint32 indexed rootIndex, + address indexed earner, + IERC20[] supportedAssets, + uint256[] supportedAssetAmounts, + IERC20[] unsupportedAssets, + uint256[] unsupportedAssetAmounts + ); + + /// @notice Emitted when a claimer is added for an earner + event ClaimerForAdded(address indexed earner); + + /// @notice Emitted when a claimer is removed for an earner + event ClaimerForRemoved(address indexed earner); + + // ============================================================================ + // CUSTOM ERRORS + // ============================================================================ + + /// @notice Error for zero address + error ZeroAddress(); + + /// @notice Error when this contract is not set as claimer for the earner + error NotClaimerFor(address earner); + + /// @notice Error for mismatched array lengths + error ArrayLengthMismatch(); + + // ============================================================================ + // FUNCTIONS + // ============================================================================ + + /// @notice Initializes the RewardsManager contract + /// @param init Initialization parameters + function initialize(Init memory init) external; + + /// @notice Updates the claimer status for an earner + /// @param earner The earner address to update claimer status for + function updateClaimerFor(address earner) external; + + /// @notice Processes a single reward claim + /// @param claim The reward merkle claim to process + function processClaim(IRewardsCoordinatorTypes.RewardsMerkleClaim calldata claim) external; + + /// @notice Processes multiple reward claims + /// @param claims Array of reward merkle claims to process + function processClaims(IRewardsCoordinatorTypes.RewardsMerkleClaim[] calldata claims) external; + + /// @notice Returns the balances of unsupported assets for the given asset list + /// @param assetList The list of assets to get balances for + /// @return An array of asset balances + function balanceAssets(IERC20[] calldata assetList) external view returns (uint256[] memory); + + /// @notice Get all claimers as an array + /// @return Array of all claimer addresses + function claimerFor() external view returns (address[] memory); + + /// @notice Get number of claimers + /// @return The total number of claimers + function claimerForLength() external view returns (uint256); + + /// @notice Get all unsupported assets as an array + /// @return Array of all unsupported assets addresses + function unsupportedAssets() external view returns (address[] memory); + + /// @notice Get number of unsupported assets + /// @return The total number of unsupported assets + function unsupportedAssetsLength() external view returns (uint256); + + /// @notice Pauses the contract + function pause() external; + + /// @notice Unpauses the contract + function unpause() external; +} diff --git a/src/interfaces/IStakerNode.sol b/src/interfaces/IStakerNode.sol index 0ae96df2..d15b7254 100644 --- a/src/interfaces/IStakerNode.sol +++ b/src/interfaces/IStakerNode.sol @@ -4,6 +4,8 @@ pragma solidity ^0.8.27; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IStrategy} from "@eigenlayer/contracts/interfaces/IStrategy.sol"; import {ISignatureUtilsMixinTypes} from "@eigenlayer/contracts/interfaces/ISignatureUtilsMixin.sol"; +import {IDelegationManager} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; +import {IDelegationManagerTypes} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; import {IStakerNodeCoordinator} from "../interfaces/IStakerNodeCoordinator.sol"; @@ -58,6 +60,9 @@ interface IStakerNode { /// @notice Error for undelegating node when not delegated error NodeIsNotDelegated(); + /// @notice Error thrown when array lengths don't match in function parameters + error LengthMismatch(uint256 length1, uint256 length2); + // ============================================================================ // FUNCTIONS // ============================================================================ @@ -66,6 +71,9 @@ interface IStakerNode { /// @param init Initialization parameters function initialize(Init memory init) external; + /// @notice Initializes V2 where the rewards claiming is delegated to the RewardsManager contract + function initializeV2() external; + /// @notice Delegates the StakerNode's assets to an operator /// @param operator Address of the operator to delegate to /// @param signature Signature authorizing the delegation @@ -86,10 +94,30 @@ interface IStakerNode { IStrategy[] calldata strategies ) external; - /// @dev Out OF SCOPE FOR V1 - /** - function undelegate() external; - */ + /// @notice Creates a withdrawal request of EL for a set of strategies + /// @dev EL creates one withdrawal request regardless of the number of strategies + /// @param strategies The set of strategies to withdraw from + /// @param shareAmounts The amount of shares (unscaled `depositShares`) to withdraw per strategy + /// @return The withdrawal root hash from Eigenlayer + function withdrawAssets( + IStrategy[] calldata strategies, + uint256[] calldata shareAmounts + ) external returns (bytes32); + + /// @notice Completes a set of withdrawal requests on EL and retrieves funds + /// @dev The funds are always withdrawn in tokens and sent to `LiquidTokenManager`, ie the node never keeps unstaked assets + /// @param withdrawals The set of EL withdrawals to complete and associated data + /// @param tokens The set of tokens to receive funds in + /// @return Array of token addresses that were received from the withdrawal + function completeWithdrawals( + IDelegationManagerTypes.Withdrawal[] calldata withdrawals, + IERC20[][] calldata tokens + ) external returns (IERC20[] memory); + + /// @notice Undelegates the node from the current operator and withdraws all shares from all strategies + /// @dev EL creates one withdrawal request per strategy in the case of undelegation + /// @return Array of withdrawal root hashes from Eigenlayer + function undelegate() external returns (bytes32[] memory); /// @notice Returns the address of the current implementation contract /// @return The address of the implementation contract diff --git a/src/interfaces/IStakerNodeCoordinator.sol b/src/interfaces/IStakerNodeCoordinator.sol index ac2d5842..a97075e8 100644 --- a/src/interfaces/IStakerNodeCoordinator.sol +++ b/src/interfaces/IStakerNodeCoordinator.sol @@ -4,9 +4,12 @@ pragma solidity ^0.8.27; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IStrategyManager} from "@eigenlayer/contracts/interfaces/IStrategyManager.sol"; import {IDelegationManager} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; +import {IRewardsCoordinator} from "@eigenlayer/contracts/interfaces/IRewardsCoordinator.sol"; import {IStakerNode} from "./IStakerNode.sol"; import {ILiquidTokenManager} from "../interfaces/ILiquidTokenManager.sol"; +import {IWithdrawalManager} from "../interfaces/IWithdrawalManager.sol"; +import {IRewardsManager} from "../interfaces/IRewardsManager.sol"; /// @title IStakerNodeCoordinator Interface /// @notice Interface for the StakerNodeCoordinator contract @@ -18,8 +21,11 @@ interface IStakerNodeCoordinator { /// @notice Initialization parameters for StakerNodeCoordinator struct Init { ILiquidTokenManager liquidTokenManager; + IWithdrawalManager withdrawalManager; + IRewardsManager rewardsManager; IDelegationManager delegationManager; IStrategyManager strategyManager; + IRewardsCoordinator rewardsCoordinator; uint256 maxNodes; address initialOwner; address pauser; @@ -64,9 +70,6 @@ interface IStakerNodeCoordinator { /// @notice Error for unsupported asset error UnsupportedAsset(IERC20 asset); - /// @notice Error for unauthorized access - error Unauthorized(); - /// @notice Error for insufficient funds error InsufficientFunds(); @@ -102,6 +105,11 @@ interface IStakerNodeCoordinator { /// @param init Initialization parameters function initialize(Init calldata init) external; + /// @notice Creates multiple staker nodes at once + /// @param number The number of staker nodes to create + /// @return An array of the newly created IStakerNode interfaces + function createStakerNodes(uint256 number) external returns (IStakerNode[] memory); + /// @notice Creates a new staker node /// @return The IStakerNode interface of the newly created staker node function createStakerNode() external returns (IStakerNode); @@ -145,10 +153,22 @@ interface IStakerNodeCoordinator { /// @return The IStrategyManager interface function strategyManager() external view returns (IStrategyManager); + /// @notice Gets the rewards coordinator contract + /// @return The IRewardsCoordinator interface + function rewardsCoordinator() external view returns (IRewardsCoordinator); + /// @notice Gets the liquid token manager contract /// @return The ILiquidTokenManager interface function liquidTokenManager() external view returns (ILiquidTokenManager); + /// @notice Gets the withdrawal manager contract + /// @return The IWithdrawalManager interface + function withdrawalManager() external view returns (IWithdrawalManager); + + /// @notice Gets the rewards manager contract + /// @return The IRewardsManager interface + function rewardsManager() external view returns (IRewardsManager); + /// @notice Gets the maximum number of nodes allowed /// @return The maximum number of nodes function maxNodes() external view returns (uint256); diff --git a/src/interfaces/ITokenRegistryOracle.sol b/src/interfaces/ITokenRegistryOracle.sol index bdaa2fcc..922aa04c 100644 --- a/src/interfaces/ITokenRegistryOracle.sol +++ b/src/interfaces/ITokenRegistryOracle.sol @@ -21,9 +21,9 @@ interface ITokenRegistryOracle { } struct TokenConfig { - uint8 primaryType; // 1=Chainlink, 2=Curve, 3=Protocol, + uint8 primaryType; // 1=Chainlink, 2=Curve, 3=Protocol, 4=UniswapV3TWAP, 5=BalancerV2 uint8 needsArg; // 0=No arg, 1=Needs arg - uint16 reserved; // For future use + uint16 reserved; // it used to be for future use and now : For TWAP period in minutes (when applicable) address primarySource; // Primary price source address address fallbackSource; // Fallback source contract address bytes4 fallbackFn; // Function selector for fallback @@ -158,4 +158,4 @@ interface ITokenRegistryOracle { /// @return price The price of the asset in 18 decimals /// @return success Whether the price fetch was successful function _getTokenPrice_getter(address token) external returns (uint256 price, bool success); -} +} \ No newline at end of file diff --git a/src/interfaces/IWETH.sol b/src/interfaces/IWETH.sol new file mode 100644 index 00000000..78750ac0 --- /dev/null +++ b/src/interfaces/IWETH.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IWETH { + function deposit() external payable; + function withdraw(uint256) external; + function balanceOf(address) external view returns (uint256); +} diff --git a/src/interfaces/IWithdrawalManager.sol b/src/interfaces/IWithdrawalManager.sol new file mode 100644 index 00000000..e37ab84b --- /dev/null +++ b/src/interfaces/IWithdrawalManager.sol @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IStrategyManager} from "@eigenlayer/contracts/interfaces/IStrategyManager.sol"; +import {IDelegationManager} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; +import {IDelegationManagerTypes} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; +import {IStrategy} from "@eigenlayer/contracts/interfaces/IStrategy.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {ILiquidToken} from "./ILiquidToken.sol"; +import {ILiquidTokenManager} from "./ILiquidTokenManager.sol"; +import {IStakerNodeCoordinator} from "./IStakerNodeCoordinator.sol"; + +/// @title IWithdrawalManager Interface +/// @notice Interface for managing withdrawals between staker nodes and users +interface IWithdrawalManager { + // ============================================================================ + // STRUCTS + // ============================================================================ + + /// @notice Initialization parameters for WithdrawalManager + /// @param initialOwner Address that will be granted DEFAULT_ADMIN_ROLE + /// @param delegationManager The EigenLayer DelegationManager contract + /// @param liquidToken The LiquidToken contract + /// @param liquidTokenManager The LiquidTokenManager contract + /// @param stakerNodeCoordinator The StakerNodeCoordinator contract + struct Init { + address initialOwner; + IDelegationManager delegationManager; + ILiquidToken liquidToken; + ILiquidTokenManager liquidTokenManager; + IStakerNodeCoordinator stakerNodeCoordinator; + } + + /// @notice Represents a user's withdrawal request + /// @param user Address of the user requesting withdrawal + /// @param assets Array of token addresses being withdrawn + /// @param requestedAmounts Array of amounts being withdrawn per asset (in the unit of the asset) + /// @param elWithdrawableShares Array of EL shares withdrawable per asset (after any slashing) + /// @param sharesDeposited The LAT shares deposited by the user, to be burned on withdrawal fulfilment + /// @param requestTime Timestamp when the withdrawal was requested + /// @param canFulfill Whether the withdrawal can be fulfilled by the user (set to true after redemption completion) + struct WithdrawalRequest { + address user; + IERC20[] assets; + uint256[] requestedAmounts; + uint256[] elWithdrawableShares; + uint256 sharesDeposited; + uint256 requestTime; + bool canFulfill; + } + + // ============================================================================ + // EVENTS + // ============================================================================ + + /// @notice Emitted when a user initiates a withdrawal request + /// @param requestId Unique identifier for the withdrawal request + /// @param user Address of the user requesting withdrawal + /// @param assets Array of token addresses being withdrawn + /// @param amounts Array of amounts being withdrawn per asset + /// @param withdrawableElShares Array of withdrawable EL shares per asset + /// @param sharesDeposited The LAT shares deposited by the user, to be burned on withdrawal fulfilment + /// @param timestamp Block timestamp when the request was made + event WithdrawalInitiated( + bytes32 indexed requestId, + address indexed user, + IERC20[] assets, + uint256[] amounts, + uint256[] withdrawableElShares, + uint256 sharesDeposited, + uint256 timestamp + ); + + /// @notice Emitted when a user's withdrawal request is fulfilled + /// @param requestId Unique identifier for the withdrawal request + /// @param user Address of the user whose withdrawal was fulfilled + /// @param assets Array of token addresses that were withdrawn + /// @param amounts Array of token amounts that were withdrawn + /// @param timestamp Block timestamp when the fulfillment occurred + event WithdrawalFulfilled( + bytes32 indexed requestId, + address indexed user, + IERC20[] assets, + uint256[] amounts, + uint256 timestamp + ); + + /// @notice Emitted when a user is slashed and the slashing is applied to the corresponding withdrawable amount on their withdrawal request + /// @param requestId Unique identifier for the withdrawal request + /// @param user Address of the user whose withdrawal was fulfilled + /// @param asset The ERC20 token that was slashed + /// @param originalAmount The original withdrawal amount requested by the user on creating the withdrawal request + /// @param withdrawableAmount The final withdrawable amount after slashing + event UserSlashed( + bytes32 indexed requestId, + address indexed user, + IERC20 indexed asset, + uint256 originalAmount, + uint256 withdrawableAmount + ); + + /// @notice Emitted when the withdrawal delay is updated + /// @param oldDelay Previous withdrawal delay value + /// @param newDelay Newly updated withdrawal delay value + event WithdrawalDelayUpdated(uint256 oldDelay, uint256 newDelay); + + /// @notice Emitted when an asset was not received in redemption completion + /// @param requestId The withdrawal request ID + /// @param asset The asset that was not received + /// @param expectedShares The expected shares that were not received + event AssetNotReceived(bytes32 indexed requestId, IERC20 indexed asset, uint256 expectedShares); + + // ============================================================================ + // CUSTOM ERRORS + // ============================================================================ + + /// @notice Error thrown when a zero address is provided where a non-zero address is required + error ZeroAddress(); + + /// @notice Error for zero amount + error ZeroAmount(); + + /// @notice Error thrown when a function restricted to LiquidToken is called by another address + /// @param sender Address that attempted the call + error NotLiquidToken(address sender); + + /// @notice Error thrown when a function restricted to LiquidTokenManager is called by another address + /// @param sender Address that attempted the call + error NotLiquidTokenManager(address sender); + + /// @notice Error thrown when array lengths don't match in function parameters + error LengthMismatch(); + + /// @notice Error thrown when a withdrawal request is invalid + error InvalidWithdrawalRequest(); + + /// @notice Error for unauthorized access + error UnauthorizedAccess(address sender); + + /// @notice Error thrown when a redemption is invalid + error InvalidRedemption(); + + /// @notice Error thrown when attempting to fulfill a withdrawal before the delay period + error WithdrawalDelayNotMet(); + + /// @notice Error thrown when withdrawal cannot be fulfilled yet (redemption not completed) + error WithdrawalNotReadyToFulfill(); + + /// @notice Error when withdrawal delay value was attempted with an invalid delay value + error InvalidWithdrawalDelay(uint256 delay); + + /// @notice Error thrown when a withdrawal request ID doesn't exist + /// @param requestId The withdrawal request ID that wasn't found + error WithdrawalRequestNotFound(bytes32 requestId); + + /// @notice Error thrown when a redemption ID doesn't exist + /// @param redemptionId The redemption ID that wasn't found + error RedemptionNotFound(bytes32 redemptionId); + + /// @notice Error thrown when contract has insufficient balance for an operation + /// @param asset The token address + /// @param required The amount required + /// @param available The amount available + error InsufficientBalance(IERC20 asset, uint256 required, uint256 available); + + /// @notice Error thrown when assets array exceeds maximum allowed + error ExceedsMaxAssets(); + + /// @notice Error thrown when a withdrawal request already exists + error RequestAlreadyExists(); + + /// @notice Error thrown when duplicate assets are provided + /// @param asset The duplicate asset address + error DuplicateAsset(address asset); + + /// @notice Error thrown when an unsupported asset is provided + /// @param asset The unsupported asset + error UnsupportedAsset(IERC20 asset); + + /// @notice Error thrown when a request is being processed + error RequestBeingProcessed(); + + /// @notice Error thrown when a redemption already exists + error RedemptionAlreadyExists(); + + /// @notice Error thrown when a reentrant call is detected + error ReentrantCall(); + + // ============================================================================ + // FUNCTIONS + // ============================================================================ + + /// @notice Initializes the contract + /// @param init Initialization parameters + function initialize(Init memory init) external; + + /// @notice Creates a withdrawal request for a user when they initate one via `LiquidToken` + /// @param assets The final assets the the user wants to end up with + /// @param amounts The withdrawal amounts per asset + /// @param elWithdrawableShares Array of EL shares withdrawable per asset (after any slashing) + /// @param sharesDeposited The LAT shares deposited by the user, to be burned on withdrawal fulfilment + /// @param user The requesting user's address + /// @param requestId The unique identifier of the withdrawal request + function createWithdrawalRequest( + IERC20[] memory assets, + uint256[] memory amounts, + uint256[] memory elWithdrawableShares, + uint256 sharesDeposited, + address user, + bytes32 requestId + ) external; + + /// @notice Allows users to fulfill a withdrawal request after the delay period and receive all corresponding funds + /// @param requestId The unique identifier of the withdrawal request + function fulfillWithdrawal(bytes32 requestId) external; + + /// @notice Called by `LiquidTokenManger` when a new redemption is created + /// @param redemptionId The unique identifier of the redemption + /// @param redemption The details of the redemption + function recordRedemptionCreated(bytes32 redemptionId, ILiquidTokenManager.Redemption calldata redemption) external; + + /// @notice Called by `LiquidTokenManger` when a redemption is completed + /// @dev If there was any slashing during the withdrawal queue period, its accounting is handled here + /// @param redemptionId The ID of the redemption + /// @param receivedAssets The set of assets that received from all EL withdrawals + /// @param receivedElShares Total EL shares received per `receivedAssets` (converted from underlying amounts) + function recordRedemptionCompleted( + bytes32 redemptionId, + IERC20[] calldata receivedAssets, + uint256[] calldata receivedElShares + ) external returns (uint256[] memory); + + /// @notice Updates the withdrawal delay period + /// @param newDelay The new withdrawal delay in seconds + function setWithdrawalDelay(uint256 newDelay) external; + + /// @notice Returns all withdrawal request IDs for a given user + /// @param user The address of the user + function getUserWithdrawalRequests(address user) external view returns (bytes32[] memory); + + /// @notice Returns all withdrawal request details for a set of request IDs + /// @param requestIds The IDs of the withdrawal requests + function getWithdrawalRequests(bytes32[] calldata requestIds) external view returns (WithdrawalRequest[] memory); + + /// @notice Returns all redemption details for a given redemption ID + /// @param redemptionId The ID of the redemption + function getRedemption(bytes32 redemptionId) external view returns (ILiquidTokenManager.Redemption memory); +} diff --git a/src/utils/TokenRegistryOracle.sol b/src/utils/TokenRegistryOracle.sol index f9c64ffa..c86456ea 100644 --- a/src/utils/TokenRegistryOracle.sol +++ b/src/utils/TokenRegistryOracle.sol @@ -4,7 +4,8 @@ pragma solidity ^0.8.27; import {Initializable} from "@openzeppelin-upgradeable/contracts/proxy/utils/Initializable.sol"; import {AccessControlUpgradeable} from "@openzeppelin-upgradeable/contracts/access/AccessControlUpgradeable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - +import {TickMath} from "@uniswap/v3-core/contracts/libraries/TickMath.sol"; +import {FullMath} from "@uniswap/v3-core/contracts/libraries/FullMath.sol"; import {ILiquidTokenManager} from "../interfaces/ILiquidTokenManager.sol"; import {ITokenRegistryOracle} from "../interfaces/ITokenRegistryOracle.sol"; import "../libraries/StalenessThreshold.sol"; @@ -12,7 +13,42 @@ import "../libraries/StalenessThreshold.sol"; interface ICurvePool { function remove_liquidity(uint256, uint256[] calldata) external; } +interface IUniswapV3Pool { + function token0() external view returns (address); + function token1() external view returns (address); + function observe( + uint32[] calldata secondsAgos + ) external view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s); +} + +interface IBalancerV2Vault { + enum SwapKind { + GIVEN_IN, + GIVEN_OUT + } + + struct BatchSwapStep { + bytes32 poolId; + uint256 assetInIndex; + uint256 assetOutIndex; + uint256 amount; + bytes userData; + } + + struct FundManagement { + address sender; + bool fromInternalBalance; + address recipient; + bool toInternalBalance; + } + function queryBatchSwap( + SwapKind kind, + BatchSwapStep[] calldata swaps, + address[] calldata assets, + FundManagement calldata funds + ) external view returns (int256[] memory assetDeltas); +} /** * @title TokenRegistryOracle * @notice Gas-optimized price oracle with primary/fallback static lookup @@ -32,7 +68,12 @@ contract TokenRegistryOracle is ITokenRegistryOracle, Initializable, AccessContr uint8 public constant SOURCE_TYPE_CHAINLINK = 1; uint8 public constant SOURCE_TYPE_CURVE = 2; uint8 public constant SOURCE_TYPE_PROTOCOL = 3; - + uint8 public constant SOURCE_TYPE_UNISWAP_V3_TWAP = 4; + uint8 public constant SOURCE_TYPE_BALANCER_V2 = 5; + address private constant BALANCER_V2_VAULT = 0xBA12222222228d8Ba445958a75a0704d566BF2C8; + // Define base assets + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant WBTC = 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599; /// @notice Core dependencies ILiquidTokenManager public liquidTokenManager; @@ -107,11 +148,17 @@ contract TokenRegistryOracle is ITokenRegistryOracle, Initializable, AccessContr if (fallbackSource == address(0)) revert FallbackSourceRequired(); } + // For UNISWAP_V3_TWAP, use reserved field for TWAP period in minutes + uint16 twapMinutes = 0; + if (primaryType == SOURCE_TYPE_UNISWAP_V3_TWAP) { + twapMinutes = 15; // Default 15 minutes, can be made configurable later + } + // Store token configuration tokenConfigs[token] = TokenConfig({ primaryType: primaryType, - needsArg: needsArg, - reserved: 0, + needsArg: needsArg, // Preserved for protocol calls + reserved: twapMinutes, // Now used for TWAP period when applicable primarySource: primarySource, fallbackSource: fallbackSource, fallbackFn: fallbackFn @@ -321,8 +368,13 @@ contract TokenRegistryOracle is ITokenRegistryOracle, Initializable, AccessContr } else if (config.primaryType == SOURCE_TYPE_CURVE) { return _getCurvePrice(config.primarySource); } else if (config.primaryType == SOURCE_TYPE_PROTOCOL) { - // Use protocol rate directly return _getContractCallPrice(token, config.primarySource, config.fallbackFn, config.needsArg); + } else if (config.primaryType == SOURCE_TYPE_UNISWAP_V3_TWAP) { + return _getUniswapV3TwapPrice(config.primarySource, config.reserved); + } else if (config.primaryType == SOURCE_TYPE_BALANCER_V2) { + // For Balancer V2, derive poolId from pool address + bytes32 poolId = _deriveBalancerPoolId(config.primarySource); + return _getBalancerV2Price(token, poolId); } return (0, false); @@ -488,4 +540,149 @@ contract TokenRegistryOracle is ITokenRegistryOracle, Initializable, AccessContr } } } -} + + function _getBalancerV2Price(address token, bytes32 poolId) internal returns (uint256 price, bool success) { + if (poolId == bytes32(0) || token == address(0)) { + return (0, false); + } + + // 1) Fetch pool tokens + address[] memory tokens; + uint256[] memory balances; + { + bytes memory inData = abi.encodeWithSelector( + 0xf94d4668, // getPoolTokens(bytes32) + poolId + ); + (bool ok, bytes memory ret) = address(BALANCER_V2_VAULT).staticcall(inData); + if (!ok || ret.length < 96) return (0, false); + (tokens, balances, ) = abi.decode(ret, (address[], uint256[], uint256)); + } + + // 2) Find token indices (handle 2 or 3 token pools) + uint256 tokenIdx = type(uint256).max; + uint256 pairedIdx = type(uint256).max; + + for (uint256 i = 0; i < tokens.length; i++) { + if (tokens[i] == token) { + tokenIdx = i; + } else if (tokens[i] == WETH || tokens[i] == WBTC) { + pairedIdx = i; + } + } + + // Verify we found both tokens + if (tokenIdx == type(uint256).max || pairedIdx == type(uint256).max) { + return (0, false); + } + + // 3) Build swap step + IBalancerV2Vault.BatchSwapStep[] memory steps = new IBalancerV2Vault.BatchSwapStep[](1); + steps[0] = IBalancerV2Vault.BatchSwapStep({ + poolId: poolId, + assetInIndex: tokenIdx, + assetOutIndex: pairedIdx, + amount: 1e18, + userData: "" + }); + + IBalancerV2Vault.FundManagement memory funds = IBalancerV2Vault.FundManagement({ + sender: address(0), + fromInternalBalance: false, + recipient: address(0), + toInternalBalance: false + }); + + // 4) Query the swap + bytes memory callData = abi.encodeWithSelector( + 0xf84d066e, // queryBatchSwap selector + IBalancerV2Vault.SwapKind.GIVEN_IN, + steps, + tokens, + funds + ); + + (bool ok2, bytes memory ret2) = address(BALANCER_V2_VAULT).call(callData); + if (!ok2) return (0, false); + + // 5) Decode result + int256[] memory deltas = abi.decode(ret2, (int256[])); + if (deltas.length <= pairedIdx) return (0, false); + + int256 pairedDelta = deltas[pairedIdx]; + if (pairedDelta >= 0) return (0, false); + + // 6) Return price + price = uint256(-pairedDelta); + + // 7) Handle WBTC decimals if needed + if (tokens[pairedIdx] == WBTC) { + price = price * 1e10; // Convert 8 decimals to 18 + } + + success = price > 0; + } + + /// @dev Derive Balancer V2 poolId from pool address by querying the pool directly + function _deriveBalancerPoolId(address poolAddress) internal view returns (bytes32 poolId) { + if (poolAddress == address(0)) return bytes32(0); + + // Most Balancer V2 pools have a getPoolId() function + assembly { + let ptr := mload(0x40) + mstore(ptr, shl(224, 0x38fff2d0)) // getPoolId() selector + + let success := staticcall(gas(), poolAddress, ptr, 4, ptr, 32) + + if success { + poolId := mload(ptr) + if iszero(gt(poolId, 0)) { + // If poolId is zero, fallback to zero-padded address + poolId := shl(96, poolAddress) + } + } + + // Update free memory pointer + mstore(0x40, add(ptr, 32)) + } + + // If assembly failed, fallback to zero-padded address + if (poolId == bytes32(0)) { + poolId = bytes32(uint256(uint160(poolAddress))); + } + } + + /// @dev Get TWAP price from Uniswap V3 pool with full precision + /// Gas cost: ~25,000-35,000 gas (more predictable than assembly) + /// Accuracy: Exact to the wei + function _getUniswapV3TwapPrice( + address pool, + uint16 twapMinutes + ) internal view returns (uint256 price, bool success) { + if (pool == address(0) || twapMinutes == 0) return (0, false); + + uint32 twapSeconds = uint32(twapMinutes) * 60; + uint32[] memory secondsAgos = new uint32[](2); + secondsAgos[0] = twapSeconds; + secondsAgos[1] = 0; + + try IUniswapV3Pool(pool).observe(secondsAgos) returns (int56[] memory tickCumulatives, uint160[] memory) { + // Calculate average tick over the period + int24 twapTick = int24((tickCumulatives[1] - tickCumulatives[0]) / int56(uint56(twapSeconds))); + + // Get sqrtPriceX96 from tick using TickMath library + uint160 sqrtPriceX96 = TickMath.getSqrtRatioAtTick(twapTick); + + // Convert sqrtPriceX96 to price + // price = (sqrtPriceX96 / 2^96)^2 + uint256 priceX192 = uint256(sqrtPriceX96) * uint256(sqrtPriceX96); + + // Normalize to 18 decimals + price = FullMath.mulDiv(priceX192, 1e18, 1 << 192); + + success = true; + } catch { + return (0, false); + } + } +} \ No newline at end of file diff --git a/test/LTMLSRIntegrationtest.t.sol b/test/LTMLSRIntegrationtest.t.sol new file mode 100644 index 00000000..227550ed --- /dev/null +++ b/test/LTMLSRIntegrationtest.t.sol @@ -0,0 +1,935 @@ +/* +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "forge-std/console.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +// Import contracts +import "./mocks/MockLSR.sol"; +import "./mocks/MockLiquidTokenManager.sol"; + +contract LTMLSRIntegrationTest is Test { + // Contracts + MockLSR public LSR; + MockLiquidTokenManager public ltm; + + // Mainnet addresses + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant WBTC = 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599; + address constant UNISWAP_V3_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564; + address constant UNISWAP_V3_QUOTER = 0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6; + address constant CURVE_STETH_POOL = 0xDC24316b9AE028F1497c275EB9192a3Ea0f67022; + address constant FRXETH_MINTER = 0xbAFA44EFE7901E04E39Dad13167D089C559c1138; + + // Token addresses + address constant ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + address constant STETH = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; + address constant CBETH = 0xBe9895146f7AF43049ca1c1AE358B0541Ea49704; + address constant RETH = 0xae78736Cd615f374D3085123A210448E74Fc6393; + address constant FRXETH = 0x5E8422345238F34275888049021821E8E08CAa1f; + address constant SFRXETH = 0xac3E018457B222d93114458476f3E3416Abbe38F; + address constant OSETH = 0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38; + address constant UNIBTC = 0x004E9C3EF86bc1ca1f0bB5C7662861Ee93350568; + + // Pool addresses from your config + address constant WETH_CBETH_POOL = 0x840DEEef2f115Cf50DA625F7368C24af6fE74410; + address constant WETH_RETH_POOL = 0x553e9C493678d8606d6a5ba284643dB2110Df823; + address constant WETH_WBTC_POOL = 0xCBCdF9626bC03E24f779434178A73a0B4bad62eD; + address constant WBTC_UNIBTC_POOL = 0x109707Ad4AbD299b3cF6F2b011c2bff88523E2f0; + address constant RETH_OSETH_POOL = 0xe080027Bd47353b5D1639772b4a75E9Ed3658A0d; + address constant WETH_STETH_POOL = 0x63818BbDd21E69bE108A23aC1E84cBf66399Bd7D; + + // Test user + address user; + string constant PASSWORD = "[REDACTED]"; + + function setUp() public { + console.log("\n=== SETUP START ==="); + + // Fork mainnet + vm.createSelectFork("wss://eth.drpc.org"); + + user = makeAddr("user"); + + // Deploy contracts + _deployContracts(); + + // Initialize LSR + _initializeLSR(); + + // Configure essential routes for auto-routing tests - commented out for T2 integration + // _configureMinimalRoutes(); + + // Get test assets - commented out for T2 integration + // _getTestAssets(); + + console.log("=== SETUP COMPLETE ===\n"); + } + + function _deployContracts() internal { + // Compute LSR address + address predictedLSRAddress = vm.computeCreateAddress(address(this), vm.getNonce(address(this))); + bytes32 passwordHash = keccak256(abi.encode(PASSWORD, predictedLSRAddress)); + + // Deploy LSR + LSR = new MockLSR(); + + // Deploy Mock LTM + ltm = new MockLiquidTokenManager(); + + // Initialize LTM + MockLiquidTokenManager.Init memory ltmInit = MockLiquidTokenManager.Init({ + strategyManager: address(0), + delegationManager: address(0), + liquidToken: address(0), + stakerNodeCoordinator: address(0), + tokenRegistryOracle: address(0), + initialOwner: address(this), + strategyController: address(this), + priceUpdater: address(this), + LSTswaprouter: address(LSR), + weth: WETH + }); + + ltm.initialize(ltmInit); + // LSR.grantOperatorRole(address(ltm)); // Not needed for mock + } + + function _initializeLSR() internal { + // Token set matching your config + address[] memory tokens = new address[](8); + tokens[0] = ETH_ADDRESS; + tokens[1] = WETH; + tokens[2] = STETH; + tokens[3] = CBETH; + tokens[4] = RETH; + tokens[5] = OSETH; + tokens[6] = WBTC; + tokens[7] = UNIBTC; + + // AssetType not needed for LAT integration testing + // LSTSwapRouter.AssetType[] memory types = new LSTSwapRouter.AssetType[](8); + // for (uint i = 0; i < 6; i++) types[i] = LSTSwapRouter.AssetType.ETH_LST; + // types[6] = LSTSwapRouter.AssetType.BTC_WRAPPED; + // types[7] = LSTSwapRouter.AssetType.BTC_WRAPPED; + + uint8[] memory decimals = new uint8[](8); + for (uint i = 0; i < 6; i++) decimals[i] = 18; + decimals[6] = 8; + decimals[7] = 8; + + // Essential pools matching your config + address[] memory pools = new address[](5); + pools[0] = CURVE_STETH_POOL; // ETH -> stETH (Curve) + pools[1] = WETH_CBETH_POOL; // UniswapV3 + pools[2] = WETH_RETH_POOL; // UniswapV3 + pools[3] = RETH_OSETH_POOL; // Curve + pools[4] = WETH_STETH_POOL; // UniswapV3 WETH-stETH + + uint256[] memory tokenCounts = new uint256[](5); + for (uint i = 0; i < 5; i++) tokenCounts[i] = 2; + + // CurveInterface not needed for LAT integration testing + // LSTSwapRouter.CurveInterface[] memory interfaces = new LSTSwapRouter.CurveInterface[](5); + // interfaces[0] = LSTSwapRouter.CurveInterface.Exchange; // ETH-stETH Curve + // interfaces[1] = LSTSwapRouter.CurveInterface.None; // UniswapV3 + // interfaces[2] = LSTSwapRouter.CurveInterface.None; // UniswapV3 + // interfaces[3] = LSTSwapRouter.CurveInterface.Exchange; // rETH-osETH Curve + // interfaces[4] = LSTSwapRouter.CurveInterface.None; // WETH-stETH UniswapV3 + + // More conservative slippage configs - commented out for LAT integration + // LSTSwapRouter.SlippageConfig[] memory slippages = new LSTSwapRouter.SlippageConfig[](12); + // slippages[0] = LSTSwapRouter.SlippageConfig(ETH_ADDRESS, STETH, 500); // ETH->stETH + // slippages[1] = LSTSwapRouter.SlippageConfig(WETH, CBETH, 1000); // Increased from 700 + // slippages[2] = LSTSwapRouter.SlippageConfig(WETH, RETH, 1500); // Increased from 1300 + // slippages[3] = LSTSwapRouter.SlippageConfig(STETH, WETH, 1000); // Increased from 700 + // slippages[4] = LSTSwapRouter.SlippageConfig(CBETH, WETH, 1000); // Increased from 700 + // slippages[5] = LSTSwapRouter.SlippageConfig(RETH, WETH, 1500); // Increased from 1300 + // slippages[6] = LSTSwapRouter.SlippageConfig(RETH, OSETH, 1200); // Increased from 800 + // slippages[7] = LSTSwapRouter.SlippageConfig(OSETH, RETH, 1200); // Increased from 800 + // slippages[8] = LSTSwapRouter.SlippageConfig(STETH, CBETH, 1500); // Multi-step + // slippages[9] = LSTSwapRouter.SlippageConfig(CBETH, RETH, 1500); // Multi-step + // slippages[10] = LSTSwapRouter.SlippageConfig(WETH, OSETH, 1500); // Multi-step + // slippages[11] = LSTSwapRouter.SlippageConfig(OSETH, WETH, 1500); // Multi-step + + // LSR initialization commented out - not needed for LAT integration testing + // LSR.initialize(tokens, types, decimals, pools, tokenCounts, interfaces, slippages); + } + + // Route configuration commented out - not needed for LAT integration testing + /* + // Configure routes matching your config exactly + function _configureMinimalRoutes() internal { + + // 1. WETH <-> stETH (UniswapV3 with fee 10000) + LSR.configureRoute( + WETH, + STETH, + LSTSwapRouter.Protocol.UniswapV3, + WETH_STETH_POOL, + 10000, // Fee from your config + [int128(0), int128(0)], + false, + address(0), + PASSWORD + ); + + LSR.configureRoute( + STETH, + WETH, + LSTSwapRouter.Protocol.UniswapV3, + WETH_STETH_POOL, + 10000, // Fee from your config + [int128(0), int128(0)], + false, + address(0), + PASSWORD + ); + + // 2. WETH <-> cbETH (UniswapV3 with fee 500) + LSR.configureRoute( + WETH, + CBETH, + LSTSwapRouter.Protocol.UniswapV3, + WETH_CBETH_POOL, + 500, + [int128(0), int128(0)], + false, + address(0), + PASSWORD + ); + + LSR.configureRoute( + CBETH, + WETH, + LSTSwapRouter.Protocol.UniswapV3, + WETH_CBETH_POOL, + 500, + [int128(0), int128(0)], + false, + address(0), + PASSWORD + ); + + // 3. WETH <-> rETH (UniswapV3 with fee 100) + LSR.configureRoute( + WETH, + RETH, + LSTSwapRouter.Protocol.UniswapV3, + WETH_RETH_POOL, + 100, + [int128(0), int128(0)], + false, + address(0), + PASSWORD + ); + + LSR.configureRoute( + RETH, + WETH, + LSTSwapRouter.Protocol.UniswapV3, + WETH_RETH_POOL, + 100, + [int128(0), int128(0)], + false, + address(0), + PASSWORD + ); + + // 4. rETH <-> osETH (Curve) + LSR.configureRoute( + RETH, + OSETH, + LSTSwapRouter.Protocol.Curve, + RETH_OSETH_POOL, + 0, + [int128(1), int128(0)], // rETH index 1, osETH index 0 + false, + address(0), + PASSWORD + ); + + LSR.configureRoute( + OSETH, + RETH, + LSTSwapRouter.Protocol.Curve, + RETH_OSETH_POOL, + 0, + [int128(0), int128(1)], // osETH index 0, rETH index 1 + false, + address(0), + PASSWORD + ); + + // 5. ETH <-> stETH (Curve) - for setup + LSR.configureRoute( + ETH_ADDRESS, + STETH, + LSTSwapRouter.Protocol.Curve, + CURVE_STETH_POOL, + 0, + [int128(0), int128(1)], // ETH index 0, stETH index 1 + false, + address(0), + PASSWORD + ); + } + */ + + // Asset acquisition commented out - not needed for LAT integration testing + /* + function _getTestAssets() internal { + + vm.deal(address(this), 10 ether); + + // Get WETH + IWETH(WETH).deposit{value: 5 ether}(); + + // Get stETH via Curve (ETH -> stETH) + ICurvePool(CURVE_STETH_POOL).exchange{value: 2 ether}(0, 1, 2 ether, 0); + + // Get cbETH via UniswapV3 + IERC20(WETH).approve(UNISWAP_V3_ROUTER, 1 ether); + IUniswapV3Router.ExactInputSingleParams memory params = IUniswapV3Router.ExactInputSingleParams({ + tokenIn: WETH, + tokenOut: CBETH, + fee: 500, + recipient: address(this), + deadline: block.timestamp + 3600, + + amountIn: 1 ether, + amountOutMinimum: 0, + sqrtPriceLimitX96: 0 + }); + IUniswapV3Router(UNISWAP_V3_ROUTER).exactInputSingle(params); + + // Get rETH via UniswapV3 + IERC20(WETH).approve(UNISWAP_V3_ROUTER, 1 ether); + IUniswapV3Router.ExactInputSingleParams memory rethParams = IUniswapV3Router.ExactInputSingleParams({ + tokenIn: WETH, + tokenOut: RETH, + fee: 100, + recipient: address(this), + deadline: block.timestamp + 3600, + amountIn: 1 ether, + amountOutMinimum: 0, + sqrtPriceLimitX96: 0 + }); + IUniswapV3Router(UNISWAP_V3_ROUTER).exactInputSingle(rethParams); + + // Get osETH by converting some rETH via Curve + uint256 rethBalance = IERC20(RETH).balanceOf(address(this)); + if (rethBalance > 0.5 ether) { + IERC20(RETH).approve(RETH_OSETH_POOL, 0.5 ether); + ICurvePool(RETH_OSETH_POOL).exchange(1, 0, 0.5 ether, 0); // rETH index 1 -> osETH index 0 + } + } + */ + + // TODO: Comment out for T5/T6 - uses outdated swapAndStake method + /* + // Test 1: Auto-routing stETH -> cbETH (should find WETH bridge) + function testAutoRoutingStETHToCbETH() public { + console.log("\n=== Test: stETH -> cbETH Auto-routing (Bridge via WETH) ==="); + + uint256 amountIn = 0.5 ether; + uint256 minAmountOut = 0.3 ether; // Very conservative + + // Check route exists + assertTrue(LSR.hasRoute(STETH, CBETH), "Route should exist via bridge"); + + IERC20(STETH).approve(address(ltm), amountIn); + + uint256 balanceBefore = ltm.mockStakedBalances(1, CBETH); + + ltm.swapAndStake(STETH, CBETH, amountIn, 1, minAmountOut); + + uint256 balanceAfter = ltm.mockStakedBalances(1, CBETH); + uint256 amountStaked = balanceAfter - balanceBefore; + + console.log("Amount staked:", amountStaked); + assertGe(amountStaked, minAmountOut, "Output too low"); + } + */ + + // TODO: Comment out for T5/T6 - uses outdated swapAndStake method + /* + // Test 2: Auto-routing cbETH -> rETH (should find WETH bridge) + function testAutoRoutingCbETHToRETH() public { + console.log("\n=== Test: cbETH -> rETH Auto-routing (Bridge via WETH) ==="); + + uint256 amountIn = 0.3 ether; + uint256 minAmountOut = 0.15 ether; // Very conservative + + assertTrue(LSR.hasRoute(CBETH, RETH), "Route should exist via bridge"); + + IERC20(CBETH).approve(address(ltm), amountIn); + + uint256 balanceBefore = ltm.mockStakedBalances(2, RETH); + + ltm.swapAndStake(CBETH, RETH, amountIn, 2, minAmountOut); + + uint256 balanceAfter = ltm.mockStakedBalances(2, RETH); + uint256 amountStaked = balanceAfter - balanceBefore; + + console.log("Amount staked:", amountStaked); + assertGe(amountStaked, minAmountOut, "Output too low"); + } + */ + + // TODO: Comment out for T5/T6 - uses outdated swapAndStake method + /* + // Test 3: Multi-step auto-routing stETH -> osETH (via WETH -> rETH) + function testAutoRoutingStETHToOsETH() public { + console.log("\n=== Test: stETH -> osETH Multi-step Auto-routing ==="); + + // Use a smaller amount and account for potential 1-2 wei loss + uint256 amountIn = 0.1 ether; + uint256 minAmountOut = 0.04 ether; // Lower expectation + + assertTrue(LSR.hasRoute(STETH, OSETH), "Multi-step route should exist"); + + IERC20(STETH).approve(address(ltm), amountIn); + + uint256 osethBalanceBefore = ltm.mockStakedBalances(3, OSETH); + + ltm.swapAndStake(STETH, OSETH, amountIn, 3, minAmountOut); + + uint256 osethBalanceAfter = ltm.mockStakedBalances(3, OSETH); + uint256 amountStaked = osethBalanceAfter - osethBalanceBefore; + + console.log("Amount staked:", amountStaked); + assertGe(amountStaked, minAmountOut, "Output too low"); + } + */ + // TODO: Comment out for T5/T6 - uses outdated swapAndStake method + /* + // Test 4: Reverse auto-routing osETH -> WETH + function testAutoRoutingOsETHToWETH() public { + console.log("\n=== Test: osETH -> WETH Reverse Auto-routing ==="); + + // Use the osETH we got in setup + uint256 osETHBalance = IERC20(OSETH).balanceOf(address(this)); + require(osETHBalance > 0, "No osETH balance available for test"); + + uint256 amountIn = osETHBalance / 2; // Use half of available balance + uint256 minAmountOut = (amountIn * 4) / 10; // Expect at least 40% due to multi-step slippage + + console.log("osETH balance:", osETHBalance); + console.log("Amount to swap:", amountIn); + console.log("Min amount out:", minAmountOut); + + IERC20(OSETH).approve(address(ltm), amountIn); + + uint256 balanceBefore = ltm.mockStakedBalances(4, WETH); + + ltm.swapAndStake(OSETH, WETH, amountIn, 4, minAmountOut); + + uint256 balanceAfter = ltm.mockStakedBalances(4, WETH); + uint256 amountStaked = balanceAfter - balanceBefore; + + console.log("Amount staked:", amountStaked); + assertGe(amountStaked, minAmountOut, "Output too low"); + } + */ + + // TODO: Comment out for T5/T6 - All remaining functions that use outdated swapAndStake method + /* + // Test 5: Complex multi-step cbETH -> osETH + function testAutoRoutingCbETHToOsETH() public { + console.log("\n=== Test: cbETH -> osETH Complex Auto-routing ==="); + + uint256 amountIn = 0.2 ether; + uint256 minAmountOut = 0.08 ether; // Very conservative for 3-step + + // Should find: cbETH -> WETH -> rETH -> osETH + assertTrue(LSR.hasRoute(CBETH, OSETH), "Complex route should exist"); + + IERC20(CBETH).approve(address(ltm), amountIn); + + uint256 balanceBefore = ltm.mockStakedBalances(5, OSETH); + + ltm.swapAndStake(CBETH, OSETH, amountIn, 5, minAmountOut); + + uint256 balanceAfter = ltm.mockStakedBalances(5, OSETH); + uint256 amountStaked = balanceAfter - balanceBefore; + + console.log("Amount staked:", amountStaked); + assertGe(amountStaked, minAmountOut, "Output too low"); + } + + // Test 6: Quote validation for auto-routed paths + function testAutoRoutingQuoteValidation() public { + console.log("\n=== Test: Auto-routing Quote Validation ==="); + + // Test multi-step quote + (uint256 quote2, , , , ) = LSR.getQuoteAndExecutionData(CBETH, OSETH, 1 ether, address(ltm)); + console.log("cbETH -> osETH quote:", quote2); + assertGt(quote2, 0.4 ether, "Multi-step quote too low"); + + // Validate execution + (bool isValid, string memory reason, uint256 estimate) = LSR.validateSwapExecution( + STETH, + CBETH, + 1 ether, + 0.5 ether, + address(ltm) + ); + assertTrue(isValid, reason); + console.log("Validation passed with estimate:", estimate); + } + + // Test 7: Error handling for impossible routes + function testAutoRoutingErrors() public { + console.log("\n=== Test: Auto-routing Error Cases ==="); + + // Test cross-category (should fail) + address USDC = address(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); + vm.expectRevert(); + ltm.swapAndStake(WETH, USDC, 1 ether, 1, 0); + console.log(" Cross-category swap rejected"); + + // Test unsupported token + vm.expectRevert(); + ltm.swapAndStake(address(0x123), WETH, 1 ether, 1, 0); + console.log(" Unsupported token rejected"); + + // Test same token + vm.expectRevert(); + ltm.swapAndStake(WETH, WETH, 1 ether, 1, 0); + console.log(" Same token swap rejected"); + } + + function testLSRRouteDiagnostics() public { + console.log("=== LSR Route Discovery Diagnostics ==="); + + // Test direct routes + console.log("Direct routes:"); + console.log("STETH->WETH:", LSR.hasRoute(STETH, WETH)); + console.log("WETH->RETH:", LSR.hasRoute(WETH, RETH)); + console.log("RETH->OSETH:", LSR.hasRoute(RETH, OSETH)); + console.log("CBETH->WETH:", LSR.hasRoute(CBETH, WETH)); + console.log("WETH->CBETH:", LSR.hasRoute(WETH, CBETH)); + + // Test bridge discovery + console.log("\nBridge routes:"); + console.log("STETH->OSETH:", LSR.hasRoute(STETH, OSETH)); + console.log("CBETH->OSETH:", LSR.hasRoute(CBETH, OSETH)); + console.log("STETH->CBETH:", LSR.hasRoute(STETH, CBETH)); + console.log("CBETH->RETH:", LSR.hasRoute(CBETH, RETH)); + console.log("OSETH->WETH:", LSR.hasRoute(OSETH, WETH)); + + // Test reverse routes + console.log("\nReverse routes:"); + console.log("WETH->STETH:", LSR.hasRoute(WETH, STETH)); + console.log("RETH->WETH:", LSR.hasRoute(RETH, WETH)); + console.log("OSETH->RETH:", LSR.hasRoute(OSETH, RETH)); + } + + function testIndividualRouteQuotes() public { + console.log("=== Individual Route Quote Testing ==="); + + // Test each leg of stETH->osETH path + console.log("Testing STETH->WETH..."); + (bool success1, bytes memory result1) = address(LSR).call( + abi.encodeWithSelector(LSR.getQuoteAndExecutionData.selector, STETH, WETH, 1 ether, address(this)) + ); + if (success1) { + (uint256 quote1, , , , ) = abi.decode(result1, (uint256, bytes, uint8, address, uint256)); + console.log(" STETH->WETH quote:", quote1); + } else { + console.log(" STETH->WETH failed"); + } + + console.log("Testing WETH->RETH..."); + (bool success2, bytes memory result2) = address(LSR).call( + abi.encodeWithSelector(LSR.getQuoteAndExecutionData.selector, WETH, RETH, 1 ether, address(this)) + ); + if (success2) { + (uint256 quote2, , , , ) = abi.decode(result2, (uint256, bytes, uint8, address, uint256)); + console.log(" WETH->RETH quote:", quote2); + } else { + console.log(" WETH->RETH failed"); + } + + console.log("Testing RETH->OSETH..."); + (bool success3, bytes memory result3) = address(LSR).call( + abi.encodeWithSelector(LSR.getQuoteAndExecutionData.selector, RETH, OSETH, 1 ether, address(this)) + ); + if (success3) { + (uint256 quote3, , , , ) = abi.decode(result3, (uint256, bytes, uint8, address, uint256)); + console.log(" RETH->OSETH quote:", quote3); + } else { + console.log(" RETH->OSETH failed"); + } + } + + function testBridgeRouteQuotes() public { + console.log("=== Bridge Route Quote Testing ==="); + + console.log("Testing STETH->OSETH..."); + (bool success1, bytes memory result1) = address(LSR).call( + abi.encodeWithSelector(LSR.getQuoteAndExecutionData.selector, STETH, OSETH, 1 ether, address(this)) + ); + if (success1) { + (uint256 quote1, , uint8 protocol1, , ) = abi.decode(result1, (uint256, bytes, uint8, address, uint256)); + console.log(" STETH->OSETH quote:", quote1); + console.log(" Protocol:", protocol1); + } else { + console.log(" STETH->OSETH failed"); + } + + console.log("Testing CBETH->OSETH..."); + (bool success2, bytes memory result2) = address(LSR).call( + abi.encodeWithSelector(LSR.getQuoteAndExecutionData.selector, CBETH, OSETH, 1 ether, address(this)) + ); + if (success2) { + (uint256 quote2, , uint8 protocol2, , ) = abi.decode(result2, (uint256, bytes, uint8, address, uint256)); + console.log(" CBETH->OSETH quote:", quote2); + console.log(" Protocol:", protocol2); + } else { + console.log(" CBETH->OSETH failed"); + } + + console.log("Testing OSETH->WETH..."); + (bool success3, bytes memory result3) = address(LSR).call( + abi.encodeWithSelector(LSR.getQuoteAndExecutionData.selector, OSETH, WETH, 1 ether, address(this)) + ); + if (success3) { + (uint256 quote3, , uint8 protocol3, , ) = abi.decode(result3, (uint256, bytes, uint8, address, uint256)); + console.log(" OSETH->WETH quote:", quote3); + console.log(" Protocol:", protocol3); + } else { + console.log(" OSETH->WETH failed"); + } + } + + function testSlippageAnalysis() public { + console.log("=== Slippage Analysis ==="); + + // Test the cbETH->rETH route + console.log("Testing cbETH->rETH with 0.3 ether..."); + (bool success, bytes memory result) = address(LSR).call( + abi.encodeWithSelector(LSR.getQuoteAndExecutionData.selector, CBETH, RETH, 0.3 ether, address(this)) + ); + + if (success) { + (uint256 quote, , , , ) = abi.decode(result, (uint256, bytes, uint8, address, uint256)); + console.log("Quote:", quote); + console.log("Input:", 0.3 ether); + + uint256 slippageBps = ((0.3 ether - quote) * 10000) / 0.3 ether; + console.log("Slippage:", slippageBps, "bps"); + + // Test what min amounts would work + console.log("Would pass with:"); + console.log(" 10% slippage (270000000000000000):", quote >= 270000000000000000); + console.log(" 15% slippage (255000000000000000):", quote >= 255000000000000000); + console.log(" 20% slippage (240000000000000000):", quote >= 240000000000000000); + console.log(" 30% slippage (210000000000000000):", quote >= 210000000000000000); + console.log(" 40% slippage (180000000000000000):", quote >= 180000000000000000); + console.log(" 50% slippage (150000000000000000):", quote >= 150000000000000000); + } else { + console.log(" cbETH->rETH failed completely"); + } + } + + function testMultiStepPlanGeneration() public { + console.log("=== Multi-Step Plan Generation ==="); + + console.log("Testing STETH->OSETH getCompleteMultiStepPlan..."); + (bool success1, bytes memory result1) = address(LSR).call( + abi.encodeWithSelector(LSR.getCompleteMultiStepPlan.selector, STETH, OSETH, 1 ether, address(this)) + ); + + if (success1) { + console.log(" STETH->OSETH plan generated successfully"); + } else { + console.log(" STETH->OSETH plan generation failed"); + } + + console.log("Testing CBETH->OSETH getCompleteMultiStepPlan..."); + (bool success2, bytes memory result2) = address(LSR).call( + abi.encodeWithSelector(LSR.getCompleteMultiStepPlan.selector, CBETH, OSETH, 1 ether, address(this)) + ); + + if (success2) { + console.log(" CBETH->OSETH plan generated successfully"); + } else { + console.log(" CBETH->OSETH plan generation failed"); + } + + console.log("Testing OSETH->WETH getCompleteMultiStepPlan..."); + (bool success3, bytes memory result3) = address(LSR).call( + abi.encodeWithSelector(LSR.getCompleteMultiStepPlan.selector, OSETH, WETH, 1 ether, address(this)) + ); + + if (success3) { + console.log(" OSETH->WETH plan generated successfully"); + } else { + console.log(" OSETH->WETH plan generation failed"); + } + } + + function testAutoRoutingCbETHToWETHViaStETH() public { + console.log("\n=== Test: cbETH -> WETH (via stETH route) ==="); + + uint256 amountIn = 0.5 ether; + uint256 minAmountOut = 0.3 ether; // Lower expectation + + IERC20(CBETH).approve(address(ltm), amountIn); + + // Check WETH balance before - should be done differently + uint256 wethBalanceBefore = ltm.mockStakedBalances(2, WETH); + + ltm.swapAndStake(CBETH, WETH, amountIn, 2, minAmountOut); + + uint256 wethBalanceAfter = ltm.mockStakedBalances(2, WETH); + uint256 amountReceived = wethBalanceAfter - wethBalanceBefore; + + console.log(string.concat("WETH received: ", Strings.toString(amountReceived))); + assertGe(amountReceived, minAmountOut, "Output too low"); + } + + function testAutoRoutingWETHToStETH() public { + console.log("\n=== Test: WETH -> stETH Auto-routing ==="); + + uint256 amountIn = 0.5 ether; + uint256 minAmountOut = 0.3 ether; // Lower expectation due to fees + + assertTrue(LSR.hasRoute(WETH, STETH), "Route should exist"); + + IERC20(WETH).approve(address(ltm), amountIn); + + // Use mock staked balance instead of direct balance check + uint256 stethBalanceBefore = ltm.mockStakedBalances(1, STETH); + + ltm.swapAndStake(WETH, STETH, amountIn, 1, minAmountOut); + + uint256 stethBalanceAfter = ltm.mockStakedBalances(1, STETH); + uint256 amountReceived = stethBalanceAfter - stethBalanceBefore; + + console.log(string.concat("stETH received: ", Strings.toString(amountReceived))); + assertGe(amountReceived, minAmountOut, "Output too low"); + } + + + + + function testSwapAndStakeAssetsToNode_RevertsOnETHAsTokenIn() public { + console.log("\n=== Test: ETH Validation - ETH as tokenIn ==="); + + IERC20[] memory assetsToSwap = new IERC20[](1); + uint256[] memory amountsToSwap = new uint256[](1); + IERC20[] memory assetsToStake = new IERC20[](1); + + assetsToSwap[0] = IERC20(ETH_ADDRESS); // ETH as tokenIn - should revert + amountsToSwap[0] = 1 ether; + assetsToStake[0] = IERC20(STETH); + + vm.expectRevert(); + ltm.swapAndStakeAssetsToNode(1, assetsToSwap, amountsToSwap, assetsToStake); + + console.log("ETH as tokenIn correctly rejected"); + } + + function testSwapAndStakeAssetsToNode_RevertsOnETHAsTokenOut() public { + console.log("\n=== Test: ETH Validation - ETH as tokenOut ==="); + + IERC20[] memory assetsToSwap = new IERC20[](1); + uint256[] memory amountsToSwap = new uint256[](1); + IERC20[] memory assetsToStake = new IERC20[](1); + + assetsToSwap[0] = IERC20(STETH); + amountsToSwap[0] = 1 ether; + assetsToStake[0] = IERC20(ETH_ADDRESS); // ETH as tokenOut - should revert + + vm.expectRevert(); + ltm.swapAndStakeAssetsToNode(1, assetsToSwap, amountsToSwap, assetsToStake); + + console.log("ETH as tokenOut correctly rejected"); + } + + function testSwapAndStakeAssetsToNodes_RevertsOnETHInMultipleAllocations() public { + console.log("\n=== Test: ETH Validation - Multiple Allocations ==="); + + MockLiquidTokenManager.NodeAllocationWithSwap[] + memory allocations = new MockLiquidTokenManager.NodeAllocationWithSwap[](2); + + // First allocation - valid + allocations[0].nodeId = 1; + allocations[0].assetsToSwap = new IERC20[](1); + allocations[0].amountsToSwap = new uint256[](1); + allocations[0].assetsToStake = new IERC20[](1); + allocations[0].assetsToSwap[0] = IERC20(WETH); + allocations[0].amountsToSwap[0] = 1 ether; + allocations[0].assetsToStake[0] = IERC20(STETH); + + // Second allocation - has ETH (should revert) + allocations[1].nodeId = 2; + allocations[1].assetsToSwap = new IERC20[](1); + allocations[1].amountsToSwap = new uint256[](1); + allocations[1].assetsToStake = new IERC20[](1); + allocations[1].assetsToSwap[0] = IERC20(ETH_ADDRESS); // ETH here + allocations[1].amountsToSwap[0] = 1 ether; + allocations[1].assetsToStake[0] = IERC20(CBETH); + + vm.expectRevert(); + ltm.swapAndStakeAssetsToNodes(allocations); + + console.log("ETH in multiple allocations correctly rejected"); + } + + // TODO: Comment out for T5/T6 - uses outdated swapAndStake method + /* + function testETHValidationWithBridgeAssetAllowed() public { + console.log("\n=== Test: ETH allowed as bridge asset in LSR ==="); + + // This test verifies that ETH can still be used as a bridge asset + // within the LSR routing, just not as direct tokenIn/tokenOut in LTM + + // Test STETH -> CBETH which might use ETH as bridge + uint256 amountIn = 0.1 ether; + uint256 minAmountOut = 0.05 ether; + + IERC20(STETH).approve(address(ltm), amountIn); + + uint256 balanceBefore = ltm.mockStakedBalances(1, CBETH); + + // This should work even if ETH is used internally as bridge + ltm.swapAndStake(STETH, CBETH, amountIn, 1, minAmountOut); + + uint256 balanceAfter = ltm.mockStakedBalances(1, CBETH); + uint256 amountStaked = balanceAfter - balanceBefore; + + console.log("Amount staked via bridge:", amountStaked); + assertGe(amountStaked, minAmountOut, "Bridge routing should work"); + console.log("ETH bridge routing works correctly"); + } + + + + function testFullWorkflowWithExternalLSTSwapRouter() public { + console.log("\n=== Test: Full Workflow with External LST Swap Router ==="); + console.log("This test demonstrates the complete T2 integration workflow:"); + console.log("1. LiquidTokenManager calls external LST-Swap-Router"); + console.log("2. LSR provides swap execution plan"); + console.log("3. LTM executes the plan step-by-step"); + console.log("4. Assets are swapped and staked to nodes"); + console.log("5. Proper event emission and state updates"); + + // STEP 1: VERIFY LSR INTEGRATION + console.log("\n--- Step 1: Verify LSR Integration ---"); + + // Check that LTM has the correct LSR address + address lsrAddress = address(ltm.LSTswaprouter()); + assertEq(lsrAddress, address(LSR), "LTM should have correct LSR address"); + console.log("+ LTM has correct LSR address:", lsrAddress); + console.log("+ LSR integration verified successfully"); + + //STEP 2: VERIFY FUNCTION SIGNATURES + console.log("\n--- Step 2: Verify All Required Functions Exist ---"); + + // Test that all required functions exist and are callable + IERC20[] memory testAssets = new IERC20[](1); + uint256[] memory testAmounts = new uint256[](1); + testAssets[0] = IERC20(WETH); + testAmounts[0] = 1 ether; + + // These calls will fail due to token balance issues, but they prove the functions exist + console.log("+ swapAndStakeAssetsToNode() - function signature verified"); + console.log("+ swapAndStakeAssetsToNodes() - function signature verified"); + console.log("+ updateLSTSwapRouter() - function signature verified"); + console.log("+ _swapAndStakeAssetsToNode() - internal function exists"); + console.log("+ _executeLSRSwapPlan() - internal function exists"); + + // STEP 3: VERIFY LSR MOCK BEHAVIOR + console.log("\n--- Step 3: Verify LSR Mock Integration ---"); + + // Test that LSR mock provides execution plans + (uint256 quotedAmount, ILSTSwapRouter.MultiStepExecutionPlan memory plan) = LSR.getCompleteMultiStepPlan( + WETH, + STETH, + 1 ether, + address(ltm) + ); + + assertTrue(quotedAmount > 0, "LSR should provide quoted amount"); + assertTrue(plan.steps.length > 0, "LSR should provide execution steps"); + console.log("+ LSR provides execution plans with quoted amount:", quotedAmount); + console.log("+ LSR provides", plan.steps.length, "execution steps"); + + // STEP 4: VERIFY ETH VALIDATION + console.log("\n--- Step 4: Verify ETH Validation Logic ---"); + + // These tests should pass as they just validate function behavior + IERC20[] memory ethAssets = new IERC20[](1); + uint256[] memory ethAmounts = new uint256[](1); + IERC20[] memory stakeAssets = new IERC20[](1); + + ethAssets[0] = IERC20(ETH_ADDRESS); + ethAmounts[0] = 1 ether; + stakeAssets[0] = IERC20(STETH); + + // This should revert due to ETH validation + vm.expectRevert(); + ltm.swapAndStakeAssetsToNode(1, ethAssets, ethAmounts, stakeAssets); + console.log("+ ETH validation working - direct ETH usage rejected"); + + // STEP 5: VERIFY UPDATE FUNCTIONALITY + console.log("\n--- Step 5: Verify LSR Update Functionality ---"); + + // Deploy new mock LSR and test update + MockLSR newLSR = new MockLSR(); + address oldLSRAddress = address(ltm.LSTswaprouter()); + + ltm.updateLSTSwapRouter(address(newLSR)); + address currentLSRAddress = address(ltm.LSTswaprouter()); + + assertEq(currentLSRAddress, address(newLSR), "LSR should be updated"); + assertNotEq(currentLSRAddress, oldLSRAddress, "LSR address should change"); + console.log("+ LSR update functionality verified"); + console.log("+ Old LSR:", oldLSRAddress); + console.log("+ New LSR:", currentLSRAddress); + + // STEP 6: VERIFY ARCHITECTURE COMPLIANCE + console.log("\n--- Step 6: Verify T2 Architecture Compliance ---"); + + console.log("+ Architecture verification:"); + console.log(" - LTM integrates with external LSR via interface"); + console.log(" - All required swap and stake functions implemented"); + console.log(" - LSR address configurable via initialize() and update()"); + console.log(" - ETH validation prevents direct ETH token usage"); + console.log(" - Multi-step execution supported via LSR plans"); + } + + + function testUpdateLSTSwapRouter() public { + console.log("\n=== Test: Update LST Swap Router ==="); + console.log("Testing the updateLSTSwapRouter admin function"); + + // Deploy a new mock LSR + MockLSR newLSR = new MockLSR(); + address oldLSRAddress = address(ltm.LSTswaprouter()); + + console.log("Old LSR address:", oldLSRAddress); + console.log("New LSR address:", address(newLSR)); + + // Update the LSR (should work as we're the admin) + ltm.updateLSTSwapRouter(address(newLSR)); + + // Verify the update + address currentLSRAddress = address(ltm.LSTswaprouter()); + assertEq(currentLSRAddress, address(newLSR), "LSR address should be updated"); + + console.log("+ LSR address updated successfully"); + console.log("Current LSR address:", currentLSRAddress); + + console.log("+ updateLSTSwapRouter function working correctly"); + } + + receive() external payable {} +} +*/ \ No newline at end of file diff --git a/test/LiquidToken.t.sol b/test/LiquidToken.t.sol index 4fbe8fdc..c2ede7b4 100644 --- a/test/LiquidToken.t.sol +++ b/test/LiquidToken.t.sol @@ -135,7 +135,7 @@ contract LiquidTokenTest is BaseTest { amountsToTransfer[0] = 5 ether; vm.prank(address(liquidTokenManager)); - liquidToken.transferAssets(assets, amountsToTransfer); + liquidToken.transferAssets(assets, amountsToTransfer, address(liquidTokenManager)); assertEq( testToken.balanceOf(address(liquidTokenManager)), @@ -196,7 +196,7 @@ contract LiquidTokenTest is BaseTest { amountsToTransfer[1] = 2 ether; vm.prank(address(liquidTokenManager)); - liquidToken.transferAssets(assets, amountsToTransfer); + liquidToken.transferAssets(assets, amountsToTransfer, address(liquidTokenManager)); assertEq( testToken.balanceOf(address(liquidTokenManager)), @@ -272,7 +272,7 @@ contract LiquidTokenTest is BaseTest { vm.prank(user1); vm.expectRevert(abi.encodeWithSelector(ILiquidToken.NotLiquidTokenManager.selector, user1)); - liquidToken.transferAssets(assets, amounts); + liquidToken.transferAssets(assets, amounts, address(liquidTokenManager)); } function testTransferAssetsInsufficientBalance() public { @@ -291,7 +291,7 @@ contract LiquidTokenTest is BaseTest { vm.expectRevert( abi.encodeWithSelector(ILiquidToken.InsufficientBalance.selector, address(testToken), 10 ether, 20 ether) ); - liquidToken.transferAssets(assets, amountsToTransfer); + liquidToken.transferAssets(assets, amountsToTransfer, address(liquidTokenManager)); } function testPause() public { diff --git a/test/LiquidTokenManager.t.sol b/test/LiquidTokenManager.t.sol index ba8d9bde..899ffb33 100644 --- a/test/LiquidTokenManager.t.sol +++ b/test/LiquidTokenManager.t.sol @@ -852,7 +852,7 @@ contract LiquidTokenManagerTest is BaseTest { function testGetDepositAssetBalanceInvalidStrategy() public { uint256 nodeId = 1; vm.expectRevert(abi.encodeWithSelector(ILiquidTokenManager.StrategyNotFound.selector, address(0x123))); - liquidTokenManager.getDepositAssetBalanceNode(IERC20(address(0x123)), nodeId); + liquidTokenManager.getDepositAssetBalanceNode(IERC20(address(0x123)), nodeId, false); } function testShareCalculation() public { diff --git a/test/ProductionPriceTest.t.sol b/test/ProductionPriceTest.t.sol index e75a9b88..05906068 100644 --- a/test/ProductionPriceTest.t.sol +++ b/test/ProductionPriceTest.t.sol @@ -216,22 +216,21 @@ contract RealWorldTokenPriceTest is BaseTest { }) ); - /* 6. osETH - curve + //6. osETH - balancerv2 mainnetTokens.push( TokenConfig({ name: "osETH", - token: 0x0C4576Ca1c365868E162554AF8e385dc3e7C66D9, + token: 0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38, strategy: 0x57ba429517c3473B6d34CA9aCd56c0e735b94c02, decimals: 18, volatilityThreshold: 5e16, - sourceType: 2, // curve - primarySource: 0xe080027Bd47353b5D1639772b4a75E9Ed3658A0d, + sourceType: 5, // bv2 + primarySource: 0xDACf5Fa19b1f720111609043ac67A9818262850c, needsArg: 0, fallbackSource: 0x0C4576Ca1c365868E162554AF8e385dc3e7C66D9, fallbackSelector: 0x18977a59 }) ); - */ // 7. ETHx - Curve mainnetTokens.push( @@ -265,7 +264,7 @@ contract RealWorldTokenPriceTest is BaseTest { }) ); - // 9. lsETH - Protocol + // 9. lsETH - chainlink mainnetTokens.push( TokenConfig({ name: "lsETH", @@ -273,8 +272,8 @@ contract RealWorldTokenPriceTest is BaseTest { strategy: 0xAe60d8180437b5C34bB956822ac2710972584473, decimals: 18, volatilityThreshold: 5e16, - sourceType: 3, // Protocol - primarySource: 0x8c1BEd5b9a0928467c9B1341Da1D7BD5e10b6549, + sourceType: 1, // chainlink + primarySource: 0xE858728eB31a25C4AcCcE17d01B68dCFC3A0ED2C, needsArg: 1, fallbackSource: 0x8c1BEd5b9a0928467c9B1341Da1D7BD5e10b6549, fallbackSelector: 0xf79c3f02 @@ -313,7 +312,7 @@ contract RealWorldTokenPriceTest is BaseTest { }) ); - // 12. wbETH - Protocol + // 12. wbETH - Protocol>>Curve mainnetTokens.push( TokenConfig({ name: "wbETH", @@ -321,8 +320,8 @@ contract RealWorldTokenPriceTest is BaseTest { strategy: 0x7CA911E83dabf90C90dD3De5411a10F1A6112184, decimals: 18, volatilityThreshold: 5e16, - sourceType: 3, // Protocol - primarySource: 0xa2E3356610840701BDf5611a53974510Ae27E2e1, + sourceType: 2, // Protocol + primarySource: 0xBfAb6FA95E0091ed66058ad493189D2cB29385E6, needsArg: 0, fallbackSource: 0xa2E3356610840701BDf5611a53974510Ae27E2e1, fallbackSelector: 0x3ba0b9a9 @@ -344,7 +343,7 @@ contract RealWorldTokenPriceTest is BaseTest { fallbackSelector: 0x07a2d13a }) ); - // 15. unibtc - proctol + // 15. unibtc - univ3 mainnetTokens.push( TokenConfig({ name: "uniBTC", @@ -352,8 +351,8 @@ contract RealWorldTokenPriceTest is BaseTest { strategy: 0x505241696AB63FaEC03ed7893246DE52EB1A8CFF, decimals: 8, volatilityThreshold: 5e16, - sourceType: 3, // protocl - primarySource: 0x861d15F8a4059cb918bD6F3670adAEB1220B298f, + sourceType: 4, // univ3 + primarySource: 0x2912868c7aC9b14dD3F64ec1713cbd8f44A17dfd, needsArg: 0, fallbackSource: 0x861d15F8a4059cb918bD6F3670adAEB1220B298f, fallbackSelector: 0x50d25bcd @@ -1272,7 +1271,7 @@ contract RealWorldTokenPriceTest is BaseTest { console.log("User1 received 100 Eigen tokens"); // Step 2: Remove the problematic token from the system - address mysteryToken = 0x96d3F6c20EEd2697647F543fE6C08bC2Fbf39758; + address mysteryToken = 0x756e0562323ADcDA4430d6cb456d9151f605290B; // Check if mystery token exists and remove it if (liquidTokenManager.tokenIsSupported(IERC20(mysteryToken))) { @@ -1882,4 +1881,212 @@ contract RealWorldTokenPriceTest is BaseTest { vm.stopPrank(); } -} + + //new test cases add for our new primary sources : + function testBalancerV2GetPrice() public { + console.log("\n======= Testing Balancer V2 Price Fetching ======="); + + // Test osETH (Balancer V2) + address osETH = 0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38; + address osETHPool = 0xDACf5Fa19b1f720111609043ac67A9818262850c; + + console.log("Testing osETH Balancer V2 price..."); + console.log("Token: %s", osETH); + console.log("Pool: %s", osETHPool); + + vm.startPrank(admin); + + try tokenRegistryOracle.getTokenPrice(osETH) returns (uint256 price) { + assertTrue(price > 0, "osETH Balancer V2 price should be positive"); + assertTrue(price >= 0.5e18 && price <= 2e18, "osETH price should be reasonable (0.5-2 ETH)"); + console.log("osETH price: %s ETH", _formatEther(price)); + console.log("osETH Balancer V2 price fetch: SUCCESS"); + } catch Error(string memory reason) { + console.log("osETH Balancer V2 price fetch FAILED: %s", reason); + assertTrue(false, "osETH Balancer V2 price should work"); + } + + vm.stopPrank(); + } + + function testUniswapV3PriceConsistency() public { + console.log("\n======= Testing Uniswap V3 Price Consistency ======="); + + address uniBTC = 0x004E9C3EF86bc1ca1f0bB5C7662861Ee93350568; + + vm.startPrank(admin); + + // Test uniBTC consistency (BTC LST) + console.log("Testing uniBTC price consistency..."); + uint256[] memory uniBTCPrices = new uint256[](3); + + for (uint i = 0; i < 3; i++) { + try tokenRegistryOracle.getTokenPrice(uniBTC) returns (uint256 price) { + uniBTCPrices[i] = price; + console.log("uniBTC price attempt %s: %s WBTC", i + 1, _formatEther(price)); + } catch { + assertTrue(false, "uniBTC price should be consistent"); + } + } + + // Check uniBTC consistency (prices should be very similar) + for (uint i = 1; i < 3; i++) { + uint256 diff = uniBTCPrices[i] > uniBTCPrices[0] + ? uniBTCPrices[i] - uniBTCPrices[0] + : uniBTCPrices[0] - uniBTCPrices[i]; + uint256 tolerance = uniBTCPrices[0] / 1000; // 0.1% tolerance + assertTrue(diff <= tolerance, "uniBTC prices should be consistent"); + } + + console.log("uniBTC Uniswap V3 price consistency: PASSED"); + + vm.stopPrank(); + } + + function testBalancerV2Configuration() public { + console.log("\n======= Testing Balancer V2 Configuration ======="); + + address osETH = 0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38; + + vm.startPrank(admin); + + // Get token configuration + ( + uint8 primaryType, + uint8 needsArg, + uint16 reserved, + address primarySource, + address fallbackSource, + bytes4 fallbackFn + ) = tokenRegistryOracle.tokenConfigs(osETH); + + // Verify configuration + assertEq(primaryType, 5, "osETH should have Balancer V2 source type"); + assertEq(needsArg, 0, "osETH should not need args for primary"); + assertEq(primarySource, 0xDACf5Fa19b1f720111609043ac67A9818262850c, "osETH primary source should be correct"); + assertEq(fallbackSource, 0x0C4576Ca1c365868E162554AF8e385dc3e7C66D9, "osETH fallback source should be correct"); + assertEq(uint32(fallbackFn), uint32(0x18977a59), "osETH fallback selector should be correct"); + + console.log("osETH Balancer V2 configuration:"); + console.log(" Primary type: %s (Balancer V2)", primaryType); + console.log(" Primary source: %s", primarySource); + console.log(" Fallback source: %s", fallbackSource); + console.log(" Fallback selector: %s", uint32(fallbackFn)); + + vm.stopPrank(); + } + + function testUniswapV3Configuration() public { + console.log("\n======= Testing Uniswap V3 Configuration ======="); + + address lsETH = 0x8c1BEd5b9a0928467c9B1341Da1D7BD5e10b6549; + address uniBTC = 0x004E9C3EF86bc1ca1f0bB5C7662861Ee93350568; + + vm.startPrank(admin); + + // Test uniBTC configuration + ( + uint8 primaryType2, + uint8 needsArg2, + uint16 reserved2, + address primarySource2, + address fallbackSource2, + bytes4 fallbackFn2 + ) = tokenRegistryOracle.tokenConfigs(uniBTC); + + assertEq(primaryType2, 4, "uniBTC should have Uniswap V3 source type"); + assertEq(needsArg2, 0, "uniBTC should not need args for primary"); + assertEq(primarySource2, 0x2912868c7aC9b14dD3F64ec1713cbd8f44A17dfd, "uniBTC primary source should be correct"); + + console.log("uniBTC Uniswap V3 configuration:"); + console.log(" Primary type: %s (Uniswap V3)", primaryType2); + console.log(" Needs arg: %s", needsArg2); + console.log(" Primary source: %s", primarySource2); + console.log(" Fallback source: %s", fallbackSource2); + + vm.stopPrank(); + } + + function testBalancerV2PriceConsistency() public { + console.log("\n======= Testing Balancer V2 Price Consistency ======="); + + address osETH = 0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38; + + vm.startPrank(admin); + + // Get price multiple times to test consistency + uint256[] memory prices = new uint256[](3); + + for (uint i = 0; i < 3; i++) { + try tokenRegistryOracle.getTokenPrice(osETH) returns (uint256 price) { + prices[i] = price; + console.log("osETH price attempt %s: %s ETH", i + 1, _formatEther(price)); + } catch { + assertTrue(false, "osETH price should be consistent"); + } + } + + // Check consistency (prices should be very similar) + for (uint i = 1; i < 3; i++) { + uint256 diff = prices[i] > prices[0] ? prices[i] - prices[0] : prices[0] - prices[i]; + uint256 tolerance = prices[0] / 1000; // 0.1% tolerance + assertTrue(diff <= tolerance, "osETH prices should be consistent"); + } + + console.log("osETH Balancer V2 price consistency: PASSED"); + + vm.stopPrank(); + } + + function testBalancerV2PoolIntegration() public { + console.log("\n======= Testing Balancer V2 Pool Integration ======="); + + address osETH = 0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38; + address osETHPool = 0xDACf5Fa19b1f720111609043ac67A9818262850c; + + vm.startPrank(admin); + + // Test that we can get the poolId from the pool + try tokenRegistryOracle.getTokenPrice(osETH) returns (uint256 price) { + console.log("Successfully fetched osETH price via Balancer V2 pool"); + console.log("Pool address: %s", osETHPool); + console.log("Token price: %s ETH", _formatEther(price)); + + // Verify price is reasonable for osETH + assertTrue(price > 0.5e18, "osETH price should be > 0.5 ETH"); + assertTrue(price < 2e18, "osETH price should be < 2 ETH"); + + console.log("Balancer V2 pool integration: SUCCESS"); + } catch Error(string memory reason) { + console.log("Balancer V2 pool integration FAILED: %s", reason); + assertTrue(false, "Balancer V2 pool integration should work"); + } + + vm.stopPrank(); + } + + // Helper function to format ether values + function _formatEther(uint256 value) internal pure returns (string memory) { + return string(abi.encodePacked(_toString(value / 1e18), ".", _toString((value % 1e18) / 1e14))); + } + + // Helper function to convert uint to string + function _toString(uint256 value) internal pure returns (string memory) { + if (value == 0) { + return "0"; + } + uint256 temp = value; + uint256 digits; + while (temp != 0) { + digits++; + temp /= 10; + } + bytes memory buffer = new bytes(digits); + while (value != 0) { + digits -= 1; + buffer[digits] = bytes1(uint8(48 + uint256(value % 10))); + value /= 10; + } + return string(buffer); + } +} \ No newline at end of file diff --git a/test/RewardsManagerTest.sol b/test/RewardsManagerTest.sol new file mode 100644 index 00000000..2c433ddd --- /dev/null +++ b/test/RewardsManagerTest.sol @@ -0,0 +1,815 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "forge-std/console.sol"; +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IRewardsCoordinator} from "@eigenlayer/contracts/interfaces/IRewardsCoordinator.sol"; +import {IRewardsCoordinatorTypes} from "@eigenlayer/contracts/interfaces/IRewardsCoordinator.sol"; + +import {MockRewardsCoordinator} from "./mocks/MockRewardsCoordinator.sol"; +import {BaseTest} from "./common/BaseTest.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; +import {RewardsManager} from "../src/core/RewardsManager.sol"; +import {IRewardsManager} from "../src/interfaces/IRewardsManager.sol"; +import {ILiquidToken} from "../src/interfaces/ILiquidToken.sol"; + +contract RewardsManagerTest is BaseTest { + MockERC20 public unsupportedToken1; + MockERC20 public unsupportedToken2; + + address public earner1 = address(0x1001); + address public earner2 = address(0x1002); + + // Track the real rewards coordinator + address public realCoordinator; + + function setUp() public override { + super.setUp(); + + // Get the real coordinator address + realCoordinator = address(rewardsManager.rewardsCoordinator()); + + // Create unsupported tokens + unsupportedToken1 = new MockERC20("Unsupported Token 1", "UNSUP1"); + unsupportedToken2 = new MockERC20("Unsupported Token 2", "UNSUP2"); + } + + // Helper function to set up mock coordinator storage + function setupMockCoordinatorStorage(address earner, IERC20 token, uint256 amount) internal { + // Set claimerFor[earner] storage slot + bytes32 claimerSlot = keccak256(abi.encode(earner, uint256(0))); + bytes32 claimerValue = bytes32(uint256(uint160(address(rewardsManager)))); + vm.store(realCoordinator, claimerSlot, claimerValue); + + // Set transferAmounts[token][recipient] storage slot + bytes32 innerSlot = keccak256(abi.encode(address(token), uint256(1))); + bytes32 transferSlot = keccak256(abi.encode(address(rewardsManager), innerSlot)); + vm.store(realCoordinator, transferSlot, bytes32(amount)); + } + + function test_ProcessClaim_AccurateBalanceTransfer() public { + // Pre-fund RewardsManager with existing balance + testToken.mint(address(rewardsManager), 50 ether); + uint256 existingBalance = testToken.balanceOf(address(rewardsManager)); + + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + // Create mock coordinator + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, testToken, 75 ether); + testToken.mint(realCoordinator, 75 ether); + + uint256 liquidTokenBalanceBefore = testToken.balanceOf(address(liquidToken)); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + uint256 liquidTokenBalanceAfter = testToken.balanceOf(address(liquidToken)); + uint256 transferred = liquidTokenBalanceAfter - liquidTokenBalanceBefore; + + // CHANGED: Should transfer FULL balance (existing + new) + assertEq(transferred, 125 ether, "Should transfer full balance"); + assertEq(testToken.balanceOf(address(rewardsManager)), 0, "RewardsManager should have 0 balance"); + } + + function test_ProcessClaim_LiquidTokenIntegration() public { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](2); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 100 ether + }); + tokenLeaves[1] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken2)), + cumulativeEarnings: 50 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + // Create mock coordinator + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + + // Set up storage for both tokens + setupMockCoordinatorStorage(earner1, testToken, 100 ether); + setupMockCoordinatorStorage(earner1, testToken2, 50 ether); + + // Fund the coordinator + testToken.mint(realCoordinator, 100 ether); + testToken2.mint(realCoordinator, 50 ether); + + uint256 liquidTokenAssetBalance1Before = liquidToken.assetBalances(address(testToken)); + uint256 liquidTokenAssetBalance2Before = liquidToken.assetBalances(address(testToken2)); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + assertEq(liquidToken.assetBalances(address(testToken)) - liquidTokenAssetBalance1Before, 100 ether); + assertEq(liquidToken.assetBalances(address(testToken2)) - liquidTokenAssetBalance2Before, 50 ether); + assertEq(testToken.balanceOf(address(liquidToken)), 100 ether); + assertEq(testToken2.balanceOf(address(liquidToken)), 50 ether); + } + + function test_ProcessClaim_ReentrancyProtection() public { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, testToken, 100 ether); + + testToken.mint(realCoordinator, 100 ether); + + uint256 balanceBefore = testToken.balanceOf(address(rewardsManager)); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + uint256 balanceAfter = testToken.balanceOf(address(rewardsManager)); + assertEq(balanceAfter, balanceBefore); // All transferred to LiquidToken + assertEq(testToken.balanceOf(address(liquidToken)), 100 ether); + } + + function test_ProcessClaim_SafeArrayOperations() public { + // Create claim with duplicate tokens + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](4); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 50 ether + }); + tokenLeaves[1] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), // Duplicate + cumulativeEarnings: 30 ether + }); + tokenLeaves[2] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(unsupportedToken1)), + cumulativeEarnings: 25 ether + }); + tokenLeaves[3] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken2)), + cumulativeEarnings: 40 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + + // Set up storage - since MockRewardsCoordinator transfers per leaf, + // we need to set the transfer amount to what we want per leaf + setupMockCoordinatorStorage(earner1, testToken, 40 ether); // Amount per leaf + setupMockCoordinatorStorage(earner1, testToken2, 40 ether); + setupMockCoordinatorStorage(earner1, unsupportedToken1, 25 ether); + + // Fund coordinator for multiple transfers of the same token + testToken.mint(realCoordinator, 80 ether); // 40 * 2 leaves + testToken2.mint(realCoordinator, 40 ether); + unsupportedToken1.mint(realCoordinator, 25 ether); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + // Verify results - MockRewardsCoordinator will transfer for each leaf + assertEq(rewardsManager.unsupportedAssetBalances(address(unsupportedToken1)), 25 ether); + + // The RewardsManager should receive tokens for each leaf, but dedupe when processing + // Check the actual balances to see what happened + uint256 testTokenReceived = testToken.balanceOf(address(liquidToken)); + uint256 testToken2Received = testToken2.balanceOf(address(liquidToken)); + + // The exact amounts depend on the RewardsManager's deduplication logic + assertGt(testTokenReceived, 0, "Should receive some testToken"); + assertEq(testToken2Received, 40 ether, "Should receive testToken2"); + } + + function test_ProcessClaim_SecurityBalanceCheck() public { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(unsupportedToken1)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, unsupportedToken1, 100 ether); + + unsupportedToken1.mint(realCoordinator, 100 ether); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + assertEq(rewardsManager.unsupportedAssetBalances(address(unsupportedToken1)), 100 ether); + } + + function test_ClaimerManagement_EfficientOperations() public { + address[] memory earners = new address[](10); + for (uint i = 0; i < 10; i++) { + earners[i] = address(uint160(0x2000 + i)); + + vm.mockCall( + realCoordinator, + abi.encodeWithSelector(IRewardsCoordinator.claimerFor.selector, earners[i]), + abi.encode(address(rewardsManager)) + ); + } + + uint256 gasBefore = gasleft(); + + for (uint i = 0; i < 10; i++) { + vm.prank(earners[i]); + rewardsManager.updateClaimerFor(earners[i]); + } + + uint256 gasUsed = gasBefore - gasleft(); + console.log("Gas used for 10 claimer updates:", gasUsed); + + assertEq(rewardsManager.claimerForLength(), 10); + + // Test removal + for (uint i = 0; i < 5; i++) { + vm.mockCall( + realCoordinator, + abi.encodeWithSelector(IRewardsCoordinator.claimerFor.selector, earners[i]), + abi.encode(address(0)) + ); + + vm.prank(earners[i]); + rewardsManager.updateClaimerFor(earners[i]); + } + + assertEq(rewardsManager.claimerForLength(), 5); + + address[] memory remainingClaimers = rewardsManager.claimerFor(); + assertEq(remainingClaimers.length, 5); + } + + function test_ProcessClaim_UnauthorizedClaimer() public { + // Mock to return address(0) - no claimer set + vm.mockCall( + realCoordinator, + abi.encodeWithSelector(IRewardsCoordinator.claimerFor.selector, earner1), + abi.encode(address(0)) + ); + + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + vm.prank(earner1); + vm.expectRevert(abi.encodeWithSelector(IRewardsManager.NotClaimerFor.selector, earner1)); + rewardsManager.processClaim(claim); + } + + function test_ProcessClaims_GasLimitProtection() public { + // Create valid claims with non-zero earner + IRewardsCoordinatorTypes.RewardsMerkleClaim[] memory claims = new IRewardsCoordinatorTypes.RewardsMerkleClaim[]( + 51 + ); + for (uint i = 0; i < 51; i++) { + // Create empty token leaves array + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](0); + claims[i] = _createBasicClaim(earner1, tokenLeaves); + } + + // Try to process more than 50 claims + vm.expectRevert("Too many claims"); + rewardsManager.processClaims(claims); + + // Create 50 valid claims + claims = new IRewardsCoordinatorTypes.RewardsMerkleClaim[](50); + for (uint i = 0; i < 50; i++) { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](0); + claims[i] = _createBasicClaim(earner1, tokenLeaves); + } + + // Should work with 50 or fewer - create valid claims + // Expect NotClaimerFor since we didn't set up coordinator + vm.expectRevert(abi.encodeWithSelector(IRewardsManager.NotClaimerFor.selector, earner1)); + rewardsManager.processClaims(claims); + } + /// + // more tests for RewardsManagerTest contract + + function test_ProcessClaim_ZeroAmountClaim() public { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 0 // Zero amount + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, testToken, 0); // No transfer + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + // Should handle zero amounts gracefully + assertEq(testToken.balanceOf(address(liquidToken)), 0); + } + + function test_ProcessClaim_EmptyTokenLeaves() public { + // Claim with no token leaves + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](0); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, testToken, 0); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + // Should handle empty claims gracefully + assertEq(testToken.balanceOf(address(liquidToken)), 0); + } + + function test_ProcessClaim_MaxTokenLeaves() public { + // Test with maximum reasonable number of token leaves + uint256 maxLeaves = 20; + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](maxLeaves); + + // Create many different tokens + MockERC20[] memory manyTokens = new MockERC20[](maxLeaves); + for (uint256 i = 0; i < maxLeaves; i++) { + manyTokens[i] = new MockERC20(string(abi.encodePacked("Token", i)), string(abi.encodePacked("TK", i))); + tokenLeaves[i] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(manyTokens[i])), + cumulativeEarnings: 1 ether + }); + } + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + + // Setup all tokens as unsupported with transfers + for (uint256 i = 0; i < maxLeaves; i++) { + setupMockCoordinatorStorage(earner1, IERC20(address(manyTokens[i])), 1 ether); + manyTokens[i].mint(realCoordinator, 1 ether); + } + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + // All should be unsupported assets + for (uint256 i = 0; i < maxLeaves; i++) { + assertEq(rewardsManager.unsupportedAssetBalances(address(manyTokens[i])), 1 ether); + } + } + + function test_ProcessClaim_MixedSupportedUnsupportedTokens() public { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](3); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), // Supported + cumulativeEarnings: 100 ether + }); + tokenLeaves[1] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(unsupportedToken1)), // Unsupported + cumulativeEarnings: 50 ether + }); + tokenLeaves[2] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken2)), // Supported + cumulativeEarnings: 75 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + + setupMockCoordinatorStorage(earner1, testToken, 100 ether); + setupMockCoordinatorStorage(earner1, unsupportedToken1, 50 ether); + setupMockCoordinatorStorage(earner1, testToken2, 75 ether); + + testToken.mint(realCoordinator, 100 ether); + unsupportedToken1.mint(realCoordinator, 50 ether); + testToken2.mint(realCoordinator, 75 ether); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + // Supported tokens should go to LiquidToken + assertEq(testToken.balanceOf(address(liquidToken)), 100 ether); + assertEq(testToken2.balanceOf(address(liquidToken)), 75 ether); + + // Unsupported should stay in RewardsManager + assertEq(rewardsManager.unsupportedAssetBalances(address(unsupportedToken1)), 50 ether); + } + + function test_ProcessClaim_ClaimerStatusChangeDuringExecution() public { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + // Mock the processClaim to do nothing for simplicity + vm.mockCall(realCoordinator, abi.encodeWithSelector(IRewardsCoordinator.processClaim.selector), abi.encode()); + + // Initially set as claimer + vm.mockCall( + realCoordinator, + abi.encodeWithSelector(IRewardsCoordinator.claimerFor.selector, earner1), + abi.encode(address(rewardsManager)) + ); + + // First call should work + vm.prank(earner1); + rewardsManager.processClaim(claim); + + // Verify the claimer was added to local storage + assertTrue(rewardsManager.isClaimerFor(earner1)); + assertEq(rewardsManager.claimerForLength(), 1); + + // Test the failed case - change claimer status to address(0) + vm.mockCall( + realCoordinator, + abi.encodeWithSelector(IRewardsCoordinator.claimerFor.selector, earner1), + abi.encode(address(0)) // No longer a claimer + ); + + // This call should fail with NotClaimerFor + vm.prank(earner1); + vm.expectRevert(abi.encodeWithSelector(IRewardsManager.NotClaimerFor.selector, earner1)); + rewardsManager.processClaim(claim); + + // The claimer should still be in local storage because the transaction reverted + assertTrue(rewardsManager.isClaimerFor(earner1)); + + // Now test successful claimer removal by calling updateClaimerFor directly + vm.prank(earner1); + rewardsManager.updateClaimerFor(earner1); + + // Now the claimer should be removed + assertFalse(rewardsManager.isClaimerFor(earner1)); + assertEq(rewardsManager.claimerForLength(), 0); + } + + function test_ProcessClaim_BalanceCalculationWithExistingBalance() public { + // Pre-fund RewardsManager with existing balance + testToken.mint(address(rewardsManager), 200 ether); + + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, testToken, 100 ether); + testToken.mint(realCoordinator, 100 ether); + + uint256 liquidTokenBalanceBefore = testToken.balanceOf(address(liquidToken)); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + uint256 liquidTokenBalanceAfter = testToken.balanceOf(address(liquidToken)); + uint256 transferred = liquidTokenBalanceAfter - liquidTokenBalanceBefore; + + // CHANGED: Should transfer FULL balance + assertEq(transferred, 300 ether, "Should transfer full balance"); + assertEq(testToken.balanceOf(address(rewardsManager)), 0, "RewardsManager should have 0 balance"); + } + + function test_ProcessClaim_UnsupportedTokenAccumulation() public { + // First claim + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves1 = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves1[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(unsupportedToken1)), + cumulativeEarnings: 50 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim1 = _createBasicClaim(earner1, tokenLeaves1); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, unsupportedToken1, 50 ether); + unsupportedToken1.mint(realCoordinator, 50 ether); + + vm.prank(earner1); + rewardsManager.processClaim(claim1); + + assertEq(rewardsManager.unsupportedAssetBalances(address(unsupportedToken1)), 50 ether); + + // Second claim - balance is set, not accumulated + setupMockCoordinatorStorage(earner1, unsupportedToken1, 30 ether); + unsupportedToken1.mint(realCoordinator, 30 ether); + + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves2 = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves2[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(unsupportedToken1)), + cumulativeEarnings: 30 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim2 = _createBasicClaim(earner1, tokenLeaves2); + + vm.prank(earner1); + rewardsManager.processClaim(claim2); + + // CHANGED: Should set to full balance (80 ether), not accumulate separately + assertEq(rewardsManager.unsupportedAssetBalances(address(unsupportedToken1)), 80 ether); + } + + function test_ProcessClaim_CoordinatorReturnsLessThanClaimed() public { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 1000 ether // Claim 1000 + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, testToken, 100 ether); // Only transfer 100 + testToken.mint(realCoordinator, 100 ether); + + uint256 liquidTokenBalanceBefore = testToken.balanceOf(address(liquidToken)); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + uint256 liquidTokenBalanceAfter = testToken.balanceOf(address(liquidToken)); + uint256 transferred = liquidTokenBalanceAfter - liquidTokenBalanceBefore; + + // Should only transfer what was actually received + assertEq(transferred, 100 ether); + } + + function test_ProcessClaim_CoordinatorTransfersNothing() public { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, testToken, 0); // No transfer + // Don't mint any tokens to coordinator + + uint256 liquidTokenBalanceBefore = testToken.balanceOf(address(liquidToken)); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + uint256 liquidTokenBalanceAfter = testToken.balanceOf(address(liquidToken)); + + // Should handle zero transfers gracefully + assertEq(liquidTokenBalanceAfter, liquidTokenBalanceBefore); + } + + function test_ProcessClaim_MultipleEarnersSequentially() public { + // Setup claims for multiple earners + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim1 = _createBasicClaim(earner1, tokenLeaves); + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim2 = _createBasicClaim(earner2, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + + // Setup both earners + setupMockCoordinatorStorage(earner1, testToken, 100 ether); + setupMockCoordinatorStorage(earner2, testToken, 100 ether); + testToken.mint(realCoordinator, 200 ether); + + uint256 liquidTokenBalanceBefore = testToken.balanceOf(address(liquidToken)); + + // Process first claim + vm.prank(earner1); + rewardsManager.processClaim(claim1); + + // Process second claim + vm.prank(earner2); + rewardsManager.processClaim(claim2); + + uint256 liquidTokenBalanceAfter = testToken.balanceOf(address(liquidToken)); + uint256 totalTransferred = liquidTokenBalanceAfter - liquidTokenBalanceBefore; + + // Should transfer total from both claims + assertEq(totalTransferred, 200 ether); + } + + function test_ProcessClaim_ExtremelyLargeDuplicates() public { + // Create claim with many duplicates of the same token + uint256 duplicateCount = 50; + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](duplicateCount); + + for (uint256 i = 0; i < duplicateCount; i++) { + tokenLeaves[i] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 1 ether + }); + } + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, testToken, 1 ether); + testToken.mint(realCoordinator, duplicateCount * 1 ether); + + uint256 gasBefore = gasleft(); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + uint256 gasUsed = gasBefore - gasleft(); + console.log("Gas used for", duplicateCount, "duplicates:", gasUsed); + + // Should handle duplicates efficiently + assertGt(testToken.balanceOf(address(liquidToken)), 0); + } + + function test_ProcessClaims_BatchProcessing() public { + // Create multiple claims + uint256 claimCount = 10; + IRewardsCoordinatorTypes.RewardsMerkleClaim[] memory claims = new IRewardsCoordinatorTypes.RewardsMerkleClaim[]( + claimCount + ); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + + for (uint256 i = 0; i < claimCount; i++) { + address earner = address(uint160(0x3000 + i)); + + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 10 ether + }); + + claims[i] = _createBasicClaim(earner, tokenLeaves); + setupMockCoordinatorStorage(earner, testToken, 10 ether); + } + + testToken.mint(realCoordinator, claimCount * 10 ether); + + uint256 liquidTokenBalanceBefore = testToken.balanceOf(address(liquidToken)); + + vm.prank(earner1); // Any caller can process batch + rewardsManager.processClaims(claims); + + uint256 liquidTokenBalanceAfter = testToken.balanceOf(address(liquidToken)); + uint256 totalTransferred = liquidTokenBalanceAfter - liquidTokenBalanceBefore; + + assertEq(totalTransferred, claimCount * 10 ether); + } + + function test_UpdateClaimerFor_EdgeCases() public { + // Test with zero address should revert + vm.expectRevert(abi.encodeWithSelector(IRewardsManager.ZeroAddress.selector)); + rewardsManager.updateClaimerFor(address(0)); + + // Test claimer addition and removal cycle + vm.mockCall( + realCoordinator, + abi.encodeWithSelector(IRewardsCoordinator.claimerFor.selector, earner1), + abi.encode(address(rewardsManager)) + ); + + vm.prank(earner1); + rewardsManager.updateClaimerFor(earner1); + assertTrue(rewardsManager.isClaimerFor(earner1)); + assertEq(rewardsManager.claimerForLength(), 1); + + // Remove claimer + vm.mockCall( + realCoordinator, + abi.encodeWithSelector(IRewardsCoordinator.claimerFor.selector, earner1), + abi.encode(address(0)) + ); + + vm.prank(earner1); + rewardsManager.updateClaimerFor(earner1); + assertFalse(rewardsManager.isClaimerFor(earner1)); + assertEq(rewardsManager.claimerForLength(), 0); + } + + function test_BalanceAssets_MultipleAssets() public { + // Setup some unsupported asset balances + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + + setupMockCoordinatorStorage(earner1, unsupportedToken1, 100 ether); + setupMockCoordinatorStorage(earner1, unsupportedToken2, 200 ether); + unsupportedToken1.mint(realCoordinator, 100 ether); + unsupportedToken2.mint(realCoordinator, 200 ether); + + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves1 = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves1[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(unsupportedToken1)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves2 = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves2[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(unsupportedToken2)), + cumulativeEarnings: 200 ether + }); + + vm.prank(earner1); + rewardsManager.processClaim(_createBasicClaim(earner1, tokenLeaves1)); + + vm.prank(earner1); + rewardsManager.processClaim(_createBasicClaim(earner1, tokenLeaves2)); + + // Test balanceAssets function + IERC20[] memory assets = new IERC20[](3); + assets[0] = unsupportedToken1; + assets[1] = unsupportedToken2; + assets[2] = testToken; // Should be 0 + + uint256[] memory balances = rewardsManager.balanceAssets(assets); + + assertEq(balances[0], 100 ether); + assertEq(balances[1], 200 ether); + assertEq(balances[2], 0); + } + + //// + function _createBasicClaim( + address earner, + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] memory tokenLeaves + ) internal pure returns (IRewardsCoordinatorTypes.RewardsMerkleClaim memory) { + IRewardsCoordinatorTypes.EarnerTreeMerkleLeaf memory earnerLeaf = IRewardsCoordinatorTypes + .EarnerTreeMerkleLeaf({earner: earner, earnerTokenRoot: bytes32(0)}); + + bytes memory emptyProof = new bytes(0); + bytes[] memory tokenTreeProofs = new bytes[](tokenLeaves.length); + for (uint i = 0; i < tokenLeaves.length; i++) { + tokenTreeProofs[i] = new bytes(0); + } + + return + IRewardsCoordinatorTypes.RewardsMerkleClaim({ + rootIndex: 1, + earnerIndex: 0, + earnerTreeProof: emptyProof, + earnerLeaf: earnerLeaf, + tokenIndices: new uint32[](tokenLeaves.length), + tokenTreeProofs: tokenTreeProofs, + tokenLeaves: tokenLeaves + }); + } +} diff --git a/test/SingleTokenWithdrawalManager.t.sol b/test/SingleTokenWithdrawalManager.t.sol new file mode 100644 index 00000000..0d5a26bb --- /dev/null +++ b/test/SingleTokenWithdrawalManager.t.sol @@ -0,0 +1,1029 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; +import "forge-std/console.sol"; +import "forge-std/console2.sol"; + +import "forge-std/Test.sol"; +import "./common/BaseTest.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; +import {StdInvariant} from "forge-std/StdInvariant.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {WithdrawalManager} from "../src/core/WithdrawalManager.sol"; +import {IWithdrawalManager} from "../src/interfaces/IWithdrawalManager.sol"; +import {ILiquidTokenManager} from "../src/interfaces/ILiquidTokenManager.sol"; +import {ILiquidToken} from "../src/interfaces/ILiquidToken.sol"; +import {IStakerNodeCoordinator} from "../src/interfaces/IStakerNodeCoordinator.sol"; +import {IDelegationManager} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; +import {IDelegationManagerTypes} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; +import {ISignatureUtilsMixinTypes} from "@eigenlayer/contracts/interfaces/ISignatureUtilsMixin.sol"; +import {ITokenRegistryOracle} from "../src/interfaces/ITokenRegistryOracle.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; +import {MockStrategy} from "./mocks/MockStrategy.sol"; +import {MockChainlinkFeed} from "./mocks/MockChainlinkFeed.sol"; +import {MockAVSRegistrar} from "./mocks/MockAVSRegistrar.sol"; +import {IStrategy} from "@eigenlayer/contracts/interfaces/IStrategy.sol"; +import {IAllocationManagerTypes} from "@eigenlayer/contracts/interfaces/IAllocationManager.sol"; +import {StrategyBase} from "@eigenlayer/contracts/strategies/StrategyBase.sol"; +import {IStrategyManager} from "@eigenlayer/contracts/interfaces/IStrategyManager.sol"; +import {IPauserRegistry} from "@eigenlayer/contracts/interfaces/IPauserRegistry.sol"; +import {OperatorSet} from "@eigenlayer/contracts/libraries/OperatorSetLib.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +struct StrategySlashPair { + address strategy; + uint256 wadToSlash; +} + +struct ExpectedBalances { + uint256 totalAssets; + uint256[1] assetBalances; // [testToken] + uint256[1] queuedAssetBalances; + uint256[1] nodeBalances; + string description; +} + +struct UserTestData { + address user; + IERC20[] assets; + uint256[] amounts; + uint256 balanceBefore; + uint256 balanceAfter; + uint256 sharesCharged; +} + +struct SlashingResults { + uint256 token1Remaining; +} + +struct WithdrawalPhaseData { + bytes32[] requestIds; + UserTestData[1] users; + SlashingResults remainingBalances; + IDelegationManagerTypes.Withdrawal[] withdrawals; // Store actual withdrawals from settlement +} + +// ------------------------------------------------------------------------------ +// Custom mocking +// ------------------------------------------------------------------------------ + +/// @notice Token that simulates rebasing ie, increase in user balances over time +/// @dev To mock LSTs like stETH, rRETH +/// @dev Use like any ERC20 token, warp time to increase user balance +contract MockRebasingToken { + string public name; + string public symbol; + uint8 public decimals = 18; + + mapping(address => uint256) private _shares; + mapping(address => mapping(address => uint256)) private _allowances; + uint256 private _totalShares; + uint256 private _totalPooledEther; + uint256 private _lastRebaseTime; + uint256 private _rebaseRate; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor(string memory _name, string memory _symbol) { + name = _name; + symbol = _symbol; + _totalPooledEther = 1e18; + _totalShares = 1e18; + _lastRebaseTime = block.timestamp; + _rebaseRate = 0; // 5e16; // 5% annually + } + + function totalSupply() external view returns (uint256) { + return _getCurrentTotalPooledEther(); + } + + function balanceOf(address account) external view returns (uint256) { + uint256 currentPooled = _getCurrentTotalPooledEther(); + if (_totalShares == 0) return 0; + return (_shares[account] * currentPooled) / _totalShares; + } + + function allowance(address owner, address spender) external view returns (uint256) { + return _allowances[owner][spender]; + } + + function approve(address spender, uint256 amount) external returns (bool) { + _allowances[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transfer(address to, uint256 amount) external returns (bool) { + return _transfer(msg.sender, to, amount); + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + uint256 currentAllowance = _allowances[from][msg.sender]; + require(currentAllowance >= amount, "ERC20: insufficient allowance"); + + if (currentAllowance != type(uint256).max) { + _allowances[from][msg.sender] = currentAllowance - amount; + } + + return _transfer(from, to, amount); + } + + function _transfer(address from, address to, uint256 amount) internal returns (bool) { + require(amount > 0, "Transfer amount must be positive"); + + // Convert amount to shares first, then check if user has enough shares + uint256 currentPooled = _getCurrentTotalPooledEther(); + uint256 sharesToTransfer = _totalShares > 0 ? (amount * _totalShares) / currentPooled : amount; + + require(_shares[from] >= sharesToTransfer, "ERC20: transfer amount exceeds balance"); + + _shares[from] -= sharesToTransfer; + _shares[to] += sharesToTransfer; + + emit Transfer(from, to, amount); + return true; + } + + function mint(address to, uint256 amount) external { + uint256 currentPooled = _getCurrentTotalPooledEther(); + uint256 sharesToMint = _totalShares > 0 ? (amount * _totalShares) / currentPooled : amount; + + _shares[to] += sharesToMint; + _totalShares += sharesToMint; + _totalPooledEther = currentPooled + amount; + + emit Transfer(address(0), to, amount); + } + + function _getCurrentTotalPooledEther() internal view returns (uint256) { + if (_rebaseRate == 0) return _totalPooledEther; + + uint256 timeElapsed = block.timestamp - _lastRebaseTime; + uint256 growth = (_totalPooledEther * _rebaseRate * timeElapsed) / (365 days * 1e18); + return _totalPooledEther + growth; + } + + function getCurrentPrice() external view returns (uint256) { + return _getCurrentTotalPooledEther(); + } +} + +/// @notice Token that simulates rounding errors during transfer causing 1 wei loss for recepient +/// @dev To mock LSTs like stETH +/// @dev For simplicity there is no loss on minting, only on further transfers +contract MockTransferLossToken is MockERC20 { + constructor(string memory name, string memory symbol) MockERC20(name, symbol) {} + + function transfer(address to, uint256 amount) public override returns (bool) { + require(balanceOf(msg.sender) >= amount, "Insufficient balance"); + + // Apply 1 wei loss on transfers + uint256 actualTransfer = amount > 0 ? amount - 1 : 0; + + // Burn the full amount from sender + _burn(msg.sender, amount); + + // Mint only the reduced amount to recipient + _mint(to, actualTransfer); + + return true; + } + + function transferFrom(address from, address to, uint256 amount) public override returns (bool) { + require(balanceOf(from) >= amount, "Insufficient balance"); + require(allowance(from, msg.sender) >= amount, "Insufficient allowance"); + + // Apply 1 wei loss on transfers + uint256 actualTransfer = amount > 0 ? amount - 1 : 0; + + // Burn the full amount from sender + _burn(from, amount); + + // Mint only the reduced amount to recipient + _mint(to, actualTransfer); + + return true; + } +} + +// ------------------------------------------------------------------------------ +// Testing +// ------------------------------------------------------------------------------ + +contract WithdrawalManagerTest is BaseTest { + IStakerNode public stakerNode; + address public operator = address(uint160(uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao))))); + address public avs = address(uint160(uint256(keccak256(abi.encodePacked(block.timestamp + 1, block.prevrandao))))); + MockAVSRegistrar public mockAVSRegistrar; + + // ------------------------------------------------------------------------------ + // Setup environment + // ------------------------------------------------------------------------------ + + function setUp() public override { + super.setUp(); + _setupAvs(); + _setupStakerNodeAndOperator(); + } + + function _setupAvs() internal { + // Deploy MockAVSRegistrar and set avs address + mockAVSRegistrar = new MockAVSRegistrar(); + avs = address(mockAVSRegistrar); + + vm.startPrank(avs); + // Register metadata + allocationManager.updateAVSMetadataURI(address(avs), "test"); + + // Create an Operator Set + IStrategy[] memory strategies = new IStrategy[](1); + strategies[0] = IStrategy(address(mockStrategy)); + + IAllocationManagerTypes.CreateSetParams[] + memory createSetParams = new IAllocationManagerTypes.CreateSetParams[](1); + createSetParams[0].operatorSetId = uint32(1); + createSetParams[0].strategies = strategies; + + allocationManager.createOperatorSets(address(avs), createSetParams); + vm.stopPrank(); + } + + function _setupStakerNodeAndOperator() internal { + // Whitelist all strategies + vm.prank(strategyManager.strategyWhitelister()); + IStrategy[] memory strategiesToWhitelist = new IStrategy[](1); + strategiesToWhitelist[0] = IStrategy(address(mockStrategy)); + strategyManager.addStrategiesToDepositWhitelist(strategiesToWhitelist); + + // Register a new Operator and register for Operator Set 1 + vm.startPrank(operator); + delegationManager.registerAsOperator(address(0), uint32(0), "ipfs://"); + uint32[] memory operatorSetIds = new uint32[](1); + operatorSetIds[0] = uint32(1); + allocationManager.registerForOperatorSets( + address(operator), + IAllocationManagerTypes.RegisterParams({avs: address(avs), operatorSetIds: operatorSetIds, data: "0x"}) + ); + vm.stopPrank(); + + // Allocate equal magnitudes of full amounts for all strategies + vm.startPrank(operator); + allocationManager.setAllocationDelay(address(operator), uint32(0)); + vm.stopPrank(); + + vm.roll(block.number + 127000); // EL accepts the allocation delay change after 126k blocks (17.5 days) on mainnet + vm.warp(18 days); + + vm.startPrank(operator); + uint64[] memory magnitudes = new uint64[](1); + magnitudes[0] = 1e18; + IAllocationManagerTypes.AllocateParams[] memory params = new IAllocationManagerTypes.AllocateParams[](1); + params[0] = IAllocationManagerTypes.AllocateParams({ + operatorSet: OperatorSet({avs: address(avs), id: uint32(1)}), + strategies: strategiesToWhitelist, + newMagnitudes: magnitudes + }); + allocationManager.modifyAllocations(address(operator), params); + vm.stopPrank(); + + // Delegate Staker Node to new Operator + vm.startPrank(admin); + ISignatureUtilsMixinTypes.SignatureWithExpiry memory signature; + stakerNode = stakerNodeCoordinator.createStakerNode(); + stakerNode.delegate(operator, signature, bytes32(0)); + vm.stopPrank(); + + vm.roll(block.number + 127000); + vm.warp(18 days); + } + + // ------------------------------------------------------------------------------ + // Test the environment setup + // ------------------------------------------------------------------------------ + + /* + NOTE: Test is ready, but rebasing not activated + /// @notice Test rebasing behavior with EigenLayer's `sharesToUnderlying` accuracy + function testRebasingTokenAccuracy() public { + address testUser = address(0x123456); + MockRebasingToken rebasingToken = token3; + MockStrategy rebasingStrategy = token3Strategy; + + // Deal tokens to user + rebasingToken.mint(testUser, 100e18); + uint256 depositAmount = rebasingToken.balanceOf(testUser); + + // Test user delegates to the operator and deposits + vm.startPrank(testUser); + + rebasingToken.approve(address(rebasingStrategy), depositAmount); + ISignatureUtilsMixinTypes.SignatureWithExpiry memory signature; + delegationManager.delegateTo(operator, signature, bytes32(0)); + rebasingToken.approve(address(strategyManager), depositAmount); + + strategyManager.depositIntoStrategy( + IStrategy(address(rebasingStrategy)), + IERC20(address(rebasingToken)), + depositAmount + ); + + vm.stopPrank(); + + // Track conversion after real deposits + uint256 userShares = rebasingStrategy.shares(testUser); + uint256 initialUnderlying = rebasingStrategy.sharesToUnderlyingView(userShares); + + // Simulate time passing for automatic rebasing (1 year = 5% growth) + _warpAndUpdateToken3Oracle(365 days); + + // Now the same shares should convert to more underlying tokens due to time-based rebasing + uint256 rebasedUnderlying = rebasingStrategy.sharesToUnderlyingView(userShares); + + assertTrue(userShares > 0, "User must have been given shares"); + assertTrue(rebasedUnderlying > initialUnderlying, "Rebasing should increase underlying value"); + + // Verify the rebase is reflected in LiquidTokenManager conversion too + uint256 ltmUnderlying = liquidTokenManager.assetSharesToUnderlying(IERC20(address(rebasingToken)), userShares); + assertEq(ltmUnderlying, rebasedUnderlying, "LTM should use strategy's sharesToUnderlying"); + } + */ + + // ------------------------------------------------------------------------------ + // Core test functions + // ------------------------------------------------------------------------------ + + function testSettleUserWithdrawalsFlow() public { + _verifyOraclePricing(); + + _performInitialDeposits(); + + _performStaking(); + + SlashingResults memory slashingResults = _performSlashing(); + + WithdrawalPhaseData memory withdrawalData = _performWithdrawalRequests(slashingResults); + + bytes32 redemptionId = _performSettlement(withdrawalData); + + SlashingResults memory queueSlashingResults = _performQueuePeriodSlashing(); + + _performRedemptionCompletion(redemptionId, withdrawalData, queueSlashingResults); + + _performWithdrawalFulfillment(withdrawalData, queueSlashingResults); + + _performFinalValidation(redemptionId, withdrawalData); + } + + // ------------------------------------------------------------------------------ + // Phase Functions + // ------------------------------------------------------------------------------ + + function _verifyOraclePricing() internal { + uint256 testTokenConvert = liquidTokenManager.convertToUnitOfAccount(IERC20(address(testToken)), 1 ether); + uint256 testTokenShares = mockStrategy.sharesToUnderlying(1 ether); + + assertTrue(testTokenConvert == 1 ether, "testToken convert should be 1e18"); + assertTrue(testTokenShares == 1 ether, "testToken strategy shares should be 1:1"); + } + + function _performInitialDeposits() internal { + address user2 = address(0x1002); + + testToken.mint(user2, 1 ether); + + _executeUserDeposit(user2, IERC20(address(testToken)), 1 ether); + + _assertExpectedBalances( + ExpectedBalances({ + totalAssets: 1 ether, + assetBalances: [uint256(1 ether)], + queuedAssetBalances: [uint256(0)], + nodeBalances: [uint256(0)], + description: "after deposits" + }) + ); + } + + function _performStaking() internal { + IERC20[] memory allAssets = _createAllAssetsArray(); + uint256[] memory assetBalances = liquidToken.balanceAssets(allAssets); + + vm.startPrank(admin); + liquidTokenManager.stakeAssetsToNode(stakerNode.getId(), allAssets, assetBalances); + vm.stopPrank(); + + _assertExpectedBalances( + ExpectedBalances({ + totalAssets: 1 ether, + assetBalances: [uint256(0)], + queuedAssetBalances: [uint256(0)], + nodeBalances: [uint256(1 ether)], + description: "after staking" + }) + ); + } + + function _performSlashing() internal returns (SlashingResults memory) { + StrategySlashPair[1] memory strategyPairs = [ + StrategySlashPair(address(mockStrategy), 5e17) // 50% slash + ]; + + _sortStrategyPairs1(strategyPairs); + + IStrategy[] memory strategiesToSlash = new IStrategy[](1); + uint256[] memory wadsToSlash = new uint256[](1); + + for (uint i = 0; i < 1; i++) { + strategiesToSlash[i] = IStrategy(strategyPairs[i].strategy); + wadsToSlash[i] = strategyPairs[i].wadToSlash; + } + + vm.prank(avs); + allocationManager.slashOperator( + address(avs), + IAllocationManagerTypes.SlashingParams({ + operator: address(operator), + operatorSetId: uint32(1), + strategies: strategiesToSlash, + wadsToSlash: wadsToSlash, + description: "test" + }) + ); + + SlashingResults memory results = SlashingResults({ + token1Remaining: liquidTokenManager.getWithdrawableAssetBalance(IERC20(address(testToken)), false) + }); + + _assertExpectedBalances( + ExpectedBalances({ + totalAssets: results.token1Remaining, + assetBalances: [uint256(0)], + queuedAssetBalances: [uint256(0)], + nodeBalances: [results.token1Remaining], + description: "after slashing" + }) + ); + + return results; + } + + function _performWithdrawalRequests( + SlashingResults memory slashingResults + ) internal returns (WithdrawalPhaseData memory) { + bytes32[] memory requestIds = new bytes32[](1); + UserTestData[1] memory users; + + users[0] = UserTestData({ + user: address(0x1002), + assets: _createUserAssets(IERC20(address(testToken))), + amounts: _createAmountsArray(0.5 ether), // After 50% slashing, only 0.5 ether available + balanceBefore: liquidToken.balanceOf(address(0x1002)), + balanceAfter: 0, + sharesCharged: 0 + }); + + // User2 successful withdrawal + vm.startPrank(users[0].user); + requestIds[0] = liquidToken.initiateWithdrawal(users[0].assets, users[0].amounts); + users[0].balanceAfter = liquidToken.balanceOf(users[0].user); + users[0].sharesCharged = users[0].balanceBefore - users[0].balanceAfter; + vm.stopPrank(); + + _verifyWithdrawalRequests(requestIds, users, slashingResults); + + // Balance assertions after withdrawal requests - assets remain staked, no queued balances yet + _assertExpectedBalances( + ExpectedBalances({ + totalAssets: slashingResults.token1Remaining, + assetBalances: [uint256(0)], // Everything remains staked during withdrawal requests + queuedAssetBalances: [uint256(0)], // No queued balances until settlement + nodeBalances: [slashingResults.token1Remaining], + description: "after withdrawal requests" + }) + ); + + return + WithdrawalPhaseData({ + requestIds: requestIds, + users: users, + remainingBalances: slashingResults, + withdrawals: new IDelegationManagerTypes.Withdrawal[](0) // Will be populated during settlement + }); + } + + function _performSettlement(WithdrawalPhaseData memory data) internal returns (bytes32) { + ILiquidTokenManager.UserWithdrawalsSettlement memory settlement = _createSettlement(data.requestIds); + + vm.recordLogs(); + vm.startPrank(admin); + liquidTokenManager.settleUserWithdrawals(settlement); + vm.stopPrank(); + + // Extract both redemptionId and withdrawals from events + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 redemptionId = _extractRedemptionId(logs); + + data.withdrawals = _extractWithdrawalsFromEigenLayerEvents(logs); + assertTrue(redemptionId != bytes32(0), "RedemptionCreatedForUserWithdrawals event should have been emitted"); + + // Verify total assets preserved after settlement (internal state transition only) + uint256 totalAssetsAfterSettlement = liquidToken.totalAssets(); + uint256 expectedTotalAssets = data.remainingBalances.token1Remaining; + + // Allow for small rounding differences (up to 10 wei) in settlement precision + uint256 tolerance = 10 wei; + if (totalAssetsAfterSettlement > expectedTotalAssets) { + assertLe( + totalAssetsAfterSettlement - expectedTotalAssets, + tolerance, + "Total assets difference after settlement exceeds tolerance" + ); + } else { + assertLe( + expectedTotalAssets - totalAssetsAfterSettlement, + tolerance, + "Total assets difference after settlement exceeds tolerance" + ); + } + + return redemptionId; + } + + function _performQueuePeriodSlashing() internal returns (SlashingResults memory) { + // Apply additional slashing during the EigenLayer withdrawal queue period + // testToken: Additional 15% slash (was 50%, now 57.5% total) + + // Create strategy-slash pairs for sorting (similar to initial slashing) + StrategySlashPair[1] memory strategyPairs = [ + StrategySlashPair(address(mockStrategy), 15e16) // testToken, 15% additional slash + ]; + + _sortStrategyPairs1(strategyPairs); + + IStrategy[] memory strategiesToSlash = new IStrategy[](1); + uint256[] memory wadsToSlash = new uint256[](1); + + for (uint i = 0; i < 1; i++) { + strategiesToSlash[i] = IStrategy(strategyPairs[i].strategy); + wadsToSlash[i] = strategyPairs[i].wadToSlash; + } + + vm.prank(avs); + allocationManager.slashOperator( + address(avs), + IAllocationManagerTypes.SlashingParams({ + operator: address(operator), + operatorSetId: uint32(1), + strategies: strategiesToSlash, + wadsToSlash: wadsToSlash, + description: "queue period slashing" + }) + ); + + SlashingResults memory results = SlashingResults({ + token1Remaining: liquidTokenManager.getWithdrawableAssetBalance(IERC20(address(testToken)), false) + }); + + // Note: totalAssets() doesn't immediately reflect queue period slashing + // The slashing effect only shows when withdrawals are completed + // So we expect totalAssets to remain the same until redemption completion + uint256 actualTotalAssets = liquidToken.totalAssets(); + + // Get current queued balances (these were set during settlement and should remain until completion) + IERC20[] memory allAssets = _createAllAssetsArray(); + uint256[] memory currentQueuedBalances = liquidToken.balanceQueuedAssets(allAssets); + + _assertExpectedBalances( + ExpectedBalances({ + totalAssets: actualTotalAssets, // Total assets remain unchanged until redemption completion + assetBalances: [uint256(0)], // Still staked + queuedAssetBalances: [currentQueuedBalances[0]], // Convert to fixed array + nodeBalances: [results.token1Remaining], + description: "after queue period slashing" + }) + ); + + return results; + } + + function _performRedemptionCompletion( + bytes32 redemptionId, + WithdrawalPhaseData memory withdrawalData, + SlashingResults memory queueSlashingResults + ) internal { + // Advance both time and blocks to pass EigenLayer withdrawal delay + // 15 days = 15 * 24 * 60 * 60 / 12 = 108,000 blocks + uint256 withdrawalDelayBlocks = 108000; + vm.roll(block.number + withdrawalDelayBlocks + 1); + vm.warp(block.timestamp + 15 days); + + // Get redemption data + ILiquidTokenManager.Redemption memory redemption = withdrawalManager.getRedemption(redemptionId); + + uint256[] memory nodeIds = new uint256[](1); + nodeIds[0] = stakerNode.getId(); + + // Use the actual withdrawal structs that were captured during settlement + uint256 numWithdrawals = withdrawalData.withdrawals.length; + require(numWithdrawals == redemption.withdrawalRoots.length, "Withdrawal count mismatch"); + + IDelegationManagerTypes.Withdrawal[][] memory withdrawals = new IDelegationManagerTypes.Withdrawal[][](1); + withdrawals[0] = new IDelegationManagerTypes.Withdrawal[](numWithdrawals); + + IERC20[][][] memory assets = new IERC20[][][](1); + assets[0] = new IERC20[][](numWithdrawals); + + // Copy the withdrawal structs that were captured from the settlement event + for (uint256 i = 0; i < numWithdrawals; i++) { + withdrawals[0][i] = withdrawalData.withdrawals[i]; + + // Verify our withdrawal struct matches the expected root + bytes32 calculatedRoot = keccak256(abi.encode(withdrawals[0][i])); + require( + calculatedRoot == redemption.withdrawalRoots[i], + "Withdrawal root mismatch - captured withdrawal doesn't match redemption" + ); + + // Create corresponding assets array + IERC20 asset = redemption.assets[i]; + assets[0][i] = new IERC20[](1); + assets[0][i][0] = asset; + } + + // Record logs to capture RedemptionCompleted event + vm.recordLogs(); + + // Manager calls `completeRedemption` + vm.startPrank(admin); + liquidTokenManager.completeRedemption(redemptionId, nodeIds, withdrawals, assets); + vm.stopPrank(); + + // Verify RedemptionCompleted event was emitted + Vm.Log[] memory logs = vm.getRecordedLogs(); + bool redemptionCompletedEmitted = false; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == keccak256("RedemptionCompleted(bytes32,address[],uint256[],uint256[])")) { + redemptionCompletedEmitted = true; + break; + } + } + assertTrue(redemptionCompletedEmitted, "RedemptionCompleted event should have been emitted"); + + // Verify that withdrawal requests are now marked as canFulfill = true + IWithdrawalManager.WithdrawalRequest[] memory requests = withdrawalManager.getWithdrawalRequests( + _createSingleRequestIdArray(withdrawalData.requestIds[0]) + ); + assertTrue(requests[0].canFulfill, "Withdrawal request should be marked as fulfillable"); + + // Balance assertions after redemption completion + uint256 actualTotalAssetsAfterCompletion = liquidToken.totalAssets(); + + _assertExpectedBalances( + ExpectedBalances({ + totalAssets: actualTotalAssetsAfterCompletion, + assetBalances: [uint256(0)], // Assets transferred to WithdrawalManager + queuedAssetBalances: [uint256(0)], // Queued balances should be cleared after completion + nodeBalances: [queueSlashingResults.token1Remaining], + description: "after redemption completion" + }) + ); + } + + function _performWithdrawalFulfillment( + WithdrawalPhaseData memory withdrawalData, + SlashingResults memory queueSlashingResults + ) internal { + // Fast-forward time past withdrawal delay (different from EL withdrawal delay) + vm.warp(block.timestamp + withdrawalManager.withdrawalDelay()); + + // Record initial user token balances before fulfillment + uint256 userInitialBalance = testToken.balanceOf(withdrawalData.users[0].user); + + // Record WithdrawalManager initial balances + uint256 wmTokenBalance = testToken.balanceOf(address(withdrawalManager)); + + // Record logs to capture WithdrawalFulfilled events + vm.recordLogs(); + + // User fulfills withdrawal + vm.startPrank(withdrawalData.users[0].user); + withdrawalManager.fulfillWithdrawal(withdrawalData.requestIds[0]); + vm.stopPrank(); + + // Verify WithdrawalFulfilled events were emitted + Vm.Log[] memory logs = vm.getRecordedLogs(); + uint256 fulfillmentEventCount = 0; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == keccak256("WithdrawalFulfilled(bytes32,address,address[],uint256[],uint256)")) { + fulfillmentEventCount++; + } + } + assertEq(fulfillmentEventCount, 1, "Should have 1 WithdrawalFulfilled event"); + + // Verify final user token balances - user should have received reduced amounts + uint256 userFinalBalance = testToken.balanceOf(withdrawalData.users[0].user); + + // User should have received tokens (more than initial) + assertTrue(userFinalBalance > userInitialBalance, "User should have received testToken"); + + // But due to cumulative slashing, they should have received less than their original 0.5 ETH + uint256 userReceivedAmount = userFinalBalance - userInitialBalance; + + // Calculate expected amounts after cumulative slashing + // testToken: 50% initial + 15% queue = 57.5% total loss, so ~42.5% remaining + // Original withdrawal was 0.5 ETH, so user should receive ~0.5 * 0.425 = ~0.2125 ETH + + // Use approximate checks since exact calculations are complex due to cumulative effects + assertTrue(userReceivedAmount < 3e17, "User should receive less than 0.3 ETH due to heavy slashing"); + assertTrue(userReceivedAmount > 1e17, "User should receive more than 0.1 ETH"); + + // Verify WithdrawalManager balances were reduced + uint256 wmTokenFinalBalance = testToken.balanceOf(address(withdrawalManager)); + assertTrue(wmTokenFinalBalance < wmTokenBalance, "WithdrawalManager testToken balance should be reduced"); + + // Verify withdrawal request was deleted (should revert if trying to fulfill again) + vm.startPrank(withdrawalData.users[0].user); + vm.expectRevert(); // Should revert with InvalidWithdrawalRequest + withdrawalManager.fulfillWithdrawal(withdrawalData.requestIds[0]); + vm.stopPrank(); + } + + function _performFinalValidation(bytes32 redemptionId, WithdrawalPhaseData memory data) internal { + ILiquidTokenManager.Redemption memory redemption = withdrawalManager.getRedemption(redemptionId); + + // Verify redemption structure + assertEq(redemption.assets.length, 1, "Redemption should have 1 asset"); + assertEq(redemption.elWithdrawableShares.length, 1, "Redemption should have 1 withdrawable share amount"); + + // Verify EigenLayer applied slashing correctly + ILiquidTokenManager.UserWithdrawalsSettlement memory settlement = _createSettlement(data.requestIds); + assertTrue( + redemption.elWithdrawableShares[0] < settlement.elDepositShares[0][0], + "testToken should be slashed" + ); + } + + // ------------------------------------------------------------------------------ + // Utility Functions + // ------------------------------------------------------------------------------ + + function _executeUserDeposit(address user, IERC20 asset, uint256 amount) internal { + IERC20[] memory assets = new IERC20[](1); + assets[0] = asset; + uint256[] memory amounts = new uint256[](1); + amounts[0] = amount; + + vm.startPrank(user); + asset.approve(address(liquidToken), amount); + liquidToken.deposit(assets, amounts, user); + vm.stopPrank(); + } + + function _createAllAssetsArray() internal view returns (IERC20[] memory) { + IERC20[] memory allAssets = new IERC20[](1); + allAssets[0] = IERC20(address(testToken)); + return allAssets; + } + + function _createUserAssets(IERC20 asset) internal pure returns (IERC20[] memory) { + IERC20[] memory assets = new IERC20[](1); + assets[0] = asset; + return assets; + } + + function _createAmountsArray(uint256 amount) internal pure returns (uint256[] memory) { + uint256[] memory amounts = new uint256[](1); + amounts[0] = amount; + return amounts; + } + + function _createSingleRequestIdArray(bytes32 requestId) internal pure returns (bytes32[] memory) { + bytes32[] memory requestIds = new bytes32[](1); + requestIds[0] = requestId; + return requestIds; + } + + function _sortStrategyPairs(StrategySlashPair[4] memory pairs) internal pure { + for (uint i = 0; i < 3; i++) { + for (uint j = 0; j < 3 - i; j++) { + if (uint160(pairs[j].strategy) > uint160(pairs[j + 1].strategy)) { + StrategySlashPair memory temp = pairs[j]; + pairs[j] = pairs[j + 1]; + pairs[j + 1] = temp; + } + } + } + } + + function _sortStrategyPairs3(StrategySlashPair[3] memory pairs) internal pure { + // Simple bubble sort for 3-element array + for (uint i = 0; i < 2; i++) { + for (uint j = 0; j < 2 - i; j++) { + if (uint160(pairs[j].strategy) > uint160(pairs[j + 1].strategy)) { + StrategySlashPair memory temp = pairs[j]; + pairs[j] = pairs[j + 1]; + pairs[j + 1] = temp; + } + } + } + } + + function _sortStrategyPairs1(StrategySlashPair[1] memory pairs) internal pure { + // No sorting needed for single element + } + + function _verifyWithdrawalRequests( + bytes32[] memory requestIds, + UserTestData[1] memory users, + SlashingResults memory slashing + ) internal { + IWithdrawalManager.WithdrawalRequest[] memory requests = withdrawalManager.getWithdrawalRequests(requestIds); + + // Basic request validation + assertEq(requests[0].requestedAmounts[0], 0.5 ether, "User requested amount should be 0.5 ETH"); + + // Verify user balance changes + assertTrue(users[0].balanceAfter < users[0].balanceBefore, "User should be charged for withdrawal"); + + // Verify shares deposited match what was charged + assertEq(requests[0].sharesDeposited, users[0].sharesCharged, "Shares deposited should match charged"); + } + + function _createSettlement( + bytes32[] memory requestIds + ) internal view returns (ILiquidTokenManager.UserWithdrawalsSettlement memory) { + ILiquidTokenManager.UserWithdrawalsSettlement memory settlement; + settlement.requestIds = requestIds; + + settlement.nodeIds = new uint256[](1); + settlement.nodeIds[0] = stakerNode.getId(); + + settlement.elAssets = new IERC20[][](1); + settlement.elDepositShares = new uint256[][](1); + + IWithdrawalManager.WithdrawalRequest[] memory requests = withdrawalManager.getWithdrawalRequests(requestIds); + + settlement.elAssets[0] = new IERC20[](1); + settlement.elDepositShares[0] = new uint256[](1); + + settlement.elAssets[0][0] = requests[0].assets[0]; + IStrategy strategy = liquidTokenManager.getTokenStrategy(requests[0].assets[0]); + settlement.elDepositShares[0][0] = strategy.underlyingToSharesView(requests[0].requestedAmounts[0]); + + console2.log("Settlement index: 0"); + console2.log(" Asset:", address(settlement.elAssets[0][0])); + console2.log(" Strategy:", address(strategy)); + console2.log(" Shares:", settlement.elDepositShares[0][0]); + + return settlement; + } + + function _extractRedemptionId(Vm.Log[] memory logs) internal pure returns (bytes32) { + bytes32 eventSig = keccak256( + "RedemptionCreatedForUserWithdrawals(bytes32,bytes32[],bytes32[],(address,address,address,uint256,uint32,address[],uint256[])[],address[][],uint256[])" + ); + + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == eventSig) { + return abi.decode(logs[i].data, (bytes32)); + } + } + + return bytes32(0); + } + + function _extractWithdrawals( + Vm.Log[] memory logs + ) internal pure returns (IDelegationManagerTypes.Withdrawal[] memory) { + bytes32 eventSig = keccak256( + "RedemptionCreatedForUserWithdrawals(bytes32,bytes32[],bytes32[],(address,address,address,uint256,uint32,address[],uint256[])[],address[][],uint256[])" + ); + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == eventSig) { + // Decode the event data to extract the withdrawals + (, , , IDelegationManagerTypes.Withdrawal[] memory withdrawals, , ) = abi.decode( + logs[i].data, + (bytes32, bytes32[], bytes32[], IDelegationManagerTypes.Withdrawal[], IERC20[][], uint256[]) + ); + return withdrawals; + } + } + revert("RedemptionCreatedForUserWithdrawals event not found"); + } + + function _extractWithdrawalsFromEigenLayerEvents( + Vm.Log[] memory logs + ) internal pure returns (IDelegationManagerTypes.Withdrawal[] memory) { + // Look for EigenLayer's SlashingWithdrawalQueued events + bytes32 slashingWithdrawalQueuedSig = keccak256( + "SlashingWithdrawalQueued(bytes32,(address,address,address,uint256,uint32,address[],uint256[]),uint256[])" + ); + + // Count how many withdrawal events we have + uint256 withdrawalCount = 0; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == slashingWithdrawalQueuedSig) { + withdrawalCount++; + } + } + + if (withdrawalCount == 0) { + revert("No SlashingWithdrawalQueued events found"); + } + + IDelegationManagerTypes.Withdrawal[] memory withdrawals = new IDelegationManagerTypes.Withdrawal[]( + withdrawalCount + ); + uint256 withdrawalIndex = 0; + + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == slashingWithdrawalQueuedSig) { + // Decode the withdrawal struct from the event data + // Event signature: SlashingWithdrawalQueued(bytes32 withdrawalRoot, Withdrawal withdrawal, uint256[] withdrawableShares) + (, IDelegationManagerTypes.Withdrawal memory withdrawal, ) = abi.decode( + logs[i].data, + (bytes32, IDelegationManagerTypes.Withdrawal, uint256[]) + ); + withdrawals[withdrawalIndex] = withdrawal; + withdrawalIndex++; + } + } + + return withdrawals; + } + + // ------------------------------------------------------------------------------ + // Helper functions + // ------------------------------------------------------------------------------ + + function _verifyPostSettlementBalances() internal { + // Verify core balance properties after settlement: + // 1. No assets in liquid state (everything staked or queued) + // 2. Queued balances exist for settled assets + + IERC20[] memory assetsToCheck = new IERC20[](1); + assetsToCheck[0] = IERC20(address(testToken)); + + uint256[] memory assetBalances = liquidToken.balanceAssets(assetsToCheck); + assertEq(assetBalances[0], 0, "All assets should remain staked or queued, none in liquid state"); + + uint256[] memory queuedBalances = liquidToken.balanceQueuedAssets(assetsToCheck); + assertTrue(queuedBalances[0] > 0, "testToken should have queued balance after settlement"); + } + + function _verifyRedemptionWithdrawableShares( + ILiquidTokenManager.Redemption memory redemption, + ILiquidTokenManager.UserWithdrawalsSettlement memory settlement + ) internal { + // For testToken: 50% slashed, so our deposit shares should become 50% less withdrawable shares + uint256 expectedTokenWithdrawableShares = settlement.elDepositShares[0][0] / 2; // 50% slashing + assertEq( + redemption.elWithdrawableShares[0], + expectedTokenWithdrawableShares, + "testToken should reflect 50% slashing" + ); + } + + function _assertExpectedBalances(ExpectedBalances memory expected) internal { + IERC20[] memory allAssets = new IERC20[](1); + allAssets[0] = IERC20(address(testToken)); + + // Check total assets + uint256 actualTotalAssets = liquidToken.totalAssets(); + assertEq( + actualTotalAssets, + expected.totalAssets, + string.concat("Total assets mismatch - ", expected.description) + ); + + // Check asset balances + uint256[] memory actualAssetBalances = liquidToken.balanceAssets(allAssets); + assertEq( + actualAssetBalances[0], + expected.assetBalances[0], + string.concat("Asset balance mismatch for testToken - ", expected.description) + ); + + // Check queued asset balances + uint256[] memory actualQueuedBalances = liquidToken.balanceQueuedAssets(allAssets); + assertEq( + actualQueuedBalances[0], + expected.queuedAssetBalances[0], + string.concat("Queued balance mismatch for testToken - ", expected.description) + ); + + // Check node balances + uint256 actualNodeBalance = liquidTokenManager.getWithdrawableAssetBalanceNode( + allAssets[0], + stakerNode.getId(), + false + ); + assertEq( + liquidTokenManager.convertToUnitOfAccount(allAssets[0], actualNodeBalance), + expected.nodeBalances[0], + string.concat("Node balance mismatch for testToken - ", expected.description) + ); + } +} diff --git a/test/StakerNodeCoordinator.t.sol b/test/StakerNodeCoordinator.t.sol index 522b6ab1..126d6bea 100644 --- a/test/StakerNodeCoordinator.t.sol +++ b/test/StakerNodeCoordinator.t.sol @@ -7,7 +7,9 @@ import {StakerNode} from "../src/core/StakerNode.sol"; import {IStakerNode} from "../src/interfaces/IStakerNode.sol"; import {IStakerNodeCoordinator} from "../src/interfaces/IStakerNodeCoordinator.sol"; import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import {IRewardsCoordinator} from "@eigenlayer/contracts/interfaces/IRewardsCoordinator.sol"; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {NetworkAddresses} from "./utils/NetworkAddresses.sol"; contract StakerNodeCoordinatorTest is BaseTest { function setUp() public override { @@ -125,11 +127,18 @@ contract StakerNodeCoordinatorTest is BaseTest { ); StakerNodeCoordinator proxiedCoordinator = StakerNodeCoordinator(address(proxy)); + // Get EigenLayer RewardsCoordinator from network addresses + uint256 chainId = block.chainid; + NetworkAddresses.Addresses memory addresses = NetworkAddresses.getAddresses(chainId); + // Initialize with zero maxNodes IStakerNodeCoordinator.Init memory init = IStakerNodeCoordinator.Init({ liquidTokenManager: liquidTokenManager, - delegationManager: delegationManager, + withdrawalManager: withdrawalManager, + rewardsManager: rewardsManager, strategyManager: strategyManager, + delegationManager: delegationManager, + rewardsCoordinator: IRewardsCoordinator(addresses.rewardsCoordinator), maxNodes: 0, // Set maxNodes to 0 initialOwner: admin, pauser: pauser, @@ -193,10 +202,16 @@ contract StakerNodeCoordinatorTest is BaseTest { ); // 5. Initialize with maxNodes = 1 (normal case) using deployer as caller + uint256 chainId = block.chainid; + NetworkAddresses.Addresses memory addresses = NetworkAddresses.getAddresses(chainId); + IStakerNodeCoordinator.Init memory init = IStakerNodeCoordinator.Init({ liquidTokenManager: liquidTokenManager, - delegationManager: delegationManager, + withdrawalManager: withdrawalManager, + rewardsManager: rewardsManager, strategyManager: strategyManager, + delegationManager: delegationManager, + rewardsCoordinator: IRewardsCoordinator(addresses.rewardsCoordinator), maxNodes: 1, // Allow 1 node initialOwner: deployer, // Initialize with deployer as owner pauser: pauser, diff --git a/test/TokenRateProvider.t.sol b/test/TokenRateProvider.t.sol index e7a9b33f..6cef19af 100644 --- a/test/TokenRateProvider.t.sol +++ b/test/TokenRateProvider.t.sol @@ -44,6 +44,11 @@ contract TokenRateProviderTest is BaseTest { MockStrategy public unibtcStrategy; MockStrategy public eigenInuStrategy; + MockERC20 public swethToken; // Swell ETH - UniswapV3 TWAP source + + address public swethUniV3Pool; // Mock Uniswap V3 pool address + + MockStrategy public swethStrategy; function setUp() public override { // Call super.setUp() first, which sets up the base test environment super.setUp(); @@ -321,13 +326,103 @@ contract TokenRateProviderTest is BaseTest { ); console.log("Native token added successfully"); - vm.stopPrank(); + // ========== UNISWAP V3 TWAP SETUP ========== + console.log("=== SETTING UP UNISWAP V3 TWAP TOKEN ==="); + + // Create swETH token and strategy + swethToken = new MockERC20("Swell Staked ETH", "swETH"); + swethStrategy = new MockStrategy(strategyManager, IERC20(address(swethToken))); + swethUniV3Pool = address(0x1234567890123456789012345678901234567890); // Mock pool address + + console.log("swethToken:", address(swethToken)); + console.log("swethStrategy:", address(swethStrategy)); + console.log("swethUniV3Pool (mock):", swethUniV3Pool); + + // Configure swETH with UniswapV3 TWAP in oracle + console.log("Configuring swETH with UniswapV3 TWAP..."); + tokenRegistryOracle.configureToken( + address(swethToken), + SOURCE_TYPE_UNISWAP_V3_TWAP, + swethUniV3Pool, + 0, // needsArg not used for TWAP + address(0), // No fallback for this test + bytes4(0) + ); + console.log("swETH configured with UniswapV3 TWAP"); + // Mock the Uniswap V3 price fetching for swETH token + console.log("Mocking UniswapV3 price fetching for swETH..."); + vm.mockCall( + address(tokenRegistryOracle), + abi.encodeWithSelector(ITokenRegistryOracle._getTokenPrice_getter.selector, address(swethToken)), + abi.encode(1.01e18, true) // price = 1.01 ETH, success = true + ); + + // Also mock the getTokenPrice function + vm.mockCall( + address(tokenRegistryOracle), + abi.encodeWithSelector(ITokenRegistryOracle.getTokenPrice.selector, address(swethToken)), + abi.encode(1.01e18) + ); + + console.log("UniswapV3 mocking complete"); + + // Add swETH token to LiquidTokenManager + console.log("Adding swETH token to LiquidTokenManager..."); + try + liquidTokenManager.addToken( + IERC20(address(swethToken)), + 18, + 0, + swethStrategy, + SOURCE_TYPE_UNISWAP_V3_TWAP, + swethUniV3Pool, + 0, + address(0), + bytes4(0) + ) + { + console.log("swETH token added successfully"); + } catch Error(string memory reason) { + console.log("swETH token add failed:", reason); + revert(string(abi.encodePacked("swETH add failed: ", reason))); + } catch { + console.log("swETH token add failed with unknown error"); + revert("swETH add failed with unknown error"); + } + + // ========== END UNISWAP V3 TWAP SETUP ========== + + vm.stopPrank(); console.log("=== SETUP COMPLETE ==="); } // Overriding and fixing the BaseTest's _updateAllPrices to use try/catch for updating prices function _updateAllPrices() internal override { + // Mock the Uniswap V3 pool calls for swETH before updating prices + vm.mockCall( + swethUniV3Pool, + abi.encodeWithSelector(bytes4(keccak256("token0()"))), + abi.encode(address(swethToken)) + ); + vm.mockCall( + swethUniV3Pool, + abi.encodeWithSelector(bytes4(keccak256("token1()"))), + abi.encode(address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2)) // WETH + ); + + // Mock observe() call to return TWAP data + int56[] memory tickCumulatives = new int56[](2); + tickCumulatives[0] = -900 * 900; // 15 minutes ago + tickCumulatives[1] = 0; // now + uint160[] memory secondsPerLiquidityCumulativeX128s = new uint160[](2); + + vm.mockCall( + swethUniV3Pool, + abi.encodeWithSelector(bytes4(keccak256("observe(uint32[])"))), + abi.encode(tickCumulatives, secondsPerLiquidityCumulativeX128s) + ); + // Instead of revoking on failure, try/catch and manually set a price vm.startPrank(user2); try tokenRegistryOracle.updateAllPricesIfNeeded() { @@ -339,6 +434,7 @@ contract TokenRateProviderTest is BaseTest { tokenRegistryOracle.updateRate(IERC20(address(stethToken)), 1.03e18); tokenRegistryOracle.updateRate(IERC20(address(osethToken)), 1.02e18); tokenRegistryOracle.updateRate(IERC20(address(unibtcToken)), 29.7e18); + tokenRegistryOracle.updateRate(IERC20(address(swethToken)), 1.01e18); } vm.stopPrank(); } @@ -493,7 +589,6 @@ contract TokenRateProviderTest is BaseTest { rethFeed.setAnswer(int256(1.04e18)); rethFeed.setUpdatedAt(block.timestamp); - // FIXED: Update BOTH stethFeed (primary) AND stethProtocol (fallback) stethFeed.setAnswer(int256(1.03e18)); stethFeed.setUpdatedAt(block.timestamp); stethProtocol.setExchangeRate(1.03e18); @@ -504,6 +599,30 @@ contract TokenRateProviderTest is BaseTest { uniBtcFeed.setAnswer(int256(99000000000000000000)); uniBtcFeed.setUpdatedAt(block.timestamp); + // Mock Uniswap V3 calls with realistic tick data + vm.mockCall( + swethUniV3Pool, + abi.encodeWithSelector(bytes4(keccak256("token0()"))), + abi.encode(address(swethToken)) + ); + vm.mockCall( + swethUniV3Pool, + abi.encodeWithSelector(bytes4(keccak256("token1()"))), + abi.encode(address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2)) + ); + + // Use realistic tick values (100 ticks ≈ 1% price change) + int56[] memory tickCumulatives = new int56[](2); + tickCumulatives[0] = -100 * 900; // 15 minutes ago, ~1% below + tickCumulatives[1] = 0; // now + uint160[] memory secondsPerLiquidityCumulativeX128s = new uint160[](2); + + vm.mockCall( + swethUniV3Pool, + abi.encodeWithSelector(bytes4(keccak256("observe(uint32[])"))), + abi.encode(tickCumulatives, secondsPerLiquidityCumulativeX128s) + ); + // Update prices initially vm.prank(user2); tokenRegistryOracle.updateAllPricesIfNeeded(); @@ -515,18 +634,14 @@ contract TokenRateProviderTest is BaseTest { uint256 warpTo = block.timestamp + 18 hours; vm.warp(warpTo); - // Update mocks with fresh values at the new timestamp + // Update mocks with fresh values rethFeed.setAnswer(int256(104000000000000000000)); rethFeed.setUpdatedAt(warpTo); - - // FIXED: Update BOTH stethFeed (primary) AND stethProtocol (fallback) with new timestamp stethFeed.setAnswer(int256(1.03e18)); stethFeed.setUpdatedAt(warpTo); stethProtocol.setExchangeRate(1.03e18); stethProtocol.setUpdatedAt(warpTo); - osethCurvePool.setVirtualPrice(1.02e18); - uniBtcFeed.setAnswer(int256(99000000000000000000)); uniBtcFeed.setUpdatedAt(warpTo); @@ -603,4 +718,102 @@ contract TokenRateProviderTest is BaseTest { assertEq(price, 1e18, "EigenInu price should always be 1"); emit log_named_uint("EigenInu price (native, always 1:1)", price); } -} + + function testConfigureSwethWithUniswapV3() public { + ( + uint8 primaryType, + uint8 needsArg, + uint16 reserved, + address primarySource, + address fallbackSource, + bytes4 fallbackFn + ) = tokenRegistryOracle.tokenConfigs(address(swethToken)); + + assertEq(primaryType, SOURCE_TYPE_UNISWAP_V3_TWAP); + assertEq(reserved, 15); // Default 15 minutes TWAP + assertEq(primarySource, swethUniV3Pool); + assertEq(fallbackSource, address(0)); + assertEq(fallbackFn, bytes4(0)); + assertTrue(tokenRegistryOracle.isConfigured(address(swethToken))); + } + + function testGetSwethPriceUniswapV3() public { + // Clear any existing mocks first + vm.clearMockedCalls(); + + // Mock the Uniswap V3 pool calls for testing + vm.mockCall( + swethUniV3Pool, + abi.encodeWithSelector(bytes4(keccak256("token0()"))), + abi.encode(address(swethToken)) + ); + vm.mockCall( + swethUniV3Pool, + abi.encodeWithSelector(bytes4(keccak256("token1()"))), + abi.encode(address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2)) // WETH + ); + + // Mock observe() call to return TWAP data + int56[] memory tickCumulatives = new int56[](2); + tickCumulatives[0] = -9000 * 900; // 15 minutes ago + tickCumulatives[1] = 0; // now + uint160[] memory secondsPerLiquidityCumulativeX128s = new uint160[](2); + + vm.mockCall( + swethUniV3Pool, + abi.encodeWithSelector(bytes4(keccak256("observe(uint32[])"))), + abi.encode(tickCumulatives, secondsPerLiquidityCumulativeX128s) + ); + + // Mock the price getter to return calculated price + vm.mockCall( + address(tokenRegistryOracle), + abi.encodeWithSelector(ITokenRegistryOracle.getTokenPrice.selector, address(swethToken)), + abi.encode(1.01e18) + ); + + assertTrue(liquidTokenManager.tokenIsSupported(IERC20(address(swethToken)))); + assertTrue(tokenRegistryOracle.isConfigured(address(swethToken))); + + uint256 price = tokenRegistryOracle.getTokenPrice(address(swethToken)); + emit log_named_uint("swETH price from UniswapV3 TWAP (ETH)", price); + + // Should return the mocked price + assertEq(price, 1.01e18); + } + + // Add this test function to verify TWAP price updates work + function testSwethPriceUpdate() public { + // Test that swETH can be manually updated + uint256 newRate = 1.05e18; + vm.prank(user2); + tokenRegistryOracle.updateRate(IERC20(address(swethToken)), newRate); + assertEq(tokenRegistryOracle.getRate(IERC20(address(swethToken))), newRate); + + // Test that swETH is included in batch updates + IERC20[] memory tokens = new IERC20[](4); + tokens[0] = IERC20(address(rethToken)); + tokens[1] = IERC20(address(stethToken)); + tokens[2] = IERC20(address(osethToken)); + tokens[3] = IERC20(address(swethToken)); + + uint256[] memory rates = new uint256[](4); + rates[0] = 1.05e18; + rates[1] = 1.04e18; + rates[2] = 1.03e18; + rates[3] = 1.02e18; + + for (uint i = 0; i < tokens.length; i++) { + assertTrue(liquidTokenManager.tokenIsSupported(tokens[i])); + assertTrue(tokenRegistryOracle.isConfigured(address(tokens[i]))); + } + + vm.prank(user2); + tokenRegistryOracle.batchUpdateRates(tokens, rates); + + assertEq(tokenRegistryOracle.getRate(tokens[0]), rates[0]); + assertEq(tokenRegistryOracle.getRate(tokens[1]), rates[1]); + assertEq(tokenRegistryOracle.getRate(tokens[2]), rates[2]); + assertEq(tokenRegistryOracle.getRate(tokens[3]), rates[3]); + } +} \ No newline at end of file diff --git a/test/WithdrawalManager.t.sol b/test/WithdrawalManager.t.sol new file mode 100644 index 00000000..4208d6bb --- /dev/null +++ b/test/WithdrawalManager.t.sol @@ -0,0 +1,1279 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; +import "forge-std/console.sol"; + +import "forge-std/Test.sol"; +import "./common/BaseTest.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; +import {StdInvariant} from "forge-std/StdInvariant.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {WithdrawalManager} from "../src/core/WithdrawalManager.sol"; +import {IWithdrawalManager} from "../src/interfaces/IWithdrawalManager.sol"; +import {ILiquidTokenManager} from "../src/interfaces/ILiquidTokenManager.sol"; +import {ILiquidToken} from "../src/interfaces/ILiquidToken.sol"; +import {IStakerNodeCoordinator} from "../src/interfaces/IStakerNodeCoordinator.sol"; +import {IDelegationManager} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; +import {IDelegationManagerTypes} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; +import {ISignatureUtilsMixinTypes} from "@eigenlayer/contracts/interfaces/ISignatureUtilsMixin.sol"; +import {ITokenRegistryOracle} from "../src/interfaces/ITokenRegistryOracle.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; +import {MockStrategy} from "./mocks/MockStrategy.sol"; +import {MockChainlinkFeed} from "./mocks/MockChainlinkFeed.sol"; +import {MockAVSRegistrar} from "./mocks/MockAVSRegistrar.sol"; +import {IStrategy} from "@eigenlayer/contracts/interfaces/IStrategy.sol"; +import {IAllocationManagerTypes} from "@eigenlayer/contracts/interfaces/IAllocationManager.sol"; +import {StrategyBase} from "@eigenlayer/contracts/strategies/StrategyBase.sol"; +import {IStrategyManager} from "@eigenlayer/contracts/interfaces/IStrategyManager.sol"; +import {IPauserRegistry} from "@eigenlayer/contracts/interfaces/IPauserRegistry.sol"; +import {OperatorSet} from "@eigenlayer/contracts/libraries/OperatorSetLib.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +struct StrategySlashPair { + address strategy; + uint256 wadToSlash; +} + +struct ExpectedBalances { + uint256 totalAssets; + uint256[4] assetBalances; // [testToken, testToken2, token3, token4] + uint256[4] queuedAssetBalances; + uint256[4] nodeBalances; + string description; +} + +struct UserTestData { + address user; + IERC20[] assets; + uint256[] amounts; + uint256 balanceBefore; + uint256 balanceAfter; + uint256 sharesCharged; +} + +struct SlashingResults { + uint256 token1Remaining; + uint256 token2Remaining; + uint256 token3Remaining; + uint256 token4Remaining; +} + +struct WithdrawalPhaseData { + bytes32[] requestIds; + UserTestData[4] users; + SlashingResults remainingBalances; + IDelegationManagerTypes.Withdrawal[] withdrawals; // Store actual withdrawals from settlement +} + +// ------------------------------------------------------------------------------ +// Custom mocking +// ------------------------------------------------------------------------------ + +/// @notice Token that simulates rebasing ie, increase in user balances over time +/// @dev To mock LSTs like stETH, rRETH +/// @dev Use like any ERC20 token, warp time to increase user balance +contract MockRebasingToken { + string public name; + string public symbol; + uint8 public decimals = 18; + + mapping(address => uint256) private _shares; + mapping(address => mapping(address => uint256)) private _allowances; + uint256 private _totalShares; + uint256 private _totalPooledEther; + uint256 private _lastRebaseTime; + uint256 private _rebaseRate; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor(string memory _name, string memory _symbol) { + name = _name; + symbol = _symbol; + _totalPooledEther = 1e18; + _totalShares = 1e18; + _lastRebaseTime = block.timestamp; + _rebaseRate = 0; // 5e16; // 5% annually + } + + function totalSupply() external view returns (uint256) { + return _getCurrentTotalPooledEther(); + } + + function balanceOf(address account) external view returns (uint256) { + uint256 currentPooled = _getCurrentTotalPooledEther(); + if (_totalShares == 0) return 0; + return (_shares[account] * currentPooled) / _totalShares; + } + + function allowance(address owner, address spender) external view returns (uint256) { + return _allowances[owner][spender]; + } + + function approve(address spender, uint256 amount) external returns (bool) { + _allowances[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transfer(address to, uint256 amount) external returns (bool) { + return _transfer(msg.sender, to, amount); + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + uint256 currentAllowance = _allowances[from][msg.sender]; + require(currentAllowance >= amount, "ERC20: insufficient allowance"); + + if (currentAllowance != type(uint256).max) { + _allowances[from][msg.sender] = currentAllowance - amount; + } + + return _transfer(from, to, amount); + } + + function _transfer(address from, address to, uint256 amount) internal returns (bool) { + require(amount > 0, "Transfer amount must be positive"); + + // Convert amount to shares first, then check if user has enough shares + uint256 currentPooled = _getCurrentTotalPooledEther(); + uint256 sharesToTransfer = _totalShares > 0 ? (amount * _totalShares) / currentPooled : amount; + + require(_shares[from] >= sharesToTransfer, "ERC20: transfer amount exceeds balance"); + + _shares[from] -= sharesToTransfer; + _shares[to] += sharesToTransfer; + + emit Transfer(from, to, amount); + return true; + } + + function mint(address to, uint256 amount) external { + uint256 currentPooled = _getCurrentTotalPooledEther(); + uint256 sharesToMint = _totalShares > 0 ? (amount * _totalShares) / currentPooled : amount; + + _shares[to] += sharesToMint; + _totalShares += sharesToMint; + _totalPooledEther = currentPooled + amount; + + emit Transfer(address(0), to, amount); + } + + function _getCurrentTotalPooledEther() internal view returns (uint256) { + if (_rebaseRate == 0) return _totalPooledEther; + + uint256 timeElapsed = block.timestamp - _lastRebaseTime; + uint256 growth = (_totalPooledEther * _rebaseRate * timeElapsed) / (365 days * 1e18); + return _totalPooledEther + growth; + } + + function getCurrentPrice() external view returns (uint256) { + return _getCurrentTotalPooledEther(); + } +} + +/// @notice Token that simulates rounding errors during transfer causing 1 wei loss for recepient +/// @dev To mock LSTs like stETH +/// @dev For simplicity there is no loss on minting, only on further transfers +contract MockTransferLossToken is MockERC20 { + constructor(string memory name, string memory symbol) MockERC20(name, symbol) {} + + function transfer(address to, uint256 amount) public override returns (bool) { + require(balanceOf(msg.sender) >= amount, "Insufficient balance"); + + // Apply 1 wei loss on transfers + uint256 actualTransfer = amount > 0 ? amount - 1 : 0; + + // Burn the full amount from sender + _burn(msg.sender, amount); + + // Mint only the reduced amount to recipient + _mint(to, actualTransfer); + + return true; + } + + function transferFrom(address from, address to, uint256 amount) public override returns (bool) { + require(balanceOf(from) >= amount, "Insufficient balance"); + require(allowance(from, msg.sender) >= amount, "Insufficient allowance"); + + // Apply 1 wei loss on transfers + uint256 actualTransfer = amount > 0 ? amount - 1 : 0; + + // Burn the full amount from sender + _burn(from, amount); + + // Mint only the reduced amount to recipient + _mint(to, actualTransfer); + + return true; + } +} + +// ------------------------------------------------------------------------------ +// Testing +// ------------------------------------------------------------------------------ + +contract WithdrawalManagerTest is BaseTest { + IStakerNode public stakerNode; + MockStrategy public token3Strategy; + MockStrategy public token4Strategy; + MockRebasingToken public token3 = new MockRebasingToken("Mock rebasing", "R"); + MockTransferLossToken public token4 = new MockTransferLossToken("Mock transfer loss", "TL"); + address public operator = address(uint160(uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao))))); + address public avs = address(uint160(uint256(keccak256(abi.encodePacked(block.timestamp + 1, block.prevrandao))))); + MockAVSRegistrar public mockAVSRegistrar; + + // ------------------------------------------------------------------------------ + // Setup environment + // ------------------------------------------------------------------------------ + + function setUp() public override { + super.setUp(); + _setupAdditionalTokens(); + _setupAvs(); + _setupStakerNodeAndOperator(); + } + + function _setupAdditionalTokens() internal { + token3Strategy = new MockStrategy(strategyManager, IERC20(address(token3))); + token4Strategy = new MockStrategy(strategyManager, IERC20(address(token4))); + + vm.startPrank(admin); + liquidTokenManager.addToken( + IERC20(address(token3)), + 18, + 0, + IStrategy(address(token3Strategy)), + SOURCE_TYPE_CHAINLINK, + address(new MockChainlinkFeed(int256(1e8), 8)), + 0, + address(0), + bytes4(0) + ); + + liquidTokenManager.addToken( + IERC20(address(token4)), + 18, + 0, + IStrategy(address(token4Strategy)), + SOURCE_TYPE_CHAINLINK, + address(new MockChainlinkFeed(int256(1e8), 8)), + 0, + address(0), + bytes4(0) + ); + vm.stopPrank(); + } + + function _setupAvs() internal { + // Deploy MockAVSRegistrar and set avs address + mockAVSRegistrar = new MockAVSRegistrar(); + avs = address(mockAVSRegistrar); + + vm.startPrank(avs); + // Register metadata + allocationManager.updateAVSMetadataURI(address(avs), "test"); + + // Create an Operator Set + IStrategy[] memory strategies = new IStrategy[](4); + strategies[0] = IStrategy(address(mockStrategy)); + strategies[1] = IStrategy(address(mockStrategy2)); + strategies[2] = IStrategy(address(token3Strategy)); + strategies[3] = IStrategy(address(token4Strategy)); + + IAllocationManagerTypes.CreateSetParams[] + memory createSetParams = new IAllocationManagerTypes.CreateSetParams[](1); + createSetParams[0].operatorSetId = uint32(1); + createSetParams[0].strategies = strategies; + + allocationManager.createOperatorSets(address(avs), createSetParams); + vm.stopPrank(); + } + + function _setupStakerNodeAndOperator() internal { + // Whitelist all strategies + vm.prank(strategyManager.strategyWhitelister()); + IStrategy[] memory strategiesToWhitelist = new IStrategy[](4); + strategiesToWhitelist[0] = IStrategy(address(mockStrategy)); + strategiesToWhitelist[1] = IStrategy(address(mockStrategy2)); + strategiesToWhitelist[2] = IStrategy(address(token3Strategy)); + strategiesToWhitelist[3] = IStrategy(address(token4Strategy)); + strategyManager.addStrategiesToDepositWhitelist(strategiesToWhitelist); + + // Register a new Operator and register for Operator Set 1 + vm.startPrank(operator); + delegationManager.registerAsOperator(address(0), uint32(0), "ipfs://"); + uint32[] memory operatorSetIds = new uint32[](1); + operatorSetIds[0] = uint32(1); + allocationManager.registerForOperatorSets( + address(operator), + IAllocationManagerTypes.RegisterParams({avs: address(avs), operatorSetIds: operatorSetIds, data: "0x"}) + ); + vm.stopPrank(); + + // Allocate equal magnitudes of full amounts for all strategies + vm.startPrank(operator); + allocationManager.setAllocationDelay(address(operator), uint32(0)); + vm.stopPrank(); + + vm.roll(block.number + 127000); // EL accepts the allocation delay change after 126k blocks (17.5 days) on mainnet + vm.warp(18 days); + + vm.startPrank(operator); + uint64[] memory magnitudes = new uint64[](4); + magnitudes[0] = 1e18; + magnitudes[1] = 1e18; + magnitudes[2] = 1e18; + magnitudes[3] = 1e18; + IAllocationManagerTypes.AllocateParams[] memory params = new IAllocationManagerTypes.AllocateParams[](1); + params[0] = IAllocationManagerTypes.AllocateParams({ + operatorSet: OperatorSet({avs: address(avs), id: uint32(1)}), + strategies: strategiesToWhitelist, + newMagnitudes: magnitudes + }); + allocationManager.modifyAllocations(address(operator), params); + vm.stopPrank(); + + // Delegate Staker Node to new Operator + vm.startPrank(admin); + ISignatureUtilsMixinTypes.SignatureWithExpiry memory signature; + stakerNode = stakerNodeCoordinator.createStakerNode(); + stakerNode.delegate(operator, signature, bytes32(0)); + vm.stopPrank(); + + vm.roll(block.number + 127000); + vm.warp(18 days); + } + + // ------------------------------------------------------------------------------ + // Test the environment setup + // ------------------------------------------------------------------------------ + + /* + NOTE: Test is ready, but rebasing not activated + /// @notice Test rebasing behavior with EigenLayer's `sharesToUnderlying` accuracy + function testRebasingTokenAccuracy() public { + address testUser = address(0x123456); + MockRebasingToken rebasingToken = token3; + MockStrategy rebasingStrategy = token3Strategy; + + // Deal tokens to user + rebasingToken.mint(testUser, 100e18); + uint256 depositAmount = rebasingToken.balanceOf(testUser); + + // Test user delegates to the operator and deposits + vm.startPrank(testUser); + + rebasingToken.approve(address(rebasingStrategy), depositAmount); + ISignatureUtilsMixinTypes.SignatureWithExpiry memory signature; + delegationManager.delegateTo(operator, signature, bytes32(0)); + rebasingToken.approve(address(strategyManager), depositAmount); + + strategyManager.depositIntoStrategy( + IStrategy(address(rebasingStrategy)), + IERC20(address(rebasingToken)), + depositAmount + ); + + vm.stopPrank(); + + // Track conversion after real deposits + uint256 userShares = rebasingStrategy.shares(testUser); + uint256 initialUnderlying = rebasingStrategy.sharesToUnderlyingView(userShares); + + // Simulate time passing for automatic rebasing (1 year = 5% growth) + _warpAndUpdateToken3Oracle(365 days); + + // Now the same shares should convert to more underlying tokens due to time-based rebasing + uint256 rebasedUnderlying = rebasingStrategy.sharesToUnderlyingView(userShares); + + assertTrue(userShares > 0, "User must have been given shares"); + assertTrue(rebasedUnderlying > initialUnderlying, "Rebasing should increase underlying value"); + + // Verify the rebase is reflected in LiquidTokenManager conversion too + uint256 ltmUnderlying = liquidTokenManager.assetSharesToUnderlying(IERC20(address(rebasingToken)), userShares); + assertEq(ltmUnderlying, rebasedUnderlying, "LTM should use strategy's sharesToUnderlying"); + } + */ + + // ------------------------------------------------------------------------------ + // Core test functions + // ------------------------------------------------------------------------------ + + function testSettleUserWithdrawalsFlow() public { + _verifyOraclePricing(); + + _performInitialDeposits(); + + _performStaking(); + + SlashingResults memory slashingResults = _performSlashing(); + + WithdrawalPhaseData memory withdrawalData = _performWithdrawalRequests(slashingResults); + + bytes32 redemptionId = _performSettlement(withdrawalData); + + SlashingResults memory queueSlashingResults = _performQueuePeriodSlashing(); + + _performRedemptionCompletion(redemptionId, withdrawalData, queueSlashingResults); + + _performWithdrawalFulfillment(withdrawalData, queueSlashingResults); + + _performFinalValidation(redemptionId, withdrawalData); + } + + // ------------------------------------------------------------------------------ + // Phase Functions + // ------------------------------------------------------------------------------ + + function _verifyOraclePricing() internal { + uint256 testTokenConvert = liquidTokenManager.convertToUnitOfAccount(IERC20(address(testToken)), 1 ether); + uint256 testToken2Convert = liquidTokenManager.convertToUnitOfAccount(IERC20(address(testToken2)), 1 ether); + uint256 token3Convert = liquidTokenManager.convertToUnitOfAccount(IERC20(address(token3)), 1 ether); + uint256 token4Convert = liquidTokenManager.convertToUnitOfAccount(IERC20(address(token4)), 1 ether); + + uint256 testTokenShares = mockStrategy.sharesToUnderlying(1 ether); + uint256 testToken2Shares = mockStrategy2.sharesToUnderlying(1 ether); + + assertTrue(testTokenConvert == 1 ether, "testToken convert should be 1e18"); + assertTrue(testToken2Convert == 0.5 ether, "testToken2 convert should be 0.5e18"); + assertTrue(token3Convert == 1 ether, "token3 convert should be 1e18"); + assertTrue(token4Convert == 1 ether, "token4 convert should be 1e18"); + assertTrue(testTokenShares == 1 ether, "testToken strategy shares should be 1:1"); + assertTrue(testToken2Shares == 1 ether, "testToken2 strategy shares should be 1:1"); + } + + function _performInitialDeposits() internal { + address user1 = address(0x1001); + address user2 = address(0x1002); + address user3 = address(0x1003); + address user4 = address(0x1004); + + testToken.mint(user1, 1 ether); + testToken2.mint(user2, 1 ether); + token3.mint(user3, 1 ether); + token4.mint(user4, 1 ether); + + _executeUserDeposit(user1, IERC20(address(testToken)), 1 ether); + _executeUserDeposit(user2, IERC20(address(testToken2)), 1 ether); + _executeUserDeposit(user3, IERC20(address(token3)), 1 ether); + _executeUserDeposit(user4, IERC20(address(token4)), 1 ether); + + _assertExpectedBalances( + ExpectedBalances({ + totalAssets: 3.5 ether - 1, + assetBalances: [uint256(1 ether), 1 ether, 1 ether, 1 ether - 1], + queuedAssetBalances: [uint256(0), 0, 0, 0], + nodeBalances: [uint256(0), 0, 0, 0], + description: "after deposits" + }) + ); + } + + function _performStaking() internal { + IERC20[] memory allAssets = _createAllAssetsArray(); + uint256[] memory assetBalances = liquidToken.balanceAssets(allAssets); + + vm.startPrank(admin); + liquidTokenManager.stakeAssetsToNode(stakerNode.getId(), allAssets, assetBalances); + vm.stopPrank(); + + _assertExpectedBalances( + ExpectedBalances({ + totalAssets: 3.5 ether - 4, + assetBalances: [uint256(0), 0, 0, 0], + queuedAssetBalances: [uint256(0), 0, 0, 0], + nodeBalances: [uint256(1 ether), 0.5 ether, 1 ether, 1 ether - 4], + description: "after staking" + }) + ); + } + + function _performSlashing() internal returns (SlashingResults memory) { + StrategySlashPair[4] memory strategyPairs = [ + StrategySlashPair(address(mockStrategy), 1e18), + StrategySlashPair(address(mockStrategy2), 5e17), + StrategySlashPair(address(token3Strategy), 15e16), + StrategySlashPair(address(token4Strategy), 10e16) + ]; + + _sortStrategyPairs(strategyPairs); + + IStrategy[] memory strategiesToSlash = new IStrategy[](4); + uint256[] memory wadsToSlash = new uint256[](4); + + for (uint i = 0; i < 4; i++) { + strategiesToSlash[i] = IStrategy(strategyPairs[i].strategy); + wadsToSlash[i] = strategyPairs[i].wadToSlash; + } + + vm.prank(avs); + allocationManager.slashOperator( + address(avs), + IAllocationManagerTypes.SlashingParams({ + operator: address(operator), + operatorSetId: uint32(1), + strategies: strategiesToSlash, + wadsToSlash: wadsToSlash, + description: "test" + }) + ); + + SlashingResults memory results = SlashingResults({ + token1Remaining: liquidTokenManager.getWithdrawableAssetBalance(IERC20(address(testToken)), false), + token2Remaining: liquidTokenManager.getWithdrawableAssetBalance(IERC20(address(testToken2)), false), + token3Remaining: liquidTokenManager.getWithdrawableAssetBalance(IERC20(address(token3)), false), + token4Remaining: liquidTokenManager.getWithdrawableAssetBalance(IERC20(address(token4)), false) + }); + + _assertExpectedBalances( + ExpectedBalances({ + totalAssets: results.token1Remaining + + results.token2Remaining / + 2 + + results.token3Remaining + + results.token4Remaining, + assetBalances: [uint256(0), 0, 0, 0], + queuedAssetBalances: [uint256(0), 0, 0, 0], + nodeBalances: [ + results.token1Remaining, + results.token2Remaining / 2, + results.token3Remaining, + results.token4Remaining + ], + description: "after slashing" + }) + ); + + return results; + } + + function _performWithdrawalRequests( + SlashingResults memory slashingResults + ) internal returns (WithdrawalPhaseData memory) { + bytes32[] memory requestIds = new bytes32[](3); + UserTestData[4] memory users; + + users[0] = UserTestData({ + user: address(0x1001), + assets: _createUserAssets(IERC20(address(testToken))), + amounts: _createAmountsArray(1 ether), + balanceBefore: 0, + balanceAfter: 0, + sharesCharged: 0 + }); + + users[1] = UserTestData({ + user: address(0x1002), + assets: _createUserAssets(IERC20(address(testToken2))), + amounts: _createAmountsArray(1 ether), + balanceBefore: liquidToken.balanceOf(address(0x1002)), + balanceAfter: 0, + sharesCharged: 0 + }); + + users[2] = UserTestData({ + user: address(0x1003), + assets: _createUserAssets(IERC20(address(token3))), + amounts: _createAmountsArray(1 ether), + balanceBefore: liquidToken.balanceOf(address(0x1003)), + balanceAfter: 0, + sharesCharged: 0 + }); + + users[3] = UserTestData({ + user: address(0x1004), + assets: _createUserAssets(IERC20(address(token4))), + amounts: _createAmountsArray(1 ether - 4 wei), + balanceBefore: liquidToken.balanceOf(address(0x1004)), + balanceAfter: 0, + sharesCharged: 0 + }); + + // User1 (100% slashed) - should fail + vm.startPrank(users[0].user); + vm.expectRevert(abi.encodeWithSignature("ZeroAmount()")); + liquidToken.initiateWithdrawal(users[0].assets, _createAmountsArray(1 ether)); + vm.stopPrank(); + + // Users 2-4 successful withdrawals + for (uint i = 1; i < 4; i++) { + vm.startPrank(users[i].user); + requestIds[i - 1] = liquidToken.initiateWithdrawal(users[i].assets, users[i].amounts); + users[i].balanceAfter = liquidToken.balanceOf(users[i].user); + users[i].sharesCharged = users[i].balanceBefore - users[i].balanceAfter; + vm.stopPrank(); + } + + _verifyWithdrawalRequests(requestIds, users, slashingResults); + + // Balance assertions after withdrawal requests - assets remain staked, no queued balances yet + _assertExpectedBalances( + ExpectedBalances({ + totalAssets: slashingResults.token1Remaining + + slashingResults.token2Remaining / + 2 + + slashingResults.token3Remaining + + slashingResults.token4Remaining, + assetBalances: [uint256(0), 0, 0, 0], // Everything remains staked during withdrawal requests + queuedAssetBalances: [uint256(0), 0, 0, 0], // No queued balances until settlement + nodeBalances: [ + slashingResults.token1Remaining, + slashingResults.token2Remaining / 2, + slashingResults.token3Remaining, + slashingResults.token4Remaining + ], + description: "after withdrawal requests" + }) + ); + + return + WithdrawalPhaseData({ + requestIds: requestIds, + users: users, + remainingBalances: slashingResults, + withdrawals: new IDelegationManagerTypes.Withdrawal[](0) // Will be populated during settlement + }); + } + + function _performSettlement(WithdrawalPhaseData memory data) internal returns (bytes32) { + ILiquidTokenManager.UserWithdrawalsSettlement memory settlement = _createSettlement(data.requestIds); + + vm.recordLogs(); + vm.startPrank(admin); + liquidTokenManager.settleUserWithdrawals(settlement); + vm.stopPrank(); + + // Extract both redemptionId and withdrawals from events + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 redemptionId = _extractRedemptionId(logs); + + data.withdrawals = _extractWithdrawalsFromEigenLayerEvents(logs); + assertTrue(redemptionId != bytes32(0), "RedemptionCreatedForUserWithdrawals event should have been emitted"); + + // Verify total assets preserved after settlement (internal state transition only) + uint256 totalAssetsAfterSettlement = liquidToken.totalAssets(); + uint256 expectedTotalAssets = data.remainingBalances.token1Remaining + + data.remainingBalances.token2Remaining / + 2 + + data.remainingBalances.token3Remaining + + data.remainingBalances.token4Remaining; + + // Allow for small rounding differences (up to 10 wei) in settlement precision + uint256 tolerance = 10 wei; + if (totalAssetsAfterSettlement > expectedTotalAssets) { + assertLe( + totalAssetsAfterSettlement - expectedTotalAssets, + tolerance, + "Total assets difference after settlement exceeds tolerance" + ); + } else { + assertLe( + expectedTotalAssets - totalAssetsAfterSettlement, + tolerance, + "Total assets difference after settlement exceeds tolerance" + ); + } + + return redemptionId; + } + + function _performQueuePeriodSlashing() internal returns (SlashingResults memory) { + // Apply additional slashing during the EigenLayer withdrawal queue period + // testToken2: Additional 25% slash (was 50%, now 62.5% total) + // token3: Additional 10% slash (was 15%, now 24% total) + // token4: Additional 15% slash (was 10%, now 23.5% total) + + // Create strategy-slash pairs for sorting (similar to initial slashing) + StrategySlashPair[3] memory strategyPairs = [ + StrategySlashPair(address(mockStrategy2), 25e16), // testToken2, 25% additional slash + StrategySlashPair(address(token3Strategy), 10e16), // token3, 10% additional slash + StrategySlashPair(address(token4Strategy), 15e16) // token4, 15% additional slash + ]; + + _sortStrategyPairs3(strategyPairs); + + IStrategy[] memory strategiesToSlash = new IStrategy[](3); + uint256[] memory wadsToSlash = new uint256[](3); + + for (uint i = 0; i < 3; i++) { + strategiesToSlash[i] = IStrategy(strategyPairs[i].strategy); + wadsToSlash[i] = strategyPairs[i].wadToSlash; + } + + vm.prank(avs); + allocationManager.slashOperator( + address(avs), + IAllocationManagerTypes.SlashingParams({ + operator: address(operator), + operatorSetId: uint32(1), + strategies: strategiesToSlash, + wadsToSlash: wadsToSlash, + description: "queue period slashing" + }) + ); + + SlashingResults memory results = SlashingResults({ + token1Remaining: liquidTokenManager.getWithdrawableAssetBalance(IERC20(address(testToken)), false), + token2Remaining: liquidTokenManager.getWithdrawableAssetBalance(IERC20(address(testToken2)), false), + token3Remaining: liquidTokenManager.getWithdrawableAssetBalance(IERC20(address(token3)), false), + token4Remaining: liquidTokenManager.getWithdrawableAssetBalance(IERC20(address(token4)), false) + }); + + // Calculate total assets considering token prices + uint256 expectedTotalAssets = results.token1Remaining + + (results.token2Remaining * 1e18) / + (2e18) + // testToken2 is priced at 0.5 ETH + results.token3Remaining + + results.token4Remaining; + + // Note: totalAssets() doesn't immediately reflect queue period slashing + // The slashing effect only shows when withdrawals are completed + // So we expect totalAssets to remain the same until redemption completion + uint256 actualTotalAssets = liquidToken.totalAssets(); + + // Get current queued balances (these were set during settlement and should remain until completion) + IERC20[] memory allAssets = _createAllAssetsArray(); + uint256[] memory currentQueuedBalances = liquidToken.balanceQueuedAssets(allAssets); + + _assertExpectedBalances( + ExpectedBalances({ + totalAssets: actualTotalAssets, // Total assets remain unchanged until redemption completion + assetBalances: [uint256(0), 0, 0, 0], // Still staked + queuedAssetBalances: [ + currentQueuedBalances[0], + currentQueuedBalances[1], + currentQueuedBalances[2], + currentQueuedBalances[3] + ], // Convert to fixed array + nodeBalances: [ + results.token1Remaining, + results.token2Remaining / 2, + results.token3Remaining, + results.token4Remaining + ], + description: "after queue period slashing" + }) + ); + + return results; + } + + function _performRedemptionCompletion( + bytes32 redemptionId, + WithdrawalPhaseData memory withdrawalData, + SlashingResults memory queueSlashingResults + ) internal { + // Advance both time and blocks to pass EigenLayer withdrawal delay + // 15 days = 15 * 24 * 60 * 60 / 12 = 108,000 blocks + uint256 withdrawalDelayBlocks = 108000; + vm.roll(block.number + withdrawalDelayBlocks + 1); + vm.warp(block.timestamp + 15 days); + + // Get redemption data + ILiquidTokenManager.Redemption memory redemption = withdrawalManager.getRedemption(redemptionId); + + uint256[] memory nodeIds = new uint256[](1); + nodeIds[0] = stakerNode.getId(); + + // Use the actual withdrawal structs that were captured during settlement + uint256 numWithdrawals = withdrawalData.withdrawals.length; + require(numWithdrawals == redemption.withdrawalRoots.length, "Withdrawal count mismatch"); + + IDelegationManagerTypes.Withdrawal[][] memory withdrawals = new IDelegationManagerTypes.Withdrawal[][](1); + withdrawals[0] = new IDelegationManagerTypes.Withdrawal[](numWithdrawals); + + IERC20[][][] memory assets = new IERC20[][][](1); + assets[0] = new IERC20[][](numWithdrawals); + + // Copy the withdrawal structs that were captured from the settlement event + for (uint256 i = 0; i < numWithdrawals; i++) { + withdrawals[0][i] = withdrawalData.withdrawals[i]; + + // Verify our withdrawal struct matches the expected root + bytes32 calculatedRoot = keccak256(abi.encode(withdrawals[0][i])); + require( + calculatedRoot == redemption.withdrawalRoots[i], + "Withdrawal root mismatch - captured withdrawal doesn't match redemption" + ); + + // Create corresponding assets array + IERC20 asset = redemption.assets[i]; + assets[0][i] = new IERC20[](1); + assets[0][i][0] = asset; + } + + // Record logs to capture RedemptionCompleted event + vm.recordLogs(); + + // Manager calls `completeRedemption` + vm.startPrank(admin); + liquidTokenManager.completeRedemption(redemptionId, nodeIds, withdrawals, assets); + vm.stopPrank(); + + // Verify RedemptionCompleted event was emitted + Vm.Log[] memory logs = vm.getRecordedLogs(); + bool redemptionCompletedEmitted = false; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == keccak256("RedemptionCompleted(bytes32,address[],uint256[],uint256[])")) { + redemptionCompletedEmitted = true; + break; + } + } + assertTrue(redemptionCompletedEmitted, "RedemptionCompleted event should have been emitted"); + + // Verify that withdrawal requests are now marked as canFulfill = true + for (uint i = 0; i < withdrawalData.requestIds.length; i++) { + if (withdrawalData.requestIds[i] != bytes32(0)) { + IWithdrawalManager.WithdrawalRequest[] memory requests = withdrawalManager.getWithdrawalRequests( + _createSingleRequestIdArray(withdrawalData.requestIds[i]) + ); + assertTrue(requests[0].canFulfill, "Withdrawal request should be marked as fulfillable"); + } + } + + // Balance assertions after redemption completion + uint256 actualTotalAssetsAfterCompletion = liquidToken.totalAssets(); + + _assertExpectedBalances( + ExpectedBalances({ + totalAssets: actualTotalAssetsAfterCompletion, + assetBalances: [uint256(0), 0, 0, 0], // Assets transferred to WithdrawalManager + queuedAssetBalances: [uint256(0), 0, 0, 0], // Queued balances should be cleared after completion + nodeBalances: [ + queueSlashingResults.token1Remaining, + queueSlashingResults.token2Remaining / 2, + queueSlashingResults.token3Remaining, + queueSlashingResults.token4Remaining + ], + description: "after redemption completion" + }) + ); + } + + function _performWithdrawalFulfillment( + WithdrawalPhaseData memory withdrawalData, + SlashingResults memory queueSlashingResults + ) internal { + // Fast-forward time past withdrawal delay (different from EL withdrawal delay) + vm.warp(block.timestamp + withdrawalManager.withdrawalDelay()); + + // Record initial user token balances before fulfillment + uint256[4] memory userInitialBalances; + userInitialBalances[0] = 0; // User 1 has no testToken balance (was rejected) + userInitialBalances[1] = testToken2.balanceOf(withdrawalData.users[1].user); + userInitialBalances[2] = token3.balanceOf(withdrawalData.users[2].user); + userInitialBalances[3] = token4.balanceOf(withdrawalData.users[3].user); + + // Record WithdrawalManager initial balances + uint256 wmToken2Balance = testToken2.balanceOf(address(withdrawalManager)); + uint256 wmToken3Balance = token3.balanceOf(address(withdrawalManager)); + uint256 wmToken4Balance = token4.balanceOf(address(withdrawalManager)); + + // User 1 should not be able to fulfill (was rejected during withdrawal request) + vm.startPrank(withdrawalData.users[0].user); + vm.expectRevert(); // Should revert with InvalidWithdrawalRequest or similar + withdrawalManager.fulfillWithdrawal(withdrawalData.requestIds[0]); // This will be bytes32(0) + vm.stopPrank(); + + // Record logs to capture WithdrawalFulfilled events + vm.recordLogs(); + + // Users 2, 3, 4 fulfill their withdrawals + for (uint i = 1; i < 4; i++) { + vm.startPrank(withdrawalData.users[i].user); + withdrawalManager.fulfillWithdrawal(withdrawalData.requestIds[i - 1]); + vm.stopPrank(); + } + + // Verify WithdrawalFulfilled events were emitted + Vm.Log[] memory logs = vm.getRecordedLogs(); + uint256 fulfillmentEventCount = 0; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == keccak256("WithdrawalFulfilled(bytes32,address,address[],uint256[],uint256)")) { + fulfillmentEventCount++; + } + } + assertEq(fulfillmentEventCount, 3, "Should have 3 WithdrawalFulfilled events"); + + // Verify final user token balances - users should have received reduced amounts + uint256 user2FinalBalance = testToken2.balanceOf(withdrawalData.users[1].user); + uint256 user3FinalBalance = token3.balanceOf(withdrawalData.users[2].user); + uint256 user4FinalBalance = token4.balanceOf(withdrawalData.users[3].user); + + // Users should have received tokens (more than initial) + assertTrue(user2FinalBalance > userInitialBalances[1], "User 2 should have received testToken2"); + assertTrue(user3FinalBalance > userInitialBalances[2], "User 3 should have received token3"); + assertTrue(user4FinalBalance > userInitialBalances[3], "User 4 should have received token4"); + + // But due to cumulative slashing, they should have received less than their original 1 ETH equivalent + uint256 user2ReceivedAmount = user2FinalBalance - userInitialBalances[1]; + uint256 user3ReceivedAmount = user3FinalBalance - userInitialBalances[2]; + uint256 user4ReceivedAmount = user4FinalBalance - userInitialBalances[3]; + + // Calculate expected amounts after cumulative slashing + // User 2 (testToken2): 50% initial + 25% queue = 62.5% total loss, so ~37.5% remaining + // User 3 (token3): 15% initial + 10% queue ≈ 24% total loss, so ~76% remaining + // User 4 (token4): 10% initial + 15% queue ≈ 23.5% total loss, so ~76.5% remaining + + // Use approximate checks since exact calculations are complex due to cumulative effects + assertTrue(user2ReceivedAmount < 5e17, "User 2 should receive less than 0.5 ETH worth due to heavy slashing"); + assertTrue(user3ReceivedAmount < 8e17, "User 3 should receive less than 0.8 ETH worth due to slashing"); + assertTrue(user4ReceivedAmount < 8e17, "User 4 should receive less than 0.8 ETH worth due to slashing"); + + // Verify WithdrawalManager balances were reduced + uint256 wmToken2FinalBalance = testToken2.balanceOf(address(withdrawalManager)); + uint256 wmToken3FinalBalance = token3.balanceOf(address(withdrawalManager)); + uint256 wmToken4FinalBalance = token4.balanceOf(address(withdrawalManager)); + + assertTrue(wmToken2FinalBalance < wmToken2Balance, "WithdrawalManager testToken2 balance should be reduced"); + assertTrue(wmToken3FinalBalance < wmToken3Balance, "WithdrawalManager token3 balance should be reduced"); + assertTrue(wmToken4FinalBalance < wmToken4Balance, "WithdrawalManager token4 balance should be reduced"); + + // Verify withdrawal requests were deleted (should revert if trying to fulfill again) + for (uint i = 1; i < 4; i++) { + vm.startPrank(withdrawalData.users[i].user); + vm.expectRevert(); // Should revert with InvalidWithdrawalRequest + withdrawalManager.fulfillWithdrawal(withdrawalData.requestIds[i - 1]); + vm.stopPrank(); + } + } + + function _performFinalValidation(bytes32 redemptionId, WithdrawalPhaseData memory data) internal { + ILiquidTokenManager.Redemption memory redemption = withdrawalManager.getRedemption(redemptionId); + + // Verify redemption structure + assertEq(redemption.assets.length, 3, "Redemption should have 3 assets"); + assertEq(redemption.elWithdrawableShares.length, 3, "Redemption should have 3 withdrawable share amounts"); + + // Verify EigenLayer applied slashing correctly + ILiquidTokenManager.UserWithdrawalsSettlement memory settlement = _createSettlement(data.requestIds); + assertTrue( + redemption.elWithdrawableShares[0] < settlement.elDepositShares[0][0], + "testToken2 should be slashed" + ); + assertTrue(redemption.elWithdrawableShares[1] < settlement.elDepositShares[1][0], "token3 should be slashed"); + assertTrue(redemption.elWithdrawableShares[2] < settlement.elDepositShares[2][0], "token4 should be slashed"); + } + + // ------------------------------------------------------------------------------ + // Utility Functions + // ------------------------------------------------------------------------------ + + function _executeUserDeposit(address user, IERC20 asset, uint256 amount) internal { + IERC20[] memory assets = new IERC20[](1); + assets[0] = asset; + uint256[] memory amounts = new uint256[](1); + amounts[0] = amount; + + vm.startPrank(user); + asset.approve(address(liquidToken), amount); + liquidToken.deposit(assets, amounts, user); + vm.stopPrank(); + } + + function _createAllAssetsArray() internal view returns (IERC20[] memory) { + IERC20[] memory allAssets = new IERC20[](4); + allAssets[0] = IERC20(address(testToken)); + allAssets[1] = IERC20(address(testToken2)); + allAssets[2] = IERC20(address(token3)); + allAssets[3] = IERC20(address(token4)); + return allAssets; + } + + function _createUserAssets(IERC20 asset) internal pure returns (IERC20[] memory) { + IERC20[] memory assets = new IERC20[](1); + assets[0] = asset; + return assets; + } + + function _createAmountsArray(uint256 amount) internal pure returns (uint256[] memory) { + uint256[] memory amounts = new uint256[](1); + amounts[0] = amount; + return amounts; + } + + function _createSingleRequestIdArray(bytes32 requestId) internal pure returns (bytes32[] memory) { + bytes32[] memory requestIds = new bytes32[](1); + requestIds[0] = requestId; + return requestIds; + } + + function _sortStrategyPairs(StrategySlashPair[4] memory pairs) internal pure { + for (uint i = 0; i < 3; i++) { + for (uint j = 0; j < 3 - i; j++) { + if (uint160(pairs[j].strategy) > uint160(pairs[j + 1].strategy)) { + StrategySlashPair memory temp = pairs[j]; + pairs[j] = pairs[j + 1]; + pairs[j + 1] = temp; + } + } + } + } + + function _sortStrategyPairs3(StrategySlashPair[3] memory pairs) internal pure { + // Simple bubble sort for 3-element array + for (uint i = 0; i < 2; i++) { + for (uint j = 0; j < 2 - i; j++) { + if (uint160(pairs[j].strategy) > uint160(pairs[j + 1].strategy)) { + StrategySlashPair memory temp = pairs[j]; + pairs[j] = pairs[j + 1]; + pairs[j + 1] = temp; + } + } + } + } + + function _verifyWithdrawalRequests( + bytes32[] memory requestIds, + UserTestData[4] memory users, + SlashingResults memory slashing + ) internal { + IWithdrawalManager.WithdrawalRequest[] memory requests = withdrawalManager.getWithdrawalRequests(requestIds); + + // Basic request validation + assertEq(requests[0].requestedAmounts[0], 1 ether, "User 2 requested amount should be 1 ETH"); + assertEq(requests[1].requestedAmounts[0], 1 ether, "User 3 requested amount should be 1 ETH"); + assertEq(requests[2].requestedAmounts[0], 1 ether - 4 wei, "User 4 requested amount should be 1 ETH - 4 wei"); + + // Verify user balance changes + assertEq(users[1].balanceAfter, 0, "User 2 should be charged full amount"); + assertEq(users[2].balanceAfter, 0, "User 3 should be charged full amount"); + assertEq(users[3].balanceAfter, 3, "User 4 should have small remainder"); + + // Verify shares deposited match what was charged + for (uint i = 1; i < 4; i++) { + assertEq(requests[i - 1].sharesDeposited, users[i].sharesCharged, "Shares deposited should match charged"); + } + } + + function _createSettlement( + bytes32[] memory requestIds + ) internal view returns (ILiquidTokenManager.UserWithdrawalsSettlement memory) { + ILiquidTokenManager.UserWithdrawalsSettlement memory settlement; + settlement.requestIds = requestIds; + + settlement.nodeIds = new uint256[](3); + for (uint i = 0; i < 3; i++) { + settlement.nodeIds[i] = stakerNode.getId(); + } + + settlement.elAssets = new IERC20[][](3); + settlement.elDepositShares = new uint256[][](3); + + IWithdrawalManager.WithdrawalRequest[] memory requests = withdrawalManager.getWithdrawalRequests(requestIds); + + for (uint256 i = 0; i < 3; i++) { + settlement.elAssets[i] = new IERC20[](1); + settlement.elDepositShares[i] = new uint256[](1); + + settlement.elAssets[i][0] = requests[i].assets[0]; + IStrategy strategy = liquidTokenManager.getTokenStrategy(requests[i].assets[0]); + settlement.elDepositShares[i][0] = strategy.underlyingToSharesView(requests[i].requestedAmounts[0]); + } + + return settlement; + } + + function _extractRedemptionId(Vm.Log[] memory logs) internal pure returns (bytes32) { + bytes32 eventSig = keccak256( + "RedemptionCreatedForUserWithdrawals(bytes32,bytes32[],bytes32[],(address,address,address,uint256,uint32,address[],uint256[])[],address[][],uint256[])" + ); + + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == eventSig) { + return abi.decode(logs[i].data, (bytes32)); + } + } + + return bytes32(0); + } + + function _extractWithdrawals( + Vm.Log[] memory logs + ) internal pure returns (IDelegationManagerTypes.Withdrawal[] memory) { + bytes32 eventSig = keccak256( + "RedemptionCreatedForUserWithdrawals(bytes32,bytes32[],bytes32[],(address,address,address,uint256,uint32,address[],uint256[])[],address[][],uint256[])" + ); + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == eventSig) { + // Decode the event data to extract the withdrawals + (, , , IDelegationManagerTypes.Withdrawal[] memory withdrawals, , ) = abi.decode( + logs[i].data, + (bytes32, bytes32[], bytes32[], IDelegationManagerTypes.Withdrawal[], IERC20[][], uint256[]) + ); + return withdrawals; + } + } + revert("RedemptionCreatedForUserWithdrawals event not found"); + } + + function _extractWithdrawalsFromEigenLayerEvents( + Vm.Log[] memory logs + ) internal pure returns (IDelegationManagerTypes.Withdrawal[] memory) { + // Look for EigenLayer's SlashingWithdrawalQueued events + bytes32 slashingWithdrawalQueuedSig = keccak256( + "SlashingWithdrawalQueued(bytes32,(address,address,address,uint256,uint32,address[],uint256[]),uint256[])" + ); + + // Count how many withdrawal events we have + uint256 withdrawalCount = 0; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == slashingWithdrawalQueuedSig) { + withdrawalCount++; + } + } + + if (withdrawalCount == 0) { + revert("No SlashingWithdrawalQueued events found"); + } + + IDelegationManagerTypes.Withdrawal[] memory withdrawals = new IDelegationManagerTypes.Withdrawal[]( + withdrawalCount + ); + uint256 withdrawalIndex = 0; + + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == slashingWithdrawalQueuedSig) { + // Decode the withdrawal struct from the event data + // Event signature: SlashingWithdrawalQueued(bytes32 withdrawalRoot, Withdrawal withdrawal, uint256[] withdrawableShares) + (, IDelegationManagerTypes.Withdrawal memory withdrawal, ) = abi.decode( + logs[i].data, + (bytes32, IDelegationManagerTypes.Withdrawal, uint256[]) + ); + withdrawals[withdrawalIndex] = withdrawal; + withdrawalIndex++; + } + } + + return withdrawals; + } + + // ------------------------------------------------------------------------------ + // Helper functions + // ------------------------------------------------------------------------------ + + function _verifyPostSettlementBalances() internal { + // Verify core balance properties after settlement: + // 1. No assets in liquid state (everything staked or queued) + // 2. Queued balances exist for settled assets + + IERC20[] memory assetsToCheck = new IERC20[](4); + assetsToCheck[0] = IERC20(address(testToken)); + assetsToCheck[1] = IERC20(address(testToken2)); + assetsToCheck[2] = IERC20(address(token3)); + assetsToCheck[3] = IERC20(address(token4)); + + uint256[] memory assetBalances = liquidToken.balanceAssets(assetsToCheck); + for (uint256 i = 0; i < 4; i++) { + assertEq(assetBalances[i], 0, "All assets should remain staked or queued, none in liquid state"); + } + + uint256[] memory queuedBalances = liquidToken.balanceQueuedAssets(assetsToCheck); + assertEq(queuedBalances[0], 0, "testToken should have no queued balance (not settled)"); + assertTrue(queuedBalances[1] > 0, "testToken2 should have queued balance after settlement"); + assertTrue(queuedBalances[2] > 0, "token3 should have queued balance after settlement"); + assertTrue(queuedBalances[3] > 0, "token4 should have queued balance after settlement"); + } + + function _verifyRedemptionWithdrawableShares( + ILiquidTokenManager.Redemption memory redemption, + ILiquidTokenManager.UserWithdrawalsSettlement memory settlement + ) internal { + // For testToken2: 50% slashed, so our deposit shares should become 50% less withdrawable shares + uint256 expectedToken2WithdrawableShares = settlement.elDepositShares[0][0] / 2; // 50% slashing + assertEq( + redemption.elWithdrawableShares[0], + expectedToken2WithdrawableShares, + "testToken2 should reflect 50% slashing" + ); + + // For token3: 15% slashed, so withdrawable shares = deposit shares * (1 - 0.15) = deposit shares * 0.85 + uint256 expectedToken3WithdrawableShares = (settlement.elDepositShares[1][0] * 85) / 100; // 85% remaining after 15% slash + assertEq( + redemption.elWithdrawableShares[1], + expectedToken3WithdrawableShares, + "token3 should reflect 15% slashing" + ); + + // For token4: 10% slashed, so withdrawable shares = deposit shares * (1 - 0.10) = deposit shares * 0.90 + uint256 expectedToken4WithdrawableShares = (settlement.elDepositShares[2][0] * 90) / 100; // 90% remaining after 10% slash + // Allow small tolerance for transfer loss token rounding + uint256 token4Tolerance = expectedToken4WithdrawableShares / 100; // 1% tolerance + uint256 token4Diff = redemption.elWithdrawableShares[2] > expectedToken4WithdrawableShares + ? redemption.elWithdrawableShares[2] - expectedToken4WithdrawableShares + : expectedToken4WithdrawableShares - redemption.elWithdrawableShares[2]; + assertTrue(token4Diff <= token4Tolerance, "token4 should reflect ~10% slashing with transfer loss tolerance"); + } + + function _assertExpectedBalances(ExpectedBalances memory expected) internal { + IERC20[] memory allAssets = new IERC20[](4); + allAssets[0] = IERC20(address(testToken)); + allAssets[1] = IERC20(address(testToken2)); + allAssets[2] = IERC20(address(token3)); + allAssets[3] = IERC20(address(token4)); + + // Check total assets + uint256 actualTotalAssets = liquidToken.totalAssets(); + assertEq( + actualTotalAssets, + expected.totalAssets, + string.concat("Total assets mismatch - ", expected.description) + ); + + // Check asset balances + uint256[] memory actualAssetBalances = liquidToken.balanceAssets(allAssets); + for (uint256 i = 0; i < 4; i++) { + assertEq( + actualAssetBalances[i], + expected.assetBalances[i], + string.concat("Asset balance mismatch for token ", Strings.toString(i), " - ", expected.description) + ); + } + + // Check queued asset balances + uint256[] memory actualQueuedBalances = liquidToken.balanceQueuedAssets(allAssets); + for (uint256 i = 0; i < 4; i++) { + assertEq( + actualQueuedBalances[i], + expected.queuedAssetBalances[i], + string.concat("Queued balance mismatch for token ", Strings.toString(i), " - ", expected.description) + ); + } + + // Check node balances + for (uint256 i = 0; i < 4; i++) { + uint256 actualNodeBalance = liquidTokenManager.getWithdrawableAssetBalanceNode( + allAssets[i], + stakerNode.getId(), + false + ); + assertEq( + liquidTokenManager.convertToUnitOfAccount(allAssets[i], actualNodeBalance), + expected.nodeBalances[i], + string.concat("Node balance mismatch for token ", Strings.toString(i), " - ", expected.description) + ); + } + } + + /// @notice Helper function that warps time and updates token3 oracle price + /// @dev Use this instead of vm.warp() when you need token3's oracle to reflect rebased value + /// @param timeToAdd Number of seconds to add to current timestamp + function _warpAndUpdateToken3Oracle(uint256 timeToAdd) internal { + vm.warp(block.timestamp + timeToAdd); + + uint256 currentPrice = token3.getCurrentPrice(); + + // Update oracle mock + vm.mockCall( + address(tokenRegistryOracle), + abi.encodeWithSelector(ITokenRegistryOracle.getTokenPrice.selector, address(token3)), + abi.encode(currentPrice) + ); + + // Update stored pricePerUnit in LiquidTokenManager for full consistency + vm.prank(admin); + liquidTokenManager.updatePrice(IERC20(address(token3)), currentPrice); + } +} diff --git a/test/common/BaseTest.sol b/test/common/BaseTest.sol index d279b165..4697fe02 100644 --- a/test/common/BaseTest.sol +++ b/test/common/BaseTest.sol @@ -7,26 +7,36 @@ import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transpa import {IPauserRegistry} from "@eigenlayer/contracts/permissions/PauserRegistry.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + import {IStrategyManager} from "@eigenlayer/contracts/interfaces/IStrategyManager.sol"; import {IDelegationManager} from "@eigenlayer/contracts/interfaces/IDelegationManager.sol"; import {IStrategy} from "@eigenlayer/contracts/interfaces/IStrategy.sol"; +import {IRewardsCoordinator} from "@eigenlayer/contracts/interfaces/IRewardsCoordinator.sol"; +import {IAllocationManager} from "@eigenlayer/contracts/interfaces/IAllocationManager.sol"; import {LiquidToken} from "../../src/core/LiquidToken.sol"; import {TokenRegistryOracle} from "../../src/utils/TokenRegistryOracle.sol"; import {LiquidTokenManager} from "../../src/core/LiquidTokenManager.sol"; import {StakerNode} from "../../src/core/StakerNode.sol"; import {StakerNodeCoordinator} from "../../src/core/StakerNodeCoordinator.sol"; +import {WithdrawalManager} from "../../src/core/WithdrawalManager.sol"; +import {RewardsManager} from "../../src/core/RewardsManager.sol"; + +import {ILiquidToken} from "../../src/interfaces/ILiquidToken.sol"; +import {ITokenRegistryOracle} from "../../src/interfaces/ITokenRegistryOracle.sol"; +import {ILiquidTokenManager} from "../../src/interfaces/ILiquidTokenManager.sol"; +import {IStakerNode} from "../../src/interfaces/IStakerNode.sol"; +import {IStakerNodeCoordinator} from "../../src/interfaces/IStakerNodeCoordinator.sol"; +import {IWithdrawalManager} from "../../src/interfaces/IWithdrawalManager.sol"; +import {IRewardsManager} from "../../src/interfaces/IRewardsManager.sol"; +import {ILSTSwapRouter} from "../../src/interfaces/ILSTSwapRouter.sol"; + import {MockERC20} from "../mocks/MockERC20.sol"; import {MockStrategy} from "../mocks/MockStrategy.sol"; import {MockChainlinkFeed} from "../mocks/MockChainlinkFeed.sol"; import {MockCurvePool} from "../mocks/MockCurvePool.sol"; import {MockProtocolToken} from "../mocks/MockProtocolToken.sol"; import {MockFailingOracle} from "../mocks/MockFailingOracle.sol"; -import {IStakerNodeCoordinator} from "../../src/interfaces/IStakerNodeCoordinator.sol"; -import {IStakerNode} from "../../src/interfaces/IStakerNode.sol"; -import {ILiquidToken} from "../../src/interfaces/ILiquidToken.sol"; -import {ITokenRegistryOracle} from "../../src/interfaces/ITokenRegistryOracle.sol"; -import {ILiquidTokenManager} from "../../src/interfaces/ILiquidTokenManager.sol"; import {NetworkAddresses} from "../utils/NetworkAddresses.sol"; contract BaseTest is Test { @@ -35,6 +45,8 @@ contract BaseTest is Test { uint8 constant SOURCE_TYPE_CURVE = 2; uint8 constant SOURCE_TYPE_PROTOCOL = 3; uint8 constant SOURCE_TYPE_NATIVE = 0; + uint8 constant SOURCE_TYPE_UNISWAP_V3_TWAP = 4; + uint8 constant SOURCE_TYPE_BALANCER_V2 = 5; // Price freshness constants uint256 constant PRICE_FRESHNESS_PERIOD = 12 hours; @@ -44,6 +56,7 @@ contract BaseTest is Test { // EigenLayer Contracts IStrategyManager public strategyManager; IDelegationManager public delegationManager; + IAllocationManager public allocationManager; // Contracts LiquidToken public liquidToken; @@ -51,6 +64,11 @@ contract BaseTest is Test { LiquidTokenManager public liquidTokenManager; StakerNodeCoordinator public stakerNodeCoordinator; StakerNode public stakerNodeImplementation; + WithdrawalManager public withdrawalManager; + RewardsManager public rewardsManager; + + // Mock LSR contract for testing + //ILSTSwapRouter public mockLSTSwapRouter; // Mock contracts - base test tokens MockERC20 public testToken; @@ -81,11 +99,13 @@ contract BaseTest is Test { address public user1 = address(3); address public user2 = address(4); - // Private variables (with leading underscore) + // Private variables LiquidToken private _liquidTokenImplementation; TokenRegistryOracle private _tokenRegistryOracleImplementation; LiquidTokenManager private _liquidTokenManagerImplementation; StakerNodeCoordinator private _stakerNodeCoordinatorImplementation; + WithdrawalManager private _withdrawalManagerImplementation; + RewardsManager private _rewardsManagerImplementation; // Helper method to use deployer for proxy interactions modifier asDeployer() { @@ -109,22 +129,28 @@ contract BaseTest is Test { // 2. Setup oracle sources _setupOracleSources(); - // 3. Initialize LiquidTokenManager + // 3. Initialize WithdrawalManager + _initializeWithdrawalManager(); + + // 4. Initialize RewardsManager + _initializeRewardsManager(); + + // 5. Initialize LiquidTokenManager _initializeLiquidTokenManager(); - // 4. Initialize LiquidToken (depends on LiquidTokenManager) + // 6. Initialize LiquidToken (depends on LiquidTokenManager) _initializeLiquidToken(); - // 5. Initialize StakerNodeCoordinator (depends on LiquidTokenManager) + // 7. Initialize StakerNodeCoordinator (depends on LiquidTokenManager, WithdrawalManager, RewardsManager) _initializeStakerNodeCoordinator(); - // 6. Add tokens after all initializations + // 8. Add tokens after all initializations _addTestTokens(); - // 7. Setup test token balances + // 9. Setup test token balances _setupTestTokens(); - // 8. Renounce roles at the end + // 10. Renounce roles at the end _renounceAllRoles(); } @@ -196,7 +222,7 @@ contract BaseTest is Test { vm.stopPrank(); } - function _renounceAllRoles() private { + function _renounceAllRoles() internal virtual { vm.startPrank(deployer); // LiquidTokenManager @@ -232,6 +258,19 @@ contract BaseTest is Test { stakerNodeCoordinator.renounceRole(stakerNodeCoordinator.STAKER_NODES_DELEGATOR_ROLE(), deployer); } + // WithdrawalManager + if (withdrawalManager.hasRole(withdrawalManager.DEFAULT_ADMIN_ROLE(), deployer)) { + withdrawalManager.renounceRole(withdrawalManager.DEFAULT_ADMIN_ROLE(), deployer); + } + + // RewardsManager + if (rewardsManager.hasRole(rewardsManager.DEFAULT_ADMIN_ROLE(), deployer)) { + rewardsManager.renounceRole(rewardsManager.DEFAULT_ADMIN_ROLE(), deployer); + } + if (rewardsManager.hasRole(rewardsManager.PAUSER_ROLE(), deployer)) { + rewardsManager.renounceRole(rewardsManager.PAUSER_ROLE(), deployer); + } + // LiquidToken if (liquidToken.hasRole(liquidToken.DEFAULT_ADMIN_ROLE(), deployer)) { liquidToken.renounceRole(liquidToken.DEFAULT_ADMIN_ROLE(), deployer); @@ -255,16 +294,17 @@ contract BaseTest is Test { mETHToETHSelector = bytes4(keccak256("mETHToETH(uint256)")); } - function _setupELContracts() private { + function _setupELContracts() internal virtual { uint256 chainId = block.chainid; NetworkAddresses.Addresses memory addresses = NetworkAddresses.getAddresses(chainId); strategyManager = IStrategyManager(addresses.strategyManager); delegationManager = IDelegationManager(addresses.delegationManager); + allocationManager = IAllocationManager(addresses.allocationManager); } - function _deployMockContracts() private { - // Base test tokens + function _deployMockContracts() internal virtual { + // Base test tokens - Fix: MockERC20 expects 3 parameters (name, symbol, decimals) testToken = new MockERC20("Test Token", "TEST"); testToken2 = new MockERC20("Test Token 2", "TEST2"); mockStrategy = new MockStrategy(strategyManager, IERC20(address(testToken))); @@ -273,26 +313,46 @@ contract BaseTest is Test { // Deploy price feed mocks with realistic values for test tokens testTokenFeed = new MockChainlinkFeed(int256(100000000), 8); // 1 ETH per TEST (8 decimals) testToken2Feed = new MockChainlinkFeed(int256(50000000), 8); // 0.5 ETH per TEST2 (8 decimals) + + // Deploy mock LSR contract - Added + // mockLSTSwapRouter = ILSTSwapRouter(address(0xDEAD)); // Placeholder address for now } - function _deployMainContracts() private { + function _deployMainContracts() internal virtual { _tokenRegistryOracleImplementation = new TokenRegistryOracle(); _liquidTokenImplementation = new LiquidToken(); _liquidTokenManagerImplementation = new LiquidTokenManager(); _stakerNodeCoordinatorImplementation = new StakerNodeCoordinator(); + _withdrawalManagerImplementation = new WithdrawalManager(); + _rewardsManagerImplementation = new RewardsManager(); stakerNodeImplementation = new StakerNode(); } - function _deployProxies() private { + function _deployProxies() internal virtual { tokenRegistryOracle = TokenRegistryOracle( address(new TransparentUpgradeableProxy(address(_tokenRegistryOracleImplementation), proxyAdminAddress, "")) ); + liquidTokenManager = LiquidTokenManager( - address(new TransparentUpgradeableProxy(address(_liquidTokenManagerImplementation), proxyAdminAddress, "")) + payable( + address( + new TransparentUpgradeableProxy(address(_liquidTokenManagerImplementation), proxyAdminAddress, "") + ) + ) ); + liquidToken = LiquidToken( address(new TransparentUpgradeableProxy(address(_liquidTokenImplementation), proxyAdminAddress, "")) ); + + withdrawalManager = WithdrawalManager( + address(new TransparentUpgradeableProxy(address(_withdrawalManagerImplementation), proxyAdminAddress, "")) + ); + + rewardsManager = RewardsManager( + address(new TransparentUpgradeableProxy(address(_rewardsManagerImplementation), proxyAdminAddress, "")) + ); + stakerNodeCoordinator = StakerNodeCoordinator( address( new TransparentUpgradeableProxy(address(_stakerNodeCoordinatorImplementation), proxyAdminAddress, "") @@ -300,7 +360,7 @@ contract BaseTest is Test { ); } - function _setupOracleSources() private { + function _setupOracleSources() internal virtual { console.log("Setting up oracle sources..."); // Grant TOKEN_CONFIGURATOR_ROLE to admin @@ -346,7 +406,7 @@ contract BaseTest is Test { vm.stopPrank(); } - function _initializeTokenRegistryOracle() private { + function _initializeTokenRegistryOracle() internal virtual { console.log("Initializing TokenRegistryOracle..."); ITokenRegistryOracle.Init memory init = ITokenRegistryOracle.Init({ initialOwner: deployer, @@ -375,14 +435,16 @@ contract BaseTest is Test { vm.stopPrank(); } - function _initializeLiquidTokenManager() private { + function _initializeLiquidTokenManager() internal virtual { console.log("Initializing LiquidTokenManager..."); + // Use mock withdrawal manager instead of address(0) ILiquidTokenManager.Init memory init = ILiquidTokenManager.Init({ liquidToken: liquidToken, strategyManager: strategyManager, delegationManager: delegationManager, stakerNodeCoordinator: stakerNodeCoordinator, tokenRegistryOracle: ITokenRegistryOracle(address(tokenRegistryOracle)), + withdrawalManager: withdrawalManager, initialOwner: deployer, strategyController: deployer, priceUpdater: address(tokenRegistryOracle) @@ -396,15 +458,25 @@ contract BaseTest is Test { vm.startPrank(deployer); liquidTokenManager.grantRole(liquidTokenManager.DEFAULT_ADMIN_ROLE(), address(this)); liquidTokenManager.grantRole(liquidTokenManager.STRATEGY_CONTROLLER_ROLE(), address(this)); + liquidTokenManager.grantRole(liquidTokenManager.PRICE_UPDATER_ROLE(), address(this)); + vm.stopPrank(); } - function _initializeStakerNodeCoordinator() private { + function _initializeStakerNodeCoordinator() internal virtual { console.log("Initializing StakerNodeCoordinator..."); + + // Get EigenLayer RewardsCoordinator from network addresses + uint256 chainId = block.chainid; + NetworkAddresses.Addresses memory addresses = NetworkAddresses.getAddresses(chainId); + IStakerNodeCoordinator.Init memory init = IStakerNodeCoordinator.Init({ liquidTokenManager: liquidTokenManager, + withdrawalManager: withdrawalManager, + rewardsManager: rewardsManager, strategyManager: strategyManager, delegationManager: delegationManager, + rewardsCoordinator: IRewardsCoordinator(addresses.rewardsCoordinator), maxNodes: 10, initialOwner: deployer, pauser: pauser, @@ -424,7 +496,51 @@ contract BaseTest is Test { vm.stopPrank(); } - function _initializeLiquidToken() private { + function _initializeWithdrawalManager() internal virtual { + console.log("Initializing WithdrawalManager..."); + IWithdrawalManager.Init memory init = IWithdrawalManager.Init({ + initialOwner: deployer, + delegationManager: delegationManager, + liquidToken: liquidToken, + liquidTokenManager: liquidTokenManager, + stakerNodeCoordinator: stakerNodeCoordinator + }); + + vm.prank(deployer); + withdrawalManager.initialize(init); + + // Grant roles + vm.startPrank(deployer); + withdrawalManager.grantRole(withdrawalManager.DEFAULT_ADMIN_ROLE(), address(this)); + vm.stopPrank(); + } + + function _initializeRewardsManager() internal virtual { + console.log("Initializing RewardsManager..."); + + // Get EigenLayer RewardsCoordinator from network addresses + uint256 chainId = block.chainid; + NetworkAddresses.Addresses memory addresses = NetworkAddresses.getAddresses(chainId); + + IRewardsManager.Init memory init = IRewardsManager.Init({ + rewardsCoordinator: IRewardsCoordinator(addresses.rewardsCoordinator), + liquidToken: liquidToken, + liquidTokenManager: liquidTokenManager, + initialOwner: deployer, + pauser: pauser + }); + + vm.prank(deployer); + rewardsManager.initialize(init); + + // Grant roles + vm.startPrank(deployer); + rewardsManager.grantRole(rewardsManager.DEFAULT_ADMIN_ROLE(), address(this)); + rewardsManager.grantRole(rewardsManager.PAUSER_ROLE(), pauser); + vm.stopPrank(); + } + + function _initializeLiquidToken() internal virtual { console.log("Initializing LiquidToken..."); ILiquidToken.Init memory init = ILiquidToken.Init({ name: "Liquid Staking Token", @@ -432,7 +548,9 @@ contract BaseTest is Test { initialOwner: deployer, pauser: pauser, liquidTokenManager: ILiquidTokenManager(address(liquidTokenManager)), - tokenRegistryOracle: ITokenRegistryOracle(address(tokenRegistryOracle)) + tokenRegistryOracle: ITokenRegistryOracle(address(tokenRegistryOracle)), + withdrawalManager: withdrawalManager, + rewardsManager: rewardsManager }); vm.prank(deployer); @@ -445,7 +563,7 @@ contract BaseTest is Test { vm.stopPrank(); } - function _setupTestTokens() private { + function _setupTestTokens() internal virtual { testToken.mint(user1, 100 ether); testToken.mint(user2, 100 ether); testToken2.mint(user1, 100 ether); @@ -483,7 +601,7 @@ contract BaseTest is Test { /** * @dev Configures a token with Chainlink as the primary price source */ - function _setupChainlinkToken(address token, address feed) internal { + function _setupChainlinkToken(address token, address feed) internal virtual { vm.prank(admin); tokenRegistryOracle.configureToken( token, @@ -498,7 +616,7 @@ contract BaseTest is Test { /** * @dev Configures a token with Protocol rate as the primary price source */ - function _setupProtocolToken(address token, address contract_, bytes4 selector, bool needsArg) internal { + function _setupProtocolToken(address token, address contract_, bytes4 selector, bool needsArg) internal virtual { vm.prank(admin); tokenRegistryOracle.configureToken( token, @@ -513,7 +631,7 @@ contract BaseTest is Test { /** * @dev Configures a token with Curve pool as the primary price source */ - function _setupCurveToken(address token, address curvePool) internal { + function _setupCurveToken(address token, address curvePool) internal virtual { vm.prank(admin); tokenRegistryOracle.configureToken( token, @@ -535,7 +653,7 @@ contract BaseTest is Test { uint8 needsArg, address fallbackSource, bytes4 fallbackFn - ) internal { + ) internal virtual { vm.prank(admin); tokenRegistryOracle.configureToken(token, primaryType, primarySource, needsArg, fallbackSource, fallbackFn); } @@ -543,53 +661,53 @@ contract BaseTest is Test { /** * @dev Gets the price of a token directly from TokenRegistryOracle */ - function _getTokenPrice(address token) internal returns (uint256) { + function _getTokenPrice(address token) internal virtual returns (uint256) { return tokenRegistryOracle.getTokenPrice(token); } // Helper functions for inheriting contracts to use - function _actAsAdmin(function() internal fn) internal { + function _actAsAdmin(function() internal fn) internal virtual { vm.startPrank(admin); fn(); vm.stopPrank(); } - function _actAsDeployer(function() internal fn) internal { + function _actAsDeployer(function() internal fn) internal virtual { vm.startPrank(deployer); fn(); vm.stopPrank(); } - function _actAsUser1(function() internal fn) internal { + function _actAsUser1(function() internal fn) internal virtual { vm.startPrank(user1); fn(); vm.stopPrank(); } - function _actAsUser2(function() internal fn) internal { + function _actAsUser2(function() internal fn) internal virtual { vm.startPrank(user2); fn(); vm.stopPrank(); } // Helper to create a new price source mock for a token - updated to use int256 - function _createMockPriceFeed(int256 price, uint8 decimals) internal returns (MockChainlinkFeed) { + function _createMockPriceFeed(int256 price, uint8 decimals) internal virtual returns (MockChainlinkFeed) { return new MockChainlinkFeed(price, decimals); } - function _createMockProtocolToken(uint256 exchangeRate) internal returns (MockProtocolToken) { + function _createMockProtocolToken(uint256 exchangeRate) internal virtual returns (MockProtocolToken) { MockProtocolToken token = new MockProtocolToken(); token.setExchangeRate(exchangeRate); return token; } - function _createMockCurvePool(uint256 virtualPrice) internal returns (MockCurvePool) { + function _createMockCurvePool(uint256 virtualPrice) internal virtual returns (MockCurvePool) { MockCurvePool pool = new MockCurvePool(); pool.setVirtualPrice(virtualPrice); return pool; } - function _createMockFailingOracle() internal returns (MockFailingOracle) { + function _createMockFailingOracle() internal virtual returns (MockFailingOracle) { return new MockFailingOracle(); } -} +} \ No newline at end of file diff --git a/test/mocks/MockAVSRegistrar.sol b/test/mocks/MockAVSRegistrar.sol new file mode 100644 index 00000000..448c2851 --- /dev/null +++ b/test/mocks/MockAVSRegistrar.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +contract MockAVSRegistrar { + function supportsAVS(address /*avs*/) external pure returns (bool) { + return true; + } + + function registerOperator( + address /*operator*/, + address /*avs*/, + uint32[] calldata /*operatorSetIds*/, + bytes calldata /*data*/ + ) external {} + + function deregisterOperator(address /*operator*/, address /*avs*/, uint32[] calldata /*operatorSetIds*/) external {} + + fallback() external {} +} diff --git a/test/mocks/MockAvsRegistrar.sol b/test/mocks/MockAvsRegistrar.sol new file mode 100644 index 00000000..448c2851 --- /dev/null +++ b/test/mocks/MockAvsRegistrar.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +contract MockAVSRegistrar { + function supportsAVS(address /*avs*/) external pure returns (bool) { + return true; + } + + function registerOperator( + address /*operator*/, + address /*avs*/, + uint32[] calldata /*operatorSetIds*/, + bytes calldata /*data*/ + ) external {} + + function deregisterOperator(address /*operator*/, address /*avs*/, uint32[] calldata /*operatorSetIds*/) external {} + + fallback() external {} +} diff --git a/test/mocks/MockChainlinkFeed.sol b/test/mocks/MockChainlinkFeed.sol index ec2c7723..44e5944f 100644 --- a/test/mocks/MockChainlinkFeed.sol +++ b/test/mocks/MockChainlinkFeed.sol @@ -6,9 +6,9 @@ contract MockChainlinkFeed { uint8 private _decimals; uint256 private _updatedAt; - constructor(int256 initialAnswer, uint8 decimals) { + constructor(int256 initialAnswer, uint8 _decimalsParam) { _answer = initialAnswer; - _decimals = decimals; + _decimals = _decimalsParam; _updatedAt = block.timestamp; } diff --git a/test/mocks/MockERC20.sol b/test/mocks/MockERC20.sol index ae55a8f2..fc24ca7d 100644 --- a/test/mocks/MockERC20.sol +++ b/test/mocks/MockERC20.sol @@ -7,7 +7,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; contract MockERC20 is ERC20 { constructor(string memory name, string memory symbol) ERC20(name, symbol) {} - function mint(address to, uint256 amount) public { + function mint(address to, uint256 amount) public virtual { _mint(to, amount); } } diff --git a/test/mocks/MockLSR.sol b/test/mocks/MockLSR.sol new file mode 100644 index 00000000..2e1e5200 --- /dev/null +++ b/test/mocks/MockLSR.sol @@ -0,0 +1,192 @@ + +/* +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ILSTSwapRouter} from "../../src/interfaces/ILSTSwapRouter.sol"; + +// Mock LSTSwapRouter contract for testing +contract MockLSTSwapRouter is ILSTSwapRouter { + using SafeERC20 for IERC20; + + // Mock storage for routes and rates + mapping(address => mapping(address => uint256)) public mockRates; + mapping(address => mapping(address => bool)) public routeExists; + mapping(address => mapping(address => Protocol)) public routeProtocols; + mapping(address => mapping(address => address)) public routeTargets; + + // Constants + address public constant ETH_ADDR = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + + constructor() { + // Common token addresses + address STETH = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; + address RETH = 0xae78736Cd615f374D3085123A210448E74Fc6393; + address CBETH = 0xBe9895146f7AF43049ca1c1AE358B0541Ea49704; + + // Set up default supported routes with 1:1 rates for testing + _setMockRoute(ETH_ADDR, WETH, 1e18, Protocol.DirectMint, WETH); + _setMockRoute(WETH, ETH_ADDR, 1e18, Protocol.DirectMint, WETH); + _setMockRoute(WETH, STETH, 1e18, Protocol.Curve, STETH); + _setMockRoute(STETH, WETH, 1e18, Protocol.Curve, WETH); + _setMockRoute(ETH_ADDR, STETH, 1e18, Protocol.Curve, STETH); + _setMockRoute(STETH, ETH_ADDR, 1e18, Protocol.Curve, ETH_ADDR); + _setMockRoute(WETH, RETH, 1e18, Protocol.UniswapV3, RETH); + _setMockRoute(RETH, WETH, 1e18, Protocol.UniswapV3, WETH); + _setMockRoute(WETH, CBETH, 1e18, Protocol.UniswapV3, CBETH); + _setMockRoute(CBETH, WETH, 1e18, Protocol.UniswapV3, WETH); + } + + function _setMockRoute( + address tokenIn, + address tokenOut, + uint256 rate, + Protocol protocol, + address target + ) internal { + mockRates[tokenIn][tokenOut] = rate; + routeExists[tokenIn][tokenOut] = true; + routeProtocols[tokenIn][tokenOut] = protocol; + routeTargets[tokenIn][tokenOut] = target; + } + + // ILSTSwapRouter implementation + function ETH_ADDRESS() external pure returns (address) { + return ETH_ADDR; + } + + function uniswapRouter() external pure returns (address) { + return 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45; + } + + function getWETHRequirements( + address tokenIn, + address tokenOut, + Protocol protocol + ) external pure returns (bool needsWrap, bool needsUnwrap, address wethAddress) { + wethAddress = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + needsWrap = tokenIn == 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + needsUnwrap = tokenOut == 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + } + + function getQuoteAndExecutionData( + address tokenIn, + address tokenOut, + uint256 amountIn, + address recipient + ) + external + returns ( + uint256 quotedAmount, + bytes memory executionData, + uint8 protocol, + address targetContract, + uint256 value + ) + { + require(routeExists[tokenIn][tokenOut], "Route not found"); + + quotedAmount = (amountIn * mockRates[tokenIn][tokenOut]) / 1e18; + protocol = uint8(routeProtocols[tokenIn][tokenOut]); + targetContract = routeTargets[tokenIn][tokenOut]; + value = tokenIn == ETH_ADDR ? amountIn : 0; + + // Simple mock execution data + executionData = abi.encodeWithSignature("transfer(address,uint256)", recipient, quotedAmount); + } + + function decodeComplexExecutionData( + bytes calldata complexData + ) + external + pure + returns (uint8 routeType, address firstTarget, bytes memory firstCalldata, bytes memory additionalData) + { + routeType = 0; + firstTarget = address(0); + firstCalldata = ""; + additionalData = ""; + } + + function getBridgeSecondLegData( + address bridgeAsset, + address finalToken, + uint256 bridgeAmount, + uint256 originalMinOut, + address recipient + ) external view returns (bytes memory executionData, address targetContract, bool requiresApproval) { + executionData = abi.encodeWithSignature("transfer(address,uint256)", recipient, bridgeAmount); + targetContract = bridgeAsset; + requiresApproval = true; + } + + function getNextStepExecutionData( + address tokenIn, + address tokenOut, + uint256 amountIn, + bytes calldata fullRouteData, + uint256 stepIndex, + address recipient + ) external view returns (bytes memory executionData, address targetContract, bool isFinalStep) { + executionData = abi.encodeWithSignature("transfer(address,uint256)", recipient, amountIn); + targetContract = tokenOut; + isFinalStep = true; + } + + function getCompleteMultiStepPlan( + address tokenIn, + address tokenOut, + uint256 amountIn, + address recipient + ) external returns (uint256 totalQuotedAmount, MultiStepExecutionPlan memory plan) { + require(routeExists[tokenIn][tokenOut], "Route not found"); + + totalQuotedAmount = (amountIn * mockRates[tokenIn][tokenOut]) / 1e18; + + SwapStep[] memory steps = new SwapStep[](1); + steps[0] = SwapStep({ + tokenIn: tokenIn, + tokenOut: tokenOut, + amountIn: amountIn, + minAmountOut: (totalQuotedAmount * 95) / 100, // 5% slippage + target: routeTargets[tokenIn][tokenOut], + data: abi.encodeWithSignature("transfer(address,uint256)", recipient, totalQuotedAmount), + value: tokenIn == ETH_ADDR ? amountIn : 0, + protocol: routeProtocols[tokenIn][tokenOut] + }); + + plan = MultiStepExecutionPlan({steps: steps, expectedFinalAmount: totalQuotedAmount}); + } + + // Mock execution function for testing + function executeSwap(address tokenIn, address tokenOut, uint256 amountIn, address recipient) external payable { + require(routeExists[tokenIn][tokenOut], "Route not found"); + + uint256 amountOut = (amountIn * mockRates[tokenIn][tokenOut]) / 1e18; + + // Handle input tokens + if (tokenIn == ETH_ADDR) { + require(msg.value >= amountIn, "Insufficient ETH"); + } else { + IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + } + + // Handle output tokens + if (tokenOut == ETH_ADDR) { + (bool success, ) = payable(recipient).call{value: amountOut}(""); + require(success, "ETH transfer failed"); + } else { + IERC20(tokenOut).safeTransfer(recipient, amountOut); + } + } + + receive() external payable {} +} + +// Type alias for backward compatibility +contract MockLSR is MockLSTSwapRouter {} +*/ \ No newline at end of file diff --git a/test/mocks/MockLiquidTokenManager.sol b/test/mocks/MockLiquidTokenManager.sol new file mode 100644 index 00000000..700a36df --- /dev/null +++ b/test/mocks/MockLiquidTokenManager.sol @@ -0,0 +1,388 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import "../../src/interfaces/ILSTSwapRouter.sol"; +import "forge-std/console.sol"; + +contract MockLiquidTokenManager is ReentrancyGuard { + using SafeERC20 for IERC20; + + // State + ILSTSwapRouter public LSTswaprouter; + address public weth; + mapping(uint256 => mapping(address => uint256)) public mockStakedBalances; + + // Constants + address constant ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + address constant STETH_ADDRESS = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; + + // Errors + error ETHNotSupportedAsDirectToken(address token); + + // Events - matching the interface + event LSTSwapRouterUpdated(address indexed oldLSR, address indexed newLSR, address updatedBy); + event AssetsSwappedAndStakedToNode( + uint256 indexed nodeId, + IERC20[] assetsSwapped, + uint256[] amountsSwapped, + IERC20[] assetsStaked, + uint256[] amountsStaked, + address indexed initiator + ); + event SwapExecuted( + address indexed tokenIn, + address indexed tokenOut, + uint256 amountIn, + uint256 amountOut, + uint256 indexed nodeId + ); + + // Structs - matching the interface + struct Init { + address strategyManager; + address delegationManager; + address liquidToken; + address stakerNodeCoordinator; + address tokenRegistryOracle; + address initialOwner; + address strategyController; + address priceUpdater; + address LSTswaprouter; + address weth; + } + + struct NodeAllocationWithSwap { + uint256 nodeId; + IERC20[] assetsToSwap; + uint256[] amountsToSwap; + IERC20[] assetsToStake; + } + + // Initialize + function initialize(Init memory init) external { + require(address(LSTswaprouter) == address(0), "Already initialized"); + LSTswaprouter = ILSTSwapRouter(init.LSTswaprouter); + weth = init.weth; + } + + // Admin function to update LSR + function updateLSTSwapRouter(address newLSTSwapRouter) external { + require(newLSTSwapRouter != address(0), "Zero address"); + address oldLSR = address(LSTswaprouter); + LSTswaprouter = ILSTSwapRouter(newLSTSwapRouter); + emit LSTSwapRouterUpdated(oldLSR, newLSTSwapRouter, msg.sender); + } + + // Main functions following the 3-function pattern from your colleague + + /// @notice Swaps multiple assets and stakes them to multiple nodes + function swapAndStakeAssetsToNodes(NodeAllocationWithSwap[] calldata allocationsWithSwaps) external nonReentrant { + for (uint256 i = 0; i < allocationsWithSwaps.length; i++) { + NodeAllocationWithSwap memory allocationWithSwap = allocationsWithSwaps[i]; + _swapAndStakeAssetsToNode( + allocationWithSwap.nodeId, + allocationWithSwap.assetsToSwap, + allocationWithSwap.amountsToSwap, + allocationWithSwap.assetsToStake + ); + } + } + + /// @notice Swaps assets and stakes them to a single node + function swapAndStakeAssetsToNode( + uint256 nodeId, + IERC20[] memory assetsToSwap, + uint256[] memory amountsToSwap, + IERC20[] memory assetsToStake + ) external nonReentrant { + _swapAndStakeAssetsToNode(nodeId, assetsToSwap, amountsToSwap, assetsToStake); + } + + /// @dev Called by `swapAndStakeAssetsToNode` and `swapAndStakeAssetsToNodes` + /// @dev Flow: MockLTM >> DEX >> MockLTM (using LSR for routing data) + function _swapAndStakeAssetsToNode( + uint256 nodeId, + IERC20[] memory assetsToSwap, + uint256[] memory amountsToSwap, + IERC20[] memory assetsToStake + ) internal { + uint256 assetsLength = assetsToStake.length; + + require(assetsLength == assetsToSwap.length, "Assets length mismatch"); + require(assetsLength == amountsToSwap.length, "Amounts length mismatch"); + require(address(LSTswaprouter) != address(0), "LSR not configured"); + + // Validate that ETH is not used as direct tokenIn or tokenOut (only as bridge asset) + for (uint256 i = 0; i < assetsLength; i++) { + if (address(assetsToSwap[i]) == ETH_ADDRESS) { + revert ETHNotSupportedAsDirectToken(address(assetsToSwap[i])); + } + if (address(assetsToStake[i]) == ETH_ADDRESS) { + revert ETHNotSupportedAsDirectToken(address(assetsToStake[i])); + } + } + + console.log("\n[MockLTM] SwapAndStakeAssetsToNode called:"); + console.log("Node ID:", nodeId); + console.log("Assets to swap:", assetsLength); + + // Mock: Simulate bringing assets from LiquidToken + console.log("[MockLTM] Simulating asset retrieval from LiquidToken..."); + + uint256[] memory amountsToStake = new uint256[](assetsLength); + + // Swap using LSR - for every tokenIn swap to corresponding tokenOut + for (uint256 i = 0; i < assetsLength; i++) { + address tokenIn = address(assetsToSwap[i]); + address tokenOut = address(assetsToStake[i]); + uint256 amountIn = amountsToSwap[i]; + + console.log("\n[MockLTM] Processing swap", i + 1, "of", assetsLength); + console.log("Token In:", tokenIn); + console.log("Token Out:", tokenOut); + console.log("Amount In:", amountIn); + + require(amountIn > 0, "Invalid swap amount"); + + if (tokenIn == tokenOut) { + // No swap needed, direct stake + amountsToStake[i] = amountIn; + console.log("Direct stake (no swap needed)"); + } else { + // Execute swap using LSR + uint256 actualAmountOut = _executeLSRSwapPlan(tokenIn, tokenOut, amountIn); + amountsToStake[i] = actualAmountOut; + + emit SwapExecuted(tokenIn, tokenOut, amountIn, actualAmountOut, nodeId); + } + } + + // Mock: Simulate transferring assets to node and staking + console.log("\n[MockLTM] Simulating asset transfer to node and staking..."); + for (uint256 i = 0; i < assetsLength; i++) { + address tokenAddress = address(assetsToStake[i]); + uint256 amount = amountsToStake[i]; + + // Mock staking - just track the balance + mockStakedBalances[nodeId][tokenAddress] += amount; + + console.log("Staked", amount, "of"); + console.log(tokenAddress, "to node", nodeId); + } + + emit AssetsSwappedAndStakedToNode( + nodeId, + assetsToSwap, + amountsToSwap, + assetsToStake, + amountsToStake, + msg.sender + ); + + console.log("[MockLTM] SwapAndStakeAssetsToNode completed successfully"); + } + + /// @dev Executes a swap plan from LSR following MockLTM >> DEX >> MockLTM flow + function _executeLSRSwapPlan( + address tokenIn, + address tokenOut, + uint256 amountIn + ) internal returns (uint256 actualAmountOut) { + console.log("\n[MockLTM] Executing LSR swap plan:"); + console.log("Token In:", tokenIn); + console.log("Token Out:", tokenOut); + console.log("Amount In:", amountIn); + + // Execute step by step with dynamic planning + return _executeStepByStepSwap(tokenIn, tokenOut, amountIn); + } + + /// @dev Execute swap step by step, regenerating execution data for each step + function _executeStepByStepSwap(address tokenIn, address tokenOut, uint256 amountIn) internal returns (uint256) { + console.log("\n[MockLTM] Starting step-by-step swap execution"); + + address currentTokenIn = tokenIn; + uint256 currentAmountIn = amountIn; + uint256 totalSteps = 0; + + // Handle stETH input precision issue at the beginning + if (currentTokenIn == STETH_ADDRESS) { + // Measure actual balance after transfer + uint256 actualBalance = IERC20(STETH_ADDRESS).balanceOf(address(this)); + console.log("stETH requested:", currentAmountIn); + console.log("stETH actual balance:", actualBalance); + + // Use actual balance if it's less than requested (precision loss) + if (actualBalance < currentAmountIn) { + currentAmountIn = actualBalance; + console.log("Adjusted stETH amount to:", currentAmountIn); + } + } + + while (currentTokenIn != tokenOut) { + totalSteps++; + console.log("\n[MockLTM] Step", totalSteps); + console.log("Current token:", currentTokenIn); + console.log("Target token:", tokenOut); + console.log("Current amount:", currentAmountIn); + + // CRITICAL: Always get fresh execution plan with current amount + // This ensures the swap data matches the actual amount we have + (uint256 quotedOutput, ILSTSwapRouter.MultiStepExecutionPlan memory plan) = LSTswaprouter + .getCompleteMultiStepPlan(currentTokenIn, tokenOut, currentAmountIn, address(this)); + + require(plan.steps.length > 0, "No steps in plan"); + + // Use the first step + ILSTSwapRouter.SwapStep memory firstStep = plan.steps[0]; + + console.log("Next token:", firstStep.tokenOut); + console.log("Expected out:", firstStep.minAmountOut); + console.log("Target contract:", firstStep.target); + + // Execute the step + uint256 actualOut = _executeStep( + firstStep.tokenIn, + firstStep.tokenOut, + firstStep.amountIn, + firstStep.minAmountOut, + firstStep.data, + firstStep.target, + firstStep.value + ); + + console.log("Actual output:", actualOut); + + // Update for next iteration + currentTokenIn = firstStep.tokenOut; + currentAmountIn = actualOut; + + // Safety check to prevent infinite loops + require(totalSteps <= 5, "Too many steps"); + } + + console.log("[MockLTM] Step-by-step swap completed successfully"); + return currentAmountIn; + } + + /// @dev Execute a single swap step + function _executeStep( + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut, + bytes memory swapData, + address targetContract, + uint256 value + ) internal returns (uint256) { + console.log("\n[MockLTM] Executing step:"); + console.log("Token in:", tokenIn); + console.log("Token out:", tokenOut); + console.log("Amount in:", amountIn); + console.log("Min amount out:", minAmountOut); + console.log("Target:", targetContract); + + // Approve tokens if needed + if (tokenIn != ETH_ADDRESS && targetContract != address(0)) { + IERC20(tokenIn).safeApprove(targetContract, 0); + IERC20(tokenIn).safeApprove(targetContract, amountIn); + console.log("Approved tokens for swap"); + } + + // Get balance before swap + uint256 balanceBefore = _getBalance(tokenOut); + console.log("Balance before:", balanceBefore); + + // Execute the swap + (bool success, bytes memory result) = targetContract.call{value: value}(swapData); + + if (!success) { + if (result.length > 0) { + assembly { + let size := mload(result) + revert(add(32, result), size) + } + } else { + revert("Swap execution failed"); + } + } + + // Reset approval + if (tokenIn != ETH_ADDRESS && targetContract != address(0)) { + IERC20(tokenIn).safeApprove(targetContract, 0); + } + + // Calculate actual output + uint256 balanceAfter = _getBalance(tokenOut); + uint256 actualOutput = balanceAfter - balanceBefore; + + console.log("Balance after:", balanceAfter); + console.log("Actual output:", actualOutput); + + // Verify minimum output with tolerance for rebasing tokens + if (tokenOut == STETH_ADDRESS) { + // For stETH, allow 2 wei tolerance + require(actualOutput + 2 >= minAmountOut, "Step output too low"); + } else { + require(actualOutput >= minAmountOut, "Step output too low"); + } + + console.log("Step executed successfully"); + return actualOutput; + } + + // Legacy function for backward compatibility (if needed) + function swapAndStake( + address tokenIn, + address targetAsset, + uint256 amountIn, + uint256 nodeId, + uint256 minAmountOut + ) external payable nonReentrant { + require(amountIn > 0, "Zero amount"); + require(tokenIn != targetAsset, "Same token"); + + // Convert to new format + IERC20[] memory assetsToSwap = new IERC20[](1); + uint256[] memory amountsToSwap = new uint256[](1); + IERC20[] memory assetsToStake = new IERC20[](1); + + assetsToSwap[0] = IERC20(tokenIn); + amountsToSwap[0] = amountIn; + assetsToStake[0] = IERC20(targetAsset); + + // Handle ETH input + if (tokenIn == ETH_ADDRESS) { + require(msg.value == amountIn, "ETH value mismatch"); + } else { + require(msg.value == 0, "Unexpected ETH"); + IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + } + + _swapAndStakeAssetsToNode(nodeId, assetsToSwap, amountsToSwap, assetsToStake); + } + + // Helper to get token balance + function _getBalance(address token) internal view returns (uint256) { + if (token == ETH_ADDRESS) { + return address(this).balance; + } else { + return IERC20(token).balanceOf(address(this)); + } + } + + // Getter functions for testing + function getStakedBalance(uint256 nodeId, address token) external view returns (uint256) { + return mockStakedBalances[nodeId][token]; + } + + function getLSTSwapRouter() external view returns (address) { + return address(LSTswaprouter); + } + + // Receive ETH + receive() external payable {} +} diff --git a/test/mocks/MockRewardsCoordinator.sol b/test/mocks/MockRewardsCoordinator.sol new file mode 100644 index 00000000..d43ac2e5 --- /dev/null +++ b/test/mocks/MockRewardsCoordinator.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IRewardsCoordinatorTypes} from "@eigenlayer/contracts/interfaces/IRewardsCoordinator.sol"; + +contract MockRewardsCoordinator { + mapping(address => address) public claimerFor; + mapping(address => mapping(address => uint256)) public transferAmounts; + + function setClaimerFor(address earner, address claimer) external { + claimerFor[earner] = claimer; + } + + function setupClaimTransfer(address token, address to, uint256 amount) external { + transferAmounts[token][to] = amount; + } + + function processClaim(IRewardsCoordinatorTypes.RewardsMerkleClaim calldata claim, address recipient) external { + // Simulate transferring tokens for each token leaf + for (uint256 i = 0; i < claim.tokenLeaves.length; i++) { + address tokenAddr = address(claim.tokenLeaves[i].token); + uint256 transferAmount = transferAmounts[tokenAddr][recipient]; + + if (transferAmount > 0) { + IERC20(tokenAddr).transfer(recipient, transferAmount); + } + } + } +} diff --git a/test/mocks/MockWETH.sol b/test/mocks/MockWETH.sol new file mode 100644 index 00000000..858da087 --- /dev/null +++ b/test/mocks/MockWETH.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IWETH} from "../../src/interfaces/IWETH.sol"; + +contract MockWETH is ERC20, IWETH { + constructor() ERC20("Wrapped Ether", "WETH") {} + + function deposit() public payable override { + _mint(msg.sender, msg.value); + } + + function withdraw(uint256 amount) external override { + _burn(msg.sender, amount); + payable(msg.sender).transfer(amount); + } + + function balanceOf(address account) public view override(ERC20, IWETH) returns (uint256) { + return ERC20.balanceOf(account); + } + + // Add mint function for testing + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + receive() external payable { + deposit(); + } +} diff --git a/test/utils/NetworkAddresses.sol b/test/utils/NetworkAddresses.sol index fcfa6b6b..5fdd7787 100644 --- a/test/utils/NetworkAddresses.sol +++ b/test/utils/NetworkAddresses.sol @@ -5,6 +5,8 @@ library NetworkAddresses { struct Addresses { address strategyManager; address delegationManager; + address rewardsCoordinator; + address allocationManager; } function getAddresses(uint256 chainId) internal pure returns (Addresses memory) { @@ -13,14 +15,18 @@ library NetworkAddresses { return Addresses({ strategyManager: 0x858646372CC42E1A627fcE94aa7A7033e7CF075A, - delegationManager: 0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A + delegationManager: 0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A, + rewardsCoordinator: 0x7750d328b314EfFa365A0402CcfD489B80B0adda, + allocationManager: 0x948a420b8CC1d6BFd0B6087C2E7c344a2CD0bc39 }); } else if (chainId == 17000) { // Holesky return Addresses({ strategyManager: 0xdfB5f6CE42aAA7830E94ECFCcAd411beF4d4D5b6, - delegationManager: 0xA44151489861Fe9e3055d95adC98FbD462B948e7 + delegationManager: 0xA44151489861Fe9e3055d95adC98FbD462B948e7, + rewardsCoordinator: 0xAcc1fb458a1317E886dB376Fc8141540537E68fE, + allocationManager: 0x96B610046E919d8190B6Ec8629C79af091FA79d0 }); } else { revert("Unsupported network");