Skip to content
markpaper

src/orders/cancel.test.ts

v0.3.0 · 8.8 KB

Download file
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' });
  });
});
All files