CertiK and TON have jointly released the latest TON ecosystem developer guide, aiming to help developers avoid common errors and pitfalls when programming with the Tact language.
Author: Certik
Cover: Photo by ilgmyzin on Unsplash
TON (The Open Network), with its innovative features and powerful smart contract performance, is constantly expanding the boundaries of Blockchain technology. Based on the experience and lessons learned from early Blockchain platforms (such as Ethereum), TON provides developers with a more efficient and flexible development environment. One of the key elements driving this progress is the Tact programming language.
Tact is a brand-new programming language designed specifically for the TON Chain, with efficiency and conciseness as its core objectives. It is easy to learn and use, and perfectly fits with smart contracts. Tact is a statically-typed language with a simple syntax and a powerful type system.
However, many of the problems developers encountered when using FunC still exist in Tact development. The following will analyze some common errors in Tact development based on audit practice cases.
Data Structures
Optional Addresses
The Tact language simplifies the declaration, decoding, and encoding of data structures. However, developers still need to be cautious. Let's look at an example:
This is the declaration of the InternalTransfer message used for internal Jetton transfers according to the TEP-74[1] standard. Note the declaration of response_destination, which is of the Address type. In Tact, addresses are required to be non-zero. However, the reference implementation[2] of the Jetton standard allows for a zero address (addr_none), which is represented by two zero bits. This means that users or other contracts may attempt to send Jettons with a zero response address, and this operation will fail unexpectedly.
Additionally, if the Transfer message sent to a user's wallet allows setting the response_destination, but the InternalTransfer message from the sender's wallet to the recipient's wallet does not support this parameter, the Jetton will "fly out", meaning the Jetton cannot reach the target address, ultimately resulting in a loss. Later, we will discuss an exceptional case on how to properly handle bounced messages.
In this case, a better structural declaration that allows for a zero address should be Address?. However, in Tact, passing an optional address to the next message is currently cumbersome.
Data Serialization
In Tact, developers can specify the serialization method for fields.
In this example, totalAmount will be serialized as coins, while releasedAmount will be serialized as int257 (default is Int). releasedAmount can be negative and will occupy 257 bits. In most cases, omitting the serialization type will not cause problems; however, if the data involves communication, this becomes crucial.
Here is an example from a project we audited:
This data structure was used by an NFT project as a response to the on-chain get_static_data[3] request. According to the standard, the response should be:
The above index is uint256 (not int257), which means the returned data will be misinterpreted by the caller, leading to unpredictable results. The likely outcome is that the report_static_data handler will roll back, and the message flow will be interrupted as a result. These examples illustrate why considering data serialization is crucial, even when using Tact.
Signed Integers
Not specifying the serialization type of Int may lead to more severe consequences than the example above. Unlike coins, int257 can be negative, which often surprises programmers. For example, it is extremely common to see amount: Int in Tact's real-time contracts.
This alone does not necessarily mean there is a vulnerability, as the amount is usually encoded into the JettonTransfer message or passed to send(SendParameters{value: amount}), which uses the coins type and does not allow negative values. However, in one case, we found a contract with a large balance that allowed users to set all values, including rewards, fees, amounts, and prices, to negative numbers. Therefore, a malicious actor could exploit this vulnerability to carry out an attack.
Concurrency
Ethereum chain developers must be aware of reentrancy attacks, where a function can be called again before the current function execution is complete. On the TON chain, reentrancy attacks are impossible.
Since TON is a system that supports asynchronous and parallel smart contract calls, tracking the order of processing actions may become more challenging. Any internal messages will be received by the target account, and the transaction results will be processed after the transaction itself, but there are no other guarantees (for more information on message passing, please refer to the relevant documentation [4]).
We cannot predict whether message 3 or message 4 will arrive first.
In this case, man-in-the-middle attacks [5] are a common attack type in the message flow. To ensure security, developers should set the delivery time for each message to be between 1 and 100 seconds, during which any other messages may be delivered. Here are some other considerations to improve security:
1. Do not check or update the contract state for use in subsequent steps of the message flow.
2. Use the carry-value pattern [6]. Do not send information about values directly, but send them along with the message.
Here is a real-world example with a vulnerability:
In the above example, the following steps occur:
1. The user sends jettons to the NftCollection through the collection_jetton_wallet.
2. A TransferNotification is sent to the NftCollection contract, and the contract records the received_jetton_amount.
3. The contract forwards the jettons to the owner of the NftCollection.
4. An Excesses message is sent to the NftCollection as the response_destination.
5. The NftItem is deployed in the Excesses handler, using the received_jetton_amount.
There are a few issues to note here:
First, the Excesses message is not guaranteed to be delivered according to the jetton standard. If there is not enough gas to send the Excesses message, it will be skipped, and the message flow will stop.
Second, updating the received_jetton_amount and using it in the subsequent steps makes the system vulnerable to concurrent execution. Other users may simultaneously send another amount and overwrite the saved amount, which could also be maliciously exploited to profit from it.
In the case of concurrency, TON is similar to traditional centralized multi-threaded systems.
Handling Returned Messages
Many contracts ignore the handling of returned messages. However, Tact makes this process straightforward:
To decide whether a message should be sent in a returnable mode, consider two factors:
1. If the message fails, who should receive the additional Toncoin? If the target should receive the funds, rather than the sending contract, then send the message in a non-returnable mode [7].
2. If the next message is rejected, what will happen to the message flow? If processing the returned message can restore a consistent state, then it's best to handle it. If not, it's better to modify the message flow.
Here's an example from the jetton standard [8]:
1. The Excesses message is sent in a non-returnable mode, as the contract does not need to return the toncoins.
2. The TransferNotification message is sent in a non-returnable mode, as the forward_ton_amount belongs to the caller, and the contract will not retain it.
3. In contrast, the BurnNotification is sent in a returnable mode, as if it is rejected by the jetton master contract, the wallet needs to restore its balance to maintain the total_supply consistency.
4. The InternalTransfer is also returnable. If the recipient rejects the funds, the sender's wallet must update the balance.
Remember the following:
1. Returned messages only receive the original 256-bit [9] message; after message identification, the effective data is only 224 bits. So, you will get limited information about the failed operation, usually some amount stored as coins.
2. If there is not enough gas, the returned message will not be delivered.
3. Returned messages cannot be returned again.
Returning Jettons
In some cases, reverting and handling returned messages is not an option. The most common example is when your contract receives a TransferNotification about arriving jettons, and returning the message could result in the jettons being locked forever. Instead, you should use a try-catch block [10] to handle it.
Let's look at an example. In the EVM, when a transaction is reverted, all the results are rolled back (except for the gas - it is taken by the miners). But in the TVM, a "transaction" is decomposed into a series of messages, so reverting just one message is likely to leave the "contract group" in an inconsistent state.
To solve this, you must manually check all the conditions and send corrective messages back and forth in emergency situations. However, as parsing the payload is very tedious without exceptions, it is best to use a try-catch block.
Here is a typical example of Jetton receiving code:
Note that even sending the jettons back will not work if there is not enough gas. Additionally, it's important to note that we are returning the jettons through the "wallet" of the sender(), not through the actual jetton wallet of our contract. This is because anyone can manually send a TransferNotification message to trick us.
Managing Gas Fees
One of the most common issues when auditing TON contracts is gas fee management. There are two main reasons for this:
1. Lack of gas fee control can lead to the following issues:
Incomplete execution of the message flow: Some operations may take effect, while others are reverted due to insufficient gas. For example, if the reward retrieval operation is completed in the jetton wallet, but the share destruction operation in the jetton master contract is ignored, the entire contract group will become inconsistent.
Users can withdraw their contract balance: Additionally, the contract may accumulate too much Toncoin.
2. TON contract developers find it difficult to manage and control gas: Tact developers need to obtain the gas consumption through testing and update the corresponding values each time they update the message flow during the development process.
We recommend the following approach:
1. Identify the "entry points": these are all message handlers that can accept messages from the "outside", i.e., from end-users or other contracts (such as Jetton wallets).
2. For each entry point, map out all possible paths and calculate the gas consumption. Use printTransactionFees() (available in @ton/sandbox, which is provided with Blueprint[11]).
3. If contracts can be deployed during the message flow, assume that they will be deployed. Deployment will consume more gas and storage fees.
4. At each entry point, add the minimum gas requirement based on the situation.
5. If the handler does not send more messages (the message flow terminates here), it's best to return the Excesses, as shown below:
Not sending the Excesses is also possible, but for high-throughput contracts like the Jetton Master, or contracts with a lot of BurnNotification messages or a large number of incoming transfers, the accumulated amount can grow quickly.
6. If the handler only sends one message - including emit(), which is actually an external message - the simplest way is to forward() the remaining gas (see above).
7. If the handler sends multiple messages, or if the communication involves ton amounts, calculating the amount to send is easier than calculating the amount to leave.
In the next example, suppose the contract wants to send the forwardAmount to two sub-contracts as a deposit:
As you can see, gas fee management requires high attention, even in simple cases. Note that if you have already sent a message, you cannot use the SendRemainingValue flag in send() mode, unless you deliberately want to spend funds from the contract balance.
Conclusion
As the TON ecosystem evolves, the secure development of Tact smart contracts will become increasingly important. While Tact provides higher efficiency and conciseness, developers must remain vigilant and avoid common pitfalls. By understanding common mistakes and implementing best practices, developers can fully unleash the potential of Tact to create powerful and secure smart contracts. Continuous learning and following security practice guidelines will ensure that the innovative capabilities of the TON ecosystem are utilized safely and effectively, contributing to a more secure and trustworthy blockchain environment.
[1] TEP-74: https://github.com/ton-blockchain/TEPs/blob/master/text/0074-jettons-standard.md#1-transfer[2] Reference implementation: https://github.com/ton-blockchain/token-contract/[3] get_static_data: https://github.com/ton-blockchain/TEPs/blob/master/text/0062-nft-standard.md#2-get_static_data[4] Related documentation: https://docs.ton.org/develop/smart-contracts/guidelines/message-delivery-guarantees#message-delivery[5] Man-in-the-middle attack: https://docs.ton.org/develop/smart-contracts/security/secure-programming#3-expect-a-man-in-the-middle-of-the-message-flow[6] Carry value pattern: https://docs.ton.org/develop/smart-contracts/security/secure-programming#4-use-a-carry-value-pattern[7] Non-bouncable mode: https://docs.ton.org/develop/smart-contracts/guidelines/non-bouncable-messages[8] Jetton standard: https://github.com/ton-blockchain/TEPs/blob/master/text/0074-jettons-standard.md#1-transfer[9] Only accept 256 bits: https://docs.tact-lang.org/book/bounced/#caveats[10] try-catch blocks: https://docs.tact-lang.org/book/statements#try-catch[11] Blueprint: https://github.com/ton-org/blueprint?tab=readme-ov-file#overviewDisclaimer: As a Block chain information platform, the articles published on this site only represent the personal views of the authors and guests, and are not related to the position of Web3Caff. The information in the articles is for reference only and does not constitute any investment advice or offer, and please comply with the relevant laws and regulations of the country or region where you are located.
Welcome to join the official Web3Caff community: X(Twitter) account | WeChat reader group | WeChat public account | Telegram subscription group | Telegram discussion group