Тестирование смарт-контрактов Ethereum на Go: прощай, JavaScript +10



image
Я хочу поблагодарить коллег: Сергея Немеша, Михаила Попсуева, Евгения Бабича и Игоря Титаренко за консультации, отзывы и тестирование. Я также хочу сказать спасибо команде PolySwarm за разработку оригинальной версии Perigord.

Это перевод моей статьи, опубликованной впервые на английском на Medium


Тестирование всегда было неотъемлемой частью разработки программного обеспечения, хотя и не самой приятной. Когда речь идет о смарт-контрактах, необходимо тщательное тестирование с исключительным вниманием к деталям, т.к. ошибки будет невозможно исправить после развертывания в блокчейн сети. За последние годы, сообщество Ethereum создало множество инструментов для разработки смарт-контрактов. Некоторые из них не стали популярными, например, Vyper — диалект Python для написания смарт-контрактов. Другие, такие как Solidity, стали признанным стандартом. Наиболее обширную документацию по тестированию смарт-контрактов на сегодняшний день предоставляет связка Truffle&Ganache. Оба этих инструмента имеют хорошую документацию, многие кейсы уже решались на Stack Overflow и подобных ресурсах. Однако, у этого подхода есть один важный недостаток: для написания тестов нужно использовать Node.js.


Ловушки JavaScript


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


let proposalExists = await voting.checkProposal();
assert.equal(proposalExists, true, 'Proposal should exist');

Если checkProposal возвращает строки “yes” или “no”, вы всегда преобразуете их в true. Динамическая типизация скрывает множество таких ловушек, и даже опытные программисты могут совершать подобные промахи, работая на большом проекте или в команде с другими разработчиками, которые могут вносить изменения в код и не сообщать об этом.


Статическая типизация в Go позволяет предотвратить подобные ошибки. Кроме того, использование языка Go вместо Node.js для тестирования — мечта любого Go-разработчика, начинающего работу со смарт-контрактами.


Моя команда занималась разработкой инвестиционной системы на основе смарт-контрактов с очень сложной архитектурой. Система смарт-контрактов содержала более 2000 строк кода. Поскольку основную часть команды составляли Go-разработчики, тестирование на Go было предпочтительнее, чем на Node.js.


Первая среда для тестирования смарт-контрактов на Go


В 2017 PolySwarm разработали Perigord — инструмент схожий с Truffle, использующий Go вместо JavaScript. К сожалению, этот проект больше не поддерживается, у него есть всего один туториал с очень простыми примерами. К тому же, он не поддерживает интеграцию с Ganache (приватный блокчейн для разработки Ethereum с очень удобным GUI). Мы улучшили Perigord путем устранения багов и внедрения двух новых функций: генерации кошельков из мнемонического кода и их использования для тестирования и подключения к блокчейну Ganache. Вы можете ознакомиться с исходным кодом по ссылке.


Оригинальный туториал Perigord содержит только простейший пример вызова контракта для изменения одного значения. Однако в реальном мире вам также нужно будет вызывать контракт с разных кошельков, отправлять и получать Ether и т.д. Теперь вы можете делать все это, используя усовершенствованный Perigord и старый добрый Ganache. Ниже вы найдете подробное руководство по разработке и тестированию смарт-контрактов с помощью Perigord&Ganache.


Использование улучшенного Perigord: полное руководство


Для использования Perigord вам нужно установить Go 1.7+, solc, abigen и Ganache. Пожалуйста, ознакомьтесь с документацией для вашей операционной системы.


Установите Perigord следующим образом:


$ go get gitlab.com/go-truffle/enhanced-perigord
$ go build

После этого вы сможете использовать команду perigord:


$ perigord
A golang development environment for Ethereum

Usage:
  perigord [command]

Available Commands:
  add         Add a new contract or test to the project
  build       (alias for compile)
  compile     Compile contract source files
  deploy      (alias for migrate)
  generate    (alias for compile)
  help        Help about any command
  init        Initialize new Ethereum project with example contracts and tests
  migrate     Run migrations to deploy contracts
  test        Run go and solidity tests

Flags:
  -h, --help   help for perigord

Use "perigord [command] --help" for more information about a command.

Сейчас мы создадим простой смарт-контракт Market, чтобы продемонстрировать доступные варианты тестирования.


Чтобы начать проект, введите в терминал следующее:


$ perigord init market

Проект появится в папке src/ в GOPATH. Переместите проект в другую папку и обновите пути импортирования, если хотите изменить его расположение. Посмотрим, что находится в папке market/.


$ tree
.
+-- contracts
¦   L-- Foo.sol
+-- generate.go
+-- main.go
+-- migrations
¦   L-- 1_Migrations.go
+-- perigord.yaml
+-- stub
¦   +-- README.md
¦   L-- main.go
+-- stub_test.go
L-- tests
    L-- Foo.go

Очень похоже на проект созданный в Truffle, не правда ли? Но это все на Go! Посмотрим, что в файле конфигурации perigord.yaml.


networks:
    dev:
        url: /tmp/geth_private_testnet/geth.ipc
        keystore: /tmp/geth_private_testnet/keystore
        passphrase: blah
        mnemonic: candy maple cake sugar pudding cream honey rich smooth crumble sweet treat
        num_accounts: 10

Для тестирования вы можете использовать как приватную сеть geth и файлы кошельков, так и подключиться к Ganache. Эти варианты взаимоисключающие. Мы возьмем мнемонику, которая используется по умолчанию, сгенерируем 10 аккаунтов и подключимся к Ganache. Замените код в perigord.yaml на:


networks:
   dev:
       url: HTTP://127.0.0.1:7545
       mnemonic: candy maple cake sugar pudding cream honey rich smooth crumble sweet treat
       num_accounts: 10

HTTP://127.0.0.1:7545 — стандартный адрес сервера Ganache RPC. Обратите внимание, что вы можете создать сколько угодно аккаунтов для тестирования, но только аккаунты, сгенерированные в Ganache (GUI), будут содержать средства.


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


Добавим контакт в проект:


$ perigord add contract Market

Постфикс .sol будет добавлен автоматически. Вы также можете добавить другие контракты или удалить контракт-пример Foo.sol. Пока вы работаете в GOPATH, вы можете использовать импорт контрактов для создания сложных конструкций. У нас будет три файла Solidity: основной контракт Market, вспомогательные контракты Ownable и Migrations и библиотека SafeMath. Вы можете найти исходный код здесь.


Теперь проект имеет следующую структуру:


.
+-- contracts
¦   +-- Market.sol
¦   +-- Ownable.sol
¦   L-- SafeMath.sol
+-- generate.go
+-- main.go
+-- migrations
¦   L-- 1_Migrations.go
+-- perigord.yaml
+-- stub
¦   +-- README.md
¦   L-- main.go
+-- stub_test.go
L-- tests
    L-- Foo.go

Генерируем байт-код EVM, биндинги ABI и Go:


$ perigord build

Добавляем миграции всех контрактов, которые вы будете деплоить. Т.к. мы деплоим только Market.sol, нам понадобиться всего одна новая миграция:


$ perigord add migration Market

Наш контракт не содержит конструктор, принимающий параметры. Если вам нужно передать параметры в конструктор, добавьте их в функцию Deploy{NewContract} в файле миграций:


address, transaction, contract, err := bindings.Deploy{NewContract}(auth, network.Client(),
  “FOO”, “BAR”)

Удалите файл-пример Foo.go и добавьте тестовый файл для нашего контракта:


$ perigord add test Market

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


func getMnemonic() string {
  viper.SetConfigFile("perigord.yaml")
  if err := viper.ReadInConfig(); err != nil {
     log.Fatal()
  }
  mnemonic := viper.GetStringMapString("networks.dev")["mnemonic"]
  return mnemonic
}

Следующая вспомогательная функция используется для получения адреса сети:


func getNetworkAddress() string {
  viper.SetConfigFile("perigord.yaml")
  if err := viper.ReadInConfig(); err != nil {
     log.Fatal()
  }
  networkAddr := viper.GetStringMapString("networks.dev")["url"]
  return networkAddr
}

Еще одна вспомогательная функция, которая нам понадобится, — sendETH, мы будем использовать ее для передачи Ether с одного из сгенерированных кошельков (обозначенный индексом) на любой Ethereum адрес:


func sendETH(s *MarketSuite, c *ethclient.Client, sender int, receiver common.Address, value *big.Int) {

  senderAcc := s.network.Accounts()[sender].Address
  nonce, err := c.PendingNonceAt(context.Background(), senderAcc)
  if err != nil {
     log.Fatal(err)
  }

  gasLimit := uint64(6721975) // in units

  gasPrice := big.NewInt(3700000000)
  wallet, err := hdwallet.NewFromMnemonic(getMnemonic())
  toAddress := receiver
  var data []byte
  tx := types.NewTransaction(nonce, toAddress, value, gasLimit, gasPrice, data)

  chainID, err := c.NetworkID(context.Background())
  if err != nil {
     log.Fatal(err)
  }

  privateKey, err := wallet.PrivateKey(s.network.Accounts()[sender])

  signedTx, err := types.SignTx(tx, types.NewEIP155Signer(chainID), privateKey)
  if err != nil {
     log.Fatal(err)
  }

  ts := types.Transactions{signedTx}
  rawTx := hex.EncodeToString(ts.GetRlp(0))

  var trx *types.Transaction

  rawTxBytes, err := hex.DecodeString(rawTx)
  err = rlp.DecodeBytes(rawTxBytes, &trx)

  err = c.SendTransaction(context.Background(), trx)
  if err != nil {
     log.Fatal(err)
  }
}

Следующие две функции используются для изменения вызова контракта:


func ensureAuth(auth bind.TransactOpts) *bind.TransactOpts {
  return &bind.TransactOpts{
     auth.From,
     auth.Nonce,
     auth.Signer,
     auth.Value,
     auth.GasPrice,
     auth.GasLimit,
     auth.Context}
}

func changeAuth(s MarketSuite, account int) bind.TransactOpts {
  return *s.network.NewTransactor(s.network.Accounts()[account])
}

Процедура тестирования


Для вызова мы создаем contractSessionActual для определенного контракта. Т.к. у контракта есть владелец, мы можем получить его адрес и проверить, соответствует ли он дефолтному нулевому аккаунту Ganache. Мы сделаем это следующим образом (опустим обработку ошибок, чтобы сэкономить место):


contractSession := contract.Session("Market")
c.Assert(contractSession, NotNil)
contractSessionActual, ok := contractSession.(*bindings.MarketSession)
c.Assert(ok, Equals, true)
c.Assert(contractSessionActual, NotNil)

owner, _ := contractSessionActual.Owner()
account0 := s.network.Accounts()[0]
c.Assert(owner.Hex(), Equals, account0.Address.Hex()) //Owner account is account 0

Следующая полезная функция — изменение кошелька, вызывающего контракт:


ownerInd := 0
sender := 5
receiver := 6

senderAcc := s.network.Accounts()[sender].Address
receiverAcc := s.network.Accounts()[receiver].Address

//Call contract on behalf of its owner
auth := changeAuth(*s, ownerInd)
_, err = contractSessionActual.Contract.SetSenderReceiverPair(ensureAuth(auth),
  senderAcc, receiverAcc)

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


auth = changeAuth(*s, sender) //Change auth fo senderAcc to make a deposit on behalf of the sender

client, _ := ethclient.Dial(getNetworkAddress())

//Let's check the current balance
balance, _ := client.BalanceAt(context.Background(), contract.AddressOf("Market"), nil)
c.Assert(balance.Int64(), Equals, big.NewInt(0).Int64()) //Balance should be 0

//Let's transfer 3 ETH to the contract on behalf of the sender
value := big.NewInt(3000000000000000000) // in wei (3 eth)
contractReceiver := contract.AddressOf("Market")
sendETH(s, client, sender, contractReceiver, value)

balance2, _ := client.BalanceAt(context.Background(), contract.AddressOf("Market"), nil)

c.Assert(balance2.Int64(), Equals, value.Int64()) //Balance should be 3 ETH

Полный код тестов приведен здесь.


Теперь откроем stub_test.go и убедимся, что все импорты указывают на ваш текущий проект. В нашем случае это:


import (
  _ "market/migrations"
  _ "market/tests"
  "testing"

  . "gopkg.in/check.v1"
)

Запустим тесты:


$ perigord test

Если все сделано правильно, то после окончания тестирования будет похожий результат:


Running migration 2
Running migration 3
OK: 1 passed
PASS
ok      market  0.657s

Если у вас возникли проблемы, скачайте исходные файлы и повторите шаги, описанные в этом руководстве.


В заключение


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


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

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (7):

  1. jehy
    /#19941456

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

    • Olena_Stoliarova
      /#19948536

      Статья рассчитана на конкретную целевую аудиторию: программистов, для которых Go — основной или единственный язык. Поэтому она в теме Go, а не JS. Здесь нет ненависти.

      • jehy
        /#19949190

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

        А хейтерство начинается прямо с бессмысленной сопроводительной картинки и вступления:

        Однако, у этого подхода есть один важный недостаток: для написания тестов нужно использовать Node.js.

        Дальше ещё целый бессмысленный параграф про «ловушки Javascript». Даже комментировать не буду.

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

        Ну и в заголовке убрать «прощай, JavaScript». Статья бы ничего не потеряла для смысла ни для какой аудитории. Но выглядела бы адекватно.

        Ну и из смешного
        Perigord — это надежный инструмент для тестирования, написанный на вашем любимом языке.

        Откуда вы знаете любимый язык читателя? Хотя да, я уже забыл — статью разрешается читать только Go разработчикам…

  2. i360u
    /#19942750

    Если вы работаете с людьми, которые вносят изменения в API и интерфейсы и не говорят об этом — у вас на любом языке будут проблемы.

    • Olena_Stoliarova
      /#19948542

      Тут не поспоришь, это всегда так. Я описала способ делать часть работы, используя Go.

      • i360u
        /#19948776 / +1

        К чему тогда эти нападки на JS? И Go и JS — прекрасные языки на своих местах, не думаю что корректно писать об одном принижая другой.

  3. dukei
    /#19955682

    Спасибо, конечно, лишний инструмент не помешает. Но скрипты всё же лучше писать на скриптовых языках. А если вы их не знаете — выучите. Знать только один Go и переделывать мир под это — плохая затея. Тем более, Go не очень-то хорош для того, чтобы быть единственным языком.