UNPKG

@lifi/composer-sdk

Version:

Public Composer SDK for building and submitting flows

555 lines (518 loc) 21.7 kB
import type { ComposeCompilePartialData, ComposeCompileRequest, ComposeCompileResult, ComposeCompileSuccessData, ComposeManifest, ComposeRouteRequest, SimulateRequest, SimulateResult, } from '@lifi/compose-spec'; import { checkCompatibility, COMPOSE_VERSION_HEADER, COMPOSER_SDK_VERSION_HEADER, parseVersion, } from '@lifi/compose-spec'; import type { ChainSummary, 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 `COMPOSER_SDK_VERSION_HEADER` 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'; // `undefined` for the `dev` sentinel, which disables the response version check. const SDK_VERSION_TRIPLE = parseVersion(SDK_VERSION); /** * 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>; /** * Fetches the chains the Compose API supports, ascending by `chainId`. * * A chain is listed once the server confirms the VM contract is deployed on * it — the same condition {@link compile}, {@link route} and * {@link simulate} validate against. A chain absent from this list is * rejected outright by those endpoints. * * The converse does not hold: a listed chain can still have no routing edge * for a given token pair, which {@link route} reports as `no_route_error`. * Use {@link getZapPacks} to enumerate the pairs that do route. * * Each entry carries a display `name` alongside its `chainId`. The name is a * deployment-maintained label — render it, but key on `chainId`. * * The list tracks the server's chain registry, which refreshes periodically. * Results are not cached by the SDK; callers should cache as appropriate. * * @returns The supported chains, ascending by `chainId`. * @throws {@link ComposeError} on network or server errors. */ readonly getChains: () => Promise<readonly ChainSummary[]>; /** * 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 * 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. * * Only synchronous edges are considered unless the request sets * `allowAsyncRoutes: true`, so by default every terminal output comes back * `delivery.when: 'now'` and a pair reachable only after settlement is a * `no_route_error`. Opting in adds the `lifi.zapAsync` fallback, which still * loses to a synchronous edge when the pair has one; read * `outputs[*].delivery.when` to see which representation you got. * * 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>; } /** * `JSON.stringify` replacer that renders `bigint` amounts as decimal strings. * * The compose wire carries token amounts as decimal strings, and `bigint` has * no JSON representation, so every payload this client sends goes through it. * Exported so the example harness prints a request exactly as it is sent. */ export 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; // Non-negative decimal integer string, as the server sends token amounts. const INTEGER_STRING = /^\d+$/; const decodeAmount = (value: unknown, path: string, url: string): bigint => { if (typeof value !== 'string' || !INTEGER_STRING.test(value)) { throw new ComposeError( 'UNKNOWN_ERROR', `Malformed compose response: ${path} is ${ value === undefined ? 'absent' : 'not an integer string' }`, { url }, ); } return BigInt(value); }; // The two records this seam reads out of a compile-family response body. Each // is `unknown` because the seam is what establishes its shape: it validates // every field before writing a decoded `bigint` back over the wire string. interface WireAmountBody { outputs?: unknown; } // One `Amount` as it arrives: each leaf is `unknown` until this seam has // checked it, and is overwritten in place with the decoded `bigint`. interface WireAmount { estimate?: unknown; minimum?: unknown; } const malformed = (path: string, what: string, url: string): ComposeError => new ComposeError( 'UNKNOWN_ERROR', `Malformed compose response: ${path} ${what}`, { url }, ); // Decode one `Estimate` / `Minimum` leaf in place: `{ basis, value }` with // `value` a decimal string on the wire and a `bigint` on `Amount`. const decodeAmountLeaf = ( amount: WireAmount, field: 'estimate' | 'minimum', path: string, url: string, ): void => { const leaf = amount[field]; if (leaf === undefined) return; if (!isNonNullObject(leaf)) { throw malformed(`${path}.${field}`, 'is not an object', url); } leaf.value = decodeAmount(leaf.value, `${path}.${field}.value`, url); }; // `outputs` is REQUIRED on every compile-family body and is the record a // caller reads amounts from, so its structure is checked here — the body is // cast, not schema-parsed — before each `value` leaf becomes a `bigint`. const decodeOutputs = (data: WireAmountBody, url: string): void => { const outputs = data.outputs; if (!isNonNullObject(outputs)) { throw malformed( 'outputs', outputs === undefined ? 'is absent' : 'is not an object', url, ); } for (const key of Object.keys(outputs)) { const entry = outputs[key]; if (!isNonNullObject(entry)) { throw malformed(`outputs.${key}`, 'is not an object', url); } const amount = entry.amount; if (!isNonNullObject(amount)) { throw malformed( `outputs.${key}.amount`, amount === undefined ? 'is absent' : 'is not an object', url, ); } decodeAmountLeaf(amount, 'estimate', `outputs.${key}.amount`, url); decodeAmountLeaf(amount, 'minimum', `outputs.${key}.amount`, url); } }; // THE decode seam for a compile-family response body: every amount the wire // renders as a decimal string becomes the `bigint` its declared type promises. // The amount record is handled here so the body is narrowed once — a // non-object body is not this function's error to raise, since the enveloping // parse already rejected it. const decodeWireAmounts = (data: unknown, url: string): void => { if (!isNonNullObject(data)) return; decodeOutputs(data, url); }; 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); decodeWireAmounts(data, 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 ComposeCompilePartialData; decodeWireAmounts(data, url); 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>; }; // A patch bump is additive and a minor bump is breaking, so a 200 from a // backend behind this SDK cannot be trusted: the newer flow this SDK authored // may have compiled under the server's older semantics without raising an // error. This covers the deploy window where the SDK is published to npm ahead // of the backend rollout. Only the compile-family POSTs are checked; discovery // GETs stay reachable from a mismatched SDK, as on the server. An absent header // (a pre-gate backend, a header-stripping proxy) is accepted, mirroring the // server's pass-through of a compile that carries no version header. const assertServerVersion = (res: Response, url: string): void => { const serverVersion = res.headers.get(COMPOSE_VERSION_HEADER); if (serverVersion === null || SDK_VERSION_TRIPLE === undefined) return; const serverTriple = parseVersion(serverVersion); if (serverTriple === undefined) return; const compatibility = checkCompatibility(SDK_VERSION_TRIPLE, serverTriple); if (compatibility === 'compatible') return; const versions = { sdkVersion: SDK_VERSION, serverVersion }; if (compatibility === 'sdk_outdated') { throw new ComposeError( 'VALIDATION_ERROR', `composer-sdk ${SDK_VERSION} is older than the compose contract served at ${url} (${serverVersion}); upgrade @lifi/composer-sdk and @lifi/compose-spec`, { status: res.status, url, kind: 'sdk_outdated', sdkOutdated: { ...versions, minimumSdkVersion: `${serverTriple.major}.${serverTriple.minor}.0`, }, }, ); } throw new ComposeError( 'VALIDATION_ERROR', `composer-sdk ${SDK_VERSION} targets a newer compose contract than the server at ${url} serves (${serverVersion}); pin @lifi/composer-sdk and @lifi/compose-spec to ${serverVersion} or wait for the server rollout`, { status: res.status, url, kind: 'server_outdated', serverOutdated: versions, }, ); }; type SendInit = | { readonly method: 'GET' } | { readonly method: 'POST'; readonly body: unknown }; // GETs on this API carry no body; POSTs carry a JSON one with `bigint` amounts // rendered as decimal strings. const requestInit = ( baseHeaders: Record<string, string>, init: SendInit, ): RequestInit => init.method === 'GET' ? { method: 'GET', headers: { ...baseHeaders } } : { method: 'POST', headers: { ...baseHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify(init.body, bigintReplacer), }; // One fetch path for every route: maps transport failures to `NETWORK_ERROR`. // The response version check is not here — only the compile-family POSTs gate // on it (see `postCompile`); discovery GETs and `/simulate` are ungated. const send = async ( { fetchFn, baseHeaders }: ComposeTransport, url: string, init: SendInit, ): Promise<Response> => { try { return await fetchFn(url, requestInit(baseHeaders, init)); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new ComposeError('NETWORK_ERROR', message, { cause: err }); } }; const postCompile = async ( transport: ComposeTransport, url: string, request: ComposeCompileRequest | ComposeRouteRequest, ): Promise<ComposeCompileResult> => { const res = await send(transport, url, { method: 'POST', body: request }); assertServerVersion(res, url); 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); }; // Every enveloped GET on this API behaves identically: no body, no query // semantics of its own, `{ data }` on success, an error envelope otherwise. const getJson = async <T>( transport: ComposeTransport, url: string, ): Promise<T> => { const res = await send(transport, url, { method: 'GET' }); if (!res.ok) { const body = await res.text(); throw errorFromHttpResponse(res.status, body, url); } return await parseBody<T>(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', [COMPOSER_SDK_VERSION_HEADER]: SDK_VERSION, 'x-lifi-api-key': trimmedApiKey, }; const transport: ComposeTransport = { fetchFn, baseHeaders }; const getManifest = async (): Promise<ComposeManifest> => getJson<ComposeManifest>(transport, `${base}/compose/manifest`); const getChains = async (): Promise<readonly ChainSummary[]> => getJson<readonly ChainSummary[]>(transport, `${base}/chains`); 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(); return getJson<readonly ZapPackOverview[]>( transport, `${base}/compose/zap-packs${qs ? `?${qs}` : ''}`, ); }; const simulate = async ( request: SimulateRequest, ): Promise<SimulateResult> => { const url = `${base}/simulate`; const res = await send(transport, url, { method: 'POST', body: request }); // 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, getChains, compile, route, getZapPacks, simulate }; };