src/signer/client.ts
v0.2.1 · 10.6 KB
// 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);
},
};
}