@lifi/composer-sdk 0.5.0 → 0.6.0

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.
Files changed (72) hide show
  1. package/README.md +13 -3
  2. package/dist/authoring/FlowBuilderCore.cjs.map +1 -1
  3. package/dist/authoring/FlowBuilderCore.d.cts +11 -4
  4. package/dist/authoring/FlowBuilderCore.d.ts +11 -4
  5. package/dist/authoring/FlowBuilderCore.js.map +1 -1
  6. package/dist/authoring/handles.cjs.map +1 -1
  7. package/dist/authoring/handles.d.cts +2 -2
  8. package/dist/authoring/handles.d.ts +2 -2
  9. package/dist/authoring/handles.js.map +1 -1
  10. package/dist/authoring/raw.cjs +5 -3
  11. package/dist/authoring/raw.cjs.map +1 -1
  12. package/dist/authoring/raw.js +5 -3
  13. package/dist/authoring/raw.js.map +1 -1
  14. package/dist/client.cjs +94 -60
  15. package/dist/client.cjs.map +1 -1
  16. package/dist/client.d.cts +12 -4
  17. package/dist/client.d.ts +12 -4
  18. package/dist/client.js +98 -60
  19. package/dist/client.js.map +1 -1
  20. package/dist/errors.cjs +42 -3
  21. package/dist/errors.cjs.map +1 -1
  22. package/dist/errors.d.cts +67 -4
  23. package/dist/errors.d.ts +67 -4
  24. package/dist/errors.js +29 -2
  25. package/dist/errors.js.map +1 -1
  26. package/dist/generated/config.generated.cjs.map +1 -1
  27. package/dist/generated/config.generated.d.cts +46 -5
  28. package/dist/generated/config.generated.d.ts +46 -5
  29. package/dist/generated/operations.generated.cjs +4 -0
  30. package/dist/generated/operations.generated.cjs.map +1 -1
  31. package/dist/generated/operations.generated.d.cts +16 -2
  32. package/dist/generated/operations.generated.d.ts +16 -2
  33. package/dist/generated/operations.generated.js +4 -0
  34. package/dist/generated/operations.generated.js.map +1 -1
  35. package/dist/index.cjs +8 -1
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.d.cts +4 -3
  38. package/dist/index.d.ts +4 -3
  39. package/dist/index.js +11 -2
  40. package/dist/index.js.map +1 -1
  41. package/dist/outputs.cjs +33 -0
  42. package/dist/outputs.cjs.map +1 -0
  43. package/dist/outputs.d.cts +10 -0
  44. package/dist/outputs.d.ts +10 -0
  45. package/dist/outputs.js +9 -0
  46. package/dist/outputs.js.map +1 -0
  47. package/dist/responseSchemas.cjs +3 -0
  48. package/dist/responseSchemas.cjs.map +1 -1
  49. package/dist/responseSchemas.d.cts +7 -4
  50. package/dist/responseSchemas.d.ts +7 -4
  51. package/dist/responseSchemas.js +3 -0
  52. package/dist/responseSchemas.js.map +1 -1
  53. package/dist/types.cjs.map +1 -1
  54. package/dist/types.d.cts +16 -5
  55. package/dist/types.d.ts +16 -5
  56. package/package.json +4 -3
  57. package/src/authoring/FlowBuilderCore.ts +12 -3
  58. package/src/authoring/handles.ts +2 -1
  59. package/src/authoring/raw.ts +4 -3
  60. package/src/client.ts +177 -79
  61. package/src/errors.ts +116 -3
  62. package/src/examples/flySwap.ts +3 -3
  63. package/src/examples/lifiZapAsync.ts +8 -7
  64. package/src/examples/run.ts +6 -9
  65. package/src/examples/swapAndZap.ts +7 -0
  66. package/src/examples/swapWithAllowRevert.ts +13 -3
  67. package/src/generated/config.generated.ts +49 -4
  68. package/src/generated/operations.generated.ts +25 -0
  69. package/src/index.ts +21 -3
  70. package/src/outputs.ts +16 -0
  71. package/src/responseSchemas.ts +10 -4
  72. package/src/types.ts +16 -4
package/src/client.ts CHANGED
@@ -8,6 +8,12 @@ import type {
8
8
  SimulateRequest,
9
9
  SimulateResult,
10
10
  } from '@lifi/compose-spec';
11
+ import {
12
+ checkCompatibility,
13
+ COMPOSE_VERSION_HEADER,
14
+ COMPOSER_SDK_VERSION_HEADER,
15
+ parseVersion,
16
+ } from '@lifi/compose-spec';
11
17
 
12
18
  import type {
13
19
  ChainSummary,
@@ -23,12 +29,14 @@ import {
23
29
  // __SDK_VERSION__ is a compile-time constant injected by tsup (via `define` in tsup.config.ts)
24
30
  // and by vitest (via `define` in vitest.config.ts). Both read the version from package.json
25
31
  // at build/test time and replace this identifier with the literal string value.
26
- // It is sent as the `x-lifi-composer-sdk` request header so the server can identify the caller.
32
+ // It is sent as the `COMPOSER_SDK_VERSION_HEADER` request header so the server can identify the caller.
27
33
  // Falls back to 'dev' when running via tsx without tsup substitution (e.g. the example harness).
28
34
  declare const __SDK_VERSION__: string;
29
35
 
30
36
  const SDK_VERSION: string =
31
37
  typeof __SDK_VERSION__ !== 'undefined' ? __SDK_VERSION__ : 'dev';
38
+ // `undefined` for the `dev` sentinel, which disables the response version check.
39
+ const SDK_VERSION_TRIPLE = parseVersion(SDK_VERSION);
32
40
 
33
41
  /**
34
42
  * Configuration for creating a low-level Compose API client.
@@ -115,11 +123,11 @@ export interface ComposeClient {
115
123
  * the server, not the shape of this contract.
116
124
  *
117
125
  * Only synchronous edges are considered unless the request sets
118
- * `allowAsyncRoutes: true`, so by default every produced resource comes back
119
- * `availability: 'now'` and a pair reachable only after settlement is a
126
+ * `allowAsyncRoutes: true`, so by default every terminal output comes back
127
+ * `delivery.when: 'now'` and a pair reachable only after settlement is a
120
128
  * `no_route_error`. Opting in adds the `lifi.zapAsync` fallback, which still
121
129
  * loses to a synchronous edge when the pair has one; read
122
- * `producedResources[*].availability` to see which representation you got.
130
+ * `outputs[*].delivery.when` to see which representation you got.
123
131
  *
124
132
  * Use {@link compile} for what the server does not author for you: several
125
133
  * chained operations, splits, explicit preconditions, or per-op guards.
@@ -176,7 +184,14 @@ export interface ComposeClient {
176
184
  readonly simulate: (request: SimulateRequest) => Promise<SimulateResult>;
177
185
  }
178
186
 
179
- const bigintReplacer = (_key: string, value: unknown): unknown =>
187
+ /**
188
+ * `JSON.stringify` replacer that renders `bigint` amounts as decimal strings.
189
+ *
190
+ * The compose wire carries token amounts as decimal strings, and `bigint` has
191
+ * no JSON representation, so every payload this client sends goes through it.
192
+ * Exported so the example harness prints a request exactly as it is sent.
193
+ */
194
+ export const bigintReplacer = (_key: string, value: unknown): unknown =>
180
195
  typeof value === 'bigint' ? value.toString() : value;
181
196
 
182
197
  const isNonNullObject = (v: unknown): v is Record<string, unknown> =>
@@ -185,16 +200,11 @@ const isNonNullObject = (v: unknown): v is Record<string, unknown> =>
185
200
  // Non-negative decimal integer string, as the server sends token amounts.
186
201
  const INTEGER_STRING = /^\d+$/;
187
202
 
188
- const decodeAmount = (
189
- value: unknown,
190
- key: string,
191
- field: 'amountOut' | 'amountOutMin',
192
- url: string,
193
- ): bigint => {
203
+ const decodeAmount = (value: unknown, path: string, url: string): bigint => {
194
204
  if (typeof value !== 'string' || !INTEGER_STRING.test(value)) {
195
205
  throw new ComposeError(
196
206
  'UNKNOWN_ERROR',
197
- `Malformed compose response: producedResources.${key}.simulated.${field} is ${
207
+ `Malformed compose response: ${path} is ${
198
208
  value === undefined ? 'absent' : 'not an integer string'
199
209
  }`,
200
210
  { url },
@@ -203,46 +213,83 @@ const decodeAmount = (
203
213
  return BigInt(value);
204
214
  };
205
215
 
206
- // `simulated.amountOut` / `.amountOutMin` are declared `bigint` on
207
- // `SimulatedAmounts` (the domain truth: `compose-core` produces real bigints)
208
- // but arrive as decimal strings on the wire (the backend stringifies them).
209
- // Decode at this seam so a consumer reading the declared type gets the value
210
- // the type promises. A resource without `simulated` is left untouched; a
211
- // `simulated` claiming a missing or non-numeric amount is a contract violation
212
- // and surfaces as a `ComposeError` rather than `NaN` / `BigInt(undefined)`.
213
- const decodeSimulatedAmounts = (data: unknown, url: string): void => {
214
- if (!isNonNullObject(data)) return;
215
- const resources = data.producedResources;
216
- if (!isNonNullObject(resources)) return;
217
- for (const key of Object.keys(resources)) {
218
- const resource = resources[key];
219
- if (!isNonNullObject(resource)) continue;
220
- const simulated = resource.simulated;
221
- if (simulated === undefined) continue;
222
- if (!isNonNullObject(simulated)) {
223
- throw new ComposeError(
224
- 'UNKNOWN_ERROR',
225
- `Malformed compose response: producedResources.${key}.simulated is not an object`,
226
- { url },
227
- );
228
- }
229
- simulated.amountOut = decodeAmount(
230
- simulated.amountOut,
231
- key,
232
- 'amountOut',
216
+ // The two records this seam reads out of a compile-family response body. Each
217
+ // is `unknown` because the seam is what establishes its shape: it validates
218
+ // every field before writing a decoded `bigint` back over the wire string.
219
+ interface WireAmountBody {
220
+ outputs?: unknown;
221
+ }
222
+
223
+ // One `Amount` as it arrives: each leaf is `unknown` until this seam has
224
+ // checked it, and is overwritten in place with the decoded `bigint`.
225
+ interface WireAmount {
226
+ estimate?: unknown;
227
+ minimum?: unknown;
228
+ }
229
+
230
+ const malformed = (path: string, what: string, url: string): ComposeError =>
231
+ new ComposeError(
232
+ 'UNKNOWN_ERROR',
233
+ `Malformed compose response: ${path} ${what}`,
234
+ { url },
235
+ );
236
+
237
+ // Decode one `Estimate` / `Minimum` leaf in place: `{ basis, value }` with
238
+ // `value` a decimal string on the wire and a `bigint` on `Amount`.
239
+ const decodeAmountLeaf = (
240
+ amount: WireAmount,
241
+ field: 'estimate' | 'minimum',
242
+ path: string,
243
+ url: string,
244
+ ): void => {
245
+ const leaf = amount[field];
246
+ if (leaf === undefined) return;
247
+ if (!isNonNullObject(leaf)) {
248
+ throw malformed(`${path}.${field}`, 'is not an object', url);
249
+ }
250
+ leaf.value = decodeAmount(leaf.value, `${path}.${field}.value`, url);
251
+ };
252
+
253
+ // `outputs` is REQUIRED on every compile-family body and is the record a
254
+ // caller reads amounts from, so its structure is checked here — the body is
255
+ // cast, not schema-parsed — before each `value` leaf becomes a `bigint`.
256
+ const decodeOutputs = (data: WireAmountBody, url: string): void => {
257
+ const outputs = data.outputs;
258
+ if (!isNonNullObject(outputs)) {
259
+ throw malformed(
260
+ 'outputs',
261
+ outputs === undefined ? 'is absent' : 'is not an object',
233
262
  url,
234
263
  );
235
- if (simulated.amountOutMin !== undefined) {
236
- simulated.amountOutMin = decodeAmount(
237
- simulated.amountOutMin,
238
- key,
239
- 'amountOutMin',
264
+ }
265
+ for (const key of Object.keys(outputs)) {
266
+ const entry = outputs[key];
267
+ if (!isNonNullObject(entry)) {
268
+ throw malformed(`outputs.${key}`, 'is not an object', url);
269
+ }
270
+ const amount = entry.amount;
271
+ if (!isNonNullObject(amount)) {
272
+ throw malformed(
273
+ `outputs.${key}.amount`,
274
+ amount === undefined ? 'is absent' : 'is not an object',
240
275
  url,
241
276
  );
242
277
  }
278
+ decodeAmountLeaf(amount, 'estimate', `outputs.${key}.amount`, url);
279
+ decodeAmountLeaf(amount, 'minimum', `outputs.${key}.amount`, url);
243
280
  }
244
281
  };
245
282
 
283
+ // THE decode seam for a compile-family response body: every amount the wire
284
+ // renders as a decimal string becomes the `bigint` its declared type promises.
285
+ // The amount record is handled here so the body is narrowed once — a
286
+ // non-object body is not this function's error to raise, since the enveloping
287
+ // parse already rejected it.
288
+ const decodeWireAmounts = (data: unknown, url: string): void => {
289
+ if (!isNonNullObject(data)) return;
290
+ decodeOutputs(data, url);
291
+ };
292
+
246
293
  const parseBody = async <T>(res: Response, url: string): Promise<T> => {
247
294
  const body = await res.json().catch((_) => null);
248
295
  if (!isNonNullObject(body) || !('data' in body)) {
@@ -258,7 +305,7 @@ const parseCompileSuccessBody = async (
258
305
  url: string,
259
306
  ): Promise<ComposeCompileResult> => {
260
307
  const data = await parseBody<ComposeCompileSuccessData>(res, url);
261
- decodeSimulatedAmounts(data, url);
308
+ decodeWireAmounts(data, url);
262
309
  return { ...data, status: 'success' as const };
263
310
  };
264
311
 
@@ -278,8 +325,8 @@ const parsePartialBody = async (
278
325
  // `data` is validated as an object by the schema; compose-spec owns its full
279
326
  // shape as a hand-authored type, so we narrow it here rather than re-declaring
280
327
  // that type as a schema. `error` is fully validated — no cast needed.
281
- const data = envelope.data as unknown as ComposeCompilePartialData;
282
- decodeSimulatedAmounts(data, url);
328
+ const data = envelope.data as ComposeCompilePartialData;
329
+ decodeWireAmounts(data, url);
283
330
  return { ...data, status: 'partial' as const, error: envelope.error };
284
331
  };
285
332
 
@@ -310,22 +357,92 @@ type ComposeTransport = {
310
357
  readonly baseHeaders: Record<string, string>;
311
358
  };
312
359
 
313
- const postCompile = async (
360
+ // A patch bump is additive and a minor bump is breaking, so a 200 from a
361
+ // backend behind this SDK cannot be trusted: the newer flow this SDK authored
362
+ // may have compiled under the server's older semantics without raising an
363
+ // error. This covers the deploy window where the SDK is published to npm ahead
364
+ // of the backend rollout. Only the compile-family POSTs are checked; discovery
365
+ // GETs stay reachable from a mismatched SDK, as on the server. An absent header
366
+ // (a pre-gate backend, a header-stripping proxy) is accepted, mirroring the
367
+ // server's pass-through of a compile that carries no version header.
368
+ const assertServerVersion = (res: Response, url: string): void => {
369
+ const serverVersion = res.headers.get(COMPOSE_VERSION_HEADER);
370
+ if (serverVersion === null || SDK_VERSION_TRIPLE === undefined) return;
371
+ const serverTriple = parseVersion(serverVersion);
372
+ if (serverTriple === undefined) return;
373
+
374
+ const compatibility = checkCompatibility(SDK_VERSION_TRIPLE, serverTriple);
375
+ if (compatibility === 'compatible') return;
376
+
377
+ const versions = { sdkVersion: SDK_VERSION, serverVersion };
378
+ if (compatibility === 'sdk_outdated') {
379
+ throw new ComposeError(
380
+ 'VALIDATION_ERROR',
381
+ `composer-sdk ${SDK_VERSION} is older than the compose contract served at ${url} (${serverVersion}); upgrade @lifi/composer-sdk and @lifi/compose-spec`,
382
+ {
383
+ status: res.status,
384
+ url,
385
+ kind: 'sdk_outdated',
386
+ sdkOutdated: {
387
+ ...versions,
388
+ minimumSdkVersion: `${serverTriple.major}.${serverTriple.minor}.0`,
389
+ },
390
+ },
391
+ );
392
+ }
393
+ throw new ComposeError(
394
+ 'VALIDATION_ERROR',
395
+ `composer-sdk ${SDK_VERSION} targets a newer compose contract than the server at ${url} serves (${serverVersion}); pin @lifi/composer-sdk and @lifi/compose-spec to ${serverVersion} or wait for the server rollout`,
396
+ {
397
+ status: res.status,
398
+ url,
399
+ kind: 'server_outdated',
400
+ serverOutdated: versions,
401
+ },
402
+ );
403
+ };
404
+
405
+ type SendInit =
406
+ | { readonly method: 'GET' }
407
+ | { readonly method: 'POST'; readonly body: unknown };
408
+
409
+ // GETs on this API carry no body; POSTs carry a JSON one with `bigint` amounts
410
+ // rendered as decimal strings.
411
+ const requestInit = (
412
+ baseHeaders: Record<string, string>,
413
+ init: SendInit,
414
+ ): RequestInit =>
415
+ init.method === 'GET'
416
+ ? { method: 'GET', headers: { ...baseHeaders } }
417
+ : {
418
+ method: 'POST',
419
+ headers: { ...baseHeaders, 'Content-Type': 'application/json' },
420
+ body: JSON.stringify(init.body, bigintReplacer),
421
+ };
422
+
423
+ // One fetch path for every route: maps transport failures to `NETWORK_ERROR`.
424
+ // The response version check is not here — only the compile-family POSTs gate
425
+ // on it (see `postCompile`); discovery GETs and `/simulate` are ungated.
426
+ const send = async (
314
427
  { fetchFn, baseHeaders }: ComposeTransport,
315
428
  url: string,
316
- request: ComposeCompileRequest | ComposeRouteRequest,
317
- ): Promise<ComposeCompileResult> => {
318
- let res: Response;
429
+ init: SendInit,
430
+ ): Promise<Response> => {
319
431
  try {
320
- res = await fetchFn(url, {
321
- method: 'POST',
322
- headers: { ...baseHeaders, 'Content-Type': 'application/json' },
323
- body: JSON.stringify(request, bigintReplacer),
324
- });
432
+ return await fetchFn(url, requestInit(baseHeaders, init));
325
433
  } catch (err) {
326
434
  const message = err instanceof Error ? err.message : String(err);
327
435
  throw new ComposeError('NETWORK_ERROR', message, { cause: err });
328
436
  }
437
+ };
438
+
439
+ const postCompile = async (
440
+ transport: ComposeTransport,
441
+ url: string,
442
+ request: ComposeCompileRequest | ComposeRouteRequest,
443
+ ): Promise<ComposeCompileResult> => {
444
+ const res = await send(transport, url, { method: 'POST', body: request });
445
+ assertServerVersion(res, url);
329
446
  if (res.status === 206) {
330
447
  return await parsePartialBody(res, url);
331
448
  }
@@ -339,19 +456,10 @@ const postCompile = async (
339
456
  // Every enveloped GET on this API behaves identically: no body, no query
340
457
  // semantics of its own, `{ data }` on success, an error envelope otherwise.
341
458
  const getJson = async <T>(
342
- { fetchFn, baseHeaders }: ComposeTransport,
459
+ transport: ComposeTransport,
343
460
  url: string,
344
461
  ): Promise<T> => {
345
- let res: Response;
346
- try {
347
- res = await fetchFn(url, {
348
- method: 'GET',
349
- headers: { ...baseHeaders },
350
- });
351
- } catch (err) {
352
- const message = err instanceof Error ? err.message : String(err);
353
- throw new ComposeError('NETWORK_ERROR', message, { cause: err });
354
- }
462
+ const res = await send(transport, url, { method: 'GET' });
355
463
  if (!res.ok) {
356
464
  const body = await res.text();
357
465
  throw errorFromHttpResponse(res.status, body, url);
@@ -386,7 +494,7 @@ export const createComposeClient = (
386
494
 
387
495
  const baseHeaders: Record<string, string> = {
388
496
  Accept: 'application/json',
389
- 'x-lifi-composer-sdk': SDK_VERSION,
497
+ [COMPOSER_SDK_VERSION_HEADER]: SDK_VERSION,
390
498
  'x-lifi-api-key': trimmedApiKey,
391
499
  };
392
500
  const transport: ComposeTransport = { fetchFn, baseHeaders };
@@ -428,17 +536,7 @@ export const createComposeClient = (
428
536
  request: SimulateRequest,
429
537
  ): Promise<SimulateResult> => {
430
538
  const url = `${base}/simulate`;
431
- let res: Response;
432
- try {
433
- res = await fetchFn(url, {
434
- method: 'POST',
435
- headers: { ...baseHeaders, 'Content-Type': 'application/json' },
436
- body: JSON.stringify(request, bigintReplacer),
437
- });
438
- } catch (err) {
439
- const message = err instanceof Error ? err.message : String(err);
440
- throw new ComposeError('NETWORK_ERROR', message, { cause: err });
441
- }
539
+ const res = await send(transport, url, { method: 'POST', body: request });
442
540
  // Only 200 (carries `ok`/`revert`) and 422 (carries the `error` member of
443
541
  // the union) have a discriminated body. 422 is deliberately intercepted
444
542
  // here (not thrown as VALIDATION_ERROR) so callers get one exhaustive
package/src/errors.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import type {
2
2
  ComposeErrorKind,
3
+ ComposeRouteErrorKind,
3
4
  FailedPreparedOp,
4
5
  GenericComposeErrorKind,
5
6
  SimulationRevert,
6
7
  } from '@lifi/compose-spec';
8
+ import z from 'zod';
7
9
 
8
10
  import {
9
11
  parseServerErrorBody,
@@ -32,6 +34,24 @@ export type ComposeErrorCode =
32
34
  | 'NOT_FOUND'
33
35
  | 'UNKNOWN_ERROR';
34
36
 
37
+ /** Version details returned when the backend rejects an outdated SDK. */
38
+ export interface ComposeSdkOutdated {
39
+ readonly sdkVersion: string;
40
+ readonly serverVersion: string;
41
+ readonly minimumSdkVersion: string;
42
+ }
43
+
44
+ /**
45
+ * Version details attached when the SDK is ahead of the backend it reached.
46
+ * Raised by the backend's version gate, or by the SDK itself when the
47
+ * `x-lifi-composer-version` response header names an older contract than the
48
+ * one this SDK was built against.
49
+ */
50
+ export interface ComposeServerOutdated {
51
+ readonly sdkVersion: string;
52
+ readonly serverVersion: string;
53
+ }
54
+
35
55
  /**
36
56
  * Error class for all failures originating from the Compose SDK or API.
37
57
  *
@@ -58,7 +78,7 @@ export class ComposeError extends Error {
58
78
  /** The request URL that produced the error. */
59
79
  readonly url?: string;
60
80
  /** Server-provided error kind for finer-grained classification. */
61
- readonly kind?: ComposeErrorKind;
81
+ readonly kind?: ComposeErrorKind | ComposeRouteErrorKind;
62
82
  /** JSON-pointer path to the field that caused a validation error. */
63
83
  readonly path?: string;
64
84
  /**
@@ -78,6 +98,10 @@ export class ComposeError extends Error {
78
98
  * `preparation_error` errors alongside {@link ComposeError.failedOps}.
79
99
  */
80
100
  readonly succeededOps?: readonly string[];
101
+ /** Version details attached only to `sdk_outdated` errors. */
102
+ readonly sdkOutdated?: ComposeSdkOutdated;
103
+ /** Version details attached only to `server_outdated` errors. */
104
+ readonly serverOutdated?: ComposeServerOutdated;
81
105
 
82
106
  constructor(
83
107
  code: ComposeErrorCode,
@@ -86,11 +110,13 @@ export class ComposeError extends Error {
86
110
  status?: number;
87
111
  url?: string;
88
112
  cause?: unknown;
89
- kind?: ComposeErrorKind;
113
+ kind?: ComposeErrorKind | ComposeRouteErrorKind;
90
114
  path?: string;
91
115
  details?: SimulationRevert;
92
116
  failedOps?: readonly FailedPreparedOp[];
93
117
  succeededOps?: readonly string[];
118
+ sdkOutdated?: ComposeSdkOutdated;
119
+ serverOutdated?: ComposeServerOutdated;
94
120
  },
95
121
  ) {
96
122
  super(message, { cause: options?.cause });
@@ -102,6 +128,8 @@ export class ComposeError extends Error {
102
128
  this.details = options?.details;
103
129
  this.failedOps = options?.failedOps;
104
130
  this.succeededOps = options?.succeededOps;
131
+ this.sdkOutdated = options?.sdkOutdated;
132
+ this.serverOutdated = options?.serverOutdated;
105
133
  }
106
134
  }
107
135
 
@@ -153,6 +181,69 @@ export const isComposePreparationError = (
153
181
  Array.isArray(e.failedOps) &&
154
182
  Array.isArray(e.succeededOps);
155
183
 
184
+ // `isComposeError` accepts a structurally-similar error by name, so an error
185
+ // raised by a *second* copy of this package in the dependency tree reaches the
186
+ // guard below without its `sdkOutdated` field being type-guaranteed. Parse it.
187
+ const sdkOutdatedSchema = z.object({
188
+ sdkVersion: z.string(),
189
+ serverVersion: z.string(),
190
+ minimumSdkVersion: z.string(),
191
+ });
192
+
193
+ /**
194
+ * Narrows an outdated-SDK rejection to its required version details.
195
+ *
196
+ * @example
197
+ * ```ts
198
+ * try {
199
+ * await builder.compile(run);
200
+ * } catch (e) {
201
+ * if (isComposeSdkOutdatedError(e)) {
202
+ * console.error(`Upgrade to at least ${e.sdkOutdated.minimumSdkVersion}`);
203
+ * return;
204
+ * }
205
+ * throw e;
206
+ * }
207
+ * ```
208
+ */
209
+ export const isComposeSdkOutdatedError = (
210
+ e: unknown,
211
+ ): e is ComposeError & { readonly sdkOutdated: ComposeSdkOutdated } =>
212
+ isComposeError(e) &&
213
+ e.kind === 'sdk_outdated' &&
214
+ sdkOutdatedSchema.safeParse(e.sdkOutdated).success;
215
+
216
+ const serverOutdatedSchema = z.object({
217
+ sdkVersion: z.string(),
218
+ serverVersion: z.string(),
219
+ });
220
+
221
+ /**
222
+ * Narrows a server-behind-SDK rejection to its required version details.
223
+ *
224
+ * This is the deploy-window error: the SDK on npm is ahead of the backend that
225
+ * answered. Pin the SDK to the server's `major.minor`, or wait for the rollout.
226
+ *
227
+ * @example
228
+ * ```ts
229
+ * try {
230
+ * await builder.compile(run);
231
+ * } catch (e) {
232
+ * if (isComposeServerOutdatedError(e)) {
233
+ * console.error(`Server serves ${e.serverOutdated.serverVersion}`);
234
+ * return;
235
+ * }
236
+ * throw e;
237
+ * }
238
+ * ```
239
+ */
240
+ export const isComposeServerOutdatedError = (
241
+ e: unknown,
242
+ ): e is ComposeError & { readonly serverOutdated: ComposeServerOutdated } =>
243
+ isComposeError(e) &&
244
+ e.kind === 'server_outdated' &&
245
+ serverOutdatedSchema.safeParse(e.serverOutdated).success;
246
+
156
247
  const STATUS_TO_CODE: ReadonlyMap<number, ComposeErrorCode> = new Map<
157
248
  number,
158
249
  ComposeErrorCode
@@ -175,6 +266,24 @@ const tryParseErrorBody = (body: string): ServerErrorBody | null => {
175
266
  return parseServerErrorBody(json);
176
267
  };
177
268
 
269
+ /**
270
+ * Extracts the version triple an `sdk_outdated` or `server_outdated` envelope
271
+ * carries. The slots reuse the same schemas the type guards narrow with. Zod
272
+ * strips keys the schema does not declare, so the parsed value is exactly the
273
+ * triple; a partial payload fails the parse and yields `undefined` rather than
274
+ * a half-populated object.
275
+ */
276
+ const readVersionSlots = (serverError: ServerErrorBody['error']) => ({
277
+ sdkOutdated:
278
+ serverError?.kind === 'sdk_outdated'
279
+ ? sdkOutdatedSchema.safeParse(serverError).data
280
+ : undefined,
281
+ serverOutdated:
282
+ serverError?.kind === 'server_outdated'
283
+ ? serverOutdatedSchema.safeParse(serverError).data
284
+ : undefined,
285
+ });
286
+
178
287
  /**
179
288
  * Constructs a {@link ComposeError} from an HTTP error response, extracting
180
289
  * structured error details from the response body when available.
@@ -191,6 +300,7 @@ export const errorFromHttpResponse = (
191
300
  ): ComposeError => {
192
301
  const parsed = tryParseErrorBody(body);
193
302
  const serverError = parsed?.error;
303
+ const { sdkOutdated, serverOutdated } = readVersionSlots(serverError);
194
304
 
195
305
  return new ComposeError(
196
306
  STATUS_TO_CODE.get(status) ??
@@ -199,7 +309,8 @@ export const errorFromHttpResponse = (
199
309
  {
200
310
  status,
201
311
  url,
202
- kind: serverError?.kind as ComposeErrorKind | undefined,
312
+ kind: serverError?.kind as
313
+ ComposeErrorKind | ComposeRouteErrorKind | undefined,
203
314
  path: serverError?.path,
204
315
  details: serverError?.details,
205
316
  // The wire schema types `kind` as a bare string; the server only ever
@@ -209,6 +320,8 @@ export const errorFromHttpResponse = (
209
320
  kind: op.kind as GenericComposeErrorKind,
210
321
  })),
211
322
  succeededOps: serverError?.succeededOps,
323
+ sdkOutdated,
324
+ serverOutdated,
212
325
  },
213
326
  );
214
327
  };
@@ -54,14 +54,14 @@ const WETH = '0x4200000000000000000000000000000000000006';
54
54
  * basis points, not percent.
55
55
  *
56
56
  * Widening it is safe but not free. The bottom of the band is what the compose
57
- * response publishes as `amountOutMin`, so a wider band advertises a weaker
57
+ * response publishes as the output's `amount.minimum`, so a wider band
58
58
  * minimum to whoever consumes your quote.
59
59
  *
60
60
  * ## Ignore the 50% slippage in the Fly calldata
61
61
  *
62
62
  * If you inspect the compiled transaction you will see a 50% slippage figure
63
63
  * where you configured 1%. That is deliberate and it is not your setting. All
64
- * Fly's own `slippage` parameter does is price its `amountOutMin`, which is an
64
+ * Fly's own `slippage` parameter does is price its minimum-out floor, which is an
65
65
  * absolute amount fixed at quote time and therefore cannot scale down when a
66
66
  * legitimately smaller input arrives. The op sends a high value so that floor
67
67
  * can never be the binding constraint, and enforces its own rate-based bound
@@ -137,7 +137,7 @@ export const buildFlySwapExample = (): {
137
137
  //
138
138
  // No slippage guard here, and none is accepted: `amountOut` declares
139
139
  // `providesMinimum`, so the op owns its own bound. Attaching one is a
140
- // `guard_error`. The published `amountOutMin` is the op's, derived from the
140
+ // `guard_error`. The published `amount.minimum` is the op's, derived from the
141
141
  // bottom of the input band.
142
142
  builder.fly.swap('swapAllToWeth', {
143
143
  bind: { amountIn: exitPt.amountOut },
@@ -21,15 +21,16 @@ export interface LifiZapAsyncInput {
21
21
  * - `lifi.zapAsync`, the future-availability sibling of `lifi.zap`
22
22
  * - Binding the required `recipient` input (the async op has no optional
23
23
  * recipient — the decl is non-optional, so `bind.recipient` is mandatory)
24
- * - Omitting `sweepTo`: `availability: 'future'` governs custody, so the
24
+ * - Omitting `sweepTo`: a future delivery governs custody, so the
25
25
  * output is delivered externally and never enters the execution VM — a
26
26
  * sweep would find nothing to move (custody, not amount reporting)
27
- * - Amount reporting is unaffected by availability: when the async lowering
28
- * binds a terminal handle the output still reports `simulated.amountOut`,
29
- * the protocol's in-transaction expected value (an estimate; the delivered
30
- * amount arrives after settlement). `simulated.amountOutMin` appears only
31
- * when a minimum source exists — for a future output that means a floor the
32
- * edge lowering itself reported, never a user guard
27
+ * - Amount reporting is unaffected by delivery timing: when the async lowering
28
+ * binds a terminal handle the output still reports an `amount.estimate`
29
+ * (`basis: 'simulated'`), the protocol's in-transaction expected value (the
30
+ * delivered amount arrives after settlement, when `delivery.when` is
31
+ * `'future'`). An `amount.minimum` appears only when a minimum source
32
+ * exists — for a future output that means a floor the edge lowering itself
33
+ * reported, never a user guard
33
34
  * - No output guards: the reported value is the initiating-transaction
34
35
  * estimate, not the asynchronously delivered amount a guard could enforce,
35
36
  * so `amountOut` is excluded from the generated `TypedGuard` union
@@ -43,6 +43,7 @@ import type {
43
43
  ComposeRouteRequest,
44
44
  } from '@lifi/compose-spec';
45
45
 
46
+ import { bigintReplacer } from '../client.js';
46
47
  import { type ComposeSdk, createComposeSdk } from '../sdk.js';
47
48
 
48
49
  import { buildAaveRepay } from './aaveRepay.js';
@@ -76,6 +77,9 @@ import { buildSwapWithOutputValidation } from './swapWithOutputValidation.js';
76
77
  import { buildPartialTransfer, buildTransferTokens } from './transferTokens.js';
77
78
  import { buildUntypedOpWithTypedRef } from './untypedOpWithTypedRef.js';
78
79
 
80
+ const stringifyJson = (value: unknown) =>
81
+ JSON.stringify(value, bigintReplacer, 2);
82
+
79
83
  const EXAMPLES: Record<string, () => ComposeCompileRequest> = {
80
84
  'aave-repay': () =>
81
85
  buildAaveRepay({
@@ -233,14 +237,7 @@ const run = async () => {
233
237
  const request = routeBuilder ? routeBuilder() : builder!();
234
238
 
235
239
  console.log('--- Request ---');
236
- console.log(
237
- JSON.stringify(
238
- request,
239
- (_key, value: unknown) =>
240
- typeof value === 'bigint' ? value.toString() : value,
241
- 2,
242
- ),
243
- );
240
+ console.log(stringifyJson(request));
244
241
 
245
242
  console.log(`\n--- Compiling against ${BASE_URL} ---`);
246
243
  const result = routeBuilder
@@ -248,7 +245,7 @@ const run = async () => {
248
245
  : await sdk.client.compile(request as ComposeCompileRequest);
249
246
 
250
247
  console.log('\n--- Result ---');
251
- console.log(JSON.stringify(result, null, 2));
248
+ console.log(stringifyJson(result));
252
249
  };
253
250
 
254
251
  run().catch((err: unknown) => {
@@ -20,6 +20,13 @@ const A_ETH_USDC = '0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5c';
20
20
  * - Chaining a swap into a zap by threading output handles
21
21
  * - The swap's amountOut feeds directly into the zap's amountIn
22
22
  * - Slippage guard on the zap output (swap output has providesMinimum)
23
+ *
24
+ * Reading the result of compiling this flow:
25
+ * - `result.outputs['swap.amountOut']` has `role: 'consumed'` (the zap consumes
26
+ * it); `consumedBy` is `'zap'` and `amount.minimum` is the swap floor the
27
+ * router enforces on the intermediate USDC.
28
+ * - `result.outputs['zap.amountOut']` has `role: 'terminal'`; its
29
+ * `amount.minimum` carries the guard floor set above.
23
30
  */
24
31
  export const buildSwapAndZapExample = (): {
25
32
  flow: Flow;