@lifi/composer-sdk
Version:
Public Composer SDK for building and submitting flows
363 lines (342 loc) • 14.1 kB
text/typescript
import type {
ComposeCompilePartialData,
ComposeCompileRequest,
ComposeCompileResult,
ComposeCompileSuccessData,
ComposeManifest,
ComposeRouteRequest,
SimulateRequest,
SimulateResult,
} from '@lifi/compose-spec';
import type { GetZapPacksOptions, ZapPackOverview } from './discovery.js';
import { ComposeError, errorFromHttpResponse } from './errors.js';
import {
parseCompilePartialEnvelope,
parseSimulateResult,
} from './responseSchemas.js';
// __SDK_VERSION__ is a compile-time constant injected by tsup (via `define` in tsup.config.ts)
// and by vitest (via `define` in vitest.config.ts). Both read the version from package.json
// at build/test time and replace this identifier with the literal string value.
// It is sent as the `x-lifi-composer-sdk` request header so the server can identify the caller.
// Falls back to 'dev' when running via tsx without tsup substitution (e.g. the example harness).
declare const __SDK_VERSION__: string;
const SDK_VERSION: string =
typeof __SDK_VERSION__ !== 'undefined' ? __SDK_VERSION__ : 'dev';
/**
* Configuration for creating a low-level Compose API client.
*/
export interface ComposeClientOptions {
/** Base URL of the Compose API. */
readonly baseUrl: string;
/** Optional custom `fetch` implementation. Defaults to `globalThis.fetch`. */
readonly fetch?: typeof globalThis.fetch;
/** LI.FI API key, sent as the `x-lifi-api-key` header on every request. Required — the Compose API rejects unauthenticated requests. */
readonly apiKey: string;
}
/**
* Low-level HTTP client for the Compose API.
*
* Handles request serialization, SDK version headers, and error mapping.
* Prefer using {@link ComposeSdk} for the full builder experience. Use this
* directly when you need to decouple request building from submission — e.g.
* build via `sdk.request()` then submit via `client.compile()` with custom
* retry logic or request inspection.
*/
export interface ComposeClient {
/**
* Fetches the server's operation manifest describing all supported operations,
* guards, materialisers, and preconditions.
* @returns The manifest document.
* @throws {@link ComposeError} on network, validation, or server errors.
*/
readonly getManifest: () => Promise<ComposeManifest>;
/**
* Submits a compile request and returns the result.
*
* When the caller passes `simulationPolicy: 'allow-revert'` and the transaction
* reverts in simulation, the server responds with HTTP 206 and the SDK returns a
* partial result (`status: 'partial'`) instead of throwing. The partial result
* includes the transaction (without `gasLimit`) and revert diagnostics.
*
* @param request - The full compile request including flow and run inputs.
* @returns A discriminated result: `status: 'success'` or `status: 'partial'`.
* @throws {@link ComposeError} on network, validation, or server errors.
*/
readonly compile: (
request: ComposeCompileRequest,
) => Promise<ComposeCompileResult>;
/**
* Compiles a token pair into a submit-ready transaction via
* `POST /compose/route`.
*
* A convenience path that skips flow authoring: the server builds a one-node
* `lifi.zap` flow with a `directDeposit` input from the `fromToken` /
* `toToken` pair and runs it through the same pipeline as {@link compile}.
* The response is therefore the same discriminated result — HTTP 206 under
* `simulationPolicy: 'allow-revert'` yields `status: 'partial'` rather than
* throwing, exactly as it does for {@link compile}.
*
* As of today that flow is a single zap step: one edge of the backend's
* routing catalog (a position entry or exit, a wrap or unwrap, a mint or
* burn). Enumerate the pairs it covers with {@link getZapPacks}; a pair with
* no edge is rejected with `kind: 'no_route_error'`. The endpoint is meant to
* author richer flows later, so the single-step limit is the current state of
* the server, not the shape of this contract.
*
* Use {@link compile} for what the server does not author for you: several
* chained operations, splits, explicit preconditions, or per-op guards.
*
* `bigint` amounts in `amount` are serialised to decimal strings
* automatically.
*
* @param request - The from/to token pair, amount, signer, and route options.
* @returns A discriminated result: `status: 'success'` or `status: 'partial'`.
* @throws {@link ComposeError} on network, validation, or server errors —
* including `NOT_FOUND` (HTTP 404) when no catalog edge covers the pair.
*/
readonly route: (
request: ComposeRouteRequest,
) => Promise<ComposeCompileResult>;
/**
* Fetches the available routing edges grouped by protocol.
*
* The edge catalog is dynamic — it reflects the current state of the
* backend's routing snapshot (protocols, chains, token blacklists).
* Results are not cached by the SDK; callers should cache as appropriate.
*
* @param options - Optional filter to restrict results to specific protocols.
* @returns An array of {@link ZapPackOverview} objects, one per protocol.
* @throws {@link ComposeError} on network or server errors (503 when the
* routing catalog is not yet initialized).
*/
readonly getZapPacks: (
options?: GetZapPacksOptions,
) => Promise<readonly ZapPackOverview[]>;
/**
* Simulates a raw, pre-encoded transaction against `POST /simulate` and
* reports how the watched balances change and how much inner-call gas it
* burns.
*
* The result is a discriminated union on `status`:
* - `'ok'` — the simulation ran successfully; `deltas`/`gasUsed` are populated.
* - `'revert'` — the simulation ran but the transaction reverted on-chain. A
* revert is a *successful simulation*, not a transport error, so it is
* returned (HTTP 200) rather than thrown — mirroring how {@link compile}
* returns `status: 'partial'` on a simulated revert.
* - `'error'` — the request was well-formed but the simulation could not be
* set up or run (HTTP 422); `message` is intentionally generic.
*
* `bigint` amounts in the request (`value`, requirement `balance`/`allowance`)
* are serialised to decimal strings automatically.
*
* @param request - The raw transaction plus funding `requirements` and the
* `trackedBalances` to watch.
* @returns A {@link SimulateResult} (`ok` / `revert` / `error`).
* @throws {@link ComposeError} on network failures, HTTP 400 (malformed
* input), 401/403 (auth), 404, 429, and 5xx.
*/
readonly simulate: (request: SimulateRequest) => Promise<SimulateResult>;
}
const bigintReplacer = (_key: string, value: unknown): unknown =>
typeof value === 'bigint' ? value.toString() : value;
const isNonNullObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null;
const parseBody = async <T>(res: Response, url: string): Promise<T> => {
const body = await res.json().catch((_) => null);
if (!isNonNullObject(body) || !('data' in body)) {
throw new ComposeError('UNKNOWN_ERROR', 'Unexpected response format', {
url,
});
}
return body.data as T;
};
const parseCompileSuccessBody = async (
res: Response,
url: string,
): Promise<ComposeCompileResult> => {
const data = await parseBody<ComposeCompileSuccessData>(res, url);
return { ...data, status: 'success' as const };
};
const parsePartialBody = async (
res: Response,
url: string,
): Promise<ComposeCompileResult> => {
const body = await res.json().catch((_) => null);
const envelope = parseCompilePartialEnvelope(body);
if (envelope === null) {
throw new ComposeError(
'UNKNOWN_ERROR',
'Unexpected partial response format',
{ url },
);
}
// `data` is validated as an object by the schema; compose-spec owns its full
// shape as a hand-authored type, so we narrow it here rather than re-declaring
// that type as a schema. `error` is fully validated — no cast needed.
const data = envelope.data as unknown as ComposeCompilePartialData;
return { ...data, status: 'partial' as const, error: envelope.error };
};
// `/simulate` is un-enveloped: the discriminated body (`{ status, ... }`) is at
// the top level, NOT wrapped in `{ data }` like `/compose`. So this reads the
// body directly and validates it against the simulate union rather than reusing
// `parseBody`.
const parseSimulateBody = async (
res: Response,
url: string,
): Promise<SimulateResult> => {
const body = await res.json().catch((_) => null);
const result = parseSimulateResult(body);
if (result === null) {
throw new ComposeError(
'UNKNOWN_ERROR',
'Unexpected simulate response format',
{ url },
);
}
return result;
};
// `POST /compose` and `POST /compose/route` differ only in path and request
// shape: both return the enveloped success data on 200 and the partial
// envelope on 206, so they share one transport.
type ComposeTransport = {
readonly fetchFn: typeof fetch;
readonly baseHeaders: Record<string, string>;
};
const postCompile = async (
{ fetchFn, baseHeaders }: ComposeTransport,
url: string,
request: ComposeCompileRequest | ComposeRouteRequest,
): Promise<ComposeCompileResult> => {
let res: Response;
try {
res = await fetchFn(url, {
method: 'POST',
headers: { ...baseHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify(request, bigintReplacer),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new ComposeError('NETWORK_ERROR', message, { cause: err });
}
if (res.status === 206) {
return await parsePartialBody(res, url);
}
if (!res.ok) {
const body = await res.text();
throw errorFromHttpResponse(res.status, body, url);
}
return await parseCompileSuccessBody(res, url);
};
/**
* Creates a low-level Compose API client.
*
* @param options - Client configuration including the API base URL.
* @returns A {@link ComposeClient} instance.
*/
export const createComposeClient = (
options: ComposeClientOptions,
): ComposeClient => {
if (!options.baseUrl || !/^https?:\/\//i.test(options.baseUrl)) {
throw new ComposeError(
'VALIDATION_ERROR',
`Invalid baseUrl: expected an HTTP(S) URL, got "${options.baseUrl}"`,
);
}
const trimmedApiKey = options.apiKey?.trim() || undefined;
if (!trimmedApiKey) {
throw new ComposeError(
'VALIDATION_ERROR',
'apiKey is required: pass a LI.FI API key to createComposeSdk().',
);
}
const fetchFn = options.fetch ?? globalThis.fetch;
const base = options.baseUrl.replace(/\/$/, '');
const baseHeaders: Record<string, string> = {
Accept: 'application/json',
'x-lifi-composer-sdk': SDK_VERSION,
'x-lifi-api-key': trimmedApiKey,
};
const transport: ComposeTransport = { fetchFn, baseHeaders };
const getManifest = async (): Promise<ComposeManifest> => {
const url = `${base}/compose/manifest`;
let res: Response;
try {
res = await fetchFn(url, {
method: 'GET',
headers: { ...baseHeaders },
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new ComposeError('NETWORK_ERROR', message, { cause: err });
}
if (!res.ok) {
const body = await res.text();
throw errorFromHttpResponse(res.status, body, url);
}
return await parseBody<ComposeManifest>(res, url);
};
const compile = async (
request: ComposeCompileRequest,
): Promise<ComposeCompileResult> =>
postCompile(transport, `${base}/compose`, request);
const route = async (
request: ComposeRouteRequest,
): Promise<ComposeCompileResult> =>
postCompile(transport, `${base}/compose/route`, request);
const getZapPacks = async (
options?: GetZapPacksOptions,
): Promise<readonly ZapPackOverview[]> => {
const params = new URLSearchParams();
if (options?.protocols !== undefined) {
// Backend expects a single comma-separated value, not repeated keys.
const raw = options.protocols;
const list = typeof raw === 'string' ? raw : raw.join(',');
params.set('protocols', list);
}
const qs = params.toString();
const url = `${base}/compose/zap-packs${qs ? `?${qs}` : ''}`;
let res: Response;
try {
res = await fetchFn(url, {
method: 'GET',
headers: { ...baseHeaders },
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new ComposeError('NETWORK_ERROR', message, { cause: err });
}
if (!res.ok) {
const body = await res.text();
throw errorFromHttpResponse(res.status, body, url);
}
return await parseBody<readonly ZapPackOverview[]>(res, url);
};
const simulate = async (
request: SimulateRequest,
): Promise<SimulateResult> => {
const url = `${base}/simulate`;
let res: Response;
try {
res = await fetchFn(url, {
method: 'POST',
headers: { ...baseHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify(request, bigintReplacer),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new ComposeError('NETWORK_ERROR', message, { cause: err });
}
// Only 200 (carries `ok`/`revert`) and 422 (carries the `error` member of
// the union) have a discriminated body. 422 is deliberately intercepted
// here (not thrown as VALIDATION_ERROR) so callers get one exhaustive
// `switch (result.status)`. Every other status is a transport error and is
// thrown — including HTTP 400 (malformed input, no `status` body) and any
// unexpected 2xx.
if (res.status === 200 || res.status === 422) {
return await parseSimulateBody(res, url);
}
const body = await res.text();
throw errorFromHttpResponse(res.status, body, url);
};
return { getManifest, compile, route, getZapPacks, simulate };
};