Введение

В этой статье мы разберем смарт-контракт чат-бот для сети TON(он удобен для понимания концепции тестов сообщений), а затем напишем для него onchain-тесты.

Это руководство является частью курса с открытым исходным кодом, который я сейчас обновляю, если вам интересны туториалы по блокчейну TON вот ссылка на репозиторий.

Про TON

TON представляет собой модель актора - это математическая модель параллельных вычислений, которая лежит в основе смарт-контрактов TON. В нем каждый смарт-контракт может получить одно сообщение, изменить собственное состояние или отправить одно или несколько сообщений в единицу времени.

Чаще всего для создания полноценного приложения на TON нужно писать несколько смарт-контрактов, которые как бы общаются друг с другом с помощью сообщений. Чтобы контракт понимал, что ему надо делать, когда в него приходит сообщение, рекомендуется использовать op. op - 32-битный идентификатор, который стоит передавать в теле сообщения.

Таким образом, внутри сообщения с помощью условных операторов, в зависимости от смарт-контракт op выполняет разные действия.

Поэтому важно уметь тестировать сообщения, чем мы сегодня и займемся.

Смарт-контракт чат-бот получает любое internal сообщение и отвечает на него internal сообщение с текстом reply.

Разбираем контракт

Стандартная библиотека

Первое, что надо сделать, это импортировать стандартную библиотеку. Библиотека представляет собой просто оболочку для наиболее распространенных команд TVM (виртуальной машины TON), которые не являются встроенными.

#include "imports/stdlib.fc";

Для обработки внутренних сообщений, нам понадобиться методrecv_internal()

() recv_internal()  {

}

Аргументы внешнего метода

Здесь возникает логичный вопрос - как понять какие аргументы должны быть у функции, чтобы она могла принимать сообщения в сети TON?

В соответствии с документацией виртуальной машины TON - TVM, когда на счете в одной из цепочек TON происходит какое-то событие, оно вызывает транзакцию.

Каждая транзакция состоит из до 5 этапов. Подробнее здесь.

Нас интересует Compute phase. А если быть конкретнее, что "в стеке" при инициализации. Для обычных транзакций, вызванных сообщением, начальное состояние стека выглядит следующим образом:

5 элементов:

  • Баланс смарт-контракта(в наноТонах)

  • Баланс входящего сообщения (в наноТонах)

  • Ячейка с входящим сообщением

  • Тело входящего сообщения, тип слайс

  • Селектор функции (для recv_internal это 0)

() recv_internal(int balance, int msg_value, cell in_msg_full, slice in_msg_body)  {

}

Но необязательно прописывать все аргументы recv_internal(). Устанавливая аргументы в recv_internal(), мы сообщаем коду смарт-контракта о некоторых из них. Те аргументы, о которых код не будет знать, будут просто лежать на дне стека, так и не тронутые. Для нашего смарт-контракта это:

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

	}

Газ для обработки сообщений

Нашему смарт-контракту нужно будет использовать газ для дальнейшей отправки сообщения, поэтому будем проверять с каким msg_value пришло сообщение, если оно очень маленькое ( меньше 0.01 TON) закончим выполнение смарт-контракта с помощью 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 ();
  }
  
}

Достаем адрес

Чтобы отправить сообщение обратно, нужно достать адрес того, кто нам его отправил. Для этого нужно разобрать ячейку in_msg.

Чтобы мы могли взять адрес, нам необходимо преобразовать ячейку в слайс c помощью begin_parse:

var cs = in_msg_full.begin_parse();

Теперь нам надо "вычитать" до адреса полученный slice. С помощью load_uint функции из стандартной библиотеки FunC она загружает целое число n-бит без знака из слайса, "вычитаем" флаги.

var flags = cs~load_uint(4);

В данном уроке мы не будем останавливаться подробно на флагах, но подробнее можно прочитать в пункте 3.1.7.

Ну и наконец-то адрес. Используем load_msg_addr() - которая загружает из слайса единственный префикс, который является допустимым MsgAddress.

slice sender_address = cs~load_msg_addr(); 

Получаем:

#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(); 

}

Отправка сообщения

Теперь нужно отправить сообщение обратно

Структура сообщения

С полной структурой сообщения можно ознакомиться здесь - message layout. Но обычно нам нет необходимости контролировать каждое поле, поэтому можно использовать краткую форму из примера:

 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();

Как вы можете видеть для построения сообщения используются функции стандартной библиотеки FunC. А именно функции "обертки" примитивов Builder (частично построенных ячеек как вы можете помнить из первого урока). Рассмотрим:

  • begin_cell() - создаст Builder для будущей ячейки

  • end_cell() - создаст Cell (ячейку)

  • store_uint - сохранит uint в Builder

  • store_slice - сохранит слайс в Builder

  • store_coins- здесь в документации имеется ввиду store_grams - используемой для хранения TonCoins. Подробнее здесь.

Message body

В тело сообщения мы положим op и наше сообщение reply, чтобы положить сообщение, нужно сделать slice.

slice msg_text = "reply";

В рекомендациях о теле сообщения, есть рекомендация добавлять op, несмотря на то, что здесь он не будет нести, какой-то функциональности, мы его добавим.

Чтобы мы могли создавать подобие клиент-серверной архитектуры на смарт-контрактах описанной в рекомендациях, предлагается начинать каждое сообщение(строго говоря тело сообщения) с некоторого флага op, который будет идентифицировать какую операцию должен выполнить смарт-контракт.

Положим в наше сообщение op равный 0.

Получим:

#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();

	}

Сообщение готово, отправим его.

Режим отправки сообщения(mode)

Для отправки сообщений используется send_raw_message из стандартной библиотеки.

Переменную msg мы уже собрали, остается разобраться mode. Описание каждого режиме есть в документации. Мы же рассмотрим на примере, чтобы было понятнее.

Пускай на балансе смарт-контракта 100 монет и мы получаем internal message c 60 монетами и отсылаем сообщение с 10, общий fee 3.

mode = 0 - баланс (100+60-10 = 150 монет), отправим(10-3 = 7 монет)

mode = 1 - баланс (100+60-10-3 = 147 монет), отправим(10 монет)

mode = 64 - баланс (100-10 = 90 монет), отправим (60+10-3 = 67 монет)

mode = 65 - баланс (100-10-3=87 монет), отправим (60+10 = 70 монет)

mode = 128 -баланс (0 монет), отправим (100+60-3 = 157 монет)

Как мы выберем mode, пойдем по документации:

  • Мы отправляем обычное сообщение, значит mode 0.

  • Оплачивать комиссию за перевод будем отдельно от стоимости сообщения, значит +1.

  • Будем также игнорировать любые ошибки, возникающие при обработке этого сообщения на action phase, значит +2.

Получаем mode == 3, итоговый смарт-контракт:

#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);
}

hexBoC

Прежде чем деплоить смарт-контракт, нужно его скомпилировать в hexBoС, давайте возьмем проект из предыдущего туторила.

Переименуем main.fc в chatbot.fc и запишем в него наш смарт-контракт.

Так как мы изменили имя файла, нужно модернизировать и compile.ts:

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();

Скомпилируйте смарт-контракт командой yarn compile.

Теперь у вас есть hexBoC представление смарт-контракта. Перейдем к тестам

Проверяем есть ли транзакция

Так как мы используем проект предыдущего туториала как шаблон, каркас тестов у нас уже есть, откроем файл main.spec.ts и удалим оттуда, все что касается GET метода:

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,
		});

	});
});

Мы видим, что на данный момент, проверяется, отправлена ли транзакция в наш смарт-контракт. Происходит, это благодаря объекту sentMessageResult.transactions. Давайте рассмотрим его пристально и разберемся, что мы может тестить опираясь на этот объект.

Если мы просто выведем в консоль этот объект, он будет состоять из большого количества raw информации, для удобства воспользуемся flattenTransaction из @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)


	});
});

То, что вы видите в консоли, можно использовать для тестов, давайте проверим, что сообщение, которое отправил наш чат-бот, равно reply.

Соберем сообщение, в соответствии с тем, что мы собирали в смарт-контракте.

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

Теперь, используя сообщения, проверим, что такая транзакция есть:

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
		});

	});
});

Запустите тесты с помощью команды yarn test и увидите, что все работает. Таким образом мы можем в тестах собирать объекты такие же как в смарт-контракте и проверять, что транзакция была.

Onchain тесты

Иногда может возникнуть ситуация, что вам надо прогнать работу ваших смарт-контрактов в тестовой сети(ситуация когда контрактов очень много). Попробуем сделать это на нашем примере.

В папке scripts сделаем файл onchain.ts, для удобства запуска, добавим в 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"
  },

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

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)

}

Тест для тестовой сети, будет предлагать нам задеплоить транзакцию через QR код в наш смарт-контракт и каждые 10 секунд проверять появилась ли ответ в сети.

Это конечно же упрощения для примера, суть просто показать логику.

Соберем QR код, по которому мы будем проводить транзакцию через Tonkeeper. Для нашего примера, важно, чтобы количество TON было достаточным, чтобы не вызывать исключение записанное в контракте.

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();

Чтобы получать данные из тестовой сети нам нужен какой-то источник данных. Данные можно получить по ADNL от Liteservers, но о ADNL поговорим в следующих туториалах. В данном туториале воспользуемся API TON центра.

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

Запросы будем делать через Http-клиент axios, установим: yarn add axios.

Среди методов Toncenter, нам нужен getTransactions c параметром limit 1, т.е. будем брать последнюю транзакцию. Напишем две вспомогательные функции для запроса информации:

// 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;
}

Теперь нам нужна функция, которая будет с интервалом вызывать API, для этого есть удобный метод 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();

Здесь важно отметить, что API отдает транзакции, а не сообщения, соответственно нам надо проверить, что IN пришло адреса нашего кошелька(здесь я его просто захардкодил) и сообщение(которое мы положили под QR), а в OUT вывести cообщение первого сообщения. Также выведем дату, получим:

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();

Запускаем командой yarn onchain, сканируем QR, отправляем транзакцию и ждем, когда придет наша транзакция.

Заключение

Я публикую здесь новые туториалы, а также статьи по аналитике данных в блокчейне например, как искать отмывочные сделки. Если вам понравилась статья, буду рад вашей подписке.

Комментарии (3)


  1. mikegordan
    07.09.2023 17:20

    вызов 1 функции является атомарной как транзакция в VM ton? Если не так, то как происходит откаты если вызвалась только 1 из 2 вызовов блокчейна ?

    ps я конечно никогда не видел столько магических цифер, какие то моды , режимы, ячейки, адреса памяти. Как будто это пост ассамблер.


  1. pnaydanovgoo
    07.09.2023 17:20

    Интересный Ton и совсем не похож на контракты в Ethereum подобных сетях. Я же правильно понял, что в статье говорится про тестирование в тестовой среде? Есть ли возможность тестировать на копии(форке) основной сети Ton?


    1. IvanRomanovich Автор
      07.09.2023 17:20

      В статье есть простой тест в тестовой среде, это где ton-community/test-utils, и в копии основной сети это on chain тесты. Разрабатывая вы пишите тесты для тестовой среды, а потом уже самые важные или те, что нельзя провести в тестовой среде в onchain. Контракты действительно не похожи на Ethreum, это особенно ощущается в стандартах токенов.