Secure Smart Contract Programming
In this section, we'll look at a few of the most interesting features of the TON blockchain, and then walk through a list of best practices for developers programming smart contracts on 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:
- One
jetton-minter
that storestotal_supply
,minter_address
, and a couple of refs: token description (metadata) andjetton_wallet_code
. - 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.
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:
- sender sends an
op::transfer
message to its wallet (sender_wallet
); sender_wallet
reduces the token balance;sender_wallet
sends anop::internal_transfer
message to the recipient's wallet (destination_wallet
);destination_wallet
increases its token balance;destination_wallet
sendsop::transfer_notification
to its owner (destination
);destination_wallet
returns excess gas withop::excesses
message onresponse_destination
(usuallysender
).
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.
TON Smart Contract Developer Must 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:
- If there is not enough gas, the transaction will be partially executed;
- If there is too much gas, the excess must be returned. This is the developer’s responsibility;
- 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.
TON Smart Contract Developer Must 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.
Tips
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.
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.
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.