消息发送是 TON 合约之间交互的唯一方式,它的发送模式中包含 Mode 和 Flag。其中 Flag 的 +2,即 SendIgnoreErrors,我之前一直没有太理解它具体的作用究竟是什么,这几天深入研究了一下,终于是弄明白个大概,这里来总结一下。
## Phases
TON 的一个交易过程分为很多的阶段(phase),一笔交易最多包含下面 5 个 phase:
+ Storage phase,计算合约租金相关
+ Credit phase,计算合约余额相关
+ Compute phase,执行合约代码,并生成一个操作序列等的集合
+ Action phase,执行一些动作,例如发送消息,更新合约代码等
+ Bounce phase,如果 compute phase 失败,则处理 bounce 相关工作
Compute phase 执行合约代码,合约中的计算也都在此阶段进行。其中也包括了 Gas 的计算,这里的 Gas 只包含合约本身运行以及计算需要的 Gas,不包括发送消息的 Gas。可以看下面一个例子([来源](https://docs.tact-lang.org/book/send/#outbound-message-processing)):
```tact=
// This contract initially has 0 nanoToncoins on the balance
contract FailureIsNothingButAnotherStep {
// And all the funds it gets are obtained from inbound internal messages
receive() {
// 1st outbound message evaluated and queued (but not sent yet)
send(SendParameters{
to: sender(),
value: ton("0.042"), // plus forward fee due to SendPayGasSeparately
mode: SendIgnoreErrors | SendPayGasSeparately,
});
// 2nd outbound message evaluated and queued (but not sent yet, and never will be!)
send(SendParameters{
to: sender(),
value: 0,
mode: SendRemainingValue | SendIgnoreErrors,
});
}
}
```
合约中需要连续发送两条消息,第一条使用了 `SendPayGasSeparately`,第二条使用了 `SendRemainingValue`。这个用法看起来是比较正常直观的,首先第一条消息发送出去 0.042 个 TON,然后将剩余的 TON 随着第二条消息全部发送出去。
但实际上,第二条消息是无法发送成功的。原因就在于,Gas 的用量是全部在 Compute phase 中计算的,这个 Gas 只是合约执行层面耗费的 Gas,不包含消息发送的 Gas。假设 Compute phase 之后,计算剩余的 Gas 数量为 ***R***,那么第一条消息发送之后,合约中剩余的 Gas 为:
***R - (0.042 + forward_fee)***
但是到了第二条消息,`SendRemainingValue` 希望发送的是经过 Compute phase 之后剩余的 Gas ***R***,也就是说合约仍然希望将数量为 ***R*** 的 TON 发送出去,那肯定是失败的(前提是合约中之前没有足量的 Gas,如果在该消息发生之前,合约中就已经存在了大于 ***0.042 + forward_fee*** 的 TON,那第二条消息也是会发送成功的,为此代码开头特别注释了合约本身余额为 0)。
此时我们回到正题,来研究 `SendIgnoreErrors` 的作用是什么,[文档](https://docs.ton.org/v3/documentation/smart-contracts/message-management/sending-messages#message-modes)中的描述如下:

可以看到,忽略掉的主要是在 action phase 阶段出现的一些错误,例如 Gas 不足等。我们就以上面的例子为模版,来验证 `+2` 对合约的执行有哪些影响。
## 测试验证
```tact=
import "@stdlib/deploy";
message Add {
a: Address;
b: Address;
num: Int;
numA: Int;
numB: Int;
}
message Body {
num: Int;
}
contract IgnoreErrorsTest with Deployable {
counter: Int;
init() {
self.counter = 0;
}
receive() {}
receive(msg: Add) {
send(SendParameters {
to: msg.a,
value: ton("0.1"),
mode: SendIgnoreErrors | SendPayGasSeparately,
body: Body {
num: msg.numA
}.toCell()
});
send(SendParameters {
to: msg.b,
value: 0,
mode: SendRemainingValue | SendIgnoreErrors,
body: Body {
num: msg.numB
}.toCell()
});
self.counter += msg.num;
}
get fun counter(): Int {
return self.counter;
}
}
contract A with Deployable {
counter: Int;
init() {
self.counter = 0;
}
receive(msg: Body) {
self.counter += msg.num;
}
get fun counter(): Int {
return self.counter;
}
}
contract B with Deployable {
counter: Int;
init(){
self.counter = 0;
}
receive(msg: Body) {
self.counter += msg.num;
}
get fun counter(): Int {
return self.counter;
}
}
```
`IgnoreErrorsTest` 合约分别向 `A` 和 `B` 发送消息,使用的 Mode 分别为 `SendPayGasSeparately` 和 `SendRemainingValue`。按照上面的例子,第二条消息是无法发送成功的。
在测试文件中,使用如下的代码来验证:
```javascript=
const sendResult = await ignoreErrorsTest.send(
deployer.getSender(),
{
value: toNano('0.5'),
},
{
$$type: 'Add',
a: a.address,
b: b.address,
numA: 12n,
numB: 34n,
num: 42n
}
);
console.log(await ignoreErrorsTest.getCounter());
console.log(await a.getCounter());
console.log(await b.getCounter());
```
执行结束后,结果如下:
```bash=
> SendMode@0.0.1 test
> jest --verbose
console.log
42n
at Object.<anonymous> (tests/IgnoreErrorsTest.spec.ts:107:9)
console.log
12n
at Object.<anonymous> (tests/IgnoreErrorsTest.spec.ts:108:9)
console.log
0n
at Object.<anonymous> (tests/IgnoreErrorsTest.spec.ts:109:9)
```
说明主合约执行成功,第一条消息发送成功,第二条发送失败。这与最开始的理论对应。我们此时去掉第二条消息的 `SendIgnoreErrors`,再次执行测试(需要重新编译),结果如下:
```bash=
> SendMode@0.0.1 test
> jest --verbose
console.log
0n
at Object.<anonymous> (tests/IgnoreErrorsTest.spec.ts:107:9)
console.log
0n
at Object.<anonymous> (tests/IgnoreErrorsTest.spec.ts:108:9)
console.log
0n
at Object.<anonymous> (tests/IgnoreErrorsTest.spec.ts:109:9)
```
说明整个交易全部失败,所有消息发送失败。
两次对比,便可以体会到 `+2` 的作用:忽略 action phase 阶段的错误,不造成整个交易的回滚。
此时我们将第二条消息的 `SendIgnoreErrors` 加上,并且将测试文件的初始 Gas 改为 `0.05`:
```tact=
const sendResult = await ignoreErrorsTest.send(
deployer.getSender(),
{
value: toNano('0.05'),
},
{
$$type: 'Add',
a: a.address,
b: b.address,
numA: 12n,
numB: 34n,
num: 42n
}
);
```
这时,在 action phase 阶段,由于初始的 Gas 只有 `0.05`,但是第一条消息希望发送 `0.1` 的 TON,那肯定失败,但是由于使用了 `SendIgnoreErrors`,因此该消息的失败不影响整个 action phase,因此第二个消息可以发送成功(因为 ***R*** 没有被第一条扣减)。
```bash=
> SendMode@0.0.1 test
> jest --verbose
console.log
42n
at Object.<anonymous> (tests/IgnoreErrorsTest.spec.ts:107:17)
console.log
0n
at Object.<anonymous> (tests/IgnoreErrorsTest.spec.ts:108:17)
console.log
34n
at Object.<anonymous> (tests/IgnoreErrorsTest.spec.ts:109:17)
```
如果我们去掉第一条消息的 `SendIgnoreErrors`,那么它的错误就不会被忽略,造成整个交易回滚:
```bash=
> SendMode@0.0.1 test
> jest --verbose
console.log
0n
at Object.<anonymous> (tests/IgnoreErrorsTest.spec.ts:107:17)
console.log
0n
at Object.<anonymous> (tests/IgnoreErrorsTest.spec.ts:108:17)
console.log
0n
at Object.<anonymous> (tests/IgnoreErrorsTest.spec.ts:109:17)
```
这时我们再来测试,如果合约中本来就有余额,会是什么情况。在测试文件中,发送消息之前,添加:
```tact=
await deployer.send({
to: ignoreErrorsTest.address,
value: toNano('0.2')
});
```
该语句的意思是向 `ignoreErrorsTest` 合约中转入 `0.2` 个 TON,然后再来测试,会发送交易成功,并且两条消息都发送成功。
[代码](https://github.com/xyyme/SendModeTest)
## 总结
费了些时间终于将这一块搞明白了,还是需要多体会多测试,才能深入理解。
## 关于我
欢迎[和我交流](https://linktr.ee/xyymeeth)