Skip to content
markpaper

src/signer/client.test.ts

v0.2.1 · 10 KB

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