Skip to main content
Gunhee

TON Security Primer: Part 1

A look into The Open Network, its unique design choices, and the security considerations for building on TON
Article heading

Have you heard of TON?

Getting to know this blockchain ecosystem is a great way to gain a new perspective on and learn about asynchronous blockchain security.

TON (The Open Network) is a blockchain protocol originally developed by Telegram and now maintained as an open-source project. Unlike EVM-based chains and others, TON employs a distinct approach to smart contract execution and state management, which has implications for both functionality and security. Understanding these architectural choices is essential when evaluating the platform’s security model.

We’ll take a look at TON’s smart contract paradigm and discuss some security considerations unique to this ecosystem.

Key Features of TON

The programming model in TON is inspired by the actor model for distributed systems. Smart contracts are stateful actors with code. In response to messages, actors can update their own state, dispatch new messages, and create new actors.

Let’s take a look at three of TON’s key features: account state and storage fees, asynchronicity, and cells.

Account State and Storage Fees

Every account has persistent data that can be modified while responding to messages. Maintaining this data incurs storage fees, which scales with the amount of data stored and acts as rent paid to the network. If an account cannot pay these fees, it enters a temporary frozen state, losing its ability to process transactions or execute code. This concept is critical for understanding Jetton wallets, as they must remain active to manage token balances and transactions. Proper protocol design includes correctly structuring incentives for paying fees. For more detail, refer to the TON documentation on storage fees and account states.

Asynchronicity

We should clarify this before moving forward: TON does not have a clear concept of an EOA (Externally Owned Account). Every address, even one that serves a minimal purpose like sending or receiving tokens, is backed by a smart contract. There is the concept of an external message, but it’s fundamentally different from an EOA: it doesn’t imply the existence of a sender in the Ethereum sense. Instead, an external component (like a user or a wallet app) can send an external message to the blockchain to trigger a contract and initiate a transaction.

Messages between actors are asynchronous. In other ecosystems, we might be familiar with atomic calls — where during a smart contract’s execution, a smart contract can obtain data from or perform an effect on a different contract. In TON, contracts cannot wait for a message to be processed to get a response; in fact, outgoing messages are placed in a queue, which is processed only after the execution of the contract that is sending them has finished.

For example, if Contract A sends a message to Contract B, then A will proceed with its logic, whether that means terminating, doing more work, or emitting further messages, without waiting to see what B does. This behavior is somewhat similar to a coroutine or an async fire-and-forget pattern. This creates some challenges for developers; on the other hand, it allows for more parallelization in execution.

FunC is a programming language used to program smart contracts on TON. In FunC, these messages may be handled as follows.

#include "imports/stdlib.fc";

() recv_internal(int my_balance, int msg_value, cell in_msg_full, slice in_msg_body) impure {
;; check if incoming message is empty (with no body)
if (in_msg_body.slice_empty?()) {
;; return successfully and accept an empty message
return ();
}

;; parse the operation type encoded in the beginning of msg body
int op = in_msg_body~load_uint(32);

;; handle op #1 = what we want to do
if (op == 1) {
;; call our utility functions to do something like writing persistent values to storage
something_you_want(args, ...);
}
}

The recv_internal function is responsible for handling incoming messages generated by other contracts. By convention, the first four bytes of the body of the incoming message contain an op identifier of the operation to be performed. This identifier is similar to Solidity function selectors. While in the case of Solidity, the compiler automatically generates a dispatcher for all public and external functions, in FunC the developer is responsible for manually handling the requested operation.

Of course, some protocol functionality will require a two-way interaction with another actor — in EVM, this might look like an external call with an observed return value. For achieving similar behavior, a common pattern in TON contracts is issuing a message and listening for a callback message to continue execution.

action diagram

After receiving a message and performing all operations associated with the opcode, the contract then waits for the next message—whether internal or external—before proceeding.

Asynchronous message-based architecture of TON maximizes scalability and efficiency by eliminating the need for sequential guarantees in most cases. For example, unlike synchronous systems where a call chain (e.g., a → b → c → d) must complete in order, TON processes each message as an independent transaction. This ensures that if a sends a message to b, it can immediately finalize its state without waiting for the subsequent steps in the chain. By decoupling transactions, TON enables parallel processing which potentially improves network performance.

transaction diagram

As shown in the diagram above, in the EVM, a transaction is only issued and the contract’s state is finalized once all call chains have completed. In contrast, in the TVM, a transaction is issued as soon as the processing for a single message is finished. Consequently, the contract’s state can be determined even if not all call chains have completed.

Cells

We would like to have meaningful data in our state and messages. This brings up the question of how TON represents the data. On one end of the spectrum, EVM-based ecosystems generally think of data as sequences of bytes. On the other end, ecosystems like Aptos and Sui have structured resources. In TON, the data in state and messages is structured in cells. These store up to 1,023 individually accessible bits and up to four references to other cells. Below is the structure of the cell datatype.

cell

Normally, the specification of how data can be serialized and deserialized to and from a cell is defined through a language called TL-B. Below is an example of how the layout of a message and a transaction are defined in the TL-B language.

It’s important to note that the FunC language does NOT allow for (de-)structuring via this TL-B language; it simply exists to make development easier by allowing you to understand the structure of your data via the TL-B language.

message$_ {X:Type} info:CommonMsgInfoRelaxed
init:(Maybe (Either StateInit ^StateInit))
body:(Either X ^X) = MessageRelaxed X;

transaction$0111 account_addr:bits256 lt:uint64
prev_trans_hash:bits256 prev_trans_lt:uint64 now:uint32
outmsg_cnt:uint15
orig_status:AccountStatus end_status:AccountStatus
^[ in_msg:(Maybe ^(Message Any)) out_msgs:(HashmapE 15 ^(Message Any)) ]
total_fees:CurrencyCollection state_update:^(HASH_UPDATE Account)
description:^TransactionDescr = Transaction;

Cells cannot contain circular references, direct or indirect, meaning that any cell cannot reference itself or any of its ancestors. This allows a set of cells to be represented as a byte array called a bag of cells.

We should also note that there is a lot more nuance about cells. For more information about cells, including cell levels, exotic cell types, and how bag-of-cells serialization works, refer to this page in the TON documentation.

Although this data paradigm may appear limited, there are some potential benefits to the design. Recall that EVM features mutable storage and memory, and developers can write contracts that statefully interact with data with reference semantics. In TON, the cell layout is amenable to immutability (in fact, all mutation is constrained to a few registers), which in theory can encourage developers to write code that is easier to reason about.

Another interesting property of the tree-like structure of cells is that they are very suitable for representing algebraic data types. This idea is mentioned by the TON whitepaper, which mentions that TVM can easily support a number of high-level paradigms. It envisions Java-like imperative languages, lazy functional languages, and eager functional languages.

Vulnerabilities in TON vs EVM

To help understand TON’s security, we can compare it with the security of EVM. As architecturally different as the two ecosystems are, they also have many differences from a security perspective.

Let’s take a look at how security issues that may arise in EVM’s Solidity play out in TON’s FunC.

Reentrancy

Reentrancy issues constitute a major vulnerability class in EVM-based smart contracts. This vulnerability occurs when a function (e.g., withdraw) calls an external contract before updating the data in storage (e.g., user_balance). In this situation, if the external contract function calls back into the first contract, the code may observe an inconsistent state and perform incorrect actions. In the case of withdraw, reentrancy issues can result in multiple withdrawals, with the most famous example being the now infamous DAO hack.

Would you expect TON to also have a reentrancy vulnerability if it communicates with external contracts before the status update happens?

As mentioned earlier, the TVM does not expose a way to perform a synchronous call. It only allows sending asynchronous messages. As such, reentrancy vulnerabilities cannot manifest in quite the same way as they do in EVM-based ecosystems. However, TVM contracts can have similar issues, where an unexpected message is received, breaking the intended logic flow of the contract. It is also possible for an expected message to not be sent (or bounced), without properly resetting the logic flow of the contract.

In other words, it’s reasonable to assume that TON’s communication paradigm excludes reentrancy vulnerabilities we’re familiar with. But on the other hand, the asynchronicity introduces new security considerations for developers.

Overflow/Underflow

Before version 0.8.0, Solidity did not guard against overflows and underflows in integer operations, leading to a number of critical vulnerabilities.

It is well-known that overflow and underflow occur when an integer falls outside the maximum or minimum value that can be represented with the number of bits used to store it. How do you think this vulnerability will be handled in TON?

In both TON and EVM, if you try to exceed the maximum and minimum values by adding or subtracting, both returns an error about the integer and reverts the transaction. In particular, TVM will throw exit code 4 (an integer-overflow exit code) and force an exit. It’s also an essential response to basic vulnerabilities.

In conclusion, overflow/underflow for basic addition and subtraction is prevented by exiting with an integer-overflow exit code in TON. Additionally, comparing NaN values will also return an overflow exit code and terminate the VM. This means that TON, like EVM, has this vulnerability.

This is indeed the correct approach; however, in TON, there is an important caveat to consider. If a bounced message – triggered by a revert – is not properly handled by the originating contract which sent the reverted transaction, it can lead to serious issues. We cover this in more detail in the Improper handling of bounced messages section below.

Division by Zero

The division-by-zero issue in Solidity reverts a transaction altogether. This can be prevented with a require statement, making it easier to determine the source of the error. This time, let’s consider how this vulnerability is handled in TON.

TON, like the EVM, does not have a type for floating-point numbers, which means that there is no way to calculate the exact value of a decimal place when doing division. Also, for divisions with a remainder (e.g., 5/2), the result is rounded down and returns 2.

So what happens if you divide a certain value by zero? Yes, of course it throws an error. And it throws TVM exit code 4. The exit code 4 means integer overflow. That means, in TVM when division by 0 occurs, the transaction will revert and return an integer-overflow error.

If you’d like to test overflow, underflow, and division by zero in TON on your own in your local environment, you can see the test codes and do so here.

Common Vulnerabilities in the Ecosystem

In addition to this, you should also be aware of common vulnerability patterns that can occur in the TON ecosystem.

Unbounded Storage

A common issue in other ecosystems is unbounded executions; since we typically pay gas fees as operations are performed, long executions like infinite loops can cause failures. These types of problems can exist in TON as well, but there are also unique considerations on the storage size.

First, developers should be responsible with actor storage. The amount of data and the depth of data are limited, and mistakes in protocol design or coding errors that lead to accumulating storage can quickly lead to contracts failing. Additionally, user input has to be treated carefully. In a typical EVM contract, especially one written in Solidity, message data is read as bounded structs. But in TON, because all data — even message data — is stored in cells, it’s possible for external inputs to be surprisingly deep. It’s important to avoid haphazardly inserting user input into storage, because it can be larger than expected.

Data Format Inconsistency

In the TON ecosystem, data structures are often described using a language called TL-B. However, even though a machine-readable description is available, FunC does not give any aid to the developer and requires manipulating data at the level of the individual bits — including when storing to or reading from the contract storage, parsing an incoming message, or creating an outgoing message.

Manually serializing and deserializing data is complex and can easily lead to bugs. It’s important to ensure consistency and proper use of data, whether it’s contract’s raw messages delivered externally or data stored in global storage.

Take a look at the following example. Can you spot the bug?

global int balance;
global int owner;
global int admin;
global cell credential;

() recv_internal(int msg_value, cell in_msg_full, slice in_msg_body) {
int op = in_msg_body~load_uint(32);

if (op == ...) {
slice ds = get_data().begin_parse();

balance = ds~load_uint(64);
owner = ds~load_uint(256);
admin = ds~load_uint(256);
credential = ds~load_ref();

;; process we must do...

set_data(
begin_cell()
.store_uint(balance, 32)
.store_uint(owner, 256)
.store_uint(admin, 256)
.store_ref(credential)
.end_cell()
);

return ();
}
}

As you can see, this code stores the balance as 32 bits but loads it as 64 bits. That means the balance loses the upper 32 bits, and variables that follow are also mixed up with previous variables. The issue is obvious in this case, as there is not much code and the data structure is trivial, but in real-world scenarios it is often much more difficult to review the code and ensure data is serialized and deserialized correctly.

Many projects in the TON ecosystem use two functions, commonly named store_data and load_data, responsible for serializing and deserializing all the storage variables of the contract. Similar functions are also often defined to deserialize incoming messages and serialize outgoing messages. Although the data is still accessed at the bit level, this software engineering best practice makes it much easier to review the code and spot bugs.

Let’s look at the example below one more time:

global int global_balance;
global int global_owner;
global int global_admin;
global cell global_credential;

() store_data() impure {
set_data(
begin_cell()
.store_uint(global_balance, 64)
.store_uint(global_owner, 256)
.store_uint(global_admin, 256)
.store_ref(global_credential)
.end_cell()
);
}

() load_data() impure {
slice ds = get_data().begin_parse();
global_balance = ds~load_uint(64);
global_owner = ds~load_uint(256);
global_admin = ds~load_uint(256);
global_credential = ds~load_ref();
}

() recv_internal(int msg_value, cell in_msg_full, slice in_msg_body) {
int op = in_msg_body~load_uint(32);

if (op == ...) {
load_data();

;; process we must do...

store_data();
return ();
}
}

The above example demonstrates a proper load and store process based on predetermined bit sizes. It also structures data loading and storage at the function level. Maintaining consistency in the data format by adhering to these predefined bit units for load and store is crucial.

Moreover, separating the code into function units greatly simplifies troubleshooting when issues arise. By clearly defining responsibilities in each function, it becomes much easier to pinpoint and address the root cause of any errors.

Address Verification

In TON, almost all actions are performed on a contract basis. For example, wallets also exist as contracts. So naturally, address management should be considered very important in TON as well. As with many ecosystems, address validation is very important, especially things like ensuring that it is checked by all ecosystems who the sender of a message is, including the EVM.

Most important is that even within a contract, depending on the actions you want to execute (most of which are denoted by op), you need to thoroughly validate not just one address but multiple addresses.

The sender of the message is contained in the in_msg_full cell. That cell contains the source address followed by the message flag in the first four bits. When validating this sender, we typically proceed as follows.

global slice verification::valid_addr; 

() recv_internal(int msg_value, cell in_msg_full, slice in_msg_body) {
slice cs = in_msg_full.begin_parse();

;; or int flags = cs~load_uint(4); to check message flags.
cs~skip_bits(4);
slice sender_addr = cs~load_msg_addr();

int op = in_msg_body~load_uint(32);

if (op == ...) {
...
throw_unless(error::invalid_address, equal_slices(sender_addr, verification::valid_addr));
...
}
}

Since all actions in TON are performed on a per-contract basis, it is often necessary to validate sender addresses exactly for each action, which is why we should validate addresses like the example above. Be sure to write your contracts carefully, because if you don’t validate the address properly, you could end up with an unintended contract executing a very important action (e.g., withdraw, mint, burn).

In the above case, we are able to explicitly hold permissioned addresses in contract state. But this is not always possible. For example, a protocol might deploy a contract for every one of its users. But as we discussed previously, it’s infeasible to record into storage all such permissioned contracts. Instead, we take advantage of how addresses are derived: actors are addressed by their initial state and code.

Example: Jetton Wallet

A prime example of this pattern is in TON’s token standard, Jetton. In EVM, we might implement tokens by storing balances in a single, central contract. But this would be problematic in TON, because the size of storage would consequently scale with the number of users.

Instead, the canonical Jetton implementation allows users to deploy a wallet actor to keep track of the balances. But then, these wallet actors need to identify each other as trusted — otherwise, what’s stopping a malicious contract from pretending it represents a user balance and initiating a transfer?

The solution is simple: we can check that a wallet has the correct code and initial state, with respect to its user.

storage#_ balance:Coins owner_address:MsgAddressInt jetton_master_address:MsgAddressInt jetton_wallet_code:^Cell = Storage;

The minter contract has a storage structure that includes a content cell, which contains metadata about the token that cannot be forged, even by someone attempting fraud, and an admin_address that manages the minter. The contract is deployed using this data as init_data. As mentioned earlier, the contract address is generated by creating a state from init_data and init_code. This means that the address is determined by the data and contract code, allowing us to calculate the address and verify whether a given Jetton minter is genuine or counterfeit.

The wallet contract follows the same principle. The storage structure of the wallet includes jetton_master_address and owner_address, both of which cannot be altered. By using this information, we can calculate a definitive address, making it easy to determine whether an address is correct and genuine.

When the jetton_wallet sends or receives tokens, calculating the wallet address is essential. This process is defined in jetton-utils.fc.

cell pack_jetton_wallet_data(int balance, slice owner_address, slice jetton_master_address, cell jetton_wallet_code) inline {
return begin_cell()
.store_coins(balance)
.store_slice(owner_address)
.store_slice(jetton_master_address)
.store_ref(jetton_wallet_code)
.end_cell();
}

cell calculate_jetton_wallet_state_init(slice owner_address, slice jetton_master_address, cell jetton_wallet_code) inline {
return begin_cell()
.store_uint(0, 2)
.store_dict(jetton_wallet_code)
.store_dict(pack_jetton_wallet_data(0, owner_address, jetton_master_address, jetton_wallet_code))
.store_uint(0, 1)
.end_cell();
}

slice calculate_jetton_wallet_address(cell state_init) inline {
return begin_cell().store_uint(4, 3)
.store_int(workchain(), 8)
.store_uint(cell_hash(state_init), 256)
.end_cell()
.begin_parse();
}

slice calculate_user_jetton_wallet_address(slice owner_address, slice jetton_master_address, cell jetton_wallet_code) inline {
return calculate_jetton_wallet_address(calculate_jetton_wallet_state_init(owner_address, jetton_master_address, jetton_wallet_code));
}

The calculate_user_jetton_wallet_address function takes the wallet’s owner_address, Jetton minter address, and Jetton wallet code as arguments to generate the actual address. This allows us to determine the address to send or receive tokens and verify which wallet a token was transferred from.

In practice, when the Jetton minter mints a token, it calculates the address of the wallet and deploys it (or sends tokens to the deployed wallet address) as shown below.

() mint_tokens(slice to_address, cell jetton_wallet_code, int amount, cell master_msg) impure {
cell state_init = calculate_jetton_wallet_state_init(to_address, my_address(), jetton_wallet_code);
slice to_wallet_address = calculate_jetton_wallet_address(state_init);
var msg = begin_cell()
.store_uint(0x18, 6)
.store_slice(to_wallet_address)
.store_coins(amount)
.store_uint(4 + 2 + 1, 1 + 4 + 4 + 64 + 32 + 1 + 1 + 1)
.store_ref(state_init)
.store_ref(master_msg);
;; pay transfer fees separately, revert on errors
send_raw_message(msg.end_cell(), 1);
}

The Jetton wallet handles received Jetton token amounts by invoking the receive_tokens function, which updates the wallet contract’s balance variable to store the new token amount. Additionally, it ensures token security by verifying the incoming Jetton amount using the previously explained address-calculation method. This verification confirms whether the tokens originate from the Jetton master (minter) or a valid wallet address. The process for sending tokens follows a similar approach.

  ;; incoming transfer
if (op == op::internal_transfer()) {
receive_tokens(in_msg_body, sender_address, my_balance, fwd_fee, msg_value);
return ();
}

() receive_tokens (slice in_msg_body, slice sender_address, int my_ton_balance, int fwd_fee, int msg_value) impure {
;; NOTE we can not allow fails in action phase since in that case there will be
;; no bounce. Thus check and throw in computation phase.
(int balance, slice owner_address, slice jetton_master_address, cell jetton_wallet_code) = load_data();
int query_id = in_msg_body~load_uint(64);
int jetton_amount = in_msg_body~load_coins();
balance += jetton_amount;
slice from_address = in_msg_body~load_msg_addr();
slice response_address = in_msg_body~load_msg_addr();
throw_unless(707,
equal_slices(jetton_master_address, sender_address)
|
equal_slices(calculate_user_jetton_wallet_address(from_address, jetton_master_address, jetton_wallet_code), sender_address)
);
...
save_data(balance, owner_address, jetton_master_address, jetton_wallet_code);
}

Improper handling of bounced messages

In TON, unexpected transaction failures must be handled with greater care than in other ecosystems.

Due to the design of the TON virtual machine and its message-passing architecture, interacting with multiple contracts requires sending multiple messages, each resulting in separate transactions. For example, a contract call chain like A → B → C would involve two distinct transactions: one from A to B, and another from B to C.

call_chain

Now, what happens if the transaction from B to C fails?

In TON, the state changes in contract C are not committed—they are discarded. However, any state changes in contract B that occurred as a result of A’s message remain committed. This creates an asymmetric state: part of the chain succeeded, part failed.

If B modified any sensitive state—like balances or access rights—those changes persist even though the downstream contract (C) failed. This can lead to unintended consequences.

failure_tx

To address this, TON uses a mechanism called the bounced message. When a contract call fails, the TON VM sends a bounced message back to the sender, notifying it of the failure.

bounced_message

If a contract that expects to receive a bounced message ignores it—or worse, processes it incorrectly—it may leave its state in an inconsistent or vulnerable condition. This can result in financial loss, permission errors, or even exploitable bugs.

Handling bounced messages properly is not optional. It’s required for secure contracts on TON.

Conclusion

TON differs from other ecosystems. Though FunC is subject to some common vulnerabilities across blockchains, its unique design leaves it open to uncommon threats. Though we’ve delved into several in this post, new introductions to TON such as the Tact language may leave it open to more vulnerabilities.

In the next post in this series, we’ll explore how TON’s new languages — such as Tolk and Tact — differ from FunC, and we’ll take a deeper dive into the TVM internals.

We hope that you launch your services on the TON ecosystem with a comprehensive security audit. Whether it’s a development issue, a design pattern issue, an economic issue, or a fundamental security issue, we’re here to help!

About Us

Zellic specializes in securing emerging technologies. Our security researchers have uncovered vulnerabilities in the most valuable targets, from Fortune 500s to DeFi giants.

Developers, founders, and investors trust our security assessments to ship quickly, confidently, and without critical vulnerabilities. With our background in real-world offensive security research, we find what others miss.

Contact us for an audit that’s better than the rest. Real audits, not rubber stamps.