Skip to content
markpaper

src/account/parse.ts

v0.2.0 · 7.3 KB

Download file
// Fail-closed decoder of `subaccount_info` (weight 2): `exists`, `healths[]`, `perp_balances[]`, `perp_products[]`.
//
// Account value = unweighted health (`healths[2].health`, x18): assets minus liabilities in USDT0.
// Unified cross margin: the USDT0 collateral is already inside it, and adding the balance again doubles
// the equity. A missing `exists`, fewer than 3 healths, a
// malformed product id or a duplicate balance invalidate the whole read (never "partially right").

import { x18ToBigInt, x18ToNumber } from '../numbers/x18.js';

/** One perp balance (position) as decoded from `perp_balances[]`. */
export interface SubaccountBalance {
  readonly productId: number;
  /** Signed position size, x18 (positive = long). */
  readonly amountX18: bigint;
  /** Quote leg of the position, x18 (`v_quote_balance`). */
  readonly vQuoteX18: bigint;
}

/** Product risk data as decoded from `perp_products[]`. */
export interface SubaccountProduct {
  readonly productId: number;
  readonly oraclePriceX18: bigint;
  /** `risk.long_weight_initial_x18` when present. */
  readonly longWeightInitialX18: bigint | null;
}

/** Decoded `subaccount_info`. */
export interface SubaccountInfo {
  /** False when the subaccount was never created (no deposit) - or the NAME is wrong. */
  readonly exists: boolean;
  /** Unweighted health (`healths[2]`), x18; `0n` when the subaccount does not exist. */
  readonly accountValueX18: bigint;
  /** Unweighted health as a double (USDT0). */
  readonly accountValue: number;
  /**
   * `healths[0]` - initial health by documentation, never checked live.
   * @experimental
   */
  readonly initialHealthX18: bigint;
  /**
   * `healths[1]` - maintenance health by documentation, never checked live.
   * @experimental
   */
  readonly maintenanceHealthX18: bigint;
  /** Balances by product id (every entry, including zero amounts). */
  readonly balances: ReadonlyMap<number, SubaccountBalance>;
  /** Product risk data by product id. */
  readonly products: ReadonlyMap<number, SubaccountProduct>;
}

/** Thrown by {@link parseSubaccountInfo}. */
export class SubaccountInfoParseError extends Error {
  override readonly name = 'SubaccountInfoParseError';
}

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

function productIdOf(v: unknown, label: string): number {
  if (typeof v !== 'number' || !Number.isSafeInteger(v) || v < 0)
    throw new SubaccountInfoParseError(`subaccount_info: ${label} has invalid product_id`);
  return v;
}

/**
 * Decodes the `data` of `{type:'subaccount_info', subaccount}`.
 *
 * @throws SubaccountInfoParseError on any malformed field.
 */
export function parseSubaccountInfo(payload: unknown): SubaccountInfo {
  if (!isRecord(payload)) throw new SubaccountInfoParseError('subaccount_info: data is not an object');
  if (typeof payload.exists !== 'boolean') throw new SubaccountInfoParseError('subaccount_info: missing exists flag');
  const exists = payload.exists;

  const healths = payload.healths;
  if (!Array.isArray(healths) || healths.length < 3)
    throw new SubaccountInfoParseError('subaccount_info: malformed healths');
  const healthAt = (i: number): bigint => {
    const h = healths[i];
    if (!isRecord(h)) throw new SubaccountInfoParseError(`subaccount_info: malformed healths[${i}]`);
    try {
      return x18ToBigInt(h.health, `healths[${i}].health`);
    } catch (e) {
      throw new SubaccountInfoParseError(`subaccount_info: ${(e as Error).message}`);
    }
  };
  const initialHealthX18 = healthAt(0);
  const maintenanceHealthX18 = healthAt(1);
  const unweighted = healthAt(2);
  const accountValueX18 = exists ? unweighted : 0n;

  const rawProducts = payload.perp_products ?? [];
  if (!Array.isArray(rawProducts)) throw new SubaccountInfoParseError('subaccount_info: malformed perp_products');
  const products = new Map<number, SubaccountProduct>();
  for (let i = 0; i < rawProducts.length; i++) {
    const p = rawProducts[i];
    if (!isRecord(p)) throw new SubaccountInfoParseError(`subaccount_info: perp_products[${i}] malformed`);
    const productId = productIdOf(p.product_id, `perp_products[${i}]`);
    let oraclePriceX18: bigint;
    let longWeightInitialX18: bigint | null = null;
    try {
      oraclePriceX18 = x18ToBigInt(p.oracle_price_x18, `perp_products[${i}].oracle_price_x18`);
      const risk = p.risk;
      if (isRecord(risk) && risk.long_weight_initial_x18 !== undefined && risk.long_weight_initial_x18 !== null) {
        longWeightInitialX18 = x18ToBigInt(
          risk.long_weight_initial_x18,
          `perp_products[${i}].risk.long_weight_initial_x18`,
        );
      }
    } catch (e) {
      throw new SubaccountInfoParseError(`subaccount_info: ${(e as Error).message}`);
    }
    if (products.has(productId))
      throw new SubaccountInfoParseError(`subaccount_info: duplicate perp_products entry for product ${productId}`);
    products.set(productId, { productId, oraclePriceX18, longWeightInitialX18 });
  }

  const rawBalances = payload.perp_balances ?? [];
  if (!Array.isArray(rawBalances)) throw new SubaccountInfoParseError('subaccount_info: malformed perp_balances');
  const balances = new Map<number, SubaccountBalance>();
  for (let i = 0; i < rawBalances.length; i++) {
    const b = rawBalances[i];
    if (!isRecord(b)) throw new SubaccountInfoParseError(`subaccount_info: perp_balances[${i}] malformed`);
    const productId = productIdOf(b.product_id, `perp_balances[${i}]`);
    const bal = b.balance;
    if (!isRecord(bal)) throw new SubaccountInfoParseError(`subaccount_info: perp_balances[${i}] has no balance`);
    let amountX18: bigint;
    let vQuoteX18: bigint;
    try {
      amountX18 = x18ToBigInt(bal.amount, `perp_balances[${i}].amount`);
      vQuoteX18 = x18ToBigInt(bal.v_quote_balance, `perp_balances[${i}].v_quote_balance`);
    } catch (e) {
      throw new SubaccountInfoParseError(`subaccount_info: ${(e as Error).message}`);
    }
    if (balances.has(productId))
      throw new SubaccountInfoParseError(`subaccount_info: duplicate perp balance for product ${productId}`);
    balances.set(productId, { productId, amountX18, vQuoteX18 });
  }

  return {
    exists,
    accountValueX18,
    accountValue: x18ToNumber(accountValueX18),
    initialHealthX18,
    maintenanceHealthX18,
    balances,
    products,
  };
}

/** Signed position amount (x18) of one product from a decoded info; `0n` when absent. */
export function positionAmountX18(info: SubaccountInfo, productId: number): bigint {
  return info.balances.get(productId)?.amountX18 ?? 0n;
}

/** Map product id -> signed amount for every NON-ZERO balance (what the stability fence compares). */
export function nonZeroAmounts(info: SubaccountInfo): Map<number, bigint> {
  const out = new Map<number, bigint>();
  for (const [pid, b] of info.balances) if (b.amountX18 !== 0n) out.set(pid, b.amountX18);
  return out;
}

/**
 * True when no non-zero amount changed between two reads (positions -> orders -> positions fence). A
 * changed amount means something filled during the read: order sizes computed from that snapshot are
 * unreliable - skip the tick.
 */
export function amountsUnchanged(before: SubaccountInfo, after: SubaccountInfo): boolean {
  const a = nonZeroAmounts(before);
  const b = nonZeroAmounts(after);
  if (a.size !== b.size) return false;
  for (const [pid, amt] of a) if (b.get(pid) !== amt) return false;
  return true;
}
All files