Реализация отображения баланса токенов на сайте
Показать баланс токена — три строки кода. Показать его правильно — уже другая история: форматирование с учётом decimals, realtime-обновления, мультисетевой контекст, кэш с инвалидацией. Разбираем от простого к сложному.
Получение баланса нативного токена
import { formatEther } from 'ethers';
import { usePublicClient } from 'wagmi';
async function getNativeBalance(address: string, chainId: number): Promise<string> {
const client = createPublicClient({
chain: mainnet,
transport: http(process.env.ETH_RPC_URL),
});
const balance = await client.getBalance({ address: address as `0x${string}` });
return formatEther(balance); // "1.234567890123456789"
}
getBalance возвращает bigint в wei. formatEther делит на 10^18. Для отображения обычно нужно округлить до 4–6 значащих цифр.
ERC-20 баланс с учётом decimals
Разные токены имеют разное количество decimals: USDC — 6, большинство ERC-20 — 18, WBTC — 8. Использовать formatEther для USDC даст неправильный результат.
import { erc20Abi, formatUnits } from 'viem';
async function getTokenBalance(
tokenAddress: `0x${string}`,
walletAddress: `0x${string}`,
client: PublicClient,
): Promise<{ formatted: string; raw: bigint; decimals: number }> {
const [balance, decimals] = await client.multicall({
contracts: [
{
address: tokenAddress,
abi: erc20Abi,
functionName: 'balanceOf',
args: [walletAddress],
},
{
address: tokenAddress,
abi: erc20Abi,
functionName: 'decimals',
},
],
});
const raw = balance.result as bigint;
const dec = decimals.result as number;
return {
raw,
decimals: dec,
formatted: formatUnits(raw, dec),
};
}
multicall — один RPC-вызов вместо двух. На проектах с 10+ токенами это критично для производительности.
Компонент с автообновлением через wagmi
// components/TokenBalance.tsx
import { useBalance, useReadContract } from 'wagmi';
import { erc20Abi, formatUnits } from 'viem';
interface TokenBalanceProps {
address: `0x${string}`;
tokenAddress?: `0x${string}`; // если не задан — показываем нативный токен
symbol?: string;
}
export function TokenBalance({ address, tokenAddress, symbol }: TokenBalanceProps) {
// Нативный баланс
const { data: nativeBalance } = useBalance({
address,
query: { refetchInterval: 12_000 }, // обновление каждый блок (~12s на Ethereum)
});
// ERC-20 баланс
const { data: tokenBalance } = useReadContract({
address: tokenAddress,
abi: erc20Abi,
functionName: 'balanceOf',
args: [address],
query: { enabled: !!tokenAddress, refetchInterval: 12_000 },
});
const { data: decimals } = useReadContract({
address: tokenAddress,
abi: erc20Abi,
functionName: 'decimals',
query: { enabled: !!tokenAddress, staleTime: Infinity }, // decimals не меняются
});
if (tokenAddress && tokenBalance != null && decimals != null) {
const formatted = formatUnits(tokenBalance as bigint, decimals as number);
return <BalanceDisplay value={formatted} symbol={symbol ?? 'TOKEN'} />;
}
if (nativeBalance) {
return <BalanceDisplay value={nativeBalance.formatted} symbol={nativeBalance.symbol} />;
}
return <BalanceSkeleton />;
}
Форматирование для UI
Сырое formatUnits возвращает строку с 18 знаками после запятой. Для отображения нужна логика:
export function formatTokenAmount(
value: string | bigint,
decimals: number,
opts?: { maxDecimals?: number; compact?: boolean },
): string {
const raw = typeof value === 'bigint' ? formatUnits(value, decimals) : value;
const num = parseFloat(raw);
if (num === 0) return '0';
const maxDec = opts?.maxDecimals ?? 4;
if (opts?.compact && num >= 1_000_000) {
return `${(num / 1_000_000).toFixed(2)}M`;
}
if (opts?.compact && num >= 1_000) {
return `${(num / 1_000).toFixed(2)}K`;
}
// Для очень маленьких значений — показать значащие цифры
if (num < 0.0001 && num > 0) {
return num.toExponential(2);
}
return num.toLocaleString('en-US', {
maximumFractionDigits: maxDec,
minimumFractionDigits: 0,
});
}
Отображение нескольких токенов
Когда нужно показать portfolio — балансы 10–20 токенов — важно не делать 20 отдельных RPC-запросов. multicall3 контракт (deployed на большинстве EVM-сетей) агрегирует все запросы в один:
import { multicall } from 'viem/actions';
async function getPortfolioBalances(
tokens: Array<{ address: `0x${string}`; decimals: number; symbol: string }>,
walletAddress: `0x${string}`,
client: PublicClient,
) {
const calls = tokens.map(token => ({
address: token.address,
abi: erc20Abi,
functionName: 'balanceOf' as const,
args: [walletAddress] as [`0x${string}`],
}));
const results = await multicall(client, { contracts: calls });
return tokens.map((token, i) => ({
...token,
balance: results[i].result as bigint,
formatted: formatUnits(results[i].result as bigint, token.decimals),
}));
}
Срок реализации: один токен с обновлением в реальном времени — половина дня. Полноценный portfolio-виджет с несколькими токенами, форматированием, skeleton-загрузкой и обновлением по блокам — 1–2 дня.







