Skip to content
markpaper

src/rest/account.ts

v0.2.1 · 5.9 KB

Download file
// Fail-closed decoding of `GET /api/v1/account?by=index&value=<account_index>`
// (knowledge base: account-and-leverage.md §1-3).
//
//   - `accounts[0]` is the account; an empty array is a READ ERROR, not an empty account;
//   - the sign of a position lives in `sign` (+-1), `position` is the magnitude: `size = sign * |position|`;
//   - equity = `total_asset_value`; there is no free spot balance outside it;
//   - `initial_margin_fraction` of a position is PERCENT (`"50.00"` = 2x), divide by 100;
//   - a broken record (no symbol, bad sign/position, duplicate symbol, NaN equity) makes the WHOLE read
//     untrusted: a truncated read is indistinguishable from an empty account.

import { leverageFromAccountFraction } from '../numbers/margin.js';
import { LighterReadError } from './errors.js';

export interface LighterPosition {
  symbol: string;
  marketId: number;
  /** Signed size: negative = short. */
  size: number;
  /** `sign` as sent (+1 / -1). */
  sign: number;
  /** `position` as sent (magnitude). */
  magnitude: number;
  entryPrice: number;
  /** Absolute notional (`position_value`). */
  positionValue: number;
  unrealizedPnl: number;
  /** Raw `initial_margin_fraction` string in PERCENT (`"50.00"`). */
  initialMarginFractionPercent: string;
  /** `round(100 / initial_margin_fraction)`; garbage -> 1. */
  leverage: number;
  /** `margin_mode` `"1"` = isolated, anything else = cross. Isolated was not verified live. */
  marginMode: 'cross' | 'isolated';
}

export interface LighterAccount {
  /** `index` = account_index. */
  index: number;
  /** `l1_address`, lower-cased. Compare with the expected owner at startup; a mismatch is trading on someone else's account. */
  l1Address: string;
  /** Equity. */
  totalAssetValue: number;
  collateral: number;
  availableBalance: number;
  crossInitialMarginRequirement: number;
  totalOrderCount: number;
  /** Open (non-zero) positions by symbol. */
  positions: Map<string, LighterPosition>;
}

const num = (v: unknown): number => {
  if (typeof v === 'number') return v;
  if (typeof v === 'string' && v.trim() !== '') return Number(v);
  return Number.NaN;
};

function isRecord(v: unknown): v is Record<string, unknown> {
  return typeof v === 'object' && v !== null && !Array.isArray(v);
}

/** `sign * |position|`; NaN when either is not a finite number. */
export function signedPosition(sign: unknown, position: unknown): number {
  const s = num(sign);
  const p = num(position);
  if (!Number.isFinite(s) || !Number.isFinite(p)) return Number.NaN;
  return s < 0 ? -Math.abs(p) : Math.abs(p);
}

/**
 * Decodes an account response. Throws {@link LighterReadError} on anything that makes the read
 * untrustworthy; never returns a partial account.
 */
export function parseAccount(payload: unknown): LighterAccount {
  const accounts = isRecord(payload) ? payload.accounts : undefined;
  if (!Array.isArray(accounts) || accounts.length === 0) {
    throw new LighterReadError('account: empty or missing accounts[] (a read error, not an empty account)');
  }
  const a = accounts[0];
  if (!isRecord(a)) throw new LighterReadError('account: accounts[0] is not an object');
  const totalAssetValue = num(a.total_asset_value);
  if (!Number.isFinite(totalAssetValue)) throw new LighterReadError('account: total_asset_value is not a number');
  const index = num(a.index);
  const positions = new Map<string, LighterPosition>();
  const raw = a.positions;
  if (raw !== undefined && !Array.isArray(raw)) throw new LighterReadError('account: positions is not an array');
  for (const p of Array.isArray(raw) ? raw : []) {
    if (!isRecord(p)) throw new LighterReadError('account: position is not an object');
    const symbol = typeof p.symbol === 'string' ? p.symbol.trim() : '';
    if (!symbol) throw new LighterReadError('account: position without symbol');
    const size = signedPosition(p.sign, p.position);
    if (!Number.isFinite(size)) throw new LighterReadError(`account: position ${symbol} has broken sign/position`);
    if (size === 0) continue;
    if (positions.has(symbol)) throw new LighterReadError(`account: duplicate position ${symbol}`);
    const imf =
      typeof p.initial_margin_fraction === 'string'
        ? p.initial_margin_fraction
        : String(p.initial_margin_fraction ?? '');
    const entry = num(p.avg_entry_price);
    const value = num(p.position_value);
    const upnl = num(p.unrealized_pnl);
    const marketId = num(p.market_id);
    positions.set(symbol, {
      symbol,
      marketId: Number.isInteger(marketId) ? marketId : -1,
      size,
      sign: size < 0 ? -1 : 1,
      magnitude: Math.abs(size),
      entryPrice: Number.isFinite(entry) ? entry : 0,
      positionValue: Number.isFinite(value) ? Math.abs(value) : 0,
      unrealizedPnl: Number.isFinite(upnl) ? upnl : 0,
      initialMarginFractionPercent: imf,
      leverage: leverageFromAccountFraction(imf),
      marginMode: String(p.margin_mode ?? '') === '1' ? 'isolated' : 'cross',
    });
  }
  const finiteOr = (v: unknown, fallback: number) => {
    const n = num(v);
    return Number.isFinite(n) ? n : fallback;
  };
  return {
    index: Number.isInteger(index) ? index : -1,
    l1Address: typeof a.l1_address === 'string' ? a.l1_address.trim().toLowerCase() : '',
    totalAssetValue,
    collateral: finiteOr(a.collateral, 0),
    availableBalance: finiteOr(a.available_balance, 0),
    crossInitialMarginRequirement: finiteOr(a.cross_initial_margin_requirement, 0),
    totalOrderCount: finiteOr(a.total_order_count, 0),
    positions,
  };
}

/**
 * Preflight check of the account owner: the configured `account_index` must belong to the address the
 * process believes it trades for. A mismatch is a startup refusal, not a warning.
 */
export function accountOwnerMatches(account: Pick<LighterAccount, 'l1Address'>, expectedAddress: string): boolean {
  const expected = expectedAddress.trim().toLowerCase();
  return expected !== '' && account.l1Address === expected;
}
All files