Calling latestRoundData() and using answer directly works in a demo. In production it's one of the most common findings in audits, because a price feed can return a value that is stale, scaled differently than you assumed, or reported while an L2 sequencer was down. A safe read checks all three.
What the feed actually returns
Chainlink Data Feeds implement AggregatorV3Interface:
function latestRoundData()
external
view
returns (
uint80 roundId,
int256 answer,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
);
function decimals() external view returns (uint8);
answeris a signed integer scaled bydecimals(). It is not in 18 decimals unless the feed says so.updatedAtis when the answer was last updated. This is what you check for staleness.answeredInRoundis deprecated. Don't build checks on it.latestAnswer()is also deprecated and gives you no timestamp. Don't use it.
Why feeds go stale
A feed doesn't update every block. It updates when the off-chain price moves more than its deviation threshold, or when the heartbeat interval passes with no update. Both values are set per feed and per network, and they're listed on the feed's page at data.chain.link.
So "the last update was 40 minutes ago" can be perfectly normal for one feed and a clear failure for another. Your staleness limit must come from that specific feed's heartbeat, with a small margin, not a global constant. If the oracle network or the chain has problems, updatedAt stops moving and your contract should notice.
Decimals: never assume 8
Many USD-quoted feeds use 8 decimals and many ETH-quoted feeds use 18, but that's a pattern, not a rule. Read decimals() (once, at deployment is fine, since it doesn't change for a given feed) and normalize before doing math with token amounts that have their own decimals. Mixing an 8-decimal price with an 18-decimal amount without scaling is an easy way to be off by a factor of 10^10.
The L2 sequencer problem
On optimistic and other rollups such as Arbitrum, OP Mainnet and Base, a single sequencer orders transactions. If it goes down, users can still force transactions in through L1, but most users can't transact normally. When the sequencer comes back, prices may have moved a lot while the on-chain feed couldn't update, and anyone who got a transaction in early could liquidate or borrow against prices nobody else could act on.
Chainlink publishes L2 Sequencer Uptime Feeds on supported L2s for exactly this:
answer == 0: the sequencer is up.answer == 1: it's down.startedAt: when the current status began.
Revert if the sequencer is down, and also for a grace period after it comes back up, so users have time to react before liquidations resume. Get the uptime feed address for your network from Chainlink's documentation. On L1 you don't need it.
A safe reader
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface AggregatorV3Interface {
function decimals() external view returns (uint8);
function latestRoundData()
external
view
returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound);
}
contract SafePriceReader {
error SequencerDown();
error GracePeriodNotOver();
error StalePrice(uint256 updatedAt);
error InvalidPrice(int256 answer);
uint256 public constant GRACE_PERIOD = 1 hours;
AggregatorV3Interface public immutable feed;
AggregatorV3Interface public immutable sequencerUptimeFeed; // address(0) on L1
uint256 public immutable maxStaleness; // feed heartbeat + margin, in seconds
uint8 public immutable feedDecimals;
constructor(address feed_, address sequencerUptimeFeed_, uint256 maxStaleness_) {
feed = AggregatorV3Interface(feed_);
sequencerUptimeFeed = AggregatorV3Interface(sequencerUptimeFeed_);
maxStaleness = maxStaleness_;
feedDecimals = feed.decimals();
}
/// @return price The feed price scaled to 18 decimals.
function getPrice() public view returns (uint256 price) {
_checkSequencer();
(, int256 answer,, uint256 updatedAt,) = feed.latestRoundData();
if (answer <= 0) revert InvalidPrice(answer);
if (updatedAt == 0 || block.timestamp - updatedAt > maxStaleness) {
revert StalePrice(updatedAt);
}
price = uint256(answer);
if (feedDecimals < 18) {
price = price * 10 ** (18 - feedDecimals);
} else if (feedDecimals > 18) {
price = price / 10 ** (feedDecimals - 18);
}
}
function _checkSequencer() internal view {
if (address(sequencerUptimeFeed) == address(0)) return;
(, int256 status, uint256 startedAt,,) = sequencerUptimeFeed.latestRoundData();
if (status != 0) revert SequencerDown();
// startedAt == 0 means the uptime feed has no valid round yet
if (startedAt == 0 || block.timestamp - startedAt <= GRACE_PERIOD) {
revert GracePeriodNotOver();
}
}
}
The grace period here is an example. Choose one that fits how fast your users need to respond, for example to top up collateral.
Other things to decide
Revert or fall back? Reverting on a bad price is the safe default for actions that depend on it, like borrowing or liquidating. But think about which actions shouldn't be blocked: users repaying debt or withdrawing unused collateral usually shouldn't depend on a fresh price. Some protocols wrap the call in try/catch and switch to a secondary source or a paused mode instead of bricking every function.
Sanity bounds. Some older aggregators have minAnswer/maxAnswer limits built in. If the market moves outside them, the feed keeps reporting the bound instead of the real price. If your protocol would be exploitable at a wildly wrong price, add your own bounds and alert on them.
Verify addresses. Take feed addresses from Chainlink's docs for the exact network, store them in your deployment config, and check them in review. A wrong feed (the right pair on the wrong network, or a similar-looking pair) returns plausible numbers and passes every check above.
Know what the price is. A Data Feed is an aggregated market price, updated on deviation or heartbeat. It's not a real-time DEX spot price, and it can lag fast moves by up to the deviation threshold. Size liquidation bonuses and collateral factors with that in mind.
Checklist
- Use
latestRoundData(), notlatestAnswer(), and ignoreansweredInRound. - Reject
answer <= 0andupdatedAt == 0. - Check
block.timestamp - updatedAtagainst that feed's heartbeat plus a margin. - Read
decimals()and normalize before mixing with token amounts. - On L2s, check the Sequencer Uptime Feed and enforce a grace period after recovery.
- Decide which functions must keep working when the price is unavailable.
- Verify every feed address against Chainlink's docs for that network.
Get the weekly commit
New blockchain deep dives every week.