@lifi/composer-sdk
v0.2.0
Published
Public Composer SDK for building and submitting flows
Readme
@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).
npm install @lifi/composer-sdk @lifi/compose-specQuick start
Swap WETH to USDC, then zap the USDC into an Aave lending position — all in a single transaction.
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, // optional
});
// 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, fetch?, apiKey? })— creates the SDK instance
Flow building
sdk.flow(chainId, options)— creates aFlowBuilderbuilder.<namespace>.<operation>(id, { bind, config })— adds an operation, returns typedOutputHandle<T>per portbuilder.untypedOp(id, op, args)— escape hatch for operations not in the manifest (returnsvoid; useraw.ref<T>()to reference its outputs)builder.build()— produces aFlowdocumentsdk.request(flow, { signer, inputs, preconditions, sweepTo, ... })— builds a compile request
HTTP client
sdk.client.compile(request)— sends the flow to the backend and returns aComposeCompileResult(discriminated union:status: 'success'orstatus: 'partial')sdk.client.getManifest()— fetches the operation manifestsdk.client.getZapPacks(options?)— fetches the available routing edges grouped by protocol, returningZapPackOverview[]. The catalog is dynamic (reflects the backend's current routing snapshot) and is not cached by the SDK. Filter to specific protocols viaGetZapPacksOptions; each entry's edges are typed asZapPackEdge.sdk.client.simulate(request)/sdk.simulate(request)— simulates a raw, pre-encoded transaction and returns aSimulateResult(discriminated union:status: 'ok' | 'revert' | 'error'). See Simulating a raw transaction.
Helpers
resources.erc20(address, chainId)/resources.native(chainId)— resource constructorsguards.*— guard factories (e.g., slippage)materialisers.*— materialiser factories (directDeposit, balanceOf, call)preconditions.*— precondition factories (erc20Balance, nativeBalance, erc20Allowance)raw.ref<T>(path)— create a typed$refpointer for use in bind slots (escape hatch foruntypedOpoutputs)raw.guard(kind, config?)/raw.materialiser(kind, config?)— low-level factories for guards and materialisersbuildSimulateRequest({ result, chainId, signer, trackedBalances, requirements?, block?, value? })— assembles aSimulateRequestfrom a compile result (pure, no I/O)
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':
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 thrownComposeErrorwith codeVALIDATION_ERROR'allow-revert'— revert returns a partial result withstatus: '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:
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.
import { createComposeSdk } from '@lifi/composer-sdk';
const sdk = createComposeSdk({ baseUrl: 'https://li.quest' });
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:
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 for the full contract.
Two runnable examples cover both paths: src/examples/simulateRawTransaction.ts builds a raw SimulateRequest directly, and 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:
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, ...
}
}Examples
The src/examples/ directory contains complete working examples:
- lifiSwap — Single token swap (WETH to USDC)
- lifiZap — Swap into a DeFi position
- 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 theComposeCompileResultdiscriminated union - swapWithFee — Swap while collecting an integrator fee via
integratorFeeBps(requires an integration-scopedapiKey) - 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 usingraw.ref<T>()
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:
npm install @lifi/composer-sdk@staging @lifi/compose-spec@stagingThe 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:
const sdk = createComposeSdk({
baseUrl: '<staging-backend-url>', // a backend that has the staged ops enabled
});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
stagingchannel is durable — it is a permanent property ofmain, and which operations it carries rotates over time as ops graduate tolatest. - The
baseUrlis 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
