TON Smart Contract Development Tutorial

The TON blockchain has emerged as a robust platform offering scalability, speed, and security. Businesses are increasingly adopting it for the development of decentralized applications (dApps). To take advantage of its full potential, understanding TON’s smart contracts is essential.

This article is an introduction to TON smart contract development, where we guide you through the essential steps, tools, and best practices needed to get started.

Before you continue, we recommend checking our blog post on TON’s general principles and architecture.

Steps of smart contract development on TON

Smart contract development on TON includes several key steps. The overall process can be described as follows:

  1. Local setup: Creating a development environment on your computer.
  2. Writing code: Implementing the smart contract using the FunC language, which is then compiled into bytecode for the TON Virtual Machine (TVM). There is also a higher-level language called Tact, which compiles to FunC, but it is still under development.
  3. Testing: Simulating the contract’s behavior locally, ensuring it functions as expected before deployment.
  4. Deployment to testnet: Deploying the contract to a test environment that mirrors the main blockchain, allowing for further testing without using real funds.
  5. Deployment to mainnet: Finally, deploying the contract to the main blockchain, making it accessible to all TON users.

In the next sections, we’ll go through all these points in detail.

1. Setting up a local machine

Before you begin writing code, you’ll need to install some essential developer tools on your computer.

To streamline your workflow, your development environment will use several handy scripts for testing, compiling, and deploying code. The best language for these scripts is TypeScript, which after compilation into JavaScript code, can run on Node.js.

Node.js. is an open source server environment operating on different platforms—Mac OS X, Windows, Unix, Linux, etc. Ensure you have a recent version of Node, such as v18. To check which one you have, simply run node -v in your terminal.

You’ll also need a reliable IDE that supports FunC and TypeScript. For that, you can use Visual Studio Code—it’s free and open source. Additionally, install the FunC Plugin to enable syntax highlighting for the FunC language.

2. Create new project using Blueprint

There is an awesome tool that provides a development environment for writing, testing and deploying TON smart contracts called Blueprint. Let’s create a Blueprint project using the following command:

npm create ton@latest

It will ask you to enter the project name, the contract name and choose the project template. We’ll use func-blog-post project name, Main contract name and An empty contract (FunC) project template.

Blueprint will generate a project that has the following structure:

.

├── README.md

├── contracts

├── jest.config.ts

├── node\_modules

├── package-lock.json

├── package.json

├── scripts

├── tests

├── tsconfig.json

└── wrappers

Here is the brief description of some of the project folders:

  • contracts – source code of all smart contracts and their imports.
  • wrappers– contract wrappers, TypeScript interface classes to interact with the smart contracts.
  • tests – test suites for the contracts.
  • scripts – deployment scripts and other scripts for interacting with deployed contracts.

Implementing a counter contract in FunC

FunC is a programming language designed for creating smart contracts on the TON blockchain. FunC programs are compiled into the Fift assembly code, which then generates the corresponding bytecode for the TVM. This bytecode can be deployed on the blockchain or executed on a local instance of the TVM.

In this article, we’ll develop a simple counter smart contract step by step, covering the most important aspects of FunC development.

Cell as a main building block of data

Before starting to write the FunC code, it’s important to understand the details of memory layout in TVM, which defines the structure of data that FunC contracts work with.

In TON, everything is made from cells. The cell is a simple data structure that stores up to 1023 bits of data and up to 4 references to other cells, forming a tree. It allows building complex data structures, such as hash maps and lists, on the top of cells.

TON cell

While this model makes smart contract development more challenging, it gives great benefits in scalability of the TON blockchain.

Handling messages

Smart contracts in TON communicate using messages, and every contract should have a logic of handling the incoming message. Let’s return to the Blueprint project and open contracts/main.fc which has the following template code:


#include "imports/stdlib.fc";


() recv_internal(int my_balance, int msg_value, cell in_msg_full, slice in_msg_body) impure {
  
}

recv_internal is a function that handles incoming messages. It has the following arguments:

  • my_balance – the current balance of the contract in TON coins.
  • msg_value – the number of TON coins received along with the message,
  • in_msg_full – the message itself, including the information about the message sender, message flags and other data. It has a cell type,
  • in_msg_body – the part of the message with the actual payload which should be used by the contract’s logic. This argument has a slice type, since it’s a part of the cell.

msg_value and in_msg_body values can be extracted from the in_msg_full argument, but they are represented by separate arguments of recv_internal for convenience.

The recv_internal function definition also has an impure word after the arguments list. In FunC, it’s called a function specifier. The list of all possible specifiers and their meaning is described in the section below.

Function specifiers

In FunC, functions can have one or more specifiers that determine their behavior:

  • impure: This specifier means that the function can cause side effects, such as modifying the contract’s state, sending messages, or throwing exceptions. This is important because if a function is not marked as impure, and its result is not used, the FunC compiler might remove the function.
  • inline and inline_ref. If a function includes an inline specifier, its code is substituted in every place where the function is called. When an inline_ref modifier is used, the code of the function is stored in a separate cell and called by a special TVM instruction.
  • method_id. It specifies that this function is a getter function.

Storage management

In TON, each smart contract has its own dedicated storage space, which is structured to store the contract state.

The initial state of this storage is defined when the contract is deployed, and it can be modified through specific functions designed for writing and reading data. This storage is not only essential for the contract’s functionality but also impacts its address on the blockchain, as the address is derived from both the deployed bytecode and the initial storage data.

Before implementing the message handling logic in recv_internal, let’s define a couple of helper functions to work with the contract storage. The storage of our contract will consist of 2 integer variables: the current counter value, and the contract id, which is specified during the contract deployment and can not be changed. The purpose of having the id variable is the possibility of deploying multiple instances of counter contract. Given the fact that the contract address is derived from deployed bytecode and initial storage data, the id part will make the address of the counter contract unique.

First, let’s implement the functionality for reading the data from the contract storage:

(int, int) load_data() {
   slice ds = get_data().begin_parse(); ;; start parsing the cell


   int counter = ds~load_uint(32); ;; read the counter value from the slice as 32-bit integer
   int id = ds~load_uint(32); ;; read the id value from the slice as 32-bit integer


   ds.end_parse(); ;; finish parsing the slice and check that it's empty


   return (counter, id); ;; return the resulting values as tuple
}

The load_data function gets the contract persistent storage cell using the get_data function from FunC standard library, extracts the counter and id values from it and returns them.

We also need to implement the logic of writing the data to the contract storage:

() save_data(int counter, int id) impure {
   set_data( ;; sets the given cell as persistent contract data
       begin_cell() ;; start building the cell
           .store_uint(counter, 32) ;; store counter value as 32-bit integer
           .store_uint(id, 32) ;; store id value as 32-bit integer
           .end_cell() ;; finish building the cell
   );
}

The save_data function takes the counter and id values as arguments, creates a cell that stores these values, and sets it as a persistent contract storage using the set_data function from the standard library.

Note that this function has an impure specifier because it modifies the contract storage.

Implementing the counter logic

We implemented the load_data and save_data functions to manage the contract storage, and now we can use them to implement the message handling logic in the recv_internal function.

By convention, the operation is defined by the first 32 bits of in_msg_body, and the contract needs to parse this value and compare it with the known operation codes. Our smart contract will support only one operation – increasing the counter value in the storage by 1, and this operation will have opcode 1. The recv_internal implementation for the counter contract will look like this:

() recv_internal(int my_balance, int msg_value, cell in_msg_full, slice in_msg_body) impure {
  if (in_msg_body.slice_empty?()) { ;; ignore all empty messages
     return ();
 }


   int op = in_msg_body~load_uint(32); ;; load the opcode from the message body


   if (op == 1) { ;; '1' stands for 'increment' operation
     (int counter, int id) = load_data(); ;; load the current storage data
     save_data(counter + 1, id); ;; save the new storage data
     return ();
 }


   throw(0xffff); ;; throw the error if the opcode is unknown


}

It extracts the opcode from the in_msg_body and compares it with the expected value, updating the storage accordingly afterwards. The message body may also have the arguments of the operation following the opcode, but this is not the case in our counter contract.

If the message contains unknown opcode, the contract fails with 0xffff error code, which conventionally means “unknown operation”.

Getter functions

Getter is a special type of function within smart contracts that allows users to retrieve or query the current state of the contract without modifying it. Getters are designed for read-only interactions, meaning they do not change the contract’s persistent state or data. This enables you to check its status without affecting its value.

Getters in TON facilitate data access for users and other contracts. When a user calls a getter function, the contract processes the request and returns the requested data. This allows for integration with decentralized applications that rely on real-time data from the blockchain.

One of the most important details about getter functions is that they can’t be called by other smart contracts, i.e. the smart contract cannot read the state of another smart contract.

Let’s implement the getter functions for the counter smart contract to get the current counter value and contract id respectively:

int get_counter() method_id {
   (int counter, _) = load_data();
   return counter;
}


int get_id() method_id {
   (_, int id) = load_data();
   return id;
}

Both these functions read the contract storage using load_data and return the respective value. Note that they have a method_id specifier indicating that this is a getter function that can only be called off-chain, but not by other smart contracts.

(spoiler) The full code of the main.fc

#include "imports/stdlib.fc";


(int, int) load_data() {
  slice ds = get_data().begin_parse(); ;; start parsing the cell


  int counter = ds~load_uint(32); ;; read the counter value from the slice as 32-bit integer
  int id = ds~load_uint(32); ;; read the id value from the slice as 32-bit integer


  ds.end_parse(); ;; finish parsing the slice and check that it's empty


  return (counter, id); ;; return the resulting values as tuple
}


() save_data(int counter, int id) impure {
  set_data( ;; sets the given cell as persistent contract data
      begin_cell() ;; start building the cell
          .store_uint(counter, 32) ;; store counter value as 32-bit integer
          .store_uint(id, 32) ;; store id value as 32-bit integer
          .end_cell() ;; finish building the cell
  );
}


() recv_internal(int my_balance, int msg_value, cell in_msg_full, slice in_msg_body) impure {
 if (in_msg_body.slice_empty?()) { ;; ignore all empty messages
     return ();
 }


 int op = in_msg_body~load_uint(32); ;; load the opcode from the message body


 if (op == 1) { ;; '1' stands for 'increase counter' operation
     (int counter, int id) = load_data(); ;; load the current storage data
     save_data(counter + 1, id); ;; save the new storage data
     return ();
 }


 throw(0xffff); ;; throw the error if the opcode is unknown
}


int get_counter() method_id {
   (int counter, _) = load_data();
   return counter;
}


int get_id() method_id {
   (_, int id) = load_data();
   return id;
}

Compilation

The final step is to compile the FunC code. This step ensures that the written code is syntactically correct and can be executed on the TVM. Blueprint has the functionality of compiling contracts and configuring compilation settings. The Blueprint project has the wrappers/Main.compile.ts file with default compilation options:

import { CompilerConfig } from '@ton/blueprint';


export const compile: CompilerConfig = {
  lang: 'func',
  targets: ['contracts/main.fc'],
};

This compiler config specifies that the contract is implemented in FunC language, and its sources are located in the contracts/main.fc file. The sources of more complex contracts can be located in multiple files, and all of them can be specified in the targets field of the CompilerConfig object.

The contract can be compiled using the npm run build command, and the compiled code will be located in the build/Main.compiled.json file.

Testing

After successfully compiling the contract, you need to ensure the contract’s code functions as expected before deployment to the blockchain.

Blueprint uses the sandbox library that allows emulating arbitrary TON smart contracts, sending messages to them and implementing assertions on the current contract state and transaction statuses.

Implementing the contract wrapper

Before writing the sandbox tests, you should first implement the wrapper for your smart contract. The wrapper is a TypeScript class which includes message (de-)serialization primitives and the logic for calling getter functions. The wrapper class then can be used in the client code and test suite to interact with the contracts from TypeScript.

The Blueprint project already has a default implementation of the contract wrapper located in wrappers/Main.ts

export type MainConfig = {};


export function mainConfigToCell(config: MainConfig): Cell {
  return beginCell().endCell();
}


export class Main implements Contract {
  constructor(readonly address: Address, readonly init?: { code: Cell; data: Cell }) {}


  static createFromAddress(address: Address) {
      return new Main(address);
  }


  static createFromConfig(config: MainConfig, code: Cell, workchain = 0) {
      const data = mainConfigToCell(config);
      const init = { code, data };
      return new Main(contractAddress(workchain, init), init);
  }


  async sendDeploy(provider: ContractProvider, via: Sender, value: bigint) {
      await provider.internal(via, {
          value,
          sendMode: SendMode.PAY_GAS_SEPARATELY,
          body: beginCell().endCell(),
      });
  }
}

This file consists of the Main class which implements the Contract interface from the @ton/core library and has some functions to create the wrapper instance and a function to send the deploy message to the contract.

The initial storage data is represented by the MainConifg data type which is currently empty. We need to modify it to add counter and id fields, as well as modify the mainConfigToCell function to serialize this data type into a cell. The correct implementation looks like this:

export type MainConfig = {
   counter: number,
   id: number
};


export function mainConfigToCell(config: MainConfig): Cell {
   return beginCell()
       .storeUint(config.counter, 32)
       .storeUint(config.id, 32)
       .endCell();
}

Apart from this, we also need to implement the function to send the increment message to the smart contract:

async sendIncrement(provider: ContractProvider, via: Sender, value: bigint) {
       await provider.internal(via, {
           value, // the amount of TON coins
           sendMode: SendMode.PAY_GAS_SEPARATELY, // specifies that gas costs will be covered separately from the message value
           body: beginCell()
               .storeUint(1, 32) // the opcode for 'increment' operation
               .endCell()
       })
   }

We also need the function to call the get_counter and get_id get methods:

async getCounter(provider: ContractProvider) {
       const result = await provider.get('get_counter', []);
       return result.stack.readNumber();
}


async getId(provider: ContractProvider) {
       const result = await provider.get('get_id', []);
       return result.stack.readNumber();
}

(spoiler) The full code of the Main.ts

import { Address, beginCell, Cell, Contract, contractAddress, ContractProvider, Sender, SendMode } from '@ton/core';


export type MainConfig = {
   counter: number,
   id: number
};


export function mainConfigToCell(config: MainConfig): Cell {
   return beginCell()
       .storeUint(config.counter, 32)
       .storeUint(config.id, 32)
       .endCell();
}


export class Main implements Contract {
   constructor(readonly address: Address, readonly init?: { code: Cell; data: Cell }) {}


   static createFromAddress(address: Address) {
       return new Main(address);
   }


   static createFromConfig(config: MainConfig, code: Cell, workchain = 0) {
       const data = mainConfigToCell(config);
       const init = { code, data };
       return new Main(contractAddress(workchain, init), init);
   }


   async sendDeploy(provider: ContractProvider, via: Sender, value: bigint) {
       await provider.internal(via, {
           value,
           sendMode: SendMode.PAY_GAS_SEPARATELY,
           body: beginCell().endCell(),
       });
   }


   async sendIncrement(provider: ContractProvider, via: Sender, value: bigint) {
       await provider.internal(via, {
           value, // the amount of TON coins
           sendMode: SendMode.PAY_GAS_SEPARATELY, // specifies that gas costs will be covered separately from the message value
           body: beginCell()
               .storeUint(1, 32) // the opcode for 'increment' operation
               .endCell()
       })
   }


   async getCounter(provider: ContractProvider) {
       const result = await provider.get('get_counter', []);
       return result.stack.readNumber();
   }


   async getId(provider: ContractProvider) {
       const result = await provider.get('get_id', []);
       return result.stack.readNumber();
   }
}

Implementing sandbox tests

The test suite for the Main contract is located in the tests/Main.spec.ts file. It already contains some logic to compile the smart contract and deploy it to the emulated environment before running the tests.

We need to implement the test in this test suite which sends the increment message to the contract and checks that the transaction succeeded and the storage is updated correctly.

it("should handle the 'increment' message and update counter in the storage", async () => {
       const counterBefore = await main.getCounter();
       const sendResult = await main.sendIncrement(deployer.getSender(), toNano('0.05'));
      
       expect(sendResult.transactions).toHaveTransaction({
           from: deployer.address,
           to: main.address,
           success: true
       });


       const counterAfter = await main.getCounter();
       expect(counterAfter).toBe(counterBefore + 1);
   });

(spoiler) The full code of the Main.spec.ts

import { Blockchain, SandboxContract, TreasuryContract } from '@ton/sandbox';
import { Cell, toNano } from '@ton/core';
import { Main } from '../wrappers/Main';
import '@ton/test-utils';
import { compile } from '@ton/blueprint';


describe('Main', () => {
   let code: Cell;


   beforeAll(async () => {
       code = await compile('Main');
   });


   let blockchain: Blockchain;
   let deployer: SandboxContract<TreasuryContract>;
   let main: SandboxContract<Main>;


   beforeEach(async () => {
       blockchain = await Blockchain.create();


       main = blockchain.openContract(Main.createFromConfig({ counter: 0, id: 0 }, code));


       deployer = await blockchain.treasury('deployer');


       const deployResult = await main.sendDeploy(deployer.getSender(), toNano('0.05'));


       expect(deployResult.transactions).toHaveTransaction({
           from: deployer.address,
           to: main.address,
           deploy: true,
           success: true,
       });
   });


   it('should deploy', async () => {
       // the check is done inside beforeEach
       // blockchain and main are ready to use
   });


   it("should handle the 'increment' message and update counter in the storage", async () => {
       const counterBefore = await main.getCounter();
       const sendResult = await main.sendIncrement(deployer.getSender(), toNano('0.05'));
      
       expect(sendResult.transactions).toHaveTransaction({
           from: deployer.address,
           to: main.address,
           success: true
       });


       const counterAfter = await main.getCounter();
       expect(counterAfter).toBe(counterBefore + 1);
   });
});

Running tests

Once the tests are written, run them using the npm run test.

How to deploy smart contracts on TON

Blueprint also provides the functionality to deploy the smart contract to testnet and mainnet. To deploy the contract using Blueprint, you need to implement the deployment script which compiles the contract, sets its initial storage data and sends the deploy message.

Let’s see how to deploy a contract using Blueprint.

Setting up the testnet wallet

Before deploying the contract, you need to have a testnet wallet, as testnet operates with test TON coins that don’t hold real-world value. Wallet apps like Tonkeeper and Tonhub have the functionality for creating a testnet wallet. Once your wallet is set up, you can get test TON coins via the official testnet giver Telegram bot.

Writing the deploy script

The deploy script is located in the scripts/deployMain.ts file. Blueprint generates the default deploy script with the following contents:

import { toNano } from '@ton/core';
import { Main } from '../wrappers/Main';
import { compile, NetworkProvider } from '@ton/blueprint';


export async function run(provider: NetworkProvider) {
   const main = provider.open(Main.createFromConfig({}, await compile('Main')));


   await main.sendDeploy(provider.sender(), toNano('0.05'));


   await provider.waitForDeploy(main.address);


   // run methods on `main`
}

As well as the sandbox test suite, it uses the TypeScript wrapper of our contract to create the smart contract instance from the initial code and data. We need to modify the script to generate the random contract id and create the Main contract wrapper using the generated id value and 0 as a default counter value.

import { toNano } from '@ton/core';
import { Main } from '../wrappers/Main';
import { compile, NetworkProvider } from '@ton/blueprint';


export async function run(provider: NetworkProvider) {
   const id = Math.floor(Math.random() * 10000);
   const main = provider.open(
       Main.createFromConfig(
           {
               id,
               counter: 0,
           },
           await compile('Main')
       )
   );


   await main.sendDeploy(provider.sender(), toNano('0.05'));


   await provider.waitForDeploy(main.address);


   console.log('Contract id', await main.getId());
}

This script deploys the contract with randomly generated id and prints the id to the console after deployment.

Deploying the contract to testnet

Once the script is ready, you can execute it with the npm run start command. Then choose the network to deploy the smart contract to and approve the transaction using any of supported wallet apps. As a result, the script displays the deployed contract address and the contract id. You can navigate the transactions on this smart contract using blockchain explorers like Tonviewer and Tonscan.

Post-deployment testing

After deployment, you have to test the contract’s functionality. This involves creating a post-deploy script that connects to the blockchain via decentralized RPC nodes to send the messages to the contract and read its state by calling getter functions.

Preparing for mainnet deployment

The process of deployment of the contract to mainnet is similar to the process of testnet deployment, but involves using a mainnet wallet with real TON coins, and requires choosing the mainnet network when running a deploy script.

Deploying the contract to mainnet makes it accessible to all TON users, so you need to be sure that you tested your contract behavior extensively with sandbox tests and on testnet, and the contract doesn’t have any security vulnerabilities.

Conclusion

We have gone through the key steps of smart contract development on TON. While TON offers advanced features that support the development of decentralized applications, it also requires developers to be well-versed in its specific technologies, such as the TON Virtual Machine (TVM) and the FunC programming language. Educating yourself on these aspects through research, practice, and learning from the community. As the ecosystem around TON matures, it is essential to observe how developers leverage its tools to create innovative solutions in the decentralized space.

Banner that links to Serokell Shop. You can buy stylish FP T-shirts there!
More from Serokell
Best Elixir Conferences to AttendBest Elixir Conferences to Attend
Datasets for machine learningDatasets for machine learning
Feature Engineering for Machine Learning | SerokellFeature Engineering for Machine Learning | Serokell