Перейти до вмісту
·11 хв читання

Ончейн-дані у продакшені: про що вам ніхто не розповість

Блокчейн-дані не є чистими, надійними чи простими. Ліміти RPC, реорганізації ланцюга, баги BigInt та компроміси індексації — важкі уроки зі створення реальних DeFi продуктів.

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

Існує така фантазія, що ончейн-дані за своєю природою заслуговують довіри. Незмінний леджер. Прозорий стан. Просто прочитай — і все готово.

Я теж у це вірив. А потім випустив DeFi дашборд у продакшен і три тижні з'ясовував, чому баланси токенів були неправильними, в історії подій були прогалини, а в базі даних містилися транзакції з блоків, які більше не існували.

Ончейн-дані — сирі, ворожі і повні крайніх випадків, які зламають ваш додаток так, що ви не помітите, поки користувач не подасть баг-репорт. Цей пост охоплює все, чого я навчився на власних помилках.

Ілюзія надійних даних#

Ось перше, чого вам ніхто не скаже: блокчейн не дає вам дані. Він дає вам переходи стану. Немає ніякого SELECT * FROM transfers WHERE user = '0x...'. Є логи, квитанції, слоти зберігання та трейси викликів — все закодоване у форматах, які потребують контексту для декодування.

Лог події Transfer дає вам from, to та value. Він не повідомляє символ токена. Не повідомляє кількість десяткових знаків. Не повідомляє, чи це легітимний переказ, чи fee-on-transfer токен, який знімає 3% зверху. Не повідомляє, чи цей блок існуватиме через 30 секунд.

"Незмінна" частина — правда, але тільки після фіналізації. А фіналізація не миттєва. І дані, які ви отримуєте від RPC-ноди, необов'язково з фіналізованого блоку. Більшість розробників запитують latest і вважають це істиною. Це баг, а не фіча.

Далі — кодування. Все в шістнадцятковому форматі. Адреси мають чексуму у змішаному регістрі (або не мають). Суми токенів — цілі числа, помножені на 10^decimals. Переказ 100 USDC в ончейні виглядає як 100000000, тому що USDC має 6 десяткових знаків, а не 18. Я бачив продакшен-код, який припускав 18 десяткових для кожного ERC-20 токена. Отримані баланси відрізнялися в 10^12 разів.

Ліміти RPC-запитів зіпсують вам вихідні#

Кожен продакшен Web3-додаток спілкується з RPC-ендпоінтом. І кожен RPC-ендпоінт має ліміти запитів, які значно жорсткіші, ніж ви очікуєте.

Ось числа, які мають значення:

  • Alchemy Free: ~30M обчислювальних одиниць/місяць, 40 запитів/хвилину. Це звучить щедро, поки ви не зрозумієте, що один виклик eth_getLogs на широкому діапазоні блоків може з'їсти сотні CU. Ви витратите місячну квоту за день індексації.
  • Infura Free: 100K запитів/день, приблизно 1.15 запитів/сек. Спробуйте пропагінувати 500K блоків логів подій з такою швидкістю.
  • QuickNode Free: аналогічно Infura — 100K запитів/день.

Платні тарифи допомагають, але не усувають проблему. Навіть за $200/місяць на плані Alchemy Growth, важке завдання індексації досягне лімітів пропускної здатності. І коли ви їх досягнете, ви не отримаєте граціозну деградацію. Ви отримаєте помилки 429 — іноді з неінформативними повідомленнями, іноді без заголовка retry-after.

Рішення — комбінація резервних провайдерів, логіки повторних спроб та дуже обдуманого підходу до того, які виклики ви робите. Ось як виглядає надійне налаштування RPC з viem:

typescript
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) при ввімкненому пакетному режимі. Якщо ви пакуєте запити, тестуйте поведінку повторних спроб явно. Не довіряйте, що це працює.

Реорганізації: баг, якого ви не побачите#

Реорганізація ланцюга відбувається, коли мережа тимчасово не погоджується щодо того, який блок є канонічним. Нода A бачить блок 1000 з транзакціями [A, B, C]. Нода B бачить інший блок 1000 з транзакціями [A, D]. Зрештою мережа сходиться, і одна версія перемагає.

На ланцюгах proof-of-work це було звичним — реорганізації на 1-3 блоки відбувалися кілька разів на день. Ethereum після злиття кращий. Успішна атака реорганізації тепер потребує координації близько 50% валідаторів. Але "кращий" — не "неможливий". Була помітна реорганізація на 7 блоків у Beacon Chain у травні 2022 року, спричинена неузгодженою реалізацією proposer boost fork у різних клієнтах.

І неважливо, наскільки рідкісні реорганізації на Ethereum mainnet. Якщо ви будуєте на L2 або сайдчейнах — Polygon, Arbitrum, Optimism — реорганізації частіші. Polygon історично мав реорганізації на 10+ блоків.

Ось практична проблема: ви проіндексували блок 18 000 000. Записали події в базу даних. Потім блок 18 000 000 зазнав реорганізації. Тепер у вашій базі є події з блоку, який не існує на канонічному ланцюзі. Ці події можуть посилатися на транзакції, які ніколи не відбулися. Ваші користувачі бачать фантомні перекази.

Виправлення залежить від вашої архітектури:

Варіант 1: затримка підтвердження. Не індексуйте дані, поки не пройде N блоків підтверджень. Для Ethereum mainnet 64 блоки (дві епохи) дають гарантії фінальності. Для L2 перевіряйте модель фінальності конкретного ланцюга. Це просто, але додає затримку — приблизно 13 хвилин на Ethereum.

Варіант 2: виявлення та відкат реорганізацій. Індексуйте агресивно, але відстежуйте хеші блоків. З кожним новим блоком перевіряйте, що хеш батьківського блоку збігається з попереднім проіндексованим блоком. Якщо ні — ви виявили реорганізацію: видаліть все з осиротілих блоків і переіндексуйте канонічний ланцюг.

typescript
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 пагінованих запитів. Кожен враховується в ліміті. І якщо будь-який виклик зазнає невдачі, вам потрібна логіка повторних спроб, відстеження курсору та спосіб відновлення з місця зупинки.

The Graph (сабграфи)#

The Graph був першим рішенням. Визначте схему, напишіть маппінги на AssemblyScript, розгорніть і робіть запити через GraphQL. Hosted Service було закрито — тепер все на децентралізованій мережі Graph Network, що означає оплату токенами GRT за запити.

Плюси: стандартизований, добре задокументований, велика екосистема існуючих сабграфів, які можна форкнути.

Мінуси: AssemblyScript болючий. Дебаг обмежений. Деплой займає хвилини або години. Якщо у вашому сабграфі баг, ви перерозгортаєте і чекаєте, поки він пересинхронізується з нуля. Децентралізована мережа додає затримку, і іноді індексери відстають від кінчика ланцюга.

Я використовував The Graph для дашбордів з великою кількістю читань, де свіжість даних у 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 блоків. Ваш код має обробляти все це.

Існують ліміти розміру відповіді. Навіть у межах діапазону блоків, якщо популярний контракт генерує тисячі подій на блок, ваша відповідь може перевищити ліміт корисного навантаження провайдера (150 МБ на Alchemy). Виклик не повертає часткових результатів. Він зазнає невдачі.

Порожні діапазони не безкоштовні. Навіть якщо відповідних логів нуль, провайдер все одно сканує діапазон блоків. Це враховується в обчислювальних одиницях.

Ось утиліта пагінації, яка обробляє ці обмеження:

typescript
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, драйвер бази даних, бібліотека логування — ви отримуєте тиху втрату точності. Число виглядає приблизно правильним, але останні кілька цифр неправильні. Ви не зловите це в тестуванні, бо ваші тестові суми малі.

Ось баг, який я випустив у продакшен:

typescript
// БАГ: Виглядає нешкідливо, але ні
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:

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

typescript
// Серіалізатор відповіді API
function serializeForApi(data: Record<string, unknown>): string {
  return JSON.stringify(data, (_, value) =>
    typeof value === "bigint" ? value.toString() : value
  );
}

Стратегія кешування: що, як довго та коли інвалідувати#

Не всі ончейн-дані мають однакові вимоги до свіжості. Ось ієрархія, яку я використовую:

Кешувати назавжди (незмінне):

  • Квитанції транзакцій (після майнінгу вони не змінюються)
  • Фіналізовані дані блоків (хеш блоку, мітка часу, список транзакцій)
  • Байткод контрактів
  • Історичні логи подій з фіналізованих блоків

Кешувати від хвилин до годин:

  • Метадані токенів (назва, символ, десяткові знаки) — технічно незмінні для більшості токенів, але оновлення проксі можуть змінити реалізацію
  • ENS-резолюції — TTL 5 хвилин працює добре
  • Ціни токенів — залежить від вимог точності, від 30 секунд до 5 хвилин

Кешувати секунди або не кешувати:

  • Поточний номер блоку
  • Баланси рахунків та nonce
  • Статус незавершених транзакцій
  • Нефіналізовані логи подій (знову проблема реорганізацій)

Реалізація не повинна бути складною. Двохрівневий кеш з in-memory LRU та Redis покриває більшість випадків:

typescript
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-коду. Але тільки якщо ви налаштуєте це правильно.

Неправильний спосіб:

typescript
// Немає виведення типів — args це unknown[], повернення unknown
const result = await client.readContract({
  address: "0x...",
  abi: JSON.parse(abiString), // парситься в рантаймі = немає інформації про типи
  functionName: "balanceOf",
  args: ["0x..."],
});

Правильний спосіб:

typescript
// Визначте 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 зробили кастомну індексацію доступною. Але фундаментальні виклики — реорганізації, ліміти запитів, кодування, фінальність — це рівень протоколу. Жодна бібліотека їх не абстрагує.

Будуйте з припущенням, що ваш RPC бреше вам, ваші блоки реорганізуються, ваші числа переповнюються, а ваш кеш подає застарілі дані. І обробляйте кожен випадок явно.

Ось так виглядають ончейн-дані продакшен-рівня.

Схожі записи