Reach out for an audit or to learn more about Macro
orMessage on Telegram
Macro Logo

How To Consume Chainlink Price Feeds Safely

Mar 20, 2023
Author avatar
Lead Security Researcher

Chainlink price feeds are reliable, but it is crucial to have circuit breakers to prevent any issues from a single source. Using a single entity is not ideal from a decentralization perspective as well, and it is better to have backup plans in case of system failure. Many developers were unaware of circuit breakers they could implement when we pointed this out.
So here we go!

Chainlink Price Feeds and Circuit Breakers !!!

Disclaimer: This document assumes that you always intend to fetch a price greater than zero.
Credits: Shoutout to Liquity's price feed; their code inspired a lot of the following content

Step 1: Call latestRoundData() on Price Feed Aggregator

Don't use latestAnswer, it's deprecated

struct ChainlinkResponse {
    uint80 roundId;
    int256 answer;
    uint256 updatedAt;
    bool success;

ChainlinkResponse memory cl;

try priceAggregator.latestRoundData() returns (
    uint80 roundId,
    int256 answer,
    uint256 /* startedAt */,
    uint256 updatedAt,
    uint80 /* answeredInRound */
) {
    cl.success = true;
    cl.roundId = roundId;
    cl.answer = answer;
    cl.updatedAt = updatedAt;

Step 2: Sanity Check

Perform a sanity check to verify that the fetched data is valid.

if (
    cl.success == true &&
    cl.roundId != 0 &&
    cl.answer >= 0 &&
    cl.updatedAt != 0 && 
    cl.updatedAt <= block.timestamp
) {
} else {
    step 6; // means chainlink failed the check

Step 3: Staleness Check

Verify if the fetched data is stale.

if (block.timestamp - cl.updatedAt > TIMEOUT) {
    step 6;
} else {

Deprecation of answeredInRound check.

In the past, it was recommended to perform a staleness check by using the answeredInRound parameter. However, this parameter is now deprecated, and it is no longer necessary to check for staleness.
You can verify this by examining the OffchainAggregator.sol contract, specifically at line 810 where the latestRoundData() function is defined.
Here, you will notice that answeredInRound is always equal to roundId.
But please be aware that this may not yet be the case for all feeds, so it is important to double-check the specific feed you are working with.

Step 4: Price Deviation Check

Perform a price deviation check (optional).
For some use cases, this is detrimental; choose wisely :)

if (abs(cl.answer - lastGoodPrice) / lastGoodPrice > ACCETABLE_DEVIATON) {
    step 6; // means chainlink failed the check
} else {

Step 5: Store Last Good Price

Store the last good price fetched by Chainlink.

lastGoodPrice = cl.answer; // chainlink's fetched price
return lastGoodPrice;

Step 6: Secondary Oracle and Mitigating Circuit Breaks.

If you are open to adding a secondary oracle, start from A or skip to B.

A. Use Secondary Oracle

When it comes to oracles, having a fallback option is always a good idea to avoid a single point of failure. There are various secondary oracle options to choose from, depending on your preferences and the asset being used.

  1. Uniswap TWAP: For assets that have a pool with good liquidity on Uniswap.
  2. Another off-chain oracle: For example, Tellor.
  3. Your own oracle: Not recommended, but if nothing works from above, you can build a simple oracle with setPrice and getPrice functions, with access only for the owner. This will only be accessed when Chainlink fails, so it's a valid trade-off between security and decentralization. The risk with this is that you need to be cautious of your off-chain source and the security of owner keys.

Once you have a secondary oracle in place, you can implement its own circuit breakers to ensure that your smart contract only uses reliable price data, and if they fail as well, go to B (Mitigating Circuit Breaks).

if (price fetch on 2nd oracle is successful and all of its own checks are passed) {
    lastGoodPrice = secondaryOracle's fetchedPrice;
    return lastGoodPrice;
} else {
    go to B;

B. Mitigating Circuit Breaks

When none of the oracles are working, it's up to you to determine the best course of action for your protocol. You should consider the best-case scenario for the protocol and the worst-case scenario for the users. For instance, in a lending protocol, you can still allow deposits and paybacks since they don't use oracles. However, you should revert borrow and withdraw operations to prevent accruing bad debt or allowing users to withdraw more collateral than they should.

When it comes to liquidations, you can revert or use the best price from the last good price and the current price. What counts as "best" is subjective. For collateral, you want to use the lowest price, while for debt, you want to use the highest.

If your price feeds are on L2, there is one more circuit break you need to be aware of 👇

Consider you have deployed a lending protocol on L2, and its sequencer goes down. This has happened in the past and may happen in the future. When the sequencer comes back online and oracles update their prices, all price movements that occurred during downtime are applied at once. If these movements are significant, they may cause chaos. Borrowers would rush to save their positions, while liquidators would rush to liquidate borrowers. Since liquidations are handled mainly by bots, borrowers are likely to suffer mass liquidations.

This is unfair to borrowers, as they could not act on their positions even if they wanted to due to the L2 downtime.

One thing to note here is If an L2 goes offline, anyone can still submit a transaction for L2 through L1 by something called as delayed inbox (arbitrum) OR canonical transaction chain (optimism).
These transactions are always executed first when the sequencer comes back online. In theory, some borrowers can still avoid liquidation by closing their position through this delayed inbox. However, it is unlikely that normal borrowers would have the required knowledge to do so, creating unfair grounds for the same set of users.

Hence, it would be ideal if your protocol gives borrowers a grace period once the sequencer returns.

The good thing is that Chainlink has a dedicated feed for sequencer uptimes. Check out their documentation on L2 Sequencer Uptime Feeds.

Basically, it works because Chainlink keeps pushing sequencer uptime updates from L1 to L2. So, the Chainlink sequencer update will go to the delayed inbox when the sequencer goes down. Since all transactions of the delayed inbox are executed before any others once the sequencer comes back online, it is guaranteed that your contract would consider a grace period.

Aave V3 follows the same approach as described in their technical paper.

Note that in the case of AAVE, if a position is heavily undercollateralized, liquidations are allowed even if the protocol is under a grace period.

Aave’s Price Sentinel Contract
Chainlink's Example Integration

That’s All, Safe Travels 🙂