Security best practices
There are several anti-patterns and potential attack vectors that Tact smart contract developers should be aware of. These can affect the security, efficiency, and correctness of the contracts. Below we discuss the do’s and don’ts specific to writing and maintaining secure Tact smart contracts.
For a deeper understanding, refer to the following resources:
- Smart contracts guidelines in TON Docs
- Secure Smart Contract Programming in TON Docs
- FunC Security Best Practices in GitHub repo
In addition, consider reading the detailed article by CertiK, a Web3 smart contract auditor: Secure Smart Contract Programming in Tact: Popular Mistakes in the TON Ecosystem.
Sending sensitive data on-chain
The entire smart contract computation is transparent, and if you have confidential values at runtime, they can be retrieved through simple emulation.
Do’s ✅
Do not send or store sensitive data on-chain.
Don’ts ❌
message Login { privateKey: Int as uint256; signature: Slice; data: Slice;}
contract Test() { receive(msg: Login) { let publicKey = getPublicKey(msg.privateKey);
require(checkDataSignature(msg.data, msg.signature, publicKey), "Invalid signature!"); }
// Empty receiver for the deployment, // which forwards the remaining value back to the sender receive() { cashback(sender()) }}
Misuse of signed integers
Unsigned integers are safer because they prevent most errors by design, while signed integers can have unpredictable consequences if not used carefully. Therefore, signed integers should be used only when absolutely necessary.
Do’s ✅
Prefer to use unsigned integers unless signed integers are required.
Don’ts ❌
The following is an example of the incorrect use of a signed integer. In the Vote
Message, the type of the votes
field is Int as int32
, which is a 32-bit signed integer. This can lead to spoofing if an attacker sends a negative number of votes instead of a positive one.
message Vote { votes: Int as int32 }
contract VoteCounter( votes: Int as uint32,) { receive(msg: Vote) { self.votes += msg.votes; }
// Empty receiver for the deployment, // which forwards the remaining value back to the sender receive() { cashback(sender()) }}
Invalid throw values
Exit codes 0 and 1 indicate normal execution of the compute phase of the transaction. Execution can be unexpectedly aborted by calling a throw()
or similar functions directly with exit codes 0 and 1. This can make debugging very difficult since such aborted execution would be indistinguishable from a normal one.
Do’s ✅
Prefer to use the require()
function to state expectations.
require(isDataValid(msg.data), "Invalid data!");
Don’ts ❌
Don’t throw 0 or 1 directly.
throw(0);throw(1);
Insecure random numbers
Generating truly secure random numbers in TON is challenging. The random()
function is pseudo-random and depends on logical time. An attacker can predict the randomized number by brute-forcing the logical time in the current block.
Do’s ✅
-
For critical applications, avoid relying solely on on-chain solutions.
-
Use
random()
with randomized logical time to enhance security by making predictions harder for attackers without access to a validator node. Note, however, that it is still not entirely foolproof. -
Consider using the commit-and-disclose scheme:
- Participants generate random numbers off-chain and send their hashes to the contract.
- Once all hashes are received, participants disclose their original numbers.
- Combine the disclosed numbers (e.g., summing them) to produce a secure random value.
For more details, refer to the Secure Random Number Generation page in TON Docs.
Don’ts ❌
Don’t rely on the random()
function.
if (random(1, 10) == 7) { // ...subsequent logic...}
Don’t use randomization in external()
message receivers, as it remains vulnerable even with randomized logical time.
Optimized message handling
String parsing from human-friendly formats into machine-readable binary structures should be done off-chain. This approach ensures that only optimized and compact messages are sent to the blockchain, minimizing computational and storage costs while avoiding unnecessary gas overhead.
Do’s ✅
Perform string parsing from human-readable formats into machine-readable binary structures off-chain to keep the contract efficient.
message Sample { parsedField: Slice }
contract Example() { receive(msg: Sample) { // Process msg.parsedField directly }
// Empty receiver for the deployment, // which forwards the remaining value back to the sender receive() { cashback(sender()) }}
Don’ts ❌
Avoid parsing strings from human-readable formats into binary structures on-chain, as this increases computational overhead and gas costs.
message Sample { field: String }
contract Example { receive(msg: Sample) { // Parsing occurs on-chain, which is inefficient let parsed = field.fromBase64(); }
// Empty receiver for the deployment, // which forwards the remaining value back to the sender receive() { cashback(sender()) }}
Gas limitation
Be careful with the Out of gas error
. It cannot be handled, so try to pre-calculate the gas consumption for each receiver using tests whenever possible. This will help avoid wasting extra gas, as the transaction will fail anyway.
Do’s ✅
message Vote { votes: Int as int32 }
contract VoteCounter() { const voteGasUsage = 10000; // precompute with tests
receive(msg: Vote) { require(context().value > getComputeFee(self.voteGasUsage, false), "Not enough gas!"); // ...subsequent logic... }
// Empty receiver for the deployment, // which forwards the remaining value back to the sender receive() { cashback(sender()) }}
Identity validation
Always validate the identity of the sender if your contract logic revolves around trusted senders. This can be done using the Ownable
trait or using state init validation. You can read more about Jetton validation and NFT validation.
Do’s ✅
Use the Ownable
trait.
import "@stdlib/ownable";
message Inc { amount: Int as uint32 }
contract Counter with Ownable { owner: Address; val: Int as uint32;
init() { self.owner = address("...SOME ADDRESS..."); self.val = 0; }
receive(msg: Inc) { self.requireOwner(); self.val += msg.amount; }
// Empty receiver for the deployment, // which forwards the remaining value back to the sender receive() { cashback(sender()) }}
Don’ts ❌
Do not execute a message without validating the sender’s identity!
contract Jetton { myJettonWalletAddress: Address; myJettonAmount: Int as coins = 0;
init(jettonWalletCode: Cell, jettonMasterAddress: Address) { self.myJettonWalletAddress = calculateJettonWalletAddress( myAddress(), jettonMasterAddress, jettonWalletCode, ); }
receive(msg: JettonTransferNotification) { // There's no check of the ownership here! self.myJettonAmount += msg.amount; }
// Empty receiver for the deployment, // which forwards the remaining value back to the sender receive() { cashback(sender()) }}
Replay protection
Replay protection is a security mechanism that prevents an attacker from reusing a previous message. More information about replay protection can be found on the External messages page in TON Docs.
Do’s ✅
To differentiate messages, always include and validate a unique identifier, such as seqno
. Update the identifier after successful processing to avoid duplicates.
Alternatively, you can implement replay protection similar to the one in the highload v3 wallet, which is not based on seqno
.
message MsgWithSignedData { bundle: SignedBundle; seqno: Int as uint64; rawMsg: Cell;}
contract Sample( publicKey: Int as uint256, seqno: Int as uint64,) { external(msg: MsgWithSignedData) { require(msg.bundle.verifySignature(self.publicKey), "Invalid signature"); acceptMessage(); self.seqno += 1; sendRawMessage(msg.rawMsg, 0); }
// Empty receiver for the deployment, // which forwards the remaining value back to the sender receive() { cashback(sender()) }}
Don’ts ❌
Do not rely on signature verification without the inclusion of a sequence number. Messages without replay protection can be resent by attackers because there is nothing to distinguish a valid original message from a replayed one.
message Msg { newMessage: Cell; signature: Slice;}
contract Sample( publicKey: Int as uint256,) { external(msg: Msg) { require( checkDataSignature(msg.toSlice(), msg.signature, self.publicKey), "Invalid signature", ); acceptMessage(); sendRawMessage(msg.newMessage, 0); }
// Empty receiver for the deployment, // which forwards the remaining value back to the sender receive() { cashback(sender()) }}
Preventing front-running with signature verification
In TON blockchain, all pending messages are publicly visible in the mempool. Front-running can occur when an attacker observes a pending transaction containing a valid signature and quickly submits their own transaction using the same signature before the original transaction is processed.
Do’s ✅
Include critical parameters like the recipient address (to
) within the data that is signed. This ensures that the signature is valid only for the intended operation and recipient, preventing attackers from reusing the signature for their benefit. Also, implement replay protection to prevent the same signed message from being used multiple times.
struct RequestBody { to: Address; seqno: Int as uint64;}
message(0x988d4037) Request { signature: Slice as bytes64; requestBody: RequestBody;}
contract SecureChecker( publicKey: Int as uint256, seqno: Int as uint64, // Add seqno for replay protection) { receive(request: Request) { // Verify the signature against the reconstructed data hash require(checkSignature(request.requestBody.toCell().hash(), request.signature, self.publicKey), "Invalid signature!");
// Check replay protection require(request.requestBody.seqno == self.seqno, "Invalid seqno"); // Assuming external message with seqno self.seqno += 1; // Increment seqno after successful processing
// Ensure the message is sent to the address specified in the signed data message(MessageParameters { to: request.requestBody.to, // Use the 'to' from the signed request value: 0, mode: SendRemainingBalance, // Caution: sending the whole balance! bounce: false, body: "Your action payload here".asComment(), // Example body }); }
// Empty receiver for the deployment, // which forwards the remaining value back to the sender receive() { cashback(sender()) }
get fun seqno(): Int { return self.seqno; }}
Remember to also implement replay protection to prevent reusing the same signature even if it’s correctly targeted.
Don’ts ❌
Do not sign data without including essential context like the recipient address. An attacker could intercept the message, copy the signature, and replace the recipient address in their own transaction, effectively redirecting the intended action or funds.
message(0x988d4037) Request { signature: Slice as bytes64; data: Slice as remaining; // 'to' address is not part of the signed data}
contract InsecureChecker( publicKey: Int as uint256,) { receive(request: Request) { // The signature only verifies 'request.data', not the intended recipient. if (checkDataSignature(request.data.hash(), request.signature, self.publicKey)) { // Attacker can see this message, copy the signature, and send their own // message to a different 'to' address before this one confirms. // The 'sender()' here is the original sender, but the attacker can initiate // a similar transaction targeting themselves or another address. message(MessageParameters { to: sender(), // Vulnerable: recipient isn't verified by the signature value: 0, mode: SendRemainingBalance, // Caution: sending the whole balance! bounce: false, }); } }
// Empty receiver for the deployment, // which forwards the remaining value back to the sender receive() { cashback(sender()) }}
Furthermore, once a signature is used in a transaction, it becomes publicly visible on the blockchain. Without proper replay protection, anyone can potentially reuse this signature and the associated data in a new transaction if the contract logic doesn’t prevent it.
Race condition of messages
A message cascade can be processed over many blocks. Assume that while one message flow is running, an attacker can initiate a second message flow in parallel. That is, if a property was checked at the beginning, such as whether the user has enough tokens, do not assume that it will still be satisfied at the third stage in the same contract.
Handle/Send bounced messages
Send messages with the bounce flag set to true
, which is the default for the send()
, message()
, and deploy()
functions. Messages bounce when the execution of a contract fails. You may want to handle this by rolling back the state of the contract using try...catch
statements and performing additional processing depending on your logic.
Do’s ✅
Handle bounced messages via a bounced message receiver to correctly react to failed messages.
contract JettonWalletSample( owner: Address, master: Address, balance: Int,) { const minTonsForStorage: Int = ton("0.01"); const gasConsumption: Int = ton("0.01");
receive(msg: TokenBurn) { let ctx: Context = context(); require(ctx.sender == self.owner, "Invalid sender");
self.balance = self.balance - msg.amount; require(self.balance >= 0, "Invalid balance");
let fwdFee: Int = ctx.readForwardFee(); require( ctx.value > fwdFee + 2 * self.gasConsumption + self.minTonsForStorage, "Invalid value - Burn", );
message(MessageParameters { to: self.master, value: 0, mode: SendRemainingValue, bounce: true, body: TokenBurnNotification { queryId: msg.queryId, amount: msg.amount, owner: self.owner, response_destination: self.owner, }.toCell(), }); }
bounced(src: bounced<TokenBurnNotification>) { self.balance = self.balance + src.amount; }
// Empty receiver for the deployment, // which forwards the remaining value back to the sender receive() { cashback(sender()) }}
Transaction and phases
From the Sending messages page of the Book:
Each transaction on TON Blockchain consists of multiple phases. Outbound messages are evaluated in the compute phase but are not sent in that phase. Instead, they’re queued in order of appearance for the action phase, where all actions listed in the compute phase, such as outbound messages or reserve requests, are executed.
Hence, if the compute phase fails, registers c4
(persistent data) and c5
(actions) won’t be updated. However, it is possible to manually save their state using the commit()
function.
Return gas excesses carefully
If excess gas is not returned to the sender, the funds will accumulate in your contracts over time. This isn’t terrible in principle, just a suboptimal practice. You can add a function to rake out excess, but popular contracts like TON Jetton still return it to the sender with the Message using the 0xd53276db
opcode.
Do’s ✅
Return excesses using a Message with the 0xd53276db
opcode.
message(0xd53276db) Excesses {}message Vote { votes: Int as int32 }
contract Sample( votes: Int as uint32,) { receive(msg: Vote) { self.votes += msg.votes;
message(MessageParameters { to: sender(), value: 0, mode: SendRemainingValue | SendIgnoreErrors, body: Excesses {}.toCell(), }); }
// Empty receiver for the deployment, // which forwards the remaining value back to the sender receive() { cashback(sender()) }}
Pulling data from another contract
Contracts in the blockchain can reside in separate shards processed by another set of validators. This means that one contract cannot pull data from another contract. Specifically, no contract can call a getter function from another contract.
Thus, any on-chain communication is asynchronous and done by sending and receiving messages.
Do’s ✅
Exchange messages to pull data from another contract.
message GetMoney {}message ProvideMoney {}message TakeMoney { money: Int as coins }
contract OneContract( money: Int as coins,) { receive(msg: ProvideMoney) { message(MessageParameters { to: sender(), value: 0, mode: SendRemainingValue | SendIgnoreErrors, body: TakeMoney { money: self.money }.toCell(), }); }
// Empty receiver for the deployment, // which forwards the remaining value back to the sender receive() { cashback(sender()) }}
contract AnotherContract( oneContractAddress: Address,) { receive(_: GetMoney) { message(MessageParameters { to: self.oneContractAddress, value: 0, mode: SendRemainingValue | SendIgnoreErrors, bounce: false, body: ProvideMoney {}.toCell(), }); }
receive(msg: TakeMoney) { require(sender() == self.oneContractAddress, "Invalid money provider!"); // ...further processing... }
// Empty receiver for the deployment, // which forwards the remaining value back to the sender receive() { cashback(sender()) }}