Обычно когда мне нужно использовать какой-то новый сервис или технологию из скриптов Perl, я захожу на CPAN, и там уже есть один или несколько подходящих модулей. Однако в случае фреймворков для работы с узлами блокчейна Ethereum и контрактами Solidity, к сожалению, мне не удалось найти нужного модуля.
Мы планируем в ближайшее время использовать смарт-контракты Solidity сети Ethereum в нашем SAAS-сервисе интернет-магазинов, написанном на Perl. Поэтому мне ничего не оставалось, как написать свой модуль Net::Ethereum (этот модуль уже доступен на CPAN, хотя и в виде альфа-версии).
Надеюсь, что модуль Net::Ethereum будет полезен тем, кто хочет интегрировать свои Perl-системы с контрактами блокчейна Ethereum. Буду очень благодарен тем, кто воспользуется этим модулем и пришлет мне свои соображения по его доработке, а также информацию о найденных ошибках.
Почему я сделал свой модуль для Perl
Многие считают, что Perl отжил свое, и находят для этого множество причин. Возможно, если бы я начинал создание своего сервиса сейчас, то выбрал бы другой язык программирования, такой как Python или Golang. Однако выбор был сделан более 10 лет назад, и тогда использование Perl было правильным решением. Это была надежная, хорошо отработанная технология, с большим количеством документации и книг, в том числе на русском языке, доступная для быстрого освоения новичками. Кроме того, репозиторий CPAN, содержащий множество полезных модулей, помогал сосредоточится на решении прикладных задач.
Сейчас, когда встала задача интеграции сервиса интернет-магазинов с контрактами Solidity, оказалось, что существуют инструменты и фреймворки только для JavaScript и Python. При этом, насколько я понимаю, официальную поддержку получила только библиотека Web3, предоставляющая API на базе JavaScript.
Конечно, мы могли бы поднять узел Node.js и сделать на нем микросервис для взаимодействия скриптов Perl и смарт-контрактов Solidity. Однако добавились бы расходы на создание и сопровождение этого узла, обеспечение его высокой нагрузочной способности и отказоустойчивости.
Переписывание нашего SAAS-сервиса на Python или JavaScript теоретически возможно, однако потребует неимоверных финансовых затрат и очень много времени. В результате я решил, что проще будет написать модуль Net::Ethereum и выполнить интеграцию со смарт-контрактами Solidity безо всяких промежуточных микросервисов.
Нам поможет Ethereum JSON RPC API
Узел (node) сети Ethereum может служить сервером, предоставляющим программный интерфейс JSON RPC для выполнения всех необходимых нам действий. Для этого интерфейса опубликовано подробное описание. Кроме того, будет полезно описание интерфейсов управления Management APIs.
Вызов большинства функций Ethereum JSON RPC API достаточно тривиален. Однако для того чтобы передать параметры конструктору смарт-скрипта, его методам, а также получить значения, возвращаемые методами, необходимо реализовать упаковку (маршалинг, marshaling) и распаковку (unmarshaling). При этом не обойтись без так называемой спецификации бинарного интерфейса, опубликованной в документе Application Binary Interface Specification.
Изучение этой спецификации может потребовать определенных усилий. Если вы решили разобраться во всех деталях маршалинга, то вам, как и мне, поможет статья Работа со смарт-контрактами через Ethereum RPC API.
Еще одна трудность реализации маршалинга связана с тем, что контракты Solidity работают с очень большими числами — int256, uint256. Скрипты Perl могут работать с такими числами с помощью модуля Math::BigInt. Этот модуль подключается в Net::Ethereum.
Надо сказать, что в настоящий момент (версия 0.27) маршалинг (и демаршалинг) реализованы только для следующих типов данных:
- uint (uint8...uint256)
- int (int8...int256)
- bool
- address
- string
В дальнейшем я планирую сделать маршалинг и для других типов данных Solidity.
Подготовка среды для использования Net::Ethereum
Все работы по созданию и отладке модуля Net::Ethereum я выполнял в облаке, на виртуальной машине Ubuntu 16.04.3 LTS xenial. При этом я пользовался приватной сетью Ethereum, развернутой на этой виртуальной машине и состоящей из одного узла.
Прежде всего, в домашнем каталоге пользователя с обычными правами нужно создать файл genesis.json:
Файл genesis.json
{
"config": {
"chainId": 1907,
"homesteadBlock": 0,
"eip155Block": 0,
"eip158Block": 0
},
"difficulty": "40",
"gasLimit": "5100000",
"alloc": {}
}
Далее создаем аккаунт:
geth --datadir node1 account new
При создании аккаунта у вас будет запрошен пароль, который необходимо сохранить.
На следующем этапе инициализируем узел:
geth --datadir node1 init genesis.json
После завершения инициализации запускаем в первом окне консоли узел следующей командой:
geth --datadir node1 --nodiscover --mine --minerthreads 1 --maxpeers 0 --verbosity 3 --networkid 98765 --rpc --rpcapi="db,eth,net,web3,personal,web3" console
Обратите внимание, что мы указали параметры --rpc и --rpcapi, они разрешают узлу предоставлять необходимые нам сервисы. Кроме того, с помощью параметра --mine мы запустили локальный майнинг, необходимый для публикации контрактов и выполнения других транзакций.
После запуска узла мы не вводим никаких команд в первом консольном окне, а просто наблюдаем за сообщениями, которые там появляются.
Для работы с командами интерфейса Web3 откройте второе консольное окно, и запустите там команду подключения к узлу:
geth --datadir node1 --networkid 98765 attach ipc://home/frolov/node1/geth.ipc
Что же касается запуска скриптов Perl, работающих с модулем Net::Ethereum, то их нужно запускать в отдельном, третьем консольном окне. Предварительно нужно установить модуль Net::Ethereum, а также убедиться, что установлена самая новая версия модуля Math::BigInt.
Модуль Net::Ethereum находится в каталоге CPAN.
Создаем и публикуем тестовый смарт-контракт
Модуль Net::Ethereum тестировался на следующем контракте:
Файл HelloSol.sol
pragma solidity ^0.4.10;
contract HelloSol {
string savedString;
uint savedValue;
address contractOwner;
function HelloSol(uint initValue, string initString) public {
contractOwner = msg.sender;
savedString = initString;
savedValue = initValue;
}
function setString( string newString ) public {
savedString = newString;
}
function getString() public constant returns( string curString) {
return savedString;
}
function setValue( uint newValue ) public {
savedValue = newValue;
}
function getValue() public constant returns( uint curValue) {
return savedValue;
}
function setAll(uint newValue, string newString) public {
savedValue = newValue;
savedString = newString;
}
function getAll() public constant returns( uint curValue, string curString) {
return (savedValue, savedString);
}
function getAllEx() public constant returns( bool isOk, address msgSender, uint curValue, string curString, uint val1, string str1, uint val2, uint val3) {
string memory sss="++ ==================================== ++";
return (true, msg.sender, 33333, sss, 9999, "Line 9999", 7777, 8888);
}
function repiter(bool pBool, address pAddress, uint pVal1, string pStr1, uint pVal2, string pStr2, uint pVal3, int pVal4) public pure
returns( bool rbBool, address rpAddress, uint rpVal1, string rpStr1, uint rpVal2, string rpStr2, uint rpVal3, int rpVal4) {
return (pBool, pAddress, pVal1, pStr1, pVal2, pStr2, pVal3, pVal4);
}
}
Сохраните этот контракт в рабочем каталоге, файле с именем HelloSol.sol.
Для компиляции и деплоя контракта я написал небольшой скрпит deploy_contract.pl, представленный ниже.
Файл deploy_contract.pl
#!/usr/bin/perl
use strict;
use Net::Ethereum;
use Data::Dumper;
my $contract_name = $ARGV[0];
my $password = $ARGV[1];
my $node = Net::Ethereum->new('http://localhost:8545/');
my $src_account = $node->eth_accounts()->[0];
print 'My account: '.$src_account, "\n";
my $constructor_params={};
$constructor_params->{ initString } = '+ Init string for constructor +';
$constructor_params->{ initValue } = 102;
my $contract_status = $node->compile_and_deploy_contract($contract_name, $constructor_params, $src_account, $password);
my $new_contract_id = $contract_status->{contractAddress};
my $transactionHash = $contract_status->{transactionHash};
my $gas_used = hex($contract_status->{gasUsed});
print "\n", 'Contract mined.', "\n", 'Address: '.$new_contract_id, "\n", 'Transaction Hash: '.$transactionHash, "\n";
my $gas_price=$node->eth_gasPrice();
my $contract_deploy_price = $gas_used * $gas_price;
my $price_in_eth = $node->wei2ether($contract_deploy_price);
print 'Gas used: '.$gas_used.' ('.sprintf('0x%x', $gas_used).') wei, '.$price_in_eth.' ether', "\n\n";
Этому скрипту нужно передать имя класса Solidity, которое должно совпадать с именем файла без расширения ".sol"., а также пароль от аккаунта, сохраненный при подготовке узла Ethereum к работе.
Программа компиляции и публикации контракта подключается к узлу по адресу localhost:8545/. Если этот адрес недоступен, проверьте команду запуска узла.
Далее программа с помощью метода eth_accounts получает массив аккаунтов, созданных на текущем узле, используя для работы первый из них.
Компиляцию и публикацию контракта выполняем метод compile_and_deploy_contract. Ему передаются имя и параметры контракта, адрес аккаунта, от имени которого будет опубликован контракт, а также пароль этого аккаунта.
Метод compile_and_deploy_contract компилирует файл исходного кода контракта, создавая в подкаталоге build рабочего каталога файл спецификации бинарного интерфейса abi и файл бинарного кода контракта. Для этого применяется следующая команда:
my $cmd = "$bin_solc --bin --abi $contract_src_path -o build --overwrite";
Далее метод compile_and_deploy_contract разблокирует аккаунт при помощи метода personal_unlockAccount, оценивает количество газа, требуемого для публикации при помощи метода deploy_contract_estimate_gas.
Публикация выполняется методом deploy_contract, при этом ожидание завершения транзакции выполняет метод wait_for_contract. После завершения публикации мы получаем код контракта методом eth_getCode чтобы убедиться, что контракт опубликовался успешно.
После завершения публикации метод compile_and_deploy_contract возвращает статус контракта. Наша программа публикации извлекает и отображает адрес опубликованного контракта, хеш транзакции, а также количество использованного газа. Стоимость публикации контракта отображается в единицах wei и ether.
Таким образом вы сможете создать свой скрипт публикации и деплоя контракта. Его можно интегрировать в систему непрерывной разработки и развертывания программного обеспечения вашей системы.
Работа с методами контракта
Для работы с контрактом мы использовали скрипт debug_contract.pl, показанный ниже.
Файл debug_contract.pl
use Net::Ethereum;
use Data::Dumper;
my $contract_name = $ARGV[0];
my $password = $ARGV[1];
my $contract_id = $ARGV[2];
my $node = Net::Ethereum->new('http://localhost:8545/');
my $src_account = $node->eth_accounts()->[0];
print 'My account: '.$src_account, "\n";
my $abi = $node->_read_file('build/'.$contract_name.'.abi');
$node->set_contract_abi($abi);
$node->set_contract_id($contract_id);
# Call contract methods without transactions
my $function_params={};
my $test1 = $node->contract_method_call('getValue', $function_params);
print Dumper($test1);
my $test = $node->contract_method_call('getString');
print Dumper($test);
my $testAll = $node->contract_method_call('getAll');
print Dumper($testAll);
my $testAllEx = $node->contract_method_call('getAllEx');
print Dumper($testAllEx);
$function_params={};
$function_params->{ pBool } = 1;
$function_params->{ pAddress } = "0xa3a514070f3768e657e2e574910d8b58708cdb82";
$function_params->{ pVal1 } = 1111;
$function_params->{ pStr1 } = "This is string 1";
$function_params->{ pVal2 } = 222;
$function_params->{ pStr2 } = "And this is String 2, very long string +++++++++++++++++=========";
$function_params->{ pVal3 } = 333;
$function_params->{ pVal4 } = '-999999999999999999999999999999999999999999999999999999999999999977777777';
my $rc = $node->contract_method_call('repiter', $function_params);
print Dumper($rc);
# Send Transaction 1
my $rc = $node->personal_unlockAccount($src_account, $password, 600);
print 'Unlock account '.$src_account.'. Result: '.$rc, "\n";
my $function_params={};
$function_params->{ newString } = '+++ New string for save +++';
my $used_gas = $node->contract_method_call_estimate_gas('setString', $function_params);
my $gas_price=$node->eth_gasPrice();
my $transaction_price = $used_gas * $gas_price;
my $call_price_in_eth = $node->wei2ether($transaction_price);
print 'Estimate Transaction Gas: '.$used_gas.' ('.sprintf('0x%x', $used_gas).') wei, '.$call_price_in_eth.' ether', "\n";
my $tr = $node->sendTransaction($src_account, $node->get_contract_id(), 'setString', $function_params, $used_gas);
print 'Waiting for transaction: ', "\n";
my $tr_status = $node->wait_for_transaction($tr, 25, $node->get_show_progress());
print Dumper($tr_status);
# Send Transaction 2
$rc = $node->personal_unlockAccount($src_account, $password, 600);
print 'Unlock account '.$src_account.'. Result: '.$rc, "\n";
$function_params={};
$function_params->{ newValue } = 77777;
$used_gas = $node->contract_method_call_estimate_gas('setValue', $function_params);
$transaction_price = $used_gas * $gas_price;
$call_price_in_eth = $node->wei2ether($transaction_price);
print 'Estimate Transaction Gas: '.$used_gas.' ('.sprintf('0x%x', $used_gas).') wei, '.$call_price_in_eth.' ether', "\n";
$tr = $node->sendTransaction($src_account, $node->get_contract_id(), 'setValue', $function_params, $used_gas);
print 'Waiting for transaction: ', "\n";
$tr_status = $node->wait_for_transaction($tr, 25, $node->get_show_progress());
print Dumper($tr_status);
$testAllEx = $node->contract_method_call('getAllEx');
print Dumper($testAllEx);
В качестве первого параметра скрипту нужно передать имя класса контракта (как и для скрипта компилирования и публикации контракта), в качестве второго — пароль учетной записи, а в качестве третьего — адрес контракта, который был выведен на консоль программой deploy_contract.pl.
В самом начале своей работы скрипт debug_contract.pl получает адрес первой учетной записи, созданной на узле, и сохраняет его в переменной $src_account. От имени этой учетной записи мы будем отправлять транзакции.
Для того чтобы мы могли вызывать методы контракта, нам необходимо подгрузить содержимое файла спецификации бинарного интерфейса abi, а также сохранить в объекте адрес контракта:
my $abi = $node->_read_file('build/'.$contract_name.'.abi');
$node->set_contract_abi($abi);
$node->set_contract_id($contract_id);
Вызов методов без образования транзакции
Если методы контракта не создают транзакции (например, возвращают значение из переменных, константы или литералы), то мы можем использовать метод contract_method_call:
$function_params={};
$function_params->{ pBool } = 1;
$function_params->{ pAddress } = "0xa3a514070f3768e657e2e574910d8b58708cdb82";
$function_params->{ pVal1 } = 1111;
$function_params->{ pStr1 } = "This is string 1";
$function_params->{ pVal2 } = 222;
$function_params->{ pStr2 } = "And this is String 2, very long string +++++++++++++++++=========";
$function_params->{ pVal3 } = 333;
$function_params->{ pVal4 } = '-999999999999999999999999999999999999999999999999999999999999999977777777';
my $rc = $node->contract_method_call('repiter', $function_params);
print Dumper($rc);
Обратите внимание, что переменная pVal4 типа int256 получает очень большое отрицательное значение. В соответствующем поле метод contract_method_call вернет значение типа Math::BigInt.
Вызов транзакционных методов
Если нужно установить значение поля данных класса Solidity, придется вызывать метод, инициирующий транзакцию (аналогично тому, как мы делали при публикации контракта).
Для этого нам прежде всего нужно разблокировать учетную запись методом personal_unlockAccount.
Далее мы готовим хеш с передаваемыми значениями $function_params и оцениваем количество газа, необходимого для выполнения транзакции при помощи метода contract_method_call_estimate_gas. Транзакция отправляется методом sendTransaction:
my $function_params={};
$function_params->{ newString } = '+++ New string for save +++';
my $used_gas = $node->contract_method_call_estimate_gas('setString', $function_params);
my $gas_price=$node->eth_gasPrice();
my $transaction_price = $used_gas * $gas_price;
my $call_price_in_eth = $node->wei2ether($transaction_price);
print 'Estimate Transaction Gas: '.$used_gas.' ('.sprintf('0x%x', $used_gas).') wei, '.$call_price_in_eth.' ether', "\n";
my $tr = $node->sendTransaction($src_account, $node->get_contract_id(), 'setString', $function_params, $used_gas);
print 'Waiting for transaction: ', "\n";
my $tr_status = $node->wait_for_transaction($tr, 25, $node->get_show_progress());
print Dumper($tr_status);
Далее при помощи метода wait_for_transaction мы дожидаемся ее завершения. Этот метод возвращает статус транзакции, который вы можете проверить.
Заключение
Читайте также мою статью Эксперименты с контрактами Solidity в тестовой сети Rinkeby блокчейна Ethereum.
Хотелось бы выразить особую благодарность автору статьи описание интерфейсов управления Management APIs. Эта статья помогла мне разобраться с самым запутанным в Ethereum JSON RPC API — упаковкой и распаковкой параметров для конструктора смарт-контракта и его методов.
Также я надеюсь, что модуль Net::Ethereum поможет проектам, созданным на языке программирования Perl, интегрироваться с блокчейном Ethereum.
vrag86
Немного просмотрел код
1. Вы глобально переопределили $/
Надо:
2. Для файлового дескриптора лучше использовать локальные переменные,
3. И после, лучше закрывать чтение
AlexandreFrolov Автор
Спасибо, добавил в TODO!
AlexandreFrolov Автор
Исправлено в версии 0.27.
nohuhu
Зачем вообще велосипед?
AlexandreFrolov Автор
Спасибо, так короче! Вообще хотелось поменьше зависимостей от сторонних модулей…
nohuhu
Если у вас нет каких-нибудь очень специальных требований к коду, то подход "поменьше зависимостей" будет весьма контрпродуктивным. Как выясняется, даже простое чтение данных из файла может оказаться весьма непростым — зачем вам писать свой код и открывать потенциальный вектор для проблем, если уже есть модуль, решающий эти проблемы?
AlexandreFrolov Автор
Да, согласен, подправлю.
TheAthlete
Сейчас обычно рекомендуют File::Slurper
AlexandreFrolov Автор
Хорошо, спасибо! Добавил в TODO!
nohuhu
Спасибо, не знал. Почитав обсуждение бага со слоями i/o и декодированием UTF-8, проникся… Чьорт побери, в какие сложные времена мы живём, если даже банальное чтение файла целиком в память оказывается совершенно нетривиальным делом. ;(
nohuhu
JFYI, при использовании лексической переменной для i/o файл будет закрыт автоматически при выходе из блока. Закрывать явно конечно лучше, но на всякий случай — катастрофы не будет. :)
А вот когда вместо лексической переменно используется file glob как в оригинальном коде, то файл автоматически закрываться не будет и код будет "течь" файловыми дескрипторами.