Реальный пример: баланс и перевод токена

Финальный разбор: страница, которая показывает баланс токена и отправляет его — со всеми правильными состояниями и проверками.

Цель — собрать всё из курса в одну осмысленную страницу dApp: подключение, чтение баланса ERC-20, отправка с учётом decimals, статусы транзакции и проверки.

Соберём типичный экран DeFi-фронта: «мой баланс токена + форма перевода». Разберём по слоям, какие куски курса где работают. Код на wagmi/React — нерабочий в песочнице (нужны кошелёк и сеть), но показывает целостную картину.

Слой 1: подключение и определение пользователя

import { useAccount } from "wagmi";
import { ConnectButton } from "@rainbow-me/rainbowkit";

function App() {
  const { address, isConnected } = useAccount();
  return (
    <div>
      <ConnectButton />
      {isConnected ? <TokenPanel user={address!} /> : <p>Подключите кошелёк</p>}
    </div>
  );
}

Слой 2: чтение баланса с decimals

import { useReadContract } from "wagmi";

function useTokenBalance(user: `0x${string}`) {
  const { data: raw } = useReadContract({
    address: TOKEN, abi, functionName: "balanceOf", args: [user],
  });
  const { data: decimals } = useReadContract({
    address: TOKEN, abi, functionName: "decimals",
  });
  return { raw, decimals };
}

Форматирование сырого баланса — чистая логика, её можно проверить прямо здесь:

function formatUnits(raw, decimals) {
  const base = 10n ** BigInt(decimals);
  const whole = raw / base;
  const frac = (raw % base).toString().padStart(Number(decimals), "0").replace(/0+$/, "");
  return frac ? `${whole}.${frac}` : whole.toString();
}
// пример: баланс 1234560 у токена с decimals=6
console.log(formatUnits(1234560n, 6)); // 1.23456

Вывод:

1.23456

Слой 3: отправка с проверками и статусами

import { useWriteContract, useWaitForTransactionReceipt, useChainId } from "wagmi";
import { parseUnits } from "viem";

function SendForm({ decimals }: { decimals: number }) {
  const chainId = useChainId();
  const { writeContract, data: hash, isPending, error } = useWriteContract();
  const { isLoading: confirming, isSuccess } =
    useWaitForTransactionReceipt({ hash });

  const onSend = (to: `0x${string}`, human: string) => {
    if (chainId !== EXPECTED_CHAIN) return alert("Переключите сеть");
    const amount = parseUnits(human, decimals); // строка -> bigint, без float
    writeContract({ address: TOKEN, abi, functionName: "transfer", args: [to, amount] });
  };

  return (
    <button disabled={isPending || confirming} onClick={() => onSend(to, value)}>
      {isPending ? "Подтвердите…" : confirming ? "Ждём блок…" : "Отправить"}
    </button>
    // + isSuccess: показать "Готово" и обновить баланс
    // + error: различить reject (4001) и реальную ошибку
  );
}

Что где из курса сработало

Кусок страницыТема курса
ConnectButton, useAccountкошелёк, wagmi, RainbowKit
balanceOf + decimals + formatUnitsчтение контракта, числа, ERC-20
parseUnits, bigintwei/decimals, антиграбля float
проверка chainIdсмена сети, мультичейн
isPending/confirming/success/errorсостояния транзакции, обработка reject

Как работает под капотом (полный путь клика)

Пользователь жмёт «Отправить». Фронт проверяет сеть, переводит человекочитаемую сумму в wei через parseUnits, wagmi через viem кодирует вызов transfer по ABI и просит кошелёк подписать. Пользователь подтверждает — транзакция уходит в мемпул (isPending закончился, есть hash). useWaitForTransactionReceipt ждёт включения в блок (confirming), затем выставляет isSuccess. Фронт инвалидирует кэш баланса — useReadContract перечитывает новое значение, и UI обновляется. Весь путь — это сумма всех уроков курса в одном клике.

Частые ошибки в такой странице

  • Брать сумму из инпута как number и звать parseUnits(Number(...)). Потеря точности; передавайте строку.
  • Не блокировать кнопку. Двойная отправка перевода.
  • Не обновлять баланс после успеха. Пользователь видит старое число и думает, что перевод не прошёл.

Итоги

  • Реальный экран dApp = подключение + чтение с decimals + запись с проверками и статусами.
  • Каждая «мелочь» из курса (float, reject, сеть, decimals) здесь обязательна.
  • Полный путь клика проходит через все слои: сеть → подпись → блок → обновление UI.
Проверьте себя
1. Почему сумму перевода берут из инпута как строку и передают в parseUnits, а не как number?
AТак короче код
BЧтобы избежать потери точности float
CЭто требование RainbowKit
DЧтобы ускорить рендер
2. Что нужно проверить перед отправкой transfer в этой странице?
AЦвет кнопки
BЧто пользователь в нужной сети (chainId)
CВерсию React
DРазмер бандла
3. Что произойдёт с балансом на экране после успешного перевода, если не инвалидировать кэш чтения?
AОбновится сам мгновенно
BОстанется старое значение, и пользователь решит, что перевод не прошёл
CКонтракт откатит транзакцию
DСменится сеть