IPOR stETH Index

Lido conducts a rebase approximately once a day, generally at a fixed time. After the rebase, it becomes possible to determine the Annual Percentage Rate (APR) given that we understand how Lido's account balance is computed.

The LIDO documentation provides insights into calculating the staked Ethereum (StETH) account balance as:

balanceOf(account) = shares[account] * totalPooledEther / totalShares
  • Where:

    • shares - A map representing the share amount of individual users. Upon depositing ether, this ether gets converted into shares and is added to the user's existing share count.

    • totalShares- The collective sum of shares across all user accounts listed in the shares map.

    • totalPooledEther Represents the aggregate of three distinct types of ether held by the protocol:

      • buffered balance: Ether retained on the contract, neither deposited nor designated for withdrawals.

      • transient balance: Ether sent to the official Deposit contract but not yet acknowledged in the beacon state.

      • beacon balance: The accumulated amount of ether in validator accounts. This quantity, relayed by oracles, is pivotal in determining the stETH total supply adjustments.

For APR calculation, if we know the balance before and after the rebase, the equation is:

APR = 100% * 365 days * (beforeBalanceOf(account) - afterBalanceOf(account)) / beforeBalanceOf(account)

However, if a user's shares remain unchanged between rebases, the formula can be simplified by calculating exchange rates before and after

beforeStackingExchangeRate = beforeTotalPooledEther / beforeTotalShares
afterStackingExchangeRate = afterTotalPooledEther / afterTotalShares
APR = 100% * 365 days * (afterStackingExchangeRate - beforeStackingExchangeRate) / beforeStackingExchangeRate

For simplicity, we assume that the time between rebases was exactly 24 hours.

How to calculate the APR at any given moment between rebases?

To determine the APR at any specific time between rebases, we must ascertain the exchange rate at that particular moment. For this purpose, one can refer to the LIDO Oracle codebase and review the following method:

    def simulate_rebase_after_report(
        blockstamp: ReferenceBlockStamp,
        el_rewards: Wei,
    ) -> LidoReportRebase:
        To calculate how much withdrawal request protocol can finalize - needs finalization share rate after this report.
        validators_count, cl_balance = self._get_consensus_lido_state(blockstamp)

        chain_conf = self.get_chain_config(blockstamp)

        simulated_tx = self.w3.lido_contracts.lido.functions.handleOracleReport(
            # We use block timestamp, instead of slot timestamp,
            # because missed slot will break simulation contract logic
            # Details: https://github.com/lidofinance/lido-oracle/issues/291
            blockstamp.block_timestamp,  # _reportTimestamp
            self._get_slots_elapsed_from_last_report(blockstamp) * chain_conf.seconds_per_slot,  # _timeElapsed
            # CL values
            validators_count,  # _clValidators
            Web3.to_wei(cl_balance, 'gwei'),  # _clBalance
            # EL values
            self.w3.lido_contracts.get_withdrawal_balance(blockstamp),  # _withdrawalVaultBalance
            el_rewards,  # _elRewardsVaultBalance
            self.get_shares_to_burn(blockstamp),  # _sharesRequestedToBurn
            # Decision about withdrawals processing
            [],  # _lastFinalizableRequestId
            0,  # _simulatedShareRate

        logger.info({'msg': 'Simulate lido rebase for report.', 'value': simulated_tx.args})

        result = simulated_tx.call(
            transaction={'from': self.w3.lido_contracts.accounting_oracle.address},

        logger.info({'msg': 'Fetch simulated lido rebase for report.', 'value': result})

        return LidoReportRebase(*result)

Upon examining this method, it becomes evident that the crux of this simulation is the handleOracleReport function within the Lido contract. This function requires several arguments to execute the transaction.

    * @notice Updates accounting stats, collects EL rewards, and distributes collected rewards
    *         if beacon balance increased, performs withdrawal requests finalization
    * @dev periodically called by the AccountingOracle contract
    * @param _reportTimestamp the moment of the oracle report calculation
    * @param _timeElapsed seconds elapsed since the previous report calculation
    * @param _clValidators number of Lido validators on Consensus Layer
    * @param _clBalance sum of all Lido validators' balances on the Consensus Layer
    * @param _withdrawalVaultBalance withdrawal vault balance on Execution Layer at `_reportTimestamp`
    * @param _elRewardsVaultBalance elRewards vault balance on Execution Layer at `_reportTimestamp`
    * @param _sharesRequestedToBurn shares requested to burn through Burner at `_reportTimestamp`
    * @param _withdrawalFinalizationBatches the ascendingly-sorted array of withdrawal request IDs obtained by calling
    * WithdrawalQueue.calculateFinalizationBatches. Empty array means that no withdrawal requests should be finalized
    * @param _simulatedShareRate share rate that was simulated by oracle when the report data created (1e27 precision)
    * NB: `_simulatedShareRate` should be calculated off-chain by calling the method with `eth_call` JSON-RPC API
    * while passing empty `_withdrawalFinalizationBatches` and `_simulatedShareRate` == 0, plugging the returned values
    * to the following formula: `_simulatedShareRate = (postTotalPooledEther * 1e27) / postTotalShares`
    * @return postRebaseAmounts[0]: `postTotalPooledEther` amount of ether in the protocol after report
    * @return postRebaseAmounts[1]: `postTotalShares` amount of shares in the protocol after report
    * @return postRebaseAmounts[2]: `withdrawals` withdrawn from the withdrawals vault
    * @return postRebaseAmounts[3]: `elRewards` withdrawn from the execution layer rewards vault
    function handleOracleReport(
        // Oracle timings
        uint256 _reportTimestamp,
        uint256 _timeElapsed,
        // CL values
        uint256 _clValidators,
        uint256 _clBalance,
        // EL values
        uint256 _withdrawalVaultBalance,
        uint256 _elRewardsVaultBalance,
        uint256 _sharesRequestedToBurn,
        // Decision about withdrawals processing
        uint256[] _withdrawalFinalizationBatches,
        uint256 _simulatedShareRate
    ) external returns (uint256[4] postRebaseAmounts)

Upon examining the output of this method, we observe that it provides two values that are particularly interesting to us:

* @return postRebaseAmounts[0]: `postTotalPooledEther` amount of ether in the protocol after report
* @return postRebaseAmounts[1]: `postTotalShares` amount of shares in the protocol after report

If we divide these values, then we have the exchange rate at any point in time:

exchangeRate = postTotalPooledEther / postTotalShares

Calculating the 24-hour Window APR

When trying to calculate the APR over 24 hours based on exchange rates:

  1. Obtain Current Exchange Rate: Secure the exchange rate at the given time between rebases.

  2. Secure Historical Data: Ensure we have data that dates back to at least more than 24 hours prior.

  3. Determine the Exchange Rate from 24 hours ago: Retrieve the exchange rate from precisely 24 hours ago using the historical data.

  4. Calculate Current APR: We can now determine the APR with the current and 24-hour-old exchange rates at our disposal. The formula is:

currentAPR = 100 * 365 * (currentExchangeRate - exchangeRate24HoursAgo) /  exchangeRate24HoursAgo

Gathering Arguments for the handleOracleReport method

reportTimestamp - This refers to the timestamp of the block for which we want to determine the exchange rate

timeElapsed - Represents the duration from the last report to the desired time point. It can be easily computed using the equation:

timeElapsed = reportTimestamp - lastReportTimestamp

clValidators - Represents the total count of Lido validators currently on the Consensus Layer.

clBalance - Refers to the aggregate balance of all Lido validators on the Consensus Layer.

To retrieve these values:

  • Make a call to the Lido Keys API. Running a local instance of this API can be much faster than accessing the public one. The repository for the API can be found at Lido Keys API GitHub.

  • Additionally, initiate a call to the Beacon Chain to fetch the list of validators. By comparing this list with the one from the Lido Keys, we can determine which validators are associated with Lido for a specific block.

  • clValidators - Count the number of Lido-associated validators.

  • clBalance - Aggregate the balances of these Lido validators

withdrawalVaultBalance - This indicates the balance of the withdrawal vault on the Execution Layer at the given reportTimestamp

One must query the Ethereum balance of the WithdrawalVault contract on the Execution Layer.

elRewardsVaultBalance - vault balance on Execution Layer at reportTimestamp

One must query the Ethereum balance of the LidoExecutionLayerRewardsVault contract on the Execution Layer.

sharesRequestedToBurn - shares requested to burn through Burner at reportTimestamp.

A call should be made to the getSharesRequestedToBurn method of the Lido Burner contract. This method produces two output values: coverShares and nonCoverShares. The cumulative sum of these two outputs provides the sharesRequestedToBurn value."

withdrawalFinalizationBatches - This is an array of withdrawal request IDs organized in ascending order.

  1. It is derived by calling the calculateFinalizationBatches method of the WithdrawalQueue contract.

  2. Within a simulation context, an empty array indicates no withdrawal requests pending finalization.

simulatedShareRate - This refers to the share rate that Oracle projected when the report data was established. When conducting a simulation, the oracle assigns this parameter a value of 0.

Last updated