@lifi/composer-sdk 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,34 +1,22 @@
1
1
  # @lifi/composer-sdk
2
2
 
3
- TypeScript SDK for building and submitting LI.FI Compose flows.
3
+ TypeScript SDK for building LI.FI Compose flows and compiling them into submit-ready transactions.
4
+
5
+ Full documentation: [docs.li.fi/composer](https://docs.li.fi/composer/overview). SDK API reference: [SDK API](https://docs.li.fi/composer/composer-api/reference/sdk-api.md).
4
6
 
5
7
  ## Install
6
8
 
7
- `@lifi/compose-spec` is a peer dependency and must be installed alongside the SDK at the same version (they are versioned in lockstep).
9
+ `@lifi/compose-spec` is a peer dependency. Install it at exactly the same version as the SDK; the two are versioned in lockstep. Node.js 24 or later is required.
8
10
 
9
11
  ```bash
10
12
  npm install @lifi/composer-sdk @lifi/compose-spec
11
13
  ```
12
14
 
13
- ## Upgrading to 0.3.0
14
-
15
- **Breaking:** `apiKey` is now required on `createComposeSdk()`.
16
-
17
- The Compose API has always rejected unauthenticated requests, but `apiKey` was typed as optional, so omitting it compiled and only failed on the first request. It is now a required `string`, and a missing or blank value throws a `ComposeError` (`VALIDATION_ERROR`) at construction time — including for plain-JavaScript callers that bypass the type check.
18
-
19
- ```diff
20
- -const sdk = createComposeSdk({ baseUrl: 'https://composer.li.quest' });
21
- +const sdk = createComposeSdk({
22
- + baseUrl: 'https://composer.li.quest',
23
- + apiKey: process.env.LIFI_API_KEY!,
24
- +});
25
- ```
26
-
27
- Create a key at [portal.li.fi](https://portal.li.fi).
15
+ `apiKey` is required (create one at [portal.li.fi](https://portal.li.fi)). A missing or blank key throws a `ComposeError` with code `VALIDATION_ERROR` when you call `createComposeSdk()`, including from plain JavaScript that skips the type check. It has been required since 0.3.0.
28
16
 
29
17
  ## Quick start
30
18
 
31
- Swap WETH to USDC, then zap the USDC into an Aave lending position — all in a single transaction.
19
+ Swap WETH to USDC, then zap the USDC into an Aave lending position, in one transaction.
32
20
 
33
21
  ```ts
34
22
  import {
@@ -43,13 +31,12 @@ const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
43
31
  const A_ETH_USDC = '0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5c'; // Aave aEthUSDC
44
32
  const OWNER = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045';
45
33
 
46
- // Create the SDK pointed at the Compose API.
47
34
  const sdk = createComposeSdk({
48
35
  baseUrl: 'https://composer.li.quest',
49
- apiKey: process.env.LIFI_API_KEY!, // required
36
+ apiKey: process.env.LIFI_API_KEY!,
50
37
  });
51
38
 
52
- // Build a two-step flow on Ethereum mainnet.
39
+ // A flow on Ethereum mainnet with one token input.
53
40
  const builder = sdk.flow(1, {
54
41
  name: 'swap-and-zap-weth-to-aave',
55
42
  inputs: {
@@ -57,7 +44,7 @@ const builder = sdk.flow(1, {
57
44
  },
58
45
  });
59
46
 
60
- // Step 1: Swap WETH → USDC via LI.FI.
47
+ // Step 1: swap WETH to USDC via LI.FI.
61
48
  const swapOutputs = builder.lifi.swap('swap', {
62
49
  bind: { amountIn: builder.inputs.amountIn },
63
50
  config: {
@@ -66,8 +53,8 @@ const swapOutputs = builder.lifi.swap('swap', {
66
53
  },
67
54
  });
68
55
 
69
- // Step 2: Zap the swapped USDC into Aave.
70
- // The swap's amountOut handle threads directly into the zap's amountIn.
56
+ // Step 2: zap the swapped USDC into Aave. The swap's amountOut handle
57
+ // feeds the zap's amountIn directly.
71
58
  builder.lifi.zap('zap', {
72
59
  bind: { amountIn: swapOutputs.amountOut },
73
60
  config: {
@@ -78,7 +65,6 @@ builder.lifi.zap('zap', {
78
65
 
79
66
  const flow = builder.build();
80
67
 
81
- // Compile the flow into transaction calldata.
82
68
  const request = sdk.request(flow, {
83
69
  signer: OWNER,
84
70
  inputs: {
@@ -87,87 +73,108 @@ const request = sdk.request(flow, {
87
73
  }),
88
74
  },
89
75
  sweepTo: builder.context.sender,
90
- // Opt into partial results. Without this, the default 'strict' policy
91
- // throws a ComposeError (HTTP 422) when simulation detects a revert,
92
- // and the `partial` branch below is never reached.
76
+ // Opt into partial results. Under the default 'strict' policy a simulated
77
+ // revert throws a ComposeError instead, and the `partial` branch is unreachable.
93
78
  simulationPolicy: 'allow-revert',
94
79
  });
95
80
 
96
81
  const result = await sdk.client.compile(request);
97
82
 
98
83
  if (result.status === 'success') {
99
- // Full success — transactionRequest includes gasLimit.
84
+ // transactionRequest includes gasLimit.
100
85
  console.log(result.transactionRequest);
101
86
 
102
- // What the flow produced, one entry per output port. The zap's output is
103
- // terminal (nothing downstream spends it), so it carries the owner it is
104
- // delivered to; the swap's output is `role: 'consumed'` by the zap.
87
+ // One entry per output port. Nothing downstream spends the zap's output, so
88
+ // it is terminal and carries its owner. The swap's output is `consumed` by the zap.
105
89
  const position = result.outputs['zap.amountOut'];
106
90
  if (position?.role === 'terminal') {
107
91
  console.log(position.owner, position.amount.estimate?.value);
108
92
  }
109
93
  } else {
110
- // result.status === 'partial' — simulation reverted.
111
- // Transaction is still available but without gasLimit.
94
+ // status === 'partial': simulation reverted. The transaction is still
95
+ // returned, without gasLimit.
112
96
  console.log(result.simulationRevert);
113
97
  }
114
98
  ```
115
99
 
100
+ `builder.compile(run)` is shorthand for `sdk.client.compile(sdk.request(builder.build(), run))`.
101
+
116
102
  ## Core concepts
117
103
 
118
- **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`).
104
+ **Flows and operations**: a flow is an ordered list of on-chain operations. You declare inputs, chain operations, and the backend compiles everything into one transaction. Operations are namespaced, e.g. `builder.lifi.swap` and `builder.core.split`. Each call takes a node id that must be unique in the flow.
119
105
 
120
- **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.
106
+ **Resources**: `resources.erc20(address, chainId)` and `resources.native(chainId)` describe the tokens flowing through the flow. A resource input must be on the flow's chain.
121
107
 
122
- **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.
108
+ **Handles**: operations return typed output handles, e.g. `OutputHandle<'resource'>` or `OutputHandle<'uint256'>`, which you bind to downstream inputs. TypeScript enforces the port types. A resource handle fits a `uint256` slot (resources are amounts), but an `address` handle does not.
123
109
 
124
- **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.
110
+ **Runtime inputs (materialisers)**: a materialiser tells the backend how to fund a flow input when the transaction executes.
125
111
 
126
- **Preconditions** — Expected on-chain state at execution time: `erc20Balance` and `nativeBalance` assert wallet holdings, `erc20Allowance` asserts token approvals.
112
+ - `directDeposit({ amount })` pulls exactly `amount`.
113
+ - `directDeposit({ amount, allowNonExact: true })` permits a capped ERC-20 deposit of up to `amount`; `directDeposit({ allowNonExact: true })` deposits with no on-chain cap (ERC-20 only).
114
+ - `balanceOf({ owner })` reads a current balance.
115
+ - `call({ target, calldata })` measures the balance delta after an arbitrary contract call.
127
116
 
128
- **Guards** — Protect against slippage and other runtime conditions. Applied per-operation via the `guards` field.
117
+ **Preconditions**: on-chain state the backend assumes when it simulates, e.g. a balance the signer will hold at execution time. The factories are `erc20Balance`, `nativeBalance`, and `erc20Allowance`.
129
118
 
130
- **Outputs** — A compile result carries `outputs`, a record keyed `'<nodeId>.<portName>'` holding every resource output the flow produces. Each entry is `role: 'terminal'` (delivered to `owner`, with `delivery.when` telling you whether it lands with this transaction) or `role: 'consumed'` (spent by the node named in `consumedBy`), so narrow on `role` before reading either field. `amount.estimate` and `amount.minimum` are both optional: an estimate is absent when nothing measured or quoted the port, and a minimum is absent when no floor is enforced. `terminalOutputs(result.outputs)` narrows the record to the terminal entries. Full field reference: [docs/sdk-interface.md](./docs/sdk-interface.md#outputs).
119
+ **Guards**: runtime checks on an operation's output, attached through its `guards` field. `guards.slippage({ port, bps })` floors an output at `bps` below its simulated amount. The `port` argument is typed against the ports a guard can protect. For example, `lifi.swap`'s `amountOut` already carries the aggregator's own floor, so it rejects a slippage guard at compile time.
131
120
 
132
- ## API surface
121
+ **Outputs**: a compile result carries `outputs`, keyed `'<nodeId>.<portName>'`, with one entry per resource output. An entry is either:
122
+
123
+ - `role: 'terminal'`: delivered to `owner`. `delivery.when` says whether it lands with this transaction (`'now'`) or later (`'future'`).
124
+ - `role: 'consumed'`: spent by the node named in `consumedBy`.
125
+
126
+ Narrow on `role` before reading those fields. `amount.estimate` and `amount.minimum` are both optional. An absent minimum means no floor is enforced; there is no `0` default. `terminalOutputs(result.outputs)` keeps only the terminal entries.
133
127
 
134
- **SDK factory**
128
+ ## API surface
135
129
 
136
- - `createComposeSdk({ baseUrl, apiKey, fetch? })` — creates the SDK instance
130
+ **SDK**
137
131
 
138
- **Flow building**
132
+ - `createComposeSdk({ baseUrl, apiKey, fetch? })`: `baseUrl` must be an HTTP(S) URL. A trailing slash is stripped.
133
+ - `sdk.flow(chainId, { inputs, name? })`: returns a `FlowBuilder`. `name` defaults to a random UUID.
134
+ - `builder.<namespace>.<operation>(nodeId, { bind, config, guards? })`: adds a node and returns one typed handle per output port.
135
+ - `builder.untypedOp(nodeId, op, { bind, config, guards? })`: escape hatch for ops without a typed method. Returns `void`; reference its outputs with `raw.ref<T>(path)`.
136
+ - `builder.build()`: returns the `Flow` document.
137
+ - `builder.compile(run)`: builds, then compiles.
138
+ - `sdk.request(flow, run)`: builds a `ComposeCompileRequest` from a flow and a `ComposeRunInput`.
139
+ - `sdk.route(request)`: compiles a token pair without authoring a flow. See [Routing a token pair](#routing-a-token-pair).
140
+ - `sdk.simulate(request)`: simulates a raw transaction. See [Simulating a raw transaction](#simulating-a-raw-transaction).
141
+ - `sdk.getSupportedChains()`: same as `sdk.client.getChains()`.
139
142
 
140
- - `sdk.flow(chainId, options)` — creates a `FlowBuilder`
141
- - `builder.<namespace>.<operation>(id, { bind, config })` — adds an operation, returns typed `OutputHandle<T>` per port
142
- - `builder.untypedOp(id, op, args)` — escape hatch for operations not in the manifest (returns `void`; use `raw.ref<T>()` to reference its outputs)
143
- - `builder.build()` — produces a `Flow` document
144
- - `sdk.request(flow, { signer, inputs, preconditions, sweepTo, ... })` — builds a compile request
143
+ **`sdk.client` (HTTP)**
145
144
 
146
- **HTTP client**
145
+ - `compile(request)`: `POST /compose`. Returns a `ComposeCompileResult` with `status: 'success'` or `status: 'partial'`.
146
+ - `route(request)`: `POST /compose/route`. Returns the same union.
147
+ - `getManifest()`: `GET /compose/manifest`, the live operation registry.
148
+ - `getChains()`: `GET /chains`. Returns `readonly ChainSummary[]` (`{ chainId, name }`), ascending by `chainId`.
149
+ - A chain is listed once the VM contract is confirmed deployed on it. `compile`, `route`, and `simulate` validate against the same condition, so they reject an unlisted chain. A listed chain can still have no routing edge for a given pair.
150
+ - `name` is a display label that can change, so key on `chainId`. A chain with no configured label reports `Chain <id>`.
151
+ - `getZapPacks(options?)`: `GET /compose/zap-packs`. Returns the routing edges grouped by protocol as `ZapPackOverview[]`. Filter with `{ protocols: 'aave' }` or an array. See [Discovering routable pairs](#discovering-routable-pairs).
152
+ - `simulate(request)`: `POST /simulate`.
147
153
 
148
- - `sdk.client.compile(request)` — sends the flow to the backend and returns a `ComposeCompileResult` (discriminated union: `status: 'success'` or `status: 'partial'`)
149
- - `sdk.client.getManifest()` — fetches the operation manifest
150
- - `sdk.client.getChains()` / `sdk.getSupportedChains()` — fetches the chains this deployment supports as `readonly ChainSummary[]` (`{ chainId, name }`), ascending by `chainId`. A chain is listed once the VM contract is confirmed deployed on it, which is the same condition `compile`, `route` and `simulate` validate against — so a chain missing from this list is rejected by those methods. The converse does not hold: a listed chain can still have no routing edge for a given pair, reported by `route` as `no_route_error`. `name` is a display label maintained by the deployment, so render it but key on `chainId`; a chain with no configured label reports `Chain <id>`. The list tracks the backend's chain registry, which refreshes periodically, and is not cached by the SDK.
151
- - `sdk.client.getZapPacks(options?)` — fetches the available routing edges grouped by protocol, returning `ZapPackOverview[]`. The catalog is dynamic and is not cached by the SDK. Filter through `GetZapPacksOptions`. An edge with `recipient: 'required'` requires the zap's `recipient` binding, delivers the output externally, and produces an output that cannot be chained or swept. An edge with `availability: 'future'` produces an output that settles only after the initiating transaction; `route` compiles it for you, and on the authoring path it needs `lifi.zapAsync` with a bound `recipient`. Delivery timing governs custody only — a future output still reports an `amount.estimate` (`basis: 'simulated'`, an in-transaction estimate; the delivered amount arrives after settlement), with an `amount.minimum` present only when a minimum source applied. The catalog publishes no settlement duration: a deferred output's ETA rides on the compile result as each future terminal output's `delivery.etaSeconds` in `outputs` (expected seconds until the proceeds land, present only on a `delivery.when: 'future'` output and only when the protocol publishes an estimate; absence means unknown, not zero).
152
- - `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).
153
- - `sdk.client.route(request)` / `sdk.route(request)` — compiles a single from/to token pair on one chain via `POST /compose/route`, returning the same `ComposeCompileResult` as `compile()`. See [Routing a token pair](#routing-a-token-pair).
154
+ The SDK caches none of these responses.
154
155
 
155
156
  **Helpers**
156
157
 
157
- - `resources.erc20(address, chainId)` / `resources.native(chainId)` — resource constructors
158
- - `guards.*` — guard factories (e.g., slippage)
159
- - `materialisers.*` — materialiser factories (directDeposit, balanceOf, call)
160
- - `preconditions.*` — precondition factories (erc20Balance, nativeBalance, erc20Allowance)
161
- - `raw.ref<T>(path)` — create a typed `$ref` pointer for use in bind slots (escape hatch for `untypedOp` outputs)
162
- - `raw.guard(kind, config?)` / `raw.materialiser(kind, config?)` — low-level factories for guards and materialisers
163
- - `buildSimulateRequest({ result, chainId, signer, trackedBalances, requirements?, block?, value? })` — assembles a `SimulateRequest` from a compile result (pure, no I/O)
164
- - `routeAmount.exact(amount)` / `routeAmount.all(simAmount)` — amount constructors for `sdk.route(...)`
158
+ - `resources.erc20(address, chainId)` / `resources.native(chainId)`.
159
+ - `guards.slippage`, `guards.coreNumericInvariant`.
160
+ - `materialisers.directDeposit`, `materialisers.balanceOf`, `materialisers.call`.
161
+ - `preconditions.erc20Balance`, `preconditions.nativeBalance`, `preconditions.erc20Allowance`.
162
+ - `raw.ref<T>(path)`, `raw.literal(kind, value)`, `raw.guard(kind, config?)`, `raw.materialiser(kind, config?)`: low-level constructors for cases the typed helpers do not cover.
163
+ - `routeAmount.exact(amount)` / `routeAmount.all(simAmount)`: amount constructors for `route`.
164
+ - `buildReachability(packs, { maxHops?, allowAsyncRoutes? })`: the pairs `route` can connect (pure, no I/O).
165
+ - `buildSimulateRequest(...)`: turns a compile result into a `SimulateRequest` (pure, no I/O).
166
+ - `terminalOutputs(outputs)`.
167
+ - `isComposeError`, `isComposePreparationError`, `isComposeSdkOutdatedError`, `isComposeServerOutdatedError`: type guards. `ComposeError` is exported as a type only, so narrow with these rather than `instanceof`.
165
168
 
166
169
  ## Routing a token pair
167
170
 
168
- Authoring a flow is the general path: you decide the operations and how they chain. `sdk.route(...)` is the shortcut for the case where the backend can decide for you — you hand it a from/to token pair, an amount, and a signer, and it returns the same submit-ready `ComposeCompileResult` that `compile()` returns.
171
+ `sdk.route(...)` is the shortcut for when the backend can choose the operations. You pass a from/to token pair on one chain, an amount, and a signer. It returns the same `ComposeCompileResult` that `compile()` does.
172
+
173
+ The backend plans the shortest chain of routing-catalog edges for the pair: entering or exiting a protocol position, a wrap or unwrap, a mint or burn. The route can be up to the server's hop ceiling (2 by default). The chain may open with one `lifi.swap` when the input has to be sold first; the swap counts as a hop, so at the default ceiling it precedes at most one catalog edge. That swap only targets a hub token or the underlying of the next deposit, so `route` is not a general swap aggregator.
169
174
 
170
- As of today the backend authors a single zap step for that pair: one edge of its routing catalog — entering or exiting a protocol position, a wrap or unwrap, a mint or burn. It is not a swap aggregator, so a pair with no catalog edge (WETH to USDC, for instance) comes back as `no_route_error`. The endpoint is meant to author more complex flows in future releases, so read the single-step limit as where the server is today rather than as the boundary of the API.
175
+ - Node ids are `hop0`, `hop1`, … and the last node is always `zap`, so the delivered output is `result.outputs['zap.amountOut']`.
176
+ - Every intermediate step settles inside the proxy in the same transaction. Only the last step may pay out to a recipient, or land after settlement (only with `allowAsyncRoutes`).
177
+ - A route that opens with a swap requires `sweepTo`, because the swap can leave unspent input in the proxy. Omitting it fails with `validation_error`.
171
178
 
172
179
  ```ts
173
180
  import { createComposeSdk, routeAmount } from '@lifi/composer-sdk';
@@ -180,73 +187,103 @@ const sdk = createComposeSdk({
180
187
  const result = await sdk.route({
181
188
  chainId: 1,
182
189
  fromToken: '0x0000000000000000000000000000000000000000', // native ETH
183
- toToken: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH — a `wrap` edge
190
+ toToken: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH, a `wrap` edge
184
191
  amount: routeAmount.exact(100_000_000_000_000_000n), // 0.1 ETH
185
192
  signer: '0xYourAddress...',
186
193
  slippageBps: 100,
187
194
  });
188
195
 
189
196
  if (result.status === 'success') {
190
- console.log(result.transactionRequest); // to, data, value, gasLimit
197
+ console.log(result.transactionRequest); // to, chainId, data, value, gasLimit
191
198
  }
192
199
  ```
193
200
 
194
201
  ### Discovering routable pairs
195
202
 
196
- The catalog is dynamic — protocols, chains, and token blacklists move — so discover pairs instead of hardcoding them:
203
+ The catalog changes as protocols, chains, and token blacklists move, so discover pairs instead of hardcoding them. `getZapPacks` lists single edges; `route` chains them. `buildReachability` computes the chained closure locally, using the planner's rule:
197
204
 
198
205
  ```ts
199
- const packs = await sdk.client.getZapPacks({ protocols: 'aave' });
206
+ import { buildReachability } from '@lifi/composer-sdk';
200
207
 
201
- for (const pack of packs) {
202
- for (const edge of pack.edges) {
203
- console.log(pack.protocol, edge.type, edge.in.address, '->', edge.out.address);
204
- }
208
+ const packs = await sdk.client.getZapPacks();
209
+ const reach = buildReachability(packs);
210
+
211
+ reach.canRoute(aUsdc, yvUsdc); // aUSDC -> USDC -> yvUSDC: true
212
+ for (const pair of reach.routableFrom(aUsdc)) {
213
+ console.log(pair.in.address, '->', pair.out.address, `(${pair.hops} hops)`);
205
214
  }
206
215
  ```
207
216
 
208
- Every edge is one routable pair: `edge.in` and `edge.out` are the `fromToken` and `toToken` of a `route` call on `edge.in.chainId`, and `inAmountMin` / `inAmountMax` bound the amount when the protocol constrains it. Two edge fields change how you call `route`:
217
+ `pair.in` and `pair.out` are the `fromToken` and `toToken` of a `route` call on `pair.in.chainId`. The rule the helper applies, and the server enforces:
209
218
 
210
- - **`recipient: 'required'`** — the protocol mints to a named receiver, so set `sweepTo` when the output should not land with the signer.
211
- - **`availability: 'future'`** — the output only exists once the transaction settles. `route` skips these unless you pass `allowAsyncRoutes: true`, so by default a pair reachable only this way is a `no_route_error` and every terminal output delivers now (`delivery.when: 'now'`). Opt in and `sweepTo` names the future output's owner, no slippage floor is applied, and you must track settlement rather than treat the transaction as completing the job. A synchronous edge still wins whenever the pair has one, so read each terminal output's `delivery.when` in `result.outputs` to know which representation you got.
219
+ - Every edge except the last must leave its output in the proxy for the next edge to consume. An edge with `recipient: 'required'` or `availability: 'future'` can only be the last hop.
220
+ - The last edge must be synchronous, unless you pass `allowAsyncRoutes: true` to both `buildReachability` and `route`.
221
+ - A route never visits the same token twice and never exceeds `maxHops`.
212
222
 
213
- ### Amounts
223
+ The helper can overstate or miss what `route` accepts in a few ways:
224
+
225
+ - `maxHops` defaults to 2, the deployment default. The server reports its effective value in `no_route_error` `details.maxHops`; pass that value if it differs.
226
+ - The helper cannot check `inAmountMin` / `inAmountMax` on an intermediate hop without an amount. A reported pair is routable structurally, just as a listed edge is.
227
+ - The server drops blacklisted tokens as intermediates, so a reported pair can still return `no_route_error`.
228
+ - Routes that open with a `lifi.swap` depend on what the aggregator can sell, so the helper does not report them.
214
229
 
215
- The `amount` field is a discriminated union with two variants, built by `routeAmount`:
230
+ Two edge fields change how you call `route`:
216
231
 
217
- - **`routeAmount.exact(amount)`** — spend exactly `amount` of `fromToken`, in its smallest unit. Use this when you know the figure.
218
- - **`routeAmount.all(simAmount)`** — spend the signer's whole `fromToken` balance, resolved on-chain at execution time. `simAmount` is only an estimate: the quote, the price impact, and the returned approvals are computed against it, but the amount actually moved is the real balance when the transaction lands. Pass your best estimate of the balance — a wildly wrong figure produces a poor quote. Not supported for a native `fromToken`: a gas coin arrives via `msg.value`, so there is no balance to sweep, and the server rejects the request.
232
+ - **`recipient: 'required'`**: the protocol mints to a named receiver. Set `sweepTo` when the output should not land with the signer.
233
+ - **`availability: 'future'`**: the output only exists once the transaction settles, and `route` uses such an edge only with `allowAsyncRoutes: true`. Once opted in:
234
+ - `sweepTo` names the future output's owner.
235
+ - No slippage floor applies.
236
+ - You must track settlement.
237
+ - A synchronous route still wins whenever one exists, so read each terminal output's `delivery.when`.
219
238
 
220
- Both accept a `bigint` or a decimal string. `bigint` values are serialised for you.
239
+ A future output still reports `amount.estimate` (`basis: 'simulated'`, the in-transaction estimate). The catalog carries no settlement time. The compile result carries it as `delivery.etaSeconds` on a future terminal output; that field is absent when the protocol publishes no estimate, and absence means unknown, not zero.
240
+
241
+ ### Amounts
242
+
243
+ - **`routeAmount.exact(amount)`**: spend exactly `amount` of `fromToken`, in its smallest unit.
244
+ - **`routeAmount.all(simAmount)`**: spend the signer's whole `fromToken` balance, read on-chain at execution time.
245
+ - The quote, price impact, and returned approvals are computed against `simAmount`. The transaction moves the real balance, so pass a realistic estimate.
246
+ - Not supported for a native `fromToken`: a gas coin arrives via `msg.value`, so there is no balance to sweep.
247
+
248
+ Both accept a `bigint` or a decimal string.
221
249
 
222
250
  ### Options
223
251
 
224
- `sdk.route(...)` shares its optional fields with the authoring path, and the server applies the same defaults. Omit a field to take the default; the SDK sends only what you set.
252
+ The SDK sends only the fields you set. The server applies the defaults.
225
253
 
226
- - **`slippageBps`** — tolerance in basis points. Defaults to `100` (1%).
227
- - **`maxPriceImpactBps`** — reject the route if the priced impact exceeds this. No cap by default.
228
- - **`integratorFeeBps`** — your fee, in basis points, taken from the input amount before the route executes. Requires an integration-scoped `apiKey`; a non-zero value without one is rejected.
229
- - **`referrer`** — attribution string passed to the routing provider.
230
- - **`sweepTo`** — address that receives the output and any leftover dust. Accepts a literal address or `{ $ref: 'context.sender' }`. Defaults to the signer.
231
- - **`checkOnChainAllowances`** — when `true`, the server reads current allowances and omits approvals that are already sufficient. Defaults to `false`.
232
- - **`simulationPolicy`** — `'strict'` (default) or `'allow-revert'`, exactly as on the authoring path. See [Simulation policy and partial results](#simulation-policy-and-partial-results).
233
- - **`simulateUserProgram`** — when `true`, additionally simulates the final user-facing transaction after the structured simulation succeeds. Defaults to `false`.
234
- - **`allowAsyncRoutes`** — when `true`, accept a route whose output arrives only after the initiating transaction settles. Defaults to `false`, which leaves such a pair a `no_route_error` and guarantees every terminal output delivers now (`delivery.when: 'now'`). Set it only if you track settlement and can live without a slippage floor.
254
+ - **`slippageBps`**: default `100` (1%). An end-to-end tolerance on the route's final output, not a per-hop one.
255
+ - **`maxPriceImpactBps`**: reject the route when its priced impact exceeds this. Default `500`; `0` disables the check.
256
+ - **`integratorFeeBps`**: your fee, taken from the input amount. Range 0 to 9000, default `0`. A non-zero value requires an integration-scoped `apiKey`.
257
+ - **`referrer`**: opaque tag forwarded to the underlying quote provider.
258
+ - **`sweepTo`**: owner of everything the route leaves behind. An address or `{ $ref: 'context.sender' }`; `context.executionAddress` is rejected. Defaults to the signer, except that a route with a swap requires it.
259
+ - **`checkOnChainAllowances`**: omit approvals that current allowances already cover. Default `false`.
260
+ - **`simulationPolicy`**: `'strict'` (default) or `'allow-revert'`. See [Simulation policy and partial results](#simulation-policy-and-partial-results).
261
+ - **`simulateUserProgram`**: also simulate the final user-facing transaction after the structured simulation succeeds. Default `false`.
262
+ - **`allowAsyncRoutes`**: accept a route whose output arrives after the transaction settles. Default `false`, which guarantees every terminal output is `delivery.when: 'now'`.
235
263
 
236
264
  ### When to author a flow instead
237
265
 
238
- `route` gives you one server-authored step. Reach for `sdk.flow(...)` when you need anything it does not express: several operations chained together, splitting one input across several destinations, guards on intermediate outputs, explicit preconditions, arithmetic, or funding from proxy-held balances. Swapping WETH to USDC and then depositing the USDC into Aave is a flow; depositing USDC straight into Aave is a route. An asynchronous position (`availability: 'future'`) as the destination is a route once you pass `allowAsyncRoutes: true` — the server picks `lifi.zapAsync` for you — but authoring the flow yourself is still the way to combine it with other steps.
266
+ Use `sdk.flow(...)` for anything `route` cannot express:
267
+
268
+ - a chain you choose yourself, or one longer than the hop ceiling
269
+ - one input split across several destinations
270
+ - guards on intermediate outputs
271
+ - explicit preconditions or arithmetic
272
+ - funding from proxy-held balances
273
+ - combining an asynchronous position with other steps
274
+
275
+ For example, swapping WETH to USDC and depositing the USDC into Aave is a route when the planner can connect it, and a flow when you want to control the swap or add steps.
239
276
 
240
277
  ### Errors
241
278
 
242
- `route` maps failures the same way `compile` does — see [Error handling](#error-handling). Two are specific to this path:
279
+ `route` maps failures like `compile` does (see [Error handling](#error-handling)). Two outcomes are specific to it:
243
280
 
244
- - **`NOT_FOUND`** with `kind: 'no_route_error'` — the backend has no routing edge for the pair on that chain. `sdk.client.getZapPacks()` reports which edges exist.
245
- - **`VALIDATION_ERROR`** — the pair is routable but the request is rejected, for example a price impact above `maxPriceImpactBps`, or a simulation revert under the default `'strict'` policy.
281
+ - **`NOT_FOUND`** with `kind: 'no_route_error'`: the planner cannot connect the pair within `details.maxHops` edges. `details.searchTruncated` reports whether the search hit its work bound.
282
+ - **`VALIDATION_ERROR`**: the pair connects but the request is rejected. Examples: price impact above `maxPriceImpactBps`, or a simulated revert under `'strict'`.
246
283
 
247
284
  ## Simulation policy and partial results
248
285
 
249
- 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'`:
286
+ The backend simulates every compiled transaction. Under the default `simulationPolicy: 'strict'`, a revert throws a `ComposeError` with code `VALIDATION_ERROR` (HTTP 422). With `'allow-revert'` you get a **partial result** (HTTP 206) instead:
250
287
 
251
288
  ```ts
252
289
  const result = await builder.compile({
@@ -256,57 +293,35 @@ const result = await builder.compile({
256
293
  });
257
294
 
258
295
  if (result.status === 'success') {
259
- // Simulation succeeded. transactionRequest includes gasLimit.
260
- const tx = result.transactionRequest;
261
- console.log(tx.gasLimit); // string
296
+ console.log(result.transactionRequest?.gasLimit); // string
262
297
  } else {
263
- // result.status === 'partial'
264
- // Simulation reverted, but a transaction is still available (without gasLimit).
298
+ // status === 'partial': the transaction is returned without gasLimit;
299
+ // estimate gas yourself.
265
300
  console.log(result.error.kind); // 'simulation_revert'
266
- console.log(result.error.message); // human-readable revert description
301
+ console.log(result.error.message);
267
302
 
268
- // Revert diagnostics
269
303
  const revert = result.simulationRevert;
270
- console.log(revert.code); // e.g. 3
271
- console.log(revert.rawErrorBytes); // raw ABI-encoded error
272
-
273
- // Decoded error candidates (when available)
274
- if (revert.decodeResult?.errorCandidates) {
275
- for (const c of revert.decodeResult.errorCandidates) {
276
- console.log(c.decodedErrorSignature, c.decodedParams);
277
- }
304
+ console.log(revert.code, revert.rawErrorBytes);
305
+ for (const c of revert.decodeResult?.errorCandidates ?? []) {
306
+ console.log(c.decodedErrorSignature, c.decodedParams);
278
307
  }
279
308
 
280
- // The transactionRequest is still usable — the caller must estimate gas themselves.
281
309
  const tx = result.transactionRequest;
282
310
  console.log(tx.to, tx.data, tx.value);
283
311
  }
284
312
  ```
285
313
 
286
- The `simulationPolicy` field accepts two values:
287
-
288
- - `'strict'` (default) — revert causes a thrown `ComposeError` with code `VALIDATION_ERROR`
289
- - `'allow-revert'` — revert returns a partial result with `status: 'partial'`
290
-
291
- 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:
292
-
293
- ```ts
294
- const result = await builder.compile({
295
- signer: OWNER,
296
- inputs: { amountIn: materialisers.balanceOf({ owner: OWNER }) },
297
- checkOnChainAllowances: true,
298
- });
299
- ```
314
+ Pass `checkOnChainAllowances: true` to have the server drop entries from `approvals` that current on-chain allowances already cover.
300
315
 
301
316
  ## Simulating a raw transaction
302
317
 
303
- `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.
318
+ `sdk.client.simulate(...)` (and `sdk.simulate(...)`) answers a question compile does not: if I send this exact transaction, how do specific balances change, and how much gas does it burn? It takes a pre-encoded transaction (`to`, hex `data`, optional `value`). It funds the sender as `requirements` describe, runs the transaction in one `eth_call`, and reports the watched balances before and after, their signed deltas, and the inner-call gas.
304
319
 
305
320
  ```ts
306
321
  import { createComposeSdk } from '@lifi/composer-sdk';
307
322
 
308
323
  const sdk = createComposeSdk({
309
- baseUrl: 'https://li.quest',
324
+ baseUrl: 'https://composer.li.quest',
310
325
  apiKey: process.env.LIFI_API_KEY!,
311
326
  });
312
327
 
@@ -315,13 +330,13 @@ const result = await sdk.client.simulate({
315
330
  from: '0x1111111111111111111111111111111111111111',
316
331
  to: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC
317
332
  data: '0xa9059cbb...', // pre-encoded transfer calldata
318
- value: 0n, // bigint accepted; serialised to "0"
333
+ value: 0n,
319
334
  requirements: [
320
335
  {
321
336
  type: 'Erc20Balance',
322
337
  wallet: '0x1111111111111111111111111111111111111111',
323
338
  token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
324
- balance: 1_000_000n, // bigint accepted
339
+ balance: 1_000_000n,
325
340
  },
326
341
  ],
327
342
  trackedBalances: [
@@ -338,25 +353,27 @@ const result = await sdk.client.simulate({
338
353
 
339
354
  switch (result.status) {
340
355
  case 'ok':
341
- // Successful simulation.
342
356
  console.log(result.gasUsed, result.deltas);
343
357
  break;
344
358
  case 'revert':
345
- // The simulation ran but the transaction reverted on-chain — NOT an error.
359
+ // The simulation ran and the transaction reverted. Not an error.
346
360
  console.log(result.revertReason, result.decodeResult);
347
361
  break;
348
362
  case 'error':
349
- // The request was well-formed but the simulation could not be set up/run.
363
+ // Well-formed request, but the simulation could not be set up or run.
350
364
  console.log(result.message);
351
365
  break;
352
366
  }
353
367
  ```
354
368
 
355
- `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.
369
+ - `requirements` entries are `Erc20Balance`, `NativeBalance`, or `Erc20Allowance`. Use the zero address as a `trackedBalances` token to watch the native balance.
370
+ - Amount fields accept `bigint` or decimal strings.
371
+ - `SIMULATE_MAX_TRACKED_BALANCES` and `SIMULATE_MAX_REQUIREMENTS` (both `40`) are exported.
372
+ - A `revert` is returned, not thrown. Only transport failures and HTTP 400/401/403/404/429/5xx throw `ComposeError`.
373
+ - `gasUsed` is inner-call execution gas only; it excludes the 21000 base cost and calldata gas.
374
+ - EOA-only behaviour, such as `msg.sender == tx.origin` checks, is not simulated faithfully, because the call runs through injected VM bytecode rather than a real EOA.
356
375
 
357
- 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`.
358
-
359
- 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:
376
+ To simulate a transaction you just compiled, `buildSimulateRequest(...)` fills in the transaction fields from the compile result. A compile result carries neither `chainId` nor `signer`, so pass both. Nothing in a flow says which balances to watch, so pass `trackedBalances` and any `requirements` too. It throws when the result has no single `transactionRequest` (a flow funded by an external action); submit that result's `actions` instead.
360
377
 
361
378
  ```ts
362
379
  import { buildSimulateRequest } from '@lifi/composer-sdk';
@@ -376,13 +393,11 @@ const req = buildSimulateRequest({
376
393
  const sim = await sdk.client.simulate(req);
377
394
  ```
378
395
 
379
- 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.
380
-
381
- 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.
396
+ Runnable versions: [`src/examples/simulateRawTransaction.ts`](src/examples/simulateRawTransaction.ts) and [`src/examples/simulateCompiledSwap.ts`](src/examples/simulateCompiledSwap.ts).
382
397
 
383
398
  ## Error handling
384
399
 
385
- All SDK errors are thrown as `ComposeError` with a `code` property:
400
+ HTTP and transport failures are thrown as a `ComposeError`. Builder mistakes (a duplicate node id, a missing output port) throw a plain `Error` at the call site. Narrow with `isComposeError`:
386
401
 
387
402
  ```ts
388
403
  import { isComposeError } from '@lifi/composer-sdk';
@@ -391,13 +406,26 @@ try {
391
406
  const result = await sdk.client.compile(request);
392
407
  } catch (err) {
393
408
  if (isComposeError(err)) {
394
- console.error(err.code, err.message);
395
- // Codes: VALIDATION_ERROR, SERVER_ERROR, RATE_LIMITED, NETWORK_ERROR, ...
409
+ console.error(err.code, err.status, err.kind, err.message);
410
+ console.error(err.path, err.port, err.fix); // when the server names a location or a fix
396
411
  }
397
412
  }
398
413
  ```
399
414
 
400
- A `preparation_error` (HTTP 422) means some prepared ops failed while others succeeded — a mixed basket with an unroutable leg. Narrow with `isComposePreparationError` to read which ops failed, then drop those `callId`s from the flow and resubmit:
415
+ | `code` | HTTP status | Typical cause |
416
+ | ------------------ | ----------- | --------------------------------------------------------------- |
417
+ | `VALIDATION_ERROR` | 400, 422 | Malformed or rejected flow or request, simulated revert, guard |
418
+ | `UNAUTHENTICATED` | 401 | Missing or invalid API key |
419
+ | `FORBIDDEN` | 403 | The server refuses the request |
420
+ | `NOT_FOUND` | 404 | `no_route_error` |
421
+ | `RATE_LIMITED` | 429 | Too many requests |
422
+ | `SERVER_ERROR` | 5xx | Backend failure or a dependency temporarily unavailable |
423
+ | `NETWORK_ERROR` | none | The request never got a response |
424
+ | `UNKNOWN_ERROR` | other | Unexpected status |
425
+
426
+ `kind` is the backend's finer-grained classification, e.g. `simulation_revert`, `guard_error`, or `price_impact_exceeded`. When `kind` is `simulation_revert`, `err.details` has the same shape as `result.simulationRevert`.
427
+
428
+ A `preparation_error` (HTTP 422) means some prepared ops (ops that fetch external quotes at compile time, such as `lifi.swap`) failed while others succeeded, e.g. a mixed basket with one unroutable leg. Narrow with `isComposePreparationError`, drop the failed node ids from the flow, and resubmit:
401
429
 
402
430
  ```ts
403
431
  import { isComposePreparationError } from '@lifi/composer-sdk';
@@ -409,72 +437,68 @@ try {
409
437
  for (const op of err.failedOps) {
410
438
  console.error(op.callId, op.op, op.kind, op.message);
411
439
  }
412
- // Prepared ops that succeeded. Both arrays list prepared ops only, so
413
- // retry by removing the `failedOps` callIds from the original flow.
440
+ // Only the prepared ops that succeeded, not the rest of the flow. To retry,
441
+ // remove the failedOps callIds from the original flow.
414
442
  console.error('prepared ok:', err.succeededOps);
415
443
  }
416
444
  }
417
445
  ```
418
446
 
419
- See [`docs/sdk-interface.md`](docs/sdk-interface.md#per-op-preparation-failures) for the full error surface.
447
+ **Version mismatch.** `compile` and `route` fail with `kind: 'sdk_outdated'` when this SDK is older than the oldest version the backend serves. They fail with `kind: 'server_outdated'` when the SDK is newer than the backend in any version component, which happens between an npm release and the backend rollout. Both come back as `VALIDATION_ERROR`. `isComposeSdkOutdatedError(err)` exposes `err.sdkOutdated.minimumSdkVersion`; `isComposeServerOutdatedError(err)` exposes `err.serverOutdated.serverVersion`. On `server_outdated`, pin both packages to the server's version or wait for the rollout.
420
448
 
421
449
  ## Examples
422
450
 
423
- The `src/examples/` directory contains complete working examples:
424
-
425
- - **lifiSwap** — Single token swap (WETH to USDC)
426
- - **lifiZap** — Swap into a DeFi position
427
- - **lifiZapAsync** — Zap into a position whose output settles after the initiating transaction (future availability)
428
- - **swapAndZap** — Multi-step: swap then deposit
429
- - **splitAndZap** — Split a resource and zap each portion into a different vault
430
- - **splitWithArithmetic** — Split then verify with add/subtract/assertEqual assertions
431
- - **dustSweep** — Split and partially use tokens, sweep leftover dust back to sender
432
- - **depositFromProxy** — Read tokens already on the proxy via `balanceOf`, with a precondition guard
433
- - **approveAndDeposit** — Approve a vault, deposit, and graduate shares via `asResource`
434
- - **consolidateToUsdc** — Consolidate multiple tokens into USDC
435
- - **consolidateToEth** — Consolidate multiple tokens into ETH
436
- - **swapToRecipient** — Swap and send to a different address
437
- - **swapWithBalanceCheck** — Swap with balance precondition
438
- - **swapWithOutputValidation** — Swap with computed slippage bounds using bpsDown/bpsUp/assertInRange
439
- - **rawCallWithArithmetic** — Query a contract with pre-encoded calldata, then scale with multiply/divide
440
- - **readContractState** — Compare peek (compile-time), staticCall (execution-time), and balanceOf (resource)
441
- - **swapWithAllowRevert** — Swap with `simulationPolicy: 'allow-revert'` and handle the `ComposeCompileResult` discriminated union
442
- - **swapWithFee** — Swap while collecting an integrator fee via `integratorFeeBps` (requires an integration-scoped `apiKey`)
443
- - **transferTokens** — Transfer ERC-20 tokens from the proxy to an arbitrary recipient
444
- - **callContract** — Call an arbitrary contract (ERC-4626 redeem; reward claim) without a dedicated typed op
445
- - **aaveRepay** — Repay an Aave v3 variable-rate debt, sweeping the unspent residual back to the sender
446
- - **aaveRepayWithATokens** — Repay Aave v3 debt by burning aToken collateral already held by the proxy
447
- - **aaveClaimRewards** — Claim accrued Aave rewards and forward the claimed amount to a recipient
448
- - **aaveSetEMode** — Switch the proxy's Aave v3 eMode category
449
- - **untypedOpWithTypedRef** — Insert an untyped operation node via `untypedOp`, then bridge its output into typed operations using `raw.ref<T>()`
450
- - **routeTokenPair** — Compile a from/to token pair via `sdk.route(...)`, with both the `exact` and `all` amount variants
451
- - **supportedChains** — Cache `sdk.getSupportedChains()`, then preflight a chain and label it from the response's `name`, so an unsupported one fails locally with a message naming the alternatives
451
+ Each file in `src/examples/` is a complete flow:
452
+
453
+ - **lifiSwap**: single token swap (WETH to USDC)
454
+ - **lifiZap**: swap into a DeFi position
455
+ - **lifiZapAsync**: zap into a position whose output settles after the initiating transaction
456
+ - **swapAndZap**: swap, then deposit
457
+ - **splitAndZap**: split a resource and zap each part into a different vault
458
+ - **splitWithArithmetic**: split, then check with add/subtract/assertEqual
459
+ - **dustSweep**: split, use part of the tokens, sweep the rest back to the sender
460
+ - **depositFromProxy**: spend tokens already on the proxy via `balanceOf`, with a precondition
461
+ - **approveAndDeposit**: approve a vault, deposit, and turn the shares into a resource via `asResource`
462
+ - **consolidateToUsdc** / **consolidateToEth**: consolidate several tokens into one
463
+ - **swapToRecipient**: swap and send to another address
464
+ - **swapWithBalanceCheck**: swap with a balance precondition
465
+ - **swapWithOutputValidation**: swap with computed bounds via bpsDown/bpsUp/assertInRange
466
+ - **invariantChecks**: swap, then assert the output with `invariant.gte` and an allowance with `invariant.allowanceAtLeast`
467
+ - **rawCallWithArithmetic**: query a contract with pre-encoded calldata, then scale the result
468
+ - **readContractState**: compare `peek` (compile time), `staticCall` (execution time), and `balanceOf` (resource)
469
+ - **emitCustomEvent**: emit a custom event with `core.emitEvent2`; `bytes32` ports accept any static 32-byte handle
470
+ - **swapWithAllowRevert**: handle both branches of `ComposeCompileResult` under `'allow-revert'`
471
+ - **swapWithFee**: collect an integrator fee via `integratorFeeBps` (requires an integration-scoped `apiKey`)
472
+ - **transferTokens**: transfer ERC-20 tokens from the proxy to any recipient
473
+ - **callContract**: call an arbitrary contract (ERC-4626 redeem, reward claim) without a typed op
474
+ - **flySwap**: on Base, exit a Pendle PT via `lifi.zap`, then swap all proceeds to WETH with `fly.swap`
475
+ - **aaveRepay**: repay Aave v3 variable debt and sweep the unspent residual to the sender
476
+ - **aaveRepayWithATokens**: repay Aave v3 debt by burning aTokens held by the proxy
477
+ - **aaveClaimRewards**: claim Aave rewards and forward them to a recipient
478
+ - **aaveSetEMode**: switch the proxy's Aave v3 eMode category
479
+ - **untypedOpWithTypedRef**: add a node with `untypedOp`, then use its output in typed ops via `raw.ref<T>()`
480
+ - **routeTokenPair**: `sdk.route(...)` with both the `exact` and `all` amount variants
481
+ - **supportedChains**: cache `sdk.getSupportedChains()`, then fail an unsupported chain locally with a message naming the alternatives
482
+ - **simulateRawTransaction** / **simulateCompiledSwap**: raw and compiled simulation
452
483
 
453
484
  ## Staging channel
454
485
 
455
- 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`:
486
+ Some operations run on the backend but are held back from the default SDK, for example because their contract changes are not live in production yet. These **staged** operations are published on the `staging` npm dist-tag:
456
487
 
457
488
  ```bash
458
489
  npm install @lifi/composer-sdk@staging @lifi/compose-spec@staging
459
490
  ```
460
491
 
461
- 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.
462
-
463
- 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:
492
+ The `staging` build types the staged operations (e.g. `lifi.flashloanRepay`). The default install never exposes them. A staged operation only runs on a backend that has it enabled, so point `baseUrl` at one:
464
493
 
465
494
  ```ts
466
495
  const sdk = createComposeSdk({
467
- baseUrl: '<staging-backend-url>', // a backend that has the staged ops enabled
496
+ baseUrl: '<staging-backend-url>',
468
497
  apiKey: process.env.LIFI_API_KEY!,
469
498
  });
470
499
  ```
471
500
 
472
- 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.
473
-
474
- The channel and the backend URL are independent:
475
-
476
- - 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`.
477
- - 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.
501
+ Against a backend without the operation, the call fails at runtime; having the types does not mean the backend supports the operation. The channel is permanent, and its contents rotate as ops graduate to `latest`. The backend URL is a runtime argument, so one staging build can target whichever backend has the ops enabled.
478
502
 
479
503
  ## License
480
504