src/signer/client.test.ts
v0.2.1 · 10 KB
import { describe, expect, it } from 'vitest';
import type { FetchLike } from '../rest/types.js';
import { ORDER_ID_A } from '../testing/fixtures.js';
import { createWriteWindow } from '../writeBudget/window.js';
import { createSignerClient, type SignerWriteEvent } from './client.js';
interface Call {
url: string;
method: string | undefined;
headers: Record<string, string>;
body: unknown;
}
type Step = { status: number; body: unknown } | { throws: string } | { hang: true };
function scripted(steps: Step[]): { fetch: FetchLike; calls: Call[] } {
const calls: Call[] = [];
const fetch: FetchLike = async (url, init) => {
calls.push({
url,
method: init.method,
headers: (init.headers as Record<string, string>) ?? {},
body: typeof init.body === 'string' ? JSON.parse(init.body) : undefined,
});
const step = steps[Math.min(calls.length - 1, steps.length - 1)] as Step;
if ('throws' in step) throw new Error(step.throws);
if ('hang' in step) {
return new Promise<Response>((_, reject) => {
init.signal?.addEventListener('abort', () => reject(init.signal?.reason ?? new Error('aborted')), {
once: true,
});
});
}
return new Response(typeof step.body === 'string' ? step.body : JSON.stringify(step.body), { status: step.status });
};
return { fetch, calls };
}
const URL = 'http://127.0.0.1:1';
const order = { marketIndex: 32, clientOrderIndex: 1712345678, baseAmount: 100, price: 160534, isAsk: true };
describe('createSignerClient', () => {
it('is fail-closed on an empty token and a bad url', () => {
expect(() => createSignerClient({ url: URL, token: '' })).toThrow(/token must not be empty/);
expect(() => createSignerClient({ url: URL, token: ' ' })).toThrow(/token/);
expect(() => createSignerClient({ url: 'nope', token: 't' })).toThrow(/invalid signer url/);
});
it('places an order with the documented body and bearer header', async () => {
const s = scripted([{ status: 200, body: { ok: true, tx_hash: '0xabc123' } }]);
const events: SignerWriteEvent[] = [];
const c = createSignerClient({ url: `${URL}/`, token: 'secret', fetch: s.fetch, onWrite: (e) => events.push(e) });
const r = await c.placeOrder({ ...order, reduceOnly: false, ioc: true });
expect(r).toMatchObject({ status: 'ok', txHash: '0xabc123' });
expect(s.calls[0]).toMatchObject({
url: `${URL}/order`,
method: 'POST',
headers: { 'content-type': 'application/json', authorization: 'Bearer secret' },
body: {
market_index: 32,
client_order_index: 1712345678,
base_amount: 100,
price: 160534,
is_ask: true,
reduce_only: false,
ioc: true,
},
});
expect(events[0]).toMatchObject({ path: '/order', critical: false, result: { status: 'ok' } });
});
it('sends the cancel order_index as the exact string and marks it critical', async () => {
const s = scripted([{ status: 200, body: { ok: true, tx_hash: null } }]);
const events: SignerWriteEvent[] = [];
const c = createSignerClient({ url: URL, token: 't', fetch: s.fetch, onWrite: (e) => events.push(e) });
const r = await c.cancelOrder({ marketIndex: 32, orderId: ORDER_ID_A });
expect(r).toMatchObject({ status: 'ok', txHash: null });
expect(s.calls[0]?.body).toEqual({ market_index: 32, order_index: ORDER_ID_A });
const sentBody = s.calls[0]?.body as { order_index: unknown } | undefined;
expect(typeof sentBody?.order_index).toBe('string');
expect(events[0]?.critical).toBe(true);
expect(() => c.cancelOrder({ marketIndex: 32, orderId: '12.5' })).toThrow(RangeError);
});
it('sends leverage with cross margin by default', async () => {
const s = scripted([{ status: 200, body: { ok: true, tx_hash: '0xdef456' } }]);
const c = createSignerClient({ url: URL, token: 't', fetch: s.fetch });
await c.updateLeverage({ marketIndex: 41, leverage: 5 });
expect(s.calls[0]?.body).toEqual({ market_index: 41, leverage: 5, margin_mode: 0 });
expect(() => c.updateLeverage({ marketIndex: 41, leverage: 0 })).toThrow(RangeError);
});
it('turns 504 / unknown:true / loopback failures / timeouts into status unknown, never rejected', async () => {
const c504 = createSignerClient({
url: URL,
token: 't',
fetch: scripted([{ status: 504, body: { ok: false, unknown: true, error: 'timeout' } }]).fetch,
});
expect(await c504.placeOrder(order)).toEqual({ status: 'unknown', error: 'timeout' });
const cFlag = createSignerClient({
url: URL,
token: 't',
fetch: scripted([{ status: 200, body: { ok: false, unknown: true } }]).fetch,
});
expect((await cFlag.placeOrder(order)).status).toBe('unknown');
const cNet = createSignerClient({ url: URL, token: 't', fetch: scripted([{ throws: 'ECONNRESET' }]).fetch });
expect(await cNet.cancelOrder({ marketIndex: 1, orderId: '5' })).toEqual({
status: 'unknown',
error: 'ECONNRESET',
});
const cHang = createSignerClient({ url: URL, token: 't', fetch: scripted([{ hang: true }]).fetch, timeoutMs: 20 });
const r = await cHang.placeOrder(order);
expect(r.status).toBe('unknown');
if (r.status === 'unknown') expect(r.error).toMatch(/timeout after 20 ms/);
});
it('classifies exchange rejections by code in the text', async () => {
const s = scripted([
{
status: 502,
body: {
ok: false,
error: 'Too Many Requests!: L1Address ratelimit reached ... 40 requests per 60 second is allowed',
},
},
{ status: 502, body: { ok: false, error: 'code=21734 too far from the mark' } },
{ status: 400, body: { ok: false, error: "bad request: 'price'" } },
{ status: 401, body: 'not json' },
]);
const c = createSignerClient({ url: URL, token: 't', fetch: s.fetch });
expect(await c.placeOrder(order)).toMatchObject({
status: 'rejected',
kind: 'rate_limited',
code: undefined,
httpStatus: 502,
});
expect(await c.placeOrder(order)).toMatchObject({ status: 'rejected', kind: 'far_from_mark', code: 21734 });
expect(await c.placeOrder(order)).toMatchObject({ status: 'rejected', kind: 'bad_request', httpStatus: 400 });
expect(await c.placeOrder(order)).toMatchObject({ status: 'rejected', kind: 'unauthorized', error: 'not json' });
});
it('takes a write-window slot before sending and reports rateLimited without a request', async () => {
const s = scripted([{ status: 200, body: { ok: true, tx_hash: '0x1' } }]);
const writeWindow = createWriteWindow({ limit: 2, reserve: 1, now: () => 0 });
const c = createSignerClient({ url: URL, token: 't', fetch: s.fetch, writeWindow });
expect((await c.placeOrder(order)).status).toBe('ok'); // ordinary: 1 of (2-1)
const throttled = await c.placeOrder(order); // ordinary allowance exhausted
expect(throttled.status).toBe('rateLimited');
expect(s.calls).toHaveLength(1);
expect((await c.placeOrder({ ...order, reduceOnly: true })).status).toBe('ok'); // critical uses the reserve
expect((await c.cancelOrder({ marketIndex: 1, orderId: '7' })).status).toBe('rateLimited');
expect(s.calls).toHaveLength(2);
expect(writeWindow.budget().used).toBe(2);
});
it('validates request integers before taking a slot', async () => {
const writeWindow = createWriteWindow({ limit: 5, reserve: 0, now: () => 0 });
const c = createSignerClient({
url: URL,
token: 't',
fetch: scripted([{ status: 200, body: { ok: true } }]).fetch,
writeWindow,
});
expect(() => c.placeOrder({ ...order, baseAmount: 0 })).toThrow(RangeError);
expect(() => c.placeOrder({ ...order, price: 1.5 })).toThrow(RangeError);
expect(() => c.placeOrder({ ...order, marketIndex: -1 })).toThrow(RangeError);
expect(writeWindow.budget().used).toBe(0);
});
it('health never throws and reports what the sidecar is bound to', async () => {
const s = scripted([
{
status: 200,
body: { ok: true, error: null, account_index: 12345, api_key_index: 3, url: 'https://api.rh.lighter.xyz' },
},
{ status: 200, body: { ok: false, error: 'check_client failed' } },
{ throws: 'ECONNREFUSED' },
]);
const c = createSignerClient({ url: URL, token: 't', fetch: s.fetch });
expect(await c.health()).toEqual({
reachable: true,
ok: true,
error: null,
accountIndex: 12345,
apiKeyIndex: 3,
url: 'https://api.rh.lighter.xyz',
});
expect(await c.health()).toMatchObject({ reachable: true, ok: false, error: 'check_client failed' });
expect(await c.health()).toMatchObject({ reachable: false, ok: false, error: 'ECONNREFUSED' });
expect(s.calls[0]).toMatchObject({ url: `${URL}/health`, method: 'GET', headers: {} });
});
it('waitForHealth polls until ok or the deadline', async () => {
const s = scripted([{ throws: 'ECONNREFUSED' }, { throws: 'ECONNREFUSED' }, { status: 200, body: { ok: true } }]);
const sleeps: number[] = [];
const c = createSignerClient({
url: URL,
token: 't',
fetch: s.fetch,
sleep: async (ms) => {
sleeps.push(ms);
},
});
const h = await c.waitForHealth({ timeoutMs: 10_000, intervalMs: 3_000 });
expect(h.ok).toBe(true);
expect(sleeps).toEqual([3_000, 3_000]);
const dead = createSignerClient({
url: URL,
token: 't',
fetch: scripted([{ throws: 'down' }]).fetch,
sleep: async () => {},
});
const h2 = await dead.waitForHealth({ timeoutMs: 0, intervalMs: 1 });
expect(h2.ok).toBe(false);
});
it('authToken returns the token or throws', async () => {
const s = scripted([
{ status: 200, body: { ok: true, token: 'tok' } },
{ status: 502, body: { ok: false, error: 'sdk error' } },
]);
const c = createSignerClient({ url: URL, token: 'bearer-x', fetch: s.fetch });
expect(await c.authToken()).toBe('tok');
expect(s.calls[0]).toMatchObject({ url: `${URL}/auth`, headers: { authorization: 'Bearer bearer-x' } });
await expect(c.authToken()).rejects.toThrow(/sdk error/);
});
});