@lifi/composer-sdk
Version:
Public Composer SDK for building and submitting flows
257 lines (201 loc) • 12 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
```
## 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, // 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 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 (reflects the backend's current routing snapshot) and is not cached by the SDK. Filter to specific protocols via `GetZapPacksOptions`; each entry's edges are typed as `ZapPackEdge`.
**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
## 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,
});
```
## 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, ...
}
}
```
## 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 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>()`
## 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
});
```
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