Try   HackMD

本文我们来学习如何使用 FunC 编写 TON 合约。FunC 是 TON 官方支持的合约开发语言,TON 上的主流钱包、DEX 等合约都是使用 FunC 编写的。FunC 有点类似于 C 语言,并且和 Solidity 以及之前学习的 Tact 语言结构不同,学起来比较困难,但是熟练之后其实是比 Tact 更加方便的。由于 TON 采用的是 Cell 数据类型,FunC 对于 Cell 数据的处理相对于 Tact 更加简单易懂。这篇文章也会加入我在学习 FunC 过程中的一些理解和一些避坑点,希望能够帮助到大家更快掌握 FunC。在阅读本文之前,希望大家能先看看之前介绍 Tact 的文章,其中介绍了一些 TON 合约的特点,本文不再重复。

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_valuein_msg_fullin_msg_body 看起来比较类似,我们使用 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 代币编写的时候,有过这样的数据类型:

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 的话,肯定对下面的语法比较熟悉:

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) }

其中 mloadmload 等操作符都是对内存中的数据进行读写。我们将内存理解成一长条的数据,那么读写都是在这条数据上进行,每个数据都有自己的位置。例如,code 在内存中的的位置就存放在 mload(0x40) 中。类似的,假设内存中有 4 个数据,每个数据的大小都是 32 bytes,他们的内存中的位置是连续的,那么他们可能分别位于内存中的 0x200x400x600x80。如果想要读取这 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()

() 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 文件,其中有如下的方法:

export function counterConfigToCell(config: CounterConfig): Cell { return beginCell().storeUint(config.id, 32).storeUint(config.counter, 32).endCell(); }

这里的作用就是定义初始的数据,为 2 个 32 bits 整数,如果我们将其改为

beginCell() .storeUint(config.id, 32) .storeUint(config.counter, 64) .storeUint(config.somethingElse, 64) .endCell();

那么 load_data() 方法就需要改为:

() 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 变量时的常用方法名,还有一种写法如下:

(int, int) load_data() { var ds = get_data().begin_parse(); return (ds~load_uint(32), ds~load_uint(32)); }

这个方法直接返回了两个 int 类型的值,由于它并没有对数据进行更改,因此没有 impure 关键字,前面的 load_data() 则是将读取出来的值赋值给了 ctx_idctx_counter,因此需要加上 impure

save_data()

load_data() 相反,save_data() 用来将数据写入到 c4 中。set_data() 是 TVM 的底层方法,用于写入 c4 数据,它需要接收 Cell 类型的数据,与前面 Counter.ts 中的写法类似,FunC 中也需要先创建一个 Cell 的盒子,然后将数据放入其中:

() 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 的变量,可以使用类似下面的语法:

self.foo = foo;

需要修改哪个,直接修改即可。但是在 FunC 中,由于我们对于 Cell 的读取必须按照顺序从 0 位置开始,因此,假设我们想要修改 c4 中的第 3 个变量,必须将所有的数据都先读取出来,然后更新对应第 3 个变量,随后将所有的变量再存储一次。实际的作用就是修改了第 3 个变量,但是过程比较复杂。

所以当我们需要修改 c4 时,都需要先调用 load_data 将所有数据读取出来,随后修改某一个变量,最后再调用 save_data() 将所有数据存储到 c4 中。

recv_internal()

来到最核心的方法,首先判断消息体是否为空:

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 变量中。

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 消息,则这么写:

int flags = cs~load_uint(4); if (flags & 1) { ;; ignore all bounced messages doSomething(); }

如果不想处理 bounced 消息,则这么写:

int flags = cs~load_uint(4); if (flags & 1) { ;; ignore all bounced messages return (); }

注意这里的 bounced 消息指的是消息在遇到错误时回弹的消息,而不是初始发送的带有 bounce = true 标志位的消息。注意区分 bouncebounced

image

这里的 flags & 1 解析的就是该条消息是否为图示中下面的这条回弹消息。

如果 in_msg_full 的具体内容感兴趣,可以看看这里

继续来看 recv_internal() 的内容,load_data() 加载了 c4 的数据,并将其存储在了两个 global 变量中。

接着从消息体 in_msg_body 中解析出两个变量,32 位的op 和 64 位的 query_id

int op = in_msg_body~load_uint(32); int query_id = in_msg_body~load_uint(64);

op 就是 opcode,根据它来区分消息体的内容,query_id 是一个比较常用的标志位,不过一般没什么用,自己写合约时,可以根据具体场景判断是否需要该变量。

if (op == op::increase) { int increase_by = in_msg_body~load_uint(32); ctx_counter += increase_by; save_data(); return (); }

这里的 op::increase 来自合约初始定义的常量:

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 的内容:

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,则可以在合约中添加:

;; ... 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_counterget_id 是两个 getter 方法,注意它们只能被链下调用。Getter 方法需要添加 method_id 关键字:

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 文件为例。

export type CounterConfig = { id: number; counter: number; };

CounterConfig 可以理解为 Counter 合约的初始化参数对象,其中包含了两个字段,但它们的名字是无所谓的,这里的 idcounter 只是 wrapper 层面的名称,FunC 中并没有这些名称。

export function counterConfigToCell(config: CounterConfig): Cell { return beginCell().storeUint(config.id, 32).storeUint(config.counter, 32).endCell(); }

counterConfigToCell 方法接收 CounterConfig 对象的参数,并将其存放在 Cell 中,并指定它俩的数据大小均为 32 位。

export const Opcodes = { increase: 0x7e8764ef, };

Opcodes 是一个枚举,目的是提高代码可读性。

来到 Counter 类中:

static createFromAddress(address: Address) { return new Counter(address); }

createFromAddress 方法通过地址获取 Counter 的实例,一般是在合约已经部署的情况下使用。

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,随后通过 codedata 生成一个包含合约初始化状态的 init 状态对象,最后通过组合 workchaininit 来生成 Counter 对象。

这个操作也说明了 TON 的合约地址是通过代码和初始化状态决定的。注意此时获取到的是某个特定的合约对象,它与合约是否部署无关。无论合约是否部署,该对象都能成功获取。一般都是先使用这个方法获取合约对象,然后向合约发送消息进行部署。

接下来有两个 send 方法,分别是 sendDeploysendIncrease,其中前者用于部署合约,后者用于发送 increase 消息。我在之前的文章中提到过,TON 合约的部署只是一条普通的消息,实际并不一定要通过这个 sendDeploy 消息才能部署,通过任何一条消息都可以,只要这条消息能够到达合约,合约就可以被部署。这里通过 sendDeploy 进行部署,也只是将部署这个动作显式表明。

我们查看 sendDeploy 的内容,实际上就是发送了一个空消息体:

body: beginCell().endCell()

当该消息到达合约时,recv_internal() 的第一条语句就是:

if (in_msg_body.slice_empty?()) { ;; ignore all empty messages return (); }

如果消息体为空,直接返回。实际上该消息什么也没有干,但它作为一条消息到达了合约,就完成了部署合约这一任务。

大家对这里感兴趣的话可以在其中加入 ~dump() 语句进行打印,观察 sendDeploy 时,是否有打印相关信息:

if (in_msg_body.slice_empty?()) { ;; ignore all empty messages ~dump(1234567); return (); }

说到这里,~dump() 是 FunC 中的一个 debug 工具,可以打印出一些内容方便 debug,类似于 Solidity 中的 console.log()。但是它目前还有一些限制,只能正常打印数字,如果是字符串和地址,则无法很好展示。例如:

if (in_msg_body.slice_empty?()) { ;; ignore all empty messages ~dump("empty message"); ~dump(my_address()); ~dump(1234567); return (); }

打印的内容为:

#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 中:

body: beginCell() .storeUint(Opcodes.increase, 32) .storeUint(opts.queryID ?? 0, 64) .storeUint(opts.increaseBy, 32) .endCell(),

对应了合约中解析的顺序:

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_counterget_id 方法。

在 wrapper 中,规定所有的消息发送方法都需要以 send 开头,所有的 Get 方法都需要以 get 开头。

目前的这些方法都是框架提供的模板,如果我们在合约中添加了其它的方法,那就需要在 wrapper 中也编写对应的 sendget 方法,以供外部调用。

单元测试

最后我们来看看 FunC 的单元测试如何编写,打开 tests/Counter.spec.ts 文件,可以看到与 Tact 的单元测试内容大同小异。

beforeAll 中的使用 compile 来获取合约的编译结果:

beforeAll(async () => { code = await compile('Counter'); });

这里是实时编译,说明我们可以在单元测试之前不用每次都先 build 再 test,这里 test 的时候直接就编译了。

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 文章

总结

本文介绍了 FunC 合约的编写和一些基本概念,其中有许多内容是我自己踩了许多坑才发现的细节,希望能够帮助到大家更好的学习 FunC 合约。这篇文章介绍了单个合约的简单编写,下篇文章将介绍如何使用 FunC 来发送消息。

关于我

欢迎和我交流