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:
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:
Some considerations referring specifically to the line of Solidity code where the price oracle is queried:
By reflecting on these and similar questions, development teams can build more robust and comprehensive threat models around price oracle integrations.
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.
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.
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 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:
Check the linked repository to find the contracts mentioned in this section. For more information on Open Price Feed, refer to the official documentation.
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:
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 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:
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 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 Median
and 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:
Median
contract, changing the threshold to update median prices.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.
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.
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.
Click here to view the slides from the workshop.
Join the next installment of the security series on September 16th!
Learn more about OpenZeppelin Contracts and sign up for a free Defender account.