名称: multiversx-sharp-edges 描述: MultiversX 中非明显行为、陷阱和平台特定怪癖的目录,这些经常导致错误。在调试意外行为、审查代码以查找微妙问题或学习平台特定陷阱时使用。
MultiversX 陷阱指南
一个关于 MultiversX 智能合约和 dApps 中常见的非明显行为、“陷阱”和平台特定怪癖的目录。理解这些陷阱对于编写正确代码至关重要。
何时使用
- 调试意外的合约行为
- 审查代码以查找平台特定的微妙问题
- 入门 MultiversX 开发
- 检查错误是否由已知怪癖引起
- 准备安全审计
1. 异步回调和回滚
陷阱
当异步调用失败时,#[callback] 仍会执行,但原始交易的状态更改不会自动回滚。
问题
#[endpoint]
fn transfer_and_update(&self, recipient: ManagedAddress, amount: BigUint) {
// 状态更改立即发生
self.total_sent().update(|t| *t += &amount);
// 异步调用到另一个合约
self.tx()
.to(&recipient)
.egld(&amount)
.callback(self.callbacks().on_transfer())
.async_call_and_exit();
}
#[callback]
fn on_transfer(&self) {
// 如果转账失败,total_sent 仍然更新了!
// 这是不一致的状态!
}
解决方案
#[endpoint]
fn transfer_and_update(&self, recipient: ManagedAddress, amount: BigUint) {
// 不要在异步调用前更新状态
self.tx()
.to(&recipient)
.egld(&amount)
.callback(self.callbacks().on_transfer(amount.clone()))
.async_call_and_exit();
}
#[callback]
fn on_transfer(&self, amount: BigUint, #[call_result] result: ManagedAsyncCallResult<()>) {
match result {
ManagedAsyncCallResult::Ok(_) => {
// 仅在成功时更新状态
self.total_sent().update(|t| *t += &amount);
},
ManagedAsyncCallResult::Err(_) => {
// 显式处理失败
// 资金自动返回合约
}
}
}
2. Gas 限制和 Gas 耗尽(OOG)
陷阱
OOG 可能导致跨分片交易处于部分状态。
跨分片 OOG 场景
1. 发送者分片处理交易(状态已更改)
2. 接收者分片 gas 耗尽
3. 接收者执行失败
4. 发送者状态更改持续存在
5. 回调以错误触发
错误做法
// 不要:跳过为回调预留 gas — 回调中 OOG 会丢失状态
self.tx().to(&other).typed(Proxy).call()
.callback(self.callbacks().on_result())
.async_call_and_exit(); // 没有为回调预留 gas!
正确做法
// 要:始终为回调预留显式 gas
self.tx().to(&other).typed(Proxy).call()
.gas(50_000_000)
.callback(self.callbacks().on_result())
.gas_for_callback(10_000_000) // 确保回调可以执行
.async_call_and_exit();
解决方案
// 始终为回调预留足够的 gas
const CALLBACK_GAS: u64 = 10_000_000;
#[endpoint]
fn safe_cross_shard(&self) {
self.tx()
.to(&other_contract)
.typed(proxy::Proxy)
.function()
.gas(50_000_000)
.callback(self.callbacks().handle_result())
.gas_for_callback(CALLBACK_GAS)
.async_call_and_exit();
}
3. 存储映射器与 Rust 类型
陷阱
VecMapper 不是 Vec。它们有根本不同的内存模型。
问题
// VecMapper:每个元素是独立的存储槽
// 访问元素 = 1 次存储读取
// 迭代 N 个元素 = N 次存储读取
#[storage_mapper("users")]
fn users(&self) -> VecMapper<ManagedAddress>;
// 如果加载到 Vec 中,你将所有内容加载到 WASM 内存
fn bad_function(&self) {
let all_users: Vec<ManagedAddress> = self.users().iter().collect();
// 对于 10,000 用户 = 10,000 次存储读取 + 大量内存分配
// 将会 gas 耗尽
}
解决方案
// 分页操作
fn process_users_paginated(&self, start: usize, count: usize) {
let len = self.users().len();
let end = (start + count).min(len);
for i in start..end {
let user = self.users().get(i + 1); // VecMapper 是 1 索引的!
self.process_user(&user);
}
}
// 或为用例使用适当的映射器
// SetMapper 用于 O(1) 包含检查
// UnorderedSetMapper 用于高效移除
4. 代币小数精度
陷阱
ESDT 可以有 0-18 位小数。硬编码小数假设会破坏合约。
问题
// 错误:假设 18 位小数
fn convert_to_usd(&self, token_amount: BigUint) -> BigUint {
let price = self.price().get(); // 价格以 10^18 表示
&token_amount * &price / BigUint::from(10u64.pow(18)) // 假设 18 位小数!
}
解决方案
fn convert_to_usd(&self, token_amount: BigUint, token_decimals: u8) -> BigUint {
let price = self.price().get();
let decimal_factor = BigUint::from(10u64).pow(token_decimals as u32);
&token_amount * &price / &decimal_factor
}
// 或要求特定小数
fn require_standard_decimals(&self, token_id: &TokenIdentifier) {
let properties = self.blockchain().get_esdt_token_data(
&self.blockchain().get_sc_address(),
token_id,
0
);
require!(properties.decimals == 18, "代币必须有 18 位小数");
}
5. 可升级性陷阱
陷阱
#[init] 在升级时不被调用。只有 #[upgrade] 运行。
问题
// V1 合约
#[init]
fn init(&self) {
self.version().set(1);
}
// V2 合约 - 添加了新存储
#[init]
fn init(&self) {
self.version().set(2);
self.new_feature_enabled().set(true); // 在升级时从不运行!
}
// 升级后:版本仍为 1,new_feature_enabled 为空!
解决方案
#[upgrade]
fn upgrade(&self) {
// 在这里初始化新存储
self.version().set(2);
self.new_feature_enabled().set(true);
// 如果需要,迁移现有数据
self.migrate_storage();
}
存储布局更改
永远不要重新排序结构体字段:
// V1
struct UserData {
balance: BigUint, // 编码在位置 0
timestamp: u64, // 编码在位置 1
}
// V2 - 破坏现有数据
struct UserData {
timestamp: u64, // 现在在位置 0 - 读取旧的 balance 字节!
balance: BigUint, // 现在在位置 1 - 读取旧的 timestamp 字节!
new_field: bool, // 这没问题(追加)
}
6. 视图中的区块信息
陷阱
#[view] 函数中的 get_block_timestamp_millis() / get_block_timestamp_seconds() 可能返回链下与链上不同的值。自 Supernova(0.6 秒轮次)以来,更推荐使用 get_block_timestamp_millis() 和 TimestampMillis 以获取亚秒级精度 — TimestampSeconds 在轮次快于 1 秒时会丢失粒度。
问题
// 问题 - 使用秒会因 Supernova 的 0.6 秒轮次而丢失精度
#[view(isExpired)]
fn is_expired(&self) -> bool {
let deadline = self.deadline().get(); // TimestampMillis
let current_time = self.blockchain().get_block_timestamp_millis();
// 链下模拟可能返回 0 或过时值!
current_time > deadline
}
解决方案
// 选项 1:不要在视图中依赖区块信息
#[view(getDeadline)]
fn get_deadline(&self) -> TimestampMillis {
self.deadline().get()
// 让客户端与他们已知的当前时间比较
}
// 选项 2:为查询接受时间戳参数
#[view(isExpiredAt)]
fn is_expired_at(&self, check_time: TimestampMillis) -> bool {
let deadline = self.deadline().get();
check_time > deadline
}
7. VecMapper 索引
陷阱
VecMapper 是 1 索引的,不像 Rust Vec 是 0 索引的。
问题
fn get_first_user(&self) -> ManagedAddress {
self.users().get(0) // 恐慌!索引 0 不存在
}
解决方案
fn get_first_user(&self) -> ManagedAddress {
require!(!self.users().is_empty(), "没有用户");
self.users().get(1) // 第一个元素在索引 1
}
fn iterate_users(&self) {
for i in 1..=self.users().len() { // 1 到 len,包含
let user = self.users().get(i);
// 处理用户
}
}
8. EGLD + ESDT 多转账(自 v0.55.0 起更新)
陷阱
自 SDK v0.55.0 起,EGLD 和 ESDT 可以在同一多转账交易中使用统一的 Payment 类型和 TokenId 一起发送。然而,你必须使用新的统一支付 API — 旧的 .egld() + .single_esdt() 链仍然不能组合。
旧问题(不再适用)
// 这以前不可能,现在通过统一 Payment API 支持
解决方案
// 为混合转账使用带有 TokenId 的统一 Payment
let mut payments = ManagedVec::new();
if let Some(egld_nz) = egld_amount.into_non_zero() {
payments.push(Payment::new(TokenId::from("EGLD-000000"), 0, egld_nz));
}
if let Some(esdt_nz) = esdt_amount.into_non_zero() {
payments.push(Payment::new(TokenId::from(token_id), 0, esdt_nz));
}
self.tx().to(&recipient).payment(&payments).transfer();
9. MapMapper 内存模型
陷阱
MapMapper 存储 4*N + 1 个存储条目,使其非常昂贵。
错误做法
// 不要:当不需要迭代时,为每用户数据使用 MapMapper
// 对于 1000 用户,这创建 4001 个存储条目!
#[storage_mapper("balances")]
fn balances(&self) -> MapMapper<ManagedAddress, BigUint>;
正确做法
// 要:使用带有地址键的 SingleValueMapper — 每用户 1 个条目
#[storage_mapper("balance")]
fn balance(&self, user: &ManagedAddress) -> SingleValueMapper<BigUint>;
// 仅当你必须迭代所有条目时才使用 MapMapper
10. Require 与 SC Panic
陷阱
当消息是动态时,require! 生成的 WASM 比 sc_panic! 更大。
问题
// 每个唯一字符串增加 WASM 大小
require!(condition1, "错误消息一");
require!(condition2, "错误消息二");
require!(condition3, "错误消息三");
解决方案
// 使用静态错误常量
const ERR_INVALID_AMOUNT: &str = "无效金额";
const ERR_UNAUTHORIZED: &str = "未授权";
require!(amount > 0, ERR_INVALID_AMOUNT);
require!(caller == owner, ERR_UNAUTHORIZED);
// 为相同错误类型重用相同常量
require!(amount1 > 0, ERR_INVALID_AMOUNT);
require!(amount2 > 0, ERR_INVALID_AMOUNT);
11. 支付中的 NonZeroBigUint(v0.64.0+)
陷阱
Payment.amount 是 NonZeroBigUint,不是 BigUint。这意味着零金额支付在类型级别是不可能的,但在从 BigUint 值创建支付时必须处理转换。
问题
// 错误 - 不会编译,Payment 期望 NonZeroBigUint
let payment = Payment::new(token_id, 0, amount); // amount 是 BigUint
// 错误 - 如果 amount 为零,在运行时恐慌
let nz = NonZeroBigUint::new_or_panic(amount);
解决方案
// 基于选项的(安全)
if let Some(amount_nz) = amount.into_non_zero() {
let payment = Payment::new(token_id, 0, amount_nz);
self.tx().to(&to).payment(payment).transfer();
}
// 当从 call_value 读取时,amount 已经是 NonZeroBigUint
let payment = self.call_value().single();
// payment.amount 是 NonZeroBigUint — 保证非零
// 使用 .as_big_uint() 获取 &BigUint 引用用于算术
self.balance(&caller).update(|b| *b += payment.amount.as_big_uint());
关键点
你不再需要对传入支付进行 require!(amount > 0, ...) 检查 — 类型系统强制了这一点。但在从计算的 BigUint 值构建支付时仍然需要验证。
12. 反向转账累积(v0.59.0+)
陷阱
来自同步调用的反向转账在同一交易中的多个调用中累积。不重置,你会得到混合了先前调用的陈旧数据。
问题
#[endpoint]
fn multi_swap(&self, dex: ManagedAddress) {
// 第一次交换
let bt1 = self.tx().to(&dex).typed(DexProxy)
.swap_a()
.returns(ReturnsBackTransfers) // 没有重置!
.sync_call();
// 第二次交换
let bt2 = self.tx().to(&dex).typed(DexProxy)
.swap_b()
.returns(ReturnsBackTransfers) // 没有重置!
.sync_call();
// BUG:bt2 包含来自 swap_a 和 swap_b 的支付
let total = bt2.into_payment_vec(); // 错误金额!
}
解决方案
#[endpoint]
fn multi_swap(&self, dex: ManagedAddress) {
let bt1 = self.tx().to(&dex).typed(DexProxy)
.swap_a()
.returns(ReturnsBackTransfersReset) // 读取前重置
.sync_call();
let bt2 = self.tx().to(&dex).typed(DexProxy)
.swap_b()
.returns(ReturnsBackTransfersReset) // 读取前重置
.sync_call();
// bt1 和 bt2 各自仅包含它们自己调用的支付
}
关键点
当一个端点进行多个返回代币的同步调用时,始终使用 ReturnsBackTransfersReset 而不是 ReturnsBackTransfers。Reset 变体在调用前调用 blockchain().reset_back_transfers(),清除累积器。
13. 待处理回调跟踪
陷阱
异步调用可能无声失败 — 如果目标合约在执行期间 gas 耗尽或恐慌,回调可能永远不会触发。没有跟踪,你的合约永远不会知道操作未完成。
问题
#[endpoint]
fn delegate_to_provider(&self, provider: ManagedAddress, amount: BigUint) {
self.pending_amount().update(|p| *p += &amount);
self.tx().to(&provider)
.typed(ProviderProxy).delegate()
.egld(&amount)
.callback(self.callbacks().on_delegate())
.async_call_and_exit();
// 如果回调永不触发,pending_amount 永远卡住
}
解决方案
#[endpoint]
fn delegate_to_provider(&self, provider: ManagedAddress, amount: BigUint) {
// 用唯一 ID 跟踪待处理操作
let op_id = self.next_op_id().update(|id| { *id += 1; *id });
self.pending_operations(op_id).set(PendingOp {
provider: provider.clone(),
amount: amount.clone(),
timestamp: self.blockchain().get_block_timestamp_millis(),
});
self.tx().to(&provider)
.typed(ProviderProxy).delegate()
.egld(&amount)
.callback(self.callbacks().on_delegate(op_id))
.async_call_and_exit();
}
#[callback]
fn on_delegate(&self, op_id: u64, #[call_result] result: ManagedAsyncCallResult<()>) {
self.pending_operations(op_id).clear(); // 始终清除跟踪
match result {
ManagedAsyncCallResult::Ok(_) => { /* 成功 */ }
ManagedAsyncCallResult::Err(_) => { /* 处理失败,退款等 */ }
}
}
// 管理员恢复卡住操作
#[endpoint(recoverPending)]
fn recover_pending(&self, op_id: u64) {
require!(self.blockchain().get_caller() == self.blockchain().get_owner_address(), "不是所有者");
let op = self.pending_operations(op_id).get();
let now = self.blockchain().get_block_timestamp_millis();
require!(now - op.timestamp > RECOVERY_TIMEOUT_MS, "太早恢复");
self.pending_operations(op_id).clear();
// 退款或重试逻辑
}
14. 使用 storage_mapper_from_address 的存储键冲突
陷阱
当使用 #[storage_mapper_from_address("key")] 读取另一个合约的存储时,如果目标合约升级并重命名其存储键,你的读取将无声返回默认值(零,空)— 没有错误。
问题
// 你的合约从 DEX 对读取 "reserve" 键
#[storage_mapper_from_address("reserve")]
fn pair_reserve(&self, addr: ManagedAddress, token: &TokenIdentifier)
-> SingleValueMapper<BigUint, ManagedAddress>;
// DEX 升级并将 "reserve" 重命名为 "token_reserve"
// 你的读取现在返回 0 — 无声错误数据!
解决方案
- 在文档中固定到特定合约版本
- 添加合理性检查:如果活跃对的储备返回 0,某些地方出错了
- 考虑添加陈旧性检查或回退到代理调用
fn get_pair_reserve_safe(&self, pair: &ManagedAddress, token: &TokenIdentifier) -> BigUint {
let reserve = self.pair_reserve(pair.clone(), token).get();
// 合理性检查 — 活跃对永远不应有零储备
if reserve == 0u64 {
// 回退:使用代理调用或回滚
sc_panic!("意外的零储备 — 目标合约可能已更改存储布局");
}
reserve
}
15. 异步边界处的缓存失效
陷阱
async_call_and_exit() 立即终止执行。同一作用域中基于 Drop 的缓存将永远不会调用其 drop() — 缓存写入无声丢失。
问题
fn bad_pattern(&self) {
let mut cache = StorageCache::new(self);
cache.balance += &deposit_amount;
// async_call_and_exit() 终止执行 — drop() 从不运行!
self.tx().to(&other).typed(Proxy).call()
.callback(self.callbacks().on_result())
.async_call_and_exit();
// cache.drop() 从不触发 — 余额更改丢失
}
解决方案
在异步调用前手动丢弃缓存(通过作用域):
fn good_pattern(&self) {
{
let mut cache = StorageCache::new(self);
cache.balance += &deposit_amount;
} // cache.drop() 在这里触发 — 写入提交
self.tx().to(&other).typed(Proxy).call()
.callback(self.callbacks().on_result())
.async_call_and_exit();
}
注意: 异步调用前的状态提交即使异步调用失败也会持续存在。如果需要回滚,跟踪待处理操作(见第 13 项)。
16. 金融计算中的舍入攻击向量
陷阱
默认 ManagedDecimal 算术截断(向零舍入)。在借贷/质押协议中,这创建系统性的价值泄漏,攻击者可以通过许多小操作利用。
问题
// 每次存款因截断而损失一小部分代币
// 攻击者进行 1000 次微小存款,每次提取舍入差异
let shares = (amount * total_shares) / total_supply; // 截断!
解决方案
为所有金融计算使用四舍五入。实现见 multiversx-defi-math 技能。
快速参考:常见陷阱
| 问题 | 错误 | 正确 |
|---|---|---|
| VecMapper 索引 | .get(0) |
.get(1) |
| 回调状态 | 异步前更新 | 回调中成功时更新 |
| 升级初始化 | 依赖 #[init] |
使用 #[upgrade] |
| 小数 | 硬编码 10^18 |
从代币属性获取 |
| MapMapper | 用于每用户数据 | 使用带有键的 SingleValueMapper |
| 视图中的区块信息 | 直接使用 | 作为参数传递 |
| EGLD + ESDT | 旧:同一交易不可能 | 在多转账中使用统一的 Payment 和 TokenId |
| NonZeroBigUint | Payment::new(id, 0, big_uint) |
amount.into_non_zero() 然后 Payment::new(...) |
| 结构体字段 | 重新排序 | 仅追加 |
| 反向转账 | 多调用时使用 ReturnsBackTransfers |
ReturnsBackTransfersReset — 重置累积器 |
| 待处理回调 | 发后不理的异步 | 用操作 ID + 恢复端点跟踪 |
| 跨合约存储键 | 假设键从不更改 | 合理性检查 + 版本固定 |
| 缓存 + 异步 | 异步调用前丢弃缓存 | 仅在回调中手动提交 |
| 金融舍入 | 默认截断 | 四舍五入(mul_half_up/div_half_up) |