Every query-layer vendor’s landing page says some version of “saves you hours of RPC plumbing.” Almost none of them show the plumbing they claim to save you from, and fewer still show a number a reader could reproduce. This post does both: real, complete code for reading five fields across three differently-proxied contracts by hand, the same read as one evmquery expression, and an honest accounting of what actually shrinks, what doesn’t, and what evmquery still doesn’t do for you.
TL;DR
Reading 5 numeric fields across 3 proxy-wrapped Ethereum contracts takes 38 lines of hand-written viem (already using viem’s own multicall action, the honest best case) versus 22 lines of an evmquery expression, for the same 1 network round trip. The real difference is ABI discovery and knowing which addresses are proxies at all, not a network trick: skip multicall and write the naive per-call version instead, and raw viem costs 5 round trips, not 1.
Exact scenario benchmarked
- Scenario: read
totalSupply()anddecimals()on USDC,getReservesCount()andMAX_NUMBER_RESERVES()on Aave V3’s Pool, andtotalSupply()on a beacon-proxied ERC-721 avatar contract, 5 fields across 3 Ethereum mainnet contracts, in one request. - Each of the 3 contracts sits behind a different proxy pattern: USDC through a legacy zOS/OpenZeppelin proxy, Aave’s Pool through an EIP-1967 transparent proxy, and the ERC-721 avatar contract through an EIP-1967 beacon proxy.
- The evmquery side was executed live against evmquery’s API while writing this post, on 2026-08-05, at Ethereum block 25,690,819: 5 on-chain calls, 1 execution round, 6 units consumed.
- The viem code is real and complete, checked against each contract’s genuine implementation ABI, but was not executed against a live RPC in this writing session. No latency number is claimed for either side; see the methodology callout below.
The scenario, in full
Three real, independently verifiable Ethereum mainnet contracts, chosen because each uses a different proxy pattern and a reader can look up every address below directly on Etherscan:
| Contract | Address | Proxy pattern | Fields read |
|---|---|---|---|
| USDC | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 |
Legacy zOS/OpenZeppelin proxy | totalSupply(), decimals() |
| Aave V3 Pool | 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2 |
EIP-1967 transparent proxy | getReservesCount(), MAX_NUMBER_RESERVES() |
| ERC-721 avatar contract | 0x8712238c3CCE66f7207e60BdaBF615D9A9C3d299 |
EIP-1967 beacon proxy | totalSupply() |
None of these are contrived. USDC is the most-integrated ERC-20 on Ethereum. Aave V3’s Pool is the entry point for every lending read on the protocol. The third contract is a real, deployed ERC-721 collection behind a beacon, the pattern where many proxies share one upgrade point through an intermediate beacon contract. Reading five numbers across three contracts like this, a mixed dashboard read, not a single-token balance check, is closer to what a real integration looks like than a synthetic benchmark contract built to make one side look good.
Raw viem, written properly
This is the honest baseline: not a naive strawman, but the correct way to do this in viem, using its own multicall action so it batches into one round trip. Getting here still requires knowing, ahead of time, that all three addresses are proxies, so you go looking for the implementation’s ABI instead of the one Etherscan shows for the proxy address itself. None of that comes from the address alone; you have to source the correct implementation ABI per contract before this compiles.
import { createPublicClient, http, type Abi } from "viem";import { mainnet } from "viem/chains";
const client = createPublicClient({ chain: mainnet, transport: http() });
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";const AAVE_POOL = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2";const AVATAR = "0x8712238c3CCE66f7207e60BdaBF615D9A9C3d299";
// Each address below is a proxy, but viem calls it directly either way:// the EVM delegates internally regardless of pattern. What's required is// the *implementation*'s ABI, not the proxy's, since USDC, Aave's Pool, and// the ERC-721 avatar contract each forward to a different contract than the one// being called.const erc20Abi = [ { name: "totalSupply", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, { name: "decimals", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "uint8" }] },] as const satisfies Abi;
const aavePoolAbi = [ { name: "getReservesCount", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, { name: "MAX_NUMBER_RESERVES", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "uint16" }] },] as const satisfies Abi;
const erc721EnumerableAbi = [ { name: "totalSupply", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] },] as const satisfies Abi;
const [usdcSupply, usdcDecimals, reservesCount, maxReserves, avatarSupply] = await client.multicall({ contracts: [ { address: USDC, abi: erc20Abi, functionName: "totalSupply" }, { address: USDC, abi: erc20Abi, functionName: "decimals" }, { address: AAVE_POOL, abi: aavePoolAbi, functionName: "getReservesCount" }, { address: AAVE_POOL, abi: aavePoolAbi, functionName: "MAX_NUMBER_RESERVES" }, { address: AVATAR, abi: erc721EnumerableAbi, functionName: "totalSupply" }, ], allowFailure: false,});That’s 38 lines total, 32 of them non-blank, count them yourself in the block above. The getReservesCount/MAX_NUMBER_RESERVES return types come from Aave’s public IPool.sol interface; the ERC-20 and ERC-721 Enumerable fragments are the standard interfaces. None of that ABI research shows up in the line count, only the code that survives it.
The same read as one evmquery expression
const apiKey = process.env.EVMQUERY_API_KEY!;const query = { chain: "evm_ethereum", schema: { contracts: { usdc: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", aave_pool: "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2", avatar: "0x8712238c3CCE66f7207e60BdaBF615D9A9C3d299", }, }, expression: "[usdc.totalSupply(), usdc.decimals(), aave_pool.getReservesCount(), aave_pool.MAX_NUMBER_RESERVES(), avatar.totalSupply()]",};
const res = await fetch("https://api.evmquery.com/api/v1/query", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": apiKey }, body: JSON.stringify(query),});
const { result } = await res.json();// result.value = ["49414349671864702", "6", "67", "128", "3"]22 lines total, 20 non-blank. No ABI import, no proxy identification, no per-contract fragment. evmquery resolved all three proxies server-side and reported it back when asked (describe_schema shows dispatches via zeppelinos, dispatches via eip1967, and dispatches via eip1967-beacon to ... then beacon-implementation to ... for the three contracts respectively), and the query above returned exactly the values in the comment, live, at block 25,690,819.
Round trips: naive vs. multicall vs. evmquery
The line-count comparison above already uses viem’s own batching. If you skip multicall and write the version most tutorials show first, five separate await client.readContract(...) calls, that’s five separate eth_call round trips over the network, regardless of which library issues them:
| Approach | Round trips | ABI/proxy research required |
|---|---|---|
Naive viem (5 separate readContract calls) |
5 | Same as the multicall version below |
viem + multicall action |
1 | Full: confirm each address is a proxy, source the correct implementation ABI, per contract |
| evmquery | 1 | None: point at the address, name the method |
One distinction the table above flattens: viem’s “1” is one round trip from your own process to your RPC endpoint. evmquery’s “1” is one HTTPS request from your process to evmquery, behind which sit the same 5 on-chain calls in 1 Multicall3 round executed server-side (the Calls: 5, Rounds: 1 metadata shown earlier). Both are “1” from the calling code’s point of view; evmquery’s version just moves the node hop behind an API instead of making it directly.
On round trips alone, correctly-written viem and evmquery tie at 1. That’s not a knock on viem, Multicall3 is a public contract and viem’s multicall action uses it well. The gap this post can actually measure is upstream of the network call: the ABI and proxy research that has to happen before either version compiles.
How this was measured
Line counts and round-trip counts are things you can verify yourself by counting the code blocks above and reading Multicall3’s aggregate3 semantics; neither is simulated or rounded for effect. The evmquery side ran live against evmquery’s API on 2026-08-05 at Ethereum block 25,690,819 (5 calls, 1 round, shown in the key facts above). The viem code is real and complete, cross-checked against Aave’s public IPool.sol interface and the standard ERC-20/ERC-721 Enumerable ABIs, but it was not executed against a live RPC endpoint in this writing session. Neither side of this post cites a timed latency number: we don’t have a way to gather enough samples against production RPC infrastructure from this writing environment to produce a figure worth trusting, and a single anecdotal run wouldn’t describe your network path anyway. For a real number, look at the performance.latencyMs field evmquery’s own API returns on every request, server-measured, for your query, not a canned one.
What evmquery does not remove
A benchmark that only lists wins is marketing, not proof. Here’s what stays exactly as much work as it was before:
- Only 3 chains today. Ethereum, Base, and BNB Smart Chain are supported; Arbitrum, Optimism, and everything else are not. A cross-chain version of this same read is still one Multicall3 batch per chain, not one batch total, on evmquery or on raw viem.
- Read-only. Nothing here replaces viem or ethers plus a signer for the write half of an app.
eth_sendRawTransactionand everything downstream of it is still yours to wire up. - ABI resolution can fail. If a contract has no verified source on Etherscan or Sourcify and doesn’t match a known interface or selector database, evmquery can’t invent an ABI any more than a human can; you upload one yourself.
- SEL has its own sharp edges. Building the exact query above, a single list literal mixing string and numeric return types threw
List elements must have the same typeuntil every field in it was normalized tosol_int. Lists and maps in evmquery’s expression language are type-uniform; a heterogeneous read needs separate queries, not one list literal. - No published latency or uptime numbers yet. evmquery’s own measured p50 latency and uptime figures are still pending internally. Until they’re measured and published, the honest position is the same one this post takes with its own numbers: don’t take a vendor’s word for a figure it hasn’t published either.
If the read in question is coming from an agent instead of your own code, Claude or Cursor asking a live contract-state question mid-conversation, the same resolution runs behind the MCP server instead of the REST endpoint shown above; the AI agent integration overview covers that surface specifically.
Next steps
- Multicall3 guide covers the batching mechanics viem’s
multicallaction is built on, including the proxy pitfalls that show up once you’re batching. - Fixing “could not decode result data (0x)” walks through the same zOS/EIP-1967 proxy identification problem from a debugging angle.
- Moralis vs Alchemy vs QuickNode vs evmquery compares evmquery against the other layers a team might reach for instead of raw RPC.
- Pricing if you want to run this exact query against your own contracts.



