Skip to content

[HIGH] parseEngineLight reads from wrong offsets on V12_17 and V12_19 slabs, returning config bytes as engine state #337

Description

@Ayomisco

Severity

HIGH

Location

src/solana/discovery.ts:436-475 (layout-driven branch of parseEngineLight)

Root cause

The layout-driven branch of parseEngineLight reads the following fields unconditionally using raw DataView calls, without checking for the sentinel value -1 that indicates a field does not exist in the layout:

// discovery.ts:436-443 (no -1 guard)
fundingIndexQpbE6:       readI128LE(data, base + l.engineFundingIndexOff),
lastFundingSlot:         readU64LE (data, base + l.engineLastFundingSlotOff),
fundingRateBpsPerSlotLast: readI64LE(data, base + l.engineFundingRateBpsOff),
...
maxCrankStalenessSlots:  readU64LE (data, base + l.engineMaxCrankStalenessOff),
totalOpenInterest:       readU128LE(data, base + l.engineTotalOiOff),

For V12_17 and V12_19 layouts (the only layouts deployed on mainnet ESa89R5 since 2026-05-01), these five offsets are -1:

// buildLayoutV12_17 / inherited by buildLayoutV12_19
engineFundingIndexOff:      -1,  // replaced by per-side f_long_num/f_short_num
engineLastFundingSlotOff:   -1,
engineFundingRateBpsOff:    -1,  // no stored funding rate in v12.17/v12.19
engineMaxCrankStalenessOff: -1,
engineTotalOiOff:           -1,  // comment says "parseEngine sums long+short when -1"

Reading readI128LE(data, base + (-1)) evaluates to readI128LE(data, engineOff - 1). For V12_19 SBF (engineOff = 616), that is offset 615. Config occupies bytes 136-615 (headerLen=136, configLen=480). Offset 615 is the last byte of the pending_admin pubkey field in MarketConfig. The 16 bytes at 600-615 are raw pubkey bytes returned as fundingIndexQpbE6, and the 8 bytes at 608-615 are returned as fundingRateBpsPerSlotLast.

The same pattern exists for V12_17 SBF (engineOff = 504): reads from offset 503, which is in the MarketConfig oiCapMultiplierBps/maxPnlCap region.

The existing longOi and shortOi reads on lines 444-445 DO guard against -1 correctly:

longOi:  l.engineLongOiOff  >= 0 ? readU128LE(...) : 0n,
shortOi: l.engineShortOiOff >= 0 ? readU128LE(...) : 0n,

The five fields above are missing the same guard.

Impact

Every call to discoverMarkets or getMarketsByAddress on a V12_17 or V12_19 slab returns incorrect engine state for all five affected fields. Because V12_19 is the only layout produced by the currently deployed mainnet program (ESa89R5), this affects every live market today.

Callers receive:

  • fundingRateBpsPerSlotLast: raw bytes from the config pending_admin pubkey (not a funding rate). Passing this to computeFundingRateAnnualized displays a meaningless, potentially extreme funding rate in the UI.
  • fundingIndexQpbE6: raw config pubkey bytes, not a funding index.
  • lastFundingSlot: config bytes, not a slot number.
  • totalOpenInterest: config bytes, not OI. The comment says the engine should sum longOi + shortOi when totalOiOff === -1, but the code reads garbage instead.
  • maxCrankStalenessSlots: config bytes, not the staleness threshold.

Attack path

A market operator sets pending_admin to a crafted pubkey whose trailing 8 bytes, when reinterpreted as a little-endian i64, represent an extreme funding rate (e.g. 0x7FFFFFFFFFFFFFFF). SDK consumers see an annual funding rate orders of magnitude above reality, discouraging traders from opening positions.

Proof of concept

import { discoverMarkets } from "@percolator/sdk";
import { computeFundingRateAnnualized } from "@percolator/sdk";
import { Connection, PublicKey } from "@solana/web3.js";

const conn = new Connection("https://api.mainnet-beta.solana.com");
const PROG = new PublicKey("ESa89R5bSMNqj1WCm8SCzxiuXMGKxTMFvpHJcXr7U7Y");

const markets = await discoverMarkets(conn, PROG, { network: "mainnet" });
for (const m of markets) {
  // fundingRateBpsPerSlotLast is read from config bytes, not engine
  const annualized = computeFundingRateAnnualized(m.engine.fundingRateBpsPerSlotLast);
  console.log("bogus funding rate:", annualized);  // garbage value
}

Fix

Apply the same -1 guard used for longOi/shortOi to the five affected fields. For totalOpenInterest, sum longOi + shortOi when the offset is absent (matching the existing code comment):

fundingIndexQpbE6: l.engineFundingIndexOff >= 0
  ? readI128LE(data, base + l.engineFundingIndexOff) : 0n,
lastFundingSlot: l.engineLastFundingSlotOff >= 0
  ? readU64LE(data, base + l.engineLastFundingSlotOff) : 0n,
fundingRateBpsPerSlotLast: l.engineFundingRateBpsOff >= 0
  ? readI64LE(data, base + l.engineFundingRateBpsOff) : 0n,
maxCrankStalenessSlots: l.engineMaxCrankStalenessOff >= 0
  ? readU64LE(data, base + l.engineMaxCrankStalenessOff) : 0n,
totalOpenInterest: l.engineTotalOiOff >= 0
  ? readU128LE(data, base + l.engineTotalOiOff)
  : (longOiValue + shortOiValue),  // sum per-side OI when no total field

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