本文我们来学习如何使用 Tact 开发 TON 合约。 ## Tact 合约架构 首先来看一个非常简单的 `Counter` 合约: ```tact= contract Counter { // persistent state variable of type Int to hold the counter value val: Int as uint32; // initialize the state variable when contract is deployed init() { self.val = 0; } // handler for incoming increment messages that change the state receive("increment") { self.val = self.val + 1; } // read-only getter for querying the counter value get fun value(): Int { return self.val; } } ``` 与 Solidity 相同,Tact 的合约也是通过 `contract` 关键字命名。 `val` 是合约中定义的 Storage 变量,它的类型是 uint32。TON 中的所有数字都是 257 位整数的形式,有正有负。但是我们可以使用 `Int as xxx` 的形式来减少其使用的空间,从而降低 Gas 的消耗。可以参考[这里](https://tact-by-example.org/02-integers)。 `init()` 是 Tact 合约中的构造方法。合约中的 Storage 变量必须要显式地在构造方法中初始化,如果没有,合约编译将会报错。由于我们只定义了 `val` 一个变量,因此只需将 `val` 初始化即可。`self` 指明这个变量是合约的 Storage 变量,而不是本地变量。 `receive()` 是合约接收消息的方法,可以简单理解为 Solidity 的 `external` 方法,但是又有所不同。我们在之前的[文章](https://hackmd.io/@xyymeeth/rJ9L_f4V1x)中提到过,TON 上的合约交互只能通过消息传递来完成,这里的 `receive()` 便是接收消息的方法。本例中是接收了一条内容为 `increment` 的明文消息,也可以有[其他类型](https://docs.tact-lang.org/book/receive/)的消息,例如: ```tact= message Add { amount: Int as uint32; } contract Receivers { val: Int as int64; init() { self.val = 0; } // handler for the "Add" message - this is a binary message that has an input argument (amount) receive(msg: Add) { self.val = self.val + msg.amount; } } ``` 在这个例子中,有一个 `receive()` 方法接收了类型为 `Add` 的参数。`Add` 是自定义的类型,`message` 可以简单理解为 `struct`。我们可以将这个接收函数简单类比成 Solidity 中的如下写法: ```solidity= function add(uint32 amount) external; ``` 注意这里的 `add` 是我自己定义的名字,与原例中的 `Add` 没有关系。`receive` 方法没有方法名,可以定义多个`receive` 方法,每个方法之间通过参数类型区分。 最后的 `get fun value(): Int` 是一个 Getter 方法,前面的 `get` 关键字表明了这一点,返回值是 Int 类型。注意 TON 的 Getter 方法只能被链下调用,也就是说这个 `value()` 方法不能被自身以及其它合约调用,而只能在链下通过一些 SDK 例如 `@ton/ton` 进行调用。 ## TON 合约特点 回忆一下之前文章提到的 TON 合约的特点: 合约交互需要通过消息传递:合约通过 `receive()` 方法接收消息。发送消息的逻辑我们后面再讲。 合约不能互相调用:只能通过消息传递进行交互,即使只读数据也无法实现,Getter 只能通过链下调用。 合约的地址通过(合约代码 + 初始状态)唯一确定。在验证这一点之前,我们先来学习 Tact 的开发工具。 ## TON 合约开发框架 TON 合约开发常用的工具是 [Blueprint](https://github.com/ton-org/blueprint),它可以用来进行合约的编译以及测试,它支持 FunC,Tact 以及刚刚发布的 Tolk,我们这里就用它来编译部署 Tact 合约。 > npm create ton@latest 运行该命令即可创建一个 Blueprint 的项目框架,我们选择 `A simple counter contract (TACT)` 这一项,它会给我们提供一套初始的 Demo 合约。 项目架构与普通的 Solidity 项目类似,值得注意的是 `wrappers` 文件夹。我们在编写 `tests` 文件夹下的单元测试以及 `scripts` 文件夹下的脚本时,需要与合约进行交互。在 Hardhat 项目中,如果需要与合约进行交互,可以使用类似 `ethers.getContractFactory("ContractName")` 的语句来获取合约信息例如 ABI 等。而在 Blueprint 中,也需要一个类似的中间介质来连接 Javascript 代码和合约代码。`wrappers` 中存放的就是这样的中间介质。在开发 Tact 时,我们不需要关心这部分,因为 Blueprint 在编译合约的时候会自动为我们生成,但是在编写 FunC 的时候,就需要手动编写 wrapper。 框架为我们生成了一个 Demo 合约,来试试编译一下: > npx blueprint build 编译成功,再来试试单元测试: > npx blueprint test 同样成功。最后来部署一下该合约: > npx blueprint run ## 部署合约 由于框架默认提供了两个脚本,因此这里有两个选择,如果我们自己编写了更多的脚本,这里也会出现。我们这里选择 `deployCounter` -> `testnet` -> `TON Connect compatible mobile wallet (example: Tonkeeper)` -> `Tonkeeper`。 这里需要链接钱包进行部署,我们使用的是 Tonkeeper,当然也可以使用其它的钱包。在手机上使用 Tonkeeper 扫描二维码并且确认交易后,交易就已经发出。在[测试网浏览器](https://testnet.tonviewer.com/)中查看该交易,可以看到: ![image](https://hackmd.io/_uploads/Sk83pRPrke.png) 这里的 A 代表我们的钱包,B 代表新部署的合约。之前提到过,TON 合约交互需要通过消息传递来完成,部署合约也是这样。由于异步的关系,我们在最开始的时候,并不知道后续的合约运行需要多少 Gas。因此 TON 的解决方案是最初始的消息附带一定量的 Gas,如果交易链中 Gas 不足,那么后续流程不再进行,已经完成的消息链不会回滚。如果最终 Gas 有剩余,这部分剩余的 Gas 由开发者决定最终去向。开发者可以决定将其留在合约中,或返还给初始 sender,或转给别的地址,这都是由合约中的逻辑决定的。 这里部署的时候,终端会打印一个 ID,我们先记下这个 ID,后面要用。 在这个部署的例子中,初始消息附带了 0.05 TON,在部署过后,仍剩余 0.04 左右的 TON,将其返还。这里返还的逻辑也是合约中代码规定的(返还逻辑来自于 import 的 @stdlib/deploy),我们后面再说。 TON 合约的部署比较神奇,这也是我踩过一些坑才发现的。部署 TON 的合约,本质上也是向合约发送消息,我们可以打开 `deployCounter.ts` 文件: ```javascript= const counter = provider.open(await Counter.fromInit(BigInt(Math.floor(Math.random() * 10000)))); await counter.send( provider.sender(), { value: toNano('0.05'), }, { $$type: 'Deploy', queryId: 0n, } ); await provider.waitForDeploy(counter.address); ``` 之前提到过,合约地址由合约代码和初始状态唯一决定,这里第一行,就已经获得到了合约示例,但是是未被部署的状态。这里的 `BigInt(Math.floor(Math.random() * 10000))` 是合约的构造方法参数。在获得了合约实例之后,下一步就是向该合约发送消息。 `provider.sender()` 代表合约的部署者。 `value: toNano('0.05')` 代表初始提供的 Gas。TON 的 decimals 是 9,因此 `toNano('0.05')` 表示 0.05 * 1e9。 `$$type: 'Deploy'` 以及 `queryId: 0n` 代表发送的消息体内容。 这里发送的 `Deploy` 消息结构实际上是来自于 `@stdlib/deploy` [合约](https://github.com/tact-lang/tact/blob/61541b7783098e1af669faccd7d2334c10981c72/stdlib/libs/deploy.tact),它为我们提供了一个通用的 `receive(deploy: Deploy)` 入口。但是我们部署该合约不一定要通过发送 `Deploy` 来实现,也可以通过发送其它的消息来完成,只要发送的是该合约能正常接收到的消息即可。并且,即使该消息在合约中执行失败,合约也能被正常部署成功。假设我们的合约有下面的方法: ```tact= receive("try") { ... // some errors here } ``` 我们可以通过发送 `try` 文本消息来部署合约,该方法在执行过程中会出错,但是合约仍能够被正常部署。是不是感觉挺神奇。时刻记住,TON 是以消息为交互介质的系统,我们只要能把消息发送成功,那么消息发送本身就算成功,至于消息到达之后的合约执行逻辑,那是后面的事情,与该消息的状态无关。对应到部署逻辑,也就是说,我们只要能保证消息正常到达合约,那么合约就可以被部署成功。该消息后续的执行成功与否不影响部署本身。 现在我们再来看引入的 `deploy.tact` 合约,`trait` 可以理解为父合约,我们可以通过 `with` 关键字来继承父合约。例如 ```tact= contract Counter with Deployable {} ``` 由于 `Counter` 合约继承了 `Deployable`,那么它就拥有了 `receive(deploy: Deploy)` 入口。因此我们可以通过发送 `Deploy` 消息来进行合约的部署。 ```tact= receive(deploy: Deploy) { self.notify(DeployOk{queryId: deploy.queryId}.toCell()); } ``` `self.notify()` 可以简单理解为将剩余 Gas 返还给 Sender,因此我们在刚才部署的时候,剩余的 0.4x 都被返还给。如果这里没有这一句,那剩余的 Gas 将全部留在合约中。 我们现在尝试将 `import "@stdlib/deploy";` 移除,并手动将 `deploy.tact` 的[代码](https://github.com/tact-lang/tact/blob/61541b7783098e1af669faccd7d2334c10981c72/stdlib/libs/deploy.tact)复制到合约中,区别是去掉这一行: ```tact= self.notify(DeployOk{queryId: deploy.queryId}.toCell()); ``` 再次尝试编译部署,可以看到浏览器中,Gas 全部被合约没收: ![image](https://hackmd.io/_uploads/Hk-kO0vrJe.png) 前面提到过,合约的地址是由合约代码和初始状态唯一确定,我们现在来验证这一点。首先将合约代码恢复到最初的状态(即带有 `import` 语句的状态)。 来看 `deployCounter.ts` 文件,我们将获取合约示例这一行改为: ```tact= const counter = provider.open(await Counter.fromInit(BigInt(ID))); ``` 这里的 ID 改为第一次部署的时候记录的 ID,然后再次运行脚本: > npx blueprint run 查看区块浏览器: ![image](https://hackmd.io/_uploads/BkvkRADBkl.png) 可以看到,这里的 TON 流向了与第一次部署相同的合约地址,但不同的是,表格中没有 `Contract deploy` 这一行,说明这次交易没有部署合约,因为合约在之前已经被部署过了。 我们这次获取合约实例是根据之前的初始化参数(ID)得到的,这证明了,合约的地址确实是由合约代码和初始状态唯一确定的。也从侧面证明了,部署合约的过程也只是一个普通的消息发送过程,发送的 `Deploy` 消息只是一个普通的消息体,只不过经常被作为一个通用的部署消息。 ## 发送消息 发送消息是 TON 合约开发中一个重要并且比较复杂的部分,只要合约之间需要交互,就需要发送消息。发送消息时需要设置一些参数,如果参数不对,可能导致消息发送失败,流程卡死,Gas 丢失等问题。因此,这部分我们需要深入了解。 我们先来看看一个简单的包含消息发送逻辑的合约: ```tact= import "@stdlib/deploy"; message MyMsg { to: Address; number: Int; } message InternalMsg { number: Int; } contract SendMsg with Deployable { receive(msg: MyMsg) { send(SendParameters { bounce: false to: msg.to, value: 0, mode: SendRemainingValue | SendIgnoreErrors, body: InternalMsg { number: msg.number }.toCell() }); } } ``` `SendMsg` 合约中有一个 `receive` 方法接收类型为 `MyMsg` 的参数,这个参数是我们在合约上面自定义的类型,你也可以定义你喜欢的类型。 `send` 语句这里是发送消息的固定写法,只是其中的参数需要根据具体场景来决定内容。 + bounce:Bool 类型,默认为 true。如果设置为 true,那么在发送消息的过程中遇到错误时,可以回弹一条消息,方便发送方来做错误处理。可以将 bounce 简单理解为 `try - catch` 机制,如果 bounce 为 true,那么同时也需要在发送方的合约中编写相应的 "catch" 部分的处理逻辑 + to:消息的接收方地址 + value: 发送出的消息携带的 Gas 数量 + mode: 消息模式,这个我们后面详细讲解 + body: 消息体内容,这里的 `InternalMsg` 也是我们自定义的类型 这四个内容就是发送消息时必须要关注的部分,我们每次在编写发送消息的代码时都要好好考虑它们的具体内容该怎么填写。 我们看到 `body` 中最后有一个 `toCell()`。在 TON 中,所有数据都是存在 Cell 中的,包括合约,消息体。一个 Cell 可以包含最多 1023 bits 的数据和最多 4 个指向其它 Cell 的引用。发送的 `body` 也需要是 Cell 类型,`toCell()` 就是将 `body` 转换成 Cell 类型。目前对 Cell 不需要过多关注,只需要知道基本概念即可,后面随着深入开发,便会慢慢理解了。 `bounce` 参数看起来是一个很好用的参数,但在实际开发项目的过程中,不太推荐用到它,因为它目前只能包含 224 bits 的可用数据,所以可用性不高。一般处理一些错误 case 时,建议还是手动编写处理错误以及回弹的逻辑。 `to` 和 `body` 都很好理解。`body` 是发送的消息体格式,一般是自己定义,需要和接收方的 `receive()` 方法对应,否则将出错。 `value` 是该消息中附带的 Gas 数量,但它的实际作用需要和 `mode` 结合来看才有实际意义。接下来我们重点来看 `mode` 的作用。 ### 消息模式 来看一个例子: ![image](https://hackmd.io/_uploads/SyKukEYHyl.png) A 合约向 B 合约发送消息,附带的 Gas 是 1 TON。这里有一个问题需要考虑,就是这条消息本身也是要付费的,正式名称叫 Forward Fee。我们可以将这个动作类比为邮寄信件。A 在信中放了 100 元,这 100 元是给 B 的费用,B 想用这 100 干什么,A 并不关心。但是 A 要关心的是,他需要为这次寄信付邮费(Forward Fee)。那么这笔邮费怎么支付呢,A 可以选择自己额外支付,100 元仍然放在信中。他也可以说:我手里总共就这 100 了,邮费需要多少,快递员直接在 100 里面扣就行。 对应于图例中,假设 A 选择额外支付 Forward Fee ,那么在 B 的视角中,`msg.value` 就是 1 TON。如果 A 希望 Forward Fee 包含在 1 TON 中,假设花费了 0.002 TON,那么在 B 的视角中,`msg.value` 就是 0.998 TON。 这两个场景,其实就是 `mode` 发挥作用的地方。当然 `mode` 的作用不止这两个,这里只是为了给大家一个直观的概念。下面我们来看看官方文档列出的 `mode` 参数[具体内容](https://docs.ton.org/v3/documentation/smart-contracts/message-management/sending-messages#message-modes)。 `mode` 中包含两个变量:`mode` 和 `flag`,它俩组合起来可以发挥最大的作用。 | Mode | 作用 | | -------- | -------- | | 0 | 普通消息 | | 64 | 将接收到的所有 Gas 附加 value 设置的数量一起发送出去 | | 128 | 将合约中的所有余额都发送出去,此时 `value` 字段无意义 | | Flag | 作用 | | -------- | -------- | | +1 | 额外付 Forward Fee | | +2 | 忽略消息发送过程中的一些错误 | | +16 | 如果出现错误,则回弹消息。如果已经使用了上面的 +2,则 +16 无效 | | +32 | 如果发送消息之后,合约余额为 0,则当前合约需要被销毁(常与 mode 128 搭配使用) | Mode 可以单独使用,也可以配合 Flag 使用,但是 Flag 不能单独使用。 在上面邮费的例子中,如果 A 希望额外付邮费,就需要使用 Flag:+1。 [Tact 文档](https://docs.tact-lang.org/book/message-mode/#base-modes)也提供了 mode 的讲解,它的优点在于提供了枚举常量,可以方便观察理解。 我们来举几个例子学习理解 `mode` 的用法。 ![image](https://hackmd.io/_uploads/r1KOqVYHyx.png) 图例中,假设 A 是我们的钱包合约,向 B 发送消息并附带 1 TON,这条消息使用额外付费的模式(即 Flag:+1)。B 在接收消息之前合约就已经有 2 TON 的余额,那么在 B 向 C 发送消息时,`mode` 该怎么设置呢? 这里需要先学习明确几个概念。首先,当入场的 1 TON 转入 B 时,B 的合约余额就已经变成了 3 TON。在 B 开始处理自身的逻辑时,消耗的 Gas 就是从这 3 TON 中扣除。假设总共消耗了 0.3 TON 的 Gas,那么 B 的逻辑执行完之后,就剩余 2.7 Gas(此时先暂不考虑 B 发送消息消耗的 Gas)。也就是说执行 B 的逻辑需要 0.3 TON 的 Gas,那么如果 A 给的入场 Gas 只有 0.1,怎么办呢。这取决于 B 的余额,如果入场 Gas + B 合约余额大于等于 0.3,那么合约就可以正常执行,这 0.2 的 Gas 由 B 的合约余额支付,如果入场 Gas + B 合约余额不足 0.3,那么交易失败。这里和 EVM 相比有很大区别,EVM 的 Gas 全部由消息发送者支付,而 TON 的 Gas 实际是由合约支付的,因为合约的余额就已经包含了入场的 Gas。由于这一点,所以我们甚至可以不给 B 转入 Gas,只要 B 的余额充足,便可以正常执行。不过这种行为有点反逻辑,并且一般的合约都会校验入场的 Gas 数量,因此这个概念我们了解即可。 回到上面的例子,假设此时 B 在处理完逻辑,到了发送消息的时候,还剩余 2.7 TON(0.7 TON 入场 Gas + 2 TON 合约余额)。如果 B 希望将剩余的 Gas(0.7 TON)全部发送给 C,邮费另付,就可以使用: > mode: 64 + 1 > 即 mode: SendRemainingValue | SendPayGasSeparately > value: 0 64 代表将剩余的 Gas 全部转入,1 代表 Forward Fee 另外支付。假设 Forward Fee 是 0.001 TON,那么在发送完消息之后,B 的余额是 2 - 0.001 = 1.999 TON,C 的入场 Gas 是 0.7 TON。 如果 B 希望在转入剩余 Gas 的基础上,再额外支付 0.2 TON,就可以使用: > mode: 64 + 1 > 即 mode: SendRemainingValue | SendPayGasSeparately > value: ton("0.2") 这里的 `ton("0.2")` 类似于 0.2 ether。TON 的 decimals 是 9,因此 `ton("0.2")` 表示 0.2 * 1e9。 如果 B 想支付一个固定数量的 Gas,例如 1.5 TON,邮费令付,那么就可以使用: > mode: 0 + 1 > 即 mode: SendPayGasSeparately(0 省略) > value: ton("1.5") 此时 B 剩余的 Gas 数量为 2.7 - 1.5 - 0.001 = 1.199 TON,C 的入场 Gas 为 1.5 TON。 如果 B 希望邮费从 1.5 TON 中扣除,可以使用: > mode: 0 > value: ton("1.5") 此时 B 剩余的 Gas 数量为 2.7 - 1.5 = 1.2 TON,C 的入场 Gas 为 1.5 - 0.001 = 1.499 TON。 这里举了几个简单的例子,希望可以帮助到大家方便理解 `mode` 的意义,更多详细的例子可以参考[这里](https://docs.ton.org/v3/documentation/smart-contracts/message-management/message-modes-cookbook)。 ## 合约租金 TON 上的合约是需要支付租金的,租金的多少取决于合约中存储的数据量大小。因此,我们在开发合约的时候,一定要保持合约中存在一定量的 TON 余额,避免合约无余额被冻结。在发送消息时尽量避免使用 Mode:128,因为它会将合约的余额全部转出,造成合约被冻结(除非有特定场景需要使用该模式)。本文对这一部分不展开过多讲解了,感兴趣的朋友可以看[这里](https://docs.ton.org/v3/documentation/smart-contracts/transaction-fees/fees-low-level#storage-fee)。 ## 单元测试 最后我们来看下 Tact 的单元测试,Blueprint 的单元测试都放在 `tests` 文件夹下,当创建一个新的合约(`npx blueprint create`)时,框架会自动生成单元测试文件。 TON 的单元测试自然也要遵循消息发送交互的模式,可以打开自动生成的单元测试文件: ```javascript= const deployResult = await counter.send( deployer.getSender(), { value: toNano('0.05'), }, { $$type: 'Deploy', queryId: 0n, } ); expect(deployResult.transactions).toHaveTransaction({ from: deployer.address, to: counter.address, deploy: true, success: true, }); ``` 第一条语句是发送消息,第二条语句是校验这次交易中,是否包含某一条交易。由于 TON 的交易是交易链的形式,每一次消息发送都是一笔单独的交易,因此一次交易中,可能会包含多笔交易。 这里第二条语句的意思就是,查看 `deployResult` 这一笔交易链中是否包含一条具有下面参数内容的交易: ```txt= from: deployer.address, to: counter.address, deploy: true, success: true, ``` 具体的使用方法可以参考 Sandbox 的[文档](https://github.com/ton-org/sandbox)。Sandbox 是开发 TON 合约常用的本地沙盒工具,类似于 EVM 的 Ganache,Hardhat localnode。 ## 总结 本文我们从零开始学习了 Tact 的开发。相比于 EVM,TON 的开发由于异步的原因,难度还是要大一些的,尤其是消息发送这块的处理。这里给大家一个比较好的入门文档,更多的深入理解还是要靠实际的上手开发来学习。下一篇我准备介绍 TON 上 Jetton 代币的 Tact 实现。Jetton 可以理解为 TON 上的 ERC20 Token,Not、Cati 等代币都是 Jetton 标准的实现。 ## 关于我 欢迎[和我交流](https://linktr.ee/xyymeeth)