Ончейн-данные в продакшене: о чем вам никто не расскажет
Блокчейн-данные не бывают чистыми, надежными или простыми. Rate-лимиты RPC, реорги цепи, баги с BigInt и компромиссы индексирования — выстраданные уроки из реальных DeFi-продуктов.
Существует фантазия, что ончейн-данные по своей природе заслуживают доверия. Неизменяемый реестр. Прозрачное состояние. Просто читай — и готово.
Я тоже в это верил. Потом я выпустил DeFi-дашборд в продакшен и провел три недели, разбираясь, почему наши балансы токенов были неверными, история событий имела пробелы, а наша база данных содержала транзакции из блоков, которых больше не существовало.
Ончейн-данные — сырые, враждебные и полные граничных случаев, которые сломают ваше приложение так, что вы не заметите, пока пользователь не пришлет баг-репорт. Этот пост охватывает всё, что я усвоил на собственном опыте.
Иллюзия надежных данных#
Вот первое, о чем никто не говорит: блокчейн не дает вам данные. Он дает вам переходы состояний. Нет никакого SELECT * FROM transfers WHERE user = '0x...'. Есть логи, квитанции, слоты хранилища и трейсы вызовов — все закодированные в форматах, для декодирования которых нужен контекст.
Лог события Transfer дает вам from, to и value. Он не сообщает символ токена. Не сообщает количество десятичных знаков. Не сообщает, является ли это легитимным переводом или fee-on-transfer токен снимает 3% сверху. Не сообщает, будет ли этот блок существовать через 30 секунд.
«Неизменяемая» часть — правда, после финализации. Но финализация не мгновенна. И данные, которые вы получаете от RPC-ноды, не обязательно из финализированного блока. Большинство разработчиков запрашивают latest и считают это истиной. Это баг, а не фича.
Потом есть кодирование. Всё в hex. Адреса в смешанном регистре с чексуммой (или без). Суммы токенов — целые числа, умноженные на 10^decimals. Перевод $100 в USDC выглядит как 100000000 ончейн, потому что у USDC 6 десятичных знаков, а не 18. Я видел продакшен-код, который предполагал 18 десятичных знаков для каждого ERC-20 токена. Результирующие балансы расходились в 10^12 раз.
Rate-лимиты RPC испортят вам выходные#
Каждое продакшен Web3-приложение общается с RPC-эндпоинтом. И у каждого RPC-эндпоинта есть rate-лимиты, которые гораздо агрессивнее, чем вы ожидаете.
Вот цифры, которые имеют значение:
- Alchemy Free: ~30M compute units в месяц, 40 запросов в минуту. Звучит щедро, пока вы не поймете, что один вызов
eth_getLogsс широким диапазоном блоков может съесть сотни CU. Вы израсходуете месячную квоту за день индексирования. - Infura Free: 100K запросов в день, примерно 1.15 запросов в секунду. Попробуйте пропагинировать 500K блоков логов событий с такой скоростью.
- QuickNode Free: Аналогично Infura — 100K запросов в день.
Платные тарифы помогают, но не устраняют проблему. Даже при $200/месяц на плане Growth от Alchemy тяжелая задача индексирования упрется в лимиты пропускной способности. И когда вы в них упираетесь, грациозной деградации не происходит. Вы получаете ошибки 429, иногда с бесполезными сообщениями, иногда без заголовка retry-after.
Решение — комбинация фолбэк-провайдеров, логики повторов и очень осознанного выбора того, какие вызовы делать. Вот как выглядит надежная RPC-настройка с viem:
import { createPublicClient, fallback, http } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({
chain: mainnet,
transport: fallback(
[
http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
retryCount: 3,
retryDelay: 1500,
timeout: 15_000,
}),
http("https://mainnet.infura.io/v3/YOUR_KEY", {
retryCount: 3,
retryDelay: 1500,
timeout: 15_000,
}),
http("https://rpc.ankr.com/eth", {
retryCount: 2,
retryDelay: 2000,
timeout: 20_000,
}),
],
{ rank: true }
),
});Опция rank: true критически важна. Она указывает viem измерять задержку и процент успешных запросов для каждого транспорта и автоматически предпочитать самый быстрый и надежный. Если Alchemy начинает вас ограничивать, viem перенаправляет трафик на Infura. Если Infura падает, переключается на Ankr.
Но есть тонкость: дефолтная логика повторов viem использует экспоненциальный бэкофф, что обычно и нужно. Однако на начало 2025 года существует известная проблема, когда retryCount не выполняет повторы при ошибках RPC-уровня (вроде 429) при включенном batch-режиме. Если вы пакуете запросы, тестируйте поведение повторов явно. Не верьте, что оно работает.
Реорги: баг, которого вы не увидите#
Реорганизация цепи происходит, когда сеть временно не может согласовать, какой блок является каноническим. Нода A видит блок 1000 с транзакциями [A, B, C]. Нода B видит другой блок 1000 с транзакциями [A, D]. В итоге сеть приходит к консенсусу, и одна версия побеждает.
На proof-of-work цепочках это было обычным делом — реорги на 1-3 блока случались несколько раз в день. Post-merge Ethereum стал лучше. Для успешной атаки реоргом теперь требуется координация около 50% валидаторов. Но «лучше» — не значит «невозможно». В мае 2022 года произошел заметный реорг на 7 блоков в Beacon Chain, вызванный несогласованными реализациями proposer boost fork в клиентах.
И не важно, насколько редки реорги в мейннете Ethereum. Если вы строите на L2 или сайдчейнах — Polygon, Arbitrum, Optimism — реорги случаются чаще. У Polygon исторически были реорги на 10+ блоков.
Вот практическая проблема: вы проиндексировали блок 18 000 000. Записали события в базу данных. Потом блок 18 000 000 был реорганизован. Теперь в вашей базе данных есть события из блока, который не существует в каноническойм цепи. Эти события могут ссылаться на транзакции, которых никогда не было. Ваши пользователи видят фантомные переводы.
Исправление зависит от вашей архитектуры:
Вариант 1: Задержка подтверждения. Не индексируйте данные, пока не пройдет N блоков подтверждений. Для мейннета Ethereum 64 блока (две эпохи) дают гарантии финальности. Для L2 проверяйте модель финальности конкретной цепи. Это просто, но добавляет задержку — примерно 13 минут на Ethereum.
Вариант 2: Обнаружение реорга и откат. Индексируйте агрессивно, но отслеживайте хеши блоков. На каждом новом блоке проверяйте, что parent hash совпадает с предыдущим проиндексированным блоком. Если нет — вы обнаружили реорг: удаляйте всё из осиротевших блоков и переиндексируйте каноническую цепь.
interface IndexedBlock {
number: bigint;
hash: `0x${string}`;
parentHash: `0x${string}`;
}
async function detectReorg(
client: PublicClient,
lastIndexed: IndexedBlock
): Promise<{ reorged: boolean; depth: number }> {
const currentBlock = await client.getBlock({
blockNumber: lastIndexed.number,
});
if (currentBlock.hash === lastIndexed.hash) {
return { reorged: false, depth: 0 };
}
// Идем назад, чтобы найти, где цепь разошлась
let depth = 1;
let checkNumber = lastIndexed.number - 1n;
while (checkNumber > 0n && depth < 128) {
const onChain = await client.getBlock({ blockNumber: checkNumber });
const inDb = await getIndexedBlock(checkNumber); // ваш запрос к БД
if (onChain.hash === inDb?.hash) {
return { reorged: true, depth };
}
depth++;
checkNumber--;
}
return { reorged: true, depth };
}Это не гипотетический пример. У меня была продакшен-система, где мы индексировали события на кончике цепи без обнаружения реоргов. Три недели всё работало нормально. Потом реорг на 2 блока в Polygon вызвал дублированное событие минта NFT в нашей базе данных. Фронтенд показывал пользователю токен, которым тот не владел. На отладку ушло два дня, потому что никто не рассматривал реорги как причину.
Проблема индексирования: выбирайте свою боль#
У вас есть три реальных варианта получения структурированных ончейн-данных в ваше приложение.
Прямые RPC-вызовы#
Просто вызывайте getLogs, getBlock, getTransaction напрямую. Это работает для небольших объемов чтения — проверка баланса пользователя, получение последних событий для одного контракта. Это не работает для исторического индексирования или сложных запросов по нескольким контрактам.
Проблема комбинаторная. Хотите все свопы Uniswap V3 за последние 30 дней? Это ~200K блоков. При лимите Alchemy в 2K блоков на вызов getLogs — это минимум 100 пагинированных запросов. Каждый из них учитывается в вашем rate-лимите. И если любой вызов упадет, вам нужна логика повторов, отслеживание курсора и способ возобновить с того места, где остановились.
The Graph (субграфы)#
The Graph был первым решением. Определяете схему, пишете маппинги на AssemblyScript, деплоите и запрашиваете через GraphQL. Hosted Service был закрыт — теперь всё в децентрализованной сети The Graph Network, что означает оплату запросов токенами GRT.
Плюсы: стандартизировано, хорошо задокументировано, большая экосистема существующих субграфов, которые можно форкнуть.
Минусы: AssemblyScript болезненный. Отладка ограничена. Деплой занимает от минут до часов. Если в субграфе баг, вы передеплоиваете и ждете, пока он пересинхронизируется с нуля. Децентрализованная сеть добавляет задержку, и иногда индексеры отстают от кончика цепи.
Я использовал The Graph для read-heavy дашбордов, где допустима задержка данных в 30-60 секунд. Там он работает хорошо. Я бы не стал использовать его для чего-то, требующего данных в реальном времени или сложной бизнес-логики в маппингах.
Кастомные индексеры (Ponder, Envio)#
Здесь экосистема значительно выросла. Ponder и Envio позволяют писать логику индексирования на TypeScript (а не AssemblyScript), запускать локально при разработке и деплоить как самостоятельные сервисы.
Ponder дает максимальный контроль. Вы определяете обработчики событий на TypeScript, он управляет конвейером индексирования, а на выходе вы получаете SQL-базу данных. Компромисс: инфраструктура — ваша ответственность. Масштабирование, мониторинг, обработка реоргов — всё на вас.
Envio оптимизирован под скорость синхронизации. Их бенчмарки показывают значительно более быструю начальную синхронизацию по сравнению с The Graph. Они нативно обрабатывают реорги и поддерживают HyperSync — специализированный протокол для быстрого получения данных. Компромисс: вы привязываетесь к их инфраструктуре и API.
Моя рекомендация: если вы строите продакшен DeFi-приложение и у вас есть инженерные ресурсы, используйте Ponder. Если нужна максимально быстрая синхронизация без управления инфраструктурой, оцените Envio. Если нужен быстрый прототип или хотите использовать субграфы, поддерживаемые сообществом, The Graph по-прежнему подходит.
getLogs опаснее, чем кажется#
RPC-метод eth_getLogs обманчиво прост. Передайте диапазон блоков и фильтры — получите подходящие логи событий. Вот что реально происходит в продакшене:
Лимиты диапазона блоков зависят от провайдера. Alchemy ограничивает 2K блоков (без лимита логов) или без лимита блоков (максимум 10K логов). У Infura другие лимиты. У QuickNode другие лимиты. Публичный RPC может ограничить до 1K блоков. Ваш код должен обрабатывать все эти случаи.
Лимиты размера ответа существуют. Даже в пределах диапазона блоков, если популярный контракт генерирует тысячи событий на блок, ответ может превысить лимит payload провайдера (150MB у Alchemy). Вызов не возвращает частичные результаты. Он падает.
Пустые диапазоны не бесплатны. Даже если подходящих логов ноль, провайдер всё равно сканирует диапазон блоков. Это учитывается в ваших compute units.
Вот утилита пагинации, которая обрабатывает эти ограничения:
import type { PublicClient, Log, AbiEvent } from "viem";
async function fetchLogsInChunks<T extends AbiEvent>(
client: PublicClient,
params: {
address: `0x${string}`;
event: T;
fromBlock: bigint;
toBlock: bigint;
maxBlockRange?: bigint;
}
): Promise<Log<bigint, number, false, T, true>[]> {
const { address, event, fromBlock, toBlock, maxBlockRange = 2000n } = params;
const allLogs: Log<bigint, number, false, T, true>[] = [];
let currentFrom = fromBlock;
while (currentFrom <= toBlock) {
const currentTo =
currentFrom + maxBlockRange - 1n > toBlock
? toBlock
: currentFrom + maxBlockRange - 1n;
try {
const logs = await client.getLogs({
address,
event,
fromBlock: currentFrom,
toBlock: currentTo,
});
allLogs.push(...logs);
currentFrom = currentTo + 1n;
} catch (error) {
// Если диапазон слишком большой (слишком много результатов), делим пополам
if (isRangeTooLargeError(error) && currentTo > currentFrom) {
const mid = currentFrom + (currentTo - currentFrom) / 2n;
const firstHalf = await fetchLogsInChunks(client, {
address,
event,
fromBlock: currentFrom,
toBlock: mid,
maxBlockRange,
});
const secondHalf = await fetchLogsInChunks(client, {
address,
event,
fromBlock: mid + 1n,
toBlock: currentTo,
maxBlockRange,
});
allLogs.push(...firstHalf, ...secondHalf);
currentFrom = currentTo + 1n;
} else {
throw error;
}
}
}
return allLogs;
}
function isRangeTooLargeError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes("Log response size exceeded") ||
message.includes("query returned more than") ||
message.includes("exceed maximum block range")
);
}Ключевая идея — бинарное деление при ошибке. Если диапазон в 2K блоков возвращает слишком много логов, делим его на два диапазона по 1K. Если 1K всё ещё слишком много — делим снова. Это автоматически адаптируется к контрактам с высокой активностью без необходимости заранее знать плотность событий.
BigInt вас смирит#
Тип Number в JavaScript — 64-битное число с плавающей точкой. Он может представлять целые числа до 2^53 - 1 — около 9 квадриллионов. Звучит много, пока вы не осознаете, что сумма в 1 ETH в wei — это 1000000000000000000 — число с 18 нулями. Это 10^18, далеко за пределами Number.MAX_SAFE_INTEGER.
Если вы случайно приведете BigInt к Number где-то в вашем конвейере — JSON.parse, драйвер базы данных, библиотека логирования — вы получите тихую потерю точности. Число выглядит примерно правильно, но последние несколько цифр неверны. Вы не поймаете это при тестировании, потому что ваши тестовые суммы маленькие.
Вот баг, который я выпустил в продакшен:
// БАГ: Выглядит безобидно, но таковым не является
function formatTokenAmount(amount: bigint, decimals: number): string {
return (Number(amount) / Math.pow(10, decimals)).toFixed(4);
}
// Для маленьких сумм это работает нормально:
formatTokenAmount(1000000n, 6); // "1.0000" -- правильно
// Для больших сумм ломается тихо:
formatTokenAmount(123456789012345678n, 18);
// Возвращает "0.1235" -- НЕВЕРНО, точность потеряна
// Number(123456789012345678n) === 123456789012345680
// Последние две цифры округлены по IEEE 754Решение: никогда не конвертируйте в Number перед делением. Используйте встроенные утилиты viem, которые оперируют строками и BigInt:
import { formatUnits, parseUnits } from "viem";
// Правильно: оперирует BigInt, возвращает строку
function formatTokenAmount(
amount: bigint,
decimals: number,
displayDecimals: number = 4
): string {
const formatted = formatUnits(amount, decimals);
// formatUnits возвращает строку с полной точностью, например "0.123456789012345678"
// Обрезаем (не округляем) до нужной точности отображения
const [whole, fraction = ""] = formatted.split(".");
const truncated = fraction.slice(0, displayDecimals).padEnd(displayDecimals, "0");
return `${whole}.${truncated}`;
}
// Также критично: используйте parseUnits для пользовательского ввода, никогда parseFloat
function parseTokenInput(input: string, decimals: number): bigint {
// parseUnits корректно обрабатывает конвертацию строки в BigInt
return parseUnits(input, decimals);
}Обратите внимание, что я обрезаю, а не округляю. Это намеренно. В финансовом контексте показывать «1.0001 ETH», когда реальное значение «1.00009999...», лучше, чем показывать «1.0001», когда реальное значение «1.00005001...» и было округлено вверх. Пользователи принимают решения на основе отображаемых сумм. Обрезание — консервативный выбор.
Ещё одна ловушка: JSON.stringify не умеет сериализовать BigInt. Он выбрасывает ошибку. Каждый ответ вашего API, содержащий суммы токенов, нуждается в стратегии сериализации. Я использую конвертацию в строку на границе API:
// Сериализатор для API-ответов
function serializeForApi(data: Record<string, unknown>): string {
return JSON.stringify(data, (_, value) =>
typeof value === "bigint" ? value.toString() : value
);
}Стратегия кеширования: что, как долго и когда инвалидировать#
Не все ончейн-данные имеют одинаковые требования к свежести. Вот иерархия, которую я использую:
Кешировать навсегда (неизменяемые):
- Квитанции транзакций (после майнинга они не меняются)
- Финализированные данные блоков (хеш блока, временная метка, список транзакций)
- Байткод контракта
- Исторические логи событий из финализированных блоков
Кешировать от минут до часов:
- Метаданные токенов (name, symbol, decimals) — технически неизменяемы для большинства токенов, но обновления прокси могут изменить реализацию
- ENS-резолвинг — TTL в 5 минут работает хорошо
- Цены токенов — зависит от ваших требований к точности, от 30 секунд до 5 минут
Кешировать на секунды или не кешировать вовсе:
- Текущий номер блока
- Балансы и nonce аккаунтов
- Статус ожидающих транзакций
- Нефинализированные логи событий (опять проблема реоргов)
Реализация не должна быть сложной. Двухуровневый кеш с in-memory LRU и Redis покрывает большинство случаев:
import { LRUCache } from "lru-cache";
const memoryCache = new LRUCache<string, unknown>({
max: 10_000,
ttl: 1000 * 60, // 1 минута по умолчанию
});
type CacheTier = "immutable" | "short" | "volatile";
const TTL_MAP: Record<CacheTier, number> = {
immutable: 1000 * 60 * 60 * 24, // 24 часа в памяти, постоянно в Redis
short: 1000 * 60 * 5, // 5 минут
volatile: 1000 * 15, // 15 секунд
};
async function cachedRpcCall<T>(
key: string,
tier: CacheTier,
fetcher: () => Promise<T>
): Promise<T> {
// Сначала проверяем память
const cached = memoryCache.get(key) as T | undefined;
if (cached !== undefined) return cached;
// Потом Redis (если есть)
// const redisCached = await redis.get(key);
// if (redisCached) { ... }
const result = await fetcher();
memoryCache.set(key, result, { ttl: TTL_MAP[tier] });
return result;
}
// Использование:
const receipt = await cachedRpcCall(
`receipt:${txHash}`,
"immutable",
() => client.getTransactionReceipt({ hash: txHash })
);Контринтуитивный урок: самый большой выигрыш в производительности — не кеширование RPC-ответов. Это полное избегание RPC-вызовов. Каждый раз, когда вы собираетесь вызвать getBlock, спросите себя: действительно ли мне нужны данные из цепи прямо сейчас, или я могу вывести их из данных, которые уже есть? Могу ли я слушать события через WebSocket вместо поллинга? Могу ли я собрать несколько чтений в один multicall?
TypeScript и ABI контрактов: правильный подход#
Система типов viem, построенная на ABIType, обеспечивает полный сквозной вывод типов от ABI вашего контракта до TypeScript-кода. Но только при правильной настройке.
Неправильный способ:
// Нет вывода типов — args это unknown[], return это unknown
const result = await client.readContract({
address: "0x...",
abi: JSON.parse(abiString), // парсинг в рантайме = нет информации о типах
functionName: "balanceOf",
args: ["0x..."],
});Правильный способ:
// Определяем ABI как const для полного вывода типов
const erc20Abi = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "balance", type: "uint256" }],
},
{
name: "transfer",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "success", type: "bool" }],
},
] as const;
// Теперь TypeScript знает:
// - functionName автодополняется до "balanceOf" | "transfer"
// - args для balanceOf это [address: `0x${string}`]
// - возвращаемый тип для balanceOf это bigint
const balance = await client.readContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: erc20Abi,
functionName: "balanceOf",
args: ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"],
});
// typeof balance = bigint -- полностью типизированУтверждение as const — это то, что всё заставляет работать. Без него TypeScript расширяет тип ABI до { name: string, type: string, ... }[], и вся машинерия вывода типов разрушается. Это самая распространенная ошибка, которую я вижу в Web3 TypeScript кодовых базах.
Для более крупных проектов используйте @wagmi/cli для генерации типизированных привязок контрактов непосредственно из вашего проекта Foundry или Hardhat. Он читает скомпилированные ABI и создает TypeScript-файлы с уже примененными утверждениями as const. Никакого ручного копирования ABI, никакого дрифта типов.
Неудобная правда#
Блокчейн-данные — это проблема распределенных систем, маскирующаяся под проблему баз данных. В тот момент, когда вы начинаете относиться к ним как к «ещё одному API», вы начинаете накапливать баги, невидимые при разработке и спорадические в продакшене.
Инструментарий стал значительно лучше. Viem — огромное улучшение по сравнению с ethers.js в плане типобезопасности и опыта разработки. Ponder и Envio сделали кастомное индексирование доступным. Но фундаментальные проблемы — реорги, rate-лимиты, кодирование, финальность — это проблемы уровня протокола. Ни одна библиотека не абстрагирует их полностью.
Стройте с допущением, что ваш RPC будет вам врать, ваши блоки будут реорганизовываться, ваши числа переполнятся, а ваш кеш подаст устаревшие данные. Затем обрабатывайте каждый случай явно.
Вот как выглядят ончейн-данные продакшен-уровня.