Ethereum Contract ABI Specification. Взаимодействие с контрактом

от автора

Создание контракта и взаимодействие с ним

Создание контракта и взаимодействие с ним

В данной статье я хочу познакомить вас с тем, как осуществляется кодирование данных в транзакции в соответствии с Contract ABI Specification. Мы вручную разберём весь процесс кодирования, создадим контракт и произведём вызов его методов. В конце я покажу как при помощи Contract ABI создать объект-оболочку через web3.js, и через него вызывать методы контракта.

План

  1. Настройка окружения

  2. Создание контракта

  3. Взаимодействие с контрактом

  4. Объект-оболочка над контрактом

Настройка окружения

Нам потребуются: компилятор 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:

Пополнение баланса контракта на 0.1 ETH

Пополнение баланса контракта на 0.1 ETH

Транзакция на пополнение баланса контракта:

Транзакция, пополнившая баланс контракта на 0.1 ETH

Транзакция, пополнившая баланс контракта на 0.1 ETH

Отлично, а теперь самое интересное. Посмотрим как вызывать обычную функцию, а в нашем контракте это функция 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().

Списание 0.01 ETH с баланс контракта на EOA аккаунт

Списание 0.01 ETH с баланс контракта на EOA аккаунт

Кстати, кодирование можно было осуществить и при помощи готовых методов в 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:

Списание 0.01 ETH с баланс контракта на EOA аккаунт

Списание 0.01 ETH с баланс контракта на EOA аккаунт

На этом всё. Мы познакомились с Contract ABI Specification и узнали как на самом деле происходит кодирование данных при вызове методов контракта. Научились при помощи ABI интерфейса получать объект-обёртку над контрактом и взаимодействовать с ним из кода приложения.


ссылка на оригинал статьи https://habr.com/ru/articles/743878/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *