Cointime

Download App
iOS & Android

Secure Smart Contract Programming in FunC: Top 10 Tips for TON Developers

Validated Project

TON is an innovative blockchain that incorporates novel concepts in smart contract design. Coming a number of years after Ethereum, the builders of TON had the advantage of being able to evaluate the successes and failures of the Ethereum Virtual Machine (EVM) model.

In this article, we’ll go through a couple of the most interesting features of the TON blockchain, then we’ll go through a list of best practices for developers programming smart contracts in FunC.

Contract Sharding

When developing contracts for the EVM, you generally break up the project into several contracts for convenience. In some cases, it is possible to implement all the functionality in one contract, and even where contract splitting was necessary (for example, Liquidity Pairs in the Automated Market Maker) this did not lead to any special difficulties. Transactions are executed in their entirety: either everything works out, or everything reverts.

In TON, it is strongly recommended to avoid “unbounded data structures” and split a single logical contract into small pieces, each of which manages a small amount of data. The basic example is the implementation of TON Jettons. This is TON's version of Ethereum's ERC-20 token standard. Briefly, we have:

  1. One jetton-minter that stores total_supplyminter_address, and a couple of refs: token description (metadata) and jetton_wallet_code.
  2. And a lot of jetton-wallet, one for each owner of these jettons. Each such wallet stores only the owner's address, their balance, jetton-minter address, and a link to jetton_wallet_code.

This is necessary so that the transfer of Jettons occurs directly between wallets and does not affect any high-load addresses, which is fundamental for the parallel processing of transactions.

That is, get ready so that your contract turns into a "group of contracts", and they will actively interact with each other.

What follows from this?

Partial Execution of Transactions is Possible

A new unique property appears in the logic of your contract: partial execution of transactions.

For example, consider the message flow of a standard TON Jetton:

As follows from the diagram,

  1. 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 op::transfer_notification to its owner (destination)
  6. destination_wallet returns excess gas with op::excesses message on response_destination (usually sender)

Note that if the destination_wallet was unable to process the op::internal_transfer message (an exception occurred or the gas ran out), then this part and subsequent steps will not be executed. But the first step (reducing the balance in sender_wallet) will be completed. The result is a partial execution of the transaction, an inconsistent state of the Jetton, and, in this case, the loss of money.

In the worst case scenario, all the tokens can be stolen in this way. Imagine that you first accrue bonuses to the user, and then send an op::burn message to their Jetton wallet, but you cannot guarantee that the op::burn will be processed successfully.

Tip #1: Always Draw Message Flow Diagrams

Even in a simple contract like a TON Jetton, there are already quite a few messages, senders, receivers, and pieces of data contained in messages. Now imagine how it looks when you’re developing something a little more complex, like a decentralized exchange (DEX) where the number of messages in one workflow can exceed ten.

At CertiK, we use the DOT language to describe and update such diagrams during the course of the audit. Our auditors find that this helps them visualize and understand the complex interactions within and between contracts.

Tip #2: Avoid Fails and Catch Bounced Messages

Using the message flow, first define the entry point. This is the message that starts the cascade of messages in your group of contracts (“consequences”). It is here that everything needs to be checked (payload, gas supply, etc.) in order to minimize the possibility of failure in subsequent stages.

If you are not sure whether it will be possible to fulfill all your plans (for example, whether the user has enough tokens to complete the deal), it means that the message flow is probably built incorrectly.

In subsequent messages (consequences), all throw_if()/throw_unless() will play the role of asserts rather than actually checking something.

Many contracts also process bounced messages just in case.

For example, in TON Jetton, if the recipient's wallet cannot accept any tokens (it depends on the logic for receiving), then the sender's wallet will process the bounced message and return the tokens to its own 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);
}

In general, we recommend processing bounced messages, however, they can’t be used as a means of full protection from failed message processing and incomplete execution.

It takes gas to send a bounced message and process it, and if there isn’t enough provided by the sender, then no bounced.

Secondly, TON does not provide for a chain of jumps. This means a bounced message can't be re-bounced. For example, if the second message is sent after the entry message, and the second one triggers the third one, then the entry contract will not be aware of the failure of processing the third message. Similarly, if the processing of the first sends the second and the third, then the failure of the second will not affect the processing of the third.

Tip #3: Expect a Man-in-the-Middle of the Message Flow

A message cascade can be processed over many blocks. Assume that while one message flow is running, an attacker can initiate a second one in parallel. That is, if a property was checked at the beginning (e.g. whether the user has enough tokens), do not assume that at the third stage in the same contract they will still satisfy this property.

Tip #4: Use a Carry-Value Pattern

It follows from the previous paragraph that messages between contracts must carry valuables.

In the same TON Jetton, this is demonstrated: sender_wallet subtracts the balance and sends it with an op::internal_transfer message to destination_wallet, and it, in turn, receives the balance with the message and adds it to its own balance (or bounces it back).

And here is an example of incorrect implementation. Why can't you find out your Jetton balance on-chain? Because such a question does not fit the pattern. By the time the response to the op::get_balance message reaches the requester, this balance could already have been spent by someone.

In this case, you can implement an alternative:

  1. master sends a message op::provide_balance to the wallet
  2. wallet zeroes its balance and sends back op::take_balance
  3. master receives the money, decides if it has enough, and either uses it (debits something in return) or sends it back to wallet

Tip #5: Return Value Instead of Rejecting

From the previous observation, it follows that your group of contracts will often receive not just a request, but a request along with a value. So you can't just refuse to execute the request (via throw_unless()), you have to send the Jettons back to the sender.

For example, a typical flow start (see TON Jetton message flow):

  1. sender sends an op::transfer message via sender_wallet to your_contract_wallet, specifying forward_ton_amount and forward_payload for your contract.
  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_amountforward_payload, and the sender_address and jetton_amount.
  4. And here in your contract in handle_transfer_notification() the flow begins.

There you need to figure out what kind of request it was, whether there is enough gas to complete it, and whether everything is correct in the payload. At this stage, you should not use throw_if()/throw_unless(), because then the Jettons will simply be lost, and the request will not be executed. It's worth using the try-catch statements available starting from FunC v0.4.0.

If something does not meet the expectations of your contract, the Jettons must be returned.

We found an example of this vulnerable implementation in a recent audit.

() handle_transfer_notification(...) impure {
...
    int jetton_amount = in_msg_body~load_coins();
    slice from_address = in_msg_body~load_msg_addr();
    slice from_jetton_address = in_msg_body~load_msg_addr();

    if (msg_value < gas_consumption) { ;; not enough gas provided
        if (equal_slices(from_jetton_address, jettonA_address)) {
            var msg = begin_cell()
                .store_uint(0x18, 6)
                .store_slice(jettonA_wallet_address)
                .store_coins(0)
                .store_uint(1, 1 + 4 + 4 + 64 + 32 + 1 + 1)
            ...
        }
    ...
}

As you can see, here the "return" is sent to jettonA_wallet_address, not sender_address. Since all decisions are made based on the analysis of in_msg_body, an attacker can forge a fake message and extract the money. Always send the return to sender_address.

If your contract accepts Jettons, it is impossible to know if it was indeed the expected Jetton that came, or just an op::transfer_notification from someone.

If your contract receives unexpected or unknown Jettons, they must also be returned.

Control the Gas

In Solidity, gas is not much of a concern for contract developers. If the user provides too little gas, everything will be reverted as if nothing had happened (but the gas will not be returned). If they provide enough, the actual costs will automatically be calculated and deducted from their balance.

In TON, the situation is different:

  1. If there is not enough gas, the transaction will be partially executed.
  2. If there is too much gas, the excess must be returned. This is the developer’s responsibility.
  3. If a “group of contracts” exchanges messages, then control and calculation must be carried out in each message.

TON cannot automatically calculate the gas. The complete execution of the transaction with all its consequences can take a long time, and by the end, the user may not have enough toncoins in their wallet. The carry-value principle is used again here.

Tip #6: Calculate Gas and Check msg_value

According to our message flow diagram, we can estimate the cost of each handler in each of the scenarios and insert a check for the sufficiency of msg_value.

You can’t just demand with a margin, say 1 TON (the gas_limit on mainnet as of the date of writing) because this gas must be divided among the “consequences”. Let's say your contract sends three messages, then you can only send 0.33 TON to each. This means that they should “demand” less. It’s important to calculate the gas requirements of your whole contract carefully.

Things get more complicated if, during development, your code starts sending more messages. Gas requirements need to be rechecked and updated.

Tip #7: Return Gas Excesses Carefully

If excess gas is not returned to the sender, the funds will accumulate in your contracts over time. In principle, nothing terrible, this is just suboptimal practice. You can add a function for raking out excesses, but popular contracts like TON Jetton still return to the sender with the message op::excesses.

TON has a useful mechanism: SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE = 64. When using this mode in send_raw_message(), the rest of the gas will be forwarded further with the message (or back) to the new recipient. It is convenient if the message flow is linear: each message handler sends only one message. But there are cases when it is not recommended to use this mechanism:

  • 1. If there are no other non-linear handlers in your contract. storage_fee is deducted from the balance of the contract, and not from the incoming gas. This means that over time, storage_fee can eat up the entire balance because everything that comes in has to go out.
  • 2. If your contract emits events, i.e. sends a message to an external address. The cost of this action is deducted from the balance of the contract, and not from msg_value.
() emit_log_simple (int event_id, int query_id) impure inline {
    var msg = begin_cell()
        .store_uint (12, 4)             ;; ext_out_msg_info$11 addr$00
        .store_uint (1, 2)              ;; addr_extern$01
        .store_uint (256, 9)            ;; len:(## 9)
        .store_uint(event_id, 256);     ;; external_address:(bits len)
        .store_uint(0, 64 + 32 + 1 + 1) ;; lt, at, init, body
        .store_query_id(query_id)
        .end_cell();

    send_raw_message(msg, SEND_MODE_REGULAR);
}
  • 3. If your contract attaches value when sending messages or uses SEND_MODE_PAY_FEES_SEPARETELY = 1. These actions deduct from the balance of the contract, which means that returning unused is "working at a loss."

In the indicated cases, a manual approximate calculation of the surplus is used:

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) {    ;; there is still something to return

var msg = begin_cell()
    .store_uint(0x10, 6)
    .store_slice(response_address)
    .store_coins(msg_value)
...

Remember, if the value of the contract balance runs out, the transaction will be partially executed, and this cannot be allowed.

Manage the 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, we are noticing a trend: <a lot of vars> is a real enumeration of 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 a number of disadvantages.

First, if you decide to add another field, say is_paused, then you need to update the load_data()/save_data() statements throughout the contract. And this is not only labor-intensive, but it also leads to hard-to-catch errors.

In a recent CertiK audit, we noticed the developer mixed up two arguments in places, and wrote:

save_data(total_supply, min_amount, swap_fee, ...

Without an external audit performed by a team of experts, finding such a bug is very difficult. The function was rarely used, and both confused parameters usually had a value of zero. You really have to know what you’re looking for to pick up on an error like this.

Secondly, there is "namespace pollution". Let's explain what the problem is with another example from an audit. In the middle of the function, the input parameter read:

int min_amount = in_msg_body~load_coins();

That is, there was a shadowing of the storage field by a local variable, and at the end of the function, this replaced value was stored in storage. The attacker had the opportunity to overwrite the state of the contract. The situation is aggravated by the fact that FunC allows the redeclaration of variables: “This is not a declaration, but just a compile-time insurance that min_amount has type int.”

And finally, parsing the entire storage and packing it back on every call to every function increases the gas cost.

Tip #8: Use Nested Storage

We recommend the following storage organization approach:

() 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);
}

Storage consists of blocks of related data. If a parameter is used in each function, for example, is_paused, then it is provided immediately by load_data(). If a parameter group is needed only in one scenario, then it does not need to be unpacked, it will not have to be packed, and it will not clog the namespace.

If the storage structure requires changes (usually adding a new field), then much fewer edits will have to be made.

Moreover, the approach can be repeated. If there are 30 storage fields in our contract, then initially you can get four groups, and then get a couple of variables and another subgroup from the first group. The main thing is not to overdo it.

Note that since a cell can store up to 1023 bits of data and up to 4 references, you will have to split the data into different cells anyway.

Hierarchical data is one of the main features of TON, let's use it for its intended purpose.

Global variables can be used, especially at the prototyping stage, where it is not entirely obvious what will be stored in the contract.

global int var1;
global cell var2;
global slice var3;

() load_data() impure {
    var cs = get_data().begin_parse();
    var1 = cs~load_coins();
    var2 = cs~load_ref();
    var3 = cs~load_bits(512);
}

() save_data() impure {
    set_data(
        begin_cell()
            .store_coins(var1)
            .store_ref(var2)
            .store_bits(var3)
            .end_cell()
        );
}

That way, if you find out that you need another variable, you just add a new global variable and modify load_data() and save_data(). No changes throughout the contract are needed. However, since there is a limitation on the number of global variables (no more than 31), this pattern can be combined with the "nested storage" recommended above.

Global variables are also often more expensive than storing everything on the stack. This, however, depends on the number of stack permutations, so it is a good idea to prototype with globals and, when storage structure is completely clear, to switch to on stack variables with a "nested" pattern.

Tip #9: Use end_parse()

Use end_parse() wherever possible when reading data from storage and from the message payload. Since TON uses bit streams with variable data format, it’s helpful to ensure that you read as much as you write. This can save you an hour of debugging.

Tip #10: Use More Helper Functions, and Avoid Magic Numbers

This tip is not unique to FunC but it is especially relevant here. Write more wrappers, and helper functions, and declare more constants.

FunC initially has an incredible amount of magic numbers. If the developer does not make any effort to limit their usage, the result is something like this:

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);

This is code from a real project, and it scares off newbies.

Fortunately, in recent versions of FunC, a couple of standard declarations can make the code clearer and more expressive. For example:

const int SEND_MODE_REGULAR = 0;
const int SEND_MODE_PAY_FEES_SEPARETELY = 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);
}

We love expressive code!

Conclusion

In this article, we’ve gone through some of the unique features developers will encounter when programming smart contracts on TON using FunC. We’ve also provided 10 tips we’ve learned throughout the course of our audits that will make your experience as smooth as possible.

We’re excited to be supporting the TON ecosystem and working with the many great projects being built on this new and innovative blockchain platform.

For an audit of your smart contract code, or to consult with our team of auditors and security experts, don’t hesitate to get in touch.

Read more: https://www.certik.com/resources/blog/7hZP8ovTGqluDNSdQ1AtaM-secure-smart-contract-programming-in-func-top-10-tips-for-ton-developers

Comments

All Comments

Recommended for you

  • US Spot Ethereum ETF Sees Net Outflow of $4.93 Million

    On June 13, according to monitoring by Trader T, the US spot Ethereum ETF experienced a net outflow of $4.93 million yesterday.

  • US Spot Bitcoin ETF Sees Net Inflow of $85.82 Million Yesterday

    On June 13, according to monitoring by Trader T, the US spot Bitcoin ETF recorded a net inflow of $85.82 million yesterday.

  • U.S. Bans Foreign Access to Fable 5 and Mythos 5; Anthropic Issues Detailed Rebuttal

    On June 13, Anthropic issued a statement announcing that the U.S. government, citing national security powers, has released an export control directive requiring the suspension of all access to the AI models Fable 5 and Mythos 5 by foreign entities, regardless of whether the individuals are within the U.S., including Anthropic employees who are foreign nationals. The practical effect of this order is that we must immediately disable access to Fable 5 and Mythos 5 for all customers to ensure compliance. Access to all other Anthropic models will not be affected. We received the government's directive at 5:21 PM (Eastern Time) today. The letter did not specify the details of its national security concerns. Our understanding is that the government believes it has become aware of a method to bypass or 'jailbreak' Fable 5. So far, the government has only provided us with verbal evidence suggesting the existence of a potential narrow, non-general jailbreak, essentially by requiring the model to read specific code libraries and fix any software defects. We are complying with the government's legitimate directive and are in the process of removing all users' access to Fable 5 and Mythos 5. However, we disagree with the conclusion that 'a narrow potential jailbreak vulnerability should be the reason to recall commercial models deployed to hundreds of millions of users.' (Jinshi)

  • Iranian Foreign Minister: Iran-U.S. Memorandum of Understanding May Be Signed in Days

    On June 13, Iranian media reported that Iranian Foreign Minister Amir-Abdollahian stated that once the final stage of negotiations between Iran and the U.S. is completed, the memorandum of understanding will be signed and announced immediately. The first phase will be signed electronically from a distance, "which may happen in the coming days." (Xinhua News Agency)

  • U.S. Officials: U.S. and Iran Close to Agreement, Signing Expected in Coming Days

    On June 13, Reuters reported that a senior U.S. official stated on Friday local time that the U.S. and Iran have not yet truly reached the finish line, but are very close to finalizing an agreement to resolve their conflicts. Washington expects to sign the agreement in the coming days. 'The negotiating team has put us in a very favorable position, but we still need to see, we haven't really reached the finish line, but we are very close,' the U.S. official said. The official noted that the agreed terms achieve a core goal of Trump. The memorandum of understanding includes the reopening of the Strait of Hormuz and the lifting of U.S. blockades on Iranian ports. Iran's highly enriched uranium will also be destroyed on-site and subsequently removed from the country. 'Iran will not gain anything from signing the memorandum or from the negotiations themselves,' the official said. 'They will receive economic rewards for fulfilling the obligations set forth in the agreement. Therefore, if they commit to handing over nuclear materials, they will gain something. If they dismantle their nuclear program or facilities, they will receive additional benefits.'

  • Iran's Foreign Ministry: Iran is Reviewing Draft Memorandum of Understanding

    On June 13, local time on the 12th, Iranian Foreign Ministry spokesperson Baghaei stated that Iran and the United States have reached an understanding on most issues, and Iran is currently in the final stages of compiling the text of the memorandum of understanding. Therefore, the previous statement by Iranian Foreign Minister Amir-Abdollahian that 'the two sides are very close to reaching an understanding' is accurate and noteworthy. Meetings of relevant decision-making bodies are ongoing, and this is a process that is being continuously advanced. To achieve a final and decisive outcome, consensus must be formed among decision-making bodies and relevant departments. Baghaei also mentioned that various speculations regarding the content of the agreement text have not been confirmed. Although specific details of the diplomatic process cannot be publicly discussed at this time, this does not mean that the public does not have the right to be informed. (CCTV News)

  • SpaceX Opens at $150 on First Day of Trading, IPO Price Set at $135

    On June 12, SpaceX opened at $150 on its first day of trading, with an IPO price set at $135.

  • Iranian Foreign Minister Claims Iran and US 'Have Never Been Closer' to Memorandum of Understanding

    On June 12, Iranian Foreign Minister Amir-Abdollahian stated on social media that Iran and the US 'have never been closer' to reaching a memorandum of understanding. He urged the media to refrain from speculating on its contents before finalization. The Iranian side will disclose all details in due course. (CCTV News)

  • BTC Surpasses $64,000

    Market data shows that BTC has surpassed $64,000, currently priced at $64,107.99, with a 24-hour increase of 2.18%. The market is experiencing significant volatility, so please ensure proper risk management.

  • ARM Soars Nearly 10%, Bank of America Predicts Server CPU Market to Quadruple by 2030

    On June 12, ARM surged nearly 10%, reaching $376.18. According to a recent forecast by Vivek Arya, an analyst at Bank of America Global Research, the total addressable market (TAM) for server CPUs is expected to skyrocket from $35 billion in 2025 to over $170 billion by 2030. This significantly exceeds the bank's previous prediction of a $125 billion market size for server CPUs by 2030. Arya stated in the report, 'We believe the rise of agent-based AI is a powerful demand accelerator that not only expands the market opportunities for CPUs but also benefits Intel, AMD, and challengers based on Arm architecture.'