src/account/parse.ts
v0.2.0 · 7.3 KB
// Fail-closed decoder of `subaccount_info` (weight 2): `exists`, `healths[]`, `perp_balances[]`, `perp_products[]`.
//
// Account value = unweighted health (`healths[2].health`, x18): assets minus liabilities in USDT0.
// Unified cross margin: the USDT0 collateral is already inside it, and adding the balance again doubles
// the equity. A missing `exists`, fewer than 3 healths, a
// malformed product id or a duplicate balance invalidate the whole read (never "partially right").
import { x18ToBigInt, x18ToNumber } from '../numbers/x18.js';
/** One perp balance (position) as decoded from `perp_balances[]`. */
export interface SubaccountBalance {
readonly productId: number;
/** Signed position size, x18 (positive = long). */
readonly amountX18: bigint;
/** Quote leg of the position, x18 (`v_quote_balance`). */
readonly vQuoteX18: bigint;
}
/** Product risk data as decoded from `perp_products[]`. */
export interface SubaccountProduct {
readonly productId: number;
readonly oraclePriceX18: bigint;
/** `risk.long_weight_initial_x18` when present. */
readonly longWeightInitialX18: bigint | null;
}
/** Decoded `subaccount_info`. */
export interface SubaccountInfo {
/** False when the subaccount was never created (no deposit) - or the NAME is wrong. */
readonly exists: boolean;
/** Unweighted health (`healths[2]`), x18; `0n` when the subaccount does not exist. */
readonly accountValueX18: bigint;
/** Unweighted health as a double (USDT0). */
readonly accountValue: number;
/**
* `healths[0]` - initial health by documentation, never checked live.
* @experimental
*/
readonly initialHealthX18: bigint;
/**
* `healths[1]` - maintenance health by documentation, never checked live.
* @experimental
*/
readonly maintenanceHealthX18: bigint;
/** Balances by product id (every entry, including zero amounts). */
readonly balances: ReadonlyMap<number, SubaccountBalance>;
/** Product risk data by product id. */
readonly products: ReadonlyMap<number, SubaccountProduct>;
}
/** Thrown by {@link parseSubaccountInfo}. */
export class SubaccountInfoParseError extends Error {
override readonly name = 'SubaccountInfoParseError';
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function productIdOf(v: unknown, label: string): number {
if (typeof v !== 'number' || !Number.isSafeInteger(v) || v < 0)
throw new SubaccountInfoParseError(`subaccount_info: ${label} has invalid product_id`);
return v;
}
/**
* Decodes the `data` of `{type:'subaccount_info', subaccount}`.
*
* @throws SubaccountInfoParseError on any malformed field.
*/
export function parseSubaccountInfo(payload: unknown): SubaccountInfo {
if (!isRecord(payload)) throw new SubaccountInfoParseError('subaccount_info: data is not an object');
if (typeof payload.exists !== 'boolean') throw new SubaccountInfoParseError('subaccount_info: missing exists flag');
const exists = payload.exists;
const healths = payload.healths;
if (!Array.isArray(healths) || healths.length < 3)
throw new SubaccountInfoParseError('subaccount_info: malformed healths');
const healthAt = (i: number): bigint => {
const h = healths[i];
if (!isRecord(h)) throw new SubaccountInfoParseError(`subaccount_info: malformed healths[${i}]`);
try {
return x18ToBigInt(h.health, `healths[${i}].health`);
} catch (e) {
throw new SubaccountInfoParseError(`subaccount_info: ${(e as Error).message}`);
}
};
const initialHealthX18 = healthAt(0);
const maintenanceHealthX18 = healthAt(1);
const unweighted = healthAt(2);
const accountValueX18 = exists ? unweighted : 0n;
const rawProducts = payload.perp_products ?? [];
if (!Array.isArray(rawProducts)) throw new SubaccountInfoParseError('subaccount_info: malformed perp_products');
const products = new Map<number, SubaccountProduct>();
for (let i = 0; i < rawProducts.length; i++) {
const p = rawProducts[i];
if (!isRecord(p)) throw new SubaccountInfoParseError(`subaccount_info: perp_products[${i}] malformed`);
const productId = productIdOf(p.product_id, `perp_products[${i}]`);
let oraclePriceX18: bigint;
let longWeightInitialX18: bigint | null = null;
try {
oraclePriceX18 = x18ToBigInt(p.oracle_price_x18, `perp_products[${i}].oracle_price_x18`);
const risk = p.risk;
if (isRecord(risk) && risk.long_weight_initial_x18 !== undefined && risk.long_weight_initial_x18 !== null) {
longWeightInitialX18 = x18ToBigInt(
risk.long_weight_initial_x18,
`perp_products[${i}].risk.long_weight_initial_x18`,
);
}
} catch (e) {
throw new SubaccountInfoParseError(`subaccount_info: ${(e as Error).message}`);
}
if (products.has(productId))
throw new SubaccountInfoParseError(`subaccount_info: duplicate perp_products entry for product ${productId}`);
products.set(productId, { productId, oraclePriceX18, longWeightInitialX18 });
}
const rawBalances = payload.perp_balances ?? [];
if (!Array.isArray(rawBalances)) throw new SubaccountInfoParseError('subaccount_info: malformed perp_balances');
const balances = new Map<number, SubaccountBalance>();
for (let i = 0; i < rawBalances.length; i++) {
const b = rawBalances[i];
if (!isRecord(b)) throw new SubaccountInfoParseError(`subaccount_info: perp_balances[${i}] malformed`);
const productId = productIdOf(b.product_id, `perp_balances[${i}]`);
const bal = b.balance;
if (!isRecord(bal)) throw new SubaccountInfoParseError(`subaccount_info: perp_balances[${i}] has no balance`);
let amountX18: bigint;
let vQuoteX18: bigint;
try {
amountX18 = x18ToBigInt(bal.amount, `perp_balances[${i}].amount`);
vQuoteX18 = x18ToBigInt(bal.v_quote_balance, `perp_balances[${i}].v_quote_balance`);
} catch (e) {
throw new SubaccountInfoParseError(`subaccount_info: ${(e as Error).message}`);
}
if (balances.has(productId))
throw new SubaccountInfoParseError(`subaccount_info: duplicate perp balance for product ${productId}`);
balances.set(productId, { productId, amountX18, vQuoteX18 });
}
return {
exists,
accountValueX18,
accountValue: x18ToNumber(accountValueX18),
initialHealthX18,
maintenanceHealthX18,
balances,
products,
};
}
/** Signed position amount (x18) of one product from a decoded info; `0n` when absent. */
export function positionAmountX18(info: SubaccountInfo, productId: number): bigint {
return info.balances.get(productId)?.amountX18 ?? 0n;
}
/** Map product id -> signed amount for every NON-ZERO balance (what the stability fence compares). */
export function nonZeroAmounts(info: SubaccountInfo): Map<number, bigint> {
const out = new Map<number, bigint>();
for (const [pid, b] of info.balances) if (b.amountX18 !== 0n) out.set(pid, b.amountX18);
return out;
}
/**
* True when no non-zero amount changed between two reads (positions -> orders -> positions fence). A
* changed amount means something filled during the read: order sizes computed from that snapshot are
* unreliable - skip the tick.
*/
export function amountsUnchanged(before: SubaccountInfo, after: SubaccountInfo): boolean {
const a = nonZeroAmounts(before);
const b = nonZeroAmounts(after);
if (a.size !== b.size) return false;
for (const [pid, amt] of a) if (b.get(pid) !== amt) return false;
return true;
}