Reading a Chainlink price feed is one contract call. Reading it correctly means three checks most integrations skip: is the answer stale, is the underlying sequencer even up (if you’re on an L2), and did you scale the result by the right number of decimals. Skip any one of these and the failure mode is silent, a price that’s hours old, a feed reporting a healthy market on a sequencer that’s been down for ten minutes, or a value that’s off by a factor of a billion.
TL;DR
Compare updatedAt to the feed’s published heartbeat (not an arbitrary number) before trusting an answer. On Base, also check the L2 sequencer uptime feed and enforce Chainlink’s recommended one-hour grace period after it comes back up. Always call decimals() instead of assuming 8.
This is a technique post, not an address lookup. For verified contract addresses, decimals, and heartbeats for ETH/USD, BTC/USD, and USDC/USD on Ethereum, Base, and BNB Smart Chain, see the Chainlink price feed address reference. This post covers the three checks you run against whichever address you pull from there.
Check 1: staleness against the heartbeat, not a guess
Every Chainlink feed publishes a heartbeat, the maximum interval its node operators commit to between updates even if the price hasn’t moved. latestRoundData() returns updatedAt, the Unix timestamp of the last update, alongside the answer itself. The correct check compares the two:
const ageSeconds = Math.floor(Date.now() / 1000) - Number(updatedAt);if (ageSeconds > heartbeatSeconds * 2) { throw new Error(`feed is stale: last updated ${ageSeconds}s ago`);}Two details matter here. First, the heartbeat is per feed, not a global constant, Ethereum’s USDC/USD feed commits to 23 hours while Base’s ETH/USD feed commits to 20 minutes, both correct for their respective pairs (a stablecoin barely moves; ETH does). Hardcoding one heartbeat value across every feed you read means treating a genuinely stale ETH/USD price as fresh, or flagging a perfectly normal USDC/USD update as stale. Second, doubling the heartbeat before rejecting gives room for normal network variance (a node running a few minutes behind schedule) without masking a feed that’s actually stuck.
Don't skip this because a feed 'always updates fast'
A feed that updates every 60 seconds in practice can still stop updating entirely if its node operators lose consensus, an oracle contract gets paused, or the wrong proxy address ends up in your config (see the address reference for how easy that mistake is). The staleness check is what catches all three failure modes with one comparison, regardless of cause.
Check 2: sequencer uptime, and why it only applies on Base here
Chainlink publishes a separate class of feed for L2 networks: the L2 Sequencer Uptime Status Feed. The problem it solves is specific to rollups. An L2’s sequencer orders and submits transactions; if it goes down, the chain doesn’t necessarily halt, but new price updates can stop reaching the L2 while old contract state (including a stale Chainlink answer) keeps reading as if nothing’s wrong. A staleness check alone can miss this if the sequencer goes down and comes back up faster than the feed’s heartbeat would otherwise flag.
Of evmquery’s three supported chains (Ethereum, Base, and BNB Smart Chain), only Base is an L2 with a sequencer, so this check only applies there. Ethereum is the L1 itself and BNB Smart Chain is an independent L1 with its own validator set, neither has a sequencer to check.
Base L2 sequencer uptime feed
- Base’s Chainlink sequencer uptime feed lives at
0xBCF85224fc0756B9Fa45aA7892530B47e10b6433, a verifiedEACAggregatorProxyconfirmed live on-chain:description()returns"L2 Sequencer Uptime Status Feed". - It exposes the same
latestRoundData()shape as a price feed, butanswermeans status, not price:0means the sequencer is up,1means it’s down. startedAtis the timestamp the current status began, not the last price update. Chainlink’s own reference pattern recommends a one-hour grace period afterstartedAtbefore trusting reads that happened right as the sequencer recovered.- Chainlink documents this feed for several L2 rollups (Arbitrum, Optimism, Base, and others), not for L1 networks or non-rollup chains like BNB Smart Chain.
The check, adapted from Chainlink’s own reference pattern:
const GRACE_PERIOD_SECONDS = 3600; // Chainlink's recommended grace period
const { answer: sequencerStatus, startedAt } = await client.readContract({ address: "0xBCF85224fc0756B9Fa45aA7892530B47e10b6433", // Base sequencer uptime feed abi: AGGREGATOR_ABI, functionName: "latestRoundData",});
if (sequencerStatus !== 0n) { throw new Error("sequencer is down");}
const timeSinceUp = Math.floor(Date.now() / 1000) - Number(startedAt);if (timeSinceUp <= GRACE_PERIOD_SECONDS) { throw new Error(`sequencer recovered too recently: ${timeSinceUp}s ago, grace period is ${GRACE_PERIOD_SECONDS}s`);}This is a real, live-validated result querying the address above through evmquery: at query time answer was 0 (sequencer up) with a startedAt well outside the grace period, meaning the price feeds shown in the address reference were safe to trust at that moment. That won’t always be true, which is the entire point of running the check on every read rather than assuming it once and moving on.
This is one extra call, not a redesign
The sequencer check is a second latestRoundData() read against a fixed, well-known address, not a new dependency or a different data source. On Ethereum and BNB Smart Chain, skip it entirely, there’s no sequencer feed to check, and the staleness check on the price feed itself is sufficient.
Check 3: decimals, called not assumed
Every USD-quoted example in the address reference happens to use 8 decimals, and it’s tempting to bake 1e8 into a helper function and move on. That assumption breaks the moment you read a crypto-quoted pair (several use 18 decimals) or a feed on a chain you haven’t tested yet. The fix costs one more call:
const decimals = await client.readContract({ address: feedAddress, abi: AGGREGATOR_ABI, functionName: "decimals",});
const price = Number(answer) / 10 ** decimals;decimals() is cheap, cacheable per feed address (it doesn’t change), and removes an entire class of “price is off by a power of ten” bugs. Treat a hardcoded decimals constant the same way you’d treat a hardcoded feed address, a shortcut that works until it doesn’t, at which point it fails silently instead of loudly.
Putting the three checks together
A production read combines all three in the order they matter: sequencer up (Base only), then price fresh, then price scaled correctly.
import { createPublicClient, http } from "viem";import { base } from "viem/chains";
const AGGREGATOR_ABI = [ { name: "latestRoundData", type: "function", stateMutability: "view", inputs: [], outputs: [ { name: "roundId", type: "uint80" }, { name: "answer", type: "int256" }, { name: "startedAt", type: "uint256" }, { name: "updatedAt", type: "uint256" }, { name: "answeredInRound", type: "uint80" }, ], }, { name: "decimals", type: "function", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "uint8" }], },] as const;
const SEQUENCER_FEED = "0xBCF85224fc0756B9Fa45aA7892530B47e10b6433";const ETH_USD_BASE = "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70";const HEARTBEAT_SECONDS = 1200; // Base ETH/USD, per the address referenceconst GRACE_PERIOD_SECONDS = 3600;
const client = createPublicClient({ chain: base, transport: http() });
async function readEthUsdOnBase() { const sequencer = await client.readContract({ address: SEQUENCER_FEED, abi: AGGREGATOR_ABI, functionName: "latestRoundData", }); if (sequencer.answer !== 0n) throw new Error("sequencer is down"); const timeSinceUp = Math.floor(Date.now() / 1000) - Number(sequencer.startedAt); if (timeSinceUp <= GRACE_PERIOD_SECONDS) throw new Error("sequencer still in grace period");
const [round, decimals] = await Promise.all([ client.readContract({ address: ETH_USD_BASE, abi: AGGREGATOR_ABI, functionName: "latestRoundData" }), client.readContract({ address: ETH_USD_BASE, abi: AGGREGATOR_ABI, functionName: "decimals" }), ]); const ageSeconds = Math.floor(Date.now() / 1000) - Number(round.updatedAt); if (ageSeconds > HEARTBEAT_SECONDS * 2) throw new Error(`feed is stale: ${ageSeconds}s`);
return Number(round.answer) / 10 ** decimals;}That’s four separate calls (two latestRoundData(), one decimals(), plus the implicit RPC round trips) for what reads as a single price. This is exactly the kind of read evmquery collapses server-side:
curl -X POST https://api.evmquery.com/api/v1/query \ -H "x-api-key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "chain": "evm_base", "schema": { "contracts": { "seq": { "address": "0xBCF85224fc0756B9Fa45aA7892530B47e10b6433" }, "feed": { "address": "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70" } } }, "expression": "cel.bind(s, seq.latestRoundData(), cel.bind(r, feed.latestRoundData(), cel.bind(d, feed.decimals(), { \"sequencerUp\": string(s.answer), \"sequencerStartedAt\": string(formatUnits(s.startedAt, 0)), \"price\": string(formatUnits(r.answer, d)), \"updatedAt\": string(formatUnits(r.updatedAt, 0)) })))" }'cel.bind nests the sequencer read, the price read, and the decimals lookup into one round trip and one billed request; the staleness and grace-period arithmetic still happens in your own code against the returned timestamps, evmquery reads the raw fields correctly scaled, it doesn’t make the trust decision for you. The Chainlink price feed reader tool runs the price half of this expression (description, scaled answer, decimals) against any of the addresses in the reference table if you want to see the shape of the response before wiring up the full check.
If you’re running this on a schedule rather than a one-off script, for a price alert, a liquidation monitor, or a treasury dashboard, see evmquery for automation for wiring the same three-part check into a polling job instead of re-running it by hand.
Next steps
- Chainlink price feed address reference: verified addresses, decimals, and heartbeats for the pairs used in the examples above.
- Chainlink price feed reader tool: try a feed read live, no code required.
- Aave V3 health factor explained: another case where a single call returns values at multiple, easy-to-confuse decimal scales.
- evmquery for automation: schedule the staleness and sequencer checks instead of running them by hand.



