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

TON: рекомендации и лучшие практики

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

Эта статья является переводом документа, опубликованного на странице блокчейна TON: smc-guidelines.txt. Возможно кому-то это поможет сделать шаг в сторону разработки для этого блокчейна. Также, в конце я сделал краткое резюме.


Внутренние сообщения


Cмарт-контракты взаимодействуют друг с другом посредством отправления так называемых внутренних сообщений ("internal messages"). Когда внутреннее сообщение достигает его указанного назначения, создается обычная транзакция на имя аккаунта назначения, и внутреннее сообщение обрабатывается согласно указанному коду и постоянных данных этого аккаунта (смарт контракта). В частности, транзакция обработки может создавать одно или более внутренних сообщений, некоторые из которых могут быть адресованы исходному адресу обрабатываемого внутреннего сообщения. Это может быть использовано для создания простых "клиент-серверных приложений", когда запрос встраивается (инкапсулируется) во внутреннее сообщение и отправляется другому смарт-контракту, который обрабатывает запрос и отправляет ответ назад, снова как внутреннее сообщение.


Этот подход приводит к необходимости различать внутренние сообщения на "запрос" и "ответ" (as a "query" or as a "response"), или не требующие какой-либо дополнительной обработки (такие как простой перевод денег). Кроме того, когда приходит ответ, должен быть способ понять к какому запросу он относится.


Чтобы достичь этой цели, рекомендуется использовать следующий шаблон внутренних сообщений (при этом помните, что блокчейн TON не навязывает никаких ограничений на тело сообщения, то есть это просто рекомендации):


0) Тело сообщения может быть внедрено в само сообщение, или может храниться в отдельной ячейке (cell*), на которую есть ссылка в сообщении, как указано во фрагменте TL-B схемы (на английском проще для понимания: or be stored in a separate cell referred to from the message, as indicated by the TL-B scheme fragment):


message$_ {X:Type} ... body:(Either X ^X) = Message X;

(https://core.telegram.org/mtproto — здесь можно почитать о TL-схемах)


Принимающий смарт-контракт должен принимать по крайней мере внутренние сообщения со встроенным телом сообщения (даже если они помещены в ячейку, содержащую сообщение — whenever they fit into the cell containing the message — не очень понятно, что это значит, поэтому прикрепил оригинал текста). Если контракт принимает тела сообщений в отдельных ячейках (используя "right" конструктор (Either X ^X)), обработка входящего сообщения не должна зависеть от конкретного способа внедрения тела сообщения. С другой стороны, совершенно законно (valid) вообще не поддерживать тело сообщения в отдельной ячейке для упрощения запросов и ответов.


1) Тело сообщения обычно начинается со следующих полей:


  • op — 32-bit (big-endian) unsigned integer, идентифицирующее операцию для исполнения, или метод смарт-контракта для вызова.
  • query_id — 64-bit (big-endian) unsigned integer, используемое во всех внутренних сообщениях типа вопрос-ответ, чтобы идентифицировать связь ответа с запросом (query_id ответа должен равняться query_id соответствующего запроса). Если op — не метод типа "запрос-ответ" (он вызывает метод, от которого не ожидается ответ), то query_id может быть упущен.
  • оставшаяся часть тела сообщения специфична для каждого поддерживаемого значения параметра op

2) Если op равен нулю, то сообщение — это простое трансферное сообщение с комментарием. Комментарий содержится в оставшейся части сообщения (без query_id и прочего, то есть начиная с 5-ого байта (пояснение: если query_id нет, то первые 4 байта занимает поле op)). Если он не начинается с байта 0xff, то комментарий — это простой текст (If it does not begin with the byte 0xff, the comment is a text one;); он может быть отображен конечному пользователю кошелька "как есть" (после фильтрации невалидных символов и символов управления (invalid and control characters) и проверки что это корректная UTF-8 строка). Например, пользователи могут указать цель простого перевода с их кошелька на кошелек другого пользователя в этом поле. С другой стороны, если комментарий начинается с байта 0xff, остаток сообщения — это "бинарный комментарий", который не должен отображаться конечному пользователю как текст (только как hex dump если необходимо). Предлагаемое использование бинарных комментариев, например, — содержать идентификатор платежа для оплаты в магазине, и быть автоматически сгенерированным и обработанным программным обеспечением магазина.


Большинство смарт-контрактов не должны выполнять нетривиальные действия или отклонять входящее сообщение при получении "простого сообщения о передаче". Таким образом, когда op оказывается нулем, функция смарт-контракта для обработки входящих внутренних сообщений (обычно называемая recv_internal()) должна немедленно завершаться с кодом 0, обозначая успешное выполнение (например, выбрасывая исключение 0, если в смарт-контракте не установлен пользовательский обработчик исключений). Это приведет к тому, что на счет получателя будет зачислена сумма, переданная сообщением, без какого-либо дальнейшего эффекта.


3) "Простое сообщение передачи без комментариев" имеет пустое тело (даже без поля op). Приведенные выше соображения применимы и к таким сообщениям. Обратите внимание, что такие сообщения должны иметь своё тело, встроенное в ячейку сообщения.


4) Мы ожидаем, что поле op сообщений-запросов будет иметь первый бит ("high-order bit", перевел как первый, это может быть некорректно, но по объяснению дальше становится понятно) пустым, то есть значение поля должно быть в диапазоне 1 .. 2^31-1, а у сообщений ответов первый (high-order) бит должен быть равен 1, то есть значение поля в диапазоне 2^31 .. 2^32-1. Если сообщение не является ни запросом, ни ответом (тело не содержит параметра query_id), то оно должно содержать параметр op в диапазоне, как у сообщения-запроса: 1 .. 2^31 - 1.


5) Существует несколько "стандартных" сообщений-ответов, у которых op равен 0xffffffff и 0xffffffffe. В общем случае значения op от 0xfffffff0 до 0xffffffff зарезервированы для таких стандартных ответов.


  • op = 0xffffffff означает "операция не поддерживается". За ним следует 64-разрядный query_id, извлеченный из исходного запроса, и 32-разрядный op исходного запроса. Все, кроме самых простых смарт-контрактов, должны возвращать эту ошибку, когда они получают запрос с неизвестным op в диапазоне 1… 2^31-1.
  • op = 0xfffffffe означает "операция не разрешена". За ним следует 64-разрядный query_id исходного запроса, а затем 32-разрядный op, извлеченный из исходного запроса.

Обратите внимание, что неизвестные "ответы" (с op в диапазоне 2^31… 2^32-1) следует игнорировать (в частности, в ответ на них не следует генерировать ответ с op равным 0xffffffff), так же как и неожиданные возвратные(bounced)-сообщения (с установленным флагом "bounced").


Оплата за обработку запросов и отправление ответов


В общем, если смарт-контракт хочет отправить запрос другому смарт-контракту, он должен заплатить за отправку внутреннего сообщения в целевой смарт-контракт (плата за пересылку сообщений: message forwarding fees), за обработку этого сообщения в пункте назначения (плата за газ: gas fees) и за отправку ответа, если это требуется (плата за пересылку сообщений: message forwarding fees).


В большинстве случаев отправитель прикрепит к внутреннему сообщению небольшое количество gram (например, 1 gram) (достаточное для оплаты обработки этого сообщения) и установит на нем флаг "bounce" (т. е. отправит возвращаемое (bounceable) внутреннее сообщение); получатель вернет неиспользованную часть полученного значения с ответом (вычитая из него плату за пересылку сообщения). Это обычно достигается путем вызова SENDRAWMSG с mode=64 (ср. Приложение а к документации TON VM).


Если получатель не может обработать полученное сообщение и выполнение завершается с ненулевым кодом выхода (например, из-за необработанного исключения десериализации ячейки), сообщение будет автоматически "возвращено" ("bounced") обратно отправителю, при этом флаг "bounce" будет снят и установлен флаг "bounced". Тело возвращенного (bounced) сообщения будет таким же, как и у исходного сообщения; поэтому важно проверить флаг "bounced" входящего внутреннего сообщения перед разбором поля op в смарт-контракте и обработкой соответствующего запроса (в противном случае существует риск того, что запрос, содержащийся в отскочившем сообщении, будет обработан его исходным отправителем как новый отдельный запрос). Если флаг "bounced" установлен, специальный код может понять, какой запрос не удался (например, путем десериализации op и query_id из отскочившего сообщения) и предпринять соответствующие действия. Более простой смарт-контракт может просто игнорировать все возвращенные сообщения (завершить с нулевым кодом выхода, если установлен флаг "bounced").


С другой стороны, получатель может успешно проанализировать входящий запрос и обнаружить, что запрошенный метод op не поддерживается или что выполнено другое условие ошибки. Тогда ответ с op равным 0xffffffff или другим соответствующим значением должен быть отправлен обратно, используя SENDRAWMSG с mode=64, как упоминалось выше.


В некоторых ситуациях отправитель хочет одновременно и передать некоторое количество денег? отправителю? (тут видимо, ошибка, и имелось в виду "получателю") и получить либо подтверждение, либо сообщение об ошибке. Например, смарт-контракт "валидатор выборов" (validator elections) получает запрос на участие в выборах вместе со ставкой в качестве присоединенной стоимости. В таких случаях имеет смысл приложить, скажем, один лишний грамм к предполагаемому значению [стоимости] (Здесь везде используется слово value, в значении оплаты за какое-то действие, поэтому я использовал слово "стоимость"). Если произошла ошибка (например, ставка не может быть принята по какой-либо причине), полная полученная сумма (за вычетом платы за обработку) должна быть возвращена отправителю вместе с сообщением об ошибке (например, с помощью SENDRAWMSG с mode=64, как описано выше). В случае успеха создается подтверждающее сообщение и ровно один грам отправляется обратно (при этом плата за передачу сообщения вычитается из этого значения; это mode=1 of SENDRAWMSG).


Использование невозвратных (non-bounceable) сообщений


Почти все внутренние сообщения, отправленные между смарт-контрактами, должны быть возвращаемыми (можно перевести как "отскакивающими", но чтобы не запутаться, легче использовать такую терминологию), т. е. должны иметь бит "bounce" непустым. Тогда, если целевой смарт-контракт не существует, или если он создает необработанное исключение при обработке этого сообщения, сообщение будет "возвращено" обратно, неся остаток исходной стоимости (value) (за вычетом всех сборов за передачу сообщений и газа). Возвращенное сообщение будет иметь то же самое тело, но с очищенным флагом "bounce" и установленным флагом "bounced". Поэтому все смарт-контракты должны проверять флаг "bounced" всех входящих сообщений и либо молча принимать их (немедленно завершая с нулевым кодом выхода), либо выполнять некоторую специальную обработку, чтобы определить, какой исходящий запрос не удался. Запрос, содержащийся в теле возвращенного сообщения, никогда не должен выполняться.


В некоторых случаях необходимо использовать невозвратные (non-bounceable) внутренние сообщения. Например, новый аккаунт не могут быть созданы без по крайней мере одного невозвратного внутреннего сообщения, отправленного ему. Если это сообщение не содержит StateInit с кодом и данными нового смарт-контракта, не имеет смысла иметь непустое тело в невозвратном внутреннем сообщении.


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


Внешние сообщения


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


Обратите внимание, что все внешние сообщения, должны быть защищены от атак повтора (replay attacks). Валидаторы обычно удаляют внешнее сообщение из пула предлагаемых внешних сообщений (полученных из сети); однако в некоторых ситуациях другой валидатор может обработать одно и то же внешнее сообщение дважды (таким образом, создается вторая транзакция для одного и того же внешнего сообщения, что приводит к дублированию исходного действия). Что еще хуже, злоумышленник может извлечь внешнее сообщение из блока, содержащего транзакцию обработки, и повторно отправить его позже. Это может заставить, например, смарт-контракт кошелька повторить платеж.


Самым простым способом защиты смарт-контрактов от атак повтора, связанных с внешними сообщениями, является хранение 32-битного счетчика cur-seqno в постоянных данных смарт-контракта и ожидание значения req-seqno в (подписанной части) любых входящих внешних сообщений. Тогда внешнее сообщение принимается (ACCEPTed — намек на примитив ACCEPT) только в случае, если и подпись действительна, и req-seqno равно cur-seqno. После успешной обработки значение cur-seqno в постоянных данных увеличивается на единицу, поэтому одно и то же внешнее сообщение больше никогда не будет принято.


Можно также включить поле expire-at во внешнее сообщение и принимать сообщение только в том случае, если текущее Unix-время меньше значения этого поля. Этот подход может использоваться в сочетании с seqno; в качестве альтернативы, принимающий смарт-контракт может хранить набор (хэши) всех последних (не истекших) принятых внешних сообщений в своих постоянных данных и отклонять новое внешнее сообщение, если оно является дубликатом одного из сохраненных сообщений. Также следует реализовать сбор и удаление просроченных сообщений в этом наборе, чтобы избежать неограниченного роста постоянных данных.


Как правило, внешнее сообщение начинается с 256-битной подписи (при необходимости), 32-битного req-seqno (при необходимости), 32-битного expire-at (при необходимости) и, возможно, 32-битного op и других необходимых параметров в зависимости от op. Шаблон внешних сообщений не должен быть таким же стандартизированным, как шаблон внутренних, поскольку внешние сообщения не используются для взаимодействия между различными смарт-контрактами (написанными разными разработчиками и управляемыми разными владельцами).


Get-методы


Ожидается, что некоторые смарт-контракты реализуют определенные четко определенные get-методы. Например, любой смарт-контракт dns-резолвера для TON DNS, как ожидается, реализует get-метод "dnsresolve". Пользовательские смарт-контракты могут определять свои конкретные get-методы. Наша единственная общая рекомендация на данный момент — реализовать get-метод "seqno" (без параметров), который возвращает текущий seqno смарт-контракта, который использует порядковые номера для предотвращения атак воспроизведения, связанных с входящими внешними методами, всякий раз, когда такой метод имеет смысл.


Словарь:


  • Cell (ячейка) — A TVM cell consists of at most 1023 bits of data, and of at most four references to other cells. All persistent data (including TVM code) in the TON Blockchain is represented as a collection of TVM cells (cf. [1, 2.5.14]). — ячейка TVM состоит не более чем из 1023 бит данных и не более чем из четырех ссылок на другие ячейки. Все постоянные данные (включая код TVM) в блокчейне TON представлены в виде набора ячеек TVM (ср. [1, 2.5.14]). — выдержка из TON Virtual Machine description (https://test.ton.org/tvm.pdf)

Какие можно сделать выводы на основе прочитанного?


  1. Можно отправлять внешние сообщения контрактам, чтобы вызвать какое-либо действие.
  2. Атаки — есть, например, атаки повтора
  3. Стоит делать метод seqno для защиты от атак повтора
  4. У dns-резолверов — метод dnsresolve
  5. Можно хранить хэши внешних сообщений для защиты от атак, но их нужно вовремя удалять, для этого стоит использовать поле expired_at у внешних сообщений
  6. Невозвратные сообщения нужны только для создания контрактов, в остальном — все внутренние сообщения возвратные
  7. Сообщения типа запрос-ответ должны содержать поля: op, query_id — необязательное, и еще некоторые зависящие от значения op
  8. К сообщению можно прикреплять текстовые комментарии в формате UTF-8 для людей и “бинарные комментарии” для автоматического чтения и обработки сторонним ПО
  9. Стоит обрабатывать исключения и делать это грамотно
  10. "Простое сообщение передачи без комментариев" — должно иметь пустое тело
  11. High-order bit сообщений типа запрос-ответ принимает значение 0 для сообщений-запросов, и значение 1 для сообщений-ответов
  12. Есть стандартные значения op для сообщений ответов для идентификации ошибок
  13. Если пришло сообщение-ответ с неизвестным op, его стоит проигнорировать, то есть завершить исполнение с кодом 0
  14. Платить надо за пересылку сообщений, за gas и за пересылку ответа. При этом, если отправил больше, чем нужно — лишнее вернется в ответе.
  15. При поступлении сообщений всегда сперва стоит проверять флаг bounced

Спасибо за внимание, буду рад конструктивному фидбэку!

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

Публикации

Истории

Работа

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