Smart Contract Security Guidelines #3: The Dangers of Price Oracles

This guide focuses on showcasing the architecture, roles and subtleties of most popular price oracles in Ethereum, with ways to safely integrate them with defensive programming practices. This content was originally part of a workshop recorded on the 26th August 2021 and led by Martin Abbatemarco, Security Researcher at OpenZeppelin. It is the third workshop in a series that covers multiple topics around secure development of smart contracts. Click here to watch the original workshop recording and here to view the slides

In this guide we cover:

  • The need for price oracles in DeFi
  • Architecture of popular smart-contract-based price oracles in the space: ChainLink, the Open Price Feed, Uniswap v2 & v3, and Maker Oracles
  • Security risks and considerations associated with each oracle
  • Must-have and nice-to-have safe guards when integrating price oracles in smart contracts

The Need for Price Oracles in Smart Contracts

Price oracles are undoubtedly a critical piece of infrastructure in the DeFi Ethereum ecosystem. For example, oracles are extensively used in lending protocols implementing patterns such as the overcollateralized loan.

Serious oracle failures can put billions of dollars deposited in DeFi contracts at risk. We realize these risks are concentrated when considering that the ever-increasing number of DeFi projects almost always rely on a small set of price oracles. Failure in any one of these price oracles could lead to a devastating domino effect felt across the entire ecosystem. So, safely integrating with reliable and secure price oracles is fundamental for the success of DeFi.

As a primer, we use the following code snippet to learn what kind of questions and considerations should be taken into account when reviewing a simple price oracle integration contract.

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

interface IOracle {
    function getPrice(IERC20 token) external returns (uint256);
}

contract Example is Ownable {

    IERC20 public immutable token;
    IOracle public oracle;
    mapping(address => uint256) public deposits;

    constructor (address tokenAddress, address oracleAddress) {
        token = IERC20(tokenAddress);
        oracle = IOracle(oracleAddress);
    }

    function setOracle(address oracleAddress) external onlyOwner {
        oracle = IOracle(oracleAddress);
    }

    /// @notice Allows taking out tokens by first depositing twice their value in ETH
    /// @param amount amount of tokens to be taken
    function borrow(uint256 amount) external payable {

        uint256 depositRequired = amount * oracle.getPrice(token) * 2;
        
        require(msg.value == depositRequired, "Bad");
        
        token.transfer(msg.sender, amount);

        deposits[msg.sender] += depositRequired;
    }

    // [...]
}

Some initial questions you should ask:

  • Why doesn’t the contract emit events?
  • Does the lack of docstrings prevent you from understanding the code’s intention?
  • Could the unfriendly error message cause problems during testing and debugging?
  • Given the learnings in “The Dangers of Token Integration” session, what kind of tokens is the contract supposed to work with?
  • Would a malicious token open a reentrancy-based attack vector?
  • There’s a function with access controls. Given the learnings in the “Strategies for Secure Access Controls” session, what’s behind the onlyOwner modifier? Are that account’s powers documented? What are the risks if ownership is compromised?

Some considerations referring specifically to the line of Solidity code where the price oracle is queried:

  • What if the price returned is zero? What if it is absurdly large?
  • What is the price’s unit? What if the price is inverted?
  • How many decimals does the price have?
  • How is the price calculated in the oracle?
  • Can you actually read the price on-chain? Or is there any prior authorization required?
  • How is the price updated in the oracle? How frequently? Are there any delays?
  • What if the price of the oracle cannot be updated?
  • Can the oracle’s logic be upgraded? Who can do it? How? When?
  • Are there privileged roles in the oracle? What are their powers?
  • Can the price oracle be manipulated? Can it be shut down? If so, how? Who can do it? When?
  • In case everything fais, are there fallback oracles to use as safety nets?

By reflecting on these and similar questions, development teams can build more robust and comprehensive threat models around price oracle integrations.

Popular Alternatives for Price Oracles in Ethereum

Price oracles come in different flavors – there is no silver bullet. The most renowned names in the space at this point are ChainLink, the Open Price Feed, Uniswap time-weighted average prices, and Maker Oracles. Each oracle operates uniquely, offering different value propositions, strengths, and weaknesses.

The intention of this guide is not to vouch for any specific price oracle. Instead, we show the available landscape of potential solutions from an impartial security standpoint. Our goal is to provide developers with insightful information so they can better assess risks and make informed decisions stemming from our own security research on the topic.


ChainLink Price Feeds

When using the latest version of ChainLink Price Feeds from your smart contracts, you query what is currently called an EACAggregatorProxy contract. Its role is to expose the necessary functionality to read price-related data of supported assets. In turn the EACAggregatorProxy will communicate with the AccessControlledOffchainAggregator contract, which holds the actual logic to receive, calculate and store prices. These prices are then submitted in a single signed report by a set of privileged actors in an off-chain p2p network aggregating data from multiple sources and following a consensus mechanism to agree on each asset’s price.

Usually there is one pair of these contracts for each supported asset in ChainLink price feeds. It is worth noting that deployed contracts looking to read prices cannot do so from the AccessControlledOffchainAggregator contracts, the corresponding EACAggregatorProxy contracts must always be queried.

While currently there’s no whitelisting mechanism to allow or disallow contracts from reading prices, powerful multisigs can tighten these access controls. In other words, the multisigs can immediately block access to price feeds at will. Therefore, to prevent denial of service scenarios, it is recommended to query ChainLink price feeds using a defensive approach with Solidity’s try/catch structure. In this way, if the call to the price feed fails, the caller contract is still in control and can handle any errors safely and explicitly.

Below is a snippet of code where the price feed’s latestRoundData function is queried. Instead of calling it directly, we surround it with try/catch. In a scenario where the call reverts, the catch block can be used to explicitly revert, call a fallback oracle, or handle the error in any way suitable for the contract’s logic.

function getPrice(address priceFeedAddress) external view returns (int256) {
        try AggregatorV3Interface(priceFeedAddress).latestRoundData() returns (
            uint80,         // roundID
            int256 price,   // price
            uint256,        // startedAt
            uint256,        // timestamp
            uint80          // answeredInRound
        ) {
            return price;
        } catch Error(string memory) {            
            // handle failure here:
            // revert, call propietary fallback oracle, fetch from another 3rd-party oracle, etc.
        }
    }

A similar approach can be followed when calling the latestAnswer function of the price feed, available in a previous interface of the contract. While this function is deprecated, there are plenty of places where it is still deployed. One notable behavior of this function to check for is that it can return a price of zero. Similar considerations as in the previous example apply for the use of try/catch in this case.

function getPrice(address priceFeedAddress) external view returns (int256) {
    try AggregatorV2V3Interface(priceFeedAddress).latestAnswer() returns (int256 price) {
        if(price > 0) {
            return price;
        } else {
            // `latestAnswer` is a deprecated method to read prices, yet still used in the wild.
            // It can return zero under certain circumstances, so integrations should handle this case.
            // Either with revert, call propietary fallback oracle, fetch from another 3rd-party oracle, etc.
        }            
    } catch Error(string memory) {
        // handle failure here
        // revert, call propietary fallback oracle, fetch from another 3rd-party oracle, etc.
    }
}

Regardless of specific examples, the following is a summary of recommendations and notes to keep in mind when interacting with ChainLink price feeds. Many of these recommendations hold for other oracles as well.

  • Do not assume systems are decentralized and permissionless. Better to verify roles and powers.
  • You are trusting off-chain operators and multisigs (listed at data.chain.link).
  • Always code defensively to mitigate potential threats.
  • Do not use deprecated interfaces. Understand the subtleties of recommended versions.
  • Depending on interfaces, check whether prices can be zero.
  • Always check the units and decimals for each price feed.

Check the linked repository to find the contracts mentioned in this section. For more information on ChainLink price feeds, refer to the official documentation.

 

The Open Price Feed

The Open Price Feed is a price oracle popularized by the Compound Protocol. Its current architecture and mechanics are as follows.

At the core of the price oracle is the UniswapAnchoredView contract. This single contract holds all the necessary logic to manage prices for multiple supported assets. The smart contract allows for trusted sources (in the current version, ChainLink price feeds) to post prices compared against an “anchor price” retrieved from Uniswap v2 markets. Posted prices can only deviate so much from the anchor price, the boundaries are well defined and known at the time of deployment as a percentage of the anchor price. They are the same for all supported assets, and cannot be modified.

Prices are posted to the UniswapAnchoredView contract via its validate function. Every time a ChainLink price feed for the supported assets receives a price it is automatically forwarded to UniswapAnchoredView.

Inside the UniswapAnchoredView contract there is a privileged account that can temporarily disable the described mechanism, switching the contract to “failover mode”.

In failover mode, the oracle no longer accepts posted prices as valid, and therefore uses anchor prices queried from Uniswap v2 markets as the sole source of truth. At the moment of writing, the powers for activating and deactivating failover mode are held by Compound’s Community Multisig.

As mentioned, to calculate anchor prices, the Open Price Feed currently uses Uniswap v2 markets. Specifically, it heavily relies on the time-weighted average prices (TWAP) of Uniswap v2 pools using a rolling window mechanism. Details of the implementation can be seen in the contract’s fetchAnchorPrice function and the pokeWindowValues function.

Querying prices from the Open Price Feed should be simple. As seen below, it can be achieved with two functions.

Note that while all prices of supported assets are returned in USD, the number of decimals varies based on the specific function queried. The price function uses 6, while getUnderlyingPrice uses 18.

The following is a summary of recommendations and notes to keep in mind when interacting with Open Price Feed:

  • ChainLink powers are limited by Uniswap v2 TWAP-based anchors. The downside is that TWAPs may be too slow to react for certain assets in periods of extreme volatility.
  • There is a failover mode in case there are any problems in reporting. It should be noted that, during failover mode, it may be necessary to update the price first before querying it.
  • The TWAP period and price bounds are immutable and the same for all assets.
  • Be careful with the amount of decimals of returned prices. It depends on the function being queried.
  • Queries revert for unsupported assets.
  • USDT, TUSD and USDC are not reported. They’re assumed to be pegged 1:1 to the US dollar.
  • Stay up to date with changes. After upgrades (usually in the Compound Protocol) views might become outdated and unreported.

Check the linked repository to find the contracts mentioned in this section. For more information on Open Price Feed, refer to the official documentation.

 

Uniswap v2 Time-Weighted Average Prices

Calculating an asset’s price by simply querying Uniswap pools can be dangerous. This is due to the fact that prices calculated as the ratio between the assets’ reserves could be trivially manipulated by attackers.

To tackle this problem, Uniswap v2 pools introduced the concept of time-weighted average prices (commonly known as TWAPs). In simple terms, each Uniswap v2 pool tracks two accumulators for each asset price. By querying an accumulator at two different points in time, it is possible to calculate the time-weighted average price of the corresponding asset over any period of time of our choosing. These accumulators can be read from the pool by querying the price0CumulativeLast and price1CumulativeLast functions, bearing in mind that the returned numbers are in fixed point notation (and therefore should be handled appropriately).

If you’re looking for utilities and examples to ease your development, you can refer to:

The following is a summary of recommendations and notes to keep in mind when interacting with Uniswap v2 to query prices:

  • There are trade offs when choosing the length of the period of time to calculate a TWAP. Longer periods are better to protect against price manipulation, but come at the expense of a slower, and potentially less accurate, price. You must choose time periods wisely, and base them on your risk analysis of each integrated asset.
  • In Uniswap v2, TWAPs are calculated as arithmetic means. Therefore, the TWAP of A in B is not the reciprocal of the TWAP of B in A. That is why there are two different accumulators being tracked and exposed. Query the one you need.
  • You are trusting that Uniswap v2 markets reflect the real market price of assets. Ultimately this means that you’re trusting arbitrageurs to act swiftly and effectively.
  • As usual, beware of units and decimals of returned prices. Make sure you are doing things right with extensive unit testing.
  • Use the available libraries and utilities in Uniswap’s repository.
  • There are additional details and caveats in the “Oracle Integrity” section of Uniswap v2’s audit.

Check the following repositories to find the contracts mentioned in this section:

For more information on the Uniswap v2 TWAPs, please refer to the official documentation.

 

Uniswap v3 Time-Weighted Average Prices

Uniswap v3 comes with a series of changes for querying prices. In simple terms, the way in which prices and accumulators are tracked within each pool has changed. In practice, this means that developers no longer need to keep track of two different accumulators for prices. Moreover, the pool can now be queried directly to get an asset’s TWAP (instead of having to build and calculate it in your own contracts).

The official OracleLibrary helper library comes with a number of functions to ease the work of integrating with Uniswap V3 pools. There is a consult function that can be used to query a pool’s TWAP by passing the pool’s address and the desired length of time. However, a few points must be noted:

  • Calls will revert if the passed period of time is too long.
  • The period of time available for consulting TWAPs depends on each pool.
  • The returned value is not the price, but the tick. You must derive the price from the tick’s value following the official documentation.
  • The ticks returned can be negative values. So be prepared to handle such scenarios.

Check the following repositories to find the contracts mentioned in this section:

For more information on the Uniswap v3 TWAPs, please refer to the official documentation.

 

Maker Oracles

Maker Oracles are one of the oldest oracles in the Ethereum ecosystem, supporting cornerstones of the space like the DAI stablecoin. Currently one must go through a formal whitelisting process to be able to query these oracles on-chain.

Maker Oracles have a set of privileged accounts (“feeds” and “relayers”) that aggregate data off-chain in a p2p network. This price data is submitted to on-chain contracts that take care of applying the necessary verification, and calculating the median price of all submitted observations. Contracts known as OSM contain additional logic to use available median prices in a time-delayed fashion. This set of contracts should be regularly “poked” to make sure they are using the latest available price data.

As previously mentioned, only whitelisted contracts can read from the Medianand OSM contracts. There are a number of ways to read that price data. In general, both the read function and the peek function (available in the median and in the OSM contracts) will return the current price for the asset with 18 decimals. However, while read can revert upon errors, peek will simply return a boolean flag along with the price. Furthermore, the OSM exposes the peep function that allows users to read the upcoming price.

Apart from the above diagram, a number of roles can be inferred, some of which have some notable powers. Among others:

  • Allow and disallow price readers and writers.
  • In the Median contract, changing the threshold to update median prices.
  • In the OSM contract, stopping the contract, changing the delay, or even deleting prices.

All of the above powers and others, along with edge cases, and failure modes, are clearly stated and documented in Maker’s official documentation. Consider reading it thoroughly to make sure you fully understand the risks of integrating with Maker’s price oracles.

For most assets, Maker Oracles follow the architecture and behaviors outlined above. Yet there are other supported assets in these oracles, such as liquidity-provider tokens and stablecoins, which differ from what we’ve seen so far.

For LP tokens, Maker Oracles use a contract called UNIV2LPOracle that has a OSM-like mechanism built-in. As seen below, the functionality exposed to read prices is pretty much the same as in the OSM contract. This contract must be “poked” regularly to ensure prices are up-to-date, including a whitelisting mechanism for readers as well.

For stablecoins, the Maker Oracle uses a simple contract called DSValue. It allows a privileged account to post a price, exposing the usual functions to read prices. It doesn’t have time-delayed prices, complex logic to calculate median prices, or whitelisting mechanisms.

 

In practice, the accounts allowed to post prices to DSValue contracts are usually timelock contracts, in turn managed by other privileged accounts in Maker’s smart contract system.

The following is a summary of recommendations and notes to keep in mind when interacting with Maker Oracles.

  • Do not assume systems are decentralized and permissionless. Better to verify roles and powers. Maker’s documentation is quite clear and extensive on this topic.
  • You are trusting off-chain feeds and relayers that aggregate and submit data, as well as privileged accounts in the Maker system.
  • Code defensively to mitigate potential threats (like being removed from whitelists).
  • Some functions to query prices revert on failure, others return boolean flags. Make sure you’re handling these cases properly.
  • If you’re querying an OSM-like oracle, make sure you understand the benefits and risks associated with reading time-delayed prices.
  • Always check units and decimals of each price feed.

Check the following repositories to find the contracts mentioned in this section:

For more information on Maker’s Oracles, please refer to the official documentation.


Closing Thoughts

  • When it comes to choosing an oracle, there’s no silver bullet. Each oracle has tradeoffs to consider.
  • In all of oracles, to different extents, you are trusting specific actors to do their job effectively. These include owners, nodes, operators, or arbitrageurs.
  • You can use defensive coding practices to mitigate threats. Make use of strategies such as delayed prices and fallback oracles that may also reduce risks in some scenarios.
  • Remember that oracles are a living thing. You can participate and contribute to make them more healthy and robust. With that in mind, it is fundamental to stay up to date with their changes.
  • Do not integrate with price oracles before having done your own research. You need to fully understand the benefits and potential risks they introduce into your project.

A Note From the Author

If you’re involved in the development of any of the oracles mentioned in this guide and would like to provide feedback, further clarify, or improve the accuracy of the statements and information provided in this workshop, please reach out to tincho@openzeppelin.com. I am happy to chat and continue improving our understanding of price oracles together.

Additional Resources


Video

Slides

Click here to view the slides from the workshop.

Learn more

Join the next installment of the security series on September 16th!

Learn more about OpenZeppelin Contracts and sign up for a free Defender account.

Be part of the community