本文将介绍如何使用 Tact 编写 Jetton 代币合约。Jetton 可以理解为 TON 上的 ERC20 代币,前段时间很火的 NOT、CATI 等都是 Jetton 代币,虽然可以简单理解为 ERC20,但是由于 TON 是异步区块链的关系,Jetton 在实现逻辑上和 ERC20 有着巨大的差别。 ## 无界数据结构(Unbounded data structures) 在 EVM 的 ERC20 代码中,用户的余额都是以 `mapping` 的数据结构存储在合约中的,合约中可以存储无限个用户的余额,即 ***Unbounded data structures***。如果想要查询任何用户的余额,只需要调用合约的 `balanceOf` 方法即可。 但是,在 TON 中,由于底层架构的不同,这种无界数据结构无法实现。TON 采用了 `Master - Wallet` 的架构来实现 Jetton 代币。 ## Jetton 架构 `Master` 合约中存储了 Jetton 的核心信息,例如 `totalSupply`、`name`、`symbol` 等都位于 Jetton Master 合约中。 `Wallet` 合约并不是我们常用的类似 TonKeeper 这种钱包合约,而是与 Jetton Master 配套的 Jetton Wallet 合约。那么顾名思义,Jetton Wallet 肯定是每个人都独立拥有的钱包,因此 Jetton Wallet 也是每个用户都拥有独属于自己的 Jetton Wallet 合约。每个用户的余额都存储在属于自己的 Jetton Wallet 合约钱包中 来看看这个图例: ![image](https://hackmd.io/_uploads/ry4rURcrkg.png) 图中一共有两个 Jetton 代币,分别是 Jetton A 和 Jetton B,有两个用户 Alice 和 Bob,他们分别拥有各自的合约钱包 Alice Wallet v4 和 Bob Wallet v3,这里就是类似于 TonKeeper 的钱包。而在 Jetton 这里,Alice 和 Bob 也都拥有属于自己的 Jetton Wallet 合约钱包: + Jetton A Master + Alice Jetton A Wallet + Bob Jetton A Wallet + Jetton B Master + Alice Jetton B Wallet + Bob Jetton B Wallet 那么,如果一个 Jetton 总共有 N 个用户在使用,就会有 N + 1 个合约,分别是 N 个 Jetton Wallet 合约和 1 个 Jetton Master 合约。 ERC20 代币的转账逻辑很简单,在 sender 调用 `transfer` 的时候,将 sender 的余额减少,recipient 的余额增加即可,这都是在 ERC20 代币自身合约逻辑中实现的。但是到 TON 这边,就复杂了起来,用户的余额都是存储在自己的 Jetton Wallet 合约中的,这种情况需要怎样转账呢,TON 为我们实现了这样一套逻辑: ![image](https://hackmd.io/_uploads/ryq8UCcr1l.png) 假设 Bob 希望向 Alice 转账 Jetton 代币,将经历下面的流程: 1. Bob 的钱包向自己拥有的 Jetton Wallet 合约发送 `transfer` 消息,消息中包含转账的数量,这里假设是 100。 2. Bob 的 Jetton Wallet 钱包接收到消息后,将自己的 Jetton 余额减去 100,然后向 Alice 的 Jetton Wallet 钱包发送 `internal transfer` 消息,其中包含数量 100。 3. Alice 的 Jetton Wallet 钱包 接收到消息后,将自己的 Jetton 余额增加 100,然后向 Alice 的合约钱包发送 `transfer notification` 消息,同时向 JOE 发送 `excesses` 消息。(注意这里 `excesses` 消息的目的是为了将剩余的 Gas 返还,一般是发送给 sender,即 Bob,这里发送给 JOE 是为了展示在机制上剩余 Gas 发送给谁都可以) ## Jetton 标准范式 TON 为 Jetton 制定了一套标准,常用的是 [TEP-74](https://github.com/ton-blockchain/TEPs/blob/master/text/0074-jettons-standard.md),它类似于 EIP20 这种标准,规定了 Jetton 代币中必须要实现的范式,我们在编写 Jetton 时,都要遵循这个范式。 ## 编写 Jetton 代币合约 在学习了解了 Jetton 的基本架构和逻辑之后,我们来学习如何用 Tact 编写 Jetton 代币合约。这里我们参考 [Ton-Dynasty](https://github.com/Ton-Dynasty/tondynasty-contracts) 的 Jetton 实现,它是一个使用 Tact 实现的合约库,类似于 Solidity 的 OpenZeppelin 库,我们在平时开发合约的时候可以引用他们的合约加快开发效率。 首先先找到这三个文件(二级菜单是文件中包含的合约名): + JettonMaster.tact + JettonMaster + JettonWallet.tact + JettonWallet + jetton_example.tact + ExampleJettonWallet + ExampleJettonMaster 为了方便我们学习和编译测试,可以创建一个新的 Blueprint 项目。然后将这几个文件的内容都复制进去,注意需要调整合约上面的导入路径。 来看这几个合约,`JettonMaster` 就是 Master 合约,但它使用了 `trait` 关键字,说明它是一个合约基类,需要被继承才能使用。它实际上已经包含了所有的核心逻辑。`ExampleJettonMaster` 合约继承了 `JettonMaster`,可以被直接部署。 与之类似,`JettonWallet` 就是 Jetton Wallet 合约,也是一个合约基类,需要被继承才能使用。`ExampleJettonWallet` 合约继承了 `JettonWallet`。 ### JettonMaster 我们在 Master 合约的最开始可以看到定义的各式各样的 Message,它的本质是结构体 Struct。凡是用于消息传递的结构体都使用 `message` 关键字来定义。注意它后面紧跟着的一串 16 进制数字,例如: ```txt= message(0x0f8a7ea5) JettonTransfer ``` 这里的 `0x0f8a7ea5` 称为 opcode。在 Tact 中,所有的 Message 都有一个标识符,例如 `JettonTransfer`。但是在 Tact 编译后的更底层语言 FunC 中,实际是不存在这个标识符的,所有的消息都以 opcode 来区分。TON 中的所有数据都是存放在 Cell 结构中,消息体同样也是。opcode 就存在于消息体 Cell 中,当接收者合约收到消息时,会从消息体 Cell 中解析出 opcode,并根据它来区分不同的消息。Tact 为了方便我们编写代码,发明了这样一套语法糖。所以消息的名称叫什么其实都无所谓,只要我们为它指定好 opcode 即可。opcode 是根据 TL-B 的语法计算出来的,我们目前还不需要深入学习,了解即可。 Jetton Master 合约中包含了代币的基本信息: ```txt= total_supply: Int; // the total number of issued jettons mintable: Bool; // flag which indicates whether minting is allowed owner: Address; // owner of this jetton jetton_content: Cell; // data in accordance to Token Data Standard #64 ``` 值得注意的是 `jetton_content` 字段,它是一个 Cell 类型的数据。我们之前说过,TON 上的所有数据都是存放在 Cell 结构中。`jetton_content` 中一般存储 Jetton 的 metadata。例如: ```json= { "name": "Huebel Bolt", "description": "Official token of the Huebel Company", "symbol": "BOLT", "decimals": 9, "image_data": "https://some_image" } ``` 这里的数据结构是由[ TEP-64](https://github.com/ton-blockchain/TEPs/blob/master/text/0064-token-data-standard.md) 规定的。 接着我们来看最下面的两个 `get` 方法。 ```tact= get fun get_jetton_data(): JettonData { return JettonData{ total_supply: self.total_supply, mintable: self.mintable, admin_address: self.owner, jetton_content: self.jetton_content, jetton_wallet_code: self.calculate_jetton_wallet_init(myAddress()).code }; } ``` `get_jetton_data` 方法返回 Jetton 的一些数据,这也是在 TEP-74 中规定的方法,注意最后的一项数据是 `jetton_wallet_code`,它是 Jetton Master 对应的 Jetton Wallet 合约的代码,但是这个 `calculate_jetton_wallet_init` 方法当前是 `abstract`,也就是需要在子类中进行实现。 ```tact= get fun get_wallet_address(owner_address: Address): Address { let initCode: StateInit = self.calculate_jetton_wallet_init(owner_address); return contractAddress(initCode); } ``` `get_wallet_address` 是查询用户 Jetton Wallet 合约地址的方法。前面说过,每个用户都有自己独立的 Jetton Wallet,它的地址就是通过这个方法计算出来的。我们可以看到第一行获取了一个 `StateInit` 类型的数据,可以将它理解为合约代码与初始化参数构成了一个实例。第二行使用 `contractAddress` 获取这个实例的地址。这也从代码层面说明了,合约的地址是由合约代码和初始化参数唯一确定的。 再来看 `JettonMaster` 合约剩下的代码,目前,主要还有两部分的逻辑: + Mint + Burn 我们先来看 `mint` 是一个怎样的流程。 ![image](https://hackmd.io/_uploads/BJ2AqlhHJe.png) Mint 操作一般只能由 Owner 发起,在 Jetton 中,Owner 向 Master 合约发送 `JettonMint` 消息: ```tact= receive(msg: JettonMint) { let ctx: Context = context(); self._mint_validate(ctx, msg); self._mint(ctx, msg); } ``` 随后合约校验发起者是否为 Owner: ```tact! virtual inline fun _mint_validate(ctx: Context, msg: JettonMint) { require(ctx.sender == self.owner, "JettonMaster: Sender is not a Jetton owner"); require(self.mintable, "JettonMaster: Jetton is not mintable"); } ``` `Context` 是合约中获取上下文的对象,例如 sender 就是 `ctx.sender`。这里校验 sender 是否为 owner,以及是否可以 mint(`mintable`)。 最后执行内部方法 `_mint` 铸币,我们重点来看这里: ```tact! virtual inline fun _mint(ctx: Context, msg: JettonMint) { let initCode: StateInit = self.calculate_jetton_wallet_init(msg.receiver); self.total_supply = self.total_supply + msg.amount; send(SendParameters { to: contractAddress(initCode), value: 0, bounce: true, mode: SendRemainingValue, body: JettonInternalTransfer { query_id: 0, amount: msg.amount, response_address: msg.origin, from: myAddress(), forward_ton_amount: msg.forward_ton_amount, forward_payload: msg.forward_payload }.toCell(), code: initCode.code, data: initCode.data }); } ``` 首先获取到这笔 mint 接收人的 Jetton Wallet 合约示例 `initCode`,然后增加 `total_supply` 的数量,最后发送消息。 发送消息这部分与我们之前的介绍相比,多了两个字段: + code: initCode.code + data: initCode.data 它们分别是 `initCode` 实例的代码和数据。这是什么意思呢,如果 Owner 想给一个用户 mint 一点代币,但是这个用户他可能并没有对应的 Jetton Wallet 合约,那就需要部署。这里的 `code` 和 `data` 就是在部署 Wallet 合约时需要使用到的代码和初始化数据。有了 `code` 和 `data`,并且前面已经获得了接收者的 Jetton Wallet 合约地址(即 `contractAddress(initCode)`),就可以在该地址上部署用户拥有的 Wallet 合约。如果合约之前已经被部署过,那就可以省略部署这一步。 其实我们在部署自己的合约时,也可以将整个过程理解成发送消息并附带了 `code` 和 `data`,如果合约不存在则部署,存在则忽略部署步骤,只是一笔简单的消息。 再来看看发送的消息体 `JettonInternalTransfer`,它的内容由 TEP-74 规定。 ```tact! body: JettonInternalTransfer { query_id: 0, amount: msg.amount, response_address: msg.origin, from: myAddress(), forward_ton_amount: msg.forward_ton_amount, forward_payload: msg.forward_payload }.toCell() ``` `JettonInternalTransfer` 结构体的名字其实可以随意,这是一个只在 Tact 中有意义的标识符,在 FunC 中,其实并不存在消息体名字,都是以消息的 opcode 来做区分。来看结构体的内容: + query_id,可以理解为一个查询 id,一般没什么太大意义 + amount,要转账的 Jetton 数量 + response_address,最终剩余的 Gas 返还者,一般的消息的发起 sender + from:一般是 Jetton Master 地址或用户地址 + forward_ton_amount:附加给 `transfer notification` 消息的 Gas 数量 + forward_payload:附加给 `transfer notification` 消息的 payload 这几个字段大家现在看着可能概念有点模糊,我们在后面的代码中会看到它们的作用。 Burn 部分由于入口在 `JettonWallet` 合约中,因此放在下部分再讲。 ### JettonWallet `JettonInternalTransfer` 消息被发送给了 JettonWallet 合约,它的接收方法如下: ```tact! receive(msg: JettonInternalTransfer) { let ctx: Context = context(); self.balance = self.balance + msg.amount; require(self.balance >= 0, "JettonWallet: Not allow negative balance after internal transfer"); self._internal_transfer_validate(ctx, msg); let remain: Int = self._internal_transfer_estimate_remain_value(ctx, msg); if (msg.forward_ton_amount > 0){ self._internal_transfer_notification(ctx, msg); } self._internal_transfer_excesses(ctx, msg, remain); } ``` 首先更新余额,然后对消息的发送者进行校验: ```tact! virtual inline fun _internal_transfer_validate( ctx: Context, msg: JettonInternalTransfer ) { if(ctx.sender != self.jetton_master){ // 要求 sender 是 msg.from 的 Jetton 钱包地址 let init: StateInit = self.calculate_jetton_wallet_init(msg.from); require(ctx.sender == contractAddress(init), "JettonWallet: Only Jetton master or Jetton wallet can call this function"); } // 如果是来自 master 的消息,直接通过 } ``` `JettonInternalTransfer` 消息只会来自两个地方: + Master 合约,用于 mint + 其它的 Jetton Wallet 合约,用于转账 这里如果 sender 是 Master 合约,则直接通过。如果 sender 不是 Master,那么它就必须是其它的 Jetton Wallet 合约。首先获取初始发送者的 Jetton Wallet 合约实例,然后校验其地址是否是当前消息的 sender。 随后计算出最终可剩余的 Gas: ```tact! virtual inline fun _internal_transfer_estimate_remain_value( ctx: Context, msg: JettonInternalTransfer ): Int { let tonBalanceBeforeMsg: Int = myBalance() - ctx.value; let storage_fee: Int = self.minTonsForStorage - min(tonBalanceBeforeMsg, self.minTonsForStorage); let remain: Int = ctx.value - (storage_fee + self.gasConsumption); if (msg.forward_ton_amount > 0) { remain = remain - (ctx.readForwardFee() + msg.forward_ton_amount); } return remain; } ``` `myBalance()` 是包含入场 Gas 的当前合约 TON 余额,因此 `myBalance() - ctx.value` 是接收消息之前合约的余额 TON 上的合约需要缴纳租金才能持续使用,因此这里需要给合约中留下一部分 TON 作为租金的数量。`minTonsForStorage` 的值,合约中硬编码为 `ton("0.01")`,是一个经过测试的可以支付一段时间租金的数量。并不是非要这个数,你想写 0.001 也可以,0.1 也可以。 ```tact! if (msg.forward_ton_amount > 0){ self._internal_transfer_notification(ctx, msg); } ``` 这里说明了前面 `JettonInternalTransfer` 消息中 `forward_ton_amount` 字段的作用。在它大于 0 的时候,会向当前 Jetton Wallet 钱包的 owner,也就是接收者的合约钱包发送一条 `transfer notification` 消息: ```tact! virtual inline fun _internal_transfer_notification( ctx: Context, msg: JettonInternalTransfer ) { if (msg.forward_ton_amount > 0) { send(SendParameters { to: self.owner, value: msg.forward_ton_amount, mode: SendPayGasSeparately, bounce: false, body: JettonTransferNotification { query_id: msg.query_id, amount: msg.amount, sender: msg.from, forward_payload: msg.forward_payload }.toCell() }); } } ``` 这条消息在有些场景中非常重要。由于 Jetton 中不存在 ***Approve - TransferFrom*** 的机制,因此在一些应用例如 DEX 中,无法用使用该机制,Jetton 只能由用户手动转入。那么 DEX 合约怎么知道 Jetton 已经转入呢,就是靠 `TransferNotification` 消息。在 DEX 等应用中,需要存在接收 `TransferNotification` 消息的逻辑。当收到该消息时,DEX 便知道有一笔新的 Jetton 转入,然后开始进行操作。 最后再调用 `_internal_transfer_excesses` 方法将剩余的 Gas 转走,一般是转给消息的初始发送者: ```tact= virtual inline fun _internal_transfer_excesses( ctx: Context, msg: JettonInternalTransfer, remain: Int ){ if((msg.response_address != newAddress(0, 0)) && remain > 0){ send(SendParameters { to: msg.response_address, value: remain, bounce: false, mode: SendIgnoreErrors, body: JettonExcesses { query_id: msg.query_id }.toCell() }); } } ``` 我们来看看 Jetton Wallet 中的转账逻辑。流程图如下: ![image](https://hackmd.io/_uploads/rJyMig2Byx.png) 如果用户想要将 Jetton 转给别人,则需要发送 `JettonTransfer` 给自己的 Jetton Wallet 合约: ```tact= receive(msg: JettonTransfer) { let ctx: Context = context(); self.balance = self.balance - msg.amount; require(self.balance >= 0, "JettonWallet: Not enough jettons to transfer"); // 校验 sender 是该 Jetton 钱包的 owner self._transfer_validate(ctx, msg); self._transfer_estimate_remain_value(ctx, msg); self._transfer_jetton(ctx, msg); } ``` 首先减去要转出的数量,随后通过 `_transfer_validate` 方法校验 sender 是否为该 Jetton Wallet 的 Owner。然后是 `_transfer_estimate_remain_value` 方法: ```tact= virtual inline fun _transfer_estimate_remain_value(ctx: Context, msg: JettonTransfer) { let fwd_count: Int = 1; if (msg.forward_ton_amount > 0) { fwd_count = 2; } require(ctx.value > fwd_count * ctx.readForwardFee() + 2 * self.gasConsumption + self.minTonsForStorage, "Not enough funds to transfer"); } ``` 简单来讲,该方法就是计算出整个转账流程所需的最小 Gas 数量,并要求初始的 Gas 大于这个数量。由于 TON 的一次交易是由多个交易组成的交易链,并且不具备原子性,因此为了避免中途 Gas 不足的场景,就需要在交易的初始入口校验 Gas 是否充足。 合约中的 `gasConsumption` 和 `minTonsForStorage` 都是经过大量的测试得出的合适值,并不一定非要用某个数字,只是这个数字比较合适。 `ctx.readForwardFee()` 是发送消息所需的花费,也就是我们上篇文章类比的 ***邮费***,它并不包含附带的 Gas。如果 `forward_ton_amount` 大于 0,则说明还需要发送一条 `TransferNotification` 消息,一共是两条消息。可能有朋友问,还有一条 `excesses` 消息怎么没有算进去,因为它转移的是剩余的 Gas,如果没有剩余的 Gas,那也是正常情况,就无需发送 `excesses` 消息。 我们注意到,发送 `JettonInternalTransfer` 消息时指定了 `bounce` 为 `true`,说明我们希望在消息遇到错误时可以返回一条 bounced 消息方便我们进行错误处理: ```tact= bounced(src: bounced<JettonInternalTransfer>) { self.balance = self.balance + src.amount; } ``` 这里便是处理错误情况的方法,由于在发送消息之前已经扣除了余额,因此如果消息出错,需要将余额再加回来。但是注意,由于 bounced 消息只能携带 224 bits 的有效数据,可用空间很小,所以其实它的可用性不高。因此在一般的业务开发中,还是推荐手动编码错误处理方法。 最后来看看 Burn 部分,先来看它的流程是怎样的: ![image](https://hackmd.io/_uploads/H19mjlnBJe.png) ```tact= receive(msg: JettonBurn) { let ctx: Context = context(); self.balance = self.balance - msg.amount; require(self.balance >= 0, "JettonWallet: Not enough balance to burn tokens"); // 校验 sender 是 Jetton 钱包的 owner self._burn_validate(ctx, msg); self._burn_tokens(ctx, msg); } ``` 首先扣除余额,然后通过 `_burn_validate` 方法来校验 sender 是否为该 Jetton Wallet 合约的 Owner,最后通过 `_burn_tokens` 方法向 Jetton Master 合约发送 `JettonBurnNotification` 消息: ```tact= virtual inline fun _burn_tokens(ctx: Context, msg: JettonBurn) { send(SendParameters{ to: self.jetton_master, value: 0, mode: SendRemainingValue, bounce: true, body: JettonBurnNotification{ query_id: msg.query_id, amount: msg.amount, sender: self.owner, response_destination: msg.response_destination }.toCell() }); } ``` 回到 `JettonMaster` 合约的消息接收部分: ```tact= receive(msg: JettonBurnNotification) { let ctx: Context = context(); self._burn_notification_validate(ctx, msg); self._burn_notification(ctx, msg); } ``` `_burn_notification_validate` 与 `_internal_transfer_validate` 方法类似,都是校验 sender 是否合法。随后在 `_burn_notification` 方法中,将 `total_supply` 更新,然后将剩余的 Gas 返还。 Jetton 的主要逻辑大概就是这样,大家还需多看多思考其中的流程逻辑以及合约关系。 ## 部署 Jetton 大家可以尝试自行在 Blueprint 中编写部署脚本并部署到测试网中,重点在于学习理解区块浏览器中显示的整个消息流,并与前面介绍的流程图对比是否一致。 ## 总结 本文主要介绍了 Jetton 代币的 Tact 实现,重点在于入门介绍,大家还是要多阅读代码,并尝试上手编写,多思考。这部分的难点主要还是在于各个场景中的消息流走向,是编写 TON 上 DeFi,GameFi 等协议的基础。 ## 关于我 欢迎[和我交流](https://linktr.ee/xyymeeth)