Your Solana lending protocol has $500M in TVL. A liquidation bot needs to execute a critical health-factor check. But someone is spending $0.50 per second flooding transactions against your protocol's global state PDA — and suddenly, every legitimate transaction either fails or costs 100x normal fees.
This is the Noisy Neighbor Attack: the cheapest, most underappreciated denial-of-service vector in Solana DeFi. And most protocols are completely vulnerable to it.
How Solana's Fee Markets Actually Work
Solana's parallel execution model is its superpower. Unlike Ethereum where every transaction competes in a single global fee auction, Solana transactions declare upfront which accounts they'll read and write. The runtime uses this to execute non-conflicting transactions in parallel.
But there's a catch: write locks are exclusive. If two transactions both want to write to the same account, they must execute sequentially. This creates localized contention — a hot account becomes a bottleneck while the rest of the network runs fine.
Localized Fee Markets (LFMs) evolved from this: the priority fee for your transaction isn't determined by global network load, but by how many other transactions are competing to write-lock the same accounts you need.
Global Fee Market (Ethereum): Localized Fee Market (Solana):
┌─────────────────────┐ ┌─────────────────────┐
│ All txs compete │ │ Protocol A: 0.001◎ │
│ for same block │ │ Protocol B: 5.000◎ │ ← Hot!
│ space │ │ Protocol C: 0.001◎ │
│ Base fee: ~$2-50 │ │ Protocol D: 0.001◎ │
└─────────────────────┘ └─────────────────────┘
The Attack: Weaponizing Write-Lock Contention
The Noisy Neighbor Attack exploits a simple observation: most Solana protocols concentrate critical state in one or two writable accounts (typically PDAs). Flood those accounts with write-lock requests, and you create a localized DoS.
Attack Architecture
// Attacker's spam program — incredibly simple
use anchor_lang::prelude::*;
#[program]
pub mod noisy_neighbor {
use super::*;
// This instruction does nothing useful — it just write-locks the target
pub fn spam(ctx: Context<Spam>) -> Result<()> {
// Touch the account to require a write lock
// Even a no-op write forces exclusive access
let target = &mut ctx.accounts.target_state;
// Intentionally burn some CU to hold the lock longer
let mut _waste: u64 = 0;
for i in 0..1000 {
_waste = _waste.wrapping_add(i);
}
Ok(())
}
}
#[derive(Accounts)]
pub struct Spam<'info> {
/// CHECK: We just need to write-lock this account
#[account(mut)]
pub target_state: AccountInfo<'info>,
#[account(mut)]
pub payer: Signer<'info>,
}
The Economics Are Terrifying
Let's do the math for attacking a lending protocol's oracle update:
Solana slot time: ~400ms
Transactions per slot write-locking same account: ~1-4 (sequential)
Cost per spam transaction:
- Base fee: 5,000 lamports (~$0.001)
- Priority fee to outbid bots: ~50,000 lamports (~$0.01)
- Total per tx: ~$0.011
Attack rate: 10 spam txs per slot (most will land)
Cost per second: 25 slots/sec × $0.011 = $0.275/sec
Cost per minute: ~$16.50
Cost per hour: ~$990
Value at risk: $500M protocol can't process liquidations for an hour
For under $1,000/hour, an attacker can effectively shut down a specific protocol's critical operations while the rest of Solana works perfectly fine.
Who Benefits?
This isn't just griefing. The attack has clear economic motivation:
Scenario 1: Liquidation Prevention
1. Attacker has an underwater position worth $2M
2. Liquidation would cost them 5-10% ($100K-$200K)
3. Flood the protocol's state PDA for $990/hour
4. Liquidation bots can't land transactions
5. Wait for market recovery or arrange OTC exit
6. Total attack cost: $990 vs $200K saved
Scenario 2: Oracle Staleness Exploitation
1. Protocol uses push-based oracle that writes to a state account
2. Attacker floods that account, delaying oracle updates
3. Protocol operates on stale prices for 30-60 seconds
4. Attacker borrows at stale (favorable) prices on the target protocol
5. Repays after real price is reflected
Scenario 3: Competitive DoS
1. Protocol A and Protocol B are competing DEXs
2. Protocol A floods Protocol B's state accounts during high-volume period
3. Users get frustrated, switch to Protocol A
4. Cost: $990/hour during peak trading = negligible customer acquisition cost
Why Most Protocols Are Vulnerable
The root cause is a common Solana design pattern: the God PDA.
// THE PROBLEM: Single global state account
#[account]
pub struct ProtocolState {
pub authority: Pubkey,
pub total_deposits: u64,
pub total_borrows: u64,
pub last_update_slot: u64,
pub oracle_price: u64,
pub interest_rate: u64,
pub fee_accumulator: u64,
pub is_paused: bool,
// Everything in one account = single point of contention
}
// Every operation write-locks this ONE account:
// - Deposits → updates total_deposits
// - Borrows → updates total_borrows
// - Liquidations → updates total_borrows + total_deposits
// - Oracle updates → updates oracle_price
// - Fee collection → updates fee_accumulator
When every protocol operation needs to write-lock the same PDA, an attacker only needs to flood one account to DoS the entire protocol.
Defense Pattern 1: State Sharding
Split your monolithic state into purpose-specific accounts:
// SOLUTION: Sharded state across multiple accounts
#[account]
pub struct PoolConfig {
// Rarely updated — admin operations only
pub authority: Pubkey,
pub fee_bps: u16,
pub is_paused: bool,
}
#[account]
pub struct PoolAccounting {
// Updated on deposits/withdrawals
pub total_deposits: u64,
pub total_shares: u64,
pub last_deposit_slot: u64,
}
#[account]
pub struct OracleState {
// Updated by oracle pushes — independent write lock
pub price: u64,
pub confidence: u64,
pub last_update_slot: u64,
pub twap: u64,
}
#[account]
pub struct BorrowState {
// Updated on borrows/repays/liquidations
pub total_borrows: u64,
pub borrow_index: u128,
pub last_accrue_slot: u64,
}
#[account]
pub struct FeeVault {
// Updated on fee collection — can be delayed
pub accumulated_fees: u64,
pub last_collection_slot: u64,
}
Now an attacker flooding OracleState doesn't block deposits (which only need PoolAccounting), and flooding PoolAccounting doesn't block liquidations (which primarily need BorrowState).
Cost to attacker: 5x more accounts to flood = 5x the cost, and they can't block all operations simultaneously.
Defense Pattern 2: User-Scoped State with Lazy Aggregation
Instead of updating a global counter on every operation, let users write to their own accounts and aggregate lazily:
#[account]
pub struct UserPosition {
pub owner: Pubkey,
pub pool: Pubkey,
pub deposited: u64,
pub borrowed: u64,
pub last_interaction_slot: u64,
}
// Deposit only write-locks the USER's account + token accounts
// No global state write lock needed!
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
let position = &mut ctx.accounts.user_position;
position.deposited = position.deposited
.checked_add(amount)
.ok_or(ErrorCode::Overflow)?;
position.last_interaction_slot = Clock::get()?.slot;
// Transfer tokens — standard CPI, no global lock
token::transfer(ctx.accounts.transfer_ctx(), amount)?;
// Emit event for off-chain aggregation
emit!(DepositEvent {
user: ctx.accounts.owner.key(),
pool: ctx.accounts.pool.key(),
amount,
slot: Clock::get()?.slot,
});
Ok(())
}
// Global totals computed off-chain from events or via cranked aggregation
// Liquidation uses per-user health checks — no global state needed
pub fn liquidate(ctx: Context<Liquidate>) -> Result<()> {
let position = &ctx.accounts.target_position;
let oracle = &ctx.accounts.oracle; // Read-only — no write lock!
let health = calculate_health_factor(
position.deposited,
position.borrowed,
oracle.price,
);
require!(health < LIQUIDATION_THRESHOLD, ErrorCode::Healthy);
// Liquidation logic using only user-scoped state
// ...
Ok(())
}
Key insight: Liquidations only need to read the oracle price and write to the borrower's position account. No global state contention.
Defense Pattern 3: Write-Lock Budgeting with Fallback Paths
Design critical operations to have a \"hot path\" (touching shared state) and a \"cold path\" (avoiding it):
pub fn liquidate_with_fallback(ctx: Context<Liquidate>) -> Result<()> {
let position = &mut ctx.accounts.target_position;
// Try to read live oracle (might be contended)
let price = if let Some(oracle) = ctx.remaining_accounts.first() {
// Hot path: use live oracle
let oracle_data = OracleState::try_deserialize(
&mut &oracle.data.borrow()[..]
)?;
require!(
Clock::get()?.slot - oracle_data.last_update_slot < 10,
ErrorCode::StaleOracle
);
oracle_data.price
} else {
// Cold path: use Pyth/Switchboard directly (read-only, no contention)
let pyth_price = get_pyth_price(&ctx.accounts.pyth_feed)?;
pyth_price
};
let health = calculate_health_factor(
position.deposited,
position.borrowed,
price,
);
require!(health < LIQUIDATION_THRESHOLD, ErrorCode::Healthy);
// Execute liquidation...
Ok(())
}
Defense Pattern 4: Priority Fee Floor Detection
Detect when your protocol is under attack and activate circuit breakers:
import { Connection, PublicKey } from '@solana/web3.js';
interface FeeAnomaly {
currentMedianFee: number;
baselineMedianFee: number;
ratio: number;
isAttack: boolean;
}
class NoisyNeighborDetector {
private baselineFees: number[] = [];
private readonly ATTACK_THRESHOLD = 10;
private readonly WINDOW_SIZE = 100;
async checkFeeAnomaly(
connection: Connection,
targetAccount: string
): Promise<FeeAnomaly> {
const signatures = await connection.getSignaturesForAddress(
new PublicKey(targetAccount),
{ limit: 50 }
);
const fees = await Promise.all(
signatures.map(async (sig) => {
const tx = await connection.getTransaction(sig.signature, {
maxSupportedTransactionVersion: 0,
});
return tx?.meta?.fee ?? 5000;
})
);
const currentMedian = this.median(fees);
const baselineMedian = this.median(this.baselineFees) || 5000;
this.baselineFees.push(currentMedian);
if (this.baselineFees.length > this.WINDOW_SIZE) {
this.baselineFees.shift();
}
const ratio = currentMedian / baselineMedian;
return {
currentMedianFee: currentMedian,
baselineMedianFee: baselineMedian,
ratio,
isAttack: ratio > this.ATTACK_THRESHOLD,
};
}
private median(arr: number[]): number {
if (arr.length === 0) return 0;
const sorted = [...arr].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
}
The Architecture Audit Checklist
Before launching a Solana protocol, answer these questions:
State Design (4 checks)
- [ ] Is protocol state split into independent accounts by function?
- [ ] Can critical operations (liquidations, oracle updates) execute without write-locking shared state?
- [ ] Are user positions stored in per-user accounts, not arrays in global state?
- [ ] Can any single writable account bottleneck more than one operation type?
Attack Resilience (4 checks)
- [ ] What's the cost to DoS each critical operation for 1 hour?
- [ ] Do liquidation bots have fallback oracle paths if the primary is contended?
- [ ] Is there fee anomaly detection and alerting for your state accounts?
- [ ] Can the protocol operate in \"degraded mode\" if one state account is unavailable?
Operational (4 checks)
- [ ] Do keeper/crank bots use dynamic priority fees based on current contention?
- [ ] Is there a circuit breaker for abnormal fee conditions?
- [ ] Are compute unit requests optimized to minimize lock duration?
- [ ] Is there documentation of which accounts each instruction write-locks?
The Bigger Picture: This Gets Worse with Firedancer
Firedancer's increased throughput (potentially 1M+ TPS) might seem like it would solve this problem. It doesn't — it makes it cheaper. More throughput means an attacker can land more spam transactions per second at the same cost. The write-lock bottleneck is a logical constraint, not a throughput constraint.
With Firedancer's dynamic block sizing, a high-performance validator might produce blocks that slower validators struggle to verify within the 400ms slot time. This creates a window where an attacker's spam transactions land on the fast validator but legitimate transactions from slower validators get delayed — amplifying the localized DoS effect.
Conclusion
The Noisy Neighbor Attack is DeFi's cheapest kill switch: under $1,000/hour to shut down any protocol that uses a single global state account. The defenses (state sharding, user-scoped state, fallback paths) are well-understood but rarely implemented because they add development complexity.
This is a design-time decision, not a runtime patch. If your protocol concentrates state in one PDA, you've given every attacker a $0.50 kill switch. Shard your state, provide fallback paths, and monitor for fee anomalies. The cost of prevention is a fraction of the cost of a $500M protocol going offline during a market crash.
DreamWork Security researches DeFi vulnerabilities across EVM and Solana ecosystems. Follow for weekly deep dives into exploits, audit tools, and security best practices.
Top comments (0)