消息发送是 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)中的描述如下: ![image](https://hackmd.io/_uploads/HJruh4HLke.png) 可以看到,忽略掉的主要是在 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)