ERC-20: balanceOf и decimals
Как читать баланс ERC-20 токена через ethers v6 и правильно форматировать raw-значение с учётом decimals.
Баланс ERC-20 — это не обычное число
balanceOf возвращает баланс токена в минимальных единицах, а не в человекочитаемом виде.
decimals показывает, сколько знаков после запятой использует токен. Например, 1 USDC хранится как 1000000, потому что у USDC decimals = 6.
Читаем баланс USDC
Создайте проект:
mkdir erc20-balance-example
cd erc20-balance-example
npm init -y
npm pkg set type="module"
npm install ethers tsx typescript @types/node
mkdir scriptsСоздайте файл scripts/read-usdc-balance.ts:
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://ethereum.publicnode.com");
const usdcAddress = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const holderAddress = "0x28c6c06298d514db089934071355e5743bf21d60";
const erc20Abi = [
"function balanceOf(address account) view returns (uint256)",
"function decimals() view returns (uint8)",
"function symbol() view returns (string)",
];
const usdc = new ethers.Contract(usdcAddress, erc20Abi, provider);
const [rawBalance, decimals, symbol] = await Promise.all([
usdc.balanceOf(holderAddress),
usdc.decimals(),
usdc.symbol(),
]);
const formattedBalance = ethers.formatUnits(rawBalance, decimals);
console.log("Token:", symbol);
console.log("Holder:", holderAddress);
console.log("Raw balance:", rawBalance.toString());
console.log("Decimals:", decimals.toString());
console.log("Formatted balance:", formattedBalance, symbol);Запустите скрипт:
npx tsx scripts/read-usdc-balance.tsКод читает баланс реального USDC-контракта в Ethereum mainnet. Это read-вызовы: они не отправляют транзакцию, не требуют private key и не списывают gas.
Как это работает
new ethers.JsonRpcProvider(...) — подключается к RPC-ноде Ethereum.
erc20Abi — минимальный ABI. Для баланса не нужен полный JSON ABI токена.
new ethers.Contract(usdcAddress, erc20Abi, provider) — создаёт read-only объект ERC-20 контракта.
usdc.balanceOf(holderAddress) — вызывает balanceOf(address) и возвращает raw uint256 как bigint.
usdc.decimals() — возвращает количество знаков после запятой для отображения токена.
ethers.formatUnits(rawBalance, decimals) — переводит raw-значение в строку, которую можно показывать пользователю.
Promise.all([...]) — запускает независимые read-вызовы параллельно.
Почему нельзя делить вручную
Raw-значение токена может быть больше безопасного диапазона JavaScript number.
Плохо:
const readable = Number(rawBalance) / 10 ** Number(decimals);Такой код может потерять точность на больших балансах. Для отображения используйте ethers.formatUnits, а для обратного преобразования пользовательского ввода — ethers.parseUnits.
const rawAmount = ethers.parseUnits("12.5", decimals);
const displayAmount = ethers.formatUnits(rawAmount, decimals);
console.log(rawAmount.toString());
console.log(displayAmount);decimals не всегда равен 18
ETH использует 18 decimals, но ERC-20 токены выбирают это значение сами.
Примеры:
| Токен | Decimals |
|---|---|
| USDC | 6 |
| USDT | 6 |
| WBTC | 8 |
| DAI | 18 |
Если UI всегда делит баланс на 10 ** 18, он покажет неверные суммы для USDC, USDT, WBTC и многих других токенов.
Частые ошибки
Считать, что у всех ERC-20 18 decimals → всегда читайте decimals() из контракта или используйте проверенные метаданные токена.
Хранить отформатированную строку как баланс → для расчётов храните raw bigint, а строку создавайте только для отображения.
Использовать Number(rawBalance) → не переводите токеновые суммы в number. Используйте bigint, formatUnits и parseUnits.
Что дальше
- ERC-20: transfer — отправить токены и дождаться подтверждения транзакции
- ERC-20: approve и allowance — разрешить контракту тратить токены пользователя
- Вызов view-функций — глубже разобрать read-вызовы через
eth_call