DEV Community

Cover image for Build an Unbreakable EVM Read Stack in Python: Async Web3.py, ABIs, Proxies, and Decimals
OnlineProxy
OnlineProxy

Posted on

Build an Unbreakable EVM Read Stack in Python: Async Web3.py, ABIs, Proxies, and Decimals

You don’t realize how fragile your on-chain tooling is—until it breaks the first time. You open a block explorer, gas spikes, your script hangs, and Web3.py throws a mysterious async error. I’ve seen this movie too many times. The good news: you can build a clean, async-first EVM data stack in Python that just works and keeps scaling from “read a single balance” to “query hundreds of contracts across networks.”

Below is a practical blueprint. It is opinionated, battle-tested, and focused on the parts that actually cause friction in real life: environment, async connectivity, ABI handling (with proxies), address hygiene, and dealing with decimals without shooting yourself in the foot.

Let’s go.

Intro hook: “Why does this feel harder than it should?”

  • You’ve connected to an RPC—but you’re “connected” and still get timeouts.
  • You called a read function—but it requires parameters you can’t guess.
  • You read a token balance—but fromWei gave you nonsense.
  • You pulled an ABI—but the contract uses a proxy, so nothing matches.
  • You compared two addresses—but they look identical and still don’t match.

If any of that rings a bell, this guide is for you.

The 5-Layer EVM Data Stack in Python (framework to remember)

  • Layer 1 — Environment: Python version, virtualenv, dependable libs.
  • Layer 2 — Connectivity: RPC choice, async provider, HTTP client strategy.
  • Layer 3 — Representation: checksummed addresses, units, decimals.
  • Layer 4 — ABI mastery: sources, proxies, and minimal ABIs.
  • Layer 5 — Contract I/O: read calls that succeed, at scale.

Question: Why your Web3.py stack breaks on Python 3.12?

Because Web3.py hasn’t fully supported 3.12 the way you expect. Use Python 3.11. That’s not personal preference; it’s a practical constraint. Set up a virtual environment on 3.11 and pin your versions. A recent Web3.py release printed errors despite “working” under the hood; to keep the console clean, install the previous stable release (as of October 7, 2024, 6.14.0 was the known-good). Don’t lose hours debugging the runtime itself—stabilize the base stack first.

Environment checklist (minimal, boring, essential)

  • Python: Install 3.11 (not 3.12).
  • Virtualenv: Create and activate a virtual environment on 3.11.
  • Web3.py: Install a stable version (e.g., 6.14.0 at the time referenced).
  • Async HTTP: Prefer curl.cffi (fast, async), but note it may fail on older macOS. If it does, fallback to aiohttp or httpx.
  • Headers: Use fake-user-agent if you need user agent rotation.
  • Pin requirements: Put your stack into requirements.txt and install it as a block. It’s cheap insurance.

Question: What exactly is an RPC—and how do you pick a good one?

An RPC is the node endpoint you speak to. It’s the server on the other end of your dialog with the chain. You ask: What’s the gas price? What’s the chain ID? What’s the latest block? It answers. If you change the RPC URL, you’re speaking to another network entirely.

Where to discover RPCs
Use Chainlist. It provides multiple HTTP and WebSocket endpoints per network, plus ping, availability and (sometimes) privacy hints. Copy an HTTP endpoint to start; you can switch to WebSocket later if you need event streams.

Async connectivity that doesn’t fight you
Use AsyncWeb3 with an async HTTP provider. Web3.py now ships async support “out of the box”—no middleware hacks. You’ll get the ergonomics to scale queries with asyncio.gather, which beats spinning up threads for I/O-bound workloads.

Pro insight: “Properties” you still await
Some JSON-RPC endpoints look like properties but behave like coroutines under the hood. You’ll awaitthem without parentheses:

  • await w3.eth.gas_price
  • await w3.eth.max_priority_fee (EIP-1559 networks)
  • await w3.eth.chain_id
  • await w3.eth.block_number It’s peculiar at first glance—await, but no ()—and a common source of confusion.

Read vs. write: the only distinction that matters (and costs money)

  • Read functions: Don’t mutate state, don’t cost gas, don’t require sending a transaction.
  • Write functions: Change state, execute inside a transaction, and cost gas.

Most of your market data, balances, metadata, and “is-this-paused?” checks are reads. Build your muscle here; writes are a natural extension later.

Question: Where do ABIs come from—and which ABI do you copy with proxies?

The ABI is the contract’s interface: function names, parameter types, return types, and mutability. You can find it on a block explorer under the contract’s “Contract” tab.

But here’s the trap: proxies. If a contract is a proxy (common for major tokens), the ABI you see on the outer contract may be tiny—just admin and a few proxy admin calls. The real implementation ABI lives behind “Read as Proxy” / “Write as Proxy” or on the linked implementation contract. For read calls, you need the implementation ABI. Address stays the proxy address; ABI comes from the implementation. That mismatch (proxy address, implementation ABI) is the correct, expected combination.

ABI handling patterns that scale

  • As JSON in a file: Place ABIs under abi/ and load via a helper (e.g., ReadJSON(path) wrapping json.load).
  • Inline JSON string: Quick and dirty for a single-off test.
  • Minimal Python object: Build a tiny ABI list with just the functions you’ll call (name, symbol, decimals, balanceOf). Omit internal types; focus on inputs, outputs, stateMutability, type. For ERC-20 reads, this is surprisingly ergonomic.

Senior pro tip: ERC‑20 ABIs are interchangeable

Tokens that conform to ERC-20 expose the same interface. If your reads are limited to canonical functions (name, symbol, decimals, balanceOf, totalSupply, allowance, etc.), you can reuse the same ABI file across USDT, USDC, DAI and co. That’s the power of standardized interfaces: same function names, same parameter types, same return types.

Question: Why does fromWei “lie” to you on tokens?
It doesn’t—but many misuse it. fromWei/toWei helpers assume native units (18 decimals by default). That’s fine for ETH or other native tokens. It is wrong for ERC-20 tokens whose decimals often differ (USDC/USDT are 6). If you pass a token balance (in its smallest units) into a native converter, you’ll divide by 10^18 instead of 10^decimals.

The rule:

  • Native coin: convert with helpers (or divide by 10^18).
  • ERC-20: first call decimals, then format amounts by dividing by 10 ** decimals.

Better yet: store and compute in base units (no floats!) and only format to human-readable strings at the edge.

Addresses: checksum isn’t cosmetic—it’s your guardrail
EVM addresses are case-insensitive as raw hex, but checksummed addresses (EIP-55) encode a validation hash in their casing. Benefits:

  • Detects typos early.
  • Enables safe equality checks when you normalize your inputs.

Guidelines:

  • Always convert user-supplied or literal addresses with to_checksum_address.
  • Don’t lowercase addresses fetched from chain—they’re already checksummed.
  • Compare addresses in checksummed form to avoid false mismatches.

Quick wins from the chain (you’ll use these everywhere)

  • Gas price: await w3.eth.gas_price, convert to Gwei by dividing by 10 ** 9. M- ax priority fee (EIP-1559 networks): await w3.eth.max_priority_fee.
  • Chain ID: await w3.eth.chain_id (1 for Ethereum mainnet, 56 for BSC, etc.).
  • Latest block: await w3.eth.block_number.

A minimal read flow for any ERC‑20

  1. Pick RPC (HTTP) for the network.
  2. Set AsyncWeb3 with the async HTTP provider.
  3. Checksummed token address.
  4. Load ERC‑20 ABI (shared file).
  5. Create a contract object: w3.eth.contract(address=..., abi=...).
  6. await contract.functions.name().call(), symbol(), decimals(), balanceOf(address).

That’s it. Repeat across tokens without swapping ABIs.

Question: NFT decimals—why do read calls fail?
Because ERC‑721 doesn’t expose decimals. It’s a non-fungible standard; units are inherently integer (token IDs). If you call decimals on an ERC‑721, you’ll either get “function not found” with the NFT ABI, or a runtime revert if you force an incompatible ABI. The practical stance:

  • Treat NFTs as integer-count assets.
  • Wrap decimals in try/except when you don’t know beforehand if you’re talking to ERC‑20 or ERC‑721, and handle the absence gracefully (e.g., assume 0 as the intuition, but don’t fabricate a call result).

Explorer-driven workflows that actually work

  • Finding active NFTs: open the NFT’s page, follow “View on Etherscan,” and read functions under “Read Contract.” Use “Token Tracker” → “Holders” to identify top holders or bridges (common top holders for bridged collections).
  • Fetching top holder’s count: identify the address, then call balanceOf(address) on the contract.
  • Sorting token balances across addresses: pick 10 addresses (CEX labels are handy), allowances aside, call balanceOf for each, normalize by token decimals, sort by the result, print.

Pro sources of friction—and fixes

  • Async but still slow: You wrote async code but run calls sequentially. Use asyncio.gather to parallelize independent read calls.
  • “Connected=True” but responses hang: Upgrade RPC or swap providers; some public endpoints throttle aggressively. Chainlist offers alternatives.
  • Random exceptions on “latest” Web3.py: Pin one version back that’s known clean for your use case.
  • Comparing addresses fails: One side is checksummed, the other is lowercase. Normalize.
  • Formatting amounts looks wrong: You used native helpers on ERC‑20 values; instead, divide by 10 ** decimals for the token you’re reading.

From zero to your first read call (step-by-step checklist)

  1. Install Python 3.11. Avoid 3.12 for now.
  2. Create and activate a virtual environment.
  3. Add dependencies to requirements.txt: web3 (stable previous release), curl.cffi (or aiohttp/httpx), fake-user-agent.
  4. pip install -r requirements.txt.
  5. Initialize AsyncWeb3 with an async HTTP provider and your RPC of choice.
  6. await w3.is_connected(); it should return True.
  7. Read network basics:
  8. Gas price (Wei and Gwei).
  9. Max priority fee (if EIP‑1559).
  10. Chain ID.
  11. Block number.
  12. Normalize target addresses with to_checksum_address.
  13. Create an abi/ directory; store reusable ABIs (e.g., erc20.json).
  14. Load ABI via a helper (ReadJSON(path)), or paste a minimal ABI list as a Python object.
  15. For proxies: use the proxy address, and the implementation ABI (via “Read as Proxy”).
  16. Create the contract: w3.eth.contract(address, abi).
  17. Call reads with await:
  18. await contract.functions.name().call()
  19. await contract.functions.symbol().call()
  20. await contract.functions.decimals().call() (ERC‑20)
  21. await contract.functions.balanceOf(address).call()
  22. Format amounts:
  23. Native: divide by 10 ** 18 for Ether units or use helpers.
  24. ERC‑20: divide by 10 ** decimals.
  25. Scale up: collect addresses, gather balance calls, normalize, sort, print.

A reusable mental model for ABI work (framework)
When you touch any contract, run this short internal checklist:

  • Address: Do I have the right address? Checksummed?
  • Proxy: Is this a proxy? If yes, fetch the implementation ABI (read as proxy).
  • Interface: Does an ERC standard apply (ERC‑20/721)? If yes, reuse standard ABI.
  • Inputs/outputs: What parameters does the function accept? What does it return?
  • Mutability: Is the function view/pure (read) or nonpayable/payable (write)?
  • Decimals: If it’s a token amount, did I use the right exponent?

Asynchronous style that’s native to Web3.py

Your future self will thank you if you standardize on async now. It unlocks:

  • Running multiple reads concurrently with asyncio.gather.
  • Scaling across many addresses without threads.
  • Cleaner control flow when you later add batched writes.

Minimal code: putting it together

  • Initialize connectivity:
    • w3 = AsyncWeb3(AsyncHTTPProvider(RPC_URL))
    • await w3.is_connected() -> True
  • Read core network info (awaited properties without parentheses):
    • await w3.eth.gas_price, await w3.eth.max_priority_fee
    • await w3.eth.chain_id, await w3.eth.block_number
  • Checksum addresses:
    • addr = to_checksum_address(raw_addr)
  • ERC‑20 reads:
    • erc20 = w3.eth.contract(address=token_addr, abi=erc20_abi)
    • name = await erc20.functions.name().call()
    • symbol = await erc20.functions.symbol().call()
    • decimals = await erc20.functions.decimals().call()
    • raw = await erc20.functions.balanceOf(addr).call()
    • human = raw / (10 ** decimals)

NFT reads:

  • name, symbol, ownerOf(tokenId), balanceOf(address) are fine.
  • Skip decimals—it doesn’t exist for ERC‑721; wrap in try/except if you probe dynamically.

Debugging mindset that saves projects

  • Read the error. The final line is the type; the stack shows the site.
  • Wrong ABI? Function not found. Proxy? Use the implementation ABI.
  • Type mismatch? Validate inputs: address, uint256, etc.
  • Wrong units? You treated an ERC‑20 as native—reformat with decimals.
  • “Connected” but failing calls? Your RPC is throttling; switch endpoints.

Question: Where do you get practical targets to test against?

  • Chains: Get RPCs from Chainlist (HTTP to start).
  • Tokens: Use well-known ERC‑20s like USDC/USDT/DAI to verify ERC‑20 reads.
  • NFTs: Navigate to mints on popular platforms, then follow through to the contract page and “Token Tracker” for holders. Read balanceOf for top accounts.

One more thing about proxy contracts

When a token page shows almost no functions under “Read Contract,” but you still know it’s a real token, check for “Read as Proxy.” The real ABI is there. The pattern behind the scene is a transparent proxy (e.g., EIP-1967)—you don’t need to memorize the pattern name; you just need to fetch the right ABI and keep the proxy address.

A senior lens on decimals

  • Native coins always have 18 decimals. Helpers and mental math are aligned.
  • ERC‑20 decimals vary; six is common for dollar-pegged tokens.
  • Don’t format when computing—format only at the output boundary.
  • If you ever sum values across tokens, normalize them to the same decimal space first. Summing raw base units of DAI (18) and USDC (6) without normalization will produce nonsense.

Beginner-to-pro: a gentle growth path

  • Start with a single read: chain_id, block_number.
  • Add one contract: name, symbol, decimals.
  • Add one address: balanceOf, then humanize with decimals.
  • Scale out: 10 addresses, concurrent reads, sorting.
  • Generalize ABIs: store in abi/, reuse ERC standard ABIs.
  • Handle proxies: address stays, ABI switches.
  • Normalize addresses: checksum in, checksum out.
  • Respect units: native vs ERC‑20.

Final Thoughts

If you get the foundations right—Python 3.11, async Web3.py, dependable RPCs, checksummed addresses, and sober handling of decimals—you eliminate 80% of the “mystery failures” that plague on-chain scripts. From there, the rest is just disciplined repetition:

  • Resolve the right ABI (and proxy logic).
  • Establish async connectivity per network.
  • Access functions with precise inputs and outputs.
  • Decode values into human-readable units at the edge.

Set up your environment once, build your minimal ERC‑20/NFT read stack, and then scale horizontally: more addresses, more contracts, more chains. The code barely changes—your leverage does.

What will you read first today—gas sanity for your strategy, balances for your whales, or metadata for your mint? Pick one, ship a clean read, and keep going.

Top comments (0)