Building a Production-Grade Slot-Based TWAP Oracle on Solana
A deep technical walkthrough of building a manipulation-resistant slot-weighted TWAP oracle on Solana: on-chain program, updater network, indexer, and security design.
Oracles are the most attacked component in DeFi. Flash loans, sandwich attacks, and low-liquidity manipulation have drained billions from protocols that trusted a single price source at a single point in time. The fix isn't better price sources — it's better price math.
This is a technical walkthrough of building a slot-weighted TWAP oracle on Solana from scratch: the program, the updater network, the indexing pipeline, and the failure modes we designed around.
The Problem With Spot Prices
A spot price oracle reads a pool's reserves and computes quote / base. This works until someone takes a flash loan, dumps into the pool moving the price 50%, triggers a liquidation or mint at the manipulated price, and repays the loan in the same transaction.
The oracle reported a "correct" price — it just happened to be correct for 400 milliseconds during an attack. Protocols using this price as truth get wrecked.
Why TWAP
Time-weighted average prices solve this by averaging price over a window. A flash loan can spike the price for one slot, but it can't sustain the spike for 100 slots. The longer the TWAP window, the harder it is to manipulate.
The math is simple. On every update: cumulative_price += last_price × (current_slot - last_slot)
To query TWAP over any window: TWAP = (cumulative_now - cumulative_past) / (slot_now - slot_past)
This is the same approach Uniswap V2/V3 uses, adapted for Solana's slot-based architecture.
Why Slots, Not Timestamps
Solana's Clock::unix_timestamp is set by validators via a stake-weighted median. It can drift seconds from real time, stall (same timestamp across multiple slots), or move non-monotonically in edge cases.
Slots are the native unit of chain progress on Solana. They increment deterministically, never repeat, and cost zero to read from the Clock sysvar.
Using slots as the time axis makes the TWAP strictly monotonic, immune to validator timestamp manipulation, and deterministic across replays.
System Architecture
The oracle system has four layers:
DEX Pools → Updater Bot → On-Chain Oracle → Indexer + API → Consumers
Prices are fetched from multiple DEX protocols: Raydium AMM, Orca Whirlpools, and Meteora DLMM. Each uses a different AMM design, reducing the risk of manipulation from any single source.
On-Chain Program
The program is 10 instructions built with Anchor 0.31.1.
Core instructions: initialize_oracle, update_price (permissionless), get_swap (read-only with staleness protection).
Admin instructions: transfer_ownership, set_paused, resize_buffer, set_max_deviation.
Reward instructions: initialize_reward_vault, fund_reward_vault, auto-pay via update_price.
Each oracle pair gets two PDAs — an Oracle account and an ObservationBuffer — seeded by ["oracle", base_mint, quote_mint] and ["obs", oracle]. No shared global state.
The observation buffer is a fixed-size pre-allocated ring buffer. We moved from Vec<Observation> (which borsh serializes the length prefix on every update) to a pre-zeroed array with a separate len field. This eliminates per-write serialization overhead.
Updater Network
The updater bot fetches prices from three DEX protocols in parallel:
- Raydium AMM v4: Read vault token balances at known offsets (336, 368)
- Orca Whirlpools: Read sqrt_price field (offset 65)
- Meteora DLMM: Read reserve token balances (offsets 152, 184)
The aggregation pipeline:
- Fetch all sources via Promise.allSettled
- Reject any source deviating >5% from the median
- Check min-sources threshold (default 2)
- Compute spread on remaining prices, skip if >5%
- Compute confidence score: (valid_sources / total) × (1 - spread)
- Submit median price to the on-chain program
Indexer
A gRPC stream via Yellowstone Geyser subscribes to all transactions involving the oracle program. Each confirmed transaction's logs are decoded for OracleUpdate events and inserted into PostgreSQL with idempotent ON CONFLICT DO NOTHING and dual uniqueness constraints.
API Server
Express + Zod with rate limiting and WebSocket:
- GET /price: current oracle state with staleness gap
- GET /twap: off-chain TWAP over N slots
- GET /history: decoded events from recent transactions
- GET /historical: bucketed price data from PostgreSQL for charts
- WS /ws: subscribe to live TWAP updates per oracle pair
WebSocket clients get updates every 2 seconds with backpressure protection — slow clients are disconnected when their send buffer exceeds 64KB.
Parallelism
Each oracle pair's accounts are derived from unique mint addresses. Two oracles share zero writable accounts. The scheduler sees: tx1 write-locks sol_oracle and sol_obs_buffer, tx2 write-locks eth_oracle, tx3 write-locks btc_oracle — no intersection, so all three execute in parallel threads within the same slot.
We validated this with a 50-pair concurrent update test — all 50 succeed in the same slot with zero contention. The oracle scales linearly with Solana's core count.
Multi-Source Aggregation
Single-source oracles are fragile. We aggregate from three independent DEX protocols, each with different AMM mechanisms: Raydium (constant-product), Orca (concentrated liquidity), Meteora (DLMM with discrete bins).
The per-source outlier rejection is critical. If Raydium reports $150 but Orca and Meteora report $83, Raydium gets rejected. Each source also validates on-chain that the pool's token mints match the oracle's expected base/quote.
Reward Incentives
Permissionless oracles need economic incentives. The reward system works through a vault PDA per oracle. The owner funds it with reward tokens, and each update_price transaction automatically pays the previous updater.
The key design choice: rewards are paid to the previous updater, not the current one. This creates a natural incentive to keep the oracle fresh — if nobody submits after you, you don't get paid.
Failure Modes and Protections
On-chain: StaleSlot, PriceDeviationTooLarge, StaleOracle, OraclePaused, InsufficientHistory.
Bot: outlier rejection, spread check, min-sources guard, retry with exponential backoff, graceful shutdown handling.
Monitoring: Prometheus metrics, Telegram alerts, Grafana dashboards.
Indexer: idempotent inserts, TWAP verification scripts.
Security Audit
We ran two automated security scans against the codebase: Trail of Bits solana-vulnerability-scanner and Frank Castle safe-solana-builder. Several issues were found and fixed, including CPI reloads, vault withdrawal instructions, Token-2022 extension validation, duplicate account constraints, and two-step ownership transfer.
After fixes, the program uses strict account validation, checked arithmetic, safe token transfers, and deterministic slot-based timing.
Benchmarks
- update_price: 455ms p50 latency, ~17,143 CU
- get_swap (simulate): 1ms
- fetchOracle: <1ms
- computeSwapFromChain: 1ms
update_price at ~17K CU is well within Solana's compute limits. Latency is dominated by transaction confirmation, not program execution.
Test Coverage
- Rust integration tests: 80
- SDK tests: 24
- API tests: 32
- E2E tests: 10
- Fuzz targets: 4, with 28M+ runs
The fuzz tests focused on arithmetic boundaries, cumulative math, and ring buffer integrity. Zero crashes across millions of runs.
Lessons Learned
- Orca vault-based pricing does not work for Whirlpools
- Byte offsets are fragile across program upgrades
- Ring buffers are tricky with realloc
- IDL drift happens frequently
- Optional accounts in Anchor require placeholders
Most complexity wasn't the math — it was handling system edges and failure modes.
What's Next
- Validate all DEX pool offsets on mainnet
- Run updater bot in dry-run mode
- Publish SDK
- Deploy full stack to devnet
The oracle is functional, but not yet battle-tested.