multiversx-sharp-edges
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseMultiversX 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 is still executed, but state changes from the original transaction are NOT automatically reverted.
#[callback]当异步调用失败时,仍会执行,但原交易的状态变更不会自动回滚。
#[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 error1. 发送方分片处理交易(状态已变更)
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
易踩陷阱
VecMapperVecVecMapperVecThe 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 removalrust
// 分页处理操作
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
// 如需高效删除操作,使用UnorderedSetMapper4. 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]#[upgrade]#[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()#[view]get_block_timestamp_millis()TimestampMillisTimestampSeconds在函数中调用/时,链下模拟返回的值可能与链上实际值不同。自Supernova版本(0.6秒出块)后,推荐使用搭配以获得亚秒级精度——当出块速度快于1秒时,会丢失精度。
#[view]get_block_timestamp_millis()get_block_timestamp_seconds()get_block_timestamp_millis()TimestampMillisTimestampSecondsThe 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
易踩陷阱
VecMapperVecVecMapperVecThe 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 type with . However, you must use the new unified payment API — the old + chain still cannot be combined.
PaymentTokenId.egld().single_esdt()自SDK v0.55.0起,EGLD和ESDT可以通过统一的类型搭配在同一笔多转账交易中一起发送。但必须使用新的统一支付API——旧的 + 链式调用仍无法组合使用。
PaymentTokenId.egld().single_esdt()The Old Problem (no longer applies)
旧版问题(已解决)
rust
// This used to be impossible, now supported via unified Payment APIrust
// 过去无法实现,现在通过统一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
易踩陷阱
MapMapper4*N + 1MapMapper4*N + 1Bad
错误示例
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 entriesrust
// 正确做法:使用带地址键的SingleValueMapper — 每个用户仅对应1个存储条目
#[storage_mapper("balance")]
fn balance(&self, user: &ManagedAddress) -> SingleValueMapper<BigUint>;
// 仅当必须遍历所有条目时才使用MapMapper10. Require vs SC Panic
10. Require 与 SC Panic 对比
The Sharp Edge
易踩陷阱
require!sc_panic!当错误消息是动态内容时,生成的WASM体积比更大。
require!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.amountNonZeroBigUintBigUintBigUintPayment.amountNonZeroBigUintBigUintBigUintThe 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 checks on incoming payments — the type system enforces this. But you still need validation when constructing payments from computed values.
require!(amount > 0, ...)BigUint对于传入的支付,你不再需要这样的检查——类型系统会保证金额非零。但从计算得到的值构造支付时,仍需要进行验证。
require!(amount > 0, ...)BigUint12. 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 instead of when an endpoint makes more than one sync call that returns tokens. The variant calls before the call, clearing the accumulator.
ReturnsBackTransfersResetReturnsBackTransfersResetblockchain().reset_back_transfers()当一个端点发起多个会返回代币的同步调用时,始终使用而非。带Reset的变体在调用前会执行,清空累积器。
ReturnsBackTransfersResetReturnsBackTransfersblockchain().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
storage_mapper_from_address14. 使用storage_mapper_from_address导致的存储键冲突
The Sharp Edge
易踩陷阱
When reading another contract's storage with , 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或空)——不会抛出错误。
#[storage_mapper_from_address("key")]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()drop()async_call_and_exit()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 arithmetic truncates (rounds toward zero). In lending/staking protocols, this creates systematic value leakage that attackers can exploit with many small operations.
ManagedDecimal默认的算术运算会截断小数(向零舍入)。在借贷/质押协议中,这会导致系统性的价值泄露,攻击者可以通过多次小额操作利用该漏洞。
ManagedDecimalThe 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 skill for implementation.
multiversx-defi-math所有金融计算都使用四舍五入(half-up)方式。具体实现可参考工具。
multiversx-defi-mathQuick Reference: Common Gotchas
快速参考:常见陷阱汇总
| Issue | Wrong | Right |
|---|---|---|
| VecMapper index | | |
| Callback state | Update before async | Update in callback on success |
| Upgrade init | Rely on | Use |
| Decimals | Hardcode | Fetch from token properties |
| MapMapper | Use for per-user data | Use SingleValueMapper with key |
| Block info in view | Direct use | Pass as parameter |
| EGLD + ESDT | Old: same tx impossible | Use unified |
| NonZeroBigUint | | |
| Struct fields | Reorder | Only append |
| BackTransfers | | |
| Pending callbacks | Fire-and-forget async | Track with op ID + recovery endpoint |
| Cross-contract storage keys | Assume keys never change | Sanity checks + version pinning |
| Cache + async | Drop cache before async call | Manual commit in callback only |
| Financial rounding | Default truncation | Half-up rounding (mul_half_up/div_half_up) |
| 问题 | 错误做法 | 正确做法 |
|---|---|---|
| VecMapper索引 | | |
| 回调状态处理 | 异步调用前更新状态 | 仅在回调成功时更新状态 |
| 升级初始化 | 依赖 | 使用 |
| 小数位数 | 硬编码 | 从代币属性中获取 |
| MapMapper使用 | 用于存储用户数据 | 使用带键的SingleValueMapper |
| 视图函数中的区块信息 | 直接使用 | 作为参数传入 |
| EGLD+ESDT转账 | 旧版:无法在同一笔交易中实现 | 使用带 |
| NonZeroBigUint使用 | | 先调用 |
| 结构体字段 | 重新排序 | 仅追加字段 |
| 回传资金处理 | 多次调用使用 | 使用 |
| 待处理回调 | 异步调用后不管不顾 | 用操作ID追踪 + 恢复端点 |
| 跨合约存储键 | 假设键永远不变 | 添加合理性检查 + 版本锁定 |
| 缓存+异步 | 异步调用前依赖缓存自动销毁 | 手动提交或仅在回调中提交 |
| 金融计算舍入 | 默认截断 | 使用四舍五入(mul_half_up/div_half_up) |