Chainlink Price Feed Reads in Raw viem: What Leaving evmquery Actually Costs

The hand-rolled viem code behind evmquery's Chainlink price-feed read: staleness, Base sequencer uptime, decimals, and multicall batching, line by line.

evmquery team··8 min read
Share
Chainlink price feed reads migrated from evmquery to raw viem: staleness, sequencer uptime, and decimals, with multicall batching

The Chainlink price feed guide on this blog makes a specific claim: reading a feed correctly takes three checks (staleness against the heartbeat, Base sequencer uptime, decimals normalization), not one. evmquery runs all three in a single request. This post writes the raw viem equivalent, line by line, run live against the same feed, so the claim that leaving evmquery means rebuilding that logic yourself isn’t just a promise.

TL;DR

The evmquery expression behind the three-check Chainlink guide is a cel.bind expression, nine lines once formatted for readability. The raw viem equivalent, with the full latestRoundData/decimals ABI and a multicall block batching all three reads into one round trip, is 56 lines. Both were run live against the same feed on 2026-10-19 and landed on the identical Chainlink round, down to the timestamp.

What this migration actually costs

  • The three-check read (sequencer status, price staleness, decimals) is one cel.bind expression in evmquery vs. 56 lines of viem (two ABI fragments, a client.multicall block, and the staleness/grace-period guards).
  • Both code paths in this post executed live on 2026-10-19: evmquery against the hosted API, viem against a public Base RPC, same sequencer feed, same ETH/USD feed, about two minutes apart.
  • The two reads returned the exact same Chainlink round — identical answer, identical updatedAt — because Chainlink’s heartbeat-based feeds only emit a new round every 20 minutes on Base, not continuously. Two back-to-back reads on different infrastructure agreeing bit-for-bit is a stronger parity check than the price-drift seen in this blog’s other migration posts.
  • string(sol_int) has no overload in evmquery’s CEL dialect — a detail this post’s research run caught breaking the published REST example in the original guide, now fixed there too (see the callout below).

The expression this post is replacing

The Chainlink price feed guide combines a sequencer-uptime check, a price read, and a decimals lookup into one nested cel.bind expression:

cel.bind(s, seq.latestRoundData(),
cel.bind(r, feed.latestRoundData(),
cel.bind(d, feed.decimals(), {
"sequencerUp": formatUnits(s.answer, 0),
"sequencerStartedAt": formatUnits(s.startedAt, 0),
"price": formatUnits(r.answer, d),
"updatedAt": formatUnits(r.updatedAt, 0)
})
)
)

Run live against Base’s sequencer uptime feed and the Base ETH/USD feed for this post:

{
"result": {
"value": {
"sequencerUp": 0,
"sequencerStartedAt": 1782491507,
"price": 2420.67831384,
"updatedAt": 1792382433
},
"type": "map<string, double>"
},
"meta": { "blockNumber": "52797032", "totalCalls": 3, "totalRounds": 1 },
"units": { "consumed": 4 }
}

Block 52,797,032, 3 on-chain calls, 1 round, 4 units. sequencerUp: 0 means up, per Chainlink’s own convention for this feed, not a bug in the output.

The original guide's curl example stopped working — fixed in this run

chainlink-price-feeds-correctly.mdx previously wrapped these same reads in an outer string(...) call (string(s.answer), string(formatUnits(s.startedAt, 0)), and so on). Re-running that exact expression live for this post returned QUERY__EXPRESSION_INVALID: found no matching overload for 'string(sol_int)' — evmquery’s CEL dialect has no string() overload for a bare sol_int, and wrapping an already-string formatUnits(...) result in another string() call hits the same error. The fix is to drop the outer string() entirely; formatUnits already returns the right representation, and the map literal above resolves to map<string, double> on its own. The original guide’s curl block has been corrected to match. If you copy an evmquery expression from an older post on this blog and it throws a string(sol_int) overload error, this is why — use the corrected version here instead.

The raw viem equivalent

Two ABI fragments (latestRoundData, decimals, shared by the sequencer feed and the price feed), one multicall call batching all three reads, and the same staleness and grace-period guards the original guide documents:

import { createPublicClient, http, type Abi } from "viem";
import { base } from "viem/chains";
const client = createPublicClient({ chain: base, transport: http() });
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 satisfies Abi;
const SEQUENCER_FEED: `0x${string}` = "0xBCF85224fc0756B9Fa45aA7892530B47e10b6433";
const GRACE_PERIOD_SECONDS = 3600; // Chainlink's recommended grace period
async function readFeedSafely(feed: `0x${string}`, heartbeatSeconds: number) {
const [sequencer, round, decimals] = await client.multicall({
contracts: [
{ address: SEQUENCER_FEED, abi: AGGREGATOR_ABI, functionName: "latestRoundData" },
{ address: feed, abi: AGGREGATOR_ABI, functionName: "latestRoundData" },
{ address: feed, abi: AGGREGATOR_ABI, functionName: "decimals" },
],
allowFailure: false,
});
const now = Math.floor(Date.now() / 1000);
if (sequencer[1] !== 0n) throw new Error("sequencer is down");
const timeSinceUp = now - Number(sequencer[2]);
if (timeSinceUp <= GRACE_PERIOD_SECONDS) {
throw new Error(`sequencer recovered too recently: ${timeSinceUp}s ago`);
}
const ageSeconds = now - Number(round[3]);
if (ageSeconds > heartbeatSeconds * 2) {
throw new Error(`feed is stale: last updated ${ageSeconds}s ago`);
}
return Number(round[1]) / 10 ** decimals;
}

56 lines, count them above. Called against the same two feeds, about two minutes after the evmquery call:

{
blockNumber: '52797086',
sequencerUp: true,
sequencerStartedAt: '1782491507',
timeSinceSequencerUpSeconds: 9892013,
feedAnswer: '242067831384',
decimals: 8,
price: 2420.67831384,
feedUpdatedAt: '1792382433',
feedAgeSeconds: 1087
}

Block 52,797,086, 54 blocks after the evmquery call, against Base’s roughly 2-second block time. client.multicall resolves the canonical Multicall3 deployment on Base automatically, same one round trip evmquery’s three reads collapse into. sequencerStartedAt (1782491507) and feedAnswer/feedUpdatedAt (242067831384, 1792382433) are identical to the evmquery result above, scaled the same way (242067831384 / 10^8 = 2420.67831384). That’s not a coincidence of similar inputs, it’s the same Chainlink round on both sides: the ETH/USD feed on Base commits to a 20-minute heartbeat, and 54 blocks (roughly two minutes) isn’t long enough for a new round to land. The sequencer, per timeSinceSequencerUpSeconds, has been up for about 114 days straight, comfortably outside the one-hour grace period.

This is a stronger parity check than it looks

The Uniswap V3 migration post shows evmquery and viem landing on different, six-blocks-apart prices for a continuously-moving pool, and explains why that gap is expected, not a bug. A Chainlink price feed doesn’t move continuously, it updates in discrete rounds. Two independent reads, on two different clients, hitting the exact same round with matching timestamps is closer to a checksum than a benchmark: either both implementations read the same on-chain state correctly, or both are wrong in the exact same way, and there’s no way a resolver bug on evmquery’s side and a hand-written ABI mismatch on viem’s side would coincidentally agree down to the second.

Searching for “Chainlink viem” turns up more than this blog alone, and it’s worth being direct about that rather than pretending otherwise. Third-party integration guides already cover the staleness check, the Base sequencer-uptime guard, and a cached decimals() call, in viem, correctly. What they don’t do is batch the three reads into one round trip. The common pattern is Promise.all over three separate readContract calls, each one its own RPC round trip against a public node, rather than the single multicall block this post uses.

That difference compounds. A Promise.all version still makes three HTTP requests after batching client-side promise resolution, viem’s own concurrency doesn’t collapse them into one network call unless you ask it to with multicall. For a one-off read the difference barely registers. For a price-alerting job polling every feed on a schedule (see evmquery for automation for the automation side of that), three round trips per feed instead of one is the difference between a polling job that scales to dozens of feeds and one that starts hitting rate limits well before it gets there. evmquery’s resolver batches by default because every read through it is, structurally, a multicall; the raw viem code above only gets the same property because this post wrote client.multicall instead of the more commonly copy-pasted Promise.all.

What this actually proves

Both code blocks in this post ran against live infrastructure while writing it, the same standard the evmquery vs. raw viem benchmark holds itself to. The honest version of the comparison: 56 lines of viem for a well-documented, widely-integrated feed pair isn’t exotic. The ABI fragment is public, the sequencer feed address is Chainlink’s own published one, and the staleness/grace-period arithmetic is Chainlink’s own recommended pattern, not evmquery’s invention. A developer who already read the original three-check guide carefully could write this file in twenty minutes.

What’s less visible until you actually try it, and what this post’s own research run just hit directly, is that the surface keeps shifting under you in small ways: an expression that worked when the original guide was written stopped working by the time this post re-ran it, for a reason (string(sol_int) losing its overload) that has nothing to do with Chainlink and everything to do with evmquery’s own CEL dialect evolving. Running the live check caught it before it reached a reader; a copy-pasted REST example in a tutorial that isn’t re-validated on a schedule wouldn’t have caught it at all. That’s not an argument that hand-rolled viem code rots faster, it’s the opposite: viem’s readContract ABI calling convention is stable in a way a hosted query language’s internal type-checking rules aren’t guaranteed to be. The genuine trade-off, for a developer deciding between the two, is 56 lines of code that won’t move under you later versus nine lines that might, in exchange for not needing to already know Chainlink’s AggregatorV3Interface return shape or remember to call client.multicall instead of Promise.all.

Next steps

Share

Skip the migration, for now

The expression benchmarked in this post is live. Get a free key and run the three-check query against your own feeds before you decide whether to hand-roll it.