DEV Community

ohmygod
ohmygod

Posted on

Solana Lending Protocol Security: A Deep Dive into Audit Best Practices

Solana Lending Protocol Security: A Deep Dive into Audit Best Practices

What Makes Lending Protocols the Most Dangerous Code in DeFi


Lending protocols sit at the intersection of complexity and capital concentration. They're the backbone of DeFi, yet they account for 43% of all value stolen in protocol exploits. With major lending protocols like Jupiter Lend entering the Solana ecosystem, understanding their unique security challenges has never been more critical.

This guide distills lessons from auditing 20+ lending protocols across EVM and Solana, with specific focus on Solana's unique architecture and the attack vectors that auditors consistently miss.

Why Solana Lending Is Different

Before diving into vulnerabilities, let's understand why Solana lending protocols face unique challenges:

┌─────────────────────────────────────────────────────────────┐
│                    EVM vs Solana Lending                     │
├─────────────────────────────┬───────────────────────────────┤
│           EVM               │            Solana             │
├─────────────────────────────┼───────────────────────────────┤
│ Contract storage            │ Account model                 │
│ msg.sender implicit         │ Explicit signer validation    │
│ Single transaction scope    │ Multi-instruction atomicity   │
│ Reentrancy via callbacks    │ CPI-based reentrancy          │
│ Gas limits                  │ Compute unit limits           │
│ Block.timestamp             │ Clock sysvar                  │
│ Storage slots               │ PDA derivation                │
└─────────────────────────────┴───────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The account model fundamentally changes how we think about state management, authorization, and atomicity. Vulnerabilities that don't exist on EVM emerge on Solana, and vice versa.

Critical Vulnerability Classes in Solana Lending

1. Interest Rate Model Manipulation

Severity: Critical

Lending protocols calculate interest rates based on utilization. Attackers can manipulate utilization to extract value from depositors.

// VULNERABLE: Interest rate calculation without time-weighting
pub fn calculate_interest_rate(
    total_deposits: u64,
    total_borrows: u64,
) -> Result<u64> {
    let utilization = total_borrows
        .checked_mul(PRECISION)?
        .checked_div(total_deposits)?;

    // Linear model - easily manipulated
    let rate = BASE_RATE + utilization.checked_mul(MULTIPLIER)?;
    Ok(rate)
}
Enter fullscreen mode Exit fullscreen mode

Attack Scenario:

  1. Attacker takes massive flash loan
  2. Deposits flash loan to crash utilization
  3. Opens borrow position at artificially low rate
  4. Withdraws to spike utilization
  5. Legitimate depositors suffer inflated rates
  6. Repays flash loan, keeps the spread

The Fix: Time-Weighted Average Utilization (TWAU)

pub struct InterestRateModel {
    pub cumulative_utilization: u128,
    pub last_update_slot: u64,
    pub twau_window: u64,  // Slots for averaging
}

pub fn update_interest_rate(
    model: &mut InterestRateModel,
    current_utilization: u64,
    current_slot: u64,
) -> Result<u64> {
    let slots_elapsed = current_slot
        .checked_sub(model.last_update_slot)
        .ok_or(ErrorCode::TimeTravel)?;

    // Accumulate time-weighted utilization
    model.cumulative_utilization = model.cumulative_utilization
        .checked_add(
            (current_utilization as u128)
                .checked_mul(slots_elapsed as u128)?
        )?;

    // Calculate TWAU
    let twau = model.cumulative_utilization
        .checked_div(model.twau_window as u128)?;

    model.last_update_slot = current_slot;

    // Use TWAU for rate calculation
    calculate_rate_from_utilization(twau as u64)
}
Enter fullscreen mode Exit fullscreen mode

2. Oracle Price Manipulation

Severity: Critical

Every lending protocol's solvency depends on accurate price feeds. Oracle manipulation is the #1 attack vector.

// VULNERABLE: Direct Pyth price read without validation
pub fn get_collateral_value(
    ctx: Context<GetValue>,
    collateral_amount: u64,
) -> Result<u64> {
    let price_feed = &ctx.accounts.price_feed;
    let price = get_price_from_pyth(price_feed)?;

    // No staleness check!
    // No confidence interval check!
    // No price deviation check!

    Ok(collateral_amount.checked_mul(price)?)
}
Enter fullscreen mode Exit fullscreen mode

Multi-Layered Oracle Security:

use pyth_solana_receiver_sdk::price_update::PriceUpdateV2;

pub struct OracleConfig {
    pub max_staleness_slots: u64,
    pub max_confidence_ratio: u64,    // e.g., 5% = 500 basis points
    pub max_deviation_from_twap: u64, // e.g., 10% = 1000 basis points
}

pub fn get_validated_price(
    price_update: &PriceUpdateV2,
    config: &OracleConfig,
    twap_oracle: &Account<TwapOracle>,
    current_slot: u64,
) -> Result<u64> {
    // 1. Extract price and metadata
    let price_data = price_update
        .get_price_no_older_than(
            &Clock::get()?,
            config.max_staleness_slots,
        )?;

    let price = price_data.price;
    let confidence = price_data.conf;
    let publish_slot = price_data.publish_time;

    // 2. Staleness check
    let slots_elapsed = current_slot
        .checked_sub(publish_slot)
        .ok_or(ErrorCode::FuturePrice)?;

    require!(
        slots_elapsed <= config.max_staleness_slots,
        ErrorCode::StaleOracle
    );

    // 3. Confidence interval check
    let confidence_ratio = confidence
        .checked_mul(10000)?
        .checked_div(price.abs() as u64)?;

    require!(
        confidence_ratio <= config.max_confidence_ratio,
        ErrorCode::OracleConfidenceTooWide
    );

    // 4. TWAP deviation check
    let twap_price = twap_oracle.get_twap()?;
    let deviation = if price > twap_price as i64 {
        (price - twap_price as i64) as u64
    } else {
        (twap_price as i64 - price) as u64
    };

    let deviation_ratio = deviation
        .checked_mul(10000)?
        .checked_div(twap_price)?;

    require!(
        deviation_ratio <= config.max_deviation_from_twap,
        ErrorCode::PriceDeviationTooHigh
    );

    // 5. Use conservative price for collateral
    // Price - confidence for collateral valuation
    // Price + confidence for debt valuation
    Ok((price - confidence as i64) as u64)
}
Enter fullscreen mode Exit fullscreen mode

3. Liquidation Logic Flaws

Severity: Critical

Liquidation is the most complex operation in lending protocols. Flaws here lead to bad debt accumulation or unfair liquidations.

// VULNERABLE: Missing health factor recalculation
pub fn liquidate(
    ctx: Context<Liquidate>,
    repay_amount: u64,
) -> Result<()> {
    let position = &mut ctx.accounts.position;
    let liquidator = &ctx.accounts.liquidator;

    // Check if position is liquidatable
    require!(
        position.health_factor < LIQUIDATION_THRESHOLD,
        ErrorCode::PositionHealthy
    );

    // Calculate collateral to seize
    let collateral_value = repay_amount
        .checked_mul(LIQUIDATION_BONUS)?;

    // Transfer debt token from liquidator
    transfer_tokens(liquidator, position, repay_amount)?;

    // Transfer collateral to liquidator
    // BUG: What if collateral amount > position's collateral?
    transfer_collateral(position, liquidator, collateral_value)?;

    // BUG: Position debt reduced but health factor not checked
    // Position could still be undercollateralized after partial liquidation
    position.debt -= repay_amount;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Attack Vectors:

  1. Dust attack: Leave tiny collateral that costs more gas to liquidate than it's worth
  2. Self-liquidation: Liquidate your own position to extract protocol reserves
  3. Flash loan liquidation: Manipulate oracle → liquidate → restore oracle

Robust Liquidation Implementation:

pub fn liquidate(
    ctx: Context<Liquidate>,
    max_repay_amount: u64,
) -> Result<()> {
    let position = &mut ctx.accounts.position;
    let market = &ctx.accounts.market;

    // 1. Snapshot current state for atomic validation
    let pre_collateral = position.collateral_amount;
    let pre_debt = position.debt_amount;

    // 2. Get validated prices
    let collateral_price = get_validated_price(
        &ctx.accounts.collateral_oracle,
        &market.oracle_config,
    )?;
    let debt_price = get_validated_price(
        &ctx.accounts.debt_oracle,
        &market.oracle_config,
    )?;

    // 3. Calculate health factor with conservative prices
    let collateral_value = pre_collateral
        .checked_mul(collateral_price)?
        .checked_mul(market.collateral_factor)?
        .checked_div(PRECISION)?;

    let debt_value = pre_debt
        .checked_mul(debt_price)?;

    let health_factor = collateral_value
        .checked_mul(PRECISION)?
        .checked_div(debt_value)?;

    require!(
        health_factor < LIQUIDATION_THRESHOLD,
        ErrorCode::PositionHealthy
    );

    // 4. Calculate maximum liquidatable amount
    // Close factor limits how much can be liquidated at once
    let max_liquidatable = pre_debt
        .checked_mul(market.close_factor)?
        .checked_div(PRECISION)?;

    let repay_amount = std::cmp::min(max_repay_amount, max_liquidatable);

    // 5. Calculate collateral to seize
    let collateral_to_seize = repay_amount
        .checked_mul(debt_price)?
        .checked_mul(PRECISION + market.liquidation_bonus)?
        .checked_div(collateral_price)?
        .checked_div(PRECISION)?;

    // 6. Ensure position has enough collateral
    require!(
        collateral_to_seize <= pre_collateral,
        ErrorCode::InsufficientCollateral
    );

    // 7. Execute transfers atomically
    execute_liquidation(
        ctx,
        repay_amount,
        collateral_to_seize,
    )?;

    // 8. Post-liquidation health check
    let post_health = calculate_health_factor(
        position.collateral_amount,
        position.debt_amount,
        collateral_price,
        debt_price,
        market.collateral_factor,
    )?;

    // Either position should be healthy or fully liquidated
    require!(
        post_health >= MINIMUM_HEALTH_FACTOR || position.debt_amount == 0,
        ErrorCode::PositionStillUnhealthy
    );

    emit!(LiquidationEvent {
        position: position.key(),
        liquidator: ctx.accounts.liquidator.key(),
        repay_amount,
        collateral_seized: collateral_to_seize,
        health_factor_before: health_factor,
        health_factor_after: post_health,
    });

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

4. Cross-Program Invocation (CPI) Vulnerabilities

Severity: Critical

Solana lending protocols interact with token programs, oracles, and DEXes. Each CPI is an attack surface.

// VULNERABLE: Unvalidated program ID in CPI
pub fn swap_collateral(ctx: Context<SwapCollateral>) -> Result<()> {
    let swap_program = &ctx.accounts.swap_program;

    // Attacker could pass a malicious program that:
    // - Reports inflated output amounts
    // - Doesn't actually transfer tokens
    // - Calls back into our protocol

    let cpi_accounts = SwapAccounts {
        source: ctx.accounts.collateral.to_account_info(),
        destination: ctx.accounts.new_collateral.to_account_info(),
    };

    let cpi_ctx = CpiContext::new(swap_program.to_account_info(), cpi_accounts);
    swap::swap(cpi_ctx, amount)?;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Secure CPI Pattern:

use anchor_lang::prelude::*;

// Whitelist of allowed DEX programs
pub const ALLOWED_SWAP_PROGRAMS: &[Pubkey] = &[
    pubkey!("JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"), // Jupiter
    pubkey!("whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc"), // Orca
];

#[derive(Accounts)]
pub struct SwapCollateral<'info> {
    #[account(
        constraint = ALLOWED_SWAP_PROGRAMS.contains(&swap_program.key())
            @ ErrorCode::UnauthorizedSwapProgram
    )]
    pub swap_program: UncheckedAccount<'info>,

    #[account(mut)]
    pub collateral: Account<'info, TokenAccount>,

    // Validate collateral account state before and after
    // ...
}

pub fn swap_collateral(ctx: Context<SwapCollateral>, amount: u64) -> Result<()> {
    // Snapshot balances before CPI
    let source_balance_before = ctx.accounts.collateral.amount;

    // Execute CPI
    execute_swap(ctx, amount)?;

    // Reload and verify balances after CPI
    ctx.accounts.collateral.reload()?;
    let source_balance_after = ctx.accounts.collateral.amount;

    // Verify expected token movement
    let tokens_sent = source_balance_before
        .checked_sub(source_balance_after)
        .ok_or(ErrorCode::UnexpectedBalanceChange)?;

    require!(
        tokens_sent == amount,
        ErrorCode::PartialSwapExecution
    );

    // Verify received tokens (similar checks)
    // ...

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

5. Account Validation Gaps

Severity: High

Lending protocols manage many account types. Missing validation creates attack vectors.

// VULNERABLE: Market account not validated against position
#[derive(Accounts)]
pub struct Borrow<'info> {
    #[account(mut)]
    pub position: Account<'info, Position>,

    #[account(mut)]
    pub market: Account<'info, Market>,  // Which market?

    #[account(mut)]
    pub reserve: Account<'info, TokenAccount>,
}
Enter fullscreen mode Exit fullscreen mode

Attack: Attacker passes their own position with a different market that has favorable parameters (higher LTV, lower interest).

Complete Validation:

#[derive(Accounts)]
pub struct Borrow<'info> {
    #[account(
        mut,
        seeds = [
            b"position",
            market.key().as_ref(),
            owner.key().as_ref(),
        ],
        bump = position.bump,
        constraint = position.market == market.key() @ ErrorCode::MarketMismatch,
        constraint = position.owner == owner.key() @ ErrorCode::OwnerMismatch,
    )]
    pub position: Account<'info, Position>,

    #[account(
        mut,
        seeds = [b"market", market.base_mint.as_ref()],
        bump = market.bump,
    )]
    pub market: Account<'info, Market>,

    #[account(
        mut,
        constraint = reserve.key() == market.reserve @ ErrorCode::InvalidReserve,
        constraint = reserve.mint == market.base_mint @ ErrorCode::MintMismatch,
    )]
    pub reserve: Account<'info, TokenAccount>,

    pub owner: Signer<'info>,
}
Enter fullscreen mode Exit fullscreen mode

Lending Protocol Audit Methodology

When auditing Solana lending protocols, I follow this systematic approach:

Phase 1: Economic Model Review (Before Code)

□ Interest rate model
  - Can utilization be manipulated?
  - Are rates bounded?
  - How does compounding work?

□ Collateral factors
  - Are LTVs appropriate per asset?
  - Is there asset-specific risk pricing?
  - How are liquidation thresholds set?

□ Liquidation economics
  - Is liquidation profitable for liquidators?
  - Are bonuses appropriate?
  - Is there bad debt socialization?

□ Oracle dependencies
  - What oracles are used?
  - What's the fallback strategy?
  - How is manipulation prevented?
Enter fullscreen mode Exit fullscreen mode

Phase 2: Access Control Mapping

Create a matrix of all privileged operations:

┌─────────────────────┬───────┬───────┬────────┬──────────┐
│ Operation           │ Admin │ Owner │ Anyone │ Timelocked│
├─────────────────────┼───────┼───────┼────────┼──────────┤
│ Update oracle       │   ✓   │       │        │    ✓     │
│ Update LTV          │   ✓   │       │        │    ✓     │
│ Pause market        │   ✓   │       │        │          │
│ Deposit             │       │   ✓   │   ✓    │          │
│ Borrow              │       │   ✓   │        │          │
│ Liquidate           │       │       │   ✓    │          │
│ Withdraw reserves   │   ✓   │       │        │    ✓     │
└─────────────────────┴───────┴───────┴────────┴──────────┘
Enter fullscreen mode Exit fullscreen mode

Phase 3: Invariant Definition

For lending protocols, these invariants must always hold:

// Core solvency invariants
assert!(total_deposits >= total_borrows);
assert!(total_collateral_value >= total_debt_value * min_collateral_ratio);

// Per-position invariants
assert!(position.health_factor >= 1.0 || position.is_being_liquidated);
assert!(position.collateral_value >= position.debt_value * position.ltv);

// Reserve invariants
assert!(reserve_balance >= total_deposits - total_borrows);
assert!(protocol_fees <= accumulated_interest);

// Interest invariants
assert!(supply_apy <= borrow_apy);  // Protocol must be profitable
assert!(cumulative_borrow_index >= previous_borrow_index);  // Monotonic
Enter fullscreen mode Exit fullscreen mode

Phase 4: Attack Surface Enumeration

For each entry point, enumerate attack vectors:

Function Flash Loan Oracle Manipulation Reentrancy Access Control
deposit Check atomicity N/A CPI guard Owner only
borrow Utilization manipulation Price check CPI guard Owner + health
withdraw Liquidity check Price check CPI guard Owner only
liquidate Self-liquidation Price manipulation CPI guard Anyone + health

Common Audit Findings in Lending Protocols

Based on my experience, here are the most frequent issues:

1. Interest Accrual Precision Loss

// PROBLEMATIC: Interest lost due to integer division
let interest = principal * rate / YEAR_IN_SECONDS;

// BETTER: Compound interest with precision
let accrued = principal
    .checked_mul(rate_per_second)?
    .checked_mul(seconds_elapsed)?
    .checked_div(PRECISION)?;
Enter fullscreen mode Exit fullscreen mode

2. Unprotected Market Initialization

// VULNERABLE: Anyone can initialize markets
pub fn initialize_market(ctx: Context<InitMarket>) -> Result<()>

// SECURE: Admin-only with validation
#[access_control(admin_only(&ctx.accounts.admin))]
pub fn initialize_market(ctx: Context<InitMarket>) -> Result<()>
Enter fullscreen mode Exit fullscreen mode

3. Missing Minimum Position Thresholds

// Without minimums, dust positions become attack vectors
require!(
    borrow_amount >= market.minimum_borrow,
    ErrorCode::BorrowTooSmall
);
require!(
    position.collateral_value >= market.minimum_collateral,
    ErrorCode::CollateralTooSmall
);
Enter fullscreen mode Exit fullscreen mode

4. Unbounded Loops in Interest Calculation

// DANGEROUS: Loop over all positions
for position in all_positions.iter() {
    accrue_interest(position)?;  // Compute exhaustion risk
}

// SAFER: Lazy interest accrual per position
pub fn touch_position(position: &mut Position) {
    let elapsed = Clock::get()?.slot - position.last_update;
    if elapsed > 0 {
        accrue_interest_single(position, elapsed)?;
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing Framework for Lending Protocols

Foundational Test Suite

#[cfg(test)]
mod invariant_tests {
    use super::*;

    #[test]
    fn test_solvency_after_random_operations() {
        let mut protocol = setup_protocol();
        let mut rng = thread_rng();

        for _ in 0..1000 {
            let operation = rng.gen_range(0..5);
            match operation {
                0 => random_deposit(&mut protocol, &mut rng),
                1 => random_borrow(&mut protocol, &mut rng),
                2 => random_withdraw(&mut protocol, &mut rng),
                3 => random_repay(&mut protocol, &mut rng),
                4 => random_liquidate(&mut protocol, &mut rng),
                _ => unreachable!(),
            }

            // Invariant check after every operation
            assert!(protocol.is_solvent());
            assert!(protocol.total_deposits() >= protocol.total_borrows());
        }
    }

    #[test]
    fn test_flash_loan_resistance() {
        let protocol = setup_protocol();

        // Simulate flash loan attack
        let flash_amount = u64::MAX / 2;

        // Attempt manipulation
        let result = protocol.simulate_attack(|p| {
            p.deposit(flash_amount)?;
            p.borrow(flash_amount * 90 / 100)?;
            p.withdraw(flash_amount)?;
            Ok(())
        });

        assert!(result.is_err() || protocol.attacker_profit() == 0);
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion: Preparing for Production Audits

Solana lending protocols represent some of the most complex and valuable code in DeFi. As Jupiter Lend and other major protocols enter the ecosystem, the demand for rigorous security reviews will only increase.

Key Takeaways:

  1. Economic security > Code security — A technically perfect protocol with flawed economics will still be exploited
  2. Oracle security is paramount — Multi-layered validation with staleness, confidence, and TWAP deviation checks
  3. Liquidation logic requires extreme care — It's where complexity and value concentration intersect
  4. CPI calls are trust boundaries — Validate programs, verify balances, guard against callbacks
  5. Invariant testing finds what unit tests miss — Define what must always be true and test it relentlessly

The protocols that survive the next market cycle will be those that treated security as a first-class concern from day one. The tools and patterns exist. The question is whether teams have the discipline to use them.


For professional Solana security audits or protocol reviews, reach out at @thedreamwork.

Tags: #Solana #DeFi #Security #SmartContracts #Lending #Jupiter #Audit #Blockchain #Web3

Top comments (0)