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

feat: ynEIGEN withdrawals #164

Merged
merged 115 commits into from
Oct 29, 2024
Merged

feat: ynEIGEN withdrawals #164

merged 115 commits into from
Oct 29, 2024

Conversation

johnnyonline
Copy link
Member

No description provided.

@johnnyonline johnnyonline changed the base branch from main to danoctavian/sc-1162/implement-generalized-withdrawal-queue-manager September 9, 2024 16:36
@@ -291,6 +291,9 @@ contract WithdrawalQueueManager is IWithdrawalQueueManager, ERC721EnumerableUpgr

uint256 unitOfAccountAmount = calculateRedemptionAmount(request.amount, redemptionRate);

// zero out the last decimal place
Copy link
Contributor

Choose a reason for hiding this comment

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

what is the reason to zero out the last decimal place?

Copy link
Member Author

Choose a reason for hiding this comment

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

it fails because of precision errors

Copy link
Member Author

Choose a reason for hiding this comment

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

removed at 7b35647.

@@ -291,6 +291,9 @@ contract WithdrawalQueueManager is IWithdrawalQueueManager, ERC721EnumerableUpgr

uint256 unitOfAccountAmount = calculateRedemptionAmount(request.amount, redemptionRate);

// zero out the last decimal place
unitOfAccountAmount = unitOfAccountAmount / 10 * 10 - 10; // @todo - is this safe? (maybe easier to just do `- 20`?)
Copy link
Contributor

Choose a reason for hiding this comment

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

> 18456789n / 10n * 10n - 10n
18456770n

From the first comment i'd asume you want the result here to be

18456789n -> 18456780n

if we can clarify first what's the objective here we can fix it.

Copy link
Member Author

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

What if we just push a slight excess of tokens to the vault all the time, instead of tolerating wei differences?

Copy link
Member Author

Choose a reason for hiding this comment

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

yep probably better - 7b35647 (left my rpcurl there oops)

@@ -382,7 +385,7 @@ contract WithdrawalQueueManager is IWithdrawalQueueManager, ERC721EnumerableUpgr
uint256 amount,
uint256 redemptionRate
) public view returns (uint256) {
return amount * redemptionRate / (10 ** redeemableAsset.decimals());
return amount * redemptionRate / (10 ** redeemableAsset.decimals()); // @todo
Copy link
Contributor

Choose a reason for hiding this comment

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

what is the @todo here?

Useful to add a clarifying comment

Copy link
Member Author

Choose a reason for hiding this comment

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

right. im in not process of cleaning up. give me another day here pls

Copy link
Contributor

Choose a reason for hiding this comment

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

no worries, i'll leave the comments anyway, with the understanding this is an early draft

Copy link
Member Author

Choose a reason for hiding this comment

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

removed at 7b35647.

* @param amount The amount of assets to transfer.
* @dev Requires the caller to be the redeemer and the contract to not be paused.
*/
function transferRedemptionAssets(address to, uint256 amount, bytes calldata /* data */) public onlyRedeemer whenNotPaused nonReentrant {
Copy link
Contributor

Choose a reason for hiding this comment

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

Another approach here is to send proportionate to the content of the vault. That way it's on the off-chain to decide the ratio and the on-chain simply uses what it has available without more complex logic.

uint256 totalRedemptionAssets = availableRedemptionAssets();
uint256 assetRatio = amount * YN_UNIT / totalRedemptionAssets;

for (asset of assets) {
    uint256 amountToTransfer = balances[address(asset)] * assetRatio / YN_UNIT;
    // Do transfer
}

We should be mindful about has here and repeated computation, we can duplicate code for less gas usage (Eg. availableRedemptionAssets logic)

Copy link
Member Author

Choose a reason for hiding this comment

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

suggested another approach on Discord. will update this thread once we have a decision.

@@ -306,7 +305,15 @@ contract EigenStrategyManager is
asset,
strategies[asset].userUnderlyingView((address(node)))
);
stakedBalances[j] += strategyBalance;

Copy link
Contributor

Choose a reason for hiding this comment

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

This function is the most gas consumptive function in the current implementation that poses problems because of the existing unbounded loops.

Let's discuss if we can include a solution for this in this current upgrade since we're updating it regardless.

This is a general problem that applies to all the multi-asset vaults that stake the assets within protocols but it's particularly pathological in Eigenlayer because of the StakingNodes pattern that is necessary to communicate to validators.

Copy link
Contributor

Choose a reason for hiding this comment

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

Idea here:

We could make the result of that inner loop cached in a storage variable per asset.

This variable will be updated all the time it when you allocate to StakinNodes or withdraw from StakingNodes.

The update function can also be permisionless - which would cover the slashing case.

This reduces it to a O(N) loop over the N assets which is reasonable for any multi-asset vault.

Copy link
Contributor

Choose a reason for hiding this comment

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

@johnnyonline this is not addressed - we'll need to cover this one

Copy link
Member Author

Choose a reason for hiding this comment

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

i'm not sure i understand what you mean here. but trying to optimize it a bit -

using function testTotalAssetsGas() public view {yneigen.totalAssets();}) as a test and forge snapshot.

testTotalAssetsGas() gas:

  • 2ae7917 - 818370
  • be925af - 741084
  • 471afc7 (using Solady PoC) - 740688 -- i can continue implementing Solady across the contracts, lmk

emit DeallocatedTokens(_amount, _token);
}

function _wrapIfNeeded(uint256 _amount, IERC20 _token) internal returns (uint256, IERC20) {
Copy link
Contributor

@danoctavian danoctavian Sep 18, 2024

Choose a reason for hiding this comment

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

we should have this asset-specific logic that handles rebasing tokens not contained here

currently all those asset specific things are in EigenStrategyManager

We should consider refactoring things so it's all contained in 1 contract

Copy link
Member Author

Choose a reason for hiding this comment

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

@danoctavian we have this wrapping logic in the ynEigenDepositAdapter currently.

//---------------------------------- WITHDRAWALS -------------------------------------
//--------------------------------------------------------------------------------------

function processPrincipalWithdrawals(
Copy link
Contributor

@danoctavian danoctavian Sep 18, 2024

Choose a reason for hiding this comment

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

What if we don't do this at all in ynEigen?

The reason this was a thing in ynETH is because

  1. we had to take fees on withdrawals - therefore we had to insert fee collection logic on the withdrawal path + the trusted input on how much was rewards

  2. there was accounting to change for each StakingNode (allocated staked eth needed to change)

  3. The principal withdrawals came out in 32 ETH chunks (no further granularity) - therefore we had to have the "withdraw" and "reinvest" breakdown

As far as i can tell 1,2,3 don't apply here, but there is some accounting inside each of the TokenStakingNode instances.

What if simply have a role there that is able to do this and is constrained to send things to the vault, and leave this contract fully unchanged?

it will act on each TokenStakingNode as it does to trigger withdrawals and queuedWithdrawals

Copy link
Contributor

Choose a reason for hiding this comment

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

we may need to store just a reference to the redemptionAssetVault since the staking nodes query the Manager for data

Copy link
Contributor

@danoctavian danoctavian Sep 18, 2024

Choose a reason for hiding this comment

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

Withdraw/Reinvest breakdown does have a use-case actually, that's reallocation.

That's fair.

Thinking how we can simplify the code as much as we can

function initializeV2(
address _redemptionAssetsVault,
address _withdrawer
) external onlyRole(DEFAULT_ADMIN_ROLE) reinitializer(2) notZeroAddress(_redemptionAssetsVault) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Given the experience with ynETH and the issues with this, we should not use

onlyRole(DEFAULT_ADMIN_ROLE)

and call the initialize as part of upgradeAndCall().

Need to think carefully through this but i believe it would be correct.

Copy link
Member Author

Choose a reason for hiding this comment

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

done


uint256 strategyWithdrawalQueueBalance;
uint256 queuedShares = node.queuedShares(strategies[asset]);
if (queuedShares > 0)
Copy link
Contributor

@danoctavian danoctavian Sep 18, 2024

Choose a reason for hiding this comment

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

let's please use { } all the time, eliminates the possibility of making a block scope mistake.

@danoctavian
Copy link
Contributor

danoctavian commented Sep 18, 2024

Main thoughts here:

Instead of making changes to TokenStakingNodesManager,

If any reinvest/withdraw logic is still maintained we hold it in in EigenStrategyManager.

_wrapIfNeeded would also only be in EigenStrategyManager, to not add logic specific to that in TokenStakingNode.

function deallocateTokens(IERC20 _token, uint256 _amount) external onlyTokenStakingNodesManager

becomes

function deallocateTokens(IERC20 _token, uint256 _amount) external onlyYieldNestStrategyManager {

And TokenStakingNodesManager does not interfere with withdrawal.

Thinking along the lines of to be able to have TokenStakingNodesManager/TokenStakingNode something we can take and attach to other protocols without it being overloaded with functionality.

Logically, withdraw/reinvest belongs in the strategy.

@danoctavian
Copy link
Contributor

danoctavian commented Sep 18, 2024

Screenshot 2024-09-19 at 1 04 40 AM

Something like this. Let me know what you think!

johnnyonline added a commit that referenced this pull request Sep 19, 2024
@johnnyonline
Copy link
Member Author

Main thoughts here:

Instead of making changes to TokenStakingNodesManager,

If any reinvest/withdraw logic is still maintained we hold it in in EigenStrategyManager.

_wrapIfNeeded would also only be in EigenStrategyManager, to not add logic specific to that in TokenStakingNode.

function deallocateTokens(IERC20 _token, uint256 _amount) external onlyTokenStakingNodesManager

becomes

function deallocateTokens(IERC20 _token, uint256 _amount) external onlyYieldNestStrategyManager {

And TokenStakingNodesManager does not interfere with withdrawal.

Thinking along the lines of to be able to have TokenStakingNodesManager/TokenStakingNode something we can take and attach to other protocols without it being overloaded with functionality.

Logically, withdraw/reinvest belongs in the strategy.

moved withdrawal logic to strategy manager 92540f4 (and removed comments here 4731a19).

refactoring _wrapIfNeeded next.

}
}

// ============================================================================================
Copy link
Contributor

Choose a reason for hiding this comment

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

move toUserAssetAmount logic in here

Copy link
Member Author

Choose a reason for hiding this comment

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

// ============================================================================================

/// @inheritdoc IWrapper
function wrap(uint256 _amount, IERC20 _token) external returns (uint256, IERC20) {
Copy link
Contributor

Choose a reason for hiding this comment

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

let's change the wrapper as discussed to be called with DELEGATECALL to avoid the double token transfer as discussed

which eliminates forceApproe

Copy link
Member Author

Choose a reason for hiding this comment

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

not as straight forward as it seemed, since wstETH doesnt allow to specify a sender, it would try to get funds from the tx.origin. discussing options on Discord.

Copy link
Contributor

Choose a reason for hiding this comment

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

Here i think you mean msg.sender not tx.origin

And what happens in this scenario is the Wrapper is msg.sender for wstETH (unlike the case with an external ibrary which still makes the contract that does DELEGATE call be the msg.sender still since the lib is not an actual contract).

And this makes it expect that funds are in there.

I want to follow up on this myself, i did a PoC and it worked for me for some reason.. might have had an error.

uint256 strategyWithdrawalQueueBalance;
uint256 queuedShares = node.queuedShares(strategies[asset]);
if (queuedShares > 0) {
strategyWithdrawalQueueBalance = toUserAssetAmount(asset, strategies[asset].sharesToUnderlyingView(queuedShares));
Copy link
Contributor

Choose a reason for hiding this comment

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

Change this loop to call toUserAssetAmount only once by summing

the results of queuedShares = node.queuedShares(strategies[asset]); and

                uint256 strategyBalance = toUserAssetAmount(
                    asset,
                    strategies[asset].userUnderlyingView((address(node)))
                );
                ```
                

Copy link
Member Author

Choose a reason for hiding this comment

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

}
}

emit AssetTransferred(ETH_ASSET, msg.sender, to, amount);
Copy link
Contributor

Choose a reason for hiding this comment

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

the even should have the first value of amount, this one is potentially 0

Copy link
Member Author

Choose a reason for hiding this comment

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

* @param amount The amount of assets to transfer.
* @dev Requires the caller to be the redeemer and the contract to not be paused.
*/
function transferRedemptionAssets(address to, uint256 amount, bytes calldata /* data */) public onlyRedeemer whenNotPaused nonReentrant {
Copy link
Contributor

Choose a reason for hiding this comment

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

WE can move forward with strategy and explain the distribution strategy in the natspec

Copy link
Member Author

Choose a reason for hiding this comment

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

stakedBalances[j] += strategyBalance;

uint256 strategyWithdrawalQueueBalance;
uint256 queuedShares = node.queuedShares(strategies[asset]);
Copy link
Contributor

Choose a reason for hiding this comment

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

we can do a bundled read for:

queuedShares and withdrawnAsset that returns a tuple so we don't do 2 separate calls to node

Copy link
Member Author

Choose a reason for hiding this comment

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

}

stakedBalances[j] +=
wrapper.toUserAssetAmount(asset, strategyBalance + strategyWithdrawalQueueBalance) +
Copy link
Contributor

Choose a reason for hiding this comment

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

for this optimization to be worthwhile, this contract call needs to be moved outside the inner loop.

Right now we still do 1 call per inner loop to wrapper.toUserAssetAmount(asset, strategyBalance + strategyWithdrawalQueueBalance)

To optimize this you need to rely on the property that

wrapper.toUserAssetAmount(asset, X) + wrapper.toUserAssetAmount(asset, Y) == wrapper.toUserAssetAmount(asset, X + Y)

Therefore you sum the uncoverted amounts inside the inner loop and convert the sum outside of the inner loop exactly once.

Copy link
Member Author

Choose a reason for hiding this comment

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

done here be925af

@johnnyonline
Copy link
Member Author

  1. implement caching for the getStakedAssetsBalances
  2. complete the unit tests

IRedemptionAssetsVaultExt public redemptionAssetsVault;
IWrapper public wrapper;

// @todo - struct
Copy link
Contributor

Choose a reason for hiding this comment

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

agreed struct also:

we could do the following:

strategiesBalance[strategy] = _strategiesBalance;
strategiesWithdrawalQueueBalance[strategy] = _strategiesWithdrawalQueueBalance

    are stored as a sum. of the 2 things since they get converted together 

using the Wrapper. This can be stored as as a uint128.

    strategiesWithdrawnBalance gets stored as the other uint128.
    
    Thus both packed in the same slot. 
    
    Make sure when writing that the slot gets written exactly one time (do the update of both at the same time).

Copy link
Member Author

Choose a reason for hiding this comment

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

uint256 stakedBalance;
uint256 withdrawnBalance;
}
mapping(IStrategy => StrategyBalance) public strategiesBalance;
Copy link
Contributor

Choose a reason for hiding this comment

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

@xhad we cache the staked balances here

/// @dev On a slashing events, users will have an incentive to call this function, to decrease the exchange rate.
/// @param asset The asset for which the balances are to be updated.
/// @param strategy The strategy for which the balances are to be updated. If not provided, we search for the strategy associated with the asset.
function updateTokenStakingNodesBalances(IERC20 asset, IStrategy strategy) public {
Copy link
Contributor

Choose a reason for hiding this comment

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

@xhad this updates the cached balances

Copy link
Contributor

Choose a reason for hiding this comment

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

in theory this can have another optimization done and that is:

take a list of assets as parameter and process them all in a single loop.

and hat woul entail we make versions of getQueuedSharesAndWithdrawn(strategy, asset)

that takes arrays as paramers and returns parameters

let's leave this for later i dont think there's enough time now

@danoctavian danoctavian merged commit 7805347 into release-candidate Oct 29, 2024
1 check failed
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.

3 participants