Интегрируем смартконтракт в веб-приложение на Nodejs

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


UI приложения


Для примера я взял знакомый всем с детства финансовый инструмент — копилку. Для того чтобы продемонстрировать всю мощь смарт-контрактов, я добавил возможность указать лимит, который не позволит снять деньги пока на счету не накопится определенная сумма. Все материалы урока вы можете найти в репозитории PiggyBank, который содержит скрипты и UI для запуска примера.


Цель урока показать полный цикл разработки, поэтому код местами значительно упрощен. В повседневной разработке я советую применять иструменты вроде Truffle.


Внимание! Для запуска необходимо вызвать npm install чтобы установить библиотеки ethereumjs-testrpc, web3 и прочие.


Шаги


Помимо написания самого смарт-контракта, необходимо проделать следущие шаги:


  1. Программно создать аккаунт
  2. Скомпилировать контракт
  3. Задеплоить контракт в блокчейн
  4. Оттестировать
  5. Создать приложение, взаимодействующее с контрактом.


Чтобы запустить приложение вам понадобится запустить тестовую сеть. Сделать это можно командой node bin/testnet.js.


Подсказка: Для запуска приложения шаги до третьего можно пропустить, так как все данные получаемые в шагах 1 и 2 добавлены в репозиторий в готовом виде.


Контракт


Для начала напишем контракт. Алгоритм работы контаркта следующий:


  1. Пользователь создает контракт и указывает неизменяемый лимит на выдачую (метод PiggyBank).
  2. Пользователь отправляет монеты на кошелек контракта (метод deposit).
  3. Пользователь проверяет доступна ли операция списания средств (метод canWithdraw).
  4. Пользователь списывает средства на свой счет (метод 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. Перед тем как запустить тесты нам понадобится запустить изолированный тестнет с предустановленными данными. Для этого необходимо:


  1. Создать новую тестовую сеть с пользователями.
  2. Задеплоить контракт в тестовую сеть.


Вот как это может выглядеть инициализация новой сети:


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.

© Habrahabr.ru