О чем эта статья?
В статье я расскажу о том, как поучаствовал в первом (из двух) конкурсе Telegram по блокчейну, не занял призовое место и решил зафиксировать опыт в статье, чтобы он не канул в Лету и, возможно, помог кому-нибудь.
Так как мне не хотелось писать абстрактный код, а сделать что-то рабочее, для статьи я написал смарт-контракт моментальную лотерею и сайт, который показывает данные смарт-контракта напрямую из TON без использования промежуточных хранилищ.
Статья будет полезна тем, кто хочет сделать свой первый смарт-контракт в TON, но не знает с чего начать.
На примере лотереи я пройду от установки окружения до публикации смарт-контракта, взаимодействия с ним и напишу сайт для получения и публикации данных.
Об участии в конкурсе
В октябре прошлого года Telegram объявил конкурс по блокчейну с новыми языками Fift
и FunC
. Нужно было на выбор написать любые из пяти предложенных смарт-контрактов. Я посчитал, что будет неплохо заняться чем-то необычным, изучить язык и сделать что-нибудь, даже если в будущем не придется писать что-либо еще. Плюс, тема постоянно на слуху.
Стоит сказать, что опыта разработки смарт-контрактов у меня не было.
Я планировал участвовать до самого конца пока получается и после написать обзорную статью, но зафейлился сразу на первом этапе. Я написал кошелек с мульти-подписью на FunC
и он в общем работал. За основу взял смарт-контракт на Solidity.
На тот момент я посчитал, чтобы занять хотя бы какое-то призовое место этого точно достаточно. В итоге около 40 из 60 участников стали призерами и меня среди них не было. В общем, в этом ничего страшного, но меня напрягла одна вещь. На момент объявление результатов ревью с тестом к моему контракту не было сделано, я спросил у участников в чате есть ли кто еще у кого его нет, таких не было.
Видимо, обратив внимание на мои сообщения, через два дня судьи опубликовали комментарий и я так и не понял, они случайно пропустили мой смарт-контракт во время судейства или просто посчитали, что он настолько плох, что не нуждается в комментарии. Я задал вопрос на странице конкурса, но ответа не получил. Хотя кто судил — не секрет, писать личные сообщения посчитал лишним.
Времени на понимание было потрачено не мало, поэтому было решено написать статью. Так как информации пока не много, статья поможет сэкономить время всем заинтересованным.
Концепт работы смарт-контрактов в TON
Прежде чем что-то писать надо разобраться с какой стороны вообще подойти к этой штуке. Поэтому сейчас я расскажу из каких частей система состоит, точнее какие части нужно знать чтобы написать хотя бы какой-то рабочий контракт.
Мы сосредоточимся на написании смарт-контракта и работе с TON Virtual Machine (TVM)
, Fift
и FunC
, поэтому статья больше похожа на описание разработки обычной программы. На том как работает сама платформа тут останавливаться не будем.
О том как работает 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/ton-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://test.ton.org/ton-lite-client-test1.config.json
Делаем первые запросы в TON
Теперь запустим lite-client
.
cd ~/TON/build
./lite-client/lite-client -C ton-lite-client-test1.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()
Разработчик сам выбирает формат сообщения которое контракт может принять.
Но обычно,
- во-первых, мы хотим защитить наш контракт от внешнего мира и сделать так чтобы только владелец контракта мог отправлять на него внешние сообщения.
- во-вторых когда мы отправляем валидное сообщение в TON мы хотим чтобы это произошло ровно один раз и при повторной отправке того же сообщения смарт-контракт отклонил его.
Поэтому почти в каждом контракте решаются эти две проблемы, так как наш контракт принимает внешние сообщения, нам тоже нужно позаботиться об этом.
Сделаем мы в обратно порядке. Сначала решим проблему с повторением, если контракт уже получал такое сообщение и обработал его, то не будет его выполнять второй раз. И потом решим проблему с тем чтобы только определнный круг лиц мог отправлять сообщения смарт-контракту.
Есть разные способы решить проблему с повторяющимися сообщениями. Мы сделаем вот как. В смарт-контракте инициализируем счетчик принятых сообщений с первоначальным значением 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)
загружает число 165, 32-х битное unsigned int
из переданного сообщения.
Дальше мы загружаем 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
Теперь, достаточно запустить наш скрипт, чтобы скомпилировать контракт. Но кроме этого нам надо записать его в константу code
. Поэтому мы создадим новый файл lotter-compiled-for-test.fif
, который и включим в файле lottery-test-suite.fif
.
Добавим в sh скирпт код, который будет просто дублировать скомплированный файл в lotter-compiled-for-test.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
Внутрь пишем
./build.sh
echo "\nCompilation completed\n"
export FIFTPATH=~/TON/ton/crypto/fift/lib
~/TON/build/crypto/fift -s lottery-test-suite.fif
Сделаем test.sh
и запустим, чтобы убедиться в работоспособности тестов.
chmod +x ./test.sh
./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 суммы на аккаунт владельца.
Дальше остается опубликовать смарт-контракт. Создадим папку requests
.
За основу я взял код публикации simple-wallet-code.fc
который можно найти в официальном репозитории.
Из того на что стоит обратить внимание. Мы формируем хранилище смарт-контракта и сообщение на вход. После это генерируется адрес смарт-контракта, то есть адрес известен еще до публикации в TON. Дальше на этот адрес нужно отправить несолько грам и только после этого нужно отправить файл с самим смартконтрактом, так как за хранение смартконтракта и операции в нем сеть берет комиссию (валидаторы, которые хранят и исоплняют смартконтракты). Код можно посмотреть тут.
Дальше мы исполняем код публикации и получаем lottery-query.boc
файл и адрес смартконтракта.
~/TON/build/crypto/fift -s requests/new-lottery.fif 0
Не забываем сохранить сгенерированные файлы: lottery-query.boc
, 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 ton-lite-client-test1.config.json
getaccount 0QAESRAUnb6vjq27KyhyLn1qLcbiZOwvHZvr1vsgkHm8Ksyd
И увидим, что аккаунт с таким адресом пуст.
account state is empty
Отправялем на адрес 0QAESRAUnb6vjq27KyhyLn1qLcbiZOwvHZvr1vsgkHm8Ksyd
2 Gram и через несколько секунд выполняем туже команду. Для отправки грамов я использую официальный кошелек, а тестовые грамы можно попросить у кого-нибудь из чата, о котором я скажу в конце статьи.
> getaccount 0QAESRAUnb6vjq27KyhyLn1qLcbiZOwvHZvr1vsgkHm8Ksyd
Смотрит, что в сети появился неинициализированный (state:account_uninit
) смартконтракт с таким адресом и балансом 1 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
.
Соответствующий коммит с изменениями вот тут.
Теперь создадим запросы для взаимодействия со смарт-контрактом.
Точнее первый для изменения адреса мы оставим в качестве самостоятельной работы, а второй для отправки грамов на адрес владельца сделаем. По факту нам нужно будет сделать тоже самое, что и в тесте на отправку грамов.
Вот такое сообщение мы будем отправлять на смартконтракт, где msg_seqno
165, action
2 и 9.5 грам для отправки.
<b 165 32 u, 2 7 u, 9500000000 Gram, b>
Не забываем подписать сообщение приватным ключом lottery.pk
, который сгенерировался ранее при создании смарт-контракта. Вот соотвествующий комит.
Получаем инормацию из смарт-контракта с помощью гет-методов
Теперь рассмотрим как запускать гет-методы смартконтракта.
Запускаем lite-client
и запускаем гет-методы, которые мы написали.
$ ./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 и публикуется на Google Cloud. Ссылка на сайт.
Пробуем
Теперь попробуем отправить туда грамов для пополнения из кошелька. Отправим 40 грам. И сделаем пару ставок для наглядности. Видим, что сайт показывает историю ставок, текущий процент выигрыша и другую полезную информацию.
Видим, что первую мы выиграли, вторую проиграли.
Послесловие
Статья получилась намного длинне чем я предполагал, может можно было и короче, а может как раз для человека, который ничего не знает о TON и хочет написать и опубликовать не самый простой смарт-контракт с возможностью с ним взаимодействовать. Возможно какие-то вещи можно было объяснить проще.
Возможно некоторые моменты в реализации можно было сделать более эффективно и элегантно, но тогда бы на подготовку статьи ушло еще больше времени. Также возможно, что я где-то ошибся или что-то не понял, поэтому если вы делаете что-то серьезное нужно опираться на официальную документацию или официальный репозиторий с кодом TON в том числе.
Надо заметить, что так как сам TON еще в активной стадии разработки могут произойти изменения, которые сломают какой-либо из шагов в этой статье (что и произошло пока я писал, уже исправил), но общий подход врядли изменится.
Про будущее TON рассуждать не буду. Возможно платформа станет чем-то большим и нам стоит потратить время на ее изучение и занять нишу своими продуктами уже сейчас.
Есть еще Libra от Facebook, у которой потенциальная аудитория пользователей больше чем у TON. О Libra я почти ничего не знаю, судя по форуму активности там намного больше чем в сообществе TON. Хотя разработчики и сообщество TON больше похоже на андеграунд, что тоже круто.
Ссылки
- Официальная документация по TON: https://test.ton.org
- Официальный репозиторий TON: https://github.com/ton-blockchain/ton
- Официальный кошелек для разных платформ: https://wallet.ton.org
- Репозиторий смарт-контракта из этой статьи: https://github.com/raiym/astonished
- Ссылка на сайт смарт-контракта: https://ton-lottery.appspot.com
- Репозиторий на расширение для Visual Studio Code для FunC: https://github.com/raiym/func-visual-studio-plugin
- Чат про ТON в Telegram, который очень помог разобраться на начальном этапе. Не будет ошибкой, если скажу, что там есть все кто писал что-то для TON. Там же можно попросить тестовых грамов. https://t.me/tondev_ru
- Еще один чат про TON в котором я находил полезную информацию: https://t.me/TONgramDev
- Первый этап конкурса: https://contest.com/blockchain
- Второй этап конкурса: https://contest.com/blockchain-2