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:
- The on-chain mark price is set to 0.
- 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.
- All long positions appear to have infinite negative PnL and become immediately liquidatable.
- 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.
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
0non invalid or uninitialized pool state instead of throwing an error:Zero is not a valid oracle price for any live asset. Returning
0ninstead of throwing means callers receive no error signal and may silently forward the invalid price downstream.price-router.tsdefends 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 ownencodePermissionlessCrankcaller 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:(oracle - entry) * abs_pos / oracledivides by zero, producing undefined behavior on the on-chain program side.An attacker who controls a PumpSwap LP position can drain the base vault to zero, causing
computePumpSwapPriceE6to return0non the next crank cycle. If the keeper does not check for zero before building the oracle instruction, the nextpushOraclePricetransaction sets mark price to 0 on-chain.Proof of concept
The differential proof:
Fix
Replace silent
return 0nwith throws so callers get an explicit error signal:Callers that previously relied on the
0nreturn to skip stale pools should catch the error explicitly. Theprice-router.tsalready handles this correctly in its catch path.