Перейти к содержимому
·29 мин чтения

Ethereum для веб-разработчиков: смарт-контракты без хайпа

Концепции Ethereum, которые нужны каждому веб-разработчику: аккаунты, транзакции, смарт-контракты, ABI-кодирование, ethers.js, WAGMI и чтение данных из блокчейна без собственной ноды.

Поделиться:X / TwitterLinkedIn

Большинство материалов «Ethereum для разработчиков» делятся на две категории: упрощённые аналогии, которые не помогают ничего построить, или глубокие спецификации протокола, которые предполагают, что вы уже знаете, что такое Merkle Patricia Trie. Ни то, ни другое не полезно, если вы веб-разработчик, который хочет прочитать баланс токена, позволить пользователю подписать транзакцию или отобразить метаданные NFT в React-приложении.

Этот пост — практическая золотая середина. Я объясню, что именно происходит, когда ваш фронтенд общается с Ethereum, какие есть движущие части и как современный инструментарий (ethers.js, viem, WAGMI) соотносится с концепциями, которые вы уже знаете по разработке веб-приложений.

Никаких метафор про вендинговые автоматы. Никаких «представьте мир, где...». Только техническая модель и код.

Ментальная модель#

Ethereum — это реплицируемая машина состояний. Каждая нода в сети поддерживает идентичную копию состояния — огромное хранилище ключ-значение, которое связывает адреса с данными аккаунтов. Когда вы «отправляете транзакцию», вы предлагаете переход состояния. Если достаточно валидаторов согласятся, что он валиден, состояние обновляется. Вот и всё.

Само состояние простое. Это отображение 20-байтовых адресов в объекты аккаунтов. У каждого аккаунта четыре поля:

  • nonce: Сколько транзакций этот аккаунт отправил (для EOA) или сколько контрактов создал (для контрактных аккаунтов). Это предотвращает атаки повторного воспроизведения.
  • balance: Количество ETH в wei (1 ETH = 10^18 wei). Всегда большое целое число.
  • codeHash: Хеш байткода EVM. Для обычных кошельков (EOA) это хеш пустых байтов. Для контрактов — хеш задеплоенного кода.
  • storageRoot: Корневой хеш дерева хранения аккаунта. Только контракты имеют значимое хранение.

Есть два типа аккаунтов, и различие важно для всего, что будет дальше:

Внешне управляемые аккаунты (EOA) контролируются приватным ключом. Это то, чем управляет MetaMask. Они могут инициировать транзакции. У них нет кода. Когда кто-то говорит «кошелёк», он имеет в виду EOA.

Контрактные аккаунты контролируются своим кодом. Они не могут инициировать транзакции — могут только выполняться в ответ на вызов. У них есть код и хранение. Когда кто-то говорит «смарт-контракт», он имеет в виду это. Код неизменяем после деплоя (с некоторыми исключениями через proxy-паттерны, что отдельная большая тема).

Критический инсайт: каждое изменение состояния в Ethereum начинается с EOA, подписывающего транзакцию. Контракты могут вызывать другие контракты, но цепочка выполнения всегда начинается с человека (или бота) с приватным ключом.

Gas: вычисления имеют цену#

Каждая операция в EVM стоит gas. Сложение двух чисел стоит 3 gas. Сохранение 32-байтового слова стоит 20 000 gas (первый раз) или 5 000 gas (обновление). Чтение хранилища стоит 2 100 gas (холодное) или 100 gas (тёплое, уже обращались в этой транзакции).

Вы не платите gas в «единицах газа». Вы платите в ETH. Итоговая стоимость:

totalCost = gasUsed * gasPrice

После EIP-1559 (обновление London) ценообразование gas стало двухкомпонентным:

totalCost = gasUsed * (baseFee + priorityFee)
  • baseFee: Устанавливается протоколом на основе загруженности сети. Сжигается (уничтожается).
  • priorityFee (чаевые): Идёт валидатору. Больше чаевых = быстрее включение.
  • maxFeePerGas: Максимум, который вы готовы платить за единицу gas.
  • maxPriorityFeePerGas: Максимальные чаевые за единицу gas.

Если baseFee + priorityFee > maxFeePerGas, ваша транзакция ждёт, пока baseFee не снизится. Вот почему транзакции «застревают» при высокой загрузке.

Практический вывод для веб-разработчиков: чтение данных бесплатно. Запись данных стоит денег. Это единственное самое важное архитектурное различие между Web2 и Web3. Каждый SELECT бесплатен. Каждый INSERT, UPDATE, DELETE стоит реальных денег. Проектируйте ваши dApp'ы соответственно.

Транзакции#

Транзакция — это подписанная структура данных. Вот поля, которые имеют значение:

typescript
interface Transaction {
  // Кто получает эту транзакцию — адрес EOA или адрес контракта
  to: string;      // 20-байтовый hex-адрес, или null для деплоя контракта
  // Сколько ETH отправить (в wei)
  value: bigint;   // Может быть 0n для чистых вызовов контракта
  // Закодированные данные вызова функции, или пусто для простых переводов ETH
  data: string;    // Hex-кодированные байты, "0x" для простых переводов
  // Последовательный счётчик, предотвращает атаки повторного воспроизведения
  nonce: number;   // Должен точно совпадать с текущим nonce отправителя
  // Лимит газа — максимальный gas, который может потребить эта транзакция
  gasLimit: bigint;
  // Параметры комиссий EIP-1559
  maxFeePerGas: bigint;
  maxPriorityFeePerGas: bigint;
  // Идентификатор цепочки (1 = mainnet, 11155111 = Sepolia, 137 = Polygon)
  chainId: number;
}

Жизненный цикл транзакции#

  1. Создание: Ваше приложение строит объект транзакции. Если вы вызываете функцию контракта, поле data содержит ABI-кодированный вызов функции (подробнее ниже).

  2. Подпись: Приватный ключ подписывает RLP-кодированную транзакцию, создавая компоненты подписи v, r, s. Это доказывает, что отправитель авторизовал именно эту транзакцию. Адрес отправителя выводится из подписи — он не указан явно в транзакции.

  3. Трансляция: Подписанная транзакция отправляется RPC-ноде через eth_sendRawTransaction. Нода валидирует её (правильный nonce, достаточный баланс, валидная подпись) и добавляет в свой mempool.

  4. Mempool: Транзакция находится в пуле ожидающих транзакций. Валидаторы выбирают транзакции для включения в следующий блок, как правило предпочитая более высокие чаевые. Именно здесь происходит фронтранинг — другие участники могут видеть вашу ожидающую транзакцию и отправить свою с более высокими чаевыми, чтобы выполниться раньше вас.

  5. Включение: Валидатор включает вашу транзакцию в блок. EVM выполняет её. Если успешно — изменения состояния применяются. Если откатывается — изменения состояния отменяются, но вы всё равно платите за gas, потреблённый до точки отката.

  6. Финальность: На proof-of-stake Ethereum блок становится «финализированным» после двух эпох (~12,8 минут). До финальности реорганизации цепочки теоретически возможны (хотя редки). Большинство приложений считают 1–2 подтверждения блока «достаточными» для некритичных операций.

Вот как выглядит отправка простого перевода ETH с ethers.js v6:

typescript
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);
 
// Ожидаем включения в блок
const receipt = await tx.wait();
console.log("Номер блока:", receipt.blockNumber);
console.log("Потрачено gas:", receipt.gasUsed.toString());
console.log("Статус:", receipt.status); // 1 = успех, 0 = откат

И то же самое с viem:

typescript
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("Хеш транзакции:", hash);

Обратите внимание на разницу: ethers возвращает объект TransactionResponse с методом .wait(). Viem возвращает только хеш — вы используете отдельный вызов publicClient.waitForTransactionReceipt({ hash }) для ожидания подтверждения. Это разделение ответственности — намеренное в дизайне viem.

Смарт-контракты#

Смарт-контракт — это задеплоенный байткод плюс постоянное хранилище по определённому адресу. Когда вы «вызываете» контракт, вы отправляете транзакцию (или делаете read-only вызов) с полем data, установленным в кодированный вызов функции.

Байткод и ABI#

Байткод — это скомпилированный код EVM. Вы не взаимодействуете с ним напрямую. Это то, что EVM выполняет.

ABI (Application Binary Interface) — это JSON-описание интерфейса контракта. Оно говорит вашей клиентской библиотеке, как кодировать вызовы функций и декодировать возвращаемые значения. Считайте его OpenAPI-спецификацией для контракта.

Вот фрагмент ABI ERC-20 токена:

typescript
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 принимает этот формат «human-readable ABI». Viem тоже может его использовать, но часто вы будете работать с полным JSON ABI, сгенерированным компилятором Solidity. Оба эквивалентны — human-readable формат просто удобнее для стандартных интерфейсов.

Как кодируются вызовы функций#

Это часть, которую большинство туториалов пропускает, и это то, что сэкономит вам часы отладки.

Когда вы вызываете transfer("0xBob...", 1000000), поле data транзакции устанавливается в:

0xa9059cbb                                                         // Селектор функции
0000000000000000000000000xBob...000000000000000000000000             // address, дополнен до 32 байт
00000000000000000000000000000000000000000000000000000000000f4240     // uint256 сумма (1000000 в hex)

Селектор функции — это первые 4 байта Keccak-256 хеша сигнатуры функции:

keccak256("transfer(address,uint256)") = 0xa9059cbb...
selector = первые 4 байта = 0xa9059cbb

Оставшиеся байты — ABI-кодированные аргументы, каждый дополнен до 32 байт. Эта схема кодирования детерминистична — один и тот же вызов функции всегда производит одинаковые calldata.

Почему это важно? Потому что когда вы видите сырые данные транзакции на Etherscan, начинающиеся с 0xa9059cbb, вы знаете, что это вызов transfer. Когда ваша транзакция откатывается и сообщение об ошибке — просто hex-blob, вы можете декодировать его, используя ABI. И когда вы собираете пакеты транзакций или взаимодействуете с multicall-контрактами, вам придётся кодировать calldata вручную.

Вот как кодировать и декодировать вручную с ethers.js:

typescript
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 и так далее. Для отображений (mappings) и динамических массивов используется схема на основе хешей.

Вы можете прочитать хранилище любого контракта напрямую, даже если переменная помечена как private в Solidity. «Private» означает только то, что другие контракты не могут её прочитать — любой может прочитать через eth_getStorageAt:

typescript
// Чтение слота хранения 0 контракта
const slot0 = await provider.getStorage(
  "0xContractAddress...",
  0
);
console.log(slot0); // Сырое 32-байтовое hex-значение

Именно так обозреватели блоков показывают «внутреннее» состояние контракта. Нет контроля доступа на чтение хранилища. Приватность на публичном блокчейне фундаментально ограничена.

События и логи#

События — это способ контракта эмитировать структурированные данные, которые сохраняются в логах транзакций, но не в хранилище контракта. Они дешевле записи в хранилище (375 gas за первый топик + 8 gas за байт данных, против 20 000 gas за запись в хранилище) и спроектированы для эффективного запроса.

Событие может иметь до 3 indexed параметров (сохраняются как «топики») и любое количество неиндексированных параметров (сохраняются как «data»). Индексированные параметры можно фильтровать — вы можете запросить «дайте мне все события Transfer, где to — этот адрес». Неиндексированные параметры нельзя фильтровать; нужно получить все подходящие события и фильтровать на клиенте.

typescript
// Подписка на события 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("Блок:", event.log.blockNumber);
  console.log("Хеш транзакции:", 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("От:", event.args.from);
  console.log("Сумма:", event.args.value.toString());
}

То же самое с viem:

typescript
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("От:", log.args.from);
  console.log("Кому:", log.args.to);
  console.log("Сумма:", 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(`Перевод: ${log.args.from} -> ${log.args.to}`);
    }
  },
});
 
// Вызовите unwatch() для остановки

Чтение данных из блокчейна#

Здесь Ethereum становится практичным для веб-разработчиков. Вам не нужно запускать ноду. Не нужно майнить. Даже кошелёк не нужен. Чтение данных из Ethereum бесплатно, не требует разрешений и работает через простой JSON-RPC API.

JSON-RPC: HTTP API Ethereum#

Каждая нода Ethereum предоставляет JSON-RPC API. Это буквально HTTP POST с JSON-телом. В транспортном уровне нет ничего специфичного для блокчейна.

typescript
// Вот что ваша библиотека делает под капотом
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 реален и случится в самый неподходящий момент.

typescript
import { ethers } from "ethers";
 
// Fallback-провайдер 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-провайдера.

typescript
import { ethers } from "ethers";
 
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
 
// Интерфейс ERC-20 — только функции чтения
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}`);          // 6 (НЕ 18!)
console.log(`Общее предложение: ${ethers.formatUnits(totalSupply, decimals)}`);
 
// Проверяем баланс конкретного адреса
const balance = await erc20.balanceOf("0xSomeAddress...");
console.log(`Баланс: ${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: Соединение с блокчейном только для чтения. Может вызывать view-функции, получать блоки, читать логи. Не может подписывать или отправлять транзакции.

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

typescript
// Из приватного ключа (серверная сторона, скрипты)
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.

typescript
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("Транзакция отменена");
}

TypeChain для типобезопасности#

Сырые ABI-взаимодействия строково-типизированы. Можно опечататься в имени функции, передать неправильные типы аргументов или неверно интерпретировать возвращаемые значения. TypeChain генерирует TypeScript-типы из ваших ABI-файлов:

typescript
// Без 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-провайдеры для этого:

typescript
// 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(`Перевод: ${from} -> ${to}`);
  console.log(`Сумма: ${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-хуки, которые управляют сложным async-состоянием взаимодействий с кошельком.

Настройка#

typescript
// 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"),
  },
});
typescript
// 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, рефетчингом и состояниями загрузки/ошибки:

typescript
"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-утверждений.

Запись в контракты#

useWriteContract управляет полным жизненным циклом: запрос к кошельку, подпись, трансляция и отслеживание подтверждения.

typescript
"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-подписку для мониторинга событий в реальном времени:

typescript
"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:

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

typescript
// Низкоуровневое взаимодействие с кошельком (вы не должны делать это напрямую, но полезно понимать)
 
// Запрос доступа к аккаунту
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 решает это протоколом обнаружения на основе событий браузера:

typescript
// Обнаружение всех доступных кошельков
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'ами через релейный сервер. Пользователь сканирует QR-код своим мобильным кошельком, устанавливая зашифрованное соединение. Запросы транзакций передаются от вашего dApp на телефон.

С WAGMI это просто ещё один коннектор:

typescript
import { walletConnect } from "wagmi/connectors";
 
const connector = walletConnect({
  projectId: "YOUR_PROJECT_ID", // Получите на cloud.walletconnect.com
  showQrModal: true,
});

Обработка переключения цепочки#

Пользователи часто находятся в неправильной сети. Ваш dApp на Mainnet, а они подключены к Sepolia. Или они на Polygon, а вам нужен Mainnet. WAGMI предоставляет useSwitchChain:

typescript
"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 не хранят изображения в блокчейне. Блокчейн хранит URI, указывающий на JSON-файл метаданных, который, в свою очередь, содержит URL изображения. Стандартный паттерн, определённый функцией tokenURI ERC-721:

Contract.tokenURI(42) → "ipfs://QmXyz.../42.json"

Этот JSON-файл следует стандартной схеме:

json
{
  "name": "Cool NFT #42",
  "description": "Очень крутой 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:

typescript
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 — это пиринговая сеть. Контент остаётся доступным, только пока кто-то его хостит («пиннит»). Если вы загрузите изображение NFT в IPFS и потом выключите свою ноду, контент пропадёт.

Сервисы пиннинга поддерживают доступность вашего контента:

  • Pinata: Самый популярный. Простой API. Щедрый бесплатный тариф (1 ГБ). Выделенные шлюзы для быстрой загрузки.
  • NFT.Storage: Бесплатный, поддерживается Protocol Labs (создатели IPFS). Спроектирован специально для метаданных NFT. Использует Filecoin для долгосрочного хранения.
  • Web3.Storage: Похож на NFT.Storage, более универсальный.
typescript
// Загрузка в 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 в кошельке? Та же проблема. Нужно сканировать каждое событие Transfer ERC-721 по всем NFT-контрактам.

Блокчейн — это оптимизированная для записи структура данных. Отлично обрабатывает новые транзакции. Ужасно отвечает на исторические запросы. Это фундаментальное несоответствие между тем, что нужно UI dApp'ов, и тем, что цепочка предоставляет нативно.

Протокол The Graph#

The Graph — это децентрализованный протокол индексации. Вы пишете «субграф» — схему и набор обработчиков событий — и The Graph индексирует цепочку и предоставляет данные через GraphQL API.

graphql
# Схема субграфа (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")
}
typescript
// Запрос к субграфу из фронтенда
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#

Если вы не хотите поддерживать субграф, и Alchemy, и Moralis предлагают прединдексированные API, которые отвечают на типичные запросы напрямую:

typescript
// 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 токенов в одном вызове
// vs. сканирование balanceOf() каждого возможного ERC-20 контракта
typescript
// 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'ов это оправданный компромисс. Ваши пользователи не заботятся, приходит ли их портфолио из субграфа или из базы данных Alchemy. Их заботит, чтобы оно загрузилось за 200 мс, а не за 30 секунд.

Типичные ошибки#

После поставки нескольких продакшен dApp'ов и отладки кода других команд, вот ошибки, которые я вижу постоянно. Каждая из них укусила меня лично.

BigInt повсюду#

Ethereum оперирует очень большими числами. Балансы ETH в wei (10^18). Общее предложение токенов может быть 10^27 и выше. JavaScript Number может безопасно представлять целые числа только до 2^53 - 1 (примерно 9 * 10^15). Этого недостаточно для сумм в wei.

typescript
// НЕПРАВИЛЬНО — тихая потеря точности
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:

  1. Никогда не конвертируйте суммы в wei в Number. Используйте BigInt повсюду, конвертируйте в читаемые строки только для отображения.
  2. Никогда не используйте Math.floor, Math.round и т.д. для BigInt. Они не работают. Используйте целочисленное деление: amount / 10n ** 6n.
  3. JSON не поддерживает BigInt. Если вы сериализуете состояние с BigInt, нужен пользовательский сериализатор: JSON.stringify(data, (_, v) => typeof v === "bigint" ? v.toString() : v).
  4. Используйте функции форматирования библиотек. ethers.formatEther(), ethers.formatUnits(), formatEther() и formatUnits() из viem. Они правильно обрабатывают конвертацию.
typescript
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"

Асинхронные операции с кошельком#

Каждое взаимодействие с кошельком асинхронно и может завершиться ошибкой, которую приложение должно обработать корректно:

typescript
// Пользователь может отклонить любой запрос к кошельку
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 — часто означает, что транзакция откатилась бы
    showToast("Транзакция не выполнится. Проверьте ваш баланс.");
  } else {
    // Неожиданная ошибка
    console.error("Ошибка транзакции:", error);
    showToast("Что-то пошло не так. Попробуйте ещё раз.");
  }
}

Ключевые подводные камни async:

  • Запросы к кошельку блокируют на стороне пользователя. await в вашем коде может занять 30 секунд, пока пользователь читает детали транзакции в MetaMask. Не показывайте спиннер загрузки, который заставит думать, что что-то сломалось.
  • Пользователь может переключить аккаунт в процессе. Вы запрашиваете одобрение от Аккаунта A, пользователь переключается на Аккаунт B, затем одобряет. Теперь Аккаунт B одобрил, но вы собираетесь отправить транзакцию от Аккаунта A. Всегда перепроверяйте подключённый аккаунт перед критическими операциями.
  • Двухшаговые паттерны записи распространены. Многие DeFi-операции требуют approve + execute. Пользователю нужно подписать две транзакции. Если он одобрит, но не выполнит, нужно проверить состояние allowance и пропустить шаг одобрения в следующий раз.

Ошибки несоответствия сетей#

Эта проблема тратит больше времени на отладку, чем любая другая. Ваш контракт на Mainnet. Ваш кошелёк на Sepolia. Ваш RPC-провайдер указывает на Polygon. Три разных сети, три разных состояния, три совершенно не связанных блокчейна. И сообщение об ошибке обычно бесполезное — «execution reverted» или «contract not found».

typescript
// Защитная проверка цепочки
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, ваша ожидающая транзакция видна в мемпуле. Бот может увидеть вашу сделку, опередить её, подтолкнув цену вверх, позволить вашей сделке выполниться по худшей цене, а затем продать сразу после для прибыли. Это называется «сэндвич-атака».

Как фронтенд-разработчик, вы не можете полностью предотвратить это, но можете смягчить:

typescript
// Установка допуска проскальзывания для свопа в стиле Uniswap
const amountOutMin = expectedOutput * 995n / 1000n; // 0.5% допуск проскальзывания
 
// Использование дедлайна для предотвращения долгоживущих ожидающих транзакций
const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 20); // 20 минут
 
await router.swapExactTokensForTokens(
  amountIn,
  amountOutMin,  // Минимально допустимый выход — откат, если получим меньше
  [tokenA, tokenB],
  userAddress,
  deadline,       // Откат, если не выполнено в течение 20 минут
);

Для высокоценных транзакций рассмотрите использование Flashbots Protect RPC, который отправляет транзакции напрямую блок-билдерам вместо публичного мемпула. Это полностью предотвращает сэндвич-атаки, потому что боты никогда не видят вашу ожидающую транзакцию:

typescript
// Использование Flashbots Protect как RPC-эндпоинта
const provider = new ethers.JsonRpcProvider("https://rpc.flashbots.net");

Путаница с десятичными#

Не все токены имеют 18 десятичных. USDC и USDT имеют 6. WBTC имеет 8. Некоторые токены имеют 0, 2 или произвольное количество. Всегда читайте decimals() из контракта перед форматированием сумм:

typescript
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"

Ошибки оценки газа#

Когда estimateGas проваливается, это обычно означает, что транзакция откатилась бы. Но сообщение об ошибке часто просто «cannot estimate gas» без указания причины. Используйте eth_call для симуляции транзакции и получения реальной причины отката:

typescript
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) {
    // Декодируем причину отката
    if (error.data) {
      // Типичные строки отката
      if (error.data.startsWith("0x08c379a0")) {
        // Error(string) — стандартный откат с сообщением
        const reason = decodeAbiParameters(
          [{ type: "string" }],
          `0x${error.data.slice(10)}`
        );
        return `Откат: ${reason[0]}`;
      }
    }
    return error.message;
  }
}

Собираем всё вместе#

Вот полный, минимальный React-компонент, который подключает кошелёк, читает баланс токена и отправляет перевод. Это скелет каждого dApp'а:

typescript
"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. Тот же опыт разработки, ниже стоимость газа, немного другие допущения доверия. Ваш код на ethers.js и viem работает идентично — просто измените chain ID и URL RPC.
  • Абстракция аккаунтов (ERC-4337): Следующая эволюция UX кошельков. Кошельки на смарт-контрактах, поддерживающие спонсирование газа, социальное восстановление и батчинг транзакций. Именно туда движется паттерн «подключить кошелёк».
  • MEV и порядок транзакций: Если вы строите DeFi, понимание Maximal Extractable Value обязательно. Документация Flashbots — каноничный ресурс.

Экосистема блокчейна развивается быстро, но фундаментальные концепции в этом посте — аккаунты, транзакции, ABI-кодирование, RPC-вызовы, индексация событий — не менялись с 2015 года и не изменятся в ближайшее время. Изучите их хорошо, и всё остальное — просто поверхность API.

Похожие записи