Skip to main content

Secure smart contract programming

In this section, we’ll explore some of the most interesting features of the TON blockchain and walk through a list of best practices for developers programming smart contracts in FunC.

Contract sharding

When developing contracts for the EVM, you typically split the project into multiple contracts for convenience. In some cases, it’s possible to implement all functionality in a single contract, and even when splitting is necessary, such as with Liquidity Pairs in an Automated Market Maker, it doesn’t introduce significant complexity. Transactions are executed entirely: either everything succeeds, or everything reverts.

In TON, it’s strongly recommended to avoid “unbounded data structures” and split a single logical contract into smaller pieces, each managing a small amount of data. A basic example is the implementation of TON Jettons, TON’s version of Ethereum’s ERC-20 token standard. In short:

  1. One jetton-minter stores total_supply, minter_address, and references to token metadata and jetton_wallet_code.
  2. Multiple jetton-wallet contracts, one for each token owner, store the owner’s address, their balance, the jetton-minter address, and a reference to jetton_wallet_code.

This design ensures that Jetton transfers occur directly between wallets without affecting high-load addresses, enabling parallel transaction processing. As a result, your contract will likely become a “group of contracts” that actively interact with each other.

Partial execution of transactions is possible

smart1.png

A unique property of TON smart contracts is the possibility of partial transaction execution. For example, consider the message flow of a standard TON Jetton:

  1. The sender sends an op::transfer message to its wallet (sender_wallet).
  2. sender_wallet reduces the token balance.
  3. sender_wallet sends an op::internal_transfer message to the recipient’s wallet (destination_wallet).
  4. destination_wallet increases its token balance.
  5. destination_wallet sends an op::transfer_notification to its owner (destination).
  6. destination_wallet returns excess gas with an op::excesses message to the response_destination, usually the sender.

If destination_wallet fails to process the op::internal_transfer message, such as due to an exception or insufficient gas, this step and subsequent ones won’t execute. However, the first step, reducing the balance in sender_wallet, will still complete. This results in partial transaction execution, an inconsistent state of the Jetton, and potential loss of funds.

In the worst-case scenario, all tokens could be stolen this way. For example, if you first credit bonuses to a user and then send an op::burn message to their Jetton wallet, you cannot guarantee that the op::burn will succeed.

TON smart contract developers must control gas

In Solidity, gas management is less of a concern for contract developers. If a user provides insufficient gas, the transaction reverts, though the gas is not refunded. If they provide enough gas, the actual costs are automatically calculated and deducted from their balance.

In TON, the situation is different:

  1. If there’s insufficient gas, the transaction may partially execute.
  2. If there’s excess gas, the developer must return it.
  3. If a “group of contracts” exchanges messages, gas control and calculation must occur in each message.

TON cannot automatically calculate gas costs. Since transaction execution can take a long time, the user might run out of TON coins by the end. This is where the carry-value principle comes into play.

TON smart contract developers must manage storage

A typical message handler in TON follows this approach:

() handle_something(...) impure {
(int total_supply, <a lot of vars>) = load_data();
... ;; do something, change data
save_data(total_supply, <a lot of vars>);
}

Unfortunately, <a lot of vars> often involves enumerating all contract data fields. For example:

(
int total_supply, int swap_fee, int min_amount, int is_stopped, int user_count, int max_user_count,
slice admin_address, slice router_address, slice jettonA_address, slice jettonA_wallet_address,
int jettonA_balance, int jettonA_pending_balance, slice jettonB_address, slice jettonB_wallet_address,
int jettonB_balance, int jettonB_pending_balance, int mining_amount, int datetime_amount, int minable_time,
int half_life, int last_index, int last_mined, cell mining_rate_cell, cell user_info_dict, cell operation_gas,
cell content, cell lp_wallet_code
) = load_data();

This approach has several drawbacks:

  1. Adding a new field requires updating load_data()/save_data() statements throughout the contract, which is labor-intensive and error-prone.
  2. Namespace pollution can lead to bugs, such as shadowing a storage field with a local variable, which can overwrite the contract state.
  3. Parsing and repacking the entire storage on every function call increases gas costs.

In a recent CertiK audit, we discovered that the developer mixed up two arguments in the save_data() function:

save_data(total_supply, min_amount, swap_fee, ...)  

Without an external audit by a team of experts, detecting such a bug is challenging. The function was rarely used, and the confused parameters often had a value of zero. Identifying this type of error requires knowing exactly what to look for.
Secondly, "namespace pollution" is a common issue. For example, in another audit, we found the following code in the middle of a function:

int min_amount = in_msg_body~load_coins();  

Here, a local variable shadowed a storage field, and at the end of the function, the overwritten value was stored in the contract state. This allowed an attacker to manipulate the contract’s state. The problem is exacerbated by FunC’s allowance of variable redeclaration: “This is not a declaration, but just a compile-time insurance that min_amount has type int.”

Tips

1. Always draw message flow diagrams

Even in a simple contract like a TON Jetton, there are numerous messages, senders, receivers, and data pieces. For more complex contracts like decentralized exchanges (DEXs), the number of messages in a single workflow can exceed ten.

smart2.png

At CertiK, we use the DOT language to describe and update such diagrams during audits. This helps auditors visualize and understand complex interactions within and between contracts.

2. Avoid failures and handle bounced messages

Define the entry point in your message flow—the message that initiates the cascade of messages in your contract group. Perform all necessary checks (payload, gas supply, etc.) here to minimize the chance of failure in subsequent stages.

If you’re unsure whether all conditions will be met, such as whether the user has enough tokens, the message flow is likely designed incorrectly.

In subsequent messages, use throw_if()/throw_unless() as assertions rather than checks. Many contracts also handle bounced messages as a precaution.

For example, in TON Jetton, if the recipient’s wallet cannot accept tokens, the sender’s wallet processes the bounced message and returns the tokens to its balance.

() on_bounce (slice in_msg_body) impure {
in_msg_body~skip_bits(32); ;;0xFFFFFFFF

(int balance, slice owner_address, slice jetton_master_address, cell jetton_wallet_code) = load_data();

int op = in_msg_body~load_op();

throw_unless(error::unknown_op, (op == op::internal_transfer) | (op == op::burn_notification));

int query_id = in_msg_body~load_query_id();
int jetton_amount = in_msg_body~load_coins();

balance += jetton_amount;
save_data(balance, owner_address, jetton_master_address, jetton_wallet_code);
}

While handling bounced messages is recommended, it’s not a complete solution. Bounced messages require gas, and if the sender doesn’t provide enough, the bounce won’t occur. Additionally, TON doesn’t support chain bounces, so a bounced message cannot be re-bounced.

3. Expect a man-in-the-middle attack

A message cascade can span multiple blocks. Assume that while one message flow is running, an attacker can initiate a parallel flow. If a property, such as the user’s token balance, is checked at the start, don’t assume it will remain valid at later stages.

4. Use the carry-value pattern

Messages between contracts must carry valuables. In TON Jetton, sender_wallet subtracts the balance and sends it with an op::internal_transfer message to destination_wallet, which then adds the balance to its own or bounces it back.

An incorrect implementation would be querying the Jetton balance on-chain. By the time the response to op::get_balance arrives, the balance might have already been spent.

Instead, implement an alternative flow:

  1. The master sends an op::provide_balance message to the wallet.
  2. The wallet zeroes its balance and sends back op::take_balance.
  3. The master receives the funds, decides if they’re sufficient, and either uses them or returns them to the wallet.

5. Return value instead of rejecting

Contracts often receive requests with values. Instead of rejecting invalid requests via throw_unless(), return the value to the sender.

For example, in TON Jetton:

  1. The sender sends an op::transfer message via sender_wallet to your_contract_wallet, specifying forward_ton_amount and forward_payload.
  2. sender_wallet sends an op::internal_transfer message to your_contract_wallet.
  3. your_contract_wallet sends an op::transfer_notification message to your_contract, delivering forward_ton_amount, forward_payload, sender_address, and jetton_amount.
  4. Your contract processes the request in handle_transfer_notification().

At this stage, avoid throw_if()/throw_unless(), as it could result in lost Jettons. Use try-catch statements, available in FunC v0.4.0+, to handle errors and return Jettons if necessary.

6. Calculate gas and check msg_value

Estimate the gas cost of each handler in your message flow and check the sufficiency of msg_value. Avoid demanding excessive gas, as it must be divided among subsequent messages. Recalculate gas requirements if your code starts sending more messages.

7. Return gas excesses carefully

If excess gas isn’t returned to the sender, funds will accumulate in your contracts. While not catastrophic, this is suboptimal. Popular contracts like TON Jetton return excess gas with an op::excesses message.

TON provides a useful mechanism: SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE = 64. This mode forwards remaining gas with the message. However, avoid this mode if:

  1. Your contract has no other non-linear handlers, as storage fees will deplete the contract balance.
  2. Your contract emits events, sending messages to external addresses, as the cost is deducted from the contract balance.
  3. Your contract attaches value to messages or uses SEND_MODE_PAY_FEES_SEPARATELY = 1, as these actions deduct from the contract balance.

In these cases, manually calculate and return excess gas:

int ton_balance_before_msg = my_ton_balance - msg_value;
int storage_fee = const::min_tons_for_storage - min(ton_balance_before_msg, const::min_tons_for_storage);
msg_value -= storage_fee + const::gas_consumption;

if(forward_ton_amount) {
msg_value -= (forward_ton_amount + fwd_fee);
...
}

if (msg_value > 0) { ;; return excess gas
var msg = begin_cell()
.store_uint(0x10, 6)
.store_slice(response_address)
.store_coins(msg_value)
...
}

Ensure the contract balance doesn’t run out, as this could lead to partial transaction execution.

8. Use nested storage

Organize storage into blocks of related data:

() handle_something(...) impure {
(slice swap_data, cell liquidity_data, cell mining_data, cell discovery_data) = load_data();
(int total_supply, int swap_fee, int min_amount, int is_stopped) = swap_data.parse_swap_data();

swap_data = pack_swap_data(total_supply + lp_amount, swap_fee, min_amount, is_stopped);
save_data(swap_data, liquidity_data, mining_data, discovery_data);
}

This approach minimizes changes when adding new fields and avoids namespace pollution. If storage contains many fields, group them hierarchically.

9. Use end_parse()

Use end_parse() when reading data from storage or message payloads. This ensures you read as much as you write, preventing hard-to-debug issues.

10. Use helper functions and avoid magic numbers

Write wrappers, helper functions, and declare constants to improve code readability. Avoid magic numbers, as they make the code harder to understand and maintain.

For example, instead of:

var msg = begin_cell()
.store_uint(0xc4ff, 17) ;; 0 11000100 0xff
.store_uint(config_addr, 256)
.store_grams(1 << 30) ;; ~1 gram of value
.store_uint(0, 107)
.store_uint(0x4e565354, 32)
.store_uint(query_id, 64)
.store_ref(vset);

send_raw_message(msg.end_cell(), 1);

Use:

const int SEND_MODE_REGULAR = 0;
const int SEND_MODE_PAY_FEES_SEPARATELY = 1;
const int SEND_MODE_IGNORE_ERRORS = 2;
const int SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE = 64;

builder store_msgbody_prefix_stateinit(builder b) inline {
return b.store_uint(4 + 2 + 1, 1 + 4 + 4 + 64 + 32 + 1 + 1 + 1);
}

builder store_body_header(builder b, int op, int query_id) inline {
return b.store_uint(op, 32).store_uint(query_id, 64);
}

() 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_address_by_state_init(state_init);

var msg = begin_cell()
.store_msg_flags(BOUNCEABLE)
.store_slice(to_wallet_address)
.store_coins(amount)
.store_msgbody_prefix_stateinit()
.store_ref(state_init)
.store_ref(master_msg);

send_raw_message(msg.end_cell(), SEND_MODE_REGULAR);
}

References

Originally written by CertiK