skills/multiversx/mx-ai-skills/multiversx-flash-loan-patterns

multiversx-flash-loan-patterns

SKILL.md

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

  1. Reentrancy guard — prevent nested operations
  2. Shard validation — caller must be same shard (atomicity requirement)
  3. Endpoint validation — callback must not be a built-in function
  4. Repayment verification — check contract balance after callback
  5. 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,
) {
    // 1. Reentrancy guard
    self.require_not_ongoing();

    // 2. Shard validation (atomicity requires same shard)
    self.require_same_shard(&target_contract);

    // 3. Endpoint validation
    self.require_valid_endpoint(&callback_endpoint);

    // 4. Calculate expected repayment
    let fee = &amount * self.fee_bps().get() / 10_000u64;
    let balance_before = self.blockchain().get_sc_balance(&asset.clone().into(), 0);

    // 5. Set guard
    self.operation_ongoing().set(true);

    // 6. Send tokens and call target
    self.tx()
        .to(&target_contract)
        .raw_call(callback_endpoint)
        .single_esdt(&asset, 0, &amount)
        .sync_call();

    // 7. Verify repayment
    let balance_after = self.blockchain().get_sc_balance(&asset.into(), 0);
    require!(
        balance_after >= balance_before + &fee,
        "Repayment insufficient"
    );

    // 8. Clear guard
    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

// DON'T: No reentrancy guard — malicious callback re-enters and borrows again
#[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"); // Bypassed by re-entry!
}

Good

// DO: Set reentrancy guard before send, clear after verification
#[endpoint(flashLoan)]
fn flash_loan(&self, asset: TokenId, amount: BigUint, target: ManagedAddress) {
    self.require_not_ongoing(); // Blocks nested calls
    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

// WRONG — if verification fails, guard stays set forever
self.operation_ongoing().set(true);
self.tx().to(&target).raw_call(endpoint).sync_call();
// If this require fails, the guard is never cleared!
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

// WRONG — checking a specific storage value instead of actual contract balance
let repaid = self.deposits(&asset).get();

// CORRECT — check actual on-chain balance
let balance_after = self.blockchain().get_sc_balance(&asset.into(), 0);

3. No Shard Validation

// WRONG — cross-shard calls break atomicity silently
fn flash_loan(&self, borrower: ManagedAddress, /* ... */) {
    // If borrower is on different shard, sync_call becomes async
    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"
        );
    }
}
Weekly Installs
7
GitHub Stars
10
First Seen
Feb 8, 2026
Installed on
opencode6
github-copilot6
codex6
kimi-cli6
gemini-cli6
amp6