UNPKG

@lifi/composer-sdk

Version:

Public Composer SDK for building and submitting flows

338 lines (269 loc) 16.3 kB
# @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`. - `sdk.client.simulate(request)` / `sdk.simulate(request)` — simulates a raw, pre-encoded transaction and returns a `SimulateResult` (discriminated union: `status: 'ok' | 'revert' | 'error'`). See [Simulating a raw transaction](#simulating-a-raw-transaction). **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 - `buildSimulateRequest({ result, chainId, signer, trackedBalances, requirements?, block?, value? })` — assembles a `SimulateRequest` from 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'`: ```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, }); ``` ## 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. ```ts 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: ```ts 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`](../../../docs/references/simulate-endpoint.md) for the full contract. Two runnable examples cover both paths: [`src/examples/simulateRawTransaction.ts`](src/examples/simulateRawTransaction.ts) builds a raw `SimulateRequest` directly, and [`src/examples/simulateCompiledSwap.ts`](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: ```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