src/account/orders.test.ts
v0.2.0 · 7.3 KB
import { describe, expect, it } from 'vitest';
import { decimalToX18, X18 } from '../numbers/x18.js';
import { buildNonce } from '../signing/nonce.js';
import type { Hex } from '../signing/types.js';
import type { QueryRequest, QueryRequester } from '../transport/types.js';
import { NOW_MS, ordersData, rawOrder, UNIVERSE } from './fixtures.js';
import {
createSweepClock,
DEFAULT_FULL_SWEEP_MS,
flattenOrdersData,
OrdersParseError,
parseOpenOrders,
readOpenOrders,
} from './orders.js';
const SENDER = `0x${'0'.repeat(64)}` as Hex;
describe('flattenOrdersData', () => {
it('flattens product_orders and fails closed on other shapes', () => {
expect(flattenOrdersData({ product_orders: [{ orders: [1, 2] }, { orders: [] }, { orders: [3] }] })).toEqual([
1, 2, 3,
]);
expect(() => flattenOrdersData({})).toThrow(/product_orders/);
expect(() => flattenOrdersData({ product_orders: [{}] })).toThrow(/malformed/);
expect(() => flattenOrdersData(null)).toThrow(OrdersParseError);
});
});
describe('parseOpenOrders', () => {
it('decodes the live shape: side from the sign, size = |unfilled|, tag and flag bit from the nonce', () => {
const [o] = parseOpenOrders([rawOrder()], UNIVERSE.byProductId);
expect(o).toMatchObject({
coin: 'BTC',
productId: 2,
side: 'sell',
price: 67000,
priceX18: 67000n * X18,
unfilled: 0.25,
unfilledX18: decimalToX18('0.25'),
amountX18: -decimalToX18('0.25'),
orderType: 'default',
reduceOnly: false,
expiration: (1n << 64n) - 1n,
placedAt: NOW_MS / 1000,
ours: true,
reduceIntent: false,
digest: `0x${'ab'.repeat(32)}`,
});
expect(o?.nonce).toBe(buildNonce({ recvTimeMs: NOW_MS + 60_000, random: 3 }));
});
it('recognises our flag bit and foreign orders by tag', () => {
const tp = rawOrder({
nonce: buildNonce({ recvTimeMs: NOW_MS, tag: 0x321, reduceIntent: true, random: 1 }).toString(),
digest: `0x${'cd'.repeat(32)}`,
});
const foreign = rawOrder({ nonce: (BigInt(NOW_MS) << 20n).toString(), digest: `0x${'ef'.repeat(32)}` });
const [a, b] = parseOpenOrders([tp, foreign], UNIVERSE.byProductId, { tag: 0x321 });
expect(a).toMatchObject({ ours: true, reduceIntent: true });
expect(b).toMatchObject({ ours: false, reduceIntent: null });
});
it('skips fully filled remnants and tolerates a missing appendix / placed_at', () => {
const filled = rawOrder({ unfilled_amount: '0', digest: `0x${'11'.repeat(32)}` });
const buy = rawOrder({
amount: decimalToX18('1').toString(),
unfilled_amount: decimalToX18('0.4').toString(),
appendix: undefined,
placed_at: undefined,
digest: `0x${'22'.repeat(32)}`,
});
const out = parseOpenOrders([filled, buy], UNIVERSE.byProductId);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ side: 'buy', unfilled: 0.4, orderType: null, appendix: null, placedAt: null });
});
it('fails closed on unknown products, malformed numbers, bad digests and duplicates', () => {
expect(() => parseOpenOrders([rawOrder({ product_id: 999 })], UNIVERSE.byProductId)).toThrow(/unknown product/);
expect(() => parseOpenOrders([rawOrder({ product_id: '2' })], UNIVERSE.byProductId)).toThrow(/product_id/);
// A nonce that arrived as a JSON number (~1.87e18 > 2^53) is already corrupted: refuse it.
const bigNonce = buildNonce({ recvTimeMs: NOW_MS + 60_000, tag: 0x5a5, random: 7 });
expect(() => parseOpenOrders([rawOrder({ nonce: Number(bigNonce) })], UNIVERSE.byProductId)).toThrow(/nonce/);
expect(() => parseOpenOrders([rawOrder({ nonce: '1.5' })], UNIVERSE.byProductId)).toThrow(/nonce/);
expect(() => parseOpenOrders([rawOrder({ price_x18: '0' })], UNIVERSE.byProductId)).toThrow(/non-positive price/);
expect(() => parseOpenOrders([rawOrder({ digest: '0x12' })], UNIVERSE.byProductId)).toThrow(/digest/);
expect(() => parseOpenOrders([rawOrder(), rawOrder()], UNIVERSE.byProductId)).toThrow(/duplicate digest/);
expect(() => parseOpenOrders([null], UNIVERSE.byProductId)).toThrow(/malformed/);
});
});
function stubQuery(handler: (body: QueryRequest) => unknown): { query: QueryRequester; bodies: QueryRequest[] } {
const bodies: QueryRequest[] = [];
const query: QueryRequester = async <T>(body: QueryRequest) => {
bodies.push(body);
return handler(body) as T;
};
return { query, bodies };
}
describe('readOpenOrders', () => {
it('reads by chunks, splits ours / foreign and reports completeness', async () => {
const universe = UNIVERSE.byProductId; // ids 2, 8, 188
const { query, bodies } = stubQuery((body) => {
const ids = body.product_ids as number[];
return ordersData(
ids.includes(2)
? [rawOrder(), rawOrder({ nonce: (BigInt(NOW_MS) << 20n).toString(), digest: `0x${'ef'.repeat(32)}` })]
: [],
);
});
const partial = await readOpenOrders({
query,
sender: SENDER,
byProductId: universe,
productIds: [2, 8],
chunkSize: 1,
});
expect(bodies.map((b) => b.product_ids)).toEqual([[2], [8]]);
expect(bodies[0]).toMatchObject({ type: 'orders', sender: SENDER });
expect(partial.orders).toHaveLength(2);
expect(partial.ours).toHaveLength(1);
expect(partial.foreign).toHaveLength(1);
expect(partial.ordersComplete).toBe(false);
expect(partial.productIds).toEqual([2, 8]);
bodies.length = 0;
const full = await readOpenOrders({ query, sender: SENDER, byProductId: universe, productIds: 'all', chunkSize: 40 });
expect(bodies).toHaveLength(1);
expect(bodies[0]?.product_ids).toEqual([2, 8, 188]);
expect(full.ordersComplete).toBe(true);
const covering = await readOpenOrders({
query,
sender: SENDER,
byProductId: universe,
productIds: [188, 2, 8, 2],
chunkSize: 40,
});
expect(covering.ordersComplete).toBe(true);
expect(covering.productIds).toEqual([188, 2, 8]);
});
it('a failed chunk throws instead of reporting fewer orders; unknown ids are refused', async () => {
const { query } = stubQuery((body) => {
if ((body.product_ids as number[]).includes(8)) throw new Error('503');
return ordersData([rawOrder()]);
});
await expect(
readOpenOrders({ query, sender: SENDER, byProductId: UNIVERSE.byProductId, productIds: [2, 8], chunkSize: 1 }),
).rejects.toThrow('503');
await expect(
readOpenOrders({ query, sender: SENDER, byProductId: UNIVERSE.byProductId, productIds: [999], chunkSize: 40 }),
).rejects.toThrow(/not in the universe/);
});
});
describe('createSweepClock', () => {
it('is due at first, then again after the interval, per key', () => {
let t = 0;
const clock = createSweepClock({ now: () => t });
expect(DEFAULT_FULL_SWEEP_MS).toBe(300_000);
expect(clock.due('a')).toBe(true);
clock.mark('a');
expect(clock.due('a')).toBe(false);
expect(clock.due('b')).toBe(true); // another subaccount keeps its own window
t = DEFAULT_FULL_SWEEP_MS - 1;
expect(clock.due('a')).toBe(false);
t = DEFAULT_FULL_SWEEP_MS;
expect(clock.due('a')).toBe(true);
clock.mark('a');
clock.reset('a');
expect(clock.due('a')).toBe(true);
clock.mark('b');
clock.reset();
expect(clock.due('b')).toBe(true);
expect(() => createSweepClock({ intervalMs: 0 })).toThrow(RangeError);
});
});