ABI: как вызов функции превращается в байты - селекторы, calldata и события
Как кодируются вызовы смарт-контрактов: селектор функции, выравнивание аргументов, динамические типы, пользовательские ошибки и топики событий на реальных примерах.
В этой статье
Контракт - это байты, и вызывать его приходится байтами. ABI (Application Binary Interface) - договорённость о том, как превратить «вызови функцию transfer с такими-то аргументами» в последовательность байтов, понятную и интерфейсу, и контракту. Знание ABI превращает загадочную строку 0xa9059cbb000… в читаемый вызов.
Селектор функции
Контракт получает на вход один блок байтов (calldata). Как он понимает, какую функцию вызвали? По первым четырём байтам - селектору.
Селектор - первые 4 байта хеша keccak256 от сигнатуры функции: имя и типы аргументов без пробелов.
сигнатура: transfer(address,uint256)
keccak256: a9059cbb2ab09eb219583f4a59a5d0623ade346d962bcd4e46b11da047c9049b
селектор: 0xa9059cbbЭто один из самых известных селекторов: именно с него начинается каждый перевод токена ERC-20. Имена аргументов в сигнатуре не участвуют - только типы.
Контракт начинает исполнение со сравнения селектора со списком известных функций и переходит к нужной. Вот несколько селекторов нашего эскроу:
| Функция | Селектор |
|---|---|
confirmDelivery(uint256) | 0xfd84cb97 |
acceptOffer(uint256,bytes32) | 0x72dfe66b |
createOffer((bool,address,address,uint16,uint16,address,uint256,uint8,bytes32)) | 0xd96ef60e |
Заметьте: вызов createOffer принимает структуру - в сигнатуре она записывается как кортеж в скобках.
Кодирование аргументов
После селектора идут аргументы. Правило для простых типов: каждый занимает 32 байта, то есть выравнивается до слова.
Вызов transfer(0x6462…90aD, 1000000) - это 4 + 32 + 32 = 68 байт:
0xa9059cbb // селектор
0000000000000000000000006462dddc0a3d893a7c17f94410e2d27162a390ad // адрес, дополненный нулями слева
00000000000000000000000000000000000000000000000000000000000f4240 // 1000000 в шестнадцатеричнойАдрес - 20 байт, поэтому слева дополняется 12 нулевыми байтами. Число дополняется нулями слева, потому что хранится как uint256 в формате big-endian. Видите f4240? Это 1 000 000: для USDT с 6 знаками - ровно один токен.
Структуры и кортежи
Структура, состоящая из статических типов (числа, адреса, bool, bytes32), кодируется «в линию»: поля идут друг за другом по 32 байта. Параметры createOffer - девять полей, значит, 9 × 32 = 288 байт после селектора:
asBuyer, counterparty, arbiter, arbiterBaseFeeBps, arbiterDisputeFeeBps,
token, amount, durationPreset, termsHashКаждое поле дополнено до слова: bool - это 32 байта, где последний равен 0 или 1; uint16 со значением 100 - это 31 нулевой байт и байт 0x64.
Динамические типы
Строки, bytes и массивы имеют переменную длину, поэтому кодируются иначе: в «голове» хранится смещение (offset) до данных, а сами данные лежат в «хвосте»: сначала длина, потом содержимое, дополненное до границы слова.
Для функции f(uint256 a, string s) вызов выглядит так: селектор, затем a, затем смещение до s (0x40 - сразу после двух слов головы), и далее в хвосте длина строки и её байты. Декодировать такое вручную неудобно, поэтому есть библиотеки (viem, ethers), которые берут ABI в формате JSON и делают всё сами.
Файл ABI
ABI в формате JSON - это описание всех функций, событий и ошибок контракта: имена, типы, входы, выходы, «изменяемость» (view, payable). Именно он позволяет интерфейсу превратить confirmDelivery(7) в нужные байты, а ответы - обратно в числа. Если ABI не совпадает с контрактом, вызов либо не пройдёт, либо сделает не то. Поэтому в проекте ABI сгенерирован из скомпилированного контракта и не пишется вручную.
Ошибки
Пользовательские ошибки (error TokenNotAllowed(address token);) тоже кодируются: 4 байта селектора ошибки плюс аргументы. Для TokenNotAllowed(address) это 0x51c9316d. Когда транзакция откатывается, узел возвращает эти байты, и интерфейс по селектору находит человеческий текст: «Эта валюта не разрешена в сети».
Так интерфейс превращает технический revert в понятную подсказку, а не в «execution reverted».
События и топики
Событие записывается в журнал транзакции как лог. Его устройство: topic0 - это keccak256 от сигнатуры события; следующие топики - значения параметров, помеченных как indexed; остальное - в поле data.
Для нашего события о завершении сделки:
DealSettled(uint256 indexed dealId, uint8 indexed outcome,
uint256 buyerAmount, uint256 sellerAmount,
uint256 arbiterFee, uint256 platformFee)
topic0 = 0xd716fe621ea710daf4a2c1145a1bc9d712441578dcb8c686ed242ea2590e7789
topic1 = dealId (индексирован)
topic2 = outcome (индексирован)
data = buyerAmount ‖ sellerAmount ‖ arbiterFee ‖ platformFeeИндексированные параметры можно искать: «покажи все события DealSettled для сделки 7» - это запрос по топику. Неиндексированные данные искать нельзя, зато они дешевле. Такой компромисс разработчик продумывает заранее: индексируют то, по чему будут искать. Как это используется для построения истории сделки - в статье про события и логи.
Инструменты для проверки
cast sig "transfer(address,uint256)"- селектор по сигнатуре (инструмент Foundry).cast 4byte 0xa9059cbb- поиск имени по селектору в публичной базе.- Decode Input Data в обозревателе - готовая расшифровка.
- viem в TypeScript:
encodeFunctionData,decodeFunctionData,toFunctionSelector.
Почему это важно и для безопасности
Селекторы - 4 байта, поэтому возможны коллизии: разные функции с одинаковым селектором. Для обычного контракта это не проблема (компилятор сообщит), но в прокси-архитектурах такие совпадения использовались для атак. Другой вывод проще: подписывая транзакцию в кошельке, вы подписываете эти самые байты. Если кошелёк умеет расшифровывать ABI - читайте расшифровку, если не умеет - это повод насторожиться.
Что почитать дальше
Разобравшись в байтах, перейдём к важной практике: как читать накопленные события, не потеряв деньги и нервы на ограничениях публичных узлов: события, логи и индексация.
Читайте также
- 3 · Под капотом
События и логи: eth_getLogs, bloom-фильтры и ограничения публичных RPC
Как работают события контрактов и eth_getLogs, почему публичные узлы режут диапазон блоков и как строить историю сделок без собственной базы.
4 мин чтения - 3 · Под капотом
EVM изнутри: стек, память, storage, calldata и стоимость газа
Как виртуальная машина Ethereum исполняет смарт-контракт: стековая архитектура, области данных, опкоды, цена операций и почему упаковка storage экономит деньги.
4 мин чтения
Упоминается в статьях
- Хеш-функции простыми словами: отпечаток данных, который нельзя подделать
- Словарь терминов: блокчейн, кошельки, смарт-контракты и эскроу от A до Я
- Анатомия транзакции: nonce, газ, mempool и подтверждения
- Хеш-обязательство: как зафиксировать условия сделки, не раскрывая их
- EVM изнутри: стек, память, storage, calldata и стоимость газа
- События и логи: eth_getLogs, bloom-фильтры и ограничения публичных RPC
- ERC-20 с нуля: пишем токен на Solidity и разбираем каждую строку
- Пишем эскроу на Solidity: конечный автомат и разбор реального контракта
- Прокси и обновляемые контракты: delegatecall, UUPS и цена гибкости
- Оптимизация газа в Solidity: 12 приёмов и когда они уместны
- Foundry для начинающих: компиляция, тесты, фаззинг и локальная сеть
Готовы попробовать?
Создайте первую сделку за пару минут. Деньги блокируются в смарт-контракте и выплачиваются по правилам, которые видны заранее.

