Разбор примера: децентрализованное приложение Эфириума для получения займов через краудфандинг

Разбор примера: децентрализованное приложение Эфириума для получения займов через краудфандинг

Artem
загружено_26.3.2017_в_23_10_40


Команда Getline.in связалась со мной и попросила предоставить им рабочий прототип приложения Эфириума для получения займов путем краудфандинга. Задача: разработать бэкенд («умный контракт») и фронтенд (децентрализованное приложение). Конечная цель: получить прототип для проверки идеи в рыночных условиях и определить технологический комплекс для дальнейшей разработки.


Так как предполагалось уложиться в сжатые сроки, мы взяли неделю на выполнение работы; что успеем сделать, то и будет конечным результатом, без продлений и доработок. Мы завершили работу примерно за 20 часов. Конечный продукт можно посмотреть на сайте etherloans.netlify.com. Чтобы сайт заработал, нужно установить плагин Metamask с открытым кошельком (также должны работать другие ноды Эфириума, которые внедряют Web3 API) и находиться в тестовой сети Эфириума.

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

1


  1. Запрос займа с указанием конечной суммы, инвестиционного периода, периода возврата средств и целевой процентной ставки.
  2. Заем создается и регистрируется, начиная с инвестиционного периода.
  3. В течение этого срока инвесторы могут вносить деньги для займа. На этом этапе сумму не может вывести ни одна из сторон.
  4. Если необходимую сумму не удается собрать, у инвесторов должна быть возможность вывести свои деньги. И тогда заем отклоняется.
  5. В противном случае дебитор получает возможность вывести сумму займа, и начинается период возврата средств.
  6. Если долг не выплачивается, условия займа считаются невыполненными. И в таком случае заем отклоняется.
  7. Если дебитор выплачивает долг, выдача займа считается успешной.
  8. После окончания периода возврата средств и выплаты долга инвесторы получают возможность вывести свои деньги.
1-CShbJaQOxL0zz_EfJY1MLA


Скриншот конечного продукта


Технологический комплекс

Мы составили план. Теперь настало время подумать о том, какие технологии использовать для его реализации.

Коротко поясню, что такое децентрализованное приложение. Это обычный веб-сайт, который можно разместить где угодно. С помощью Ethereum Javascript Web3 API можно взаимодействовать с нодой Эфириума, которая установлена на компьютере клиента, или с любой другой общедоступной и настроенной нодой в Интернете. Эта нода служит шлюзом для подключения к сети Эфириума и позволяет «общаться» с развернутыми «умными контрактами». Так как нелокальные ноды не хранят информацию о кошельке клиента, мы не могли использовать облачные варианты для ввода и вывода средств. Мы остановили выбор на ноде, установленной на компьютере пользователя.

2


В случае децентрализованного приложения бэкенд размещается не на сервере какой-либо компании. Это ваш «умный контракт», который располагается в блокчейне. Контракты разрабатывают на языке Solidity (есть и другие языки, но это наиболее популярный и предпочтительный). Разрабатывать контракты Эфириума все еще нужно напрямую, но такие фреймворки, как Truffle или Embarkпозволяют легко развернуть их в сетях, осуществлять переносы для обновления существующих контрактов и проводить модульное тестирование в цепочке и вне ее. Мы решили использовать Truffle с нодойTestRPC. TestRPC позволяет имитировать весь блокчейн. То есть он локально и быстро имитирует все транзакции. Он отлично подходит для тестирования перед реальным развертыванием.

Для фронтенда мы решили использоватьAngular 2. Многие члены сообщества применяют Meteor.js, так как он хорошо подходит под модель децентрализованного приложения. Но мы были ограничены во времени и решили использовать то, что знаем лучше.

«Умный контракт»

Согласно замыслу код «умных контрактов» неизменный. Если вы развернули контракт, он навсегда сохранит в блокчейне свою текущую форму. Контракты разрабатывают на объектно-ориентированном языке. Поэтому их внутреннее состояние можно менять, вызывая методы. Вызовы методов — это транзакции блокчейна. При добавлении транзакций состояние контракта меняется для каждого участвующего клиента Эфириума. Поэтому изменения должны коснуться каждого компьютера. Это затратный процесс. И так как язык контрактов полный по Тьюрингу, нельзя предугадать, закончится ли он вообще. В Эфириуме проблема решается так: каждый запуск инструкции стоит определенное количество эфира (в целях упрощения используется более комплексная абстракция под названием газ).

Но контракты также поддерживают «чистые» функции (без побочных эффектов). Они не меняют состояние контракта и не затрагивают блокчейн; только ноду, которую использует децентрализованное приложение. Такие функции используются для получения данных о контракте, чтобы, например, показать их пользователю.

3


Разница между вызовом метода и «чистой» функции


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

struct Loan {
    address debtor;
    uint investmentDeadlineBlock;
    uint fundingGoal;
    uint amountGathered;
    uint loanRepaymentBlock;
    uint interestRatePermil;
    uint amountOwed;
    mapping (address => uint) lenders;
}
// Total number of loans created
uint public numLoans = 0;
mapping (uint => Loan) loans;

Определение займа


Вот несколько интересных деталей:

  • Структуры (struct) — часть языка Solidity, а не лежащей в основе виртуальной машины. Поэтому для каждого свойства структуры требовался собственный дополнительный метод get.
  • Виртуальная машина Эфириума не поддерживает числа ни с плавающей, ни с фиксированной запятой. Поэтому мы решили использовать промилледля учета долей.
  • Есть постоянная block.timestamp, но майнеры могут ее сместить относительно реального времени. Поэтому в целях безопасности не следует на нее полагаться.
  • С другой стороны, блоки нельзя так просто добывать быстрее или медленнее, если это необходимо. Поэтому определение конечных сроков при получении постоянной block.number служит более подходящим решением.
  • Нельзя выполнить перебор отображения (mapping). Чтобы получить все займы, нужна альтернатива.

Эфириум изначально поддерживает события (event), которые можно запускать, когда в контракте происходит что-то важное. Кроме того, эти события можно отслеживать через фронтенд. Он позволил нам, при необходимости, автоматически обновлять данные займа, вместо опроса блокчейна.

// Declaration
event NewLoan(uint id);
event Invested(uint id);
event PaidBack(uint id);
event WidthdrawnLoan(uint id);
event WidthdrawnPayback(uint id);

// And how to emmit them
function newLoan(...) {
  ...
  NewLoan(numLoans - 1);
  ...
}

Наши события


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

function invest(uint loanID) payable {
    Loan loan = loans[loanID];
    if (loan.investmentDeadlineBlock < block.number) {
        throw;
    }
    if (loan.amountGathered + msg.value > loan.fundingGoal) {
        throw;
    }
    ...
}

Подтверждение, что действие соответствует условиям


Даже если деньги вернулись, майнеры должны выполнять программу до генерации исключения. Таким образом газ, используемый для выполнения метода, полностью расходуется и не возвращается инициатору вызова. Учтите также, что только методы с модификатором payable могут получать эфир.

Отправка эфира из контракта — тоже непростая задача. Так как адрес, на который мы посылаем деньги может быть контрактом, его методы также должны вызываться при получении денег. И они вызываются в середине выполнения метода первого контракта. Поэтому второй контракт также может вызвать первый контракт во время его выполнения. Это свойство называют реентерабельностью. Это значит, что если контракт написан неправильно, при вызове метода его состояние будет не таким, как вы ожидали. Это можно использовать, когда контракт обновляет состояние только после отправки денег. Несложно представить, как злоумышленник пишет контракт, который вызывает ReceivePayment() каждый раз, когда получает деньги. Выполнение зацикливается, и так можно вывести все средства из контракта займа. Поэтому лучше обновлять состояние заранее.

4


Контракт 2 «атакует» контракт 1, создавая петлю

function widthdrawLoan(uint loanID) {
    ...
    uint sendAmount = loan.amountGathered;
    loan.amountGathered = 0;
    if (!loan.debtor.send(sendAmount)) {
        throw;
    }
    ...
}

Обновление состояния до отправки денег


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

Тестирование

Truffle позволяет писать автоматизированные тесты для «умных контрактов». Сначала он развертывает контракт в предоставленной сети. В большинстве случаев в имитацию, созданную TestRPC. Но тесты можно запускать и в настоящих сетях (и это нужно делать как минимум один раз перед развертыванием).

Затем вы взаимодействуете с контрактом таким же образом, как децентрализованное приложение; в Javascript с помощью Web3 API. В Truffle также есть незначительный слой абстракции для удобства. Это заключение каждого вызова в обещание (promise).

contract("Loans", accounts => {
    it("should create new loans", () => {
        return Loans.new()
            .then(instance => {
                return Promise.resolve()
                    .then(() => instance.newLoan(111, 222, 333, 444))
                    .then(() => instance.getLoanGoal(0))
            }).then(loanGoal => {
                assert.equal(loanGoal, "111")
            });
    });
}

Очень простой тест, который создает новый заем

it("should throw when investing on non existing loan", finish => {
    Loans.new()
        .then(instance => instance.invest(123))
        .then(result => finish("Expected throw, got: " + result))
        .catch(() => finish())
});

Перехват исключения внутри контракта сначала был неочевидным


Самой большой проблемой при тестировании была проверка конечных сроков для займов. Нельзя ускорить время, и TestRPC не позволяет сдвинуть постоянную block.number вперед. Мы обошли проблему с помощью внутренних механизмов TestRPC. При каждой транзакции добывается один блок. Мы просто провели столько пустых транзакций, сколько нужно было блоков. Но это решение не работает в реальных сетях. Я не думаю, что есть какой-либо способ протестировать конечные сроки в реальной сети без изобретения машины времени.

function pushBlocks(count) {
    var rpcPromise = Promise.resolve();
    for (var i = 0; i < count; i++) {
        // TestRPC pushes one transaction per block, so we force useless transactions
        rpcPromise = rpcPromise.then(Loans.new())
    }
    return rpcPromise
}

Хелпер promise позволяет попасть в будущее


Фронтенд

Фронтенд получился на удивление простым. Обычное приложение Angular 2 с другим методом получения данных внутри служб (service) (или моделей, как их называют в других фреймворках).

Здесь снова помог Truffle. При компилировании и развертывании контрактов, он выдавал файл JSON со всеми данными, необходимыми для работы с контрактом через Web3. Так как Webpack позволяет напрямую импортировать файлы JSON в Javascript, мы могли просто получить их из директории компиляций, отделив Truffle от фронтенда.

import * as Contract from 'truffle-contract';
import LoansData from '../../../../build/contracts/Loans.json';
// Use Truffle-contract to construct the contract definition from Truffle's JSON
let Loans = Contract(LoansData);
Loans.setProvider(web3.currentProvider);

// JSON also includes the address at which the contract is deployed
// getting an instance is very easy
Loans.deployed().then((instance) => {
  ...
});

Как работать с контрактом внутри браузера


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

Loans.deployed().then((instance) => {
    // Truffle-contract only promisifes Contract related calls,
    // rest of web3 is still callback-based
    web3.eth.getBlockNumber((err, result) => {
      // Once we gather all historic emmited events for loan creation
      // We skip the current block as it will be updated when listening for new events
      instance
          .NewLoan(null, {fromBlock: 0, toBlock: result - 1})
          .get(this.batchNewLoans.bind(this));
          
      // We listen to all new oncoming events in the future, this includes
      // the current block we're at
      instance
          .allEvents()
          .watch(this.onLoanEvent.bind(this))
    });
});

Развертывание

Эта часть была самой сложной. Я не хотел тратить всю ночь на загрузку целого блокчейна, который занимает 50+ Гбайт. Тонкие клиенты позволяют развернуть контракт без полной загрузки блокчейна. Но большинство из них находятся в разработке. Сейчас доступен только Metamask. Но это плагин для Chrome, поэтому он изолирован и не «прослушивает» обычные команды RPC. «Общаться» с ним можно только через Web3 API на веб-сайте. Truffle не может связываться с ним, чтобы выполнить развертывание и выдать JSON, необходимый для фронтенда!

Мы решили развернуть ноду TestRPC, затем открыть новую вкладку Chrome, вручную развернуть скомпилированный байт-код через Web3, обновить в JSON значения и перекомпилировать фронтенд.

{
  "contract_name": "Loans",
  "abi": [
    // API definition to talk to the contract here
  ],
  "unlinked_binary": "<insert base64 bytecode here>",
  "networks": {
    "1423424234": {
      "events": {
        ...
      },
      "links": {},
      "address": "0xd4802e315391dbb8138fe999a3efa230185052e1",
      "updated_at": 1490010934907
    }
  },
  "schema_version": "0.0.5",
  "updated_at": 1490010934907
}

JSON, выданный Truffle


Нам нужно было изменить две вещи, чтобы фронтенд работал с JSON: ID сети и адрес контракта. Получить ID сети было довольно просто: web3.version.network возвращает «3» для тестовой сети. Развернуть контракт было сложнее. Потребовалось время, чтобы изучить и понять, как сделать все правильно.

// Insert the abi array from JSON as the parameter
Loans = web3.eth.contract(...);
Loans.new(
  {data: "<insert compiled bytecode>, from: web3.eth.accounts[0]},
  function(err, instance) { console.log(err, instance); });

Развертывание нового контракта через Web3


После этого мы легко получили адрес контракта из его экземпляра и обновили данные в JSON.

1-BEqgUweLjmvFp0aHG3Vk5g


Инструменты разработки в Chrome впечатляют, но для меня это не самая любимая среда IDE


Улучшения для следующих версий

Сейчас заем работает по конечным срокам, и прежде чем проводить какие-либо операции, контракт проверяет диапазон этих сроков. Это ненадежно (особенно в отношении единичных ошибок в блоке). Здесь встречается некоторое дублирование, и код сложно читать. В следующей версии необходимо использоватьмодель с машиной состояний, которую проще понять и легко применить с помощью модификаторов Solidity. Они позволяют проводить операцию только в определенном состоянии.

У нас было мало времени, поэтому не вся функциональность «умного контракта» доступна во фронтенде децентрализованного приложения. Инвесторы и владелец контракта все еще должны выводить средства вручную с помощью Web3 API.

Займы обновляются во фронтенде, когда происходит что-то важное, например поступление инвестиций или возврат средств. Но программа не может определять конец периода и обновиться в этот момент. Это связано с тем, что контракт выполняет операции только по запросу, а не все время. Есть несколько решений, доступных в цепочке, например Ethereum Alarm Clock. Но самый простой и дешевый способ — установить во фронтенде таймеры Javascript, которые будут вычислять конец периода и при его наступлении обновлять заем. Одна из технических проблем заключается в том, что время генерации блока в Эфириуме меняется, поэтому время можно оценивать только по добыче конечных блоков. Но эта проблема будет незаметна для пользователя. Мы можем в фоновом режиме перепроверять контракт после завершения работы таймеров и таким образом их регулировать.

Для улучшения безопасности в следующей версии я бы предложил помещать каждый заем в собственный отдельный контракт. Сейчас, если возникнет ошибка в методах вывода, злоумышленник сможет забрать все средства из каждого займа. Если разделить займы, украсть получится только средства из атакованного займа. Я бы сделал это так: глобальный объединяющий контракт, который также развертывает займы. Такая структура позволит легко обновлять код займов. Текущие займы останутся прежними. Это гарантирует, что владелец не сможет вмешаться в их работу. Но новые займы могли бы получать обновленную версию во время создания.

Заключение

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

Но это интересный опыт!




Report Page