src/signer/client.ts
v0.1.0 · 3.8 KB
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>;