ERC-20 на фронте: баланс, decimals, approve

ERC-20 — стандарт токенов. Учимся читать баланс с учётом decimals и понимать паттерн approve + transferFrom.

ERC-20 — стандарт взаимозаменяемых токенов: фиксированный набор функций (balanceOf, transfer, approve, allowance, transferFrom), который реализуют тысячи токенов.

USDT, USDC, DAI, любой проектный токен — это ERC-20. Стандарт означает: один ABI подходит ко всем токенам. Научились работать с одним — работаете со всеми.

Баланс и decimals

balanceOf возвращает сырое число. Сколько это «настоящих» токенов — зависит от decimals. У большинства токенов 18, у USDC и USDT — 6. Игнорировать decimals = показать пользователю в миллион раз больше или меньше:

// сырой баланс и decimals читаются из контракта; здесь — чистый расчёт
function formatUnits(raw, decimals) {
  const base = 10n ** BigInt(decimals);
  const whole = raw / base;
  const frac = (raw % base).toString().padStart(decimals, "0").replace(/0+$/, "");
  return frac ? `${whole}.${frac}` : whole.toString();
}

console.log(formatUnits(1500000n, 6));               // USDC: 1.5
console.log(formatUnits(2500000000000000000n, 18));  // DAI: 2.5
console.log(formatUnits(1000000n, 18));              // 0.000000000001

Вывод:

1.5
2.5
0.000000000001

Та же логика лежит в formatUnits/parseUnits из ethers — это обобщение formatEther на произвольные decimals.

Паттерн approve + transferFrom

Ключевая идея ERC-20, которую обязан понимать фронтендер. Прямой transfer переводит ваши токены сами. Но часто токены должен потратить другой контракт (биржа, стейкинг). Контракт не может взять ваши токены без разрешения. Поэтому:

  1. approve(spender, amount) — вы разрешаете контракту spender тратить до amount ваших токенов.
  2. transferFrom — теперь spender может списать токены в пределах разрешения.

На фронте это значит две транзакции для одной операции «положить токены в пул»: сначала approve, потом основное действие. UX-ловушка: пользователь часто не понимает, зачем «два раза подтверждать».

// шаг 1: разрешить
const a = await token.approve(spenderAddress, amount);
await a.wait();
// шаг 2: spender (другой контракт) списывает
const b = await pool.deposit(amount); // внутри вызовет transferFrom

allowance — текущее разрешение

Перед approve полезно прочитать allowance(owner, spender) — сколько уже разрешено. Если разрешения хватает, второй approve не нужен (экономит газ и клик). Это стандартная проверка в DeFi-фронтах.

Как работает под капотом

approve записывает в контракт токена число: «адрес owner разрешил адресу spender тратить N». transferFrom при вызове проверяет это число, уменьшает его и переводит токены. Никакого «доступа к кошельку» — всё в рамках состояния контракта токена. Бесконечный approve (amount = 2^256-1) часто используют, чтобы не подтверждать каждый раз, но это риск безопасности: вредоносный контракт сможет вывести всё. Безопаснее approve ровно на нужную сумму.

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

  • Не учитывать decimals. У USDC их 6, не 18 — баланс отобразится неверно.
  • Делать approve, когда allowance уже достаточно. Лишняя транзакция и газ.
  • Бездумный бесконечный approve. Удобно, но опасно; предлагайте approve на сумму.

Итоги

  • ERC-20 — единый стандарт; один ABI на все токены.
  • Всегда учитывайте decimals при показе баланса.
  • Паттерн approve + transferFrom = две транзакции; проверяйте allowance заранее.
Проверьте себя
1. Почему у USDC баланс надо делить на 10^6, а не 10^18?
AОшибка контракта
BУ USDC decimals = 6, а не 18
CТак требует ethers
DUSDC не ERC-20
2. Зачем нужен approve перед тем, как контракт спишет ваши токены?
AЧтобы оплатить газ
BЧтобы дать контракту разрешение тратить токены через transferFrom
CЧтобы сменить сеть
DЧтобы прочитать баланс
3. Что стоит проверить перед вызовом approve, чтобы не тратить лишний газ?
AchainId
BТекущий allowance(owner, spender)
CНомер блока
Ddecimals спендера