Jetton and NFT Standard Pitfalls on TON: A Security Guide
A security guide to TON jetton security: real minting bugs, transfer-notification spoofing, and NFT ownership pitfalls we've found in audits.
Why Jettons Break in Ways ERC-20 Never Did
TON's jetton standard looks like ERC-20 at first glance — mint, transfer, balance, burn — but the architecture underneath is nothing like Ethereum's. There's no single contract holding a mapping of balances. Every jetton holder gets their own wallet contract, deployed on demand, and the master contract only tracks total supply and mints new wallets. That design choice is why jetton bugs don't look like typical token bugs. You don't get reentrancy in the Solidity sense. You get wrong-address mixups, missing sender checks, and gas-accounting errors that drain contracts one message at a time.
We've audited close to two dozen TON projects at this point — jetton launches, NFT collections, and a few hybrid DeFi protocols building on top of both. The same handful of mistakes keep showing up, and they're rarely exotic. They're places where a developer treated TON's actor model like a normal smart contract call and got burned by the asynchronous, message-passing reality underneath.
Mistake 1: Trusting sender() Instead of Deriving the Jetton Wallet Address
This is the single most common bug we find, and it's the one that actually loses money. When a jetton wallet contract sends a transfer notification to a recipient contract (say, a DEX pool or a staking contract), a naive implementation checks msg.sender or the equivalent sender() call in FunC/Tact and trusts it as "this jetton was really transferred to me."
The problem: on TON, any contract can send any message to any other contract. Nothing stops an attacker from deploying a fake jetton wallet that mimics the real one's message format and firing a transfer_notification op code straight at your contract, claiming a 10,000 USDT deposit that never happened.
The correct pattern is to derive the expected jetton wallet address yourself, using the jetton master address and your own contract's address as the owner, via calculate_jetton_wallet_address (state-init based address derivation), and compare it against the actual sender. If they don't match, reject the message. This is roughly the TON equivalent of checking msg.sender == expectedToken in Solidity, except here you have to compute that expected address rather than just knowing it — and it's easy to skip during a rushed deployment because the happy path works fine without it.
;; inside recv_internal, handling op::transfer_notification
slice sender_address = cs~load_msg_addr();
cell state_init = calculate_jetton_wallet_state_init(
my_address(), jetton_master_address, jetton_wallet_code
);
slice expected_wallet = calculate_address(state_init);
throw_unless(707, equal_slices(sender_address, expected_wallet));
Skip this and your "deposit and get shares" contract becomes a free money printer for anyone who understands TON's message format, which is public and not hard to replicate.
Mistake 2: Forgetting Forward Fees and Bounce Handling
TON charges for message forwarding, and every internal message needs enough TON attached to cover gas plus the forward fee for any messages it triggers downstream. Jetton transfers involve at least two hops — owner wallet to recipient wallet, then a notification hop to the recipient contract — sometimes three if there's a response message back to the original sender.
Underfund a transfer and the chain fails partway through. Worse, if a message runs out of gas mid-execution and bounces, the jetton wallet's balance can end up out of sync with what the master contract or an off-chain indexer believes is true, because the bounce handler either isn't implemented or silently swallows the failure instead of reverting the internal state change. We've seen this manifest as jettons that vanish from a user's wallet without a corresponding mint or burn event anywhere — support tickets that took hours to trace back to a badly configured forward_ton_amount.
The fix is boring but necessary: calculate forward fees using the current fee config rather than hardcoding a value that worked during testnet deploy, and always implement a bounce handler in recv_internal that reverses in-flight state changes if a downstream message fails. Testnet fee prices at the time of writing are lower than mainnet, so contracts that "worked fine" in testing sometimes fail the moment they hit mainnet gas costs.
Mistake 3: NFT Ownership Confusion in Marketplace Integrations
NFT collections on TON follow a similar per-item-contract pattern — each NFT is its own deployed contract, with the collection contract just tracking indices and minting new items. A recurring pitfall in marketplace and staking integrations: checking NFT ownership by calling a get-method (get_nft_data) at the moment of listing, then trusting that ownership hasn't changed by the time a sale executes.
Because TON messages are asynchronous, there's a real window between "we checked ownership" and "the sale transaction lands" where the owner could have transferred the NFT elsewhere, or — more maliciously — where a crafted collection contract returns fabricated data from its get-method entirely. Get-methods aren't validated the way on-chain state is; if you're indexing off-chain and trusting a third-party collection's get-method output without cross-checking it against the actual NFT item contract's stored owner, you're exposed to fake collections designed to spoof marketplace listings.
The safer pattern is to always read ownership directly from the NFT item contract at settlement time, not the collection's cached data, and to add an explicit owner-change check as the final gate before releasing payment.
Mistake 4: Minting Logic That Skips Total Supply Reconciliation
Jetton masters track total_supply as their own piece of state, incremented on mint and decremented on burn. Because minting is just the master sending an internal message to deploy or top up a jetton wallet, a bug in the message construction — wrong amount encoded, wrong recipient address, or a duplicate mint triggered by a retried transaction — desyncs total_supply from the sum of actual wallet balances. Nobody notices until an exchange listing requires a supply audit and the numbers don't reconcile.
Build idempotency into mint operations (a query_id or nonce check that rejects replayed mint requests) and periodically reconcile total_supply against wallet balances during testing, not just at launch.
None of these are exotic vulnerability classes. They're standard "know your platform's actual execution model" mistakes, and they're exactly what a structured review process catches before mainnet rather than after a drained pool. If you're building on jettons or NFTs and want a second set of eyes before launch, our smart contract audit service covers TON-specific message flow and wallet-derivation checks alongside the usual logic review, and for teams still scoping the token design, a strategy consultation upfront tends to catch these architectural issues before a single line of FunC gets written.
For more on what a real audit process actually checks line by line, see our breakdown of what auditors check across FunC, Tact, and other languages, our guide on audit costs across EVM, Solana, and TON in 2026, and our take on whether you need an audit before launch at all.
Get a smart contract audit before your jetton or NFT contract goes live — these bugs are cheap to fix pre-launch and expensive to explain after.
Need a bot like this built?
We design, build and run trading bots on Solana, Hyperliquid and Polymarket.
Start a projectMore from the blog
Public vs Paid RPC: How to Run a Fair Benchmark
A fair comparison of public and paid RPC endpoints must account for quotas, workload, freshness and support guarantees.
Read articleRPC Benchmark Percentiles Explained: p50, p95 and p99
Why averages hide the latency spikes that break trading bots, wallets and production blockchain applications.
Read articleHyperliquid REST vs WebSocket Benchmark: What to Measure
A benchmark plan for Hyperliquid market data that separates request latency from streaming freshness and recovery.
Read article