Yield Oracle

Overview


A unique characteristic of the Ion money market is that borrowers are charged interest rates to varying degrees based on the risk profile of their collateral asset.

In order to calculate the most accurate and live interest rates for our various types of collateral, we need to know each yield-bearing collateral’s estimated APY (annual percentage yield) for the following year. We use the standard formula to calculate APY:

APY=IpP=(ERtERt0)365tt0APY = I_p * P = (ER_{t} - ER_{t_0}) * \frac{365}{t - t_0}

Here, I_p and P represents the “periodic interest” and “number of periods in one year,” respectively. Periodic interest is simply the amount of yield accrued over a single period, and multiplying this by the number of periods in the year logically leads to annualized yield.

Design Decisions


How Interest is Tracked

We can track the interest accrued over a period by viewing how the exchange rate ER changes (and thus is parametrized by time). Specifically, we define periodic interest as

ERtERt0ER_t - ER_{t_0}

where we subtract the current exchange rate at time t as compared to the initial exchange rate at the beginning of our observation window t_0.

Determining the Period

Now, the only thing left is to define the length of the period

tt0t - t_0

which we will refer to as “look-back” days. We extract this value by looking at historical exchange rates over protocol lifetimes, backtesting a range of potential periods against mainnet data to observe what lookback period is most sensible and closest to the actual observed APY.

This is only possible with protocols that have been around for at least one year, so instead we looked at all calculated APYs over the available data to determine what number of look-back days allows for a healthy amount of volatility while also hugging a sensible APY value that corresponds to the protocol’s publicly-reported APY.

In the Ion market, having a shorter period would allow the lenders to capture more upside when yield rates increase, and also lose yield for lenders when yield rates decrease. Having a longer period would give lenders a more steady averaged-out income.

Because the staking yield has historically performed in an increasing trend, we chose 7 days as a reasonable balance between volatility and steady yields.

Invariant

An invariant to mention is that as long as the protocol continues to earn yield on its staked assets, the exchange rate should never decline (i.e. it is monotonically nondecreasing), but if the protocol begins to lose its staked assets (due to mass slashing, validator downtime, etc.), then the calculated yield would be negative, and thus we want to place an implicit bound on the APY to be non-negative (as an invariant). Lastly, we can find the number of periods in a year by dividing the number of days in a year (365) by the number of days elapsed over the period (t - t_0) .

Updating Contract State

The only goal of this contract should be to update and store the most recent APY estimate for each protocol we support. We designed the contract around the following invariants/requirements:

  1. APY should be recalculated daily based on the newest posted exchange rate from the protocol’s smart contracts.

  2. We want each protocol to use the same number of look back days (observation window) in determining the length of their period to be equitable to everyone.

  3. We believe the exchange rates that protocols post to their public smart contracts are accurate reflections of each protocol’s balances (i.e. we will not verify those exchange rates via an external service).

For Non-Interest Bearing Collaterals (YieldOracleNull)

In the case that an instance of IonPool only supports non-yield-bearing collaterals, the interest rate module should always trigger the minimum borrow rate. For this, we have a YieldOracle that always returns 0. The code is very simple:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.21;

import { IYieldOracle } from "./interfaces/IYieldOracle.sol";

contract YieldOracleNull is IYieldOracle {
    function apys(uint256) external pure returns (uint32) {
        return 0;
    }
}

The minimum borrow rate can then be parameterized as a standard two-slope curve and the adjusted curve can be ignored altogether.

Methodology


  1. The contract will essentially store the updated APY for each protocol in a public state variable that should always be accessible. There should be an update function that is called exactly once a day (or whatever the decided sync schedule dictates) that will fetch the most recent exchange rates from our providers and will use stored historical exchange rates to calculate the newest APY. Then, it will store the exchange rates internally to be used in a future APY calculation (i.e. it will serve as ER at t_0 at some point in the future).

  2. To efficiently store the exchange rates, we will use a circular buffer implemented via a Solidity array that will store the past X historical exchange rates where X is defined by the number of look-back days. This comes from the idea that on day t, we only need information about the exchange rate at t_0 (and the live exchange rate) to calculate the APY.

  3. After we calculate the APY for that day, then any future APY calculations will not require ER at t_0 and thus we can overwrite those values with today’s exchange rate information. Then tomorrow, we will fetch the historical exchange rate on day t_0 + 1, consume it to update APY for tomorrow, and overwrite the exchange rates with tomorrow’s exchange rates. Thus, we can use a buffer whose size is equivalent to the number of look-back days.

  4. Lastly, we want to simply update all APYs at once, but if a protocol reports an invalid exchange rate, this would lead to the update failing. This is OK since the protocol will continue to operate on the previous APY and staking APYs tend to be quite non-volatile. However, we will be closely monitoring the success of these transactions and will be troubleshooting (and pausing markets), if necessary.

Contract Architecture


Contract: YieldOracle.sol

State Variables:

  • apys: stores the most updated APYs for each protocol.

  • historicalExchangeRates: stored exchange rates in circular buffer

  • currentIndex: current index in historicalExchangeRates to read values from to calculate the next APY

Public Contract Functions

  • function updateAll() external

    • fetches newest exchange rate data from protocols

    • retrieves historical exchange rates from state variable

    • calculates and updates apys with newest APYs

    • updates the state variables accordingly (new exchange rate and increment the current index, set the lastUpdated to current block timestamp)

Deployment


Validity Checks

  • In the APYOracle Deployment script, it needs to retrieve the correct ordering of the collaterals for how collateralIds get mapped to collateral names.

  • This will likely come from our Interest Rates module.

Scrape Past Historical Exchange Rates

  • When we deploy the oracle, we need to ensure that we initialize historicalExchangeRates with the previous X days of exchange rates (where X is equivalent to the number of look back days).

  • We can accomplish this by writing a script that queries an archive node via RPC using Typescript, running it with FFI, and feeding its results into our Solidity deployment script.

Last updated