Архитектура dApp: фронтенд, индексация, ноды, RPC, офчейн-компоненты
Контракт задеплоен, тесты зелёные, адрес в блокчейн-эксплорере. Дальше выясняется неприятное: пользоваться этим невозможно. Список позиций не собирается — в цепи нет запроса «все токены владельца». История операций не показывается — состояние хранит только текущий срез. Кнопка «Вывести» падает у половины пользователей, потому что фронтенд не спросил, в какой они сети. Страница показывает баланс, которого уже нет, потому что RPC-провайдер ответил со сторонней реплики.
Всё это не мелочи интеграции, а суть темы. Блокчейн — не бэкенд. Это реплицированная машина состояний, оптимизированная под верифицируемость и абсолютно не оптимизированная под чтение, поиск, агрегацию и уведомления. Между ней и человеком приходится ставить целый слой обычной, ничем не защищённой инфраструктуры: узлы, индексаторы, базы, шлюзы, релееры, фронтенд на CDN. И вот главный вопрос всей статьи, к которому мы будем возвращаться в каждом разделе:
Что останется работать, если вся ваша инфраструктура исчезнет?
Если ответ «ничего» — вы построили обычное веб-приложение, которое использует блокчейн как дорогую и медленную базу данных. Если «пользователь всё ещё сможет забрать свои средства напрямую» — это 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, которую наполняет индексатор. Источник данных для него — логи событий.
Весь дизайн определяют три свойства логов. Они дёшевы для записи: 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 картина иная: секвенсор может переупорядочить транзакции, а у некоторых сетей есть собственные окна отката — детали в «Масштабировании». Индексатор, который не умеет откатываться, тихо накапливает мусор: удвоенные депозиты, позиции, которых нет в цепи, несходящиеся балансы. Проявляется через недели, чинится полной переиндексацией.
из newHeads или опроса"] --> B{"parentHash новой головы
совпадает с хешом
последнего обработанного?"} B -- да --> C["Обработать блок:
записать логи, сдвинуть курсор"] B -- нет --> D["Реорг обнаружен"] D --> E["Идти назад по канону:
сравнивать хеши блоков
с сохранёнными в BLOCKS"] E --> F{"Нашли общего предка N?"} F -- нет --> E F -- да --> G["Удалить все производные записи
с номером блока выше N"] G --> H["Переиграть блоки от N плюс 1
до новой головы"] H --> C C --> I{"Номер блока
не выше finalized?"} I -- да --> J["Пометить finalized,
чистить журнал отката"] I -- нет --> K["Оставить в откатываемой зоне"] E --> L["Ушли глубже финализации —
аварийная остановка и алерт:
это рассинхрон или другая сеть"]
// Ядро цикла индексации. Опущены транзакции БД и ретраи, но логика полная.
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 и тестирование — эксплуатация узлов, секреты, мониторинг и способы проверять систему, в которой нельзя нажать «отменить».
Общая карта портала и порядок изучения — в дорожной карте.