安全智能合约编程
在本节中,我们将介绍 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 成本。