Блокчейн и смарт-контракты все еще остаются горячей темой среди разработчиков и технических специалистов. Существует много исследований и рассуждений об их будущем и о том, куда это все движется и куда приведет. У нас в Waves Platform свой взгляд на то, какими должны быть смарт-контракты, и в этой статье я расскажу, как мы их делали, с какими проблемами сталкивались и почему они не похожи на смарт-контракты других блокчейн-проектов (в первую очередь Ethereum).


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


Как мы дошли до жизни такой?


Нас очень часто спрашивали, когда же у нас появятся смарт-контракты, потому что разработчикам нравились простота работы с сетью, скорость сети (спасибо Waves NG) и невысокий уровень комиссий. Однако смарт-контракты дают гораздо больше пространства для фантазии.


Смарт-контракты стали очень популярными в последние несколько лет благодаря распространению блокчейна. Те, кто сталкивался с технологией блокчейн в своей работе, при упоминании смарт-контрактов обычно думают об Ethereum и Solidity. Но блокчейн-платформ со смарт-контрактами много, и большинство из них просто повторили то, что сделали Ethereum (виртуальная машина + свой язык контрактов). Интересный список с разными языками и подходами есть в этом репозитории.


Что такое смарт-контракт?


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


С технической точки зрения (которая нас больше интересует) смарт-контракт — алгоритм (код), который выполняется не на каком-то одном сервере или компьютере, а на многих (или всех) нодах в сети блокчейн, т.е. децентрализованно.


Как это работает?


Первым прототипом смарт-контракта на блокчейне правильно считать Bitcoin Script — неполный по Тюрингу, stack-based язык в сети Bitcoin. В Bitcoin не существует понятия аккаунтов, вместо этого существуют входы (inputs) и выходы (outputs). В биткоине при совершении транзакции (создании выхода) необходимо сослаться на транзакцию получения (вход). Кому интересны технические подробности устройства Bitcoin, рекомендую ознакомиться с этой серией статей. Так как в Bitcoin отсутствуют аккаунты, Bitcoin Script определяет, в каком случае тот или иной выход может быть потрачен.


Ethereum предлагает гораздо больше возможностей, т.к. Предоставляет Solidity — полный по Тьюрингу язык, который исполняется в виртуальной машине внутри каждой ноды. С большой властью приходит и большая ответственность, а с широким спектром возможностей — довольно большое число ограничений, о которых мы поговорим позже.


Смарт-контракты Waves


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


После анализа сценариев использования мы выяснили, что есть 2 большие категории задач, которые обычно решаются с помощью смарт-контрактов:


  1. Простые и понятные задачи вроде multisig, atomic swaps или escrow.
  2. dApps, полноценные децентрализованные приложения с пользовательской логикой. Если быть точнее, то это — backend для децентрализованных приложений. Самым ярким примером могут служить Cryptokitties или Bancor.

Существует также третий, самый популярный тип контрактов — токены. В сети Ethereum, например, подавляющая часть работающих контрактов — это токены стандарта ERC20. В Waves для создания токенов нет необходимости делать смарт-контракты, т.к. они являются частью самого блокчейна, и для выпуска токена (с возможностью сразу торговать им на децентрализованной бирже (DEX)) достаточно отправить одну транзакцию типа issue (транзакция выпуска).


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


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


Сделаем аккаунты умными


Постепенно мы пришли к концепции смарт-аккаунтов, которые предназначены для решения в первую очередь простых задач. Их идея очень похожа на Bitcoin Script: к аккаунту могут быть добавлены дополнительные правила, которые определяют валидность исходящей транзакции. Главными требованиями для смарт-аккаунтов были:


  1. Максимальная безопасность. Практически каждый месяц можно встретить новости о том, что была найдена очередная уязвимость в типовых контрактах Ethereum. Нам хотелось такого избежать.
  2. Отсутствие необходимости в газе, чтобы комиссия была фиксированной. Для этого скрипт должен выполняться за предсказуемое количество времени и иметь достаточно жесткие ограничения по размеру.

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


  1. В блокчейне Waves на данный момент существует 13 различных типов транзакций.


  1. В блокчейне Waves не входы и выходы (как в Bitcoin), а аккаунты (как, например в Nxt). Транзакция выполняется от имени одного конкретного аккаунта.
  2. По умолчанию корректность транзакции определяется текущим состоянием блокчейна и валидностью подписи, от имени которой отправляется транзакция. JSON-представление транзакции выглядит достаточно просто:


Так как у нас в блокчейне уже есть разные типы транзакций, мы решили, что не будем делать отдельную сущность в виде смарт-аккаунта, а добавим новую транзакцию, которая превращает обычный аккаунт в умный. Любой аккаунт может стать смарт-аккаунтом с измененными правилами валидации транзакций, для этого аккаунт должен просто отправить транзакцию типа SetScriptTransaction, которая содержит скомпилированный контракт.


В случае смарт-аккаунта, контракт является правилом валидации для каждой _исходящей _транзакции.


А что там с газом?


Одна из основных задач, которую мы перед собой ставили — избавиться от газа для простых операций. Это не значит, что комиссии не будет. Она нужна, чтобы у майнеров был интерес выполнять скрипты. Мы подошли к вопросу с практической стороны и решили провести performance-тестирование и посчитать скорость выполнения различных операций. Для этого были разработаны бенчмарки с использованием JMH. Результаты можно посмотреть здесь. Итоговыми ограничениями стали:


  1. Скрипт должен выполняться быстрее, чем 20 операций проверки подписи, что означает, что проверки для смарт-аккаунта будут не более чем в 20 раз медленнее, чем для обычного аккаунта. Размер скрипта не должен превышать 8 Кбайт.
  2. Чтобы майнерам было выгодно выполнять смарт-контракты, мы установили минимальную дополнительную комиссию для смарт-аккаунтов в размере 0.004 WAVES. Минимальная комиссия в сети Waves за транзакцию составляет 0.001 WAVES, в случае со смарт-аккаунтом — 0.005 WAVES.

Язык для смарт-контрактов


Одной из самых сложных задач стало создание своего языка смарт-контрактов. Брать любой существующий turing-complete язык и адаптировать (урезать) под наши задачи кажется стрельбой из пушки по воробьям:, кроме этого, зависеть от чужой кодовой базы в блокчейн проекте крайне рискованно.


Давайте попробуем представить каким должен быть идеальный язык для смарт-контрактов. На мой взгляд, любой язык программирования должен принуждать писать "правильный" и безопасный код, т.е. в идеале должен быть один правильный путь. Да, если захочется, можно на любом языке написать совершенно нечитаемый и неподдерживаемый код, но это должно быть сложнее, чем написать правильно (привет PHP и JavaScript). В то же время язык должен быть удобным для разработки. Так как язык выполняется на всех нодах сети, необходимо, чтобы он был максимально эффективным — ленивое исполнение может экономить достаточно много ресурсов. Хочется также в языке иметь мощную систему типов, желательно алгебраических, ведь это помогает как можно более четко описать контракт и приблизиться к мечте "Code is law". Если чуть более формализовать наши требования, то получим следующие параметры языка:


  1. Быть строго и статически типизированным. Строгая типизация позволяет автоматически исключить многие потенциальные ошибки программистов.
  2. Иметь мощную систему типов, чтобы было сложнее прострелить себе ногу.
  3. Быть ленивым, чтобы не тратить драгоценные такты процессора впустую.
  4. Иметь в стандартной библиотеке специфические функции для работы с блокчейном, например, хэшами. В то же время, стандартная библиотека языка не должна быть перегружена, ведь всегда должен быть один правильный путь.
  5. Не иметь исключений в runtime.

В нашем языке RIDE мы пытались учесть эти важные черты, и, так как мы разрабатываем много на Scala и любим функциональное программирование, то язык в некоторых моментах похож на Scala и F#.


Наибольшие проблемы в реализации на практике возникли с последним требованием, ведь если не иметь в языке исключений, то, например, операция сложения должна будет возвращать Option, который необходимо будет проверять на переполнение, что будет однозначно неудобно разработчикам. Компромиссным решением стало наличие исключений, однако без возможности их перехватить — если было исключение, то транзакция невалидна. Другой проблемой было перенести в язык все модели данных, которые у нас есть в блокчейне. Я уже описывал, что в Waves есть 13 разных типов транзакций, которые необходимо поддерживать в языке и давать доступ ко всем их полям.


Полная информация по доступным операциям и типам данных в RIDE есть на странице описания языка. Из интересных особенностей языка можно еще выделить то, что язык expression-based, то есть всё является expression'ом, а также наличие pattern matching, который позволяет удобно описывать условия для разных типов транзакций:


match tx {
  case t:TransferTransaction => t.recepient
  case t:MassTransferTransaction => t.transfers
  case _ => throw
}

Кому интересно узнать, как устроена работа с кодом на RIDE, стоит ознакомиться с white paper, где описываются все стадии работы с контрактом: парсинг, компиляция, десериализация, вычисление сложности скрипта и выполнение. Первые две стадии — парсинг и компиляция выполняются off-chain, в блокчейн попадает только скомпилированный в base64 контракт. Десериализация, вычисление сложности и исполнение делаются on-chain, причем несколько раз на разных стадиях:


  1. При получении транзакции и добавлении в UTX, иначе будет ситуация, когда транзакция принята нодой блокчейна, например, через REST API, но никогда не попадет в блок.
  2. При формировании блока майнящая нода валидирует транзакции и выполнение скрипта обязательно.
  3. При получении не майнящими нодами блока и валидации входящих туда транзакций.

Каждая оптимизация в работе с контрактами становится ценной, потому что она выполняется много раз на многих узлах сети. Сейчас ноды Waves спокойно запускаются на виртуальных машинах за $15 в DigitalOcean, несмотря на увеличение нагрузок после релиза смарт-аккаунтов.


А что в итоге?


А теперь давайте посмотрим, что у нас в Waves получилось. Напишем свой первый собственный контракт, пусть это будет стандартный контракт multisig 2-of-3. Для написания контракта можно использовать online IDE (тулинг для языка — тема для отдельной статьи). Создадим новый пустой контракт (New > Empty Contract).


Первым делом объявим публичные ключи Alice, Bob и Cooper, которые будут контролировать аккаунт. Необходимо будет 2 из их 3 подписей:


let alicePubKey  = base58'B1Yz7fH1bJ2gVDjyJnuyKNTdMFARkKEpV'
let bobPubKey    = base58'7hghYeWtiekfebgAcuCg9ai2NXbRreNzc'
let cooperPubKey = base58'BVqYXrapgJP9atQccdBPAgJPwHDKkh6A8'

В документации описана функция sigVerify, которая позволяет проверить подпись транзакции:



Аргументами функции являются тело транзакции, проверяемая подпись и публичный ключ. В контракте в глобальной области видимости доступен объект tx, в котором хранится информация о транзакции. У данного объекта есть поле tx.bodyBytes, которое содержит байты отправляемой транзакции. Так же есть массив tx.proofs, в котором хранятся подписи, которых может быть до 8. Стоит отметить, что на самом деле в tx.proofs можно отправлять не только подписи, а любую другую информацию, которая может использоваться контрактом.


Мы можем убедиться, что все подписи представлены и они в верном порядке с помощью 3 простых строк:


let aliceSigned  = if(sigVerify(tx.bodyBytes, tx.proofs[0], alicePubKey  )) then 1 else 0
let bobSigned    = if(sigVerify(tx.bodyBytes, tx.proofs[1], bobPubKey    )) then 1 else 0
let cooperSigned = if(sigVerify(tx.bodyBytes, tx.proofs[2], cooperPubKey )) then 1 else 0

Ну и последним шагом будет проверка, что представлено не менее 2 подписей.


aliceSigned + bobSigned + cooperSigned >= 2

Весь контракт мультиподписи 2 из 3 выглядит так:


# объявляем публичные ключи
let alicePubKey  = base58'B1Yz7fH1bJ2gVDjyJnuyKNTdMFARkKEpV'
let bobPubKey    = base58'7hghYeWtiekfebgAcuCg9ai2NXbRreNzc'
let cooperPubKey = base58'BVqYXrapgJP9atQccdBPAgJPwHDKkh6A8'

# проверяем какие подписи есть у транзакции
let aliceSigned  = if(sigVerify(tx.bodyBytes, tx.proofs[0], alicePubKey  )) then 1 else 0
let bobSigned    = if(sigVerify(tx.bodyBytes, tx.proofs[1], bobPubKey    )) then 1 else 0
let cooperSigned = if(sigVerify(tx.bodyBytes, tx.proofs[2], cooperPubKey )) then 1 else 0

# проверяем, что у транзакции не менее 2 валидных подписей
aliceSigned + bobSigned + cooperSigned >= 2

Обратите внимание: в коде нет ключевых слов вроде return, потому что последняя выполняемая строка считается результатом скрипта, и именно поэтому она всегда должна возвращать true или false


Для сравнения, распространенный контракт Ethereum для мультиподписи выглядит сильно сложнее. Даже относительно простые вариации выглядят так:


    pragma solidity ^0.4.22;

            contract SimpleMultiSig {

              uint public nonce;                 // (only) mutable state
              uint public threshold;             // immutable state
              mapping (address => bool) isOwner; // immutable state
              address[] public ownersArr;        // immutable state

              // Note that owners_ must be strictly increasing, in order to prevent duplicates
              constructor(uint threshold_, address[] owners_) public {
                require(owners_.length <= 10 && threshold_ <= owners_.length && threshold_ >= 0);

                address lastAdd = address(0); 
                for (uint i = 0; i < owners_.length; i++) {
                  require(owners_[i] > lastAdd);
                  isOwner[owners_[i]] = true;
                  lastAdd = owners_[i];
                }
                ownersArr = owners_;
                threshold = threshold_;
              }

              // Note that address recovered from signatures must be strictly increasing, in order to prevent duplicates
          function execute(uint8[] sigV, bytes32[] sigR, bytes32[] sigS, address destination, uint value, bytes data) public {
                require(sigR.length == threshold);
                require(sigR.length == sigS.length && sigR.length == sigV.length);

                // Follows ERC191 signature scheme: https://github.com/ethereum/EIPs/issues/191
                bytes32 txHash = keccak256(byte(0x19), byte(0), this, destination, value, data, nonce);

                address lastAdd = address(0); // cannot have address(0) as an owner
                for (uint i = 0; i < threshold; i++) {
                  address recovered = ecrecover(txHash, sigV[i], sigR[i], sigS[i]);
                  require(recovered > lastAdd && isOwner[recovered]);
                  lastAdd = recovered;
                }

                // If we make it here all signatures are accounted for.
                // The address.call() syntax is no longer recommended, see:
                // https://github.com/ethereum/solidity/issues/2884
                nonce = nonce + 1;
                bool success = false;
                assembly { success := call(gas, destination, value, add(data, 0x20), mload(data), 0, 0) }
                require(success);
              }

              function () payable public {}
            }

В IDE встроена консоль, которая позволяет сразу же компилировать контракт, задеплоить его, создавать транзакции и смотреть результат выполнения. А если вы хотите серьезно работать с контрактами, то рекомендую посмотреть на библиотеки для разных языков и плагин для Visual Studio Code.


Если у вас руки так и чешутся, то в конце статьи есть самые важные ссылки, с которых стоит начать погружение.


Система мощнее, чем язык


В блокчейне Waves есть специальные типы данных для хранения данных — Data Transactions. Они работают как key-value хранилище, привязанное к аккаунту, то есть в каком-то смысле это состояние (state) аккаунта.



В дата транзакции могут быть записаны строки, числа, булевые значения и массивы байтов до 32 Кбайт на один ключ. Пример работы с Data Transactions, которые позволяет отправить транзакцию только если в key-value хранилище аккаунта уже лежит число 42 по ключу key:


let keyName = "key"
match (tx) {
  case tx:DataTransaction => 
    let x = extract(getInteger(tx.sender, keyName))
    x == 42
  case _ => false
}

Благодаря Data Transaction, смарт-аккаунты становятся крайне мощным инструментом, который позволяет работать с оракулами, управлять состоянием и удобно описывать поведение.


В этой статье описывается как можно реализовать NFT (Non-fungible tokens) с помощью Data Transactions и смарт-контракта, который управляет состоянием. В итоге в стейте аккаунта будут записи вида:


+------------+-----------------------------------------------+
| Token Name |                Owner Publc Key                |
+------------+-----------------------------------------------+
| "Token #1" | "6iQaHazE9NVAJfAjMpHifDXMfr1euWcy8fmW6rNcdhr" |
| "Token #2" | "3tNLxyJnyxLzDkMkqiZmUjRqXe1UuwFeSyQ14GRYnGL" |
| "Token #3" | "3wH7rENpbS78uohErXHq77yKzQwRyKBYhzCR9nKU17q" |
| "Token #4" | "6iQaHazE9NVAJfAjMpHifDXMfr1euWcy8fmW6rNcdhr" |
| "Token #5" | "6iQaHazE9NVAJfAjMpHifDXMfr1euWcy8fmW6rNcdhr" |
+------------+-----------------------------------------------+

Сам контракт NFT выглядит крайне просто:


match tx {
  case dt: DataTransaction =>
    let oldOwner = extract(getString(dt.sender, dt.data[0].key))
    let newOwner = getBinary(dt.data, 0)
    size(dt.data) == 1 && sigVerify(dt.bodyBytes, dt.proofs[0], fromBase58String(oldOwner))
  case _ => false
}

А что дальше?


Дальнейшим развитием смарт-контрактов Waves являются Ride4DApps, который позволит вызывать контракты на других аккаунтах, и полный по Тьюрингу язык (или система), который позволит решать все типы задач, триггерить другие задачи и т.д.


Другим интересным направлением развития смарт-контрактов в экосистеме Waves являются Smart Assets, которые работают по схожему принципу — неполные по Тьюрингу контракты, которые относятся к токену. Контракт контролирует условия, при которых могут быть совершены транзакции с токеном. Например, с их помощью можно будет замораживать токены до определенной высоты блокчейна или запрещать p2p торговлю токенами. Более подробно про смарт-ассеты можно прочитать в блоге.


Ну и в конце еще раз приведу список того, что будет необходимо для начала работы со смарт-контрактами в сети Waves.


  1. Документация
  2. IDE с консолью
  3. White paper для самых любопытных

Комментарии (2)


  1. RussoT
    22.02.2019 14:01

    А когда на русском запилят документацию и white paper?


    1. ikardanoff Автор
      22.02.2019 14:02

      Документация частично есть на русском, но переводится она силами сообщества. White paper в данный момент не планируется переводить.