Блокчейн
Транзакции и receipts в Ethereum
Когда вы нажимаете «Отправить» в MetaMask, за кулисами происходит удивительная цепочка: ваша транзакция кодируется в формат, изобретённый специально для Ethereum, подписывается криптографической подписью, проходит через mempool, исполняется виртуальной машиной, генерирует квитанцию с доказательством результата и встраивается в дерево Merkle. Всё это занимает 12 секунд. Но что именно происходит внутри этих 12 секунд? Как транзакция перевода 1 ETH отличается от вызова Uniswap swap? И как ваш кошелёк за миллисекунды находит все ваши Transfer-события среди триллиона логов, не скачивая весь блокчейн?
- **EIP-1559 и сжигание ETH** - с августа 2021 года каждая транзакция Type 2 сжигает baseFee, уничтожая ETH. За первые два года сожжено более 3.5 миллионов ETH (~6B). Понимание структуры транзакций объясняет, почему ETH может быть дефляционным активом
- **Etherscan и индексаторы** - когда вы видите историю транзакций на Etherscan, сервис использует Bloom-фильтры для быстрого поиска event logs в миллионах блоков. The Graph индексирует события для dApp-ов: каждый Uniswap swap, каждый NFT mint - это event log, найденный через Bloom
- **Layer 2 rollups (Optimism, Arbitrum)** - EIP-4844 blob-транзакции снизили стоимость публикации данных rollups в 10-100 раз. Понимание типов транзакций объясняет, почему L2-транзакции стали стоить доли цента
Предварительные знания
Типы транзакций Ethereum
Транзакция в Ethereum - это **единственный способ изменить состояние** блокчейна. Ни один баланс не сдвинется, ни один контракт не исполнится без подписанной транзакции. С момента запуска сети формат транзакций эволюционировал: от единственного legacy-формата до четырёх различных типов, каждый из которых решает конкретную проблему.
Каждая транзакция, независимо от типа, содержит набор **обязательных полей**. Рассмотрим структуру на примере самого распространённого Type 2 (EIP-1559):
Поле **nonce** - это счётчик транзакций аккаунта, начиная с 0. Каждая новая транзакция должна иметь nonce ровно на 1 больше предыдущей. Это предотвращает replay attacks (повторную отправку той же транзакции) и гарантирует строгий порядок обработки транзакций от одного аккаунта.
Ключевое различие Type 0 и Type 2 - в модели ценообразования gas:
**Type 3 (blob-транзакции)** принципиально отличаются: blob-данные хранятся **вне execution layer** и автоматически удаляются через ~18 дней. Rollups используют их вместо calldata, что снижает стоимость публикации данных в 10-100 раз. Каждый blob - это ~128 KB данных с собственным рынком gas (blob gas), независимым от основного.
В чём главное отличие EIP-1559 (Type 2) транзакции от legacy (Type 0) с точки зрения распределения fee?
RLP: сериализация данных Ethereum
Когда транзакция подписана и готова к отправке в сеть, её нужно превратить в последовательность байтов. Ethereum использует для этого **RLP (Recursive Length Prefix)** - собственный формат сериализации, выбранный за его **детерминизм** и **простоту**. В отличие от JSON или Protobuf, RLP гарантирует, что одни и те же данные всегда дадут один и тот же байтовый результат.
**Детерминизм** критически важен для блокчейна. Если два узла закодируют одну и ту же транзакцию по-разному (например, JSON позволяет разный порядок ключей), то их хеши не совпадут, и консенсус станет невозможен. RLP устраняет эту проблему: формат настолько жёсткий, что каноническое представление единственно.
RLP кодирует только два примитива: **строки** (байтовые последовательности) и **списки** (вложенные структуры). Все данные Ethereum - числа, адреса, хеши - представляются как байтовые строки. Правила кодирования:
Посмотрим, как простой перевод ETH кодируется в RLP:
Для типов транзакций 1, 2, 3 используется **typed envelope** (EIP-2718): закодированная транзакция начинается с байта типа (`0x01`, `0x02`, `0x03`), за которым следует RLP payload. Legacy-транзакции (Type 0) не имеют префикса - их RLP начинается напрямую с `0xf8..` (начало длинного списка).
Ethereum постепенно переходит от RLP к более эффективному формату **SSZ (Simple Serialize)**, который используется в Beacon Chain (Proof of Stake). SSZ поддерживает фиксированную и переменную длину, типы данных, и оптимизирован для Merkle-доказательств. Однако execution layer по-прежнему использует RLP, и полный переход на SSZ - задача будущих хардфорков.
Почему Ethereum использует RLP вместо широко распространённых форматов вроде JSON или Protobuf?
Event Logs и Bloom-фильтры
Когда смарт-контракт хочет сообщить внешнему миру о произошедшем событии, он использует **event logs** - специальный механизм, реализованный через opcodes `LOG0`-`LOG4`. Логи не хранятся в state trie (они недоступны из других контрактов), но записываются в блокчейн и индексируются для быстрого поиска через **Bloom-фильтры**.
В Solidity event logs объявляются через ключевое слово `event`, а параметры с `indexed` попадают в topics:
Теперь главный вопрос: как найти все Transfer-события среди миллионов блоков, не скачивая каждый? Здесь помогает **Bloom-фильтр** - вероятностная структура данных, встроенная в заголовок каждого блока.
На практике dApp-ы используют библиотеки вроде **ethers.js** для подписки на события:
Event logs **нельзя прочитать из смарт-контракта**. Они записываются через LOG opcodes, но в EVM нет opcode для чтения логов. Логи предназначены исключительно для внешних наблюдателей (dApp-ы, индексаторы вроде The Graph, аналитические сервисы). Если контракту нужны данные - используйте storage.
Bloom-фильтр в заголовке блока Ethereum показывает, что в блоке ВОЗМОЖНО есть Transfer-событие от адреса Alice. Что это означает?
Transaction Receipts и Receipt Trie
После исполнения каждой транзакции EVM создаёт **transaction receipt** - квитанцию, фиксирующую результат. Receipt содержит статус (успех/revert), фактический расход gas, все event logs и Bloom-фильтр этой конкретной транзакции. Receipts не отправляются - они **вычисляются каждым узлом** на основе исполнения транзакций.
Все receipts блока объединяются в **Receipt Trie** - Merkle Patricia Trie, корень которого записывается в заголовок блока как `receiptsRoot`. Вместе с `transactionsRoot` и `stateRoot` это создаёт систему криптографических доказательств:
Receipt Trie позволяет **light clients** (кошельки, мобильные приложения) проверить результат транзакции, не скачивая весь блокчейн:
**logsBloom в заголовке блока** - это побитовое OR всех Bloom-фильтров отдельных receipts. Это позволяет за одну проверку (256 байт) определить, может ли блок содержать интересующее событие. Если бит не установлен в logsBloom блока - ни одна транзакция блока не содержит этого события, гарантированно.
Receipt **не содержит return data** функции (`returns (uint256)`). Если вы вызвали `transfer()` и хотите узнать возвращённое значение - receipt покажет только status (1 или 0) и event logs. Return data доступна только в момент исполнения (для вызывающего контракта через RETURNDATACOPY) или при симуляции через `eth_call`.
Transaction receipt - это подтверждение от сети, отправляемое пользователю после включения транзакции в блок
Receipt - это структура данных, которую каждый узел ВЫЧИСЛЯЕТ локально при исполнении транзакции. Никто никому ничего не «отправляет» - любой узел, исполнивший транзакцию, получит идентичный receipt. Его корректность гарантирована включением в Receipt Trie с receiptsRoot в заголовке блока
Это заблуждение переносит ментальную модель из Web2 (сервер отправляет ответ клиенту) на блокчейн. В действительности блокчейн - это реплицированная state machine: все узлы исполняют одни и те же транзакции и независимо приходят к одному результату. Receipt - не ответ сервера, а доказуемый артефакт исполнения.
Light client хочет проверить, что транзакция в блоке #19000000 завершилась успешно. Какие данные ему необходимы?
Итоги
- **4 типа транзакций**: Legacy (Type 0) с фиксированным gasPrice, EIP-2930 (Type 1) с access lists, EIP-1559 (Type 2) с baseFee + priorityFee и сжиганием, EIP-4844 (Type 3) с blob-данными для rollups
- **RLP - детерминированная сериализация** Ethereum: кодирует только строки и списки, гарантирует единственное каноническое представление данных. Это критично для совпадения хешей на всех узлах сети
- **Event logs (LOG0-LOG4)** - способ контрактов уведомлять внешний мир. Topics (до 4) индексируются, data - нет. Логи нельзя прочитать из контракта - они предназначены для dApp-ов и индексаторов
- **Bloom-фильтр (2048 бит)** в заголовке блока позволяет за одну проверку исключить блок из поиска. False negative невозможен, false positive ~0.1%. Это даёт кошелькам скорость поиска событий среди миллионов блоков
- Итого: от нажатия «Отправить» до финализации транзакция проходит путь RLP-кодирование → подпись → исполнение → receipt → Receipt Trie → receiptsRoot в заголовке блока - и именно эта цепочка позволяет кошельку за миллисекунды доказать результат без скачивания всего блокчейна
Связанные темы
Транзакции - центральный механизм Ethereum, связывающий аккаунты, gas, EVM и масштабирование:
- Ethereum: аккаунты и state — Транзакция изменяет nonce отправителя, балансы EOA/контрактов и storage - все данные, хранящиеся в State Trie
- Gas: экономика вычислений — gasLimit, maxFeePerGas, priorityFee - поля транзакции, определяющие стоимость исполнения. Receipt фиксирует фактический gasUsed
- EVM: виртуальная машина — EVM исполняет транзакцию: bytecode из calldata, opcodes LOG0-LOG4 для event logs, SLOAD/SSTORE для изменения state
- Rollups: масштабирование — EIP-4844 blob-транзакции - ключевой механизм удешевления L2 rollups, публикующих данные на L1
Вопросы для размышления
- Почему Ethereum решил сжигать baseFee (EIP-1559) вместо того, чтобы отдавать всю плату валидаторам? Какие экономические и безопасностные последствия это создаёт?
- Bloom-фильтр допускает false positive. Как бы изменилась производительность поиска логов, если бы вместо Bloom использовался точный индекс (без false positive)? Какой компромисс выбрал Ethereum?
- Receipt не содержит return data функции. Почему это дизайн-решение, а не ограничение? Как dApp-ы обходят это (подсказка: event logs и eth_call)?