DEV Community

Cover image for Your first painless EVM reads in Python: an async-first blueprint with traps, fixes, and a mental model that sticks
OnlineProxy
OnlineProxy

Posted on

Your first painless EVM reads in Python: an async-first blueprint with traps, fixes, and a mental model that sticks

If you’ve ever tried to “just call a read function” on a smart contract and somehow ended up debugging provider errors, wrong units, or a mysterious ABI mismatch — welcome. Most of us have scars from simple things that weren’t actually simple: a gas price that looked like a phone number, a balance in wei you forgot to scale, a decimals() call that reverts on an NFT, or a .call() you forgot to append to the function.

This guide is the senior-level, practical path to reading from EVM networks with Python — async from day one, stable versions only, and a mental model that prevents 80% of avoidable mistakes. You’ll get a step-by-step setup, a framework for thinking about RPC, units, and ABIs, and compact patterns for real-world tasks like tracking token balances, reading ERC-20 metadata, and avoiding proxy-ABI traps.

Why start async and why Python 3.11?

Short answer: scalability and compatibility.

  • Async gives you native concurrency that “just works” with web3.py and lets you scale to many accounts without jumping straight to multi-threading. You’ll later batch work with asyncio.gather or asyncio.wait instead of redesigning everything.

  • web3.py==6.14.0 is the safe choice right now. Newer builds have known issues; stick to 6.14.0 until the dust settles. It pairs cleanly with Python 3.11. Python 3.12 isn’t fully supported by web3.py at the time of writing; 3.11 is stable and proven.

  • Use an async HTTP client. curl_cffi is an excellent aiohttp alternative; if both disagree with your system, try httpx. Also add fake-user-agent when you need realistic headers.

The environment that won’t fight you

  • Install Python 3.11 (not 3.12) for compatibility.

  • Create and activate a virtual environment for the project.

  • Use PyCharm Community if you want batteries-included ergonomics: per-project interpreter, smart run configs, and built-in style nudges (PEP 8). VS Code is fast, but PyCharm removes a lot of “why did it run from the wrong directory?” friction for newcomers.

Minimal requirements.txt you can rely on:

  • web3==6.14.0
  • curl_cffi (or httpx as a fallback)
  • fake-user-agent

If you experience issues on older macOS versions, curl_cffi may fail to build; switch to httpx.

What exactly are you talking to when you “connect”?

Think of AsyncWeb3 as a remote control. Once you instantiate it with an async HTTP provider, everything hangs off that object. You press buttons (call methods) and get a consistent interface for chain access:

  • v3.is_connected(): did we actually reach the node?
  • v3.eth.gas_price: current gas price (awaitable attribute)
  • v3.eth.max_priority_fee: EIP-1559 tip (awaitable attribute; available on networks that support 1559)
  • v3.eth.chain_id
  • v3.eth.block_number
  • v3.eth.get_balance(address)

Notice the subtlety: some values are accessed as properties without parentheses but must still be awaited because they perform an async fetch under the hood. That’s normal in web3.py async mode.

Choose RPCs like you’d choose your ISP

  • Use Chainlist to find multiple RPC endpoints per network. You can compare privacy flags, latency, and see HTTP vs WebSocket options.
  • You’ll mostly use the async HTTP provider; WebSockets are available but not required for this article’s scope.
  • Changing chains is as trivial as changing the RPC URL. Everything else in your code can stay the same.

Checksum addresses: lower() will betray you

A checksum address is how EVM networks keep you from sending to a mistyped address. When you read addresses from chain (events, storage), they’ll already be checksumed. Your hardcoded or user-provided addresses should be normalized to checksum before comparisons or contract calls.

  • Compare only checksum addresses. Don’t trust lower() or upper() equality — it “works” until it doesn’t.
  • Convert inputs to checksum early, and don’t touch addresses returned by the blockchain (they’re already in checksum format). Units don’t lie: wei, gwei, ether — and then decimals happen

Native currency (ETH, BNB, etc.) is 18 decimals. ERC-20 tokens can use anything: USDC is 6, DAI is 18, others may vary. Three principles keep you sane:

  • The chain returns integers (no floats). You’ll see balances and gas in wei.
  • For native values, from_wei and to_wei are your friends for quick conversions: gwei, ether, etc.
  • For ERC-20 tokens, never assume 18. Always fetch decimals() and normalize by 10 ** decimals. If you add DAI (18) and USDC (6) without normalizing: your totals will be fiction.

The framework you can retell in one minute

  • Layer 1 — Network access: RPC and provider. Connect, verify, get basic chain params.
  • Layer 2 — Units and addresses: wei vs ether, decimals per token, checksum addresses everywhere. Layer 3 — Contract surface: ABI and function calls. For proxies, use the proxy’s ABI with the original contract address.

Keep this structure in your head and you’ll debug in the right order.

A reliable async connect-and-probe snippet

import asyncio
from web3 import AsyncWeb3
from web3.providers.async_rpc import AsyncHTTPProvider

async def main():
    rpc = "https://your.rpc.url"
    v3 = AsyncWeb3(AsyncHTTPProvider(rpc))

    print("Connected:", await v3.is_connected())

    # awaitable attributes (no parentheses)
    gas_wei = await v3.eth.gas_price
    print("Gas (wei):", gas_wei)
    print("Chain ID:", await v3.eth.chain_id)
    print("Block number:", await v3.eth.block_number)

    # On 1559 networks:
    try:
        max_priority = await v3.eth.max_priority_fee
        print("Max priority fee (wei):", max_priority)
    except Exception:
        print("This network may not support EIP-1559 tip query.")

if __name__ == "__main__":
    asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Why does .call() sometimes “do nothing”?

Because you forgot it. In web3.py, contract reads are two-step:

  • Build the function call: contract.functions.name()
  • Execute the call: .call(), which itself is awaitable in async mode

Miss .call() and you’re passing around a function builder, not the result. Also remember: put parentheses after the function name, then .call(), and finally await the whole thing.

Proxies: A two-contract reality you must respect

A lot of production contracts are proxies (transparent proxy pattern, e.g., EIP-1967). That means:

  • The “contract address” users interact with is a proxy.
  • The ABI you need for read/write functions often lives under “Read as Proxy” / “Write as Proxy” tabs in explorers.
  • Take the address from the original page (the proxy you’ll call), but copy the ABI from the proxy/implementation section.

If you’re staring at a suspiciously short ABI, it’s probably the proxy wrapper. Switch to “as proxy,” scroll to the ABI, and copy that.

Read vs write: cost and state

  • Read functions (view, pure) do not change state and are free. They often appear under “ReadContract” in explorers.
  • Write functions (nonpayable, payable) mutate state and cost gas. You’ll see them under “WriteContract” and they will require a proper transaction.

ERC-20 vs ERC-721: don’t ask NFTs for decimals()

  • ERC-20 (fungible) tokens share a common surface: name, symbol, decimals, totalSupply, balanceOf, transfer, approve, allowance.
  • ERC-721 (non-fungible) has a different surface: name, symbol, ownerOf, tokenURI — and crucially, no decimals(). Calling decimals() on an NFT contract either fails at ABI level (if the ABI doesn’t include it) or reverts at runtime. Wrap in try/except if you’re probing unverified contracts.

Why a single ERC-20 ABI seems to “work everywhere”

Because that’s the point of the interface. All ERC-20 tokens implement the same function names, input types, and return types for the standard surface. That’s why a minimal ABI containing just name, symbol, decimals, balanceOf can drive reads across USDT, USDC, DAI, and beyond.

How to store and load ABIs without hating yourself

  • Use abi/ as a folder, e.g., abi/token.json.
  • Load JSON at runtime (with json.loads on file contents) and pass it to v3.eth.contract(address, abi=abi_json).
  • You can also construct a minimal ABI as a Python object (list of dicts) if you only need a handful of functions. This keeps the code legible and avoids 1000-line paste walls.

Example: minimal ERC-20 ABI as a Python object

TOKEN_ABI = [
    {
        "type": "function",
        "name": "name",
        "inputs": [],
        "outputs": [{"type": "string", "name": ""}],
        "stateMutability": "view",
    },
    {
        "type": "function",
        "name": "symbol",
        "inputs": [],
        "outputs": [{"type": "string", "name": ""}],
        "stateMutability": "view",
    },
    {
        "type": "function",
        "name": "decimals",
        "inputs": [],
        "outputs": [{"type": "uint8", "name": ""}],
        "stateMutability": "view",
    },
    {
        "type": "function",
        "name": "balanceOf",
        "inputs": [{"type": "address", "name": "who"}],
        "outputs": [{"type": "uint256", "name": ""}],
        "stateMutability": "view",
    },
]
Enter fullscreen mode Exit fullscreen mode

Instantiate and read standard metadata

import asyncio
import json
from web3 import AsyncWeb3
from web3.providers.async_rpc import AsyncHTTPProvider
from eth_utils import to_checksum_address  # or implement your checksum

TOKEN_ABI = [...]  # as above

async def main():
    rpc = "https://your.arbitrum.mainnet.rpc"
    v3 = AsyncWeb3(AsyncHTTPProvider(rpc))

    token_address = to_checksum_address("0x...")  # e.g., USDC on Arbitrum
    contract = v3.eth.contract(address=token_address, abi=TOKEN_ABI)

    name = await contract.functions.name().call()
    symbol = await contract.functions.symbol().call()
    decimals = await contract.functions.decimals().call()

    # Read a holder’s balance
    holder = to_checksum_address("0x...")
    raw = await contract.functions.balanceOf(holder).call()
    human = raw / (10 ** decimals)

    print(name, symbol, decimals, human)

if __name__ == "__main__":
    asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

Pitfall: never use from_wei(..., "ether") for tokens. That helper assumes 18 decimals. Tokens vary; always divide by 10 ** decimals.

Reading native balances (and converting them properly)

native_raw = await v3.eth.get_balance(holder)
# Convert to ether (native currency with 18 decimals)
ether = v3.from_wei(native_raw, "ether")
gwei = v3.from_wei(await v3.eth.gas_price, "gwei")
Enter fullscreen mode Exit fullscreen mode

For native units, from_wei and to_wei accept canonical units like wei, gwei, ether and others. For tokens, use decimals.

Probing chain state you’ll need in real workflows

  • Current gas price (wei and gwei)
  • Chain ID (to verify you’re on the intended network)
  • Latest block number (to measure recency or pace)
  • Max priority fee (if the network supports EIP-1559)
gas_wei = await v3.eth.gas_price
gas_gwei = int(v3.from_wei(gas_wei, "gwei"))
print("Gas:", gas_gwei, "gwei")
print("Chain:", await v3.eth.chain_id)
print("Block:", await v3.eth.block_number)
Enter fullscreen mode Exit fullscreen mode

Explorer literacy: where ABI, proxy tabs, and token trackers live

When you land on a token page:

  • Contract tab: verified source code (if any) and the “ABI” section at the bottom.
  • ReadContract/WriteContract tabs: read and write surfaces for the proxy shell (often limited).
  • Read as Proxy/Write as Proxy: where the full implementation surface lives. Copy ABI from here for production calls.
  • Token tracker: shows holders, top holders, total supply. Great for identifying whale addresses or checking if you’re reading the right token (USDC vs bridged variants, for example).

Three educational mini-projects that force the right habits

  1. Read NFT metadata and a top holder’s balance
  2. Identify a trending mint on your preferred explorer or marketplace page.
  3. Find the contract, open the Contract tab, get the ABI.
  4. Use functions.name().call() and functions.symbol().call().
  5. Use the token tracker to find a top holder and read their balance with balanceOf.
  6. Normalize addresses with checksum.

  7. Rank 10 addresses by USDT holdings on Ethereum

  8. Gather 10 addresses (exchange hot wallets are easy anchors).

  9. Use USDT’s address and an ERC-20 ABI (proxy ABI if applicable).

  10. For each address: checksum it, balanceOf, fetch USDT decimals, divide by 10 ** decimals.

  11. Sort and print from richest to poorest.

  12. Decimals don’t exist on NFTs — prove it

  13. Pick two NFT contracts on a ZK or L2 explorer; one may be unverified.

  14. Try calling decimals() in a try/except block.

  15. Print the error for one, and confirm that name/symbol work while decimals() is absent.

  16. Reinforce the mental model: ERC-721 != ERC-20.

Common mistakes and their quick fixes

  • Wrong Python version: 3.12 can break web3.py. Use 3.11.
  • Wrong web3.py version: stick to 6.14.0 for now.
  • Forgot .call(): builder ≠ execution. Always await contract.functions.fn(...).call().
  • Awaiting properties: gas_price and max_priority_fee are awaitable attributes without parentheses.
  • Token vs native units: don’t from_wei tokens. Use decimals.
  • Mixed-decimal arithmetic: never sum cross-token balances before normalizing by decimals.
  • Non-checksummed addresses: convert inputs to checksum before comparing or calling.
  • Proxy confusion: short ABI? Switch to “as proxy” and copy ABI there. Use the original proxy address with the implementation ABI.

A mnemonic framework for ABI sanity

  • Address from what users point to (the proxy address).
  • ABI from where functions actually are (“Read as Proxy”).
  • Minimal ABI in code to stay legible (only the functions you call).
  • JSON ABIs live in abi/ folder (abi/token.json), loaded with json.loads.

Step-by-step checklist for beginners

Use this as your repeatable path for any new read client.

  1. Prepare the environment
  2. Install Python 3.11.
  3. Create a virtualenv and activate it.
  4. Install web3==6.14.0, curl_cffi (or httpx), fake-user-agent.
  5. Use PyCharm Community (or your preferred editor). Set the interpreter to your venv.

2.Connect to a network

  • Pick a reliable RPC from Chainlist.
  • Instantiate AsyncWeb3(AsyncHTTPProvider(rpc)).
  • Verify await v3.is_connected().
  1. Probe the network
  2. Fetch and print gas_price (wei and gwei), chain_id, and block_number.
  3. If EIP-1559: read max_priority_fee (handle exceptions for non-1559 chains).

  4. Normalize addresses

  5. Convert all inputs to checksum using a helper (to_checksum_address or equivalent).

  6. Never alter on-chain addresses you read back (they’re already checksum).

  7. Read native balances

  8. Use await v3.eth.get_balance(address).

  9. Convert with from_wei(..., "ether") if you need human-readable output.

  10. Read token metadata and balances

  11. Find the token’s contract address.

  12. If you suspect proxy: open “Read as Proxy” and copy ABI from there.

  13. Load ABI from abi/token.json or use a minimal Python object ABI.

  14. Instantiate the contract with v3.eth.contract(address, abi=abi).

  15. Read name, symbol, decimals.

  16. For balances: balanceOf then divide by 10 ** decimals.

  17. Remember NFTs are not ERC-20

  18. Do not call decimals() on ERC-721.

  19. Use try/except if you’re probing unknown surfaces.

  20. Keep units straight in calculations

  21. Always normalize before summing cross-token balances.

  22. Only use from_wei/to_wei for native units.

  23. Structure your codebase

  24. Keep ABIs in an abi/ directory.

  25. Keep a reusable minimal ERC-20 ABI in code for quick reads.

  26. Use a dedicated module for RPC selection and connection.

  27. Scale out with async

  28. Wrap account operations into coroutines.

  29. Batch calls with asyncio.gather when you’re ready to parallelize.

Answers to questions you’ll Google sooner or later

  • Why am I getting a huge integer for gas? Because it’s wei. Convert to gwei with from_wei.
  • Why does decimals() crash on my NFT? Because ERC-721 doesn’t define decimals. It’s by design.
  • Why do I get a tiny value from from_wei(..., "ether") on USDC? Because you applied an 18-decimal conversion to a 6-decimal token. Divide by 10 ** 6, not 10 ** 18.
  • Why does my read fail even though the function exists on the explorer? You’re probably using the proxy shell’s ABI. Switch to “Read as Proxy,” copy that ABI, and use the proxy’s address.
  • Why does v3.eth.gas_price() error with “not callable”? It’s an awaitable attribute, not a method. Use await v3.eth.gas_price (no parentheses).

A mental model you’ll actually use

  • Think “dialogue with a server” via RPC. Every question you ask must be precise: the right address, the right ABI, the right units.
  • Treat AsyncWeb3 as a remote control with methods and properties that map to network capabilities.
  • Treat ABIs as contracts’ public shapes. For proxies, the shape you need is on the proxy view in explorers.
  • Treat units as a strict schema: integers on-chain, explicit conversions off-chain.
  • Treat addresses as identifiers with validation (checksum), not as casual strings.

Final Thoughts

Reading from EVM networks in Python is supposed to be straightforward, but the ecosystem’s sharp edges make it feel harder than it is. Once you anchor on three layers — network access, units and addresses, and contract surface — the confusion fades. Always normalize addresses, always be explicit about units, always fetch decimals for tokens, and be ruthless about which ABI you’re using on proxied deployments. Start async. Stick to stable versions. And keep your ABIs tidy — either in abi/ or as minimal Python objects when you know exactly what you need.

The next time you need to sanity-check gas, list top token holders, or probe a token’s metadata across chains, you’ll have a repeatable path that scales from one-off inquiries to production-grade parsers. What will you read first — a whale watch on a new L2, or your own portfolio history across ERC-20s and NFTs?

Top comments (0)