How evmquery Resolves a Contract Read: ABI, Proxy, and Multicall3

A live walkthrough of what evmquery does between an address and a typed result: ABI resolution, proxy unwinding, and a Multicall3 batch, plus an honest look at portability.

evmquery team··8 min read
Share
How evmquery resolves a contract read: ABI resolution, proxy unwind, Multicall3 batch

You write aave_pool.getReservesCount() and name an address. A moment later a typed value comes back. In between, evmquery has to figure out what that address actually is, whether it forwards to somewhere else, and how to fold your read into as few on-chain round trips as possible, all without you telling it any of that. This post walks through exactly what happens in that gap, with a real address, a real proxy, and a real response, then addresses the question every hosted layer owes an honest answer to: what happens if you need to stop using it.

TL;DR

A contract read against evmquery does three things server-side before it does one thing on-chain: resolve the ABI, follow however many proxy hops separate the address you gave it from the contract that actually holds the logic, and fold every independent read into one Multicall3 round per chain. The response is plain JSON either way, and describe_schema will hand you the resolved address and ABI for any contract you’ve queried, which is what you’d need to hand-write the same call in viem or ethers if you ever left.

What evmquery does and doesn't do with a query

  • evmquery’s privacy policy states it does not retain the full decoded result payload of a query beyond what’s needed to deliver the response; what it logs is a timestamp, the chain and contract address targeted, and a hash of the expression, not the expression text itself, kept at full fidelity for 30 days before aggregation or deletion.
  • Contract resolution recognizes ten distinct dispatch kinds server-side (per the ProxyKind type in evmquery’s own codebase): EIP-1167 minimal proxy clones, EIP-1967 transparent proxies, EIP-1967 beacon proxies and the separate beacon-implementation hop that follows them, EIP-1822 UUPS, the legacy zeppelinOS/OpenZeppelin proxy, Gnosis Safe, EIP-2535 diamonds, EIP-7702 delegation, and plain getter-based indirection.
  • Every response is plain JSON over a REST endpoint (result.value, result.type, meta.blockNumber), readable with fetch or requests and nothing else. An optional typed @evmquery/sdk package exists for convenience, but it isn’t required to parse a response.

What “resolving a contract” actually means

An address by itself tells you nothing about what functions it exposes. Most production contracts you’ll actually integrate against sit behind a proxy, so the address you’re given isn’t even where the logic lives; it’s a thin forwarder. Resolving a read against that address means answering three questions before a single eth_call goes out: what ABI does this address respond to, is it a proxy and if so what does it forward to, and which of the reads you asked for can be grouped into one on-chain round trip instead of several. evmquery answers all three server-side. The rest of this post runs that pipeline against two real, differently-proxied Ethereum contracts, live, while writing this post.

Step one: a one-hop unwind

Aave V3’s Pool contract on Ethereum is the entry point for every lending read on the protocol, and it sits behind a standard EIP-1967 transparent proxy. Asking evmquery to describe it, with resolution included, shows the hop before any read happens. The response below follows the documented _extension.resolution schema exactly (route is an ordered list of { kind, to } hops); it’s trimmed to one method for length, but the resolution, the address, and the method name are the same ones verified live while writing this post:

curl -X POST https://api.evmquery.com/api/v1/query/describe \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chain": "evm_ethereum",
"schema": {
"contracts": { "aave_pool": { "address": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" } }
},
"include": ["resolution"]
}'
# {
# "contracts": [{
# "name": "aave_pool",
# "address": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2",
# "_extension": { "resolution": {
# "status": "verified",
# "route": [{ "kind": "eip1967", "to": "0x728a138A4823392C2EFA55e028d434F526fE03CF" }]
# }},
# "methods": [
# { "name": "getReservesCount", "_extension": { "resolution": {
# "executesAt": "0x728a138A4823392C2EFA55e028d434F526fE03CF", "source": "sourcify" } } }
# # every other method on this contract resolves to the same implementation address
# ]
# }]
# }

One hop, one implementation address, and every method already bound to it. Running the actual read confirms it, live, at the time of writing, over the REST envelope evmquery documents (result.value, result.type, meta.blockNumber, meta.totalCalls, meta.totalRounds):

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_ethereum",
"schema": {
"contracts": { "aave_pool": { "address": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" } }
},
"expression": "[aave_pool.getReservesCount(), aave_pool.MAX_NUMBER_RESERVES()]"
}'
# {
# "result": { "value": ["67", "128"], "type": "list<sol_int>" },
# "meta": { "blockNumber": 25690921, "totalCalls": 2, "totalRounds": 1 }
# }

Two fields, one contract, one proxy hop resolved automatically, one round, verified live at block 25,690,921.

Step two: a two-hop unwind

Not every proxy resolves in one hop. This second contract, a beacon-proxied ERC-721 on Ethereum, sits behind an EIP-1967 beacon proxy, which is itself an extra layer of indirection: the proxy points at a beacon contract, and the beacon points at the actual implementation. The same POST /query/describe call reports both hops in order, in its route array:

curl -X POST https://api.evmquery.com/api/v1/query/describe \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chain": "evm_ethereum",
"schema": {
"contracts": { "avatar": { "address": "0x8712238c3CCE66f7207e60BdaBF615D9A9C3d299" } }
},
"include": ["resolution"]
}'
# {
# "contracts": [{
# "name": "avatar",
# "address": "0x8712238c3CCE66f7207e60BdaBF615D9A9C3d299",
# "_extension": { "resolution": {
# "status": "verified",
# "route": [
# { "kind": "eip1967-beacon", "to": "0x415eaCC51dc77E97C6bebb3296d5FFB84cCe5d8F" },
# { "kind": "beacon-implementation", "to": "0x4C9feE9218DCC2d11374dD9ca80669fF9D58d0eD" }
# ]
# }},
# "methods": [
# { "name": "totalSupply", "_extension": { "resolution": {
# "executesAt": "0x4C9feE9218DCC2d11374dD9ca80669fF9D58d0eD", "source": "sourcify" } } }
# # every other method on this contract resolves the same two hops down
# ]
# }]
# }

The beacon indirection (eip1967-beacon) and the hop it points through to reach the implementation (beacon-implementation) are reported as two separate steps, not collapsed into one. A caller working this out by hand would need to read the proxy’s beacon slot, then read the beacon contract’s own implementation getter, before it could even look up the right ABI.

Step three: one Multicall3 round across both

The point of resolving each address individually is to make it possible to batch all of them into one request. Asking for a field from each of the two contracts above, which sit behind two different proxy patterns with two different hop counts, still executes as one on-chain round trip:

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_ethereum",
"schema": {
"contracts": {
"aave_pool": { "address": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" },
"avatar": { "address": "0x8712238c3CCE66f7207e60BdaBF615D9A9C3d299" }
}
},
"expression": "[aave_pool.getReservesCount(), avatar.totalSupply()]"
}'
# {
# "result": { "value": ["67", "3"], "type": "list<sol_int>" },
# "meta": { "blockNumber": 25690926, "totalCalls": 2, "totalRounds": 1 }
# }

Address in, ABI resolved twice over two different proxy shapes, one field read from each, one Multicall3 round out, at block 25,690,926. That’s the full pipeline this post set out to show, and every number above is a live server response, not a canned example. (The MCP tool used to verify this response also reports units consumed per query, 3 units for this one; that figure is a metering detail of the tool, not a field in the REST envelope shown above.)

What evmquery keeps, and what it doesn’t

The KeyFacts block above summarizes evmquery’s own privacy policy on this: for each query, it logs a timestamp, the chain and contract address targeted, a hash of the expression rather than the expression text, the result status, and a short-lived debugging trace, and it explicitly does not retain the full decoded result payload beyond delivering the response to you. Usage logs at full fidelity are kept 30 days, after which they’re aggregated or deleted. If the addresses in your expression are wallet addresses rather than protocol contracts, evmquery’s Data Processing Addendum treats you as the controller of that data and evmquery as the processor, since a wallet address can be personal data depending on what else it’s tied to.

None of this is a claim that no data is logged; it’s a claim about which parts are, sourced directly from the published policy rather than assumed.

If evmquery disappeared tomorrow

The honest version of this question starts with the wire format: every response is plain JSON over a REST endpoint. result.value, result.type, meta.blockNumber. Nothing about that shape requires evmquery’s client, evmquery’s language, or evmquery’s account to read. A fetch call and JSON.parse is the whole client. An optional typed SDK (@evmquery/sdk) exists, but you were never required to use it.

What doesn’t exist is a single “export my integration” button. What does exist, for any contract you’ve ever pointed evmquery at, is exactly what this post just ran: describe_schema with include: ["resolution"], which hands back the resolved implementation address, the hop chain that got there, and the ABI signature for every method, live, for the cost of a request. That’s the same information a hand-rolled viem or ethers migration would need to hardcode instead of asking evmquery’s resolver to find it. Pull that output down for the contracts your integration depends on, and you have what evmquery’s server-side resolution found, without evmquery in the loop.

Where this blog is honestly uneven: some posts here, like the Multicall3 guide, the evmquery vs. raw viem benchmark, and the “0x” debugging post, show the equivalent hand-written viem or ethers code next to the evmquery expression, so migrating off is closer to deleting a dependency than rewriting logic. Others, like the Aave health-factor guide or the Uniswap V3 pool-data guide, show only the evmquery call, with no hand-rolled equivalent published alongside it. If your integration leans on one of those, leaving means writing the raw multicall yourself, using the resolved address and ABI above as the starting point, not copying code this blog already wrote for you.

What genuinely doesn’t move if you leave: the chain state itself was never evmquery’s to hold onto. It’s public. There’s no dataset behind an account wall, no proprietary export format, nothing evmquery keeps that you’d need permission to take with you. The only thing that goes away is the resolution and batching work. evmquery’s own developer-focused overview names exactly this trade-off in its FAQ: you can still wire viem and an RPC key yourself, free and available whether or not evmquery exists; evmquery’s pitch is only that it does that wiring for you.

Next steps

Share

Try the exact query in this post

Both contracts, both proxy patterns, one round trip. Get a free key and point the same expression at your own contracts.