TON 是一个创新的区块链,在智能合约设计中融入了新颖的概念。在以太坊出现几年后,TON 的构建者具有能够评估以太坊虚拟机 (EVM) 模型的成功和失败的优势。有关更多信息,请查看TON 区块链的六个独特方面,这些方面将使 Solidity 开发人员感到惊讶。
在本文中,我们将介绍 TON 区块链的几个最有趣的功能,然后介绍开发人员在 FunC 中编写智能合约的最佳实践列表。
合约分片
在为 EVM 开发合约时,为了方便起见,通常会将项目分解为多个合约。在某些情况下,可以在一份合约中实现所有功能,即使需要拆分合约(例如,自动做市商中的流动性对),这也不会导致任何特殊困难。交易是完整执行的:要么一切顺利,要么一切恢复。
在 TON 中,强烈建议避免“无界数据结构”,将单个逻辑合约拆分成小块,每个小块管理少量数据。基本示例是 TON Jettons 的实施。这是以太坊 ERC-20 代币标准的 TON 版本。简而言之,我们有:
- 一个
jetton-minter
存储total_supply
、minter_address
和一些参考:令牌描述(元数据)和jetton_wallet_code
。 - 还有很多
jetton-wallet
,每个喷射船的主人都有一个。每个此类钱包仅存储所有者的地址、余额、jetton-minter
地址以及jetton_wallet_code
。
这是必要的,这样 Jetton 的转移可以直接在钱包之间进行,并且不会影响任何高负载地址,这对于并行处理交易至关重要。
也就是说,准备好让你的合约变成一个「合约组」,并且它们会积极地相互交互。
由此得出什么结论呢?
可以部分执行交易
合约逻辑中出现了一个新的独特属性:交易的部分执行。
例如,考虑标准 TON Jetton 的消息流:
从图中可以看出:
op::transfer
发送者向其钱包发送消息 (sender_wallet
)sender_wallet
减少代币余额sender_walletop::internal_transfer
向收件人的钱包发送消息 (destination_wallet
)destination_wallet
增加其代币余额destination_wallet
发送op::transfer_notification
给其所有者 (destination
)destination_wallet
返回多余的 gas 并op::excesses
显示消息response_destination
(通常sender
)
请注意,如果destination_wallet
无法处理op::internal_transfer
消息(发生异常或气体耗尽),则这部分和后续步骤将不会执行。但第一步(减少 中的余额sender_wallet
)将会完成。结果是交易的部分执行、不一致的状态Jetton
,以及在这种情况下的金钱损失。
在最坏的情况下,所有代币都可以通过这种方式被盗。想象一下,您首先为用户累积奖金,然后op::burn
向他们的 Jetton 钱包发送消息,但您不能保证消息op::burn
会成功处理。
技巧#1:始终绘制消息流程图
即使在像 TON Jetton 这样的简单合约中,也已经有相当多的消息、发送者、接收者以及消息中包含的数据片段。现在想象一下,当您开发一些更复杂的东西时,例如去中心化交易所(DEX),其中一个工作流程中的消息数量可能超过十条,情况会是什么样子。
在 CertiK,我们在审核过程中使用DOT语言来描述和更新此类图表。我们的审计员发现,这有助于他们可视化和理解合同内部和合同之间的复杂交互。
提示 #2:避免失败并捕获退回的消息
使用消息流,首先定义入口点。这是在您的合约组中启动消息级联的消息(“后果”)。正是在这里,需要检查一切(有效载荷、气体供应等),以尽量减少后续阶段发生故障的可能性。
如果您不确定是否可以完成您的所有计划(例如,用户是否有足够的代币来完成交易),则意味着消息流可能构建不正确。
在后续消息(后果)中,all throw_if()
/throw_unless()
将扮演断言的角色,而不是实际检查某些内容。
许多合约还会处理退回的消息,以防万一。
例如,在TON Jetton中,如果接收者的钱包无法接受任何代币(这取决于接收逻辑),那么发送者的钱包将处理退回的消息并将代币返回到自己的余额中。
() on_bounce (slice in_msg_body) impure {
in_msg_body~skip_bits(32); ;;0xFFFFFFFF
(int balance, slice owner_address, slice jetton_master_address, cell jetton_wallet_code) = load_data();
int op = in_msg_body~load_op();
throw_unless(error::unknown_op, (op == op::internal_transfer) | (op == op::burn_notification));
int query_id = in_msg_body~load_query_id();
int jetton_amount = in_msg_body~load_coins();
balance += jetton_amount;
save_data(balance, owner_address, jetton_master_address, jetton_wallet_code);
}
一般来说,我们建议处理退回的消息,但是,它们不能用作全面保护消息处理失败和执行不完整的手段。
发送退回的消息并对其进行处理需要消耗gas,如果发件人提供的气体不足,则不会退回。
其次,TON不提供跳转链。这意味着退回的邮件无法重新退回。例如,如果在入口消息之后发送第二条消息,并且第二条消息触发了第三条消息,那么入口合约将不会意识到处理第三条消息失败。同样,如果第一个的处理发送了第二个和第三个,那么第二个的失败不会影响第三个的处理。
提示 #3:消息流中存在中间人
消息级联可以在多个块上进行处理。假设当一个消息流正在运行时,攻击者可以并行启动第二个消息流。也就是说,如果一开始就检查了某个属性(例如用户是否有足够的代币),则不要假设在同一合约的第三阶段他们仍然会满足该属性。
建议#4:使用携带价值模式
从上一段可以看出,合约之间的消息必须携带贵重物品。
在同一个 TON Jetton 中,这得到了演示:sender_wallet
减去余额并将其与op::internal_transfer
消息一起发送到destination_wallet
,而 反过来,它接收带有消息的余额并将其添加到自己的余额中(或将其退回)。
这是一个错误实施的例子。为什么你查不到你的链上 Jetton 余额?因为这样的问题不符合题型。当对消息的响应op::get_balance
到达请求者时,这笔余额可能已经被某人花掉了。
在这种情况下,您可以实施替代方案:
op::provide_balance
master向钱包发送消息- 钱包将其余额归零并发回
op::take_balance
- 主人收到钱,决定是否有足够的钱,然后使用它(借记一些回报)或将其发送回钱包
技巧#5:返回值而不是拒绝
根据之前的观察,您的合约组通常不仅会收到请求,还会收到带有值的请求。因此,您不能只是拒绝执行请求(通过throw_unless()
),您必须将 Jettons 发送回发送者。
例如,典型的流程启动(请参阅 TON Jetton 消息流程):
op::transfer
发送者通过sender_wallet
to发送一条消息your_contract_wallet
,指定forward_ton_amount
和forward_payload
为您的合同。sender_wallet
发送op::internal_transfer
消息至your_contract_wallet
。your_contract_wallet
发送op::transfer_notification
消息至your_contract
、传递forward_ton_amount
、forward_payload
以及sender_address
和jetton_amount
。- 流程从这里
handle_transfer_notification()
开始。
在那里,您需要弄清楚它是什么类型的请求,是否有足够的气体来完成它,以及有效负载中的所有内容是否正确。在此阶段,您不应该使用throw_if()
/ throw_unless()
,因为这样 Jetton 就会丢失,并且请求将不会被执行。值得使用从 FunC v0.4.0 开始提供的try-catch 语句。
如果某些东西不符合您的合同期望,则必须归还杰顿。
我们在最近的一次审计中发现了这种存在漏洞的实施示例。
() handle_transfer_notification(...) impure {
...
int jetton_amount = in_msg_body~load_coins();
slice from_address = in_msg_body~load_msg_addr();
slice from_jetton_address = in_msg_body~load_msg_addr();
if (msg_value < gas_consumption) { ;; not enough gas provided
if (equal_slices(from_jetton_address, jettonA_address)) {
var msg = begin_cell()
.store_uint(0x18, 6)
.store_slice(jettonA_wallet_address)
.store_coins(0)
.store_uint(1, 1 + 4 + 4 + 64 + 32 + 1 + 1)
...
}
...
}
正如您所看到的,这里“返回”被发送到jettonA_wallet_address
,而不是sender_address
。由于所有决策都是基于 的分析做出的in_msg_body
,因此攻击者可以伪造虚假消息并提取资金。始终将退货发送至sender_address
。
如果您的合同接受 Jetton,则无法知道它是否确实是预期的 Jetton,或者只是来自op::transfer_notification
某人。
如果您的合同收到意外或未知的 Jettons,则也必须将其退回。
TON 智能合约开发者必须控制 Gas
在 Solidity 中,gas 并不是合约开发者关心的问题。如果用户提供的gas太少,一切都会恢复原样,就像什么都没发生一样(但gas不会被返还)。如果他们提供足够的资金,实际成本将自动计算并从他们的余额中扣除。
在 TON 中,情况有所不同:
- 如果没有足够的gas,交易将被部分执行。
- 如果气体过多,则必须退回多余的气体。这是开发商的责任。
- 如果“一组合约”交换消息,则必须在每条消息中进行控制和计算。
TON 无法自动计算气体。交易的完整执行及其所有后果可能需要很长时间,到最后,用户的钱包中可能没有足够的 toncoin。这里再次使用了套利价值原则。
技巧#6:计算 Gas 并检查 msg_value
根据我们的消息流图,我们可以估计每个场景中每个处理程序的成本,并插入对 msg_value 充足性的检查。
你不能只要求一定的保证金,比如 1 TON(截至撰写本文之日主网上的 Gas_limit),因为这些 Gas 必须在“后果”之间分配。假设您的合约发送了 3 条消息,那么您只能向每条消息发送 0.33 TON。这意味着他们应该减少“要求”。仔细计算整个合约的 Gas 需求非常重要。
如果在开发过程中您的代码开始发送更多消息,事情就会变得更加复杂。气体需求需要重新检查和更新。
提示#7:小心回收过量 Gas
如果多余的 Gas 没有退还给发送者,资金将随着时间的推移在您的合约中累积。原则上,没什么可怕的,这只是次优的做法。您可以添加一个函数来剔除多余的内容,但像 TON Jetton 这样的流行合约仍然会通过消息 op::excesses 返回给发送者。
TON 有一个有用的机制:SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE = 64
。当在 中使用此模式时send_raw_message()
,其余的 gas 将与消息一起进一步转发(或返回)给新的接收者。如果消息流是线性的,那就很方便:每个消息处理程序只发送一条消息。但有些情况下不建议使用此机制:
- 如果您的合约中没有其他非线性处理程序。storage_fee是从合约余额中扣除的,而不是从传入的gas中扣除的。这意味着随着时间的推移,storage_fee 可能会耗尽全部余额,因为所有进来的东西都必须出去。
- 如果您的合约发出事件,即向外部地址发送消息。此操作的成本从合约余额中扣除,而不是从 msg_value 中扣除。
() emit_log_simple (int event_id, int query_id) impure inline {
var msg = begin_cell()
.store_uint (12, 4) ;; ext_out_msg_info$11 addr$00
.store_uint (1, 2) ;; addr_extern$01
.store_uint (256, 9) ;; len:(## 9)
.store_uint(event_id, 256); ;; external_address:(bits len)
.store_uint(0, 64 + 32 + 1 + 1) ;; lt, at, init, body
.store_query_id(query_id)
.end_cell();
send_raw_message(msg, SEND_MODE_REGULAR);
}
- 如果您的合同在发送消息或使用时附加价值
SEND_MODE_PAY_FEES_SEPARETELY = 1
。这些行为会从合同余额中扣除,这意味着退回未使用的就是「亏本工作」。
在所示情况下,使用手动近似计算盈余:
int ton_balance_before_msg = my_ton_balance - msg_value;
int storage_fee = const::min_tons_for_storage - min(ton_balance_before_msg, const::min_tons_for_storage);
msg_value -= storage_fee + const::gas_consumption;
if(forward_ton_amount) {
msg_value -= (forward_ton_amount + fwd_fee);
...
}
if (msg_value > 0) { ;; there is still something to return
var msg = begin_cell()
.store_uint(0x10, 6)
.store_slice(response_address)
.store_coins(msg_value)
...
请记住,如果合约余额的价值用完,交易将被部分执行,这是不允许的。
TON 智能合约开发人员必须管理存储
TON 中的典型消息处理程序遵循以下方法:
() handle_something(...) impure {
(int total_supply, <a lot of vars>) = load_data();
... ;; do something, change data
save_data(total_supply, <a lot of vars>);
}
不幸的是,我们注意到一个趋势:<a lot of vars>
是所有合约数据字段的真实枚举。例如,
(
int total_supply, int swap_fee, int min_amount, int is_stopped, int user_count, int max_user_count,
slice admin_address, slice router_address, slice jettonA_address, slice jettonA_wallet_address,
int jettonA_balance, int jettonA_pending_balance, slice jettonB_address, slice jettonB_wallet_address,
int jettonB_balance, int jettonB_pending_balance, int mining_amount, int datetime_amount, int minable_time,
int half_life, int last_index, int last_mined, cell mining_rate_cell, cell user_info_dict, cell operation_gas,
cell content, cell lp_wallet_code
) = load_data();
这种方法有许多缺点。
首先,如果您决定添加另一个字段,例如is_paused
,那么您需要更新整个合约中的load_data()
/语句。save_data()
这不仅是劳动密集型的,而且还会导致难以发现的错误。
在最近的 CertiK 审计中,我们注意到开发人员在某些地方混淆了两个参数,并写道:
save_data(total_supply, min_amount, swap_fee, ...
如果没有专家团队进行的外部审计,发现这样的错误是非常困难的。该函数很少使用,并且两个混淆参数的值通常为零。您确实必须知道要查找的内容才能发现此类错误。
其次,存在“命名空间污染”。让我们用审计中的另一个例子来解释问题所在。在函数中间,输入参数为:
int min_amount = in_msg_body~load_coins();
也就是说,局部变量对存储字段进行了遮蔽,并且在函数结束时,该替换值被存储在存储中。攻击者有机会覆盖合约的状态。FunC 允许重新声明变量这一事实使情况更加恶化:「这不是声明,而只是 min_amount 类型为 int 的编译时保险。」
最后,解析整个存储并在每次调用每个函数时将其打包会增加 gas 成本。
技巧 #8:使用嵌套存储
我们推荐以下存储组织方法:
() handle_something(...) impure {
(slice swap_data, cell liquidity_data, cell mining_data, cell discovery_data) = load_data();
(int total_supply, int swap_fee, int min_amount, int is_stopped) = swap_data.parse_swap_data();
…
swap_data = pack_swap_data(total_supply + lp_amount, swap_fee, min_amount, is_stopped);
save_data(swap_data, liquidity_data, mining_data, discovery_data);
}
存储由相关数据块组成。如果每个函数中都使用了参数,例如 ,is_paused
则立即由 提供load_data()
。如果一个参数组只在一种场景下需要,那么它就不需要解包,也不需要打包,也不会堵塞命名空间。
如果存储结构需要更改(通常添加新字段),则需要进行的编辑就会少得多。
此外,该方法可以重复。如果我们的合约中有 30 个存储字段,那么最初您可以得到四个组,然后从第一组中得到几个变量和另一个子组。最主要的是不要做得太过分。
请注意,由于一个单元格最多可以存储1023位数据和最多 4 个引用,因此您无论如何都必须将数据拆分到不同的单元格中。
分层数据是 TON 的主要功能之一,让我们将其用于其预期目的。
可以使用全局变量,特别是在原型设计阶段,此时合约中存储的内容并不完全明显。
global int var1;
global cell var2;
global slice var3;
() load_data() impure {
var cs = get_data().begin_parse();
var1 = cs~load_coins();
var2 = cs~load_ref();
var3 = cs~load_bits(512);
}
() save_data() impure {
set_data(
begin_cell()
.store_coins(var1)
.store_ref(var2)
.store_bits(var3)
.end_cell()
);
}
这样,如果您发现需要另一个变量,只需添加一个新的全局变量并修改load_data()
和save_data()
。整个合同无需更改。不过,由于全局变量的数量有限制(不超过31个),因此这种模式可以与上面推荐的“嵌套存储”结合起来。
全局变量通常也比将所有内容存储在堆栈上更昂贵。然而,这取决于堆栈排列的数量,因此最好使用全局变量进行原型设计,并且当存储结构完全清晰时,切换到具有“嵌套”模式的堆栈变量。
技巧 #9:使用 end_parse()
end_parse()
从存储和消息负载中读取数据时,请尽可能使用。由于 TON 使用具有可变数据格式的比特流,因此确保您读取的内容与写入的内容一样多是很有帮助的。这可以节省您一个小时的调试时间。
提示 #10:使用更多辅助函数,并避免使用幻数
这个技巧并不是 FunC 独有的,但在这里特别重要。编写更多包装器和辅助函数,并声明更多常量。
FunC 最初拥有数量惊人的魔法数字。如果开发人员不采取任何措施限制其使用,结果将是这样的:
var msg = begin_cell()
.store_uint(0xc4ff, 17) ;; 0 11000100 0xff
.store_uint(config_addr, 256)
.store_grams(1 << 30) ;; ~1 gram of value
.store_uint(0, 107)
.store_uint(0x4e565354, 32)
.store_uint(query_id, 64)
.store_ref(vset);
send_raw_message(msg.end_cell(), 1);
这是来自真实项目的代码,它会吓跑新手。
幸运的是,在 FunC 的最新版本中,一些标准声明可以使代码更清晰、更具表现力。例如:
const int SEND_MODE_REGULAR = 0;
const int SEND_MODE_PAY_FEES_SEPARETELY = 1;
const int SEND_MODE_IGNORE_ERRORS = 2;
const int SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE = 64;
builder store_msgbody_prefix_stateinit(builder b) inline {
return b.store_uint(4 + 2 + 1, 1 + 4 + 4 + 64 + 32 + 1 + 1 + 1);
}
builder store_body_header(builder b, int op, int query_id) inline {
return b.store_uint(op, 32).store_uint(query_id, 64);
}
() mint_tokens(slice to_address, cell jetton_wallet_code, int amount, cell master_msg) impure {
cell state_init = calculate_jetton_wallet_state_init(to_address, my_address(), jetton_wallet_code);
slice to_wallet_address = calculate_address_by_state_init(state_init);
var msg = begin_cell()
.store_msg_flags(BOUNCEABLE)
.store_slice(to_wallet_address)
.store_coins(amount)
.store_msgbody_prefix_stateinit()
.store_ref(state_init)
.store_ref(master_msg);
send_raw_message(msg.end_cell(), SEND_MODE_REGULAR);
}
我们喜欢富有表现力的代码!
结论
在本文中,我们介绍了开发人员在使用 FunC 在 TON 上编写智能合约时会遇到的一些独特功能。我们还提供了我们在审核过程中学到的 10 条提示,这些提示将使您的体验尽可能顺利。
我们很高兴能够支持 TON 生态系统,并与在这个全新的创新区块链平台上构建的许多伟大项目合作。
本站所提供的所有资讯均仅供读者参考。这些资讯不代表任何投资建议、提供、邀请或推荐。读者在使用这些资讯时,应当考虑自己的个人需求、投资目标和财务状况。所有投资都伴随着一定的风险,在做出任何投资决策之前请多加留意。