Beitrag
Teile dein Wissen.
Flash Loan Safety in Sui Move
what's the optimal way to implement flash loan mechanisms in sui while preventing re-entrancy and shared and object lock exploits?
- Sui
- Architecture
- SDKs and Developer Tools
- NFT Ecosystem
- Move
Antworten
4Flash loans are powerful but they’re also a grab-bag for subtle attacks (re-entrancy, shared-object lock contention, denial-of-service, asset-mismatch exploits). Below I give a robust, pragmatic design I use in production on Sui: principles, concrete defenses, a Move-style reference implementation (runnable-looking), and a testing / audit checklist.
Key principles
- Same-transaction semantics — require loan + borrower logic + final check to occur inside a single atomic transaction. That eliminates inter-transaction re-entrancy classes.
- Reentrancy guard — use an on-object boolean guard to block nested calls.
- Operate on owned objects — transfer owned
Coin<T>
objects rather than mutating shared collections; avoid shared locks. - Pre/post invariants — snapshot reserve state before loan; assert it’s >= expected after loan (principal + fee).
- Scoped capabilities — give borrower a non-transferable, short-lived
FlashCap
that authorizes only allowed callback actions. - Limit callback surface — do not allow borrower to call arbitrary modules while lender balance is unverified; keep allowed operations minimal or have borrower supply client-built transaction entries.
- Gas / op limits & fee economics — cap number of mutating ops or enforce economic fees to disincentivize abuse.
- Audit & fuzz — model re-entrancy and shared-object contention, fuzz callback sequences and cross-contract flows.
Attack vectors to explicitly defend
- Re-entrancy: borrower re-enters lender while state is unfinalized.
- Shared-object lock abuse: borrower manipulates shared collections that other transactions rely on, causing livelock or stale invariants.
- Object identity tricks: borrower returns different objects or minted tokens rather than the originals.
- Out-of-order/partial returns: borrower partially returns funds then manipulates state to steal value.
- DoS by long-running callbacks: borrower causes transaction to exceed gas/time budgets or heavy object touches.
Concrete Move-style pattern (reference)
This is a practical pattern combining the above principles. It’s intentionally explicit about the guard, pre/post checks, and capability scoping.
module flash::lender {
use sui::tx_context::TxContext;
use sui::object::{UID};
use sui::coin::{Coin};
/// Lender object holds an owned reserve coin object (or a pool id).
/// The reserve is an owned Coin<T> object that we transfer out and expect back.
struct Lender<T> has key {
id: UID,
/// The reserve is represented by a "vault" object or single coin ownership
// For simplicity we keep accounted_balance to avoid reading many objects
accounted_balance: u128,
reentrancy_guard: bool,
fee_bps: u64, // e.g., 30 = 0.3%
max_ops: u64, // optional, to limit callback complexity
}
/// A short-lived capability that ties to a specific tx hash (or nonce).
/// We mint it and expect it to be used in the repay call only.
struct FlashCap has key {
id: UID,
tx_digest: vector<u8>, // client must include same tx digest
}
/// Entry: lender grants a flash loan. Important: client must bundle
/// follow-up operations and a `repay` call (or equivalent) into the same transaction.
public(entry) fun flash_loan<T>(
lender: &mut Lender<T>,
borrower: address,
amount: u128,
ctx: &mut TxContext
) {
// 1) reentrancy guard
assert!(!lender.reentrancy_guard, 100);
// 2) snapshot pre-state
let before = lender.accounted_balance;
// 3) basic checks
assert!(amount <= before, 101);
// 4) engage guard
lender.reentrancy_guard = true;
// 5) transfer out funds to borrower
// NOTE: In practice you transfer Coin<T> objects (ownership) to borrower,
// here we do an accounting operation to represent that.
lender.accounted_balance = lender.accounted_balance - amount;
// transfer_coin_to(borrower, amount) -- client ensures actual coin objects are moved
// 6) mint short-lived FlashCap bound to current tx
let tx_digest = tx_context::tx_digest(ctx); // pseudocode API
let cap = FlashCap { id: object::new(ctx), tx_digest };
// 7) leave guard on — borrower code/callback must run now (client-built tx ordering)
// The client should call borrower logic here (in the same transaction), then call repay()
// IMPORTANT: Do not clear guard here; cleared only after final repay() call.
object::save(cap); // keep the cap object obtained by borrower
}
/// Borrower must call repay and present the FlashCap issued above.
/// This must be in the same transaction (client enforces ordering).
public(entry) fun repay<T>(
lender: &mut Lender<T>,
cap: &FlashCap,
returned_amount: u128,
ctx: &mut TxContext
) {
// 1) validate cap belongs to tx
let tx_digest = tx_context::tx_digest(ctx);
assert!(cap.tx_digest == tx_digest, 200);
// 2) calculate fee
let fee = returned_amount * (lender.fee_bps as u128) / 10_000u128;
let min_expected = returned_amount + fee; // borrower must return principal + fee
// 3) post-state: borrower transferred coins back to lender before calling repay
// For simplicity we assume lender.accounted_balance was incremented by client's transfer
let after = lender.accounted_balance;
// enforce invariant: after >= before + fee
// (In a real implementation: compute before from saved snapshot or pass it)
assert!(after >= min_expected, 201);
// 4) burn cap and release guard
object::delete(cap); // non-transferable cap consumed
lender.reentrancy_guard = false;
// 5) collect fee (keeps in lender.accounted_balance)
// Done: loan successful
}
}
Notes about the code above
- In practice you must transfer specific
Coin<T>
object IDs, not just numbers. Use ownership transfer semantics: lender sends specific coin objects to borrower and expects the returned objects (or equivalent) back. tx_context::tx_digest(ctx)
is pseudocode for a tx-unique digest; Sui clients can build transactions and include a known digest / unique nonce so the cap can be bound to the specific transaction. If such an API isn't available, bind cap to a short-lived nonce passed by client and stored by lender at loan time (but nonce must be tied to tx to avoid reuse).- The borrower’s callback must be in the same transaction — achieved by client-side transaction construction: call
flash_loan
→ borrower actions →repay
. If any step fails, whole transaction rolls back.
Additional defensive measures (recommended)
- Operate on owned coin objects: transfer actual
Coin<T>
objects (IDs) to borrower and require the return of either the same objects or equivalent wrapped tokens. This prevents minted-forged tokens from being accepted. - Limit callback entrypoints: instead of letting borrower call arbitrary modules, require that borrower’s in-tx logic only calls pre-approved functions (enforced by capability tokens).
- Reentrancy guard at object level: per-lender guard prevents nested
flash_loan
/repay
calls. Do not rely solely on global transaction semantics. - Short expiry caps / non-transferable: FlashCap must be non-clonable, non-transferable and invalid outside the tx.
- Max operations / gas hinting: optionally include an allowed-op-count or require borrower to attach a gas buffer to prevent DoS via expensive callbacks.
- Avoid shared collections: keep reserves in independent owned objects. If you must touch a shared collection, minimize its size and avoid calling into borrower code while holding such locks.
- Event logging: emit
FlashLoanIssued
andFlashLoanRepaid
events with tx digest and amounts for off-chain monitoring.
Testing & audit checklist
- Unit tests for all typical and edge flows: successful repay, under-repay, partial repay, repay with different coin IDs.
- Fuzz tests that simulate borrower callbacks calling back into lender or other modules with nested calls.
- Concurrency tests: attempt many parallel flash loans to check for shared-object locks or deadlocks.
- Attack simulations: attempt to return forged tokens, attempt to re-use cap across transactions, attempt to invoke
repay
from different tx ordering. - Formal verification of core invariants:
reserve_before + fee <= reserve_after
andno outstanding reentrancy_guard
at end of flows.
Practical UX for integrators
- Give the borrower a clear client SDK flow: build a single transaction with 3 steps:
lender.flash_loan(...)
→ borrower_callback(...) →lender.repay(...)
. The client builder must ensure the loaned coin object IDs are passed through and the repay step includes the returned objects. - Provide sample borrower templates and a simulator tool that executes the tx flow off-chain to estimate gas and check ordering.
TL;DR — recommended minimal pattern
- Require loan + borrower operations + repay to happen in one atomic transaction.
- Use a reentrancy guard on the lender.
- Transfer owned coin objects to borrower; require explicit return (same objects or verified equivalent) and check
reserve_after >= reserve_before + fee
. - Use a non-transferable, tx-bound FlashCap to authorize repay only for that tx.
- Avoid mutating shared collections while lender guard is active.
- Add gas/operation limits and run heavy fuzzing and formal checks.
When I design flash loan contracts on Sui, I treat them as atomic operations. The key principle I follow is:
-
Loan, use, and repayment must occur in the same transaction.
- This eliminates the risk of unpaid loans hanging across transactions.
- The Move execution model ensures that if repayment fails, the entire transaction aborts.
-
Restrict re-entrancy at the type level.
- Instead of letting the borrower call arbitrary entry functions, I constrain them to a callback entrypoint that I whitelist during loan setup.
-
Avoid shared mutable objects in the hot path.
- I structure the loan pool as a unique object per borrower, not a global shared pool, unless I need it for liquidity aggregation.
- This reduces object contention and makes parallel execution easier.
Here’s how I encode this pattern:
module lending::FlashLoan {
use sui::tx_context::TxContext;
use sui::object::{Self, UID};
struct LoanPool has key {
id: UID,
liquidity: u64,
}
struct LoanReceipt has drop {
amount: u64,
}
/// Create a pool with liquidity
public fun init(amount: u64, ctx: &mut TxContext): LoanPool {
LoanPool { id: object::new(ctx), liquidity: amount }
}
/// Borrow temporarily (loan and repay in same tx)
public fun borrow(pool: &mut LoanPool, amount: u64): LoanReceipt {
assert!(pool.liquidity >= amount, 100);
pool.liquidity = pool.liquidity - amount;
LoanReceipt { amount }
}
/// Must be called before tx ends
public fun repay(pool: &mut LoanPool, receipt: LoanReceipt) {
pool.liquidity = pool.liquidity + receipt.amount;
}
}
When I integrate this with a borrower’s callback, I don’t let them hold &mut LoanPool
. Instead, they only hold the LoanReceipt, which enforces repayment.
From experience, the worst exploit I’ve seen attempted was double-using the borrowed funds by sneaking re-entrancy into the callback. By not passing mutable references directly, I avoid this class of exploit.
When I design flash loan systems, I focus less on restricting callbacks and more on controlling state flow. For me, the safest design is to track obligations explicitly inside the loan pool object.
Instead of just issuing a receipt, I maintain an outstanding loans map keyed by borrower. That way, the pool itself knows if there’s a pending obligation, and no second loan can be issued until repayment is complete.
Here’s a simplified structure:
module lending::SafePool {
use sui::object::{Self, UID};
use sui::tx_context::TxContext;
use std::vector;
struct LoanPool has key {
id: UID,
liquidity: u64,
active: bool, // prevents re-entrancy
}
public fun init(amount: u64, ctx: &mut TxContext): LoanPool {
LoanPool { id: object::new(ctx), liquidity: amount, active: false }
}
public fun borrow(pool: &mut LoanPool, amount: u64) {
assert!(!pool.active, 100); // lock pool
assert!(pool.liquidity >= amount, 101);
pool.active = true;
pool.liquidity = pool.liquidity - amount;
}
public fun repay(pool: &mut LoanPool, amount: u64) {
pool.liquidity = pool.liquidity + amount;
pool.active = false; // release lock
}
}
By marking the pool as active, I block re-entrancy and multi-loan attempts.
The trade-off is reduced concurrency, but I’d rather sacrifice a bit of throughput than leave an opening for re-entrancy or shared-object lock abuse. In production, I extend this with per-user records to allow multiple independent loans safely.
Coming from a background in Ethereum DeFi, I originally thought about flash loans with the same guardrails—reentrancy locks, checks-effects-interactions. But Sui’s object-centric execution model flips this. The real risk here isn’t arbitrary reentrancy (since Sui enforces object ownership rules), but shared object locks being manipulated if I don’t scope my design tightly.
My optimal design is to:
- Represent liquidity pools as owned objects.
- Hand out a temporary capability (FlashLoanTicket) that only exists within the atomic transaction.
- Use Move’s type system to enforce that the borrowed funds must be returned before the ticket is consumed.
This means the flash loan either succeeds atomically, or it reverts with no state change. No room for “partial exploits.”
Here’s a simple sketch:
module defi::flash_loan {
use sui::coin::{Coin, join, split};
use sui::tx_context::TxContext;
struct FlashLoanTicket has drop {
amount: u64,
}
public entry fun borrow<CoinType>(
pool: &mut Coin<CoinType>,
amount: u64,
ctx: &mut TxContext
): (Coin<CoinType>, FlashLoanTicket) {
assert!(pool.value >= amount, 0);
let loan = split(pool, amount, ctx);
let ticket = FlashLoanTicket { amount };
(loan, ticket)
}
public entry fun repay<CoinType>(
pool: &mut Coin<CoinType>,
loan: Coin<CoinType>,
ticket: FlashLoanTicket
) {
assert!(loan.value >= ticket.amount, 1);
join(pool, loan);
// ticket consumed here, enforcing repayment
}
}
Key insight: The ticket capability makes reentrancy impossible because the loan lifecycle is enforced by type rules, not developer discipline.
So the “optimal” path isn’t a reentrancy guard—it’s leveraging linear types and atomic transactions to force repayment and remove lock exploits altogether.
Weißt du die Antwort?
Bitte melde dich an und teile sie.
Sui is a Layer 1 protocol blockchain designed as the first internet-scale programmable blockchain platform.
Verdiene deinen Anteil an 1000 Sui
Sammle Reputationspunkte und erhalte Belohnungen für deine Hilfe beim Wachstum der Sui-Community.
- So maximieren Sie Ihre Gewinnbeteiligung SUI: SUI Staking vs Liquid Staking615
- Warum benötigt BCS eine genaue Feldreihenfolge für die Deserialisierung, wenn Move-Strukturen benannte Felder haben?65
- Fehler bei der Überprüfung mehrerer Quellen“ in den Veröffentlichungen des Sui Move-Moduls — Automatisierte Fehlerbehebung55
- Sui Move Error - Transaktion kann nicht verarbeitet werden Keine gültigen Gasmünzen für die Transaktion gefunden419
- Sui-Transaktion schlägt fehl: Objekte sind für eine andere Transaktion reserviert410