安全智能合约编程
在本节中,我们将介绍 TON 区块链最有趣的几个功能,然后介绍开发人员在 FunC 上编写智能合约的最佳实践清单。
合约分片
在为 EVM 开发合约时,为了方便起见,通常会将项目拆分为多个合约。在某些情况下,可以在一份合约中实现所有功能,即使在有必要拆分合约的情况下(例如,自动做市商中的流动性对),也不会造成任何特殊困难。交易全部执行:要么全部成功,要么全部失败。
在 TON 中,强烈建议避免 "无界数据结构 (unbounded data structures)" 和将单个逻辑合约分割成小块,每个小块管理少量数据。基本的例子是 TON Jettons 的实现。这是 TON 版本的以太坊 ERC-20 代币标准。简而言之,我们有
- 一个
jetton-minter
,用于存储total_supply
、minter_address
和几个引用:令牌描述(元数据)和jetton_wallet_code
。 - 还有大量的 jetton 钱包,每个 jetton 的所有者都有一个。每个钱包只存储所有者的地址、余额、jetton-minter 地址和 jetton_wallet_code 的链接。
这样做是必要的,因为这样可以在钱包之间直接传输 Jettons,而不会影响任何高负载地址,这对并行处理交易至关重要。
也就是说,做好准备,让你的合约变成 "一组合约",而且它们之间会积极互动。
可以部分执行交易
合约逻辑中出现了一个新的独特属性:部分执行交易。
例如,考虑一下标准 TON Jetton 的信息流:
如图所示:
- 发送者会向其钱包 (
sender_wallet
)发送一条op::transfer
信息; sender_wallet
减少令牌余额;- 发送方钱包向接收方钱包(目的地钱包)发送
op::internal_transfer
消息; destination_wallet
增加其令牌余额;destination_wallet
向其所有者 (destination
)发送op::transfer_notification
;destination_wallet
在response_destination
(通常是sender
)上返回带有op::excesses
信息的多余 gas 。
请注意,如果 destination_wallet
无法处理 op::internal_transfer
消息(出现异常或 gas 耗尽),则不会执行此部分和后续步骤。但第一步(减少 sender_wallet
中的余额)将会完成。结果是部分执行了交易,Jetton
的状态不一致,在这种情况下,钱会丢失。
在最坏的情况下,所有代币都可能以这种方式被盗。试想一下,你先给用户累积奖金,然后向他们的 Jetton 钱包发送 op::burn
消息,但你不能保证 op::burn
会被成功处理。
TON 智能合约开发者必须控制 gas
在 Solidity 中,合约开发人员不太关心 gas 问题。如果用户提供的 gas 太少,一切都会恢复原状,就像什么都没发生过一样(但 gas 不会退还)。如果用户提供了足够的 gas ,实际成本将自动计算并从余额中扣除。
在 TON,情况有所不同:
- 如果没有足够的 gas ,交易将被部分执行;
- 如果 gas 过多,多余部分必须退还。这是开发商的责任;
- 如果 "一组合约" 交换信息,则必须在每条信息中进行控制和计算。
TON 无法自动计算 gas 。交易的完整执行及其所有后果可能需要很长时间,到最后,用户钱包里可能没有足够的 TON 币。这里再次使用了携带价值原则。
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();
也就是说,存储字段被局部变量遮蔽(shadowing),在函数末尾,被覆盖的值被写回了存储中。攻击者因此有机会篡改合约的状态。这一问题更为严重的是,FunC 允许变量重新声明:“这不是一次声明,而仅仅是一次编译时的类型检查,确保 min_amount
的类型为 int
。”
最后,每次调用每个函数时都要解析整个存储空间并打包回去,这也增加了 gas 成本。
小贴士
1.始终绘制信息流程图
即使是在像 TON Jetton 这样的简单合约中,也已经有相当多的消息、发送方、接收方以及消息中包含的数据块。现在想象一下,当你在开发一些更复杂的东西时,比如去中心化交易所(DEX),一个工作流中的消息数量可能会超过十条,你会怎么想?
在 CertiK,我们使用 DOT语言在审计过程中描述和更新此类图表。我们的审计人员发现,这有助于他们直观地理解合约内部和合约之间复杂的互动关系。
2.避免失败并捕捉被退回的信息
使用信息流,首先定义入口点。这是在您的合约组("后果")中启动一连串信息的信息。在这里,一切都需要检查(有效载荷、 gas 供应等),以尽量减少后续阶段出现故障的可能性。
如果您不能确定是否能完成所有计划(例如,用户是否有足够的代币来完成交易),这意味着信息流的构 建可能不正确。
在随后的信息(后果)中,所有 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 ,如果发件人没有提供足够的 gas ,那么就没有被退回的消息。
其次,TON 不提供跳转链。这意味着被跳转的信息不能再被跳转。例如,如果第二条信息是在入口报文之后发送的,而第二条信息触发了第三条信息,那么入口合约将不会知道第三条信息处理失败。同样,如果第一条信息的处理发送了第二条和第三条信息,那么第二条信息的处理失败也不会影响第三条信息的处理。