Oracles and Chainlink Price Feeds
Smart contracts are sandboxed. They cannot fetch a stock price, call a REST API, or read a database. Anything that comes from outside the chain has to be put on chain by something, and that something is called an oracle. This lecture covers what oracles actually are, why they matter for any serious DeFi protocol, how Chainlink's price feed architecture solves the trust problem, and how to consume a feed safely.
The oracle problem
The EVM is a deterministic state machine. Every node must execute every transaction and arrive at the same result, otherwise consensus breaks. That requirement rules out anything that could return different values to different nodes: HTTP requests, random number generators, file system reads, system clocks beyond block.timestamp. None of these are available inside Solidity, and no compiler flag will give them to you.
This is a problem because most interesting financial applications depend on real-world data. A lending protocol needs to know what its collateral is worth, in dollars, right now, so it can liquidate undercollateralized loans. A stablecoin needs to know what the dollar is doing to maintain its peg. A derivatives platform needs to know the price of whatever asset its contracts settle against. An insurance contract needs to know whether a flight was delayed, whether a hurricane hit, whether a parametric trigger condition was met.
None of this data exists on chain natively. The only way to get it there is for someone outside to write a transaction that puts it there. That someone is an oracle.
What "an oracle" actually is
The word makes oracles sound mysterious. They are not. An oracle, mechanically, is two things:
- A contract on chain that holds some data in storage.
- An off-chain operator who sends transactions that update that storage.
Your contract reads from the oracle contract the same way you read from any other contract: a normal view call. The interesting question is not how the read works (it's just a function call), but who you trust to do the writing.
That trust question is the entire field. If the operator decides to publish a fake price, your contract has no way to tell. If the operator's infrastructure goes down, your contract reads a stale value. If the operator is compromised, your protocol is compromised. The whole engineering effort around production oracle systems is about reducing how much you have to trust any single party.
Why single-source oracles are dangerous
The simplest possible oracle is one contract, one operator. The operator queries one data source, signs a transaction with the result, and writes it on chain. This works fine for a hobby project. It is unacceptable for anything holding real funds.
Three failure modes break a single-source oracle:
- The source is wrong. Even reputable exchanges have brief glitches. An API returns the price of a different asset for a few seconds. Volume thins out and the last trade is far from fair value. If your oracle pulls from one place, you inherit every glitch from that place.
- The operator is malicious. A single party with the ability to push any number on chain has every incentive to do so when the payoff is large enough. The history of DeFi exploits includes cases where compromised oracle keys were used to drain protocols of tens of millions of dollars in single transactions.
- The operator goes offline. The oracle stops updating. Your contract continues to operate using an increasingly stale price. By the time anyone notices, positions that should have been liquidated have moved against the protocol.
Any one of these is enough to lose the entire treasury. Real protocols cannot use a single-source oracle and expect to survive a market move.
How Chainlink solves it
Chainlink price feeds attack the problem at three independent levels.
The OCR step (Off-Chain Reporting) is the part that matters for cost. Without it, every node would have to write its own answer on chain, paying gas, and your contract would have to read all of them and compute the median itself. OCR lets the nodes do the median calculation off chain via a peer-to-peer protocol, agree on a single answer, and have one of them write that answer to the aggregator in one transaction. The on-chain footprint is a single write per update, regardless of how many nodes participated.
The on-chain side is split into two contracts: an aggregator that does the actual storage and verification, and a proxy that consumers point at. The proxy address never changes. The aggregator behind it can be swapped out for upgrades. This is why production code always reads from the proxy address, not the aggregator directly. The full list of proxy addresses per network is on the supported networks page.
Reading a feed
Consuming a price feed is one interface and one function call. Import the interface from the Chainlink contracts package:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
contract PriceConsumer {
AggregatorV3Interface internal immutable priceFeed;
// Sepolia ETH/USD proxy
constructor() {
priceFeed = AggregatorV3Interface(0x694AA1769357215DE4FAC081bf1f309aDC325306);
}
function getLatestPrice() public view returns (int256) {
(
/* uint80 roundId */,
int256 answer,
/* uint256 startedAt */,
uint256 updatedAt,
/* uint80 answeredInRound */
) = priceFeed.latestRoundData();
return answer;
}
}The latestRoundData() function returns five values:
roundId— identifier for this round of dataanswer— the price itself, as a signed integerstartedAt— when the round was first reportedupdatedAt— when the round was last updatedansweredInRound— deprecated, ignore it
Most consumers only need answer and updatedAt. The example above ignores everything else for clarity, but production code should always read updatedAt (see the staleness section below).
The price comes back as int256, not uint256. The signed type exists because some feeds report values that can legitimately be negative (interest rate differentials, for example). Price feeds for assets like ETH/USD will never actually return a negative number, but the interface uses the wider type to accommodate the full range of possible feed types.
Decimals normalization
The answer returned is an integer, with the decimal point implicit. Each feed has a decimals() function that tells you how many digits to interpret as decimals.
For ETH/USD, the convention is 8 decimals. If the feed returns answer = 350000000000, the actual price is 3500.00000000 USD. To use this in computation alongside an 18-decimal token amount, you have to either scale the price up or scale the token amount down. The typical approach is to multiply the price by 10^10 to bring it to 18-decimal precision:
function getEthPriceIn18Decimals() public view returns (uint256) {
(, int256 answer, , , ) = priceFeed.latestRoundData();
require(answer > 0, "negative or zero price");
return uint256(answer) * 1e10;
}The require(answer > 0) check serves two purposes: it converts the safe range into uint256, and it catches the edge case where a misconfigured feed reports zero or negative (which for a USD price feed should never happen). Doing this once at the read site is much cheaper than verifying everywhere downstream.
If you call decimals() once at construction and store it, you avoid one external call on every price read. Decimals do not change for a given feed.
Staleness checks
A feed updates either when the price moves enough OR when enough time has passed. Both conditions, not just the first.
The implication is that the on-chain price can lag the real price for up to a heartbeat interval if the price hasn't moved enough to trigger an update. For ETH/USD on mainnet, that's at most one hour. For less active feeds, heartbeats can be 24 hours or longer.
This means stale data is a real possibility, even under normal operation. Your contract must validate that the data is recent enough for its purposes:
function getValidatedPrice() public view returns (uint256) {
(, int256 answer, , uint256 updatedAt, ) = priceFeed.latestRoundData();
require(answer > 0, "invalid price");
require(block.timestamp - updatedAt < 3600, "stale price");
return uint256(answer);
}The exact staleness threshold depends on the feed's heartbeat and your protocol's tolerance. A lending protocol that liquidates on small price moves needs fresher data than a settlement contract that runs once a day. The constant 3600 (one hour) is appropriate for ETH/USD with a one-hour heartbeat plus some buffer. Setting it too low causes false reverts during normal operation. Setting it too high lets your protocol act on data that may no longer reflect reality.