Loading...
Loading...
Catalog of non-obvious behaviors, gotchas, and platform-specific quirks in MultiversX that often lead to bugs. Use when debugging unexpected behavior, reviewing code for subtle issues, or learning platform-specific pitfalls.
npx skill4agent add multiversx/mx-ai-skills multiversx-sharp-edges#[callback]#[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!
}#[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
}
}
}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// 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!// 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();// 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();
}VecMapperVec// 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
}// 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// 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!
}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");
}#[init]#[upgrade]// 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!#[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();
}// 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)
}get_block_timestamp_millis()get_block_timestamp_seconds()#[view]get_block_timestamp_millis()TimestampMillisTimestampSeconds// 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
}// 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
}VecMapperVecfn get_first_user(&self) -> ManagedAddress {
self.users().get(0) // PANIC! Index 0 doesn't exist
}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
}
}PaymentTokenId.egld().single_esdt()// This used to be impossible, now supported via unified Payment API// 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();MapMapper4*N + 1// 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>;// 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 entriesrequire!sc_panic!// Each unique string increases WASM size
require!(condition1, "Error message one");
require!(condition2, "Error message two");
require!(condition3, "Error message three");// 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);Payment.amountNonZeroBigUintBigUintBigUint// 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);// 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());require!(amount > 0, ...)BigUint#[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!
}#[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
}ReturnsBackTransfersResetReturnsBackTransfersResetblockchain().reset_back_transfers()#[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
}#[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
}storage_mapper_from_address#[storage_mapper_from_address("key")]// 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!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
}async_call_and_exit()drop()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
}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();
}ManagedDecimal// 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!multiversx-defi-math| 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) |