All articles
Smart Contracts·June 29, 2026·6 min read

TON Contract Security Pitfalls: Bounces, Gas and Replay

TON smart contract security breaks EVM habits: async bounces, mid-execution gas death, and replay attacks need TVM-native fixes.

A wallet contract on TON can lose a user's funds without a single line of "bad" logic in it — just by handling an incoming message the way you'd handle one on Ethereum. That's the trap. TON's actor model, its gas accounting, and its message-passing semantics are different enough from EVM and Solana that patterns which are safe elsewhere become exploitable here. We audit a lot of FunC and Tact code at TierZero, and the same three bug classes come back on almost every engagement: mishandled bounces, gas exhaustion mid-flow, and missing replay protection. None of these have a direct analog in Solidity or Rust-based Solana programs, which is exactly why teams porting logic from those ecosystems get burned.

Why TON isn't just "EVM with different syntax"

Ethereum contracts execute synchronously — a call either completes atomically or the whole transaction reverts. Solana programs run in a single transaction with a fixed, pre-declared account list and compute budget. TON has neither guarantee. Every contract-to-contract interaction is an asynchronous message sent to the blockchain, processed in its own transaction, at its own gas cost, with no atomicity across the chain of calls. If contract A sends a message to contract B, and B's handler fails, A's storage has already been committed. There's no automatic rollback across the two.

This single fact — async, non-atomic, per-message execution — is the root cause of most TON-specific vulnerabilities. It changes how you have to think about error handling, state consistency, and idempotency.

Bounced messages: the footgun with no EVM equivalent

When a contract sends an internal message and the receiving contract's transaction fails (out of gas, thrown exception, insufficient balance), TVM can automatically send a "bounce" message back to the sender — but only if the original message had the bounce flag set (the default in most SDKs) and enough value attached to cover the return trip. The bounced message carries the original message body prefixed with opcode 0xffffffff, truncated to the first 256 bits, and whatever TON was left over after gas fees.

The pitfall: developers assume a bounce means "the operation didn't happen, no state changed." That's often false. If contract B updated its own storage before hitting the failure (say, it credited a balance, then failed on a further downstream call), that update is already final. The bounce only tells contract A that B's transaction reverted — B's actual internal state depends on where in its logic the failure occurred, and TON does NOT guarantee full atomic rollback of everything B touched across sub-calls it triggered.

Worse, plenty of contracts simply don't implement a bounce handler at all. If you don't route messages with opcode 0xffffffff to dedicated logic, the bounce falls into your default receiver and can be misinterpreted as a normal user message, or silently ignored — meaning your accounting drifts from on-chain reality and nobody notices until reconciliation.

() recv_internal(int my_balance, int msg_value, cell in_msg_full, slice in_msg_body) impure {
    slice cs = in_msg_full.begin_parse();
    int flags = cs~load_uint(4);
    if (flags & 1) {
        ;; this is a bounced message — handle it explicitly
        in_msg_body~skip_bits(32); ;; skip 0xFFFFFFFF
        int original_op = in_msg_body~load_uint(32);
        ;; revert whatever local state you optimistically changed
        handle_bounce(original_op, in_msg_body);
        return ();
    }
    ;; normal message processing
}

Skip that flags & 1 check and every failed downstream call becomes an unhandled edge case in production.

Gas: paying per instruction, dying without warning

TON gas isn't estimated once and capped like an Ethereum gas limit you set upfront with slack. Each message carries the TON value attached to it, which gets converted to gas at the current gas price for that workchain. If a contract's logic branches into an expensive path — a loop over a growing dictionary, a large cell deserialization, an unexpectedly deep recursive call — and the attached value doesn't cover it, execution stops mid-instruction. Storage changes made before the failure point in that transaction are discarded, but any messages that transaction already sent before running out of gas may already be in flight, and any state in other contracts those messages triggered is not rolled back with it.

The practical exploit surface: an attacker sends a message with the minimum viable value, deliberately structured to push your contract into its most expensive execution branch (e.g., forcing a dictionary lookup that doesn't hit cache, or growing an array-like structure it has to iterate). If your fee calculation assumes the average case rather than the worst case, you either lose funds covering the shortfall or leave the contract in an inconsistent state because a follow-up message never got sent. Always compute gas costs for the most expensive reachable branch, not the common one, and reject messages that don't carry enough value up front rather than trying to execute and hoping.

Replay protection: no nonce field, no free lunch

EVM gives you tx.nonce for free. Solana has recent-blockhash-based transaction expiry built into the runtime. TON gives you neither automatically — replay protection is something the contract has to implement itself, and it's easy to get wrong or skip entirely, especially in stateless "get" methods or off-chain-signed message flows.

The standard pattern is a seqno stored in the contract, incremented on every processed external message, checked against the seqno embedded in the incoming signed message. Miss this and any previously valid signed message can be replayed indefinitely — this is the exact bug class that has drained TON wallet contracts in the wild when developers rolled custom wallet logic instead of using audited templates. A second, less obvious version of this bug: contracts that accept internal messages with an embedded "authorize once" flag but don't track which message hashes or seqnos have already been consumed, allowing a bounce-and-resend or a duplicated relay to execute the same authorization twice.

() recv_external(slice in_msg) impure {
    var signature = in_msg~load_bits(512);
    var cs = in_msg;
    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());
    var ds = get_data().begin_parse();
    var (stored_seqno, stored_key) = (ds~load_uint(32), ds~load_uint(256));
    throw_unless(33, msg_seqno == stored_seqno);
    throw_unless(34, check_signature(slice_hash(in_msg), signature, stored_key));
    accept_message();
    set_data(begin_cell().store_uint(stored_seqno + 1, 32).store_uint(stored_key, 256).end_cell());
    ;; process message
}

valid_until plus a strictly incrementing seqno, checked and bumped before accept_message(), is the minimum bar. Anything less and you're one replayed message away from an incident report.

What this means for launch readiness

None of these three issues show up in a quick read-through — they surface under adversarial testing, fuzzing with malformed bounces, or gas-limited fault injection, which is exactly what a proper smart contract audit is built to catch before mainnet. If you're earlier in the build, a code review and audit pass on your FunC or Tact source during development catches these patterns before they calcify into architecture, which is cheaper than fixing them after a token generation event is scheduled. And if you're building the contract from scratch, it's worth having smart contract development done by people who've already hit these TVM-specific edge cases rather than relearning them on your users' funds — the same applies if the contract is the backend for a dApp where a failed bounce means a broken frontend state, not just a broken balance.

For a fuller pre-launch checklist specific to FunC and Tact, see our TON smart contract audit checklist, and if you're weighing whether TON's audit costs differ from EVM or Solana, we break that down in smart contract audit costs across EVM, Solana and TON. If you're still deciding whether an audit is necessary at all before you ship, read do you need a smart contract audit before mainnet launch first.

Get a TON smart contract audit scoped around bounce handling, gas worst-case paths, and replay protection before you push funds through a contract that's only ever been tested on the happy path.

Need a bot like this built?

We design, build and run trading bots on Solana, Hyperliquid and Polymarket.

Start a project
#TON smart contract security#FunC#TVM#replay protection#smart contract audit