How To Obtain Historical Cryptocurrency Price Data Using Chainlink Price Feeds

Fetching historical cryptocurrency price data into a smart contract is a common requirement of Web3 applications, with many protocols relying on high-quality, up-to-date price data for operating and securing DeFi applications. In addition to this, historical crypto price data is also a requirement sometimes raised by smart contract developers, with some protocols needing accurate, high-quality historical price data in addition to the latest up-to-date prices.

In this technical article, we’ll show you a working solution on how you can obtain historical price data from Chainlink Price Feeds and verify the result on-chain.

The Need for Accurate Historical Crypto Price Data

The past few years have seen explosive growth in DeFi. One common requirement for DeFi protocols is highly secure, accurate, and reliable price data. Chainlink Price Feeds have become the most widely used price oracles in the DeFi ecosystem, already securing tens of billions of dollars in value for leading and emerging protocols such as Aave, Synthetix, and Trader Joe

The most common use case for Price Feeds is consuming up-to-date price data for a given asset pair. However, sometimes a DeFi protocol or dApp has a requirement to look up the historical price of a given asset at a certain point in time. An example of this use case is a financial product that requires comparing prices across various periods in time, such as cryptocurrency insurance, which requires historical data to calculate market volatility and dynamically adjust premiums.

Historical price data can already easily be obtained via numerous market APIs, then consumed on-chain using the Chainlink Any API functionality. However, this solution presents some security considerations—the data source and oracle delivering the data may be centralized, with no way to verify whether the data is accurate. Like real-time price data, historical price data should also be decentralized, and have sufficient market coverage to reflect market-wide price discovery at the time. This entails not using a single data source, exchange, or API.

Chainlink Price Feeds mitigate these issues by offering decentralized, high-quality price data from premium data providers, aggregated using a volume-weighted average price. When specifically looking at historical price data, Price Feeds help give users confidence that the reported price is accurate, and reflects the price across the entire market at that point in time, and not just a single exchange.

There are already solutions that can be leveraged to obtain historical Chainlink Price Feed data, such as Pier Two’s API or a Subgraph on The Graph. While these are valid solutions, they still rely on either an API being available or off-chain indexed data being correct. 

In this solution, we’ll show how you can obtain historical price data from Chainlink Price Feeds using a Chainlink oracle to perform the required off-chain computation, which serves as an input for the consuming smart contract to obtain the historical price data on-chain in a trust-minimized way.

Price Feed Contracts Overview

From a consuming contract point of view, Chainlink Price Feeds smart contracts can generally be split into two categories—proxy contracts and aggregator contracts

The proxy contract represents a particular price pair (such as ETH/USD) and is the contract that users interact with. This contract contains various external facing functions to obtain round data based on certain parameters, such as obtaining the latest round details against the price pair or obtaining an answer at a particular round.

These proxy contracts also store a reference to all of the underlying aggregator contracts for the particular proxy contract, as well as which one the current aggregator contract is. This information can be obtained via the aggregator and the phaseAggregators getter functions, passing in a phase ID. In the example below, we can see that the second aggregator contract (phase ID = 2) for the Kovan ETH/USD proxy contract is the current one.

Screenshot of the Kovan ETH/USD Proxy contract
Obtaining the second aggregator in the Kovan ETH/USD proxy contract.
Screenshot of obtaining the current aggregator in the Kovan ETH/USD Proxy contract.
Obtaining the current aggregator in the Kovan ETH/USD proxy contract.

The underlying aggregator contracts have slightly different implementations depending on which version they are (FluxAggregator, legacy Aggregator, etc.), but they all store aggregated round data and provide functions for oracles to submit answers to.

Diagram of Chainlink Price Feeds' contracts overview
Chainlink Price Feeds contracts overview.

Solution Overview

Chainlink Price Feeds price data is stored on-chain, and developers can execute the getLatestRoundData function to obtain the latest price data for a price pair. However, obtaining historical price data from Price Feeds is not as straightforward and involves some complexities.

Chainlink Price Feeds store price data within aggregation rounds, which are identified by their unique round ID. A new round is initiated when the price deviates greater than the specified deviation threshold for the price pair, or when the heartbeat threshold expires. A developer can easily run the getHistoricalPrice function to look up a historical price if they know the correct round ID, however, there is no direct correlation between a round ID and a timestamp, block, or anything else that can be used to determine a point in time.

Additionally, there are multiple different versions of Chainlink Price Feed aggregator contracts, such as the FluxAggregator and the Off-Chain Reporting OffchainAggregator contract. A price feed could have been using a FluxAggregator contract for a certain period of time, and then got updated to an OCR-compatible OffchainAggregator contract. This means that depending on what point in time a consuming contract wants to obtain historical price data from, the solution needs to be able to handle looking at multiple different aggregator contracts.

In addition to this, round IDs between the external facing proxy contracts and the underlying aggregator contracts are purposely phased, with the proxy contract rounds always being a much larger number than the aggregator round IDs. This is because the external facing round ID always needs to be an incrementing number, whereas the underlying aggregator round IDs can potentially start from 1 again when a new aggregator contract is deployed. The round ID of the external proxy contract for a given internal aggregator round ID can deterministically be determined via the following calculation, where phase is the aggregator contract deployed (first, second, third, etc.) against the proxy contract, and originalRoundId is the round of the underlying aggregator contract. You can determine the phase by calling the phaseAggregators getter method against the proxy contract.

external proxy round ID = uint80(uint256(phase) << 64 | originalRoundId);

Finally, not all rounds can be assumed to have price data. Some rounds (mainly on testnets) may have zero values for price and timestamp fields due to time-outs or environment-specific issues at the time.

Due to these complexities, it becomes clear that obtaining accurate and verifiable historical price data purely on-chain is not easily achievable without an inefficient solution, such as doing a large amount of iterating over round data or by reading and storing a mapping of rounds and timestamps on-chain, which would get very expensive.

In addition to giving smart contracts access to off-chain data and events, the Chainlink decentralized oracle network provides a generalized framework for performing off-chain computation. This means that rather than storing a large amount of data on-chain or doing iterations over an unknown number of rounds, an on-chain smart contract wanting historical price data can leverage an oracle running a custom external adapter to determine what the correct round ID is for a given price pair at a specified date. The oracle can then return the round ID back on-chain, and the consuming contract can use that information to immediately verify the historical price data on-chain using the existing historical price data API, comparing timestamps against the returned round with the search parameter timestamp. 

This solution is consistent with the notion that an oracle is responsible for everything a blockchain is not responsible for, as well as anything that a blockchain can’t or shouldn’t do due to capability limitations or efficiency considerations. In addition to this, there are multiple additional benefits of obtaining historical price data this way:

  • There’s no storing of a large amount of data on-chain or doing heavy iterating over round data on-chain
  • The result of the off-chain computation is verified on-chain using the existing historical price function, mitigating the risk of a malicious oracle providing incorrect data
  • The external adapter acts in a stateless manner, never storing data and providing a generalized approach for obtaining historical price data that any node operator can run on their Chainlink node
  • The external adapter works purely with on-chain data, without relying on external APIs or other systems to be available to provide a solution
Diagram of using External Adapters with on-chain smart contracts
Using External Adapters with on-chain smart contracts.

How To Get Historical Cryptocurrency Price Data Using a Chainlink Oracle

Creating The Request for Historical Price Data

To initiate a request for historical price data, a consuming contract simply submits an API request to an oracle that’s running the custom historical price external adapter in a job specification, passing in a valid proxy contract address, and the timestamp (in Unix time) for which to return price data for.

 function getHistoricalPrice(address _proxyAddress, uint _unixTime) public returns (bytes32
requestId)
    {

        Chainlink.Request memory request = buildChainlinkRequest(jobId, address(this), 
this.singleResponseFulfill.selector);

        // Set the URL to perform the GET request on
        request.add("proxyAddress", addressToString(_proxyAddress));
        request.add("unixDateTime", uint2str(_unixTime));

        //set the timestamp being searched, we will use it for verification after
        searchTimestamp = _unixTime;

        //reset any previous values
        answerRound = 0;
        previousRound = 0;
        nextRound = 0;
        nextPrice = 0;
        nextPriceTimestamp = 0;
        previousPrice = 0;
        previousPriceTimestamp = 0;
        priceAnswer = 0;
        priceTimestamp = 0;

        // Sends the request
        return sendChainlinkRequestTo(oracle, request, fee);
    }

The Historical Price External Adapter

Once the Chainlink node receives this request, it sends the input data to the historical price external adapter, which will find out the correct round ID that contains the answer for the timestamp being searched, as well as the round IDs that proceed and follow it. These two extra round IDs are required for verification purposes, so we can verify that the returned round ID answer is the closest to the timestamp being searched, where the round’s updatedAt timestamp is less than the search timestamp parameter. 

Once the external adapter receives the proxy contract address and the search timestamp parameter, it performs the following computation: 

  • Perform some validation on the passed-in address and timestamp
  • Determine which underlying aggregator contract will contain the round at the specified timestamp
  • For the resulting aggregator contract, get a list of all the round IDs stored in the aggregator (using eth_getlogs)
  • Using the list of returned round IDs, perform a binary search over the list to find the correct round ID for the timestamp being searched. Time-efficiency of this approach is O(logN) compared to a less efficient linear search, which has a time efficiency of O(N)
  • If any rounds are found with null or invalid data (due to time-outs etc), the algorithm will determine how many rounds null or invalid data is present leading up to, and after the round is inspected by the binary search, it will then strip all of the invalid rounds out, and start a new binary search on the new list of rounds
  • When a resulting round ID is found, the external adapter will use that round ID, along with the previous and next round ID in the aggregator contract, calculate the phased proxy contract round IDs for each of the three values, and then return them in the adapter output under the elements roundAnswer, earlierRoundAnswer, and laterRoundAnswer
  • The oracle processing the request for historical price data will take these three results and return them on-chain to the consuming contract using Chainlink’s multi-variable response feature
{
    "jobRunID": "534ea675a9524e8e834585b00368b178",
    "data": {
        "roundAnswer": "36893488147419111519",
        "earlierRoundAnswer": "36893488147419111518",
        "laterRoundAnswer": "36893488147419111520"
    },
    "result": null,
    "statusCode": 200
}

Verifying The Result On-chain

Using a centralized data source or oracle for historical price data creates a potential security vulnerability for a smart contract. However, in this case, for a reported round ID from an oracle, we can leverage the existing historical price data function to verify the round ID on-chain, creating a trust minimized verification method for obtaining historical price data, as the data is still being obtained on-chain from the Price Feed contracts with just the unknown round ID being calculated and returned by the oracle.

We can verify the returned round information and then use it to obtain the final resulting historical price with the following validations:

First, we verify all three returned rounds (answerRound, previousRound, nextRound) all contain valid returned round data

//verify the responses
        //first get back the responses for each round
        (
            uint80 id,
            int price,
            uint startedAt,
            uint timeStamp,
            uint80 answeredInRound
        ) = priceFeed.getRoundData(_answerRound);
        require(timeStamp > 0, "Round not complete");
        priceAnswer = price;
        priceTimestamp = timeStamp;

        (
            id,
            price,
            startedAt,
            timeStamp,
            answeredInRound
        ) = priceFeed.getRoundData(_previousRound);
        require(timeStamp > 0, "Round not complete");
        previousPrice = price;
        previousPriceTimestamp = timeStamp;

        (
            id,
            price,
            startedAt,
            timeStamp,
            answeredInRound
        ) = priceFeed.getRoundData(_previousRound);
        require(timeStamp > 0, "Round not complete");
        nextPrice = price;
        nextPriceTimestamp = timeStamp;

Next, we perform some validation on the rounds, and the timestamps for each of them.

  • Ensure ordering of the rounds is correct
  • Ensure timestamps of the three rounds are all in correct chronological order
  • If there are any gaps in round numbers (ie previousRound = 1625097600 and answerRound = 1625097605), ensure that there’s no valid round data for any roundIDs in between the returned round IDs. So for this example of previousRound = 1625097600 and answerRound = 1625097605, the contract would ensure that rounds 1625097601, 1625097602, 1625097603, and 1625097604 don’t return valid round data
​​//first, make sure order of rounds is correct
        require(previousPriceTimestamp < timeStamp, "Previous price timetamp must be < answer
timestamp");
        require(timeStamp < nextPriceTimestamp, "Answer timetamp must be < next round
timestamp");

        //next, make sure prev round is before timestamp that was searched, and next round is
after
        require(previousPriceTimestamp < searchTimestamp, "Previous price timetamp must be < 
search timestamp");
        require(searchTimestamp < nextPriceTimestamp, "Search timetamp must be < next round 
timestamp");
require(priceTimestamp <= searchTimestamp, "Answer timetamp must be less than or equal to 
searchTimestamp timestamp");

        //check if gaps in round numbers, and if so, ensure there's no valid data in between
        if (answerRound - previousRound > 1) {
            for (uint80 i= previousRound; i<answerRound; i++) {
                (uint80 id,
                int price,
                uint startedAt,
                uint timeStamp,
                uint80 answeredInRound
                ) = priceFeed.getRoundData(i);
                require(timeStamp == 0, "Missing Round Data");
            }
        }

        if (nextRound - answerRound > 1) {
            for (uint80 i= answerRound; i<nextRound; i++) {
                (uint80 id,
                int price,
                uint startedAt,
                uint timeStamp,
                uint80 answeredInRound
                ) = priceFeed.getRoundData(i);
                require(timeStamp == 0, "Missing Round Data");
            }
        }

If all of the checks above have successfully passed, then the price answer at the returned round (answerRound) is the verified price of the price pair at the specified timestamp.

Summary

Chainlink Price Feeds provide a reliable way to get high-quality price data into Solidity smart contracts. In addition to this, Chainlink’s oracle framework provides the ability to perform off-chain computation, allowing developers to access historical price data in a highly secure, trust-minimized, and verifiable way. 

If you’re a developer and want to connect your smart contract to existing data and infrastructure outside the underlying blockchain, reach out here or visit the developer documentation.

More on This Topic

Need Integration Support?
Talk to an expert
Faucets
Get testnet tokens
Read the Docs
Technical documentation