В данной статье я хочу познакомить вас с тем, как осуществляется кодирование данных в транзакции в соответствии с Contract ABI Specification. Мы вручную разберём весь процесс кодирования, создадим контракт и произведём вызов его методов. В конце я покажу как при помощи Contract ABI создать объект-оболочку через web3.js, и через него вызывать методы контракта.
План
Настройка окружения
Создание контракта
Взаимодействие с контрактом
Объект-оболочка над контрактом
Настройка окружения
Нам потребуются: компилятор Solidity, сам контракт, подключение к тестовой сети Sepolia и аккаунт с тестовыми Ether на балансе. Так же нам необходимо будет добавить приватный ключ этого аккаунта в Wallet библиотеки web3.js
Начнём с компилятора Solidity. Существуют разные способы установки компилятора Solidity, всё зависит от вашей ОС и каким способом вы хотите его установить: npm, Docker, Linux Packages и т.д. Как установить компилятор можно посмотреть здесь.
Создадим рабочий каталог, установим web3.js и добавим в него наш контракт. Версия web3.js на момент написания статьи 4.0.1
$ mkdir raw-contract
$ cd raw-contract
$ npm install web3
$ nano Faucet.sol
Код контракта:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.0 <0.9.0;
contract Faucet {
// Accept any incoming amount
receive() external payable {}
// Give out ether to anyone who asks
function withdraw(uint withdraw_amount) public {
// Limit withdrawal amount
require(withdraw_amount <= 0.01 ether);
// Send the amount to the address that requested it
payable(msg.sender).transfer(withdraw_amount);
}
}
Контракт я взял из своего предыдущего примера, когда мы деплоили его в локальный блокчейн Ganache. Логика контракта позволяет зачислять Ether на его баланс, и даёт возможность каждому снять в свою пользу по 0.01 Ether за одну транзакцию.
На всякий случай проверим версию компилятора, она должна быть 0.8.x
:
$ solc --version
// out:
solc, the solidity compiler commandline interface
Version: 0.8.20+commit.a1b79de6.Darwin.appleclang
Теперь скомпилируем контракт и получим его бинарное представление:
$ solc --bin Faucet.sol
======= Faucet.sol:Faucet =======
Binary:
608060405234801561000f575f80fd5b506101468061001d5f395ff3fe608060405260043610610021575f3560e01c80632e1a7d4d1461002c57610028565b3661002857005b5f80fd5b348015610037575f80fd5b50610052600480360381019061004d91906100e5565b610054565b005b662386f26fc10000811115610067575f80fd5b3373ffffffffffffffffffffffffffffffffffffffff166108fc8290811502906040515f60405180830381858888f193505050501580156100aa573d5f803e3d5ffd5b5050565b5f80fd5b5f819050919050565b6100c4816100b2565b81146100ce575f80fd5b50565b5f813590506100df816100bb565b92915050565b5f602082840312156100fa576100f96100ae565b5b5f610107848285016100d1565b9150509291505056fea2646970667358221220c9fe2f78a923e108f0619a2d655b35d18297668335e649a10272c09a790a188964736f6c63430008140033
То что мы получили, это бинарное представление контракта, которое мы добавим в поле data
транзакции и отправим в сеть Ethereum, после чего наш контракт будет задеплоен.
Подготовим подключение к сети Ethereum, для этого зайдем в консоль node.js:
$ node
Подключимся к тестовому блокчейну Sepolia:
> const { Web3 } = require('web3');
> const web3 = new Web3('https://rpc2.sepolia.org');
Чтобы web3.js смог подписать нашу транзакцию, мы должны добавить приватный ключ аккаунта, с которого будем отправлять эту транзакцию. Передадим в метод add()
приватный ключ:
> web3.eth.accounts.wallet.add('0x0e...e3');
Вывод:
Wallet(1) [
{
address: '0xf79ac5fa7F6e76590Ae6cE641aB42b33CAB86C47',
privateKey: '0x0e...e3',
signTransaction: [Function: signTransaction],
sign: [Function: sign],
encrypt: [Function: encrypt]
},
_accountProvider: {
create: [Function: createWithContext],
privateKeyToAccount: [Function: privateKeyToAccountWithContext],
decrypt: [Function: decryptWithContext]
},
_addressMap: Map(1) { '0xf79ac5fa7f6e76590ae6ce641ab42b33cab86c47' => 0 },
_defaultKeyName: 'web3js_wallet'
]
Для отправки транзакции я воспользуюсь своим существующим аккаунтом и тестовыми Ether на балансе. Если у вас нет своего аккаунта, то вы можете с лёгкостью его создать одной командой, и пополнить тестовыми Ether. Как это сделать я описывал в одной из своих предыдущих статей.
У нас всё готово для отправки транзакции на создание контракта.
Создание контракта
Добавим префикс 0x
к началу кода контракта и поместим его в переменную:
> var contractCode = '0x608060405234801561000f575f80fd5b506101468061001d5f395ff3fe608060405260043610610021575f3560e01c80632e1a7d4d1461002c57610028565b3661002857005b5f80fd5b348015610037575f80fd5b50610052600480360381019061004d91906100e5565b610054565b005b662386f26fc10000811115610067575f80fd5b3373ffffffffffffffffffffffffffffffffffffffff166108fc8290811502906040515f60405180830381858888f193505050501580156100aa573d5f803e3d5ffd5b5050565b5f80fd5b5f819050919050565b6100c4816100b2565b81146100ce575f80fd5b50565b5f813590506100df816100bb565b92915050565b5f602082840312156100fa576100f96100ae565b5b5f610107848285016100d1565b9150509291505056fea2646970667358221220c9fe2f78a923e108f0619a2d655b35d18297668335e649a10272c09a790a188964736f6c63430008140033';
Пробуем отправить транзакцию:
> await web3.eth.sendTransaction({from: '0xf79ac5fa7F6e76590Ae6cE641aB42b33CAB86C47', gasLimit: 21000, data: contractCode});
В данном случае мы получили ошибку:
Uncaught TransactionRevertInstructionError: Transaction has been reverted by the EVM
...
innerError: undefined,
reason: 'err: intrinsic gas too low: have 21000, want 58368 (supplied gas 21000)',
signature: undefined,
receipt: undefined,
data: undefined,
code: 402
}
Всё дело в том что нам не хватило Gas для отправки транзакции. В коде сообщения мы видим, что было проставлено 21000, но требовалось 58368. Что самое интересное, если мы установим требуемое значение в 58368, то это не значит, что мы успешно создадим контракт. Этого хватит лишь для отправки транзакции, но не хватит для создания самого контракта в EVM, и мы увидим ситуацию как здесь:
Упрощённо, что здесь произошло: транзакция попала в сеть Ethereum, распространилась по нодам, и была помещена в Mempool на них. Proof of Stake алгоритм выбрал ноду, которая в данный момент будет валидировать, исполнять и помещать транзакции из Mempool в новый блок. Нашей транзакции посчастливилось, и она была выбрана нодой для добавления в блок.
Нода взяла транзакцию в обработку и запустила на своей локальной EVM код который находился в поле data
. В процессе исполнения был создан аккаунт для контракта, и начался процесс деплоя контракта в storage
этого аккаунта. В ходе деплоя было обнаружено, что в транзакции недостаточно Gas для завершения процесса, и возникла ошибка: Out of Gas error.
В итоге у нас появилась ситуация при которой аккаунт под контракт был создан, а его код не был сохранён в storage
аккаунта. К тому же мы потеряли 21000 Gas, которые были использованы при исполнении кода, так как нода потратила свои вычислительные ресурсы на исполнение этого кода. Как видим, операции создания аккаунта контракта и его деплоя не атомарны в сети Ethereum. Изменения из storage
аккаунта откатились, но сам созданный аккаунт так и остался в блокчейне с нулём вместо кода контракта:
Чтобы не воспроизводить описанный сценарий, а так же не вычислять точное значение Gas для деплоя контракта, установим gasLimit
с запасом. Оставшийся после деплоя контракта Gas вернётся на баланс нашего аккаунта. Более точное количество Gas можно узнать путём деплоя контракта в локальном блокчейне, например Ganache, или же задеплоить его в RemixIDE, а потом посмотреть количество использованного Gas. При использовании библиотек можно воспользоваться вспомогательными функциями, которые позволяют вычислить требуемое количество Gas до проведения транзакции.
Итак, установим gasLimit
с запасом, и отправим транзакцию на создание контракта:
> await web3.eth.sendTransaction({from: '0xf79ac5fa7F6e76590Ae6cE641aB42b33CAB86C47', gasLimit: 300000, data: contractCode});
Квитанция транзакции:
{
blockHash: '0x57e6957ca0be6079ddd8a4af7e28a677f5fce8c19ff4a84fdc8bebf3c4957ad7',
blockNumber: 3749703n,
contractAddress: '0x13c96729039f1da4ea42ffe1a7e9cac1cf42801d',
cumulativeGasUsed: 29713841n,
effectiveGasPrice: 294172321n,
from: '0xf79ac5fa7f6e76590ae6ce641ab42b33cab86c47',
gasUsed: 123683n,
logs: [],
logsBloom: '0x00000000000000000000000…0000000000000000000000000000',
status: 1n,
transactionHash: '0x3650d8427dd426fa76967a2d69dd84e67def5cc81cf9875e54221fb97ea14aaa',
transactionIndex: 35n,
type: 0n
}
Контракт успешно создан, вот его адрес:
0x13c96729039f1da4ea42ffe1a7e9cac1cf42801d
Аккаунт контракта:
Код контракта:
Транзакция, которая создала контракт:
Отлично, раз контракт создан, то давайте тогда обратимся к его методу. Например пополним баланс контракта.
Взаимодействие с контрактом
В прошлой статье я использовал фреймворк Truffle для упрощения взаимодействия с контрактом. Мы получали объект-оболочку и через него вызывали методы контракта. На этот раз мы будем вызывать методы путём отправки транзакций с закодированным вызовом метода в поле data
.
К счастью, для пополнения баланса контракта, нам не нужно ничего дополнительно кодировать, так как у нас есть fallback функция receive()
, которая отработает при поступлении обычной транзакции без поля data
, и зачислит Ether из поля value
на баланс контракта. Подробнее про fallback функции я писал здесь.
Итак, отправим 0.1 Ether на контракт. GasLimit тоже установим с небольшим запасом:
> await web3.eth.sendTransaction({from: '0xf79ac5fa7F6e76590Ae6cE641aB42b33CAB86C47', to:'0x13c96729039f1da4ea42ffe1a7e9cac1cf42801d', value: web3.utils.toWei('0.1', 'ether'), gasLimit: 30000});
Квитанция:
{
blockHash: '0xeb8f28d40966400fcfba7690c938534c93a295ba860b961f72520ad5cf5b3395',
blockNumber: 3749766n,
cumulativeGasUsed: 14620676n,
effectiveGasPrice: 94971021n,
from: '0xf79ac5fa7f6e76590ae6ce641ab42b33cab86c47',
gasUsed: 21055n,
logs: [],
logsBloom: '0x000000000000000000000000000…00000000000000000000000000',
status: 1n,
to: '0x13c96729039f1da4ea42ffe1a7e9cac1cf42801d',
transactionHash: '0xa50902396f4ac2c15fd7c551cef084acf13be2c92c5dd2c91308793b37fe1a95',
transactionIndex: 101n,
type: 0n
}
Баланс пополнился на 0.1 Ether:
Транзакция на пополнение баланса контракта:
Отлично, а теперь самое интересное. Посмотрим как вызывать обычную функцию, а в нашем контракте это функция withdraw(),
в понятной для протокола Ethereum форме.
Чтобы осуществить вызов метода, нам необходимо закодировать сигнатуру метода и её аргументы. Закодированная сигнатура метода в документации Solidity называется function selector.
Сигнатурой метода в Solidity являются: имя функции + типы аргументов в скобках через запятую и без пробелов. В нашем случае сигнатура выглядит следующим образом:
withdraw(uint256)
Чтобы получить function selector, вычислим Keccak-256 хэш от сигнатуры метода:
> web3.utils.sha3('withdraw(uint256)');
// Out:
'0x2e1a7d4d13322e7b96f9a57413e1525c250fb7a9021cf91d1540d5b69f16a49f'
и возьмём первые 4 байта от вычисленного хэша (один байт это два hex-символа не считая 0x
префикса):
0x2e1a7d4d
Это и есть function selector. Теперь осталось закодировать сам аргумент, в нашем случае это 0.01 Ether. Для этого сначала сконвертируем 0.01 Ether в Wei, так как протокол Ethereum оперирует значениями в Wei:
> web3.utils.toWei('0.01', 'ether');
// Out:
'10000000000000000'
Затем сконвертируем полученное значение в шестнаддатеричную форму:
> web3.utils.toHex(10000000000000000);
// Out:
'0x2386f26fc10000'
Добавим паддинг слева. Поскольку мы использовали тип uint256
, а его размер равен 256 бит или 32 байта, то и отправить мы должны число длиной 256 бит. Для этого нам необходимо добавить нули слева, чтобы число в итоге имело размер 256 бит, или длину в 64 символа. Соответственно к нашим 14 символам добавим ещё 50 нулей слева:
000000000000000000000000000000000000000000000000002386f26fc10000
И теперь поместим сам аргумент после function selector:
0x2e1a7d4d000000000000000000000000000000000000000000000000002386f26fc10000
Все. Наш вызов метода withdraw()
на снятие 0.01 Ether с баланса контракта готов. Теперь поместим его в поле data
транзакции и отправим её:
> await web3.eth.sendTransaction({from: '0xf79ac5fa7F6e76590Ae6cE641aB42b33CAB86C47', to: '0x13C96729039F1da4Ea42Ffe1a7E9Cac1cF42801D', gasLimit: 50000, data: '0x2e1a7d4d000000000000000000000000000000000000000000000000002386f26fc10000'});
Квитанция:
{
blockHash: '0xb4994dfc02f5ecbff87a28ee8fc157f2af34816b23401bd78e24ea24d169c6d0',
blockNumber: 3750579n,
cumulativeGasUsed: 3294721n,
effectiveGasPrice: 27416831971n,
from: '0xf79ac5fa7f6e76590ae6ce641ab42b33cab86c47',
gasUsed: 28559n,
logs: [],
logsBloom: '0x0000000000000000000000…0000000000000000000000000',
status: 1n,
to: '0x13c96729039f1da4ea42ffe1a7e9cac1cf42801d',
transactionHash: '0xf8c01ab85fb32c87d2d4b98981171ee2365aa5d77f0580844909bd4104daf129',
transactionIndex: 16n,
type: 0n
}
Отлично. Транзакции прошла успешно, с контракта списалось 0.01 Ether и зачислилось на баланс аккаунта, с которого был вызван метод withdraw()
.
Кстати, кодирование можно было осуществить и при помощи готовых методов в web3.js:
> web3.eth.abi.encodeFunctionSignature('withdraw(uint256)');
// out:
'0x2e1a7d4d'
> web3.eth.abi.encodeParameter('uint256', '10000000000000000');
// out:
'0x000000000000000000000000000000000000000000000000002386f26fc10000'
Но суть была в том, чтобы показать как именно осуществляется кодирование.
Объект-оболочка над контрактом
Выше мы разобрали процесс ручного кодирования данных для взаимодействия с контрактом. Обычно при разработке Dapp приложений взаимодействие с контрактом осуществляется при помощи таких библиотек как: Truffle, web3.js, ethers.js, Web3.py, web3j. Все эти библиотеки позволяют обращаться к контракту из кода приложения как к обычному объекту путём вызова его методов. Всё необходимое кодирование данных и отправку транзакции эти объекты берут на себя. Ниже мы рассмотрим как в web3.js можно получить такой объект, и при помощи него произведём вызов метода контракта.
Для создания объекта контракта в web3.js нам понадобится ABI (Application Binary Interface) контракта, который представляет собой описание методов контракта, типов данных и прочей информации, необходимой библиотекам для взаимодействия с контрактом.
Сам ABI в json формате мы можем получить следующим образом:
$ solc Faucet.sol --abi
Вывод:
======= Faucet.sol:Faucet =======
Contract JSON ABI
[{"inputs":[{"internalType":"uint256","name":"withdraw_amount","type":"uint256"}],"name":"withdraw","outputs":[],"stateMutability":"nonpayable","type":"function"},{"stateMutability":"payable","type":"receive"}]
Отформатированный ABI:
[
{
"inputs": [
{
"internalType": "uint256",
"name": "withdraw_amount",
"type": "uint256"
}
],
"name": "withdraw",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"stateMutability": "payable",
"type": "receive"
}
]
Здесь мы видим метод withdraw
и данные о нём, а так же fallback функцию receive
, которая сообщает, что контракт может принимать Ether на свой адрес.
Этот json передаётся в конструктор объекта, через который мы будем взаимодействовать с контрактом, далее сам объект уже будет выполнять операции по кодированию вызовов методов контракта.
Сконвертируем json в JavaScript объект:
> var contractABI = JSON.parse('[{"inputs":[{"internalType":"uint256","name":"withdraw_amount","type":"uint256"}],"name":"withdraw","outputs":[],"stateMutability":"nonpayable","type":"function"},{"stateMutability":"payable","type":"receive"}]');
Передадим описание и адрес контракта в конструктор, и получим сам объект контракта:
> var myContract = new web3.eth.Contract(contractABI, '0x13c96729039f1da4ea42ffe1a7e9cac1cf42801d', {from: '0xf79ac5fa7F6e76590Ae6cE641aB42b33CAB86C47'});
Обратим внимание на объект с полем from
- так мы указали контракту с какого аккаунта по-умолчанию будут происходить вызовы в его адрес.
Вызов метода будет выглядеть следующим образом:
> await myContract.methods.withdraw(web3.utils.toWei('0.01', 'ether')).send({gasLimit: 50000});
Квитанция:
{
blockHash: '0xf39c80e703689eab40d9547ffc252304996e3c6004c62e654c513f8a9d03d4a4',
blockNumber: 3763915n,
cumulativeGasUsed: 7956770n,
effectiveGasPrice: 3540322410n,
from: '0xf79ac5fa7f6e76590ae6ce641ab42b33cab86c47',
gasUsed: 28559n,
logs: [],
logsBloom: '0x0000000000000…00000000000000000000000000',
status: 1n,
to: '0x13c96729039f1da4ea42ffe1a7e9cac1cf42801d',
transactionHash: '0xca5275a466e9acd34c723203d6847e42a4cf49f2156835cd7e9418d572924e59',
transactionIndex: 55n,
type: 0n
}
Видим, что снова списалось 0.01 Ether:
На этом всё. Мы познакомились с Contract ABI Specification и узнали как на самом деле происходит кодирование данных при вызове методов контракта. Научились при помощи ABI интерфейса получать объект-обёртку над контрактом и взаимодействовать с ним из кода приложения.