Skip to content
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

Conversation

danoctavian
Copy link
Contributor

No description provided.

@@ -17,6 +17,8 @@ interface ILSDStakingNodeEvents {
event DepositToEigenlayer(IERC20 indexed asset, IStrategy indexed strategy, uint256 amount, uint256 eigenShares);
Copy link
Contributor Author

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

/// @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(
Copy link
Contributor Author

@danoctavian danoctavian Jun 24, 2024

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)

//---------------------------------- WITHDRAWAL REQUESTS -----------------------------
//--------------------------------------------------------------------------------------

function requestWithdrawal(uint256 amount) external nonReentrant {
Copy link
Contributor Author

@danoctavian danoctavian Jun 24, 2024

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 {
Copy link
Contributor Author

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 {
Copy link
Contributor Author

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)

//--------------------------------------------------------------------------------------

function requestWithdrawal(uint256 amount) external nonReentrant {
if (amount <= 0) {
Copy link
Member

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.

revert AmountMustBeGreaterThanZero();
}

redeemableAsset.transferFrom(msg.sender, address(this), amount);
Copy link
Member

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().

redeemableAsset.transferFrom(msg.sender, address(this), amount);

uint256 currentRate = redemptionAssetsVault.redemptionRate();
uint256 tokenId = _tokenIdCounter++;
Copy link
Member

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.

_burn(tokenId);
redeemableAsset.burn(request.amount);

uint256 unitOfAccountAmount = calculateRedemptionAmount(request.amount, request.redemptionRateAtRequestTime);
Copy link
Member

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).

revert InsufficientBalance(currentBalance, unitOfAccountAmount);
}

redemptionAssetsVault.transferRedemptionAssets(receiver, netUnitOfAccountAmount);
Copy link
Member

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()));
Copy link
Member

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.

Copy link
Collaborator

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()));
Copy link
Member

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.

Copy link
Collaborator

@kahuna099 kahuna099 Jul 13, 2024

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);
Copy link
Member

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.

Copy link
Contributor Author

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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be payable?

Copy link
Contributor Author

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}("");
Copy link
Member

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).

@danoctavian danoctavian force-pushed the danoctavian/sc-1162/implement-generalized-withdrawal-queue-manager branch 2 times, most recently from df2bc0a to 6429a93 Compare June 30, 2024 22:28
Copy link
Collaborator

@xhad xhad left a 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.

Comment on lines 111 to 112
// TODO: fix for mainnet deployment
WITHDRAWAL_MANAGER: mainnetWallets.YNDev
Copy link
Collaborator

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?

Copy link
Contributor Author

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.

@@ -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";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused Import

@@ -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";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDelayedWithdrawRouter is unused

@@ -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";
Copy link
Collaborator

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;
}
Copy link
Collaborator

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?

Copy link
Contributor Author

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.

import {IStakingNode} from "src/interfaces/IStakingNode.sol";
import {IStakingNodesManager} from "src/interfaces/IStakingNodesManager.sol";

contract HoleskyStakingNodesManager is StakingNodesManager {
Copy link
Collaborator

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?

Copy link
Contributor Author

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.


import "forge-std/console.sol";
Copy link
Collaborator

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

Copy link
Contributor Author

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.

@@ -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";
Copy link
Collaborator

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

import {IRedeemableAsset} from "src/interfaces/IRedeemableAsset.sol";
import {IRedemptionAssetsVault} from "src/interfaces/IRedemptionAssetsVault.sol";

import "forge-std/console.sol";
Copy link
Collaborator

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;

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

space

@danoctavian
Copy link
Contributor Author

danoctavian commented Jul 3, 2024

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.

Great!

@danoctavian danoctavian closed this Jul 3, 2024
@danoctavian danoctavian reopened this Jul 3, 2024
// 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;
Copy link
Contributor Author

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.

@danoctavian danoctavian force-pushed the danoctavian/sc-1162/implement-generalized-withdrawal-queue-manager branch 2 times, most recently from f576c48 to 82a8d61 Compare July 5, 2024 13:15
)
public
onlyRole(WITHDRAWAL_MANAGER_ROLE)
{
Copy link
Contributor Author

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.

@@ -119,6 +127,8 @@ contract StakingNodesManager is

bool public validatorRegistrationPaused;

address public withdrawalAssetsVault;
Copy link
Contributor Author

@danoctavian danoctavian Jul 5, 2024

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.

@danoctavian danoctavian force-pushed the danoctavian/sc-1162/implement-generalized-withdrawal-queue-manager branch from 8351e67 to 3ef6bf8 Compare July 6, 2024 00:20
revert FeePercentageExceedsLimit();
}
withdrawalFee = feePercentage;
emit WithdrawalFeeUpdated(feePercentage);
Copy link
Collaborator

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) {
Copy link
Collaborator

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) {
Copy link
Collaborator

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())))) {

Copy link
Collaborator

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())) {

@xhad
Copy link
Collaborator

xhad commented Jul 11, 2024

Just noticed this, a minor suggestion. Perhaps we can resolve all of the compiler warnings running the test suite prior to audit.

Compiler run successful with warnings:
Warning (2519): This declaration shadows an existing declaration.
   --> test/scenarios/fork/ynETHWithdrawals.t.sol:378:9:
    |
378 |         uint256 _secondsToFinalization = ynETHWithdrawalQueueManager.MAX_SECONDS_TO_FINALIZATION() + 1;
    |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Note: The shadowed declaration is here:
   --> test/scenarios/fork/ynETHWithdrawals.t.sol:375:76:
    |
375 |     function testSetSecondsToFinalizationSecondsToFinalizationExceedsLimit(uint256 _secondsToFinalization) public {
    |                                                                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Warning (5667): Unused function parameter. Remove or comment out the variable name to silence this warning.
   --> test/utils/StakingNodeTestBase.sol:139:50:
    |
139 |     function setupForVerifyAndProcessWithdrawals(uint256 nodeId, string memory path) public {
    |                                                  ^^^^^^^^^^^^^^

Warning (2072): Unused local variable.
   --> test/utils/StakingNodeTestBase.sol:224:9:
    |
224 |         uint256 unverifiedStakedETH = stakingNodeInstance.getUnverifiedStakedETH();
    |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^

Warning (2072): Unused local variable.
   --> test/utils/StakingNodeTestBase.sol:225:9:
    |
225 |         int256 shares = eigenPodManager.podOwnerShares(address(stakingNodeInstance));
    |         ^^^^^^^^^^^^^

Warning (5667): Unused function parameter. Remove or comment out the variable name to silence this warning.
   --> test/scenarios/fork/ynETHWithdrawals.t.sol:375:76:
    |
375 |     function testSetSecondsToFinalizationSecondsToFinalizationExceedsLimit(uint256 _secondsToFinalization) public {

@danoctavian danoctavian marked this pull request as ready for review July 12, 2024 08:27
) external;

function queueWithdrawals(
uint256 sharesAmount
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question from @kahuna099

Why not withdraw
podOwnerShares[podOwner]

and have 0 params?

@danoctavian danoctavian merged commit 87ec4ad into release-candidate Oct 2, 2024
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants