Bounced Messages: обработка ошибок
Обработка bounced-сообщений — это первая линия обороны от потери средств в TON. Каждый раз, когда ваш контракт отправляет сообщение с TON другому контракту и тот не может его обработать, средства возвращаются как bounced-сообщение. Если ваш контракт не обрабатывает bounce — деньги пользователя исчезают в «чёрной дыре».
В модуле M02 мы разобрали асинхронную модель сообщений TON: контракты общаются друг с другом через внутренние сообщения, и каждое сообщение обрабатывается в отдельной транзакции. Но что происходит, когда получатель не может обработать сообщение?
TON предусматривает механизм bounced messages — автоматический ответ, сигнализирующий об ошибке. Без правильной обработки этих сообщений контракт может потерять средства или оказаться в несогласованном состоянии.
Когда возникает bounce
Bounced-сообщение отправляется автоматически, когда:
- Получатель выбрасывает исключение — функция
require()не прошла, вызванthrow(), или произошла ошибка выполнения - Недостаточно газа — значение
msg_valueне покрывает стоимость выполнения у получателя - Контракт не развёрнут — сообщение пришло на адрес без кода (пустой аккаунт)
При этом bounce происходит только если в исходном сообщении установлен флаг bounce: true (по умолчанию в TON это так для внутренних сообщений).
Контракт A Контракт B
| |
|--- transfer (bounce: true) ------->|
| | throw("Error!")
|<--- bounced message ---------------|
| |
| bounced handler |
| восстанавливает состояние |
Уязвимый контракт: Jetton Wallet без bounced-handler
Рассмотрим классическую уязвимость — Jetton Wallet, который отправляет перевод токенов, но не обрабатывает bounce.
// УЯЗВИМЫЙ КОД -- НЕ ИСПОЛЬЗОВАТЬ В ПРОДАКШЕНЕ
contract VulnerableJettonWallet {
balance: Int as coins;
owner: Address;
receive(msg: TokenTransfer) {
require(sender() == self.owner, "Not owner");
require(self.balance >= msg.amount, "Insufficient balance");
// Уменьшаем баланс ПЕРЕД отправкой
self.balance -= msg.amount;
// Отправляем перевод получателю
send(SendParameters{
to: msg.destination,
value: 0,
mode: SendRemainingValue,
bounce: true,
body: InternalTransfer{
amount: msg.amount,
from: self.owner
}.toCell(),
});
}
// НЕТ bounced-handler!
// Если получатель упадёт, баланс не восстановится
}
Критическая уязвимость: сгорание токенов
Если Jetton Wallet отправляет transfer и получатель выбрасывает исключение, баланс отправителя уже уменьшен, а получатель токены не получил. Без bounced-handler токены фактически сгорают — они списаны у отправителя, но не зачислены получателю.
Исправленный контракт с bounced-handler
В Tact bounced-handler объявляется через ключевое слово bounced. Обратите внимание: в модуле M03, когда мы писали первые контракты на Tact, мы уже видели эту конструкцию — теперь разберём её подробно.
contract SecureJettonWallet {
balance: Int as coins;
owner: Address;
receive(msg: TokenTransfer) {
require(sender() == self.owner, "Not owner");
require(self.balance >= msg.amount, "Insufficient balance");
self.balance -= msg.amount;
send(SendParameters{
to: msg.destination,
value: 0,
mode: SendRemainingValue,
bounce: true,
body: InternalTransfer{
amount: msg.amount,
from: self.owner
}.toCell(),
});
}
// Bounced handler: восстанавливаем баланс при ошибке
bounced(src: bounced<InternalTransfer>) {
self.balance += src.amount;
}
}
При получении bounced-сообщения контракт восстанавливает ранее списанный баланс. Это гарантирует, что при любом исходе общий supply токенов остаётся корректным.
Проверка знанийIf a contract sends a message with `bounce: true` but does not implement a bounced handler, and the recipient throws an error, what happens to the funds or state changes that were already applied before sending?
Ограничения bounced-сообщений
Только первые 256 бит тела
Bounced-сообщение содержит только первые 256 бит оригинального тела сообщения (плюс 32-битный флаг 0xFFFFFFFF). Это значит, что сложные данные из оригинального сообщения будут потеряны.
Частые ошибки
- Полностью игнорируют bounced-сообщения: это самая опасная ошибка, приводящая к необратимой потере средств пользователей.
- Обрабатывают bounce, но не откатывают состояние (например, не возвращают internal balance после неудачного transfer).
- Забывают, что тело bounced-сообщения обрезано до 256 бит: восстановить полную информацию оригинала невозможно.
- Отправляют сообщения с флагом non-bounceable без крайней необходимости, что лишает контракт возможности узнать о неудаче.
Ограничение тела bounced-сообщения
Если ваше сообщение содержит данные за пределами первых 256 бит (ссылки на другие ячейки, длинные строки), эти данные не попадут в bounced-handler. Проектируйте сообщения так, чтобы ключевые поля для восстановления (amount, id) находились в начале тела.
В Tact это отражено в типе bounced<T> — компилятор автоматически ограничивает доступные поля.
Цепные bounce-сообщения не поддерживаются
Bounced-сообщение само отправляется с флагом bounce: false. Это значит, что если обработка bounced-сообщения тоже упадёт, повторного bounce не будет — сообщение будет потеряно.
A ---(bounce: true)---> B B выбрасывает ошибку
A <---(bounce: false)--- B Bounced message
|
| Если обработка bounce у A
| тоже упадёт -- сообщение
| потеряно навсегда!
Поэтому bounced-handler должен быть максимально простым — только восстановление состояния, без сложной логики.
Gas для bounce
Bounced-сообщение оплачивается из msg_value оригинального сообщения. Если отправитель приложил слишком мало газа, bounce-сообщению может не хватить средств для доставки.
Правило: при отправке сообщений, которые могут быть bounced, закладывайте достаточно газа не только на выполнение, но и на обратный путь bounce-сообщения.
Паттерны обработки bounced-сообщений
Паттерн 1: Восстановление баланса (Jetton 2.0)
Стандартный паттерн из Jetton 2.0 — восстановить баланс при неудачном переводе:
bounced(src: bounced<InternalTransfer>) {
// Восстанавливаем баланс, списанный при отправке
self.balance += src.amount;
}
Паттерн 2: Отмена операции (NFT Transfer)
При передаче NFT — вернуть владение обратно:
bounced(src: bounced<NftTransfer>) {
// Передача не состоялась -- возвращаем владельца
self.owner = src.prev_owner;
}
Паттерн 3: Маркировка транзакции как неудачной
Для сложных операций — пометить транзакцию как failed для последующей обработки:
bounced(src: bounced<ProcessPayment>) {
// Помечаем платёж как неудавшийся
self.pendingPayments.set(src.paymentId, false);
}
Ключевые правила
- Всегда пишите bounced-handler для операций, которые изменяют состояние перед отправкой сообщения
- Держите bounced-handler простым — только восстановление состояния, никакой сложной логики
- Критичные данные — в начале тела сообщения (первые 256 бит)
- Закладывайте газ на обратный путь bounce-сообщения
- Тестируйте сценарий отказа — убедитесь, что после bounce контракт возвращается в корректное состояние
В следующем уроке мы разберём, как правильно управлять газом — включая вопрос, сколько газа закладывать для безопасных операций.
Проверьте понимание
Закончили урок?
Отметьте его как пройденный, чтобы отслеживать свой прогресс
Войдите чтобы оценить урок