Skip to content
markpaper

src/errors/statuses.ts

v0.3.0 · 17.5 KB

Download file
// Fail-closed parsing of /exchange response bodies.
//
// Success is only an EXPLICIT per-element confirmation. `{ status: 'ok', statuses: [] }`, a missing
// `statuses`, or an element of an unknown shape are "not confirmed" - and "not confirmed" does not
// mean "never reached the exchange": reconcile against the book before acting. Reading
// `{status:'err'}` or empty statuses as a successful cancel places a replacement over a live order
// and duplicates it.

import { classifyExchangeError, type ExchangeErrorKind } from './classify.js';
import { addDecimal, compareDecimal, parseDecimal, PLAIN_UNSIGNED_DECIMAL, toPlainString, type Dec } from './decimal.js';

/** Overfill tolerance from the knowledge base: `totalSz > requestedSz + 1e-9` is not trusted. */
const OVERFILL_TOLERANCE: Dec = { units: 1n, scale: 9 };

/** Why an element could not be confirmed. */
export type InvalidStatusReason =
  | 'unknown-shape'
  | 'missing'
  | 'bad-oid'
  | 'bad-fill-size'
  | 'bad-fill-price'
  | 'overfill';

/** String statuses that accept an order without returning its oid. */
export type AcceptedWithoutOid = 'resting' | 'waitingForFill' | 'waitingForTrigger';

/** One normalized element of `response.data.statuses` for `order` / `batchModify`. */
export type OrderStatusResult =
  /** Resting in the book. */
  | { readonly status: 'resting'; readonly oid: number; readonly cloid?: `0x${string}` }
  /** Filled immediately (for IoC possibly partially). Sizes and prices stay exact decimal strings. */
  | {
      readonly status: 'filled';
      readonly oid: number;
      readonly totalSz: string;
      readonly avgPx: string;
      readonly cloid?: `0x${string}`;
    }
  /**
   * Accepted, but the exchange answered with a bare string and no oid. `grouping: 'positionTpsl'`
   * returns `'waitingForTrigger'` / `'resting'` like this (observed live); SDK 0.33.3 types also
   * list `'waitingForFill'`. Find the oid via `frontendOpenOrders` (coin, isTrigger, reduceOnly,
   * orderType, triggerPx).
   */
  | { readonly status: 'accepted'; readonly detail: AcceptedWithoutOid }
  /** Rejected by the exchange. */
  | { readonly status: 'error'; readonly message: string; readonly kind: ExchangeErrorKind }
  /** Could not be confirmed either way: reconcile. */
  | { readonly status: 'invalid'; readonly reason: InvalidStatusReason; readonly raw: unknown };

/** One normalized element of `response.data.statuses` for `cancel` / `cancelByCloid`. */
export type CancelStatusResult =
  /** Explicitly canceled. A fill may still have happened just before the cancel. */
  | { readonly status: 'success' }
  /**
   * `Order was never placed, already canceled, or filled.` - the order is not in the book. Forget it
   * locally instead of cancelling forever, but the position may have moved (it may have filled).
   */
  | { readonly status: 'alreadyGone'; readonly message: string }
  /** Any other rejection: the order may still rest. Keep it and do not place a replacement until reconciled. */
  | { readonly status: 'error'; readonly message: string; readonly kind: ExchangeErrorKind }
  /** Unknown shape or missing element: not confirmed. */
  | { readonly status: 'invalid'; readonly reason: 'unknown-shape' | 'missing'; readonly raw: unknown };

/** Whole-response parse result. */
export type ExchangeResponseParse<R> =
  | {
      readonly ok: true;
      readonly results: readonly R[];
      /** `true` when any element is `invalid` or the element count differs from the request. */
      readonly needsReconcile: boolean;
    }
  /** `{ status: 'err', response: '<text>' }`: the whole action was rejected, nothing was applied. */
  | { readonly ok: false; readonly reason: 'rejected'; readonly message: string; readonly kind: ExchangeErrorKind }
  /** Anything else (no `statuses`, empty `statuses`, not `status: 'ok'`): NOT confirmed, reconcile. */
  | { readonly ok: false; readonly reason: 'malformed'; readonly detail: string };

/** Options for order status parsing. */
export interface OrderStatusParseOptions {
  /**
   * Requested size of each order, in batch order (decimal string or number). Enables the overfill
   * check: a `filled.totalSz` above the requested size is not trusted. Also sets the expected count.
   */
  readonly requestedSizes?: ReadonlyArray<string | number | undefined>;
  /** Number of orders sent. Missing elements become `invalid` / `missing`. */
  readonly expectedCount?: number;
}

type Obj = Record<string, unknown>;

function isObj(v: unknown): v is Obj {
  return typeof v === 'object' && v !== null && !Array.isArray(v);
}

const CLOID = /^0x[0-9a-f]{32}$/i;

/**
 * An oid must be a positive safe integer. Ids above 2^53 are silently rounded by `JSON.parse`, and a
 * cancel by a rounded id "succeeds" while the order stays in the book, so an unsafe value is
 * rejected instead of being trusted. Digit strings are accepted for callers
 * that parse JSON losslessly.
 */
function parseOid(v: unknown): number | null {
  if (typeof v === 'number') return Number.isSafeInteger(v) && v > 0 ? v : null;
  if (typeof v === 'string' && /^[1-9]\d{0,15}$/.test(v)) {
    const n = Number(v);
    return Number.isSafeInteger(n) ? n : null;
  }
  return null;
}

function parseCloid(v: unknown): `0x${string}` | undefined {
  return typeof v === 'string' && CLOID.test(v) ? (v.toLowerCase() as `0x${string}`) : undefined;
}

/** Positive decimal kept as the exchange string (or rendered from a number without exponent). */
function positiveDecimal(v: unknown): string | null {
  let text: string;
  if (typeof v === 'string') {
    if (!PLAIN_UNSIGNED_DECIMAL.test(v)) return null; // 'Infinity', '-1', '1e3', '' are not a fill
    text = v;
  } else if (typeof v === 'number') {
    const d = parseDecimal(v);
    if (!d) return null;
    text = toPlainString(d);
  } else {
    return null;
  }
  const d = parseDecimal(text);
  return d && d.units > 0n ? text : null;
}

function invalid(reason: InvalidStatusReason, raw: unknown): OrderStatusResult {
  return { status: 'invalid', reason, raw };
}

function singleKey(o: Obj, keys: readonly string[]): string | null {
  const present = keys.filter((k) => k in o);
  return present.length === 1 ? (present[0] as string) : null;
}

/**
 * Normalizes one `statuses[i]` element of an `order` / `batchModify` response.
 *
 * - `{ resting: { oid, cloid? } }` -> `resting`; `{ filled: { oid, totalSz, avgPx, cloid? } }` -> `filled`;
 * - `'resting' | 'waitingForFill' | 'waitingForTrigger'` -> `accepted` (no oid);
 * - `{ error: string }` -> `error` with a classified `kind`;
 * - anything else -> `invalid` (fail-closed). A fill with a non-numeric, zero or negative `totalSz`
 *   / `avgPx`, a bad oid, or `totalSz` above `requestedSz` is `invalid`, never a trusted fill.
 */
export function parseOrderStatus(raw: unknown, requestedSz?: string | number): OrderStatusResult {
  if (raw === 'resting' || raw === 'waitingForFill' || raw === 'waitingForTrigger') {
    return { status: 'accepted', detail: raw };
  }
  if (!isObj(raw)) return invalid('unknown-shape', raw);
  const key = singleKey(raw, ['resting', 'filled', 'error']);
  if (key === null) return invalid('unknown-shape', raw);

  if (key === 'error') {
    const message = raw.error;
    if (typeof message !== 'string') return invalid('unknown-shape', raw);
    return { status: 'error', message, kind: classifyExchangeError(message) };
  }

  const body = raw[key];
  if (!isObj(body)) return invalid('unknown-shape', raw);
  const oid = parseOid(body.oid);
  if (oid === null) return invalid('bad-oid', raw);
  const cloid = parseCloid(body.cloid);

  if (key === 'resting') {
    return cloid ? { status: 'resting', oid, cloid } : { status: 'resting', oid };
  }

  const totalSz = positiveDecimal(body.totalSz);
  if (totalSz === null) return invalid('bad-fill-size', raw);
  const avgPx = positiveDecimal(body.avgPx);
  if (avgPx === null) return invalid('bad-fill-price', raw);
  if (requestedSz !== undefined) {
    const req = parseDecimal(requestedSz);
    const got = parseDecimal(totalSz);
    // A fill larger than the order is impossible; trusting it would corrupt position accounting.
    // - Tolerance 1e-9 (knowledge base): a requested size computed as a float (`0.28999999999999997`
    //   for an order sent as "0.29") must not turn a normal full fill into "invalid".
    // - A requested size <= 0 has no upper bound: `s: '0'` on a positionTpsl order tracks the whole
    //   position, so any fill is legitimate.
    if (req && got && req.units > 0n && compareDecimal(got, addDecimal(req, OVERFILL_TOLERANCE)) > 0) {
      return invalid('overfill', raw);
    }
  }
  return cloid
    ? { status: 'filled', oid, totalSz, avgPx, cloid }
    : { status: 'filled', oid, totalSz, avgPx };
}

/**
 * Normalizes a `statuses` array of an `order` / `batchModify` response; `statuses[i]` belongs to the
 * i-th order sent. A non-array gives `[]` (use {@link parseOrderResponse} to tell that apart).
 * With `expectedCount` / `requestedSizes`, missing trailing elements become `invalid` / `missing`.
 */
export function parseOrderStatuses(
  statuses: unknown,
  opts: OrderStatusParseOptions = {},
): OrderStatusResult[] {
  const list: readonly unknown[] = Array.isArray(statuses) ? statuses : [];
  const expected = opts.expectedCount ?? opts.requestedSizes?.length;
  const results = list.map((s, i) => parseOrderStatus(s, opts.requestedSizes?.[i]));
  if (expected !== undefined) {
    for (let i = results.length; i < expected; i++) results.push(invalid('missing', undefined));
  }
  return results;
}

/** Oids of orders that were placed (resting or filled). Use it to keep track of orders from a failed batch. */
export function placedOids(results: readonly OrderStatusResult[]): number[] {
  const oids: number[] = [];
  for (const r of results) if (r.status === 'resting' || r.status === 'filled') oids.push(r.oid);
  return oids;
}

interface Located {
  readonly statuses: unknown[] | null;
  readonly rejected: { message: string; kind: ExchangeErrorKind } | null;
  readonly detail: string;
}

function rejectionText(response: unknown): string {
  if (typeof response === 'string') return response;
  try {
    return JSON.stringify(response) ?? String(response);
  } catch {
    return String(response);
  }
}

function locateStatuses(body: unknown): Located {
  if (!isObj(body)) return { statuses: null, rejected: null, detail: 'body is not an object' };
  if (body.status === 'err') {
    const message = rejectionText(body.response);
    return { statuses: null, rejected: { message, kind: classifyExchangeError(message) }, detail: '' };
  }
  if (body.status !== 'ok') return { statuses: null, rejected: null, detail: `status is ${rejectionText(body.status)}` };
  const response = body.response;
  const data = isObj(response) ? response.data : undefined;
  const statuses = isObj(data) ? data.statuses : undefined;
  if (!Array.isArray(statuses)) return { statuses: null, rejected: null, detail: 'no statuses array' };
  return { statuses, rejected: null, detail: '' };
}

function parseBatch<R extends { status: string }>(
  body: unknown,
  expected: number | undefined,
  parseAll: (statuses: unknown[]) => R[],
): ExchangeResponseParse<R> {
  const loc = locateStatuses(body);
  if (loc.rejected) return { ok: false, reason: 'rejected', ...loc.rejected };
  if (!loc.statuses) return { ok: false, reason: 'malformed', detail: loc.detail };
  // `{status:'ok', statuses:[]}` is a trap: it confirms nothing.
  if (loc.statuses.length === 0 && expected !== 0) {
    return { ok: false, reason: 'malformed', detail: 'empty statuses' };
  }
  const results = parseAll(loc.statuses);
  const countMismatch = expected !== undefined && loc.statuses.length !== expected;
  return { ok: true, results, needsReconcile: countMismatch || results.some((r) => r.status === 'invalid') };
}

/**
 * Parses a whole `order` / `batchModify` body (a successful SDK result or the `response` of an
 * `ApiRequestError`), fail-closed:
 *
 * - `status: 'err'` -> `{ ok: false, reason: 'rejected' }`: nothing applied;
 * - `status` other than `'ok'`, no `statuses`, or an empty `statuses` -> `malformed`: not confirmed;
 * - otherwise per-element results, with `needsReconcile` set if anything is unconfirmed.
 */
export function parseOrderResponse(
  body: unknown,
  opts: OrderStatusParseOptions = {},
): ExchangeResponseParse<OrderStatusResult> {
  const expected = opts.expectedCount ?? opts.requestedSizes?.length;
  return parseBatch(body, expected, (statuses) => parseOrderStatuses(statuses, opts));
}

/**
 * Normalizes one cancel status. Only the literal `'success'` confirms a cancel; `alreadyGone` means
 * the order is not in the book; every other shape is `error` / `invalid` and must block a
 * replacement on that side until the book is reconciled.
 */
export function parseCancelStatus(raw: unknown): CancelStatusResult {
  if (raw === 'success') return { status: 'success' };
  if (isObj(raw) && typeof raw.error === 'string' && Object.keys(raw).length === 1) {
    const kind = classifyExchangeError(raw.error);
    return kind === 'orderNotFound'
      ? { status: 'alreadyGone', message: raw.error }
      : { status: 'error', message: raw.error, kind };
  }
  return { status: 'invalid', reason: 'unknown-shape', raw };
}

/** Normalizes a cancel `statuses` array; missing elements up to `expectedCount` become `invalid`. */
export function parseCancelStatuses(statuses: unknown, opts: { expectedCount?: number } = {}): CancelStatusResult[] {
  const list: readonly unknown[] = Array.isArray(statuses) ? statuses : [];
  const results = list.map(parseCancelStatus);
  if (opts.expectedCount !== undefined) {
    for (let i = results.length; i < opts.expectedCount; i++) {
      results.push({ status: 'invalid', reason: 'missing', raw: undefined });
    }
  }
  return results;
}

/** Parses a whole `cancel` / `cancelByCloid` body, fail-closed (see {@link parseOrderResponse}). */
export function parseCancelResponse(
  body: unknown,
  opts: { expectedCount?: number } = {},
): ExchangeResponseParse<CancelStatusResult> {
  return parseBatch(body, opts.expectedCount, (statuses) => parseCancelStatuses(statuses, opts));
}

/** `true` when the order is confirmed out of the book (`success` or `alreadyGone`). */
export function isCancelConfirmed(result: CancelStatusResult): boolean {
  return result.status === 'success' || result.status === 'alreadyGone';
}

/** Normalized `twapOrder` / `twapCancel` result. */
export type TwapStatusResult =
  | { readonly status: 'running'; readonly twapId: number }
  | { readonly status: 'success' }
  | { readonly status: 'error'; readonly message: string; readonly kind: ExchangeErrorKind }
  | { readonly status: 'invalid'; readonly detail: string };

/**
 * Parses a `twapOrder` / `twapCancel` body. TWAP errors come with the OUTER status still `'ok'` and
 * the text inside `response.data.status.error` (e.g. `Invalid TWAP duration: 1 min(s)`), so checking
 * only the outer status reports a rejected TWAP as placed.
 */
export function parseTwapResponse(body: unknown): TwapStatusResult {
  if (!isObj(body)) return { status: 'invalid', detail: 'body is not an object' };
  if (body.status === 'err') {
    const message = rejectionText(body.response);
    return { status: 'error', message, kind: classifyExchangeError(message) };
  }
  if (body.status !== 'ok') return { status: 'invalid', detail: 'status is not ok' };
  const data = isObj(body.response) ? body.response.data : undefined;
  const st = isObj(data) ? data.status : undefined;
  if (st === 'success') return { status: 'success' };
  if (isObj(st)) {
    if (typeof st.error === 'string') return { status: 'error', message: st.error, kind: classifyExchangeError(st.error) };
    const running = st.running;
    if (isObj(running)) {
      const id = running.twapId;
      if (typeof id === 'number' && Number.isSafeInteger(id) && id >= 0) return { status: 'running', twapId: id };
      return { status: 'invalid', detail: 'bad twapId' };
    }
  }
  return { status: 'invalid', detail: 'unknown twap status shape' };
}

/** Result of a single-effect action (`updateLeverage`, `approveAgent`, `scheduleCancel`, transfers...). */
export type ActionResult =
  | { readonly ok: true }
  | { readonly ok: false; readonly reason: 'rejected'; readonly message: string; readonly kind: ExchangeErrorKind }
  | { readonly ok: false; readonly reason: 'malformed'; readonly detail: string };

/**
 * Checks the body of an action that answers `{ status: 'ok', response: { type: 'default' } }`.
 *
 * Always check it: a silently swallowed failed `updateLeverage` keeps opening positions at the
 * account's previous leverage. Only `status: 'ok'` without embedded errors
 * (`data.statuses[i].error`, `data.status.error`) is success.
 */
export function parseActionResponse(body: unknown): ActionResult {
  if (!isObj(body)) return { ok: false, reason: 'malformed', detail: 'body is not an object' };
  if (body.status === 'err') {
    const message = rejectionText(body.response);
    return { ok: false, reason: 'rejected', message, kind: classifyExchangeError(message) };
  }
  if (body.status !== 'ok') return { ok: false, reason: 'malformed', detail: 'status is not ok' };
  const data = isObj(body.response) ? body.response.data : undefined;
  if (isObj(data)) {
    if (isObj(data.status) && typeof data.status.error === 'string') {
      return { ok: false, reason: 'rejected', message: data.status.error, kind: classifyExchangeError(data.status.error) };
    }
    if (Array.isArray(data.statuses)) {
      const firstError = data.statuses.find((s): s is { error: string } => isObj(s) && typeof s.error === 'string');
      if (firstError) {
        return { ok: false, reason: 'rejected', message: firstError.error, kind: classifyExchangeError(firstError.error) };
      }
    }
  }
  return { ok: true };
}
All files