Book
Debugging

Debugging Tact contracts

Without fail, the code we write as smart contract developers doesn’t always do what we expected 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

Clarify the problem by asking yourself the right questions

It helps to clarify the problem that you ran into before you try to fix it. Furthermore, clearly stated problems are much more easier to understand for someone else — this is very handy if you want to get meaningful help when asking in Tact's Telegram chat (opens in a new tab) or filling an issue on Tact's GitHub (opens in a new tab).

So, before you start debugging, make sure you've identified the problem you're trying to solve:

  1. What did you expect your code to do?

  2. What happened instead?

    If you've run into a syntax error (breaking the rules of the language), reference error (using the wrong names), type error (confusing one type for another) or some other exception during compilation, that's great! This means that compiler has already found the issue for you, and now all that's left is to fix it.

    If something else happened, it's most likely a logic error in your code, when your expectations didn't match the actual state the contract got in. To resolve that, try stepping through your code and checking state of variables in it via dump() function or alike.

Examine your assumptions

Before you investigate a bug or an error, think of the assumptions that made you expect a certain result. Unknown or unclear expectations can get in the way of identifying a problem, even when you're looking right at the cause of the problem.

Here are a few questions to ask yourself to challenge your assumptions:

  • Are you using the right API (that is, the right global static function or extension function)? An API that you're using might not do what you think it does, so make sure to consult with the Reference section or ask in the Tact Telegram chat (opens in a new tab).

  • Are you using an API correctly? Maybe, you used the right API but didn't use it in the right way.

  • Did you make a change to your code and assume it's unrelated to the issue you're facing? A common pitfall here is to modify the code and try to run the tests right away, without compiling the changes first.

  • Did you expect variable or a Cell to contain a certain value (or a certain type of value) that is different from what was really there? Pay attention to your types and data layouts, especially their representation in TL-B schemas (opens in a new tab).

  • Do you know the intent of the code? It's often more difficult to debug someone else's code. If it's not your code, it's possible you might need to spend time learning exactly what the code does before you can debug it effectively.

💡

When writing contracts, start small and start with code that works! Sometimes, it is easier to fix a large or complicated set of code by starting with a small piece of code that demonstrates the core task you are trying to achieve. Then, you can modify or add code incrementally, testing at each point for errors.

Here, it may be helpful to test your assumptions in a small experimental playground before rolling out a complete implementation.

Go over your code and observe the values

At the moment, Tact doesn't have a step-through debugger. Despite that, it's still possible to use the "printf debugging" (opens in a new tab) approach.

It involves actively placing dump() and dumpStack() function calls throughout your code and observing states of variables at a given point of time. Note, that those functions work only in a debug mode and won't be executed otherwise.

Once you found that some value isn't equal to what you've expected it to be, don't rush to fixing the issue on the spot. That's because what you're seeing may not be the root cause of it and merely a symptom, effect. Be very careful with cause-and-effect relationships and figure out which's which to resolve the cause and not introduce new mess for your future self.

💡

See how to use dump() for debugging: Debug with dump().

In addition to dumping values, it's often helpful to use assertive functions like require(), nativeThrowIf() and nativeThrowUnless(). They help stating your assumptions clear, and are handy for setting up "trip wires" for catching issues in the future.

And if you didn't find or cannot resolve the cause of your issues, try asking the community in Tact's Telegram chat (opens in a new tab) or, if your issue or question is generally related to TON more than it's related to Tact, hop into TON Dev Telegram chat (opens in a new tab).

Common debugging functions

Tact provides a handful amount 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, one needs to enable debug mode.

The simplest and recommended approach is to modify a tact.config.json file in the root of your project (or create it if it didn't exist yet), and set the debug property to true.

If you're working on a Blueprint (opens in a new tab)-based project, you can enable debug mode in the compilation configs of your contracts, which are located in a directory named wrappers/:

wrappers/YourContractName.compile.ts
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 (opens in a new tab) 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 (opens in a new tab) projects. In such cases values specified in tact.config.json act as default unless modified in the wrappers/.

💡

Read more about configuration and tact.config.json file: Configuration.
See how to use dump() for debugging: Debug with dump().

Writing tests in Blueprint, with Sandbox and Jest

The Blueprint (opens in a new tab) is a popular development framework for writing, testing and deploying smart contracts on TON Blockchain.

For testing smart contracts it uses the Sandbox (opens in a new tab), a local TON Blockchain emulator and Jest (opens in a new tab), a JavaScript testing framework.

Whenever you create a new Blueprint (opens in a new tab) project or use blueprint create command inside the existing project, it creates a new contract alongside with a test suite file for it.

Those files are placed in tests/ folder and executed with Jest (opens in a new tab). By default, all tests run, unless you specify 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 that we have a contract named Playground, written in contracts/playground.tact file. If we've created that contract through Blueprint (opens in a new tab), then it also created a tests/Playground.spec.ts test suite file for us.

The test file contains a single describe() Jest (opens in a new tab) function call, which denotes a test group.

Inside that group, you'll have three variables, available in all tests within:

  • blockchain — local blockchain instance provided by Sandbox (opens in a new tab)
  • deployer — a TypeScript wrapper used for deploying our Playground contract or any other we'd like to be deployed
  • playground — a TypeScript wrapper for our Playground contract
💡

It's a common mistake to update .tact code and run tests without making a build first. That's because tests in Blueprint (opens in a new tab) rely on TypeScript wrappers generated by a Tact compiler and work with the latest build made.

That's why every time you make a change to your Tact code, make sure to also build it with npx blueprint build before you execute the test suite. For your convenience, you may unite builds and tests into a single command, as shown in the experimental lab setup.

Then, a beforeEach() Jest (opens in a new tab) function is called — it specifies all the code to be executed before each of the subsequent test closures.

💡

It is strongly advised not to modify the contents of beforeEach(), unless you really need some specific behavior for each test closure or parameters of your init() function have changed.

Finally, each test closure is described with a call to it() Jest (opens in a new tab) function — that's where tests are actually written.

A simplest example of the test closure can look like that:

it('should deploy', async () => {
  // The check is done inside beforeEach, so this can be empty
});

Debug with dump()

To see results of dump() function calls and use "printf debugging" approach, one has to:

  1. Put calls to dump() and other common debugging functions in relevant places of the code.
  2. Run Jest (opens in a new tab) tests, which would call target functions and send messages to 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 would output the amount passed in msg Struct to contract's debug console:

contracts/simple_counter.tact
// ...
receive(msg: Add) {
    dump(msg.amount);
    // ...
}
// ...

Next, let's comment out all existing it() test closures in tests/SimpleCounter.spec.ts file. And then add the following one:

tests/SimpleCounter.spec.ts
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)

Which is produced by of our dump() call above.

💡

Read more about sending messages to contracts in tests: Send messages to contracts.

State expectations with expect()

The integral parts of writing tests is ensuring that your expectations match the observed reality. For that, Jest (opens in a new tab) provides a function expect(), which is used as follows:

  1. First, an observed variable is provided.
  2. Then, a specific method is called to check a certain property of that variable.

Here's a more involved example, which uses expect() function to check that counter contract actually 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 (opens in a new tab) import @ton/test-utils library, which provides access to a number of additional helper methods for the result type of expect() Jest (opens in a new tab) function. 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 has 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 auto-completion options provided by your editor or IDE.

toEqualCell

The method expect(…).toEqualCell() checks equality of two cells:

expect(oneCell).toEqualCell(anotherCell);

toEqualSlice

The method expect(…).toEqualSlice() checks equality of two slices:

expect(oneSlice).toEqualSlice(anotherSlice);

toEqualAddress

The method expect(…).toEqualAddress() checks equality of two addresses:

expect(oneAddress).toEqualAddress(anotherAddress);

Send messages to contracts

To send messages to contracts, use .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!',
);

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 results of such sends, because they contain events 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 (opens in a new tab) provides a helper function printTransactionFees(), which pretty-prints all the values and fees that went into transactions provided. It is quite handy for observing the flow of nanoToncoins.

To use it, modify imports from @ton/sandbox on 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 compute and action phases (opens in a new tab), inspect each transaction individually:

// Storing the transaction handled by the receiver in a separate constant
const receiverHandledTx = res.transactions[1];
expect(receiverHandledTx.description.type).toEqual('generic');
 
// Needed to please TypeScript
if (receiverHandledTx.description.type !== 'generic') {
  throw new Error('Generic transaction expected');
}
 
// Total fees
console.log('Total fees: ', receiverHandledTx.totalFees);
 
// Compute fee
const computeFee = receiverHandledTx.description.computePhase.type === 'vm'
  ? receiverHandledTx.description.computePhase.gasFees
  : undefined;
console.log('Compute fee: ', computeFee);
 
// Action fee
const actionFee = receiverHandledTx.description.actionPhase?.totalActionFees;
console.log('Action fee: ', actionFee);
 
// Now we can do some involved checks, like limiting the fees to 1 Toncoin
expect(
  (computeFee ?? 0n)
  + (actionFee ?? 0n)
).toBeLessThanOrEqual(toNano('1'));
💡

Sandbox (opens in a new tab) has many more utility functions, which are often handy. For example, it provides prettyLogTransaction() and prettyLogTransactions(), which operate on a single or multiple transactions respectively and pretty-print flow of values between the addresses.

Transactions with intentional errors

Sometimes it's useful to make negative tests, featuring intentional errors and throwing specific exit codes.

Example of such Jest (opens in a new tab) test closure in Blueprint (opens in a new tab):

tests/YourTestFileHere.spec.ts
it('throws specific exit code', async () => {
  // Send a specific message to our contract and store the results
  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 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 exitCode field in toHaveTransaction() method of expect().

However, it's useful to narrow the scope by specifying the recipient address to, such that Jest would look 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 (opens in a new tab) starts at the moment of the creation of those in 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. And now, to override the time and time travel a little, we do.

Let's add the following line by the end of it, setting blockchain.now explicitly to the time when deployment message was handled:

beforeEach(async () => {
  // ...
  blockchain.now = deployResult.transactions[1].now;
});

Now, we can manipulate time in out test clauses. For example, let's make a transaction one minute after the deployment and another one after two:

it('your test clause title', async () => {
  blockchain.now += 60; // 60 seconds late
  const res1 = await yourContractName.send(…);
  blockchain.now += 60; // another 60 seconds late
  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 (opens in a new tab), 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 one 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 (opens in a new tab) — a dictionary that holds the list of outgoing messages that were created while executing the transaction.

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 destination specified as - or empty.

Note, that some explorers deserialize the message body sent for you, while others don't.

Handling bounced messages

When sent with bounce: true, messages can bounce back in case of errors. Make sure to write 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 224224 usable data bits in their message body and don't have any references, so one cannot recover much data from it. 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 (opens in a new tab) 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

That will prevent pollution of your existing one with arbitrary code and tests.

The new project can be named anything, but I'll name it Playground to convey the right intent.

To create it, run the following command:

Versions of Blueprint (opens in a new tab) 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 our Playground contract for testing.

Update the test suite

Move into the newly created tact-playground/ project and in the tests/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
import "@stdlib/deploy";
 
contract Playground with Deployable {
    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 of 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:

yarn test -t plays

From 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 wanted to test.

For simplicity and cleaner output's sake, you may add a new field to scripts in your package.json, such that you'll only need to run yarn lab to build and test in one.

On Linux or macOS, it would look like:

package.json
{
  "scripts": {
    "lab": "blueprint build 1>/dev/null && yarn test -t plays"
  }
}

And here's how it may look on Windows:

package.json
{
  "scripts": {
    "build": "blueprint build | out-null",
    "lab": "yarn build && yarn test -t plays"
  }
}

To run:

yarn lab