何时使用
当你在 Python / TypeScript / Solidity 中读取 ERC-20 余额、把链上原始余额换算成法币价值、跨多条 EVM 链比较代币数量、处理桥接(bridged)资产,或构建投资组合追踪器、交易机器人、数据看板、DeFi 聚合器时使用。
小数位静默不匹配是导致余额或 USD 估值差几个数量级却不报错的最常见原因之一——它不抛异常,只给你错误的数字。
不该用:
- 非 EVM 链(如 Solana、比特币)——精度模型不同,本条不适用。
- 精度已由可信链下数据源固化、且仅做纯展示的场景,可省去运行时查询;但只要参与计算或跨链比较,仍应回到运行时查询。
步骤
- 运行时查询:对每个代币调用合约的
decimals(),不要按符号(symbol)硬编码。 - 按链+地址缓存:用
(chain_id, token_address)做缓存键,绝不用符号缓存(同符号在不同链精度可能不同)。 - 精确数学:换算与估值用
Decimal/BigInt等精确类型,禁用 float。 - 防御回退:
decimals()revert 时回退到 18 并记录告警日志,保持可见。 - 桥接/包装后重查:资产经桥接或 wrapper 变更后重新查询小数位。
- 统一归一化:比较或计价前,把内部记账统一归一化(如归一到 WAD/18 位)。
指令
- 始终在运行时查询
decimals(),不要假设稳定币都用相同小数位。 - 缓存键用链 + 代币地址,不用符号。
- 精确数学优先:
Decimal/BigInt或等价精确类型,绝不用 float。 - 桥接或 wrapper 变更后必须重查小数位。
- 比较和价格计算前先一致地归一化内部记账。
示例
运行时查询余额(Python / web3.py):
from decimal import Decimal
from web3 import Web3
ERC20_ABI = [
{"name": "decimals", "type": "function", "inputs": [],
"outputs": [{"type": "uint8"}], "stateMutability": "view"},
{"name": "balanceOf", "type": "function",
"inputs": [{"name": "account", "type": "address"}],
"outputs": [{"type": "uint256"}], "stateMutability": "view"},
]
def get_token_balance(w3: Web3, token_address: str, wallet: str) -> Decimal:
contract = w3.eth.contract(
address=Web3.to_checksum_address(token_address),
abi=ERC20_ABI,
)
decimals = contract.functions.decimals().call()
raw = contract.functions.balanceOf(Web3.to_checksum_address(wallet)).call()
return Decimal(raw) / Decimal(10 ** decimals)
不要因为某符号在别处通常是 6 位就硬编码 1_000_000。
按链 + 代币缓存(lru_cache,键含 chain_id):
from functools import lru_cache
@lru_cache(maxsize=512)
def get_decimals(chain_id: int, token_address: str) -> int:
w3 = get_web3_for_chain(chain_id)
contract = w3.eth.contract(
address=Web3.to_checksum_address(token_address),
abi=ERC20_ABI,
)
return contract.functions.decimals().call()
防御性处理非标准代币(revert 回退 18 并告警):
try:
decimals = contract.functions.decimals().call()
except Exception:
logging.warning(
"decimals() reverted on %s (chain %s), defaulting to 18",
token_address,
chain_id,
)
decimals = 18
记录回退日志保持可见——旧的或非标准代币依然存在。
Solidity 中归一化到 WAD(18 位):
interface IERC20Metadata {
function decimals() external view returns (uint8);
}
function normalizeToWad(address token, uint256 amount) internal view returns (uint256) {
uint8 d = IERC20Metadata(token).decimals();
if (d == 18) return amount;
if (d < 18) return amount * 10 ** (18 - d);
return amount / 10 ** (d - 18);
}
TypeScript(ethers,并发取 decimals 与余额):
import { Contract, formatUnits } from 'ethers';
const ERC20_ABI = [
'function decimals() view returns (uint8)',
'function balanceOf(address) view returns (uint256)',
];
async function getBalance(provider: any, tokenAddress: string, wallet: string): Promise<string> {
const token = new Contract(tokenAddress, ERC20_ABI, provider);
const [decimals, raw] = await Promise.all([
token.decimals(),
token.balanceOf(wallet),
]);
return formatUnits(raw, decimals);
}
命令行快速链上核对(foundry cast):
cast call <token_address> "decimals()(uint8)" --rpc-url <rpc>
注意事项
- 不要假设所有稳定币小数位一致:USDT/USDC 在以太坊是 6 位,在部分链或桥接版本可能是 18 位。
- 缓存键必须含
chain_id:同一符号、甚至同一地址在不同链上精度可能不同。 - 浮点不精确:float 在大数或多次运算后会丢精度,统一用
Decimal/BigInt。 decimals()可能 revert:极旧或非标准 ERC-20 未实现该方法,需回退并告警,不要静默吞掉。- 桥接/wrapper 会改变精度:资产跨桥或被包装后务必重查,不要沿用旧值。
- 计价/比较前先归一化:避免把不同精度的内部记账直接相加或比大小。
互见
- 同域 fintech 下涉及链上金额、价格计算、稳定币处理的技能。
- 涉及 web3.py / ethers.js 合约调用与缓存策略的技能。
采编自 affaan-m/everything-claude-code(MIT),适配重写自其 evm-token-decimals 技能。