Skip to content
markpaper

src/account/orders.ts

v0.2.0 · 9.6 KB

Download file
// Own open orders: decoding the `orders` query, ownership and flag-bit recovery from the nonce tag,
// chunked reading by product ids and the completeness flag.
//
// `orders` needs an explicit `product_ids` list (weight 2 per id). A read over PART of the markets looks
// exactly like an empty account; only a read that covered EVERY product of the universe proves "no
// orders anywhere" (`ordersComplete`). Irreversible decisions (e.g. treating the account as empty)
// need that flag. Foreign orders (no tag) are never adopted as ours or cancelled, but they are
// exposure.

import type { PerpProduct } from '../markets/types.js';
import { absX18, parseUint, x18ToBigInt, x18ToNumber } from '../numbers/x18.js';
import { parseAppendix } from '../signing/appendix.js';
import { DEFAULT_NONCE_TAG, parseNonce } from '../signing/nonce.js';
import { isBytes32 } from '../signing/subaccount.js';
import type { Hex } from '../signing/types.js';
import type { QueryRequester } from '../transport/types.js';
import { chunkProductIds } from '../transport/weights.js';

/** Live shape of one order in `product_orders[].orders[]` (knowledge base `orders.md` §8). */
export interface RawOpenOrder {
  product_id: number;
  sender: string;
  price_x18: string;
  amount: string;
  expiration: string;
  order_type?: string;
  nonce: string;
  appendix?: string;
  unfilled_amount: string;
  digest: string;
  placed_at?: number;
}

/** Decoded resting order. */
export interface NadoOpenOrder {
  readonly coin: string;
  readonly productId: number;
  readonly digest: Hex;
  readonly side: 'buy' | 'sell';
  readonly priceX18: bigint;
  readonly price: number;
  /** Remaining size, x18, absolute. */
  readonly unfilledX18: bigint;
  readonly unfilled: number;
  /** Original signed amount, x18. */
  readonly amountX18: bigint;
  readonly nonce: bigint;
  readonly appendix: bigint | null;
  readonly orderType: 'default' | 'ioc' | 'fok' | 'post_only' | null;
  readonly reduceOnly: boolean;
  readonly expiration: bigint;
  /** Unix seconds from `placed_at`. */
  readonly placedAt: number | null;
  /** True when the nonce carries our tag. */
  readonly ours: boolean;
  /** Caller-defined flag bit of OUR nonce (see `buildNonce`); `null` for foreign orders. */
  readonly reduceIntent: boolean | null;
}

/** Thrown by the order decoders. */
export class OrdersParseError extends Error {
  override readonly name = 'OrdersParseError';
}

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

/** Flattens the `data` of `{type:'orders'}` (`product_orders[{orders:[...]}]`) into raw orders. */
export function flattenOrdersData(data: unknown): unknown[] {
  if (!isRecord(data)) throw new OrdersParseError('orders: data is not an object');
  const productOrders = data.product_orders;
  if (!Array.isArray(productOrders)) throw new OrdersParseError('orders: missing product_orders');
  const flat: unknown[] = [];
  for (const po of productOrders) {
    const orders = isRecord(po) ? po.orders : undefined;
    if (!Array.isArray(orders)) throw new OrdersParseError('orders: malformed product_orders entry');
    flat.push(...orders);
  }
  return flat;
}

/**
 * Decodes raw orders. Fully filled remnants (`unfilled_amount === 0`) are skipped. An order on a product
 * absent from `byProductId` fails the whole decode (the universe is stale or the payload is foreign).
 *
 * @throws OrdersParseError
 */
export function parseOpenOrders(
  raw: readonly unknown[],
  byProductId: ReadonlyMap<number, PerpProduct>,
  opts: { tag?: number } = {},
): NadoOpenOrder[] {
  const tag = opts.tag ?? DEFAULT_NONCE_TAG;
  const out: NadoOpenOrder[] = [];
  const seen = new Set<string>();
  for (let i = 0; i < raw.length; i++) {
    const o = raw[i];
    if (!isRecord(o)) throw new OrdersParseError(`orders: item ${i} malformed`);
    const pid = o.product_id;
    if (typeof pid !== 'number' || !Number.isSafeInteger(pid))
      throw new OrdersParseError(`orders: item ${i} has invalid product_id`);
    const product = byProductId.get(pid);
    if (!product) throw new OrdersParseError(`orders: item ${i} references unknown product ${pid}`);
    let priceX18: bigint;
    let unfilledSigned: bigint;
    let amountX18: bigint;
    let nonce: bigint;
    let expiration: bigint;
    let appendix: bigint | null = null;
    try {
      priceX18 = x18ToBigInt(o.price_x18, `orders[${i}].price_x18`);
      unfilledSigned = x18ToBigInt(o.unfilled_amount, `orders[${i}].unfilled_amount`);
      amountX18 = x18ToBigInt(o.amount, `orders[${i}].amount`);
      nonce = parseUint(o.nonce, `orders[${i}].nonce`);
      expiration = parseUint(o.expiration, `orders[${i}].expiration`);
      if (o.appendix !== undefined && o.appendix !== null) appendix = parseUint(o.appendix, `orders[${i}].appendix`);
    } catch (e) {
      throw new OrdersParseError(`orders: ${(e as Error).message}`);
    }
    if (priceX18 <= 0n) throw new OrdersParseError(`orders: item ${i} has non-positive price`);
    const digest = typeof o.digest === 'string' ? o.digest.toLowerCase() : '';
    if (!isBytes32(digest)) throw new OrdersParseError(`orders: item ${i} has a malformed digest`);
    if (unfilledSigned === 0n) continue;
    if (seen.has(digest)) throw new OrdersParseError(`orders: duplicate digest ${digest}`);
    seen.add(digest);
    const parts = parseNonce(nonce);
    const ours = parts.tag === tag;
    const decodedAppendix = appendix === null ? null : parseAppendix(appendix);
    const placedAt = typeof o.placed_at === 'number' && Number.isFinite(o.placed_at) ? o.placed_at : null;
    out.push({
      coin: product.coin,
      productId: pid,
      digest: digest as Hex,
      side: unfilledSigned > 0n ? 'buy' : 'sell',
      priceX18,
      price: x18ToNumber(priceX18),
      unfilledX18: absX18(unfilledSigned),
      unfilled: x18ToNumber(absX18(unfilledSigned)),
      amountX18,
      nonce,
      appendix,
      orderType: decodedAppendix?.orderType ?? null,
      reduceOnly: decodedAppendix?.reduceOnly ?? false,
      expiration,
      placedAt,
      ours,
      reduceIntent: ours ? parts.reduceIntent : null,
    });
  }
  return out;
}

/** Options of {@link readOpenOrders}. */
export interface ReadOpenOrdersOptions {
  query: QueryRequester;
  /** bytes32 subaccount. */
  sender: Hex;
  /** Universe for names and completeness. */
  byProductId: ReadonlyMap<number, PerpProduct>;
  /** Products to read, or `'all'` for a full sweep of the universe. */
  productIds: 'all' | Iterable<number>;
  /** Nonce tag that marks our orders. Default {@link DEFAULT_NONCE_TAG}. */
  tag?: number;
  /** Caller-selected products per request; must fit the configured query bucket. */
  chunkSize: number;
  /** Telemetry label. Default `'orders'`. */
  label?: string;
}

/** Result of {@link readOpenOrders}. */
export interface OpenOrdersRead {
  readonly orders: readonly NadoOpenOrder[];
  /** Orders carrying our tag. */
  readonly ours: readonly NadoOpenOrder[];
  /** Orders without our tag (manual, or placed by another process on the same subaccount). */
  readonly foreign: readonly NadoOpenOrder[];
  /** True only when the read covered EVERY product of the universe. */
  readonly ordersComplete: boolean;
  /** Product ids that were queried. */
  readonly productIds: readonly number[];
}

/**
 * Reads open orders for the given products in chunks. Any chunk failure throws: a partial read must not
 * be reported as "fewer orders".
 */
export async function readOpenOrders(options: ReadOpenOrdersOptions): Promise<OpenOrdersRead> {
  const { query, sender, byProductId } = options;
  const chunkSize = options.chunkSize;
  const universeIds = [...byProductId.keys()];
  const ids = options.productIds === 'all' ? universeIds : [...new Set(options.productIds)];
  for (const id of ids)
    if (!byProductId.has(id)) throw new OrdersParseError(`orders: product ${id} is not in the universe`);
  const flat: unknown[] = [];
  for (const chunk of chunkProductIds(ids, chunkSize)) {
    const data = await query({ type: 'orders', sender, product_ids: chunk }, { label: options.label ?? 'orders' });
    flat.push(...flattenOrdersData(data));
  }
  const orders = parseOpenOrders(flat, byProductId, { tag: options.tag });
  const queried = new Set(ids);
  const ordersComplete = universeIds.every((id) => queried.has(id));
  return {
    orders,
    ours: orders.filter((o) => o.ours),
    foreign: orders.filter((o) => !o.ours),
    ordersComplete,
    productIds: ids,
  };
}

/** Default interval of the full order sweep per subaccount. */
export const DEFAULT_FULL_SWEEP_MS = 5 * 60_000;

/** Per-key clock for the full sweep window. */
export interface SweepClock {
  /** True when the window for `key` (e.g. the bytes32 subaccount) has elapsed. */
  due(key: string): boolean;
  /** Marks the window as spent NOW. Call it only when the sweep-backed snapshot is usable (stable, positions ok). */
  mark(key: string): void;
  /** Forgets a key. */
  reset(key?: string): void;
}

/**
 * Sweep clock keyed by subaccount. A single clock shared by several subaccounts lets the first one polled
 * consume the window, and the others never get a full read (their stray orders stay invisible).
 */
export function createSweepClock(opts: { intervalMs?: number; now?: () => number } = {}): SweepClock {
  const interval = opts.intervalMs ?? DEFAULT_FULL_SWEEP_MS;
  if (!(interval > 0)) throw new RangeError('intervalMs must be positive');
  const now = opts.now ?? (() => Date.now());
  const last = new Map<string, number>();
  return {
    due: (key) => now() - (last.get(key) ?? Number.NEGATIVE_INFINITY) >= interval,
    mark: (key) => {
      last.set(key, now());
    },
    reset: (key) => {
      if (key === undefined) last.clear();
      else last.delete(key);
    },
  };
}
All files