src/account/snapshot.ts
v0.3.0 · 6.1 KB
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);
}