Привет, Хабр!

Сегодня поговорим о событиях в Solidity — одном из важных механизмов, с помощью которого смарт‑контракты общаются с внешним миром. Если вы интересиовались разработкой на Ethereum, то наверняка слышали про события: например, каждый токен ERC-20 при трансфере генерирует событие Transfer, благодаря чему кошельки и блоксканеры сразу видят движение токенов. Но что же такое events, как они работают и как их правильно использовать?

Зачем нужны события на блокчейне

Начнём с концепции. При разработке обычного приложения мы можем выводить логи, печатать отладочные сообщения, вызывать callback, делать всё, чтобы сообщить о произошедшем. В смарт‑контрактах Ethereum всё иначе, контракт не может напрямую вернуть данные наружу после выполнения. Нельзя просто вызвать print() или вернуть из транзакции результат, транзакция либо успешно прошла (изменив состояние блокчейна), либо откатилась.

Как же внешние системы узнают о том, что произошло внутри контракта?

Для этого и существуют события. Solidity предоставляет имеет слово event для определения события внутри контракта. Когда в коде эмитится событие, Ethereum‑запись транзакции получает специальную запись лог. Логи хранятся в блокчейне наряду с транзакциями, но отдельно от основного состояния. В них можно записать некоторую информацию, и потом внешнее приложение может эти логи читать и фильтровать. Проще говоря, события такая вот обёртка над механизмом логов Ethereum.

Когда стоит использовать события? Каждый раз, когда смарт‑контракт совершает что‑то, о чём полезно узнать вне блокчейна.

  • Изменение важного состояния.

  • Административные действия.

  • Данные для UI или аналитики.

События служат уведомлениями. Внутри сети Ethereum они не участвуют в вычислениях, но оффчейн мир их слушает и реагирует.

Объявление и генерация события в Solidity

Перейдём к синтаксису и практике.

Объявить событие в Solidity очень просто, почти как структуру данных. Используется ключевое слово event. Пример:

pragma solidity ^0.8.0;

contract MyContract {
    event ValueChanged(address indexed user, uint oldValue, uint newValue);

    uint public value;

    function updateValue(uint _newValue) public {
        uint _oldValue = value;
        value = _newValue;
        // Эмитим событие, чтобы внешние слушатели узнали о смене значения
        emit ValueChanged(msg.sender, _oldValue, _newValue);
    }
}

Объявили событие ValueChanged с тремя параметрами: user (адрес пользователя), oldValue и newValue.

В функции updateValue после изменения значения переменной мы вызываем emit ValueChanged(...). Синтаксис прост, пишем emit, имя события и передаём параметры. В результате при каждом вызове updateValue контракт будет генерировать лог‑событие, которое содержит:

  • Адрес контракта.

  • Имя события.

  • Указанные три параметра: конкретный адрес пользователя, старое значение, новое значение.

Внутри блокчейна событие попадёт в лог транзакции. Если транзакция откатится, то и событие не сохранится, логи пишутся только при успешном выполнении. Если вызов updateValue(42) прошёл, то можно найти в блокчейне лог события ValueChanged с нужными данными.

Чтение событий снаружи

Как же внешнее приложение получит эти данные? Обычно с помощью веб3 библиотек или API нодов, Например, на Web3.js это делается так:

const contract = new web3.eth.Contract(abi, contractAddress);
contract.events.ValueChanged({ filter: { user: SOME_ADDRESS } })
    .on('data', event => {
        console.log("Caught event:", event.returnValues);
    });

Этот код подписывается на события ValueChanged нашего контракта и будет выводить в консоль event.returnValues, когда событие происходит. Метод contract.events.ValueChanged() настраивает фильтр, здесь мы, например, могли указать filter: {user: SOME_ADDRESS} чтобы ловить события только от конкретного пользователя. Если фильтр не нужен, можно оставить пустым объектом.

В результате при вызове updateValue на контракте, наш JS‑листенер получит объект события, где внутри returnValues будет что‑то вроде:

{
  "user": "0x1234...abcd",
  "oldValue": "100",
  "newValue": "42"
}

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

Топики и indexed: как фильтруются события

Когда мы объявляем событие, Solidity определяет для него так называемую сигнатуру события строку вида "ValueChanged(address,uint256,uint256)" и вычисляет от неё хеш Keccak-256. Этот хеш служит ид��нтификатором события. При каждом эмите Ethereum формирует запись в логах, состоящую из:

  • Topics — до 4 значений типа bytes32.

  • Data — произвольные данные, закодированные в виде ABI.

По стандарту Solidity:

  • Первый топик (topics[0]) содержит хеш сигнатуры события, если событие не объявлено как anonymous. То есть для нашего ValueChanged первый топик будет keccak256("ValueChanged(address,uint256,uint256)").

  • Далее, если какие‑то параметры события помечены как indexed, каждый такой параметр занимает по одному топику (в порядке объявлении).

  • Параметры, не помеченные indexed, не попадают в топики, они идут в секцию Data.

Всего максимальное число топиков — 4. Один из них обычно уходит под сигнатуру, остаётся три для индексированных полей. Поэтому Solidity разрешает отметить indexed не более трех параметров события. Если сделать событие anonymous, то сигнатура не будет добавлена как первый топик, тогда разработчик может использовать все 4 топика под свои параметры. Но об anonymous чуть позже.

Что дают эти топики? Фильтрацию событий. Когда внешняя программа или блоксканер хочет найти определённые события, она может у ноды Ethereum запросить: дай мне логи с таким‑то адресом контракта, таким‑то первым топиком, и, например, вторым топиком равным X. Нода пройдётся по блокам и выберет только подходящие.

Допустим, наш контракт выпускает событие ValueChanged(user, old, new). Мы хотим найти все случаи, где user = 0xABC.... Если user помечен indexed (а мы так и сделали), то можно сделать RPC‑запрос фильтра по то��ику[1] = 0×000...ABC (адрес пользователя). Нода мгновенно вернёт только события этого пользователя, не перебирая все транзакции. Очень круто и полезно. Именно поэтому обычно адреса участников в событиях помечают indexed. В ERC-20 Transfer событии оба адреса (from и to) индексированы, чтобы можно было фильтровать переводы по отправителю или получателю.

В нашем JS‑примере выше { filter: { user: SOME_ADDRESS } } как раз и задействует топики, web3 сформирует запрос с нужным топиком для user. Можно и вручную вызывать web3.eth.getPastLogs с указанием массива топиков.

Anonymous events

Solidity позволяет объявлять событие с модификатором anonymous. Это редкий случай, но для полноты знаний упомянем. Anonymous‑событие — это событие, у которого не будет первого топика с сигнатурой. То есть вообще никак не кодируется тип события. Зачем такое может понадобиться?

Во‑первых, это чуть дешевле по газу. Эмитить событие без сигнатуры требует записать на один хеш меньше (32 байта) — экономия 8 газ за байт хранения в логах плюс немного на вычислении. Также дешевле деплой контракта, информация о событии короче, хотя там выгода совсем копейки.

Однако у anonymous events есть серьёзный минус. Нельзя легко отфильтровать по типу события. В логе не будет явного указания, что это за событие, только адрес контракта. Теоретически, зная, что контракт мог эмитить только такой вид события, клиент может обработать. Но если в контракте несколько anonymous‑событий разных типов, то их приходится различать по структуре данных вручную. В общем, обычно anonymous не используются.

В 99% случаев вам хватит обычных событий. Про anonymous просто знайте, что они есть.

Cобытия в ERC-20

Для закрепления, взглянем на знакомый многим пример — стандарт токена ERC-20. В упрощённом виде там объявлены два события:

event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);

Контракт вызывает emit Transfer(...) каждый раз, когда токены переводятся (методы transfer и transferFrom). И вызывает emit Approval(...) когда кто‑то устанавливает разрешение (approve) на расходование своих средств другим. Каждый блокчейн‑обозреватель при отображении балансов токена не будет опрашивать 100 тысяч адресов методами, он просто слушает события Transfer и обновляет у кого сколько. Аналогично, ваш кошелёк, когда вы делаете approve, может отследить событие Approval и отобразить, что теперь DApp такой‑то может тратить ваши токены.

Оба адреса from и to в Transfer помечены indexed. Это сделано, чтобы можно было фильтровать события по любому из участников. Именно поэтому на EtherScan легко найдёте все переводы, где вы были получателем, система делает фильтр по вашему адресу. Value не индексируется. События не объявлены anonymous.

Также стоит упомянуть, что задавать события в интерфейсах — обычное дело. Интерфейс IERC20 содержит объявление этих events, чтобы другие контракт могли ожидать их. События — часть контракта, и их можно наследовать, переопределять, хотя это редкость.

Если тема событий, логов и оффчейн-интеграций вам откликнулась, логичное продолжение — системно разобраться в Solidity. Курс Solidity Developer фокусируется на актуальной версии языка, архитектуре смарт-контрактов, безопасности, тестировании и работе с экосистемой Ethereum — от EIP до практики с OpenZeppelin и Hardhat. Пройдите входное тестирование, чтобы узнать, подойдет ли вам программа.

Для знакомства с форматом обучения и экспертами приходите на бесплатные демо-уроки:

  • 15 января, 20:00. «Инструменты и полный цикл разработки dApp на Solidity». Записаться

  • 21 января, 20:00. «Solidity & Web3. Первый шаг к пониманию блокчейна и карьере в цифровой экономике». Записаться