How to Get the ABI of an Unverified or Proxy Contract

Etherscan has no source, or the wrong source. Here's how to get the ABI of an unverified contract or a proxy: read the EIP-1967 slot, recover selectors from bytecode, or skip both steps.

evmquery team··9 min read
Share
Getting the ABI of an unverified or proxy contract: EIP-1967 storage slots and bytecode selector recovery

Etherscan shows you a contract’s ABI when someone verified its source. Plenty of contracts never get that treatment, and plenty more are proxies, where the address you have and the address whose code actually runs are two different places. Either way you’re left with an address, no usable interface, and a call you can’t build. Getting the ABI of an unverified contract, or the real ABI behind a proxy, means reconstructing the interface yourself instead of copying it off a page.

TL;DR

For proxies, read the EIP-1967 storage slot directly, it’s a fixed, standardized location, so you don’t need the proxy’s own ABI to find the implementation address. For unverified contracts, walk the bytecode’s selector dispatcher and match what you find against a signature database. evmquery does both automatically and returns one typed ABI regardless of which case you’re in.

Why Etherscan has no ABI (or the wrong one)

There are two distinct ways to end up staring at an address with nothing to call.

The first is a genuinely unverified contract: nobody ever submitted source, so Etherscan shows raw bytecode and, at best, a “Method ID: 0x…” next to any transaction that hit it. No names, no argument types, nothing to build a call from.

The second is subtler and catches more people: the contract is verified, but what’s verified is a proxy. Its own source is usually a few dozen lines, an implementation() getter, a fallback() that delegatecalls onward, and not much else. The functions you actually want, the ones a user or an integration calls, live on a different address entirely. Etherscan can link the two if someone runs its “Is this a Proxy?” detection on that specific contract page, and it handles the common patterns, but it’s an opt-in, per-contract action, not something that happens automatically for every proxy shape. Miss it, and the ABI you’re looking at describes the wrapper, not the logic behind it.

Both cases end at the same place: you have an address and no ABI you can trust. The fix is different for each, so we’ll take them in turn, proxies first.

Proxies: read the EIP-1967 implementation slot

EIP-1967 exists because early proxy patterns stored the implementation address in an arbitrary storage slot, one the proxy’s own logic picked, which could collide with a variable the implementation contract also wanted to use at that same slot. EIP-1967 fixes the collision problem by reserving a slot nobody would pick by accident: the low 256 bits of keccak256("eip1967.proxy.implementation") - 1.

import { keccak256, toBytes } from "viem";
const slot = BigInt(keccak256(toBytes("eip1967.proxy.implementation"))) - 1n;
console.log("0x" + slot.toString(16));
// 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

That’s a fixed constant, the same for every EIP-1967 proxy on every EVM chain. You don’t need the proxy’s ABI, or any ABI at all, to read it, eth_getStorageAt takes a slot, not a function call:

curl -s https://ethereum-rpc.publicnode.com -X POST -H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0", "id": 1, "method": "eth_getStorageAt",
"params": [
"0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2",
"0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc",
"latest"
]
}'
# {"result":"0x000000000000000000000000728a138a4823392c2efa55e028d434f526fe03cf"}

That address is Aave V3’s Pool contract on Ethereum. The last 20 bytes of the returned word, 0x728a138A4823392C2EFA55e028d434F526fE03CF, are its implementation, live-verified while writing this post and matching the same address evmquery’s own resolution reports for the same contract. Once you have that address, you’re back to a normal problem: if it’s verified, its ABI is on Etherscan; if it isn’t, the bytecode-recovery section below applies to it instead of the proxy.

Not every proxy uses this slot

USDC’s Ethereum deployment (0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) is itself upgradeable, evmquery’s own resolution confirms it dispatches to an implementation at 0x43506849D7C04F9138D1A2050bbF3A0c054402dd, but reading the EIP-1967 slot on it returns all zeros. Circle’s proxy predates EIP-1967 by a few years and uses its own legacy zeppelinOS slot instead. A zero read at the standard slot means “not this pattern,” not “not a proxy.” See which major contracts are upgradeable proxies for a wider census of which pattern real contracts actually use.

Two variants worth knowing before you assume one slot covers every proxy:

  • Beacon proxies store a beacon address, not the implementation, at a second reserved slot (keccak256("eip1967.proxy.beacon") - 1, 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50). Reading that slot on a beacon-proxied ERC-721 on Ethereum (0x8712238c3CCE66f7207e60BdaBF615D9A9C3d299) returns 0x415eaCC51dc77E97C6bebb3296d5FFB84cCe5d8F, live-verified the same way, and that address is the beacon, not the implementation. You then call the beacon’s own implementation() getter to get the second hop.
  • UUPS proxies reuse the exact same implementation slot as a transparent proxy, the standard was written so both patterns store the address the same way, the only difference is who’s authorized to call the upgrade function. Diamonds (EIP-2535) are the real exception: there’s no single implementation slot to read at all, a diamond maintains a facet-to-selector mapping instead, and recovering its interface means enumerating facets through the diamond’s loupe functions, not a storage read.

The proxy detector runs this resolution for you across all four patterns and reports which one matched, without you writing a single storage read by hand.

Unverified contracts: recover selectors from bytecode

No source doesn’t mean no information. Every external Solidity function is reachable through a dispatcher at the start of the runtime bytecode: a chain of PUSH4 <selector> DUP EQ JUMPI blocks, one per function, that compares the first four bytes of your calldata against each function’s selector until one matches, then jumps into that function’s code. Walking that dispatcher recovers every selector the contract responds to, directly from bytecode, no source required.

What you get back from that walk is a list of 4-byte hashes, not names. 0xa9059cbb doesn’t tell you it’s transfer(address,uint256), it’s the result of hashing that signature and keeping the first four bytes. To turn a selector back into a readable name, you check it against a signature database: 4byte.directory and OpenChain are both crowd-sourced indexes of known selector-to-signature mappings, built by everyone who’s ever submitted an ABI to them.

WhatsABI (@shazow/whatsabi on npm) automates the whole loop, bytecode fetch, selector extraction, proxy detection, and signature-database lookups, in one call:

import { createPublicClient, http } from "viem";
import { mainnet } from "viem/chains";
import { whatsabi } from "@shazow/whatsabi";
const client = createPublicClient({ chain: mainnet, transport: http() });
const result = await whatsabi.autoload("0x...", { provider: client });
console.log(result.abi);

Under the hood, autoload is just provider.getCode(address) followed by whatsabi.selectorsFromBytecode(code) for the raw selector list and whatsabi.abiFromBytecode(code) for an ABI-shaped result, with whatsabi.loaders.OpenChainSignatureLookup filling in names where a match exists. evmole does the same core job, selector and argument-type extraction from bytecode, with a Rust core and Python and JS bindings, and tends to be faster on large contracts.

When a selector isn’t in any signature database at all, no name exists to look up, tools like heimdall-rs and Dedaub’s decompiler go a step further and reconstruct readable pseudocode from the bytecode’s control flow, which is often enough to infer what a function does and guess reasonable argument types even with zero matching signature anywhere.

What signature databases can’t tell you

A signature-database hit gets you a function name and, from the signature string itself, the argument types in declaration order, transfer(address,uint256) tells you two arguments, an address and a uint256, in that order. What it doesn’t reliably get you:

  • Return types. A function’s selector is computed from its name and argument types only, the return type isn’t part of the hash, so a signature database entry for balanceOf(address) tells you nothing about what it returns. You either know the ERC-20 convention already or decode the raw output speculatively and check that a uint256 interpretation makes sense.
  • Parameter names. transfer(address,uint256) doesn’t say whether the second argument is an amount, a token ID, or something else, only its type. Names aren’t part of a signature at all.
  • Collisions. A 4-byte selector is short enough that unrelated functions hash to the same value. A database lookup can hand you a plausible, wrong signature for a given selector, and it will decode without erroring, it’ll just decode against the wrong function.

None of that makes a signature-database match useless, it’s usually enough to call the contract without the ABI you’d otherwise need to go find, which is the whole point when you have nothing else. It just means a bytecode-recovered ABI carries a lower confidence label than a verified one, and code built against it should treat return values and argument meaning as best-effort until confirmed against the contract’s actual behavior.

The one-step way

Both paths above are real, and worth knowing how to do by hand. In practice, resolving either one is one HTTP call with evmquery: point describe_schema at an address and it works through the same resolution order a human would, verified source first (Sourcify, then Etherscan), then EIP-1967, beacon, UUPS, and diamond proxy detection, then known-interface matching against common standards, then signature-database and bytecode-recovery fallbacks, and returns the read functions it found, tagged with where each method came from.

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": { "usdc": { "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" } } },
"include": ["resolution"]
}'

For USDC, that comes back with resolution.route showing the legacy zeppelinOS hop to 0x43506849D7C04F9138D1A2050bbF3A0c054402dd, plus all 23 read methods (balanceOf, allowance, totalSupply, and the rest) already bound to that implementation, sourced from Sourcify, live-verified while writing this post. evmquery is a read layer, so describe returns view and pure functions only; write functions like transfer, events, and custom errors aren’t part of the response. The same call against a beacon proxy reports both hops in order; against a genuinely unverified contract, it falls through to the signature-database and bytecode-recovery steps described above and tags the result accordingly instead of returning nothing.

What each method's resolution reports

  • _extension.resolution.source names where a method’s ABI fragment came from: verified source (sourcify or etherscan), a known interface match, a signature database, or bytecode recovery, so a caller can tell a high-confidence read from a best-effort one.
  • _extension.resolution.executesAt is the address the method actually runs at, after however many proxy hops separate it from the address you queried.
  • The same resolution powers the contract inspector and, purpose-built for pulling the read-only ABI as JSON, the ABI tool.

If what you actually need next is decoding a specific transaction’s calldata against a resolved ABI rather than just holding the interface, decoding calldata without an ABI file picks up exactly where this post ends. And if you want the concept-level version of what an ABI even is before any of this, start with smart contract ABI explained.

Either the storage read and the bytecode walk, or one call that does both: evmquery for developers covers the rest of the surface, REST, MCP, and the query language these examples use.

Next steps

Share

Skip the storage reads and the bytecode walk

evmquery resolves proxies and recovers selectors from bytecode server-side, then hands back a typed ABI either way.