Web3 и блокчейн Архитектура dApp: фронтенд, индексация, ноды, RPC, офчейн-компоненты
0%

Архитектура dApp: фронтенд, индексация, ноды, RPC, офчейн-компоненты

Архитектура dApp: фронтенд, индексация, ноды, RPC, офчейн-компоненты

Контракт задеплоен, тесты зелёные, адрес в блокчейн-эксплорере. Дальше выясняется неприятное: пользоваться этим невозможно. Список позиций не собирается — в цепи нет запроса «все токены владельца». История операций не показывается — состояние хранит только текущий срез. Кнопка «Вывести» падает у половины пользователей, потому что фронтенд не спросил, в какой они сети. Страница показывает баланс, которого уже нет, потому что RPC-провайдер ответил со сторонней реплики.

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

Что останется работать, если вся ваша инфраструктура исчезнет?

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

Пять слоёв и одна граница доверия

Слои архитектуры dApp: клиент, слой доступа, офчейн-компоненты, цепь и граница доверия между ними

Схема выше — каркас всей статьи. Пунктирная линия отделяет то, что защищено консенсусом, от того, что защищено только вашей операционной дисциплиной: выше линии всё можно подменить, отключить, зацензурировать или взломать через DNS; ниже — нельзя, потому что каждая полная нода мира перепроверит переход состояния. Отсюда единственное архитектурное правило, из которого выводится всё остальное:

Офчейн-компонент имеет право только предлагать действие. Проверять и разрешать его обязан контракт.

Каждый раз, когда сервис получает возможность сделать что-то, чего пользователь не может сделать сам напрямую через публичный узел, — граница сдвинулась вверх, и вместе с ней сдвинулись все гарантии. Иногда это осознанный выбор. Плохо, когда это происходит незаметно.

Цепь как база данных: чего в ней нет

Прежде чем строить, полезно честно перечислить, чего блокчейн не умеет. Мы разбирали модель состояния в Ethereum и EVM; здесь важны практические следствия.

Привычная операция Что в блокчейне Как жить
SELECT ... WHERE owner = ? нет: состояние — key-value поверх дерева Меркла–Патриции индексатор строит таблицу
ORDER BY, LIMIT, пагинация нет: контракт не умеет сортировать без квадратичного перебора индексатор
JOIN двух контрактов нет: даже атомарный вызов не даёт выборки индексатор
COUNT, SUM, агрегаты только если контракт заранее считал их в storage — а это дорого индексатор
История изменений состояние хранит только «сейчас»; прошлое — в логах и архивных нодах логи + индексатор
Триггер, push-уведомление контракт не может ничего инициировать: он только реагирует на вызов кипер, подписка на события
Полнотекстовый поиск нет и близко индексатор
Планировщик «выполнить в 9:00» у EVM нет времени выполнения без транзакции кипер
Приватность всё публично, включая мемпул до включения в блок шифрование до записи

Правая колонка объясняет, почему индексатор — не опциональный компонент, а обязательный: без него у вас есть банковский реестр, в котором можно узнать баланс по номеру счёта и больше ничего. И ещё одна ловушка масштаба: eth_call бесплатен по газу, но не по времени — это реальное исполнение байткода поверх состояния. Страница со списком из 200 позиций по три геттера на каждую — это 600 запросов, и никакой провайдер не скажет вам за это спасибо.

Слой RPC: то, через что вы вообще видите цепь

Клиент никогда не разговаривает с «блокчейном». Он разговаривает с конкретным процессом geth, erigon, nethermind или reth по JSON-RPC 2.0 — обычный HTTP POST с телом {"jsonrpc":"2.0","id":1,"method":...,"params":[...]}. Всё, что вы видите на экране, — это ответ одного конкретного узла, которому вы решили поверить.

# Высота цепи — ответ в hex, не в десятичном
curl -s https://rpc.example.org -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}'   # -> "0x14f2a3b"

# Баланс ERC-20: balanceOf(address) = селектор 0x70a08231 + адрес, дополненный до 32 байт
curl -s https://rpc.example.org -H 'content-type: application/json' -d '{
  "jsonrpc":"2.0","id":2,"method":"eth_call","params":[{
    "to":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
    "data":"0x70a08231000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045"
  },"latest"]}'

Ключевое, что стоит усвоить с первого дня: eth_call ничего не меняет и ничего не стоит, а eth_sendRawTransaction меняет всё и стоит газа. Это два разных мира, и путать их в архитектуре нельзя.

Минимальный набор методов, который придётся знать наизусть

Метод Зачем Подводный камень
eth_chainId проверить, в той ли вы сети обязателен при каждом старте UI
eth_call прочитать состояние, симулировать вызов ответ зависит от тега блока
eth_estimateGas оценить лимит газа оценивает на текущем состоянии; к моменту включения оно другое
eth_getLogs вытащить события у провайдеров жёсткие лимиты по диапазону и числу результатов
eth_getTransactionReceipt узнать, что стало с транзакцией null не значит «провалилась», значит «ещё не в блоке»
eth_sendRawTransaction отправить подписанные байты возвращает хеш, а не результат исполнения
eth_feeHistory построить разумные maxFeePerGas без него UI обречён на «transaction underpriced»
eth_getBlockByNumber заголовок, timestamp, baseFee тег блока снова решает всё
eth_subscribe newHeads, logs по WebSocket соединение молча умирает, нужен heartbeat
debug_traceTransaction разобрать, почему упало есть не у всех провайдеров и дорог

Теги блока: latest, safe, finalized — и почему это не косметика

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

Тег Что это Задержка Может исчезнуть
pending локальное представление узла о ещё не собранном блоке 0 да, легко
latest последний блок, который узел считает головой ~12 с да
safe последний justified чекпоинт ~1–2 эпохи, около 6 минут крайне маловероятно
finalized финализированный чекпоинт ~2 эпохи, около 13 минут только при потере трети стейка
earliest генезис нет

Практическое правило, за нарушение которого платят реальными деньгами:

  • UI-обновленияlatest. Пользователь хочет видеть результат сразу, а редкий откат на один блок он переживёт.
  • Деньги наружу (кредитование в фиате, отгрузка товара, начисление у вас в системе) — только finalized. Между latest и finalized лежат ~13 минут, в течение которых транзакция теоретически может исчезнуть из канона.
  • Курсоры индексатора — держите две отметки: «обработано до» на latest и «окончательно» на finalized. Всё между ними обязано быть откатываемым.

Отдельно про pending: его семантика не стандартизована и различается между клиентами. Строить на нём логику — гарантированные плавающие баги.

Чтение и запись: два принципиально разных пути

Обратите внимание на две вещи. Первая: хеш транзакции не является подтверждением успеха — она может провалиться в блоке со status: 0, и газ при этом спишется. Фронтенд обязан читать receipt, а не радоваться хешу. Вторая: между receipt и обновлением списка есть лаг индексатора; если его не показать, пользователь решит, что ничего не прошло, и нажмёт кнопку второй раз.

Батчинг, Multicall и лимиты провайдера

Сто отдельных eth_call — сто раундтрипов и почти наверняка рейт-лимит. JSON-RPC batch (массив запросов в одном теле) работает для любых методов, но провайдеры ограничивают размер пачки и не гарантируют, что все запросы обслужит один узел, — состояние внутри пачки может разъехаться. Multicall3 по адресу 0xcA11bde05977b3631167028862bE2a173976CA11, задеплоенный на десятках сетей по одному и тому же адресу, решает обе задачи: один eth_call с массивом вызовов, исполнение в одном контексте, все результаты гарантированно с одного среза состояния. viem собирает такие пачки автоматически.

eth_getLogs — отдельная боль: типичные лимиты публичных провайдеров это 10 000 записей в ответе и 2 000–10 000 блоков в диапазоне. Свой узел лимитов не имеет, но запрос на миллион блоков обслуживает минутами. Любой код, читающий историю, обязан уметь резать диапазон пополам при ошибке и продолжать.

Провайдер — это не «облако», а конкретные узлы за балансировщиком

Самый неочевидный класс багов: вы отправили транзакцию, получили хеш, сразу спрашиваете eth_getTransactionReceipt — и получаете null, потом снова null, а потом вдруг данные. Причина не в сети: балансировщик отправил второй запрос на другой узел, который ещё не получил вашу транзакцию по gossip. Классическое нарушение read-your-writes из моделей согласованности. Следствия: null в receipt — не отрицательный ответ; используйте sticky-сессии, если провайдер их даёт; и никогда не сравнивайте ответы разных эндпоинтов — они относятся к разным моментам времени.

Ноды: свой узел, провайдер или лёгкий клиент

Постмерджевый узел Ethereum — это два процесса: execution-клиент (Geth, Nethermind, Besu, Erigon, Reth) и consensus-клиент (Lighthouse, Prysm, Teku, Nimbus, Lodestar). Они общаются по Engine API на порту 8551 и аутентифицируются общим JWT-секретом. Запустить только один из них нельзя: EL без CL не знает, какая цепь каноническая.

# Общий секрет для Engine API — без него клиенты друг друга не примут
openssl rand -hex 32 | tr -d '\n' > /var/lib/ethereum/jwt.hex

geth --mainnet --syncmode snap --authrpc.jwtsecret /var/lib/ethereum/jwt.hex \
     --http --http.api eth,net,web3 --http.addr 127.0.0.1

lighthouse bn --network mainnet --execution-endpoint http://localhost:8551 \
     --execution-jwt /var/lib/ethereum/jwt.hex \
     --checkpoint-sync-url https://beaconstate.info

Флаг --checkpoint-sync-url заслуживает отдельного слова: он позволяет начать не с генезиса, а с недавнего доверенного состояния — синхронизация сокращается с недель до часов ценой того, что точка старта принимается на веру.

Вариант Диск Что даёт Чем платите
Full node (snap sync) порядка 1,2–2 ТБ, растёт полная проверка новых блоков, свой мемпул, нет лимитов на eth_getLogs эксплуатация, NVMe обязателен
Archive node 3 ТБ и выше (Erigon/Reth экономнее Geth) состояние на любом историческом блоке, трейсы дорого и долго
Провайдер RPC 0 старт за минуту, много сетей доверие, лимиты, единая точка отказа, цензура по IP
Лёгкий клиент (Helios и подобные) десятки МБ проверяет заголовки через sync committee, не верит провайдеру на слово покрывает не все методы, экосистема ещё молодая

Честная позиция: подавляющее большинство dApp живёт на провайдерах, и это нормально ровно до тех пор, пока вы помните, что провайдер — часть модели угроз. Он может отдать устаревшее состояние, отфильтровать ваши транзакции, упасть в самый неудобный момент или заблокировать регион. Свой узел не делает вас неуязвимым, но убирает из цепочки доверия одну коммерческую компанию.

Отказоустойчивость: фолбэк, кворум, деградация

import { createPublicClient, fallback, http, webSocket } from "viem";
import { mainnet } from "viem/chains";

// rank — клиент сам замеряет задержку и двигает быстрый эндпоинт вверх.
// retryCount внутри http() — попытки в пределах одного транспорта.
export const publicClient = createPublicClient({
  chain: mainnet,
  transport: fallback(
    [
      webSocket(process.env.WS_PRIMARY),           // подписки: newHeads, logs
      http(process.env.HTTP_PRIMARY, { retryCount: 2, timeout: 8_000 }),
      http(process.env.HTTP_SECONDARY, { retryCount: 2, timeout: 8_000 }),
      http("https://cloudflare-eth.com"),          // публичный запас на самый край
    ],
    { rank: { interval: 30_000 }, retryCount: 1 },
  ),
  batch: { multicall: { wait: 16 } },              // склеивает чтения в один Multicall3
});

Там, где цена ошибки высока (баланс перед крупной операцией, проверка владельца), имеет смысл кворум чтения: спросить двух независимых провайдеров и сравнить. Расхождение при разных blockNumber — обычное дело, при одинаковом — повод остановиться и не показывать пользователю ничего. И отдельно про WebSocket: он обрывается молча, поэтому продакшн-код всегда содержит heartbeat, переподписку и дозаливку пропуска через eth_getLogs от последнего обработанного блока — иначе после каждого разрыва в данных остаётся дыра, которую никто не заметит месяцами.

Фронтенд и кошелёк: это протокол, а не библиотека

Кошелёк общается с приложением по EIP-1193 — крошечному интерфейсу с методом request({ method, params }) и событиями accountsChanged, chainChanged, disconnect. Всё остальное — обёртки. Историческая проблема: несколько расширений дрались за единственный window.ethereum, и побеждало последнее внедрившееся. EIP-6963 решает это обменом событиями: страница шлёт eip6963:requestProvider, каждый кошелёк отвечает eip6963:announceProvider со своими метаданными и собственным объектом провайдера, а выбор остаётся за пользователем.

type Eip6963Detail = {
  info: { uuid: string; name: string; icon: string; rdns: string };
  provider: import("viem").EIP1193Provider;
};

const wallets = new Map<string, Eip6963Detail>();

window.addEventListener("eip6963:announceProvider", (e) => {
  const detail = (e as CustomEvent<Eip6963Detail>).detail;
  wallets.set(detail.info.rdns, detail);   // rdns — стабильный идентификатор кошелька
});
window.dispatchEvent(new Event("eip6963:requestProvider"));

async function connect(rdns: string) {
  const { provider } = wallets.get(rdns)!;
  const [account] = await provider.request({ method: "eth_requestAccounts" });

  // Сеть проверяем ВСЕГДА и при каждом изменении: тот же адрес контракта
  // в другой сети — это другой контракт либо пустота.
  const chainId = await provider.request({ method: "eth_chainId" });
  if (chainId !== "0x1") {
    await provider.request({
      method: "wallet_switchEthereumChain",
      params: [{ chainId: "0x1" }],
    }); // код 4902 — сеть кошельку неизвестна, тогда wallet_addEthereumChain
  }
  provider.on("chainChanged", () => window.location.reload()); // проще, чем чинить состояние
  return account;
}

Подписи бывают двух видов, и разница критична для безопасности. personal_sign подписывает произвольную строку — пользователь видит текст, но не понимает последствий. EIP-712 подписывает типизированную структуру с доменом (name, version, chainId, verifyingContract), и кошелёк показывает поля с именами. Домен намертво привязывает подпись к конкретной сети и контракту, убивая целый класс атак повторного воспроизведения. Требуется подпись — только EIP-712, без вариантов.

И то, о чём забывают: у кошелька-контракта (Safe, любой ERC-4337 аккаунт) нет приватного ключа, и ecrecover для него не сработает. Проверять подписи нужно через EIP-1271: вызвать isValidSignature(hash, signature) на адресе подписанта и сравнить с magic value 0x1626ba7e. viem делает это прозрачно в verifyMessage/verifyTypedData.

Жизненный цикл транзакции в интерфейсе

Большинство фронтендов реализует ровно три состояния из этих десяти — и потому регулярно врёт пользователю. Отдельно отметим переход confirming → pool: реорг возвращает транзакцию в мемпул, и оптимистично показанный результат обязан откатиться. Поэтому оптимистичный UI в Web3 должен быть визуально помечен («ожидает подтверждения»), а не выдавать себя за факт.

Сухой прогон обязателен, а ошибки надо декодировать

Отправлять транзакцию без предварительного eth_call — значит регулярно сжигать газ пользователя на заведомо провальных вызовах. Симуляция стоит один запрос и ловит почти все ошибки: недостаточный баланс, отсутствующий approve, закрытое окно, неверную роль.

import { BaseError, ContractFunctionRevertedError } from "viem";

async function withdraw(amount: bigint, account: `0x${string}`) {
  try {
    // simulateContract = eth_call с теми же параметрами + подготовка запроса на запись
    const { request, result } = await publicClient.simulateContract({
      address: VAULT, abi: vaultAbi, functionName: "withdraw",
      args: [amount], account,
    });

    const hash = await walletClient.writeContract(request);
    // Ждём именно receipt, и не одно подтверждение, если речь о деньгах
    const receipt = await publicClient.waitForTransactionReceipt({ hash, confirmations: 2 });
    if (receipt.status === "reverted") throw new Error("Транзакция включена, но упала");
    return { receipt, result };
  } catch (e) {
    if (e instanceof BaseError) {
      const revert = e.walk((err) => err instanceof ContractFunctionRevertedError);
      if (revert instanceof ContractFunctionRevertedError) {
        // Начиная с Solidity 0.8.4 контракты возвращают custom errors:
        // 4 байта селектора + ABI-кодировка аргументов. Без ABI это просто hex.
        const name = revert.data?.errorName ?? "неизвестная ошибка";
        const args = revert.data?.args ?? [];
        throw new Error(`Контракт отказал: ${name} ${JSON.stringify(args)}`);
      }
    }
    throw e;
  }
}

Для справки: строковый require кодируется как Error(string) с селектором 0x08c379a0, assert и переполнения — как Panic(uint256) с селектором 0x4e487b71 (код 0x11 — арифметическое переполнение, 0x32 — выход за границы массива). Сырой hex в интерфейсе — признак недоделанного фронтенда.

Отдельная тема — приватность отправки. Обычная транзакция сначала попадает в публичный мемпул, где её видят все, включая ботов. Для операций, чувствительных к опережению (свопы, ликвидации, клейм по первому пришедшему), используют приватные каналы отправки, доставляющие транзакцию строителям блоков в обход публичного мемпула: риск сэндвич-атак снижается, посредник в цепочке доверия добавляется.

Индексация: то, ради чего dApp вообще может существовать

Всё, что вы видите в интерфейсе любого работающего протокола — история, списки, графики, «мои позиции», — приходит не из блокчейна, а из обычной PostgreSQL, которую наполняет индексатор. Источник данных для него — логи событий.

Раскладка лога события: address, topics и data, и как по ним фильтрует eth_getLogs

Весь дизайн определяют три свойства логов. Они дёшевы для записи: LOG2 обходится примерно в 1 400 газа против 22 100 за одну новую ячейку storage, — логировать можно щедро. Они невидимы для контрактов: логи не входят в состояние, ни один контракт не прочитает ни своё, ни чужое событие, это односторонний канал наружу. И они фильтруются только по address и topics: позиции topic соединяются логическим «И», массив внутри позиции — «ИЛИ», ничего сложнее узел не умеет.

Проектирование событий под индексацию

Событие — публичный API контракта, который нельзя поменять после деплоя. Ошибки здесь стоят дорого: индексатор придётся переписывать, а историю — терять.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Vault {
    // indexed — то, по чему будут искать; максимум три поля у обычного события.
    // Всё остальное кладём в data: оно всё равно доедет до индексатора.
    event Deposited(
        address indexed account,     // topic1 — «покажи мои депозиты»
        address indexed asset,       // topic2 — «покажи всё по USDC»
        uint256 amount,              // data — по значению всё равно не отфильтруешь
        uint256 sharesMinted,        // data — избавляет индексатор от пересчёта
        uint256 totalAssetsAfter     // data — снимок, чтобы не звать eth_call на каждый лог
    );

    // Плохо: строка в indexed — в topic попадёт keccak256(строки), текст потерян навсегда.
    // event BadLabel(string indexed label);
    // Хорошо: хеш для поиска и сам текст для отображения.
    event Labeled(bytes32 indexed labelHash, string label);
}

Правила, выведенные из практики:

  • Кладите в событие всё, что понадобится индексатору. Каждое недостающее поле превращается в eth_call на историческом блоке, а это архивная нода и совсем другие деньги.
  • Не индексируйте строки и динамические массивы — в topic попадает keccak256 содержимого, оригинал не восстановить.
  • Не полагайтесь на порядок между контрактами. Внутри блока порядок задаёт пара (transactionIndex, logIndex); между блоками — только номер блока.
  • Версионируйте. Новая сигнатура — новое topics[0], и старый индексатор просто перестанет видеть события.
  • Событие — не гарантия факта. Лог из откаченной реоргом транзакции исчезает: пока блок не финализирован, любое событие условно.

Модель данных индексатора

Две вещи здесь неслучайны. Первая: составной первичный ключ (tx_hash, log_index) — это естественный ключ идемпотентности. Индексатор гарантированно будет переобрабатывать блоки (после перезапуска, после реорга, после дозаливки пропуска), и ON CONFLICT DO NOTHING превращает эту переобработку в безопасную операцию. Вторая: blocks.hash и blocks.parent_hash хранятся не для красоты — без них невозможно детектировать реорг.

-- Индексы под реальные запросы UI, а не «на всякий случай»
CREATE INDEX ON deposits (account, block_number DESC);   -- лента пользователя
CREATE INDEX ON deposits (asset, block_number DESC);     -- лента по активу
CREATE INDEX ON deposits (block_number);                 -- откат при реорге

-- Отставание индексатора — главная метрика мониторинга
SELECT :chain_head - max(number) AS lag_blocks FROM blocks;

Реорги: единственная по-настоящему сложная часть

Реорганизация цепи — это когда блоки, которые узел считал каноническими, перестают ими быть. В Ethereum PoS одиночные реорги на глубину 1–2 блока рутинны; глубже финализированного чекпоинта откат невозможен без потери огромного стейка. На L2 картина иная: секвенсор может переупорядочить транзакции, а у некоторых сетей есть собственные окна отката — детали в «Масштабировании». Индексатор, который не умеет откатываться, тихо накапливает мусор: удвоенные депозиты, позиции, которых нет в цепи, несходящиеся балансы. Проявляется через недели, чинится полной переиндексацией.

// Ядро цикла индексации. Опущены транзакции БД и ретраи, но логика полная.
async function ingest(head: { number: bigint; hash: `0x${string}`; parentHash: `0x${string}` }) {
  const last = await db.lastIndexedBlock();

  if (last && head.parentHash !== last.hash) {
    // Расходимся назад, пока хеш в нашей базе не совпадёт с каноном
    let n = last.number;
    for (;;) {
      const canonical = await publicClient.getBlock({ blockNumber: n });
      const stored = await db.blockAt(n);
      if (!stored || stored.hash === canonical.hash) break;
      if (last.number - n > MAX_REORG_DEPTH) {
        // Глубже финализации откатов не бывает — значит, проблема не в реорге
        throw new FatalError(`Расхождение глубже ${MAX_REORG_DEPTH} блоков на ${n}`);
      }
      n -= 1n;
    }
    // Каскадное удаление производных таблиц одной транзакцией
    await db.rollbackAbove(n);
  }

  const from = (await db.lastIndexedBlock())?.number ?? START_BLOCK;
  for (let lo = from + 1n; lo <= head.number; lo += CHUNK) {
    const hi = lo + CHUNK - 1n > head.number ? head.number : lo + CHUNK - 1n;
    const logs = await publicClient.getLogs({
      address: CONTRACTS, events: EVENTS, fromBlock: lo, toBlock: hi,
    });
    // (tx_hash, log_index) — ключ идемпотентности: повтор безопасен
    await db.upsertLogs(logs);
    await db.applyProjections(lo, hi);   // пересчёт POSITIONS и агрегатов
    await db.setCursor(hi);
  }

  const finalized = await publicClient.getBlock({ blockTag: "finalized" });
  await db.markFinalized(finalized.number);
}

Ключевая мысль: проекции должны быть пересчитываемыми. Если POSITIONS обновляется инкрементально (shares = shares + delta), откат требует хранить журнал обратных операций. Если проекция пересчитывается из DEPOSITS за диапазон блоков — откат сводится к DELETE и повторному прогону. Второй путь дороже по CPU и несравнимо дешевле по количеству ночных инцидентов. Это ровно тот же выбор, что в event sourcing из архитектурных паттернов.

Готовые индексаторы против своего

The Graph — самый известный вариант: вы описываете источники и обработчики в subgraph.yaml, схему в GraphQL, мапперы на AssemblyScript, а сеть индексаторов исполняет это и отдаёт GraphQL-эндпоинт.

specVersion: 0.0.5
schema: { file: ./schema.graphql }
dataSources:
  - kind: ethereum
    name: Vault
    network: mainnet
    source:
      address: "0x1234567890abcdef1234567890abcdef12345678"
      abi: Vault
      startBlock: 18000000        # не с генезиса — иначе синхронизация займёт недели
    mapping:
      kind: ethereum/events
      apiVersion: 0.0.7
      language: wasm/assemblyscript
      file: ./src/vault.ts
      entities: [Deposit, Position]
      abis: [{ name: Vault, file: ./abis/Vault.json }]
      eventHandlers:
        # Сигнатура должна совпадать с ABI побайтно, включая слово indexed
        - event: Deposited(indexed address,indexed address,uint256,uint256,uint256)
          handler: handleDeposited

Реорги The Graph обрабатывает сам — это и есть главная причина его брать. Расплата: ограниченная модель запросов (GraphQL без произвольных агрегатов), детерминированные ошибки, останавливающие сабграф целиком, и полная пересинхронизация при изменении мапперов.

Подход Когда разумно Чем платите
The Graph стандартные события, нужна децентрализация чтения модель запросов, время пересинхронизации
Ponder, Subsquid, Envio нужен TypeScript, своя схема и SQL эксплуатация своей базы
Свой индексатор сложные проекции, много сетей, интеграция с бэкендом реорги, дозаливка, мониторинг — всё на вас
Готовые API и аналитические платформы прототип, дашборды, разовые отчёты лимиты, задержки, для UI не годится

Практический совет: начинайте с готового, переходите на своё, когда упрётесь. Собственный индексатор — отдельный сервис с собственным дежурством, и заводить его на старте почти всегда преждевременно.

Офчейн-компоненты: что выносить и по какому правилу

Контракт не умеет трёх вещей: сам себя запускать, платить за пользователя и хранить что-либо дёшево. Отсюда весь класс офчейн-сервисов.

Релееры, мета-транзакции и абстракция аккаунта

Требование «заведи кошелёк, купи нативный токен на газ, потом пользуйся» отсекает большую часть аудитории. Решения выстроились в три поколения:

  • ERC-2771 (мета-транзакции). Пользователь подписывает структуру EIP-712, релеер отправляет её доверенному форвардеру, форвардер вызывает целевой контракт и дописывает адрес подписанта в конец calldata. Контракт наследует ERC2771Context и берёт отправителя оттуда вместо msg.sender. Простая схема, но каждый контракт должен явно доверять форвардеру — а это ровно та точка, где обычно и находят дыру: доверенный форвардер может подделать любого отправителя.
  • ERC-4337 (абстракция аккаунта без изменения протокола). Кошелёк пользователя — контракт. Вместо транзакции он подписывает UserOperation, которая летит в отдельный альтернативный мемпул. Бандлер собирает пачку таких операций и отправляет одной транзакцией в синглтон EntryPoint. Пэймастер может оплатить газ — из вашего бюджета, за токены или по подписке. Бонусом идут произвольная логика проверки подписи, социальное восстановление и лимиты трат.
  • EIP-7702 (делегация для обычных аккаунтов). Транзакция нового типа, позволяющая EOA временно указать код, который исполняется от её имени. Обычный кошелёк получает пакетные операции и спонсирование газа без миграции на контрактный аккаунт.

Во всех трёх случаях релеер не получает контроля над средствами. Он платит за газ и выбирает, что отправлять; он не может ни изменить подписанное намерение, ни подписать своё. Это и есть правильная форма офчейн-компонента: цензурировать может, украсть — нет.

Подписи вместо ончейн-списков

Классическая задача: раздать доступ тысяче адресов. Наивное решение — маппинг в контракте, тысяча транзакций и десятки миллионов газа. Правильное — подписать разрешения офчейн и проверять их при использовании.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract Claim is EIP712 {
    address public immutable signer;              // ключ бэкенда, не админ протокола
    mapping(bytes32 => bool) public usedNonces;   // защита от повторного использования

    // Хеш типа считается один раз на этапе компиляции
    bytes32 private constant TICKET_TYPEHASH =
        keccak256("Ticket(address to,uint256 amount,uint256 nonce,uint256 deadline)");

    error BadSignature();
    error TicketExpired();
    error TicketUsed();

    constructor(address _signer) EIP712("Claim", "1") { signer = _signer; }

    function claim(uint256 amount, uint256 nonce, uint256 deadline, bytes calldata sig) external {
        if (block.timestamp > deadline) revert TicketExpired();   // подписи обязаны протухать

        // _hashTypedDataV4 добавляет domain separator: chainId и адрес контракта.
        // Именно поэтому подпись нельзя переиграть в другой сети или на другом деплое.
        bytes32 digest = _hashTypedDataV4(
            keccak256(abi.encode(TICKET_TYPEHASH, msg.sender, amount, nonce, deadline))
        );
        if (usedNonces[digest]) revert TicketUsed();
        if (ECDSA.recover(digest, sig) != signer) revert BadSignature();

        usedNonces[digest] = true;    // отмечаем ДО внешних вызовов: см. статью о безопасности
        _mint(msg.sender, amount);
    }
}

Здесь обязательна каждая строка. deadline не даёт подписи жить вечно — украденная база подписей перестаёт быть проблемой через сутки. usedNonces закрывает повторное использование: без него один тикет отработает бесконечно. Домен EIP-712 привязывает подпись к сети и адресу — иначе подпись из тестовой сети применят в основной. Порядок «сначала пометить, потом действовать» — это checks-effects-interactions из статьи о безопасности контрактов. И главное: компрометация ключа подписи позволяет напечатать сколько угодно тикетов, поэтому он живёт в KMS или HSM, а не в переменной окружения, с лимитами и алертами. Требования к сервисному ключу не мягче, а жёстче, чем к пользовательскому в «Кошельках и ключах».

Киперы: у контракта нет крона

Контракт не может выполниться сам: ликвидация просроченной позиции, ребалансировка, закрытие раунда, начисление процентов — всё это требует, чтобы кто-то извне отправил транзакцию. Такой сервис называют кипером, и ключевой вопрос его дизайна — что произойдёт, если он умрёт. Если протокол при этом становится неплатёжеспособным, вы построили систему с офчейн-точкой отказа, отвечающей за деньги. Правильный подход — сделать вызов открытым и вознаграждаемым: любой может выполнить ликвидацию и получить часть залога, а ваш кипер тогда просто самый быстрый из многих, а не единственный. Оракулы — частный и самый опасный случай офчейн-компонента; им посвящена отдельная часть статьи о безопасности контрактов.

Хостинг фронтенда и его подмена

Уязвимость номер один в дизайне dApp — не контракт, а то, что пользователь открывает https://ваш-протокол.xyz, а этот адрес указывает на CDN, управляемый через панель регистратора домена с восстановлением пароля по SMS. Контракт можно проаудировать; DNS-запись меняется за минуту. Это не теория: отрасль пережила несколько громких инцидентов ровно этого класса — перехват DNS у крупного DeFi-протокола с подменённым фронтендом, внедрение вредоносного скрипта через взломанную учётку в CDN, компрометацию популярной библиотеки подключения кошельков через npm-аккаунт разработчика. Контракты во всех случаях были в полном порядке, ущерб — от сотен тысяч до сотен миллионов USD.

Меры, которые реально работают:

  • Сборка фронтенда в IPFS + contenthash в ENS. Имя протокол.eth указывает на конкретный CID, обновляемый транзакцией в цепи с мультиподписью. Подменить содержимое незаметно нельзя: CID — хеш содержимого, см. децентрализованные хранилища. Но помните: шлюз, через который браузер это открывает, снова остаётся доверенным звеном.
  • Отсутствие внешних скриптов на страницах подписания. Никакой аналитики, чат-виджетов и шрифтов с чужих доменов там, где пользователь подтверждает транзакцию. Плюс строгий CSP и Subresource Integrity.
  • Воспроизводимая сборка и публикуемый хеш, чтобы пользователь мог сверить, что загруженное соответствует исходникам.
  • Инструкция по прямому взаимодействию с контрактом — через эксплорер или CLI. Это и есть тот самый ответ на вопрос «что останется, если исчезнет ваша инфраструктура».

Про скам скажем прямо, без эвфемизмов. Типовая схема потери средств: пользователь приходит по рекламной ссылке или из личного сообщения на клон интерфейса, подключает кошелёк и подписывает то, что ему показали, — чаще всего approve на неограниченную сумму либо Permit/Permit2, подпись, которая вообще не требует газа и потому не выглядит опасной. Через несколько часов, когда жертва уже забыла об этом, токены выводятся одной транзакцией.

Что должно делать приложение: запрашивать разрешение ровно на нужную сумму, а не на type(uint256).max; объяснять подпись EIP-712 своими словами рядом с окном кошелька; предупреждать о существующих разрешениях и давать кнопку отзыва; никогда не просить seed-фразу и не иметь для этого поля в принципе. Что должен помнить пользователь: подпись без газа — тоже разрешение распоряжаться средствами, и оно может быть бессрочным.

Наблюдаемость и эксплуатация

dApp — распределённая система с необычно жёсткими последствиями сбоев: транзакция не откатывается кнопкой. Минимальный набор метрик, за которыми стоит следить:

Метрика Порог тревоги Что означает
Отставание индексатора, блоки больше 25 UI показывает устаревшие данные
Ошибки RPC по эндпоинтам больше 1 % за 5 минут провайдер деградирует, пора переключаться
Расхождение blockNumber между провайдерами больше 5 блоков один из них завис
Доля транзакций со status: 0 больше 2 % ошибка в симуляции или в UI
Медиана времени до включения больше 3 минут заниженные комиссии в оценке
Баланс релеера и кипера меньше суточного расхода сервис встанет ночью
Возраст последнего прогона кипера, глубина реоргов интервал × 2; глубже 3 блоков автоматизация встала либо сеть ведёт себя странно

Особая практика — прогон против форка сети. Инструменты вроде Anvil и Hardhat умеют поднимать локальный узел, который читает состояние основной сети по RPC. На таком форке можно прогнать сценарий целиком: подписи, релеер, индексатор, UI. Это самый дешёвый способ поймать «на тестовой сети работало» до того, как это увидят пользователи. Общие принципы — в треке по тестированию.

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

Считать хеш транзакции подтверждением. Хеш означает «узел принял байты». Дальше транзакция может провалиться, быть заменённой, выпасть из мемпула или откатиться реоргом.

Не проверять chainId. Один и тот же адрес в другой сети — либо другой контракт, либо пустота. Вызов в пустоту у низкоуровневого call возвращает успех — это классика потерянных средств.

Индексатор без обработки реоргов. Работает месяцами, потом обнаруживаются дубли и несходящиеся балансы, и лечится только полной переиндексацией.

Один RPC-эндпоинт. Провайдер упал — приложение мертво целиком, включая вывод средств.

Читать историю через eth_call на исторических блоках. Требует архивную ноду и стоит на порядки дороже, чем те же данные из логов. Не хватает данных — исправляйте события, а не наращивайте расходы.

approve(spender, type(uint256).max) по умолчанию и personal_sign вместо EIP-712. Первое даёт получателю бессрочное право на весь баланс; второе лишает подпись домена, и она переносится между сетями и контрактами.

Офчейн-сервис умеет то, чего не умеет пользователь. Если ваш бэкенд может вывести средства, а пользователь напрямую — нет, это не dApp. Так тоже можно строить, но называть вещи стоит своими именами.

Игнорировать лаг индексатора в UI. Транзакция прошла, список не обновился, пользователь жмёт ещё раз и платит газ дважды.

Ключ релеера в переменной окружения и молчащий WebSocket. Первое — самый частый способ потерять сервисный ключ (логи, дамп образа, CI). Второе — обрыв без ошибки, после которого события просто перестают приходить; heartbeat и дозаливка пропуска обязательны.

Мини-итог

  • Блокчейн не бэкенд: в нём нет выборок, сортировок, агрегатов, истории и уведомлений. Всё это делает обычная инфраструктура рядом, и она не защищена консенсусом.
  • Слой RPC — это конкретный узел, которому вы решили поверить. Теги latest, safe и finalized задают, насколько вы ему верите: UI на latest, деньги наружу — только на finalized.
  • Свой узел стоит 1,2–2 ТБ NVMe и постоянной эксплуатации; провайдер стоит доверия, лимитов и единой точки отказа. Осознанный выбор допустим, неосознанный — нет.
  • Кошелёк — это протокол: EIP-1193 для вызовов, EIP-6963 для выбора кошелька, EIP-712 для подписей с доменом, EIP-1271 для контрактных аккаунтов. Каждой записи предшествует сухой прогон eth_call, каждому revert — декодирование custom error, а хеш транзакции не подтверждает ничего.
  • Индексация строится на логах: дёшево писать, фильтровать можно только по address и topics, контракт свои события не читает. Событие — неизменяемый публичный API, проектируйте его как API.
  • Реорг обязателен к обработке: храните hash и parent_hash, откатывайтесь до общего предка, делайте проекции пересчитываемыми и используйте (tx_hash, log_index) как ключ идемпотентности.
  • Офчейн-компонент может предлагать, но не разрешать. Релеер платит за газ и не владеет средствами; кипер должен быть заменяем любым желающим; ключ подписи живёт в KMS.
  • Самая частая причина потери денег в dApp — не контракт, а подменённый фронтенд и бездумно подписанное разрешение. Ограничивайте суммы approve, объясняйте подписи и убирайте чужие скрипты со страниц подписания.
  • Проверка архитектуры одна: выключите всю свою инфраструктуру мысленно. Если пользователь не может забрать своё через публичный узел — это не децентрализованное приложение.

Источники

  • Ethereum JSON-RPC API — спецификация методов: ethereum.org/en/developers/docs/apis/json-rpc
  • EIP-1193 (провайдер), EIP-6963 (обнаружение кошельков), EIP-712 (типизированные подписи), EIP-1271 (подписи контрактных аккаунтов) — eips.ethereum.org
  • ERC-2771 (мета-транзакции), ERC-4337 (абстракция аккаунта), EIP-7702 (делегация EOA) — eips.ethereum.org/EIPS/eip-4337
  • viem — клиенты, транспорты, симуляция и декодирование ошибок: viem.sh
  • wagmi — React-хуки поверх viem: wagmi.sh
  • The Graph Docs — сабграфы, мапперы, обработка реоргов: thegraph.com/docs
  • Ponder — индексатор на TypeScript с реорг-безопасностью: ponder.sh/docs
  • Subsquid (SQD) — фреймворк индексации: docs.sqd.ai
  • Multicall3 — единый адрес и ABI на десятках сетей: github.com/mds1/multicall
  • Geth Docs — синхронизация, Engine API, требования к диску: geth.ethereum.org/docs
  • Lighthouse Book — checkpoint sync и запуск пары EL+CL: lighthouse-book.sigmaprime.io
  • Helios — лёгкий клиент Ethereum: github.com/a16z/helios
  • Foundry Book — Anvil, форк основной сети, тестирование сценариев: book.getfoundry.sh
  • OpenZeppelin Contracts — EIP712, ECDSA, ERC2771Context: docs.openzeppelin.com/contracts
  • Rekt News — разборы реальных инцидентов, включая подмену фронтендов: rekt.news

Что дальше

Трек закончен. Мы прошли путь от хеш-цепочки и подписи до работающего приложения: как устроен блок, криптография, консенсус, EVM, контракты и их безопасность, ключи, токены, хранилища, масштабирование и, наконец, архитектура dApp.

Главный вывод трека простой: почти всё в Web3 — это обычная распределённая система, к которой добавили один необычный компонент — реплицированную машину состояний с открытым доступом на запись. Поэтому дальше полезнее всего идти не «глубже в блокчейн», а вширь — туда, откуда взяты все остальные детали:

  • Распределённые системы — согласованность, консенсус, отказы и время. Половина сложности этого трека родом отсюда.
  • Базы данных — индексы, планы запросов, транзакции: без этого свой индексатор не построить.
  • Архитектурные паттерны — CQRS, event sourcing, устойчивость: индексатор и проекции ровно об этом.
  • Фронтенд — состояние, загрузка данных, обработка ошибок; интерфейс dApp остаётся обычным фронтендом с необычной моделью задержек.
  • Дата-инженерия — пайплайны, идемпотентность, качество данных: индексация цепи по сути ETL.
  • DevOps и тестирование — эксплуатация узлов, секреты, мониторинг и способы проверять систему, в которой нельзя нажать «отменить».

Общая карта портала и порядок изучения — в дорожной карте.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов