Try   HackMD

本文我们来学习 TON 合约升级,TON 的合约升级相比于 Solidity 的合约升级会简单很多。它不需要类似代理合约之类的东西,合约自身支持通过 set_code 方法将新的代码逻辑直接设置到当前地址。在 set_code 之后,合约地址不变,代码逻辑更新。

在前面几篇文章学习了 FunC 之后,我们的重点将会放在 FunC 上,因此本文我们会介绍 FunC 的合约升级。Tact 目前还不支持原生升级方案,需要结合 FunC 的 Native Function 来实现,这部分内容读者感兴趣的话可以自行学习理解。

合约升级实践

首先通过 npm create ton@latest 创建一个新的项目,第一个合约命名为 ContractV1,其内存变量布局如下:

  • uint32

即只有一个 uint32 类型的变量。

合约内容如下:

#include "imports/stdlib.fc"; int load_data() inline { slice ds = get_data().begin_parse(); var data = ds~load_uint(32); ds.end_parse(); return data; } () recv_internal(int my_balance, int msg_value, cell in_msg_full, slice in_msg_body) impure { if (in_msg_body.slice_empty?()) { ;; ignore all empty messages return (); } slice in_msg_full_slice = in_msg_full.begin_parse(); int msg_flags = in_msg_full_slice~load_msg_flags(); if (msg_flags & 1) { return (); } (int op, int query_id) = in_msg_body~load_op_and_query_id(); if (op == 123) { (cell new_data, cell new_code) = (in_msg_body~load_ref(), in_msg_body~load_ref()); in_msg_body.end_parse(); set_data(new_data); set_code(new_code); return (); } } int data() method_id { return load_data(); }

其中我们重点来看 opcode = 123 这部分,这里的 123 是自定义的 opcode,你可以定义成你喜欢的数字。这部分中,从 in_msg_body 中解析出了两部分数据,分别是 new_datanew_code,分别代表新的数据和新的代码。这说明,在更新合约代码时,数据也可以同时更新,并且由于 TON 合约 set_data 方法的特性,合约中之前的所有数据都可以在此刻被消除,然后保存全新的数据,这相比于 Solidity 的合约升级更加灵活。

对应于合约的内存结构,wrapper 中的合约初始化数据部分应如下:

export type ContractV1Config = { data: number, }; export function contractV1ConfigToCell(config: ContractV1Config): Cell { return beginCell().storeUint(config.data, 32).endCell(); }

随后编写合约升级的消息发送部分:

async sendUpgrade( provider: ContractProvider, via: Sender, new_code: Cell, new_data: Cell, value: bigint = toNano('0.1'), query_id: bigint | number = 0 ) { await provider.internal(via, { sendMode: SendMode.PAY_GAS_SEPARATELY, body: ContractV1.upgradeMessage(new_code, new_data, query_id), value }); } static upgradeMessage(new_code: Cell, new_data: Cell, query_id: bigint | number = 0) { return beginCell().storeUint(123, 32).storeUint(query_id, 64) .storeRef(new_data) .storeRef(new_code) .endCell(); }

upgradeMessage 中构造了 opcode = 123 对应的消息体,这里注意 new_datanew_code 的顺序不要搞反。

v1 -> v2

现在我们来对该合约进行升级,新创建一个合约命名为 ContractV2,内容如下:

#include "imports/stdlib.fc"; (int, int, slice, int) load_data() inline { slice ds = get_data().begin_parse(); var data = (ds~load_uint(32), ds~load_uint(32), ds~load_msg_addr(), ds~load_uint(32)); ds.end_parse(); return data; } () recv_internal(int my_balance, int msg_value, cell in_msg_full, slice in_msg_body) impure { if (in_msg_body.slice_empty?()) { ;; ignore all empty messages return (); } slice in_msg_full_slice = in_msg_full.begin_parse(); int msg_flags = in_msg_full_slice~load_msg_flags(); if (msg_flags & 1) { return (); } (int op, int query_id) = in_msg_body~load_op_and_query_id(); if (op == 123) { ;; throw_unless(error::not_owner, equal_slices_bits(sender_address, admin_address)); (cell new_data, cell new_code) = (in_msg_body~load_ref(), in_msg_body~load_ref()); in_msg_body.end_parse(); set_data(new_data); set_code(new_code); return (); } } int data() method_id { (int a, int b, slice c, int d) = load_data(); return a + b + d; }

其中内存布局变为了:

  • uint32
  • uint32
  • address
  • uint32

同时,新合约的 load_datadata() 方法都升级成了新的逻辑。

data() 方法的返回值变更为了 a + b + d

然后在单元测试中,构造出 new_data 的结构体:

const v2Data = beginCell() .storeUint(123, 32) .storeUint(13, 32) .storeAddress(deployer.address) .storeUint(4, 32) .endCell();

接着发送升级消息进行合约升级:

const upgrade = await contractV1.sendUpgrade( deployer.getSender(), v2_code, v2Data, toNano('0.05') );

在升级完成后,由于地址不变,因此我们可以通过 v1 的合约地址获取 v2 的合约实例:

contractV2 = blockchain.openContract(ContractV2.createFromAddress(v1Address)); const data2 = await contractV2.getData(); console.log(data2);

随后打印出升级后的 data,结果是 140,即 123 + 13 + 4,说明数据和逻辑都已经更新成功。

v2 -> v3

我们来再更新一次合约,这次的目的是希望减少合约中的内存变量。v3 合约如下:

#include "imports/stdlib.fc";

(int, slice) load_data() inline {
    slice ds = get_data().begin_parse();
    var data = (ds~load_uint(32), ds~load_msg_addr());
    ds.end_parse();
    return data;
}

() recv_internal(int my_balance, int msg_value, cell in_msg_full, slice in_msg_body) impure {
    if (in_msg_body.slice_empty?()) { ;; ignore all empty messages
        return ();
    }
    slice in_msg_full_slice = in_msg_full.begin_parse();
    int msg_flags = in_msg_full_slice~load_msg_flags();

    if (msg_flags & 1) {
        return ();
    }

    (int op, int query_id) = in_msg_body~load_op_and_query_id();

    if (op == 123) {
        (cell new_data, cell new_code) = (in_msg_body~load_ref(), in_msg_body~load_ref());
        in_msg_body.end_parse();
        set_data(new_data);
        set_code(new_code);
        return ();
    }
}

int data() method_id {
    (int a, slice b) = load_data();
    return a;
}

load_data 方法可知,v3 合约的内存布局如下:

  • uint32
  • address

其中 ds.end_parse(); 语句表示指针已经读取到内存的最后一块,并且后续没有内容了。这方便我们确认此次更新是完全改写了 v2 的内存,而不是只覆盖了 v2 的前两个内存字段。

模仿前面 v1 -> v2 的升级逻辑,我们可以很快速的写出 v2 -> v3 的升级逻辑:

const v3Data = beginCell()
    .storeUint(413, 32)
    .storeAddress(deployer.address)
.endCell();

const upgrade2 = await contractV2.sendUpgrade(
    deployer.getSender(),
    v3_code,
    v3Data,
    toNano('0.05')
);

contractV3 = blockchain.openContract(
    ContractV3.createFromAddress(v1Address)
);
const data3 = await contractV3.getData();
console.log(data3);

运行即可打印出 413,并且没有报错。这说明 ds.end_parse() 校验成功,即 v3 的内存布局已经完全覆写了 v2 的内存。

合约地址的思考

我们知道,TON 的合约地址是由合约代码和初始化状态决定的,一旦这两者确定,那么合约的地址就已经确定。但是在合约升级这部分,似乎又有了矛盾。

在我们的这几个例子中,v1 -> v2 -> v3,合约的地址都没有变化,最初的地址是由 v1 合约的代码和初始化状态决定的,但是合约更新之后,地址没有变化(如果变了也就不叫合约升级了)。这就与我们之前的理解有所矛盾,这里的深层原因我还没有搞明白,如果有读者感兴趣,欢迎共同交流学习。

总结

本文我们简单介绍了 TON 的合约升级方法,相比于 Solidity,它简单许多,但也有更大的风险点,例如合约数据可以随意改写,一个小失误就能造成合约的数据全部丢失并被覆盖。同时合约地址的不变性也与之前所学习的概念有所矛盾。

总之,我们在写合约时,逻辑的完整性以及安全性仍然是第一位的,不能完全依赖于合约的可升级特性。一般来说可升级特性都是在极端情况下为我们提供一个备选解决方案。对于升级的权限也要严格管理,本文的例子为了展示方便,没有进行权限管理。如果在实际业务场景下一定要控制好升级的权限,避免资产丢失。

代码

本文所有的代码都在这里

关于我

欢迎和我交流