@lifi/composer-sdk
Version:
Public Composer SDK for building and submitting flows
469 lines (360 loc) • 24.6 kB
Markdown
# @lifi/composer-sdk
TypeScript SDK for building and submitting LI.FI Compose flows.
## Install
`@lifi/compose-spec` is a peer dependency and must be installed alongside the SDK at the same version (they are versioned in lockstep).
```bash
npm install @lifi/composer-sdk @lifi/compose-spec
```
## Upgrading to 0.3.0
**Breaking:** `apiKey` is now required on `createComposeSdk()`.
The Compose API has always rejected unauthenticated requests, but `apiKey` was typed as optional, so omitting it compiled and only failed on the first request. It is now a required `string`, and a missing or blank value throws a `ComposeError` (`VALIDATION_ERROR`) at construction time — including for plain-JavaScript callers that bypass the type check.
```diff
-const sdk = createComposeSdk({ baseUrl: 'https://composer.li.quest' });
+const sdk = createComposeSdk({
+ baseUrl: 'https://composer.li.quest',
+ apiKey: process.env.LIFI_API_KEY!,
+});
```
Create a key at [portal.li.fi](https://portal.li.fi).
## Quick start
Swap WETH to USDC, then zap the USDC into an Aave lending position — all in a single transaction.
```ts
import {
createComposeSdk,
resources,
guards,
materialisers,
} from '@lifi/composer-sdk';
const WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2';
const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
const A_ETH_USDC = '0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5c'; // Aave aEthUSDC
const OWNER = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045';
// Create the SDK pointed at the Compose API.
const sdk = createComposeSdk({
baseUrl: 'https://composer.li.quest',
apiKey: process.env.LIFI_API_KEY!, // required
});
// Build a two-step flow on Ethereum mainnet.
const builder = sdk.flow(1, {
name: 'swap-and-zap-weth-to-aave',
inputs: {
amountIn: resources.erc20(WETH, 1),
},
});
// Step 1: Swap WETH → USDC via LI.FI.
const swapOutputs = builder.lifi.swap('swap', {
bind: { amountIn: builder.inputs.amountIn },
config: {
resourceOut: resources.erc20(USDC, 1),
slippage: 0.03,
},
});
// Step 2: Zap the swapped USDC into Aave.
// The swap's amountOut handle threads directly into the zap's amountIn.
builder.lifi.zap('zap', {
bind: { amountIn: swapOutputs.amountOut },
config: {
resourceOut: resources.erc20(A_ETH_USDC, 1),
},
guards: [guards.slippage({ port: 'amountOut', bps: 100 })],
});
const flow = builder.build();
// Compile the flow into transaction calldata.
const request = sdk.request(flow, {
signer: OWNER,
inputs: {
amountIn: materialisers.directDeposit({
amount: '1000000000000000000',
}),
},
sweepTo: builder.context.sender,
// Opt into partial results. Without this, the default 'strict' policy
// throws a ComposeError (HTTP 422) when simulation detects a revert,
// and the `partial` branch below is never reached.
simulationPolicy: 'allow-revert',
});
const result = await sdk.client.compile(request);
if (result.status === 'success') {
// Full success — transactionRequest includes gasLimit.
console.log(result.transactionRequest);
} else {
// result.status === 'partial' — simulation reverted.
// Transaction is still available but without gasLimit.
console.log(result.simulationRevert);
}
```
## Core concepts
**Flows and operations** — A flow is a sequence of on-chain operations. You declare inputs, chain operations together, and the backend compiles everything into a single transaction. Operations are namespaced (e.g., `builder.lifi.swap`, `builder.core.split`).
**Resources** — `resources.erc20(address, chainId)` and `resources.native(chainId)` describe the tokens flowing through your operations. They carry chain and address metadata used for routing and validation.
**Handles** — Operations produce typed output handles (e.g. `OutputHandle<'resource'>`, `OutputHandle<'uint256'>`) that you bind to downstream inputs. The type system enforces compatibility at compile time — a resource handle can flow into a `uint256` slot (since resources are amounts), but an `address` handle cannot.
**Runtime inputs (materialisers)** — Materialisers resolve input values at execution time rather than at build time. `directDeposit` is exact by default when you provide an amount; pass `allowNonExact: true` to permit capped ERC-20 deposits or deposit-all behavior. `balanceOf` reads the wallet's current balance; `call` measures a balance delta after an arbitrary contract call.
**Preconditions** — Expected on-chain state at execution time: `erc20Balance` and `nativeBalance` assert wallet holdings, `erc20Allowance` asserts token approvals.
**Guards** — Protect against slippage and other runtime conditions. Applied per-operation via the `guards` field.
## API surface
**SDK factory**
- `createComposeSdk({ baseUrl, apiKey, fetch? })` — creates the SDK instance
**Flow building**
- `sdk.flow(chainId, options)` — creates a `FlowBuilder`
- `builder.<namespace>.<operation>(id, { bind, config })` — adds an operation, returns typed `OutputHandle<T>` per port
- `builder.untypedOp(id, op, args)` — escape hatch for operations not in the manifest (returns `void`; use `raw.ref<T>()` to reference its outputs)
- `builder.build()` — produces a `Flow` document
- `sdk.request(flow, { signer, inputs, preconditions, sweepTo, ... })` — builds a compile request
**HTTP client**
- `sdk.client.compile(request)` — sends the flow to the backend and returns a `ComposeCompileResult` (discriminated union: `status: 'success'` or `status: 'partial'`)
- `sdk.client.getManifest()` — fetches the operation manifest
- `sdk.client.getZapPacks(options?)` — fetches the available routing edges grouped by protocol, returning `ZapPackOverview[]`. The catalog is dynamic and is not cached by the SDK. Filter through `GetZapPacksOptions`. An edge with `recipient: 'required'` requires the zap's `recipient` binding, delivers the output externally, and produces an output that cannot be chained or swept. An edge with `availability: 'future'` produces an output that settles only after the initiating transaction; route it via `lifi.zapAsync` with a bound `recipient`. Availability governs custody only — a future output still reports `simulated.amountOut` (an in-transaction estimate; the delivered amount arrives after settlement), with `simulated.amountOutMin` present only when a minimum source exists.
- `sdk.client.simulate(request)` / `sdk.simulate(request)` — simulates a raw, pre-encoded transaction and returns a `SimulateResult` (discriminated union: `status: 'ok' | 'revert' | 'error'`). See [Simulating a raw transaction](#simulating-a-raw-transaction).
- `sdk.client.route(request)` / `sdk.route(request)` — compiles a single from/to token pair on one chain via `POST /compose/route`, returning the same `ComposeCompileResult` as `compile()`. See [Routing a token pair](#routing-a-token-pair).
**Helpers**
- `resources.erc20(address, chainId)` / `resources.native(chainId)` — resource constructors
- `guards.*` — guard factories (e.g., slippage)
- `materialisers.*` — materialiser factories (directDeposit, balanceOf, call)
- `preconditions.*` — precondition factories (erc20Balance, nativeBalance, erc20Allowance)
- `raw.ref<T>(path)` — create a typed `$ref` pointer for use in bind slots (escape hatch for `untypedOp` outputs)
- `raw.guard(kind, config?)` / `raw.materialiser(kind, config?)` — low-level factories for guards and materialisers
- `buildSimulateRequest({ result, chainId, signer, trackedBalances, requirements?, block?, value? })` — assembles a `SimulateRequest` from a compile result (pure, no I/O)
- `routeAmount.exact(amount)` / `routeAmount.all(simAmount)` — amount constructors for `sdk.route(...)`
## Routing a token pair
Authoring a flow is the general path: you decide the operations and how they chain. `sdk.route(...)` is the shortcut for the case where the backend can decide for you — you hand it a from/to token pair, an amount, and a signer, and it returns the same submit-ready `ComposeCompileResult` that `compile()` returns.
As of today the backend authors a single zap step for that pair: one edge of its routing catalog — entering or exiting a protocol position, a wrap or unwrap, a mint or burn. It is not a swap aggregator, so a pair with no catalog edge (WETH to USDC, for instance) comes back as `no_route_error`. The endpoint is meant to author more complex flows in future releases, so read the single-step limit as where the server is today rather than as the boundary of the API.
```ts
import { createComposeSdk, routeAmount } from '@lifi/composer-sdk';
const sdk = createComposeSdk({
baseUrl: 'https://composer.li.quest',
apiKey: process.env.LIFI_API_KEY!,
});
const result = await sdk.route({
chainId: 1,
fromToken: '0x0000000000000000000000000000000000000000', // native ETH
toToken: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH — a `wrap` edge
amount: routeAmount.exact(100_000_000_000_000_000n), // 0.1 ETH
signer: '0xYourAddress...',
slippageBps: 100,
});
if (result.status === 'success') {
console.log(result.transactionRequest); // to, data, value, gasLimit
}
```
### Discovering routable pairs
The catalog is dynamic — protocols, chains, and token blacklists move — so discover pairs instead of hardcoding them:
```ts
const packs = await sdk.client.getZapPacks({ protocols: 'aave' });
for (const pack of packs) {
for (const edge of pack.edges) {
console.log(pack.protocol, edge.type, edge.in.address, '->', edge.out.address);
}
}
```
Every edge is one routable pair: `edge.in` and `edge.out` are the `fromToken` and `toToken` of a `route` call on `edge.in.chainId`, and `inAmountMin` / `inAmountMax` bound the amount when the protocol constrains it. Two edge fields change how you call `route`:
- **`recipient: 'required'`** — the protocol mints to a named receiver, so set `sweepTo` when the output should not land with the signer.
- **`availability: 'future'`** — the output only exists once the transaction settles. `route` cannot compile these; they need `lifi.zapAsync` on the authoring path.
### Amounts
The `amount` field is a discriminated union with two variants, built by `routeAmount`:
- **`routeAmount.exact(amount)`** — spend exactly `amount` of `fromToken`, in its smallest unit. Use this when you know the figure.
- **`routeAmount.all(simAmount)`** — spend the signer's whole `fromToken` balance, resolved on-chain at execution time. `simAmount` is only an estimate: the quote, the price impact, and the returned approvals are computed against it, but the amount actually moved is the real balance when the transaction lands. Pass your best estimate of the balance — a wildly wrong figure produces a poor quote. Not supported for a native `fromToken`: a gas coin arrives via `msg.value`, so there is no balance to sweep, and the server rejects the request.
Both accept a `bigint` or a decimal string. `bigint` values are serialised for you.
### Options
`sdk.route(...)` shares its optional fields with the authoring path, and the server applies the same defaults. Omit a field to take the default; the SDK sends only what you set.
- **`slippageBps`** — tolerance in basis points. Defaults to `100` (1%).
- **`maxPriceImpactBps`** — reject the route if the priced impact exceeds this. No cap by default.
- **`integratorFeeBps`** — your fee, in basis points, taken from the input amount before the route executes. Requires an integration-scoped `apiKey`; a non-zero value without one is rejected.
- **`referrer`** — attribution string passed to the routing provider.
- **`sweepTo`** — address that receives the output and any leftover dust. Accepts a literal address or `{ $ref: 'context.sender' }`. Defaults to the signer.
- **`checkOnChainAllowances`** — when `true`, the server reads current allowances and omits approvals that are already sufficient. Defaults to `false`.
- **`simulationPolicy`** — `'strict'` (default) or `'allow-revert'`, exactly as on the authoring path. See [Simulation policy and partial results](#simulation-policy-and-partial-results).
- **`simulateUserProgram`** — when `true`, additionally simulates the final user-facing transaction after the structured simulation succeeds. Defaults to `false`.
### When to author a flow instead
`route` gives you one server-authored step. Reach for `sdk.flow(...)` when you need anything it does not express: several operations chained together, splitting one input across several destinations, guards on intermediate outputs, explicit preconditions, arithmetic, funding from proxy-held balances, or an asynchronous position (`availability: 'future'`) as the destination. Swapping WETH to USDC and then depositing the USDC into Aave is a flow; depositing USDC straight into Aave is a route.
### Errors
`route` maps failures the same way `compile` does — see [Error handling](#error-handling). Two are specific to this path:
- **`NOT_FOUND`** with `kind: 'no_route_error'` — the backend has no routing edge for the pair on that chain. `sdk.client.getZapPacks()` reports which edges exist.
- **`VALIDATION_ERROR`** — the pair is routable but the request is rejected, for example a price impact above `maxPriceImpactBps`, or a simulation revert under the default `'strict'` policy.
## Simulation policy and partial results
By default, the Compose backend simulates the compiled transaction and returns an error (HTTP 422) if simulation detects a revert. You can opt into receiving a **partial result** instead by passing `simulationPolicy: 'allow-revert'`:
```ts
const result = await builder.compile({
signer: OWNER,
inputs: { amountIn: materialisers.balanceOf({ owner: OWNER }) },
simulationPolicy: 'allow-revert',
});
if (result.status === 'success') {
// Simulation succeeded. transactionRequest includes gasLimit.
const tx = result.transactionRequest;
console.log(tx.gasLimit); // string
} else {
// result.status === 'partial'
// Simulation reverted, but a transaction is still available (without gasLimit).
console.log(result.error.kind); // 'simulation_revert'
console.log(result.error.message); // human-readable revert description
// Revert diagnostics
const revert = result.simulationRevert;
console.log(revert.code); // e.g. 3
console.log(revert.rawErrorBytes); // raw ABI-encoded error
// Decoded error candidates (when available)
if (revert.decodeResult?.errorCandidates) {
for (const c of revert.decodeResult.errorCandidates) {
console.log(c.decodedErrorSignature, c.decodedParams);
}
}
// The transactionRequest is still usable — the caller must estimate gas themselves.
const tx = result.transactionRequest;
console.log(tx.to, tx.data, tx.value);
}
```
The `simulationPolicy` field accepts two values:
- `'strict'` (default) — revert causes a thrown `ComposeError` with code `VALIDATION_ERROR`
- `'allow-revert'` — revert returns a partial result with `status: 'partial'`
You can also pass `checkOnChainAllowances: true` to have the server filter the returned `approvals` array against current on-chain allowances, omitting approvals that are already sufficient:
```ts
const result = await builder.compile({
signer: OWNER,
inputs: { amountIn: materialisers.balanceOf({ owner: OWNER }) },
checkOnChainAllowances: true,
});
```
## Simulating a raw transaction
`sdk.client.simulate(...)` (and the `sdk.simulate(...)` pass-through) answer a question the compile pipeline does not: _if I send this exact transaction, how do specific token balances change and how much gas does it burn?_ It takes a raw, pre-encoded transaction (a `to`, hex `data`, optional native `value`), funds a sender, runs it in one `eth_call`, and reports the watched balances before/after, their signed deltas, and the inner-call gas.
```ts
import { createComposeSdk } from '@lifi/composer-sdk';
const sdk = createComposeSdk({
baseUrl: 'https://li.quest',
apiKey: process.env.LIFI_API_KEY!,
});
const result = await sdk.client.simulate({
chainId: 1,
from: '0x1111111111111111111111111111111111111111',
to: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC
data: '0xa9059cbb...', // pre-encoded transfer calldata
value: 0n, // bigint accepted; serialised to "0"
requirements: [
{
type: 'Erc20Balance',
wallet: '0x1111111111111111111111111111111111111111',
token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
balance: 1_000_000n, // bigint accepted
},
],
trackedBalances: [
{
token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
owner: '0x1111111111111111111111111111111111111111',
},
{
token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
owner: '0x2222222222222222222222222222222222222222',
},
],
});
switch (result.status) {
case 'ok':
// Successful simulation.
console.log(result.gasUsed, result.deltas);
break;
case 'revert':
// The simulation ran but the transaction reverted on-chain — NOT an error.
console.log(result.revertReason, result.decodeResult);
break;
case 'error':
// The request was well-formed but the simulation could not be set up/run.
console.log(result.message);
break;
}
```
`requirements` is the funding-instruction union (`Erc20Balance`, `NativeBalance`, `Erc20Allowance`); use the zero address as a `trackedBalances` `token` to watch native balance. Amount fields accept `bigint` (serialised to decimal strings) or strings. The caps `SIMULATE_MAX_TRACKED_BALANCES` and `SIMULATE_MAX_REQUIREMENTS` (both `40`) are exported for reference.
Unlike `compile`, a `revert` is returned (not thrown): a revert is a _successful simulation_ whose execution reverted. Only transport failures and HTTP 400/401/403/404/429/5xx throw `ComposeError`.
To simulate a transaction you just compiled, `buildSimulateRequest(...)` assembles the request without re-typing the transaction fields. `chainId` and `signer` are required (a compile result carries neither), and `trackedBalances`/`requirements` cannot be inferred from a flow:
```ts
import { buildSimulateRequest } from '@lifi/composer-sdk';
const compiled = await builder.compile({ inputs: { ... }, signer: '0x1111...' });
const req = buildSimulateRequest({
result: compiled,
chainId: 1,
signer: '0x1111111111111111111111111111111111111111',
trackedBalances: [
{
token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
owner: '0x1111111111111111111111111111111111111111',
},
],
});
const sim = await sdk.client.simulate(req);
```
Two caveats from the endpoint: `gasUsed` is the inner-call execution gas only (it excludes the 21000 base tx cost and calldata gas), and EOA-only behaviour (e.g. `msg.sender == tx.origin` checks) is not faithfully simulated because the call runs through injected VM bytecode rather than a real EOA. See [`docs/references/simulate-endpoint.md`](../../../docs/references/simulate-endpoint.md) for the full contract.
Two runnable examples cover both paths: [`src/examples/simulateRawTransaction.ts`](src/examples/simulateRawTransaction.ts) builds a raw `SimulateRequest` directly, and [`src/examples/simulateCompiledSwap.ts`](src/examples/simulateCompiledSwap.ts) shows the end-to-end compile → `buildSimulateRequest` → `simulate` arc.
## Error handling
All SDK errors are thrown as `ComposeError` with a `code` property:
```ts
import { isComposeError } from '@lifi/composer-sdk';
try {
const result = await sdk.client.compile(request);
} catch (err) {
if (isComposeError(err)) {
console.error(err.code, err.message);
// Codes: VALIDATION_ERROR, SERVER_ERROR, RATE_LIMITED, NETWORK_ERROR, ...
}
}
```
A `preparation_error` (HTTP 422) means some prepared ops failed while others succeeded — a mixed basket with an unroutable leg. Narrow with `isComposePreparationError` to read which ops failed, then drop those `callId`s from the flow and resubmit:
```ts
import { isComposePreparationError } from '@lifi/composer-sdk';
try {
const result = await sdk.client.compile(request);
} catch (err) {
if (isComposePreparationError(err)) {
for (const op of err.failedOps) {
console.error(op.callId, op.op, op.kind, op.message);
}
// Prepared ops that succeeded. Both arrays list prepared ops only, so
// retry by removing the `failedOps` callIds from the original flow.
console.error('prepared ok:', err.succeededOps);
}
}
```
See [`docs/sdk-interface.md`](docs/sdk-interface.md#per-op-preparation-failures) for the full error surface.
## Examples
The `src/examples/` directory contains complete working examples:
- **lifiSwap** — Single token swap (WETH to USDC)
- **lifiZap** — Swap into a DeFi position
- **lifiZapAsync** — Zap into a position whose output settles after the initiating transaction (future availability)
- **swapAndZap** — Multi-step: swap then deposit
- **splitAndZap** — Split a resource and zap each portion into a different vault
- **splitWithArithmetic** — Split then verify with add/subtract/assertEqual assertions
- **dustSweep** — Split and partially use tokens, sweep leftover dust back to sender
- **depositFromProxy** — Read tokens already on the proxy via `balanceOf`, with a precondition guard
- **approveAndDeposit** — Approve a vault, deposit, and graduate shares via `asResource`
- **consolidateToUsdc** — Consolidate multiple tokens into USDC
- **consolidateToEth** — Consolidate multiple tokens into ETH
- **swapToRecipient** — Swap and send to a different address
- **swapWithBalanceCheck** — Swap with balance precondition
- **swapWithOutputValidation** — Swap with computed slippage bounds using bpsDown/bpsUp/assertInRange
- **rawCallWithArithmetic** — Query a contract with pre-encoded calldata, then scale with multiply/divide
- **readContractState** — Compare peek (compile-time), staticCall (execution-time), and balanceOf (resource)
- **swapWithAllowRevert** — Swap with `simulationPolicy: 'allow-revert'` and handle the `ComposeCompileResult` discriminated union
- **swapWithFee** — Swap while collecting an integrator fee via `integratorFeeBps` (requires an integration-scoped `apiKey`)
- **transferTokens** — Transfer ERC-20 tokens from the proxy to an arbitrary recipient
- **callContract** — Call an arbitrary contract (ERC-4626 redeem; reward claim) without a dedicated typed op
- **aaveRepay** — Repay an Aave v3 variable-rate debt, sweeping the unspent residual back to the sender
- **aaveRepayWithATokens** — Repay Aave v3 debt by burning aToken collateral already held by the proxy
- **aaveClaimRewards** — Claim accrued Aave rewards and forward the claimed amount to a recipient
- **aaveSetEMode** — Switch the proxy's Aave v3 eMode category
- **untypedOpWithTypedRef** — Insert an untyped operation node via `untypedOp`, then bridge its output into typed operations using `raw.ref<T>()`
- **routeTokenPair** — Compile a from/to token pair via `sdk.route(...)`, with both the `exact` and `all` amount variants
## Staging channel
Some operations exist on the Compose backend but are deliberately held back from the default SDK — for example an op whose required contract changes are not yet live on production. These **staged** operations are published on a separate npm dist-tag, `staging`:
```bash
npm install @lifi/composer-sdk@staging @lifi/compose-spec@staging
```
The `staging` build includes the not-yet-public operations in its typed surface (e.g. `lifi.flashloanRepay`), so you can author flows against them with full type-checking. The default install (`@lifi/composer-sdk`, the `latest` dist-tag) never exposes them.
A staged operation only runs if the backend you point at actually has it enabled. The SDK has no default backend — you supply one per instance:
```ts
const sdk = createComposeSdk({
baseUrl: '<staging-backend-url>', // a backend that has the staged ops enabled
apiKey: process.env.LIFI_API_KEY!,
});
```
Calling a staged operation against a backend that does not have it enabled fails at runtime as a service-level error; the typed surface being present does not guarantee backend availability.
The channel and the backend URL are independent:
- The `staging` **channel** is durable — it is a permanent property of `main`, and which operations it carries rotates over time as ops graduate to `latest`.
- The `baseUrl` is **per-environment and disposable** — it is a runtime argument, not a build-time property, so the same staging build can target whichever backend currently has the ops enabled.
## License
Apache-2.0