• Pricing
  • Enterprise
  • Customers
  • Blog

TON: Ultimate developer guide from smart contracts to Jettons

If you’re a Web3 developer, the evolving ecosystem of blockchain technology continually presents innovation avenues. As a leading blockchain service provider, we at Chainstack believe in empowering developers like you with the insights and tools necessary to stay ahead in this dynamic space. That’s why we have put together this extensive guide for developing on one of the most promising blockchains today—the TON Network.

TON offers a unique space for creating fungible tokens, Jettons, that extend far beyond basic smart contract deployment. This guide aims to dismantle complexities and guide you through a systematic development process on TON using key tools like the Blueprint SDK, Sandbox, and how to integrate high-performance Chainstack infrastructure into the mix.

Let’s begin!

How to set up your environment for TON?

As a Web3 developer, the foundation of your journey into TON development is setting up the right environment. It’s crucial to have the right tools and access to help you build, test, and deploy effectively. Here’s how you can set it up:

1.1 Deploy a TON node on Chainstack

To begin with, hop onto the Chainstack console and get started with a free Developer account. Once your account is active, you’ll gain instant access to deploy nodes on various blockchains, including TON. By deploying a TON testnet node, you set the stage for interacting with the blockchain. Obtain the necessary credentials and API keys for your node for future transactions. Here’s a quick video guide on how you can navigate the interface to do that:

Figure 1: How to deploy an Elastic Node in Advanced mode on Chainstack; Source: YouTube

1.2 Install prerequisites

A few additional installations are necessary to ensure smooth operations. Ensure you have the current version of Node.js and a package manager (i.e., npm or yarn) installed on your system. Also, you’ll need a TON wallet with some testnet tokens.

Don’t worry if don’t have any testnet tokens; they are readily obtainable from the TON faucet. And if you are not familiar with TON wallets, you can learn more about the most popular choices and how they fare against each other in our dedicated guide here.

1.3 Initialize the Blueprint SDK

The Blueprint SDK is a valuable tool that simplifies TON development. Use it to initialize a new project directory and you’ll get a structured directory complete with folders dedicated to managing your smart contracts, scripts, tests, and wrappers. You can choose from pre-existing templates or start an empty project based on your preferences. Run the following in your CLI then select an empty project in TACT to start fresh:

npm create ton@latest

Once your project is created, the Blueprint SDK automatically organizes your files into a structured directory system, making the development process smoother. Here’s a quick overview of the key directories:

  • build/: Stores the compiled output of your smart contracts, including bytecode and other important artifacts generated after running the build command.
  • contracts/: This is where your smart contracts reside. Any .tact file, such as your SimpleStorage contract, will be saved in this directory.
  • scripts/: Used for deployment and interaction scripts. These scripts are essential for deploying your smart contracts to the TON blockchain and interacting with them, such as retrieving contract data or sending transactions.
  • tests/: Contains all the test files for your smart contracts. You can write and execute tests here to verify that your contracts behave correctly. The default test ensures the contract deploys successfully, but you can extend it to test other functionalities.
  • wrappers/: Holds the TypeScript wrappers for your smart contracts. These wrappers allow you to interact with the contracts in a type-safe manner when writing scripts or running tests.

This structured setup helps streamline the development lifecycle from contract creation to deployment and testing.

How to deploy a TON smart contract?

Web3 developers know that the core of any blockchain development lies in the successful deployment of a smart contract. Once your environment is set, it’s time to get hands-on with TON smart contracts using the powerful Blueprint SDK. Meanwhile, Chainstack’s seamless node management will ensure you keep your focus where it truly matters—creating exceptional Web3 experiences.

2.1 Write the TON smart contract in TACT

You’ll locate the contracts/ folder inside your Blueprint project. Here, create a new file where you will script your smart contract. Start with a simple storage contract in the TACT language, storing a number and incrementing a counter for each interaction.

// ./contracts/simple_contract.tact
import "@stdlib/deploy";
// Allows the contract to receive a custom object of type "Save" specifying the number to save in the contract.
message Save {
    amount: Int as uint32;
}
// This is an example of a simple storage contract. It has a function that increments the saved number by one when the function is called.
contract SimpleContract with Deployable {
    // Declare variables
    // Variables structure: name: type 
    id: Int as uint32; // This is an ID for contract deployment
    savedNumber: Int as uint32;  
    counter: Int as uint32;  
    
    // init is similar to a contructor in Solidity
    init(id: Int) {
        // Init the id assed from the contructor
        self.id = id;
        // Initialize the variables to zero when the contract is deployed
        self.savedNumber = 0;
        self.counter = 0;
    }
    // TON contracts can recevie messages
    // This function makes an action when a specific message is recevied
    // In this case, when the contract recevies the message "add 1" will increment the counter variable by 1
    receive("add 1"){
        self.counter = self.counter + 1; 
    }
    // This allows the contract to recevie objects, in this case of type "Save"
    // Save a value in the contract
    receive(msg: Save){
        self.savedNumber = msg.amount;
    }
    // Getter function to read the variable
    get fun Number(): Int { // Int is the type of value returned
        return self.savedNumber;
    }
    // Getter function to read the counter variable
    get fun Counter(): Int { // Int is the type of value returned
        return self.counter;
    }
    // Getter function for the ID
    get fun Id(): Int {
        return self.id;
    }
}

2.2 Understand the TON smart contract components

While the contract might look simple, it has components crucial for more complex contracts you might write in the future. These include State Variables, the Initialization Function, Message Handlers, and Getter Functions—each performing a unique role inside your contract.

Imports and message declaration

  • The contract starts by importing essential modules using import "@stdlib/deploy";.
  • A custom message type called Save is declared. This message contains an amount field of type Int, aliased as uint32.

Contract declaration

  • The contract is defined as SimpleContract and includes the Deployable trait, allowing it to be deployed on the blockchain.
  • Three variables are declared within the contract:
    • id: Identifies the contract.
    • savedNumber: Stores a number in the contract.
    • counter: Tracks how many times a specific message is received.

Initialization function

  • The init() function acts like a constructor and is called when the contract is deployed.
    • It initializes the id field with a custom value provided during deployment.
    • The savedNumber and counter fields are both initialized to zero.

Message handlers

  • The contract is designed to handle messages:
    • The receive("add 1") handler increments the counter by 1 whenever it receives the message "add 1".
    • The receive(msg: Save) handler accepts a Save message object and stores the provided amount in savedNumber.

Getter functions

  • The contract includes getter functions to retrieve the values stored in the variables:
    • get fun Number(): Returns the current value of savedNumber.
    • get fun Counter(): Returns the current value of the counter.
    • get fun Id(): Returns the id of the contract.

Overview

The SimpleContract provides a basic example of a storage contract on the TON Network. It initializes with custom values, processes incoming messages to perform specific actions (like saving numbers or incrementing a counter), and allows users to query the stored values using getter functions.

2.3 Compile and build the contract

The next step after successful scripting is compiling the contract with Blueprint SDK. Successful compilation takes your TACT code and translates it into a format that TON’s nodes will understand.

Run the following in CLI to compile build your contract:

npx blueprint build

The response will be similar to this:

Using file: SimpleContract
Build script running, compiling SimpleContract
⏳ Compiling...   > 👀 Enabling debug
   > SimpleContract: tact compiler
   > SimpleContract: func compiler
   > SimpleContract: fift decompiler
   > Packaging
   > SimpleContract
   > Bindings
   > SimpleContract
   > Reports
   > SimpleContract
⚠️ Make sure to disable debug mode in contract wrappers before doing production deployments!
✅ Compiled successfully! Cell BOC result:
{
  "hash": "8bb0916eb10debd617ebaba79be7097cc21e41597dc940d16af521dbed9dad25",
  "hashBase64": "i7CRbrEN69YX66unm+cJfMIeQVl9yUDRavUh2+2drSU=",
  "hex": "b5ee9c724102110100029f000114ff00f4a413f4bcf2c80b0102016202070298d001d0d3030171b0a301fa400120d74981010bbaf2e08820d70b0a208104ffbaf2d0898309baf2e088545053036f04f86102f862db3c59db3cf2e082c8f84301cc7f01ca000101cb1fc9ed54090301f6eda2edfb0192307fe07021d749c21f953020d70b1fde208210946a98b6ba8ea830d31f018210946a98b6baf2e081d33f0131c8018210aff90f5758cb1fcb3fc9f84201706ddb3c7fe0c0008e2af90182eb0d7299a100d9de4cf674453aeb6aa320067fc00702b62aa29243621d23217dba94a47fdb31e09130e27004013a6d6d226eb3995b206ef2d0806f22019132e2102470030480425023db3c0501cac87101ca01500701ca007001ca02500520d74981010bbaf2e08820d70b0a208104ffbaf2d0898309baf2e088cf165003fa027001ca68236eb3917f93246eb3e2973333017001ca00e30d216eb39c7f01ca0001206ef2d08001cc95317001ca00e2c901fb000600987f01ca00c87001ca007001ca00246eb39d7f01ca0004206ef2d0805004cc9634037001ca00e2246eb39d7f01ca0004206ef2d0805004cc9634037001ca00e27001ca00027f01ca0002c958cc020120080c020fbd10c6d9e6d9e18c090b013ced44d0d401f863d2000194d31f0131e030f828d70b0a8309baf2e089db3c0a0002700002200201200d0e00b9bbbd182705cec3d5d2cae7b1e84ec39d64a851b6682709dd6352d2b647cb322d3af2dfdf1623982702055c01b80676394ce583aae4725b2c382701bd49def954596f1c753d3de0559c32682709d974e5ab34ecb733a0e966d9466e8a480201480f100011b0afbb5134348000600075b26ee3435697066733a2f2f516d576165594177773744717159335651704e5136456232414146466d67416346323365383955655a7947327764820ab944fa3"
}
✅ Wrote compilation artifact to build/SimpleContract.compiled.json

2.4 Write tests for the contract

Your contract is only as good as it performs, and the TON Sandbox is the perfect tool for you to write tests and validate your contract’s deployment and interactions before the actual deployment. Here’s a sample test file you can use to do a TON smart contract test:

// ./tests/SimpleContract.spec.ts
import { Blockchain, SandboxContract, TreasuryContract } from '@ton/sandbox';
import { toNano } from '@ton/core';
import { SimpleContract } from '../wrappers/SimpleContract';
import '@ton/test-utils';
// On TON we can test by creating a virtual chain
describe('SimpleContract', () => {
    let blockchain: Blockchain; // Init a virtual chain
    let deployer: SandboxContract<TreasuryContract>;
    let simpleContract: SandboxContract<SimpleContract>; // Init the smart contract instance
    const contractId = 1648n; // Id for deployment that will be passed in the contructor. Random value in this example
    beforeEach(async () => {
        blockchain = await Blockchain.create();
        simpleContract = blockchain.openContract(await SimpleContract.fromInit(contractId));
        // Init the deployer. It comes with 1M TON tokens
        deployer = await blockchain.treasury('deployer');
        const deployResult = await simpleContract.send(
            deployer.getSender(),
            {
                value: toNano('0.05'), // Value to send to the contract
            },
            {
                $$type: 'Deploy', // This because the contract inherits the Deployable trait.
                queryId: 0n,
            },
        );
        // Here is the test. In this case it tests that the contract is deployed correctly.
        expect(deployResult.transactions).toHaveTransaction({
            from: deployer.address,
            to: simpleContract.address,
            deploy: true,
            success: true,
        });
    });
    it('should deploy', async () => {
        // the check is done inside beforeEach
        // blockchain and simpleContract are ready to use
        console.log('Deploying contract...');
        const conttactId = await simpleContract.getId();
        console.log(`Fetched ID during deployment: ${conttactId}`);
    });
    it('should increase', async () => {
        console.log('Testing increase by 1 function...');
        const counterBefore = await simpleContract.getCounter();
        console.log('counterBefore - ', counterBefore);
        await simpleContract.send(
            deployer.getSender(),
            {
                value: toNano('0.02'),
            },
            'add 1', // The message the contract expects
        );
        const counterAfter = await simpleContract.getCounter();
        console.log('counterAfter - ', counterAfter);
        // Check it incremented the value
        expect(counterBefore).toBeLessThan(counterAfter);
    });
    it('should save the amount', async () => {
        console.log('Testing increase by given value function...');
        const numeberBefore = await simpleContract.getNumber();
        const amount = 10n;
        console.log(`Value to save: ${amount}`);
        console.log(`Number saved before: ${numeberBefore}`);
        await simpleContract.send(
            deployer.getSender(),
            {
                value: toNano('0.02'),
            },
            {
                $$type: 'Save', // This time it's an object and not just text
                amount: amount,
            },
        );
        const numberAfter = await simpleContract.getNumber();
        console.log(`Number saved after: ${numberAfter}`);
    });
});

Let’s get a better understanding of how the test and its components work:

Imports

  • The necessary modules and utilities are imported from TON Sandbox, core libraries, and the SimpleContract wrapper.
  • These imports allow interaction with the virtual chain and smart contracts in a test environment.

Test suite initialization (describe block)

  • The test suite is defined inside the describe block, which initializes key variables:
    • blockchain: Represents a virtual chain used to test contract behavior.
    • deployer: A sandbox instance representing the contract deployer with 1M TON tokens for testing purposes.
    • simpleContract: The instance of the SimpleContract being tested.

beforeEach hook

  • The beforeEach hook is executed before each individual test to set up the testing environment:
    • A virtual blockchain is initialized.
    • The contract is deployed using the deployer account.
    • A transaction is sent to deploy the contract, and the result is checked to ensure the deployment was successful.

Test 1: Deployment verification

  • This test checks whether the contract is deployed correctly.
  • It retrieves the contract ID after deployment and logs the ID for verification.
  • The actual deployment is validated in the beforeEach hook.

Test 2: Counter increment functionality

  • This test verifies that the add 1 message increments the counter in the contract.
  • It checks the counter value before and after the message is sent, ensuring that the value increases as expected.

Test 3: Saving a number in the contract

  • This test validates the functionality of the Save message.
  • It sends a message to store a specified amount in the savedNumber variable, and then verifies that the value was correctly updated in the contract.

Running the tests

  • All tests can be run using the Blueprint SDK by executing the test command: bash Copy code npx blueprint test

2.5 Deploy the TON smart contract to testnet

The Blueprint SDK provides built-in endpoints for deploying contracts on both mainnet and testnet. However, to improve performance and reliability, it’s recommended to use your Chainstack endpoint instead. To configure this:

  • Create a new configuration file named blueprint.config.ts in the project’s root directory.
  • Set up the network settings in this file by specifying the Chainstack endpoint, along with the network type (testnet or mainnet) and the version.
import { Config } from '@ton/blueprint';
export const config: Config = {
    network: {
        endpoint: 'YOUR_CHAINSTACK_ENDPOINT',
        type: 'testnet',
        version: 'v4',
        //key: 'YOUR_API_KEY',
    },
};

After configuring the custom endpoint, update the deployment script to include the contract ID. This ID can be generated randomly or customized based on the specific contract address generated during deployment.

// ./scripts/deploySimpleContract.ts
import { toNano } from '@ton/core';
import { SimpleContract } from '../wrappers/SimpleContract';
import { NetworkProvider } from '@ton/blueprint';
export async function run(provider: NetworkProvider) {
  
  	// Edit this ID
    const contractId = 1648n;
    const simpleContract = provider.open(await SimpleContract.fromInit(contractId));
    await simpleContract.send(
        provider.sender(),
        {
            value: toNano('0.5'),
        },
        {
            $$type: 'Deploy',
            queryId: 0n,
        },
    );
    // Deploy contract
    await provider.waitForDeploy(simpleContract.address);
    console.log(`Deployed at address ${simpleContract.address}`);
    // run methods on `simpleContract`
}

Before you run the script, however, do add the your wallet’s mnemonic phrase and version to your environment variables:

export WALLET_MNEMONIC=""
export WALLET_VERSION="v4"

Great! Now it’s time to run your script and deploy your TON smart contract:

npx blueprint run

The response should look like this:

Using file: deploySimpleContract
? Which network do you want to use? testnet
? Which wallet are you using? Mnemonic
Connected to wallet at address: EQDrNXDLYKstXHj5xV6_md1nYvvrb6y6v4bFyTZReZ-vFYdx
Sent transaction
Contract deployed at address EQDVoYZ96Gtc-nQM0U4-rj0mporVOTlSpmB64Tn6HJax98VN
You can view it at <https://testnet.tonscan.org/address/EQDVoYZ96Gtc-nQM0U4-rj0mporVOTlSpmB64Tn6HJax98VN>
Deployed at address EQDVoYZ96Gtc-nQM0U4-rj0mporVOTlSpmB64Tn6HJax98VN

2.6 How to interact with the deployed TON smart contract?

Calling a method in your contract executes an interaction, whether it’s incrementing the counter or saving a number. Reading the state can confirm the changes. Writing a script at this stage to interact with the contract on the testnet allows you to observe the contract’s behavior and ensure it’s performing as expected:

// ./scripts/getCounter.ts
import { SimpleContract } from '../wrappers/SimpleContract';
import { NetworkProvider } from '@ton/blueprint';
export async function run(provider: NetworkProvider) {
    const contractId = 1648n; // Random in this case
    const simpleContract = provider.open(await SimpleContract.fromInit(contractId));
    const id = await simpleContract.getId();
    const savedNumber = await simpleContract.getNumber();
    const counter = await simpleContract.getCounter();
    console.log(`Fethching smart contract data...`);
    console.log(`Contract ID: ${id}`);
    console.log(`Current saved number: ${savedNumber}`);
    console.log(`Current counter: ${counter}`);
}

After running it via npx blueprint run your result will follow this pattern:

? Choose file to use
? Choose file to use getCounter
? Which network do you want to use?
? Which network do you want to use? testnet
? Which wallet are you using?
? Which wallet are you using? Mnemonic
Connected to wallet at address: EQDrNXDLYKstXHj5xV6_md1nYvvrb6y6v4bFyTZReZ-vFYdx
Fethching smart contract data...
Contract ID: 1648
Current counter: 0

You can then use your wallet to commit a transaction with the message “Save” and some TON token to save the value with this script:

// ./scripts/getCounter.ts
import { toNano } from '@ton/core';
import { SimpleContract } from '../wrappers/SimpleContract';
import { NetworkProvider } from '@ton/blueprint';
export async function run(provider: NetworkProvider) {
    const contractId = 1648n;
    const simpleContract = provider.open(await SimpleContract.fromInit(contractId));
    const id = await simpleContract.getId();
    const counter = await simpleContract.getNumber();
    console.log(`Sending increasing value...`);
    console.log(`Contract ID: ${id}`);
    console.log(`Current counter: ${counter}`);
    // Call the Add function and add 7
    await simpleContract.send(provider.sender(), { value: toNano('0.02') }, { $$type: 'Save', amount: 7n });
}

That’s it! You’ve now successfully deployed a TON smart contract and interacted with it.

How to develop TON fungible tokens (Jettons)?

With a solid foundation in TON smart contract development, let’s now explore Jettons—the unique fungible tokens on the TON Network. As a Web3 developer, you not only need to understand what these tokens are, but also how to effectively create and manage them.

3.1 What are TON Jettons?

On TON, fungible tokens are known as Jettons, and they follow a specific standard outlined in TEP74. This standard defines the key mechanisms for Jetton transfers and how to access essential information such as the token’s name, circulating supply, and other common metadata. The metadata specification for Jettons is detailed in TEP64.

Jettons are structured around two key contract types:

  • Master smart contract (Jetton Minter): Responsible for minting new Jettons, managing the total supply, and providing shared information about the token.
  • Jetton-wallet contracts: These individual smart contracts hold the token balance for each user in a decentralized manner, ensuring that each owner has a unique Jetton wallet.

The TON core team has provided example contracts for the Jetton minter and wallet, which serve as the foundation for further development.

Figure 2: How to shard your TON smart contract and why; Source: TON

3.2 Get started with TON Jetton smart contracts

To start creating Jettons, you can set up a new Blueprint project or extend your existing one. This involves adding both the Jetton minter and wallet contracts to your contracts/ folder.

Jetton Minter and Wallet contracts serve different purposes in the Jetton ecosystem. The Jetton Minter contract takes charge of creating tokens and managing the total supply. On the other hand, the Jetton Wallet contract is assigned to each user and takes care of user balances and token transactions.

First run npm create ton@latest . in your CLI, then create a blueprint.config.ts file in your project root, if you haven’t done that in the previous steps already:

import { Config } from '@ton/blueprint';
export const config: Config = {
    network: {
        endpoint: '<https://ton-testnet.core.chainstack.com/.../api/v2/jsonRPC>',
        type: 'testnet',
        version: 'v2',
        // key: 'YOUR_API_KEY',
    },
};

Next, copy the necessary contracts from the standard implementation into your project’s contracts folder. You will need the following files: jetton-minter-discoverable.fc, jetton-wallet.fc, jetton-utils.fc, discovery-params.fc, op-codes.fc, and params.fc. Here’s a brief overview of their roles:

  • The minter contract allows the admin (contract owner) to control the minting process and manage the total token supply. It also implements the TEP89 feature, which simplifies the discovery of Jetton wallet addresses by other smart contracts. This is an important feature because, previously, contracts couldn’t directly access methods from other contracts.
  • The wallet contract manages the transfer, storage, and burning of Jettons for individual users. It keeps track of the wallet balance, the owner’s address, and information about the Jetton minter contract, updating these details after any transfers or burns.
  • The jetton-utils.fc file provides essential functions for creating and managing Jetton wallets. It ensures that all necessary data is packed and calculates the correct wallet address for each user.
  • The discovery-params.fc and op-code.fc files contain standard operational codes for the minter and wallet contracts. Additionally, they include the is_resolvable function, which checks whether an address is within the same workchain as the contract by comparing the workchain ID from the address with that of the contract. These checks are also included in params.fc.

Ensure your contracts have the necessary imports before proceeding.

// ./contracts/jetton-minter-discoverable.fc
#include "imports/stdlib.fc";
#include "jetton-utils.fc";
#include "discovery-params.fc";
#include "op-codes.fc";

For the wallet contract:

// ./contracts/jetton-wallet.fc
#include "jetton-utils.fc";
#include "op-codes.fc";

For utility functions:

// ./contracts/jetton-utils.fc
#include "params.fc";

And for contract parameters:

// ./contracts/params.fc
#include "imports/stdlib.fc";

3.3 Compile and build the TON Jetton smart contracts

Just like with TON smart contracts, your Jetton contracts must also be compiled using the Blueprint SDK. In the wrappers folder, create or update the compilation files:

// ./wrappers/JettonMinter.compile.ts
import { CompilerConfig } from '@ton/blueprint';
export const compile: CompilerConfig = {
    lang: 'func',
    targets: [
        'contracts/jetton-minter-discoverable.fc'
    ],
};

For the Jetton wallet:

// ./wrappers/JettonWallet.compile.ts
import { CompilerConfig } from '@ton/blueprint';
export const compile: CompilerConfig = {
    lang: 'func',
    targets: [
        'contracts/jetton-wallet.fc'
    ],
};

Finally, run the build command:

npx blueprint build

3.4 Create wrappers for Jettons

To run scripts and tests for the smart contracts, you’ll need to create TypeScript interfaces. These interfaces are contained in the wrappers folder and implement the Contract class from @ton/core. They also include serialization functions, getter wrappers, and compilation functions that streamline the interaction with your smart contracts.

To set this up, copy the following files into your project’s wrappers folder: JettonMinter.ts, JettonWallet.ts, JettonConstants.ts, and ui-utils.ts. If you prefer, you can use shortened versions of these files from an existing repository. Note that ui-utils.ts contains some additional adjustments for usability.

The Blueprint framework integrates Sandbox, a tool that allows developers to simulate the behavior of smart contracts as if they were deployed on a real blockchain. You can use this tool to test contracts within a controlled environment. Copy the test scripts from our repository, similar to how you copied the wrappers. These tests have been shortened and adjusted based on examples provided by the TON core team.

To run the tests using Sandbox, execute the following command:

npx blueprint test

3.5 Deploy the TON Jetton smart contracts

In the scripts folder, create a file called deployJettonMinter.ts. This script will handle the deployment of your Jetton minter contract. The following code sets up the deployment process:

// ./scripts/deployJettonMinter.ts
import {toNano} from '@ton/core';
import {JettonMinter} from '../wrappers/JettonMinter';
import {compile, NetworkProvider} from '@ton/blueprint';
import {jettonWalletCodeFromLibrary, promptUrl, promptUserFriendlyAddress} from "../wrappers/ui-utils";
export async function run(provider: NetworkProvider) {
    const isTestnet = provider.network() !== 'mainnet';
    const ui = provider.ui();
    const jettonWalletCodeRaw = await compile('JettonWallet');
    const adminAddress = await promptUserFriendlyAddress("Enter the address of the jetton owner (admin):", ui, isTestnet);
    const jettonMetadataUri = await promptUrl("Enter jetton metadata uri (<https://jettonowner.com/jetton.json>)", ui)
    const jettonWalletCode = jettonWalletCodeFromLibrary(jettonWalletCodeRaw);
    const minter = provider.open(JettonMinter.createFromConfig({
            admin: adminAddress.address,
            wallet_code: jettonWalletCode,
            jetton_content: {type: 1, uri: jettonMetadataUri}
        },
        await compile('JettonMinter'))
    );
    await minter.sendDeploy(provider.sender(), toNano("1.5")); // send 1.5 TON
}

The metadata for the Jetton token must be provided in JSON format. The JSON should contain details such as the token name, description, symbol, decimals, and base64-encoded image data. Here’s an example:

{
   "name": "Example Token",
   "description": "Official token",
   "symbol": "EXTO",
   "decimals": 9,
   "image_data": "4bWxuczPHN2ZyB0..."
}

With that out of the way, you are ready to deploy your Jetton contracts to the TON testnet. To do that run the following command:

npx blueprint run

This will start the deployment process, and once complete, your Jetton minter contract will be live on the TON testnet.

How to customize TON fungible tokens (Jettons)?

Developing fungible tokens in the form of Jettons is only part of the exciting journey on the TON Blockchain. As a Web3 developer working with Chainstack, you’ll be creating far more than standard tokens. You’ll be customizing those tokens to have unique features such as a capped supply and minting price. Here’s how to do it:

4.1 Modify the TON Jetton minter smart contract

The customization of your Jettons in this tutorial involves adding a capped supply and minting price. Capping supply limits the total number of tokens that can go into circulation, and the mint price ensures users pay a specific amount of TON for each Jetton minted.

To add these features, you’ll need to update the storage of the Jetton minter contract to include capped_supply and price fields:

(int, int, int, slice, cell, cell) load_data() inline {
    slice ds = get_data().begin_parse();
    return (
            ds~load_coins(), ;; total_supply
            ds~load_coins(), ;; capped_supply
            ds~load_uint(32), ;; price
            ds~load_msg_addr(), ;; admin_address
            ds~load_ref(), ;; content
            ds~load_ref() ;; jetton_wallet_code
    );
}
() save_data(int total_supply, int capped_supply, int price, slice admin_address, cell content, cell jetton_wallet_code) impure inline {
    set_data(begin_cell()
            .store_coins(total_supply)
            .store_coins(capped_supply)
            .store_uint(price, 32)
            .store_slice(admin_address)
            .store_ref(content)
            .store_ref(jetton_wallet_code)
            .end_cell()
    );
}

You’ll also need to add getter methods to fetch this data from the contract at any time:

(int, int, int, slice, cell, cell) get_jetton_data() method_id {
    (int total_supply, int capped_supply, int price, slice admin_address, cell content, cell jetton_wallet_code) = load_data();
    return (total_supply, capped_supply, price, admin_address, content, jetton_wallet_code);
}
slice get_wallet_address(slice owner_address) method_id {
    (_, _, _, _, _, cell jetton_wallet_code) = load_data();
    return calculate_user_jetton_wallet_address(owner_address, my_address(), jetton_wallet_code);
}
int get_token_price() method_id {
    (_, _, int price, _, _, _) = load_data();
    return price;

Your minting logic should be tweaked to ensure no minting occurs once the capped supply is reached. Further, you’ll need to compute exactly how many Jettons a user gets for the TON sent, factoring in the price per Jetton:

    if (op == op::mint()) {
        slice to_address = in_msg_body~load_msg_addr();
        int buy_amount = msg_value - min_tons_for_storage;
        int jetton_amount = muldiv(buy_amount, 1, price);
        ;; Check if minting exceeds the capped supply
        throw_unless(256, total_supply + jetton_amount <= capped_supply);
        var mint_request = begin_cell()
                        .store_uint(op::internal_transfer(), 32)
                        .store_uint(0, 64)
                        .store_coins(jetton_amount) ;; max 124 bit
                        .store_uint(0, 2) ;; from_address, addr_none$00
                        .store_slice(my_address()) ;; response_address, 3 + 8 + 256 = 267 bit
                        .store_coins(0) ;; forward_amount, 4 bit if zero
                        .store_uint(0, 1) ;; no forward_payload, 1 bit
                        .end_cell();
        mint_tokens(to_address, jetton_wallet_code, min_tons_for_storage, mint_request);
        save_data(total_supply + jetton_amount, capped_supply, price, admin_address, content, jetton_wallet_code);
        return ();
    }

4.2 Update the TON Jetton wrappers

In line with these changes, update your TypeScript wrappers for the Jetton contracts. They should now allow interaction with the capped supply and mint price, and gracefully handle any errors that arise if the capped supply is exceeded.

First, you’ll need to update the configuration in JettonMinter.ts to include two new fields: capped_supply and price. This will allow the minter contract to track the maximum token supply and set a price for minting new tokens.

export type JettonMinterConfig = {
    admin: Address;
    jetton_content: Cell | JettonMinterContent;
    wallet_code: Cell;
    capped_supply: bigint;
    price: bigint;
};
export function jettonMinterConfigToCell(config: JettonMinterConfig): Cell {
    const content = config.jetton_content instanceof Cell ? config.jetton_content : jettonContentToCell(config.jetton_content);
    return beginCell()
                      .storeCoins(0)
                      .storeCoins(config.capped_supply)
                      .storeUint(config.price, 32)
                      .storeAddress(config.admin)
                      .storeRef(content)
                      .storeRef(config.wallet_code)
           .endCell();
}

This updated configuration ensures that both the capped supply and price are stored and initialized during contract deployment.

Then as the next, embed the minting logic within the minter contract itself. The interface will now creates a simple minting message containing the necessary TON amount and the recipient’s address.

static mintMessage(from: Address, to: Address, query_id: number | bigint = 0) {
    return beginCell().storeUint(Op.mint, 32).storeUint(query_id, 64) // op, queryId
                      .storeAddress(to)
           .endCell();
}
async sendMint(provider: ContractProvider, via: Sender, to: Address, forward_ton_amount: bigint, total_ton_amount: bigint) {
    if(total_ton_amount < forward_ton_amount) {
        throw new Error("Total ton amount should be > forward amount");
    }
    await provider.internal(via, {
        sendMode: SendMode.PAY_GAS_SEPARATELY,
        body: JettonMinter.mintMessage(this.address, to),
        value: total_ton_amount
    });
}

This structure ensures that minting new Jettons is handled by sending a pre-packaged message that specifies the necessary details.

Since you’ve added new properties, the getter functions also need to be updated. The following methods retrieve information about the total supply, capped supply, and token price, and they include the newly added fields.

async getJettonData(provider: ContractProvider) {
    const res = await provider.get('get_jetton_data', []);
    const totalSupply = res.stack.readBigNumber();
    const cappedSupply = res.stack.readBigNumber();
    const price = res.stack.readBigNumber();
    const adminAddress = res.stack.readAddress();
    const content = res.stack.readCell();
    const walletCode = res.stack.readCell();
    return {
        totalSupply,
        cappedSupply,
        price,
        adminAddress,
        content,
        walletCode
    };
}
async getWalletAddress(provider: ContractProvider, owner: Address): Promise<Address> {
    const res = await provider.get('get_wallet_address', [{
        type: 'slice',
        cell: beginCell().storeAddress(owner).endCell()
    }])
    return res.stack.readAddress();
}
async getTokenPrice(provider: ContractProvider): Promise<BigInt> {
    const res = await provider.get('get_token_price', []);
    return res.stack.readBigNumber();
}

These updated getters allow you to retrieve and interact with the new properties, such as the token price and capped supply, making it easier to manage Jetton data.

4.3 Test the Jettons customization

Now it’s time to go back to the Sandbox and test your newly customized Jettons. Ensure your tests validate that the capped supply and mint price work correctly during minting. As always, make sure to cover scenarios where the total supply exceeds the cap or improper amounts of TON are sent for minting.

This first example test checks whether Jettons are minted correctly based on the amount of TON sent. It calculates the number of Jettons to be purchased, factoring in the costs, including storage fees. The initial Jetton balance and total supply are retrieved, and the minting message is sent:

    it('should mint correct amount of jettons based on the sent TON amount', async () => {
        // Calculate costs of minting
        const jettonsToPurchase = (await jettonMinter.getJettonData()).cappedSupply;
        const jettonsCost = jettonsToPurchase * price;
        const amountToSend = jettonsCost + toNano('1');  // Assuming 1 TON for storage fees
        const forwardFee = toNano('0.01');
        const expectedMintedJettons = jettonsCost / price;
        // Retrieve initial balance and supply
        const userJettonWallet = await userWallet(user.address);
        const initUserJettonBalance = await userJettonWallet.getJettonBalance();
        const initJettonSupply = (await jettonMinter.getJettonData()).totalSupply;
        // Send the minting message
        const res = await jettonMinter.sendMint(
            user.getSender(),
            user.address,
            forwardFee,
            amountToSend
        );
        // Verify the transaction
        expect(res.transactions).toHaveTransaction({
            on: userJettonWallet.address,
            op: Op.internal_transfer,
            success: true,
            deploy: true
        });
        // Verify that the user's minted jettons match the expected amount
        const currentUserJettonBalance = await userJettonWallet.getJettonBalance();
        const mintedUserJettons = currentUserJettonBalance - initUserJettonBalance;
        expect(mintedUserJettons).toEqual(expectedMintedJettons);
        // Verify that the total supply matches the expected amount of minted jettons
        const updatedTotalSupply = (await jettonMinter.getJettonData()).totalSupply;
        const mintedTotalSupply = updatedTotalSupply - initJettonSupply;
        expect(mintedTotalSupply).toEqual(expectedMintedJettons);
        printTransactionFees(res.transactions);
    });

After the transaction is complete, the test verifies that the correct amount of Jettons has been minted, checking both the user’s balance and the total supply. Additionally, the transaction details are validated, ensuring that the internal transfer was successful and the deployment of the user wallet occurred.

As the second example test, the goal is to check that the minting process does not exceed the capped supply. It calculates the amount of Jettons to purchase beyond the cap, along with the necessary costs:

    it('should not mint more than capped supply', async () => {
        // Calculate costs of minting
        const jettonsToPurchase = (await jettonMinter.getJettonData()).cappedSupply + 1n;
        const jettonsCost = jettonsToPurchase * price;
        const amountToSend = jettonsCost + toNano('1');  // Assuming 1 TON for storage fees
        const forwardFee = toNano('0.01');
        // Send the minting message
        const res = await jettonMinter.sendMint(
            user.getSender(),
            user.address,
            forwardFee,
            amountToSend
        );
        // Verify the transaction
        expect(res.transactions).toHaveTransaction({
            from: user.address,
            to: jettonMinter.address,
            aborted: true, // High exit codes are considered to be fatal
            exitCode: 256,
        });
    });

The test then attempts to send the minting message and verifies that the transaction is aborted due to exceeding the capped supply. The transaction is checked for the correct exit code to ensure that it failed as expected.

4.4 Deploy the customized TON Jettons

With your tests successful, you are ready to deploy this customization into the TON testnet. Deploy the Jetton minter contract that now includes capped supply and mint price. Once deployed, you can mint Jettons and check whether your rules are enforced correctly by interacting with the Jettons contracts:

import {toNano} from '@ton/core';
import {JettonMinter} from '../wrappers/JettonMinter';
import {compile, NetworkProvider} from '@ton/blueprint';
import {jettonWalletCodeFromLibrary, promptUrl, promptUserFriendlyAddress} from "../wrappers/ui-utils";
export async function run(provider: NetworkProvider) {
    const isTestnet = provider.network() !== 'mainnet';
    const ui = provider.ui();
    const jettonWalletCodeRaw = await compile('JettonWallet');
    const adminAddress = await promptUserFriendlyAddress("Enter the address of the jetton owner (admin):", ui, isTestnet);
    const jettonMetadataUri = await promptUrl("Enter jetton metadata uri (<https://jettonowner.com/jetton.json>)", ui)
    const jettonWalletCode = jettonWalletCodeFromLibrary(jettonWalletCodeRaw);
    const minter = provider.open(JettonMinter.createFromConfig({
            admin: adminAddress.address,
            wallet_code: jettonWalletCode,
            jetton_content: {type: 1, uri: jettonMetadataUri},
            capped_supply: 1000n,
            price: toNano('0.01')
        },
        await compile('JettonMinter'))
    );
    await minter.sendDeploy(provider.sender(), toNano("1.5")); // send 1.5 TON
}

Further reading

Expand your TON knowledge and development skills with these comprehensive Chainstack resources:

Bringing it all together

Congratulations! You’ve successfully traversed an educational journey from deploying a smart contract on the TON Network, developing Jetton tokens, to customizing them with advanced functionality like capped supply and mint price. With the knowledge you’ve gained, you’re ready to build, test, and deploy complex DApps on the TON Network using Chainstack, Blueprint SDK, and the exclusive capabilities of the ecosystem.

This detailed guide aimed to give you a structured, step-by-step approach to mastering TON development from basic smart contracts to fully customized fungible tokens. As you continue to explore and develop on TON, remember that Chainstack is with you every step of the way, ready to empower your blockchain adventures with our powerful and easy-to-use node infrastructure.

Power-boost your project on Chainstack

Have you already explored what you can achieve with Chainstack? Get started for free today.

SHARE THIS ARTICLE

Easy access to Bitcoin network with Chainstack

We are happy to welcome Bitcoin as the latest addition to Chainstack’s multi-protocol line-up. Developers can now make unlimited requests to free gateways to the Bitcoin network via shared Chainstack nodes.

Jona Smulders-Cohen
Nov 14
Customer Stories

Gamerse

Securing stable platform and token performance on BNB Chain, while reinforcing it with cross-chain support.

Definitive

Definitive tackles multi-chain data scalability with Dedicated Subgraphs and Debug & Trace for a 4X+ infrastructure ROI.

Trust

Trust Wallet leverages a custom gateway to handle high-volumes of requests across multiple networks with a 400% ROI on nodes.