web3.py 读 ERC-20:balanceOf、decimals 换算与裸 eth_call 核对
摘要:ERC-20 余额记在代币合约里,balanceOf 返回的是整数,要用 decimals 换算。本文用 web3.py 8.0.0 在 Sepolia 真实读取 USDC(6 位小数)与 LINK(18 位小数)的 name、symbol、decimals、totalSupply、balanceOf,用 block_identifier 固定块高,把 balanceOf 拆成裸 eth_call 自己编码解码,并演示 decimals 写死、BadFunctionCallOutput、float 丢精度三个坑,最后到 Sepolia Etherscan 按持有人核对。
ERC-20 代币的余额不存在你的地址里,而是记在代币合约自己的存储里:想知道某个地址有多少 USDC,就得去问 USDC 合约的 balanceOf;而合约返回的是一个不带小数点的整数,必须再用 decimals 换算,才是钱包里看到的数。上一篇 web3.py 连 Sepolia 只读了 ETH 余额,本文接着用 web3.py 8.0.0 在本机真实跑通两段脚本:读 Sepolia 上 USDC(6 位小数)和 LINK(18 位小数)的 name / symbol / decimals / totalSupply / balanceOf,再把 balanceOf 拆成一次裸 eth_call 自己编码、解码。全程只读测试网,不需要私钥、不发交易、不花 Gas。
JavaScript 版本的同一件事(ABI 怎么写、ethers 的 Contract 对象)见 ethers.js 读合约:ABI、address 与 call 字段,本文不再重复 ABI 基础,重点放在 web3.py 的写法差异、固定块高读取和 decimals 换算这几个最容易算错的地方。
ERC-20 规定了哪几个读函数
EIP-20 标准里,和「读余额」相关的是这几个 view 函数:
| 函数 | 返回类型 | EIP-20 怎么说 | 本文怎么用 |
|---|---|---|---|
balanceOf(address) |
uint256 | 必须实现;返回该地址的余额 | 核心:读某地址持有多少 |
decimals() |
uint8 | OPTIONAL,仅用于显示,调用方不能假设一定存在 | 把整数换算成人类可读的数 |
totalSupply() |
uint256 | 必须实现;代币总供应量 | 同样要用 decimals 换算 |
name() / symbol() |
string | OPTIONAL,仅用于显示 | 只作参考,不能用来判真假 |
注意标准原文对 decimals 的定位:它只是「给界面显示用」的可选函数,链上的余额、转账永远是整数。所以「1 USDC」在合约里其实是 1000000,「1 LINK」是 1000000000000000000。
web3.py 的 call() 与 block_identifier
web3.py 里读合约用 contract.functions.xxx(参数).call()。官方 Contracts 文档 说明:ContractFunction.call 通过 eth_call 在节点本地执行,不会产生链上交易;并且可以传 block_identifier 指定在哪个块的状态上执行(块号、块哈希、'latest' 等)。

为什么要固定块高?一次读 5 个字段就是 5 次 RPC 请求,以太坊大约每 12 秒出一个块,如果中间正好出了新块,你读到的 totalSupply 和 balanceOf 可能来自两个不同的状态。先取一次 block_number,后面每次 call 都带上它,结果就是同一个快照。文档也提醒:查询太久以前的块需要节点开启归档(archive)模式,公共节点不一定支持,读最近的块没问题。
准备:两枚测试网代币的官方地址
合约地址一定要从发行方官方文档拿,不要从搜索结果或群里复制——名字和图标谁都可以仿,地址仿不了。本文用两枚 Sepolia 代币:
- USDC:
0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238,来自 Circle 官方 USDC 合约地址页 的 Testnet 表(Ethereum Sepolia 一行),官方注明测试网 USDC 没有任何金融价值。 - LINK:
0x779877A7B0D9E8603169DdbD7836e478b4624789,来自 Chainlink 官方 LINK Token Contracts 页。

官方文档已经写了 Decimals 18,但代码里仍然应该从合约读,而不是写死——下面会看到 USDC 是 6 位,写死 18 会差出 1012 倍。环境沿用上一篇的虚拟环境(pip install web3,本机版本 8.0.0)。
第一段代码:读 name / symbol / decimals / totalSupply / balanceOf
ABI 只放用得到的 5 个函数即可。示例地址 0xA8ab…5a75 是写作时从 Sepolia 上 LINK 的 Transfer 事件里挑出的一个普通外部账户(EOA),恰好同时持有两种测试代币。
from decimal import Decimal
from web3 import Web3
RPC = "https://ethereum-sepolia-rpc.publicnode.com" # 公共 Sepolia 节点
w3 = Web3(Web3.HTTPProvider(RPC, request_kwargs={"timeout": 15}))
assert w3.eth.chain_id == 11155111, "连到的不是 Sepolia,停止"
# 只放本文要用的 5 个 ERC-20 只读函数(完整 ABI 也行,但没必要)
def fn(name, inputs, out):
return {"type": "function", "name": name, "stateMutability": "view",
"inputs": [{"name": n, "type": t} for n, t in inputs],
"outputs": [{"name": "", "type": out}]}
ERC20_ABI = [
fn("name", [], "string"),
fn("symbol", [], "string"),
fn("decimals", [], "uint8"),
fn("totalSupply", [], "uint256"),
fn("balanceOf", [("account", "address")], "uint256"),
]
TOKENS = { # 合约地址取自 Circle / Chainlink 官方文档
"USDC": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
"LINK": "0x779877A7B0D9E8603169DdbD7836e478b4624789",
}
HOLDER = Web3.to_checksum_address("0xa8ab35a4a8628d06fbd1c84e14b53c8ff62e5a75")
block = w3.eth.block_number # 固定同一个块,几次调用读的是同一份状态
print("block:", block, "| holder:", HOLDER)
for label, addr in TOKENS.items():
token = w3.eth.contract(address=Web3.to_checksum_address(addr), abi=ERC20_ABI)
call = dict(block_identifier=block)
name = token.functions.name().call(**call)
symbol = token.functions.symbol().call(**call)
decimals = token.functions.decimals().call(**call)
supply = token.functions.totalSupply().call(**call)
raw = token.functions.balanceOf(HOLDER).call(**call)
unit = Decimal(10) ** decimals
print(f"\n[{label}] {token.address}")
print(f" name={name!r} symbol={symbol!r} decimals={decimals}")
print(f" totalSupply raw={supply} -> {Decimal(supply) / unit:,} {symbol}")
print(f" balanceOf raw={raw} -> {Decimal(raw) / unit} {symbol}")
运行 python erc20_read.py,下图是本机真实终端输出(2026-10-07 下午,Sepolia 块 11861912):

几个值得注意的细节:
- 同样是「整数 ÷ 10decimals」:USDC 的 160000000 除以 106 是 160;LINK 的 875 后面跟 18 个 0,除以 1018 是 875。原始整数的位数和余额大小没有直接关系,取决于 decimals。
- name() 和文档写法不完全一样:Chainlink 文档表格里 Name 写的是「Chainlink Token on Ethereum Sepolia Testnet」,合约
name()返回的是'ChainLink Token'。name / symbol 是合约部署者自己填的字符串,只能参考;判断是不是那枚币,看合约地址。 - web3.py 要求校验和地址:
w3.eth.contract(address=...)和函数参数里的地址都必须是 EIP-55 校验和格式,从别处拿来的全小写地址先过一遍Web3.to_checksum_address(),否则会抛InvalidAddress(上一篇的坑 2)。 - 换算用 Decimal:示例用 Python 标准库
decimal.Decimal做除法,原因见下文坑 3。
到 Sepolia Etherscan 核对余额与 decimals
在 Sepolia Etherscan 打开 LINK 代币页,在右侧搜索框按持有人地址过滤(URL 形如 /token/合约地址?a=持有人地址):Other Info 里写着 TOKEN CONTRACT (WITH 18 DECIMALS),「FILTERED BY TOKEN HOLDER」一栏 BALANCE 为 875 LINK,Max Total Supply 1,000,000,000 LINK,和脚本输出一致。

同样的方法打开 USDC 按该地址过滤的页面,写作时显示 WITH 6 DECIMALS、BALANCE 160 USDC。Etherscan 显示的余额其实也是它自己调 balanceOf 再按 decimals 换算的,和你脚本做的是同一件事。
第二段代码:把 balanceOf 拆成一次裸 eth_call
contract.functions.balanceOf(addr).call() 背后发生了什么?自己拼一次就清楚了:calldata = keccak256("balanceOf(address)") 的前 4 字节(函数选择器)+ 参数按 ABI 规则编码成 32 字节;节点返回 32 字节,再按 uint256 解码。web3.py 里编码、解码都交给 w3.codec。脚本最后顺便演示三个常见坑。
from decimal import Decimal
from web3 import Web3
from web3.exceptions import BadFunctionCallOutput
w3 = Web3(Web3.HTTPProvider("https://ethereum-sepolia-rpc.publicnode.com",
request_kwargs={"timeout": 15}))
assert w3.eth.chain_id == 11155111
USDC = Web3.to_checksum_address("0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238")
HOLDER = Web3.to_checksum_address("0xa8ab35a4a8628d06fbd1c84e14b53c8ff62e5a75")
block = w3.eth.block_number
# 1) balanceOf 在底层就是一次 eth_call:4 字节选择器 + 32 字节地址参数
selector = Web3.keccak(text="balanceOf(address)")[:4]
data = selector + w3.codec.encode(["address"], [HOLDER])
print("selector:", "0x" + selector.hex())
print("calldata:", "0x" + data.hex())
ret = w3.eth.call({"to": USDC, "data": data}, block)
print("eth_call 返回:", ret.to_0x_hex())
(raw,) = w3.codec.decode(["uint256"], ret)
print("decode uint256:", raw)
# 2) decimals 同理,选择器 0x313ce567
dec_ret = w3.eth.call({"to": USDC, "data": Web3.keccak(text="decimals()")[:4]}, block)
(decimals,) = w3.codec.decode(["uint8"], dec_ret)
print("decimals():", decimals)
# 坑 1:把 USDC 当 18 位小数算
print("\n坑1 正确 /10**6 :", Decimal(raw) / Decimal(10) ** decimals, "USDC")
print("坑1 错误 /10**18:", Decimal(raw) / Decimal(10) ** 18, "USDC")
# 坑 2:对一个没有合约代码的地址调 balanceOf
ABI = [{"type": "function", "name": "balanceOf", "stateMutability": "view",
"inputs": [{"name": "a", "type": "address"}], "outputs": [{"name": "", "type": "uint256"}]}]
print("\n坑2 HOLDER 的 code 长度:", len(w3.eth.get_code(HOLDER)))
try:
w3.eth.contract(address=HOLDER, abi=ABI).functions.balanceOf(HOLDER).call()
except BadFunctionCallOutput as e:
print("坑2 BadFunctionCallOutput:", str(e)[:96], "...")
# 坑 3:浮点数换算丢精度
link_raw = 222500000000000001500
print("\n坑3 float :", link_raw / 10**18)
print("坑3 Decimal:", Decimal(link_raw) / Decimal(10) ** 18)

逐条解读:
- 选择器 0x70a08231:任何 ERC-20 的
balanceOf(address)选择器都一样;返回值0x…9896800就是十六进制的 160000000。在 Etherscan 的 Input Data、交易解码里看到0x70a08231开头,就知道是在查余额。 - 坑 1:decimals 写死。把 USDC 当 18 位小数,160 USDC 会被算成 1.6×10-10;反过来把 18 位的币当 6 位,会放大 1012 倍。USDT、USDC 在以太坊上是 6 位,大部分代币是 18 位,但没有任何保证,每个合约都要读一次 decimals(可以缓存,因为它一般不变)。
- 坑 2:BadFunctionCallOutput。脚本故意把一个普通钱包地址(
get_code长度为 0)当成合约去调balanceOf,节点对没有代码的地址执行eth_call会返回空数据,web3.py 解码失败抛出这个异常,提示「合约是否正确部署、节点是否同步」。遇到它,先查三件事:地址是不是填成了钱包地址、RPC 是不是连到了别的网络(chain_id 断言)、合约是不是在这条链上。 - 坑 3:用 float 换算。一个 18 位代币的余额 222500000000000001500,用
/ 10**18得到 float 222.5,末尾的 1500 wei 直接丢了;用Decimal则保留到 222.5000000000000015。展示可以四舍五入,但记账、对账、算转账金额时一律用整数或 Decimal,转账时用int(Decimal(数量) * 10**decimals)换回整数。
web3.py 与 ethers.js 读 ERC-20 对照
| 做什么 | web3.py | ethers.js v6 |
|---|---|---|
| 建合约对象 | w3.eth.contract(address=, abi=) |
new Contract(addr, abi, provider) |
| 读余额 | c.functions.balanceOf(a).call() |
await c.balanceOf(a) |
| 指定块高 | .call(block_identifier=n) |
await c.balanceOf(a, {{ blockTag: n }}) |
| 返回类型 | Python int(任意精度) | bigint |
| 换算显示 | Decimal(raw) / 10**dec |
formatUnits(raw, dec) |
| 手工编码 | w3.codec.encode / decode |
Interface.encodeFunctionData |
自查清单
- 合约地址只从发行方官方文档取,并在浏览器上确认它在你要的那条链上。
- 脚本开头断言
chain_id,一组读取共用同一个block_identifier。 - decimals 从合约读,不写死;换算用 Decimal,记账用整数。
- name / symbol 只作显示,不作为真假依据。
- 出现 BadFunctionCallOutput 先查地址类型(
get_code)、网络和合约是否存在。 - 读到的余额到对应网络的浏览器按持有人过滤核对一次。
换到主网读真实代币时,代码只需换 RPC 和合约地址;要在主网发交易就需要 ETH 付 Gas,可以在 币安 或 欧易 准备少量 ETH,日常资产钱包可用 欧易 Web3 钱包(创建时可填推荐码 CHENGXULVTU)。开发调试始终用和资产钱包完全分开的测试账户。
相关阅读
- web3.py 连 Sepolia:HTTPProvider、读余额与本地账户
- ethers.js 读合约:ABI、address 与 call 字段
- 波场 TRON 怎么运作:Energy、Bandwidth 与 TRC-20
- 以太坊 Gas 费怎么算:Base Fee、Priority Fee 与 EIP-1559 字段对照
说明:文中代码只连 Sepolia 测试网公共 RPC,只做只读查询,不涉及任何私钥或真实资金;示例持有人地址为链上公开地址,仅用于演示读取。块高、余额、总供应量为 2026-10-07 运行与截图时的测试网数据,会持续变化,测试网代币没有任何价值。接口以 web3.py 官方文档和 EIP-20 原文为准。本文只讲技术方法,不构成任何投资建议。
