Skip to content
markpaper

src/signer/client.ts

v0.1.0 · 3.8 KB

Download file
import { isSignatureString } from '../address/base58.js';
import { decodeWriteAnswer } from './decode.js';
import { decodeSidecarHealth } from './health.js';

export interface SignerClientOptions {
  baseUrl: string;
  token: string;
  timeoutMs: number;
  fetch?: typeof globalThis.fetch;
}
export type SignerWriteResult =
  | { kind: 'answer'; answer: ReturnType<typeof decodeWriteAnswer> }
  | { kind: 'refused'; httpStatus: number; code: string; message: string }
  | { kind: 'no-answer'; error: string; signature: string | null };
const REFUSED = new Set([400, 401, 403, 409, 413, 503]);
export function createSignerClient(opts: SignerClientOptions) {
  const u = new URL(opts.baseUrl);
  const host = u.hostname.replace(/^\[|\]$/g, '');
  if (
    !(/^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(host) || host === '::1' || host === 'localhost') ||
    !['http:', 'https:'].includes(u.protocol)
  )
    throw new TypeError('Signer URL must be loopback');
  if (opts.token.length < 32 || /\s/.test(opts.token) || !Number.isSafeInteger(opts.timeoutMs) || opts.timeoutMs < 1)
    throw new TypeError('Bearer token and explicit timeout required');
  const baseUrl = opts.baseUrl.replace(/\/+$/, '');
  const fetchImpl = opts.fetch ?? globalThis.fetch;
  async function request(path: string, body?: unknown) {
    const ctl = new AbortController();
    const t = setTimeout(() => ctl.abort(), opts.timeoutMs);
    try {
      const r = await fetchImpl(baseUrl + path, {
        signal: ctl.signal,
        method: body === undefined ? 'GET' : 'POST',
        headers: {
          authorization: `Bearer ${opts.token}`,
          ...(body === undefined ? {} : { 'content-type': 'application/json' }),
        },
        ...(body === undefined ? {} : { body: JSON.stringify(body) }),
      });
      const raw = await r.text();
      let json: unknown;
      try {
        json = JSON.parse(raw);
      } catch {
        return { reachable: false, httpStatus: r.status, body: null, error: 'Signer response is not JSON' };
      }
      return { reachable: true, httpStatus: r.status, body: json };
    } catch (e) {
      return { reachable: false, httpStatus: 0, body: null, error: (e as Error).message };
    } finally {
      clearTimeout(t);
    }
  }
  return {
    get: request,
    async health() {
      const r = await request('/health');
      return { ...r, health: r.httpStatus === 200 ? decodeSidecarHealth(r.body) : null };
    },
    async write(
      path: '/order' | '/cancel' | '/cancelAll',
      body: { intentId: string } & Record<string, unknown>,
      side: 'bid' | 'ask' | null,
    ): Promise<SignerWriteResult> {
      // No automatic retries: the same intent and bytes can be explicitly reconciled by the caller.
      const r = await request(path, body);
      if (!r.reachable) return { kind: 'no-answer', error: r.error ?? 'No response', signature: null };
      if (r.httpStatus === 200)
        return { kind: 'answer', answer: decodeWriteAnswer(r.body, { intentId: body.intentId, side }) };
      const b = (r.body ?? {}) as Record<string, unknown>;
      if (REFUSED.has(r.httpStatus))
        return {
          kind: 'refused',
          httpStatus: r.httpStatus,
          code: String(b.error ?? ''),
          message: String(b.message ?? ''),
        };
      return {
        kind: 'no-answer',
        error: `HTTP ${r.httpStatus}`,
        signature: typeof b.signature === 'string' && isSignatureString(b.signature) ? b.signature : null,
      };
    },
    async tx(signature: string, side: 'bid' | 'ask' | null) {
      if (!isSignatureString(signature)) return null;
      const r = await request(`/tx/${signature}`);
      return r.reachable && r.httpStatus === 200
        ? decodeWriteAnswer(r.body, { side, expectedSignature: signature })
        : null;
    },
  };
}
export type SignerClient = ReturnType<typeof createSignerClient>;
All files