@gibs/quotes 1.13.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 (44) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +45 -0
  3. package/dist/cache.d.ts +73 -0
  4. package/dist/cache.js +137 -0
  5. package/dist/carriers/calibration.d.ts +115 -0
  6. package/dist/carriers/calibration.js +83 -0
  7. package/dist/carriers/lifi.d.ts +63 -0
  8. package/dist/carriers/lifi.js +217 -0
  9. package/dist/carriers/near-intents.d.ts +91 -0
  10. package/dist/carriers/near-intents.js +249 -0
  11. package/dist/carriers/price-catalogue.d.ts +186 -0
  12. package/dist/carriers/price-catalogue.js +191 -0
  13. package/dist/carriers/relay.d.ts +97 -0
  14. package/dist/carriers/relay.js +242 -0
  15. package/dist/chain-lists.d.ts +144 -0
  16. package/dist/chain-lists.js +169 -0
  17. package/dist/client.d.ts +102 -0
  18. package/dist/client.js +402 -0
  19. package/dist/index.d.ts +19 -0
  20. package/dist/index.js +18 -0
  21. package/dist/quote-guards.d.ts +128 -0
  22. package/dist/quote-guards.js +240 -0
  23. package/dist/quote-refusals.d.ts +180 -0
  24. package/dist/quote-refusals.js +284 -0
  25. package/dist/quote-service-contract.d.ts +2240 -0
  26. package/dist/quote-service-contract.js +247 -0
  27. package/dist/search/capabilities.d.ts +68 -0
  28. package/dist/search/capabilities.js +28 -0
  29. package/dist/search/enumerate.d.ts +162 -0
  30. package/dist/search/enumerate.js +313 -0
  31. package/dist/search/index.d.ts +17 -0
  32. package/dist/search/index.js +17 -0
  33. package/dist/search/serve.d.ts +178 -0
  34. package/dist/search/serve.js +142 -0
  35. package/dist/search/waves.d.ts +481 -0
  36. package/dist/search/waves.js +939 -0
  37. package/dist/single-flight.d.ts +90 -0
  38. package/dist/single-flight.js +142 -0
  39. package/dist/types.d.ts +502 -0
  40. package/dist/types.js +1 -0
  41. package/dist/upstream.d.ts +126 -0
  42. package/dist/upstream.js +343 -0
  43. package/llms.txt +217 -0
  44. package/package.json +142 -0
@@ -0,0 +1,343 @@
1
+ import { isLifiInvalidApiKeyResponse } from './quote-refusals.js';
2
+ import { ConcurrencyDeadlineExceededError, createKeyedConcurrencyLimiter, createRetryAfterCooldown, } from './quote-guards.js';
3
+ /**
4
+ * The single choke point every outbound network call a {@link Carrier} makes
5
+ * passes through — see `docs/quote-service.md`'s "Carriers" section for the
6
+ * narrative and `types.ts`'s {@link UpstreamFunnel} for the contract this
7
+ * module implements.
8
+ *
9
+ * TWO LAYERS, BECAUSE `Carrier.ask` HIDES THE WIRE. `UpstreamFunnel.wrap`
10
+ * only sees a {@link CarrierAskRequest} (an edge and an amount) and a
11
+ * {@link CarrierAskAnswer} (delivered/refusal) — never a URL, a request body,
12
+ * or a response header, because that is exactly what makes `Carrier` usable
13
+ * for a free local read as well as a network call. The guards this module
14
+ * must enforce — the Retry-After cooldown, the 401-code-1010 breaker, and
15
+ * the chain tripwire's URL/body scan — all need that wire-level view. So
16
+ * this module builds it in two pieces:
17
+ *
18
+ * - {@link createUpstreamFunnel}'s `wrap` applies the STRUCTURAL guards every
19
+ * `Carrier.ask` call can be judged on without touching the network: the
20
+ * chain-id tripwire read off the edge itself, the per-carrier concurrency
21
+ * cap, and refusing locally while a carrier's cooldown is active.
22
+ * - Its `guardedFetch` is the WIRE-level guard: every built-in carrier
23
+ * (`carriers/lifi.ts`, `carriers/relay.ts`, `carriers/near-intents.ts`)
24
+ * must call it — bound to its own {@link CarrierId} — instead of calling
25
+ * `fetch` directly. It re-runs the chain tripwire against the actual
26
+ * outgoing URL and body (the backstop `docs/quote-service.md` calls for:
27
+ * "rejects any 369/943 in the address or body"), records a `429`'s
28
+ * `Retry-After` into the SAME cooldown `wrap` reads, and — for `'lifi'`
29
+ * only — arms the five-minute breaker on a `401` carrying LI.FI's
30
+ * `code: 1010`.
31
+ *
32
+ * A caller wires the two together: build the funnel first, construct each
33
+ * carrier with `(input, init) => funnel.guardedFetch(carrierId, input, init)`
34
+ * as its `fetchImpl`, THEN call `funnel.wrap(carrier)` on the result. Skipping
35
+ * either half leaves a real gap — `wrap` alone never sees a 429 to cool down
36
+ * from, and `guardedFetch` alone never caps concurrency.
37
+ */
38
+ // ---------------------------------------------------------------------------
39
+ // Defaults
40
+ // ---------------------------------------------------------------------------
41
+ /** Every built-in carrier's own concurrency cap, unless `concurrencyByCarrier` overrides it. */
42
+ export const DEFAULT_CARRIER_CONCURRENCY = 4;
43
+ /** NEAR Intents' own default concurrency cap — see `docs/quote-service.md`'s "Guards" section: "4, NEAR 3." */
44
+ export const NEAR_INTENTS_CONCURRENCY = 3;
45
+ /** Per-provider defaults, read when `concurrencyByCarrier` names no override for a carrier. */
46
+ export const DEFAULT_CONCURRENCY_BY_CARRIER = {
47
+ lifi: DEFAULT_CARRIER_CONCURRENCY,
48
+ relay: DEFAULT_CARRIER_CONCURRENCY,
49
+ 'near-intents': NEAR_INTENTS_CONCURRENCY,
50
+ };
51
+ /** How long the LI.FI carrier is refused after a `401` carrying `code: 1010` — five minutes, per `docs/quote-service.md`'s "Cache" section. */
52
+ export const LIFI_INVALID_API_KEY_BREAKER_MS = 5 * 60_000;
53
+ // ---------------------------------------------------------------------------
54
+ // Errors
55
+ // ---------------------------------------------------------------------------
56
+ /**
57
+ * Thrown by {@link GuardedFetch} BEFORE any network call, when the outgoing
58
+ * request names a chain id the host listed in `neverSendChainIds` — either in
59
+ * a query parameter or inside the JSON body, under one of the structured
60
+ * chain-id field names this module reads (never a raw string search over the
61
+ * whole request, which would also match an amount or an address that merely
62
+ * CONTAINS the digits).
63
+ */
64
+ export class NeverSendChainIdError extends Error {
65
+ chainId;
66
+ carrierId;
67
+ constructor(chainId, carrierId) {
68
+ super(`refusing to ask "${carrierId}" about chain ${chainId}: it is on this host's neverSendChainIds`);
69
+ this.chainId = chainId;
70
+ this.carrierId = carrierId;
71
+ this.name = 'NeverSendChainIdError';
72
+ }
73
+ }
74
+ /**
75
+ * Thrown by {@link GuardedFetch} when a configured provider key would
76
+ * otherwise appear in the outgoing URL or body — `docs/quote-service.md`'s
77
+ * rule, "keys go only into headers, never URLs or bodies, never logs,"
78
+ * enforced rather than merely documented.
79
+ */
80
+ export class KeyLeakError extends Error {
81
+ carrierId;
82
+ constructor(carrierId) {
83
+ super(`refusing to send "${carrierId}"'s request: a configured key appears in the URL or body`);
84
+ this.carrierId = carrierId;
85
+ this.name = 'KeyLeakError';
86
+ }
87
+ }
88
+ /**
89
+ * Thrown by {@link GuardedFetch} for `'lifi'` while its five-minute breaker
90
+ * is active — see {@link LIFI_INVALID_API_KEY_BREAKER_MS}. Never thrown for
91
+ * any other carrier; only LI.FI answers a rejected key with a 401 this
92
+ * module can classify (`isLifiInvalidApiKeyResponse`).
93
+ */
94
+ export class LifiApiKeyBreakerActiveError extends Error {
95
+ constructor() {
96
+ super('refusing to ask "lifi": the configured api key was rejected within the last five minutes');
97
+ this.name = 'LifiApiKeyBreakerActiveError';
98
+ }
99
+ }
100
+ // ---------------------------------------------------------------------------
101
+ // The chain-id tripwire — structured fields only, never a raw string search
102
+ // ---------------------------------------------------------------------------
103
+ /** Query-parameter names a provider's `GET` request names a chain id under. */
104
+ const CHAIN_ID_QUERY_PARAM_NAMES = new Set([
105
+ 'fromChain',
106
+ 'toChain',
107
+ 'originChainId',
108
+ 'destinationChainId',
109
+ 'chainId',
110
+ ]);
111
+ /** JSON body key names a provider's request names a chain id under. */
112
+ const CHAIN_ID_BODY_KEY_NAMES = new Set([
113
+ 'fromChainId',
114
+ 'toChainId',
115
+ 'originChainId',
116
+ 'destinationChainId',
117
+ 'chainId',
118
+ ]);
119
+ /**
120
+ * Reads every value found under a known chain-id query-parameter name.
121
+ *
122
+ * @param url - the outgoing request's URL
123
+ * @returns every chain id the query string structurally names
124
+ */
125
+ const chainIdsFromQuery = (url) => {
126
+ const found = [];
127
+ for (const [key, value] of url.searchParams) {
128
+ if (!CHAIN_ID_QUERY_PARAM_NAMES.has(key))
129
+ continue;
130
+ const parsed = Number(value);
131
+ if (Number.isFinite(parsed))
132
+ found.push(parsed);
133
+ }
134
+ return found;
135
+ };
136
+ /**
137
+ * Recursively reads every value found under a known chain-id JSON body key,
138
+ * at any depth. Every OTHER field — an amount, an address, a slippage
139
+ * fraction — is walked past without being read, which is what keeps an
140
+ * amount that happens to contain a guarded chain id's digits (`"369"` inside
141
+ * `"36900000"`) from ever being mistaken for one.
142
+ *
143
+ * @param value - the parsed JSON body, or a nested part of it
144
+ * @param found - collects every chain id found, by mutation
145
+ */
146
+ const collectChainIdsFromBody = (value, found) => {
147
+ if (Array.isArray(value)) {
148
+ for (const entry of value)
149
+ collectChainIdsFromBody(entry, found);
150
+ return;
151
+ }
152
+ if (typeof value !== 'object' || value === null)
153
+ return;
154
+ for (const [key, entryValue] of Object.entries(value)) {
155
+ if (CHAIN_ID_BODY_KEY_NAMES.has(key)) {
156
+ if (typeof entryValue === 'number' && Number.isFinite(entryValue)) {
157
+ found.push(entryValue);
158
+ }
159
+ else if (typeof entryValue === 'string' && /^\d+$/.test(entryValue)) {
160
+ found.push(Number(entryValue));
161
+ }
162
+ }
163
+ collectChainIdsFromBody(entryValue, found);
164
+ }
165
+ };
166
+ /**
167
+ * Every chain id a request structurally names, from its URL and its JSON
168
+ * body — the set {@link GuardedFetch} checks against `neverSendChainIds`
169
+ * before ever calling `fetch`.
170
+ *
171
+ * @param url - the outgoing request's URL
172
+ * @param body - the outgoing request's raw body, when it has one
173
+ * @returns every chain id named in a recognized field
174
+ */
175
+ const namedChainIds = (url, body) => {
176
+ const found = new Set(chainIdsFromQuery(url));
177
+ if (body === undefined)
178
+ return found;
179
+ let parsedBody;
180
+ try {
181
+ parsedBody = JSON.parse(body);
182
+ }
183
+ catch {
184
+ // Not a JSON body — nothing structured left to read.
185
+ return found;
186
+ }
187
+ const collected = [];
188
+ collectChainIdsFromBody(parsedBody, collected);
189
+ for (const id of collected)
190
+ found.add(id);
191
+ return found;
192
+ };
193
+ const buildGuardedFetch = (options) => {
194
+ const { neverSendChainIds, keys, retryAfterCooldown, lifiKeyBreaker, fetchImpl, now, onRateLimited } = options;
195
+ return async (carrierId, input, init) => {
196
+ const url = new URL(typeof input === 'string' ? input : input.toString());
197
+ const body = typeof init?.body === 'string' ? init.body : undefined;
198
+ for (const chainId of namedChainIds(url, body)) {
199
+ if (neverSendChainIds.has(chainId)) {
200
+ throw new NeverSendChainIdError(chainId, carrierId);
201
+ }
202
+ }
203
+ const urlAndBody = `${url.toString()}\u0000${body ?? ''}`;
204
+ for (const key of keys) {
205
+ if (key !== '' && urlAndBody.includes(key)) {
206
+ throw new KeyLeakError(carrierId);
207
+ }
208
+ }
209
+ if (carrierId === 'lifi' && lifiKeyBreaker.isActive('lifi', now())) {
210
+ throw new LifiApiKeyBreakerActiveError();
211
+ }
212
+ const response = await fetchImpl(url.toString(), init);
213
+ if (response.status === 429) {
214
+ retryAfterCooldown.record(carrierId, response.headers.get('retry-after'), now());
215
+ onRateLimited();
216
+ }
217
+ if (carrierId === 'lifi' && response.status === 401) {
218
+ const body401 = await response
219
+ .clone()
220
+ .json()
221
+ .catch(() => null);
222
+ if (isLifiInvalidApiKeyResponse(response.status, body401)) {
223
+ // Logged loudly by the host — this module only classifies and arms
224
+ // the breaker; see `quote-refusals.ts`'s own note on why a rejected
225
+ // key is never silently absorbed here.
226
+ lifiKeyBreaker.record('lifi', null, now());
227
+ }
228
+ }
229
+ return response;
230
+ };
231
+ };
232
+ // ---------------------------------------------------------------------------
233
+ // wrap — the structural half every Carrier.ask goes through
234
+ // ---------------------------------------------------------------------------
235
+ /**
236
+ * The chain-id tripwire's STRUCTURAL half, read straight off the edge a
237
+ * carrier was asked about — applies to EVERY carrier, rate-limited or not,
238
+ * per `types.ts`'s own note: "a local read that happened to be pointed at a
239
+ * guarded chain is exactly as dangerous as a network call to one." This runs
240
+ * BEFORE the carrier's own `ask`, so a free local read never even starts
241
+ * when its edge names a guarded chain.
242
+ *
243
+ * @param ask - the carrier's own `ask`
244
+ * @param carrierId - the carrier's own id, for the refusal's `reason`
245
+ * @param neverSendChainIds - the host's guarded chain ids
246
+ * @returns a wrapped `ask` that refuses `cannot-carry` before calling through
247
+ */
248
+ const chainGuardedAsk = (ask, carrierId, neverSendChainIds) => {
249
+ return async (request) => {
250
+ const guardedChainId = [request.edge.from.chainId, request.edge.to.chainId].find((chainId) => neverSendChainIds.has(chainId));
251
+ if (guardedChainId !== undefined) {
252
+ return {
253
+ ok: false,
254
+ refusal: {
255
+ kind: 'cannot-carry',
256
+ reason: `chain ${guardedChainId} is on this host's neverSendChainIds`,
257
+ minimumAmount: null,
258
+ },
259
+ };
260
+ }
261
+ return ask(request);
262
+ };
263
+ };
264
+ /**
265
+ * Builds the funnel every carrier's `ask` — built-in or host-registered —
266
+ * should be wrapped through, and the wire-level {@link GuardedFetch} every
267
+ * built-in carrier's own HTTP call should go through. See this module's
268
+ * head note for how the two fit together.
269
+ *
270
+ * @param options - see {@link CreateUpstreamFunnelOptions}
271
+ * @returns the funnel
272
+ */
273
+ export const createUpstreamFunnel = (options = {}) => {
274
+ const neverSendChainIds = new Set(options.neverSendChainIds ?? []);
275
+ const keys = (options.keys ?? []).filter((key) => key !== '');
276
+ const concurrencyLimiter = createKeyedConcurrencyLimiter();
277
+ const retryAfterCooldown = createRetryAfterCooldown(options.defaultRetryAfterMs === undefined ? {} : { defaultMs: options.defaultRetryAfterMs });
278
+ const lifiKeyBreaker = createRetryAfterCooldown({ defaultMs: LIFI_INVALID_API_KEY_BREAKER_MS });
279
+ const fetchImpl = options.fetch ?? fetch;
280
+ const now = options.now ?? Date.now;
281
+ let rateLimited429Count = 0;
282
+ const guardedFetch = buildGuardedFetch({
283
+ neverSendChainIds,
284
+ keys,
285
+ retryAfterCooldown,
286
+ lifiKeyBreaker,
287
+ fetchImpl,
288
+ now,
289
+ onRateLimited: () => {
290
+ rateLimited429Count += 1;
291
+ },
292
+ });
293
+ const concurrencyLimitFor = (carrier) => options.concurrencyByCarrier?.[carrier.id] ??
294
+ carrier.concurrencyLimit ??
295
+ DEFAULT_CONCURRENCY_BY_CARRIER[carrier.id] ??
296
+ DEFAULT_CARRIER_CONCURRENCY;
297
+ return {
298
+ guardedFetch,
299
+ rateLimited429Count: () => rateLimited429Count,
300
+ queueDepth: () => concurrencyLimiter.queueDepth(),
301
+ wrap: (carrier) => {
302
+ const chainGuarded = chainGuardedAsk(carrier.ask, carrier.id, neverSendChainIds);
303
+ if (!carrier.isRateLimited) {
304
+ // A FREE CARRIER IS RETURNED UNCHANGED but for the chain guard —
305
+ // see `types.ts`'s own note on `UpstreamFunnel`.
306
+ return { ...carrier, ask: chainGuarded };
307
+ }
308
+ const limit = concurrencyLimitFor(carrier);
309
+ const guardedAsk = async (request) => {
310
+ const nowMs = now();
311
+ if (retryAfterCooldown.isActive(carrier.id, nowMs)) {
312
+ const clearsAtMs = retryAfterCooldown.clearsAtMs(carrier.id);
313
+ return {
314
+ ok: false,
315
+ refusal: {
316
+ kind: 'could-not-ask',
317
+ reason: clearsAtMs === null
318
+ ? 'cooling down after a rate limit'
319
+ : `cooling down after a rate limit until ${new Date(clearsAtMs).toISOString()}`,
320
+ minimumAmount: null,
321
+ },
322
+ };
323
+ }
324
+ try {
325
+ return await concurrencyLimiter.run(carrier.id, limit, () => chainGuarded(request), {
326
+ now,
327
+ ...(options.askDeadlineMs === undefined ? {} : { deadlineMs: nowMs + options.askDeadlineMs }),
328
+ });
329
+ }
330
+ catch (error) {
331
+ if (error instanceof ConcurrencyDeadlineExceededError) {
332
+ return {
333
+ ok: false,
334
+ refusal: { kind: 'could-not-ask', reason: 'busy', minimumAmount: null },
335
+ };
336
+ }
337
+ throw error;
338
+ }
339
+ };
340
+ return { ...carrier, ask: guardedAsk };
341
+ },
342
+ };
343
+ };
package/llms.txt ADDED
@@ -0,0 +1,217 @@
1
+ # Cross-Chain Quote Client
2
+
3
+ > An isomorphic TypeScript library that asks several cross-chain bridge
4
+ > aggregators for live quotes at once, merges the answers, and helps you
5
+ > route a transfer for the best delivered amount at the fewest wallet
6
+ > signatures. It runs unchanged in a browser tab or on a Node server. It
7
+ > carries no host name, no chain policy, and no secret of its own — you
8
+ > supply all three through one configuration object.
9
+
10
+ ## Quick orientation
11
+
12
+ - This package runs in three modes: **from your own backend** (with real
13
+ provider keys), **straight from a browser** (with no secret, or a public
14
+ identifier only), or **against your own hosted quote service, with a
15
+ browser fallback**. Pick one mode below and use its configuration shape.
16
+ - The wire contract below (`POST /v1/display-quotes`) is what a hosted
17
+ quote service and a direct-from-browser client both speak. If you run
18
+ your own service with the server-side pieces of this package, your
19
+ server and your browser client already agree on the same request and
20
+ response shapes.
21
+ - A short list of rules never bend. Read them before you build anything
22
+ that signs a transaction from a quote this library returns.
23
+
24
+ ## Install
25
+
26
+ Add the package as a dependency with your usual package manager, then
27
+ import from its root:
28
+
29
+ ```ts
30
+ import { createQuoteClient, type QuoteClientConfig } from '<this-package>'
31
+ ```
32
+
33
+ Replace `<this-package>` with the name you installed this under — you
34
+ already know it from your own `package.json` dependency entry.
35
+
36
+ ## Configuration: `QuoteClientConfig`
37
+
38
+ Every fact about who is asking — provider keys, allowed origins, chain
39
+ policy — lives in one object. Nothing else in this library carries a
40
+ default that assumes a particular deployment.
41
+
42
+ ```ts
43
+ type QuoteClientConfig = {
44
+ providers?: {
45
+ lifi?: { apiKey?: string; integrator?: string; baseUrl?: string; fee?: number }
46
+ relay?: { apiKey?: string; referrer?: string; baseUrl?: string }
47
+ nearIntents?: { apiKey?: string; referral?: string; baseUrl?: string }
48
+ }
49
+ serviceUrl?: string
50
+ fallback: 'direct' | 'none'
51
+ probeAddresses?: Partial<Record<Ecosystem, string>>
52
+ neverSendChainIds?: readonly number[]
53
+ cache?: { ttlMs?: number; maxEntries?: number }
54
+ limits?: { concurrencyByCarrier?: Record<string, number>; defaultRetryAfterMs?: number }
55
+ fetch?: typeof fetch
56
+ now?: () => number
57
+ }
58
+ ```
59
+
60
+ `fallback` is required on purpose. Every caller states what happens when
61
+ `serviceUrl` cannot be reached, instead of inheriting a silent default.
62
+
63
+ ## Mode 1: your own backend, with real secrets
64
+
65
+ Run this on a server you control. Provider keys stay on that server and
66
+ are never sent to a browser.
67
+
68
+ ```ts
69
+ const client = createQuoteClient(
70
+ {
71
+ providers: {
72
+ lifi: { apiKey: process.env.LIFI_API_KEY, integrator: 'your-app' },
73
+ relay: { apiKey: process.env.RELAY_API_KEY },
74
+ },
75
+ fallback: 'none',
76
+ neverSendChainIds: [/* any chain id your own bridge must never expose to an aggregator */],
77
+ },
78
+ { carriers: [/* built-in and/or your own Carrier implementations — see below */] },
79
+ )
80
+
81
+ const response = await client.displayQuotes({ asks: [/* ... */] })
82
+ ```
83
+
84
+ ## Mode 2: straight from the browser, no service
85
+
86
+ Send no secret to the browser. Omit every `providers.*.apiKey`, or send
87
+ only a public, rate-limit-budget identifier — the same way a browser-safe
88
+ value already works today. Leave `serviceUrl` unset.
89
+
90
+ ```ts
91
+ const client = createQuoteClient(
92
+ {
93
+ providers: { relay: { referrer: 'your-app.example' } },
94
+ fallback: 'none',
95
+ },
96
+ { carriers: [/* your carriers */] },
97
+ )
98
+ ```
99
+
100
+ Every request this client makes goes straight from the visitor's browser
101
+ to each provider.
102
+
103
+ ## Mode 3: your own hosted service, with a browser fallback
104
+
105
+ Point the browser at a service you run — built from this same package's
106
+ server-side pieces — and fall back to Mode 2 automatically when that
107
+ service cannot be reached.
108
+
109
+ ```ts
110
+ const client = createQuoteClient({
111
+ serviceUrl: 'https://quotes.your-app.example',
112
+ fallback: 'direct',
113
+ providers: { relay: { referrer: 'your-app.example' } }, // used only during a fallback
114
+ })
115
+ ```
116
+
117
+ `fallback: 'direct'` opens a 60-second breaker after one failed call to
118
+ `serviceUrl`, so a flapping service does not fail every single request on
119
+ its way down; `fallback: 'none'` instead answers every ask with a
120
+ `could-not-ask` refusal and never falls back to a direct call.
121
+
122
+ ## Wire contract: `POST /v1/display-quotes`
123
+
124
+ If you run your own service (Mode 3), have it answer this exact shape —
125
+ the browser client this package builds already speaks it.
126
+
127
+ Request body: `{ "asks": DisplayAsk[] }`, 1 to 24 asks, answered in order.
128
+
129
+ ```ts
130
+ type DisplayAsk = {
131
+ from: { chainId: number; token: string }
132
+ to: { chainId: number; token: string }
133
+ amount: string // decimal string, base units of from.token
134
+ recipientEcosystem?: 'evm' | 'svm' | 'bvm' | 'tvm' | 'tonvm' | 'hypevm' | 'zcash' // default: derived from to.chainId
135
+ providers?: ('lifi' | 'relay' | 'near-intents')[] // default: every eligible provider
136
+ precision?: 'estimate-ok' | 'exact' // default: 'estimate-ok'
137
+ }
138
+ ```
139
+
140
+ Response: `{ "answers": [{ "providers": { lifi?, relay?, "near-intents"? } }] }`,
141
+ one answer per ask, in ask order. Each provider slot is one of three
142
+ shapes:
143
+
144
+ - **`quoted`** — `basis: 'exact'` or `basis: 'scaled-from-nearby-amount'`,
145
+ plus `askedAmount`, `expectedAmount`, an optional `minimumAmount`,
146
+ `quotedAt`, `ageMs`, and `cache: 'miss' | 'hit' | 'joined'`. A
147
+ `scaled-from-nearby-amount` answer also carries `scaledFrom`: the real
148
+ amount and the real answer it was scaled from.
149
+ - **`refused`** — `kind: 'no-quote' | 'could-not-ask' | 'cannot-carry' | 'invalid-api-key'`,
150
+ a `reason` (or null), and `minimumAmount` when the provider named one.
151
+ - **`pending`** — the provider's own call is still running. Retry after
152
+ `retryAfterMs`. The call keeps running in the background and fills the
153
+ cache for the next ask that asks the same question.
154
+
155
+ `precision: 'exact'` asks for a genuine answer to the exact amount you
156
+ asked for. It never accepts a scaled answer — see "Rules that never
157
+ bend," below.
158
+
159
+ ## Route search: carriers
160
+
161
+ The route-search engine asks every registered *carrier* — a built-in
162
+ aggregator (LI.FI, Relay, NEAR Intents) or a local read you register
163
+ yourself (a direct on-chain swap, your own bridge contract) — what it can
164
+ deliver, then scores every combination it can build for the best
165
+ delivered amount at the fewest wallet signatures.
166
+
167
+ Register your own carrier by implementing the `Carrier` interface:
168
+
169
+ ```ts
170
+ const myOwnCarrier: Carrier = {
171
+ id: 'my-carrier',
172
+ edgesFrom: (from, to) => [/* every edge this carrier can move funds across right now */],
173
+ isRateLimited: false, // true for anything that calls a rate-limited network API
174
+ ask: async ({ edge, inputAmount }) => {
175
+ // return { ok: true, deliveredAmount, minimumDeliveredAmount } or
176
+ // { ok: false, refusal: { kind, reason, minimumAmount } }
177
+ },
178
+ // fusesWithNext?: (nextHop) => boolean — set this only when arriving via
179
+ // this carrier ALSO executes the next hop, so the two cost one signature.
180
+ }
181
+ ```
182
+
183
+ "Fewest signatures for the most delivered" is the engine's actual scoring
184
+ order, not a slogan: it maximizes delivered amount first, then minimizes
185
+ how many wallet signatures a route costs among routes that tie. A carrier
186
+ that fuses two hops into a single signature beats an otherwise-equal route
187
+ that costs an extra step.
188
+
189
+ Every network call a carrier makes — built in or your own — passes
190
+ through this library's shared guards (a keyed concurrency limiter, a
191
+ Retry-After cooldown, and your `neverSendChainIds` tripwire) before it
192
+ ever reaches `fetch`, once you wrap it through the client's upstream
193
+ funnel.
194
+
195
+ ## Rules that never bend
196
+
197
+ - **`neverSendChainIds` is enforced everywhere, including forwarding.** If
198
+ you run a chain that must never reach a public aggregator, name its id
199
+ here. Every outbound call this library makes, direct or forwarded,
200
+ refuses any request naming it.
201
+ - **A binding quote is never cached or shared.** A quote that reserves a
202
+ real user address, real calldata, or a live deposit address answers
203
+ fresh every time — never from this library's own cache.
204
+ - **A scaled figure never reaches an execution builder.** `expectedAmount`
205
+ and `minimumAmount` on a `basis: 'scaled-from-nearby-amount'` answer
206
+ carry a distinct type brand (`EstimatedDelivery`) from a genuine
207
+ `basis: 'exact'` answer (`ExactAmount`). The function that decodes an
208
+ amount for spending only accepts the exact brand, so mixing them up is a
209
+ compile error, not a rule you have to remember to check.
210
+ - **Provider keys never run in a browser.** If your frontend needs a
211
+ display quote and you hold a real key, put it behind your own service
212
+ (Mode 3) — never Mode 2 with a real `apiKey` set on a browser build.
213
+ - **Verify a route's execution calldata before signing it**, independent
214
+ of what a display quote showed. A display quote exists to show a reader
215
+ what to expect; it is never the thing that gets signed. Build and check
216
+ the real execution transaction separately, immediately before asking a
217
+ wallet to sign it.