back to research
web3rustengineering

Building a Production-Grade Slot-Based TWAP Oracle on Solana

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.

Chaitanya

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:

  1. Takes a flash loan
  2. Dumps into the pool, moving the price 50%
  3. Triggers a liquidation or mint at the manipulated price
  4. 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)
  • 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
  • Deterministic across replays

Every slot that passes with a price active contributes proportionally to the cumulative value. No gaps, no drift, no manipulation surface.

System Architecture

The oracle system has four layers:

DEX Pools → Updater Bot → On-Chain Oracle → Indexer + API → Consumers

Price Sources

Prices are fetched from multiple DEX protocols:

  • Raydium AMM
  • Orca Whirlpools
  • 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

  • initialize_oracle
  • update_price (permissionless)
  • get_swap (read-only with staleness protection)

Admin

  • transfer_ownership
  • set_paused
  • resize_buffer
  • set_max_deviation

Rewards

  • 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] ["observation", 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 the per-write serialization overhead for the vec length.

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)

Each source validates pool mints against the oracle's base/quote and auto-inverts if the pool ordering is reversed.

The aggregation pipeline:

  1. Fetch all sources via Promise.allSettled
  2. Reject any source deviating >5% from the median
  3. Check min-sources threshold (default 2)
  4. Compute spread on remaining prices, skip if >5%
  5. Compute confidence score: (valid_sources / total) × (1 - spread)
  6. 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.

This feeds the /historical API endpoint for charting and the reward distribution calculator.

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 (Oracle PDA, ObservationBuffer PDA) are derived from unique mint addresses. Two oracles share zero writable accounts.

The scheduler sees:

tx1 write-locks: {sol_oracle, sol_obs_buffer} tx2 write-locks: {eth_oracle, eth_obs_buffer} tx3 write-locks: {btc_oracle, btc_obs_buffer}

No intersection → 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.

This means the oracle scales linearly with Solana's core count. Adding a new trading pair doesn't slow down existing pairs.

Multi-Source Aggregation

Single-source oracles are fragile. A pool can be manipulated, have stale liquidity, or simply go down.

We aggregate from three independent DEX protocols, each with different AMM mechanisms:

  • Raydium: constant-product AMM
  • 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. The median of the remaining two sources becomes the submitted price.

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 from the vault.

The key design choice: rewards are paid to the previous updater, not the current one.

This means:

  1. Updater A submits a price at slot 100
  2. Updater B submits at slot 110, A gets paid
  3. Updater C submits at slot 120, B gets paid

If nobody submits after you, you don't get paid.

This creates a natural incentive to keep the oracle fresh

Failure Modes and Protections

Every component has explicit failure handling.

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 skills against the codebase:

  • Trail of Bits solana-vulnerability-scanner
  • Frank Castle safe-solana-builder

The scanner passed all critical vulnerability patterns. The checklist found several issues which were 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 at ~17K CU is well under limits. Latency is dominated by transaction confirmation, not program execution.

Test Coverage

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

  1. Validate all DEX pool offsets on mainnet
  2. Run updater bot in dry-run mode
  3. Publish SDK
  4. Deploy full stack to devnet

The oracle is functional, but not yet battle-tested.