owned this note
owned this note
Published
Linked with GitHub
---
title: Withdrawals spec
tags: withdrawals, lip, spec, design
status: Pre-Draft
author: Eugene Mamin, Alexey Potapkin, Artyom Veremeenko
discussions-to: TBA
created: 2022-09-13
updated: 2022-11-24
---
# Withdrawals spec
>TODO [dzhon]:
>- WithdrawalQueue as interface for users (minimize Lido bytecode size)
>- IMPROVE exit signalling (separate contract is expected)
>- naming
>- oracle
>- spin-off doc for discussion (DoF extracts)
>- self-coverage moved to pre-rebase (?): pre-rebase / post-rebase cover | non-cover
>- Versioning for initialization (LIP-10)
>- Automated counters for exits to prevent NOs oversubmitting
>- pre-rebase / post-rebase oracle report
>- oracle invariants on expected ids
>- finalization price needs to be described once again
>- how to select finalization id (Oracle report flow)
>- can have a separate contract to query for APR (to transplate the Oracle)
>- Oracle may go not through Lido, but call separate contracts and Lido as the last one (idealistically)
>- EIP-2612
>- setELRewardsVault + setProtocolContracts
>- petrify
>- stake limit constant gas
> Frontend requests:
> - Permit
> - Claim multiple requests
> - WstETH unwrap
> - Show what Lido oracle would have been reported now upfront
> Tooling
> - Bootstrap non-exited
## Abstract
A major Lido protocol upgrade that allows holders to redeem `stETH` back to `ETH` after the [`Shanghai`](https://ethereum-magicians.org/t/shanghai-core-eip-consideration/10777)/[`Capella`](https://github.com/ethereum/consensus-specs/issues/2758) hardfork. It consists of three relatively separate flows described further in the doc.
## General overview
`stETH` holder interacts with Lido by locking tokens on [`WithdrawalQueue`](https://etherscan.io/address/0xb9d7934878b5fb9610b3fe8a5e441e8fad7e293f) and receiving an enqueued position index in return. The position is monotonically increasing counter representing the place in the internal withdrawal requests queue. Locked `stETH` continues accumulating rewards on behalf of other protocol users, the original holder stops receiving them since the block number of the successfully placed withdrawal request.
Upon the next oracle report, previously accumulated withdrawal requests are processed and finalized if eligible (timelock passed and enough funds to fulfill). Other requests are carried over to the next oracle report round. `stETH` [shares](https://docs.lido.fi/guides/steth-integration-guide#steth-internals-share-mechanics) are burned on behalf of the Oracle report together with decreasing the total pooled ether amount.
The logic of withdrawal requests unwinding and processing is implemented within the off-chain Oracle daemon. The Oracle daemon provides the following data during the reports: the index of the last finalized withdrawal request, total Lido validators balance, active validators number, exited validators number per each node operator, balance of the Lido withdrawal credentials address (all info is valid for the last block of the expected report epoch: [[1]](https://docs.lido.fi/contracts/lido-oracle#getexpectedepochid), [[2]](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-2.md#use-only-the-first-epoch-of-the-current-frame-for-oracles-reporting)).
The oracle must track all of the Lido-participating validators' exits (node operators could also initiate a voluntary exit without request from Lido) that occurred over the previous oracle round and modify their on-chain state stored within Lido protocol to process rewards and balance the newly submitted ether.
After the withdrawals requests have been processed and finalized, anyone can trigger sending the claimed ether by providing finalized position index to the original requestor (recipient).
Another signalling off-chain process which has its own lifecycle, is needed to report the validator keys needs to be exited to fulfill withdrawal requests. This process could be executed on behalf of the main Oracle committee to request the exit of specific validators based on the amount of withdrawal requests, slashing conditions, status/performance of validators controlled by Lido, staked ether buffer size, execution layer rewards, and previously requested withdrawals. The order of the proposed exits is deterministic and verifiable externally (i.e., sorted by validator's index resembling validator age counted from its activation).
>NOTE:
>Withdrawals design landscape id described [here](https://hackmd.io/tbBm1oIiQtCLbDDmYIAExw?view) in depth (WIP).
#### Slashing
If Lido validator(s) got slashed, then withdrawal requests placed since the day of slashing will be finalized no earlier then midterm slashing penalties would have been taken into accound (~18 days). New overlapping slashings don't affect the withdrawal request fulfillment to prevent an obvious denial of service attack vector (by slashing single validator each ~18 days). The diagram below outlines the idea of slashing delays:
```mermaid
gantt
title Processing requests with undergoing slashing
axisFormat %b %d
section Slashings
Slashing 1 :a1, 2022-10-11, 36d
Slashing 1 midterm : milestone, m1, 2022-10-29,
Slashing 2 :a2, 2022-10-16, 36d
Slashing 2 midterm : milestone, m2, 2022-11-03,
Slashing 3 :a3, 2022-11-18, 36d
Slashing 3 midterm : milestone, m3, 2022-12-06,
section Withdrawal requests
Request 1 (no extra delay) :r1, 2022-10-08, 2d
Request 2 (+18d of slashing 1):r2, 2022-10-11, 18d
Request 3 (+15d of slashing 1):r3, 2022-10-14, 15d
Request 4 (+10d of slashing 2):r4, 2022-10-24, 10d
Request 5 (no extra delay):r5, 2022-11-15, 2d
Request 6 (no extra delay):r6, 2022-12-07, 2d
```
Further details are provided within the dedicated [Slasing conditions](#Slashing-conditions) section.
#### Funding sources for withdrawals
There are three possible funding sources to fulfill withdrawal requests:
1. Native post-Shanghai/Capella [Ethereum withdrawals](https://eips.ethereum.org/EIPS/eip-4895)
- full withdrawals of exited validators
- partial withdrawals or ["skimmed rewards"](https://github.com/ethereum/consensus-specs/blob/dev/specs/capella/beacon-chain.md#introduction)
2. Execution Layer rewards (post-Merge block proposer rewards: [priority fees and MEV](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-12.md))
3. Deposit buffer (user-submitted ether in exchange of `stETH`)
##### Optimistic funding
In most of the cases, Lido withdrawal requests could be fulfilled using all three sources. There is a reserve mark securing particular funds amount for withdrawals using the sources 2 and 3, allowing extra amounts be re-staked back into the protocol boosting capital efficiency.
Reserve mark updates on each oracle report to address two considerations:
- hold funds to partially fill first unfinalized withdrawal request (crucial if it's huge)
- disable deterministic funds amount being used for deposits untill the next Oracle report (i.e., disentangle deposits flow)
##### Fallback to the native-only funding approach
In case of newly registered since the previous report slashing events (*NB: even for non-Lido validators*), the Oracle is allowed to use only the first source to fulfill withdrawal requests on behalf of the current report.
See [new slashings events](#New-slashing-events) for more details about buffers access.
## Flows
### Staker's flow
Staker's flow describes all the steps that are required for a common user of the protocol to withdraw their ether.
The overall staker's flow as a sequence diagram is represented below:
```mermaid
%%{
init: {
"theme": "forest"
}
}%%
sequenceDiagram
title Staker's flow
participant Staker
participant WithdrawalQueue
participant Anyone
Note right of Staker: Step 1
Staker->>WithdrawalQueue: requestWithdrawal(amount of stETH to lock)
WithdrawalQueue-->>Staker: return (request id)
Note right of Anyone: Step 2
loop Untill (request status) is final
Anyone->>WithdrawalQueue: withdrawalRequestStatus(request id)
WithdrawalQueue-->>Anyone: return (request status)
end
Note right of Anyone: Step 3
Anyone->>WithdrawalQueue: claimWithdrawal(request id)
WithdrawalQueue->>Staker: transfer ether (#8804; originally locked stETH amount)
```
##### Step 1. Initiate a request
Withdrawal requests are initiated by a stETH holder via the `WithdrawalQueue` contract using the entry-point `requestWithdrawal` method which:
- irreversibly locks given `stETH` amount by trasferring it from the caller (`msg.sender`) relying on provided allowance (explicit approval)
- enqueues withdrawal request into the contract's requests queue
- associates an incremental unique id (i.e., position in the FIFO-serving queue) with the caller's address
The amount of ETH that staker will receive upon withdrawal is capped at this stage (locked `stETH` stops accrueing rewards on behalf of the caller since the moment of withdrawal request had been enqueued succesfully).
##### Step 2. Wait until the request finalization
There are two approaches to track requests finalization:
- On-chain: call the permissionless `withdrawalRequestStatus` method of the `WithdrawalQueue` contract allows tracking withdrawal requests providing the request id.
- Off-chain: subscribe to the `WithdrawalsFinalized` events emitted by the contract with a terminating condition if last finalized unique id becomes equal or greater than the needed unique id.
The withdrawal request recipient can receive in the end:
- ether amount corresponding to the locked `stETH` amount when called the `requestWithdrawal` method previously
- ether amount less than locked `stETH` amount in case of mass slashing events (unlikely, will be described further below)
This amount becomes fully determined (immutable) once the withdrawal request marked as finalized and becomes claimable.
Withdrawal requests are expected to become finalized in a couple of days for the most cases. In case of mass slashing events and penalties finalization needs to be postponed up to ~18 days.
##### Step 3. Claim ether
Once withdrawal request has been fulfilled completely and marked as finalized, anyone is able to call the `claimWithdrawal` method on the `WithdrawalQueue` contract, providing the original unique withdrawal request id. The method transfers ethers to the original requestor (recipient) who locked their `stETH` on the first step.
### Oracle flow (protocol accounting)
Rough oracle flow is the following:
- **take a pre- snapshot of the `stETH` share price**
- update exited validators number for each Lido-participating node operator
- estimate transient validators balance
- finalize withdrawal requests (if possible) by burning `stETH` shares and locking ether in the same time (i.e., decreasing a total pooled ether amount)
- resubmit remaining funds up to the reported `WithdrawalQueue` balance at block `N` to the deposit buffer
- set the reserve mark value to leave the necessary amount of funds in deposit buffer till the next report to use for withdrawals
- **take a post- snapshot of the `stETH` share price for rewards**
- calculate both consensus and execution layer rewards
- mint&distribute protocol fee on top of rewards only if consensus layer rewards part is positive
- **take a post- snapshot of the `stETH` share price for APR calculation**
Oracle report should provide the following data:
- Consensus layer data:
* Lido validators balances sum at block `N`
* Active Lido validators number at block `N`
* Cumulative number of exited Lido validators number at block `N`
* List of exited Lido validators number at block `N` per each Node Operator since the previously completed report
- Execution layer data:
* The balance of `WithdrawalQueue` available for distribution at block `N`
- Decision data:
* New withdrawals buffered ether reserve mark value
* List of triples: [last finalized request id, finalized stETH amount, finalized shares amount]
Requirements and constraints:
- Block `N` corresponds to the last block of the **finalized** expected epoch
- Off-chain Oracle daemon decides how many withdrawal requests have to be finalized on each report. It **must** suspend finalization if slashing occured until midterm penalty applied (see [Slashing](#Slashing) and [Slashing Conditions](#Slashing-conditions))
- List of triples `[last finalized request id, amount of finalized stETH, amount of finalized shares]` is used to deliver the values of the previously missed reportable epochs since the last successfully completed report
- Oracle is able to finalize the requests happened only BEFORE the fixed offset from block `N` (10 epochs by default)
- A validator is exited once became [inactive](https://github.com/ethereum/consensus-specs/blob/11a037fd9227e29ee809c9397b09f8cc3383a8c0/specs/phase0/beacon-chain.md#is_active_validator) (reached the assigned `exit_epoch`)
- The Oracle daemon are unable to deliver the retrospective report if an [expected reportable epoch](https://docs.lido.fi/contracts/lido-oracle#getexpectedepochid) was missed. A new report should deliver the data for missed period(s) happened since the last successfully completed report.
#### Momentary stETH total supply (TVL)
Previously [total stETH supply](https://docs.lido.fi/contracts/lido#rebasing) (or `totalPooledEther`) for Lido was defined as follows:
```rust
totalPooledEther = bufferedEther + transientBalance + beaconBalance'
```
where:
* `bufferedEther` — ether stored on the Lido contract and haven't deposited to official Deposit contract yet (*momentary*)
* `transientBalance = (depositedValidators - beaconValidators') * DEPOSIT_SIZE` — ether submitted to the official Deposit contract but not yet visible in the beacon state (*momentary*)
* `beaconBalance'` — total amount of ether on validator account (reported by oracles, having strongest impact to stETH total supply)
* `'` - is for values that corresponds to the latest oracle report (which already looks in the past)
New formula for total supply (`totalPooledEtherNew`) is following:
```rust
totalPooledEtherNew =
bufferedEther + transientBalanceNew' + beaconBalance'
```
where:
* `transientBalanceNew = (depositedValidators - beaconValidators' - withdrawnValidators') * DEPOSIT_SIZE` (*momentary*)
The formula is valid during the whole lifecycle except the oracle-report quorum-reaching tx internals.
#### Reward distribution run
Previously rewards amount was calculated by the following formula:
```rust
totalRewards
= postTotalPooledEther - preTotalPooledEther
= (postBeaconBalance - preBeaconBalance) - (preTransientBalance - postTransientBalance) + (postBufferedEther - preBufferedEther)
```
where:
* `preTotalPooledEther` — total pooled ether amount, queried right BEFORE every report push to the Lido contract
* `postTotalPooledEther` — total pooled ether amount, queried right AFTER every report push to the Lido contract
* `(postBufferedEther - preBufferedEther) = executionLayerRewards` — amount of execution layer rewards collected between two consequitive oracle reports (became non-zero starting from the Merge)
Protocol fee distribution was performed only if the following condition is met: `postBeaconBalance > preBeaconBalance`
Pre-withdrawals rewards were from beacon chain balance increase and execution layer tips/MEV.
Post-withdrawals rewards calculation should include the third source: burning `stETH` shares matching the previous 'total pooled ether' per 'single share' ratio. Effectively, it means that reward distribution should be dependant not only on `totalPooledEther`, but also on the underlying shares amount change:
Now it should be updated to:
```rust
preSharesRate = preTotalPooledEther / preTotalShares
postSharesRate = postTotalPooledEther / postTotalSharesPreFee
totalRewards = postTotalSharesPreFee * (postSharesRate - preSharesRate)
```
where:
* `preTotalShares` — `stETH` shares amount, queried right BEFORE every report push to the Lido contract
* `postTotalSharesPreFee` - `stETH` shares amount, queried right AFTER every report push to the Lido contract (before distributing protocol fee)
Protocol fee mint&distribution should be performed only if the following condition is met: `(totalRewards - executionLayerRewards) > 0`
>NOTE
>In case of external token integrations rewards calculation should take into account
> [In-protocol coverage application mechanism (LIP-6)](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-6.md).
#### APR
An APR calculation should also be updated to the following formula:
```rust
preSharesRate = preTotalPooledEther / preTotalShares
postSharesRate = postTotalPooledEther / postTotalSharesPostFee
APR = (postSharesRate - preSharesRate) * secondsInYear / (preSharesRate * timeElapsed)
```
where:
* `postTotalSharesPostFee` - `stETH` shares amount, queried right AFTER every report push to the Lido contract (after distributing protocol fee)
* `secondsInYear` — seconds amount per single year
* `timeElapsed` — time passed since the previous oracle report (in seconds)
>NOTE
>The APR calculation is opaque to the self-coverage application and protocol fee amount.
#### Withdrawal request finalization
Pending withdrawal requests got finalized as a part of the Oracle report.
The Oracle report delivers the following values used to finalize the withdrawal requests:
* the balance of WC address at block N
* the amount of locked ether on WC address at block N
* the list of triples to describe each finalized batch of requests:
* id of the last withdrawal request id in the batch
* the total pooled ether amount per single stETH (i.e., share price) in the batch
It means, that requests till last withdrawal request id from the list's tail element become finalized (or claimable), noting `id` of the last finalized withdrawal request at block `N` is required to avoid on-chain loops over the withdrawal requests on their finalization. The total pooled ether amount per single stETH share for the blocks range got recorded into the table to facilitate a calculation of ether amount claimable for each request (even for old ones).
```mermaid
sequenceDiagram
title Oracle flow
participant OO as Off#hyphen;chain oracle
participant WQ as WithdrawalQueue
participant LO as LidoOracle
participant Lido
OO-->>OO: determine finalized block N
OO->>OO: read CL data at block N
OO->>OO: read WC balance at block N
OO->>WQ: readLastRequestId(block N)
WQ-->>OO: return (lastRequestId)
loop Collect unfinalized requests info (≤ lastRequestId)
OO->>WQ: getRequestInfo(requestId)
WQ-->>OO: return (request info)
end
OO-->>OO: determine last requestId for finalization
OO-->>LO: push CL, EL data + last requestId for finalization
LO-->>Lido: update accounting
Lido-->>WQ: finalize(lastRequestId, currentSharePrice)
```
### Exit signals flow
>Same committee and codebase as for the existing Oracle
The exit daemon is used to inform that some Lido validators should be ejected. The purpose of the daemon is to secure and distribute these kind of reports via smart-contract events. It's architecture is akin [Generalized Message Bus](https://ethresear.ch/t/withdrawal-credentials-exits-based-on-a-generalized-message-bus/12516) approach introduced previously.
Exit daemon runs its own lifecycle and doesn't need to be aligned with the general Lido oracle reports.
Exit daemon delivers the following data:
- an exact pubkey to exit
- NO id and stakingModuleId where the pubkey belongs
- validator index corresponding to the validator with the pubkey
- block/epoch number of decision (Q: not needed?)
- proof of validity (merkle root of all keys?)
> TODO: Node Operator can stop responding at all, we want to account it in exit algo
> Q: If store counters of non-exited (not responded in time with VoluntaryExit command) for every NO, how is it gointg *actually* be used?
> Q: If validator responded
> TODO: after oracle cold start: what data is needed to understand how far in past Oracle would need to go
#### Calculate/evaluate amount of validators for exit
Rough algorithm looks like this.
1. Take not finalized withdrawal requests form `WithdrawalQueue`
1. Evaluate amount of the requests which don't require to exit any additional validators:
1. Number of requests for which there is enough ether in buffers permitted for withdrawals
2. Number of requests which are going to receive their ether from pending exit requests (requested and for which the validators responded under `TIMEOUT_FOR_EXIT_REQUEST`)
3. /optional/ Take into account the ether which is going to come from sponteneous validator exits
1. Calc amount of packs of 32 ETH needed to satisfy ether demand from the rest of the withdrawal requests
#### Select specific validators for exit
Based on the [exit order discussion](https://research.lido.fi/t/withdrawals-on-validator-exiting-order/3048).
1. Take list of Lido validators ordered by validator activation time ascending
2. Identify the last validator requested for exit (by taking the last `ValidatorExitRequest` event)
3. Take the first `N` validators, skipping already exited / exiting validators
#### Parameters of protocol exit flow
Parameters of the protocol related to validator exit flow
- `TIMEOUT_FOR_EXIT_REQUEST` Time since emitting `ValidatorExitRequest` event till the corresponding validator is considered non-responded and skipped. Likely should be stored in `ValidatorExitBus` contract
- `MAX_KEYS_TO_REPORT_FOR_EXIT_AT_ONCE` Constant to prevent oracle from hitting block gas limit while reporting to `ValidatorExitBus`. Determined based on the transaction gas cost
- `MAX_EXIT_REQUESTS_PER_DAY` Safely limit on the amount of exit reports allowed per day. To protect from exiting insane amounts of validators due to malicios or incorrect oracle reports.
```mermaid
%%{
init: {
"theme": "neutral"
}
}%%
sequenceDiagram
title Exit daemon flow
participant ED as Exit daemon
participant LO as Lido Oracle
participant NO as Node Operator
ED-->ED: determine amount of validators keys to exit
loop Push validators keys (≤ keys count)
ED-->LO: push next validator pubkey
LO-->LO: emit event with validator pubkey
end
loop Waiting for relevant keys
NO-->LO: read events for relevant pubkey
NO-->NO: sign exit message and send to CL if relevant
end
```
* The propose tooling for signing exit messages described [here](https://research.lido.fi/t/withdrawals-automating-lido-validator-exits/3272)
## Technical Scope
### On-chain smart contracts
#### Lido
An facade for users to stake and unstake (withdraw) the funds, and StETH manager as well.
- stETH ERC-20 logic
- stakers top-level interactions (submit/withdraw entry point)
- protocol parameters management
- rewards/fee distribution
#### WithdrawalQueue
A separate smart contract that allows to lock up stETH to get the position marker in return, and once the protocol could burn stETH and return ETH (*replaces the [Withdrawal Manager Stub](https://docs.lido.fi/contracts/withdrawals-manager-stub) by occupying the [WC address](https://etherscan.io/address/0xb9d7934878b5fb9610b3fe8a5e441e8fad7e293f).
#### OracleBase
Base contract for both `LidoOracle` and `ValidatorsExitBusOracle` with a commong logic
- Commitee and quorum logic
- Reports merkalization and housekeeping (if needed)
- General-purpose raw-encoded calldata collecting
#### LidoOracle (derived from OracleBase)
- Validators statuses for the expected epoch
- WC balances for the expected epoch
- Sanity checks
- stETH rebase (updates TVL, initiates reward distribution run
- Pre- and post- rebasing hooks
- Reports calldata has a typed interface
#### ValidatorsExitBusOracle (derived from OracleBase)
- Next batch for exit reports by oracle committee
- Sanity checks
- Rate limits for exiting keys
- Events to inform external observes regarding each key
- Reports calldata has a typed interface
## Smart contracts specification
### Lido.sol
>NOTE:
>The following definitions presume the Solidity v0.4 syntax.
The following new entities are defined:
#### Functions
##### Function: `requestWithdrawal`
```solidity
function requestWithdrawal(
uint256 _amountOfStETH
) external returns (uint256 requestId);
```
Irreversibly locks `_amountOfStETH` stETH to place the withdrawal request from `msg.sender`.
Transfers `_amountOfStETH` to the `WithdrawalQueue` contract.
Returns the incremental unique enqueued request position (`requestId`).
- reverts if `msg.sender` have stETH balance lower than `_amountOfStETH`
- reverts if `_amountOfStETH` lower than `MIN_STETH_WITHDRAWAL_AMOUNT`
- reverts if `_amountOfStETH` greater than `MAX_STETH_WITHDRAWA_AMOUNT`
- reverts if withdrawals are paused
- reverts if protocol is paused
- emits `WithdrawalRequested(_requestId, msg.sender, _amountOfStETH, StETH.getSharesByPooledEther(_amountOfStETH))`.
>NOTE:
> `WithdrawalQueue.MIN_STETH_WITHDRAWAL_AMOUNT = 0.1 stETH` by default
> `WithdrawalQueue.MAX_STETH_WITHDRAWA_AMOUNT = 16000 stETH` by default
###### Parameters
| Name | Type | Description |
| ---------------- | --------- | ------------------------------- |
| `_amountOfStETH` | `uint256` | stETH amount to withdraw as ETH |
##### Functon: `withdrawalRequestStatus`
```solidity
function withdrawalRequestStatus(uint256 _requestId)
external view
returns (
address recipient,
uint256 requestBlockNumber,
uint256 etherToWithdraw,
bool isFinalized,
bool isClaimed
)
```
Retrieves the status of the previously placed withdrawal request.
Returns the exhaustive info about the request status.
- returns all zero fields if `_requestId` is invalid (not placed yet)
- never reverts intentionally
###### Parameters
| Name | Type | Description |
| ----------- | --------- | ---------------------------------------------------------------- |
| `_requestId`| `uint256` | withdrawal request id returned by `requestWithdrawal` previously |
##### Function: `claimWithdrawal`
```solidity
function claimWithdrawal(uint256 _requestId) external;
```
Transfers withdrawn ether back to the `_requestId` recipient.
Emits `WithdrawalClaimed(_requestId, recipient, msg.sender)`.
- reverts if `_requestId` is invalid or already claimed
- reverts if `withdrawalRequestStatus(_requestId)` returns `isFinalized == false`
###### Parameters
| Name | Type | Description |
| ----------- | --------- | ---------------------------------------------------------------- |
| `_requestId`| `uint256` | withdrawal request id returned by `requestWithdrawal` previously |
##### Function: `handleOracleReport`
```solidity
function handleOracleReport(
// CL values
uint256 _beaconValidators,
uint256 _beaconBalance,
uint256 _totalExitedValidators,
uint256[] _stakingModuleIds,
uint256[] _nodeOperatorsWithExitedValidators,
uint256[] _exitedValidatorsNumbers,
// EL values
uint256 _wcBufferedEther,
// decision
uint256 _newDepositBufferWithdrawalsReserve,
uint256[] _requestIdToFinalizeUpTo,
uint256[] _finalizationPooledEtherAmount,
uint256[] _finalizationSharesAmount
) external;
```
Updates the protocol's state depending on the oracle-provided data.
Reports finalized withdrawals requests.
- reverts if sanity checks were not met (TODO)
- reverts if called by anyone except [`LidoOracle`](https://docs.lido.fi/contracts/lido-oracle)
- reverts if `_beaconValidators + _totalExitedValidators` is greater than ever deposited Lido validators
- emits ... TODO
###### Parameters
| Name | Type | Description |
| ------------------------------------- | ----------- | -------------------------------------------------- |
| `_beaconValidators` | `uint256` | number of Lido's keys in the beacon state |
| `_beaconBalance` | `uint256` | summarized balance of Lido-controlled keys in wei |
| `_totalExitedValidators` | `uint256` | aggregated number of Lido's ever exited validators |
| `_stakingModuleIds` | `uint256[]` | node operators modules indexes |
| `_nodeOperatorsWithExitedValidators` | `uint256[]` | node operators with exited validators (indexes) |
| `_exitedValidatorsNumber` | `uint256[]` | number of Lido's exited validators in the beacon state for each node operator (see above) |
| `_wcBufferedEther` | `uint256` | buffered withdrawal credentials balance |
| `_newWithdrawalsReservedAmount` | `uint256` | deposit buffer and EL rewards aggregated amount reserved for withdrawals till the next Oracle report |
| `_requestIdToFinalizeUpTo` | `uint256[]` | array of the request ids to finalize up to |
| `_finalizationSharePrice` | `uint256[]` | array of the request finalization stETH share prices |
#### Events
##### Event: `WithdrawalRequested`
```solidity
event WithdrawalRequested(
uint256 indexed requestId,
address indexed recipient,
uint256 amountOfStETH,
uint256 amountOfShares,
);
```
Emitted when a new withdrawal request placed.
See: `requestWithdrawal`.
##### Event: `WithdrawalClaimed`
```solidity
event WithdrawalClaimed(
uint256 indexed requestId,
address indexed recipient,
address initiator
);
```
Emitted when withdrawal request claimed and ether transferred to `recipient`.
See: `claimWithdrawal`.
### WithdrawalQueue
#### Public constants (immutables)
##### Constant: MIN_STETH_WITHDRAWAL_AMOUNT
```solidity
uint256 public constant MIN_STETH_WITHDRAWAL_AMOUNT = 0.1 ether;
```
Minimum stETH amount to request a withdrawal.
Used to prevent unbearable gas costs and primitive grieffing.
##### Constant: MIN_FINALIZATION_BLOCKS
```solidity
uint256 public constant MIN_FINALIZATION_BLOCKS = 10 * 32; /* 1 hour */
```
Minimum blocks number to finalize the withdrawal request.
##### Constant: MAX_STETH_WITHDRAWAL_AMOUNT
```solidity
uint256 public constant MAX_STETH_WITHDRAWAL_AMOUNT = 500 * 32 ether; /* 16 000 stETH */
```
Maximum stETH amount to request a withdrawal.
Used to prevent long hardly fillable requests
##### Immutable: LIDO
```solidity
address public immutable LIDO;
```
An explicit pointer to the `Lido` contract used for auth purposes.
#### Structs
##### Struct: Request
```solidity
struct Request {
address recipient;
uint96 requestBlockNumber;
uint128 etherAmount;
uint128 sharesAmount;
bool claimed;
}
```
The structure is used to represent the withdrawal request in the Lido withdrawals queue.
##### Struct: Request
#### Functions
##### Constructor
```solidity
constructor(address _owner);
```
Constructs the `WithdrawalQueue` instance by setting the `_lido` address.
- reverts if `_owner` is zero address
- reverts if `_owner` is not a contract
###### Parameters
| Name | Type | Description |
| ------- | --------- | --------------------------------------- |
| `_lido` | `address` | address of the deployed `Lido` contract |
##### Function: `enqueue`
```solidity
function enqueue(
address _recipient,
uint256 _etherAmount,
uint256 _sharesAmount
) external returns (uint256 requestId);
```
Place a request in a queue.
Returns an incremental unique position id within the queue.
- reverts if `msg.sender` is not `LIDO`
###### Parameters
| Name | Type | Description |
| ----------------- | --------- | ----------------------------------- |
| `_recipient` | `address` | address of the withdrawal recipient |
| `_etherAmount` | `uint256` | locked amount of stETH |
| `_sharesAmount` | `uint256` | locked amount of shares |
##### Function: `finalize`
```solidity
function finalize(
uint256 _lastIdToFinalize,
uint256 _totalPooledEther,
uint256 _totalShares
) external payable returns (uint256 sharesToBurn) {
```
Marks next requests in the queue finalized up to `_lastIdToFinalize` index.
Returns amount of shares to be burnt.
- reverts if `msg.sender` is not `LIDO` (TODO: why not Oracle?)
- reverts if `_lastIdToFinalize` is invalid
- reverts if `_lastIdToFinalize` is less then reported before
- reverts if the `_lastIdToFinalize` request was placed later than the `_finalizationBlack - MIN_FINALIZATION_BLOCKS` block
- reverts if not enough funds are passed to fulfill the requests
###### Parameters
| Name | Type | Description |
| ------------------------- | --------- | -------------------------------|
| `_lastIdToFinalize` | `uint256` | last id considered as finalized|
| `_totalPooledEther` | `uint256` | |
| `_totalShares` | `uint256` | |
##### Function: `claim`
```solidity
function claim(uint256 _requestId) external returns (address recipient);
```
Transfers locked ether to the `recipient` address (original withdrawal recipient associated with the request).
Permanently removes the `_requestId` request from the queue.
Returns the original withdrawal request `recipient` address.
###### Parameters
| Name | Type | Description |
| ---------------- | --------- | ---------------------------|
| `_requestId` | `uint256` | the withdrawal request id |
#### Events
##### Event: `WithdrawalsFinalized`
```solidity
event WithdrawalsFinalized(
uint256 indexed prevFinalizedRequestId,
uint256 indexed lastFinalizedRequestId,
uint256 finalizedBlock,
uint256 amountOfFinalizedShares,
uint256 amountOfFinalizedEther
);
```
Emitted when a bunch of withdrawal requests finalized.
See: `TODO`.
### LidoOracle
#### Functions
##### Function: `setReportAlgorithmVersion`
```solidity
function setReportAlgorithmVersion(uint256 _expectedVersion, uint256 _timestamp) external;
```
Sets expected Oracle algorithm version since the provided `timestamp`.
- reverts if called by anyone except `DAO Agent`
- reverts if `_timestamp < block.timestamp` (retroactive change)
- emits `ReportAlgorithmUpdated(_expectedVersion, _timestamp)`
##### Function: `getCurrentReportAlgorithmVersion`
```solidity
function getCurrentReportAlgorithmVersion() external view returns (uint256);
```
Retrieves the current Oracle algorithm version.
##### Function: `getExpectedReportAlgorithmVersion`
```solidity
function getExpectedReportAlgorithmVersion() external view returns (
uint256 expectedVersion,
uint256 timestamp
);
```
Retrieves the expected Oracle algorithm version.
Returns `timestamp == 0` and `expectedVersion == getCurrentReportAlgorithmVersion()`
if there is no an already assigned expected version.
#### Events
##### Event: `ReportAlgorithmUpdated`
```solidity
event ReportAlgorithmChanged(
uint256 indexed expectedVersion,
uint256 indexed activationTimestamp
);
```
### ValidatorsExitBusOracle
> Q: maybe separate `ValidatorsExitBus` and `ValidatorsExitBusOracle` where Oracle contains committe / batch reporting / merkalization logic and main contract bus logic. Or at least inherit one from another
> The committe is updated by DAO, don't want to do two updates if the committe is actually the same. Is it possible to change LidoOracle address (if go crazy %))
#### Functions
##### Constructor
```solidity
constructor(address _lidoOracle);
```
Initialize the instance with the provided `LidoOracle` address.
##### Function: `reportKeysToEject`
```solidity
function reportKeysToExit(
uint256[] calldata _stakingModuleIds,
uint256[] calldata _nodeOperatorIds,
bytes[] calldata _validatorPubkeys
) external;
```
Reports validators pubkeys to be exited next.
- reverts if `msg.sender` is not in `lidoOracle.getOracleMembers()`
- reverts if args arrays length doesn't match
- reverts if args arrays are empty
- emits `ValidatorExitRequested` (for each pubkey)
##### Function: `increaseUnexitedValidatorsNumber`
```solidity
function increaseUnexitedValidators(
uint256 _stakingModuleId,
uint256 _nodeOperatorId,
uint256 _incrementNumber
) external;
```
Reports validators pubkeys to be exited next.
- reverts if `msg.sender` is not in `lidoOracle.getOracleMembers()`
- reverts if
##### Function: `decreaseUnexitedValidators`
```solidity
function decreaseUnexitedValidatorsNumber(
uint256 _stakingModuleId,
uint256 _nodeOperatorId,
uint256 _decrementNumber
)
```
##### Function: `setExitingValidatorsLimit`
```solidity
function setExitingValidatorsLimit(
uint256 _maxExitingValidatorsLimit,
uint256 _exitingValidatorsIncreasePerBlock
);
```
Sets the exiting validators rate limit.
- reverts if `_maxExitingValidatorsLimit` == 0
- reverts if `_maxExitingValidatorsLimit` >= 2^64
- reverts if `_maxExitingValidatorsLimit` < `_exitingValidatorsIncreasePerBlock`
- reverts if `_maxExitingValidatorsLimit` / `_exitingValidatorsIncreasePerBlock` >= 2^32 (only if `_exitingValidatorsIncreasePerBlock` > 0)
##### Function: `getCurrentExitingValidatorsLimit`
```solidity
function getCurrentExitingValidatorsLimit() external view returns (uint256);
```
Returns how much validators can be reported to exit in the current block.
##### Function: `getExitingValidatorsLimitFullInfo`
```solidity
function getExitingValidatorsLimitFullInfo() external view returns (
uint256 currentExitingValidatorsLimit,
uint256 maxExitingValidatorsLimit,
uint256 prevExitingValidatorsLimit,
uint256 prevExitingReportBlockNumber
);
```
Returns full info about current validator exits limit params and state.
#### Events
##### Event: `ValidatorExitRequested`
```solidity
event ValidatorExitRequested(
uint256 indexed stakingModuleId;
uint256 indexed nodeOperatorId;
bytes validatorPubkey;
)
```
##### Event: `UnexitedValidatorsChanged`
```solidity
event UnexitedValidatorsChanged(
uint256 indexed stakingModuleId;
uint256 indexed nodeOperatorId;
uint256 unexitedValidators;
)
```
##### Event: `ExitingValidatorsLimitSet`
```solidity
event ExitingValidatorsLimitSet(
uint256 maxExitingValidatorsLimit,
uint256 exitingValidatorsIncreasePerBlock
)
```
## Slashing conditions
### Penalties socialization and accounting principles
Consensus layer specifications define the following types of the [slashing penalties](https://docs.prylabs.network/docs/how-prysm-works/validator-lifecycle#slashing-state):
- one-time minimum penalty when the misbehaving validator being caught
- midterm attack multiplier penalty
- missed attestation (inactivity) penalty on the beginning of each epoch until exited
```mermaid
gantt
title Ethereum slashing penalties timeline
axisFormat %d
section begin
Slashing started (epoch0) : milestone, m3, 2022-10-01,
section slashing
Slashing duration :crit, :a1, 2022-10-01, 36d
Minimum penalty (epoch0) : milestone, m1, 2022-10-01,
Midterm Attack Multiplier Penalty (epoch0 + 2^12): milestone, m2, 2022-10-19,
Missed attestation penalties (epoch0, ..., epoch0 + 2^13) :a2, 2022-10-01, 36d
section end
Slashing completed (epoch0 + 2^13) : milestone, m3, 2022-11-06,
```
Missed attestation penalties are significantly lower than previous two. The second penalty resolves only on the 2^12-th epoch from epoch of the detected slashing.
To socialize penalties with both long-term stakers staying in pool and those who want to withdraw, the withdrawal requests finalization is delayed if slashing started in the same day of withdrawal request or before all ongoing slashings midterm penalties would have been resolved.
These requests are prolonged up to 2^12 CL epochs at least to resolve the midterm attack multiplier penalty.
Up-to-date post-Merge missed attestation penalties for slashed validators:
```python
# for every `epoch` of missed attestation during the slashing
EFFECTIVE_BALANCE_INCREMENT = 10**9 # 1 Gwei
BASE_REWARD_FACTOR = 64
total_active_balance[epoch] = 15 * 10**6 * 10**9 # current estimation in Gwei
base_reward_per_increment[epoch] = Gwei(
EFFECTIVE_BALANCE_INCREMENT * BASE_REWARD_FACTOR
// integer_squareroot(total_active_balance)
)
increments[epoch] = effective_balance // EFFECTIVE_BALANCE_INCREMENT
base_reward[epoch] = Gwei(increments[epoch] * base_reward_per_increment[epoch])
WEIGHT_DENOMINATOR = 64
weight = TIMELY_SOURCE_WEIGHT + TIMELY_TARGET_WEIGHT = 14 + 26 = 40
epoch_penalty[epoch] = Gwei(base_reward[epoch] * weight // WEIGHT_DENOMINATOR)
```
To allow finalizing withdrawal requests right after the midterm attack multiplier penalty applied, could be estimated as follows:
```python
# start from the epoch of the applied midterm penalty
epoch = slashing_started_epoch + 2**12 + 1
# account for all remaining epochs
remaining_penalty = epoch_penalty[epoch] * 2**12
```
Expected missed attestation penalty for the current network conditions is ~10^4 Gwei per epoch (~0.0025 ETH daily, or ~0.1 ETH for the whole slashing period for sporadic slashing cases).
To calculate balances on-chain these penalties could be neglected as insignificant (they are diluted by the remaining in-protocol holders).
### Overlapped slashings cases
To prevent common griefing cases when one validator got slashed each ~2^12 epochs to prevent withdrawals requests finalization, it's decided to socialized penalties only for slashings registered in the day of request or already known but with unresolved midterm penalties.
See the diagram [above](#Slashing).
### New slashing events
If at least one new CL-wide slashing (i.e., even non-Lido) was registered before the reported epoch, then Oracle is allowed to finalize withdrawal request only using funds on the withdrawal credentials (without additional buffers) during the report.
This one addresses the following expectations:
- a new client version contains a bug leading to slashings and it is not propagated yet for Lido validators
- one of the NOs who also runs validators without Lido but within the same/similar environment got slashed and it is not propagated yet for Lido validators slice
CDP
### Timelock
There is an artificial timelock to pick up withdrawal requests happened only before some time till reported block N. The purpose is to ensure that all slashings got catched and reported (especially, for attesters) for the finalized epoch by Oracle. Timelock value is decided to be set as 10 epochs (~1 hour).
Another consideration to support timelock is having time margin to estimate and decide needed validator exits before the actual Oracle report.
The diagram below represents the timelock mechanics (for simplicity: no slashing events).
TODO: emphasize meaning of the diagram
maybe separate one ()
```mermaid
gantt
title Timelock
axisFormat %H:%M
dateFormat DD-HH-mm-ss
section begin
Previous oracle report (epoch0 + X) : milestone, m1, 01-12-00-00,1min
section requests allowed to finalize
Request 1 : milestone, m2, 01-10-50-00,1min
Request 2 : milestone, m3, 01-18-59-22,1min
Request 3 : milestone, m4, 02-10-20-00,1min
section timelock
Previous block N (epoch0): milestone, m5, 01-11-30-00,1min
New period start (epoch0 - 10): milestone, m6, 01-10-26-00,1min
Allowed period for the finalizable withdrawals requests on expected report : a1, 01-10-26-00,1d
New period end (epoch0 + 225 - 10): milestone, m7, 02-10-26-00,1min
Expected block N (epoch0 + 225): milestone, m10, 02-11-30-00,1min
section requests to carry over for the next period
Request 4 : milestone, m8, 02-10-30-00,1min
Request 5 : milestone, m9, 02-11-30-00,1min
section end
Expected oracle report (epoch0 + 225 + Y) : milestone, m4, 02-12-05-00,1min
```
## Security considerations
### Withdrawal requests limits
There are three limits introduced to mitigate possible overspending due to flooding and denial of service in general.
##### 1. Minimum stETH amount to withdraw per single request
The `WithdrawalQueue.MIN_STETH_WITHDRAWAL_AMOUNT` value prevents attempts to put tons of dust withdrawal requests since their execution are costly for protocol (in terms of oracles' gas spending).
##### 2. Maximum stETH amount to withdral per single request
The `WithdrawalQueue.MAX_STETH_WITHDRAWAL_AMOUNT` value prevents Lido's withdrawal queue clogging to serve only one huge withdrawal (since withdrawal requests are executed in the FIFO order).
##### 3. Validators exiting requests rate limit
The `ValidatorsExitBusOracle.getCurrentExitingValidatorsLimit()` is checked to prevent massive spurious validators exiting requests.
##### 4. Withdrawal request fulfillment timelock
The `WithdrawalQueue.MIN_FINALIZATION_BLOCKS` prevents slashing events upsides and short-term arbs.
##### 5. Pause/resume withdrawals on-chain
The `WithdrawalQueue.pauseWithdrawals` and `WithdrawalQueue.resumeWithdrawals` are intended to temprorary pause withdrawals in case of incidents. Paused state prevents finalizing of the non-finalized yet withdrawals requests.
### Oracle trust assumptions
Both of the proposed Oracles are trusted and depends on the committee's threshold quorum value. The 'quorum' word meaning follows [LIP-2](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-2.md#change-the-meaning-of-quorum).
Here is the list of additional sanity checks on top of [LIP-2](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-2.md#sanity-checks-the-oracles-reports-by-configurable-values):
- allowed exited validators relative increase
- allowed wc buffered ether (?)
- allowed request id to finalize up to
- allowed finalized share price
TODO: revisit existing sanity checks
### Upgradability
The following contracts are upgradable through the usual Lido DAO Aragon voting process:
- WithdrawalQueue
- Lido
- LidoOracle
- ValidatorsExitBusOracle
Off-chain oracle is upgradable by means of changing the expected version for the upcoming reports stored on-chain within `LidoOracle`, see [`LidoOracle.setReportAlgorithmVersion`](#Function-setReportAlgorithmVersion).
## Backward compatibility
The proposed upgrade breaks backward compatibility with the previous protocol state ([Merge-ready version](https://research.lido.fi/t/announcement-merge-ready-protocol-service-pack/2184)) by allowing withdrawals, changing Oracle data scheme, and changing accounting invariants. [Partial withdrawals](https://github.com/ethereum/consensus-specs/pull/2862) will change the previous [unlimited beacon balance increase invariant](https://github.com/lidofinance/lido-dao/blob/master/contracts/0.4.24/Lido.sol#L510) by redirecting extra validators rewards to the withdrwawal credentials address.
An ideal upgrade plan should fit to the following requirements
- R1: Start collecting and enqueueing withdrawal request in advance (before the Shanghai/Capella hardfork activated)
- R2: Allow huge beacon balances drop in once capella enabled due to first ~three-four days of the huge partial withdrawals (validators' balance exceeding `MAX_EFFECTIVE_BALANCE`(=32 ETH) will be withdrawn once Capella activated)
- R3: Start fulfilling withdrawal requests once Shanghai/Capella hardfork activated
There are three version of the off-chain oracle:
- OV0: pre-upgrade merge-ready version
- OV0-1: withdrawals-enabled version without withdrawals fulfillment (i.e., reports data according to the proposed scheme, but the last finalized withdrawal request id is always 0)
- OV1: withdrawals-enabled version with withdrawals fulfillment
An on-chain contracts set has two versions:
- C0: pre-upgrade merge-ready version (will report slashing once skimming enabled)
- C1: withdrawals-enabled version
Rough outline of the possible upgrade plan:
1. Deploy Oracle daemon allowing to conditionally report data (OV0 for C0 and OV0-1 for C1). API version is decided by reading the Lido contract version on-chain via [Lido Repo#getLatest()](https://etherscan.io/address/0xF5Dc67E54FC96F993CD06073f71ca732C1E654B1#readProxyContract#F14).
2. (*implements R1*). Deploy C1 smart contracts (impose API version switch) to start collecting withdrawal requests
3. (*implements R2*). Deploy Oracle daemon allowing to enable/disable withdrawals fulfillment (OV0-1 and OV1, default is OV0-1)
4. (*implements R3*). Run DAO voting to bump the oracle algorithm version with [`LidoOracle.setReportAlgorithmVersion(ov1, t_capella)`](#Function-setReportAlgorithmVersion) (e.g. enable fulfillment with OV1) starting from the provided timestamp `t_capella` (equals to the target Shanghai/Capella fork-choice timestamp).
## Test cases
### Base scenarios
- Protocol upgrade contract invariants
- Happy path from the forked state
- Simple slashing from the forked state
- Large withdrawal request
- Skipped oracle reports
- Large protocol rebase
### Math correctness
- Coverage application (don't account as rewards)
- Full withdrawal of the whole protocol
- Double accounting pre- and post- report
- Long-term simulation
- Redeemed amounts
### Stressed states
- Initial flood of skimmed rewards on Capella activation
- Overlapped slashing scenarios
- Flood of small withdrawal requests
- Large amount of keys to exit (Ethereum queue clogging)
### Gas consumption
- Gas usage to emit exiting keys
- Gas usage to fill out withdrawal requests
- Staker flow overall gas costs
### Failure modes
- Majority of validators refuse to exit
- Outstanding massive slashing
- No oracle reports for a long period
- Bank run due to market conditions
## References
* [Reference implementation (WIP)](https://github.com/lidofinance/lido-dao/pull/446)
* [LIP-2. Oracle contract upgrade v2](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-2.md)
* [LIP-6. In-protocol coverage application mechanism](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-6.md)
* [LIP-7. Composite oracle beacon report receiver](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-7.md)
* [LIP-10. Proxy initializations and LidoOracle upgrade](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-10.md)
* [LIP-12. On-chain part of the rewards distribution after the Merge](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-12.md)
* [LIP-14. Protocol safeguards. Staking rate limiting](https://github.com/lidofinance/lido-improvement-proposals/blob/develop/LIPS/lip-14.md)
* [Consensus Layer. Withdrawals tracking issue](https://github.com/ethereum/consensus-specs/issues/2758)
* [Consensus Layer `get_flag_index_deltas` (assign rewards and penalties)](https://eth2book.info/bellatrix/part3/helper/accessors#def_get_flag_index_deltas)
* [Prysm docs. Validator lifecycle](https://docs.prylabs.network/docs/how-prysm-works/validator-lifecycle)