The Open Network (TON) continues to push the boundaries of blockchain technology with its innovative features and robust smart contract capabilities. Building on the insights and lessons learned from previous blockchain platforms like Ethereum, TON offers developers a more efficient and flexible environment. One of the key components driving this advancement is the Tact language.
Tact is a new programming language for the TON blockchain that is focused on efficiency and simplicity. It is designed to be easy to learn and use, and to fit well with smart contracts. Tact is a statically typed language with a simple syntax and a powerful type system.
Nevertheless, many of the pitfalls developers face with FunC remain relevant when developing in Tact. Below, we will examine some of the common mistakes from our audit practice of Tact.
Tact makes it easy to declare, decode, and encode data structures. However, it is still necessary to be cautious. Let’s consider an example:
This is the declaration of the InternalTransfer message used for transferring Jettons according to the TEP-74 standard. Note the declaration of response_destination, which is an Address. In Tact, an Address must be a non-zero address. However, the reference implementation of the Jetton standard allows for zero address (addr_none), which is represented by two zero bits. This means that the user or another contract will probably try to send jettons with zero response_destination and it will unexpectedly fail.
Moreover, if the Transfer message (from the user to their wallet) allows response_destination, but the InternalTransfer message (from sender wallet to recipient wallet) doesn’t allow it, the jettons will fly out, but will not get to the destination and will be lost. Later, we will discuss an exception, whereby the bounced message is properly handled.
In this case, the better structure declaration allowing zero address would be Address?, but passing the optional address further to the next message is currently a bit cumbersome in Tact.
In Tact, the developer can specify how the field is serialized.
In this example, the totalAmount will be serialized as coins, and releasedAmount will be serialized as int257 (default for Int). It can be negative and it will occupy 257 bits. In most cases, omitting the serialization type will not bring any problems; however, if the data is involved in communication, it becomes crucial.
Here is an example from the project we audited:
This data structure is used by the NFT item as a reply to an on-chain get_static_data request. According to the standard, the reply should be:
The index above is uint256 (not int257), which means that the returned data will be misinterpreted by the caller with unpredictable results. It is likely that the report_static_data handler will revert and the message flow will break. These examples illustrate why it is important to think about data serialization, even when working in Tact.
Not specifying the serialization type for Int can lead to much more serious consequences than in the above examples. Unlike coins, int257 can be negative, which often surprises programmers. For instance, among live contracts on Tact, it's incredibly common to see amount: Int.
By itself, this doesn’t necessarily indicate a vulnerability, because this amount is usually encoded in a JettonTransfer message or in send(SendParameters{ value: amount, which uses coins and doesn’t allow negative amounts. However, in one case, we encountered a contract with a significant balance that allowed the user to set everything negative: reward, fee, amount, price, etc. Consequently, a malicious actor could have exploited the vulnerability.
In Ethereum, developers must be conscious of reentrancy, which is the ability to call the function of the same contract again before the current function execution has been completed. In TON, reentrancy is not possible.
Since TON is a system with asynchronous and parallel smart contract calls, it can be even more difficult to follow the order of actions processed. Any internal message will definitely be received by the destination account, and transaction consequences are processed after the transaction itself, but there are no other guarantees (see more about messages delivery).
We can’t predict whether Message 3 or Message 4 will be delivered first.
In this case, a Man-in-the-Middle attack of the message flow is highly possible. To be safe, developers should assume that each message is delivered in 1 to 100 seconds, and during that period, any other messages can be delivered. Here are some other aspects to keep in mind to boost security:
Here is the real example (buggy):
In the above example, the following steps occur:
There are several problems here:
In case of concurrency, TON is similar to traditional centralized multi-threaded systems.
Many contracts neglect the handling of bounced messages. However, Tact makes this process straightforward:
To decide whether the message should be sent in bounceable mode, consider two factors:
Here is an example in Jetton standard:
Keep in mind the following:
In some cases, reverting and handling the bounced message is not an option. The most common example is when your contract gets the TransferNotification about arrived jettons. Bouncing it will likely make the jettons blocked forever. Instead, you should use the try-catch block.
Let’s take a look at an example. In the EVM, when a transaction is reverted, all consequences are rolled back (except the gas—it is taken by the miner). In the TVM, the “transaction” is divided into a cascade of messages, so reverting just one will likely make the “group of contracts” inconsistent.
To handle that, you must manually check all conditions and, in case of emergency, send fixing messages back and forth. But since parsing payloads in an exception-free manner is cumbersome, it is preferable to use a try-catch block.
Below is a typical Jetton receiving code:
Notice that it will not function if the gas is insufficient, even for sending jettons back. Also notice that we send jettons back via the sender() “wallet”, not via our contract real jetton wallet, because anyone can send a TransferNotification message manually to fool us.
One of the most common problem classes we detect during auditing of TON contracts is gas management problems. There are two primary reasons for that:
We recommend the following:
It is fine to not send Excesses, but for contracts with big throughput like Jetton Master with a lot of BurnNotifications, or Jetton Wallet with a lot of incoming transfers, the amount accumulated can quickly expand.
In the next example, let’s assume that the contract wants to send forwardAmount to two child contracts as deposit:
As you can see, gas management requires significant attention, even in simple cases. Remember that you can’t use the SendRemainingValue flag in send() mode if you have already sent messages, unless you deliberately want to spend money from the contract balance itself.
Secure smart contract development in Tact will become increasingly more important as the TON ecosystem evolves. While Tact offers enhanced efficiency and simplicity, developers must remain vigilant to avoid common pitfalls. By understanding the common mistakes and implementing best practices, developers can harness the full potential of Tact to create robust and secure smart contracts. Continuous learning and adherence to security guidelines will ensure that the innovative capabilities of the TON blockchain are leveraged safely and effectively, contributing to a more secure and trustworthy blockchain environment.