A security researcher's field notes from auditing next-gen Solana lending protocols
Tick-based lending is having its moment. Inspired by Uniswap V3's concentrated liquidity model, a new wave of Solana lending protocols are replacing traditional pool-based architectures with tick systems — discrete price intervals that enable concentrated lending positions and granular liquidation.
The pitch is compelling: capital efficiency goes up, liquidation becomes more surgical, and LPs can target specific price ranges. But having just completed a deep audit of a major Solana tick-based lending protocol (40,000+ lines of Rust), I can tell you the security surface is dramatically larger than traditional lending pools.
Here are five critical attack vectors I've found that most auditors overlook.
1. The Withdrawal Limit Bypass: Death by a Thousand Cuts
Traditional lending protocols enforce withdrawal limits — caps on how much liquidity can leave in a given epoch. This prevents bank runs and protects borrowers.
Tick-based protocols typically inherit this pattern. But here's the catch: partial withdrawals within the same block can bypass aggregate limits.
// Vulnerable pattern: per-call limit check
fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let remaining_limit = ctx.accounts.pool.withdrawal_limit
- ctx.accounts.pool.withdrawn_this_epoch;
require!(amount <= remaining_limit, ErrorCode::LimitExceeded);
// Process withdrawal...
ctx.accounts.pool.withdrawn_this_epoch += amount;
Ok(())
}
The issue: An attacker with a large position can split their withdrawal across multiple instructions in a single transaction. If the limit is 100 SOL per epoch, they submit 10 withdrawals of 10 SOL each. Each individual call passes the limit check because withdrawn_this_epoch may not reflect pending same-tx state correctly, or the limit check uses stale data.
The Fix: Track cumulative withdrawals atomically. Better yet, implement a two-phase withdrawal with a mandatory delay:
fn request_withdrawal(ctx: Context<RequestWithdraw>, amount: u64) -> Result<()> {
let clock = Clock::get()?;
ctx.accounts.position.pending_withdrawal = amount;
ctx.accounts.position.withdrawal_available_at =
clock.unix_timestamp + WITHDRAWAL_DELAY;
Ok(())
}
fn claim_withdrawal(ctx: Context<ClaimWithdraw>) -> Result<()> {
let clock = Clock::get()?;
require!(
clock.unix_timestamp >= ctx.accounts.position.withdrawal_available_at,
ErrorCode::TooEarly
);
// Now process with current-epoch limit check
}
2. Circular Oracle Manipulation via Flash Loans
Tick-based lending protocols need price oracles to determine which ticks are active, calculate health factors, and trigger liquidations. Many Solana protocols implement internal oracles — using their own pool's exchange rates or token prices derived from on-chain state.
This creates a devastating circular dependency: the protocol's own TVL influences the price feed that determines liquidation thresholds.
Flash Loan → Inflate Pool TVL → Oracle reports inflated price
→ Borrow against inflated collateral → Repay flash loan → Profit
I found a variant where mSOL/jitoSOL stake pool oracles could be manipulated because they read on-chain delegation data that's modifiable within the same transaction window. The attack:
- Flash loan large SOL amount
- Stake into validator pool (inflating stake pool exchange rate)
- Borrow against the inflated mSOL price
- Unstake and repay flash loan
- Walk away with unbacked debt
The Fix: Never use on-chain state that can be modified in the same transaction as a price input. Enforce TWAP (Time-Weighted Average Price) with a minimum observation window, or use external oracles (Pyth, Switchboard) with strict staleness checks:
fn validate_oracle_price(oracle: &OracleData) -> Result<u64> {
let clock = Clock::get()?;
let age = clock.unix_timestamp - oracle.last_update_timestamp;
// Reject prices older than 60 seconds
require!(age <= 60, ErrorCode::StaleOracle);
// Reject if confidence interval is too wide (>2%)
let confidence_ratio = oracle.confidence * 100 / oracle.price;
require!(confidence_ratio <= 2, ErrorCode::LowConfidenceOracle);
Ok(oracle.price)
}
3. Debt Factor Underflow: The Permanent Bad Debt Trap
In tick-based systems, each tick maintains a debt_factor — a running multiplier that tracks how much debt has accumulated. When liquidation occurs, the debt factor increases to redistribute bad debt across remaining positions in the tick.
But what happens when the debt factor calculation underflows?
// Dangerous pattern
let new_debt_factor = current_debt_factor
.checked_sub(liquidated_debt_share)
.unwrap_or(0); // ← Silent underflow to zero!
If liquidated_debt_share exceeds current_debt_factor due to precision loss from repeated partial liquidations, the debt factor drops to zero. This permanently blocks further liquidations on that tick because the protocol thinks there's no debt to liquidate.
Result: permanent bad debt that's impossible to clear, slowly poisoning the protocol's solvency.
The Fix: Use saturating arithmetic with explicit bad debt tracking:
fn process_liquidation(tick: &mut Tick, liquidated_amount: u128) -> Result<()> {
if liquidated_amount > tick.total_debt {
// Track bad debt explicitly instead of underflowing
let bad_debt = liquidated_amount - tick.total_debt;
tick.total_debt = 0;
tick.accumulated_bad_debt += bad_debt;
emit!(BadDebtEvent { tick_id: tick.id, amount: bad_debt });
} else {
tick.total_debt -= liquidated_amount;
}
Ok(())
}
4. Zero-Divisor Debt Erasure in Math Libraries
Tick-based protocols do a lot of math. Exchange rate calculations, debt scaling, tick-to-price conversions — all require custom big-number arithmetic. The most dangerous pattern I've found:
/// Multiply then divide: (a * b) / c
fn mul_div(a: u128, b: u128, c: u128) -> u128 {
if c == 0 {
return 0; // ← THIS ERASES USER DEBT
}
// ... normal calculation
}
When c (the divisor) is zero due to an empty tick or uninitialized state, the function silently returns zero instead of reverting. If this function calculates a user's scaled debt, their debt disappears.
An attacker can trigger this by:
- Creating a position in a tick
- Manipulating the tick to have zero total supply (via dust amounts and rounding)
- Calling any function that recalculates their debt position
- Debt reads as zero — free money
The Fix: Always revert on zero divisor in financial math:
fn mul_div(a: u128, b: u128, c: u128) -> Result<u128> {
require!(c > 0, MathError::DivisionByZero);
let result = (a as u256)
.checked_mul(b as u256)
.ok_or(MathError::Overflow)?
.checked_div(c as u256)
.ok_or(MathError::DivisionByZero)?;
u128::try_from(result).map_err(|_| MathError::Overflow)
}
5. Tick ID Overflow: The Silent Position Killer
Every tick in a tick-based system has a unique ID, typically auto-incremented. On Solana, where accounts have limited space, protocols often use u32 for tick IDs to save rent.
pub struct TickRegistry {
pub next_tick_id: u32, // Wraps at 4,294,967,295
// ...
}
At 4.29 billion ticks, the counter wraps to zero, overwriting existing tick data. While this seems theoretical, consider that:
- Each user position creates tick entries
- Liquidations create new ticks
- Automated strategies (bots) can create thousands of ticks per day
- At 1000 ticks/day, overflow takes ~11,700 years... but at 100,000/day (realistic for popular protocols), it's 117 years
- More critically: partial overflow at
u16boundaries (65,535) can cause issues if any downstream code truncates
The real danger isn't hitting u32::MAX — it's that intermediate calculations might truncate tick IDs, causing position collisions where two users share a tick they shouldn't.
The Fix: Use u64 for IDs (practically infinite), validate no wrapping occurs, and never truncate IDs in downstream logic:
pub fn create_tick(registry: &mut TickRegistry) -> Result<u64> {
let tick_id = registry.next_tick_id;
registry.next_tick_id = tick_id
.checked_add(1)
.ok_or(ErrorCode::TickIdOverflow)?;
Ok(tick_id)
}
The Audit Checklist
If you're auditing a tick-based lending protocol, here's my condensed checklist:
| Category | What to Check |
|---|---|
| Withdrawal Limits | Can limits be bypassed via same-block batching? |
| Oracle Independence | Does any oracle read state modifiable in the same tx? |
| Debt Accounting | Can debt factors underflow or overflow? |
| Math Safety | Does any division return 0 on zero divisor? |
| ID Management | Can tick/position IDs wrap or collide? |
| Precision Loss | Does rounding accumulate across tick iterations? |
| Liquidation Completeness | Can partial liquidations leave un-liquidatable dust? |
| Flash Loan Interactions | Can any read-then-write pattern be exploited intra-tx? |
Looking Forward
Tick-based lending represents genuine innovation — the capital efficiency gains are real. But the security surface is 3-5x larger than traditional pool models. Every tick boundary is an edge case. Every precision calculation compounds.
My advice for protocol developers: start with a simpler model and add complexity only when you have the security budget to match. For auditors: don't let the elegance of the math distract you from the implementation footguns.
DreamWork Security specializes in Solana and EVM smart contract auditing. Follow for weekly security research.
Tags: #security #solana #defi #web3 #smartcontracts
Top comments (0)