Skip to content
markpaper

src/signer/client.ts

v0.2.1 · 10.6 KB

Download file
// Client of the Python signer sidecar (knowledge base: signing-and-sdk.md §3.5, orders.md §9).
//
// The sidecar signs with the official lighter-sdk and returns the raw answer. This client:
//   - never retries a write (a repeated placement is a second position; the caller that sees the book
//     decides about repeats);
//   - turns a timeout, a 504 or a loopback failure into `status: 'unknown'` - not a rejection;
//   - optionally takes a slot in a `WriteWindow` BEFORE the request (`rateLimited` when refused);
//   - sends the cancel `order_index` as a STRING (numbers above 2^53 do not survive JSON);
//   - is fail-closed on access: an empty bearer token is a configuration error, not "no auth".
// Default timeouts: 35 s for writes (above the sidecar's own 25 s so its 504 can arrive),
// 10 s for /health, 20 s for /auth.

import type { FetchLike } from '../rest/types.js';
import type { WriteWindow } from '../writeBudget/window.js';
import { interpretSignerError } from './errors.js';
import {
  type CancelOrderRequest,
  CROSS_MARGIN_MODE,
  type PlaceOrderRequest,
  SIGNER_PATHS,
  type SignerHealth,
  type SignerWriteResult,
  type UpdateLeverageRequest,
} from './types.js';

export const DEFAULT_SIGNER_WRITE_TIMEOUT_MS = 35_000;
export const DEFAULT_SIGNER_HEALTH_TIMEOUT_MS = 10_000;
export const DEFAULT_SIGNER_AUTH_TIMEOUT_MS = 20_000;
/** Startup wait for the sidecar: poll /health every 3 s for up to 90 s (knowledge base: ops.md TL;DR 2). */
export const DEFAULT_SIGNER_STARTUP_WAIT_MS = 90_000;
export const DEFAULT_SIGNER_STARTUP_POLL_MS = 3_000;

export interface SignerWriteEvent {
  path: string;
  critical: boolean;
  result: SignerWriteResult;
  durationMs: number;
}

export interface SignerClientOptions {
  /** Sidecar base URL, e.g. `http://127.0.0.1:<port>`. */
  url: string;
  /** Bearer token shared with the sidecar. Must not be empty. */
  token: string;
  fetch?: FetchLike;
  /** Write timeout. Default 35 000. */
  timeoutMs?: number;
  healthTimeoutMs?: number;
  authTimeoutMs?: number;
  /**
   * Local write window. When given, every write takes a slot first: cancels and reduceOnly orders as
   * critical, placements and leverage as ordinary. Without it the caller must throttle.
   */
  writeWindow?: WriteWindow;
  /** Called after every write. Exceptions thrown by the callback are swallowed. */
  onWrite?: (event: SignerWriteEvent) => void;
  /** Sleep, for `waitForHealth` in tests. */
  sleep?: (ms: number) => Promise<void>;
}

export interface SignerClient {
  readonly url: string;
  /** `GET /health`. Never throws: an unreachable sidecar is `{ reachable: false, ok: false }`. */
  health(): Promise<SignerHealth>;
  /** Polls `/health` until `ok` or the deadline; returns the last answer. Startup only. */
  waitForHealth(opts?: { timeoutMs?: number; intervalMs?: number }): Promise<SignerHealth>;
  /** `GET /auth` -> the 10-minute auth token for private reads. Throws on failure (plug into `createAuthTokenCache`). */
  authToken(): Promise<string>;
  /** `POST /order`. Critical when `reduceOnly`. */
  placeOrder(req: PlaceOrderRequest): Promise<SignerWriteResult>;
  /** `POST /cancel`. Always critical. Confirmed ONLY by `status: 'ok'`. */
  cancelOrder(req: CancelOrderRequest): Promise<SignerWriteResult>;
  /** `POST /leverage`. Ordinary write; one per market, remember what is applied. */
  updateLeverage(req: UpdateLeverageRequest): Promise<SignerWriteResult>;
}

interface SidecarBody {
  ok?: unknown;
  tx_hash?: unknown;
  error?: unknown;
  unknown?: unknown;
  token?: unknown;
  account_index?: unknown;
  api_key_index?: unknown;
  url?: unknown;
}

const DIGITS = /^[0-9]+$/;

function nonNegativeInt(value: number, label: string): number {
  if (!Number.isSafeInteger(value) || value < 0)
    throw new RangeError(`${label} must be a non-negative integer, got ${String(value)}`);
  return value;
}

function positiveInt(value: number, label: string): number {
  if (!Number.isSafeInteger(value) || value <= 0)
    throw new RangeError(`${label} must be a positive integer, got ${String(value)}`);
  return value;
}

function withTimeout(ms: number): { signal: AbortSignal; dispose: () => void } {
  const ctl = new AbortController();
  const timer = setTimeout(() => ctl.abort(new Error(`timeout after ${ms} ms`)), ms);
  return { signal: ctl.signal, dispose: () => clearTimeout(timer) };
}

function parseBody(text: string): SidecarBody {
  try {
    const v: unknown = JSON.parse(text);
    return typeof v === 'object' && v !== null ? (v as SidecarBody) : {};
  } catch {
    return {};
  }
}

const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
const numOrUndefined = (v: unknown): number | undefined =>
  typeof v === 'number' && Number.isFinite(v) ? v : undefined;

export function createSignerClient(opts: SignerClientOptions): SignerClient {
  const url = opts.url.trim().replace(/\/+$/, '');
  if (!/^https?:\/\//.test(url)) throw new Error(`invalid signer url: ${opts.url}`);
  const token = opts.token.trim();
  if (!token) throw new Error('signer token must not be empty (fail-closed: the sidecar refuses an empty token too)');
  const fetchImpl: FetchLike = opts.fetch ?? ((input, init) => fetch(input, init));
  const writeTimeout = opts.timeoutMs ?? DEFAULT_SIGNER_WRITE_TIMEOUT_MS;
  const healthTimeout = opts.healthTimeoutMs ?? DEFAULT_SIGNER_HEALTH_TIMEOUT_MS;
  const authTimeout = opts.authTimeoutMs ?? DEFAULT_SIGNER_AUTH_TIMEOUT_MS;
  const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
  const authHeaders = { authorization: `Bearer ${token}` };

  const emit = (e: SignerWriteEvent) => {
    try {
      opts.onWrite?.(e);
    } catch {
      // telemetry must not break writes
    }
  };

  async function getJson(
    path: string,
    timeoutMs: number,
    auth: boolean,
  ): Promise<{ status: number; body: SidecarBody }> {
    const { signal, dispose } = withTimeout(timeoutMs);
    try {
      const res = await fetchImpl(`${url}${path}`, { method: 'GET', headers: auth ? authHeaders : {}, signal });
      return { status: res.status, body: parseBody(await res.text()) };
    } finally {
      dispose();
    }
  }

  async function write(path: string, body: unknown, critical: boolean): Promise<SignerWriteResult> {
    const started = Date.now();
    const done = (result: SignerWriteResult): SignerWriteResult => {
      emit({ path, critical, result, durationMs: Date.now() - started });
      return result;
    };
    if (opts.writeWindow) {
      const slot = opts.writeWindow.tryTake(critical);
      if (!slot.ok) return done({ status: 'rateLimited', error: slot.error });
    }
    const { signal, dispose } = withTimeout(writeTimeout);
    let status: number;
    let text: string;
    try {
      const res = await fetchImpl(`${url}${path}`, {
        method: 'POST',
        headers: { 'content-type': 'application/json', ...authHeaders },
        body: JSON.stringify(body),
        signal,
      });
      status = res.status;
      text = await res.text();
    } catch (err) {
      // Loopback failed or timed out: the sidecar may have received the request and sent the order.
      return done({ status: 'unknown', error: err instanceof Error ? err.message : String(err) });
    } finally {
      dispose();
    }
    const json = parseBody(text);
    const errorText = str(json.error);
    if (status === 504 || json.unknown === true) return done({ status: 'unknown', error: errorText ?? 'timeout' });
    if (json.ok === true) {
      const tx = str(json.tx_hash);
      return done({ status: 'ok', txHash: tx ?? null, raw: json });
    }
    const info = interpretSignerError(errorText ?? (text || `HTTP ${status}`), status);
    return done({
      status: 'rejected',
      error: info.message,
      kind: info.kind,
      code: info.code,
      httpStatus: status,
      raw: json,
    });
  }

  async function health(): Promise<SignerHealth> {
    try {
      const { status, body } = await getJson(SIGNER_PATHS.health, healthTimeout, false);
      if (status !== 200)
        return {
          reachable: true,
          ok: false,
          error: str(body.error) ?? `HTTP ${status}`,
          accountIndex: undefined,
          apiKeyIndex: undefined,
          url: undefined,
        };
      return {
        reachable: true,
        ok: body.ok === true,
        error: str(body.error) ?? null,
        accountIndex: numOrUndefined(body.account_index),
        apiKeyIndex: numOrUndefined(body.api_key_index),
        url: str(body.url),
      };
    } catch (err) {
      return {
        reachable: false,
        ok: false,
        error: err instanceof Error ? err.message : String(err),
        accountIndex: undefined,
        apiKeyIndex: undefined,
        url: undefined,
      };
    }
  }

  return {
    url,
    health,
    async waitForHealth(o = {}) {
      const timeoutMs = o.timeoutMs ?? DEFAULT_SIGNER_STARTUP_WAIT_MS;
      const intervalMs = o.intervalMs ?? DEFAULT_SIGNER_STARTUP_POLL_MS;
      const deadline = Date.now() + timeoutMs;
      let last = await health();
      while (!last.ok && Date.now() < deadline) {
        await sleep(intervalMs);
        last = await health();
      }
      return last;
    },
    async authToken() {
      const { status, body } = await getJson(SIGNER_PATHS.auth, authTimeout, true);
      const tok = str(body.token);
      if (status !== 200 || body.ok !== true || !tok) {
        throw new Error(`signer /auth failed: ${str(body.error) ?? `HTTP ${status}`}`);
      }
      return tok;
    },
    placeOrder(req) {
      const body = {
        market_index: nonNegativeInt(req.marketIndex, 'marketIndex'),
        client_order_index: nonNegativeInt(req.clientOrderIndex, 'clientOrderIndex'),
        base_amount: positiveInt(req.baseAmount, 'baseAmount'),
        price: positiveInt(req.price, 'price'),
        is_ask: req.isAsk === true,
        reduce_only: req.reduceOnly === true,
        ioc: req.ioc === true,
      };
      return write(SIGNER_PATHS.order, body, req.reduceOnly === true);
    },
    cancelOrder(req) {
      const id = req.orderId.trim();
      if (!DIGITS.test(id))
        throw new RangeError(`orderId must be the exact digit string, got ${JSON.stringify(req.orderId)}`);
      const body = { market_index: nonNegativeInt(req.marketIndex, 'marketIndex'), order_index: id };
      return write(SIGNER_PATHS.cancel, body, true);
    },
    updateLeverage(req) {
      const body = {
        market_index: nonNegativeInt(req.marketIndex, 'marketIndex'),
        leverage: positiveInt(req.leverage, 'leverage'),
        margin_mode: nonNegativeInt(req.marginMode ?? CROSS_MARGIN_MODE, 'marginMode'),
      };
      return write(SIGNER_PATHS.leverage, body, false);
    },
  };
}
All files