Skip to content
markpaper

src/account/decode.ts

v0.1.0 · 9.4 KB

Download file
import { BID_SEQ_MIN, parseI64, parseU64 } from '../numbers/ints.js';
import type { EchoRow } from '../pending/memory.js';
export class PhoenixReadError extends Error {
  override name = 'PhoenixReadError';
}
export interface StatePosition {
  symbol: string;
  subaccountIndex: number;
  lots: bigint;
  entryPriceTicks: bigint;
  entryPriceUsd: number | null;
}
export interface StateOrder extends EchoRow {
  subaccountIndex: number;
  conditional: boolean;
}
export interface DecodedState {
  slot: number;
  capabilitiesState: string | null;
  positions: StatePosition[];
  orders: StateOrder[];
  collateralBySub: Map<number, bigint>;
}
function arrOrEmpty(v: unknown, what: string): unknown[] {
  if (v === undefined || v === null) return [];
  if (!Array.isArray(v)) throw new PhoenixReadError('Expected an array');
  return v;
}
export function decodeTraderState(
  payload: unknown,
  expected: {
    authority: string;
    traderPdaIndex: number;
  },
): DecodedState {
  if (!payload || typeof payload !== 'object' || Array.isArray(payload))
    throw new PhoenixReadError('Response must be an object');
  const p = payload as Record<string, unknown>;
  if (p.authority !== expected.authority) throw new PhoenixReadError('State authority differs from expected owner');
  if (p.traderPdaIndex !== expected.traderPdaIndex)
    throw new PhoenixReadError('State PDA index differs from expected index');
  const slot = p.slot;
  if (typeof slot !== 'number' || !Number.isSafeInteger(slot) || slot <= 0)
    throw new PhoenixReadError('Snapshot requires a positive safe-integer slot');
  const snap = p.snapshot as Record<string, unknown> | undefined;
  if (!snap || typeof snap !== 'object' || Array.isArray(snap)) throw new PhoenixReadError('Snapshot object missing');
  const subs = snap.subaccounts;
  if (!Array.isArray(subs)) throw new PhoenixReadError('Snapshot subaccounts must be an array');
  const caps = snap.capabilities as
    | {
        state?: unknown;
      }
    | undefined;
  const positions: StatePosition[] = [];
  const orders: StateOrder[] = [];
  const collateralBySub = new Map<number, bigint>();
  const seenSub = new Set<number>();
  const seenPositions = new Set<string>();
  for (const rawSub of subs as unknown[]) {
    if (!rawSub || typeof rawSub !== 'object' || Array.isArray(rawSub))
      throw new PhoenixReadError('Subaccount must be an object');
    const s = rawSub as Record<string, unknown>;
    const idx = s.subaccountIndex;
    if (typeof idx !== 'number' || !Number.isInteger(idx) || idx < 0 || idx > 255)
      throw new PhoenixReadError('Subaccount index must be an integer in 0..255');
    if (seenSub.has(idx)) throw new PhoenixReadError('Duplicate subaccount index');
    seenSub.add(idx);
    const coll = parseI64(s.collateral);
    if (coll === null) throw new PhoenixReadError('Collateral must be an i64 decimal string');
    collateralBySub.set(idx, coll);
    for (const rp of arrOrEmpty(s.positions, `subaccount ${idx}: positions`)) {
      if (!rp || typeof rp !== 'object') throw new PhoenixReadError('Position must be an object');
      const r = rp as Record<string, unknown>;
      const symbol = typeof r.symbol === 'string' ? r.symbol : '';
      if (!symbol) throw new PhoenixReadError('Market symbol missing');
      const positionKey = `${idx}:${symbol}`;
      if (seenPositions.has(positionKey)) throw new PhoenixReadError('Duplicate position symbol within subaccount');
      seenPositions.add(positionKey);
      const lots = parseI64(r.basePositionLots);
      if (lots === null) throw new PhoenixReadError('Position lots must be an i64 decimal string');
      const entryTicks = parseU64(r.entryPriceTicks);
      if (entryTicks === null) throw new PhoenixReadError('Entry price ticks must be a u64 decimal string');
      const entryUsd =
        typeof r.entryPriceUsd === 'string' && /^\d+(\.\d+)?$/.test(r.entryPriceUsd) ? Number(r.entryPriceUsd) : null;
      if (entryUsd !== null && !Number.isFinite(entryUsd)) throw new PhoenixReadError('Entry USD price is not finite');
      positions.push({ symbol, subaccountIndex: idx, lots, entryPriceTicks: entryTicks, entryPriceUsd: entryUsd });
    }
    for (const rg of arrOrEmpty(s.orders, `subaccount ${idx}: orders`)) {
      if (!rg || typeof rg !== 'object') throw new PhoenixReadError('Order group must be an object');
      const g = rg as Record<string, unknown>;
      const symbol = typeof g.symbol === 'string' ? g.symbol : '';
      if (!symbol) throw new PhoenixReadError('Market symbol missing');
      for (const ro of arrOrEmpty(g.orders, `${symbol}: orders`)) {
        if (!ro || typeof ro !== 'object') throw new PhoenixReadError('Order must be an object');
        const o = ro as Record<string, unknown>;
        if (o.change === 'closed') throw new PhoenixReadError('Closed delta row is invalid in a state snapshot');
        const side = o.side;
        if (side !== 'bid' && side !== 'ask') throw new PhoenixReadError('Order side must be bid or ask');
        const seq = parseU64(o.orderSequenceNumber);
        const ticks = parseU64(o.priceTicks);
        const lotsLeft = parseU64(o.sizeRemainingLots);
        if (seq === null || ticks === null || ticks === 0n || lotsLeft === null) {
          throw new PhoenixReadError(
            'Order identity and remaining size require exact u64 decimal strings; price must be positive',
          );
        }
        if (seq >= BID_SEQ_MIN !== (side === 'bid'))
          throw new PhoenixReadError('Order sequence number disagrees with bid/ask side');
        if (typeof o.reduceOnly !== 'boolean') throw new PhoenixReadError('reduceOnly must be boolean');
        if (lotsLeft === 0n) continue;
        orders.push({
          symbol,
          subaccountIndex: idx,
          side,
          priceTicks: ticks.toString(),
          seq: seq.toString(),
          lots: lotsLeft,
          reduceOnly: o.reduceOnly,
          conditional: o.isStopLoss === true || o.isConditionalOrder === true,
        });
      }
    }
  }
  if (!seenSub.has(0)) throw new PhoenixReadError('Duplicate subaccount index');
  return {
    slot,
    capabilitiesState: typeof caps?.state === 'string' ? caps.state : null,
    positions,
    orders,
    collateralBySub,
  };
}
export interface ViewPosition {
  positionValue: number;
  unrealizedPnl: number;
  initialMargin: number;
  sizeNonZero: boolean | null;
}
export interface DecodedView {
  slot: number | null;
  portfolioValue: number;
  riskState: string | null;
  positions: Map<string, ViewPosition>;
  initialMarginUsd: number | null;
  collateralUsd: number | null;
}
function uiAmount(v: unknown, what: string): number {
  const ui = (
    v as {
      ui?: unknown;
    } | null
  )?.ui;
  if (typeof ui !== 'string' || !/^-?\d+(\.\d+)?$/.test(ui))
    throw new PhoenixReadError('TokenAmount.ui must be a decimal string');
  const n = Number(ui);
  if (!Number.isFinite(n)) throw new PhoenixReadError('TokenAmount.ui is not finite');
  return n;
}
function uiAmountOpt(v: unknown): number | null {
  const ui = (
    v as
      | {
          ui?: unknown;
        }
      | null
      | undefined
  )?.ui;
  const n = typeof ui === 'string' && /^-?\d+(\.\d+)?$/.test(ui) ? Number(ui) : NaN;
  return Number.isFinite(n) ? n : null;
}
export function decodeTraderView(
  payload: unknown,
  expected: {
    authority: string;
    traderPda: string;
  },
): DecodedView {
  if (!payload || typeof payload !== 'object' || Array.isArray(payload))
    throw new PhoenixReadError('Response must be an object');
  const v = payload as Record<string, unknown>;
  if (typeof v.error === 'string') throw new PhoenixReadError('Phoenix response validation failed');
  if (v.authority !== expected.authority) throw new PhoenixReadError('View authority differs from expected owner');
  if (v.traderKey !== expected.traderPda) throw new PhoenixReadError('View trader PDA differs from expected account');
  if (v.traderPdaIndex !== 0 || v.traderSubaccountIndex !== 0) throw new PhoenixReadError('View indices must be 0/0');
  const portfolioValue = uiAmount(v.portfolioValue, 'portfolioValue');
  const positions = new Map<string, ViewPosition>();
  for (const rp of arrOrEmpty(v.positions, 'positions')) {
    const r = rp as Record<string, unknown>;
    const symbol = typeof r?.symbol === 'string' ? r.symbol : '';
    if (!symbol) throw new PhoenixReadError('Market symbol missing');
    if (positions.has(symbol)) throw new PhoenixReadError('Duplicate view position symbol');
    const sizeUi = (
      r.positionSize as
        | {
            ui?: unknown;
          }
        | null
        | undefined
    )?.ui;
    positions.set(symbol, {
      positionValue: Math.abs(uiAmount(r.positionValue, `${symbol}.positionValue`)),
      unrealizedPnl: uiAmount(r.unrealizedPnl, `${symbol}.unrealizedPnl`),
      initialMargin: Math.abs(uiAmount(r.positionInitialMargin ?? r.initialMargin, `${symbol}.initialMargin`)),
      sizeNonZero:
        typeof sizeUi === 'string' && /^-?\d+(\.\d+)?$/.test(sizeUi) && Number.isFinite(Number(sizeUi))
          ? Number(sizeUi) !== 0
          : null,
    });
  }
  const slot = typeof v.slot === 'number' && Number.isSafeInteger(v.slot) ? v.slot : null;
  const im = uiAmountOpt(v.initialMargin);
  const coll = uiAmountOpt(v.effectiveCollateral) ?? uiAmountOpt(v.collateralBalance);
  return {
    slot,
    portfolioValue,
    riskState: typeof v.riskState === 'string' ? v.riskState : null,
    positions,
    initialMarginUsd: im !== null ? Math.abs(im) : null,
    collateralUsd: coll,
  };
}
All files