Skip to content

调试 Tact 合约

javascript
import { Callout, Steps, Tabs } from 'nextra/components'

作为智能合约开发者,我们编写的代码并不总是按预期运行。有时会发生完全不同的情况!当出现意外时,接下来的任务是找出原因。为此,有多种方法可以揭示代码中的问题或“错误”。让我们开始调试吧!

通用方法 [#approach]

常见调试函数 [#debug-functions]

Tact 提供了许多有用的调试函数:核心库 → 调试

在编译选项中启用调试模式 [#debug-mode]

为了使某些函数如 dump()dumpStack() 工作,需要启用调试模式。

最简单且推荐的方法是修改项目根目录中的 tact.config.json 文件(如果它尚不存在,请创建它),并debug 属性设置为 true{:json}

如果你正在处理基于 Blueprint 的项目,可以在位于 wrappers/ 目录中的合约编译配置中启用调试模式:

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

export const compile: CompilerConfig = {
  lang: 'tact',
  target: 'contracts/your_contract_name.tact',
  options: {
    debug: true, // ← 就是这个!
  }
};

注意,从 0.20.0 版本开始,Blueprint 自动在 wrappers/ 中为新合约启用调试模式。

此外,Blueprint 项目中仍然可以使用 tact.config.json。在这种情况下,tact.config.json 中指定的值作为默认值,除非在 wrappers/ 中进行了修改。

在 Blueprint 中编写测试,使用 Sandbox 和 Jest [#tests]

Blueprint 是一个流行的开发框架,用于在 TON 区块链上编写、测试和部署智能合约。

为了测试智能合约,它使用 Sandbox,一个本地 TON 区块链模拟器和 Jest,一个 JavaScript 测试框架。

每当你创建一个新的 Blueprint 项目或在现有项目中使用 blueprint create 命令时,它会创建一个新的合约以及一个测试套件文件。

这些文件放置在 tests/ 文件夹中,并使用 Jest 执行。默认情况下,所有测试都会运行,除非你指定特定的组或测试闭包。有关其他选项,请参阅 Jest CLI 中的简要文档:jest --help

测试文件的结构 [#tests-structure]

假设我们有一个名为 Playground 的合约,写在 contracts/playground.tact 文件中。如果我们通过 Blueprint 创建了该合约,那么它也会为我们创建一个 tests/Playground.spec.ts 测试套件文件。

测试文件包含一个单独的 describe(){:typescript} Jest 函数调用,它表示一个测试组。

在该组内,你将有三个变量,在所有测试中都可用:

  • blockchain — 由 Sandbox 提供的本地区块链实例
  • deployer — 一个用于部署我们的 Playground 合约或任何其他我们想要部署的合约的 TypeScript 包装器
  • playground — 我们的 Playground 合约的 TypeScript 包装器

然后,调用 beforeEach() Jest 函数——它指定在每个后续测试闭包之前执行的所有代码。

最后,每个测试闭包由调用 it() Jest 函数来描述——这是实际编写测试的地方。

一个最简单的测试闭包示例如下:

typescript
it('should deploy', async () => {
  // 检查是在 beforeEach 中完成的,所以这里可以是空的
});

使用 dump() 调试 [#tests-dump]

要查看 dump() 函数调用的结果并使用 "printf 调试" 方法,需要:

  1. 在代码的相关位置放置 dump() 和其他常见调试函数 的调用。
  2. 运行 Jest 测试,它会调用目标函数并向目标接收者发送消息。

假设你已经创建了一个新的计数器合约项目,让我们看看它在实践中是如何工作的。

首先,让我们在 contracts/simple_counter.tact 中放置一个 dump() 调用,它会将传递给合约的 msg 结构体 中的 amount 输出到合约的调试控制台:

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

接下来,让我们注释掉 tests/SimpleCounter.spec.ts 文件中所有现有的 it(){:typescript} 测试闭包。然后添加以下内容:

typescript
it('should dump', async () => {
  await playground.send(
    deployer.getSender(),
    { value: toNano('0.5') },
    { $type: 'Add', queryId: 1n, amount: 1n },
  );
});

它向我们的合约的 receive(msg: Add) 接收器 发送一条消息,而不存储发送的结果

现在,如果我们使用 yarn build{:shell} 构建我们的合约,并使用 yarn test{:shell} 运行我们的测试套件,我们将在测试日志中看到以下内容:

shell
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)

这是由我们上面的 dump() 调用产生的。

使用 expect() 声明期望 [#tests-expect]

编写测试的核心部分是确保你的期望与观察到的现实相符。为此,Jest 提供了一个函数 expect(),其用法如下:

  1. 首先,提供一个观察到的变量。
  2. 然后,调用一个特定的方法来检查该变量的某个属性。

这是一个更复杂的示例,使用 expect() 函数检查计数器合约是否确实正确增加了计数器:

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

实用方法 [#tests-jest-utils]

Blueprint 生成的测试文件导入 @ton/test-utils 库,该库提供了许多额外的辅助方法用于 expect(){:typescript} Jest 函数的结果类型。注意,常规方法如 toEqual(){:typescript} 仍然存在并且可以使用。

toHaveTransaction

方法 expect(…).toHaveTransaction(){:typescript} 检查事务列表中是否有匹配你指定属性的事务:

typescript
const res = await yourContractName.send(…);
expect(res.transactions).toHaveTransaction({
  // 例如,让我们检查到你的合约的事务是否成功:
  to: yourContractName.address,
  success: true,
});

要了解这些属性的完整列表,请查看你的编辑器或 IDE 提供的自动补全选项。

toEqualCell

方法 expect(…).toEqualCell(){:typescript} 检查两个 cells 的相等性:

typescript
expect(oneCell).toEqualCell(anotherCell);

toEqualSlice

方法 expect(…).toEqualSlice(){:typescript} 检查两个 slices 的相等性:

typescript
expect(oneSlice).toEqualSlice(anotherSlice);

toEqualAddress

方法 expect(…).toEqualAddress(){:typescript} 检查两个 addresses 的相等性:

typescript
expect(oneAddress).toEqualAddress(anotherAddress);

向合约发送消息 [#tests-send]

要向合约发送消息,请在它们的 TypeScript 包装器上使用 .send(){:typescript} 方法,如下所示:

typescript
// 它接受 3 个参数:
await yourContractName.send(
  // 1. 消息的发送者
  deployer.getSender(), // 这是一个默认的国库,可以替换

  // 2. 价值和(可选)弹跳,默认情况下为 true
  { value: toNano('0.5'), bounce: false },

  // 3. 消息体,如果有的话
  '看着我!',
);

消息体可以是一个简单的字符串,或者是一个对象,指定 Message 类型的字段:

typescript
await yourContractName.send(
  deployer.getSender(),
  { value: toNano('0.5') },
  {
    $type: 'NameOfYourMessageType',
    field1: 0n, // bigint 零
    field2: 'yay',
  },
);

通常,存储这些发送的结果很重要,因为它们包含发生的事件、进行的交易和发送的外部消息:

typescript
const res = await yourContractName.send(…);
// res.events — 发生的事件数组
// res.externals — 外部输出消息数组
// res.transactions — 进行的交易数组

有了这些,我们可以轻松过滤或检查某些交易:

typescript
expect(res.transactions).toHaveTransaction(…);

观察费用和价值 [#tests-fees]

Sandbox 提供了一个辅助函数 printTransactionFees(),它可以漂亮地打印所有进入交易中的值和费用。它对于观察 nanoToncoins 的流动非常有用。

要使用它,请修改测试文件顶部的 @ton/sandbox 导入:

typescript
import { Blockchain, SandboxContract, TreasuryContract, printTransactionFees } from '@ton/sandbox';
//                                                      ^^^^^^^^^^^^^^^^^^^^

然后,提供一个包含交易的数组作为参数,如下所示:

typescript
printTransactionFees(res.transactions);

要处理总费用或计算和操作阶段的单个费用,请单独检查每个交易:

typescript
// 将由接收方处理的交易存储在单独的常量中
const receiverHandledTx = res.transactions[1];
expect(receiverHandledTx.description.type).toEqual('generic');

// 需要满足 TypeScript
if (receiverHandledTx.description.type !== 'generic') {
  throw new Error('Generic transaction expected');
}

// 总费用
console.log('Total fees: ', receiverHandledTx.totalFees);

// 计算费用
const computeFee = receiverHandledTx.description.computePhase.type === 'vm'
  ? receiverHandledTx.description.computePhase.gasFees
  : undefined;
console.log('Compute fee: ', computeFee);

// 操作费用
const actionFee = receiverHandledTx.description.actionPhase?.totalActionFees;
console.log('Action fee: ', actionFee);

// 现在我们可以进行一些复杂的检查,比如将费用限制在 1 Toncoin 以内
expect(
  (computeFee ?? 0n)
  + (actionFee ?? 0n)
).toBeLessThanOrEqual(toNano('1'));

带有意图错误的交易 [#tests-errors]

有时进行负面测试是有用的,这些测试包含故意的错误并抛出特定的退出代码

Blueprint 中的这样的 Jest 测试闭包示例:

typescript
it('throws specific exit code', async () => {
  // 向我们的合约发送特定消息并存储结果
  const res = await your_contract_name.send(
    deployer.getSender(),
    {
      value: toNano('0.5'), // 发送的 nanoToncoins 值
      bounce: true,         // (默认)可弹跳消息
    },
    'the message your receiver expects', // ← 更改为你的消息
  );

  // 期望交易以特定退出代码失败
  expect(res.transactions).toHaveTransaction({
    to: your_contract_name.address,
    exitCode: 5, // ← 更改为你的退出代码
  });
});

注意,要跟踪具有特定退出代码的交易,只需在 expect() 方法的 toHaveTransaction() 中指定 exitCode 字段。

但是,通过指定接收地址 to 来缩小范围是有用的,这样 Jest 只会查看由我们的消息引起的交易。

模拟时间的流逝 [#tests-time]

本地区块链实例中的 Unix 时间由 Sandbox 提供,从 beforeEach() 块中创建这些实例的时刻开始。

typescript
beforeEach(async () => {
  blockchain = await Blockchain.create(); // ← 这里
  // ...
});

之前,我们被警告不要修改 beforeEach() 块,除非我们确实需要。而现在,为了覆盖时间并进行一些时间旅行,我们确实需要这样做。

让我们在 beforeEach() 的末尾添加以下行,将 blockchain.now 显式设置为处理部署消息的时间:

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

现在,我们可以在测试条款中操纵时间。例如,让我们在部署后一分钟进行一次交易,再在两分钟后进行另一笔交易:

typescript
it('your test clause title', async () => {
  blockchain.now += 60; // 60 秒后
  const res1 = await yourContractName.send(…);
  blockchain.now += 60; // 再 60 秒后
  const res2 = await yourContractName.send(…);
});

通过 emit 记录日志 [#logging]

一个全局静态函数 emit() 向外部世界发送消息——它没有特定的接收者。

这个函数对于在链外记录和分析数据非常方便——只需查看合约生成的外部消息

本地 Sandbox 测试中的日志 [#logging-local]

Sandbox 中部署时,你可以从接收函数调用 emit(),然后观察发送的外部消息列表:

typescript
it('emits', async () => {
  const res = await simpleCounter.send(
    deployer.getSender(),
    { value: toNano('0.05') },
    'emit_receiver', // ← 更改为你的接收者处理的消息
  );

  console.log("Address of our contract: " + simpleCounter.address);
  console.log(res.externals); // ← 这里可以看到 emit() 调用的结果,
                              //   以及所有外部消息
});

已部署合约的日志 [#logging-deployed]

TON 区块链上的每笔交易包含 out_msgs——一个字典,保存执行交易时创建的外发消息列表。

要在该字典中查看来自 emit() 的日志,请查找没有接收者的外部消息。在各种 TON 区块链浏览器中,这样的交易将标记为 external-out,目标指定为 -empty

注意,有些浏览器会为你反序列化发送的消息体,而有些则不会。

处理弹跳消息 [#bounced]

当使用 bounce: true 发送消息时,如果发生错误,消息可能会弹回。请确保编写相关的 bounced() 消息接收者并优雅地处理弹回的消息:

ts
bounced(msg: YourMessage) {
    // ...好吧,队伍,让我们弹回!...
}

请记住,TON 中的弹回消息在消息体中只有 224 个可用数据位,并且没有任何引用,因此无法恢复太多数据。然而,你仍然可以看到消息是否弹回,从而创建更健壮的合约。

阅读更多关于弹回消息和接收者的信息:弹回消息

实验性实验室设置 [#lab]

如果你对 Blueprint 的测试设置感到不知所措,或者只想快速测试一些东西,不用担心——有一种方法可以设置一个简单的操场作为实验室来测试你的想法和假设。