Skip to main content
This guide walks through every step of building a Flow with @lifi/composer-sdk: configuring the SDK for production, declaring inputs, chaining ops, supplying runtime values, attaching safety invariants, and submitting. Read it top to bottom on first integration; come back to specific sections as reference. For a 5-minute first-flow demo, see the Quickstart. For the SDK type surface, see the SDK API reference.

Production setup

Configure the SDK

import { createComposeSdk } from '@lifi/composer-sdk';

const sdk = createComposeSdk({
  baseUrl: process.env.COMPOSE_BASE_URL ?? 'https://composer.li.quest',
  apiKey: process.env.LIFI_API_KEY,
});

if (!process.env.LIFI_API_KEY && process.env.NODE_ENV === 'production') {
  throw new Error('LIFI_API_KEY is required');
}
ComposeSdkOptions:
OptionTypeNotes
baseUrlstringBase URL of the Compose API. SDK default is https://composer.li.quest.
apiKeystringRequired during the technical preview. Sent as the x-lifi-api-key header on every request. Create one at portal.li.fi.
fetchtypeof globalThis.fetch?Optional. Inject a polyfill, logging wrapper, or retry/backoff middleware.

Wire in a signer

@lifi/composer-sdk itself does not sign or broadcast transactions. It returns a transactionRequest object ({ to, data, value, gasLimit? }) that you hand to your wallet library.
import { createWalletClient, custom } from 'viem';
import { mainnet } from 'viem/chains';

const wallet = createWalletClient({
  chain: mainnet,
  transport: custom(window.ethereum),
});

const [signer] = await wallet.requestAddresses();

const result = await builder.compile({
  signer,
  inputs: { /* … */ },
});

const hash = await wallet.sendTransaction({
  account: signer,
  to: result.transactionRequest.to as `0x${string}`,
  data: result.transactionRequest.data as `0x${string}`,
  value: BigInt(result.transactionRequest.value ?? '0'),
});
The signer field in run is purely informational for the compiler; it derives the user’s execution proxy address and sources approvals. The SDK does not validate that the address controls a private key.

Custom fetch (optional)

const sdk = createComposeSdk({
  baseUrl: 'https://composer.li.quest',
  fetch: async (url, init) => {
    const start = Date.now();
    const res   = await globalThis.fetch(url, init);
    console.log(`[compose] ${init?.method ?? 'GET'} ${url}${res.status} in ${Date.now() - start}ms`);
    return res;
  },
});
Common uses: metrics and tracing, retry with exponential backoff, request signing, AbortSignal wiring.

Declare inputs

Inputs are a named record. Each entry is either a resource declaration (token) or a scalar type name.
import { createComposeSdk, resources } from '@lifi/composer-sdk';

const WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2';

const builder = sdk.flow(1, {
  name: 'my-flow',
  inputs: {
    amountIn: resources.erc20(WETH, 1),
  },
});
The builder exposes typed handles as builder.inputs.<name>:
  • builder.inputs.amountIn is a ResourceInputHandle carrying the WETH resource.
Inputs can also be scalar Solidity types (e.g. 'address', 'uint256'), each surfaced as an InputHandle<T>. Declare a scalar input only when a later op or sweepTo actually consumes it — for example a recipient: 'address' you forward to sweepTo (see swapToRecipient.ts). options.name is optional; when omitted, the SDK generates a UUID for the Flow’s id field. Resource semantics (linear consumption, the asResource graduation, port behaviors) are covered in Resource Model.

Chain ops in your flow

The SDK provides a typed method for each op the Composer API supports:
  • builder.lifi.swap(id, { bind, config, guards? })
  • builder.lifi.zap(id, { bind, config, guards? })
  • builder.core.call(id, { bind, config, ... })
  • builder.core.asResource(id, ...), builder.core.balanceOf(id, ...), and the arithmetic methods builder.core.add(id, ...), subtract, multiply, divideDown, divideUp, bpsDown, bpsUp — one method per arithmetic op.
Each returns a typed map of output handles keyed by output-port name. Bind one call’s output handle directly into another call’s bind map:
import { resources } from '@lifi/composer-sdk';

const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
const A_ETH_USDC = '0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5c';

const swap = builder.lifi.swap('swap', {
  bind: { amountIn: builder.inputs.amountIn },
  config: {
    resourceOut: resources.erc20(USDC, 1),
    slippage: 0.03,
  },
});

builder.lifi.zap('zap', {
  bind: { amountIn: swap.amountOut },          // ← thread the swap output straight in
  config: { resourceOut: resources.erc20(A_ETH_USDC, 1) },
});
Each method is typed: passing a 'resource' handle into a non-resource slot is a compile-time error. The dotpath strings the SDK ultimately serialises to (e.g. swap.amountOut) are covered in References.

Runtime context

Runtime values that the compiler fills in at execution time are accessed through builder.context:
  • builder.context.sender is a typed ref to the signer address (address).
  • builder.context.executionAddress is a typed ref to the predicted execution proxy address (address).
These are used most often in bind slots that want the signer as a recipient, or as the sweepTo target.

untypedOp escape hatch

builder.untypedOp is the escape hatch for capabilities the typed SDK surface doesn’t expose yet — a new op the generated methods don’t cover, or an experimental config field. It lets you author the node directly instead of waiting for an SDK release:
builder.untypedOp('custom', 'experimental.op', {
  bind: { x: { $ref: 'input.amountIn' } },
  config: { customField: 42 },
});
untypedOp returns void and accepts raw Ref values in its bind map — no handle conversion, no type checks. To feed an untypedOp node’s output into a typed downstream call, wrap the path with raw.ref<T>(path); you supply the phantom type and the SDK accepts it in any Bindable<T> slot (there is no runtime validation):
import { raw } from '@lifi/composer-sdk';

builder.core.asResource('wrapped', {
  bind: { handle: raw.ref<'uint256'>('custom.result') },
  config: { resource: resources.erc20(USDC, 1) },
});
Prefer typed builder methods whenever they exist; reach for untypedOp only when they don’t.

Wire runtime values

A flow declares what kind of value each input expects. The concrete value is supplied at compile time, in run.inputs, when you call builder.compile(run) or sdk.request(flow, run).

Three input shapes

For every input the flow declares, run.inputs[name] must be one of three shapes:
  • bigint. A literal numeric value. Used for scalar inputs declared as uint256 and for resource inputs whose amount you already know upfront. Serialised on the wire as a 0x-padded 32-byte hex string.
  • string. A pre-encoded value. For scalar inputs, the string must already conform to the declared type (e.g. a 0x… address for an address input, a decimal or hex integer for a uint256).
  • MaterialiserInput. A descriptor { kind: "<materialiser>", …config } telling the service how to compute the value (see below).
const result = await builder.compile({
  signer: '0xYourSignerAddress',
  inputs: {
    minOut: 2_400_000_000n,                                  // literal bigint (uint256 scalar input)
    amountIn: materialisers.directDeposit({                  // materialiser descriptor
      amount: '1000000000000000000',
    }),
  },
  sweepTo: builder.context.sender,
});
Every input declared by the flow must appear in run.inputs. Missing or unknown keys fail with validation_error.

What a materialiser is

A materialiser is a deterministic pure function (flow, context) → binding that resolves a flow input at compile time. It tells the service how to produce the handle the VM consumes at the top of the program: for example, “deposit exactly 1 WETH via transferFrom” (directDeposit) or “read the signer’s on-chain USDC balance at execution time” (balanceOf). Why not always use a literal?
  • The value isn’t known until execution. balanceOf reads the signer’s balance at transaction time, which lets you write flows like “zap whatever USDC I hold right now.”
  • The value needs specific VM instructions. directDeposit doesn’t just push a number; it bakes in the transferFrom semantics that pull the tokens into the proxy and, for exact deposits, an equality check that the expected amount arrived.
Built-in materialisers:
KindAcceptsWhat it does
directDepositresourceDeposit into the VM. Native coin via msg.value, ERC-20 via transferFrom. { amount } for exact; { allowNonExact: true } to deposit whatever the approval allows.
balanceOfresourceRead balanceOf(owner) on the resource token (or native balance for native coin) at execution time and use it as the input amount.
callresourceExecute an arbitrary contract call, then measure the balance delta of the resource for the execution proxy, and use that delta as the input amount.
flashloan (preview)resourceETHGlobal hackathon only — unaudited. Borrow the amount from a flashloan provider (aave-v3, erc3156, balancer-v2, or morpho-blue); the proxy populates it at callback time. Available only on the hackathon deployment — not in the production SDK. See flashloan.
See the materialisers catalog for the live list and per-kind config schemas.

Assumptions for runtime amounts

When you use a runtime materialiser (balanceOf, call, or directDeposit with allowNonExact: true) the compiler doesn’t know the concrete amount at compile time. Most of the time this is fine; the VM reads the amount at execution time. But when the compiler needs the amount to plan routes (for example, to pick an aggregator quote for a lifi.swap) it falls back to run.assumptions[name], a per-input bigint hint.
await builder.compile({
  signer: '0xYourSignerAddress',
  inputs: {
    amountIn: materialisers.balanceOf({ owner: '0xYourSignerAddress' }),
  },
  assumptions: {
    amountIn: 1_000_000_000_000_000_000n,
  },
  sweepTo: builder.context.sender,
});
If a route plan depends on a runtime amount and no assumption is supplied, compilation may fail with preparation_error (HTTP 422). Provide an assumption whenever you expect the runtime amount to differ materially from the default.

Add safety: preconditions and guards

A flow’s safety story has two halves. Preconditions describe state that must be true before the transaction runs, feeding the simulator as overrides. Guards are invariants the VM enforces during the transaction, baked into the calldata.

Preconditions: state before execution

A precondition is a declarative assertion about on-chain state. Three types are registered:
TypeAssertsConfig
Erc20BalanceA wallet holds at least a given ERC-20 balance{ wallet, token, balance }
NativeBalanceA wallet holds at least a given native coin balance{ wallet, balance }
Erc20AllowanceAn owner has granted at least allowance of token to spender{ owner, spender, token, allowance }
The SDK exposes typed factories:
import { preconditions } from '@lifi/composer-sdk';

preconditions.erc20Balance({
  wallet: '0xYourSignerAddress',
  token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
  balance: '1000000',
});
preconditions.nativeBalance({ wallet: '0x…', balance: '1000000000000000000' });
preconditions.erc20Allowance({
  owner: '0x…',
  spender: '0x…',
  token: '0x…',
  allowance: '1000000000000000000',
});
Preconditions reach the simulator via two paths, concatenated at compile time:
  • Explicit. Anything you pass in run.preconditions.
  • Materialiser-produced. For example, directDeposit({ amount }) on an ERC-20 resource automatically emits an Erc20Balance precondition and an Erc20Allowance precondition.
You rarely need to duplicate materialiser-produced preconditions explicitly. Add explicit preconditions when the flow’s expectations aren’t already captured by its inputs, for example when using balanceOf to source an amount that should already be on the proxy. At compile time, preconditions are sent to the simulator as state override requirements. The simulator patches storage slots so the wallet has the specified balance / allowance, then runs the simulation against that synthetic state. Simulation does not fail just because live on-chain state doesn’t match a precondition; the whole point is to let the simulator proceed as if the precondition held. The caller is responsible for waiting until the real state catches up before submitting the transaction.

Guards: invariants during execution

Guards are passed as the guards option on an op call:
import { guards } from '@lifi/composer-sdk';

builder.lifi.zap('zap', {
  bind: { amountIn: builder.inputs.amountIn },
  config: { resourceOut: resources.erc20(A_ETH_USDC, 1) },
  guards: [
    // 100 bps (1%) slippage floor on the zap's amountOut port.
    guards.slippage({ port: 'amountOut', bps: 100 }),
  ],
});
The builder rejects guards that aren’t compatible with a call’s ports at compile time. See Guards for what guards do under the hood; see the guards catalog for every registered guard. You can attach more than one guard to the same op; they run independently during simulation. When two guards produce a minimum for the same port, the larger value wins and is reported on producedResources[*].simulated.amountOutMin. Don’t apply guards that duplicate the internal behaviour of an op. Configure slippage on the op itself, not via a guard, whenever the op accepts a slippage config field. lifi.swap is the canonical example: passing slippage: 0.03 bakes a 3% floor into the aggregator quote, and the amountOut port carries providesMinimum: true so a redundant slippage guard would be refused at compile time.

When to use which

PreconditionsGuards
WhenBefore the transaction runs (state overrides for simulation)During the transaction (on-chain assertions)
Where enforcedCaller’s responsibility (wait for real state to match)Compiled into the transaction itself
ShapePlain data: { type, …config }Op-attached invariant with observed ports
ProductionExplicit or materialiser-producedAttached via CallArgs.guards
FailureCaller-visible pre-flight mismatchguard_error at simulation; revert at execution
Use preconditions to describe what must be true before the transaction runs. Use guards to enforce what must be true during the transaction.

Submit the flow

const result = await builder.compile({
  signer,
  inputs: {
    amountIn: materialisers.directDeposit({ amount: '1000000000000000000' }),
  },
  sweepTo: builder.context.sender,
});
builder.compile(run) is the canonical one-shot path: it calls builder.build(), forms a ComposeCompileRequest via sdk.request, and POSTs to /compose. For lower-level patterns (queueing, server-side proxy, signed-request inspection), call builder.build() and sdk.request(flow, run) directly. See the SDK API reference. sdk.compile() does not exist at the SDK level. Compile lives on the builder.

The run input

Every submission passes a ComposeRunInput: per-input values, the signer address, optional preconditions, a sweepTo target for terminal resources, and policy fields (simulationPolicy, checkOnChainAllowances, maxPriceImpactBps). See the SDK API reference for the full type signature. sweepTo is a catch-all that moves every residual proxy balance to the target — but some terminal resources can’t be moved (e.g. aTokens that back an open debt). See Terminal Resources for when to emit explicit transfers instead.

Approvals

ComposeCompileResult.approvals lists any ERC-20 approvals the signer must grant before the compose transaction can execute. The signer must approve the execution proxy for each input token. Submit those approvals first, then the compose transaction. Pass checkOnChainAllowances: true in run to have the server check current on-chain allowances and omit approvals that are already sufficient.

Errors you might see

Compile-time errors:
KindCause
Missing input "<name>" (validation_error)A declared flow input has no entry in run.inputs.
Unknown input "<name>" (validation_error)run.inputs has a key the flow did not declare. Check for typos.
Unknown materialiser "<kind>"The descriptor’s kind is not registered in the manifest.
Materialiser "<kind>" accepts resource inputs onlyThe materialiser is typed for the other kind of input.
linearity_errorTwo ops both bind the same resource handle as a consuming input.
Guard targets port "<p>" which provides its own minimumAttached a minimum-out guard to a port whose op already declares providesMinimum.
Guard targets port "<p>" which does not existPort name typo or wrong op.
Simulation-time and runtime errors:
KindCause
preparation_error (HTTP 422)A runtime amount was needed and no assumptions value was supplied.
no_route_error (HTTP 404)The routing layer could not find a path for a lifi.zap edge.
guard_error (HTTP 422)A guard assertion would fail at execution time. Loosen tolerance or investigate the quote.
simulation_revert (HTTP 422)The simulated transaction reverts on-chain. Inspect the revert details.
Under simulationPolicy: "allow-revert" a simulation_revert returns the compiled calldata and the revert diagnostics under HTTP 206 instead of failing outright. Use this when you want to surface the revert reason to the user. For the full error catalog see Errors.

See also

  • Quickstart — five-minute first-flow walkthrough.
  • Recipes — three canonical flows you can copy.
  • SDK API reference — exhaustive API surface.
  • Concepts — Flow shape, refs, resources, execution model, guards.
  • Errors — full ComposeError catalog.