本文我们来学习如何使用 FunC 编写 TON 合约。FunC 是 TON 官方支持的合约开发语言,TON 上的主流钱包、DEX 等合约都是使用 FunC 编写的。FunC 有点类似于 C 语言,并且和 Solidity 以及之前学习的 Tact 语言结构不同,学起来比较困难,但是熟练之后其实是比 Tact 更加方便的。由于 TON 采用的是 Cell 数据类型,FunC 对于 Cell 数据的处理相对于 Tact 更加简单易懂。这篇文章也会加入我在学习 FunC 过程中的一些理解和一些避坑点,希望能够帮助到大家更快掌握 FunC。在阅读本文之前,希望大家能先看看之前介绍 Tact 的文章,其中介绍了一些 TON 合约的特点,本文不再重复。
首先来看看一个简单的 FunC 合约是什么架构:
这个方法就是 FunC 合约的入口,它包含四个参数:
这个合约内容看起来与 Solidity 和 Tact 大相径庭,Solidity 合约一般都是定义 N 个方法,需要调用哪个方法就调用哪个,但是 FunC 只有一个入口,就是这个 recv_internal()
。我们知道 TON 的合约交互都是靠消息传递,对于 FunC 来说,recv_internal()
就是所有消息的接收入口,也就是说所有消息发送过来,都需要由 recv_internal
处理。
回想之前学过的 Tact,它的结构是与 Solidity 类似的,有 N 个 receive()
方法来处理接收到的消息,但是这种语法实际只是 Tact 编译器为我们提供的语法糖。Tact 最终会被编译成 FunC 语言,它的底层实际上也是只有一个 recv_internal()
方法。
再来看这四个参数,首先所有的消息都会附带一定量的 TON 作为 Gas,msg_value
就是这个 Gas 的数量。my_balance
指的是当前合约的余额,它已经包含了 msg_value
。也就是说,在该消息之前,合约的余额 = my_balance - msg_value
。in_msg_full
和 in_msg_body
看起来比较类似,我们使用 Tact 中的发送消息来举例:
这里发送了一条消息,其中包含了实际消息体 InternalMsg
。在这个例子中,InternalMsg
就是 in_msg_body
,而 SendParameters
中所包含的所有信息就是 in_msg_full
,包括发送地址,接收地址等等。如果将消息类比成一封信的话,in_msg_body
就是信中的内容,in_msg_full
既包含信中的内容,也包含信封上信息,例如发件人,收件人等。那么在我们想要获得消息 sender
的时候,就需要通过解析 in_msg_full
来获取。
与 recv_internal()
相对应的还有一个 recv_external()
,我们知道 TON 的消息分为内部消息和外部消息,内部消息就是合约之间传递的消息,外部消息就是从链下发送到链上的消息。普通的合约业务编写都不涉及外部消息,一般只有钱包之类的合约需要接收外部消息。
如果所有消息都通过 recv_internal()
接收,那么不同的消息应该如何区分呢?FunC 中通过 opcode
进行区分,opcode
存在于消息体中,合约通过解析 opcode
来判断当前的消息是什么类型并加以处理。
我们之前在学习 Tact 的 Jetton 代币编写的时候,有过这样的数据类型:
其中 0x0f8a7ea5
就是 JettonTransfer
消息的 opcode。在 FunC 中其实并不存在 JettonTransfer
这样的标识符,它只认识 opcode,也就是 0x0f8a7ea5
。FunC 通过不同的 opcode,就可以处理不同的消息。
recv_internal()
最后的 impure
常用在一些写方法上,如果该方法对合约的数据有改动,就需要添加 impure
关键字,否则可能会被编译器优化掉。
TON 上的数据都是 Cell 类型,所有的数据解析、读取都是与 Cell 相关的。一个 Cell 可以存储最多 1023 bits 数据和最多 4 个对其它 Cell 的引用。
如果写过 Solidity assembly 的话,肯定对下面的语法比较熟悉:
其中 mload
、mload
等操作符都是对内存中的数据进行读写。我们将内存理解成一长条的数据,那么读写都是在这条数据上进行,每个数据都有自己的位置。例如,code
在内存中的的位置就存放在 mload(0x40)
中。类似的,假设内存中有 4 个数据,每个数据的大小都是 32 bytes,他们的内存中的位置是连续的,那么他们可能分别位于内存中的 0x20
、0x40
、0x60
、0x80
。如果想要读取这 4 个数据,就需要在这条内存上一个接着一个读取。
为什么要介绍 Solidity assembly 的内容,因为 FunC 中对于 Cell 的操作有点类似于它。我们可以将 Cell 类型也理解为一条数据,其中包含了最多 1023 bits 和最多 4 个引用。如果想要获取 Cell 中的数据,就需要一个一个读取。Solidity assembly 中,想要读取某个位置的数据,只要知道 index 就可以通过 mload()
获取,但是 FunC 中更加复杂一点,如果想要获取某个位置的数据,必须从 0 位置开始,一个一个读取,直到指针到达该数据的起始位,才能获取该数据。
为什么需要在学习 FunC 之前了解 Cell 类型,因为 FunC 中充满着对 Cell 数据的各种读写,在了解 Cell 类型之后,我们学习 FunC 才能更加得心应手。
我们仍然通过经典的 Counter 合约来学习 FunC 的编写。首先创建一个 Blueprint 项目:
npm create ton@latest
选择创建 A simple counter contract (FunC)
,此时框架会为我们创建一个 Counter 合约模板,其中包含了下面的方法(版本的不同,生成的合约可能略有差异):
我们分别来看这些方法都是干什么的。
get_data()
是 TVM 中的底层方法,TON 合约的 Storage 数据都存放在 c4 寄存器中,get_data()
方法获取 c4 中的所有数据并作为 Cell 类型返回。由于 Cell 类型不能直接读取,需要通过 Slice 这个介质读取,begin_parse()
的作用就是将 Cell 类型转换为 Slice 类型,此时 ds
就是一个包含该合约所有 Storage 数据的 Slice 变量。
随后,通过 ds~load_uint()
方法来获取 ds 中的数据,load_uint(32)
表示读取接下来的大小为 32 bits 的 uint 类型数据。注意这里有一个指针的概念,我们前面说过,可以将 Cell 类型简单理解为一条数据,每次读取都需要从 0 位置开始读取,也就是说读取的指针是从 0 开始,这里第一次在读取 32 bits 之后,将数据赋值给 ctx_id
变量,此时指针后移了 32 bits,第二次继续读取 32 bits 的数据,将其赋值给 ctx_counter
变量。
也就是说,两次 ds~load_uint(32)
操作读取的是两个位置的数据,并不是同一个数据。并且这里读取的实际数据类型并不是固定的,示例中是读取了两个 32 bits 的整数,这是由合约的初始化参数决定的。可以打开 wrappers/Counter.ts
文件,其中有如下的方法:
这里的作用就是定义初始的数据,为 2 个 32 bits 整数,如果我们将其改为
那么 load_data()
方法就需要改为:
初始化的数据格式需要与 load_data()
中的读取内容对应,否则会出错。
beginCell()
与 endCell()
可以简单理解为创建一个 Cell 的盒子,所有需要放到 Cell 中的数据都放在它俩之间。实际意义是由于 Cell 不能直接写入,需要借助 Builder 类型写入。beginCell()
首先创建一个 Builder 类型,然后往其中放数据,最后 endCell()
将其转化为 Cell 类型。
load_data()
方法是获取 Storage 变量时的常用方法名,还有一种写法如下:
这个方法直接返回了两个 int 类型的值,由于它并没有对数据进行更改,因此没有 impure
关键字,前面的 load_data()
则是将读取出来的值赋值给了 ctx_id
和 ctx_counter
,因此需要加上 impure
。
与 load_data()
相反,save_data()
用来将数据写入到 c4 中。set_data()
是 TVM 的底层方法,用于写入 c4 数据,它需要接收 Cell 类型的数据,与前面 Counter.ts
中的写法类似,FunC 中也需要先创建一个 Cell 的盒子,然后将数据放入其中:
这里的存储顺序也需要与前面的 load_data()
以及 Counter.ts
中的初始化顺序相同。FunC 中对于 Storage 变量的存储与 Solidity 以及 Tact 中的方法有很大不同,在后两者的语法中,假设我们需要修改某一个名为 foo
的变量,可以使用类似下面的语法:
需要修改哪个,直接修改即可。但是在 FunC 中,由于我们对于 Cell 的读取必须按照顺序从 0 位置开始,因此,假设我们想要修改 c4 中的第 3 个变量,必须将所有的数据都先读取出来,然后更新对应第 3 个变量,随后将所有的变量再存储一次。实际的作用就是修改了第 3 个变量,但是过程比较复杂。
所以当我们需要修改 c4 时,都需要先调用 load_data
将所有数据读取出来,随后修改某一个变量,最后再调用 save_data()
将所有数据存储到 c4 中。
来到最核心的方法,首先判断消息体是否为空:
注意这里判断的是 in_msg_body
,也就是信件中的内容,而不是 in_msg_full
。如果为空,直接结束。
slice cs = in_msg_full.begin_parse();
的作用是将 Cell 类型的 in_msg_full
转换为 Slice 类型并存放在 cs
变量中。
这里的逻辑涉及到了 in_msg_full
的具体内容,我们前面说过 in_msg_full
指的是信件的所有内容,包含信封上的内容。这里先读取一个 4 bits 的整数,这个整数包含了这条消息的一些 metadata,其中最后一位代表该条消息是否为 bounced 消息。如果是 bounced 消息,则直接结束。这条数据格式的读取方法,大家当前可以将其作为一种标准的处理模式,我们目前还不需要对消息的格式了解太过深入,只需要知道,如果想要处理 bounced 消息,则这么写:
如果不想处理 bounced 消息,则这么写:
注意这里的 bounced 消息指的是消息在遇到错误时回弹的消息,而不是初始发送的带有 bounce = true
标志位的消息。注意区分 bounce
和 bounced
。
这里的 flags & 1
解析的就是该条消息是否为图示中下面的这条回弹消息。
如果 in_msg_full
的具体内容感兴趣,可以看看这里。
继续来看 recv_internal()
的内容,load_data()
加载了 c4 的数据,并将其存储在了两个 global
变量中。
接着从消息体 in_msg_body
中解析出两个变量,32 位的op
和 64 位的 query_id
:
op
就是 opcode,根据它来区分消息体的内容,query_id
是一个比较常用的标志位,不过一般没什么用,自己写合约时,可以根据具体场景判断是否需要该变量。
这里的 op::increase
来自合约初始定义的常量:
这种字符串后面带 c
是 FunC 常见的创建 opcode 的方法,可以将该字符串转换成 32 位 16 进制的 opcode,这里的结果是 0x7e8764ef
。
随后在 if
语句中处理相应的逻辑,注意 in_msg_body
的读取也是需要从 0 开始一个一个读取,并且读取指针会在读取后顺移。这里可以根据 in_msg_body
的读取逻辑,总结出 in_msg_body
的数据分别是:
我们打开 wrappers/Counter.ts
文件,查看 sendIncrease
的内容:
可以确认两部分的数据结构相同。
回到方法内容,该合约只支持了一个 opcode,也就是一种消息类型,如果我们希望增加更多的数据类型,可以自定义。例如,我们想增加 op::decrease
,则可以在合约中添加:
最后,throw(0xffff)
代表我们的合约只支持前面的 opcode,如果 opcode 不符合规定,则抛出异常。
最后的 get_counter
和 get_id
是两个 getter 方法,注意它们只能被链下调用。Getter 方法需要添加 method_id
关键字:
此时我们就已经编写完了一个简单的 Counter 合约,如果想要与它进行交互的话,需要一个中间介质帮助我们,即 wrappers 文件夹下的内容。对于 Tact 来说,每次编译时,框架都会根据合约内容为我们生成对应的 wrapper 内容,但是对于 FunC 来说,我们就需要自行编写 wrappers 的内容来适配我们的合约。
使用框架新建合约的时候,它已经为我们生成了一系列文件,包括合约本身,wrapper 文件,单元测试文件,部署脚本等。例如上面的 Counter 合约,已经有一套现成的模板。但是如果我们想要从零开始编写自己的合约,那么这些文件内容都需要自己编写。例如,我们为 Counter 合约添加了 decrease
消息处理逻辑,那就同样需要在 wrapper 中编写相应的 sendDecrease
方法。
来看看 wrapper 中都包含了什么内容,以 Counter.ts 文件为例。
CounterConfig
可以理解为 Counter 合约的初始化参数对象,其中包含了两个字段,但它们的名字是无所谓的,这里的 id
和 counter
只是 wrapper 层面的名称,FunC 中并没有这些名称。
counterConfigToCell
方法接收 CounterConfig
对象的参数,并将其存放在 Cell 中,并指定它俩的数据大小均为 32 位。
Opcodes
是一个枚举,目的是提高代码可读性。
来到 Counter
类中:
createFromAddress
方法通过地址获取 Counter
的实例,一般是在合约已经部署的情况下使用。
createFromConfig
是我们要认真看的一个方法,它先调用了前面的 counterConfigToCell
方法生成一个包含初始化数据的 Cell 数据 data
,随后通过 code
和 data
生成一个包含合约初始化状态的 init
状态对象,最后通过组合 workchain
和 init
来生成 Counter
对象。
这个操作也说明了 TON 的合约地址是通过代码和初始化状态决定的。注意此时获取到的是某个特定的合约对象,它与合约是否部署无关。无论合约是否部署,该对象都能成功获取。一般都是先使用这个方法获取合约对象,然后向合约发送消息进行部署。
接下来有两个 send 方法,分别是 sendDeploy
和 sendIncrease
,其中前者用于部署合约,后者用于发送 increase 消息。我在之前的文章中提到过,TON 合约的部署只是一条普通的消息,实际并不一定要通过这个 sendDeploy
消息才能部署,通过任何一条消息都可以,只要这条消息能够到达合约,合约就可以被部署。这里通过 sendDeploy
进行部署,也只是将部署这个动作显式表明。
我们查看 sendDeploy
的内容,实际上就是发送了一个空消息体:
当该消息到达合约时,recv_internal()
的第一条语句就是:
如果消息体为空,直接返回。实际上该消息什么也没有干,但它作为一条消息到达了合约,就完成了部署合约这一任务。
大家对这里感兴趣的话可以在其中加入 ~dump()
语句进行打印,观察 sendDeploy
时,是否有打印相关信息:
说到这里,~dump()
是 FunC 中的一个 debug 工具,可以打印出一些内容方便 debug,类似于 Solidity 中的 console.log()
。但是它目前还有一些限制,只能正常打印数字,如果是字符串和地址,则无法很好展示。例如:
打印的内容为:
来看 sendIncrease
方法,我们在之前已经简单看过,它将合约中需要的信息包装在了 Cell 中:
对应了合约中解析的顺序:
wrapper 最后,是两个 Get 方法,它们分别包装了 get_counter
和 get_id
方法。
在 wrapper 中,规定所有的消息发送方法都需要以 send
开头,所有的 Get 方法都需要以 get
开头。
目前的这些方法都是框架提供的模板,如果我们在合约中添加了其它的方法,那就需要在 wrapper 中也编写对应的 send
或 get
方法,以供外部调用。
最后我们来看看 FunC 的单元测试如何编写,打开 tests/Counter.spec.ts
文件,可以看到与 Tact 的单元测试内容大同小异。
beforeAll
中的使用 compile
来获取合约的编译结果:
这里是实时编译,说明我们可以在单元测试之前不用每次都先 build 再 test,这里 test 的时候直接就编译了。
beforeEach
中先获取 counter
合约实例,此时合约还没有部署,但是它的地址我们已经知道,然后调用 sendDeploy
发送一条消息,将合约部署。
单元测试部分别的内容都比较简单,大家自己看看理解即可。
合约的部署脚本位于 scripts
文件夹下,其内容与单元测试部分类似,这里不再重复介绍。对合约部署操作不太了解的朋友可以看看之前 Tact 文章。
本文介绍了 FunC 合约的编写和一些基本概念,其中有许多内容是我自己踩了许多坑才发现的细节,希望能够帮助到大家更好的学习 FunC 合约。这篇文章介绍了单个合约的简单编写,下篇文章将介绍如何使用 FunC 来发送消息。
欢迎和我交流