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
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 standardStateInitaddressing.
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:
- Spoofed sender. An attacker deploys an arbitrary contract that emits
internal_transferortransfer_notificationmessages with attacker-chosen amounts, targeting a DEX, custodian, or bot that trusts the opcode rather than the sender's address. - Bounce mishandling. TON messages carry a
bounceflag; if the destination contract doesn't exist, runs out of gas, or throws, the message (or the returned coins) bounces back with thebouncedflag set. If the originating wallet doesn't re-credit on bounce, its internal balance and the chain's actual token distribution diverge. - 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_notificationwithout recomputing the sender's expected address. Any contract that credits a deposit, mints an LP position, or fills an order on receipt ofop::7362d09cmust 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 forinternal_transferandburn_notificationpaths, 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