MultiversX智能合约开发陷阱与解决方案Skill multiversx-sharp-edges

这个技能是一个针对 MultiversX 区块链平台的开发指南,详细列出了常见的非明显行为、陷阱和平台特定问题及其解决方案,帮助开发者在编写智能合约和 dApps 时避免错误。关键词包括 MultiversX, 智能合约, 陷阱, 开发指南, 区块链开发, 调试技巧, 异步回调, gas 限制, 存储映射。

智能合约 0 次安装 0 次浏览 更新于 3/21/2026

名称: 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 索引

陷阱

VecMapper1 索引的,不像 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.amountNonZeroBigUint,不是 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 而不是 ReturnsBackTransfersReset 变体在调用前调用 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 旧:同一交易不可能 在多转账中使用统一的 PaymentTokenId
NonZeroBigUint Payment::new(id, 0, big_uint) amount.into_non_zero() 然后 Payment::new(...)
结构体字段 重新排序 仅追加
反向转账 多调用时使用 ReturnsBackTransfers ReturnsBackTransfersReset — 重置累积器
待处理回调 发后不理的异步 用操作 ID + 恢复端点跟踪
跨合约存储键 假设键从不更改 合理性检查 + 版本固定
缓存 + 异步 异步调用前丢弃缓存 仅在回调中手动提交
金融舍入 默认截断 四舍五入(mul_half_up/div_half_up)