Работа со смарт-контрактами кошелька
👋 Введение
Прежде чем приступать к разработке смарт-контрактов, важно изучить как работают кошельки и транзакции на TON. Это поможет разработчикам понять принцип взаимодействия между кошельками, сообщениями и смарт-контрактами для выполнения конкретных задач разработки.
Перед чтением руководства рекомендуется ознакомиться со статьей Т ипы контрактов кошелька.
Мы научимся создавать операции без использования предварительно настроенных функций, это полезно для лучшего понимания процесса разработки. Дополнительные ссылки на материалы для изучения находятся в конце каждого раздела.
💡 Перед началом работы
Изучение данного руководства потребует базовых знаний JavaScript и TypeScript или Golang. На балансе кошелька должно быть как минимум 3 TON (это может быть биржевой счет, некастодиальный кошелек или бот Кошелек от Telegram). Также необходимо иметь представление об адресах в TON, ячейке, блокчейне блокчейнов.
Работа с TON Testnet часто приводит к ошибкам при развертывании, сложностям с отслеживанием транзакций и нестабильной работе сети. Большую часть разработки лучше реализовать в TON Mainnet, чтобы избежать проблем, которые могут возникнуть при попытках уменьшить количество транзакций, и понизить сборы, соответственно.
💿 Исходный код
Все примеры кода, используемые в руководстве, можно найти в репозитории GitHub.
✍️ Что нужно для начала работы
- Установленный NodeJS
- Специальные библиотеки TON: @ton/ton 13.5.1+, @ton/core 0.49.2+ и @ton/crypto 3.2.0+
ОПЦИОНАЛЬНО: Если вы предпочитаете использовать Go, а не JS, то для разработки на TON необходи мо установить GoLand IDE и библиотеку tonutils-go. Эта библиотека будет использоваться в данном руководстве для версии Go.
- JavaScript
- Golang
npm i --save @ton/ton @ton/core @ton/crypto
go get github.com/xssnick/tonutils-go
go get github.com/xssnick/tonutils-go/adnl
go get github.com/xssnick/tonutils-go/address
⚙ Настройте свое окружение
Для создания проекта TypeScript выполните следующие шаги:
- Создайте пустую папку (мы назовем ее WalletsTutorial).
- Откройте папку прое кта с помощью CLI.
- Используйте следующие команды для настройки проекта:
npm init -y
npm install typescript @types/node ts-node nodemon --save-dev
npx tsc --init --rootDir src --outDir build \ --esModuleInterop --target es2020 --resolveJsonModule --lib es6 \ --module commonjs --allowJs true --noImplicitAny false --allowSyntheticDefaultImports true --strict false
Процесс ts-node
запускает выполнение кода TypeScript без предварительной компиляции, а nodemon
используется для автоматического перезапуска приложения node при обнаружении изменений файлов в директории.
"files": [
"\\",
"\\"
]
- Затем создайте конфигурацию
nodemon.json
в корне проекта со следующим содержанием:
{
"watch": ["src"],
"ext": ".ts,.js",
"ignore": [],
"exec": "npx ts-node ./src/index.ts"
}
- Добавьте этот скрипт в
package.json
вместо "test", который добавляется при создании проекта:
"start:dev": "npx nodemon"
- Создайте папку
src
в корне проекта и файлindex.ts
в этой папке. - Далее добавьте следующий код:
async function main() {
console.log("Hello, TON!");
}
main().finally(() => console.log("Exiting..."));
- Запустите код в терминале:
npm run start:dev
- В итоге в консоли появится следующий вывод:
TON Community создали отличный инструмент для автоматизации всех процессов разр аботки (развертывание, написание контрактов, тестирование) под названием Blueprint. Однако нам не понадобится такой мощный инструмент, поэтому следует держаться приведенных выше инструкций.
ОПЦИОНАЛЬНО: При использовании Golang выполните следующие шаги:
- Установите GoLand IDE.
- Создайте папку проекта и файл
go.mod
со следующим содержанием (для выполнения этого процесса может потребоваться изменить версию Go, если текущая используемая версия устарела):
module main
go 1.20
- Введите следующую команду в терминал:
go get github.com/xssnick/tonutils-go
- Создайте файл
main.go
в корне проекта со следующим содержанием:
package main
import (
"log"
)
func main() {
log.Println("Hello, TON!")
}
- Измените наименование модуля в файле
go.mod
наmain
. - Запустите код выше до появления вывода в терминале.
Также можно использовать другую IDE, поскольку GoLand не бесплатна, но она предпочтительнее.
В каждом последующем разделе руководства будут указаны только те импорты, которые необходимы для конкретного раздела кода, новые импорты нужно будет добавлять и объединять со старыми.
🚀 Давайте начнем!
В этом разделе мы узнаем, какие кошельки (V3 и V4) чаще всего используются на блокчейне TON, и как работают их смарт-контракты. Это позволит разработчикам лучше понять различные типы сообщений на платформе TON, упростить их создание и отправку в блокчейн, научиться разворачивать кошельки и, в конечном итоге, работать с highload-кошельками.
Наша основная задача – научиться создавать сообщения, используя различные объекты и функции: @ton/ton, @ton/core, @ton/crypto (ExternalMessage, InternalMessage, Signing и т.д.), чтобы понять, как выглядят сообщения в более широких масштабах. Для этого мы будем использовать две основные версии кошелька (V3 и V4), поскольку биржи, некастодиальные кошельки и большинство пользователей используют именно эти версии.
There may be occasions in this tutorial when there is no explanation for particular details. In these cases, more details will be provided in later stages of this tutorial.
ВАЖНО: В данном руководстве используется код кошелька V3. Следует отметить, что версия 3 имеет две ревизии: R1 и R2. В настоящее время используется только вторая ревизия, поэтому, когда мы по тексту ссылаемся на V3, это означает V3R2.
💎 Кошельки TON Blockchain
Все кошельки, работающие на блокчейне TON, являются смарт-контрактами, и все, что работает на TON функционирует как смарт-контракт. Как и в большинстве блокчейнов TON позволяет разворачивать смарт-контракты и модифицировать их для различных целей, предоставляя возможность полной кастомизация кошелька. В TON смарт-контракты кошелька облегчают взаимодействие между платформой и другими типами смарт-контрактов. Однако важно понимать, как происходит данное взаимодействие.
Взаимодействие с кошельком
В блокчейне TON существует два типа сообщений: internal
(внутренние) и external
(внешние). Внешние сообщения позволяют отправлять сообщения в блокчейн из внешнего мира, тем самым обеспечивая связь со смарт-контрактами, которые принимают такие сообщения. Функция, отвечающая за выполнение этого процесса, выглядит следующим образом:
() recv_external(slice in_msg) impure {
;; some code
}
Перед более подробным изучением кошельков давайте рассмотрим, как они принимают внешние сообщения. На TON каждый кошелек хранит public key
, seqno
и subwallet_id
владельца. При получении внешнего сообщения кошелек использует метод get_data()
для извлечения данных из хранилища. Затем проводится несколько процедур верификации и определяется, принимать сообщение или нет. Это происходит следующим образом:
() recv_external(slice in_msg) impure {
var signature = in_msg~load_bits(512); ;; get signature from the message body
var cs = in_msg;
var (subwallet_id, valid_until, msg_seqno) = (cs~load_uint(32), cs~load_uint(32), cs~load_uint(32)); ;; get rest values from the message body
throw_if(35, valid_until <= now()); ;; check the relevance of the message
var ds = get_data().begin_parse(); ;; get data from storage and convert it into a slice to be able to read values
var (stored_seqno, stored_subwallet, public_key) = (ds~load_uint(32), ds~load_uint(32), ds~load_uint(256)); ;; read values from storage
ds.end_parse(); ;; make sure we do not have anything in ds variable
throw_unless(33, msg_seqno == stored_seqno);
throw_unless(34, subwallet_id == stored_subwallet);
throw_unless(35, check_signature(slice_hash(in_msg), signature, public_key));
accept_message();
💡 Полезные ссылки:
Теперь давайте рассмотрим подробнее.
Защита от повторения – Seqno
Защита от повторения сообщений в смарт-контракте кошелька основана на seqno
(Sequence Number) – порядковом номере, который отслеживает порядок отправляемых сообщений. Предотвращение повторения сообщений очень важно, так как дубликаты могут поставить под угрозу целостность системы. Если изучить код смарт-контракта кошелька, то seqno
обычно обрабатывается следующим образом:
throw_unless(33, msg_seqno == stored_seqno);
Код выше сравнивает seqno
, пришедшее во входящем сообщении с seqno
, которое хранится в смарт-контракте. Если значения не совпадают, то контракт возвращает ошибку с кодом завершения 33
. Таким образом, предоставление отправителем недействительного seqno
указывает на ошибку в последовательности сообщений, смарт-контракт предотвращает дальнейшую обработку и гарантирует защиту от таких случаев.
Также важно учитывать, что внешние сообщения могут быть отправлены кем угодно. Это означает, что, если вы отправите кому-то 1 TON, кто-то другой сможет повторить это сообщение. Однако, когда seqno увеличивается, предыдущее внешнее сообщение становится недействительным, а значит никто не сможет его повторить, что предотвращает возможность кражи ваших средств.
Подпись
Как уже упоминалось ранее, смарт-контракты кошелька принимают внешние сообщения. Однако, поскольку эти сообщения приходят из внешнего мира, таким данным нельзя полностью доверять. Поэтому в каждом кошельке хранится публичный ключ владельца. Когда кошелек получает внешнее сообщение, подписанное приватным ключом владельца, смарт-контракт использует публичный ключ для проверки подписи сообщения. Это гарантирует, что сообщение пришло именно от владельца контракта.
Чтобы выполнить эту проверку кошелек сначала извлекает подпись из входящего сообщения, затем загружает публичный ключ из хранилища, и проверяет подпись с помощью следующих процедур:
var signature = in_msg~load_bits(512);
var ds = get_data().begin_parse();
var (stored_seqno, stored_subwallet, public_key) = (ds~load_uint(32), ds~load_uint(32), ds~load_uint(256));
throw_unless(35, check_signature(slice_hash(in_msg), signature, public_key));
Если все процедуры верификации завершены корректно, смарт-контракт принимает сообщение и обрабатывает сообщение:
accept_message();
Поскольку внешние сообщения не содержат Toncoin, необходимых для оплаты комиссии за транзакцию, функция accept_message()
применяет параметр gas_credit
(в настоящее время его значение составляет 10 000 единиц газа). Это позволяет контракту производить необходимые расчеты бесплатно, если газ не превышает значение gas_credit
. После вызова функции accept_message()
смарт-контракт вычитает все затраты на газ (в TON) из своего баланса. Подробнее об этом процессе можно прочитать здесь.
Срок действия транзакции
Еще одним шагом, используемым для проверки действительности внешних сообщений, является поле valid_until
. Как видно из наименования переменной, это время в UNIX до которого сообщение будет действительным. Если процесс проверки завершился неудачей, контракт завершает обработку транзакции и возвращает код завершения 35
:
var (subwallet_id, valid_until, msg_seqno) = (cs~load_uint(32), cs~load_uint(32), cs~load_uint(32));
throw_if(35, valid_until <= now());
Этот алгоритм защищает от потенциальных ошибок, например, когда сообщение уже недействительно, но по-прежнему отправляется в блокчейн по неизвестной причине.
Различия кошелька V3 и V4
Ключевое различие между V3 и V4 кошелька заключается в поддержке кошельком V4 плагинов
, пользователи могут устанавливать или удалять данные плагины, которые представляют собой специализированные смарт-контракты, способные запрашивать в назначенное время оп ределенное количество TON из смарт-контракта кошелька.
Смарт-контракты кошелька, в свою очередь, на запросы плагинов автоматически отправляют в ответ нужное количество TON без необходимости участия владельца. Эта функция отражает модель подписки, которая является основным назначением плагинов. Мы не будем углубляться в детали далее, поскольку это выходит за рамки данного руководства.
Как кошельки облегчают взаимодействие со смарт-контрактами
Как мы уже говорили, смарт-контракт кошелька принимает внешние сообщения, проверяет их и обрабатывает, если все проверки пройдены. Затем контракт запускает цикл извлечения сообщений из тела внешнего сообщения, после чего создает внутренние сообщения и отправляет их в блокчейн:
cs~touch();
while (cs.slice_refs()) {
var mode = cs~load_uint(8); ;; load message mode
send_raw_message(cs~load_ref(), mode); ;; get each new internal message as a cell with the help of load_ref() and send it
}
touch()
На TON все смарт-контракты выполняются в виртуальной машине TON (TVM), основанной на стековой процессорной архитектуре. ~ touch()
помещает переменную cs
на вершину стека, чтобы оптимизировать выполнение кода для меньшего расхода газа.
Поскольку в одной ячейке может храниться максимум 4 ссылки, то на одно внешнее сообщение можно отправить максимум 4 внутренних сообщения.
💡 Полезные ссылки:
📬 Внешние и внутренние сообщения
В этом разделе мы узнаем чуть больше о внутренних internal
и внешних external
сообщениях. Мы создадим и отправим сообщения в сеть, при этом постараемся свести к минимуму зависимость от заранее созданных функций.
Для упрощения задачи воспользуемся готовым кошельком. Для этого:
- Установите приложение кошелька (например, автор использует Tonkeeper).
- В настройках приложения кошелька перейдите на версию V3R2.
- Внесите 1 TON на кошелек.
- Отправьте сообщение на другой адрес (можно отправить его себе на этот же кошелек).
В результате приложение Tonkeeper развернет смарт-контракт кошелька, который мы сможем использовать дл я следующих шагов.
На момент написания руководства большинство приложений-кошельков на TON по умолчанию используют кошелек V4. В этом разделе плагины не требуются, а значит будет достаточно функциональности кошелька V3. Tonkeeper позволяет пользователям выбрать нужную им версию кошелька, поэтому рекомендуется развернуть кошелек V3.
TL-B
Как упоминалось ранее, все в блокчейне TON – это смарт-контракт, состоящий из ячеек. Чтобы правильно сериализовать и десериализовать данные, нам нужны стандарты. Для этой цели был разработан TL-B
– универсальный инструмент для описания различных типов данных, структур и последовательностей внутри ячеек.
В этом разделе мы будем работать со схемой данных block.tlb. Этот файл будет незаменим при дальнейшей разработке, поскольку в нем описывается, как собираются различные типы ячеек. В нашем конкретном случае там представлена подробная информация о структуре и поведении внутренних и внешних сообщений.
В данном руководстве представлена общая информация. Для получения более подробной информации изучите документацию по TL-B.
CommonMsgInfo
Изначально каждое сообщение должно хранить CommonMsgInfo
(TL-B) или CommonMsgInfoRelaxed
(TL-B). Эти данные позволяют определить технические детали, относящиеся к типу и времени сообщения, адресу получателя, техническим флагам и сборам.
Читая файл block.tlb
, можно заметить три типа CommonMsgInfo: int_msg_info$0
, ext_in_msg_info$10
, ext_out_msg_info$11
. Если не углубляться в детали, то конструктор ext_out_msg_info
– это внешнее сообщение, которое может отправляться смарт-контрактом в качестве внешнего лога. Как пример подобного формата изучите смарт-контракт Избирателя.
В схеме TL-B указано, что при использовании типа ext_in_msg_info доступен только CommonMsgInfo. Это происходит потому, что такие поля сообщения, как src
, created_lt
, created_at
и другие, перезаписываются валидаторами во время обработки транзакций. В данном случае поле src
наиболее важно, поскольку при отправке сообщения адрес отправителя неизвестен, и валидаторы заполняют данное поле во время верификации. Это гарантирует, что адрес src
достоверен и не может быть изменен.
Структура CommonMsgInfo
поддерживает спецификацию MsgAddress
, но так как адрес отправителя обычно неизвестен, то записывается addr_none$00
(00
– два нулевых бита), и в этом случае используется структура CommonMsgInfoRelaxed
, которая поддерживает addr_none
. Для ext_in_msg_info
(входящие внешние сообщения) используется структура CommonMsgInfo
, поскольку эти типы сообщений используют данные не отправителя, а MsgAddressExt, что означает отсутствие необходимости перезаписывать данные.
Числа после символа $
– это биты, которые необходимо хранить в начале определенной структуры, для дальнейшей идентификации этих структур при чтении (десериализации).
Создание внутреннего сообщения
Внутренние сообщения используются для передачи сообщений между смарт-контрактами. При анализе контрактов, которые отправляют сообщения, включающие написание контрактов (таких как NFT и Jetons), часто используются следующие строки кода:
var msg = begin_cell()
.store_uint(0x18, 6) ;; or 0x10 for non-bounce
.store_slice(to_address)
.store_coins(amount)
.store_uint(0, 1 + 4 + 4 + 64 + 32 + 1 + 1) ;; default message headers (see sending messages page)
;; store something as a body
Рассмотрим 0x18
и 0x10
, которые представляют собой шестнадцатеричные числа, расположенные следующим образом (с учетом, что нужно выделить 6 бит): 011000
и 010000
. Это означает, что приведенный выше код можно переписать следующим образом:
var msg = begin_cell()
.store_uint(0, 1) ;; this bit indicates that we send an internal message according to int_msg_info$0
.store_uint(1, 1) ;; IHR Disabled
.store_uint(1, 1) ;; or .store_uint(0, 1) for 0x10 | bounce
.store_uint(0, 1) ;; bounced
.store_uint(0, 2) ;; src -> two zero bits for addr_none
.store_slice(to_address)
.store_coins(amount)
.store_uint(0, 1 + 4 + 4 + 64 + 32 + 1 + 1) ;; default message headers (see sending messages page)
;; store something as a body
Давайте рассмотрим подробно каждый параметр:
Параметр | Описание |
---|---|
IHR Disabled | В настоящее время отключена (это означает, что мы храним значение 1 ), поскольку Мгновенная Маршрутизация Гиперкуба (Instant Hypercube Routing) реализована не полностью. Опция будет необходима, когда в сети появится большое количество шардчейнов. Подробнее об IHR Disabled можно прочитать в tblkch.pdf (глава 2) |
Bounce | При отправке сообщений в процессе обработки смарт-контракта могут возникать различные ошибки. Чтобы избежать пот ери TON, необходимо установить Bounce значение 1 (true). В этом случае, если во время обработки транзакции возникнут ошибки в контракте, то сообщение и отправленное количество TON (за вычетом комиссии) будут возвращены отправителю. Подробнее о невозвращаемых сообщениях можно прочитать здесь |
Bounced | Возвращаемые сообщения (Bounced) – это сообщения, которые возвращаются отправителю из-за ошибки, возникшей при обработке транзакции с помощью смарт-контракта. Этот параметр сообщает о том, является ли полученное сообщение вернувшимся |
Src | Адрес отправителя. В этом случае записываются два нулевых бита, чтобы указать адрес addr_none |
Следующие две строки кода:
...
.store_slice(to_address)
.store_coins(amount)
...
- указываем получателя и количество TON для отправки.
Посмотрим на оставшиеся строки кода:
...
.store_uint(0, 1) ;; Extra currency
.store_uint(0, 4) ;; IHR fee
.store_uint(0, 4) ;; Forwarding fee
.store_uint(0, 64) ;; Logical time of creation
.store_uint(0, 32) ;; UNIX time of creation
.store_uint(0, 1) ;; State Init
.store_uint(0, 1) ;; Message body
;; store something as a body
Параметр | Описание |
---|---|
Extra currency | Дополнительная валюта. Это первичная реализация существующих жетонов, и в настоящее время она не используется |
IHR fee | Комиссия IHR. Как уже говорилось, IHR в настоящее время не используется, поэтому данная комиссия всегда равна нулю. Подробнее об этом можно прочитать в tblkch.pdf (3.1.8) |
Forwarding fee | Комиссия за пересылку сообщения. Подробнее об этом можно прочитать здесь |
Logical time of creation | Время, затраченное на создание корректной очереди сообщений |
UNIX time of creation | Время создания сообщения в UNIX |
State Init | Код и исходные данные для развертывания смарт-контракта. Если бит установлен в 0 , это означает, что у нас нет State Init. Но если он установлен в 1 , то необходимо записать еще один бит, который будет указывать, хранится ли State Init в той же ячейке (0 ) или записан как ссылка (1 ) |
Message body | Тело сообщения. Параметр определяет, как хранится тело сообщения. Иногда тело сообщения слишком велико, чтобы поместиться в само сообщение. В этом случае его следует хранить как ссылку, при этом бит устанавливается в 1 , чтобы показать, что тело сообщения используется в качестве ссылк и. Если бит равен 0 , тело находится в той же ячейке, что и сообщение |
Значения, описанные выше (включая src
), за исключением битов State Init
и Message Body
, перезаписываются валидаторами.
Если значение числа умещается в меньшее количество бит, чем установлено, то недостающие нули добавляются к левой части значения. Например, 0x18 умещается в 5 бит -> 11000
. Однако, как было сказано выше, требуется 6 бит, тогда конечный результат принимается 011000
.
Приступим к формированию сообщения, которое будет отправлено с некоторым количеством Toncoin на другой кошелек V3. Для начала, допустим, пользователь хочет отправить 0.5 TON самому себе с текстом Hello, TON!. Чтобы узнать как отправить сообщение с комментарием обратитесь к этому разделу документации.
- JavaScript
- Golang
import { beginCell } from '@ton/core';
let internalMessageBody = beginCell()
.storeUint(0, 32) // write 32 zero bits to indicate that a text comment will follow
.storeStringTail("Hello, TON!") // write our text comment
.endCell();
import (
"github.com/xssnick/tonutils-go/tvm/cell"
)
internalMessageBody := cell.BeginCell().
MustStoreUInt(0, 32). // write 32 zero bits to indicate that a text comment will follow
MustStoreStringSnake("Hello, TON!"). // write our text comment
EndCell()
Выше мы создали ячейку InternalMessageBody
, в которой хранится тело нашего сообщения. Обратите внимание, что при хранении текста, который не помещается в одну ячейку (1023 бита), необходимо разбить данные на несколько ячеек в соответствии со следующей документацией. Однако, в нашем случае высокоуровневые библиотеки создают ячейки в соответствии с требованиями, поэтому на данном этапе об этом не стоит беспокоиться.
Затем, в соответствии с ранее изученной информацией, создается InternalMessage
:
- JavaScript
- Golang
import { toNano, Address } from '@ton/ton';
const walletAddress = Address.parse('put your wallet address');
let internalMessage = beginCell()
.storeUint(0, 1) // indicate that it is an internal message -> int_msg_info$0
.storeBit(1) // IHR Disabled
.storeBit(1) // bounce
.storeBit(0) // bounced
.storeUint(0, 2) // src -> addr_none
.storeAddress(walletAddress)
.storeCoins(toNano("0.2")) // amount
.storeBit(0) // Extra currency
.storeCoins(0) // IHR Fee
.storeCoins(0) // Forwarding Fee
.storeUint(0, 64) // Logical time of creation
.storeUint(0, 32) // UNIX time of creation
.storeBit(0) // No State Init
.storeBit(1) // We store Message Body as a reference
.storeRef(internalMessageBody) // Store Message Body as a reference
.endCell();
import (
"github.com/xssnick/tonutils-go/address"
"github.com/xssnick/tonutils-go/tlb"
)
walletAddress := address.MustParseAddr("put your address")
internalMessage := cell.BeginCell().
MustStoreUInt(0, 1). // indicate that it is an internal message -> int_msg_info$0
MustStoreBoolBit(true). // IHR Disabled
MustStoreBoolBit(true). // bounce
MustStoreBoolBit(false). // bounced
MustStoreUInt(0, 2). // src -> addr_none
MustStoreAddr(walletAddress).
MustStoreCoins(tlb.MustFromTON("0.2").NanoTON().Uint64()). // amount
MustStoreBoolBit(false). // Extra currency
MustStoreCoins(0). // IHR Fee
MustStoreCoins(0). // Forwarding Fee
MustStoreUInt(0, 64). // Logical time of creation
MustStoreUInt(0, 32). // UNIX time of creation
MustStoreBoolBit(false). // No State Init
MustStoreBoolBit(true). // We store Message Body as a reference
MustStoreRef(internalMessageBody). // Store Message Body as a reference
EndCell()