Ethereum для веб-розробників: Смартконтракти без хайпу
Концепції Ethereum для кожного веб-розробника: акаунти, транзакції, смартконтракти, ABI encoding, ethers.js, WAGMI та читання on-chain даних без власної ноди.
Більшість контенту «Ethereum для розробників» потрапляє в дві категорії: спрощені аналогії, що не допомагають нічого побудувати, або глибокі специфікації протоколу, що припускають, що ви вже знаєте, що таке Merkle Patricia Trie. Жодне не є корисним, якщо ви веб-розробник, який хоче прочитати баланс токенів, дозволити користувачу підписати транзакцію або відобразити метадані NFT у React-застосунку.
Ця стаття — практична золота середина. Я поясню точно, що відбувається, коли ваш фронтенд спілкується з Ethereum, які є рухомі частини, і як сучасний інструментарій (ethers.js, viem, WAGMI) відповідає концепціям, які ви вже розумієте з побудови веб-застосунків.
Жодних метафор про торговельні автомати. Жодних «уявіть собі світ, де...» Лише технічна модель і код.
Ментальна модель#
Ethereum — це реплікована машина станів. Кожна нода в мережі підтримує ідентичну копію стану — масивне сховище ключ-значення, яке відображає адреси на дані акаунтів. Коли ви «надсилаєте транзакцію», ви пропонуєте перехід стану. Якщо достатня кількість валідаторів погоджується, що він валідний, стан оновлюється. Ось і все.
Сам стан досить простий. Це відображення 20-байтових адрес на об'єкти акаунтів. Кожен акаунт має чотири поля:
- nonce: Скільки транзакцій цей акаунт надіслав (для EOA) або скільки контрактів він створив (для контрактних акаунтів). Це запобігає replay-атакам.
- balance: Кількість ETH, деномінована в wei (1 ETH = 10^18 wei). Завжди великий цілочисельний тип.
- codeHash: Хеш EVM-байткоду. Для звичайних гаманців (EOA) це хеш порожніх байтів. Для контрактів — хеш задеплоєного коду.
- storageRoot: Кореневий хеш trie сховища акаунта. Лише контракти мають значуще сховище.
Є два типи акаунтів, і ця відмінність важлива для всього, що йде далі:
Зовнішні акаунти (Externally Owned Accounts, EOA) контролюються приватним ключем. Це те, чим керує MetaMask. Вони можуть ініціювати транзакції. Вони не мають коду. Коли хтось каже «гаманець», мається на увазі EOA.
Контрактні акаунти контролюються їхнім кодом. Вони не можуть ініціювати транзакції — вони можуть лише виконуватися у відповідь на виклик. Вони мають код і сховище. Коли хтось каже «смартконтракт», мається на увазі саме це. Код є незмінним після деплою (з деякими винятками через proxy-патерни, що є цілком окремою дискусією).
Критичний інсайт: кожна зміна стану в Ethereum починається з того, що EOA підписує транзакцію. Контракти можуть викликати інші контракти, але ланцюг виконання завжди починається з людини (або бота) з приватним ключем.
Gas: Обчислення мають ціну#
Кожна операція в EVM коштує gas. Додавання двох чисел коштує 3 gas. Зберігання 32-байтового слова коштує 20 000 gas (перший раз) або 5 000 gas (оновлення). Читання сховища коштує 2 100 gas (холодне) або 100 gas (тепле, вже доступне в цій транзакції).
Ви не платите gas у «одиницях gas». Ви платите в ETH. Загальна вартість:
totalCost = gasUsed * gasPrice
Після EIP-1559 (London upgrade) ціноутворення gas стало двокомпонентним:
totalCost = gasUsed * (baseFee + priorityFee)
- baseFee: Встановлюється протоколом на основі завантаженості мережі. Спалюється (знищується).
- priorityFee (чайові): Йде валідатору. Більші чайові = швидше включення.
- maxFeePerGas: Максимум, який ви готові платити за одиницю gas.
- maxPriorityFeePerGas: Максимальні чайові за одиницю gas.
Якщо baseFee + priorityFee > maxFeePerGas, ваша транзакція чекає, поки baseFee знизиться. Ось чому транзакції «застрягають» під час високого навантаження.
Практичне значення для веб-розробників: читання даних безплатне. Запис даних коштує грошей. Це єдина найважливіша архітектурна відмінність між Web2 і Web3. Кожен SELECT безплатний. Кожен INSERT, UPDATE, DELETE коштує реальних грошей. Проєктуйте свої dApp відповідно.
Транзакції#
Транзакція — це підписана структура даних. Ось поля, які мають значення:
interface Transaction {
// Хто отримує цю транзакцію — адреса EOA або контрактна адреса
to: string; // 20-байтова hex-адреса, або null для деплою контракту
// Скільки ETH надіслати (в wei)
value: bigint; // Може бути 0n для чистих викликів контрактів
// Закодовані дані виклику функції, або порожні для простих переказів ETH
data: string; // Hex-закодовані байти, "0x" для простих переказів
// Послідовний лічильник, запобігає replay-атакам
nonce: number; // Має точно дорівнювати поточному nonce відправника
// Ліміт gas — максимум gas, який може спожити ця транзакція
gasLimit: bigint;
// Параметри плати EIP-1559
maxFeePerGas: bigint;
maxPriorityFeePerGas: bigint;
// Ідентифікатор ланцюга (1 = mainnet, 11155111 = Sepolia, 137 = Polygon)
chainId: number;
}Життєвий цикл транзакції#
-
Побудова: Ваш застосунок будує об'єкт транзакції. Якщо ви викликаєте функцію контракту, поле
dataмістить ABI-закодований виклик функції (детальніше нижче). -
Підписання: Приватний ключ підписує RLP-закодовану транзакцію, створюючи компоненти підпису
v,r,s. Це доводить, що відправник авторизував саме цю конкретну транзакцію. Адреса відправника виводиться з підпису — вона не вказана явно в транзакції. -
Трансляція: Підписана транзакція надсилається на RPC-ноду через
eth_sendRawTransaction. Нода валідує її (правильний nonce, достатній баланс, валідний підпис) і додає її до свого mempool. -
Mempool: Транзакція знаходиться в пулі очікуючих транзакцій. Валідатори вибирають транзакції для включення в наступний блок, зазвичай віддаючи перевагу вищим чайовим. Тут відбувається фронт-ранінг — інші учасники можуть бачити вашу очікуючу транзакцію і подати свою з вищими чайовими для виконання перед вашою.
-
Включення: Валідатор включає вашу транзакцію в блок. EVM виконує її. Якщо вона успішна, зміни стану застосовуються. Якщо вона reverts, зміни стану відкочуються — але ви все одно платите за gas, спожитий до точки revert.
-
Фінальність: На proof-of-stake Ethereum блок стає «фіналізованим» після двох епох (~12,8 хвилин). До фінальності реорганізації ланцюга теоретично можливі (хоча рідкісні). Більшість застосунків вважають 1-2 підтвердження блоків «достатніми» для некритичних операцій.
Ось як виглядає надсилання простого переказу ETH з ethers.js v6:
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
const wallet = new ethers.Wallet("0xYOUR_PRIVATE_KEY", provider);
const tx = await wallet.sendTransaction({
to: "0xRecipientAddress...",
value: ethers.parseEther("0.1"), // Конвертує "0.1" в wei (100000000000000000n)
});
console.log("Tx hash:", tx.hash);
// Чекаємо включення в блок
const receipt = await tx.wait();
console.log("Block number:", receipt.blockNumber);
console.log("Gas used:", receipt.gasUsed.toString());
console.log("Status:", receipt.status); // 1 = успіх, 0 = revertІ те саме з viem:
import { createWalletClient, http, parseEther } from "viem";
import { mainnet } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
const account = privateKeyToAccount("0xYOUR_PRIVATE_KEY");
const client = createWalletClient({
account,
chain: mainnet,
transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
const hash = await client.sendTransaction({
to: "0xRecipientAddress...",
value: parseEther("0.1"),
});
console.log("Tx hash:", hash);Зверніть увагу на різницю: ethers повертає об'єкт TransactionResponse з методом .wait(). Viem повертає лише хеш — ви використовуєте окремий виклик publicClient.waitForTransactionReceipt({ hash }) для очікування підтвердження. Це розділення відповідальностей є навмисним у дизайні viem.
Смартконтракти#
Смартконтракт — це задеплоєний байткод плюс постійне сховище за конкретною адресою. Коли ви «викликаєте» контракт, ви надсилаєте транзакцію (або робите read-only виклик) з полем data, встановленим на закодований виклик функції.
Байткод та ABI#
Байткод — це скомпільований EVM-код. Ви не взаємодієте з ним напряму. Це те, що виконує EVM.
ABI (Application Binary Interface) — це JSON-опис інтерфейсу контракту. Він повідомляє вашій клієнтській бібліотеці, як кодувати виклики функцій та декодувати значення, що повертаються. Вважайте це специфікацією OpenAPI для контракту.
Ось фрагмент ABI токена ERC-20:
const ERC20_ABI = [
// Read-only функції (view/pure — без вартості gas при зовнішньому виклику)
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address owner) view returns (uint256)",
"function allowance(address owner, address spender) view returns (uint256)",
// Функції зміни стану (потребують транзакції, коштують gas)
"function transfer(address to, uint256 amount) returns (bool)",
"function approve(address spender, uint256 amount) returns (bool)",
"function transferFrom(address from, address to, uint256 amount) returns (bool)",
// Події (емітуються під час виконання, зберігаються в логах транзакцій)
"event Transfer(address indexed from, address indexed to, uint256 value)",
"event Approval(address indexed owner, address indexed spender, uint256 value)",
] as const;Ethers.js приймає цей формат «людиночитабельного ABI». Viem також може його використовувати, але часто ви працюватимете з повним JSON ABI, згенерованим компілятором Solidity. Обидва еквівалентні — людиночитабельний формат просто зручніший для поширених інтерфейсів.
Як кодуються виклики функцій#
Це частина, яку більшість туторіалів пропускають, і це та частина, що збереже вам години дебагінгу.
Коли ви викликаєте transfer("0xBob...", 1000000), поле data транзакції встановлюється на:
0xa9059cbb // Селектор функції
0000000000000000000000000xBob...000000000000000000000000 // address, доповнена до 32 байтів
00000000000000000000000000000000000000000000000000000000000f4240 // uint256 amount (1000000 у hex)
Селектор функції — це перші 4 байти хешу Keccak-256 сигнатури функції:
keccak256("transfer(address,uint256)") = 0xa9059cbb...
selector = перші 4 байти = 0xa9059cbb
Решта байтів — це ABI-закодовані аргументи, кожен доповнений до 32 байтів. Ця схема кодування детерміністична — один і той же виклик функції завжди створює одні й ті самі calldata.
Чому це важливо? Тому що коли ви бачите сирі дані транзакції на Etherscan і вони починаються з 0xa9059cbb, ви знаєте, що це виклик transfer. Коли ваша транзакція revert і повідомлення про помилку — лише hex-блоб, ви можете декодувати його за допомогою ABI. І коли ви будуєте батчі транзакцій або взаємодієте з multicall-контрактами, ви будете кодувати calldata вручну.
Ось як кодувати та декодувати вручну з ethers.js:
import { ethers } from "ethers";
const iface = new ethers.Interface(ERC20_ABI);
// Закодувати виклик функції
const calldata = iface.encodeFunctionData("transfer", [
"0xBobAddress...",
1000000n,
]);
console.log(calldata);
// 0xa9059cbb000000000000000000000000bob...000000000000000000000000000f4240
// Декодувати calldata назад у назву функції та аргументи
const decoded = iface.parseTransaction({ data: calldata });
console.log(decoded.name); // "transfer"
console.log(decoded.args[0]); // "0xBobAddress..."
console.log(decoded.args[1]); // 1000000n (BigInt)
// Декодувати дані повернення функції
const returnData = "0x0000000000000000000000000000000000000000000000000000000000000001";
const result = iface.decodeFunctionResult("transfer", returnData);
console.log(result[0]); // trueСлоти сховища#
Сховище контракту — це сховище ключ-значення, де ключі та значення мають розмір 32 байти. Solidity призначає слоти сховища послідовно, починаючи з 0. Перша оголошена змінна стану йде в слот 0, наступна в слот 1 і так далі. Mapping та динамічні масиви використовують схему на основі хешів.
Ви можете читати сховище будь-якого контракту напряму, навіть якщо змінна позначена як private в Solidity. «Private» означає лише те, що інші контракти не можуть її прочитати — будь-хто може прочитати через eth_getStorageAt:
// Читання слота сховища 0 контракту
const slot0 = await provider.getStorage(
"0xContractAddress...",
0
);
console.log(slot0); // Сире 32-байтове hex-значенняОсь як блокчейн-експлорери показують «внутрішній» стан контракту. Немає контролю доступу на читання сховища. Приватність у публічному блокчейні фундаментально обмежена.
Події та логи#
Події — це спосіб контракту емітувати структуровані дані, які зберігаються в логах транзакцій, але не в сховищі контракту. Вони дешевші за запис у сховище (375 gas за перший topic + 8 gas за байт даних, проти 20 000 gas за запис у сховище) і вони розроблені для ефективних запитів.
Подія може мати до 3 indexed параметрів (зберігаються як «topics») і будь-яку кількість неіндексованих параметрів (зберігаються як «data»). Індексовані параметри можна фільтрувати — ви можете запитати «дай мені всі Transfer-події, де to — ця адреса». Неіндексовані параметри не можна фільтрувати; вам потрібно отримати всі відповідні події та фільтрувати на стороні клієнта.
// Прослуховування Transfer-подій у реальному часі з ethers.js
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
contract.on("Transfer", (from, to, value, event) => {
console.log(`${from} -> ${to}: ${ethers.formatUnits(value, 18)} токенів`);
console.log("Block:", event.log.blockNumber);
console.log("Tx hash:", event.log.transactionHash);
});
// Запит історичних подій
const filter = contract.filters.Transfer(null, "0xMyAddress..."); // from=будь-хто, to=конкретний
const events = await contract.queryFilter(filter, 19000000, 19100000); // діапазон блоків
for (const event of events) {
console.log("From:", event.args.from);
console.log("Value:", event.args.value.toString());
}Те саме з viem:
import { createPublicClient, http, parseAbiItem } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({
chain: mainnet,
transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
// Історичні логи
const logs = await client.getLogs({
address: "0xTokenAddress...",
event: parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)"
),
args: {
to: "0xMyAddress...",
},
fromBlock: 19000000n,
toBlock: 19100000n,
});
for (const log of logs) {
console.log("From:", log.args.from);
console.log("To:", log.args.to);
console.log("Value:", log.args.value);
}
// Спостереження в реальному часі
const unwatch = client.watchEvent({
address: "0xTokenAddress...",
event: parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)"
),
onLogs: (logs) => {
for (const log of logs) {
console.log(`Transfer: ${log.args.from} -> ${log.args.to}`);
}
},
});
// Викличте unwatch() щоб припинити прослуховуванняЧитання on-chain даних#
Ось де Ethereum стає практичним для веб-розробників. Вам не потрібно запускати ноду. Вам не потрібно майнити. Вам навіть не потрібен гаманець. Читання даних з Ethereum безплатне, безперешкодне і працює через простий JSON-RPC API.
JSON-RPC: HTTP API Ethereum#
Кожна нода Ethereum виставляє JSON-RPC API. Це буквально HTTP POST із JSON-тілами. Нічого блокчейн-специфічного в транспортному рівні немає.
// Це те, що ваша бібліотека робить під капотом
const response = await fetch("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "eth_call",
params: [
{
to: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
data: "0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
// balanceOf(vitalik.eth)
},
"latest",
],
}),
});
const result = await response.json();
console.log(result);
// { jsonrpc: "2.0", id: 1, result: "0x000000000000000000000000000000000000000000000000000000174876e800" }Це сирий eth_call. Він симулює виконання транзакції без фактичної відправки. Без вартості gas. Без зміни стану. Просто читає значення, що повертається. Ось як view і pure функції працюють ззовні — вони використовують eth_call замість eth_sendRawTransaction.
Два критичних RPC-методи#
eth_call: Симулює виконання. Безплатно. Без зміни стану. Використовується для всіх операцій читання — перевірка балансів, читання цін, виклик view-функцій. Може бути викликаний на будь-якому історичному блоці, якщо вказати номер блоку замість «latest».
eth_sendRawTransaction: Подає підписану транзакцію для включення в блок. Коштує gas. Змінює стан (якщо успішно). Використовується для всіх операцій запису — переказів, апрувів, свопів, мінтів.
Все інше в JSON-RPC API — це або варіант цих двох, або допоміжний метод (eth_blockNumber, eth_getTransactionReceipt, eth_getLogs тощо).
Провайдери: Ваш шлюз до ланцюга#
Ви не запускаєте власну ноду. Майже ніхто цього не робить для розробки застосунків. Натомість ви використовуєте сервіс провайдера:
- Alchemy: Найпопулярніший. Чудовий дашборд, підтримка вебхуків, розширені API для NFT і метаданих токенів. Безплатний рівень: ~300M compute units/місяць.
- Infura: Оригінальний. Належить ConsenSys. Надійний. Безплатний рівень: 100K запитів/день.
- QuickNode: Добрий для мультичейн. Трохи інша модель ціноутворення.
- Публічні RPC-ендпоінти:
https://rpc.ankr.com/eth,https://cloudflare-eth.com. Безплатні, але з обмеженням частоти та іноді ненадійні. Прийнятні для розробки, небезпечні для продакшену. - Tenderly: Чудовий для симуляції та дебагінгу. Їхній RPC включає вбудований симулятор транзакцій.
Для продакшену завжди налаштовуйте щонайменше двох провайдерів як фоллбеки. Простій RPC — це реальність, і він станеться у найгірший можливий момент.
import { ethers } from "ethers";
// ethers.js v6 фоллбек-провайдер
const provider = new ethers.FallbackProvider([
{
provider: new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/KEY1"),
priority: 1,
stallTimeout: 2000,
weight: 2,
},
{
provider: new ethers.JsonRpcProvider("https://mainnet.infura.io/v3/KEY2"),
priority: 2,
stallTimeout: 2000,
weight: 1,
},
]);Безплатне читання стану контракту#
Це потужний хід, про який більшість Web2-розробників не здогадується: ви можете читати будь-які публічні дані з будь-якого контракту на Ethereum, нічого не платячи, без гаманця та без будь-якої автентифікації, крім API-ключа для вашого RPC-провайдера.
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
// Інтерфейс ERC-20 — лише read-функції
const erc20 = new ethers.Contract(
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
[
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address) view returns (uint256)",
],
provider // Зверніть увагу: provider, не signer. Лише читання.
);
const [name, symbol, decimals, totalSupply] = await Promise.all([
erc20.name(),
erc20.symbol(),
erc20.decimals(),
erc20.totalSupply(),
]);
console.log(`${name} (${symbol})`); // "USD Coin (USDC)"
console.log(`Decimals: ${decimals}`); // 6 (НЕ 18!)
console.log(`Total supply: ${ethers.formatUnits(totalSupply, decimals)}`);
// Перевірити баланс конкретної адреси
const balance = await erc20.balanceOf("0xSomeAddress...");
console.log(`Balance: ${ethers.formatUnits(balance, decimals)} USDC`);Без гаманця. Без gas. Без транзакції. Просто JSON-RPC eth_call під капотом. Це ідентично за концепцією виконанню GET-запиту до REST API. Блокчейн — це база даних, контракт — це API, а eth_call — ваш SELECT-запит.
ethers.js v6#
ethers.js — це jQuery Web3 — це була перша бібліотека, яку вивчила більшість розробників, і вона досі найбільш використовувана. Версія 6 — значне покращення порівняно з v5, з нативною підтримкою BigInt (нарешті), ESM-модулями та чистішим API.
Три основні абстракції#
Provider: Read-only з'єднання з блокчейном. Може викликати view-функції, отримувати блоки, читати логи. Не може підписувати або надсилати транзакції.
import { ethers } from "ethers";
// Підключення до ноди
const provider = new ethers.JsonRpcProvider("https://...");
// Базові запити
const blockNumber = await provider.getBlockNumber();
const balance = await provider.getBalance("0xAddress...");
const block = await provider.getBlock(blockNumber);
const txCount = await provider.getTransactionCount("0xAddress...");Signer: Абстракція над приватним ключем. Може підписувати транзакції та повідомлення. Signer завжди підключений до Provider.
// З приватного ключа (серверна сторона, скрипти)
const wallet = new ethers.Wallet("0xPrivateKey...", provider);
// З браузерного гаманця (клієнтська сторона)
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const signer = await browserProvider.getSigner();
// Отримати адресу
const address = await signer.getAddress();Contract: JavaScript-проксі для задеплоєного контракту. Методи на об'єкті Contract відповідають функціям в ABI. View-функції повертають значення. Функції зміни стану повертають TransactionResponse.
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
// Читання (безплатно, повертає значення напряму)
const balance = await usdc.balanceOf("0xSomeAddress...");
// balance — це bigint: 1000000000n (1000 USDC з 6 десятковими)
// Для запису підключіться з signer
const usdcWithSigner = usdc.connect(signer);
// Запис (коштує gas, повертає TransactionResponse)
const tx = await usdcWithSigner.transfer("0xRecipient...", 1000000n);
const receipt = await tx.wait(); // Чекаємо включення в блок
if (receipt.status === 0) {
throw new Error("Transaction reverted");
}TypeChain для типобезпеки#
Сирі ABI-взаємодії мають строковий тип. Ви можете помилково написати назву функції, передати неправильні типи аргументів або неправильно інтерпретувати значення, що повертаються. TypeChain генерує TypeScript-типи з ваших ABI-файлів:
// Без TypeChain — без перевірки типів
const balance = await contract.balanceOf("0x...");
// balance має тип 'any'. Немає автодоповнення. Легко зловживати.
// З TypeChain — повна типобезпека
import { USDC__factory } from "./typechain";
const usdc = USDC__factory.connect(USDC_ADDRESS, provider);
const balance = await usdc.balanceOf("0x...");
// balance має тип BigNumber. Автодоповнення працює. Помилки типів ловляться при компіляції.Для нових проєктів розгляньте використання вбудованого виведення типів viem з ABI. Він досягає того ж результату без окремого кроку генерації коду.
Прослуховування подій#
Стрімінг подій у реальному часі критичний для чуйних dApp. ethers.js використовує WebSocket-провайдери для цього:
// WebSocket для подій у реальному часі
const wsProvider = new ethers.WebSocketProvider("wss://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
const contract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, wsProvider);
// Прослуховувати всі Transfer-події
contract.on("Transfer", (from, to, value, event) => {
console.log(`Transfer: ${from} -> ${to}`);
console.log(`Amount: ${ethers.formatUnits(value, 6)} USDC`);
});
// Прослуховувати переказів НА конкретну адресу
const filter = contract.filters.Transfer(null, "0xMyAddress...");
contract.on(filter, (from, to, value) => {
console.log(`Вхідний переказ: ${ethers.formatUnits(value, 6)} USDC від ${from}`);
});
// Очищення коли закінчили
contract.removeAllListeners();WAGMI + Viem: Сучасний стек#
WAGMI (We're All Gonna Make It) — це бібліотека React-хуків для Ethereum. Viem — це базовий TypeScript-клієнт, який вона використовує. Разом вони значною мірою замінили ethers.js + web3-react як стандартний стек для фронтенд-розробки dApp.
Чому перехід? Три причини: повне виведення TypeScript-типів з ABI (без кодогенерації), менший розмір бандлу та React-хуки, що обробляють брудне асинхронне керування станом взаємодій з гаманцем.
Налаштування#
// wagmi.config.ts
import { createConfig, http } from "wagmi";
import { mainnet, sepolia } from "wagmi/chains";
import { injected, walletConnect } from "wagmi/connectors";
export const config = createConfig({
chains: [mainnet, sepolia],
connectors: [
injected(),
walletConnect({ projectId: "YOUR_WALLETCONNECT_PROJECT_ID" }),
],
transports: {
[mainnet.id]: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
[sepolia.id]: http("https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY"),
},
});// app/providers.tsx
"use client";
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "./wagmi.config";
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</WagmiProvider>
);
}Читання даних контракту#
useReadContract — це хук, який ви використовуватимете найчастіше. Він огортає eth_call кешуванням React Query, рефетчингом та станами loading/error:
"use client";
import { useReadContract } from "wagmi";
import { formatUnits } from "viem";
const ERC20_ABI = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "owner", type: "address" }],
outputs: [{ name: "balance", type: "uint256" }],
},
] as const;
function TokenBalance({ address }: { address: `0x${string}` }) {
const { data: balance, isLoading, error } = useReadContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
abi: ERC20_ABI,
functionName: "balanceOf",
args: [address],
});
if (isLoading) return <span>Завантаження...</span>;
if (error) return <span>Помилка: {error.message}</span>;
// balance має тип bigint, бо ABI вказує uint256
return <span>{formatUnits(balance ?? 0n, 6)} USDC</span>;
}Зверніть увагу на as const в ABI. Це критично. Без нього TypeScript втрачає літеральні типи і balance стає unknown замість bigint. Уся система виведення типів залежить від const assertions.
Запис у контракти#
useWriteContract обробляє повний життєвий цикл: запит до гаманця, підписання, трансляцію та відстеження підтвердження.
"use client";
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { parseUnits } from "viem";
function SendTokens() {
const { writeContract, data: hash, isPending, error } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash,
});
function handleSend() {
writeContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: ERC20_ABI,
functionName: "transfer",
args: [
"0xRecipientAddress...",
parseUnits("100", 6), // 100 USDC
],
});
}
return (
<div>
<button onClick={handleSend} disabled={isPending}>
{isPending ? "Підтвердіть у гаманці..." : "Надіслати 100 USDC"}
</button>
{hash && <p>Транзакція: {hash}</p>}
{isConfirming && <p>Очікування підтвердження...</p>}
{isSuccess && <p>Переказ підтверджено!</p>}
{error && <p>Помилка: {error.message}</p>}
</div>
);
}Спостереження за подіями#
useWatchContractEvent налаштовує WebSocket-підписку для моніторингу подій у реальному часі:
"use client";
import { useWatchContractEvent } from "wagmi";
import { useState } from "react";
import { formatUnits } from "viem";
interface TransferEvent {
from: string;
to: string;
value: string;
}
function LiveTransfers() {
const [transfers, setTransfers] = useState<TransferEvent[]>([]);
useWatchContractEvent({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: ERC20_ABI,
eventName: "Transfer",
onLogs(logs) {
const newTransfers = logs.map((log) => ({
from: log.args.from as string,
to: log.args.to as string,
value: formatUnits(log.args.value as bigint, 6),
}));
setTransfers((prev) => [...newTransfers, ...prev].slice(0, 50));
},
});
return (
<ul>
{transfers.map((t, i) => (
<li key={i}>
{t.from.slice(0, 8)}... → {t.to.slice(0, 8)}...: {t.value} USDC
</li>
))}
</ul>
);
}Патерни підключення гаманця#
Підключення гаманця користувача — це «логін» Web3. Тільки що це не логін. Немає сесії, немає cookie, немає серверного стану. Підключення гаманця дає вашому застосунку дозвіл на читання адреси користувача та запит підписів транзакцій. Ось і все.
Інтерфейс провайдера EIP-1193#
Кожен гаманець виставляє стандартний інтерфейс, визначений EIP-1193. Це об'єкт з методом request:
interface EIP1193Provider {
request(args: { method: string; params?: unknown[] }): Promise<unknown>;
on(event: string, handler: (...args: unknown[]) => void): void;
removeListener(event: string, handler: (...args: unknown[]) => void): void;
}MetaMask інжектує це як window.ethereum. Інші гаманці або інжектують свою власну властивість, або також використовують window.ethereum (що спричиняє конфлікти — проблема «війни гаманців», частково вирішена EIP-6963).
// Низькорівнева взаємодія з гаманцем (не варто робити напряму, але корисно для розуміння)
// Запит доступу до акаунту
const accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});
console.log("Підключена адреса:", accounts[0]);
// Отримати поточний ланцюг
const chainId = await window.ethereum.request({
method: "eth_chainId",
});
console.log("Chain ID:", parseInt(chainId, 16)); // "0x1" -> 1 (mainnet)
// Прослуховувати зміни акаунту (користувач змінює акаунти в MetaMask)
window.ethereum.on("accountsChanged", (accounts: string[]) => {
if (accounts.length === 0) {
console.log("Гаманець відключено");
} else {
console.log("Переключено на:", accounts[0]);
}
});
// Прослуховувати зміни ланцюга (користувач змінює мережі)
window.ethereum.on("chainChanged", (chainId: string) => {
// Рекомендований підхід — перезавантажити сторінку
window.location.reload();
});EIP-6963: Виявлення кількох гаманців#
Старий підхід window.ethereum ламається, коли у користувачів встановлено кілька гаманців. Який отримує window.ethereum? Останній, що інжектується? Перший? Це гонка умов.
EIP-6963 виправляє це протоколом виявлення на основі подій браузера:
// Виявлення всіх доступних гаманців
interface EIP6963ProviderDetail {
info: {
uuid: string;
name: string;
icon: string;
rdns: string; // Зворотне доменне ім'я, напр. "io.metamask"
};
provider: EIP1193Provider;
}
const wallets: EIP6963ProviderDetail[] = [];
window.addEventListener("eip6963:announceProvider", (event: CustomEvent) => {
wallets.push(event.detail);
});
// Запросити всі гаманці оголосити себе
window.dispatchEvent(new Event("eip6963:requestProvider"));
// Тепер 'wallets' містить усі встановлені гаманці з їхніми назвами та іконками
// Ви можете показати UI вибору гаманцяWAGMI обробляє все це за вас. Коли ви використовуєте конектор injected(), він автоматично використовує EIP-6963, якщо доступний, і повертається до window.ethereum.
WalletConnect#
WalletConnect — це протокол, що з'єднує мобільні гаманці з десктопними dApp через relay-сервер. Користувач сканує QR-код своїм мобільним гаманцем, встановлюючи зашифроване з'єднання. Запити транзакцій передаються від вашого dApp на їхній телефон.
З WAGMI це просто ще один конектор:
import { walletConnect } from "wagmi/connectors";
const connector = walletConnect({
projectId: "YOUR_PROJECT_ID", // Отримайте з cloud.walletconnect.com
showQrModal: true,
});Обробка переключення ланцюгів#
Користувачі часто знаходяться в неправильній мережі. Ваш dApp на Mainnet, вони підключені до Sepolia. Або вони на Polygon, а вам потрібен Mainnet. WAGMI надає useSwitchChain:
"use client";
import { useAccount, useSwitchChain } from "wagmi";
import { mainnet } from "wagmi/chains";
function NetworkGuard({ children }: { children: React.ReactNode }) {
const { chain } = useAccount();
const { switchChain, isPending } = useSwitchChain();
if (!chain) return <p>Будь ласка, підключіть ваш гаманець</p>;
if (chain.id !== mainnet.id) {
return (
<div>
<p>Будь ласка, переключіться на Ethereum Mainnet</p>
<button
onClick={() => switchChain({ chainId: mainnet.id })}
disabled={isPending}
>
{isPending ? "Переключення..." : "Переключити мережу"}
</button>
</div>
);
}
return <>{children}</>;
}IPFS та метадані#
NFT не зберігають зображення on-chain. Блокчейн зберігає URI, що вказує на JSON-файл метаданих, який у свою чергу містить URL зображення. Стандартний патерн, визначений функцією tokenURI ERC-721:
Contract.tokenURI(42) → "ipfs://QmXyz.../42.json"
Цей JSON-файл відповідає стандартній схемі:
{
"name": "Cool NFT #42",
"description": "A very cool NFT",
"image": "ipfs://QmImageHash...",
"attributes": [
{ "trait_type": "Background", "value": "Blue" },
{ "trait_type": "Rarity", "value": "Legendary" }
]
}IPFS CID проти URL#
Адреси IPFS використовують Content Identifiers (CID) — хеші самого контенту. ipfs://QmXyz... означає «контент, чий хеш — QmXyz...». Це контент-адресоване сховище: URI виводиться з контенту, тому контент ніколи не може змінитися без зміни URI. Це гарантія незмінності, на яку покладаються NFT (коли вони дійсно використовують IPFS — багато хто використовує централізовані URL замість цього, що є тривожним сигналом).
Для відображення IPFS-контенту в браузері потрібен шлюз, що перетворює IPFS URI в HTTP:
function ipfsToHttp(uri: string): string {
if (uri.startsWith("ipfs://")) {
const cid = uri.replace("ipfs://", "");
return `https://ipfs.io/ipfs/${cid}`;
// Або використовуйте виділений шлюз:
// return `https://YOUR_PROJECT.mypinata.cloud/ipfs/${cid}`;
}
return uri;
}
// Отримання метаданих NFT
async function getNftMetadata(
contractAddress: string,
tokenId: bigint,
provider: ethers.Provider
) {
const contract = new ethers.Contract(
contractAddress,
["function tokenURI(uint256 tokenId) view returns (string)"],
provider
);
const tokenUri = await contract.tokenURI(tokenId);
const httpUri = ipfsToHttp(tokenUri);
const response = await fetch(httpUri);
const metadata = await response.json();
return {
name: metadata.name,
description: metadata.description,
image: ipfsToHttp(metadata.image),
attributes: metadata.attributes,
};
}Сервіси пінінгу#
IPFS — це peer-to-peer мережа. Контент залишається доступним, лише поки хтось його хостить («пінить»). Якщо ви завантажите зображення NFT на IPFS і потім вимкнете свою ноду, контент зникне.
Сервіси пінінгу тримають ваш контент доступним:
- Pinata: Найпопулярніший. Простий API. Щедрий безплатний рівень (1 ГБ). Виділені шлюзи для швидшого завантаження.
- NFT.Storage: Безплатний, підтримується Protocol Labs (творцями IPFS). Розроблений спеціально для метаданих NFT. Використовує Filecoin для довготривалого збереження.
- Web3.Storage: Подібний до NFT.Storage, більш загального призначення.
// Завантаження на Pinata
async function pinToIpfs(file: File): Promise<string> {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
method: "POST",
headers: {
Authorization: `Bearer ${PINATA_JWT}`,
},
body: formData,
});
const result = await response.json();
return `ipfs://${result.IpfsHash}`; // Повертає CID
}Проблема індексації#
Ось брудний секрет блокчейн-розробки: ви не можете ефективно запитувати історичні дані з RPC-ноди.
Хочете всі Transfer-події для токена за останній рік? Вам потрібно сканувати мільйони блоків з eth_getLogs, пагінуючи шматками по 2 000-10 000 блоків (максимум залежить від провайдера). Це тисячі RPC-викликів. Це займе від хвилин до годин і спалить вашу квоту API.
Хочете всі токени, якими володіє конкретна адреса? Для цього немає одного RPC-виклику. Вам потрібно було б сканувати кожну Transfer-подію для кожного ERC-20 контракту, відстежуючи баланси. Це нездійсненно.
Хочете всі NFT в гаманці? Та ж проблема. Потрібно сканувати кожну ERC-721 Transfer-подію для кожного NFT-контракту.
Блокчейн — це структура даних, оптимізована для запису. Він чудово обробляє нові транзакції. Він жахливий для відповідей на історичні запити. Це фундаментальна невідповідність між тим, що потрібно UI dApp, і тим, що ланцюг надає нативно.
Протокол The Graph#
The Graph — це децентралізований протокол індексації. Ви пишете «subgraph» — схему та набір обробників подій — і The Graph індексує ланцюг та обслуговує дані через GraphQL API.
# Схема subgraph (schema.graphql)
type Transfer @entity {
id: Bytes!
from: Bytes!
to: Bytes!
value: BigInt!
blockNumber: BigInt!
timestamp: BigInt!
}
type Account @entity {
id: Bytes!
balance: BigInt!
transfersFrom: [Transfer!]! @derivedFrom(field: "from")
transfersTo: [Transfer!]! @derivedFrom(field: "to")
}// Запит subgraph з фронтенду
const SUBGRAPH_URL =
"https://api.studio.thegraph.com/query/YOUR_ID/YOUR_SUBGRAPH/v0.0.1";
async function getRecentTransfers(address: string) {
const query = `
query GetTransfers($address: Bytes!) {
transfers(
where: { from: $address }
orderBy: blockNumber
orderDirection: desc
first: 100
) {
id
from
to
value
blockNumber
timestamp
}
}
`;
const response = await fetch(SUBGRAPH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables: { address } }),
});
const { data } = await response.json();
return data.transfers;
}Компроміс: The Graph додає затримку (зазвичай 1-2 блоки позаду голови ланцюга) та ще одну залежність. Децентралізована мережа також має витрати на індексацію (ви платите в токенах GRT). Для менших проєктів хостований сервіс (Subgraph Studio) безплатний.
Розширені API Alchemy та Moralis#
Якщо ви не хочете підтримувати subgraph, і Alchemy, і Moralis пропонують попередньо проіндексовані API, що відповідають на поширені запити напряму:
// Alchemy: Отримати всі баланси ERC-20 токенів для адреси
const response = await fetch(
`https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "alchemy_getTokenBalances",
params: ["0xAddress...", "erc20"],
}),
}
);
// Повертає ВСІ баланси ERC-20 токенів одним викликом
// проти сканування balanceOf() кожного можливого ERC-20 контракту// Alchemy: Отримати всі NFT, якими володіє адреса
const response = await fetch(
`https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY/getNFTs?owner=0xAddress...`
);
const { ownedNfts } = await response.json();
for (const nft of ownedNfts) {
console.log(`${nft.title} - ${nft.contract.address}#${nft.tokenId}`);
}Ці API є пропрієтарними та централізованими. Ви обмінюєте децентралізацію на досвід розробника. Для більшості dApp це вартісний компроміс. Вашим користувачам байдуже, чи їхній перегляд портфоліо надходить із subgraph або з бази даних Alchemy. Їх хвилює, що він завантажується за 200 мс замість 30 секунд.
Поширені підводні камені#
Після випуску кількох продакшен dApp та дебагінгу коду інших команд, ось помилки, які я бачу знову і знову. Кожна з них вкусила мене особисто.
BigInt повсюди#
Ethereum працює з дуже великими числами. Баланси ETH в wei (10^18). Поставки токенів можуть бути 10^27 або вище. JavaScript Number може безпечно представляти цілі числа лише до 2^53 - 1 (приблизно 9 * 10^15). Цього недостатньо для wei-сум.
// НЕПРАВИЛЬНО — тиха втрата точності
const balance = 1000000000000000000; // 1 ETH у wei
const double = balance * 2;
console.log(double); // 2000000000000000000 — виглядає правильно, але...
const largeBalance = 99999999999999999999; // ~100 ETH
console.log(largeBalance); // 100000000000000000000 — НЕПРАВИЛЬНО! Округлено вгору.
console.log(largeBalance === 100000000000000000000); // true — пошкодження даних
// ПРАВИЛЬНО — використовуйте BigInt
const balance = 1000000000000000000n;
const double = balance * 2n;
console.log(double.toString()); // "2000000000000000000" — правильно
const largeBalance = 99999999999999999999n;
console.log(largeBalance.toString()); // "99999999999999999999" — правильноПравила для BigInt у dApp-коді:
- Ніколи не конвертуйте wei-суми в
Number. ВикористовуйтеBigIntповсюди, конвертуйте в людиночитабельні рядки лише для відображення. - Ніколи не використовуйте
Math.floor,Math.roundтощо з BigInt. Вони не працюють. Використовуйте цілочисельний поділ:amount / 10n ** 6n. - JSON не підтримує BigInt. Якщо ви серіалізуєте стан, що включає BigInt, потрібен кастомний серіалізатор:
JSON.stringify(data, (_, v) => typeof v === "bigint" ? v.toString() : v). - Використовуйте функції форматування бібліотек.
ethers.formatEther(),ethers.formatUnits(),formatEther()таformatUnits()з viem. Вони обробляють конвертацію правильно.
import { formatUnits, parseUnits } from "viem";
// Відображення: BigInt → людиночитабельний рядок
const weiAmount = 1500000000000000000n; // 1.5 ETH
const display = formatUnits(weiAmount, 18); // "1.5"
// Введення: людиночитабельний рядок → BigInt
const userInput = "1.5";
const wei = parseUnits(userInput, 18); // 1500000000000000000n
// USDC має 6 десяткових, не 18
const usdcAmount = 100000000n; // 100 USDC
const usdcDisplay = formatUnits(usdcAmount, 6); // "100.0"Асинхронні операції з гаманцем#
Кожна взаємодія з гаманцем є асинхронною і може зазнати невдачі способами, які ваш застосунок має обробляти елегантно:
// Користувач може відхилити будь-який запит до гаманця
try {
const tx = await writeContract({
address: contractAddress,
abi: ERC20_ABI,
functionName: "approve",
args: [spenderAddress, amount],
});
} catch (error) {
if (error.code === 4001) {
// Користувач відхилив транзакцію у своєму гаманці
// Це нормально — не помилка для репортування
showToast("Транзакцію скасовано");
} else if (error.code === -32603) {
// Внутрішня помилка JSON-RPC — часто означає, що транзакція зробить revert
showToast("Транзакція зазнає невдачі. Перевірте ваш баланс.");
} else {
// Неочікувана помилка
console.error("Помилка транзакції:", error);
showToast("Щось пішло не так. Спробуйте ще раз.");
}
}Ключові асинхронні підводні камені:
- Запити до гаманця блокують на стороні користувача.
awaitу вашому коді може тривати 30 секунд, поки користувач читає деталі транзакції в MetaMask. Не показуйте спінер завантаження, що змусить їх думати, що щось зламалося. - Користувач може змінити акаунт посеред взаємодії. Ви запитуєте approval від Account A, користувач переключається на Account B, потім підтверджує. Тепер Account B підтвердив, але ви збираєтесь надіслати транзакцію від Account A. Завжди перевіряйте підключений акаунт перед критичними операціями.
- Двокрокові патерни запису поширені. Багато DeFi-операцій потребують
approve+execute. Користувач повинен підписати дві транзакції. Якщо він підтверджує, але не виконує, вам потрібно перевірити стан allowance і пропустити крок approval наступного разу.
Помилки невідповідності мережі#
Цей пункт марнує більше часу дебагінгу, ніж будь-яка інша проблема. Ваш контракт на Mainnet. Ваш гаманець на Sepolia. Ваш RPC-провайдер вказує на Polygon. Три різні мережі, три різні стани, три абсолютно не пов'язані блокчейни. І повідомлення про помилку зазвичай некорисне — «execution reverted» або «contract not found».
// Захисна перевірка ланцюга
import { useAccount, useChainId } from "wagmi";
function useRequireChain(requiredChainId: number) {
const chainId = useChainId();
const { isConnected } = useAccount();
if (!isConnected) {
return { ready: false, error: "Будь ласка, підключіть ваш гаманець" };
}
if (chainId !== requiredChainId) {
return {
ready: false,
error: `Будь ласка, переключіться на ${getChainName(requiredChainId)}. Ви на ${getChainName(chainId)}.`,
};
}
return { ready: true, error: null };
}Фронт-ранінг у DeFi#
Коли ви подаєте своп на DEX, ваша очікуюча транзакція видима в mempool. Бот може побачити вашу операцію, фронт-ранити її, піднявши ціну, дозволити вашій операції виконатися за гіршою ціною, а потім негайно продати з прибутком. Це називається «сендвіч-атака».
Як фронтенд-розробник, ви не можете повністю це запобігти, але можете пом'якшити:
// Встановлення толерантності прослизання для свопу в стилі Uniswap
const amountOutMin = expectedOutput * 995n / 1000n; // 0,5% толерантності прослизання
// Використання дедлайну для запобігання довготривалим очікуючим транзакціям
const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 20); // 20 хвилин
await router.swapExactTokensForTokens(
amountIn,
amountOutMin, // Мінімально прийнятний вихід — revert, якщо отримаємо менше
[tokenA, tokenB],
userAddress,
deadline, // Revert, якщо не виконано протягом 20 хвилин
);Для транзакцій великої вартості розгляньте використання Flashbots Protect RPC, який надсилає транзакції безпосередньо до будівельників блоків замість публічного mempool. Це повністю запобігає сендвіч-атакам, тому що боти ніколи не бачать вашу очікуючу транзакцію:
// Використання Flashbots Protect як RPC-ендпоінту
const provider = new ethers.JsonRpcProvider("https://rpc.flashbots.net");Плутанина з десятковими#
Не всі токени мають 18 десяткових. USDC та USDT мають 6. WBTC має 8. Деякі токени мають 0, 2 або довільну кількість десяткових. Завжди читайте decimals() з контракту перед форматуванням сум:
async function formatTokenAmount(
tokenAddress: string,
rawAmount: bigint,
provider: ethers.Provider
): Promise<string> {
const contract = new ethers.Contract(
tokenAddress,
["function decimals() view returns (uint8)", "function symbol() view returns (string)"],
provider
);
const [decimals, symbol] = await Promise.all([
contract.decimals(),
contract.symbol(),
]);
return `${ethers.formatUnits(rawAmount, decimals)} ${symbol}`;
}
// formatTokenAmount(USDC, 1000000n, provider) → "1.0 USDC"
// formatTokenAmount(WETH, 1000000000000000000n, provider) → "1.0 WETH"
// formatTokenAmount(WBTC, 100000000n, provider) → "1.0 WBTC"Невдачі оцінки Gas#
Коли estimateGas зазнає невдачі, це зазвичай означає, що транзакція зробить revert. Але повідомлення про помилку часто просто «cannot estimate gas» без вказівки чому. Використовуйте eth_call для симуляції транзакції та отримання фактичної причини revert:
import { createPublicClient, http, decodeFunctionResult } from "viem";
async function simulateAndGetError(client: ReturnType<typeof createPublicClient>, tx: object) {
try {
await client.call({
account: tx.from,
to: tx.to,
data: tx.data,
value: tx.value,
});
return null; // Немає помилки — транзакція буде успішною
} catch (error) {
// Декодувати причину revert
if (error.data) {
// Поширені рядки revert
if (error.data.startsWith("0x08c379a0")) {
// Error(string) — стандартний revert з повідомленням
const reason = decodeAbiParameters(
[{ type: "string" }],
`0x${error.data.slice(10)}`
);
return `Revert: ${reason[0]}`;
}
}
return error.message;
}
}Збираємо все разом#
Ось повний мінімальний React-компонент, що підключає гаманець, читає баланс токену та надсилає переказ. Це скелет кожного dApp:
"use client";
import { useAccount, useConnect, useDisconnect, useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { injected } from "wagmi/connectors";
import { formatUnits, parseUnits } from "viem";
import { useState } from "react";
const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const USDC_ABI = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
{
name: "transfer",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "", type: "bool" }],
},
] as const;
export function TokenDashboard() {
const { address, isConnected } = useAccount();
const { connect } = useConnect();
const { disconnect } = useDisconnect();
const [recipient, setRecipient] = useState("");
const [amount, setAmount] = useState("");
// Читання балансу — виконується лише коли address визначено
const { data: balance, refetch: refetchBalance } = useReadContract({
address: USDC_ADDRESS,
abi: USDC_ABI,
functionName: "balanceOf",
args: address ? [address] : undefined,
query: { enabled: !!address },
});
// Запис: переказ токенів
const {
writeContract,
data: txHash,
isPending: isSigning,
error: writeError,
} = useWriteContract();
// Очікування підтвердження
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
});
// Оновити баланс після підтвердження
if (isSuccess) {
refetchBalance();
}
if (!isConnected) {
return (
<button onClick={() => connect({ connector: injected() })}>
Підключити гаманець
</button>
);
}
return (
<div>
<p>Підключено: {address}</p>
<p>
Баланс USDC:{" "}
{balance !== undefined ? formatUnits(balance, 6) : "Завантаження..."}
</p>
<div>
<input
placeholder="Адреса отримувача (0x...)"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
<input
placeholder="Сума (напр. 100)"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<button
onClick={() => {
writeContract({
address: USDC_ADDRESS,
abi: USDC_ABI,
functionName: "transfer",
args: [recipient as `0x${string}`, parseUnits(amount, 6)],
});
}}
disabled={isSigning || isConfirming}
>
{isSigning
? "Підтвердіть у гаманці..."
: isConfirming
? "Підтвердження..."
: "Надіслати USDC"}
</button>
</div>
{writeError && <p style={{ color: "red" }}>{writeError.message}</p>}
{isSuccess && <p style={{ color: "green" }}>Переказ підтверджено!</p>}
{txHash && (
<a
href={`https://etherscan.io/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
>
Переглянути на Etherscan
</a>
)}
<button onClick={() => disconnect()}>Відключити</button>
</div>
);
}Куди рухатися далі#
Ця стаття покрила основні концепції та інструментарій для веб-розробників, що заходять в Ethereum. У кожній області є набагато більше глибини:
- Solidity: Якщо ви хочете писати контракти, а не просто взаємодіяти з ними. Офіційна документація та курси Patrick Collins — найкращі відправні точки.
- ERC стандарти: ERC-20 (взаємозамінні токени), ERC-721 (NFT), ERC-1155 (мульти-токен), ERC-4626 (токенізовані сховища). Кожен визначає стандартний інтерфейс, який реалізують усі контракти в цій категорії.
- Layer 2: Arbitrum, Optimism, Base, zkSync. Той самий досвід розробника, нижчі витрати на gas, дещо інші припущення щодо довіри. Ваш код ethers.js та viem працює ідентично — просто змініть chain ID та RPC URL.
- Account Abstraction (ERC-4337): Наступна еволюція UX гаманця. Гаманці на смартконтрактах, що підтримують спонсорування gas, соціальне відновлення та батчеві транзакції. Це те, куди рухається патерн «підключити гаманець».
- MEV та порядок транзакцій: Якщо ви будуєте DeFi, розуміння Maximal Extractable Value не є опціональним. Документація Flashbots — канонічний ресурс.
Блокчейн-екосистема рухається швидко, але фундаментальні принципи в цій статті — акаунти, транзакції, ABI encoding, RPC-виклики, індексація подій — не змінювалися з 2015 року і не зміняться найближчим часом. Вивчіть їх добре, і все інше — лише API-поверхня.