# @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
