Skip to content
markpaper

src/errors/sdk.ts

v0.3.0 · 8 KB

Download file
// Recognition of @nktkas/hyperliquid error objects without importing the SDK.
//
// Errors are recognized by `name` and shape, never by `instanceof`: SDK 0.27.1 did not export
// ApiRequestError from the package root, and even with 0.33.3 (where it is exported) two copies of
// the SDK in one dependency tree make `instanceof` fail. The cause chain is walked so an SDK error
// wrapped by a retry layer or a logger is still found.

import {
  parseCancelStatuses,
  parseOrderStatuses,
  placedOids,
  type CancelStatusResult,
  type OrderStatusParseOptions,
  type OrderStatusResult,
} from './statuses.js';

const MAX_CAUSE_DEPTH = 6;

/** `err` followed by up to 6 levels of `.cause`; a cycle stops the walk. */
export function causeChain(err: unknown): unknown[] {
  const nodes: unknown[] = [];
  let cur: unknown = err;
  while (nodes.length <= MAX_CAUSE_DEPTH && cur !== null && cur !== undefined && !nodes.includes(cur)) {
    nodes.push(cur);
    if (typeof cur !== 'object') break;
    cur = (cur as { cause?: unknown }).cause;
  }
  return nodes;
}

/** Visits `err` and up to 6 levels of `.cause`; returns the first node the predicate accepts. */
export function findInCauseChain(err: unknown, predicate: (node: unknown) => boolean): unknown {
  return causeChain(err).find(predicate);
}

function hasName(node: unknown, name: string): boolean {
  return typeof node === 'object' && node !== null && (node as { name?: unknown }).name === name;
}

/** Shape of an SDK `ApiRequestError` (0.27.1 and 0.33.3). */
export interface ApiRequestErrorLike {
  readonly name: 'ApiRequestError';
  readonly message: string;
  /** Full raw /exchange body. */
  readonly response: unknown;
}

/** Finds an `ApiRequestError` (by name) in `err` or its cause chain. */
export function findApiRequestError(err: unknown): ApiRequestErrorLike | undefined {
  return findInCauseChain(err, (n) => hasName(n, 'ApiRequestError') && 'response' in (n as object)) as
    | ApiRequestErrorLike
    | undefined;
}

/** The exchange body carried by an `ApiRequestError`. */
export interface ExchangeErrorBody {
  /** `'err'` when the whole action was rejected; `'ok'` for per-element errors (batch or TWAP). */
  readonly status: string;
  readonly response: unknown;
}

/**
 * Returns the /exchange body of an SDK `ApiRequestError`, or `null` for any other error.
 *
 * - `{ status: 'err', response: '<text>' }`: the whole action was rejected, nothing applied;
 * - `{ status: 'ok', response: { type, data: { statuses } } }`: a batch with at least one `{error}`
 *   element - the NEIGHBOURS MAY HAVE BEEN PLACED (see {@link extractPartialBatch});
 * - `{ status: 'ok', response: { data: { status: { error } } } }`: a rejected TWAP.
 */
export function exchangeErrorBody(err: unknown): ExchangeErrorBody | null {
  const api = findApiRequestError(err);
  const body = api?.response as { status?: unknown; response?: unknown } | undefined;
  if (!body || typeof body !== 'object' || typeof body.status !== 'string') return null;
  return { status: body.status, response: body.response };
}

/** Per-element results salvaged from a batch the SDK threw on. */
export type PartialBatch =
  | {
      readonly kind: 'order';
      /** `response.type` of the body (`'order'`, `'batchModify'`...). */
      readonly responseType: string;
      readonly results: readonly OrderStatusResult[];
      /** Oids that are live or filled despite the thrown error: record them, they are real orders. */
      readonly placedOids: readonly number[];
      readonly needsReconcile: boolean;
    }
  | {
      readonly kind: 'cancel';
      readonly responseType: string;
      readonly results: readonly CancelStatusResult[];
      readonly needsReconcile: boolean;
    };

/**
 * Recovers per-order results from a partially failed batch.
 *
 * The SDK throws `ApiRequestError` when ANY `statuses[i]` is `{ error }`, even though the other orders
 * of the batch were placed; their oids live only in `err.response.response.data.statuses` (unchanged
 * from 0.27.1 to 0.33.3). Treating "the SDK threw" as "nothing was placed" loses track of live orders
 * and can orphan a TP/SL pair. Returns `null` when `err` is not such a batch error
 * (transport failure, `status: 'err'` rejection, TWAP rejection, body without a `statuses` array). A
 * `statuses` array that is empty or comes with an unexpected outer status still yields a batch, with
 * `needsReconcile: true`.
 *
 * Bodies with `response.type === 'cancel'` are parsed as cancel statuses, everything else as orders.
 */
export function extractPartialBatch(err: unknown, opts: OrderStatusParseOptions = {}): PartialBatch | null {
  // Read the raw body rather than exchangeErrorBody(): the SDK's bulk check ignores the outer
  // `status`, so a batch body without one still throws and still carries live oids.
  const body = findApiRequestError(err)?.response as { status?: unknown; response?: unknown } | undefined;
  if (!body || typeof body !== 'object' || body.status === 'err') return null;
  const response = body.response as { type?: unknown; data?: { statuses?: unknown } } | undefined;
  const statuses = response?.data?.statuses;
  if (!Array.isArray(statuses)) return null;
  const responseType = typeof response?.type === 'string' ? response.type : '';
  // Once a `statuses` array is present the body is a batch, whatever else is odd about it. An empty
  // array or an unexpected outer status must NOT fall back to "whole action rejected" (that reads as
  // "nothing applied"): it is returned as a batch that needs reconciling.
  const odd = body.status !== 'ok' || statuses.length === 0;
  if (responseType === 'cancel') {
    const results = parseCancelStatuses(statuses, opts.expectedCount === undefined ? {} : { expectedCount: opts.expectedCount });
    const countMismatch = opts.expectedCount !== undefined && statuses.length !== opts.expectedCount;
    const needsReconcile = odd || countMismatch || results.some((r) => r.status === 'invalid');
    return { kind: 'cancel', responseType, results, needsReconcile };
  }
  const expected = opts.expectedCount ?? opts.requestedSizes?.length;
  const results = parseOrderStatuses(statuses, opts);
  const countMismatch = expected !== undefined && statuses.length !== expected;
  return {
    kind: 'order',
    responseType,
    results,
    placedOids: placedOids(results),
    needsReconcile: odd || countMismatch || results.some((r) => r.status === 'invalid'),
  };
}

/**
 * `true` for an SDK `ValidationError` (0.33.3): parameters failed schema validation BEFORE the lock
 * and nonce, so nothing was signed or sent and no nonce was used.
 */
export function isValidationError(err: unknown): boolean {
  return findInCauseChain(err, (n) => hasName(n, 'ValidationError')) !== undefined;
}

/** `true` for an SDK `AbstractWalletError` (signing or address lookup failed; nothing was sent). */
export function isWalletError(err: unknown): boolean {
  return findInCauseChain(err, (n) => hasName(n, 'AbstractWalletError')) !== undefined;
}

/**
 * `true` when a browser wallet user declined the signature: EIP-1193 `code === 4001` or a message
 * matching `/user (denied|rejected)/i`, anywhere in the cause chain.
 */
export function isUserRejectedSignature(err: unknown): boolean {
  return (
    findInCauseChain(err, (n) => {
      if (typeof n !== 'object' || n === null) return typeof n === 'string' && /user (denied|rejected)/i.test(n);
      const o = n as { code?: unknown; message?: unknown };
      return o.code === 4001 || (typeof o.message === 'string' && /user (denied|rejected)/i.test(o.message));
    }) !== undefined
  );
}

/**
 * Calls `fn` and always returns a promise, turning a synchronous throw into a rejection.
 *
 * `ExchangeClient.order/cancel/...` in SDK 0.33.3 are not `async`: parameter validation runs before
 * the promise exists, so a `ValidationError` is thrown synchronously and
 * `ex.order(params).catch(handler)` never sees it. `invokeAsync(() => ex.order(params)).catch(handler)`
 * does.
 */
export function invokeAsync<T>(fn: () => T | PromiseLike<T>): Promise<Awaited<T>> {
  try {
    return Promise.resolve(fn()) as Promise<Awaited<T>>;
  } catch (err) {
    return Promise.reject(err);
  }
}
All files