Skip to content
markpaper

src/orders/sdk-contract.test.ts

v0.3.0 · 7 KB

Download file
// Contract tests against the real @nktkas/hyperliquid 0.33.3 ExchangeClient (devDependency).
// No network: the client gets a stub transport and a throwaway generated key. They prove that the
// wire orders built here pass the SDK schema (cloid length, decimal strings) and that SDK errors
// are mapped as documented.

import { ExchangeClient } from '@nktkas/hyperliquid';
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
import { describe, expect, it } from 'vitest';
import type { AssetInfo } from '../assets/index.js';
import { cancelOrders, clearScheduledCancel, scheduleCancel } from './cancel.js';
import { marketClose } from './close.js';
import { placeOrders } from './place.js';
import { placeTwap } from './twap.js';
import type { CancelExchange, OrderExchange, ScheduleCancelExchange, TwapExchange } from './types.js';

type Sent = { action: Record<string, unknown>; vaultAddress?: string; nonce: number };

function client(respond: (action: Record<string, unknown>) => unknown): { ex: ExchangeClient; sent: Sent[] } {
  const sent: Sent[] = [];
  const transport = {
    isTestnet: true,
    request: async <T>(_endpoint: string, payload: unknown): Promise<T> => {
      const p = payload as Sent;
      sent.push(p);
      return respond(p.action) as T;
    },
  };
  return { ex: new ExchangeClient({ wallet: privateKeyToAccount(generatePrivateKey()), transport }), sent };
}

const registry = {
  async resolve(coin: string): Promise<AssetInfo> {
    return { coin, assetId: 159, index: 159, szDecimals: 2, maxPxDecimals: 4, maxLeverage: 10, onlyIsolated: false, isDelisted: false, dex: '', dexIndex: 0, market: 'perp' };
  },
};

const orderBody = (statuses: unknown[]) => ({ status: 'ok', response: { type: 'order', data: { statuses } } });

describe('ExchangeClient satisfies the structural interfaces', () => {
  it('assignable at compile time', () => {
    const { ex } = client(() => ({ status: 'ok', response: { type: 'default' } }));
    const o: OrderExchange = ex;
    const c: CancelExchange = ex;
    const s: ScheduleCancelExchange = ex;
    const t: TwapExchange = ex;
    expect([o, c, s, t].every((x) => x === ex)).toBe(true);
  });
});

describe('placeOrders through the real SDK', () => {
  it('built orders pass SDK validation and are signed as sent', async () => {
    const { ex, sent } = client(() => orderBody([{ resting: { oid: 1 } }, { filled: { oid: 2, totalSz: '0.5', avgPx: '101' } }]));
    const r = await placeOrders(ex, registry, [
      { coin: 'HYPE', side: 'buy', size: '1.239', price: 83.20512, tif: 'Alo' },
      { coin: 'HYPE', side: 'buy', size: 1, market: { slippage: 0.01, mid: 100 } },
    ]);
    expect(r.orders.map((o) => o.outcome.status)).toEqual(['resting', 'filled']);
    const action = sent[0]?.action as { type: string; orders: Record<string, unknown>[]; grouping: string };
    expect(action.type).toBe('order');
    expect(action.grouping).toBe('na');
    expect(action.orders[0]).toMatchObject({ a: 159, b: true, p: '83.205', s: '1.23', r: false, t: { limit: { tif: 'Alo' } } });
    expect(action.orders[0]?.c).toBe(r.orders[0]?.cloid);
    expect(String(action.orders[0]?.c)).toMatch(/^0x[0-9a-f]{32}$/);
  });

  it('a partially failed batch thrown by the SDK keeps the placed order', async () => {
    const { ex } = client(() => orderBody([{ resting: { oid: 31 } }, { error: 'Order has invalid price.' }]));
    const r = await placeOrders(ex, registry, [
      { coin: 'HYPE', side: 'buy', size: 1, price: 50 },
      { coin: 'HYPE', side: 'buy', size: 1, price: 51 },
    ]);
    expect(r.orders.map((o) => o.outcome)).toEqual([
      expect.objectContaining({ status: 'resting', oid: 31 }),
      expect.objectContaining({ status: 'rejected', kind: 'invalidPrice' }),
    ]);
    expect(r.batches[0]?.outcome).toBe('partial');
  });

  it('vaultAddress reaches the signed payload', async () => {
    const { ex, sent } = client(() => orderBody([{ filled: { oid: 2, totalSz: '1', avgPx: '99' } }]));
    const vaultAddress = `0x${'AB'.repeat(20)}`;
    const r = await marketClose(ex, registry, { coin: 'HYPE', position: { side: 'long', size: 1 }, mid: 100 }, { vaultAddress });
    expect(r.status).toBe('filled');
    expect(sent[0]?.vaultAddress).toBe(vaultAddress.toLowerCase());
  });
});

describe('cancels, scheduleCancel and TWAP through the real SDK', () => {
  it('cancel and cancelByCloid payloads', async () => {
    const { ex, sent } = client(() => ({ status: 'ok', response: { type: 'cancel', data: { statuses: ['success'] } } }));
    const cloid = `0x${'EE'.repeat(16)}`;
    const r = await cancelOrders(ex, registry, [
      { coin: 'HYPE', oid: 77 },
      { coin: 'HYPE', cloid },
    ]);
    expect(r.allConfirmed).toBe(true);
    expect(sent.map((s) => s.action)).toEqual([
      { type: 'cancel', cancels: [{ a: 159, o: 77 }] },
      { type: 'cancelByCloid', cancels: [{ asset: 159, cloid: cloid.toLowerCase() }] },
    ]);
  });

  it('scheduleCancel sends time; clearing with a vault keeps vaultAddress and sends no time', async () => {
    const { ex, sent } = client(() => ({ status: 'ok', response: { type: 'default' } }));
    const time = Date.now() + 60_000;
    expect(await scheduleCancel(ex, time)).toEqual({ ok: true, time });
    const vaultAddress = `0x${'cd'.repeat(20)}`;
    expect(await clearScheduledCancel(ex, { vaultAddress })).toEqual({ ok: true, time: null });
    expect(sent[0]?.action).toEqual({ type: 'scheduleCancel', time });
    expect(JSON.stringify(sent[1]?.action)).toBe('{"type":"scheduleCancel"}');
    expect(sent[1]?.vaultAddress).toBe(vaultAddress);
  });

  it('SDK 0.33.3 quirk: scheduleCancel(opts) loses the options, and an undefined time does not change the hash', async () => {
    const { createL1ActionHash } = await import('@nktkas/hyperliquid/signing');
    const { ex, sent } = client(() => ({ status: 'ok', response: { type: 'default' } }));
    const vaultAddress = `0x${'cd'.repeat(20)}`;
    await ex.scheduleCancel({ vaultAddress });
    expect(sent[0]?.vaultAddress).toBeUndefined();
    const plain = createL1ActionHash({ action: { type: 'scheduleCancel' }, nonce: 1 });
    const withUndefined = createL1ActionHash({ action: { type: 'scheduleCancel', time: undefined }, nonce: 1 });
    expect(withUndefined).toBe(plain);
  });

  it('twapOrder payload and a TWAP error thrown by the SDK', async () => {
    const good = client(() => ({ status: 'ok', response: { type: 'twapOrder', data: { status: { running: { twapId: 5 } } } } }));
    expect(await placeTwap(good.ex, registry, { coin: 'HYPE', side: 'sell', size: '2.5', minutes: 30 })).toMatchObject({ status: 'running', twapId: 5 });
    expect(good.sent[0]?.action).toEqual({ type: 'twapOrder', twap: { a: 159, b: false, s: '2.5', r: false, m: 30, t: false } });
    const bad = client(() => ({ status: 'ok', response: { type: 'twapOrder', data: { status: { error: 'Invalid TWAP duration: 1 min(s)' } } } }));
    expect(await placeTwap(bad.ex, registry, { coin: 'HYPE', side: 'sell', size: 1, minutes: 5 })).toMatchObject({
      status: 'rejected',
      kind: 'invalidTwapDuration',
    });
  });
});
All files