-
Notifications
You must be signed in to change notification settings - Fork 8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Danoctavian/sc 1162/implement generalized withdrawal queue manager #118
Danoctavian/sc 1162/implement generalized withdrawal queue manager #118
Conversation
src/LSDStakingNode.sol
Outdated
@@ -17,6 +17,8 @@ interface ILSDStakingNodeEvents { | |||
event DepositToEigenlayer(IERC20 indexed asset, IStrategy indexed strategy, uint256 amount, uint256 eigenShares); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can skip this file for now we're probably not going to include this in this release
src/StakingNodesManager.sol
Outdated
/// @param nodeId The ID of the node processing the withdrawal. | ||
/// @param amountToReinvest Amount of ETH to reinvest into ynETH. | ||
/// @param amountToQueue Amount of ETH to send to the withdrawal queue. | ||
function processPrincipalWithdrawalsForNode( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this permissioned potentially can be improved.
Gives full control currently for allocating funds back into pool vs withdrawal. This is the most flexible from our PoV.
Some potential changes:
make it supply ETH to withdrawal vault based on deficit
make it allocate funds for another stakingNode to better support predictably reallocation between 2 stakingNodes. (eg. move 100 validators from StakingNode 1 to StakingNode 2)
src/WithdrawalQueueManager.sol
Outdated
//---------------------------------- WITHDRAWAL REQUESTS ----------------------------- | ||
//-------------------------------------------------------------------------------------- | ||
|
||
function requestWithdrawal(uint256 amount) external nonReentrant { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What this abstraction doesn't support is the ability to request redemption in a particular asset or set off assets (eg. i request to exit ynLSDe in sfrxETH and rETH, maybe in a certain ratio).
This is enough for ynETH -> ETH.
We can spend more time thinking about that or extended this contract with an additional function when that use case arises.
The trickiness there is availability of said assets, as you'd be able to do that, given that you don't do any rebalancing only if those assets are available in the protocol, and that requires extra tracking and ability to inspect the state of "withdrawable" array of assets at the given time of requestWithdrawal and "book" them. This is for a token like ynLSDe, doesn't really apply for this release.
import "forge-std/console.sol"; | ||
|
||
|
||
contract StakingNodeTestBase is ScenarioBaseTest, ProofParsingV1 { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
looking for opinions on how to structure these tests which run off of the existing deployment better.
This is for example one use case, since for proofs tests the most straighforward way is to test the existing real deployment and leave some validators "hanging" to test against them.
import "forge-std/console.sol"; | ||
|
||
|
||
contract StakingNodeTestBase is ScenarioBaseTest, ProofParsingV1 { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we have a more general problem with tests:
Tests that execute all tests on a fresh deployment vs tests that assume the deployment is the existing deployment.
The latter is the most accurate, since we roll the upgrade for ynETH on top of the existing deployment.
"Fresh" integration tests on a fresh deployment have the advantage that we can push the system to any configuration, while the mainnet one keeps moving underneat you (may break tests randomly, won't be able to reproduce certain system states without lots of prank-ing and etching which introduce error-prone additional mutations)
src/WithdrawalQueueManager.sol
Outdated
//-------------------------------------------------------------------------------------- | ||
|
||
function requestWithdrawal(uint256 amount) external nonReentrant { | ||
if (amount <= 0) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
amount
(uint256
) cant be smaller than 0
.
src/WithdrawalQueueManager.sol
Outdated
revert AmountMustBeGreaterThanZero(); | ||
} | ||
|
||
redeemableAsset.transferFrom(msg.sender, address(this), amount); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ignoring return value - use safeTransferFrom()
.
src/WithdrawalQueueManager.sol
Outdated
redeemableAsset.transferFrom(msg.sender, address(this), amount); | ||
|
||
uint256 currentRate = redemptionAssetsVault.redemptionRate(); | ||
uint256 tokenId = _tokenIdCounter++; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
++_tokenIdCounter
will be more gas efficient.
src/WithdrawalQueueManager.sol
Outdated
_burn(tokenId); | ||
redeemableAsset.burn(request.amount); | ||
|
||
uint256 unitOfAccountAmount = calculateRedemptionAmount(request.amount, request.redemptionRateAtRequestTime); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Redundant calculation, can be done once (see L166).
src/WithdrawalQueueManager.sol
Outdated
revert InsufficientBalance(currentBalance, unitOfAccountAmount); | ||
} | ||
|
||
redemptionAssetsVault.transferRedemptionAssets(receiver, netUnitOfAccountAmount); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At this point, we check two times that the balance is sufficient, is this necessary?
uint256 sharesAmount | ||
) external onlyOperator returns (bytes32[] memory fullWithdrawalRoots) { | ||
|
||
IDelegationManager delegationManager = IDelegationManager(address(stakingNodesManager.delegationManager())); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Redundant casts. can simply call stakingNodesManager.delegationManager()
. Also redundant state variable though, used only once.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yep, lets remove the above line and replace line 367 with:
fullWithdrawalRoots = stakingNodesManager.delegationManager().queueWithdrawals(params);
} | ||
} | ||
|
||
IDelegationManager delegationManager = IDelegationManager(address(stakingNodesManager.delegationManager())); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Redundant casts. can simply call stakingNodesManager.delegationManager(). Also redundant state variable though, used only once.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
indeed, lets simplify by removing the above line (412) and replacing line 422 with:
stakingNodesManager.delegationManager().completeQueuedWithdrawals(
withdrawals,
tokens,
middlewareTimesIndexes,
receiveAsTokens
);
// 2. For beaconChainETHStrategy, the DelegationManager calls _withdrawSharesAsTokens interacts with the EigenPodManager.withdrawSharesAsTokens | ||
// 3. Finally, the EigenPodManager calls withdrawRestakedBeaconChainETH on the EigenPod of this StakingNode to finalize the withdrawal. | ||
// 4. the EigenPod decrements withdrawableRestakedExecutionLayerGwei and send the ETH to address(this) | ||
delegationManager.completeQueuedWithdrawals(withdrawals, tokens, middlewareTimesIndexes, receiveAsTokens); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Noob question - will it always revert? because receive
function limits sender to delayedWithdrawalRouter
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah i needed to fix it up!
unverifiedStakedETH += amount; | ||
} | ||
|
||
function deallocateStakedETH(uint256 amount) external payable onlyStakingNodesManager { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this be payable
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
correct, no
withdrawnValidatorPrincipal -= amount; | ||
|
||
|
||
(bool success, ) = address(stakingNodesManager).call{value: amount}(""); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can use msg.sender
instead of address(stakingNodesManager)
.
df2bc0a
to
6429a93
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a few tests that are failing, but otherwise this seems like a very solid base heading into the audit. My suggestion is that we should lean more heavily this time and collaboratively on the offchain and admin processes to ensure we have a very reliable secure system in place. Glad to hear you're up for taking the lead, but I'm def here to help and want to assist where you think is best.
script/Actors.sol
Outdated
// TODO: fix for mainnet deployment | ||
WITHDRAWAL_MANAGER: mainnetWallets.YNDev |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Has this TODO been resolved?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, we need a Withdrawal Manager Multisig created and decide the treshold and composition of that.
src/interfaces/IynETH.sol
Outdated
@@ -3,8 +3,9 @@ pragma solidity ^0.8.24; | |||
|
|||
import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; | |||
import {IERC20Permit} from "lib/openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Permit.sol"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused Import
src/StakingNode.sol
Outdated
@@ -4,6 +4,8 @@ pragma solidity ^0.8.24; | |||
import {ReentrancyGuardUpgradeable} from "lib/openzeppelin-contracts-upgradeable/contracts/utils/ReentrancyGuardUpgradeable.sol"; | |||
import {BeaconChainProofs} from "lib/eigenlayer-contracts/src/contracts/libraries/BeaconChainProofs.sol"; | |||
import {IDelegationManager } from "lib/eigenlayer-contracts/src/contracts/interfaces/IDelegationManager.sol"; | |||
import {IEigenPodManager } from "lib/eigenlayer-contracts/src/contracts/interfaces/IEigenPodManager.sol"; | |||
import {IDelayedWithdrawalRouter } from "lib/eigenlayer-contracts/src/contracts/interfaces/IDelayedWithdrawalRouter.sol"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IDelayedWithdrawRouter is unused
src/StakingNode.sol
Outdated
@@ -12,7 +14,10 @@ import {IEigenPodManager} from "lib/eigenlayer-contracts/src/contracts/interface | |||
import {IStakingNodesManager} from "src/interfaces/IStakingNodesManager.sol"; | |||
import {IStakingNode} from "src/interfaces/IStakingNode.sol"; | |||
import {RewardsType} from "src/interfaces/IRewardsDistributor.sol"; | |||
import {IERC20} from "lib/openzeppelin-contracts/contracts/interfaces/IERC20.sol"; | |||
import {ONE_GWEI, DEFAULT_VALIDATOR_STAKE} from "src/Constants.sol"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ONE_GWEI is unused
@@ -111,6 +135,10 @@ contract StakingNode is IStakingNode, StakingNodeEvents, ReentrancyGuardUpgradea | |||
nodeId = init.nodeId; | |||
} | |||
|
|||
function initializeV2(uint256 initialUnverifiedStakedETH) external onlyStakingNodesManager reinitializer(2) { | |||
unverifiedStakedETH = initialUnverifiedStakedETH; | |||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Q: How is the unverified staked ETH calculated?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The unverifiedStakedETH should ideally be 0 on upgrade time.
node.initializeV2(0); |
We MUST ensure all validators are verified at time of upgrade to perform it as shown above.
src/HoleskyStakingNodesManager.sol
Outdated
import {IStakingNode} from "src/interfaces/IStakingNode.sol"; | ||
import {IStakingNodesManager} from "src/interfaces/IStakingNodesManager.sol"; | ||
|
||
contract HoleskyStakingNodesManager is StakingNodesManager { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm curious why we have HoleskyStakingNodesManager. Is this because of Eigenlayer verison difference between the networks?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Holesky has StakingNode with id with a fixed number of dangling validators by convention that remain unverified.
That number should be what the variable is initialized with and as with Mainnet, no other validators should be staked once that value is set to correspond to the beacon chain reality and on-chain reality.
For example, if we agree to have 20 unverified validators, we will initialize the unverifiedStakedETH variable with 20 * 32 ether.
src/StakingNode.sol
Outdated
|
||
import "forge-std/console.sol"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good to remove this prior to the audit
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
will clean all these up and turn this into a a full PR not just draft.
src/StakingNodesManager.sol
Outdated
@@ -17,6 +17,9 @@ import {IStakingNode} from "src/interfaces/IStakingNode.sol"; | |||
import {IStakingNodesManager} from "src/interfaces/IStakingNodesManager.sol"; | |||
import {IynETH} from "src/interfaces/IynETH.sol"; | |||
|
|||
import "forge-std/console.sol"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good to remove prior to audit
src/WithdrawalQueueManager.sol
Outdated
import {IRedeemableAsset} from "src/interfaces/IRedeemableAsset.sol"; | ||
import {IRedemptionAssetsVault} from "src/interfaces/IRedemptionAssetsVault.sol"; | ||
|
||
import "forge-std/console.sol"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note to remove before audit
address withdrawalQueueAdmin; | ||
uint256 withdrawalFee; | ||
address feeReceiver; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
space
Great! |
// Decrease unverifiedStakedETH by DEFAULT_VALIDATOR_STAKE regardless if the current balance of the validator | ||
// Since unverifiedStakedETH was increased by DEFAULT_VALIDATOR_STAKE when the validator was staked | ||
// within the Beacon Chain | ||
unverifiedStakedETH -= DEFAULT_VALIDATOR_STAKE; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
there's a bug here.
if you decrease this by 32ETH, but verifyWithdrawalCredentials yields for example 0 shares, because validator was withdrawn, balance drops potentially without justification. assets may still be in the system.
f576c48
to
82a8d61
Compare
src/StakingNodesManager.sol
Outdated
) | ||
public | ||
onlyRole(WITHDRAWAL_MANAGER_ROLE) | ||
{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
there's a bug in totalAssets() as a followup to withdrawals.
What goes into witdrawal is not counted into TVL.
This is incorrect, it must be counted until the ynETH is burned.
src/StakingNodesManager.sol
Outdated
@@ -119,6 +127,8 @@ contract StakingNodesManager is | |||
|
|||
bool public validatorRegistrationPaused; | |||
|
|||
address public withdrawalAssetsVault; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please pay great attention to memory layouts and that being respected when reviewed given that we use Proxy contracts for all.
8351e67
to
3ef6bf8
Compare
revert FeePercentageExceedsLimit(); | ||
} | ||
withdrawalFee = feePercentage; | ||
emit WithdrawalFeeUpdated(feePercentage); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
just like the above and below functions include the old and new value in the event args.
* @param index The index of the withdrawal request. | ||
* @return True if the request is finalized, false otherwise. | ||
*/ | ||
function withdrawalRequestIsFinalized(uint256 index) public view returns (bool) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this function will return true for nonexistent withdrawal requests.
If you pass in a nonexistent index, then WithdrawalRequest memory request = withdrawalRequests[index];
will return an empty WithdrawalRequest
. Then isFinalized
will check return block.timestamp >= request.creationTimestamp + secondsToFinalization;
, which will become some-timestamp >= 0 + secondsToFianlization
. And since secondsToFinalization
is very very likely to be less than block.timestamp
, the isFinalized
check will return true, even though this withdrawal request does not exist.
fix: if (request.creationTimestamp == 0) revert WithdrawalRequestDoesNotExist(index);
* @param tokenId The token ID of the withdrawal request. | ||
* @return request The withdrawal request details. | ||
*/ | ||
function withdrawalRequest(uint256 tokenId) public view returns (WithdrawalRequest memory request) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
look at the above comment, but it might make sense to just revert here if no withdrawal request with this id exists, instead of returning an empty WithdrawalRequest
src/ynETH.sol
Outdated
// ETH can be returned either by the stakinNodesManager or by the redemptionAssetsVault | ||
if (!(msg.sender == address(stakingNodesManager) | ||
|| msg.sender == (address(stakingNodesManager.redemptionAssetsVault())))) { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
there is nothing happening here, you are missing a revert in the if-body. Right now anybody can call processWithdrawnETH
(not that that is such a big problem, but it shouldn't be allowed)
Also, just a personal preference but i think the if-condition is more readable if you write it like:
if (msg.sender != address(stakingNodesManager &&
msg.sender != address(stakingNodesManager.redemptionAssetsVault())) {
Just noticed this, a minor suggestion. Perhaps we can resolve all of the compiler warnings running the test suite prior to audit.
|
) external; | ||
|
||
function queueWithdrawals( | ||
uint256 sharesAmount |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No description provided.