本文我们来学习如何使用 FunC 编写 TON 上的 Jetton 代币合约。在开始之前,希望读者已经阅读过之前的关于 Tact 编写 Jetton 的文章。其中介绍的一些 Jetton 特性,本文不再重复。
我们这里使用 TON 官方提供的 FunC 代码,来学习 Jetton 的编写。
与 Tact 相同,FunC 的 Jetton 也遵守相同的架构,即每个 Jetton 都分为 Master 合约和用户独立的 Jetton 钱包合约。不过在 FunC 这边,Master 被称为 Minter 合约,这应该是历史命名习惯,大家了解即可。
官方代码中,主要包含下面这些文件:
另外还有一些相关拓展合约,不过本文我们主要来学习上面的内容。上述文件中,前两个分别是 Minter 和 Jetton wallet 合约,下面三个是一些辅助工具方法。
我们从简单的内容看起,先看这几个工具方法的内容。
该文件中包含的是 Jetton 中需要用到的 opcode,对于每个动作,都定义了对应的 opcode。合约会根据 opcode 的不同来处理不同的消息。
asm
表示前面定义的方法使用后面的汇编操作码,PUSHINT
表示将整数压入栈中。在 FunC 中常在定义常量时会使用到该操作码。
TON 中存在主链和工作链,我们的合约一般都是部署在工作链上。force_chain
方法的作用是校验地址必须为 0 工作链的地址,该工作链的标识符为 0,主链的标识符为 -1。
该文件中包含四个方法:
这几个方法目的主要是为了计算合约的初始化状态和地址。
合约中向其它合约发送消息时,如果目标合约有可能还没有被部署,需要在消息中附带合约的代码和初始化状态。在 Tact 中,该消息可以这样编写:
其中,最后的 code
和 data
就是目标合约的代码和初始化状态。在 FunC 中,同样的场景也需要附带 code
和 data
。
TEP-74 中规定,Jetton wallet 的数据中需要包含如下的这四个字段:
所以需要将其打包起来并放入 Cell 中,该方法就是用于打包初始化的数据。
在编写自己的代码时,初始化需要什么数据,就类比该方法将其打包进 Cell 即可。
该方法接收的参数为初始化的数据,并调用 pack_jetton_wallet_data
将其打包,最后生成初始化合约的状态。类似于 Tact 中的:
pack_jetton_wallet_data
的第一个参数为 0,代表初始化的钱包余额为 0。
该方法的内容可能比较难懂,例如 store_uint(0, 2)
、store_uint(0, 1)
等,我们目前先不用纠结,只需知道这是固定形式即可。在编写自己的代码时,第一个 store_dict
放入目标合约的代码,第二个 store_dict
放入目标合约的初始化数据即可。
该方法通过上一步生成的合约初始化状态,计算出其地址。同样,目前不需要理解细节,这种写法是固定形式。类似于 Tact 中的:
这个就很简单了,接收初始化数据,并返回目标合约的地址。
看完了这几个辅助方法,我们接下来学习 Minter 和 Jetton wallet 合约。
读取合约 c4 寄存器中的数据,数据类型由 TEP-74 定义。
遵循 TEP-74 定义的规则,与 load_data
读取的数据需一致。
合约的入口方法,该方法前面的一些内容都比较常规,这里不再重复介绍,不理解的读者可以先学些这篇 FunC 的入门文章。
其中 slice sender_address = cs~load_msg_addr();
是从消息中获取 sender
地址。
随后通过 load_data
方法获取合约中存储的数据:
接下来的部分就是根据 opcode 的不同,处理不同的消息内容。总共包括下面四个消息:
其中,3
和 4
也是 opcode,它俩处理的逻辑比较简单,都是从消息体中获取相关数据,并将其存储到 c4 寄存器中:
其中,equal_slices
用于判断两个 slice(这里是两个地址)是否相同,如果 sender
不是管理员地址,则抛出异常。
打开 wrappers/JettonMinter.ts
文件,找到它们的消息体构成:
可以看到它们的消息体数据结构与合约中的解析部分相同。
op::burn_notification()
消息来自于 Jetton wallet 合约,这部分放到后面我们再做分析。
来看 op::mint()
部分,回忆一下 mint 的逻辑如下:
代码如下:
首先判断消息发送者是否是 admin,随后从 body
中解析数据,我们查看 wrappers/JettonMinter.ts
中的 mint 消息结构:
首先看 return 的 Cell,包含的数据如下:
对应于合约中,if
语句之前已经解析了 opcode
和 query_id
,随后又解析出 to_address
、total_ton_amount
、jetton_amount
、mintMsg
。
to_address
为 mint 接收者的地址,注意是接收者的 TON 钱包地址,而不是 Jetton 钱包地址。
total_ton_amount
为发送给 Jetton wallet 消息附带的 TON 数量。
jetton_amount
为 mint 的 Jetton 数量。
mintMsg
为发送给 Jetton wallet 的实际消息体,包含的数据如下:
在上述代码部分,mintMsg
被解析为 master_msg
,随后跳过前面两个数据,即 internal transfer opcode
和 query_id
,读取第三个数据 jetton_amount
。
随后,调用 mint_tokens
发送消息,最后使用 save_data
将 total_supply
数据更新。
前面两行通过调用之前介绍的辅助方法,获取目标合约的初始状态和地址,随后构造消息体内容,在消息体最后附上初始化状态和实际 body 消息体。对于这一堆参数不理解的读者可以阅读这篇文章。
最后使用 send_raw_message
方法发送消息,这里使用的 mode 是 1
,也就是 pay transfer fees separately,即发送消息的 Gas 需要额外支付。
消息被 Jetton wallet 合约接收,接收部分我们放在后面介绍。
还剩余两个 Getter 方法:
它们被链下调用,第一个方法用于获取合约中存储的数据,第二个方法用于获取用户的 Jetton wallet 地址。
接着来看 Jetton wallet 合约,用户转账以及销毁代币的入口都在该合约中。
首先定义了两个常量:
由于 TON 的合约需要向系统缴纳租金,min_tons_for_storage()
就是合约中必须维持的最低 TON 数量。gas_consumption()
是转账中的 Gas 消耗数量。这两个值都是经过测试之后的一个比较均衡的数量,你也可以将其变更,不过如果减少了,可能会导致一些问题。
分别用于加载与存储合约中的数据,他们的数据类型也是 TEP-74 中定义的,注意它们也与前面 mint 时构造的目标合约初始化数据一一对应:
先来看消息入口方法,这里与 Minter 合约的入口相比,有一些不同:
对于 bounced 消息,这里使用了 on_bounce
方法进行处理,该方法的内容我们后面再看。
接着从 in_msg_full
中解析出 sender 地址,并跳过一系列数据,获取该消息体的 fwd_fee
,这部分需要参考消息的具体构成。
一个消息的实际构成如下所示:
其中前 4 个 store_uint
的内容就是最开始的 flags
,随后跳过 destination
、amount
、extra_currencies
、ihr_fee
。最后通过 cs~load_coins()
解析出 fwd_value
,并通过:
计算出 fwd_fee
,可以将其简单理解成每次发送消息时所需的费用,即邮费,而不包含附带的 TON 数量。这部分的计算逻辑目前官方文档也没有给出一个特别清晰的说明,应该是和验证节点有关,大家目前暂时记住即可。
随后从 in_msg_body
中解析出 op
,并根据 op
的不同处理不同逻辑:
代码比较多,我们就挑一些重要的点来说。一些简单的数据解析等,就不再重复。
校验接收者的地址必须是 0 workchain 链上的地址。
转账这里与 mint 类似,由于接收者可能并不拥有 Jetton wallet,因此需要构造出目标合约的状态与地址,并将其附带在消息的后面。
重点来看看这几行代码,首先是从 in_msg_body
中解析出了 response_address
、custom_payload
、forward_ton_amount
。根据 TEP-74 的规范,此时后面应该还跟有 forward_payload
,也就是说 in_msg_body
的解析指针目前是在forward_payload
的起始位置。
我们查看 TEP-74 的数据类型规定:
它的意思是 forward_payload
的内容要么直接在当前 Cell 中原地解包,要么存储在另一个 Cell 中,并被引用进来。
对于前者,接下来的一个 bit 需要存储 0,后面再跟上 forward_payload
的实际内容。如果 forward_payload
为空,该 bit 也需要存储 0。对于后者,接下来的一个 bit 需要存储 1,后面跟上 forward_payload
的引用。也就是说,不论 forward_payload
是以何种形式存储,接下来的一个 bit 都是有数据的,0 或 1(参考)。
我们可以查看文档中给出的 Jetton Transfer 的消息构成的例子进行验证:
forwardPayload
作为引用放在最后,在它之前,存储的是 1。
forward payload
实际为空,但最后仍需要存储一位 0。
我们再来查看 wrappers/JettonWallet.ts
中 Jetton 转账消息的构成:
对于 forwardPayload
的存储,使用了 storeMaybeRef
。查看一个 golang 的实现:
如果 ref
为空,则需要存储一位 0,否则存储一位 1,并将 ref
作为引用存储。
看完了这些代码,我们再回头看 Jetton wallet 的代码:
forward_ton_amount
接下来的一位必然是有数据的,要么是 0,要么是 1。不管是什么,都是有数据的,因此能够通过 708
的校验,如果没有数据,则说明发送的消息体不符合 Jetton 的规范。
随后构造消息体,并将目标合约的 state_init
附带在消息体中。
这里是要求用户传入的 Gas 足够处理完整个流程。这部分的计算在之前的 Tact 文章中讲过,这里不再重复。
最后,发送消息,并将余额更新到合约数据中。
该方法的整体逻辑都比较简单,参考之前 Tact 的文章很容易理解。不过其中有两部分代码需要关注:
这里使用了 0x10
,即 non-bounceable 消息。这条消息是给接收者的地址发送 transfer_notification
消息,但是接收者的钱包可能还没有被部署。这种场景是可能存在的,例如 A 给 B 转 100 个 USDT,但是 B 刚刚生成了主网地址,还没有部署它自己的合约钱包。这条消息我们并不在乎它是否成功,因为主逻辑已经完成。此时如果使用了 bounceable 消息,会使整个逻辑变得更复杂,也可能会出错。
TON 中规定,如果向一个可能未被部署的合约发送消息,要么需附带 state init,要么需是 non-bounceable 消息。
preload_uint
用于预加载整数,也就是指针不会移动。在 Cell 中,空地址使用 2 位 0 表示,如果这里预加载的 2 位数据不是 0,则说明
response_address
不为空。如果该地址不为空,并且剩余的 Gas 大于 0,则将剩余的 Gas 返还给 response_address
。
这条消息同样为 non-bounceable 消息,因为返还 Gas 的地址可能未被部署。
burn 的逻辑更加简单,最后会向 Minter 合约发送消息。Minter 中同样使用了 preload_uint
来预加载地址,如果非空,则将剩余的 Gas 返还给 response_address
。
最后来看看这个比较新鲜的方法,它用于处理接收到的 bounced 回弹消息:
它的参数为 in_msg_body
,但是这个 body 并不是我们最初发送的 body。当初始 bounceable 消息变成 bounced 消息时,bounced 的消息体 body 将以 0xFFFFFFFF
开头(参考)。随后跟随的是原始消息体 body 的前 256 位数据,多余的数据将被截掉。
在 on_bounce
方法中,对于 in_msg_body
,先跳过前面的 0xFFFFFFFF
,随后从消息体中解析出 op
、query_id
和 jetton_amount
,它们分别占据 32 位、64 位、120 位的数据,加起来小于 256 位。方法中限制,返回的 op
必须是 op::internal_transfer()
或 op::burn_notification()
。由于这两条消息的前三个字段,都是 op
、query_id
和 jetton_amount
,因此可以放在一起处理。
这两条消息之前均是减少余额的行为,如果这两条消息被回弹,则将消息中的 jetton_amount
加回来。
这里的 bounced 消息,由于它的结构比较简单,最重要的字段就是 jetton_amount
,也恰好可以放在 bounced 消息体中,因此使用 bounced 消息来处理很合适。但是在实际业务开发中,并不推荐使用 bounced 消息,因为它的信息内容有限。如果需要有类似的错误处理,更推荐在接收合约中直接编写错误处理逻辑并回发消息。
最后还剩余一个 Get 方法,用于返回该钱包合约存储的数据,即用户的 Jetton 余额,用户的地址,Jetton minter 的合约地址,Jetton wallet 的合约代码。
至此,我们就完成了 FunC Jetton 合约的编写。
本文详细介绍了 FunC Jetton 合约的编写,并对一些代码细节进行了详细的讲解,如果对其都能够掌握,那么对于 FunC 的编写,将会更加得心应手。在学习完 FunC 版本的 Jetton 代码之后,其实会深切体会到 FunC 对于 Cell 数据的处理更加精确,因此如果想要在 TON 生态深入,FunC 是必须要掌握的。
欢迎和我交流