diff --git a/contracts/LRTDepositPool.sol b/contracts/LRTDepositPool.sol index 48e242e..0229271 100644 --- a/contracts/LRTDepositPool.sol +++ b/contracts/LRTDepositPool.sol @@ -250,33 +250,7 @@ contract LRTDepositPool is ILRTDepositPool, LRTConfigRoleChecker, PausableUpgrad /// @dev only callable by LRT admin /// @param nodeDelegatorAddress NodeDelegator contract address function removeNodeDelegatorContractFromQueue(address nodeDelegatorAddress) public onlyLRTAdmin { - // 1. revert if node delegator contract has asset in eigenlayer asset strategies - - // 1.1 check if NDC has native ETH balance in eigen layer - if (INodeDelegator(nodeDelegatorAddress).getETHEigenPodBalance() > 0) { - revert NodeDelegatorHasETHInEigenlayer(); - } - - // 1.2 check if NDC has LST balance in eigen layer - (address[] memory assets, uint256[] memory assetBalances) = - INodeDelegator(nodeDelegatorAddress).getAssetBalances(); - - uint256 assetsLength = assets.length; - for (uint256 i; i < assetsLength;) { - if (assetBalances[i] > 0) { - revert NodeDelegatorHasAssetBalance(assets[i], assetBalances[i]); - } - - if (IERC20(assets[i]).balanceOf(nodeDelegatorAddress) > 0) { - revert NodeDelegatorHasAssetBalance(assets[i], IERC20(assets[i]).balanceOf(nodeDelegatorAddress)); - } - - unchecked { - ++i; - } - } - - // 2. remove node delegator contract from queue + // 1. check if node delegator contract is in queue uint256 length = nodeDelegatorQueue.length; uint256 ndcIndex; @@ -286,6 +260,7 @@ contract LRTDepositPool is ILRTDepositPool, LRTConfigRoleChecker, PausableUpgrad break; } + // 1.1 If node delegator contract is not found in queue, revert if (i == length - 1) { revert NodeDelegatorNotFound(); } @@ -295,9 +270,40 @@ contract LRTDepositPool is ILRTDepositPool, LRTConfigRoleChecker, PausableUpgrad } } - // 2.1 remove from isNodeDelegator mapping + // 2. revert if node delegator contract has any asset balances. + + // 2.1 check if NDC has native ETH balance in eigen layer and in itself. + if ( + INodeDelegator(nodeDelegatorAddress).getETHEigenPodBalance() > 0 + || address(nodeDelegatorAddress).balance > 0 + ) { + revert NodeDelegatorHasETH(); + } + + // 2.2 check if NDC has LST balance + address[] memory supportedAssets = lrtConfig.getSupportedAssetList(); + uint256 supportedAssetsLength = supportedAssets.length; + + uint256 assetBalance; + for (uint256 i; i < supportedAssetsLength; i++) { + if (supportedAssets[i] == LRTConstants.ETH_TOKEN) { + // ETH already checked above. + continue; + } + + assetBalance = IERC20(supportedAssets[i]).balanceOf(nodeDelegatorAddress) + + INodeDelegator(nodeDelegatorAddress).getAssetBalance(supportedAssets[i]); + + if (assetBalance > 0) { + revert NodeDelegatorHasAssetBalance(supportedAssets[i], assetBalance); + } + } + + // 3. remove node delegator contract from queue + + // 3.1 remove from isNodeDelegator mapping isNodeDelegator[nodeDelegatorAddress] = 0; - // 2.2 remove from nodeDelegatorQueue + // 3.2 remove from nodeDelegatorQueue nodeDelegatorQueue[ndcIndex] = nodeDelegatorQueue[length - 1]; nodeDelegatorQueue.pop(); diff --git a/contracts/NodeDelegator.sol b/contracts/NodeDelegator.sol index 56a1859..a6e8c0d 100644 --- a/contracts/NodeDelegator.sol +++ b/contracts/NodeDelegator.sol @@ -8,6 +8,7 @@ import { LRTConfigRoleChecker, ILRTConfig } from "./utils/LRTConfigRoleChecker.s import { INodeDelegator } from "./interfaces/INodeDelegator.sol"; import { IStrategy } from "./interfaces/IStrategy.sol"; import { IEigenStrategyManager } from "./interfaces/IEigenStrategyManager.sol"; +import { IEigenDelayedWithdrawalRouter } from "./interfaces/IEigenDelayedWithdrawalRouter.sol"; import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; @@ -161,9 +162,6 @@ contract NodeDelegator is INodeDelegator, LRTConfigRoleChecker, PausableUpgradea // feature is activated. Additionally, ensure verification of both staked but unverified and staked and verified // ETH native supply NDCs as provided to Eigenlayer. ethStaked = stakedButUnverifiedNativeETH; - if (address(eigenPod) != address(0)) { - ethStaked += address(eigenPod).balance; - } } /// @notice Stake ETH from NDC into EigenLayer. it calls the stake function in the EigenPodManager @@ -173,12 +171,14 @@ contract NodeDelegator is INodeDelegator, LRTConfigRoleChecker, PausableUpgradea /// @param depositDataRoot The deposit data root of the validator /// @dev Only LRT Operator should call this function /// @dev Exactly 32 ether is allowed, hence it is hardcoded + /// @dev offchain checks withdraw credentials authenticity function stake32Eth( bytes calldata pubkey, bytes calldata signature, bytes32 depositDataRoot ) external + whenNotPaused onlyLRTOperator { // Call the stake function in the EigenPodManager @@ -191,6 +191,31 @@ contract NodeDelegator is INodeDelegator, LRTConfigRoleChecker, PausableUpgradea emit ETHStaked(pubkey, 32 ether); } + /// @dev initiate a delayed withdraw of the ETH before the eigenpod is verified + /// which will be available to claim after withdrawalDelay blocks + function initiateWithdrawRewards() external onlyLRTOperator { + uint256 eigenPodBalance = address(eigenPod).balance; + uint256 ethValidatorMinBalanceThreshold = 16 ether; + if (eigenPodBalance > ethValidatorMinBalanceThreshold) { + revert InvalidRewardAmount(); + } + + eigenPod.withdrawBeforeRestaking(); + emit ETHRewardsWithdrawInitiated(eigenPodBalance); + } + + /// @dev claims back the withdrawal amount initiated to this nodeDelegator contract + /// once withdrawal amount is claimable + function claimRewards(uint256 maxNumberOfDelayedWithdrawalsToClaim) external onlyLRTOperator { + uint256 balanceBefore = address(this).balance; + address delayedRouterAddr = eigenPod.delayedWithdrawalRouter(); + IEigenDelayedWithdrawalRouter elDelayedRouter = IEigenDelayedWithdrawalRouter(delayedRouterAddr); + elDelayedRouter.claimDelayedWithdrawals(address(this), maxNumberOfDelayedWithdrawalsToClaim); + uint256 balanceAfter = address(this).balance; + + emit ETHRewardsClaimed(balanceAfter - balanceBefore); + } + /// @dev Triggers stopped state. Contract must not be paused. function pause() external onlyLRTManager { _pause(); @@ -212,8 +237,6 @@ contract NodeDelegator is INodeDelegator, LRTConfigRoleChecker, PausableUpgradea emit ETHDepositFromDepositPool(msg.value); } - /// @dev allow NodeDelegator to receive ETH rewards - receive() external payable { - emit ETHRewardsReceived(msg.value); - } + /// @dev allow NodeDelegator to receive ETH + receive() external payable { } } diff --git a/contracts/interfaces/IEigenDelayedWithdrawalRouter.sol b/contracts/interfaces/IEigenDelayedWithdrawalRouter.sol new file mode 100644 index 0000000..4abb93f --- /dev/null +++ b/contracts/interfaces/IEigenDelayedWithdrawalRouter.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.21; + +interface IEigenDelayedWithdrawalRouter { + function claimDelayedWithdrawals(address recipient, uint256 maxNumberOfDelayedWithdrawalsToClaim) external; +} diff --git a/contracts/interfaces/IEigenPod.sol b/contracts/interfaces/IEigenPod.sol index e4e0b04..ca617b8 100644 --- a/contracts/interfaces/IEigenPod.sol +++ b/contracts/interfaces/IEigenPod.sol @@ -46,6 +46,13 @@ library BeaconChainProofs { } interface IEigenPod { + /// @return delayedWithdrawalRouter address of eigenlayer delayedWithdrawalRouter, + /// which does book keeping of delayed withdrawls + function delayedWithdrawalRouter() external returns (address); + + /// @notice Called by the pod owner to withdraw the balance of the pod when `hasRestaked` is set to false + function withdrawBeforeRestaking() external; + /** * @notice This function verifies that the withdrawal credentials of the podOwner are pointed to * this contract. It also verifies the current (not effective) balance of the validator. It verifies the provided diff --git a/contracts/interfaces/ILRTConfig.sol b/contracts/interfaces/ILRTConfig.sol index dcc0210..97aecf6 100644 --- a/contracts/interfaces/ILRTConfig.sol +++ b/contracts/interfaces/ILRTConfig.sol @@ -10,6 +10,8 @@ interface ILRTConfig { error CallerNotLRTConfigManager(); error CallerNotLRTConfigOperator(); error CallerNotLRTConfigAllowedRole(string role); + error CannotUpdateStrategyAsItHasFundsNDCFunds(address ndc, uint256 amount); + error InvalidMaxRewardAmount(); // Events event SetToken(bytes32 key, address indexed tokenAddr); @@ -19,8 +21,7 @@ interface ILRTConfig { event AssetDepositLimitUpdate(address indexed asset, uint256 depositLimit); event AssetStrategyUpdate(address indexed asset, address indexed strategy); event SetRSETH(address indexed rsETH); - - error CannotUpdateStrategyAsItHasFundsNDCFunds(address ndc, uint256 amount); + event UpdateMaxRewardAmount(uint256 maxRewardAmount); // methods diff --git a/contracts/interfaces/ILRTDepositPool.sol b/contracts/interfaces/ILRTDepositPool.sol index ea37c3b..7cece56 100644 --- a/contracts/interfaces/ILRTDepositPool.sol +++ b/contracts/interfaces/ILRTDepositPool.sol @@ -12,7 +12,7 @@ interface ILRTDepositPool { error MinimumAmountToReceiveNotMet(); error NodeDelegatorNotFound(); error NodeDelegatorHasAssetBalance(address assetAddress, uint256 assetBalance); - error NodeDelegatorHasETHInEigenlayer(); + error NodeDelegatorHasETH(); //events event MaxNodeDelegatorLimitUpdated(uint256 maxNodeDelegatorLimit); diff --git a/contracts/interfaces/INodeDelegator.sol b/contracts/interfaces/INodeDelegator.sol index d519a1b..e548c89 100644 --- a/contracts/interfaces/INodeDelegator.sol +++ b/contracts/interfaces/INodeDelegator.sol @@ -9,12 +9,14 @@ interface INodeDelegator { event ETHDepositFromDepositPool(uint256 depositAmount); event EigenPodCreated(address indexed eigenPod, address indexed podOwner); event ETHStaked(bytes valPubKey, uint256 amount); - event ETHRewardsReceived(uint256 amount); + event ETHRewardsClaimed(uint256 amount); + event ETHRewardsWithdrawInitiated(uint256 amount); // errors error TokenTransferFailed(); error StrategyIsNotSetForAsset(); error InvalidETHSender(); + error InvalidRewardAmount(); // getter function stakedButUnverifiedNativeETH() external view returns (uint256); diff --git a/contracts/utils/LRTConstants.sol b/contracts/utils/LRTConstants.sol index fed18cd..0179b24 100644 --- a/contracts/utils/LRTConstants.sol +++ b/contracts/utils/LRTConstants.sol @@ -29,7 +29,7 @@ library LRTConstants { bytes32 public constant EIGEN_POD_MANAGER = keccak256("EIGEN_POD_MANAGER"); // native ETH as ERC20 for ease of implementation - address public constant ETH_TOKEN = address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); + address public constant ETH_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; // Operator Role bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); diff --git a/test/LRTDepositPoolTest.t.sol b/test/LRTDepositPoolTest.t.sol index 2e2da4b..8c22c88 100644 --- a/test/LRTDepositPoolTest.t.sol +++ b/test/LRTDepositPoolTest.t.sol @@ -23,6 +23,7 @@ contract LRTOracleMock { contract MockNodeDelegator { address[] public assets; uint256[] public assetBalances; + uint256 public mockAssetBalance; uint256 private _stakedButUnverifiedNativeETH; uint256 private _eigenPodBalance; @@ -30,10 +31,11 @@ contract MockNodeDelegator { constructor(address[] memory _assets, uint256[] memory _assetBalances) { assets = _assets; assetBalances = _assetBalances; + mockAssetBalance = 1e18; } - function getAssetBalance(address) external pure returns (uint256) { - return 1e18; + function getAssetBalance(address) external view returns (uint256) { + return mockAssetBalance; } function getAssetBalances() external view returns (address[] memory, uint256[] memory) { @@ -47,6 +49,7 @@ contract MockNodeDelegator { function removeAssetBalance() external { assetBalances[0] = 0; assetBalances[1] = 0; + mockAssetBalance = 0; } function transferBackToLRTDepositPool(address asset, uint256 amount) external { @@ -637,7 +640,7 @@ contract LRTDepositPoolAddNodeDelegatorContractToQueue is LRTDepositPoolTest { } } -contract LTRRemoveNodeDelegatorFromQueue is LRTDepositPoolTest { +contract LRTDepositPoolRemoveNodeDelegatorFromQueue is LRTDepositPoolTest { address public nodeDelegatorContractOne; address public nodeDelegatorContractTwo; address public nodeDelegatorContractThree; diff --git a/test/integration/LRTNativeEthStakingIntegrationTest.t.sol b/test/integration/LRTNativeEthStakingIntegrationTest.t.sol index a289104..70d11cf 100644 --- a/test/integration/LRTNativeEthStakingIntegrationTest.t.sol +++ b/test/integration/LRTNativeEthStakingIntegrationTest.t.sol @@ -59,8 +59,9 @@ contract LRTNativeEthStakingIntegrationTest is Test { // add oracle for ETH address oneETHOracle = address(new OneETHPriceOracle()); - vm.prank(manager); + vm.startPrank(manager); lrtOracle.updatePriceOracleFor(LRTConstants.ETH_TOKEN, oneETHOracle); + vm.stopPrank(); } function setUp() public { @@ -197,6 +198,53 @@ contract LRTNativeEthStakingIntegrationTest is Test { ); } + function test_withdrawRewards() external { + // create eigen pod + vm.prank(manager); + nodeDelegator1.createEigenPod(); + + address eigenPod = address(nodeDelegator1.eigenPod()); + // same eigenPod address should be created + assertEq(eigenPod, 0xf7483e448c1B94Ea557A53d99ebe7b4feE0c91df, "Wrong eigenPod address"); + + // stake 32 eth for validator1 + bytes memory pubkey = + hex"8ff0088bf2bc73a41c74d1b1c6c997e4963ceffde55a09fef27596016d919b74b45372e8aa69fda5aac38a0c1a38dfd5"; + bytes memory signature = hex"95e07ee28de0316ecdf9b528c222d81242898ee0095e284582bb453d331b7760" + hex"6d8dca23ab8980459ea8a9b9710e2f740fceb1a1c221a7fd75eb3ef4a6b68809" + hex"f3e76387f01f5d31718e6306375b20b29cb08d1374c7fb125d50c1b2f5a5cc0b"; + + bytes32 depositDataRoot = hex"6f30f44f0d8dada6ba5d8fd617c727020c01c697587d1a04ff6661be656198bc"; + + vm.deal(address(nodeDelegator1), 32 ether); + vm.startPrank(operator); + nodeDelegator1.stake32Eth(pubkey, signature, depositDataRoot); + + // eigenPod receives some rewards + uint256 rewardsAmount = 2.5 ether; + vm.deal(address(eigenPod), rewardsAmount); + + assertEq(address(eigenPod).balance, rewardsAmount); + + nodeDelegator1.initiateWithdrawRewards(); + + // rewards moves to delayedWithdrawalRouter + assertEq(address(eigenPod).balance, 0); + + console.log("block number before vm.roll: ", block.number); + // set block to 7 days after so that rewards can be claimed + vm.roll(block.number + 50_400); + console.log("block number after vm.roll: ", block.number); + + uint256 ndcBalanceBefore = address(nodeDelegator1).balance; + + nodeDelegator1.claimRewards(1); + + assertEq(address(nodeDelegator1).balance, ndcBalanceBefore + rewardsAmount); + + vm.stopPrank(); + } + function test_removeNDCs() external { // ------ STEP1: add NDCs ---------