Всем привет! Вдохновившись r/place и желая реализовать наконец-то свой первый смарт-контракт на блокчейне, мы решили сделать всем доступное и веселое приложение в сети Ethereum, которое позволяет рисовать на холсте размером в 1000 x 1000 px, сохраняя каждый выбранный и раскрашенный пользователем пиксель в блокчейн. Вы можете рисовать также в реальном времени со своими друзьями и наблюдать, как в реальном времени меняется цвет выбранного пикселя по мере того, как в сети подтверждаются транзакции смарт-контракта.

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

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

Компоненты


Планируя в большей степени сфокусироваться на написании смарт-контракта, мы не хотели тратить очень много времени на серверную архитектуру и сделали все максимально простым.
Основным условием для работы с приложением является наличие Ethereum-совместимого браузера и плагина к браузеру (Metamask etc) с доступом к кошельку. В этом случае клиент самостоятельно отправляет транзакции в блокчейн и сервер лишь отдает обновленный стейт контракта через Nginx. Таким образом, вся целостность и безопасность данных гарантируется смарт-контрактом в блокчейне.

image

Таким образом у нас получились:

  • Смарт-контракт, написанный на Solidity и задеплоенный в основную сеть.
  • Front-end — написанный на Vanilla JS и использующий web3.js библиотеку и требующий Metamask плагин для работы с контрактом.
  • Server-side — клиент, написанный на Scala с использованием Web3J для работы с blockchain нодой.
  • Parity — нода, задеплоенная на DigitalOcean для синхронизации с сетью.

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


В первую очередь мы взялись за реализацию смарт-контракта и учитывая наш начальный опыт в работе с Solidity, и полагаясь на best practices доступные в сети, мы решили пойти путем MVC и сделать модель для хранения стейта контракта и контроллер, который сможет обновлять эту модель.

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

Единственный минус такого подхода — это большая стоимость смарт-контракта при инициализации в сети Ethereum (на тот момент порядка $1,100).

Наивная реализация


Первоначально мы не думали об эффективности нашей имплементации и преждевременной оптимизации, поэтому сделали первый контракт максимально топорным. Учитывая размер нашего холста для рисования в 1000px x 1000px, у нас получился массив из 1 миллиона uint-ов, в котором мы хранили все пиксели.

Пример реализации контракта ниже:

contract PixelsField is Controllable {
    uint[1000000] public colors;
  
    event PixelChanged(uint coordinates, uint clr);
    
    function setPixel(uint coordinates, uint clr) public onlyController {
        require(clr < 16);
        require(coordinates < 1000000);
    
        colors[coordinates] = clr;
        PixelChanged(coordinates, clr);
  }
  
    function allPixels() public view returns (uint[1000000]) {
        return colors;
    }
}

Трудности


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

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

Улучшенная версия


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

Изучив внимательно типы данных, доступные в Solidity, мы решили остаться с uint256, но начали записывать в него как позицию, так и цвет пикселя, тем самым надеясь поместить каждый пиксель в 4 bit-а из 256 доступных.

Для этого нам понадобилось немного побитовой магии, в которой мы кодируем координаты по X и Y каждого пикселя в индекс элемента массива и применяем битовый сдвиг и маску.

Пример реализации ниже:

function setPixel(uint coordinate, uint color) public onlyController {
    require(color < 16);
    require(coordinate < 1000000);

    uint idx = coordinate / ratio;
    uint bias = coordinate % ratio;
        
    uint old = colors[idx];
    uint zeroMask = ~(bitMask << (n * bias));
    colors[idx] = (old & zeroMask) | (color << (n * bias));
        
    PixelChanged(coordinate, color);
}

Быстрый подсчет нашей реализованной оптимизации показал, что для хранения 1 миллиона пикселей нам нужно будет лишь 1 000 0000 / 64бита = 15 625 элементов типа uint256.

Тем самым мы уменьшили изначальный массив из нашей наивной реализации в 64 раза и смогли прочитать весь массив за приемлемое время на клиенте.

Полный пример стейта контракта ниже:

contract PixelsField is Controllable {
  
    event PixelChanged(uint coordinates, uint clr);
    
    uint[15625] public colors;
    
    uint bitMask;
    uint n = 4;
    uint ratio = 64;
    
    function PixelsField() public {
        bitMask = (uint8(1) << n) - 1;
    }
    
    function setPixel(uint coordinate, uint color) public onlyController {
        require(color < 16);
        require(coordinate < 1000000);

        uint idx = coordinate / ratio;
        uint bias = coordinate % ratio;
        
        uint old = colors[idx];
        uint zeroMask = ~(bitMask << (n * bias));
        colors[idx] = (old & zeroMask) | (color << (n * bias));
        
        PixelChanged(coordinate, color);
    }
    
    function getPixel(uint coordinate) public view returns (uint) {
        var idx = coordinate / ratio;
        var bias = coordinate % ratio;
        return (colors[idx] >> (n * bias)) & bitMask;
    }
    
    function allPixels() public view returns (uint256[15625]) {
        return colors;
    }
}

Взаимодействие с контрактом


Для взаимодействия с контрактом из UI мы добавили следующие функции, которые имеют доступ к состоянию контракта:

function getPixel(uint coordinate) public view returns (uint)
function allPixels() public view returns (uint256[15625])

Пользовательский интерфейс


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

Отрисовка всего холста — достаточно быстрая и недорогая операция прежде всего из-за компактного размера массива пикселей в блокчейне и использования Canvas-а в браузере.

Более того, мы учли, что среди посетителей могут быть также те, у кого не установлен Ethereum-совместимый браузер или плагин к браузеру (Metamask etc), поэтому мы позволили нашему серверу генерировать текущий стейт всех пикселей на холсте из блокчейна и отдавать уже клиенту статичную картинку через Nginx.

Чтобы перекрасить пиксель, мы используем библиотеку web3.js. Вызов функции из контракта приведен ниже:

const colorSelected = (color) => () => {
  hidePicker();
  web3.eth.getAccounts((_error, accounts) => {
    if (accounts.length === 0) {
      alert("Please login in you wallet. Account not found ?\_(?)_/?.");
      return;
    };
    const config = {
      from: accounts[0],
      gasPrice: 2500000000,
      gasLimit: 50000,
      value: 0
    };
    try {
      controllerContract.methods.setPixel(settings.selectedcoordinate, color).send(config, (error, addr) => {
        if (error) {
          console.log(error);
          return;
        }
        userPixels.push({
          coord: settings.selectedcoordinate,
          color: color
        });
        const {x, y} = numberToCoord(settings.selectedcoordinate);
        setPixel(ctx, x, y, settings.colors[color]);
      });
    } catch (error) {
      console.log(error);
    }
  });
}

Сервер


Реализация API не была нашим приоритетом, поскольку мы надеялись полностью положиться на библиотеку web3.js на клиенте.

Но поскольку есть ряд пользователей браузеров без Ethereum совместимых плагинов и мобильных устройств, мы решились на поднятие своей Parity ноды в DigitalOcean окружении и синхронизации ее с сетью.

Для того, чтоб взаимодействовать с Parity мы написали легковесный API, который poll-ит Parity ноду, забирает текущий стейт контракта и отрисовывает этот самый последний стейт, сохраняя картинку в png формате на сервере. Дальше это уже забота Nginx отдать картинку клиенту.

Поскольку стейт контракта это массив из uint256 данных, примерный payload того, что мы получаем из контракта выглядит вот так:
0x000000000000000000000000000000000000000000000000000000000000000b….

И нам приходится делать трансформацию учитывая наши доступные 16 цветов на клиенте и требуемый результат в виде png картинки:

Пример ниже:

mport java.awt.{Color => AwtColor}
import java.io.{File, FileOutputStream}
import java.time.Instant

import com.sksamuel.scrimage.nio.PngWriter
import com.sksamuel.scrimage.{Image, Pixel}
import com.typesafe.scalalogging.StrictLogging
import org.web3j.utils.{Numeric => NumericTools}

import scala.util.Try
object Composer extends StrictLogging {

 private lazy val colorMapping: Map[Char, String] = Map(
   '0' -> "#FFFFFF",
   '1' -> "#9D9D9D",
   '2' -> "#000000",
   '3' -> "#BE2633",
   '4' -> "#E06F8B",
   '5' -> "#493C2B",
   '6' -> "#A46422",
   '7' -> "#EB8931",
   '8' -> "#F7E26B",
   '9' -> "#2F484E",
   'a' -> "#44891A",
   'b' -> "#A3CE27",
   'c' -> "#1B2632",
   'd' -> "#005784",
   'e' -> "#31A2F2",
   'f' -> "#B2DCEF")

 private lazy val pixelsMapping: Map[Char, Pixel] = hex2Pixels(colorMapping)

 private val canvasHeight = 1000
 private val canvasWidth = 1000
 private val segmentLength = 64

 def hex2Pixels(map: Map[Char, String]): Map[Char, Pixel] = {
   def pixel(hex: String) = {
     for {
       color <- Try(AwtColor.decode(hex)).toOption
       pixel = Pixel(color.getRed, color.getGreen, color.getBlue, 255)
     } yield pixel
   }

   for {
     (color, hex) <- map
     pixel <- pixel(hex)
   } yield color -> pixel
 }

 def apply(encoded: String): Unit = {
   val startedAt = Instant.now
   val pixels = translateToPixels(encoded)

   write(pixels, fileName)

   logger.info(s"Successfully wrote $fileName, took ${ Instant.now.toEpochMilli - startedAt.toEpochMilli } ms")
 }

 def translateToPixels(encoded: String): List[Pixel] = {
   def decode(color: Char) = for (pixel <- pixelsMapping.get(color)) yield pixel

   val extracted = NumericTools.cleanHexPrefix(encoded)

   extracted.grouped(segmentLength)
     .toList
     .par
     .flatMap(_.reverse.toSeq)
     .flatMap(decode)
     .toList
 }

 private def write(pixels: List[Pixel], fileName: String): Unit = {
   val file = new File(fileName)
   val out = new FileOutputStream(file, false) // don't append existing file

   val image = Image(canvasWidth, canvasHeight, pixels.toArray)
   val pngWriter = PngWriter()

   pngWriter.write(image, out)

   out.flush()
   out.close()
 }
}

Результат


Чему научились:


Подводя итоги, нам было очень интересно и познавательно сделать первое приложение на блокчейне и выложить его в mainnet и мы надеемся, что пользователям будет также увлекательно попробовать нарисовать что-то на блокчейне и оставить это в истории :)

Дальнейшие планы


Мы собираемся развивать ethplace.io и с удовольствием поделимся в скором времени новостями о новых интересных фичах, над которыми работаем!

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


  1. GarudaJI
    02.02.2018 17:53
    +1

    Про упаковку битов прикольно, непонятно только причем тут игры =)

    p.s. Может кто реализует идею — смарт контракты для цифровых унитазов. Тогда в онлайне можно будет смотреть как сливаются транзакции, немного дать газу, ну и каждый раз переживать чувство единства с человечеством, профит!


  1. war_hol Автор
    02.02.2018 19:24

    Если интересно посмотреть какое получилось приложение, вот ссылка — ethplace.io


  1. kozyabka
    02.02.2018 23:46

    И все это лишь потребует немного Ethereum-a на покрытие расходов газа транзакции!

    Вот это развлечение!!! Может просто фотошоп открыть и бесплатно пиксели рисовать?


    1. war_hol Автор
      02.02.2018 23:56

      Adobe Photoshop — 1288.00 руб./мес. www.adobe.com/ru/creativecloud/plans.html


      1. 1tone
        03.02.2018 00:26

        Вообще-то 644 руб/мес и Lightroom бонусом. Вы не туда смотрите.


        1. war_hol Автор
          03.02.2018 00:30

          В любом случае, у нас стоимость транзакции примерно 20 центов. Согласитесь не плохая альтернатива если вам нужно перекрашивать, например, не более 40 пикселей в месяц.


          1. Jetmanman
            04.02.2018 22:25

            Какие 20 центов, незнаю как сейчас, но сеть Ethereum достигла своего максимума недавно это 16 транзакций в секунду и цена ща транзакцию доходила до 3 долларов, когда я пользовался мне это обходилось в от 0.6 до 1.5 $, и цена зависит от того за сколько вы покупали эфир. А для таких игр цена должна быть 0.1 цент наверно, все что выше цента это дорого для микроплатежей.


            1. war_hol Автор
              04.02.2018 22:27

              Все же зависит от цены за газ, если вы не торопитесь, то можете установить её на уровне 2 Gwei и подождать пару часов, тогда цена будет 3-4 цента.


  1. alatushkin
    02.02.2018 23:50
    +1

    Альтернативный вариант — вместо Digital Ocean — посмотрите в сторону INFURA
    +Для игры наверно не важно, а в остальном — ноды не всегда стабильно работают, поэтому про мониторинг и избыточность стоит помнить.


  1. altervision
    03.02.2018 09:50
    +1

    Классная идея, классная реалиация, ребят!
    Но её можно слегка оптимизировать, просто… убрав из неё блокчейн.

    Почему-то напомнило: лет так Nнадцать тому назад одно юное дарование решило продавать пиксели на своём сайте по одному доллару любому желающему.


    1. war_hol Автор
      04.02.2018 22:29

      Да, там в самом начале статьи написано что мы в учебных целях решили взять интересное веб-приложение и сделать его на блокчейне. Посмотрите видео в youtube по запросу r/place — там много интересного.


  1. alatushkin
    03.02.2018 12:44

    Кстати, хотел спросить:
    У вас в подписи TypeScript developer
    Почему решили делать всё не на одном «языке» (и фронт и бэк на js/ts) и переиспользовать общую логику, а а решили собирать букет из разных технологий?


    1. war_hol Автор
      04.02.2018 22:30

      Нас в команде три человека и каждый использовал что удобнее. Я делал только фронтенд и часть смарт-контрактов.