src/account/orders.ts
v0.2.0 · 9.6 KB
// Own open orders: decoding the `orders` query, ownership and flag-bit recovery from the nonce tag,
// chunked reading by product ids and the completeness flag.
//
// `orders` needs an explicit `product_ids` list (weight 2 per id). A read over PART of the markets looks
// exactly like an empty account; only a read that covered EVERY product of the universe proves "no
// orders anywhere" (`ordersComplete`). Irreversible decisions (e.g. treating the account as empty)
// need that flag. Foreign orders (no tag) are never adopted as ours or cancelled, but they are
// exposure.
import type { PerpProduct } from '../markets/types.js';
import { absX18, parseUint, x18ToBigInt, x18ToNumber } from '../numbers/x18.js';
import { parseAppendix } from '../signing/appendix.js';
import { DEFAULT_NONCE_TAG, parseNonce } from '../signing/nonce.js';
import { isBytes32 } from '../signing/subaccount.js';
import type { Hex } from '../signing/types.js';
import type { QueryRequester } from '../transport/types.js';
import { chunkProductIds } from '../transport/weights.js';
/** Live shape of one order in `product_orders[].orders[]` (knowledge base `orders.md` §8). */
export interface RawOpenOrder {
product_id: number;
sender: string;
price_x18: string;
amount: string;
expiration: string;
order_type?: string;
nonce: string;
appendix?: string;
unfilled_amount: string;
digest: string;
placed_at?: number;
}
/** Decoded resting order. */
export interface NadoOpenOrder {
readonly coin: string;
readonly productId: number;
readonly digest: Hex;
readonly side: 'buy' | 'sell';
readonly priceX18: bigint;
readonly price: number;
/** Remaining size, x18, absolute. */
readonly unfilledX18: bigint;
readonly unfilled: number;
/** Original signed amount, x18. */
readonly amountX18: bigint;
readonly nonce: bigint;
readonly appendix: bigint | null;
readonly orderType: 'default' | 'ioc' | 'fok' | 'post_only' | null;
readonly reduceOnly: boolean;
readonly expiration: bigint;
/** Unix seconds from `placed_at`. */
readonly placedAt: number | null;
/** True when the nonce carries our tag. */
readonly ours: boolean;
/** Caller-defined flag bit of OUR nonce (see `buildNonce`); `null` for foreign orders. */
readonly reduceIntent: boolean | null;
}
/** Thrown by the order decoders. */
export class OrdersParseError extends Error {
override readonly name = 'OrdersParseError';
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
/** Flattens the `data` of `{type:'orders'}` (`product_orders[{orders:[...]}]`) into raw orders. */
export function flattenOrdersData(data: unknown): unknown[] {
if (!isRecord(data)) throw new OrdersParseError('orders: data is not an object');
const productOrders = data.product_orders;
if (!Array.isArray(productOrders)) throw new OrdersParseError('orders: missing product_orders');
const flat: unknown[] = [];
for (const po of productOrders) {
const orders = isRecord(po) ? po.orders : undefined;
if (!Array.isArray(orders)) throw new OrdersParseError('orders: malformed product_orders entry');
flat.push(...orders);
}
return flat;
}
/**
* Decodes raw orders. Fully filled remnants (`unfilled_amount === 0`) are skipped. An order on a product
* absent from `byProductId` fails the whole decode (the universe is stale or the payload is foreign).
*
* @throws OrdersParseError
*/
export function parseOpenOrders(
raw: readonly unknown[],
byProductId: ReadonlyMap<number, PerpProduct>,
opts: { tag?: number } = {},
): NadoOpenOrder[] {
const tag = opts.tag ?? DEFAULT_NONCE_TAG;
const out: NadoOpenOrder[] = [];
const seen = new Set<string>();
for (let i = 0; i < raw.length; i++) {
const o = raw[i];
if (!isRecord(o)) throw new OrdersParseError(`orders: item ${i} malformed`);
const pid = o.product_id;
if (typeof pid !== 'number' || !Number.isSafeInteger(pid))
throw new OrdersParseError(`orders: item ${i} has invalid product_id`);
const product = byProductId.get(pid);
if (!product) throw new OrdersParseError(`orders: item ${i} references unknown product ${pid}`);
let priceX18: bigint;
let unfilledSigned: bigint;
let amountX18: bigint;
let nonce: bigint;
let expiration: bigint;
let appendix: bigint | null = null;
try {
priceX18 = x18ToBigInt(o.price_x18, `orders[${i}].price_x18`);
unfilledSigned = x18ToBigInt(o.unfilled_amount, `orders[${i}].unfilled_amount`);
amountX18 = x18ToBigInt(o.amount, `orders[${i}].amount`);
nonce = parseUint(o.nonce, `orders[${i}].nonce`);
expiration = parseUint(o.expiration, `orders[${i}].expiration`);
if (o.appendix !== undefined && o.appendix !== null) appendix = parseUint(o.appendix, `orders[${i}].appendix`);
} catch (e) {
throw new OrdersParseError(`orders: ${(e as Error).message}`);
}
if (priceX18 <= 0n) throw new OrdersParseError(`orders: item ${i} has non-positive price`);
const digest = typeof o.digest === 'string' ? o.digest.toLowerCase() : '';
if (!isBytes32(digest)) throw new OrdersParseError(`orders: item ${i} has a malformed digest`);
if (unfilledSigned === 0n) continue;
if (seen.has(digest)) throw new OrdersParseError(`orders: duplicate digest ${digest}`);
seen.add(digest);
const parts = parseNonce(nonce);
const ours = parts.tag === tag;
const decodedAppendix = appendix === null ? null : parseAppendix(appendix);
const placedAt = typeof o.placed_at === 'number' && Number.isFinite(o.placed_at) ? o.placed_at : null;
out.push({
coin: product.coin,
productId: pid,
digest: digest as Hex,
side: unfilledSigned > 0n ? 'buy' : 'sell',
priceX18,
price: x18ToNumber(priceX18),
unfilledX18: absX18(unfilledSigned),
unfilled: x18ToNumber(absX18(unfilledSigned)),
amountX18,
nonce,
appendix,
orderType: decodedAppendix?.orderType ?? null,
reduceOnly: decodedAppendix?.reduceOnly ?? false,
expiration,
placedAt,
ours,
reduceIntent: ours ? parts.reduceIntent : null,
});
}
return out;
}
/** Options of {@link readOpenOrders}. */
export interface ReadOpenOrdersOptions {
query: QueryRequester;
/** bytes32 subaccount. */
sender: Hex;
/** Universe for names and completeness. */
byProductId: ReadonlyMap<number, PerpProduct>;
/** Products to read, or `'all'` for a full sweep of the universe. */
productIds: 'all' | Iterable<number>;
/** Nonce tag that marks our orders. Default {@link DEFAULT_NONCE_TAG}. */
tag?: number;
/** Caller-selected products per request; must fit the configured query bucket. */
chunkSize: number;
/** Telemetry label. Default `'orders'`. */
label?: string;
}
/** Result of {@link readOpenOrders}. */
export interface OpenOrdersRead {
readonly orders: readonly NadoOpenOrder[];
/** Orders carrying our tag. */
readonly ours: readonly NadoOpenOrder[];
/** Orders without our tag (manual, or placed by another process on the same subaccount). */
readonly foreign: readonly NadoOpenOrder[];
/** True only when the read covered EVERY product of the universe. */
readonly ordersComplete: boolean;
/** Product ids that were queried. */
readonly productIds: readonly number[];
}
/**
* Reads open orders for the given products in chunks. Any chunk failure throws: a partial read must not
* be reported as "fewer orders".
*/
export async function readOpenOrders(options: ReadOpenOrdersOptions): Promise<OpenOrdersRead> {
const { query, sender, byProductId } = options;
const chunkSize = options.chunkSize;
const universeIds = [...byProductId.keys()];
const ids = options.productIds === 'all' ? universeIds : [...new Set(options.productIds)];
for (const id of ids)
if (!byProductId.has(id)) throw new OrdersParseError(`orders: product ${id} is not in the universe`);
const flat: unknown[] = [];
for (const chunk of chunkProductIds(ids, chunkSize)) {
const data = await query({ type: 'orders', sender, product_ids: chunk }, { label: options.label ?? 'orders' });
flat.push(...flattenOrdersData(data));
}
const orders = parseOpenOrders(flat, byProductId, { tag: options.tag });
const queried = new Set(ids);
const ordersComplete = universeIds.every((id) => queried.has(id));
return {
orders,
ours: orders.filter((o) => o.ours),
foreign: orders.filter((o) => !o.ours),
ordersComplete,
productIds: ids,
};
}
/** Default interval of the full order sweep per subaccount. */
export const DEFAULT_FULL_SWEEP_MS = 5 * 60_000;
/** Per-key clock for the full sweep window. */
export interface SweepClock {
/** True when the window for `key` (e.g. the bytes32 subaccount) has elapsed. */
due(key: string): boolean;
/** Marks the window as spent NOW. Call it only when the sweep-backed snapshot is usable (stable, positions ok). */
mark(key: string): void;
/** Forgets a key. */
reset(key?: string): void;
}
/**
* Sweep clock keyed by subaccount. A single clock shared by several subaccounts lets the first one polled
* consume the window, and the others never get a full read (their stray orders stay invisible).
*/
export function createSweepClock(opts: { intervalMs?: number; now?: () => number } = {}): SweepClock {
const interval = opts.intervalMs ?? DEFAULT_FULL_SWEEP_MS;
if (!(interval > 0)) throw new RangeError('intervalMs must be positive');
const now = opts.now ?? (() => Date.now());
const last = new Map<string, number>();
return {
due: (key) => now() - (last.get(key) ?? Number.NEGATIVE_INFINITY) >= interval,
mark: (key) => {
last.set(key, now());
},
reset: (key) => {
if (key === undefined) last.clear();
else last.delete(key);
},
};
}