Barrigaz Blog
Все статьи

Как перестать бояться и начать кодить в TON - в сравнении С EVM-сетями (Ethereum и т.д.)

Даниль Вахидов·
· 11 мин
Как перестать бояться и начать кодить в TON - в сравнении С EVM-сетями (Ethereum и т.д.)

У меня за плечами внушительный опыт разработки в EVM-совместимых сетях (Ethereum, Polygon и др.). Я создавал структурные продукты: пулы ликвидности, лендинг-протоколы, системы лимитных ордеров, арбитражных ботов и различные утилиты.

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

Написав несколько контрактов, я решил помочь тем, кто уже умеет в Solidity, но хочет быстро разобраться в архитектуре TON. Я не буду погружаться в синтаксис языков Func или Tact/Tolk, а затрону только фундаментальные отличия.

Навигация

Что такое TON

  1. В сети TON любой активный адрес является контрактом (даже обычный кошелек). Именно поэтому существуют различные версии кошельков, т.к. фактически это разные виды/версии контрактов кошельков (v4, v5)
  2. транзакция - это обработка только одного входящего сообщения
  3. что мы в EVM привыкли называть транзакцией (несколько взаимодействий между контрактами) - называется цепочка транзакций (trace)

Взаимодействие между контрактами: забудьте про атомарность

Самое важное и глобальное отличие - принцип взаимодействия.

EVM: чтобы отправить транзакцию, мы вызываем метод контракта. Когда наша транзакция принимается и включается в сеть - выполнение становится эксклюзивным: “весь мир принадлежит мне”. Сеть замирает, состояние всех контрактов зафиксировано. Я могу динамически “спросить” у любого другого контракта его баланс или переменную, получить ответ в той же транзакции и продолжить логику. Если в конце что-то пошло не так, я делаю revert, и вся цепочка вызовов откатывается, будто ничего и не было.

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

Это и есть асинхронность. То, что в EVM было одной монолитной транзакцией, в TON превращается в “цепочку” (trace) транзакций, между которыми могут вклиниться действия других пользователей. Сеть гарантирует доставку сообщения (при достачном value для исполнения), но мы должны проектировать логику так, чтобы контракт умел ждать ответа и обрабатывать промежуточные состояния. В TON нельзя “спросить и сразу получить ответ”. Можно только “отправить письмо и когда-нибудь получить ответ”.

Работа с газом и value транзакции

Комиссии (fees) здесь делятся на три части:

  • Storage fee - плата за место в блокчейне. В TON контракт платит за каждый байт данных в каждом блоке (стоимость есть в каждом блоке, начисляется со временем). Если баланс контракта станет нулевым из-за списания этой комиссии, контракт может быть удален/умереть (uninitialized) включая все данные/переменные, что в нем были.
  • Computation fee - аналог газа в EVM за непосредственное исполнение кода.
  • Forward fee - плата за доставку сообщения от одного контракта к другому.

EVM вы просто указываете gas_limit на всю транзакцию.

В TON: Вам нужно передать достаточно монет (value) в первом сообщении, чтобы их хватило на всю цепочку (пересылка + вычисления + хранение на каждом контракте). Если вы отправили 1 TON, а цепочка из 5 контрактов съела его на 3-м шаге, транзакция просто прервется на середине, и ручной откат может не сработать (не хватит газа на сам откат).

EVM msg.value используется только для передачи нативной валюты (ETH). Газ отправляется и считается отдельно. И платит всегда тот, кто подписал транзакцию (EOA — внешний кошелек).

TON вы используете только value, прикрепляя к транзакции какое-то количество - это и есть “топливо” для всех последующих контрактов в цепочке. Если не прикрепить достаточно value, следующее сообщение просто не дойдет до адресата или не сможет обработаться. И за каждое последующее сообщение в цепочке платит тот контракт, который отправляет текущее сообщение. Обычно это работает по принципу эстафеты: пользователь дает на первом вызове 1 TON, контракт забирает свою комиссию (на исполнение, возможно какие-то доп. TON) и пересылает остаток (например, 0.9 TON) следующему контракту. Тот делает свою работу/снимает свои комиссии и отправляет остаток дальше. Конечный “лишний” газ обычно возвращается пользователю последним сообщением.

Если же в середине цепочки на контракте закончились монеты для оплаты газа:

  1. Текущее сообщение не обработается.
  2. Bounced-сообщение (откат транзакции) не отправится, потому что на него тоже нужен газ.
  3. Цепочка “зависает” в неопределенном состоянии. Средства могут остаться заблокированными на промежуточных контрактах.

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

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

Хранение данных

EVM мы привыкли к mapping(address => uint256) и тому, что данные в контракте могут храниться вечно, пока жив сам контракт.

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

Из-за Storage fee хранить огромные маппинги на одном контракте - роскошь (и технически труднореализуемо). Поэтому в TON используется архитектура мастер-контракт и дочерние контракты. Вместо одного смарт-контракта с маппингом, например, всех балансов, мы имеем:

  • Один мастер-контракт (с мета-данными).
  • Тысячи маленьких контрактов “кошельков” для каждого пользователя.

Баланс пользователя хранится на его личном мини-контракте этого токена. Это дешево, масштабируемо и перекладывает расходы на хранение данных с протокола на конкретного пользователя. В то же время такая схема накладывает специфику работы логики самого контракта, где нам нужно предусматривать как позитивные, так и негативные варианты ответа “кошельков/стейтов” пользователя, реализуя callback-и и передачу сообщений между контрактами, учитывая что все они асинхронны.

Логи: их здесь нет (в привычном виде)

В Ethereum (и других EVM сетях) events - это основа фронтенда и аналитики. Контракт делает событие, и любой индексатор может его прочесть в любой момент времени.

В TON событийного лога как отдельной структуры данных внутри блока, которую можно дешево читать извне “по подписке”, не существует. Точнее, технически в транзакции есть поле для внешних сообщений (external outbound messages), которые можно интерпретировать как события, но работа с ними сильно отличается.

Если в EVM логи - это побочный продукт транзакции, то в TON для того, чтобы “сообщить миру” о чем-то, контракт отправляет External Outbound Message. Индексаторы (вроде Tonapi или Toncenter или самописный индексатор) парсят эти сообщения. Будьте готовы к тому, что привычного eth_getLogs тут нет, и на ранних этапах отладка через “внешние сообщения” может показаться болью. В TON нет встроенного event-индекса как в EVM, все держится на индексаторах и фронт зависит от внешнего сервиса.

Откат транзакции (Bounced messages)

Это, пожалуй, самый сложный момент для EVM-разработчика. В solidity если require() не прошел - всё “схлопнулось” и транзакции будто не существовало. В асинхронном TON, если ваша цепочка сообщений дошла до третьего контракта и там упала, первые два контракта об этом автоматически не узнают. Их стейт уже мог измениться! Поэтому нужно все проектировать изначально с расчетом, чтобы критичный для нас или транзакции стейт менялся только тогда, когда прошла вся цепочка и мы действительно можем поменять важные переменные.

Для обработки ошибок в TON существует механизм Bounced-сообщений: Если сообщение было отправлено с флагом bounce: true, и при его обработке на стороне получателя возникла ошибка (или контракт не найден), сеть автоматически генерирует “ответное” сообщение обратно отправителю, включая весь неиспользованный (конечно минус газ за исполнение) TON, который был изначально отправлен. В этом “возвратном” сообщении будет часть данных исходного запроса. Вы обязаны написать в своем контракте обработчик on_bounce, который вернет балансы в исходное состояние (например, начислит обратно токены пользователю, если их перевод не удался).

Важно: Bounce-сообщение несет в себе лишь ограниченное количество газа и данных. Это не полноценный Rollback, а ручная работа по восстановлению целостности данных. Если вы забыли обработать bounce - средства могут просто “зависнуть” на контракте-промежутке.

На практике же - вместо классического bounce (которое в основном используется для возврата forward fee) на критичных данных необходимо проектировать “поток” транзакций особым способом.

Рассмотрим базовый, упрощенный пример - у нас есть мастер-контракт в котором мы хотим распродать не больше 50 айтемов. Пользователь может как покупать, так и продавать айтемы пока supply < 50, после чего торги останавливаются. Т.к. мы не храним на мастер-контракте балансы и т.п. вещи то при покупке планируем следующий флоу

покупка, которая инициируется на мастер контракт.

  1. Проверяем, что еще доступно к продаже (не превысили 50)
  2. проверяем, что с транзакцией было передано TON, не меньше чем сумма за покупку + газ на последующее выполнение
  3. рассчитываем и оставляем (резервируем) на мастер-контракте сумму за покупку
  4. увеличиваем supply
  5. отправляем (или деплоим) контракт-баланс пользователя. Т.к. сеть гарантирует доставку сообщения - то мы знаем точно, что на вновь созданном контракте будет выставлен нужный нам баланс и можем забыть о действии, т.к. его нам не надо контролировать.

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

продажа, отправляем на контракт-баланс

  1. проверяем на баланс-контракте, что у юзера есть действительно на балансе хоть 1 айтем
  2. проверяем, что газа на последующие выполнения нам хватит - иначе просто фейлимся
  3. уменьшаем количество айтемов на баланс-контракте
  4. формируем и отправляем транзакцию на мастер-контракт, о продаже
  5. на мастер контракте что вызов произошел именно от валидного контракта баланса (для этого есть свои определенные механизмы, что мы на 100% уверены кто именно нас вызывает)
    • если supply < 50 - то уменьшаем сапплай и отправляем TON за продажу с мастер-контракта пользователю.
    • Если же supply = 50, то нам необходимо вернуть состояние баланс-контракта (отменить действие 3). Для этого мы формируем следующее сообщение на баланс-контракт, чтобы снова увеличить на нем баланс.

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

Message race conditions

EVM - мы эксклюзивно выполняемся в момент исполнения транзакции и можем пренебречь другими, зная что никто не сможет “влезть” и например трансфернуть токен и т.п.

TON - в цепочке транзакций между нашей первой и второй транзакцией может быть огромное количество других сообщений/взаимодействий с нашим контрактом от других пользователей, которые поменяют данные нашего контракта, так что оно может “разойтись” между нашей первой и второй транзакцией в цепочке. TON обрабатывает все обращения к контракту по-одному, последовательно, но мы не можем контролировать/гарантировать их порядок. Race condition в TON - это не “два параллельных потока меняют одну переменную одновременно”, как в классическом программировании. В TON это “ты не знаешь, в каком состоянии будет контракт к моменту обработки твоего сообщения”.

Рассмотрим на примере хранилища, где есть какой-то баланс какого-то Jetton-токена и есть deposit/withdraw методы. Предположим, что у самого vault есть 1000 токенов, а на внутреннем балансе юзера на vault 100 токенов.

Trace транзакции (упрощенно)

  1. tx1: wallet -> vault: withdraw, проверили баланс, отправляем сообщение в jettonWallet_Vault переведи юзеру 100 токенов.
  2. tx2: vault -> jettonWallet_Vault: уменьшает баланс vault на 100 + transfer пользователю
  3. tx3: jettonWallet_Vault -> jettonWallet_User: увеличивает баланс юзера + отправляет excesses (подтверждение отправления) инициатору перевода
  4. tx3: jettonWallet_User -> vault: пришло подтверждение отправки, уменьшаем внутренний баланс пользователя на 100, withdraw завершен

Представим, что юзер отправил 2 withdraw почти одновременно. И можем получить последовательность

  • withdraw1 - tx1 - баланс 100, продолжаем
  • withdraw1 - tx2 - отправка токенов
  • withdraw2 - tx1 - баланс 100, продолжаем (тут как раз возникает race condition)
  • withdraw2 - tx2 - отправка токенов
  • withdraw1 - tx3 - юзер получил 100 токенов
  • withdraw1 - tx4 - уменьшили баланс юзера у vault на 100
  • withdraw2 - tx3 - юзер получил 100 токенов
  • withdraw2 - tx4 - баланс юзера ушел в минус (или даже транзакция “упала”)

По итогу юзер получил лишних 100 токенов, которые ему не принадлежат.

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

Идемпотентность

  • EVM используется глобальный nonce для каждой транзакции аккаунта. Если вы отправите две транзакции с одинаковым nonce, сеть примет только одну. Это встроенный механизм идемпотентности на уровне протокола.
  • TON нет системного nonce. Защита от повторов ложится на плечи разработчика смарт-контракта. Стандартный контракт кошелька (Wallet v3/v4) использует seqno, но это логика самого контракта кошелька, а не всей сети.
  • EVM вы можете заранее вычислить hash транзакции еще до ее отправки.
  • TON hash внешнего сообщения и hash итоговой транзакции в блоке - это разные сущности. Хеш транзакции генерируется только после исполнения. Для отслеживания статуса и обеспечения идемпотентности на стороне API следует использовать связку параметров address + subwallet_id + seqno или кастомный уникальный payload.

Реализация консистентности

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

  • Seqno: контракт хранит текущий порядковый номер и принимает сообщение только если его seqno совпадает с ожидаемым, после чего увеличивает его. Тем самым гарантируем, что не выполним транзакцию #5 раньше чем транзакцию #4 или не выполним дважды транзакцию #6.
  • valid_until (expire): Каждое сообщение имеет ограничение по времени. Если транзакция не попала в блок до определенного времени, она становится невалидной. Это критично для очистки памяти от старых идентификаторов транзакций.

В процессе работы одной логической операции может возникнуть некоторое количество внутренних сообщений (цепочка/trace) между контрактами (internal messages). Best practices для обеспечения идемпотентности в процессе отработки цепочки является генерация и пробрасывания уникального query_id между контрактами. При помощи этого идентификатора, сохраняя его в стейте контракта на некоторое время вы можете отслеживать повторные обращения, чтобы не выполнить логику, откинув невалидный запрос.

Резюме

Переход в TON - это не изучение нового синтаксиса, это смена парадигмы с “синхронного компьютера” на “почтовую службу между миллионами маленьких офисов”. Если вы научитесь мыслить категориями сообщений и асинхронных ответов, вы откроете для себя сеть с практически бесконечной масштабируемостью, которую невозможно “положить” одним популярным минтингом NFT.

Правила разработки в TON:

  • никогда не доверяй входящим данным
  • обновляй state до отправки сообщений
  • всегда обрабатывай bounce
  • проверяй value
  • делай операции идемпотентными
Даниль Вахидов

Разработчик и архитектор распределённых систем. Увлекается блокчейном и автоматизацией. В Barrigaz отвечает за продукт и инфраструктуру.

Читать на: English