src/orders/cancel.test.ts
v0.3.0 · 8.8 KB
import { describe, expect, it } from 'vitest';
import { AssetRegistryError, type AssetInfo } from '../assets/index.js';
import { cancelOrders, clearScheduledCancel, MIN_SCHEDULE_CANCEL_LEAD_MS, scheduleCancel } from './cancel.js';
import { HlOrderError } from './errors.js';
import type { CancelExchange, ScheduleCancelExchange } from './types.js';
const registry = {
async resolve(coin: string): Promise<AssetInfo> {
const ids: Record<string, number> = { HYPE: 159, 'xyz:TSLA': 110001 };
const id = ids[coin];
if (id === undefined) throw new AssetRegistryError(coin, 'unlisted', 'not_in_universe');
return { coin, assetId: id, index: id % 10000, szDecimals: 2, maxPxDecimals: 4, maxLeverage: 10, onlyIsolated: false, isDelisted: false, dex: '', dexIndex: 0, market: 'perp' };
},
};
const named = (name: string, message: string, extra: object = {}) => Object.assign(new Error(message), { name }, extra);
const apiError = (response: unknown) => named('ApiRequestError', 'api', { response });
const httpError = (status: number) => named('HttpRequestError', `${status}`, { response: { status } });
const cancelBody = (statuses: unknown[]) => ({ status: 'ok', response: { type: 'cancel', data: { statuses } } });
const CLOID = `0x${'AA'.repeat(16)}`;
function stub(handler: (kind: 'cancel' | 'cancelByCloid', params: unknown) => unknown) {
const calls: { kind: string; params: unknown; opts: unknown }[] = [];
const ex: CancelExchange = {
cancel(params, opts) {
calls.push({ kind: 'cancel', params, opts });
return Promise.resolve().then(() => handler('cancel', params));
},
cancelByCloid(params, opts) {
calls.push({ kind: 'cancelByCloid', params, opts });
return Promise.resolve().then(() => handler('cancelByCloid', params));
},
};
return { ex, calls };
}
describe('cancelOrders', () => {
it('cancels by oid and by cloid in two batches, with numeric asset ids', async () => {
const { ex, calls } = stub(() => cancelBody(['success']));
const r = await cancelOrders(ex, registry, [
{ coin: 'HYPE', oid: 42 },
{ coin: 'xyz:TSLA', cloid: CLOID },
]);
expect(calls).toEqual([
{ kind: 'cancel', params: { cancels: [{ a: 159, o: 42 }] }, opts: undefined },
{ kind: 'cancelByCloid', params: { cancels: [{ asset: 110001, cloid: CLOID.toLowerCase() }] }, opts: undefined },
]);
expect(r.items.map((i) => [i.outcome.status, i.confirmed])).toEqual([
['success', true],
['success', true],
]);
expect(r).toMatchObject({ allConfirmed: true, needsReconcile: false, retry: null });
});
it('"never placed, already canceled, or filled" in a thrown batch means gone; other errors keep the order', async () => {
const { ex } = stub(() => {
throw apiError(cancelBody([{ error: 'Order was never placed, already canceled, or filled.' }, { error: 'Something else went wrong.' }]));
});
const r = await cancelOrders(ex, registry, [
{ coin: 'HYPE', oid: 1 },
{ coin: 'HYPE', oid: 2 },
]);
expect(r.items[0]).toMatchObject({ confirmed: true, outcome: { status: 'alreadyGone' } });
expect(r.items[1]).toMatchObject({ confirmed: false, outcome: { status: 'error' } });
expect(r).toMatchObject({ allConfirmed: false, needsReconcile: true });
});
it('empty statuses and missing elements are not a cancel', async () => {
const empty = stub(() => cancelBody([]));
const r1 = await cancelOrders(empty.ex, registry, [{ coin: 'HYPE', oid: 1 }]);
expect(r1.items[0]?.outcome).toEqual({ status: 'unconfirmed', reason: 'empty statuses' });
expect(r1).toMatchObject({ allConfirmed: false, needsReconcile: true, retry: 'retry' });
const short = stub(() => cancelBody(['success']));
const r2 = await cancelOrders(short.ex, registry, [
{ coin: 'HYPE', oid: 1 },
{ coin: 'HYPE', oid: 2 },
]);
expect(r2.items[1]?.outcome).toEqual({ status: 'unconfirmed', reason: 'missing' });
expect(r2.needsReconcile).toBe(true);
});
it('status err is not a cancel', async () => {
const { ex } = stub(() => ({ status: 'err', response: 'Some rejection' }));
const r = await cancelOrders(ex, registry, [{ coin: 'HYPE', oid: 1 }]);
expect(r.items[0]?.outcome).toMatchObject({ status: 'error', message: 'Some rejection' });
expect(r.allConfirmed).toBe(false);
});
it('transport failures: unknown on 5xx, not sent on 429; cancels are idempotent so retry is advised', async () => {
const five = stub(() => {
throw httpError(500);
});
const r1 = await cancelOrders(five.ex, registry, [{ coin: 'HYPE', oid: 1 }]);
expect(r1.items[0]?.outcome.status).toBe('unknown');
expect(r1).toMatchObject({ retry: 'retry', needsReconcile: true });
const limited = stub(() => {
throw httpError(429);
});
const r2 = await cancelOrders(limited.ex, registry, [{ coin: 'HYPE', oid: 1 }]);
expect(r2.items[0]?.outcome).toMatchObject({ status: 'not_sent', retryable: true });
expect(r2.failures).toHaveLength(1);
});
it('invalid targets are reported and not sent; valid ones still go', async () => {
const { ex, calls } = stub(() => cancelBody(['success']));
const r = await cancelOrders(ex, registry, [
{ coin: 'HYPE', oid: 0 },
{ coin: 'HYPE', oid: 1.5 },
{ coin: 'HYPE', oid: Number.NaN },
{ coin: 'HYPE', cloid: '0x1' },
{ coin: 'HYPE', oid: 1, cloid: CLOID } as never,
{ coin: '', oid: 1 },
{ coin: 'NOPE', oid: 1 },
{ coin: 'HYPE', oid: 7 },
]);
expect(r.items.slice(0, 7).every((i) => i.outcome.status === 'invalid_target')).toBe(true);
expect(r.items[7]).toMatchObject({ confirmed: true, oid: 7 });
expect(calls).toHaveLength(1);
expect(r).toMatchObject({ allConfirmed: false, needsReconcile: false });
});
it('passes vaultAddress and rejects a non-array', async () => {
const { ex, calls } = stub(() => cancelBody(['success']));
await cancelOrders(ex, registry, [{ coin: 'HYPE', oid: 3 }], { vaultAddress: `0x${'0'.repeat(40)}` });
expect(calls[0]?.opts).toEqual({ vaultAddress: `0x${'0'.repeat(40)}` });
await expect(cancelOrders(ex, registry, 'x' as never)).rejects.toThrow(HlOrderError);
});
});
describe('scheduleCancel (dead man switch)', () => {
function scheduler(handler: () => unknown) {
const calls: unknown[][] = [];
const ex = {
scheduleCancel(...args: unknown[]) {
calls.push(args);
return Promise.resolve().then(handler);
},
} as unknown as ScheduleCancelExchange;
return { ex, calls };
}
const NOW = 1_800_000_000_000;
const okBody = { status: 'ok', response: { type: 'default' } };
it('schedules at a time at least 5 s ahead', async () => {
const { ex, calls } = scheduler(() => okBody);
expect(await scheduleCancel(ex, NOW + 60_000, { now: () => NOW })).toEqual({ ok: true, time: NOW + 60_000 });
expect(await scheduleCancel(ex, new Date(NOW + MIN_SCHEDULE_CANCEL_LEAD_MS), { now: () => NOW })).toMatchObject({ ok: true });
expect(calls[0]).toEqual([{ time: NOW + 60_000 }]);
await expect(scheduleCancel(ex, NOW + 4_999, { now: () => NOW })).rejects.toThrow(/5000 ms ahead/);
await expect(scheduleCancel(ex, NOW + 60_000.5, { now: () => NOW })).rejects.toThrow(HlOrderError);
});
it('passes vault options as the second argument', async () => {
const { ex, calls } = scheduler(() => okBody);
const vaultAddress = `0x${'1'.repeat(40)}`;
await scheduleCancel(ex, NOW + 10_000, { now: () => NOW, vaultAddress });
expect(calls[0]).toEqual([{ time: NOW + 10_000 }, { vaultAddress }]);
});
it('clearing sends no time; options go second behind an undefined time (SDK 0.33.3 drops them otherwise)', async () => {
const { ex, calls } = scheduler(() => okBody);
expect(await clearScheduledCancel(ex)).toEqual({ ok: true, time: null });
const vaultAddress = `0x${'2'.repeat(40)}`;
await clearScheduledCancel(ex, { vaultAddress });
expect(calls).toEqual([[], [{ time: undefined }, { vaultAddress }]]);
expect('time' in (calls[1]?.[0] as object)).toBe(true);
});
it('fail-closed results', async () => {
const rejected = scheduler(() => {
throw apiError({ status: 'err', response: 'Scheduled cancel rejected' });
});
expect(await scheduleCancel(rejected.ex, NOW + 10_000, { now: () => NOW })).toMatchObject({ ok: false, reason: 'rejected' });
const down = scheduler(() => {
throw httpError(502);
});
expect(await scheduleCancel(down.ex, NOW + 10_000, { now: () => NOW })).toMatchObject({ ok: false, reason: 'unknown', retry: 'retry' });
const weird = scheduler(() => ({ status: 'maybe' }));
expect(await clearScheduledCancel(weird.ex)).toMatchObject({ ok: false, reason: 'malformed' });
const limited = scheduler(() => {
throw httpError(429);
});
expect(await clearScheduledCancel(limited.ex)).toMatchObject({ ok: false, reason: 'not-applied', retry: 'retry' });
});
});