К основному содержимому

Введите слово из вопроса или названия статьи.

3 · Под капотом

События и логи: eth_getLogs, bloom-фильтры и ограничения публичных RPC

Как работают события контрактов и eth_getLogs, почему публичные узлы режут диапазон блоков и как строить историю сделок без собственной базы.

4 мин чтенияОбновлено:
В этой статье

У смарт-контракта есть два способа сообщить миру, что произошло: изменить состояние (storage) и записать событие (event). Состояние показывает, «как сейчас». События рассказывают, «как к этому пришли». Для интерфейсов, показывающих историю сделок, именно события - основной источник данных.

Что такое событие

Событие - это запись в журнале транзакции. Контракт делает emit DisputeOpened(dealId, by, at), и в квитанции транзакции появляется лог: адрес контракта, набор топиков и данные. Структуру лога разобрали в статье про ABI: topic0 - идентификатор события, индексированные аргументы - следующие топики, остальное - data.

Важные свойства событий:

  • Дёшево. Запись события в разы дешевле записи в storage.
  • Недоступно контракту. Контракт не может прочитать свои или чужие события - они нужны только внешнему миру.
  • Постоянно. Однажды записанный лог остаётся в истории цепочки.
  • Привязано к транзакции. Откатилась транзакция - откатились и её события.

Читаем события: eth_getLogs

Интерфейс получает события от узла запросом eth_getLogs с фильтром:

json
{
  "address": "0x6462DDDc0a3D893A7C17f94410e2d27162a390aD",
  "fromBlock": "0x…",
  "toBlock": "0x…",
  "topics": [
    ["0x2241d0fd…", "0xd716fe62…"],
    "0x0000…0007"
  ]
}

Здесь topics[0] - список интересующих событий (допускается несколько вариантов), topics[1] - значение первого индексированного параметра, например номер сделки. Фильтр по адресу и топикам - это и есть причина, по которой индексируют параметры: так узел находит нужное быстро.

В ответ приходит список логов с номером блока, хешем транзакции, топиками и данными. Дальше интерфейс декодирует их по ABI и строит ленту: «создана», «принята», «открыт спор», «завершена».

Как узел ищет быстро: bloom-фильтры

Просматривать все блоки подряд было бы мучительно медленно. В заголовок каждого блока записывается bloom-фильтр - компактная структура, которая позволяет быстро ответить «в этом блоке точно нет событий этого контракта с этим топиком» или «возможно, есть». Узел отбрасывает пустые блоки и проверяет только вероятные. Фильтр допускает ложные срабатывания, но никогда не пропускает настоящее событие.

Практическая проблема: лимиты публичных узлов

Здесь начинается то, о чём редко пишут в учебниках. Публичные RPC-узлы бесплатны, поэтому защищаются от дорогих запросов. Запрос логов за миллионы блоков - именно такой. Из нашего опыта подключения четырёх сетей:

  • Некоторые узлы ограничивают диапазон блоков за один запрос - от 2 000 до 50 000, зависит от сети и поставщика.
  • Некоторые вообще не отдают логи (например, один из публичных узлов BNB Chain).
  • Самое коварное: отдельные узлы при слишком широком диапазоне отвечают не ошибкой, а пустым списком. Интерфейс видит «событий нет» и показывает пустую историю, хотя сделки есть.
  • Публичные узлы часто не хранят старое состояние: запросить «как было 100 000 блоков назад» нельзя.

Из этого следует несколько правил для разработчика.

Правило 1. Не доверяйте пустому ответу

Пустой список на широкий диапазон может означать «нет событий», а может - «узел отказался». Проверяйте на заведомо известном событии при настройке нового узла, а в коде не принимайте решение «событий нет» по одному запросу.

Правило 2. Двигайтесь окнами и адаптируйтесь

Если нужны последние события, не запрашивайте всю историю. Идите от свежих блоков к старым окнами, и если узел вернул ошибку лимита, уменьшайте окно вдвое. Остановитесь, когда нашли достаточно данных или упёрлись в блок развёртывания контракта. Вот схема такого обхода:

ts
// от свежих блоков к старым, окно сжимается при ошибке
let to = latest;
let span = 50_000n;
while (to > deployBlock && found.length < needed && budget-- > 0) {
  const from = to - span < deployBlock ? deployBlock : to - span;
  try {
    found.push(...(await getLogs({ fromBlock: from, toBlock: to })));
    to = from - 1n;
  } catch {
    span /= 2n;            // узел отказал - сузим окно
    if (span < 1000n) break;
  }
}

Правило 3. Знайте блок развёртывания

Нет смысла просматривать блоки до момента, когда контракт появился. Блок развёртывания - параметр конфигурации, который ограничивает поиск.

Правило 4. Не заводите лишнего: счётчик часто лучше логов

Для списка «сделки пользователя» контракту удобно хранить счётчик dealCount. Пока сделок немного, их можно перебрать по номерам через пакетное чтение состояния (Multicall) - это не требует логов вообще. Логи подключаются, когда сделок слишком много для перебора. Мы используем именно такую гибридную схему: она работает даже на узлах, которые не отдают логи.

Когда логов недостаточно: индексаторы

Для больших объёмов и сложных запросов («все сделки пользователя за год, отсортированные по сумме») нужен индексатор - отдельный сервис, который один раз читает все события в собственную базу и отвечает на запросы быстро. Примеры: The Graph, Ponder, самописные решения. Он не доверяется слепо: данные в нём - копия цепочки, а источником истины остаётся блокчейн, и важные действия следует перепроверять чтением контракта напрямую.

Реорганизации цепочки

Недавние блоки могут быть переписаны (reorg): сеть изменила мнение о последних блоках, и ваша транзакция оказалась в другом блоке или в никаком. Поэтому интерфейсы не считают событие окончательным, пока не прошло достаточно подтверждений, а индексаторы умеют «откатывать» последние блоки.

Что почитать дальше

Теперь, когда понятно, как данные попадают из блокчейна в интерфейс, пора написать собственный контракт: ERC-20 с нуля. Дальше - как подписи работают в контрактах: ECDSA и EIP-712, и затем конечный автомат эскроу на Solidity.

Упоминается в статьях

Готовы попробовать?

Создайте первую сделку за пару минут. Деньги блокируются в смарт-контракте и выплачиваются по правилам, которые видны заранее.