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 12 commits into
base: develop
Choose a base branch
from
Open

Conversation

mkurayan
Copy link
Contributor

@mkurayan mkurayan commented Dec 20, 2024

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 TriggerableWithdrawals {
    // Address of the EIP-7002 pre-deployed contract.
    address constant WITHDRAWAL_REQUEST = 0x0c15...;

    function addFullWithdrawalRequests(
        bytes[] calldata pubkeys,
        uint256 totalWithdrawalFee
    ) internal {
        // Implementation omitted for brevity.
        // WITHDRAWAL_REQUEST.call{value: fee}(withdrawalRequests);
    }
    
    function addPartialWithdrawalRequests(
        bytes[] calldata pubkeys,
        uint64[] calldata amounts,
        uint256 totalWithdrawalFee
    ) 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();
        }

        TriggerableWithdrawals.addFullWithdrawalRequests(pubkeys, msg.value);
    }
}

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                               40       1  97.50%   169
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                    43       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                                                           3078     119  96.13%

Diff against master

Filename                                          Stmts    Miss  Cover
----------------------------------------------  -------  ------  --------
contracts/0.8.9/WithdrawalVault.sol                 +19      +1  -2.50%
contracts/0.8.9/lib/TriggerableWithdrawals.sol      +43       0  +100.00%
TOTAL                                               +62      +1  +0.04%

Results for commit: 66ccbcf

Minimum allowed coverage is 95%

♻️ This comment has been updated with latest results

contracts/0.8.9/lib/WithdrawalRequests.sol Outdated Show resolved Hide resolved
contracts/0.8.9/lib/WithdrawalRequests.sol Outdated Show resolved Hide resolved
contracts/0.8.9/WithdrawalVault.sol Outdated Show resolved Hide resolved
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).

contracts/0.8.9/lib/WithdrawalRequests.sol Outdated Show resolved Hide resolved
*/
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.

Copy link
Member

Choose a reason for hiding this comment

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

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.

See no reason to pass it inside the function when it can definitely be checked before and after the function call in the WithdrawalVault itself. It's kinda alien constraint for the raw withdrawal request creation library.

E.g. in the vaults we don't care where the funds for the gas will come from and we don't need to check it at all.

Copy link
Contributor Author

@mkurayan mkurayan Jan 14, 2025

Choose a reason for hiding this comment

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

The EIP-7002 specification does not impose an upper limit on the withdrawal request fee; this was intentionally designed for flexibility. The library implementation proposed in this PR follows the EIP-7002 specification and does not add any extra restrictions on withdrawal requests.

If we do not want the general purpose library, we can remove control over the request fee, and narrow the library's functionality to allow only requests with minimal fees. This would simplify the code slightly, but also limit the library's potential use cases.

I am not confident that we will not need control over the request fee in the future, @folkyatina what do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

After an internal discussion, we agreed to follow the eip 7002 specification and keep control over the request fee amount, but pass the request fee instead of the total withdrawal fee to simplify library implementation.

contracts/0.8.9/lib/TriggerableWithdrawals.sol Outdated Show resolved Hide resolved
Copy link
Member

@folkyatina folkyatina left a comment

Choose a reason for hiding this comment

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

Still see some potential for improvement

*/
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.

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.

See no reason to pass it inside the function when it can definitely be checked before and after the function call in the WithdrawalVault itself. It's kinda alien constraint for the raw withdrawal request creation library.

E.g. in the vaults we don't care where the funds for the gas will come from and we don't need to check it at all.

contracts/0.8.9/lib/TriggerableWithdrawals.sol Outdated Show resolved Hide resolved
contracts/0.8.9/lib/TriggerableWithdrawals.sol Outdated Show resolved Hide resolved
Add role ADD_FULL_WITHDRAWAL_REQUEST_ROLE for full withdrawal requests.
Access pubkeys and amounts directly instead of copying them to memory.
pass pubkeys as array of bytes
Comment on lines +146 to +174
function addFullWithdrawalRequests(
bytes calldata pubkeys
) external payable onlyRole(ADD_FULL_WITHDRAWAL_REQUEST_ROLE) {
uint256 prevBalance = address(this).balance - msg.value;

uint256 minFeePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee();
uint256 totalFee = pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH * minFeePerRequest;

if(totalFee > msg.value) {
revert InsufficientTriggerableWithdrawalFee(
msg.value,
totalFee,
pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH
);
}

TriggerableWithdrawals.addFullWithdrawalRequests(pubkeys, minFeePerRequest);

uint256 refund = msg.value - totalFee;
if (refund > 0) {
(bool success, ) = msg.sender.call{value: refund}("");

if (!success) {
revert TriggerableWithdrawalRefundFailed();
}
}

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

Check warning

Code scanning / Slither

Divide before multiply Medium

Comment on lines +146 to +174
function addFullWithdrawalRequests(
bytes calldata pubkeys
) external payable onlyRole(ADD_FULL_WITHDRAWAL_REQUEST_ROLE) {
uint256 prevBalance = address(this).balance - msg.value;

uint256 minFeePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee();
uint256 totalFee = pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH * minFeePerRequest;

if(totalFee > msg.value) {
revert InsufficientTriggerableWithdrawalFee(
msg.value,
totalFee,
pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH
);
}

TriggerableWithdrawals.addFullWithdrawalRequests(pubkeys, minFeePerRequest);

uint256 refund = msg.value - totalFee;
if (refund > 0) {
(bool success, ) = msg.sender.call{value: refund}("");

if (!success) {
revert TriggerableWithdrawalRefundFailed();
}
}

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

Check warning

Code scanning / Slither

Dangerous strict equalities Medium

Copy link
Member

@tamtamchik tamtamchik left a comment

Choose a reason for hiding this comment

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

Looks good! Minor comments about library placement, constant WITHDRAWAL_REQUEST (may be immutable), and some style fixes suggestions.

revert TreasuryZeroAddress();
}
constructor(address _lido, address _treasury) {
_requireNonZero(_lido);
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
_requireNonZero(_lido);
_onlyNonZeroAddress(_lido);

@@ -0,0 +1,163 @@
// SPDX-FileCopyrightText: 2023 Lido <info@lido.fi>
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// SPDX-FileCopyrightText: 2023 Lido <info@lido.fi>
// SPDX-FileCopyrightText: 2025 Lido <info@lido.fi>


pragma solidity 0.8.9;
library TriggerableWithdrawals {
address constant WITHDRAWAL_REQUEST = 0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA;
Copy link
Member

Choose a reason for hiding this comment

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

Add some comment with "validation" link?

Suggested change
address constant WITHDRAWAL_REQUEST = 0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA;
/// @dev https://eips.ethereum.org/EIPS/eip-7002#configuration
address constant WITHDRAWAL_REQUEST = 0x0c15F14308530b7CDB8460094BbB9cC28b9AaaAA;

Copy link
Member

@tamtamchik tamtamchik Jan 20, 2025

Choose a reason for hiding this comment

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

May this contract address be changed on testnets to something else (as, for example, with the deposit contract on Holesky)? We'll have to use a separate library for it?

// SPDX-FileCopyrightText: 2023 Lido <info@lido.fi>
// SPDX-License-Identifier: GPL-3.0

pragma solidity 0.8.9;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
pragma solidity 0.8.9;
// solhint-disable-next-line lido/fixed-compiler-version
pragma solidity >=0.8.9 <0.9.0;

Also, consider moving to common libs?

if (!success) {
revert WithdrawalRequestAdditionFailed(callData);
}
}
Copy link
Member

Choose a reason for hiding this comment

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

emit WithdrawalRequestAdded(...) ? Or is this done on the WITHDRAWAL_REQUEST contract side, or should it be done on the side of the contract that uses this library?

@@ -9,6 +9,9 @@ import "@openzeppelin/contracts-v4.4/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts-v4.4/token/ERC20/utils/SafeERC20.sol";

import {Versioned} from "./utils/Versioned.sol";
import {AccessControlEnumerable} from "./utils/access/AccessControlEnumerable.sol";
import {TriggerableWithdrawals} from "./lib/TriggerableWithdrawals.sol";
import { ILidoLocator } from "../common/interfaces/ILidoLocator.sol";
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
import { ILidoLocator } from "../common/interfaces/ILidoLocator.sol";
import {ILidoLocator} from "../common/interfaces/ILidoLocator.sol";

uint256 minFeePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee();
uint256 totalFee = pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH * minFeePerRequest;

if(totalFee > msg.value) {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if(totalFee > msg.value) {
if (totalFee > msg.value) {

}

function _validateAndCountPubkeys(bytes calldata pubkeys) private pure returns (uint256) {
if(pubkeys.length % PUBLIC_KEY_LENGTH != 0) {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if(pubkeys.length % PUBLIC_KEY_LENGTH != 0) {
if (pubkeys.length % PUBLIC_KEY_LENGTH != 0) {

revert InsufficientRequestFee(feePerRequest, minFeePerRequest);
}

if(address(this).balance < feePerRequest * keysCount) {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if(address(this).balance < feePerRequest * keysCount) {
if (address(this).balance < feePerRequest * keysCount) {

uint256 prevBalance = address(this).balance - msg.value;

uint256 minFeePerRequest = TriggerableWithdrawals.getWithdrawalRequestFee();
uint256 totalFee = pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH * minFeePerRequest;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
uint256 totalFee = pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH * minFeePerRequest;
uint256 totalFee = pubkeys.length / TriggerableWithdrawals.PUBLIC_KEY_LENGTH * minFeePerRequest;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants