@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
ComposeSdkOptions:
| Option | Type | Notes |
|---|---|---|
baseUrl | string | Base URL of the Compose API. SDK default is https://composer.li.quest. |
apiKey | string | Required during the technical preview. Sent as the x-lifi-api-key header on every request. Create one at portal.li.fi. |
fetch | typeof 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.
- viem
- ethers v6
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)
AbortSignal wiring.
Declare inputs
Inputs are a named record. Each entry is either a resource declaration (token) or a scalar type name.builder.inputs.<name>:
builder.inputs.amountInis aResourceInputHandlecarrying the WETH resource.
'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 methodsbuilder.core.add(id, ...),subtract,multiply,divideDown,divideUp,bpsDown,bpsUp— one method per arithmetic op.
bind map:
'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 throughbuilder.context:
builder.context.senderis a typed ref to the signer address (address).builder.context.executionAddressis a typed ref to the predicted execution proxy address (address).
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:
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):
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, inrun.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 asuint256and for resource inputs whose amount you already know upfront. Serialised on the wire as a0x-padded 32-byte hex string.string. A pre-encoded value. For scalar inputs, the string must already conform to the declared type (e.g. a0x…address for anaddressinput, a decimal or hex integer for auint256).MaterialiserInput. A descriptor{ kind: "<materialiser>", …config }telling the service how to compute the value (see below).
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.
balanceOfreads 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.
directDepositdoesn’t just push a number; it bakes in thetransferFromsemantics that pull the tokens into the proxy and, for exact deposits, an equality check that the expected amount arrived.
| Kind | Accepts | What it does |
|---|---|---|
directDeposit | resource | Deposit into the VM. Native coin via msg.value, ERC-20 via transferFrom. { amount } for exact; { allowNonExact: true } to deposit whatever the approval allows. |
balanceOf | resource | Read balanceOf(owner) on the resource token (or native balance for native coin) at execution time and use it as the input amount. |
call | resource | Execute 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) | resource | ETHGlobal 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. |
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.
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:| Type | Asserts | Config |
|---|---|---|
Erc20Balance | A wallet holds at least a given ERC-20 balance | { wallet, token, balance } |
NativeBalance | A wallet holds at least a given native coin balance | { wallet, balance } |
Erc20Allowance | An owner has granted at least allowance of token to spender | { owner, spender, token, allowance } |
- Explicit. Anything you pass in
run.preconditions. - Materialiser-produced. For example,
directDeposit({ amount })on an ERC-20 resource automatically emits anErc20Balanceprecondition and anErc20Allowanceprecondition.
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 theguards option on an op call:
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
| Preconditions | Guards | |
|---|---|---|
| When | Before the transaction runs (state overrides for simulation) | During the transaction (on-chain assertions) |
| Where enforced | Caller’s responsibility (wait for real state to match) | Compiled into the transaction itself |
| Shape | Plain data: { type, …config } | Op-attached invariant with observed ports |
| Production | Explicit or materialiser-produced | Attached via CallArgs.guards |
| Failure | Caller-visible pre-flight mismatch | guard_error at simulation; revert at execution |
Submit the flow
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 aComposeRunInput: 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:| Kind | Cause |
|---|---|
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 only | The materialiser is typed for the other kind of input. |
linearity_error | Two ops both bind the same resource handle as a consuming input. |
Guard targets port "<p>" which provides its own minimum | Attached a minimum-out guard to a port whose op already declares providesMinimum. |
Guard targets port "<p>" which does not exist | Port name typo or wrong op. |
| Kind | Cause |
|---|---|
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. |
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
ComposeErrorcatalog.

