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

В данной статье я хочу познакомить вас с тем, как осуществляется кодирование данных в транзакции в соответствии с 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 интерфейса получать объект-обёртку над контрактом и взаимодействовать с ним из кода приложения.

Комментарии (0)