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 withasyncio.gather
orasyncio.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 byweb3.py
at the time of writing; 3.11 is stable and proven.Use an async HTTP client.
curl_cffi
is an excellentaiohttp
alternative; if both disagree with your system, tryhttpx
. Also addfake-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
(orhttpx
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()
orupper()
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())
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, nodecimals()
. Callingdecimals()
on an NFT contract either fails at ABI level (if the ABI doesn’t include it) or reverts at runtime. Wrap intry/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 tov3.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",
},
]
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())
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")
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)
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
- Read NFT metadata and a top holder’s balance
- Identify a trending mint on your preferred explorer or marketplace page.
- Find the contract, open the Contract tab, get the ABI.
- Use
functions.name().call()
andfunctions.symbol().call()
. - Use the token tracker to find a top holder and read their balance with
balanceOf
. Normalize addresses with checksum.
Rank 10 addresses by USDT holdings on Ethereum
Gather 10 addresses (exchange hot wallets are easy anchors).
Use USDT’s address and an ERC-20 ABI (proxy ABI if applicable).
For each address: checksum it,
balanceOf
, fetch USDTdecimals
, divide by10 ** decimals
.Sort and print from richest to poorest.
Decimals don’t exist on NFTs — prove it
Pick two NFT contracts on a ZK or L2 explorer; one may be unverified.
Try calling
decimals()
in atry/except
block.Print the error for one, and confirm that
name
/symbol
work whiledecimals()
is absent.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 to6.14.0
for now. - Forgot
.call()
: builder ≠ execution. Alwaysawait contract.functions.fn(...).call()
. - Awaiting properties:
gas_price
andmax_priority_fee
are awaitable attributes without parentheses. - Token vs native units: don’t
from_wei
tokens. Usedecimals
. - 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 withjson.loads
.
Step-by-step checklist for beginners
Use this as your repeatable path for any new read client.
- Prepare the environment
- Install Python 3.11.
- Create a virtualenv and activate it.
- Install
web3==6.14.0
,curl_cffi
(orhttpx
),fake-user-agent
. - 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()
.
- Probe the network
- Fetch and print
gas_price
(wei and gwei),chain_id
, andblock_number
. If EIP-1559: read
max_priority_fee
(handle exceptions for non-1559 chains).Normalize addresses
Convert all inputs to checksum using a helper (
to_checksum_address
or equivalent).Never alter on-chain addresses you read back (they’re already checksum).
Read native balances
Use
await v3.eth.get_balance(address)
.Convert with
from_wei(..., "ether")
if you need human-readable output.Read token metadata and balances
Find the token’s contract address.
If you suspect proxy: open “Read as Proxy” and copy ABI from there.
Load ABI from
abi/token.json
or use a minimal Python object ABI.Instantiate the contract with v
3.eth.contract(address, abi=abi)
.Read
name
,symbol
,decimals
.For balances:
balanceOf
then divide by10 ** decimals
.Remember NFTs are not ERC-20
Do not call
decimals()
on ERC-721.Use
try/except
if you’re probing unknown surfaces.Keep units straight in calculations
Always normalize before summing cross-token balances.
Only use
from_wei
/to_wei
for native units.Structure your codebase
Keep ABIs in an
abi/
directory.Keep a reusable minimal ERC-20 ABI in code for quick reads.
Use a dedicated module for RPC selection and connection.
Scale out with async
Wrap account operations into coroutines.
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 by10 ** 6
, not10 ** 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. Useawait 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)