Oracle Manipulation Remediation (Solana)
How to defend Solana DeFi programs against oracle price manipulation through account ownership verification, staleness checks, confidence interval validation, and multi-oracle aggregation.
Oracle Manipulation Remediation (Solana)
Overview
Oracle manipulation vulnerabilities in Solana DeFi programs are addressed through a layered defense approach: verify the oracle account’s ownership, check price freshness, validate confidence intervals, and — for high-value protocols — aggregate multiple independent oracles.
Recommended Fix
Pyth Network Integration (Production Standard)
use pyth_sdk_solana::{load_price_feed_from_account_info, PriceFeed};
use solana_program::{clock::Clock, pubkey::Pubkey, sysvar::Sysvar};
const MAX_PRICE_AGE_SECONDS: i64 = 60; // Maximum 60 seconds stale
const MAX_CONFIDENCE_BPS: u64 = 100; // Maximum 1% confidence band
const PYTH_PROGRAM_ID: &str = "FsJ3A3u2vn5cTVofAjvy6y5kwABJAqYWpe4975bi2epH";
pub struct ValidatedPrice {
pub price: i64,
pub exponent: i32,
pub confidence: u64,
}
pub fn get_pyth_price(
price_account: &AccountInfo,
) -> Result<ValidatedPrice, ProgramError> {
let pyth_program = Pubkey::from_str(PYTH_PROGRAM_ID).unwrap();
// 1. Verify account is owned by Pyth
if price_account.owner != &pyth_program {
msg!("Error: Price account not owned by Pyth program");
return Err(ProgramError::InvalidAccountData);
}
// 2. Load and parse the price feed
let price_feed = load_price_feed_from_account_info(price_account)
.map_err(|e| {
msg!("Error loading price feed: {:?}", e);
ProgramError::InvalidAccountData
})?;
let clock = Clock::get()?;
// 3. Validate freshness — reject stale prices
let price = price_feed
.get_price_no_older_than(clock.unix_timestamp, MAX_PRICE_AGE_SECONDS)
.ok_or_else(|| {
msg!("Error: Price is stale (last update > {} seconds ago)", MAX_PRICE_AGE_SECONDS);
ProgramError::InvalidAccountData
})?;
// 4. Validate confidence interval
// Confidence is the standard deviation of the price in the same units as price
if price.price == 0 {
return Err(ProgramError::InvalidAccountData);
}
let confidence_bps = price.conf as u64 * 10_000 / price.price.unsigned_abs() as u64;
if confidence_bps > MAX_CONFIDENCE_BPS {
msg!(
"Error: Price confidence too wide: {}bps (max {}bps)",
confidence_bps,
MAX_CONFIDENCE_BPS
);
return Err(ProgramError::InvalidAccountData);
}
// 5. Validate price is positive and reasonable
if price.price <= 0 {
msg!("Error: Invalid price value: {}", price.price);
return Err(ProgramError::InvalidAccountData);
}
Ok(ValidatedPrice {
price: price.price,
exponent: price.expo,
confidence: price.conf,
})
}
// Example usage in a lending protocol
pub fn calculate_collateral_value(
accounts: &[AccountInfo],
collateral_amount: u64,
) -> Result<u64, ProgramError> {
let price_account = &accounts[0];
let validated_price = get_pyth_price(price_account)?;
// Convert price to USD with proper exponent handling
// price * 10^exponent gives the USD price
let price_usd = if validated_price.exponent >= 0 {
(validated_price.price as u64)
.checked_mul(10_u64.pow(validated_price.exponent as u32))
.ok_or(ProgramError::ArithmeticOverflow)?
} else {
(validated_price.price as u64)
.checked_div(10_u64.pow((-validated_price.exponent) as u32))
.ok_or(ProgramError::ArithmeticOverflow)?
};
collateral_amount
.checked_mul(price_usd)
.ok_or(ProgramError::ArithmeticOverflow)
}
Switchboard V2 Integration
use switchboard_v2::AggregatorAccountData;
const MAX_STALENESS_SLOTS: u64 = 150; // ~1 minute at 400ms per slot
const SWITCHBOARD_MAINNET_PROGRAM: &str = "SW1TCH7qEPTdLsDHRgPuMQjbQxKdH2aBStViMFnt64f";
pub fn get_switchboard_price(price_account: &AccountInfo) -> Result<f64, ProgramError> {
let switchboard_program = Pubkey::from_str(SWITCHBOARD_MAINNET_PROGRAM).unwrap();
// Verify account ownership
if price_account.owner != &switchboard_program {
msg!("Error: Aggregator not owned by Switchboard program");
return Err(ProgramError::InvalidAccountData);
}
let aggregator = AggregatorAccountData::new(price_account)
.map_err(|_| ProgramError::InvalidAccountData)?;
// Check staleness using slot-based comparison
let clock = Clock::get()?;
let last_update_slot = aggregator.latest_confirmed_round.round_open_slot;
if clock.slot.saturating_sub(last_update_slot) > MAX_STALENESS_SLOTS {
msg!(
"Error: Price is stale. Last updated slot {}, current slot {}",
last_update_slot,
clock.slot
);
return Err(ProgramError::InvalidAccountData);
}
// Check that the round has minimum confirmations
if aggregator.latest_confirmed_round.num_success < aggregator.min_oracle_results {
msg!("Error: Insufficient oracle confirmations");
return Err(ProgramError::InvalidAccountData);
}
let result = aggregator.get_result().map_err(|_| ProgramError::InvalidAccountData)?;
let price = f64::try_from(result).map_err(|_| ProgramError::InvalidAccountData)?;
if price <= 0.0 {
return Err(ProgramError::InvalidAccountData);
}
Ok(price)
}
Alternative Mitigations
TWAP (Time-Weighted Average Price): Calculate the average price over a recent window of oracle updates rather than using the instantaneous spot price. This makes manipulation more expensive as an attacker must sustain the price deviation for the entire window duration.
Multi-oracle aggregation: Read from both Pyth and Switchboard, and reject transactions where the prices deviate by more than a threshold. This requires both oracle systems to be simultaneously manipulated for an attack to succeed.
const MAX_PRICE_DEVIATION_BPS: u64 = 200; // 2% maximum deviation between oracles
pub fn get_aggregated_price(
pyth_account: &AccountInfo,
switchboard_account: &AccountInfo,
) -> Result<u64, ProgramError> {
let pyth_price = get_pyth_price(pyth_account)?.price as u64;
let switchboard_price = get_switchboard_price(switchboard_account)? as u64;
// Check deviation between oracle sources
let higher = pyth_price.max(switchboard_price);
let lower = pyth_price.min(switchboard_price);
let deviation_bps = (higher - lower) * 10_000 / lower;
if deviation_bps > MAX_PRICE_DEVIATION_BPS {
msg!(
"Error: Oracle prices deviate by {}bps (max {}bps)",
deviation_bps,
MAX_PRICE_DEVIATION_BPS
);
return Err(ProgramError::InvalidAccountData);
}
// Use the more conservative (lower) price for borrowing, higher for liquidation
Ok(lower)
}
Circuit breakers: Implement maximum price movement limits per slot. If the oracle price moves more than a defined percentage in a single slot, pause the protocol until governance reviews the situation.
Common Mistakes
-
Not checking oracle account ownership: Any account can be formatted to look like a Pyth price feed. Always verify
price_account.owner == pyth_program_id. -
Using
get_price_unchecked(): This method returns stale prices without any age validation. Always useget_price_no_older_than(). -
Ignoring the confidence interval: A wide confidence interval (large
confrelative toprice) indicates uncertainty — using prices during high-uncertainty periods enables exploitation. -
Hardcoding the Pyth program ID as a constant string without runtime verification: The program ID must be verified against the passed account at runtime, not just matched to a compile-time constant, to prevent account spoofing.
-
Not handling the price exponent: Pyth prices include a scaling exponent.
price * 10^expogives the actual USD price. Ignoring the exponent can cause calculations to be off by many orders of magnitude. -
Single oracle dependency: Using a single oracle is a single point of failure. If the oracle goes offline, the protocol is either halted or frozen with stale prices.