Skip to content

[HIGH] DEX oracle parsers return 0n on invalid pool state instead of throwing, enabling silent zero-price oracle pushes #338

Description

@Ayomisco

Severity

HIGH

Location

src/solana/dex-oracle.ts:163 (PumpSwap), src/solana/dex-oracle.ts:234 (Raydium CLMM), src/solana/dex-oracle.ts:306 (Meteora DLMM)

Root cause

Three DEX oracle price parsers return 0n on invalid or uninitialized pool state instead of throwing an error:

// PumpSwap -- dex-oracle.ts:163
if (baseAmount === 0n) return 0n;  // pool base vault drained or uninitialized

// Raydium CLMM -- dex-oracle.ts:234
if (sqrtPriceX64 === 0n) return 0n;  // pool uninitialized

// Meteora DLMM -- dex-oracle.ts:306
if (binStep === 0) return 0n;  // pool uninitialized

Zero is not a valid oracle price for any live asset. Returning 0n instead of throwing means callers receive no error signal and may silently forward the invalid price downstream.

price-router.ts defends against this in its own aggregation path (if (!(price > 0)) continue), but callers that use the DEX parsers directly -- including custom keeper integrations and the SDK's own encodePermissionlessCrank caller chain -- receive no protection.

Impact

If a keeper or integrator calls a DEX parser directly, receives 0n, and passes the result to an oracle price update instruction without an explicit zero check:

  1. The on-chain mark price is set to 0.
  2. All PnL calculations reference oracle = 0: the coin-margined PnL formula (oracle - entry) * abs_pos / oracle divides by zero, producing undefined behavior on the on-chain program side.
  3. All long positions appear to have infinite negative PnL and become immediately liquidatable.
  4. The keeper's liquidation loop triggers on every account, draining insurance funds and disrupting market settlement.

An attacker who controls a PumpSwap LP position can drain the base vault to zero, causing computePumpSwapPriceE6 to return 0n on the next crank cycle. If the keeper does not check for zero before building the oracle instruction, the next pushOraclePrice transaction sets mark price to 0 on-chain.

Proof of concept

import {
  computePumpSwapPriceE6,
  encodePermissionlessCrank,
} from "@percolator/sdk";

// Simulate a drained PumpSwap base vault (baseAmount = 0)
const emptyVaultData = new Uint8Array(128); // all-zero: baseAmount at offset 64 = 0n
const poolData = new Uint8Array(256);        // minimal pool header

const price = computePumpSwapPriceE6(poolData, emptyVaultData, 6, 9);
console.log(price);  // 0n -- no exception, no warning

// A keeper that does not check for zero proceeds to encode:
const ix = encodePermissionlessCrank({
  assetIndex: 0,
  oraclePriceE6: price,   // 0n forwarded on-chain
  nowSlot: 0n,
});
// On-chain: mark price = 0 -> all longs instantly liquidatable

The differential proof:

// SHOULD fail: draining base vault causes 0n return (no throw today)
const drainedPrice = computePumpSwapPriceE6(poolData, emptyVaultData, 6, 9);
assert(drainedPrice !== 0n, "expected throw, got 0n");  // fails -- this is the bug

// SHOULD succeed: normal vault returns a positive price
const normalPrice = computePumpSwapPriceE6(poolData, normalVaultData, 6, 9);
assert(normalPrice > 0n, "expected positive price");  // passes

Fix

Replace silent return 0n with throws so callers get an explicit error signal:

// PumpSwap
if (baseAmount === 0n) {
  throw new Error("computePumpSwapPriceE6: base vault is empty (amount=0) -- pool drained or uninitialized");
}

// Raydium CLMM
if (sqrtPriceX64 === 0n) {
  throw new Error("computeRaydiumClmmPriceE6: sqrtPriceX64 is 0 -- pool uninitialized");
}

// Meteora DLMM
if (binStep === 0) {
  throw new Error("computeMeteoraDlmmPriceE6: binStep is 0 -- pool uninitialized");
}

Callers that previously relied on the 0n return to skip stale pools should catch the error explicitly. The price-router.ts already handles this correctly in its catch path.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions