В предыдущей статье я рассказал как установить окружение для ознакомления с библиотекой web3.js. Тогда мы использовали ethereum-блокчейн Ganache и библиотеку Truffle. В данной статье я покажу как формировать ethereum-транзакцию используя только библиотеку web3.js и Ganache, без использования библиотеки Truffle.

Мы подключимся к локальному блокчейну Ganache, я покажу как создать новый аккаунт в дополнение к стандартным 10-ти аккаунтам. Далее мы сформируем транзакцию вручную, выполним её подписание и отправим в сеть. Посмотрим на тело сериализованной и подписанной транзакции в том виде, в котором она передаётся в ethereum-блокчейн (raw transaction). Так как команд будет много, то все эти шаги мы выполним в форме js-скрипта.

Напомню, что Truffle это фреймворк, который внутри себя содержит библиотеку web3.js, но помимо web3.js Truffle предлагает множество других инструментов для тестирования, деплоя и отладки смарт-контрактов. Truffle пригодится нам в следующих статьях, когда речь пойдёт о написании смарт-контрактов, и отправки сообщений в их адрес, а пока спустимся на этаж ниже и посмотрим более детально на процесс формирования транзакции.

Что нам понадобится?

  • Node.js

  • Web3.js

  • Ganache


Итак, приступим!

Для начала запустим наш локальный блокчейн Ganache:

Первоначальное состояние блокчейна
Первоначальное состояние блокчейна

Далее, создадим рабочую директорию и установим в ней библиотеку web3:

$ mkdir web3-transaction
$ cd web3-transaction
$ npm install web3

Запустим консоль node.js командой:

$ node

В консоли настроим подключение к Ganache:

> const Web3 = require('web3');
> const web3 = new Web3('http://127.0.0.1:7545');
> Web3

Если вы увидели вывод, представленный ниже, значит вы успешно подключились:

Функционал объекта Web3
Функционал объекта Web3

Проверим соединение ещё раз, запросив Ganache показать нам предустановленные аккаунты:

> web3.eth.getAccounts().then(console.log);

Вывод:

[
  '0xbc1415C5059aC055aE44Bd02ff1ad59AbEA8123f',
  '0x76770DdEb60e538C7A4fE43583772c4cfED09Aab',
  '0×3888942905f12974Fb0306D5E578da79811b0aE0',
  // other accounts ...
]

Поскольку getAccounts() возвращает объект Promise, то необходимо дописать .then(console.log). Тот же самый вывод можно получить используя await перед вызовом:

> await web3.eth.getAccounts();

Создание нового аккаунта в Ganache

Теперь я хочу продемонстрировать, как к существующим 10-ти аккаунтам мы можем создавать новые аккаунты, и эти аккаунты ничем не будут отличаться от предустановленных аккаунтов, ну почти ничем, ниже я расскажу про их небольшое различие.

Создадим новый аккаунт:

> web3.eth.accounts.create();

Вывод:

{
  address: '0x4bec925613AF22cC98886f6EbCb68BcB0B5f02F1',
  privateKey: '0x056cdabfa5b6434d9fb7eff7ceb16a11d48650dc4e81c734a5e7b3296e6e7fae',
  signTransaction: [Function: signTransaction],
  sign: [Function: sign],
  encrypt: [Function: encrypt]
}

В консоли мы видим данные по новому аккаунту, в частности адрес и связанный с ним приватный ключ.

А вот теперь про небольшое отличие между аккаунтами созданными вручную, и теми, которые были сгенерированы при старте Ganache. Если вы откроете Ganache, то вы не увидите вновь созданный аккаунт. Почему так происходит? На самом деле Ganache при запуске создаёт аккаунты в отдельном объекте, назовём его массив. При отображении аккаунтов в GUI он использует этот массив, точно так же как и метод web3.eth.getAccounts(). Созданный нами аккаунт хоть и отсутствует в этом массиве, но существует в том же самом блокчейне. Поэтому с технической точки зрения они идентичны. Чтобы в этом убедиться, давайте отправим несколько Ether c предустановленного аккаунта на наш новый аккаунт.

Перед отправкой Ether, проверим баланс нашего нового аккаунта:

> await web3.eth.getBalance('0x4bec925613AF22cC98886f6EbCb68BcB0B5f02F1');

// Out: '0'

Видим, что вновь созданный аккаунт не имеет средств на балансе.

Теперь отправим на этот аккаунт 70 Ether с аккаунта, который Ganache создал при старте, а заодно убедимся, что все аккаунты существуют в рамках одного блокчейна:

> await web3.eth.sendTransaction({to: '0x4bec925613AF22cC98886f6EbCb68BcB0B5f02F1', from: '0xbc1415C5059aC055aE44Bd02ff1ad59AbEA8123f', value: web3.utils.toWei('70', 'ether')});

Проверим новый аккаунт:

> await web3.eth.getBalance('0x4bec925613AF22cC98886f6EbCb68BcB0B5f02F1');

// '70000000000000000000'

Вывод показан в Wei. Если мы хотим получить вывод в Ether, то можем применить следующий вызов:

> await web3.eth.getBalance('0x4bec925613AF22cC98886f6EbCb68BcB0B5f02F1', (call, wei) => { balance = web3.utils.fromWei(wei, 'ether') });

// Out: '70000000000000000000'

> balance

// Out: '70'

Здесь мы передали методу getBalance() вторым аргументом функцию, которая сконвертировала полученный результат в Ether и сохранила его в переменной balance.

Отлично, теперь откроем Ganache GUI и убедимся, что с аккаунта-отправителя списались средства.

Состояние после отправки 70 Ether с первого аккаунта на вновь созданный аккаунт
Состояние после отправки 70 Ether с первого аккаунта на вновь созданный аккаунт

Теперь у нас достаточно средств на новом аккаунте, и мы убедились что не имеет значения каким образом был создан аккаунт, вручную или же при старте Ganache.

Формируем транзакцию по шагам

Теперь я покажу как отдельно сформировать транзакцию, отдельно её подписать и отдельно отправить подписанную транзакцию в сеть Ethereum.

Для отправки подписанной транзакции мы будем использовать метод sendSignedTransaction(). Это метод берёт подписанную сериализованную транзакцию, и отправляет в сеть Ethereum как есть.

Но перед этим я хотел бы рассказать, что же происходило под капотом предыдущего метода отправки.

Ранее для отправки транзакции мы использовали метод sendTransaction() библиотеки web3. Данный метод под капотом совершает множество операций за нас. Вот лишь некоторые из них:

  • Находит приватный ключ отправителя

  • Генерирует поле nonce

  • Определяет значения полей gasPrice и gas

  • Создаёт объект транзакции

  • Подписывает эту транзакцию

  • Сериализует транзакцию используя RLP кодирование

  • Отправляет подписанную и сериализованную транзакцию в сеть Ethereum

Может возникнуть вопрос - для чего нам нужно отдельно вручную выполнять эти шаги, если уже есть готовый метод, который за нас всё сделает? Дело в том, что иногда при написании Dapp приложений, нам действительно нужен ручной контроль над этапами формирования транзакции.

Вот лишь один из примеров. Представьте, что вы имеете аккаунт с очень большим количеством криптовалюты на нём, и вам бы не очень хотелось каким-то образом скомпрометировать приватный ключ от этого аккаунта. Самый верный способ обеспечить безопасность приватного ключа - это хранить его на offline устройстве, там же осуществлять формирование и подписание транзакции. В роли такого устройства может выступать полностью изолированный от сети компьютер. После формирования, подписания, и сериализации транзакции на offline устройстве, необходимо перенести её на online компьютер. Компьютер, подключённый к сети будет выступать в роли гейта к блокчейн-сети, и отправит транзакцию в сеть как есть. В таком случае никто не будет иметь онлайн-доступа к нашему приватному ключу.

Перенести транзакцию можно как при помощи флеш-накопителя, так и при помощи QR-кода, который считывается устройством-отправителем. Кстати, такой вид оффлайн-устройства называется ещё холодный кошелёк, или cold-wallet, а кошелёк, который хранит приватные ключи на онлайн-устройстве называют горячий кошелёк, или hot-wallet. В качестве cold-wallet на рынке присутствуют готовые решения в виде usb-устройств. Примером hot-wallet может служить расширение к браузеру MetaMask. Как правило, на hot-wallet рекомендуют хранить приватные ключи от тех аккаунтов, на которых хранятся незначительные средства. Hot-wallet крайне не рекомендуется использовать для хранения приватных ключей от аккаунтов с крупными суммами на балансе. Можно провести аналогию с банковской картой, на которой хранят небольшую сумму денег для повседневных платежей, а основная сумма лежит на счетах не привязанных к банковской карте.

В приведённом списке параметров существует ещё загадочное поле nonce, чтобы вас не утруждать техническими подробностями, и быстрее перейти к практике, скажу лишь что это целочисленный счётчик транзакций, который есть в каждом аккаунте и прописывается в каждую транзакцию. В конце статьи я к нему ещё вернусь, и опишу его назначение, а пока примем этот параметр как данность.

Про назначение полей gas и gasPrice я писал в предыдущей статье. Напомню лишь, что это плата за пользование вычислительными ресурсами сети Ethereum.

Итак, практика

В качестве участников транзакции я советую выбрать первый и второй аккаунты из Ganache, чтобы было удобнее смотреть изменения на балансах после многократного выполнения скрипта. Но для интереса вы можете потом поэкспериментировать и с тем аккаунтом, который мы создали ранее. Для того чтобы взять приватный ключ, кликаем на значок ключа в строке аккаунта-отправителя.

Поскольку команд будет много, то удобнее создать js-скрипт. Создадим внутри нашей папки web3-transfer файл transfer.js и поместим туда скрипт (имя файла не имеет значения, вы можете назвать как вам больше нравится). 

Наш скрипт transfer.js :

const Web3 = require("web3");
const web3 = new Web3("http://127.0.0.1:7545");

// replace this data with data from your blockchain
const senderAddress = "0xbc1415C5059aC055aE44Bd02ff1ad59AbEA8123f";
const senderPrivateKey = "0xa81eab077a97ed7e3bc312894292839ed96ee051c6b11fa9c29600792c11c6bb";
const recepientAddress = "0x76770DdEb60e538C7A4fE43583772c4cfED09Aab";

console.log("Sending 1 ether from address:", senderAddress, "to address:", recepientAddress);

async function transfer() {
  // nonce starts at 0 and increments by 1 after each transaction
  const nonce = await web3.eth.getTransactionCount(senderAddress, "latest");

  const transaction = {
    to: recepientAddress,
    value: web3.utils.toWei("1", "ether"),
    gas: 30000,
    nonce: nonce,
  };

  const signedTransaction = await web3.eth.accounts.signTransaction(
    transaction,
    senderPrivateKey
  );

  const rawTransaction = signedTransaction.rawTransaction;

  console.log("Nonce for address", senderAddress, "is:", nonce);
  console.log("Raw transaction:", rawTransaction);

  web3.eth.sendSignedTransaction(rawTransaction, function (error, hash) {
    if (!error) {
      console.log("The hash of your transaction is: ", hash,
        "\n Check Transactions tab in Ganache to view your transaction!");
    } else {
      console.log("Something went wrong while submitting your transaction:", error);
    }
  });
}

transfer();

В скрипте я добавил логи, чтобы можно было отслеживать изменение поля nonce и видеть транзакцию в чистом виде.

Выйдем из консоли node (Ctrl + D) и запустим транзакцию в терминале:

$ node transfer.js

Вывод:

Sending 1 ether from address: 0xbc1415C5059aC055aE44Bd02ff1ad59AbEA8123f to address: 0x76770DdEb60e538C7A4fE43583772c4cfED09Aab
Nonce for address 0xbc1415C5059aC055aE44Bd02ff1ad59AbEA8123f is: 1
Raw transaction: 0xf86e018504a817c8008275309476770ddeb60e538c7a4fe43583772c4cfed09aab880de0b6b3a764000080820a95a02bd617982bbf2cca3f332f1cc6c93fd5a1e10e3586f49d3bd936d5e5509c128ca0759cf486fb9289280ecd2a8d18f7686d1604474ecd263c54aedfc4d646db0d85
The hash of your transaction is:  0x7c5257dc29eab0d579931674927004089b6141a6d0127d3d30be43ae24041d97
 Check Transactions tab in Ganache to view your transaction!

Видим, что nonce у адреса-отправителя равен 1, так как до этого мы уже производили с него транзакцию в пользу вновь созданного аккаунта. Так же видим транзакцию в том виде, в котором она понятна протоколу Ethereum:

0xf86e018504a817c8008275309476770ddeb60e538c7a4fe43583772c4cfed09aab880de0b6b3a764000080820a95a02bd617982bbf2cca3f332f1cc6c93fd5a1e10e3586f49d3bd936d5e5509c128ca0759cf486fb9289280ecd2a8d18f7686d1604474ecd263c54aedfc4d646db0d85

Зайдём в Ganache и посмотрим на изменения:

Состояние после отправки 1 Ether с первого аккаунта на второй
Состояние после отправки 1 Ether с первого аккаунта на второй

Отлично, балансы изменились, транзакция прошла успешно. Можете несколько раз позапускать этот скрипт и увидеть, как изменяется nonce и баланс.

 В выводе скрипта мы видим так же хэш нашей транзакции:

0x7c5257dc29eab0d579931674927004089b6141a6d0127d3d30be43ae24041d97

По этому хэшу находят транзакцию в блокчейне. Если мы откроем вкладку Transactions в GUI Ganache, то увидим все наши транзакции (я запустил скрипт несколько раз):

Наши транзакции во кладке Transactions
Наши транзакции во кладке Transactions

Аналогично во вкладке Blocks мы можем обнаружить блоки в котором находятся наши транзакции. По одной транзакции на блок. В реальном мире в одном блоке содержатся сотни транзакций, а сами блоки формируются нодами-майнерами, за что получают финансовое вознаграждение, так как выступают в роли формирователей сети blockchain.

Вновь созданные блоки с нашими транзакциями во вкладке Blocks
Вновь созданные блоки с нашими транзакциями во вкладке Blocks

Ну и в заключение, несколько слов про поле Nonce

Nonce, он же "number only used once", предназначен для защиты от двух видов атак: replay-attack и double-spending attack. Про механизм этих атак можно почитать здесь, а если вкратце, то во-первых, без поля nonce в сети Ethereum мы могли бы многократно повторить транзакцию, которая уже была осуществлена, причём выполнить её без ведома самого отправителя (replay-attack), а во-вторых сеть Ethereum не обладала бы информацией об очередности обработки транзакций с одного и того же аккаунта (double-spending attack).

Как же работает nonce? Nonce это дополнительное целочисленное поле внутри каждого аккаунта, которое проставляется в отправляемую транзакцию. Иными словами, это счётчик отправленных транзакций с конкретного аккаунта. С каждой новой транзакцией с одного и того же аккаунта, данный параметр увеличивается на один. Допустим мы создали новый аккаунт, поле nonce у него будет равняться нулю, отправили транзакцию, поле nonce у этой транзакции тоже будет равняться нулю. После отправки транзакции, аккаунт увеличит это поле на 1, таким образом в следующей транзакции это поле будет равняться 1, и так далее. И если мы захотим отправить точно такую же транзакцию на такой же адрес и с тем же количеством Ether, то nonce изменит хэш этой транзакции, а следовательно и её подпись. Забегу немного вперёд - у аккаунта на котором задеплоен смарт контракт, поле nonce инициализируется не нулём, а единицей (EIP-161).

Таким образом, с полем nonce хэш SHA-3 (а точнее Keccak-256) любой транзакции всегда будет разный, следовательно подпись никогда не будет повторяться, каждая транзакция будет уникальной, а сеть Ethereum может учитывать порядок обработки транзакций.

На этом всё. Мы научились новым методам библиотеки web3.js, в частности: создание новых аккаунтов, подписание транзакции и отправки её в сеть. Посмотрели из чего состоит транзакция и увидели её сырое представление. Надеюсь информация была полезной :)

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


  1. Davidchanz
    30.05.2023 10:28

    Как только увидел переменную web3 и Web3 вырубил комп и пошёл в душ потомучто почувствовал себя грязным


    1. wakarimasen Автор
      30.05.2023 10:28

      Что же конкретно вас смутило?