本文我们来学习如何使用 FunC 编写 TON 合约。FunC 是 TON 官方支持的合约开发语言,TON 上的主流钱包、DEX 等合约都是使用 FunC 编写的。FunC 有点类似于 C 语言,并且和 Solidity 以及之前学习的 Tact 语言结构不同,学起来比较困难,但是熟练之后其实是比 Tact 更加方便的。由于 TON 采用的是 Cell 数据类型,FunC 对于 Cell 数据的处理相对于 Tact 更加简单易懂。这篇文章也会加入我在学习 FunC 过程中的一些理解和一些避坑点,希望能够帮助到大家更快掌握 FunC。在阅读本文之前,希望大家能先看看之前介绍 Tact 的[文章](https://hackmd.io/@xyymeeth/HkOEnF8rye),其中介绍了一些 TON 合约的特点,本文不再重复。 ## FunC 合约架构 首先来看看一个简单的 FunC 合约是什么架构: ```func= () recv_internal(int my_balance, int msg_value, cell in_msg_full, slice in_msg_body) impure { ;; code will be here } ``` 这个方法就是 FunC 合约的入口,它包含四个参数: + my_balance:当前合约的余额,已经包含了 msg_value + msg_value:进站消息附带的 TON 数量 + in_msg_full:进站消息体,包含消息全部内容 + in_msg_body:进站消息体,只包含附带的消息体 这个合约内容看起来与 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 中的发送消息来举例: ```tact= send(SendParameters { bounce: false to: msg.to, value: 0, mode: SendRemainingValue | SendIgnoreErrors, body: InternalMsg { number: msg.number }.toCell() }); ``` 这里发送了一条消息,其中包含了实际消息体 `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 代币编写的时候,有过这样的数据类型: ```txt message(0x0f8a7ea5) JettonTransfer ``` 其中 `0x0f8a7ea5` 就是 `JettonTransfer` 消息的 opcode。在 FunC 中其实并不存在 `JettonTransfer` 这样的标识符,它只认识 opcode,也就是 `0x0f8a7ea5`。FunC 通过不同的 opcode,就可以处理不同的消息。 `recv_internal()` 最后的 `impure` 常用在一些写方法上,如果该方法对合约的数据有改动,就需要添加 `impure` 关键字,否则可能会被编译器优化掉。 ## Cell 类型 TON 上的数据都是 Cell 类型,所有的数据解析、读取都是与 Cell 相关的。一个 Cell 可以存储最多 1023 bits 数据和最多 4 个对其它 Cell 的引用。 如果写过 Solidity assembly 的话,肯定对下面的语法比较熟悉: ```solidity= assembly { // retrieve the size of the code, this needs assembly let size := extcodesize(addr) // allocate output byte array - this could also be done without assembly // by using code = new bytes(size) code := mload(0x40) // new "memory end" including padding mstore(0x40, add(code, and(add(add(size, 0x20), 0x1f), not(0x1f)))) // store length in memory mstore(code, size) // actually retrieve the code, this needs assembly extcodecopy(addr, add(code, 0x20), 0, size) } ``` 其中 `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 合约编写 为什么需要在学习 FunC 之前了解 Cell 类型,因为 FunC 中充满着对 Cell 数据的各种读写,在了解 Cell 类型之后,我们学习 FunC 才能更加得心应手。 我们仍然通过经典的 Counter 合约来学习 FunC 的编写。首先创建一个 Blueprint 项目: > npm create ton@latest 选择创建 `A simple counter contract (FunC)`,此时框架会为我们创建一个 Counter 合约模板,其中包含了下面的方法(版本的不同,生成的合约可能略有差异): + load_data() + save_data() + recv_internal() + get_counter() + get_id() 我们分别来看这些方法都是干什么的。 ### load_data() ```func= () load_data() impure { var ds = get_data().begin_parse(); ctx_id = ds~load_uint(32); ctx_counter = ds~load_uint(32); ds.end_parse(); } ``` `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` 文件,其中有如下的方法: ```ts= export function counterConfigToCell(config: CounterConfig): Cell { return beginCell().storeUint(config.id, 32).storeUint(config.counter, 32).endCell(); } ``` 这里的作用就是定义初始的数据,为 2 个 32 bits 整数,如果我们将其改为 ```ts= beginCell() .storeUint(config.id, 32) .storeUint(config.counter, 64) .storeUint(config.somethingElse, 64) .endCell(); ``` 那么 `load_data()` 方法就需要改为: ```func= () load_data() impure { var ds = get_data().begin_parse(); ctx_id = ds~load_uint(32); ctx_counter = ds~load_uint(64); ctx_somethingElse = ds~load_uint(64); ds.end_parse(); } ``` 初始化的数据格式需要与 `load_data()` 中的读取内容对应,否则会出错。 `beginCell()` 与 `endCell()` 可以简单理解为创建一个 Cell 的盒子,所有需要放到 Cell 中的数据都放在它俩之间。实际意义是由于 Cell 不能直接写入,需要借助 Builder 类型写入。`beginCell()` 首先创建一个 Builder 类型,然后往其中放数据,最后 `endCell()` 将其转化为 Cell 类型。 `load_data()` 方法是获取 Storage 变量时的常用方法名,还有一种写法如下: ```func= (int, int) load_data() { var ds = get_data().begin_parse(); return (ds~load_uint(32), ds~load_uint(32)); } ``` 这个方法直接返回了两个 int 类型的值,由于它并没有对数据进行更改,因此没有 `impure` 关键字,前面的 `load_data()` 则是将读取出来的值赋值给了 `ctx_id` 和 `ctx_counter`,因此需要加上 `impure`。 ### save_data() 与 `load_data()` 相反,`save_data()` 用来将数据写入到 c4 中。`set_data()` 是 TVM 的底层方法,用于写入 c4 数据,它需要接收 Cell 类型的数据,与前面 `Counter.ts` 中的写法类似,FunC 中也需要先创建一个 Cell 的***盒子***,然后将数据放入其中: ```func= () save_data() impure { set_data( begin_cell() .store_uint(ctx_id, 32) .store_uint(ctx_counter, 32) .end_cell() ); } ``` 这里的存储顺序也需要与前面的 `load_data()` 以及 `Counter.ts` 中的初始化顺序相同。FunC 中对于 Storage 变量的存储与 Solidity 以及 Tact 中的方法有很大不同,在后两者的语法中,假设我们需要修改某一个名为 `foo` 的变量,可以使用类似下面的语法: ```tact= self.foo = foo; ``` 需要修改哪个,直接修改即可。但是在 FunC 中,由于我们对于 Cell 的读取必须按照顺序从 0 位置开始,因此,假设我们想要修改 c4 中的第 3 个变量,必须将所有的数据都先读取出来,然后更新对应第 3 个变量,随后将所有的变量再存储一次。实际的作用就是修改了第 3 个变量,但是过程比较复杂。 所以当我们需要修改 c4 时,都需要先调用 `load_data` 将所有数据读取出来,随后修改某一个变量,最后再调用 `save_data()` 将所有数据存储到 c4 中。 ### recv_internal() 来到最核心的方法,首先判断消息体是否为空: ```func= if (in_msg_body.slice_empty?()) { ;; ignore all empty messages return (); } ``` 注意这里判断的是 `in_msg_body`,也就是信件中的***内容***,而不是 `in_msg_full`。如果为空,直接结束。 `slice cs = in_msg_full.begin_parse();` 的作用是将 Cell 类型的 `in_msg_full` 转换为 Slice 类型并存放在 `cs` 变量中。 ```func= int flags = cs~load_uint(4); if (flags & 1) { ;; ignore all bounced messages return (); } ``` 这里的逻辑涉及到了 `in_msg_full` 的具体内容,我们前面说过 `in_msg_full` 指的是信件的所有内容,包含***信封上的内容***。这里先读取一个 4 bits 的整数,这个整数包含了这条消息的一些 metadata,其中最后一位代表该条消息是否为 bounced 消息。如果是 bounced 消息,则直接结束。这条数据格式的读取方法,大家当前可以将其作为一种标准的处理模式,我们目前还不需要对消息的格式了解太过深入,只需要知道,如果想要处理 bounced 消息,则这么写: ```func= int flags = cs~load_uint(4); if (flags & 1) { ;; ignore all bounced messages doSomething(); } ``` 如果不想处理 bounced 消息,则这么写: ```func= int flags = cs~load_uint(4); if (flags & 1) { ;; ignore all bounced messages return (); } ``` 注意这里的 bounced 消息指的是消息在遇到错误时回弹的消息,而不是初始发送的带有 `bounce = true` 标志位的消息。注意区分 `bounce` 和 `bounced`。 ![image](https://hackmd.io/_uploads/By7oIjDD1g.png) 这里的 `flags & 1` 解析的就是该条消息是否为图示中下面的这条回弹消息。 如果 `in_msg_full` 的具体内容感兴趣,可以看看[这里](https://docs.ton.org/v3/documentation/smart-contracts/message-management/sending-messages)。 继续来看 `recv_internal()` 的内容,`load_data()` 加载了 c4 的数据,并将其存储在了两个 `global` 变量中。 接着从消息体 `in_msg_body` 中解析出两个变量,32 位的`op` 和 64 位的 `query_id`: ```func= int op = in_msg_body~load_uint(32); int query_id = in_msg_body~load_uint(64); ``` `op` 就是 opcode,根据它来区分消息体的内容,`query_id` 是一个比较常用的标志位,不过一般没什么用,自己写合约时,可以根据具体场景判断是否需要该变量。 ```func= if (op == op::increase) { int increase_by = in_msg_body~load_uint(32); ctx_counter += increase_by; save_data(); return (); } ``` 这里的 `op::increase` 来自合约初始定义的常量: ```func= const op::increase = "op::increase"c; ``` 这种字符串后面带 `c` 是 FunC 常见的创建 opcode 的方法,可以将该字符串转换成 32 位 16 进制的 opcode,这里的结果是 `0x7e8764ef`。 随后在 `if` 语句中处理相应的逻辑,注意 `in_msg_body` 的读取也是需要从 0 开始一个一个读取,并且读取指针会在读取后顺移。这里可以根据 `in_msg_body` 的读取逻辑,总结出 `in_msg_body` 的数据分别是: + op, 32 bits + query_id, 64 bits + increase_by, 32 bit 我们打开 `wrappers/Counter.ts` 文件,查看 `sendIncrease` 的内容: ```ts= await provider.internal(via, { value: opts.value, sendMode: SendMode.PAY_GAS_SEPARATELY, body: beginCell() .storeUint(Opcodes.increase, 32) .storeUint(opts.queryID ?? 0, 64) .storeUint(opts.increaseBy, 32) .endCell(), }); ``` 可以确认两部分的数据结构相同。 回到方法内容,该合约只支持了一个 opcode,也就是一种消息类型,如果我们希望增加更多的数据类型,可以自定义。例如,我们想增加 `op::decrease`,则可以在合约中添加: ```func= ;; ... const op::decrease = "op::decrease"c; ;; ... () recv_internal(int my_balance, int msg_value, cell in_msg_full, slice in_msg_body) impure { ;; ... if (op == op::increase) { int increase_by = in_msg_body~load_uint(32); ctx_counter += increase_by; save_data(); return (); } if (op == op::decrease) { int decrease_by = in_msg_body~load_uint(32); ctx_counter -= decrease_by; save_data(); return (); } ;; ... } ``` 最后,`throw(0xffff)` 代表我们的合约只支持前面的 opcode,如果 opcode 不符合规定,则抛出异常。 ### Getter 最后的 `get_counter` 和 `get_id` 是两个 getter 方法,注意它们只能被链下调用。Getter 方法需要添加 `method_id` 关键字: ```func= int get_counter() method_id { load_data(); return ctx_counter; } ``` 此时我们就已经编写完了一个简单的 Counter 合约,如果想要与它进行交互的话,需要一个中间介质帮助我们,即 wrappers 文件夹下的内容。对于 Tact 来说,每次编译时,框架都会根据合约内容为我们生成对应的 wrapper 内容,但是对于 FunC 来说,我们就需要自行编写 wrappers 的内容来适配我们的合约。 ## Wrapper 使用框架新建合约的时候,它已经为我们生成了一系列文件,包括合约本身,wrapper 文件,单元测试文件,部署脚本等。例如上面的 Counter 合约,已经有一套现成的模板。但是如果我们想要从零开始编写自己的合约,那么这些文件内容都需要自己编写。例如,我们为 Counter 合约添加了 `decrease` 消息处理逻辑,那就同样需要在 wrapper 中编写相应的 `sendDecrease` 方法。 来看看 wrapper 中都包含了什么内容,以 Counter.ts 文件为例。 ```ts= export type CounterConfig = { id: number; counter: number; }; ``` `CounterConfig` 可以理解为 Counter 合约的初始化参数对象,其中包含了两个字段,但它们的名字是无所谓的,这里的 `id` 和 `counter` 只是 wrapper 层面的名称,FunC 中并没有这些名称。 ```ts= export function counterConfigToCell(config: CounterConfig): Cell { return beginCell().storeUint(config.id, 32).storeUint(config.counter, 32).endCell(); } ``` `counterConfigToCell` 方法接收 `CounterConfig` 对象的参数,并将其存放在 Cell 中,并指定它俩的数据大小均为 32 位。 ```ts= export const Opcodes = { increase: 0x7e8764ef, }; ``` `Opcodes` 是一个枚举,目的是提高代码可读性。 来到 `Counter` 类中: ```ts= static createFromAddress(address: Address) { return new Counter(address); } ``` `createFromAddress` 方法通过地址获取 `Counter` 的实例,一般是在合约已经部署的情况下使用。 ```ts= static createFromConfig(config: CounterConfig, code: Cell, workchain = 0) { const data = counterConfigToCell(config); const init = { code, data }; return new Counter(contractAddress(workchain, init), init); } ``` `createFromConfig` 是我们要认真看的一个方法,它先调用了前面的 `counterConfigToCell` 方法生成一个包含初始化数据的 Cell 数据 `data`,随后通过 `code` 和 `data` 生成一个包含合约初始化状态的 `init` 状态对象,最后通过组合 `workchain` 和 `init` 来生成 `Counter` 对象。 这个操作也说明了 TON 的合约地址是通过代码和初始化状态决定的。注意此时获取到的是某个特定的合约对象,它与合约是否部署无关。无论合约是否部署,该对象都能成功获取。一般都是先使用这个方法获取合约对象,然后向合约发送消息进行部署。 接下来有两个 send 方法,分别是 `sendDeploy` 和 `sendIncrease`,其中前者用于部署合约,后者用于发送 increase 消息。我在之前的[文章](https://hackmd.io/@xyymeeth/HkOEnF8rye#%E9%83%A8%E7%BD%B2%E5%90%88%E7%BA%A6)中提到过,TON 合约的部署只是一条普通的消息,实际并不一定要通过这个 `sendDeploy` 消息才能部署,通过任何一条消息都可以,只要这条消息能够到达合约,合约就可以被部署。这里通过 `sendDeploy` 进行部署,也只是将部署这个动作显式表明。 我们查看 `sendDeploy` 的内容,实际上就是发送了一个空消息体: ```ts= body: beginCell().endCell() ``` 当该消息到达合约时,`recv_internal()` 的第一条语句就是: ```func= if (in_msg_body.slice_empty?()) { ;; ignore all empty messages return (); } ``` 如果消息体为空,直接返回。实际上该消息什么也没有干,但它作为一条消息到达了合约,就完成了部署合约这一任务。 大家对这里感兴趣的话可以在其中加入 `~dump()` 语句进行打印,观察 `sendDeploy` 时,是否有打印相关信息: ```func= if (in_msg_body.slice_empty?()) { ;; ignore all empty messages ~dump(1234567); return (); } ``` 说到这里,`~dump()` 是 FunC 中的一个 debug 工具,可以打印出一些内容方便 debug,类似于 Solidity 中的 `console.log()`。但是它目前还有一些限制,只能正常打印数字,如果是字符串和地址,则无法很好展示。例如: ```func= if (in_msg_body.slice_empty?()) { ;; ignore all empty messages ~dump("empty message"); ~dump(my_address()); ~dump(1234567); return (); } ``` 打印的内容为: ```bash= #DEBUG#: s0 = CS{Cell{00a7d1b088831c0238816e2f595b5c1d1e481b595cdcd859d963f880c3e0a3f880c208204b5a1ff880c38007434c0cc1c6c244c383c05b4c7f4cfcc4060841fa1d93beea6f4c7cc3e1080683e18bc05f80c2103fcbc2} bits: 82..186; refs: 0..0} #DEBUG#: s0 = CS{Cell{005fc008585c24112f37479162e19eb6f0ee2dcad4f3eedf942bc38594a2b08379748ec00000000000000000000000000004} bits: 1..268; refs: 0..0} #DEBUG#: s0 = 1234567 ``` 来看 `sendIncrease` 方法,我们在之前已经简单看过,它将合约中需要的信息包装在了 Cell 中: ```ts= body: beginCell() .storeUint(Opcodes.increase, 32) .storeUint(opts.queryID ?? 0, 64) .storeUint(opts.increaseBy, 32) .endCell(), ``` 对应了合约中解析的顺序: ```func= int op = in_msg_body~load_uint(32); int query_id = in_msg_body~load_uint(64); int increase_by = in_msg_body~load_uint(32); ``` wrapper 最后,是两个 Get 方法,它们分别包装了 `get_counter` 和 `get_id` 方法。 在 wrapper 中,规定所有的消息发送方法都需要以 `send` 开头,所有的 Get 方法都需要以 `get` 开头。 目前的这些方法都是框架提供的模板,如果我们在合约中添加了其它的方法,那就需要在 wrapper 中也编写对应的 `send` 或 `get` 方法,以供外部调用。 ## 单元测试 最后我们来看看 FunC 的单元测试如何编写,打开 `tests/Counter.spec.ts` 文件,可以看到与 Tact 的单元测试内容大同小异。 `beforeAll` 中的使用 `compile` 来获取合约的编译结果: ```ts= beforeAll(async () => { code = await compile('Counter'); }); ``` 这里是实时编译,说明我们可以在单元测试之前不用每次都先 build 再 test,这里 test 的时候直接就编译了。 ```ts= counter = blockchain.openContract( Counter.createFromConfig( { id: 0, counter: 0, }, code ) ); deployer = await blockchain.treasury('deployer'); const deployResult = await counter.sendDeploy(deployer.getSender(), toNano('0.05')); ``` `beforeEach` 中先获取 `counter` 合约实例,此时合约还没有部署,但是它的地址我们已经知道,然后调用 `sendDeploy` 发送一条消息,将合约部署。 单元测试部分别的内容都比较简单,大家自己看看理解即可。 ## 合约部署 合约的部署脚本位于 `scripts` 文件夹下,其内容与单元测试部分类似,这里不再重复介绍。对合约部署操作不太了解的朋友可以看看之前 Tact [文章](https://hackmd.io/@xyymeeth/HkOEnF8rye#%E9%83%A8%E7%BD%B2%E5%90%88%E7%BA%A6)。 ## 总结 本文介绍了 FunC 合约的编写和一些基本概念,其中有许多内容是我自己踩了许多坑才发现的细节,希望能够帮助到大家更好的学习 FunC 合约。这篇文章介绍了单个合约的简单编写,下篇文章将介绍如何使用 FunC 来发送消息。 ## 关于我 欢迎[和我交流](https://linktr.ee/xyymeeth)