Skip to main content
Sigvex

Flash Loan Validation Remediation

How to enforce flash loan repayment validation to prevent borrowers from defaulting on loans.

Flash Loan Validation Remediation

Overview

Related Detector: Flash Loan Validation

Flash loan validation vulnerabilities occur when a program lends tokens and then fails to verify that the borrower repaid the loan (plus fees) within the same transaction. The fix is to record the pool balance before lending, then verify it is at least pre_balance + fee after the borrower’s callback completes.

Before (Vulnerable)

pub fn flash_borrow(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
    let pool = &accounts[0];
    let borrower = &accounts[1];

    // Lend tokens
    **pool.try_borrow_mut_lamports()? -= amount;
    **borrower.try_borrow_mut_lamports()? += amount;

    // Borrower callback
    invoke(&callback_ix, accounts)?;

    // MISSING: no repayment check
    Ok(())
}

After (Fixed)

pub fn flash_borrow(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
    let pool = &accounts[0];
    let borrower = &accounts[1];

    // FIXED: record pre-loan balance
    let pre_balance = **pool.try_borrow_lamports()?;

    // FIXED: calculate minimum repayment
    let fee = amount
        .checked_mul(FLASH_FEE_BPS)
        .ok_or(ProgramError::ArithmeticOverflow)?
        .checked_div(10_000)
        .ok_or(ProgramError::ArithmeticOverflow)?
        .max(1);

    // Lend tokens
    **pool.try_borrow_mut_lamports()? -= amount;
    **borrower.try_borrow_mut_lamports()? += amount;

    // Borrower callback
    invoke(&callback_ix, accounts)?;

    // FIXED: verify repayment
    let post_balance = **pool.try_borrow_lamports()?;
    let min_balance = pre_balance
        .checked_add(fee)
        .ok_or(ProgramError::ArithmeticOverflow)?;

    if post_balance < min_balance {
        return Err(ProgramError::InsufficientFunds);
    }
    Ok(())
}

Alternative Mitigations

1. Two-instruction flash loan pattern

Split the flash loan into borrow and repay instructions, with the repay instruction enforcing the invariant:

pub fn flash_borrow(ctx: Context<FlashBorrow>, amount: u64) -> Result<()> {
    let pool = &mut ctx.accounts.pool;

    // Record the loan in pool state
    pool.outstanding_flash_loan = amount;
    pool.flash_loan_fee = calculate_fee(amount);
    pool.flash_loan_borrower = *ctx.accounts.borrower.key;

    // Transfer to borrower
    // ...
    Ok(())
}

pub fn flash_repay(ctx: Context<FlashRepay>) -> Result<()> {
    let pool = &mut ctx.accounts.pool;

    // Verify outstanding loan exists
    require!(pool.outstanding_flash_loan > 0, ErrorCode::NoOutstandingLoan);

    // Verify repayment was made (pool balance check)
    let required = pool.outstanding_flash_loan + pool.flash_loan_fee;
    // ... verify balance

    // Clear loan state
    pool.outstanding_flash_loan = 0;
    pool.flash_loan_fee = 0;
    Ok(())
}

2. CPI callback with program-controlled repayment

Instead of relying on the borrower to repay, use a CPI callback pattern where the program transfers funds back automatically:

pub fn flash_loan(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
    let pool = &accounts[0];
    let borrower = &accounts[1];

    let pre_balance = **pool.try_borrow_lamports()?;

    // Lend
    **pool.try_borrow_mut_lamports()? -= amount;
    **borrower.try_borrow_mut_lamports()? += amount;

    // Borrower callback
    invoke(&callback_ix, accounts)?;

    // Program-controlled repayment: pull funds back
    let repayment = amount + calculate_fee(amount);
    **borrower.try_borrow_mut_lamports()? -= repayment;
    **pool.try_borrow_mut_lamports()? += repayment;

    Ok(())
}

3. Invariant check at instruction end

Use a post-instruction invariant check that verifies pool solvency regardless of what operations occurred:

pub fn verify_pool_invariant(pool: &AccountInfo, expected_min: u64) -> ProgramResult {
    let balance = **pool.try_borrow_lamports()?;
    if balance < expected_min {
        msg!("Pool invariant violated: {} < {}", balance, expected_min);
        return Err(ProgramError::InvalidAccountData);
    }
    Ok(())
}

Common Mistakes

Mistake 1: Checking balance before the callback instead of after

// WRONG: balance checked before callback, not after
let balance = **pool.try_borrow_lamports()?;
assert!(balance >= min_required);  // This checks the pre-lend balance
invoke(&callback_ix, accounts)?;   // Borrower defaults after this

The balance check must occur after all external calls have completed.

Mistake 2: Using the wrong account for balance verification

invoke(&callback_ix, accounts)?;
// WRONG: checking borrower's balance instead of pool's
let borrower_balance = **borrower.try_borrow_lamports()?;

Verify the lending pool’s balance, not the borrower’s.

Mistake 3: Fee calculation that rounds to zero

// WRONG: for small amounts, fee rounds to zero
let fee = amount * FEE_BPS / 10_000;  // fee = 0 when amount < 10000/FEE_BPS

Enforce a minimum fee of at least 1 lamport: .max(1).

Mistake 4: No cap on flash loan amount

// WRONG: no maximum borrow amount
**pool.try_borrow_mut_lamports()? -= amount;  // amount could be pool's entire balance

Cap the flash loan to a percentage of the pool balance to limit exposure.

References