src/orders/sdk-contract.test.ts
v0.3.0 · 7 KB
// 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',
});
});
});