Tact vs FunC: Which Is Safer to Audit on TON?
Tact vs FunC security compared: which TON contract language cuts audit bug surface, and which one just moves the risk to the compiler.
Ask a TON auditor which contracts take longer to review and you'll get a fast answer: FunC ones, almost every time. Not because FunC is worse — plenty of production-grade jettons and wallets are written in it — but because it hands the developer a loaded gun and calls it "flexibility." Tact was built specifically to take some of that gun away. The question worth answering isn't which language is "better," it's which one produces less attack surface per line of business logic, and where the risk moves to when you pick the safer-looking option.
Two languages, two trust models
FunC compiles close to TVM assembly. You're manipulating cells, slices, and builders directly — the same primitives the virtual machine itself works with. There's no type system protecting you from reading a Slice past its bit length, no compiler stopping you from forgetting to check a message's bounced flag, and no structural guarantee that the opcode you parsed matches the payload you assumed follows it. Every one of those is manual discipline, and manual discipline is exactly what audits exist to check.
Tact sits a layer above that. It compiles down to FunC (and eventually the same TVM bytecode), but the source you write has structs, typed Message definitions, pattern-matched receive() handlers, and a type checker that refuses to compile obviously wrong code. The tradeoff is that you're trusting a younger compiler to generate the low-level cell packing correctly on your behalf — trust that FunC never asks you to extend to a code generator, because there isn't one standing between you and the bytecode.
Where FunC bites auditors
The recurring findings we flag in FunC contracts cluster around a small set of patterns:
- Manual opcode dispatch. A contract reads
in_msg_body~load_uint(32)for the opcode and then branches withif/elseifchains. Miss a branch, forget athrow_unlesson an unknown opcode, and the contract silently no-ops or — worse — falls through to logic meant for a different message type. - Bounced message handling. FunC requires you to explicitly check the bounced flag on inbound messages before trusting the payload. Skip it and a bounced message gets processed as if it were a real one, which has caused real fund-accounting bugs in jetton-style contracts.
- Cell and gas limits. Cells cap out at 1023 bits and 4 references. Nothing in FunC stops you from building a cell that exceeds that at runtime; you find out with a runtime exception, sometimes only under specific data conditions your test suite never hit.
- Signed/unsigned and bit-width mismatches. FunC's integers are all 257-bit under the hood, but loading fixed-width fields (
load_uint(64)vsload_int(64)) is a manual choice made at every call site, and copy-pasted code frequently gets it wrong in the second location.
None of these are exotic. They're the boring, repeatable stuff that shows up in checklist form in most FunC audits — which is exactly why we maintain a dedicated FunC and Tact audit checklist for engagements on either language.
What Tact closes off — and what it doesn't
Tact's Message types generate their opcode automatically (a CRC32 of the message signature by default), and receive() functions pattern-match on the typed message rather than a manually parsed integer. That eliminates the entire "forgot to check the opcode" bug class structurally — you can't accidentally handle a Transfer payload in the Burn branch, because the compiler is doing the routing.
message(0x7362d09c) TokenTransfer {
queryId: Int as uint64;
amount: Int as coins;
destination: Address;
}
contract JettonWallet {
balance: Int as coins;
receive(msg: TokenTransfer) {
require(msg.amount <= self.balance, "insufficient balance");
self.balance -= msg.amount;
// compiler guarantees msg.destination is a valid Address,
// msg.amount decoded per the `coins` var-int layout — no manual load_uint math
}
bounced(msg: bounced<TokenTransfer>) {
self.balance += msg.amount; // typed bounced handling, no manual flag check
}
}
Compare that to the FunC equivalent, where the opcode check, the field widths, and the bounced flag are all things the developer writes by hand:
() recv_internal(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) { ;; bounced message — easy to forget this check
return ();
}
int op = in_msg_body~load_uint(32);
if (op == 0x7362d09c) {
int query_id = in_msg_body~load_uint(64);
int amount = in_msg_body~load_coins();
slice destination = in_msg_body~load_msg_addr(); ;; no compile-time guarantee this is well-formed
;; ...
}
}
The Tact version is shorter and structurally rules out a handful of bug classes we see repeatedly. But it doesn't rule out logic bugs — require(msg.amount <= self.balance, ...) still has to be written correctly, and Tact's abstractions can obscure exactly how much gas a given struct access or trait method costs, which matters when you're reasoning about denial-of-service via gas exhaustion. Tact is also a smaller, younger codebase than the FunC compiler; auditors reviewing Tact contracts have to hold open the possibility of a code-generation bug in the compiler itself, not just the business logic on top of it. That's a real, if less frequent, category of risk.
Comparison
| Aspect | FunC | Tact |
|---|---|---|
| Abstraction level | Near TVM assembly, manual cell/slice ops | Structs, typed messages, traits |
| Opcode/message routing | Manual parsing, manual dispatch | Compiler-generated, pattern-matched |
| Bounced message handling | Manual flag check required | Typed bounced<T> handler |
| Type safety | Minimal — mostly int/slice/cell |
Structural types, nullable checks |
| Compiler maturity | Long-standing, reference implementation | Newer, faster-moving, smaller surface area of real-world audits |
| Gas transparency | Very explicit, easy to reason about cost | Some cost hidden behind struct/trait sugar |
| Ecosystem precedent | Most production jettons, wallets, DEXs | Growing, especially in newer dApp templates |
| Audit bug surface | Wide — dispatch, bounds, flags all manual | Narrower for message handling, same for business logic |
Which to pick when
If you're shipping a jetton, wallet, or anything that closely mirrors an existing, heavily-audited FunC standard, staying in FunC and diffing against the reference implementation is often the lower-risk path — you inherit years of scrutiny instead of asking a newer compiler to prove itself on your specific contract. If you're building novel logic — a custom escrow, a multi-step DeFi flow, anything with several message types and branching state — Tact's structural guarantees remove a class of mistakes that FunC audits catch constantly, and that reduction is worth more than the marginal compiler-maturity risk in most cases.
Either way, the language choice changes what an audit should focus on, not whether you need one. Our smart contract audit service scopes FunC and Tact engagements differently for exactly this reason, and if you're earlier in the build than an audit, our smart contract development team can help you pick the right language for the contract you're actually building rather than the one that's trendiest this quarter. If cost is the open question before you commit either way, it's worth reading how audit pricing compares across EVM, Solana, and TON, and if you're still deciding whether you need one at all before mainnet, start with this rundown on pre-launch audits.
Talk to us about a TON smart contract audit before you lock in a language or a launch date — it's cheaper to change either one now than after mainnet.
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