Розробка Jetton-токена (TON)
Jetton — стандарт токенів у блокчейні TON, аналог ERC-20 на Ethereum. Якщо ви прийшли з Ethereum-бекграундом, перше, що потрібно зрозуміти: архітектура принципово інша. У EVM токен — це один контракт з mapping балансів. У TON токен — це система контрактів: один Jetton Master та N Jetton Wallet (по одному на кожного держателя). Це не особливість реалізації — це фундаментальна наслідок архітектури TON з шардингом.
Архітектура: Jetton Master + Jetton Wallet
Jetton Master зберігає метаданні токена (name, symbol, decimals, totalSupply) та містить логіку мінтингу. Він не зберігає баланси — він їх не знає.
Jetton Wallet — окремий контракт для кожної адреси. Розгортається автоматично при першій відправці токенів на адресу. Зберігає баланс конкретного користувача та обробляє transfer.
Alice → (transfer) → Alice's Jetton Wallet
Alice's Jetton Wallet → (internal message) → Bob's Jetton Wallet
Bob's Jetton Wallet: отримує токени, збільшує баланс
Чому так влаштовано? TON використовує sharding: різні контракти можуть знаходитися в різних шардах. Якби баланси зберігалися в одному місці, кожен transfer створював би cross-shard транзакцію — вузьке місце. З розподіленими гаманцями transfer між двома гаманцями — локальна операція в одному шарді.
Розробка на Tact
Tact — високорівневий язык для TON контрактів, кращий за FunC для нових проектів через читаємість та типобезпеку.
Jetton Master на 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 на 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;
// Адреса гаманця отримувача обчислюється детерміністично
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 та TON fee: особливості
У TON кожне повідомлення (message) несе TON для оплати gas подальших операцій. При transfer ви повинні відправити достатньо TON, щоб покрити: gas для Jetton Wallet відправителя, gas для Jetton Wallet отримувача (включаючи розгортання, якщо новий), опціональний forwardAmount для сповіщення смарт-контракту отримувача.
Типова вартість Jetton transfer: 0.05–0.1 TON на операцію. Для нових отримувачів (розгортання гаманця) — трохи дороже (~0.05 TON extra на storage fees). Недостатня кількість TON у повідомленні призведе до bounce — транзакція скасується, токени повернуться.
Bounce handling — обов'язкова частина реалізації. TON унікальний тим, що failed транзакції повертають повідомлення відправнику через bounce. Jetton Wallet повинен обробляти bounced повідомлення та повертати баланс:
bounced(msg: bounced<TokenTransferInternal>) {
// Повертаємо баланс якщо transfer не пройшов
self.balance += msg.amount;
}
Метаданні токена
TON використовує off-chain метаданні через стандарт TEP-64. Метаданні зберігаються в IPFS або на HTTPS, посилання записується в Jetton Master:
{
"name": "My Token",
"description": "Token description",
"symbol": "MTK",
"decimals": "9",
"image": "https://example.com/logo.png",
"image_data": null
}
У контракті посилання на метаданні кодується в cell як snake-encoded string або offchain content URL. Стандарт передбачає обидва формати: повністю on-chain (усі дані в cell) або off-chain зі snake URL.
Інтеграція з TON Connect та кошельками
Для DApp інтеграції — TON Connect 2.0. SDK для 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);
// Отримуємо адресу Jetton Wallet відправителя
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,
});
}
}
Тестування з Blueprint
Blueprint — офіційний тестовий фреймворк для TON контрактів. Підтримує sandbox оточення з симуляцією блокчейна:
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');
// Мінтимо Alice
await jettonMaster.sendMint(deployer.getSender(), alice.address, toNano('1000'));
// Перевіряємо баланс
const aliceWallet = await jettonMaster.getWalletAddress(alice.address);
const balance = await aliceWallet.getBalance();
expect(balance).toEqual(toNano('1000'));
// Transfer від Alice до 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: що найчастіше ломається
Недостатньо TON у повідомленні. Найчастіша причина failed transfer-ів. Рішення: завжди перевіряти minTonsForStorage + tonBalanceBeforeMsg в тестах.
Некоректна обробка bounce. Якщо Jetton Wallet отримувача не існує і гаманець не буде створений (недостатньо TON) — повідомлення повернеться як bounced. Без правильного bounce handler баланс відправителя потеряється назавжди.
Помилки у вичисленні адреси гаманця. Адреса Jetton Wallet — детерміністична функція від (owner_address, jetton_master_address, init_code). Будь-яка помилка в init_data призведе до того, що обчислений off-chain адрес не збігатиметься з розгорнутим.
Терміни розробки стандартного Jetton з mint/burn та базовою документацією: 3–5 робочих днів. З кастомною логікою (комісії, whitelist, vesting) — 1–2 тижні.







