Правильная обработка bounce для защиты средств

Свяжешь bounce с учётом балансов: не потеряешь согласованность storage, когда исходящее сообщение отскочит.

Bounce и деньги

При bounce: true неуспех у получателя возвращает value отправителю. Если ты уже списал внутренний баланс пользователя и не обработал bounced, on-chain «долг» и реальные монеты разъедутся. Защита средств здесь — не «отключить bounce», а компенсировать локальное состояние.


Уязвимо: списание без bounce-ветки

tact (fallback highlight)
// @tact-lang/compiler 1.6.13
// УЯЗВИМО: баланс уменьшили, bounce не обрабатываем

message Payout {
    amount: Int as coins;
}

contract EscrowBad(
    balances: map<Address, Int>,
) {
    receive() { cashback(sender()) }

    receive(msg: Payout) {
        let bal: Int = self.balances.get(sender()) ?? 0;
        require(bal >= msg.amount, "low balance");
        self.balances.set(sender(), bal - msg.amount);

        // Если получатель упадёт — value вернётся сюда, а запись в map уже меньше
        send(SendParameters{
            to: sender(),
            value: msg.amount,
            mode: SendPayGasSeparately,
            bounce: true,
        });
    }
}

Исправлено: компенсация в bounced

tact (fallback highlight)
// @tact-lang/compiler 1.6.13
// ИСПРАВЛЕНО: при bounce возвращаем сумму на баланс

message Payout {
    queryId: Int as uint64;
    amount: Int as coins;
    beneficiary: Address;
}

contract EscrowOk(
    balances: map<Address, Int>,
) {
    receive() { cashback(sender()) }

    receive(msg: Payout) {
        require(sender() == msg.beneficiary, "not beneficiary");
        let bal: Int = self.balances.get(msg.beneficiary) ?? 0;
        require(bal >= msg.amount, "low balance");
        self.balances.set(msg.beneficiary, bal - msg.amount);

        message(MessageParameters{
            to: msg.beneficiary,
            value: msg.amount,
            mode: SendPayGasSeparately,
            bounce: true,
            body: Payout{
                queryId: msg.queryId,
                amount: msg.amount,
                beneficiary: msg.beneficiary,
            }.toCell(),
        });
    }

    bounced(msg: bounced<Payout>) {
        let bal: Int = self.balances.get(msg.beneficiary) ?? 0;
        self.balances.set(msg.beneficiary, bal + msg.amount);
    }
}

Как это работает

  • Оптимистичная модель: сначала меняешь storage, потом шлёшь; при bounce — обратная операция.
  • В bounced<T> доступен только префикс полей (~224 бит тела) — amount и beneficiary ставь в начале message.
  • bounce: false на выплате кошельку иногда осознаннен; для контракт→контракт с учётом долгов почти всегда нужен true + handler.
  • Подробнее про лимит полей — в статье про bounce в разделе контрактов.

Частые ошибки

Глотаешь bounce пустым bounced → value вернулось, ledger не восстановился.

Важные поля после длинного префикса → в typed bounce их нет; компенсация невозможна.

Шлёшь повторный payout из bounce без лимита → риск циклов; компенсация должна быть локальной.


Что дальше

Материалы gramdocs.tech носят образовательный характер и не являются финансовой, юридической или инвестиционной рекомендацией. Работа с блокчейном TON и токеном Gram связана с рисками потери средств. Правовая информация