Move.

Bài viết

Chia sẻ kiến thức của bạn.

Sato$hii.
Aug 29, 2025
Hỏi đáp Chuyên Gia

How do i safely implement a flash-loan facility in Move?

How do I safely implement a flash-loan facility in Move that is immune to reentrancy and oracle-manipulation within a single transaction?

  • Move CLI
  • Move
  • Smart Contract
  • Move Module
0
2
Chia sẻ
Bình luận
.

Câu trả lời

2
Kurosakisui.
Aug 29 2025, 21:34

For flash loans lending large amounts in a single transaction and expecting repayment before transaction end is required. The risk: borrower contracts can reenter the lender via callbacks and manipulate on-chain state (or oracles) to profit. Move’s resource semantics and strict borrow rules help but you still need an explicit pattern.

Strategy

  1. Use atomic check-and-settle: Lender transfers a temporary loan object (linear) and expects a callback that returns a repayment receipt or the loan object itself before end of transaction.
  2. Avoid any external mutable global state reads inside the flash loan liability window — instead snapshot all external prices/ancillary data before lending, and use those snapshots to compute required repayment.
  3. Use loan tokens (a linear placeholder) that the borrower must return or burn in the same transaction; the lender verifies return before allowing state changes that depend on the loan.
  4. For oracles: either use an on-chain oracle with finalized price (not mutable within TX) or require borrowers to post collateral that is computed from a snapshot.

Working-style code (loan object pattern)

module 0xA::flash_loan {
    use std::signer;

    struct Coin has store, drop {
        value: u128
    }

    // LoanTicket is a linear token representing outstanding loan.
    struct LoanTicket has key {}

    // Lender state records outstanding loans count (for auditing)
    struct Lender has key {
        liquidity: u128,
        outstanding: u64,
        admin: address
    }

    public fun init(admin: &signer, initial_liquidity: u128) {
        move_to(admin, Lender { liquidity: initial_liquidity, outstanding: 0u64, admin: signer::address_of(admin) });
    }

    // Core flash loan: lender gives Coin and a LoanTicket; borrower must return coin + fee
    // before transaction ends. Because Move enforces linearity, if LoanTicket is not consumed
    // the transaction will abort on resource-leak at end (or you design explicit check).
    public fun request_flash_loan(borrower: &signer, amount: u128, fee_basis_points: u64) acquires Lender {
        let lender_addr = 0xA; // assume lender stored at 0xA
        let l = borrow_global_mut<Lender>(lender_addr);
        assert!(l.liquidity >= amount, 1);

        // Snapshot important data (e.g., oracle price) — implement as read-only in practice
        let price_snapshot = get_price_snapshot();

        // Update lender accounting before handing out funds
        l.liquidity = l.liquidity - amount;
        l.outstanding = l.outstanding + 1;

        // Create loan coin and ticket and transfer to borrower
        let loan_coin = Coin { value: amount };
        let ticket = LoanTicket {};
        // transfer to borrower — in Move, to move to borrower we'd call move_to(borrower, ...)
        move_to(borrower, loan_coin);
        move_to(borrower, ticket);

        // Now borrower must perform arbitrary operations (in the same TX) and return:
        // - the original loan coin + fee, and
        // - the LoanTicket (or the loan coin is burned and ticket consumed)
        // Enforcement: lender provides a `settle_loan` entrypoint that borrower must call
        // before finishing TX. Because Move requires linear resource consumption, if ticket
        // remains in borrower's account when the TX finishes the prover (or runtime) will detect it.
    }

    public fun settle_loan(borrower: &signer, coin: Coin, ticket: LoanTicket, fee_basis_points: u64) acquires Lender {
        // Check repayment amount matches expectation (use snapshot logic if necessary)
        let repay_amount = coin.value;
        let fee = (repay_amount * (fee_basis_points as u128)) / 10000u128;
        // recompute expected minimal repayment using snapshots (omitted)
        // Accept repayment
        let lender_addr = 0xA;
        let l = borrow_global_mut<Lender>(lender_addr);
        l.liquidity = l.liquidity + repay_amount;
        l.outstanding = l.outstanding - 1;
        // consume ticket and coin naturally by function arguments (linear resources consumed)
    }

    fun get_price_snapshot(): u128 {
        // Implement oracle snapshot logic; MUST be read-only and not mutable by borrower within the TX.
        100u128
    }
}

Why this resists reentrancy & oracle manipulation

  • The LoanTicket is a linear resource that must be returned/consumed. If the borrower tries to reenter and leave the ticket unconsumed, the runtime will detect resource misuse at end-of-tx (or an explicit check ensures ticket is consumed).
  • Snapshotting critical external data before handing funds prevents borrowers from manipulating those reads during their execution because you use snapshots for settlement.
  • All checks/settlement happen in the same transaction: either borrower returns funds + ticket (success) or resources remain unbalanced and the transaction aborts (atomic revert).

Practical considerations

  • Ensure oracle snapshots come from an oracle service that cannot be modified within the same transaction (e.g., block-finalized data or on-chain oracle with restricted updates).
  • Add explicit assert checks in settle_loan to ensure repayment satisfies formula based on the snapshot.
  • For Sui/Aptos differences: Sui’s object model makes the loan-ticket pattern natural as a distinct object ID; on Aptos use account resources and the same linear guarantees.

2
Bình luận
.
Michael Ace.
Aug 29 2025, 21:55

Use a three-part pattern:

  1. Lender mints a temporary escrow capability token (EscrowCap) and a snapshot receipt capturing relevant external state (oracle snapshots, pool reserves, etc.).
  2. The borrower must return the EscrowCap plus a SettlementBond (or burn a bond) that economically ensures correct behavior if the borrower tries to cheat.
  3. Use a PostCommitVerifier — a small on-chain verifier that checks that the borrower returned required tokens before allowing finalization/settlement effects that depend on the loan to be visible to others.

This pattern complements linearity with economic deterrence and explicit snapshots of external state.

Why this helps beyond the previous pattern

  • Snapshot receipts prevent oracle-manipulation because settlement always references an immutable snapshot object.
  • A SettlementBond (collateral) provides on-chain slashing if the borrower fails to return funds; you don't depend only on resource-leak detection.
  • PostCommitVerifier ensures any side-effects that should be conditional on successful repayment are withheld until the borrower completes settlement.

Working-style Move sketch

module 0xA::flash2 {
    use std::signer;
    use std::vector;

    struct Coin has store, drop { value: u128 }
    struct EscrowCap has key {}        // linear token given to borrower
    struct Snapshot has key { oracle_price: u128, pool_reserve: u128 } // immutable snapshot object
    struct SettlementBond has key { value: u128 } // collateral locked by borrower

    struct Lender has key { balance: u128, admin: address }

    public fun init(admin: &signer, liquidity: u128) {
        move_to(admin, Lender { balance: liquidity, admin: signer::address_of(admin) });
    }

    /// Lender issues a flash loan: creates Snapshot and EscrowCap and transfers them to borrower.
    /// Borrower must return loan + EscrowCap and either return bond or get slashed.
    public fun issue_loan(lender: &signer, borrower: &signer, amount: u128, bond_amount: u128) acquires Lender {
        let l = borrow_global_mut<Lender>(signer::address_of(lender));
        assert!(l.balance >= amount, 1);

        // create snapshot now (immutable object) — snapshot logic must use deterministic data
        let snap = Snapshot { oracle_price: read_oracle(), pool_reserve: l.balance };

        // create escrow cap and transfer to borrower
        let cap = EscrowCap {};
        let bond = SettlementBond { value: bond_amount };

        // move coin, cap, snapshot, bond to borrower storage
        let loan_coin = Coin { value: amount };
        move_to(borrower, loan_coin);
        move_to(borrower, cap);
        move_to(borrower, snap);
        move_to(borrower, bond);

        // decrease lender balance (funds out)
        l.balance = l.balance - amount;
    }

    /// Borrower must call settle() to return coin and cap; lender can call `finalize_if_settled`.
    public fun settle(borrower: &signer, coin: Coin, cap: EscrowCap, snap: Snapshot, bond: SettlementBond) acquires Lender {
        // Verify snapshot still accepted (snapshot based invariants)
        let expected_fee = compute_fee(coin.value, snap.oracle_price);
        let repayment = coin.value + expected_fee;

        // Accept coin: return to lender balance
        let lender_addr = 0xA; // or stored in Lender.admin
        let l = borrow_global_mut<Lender>(lender_addr);
        l.balance = l.balance + repayment;
        // consume cap, snap, bond (linear resources) to mark settlement
        // If settlement correct, bond is returned to borrower (or burned and a receipt issued).
        // For demo, we burn the bond: its consumption indicates successful settlement.
    }

    /// Lender or anyone can call this to finalize effects that depend on loan being repaid.
    /// If the borrower failed to settle, bond is forfeited and slashed into lender balance.
    public fun finalize_unsettled(lender: &signer, borrower_addr: address) acquires Lender {
        // check whether borrower's escrows remain unconsumed (exists<Snapshot> or EscrowCap)
        if (exists<Snapshot>(borrower_addr) || exists<EscrowCap>(borrower_addr)) {
            // borrower did not settle — slash bond if exists and credit lender
            if (exists<SettlementBond>(borrower_addr)) {
                let bond = move_from<SettlementBond>(borrower_addr);
                let l = borrow_global_mut<Lender>(signer::address_of(lender));
                l.balance = l.balance + bond.value;
            }
            // optionally revert borrower's unpaid actions by refusing to finalize dependent state
        }
    }

    fun read_oracle(): u128 { 100u128 } // placeholder
    fun compute_fee(amount: u128, _price: u128): u128 { (amount / 1000u128) } // 0.1% fee
}

Practical notes

  • The snapshot object is immutable and part of the borrower's state — attacker cannot change it after receipt.
  • The bond enforces economic consequences if the borrower parks the EscrowCap and never calls settle.
  • finalize_unsettled can be called by anyone after a grace period; you can strengthen this pattern with explicit time locks (epoch counters) to allow challenges.
  • On Sui, model Snapshot and EscrowCap as distinct objects; object existence checks are cheap and map well to Sui’s object model.

0
Bình luận
.

Bạn có biết câu trả lời không?

Hãy đăng nhập và chia sẻ nó.

Move is an executable bytecode language used to implement custom transactions and smart contracts.

242Bài viết541Câu trả lời
Sui.X.Peera.

Kiếm phần của bạn từ 1000 Sui

Tích lũy điểm danh tiếng và nhận phần thưởng khi giúp cộng đồng Sui phát triển.

Chiến dịch phần thưởngTháng Tám