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 │
└─────────────────────────────┴───────────────────────────────┘
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)
}
Attack Scenario:
- Attacker takes massive flash loan
- Deposits flash loan to crash utilization
- Opens borrow position at artificially low rate
- Withdraws to spike utilization
- Legitimate depositors suffer inflated rates
- 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)
}
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)?)
}
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)
}
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(())
}
Attack Vectors:
- Dust attack: Leave tiny collateral that costs more gas to liquidate than it's worth
- Self-liquidation: Liquidate your own position to extract protocol reserves
- 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(())
}
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(())
}
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(())
}
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>,
}
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>,
}
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?
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 │ ✓ │ │ │ ✓ │
└─────────────────────┴───────┴───────┴────────┴──────────┘
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
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)?;
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<()>
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
);
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)?;
}
}
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);
}
}
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:
- Economic security > Code security — A technically perfect protocol with flawed economics will still be exploited
- Oracle security is paramount — Multi-layered validation with staleness, confidence, and TWAP deviation checks
- Liquidation logic requires extreme care — It's where complexity and value concentration intersect
- CPI calls are trust boundaries — Validate programs, verify balances, guard against callbacks
- 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)