Погружение в разработку на Ethereum. Часть 3: приложение для пользователя

  • Tutorial
В предыдущих статьях (часть 1 и часть 2) мы описали как можно пользоваться децентрализованными приложениями на смарт-контрактах, если вы сами не против быть нодом. Но чем меньше пользователя заставляют совершать дополнительные действия — тем лучше. Для работы со смарт контрактами необходимо подписывать транзакции приватным ключом, поэтому совсем без дополнительный действий, к сожалению, не обойтись. В этой статье мы рассмотрим два подхода: первый — полноценное децентрализованное приложение (DApp) на javascript с использованием библиотеки Web3 и плагина MetaMask, второй — аналогичное приложение, но использующее Ruby on Rails API и гем Ethereum.rb для доступа к блокчейну.



Для демонстрации работы настоящего DApp рассмотрим приложение, вдохновленное официальным примером. Вместо Democracy с голосованием и выполнением произвольных транзакций сделаем упрощенный контракт Charity, в котором кто угодно создает предложение по распределению денег (эфира), участники голосуют, и после истечения дедлайна предложение либо выполняется, либо нет, в зависимости от результата голосования.
Логика смарт контракта в данном случае не очень важна, наша цель — продемонстрировать варианты взаимодействия пользователя с блокчейном.
Рассмотрим какие средства мы будем использовать чуть подробнее а после перейдем непосредственно к приложению.

Используемые средства


1. MetaMask


Это такой плагин для браузера Chrome, разработчики пишут, что планируют и для Firefox, но пока только хром. Скачать можно здесь. Плагин делает две вещи:

  • Обеспечивает подключение к нужной Ethereum-сети через свой удаленный узел, благодаря этому пользователю не требуется разворачивать узел у себя.
  • Позволяет создавать новые или импортировать существующие аккаунты для работы с блокчейном. Это дает возможность подписывать транзакции приватным ключом, который хранится локально у пользователя.

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



При необходимости можно импортировать существующий ключ, но удобнее просто создать новый.



Если вы хотите импортировать ключ, созданный в Geth или Mist — при импорте выбирайте JSON File и находите файл с нужным адресом в директориях ~/.ethereum/<сеть>/keystore. Стоит отметить, что по крайней мере на Ubuntu на момент публикации статьи есть баг с открытием JSON файла: после выбора файла окно MetaMask закрывается и никакой ключ не импортируется. В этом случае попробуйте открыть MetaMask в отдельной вкладке, используя адрес расширения chrome-extension://nkbihfbeogaeaoehlefnkodbefgpgknn/popup.html
Кстати, выбрав тестовую сеть и нажав BUY можно получить ссылки на ресурсы, раздающие тестовый эфир. Даже для Ropsten (достаточно нажать request 1 ether from faucet):



2. Использование библиотеки Web3.js с плагином MetaMask


Web3.js — это JavaScript библиотека, та же, которую мы использовали в Geth в предыдущей статье. MetaMask встраивает Web3.js в каждую открытую страницу, поэтому можно протестировать простые команды непосредственно в javascript консоли в Chrome Developer Tools. Важно отметить, на момент написания этой статьи, актуальная версия Web3.js — 0.20.1. Документация для версии 0.x.x доступна по ссылке, не путайте с документацией для версии 1.0 (ссылка).
Выполним две команды, одну на получение данных, например баланса аккаунта, вторую на изменение, например задание строки в смарт контракте StringHolder из предыдущей статьи. Предварительно не забудьте создать аккаунт в MetaMask, подключиться к нужной сети (в данном случае Ropsten Test Network) и зайти в консоль Developer Tools.


> web3.eth.getBalance(web3.eth.accounts[0], function(error, result) { console.log(web3.fromWei(result.toNumber())); } )

Не забывайте при вызове методов контракта передавать коллбэк функцию в качестве последнего аргумента. Можно так:

> web3.eth.getBalance(web3.eth.accounts[0], console.log)
null
e {s: 1, e: 18, c: Array(2)}
c:(2) [78950, 84540000000000]
e:18
s:1
__proto__:Object

Web3.js использует библиотеку BigNumber для числовых значений. В примере выше ответ выведен без преобразования.
Команды чтения выполняются сразу, а если вы захотите выполнить функцию, изменяющую данные в смарт контракте (не помеченную как constant), то MetaMask выдаст окно подписи транзакции. Продемонстрируем это, открыв контракт StringHolder из прошлой статьи и вызвав в нем метод задания строки:

> var address = "0x65cA73D13a2cc1dB6B92fd04eb4EBE4cEB70c5eC";
> var abi = [ { "constant": false, "inputs": [ { "name": "newString", "type": "string" } ], "name": "setString", "outputs": [], "payable": false, "type": "function" }, { "constant": true, "inputs": [], "name": "getString", "outputs": [ { "name": "", "type": "string", "value": "Hello World!" } ], "payable": false, "type": "function" } ];
> var contract = web3.eth.contract(abi);
> var stringHolder = contract.at(address)
> stringHolder.getString(console.log)
null "Hello Dolly!!!  22"
> stringHolder.setString("Hello from the other side!", console.log)


После вызова метода setString выдается окно с запросом подтвердить транзакцию и информацией по предположительным затратам газа и эфира. Нажмите Submit. В консоли увидите хеш транзакции.


Через некоторое время проверим, что строка изменилась:

> stringHolder.setString("Hello from the other side!", console.log)
"0x4252c00ff25b690846ec8ad3b4266047a75a1708153bcac24066da9cb01e6db5"
> stringHolder.getString(console.log)
null "Hello from the other side!"

Все работает как должно.

3. Ethereum.rb


Это библиотека для работы с блокчейном Ethereum из Ruby (ссылка на github) — на данный момент поддерживается наиболее активно.
Попробуем открыть контракт StringHolder из irb консоли:

> require “ethereum.rb”
> abi = '[ { "constant": false, "inputs": [ { "name": "newString", "type": "string" } ], "name": "setString", "outputs": [], "payable": false, "type": "function" }, { "constant": true, "inputs": [], "name": "getString", "outputs": [ { "name": "", "type": "string", "value": "Hello World!" } ], "payable": false, "type": "function" } ]'
> address = "0x65cA73D13a2cc1dB6B92fd04eb4EBE4cEB70c5eC"
> contract = Ethereum::Contract.create(name: "StringHolder", address: address, abi: abi)
> contract.call.get_string()
“Hello from the other side!”

Заметьте, кэмел-кейс (getString) автоматически преобразовался в снейк-кейс (get_string)

Особенность вызова методов здесь в том, что через call вызываются только геттеры, где нужно просто получить данные. Для выполнения транзакций будет нужен transact_and_wait для синхронного вызова либо transact для асинхронного вызова.
Попробуем вызвать функцию set_string, для которой будет создаваться транзакция. Есть два способа: unlockAccount (deprecated) и sign transaction (об этом чуть позже).

> Ethereum::IpcClient.new.personal_unlock_account(Ethereum::IpcClient.new.eth_coinbase["result"], "<пароль>")

Необходимо назначить от кого посылается транзакция (того же, кого мы сейчас разблокировали):

> contract.sender = Ethereum::IpcClient.new.eth_coinbase["result"]

Потом можно вызывать сеттер вместо call подставив либо transact_and_wait, либо transact:

> contract.transact_and_wait.set_string(“Hello darkness, my old friend”)

Дожидаемся окончания, вызываем

> contract.call.get_string()

Видим наши изменения — все работает.

Что должен делать наш DApp


Сформулируем задачу. У нас должен быть контракт, представляющий собой благотворительную организацию со счетом. В этой организации могут быть зарегистрированы пользователи, которые могут выступать и в качестве получателей пожертвований и в качестве голосующих за предложения. Нужен способ создать предложение о перечислении денег (эфира) со счета организации на один из зарегистрировавшихся аккаунтов. Чтобы не было искушения забрать за раз весь эфир введем ограничение — предложить можно не более 1 эфира. Далее идет голосование (можно голосовать “за” или “против”), которое нельзя завершать до определенного дедлайна (5 минут с момента создания предложения). После дедлайна голоса продолжают приниматься, но должна быть возможность завершить голосование, и если оно завершилось с бо́льшим количеством “за” чем “против” — перечислять эфир со счета организации на счет получателя. Если больше голосов “против” — ничего не делать.
В целом схема приложения такая:


Два js модуля — Blockchain.js и BlockchainApi.js — отвечают за работу с блокчейном. Они делают одно и тоже, только первый работает c Web3.js и через ноду MetaMask напрямую обращается к блокчейну, второй — делает ajax запросы к Rails API, где уже происходит взаимодействие с блокчейном через гем ethereum.rb. Само клиентское приложение написано на React и не зависит от того, какой js модуль из двух используется.

Основной контракт Charity


Контракт в виде одного единственного файла, поэтому не будем пользоваться специальными средствами для деплоя, сделать это можно любым способом, хоть через Mist, хоть используя командную строку Geth. Можете использовать наш код отсюда. В целом его схему можно изобразить так:



Рассмотрим код контракта Charity.sol по логическим блокам. Сначала идет описание всех нужных нам переменных:

    uint public debatingPeriodInMinutes; // время на голосование
    Proposal[] public proposals; // массив предложений пожертвований, структура описана далее
    uint public numProposals; // количество элементов в массиве proposals
    uint public numMembers; //количество элементов в массиве members
    mapping (address => uint) public memberId; // маппинг адреса аккаунта на индекс в массиве members
    address[] public members; // массив зарегистрированных аккаунтов

Маппинг позволяет по адресу пользователя получить его индекс в массиве. Если пользователя с таким адресом не существует, то будет возвращен индекс 0. На этом будет основана далее функция, определяющая, зарегистрирован ли пользователь с данным адресом. Но это вносит требование для массива: пользователи должны храниться в массиве начиная с индекса 1. Код, отвечающий за эту логику будет рассмотрен дальше. А пока идет структура для хранения предложения.

    struct Proposal {
        address recipient; // получатель
        uint amount; // размер пожертвования
        string description; // описание пожертвования
        uint votingDeadline; // дедлайн
        bool executed; // флаг, что пожертвование уже совершено или отклонено
        bool proposalPassed; // флаг, что голосование одобрило пожертвование
        uint numberOfVotes; // количество голосов
        int currentResult; // сумма голосов, “за“ = +1, “против” = -1
        Vote[] votes; // список голосов с адресом каждого проголосовавшего и значением голоса, структура описана далее
        mapping (address => bool) voted; // маппинг для быстрой проверки, проголосовал ли аккаунт с таким-то адресом
    }

Структура голосов, складывается в массив для каждого предложения

    struct Vote {
        bool inSupport; // значение голоса
        address voter; // и адрес аккаунта проголосовавшего
    }

Рассмотрим модификатор, который позволит нам контролировать, что выполнение методов, к которым он будет добавлен, возможно только если пользователь зарегистрирован. Код проверки, как уже было сказано, основан на том, что несуществующие элементы маппинга дают индекс 0, а пользователей будем хранить начиная с индекса 1:

    modifier onlyMembers {
        require (memberId[msg.sender] != 0); // методы будут выполнять такой код проверки
        _; // код модифицируемого метода подставляется вместо знака подчеркивания
    }

msg — это структура, по которой можно получить информацию о вызывающем. В данном случае msg.sender — это адрес аккаунта, который вызвал метод с этим модификатором.

Опишем конструктор нашего контракта, который будет выполняться при деплое. Все, что требуется задавать — время, которое выделяется для голосования за каждое предложение. Кроме этого увеличиваем размер массива members, потому что добавлять пользователей будем исходя из размера, а нулевой элемент остается зарезервированным.

    function Charity( uint _minutesForDebate ) payable { // payable означает, что вместе с транзакцией можно отправить эфир, этот эфир зачислится на счет контракта
        debatingPeriodInMinutes = _minutesForDebate;
        members.length++;
    }

Функция для добавления пользователя:

    function addMember(address _targetMember) {
        if (memberId[_targetMember] == 0) { // 0 является признаком, что пользователь не зарегистрирован
            uint id;
            memberId[_targetMember] = members.length; // индексом будет номер еще не добавленного элемента, сохраняем его в маппинге
            id = members.length++; // сохраняем индекс и увеличиваем размер массива
            members[id] = _targetMember; // сохраняем адрес в массиве
        }
    }

Заметьте функцию require — она пришла на замену throw в более старых версиях solidity. В require передается true или false, если это false — то срабатывает обработчик аналогичный throw — откатывается вся транзакция.
Чтобы можно было проверить, находится ли адрес в списке пользователей используем такую функцию:

function isMember( address _targetMember ) constant returns ( bool ) {
        return ( memberId[_targetMember] != 0 );
    }

Следующая функция — для создания предложения, принимает адрес получателя пожертвования, количество эфира в wei и строку с описанием. К этой функции применяется модификатор onlyMembers, это значит до выполнения всего кода произойдет проверка, что вызывающий аккаунт зарегистрирован. Здесь вы увидите такие преобразования как 1 ether и 1 minutes. Полный список таких суффиксов можете посмотреть здесь, они сделаны для удобства и могут применяться только к значениям, но не к переменным. Но чтобы применить к переменной достаточно просто добавить 1 к суффиксу, что и сделано в нашем случае для преобразования в секунды.

    function newProposal(
            address _beneficiary, // получатель пожертвования
            uint _weiAmount, // размер пожертвования в wei
            string _description // произвольная строка
        )
            onlyMembers
            returns (uint proposalID)
    {
        require( _weiAmount <= (1 ether) ); // ограничим пожертвование в 1 эфира 
        proposalID = proposals.length++; // увеличение размера массива на 1
        Proposal storage p = proposals[proposalID]; // далее идет присвоение элементу значений
        p.recipient = _beneficiary;
        p.amount = _weiAmount;
        p.description = _description;
        p.votingDeadline = now + debatingPeriodInMinutes * 1 minutes; // расчет дедлайна по текущему времени и времени на голосование
        p.executed = false; // обнуление флагов завершенности транзакции
        p.proposalPassed = false;
        p.numberOfVotes = 0;
        numProposals = proposalID + 1; // сохранение размера массива

        return proposalID;
    }

Заметьте здесь ключевое слово now — это текущее время, но не на момент вызова транзакции, а на момент создания блока. Поэтому дедлайн будет отсчитываться с момента, когда предложение уже будет создано на блокчейне.

Несмотря на то, что proposals у нас public, получать таким образом можно только простейшие поля в виде массива. То есть вызвав в контракте метод например proposals(1), мы получим предложение с индексом 1 в виде массива { recipient, amount, description, votingDeadline, executed, proposalPassed, numberOfVotes, currentResult }, а массивы votes и voted внутри структуры не вернутся. Но нам нужна информация о том, проголосовал ли пользователь за определенное предложение, чтобы отображать его голос или дать возможность проголосовать. И желательно сделать это в одно обращение, поэтому мы получаем эту информацию когда читаем структуру Proposal для отображения в нашем приложении с помощью специальной функции getProposal, которая принимает аккаунт, для которого нужен статус голоса и идентификатор предложения.

    function getProposal( address _member, uint _proposalNumber ) constant
        returns ( address, // описываем типы в возвращаемом массиве
                  uint,
                  string,
                  uint,
                  bool,
                  bool,
                  uint,
                  int,
                  int ) {
        Proposal memory proposal = proposals[ _proposalNumber ]; // берем элемент для удобства
        int vote = getVoted( _member, _proposalNumber ); // используем вспомогательную функцию (описана позже) для получения информации о голосе конкретного пользователя
        return ( proposal.recipient,
                 proposal.amount,
                 proposal.description,
                 proposal.votingDeadline,
                 proposal.executed,
                 proposal.proposalPassed,
                 proposal.numberOfVotes,
                 proposal.currentResult,
                 vote ); // высылаем массив в соответствии с ожидаемыми типами
    }

А это вспомогательная функция, которая ищет как проголосовал конкретный пользователь в конкретном предложении. Возвращаться будет: 0 — если пользователь не проголосовал, 1 — если пользователь проголосовал “за”, -1 — если проголосовал “против”.

    function getVoted(address _member, uint _proposalNumber) constant returns(int)
    {
        Proposal storage p = proposals[_proposalNumber];
        int result = 0;
        int true_int = 1;
        int false_int = -1; // определяем возвращаемые значения
        for (uint i = 0; i < p.numberOfVotes; i++)
        {
            if (p.votes[i].voter == _member) // ищем нужного пользователя перебором
            {
                result = p.votes[i].inSupport ? true_int : false_int;
                break; // если находим выходим и возвращаем значение
            }
        }
        return result;
    }

Голосование: для предложения с конкретным номером отдаем голос true (за) или false (против).

    function vote(
            uint _proposalNumber, // номер предложения, за которое отдается голос
            bool _supportsProposal // голос
    )
            onlyMembers
            returns (uint voteID)
    {
        Proposal storage p = proposals[_proposalNumber];    // для удобства возьмем нужный элемент из массива
        require (p.voted[msg.sender] != true);      // не продолжать если пользователь уже голосовал
        p.voted[msg.sender] = true;                // отметить пользователя как проголосовавшего
        p.numberOfVotes++;                 // увеличить количество проголосовавших для предложения
        if (_supportsProposal) {                        // если проголосовали “за”
            p.currentResult++;                          // увеличить результат на 1
        } else {                                        // если против
            p.currentResult--;                          // уменьшить результат на 1
        }
        voteID = p.votes.length++; // добавление нового голоса в массив голосов
        p.votes[voteID] = Vote({inSupport: _supportsProposal, voter: msg.sender}); // инициализация структуры
        return p.numberOfVotes;
    }

И последняя функция executeProposal служит для завершения голосования и отправки (или неотправки) эфира на адрес получателя.

    function executeProposal(uint _proposalNumber) { // выполнить предложение с таким номером
        Proposal storage p = proposals[_proposalNumber];

        require ( !(now < p.votingDeadline || p.executed) ); // предложение должно 1) пройти дедлайн, 2) не быть уже выполненным
        p.executed = true; // помечаем как выполненное

        if (p.currentResult > 0) { // если большинство проголосовало “за”
            require ( p.recipient.send(p.amount) ); // отправить эфир получателю
            p.proposalPassed = true; // пометить, что предложение одобрено
        } else { // если “за” проголосовало не большинство
            p.proposalPassed = false; // пометить, что предложение отклонено и ничего не отправлять
        }
    }

В конце присутствует пустая функция с модификатором payable.

function () payable {}

Это нужно для того, чтобы на адрес контракта можно было присылать эфир. Вообще пустая функция — это функция, которая принимает и обрабатывает все сообщения, которые не являются вызовом функций. Все, что нам требуется — это сделать ее payable, тогда отправленный газ просто зачислится на контракт без каких-либо дополнительных действий. Но заметьте, что на других функциях этого модификатора нет, поэтому в нашем случае нельзя отправлять эфир например с вызовом addMember.

Вариант приложения с использованием Web3.js


Основной сценарий приложения:

  1. Пользователь подключается к сети Ropsten через MetaMask
  2. Если на аккаунте нет эфира, будет невозможно выполнить ни одну транзакцию. Мы добавили функцию получения эфира, которая становится доступна при балансе аккаунта меньше 0.1 эфира. Реализовано это через сторонний сервис, на который делается ajax запрос с адресом, на который нужно перевести эфир.
  3. Основные действия со смарт контрактом доступны только после того, как пользователь станет участником организации. Для этого вызывается метод addMember в смарт контракте.
  4. Участник организации может создать Предложение о переводе средств (далее Proposal), или проголосовать за уже существующее.
  5. Когда истекает время для Proposal (время создания + 5 минут), появляется возможность его завершить, в результате чего, в зависимости от распределения голосов, эфир будет переведен на указанный адрес, или нет.

Демонстрация приложения доступна по ссылке — MetaMask версия.
Исходный код здесь.

Еще раз обращаем ваше внимание на то, что текущая версия Web3.js — 0.20.1. Но уже готовится к релизу версия 1.0, в которой изменения достаточно существенны. Как мы говорили выше, MetaMask встраивает web3 в страницу, и его можно сразу использовать. Но учитывая то, что библиотека активно развивается, а нам нужно гарантировать работоспособность приложения для пользователя, необходимо использовать свою залоченную версию, и переопределять объект web3, который встраивает MetaMask. Мы делаем это здесь в следующем методе:

 initializeWeb3() {
    if (typeof web3 !== 'undefined') { // если MetaMask заинжектил библиотеку
      const defaultAccount = web3.eth.defaultAccount; // сохраняем привязанный аккаунт
      window.web3 = new Web3(web3.currentProvider); // инициализируем свою библиотеку
      window.web3.eth.defaultAccount = defaultAccount; // возвращаем привязанный аккаунт
    }  }

Делать это нужно после события window.onload.
Одна неочевидная проблема, которая решается в этом коде — если просто сделать window.web3 = new Web3(web3.currentProvider) как предлагается в официальной документации, то не подхватывается аккаунт по умолчанию.
Еще в MetaMask, как уже писалось, можно выбирать сеть из списка. У нас используются адреса контрактов в сети Ropsten, если попытаться подключаться по этим адресам в других сетях — результат будет непредсказуем. Поэтому прежде чем предоставлять доступ к приложению, нужно проверить в той ли сети находится пользователь. Получить идентификатор сети можно с помощью команды:

web3.version.getNetwork(function (err, netId) {});

Мы делаем эту проверку здесь и сравниваем результат с id для сети Ropsten — это 3.

Список id всех сетей можно увидеть например здесь в описании net_version.

Вся логика работы с блокчейном находится в файле blockchain.js.

Здесь есть два типа функций — функции для получения данных из блокчейна и функции изменяющие данные в блокчейне. Большинство методов из web3.js выполняются асинхронно и принимают callback в качестве последнего параметра. Поскольку зачастую приходится вызывать несколько методов для получения данных, и вызов некоторых из них зависит от результата работы других — удобно использовать промисы. В версии 1.0 web3.js асинхронные методы возвращают промисы по умолчанию.

Приведем один пример получения информации из блокчейна:
Функция getCurrentAccountInfo возвращает адрес текущего аккаунта, баланс и флаг того, является ли данный аккаунт участником организации.

Blockchain.prototype.getCurrentAccountInfo = function() {
  const address = this.address;
  if (address == undefined) {
    return Promise.resolve({});
  }

  const balancePromise = new Promise(function(resolve, reject) {
    web3.eth.getBalance(address, function(err, res) {
      err ? reject(err) : resolve(web3.fromWei(res).toNumber());
    });
  });

  const authorizedPromise = new Promise(function(resolve, reject) {
    this.contractInstance.isMember(address, function(err, res) {
      err ? reject(err) : resolve(res);
    });
  }.bind(this));

  return new Promise(function(resolve, reject) {
    Promise.all([balancePromise, authorizedPromise]).then(function(data) {
      resolve({
        address: address,
        balance: data[0],
        isMember: data[1]
      });
    });
  });
};

Рассмотрим теперь функцию изменения данных в блокчейне, например функция добавления участника организации.

Blockchain.prototype.becomeMember = function() {
  return new Promise(function(resolve, reject) {
    this.contractInstance.addMember(this.address, function(err, res) {
      err ? reject(err) : resolve(res);
    });
  }.bind(this));
};

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

Blockchain.prototype.checkTransaction = function(transaction) {
  const txPromise = new Promise(function(resolve, reject) {
    web3.eth.getTransaction(transaction.transactionHash, function(err, res) {
      err ? reject(err) : resolve(res);
    });
  });

  const txReceiptPromise = new Promise(function(resolve, reject) {
    web3.eth.getTransactionReceipt(transaction.transactionHash, function(err, res) {
      err ? reject(err) : resolve(res);
    });
  });

  return new Promise(function(resolve, reject) {
    Promise.all([txPromise, txReceiptPromise]).then(function(res) {
      const tx = res[0];
      const txReceipt = res[1];
      const succeeded = txReceipt && txReceipt.blockNumber && txReceipt.gasUsed < tx.gas;
      const failed = txReceipt && txReceipt.blockNumber && txReceipt.gasUsed == tx.gas;

      let state = transactionStates.STATE_PENDING;
      if (succeeded) {
        state = transactionStates.STATE_SUCCEEDED;
      } else if (failed) {
        state = transactionStates.STATE_FAILED;
      }

      resolve(state);
    });
  });
};


При создании новой транзакции, мы добавляем ее в localStorage и периодически опрашиваем ее статус, до тех пор пока не узнаем что она выполнилась успешно или нет. Логика мониторинга транзакций находится в файле — transactions-storage.js.

Вариант приложения с использованием Ruby on Rails и гема ethereum.rb


По-настоящему децентрализованное приложение выглядит как вариант, описанный выше. Пользователь сам подписывает транзакции при помощи ключа, который хранится у него же. Но помимо случаев, когда пользователь приложения непосредственно взаимодействует с блокчейном, бывают случаи, когда требуется доступ к блокчейну на стороне бекенда. Возможно это какое-то внутреннее приложение и действия с блокчейном выполняются от аккаунтов, которые вы контролируете и ключи для которых вы можете хранить на сервере. Возможно логика вашего приложения на смарт контрактах подразумевает реагирование на определенные события от вашего централизованного сервера. В данном случае, помимо web3.js, который вы конечно тоже можете использовать на сервере, было бы неплохо иметь инструмент для привычного вам стека разработки. Для нас таким является Ruby on Rails, поэтому мы решили попробовать библиотеку ethereum.rb, которая должна решать туже задачу, что и web3.js.

Демонстрация приложения доступна по ссылке — Rails API версия.
Исходный код здесь.

Для демонстрации работы с ethereum.rb, мы сделали аналогичный набор функций, описанным в blockchain.js. Код работы с блокчейном можно посмотреть здесь, а мы опишем основные моменты и, возможно, отличия.

Первое — теперь у нас нет доступа к аккаунту пользователя, поэтому нам нужно сделать некоторый процесс авторизации и создавать для каждого пользователя аккаунт в блокчейне. Процесс авторизации в данном примере максимально упрощен, мы просто генерируем токен, который сохраняется в LocalStorage браузера, и по которому происходит авторизация запросов к API.

В целом схема работы с аккаунтами такая:

  1. Пользователь нажимает кнопку Create Account, на сервере в базе данных создается пользователь с уникальным токеном для авторизации, создается аккаунт для подключения к блокчейну и приватный ключ для аккаунта сохраняется в базе данных, токен возвращается пользователю для дальнейшей авторизации.
  2. Пользователь делает запросы к API используя auth token для авторизации.
  3. Приватный ключ пользователя из БД используется для подписи транзакций.

Как и в js версии, есть два типа методов — те что берут данные из блокчейна, и те что изменяют их. Первые — достаточно простые и как они работают понятно из примера.

def proposals(address=nil)
    count = @contract_instance.call.num_proposals
    to = count - 1
    from = count > PROPOSALS_LIMIT ? count - PROPOSALS_LIMIT : 0

    res = (from..to).map do |idx|
      proposal = if address
        @contract_instance.call.get_proposal(address, idx)
      else
        @contract_instance.call.proposals(idx)
      end
      Proposal.new(proposal, idx)
    end

    res.sort_by(&:index).reverse
  end

Методы, которые изменяют данные в блокчейне — создают транзакцию, которую нужно подписать приватным ключом. Для работы с приватными ключами используется гем Eth. Ниже демонстрируется процесс создания нового аккаунта для блокчейна и подпись транзакции приватным ключом.

 def self.new_account
    new_key = Eth::Key.new
    return new_key.address.downcase, new_key.private_hex
  end

 def signed_transactions(private_key_hex)
    key = Eth::Key.new priv: private_key_hex #создание объекта ключа из бинарного
    @contract_instance.key = key # задание ключа для всех вызовов этого объекта контракта
    res = yield(@contract_instance) # выполнение нужных транзакций
    @contract_instance.key = nil # обнуление ключа после отправки транзакций
    res
  end

Рассмотрим метод signed_transactions — он принимает приватный ключ и блок кода, который мы хотим выполнить используя данный ключ.
В базе данных ключ хранится в бинарном виде, из которого создается объект ключа при помощи гема Eth. Далее этот ключ присваивается в поле key экземпляра контракта. Подпись транзакций происходит автоматически в геме ethereum.rb если в поле key контракта задан ключ. После вызова нужного метода, или нескольких методов, обнуляем ключ, чтобы случайно не отправить еще транзакций от этого же аккаунта.

Для примера использования signed_transactions рассмотрим метод завершения Proposal, который вызывает метод executeProposal в смарт контракте:

 def finish_proposal(proposal_index, options={})
    tx = signed_transactions(options[:private_key]) do |contract_instance|
      contract_instance.transact.execute_proposal(proposal_index) 
    end
    tx.id
  end

Метод возвращает хеш транзакции. Логика проверки статуса транзакции аналогична той, что мы использовали в js версии.

def transaction_status(tx_hash)
    tx = @client.eth_get_transaction_by_hash(tx_hash)['result']
    tx_receipt = @client.eth_get_transaction_receipt(tx_hash)['result']
    if tx_receipt
      block_number = tx_receipt['blockNumber']

      gas_used = tx_receipt['gasUsed'].to_i(16)
      gas = tx['gas'].to_i(16)

      {
        succeeded: block_number && gas_used < gas,
        failed: block_number && gas_used == gas
      }
    end
  end


Смотрите как работает


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

Что в итоге?


На наш взгляд оба варианта имеют право на жизнь, но в различных случаях. В данном конкретном примере версия с Ruby немного притянута за уши и версия использующая MetaMask более логична. Но, как уже было упомянуто в начале, задачей было не дать образец проекта, а показать на простой логике примеры взаимодействия с блокчейном при помощи javascript и ruby. Надеемся с этой задачей мы справились.
Следующей задачей ставим себе описание более продвинутой работы с деплоем и тестированием смарт контрактов. Ожидайте следующую статью на эту тему!

Погружение в разработку на Ethereum:
Часть 1: введение
Часть 2: Web3.js и газ
  • +10
  • 14.9k
  • 4
Share post

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 4

    +1
    Хорошая статья для начинающих, но хотелось бы обратить внимание будущих разработчиков смарт-контрактов на документацию Solidity.

    В частности Security Considerations
    Там приведена частая ошибка, которая встречается и здесь:
    if (p.currentResult > 0) { // если большинство проголосовало “за”
                require ( p.recipient.send(p.amount) ); // отправить эфир получателю
                p.proposalPassed = true; // пометить, что предложение одобрено


    если злоумышленник сможет внести своё «предложение» адресом получателя у которого будет «контракт» контролируемый им, с помощью соц. инженерии добиться принятия своего предложения, то в контракте-получателе платежа может быть код, который в момент получения денег снова вызовет executeProposal(uint _proposalNumber), который пришлет еще немного денег и снова вызовет сам себя и так до тех пор пока не выгребет весь банк либо не закончится gas на транзакцию. А т.к. транзакция откатится — можно будет вызвать метод снова и забрать то, что не забрал, т.к. p.proposalPassed = true; не вызовется

    Так же стоит помнить, что глубина стека в EVM ограничена и злоумышленник может вызвать ваш метод таким образом, что вы не сможете пойти «глубже». Иногда это тоже может иметь значение.

    А в остальном — всем удачных контрактов)
    И помните про цену размещения мегабайта данных в блокчейне эфира
      0
      alatushkin, спасибо за комментарий, очень правильное замечание.
        0
        А у меня вопрос по второй части «Погружение в разработку на Ethereum».
        Я опубликовал контракт через Geth, получил его адрес. Ссылка на него сохранилось в локальной переменной.
        Каким образом я могу им управлять (вызывать методы) через Geth на другом компьютере?
        Какие команды необходимо для этого ввести в консоли Geth?
          0
          Для открытия нужен не только адрес, но еще и abi (интерфейс). Как с этими данными открыть контракт описано как раз во второй части в пункте «Hello Command Line!»

        Only users with full accounts can post comments. Log in, please.