Jetton Token Development (TON)
Jetton is the token standard in TON blockchain, analogue of ERC-20 on Ethereum. If you come with Ethereum background, first thing to understand: architecture is fundamentally different. In EVM token is one contract with balance mapping. In TON token is system of contracts: one Jetton Master and N Jetton Wallet (one per each holder). This isn't implementation detail — it's fundamental consequence of TON's sharding architecture.
Architecture: Jetton Master + Jetton Wallet
Jetton Master stores token metadata (name, symbol, decimals, totalSupply) and contains minting logic. It doesn't store balances — doesn't know them.
Jetton Wallet — separate contract for each address. Deployed automatically on first token transfer to address. Stores specific user's balance and handles transfer.
Alice → (transfer) → Alice's Jetton Wallet
Alice's Jetton Wallet → (internal message) → Bob's Jetton Wallet
Bob's Jetton Wallet: receives tokens, increases balance
Why designed this way? TON uses sharding: different contracts can be in different shards. If balances were stored in one place, every transfer would create cross-shard transaction — bottleneck. With distributed wallets, transfer between two wallets is local operation in one shard.
Development on Tact
Tact is high-level language for TON contracts, preferable to FunC for new projects due to readability and type safety.
Jetton Master on Tact:
import "@stdlib/jetton";
message(0x178d4519) TokenTransferInternal {
queryId: Int as uint64;
amount: Int as coins;
from: Address;
responseAddress: Address?;
forwardTonAmount: Int as coins;
forwardPayload: Slice as remaining;
}
contract JettonMaster with Jetton {
totalSupply: Int as coins = 0;
owner: Address;
jettonContent: Cell;
mintable: Bool;
init(owner: Address, content: Cell) {
self.owner = owner;
self.jettonContent = content;
self.mintable = true;
}
receive(msg: JettonMint) {
require(sender() == self.owner, "Only owner can mint");
require(self.mintable, "Minting disabled");
self.totalSupply += msg.amount;
let initData: StateInit = self.getJettonWalletInit(msg.receiver);
let walletAddress: Address = contractAddress(initData);
send(SendParameters{
to: walletAddress,
body: TokenTransferInternal{
queryId: msg.queryId,
amount: msg.amount,
from: myAddress(),
responseAddress: msg.responseAddress,
forwardTonAmount: msg.forwardTonAmount,
forwardPayload: emptySlice(),
}.toCell(),
value: msg.tonAmount,
mode: SendIgnoreErrors,
code: initData.code,
data: initData.data,
});
}
fun getJettonWalletInit(owner: Address): StateInit {
return initOf JettonWallet(owner, myAddress());
}
}
Jetton Wallet on Tact:
contract JettonWallet with JettonWallet {
balance: Int as coins = 0;
owner: Address;
jettonMaster: Address;
init(owner: Address, jettonMaster: Address) {
self.owner = owner;
self.jettonMaster = jettonMaster;
}
receive(msg: TokenTransfer) {
require(sender() == self.owner, "Not owner");
require(msg.amount > 0, "Zero amount");
require(self.balance >= msg.amount, "Insufficient balance");
self.balance -= msg.amount;
// Recipient wallet address computed deterministically
let receiverWalletInit: StateInit = initOf JettonWallet(msg.destination, self.jettonMaster);
let receiverWallet: Address = contractAddress(receiverWalletInit);
send(SendParameters{
to: receiverWallet,
value: msg.forwardTonAmount + context().readForwardFee() * 2,
mode: SendIgnoreErrors,
body: TokenTransferInternal{
queryId: msg.queryId,
amount: msg.amount,
from: self.owner,
responseAddress: msg.responseAddress,
forwardTonAmount: msg.forwardTonAmount,
forwardPayload: msg.forwardPayload,
}.toCell(),
code: receiverWalletInit.code,
data: receiverWalletInit.data,
});
}
}
Gas and TON fee: peculiarities
In TON each message (message) carries TON to pay for gas of further operations. On transfer you must send enough TON to cover: gas for sender's Jetton Wallet, gas for recipient's Jetton Wallet (including deploy if new), optional forwardAmount for notifying recipient smart contract.
Typical Jetton transfer cost: 0.05–0.1 TON per operation. For new recipients (wallet deploy) — slightly more (~0.05 TON extra for storage fees). Insufficient TON in message leads to bounce — transaction is cancelled, tokens returned.
Bounce handling — mandatory part of implementation. TON is unique in that failed transactions return message to sender via bounce. Jetton Wallet should handle bounced messages and return balance:
bounced(msg: bounced<TokenTransferInternal>) {
// Return balance if transfer failed
self.balance += msg.amount;
}
Token metadata
TON uses off-chain metadata via TEP-64 standard. Metadata is stored on IPFS or HTTPS, link recorded in Jetton Master:
{
"name": "My Token",
"description": "Token description",
"symbol": "MTK",
"decimals": "9",
"image": "https://example.com/logo.png",
"image_data": null
}
In contract link to metadata is encoded in cell as snake-encoded string or offchain content URL. Standard provides both formats: completely on-chain (all data in cell) or off-chain with snake URL.
Integration with TON Connect and wallets
For DApp integration — TON Connect 2.0. SDK for React:
import { TonConnectUI, useTonConnectUI } from '@tonconnect/ui-react';
import { toNano, Address, beginCell } from '@ton/core';
import { JettonMaster } from './wrappers/JettonMaster';
function TransferButton({ jettonAddress, recipient, amount }) {
const [tonConnectUI] = useTonConnectUI();
async function handleTransfer() {
const jettonMaster = JettonMaster.createFromAddress(Address.parse(jettonAddress));
const senderAddress = Address.parse(tonConnectUI.wallet!.account.address);
// Get sender's Jetton Wallet address
const senderWalletAddress = await jettonMaster.getWalletAddress(client, senderAddress);
const transferPayload = beginCell()
.storeUint(0xf8a7ea5, 32) // op::transfer
.storeUint(0, 64) // query_id
.storeCoins(toNano(amount))
.storeAddress(Address.parse(recipient))
.storeAddress(senderAddress) // response_destination
.storeBit(false) // no custom_payload
.storeCoins(toNano("0.01")) // forward_ton_amount
.storeBit(false) // no forward_payload
.endCell();
await tonConnectUI.sendTransaction({
messages: [{
address: senderWalletAddress.toString(),
amount: toNano("0.05").toString(),
payload: transferPayload.toBoc().toString("base64"),
}],
validUntil: Math.floor(Date.now() / 1000) + 360,
});
}
}
Testing with Blueprint
Blueprint is official testing framework for TON contracts. Supports sandbox environment with blockchain simulation:
import { Blockchain, SandboxContract } from '@ton/sandbox';
import { JettonMaster } from '../wrappers/JettonMaster';
describe('Jetton', () => {
let blockchain: Blockchain;
let jettonMaster: SandboxContract<JettonMaster>;
beforeEach(async () => {
blockchain = await Blockchain.create();
const deployer = await blockchain.treasury('deployer');
jettonMaster = blockchain.openContract(
JettonMaster.createFromConfig({ owner: deployer.address, ... }, code)
);
await jettonMaster.sendDeploy(deployer.getSender(), toNano('0.05'));
});
it('should mint and transfer', async () => {
const alice = await blockchain.treasury('alice');
const bob = await blockchain.treasury('bob');
// Mint to Alice
await jettonMaster.sendMint(deployer.getSender(), alice.address, toNano('1000'));
// Check balance
const aliceWallet = await jettonMaster.getWalletAddress(alice.address);
const balance = await aliceWallet.getBalance();
expect(balance).toEqual(toNano('1000'));
// Transfer from Alice to Bob
await aliceWallet.sendTransfer(alice.getSender(), bob.address, toNano('100'));
const bobWallet = await jettonMaster.getWalletAddress(bob.address);
expect(await bobWallet.getBalance()).toEqual(toNano('100'));
});
});
TON specifics: what most often breaks
Insufficient TON in message. Most common reason for failed transfers. Solution: always check minTonsForStorage + tonBalanceBeforeMsg in tests.
Incorrect bounce handling. If recipient Jetton Wallet doesn't exist and wallet won't be created (insufficient TON) — message returns as bounced. Without proper bounce handler sender's balance is lost forever.
Errors in wallet address calculation. Jetton Wallet address is deterministic function of (owner_address, jetton_master_address, init_code). Any error in init_data leads to off-chain computed address not matching deployed address.
Timeline for standard Jetton with mint/burn and basic documentation: 3–5 working days. With custom logic (fees, whitelist, vesting) — 1–2 weeks.







