Try   HackMD

Lido V2 on Goerli: a note for integrations

On March 24, Lido protocol on Goerli testnet has been successfully upgraded to V2.

This document is intended for Lido's existing partners and developers looking to understand the scope of the upgrade in terms of how their product interacts with Lido protocol. A high-level upgrade overview can be found in the blog post.

The code of the upgrade is open and available on Lido's GitHub. You can find all the changes in this PR.
The addresses of all the contracts deployed on Goerli are listed on the Lido's docs hub.
Lido UI has also been upgraded to V2 and is now being tested at https://stake.testnet.fi/

Deposit flow

No changes has been done to the deposit flow. All the addresses, calls, methods and events remain the same as before the protocol upgrade.

Subgraph and Reward History

Subgraph and Reward History on Goerli are not operational at the moment. The issue is being addressed, we expect the services to resume by end of April.

stETH shares

Shares mechanics explained in our integration guide remain the same as before the protocol upgrade.

Withdrawal flow

The biggest highlight of the upgrade is enabling withdrawals. The withdrawal flow is quite different from the deposit flow, unlike deposit flow, it is also async.
The process consists of the following steps:

  • user sends withdrawal request and receives in return an NFT representing the position of their request in the FIFO queue;
  • after some oracle reports their request becomes claimable (FIFO order holds here as well, no request can be skipped);
  • user claims their stETH, while the NFT gets burnt.

Request size should be at least 100 wei (in stETH) and at most 1000 stETH. Larger amounts should be withdrawn in multiple requests, which can be batched via in-protocol API. Once requested, withdrawal cannot be canceled. The withdrawal NFT can be transferred to a different address, and the new owner will be able to claim the requested withdrawal once finalized.

The amount of claimable ETH is determined once the withdrawal request is finalised. The rate stETH/ETH of the request finalisation can't get higher than it's been at the moment of request creation. the user will be able to claim:

  • normally – ETH amount corresponding to the stETH amount at the moment of the request's placement
    OR
  • lowered ETH amount corresponding to the oracle-reported share rate in case the protocol had undergone significant losses (slashings and penalties)

The second option is unlikely, and we haven't ever seen the conditions for it on mainnet so far.

See the detailed description of the withdrawal flow below.

The end-user contract to deal with the withdrawals is WithdrawalQueueERC721.sol, which implements the ERC721 standard. NFT represents the position in the withdrawal queue and may be claimed after finalization of the request.

The overview of the most frequent operations around the NFT queue.

  • Get the NFTs owned by the user (the not yet claimed ones)

    • Call to getWithdrawalRequests(address _owner) will return an array of NFT ids.
  • Get NFT asset metadata

    • Call tokenURI(uint256) as declared via ERC721 (Returns empty string currently. Subject to change in the future.)
  • Init withdrawal

    • stETH

      • Call requestWithdrawalsWithPermit(uint256[] calldata amounts, address _owner, PermitInput calldata _permit) and get the ids of created positions, where msg.sender will be used to transfer tokens from and the _owner will be the address that can claim or transfer NFT (defaults to msg.sender if it’s not provided.
        • Alternatively, spending stETH on behalf of WithdrawalQueueERC721.sol contract can be approved in a separate upfront transaction (stETH.approve(withdrawalQueueERC712.address, allowance)), and the requestWithdrawals(uint256[] calldata amounts, address _owner) method called afterwards.
    • wstETH

      • Call requestWithdrawalsWstETHWithPermit(uint256[] calldata amounts, address _owner, PermitInput calldata _permit) and get the ids of created positions, where msg.sender will be used to transfer tokens from, and the _owner will be the address that can claim or transfer NFT (defaults to msg.sender if it’s not provided.
        • Alternatively, spending wstETH on behalf of WithdrawalQueueERC721.sol contract can be approved in a separate upfront transaction (wstETH.approve(withdrawalQueueERC712.address, allowance)), and the requestWithdrawalsWstETH(uint256[] calldata amounts, address _owner) method called afterwards.

      PermitInput structure defined as follows:

      ​​​​​​​​struct PermitInput {
      ​​​​​​​​    uint256 value;
      ​​​​​​​​    uint256 deadline;
      ​​​​​​​​    uint8 v;
      ​​​​​​​​    bytes32 r;
      ​​​​​​​​    bytes32 s;
      ​​​​​​​​}
      
  • Get withdrawal request status (statuses refresh every ~4 hours on Goerli and once a day on mainnet)

    • Call getWithdrawalStatus(uint256[] calldata _requestIds) with ids of the user's positions and get the amount of stETH from the returned struct.
    ​​​​/// @notice output format struct for `_getWithdrawalStatus()` method
    ​​​​struct WithdrawalRequestStatus {
    ​​​​    /// @notice stETH token amount that was locked on withdrawal queue for this request
    ​​​​    uint256 amountOfStETH;
    ​​​​    /// @notice amount of stETH shares locked on withdrawal queue for this request
    ​​​​    uint256 amountOfShares;
    ​​​​    /// @notice address that can claim or transfer this request
    ​​​​    address owner;
    ​​​​    /// @notice timestamp of when the request was created, in seconds
    ​​​​    uint256 timestamp;
    ​​​​    /// @notice true, if request is finalized
    ​​​​    bool isFinalized;
    ​​​​    /// @notice true, if request is claimed. Request is claimable if (isFinalized && !isClaimed)
    ​​​​    bool isClaimed;
    ​​​​}
    

    NOTE: Since stETH is an essential token if the user tries to request a withdrawal using wstETH directly, the amount will be nominated in stETH on request creation.

  • Claim user's ETH

    • Transact to claimWithdrawal(uint256) with the NFT Id on behalf of the NFT owner.
  • It is also possible to claim withdrawals in batches, it's being done in two steps:

    • Call claimWithdrawals(uint256[] calldata _requestIDs, hints) where
      • hints = findCheckpointHints(uint256[] calldata _requestIDs, 1, lastCheckpoint)
      • lastCheckpoint = getLastCheckpointIndex()

There is an unlikely scenario of Lido protocol going into bunker mode in case of Lido's validators getting slashed. This will result in the withdrawals processing becoming significantly slower, but it isn't meant to interfere with any specific withdrawal flow elements. However, the amount of ETH claimed in this case might be lower than the amount requested in stETH.
The full withdrawal flow is expected to take from a few hours on Goerli or a single day on mainnet to up to several weeks (in case of the bunker mode on). In other words, the request finalization can't happen earlier than on the next Accounting Oracle report, but will take longer if there's not enough ETH in EL buffers, and validator exits will be triggered.

How to get APR

Lido on Ethereum V2 employs not one, but two Oracles: Accounting Oracle & Exit Bus. Former provides rebase data & finalizes withdrawal request, the latter β€” determines which validators to ask for exiting.
In order to retain backwards compatibility, original Lido Oracle contract is updated & renamed to Legacy Oracle. The data is updated, but the contract is deprecated and would be removed in the upgrades following V2.
Although the old way of calculating the APR would still result in relevant numbers, the math might be off in case of significant withdrawals.

How it was

NB: OUTDATED OLD FORMULA

protocolAPR = (postTotalPooledEther - preTotalPooledEther) * secondsInYear / (preTotalPooledEther * timeElapsed)
lidoFeeAsFraction = lidoFee / basisPoint
userAPR = protocolAPR * (1 - lidoFeeAsFraction)

How it is

New proper way:

from [1]:

// Emits when token rebased (total supply and/or total shares were changed)
event TokenRebased(
    uint256 indexed reportTimestamp,
    uint256 timeElapsed,
    uint256 preTotalShares,
    uint256 preTotalEther, /* preTotalPooledEther */
    uint256 postTotalShares,
    uint256 postTotalEther, /* postTotalPooledEther */
    uint256 sharesMintedAsFees /* fee part included in `postTotalShares` */
);

To calculated APR for the last completed oracle frame:

preShareRate = preTotalEther * 1e27 / preTotalShares
postShareRate = postTotalEther * 1e27 / postTotalShares

userAPR = 
    secondsInYear * (
        (postShareRate - preShareRate) / preShareRate
    ) / timeElapsed

So, the new formula takes into account preTotalShares and postTotalShares values, while, in contrast, the old formula didn't use them:
The new formula also doesn't require to calculate lidoFee at all (because fee distribution works by changing total shares amount under the hood).

Why does it matter

When Lido V2 protocol finalizes withdrawal requests, the Lido contract sends ether to WithdrawalQueue (excluding these funds from totalPooledEther, i.e., decreasing TVL) and assigns to burn underlying locked requests' stETH shares in return.

In other words, withdrawal finalization decreases both TVL and total shares.

Old formula isn't suitable anymore because it catches TVL changes, but skips total shares changes.

Illustrative example (using smallish numbers far from the real ones for simplicity):

preTotalEther = 1000 ETH
preTotalShares = 1000 * 10^18 // 1 share : 1 wei

postTotalEther = 990 ETH
postTotalShares = 950 * 10^18

timeElapsed = 24 * 60 * 60 // 1 day, or 86400 seconds

//!!! using the old (broken) method

// protocolAPR = (postTotalPooledEther - preTotalPooledEther) * secondsInYear / (preTotalPooledEther * timeElapsed)
protocolAPR = (990ETH - 1000ETH) * 31557600 / (1000ETH * 86400) = -3.6525
//lidoFeeAsFraction = lidoFee / basisPoint = 0.1
//userAPR = protocolAPR * (1 - lidoFeeAsFraction) = protocolAPR * (1 - 0.1)

userAPR = -3.6525 * (1 - 0.1) = -3.28725

//!!! i.e, userAPR now is ~minus 329%

//!!! using the new (proper) method

preShareRate = 1e27
postShareRate = 1042105263157894736842105263 // ~1e27 * 1.0421052631578946
userAPR = 31557600 * (42105263157894736842105263 / 1e27) / 86400 = 15.378947368421048

//!!! i.e., userAPR now is ~plus 1539%

APR shown on the Lido's UIs

Please note that the APR shown on Lido's UI is not expected to match the APR calculated on-chain. The one displayed on our websites is a 7-days rolling average APR.

Other notable changes