@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.
- package/README.md +13 -3
- package/dist/authoring/FlowBuilderCore.cjs.map +1 -1
- package/dist/authoring/FlowBuilderCore.d.cts +11 -4
- package/dist/authoring/FlowBuilderCore.d.ts +11 -4
- package/dist/authoring/FlowBuilderCore.js.map +1 -1
- package/dist/authoring/handles.cjs.map +1 -1
- package/dist/authoring/handles.d.cts +2 -2
- package/dist/authoring/handles.d.ts +2 -2
- package/dist/authoring/handles.js.map +1 -1
- package/dist/authoring/raw.cjs +5 -3
- package/dist/authoring/raw.cjs.map +1 -1
- package/dist/authoring/raw.js +5 -3
- package/dist/authoring/raw.js.map +1 -1
- package/dist/client.cjs +94 -60
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +12 -4
- package/dist/client.d.ts +12 -4
- package/dist/client.js +98 -60
- package/dist/client.js.map +1 -1
- package/dist/errors.cjs +42 -3
- package/dist/errors.cjs.map +1 -1
- package/dist/errors.d.cts +67 -4
- package/dist/errors.d.ts +67 -4
- package/dist/errors.js +29 -2
- package/dist/errors.js.map +1 -1
- package/dist/generated/config.generated.cjs.map +1 -1
- package/dist/generated/config.generated.d.cts +46 -5
- package/dist/generated/config.generated.d.ts +46 -5
- package/dist/generated/operations.generated.cjs +4 -0
- package/dist/generated/operations.generated.cjs.map +1 -1
- package/dist/generated/operations.generated.d.cts +16 -2
- package/dist/generated/operations.generated.d.ts +16 -2
- package/dist/generated/operations.generated.js +4 -0
- package/dist/generated/operations.generated.js.map +1 -1
- package/dist/index.cjs +8 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/outputs.cjs +33 -0
- package/dist/outputs.cjs.map +1 -0
- package/dist/outputs.d.cts +10 -0
- package/dist/outputs.d.ts +10 -0
- package/dist/outputs.js +9 -0
- package/dist/outputs.js.map +1 -0
- package/dist/responseSchemas.cjs +3 -0
- package/dist/responseSchemas.cjs.map +1 -1
- package/dist/responseSchemas.d.cts +7 -4
- package/dist/responseSchemas.d.ts +7 -4
- package/dist/responseSchemas.js +3 -0
- package/dist/responseSchemas.js.map +1 -1
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +16 -5
- package/dist/types.d.ts +16 -5
- package/package.json +4 -3
- package/src/authoring/FlowBuilderCore.ts +12 -3
- package/src/authoring/handles.ts +2 -1
- package/src/authoring/raw.ts +4 -3
- package/src/client.ts +177 -79
- package/src/errors.ts +116 -3
- package/src/examples/flySwap.ts +3 -3
- package/src/examples/lifiZapAsync.ts +8 -7
- package/src/examples/run.ts +6 -9
- package/src/examples/swapAndZap.ts +7 -0
- package/src/examples/swapWithAllowRevert.ts +13 -3
- package/src/generated/config.generated.ts +49 -4
- package/src/generated/operations.generated.ts +25 -0
- package/src/index.ts +21 -3
- package/src/outputs.ts +16 -0
- package/src/responseSchemas.ts +10 -4
- 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 `
|
|
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
|
|
119
|
-
* `
|
|
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
|
-
* `
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
207
|
-
// `
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
): Promise<
|
|
318
|
-
let res: Response;
|
|
429
|
+
init: SendInit,
|
|
430
|
+
): Promise<Response> => {
|
|
319
431
|
try {
|
|
320
|
-
|
|
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
|
-
|
|
459
|
+
transport: ComposeTransport,
|
|
343
460
|
url: string,
|
|
344
461
|
): Promise<T> => {
|
|
345
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
};
|
package/src/examples/flySwap.ts
CHANGED
|
@@ -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 `
|
|
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
|
|
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 `
|
|
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`:
|
|
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
|
|
28
|
-
* binds a terminal handle the output still reports `
|
|
29
|
-
* the protocol's in-transaction expected value (
|
|
30
|
-
* amount arrives after settlement
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
package/src/examples/run.ts
CHANGED
|
@@ -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(
|
|
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;
|