DEV Community

Cover image for TON Smart Contract Pipeline Part4 - Chatbot Contract + Writing onchain tests on the testnet
Ivan Romanovich 🧐
Ivan Romanovich 🧐

Posted on

TON Smart Contract Pipeline Part4 - Chatbot Contract + Writing onchain tests on the testnet

Introduction

In this tutorial, we will analyze the chatbot smart contract and then write on-chain tests for it. In this tutorial, we will focus how to inspect transactions in tests and for onchain tests.

This tutorial is part of an open source course that I am currently updating, link to the repository, I will be glad to see your star

About TON

TON is an actor model is a mathematical parallel computing model that underlies TON smart contracts. In it, each smart contract can receive one message, change its own state, or send one or more messages per unit of time.

Most often, to create a full-fledged application on TON, you need to write several smart contracts that seem to communicate with each other using messages. In order for the contract to understand what it needs to do when a message arrives, it is recommended to use op. op is a 32-bit identifier that should be passed in the body of the message.

Thus, inside the message using conditional statements, depending on the smart contract op performs different actions.

Therefore, it is important to be able to test messages, which we will do today.

The chatbot smart contract receives any internal message and responds to it with an internal message with the reply text.

Parsing the contract

Standard Library

The first thing to do is import the standard library. The library is just a wrapper for the most common TVM (TON virtual machine) commands that are not built-in.

#include "imports/stdlib.fc";
Enter fullscreen mode Exit fullscreen mode

To process internal messages, we need the recv_internal() method

() recv_internal()  {

}
Enter fullscreen mode Exit fullscreen mode
External method arguments

Here a logical question arises - how to understand what arguments a function should have so that it can receive messages on the TON network?

According to the documentation of the TON virtual machine - TVM, when an event occurs on an account in one of the TON chains, it triggers a transaction.

Each transaction consists of up to 5 stages. More details here.

We are interested in Compute phase. And to be more specific, what is "on the stack" during initialization. For normal post-triggered transactions, the initial state of the stack looks like this:

5 elements:

  • Smart contract balance (in nanoTons)
  • Incoming message balance (in nanotones)
  • Cell with incoming message
  • Incoming message body, slice type
  • Function selector (for recv_internal it is 0)
() recv_internal(int balance, int msg_value, cell in_msg_full, slice in_msg_body)  {

}
Enter fullscreen mode Exit fullscreen mode

But it is not necessary to write all the arguments to recv_internal(). By setting arguments to recv_internal(), we tell the smart contract code about some of them. Those arguments that the code will not know about will simply lie at the bottom of the stack, never touched. For our smart contract, this is:

    () recv_internal(int msg_value, cell in_msg, slice in_msg_body) impure {

    }
Enter fullscreen mode Exit fullscreen mode
Gas to handle messages

Our smart contract will need to use the gas to send the message further, so we will check with what msg_value the message came, if it is very small (less than 0.01 TON), we will finish the execution of the smart contract with return().

#include "imports/stdlib.fc";

() recv_internal(int msg_value, cell in_msg, slice in_msg_body) impure {

  if (msg_value < 10000000) { ;; 10000000 nanoton == 0.01 TON
    return ();
  }

}
Enter fullscreen mode Exit fullscreen mode
Get the address

To send a message back, you need to get the address of the person who sent it to us. To do this, you need to parse the in_msg cell.

In order for us to take the address, we need to convert the cell into a slice using begin_parse:

var cs = in_msg_full.begin_parse();
Enter fullscreen mode Exit fullscreen mode

Now we need to "subtract" the resulting slice to the address. Using the load_uint function from the FunC standard library it loads an unsigned n-bit integer from the slice, "subtract" the flags.

var flags = cs~load_uint(4);
Enter fullscreen mode Exit fullscreen mode

In this lesson, we will not dwell on the flags in detail, but you can read more in paragraph 3.1.7.

And finally, the address. Use load_msg_addr() - which loads from the slice the only prefix that is a valid MsgAddress.

slice sender_address = cs~load_msg_addr(); 
Enter fullscreen mode Exit fullscreen mode

Code:

#include "imports/stdlib.fc";

() recv_internal(int msg_value, cell in_msg, slice in_msg_body) impure {

  if (msg_value < 10000000) { ;; 10000000 nanoton == 0.01 TON
    return ();
  }

  slice cs = in_msg.begin_parse();
  int flags = cs~load_uint(4); 
  slice sender_address = cs~load_msg_addr(); 

}
Enter fullscreen mode Exit fullscreen mode
Sending a message

Now you need to send a message back

Message structure

The full message structure can be found here - message layout. But usually we don't need to control each field, so we can use the short form from example:

 var msg = begin_cell()
    .store_uint(0x18, 6)
    .store_slice(addr)
    .store_coins(amount)
    .store_uint(0, 1 + 4 + 4 + 64 + 32 + 1 + 1)
    .store_slice(message_body)
  .end_cell();
Enter fullscreen mode Exit fullscreen mode

As you can see, functions of the FunC standard library are used to build the message. Namely, the "wrapper" functions of the Builder primitives (partially built cells, as you may remember from the first lesson). Consider:

begin_cell() - will create a Builder for the future cell
end_cell() - will create a Cell (cell)
store_uint - store uint in Builder
store_slice - store the slice in the Builder
store_coins - here the documentation means store_grams - used to store TonCoins. More details here.

Message body

In the body of the message we put op and our message reply, to put a message we need to do slice.

slice msg_text = "reply";
Enter fullscreen mode Exit fullscreen mode

In the recommendations about the body of the message, there is a recommendation to add op, despite the fact that it will not carry any functionality here, we will add it.

In order for us to create a similar client-server architecture on smart contracts described in the recommendations, it is proposed to start each message (strictly speaking, the message body) with some op flag, which will identify what operation the smart contract should perform.

Let's put op equal to 0 in our message.

Now the code looks like this:

#include "imports/stdlib.fc";

() recv_internal(int msg_value, cell in_msg, slice in_msg_body) impure {

  if (msg_value < 10000000) { ;; 10000000 nanoton == 0.01 TON
    return ();
  }

  slice cs = in_msg.begin_parse();
  int flags = cs~load_uint(4); 
  slice sender_address = cs~load_msg_addr(); 

  slice msg_text = "reply"; 

  cell msg = begin_cell()
      .store_uint(0x18, 6)
      .store_slice(sender_address)
      .store_coins(100) 
      .store_uint(0, 1 + 4 + 4 + 64 + 32 + 1 + 1)
      .store_uint(0, 32)
      .store_slice(msg_text) 
  .end_cell();

    }
Enter fullscreen mode Exit fullscreen mode

The message is ready, let's send it.

Message sending mode(mode)

To send messages, use send_raw_message from the standard library.

We have already collected the msg variable, it remains to figure out mode. Description of each mode is in documentation. Let's look at an example to make it clearer.

Let there be 100 coins on the balance of the smart contract and we receive an internal message with 60 coins and send a message with 10, the total fee is 3.

mode = 0 - balance (100+60-10 = 150 coins), send(10-3 = 7 coins)
mode = 1 - balance (100+60-10-3 = 147 coins), send(10 coins)
mode = 64 - balance (100-10 = 90 coins), send (60+10-3 = 67 coins)
mode = 65 - balance (100-10-3=87 coins), send (60+10 = 70 coins)
mode = 128 -balance (0 coins), send (100+60-3 = 157 coins)

As we choose mode, let's go to documentation:

  • We're sending a normal message, so mode 0.
  • We will pay the commission for the transfer separately from the cost of the message, which means +1.
  • We will also ignore any errors that occur during the processing of this message on the action phase, so +2.

We get mode == 3, the final smart contract:

#include "imports/stdlib.fc";

() recv_internal(int msg_value, cell in_msg, slice in_msg_body) impure {

  if (msg_value < 10000000) { ;; 10000000 nanoton == 0.01 TON
    return ();
  }

  slice cs = in_msg.begin_parse();
  int flags = cs~load_uint(4); 
  slice sender_address = cs~load_msg_addr(); 

  slice msg_text = "reply"; 

  cell msg = begin_cell()
      .store_uint(0x18, 6)
      .store_slice(sender_address)
      .store_coins(100) 
      .store_uint(0, 1 + 4 + 4 + 64 + 32 + 1 + 1)
      .store_uint(0, 32)
      .store_slice(msg_text) 
  .end_cell();

  send_raw_message(msg, 3);
}
Enter fullscreen mode Exit fullscreen mode

hexBoC

Before deploying a smart contract, you need to compile it into hexBoС, let's take the project from the previous tutorial.

Let's rename main.fc to chatbot.fc and write our smart contract into it.

Since we changed the filename, we need to upgrade compile.ts as well:

import * as fs from "fs";
import { readFileSync } from "fs";
import process from "process";
import { Cell } from "ton-core";
import { compileFunc } from "@ton-community/func-js";

async function compileScript() {

    const compileResult = await compileFunc({
        targets: ["./contracts/chatbot.fc"], 
        sources: (path) => readFileSync(path).toString("utf8"),
    });

    if (compileResult.status ==="error") {
        console.log("Error happend");
        process.exit(1);
    }

    const hexBoC = 'build/main.compiled.json';

    fs.writeFileSync(
        hexBoC,
        JSON.stringify({
            hex: Cell.fromBoc(Buffer.from(compileResult.codeBoc,"base64"))[0]
                .toBoc()
                .toString("hex"),
        })

    );

    console.log("Compiled, hexBoC:"+hexBoC);

}

compileScript();
Enter fullscreen mode Exit fullscreen mode

Compile the smart contract with the yarn compile command.

You now have a hexBoC representation of the smart contract.

Check if there is a transaction

Since we are using the draft of the previous tutorial as a template, we already have a test framework, open the main.spec.ts file and remove from there everything related to the GET method:

import { Cell, Address, toNano } from "ton-core";
import { hex } from "../build/main.compiled.json";
import { Blockchain } from "@ton-community/sandbox";
import { MainContract } from "../wrappers/MainContract";
import { send } from "process";
import "@ton-community/test-utils";

describe("test tests", () => {
    it("test of test", async() => {
        const codeCell = Cell.fromBoc(Buffer.from(hex,"hex"))[0];

        const blockchain = await Blockchain.create();

        const myContract = blockchain.openContract(
            await MainContract.createFromConfig({}, codeCell)
        );

        const senderWallet = await blockchain.treasury("sender");

        const sentMessageResult = await myContract.sendInternalMessage(senderWallet.getSender(),toNano("0.05"));

        expect(sentMessageResult.transactions).toHaveTransaction({
            from: senderWallet.address,
            to: myContract.address,
            success: true,
        });

    });
});
Enter fullscreen mode Exit fullscreen mode

We see that at the moment, it is checked whether the transaction has been sent to our smart contract. This is due to the sentMessageResult.transactions object. Let's take a close look at it and see what we can test based on this object.

If we just print this object to the console, it will consist of a lot of raw information, for convenience we will use flattenTransaction from @ton-community/test-utils:

import { Cell, Address, toNano } from "ton-core";
import { hex } from "../build/main.compiled.json";
import { Blockchain } from "@ton-community/sandbox";
import { MainContract } from "../wrappers/MainContract";
import { send } from "process";
import "@ton-community/test-utils";
import { flattenTransaction } from "@ton-community/test-utils";



describe("msg test", () => {
    it("test", async() => {
        const codeCell = Cell.fromBoc(Buffer.from(hex,"hex"))[0];

        const blockchain = await Blockchain.create();

        const myContract = blockchain.openContract(
            await MainContract.createFromConfig({}, codeCell)
        );

        const senderWallet = await blockchain.treasury("sender");

        const sentMessageResult = await myContract.sendInternalMessage(senderWallet.getSender(),toNano("0.05"));

        expect(sentMessageResult.transactions).toHaveTransaction({
            from: senderWallet.address,
            to: myContract.address,
            success: true,
        });

        const arr = sentMessageResult.transactions.map(tx => flattenTransaction(tx));
        console.log(arr)


    });
});
Enter fullscreen mode Exit fullscreen mode

What you see in the console can be used for tests, let's check that the message our chatbot sent is equal to reply.

Let's assemble the message, in accordance with what we collected in the smart contract.

    let reply = beginCell().storeUint(0, 32).storeStringTail("reply").endCell();
Enter fullscreen mode Exit fullscreen mode

Now, using messages, check that there is such a transaction:

import { Cell, Address, toNano, beginCell } from "ton-core";
import { hex } from "../build/main.compiled.json";
import { Blockchain } from "@ton-community/sandbox";
import { MainContract } from "../wrappers/MainContract";
import { send } from "process";
import "@ton-community/test-utils";
import { flattenTransaction } from "@ton-community/test-utils";



describe("msg test", () => {
    it("test", async() => {
        const codeCell = Cell.fromBoc(Buffer.from(hex,"hex"))[0];

        const blockchain = await Blockchain.create();

        const myContract = blockchain.openContract(
            await MainContract.createFromConfig({}, codeCell)
        );

        const senderWallet = await blockchain.treasury("sender");

        const sentMessageResult = await myContract.sendInternalMessage(senderWallet.getSender(),toNano("0.05"));

        expect(sentMessageResult.transactions).toHaveTransaction({
            from: senderWallet.address,
            to: myContract.address,
            success: true,
        });

        //const arr = sentMessageResult.transactions.map(tx => flattenTransaction(tx));

        let reply = beginCell().storeUint(0, 32).storeStringTail("reply").endCell();

        expect(sentMessageResult.transactions).toHaveTransaction({
            body: reply,
            from: myContract.address,
            to: senderWallet.address
        });

    });
});
Enter fullscreen mode Exit fullscreen mode

Run the tests with the yarn test command and see that everything works. Thus, in tests we can collect objects the same as in a smart contract and check that the transaction was.

Onchain tests

Sometimes a situation may arise that you need to run your smart contracts on the test network (a situation where there are a lot of contracts). Let's try this with our example.

In the scripts folder we will make the onchain.ts file, for ease of launch, add to package.json "onchain": "ts-node ./scripts/onchain.ts":

  "scripts": {
    "compile": "ts-node ./scripts/compile.ts",
    "test": "yarn jest",
    "deploy": "yarn compile && ts-node ./scripts/deploy.ts",
    "onchain": "ts-node ./scripts/onchain.ts"
  },
Enter fullscreen mode Exit fullscreen mode

Первое, что нам понадобиться для тестов, это адрес смарт-контракта, соберем его:

import { Cell, beginCell, contractAddress, toNano} from "ton-core";
import { hex } from "../build/main.compiled.json";
import { TonClient } from "ton";

async function onchainScript() {
    const codeCell = Cell.fromBoc(Buffer.from(hex,"hex"))[0];
    const dataCell = new Cell();

    const address = contractAddress(0,{
        code: codeCell,
        data: dataCell,
    });

    console.log("Address: ",address)

}

onchainScript();
Enter fullscreen mode Exit fullscreen mode

The test for the test network will offer us to deploy a transaction via a QR code into our smart contract and check every 10 seconds if the answer has appeared on the network.

Of course, this is a simplification for an example, the essence is just to show the logic.

Let's collect a QR code, by which we will conduct a transaction through Tonkeeper. For our example, it is important that the amount of TON is sufficient so as not to throw an exception written in the contract.

import { Cell, beginCell, contractAddress, toNano} from "ton-core";
import { hex } from "../build/main.compiled.json";
import { TonClient } from "ton";
import qs from "qs";
import qrcode from "qrcode-terminal";

async function onchainScript() {
    const codeCell = Cell.fromBoc(Buffer.from(hex,"hex"))[0];
    const dataCell = new Cell();

    const address = contractAddress(0,{
        code: codeCell,
        data: dataCell,
    });

    console.log("Address: ",address)

    let transactionLink =
    'https://app.tonkeeper.com/transfer/' +
    address.toString({
        testOnly: true,
    }) +
    "?" +
    qs.stringify({
        text: "Sent simple in",
        amount: toNano("0.6").toString(10),
    });

    console.log("Transaction link:",transactionLink);


    qrcode.generate(transactionLink, {small: true }, (qr) => {
        console.log(qr);
    });

}

onchainScript();
Enter fullscreen mode Exit fullscreen mode

In order to receive data from the test network, we need some kind of data source. Data can be obtained via ADNL from Liteservers, but we will talk about ADNL in the following tutorials. In this tutorial, we will use the TON Center API.

    const API_URL = "https://testnet.toncenter.com/api/v2"
Enter fullscreen mode Exit fullscreen mode

We will make requests through the Http client axios, install: yarn add axios.

Among the Toncenter methods, we need getTransactions with the limit 1 parameter, i.e. we will take the last transaction. Let's write two helper functions for requesting information:

// axios http client // yarn add axios
async function getData(url: string): Promise<any> {
    try {
      const config: AxiosRequestConfig = {
        url: url,
        method: "get",
      };
      const response: AxiosResponse = await axios(config);
      //console.log(response)
      return response.data.result;
    } catch (error) {
      console.error(error);
      throw error;
    }
  }

async function getTransactions(address: String) {
  var transactions;
  try {
    transactions = await getData(
      `${API_URL}/getTransactions?address=${address}&limit=1`
    );
  } catch (e) {
    console.error(e);
  }
  return transactions;
}
Enter fullscreen mode Exit fullscreen mode

Now we need a function that will call the API at intervals, for this there is a convenient method SetInterval:

import { Cell, beginCell, contractAddress, toNano} from "ton-core";
import { hex } from "../build/main.compiled.json";
import { TonClient } from "ton";
import qs from "qs";
import qrcode from "qrcode-terminal";
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";


const API_URL = "https://testnet.toncenter.com/api/v2"

    // axios http client // yarn add axios
async function getData(url: string): Promise<any> {
    try {
      const config: AxiosRequestConfig = {
        url: url,
        method: "get",
      };
      const response: AxiosResponse = await axios(config);
      //console.log(response)
      return response.data.result;
    } catch (error) {
      console.error(error);
      throw error;
    }
  }

async function getTransactions(address: String) {
  var transactions;
  try {
    transactions = await getData(
      `${API_URL}/getTransactions?address=${address}&limit=1`
    );
  } catch (e) {
    console.error(e);
  }
  return transactions;
}

async function onchainScript() {
    const codeCell = Cell.fromBoc(Buffer.from(hex,"hex"))[0];
    const dataCell = new Cell();

    const address = contractAddress(0,{
        code: codeCell,
        data: dataCell,
    });


    console.log("Address: ",address)

    let transactionLink =
    'https://app.tonkeeper.com/transfer/' +
    address.toString({
        testOnly: true,
    }) +
    "?" +
    qs.stringify({
        text: "Sent simple in",
        amount: toNano("0.6").toString(10),
        //bin: beginCell().storeUint(1,32).endCell().toBoc({idx: false}).toString("base64"),
    });

    console.log("Transaction link:",transactionLink);


    qrcode.generate(transactionLink, {small: true }, (qr) => {
        console.log(qr);
    });

    setInterval(async () => {
        const txes = await getTransactions(address.toString());
        if(txes[0].in_msg.source === "EQCj2gVRdFS0qOZnUFXdMliONgSANYXfQUDMsjd8fbTW-RuC") {

        }

    },10000)


}

onchainScript();
Enter fullscreen mode Exit fullscreen mode

It is important to note here that the API returns transactions, not messages, so we need to check that IN received the address of our wallet (here I just hardcoded it) and the message (which we put under the QR), and output the message of the first message in OUT. We also display the date, we get:

import { Cell, beginCell, contractAddress, toNano} from "ton-core";
import { hex } from "../build/main.compiled.json";
import { TonClient } from "ton";
import qs from "qs";
import qrcode from "qrcode-terminal";
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";


const API_URL = "https://testnet.toncenter.com/api/v2"

    // axios http client // yarn add axios
async function getData(url: string): Promise<any> {
    try {
      const config: AxiosRequestConfig = {
        url: url,
        method: "get",
      };
      const response: AxiosResponse = await axios(config);
      //console.log(response)
      return response.data.result;
    } catch (error) {
      console.error(error);
      throw error;
    }
  }

async function getTransactions(address: String) {
  var transactions;
  try {
    transactions = await getData(
      `${API_URL}/getTransactions?address=${address}&limit=1`
    );
  } catch (e) {
    console.error(e);
  }
  return transactions;
}

async function onchainScript() {
    const codeCell = Cell.fromBoc(Buffer.from(hex,"hex"))[0];
    const dataCell = new Cell();

    const address = contractAddress(0,{
        code: codeCell,
        data: dataCell,
    });


    console.log("Address: ",address)

    let transactionLink =
    'https://app.tonkeeper.com/transfer/' +
    address.toString({
        testOnly: true,
    }) +
    "?" +
    qs.stringify({
        text: "Sent simple in",
        amount: toNano("0.6").toString(10),
        //bin: beginCell().storeUint(1,32).endCell().toBoc({idx: false}).toString("base64"),
    });

    console.log("Transaction link:",transactionLink);


        qrcode.generate(transactionLink, {small: true }, (qr) => {
            console.log(qr);
        });

        setInterval(async () => {
            const txes = await getTransactions(address.toString());
            if(txes[0].in_msg.source === "EQCj2gVRdFS0qOZnUFXdMliONgSANYXfQUDMsjd8fbTW-RuC") {

                console.log("Last tx: " + new Date(txes[0].utime * 1000))
                console.log("IN from: "+ txes[0].in_msg.source+" with msg: "+ txes[0].in_msg.message)
                console.log("OUT from: "+ txes[0].out_msgs[0].source +" with msg: "+ txes[0].out_msgs[0].message)
            }

        },10000)


    }

    onchainScript();
Enter fullscreen mode Exit fullscreen mode

We launch the yarn onchain command, scan the QR, send the transaction and wait for our transaction to arrive.

Conclusion

I hope you enjoyed the pipeline series. I will be grateful to the asterisk on the repository.I publish tutorials here, if you liked the article, subscribe so as not to miss new ones.

Top comments (0)