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/withdrawal credentials #904

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from
Open

Conversation

mkurayan
Copy link
Contributor

Summary

This PR introduces an early-stage prototype for a potential implementation of EIP-7685: General Purpose Execution Layer Requests. Specifically, it implements EIP-7002: Execution Layer Triggerable Withdrawals within the Lido WithdrawalVault contract, which serves as the withdrawal credentials address for Lido validators.

Approach

The implementation follows the first approach outlined in the ADR for Withdrawal Credentials Contract. This approach was selected due to the following advantages:

  • Clear and specific interfaces: Each request type has a dedicated, well-defined interface.
  • Custom logic and parameter validation: Request methods can validate parameters and incorporate tailored logic as needed.
  • Granularity and modularity: WithdrawalCredentials contracts, such as the existing Lido WithdrawalVault and future Vaults, can expose only the necessary subset of request handlers. This allows for unique permission models and specialized logic.

Implementation Details

In this iteration, the WithdrawalVault contract supports only full withdrawal requests. Partial withdrawals are not included because they are not part of the proposed Triggerable Withdrawal V1 implementation within the Lido protocol.

The following pseudo-code demonstrates the approach:

library WithdrawalRequests {
    // Address of the EIP-7002 pre-deployed contract.
    address constant WITHDRAWAL_REQUEST = 0x0c15...;

    function addFullWithdrawalRequests(
        bytes[] calldata pubkeys
    ) internal {
        // Implementation omitted for brevity.
        // WITHDRAWAL_REQUEST.call{value: fee}(withdrawalRequests);
    }
    
    function addPartialWithdrawalRequests(
        bytes[] calldata pubkeys,
        uint64[] calldata amounts
    ) internal {
        // Implementation omitted for brevity.
        // WITHDRAWAL_REQUEST.call{value: fee}(withdrawalRequests);
    }
}

contract WithdrawalVault {
    function addFullWithdrawalRequests(
        bytes[] calldata pubkeys
    ) external payable {
        if (msg.sender != address(VALIDATORS_EXIT_BUS)) {
            revert NotValidatorExitBus();
        }

        WithdrawalRequests.addFullWithdrawalRequests(pubkeys);
    }
}

Key Considerations

The modular design of separate libraries for different withdrawal request types ensures that withdrawal credentials contracts can import and use only the minimal functionality required. For example:

  • Contract A: Supports only full withdrawal requests.
  • Contract B: Supports full withdrawals, partial withdrawals, and consolidation requests.

Notes

This PR is an initial prototype and is subject to further iterations based on feedback and evolving requirements.

@mkurayan mkurayan requested a review from a team as a code owner December 20, 2024 09:26
Copy link

github-actions bot commented Dec 20, 2024

badge

Hardhat Unit Tests Coverage Summary

Filename                                                       Stmts    Miss  Cover    Missing
-----------------------------------------------------------  -------  ------  -------  ---------
contracts/0.4.24/Lido.sol                                        212       0  100.00%
contracts/0.4.24/StETH.sol                                        72       0  100.00%
contracts/0.4.24/StETHPermit.sol                                  15       0  100.00%
contracts/0.4.24/lib/Packed64x4.sol                                5       0  100.00%
contracts/0.4.24/lib/SigningKeys.sol                              36       0  100.00%
contracts/0.4.24/lib/StakeLimitUtils.sol                          37       0  100.00%
contracts/0.4.24/nos/NodeOperatorsRegistry.sol                   512       0  100.00%
contracts/0.4.24/oracle/LegacyOracle.sol                          72       0  100.00%
contracts/0.4.24/utils/Pausable.sol                                9       0  100.00%
contracts/0.4.24/utils/Versioned.sol                               5       0  100.00%
contracts/0.6.12/WstETH.sol                                       17       0  100.00%
contracts/0.8.4/WithdrawalsManagerProxy.sol                       61       0  100.00%
contracts/0.8.9/BeaconChainDepositor.sol                          21       2  90.48%   48, 51
contracts/0.8.9/Burner.sol                                        71       0  100.00%
contracts/0.8.9/DepositSecurityModule.sol                        128       0  100.00%
contracts/0.8.9/EIP712StETH.sol                                   16       0  100.00%
contracts/0.8.9/LidoExecutionLayerRewardsVault.sol                16       0  100.00%
contracts/0.8.9/LidoLocator.sol                                   18       0  100.00%
contracts/0.8.9/OracleDaemonConfig.sol                            28       0  100.00%
contracts/0.8.9/StakingRouter.sol                                316       0  100.00%
contracts/0.8.9/WithdrawalQueue.sol                               88       0  100.00%
contracts/0.8.9/WithdrawalQueueBase.sol                          146       0  100.00%
contracts/0.8.9/WithdrawalQueueERC721.sol                         89       0  100.00%
contracts/0.8.9/WithdrawalVault.sol                               29       2  93.10%   77-78
contracts/0.8.9/lib/Math.sol                                       4       0  100.00%
contracts/0.8.9/lib/PositiveTokenRebaseLimiter.sol                22       0  100.00%
contracts/0.8.9/lib/TriggerableWithdrawals.sol                    40       0  100.00%
contracts/0.8.9/lib/UnstructuredRefStorage.sol                     2       0  100.00%
contracts/0.8.9/oracle/AccountingOracle.sol                      190       2  98.95%   189-190
contracts/0.8.9/oracle/BaseOracle.sol                             89       1  98.88%   397
contracts/0.8.9/oracle/HashConsensus.sol                         263       1  99.62%   1005
contracts/0.8.9/oracle/ValidatorsExitBusOracle.sol                91      91  0.00%    96-461
contracts/0.8.9/proxy/OssifiableProxy.sol                         17       0  100.00%
contracts/0.8.9/sanity_checks/OracleReportSanityChecker.sol      232       0  100.00%
contracts/0.8.9/utils/DummyEmptyContract.sol                       0       0  100.00%
contracts/0.8.9/utils/PausableUntil.sol                           31       0  100.00%
contracts/0.8.9/utils/Versioned.sol                               11       0  100.00%
contracts/0.8.9/utils/access/AccessControl.sol                    23       0  100.00%
contracts/0.8.9/utils/access/AccessControlEnumerable.sol           9       0  100.00%
contracts/testnets/sepolia/SepoliaDepositAdapter.sol              21      21  0.00%    49-100
TOTAL                                                           3064     120  96.08%

Diff against master

Filename                                          Stmts    Miss  Cover
----------------------------------------------  -------  ------  --------
contracts/0.8.9/WithdrawalVault.sol                  +8      +2  -6.90%
contracts/0.8.9/lib/TriggerableWithdrawals.sol      +40       0  +100.00%
TOTAL                                               +48      +2  -0.01%

Results for commit: 2fc90ec

Minimum allowed coverage is 95%

♻️ This comment has been updated with latest results

* @param pubkeys An array of public keys for the validators requesting withdrawals.
* @param amounts An array of corresponding withdrawal amounts for each public key.
*/
function addPartialWithdrawalRequests(
Copy link
Member

Choose a reason for hiding this comment

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

What if I want a batch that is mix of partial and full withdrawals?

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 separation of Full and Partial withdrawals was suggested to simplify an interaction with the withdrawal contract.

An approach, proposed in the Triggerable Withdravals document suggests that the Validator Exit Bus will only use full withdrawal. Usages of partial withdrawals might require a different mechanism inside the Lido protocol, which is why this PR proposes to separate those types of requests.

If you consider that a mix of partial and full withdrawals might be useful in the future we can consider multiple approaches.

  1. Add the addWithdravalCredentials method which will consume both partial and full withdrawals.

  2. In addition to the first approach, remove addPartialWithdrawalRequests method, so the addWithdravalCredentials will be used for both partials and mix (partial + full) withdrawal requests.

What do you think will be an optimal approach?

Copy link
Member

Choose a reason for hiding this comment

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

1st one will be fine. I think that this kind of constraint is not required in vaults.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, I am also considering the first approach, noted, thanks!

feeToSend += unallocatedFee;
}

bytes memory callData = abi.encodePacked(pubkey, amount);
Copy link
Member

Choose a reason for hiding this comment

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

memory expansion in a loop can eat a lot of gas very quickly. Maybe worth to optimize like in BeaconChainDepositor.

@@ -27,6 +28,7 @@ contract WithdrawalVault is Versioned {

ILido public immutable LIDO;
address public immutable TREASURY;
address public immutable VALIDATORS_EXIT_BUS;
Copy link
Member

Choose a reason for hiding this comment

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

It may be better to store locator here

revert NoWithdrawalRequests();
}

uint256 minFeePerRequest = getWithdrawalRequestFee();
Copy link
Member

Choose a reason for hiding this comment

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

Doesn't fee increase with each request?

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, the fee will not increase with each request. Inside the transaction, all requests will have the same fee.

EIP 7002 uses block-by-block behavior.

If block N processes X requests, then at the end of block N the number of withdrawal requests that the chain has processed relative to the “targeted” number increases by X - TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK, and so the fee in block N+1 increases by a factor of e**((X - TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK) / WITHDRAWAL_REQUEST_FEE_UPDATE_FRACTION).

emit WithdrawalRequestAdded(pubkey, amount);
}

assert(address(this).balance == prevBalance);
Copy link
Member

Choose a reason for hiding this comment

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

Is it possible to calculate the fee exactly at the moment of signing the tx? Shouldn't we ease the constraint and add some refund capability here?

Copy link
Contributor Author

@mkurayan mkurayan Dec 20, 2024

Choose a reason for hiding this comment

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

Great question.

Calls to the system contract require a fee payment defined by the current contract state. Overpaid fees are not returned to the caller. It is not generally possible to compute the exact required fee amount ahead of time.

When adding a withdrawal request from a contract, the contract can perform a read operation to check for the current fee and then pay an exact amount.

When adding a withdrawal request from EOA request may result in overpayment of fees. But the gas cost of returning the overage would likely be higher than the overage itself.

Proposed in this PR implementation does not imply any restriction on the caller, but makes sure that the withdrawal credential balance (accounting) will not be affected.

In the proposed Triggerable Withdrawal implementation, the dedicated bot will provide the fee within the withdrawal request transaction and, a small overpayment is possible. This is the simplest approach, which is currently considered for Triggerable Withdrawal implementation.

Other withdrawal contracts may consider the first approach without overpayment.

Copy link
Member

Choose a reason for hiding this comment

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

For vaults, it will be good to exclude msg.value or fees' amount assumptions from this method. I mean, it's ok that we can't foresee the price, but in the same time, I'd like that the excessive amount just stay on the vault if any and will be available for withdraw later.

So, I'd rather just check if the balance is enough to add all requests and does not impose any assumptions on if the balance was topped up in this tx or before.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point, I agree. The fee allocation strategy should be decoupled from the withdrawals library, allowing each contract to determine its own approach based on its design and requirements. To address these changes, I suggest adding a new totalWithdrawalFee parameter, which represents the total amount that will be spent on the EIP-7002 withdrawal requests.

The following pseudo-code demonstrates the approach:

library WithdrawalRequests {
    function addWithdrawalRequests(
        bytes[] calldata pubkeys,
        uint64[] memory amounts,
        uint256 totalWithdrawalFee
    ) internal {
        // Implementation that uses totalWithdrawalFee to cover EIP-7002 withdrawal requests
        ...
    }

    function getWithdrawalRequestFee() internal pure returns (uint256) {
        // Returns the minimum required fee per request
        ...
    }
}

contract A {
    function addWithdrawalRequests(
        bytes[] calldata pubkeys,
        uint64[] calldata amounts
    ) external payable {
        // Use the entire sent amount (msg.value) as the total fee for withdrawal requests
        uint256 totalWithdrawalFee = msg.value;
        WithdrawalRequests.addWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee);
    }
}

contract B {
    function addWithdrawalRequests(
        bytes[] calldata pubkeys,
        uint64[] calldata amounts
    ) external {
        // Use the minimum required fee per request
        uint256 minFeePerRequest = WithdrawalRequests.getWithdrawalRequestFee();
        uint256 totalWithdrawalFee = minFeePerRequest * pubkeys.length;
        WithdrawalRequests.addWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee);
    }
}

// Additional contracts can implement other fee allocation strategies as needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. Here is some additional discussion on this topic: #904 (comment)

Comment on lines +90 to +138
function _addWithdrawalRequests(
bytes[] calldata pubkeys,
uint64[] memory amounts,
uint256 totalWithdrawalFee
) internal {
uint256 keysCount = pubkeys.length;
if (keysCount == 0) {
revert NoWithdrawalRequests();
}

if(address(this).balance < totalWithdrawalFee) {
revert InsufficientBalance(address(this).balance, totalWithdrawalFee);
}

uint256 minFeePerRequest = getWithdrawalRequestFee();
if (minFeePerRequest * keysCount > totalWithdrawalFee) {
revert FeeNotEnough(minFeePerRequest, keysCount, totalWithdrawalFee);
}

uint256 feePerRequest = totalWithdrawalFee / keysCount;
uint256 unallocatedFee = totalWithdrawalFee % keysCount;
uint256 prevBalance = address(this).balance - totalWithdrawalFee;

for (uint256 i = 0; i < keysCount; ++i) {
bytes memory pubkey = pubkeys[i];
uint64 amount = amounts[i];

if(pubkey.length != 48) {
revert InvalidPubkeyLength(pubkey);
}

uint256 feeToSend = feePerRequest;

if (i == keysCount - 1) {
feeToSend += unallocatedFee;
}

bytes memory callData = abi.encodePacked(pubkey, amount);
(bool success, ) = WITHDRAWAL_REQUEST.call{value: feeToSend}(callData);

if (!success) {
revert WithdrawalRequestAdditionFailed(pubkey, amount);
}

emit WithdrawalRequestAdded(pubkey, amount);
}

assert(address(this).balance == prevBalance);
}

Check warning

Code scanning / Slither

Dangerous strict equalities Medium

*/
function addFullWithdrawalRequests(
bytes[] calldata pubkeys,
uint256 totalWithdrawalFee
Copy link
Member

Choose a reason for hiding this comment

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

It think that this parameter is not required at all. We can safely assume that all required ether is on the balance of the contract and we'll revert if it's not true and if we need some additional constraints (like, msg.value == fee), we can add it in the contract that use that lib.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This parameter was introduced to decouple the fee allocation strategy from the withdrawals library, discussion. It enables contracts to employ different allocation strategies.

The proposed Validator Exitt Bus Triggerable Withdrawal implementation assumes that the withdrawal fee is provided by the actor who triggers the withdrawals. In this approach, the WithdrawalVault.sol balance remains unaffected, preventing issues with the Oracle’s accounting. Consequently, the entire msg.value sent is used as the fee for withdrawal requests:

// Simplified pseudo-code
function addWithdrawalRequests(
    bytes[] calldata pubkeys,
    uint64[] calldata amounts
) external payable {
    // Use the entire sent amount (msg.value) as the total fee for withdrawal requests
    uint256 totalWithdrawalFee = msg.value;
    WithdrawalRequests.addWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee);
}

Other vaults could employ the strategy you mentioned, assuming all required Ether is already in the contract’s balance:

// Simplified pseudo-code
function addWithdrawalRequests(
    bytes[] calldata pubkeys,
    uint64[] calldata amounts
) external {
    // Use the minimum required fee per request
    uint256 minFeePerRequest = WithdrawalRequests.getWithdrawalRequestFee();
    uint256 totalWithdrawalFee = minFeePerRequest * pubkeys.length;
    WithdrawalRequests.addWithdrawalRequests(pubkeys, amounts, totalWithdrawalFee);
}

When the withdrawal fee is specified explicitly, any fee allocation strategy can be used. The library ensures that the provided fee sufficiently covers all requests and that the exact fee amount is spent.

uint256 prevBalance = address(this).balance - totalWithdrawalFee;

for (uint256 i = 0; i < keysCount; ++i) {
bytes memory pubkey = pubkeys[i];
Copy link
Member

Choose a reason for hiding this comment

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

Unnecessary memory extension?

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.

2 participants