From 702710689a8e55534e1943edf82c90e807c65efb Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Mon, 25 Sep 2023 21:10:47 +0400 Subject: [PATCH 1/2] feat: claim with nft --- src/CSAccounting.sol | 44 +++++++ src/interfaces/IWithdrawalQueue.sol | 11 ++ test/CSAccounting.t.sol | 131 ++++++++++++++++++++- test/helpers/Fixtures.sol | 8 +- test/helpers/Permit.sol | 6 +- test/helpers/mocks/LidoLocatorMock.sol | 8 +- test/helpers/mocks/WithdrawalQueueMock.sol | 55 +++++++++ 7 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 src/interfaces/IWithdrawalQueue.sol create mode 100644 test/helpers/mocks/WithdrawalQueueMock.sol diff --git a/src/CSAccounting.sol b/src/CSAccounting.sol index 0b0db45e..52fd3639 100644 --- a/src/CSAccounting.sol +++ b/src/CSAccounting.sol @@ -10,6 +10,7 @@ import { ICSModule } from "./interfaces/ICSModule.sol"; import { ILido } from "./interfaces/ILido.sol"; import { IWstETH } from "./interfaces/IWstETH.sol"; import { ICSFeeDistributor } from "./interfaces/ICSFeeDistributor.sol"; +import { IWithdrawalQueue } from "./interfaces/IWithdrawalQueue.sol"; contract CSAccountingBase { event ETHBondDeposited( @@ -42,6 +43,11 @@ contract CSAccountingBase { address to, uint256 amount ); + event ETHRewardsRequested( + uint256 indexed nodeOperatorId, + address to, + uint256 amount + ); } contract CSAccounting is CSAccountingBase, AccessControlEnumerable { @@ -542,6 +548,40 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { emit WstETHRewardsClaimed(nodeOperatorId, rewardAddress, wstETHAmount); } + /// @notice Request full reward (fee + bond) in Withdrawal NFT (unstETH) for the given node operator available for this moment. + /// @dev reverts if amount isn't between MIN_STETH_WITHDRAWAL_AMOUNT and MAX_STETH_WITHDRAWAL_AMOUNT + /// @param rewardsProof merkle proof of the rewards. + /// @param nodeOperatorId id of the node operator to request rewards for. + /// @param cumulativeFeeShares cummulative fee shares for the node operator. + /// @return requestIds an array of the created withdrawal request ids + function requestRewardsETH( + bytes32[] memory rewardsProof, + uint256 nodeOperatorId, + uint256 cumulativeFeeShares, + uint256 ETHAmount + ) external returns (uint256[] memory requestIds) { + address rewardAddress = _getNodeOperatorRewardAddress(nodeOperatorId); + _isSenderEligableToClaim(rewardAddress); + uint256 claimableShares = _pullFeeRewards( + rewardsProof, + nodeOperatorId, + cumulativeFeeShares + ); + uint256 toClaim = ETHAmount < _ethByShares(claimableShares) + ? _sharesByEth(ETHAmount) + : claimableShares; + uint256[] memory amounts = new uint256[](1); + amounts[0] = _lido().getPooledEthByShares(toClaim); + requestIds = _withdrawalQueue().requestWithdrawals( + amounts, + rewardAddress + ); + bondShares[nodeOperatorId] -= toClaim; + totalBondShares -= toClaim; + emit ETHRewardsRequested(nodeOperatorId, rewardAddress, amounts[0]); + return requestIds; + } + /// @notice Penalize bond by burning shares /// @param nodeOperatorId id of the node operator to penalize bond for. /// @param shares amount shares to burn. @@ -569,6 +609,10 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { return ICSFeeDistributor(FEE_DISTRIBUTOR); } + function _withdrawalQueue() internal view returns (IWithdrawalQueue) { + return IWithdrawalQueue(LIDO_LOCATOR.withdrawalQueue()); + } + function _getNodeOperatorActiveKeys( uint256 nodeOperatorId ) internal view returns (uint256) { diff --git a/src/interfaces/IWithdrawalQueue.sol b/src/interfaces/IWithdrawalQueue.sol new file mode 100644 index 00000000..0c272ccd --- /dev/null +++ b/src/interfaces/IWithdrawalQueue.sol @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.21; + +interface IWithdrawalQueue { + function requestWithdrawals( + uint256[] calldata _amounts, + address _owner + ) external returns (uint256[] memory requestIds); +} diff --git a/test/CSAccounting.t.sol b/test/CSAccounting.t.sol index 3792010d..cadcd811 100644 --- a/test/CSAccounting.t.sol +++ b/test/CSAccounting.t.sol @@ -13,12 +13,22 @@ import { WstETHMock } from "./helpers/mocks/WstETHMock.sol"; import { LidoLocatorMock } from "./helpers/mocks/LidoLocatorMock.sol"; import { CommunityStakingModuleMock } from "./helpers/mocks/CommunityStakingModuleMock.sol"; import { CommunityStakingFeeDistributorMock } from "./helpers/mocks/CommunityStakingFeeDistributorMock.sol"; +import { WithdrawalQueueMockBase, WithdrawalQueueMock } from "./helpers/mocks/WithdrawalQueueMock.sol"; + import { Fixtures } from "./helpers/Fixtures.sol"; -contract CSAccountingTest is Test, Fixtures, PermitTokenBase, CSAccountingBase { +contract CSAccountingTest is + Test, + Fixtures, + PermitTokenBase, + CSAccountingBase, + WithdrawalQueueMockBase +{ LidoLocatorMock internal locator; WstETHMock internal wstETH; LidoMock internal stETH; + WithdrawalQueueMock internal wq; + Stub internal burner; CSAccounting public accounting; @@ -911,6 +921,125 @@ contract CSAccountingTest is Test, Fixtures, PermitTokenBase, CSAccountingBase { accounting.claimRewardsWstETH(new bytes32[](1), 0, 1, 1 ether); } + function test_requestRewardsETH() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(address(feeDistributor), 0.1 ether); + vm.prank(address(feeDistributor)); + uint256 sharesAsFee = stETH.submit{ value: 0.1 ether }(address(0)); + + vm.deal(user, 32 ether); + vm.startPrank(user); + stETH.submit{ value: 32 ether }({ _referal: address(0) }); + accounting.depositStETH(user, 0, 32 ether); + + uint256 requestedAsUnstETH = stETH.getPooledEthByShares(sharesAsFee); + uint256 requestedAsUnstETHAsShares = stETH.getSharesByPooledEth( + requestedAsUnstETH + ); + + vm.expectEmit( + true, + true, + true, + true, + address(locator.withdrawalQueue()) + ); + emit WithdrawalRequested( + 1, + address(accounting), + user, + requestedAsUnstETH, + requestedAsUnstETHAsShares + ); + vm.expectEmit(true, true, true, true, address(accounting)); + emit ETHRewardsRequested( + 0, + user, + stETH.getPooledEthByShares(sharesAsFee) + ); + + uint256 bondSharesBefore = accounting.getBondShares(0); + uint256[] memory requestIds = accounting.requestRewardsETH( + new bytes32[](1), + 0, + sharesAsFee, + UINT256_MAX + ); + uint256 bondSharesAfter = accounting.getBondShares(0); + + assertEq(requestIds.length, 1, "request ids length should be 1"); + assertEq( + bondSharesAfter, + bondSharesBefore, + "bond shares should not change after request" + ); + assertEq( + stETH.sharesOf(address(locator.withdrawalQueue())), + requestedAsUnstETHAsShares, + "shares of withdrawal queue should be equal to requested shares" + ); + assertEq(stETH.sharesOf(address(user)), 0, "user shares should be 0"); + } + + function test_requestRewardsETH_WithDesirableValue() public { + _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); + vm.deal(address(feeDistributor), 0.1 ether); + vm.prank(address(feeDistributor)); + uint256 sharesAsFee = stETH.submit{ value: 0.1 ether }(address(0)); + + vm.deal(user, 32 ether); + vm.startPrank(user); + stETH.submit{ value: 32 ether }({ _referal: address(0) }); + accounting.depositStETH(user, 0, 32 ether); + + uint256 requestedAsShares = stETH.getSharesByPooledEth(0.05 ether); + uint256 requestedAsUnstETH = stETH.getPooledEthByShares( + requestedAsShares + ); + uint256 requestedAsUnstETHAsShares = stETH.getSharesByPooledEth( + requestedAsUnstETH + ); + + vm.expectEmit( + true, + true, + true, + true, + address(locator.withdrawalQueue()) + ); + emit WithdrawalRequested( + 1, + address(accounting), + user, + requestedAsUnstETH, + requestedAsUnstETHAsShares + ); + vm.expectEmit(true, true, true, true, address(accounting)); + emit ETHRewardsRequested(0, user, requestedAsUnstETH); + + uint256 bondSharesBefore = accounting.getBondShares(0); + uint256[] memory requestIds = accounting.requestRewardsETH( + new bytes32[](1), + 0, + sharesAsFee, + 0.05 ether + ); + uint256 bondSharesAfter = accounting.getBondShares(0); + + assertEq(requestIds.length, 1, "request ids length should be 1"); + assertEq( + bondSharesAfter, + (bondSharesBefore + sharesAsFee) - requestedAsShares, + "bond shares after should be equal to before and fee minus requested shares" + ); + assertEq( + stETH.sharesOf(address(locator.withdrawalQueue())), + requestedAsUnstETHAsShares, + "shares of withdrawal queue should be equal to requested shares" + ); + assertEq(stETH.sharesOf(address(user)), 0, "user shares should be 0"); + } + function test_penalize_LessThanDeposit() public { _createNodeOperator({ ongoingVals: 16, withdrawnVals: 0 }); vm.deal(user, 32 ether); diff --git a/test/helpers/Fixtures.sol b/test/helpers/Fixtures.sol index 02a3968e..b9c83a03 100644 --- a/test/helpers/Fixtures.sol +++ b/test/helpers/Fixtures.sol @@ -6,6 +6,7 @@ import { StdCheats } from "forge-std/StdCheats.sol"; import { LidoMock } from "./mocks/LidoMock.sol"; import { WstETHMock } from "./mocks/WstETHMock.sol"; import { LidoLocatorMock } from "./mocks/LidoLocatorMock.sol"; +import { WithdrawalQueueMock } from "./mocks/WithdrawalQueueMock.sol"; import { Stub } from "./mocks/Stub.sol"; contract Fixtures is StdCheats { @@ -24,7 +25,12 @@ contract Fixtures is StdCheats { _sharesAmount: 7059313073779349112833523 }); burner = new Stub(); - locator = new LidoLocatorMock(address(stETH), address(burner)); + WithdrawalQueueMock wq = new WithdrawalQueueMock(address(stETH)); + locator = new LidoLocatorMock( + address(stETH), + address(burner), + address(wq) + ); wstETH = new WstETHMock(address(stETH)); } } diff --git a/test/helpers/Permit.sol b/test/helpers/Permit.sol index c022b970..0c3dbd3a 100644 --- a/test/helpers/Permit.sol +++ b/test/helpers/Permit.sol @@ -1,5 +1,7 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.21; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; diff --git a/test/helpers/mocks/LidoLocatorMock.sol b/test/helpers/mocks/LidoLocatorMock.sol index 2c9e05d7..66fe8afd 100644 --- a/test/helpers/mocks/LidoLocatorMock.sol +++ b/test/helpers/mocks/LidoLocatorMock.sol @@ -6,10 +6,12 @@ pragma solidity 0.8.21; contract LidoLocatorMock { address public l; address public b; + address public wq; - constructor(address _lido, address _burner) { + constructor(address _lido, address _burner, address _wq) { l = _lido; b = _burner; + wq = _wq; } function lido() external view returns (address) { @@ -19,4 +21,8 @@ contract LidoLocatorMock { function burner() external view returns (address) { return b; } + + function withdrawalQueue() external view returns (address) { + return wq; + } } diff --git a/test/helpers/mocks/WithdrawalQueueMock.sol b/test/helpers/mocks/WithdrawalQueueMock.sol new file mode 100644 index 00000000..01b11a8a --- /dev/null +++ b/test/helpers/mocks/WithdrawalQueueMock.sol @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.21; + +import { IStETH } from "../../../src/interfaces/IStETH.sol"; + +contract WithdrawalQueueMockBase { + /// @dev Contains both stETH token amount and its corresponding shares amount + event WithdrawalRequested( + uint256 indexed requestId, + address indexed requestor, + address indexed owner, + uint256 amountOfStETH, + uint256 amountOfShares + ); +} + +contract WithdrawalQueueMock is WithdrawalQueueMockBase { + IStETH public stETH; + + uint256 public constant MIN_STETH_WITHDRAWAL_AMOUNT = 100; + + uint256 public constant MAX_STETH_WITHDRAWAL_AMOUNT = 1000 ether; + + constructor(address _stETH) { + stETH = IStETH(_stETH); + } + + function requestWithdrawals( + uint256[] calldata _amounts, + address _owner + ) external returns (uint256[] memory requestIds) { + requestIds = new uint256[](_amounts.length); + for (uint256 i = 0; i < _amounts.length; ++i) { + require( + _amounts[i] <= MAX_STETH_WITHDRAWAL_AMOUNT, + "amount is greater than MAX_STETH_WITHDRAWAL_AMOUNT" + ); + require( + _amounts[i] >= MIN_STETH_WITHDRAWAL_AMOUNT, + "amount is less than MIN_STETH_WITHDRAWAL_AMOUNT" + ); + stETH.transferFrom(msg.sender, address(this), _amounts[i]); + emit WithdrawalRequested( + i + 1, + msg.sender, + _owner, + _amounts[i], + stETH.getSharesByPooledEth(_amounts[i]) + ); + requestIds[i] = i + 1; + } + } +} From c9c5b5691c8717affc81a1b24ae6b4f5272ca521 Mon Sep 17 00:00:00 2001 From: vgorkavenko Date: Fri, 27 Oct 2023 16:07:25 +0400 Subject: [PATCH 2/2] fix: after merge --- src/CSAccounting.sol | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/CSAccounting.sol b/src/CSAccounting.sol index d0eb92a1..6a0ced02 100644 --- a/src/CSAccounting.sol +++ b/src/CSAccounting.sol @@ -566,8 +566,11 @@ contract CSAccounting is CSAccountingBase, AccessControlEnumerable { uint256 cumulativeFeeShares, uint256 ETHAmount ) external returns (uint256[] memory requestIds) { - address rewardAddress = _getNodeOperatorRewardAddress(nodeOperatorId); - _isSenderEligableToClaim(rewardAddress); + ( + address managerAddress, + address rewardAddress + ) = _getNodeOperatorAddresses(nodeOperatorId); + _isSenderEligibleToClaim(managerAddress); uint256 claimableShares = _pullFeeRewards( rewardsProof, nodeOperatorId,