Автор: Павел Найданов 🕵️♂️
Contract Application Binary Interface (ABI) - это стандартный способ взаимодействия с контрактом в экосистеме Ethereum. Взаимодействие может происходить как извне блокчейна, так и внутри экосистемы между контрактами.
Чтобы понять необходимость в ABI, необходимо понимать следующие аспекты solidity разработки:
- EVM. Виртуальная вычислительная среда, другими словами распределенный компьютер, отвечающий за выполнение алгоритмов в сети Ethereum. Такие алгоритмы называют смарт-контрактами
- Смарт-контракт. Код для выполнения операций в сети Ethereum
- Machine-Readable Code. EVM не может интерпретировать код смарт-контракта, написанный на высокоуровневом языке программирования. Solidity не интерпретируется в том числе. Любой код должен быть скомпилирован в машиночитаемый код или байт-код, который содержит инструкции в двоичном формате
Таким образом для того, чтобы код смарт-контрактов стал понятен EVM необходима компиляция высокоуровнего кода в байт-код. В результате компиляции мы получаем два продукта:
- Machine-Readable Code или байт-код. Мы говорили о нем выше.
- Двоичный интерфейс приложения(ABI). Он необходим для того, чтобы мы понимали что закодировано в байт-коде и могли с ним взаимодействовать.
Мы знаем, что существуют статические и динамические типы данных.
Пример динамических типов:
- bytes
- string
- T[] для любого T(массив с любыми типами данных)
Все остальные типы являются статическими(uint, address, bool и т.д.)
Данные кодируются в соответствии с их типом, как описано в этой спецификации. Это сложно для понимания, но необходимо прочитать детально, чтобы разобраться с тем как это работает.
Важно! Статические и динамические типы кодируются по-разному. Статические типы кодируются на месте. Для динамических типов кодируется "ссылка" или "сдвиг", которая означает количество байтов. Выполняя смещение на это количество байт можно получить значение для динамического типа данных.
Для вызова функции сначала кодируется селектор функции(function selector). Это первые четыре байта хэша Keccak-256 сигнатуры функции.
bytes4(keccak256("function signature")
Сигнатура определяется как каноническое выражение базового прототипа функции без модификаторов. Типы параметров разделяются одной запятой — пробелы не используются.
Пример закодированного селектора функции sum()
:
sum(uint256,uint256) => cad0899b
Начиная с пятого байта кодируются аргументы функции:
- если тип аргумента статический, то кодируется сразу его значение(в 32-х байтах)
- если тип аргумента динамический, то кодируется указатель(смещение или offset) на начало хранения значение аргумента относительно начала блока аргументов. А уже по смещению можно найти само значение.
В спецификации было несколько примеров. Ниже разберем похожий пример кодирования вызова функции.
Будем кодировать функцию bar(uint256,uint256[])
. В эту функцию переданы аргументы 42
и массив [21, 22]
. Алгоритм кодирования будет выглядеть следующим образом:
- Кодируем селектор функции, как
bytes4(keccak256("bar(uint256,uint256[])") => ae2c7970
На данном этапе получаем:
0xae2c7970
- Кодируем первый аргумент
42
. Тип аргумента uint256 является статическим. Поэтому кодируем значение42
на месте.42
в hex равно2a
.2a
дополняем до 32-x байтного слова.
000000000000000000000000000000000000000000000000000000000000002a
На данном этапе получаем:
0xae2c7970 + 000000000000000000000000000000000000000000000000000000000000002a
- Кодируем второй аргумент
[21, 22]
. Тип аргументаuint256[]
является динамическим. Поэтому кодируем сначала сдвиг(ссылку) на место, где будет закодирована длина массива. Помним, что считаем количество байтов от начала блока аргументов000000000000000000000000000000000000000000000000000000000000002a => 32 байта + 32 байта(значение будущего сдвига)
. Получается длина массива будет закодирована после 64 байт. Кодируем значение64
. В hex это равно40
. По традиции дополняем до 32-х байт.
0000000000000000000000000000000000000000000000000000000000000040
На данном этапе получаем:
0xae2c7970 + 000000000000000000000000000000000000000000000000000000000000002a + 0000000000000000000000000000000000000000000000000000000000000040
- После того, как мы закодировали сдвиг, нам необходимо закодировать саму длину массива. Чтобы знать, когда остановится считывать элементы массива. В данном случае длина массива равна
2
. Кодируем:
0000000000000000000000000000000000000000000000000000000000000002
На данном этапе получаем:
0xae2c7970 + 000000000000000000000000000000000000000000000000000000000000002a + 0000000000000000000000000000000000000000000000000000000000000040 + 0000000000000000000000000000000000000000000000000000000000000002
- Осталось только закодировать два значения массива
21
(15 в hex) и22
(16 hex). Дополняем эти значения до 32-x байтных слов
21 => 0000000000000000000000000000000000000000000000000000000000000015
22 => 0000000000000000000000000000000000000000000000000000000000000016
На данном этапе получаем:
0xae2c7970 + 000000000000000000000000000000000000000000000000000000000000002a + 00000000000000000000000000000000000000000000000000000000000000040 + 0000000000000000000000000000000000000000000000000000000000000002 + 0000000000000000000000000000000000000000000000000000000000000015 + 0000000000000000000000000000000000000000000000000000000000000016
- Для наглядности можно представить это следующим образом
Encoded function selector:
0xae2c7970
Arguments block:
0 - 000000000000000000000000000000000000000000000000000000000000002a - encoded 42 value
1 - 0000000000000000000000000000000000000000000000000000000000000040 - encoded offset for [21, 22]
2 - 0000000000000000000000000000000000000000000000000000000000000002 - number of items in the array [21, 22]
3 - 0000000000000000000000000000000000000000000000000000000000000015 - encoded 21 value(the first array item)
4 - 0000000000000000000000000000000000000000000000000000000000000016 - encoded 22 value(the second array item)
В результате мы получаем следующий хэш:
0xae2c7970000000000000000000000000000000000000000000000000000000000000002a0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000150000000000000000000000000000000000000000000000000000000000000016
После компиляции ABI представлен в виде json файла. Пример ABI ERC20 токена можно посмотреть тут.
Формат JSON для ABI контракта состоит из описания функций, событий и ошибок.
Описание функции представляет собой объект JSON с полями:
- type: тип функции("function", "constructor", "receive","fallback");
- name: имя функции;
- inputs: массив объектов на вход(аргументы функции), каждый из которых содержит: name(имя параметра), type и components(для кортежей)
- outputs: массив объектов на выход(возвращаемые значения), похож на inputs.
- stateMutability: pure, view, nonpayable и payable.
Важно! Тип возвращаемого значения функции не является частью сигнатуры функции, а значит и не кодируется. Однако ABI содержит информацию о выходных значениях в поле outputs.
Для взаимодействия с байт-кодом смарт контракта данные кодируются и декодируются. Это постоянный двусторонний процесс.
В solidity существуют специальные функции для работы с кодированием и декодированием данных:
-
abi.decode(bytes memory encodedData, (...)) returns (...)
-
abi.encode(...) returns (bytes memory)
-
abi.encodePacked(...) returns (bytes memory)
-
abi.encodeWithSelector(bytes4 selector, ...) returns (bytes memory)
-
abi.encodeWithSignature(string memory signature, ...) returns (bytes memory)
-
abi.encodeCall(function functionPointer, (...)) returns (bytes memory)
Подробнее про эти функции в документации тут
Неплохая статья, которая дает чуть больше пояснений для этих встроенных функций.
abi.encode();
Это стандартный метод кодирования аргументов, согласно выше описанной спецификации.
abi.encodePacked();
Это нестандартный, упакованный режим кодировки. Особенности:
- Значения, типы которых короче 32 байтов не дополняются нулями и не дополняются знаками
- Динамические типы кодируются на месте и без длины.
- Элементы массива дополняются нулями, но все еще кодируются на месте
- Структуры и вложенные массивы не поддерживаются
Подробнее тут.
Важно! Если для keccak256(abi.encodePacked(a, b))
, где a
и b
динамические типы, то легко получить коллизию хешей. Более того справедливо следующее abi.encodePacked("a", "bc") == abi.encodePacked("ab", "c")
. Для таких случаев с динамическими типами лучше использовать стандартный abi.encode()
.
Эти и другие полезные наработки по кодированию и декодированию данных при помощи abi можно опробовать в remix с нашим заготовленным контрактом.