Skip to main content
This quickstart builds a minimal Flow that swaps WETH into USDC and zaps the USDC into an Aave lending position — the same shape as the swap-and-zap recipe. By the end you will have a ComposeCompileResult containing transactionRequest.data (calldata) ready to sign. For a full walkthrough of inputs, handles, materialisers, guards, preconditions, and submission, see Build a Flow after this page.
You don’t need to learn every concept first. Composer has its own vocabulary — Flow, operation, resource, input, materialiser, guard — but this page explains each term in one line, right where it first appears in the code. Skim it, run the example, and follow the linked concept pages only when you want to go deeper. This single example is enough to start building and experimenting with Composer.

Prerequisites

  • Node.js ≥ 20 and a TypeScript project (tsc, tsx, or ts-node all work).
  • An EVM account with a small amount of WETH on Ethereum mainnet (this example uses 1 WETH for illustration).
  • The signer’s 0x-prefixed address. The quickstart does not send the transaction; it stops at the compiled calldata. Signer wiring (viem, ethers, etc.) is covered in Build a Flow.
  • A LI.FI API key. Composer is in technical preview and every request is authenticated. Register at portal.li.fi and create a key from your dashboard.
Base URL. The public compose endpoint is served by a dedicated host; the SDK default is https://composer.li.quest. If your integration uses a staging or internal environment, ask your LI.FI contact for the right baseUrl.
ETHGlobal hackathon — unaudited preview. The hackathon runs against a separate, unaudited deployment. Install the staging build — yarn add @lifi/composer-sdk@staging @lifi/compose-spec@staging — instead of the @alpha install shown below: the hackathon ops (flashloans, lifi.flashloanRepay) ship only in @staging, not in @alpha/@latest. Point the SDK at https://ethglobal-composer.li.quest instead of the default, and use the hackathon contract addresses from the Addresses page. This deployment — and any surface available only there, such as flashloans — is for the hackathon and experimentation only. Don’t use it in production or with significant funds. For the full hackathon setup, see the ETHGlobal launchpad.

Steps

1

Install the SDK

The @lifi/composer-sdk package is published on npm. Until a stable release ships, pin the alpha range.
npm install @lifi/composer-sdk@alpha
# or: yarn add @lifi/composer-sdk@alpha
# or: pnpm add @lifi/composer-sdk@alpha
2

Create the SDK

createComposeSdk returns a handle with a flow factory, a low-level client, and a request helper for custom transports. During the technical preview an apiKey is required.
import { createComposeSdk } from '@lifi/composer-sdk';

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

Build the flow

A Flow is the document you’re building here: an ordered list of steps that compiles into one transaction. You declare its inputs, then chain operations on the builder.
  • An input is a value the flow consumes. amountIn here is a resource — a token balance (1 WETH) that Composer tracks as it moves between steps. (Inputs can also be plain scalars like a uint256 or an address.)
  • An operation (op) is one named step in the flow, like a swap or a deposit. builder.lifi.swap and builder.lifi.zap are ops; the zap binds the swap’s output as its own input, threading the two together. These two are a small slice of the full op catalog.
Input names and node ids (the first argument to each op) are yours to choose — they become the keys you bind runtime values to and the handles downstream ops reference.
const builder = sdk.flow(1, {
  name: 'swap-and-zap-quickstart',
  inputs: {
    amountIn: resources.erc20(WETH, 1),
  },
});

// Swap WETH → USDC, then zap into Aave.
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 },
  config: { resourceOut: resources.erc20(A_ETH_USDC, 1) },
});
See the full example below for the imports and address constants.
4

Compile the flow

builder.compile(run) ships the flow to POST /compose, simulates it, and returns a ComposeCompileResult containing the calldata your user signs.The inputs map here supplies the concrete runtime value for each input the flow declared. directDeposit is a materialiser — a small descriptor that tells Composer how to obtain the tokens at execution time (here, pull exactly 1 WETH into the proxy via transferFrom). sweepTo says where leftover balances go when the flow finishes.
const result = await builder.compile({
  signer: '0xYourSignerAddress',
  inputs: {
    amountIn: materialisers.directDeposit({
      amount: '1000000000000000000', // 1 WETH (18 decimals)
    }),
  },
  sweepTo: builder.context.sender,
});
result.transactionRequest is what you hand to your wallet:
{
  "to":    "0x6b3e…1f04",  // your per-user execution proxy (== userProxy)
  "data":  "0x…",          // calldata the proxy delegatecalls into the VM
  "value": "0"
}
The to address is your per-user execution proxy — the same value as userProxy. On the proxy’s first use it’s instead the proxy factory, which deploys your proxy and runs the flow in one transaction. Either way the proxy delegatecalls the shared Composer VM, so the flow executes in your proxy’s own balance and storage context. The VM and proxy-factory addresses per chain are on the Addresses page.Alongside transactionRequest, the result carries userProxy (the execution address), producedResources (simulated amounts per terminal resource), and any approvals the signer must grant first.

Full example

Adapted from swapAndZap.ts in the composer-sdk-examples repo:
import {
  createComposeSdk,
  guards,
  materialisers,
  resources,
} from '@lifi/composer-sdk';

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

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

  const builder = sdk.flow(1, {
    name: 'swap-and-zap-quickstart',
    inputs: {
      amountIn: resources.erc20(WETH, 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,
    },
  });

  // Zap the swapped USDC into Aave's aEthUSDC position.
  builder.lifi.zap('zap', {
    bind: { amountIn: swapOutputs.amountOut },
    config: {
      resourceOut: resources.erc20(A_ETH_USDC, 1),
    },
    guards: [guards.slippage({ port: 'amountOut', bps: 100 })],
  });

  const result = await builder.compile({
    signer: '0xYourSignerAddress',
    inputs: {
      amountIn: materialisers.directDeposit({
        amount: '1000000000000000000', // 1 WETH (18 decimals)
      }),
    },
    sweepTo: builder.context.sender,
  });

  console.log(JSON.stringify(result.transactionRequest, null, 2));
};

run().catch((err) => {
  console.error(err);
  process.exit(1);
});
The full example adds one term the steps didn’t: a guard. guards.slippage({ port: 'amountOut', bps: 100 }) on the zap is an invariant Composer enforces during execution — here, a 1% slippage floor on the zap’s output — so the flow fails safely instead of executing on bad terms. The swap doesn’t need one: its slippage: 0.03 config already bakes a floor into the quote.

What the SDK builds

builder.compile(run) doesn’t just call the endpoint — it serialises the typed builder state to a wire-format Flow document, ships it to POST /compose, and parses the response. If you want to see what the wire actually looks like, swap builder.compile(run) for builder.build():
const flow = builder.build();
console.log(JSON.stringify(flow, null, 2));
{
  "version": 1,
  "id": "swap-and-zap-quickstart",
  "chainId": 1,
  "inputs": [
    {
      "name": "amountIn",
      "resource": { "kind": "erc20", "token": "0xC02a…Cc2", "chainId": 1 }
    }
  ],
  "nodes": [
    {
      "id": "swap",
      "op": "lifi.swap",
      "bind":   { "amountIn": { "$ref": "input.amountIn" } },
      "config": { "resourceOut": { "kind": "erc20", "token": "0xA0b8…eB48", "chainId": 1 }, "slippage": 0.03 }
    },
    {
      "id": "zap",
      "op": "lifi.zap",
      "bind":   { "amountIn": { "$ref": "swap.amountOut" } },
      "config": { "resourceOut": { "kind": "erc20", "token": "0x98C2…6F5c", "chainId": 1 } },
      "guards": [ { "kind": "slippage", "port": "amountOut", "bps": 100 } ]
    }
  ]
}
This is the Flow document the compose endpoint receives. Every input name (amountIn), node id (swap, zap), and $ref you see here is exactly what you wrote in the builder above — the SDK does not rename or rewrite. See Flow Structure for the full wire-format reference.

What you got back

ComposeCompileResult is a discriminated union on status. Branch on it before reading shape-specific fields:
if (result.status === 'success') {
  // transactionRequest is ready to sign
  await wallet.sendTransaction(result.transactionRequest);
} else {
  // status === 'partial' — only reachable with simulationPolicy: 'allow-revert'.
  // transactionRequest is still present, but simulation reverted.
  console.error(result.error.kind, result.error.message);
  console.error(result.simulationRevert); // structured revert diagnostics
}
On status: 'success', the payload contains:
  • transactionRequest{ to, data, value, gasLimit? }. to is your execution proxy (the proxy factory on the proxy’s first use); data is the calldata the proxy delegatecalls into the VM.
  • userProxy — the predicted execution proxy address (a deterministic per-signer contract derived by the proxy factory; see Account model).
  • producedResources — a map of terminal resource names to per-resource records. Simulated amounts live at <resource>.simulated?.amountOut and <resource>.simulated?.amountOutMin.
  • approvals — ERC-20 approvals the signer must grant before executing the transaction (omitted when none are needed).
  • priceImpact — optional USD price-impact breakdown when maxPriceImpactBps is set on the run.
On status: 'partial', the payload additionally carries error ({ kind, message }) and simulationRevert. This shape is only returned when you pass simulationPolicy: 'allow-revert' on the run; under the default 'strict' policy a revert raises a ComposeError instead.

Next steps

You’ve now seen every core concept — Flow, operation, resource, input, materialiser, guard — in action. The example above is enough to start experimenting. When you want the full picture of any of these, these pages go deeper:
  • Build a Flow — full walkthrough of inputs, handles, materialisers, guards, preconditions, and submission.
  • Flow Structure — the JSON document shape your builder produces.
  • References — how handles become $ref strings on the wire.
  • Recipes — three canonical flow shapes (Dust Sweep, Swap and Deposit, Split Deposits).