All audits
Passed with issuesTONIndependent

TON Jetton TEP-74

Smart contract security audit by TierZero · 2024-06-15

Independent research. This is an unsolicited security analysis published by TierZero — not a commissioned audit, and no certificate is issued. It reflects our own review of public code and on-chain events.

Findings summary

0
Critical
0
High
1
Medium
3
Low
2
Informational

Independent research — not a commissioned audit. This is an educational design review of a public, battle-tested standard, not a certification of any specific deployment.

What it is

TEP-74 defines the Jetton standard on TON — the fungible token equivalent of ERC-20, but with a structurally different trust model. There is no single contract holding a balance mapping. Instead:

  • A jetton-minter (master) contract holds total supply and metadata and is the mint authority.
  • Every holder gets their own jetton-wallet contract instance, deployed at an address deterministically derived from (jetton-wallet code, owner address, jetton master address) via TON's standard StateInit addressing.

A transfer is not a storage write in a shared ledger — it's a chain of internal messages: owner calls their own wallet with op::transfer (0x0f8a7ea5), that wallet debits its balance and sends op::internal_transfer (0x178d4519) to the recipient's wallet (deploying it on the fly if needed), and the recipient wallet credits itself and optionally fires op::transfer_notification (0x7362d09c) to the owner's contract, plus an excess-return message. TEP-89 layers on a discovery mechanism so third parties can ask the master "what is wallet address X's jetton wallet" and get a deterministic answer without an on-chain lookup table.

This is the reference model shipped in the ton-blockchain/token-contract repository and reused, with variation, by essentially every Jetton on the network.

Threat model

Because balance state is sharded across N independently-addressed contracts instead of one ledger, the attack surface shifts from "read/write a mapping safely" to "prove that a message claiming to move tokens actually came from the contract entitled to move them." The realistic threats are:

  1. Spoofed sender. An attacker deploys an arbitrary contract that emits internal_transfer or transfer_notification messages with attacker-chosen amounts, targeting a DEX, custodian, or bot that trusts the opcode rather than the sender's address.
  2. Bounce mishandling. TON messages carry a bounce flag; if the destination contract doesn't exist, runs out of gas, or throws, the message (or the returned coins) bounces back with the bounced flag set. If the originating wallet doesn't re-credit on bounce, its internal balance and the chain's actual token distribution diverge.
  3. State-init / code-hash drift. If a wallet's deployed code doesn't match the code hash the master (or a counterparty) expects, address derivation and thus sender verification silently breaks.

None of these require breaking TON's VM or consensus — they're implementation-discipline failures layered on top of a sound protocol.

Why the design holds (key invariants & mitigations)

The standard's core invariant: a jetton-wallet is only ever trusted if its address matches the one deterministically computable from (code, owner, master). Nothing about the message's opcode or payload is trusted on its own.

;; simplified sender check inside op::internal_transfer handling
slice sender = cs~load_msg_addr();
cell expected_init = calculate_jetton_wallet_state_init(
    owner_address, jetton_master_address, jetton_wallet_code
);
slice expected_addr = calculate_address(workchain, expected_init);

throw_unless(705,
    equal_slices(sender, expected_addr)   ;; a legitimate sibling wallet
    | equal_slices(sender, jetton_master_address) ;; or the master, for mint
);
balance += amount;

This mirrors a pattern we've written about on the Solana side in our review of cross-program invocation risk in Solana DeFi: the vulnerability class isn't "the message lied about what it is," it's "the receiving code trusted the caller's self-description instead of deriving the caller's identity independently."

Bounce handling closes the second gap. Reference wallets implement an on_bounce handler that reverses the optimistic debit if the paired internal_transfer never lands:

() on_bounce(slice body) impure {
    body~skip_bits(32); ;; 0xFFFFFFFF bounce prefix
    int op = body~load_uint(32);
    if (op == op::internal_transfer) {
        (int amount, _) = parse_bounced_body(body);
        balance += amount; ;; restore the debit, funds never left
    }
}

Skipping this doesn't lose tokens outright — TON's bounce mechanism does return the message's TON value — but it does leave the wallet's own bookkeeping permanently wrong relative to what actually happened on-chain, which is its own class of bug.

Where implementers get it wrong

  • Trusting transfer_notification without recomputing the sender's expected address. Any contract that credits a deposit, mints an LP position, or fills an order on receipt of op::7362d09c must verify the sending address against the specific (owner, master, code) triple for the jetton it thinks it's receiving — otherwise a fake jetton with a copycat notification format is indistinguishable from the real one. This is the TON analogue of account-identity confusion we cover in our piece on Anchor discriminator type-confusion attacks: checking a tag on the data instead of the provenance of the sender.
  • Hardcoding or caching the expected wallet code hash incorrectly, so a legitimately upgraded reference wallet (or a different, non-malicious Jetton implementation) fails verification, or worse, a stale hash accepts an outdated and now-exploitable wallet version.
  • Omitting or under-testing on_bounce, especially for internal_transfer and burn_notification paths, leaving wallets that silently drift out of sync with actual token movement after any recipient-side failure (undeployed wallet, insufficient gas, malformed forward payload).
  • Under-provisioning forward_ton_amount, causing the notification leg to fail non-fatally in ways that are easy to miss in testing but change user-visible behavior in production.

Takeaways

TEP-74's wallet/master split is sound precisely because it replaces "the ledger says so" with "the address proves so" — every trust decision reduces to StateInit-based address derivation. Where deployments fail is almost always a shortcut around that derivation: trusting an opcode, a payload field, or a stale code hash instead of recomputing the expected sender. Anyone building on top of Jettons — DEXs, custodial paths, payment processors — should treat sender-address recomputation and bounce reconciliation as mandatory review items, not implementation details. If you're shipping a Jetton-adjacent contract or reviewing one already in production, that's exactly the kind of invariant-by-invariant check our smart contract audit engagements are built around.

Need your contracts audited?

Manual review + tooling across TON, Solana and EVM — certificate and full report included.

Get an audit