本文我们来学习如何使用 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`。

这里的 `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)