Как стать автором
Обновить

О том как написать и опубликовать смарт-контракт в Telegram Open Network (TON)

Время на прочтение 27 мин
Количество просмотров 13K

О чем эта статья?


В статье я расскажу о том, как поучаствовал в первом (из двух) конкурсе Telegram по блокчейну, не занял призовое место и решил зафиксировать опыт в статье, чтобы он не канул в Лету и, возможно, помог кому-нибудь.


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


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


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


Об участии в конкурсе


В октябре прошлого года Telegram объявил конкурс по блокчейну с новыми языками Fift и FunC. Нужно было на выбор написать любые из пяти предложенных смарт-контрактов. Я посчитал, что будет неплохо заняться чем-то необычным, изучить язык и сделать что-нибудь, даже если в будущем не придется писать что-либо еще. Плюс, тема постоянно на слуху.


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


Я планировал участвовать до самого конца пока получается и после написать обзорную статью, но зафейлился сразу на первом этапе. Я написал кошелек с мульти-подписью на FunC и он в общем работал. За основу взял смарт-контракт на Solidity.


На тот момент я посчитал, чтобы занять хотя бы какое-то призовое место этого точно достаточно. В итоге, около 40 из 60 участников стали призерами и меня среди них не было. В общем, в этом ничего страшного, но меня напрягла одна вещь. На момент объявление результатов ревью с тестом к моему контракту не было сделано, я спросил у участников в чате есть ли кто еще у кого его нет, таких не было.


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


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


Концепт работы смарт-контрактов в TON


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


Мы сосредоточимся на написании смарт-контракта на FunC и Fift, который будет компилироваться в Fift-ассемблер и выполняться в TON Virtual Machine (TVM). Поэтому статья больше похожа на описание разработки обычной программы. На том как работает сама платформа тут останавливаться не будем.


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


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


Допустим мы уже написали смарт-контракт на FunC, после этого мы компилируем код в Fift-ассемблер.


Скомпилированный смарт-контракт остается опубликовать. Для этого нужно написать код на Fift, который на вход будет принимать код смарт-контракта и еще некоторые параметры, а на выходе получится файл с расширением .boc (что означает "bag of cells"), и, в зависимости от того как напишем, приватный ключ и адрес, который генерируется на основе кода смарт-контракта.


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


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


Чтобы опубликовать смарт-контракт в TON полученный .boc файл нужно отправить в блокчейн с помощью лайт-клиента (о чем ниже). После публикации со смарт-контрактом можно будет взаимодейстовать, отправляя ему сообщения снаружи (например, с помощью лайт-клиента) или изнутри (например, один смарт-контракт шлет другому сообщение внутри TON).


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


Очень часто я искал по ключевым словам в Telegram-чате, где во время конкурса собрались все участники и сотрудники Telegram в том числе, и начали обсуждать Fift и FunC. Ссылка в конце статьи.


Пора перейти к практике.


Подготовка окружения для работы с TON


Все что будет описано в статье я делал на MacOS и перепроверил в чистой Ubuntu 18.04 LTS на Docker.


Первое что нужно сделать скачать и установить lite-client с помощью, которого можно отправлять запросы в TON.


Инструкция на официальном сайте довольно понятно описывает процесс установке. Тут мы следуем инструкции попутно устанавливая недостающие зависимости. Я не стал сам компилировать каждую библотеку по отдельности и устанавливал из официального репозитория Ubuntu (на MacOS я использовал brew).


apt -y install git 
apt -y install wget 
apt -y install cmake 
apt -y install g++ 
apt -y install zlib1g-dev 
apt -y install libssl-dev 

После того как все зависимости установлены мы установим lite-client, Fift и FunC.


Сначала клонируем репозиторий TON вместе с зависимостями. Для удобства все будем делать в папке ~/TON.


cd ~/TON
git clone https://github.com/newton-blockchain/ton.git
cd ./ton
git submodule update --init --recursive

В репозитортии также хранятся реалзиации Fift и FunC.


Теперь мы готовы собрать проект. Код репозитория склонирован в папку ~/TON/ton. В ~/TON создаем папку build и собираем в ней проект.


mkdir ~/TON/build 
cd ~/TON/build
cmake ../ton

Так как мы собираемся писать смарт-контракт нам нужен не только lite-client, но и Fift с FunC, поэтому компилируем все. Не быстрый процесс поэтому ждем.


cmake --build . --target lite-client
cmake --build . --target fift
cmake --build . --target func

Далее скачиваем конфигурационный файл в котором лежат данные о ноде к которой lite-client будет подключаться.


wget https://newton-blockchain.github.io/global.config.json

Делаем первые запросы в TON


Теперь запустим lite-client.


cd ~/TON/build
./lite-client/lite-client -C global.config.json

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


[ 1][t 2][1582054822.963129282][lite-client.h:201][!testnode]   conn ready
[ 2][t 2][1582054823.085654020][lite-client.cpp:277][!testnode] server version is 1.1, capabilities 7
[ 3][t 2][1582054823.085725069][lite-client.cpp:286][!testnode] server time is 1582054823 (delta 0)
...

Можно выполнить команду help и посмотреть какие команды доступны.


help

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


list of available commands:
last    Get last block and state info from server
sendfile <filename> Load a serialized message from <filename> and send it to server
getaccount <addr> [<block-id-ext>]  Loads the most recent state of specified account; <addr> is in [<workchain>:]<hex-or-base64-addr> format
runmethod <addr> [<block-id-ext>] <method-id> <params>...   Runs GET method <method-id> of account <addr> with specified parameters

last получает последний созданный блок с сервера. 

sendfile <filename> отправляет в TON файл с сообщением, именно с помощью этой команды публикуется смарт-контракт и запрсосы к нему. 

getaccount <addr> загружает текущее состояние смарт-контракта с указанным адресом. 

runmethod <addr> [<block-id-ext>] <method-id> <params>  запускает get-методы смартконтракта. 

Теперь мы готовы к написанию самого смарт-контракта.


Реализация


Идея


Как уже писал выше, смарт-контракт который мы пишем это лотерея.


Причем это не лотерея в которой надо купить билет и ждать час, день или месяц, а моментальная в которой пользователь переводит на адрес смарт-контракта N грамов, и моментально получает обратно 2 * N грамов или проигрывает. Вероятность победы сделаем около 40%. Если грамов для выплаты не достаточно, то будем считать транзакцию пополнением.


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


Написание смарт-контракта


Для удобства я сделал подстветку кода для FunC, плагин можно найти и установить в поиске Visual Studio Code, если вдруг захочется добавить что-то, то выложил плагин в открытый доступ. Также ранее кем-то был сделан плагин для работы с Fift, тоже можно и установить найти в VSC.


Сразу создадим репозиторий куда будем комитить промежуточные результаты.


Чтобы облегчить себе жизнь мы будем писать смарт-контракт и тестировать локально, до тех пор пока он не будет готов. Только после этого опубликуем его в TON.


У смарт-контракта есть два внешних метода к которым можно обращаться. Первый, recv_external() эта функция выполняется когда запрос к контракту происходит из внешнего мира, то есть не из TON, например когда мы сами формируем сообщение и отправляем его через lite-client. Второй, recv_internal() это когда внутри самого TON какой-либо контракт обращается к нашему. В обоих случаях можно передать параметры в функцию.


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


() recv_internal(slice in_msg) impure {
    ;; TODO: implementation 
}

() recv_external(slice in_msg) impure {
    ;; TODO: implementation  
}

Тут надо пояснить что такое slice. Все хранящиееся данные в TON Blockchain это коллекция TVM cell или просто cell, в такой ячейке можно хранить до 1023 бит данных и до 4 ссылок на другие ячейки.


TVM cell slice или slice это часть существующей cell используется для ее парсинга, дальше будет понятно. Главное для нас, что в смарт-контракт мы можем передать slice и в зависимости от вида сообщения обработать данные в recv_external() или recv_internal().


impure — ключевое слово, которое указывает на то, что функция изменяет данные смарт-контракта.


Сохраним код контракта в lottery-code.fc и скомпилируем.


~/TON/build/crypto/func -APSR -o lottery-compiled.fif ~/TON/ton/crypto/smartcont/stdlib.fc ./lottery-code.fc 

Значение флагов можно посмотреть с помощью команды


~/TON/build/crypto/func -help

У нас получился скомпилированный Fift-ассемблер код в lottery-compiled.fif.


// lottery-compiled.fif

"Asm.fif" include
// automatically generated from `/Users/rajymbekkapisev/TON/ton/crypto/smartcont/stdlib.fc` `./lottery-code.fc` 
PROGRAM{
  DECLPROC recv_internal
  DECLPROC recv_external
  recv_internal PROC:<{
    //  in_msg
    DROP    // 
  }>
  recv_external PROC:<{
    //  in_msg
    DROP    // 
  }>
}END>c

Его можно запустить локально, для этого подготовим окружение.


Заметим, первой строчкой подключается Asm.fif, это код написанный на Fift реализации Fift-ассемблера.


Так как мы хотим запускать и тестировать смарт-контракт локально создадим файл lottery-test-suite.fif и скопируем туда скомпилированный код заменив в нем последнюю строчку, которая записывает код смарт-контракта в константу code, чтобы потом передать его в виртуальную машину:


"TonUtil.fif" include
"Asm.fif" include

PROGRAM{
  DECLPROC recv_internal
  DECLPROC recv_external
  recv_internal PROC:<{
    //  in_msg
    DROP    // 
  }>
  recv_external PROC:<{
    //  in_msg
    DROP    // 
  }>
}END>s constant code

Пока вроде понятно, теперь добавим в тот же файл код, который мы будем исполльзовать для запуска TVM.


0 tuple 0x076ef1ea , // magic
0 , 0 , // actions msg_sents
1570998536 , // unix_time
1 , 1 , 3 , // block_lt, trans_lt, rand_seed
0 tuple 100000000000000 , dictnew , , // remaining balance
0 , dictnew , // contract_address, global_config
1 tuple // wrap to another tuple
constant c7

0 constant recv_internal // to run recv_internal() 
-1 constant recv_external // to invoke recv_external()

В c7 мы записываем контекст, то есть данные с которыми будет запускаться TVM (или состояние сети). Еще во время конкурса один из разработчиков показал как создается c7 и я скопировал. В этой статье нам возможно нужно будет менять rand_seed так как от него зависит генерация случайного числа и не менять, то каждый раз будет возвращаться тоже самое число.


recv_internal и recv_external константы со значением 0 и -1 будут отвечать за вызов соотвествующих фунций в смарт-контракте.


Теперь мы готовы создать первый тест к нашему пустому смарт-контракту. Для наглядности пока все тесты мы будем добавлять в этот же файл lottery-test-suite.fif.


Создадим переменную storage и запишем в нее пустой cell, это будет хранилище смарт-контракта.


message это сообщение, котрое мы передадим смарт-конртакту извне. Его тоже сделаем пока пустым.


variable storage 
<b b> storage ! 

variable message 
<b b> message ! 

После того как мы подготовили конастанты и переменные мы запускаем TVM с помощью команды runvmctx и передаем созданные параметры на вход.


message @ 
recv_external 
code 
storage @ 
c7 
runvmctx 

В итоге у нас получится вот такой промежуточный код на Fift.


Теперь мы можем запустить получшившийся код.


export FIFTPATH=~/TON/ton/crypto/fift/lib // выполняем один раз для удобства 
~/TON/build/crypto/fift -s lottery-test-suite.fif 

Программа должна отработать без ошибок и в выводе увидим лог выполнения.


execute SETCP 0
execute DICTPUSHCONST 19 (xC_,1)
execute DICTIGETJMPZ
execute DROP
execute implicit RET
[ 3][t 0][1582281699.325381279][vm.cpp:479]     steps: 5 gas: used=304, max=9223372036854775807, limit=9223372036854775807, credit=0

Отлично, мы написали первую рабочую версию смарт-контракта вместе с тестом.


Обработка внешних сообщений к смарт-контракту


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


Разработчик сам выбирает формат сообщения которое контракт может принять. Но обычно,


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

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


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


Возвращаемся в lottery-test-suite.fif и дописываем в него второй тест. Отправим неверный номер, код должен выкинуть исключение. Например, пусть в данных контракта хранится 166, а мы отправим 165.


<b 166 32 u, b> storage !
<b 165 32 u, b> message !

message @ 
recv_external 
code 
storage @ 
c7 
runvmctx

drop 
exit_code ! 
."Exit code " exit_code @ . cr 
exit_code @ 33 - abort"Test #2 Not passed"

Запустим.


 ~/TON/build/crypto/fift -s lottery-test-suite.fif 

И увидим что тест выполняется с ошибкой.


[ 1][t 0][1582283084.210902214][words.cpp:3046] lottery-test-suite.fif:67: abort": Test #2 Not passed
[ 1][t 0][1582283084.210941076][fift-main.cpp:196]      Error interpreting file `lottery-test-suite.fif`: error interpreting included file `lottery-test-suite.fif` : lottery-test-suite.fif:67: abort": Test #2 Not passed

На этом этапе lottery-test-suite.fif должен выглядеть как по ссылке.


Теперь давайте допишем логику счетчика в смарт-контракта в lottery-code.fc.


() recv_internal(slice in_msg) impure {
    ;; TODO: implementation 
}

() recv_external(slice in_msg) impure {
    if (slice_empty?(in_msg)) {
        return (); 
    }
    int msg_seqno = in_msg~load_uint(32);
    var ds = begin_parse(get_data());
    int stored_seqno = ds~load_uint(32);
    throw_unless(33, msg_seqno == stored_seqno);
}

В slice in_msg лежит сообщение, которые мы отправляем.


Сначала проверяем если в сообщении есть данные, если нет, то просто выходим.


Далее мы парсим сообщение. in_msg~load_uint(32) загружает 32-х битное unsigned int число 165 из переданного сообщения.


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


Теперь скомпилируем.


~/TON/build/crypto/func -APSR -o lottery-compiled.fif ~/TON/ton/crypto/smartcont/stdlib.fc ./lottery-code.fc 

Получившийся код скопируем в lottery-test-suite.fif, не забывая заменить последнюю строчку. Проверяем, что тест проходит.


~/TON/build/crypto/fift -s lottery-test-suite.fif

Вот тут можно посмотреть соотвествующий коммит с текущими результатами.


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


В папке с проектом создадим файл build.sh со следующим содержанием.


#!/bin/bash

~/TON/build/crypto/func -SPA -R -o lottery-compiled.fif ~/TON/ton/crypto/smartcont/stdlib.fc ./lottery-code.fc

Сделаем его исполняемым.


chmod +x ./build.sh

Теперь, достаточно запустить наш скрипт, чтобы скомпилировать контракт в lottery-compiled.fif. Но кроме этого нам надо записать его в константу code.


Добавим в sh скирпт код, который будет просто дублировать скомплированный файл в lottery-compiled-for-test.fif и менять в нем последнюю строчку. Его и включим в файле lottery-test-suite.fif.


# copy and change for test 
cp lottery-compiled.fif lottery-compiled-for-test.fif
sed '$d' lottery-compiled-for-test.fif > test.fif
rm lottery-compiled-for-test.fif
mv test.fif lottery-compiled-for-test.fif
echo -n "}END>s constant code" >> lottery-compiled-for-test.fif

Теперь чтобы проверить, запустим получившийся скрипт и у нас сгенерируется файл lottery-compiled-for-test.fif, который мы включим в наш lottery-test-suite.fif.


В lottery-test-suite.fif удаляем код контракт и добавляем строчку "lottery-compiled-for-test.fif" include.


Запускаем тесты, чтобы проверить, что они проходят.


~/TON/build/crypto/fift -s lottery-test-suite.fif

Отлично, теперь, чтобы автоматизировать запуск тестов создаим файл test.sh, который сначала будет выполнять build.sh, а потом запускать тесты.


touch test.sh
chmod +x test.sh

Внутрь пишем test.sh.


./build.sh 

echo "\nCompilation completed\n"

export FIFTPATH=~/TON/ton/crypto/fift/lib
~/TON/build/crypto/fift -s lottery-test-suite.fif

Проверяем, что контракт компилируется и тесты выполняются.


./test.sh

Отлично, теперь при запуске test.sh сразу будет происходить компиляция и запуск тестов. Вот ссылка на коммит.


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


Создадим папку build где будем хранить скопилированный контракт и его клон записанный в константу lottery-compiled.fif, lottery-compiled-for-test.fif. Также создадим папку test где будут хранится файл с тестами lottery-test-suite.fif и потенциально другие вспомогательные файлы. Ссылка на соответствующие изменения.


Продолжим разработку смарт-контракта.


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


Сейчас подумаем над тем какая структура данных и какие данные нужно сохранять в смарт-контракте. Опишу все что мы храним.


`seqno` 32-х битное целое положительное число счетчик. 

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

`order_seqno` 32-х битное целое положительное число хранит счетчик количества ставок. 

`number_of_wins` 32-х битное целое положительное число хранит  количество побед. 

`incoming_amount` тип данных Gram (первые 4 бита отвечает за длину), хранит общее количество грамов, которые были отправлены на контртакт. 

`outgoing_amount` общее количество грамов, которое было отправлено победителям. 

`owner_wc` номер воркчейна, 32-х битное (в некоторых местах написано, что 8-ми битное) целое число. В данный момент всего два -1 и 0. 

`owner_account_id` 256-ти битное целое положительное число, адрес контракта в текущем воркчейне. 

`orders` переменная типа словарь, хранит последние двадцать ставок. 

Далее нужно написать две функции. Первую назовем pack_state(), которая будет упаковывать данные для последующего сохранения его в хранилище смарт-контракта. Вторую, назовем unpack_state() будет считывать и возвращать данные из хранилища.


_ pack_state(int seqno, int pubkey, int order_seqno, int number_of_wins, int incoming_amount, int outgoing_amount, int owner_wc, int owner_account_id, cell orders) inline_ref {
    return begin_cell()
            .store_uint(seqno, 32)
            .store_uint(pubkey, 256)
            .store_uint(order_seqno, 32)
            .store_uint(number_of_wins, 32)
            .store_grams(incoming_amount)
            .store_grams(outgoing_amount)
            .store_int(owner_wc, 32)
            .store_uint(owner_account_id, 256)
            .store_dict(orders)
            .end_cell();
}

_ unpack_state() inline_ref {
    var ds = begin_parse(get_data());
    var unpacked = (ds~load_uint(32), ds~load_uint(256), ds~load_uint(32), ds~load_uint(32), ds~load_grams(), ds~load_grams(), ds~load_int(32), ds~load_uint(256), ds~load_dict());
    ds.end_parse();
    return unpacked;
}

Добавляем эти две функции вначало смарт-контракта. Получится вот такой промежуточный результат.


Чтобы сохранить данные нужно будет вызвать встроенную фунцкии set_data() и она запишет данные из pack_state() в хранилище смарт-контракта.


cell packed_state = pack_state(arg_1, .., arg_n); 
set_data(packed_state);

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


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


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


Перед тем как продолжить создадим пару приватный/публичные ключи и запишем приватный в test/keys/owner.pk. Для этого запустим Fift в интерактивном режиме и выполним четыре команды.


`newkeypair` генерация публичного и приватного ключа и запись их в стек. 

`drop` удаления из стека верхнего элемента (в данном случае публичный ключ)  

`.s` просто посмотреть что лежит в стеке в данный момент 

`"owner.pk" B>file` запись приватного ключа в файл с именем `owner.pk`. 

`bye` завершает работу с Fift. 

Создадим папку keys внутри папки test и туда запишем приватный ключ.


mkdir test/keys
cd test/keys
~/TON/build/crypto/fift -i 
newkeypair
 ok
.s 
BYTES:128DB222CEB6CF5722021C3F21D4DF391CE6D5F70C874097E28D06FCE9FD6917 BYTES:DD0A81AAF5C07AAAA0C7772BB274E494E93BB0123AA1B29ECE7D42AE45184128 
drop 
 ok
"owner.pk" B>file
 ok
bye

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


Теперь нам нужно написать проверку подписи. Начнем с теста. Сначала мы считываем приватный ключ из файла с помощью функции file>B и записываем его в переменную owner_private_key. Дальше с помощью функции priv>pub конвертируем приватный ключ в публичный и запишем результат в owner_public_key.


variable owner_private_key
variable owner_public_key 

"./keys/owner.pk" file>B owner_private_key !
owner_private_key @ priv>pub owner_public_key !

Оба ключа нам понадобятся.


Инициализируем хранилище смарт-контракта. Произвольными данными заполним данными в той же самой последовательности, как в функции pack_state()и запишем в переменную storage.


variable owner_private_key
variable owner_public_key 
variable orders
variable owner_wc
variable owner_account_id

"./keys/owner.pk" file>B owner_private_key !
owner_private_key @ priv>pub owner_public_key !
dictnew orders !
0 owner_wc !
0 owner_account_id !

<b 0 32 u, owner_public_key @ B, 0 32 u, 0 32 u, 0 Gram, 0 Gram, owner_wc @ 32 i, owner_account_id @ 256 u,  orders @ dict, b> storage !

Далее составим подписанное сообщение, в нем будет только подпись и значение счетчика.


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


variable message_to_sign
variable message_to_send
variable signature
<b 0 32 u, b> message_to_sign !
message_to_sign @ hashu owner_private_key @ ed25519_sign_uint signature !
<b signature @ B, 0 32 u, b> <s  message_to_send !  

В итоге, сообщение, которое мы отправим в смарт-контракт записано в переменную message_to_send, про функции hashu, ed25519_sign_uint можно почитать в документации по Fift.


И для запуска теста снова вызываем.


message_to_send @ 
recv_external 
code 
storage @
c7
runvmctx

Вот так должен выглядть файл с тестами на данном этапе.


Запустим тест и он упадет, поэтому изменим смарт-контракт, чтобы он смог получать сообщения такого формата и проверять подпись.


Сначала считаем из сообщения 512 бит подписи и запишем в переменную, далее считаем 32 бита переменной счетчика.


Так как у нас есть функция считывания данных из хранилища смарт-конракта будем использовать ее.


Дальше проверка счетчика переданного с хранилищем и проверка подписи. Если что-то не совпадет, то выбрасываем исключение с соответствующим кодом.


var signature = in_msg~load_bits(512);
var message = in_msg;
int msg_seqno = message~load_uint(32);
(int stored_seqno, int pubkey, int order_seqno, int number_of_wins, int incoming_amount, int outgoing_amount, int owner_wc, int owner_account_id, cell orders) = unpack_state();
throw_unless(33, msg_seqno == stored_seqno);
throw_unless(34, check_signature(slice_hash(in_msg), signature, pubkey));

Соотвествуюший коммит вот тут.


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


Во втором тесте добавим подпись сообщения и изменим хранилище смарт-контракта. Вот так выглядит файл с тестами в данный момент.


Напишем четвертый тест, в котором будем отправлять сообщение подписанное чужим приватным ключом. Создадим еще один приватный ключ и сохраним в файл not-owner.pk. Этим приватным ключом подпишем сообщение. Запустим тесты и убедимся, что все тесты проходят. Коммит на текущий момент.


Теперь наконец мы можем перейти к реализации логики смарт-контракта. В recv_external() мы будем принимать два типа сообщений.


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


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


<b 0 32 u, 1 @ 7 u, new_owner_wc @  32 i, new_owner_account_id @ 256 u, b> message_to_sign !

В тесте можно увидеть как происходит десереализация хранилища смартконтратка storage в Fift. Десериализация переменных описана в документации по Fift. Ссылка на коммит с добавлением теста. Запускаем тест и убедимся, что он падает.


Теперь добавим логику по изменению адреса владельца лотереи. В смарт-контракте мы продолжаем парсить message, считываем в action. Напомним, что у нас будет два action: изменение адреса и отправка грамов. Потом считываем новый адрес владельца контракта и сохраняем в хранилище.


Запускаем тесты и видим, что третий тест падает. Падает из-за того, что контракт теперь дополнительно парсит 7 бит из сообщения, которых не хватает в тесте. Добавим в сообщение несуществущий action. Запустим тесты и видим, что все проходят. Тут коммит на изменения. Отлично.


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


Теперь допишем код. Сначала напишем два вспомогательных метода. Первый гет метод для того чтобы узнать текущий баланс смарт-контракта.


int balance() inline_ref method_id {
    return get_balance().pair_first();
}

И второй для отправки грамов на другой смарт-контракт. Этот метод я полностью скопировал из другого смарт-контракта.


() send_grams(int wc, int addr, int grams) impure {
    ;; int_msg_info$0 ihr_disabled:Bool bounce:Bool bounced:Bool src:MsgAddress -> 011000
    cell msg = begin_cell()
    ;;  .store_uint(0, 1) ;; 0 <= format indicator int_msg_info$0 
    ;;  .store_uint(1, 1) ;; 1 <= ihr disabled
    ;;  .store_uint(1, 1) ;; 1 <= bounce = true
    ;;  .store_uint(0, 1) ;; 0 <= bounced = false
    ;;  .store_uint(4, 5)  ;; 00100 <= address flags, anycast = false, 8-bit workchain
        .store_uint (196, 9)
        .store_int(wc, 8)
        .store_uint(addr, 256)
        .store_grams(grams)
        .store_uint(0, 107) ;; 106 zeroes +  0 as an indicator that there is no cell with the data.
        .end_cell(); 
    send_raw_message(msg, 3); ;; mode, 2 for ignoring errors, 1 for sender pays fees, 64 for returning inbound message value
}

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


int amount_to_send = message~load_grams();
throw_if(36, amount_to_send + 500000000 > balance());
accept_message();
send_grams(owner_wc, owner_account_id, amount_to_send);
set_data(pack_state(stored_seqno + 1, pubkey, order_seqno, number_of_wins, incoming_amount, outgoing_amount, owner_wc, owner_account_id, orders));

Вот так выглядит смарт-контракт на данный момент. Запустим тесты и убедимся, что они проходят.


Кстати, за обработанное сообщение у смарт-контракта каждый раз списывается комиссия. Чтобы смарт-контракт выполнил запрос, после базовых проверок нужно вызывать accept_message().


Обработка внутренних сообщений с смарт-контракту


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


Сначала напишем простой тест. Для этого нам понадобится тестовый адрес смарт-контракта с которого мы будто отправляем грамы на смарт-контракт.


Адрес смартконтракта состоит из двух чисел, 32-х битное целое число отвечает за workchain и 256-ти целое неотрицательное уникальный номер аккаунта в этом workchain. Например, -1 и 12345, этот адрес и сохраним в файл.


Я скопировал функцию по сохранению адреса из TonUtil.fif.


// ( wc addr fname -- )  Save address to file in 36-byte format
{ -rot 256 u>B swap 32 i>B B+ swap B>file } : save-address

Давайте разберем, как работает функция, это даст понимание как работает Fift. Запускаем Fift в интерактивном режиме.


~/TON/build/crypto/fift -i 

Сначала мы кладем в стек -1, 12345 и название будущего файла "sender.addr":


-1 12345 "sender.addr" 

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


"sender.addr" -1 12345

256 u>B конвертирует 256-ти битное неотрицательное целое в байты.


"sender.addr" -1 BYTES:0000000000000000000000000000000000000000000000000000000000003039

swap меняет местами два верхних элемента стека.


"sender.addr" BYTES:0000000000000000000000000000000000000000000000000000000000003039 -1

32 i>B конвертирует 32-х битное целое в байты.


"sender.addr" BYTES:0000000000000000000000000000000000000000000000000000000000003039 BYTES:FFFFFFFF

B+ соединяет две последовательности из байтов в одну.


 "sender.addr" BYTES:0000000000000000000000000000000000000000000000000000000000003039FFFFFFFF

Снова swap.


BYTES:0000000000000000000000000000000000000000000000000000000000003039FFFFFFFF "sender.addr" 

И наконец функция B>file принимает на вход два параметра, байты и имя файла, и выполняет запись байтов в файл. После этого наш стек пуст. Останавливаем Fift. В текущей папке создан файл sender.addr. Перенесем файл в созданную папку test/addresses/.


Напишем простой тест, который будет отправлять грамы на смарт-контракт. Вот коммит.


Теперь займемся логикой внутреннего сообщения лотереи.


Первое, что мы делаем, проверяем сообщение bounced или нет, если bounced, то игнорируем. bounced значит, что контракт вернет грамы если произойдет какая-то ошибка. Мы не будем возвращать грамы, если вдруг возникнет ошибка.


Проверяем, количество отправленных грамов, если меньше чем полграма, то просто принимаем сообщение и больше ничего не делаем.


Далее, парсим адрес смарт-контракта с которого пришло сообщение.


Считываем данные из хранилища и дальше удаляем старые ставки из истории если их больше двадцати. Для удобства я написал три дополнительные функции pack_order(), unpack_order(), remove_old_orders().


Дальше мы смотрим если баланса не хватает на выплату, то считаем, что это не ставка, а пополенение и сохраняем пополнение в orders.


Дальше наконец суть смарт-контракта.


Если игрок проиграл мы сохраняем его в историю ставок и если сумма больше 3 грамов отправялем 1/3 владельцу смарт-контракта.


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


() recv_internal(int order_amount, cell in_msg_cell, slice in_msg) impure {
    var cs = in_msg_cell.begin_parse();
    int flags = cs~load_uint(4);  ;; int_msg_info$0 ihr_disabled:Bool bounce:Bool bounced:Bool
    if (flags & 1) { ;; ignore bounced
        return ();
    }
    if (order_amount < 500000000) { ;; just receive grams without changing state 
        return ();
    }
    slice src_addr_slice = cs~load_msg_addr();
    (int src_wc, int src_addr) = parse_std_addr(src_addr_slice);
    (int stored_seqno, int pubkey, int order_seqno, int number_of_wins, int incoming_amount, int outgoing_amount, int owner_wc, int owner_account_id, cell orders) = unpack_state();
    orders = remove_old_orders(orders, order_seqno);
    if (balance() < 2 * order_amount + 500000000) { ;; not enough grams to pay the bet back, so this is re-fill
        builder order = pack_order(order_seqno, 1, now(), order_amount, src_wc, src_addr);
        orders~udict_set_builder(32, order_seqno, order);
        set_data(pack_state(stored_seqno, pubkey, order_seqno + 1, number_of_wins, incoming_amount + order_amount, outgoing_amount, owner_wc, owner_account_id, orders));
        return ();
    }
    if (rand(10) >= 4) {
        builder order = pack_order(order_seqno, 3, now(), order_amount, src_wc, src_addr);
        orders~udict_set_builder(32, order_seqno, order);
        set_data(pack_state(stored_seqno, pubkey, order_seqno + 1, number_of_wins, incoming_amount + order_amount, outgoing_amount, owner_wc, owner_account_id, orders));
        if (order_amount > 3000000000) {
            send_grams(owner_wc, owner_account_id, order_amount / 3);
        }
        return ();
    }
    send_grams(src_wc, src_addr, 2 * order_amount);
    builder order = pack_order(order_seqno, 2, now(), order_amount, src_wc, src_addr);
    orders~udict_set_builder(32, order_seqno, order);
    set_data(pack_state(stored_seqno, pubkey, order_seqno + 1, number_of_wins + 1, incoming_amount, outgoing_amount + 2 * order_amount, owner_wc, owner_account_id, orders));
}

Вот и все. Соответствующий коммит.


Написание гет-методов


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


Добавим гет-методы. О том как получать информацию о смарт-контракте напишем ниже.


Еще я забыл добавить код, который будет обрабатывать самый первый запрос, который происходит при публикации смарт-контракта. Соответствующий коммит. И еще исправил баг с отправлением 1/3 суммы на аккаунт владельца.


Публикация смарт-контратка в TON


Остается опубликовать смарт-контракт. Создадим папку requests.


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


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


Дальше мы исполняем код публикации и получаем lottery-query.boc файл и адрес смартконтракта.


~/TON/build/crypto/fift -s requests/new-lottery.fif 0

Не забываем сохранить сгенерированные файлы: lottery.addr, lottery.pk.


Среди прочего в логах выполнения увидим адрес смарт-контракта.


new wallet address = 0:044910149dbeaf8eadbb2b28722e7d6a2dc6e264ec2f1d9bebd6fb209079bc2a 
(Saving address to file lottery.addr)
Non-bounceable address (for init): 0QAESRAUnb6vjq27KyhyLn1qLcbiZOwvHZvr1vsgkHm8Ksyd
Bounceable address (for later access): kQAESRAUnb6vjq27KyhyLn1qLcbiZOwvHZvr1vsgkHm8KpFY

Ради интереса сделаем запрос в TON.


$ ./lite-client/lite-client -C global.config.json 
getaccount 0QAESRAUnb6vjq27KyhyLn1qLcbiZOwvHZvr1vsgkHm8Ksyd

И увидим, что аккаунт с таким адресом пуст.


account state is empty

Отправялем на адрес 0QAESRAUnb6vjq27KyhyLn1qLcbiZOwvHZvr1vsgkHm8Ksyd 2 грама и через несколько секунд выполняем туже команду. Для отправки грамов я использую официальный кошелек, а тестовые грамы можно попросить у кого-нибудь из чата, о котором я скажу в конце статьи.


> last
> getaccount 0QAESRAUnb6vjq27KyhyLn1qLcbiZOwvHZvr1vsgkHm8Ksyd

Смотрит, что смарт-контракт изменил статус с пустого на неинициализированный (state:account_uninit) и балансом 2 000 000 000 нанограм.


account state is (account
  addr:(addr_std
    anycast:nothing workchain_id:0 address:x044910149DBEAF8EADBB2B28722E7D6A2DC6E264EC2F1D9BEBD6FB209079BC2A)
  storage_stat:(storage_info
    used:(storage_used
      cells:(var_uint len:1 value:1)
      bits:(var_uint len:1 value:103)
      public_cells:(var_uint len:0 value:0)) last_paid:1583257959
    due_payment:nothing)
  storage:(account_storage last_trans_lt:3825478000002
    balance:(currencies
      grams:(nanograms
        amount:(var_uint len:4 value:2000000000))
      other:(extra_currencies
        dict:hme_empty))
    state:account_uninit))
x{C00044910149DBEAF8EADBB2B28722E7D6A2DC6E264EC2F1D9BEBD6FB209079BC2A20259C2F2F4CB3800000DEAC10776091DCD650004_}
last transaction lt = 3825478000001 hash = B043616AE016682699477FFF01E6E903878CDFD6846042BA1BFC64775E7AC6C4
account balance is 2000000000ng

Теперь опубликуем смарт-контракт. Запустим lite-client и выполним.


> sendfile lottery-query.boc
[ 1][t 2][1583008371.631410122][lite-client.cpp:966][!testnode] sending query from file lottery-query.boc
[ 3][t 1][1583008371.828550100][lite-client.cpp:976][!query]    external message status is 1 

Проверим, что контракт опубликован.


> last
> getaccount 0QAESRAUnb6vjq27KyhyLn1qLcbiZOwvHZvr1vsgkHm8Ksyd

Среди прочего получим.


  storage:(account_storage last_trans_lt:3825499000002
    balance:(currencies
      grams:(nanograms
        amount:(var_uint len:4 value:1987150999))
      other:(extra_currencies
        dict:hme_empty))
    state:(account_active

Видим, что account_active.


Соответствующий коммит с изменениями вот тут.


Отправка внешних сообщений


Теперь создадим запросы для взаимодействия со смарт-контрактом.


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


Вот такое сообщение мы будем отправлять на смартконтракт, где msg_seqno 165, action 2 и 9.5 грам для отправки.


<b 165 32 u, 2 7 u, 9500000000 Gram, b>

Не забываем подписать сообщение приватным ключом lottery.pk, который сгенерировался ранее при создании смарт-контракта. Вот соотвествующий комит.


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


Теперь рассмотрим как запускать гет-методы смарт-контракта.


Запускаем lite-client и вызываем runmethod с адресом смарт-контракта и нужным гет-методом.


$ ./lite-client/lite-client -C ton-lite-client-test1.config.json
> runmethod 0QAESRAUnb6vjq27KyhyLn1qLcbiZOwvHZvr1vsgkHm8Ksyd balance
arguments:  [ 104128 ] 
result:  [ 64633878952 ] 
...

В result содержится значение, которое возвращает функция balance() из нашего смарт-контракта.
Тоже самое выполним еще для нескольких методов.


> runmethod 0QAESRAUnb6vjq27KyhyLn1qLcbiZOwvHZvr1vsgkHm8Ksyd get_seqno
...
arguments:  [ 77871 ] 
result:  [ 1 ] 

Запросим историю ставок.


> runmethod 0QAESRAUnb6vjq27KyhyLn1qLcbiZOwvHZvr1vsgkHm8Ksyd get_orders
...
arguments:  [ 67442 ] 
result:  [ ([0 1 1583258284 10000000000 0 74649920601963823558742197308127565167945016780694342660493511643532213172308] [1 3 1583258347 4000000000 0 74649920601963823558742197308127565167945016780694342660493511643532213172308] [2 1 1583259901 50000000000 0 74649920601963823558742197308127565167945016780694342660493511643532213172308]) ] 

Мы будем использовать lite-client и гет-методы чтобы выводить информацию о смарт-контракте на сайте.


Показываем данные смарт-контракта на сайте


Я написал простой веб-сайт на Python для того чтобы показать данные из смарт-контракта в удобном виде. Тут я не буду подробно останавливаться на нем и опубликую сайт одним коммитом.


Запросы к TON делаются из Python с помощью lite-client. Для удобства сайт пакуется в Docker и публикуется на Digital Ocean. Ссылка на сайт.


Делаем ставки


Теперь попробуем отправить туда 64 грамa для пополнения из кошелька. И сделаем пару ставок для наглядности. Видим, что сайт показывает историю ставок, текущий процент выигрыша и другую полезную информацию.


Послесловие


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


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


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


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


Про будущее TON рассуждать не буду. Возможно платформа станет чем-то большим и нам стоит потратить время на ее изучение и создавать свои решения. А может и нет.


Есть еще Libra от Facebook, у которой потенциальная аудитория пользователей еще больше чем у TON. О Libra я почти ничего не знаю, судя по форуму активности там намного больше чем в сообществе TON. Хотя разработчики и сообщество TON больше похоже на андеграунд движение, что тоже круто.


Ссылки


  1. Официальный сайт: https://ton.org
  2. Официальный репозиторий TON: https://github.com/newton-blockchain/ton
  3. Официальный кошелек для разных платформ: https://ton.org/wallets
  4. Репозиторий смарт-контракта из этой статьи: https://github.com/raiym/astonished
  5. Ссылка на сайт смарт-контракта: https://astonished-d472d.ondigitalocean.app
  6. Репозиторий на расширение для Visual Studio Code для FunC: https://github.com/raiym/func-visual-studio-plugin
  7. Чат про ТON в Telegram, который очень помог разобраться на начальном этапе. Не будет ошибкой, если скажу, что там есть все кто писал что-то для TON. Там же можно попросить тестовых грамов. https://t.me/tondev_ru
  8. Еще один чат про TON в котором я находил полезную информацию: https://t.me/TONgramDev
  9. Первый этап конкурса: https://contest.com/blockchain
  10. Второй этап конкурса: https://contest.com/blockchain-2

7 июля 2020: так как Telegram сегодня объявил, что прекращает поддержку тестовых серверов сети TON, я тоже перестаю поддерживать сайт смарт-контракта. Код доступен на GitHub.


28 января 2022: Статья обновлена и проверена на актуальность.

Теги:
Хабы:
+5
Комментарии 2
Комментарии Комментарии 2

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн