Интегрируем смартконтракт в веб-приложение на Nodejs
- понедельник, 4 декабря 2017 г. в 03:12:28
Если вам интересна тема разработки продуктов использующих смартконтракты, но вы хотите понять полный цикл создания таких приложений, то этот урок специально для вас (надеюсь). Из него вы узнаете как разработать, оттестировать, залить в сеть и интегрировать в ваше приложение смартконтракт для блокчейна Ethereum.
Для примера я взял знакомый всем с детства финансовый инструмент — копилку. Для того чтобы продемонстрировать всю мощь смарт-контрактов, я добавил возможность указать лимит, который не позволит снять деньги пока на счету не накопится определенная сумма. Все материалы урока вы можете найти в репозитории PiggyBank, который содержит скрипты и UI для запуска примера.
Цель урока показать полный цикл разработки, поэтому код местами значительно упрощен. В повседневной разработке я советую применять иструменты вроде Truffle.
Внимание! Для запуска необходимо вызвать npm install
чтобы установить библиотеки ethereumjs-testrpc, web3 и прочие.
Помимо написания самого смарт-контракта, необходимо проделать следущие шаги:
Чтобы запустить приложение вам понадобится запустить тестовую сеть. Сделать это можно командой node bin/testnet.js
.
Подсказка: Для запуска приложения шаги до третьего можно пропустить, так как все данные получаемые в шагах 1 и 2 добавлены в репозиторий в готовом виде.
Для начала напишем контракт. Алгоритм работы контаркта следующий:
PiggyBank
).deposit
).canWithdraw
).withdraw
).Приведу код контракта из файла contract.sol
:
pragma solidity ^0.4.0;
contract PiggyBank {
// Адрес владельца кошелька
address public owner;
// Лимит на выдачу средств
uint public limit;
// Множитель для перевода ether в wei
uint decimals = (10 ** 18);
// Модификатор метода. Предотвращет вызов посторонними
// методов доступных только владельцу контракта.
modifier isOwner() {
require(msg.sender == owner);
_;
}
// Событие вызываемое в момент пополнения счета.
event Deposit(address indexed from, uint value);
// Конструктор, получает минимальное количество монет на счету доступное для списания
// значение должно быть больше нуля и указывается в ether.
function PiggyBank(uint _limit) public {
require(_limit > 0);
owner = msg.sender;
limit = _limit * decimals;
}
// Метод для пополнения счета. Обязан содержать модификатор payable, чтобы
// принимать средства.
function deposit() public payable {
Deposit(msg.sender, msg.value);
}
// Метод проверяет достигнут ли лимит и доступны ли средства для списания.
// Ничего не изменяет поэтому имеет модификатор constant
function canWithdraw() public constant returns (bool) {
return this.balance >= limit;
}
// Метод отправляет средства владельцу контракта.
// Здесь мы используем собственный модификатор isOwner для отправки средств
// владельцу контракта.
function withdraw() public isOwner {
require(canWithdraw());
// Вместо owner можно использовать msg.sender, так как они в данном случае совпадают:
// вызвать метод может только owner.
owner.transfer(this.balance);
}
// Метод уничтожает контракт, но только если баланс пуст, иначе возвращает ошибку.
function kill() public isOwner {
require(this.balance == 0);
selfdestruct(owner);
}
}
В быстрой разработке контракта вам поможет онлайн-IDE.
После того, как контракт создан можно приступить к решению инфраструктурных задач.
Файл 1-account.js
.
Данный скрипт создает тестовый аккаунт с определенным балансом. При вызове из консоли файла 0-account.js вам будет предложенно ввести пароль и сумму в ether на вашем счету. После успешного выполнения секретный ключ и сумма будут записаны в файл account.json
.
Файлaccount.json
используется в тестнете. Поэтому, если тестнет запущен (bin/testnet.js
), перезапустите его.
расскажу подробнее о ключах. Для создания аккаунта необходимо создать секретный ключ. Из секретного ключа будут в дальнейшем получен адрес кошелька и публичный ключ. Секретный ключ это шестнадцатиричное число размером 256 бит, представленное в виде строки длинной 64 символа, содержащей префикс 0x
.
Лучше всего получить подобное значение с помощью генератора случайных чисел:
const crypto = require('crypto');
const key = '0x' + crypto.randomBytes(32).toString('hex');
Но для тестовых нужд мы будем получать sha3-хеш из введенного пользователем пароля:
const privateKey = Web3.utils.soliditySha3({ type: 'string', value: '******' });
Что на выходе даст нам:
0xc774c26b6185ccacd0ea11d1e5f03b5bac7d8171911d1861b8b7c1ab123ec94a
Чтобы работать с кошельком созданным вручную вам понадобится добавить его через web3 API. И хотя в данном уроке вам это не понадобится я все же покажу как это делается:
// Получаем адрес из приватного ключа
const address = web3.eth.accounts.privateKeyToAccount(privateKey);
// Добавляем кошелек
web3.accounts.wallets.add({
privateKey,
address,
});
После этого вы сможете отправлять транзакции с помощью сгенерированного вами ключа. Это может понадобится если ваше приложение будет самостоятельно создавать аккаунты, особенно, если их много. Стандартный метод web3.eth.personal.newAccount
будет записывать ключи на диск в директорию ~/.ethereum/keystore
, что может быть по каким-либо причинам не желательно.
Внимание! Настоятельно рекомендуется хранить закрытые ключи в зашифрованном виде.
Про менеджмент ключей и использование собственных ключей, я постараюсь рассказать отдельно.
Файл 2-compile.js
.
Данный скрипт компилирует исходный код из файла contract.sol
и сохраняет результат в code.json
, который будет использоваться в дальнейшем для деплоя и взаимодействия с контрактом.
Контакты в сети Ethereum хранятся в бинаром представлении, поэтому перед тем как использовать контракт нам необходимо скомпилирвоать исходный код. Делается это с помощью инструмента solc и в случае nodejs пакета solc (это скомпилированный с помощью emscripten solc).
После компиляции мы получим на выходе бинарный код bytecode
, а так же описание интерфейса контракта. Вот как будет представлен метод withdraw в интерфейсе:
{
"interface": [
{
"constant": false,
"inputs": [],
"name": "withdraw",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
...
На выходе solc возвращает все контракты, которые найдет в исходном коде. Нам понадобится выбрать один, в нашем случае это PiggyBank
:
const compiled = result.contracts[':PiggyBank'];
Файл 3-deploy.js
.
Скрипт берет скомплированный код из code.json
. А затем создает контракт и заливает код в тестнет от имени пользователя. Полученный в результате адрес и интерфейс контракта записываются в файл contract.json
.
Сначала создается пустой инстанс с интерфейсом и настройками по умолчанию (from
и gas
).
const PiggyBank = new web3.eth.Contract(code.interface, {
from: coinbase,
gas: 5000000, // Максимальное количество бензина
});
From — адрес от имени которого будут вызыватсья методы контракта.
Gas или бензин — это топливо для контракта, которое тратится в процессе работы приложения. Он нужен для того, чтобы избежать бесконечных циклов, способных остановить работу сети.
И так, все готово к заливке контракта:
const contract = await PiggyBank.deploy({
// Код контракта
data: code.bytecode,
// Аргументы конструктора
arguments: [1],
})
.send();
Вызов конструктора происходит в момент деплоя, поэтому мы сразу передаем в него аргументы. В случае с PiggyBank конструктор содержит один аргумент uint _limit
. После выполнения данного кода с нас списали средства отдельно за проведение транзакции и отдельно за выполнение кода конструктора.
Все готов к запуску, останется только сохранить адрес контракта:
contract.options.address;
Файл 4-run.js
. Запуск npm start
.
Скрипт запускает веб-сервер на порту $PORT
или 8080
с простым интерфейсом для взаимодействия с контрактом. Открыв в браузере http://localhost:8080
вы сможете перечислить деньги на счет (deposit
) или перевести на счет владельца (withdraw
).
Рассмотрим что происходит немного подробнее. Для начала мы создаем инстанс контракта ссылающийся на тот, что мы задеплоили ранее:
const piggy = new web3.eth.Contract(contract.interface, contract.address, {
from: coinbase,
gas: 5000000,
});
К вызову конструктора добавился еще один аргумент — address
, который указывает на то, что это действующий контракт. Давайте посмотрим что мы можем с ним сделать. Как вы помните у нас есть методы deposit
, canWithdraw
и withdraw
. Чтобы пополнить счет нам необходимо вызвать метод deposit
и отправть несколько монет в копилку.
piggy.methods.deposit().send({
// Конвертируем ether в wei
value: web3.utils.toWei('1', 'ether'),
});
Ethereum использует в расчетах 18 знаков после запятой и при этом не поддерживает типы с плавающей точкой. Рассчеты производятся в веях, а затем конвертируются в эзеры. Для этого перед отправкой мы конвертируем ether в wei с помощью метода web3.utils.toWei
. Которая в свою очередь использует библиотеку BigNumber.js, для
рассчетов со значениями превышающими макисмально допустимые для типа Number.
Вызов метода canWithdraw
будет отличаться, так как этот метод не вносит никаких изменений (constant
), то для вызова вместо send
используется call
. Такая операция не вызовет списания средств и расходование бензина:
piggy.methods.canWithdraw().call();
Метод для отправки монет в копилку может выглядеть так:
router.use(async ({res}) => {
await piggy.methods.deposit().send({
value: web3.utils.toWei('1', 'ether'),
});
res.json(true);
});
Файл 5-destroy.js
.
Скрипт уничтожает контракт и удаляет из блокчена данные контракта. Не смотря на то, что вы все еще можете перречислить деньги на контракт, выполнить иные операции вы уже не сможете.
Файл test/test.spec.js
. Запуск npm test
.
Для тестирвоания используется билиотека mocha. Перед тем как запустить тесты нам понадобится запустить изолированный тестнет с предустановленными данными. Для этого необходимо:
Вот как это может выглядеть инициализация новой сети:
const Web3 = require('web3');
const TestRpc = require('ethereumjs-testrpc');
const web3 = new Web3(
TestRpc.provider({
accounts: [
{
secretKey: Web3.utils.soliditySha3('password1'),
balance: Web3.utils.toWei(String(10), 'ether'),
},
{
secretKey: Web3.utils.soliditySha3('password2'),
balance: Web3.utils.toWei(String(10), 'ether'),
},
],
}),
);
Мы создаем тестнет с двумя пользователями, а затем инициализируем инстанс web3. Тестнет готов. Можно приступать к тестированию. Например оттестируем конструктор:
describe('PiggyBank()', function() {
it('Should instantiate contract', async function() {
await PiggyBank.deploy({
data: code.bytecode,
arguments: [2],
})
.send();
const limit = await PiggyBank.methods().limit().call();
should(web3.utils.fromWei(limit, 'ether')).be.equal('2');
});
});
В данном примере мы написали очень простое приложение, не обладающее сверхсложным поведением, но наглядно иллюстрирующее жизненный цикл контракта. Надеюсь это будет полезно тем, кто только начинает осваивать разработку для Ethereum.