@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
package/dist/client.js ADDED
@@ -0,0 +1,402 @@
1
+ import { ecosystemByChainId } from '@gibs/bridge-sdk/relay-quote';
2
+ import { createAnswerCache, isNoQuoteMinimumReusableForAmount } from './cache.js';
3
+ import { displayQuoteCacheKey, scaleToAskedAmount } from './quote-guards.js';
4
+ import { decodeBaseUnitsAmount, displayQuoteRequestSchema, displayQuoteResponseSchema, encodeBaseUnitsAmount, toEstimatedDelivery, toExactAmount, } from './quote-service-contract.js';
5
+ import { createSingleFlightGroup } from './single-flight.js';
6
+ import { createUpstreamFunnel } from './upstream.js';
7
+ /**
8
+ * `createQuoteClient` — the one function every host, backend or frontend,
9
+ * builds its `POST /v1/display-quotes` behaviour from. See
10
+ * `docs/quote-service.md`'s "Library" section for the three modes
11
+ * {@link QuoteClientConfig} selects between, and this module's own doc
12
+ * comments for how each is implemented.
13
+ */
14
+ /** Every provider id the display-quote wire contract can answer for. */
15
+ const ALL_PROVIDER_IDS = ['lifi', 'relay', 'near-intents'];
16
+ /** The 6 s local-mode per-(ask, carrier) deadline `docs/quote-service.md`'s "Decisions" section names. */
17
+ export const DEFAULT_LOCAL_ASK_DEADLINE_MS = 6_000;
18
+ /** How long a fallback-to-direct breaker stays open before the next call retries the service — `docs/quote-service.md`'s "Decisions" section: "direct calls for 60 seconds." */
19
+ export const SERVICE_BREAKER_MS = 60_000;
20
+ /**
21
+ * A fixed cache-key generation for probe addresses, until a later step wires
22
+ * a real, rotating probe-address generation into this client — see
23
+ * `types.ts`'s `QuoteClientConfig.probeAddresses` and
24
+ * `quote-guards.ts`'s `displayQuoteCacheKey`, whose `probeSetVersion`
25
+ * parameter exists for exactly that rotation.
26
+ */
27
+ const PROBE_SET_VERSION = 'v1';
28
+ // ---------------------------------------------------------------------------
29
+ // DisplayAsk -> CarrierHopEdge — the known gap this step leaves open
30
+ // ---------------------------------------------------------------------------
31
+ /**
32
+ * Builds the {@link CarrierHopEdge} one (ask, carrier) pair is asked about.
33
+ *
34
+ * `decimals: 0` IS A PLACEHOLDER, NOT A REAL VALUE. `DisplayAsk` carries only
35
+ * a chain id and a token address on each side — no decimals, because this
36
+ * wire contract has no token list to read them from yet (`docs/quote-service.md`'s
37
+ * "Library" section lists "the chain-list loader" as a later step). Every
38
+ * figure this client computes stays in base units end to end and never
39
+ * multiplies or divides by a power of ten, so an inaccurate `decimals` here
40
+ * cannot corrupt an answer — but a `Carrier` implementation that DOES read it
41
+ * (to format a human-readable amount for logging, say) would see a wrong
42
+ * value. Flagged here rather than silently guessed at.
43
+ *
44
+ * @param ask - the ask being resolved
45
+ * @param providerId - which carrier this edge is for
46
+ * @returns the edge
47
+ */
48
+ const buildHopEdge = (ask, providerId) => {
49
+ const from = {
50
+ chainId: ask.from.chainId,
51
+ address: ask.from.token,
52
+ ecosystem: ecosystemByChainId(ask.from.chainId),
53
+ decimals: 0,
54
+ };
55
+ const to = {
56
+ chainId: ask.to.chainId,
57
+ address: ask.to.token,
58
+ // `docs/quote-service.md`'s "Endpoint" section: "recipientEcosystem ...
59
+ // default `ecosystemByChainId(to.chainId)`."
60
+ ecosystem: ask.recipientEcosystem ?? ecosystemByChainId(ask.to.chainId),
61
+ decimals: 0,
62
+ };
63
+ return { carrier: providerId, from, to, fusesNext: false, origin: 'wallet', floors: [] };
64
+ };
65
+ /** Turns one {@link CarrierAskAnswer} into the wire's {@link DisplayProviderSlot}. */
66
+ const displaySlotFromCarrierAnswer = (carrierAnswer, ask, quotedAtIso) => {
67
+ if (!carrierAnswer.ok) {
68
+ const { refusal } = carrierAnswer;
69
+ // `CarrierRefusalKind` is broader than the wire's own refusal vocabulary
70
+ // — a local-read bound (`below-minimum`, `above-available`) reads as
71
+ // `no-quote` on the wire: the carrier considered this amount and, for
72
+ // its own reasons, declined it, which is exactly what `no-quote` means
73
+ // to a reader of the response.
74
+ const kind = refusal.kind === 'below-minimum' || refusal.kind === 'above-available' ? 'no-quote' : refusal.kind;
75
+ return {
76
+ status: 'refused',
77
+ kind,
78
+ reason: refusal.reason,
79
+ ...(refusal.minimumAmount === null ? {} : { minimumAmount: encodeBaseUnitsAmount(refusal.minimumAmount) }),
80
+ };
81
+ }
82
+ return {
83
+ status: 'quoted',
84
+ basis: 'exact',
85
+ askedAmount: ask.amount,
86
+ expectedAmount: toExactAmount(carrierAnswer.deliveredAmount),
87
+ ...(carrierAnswer.minimumDeliveredAmount === null
88
+ ? {}
89
+ : { minimumAmount: toExactAmount(carrierAnswer.minimumDeliveredAmount) }),
90
+ quotedAt: quotedAtIso,
91
+ ageMs: 0,
92
+ cache: 'miss',
93
+ };
94
+ };
95
+ // ---------------------------------------------------------------------------
96
+ // Cache-hit adaptation — reuse, scale, or fall through to a fresh ask
97
+ // ---------------------------------------------------------------------------
98
+ /**
99
+ * Whether a cache hit may answer THIS ask, per `docs/quote-service.md`'s
100
+ * "Cache" and "Rules that never bend" sections: a `pending` placeholder is
101
+ * never reusable; an `exact` ask only reuses an entry recorded for the
102
+ * IDENTICAL literal amount (never a scaled figure); a `no-quote` naming a
103
+ * minimum only reuses for an amount also below that minimum
104
+ * ({@link isNoQuoteMinimumReusableForAmount}); everything else reuses freely.
105
+ *
106
+ * @param answer - the cached entry's own answer
107
+ * @param ask - the ask being resolved
108
+ * @param askedAmount - `ask.amount`, decoded
109
+ * @returns whether the entry may answer this ask
110
+ */
111
+ const isCacheEntryReusable = (answer, ask, askedAmount) => {
112
+ if (answer.status === 'pending')
113
+ return false;
114
+ if (answer.status === 'quoted') {
115
+ if ((ask.precision ?? 'estimate-ok') === 'exact') {
116
+ return decodeBaseUnitsAmount(answer.askedAmount) === askedAmount;
117
+ }
118
+ return true;
119
+ }
120
+ return isNoQuoteMinimumReusableForAmount(answer, askedAmount);
121
+ };
122
+ /**
123
+ * Adapts a reusable cache entry into this ask's own answer: unchanged (but
124
+ * for `ageMs` and `cache`) when its `askedAmount` already matches, or scaled
125
+ * via {@link scaleToAskedAmount} into a `basis: 'scaled-from-nearby-amount'`
126
+ * answer otherwise. Only ever called after {@link isCacheEntryReusable}
127
+ * confirms scaling (or reuse at all) is allowed for this ask.
128
+ *
129
+ * @param entry - the cache entry
130
+ * @param askedAmount - `ask.amount`, decoded
131
+ * @param nowMs - the current time, for `ageMs`
132
+ * @returns this ask's own answer
133
+ */
134
+ const adaptCacheHit = (entry, askedAmount, nowMs) => {
135
+ const { answer } = entry;
136
+ const ageMs = Math.max(0, nowMs - entry.recordedAtMs);
137
+ if (answer.status !== 'quoted')
138
+ return answer;
139
+ const recordedAskedAmount = decodeBaseUnitsAmount(answer.askedAmount);
140
+ if (recordedAskedAmount === askedAmount) {
141
+ return { ...answer, ageMs, cache: 'hit' };
142
+ }
143
+ const rawExpected = decodeBaseUnitsAmount(answer.expectedAmount);
144
+ const scaledExpected = scaleToAskedAmount(rawExpected, askedAmount, recordedAskedAmount);
145
+ const scaledMinimum = answer.minimumAmount === undefined
146
+ ? undefined
147
+ : scaleToAskedAmount(decodeBaseUnitsAmount(answer.minimumAmount), askedAmount, recordedAskedAmount);
148
+ return {
149
+ status: 'quoted',
150
+ basis: 'scaled-from-nearby-amount',
151
+ askedAmount: encodeBaseUnitsAmount(askedAmount),
152
+ quotedAt: answer.quotedAt,
153
+ ageMs,
154
+ cache: 'hit',
155
+ expectedAmount: toEstimatedDelivery(scaledExpected),
156
+ ...(scaledMinimum === undefined ? {} : { minimumAmount: toEstimatedDelivery(scaledMinimum) }),
157
+ scaledFrom: { askedAmount: answer.askedAmount, expectedAmount: answer.expectedAmount },
158
+ };
159
+ };
160
+ const scheduleTimer = (callback, delayMs) => {
161
+ const handle = setTimeout(callback, Math.max(0, delayMs));
162
+ if (typeof handle === 'object' && handle !== null && 'unref' in handle) {
163
+ handle.unref();
164
+ }
165
+ return () => clearTimeout(handle);
166
+ };
167
+ /**
168
+ * Resolves one provider's slot for one ask: a fresh cache hit answers
169
+ * immediately; otherwise a fresh or shared upstream ask races the local
170
+ * deadline — a carrier that misses it yields `pending`, while its call keeps
171
+ * running in the background and fills the cache for the next reader.
172
+ *
173
+ * @param context - see {@link ResolveProviderSlotContext}
174
+ * @returns this provider's own slot for this ask
175
+ */
176
+ const resolveProviderSlot = async (context) => {
177
+ const { ask, providerId, carrier, cache, singleFlight, now, localAskDeadlineMs, stats } = context;
178
+ const askedAmount = decodeBaseUnitsAmount(ask.amount);
179
+ const cacheKey = displayQuoteCacheKey(providerId, ask, PROBE_SET_VERSION);
180
+ const cached = cache.get(cacheKey);
181
+ if (cached !== null && isCacheEntryReusable(cached.answer, ask, askedAmount)) {
182
+ stats.recordCacheHit();
183
+ return adaptCacheHit(cached, askedAmount, now());
184
+ }
185
+ const run = async () => {
186
+ stats.recordUpstreamCall(providerId);
187
+ const edge = buildHopEdge(ask, providerId);
188
+ const carrierAnswer = await carrier.ask({ edge, inputAmount: askedAmount });
189
+ const quotedAtMs = now();
190
+ const answer = displaySlotFromCarrierAnswer(carrierAnswer, ask, new Date(quotedAtMs).toISOString());
191
+ cache.set(cacheKey, answer, quotedAtMs);
192
+ return { answer, askedAmount };
193
+ };
194
+ const singleFlightRequest = {
195
+ bucketKey: cacheKey,
196
+ askedAmount,
197
+ precision: ask.precision ?? 'estimate-ok',
198
+ };
199
+ let cancelDeadline;
200
+ const deadline = new Promise((resolve) => {
201
+ cancelDeadline = scheduleTimer(() => resolve({ kind: 'timeout' }), localAskDeadlineMs);
202
+ });
203
+ const settled = singleFlight.ask(singleFlightRequest, run).then((result) => ({ kind: 'settled', result }), (error) => ({ kind: 'error', error }));
204
+ const winner = await Promise.race([settled, deadline]);
205
+ cancelDeadline?.();
206
+ if (winner.kind === 'timeout') {
207
+ // The shared call is NOT cancelled — it keeps running, and its own
208
+ // `run` closure fills the cache when it settles, for the next reader
209
+ // (or the next poll of this same one) to find as a `hit`.
210
+ return { status: 'pending', retryAfterMs: localAskDeadlineMs };
211
+ }
212
+ if (winner.kind === 'error') {
213
+ const message = winner.error instanceof Error ? winner.error.message : null;
214
+ return { status: 'refused', kind: 'could-not-ask', reason: message };
215
+ }
216
+ if (winner.result.joined) {
217
+ stats.recordCacheJoined();
218
+ }
219
+ else {
220
+ stats.recordCacheMiss();
221
+ }
222
+ return winner.result.answer;
223
+ };
224
+ const runLocally = async (asks, context) => {
225
+ const answers = [];
226
+ for (const ask of asks) {
227
+ const requestedProviderIds = ask.providers ?? ALL_PROVIDER_IDS;
228
+ const slotEntries = await Promise.all(requestedProviderIds.map(async (providerId) => {
229
+ const carrier = context.carriers.find((candidate) => candidate.id === providerId);
230
+ if (carrier === undefined) {
231
+ // Not registered at all. Explicitly requested: say so. Left to the
232
+ // default (every eligible provider): simply not eligible, so its
233
+ // slot is omitted rather than inventing a refusal for a provider
234
+ // nobody wired up.
235
+ if (ask.providers === undefined)
236
+ return null;
237
+ return [providerId, { status: 'refused', kind: 'could-not-ask', reason: `no "${providerId}" carrier is registered` }];
238
+ }
239
+ const slot = await resolveProviderSlot({
240
+ ask,
241
+ providerId,
242
+ carrier,
243
+ cache: context.cache,
244
+ singleFlight: context.singleFlight,
245
+ now: context.now,
246
+ localAskDeadlineMs: context.localAskDeadlineMs,
247
+ stats: context.stats,
248
+ });
249
+ return [providerId, slot];
250
+ }));
251
+ const providers = {};
252
+ for (const entry of slotEntries) {
253
+ if (entry === null)
254
+ continue;
255
+ const [providerId, slot] = entry;
256
+ providers[providerId] = slot;
257
+ }
258
+ answers.push({ providers });
259
+ }
260
+ return { answers };
261
+ };
262
+ // ---------------------------------------------------------------------------
263
+ // Refused-could-not-ask: fallback: 'none', or a service that stays down
264
+ // ---------------------------------------------------------------------------
265
+ const refusedCouldNotAskResponse = (asks, reason) => ({
266
+ answers: asks.map((ask) => {
267
+ const providers = {};
268
+ for (const providerId of ask.providers ?? ALL_PROVIDER_IDS) {
269
+ providers[providerId] = { status: 'refused', kind: 'could-not-ask', reason };
270
+ }
271
+ return { providers };
272
+ }),
273
+ });
274
+ // ---------------------------------------------------------------------------
275
+ // createQuoteClient
276
+ // ---------------------------------------------------------------------------
277
+ /**
278
+ * Builds a {@link QuoteClient} from {@link QuoteClientConfig} — see this
279
+ * module's head note and `docs/quote-service.md`'s "Library" section for the
280
+ * three modes `config` selects between.
281
+ *
282
+ * @param config - see {@link QuoteClientConfig}
283
+ * @param options - see {@link QuoteClientOptions}
284
+ * @returns the client
285
+ */
286
+ export const createQuoteClient = (config, options = {}) => {
287
+ const fetchImpl = config.fetch ?? fetch;
288
+ const now = config.now ?? Date.now;
289
+ const localAskDeadlineMs = options.localAskDeadlineMs ?? DEFAULT_LOCAL_ASK_DEADLINE_MS;
290
+ const cache = createAnswerCache({
291
+ ttlMs: config.cache?.ttlMs,
292
+ maxEntries: config.cache?.maxEntries,
293
+ now,
294
+ chainListRefreshStamp: options.chainListRefreshStamp,
295
+ });
296
+ const singleFlight = createSingleFlightGroup();
297
+ const configuredKeys = [
298
+ config.providers?.lifi?.apiKey,
299
+ config.providers?.relay?.apiKey,
300
+ config.providers?.nearIntents?.apiKey,
301
+ ].filter((key) => typeof key === 'string' && key !== '');
302
+ const funnel = options.funnel ??
303
+ createUpstreamFunnel({
304
+ neverSendChainIds: config.neverSendChainIds,
305
+ keys: configuredKeys,
306
+ concurrencyByCarrier: config.limits?.concurrencyByCarrier,
307
+ defaultRetryAfterMs: config.limits?.defaultRetryAfterMs,
308
+ fetch: fetchImpl,
309
+ now,
310
+ });
311
+ const carriers = (options.carriers ?? []).map((carrier) => funnel.wrap(carrier));
312
+ // `statsSnapshot`'s own counters — see `QuoteClientStatsRecorder`. Cache
313
+ // hit/miss/joined counts are this client's own, since only `client.ts`
314
+ // knows which of the three happened; the rate-limit and queue-depth
315
+ // figures are read live off `funnel` itself (see `upstream.ts`) rather
316
+ // than duplicated here, so there is exactly one place either can drift.
317
+ let hitCount = 0;
318
+ let missCount = 0;
319
+ let joinedCount = 0;
320
+ const upstreamCallsByCarrier = {};
321
+ const statsRecorder = {
322
+ recordCacheHit: () => {
323
+ hitCount += 1;
324
+ },
325
+ recordCacheMiss: () => {
326
+ missCount += 1;
327
+ },
328
+ recordCacheJoined: () => {
329
+ joinedCount += 1;
330
+ },
331
+ recordUpstreamCall: (providerId) => {
332
+ upstreamCallsByCarrier[providerId] = (upstreamCallsByCarrier[providerId] ?? 0) + 1;
333
+ },
334
+ };
335
+ // The breaker `docs/quote-service.md`'s "Decisions" section names: once a
336
+ // `serviceUrl` call fails under `fallback: 'direct'`, every call for the
337
+ // next 60 s skips straight to local mode rather than trying — and failing
338
+ // against — the service again.
339
+ let serviceUnavailableUntilMs = null;
340
+ const callService = async (asks) => {
341
+ // `config.serviceUrl` is only read inside this function, itself only
342
+ // called once `config.serviceUrl !== undefined` has already been
343
+ // checked by the caller — see `displayQuotes` below.
344
+ const url = `${config.serviceUrl.replace(/\/+$/, '')}/v1/display-quotes`;
345
+ let response;
346
+ try {
347
+ response = await fetchImpl(url, {
348
+ method: 'POST',
349
+ headers: { 'content-type': 'application/json' },
350
+ body: JSON.stringify({ asks }),
351
+ });
352
+ }
353
+ catch {
354
+ return { ok: false };
355
+ }
356
+ // A NON-2xx, OR AN UNREADABLE BODY, IS TREATED THE SAME AS UNREACHABLE.
357
+ // `docs/quote-service.md`'s "Decisions" section names network error, 5xx
358
+ // and 403 explicitly; any other status this service should never answer
359
+ // with (400, 404, ...) means a caller cannot use the response either
360
+ // way, so it is folded into the same fallback path rather than left to
361
+ // throw — a display-quote surface degrading to "could not ask" beats one
362
+ // that throws on an upstream's unexpected day.
363
+ if (!response.ok)
364
+ return { ok: false };
365
+ const body = await response.json().catch(() => null);
366
+ const parsed = displayQuoteResponseSchema.safeParse(body);
367
+ if (!parsed.success)
368
+ return { ok: false };
369
+ return { ok: true, response: parsed.data };
370
+ };
371
+ return {
372
+ displayQuotes: async (rawRequest) => {
373
+ const { asks } = displayQuoteRequestSchema.parse(rawRequest);
374
+ if (config.serviceUrl === undefined) {
375
+ return runLocally(asks, { carriers, cache, singleFlight, now, localAskDeadlineMs, stats: statsRecorder });
376
+ }
377
+ const breakerActive = config.fallback === 'direct' && serviceUnavailableUntilMs !== null && now() < serviceUnavailableUntilMs;
378
+ if (!breakerActive) {
379
+ const outcome = await callService(asks);
380
+ if (outcome.ok) {
381
+ serviceUnavailableUntilMs = null;
382
+ return outcome.response;
383
+ }
384
+ if (config.fallback === 'direct') {
385
+ serviceUnavailableUntilMs = now() + SERVICE_BREAKER_MS;
386
+ }
387
+ }
388
+ if (config.fallback === 'none') {
389
+ return refusedCouldNotAskResponse(asks, 'the quote service could not be reached');
390
+ }
391
+ return runLocally(asks, { carriers, cache, singleFlight, now, localAskDeadlineMs, stats: statsRecorder });
392
+ },
393
+ statsSnapshot: () => ({
394
+ hits: hitCount,
395
+ misses: missCount,
396
+ joined: joinedCount,
397
+ upstreamCallsByCarrier: { ...upstreamCallsByCarrier },
398
+ rateLimited429s: funnel.rateLimited429Count(),
399
+ queueDepth: funnel.queueDepth(),
400
+ }),
401
+ };
402
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@gibs/quotes` — the isomorphic core for asking cross-chain bridge
3
+ * aggregators for display and execution quotes. See `docs/quote-service.md`'s
4
+ * "Library" section for the three ways a host builds a client from this
5
+ * package, and the per-module files for everything this barrel re-exports.
6
+ */
7
+ export * from './quote-service-contract.js';
8
+ export * from './quote-refusals.js';
9
+ export * from './quote-guards.js';
10
+ export * from './cache.js';
11
+ export * from './single-flight.js';
12
+ export * from './client.js';
13
+ export * from './upstream.js';
14
+ export * from './chain-lists.js';
15
+ export * from './carriers/lifi.js';
16
+ export * from './carriers/relay.js';
17
+ export * from './carriers/near-intents.js';
18
+ export * from './search/index.js';
19
+ export type * from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `@gibs/quotes` — the isomorphic core for asking cross-chain bridge
3
+ * aggregators for display and execution quotes. See `docs/quote-service.md`'s
4
+ * "Library" section for the three ways a host builds a client from this
5
+ * package, and the per-module files for everything this barrel re-exports.
6
+ */
7
+ export * from './quote-service-contract.js';
8
+ export * from './quote-refusals.js';
9
+ export * from './quote-guards.js';
10
+ export * from './cache.js';
11
+ export * from './single-flight.js';
12
+ export * from './client.js';
13
+ export * from './upstream.js';
14
+ export * from './chain-lists.js';
15
+ export * from './carriers/lifi.js';
16
+ export * from './carriers/relay.js';
17
+ export * from './carriers/near-intents.js';
18
+ export * from './search/index.js';
@@ -0,0 +1,128 @@
1
+ import type { ProviderId } from '@gibs/bridge-sdk/providers';
2
+ import { type DisplayAsk } from './quote-service-contract.js';
3
+ /**
4
+ * The shared upstream guards `docs/quote-service.md`'s "Guards" and "Cache"
5
+ * sections name: a keyed concurrency limiter with a deadline, a Retry-After
6
+ * cooldown, the display-quote cache key, and the bigint scaling formula. A
7
+ * host wraps every network call to a provider through these before it ever
8
+ * reaches `fetch` — see `types.ts`'s `UpstreamFunnel`.
9
+ */
10
+ /**
11
+ * Thrown by {@link KeyedConcurrencyLimiter.run} when a slot under `key` does
12
+ * not open before the caller's deadline. The task is never started — a host
13
+ * catches this and answers `could-not-ask: busy` (`docs/quote-service.md`'s
14
+ * "Guards" section) rather than queuing indefinitely behind a saturated
15
+ * provider.
16
+ */
17
+ export declare class ConcurrencyDeadlineExceededError extends Error {
18
+ readonly key: string;
19
+ constructor(key: string);
20
+ }
21
+ /** Caps how many tasks registered under the same key run at once. */
22
+ export type KeyedConcurrencyLimiter = {
23
+ /**
24
+ * Runs `task` once fewer than `limit` tasks are already active under
25
+ * `key`, queuing FIFO otherwise.
26
+ *
27
+ * @param key - the concurrency bucket (a provider id, a carrier id, ...)
28
+ * @param limit - the most tasks that may run under `key` at once
29
+ * @param task - the work to run once a slot is free
30
+ * @param options.deadlineMs - epoch milliseconds after which a still-queued
31
+ * task rejects with {@link ConcurrencyDeadlineExceededError} instead of
32
+ * running. Omitted means "wait indefinitely," matching the query-layer
33
+ * caller that has no deadline of its own.
34
+ * @param options.now - the clock; defaults to `Date.now`, overridable for a test
35
+ * @returns whatever `task` resolves to
36
+ * @throws {ConcurrencyDeadlineExceededError} when the deadline passes first
37
+ */
38
+ readonly run: <Value>(key: string, limit: number, task: () => Promise<Value>, options?: {
39
+ readonly deadlineMs?: number;
40
+ readonly now?: () => number;
41
+ }) => Promise<Value>;
42
+ /**
43
+ * How many tasks are queued FIFO right now, waiting for a slot — across
44
+ * every key, or (with `key`) one key alone. A task already running counts
45
+ * toward `limit`, never toward this: it answers "how many are waiting,"
46
+ * the figure `/stats`'s `queueDepth` reports, per `docs/quote-service.md`'s
47
+ * "Endpoint" section.
48
+ *
49
+ * @param key - narrows to one concurrency bucket; omitted sums every key
50
+ * @returns the queued count
51
+ */
52
+ readonly queueDepth: (key?: string) => number;
53
+ };
54
+ /**
55
+ * Builds a fresh, empty {@link KeyedConcurrencyLimiter}.
56
+ *
57
+ * @returns the limiter
58
+ */
59
+ export declare const createKeyedConcurrencyLimiter: () => KeyedConcurrencyLimiter;
60
+ /** How long a key is treated as rate-limited when its answer carries no readable `Retry-After`. */
61
+ export declare const DEFAULT_RETRY_AFTER_COOLDOWN_MS = 30000;
62
+ /** Tracks per-key `429`/`Retry-After` cooldowns, so a key is asked again only once its own cooldown has passed. */
63
+ export type RetryAfterCooldown = {
64
+ /**
65
+ * Records that `key` answered `429` (or any rate-limited status) just now,
66
+ * so it is treated as cooling down until the cooldown
67
+ * {@link backoffMsFromRetryAfter} derives from `retryAfterHeader` elapses.
68
+ *
69
+ * @param key - the provider or carrier that answered rate-limited
70
+ * @param retryAfterHeader - the response's `Retry-After` header value, or null
71
+ * @param nowMs - the current time in epoch milliseconds; defaults to `Date.now()`
72
+ */
73
+ readonly record: (key: string, retryAfterHeader: string | null, nowMs?: number) => void;
74
+ /**
75
+ * Whether `key` is still inside a cooldown {@link record} started.
76
+ *
77
+ * @param key - the key under consideration
78
+ * @param nowMs - the current time in epoch milliseconds; defaults to `Date.now()`
79
+ * @returns whether a call under this key should be skipped for now
80
+ */
81
+ readonly isActive: (key: string, nowMs?: number) => boolean;
82
+ /**
83
+ * The cooldown's own deadline for `key`, in epoch milliseconds, or null
84
+ * when none is recorded — the figure a `could-not-ask: busy` /
85
+ * `retryAfterMs` answer is computed from.
86
+ *
87
+ * @param key - the key under consideration
88
+ * @returns the epoch millisecond the cooldown clears, or null
89
+ */
90
+ readonly clearsAtMs: (key: string) => number | null;
91
+ /** Clears every recorded cooldown. Test-only. */
92
+ readonly reset: () => void;
93
+ };
94
+ /**
95
+ * Builds a fresh, empty {@link RetryAfterCooldown}.
96
+ *
97
+ * @param options.defaultMs - the fallback cooldown; defaults to
98
+ * {@link DEFAULT_RETRY_AFTER_COOLDOWN_MS}
99
+ * @returns the cooldown tracker
100
+ */
101
+ export declare const createRetryAfterCooldown: (options?: {
102
+ readonly defaultMs?: number;
103
+ }) => RetryAfterCooldown;
104
+ /**
105
+ * Builds the display-quote cache key for one provider's answer to one ask,
106
+ * exactly per `docs/quote-service.md`'s "Cache" section:
107
+ * `v1|provider|from.chainId|from.token|to.chainId|to.token|recipientEcosystem|probeSetVersion|bucket`.
108
+ *
109
+ * @param provider - which provider this key caches an answer for
110
+ * @param ask - the ask being cached; only the fields the key is built from
111
+ * @param probeSetVersion - the server's own probe-address generation, so a
112
+ * rotated probe address never serves a cached answer keyed to the old one
113
+ * @returns the cache key
114
+ */
115
+ export declare const displayQuoteCacheKey: (provider: ProviderId, ask: Pick<DisplayAsk, "from" | "to" | "amount" | "recipientEcosystem">, probeSetVersion: string) => string;
116
+ /**
117
+ * Scales a cached figure — answered for a NEARBY amount — into an estimate
118
+ * for the amount actually asked, per `docs/quote-service.md`'s "Cache"
119
+ * section: `scaled = figure × userAmount / askedAmount`, entirely in bigint.
120
+ *
121
+ * @param figure - the nearby amount's own raw answer
122
+ * @param userAmount - the amount actually asked for (this asker's own amount)
123
+ * @param askedAmount - the amount that was actually asked upstream (the
124
+ * nearby, cached amount `figure` answers)
125
+ * @returns the scaled estimate, in the same units as `figure`
126
+ * @throws when `askedAmount` is not positive — there is nothing to scale from
127
+ */
128
+ export declare const scaleToAskedAmount: (figure: bigint, userAmount: bigint, askedAmount: bigint) => bigint;