Разработка 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 транзакцию — узкое место. С distributed wallet-ами 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');
// Mint к 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 недели.







