Deep Dive Into USDt on TON
This article aims to help everyone understand the USDT smart contract code in the GitHub repository provided here and to explain a bit about the FunC language (the smart contract language in TON).
This way, everyone can easily understand TON and the fungible tokens on @TON_Blockchain.
It consists of just 290 lines of code for the Jetton Minter and 267 lines of code for the Jetton Wallet. So, let's begin!
Click here to access the USDt on TON Github Repo.
Architecture & The First Concept
First of all, you need to have a basic understanding of Jetton Minter and Jetton Wallet; in the TON Blockchain, we refer to fungible tokens as Jetton Tokens. (https://en.m.wikipedia.org/wiki/Jeton)
This means that every 'Wallet Address' that receives USDT will be assigned a new Jetton Wallet address owned by 'You'.
For example, if you have 10 assets, then your main Wallet Address will manage the other 10 Jetton Wallet addresses, creating a one-to-many design compared to the Ethereum world.
The benefit of this 'Sharding' structure lies in two major ideas:
- The balances are isolated from each other, with storage being unique to different addresses. Therefore, there is no common "permit" attack as seen in the Ethereum world. This is a fundamental difference.
- When you deposit into the Staking contract, your balance changes, and the staking contract also has its own Jetton Wallet. This setup allows you to 'Transfer the current balance you have in your Jetton Wallet' without worrying that a hacker can access 'ALL your USDt,' even if you just stake a small transaction to the Staking Pool. This makes it safer.
- Simply put, the Jetton Master stores the total supply of USDT, and each Jetton Wallet resides in a unique contract address and is associated with only "One Owner."
- This is why TON can handle 100,000+ transactions without crashing while also maintaining safety in smart contract design. At the same time, the contract enables asynchronous features that can scale the blockchain.
First of FunC code - Storage
{-
storage#_ total_supply:Coins admin_address:MsgAddress next_admin_address:MsgAddress jetton_wallet_code:^Cell metadata_uri:^Cell = Storage;
-}
(int, slice, slice, cell, cell) load_data() inline {
slice ds = get_data().begin_parse();
var data = (
ds~load_coins(), ;; total_supply
ds~load_msg_addr(), ;; admin_address
ds~load_msg_addr(), ;; next_admin_address
ds~load_ref(), ;; jetton_wallet_code
ds~load_ref() ;; metadata_url (contains snake slice without 0x0 prefix)
);
ds.end_parse();
return data;
}
() save_data(int total_supply, slice admin_address, slice next_admin_address, cell jetton_wallet_code, cell metadata_uri) impure inline {
set_data(
begin_cell()
.store_coins(total_supply)
.store_slice(admin_address)
.store_slice(next_admin_address)
.store_ref(jetton_wallet_code)
.store_ref(metadata_uri)
.end_cell()
);
}
The first part of the FunC code always claims the storage. As you can see, it is only related to the five major storages:
- Total Supply
- Admin Address
- Next Admin Address
- Jetton Wallet Code
- Metadata URL
The Admin Address is crucial, as we all know from USDT on ERC20; it holds the highest authority and can manage many functionalities for USDT. Yes, the USDT contract is under centralized management.
The Next Admin Address is somewhat special, but we will discuss this later.
The Metadata URL is also commonly used when discussing tokens on other networks, similar to NFT metadata.
And yes, we store the contract data in a Cell
way; it's the brand new data type that only shows on TON Blockchain; for more info, go to ton.org/learn here.
These two fundamental functions(load_data
and save_data
) are the basic requirements for all FunC smart contract codes. Remember to declare them at the beginning of the contract.
The "main()" in FunC code
The recv_internal(...)
function is always the main highlight of our FunC code.
Keep in mind, it's analogous to the contract()
declaration in @solidity_lang. This function typically handles some core messages: 1) msg_value 2) in_msg_full 3) in_msg_body, and 4) my_balance.
However, I won't delve too deeply into this part because it's both fundamental and complex for newcomers to grasp. I'll likely write a separate article about this later.
The functions for Jetton Minter
To expand on the functionality, we can note that the Minter Contract includes several key functions:
- op::mint
- op::burn_notification
- op::provide_wallet_address
- op::change_admin
- op::claim_admin
- op::call_to
- op::change_metadata_uri
- op::upgrade
- op::top_up
The main function I want to highlight is op::mint
, as it is crucial for everyone's assets and their confidence in @Tether_to.
This function is of great importance. It's associated with the opcode 0x642b6d07
, which acts similarly to the function selector mentioned in the Ethereum network. The same concept applies here in the TON Blockchain.
if (op == op::mint) { ;; 0x642b7d07
throw_unless(error::not_owner, equal_slices_bits(sender_address, admin_address));
slice to_address = in_msg_body~load_msg_addr();
check_same_workchain(to_address);
int ton_amount = in_msg_body~load_coins();
cell master_msg = in_msg_body~load_ref();
in_msg_body.end_parse();
;; see internal_transfer TL-B layout in jetton.tlb
slice master_msg_slice = master_msg.begin_parse();
throw_unless(error::invalid_op, master_msg_slice~load_op() == op::internal_transfer);
master_msg_slice~skip_query_id();
int jetton_amount = master_msg_slice~load_coins();
master_msg_slice~load_msg_addr(); ;; from_address
master_msg_slice~load_msg_addr(); ;; response_address
int forward_ton_amount = master_msg_slice~load_coins(); ;; forward_ton_amount
check_either_forward_payload(master_msg_slice); ;; either_forward_payload
;; a little more than needed, it’s ok since it’s sent by the admin and excesses will return back
check_amount_is_enough_to_transfer(ton_amount, forward_ton_amount, fwd_fee);
send_to_jetton_wallet(to_address, jetton_wallet_code, ton_amount, master_msg, TRUE);
save_data(total_supply + jetton_amount, admin_address, next_admin_address, jetton_wallet_code, metadata_uri);
return ();
}
The minting process is definitely the core! Since this is the most challenging part, it includes several common techniques in FunC, but they might be a bit difficult to digest for those viewing FunC code for the first time.
The first two lines of code primarily parse the Cell data we mentioned earlier, specifically the in_msg_full
, into the temporary Slice
data type. Although Slice is also a new data type, it's the method we use when we want to modify the data.
The Slice data type here is more like a blank canvas, allowing us to draw what we want and initially paste what we find onto the Cell data.
slice in_msg_full_slice = in_msg_full.begin_parse();
int msg_flags = in_msg_full_slice~load_msg_flags();
The msg_flags is the first parameter we have in the message input (aka. the in_msg_full
there). So that is why we need to fetch this data first and then execute with the function later: if (is_bounced(msg_flags)) {....}
.
slice sender_address = in_msg_full_slice~load_msg_addr();
Then we run with the in_msg_full_slice~load_msg_addr();
that is the way to fetch the current sender to this USDT Jetton Minter address; this is the way that we can know who is sending the message to this contract.
int fwd_fee_from_in_msg = in_msg_full_slice~retrieve_fwd_fee();
int fwd_fee = get_original_fwd_fee(MY_WORKCHAIN, fwd_fee_from_in_msg); ;; we use message fwd_fee for estimation of forward_payload costs
Next, we also need to fetch a special piece of data called "fwd_fee" from the code. This parameter helps us understand the "forward passing fee" of the message, which is crucial since the TON Blockchain operates on a message-based system.
(int op, int query_id) = in_msg_body~load_op_and_query_id();
(int total_supply, slice admin_address, slice next_admin_address, cell jetton_wallet_code, cell metadata_uri) = load_data();
Next, we need to fetch the op_code
and query_id
, which are common and essential parameters for every transaction in TON.
Up to this point, we have established a series of steps to help our contract understand the nature of the current message input; since we've just fetched the op_code
information and identified who is sending this message to the contract, the query_id
helps us navigate which specific transaction is for the short-term period.
throw_unless(error::not_owner, equal_slices_bits(sender_address, admin_address));
Later, as we proceed to the Mint
process, the first line of code checks whether the sender is the admin; this is crucial because the minting process is authorized only for the admin to initiate or execute.
Second, we decode the message input body through "a series of steps", and we now read the to_address because we understand what the message should look like.
This means that if you do not open-source your code, people may not know how to format the message correctly, and thus, your smart contract won't accept it because it does not match the required format.
slice to_address = in_msg_body~load_msg_addr();
check_same_workchain(to_address);
Later, once we fetch additional data like ton_amount - the amount of Toncoin we want to forward, and the master_msg that we intend to send to the jetton wallet next (we will discuss master_msg in more detail later):
int ton_amount = in_msg_body~load_coins();
cell master_msg = in_msg_body~load_ref();
in_msg_body.end_parse();
Next, we load the entirety of the "master_msg" after we've claimed the temporary data; now, we are running a series of methods to decode the message itself.
slice master_msg_slice = master_msg.begin_parse();
throw_unless(error::invalid_op, master_msg_slice~load_op() == op::internal_transfer);
master_msg_slice~skip_query_id();
int jetton_amount = master_msg_slice~load_coins();
master_msg_slice~load_msg_addr(); ;; from_address
master_msg_slice~load_msg_addr(); ;; response_address
int forward_ton_amount = master_msg_slice~load_coins(); ;; forward_ton_amount
check_either_forward_payload(master_msg_slice); ;; either_forward_payload
You can see that most of the time we spend is on "decoding the data from master_msg." Once we clarify all the data we have at hand (akin to finally having all your ingredients ready and starting to cook), we then proceed with the final three lines of code in the modular functions.
;; a little more than needed, it’s ok since it’s sent by the admin and excesses will return back
check_amount_is_enough_to_transfer(ton_amount, forward_ton_amount, fwd_fee);
send_to_jetton_wallet(to_address, jetton_wallet_code, ton_amount, master_msg, TRUE);
save_data(total_supply + jetton_amount, admin_address, next_admin_address, jetton_wallet_code, metadata_uri);
return ();
The check_amount_is_enough_to_transfer
function is straightforward; it ensures that the transaction has enough funds to cover the gas fees.
The send_to_jetton_wallet
function is the core module that actually "sends the minting message to the jetton wallet address" receiving the new USDT tokens.
More importantly, the final save_data line records the amount of Jetton we just minted and adds it to the Jetton Minter contract's storage. This helps the minter contract recognize that the total supply has increased, as indicated by total_supply + jetton_amount.
save_data(total_supply + jetton_amount, admin_address, next_admin_address, jetton_wallet_code, metadata_uri);
The new receiver(Jetton Wallet)
The next function in the process involves the minter contract sending a message to the Jetton Wallet, which is the recipient for the new minting.
There are some intricate and unique techniques in the code, but I will only explain the key parts to keep the article concise.
The first notable aspect is the "raw_reserve." In TON, we need to consider the storage fee, which means we always maintain more toncoin than the contract might need, leaving some extra in reserve.
The second important concept involves calculating the state_init to determine the Jetton Wallet address that belongs to the receiver (e.g., each address has a unique Jetton Wallet address exclusively for USDT.
Different Jetton assets will lead to different Jetton Wallet addresses).
cell state_init = calculate_jetton_wallet_state_init(to_address, my_address(), jetton_wallet_code);
slice to_wallet_address = calculate_jetton_wallet_address(state_init);
Next, we also need to create the message body that is prepared to "activate the initialization of the Jetton Wallet," similar to deploying it. Therefore, we must address the state initialization of the smart contract, which is why we are building the new message body here:
;; build MessageRelaxed, see TL-B layout in stdlib.fc#L733
var msg = begin_cell()
.store_msg_flags_and_address_none(BOUNCEABLE)
.store_slice(to_wallet_address) ;; dest
.store_coins(ton_amount);
if (need_state_init) {
msg = msg.store_statinit_ref_and_body_ref(state_init, master_msg);
} else {
msg = msg.store_only_body_ref(master_msg);
}
Also, most importantly, we need to initiate the transaction that allows the Jetton Minter to send the minting transaction to the Jetton Wallet.
This is facilitated by the send_raw_message function, which ensures that the Jetton Wallet is correctly set up with the necessary state and funds when it is first activated.
send_raw_message(msg.end_cell(), SEND_MODE_PAY_FEES_SEPARATELY | SEND_MODE_BOUNCE_ON_ACTION_FAIL);
This line completes the process of sending the transaction to the Jetton Wallet in the full contract code.
Now, the full function for sending message is like here: () send_to_jetton_wallet(slice to_address, cell jetton_wallet_code, int ton_amount, cell master_msg, int need_state_init) impure inline { raw_reserve(ONE_TON, RESERVE_REGULAR); ;; reserve for storage fees
cell state_init = calculate_jetton_wallet_state_init(to_address, my_address(), jetton_wallet_code);
slice to_wallet_address = calculate_jetton_wallet_address(state_init);
;; build MessageRelaxed, see TL-B layout in stdlib.fc#L733
var msg = begin_cell()
.store_msg_flags_and_address_none(BOUNCEABLE)
.store_slice(to_wallet_address) ;; dest
.store_coins(ton_amount);
if (need_state_init) {
msg = msg.store_statinit_ref_and_body_ref(state_init, master_msg);
} else {
msg = msg.store_only_body_ref(master_msg);
}
send_raw_message(msg.end_cell(), SEND_MODE_PAY_FEES_SEPARATELY | SEND_MODE_BOUNCE_ON_ACTION_FAIL);
}
You can check the full data in the repo here.
Jetton Wallet
Now, let's go to the Jetton Wallet contract(jetton-wallet.fc
). The most important and interesting feature is that the USDT Jetton Wallet is set with extract parameters in the contract storage, which is the "status" data.
(int, int, slice, slice) load_data() inline {
slice ds = get_data().begin_parse();
var data = (
ds~load_uint(STATUS_SIZE), ;; A
ds~load_coins(), ;; balance
ds~load_msg_addr(), ;; owner_address
ds~load_msg_addr() ;; jetton_master_address
);
ds.end_parse();
return data;
}
As you can see in the code base, it says:
Note, status==0 means unlocked - the user can freely transfer and receive jettons (only admin can burn).
(status & 1) bit means the user can not send jettons
(status & 2) bit means the user can not receive jettons.
Master (minter) smart contract able to make outgoing actions (transfer, burn jettons) with any status.
This setup is crucial because the Jetton Wallet requires administrative authority to potentially FREEZE your jetton wallet (e.g., your balance in USDT token). The status is composed of a 4-bit combination.
How can the admin change your Jetton Wallet?
The Jetton wallet has a unique op_code called "op::set_status," which is not enabled for execution by the owner of this Jetton wallet but by the Jetton master.
if (op == op::set_status) { ;; 0xeed236d3
;; skip the query_id
in_msg_body~skip_query_id();
;; read the new status for this Jetton Wallet
int new_status = in_msg_body~load_uint(STATUS_SIZE);
in_msg_body.end_parse();
(_, int balance, slice owner_address, slice jetton_master_address) = load_data();
throw_unless(error::not_valid_wallet, equal_slices_bits(sender_address, jetton_master_address));
save_data(new_status, balance, owner_address, jetton_master_address);
return ();
}
Final Words
This is my first attempt at writing a technical article on x.com. Unfortunately, it's been more challenging than I anticipated, especially with pasting code. Moreover, it seems that the reading experience here isn't as good as in other places. So, if you could take a moment to click LIKE or share this article, it would be greatly appreciated.
Another point is that the USDT contracts are a unique and special presence among many other Layer 1 blockchains. It's more neutral and distinct, and importantly, it's exciting to see it now in the @Telegram network ecosystem. Although we've known about this development for months, I couldn't review the code until it launched on the mainnet (check the code here on explorer: https://tonviewer.com/EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs?section=code).
The main purpose of this article is to encourage more people to read and write FunC code and join the vast community in the TON network. Building USDT on any DApps is one of the coolest things you can do right now, especially since it reaches hundreds of millions of users from day one.
As of now, the total supply of USDT on TON is over $130 million, and I believe this number will continue to grow daily. It's hard to measure a network like this on any other planet before.
I know this article and the test cases in the repository aren't perfect, but I hope you can learn something from them and feel that you have gained some knowledge.
(P.S.: I am the Dev Rel Asia Lead at Ton Foundation. If you're interested in building on TON, feel free to reach out! My DMs are open!)
- Dev Chat List: t.me/addlist/1r5Vcb8eljk5Yzcy
- Developer Portal: ton.org/dev
- Documentation: docs.ton.org
- Forum: tonresear.ch