Debugging and testing
Without fail, the code we write as smart contract developers doesn’t always do what we expect it to do. Sometimes it does something completely different! When the unexpected happens, the next task is to figure out why. To do so, there are various ways to reveal problems or “bugs” in the code. Let’s get to debugging!
General approach
At the moment, Tact doesn’t have a step-through debugger. Despite that, it’s still possible to use the “printf debugging” approach.
This involves actively placing dump()
and dumpStack()
function calls throughout your code and observing the states of variables at a given point in time. Note that these functions work only in debug mode and won’t be executed otherwise.
In addition to dumping values, it’s often helpful to use assertive functions like require()
, throwIf()
, and throwUnless()
. They help clearly state your assumptions and are handy for setting up “trip wires” for catching issues in the future.
If you can’t find or resolve the cause of your issues, try asking the community in Tact’s Telegram chat, or if your issue or question is generally related to TON more than to Tact, hop into the TON Dev Telegram chat.
Common debugging functions
Tact provides a handful of various functions useful for debugging: Core library → Debug.
Enabling debug mode in compilation options
In order to make certain functions like dump()
or dumpStack()
work, you need to enable debug mode.
The simplest and recommended approach is to modify the tact.config.json
file in the root of your project (or create it if it doesn’t exist yet) and set the debug
property to true
.
If you’re working on a Blueprint-based project, you can enable debug mode in the compilation configs of your contracts, which are located in a directory named wrappers/
:
import { CompilerConfig } from '@ton/blueprint';
export const compile: CompilerConfig = { lang: 'tact', target: 'contracts/your_contract_name.tact', options: { debug: true, // ← that's the stuff! }};
Note that versions of Blueprint starting with 0.20.0 automatically enable debug mode in wrappers/
for new contracts.
In addition to that, tact.config.json
may still be used in Blueprint projects. In such cases, values specified in tact.config.json
act as defaults unless modified in the wrappers/
.
Writing tests in Blueprint, with Sandbox and Jest
The Blueprint is a popular development framework for writing, testing, and deploying smart contracts on TON Blockchain.
For testing smart contracts, it uses the Sandbox, a local TON Blockchain emulator, and Jest, a JavaScript testing framework.
Whenever you create a new Blueprint project or use the blueprint create
command inside an existing project, it creates a new contract along with a test suite file for it.
Those files are placed in the tests/
folder and executed with Jest. By default, all tests run unless you specify a specific group or test closure. For other options, refer to the brief documentation in the Jest CLI: jest --help
.
Structure of test files
Let’s say we have a contract named Playground
, written in the contracts/playground.tact
file. If we’ve created that contract through Blueprint, it also created a tests/Playground.spec.ts
test suite file for us.
The test file contains a single describe()
Jest function call, which denotes a test group.
Inside that group, you’ll have three variables available in all tests within:
blockchain
— a local blockchain instance provided by Sandboxdeployer
— a TypeScript wrapper used for deploying ourPlayground
contract or any other we’d like to deployplayground
— a TypeScript wrapper for ourPlayground
contract
Then, a beforeEach()
Jest function is called — it specifies all the code to be executed before each of the subsequent test closures.
Finally, each test closure is described with a call to the it()
Jest function—that’s where tests are actually written.
The simplest example of a test closure can look like this:
it('should deploy', async () => { // The check is done inside beforeEach, so this can be empty});
Debug with dump()
To see the results of dump()
function calls and use the “printf debugging” approach, one has to:
- Put calls to
dump()
and other common debugging functions in relevant places within the code. - Run Jest tests, which will call the target functions and send messages to the target receivers.
Assuming you’ve created a new counter contract project, let’s see how it works in practice.
First, let’s place a call to dump()
in contracts/simple_counter.tact
, which will output the amount
passed in the msg
Struct to the contract’s debug console:
// ...receive(msg: Add) { dump(msg.amount); // ...}// ...
Next, let’s comment out all existing it()
test closures in the tests/SimpleCounter.spec.ts
file. Then add the following one:
it('should dump', async () => { await playground.send( deployer.getSender(), { value: toNano('0.5') }, { $$type: 'Add', queryId: 1n, amount: 1n }, );});
It sends a message to our contract’s receive(msg: Add)
receiver without storing the results of such send.
Now, if we build our contract with yarn build
and run our test suite with yarn test
, we’ll see the following in the test logs:
console.log #DEBUG#: [DEBUG] File contracts/simple_counter.tact:17:9 #DEBUG#: 1
at SmartContract.runCommon (node_modules/@ton/sandbox/dist/blockchain/SmartContract.js:221:21)
This output is produced by our dump()
call above.
State expectations with expect()
An integral part of writing tests is ensuring that your expectations match the observed reality. For that, Jest provides a function expect()
, which is used as follows:
- First, an observed variable is provided.
- Then, a specific method is called to check a certain property of that variable.
Here’s a more involved example, which uses the expect()
function to check that a counter contract properly increases the counter:
it('should increase counter', async () => { const increaseTimes = 3; for (let i = 0; i < increaseTimes; i++) { console.log(`increase ${i + 1}/${increaseTimes}`);
const increaser = await blockchain.treasury('increaser' + i);
const counterBefore = await simpleCounter.getCounter(); console.log('counter before increasing', counterBefore);
const increaseBy = BigInt(Math.floor(Math.random() * 100)); console.log('increasing by', increaseBy);
const increaseResult = await simpleCounter.send( increaser.getSender(), { value: toNano('0.05') }, { $$type: 'Add', queryId: 0n, amount: increaseBy } );
expect(increaseResult.transactions).toHaveTransaction({ from: increaser.address, to: simpleCounter.address, success: true, });
const counterAfter = await simpleCounter.getCounter(); console.log('counter after increasing', counterAfter);
expect(counterAfter).toBe(counterBefore + increaseBy); }});
Utility methods
Test files generated by Blueprint import the @ton/test-utils
library, which provides access to a number of additional helper methods for the result type of the expect()
function from Jest. Note that regular methods like toEqual()
are still there and ready to be used.
toHaveTransaction
The method expect(…).toHaveTransaction()
checks that the list of transactions contains a transaction matching certain properties you specify:
const res = await yourContractName.send(…);expect(res.transactions).toHaveTransaction({ // For example, let's check that a transaction to your contract was successful: to: yourContractName.address, success: true,});
To know the full list of such properties, look at the auto-completion options provided by your editor or IDE.
toEqualCell
The method expect(…).toEqualCell()
checks the equality of two cells:
expect(oneCell).toEqualCell(anotherCell);
toEqualSlice
The method expect(…).toEqualSlice()
checks the equality of two slices:
expect(oneSlice).toEqualSlice(anotherSlice);
toEqualAddress
The method expect(…).toEqualAddress()
checks the equality of two addresses:
expect(oneAddress).toEqualAddress(anotherAddress);
Interacting with TypeScript wrappers
Upon successful compilation, the Tact compiler produces a .ts
file that includes all necessary TypeScript wrappers for easy interaction with a compiled contract. You can use them in tests, deployment scripts, or elsewhere.
Some of the most commonly used generated utility functions, structural types, classes, and constants are described below.
For more, see: TypeScript wrappers on the Compilation page.
Structures and corresponding (de)composition functions
All the structs and message structs observable in the compilation report are provided as new type
definitions.
For each such definition, there are corresponding storeStructureName()
and loadStructureName()
functions to help compose and parse cells from these structures.
// The following import path is relevant for Blueprint projects,// but your mileage may vary — check your output/ or build/ folders for exact paths.import { StateInit, loadStateInit, storeStateInit } from '../wrappers/MyContract';import { beginCell } from '@ton/core';
let si1: StateInit = { $$type: 'StateInit', code: beginCell().endCell(), data: beginCell().endCell(),};
// Storing StateInit, mutating the passed Builderlet b = beginCell();storeStateInit(si1)(b);
// Loading StateInit, mutating the passed Slicelet s = b.asSlice();let si2 = loadStateInit(s);
// They are equalsi1.code === si2.code;si1.data === si2.data;
Contract wrapper class
The contract wrapper class provides convenient methods to send messages and call getters. It also exposes contract constants, a record of error string messages to exit code numbers, and a record of all message opcodes.
// MyContract is a contract class in the generated TypeScript bindings.// The following import path is relevant for Blueprint projects,// but your mileage may vary — check your output/ or build/ folders for exact paths.import { MyContract } from '../wrappers/MyContract';import { NetworkProvider } from '@ton/blueprint';import { toNano } from '@ton/core';
// The main function of each script in Blueprint projectsexport async function run(provider: NetworkProvider) { // Creating an instance of MyContract with no initial data const myContract = provider.open(await MyContract.fromInit());
// Deploying it via a simple message with `null` body await myContract.send( provider.sender(), { value: toNano('0.05') }, null, ); await provider.waitForDeploy(myContract.address);
// Now, let's call some getter await myContract.getGas();}
Bidirectional access for exit codes
Available since Tact 1.6.1You can access two records that mirror each other: a ContractName_errors
record, which maps exit code numbers to their error message strings, and a ContractName_errors_backward
record, which does the opposite — maps the error message strings to the exit code numbers. Organized this way, they provide convenient bidirectional access to exit codes.
Both records only feature the standard exit codes reserved by TON Blockchain and Tact compiler and those generated by require()
function. If you define some custom exit codes as global or contract constants, those will be exported separately.
Additionally, you can access the ContractName_errors_backward
record through the static field ContractName.errors
.
// MyContract is a contract class in the generated TypeScript bindings.// The following import path is relevant for Blueprint projects,// but your mileage may vary — check your output/ or build/ folders for exact paths.import { MyContract, // contract class MyContract_errors, // record of exit code numbers to error string messages MyContract_errors_backward, // record of error string messages to exit code numbers} from '../wrappers/MyContract';
// ...somewhere down in the body of the `it()` test closure...expect(MyContract_errors[37]).toBe("Not enough Toncoin");expect(receipt.transactions).toHaveTransaction({ from: walletV5.address, to: myContract, // instance of MyContract actionResultCode: MyContract_errors_backward['Not enough Toncoin'], // 37});
// Similar to the previous `toHaveTransaction()` check,// but now uses the static field instead of the global recordexpect(receipt.transactions).toHaveTransaction({ from: walletV5.address, to: myContract, // instance of MyContract actionResultCode: myContract.errors['Not enough Toncoin'], // 37});
Only the standard exit codes reserved by TON Blockchain and Tact compiler and those generated by require()
are present in those records. If you define some custom exit codes as global or contract constants, those will be exported separately.
// Global constantexport const CUSTOM_CONSTANT_NAME = 42n;
export class MyContract implements Contract { // Constants declared in the contract or its inherited traits public static readonly storageReserve = 0n;
// ...}
Message opcodes
Available since Tact 1.6.1Through the static field ContractName.opcodes
you can access opcodes of message structs that are declared or imported directly in the contract code and those exposed from the Core standard library.
// MyContract is a contract class in the generated TypeScript bindings.// The following import path is relevant for Blueprint projects,// but your mileage may vary — check your output/ or build/ folders for exact paths.import { MyContract } from '../wrappers/MyContract';
// ...somewhere down in the body of the `it()` test closure...expect(receipt.transactions).toHaveTransaction({ from: walletV5.address, to: escrowContract, // instance of MyContract op: escrowContract.opcodes.Funding, // 2488396969 (or 0x9451eca9 in hex)});
Send messages to contracts
To send messages to contracts, use the .send()
method on their TypeScript wrappers like so:
// It accepts 3 arguments:await yourContractName.send( // 1. sender of the message deployer.getSender(), // this is a default treasury, can be replaced
// 2. value and (optional) bounce, which is true by default { value: toNano('0.5'), bounce: false },
// 3. a message body, if any 'Look at me!',);
The message body can be a simple string or an object specifying fields of the Message type:
await yourContractName.send( deployer.getSender(), { value: toNano('0.5') }, { $$type: 'NameOfYourMessageType', field1: 0n, // bigint zero field2: 'yay', },);
More often than not, it’s important to store the results of such sends because they contain events that occurred, transactions made, and external messages sent:
const res = await yourContractName.send(…);// res.events — array of events occurred// res.externals — array of external-out messages// res.transactions — array of transactions made
With that, we can easily filter or check certain transactions:
expect(res.transactions).toHaveTransaction(…);
Observe the fees and values
Sandbox provides a helper function printTransactionFees()
, which pretty-prints all the values and fees used in the provided transactions. It is quite handy for observing the flow of nanoToncoins.
To use it, modify the imports from @ton/sandbox
at the top of the test file:
import { Blockchain, SandboxContract, TreasuryContract, printTransactionFees } from '@ton/sandbox';// ^^^^^^^^^^^^^^^^^^^^
Then, provide an array of transactions as an argument, like so:
printTransactionFees(res.transactions);
To work with individual values of total fees or fees from the compute and action phases, inspect each transaction individually:
// Storing the transaction handled by the receiver in a separate constantconst receiverHandledTx = res.transactions[1];expect(receiverHandledTx.description.type).toEqual('generic');
// Needed to please TypeScriptif (receiverHandledTx.description.type !== 'generic') { throw new Error('Generic transaction expected');}
// Total feesconsole.log('Total fees: ', receiverHandledTx.totalFees);
// Compute feeconst computeFee = receiverHandledTx.description.computePhase.type === 'vm' ? receiverHandledTx.description.computePhase.gasFees : undefined;console.log('Compute fee: ', computeFee);
// Action feeconst actionFee = receiverHandledTx.description.actionPhase?.totalActionFees;console.log('Action fee: ', actionFee);
// Now we can do some involved checks, like limiting the fees to 1 Toncoinexpect( (computeFee ?? 0n) + (actionFee ?? 0n)).toBeLessThanOrEqual(toNano('1'));
Transactions with intentional errors
Sometimes it’s useful to perform negative tests featuring intentional errors and throwing specific exit codes.
An example of such a Jest test closure in Blueprint:
it('throws specific exit code', async () => { // Send a specific message to our contract and store the result const res = await your_contract_name.send( deployer.getSender(), { value: toNano('0.5'), // value in nanoToncoins sent bounce: true, // (default) bounceable message }, 'the message your receiver expects', // ← change it to yours );
// Expect the transaction to our contract to fail with a certain exit code expect(res.transactions).toHaveTransaction({ to: your_contract_name.address, exitCode: 5, // ← change it to yours });});
Note that to track down transactions with a certain exit code, you only need to specify the exitCode
field in the object argument to the toHaveTransaction()
method of expect()
.
However, it’s useful to narrow the scope by specifying the recipient address to
, so that Jest looks only at the transaction caused by our message to the contract.
Simulate passage of time
The Unix time in local blockchain instances provided by Sandbox starts at the moment of their creation in the beforeEach()
block.
beforeEach(async () => { blockchain = await Blockchain.create(); // ← here // ...});
Previously, we’ve been warned not to modify the beforeEach()
block unless we really need to. Now, we must override the time and travel a little forward.
Let’s add the following line at the end of it, explicitly setting blockchain.now
to the time when the deployment message was handled:
beforeEach(async () => { // ... blockchain.now = deployResult.transactions[1].now;});
Now, we can manipulate time in our test clauses. For example, let’s make a transaction one minute after deployment and another one after two minutes:
it('your test clause title', async () => { blockchain.now += 60; // 60 seconds later const res1 = await yourContractName.send(…); blockchain.now += 60; // another 60 seconds later const res2 = await yourContractName.send(…);});
Logging via emit
A global static function emit()
sends a message to the outer world — it doesn’t have a specific recipient.
This function is very handy for logging and analyzing data off-chain — one just has to look at external messages produced by the contract.
Logs in local Sandbox tests
When deploying in the Sandbox, you may call emit()
from a receiver function and then observe the list of sent external messages:
it('emits', async () => { const res = await simpleCounter.send( deployer.getSender(), { value: toNano('0.05') }, 'emit_receiver', // ← change to the message your receiver handles );
console.log("Address of our contract: " + simpleCounter.address); console.log(res.externals); // ← here you would see results of emit() calls, // and all external messages in general});
Logs of a deployed contract
Every transaction on TON Blockchain contains out_msgs
— a dictionary that holds the list of outgoing messages created during the transaction execution.
To see logs from emit()
in that dictionary, look for external messages without a recipient. In various TON Blockchain explorers, such transactions will be marked as external-out
with the destination specified as -
or empty
.
Note that some explorers deserialize the message body sent for you, while others don’t. However, you can always parse it yourself locally.
Parsing the body of the emitted message
Consider the following example:
// We have a Structstruct Ballroom { meme: Bool; in: Int; theory: String;}
// And a simple contract,contract Bonanza { // which can receive a String message, receive("time to emit") { // emit a String emit("But to the Supes? Absolutely diabolical.".asComment());
// and a Struct emit(Ballroom{meme: true, in: 42, theory: "Duh"}.toCell()); }}
Now, let’s make a simple test clause for the Bonanza
contract:
it('emits', async () => { const res = await bonanza.send( deployer.getSender(), { value: toNano('0.05') }, 'time to emit', );});
Here, the res
object would contain the list of sent external messages as its externals
field. Let’s access it to parse the first message sent via a call to emit()
in Tact code (or emitted for short):
it('emits', async () => { // ... prior code ...
// We'll need only the body of the observed message: const firstMsgBody = res.externals[0].body;
// Now, let's parse it, knowing it's a text message. // NOTE: In a real-world scenario, // you'd want to check that first or wrap this in a try...catch const firstMsgText = firstMsgBody.asSlice().loadStringTail();
// "But to the Supes? Absolutely diabolical." console.log(firstMsgText);});
To parse the second emitted message, we could manually use several .loadSomething()
functions, but that’s overly brittle — if the fields of the Ballroom
Struct ever change, you’d need to start all over. That could significantly backfire when you have numerous tests written in that manner.
Fortunately, the Tact compiler auto-generates TypeScript bindings (or wrappers) for contracts, making it easy to reuse them in your test suite. Not only do they provide wrappers for the contract you’re testing, but they also export helper functions to store or load Structs and Messages defined in the contract. These helper functions have names similar to their corresponding Structs and Messages, prefixed with load
.
For example, in our case, we’ll need a function called loadBallroom()
, to parse a Slice
into the Ballroom
Struct in TypeScript. To import it, either type the name and let your IDE suggest auto-importing it for you, or look at the top of your test suite file — there should be a similar line:
import { Bonanza } from '../wrappers/Bonanza';// ^ here you could import loadBallroom
With that, let’s parse the second emitted message:
it('emits', async () => { // ... prior code ...
// We'll need only the body of the observed message: const secondMsgBody = res.externals[1].body;
// Now, let's parse it, knowing it's the Ballroom Struct. // NOTE: In a real-world scenario, // you'd want to check that first or wrap this in a try...catch const secondMsgStruct = loadBallroom(secondMsgBody.asSlice());
// { '$$type': 'Ballroom', meme: true, in: 42n, theory: 'Duh' } console.log(secondMsgStruct);});
Note that it’s also possible to parse emitted messages of deployed contracts outside of our test suite. You would simply need to obtain the emitted message bodies and use the auto-generated TypeScript bindings from Tact alongside the @ton/core
library, just like we’ve done in the examples above.
Handling bounced messages
When sent with bounce: true
, messages can bounce back in case of errors. Make sure to write the relevant bounced()
message receivers and handle bounced messages gracefully:
bounced(msg: YourMessage) { // ...alright squad, let's bounce!...}
Keep in mind that bounced messages in TON have only 224 usable data bits in their message body and do not have any references, so one cannot recover much data from them. However, you still get to see whether the message has bounced or not, allowing you to create more robust contracts.
Read more about bounced messages and receivers: Bounced messages.
Experimental lab setup
If you’re overwhelmed by the testing setup of Blueprint or just want to test some things quickly, worry not — there is a way to set up a simple playground as an experimental lab to test your ideas and hypotheses.
-
Create a new Blueprint project
This will prevent pollution of your existing project with arbitrary code and tests.
The new project can be named anything, but we’ll name it
Playground
to convey its intended purpose.To create it, run the following command:
Terminal window # recommendedyarn create ton tact-playground --type tact-empty --contractName PlaygroundTerminal window npm create ton@latest -- tact-playground --type tact-empty --contractName PlaygroundTerminal window pnpm create ton@latest tact-playground --type tact-empty --contractName PlaygroundTerminal window bun create ton@latest tact-playground --type tact-empty --contractName PlaygroundVersions of Blueprint starting with 0.20.0 automatically enable debug mode in
wrappers/
for new contracts, so we only have to adjust the testing suite and prepare ourPlayground
contract for testing. -
Update the test suite
Move into the newly created
tact-playground/
project, and in the filetests/Playground.spec.ts
, change the"should deploy"
test closure to the following:tests/Playground.spec.ts it('plays', async () => {const res = await playground.send(deployer.getSender(),{ value: toNano('0.5') }, // ← here you may increase the value in nanoToncoins sent'plays',);console.log("Address of our contract: " + playground.address);console.log(res.externals); // ← here one would see results of emit() calls}); -
Modify the contract
Replace the code in
contracts/playground.tact
with the following:contracts/playground.tact contract Playground {// Empty receiver for the deploymentreceive() {// Forward the remaining value in the// incoming message back to the sendercashback(sender());}receive("plays") {// NOTE: write your test logic here!}}The basic idea of this setup is to place the code you want to test into the receiver function responding to the string message
"plays"
.Note that you can still write any valid Tact code outside of that receiver, but in order to test it, you’ll need to write related test logic inside it.
-
Let’s test!
With that, our experimental lab setup is complete. To execute that single test we’ve prepared for our
Playground
contract, run the following:Terminal window yarn test -t playsFrom now on, to test something, you only need to modify the contents of the tested receiver function of your Tact contract file and re-run the command above. Rinse and repeat that process until you’ve tested what you intended to test.
For simplicity and cleaner output, you may add a new field to
scripts
in yourpackage.json
, enabling execution ofyarn lab
to build and test in one step.On Linux or macOS, it would look like:
{"scripts": {"lab": "blueprint build --all 1>/dev/null && yarn test -t plays"}}And here’s how it may look on Windows:
{"scripts": {"build": "blueprint build --all | out-null","lab": "yarn build && yarn test -t plays"}}To run:
Terminal window yarn lab