As part of the price sanity check in PriceFeed.sol
, Liquity fetches the latest price from Chainlink and the price before the latest, in order to compare the magnitude of the deviation between consecutive Chainlink price updates.
The Chainlink ETH-USD feed operates via a “round” system - nodes periodically submit price data and the data is aggregated off-chain by Chainlink’s oracle network. A new round occurs when the aggregate price is pushed on-chain to the aggregator contract, to be consumed by dApps like Liquity.
Liquity identifies Chainlink price updates by roundId
. Liquitry defines the current Chainlink price as the one with the latest roundId
, and the previous Chainlink price as the price with a round ID of roundId - 1
. Here is the Liquity code where the previous Chainlink price is fetched in the _getPrevChainlinkResponse
function in PriceFeed.sol
:
|
function _getPrevChainlinkResponse(uint80 _currentRoundId, uint8 _currentDecimals) internal view returns (ChainlinkResponse memory prevChainlinkResponse) { |
|
/* |
|
* NOTE: Chainlink only offers a current decimals() value - there is no way to obtain the decimal precision used in a |
|
* previous round. We assume the decimals used in the previous round are the same as the current round. |
|
*/ |
|
|
|
// Try to get the price data from the previous round: |
|
try priceAggregator.getRoundData(_currentRoundId - 1) returns |
|
( |
|
uint80 roundId, |
|
int256 answer, |
|
uint256 /* startedAt */, |
|
uint256 timestamp, |
|
uint80 /* answeredInRound */ |
|
) |
|
{ |
|
// If call to Chainlink succeeds, return the response and success = true |
|
prevChainlinkResponse.roundId = roundId; |
|
prevChainlinkResponse.answer = answer; |
|
prevChainlinkResponse.timestamp = timestamp; |
|
prevChainlinkResponse.decimals = _currentDecimals; |
|
prevChainlinkResponse.success = true; |
|
return prevChainlinkResponse; |
|
} catch { |
|
// If call to Chainlink aggregator reverts, return a zero response with success = false |
|
return prevChainlinkResponse; |
|
} |
|
} |
However, using roundId - 1
for the previous price assumes that the roundId
always and only increases by 1 every time a new round occurs.
Impact
If this assumption did not hold and roundId
could jump by >1 between price updates, then if Liquity tried to fetch the price with roundId - 1
when no Chainlink round with this value existed, this call to the Chainlink aggregator here would revert on L550 in the above function:
|
try priceAggregator.getRoundData(_currentRoundId - 1) returns |
This revert would be caught by the try-catch statement, and Liquity will in turn decide that Chainlink has failed in the _chainlinkIsBroken
function:
|
function _chainlinkIsBroken(ChainlinkResponse memory _currentResponse, ChainlinkResponse memory _prevResponse) internal view returns (bool) { |
|
return _badChainlinkResponse(_currentResponse) || _badChainlinkResponse(_prevResponse); |
|
} |
|
|
|
function _badChainlinkResponse(ChainlinkResponse memory _response) internal view returns (bool) { |
|
// Check for response call reverted |
|
if (!_response.success) {return true;} |
|
// Check for an invalid roundId that is 0 |
|
if (_response.roundId == 0) {return true;} |
|
// Check for an invalid timeStamp that is 0, or in the future |
|
if (_response.timestamp == 0 || _response.timestamp > block.timestamp) {return true;} |
|
// Check for non-positive price |
|
if (_response.answer <= 0) {return true;} |
|
|
|
return false; |
|
} |
It would then fall back to using Tellor as the ETH-USD price oracle.
This scenario is undesirable - a delta in price round IDs greater than one on the (otherwise functional) Chainlink feed should not in itself trigger a switch to the fallback oracle.
Can roundId
ever jump by >1 between two updates?
In normal times, the roundId
increases by 1 at each new round.
According to Chainlink, the exception is when the aggregator logic contract is upgraded. Chainlink price feeds have a proxy architecture, and the proxy calculates the roundId
based in part on a unique phaseId
of the underlying aggregator.
When the aggregator is upgraded the roundId
for consecutive price updates can differ by >1 due to the way the roundId
is calculated based on the phaseId
of the aggregator contract, which changes at every upgrade:
https://docs.chain.link/data-feeds/historical-data
This is evidenced here:
https://dune.com/queries/266838/3020619
However, in practice Chainlink sensibly do not immediately point the proxy to the new aggregator implementation. After deployment, the aggregator accrues a few hundred rounds of price data for quality control purposes before the proxy is pointed to the new aggregator contract.
This means that when Liquity fetches price data for the first time after an aggregator upgrade, both roundId
and roundId - 1
will be valid round IDs and Liquity will successfully fetch both the the current and previous round price data (assuming Chainlink is otherwise functioning properly).
As part of the price sanity check in
PriceFeed.sol
, Liquity fetches the latest price from Chainlink and the price before the latest, in order to compare the magnitude of the deviation between consecutive Chainlink price updates.The Chainlink ETH-USD feed operates via a “round” system - nodes periodically submit price data and the data is aggregated off-chain by Chainlink’s oracle network. A new round occurs when the aggregate price is pushed on-chain to the aggregator contract, to be consumed by dApps like Liquity.
Liquity identifies Chainlink price updates by
roundId
. Liquitry defines the current Chainlink price as the one with the latestroundId
, and the previous Chainlink price as the price with a round ID ofroundId - 1
. Here is the Liquity code where the previous Chainlink price is fetched in the_getPrevChainlinkResponse
function inPriceFeed.sol
:dev/packages/contracts/contracts/PriceFeed.sol
Lines 543 to 570 in a0948b6
However, using
roundId - 1
for the previous price assumes that theroundId
always and only increases by 1 every time a new round occurs.Impact
If this assumption did not hold and
roundId
could jump by >1 between price updates, then if Liquity tried to fetch the price withroundId - 1
when no Chainlink round with this value existed, this call to the Chainlink aggregator here would revert on L550 in the above function:dev/packages/contracts/contracts/PriceFeed.sol
Line 550 in a0948b6
This revert would be caught by the try-catch statement, and Liquity will in turn decide that Chainlink has failed in the
_chainlinkIsBroken
function:dev/packages/contracts/contracts/PriceFeed.sol
Lines 346 to 361 in a0948b6
It would then fall back to using Tellor as the ETH-USD price oracle.
This scenario is undesirable - a delta in price round IDs greater than one on the (otherwise functional) Chainlink feed should not in itself trigger a switch to the fallback oracle.
Can
roundId
ever jump by >1 between two updates?In normal times, the
roundId
increases by 1 at each new round.According to Chainlink, the exception is when the aggregator logic contract is upgraded. Chainlink price feeds have a proxy architecture, and the proxy calculates the
roundId
based in part on a uniquephaseId
of the underlying aggregator.When the aggregator is upgraded the
roundId
for consecutive price updates can differ by >1 due to the way theroundId
is calculated based on thephaseId
of the aggregator contract, which changes at every upgrade:https://docs.chain.link/data-feeds/historical-data
This is evidenced here:
https://dune.com/queries/266838/3020619
However, in practice Chainlink sensibly do not immediately point the proxy to the new aggregator implementation. After deployment, the aggregator accrues a few hundred rounds of price data for quality control purposes before the proxy is pointed to the new aggregator contract.
This means that when Liquity fetches price data for the first time after an aggregator upgrade, both
roundId
androundId - 1
will be valid round IDs and Liquity will successfully fetch both the the current and previous round price data (assuming Chainlink is otherwise functioning properly).