| name | mvx-flash-loan-patterns |
| description | Atomic lend-execute-verify pattern for MultiversX smart contracts. Use when building flash loans, atomic swaps, temporary grants, or any operation that lends assets, executes a callback, and verifies repayment within a single transaction. |
MultiversX Atomic Lend-Execute-Verify Pattern
A pattern for operations that temporarily lend assets, execute an external callback, and verify repayment — all atomically within a single transaction.
What Problem Does This Solve?
You want to lend tokens to a contract, let it execute arbitrary logic, and guarantee repayment (plus fee) before the transaction completes. If repayment fails, the entire transaction reverts.
When to Use
| Scenario | Use This Pattern? |
|---|
| Flash loans | Yes — the canonical use case |
| Atomic swaps with verification | Yes — send tokens, verify counterparty sent back |
| Temporary grants (execute-then-return) | Yes — lend tokens for computation, verify return |
| Cross-shard operations | No — atomicity requires same-shard |
| Simple transfers | No — overkill |
Security Checklist
- Reentrancy guard — prevent nested operations
- Shard validation — caller must be same shard (atomicity requirement)
- Endpoint validation — callback must not be a built-in function
- Repayment verification — check contract balance after callback
- Guard cleanup — always clear the reentrancy flag
Core Flow: Guard → Send → Execute → Verify → Clear
#[endpoint(atomicOperation)]
fn atomic_operation(
&self,
asset: TokenId,
amount: BigUint,
target_contract: ManagedAddress,
callback_endpoint: ManagedBuffer,
) {
self.require_not_ongoing();
self.require_same_shard(&target_contract);
self.require_valid_endpoint(&callback_endpoint);
let fee = &amount * self.fee_bps().get() / 10_000u64;
let balance_before = self.blockchain().get_sc_balance(&asset.clone().into(), 0);
self.operation_ongoing().set(true);
self.tx()
.to(&target_contract)
.raw_call(callback_endpoint)
.single_esdt(&asset, 0, &amount)
.sync_call();
let balance_after = self.blockchain().get_sc_balance(&asset.into(), 0);
require!(
balance_after >= balance_before + &fee,
"Repayment insufficient"
);
self.operation_ongoing().set(false);
}
Reentrancy Guard
#[storage_mapper("operationOngoing")]
fn operation_ongoing(&self) -> SingleValueMapper<bool>;
fn require_not_ongoing(&self) {
require!(
!self.operation_ongoing().get(),
"Operation already in progress"
);
}
Why: Without this, a malicious callback could re-enter the operation endpoint, creating nested operations that bypass repayment checks.
Shard Validation
fn require_same_shard(&self, target_address: &ManagedAddress) {
let target_shard = self.blockchain().get_shard_of_address(target_address);
let contract_shard = self.blockchain().get_shard_of_address(
&self.blockchain().get_sc_address()
);
require!(
target_shard == contract_shard,
"Target must be on same shard"
);
}
Why: Cross-shard calls execute in different blocks/rounds, breaking atomicity. The callback would run in a separate transaction, allowing manipulation between the send and verification.
Endpoint Validation
fn require_valid_endpoint(&self, endpoint: &ManagedBuffer<Self::Api>) {
require!(
!endpoint.is_empty() && !self.blockchain().is_builtin_function(endpoint),
"Invalid callback endpoint"
);
}
Why: Built-in functions (token transfers, ESDT operations) could redirect tokens without executing the expected callback, bypassing repayment logic.
Reentrancy Guard Examples
Bad
#[endpoint(flashLoan)]
fn flash_loan(&self, asset: TokenId, amount: BigUint, target: ManagedAddress) {
let balance_before = self.blockchain().get_sc_balance(&asset.clone().into(), 0);
self.tx().to(&target).raw_call("execute").single_esdt(&asset, 0, &amount).sync_call();
let balance_after = self.blockchain().get_sc_balance(&asset.into(), 0);
require!(balance_after >= balance_before, "Not repaid");
}
Good
#[endpoint(flashLoan)]
fn flash_loan(&self, asset: TokenId, amount: BigUint, target: ManagedAddress) {
self.require_not_ongoing();
self.operation_ongoing().set(true);
let balance_before = self.blockchain().get_sc_balance(&asset.clone().into(), 0);
self.tx().to(&target).raw_call("execute").single_esdt(&asset, 0, &amount).sync_call();
let balance_after = self.blockchain().get_sc_balance(&asset.into(), 0);
require!(balance_after >= balance_before, "Not repaid");
self.operation_ongoing().set(false);
}
Anti-Patterns
1. Forgetting to Clear the Guard
self.operation_ongoing().set(true);
self.tx().to(&target).raw_call(endpoint).sync_call();
require!(balance_after >= expected, "Not repaid");
self.operation_ongoing().set(false);
Note: In MultiversX, if require! fails the transaction reverts, so the guard is also reverted. But in callback-based flows, be careful about which execution context you're in.
2. Checking Balance Incorrectly
let repaid = self.deposits(&asset).get();
let balance_after = self.blockchain().get_sc_balance(&asset.into(), 0);
3. No Shard Validation
fn flash_loan(&self, borrower: ManagedAddress, ) {
self.tx().to(&borrower).raw_call(endpoint).sync_call();
}
Template
#[multiversx_sc::module]
pub trait AtomicOperationModule {
#[storage_mapper("operationOngoing")]
fn operation_ongoing(&self) -> SingleValueMapper<bool>;
#[storage_mapper("feeBps")]
fn fee_bps(&self) -> SingleValueMapper<u64>;
fn require_not_ongoing(&self) {
require!(!self.operation_ongoing().get(), "Operation already in progress");
}
fn require_same_shard(&self, target: &ManagedAddress) {
let target_shard = self.blockchain().get_shard_of_address(target);
let self_shard = self.blockchain().get_shard_of_address(&self.blockchain().get_sc_address());
require!(target_shard == self_shard, "Must be same shard");
}
fn require_valid_endpoint(&self, endpoint: &ManagedBuffer<Self::Api>) {
require!(
!endpoint.is_empty() && !self.blockchain().is_builtin_function(endpoint),
"Invalid endpoint"
);
}
}