PerkOracle

PerkOracle is Perk's custom oracle system. It provides on-chain price feeds for any SPL token with DEX liquidity — covering the long tail of tokens that Pyth doesn't support.


Why PerkOracle Exists

Perk is permissionless — anyone can create a market for any token. But most tokens don't have Pyth price feeds. Without a price oracle, there's no way to calculate funding rates, trigger liquidations, or anchor the vAMM.

PerkOracle solves this by aggregating prices from multiple off-chain sources, posting them on-chain, and enforcing strict safety checks. If the oracle can't provide a reliable price, trading halts. It never uses a bad price.


Oracle Tiers

TierPrimary OracleFallbackCoverage
1PythPerkOracleSOL, BTC, ETH, major tokens
2PerkOracleNoneAny token with DEX liquidity

Tier 1 markets use Pyth as the primary source with PerkOracle as a fallback. If Pyth goes stale or becomes unavailable, the market automatically falls back to PerkOracle rather than halting entirely.

Tier 2 markets rely solely on PerkOracle.


Architecture

Jupiter Price API v3 ──┐
                       ├── Cranker (off-chain) ── median + validation ──▶ on-chain PerkOraclePrice
Birdeye API ───────────┘

Price Aggregation Pipeline

  1. Fetch prices from currently two active sources (Jupiter, Birdeye):

    • Jupiter Price API v3 (api.jup.ag/price/v3)
    • Birdeye Token Price API
  2. Validate each source:

    • Price must be positive
    • Jupiter must show sufficient liquidity (dust filter)
    • No source can deviate more than the configured outlier threshold from the median (default: 1%)
  3. Aggregate:

    • Take the median of valid sources (not the mean — resistant to one compromised source)
    • Confidence = spread across sources (max - min)
  4. Post on-chain:

    • Call update_perk_oracle with: median price, confidence, source count
    • Update frequency: ~125ms between oracles, ~10 second full cycle across all oracles

On-Chain Account

Each token has a single PerkOraclePrice PDA, shared across all markets for that token. If multiple markets exist for the same token (created by different creators), they all read from the same oracle account. There is one price feed per token, not per market.

Seeds: [b"perk_oracle", token_mint]

Field                   Type    Description
─────────────────────────────────────────────────────────
token_mint              Pubkey  Token this oracle prices
authority               Pubkey  Authorized updater (cranker)
price                   u64     Current price (1e6 scale)
confidence              u64     Spread across sources (1e6 scale)
timestamp               i64     Unix timestamp of last update
num_sources             u8      Sources used in this update
min_sources             u8      Minimum required for valid update
last_slot               u64     Solana slot of last update
ema_price               u64     Exponential moving average
max_staleness_seconds   u32     Staleness threshold
is_frozen               bool    Admin emergency freeze
total_updates           u64     Lifetime update counter

Initialization (Permissionless)

Oracle creation is fully permissionless since v1.2.0. Anyone can call initialize_perk_oracle for any SPL token by paying rent — no admin approval required.

  • Payer: Any wallet (pays oracle account rent)
  • Oracle authority: Inherited automatically from Protocol.oracle_authority (set once by admin via admin_set_oracle_authority)
  • One oracle per token: PDA seeds are [b"perk_oracle", token_mint] — duplicate initialization fails deterministically
  • Auto-initialization: During market creation, if no oracle exists for the token, the SDK auto-initializes one. The payer covers oracle rent in addition to the 1 SOL market creation fee.

The admin's only role in the oracle lifecycle is setting Protocol.oracle_authority once (the cranker pubkey). After that, oracle creation is entirely user-driven.


Security Checks

Every update_perk_oracle call must pass all of these:

CheckRuleRationale
AuthoritySigner must be oracle.authorityOnly authorized updater
Not frozenis_frozen == falseEmergency freeze respected
Gap protectionTime since last update < max_staleness × 2Prevents stale→wild price jumps
Source minimumnum_sources >= min_sourcesConfigurable minimum (default 2, can be set to 1 for resilience when one API is rate-limited)
Price positiveprice > 0No zero or negative prices
Rate limitcurrent_slot > last_slotMax one update per slot (~400ms)

What's NOT Enforced On-Chain

Price banding is configurable per oracle but can be disabled (set to 0). Memecoins need to move freely — a 1000% pump in an hour is valid. Banding is recommended for stablecoins and majors:

Market TypeRecommended BandRationale
Memecoins0 (disabled)Wild price action is the product
Major tokens500 bps (5%)Covers extreme volatility
Stablecoins100 bps (1%)Should never move more than 1%

EMA Tracking

The oracle maintains an exponential moving average (ema_price) alongside the spot price. This provides:

  • A smoothed reference for circuit breaker comparisons
  • Resistance to short-term price spikes
  • A baseline for detecting abnormal price movement

Circuit Breaker

When enabled, the circuit breaker monitors price deviation from the EMA over a sliding window (50 slots ≈ 20 seconds):

  • If the current price deviates beyond the configured threshold from the EMA, the oracle rejects the update
  • The sliding window uses a multiplier (3x the per-update band) to allow gradual large moves while blocking sudden jumps
  • After an unfreeze, the first update is banded against the pre-freeze price to prevent post-freeze manipulation

Staleness

Every instruction that reads the oracle checks staleness:

if (clock.unix_timestamp - oracle.timestamp > max_staleness_seconds) {
    return Err(OracleStale);
}

Default staleness: 120 seconds (configurable per oracle, 5–300 seconds range).

If the oracle goes stale, the market effectively pauses — no trades, no liquidations, no position changes. This is by design. A halted market is better than a market trading on bad data.


Fail-Closed Design

PerkOracle is fail-closed, not fail-open:

  • Cranker goes offline → oracle goes stale → market pauses automatically
  • API sources return conflicting data → outlier rejection removes bad source → if fewer than min_sources remain, update fails → stale → pause
  • All API sources go down → cranker can't meet min_sources → no updates → stale → pause
  • Admin freezes oracle → all markets using it halt immediately
  • Oracle is frozen, then unfrozen → first update is banded against pre-freeze price

At no point does the system fall back to guessing, interpolating, or using a known-bad price.


Cranker Redundancy

  • Multiple cranker instances can run simultaneously (different machines, different regions)
  • Rate limit (1 update per slot) means only one wins per slot — no conflict
  • If the primary cranker goes down, backup takes over seamlessly
  • Crankers are independent — they all fetch prices and post independently, no coordination needed

Fallback Logic

For Tier 1 markets (Pyth primary + PerkOracle fallback):

1. Try reading the primary oracle (Pyth)
2. If primary fails (stale, unavailable) → try fallback (PerkOracle)
3. If fallback also fails → instruction reverts, market pauses

Fallback only activates when the primary fails. Sources are never mixed or averaged together.


Emergency Procedures

Suspected Manipulation

  1. Admin calls freezePerkOracle() for the affected token
  2. All markets using that oracle halt (trades, liquidations, everything)
  3. Investigate and resolve
  4. Admin calls unfreezePerkOracle() to resume

Cranker Key Compromise

  1. Admin freezes all PerkOracle-dependent oracles
  2. Transfer oracle authority via transferOracleAuthority() to a new key
  3. Deploy new cranker with new key
  4. Unfreeze

Key Isolation

The oracle cranker key can only write prices. It has no admin privileges, no vault access, no ability to move funds. If compromised, an attacker can post bad prices but cannot steal anything. The freeze mechanism shuts this down immediately.


Per-Oracle Confidence Tiers (v1.4.0)

Each oracle can have a custom max_confidence_bps threshold stored in the oracle account's reserved bytes. This allows different confidence requirements per token class:

Token Classmax_confidence_bpsRationale
Majors (SOL, BTC, ETH, etc.)200 bps (2%)Tight spreads expected
Everything else (memecoins)500 bps (5%)Wider spreads normal

When set, the on-chain program reads max_confidence_bps from _reserved[29..31] of the oracle account instead of using the hardcoded 200 bps default. Admin sets this via updateOracleConfig().


Security Invariants

  1. No trade executes on a stale price
  2. No trade executes on a frozen oracle
  3. No single entity can unilaterally move the oracle price (min 2 sources required)
  4. Cranker compromise cannot steal funds (cranker only writes prices)
  5. Oracle failure mode is always halt, never use-bad-price
  6. Fallback only activates when primary fails (no source mixing)
  7. Protocol remains solvent regardless of oracle behavior (Percolator handles worst case)