@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.
- package/LICENSE +15 -0
- package/README.md +45 -0
- package/dist/cache.d.ts +73 -0
- package/dist/cache.js +137 -0
- package/dist/carriers/calibration.d.ts +115 -0
- package/dist/carriers/calibration.js +83 -0
- package/dist/carriers/lifi.d.ts +63 -0
- package/dist/carriers/lifi.js +217 -0
- package/dist/carriers/near-intents.d.ts +91 -0
- package/dist/carriers/near-intents.js +249 -0
- package/dist/carriers/price-catalogue.d.ts +186 -0
- package/dist/carriers/price-catalogue.js +191 -0
- package/dist/carriers/relay.d.ts +97 -0
- package/dist/carriers/relay.js +242 -0
- package/dist/chain-lists.d.ts +144 -0
- package/dist/chain-lists.js +169 -0
- package/dist/client.d.ts +102 -0
- package/dist/client.js +402 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +18 -0
- package/dist/quote-guards.d.ts +128 -0
- package/dist/quote-guards.js +240 -0
- package/dist/quote-refusals.d.ts +180 -0
- package/dist/quote-refusals.js +284 -0
- package/dist/quote-service-contract.d.ts +2240 -0
- package/dist/quote-service-contract.js +247 -0
- package/dist/search/capabilities.d.ts +68 -0
- package/dist/search/capabilities.js +28 -0
- package/dist/search/enumerate.d.ts +162 -0
- package/dist/search/enumerate.js +313 -0
- package/dist/search/index.d.ts +17 -0
- package/dist/search/index.js +17 -0
- package/dist/search/serve.d.ts +178 -0
- package/dist/search/serve.js +142 -0
- package/dist/search/waves.d.ts +481 -0
- package/dist/search/waves.js +939 -0
- package/dist/single-flight.d.ts +90 -0
- package/dist/single-flight.js +142 -0
- package/dist/types.d.ts +502 -0
- package/dist/types.js +1 -0
- package/dist/upstream.d.ts +126 -0
- package/dist/upstream.js +343 -0
- package/llms.txt +217 -0
- 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
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|