китаев.tech

ERC-20: balanceOf и decimals

Как читать баланс ERC-20 токена через ethers v6 и правильно форматировать raw-значение с учётом decimals.

Баланс ERC-20 — это не обычное число

balanceOf возвращает баланс токена в минимальных единицах, а не в человекочитаемом виде.

decimals показывает, сколько знаков после запятой использует токен. Например, 1 USDC хранится как 1000000, потому что у USDC decimals = 6.


Читаем баланс USDC

Создайте проект:

bash
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:

typescript
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);

Запустите скрипт:

bash
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.

Плохо:

typescript
const readable = Number(rawBalance) / 10 ** Number(decimals);

Такой код может потерять точность на больших балансах. Для отображения используйте ethers.formatUnits, а для обратного преобразования пользовательского ввода — ethers.parseUnits.

typescript
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
USDC6
USDT6
WBTC8
DAI18

Если UI всегда делит баланс на 10 ** 18, он покажет неверные суммы для USDC, USDT, WBTC и многих других токенов.


Частые ошибки

Считать, что у всех ERC-20 18 decimals → всегда читайте decimals() из контракта или используйте проверенные метаданные токена.

Хранить отформатированную строку как баланс → для расчётов храните raw bigint, а строку создавайте только для отображения.

Использовать Number(rawBalance) → не переводите токеновые суммы в number. Используйте bigint, formatUnits и parseUnits.


Что дальше

Материалы китаev.tech публикуются в образовательных целях и не являются инвестиционной рекомендацией. Примеры кода и описания протоколов — для обучения; использование в продакшне на ваш собственный риск. Дисклеймер и политика конфиденциальности.