multiversx-sharp-edges

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

MultiversX Sharp Edges

MultiversX 开发易踩陷阱

A catalog of non-obvious behaviors, "gotchas," and platform-specific quirks that frequently lead to bugs in MultiversX smart contracts and dApps. Understanding these sharp edges is essential for writing correct code.
本文汇总了MultiversX智能合约和dApp开发中,那些常引发bug的非直观行为、“陷阱”以及平台特有特性。了解这些易踩陷阱是编写正确代码的关键。

When to Use

适用场景

  • Debugging unexpected contract behavior
  • Reviewing code for subtle platform-specific issues
  • Onboarding to MultiversX development
  • Checking if a bug might be caused by a known quirk
  • Preparing for security audits
  • 调试合约的异常行为
  • 检查代码中平台特有的潜在问题
  • 快速上手MultiversX开发
  • 排查bug是否由已知的平台特性导致
  • 为安全审计做准备

1. Async Callbacks & Reverts

1. 异步回调与回滚

The Sharp Edge

易踩陷阱

When an async call fails, the
#[callback]
is still executed, but state changes from the original transaction are NOT automatically reverted.
当异步调用失败时,
#[callback]
仍会执行,但原交易的状态变更不会自动回滚

The Problem

问题示例

rust
#[endpoint]
fn transfer_and_update(&self, recipient: ManagedAddress, amount: BigUint) {
    // State change happens IMMEDIATELY
    self.total_sent().update(|t| *t += &amount);

    // Async call to another contract
    self.tx()
        .to(&recipient)
        .egld(&amount)
        .callback(self.callbacks().on_transfer())
        .async_call_and_exit();
}

#[callback]
fn on_transfer(&self) {
    // If transfer FAILED, total_sent is STILL updated!
    // This is inconsistent state!
}
rust
#[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仍会被更新!
    // 这会导致状态不一致!
}

The Solution

解决方案

rust
#[endpoint]
fn transfer_and_update(&self, recipient: ManagedAddress, amount: BigUint) {
    // DON'T update state before async call
    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(_) => {
            // Only update state on SUCCESS
            self.total_sent().update(|t| *t += &amount);
        },
        ManagedAsyncCallResult::Err(_) => {
            // Handle failure explicitly
            // Funds return to contract automatically
        }
    }
}
rust
#[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 Limits & Out of Gas (OOG)

2. 燃气限制与燃气耗尽(OOG)

The Sharp Edge

易踩陷阱

OOG can leave cross-shard transactions in partial states.
燃气耗尽会导致跨分片交易处于部分完成的状态。

Cross-Shard OOG Scenario

跨分片燃气耗尽场景

1. Sender shard processes transaction (state changed)
2. Receiver shard runs out of gas
3. Receiver execution fails
4. Sender state changes PERSIST
5. Callback triggered with error
1. 发送方分片处理交易(状态已变更)
2. 接收方分片燃气耗尽
3. 接收方执行失败
4. 发送方的状态变更仍会保留
5. 触发携带错误信息的回调

Bad

错误示例

rust
// DON'T: Skip gas reservation for callbacks — OOG in callback loses state
self.tx().to(&other).typed(Proxy).call()
    .callback(self.callbacks().on_result())
    .async_call_and_exit(); // No gas reserved for callback!
rust
// 错误做法:不为回调预留燃气 — 回调中燃气耗尽会导致状态丢失
self.tx().to(&other).typed(Proxy).call()
    .callback(self.callbacks().on_result())
    .async_call_and_exit(); // 未为回调预留燃气!

Good

正确示例

rust
// DO: Always reserve explicit gas for callbacks
self.tx().to(&other).typed(Proxy).call()
    .gas(50_000_000)
    .callback(self.callbacks().on_result())
    .gas_for_callback(10_000_000) // Ensures callback can execute
    .async_call_and_exit();
rust
// 正确做法:始终为回调显式预留燃气
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();

The Solution

解决方案

rust
// Always reserve enough gas for callbacks
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();
}
rust
// 始终为回调预留足够的燃气
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. Storage Mappers vs Rust Types

3. 存储映射器与Rust原生类型

The Sharp Edge

易踩陷阱

VecMapper
is NOT a
Vec
. They have fundamentally different memory models.
VecMapper
不是
Vec
,它们的内存模型完全不同。

The Problem

问题示例

rust
// VecMapper: Each element is a separate storage slot
// Accessing element = 1 storage read
// Iterating N elements = N storage reads
#[storage_mapper("users")]
fn users(&self) -> VecMapper<ManagedAddress>;

// If you load into a Vec, you load EVERYTHING into WASM memory
fn bad_function(&self) {
    let all_users: Vec<ManagedAddress> = self.users().iter().collect();
    // With 10,000 users = 10,000 storage reads + massive memory allocation
    // WILL run out of gas
}
rust
// 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();
    // 若有10000个用户,会产生10000次存储读取 + 大量内存分配
    // 必然会燃气耗尽
}

The Solution

解决方案

rust
// Paginate operations
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 is 1-indexed!
        self.process_user(&user);
    }
}

// Or use appropriate mapper for the use case
// SetMapper for O(1) contains checks
// UnorderedSetMapper for efficient removal
rust
// 分页处理操作
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);
    }
}

// 或根据使用场景选择合适的映射器
// 如需O(1)时间复杂度的存在性检查,使用SetMapper
// 如需高效删除操作,使用UnorderedSetMapper

4. Token Decimal Precision

4. 代币小数精度

The Sharp Edge

易踩陷阱

ESDTs can have 0-18 decimals. Hardcoding decimal assumptions breaks contracts.
ESDT代币的小数位数可以是0-18位。硬编码小数位数会导致合约失效。

The Problem

问题示例

rust
// WRONG: Assumes 18 decimals
fn convert_to_usd(&self, token_amount: BigUint) -> BigUint {
    let price = self.price().get();  // Price in 10^18
    &token_amount * &price / BigUint::from(10u64.pow(18))  // Assumes 18 decimals!
}
rust
// 错误做法:假设代币是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位小数!
}

The Solution

解决方案

rust
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
}

// Or require specific decimals
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, "Token must have 18 decimals");
}
rust
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. Upgradeability Pitfalls

5. 合约升级陷阱

The Sharp Edge

易踩陷阱

#[init]
is NOT called on upgrade. Only
#[upgrade]
runs.
#[init]
函数在合约升级时不会被调用,只有
#[upgrade]
函数会执行。

The Problem

问题示例

rust
// V1 contract
#[init]
fn init(&self) {
    self.version().set(1);
}

// V2 contract - added new storage
#[init]
fn init(&self) {
    self.version().set(2);
    self.new_feature_enabled().set(true);  // NEVER RUNS ON UPGRADE!
}

// After upgrade: version is still 1, new_feature_enabled is empty!
rust
// V1合约
#[init]
fn init(&self) {
    self.version().set(1);
}

// V2合约 - 新增了存储变量
#[init]
fn init(&self) {
    self.version().set(2);
    self.new_feature_enabled().set(true);  // 升级时永远不会执行!
}

// 升级后:version仍为1,new_feature_enabled的值为空!

The Solution

解决方案

rust
#[upgrade]
fn upgrade(&self) {
    // Initialize new storage here
    self.version().set(2);
    self.new_feature_enabled().set(true);

    // Migrate existing data if needed
    self.migrate_storage();
}
rust
#[upgrade]
fn upgrade(&self) {
    // 在此处初始化新增的存储变量
    self.version().set(2);
    self.new_feature_enabled().set(true);

    // 如有需要,迁移现有数据
    self.migrate_storage();
}

Storage Layout Changes

存储布局变更

NEVER reorder struct fields:
rust
// V1
struct UserData {
    balance: BigUint,    // Encoded at position 0
    timestamp: u64,      // Encoded at position 1
}

// V2 - BREAKS EXISTING DATA
struct UserData {
    timestamp: u64,      // Now at position 0 - reads old balance bytes!
    balance: BigUint,    // Now at position 1 - reads old timestamp bytes!
    new_field: bool,     // This is fine (appended)
}
绝对不要重新排序结构体字段:
rust
// V1
struct UserData {
    balance: BigUint,    // 编码在位置0
    timestamp: u64,      // 编码在位置1
}

// V2 - 会破坏现有数据
struct UserData {
    timestamp: u64,      // 现在在位置0 - 会读取旧的balance字节!
    balance: BigUint,    // 现在在位置1 - 会读取旧的timestamp字节!
    new_field: bool,     // 这个没问题(追加字段)
}

6. Block Info in Views

6. 视图函数中的区块信息

The Sharp Edge

易踩陷阱

get_block_timestamp_millis()
/
get_block_timestamp_seconds()
in
#[view]
functions may return different values off-chain vs on-chain. Since Supernova (0.6s rounds), prefer
get_block_timestamp_millis()
with
TimestampMillis
for sub-second precision —
TimestampSeconds
loses granularity when rounds are faster than 1 second.
#[view]
函数中调用
get_block_timestamp_millis()
/
get_block_timestamp_seconds()
时,链下模拟返回的值可能与链上实际值不同。自Supernova版本(0.6秒出块)后,推荐使用
get_block_timestamp_millis()
搭配
TimestampMillis
以获得亚秒级精度——当出块速度快于1秒时,
TimestampSeconds
会丢失精度。

The Problem

问题示例

rust
// Problem - using seconds loses precision with Supernova's 0.6s rounds
#[view(isExpired)]
fn is_expired(&self) -> bool {
    let deadline = self.deadline().get(); // TimestampMillis
    let current_time = self.blockchain().get_block_timestamp_millis();
    // Off-chain simulation may return 0 or stale value!
    current_time > deadline
}
rust
// 问题 - 使用秒级时间戳会在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
}

The Solution

解决方案

rust
// Option 1: Don't rely on block info in views
#[view(getDeadline)]
fn get_deadline(&self) -> TimestampMillis {
    self.deadline().get()
    // Let client compare with their known current time
}

// Option 2: Accept timestamp as parameter for queries
#[view(isExpiredAt)]
fn is_expired_at(&self, check_time: TimestampMillis) -> bool {
    let deadline = self.deadline().get();
    check_time > deadline
}
rust
// 方案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 Indexing

7. VecMapper 索引规则

The Sharp Edge

易踩陷阱

VecMapper
is 1-indexed, not 0-indexed like Rust
Vec
.
VecMapper
从1开始索引的,而非Rust原生
Vec
的从0开始。

The Problem

问题示例

rust
fn get_first_user(&self) -> ManagedAddress {
    self.users().get(0)  // PANIC! Index 0 doesn't exist
}
rust
fn get_first_user(&self) -> ManagedAddress {
    self.users().get(0)  // 会 panic!索引0不存在
}

The Solution

解决方案

rust
fn get_first_user(&self) -> ManagedAddress {
    require!(!self.users().is_empty(), "No users");
    self.users().get(1)  // First element is at index 1
}

fn iterate_users(&self) {
    for i in 1..=self.users().len() {  // 1 to len, inclusive
        let user = self.users().get(i);
        // process user
    }
}
rust
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到长度值,包含两端
        let user = self.users().get(i);
        // 处理用户数据
    }
}

8. EGLD + ESDT Multi-Transfers (Updated since v0.55.0)

8. EGLD + ESDT 混合转账(v0.55.0版本后更新)

The Sharp Edge

易踩陷阱

Since SDK v0.55.0, EGLD and ESDT can be sent together in the same multi-transfer transaction using the unified
Payment
type with
TokenId
. However, you must use the new unified payment API — the old
.egld()
+
.single_esdt()
chain still cannot be combined.
自SDK v0.55.0起,EGLD和ESDT可以通过统一的
Payment
类型搭配
TokenId
在同一笔多转账交易中一起发送。但必须使用新的统一支付API——旧的
.egld()
+
.single_esdt()
链式调用仍无法组合使用。

The Old Problem (no longer applies)

旧版问题(已解决)

rust
// This used to be impossible, now supported via unified Payment API
rust
// 过去无法实现,现在通过统一Payment API支持

The Solution

解决方案

rust
// Use unified Payment with TokenId for mixed transfers
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();
rust
// 使用统一的Payment搭配TokenId实现混合转账
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 Memory Model

9. MapMapper 内存模型

The Sharp Edge

易踩陷阱

MapMapper
stores
4*N + 1
storage entries, making it very expensive.
MapMapper
会存储
4*N + 1
个存储条目,成本非常高。

Bad

错误示例

rust
// DON'T: Use MapMapper for per-user data when you don't need iteration
// For 1000 users, this creates 4001 storage entries!
#[storage_mapper("balances")]
fn balances(&self) -> MapMapper<ManagedAddress, BigUint>;
rust
// 错误做法:当不需要遍历功能时,不要用MapMapper存储用户数据
// 1000个用户会创建4001个存储条目!
#[storage_mapper("balances")]
fn balances(&self) -> MapMapper<ManagedAddress, BigUint>;

Good

正确示例

rust
// DO: Use SingleValueMapper with address key — 1 entry per user
#[storage_mapper("balance")]
fn balance(&self, user: &ManagedAddress) -> SingleValueMapper<BigUint>;
// Only use MapMapper when you MUST iterate over all entries
rust
// 正确做法:使用带地址键的SingleValueMapper — 每个用户仅对应1个存储条目
#[storage_mapper("balance")]
fn balance(&self, user: &ManagedAddress) -> SingleValueMapper<BigUint>;
// 仅当必须遍历所有条目时才使用MapMapper

10. Require vs SC Panic

10. Require 与 SC Panic 对比

The Sharp Edge

易踩陷阱

require!
generates larger WASM than
sc_panic!
when the message is dynamic.
当错误消息是动态内容时,
require!
生成的WASM体积比
sc_panic!
更大。

The Problem

问题示例

rust
// Each unique string increases WASM size
require!(condition1, "Error message one");
require!(condition2, "Error message two");
require!(condition3, "Error message three");
rust
// 每个唯一的字符串都会增加WASM体积
require!(condition1, "错误信息1");
require!(condition2, "错误信息2");
require!(condition3, "错误信息3");

The Solution

解决方案

rust
// Use static error constants
const ERR_INVALID_AMOUNT: &str = "Invalid amount";
const ERR_UNAUTHORIZED: &str = "Unauthorized";

require!(amount > 0, ERR_INVALID_AMOUNT);
require!(caller == owner, ERR_UNAUTHORIZED);

// Reuse same constant for same error type
require!(amount1 > 0, ERR_INVALID_AMOUNT);
require!(amount2 > 0, ERR_INVALID_AMOUNT);
rust
// 使用静态错误常量
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 in Payments (v0.64.0+)

11. 支付中的NonZeroBigUint(v0.64.0+)

The Sharp Edge

易踩陷阱

Payment.amount
is
NonZeroBigUint
, not
BigUint
. This means zero-amount payments are impossible at the type level, but you must handle conversions when creating payments from
BigUint
values.
Payment.amount
的类型是
NonZeroBigUint
,而非
BigUint
。这意味着类型层面不允许零金额支付,但从
BigUint
值创建支付时必须处理转换逻辑。

The Problem

问题示例

rust
// WRONG - won't compile, Payment expects NonZeroBigUint
let payment = Payment::new(token_id, 0, amount); // amount is BigUint

// WRONG - panics at runtime if amount is zero
let nz = NonZeroBigUint::new_or_panic(amount);
rust
// 错误 - 编译不通过,Payment期望NonZeroBigUint类型
let payment = Payment::new(token_id, 0, amount); // amount是BigUint类型

// 错误 - 如果amount为0,运行时会panic
let nz = NonZeroBigUint::new_or_panic(amount);

The Solution

解决方案

rust
// Option-based (safe)
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();
}

// When reading from call_value, amount is already NonZeroBigUint
let payment = self.call_value().single();
// payment.amount is NonZeroBigUint — guaranteed non-zero
// Use .as_big_uint() to get a &BigUint reference for arithmetic
self.balance(&caller).update(|b| *b += payment.amount.as_big_uint());
rust
// 基于Option的安全处理方式
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读取时,金额已经是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());

Key Point

关键点

You no longer need
require!(amount > 0, ...)
checks on incoming payments — the type system enforces this. But you still need validation when constructing payments from computed
BigUint
values.
对于传入的支付,你不再需要
require!(amount > 0, ...)
这样的检查——类型系统会保证金额非零。但从计算得到的
BigUint
值构造支付时,仍需要进行验证。

12. BackTransfers Accumulation (v0.59.0+)

12. 回传资金累积(v0.59.0+)

The Sharp Edge

易踩陷阱

Back-transfers from sync calls accumulate across multiple calls in the same transaction. Without resetting, you get stale data from previous calls mixed in.
同步调用的回传资金会在同一笔交易的多次调用中累积。若不重置,会混入之前调用的旧数据。

The Problem

问题示例

rust
#[endpoint]
fn multi_swap(&self, dex: ManagedAddress) {
    // First swap
    let bt1 = self.tx().to(&dex).typed(DexProxy)
        .swap_a()
        .returns(ReturnsBackTransfers) // No reset!
        .sync_call();

    // Second swap
    let bt2 = self.tx().to(&dex).typed(DexProxy)
        .swap_b()
        .returns(ReturnsBackTransfers) // No reset!
        .sync_call();

    // BUG: bt2 contains payments from BOTH swap_a AND swap_b
    let total = bt2.into_payment_vec(); // Wrong amount!
}
rust
#[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();

    // 错误:bt2包含swap_a和swap_b两次调用的回传资金
    let total = bt2.into_payment_vec(); // 金额错误!
}

The Solution

解决方案

rust
#[endpoint]
fn multi_swap(&self, dex: ManagedAddress) {
    let bt1 = self.tx().to(&dex).typed(DexProxy)
        .swap_a()
        .returns(ReturnsBackTransfersReset) // Resets before reading
        .sync_call();

    let bt2 = self.tx().to(&dex).typed(DexProxy)
        .swap_b()
        .returns(ReturnsBackTransfersReset) // Resets before reading
        .sync_call();

    // bt1 and bt2 each contain only their own call's payments
}
rust
#[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分别只包含对应调用的回传资金
}

Key Point

关键点

Always use
ReturnsBackTransfersReset
instead of
ReturnsBackTransfers
when an endpoint makes more than one sync call that returns tokens. The
Reset
variant calls
blockchain().reset_back_transfers()
before the call, clearing the accumulator.
当一个端点发起多个会返回代币的同步调用时,始终使用
ReturnsBackTransfersReset
而非
ReturnsBackTransfers
。带Reset的变体在调用前会执行
blockchain().reset_back_transfers()
,清空累积器。

13. Pending Callback Tracking

13. 待处理回调追踪

The Sharp Edge

易踩陷阱

Async calls can fail silently — the callback may never fire if the target contract runs out of gas or panics during execution. Without tracking, your contract will never know the operation is incomplete.
异步调用可能会静默失败——如果目标合约在执行过程中燃气耗尽或panic,回调可能永远不会触发。若不进行追踪,你的合约永远不会知道操作未完成。

The Problem

问题示例

rust
#[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();
    // If callback never fires, pending_amount is stuck forever
}
rust
#[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会一直处于待处理状态
}

The Solution

解决方案

rust
#[endpoint]
fn delegate_to_provider(&self, provider: ManagedAddress, amount: BigUint) {
    // Track the pending operation with a unique 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(); // Always clear tracking
    match result {
        ManagedAsyncCallResult::Ok(_) => { /* success */ }
        ManagedAsyncCallResult::Err(_) => { /* handle failure, refund etc */ }
    }
}

// Admin recovery for stuck operations
#[endpoint(recoverPending)]
fn recover_pending(&self, op_id: u64) {
    require!(self.blockchain().get_caller() == self.blockchain().get_owner_address(), "Not owner");
    let op = self.pending_operations(op_id).get();
    let now = self.blockchain().get_block_timestamp_millis();
    require!(now - op.timestamp > RECOVERY_TIMEOUT_MS, "Too early to recover");
    self.pending_operations(op_id).clear();
    // Refund or retry logic
}
rust
#[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 Key Collisions with
storage_mapper_from_address

14. 使用storage_mapper_from_address导致的存储键冲突

The Sharp Edge

易踩陷阱

When reading another contract's storage with
#[storage_mapper_from_address("key")]
, if the target contract upgrades and renames its storage keys, your reads silently return default values (zero, empty) — no error.
当使用
#[storage_mapper_from_address("key")]
读取其他合约的存储时,如果目标合约升级并重命名了存储键,你的读取操作会静默返回默认值(0或空)——不会抛出错误。

The Problem

问题示例

rust
// Your contract reads the "reserve" key from a DEX pair
#[storage_mapper_from_address("reserve")]
fn pair_reserve(&self, addr: ManagedAddress, token: &TokenIdentifier)
    -> SingleValueMapper<BigUint, ManagedAddress>;

// DEX upgrades and renames "reserve" to "token_reserve"
// Your reads now return 0 — silently incorrect data!
rust
// 你的合约读取DEX交易对的"reserve"键
#[storage_mapper_from_address("reserve")]
fn pair_reserve(&self, addr: ManagedAddress, token: &TokenIdentifier)
    -> SingleValueMapper<BigUint, ManagedAddress>;

// DEX升级后将"reserve"重命名为"token_reserve"
// 你的读取操作现在会返回0 — 数据错误但无任何提示!

The Solution

解决方案

  • Pin to specific contract versions in your documentation
  • Add sanity checks: if reserve returns 0 for an active pair, something is wrong
  • Consider adding a staleness check or fallback to proxy calls
rust
fn get_pair_reserve_safe(&self, pair: &ManagedAddress, token: &TokenIdentifier) -> BigUint {
    let reserve = self.pair_reserve(pair.clone(), token).get();
    // Sanity check — active pairs should never have zero reserves
    if reserve == 0u64 {
        // Fallback: use proxy call or revert
        sc_panic!("Unexpected zero reserve — target contract may have changed storage layout");
    }
    reserve
}
  • 在文档中明确依赖的合约版本
  • 添加合理性检查:如果活跃交易对的储备金返回0,说明存在问题
  • 考虑添加过期检查或回退到代理调用
rust
fn get_pair_reserve_safe(&self, pair: &ManagedAddress, token: &TokenIdentifier) -> BigUint {
    let reserve = self.pair_reserve(pair.clone(), token).get();
    // 合理性检查 — 活跃交易对的储备金绝不可能为0
    if reserve == 0u64 {
        // 回退:使用代理调用或抛出错误
        sc_panic!("储备金为0,异常 — 目标合约可能已变更存储布局");
    }
    reserve
}

15. Cache Invalidation Across Async Boundaries

15. 异步边界下的缓存失效

The Sharp Edge

易踩陷阱

async_call_and_exit()
terminates execution immediately. A Drop-based cache in the same scope will never have its
drop()
called — cached writes are silently lost.
async_call_and_exit()
会立即终止执行。同一作用域中基于Drop的缓存永远不会执行
drop()
方法——缓存的写入操作会静默丢失。

The Problem

问题示例

rust
fn bad_pattern(&self) {
    let mut cache = StorageCache::new(self);
    cache.balance += &deposit_amount;

    // async_call_and_exit() terminates execution — drop() NEVER runs!
    self.tx().to(&other).typed(Proxy).call()
        .callback(self.callbacks().on_result())
        .async_call_and_exit();
    // cache.drop() never fires — balance change is LOST
}
rust
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()未执行 — 余额变更丢失
}

The Solution

解决方案

Manually drop the cache (via scoping) before the async call:
rust
fn good_pattern(&self) {
    {
        let mut cache = StorageCache::new(self);
        cache.balance += &deposit_amount;
    } // cache.drop() fires here — writes committed

    self.tx().to(&other).typed(Proxy).call()
        .callback(self.callbacks().on_result())
        .async_call_and_exit();
}
Note: State committed before the async call persists even if the async call fails. Track pending operations if you need rollback (see item 13).
在异步调用前手动(通过作用域)销毁缓存:
rust
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. Rounding Attack Vectors in Financial Calculations

16. 金融计算中的舍入攻击向量

The Sharp Edge

易踩陷阱

Default
ManagedDecimal
arithmetic truncates (rounds toward zero). In lending/staking protocols, this creates systematic value leakage that attackers can exploit with many small operations.
默认的
ManagedDecimal
算术运算会截断小数(向零舍入)。在借贷/质押协议中,这会导致系统性的价值泄露,攻击者可以通过多次小额操作利用该漏洞。

The Problem

问题示例

rust
// Each deposit loses a fraction of a token due to truncation
// Attacker makes 1000 tiny deposits, each time extracting the rounding difference
let shares = (amount * total_shares) / total_supply; // Truncates!
rust
// 每次存款都会丢失一小部分代币(截断导致)
// 攻击者发起1000次小额存款,每次提取舍入差额
let shares = (amount * total_shares) / total_supply; // 会截断!

The Solution

解决方案

Use half-up rounding for ALL financial calculations. See the
multiversx-defi-math
skill for implementation.
所有金融计算都使用四舍五入(half-up)方式。具体实现可参考
multiversx-defi-math
工具。

Quick Reference: Common Gotchas

快速参考:常见陷阱汇总

IssueWrongRight
VecMapper index
.get(0)
.get(1)
Callback stateUpdate before asyncUpdate in callback on success
Upgrade initRely on
#[init]
Use
#[upgrade]
DecimalsHardcode
10^18
Fetch from token properties
MapMapperUse for per-user dataUse SingleValueMapper with key
Block info in viewDirect usePass as parameter
EGLD + ESDTOld: same tx impossibleUse unified
Payment
with
TokenId
in multi-transfer
NonZeroBigUint
Payment::new(id, 0, big_uint)
amount.into_non_zero()
then
Payment::new(...)
Struct fieldsReorderOnly append
BackTransfers
ReturnsBackTransfers
with multiple calls
ReturnsBackTransfersReset
— resets accumulator
Pending callbacksFire-and-forget asyncTrack with op ID + recovery endpoint
Cross-contract storage keysAssume keys never changeSanity checks + version pinning
Cache + asyncDrop cache before async callManual commit in callback only
Financial roundingDefault truncationHalf-up rounding (mul_half_up/div_half_up)
问题错误做法正确做法
VecMapper索引
.get(0)
.get(1)
回调状态处理异步调用前更新状态仅在回调成功时更新状态
升级初始化依赖
#[init]
使用
#[upgrade]
小数位数硬编码
10^18
从代币属性中获取
MapMapper使用用于存储用户数据使用带键的SingleValueMapper
视图函数中的区块信息直接使用作为参数传入
EGLD+ESDT转账旧版:无法在同一笔交易中实现使用带
TokenId
的统一
Payment
进行多转账
NonZeroBigUint使用
Payment::new(id, 0, big_uint)
先调用
amount.into_non_zero()
再创建Payment
结构体字段重新排序仅追加字段
回传资金处理多次调用使用
ReturnsBackTransfers
使用
ReturnsBackTransfersReset
— 重置累积器
待处理回调异步调用后不管不顾用操作ID追踪 + 恢复端点
跨合约存储键假设键永远不变添加合理性检查 + 版本锁定
缓存+异步异步调用前依赖缓存自动销毁手动提交或仅在回调中提交
金融计算舍入默认截断使用四舍五入(mul_half_up/div_half_up)