A real incident, not a hypothetical
In May 2022, as TerraUSD lost its dollar peg and LUNA collapsed toward fractions of a cent, Chainlink's LUNA/USD price feed did exactly what it was designed to do under extreme conditions: it paused. Two lending protocols reading that feed, Venus Finance on BSC and Blizz Finance on Avalanche, kept operating as if the price was still updating. Venus lost roughly $11.2 million. Blizz lost roughly $8.3 million. Combined, just under $19.5 million, not from a bug in Chainlink's aggregation, but from contracts that never checked whether the data they were reading was actually fresh.
This is day 5 of a 28-day series on Chainlink's architecture. Days 1 through 4 covered the oracle problem, the legacy request model, why DONs aren't multisigs, and the OCR consensus protocol underneath every feed. Today is where all of that becomes concrete: how a Chainlink Price Feed actually decides when to update, what's actually sitting behind the address you copy from data.chain.link, and the specific integration mistake that turned a working safety mechanism into a $19.5 million hole.
Push, not pull: the two triggers that actually update a feed
Chainlink Data Feeds do not stream continuously. They update on exactly two conditions, and the first one that fires wins.
Deviation threshold. Individual nodes monitor their data sources continuously. The moment the off-chain price moves beyond a configured percentage from the last on-chain value, a new aggregation round starts. For ETH/USD on Ethereum mainnet, that threshold is 0.5%: a half-percent move triggers an update immediately, regardless of how much time has passed since the last one.
Heartbeat threshold. A backup timer, independent of price movement. If the deviation threshold hasn't fired within the configured heartbeat window, the feed updates anyway, just to guarantee the data never goes silent for too long even during quiet markets. ETH/USD's heartbeat is 3600 seconds, one hour. Many feeds, especially for less volatile assets, run heartbeats well over an hour.
Whichever condition triggers first wins that round. A volatile asset spends most of its life being updated by deviation. A flat, low-volume asset spends most of its life being updated by heartbeat. Both numbers are public, queryable per feed at data.chain.link, and they're not the same across every feed, even for the same asset on different chains. Checking the specific heartbeat and deviation threshold for the exact feed you're integrating, not assuming it matches some other feed you've used before, is a five-minute task that prevents a real class of bugs.
What's actually behind the address you copy
When you grab a feed address from data.chain.link and call it in your contract, you're not calling the contract that actually does the aggregation. You're calling a Proxy.
The Proxy is a thin pass-through that forwards calls to the real implementation: an AccessControlledOffchainAggregator contract, which is where OCR-signed reports actually land and get verified, and where latestAnswer actually gets updated. Routing through a Proxy means the underlying aggregator can be swapped out, for an upgrade, a bug fix, a configuration change, without consumer contracts needing to update a single address. Every time the aggregator gets swapped, a phaseId increments, which is also what lets you query historical aggregators and not just the current one.
That upgradeability isn't free of trust assumptions, and it's worth being precise about exactly what those assumptions are instead of treating "Chainlink Data Feeds" as one monolithic trust boundary. The Proxy contract for Chainlink's own feeds is owned by a Safe multisig with 9 owners and a signing threshold of 4. Four signatures can update the underlying aggregator for a feed arbitrarily. That's a meaningfully different and more centralized trust assumption than the OCR consensus happening round to round, and it's a governance-layer risk, not a data-layer one, exactly the same distinction day 3 of this series covered between DON consensus and multisig-controlled parameters.
The function everyone calls, and the field almost everyone ignores
Consumer contracts read a feed through the AggregatorV3Interface, most commonly by calling latestRoundData(). That function returns five values: roundId, answer, startedAt, updatedAt, and answeredInRound.
Most integration tutorials show code that destructures all five and then only ever uses answer. That's the entire footgun, in one sentence. updatedAt is a timestamp, the last time this round was actually written on-chain, and it is sitting right there in the return value, free, no extra call required, and an enormous number of production contracts simply never read it.
Here's why that field exists at all: a feed can stop updating, not because of a hack, but because of exactly the kind of extreme volatility event that makes accurate pricing matter most. Circuit breakers, deliberate pause conditions, sequencer downtime on L2s, and genuine network issues are all real, documented reasons a feed's updatedAt can fall behind. When that happens, latestRoundData() doesn't revert. It keeps returning the last successfully reported answer, the most recent one it has, with no built-in signal that anything is wrong unless the calling contract specifically checks how old that answer actually is.
That's precisely what happened to Venus and Blizz. The LUNA/USD feed paused under extreme conditions. Both protocols kept calling latestRoundData(), kept getting an answer back, and kept treating it as current, because nothing in their integration code ever compared updatedAt against the feed's own published heartbeat to check whether the round was actually still fresh.
The check that should exist in every integration
The fix is not complicated, and that's exactly what makes its absence in two real, exploited protocols so notable:
(
uint80 roundId,
int256 answer,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
) = priceFeed.latestRoundData();
require(updatedAt >= block.timestamp - MAX_DELAY, "Stale price");
require(answer > 0, "Invalid price");
MAX_DELAY should be set relative to the specific feed's published heartbeat, not an arbitrary guess. If a feed's heartbeat is one hour, a MAX_DELAY tighter than that will cause your contract to revert unnecessarily on perfectly healthy data, a self-inflicted denial of service. A MAX_DELAY looser than the heartbeat defeats the entire point of checking in the first place. The correct number comes directly from the same heartbeat value published for that exact feed at data.chain.link, not copied from a different feed's documentation or a tutorial that happened to use a round number.
When a staleness check fails, the response matters as much as the check itself. Reverting the entire transaction is the simplest option, but for a lending protocol mid-liquidation, an uncontrolled revert can itself become a denial-of-service vector. More mature integrations pause the specific operation, fall back to a secondary oracle, or hold position rather than act on data they can no longer vouch for. Which strategy is right depends entirely on what the contract is actually doing with the price, but doing nothing, the strategy Venus and Blizz effectively had, is the one option that's never correct.
A second, quieter gotcha: minAnswer and maxAnswer
Staleness gets most of the attention because it's tied to a real, public incident with a real dollar figure attached. There's a second field worth knowing about, mostly because of how it's changed over time rather than how it currently behaves.
Aggregator contracts include minAnswer and maxAnswer values, originally intended as circuit-breaker bounds, a sanity check meant to prevent a feed from reporting an absurd, clearly-wrong number. On most current Chainlink feeds, these bounds are no longer actively enforced and don't stop a contract from reading the most recent answer. That's worth knowing specifically because of how it bit at least one major protocol historically: a contract that hard-coded an assumption that the feed would always revert or otherwise reject an out-of-bounds answer, rather than checking the bounds itself, was relying on enforcement that wasn't actually guaranteed to be there. The lesson generalizes past this one field: any safety property you're not explicitly checking in your own contract is a safety property you don't actually have, regardless of what you assume the upstream feed is doing on your behalf.
What this sets up for tomorrow
Today covered Data Feeds specifically, the most widely used Chainlink product and the one almost every DeFi protocol integrates first. The deviation and heartbeat mechanism, the Proxy-to-aggregator architecture, and the staleness check are the concrete, auditable details underneath everything days 1 through 4 built up as theory. Tomorrow moves to VRF: how cryptographic proof replaces trust in randomness, and why block.timestamp as a source of randomness is the gambling-and-NFT-mint equivalent of the exact mistake this article just walked through, trusting an input without verifying it's actually what you think it is.
I'm a smart contract security researcher writing through Chainlink's full architecture for 28 days, from the node layer up to the Chainlink Runtime Environment. Follow along at ramprasadgoud.dev or on X @0xramprasad.
Top comments (0)