src/account/decode.ts
v0.1.0 · 9.4 KB
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,
};
}