Skip to content
markpaper

src/account/snapshot.ts

v0.3.0 · 6.1 KB

Download file
import type { InfoRequester } from '../transport/types.js';
import { normalizeAddress } from './address.js';
import { callOptions } from './call.js';
import type { AccountCallOptions } from './call.js';
import { HlAccountError } from './errors.js';
import { describe, isObject, parsePerpStates } from './parse.js';
import type { AccountSnapshot, PerpDexState, SpotRead } from './types.js';

/**
 * Lists every perp dex id: `""` (main) followed by each builder dex name from `{type:"perpDexs"}`.
 *
 * Why not a hardcoded `["", "xyz"]`: the dex list keeps growing, and equity on a dex missing from the
 * list is invisible (a newly listed dex can hold part of the equity). `perpDexs` is a heavy request
 * (weight 20); cache its result and pass it as `dexes` when polling.
 *
 * Index 0 of the answer is `null` (main dex); `null` entries elsewhere are skipped. Any other entry must be
 * an object with a non-empty string `name`: a malformed entry throws instead of being skipped, because a
 * silently missing dex makes the equity sum invalid as a whole.
 *
 * @throws {HlAccountError} `INVALID_RESPONSE` when the answer is not an array or an entry is malformed.
 */
export async function fetchPerpDexNames(info: InfoRequester, opts?: AccountCallOptions): Promise<string[]> {
  const raw = await info<unknown>({ type: 'perpDexs' }, callOptions(opts));
  if (!Array.isArray(raw)) {
    throw new HlAccountError('INVALID_RESPONSE', `perpDexs answer is not an array (got ${describe(raw)})`);
  }
  const names = [''];
  raw.forEach((d, i) => {
    if (d === null) return;
    if (!isObject(d) || typeof d.name !== 'string' || d.name === '') {
      throw new HlAccountError('INVALID_RESPONSE', `perpDexs[${i}] has no dex name (got ${describe(d)})`);
    }
    if (!names.includes(d.name)) names.push(d.name);
  });
  return names;
}

export interface FetchAccountSnapshotOptions extends AccountCallOptions {
  /**
   * Perp dexes to read. `"all"` (default) resolves the full list via `perpDexs` on every call.
   * An explicit list is used as given, but the main dex `""` is always included and placed first:
   * the main dex is encoded as an empty string and `list.filter(Boolean)` silently drops it,
   * hiding the main dex's share of the equity.
   *
   * When passing a subset, remember the sum only covers those dexes — label it as such.
   */
  dexes?: readonly string[] | 'all';
}

/** Resolves the requested dex list: main dex first, deduplicated, strings only. */
export function resolveDexList(dexes: readonly string[]): string[] {
  const out = [''];
  for (const d of dexes) {
    if (typeof d !== 'string') throw new HlAccountError('INVALID_ARGUMENT', `dex ids must be strings (got ${describe(d)})`);
    if (!out.includes(d)) out.push(d);
  }
  return out;
}

/**
 * Reads the perp state of every dex and the spot state in ONE `Promise.all`.
 *
 * Rules implemented:
 * - `clearinghouseState` covers one dex only; without `dex` it returns just the main dex. Each HIP-3
 *   dex is read with `dex: "<name>"` (skipping them understates equity by whatever sits on those dexes).
 * - Perp and spot are read at the same moment: HL flips the Unified Account convention between
 *   replicas (spot collateral inside the perp number or outside), so only a same-moment sum is invariant.
 * - Every perp answer is validated (see {@link parseDexState}); HTTP 200 without `marginSummary`,
 *   a non-numeric `szi`, cross-dex duplicate coins etc. throw `DEGRADED_PERP` instead of reading as $0/flat.
 * - A failed or malformed spot read does NOT reject the snapshot: it is recorded as
 *   `spot: { ok: false }`. A throwing spot read inside `Promise.all` would drop the whole tick,
 *   guard closes and TP maintenance included, although the perp reads were fine.
 *
 * A single snapshot is not a fact: lagging replicas return well-formed answers with equity 9x lower
 * (or 48x higher). Smooth the SUM with {@link createEquitySmoother} before acting on it.
 * After your own fill `clearinghouseState` lags by up to ~1 s: two fast entry signals can both see "no
 * position" and double it. Decide entries from your local record; re-read HL no sooner than ~1.2 s.
 *
 * @param user Account address (master or sub-account), NOT the agent/API wallet address.
 * @throws {HlAccountError} `INVALID_ARGUMENT`, `INVALID_RESPONSE` (perpDexs), `DEGRADED_PERP`;
 *   transport errors of any perp read are rethrown.
 */
export async function fetchAccountSnapshot(
  info: InfoRequester,
  user: string,
  opts: FetchAccountSnapshotOptions = {},
): Promise<AccountSnapshot> {
  const address = normalizeAddress(user, 'user');
  const call = callOptions(opts);
  const requested = opts.dexes ?? 'all';
  const dexes = requested === 'all' ? await fetchPerpDexNames(info, opts) : resolveDexList(requested);
  const fetchedAt = Date.now();

  // `startCall` also turns a synchronous throw of a plain-function requester into a rejection, so a
  // broken spot call can never reject the whole snapshot. All calls still start in the same tick.
  const spotPromise: Promise<SpotRead> = startCall(() => info<unknown>({ type: 'spotClearinghouseState', user: address }, call)).then(
    (state): SpotRead =>
      isObject(state) && Array.isArray(state.balances)
        ? { ok: true, state }
        : { ok: false, error: 'spotClearinghouseState answer has no balances array (degraded read, not zero)' },
    (err: unknown): SpotRead => ({ ok: false, error: `spotClearinghouseState failed: ${errorMessage(err)}` }),
  );
  const perpPromises = dexes.map((dex) =>
    info<unknown>(
      dex === '' ? { type: 'clearinghouseState', user: address } : { type: 'clearinghouseState', user: address, dex },
      call,
    ).then((state): PerpDexState => ({ dex, state })),
  );

  const [spot, ...perp] = await Promise.all([spotPromise, ...perpPromises]);
  parsePerpStates(perp); // throws DEGRADED_PERP on any invalid dex answer
  return { user: address, perp, spot, fetchedAt };
}

function startCall<T>(fn: () => Promise<T>): Promise<T> {
  try {
    return Promise.resolve(fn());
  } catch (err) {
    return Promise.reject(err);
  }
}

function errorMessage(err: unknown): string {
  return err instanceof Error ? err.message : String(err);
}
All files