@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,242 @@
1
+ import { isNativeAsset } from '@gibs/bridge-sdk/ecosystems';
2
+ import { buildRelayPreQuoteRequest, parseRelayPreQuote } from '@gibs/bridge-sdk/pre-quote';
3
+ import { relayQuoteUrl, relayRequestHeaders } from '@gibs/bridge-sdk/relay-quote';
4
+ import { readRelayRefusal } from '../quote-refusals.js';
5
+ import { createCalibrationStore } from './calibration.js';
6
+ import { createRelayPriceCatalogue, estimateDeliveredAmount } from './price-catalogue.js';
7
+ const cannotCarryAnswer = (reason) => ({
8
+ ok: false,
9
+ refusal: { kind: 'cannot-carry', reason, minimumAmount: null },
10
+ });
11
+ const couldNotAskAnswer = (reason) => ({
12
+ ok: false,
13
+ refusal: { kind: 'could-not-ask', reason, minimumAmount: null },
14
+ });
15
+ /**
16
+ * Which of Relay's published platform-fee tiers an edge most likely falls
17
+ * under — read from `docs.relay.link/how-it-works/fees` and
18
+ * `docs.relay.link/references/api/api_core_concepts/fees.md` (both fetched
19
+ * 2026-09-24): Token Bridge & same-chain Wrap/Unwrap 0.00%, Stablecoin Swap
20
+ * 0.01%, Major Swap (ETH, BTC, SOL, POL, BNB, PLUME, and any of those paired
21
+ * with a stablecoin) 0.06%, Minor Swap (everything else) 0.15%.
22
+ *
23
+ * WHY THIS IS A HEURISTIC, NOT A LOOKUP. {@link CarrierAssetNode} carries no
24
+ * token symbol (see `@gibs/bridge-sdk/routing`'s `AssetNode` — only chain
25
+ * id, address, ecosystem, decimals), and this host-agnostic package keeps no
26
+ * stablecoin/major-asset registry of its own to classify one. So this
27
+ * function only ever answers the two tiers it CAN determine from the edge's
28
+ * own shape, honestly:
29
+ *
30
+ * - `'bridge'` — the literal SAME address crosses chains (a real, checkable
31
+ * fact: some tokens, most commonly stablecoins deployed through a
32
+ * deterministic factory, share one address across chains).
33
+ * - `'major'` — either side is that chain's OWN native asset. Every chain
34
+ * this package's built-in carriers reach is presently an Ethereum-Virtual-
35
+ * Machine chain whose native coin is ether or a major chain's own gas
36
+ * token (BNB, and so on) — Relay's own "major" list — so a native-asset
37
+ * edge is major far more often than not.
38
+ * - `'minor'` — the default for everything else, INCLUDING a stablecoin pair
39
+ * this function cannot tell apart from an ordinary token pair without a
40
+ * symbol. That misclassification prices 0.15% instead of the true 0.01%,
41
+ * a difference `calibrationStore` (keyed per chain pair — see
42
+ * `carriers/lifi.ts`'s identical note) absorbs quickly for any pair quoted
43
+ * more than a few times, because the SAME pair keeps landing in the same
44
+ * true tier.
45
+ *
46
+ * @param edge - the candidate hop
47
+ * @returns the tier
48
+ */
49
+ export const classifyRelaySwapKind = (edge) => {
50
+ const sameAddress = edge.from.address.toLowerCase() === edge.to.address.toLowerCase();
51
+ if (sameAddress && edge.from.chainId !== edge.to.chainId) {
52
+ return 'bridge';
53
+ }
54
+ if (isNativeAsset(edge.from.address, edge.from.ecosystem) || isNativeAsset(edge.to.address, edge.to.ecosystem)) {
55
+ return 'major';
56
+ }
57
+ return 'minor';
58
+ };
59
+ /** {@link classifyRelaySwapKind}'s own tier, as a basis-point fraction of the platform fee — see that function's doc for the sources. */
60
+ const relayPlatformFeeFractionByKind = {
61
+ bridge: 0,
62
+ major: 0.0006,
63
+ minor: 0.0015,
64
+ };
65
+ /**
66
+ * Relay's flat execution charge, in United States dollars — "Relay charges
67
+ * a $0.02 flat fee... always included," read from
68
+ * `docs.relay.link/how-it-works/fees` and
69
+ * `docs.relay.link/references/api/api_core_concepts/fees.md`, both fetched
70
+ * 2026-09-24.
71
+ *
72
+ * DESTINATION GAS IS NOT ADDED TO IT, even though the same source names
73
+ * "destination (fill) gas estimate" as part of Relay's execution cost
74
+ * alongside this flat fee. A live destination gas price needs a network
75
+ * call this package's synchronous `estimate()` cannot make (see `types.ts`'s
76
+ * `Carrier.estimate` doc), so it is left, along with Relay's own swap-impact
77
+ * cost, to {@link CalibrationStore} — see `classifyRelaySwapKind`'s doc for
78
+ * why that correction converges quickly per chain pair.
79
+ */
80
+ const relayFlatFeeUsd = 0.02;
81
+ /**
82
+ * Builds Relay as a {@link ProviderAsker}.
83
+ *
84
+ * @param options - see {@link CreateRelayCarrierOptions}
85
+ * @returns the carrier
86
+ */
87
+ export const createRelayCarrier = (options) => {
88
+ const { config, probeAddresses, chainList } = options;
89
+ const fetchImpl = options.fetchImpl ?? fetch;
90
+ const priceCatalogue = options.priceCatalogue ?? createRelayPriceCatalogue({ baseUrl: config?.baseUrl, apiKey: config?.apiKey, fetchImpl });
91
+ const calibrationStore = options.calibrationStore ?? createCalibrationStore();
92
+ const appFeeFraction = options.appFeeBasisPoints !== undefined && options.appFeeBasisPoints > 0 ? options.appFeeBasisPoints / 10_000 : 0;
93
+ /**
94
+ * A provider-model estimate for this edge — see `types.ts`'s
95
+ * `Carrier.estimate` doc, `price-catalogue.ts`'s `estimateDeliveredAmount`
96
+ * for the shared arithmetic, and `classifyRelaySwapKind`/`relayFlatFeeUsd`
97
+ * for the fee model and its sources.
98
+ */
99
+ const estimate = (edge, amountIn) => {
100
+ const priceFromUsd = priceCatalogue.priceUsd(edge.from);
101
+ const priceToUsd = priceCatalogue.priceUsd(edge.to);
102
+ if (priceFromUsd === null || priceToUsd === null)
103
+ return null;
104
+ const platformFeeFraction = relayPlatformFeeFractionByKind[classifyRelaySwapKind(edge)];
105
+ const calibrationFactor = calibrationStore.factor({
106
+ providerId: 'relay',
107
+ fromChainId: edge.from.chainId,
108
+ toChainId: edge.to.chainId,
109
+ });
110
+ return {
111
+ deliveredAmount: estimateDeliveredAmount({
112
+ amountIn,
113
+ fromDecimals: edge.from.decimals,
114
+ toDecimals: edge.to.decimals,
115
+ priceFromUsd,
116
+ priceToUsd,
117
+ percentageFee: platformFeeFraction + appFeeFraction,
118
+ fixedCostUsd: relayFlatFeeUsd,
119
+ calibrationFactor,
120
+ }),
121
+ };
122
+ };
123
+ const edgesFrom = (from, to) => {
124
+ // Relay's pre-quote body types `destinationCurrency` as `Hex` — the
125
+ // delivered side of every route this carrier expresses is always an
126
+ // Ethereum-Virtual-Machine chain, exactly like the plain LI.FI pre-quote.
127
+ if (to.ecosystem !== 'evm')
128
+ return [];
129
+ const chainIds = chainList.chainIds('relay');
130
+ if (chainIds === null)
131
+ return [];
132
+ if (!chainIds.has(from.chainId) || !chainIds.has(to.chainId))
133
+ return [];
134
+ return [{ carrier: 'relay', from, to, fusesNext: false, origin: 'wallet', floors: [] }];
135
+ };
136
+ const ask = async ({ edge, inputAmount }) => {
137
+ if (inputAmount <= 0n)
138
+ return cannotCarryAnswer('non-positive input amount');
139
+ const fromAddress = probeAddresses?.[edge.from.ecosystem];
140
+ const recipientAddress = probeAddresses?.evm;
141
+ if (fromAddress === undefined || recipientAddress === undefined) {
142
+ return cannotCarryAnswer('no probe address configured for this ecosystem');
143
+ }
144
+ const request = {
145
+ fromChainId: edge.from.chainId,
146
+ fromTokenAddress: edge.from.address,
147
+ fromAddress,
148
+ toChainId: edge.to.chainId,
149
+ toTokenAddress: edge.to.address,
150
+ amount: inputAmount,
151
+ recipient: recipientAddress,
152
+ ...(edge.from.ecosystem === 'evm' ? {} : { originVmType: edge.from.ecosystem }),
153
+ // ALWAYS SET, NEVER LEFT `undefined` — `buildRelayPreQuoteRequest` falls
154
+ // back to `@gibs/bridge-sdk/relay-quote`'s `relayReferrer` constant
155
+ // ('gibs.finance') when `relayReferrer` is `undefined`. `null` (not
156
+ // `''`) tells the builder to OMIT the `referrer` field entirely when
157
+ // the host configured none — Relay's live behavior (verified
158
+ // 2026-09-23) treats ANY `referrer` value, including `''`, as
159
+ // "a referrer was supplied" and then requires an api key, so an empty
160
+ // string would turn an anonymous, keyless ask into a 401. See
161
+ // `carriers/lifi.ts`'s identical note on `lifiIntegrator`.
162
+ relayReferrer: config?.referrer ?? null,
163
+ };
164
+ const body = buildRelayPreQuoteRequest(request);
165
+ if (body === null)
166
+ return cannotCarryAnswer(null);
167
+ // NEVER ASK A PROVIDER ABOUT A CHAIN IT DOES NOT LIST — see
168
+ // `carriers/lifi.ts`'s identical guard for why `null` and "loaded but
169
+ // absent" are answered differently.
170
+ const chainIds = chainList.chainIds('relay');
171
+ if (chainIds === null)
172
+ return couldNotAskAnswer('Relay chain list not loaded');
173
+ if (!chainIds.has(edge.from.chainId) || !chainIds.has(edge.to.chainId)) {
174
+ return cannotCarryAnswer("Relay does not list one of this hop's chains");
175
+ }
176
+ const quoteUrl = config?.baseUrl === undefined ? relayQuoteUrl : `${config.baseUrl.replace(/\/+$/, '')}/quote/v2`;
177
+ let response;
178
+ try {
179
+ response = await fetchImpl(quoteUrl, {
180
+ method: 'POST',
181
+ body: JSON.stringify(body),
182
+ headers: relayRequestHeaders(config?.apiKey),
183
+ });
184
+ }
185
+ catch (error) {
186
+ return couldNotAskAnswer(error instanceof Error ? error.message : null);
187
+ }
188
+ if (!response.ok) {
189
+ const responseBody = await response.json().catch(() => null);
190
+ const refusal = readRelayRefusal(response.status, responseBody);
191
+ return {
192
+ ok: false,
193
+ refusal: {
194
+ kind: refusal.kind === 'invalid-api-key' ? 'could-not-ask' : refusal.kind,
195
+ reason: refusal.reason,
196
+ minimumAmount: refusal.minimumAmount ?? null,
197
+ },
198
+ };
199
+ }
200
+ const result = parseRelayPreQuote(await response.json());
201
+ if (result === null)
202
+ return couldNotAskAnswer('Relay pre-quote response had an unexpected shape');
203
+ // CALIBRATES `estimate()` AGAINST THIS CONFIRMED QUOTE — see `lifi.ts`'s
204
+ // identical note.
205
+ const priorEstimate = estimate(edge, inputAmount);
206
+ if (priorEstimate !== null) {
207
+ calibrationStore.record({ providerId: 'relay', fromChainId: edge.from.chainId, toChainId: edge.to.chainId }, result.expectedAmount, priorEstimate.deliveredAmount);
208
+ }
209
+ return {
210
+ ok: true,
211
+ deliveredAmount: result.expectedAmount,
212
+ minimumDeliveredAmount: result.minimumAmount,
213
+ claimedDurationSeconds: null,
214
+ };
215
+ };
216
+ return {
217
+ id: 'relay',
218
+ edgesFrom,
219
+ ask,
220
+ // Warms `priceCatalogue` for every node the search might branch through,
221
+ // filtered to chains Relay itself lists FIRST — the same "never name an
222
+ // unlisted chain to this provider" gate `ask` uses, and the same reason
223
+ // `lifi.ts`'s identical `prepare` filters too (this codebase's own node
224
+ // universe includes PulseChain, which Relay's own `/chains` registry
225
+ // does not list).
226
+ prepare: async (nodes) => {
227
+ const chainIds = chainList.chainIds('relay');
228
+ if (chainIds === null)
229
+ return;
230
+ const listedNodes = nodes.filter((node) => chainIds.has(node.chainId));
231
+ await priceCatalogue.ensureLoaded(listedNodes);
232
+ },
233
+ estimate,
234
+ isRateLimited: true,
235
+ depositMode: 'wallet-signed',
236
+ sender: 'aggregator',
237
+ // A provisional prior for pre-quote scoring, not a measured figure — see
238
+ // `lifi.ts`'s own note; Relay's own `ask` answers
239
+ // `claimedDurationSeconds: null` per-quote, above.
240
+ settlementSeconds: 60,
241
+ };
242
+ };
@@ -0,0 +1,144 @@
1
+ import type { CarrierId } from './types.js';
2
+ /** What loading one provider's chain list yields. */
3
+ export type ChainListLoadResult = {
4
+ /** Every chain id this provider currently lists. */
5
+ readonly chainIds: readonly number[];
6
+ /**
7
+ * Whatever ELSE the same response carried that a carrier's own `ask` might
8
+ * need — NEAR Intents' full asset list, in particular (see
9
+ * {@link nearIntentsChainListSource}), so `carriers/near-intents.ts` reads
10
+ * it from this same cache rather than fetching `/v0/tokens` a second time
11
+ * under a second refresh cadence. `unknown` here because this module does
12
+ * not know, and must not know, any built-in carrier's own shape — a
13
+ * carrier that reads it narrows the type itself.
14
+ */
15
+ readonly capability: unknown;
16
+ };
17
+ /** One provider's own chain-list endpoint, read fresh on every {@link ChainListRegistry.refresh} call. */
18
+ export type ChainListSource = {
19
+ /**
20
+ * Fetches and parses this provider's own chain list.
21
+ *
22
+ * @param fetchImpl - the `fetch` to call through
23
+ * @throws when the request fails or the response shape is unreadable —
24
+ * {@link ChainListRegistry.refresh} catches this and keeps serving the
25
+ * last good snapshot rather than propagating it
26
+ */
27
+ readonly load: (fetchImpl: typeof fetch) => Promise<ChainListLoadResult>;
28
+ };
29
+ /**
30
+ * Builds LI.FI's own chain-list source (`GET /v1/chains`).
31
+ *
32
+ * @param baseUrl - overrides LI.FI's own API root, mirroring
33
+ * `LifiProviderConfig.baseUrl` — omitted uses `lifiChainsUrl` directly
34
+ * @returns the source
35
+ */
36
+ export declare const lifiChainListSource: (baseUrl?: string) => ChainListSource;
37
+ /**
38
+ * Builds Relay's own chain-list source (`GET /chains`).
39
+ *
40
+ * @param baseUrl - overrides Relay's own API root, mirroring
41
+ * `RelayProviderConfig.baseUrl` — omitted uses `relayChainsUrl` directly
42
+ * @returns the source
43
+ */
44
+ export declare const relayChainListSource: (baseUrl?: string) => ChainListSource;
45
+ /**
46
+ * Builds NEAR Intents' own chain-list source, read off `GET /v0/tokens` —
47
+ * the service has no chain-listing endpoint of its own, so the chain ids
48
+ * this exposes are DERIVED, by mapping every asset's `blockchain` short name
49
+ * through `chainIdByNearIntentsChain` and keeping only the names this
50
+ * repository already resolves to a chain id (an unresolved name names no
51
+ * route this codebase can build anyway, so it is dropped rather than
52
+ * guessed at).
53
+ *
54
+ * The full parsed asset list rides along as {@link ChainListLoadResult.capability}
55
+ * so `carriers/near-intents.ts` can build a request from it directly,
56
+ * without a second fetch under a second refresh cadence.
57
+ *
58
+ * @param baseUrl - overrides NEAR Intents' own API root, mirroring
59
+ * `NearIntentsProviderConfig.baseUrl` — omitted uses `nearIntentsTokensUrl` directly
60
+ * @returns the source
61
+ */
62
+ export declare const nearIntentsChainListSource: (baseUrl?: string) => ChainListSource;
63
+ /** Every built-in provider's own chain-list source, keyed by {@link CarrierId} — the map {@link createChainListRegistry} is typically built from. */
64
+ export declare const builtInChainListSources: (baseUrls?: Readonly<Partial<Record<"lifi" | "relay" | "near-intents", string>>>) => Readonly<Record<CarrierId, ChainListSource>>;
65
+ /** How often {@link ChainListRegistry.startAutoRefresh} refreshes every registered source. */
66
+ export declare const DEFAULT_CHAIN_LIST_REFRESH_INTERVAL_MS: number;
67
+ /** How long a snapshot is still served after its own refresh cadence would have replaced it, once refreshing itself starts failing. */
68
+ export declare const DEFAULT_CHAIN_LIST_MAX_STALE_MS: number;
69
+ export type CreateChainListRegistryOptions = {
70
+ /** Every provider (or host carrier) whose chain list this registry loads and refreshes, keyed by {@link CarrierId}. */
71
+ readonly sources: Readonly<Record<CarrierId, ChainListSource>>;
72
+ readonly fetch?: typeof fetch;
73
+ readonly now?: () => number;
74
+ /** Overrides {@link DEFAULT_CHAIN_LIST_REFRESH_INTERVAL_MS}. */
75
+ readonly refreshIntervalMs?: number;
76
+ /** Overrides {@link DEFAULT_CHAIN_LIST_MAX_STALE_MS}. */
77
+ readonly maxStaleMs?: number;
78
+ };
79
+ /**
80
+ * Every registered provider's own chain list, on a refresh cadence, with the
81
+ * could-not-ask-until-loaded gate `docs/quote-service.md` calls for.
82
+ */
83
+ export type ChainListRegistry = {
84
+ /** Whether `id` has a usable snapshot right now — loaded, and not yet past its stale grace window. */
85
+ readonly isLoaded: (id: CarrierId) => boolean;
86
+ /**
87
+ * `id`'s own chain ids, or null when there is no usable snapshot —
88
+ * NEVER LOADED, or loaded but now stale past {@link DEFAULT_CHAIN_LIST_MAX_STALE_MS}.
89
+ * A carrier reading null must refuse `could-not-ask`, never guess.
90
+ *
91
+ * @param id - the carrier or provider id
92
+ * @returns the chain ids, or null
93
+ */
94
+ readonly chainIds: (id: CarrierId) => ReadonlySet<number> | null;
95
+ /**
96
+ * `id`'s own {@link ChainListLoadResult.capability} value, or undefined
97
+ * when there is no usable snapshot. A caller narrows the type itself —
98
+ * see {@link ChainListLoadResult.capability}'s own doc for why this module
99
+ * cannot narrow it for them.
100
+ *
101
+ * @param id - the carrier or provider id
102
+ * @returns the capability payload, or undefined
103
+ */
104
+ readonly capability: (id: CarrierId) => unknown;
105
+ /**
106
+ * `id`'s own snapshot's load time, in epoch milliseconds, or null when
107
+ * there is no usable snapshot — the figure `docs/quote-service.md`'s
108
+ * "Cache" section means by "a refresh stamp for the cache's cannot-carry
109
+ * lifetime": a `cannot-carry` answer cached against this stamp is stale
110
+ * the moment the stamp moves, because the chain list that justified it
111
+ * might no longer.
112
+ *
113
+ * @param id - the carrier or provider id
114
+ * @returns the load time, or null
115
+ */
116
+ readonly refreshStampMs: (id: CarrierId) => number | null;
117
+ /**
118
+ * Refreshes `id`'s own list now, joining an already-in-flight refresh for
119
+ * the same id rather than starting a second one. A failure is swallowed
120
+ * here — the previous snapshot (if any) keeps serving until it ages past
121
+ * the stale window; a host that wants failures logged reads
122
+ * {@link ChainListRegistry.isLoaded} or {@link ChainListRegistry.refreshStampMs}
123
+ * itself to notice one never lands.
124
+ *
125
+ * @param id - the carrier or provider id
126
+ * @returns settles once the refresh attempt finishes, success or failure
127
+ */
128
+ readonly refresh: (id: CarrierId) => Promise<void>;
129
+ /** Refreshes every registered id, concurrently. */
130
+ readonly refreshAll: () => Promise<void>;
131
+ /** Starts one recurring, unref'd refresh timer per registered id, at {@link CreateChainListRegistryOptions.refreshIntervalMs}. */
132
+ readonly startAutoRefresh: () => void;
133
+ /** Cancels every timer {@link ChainListRegistry.startAutoRefresh} started. Idempotent. */
134
+ readonly stopAutoRefresh: () => void;
135
+ };
136
+ /**
137
+ * Builds a fresh {@link ChainListRegistry} with no snapshots loaded — every
138
+ * id refuses `could-not-ask` (via {@link ChainListRegistry.chainIds} returning
139
+ * null) until its first successful {@link ChainListRegistry.refresh}.
140
+ *
141
+ * @param options - see {@link CreateChainListRegistryOptions}
142
+ * @returns the registry
143
+ */
144
+ export declare const createChainListRegistry: (options: CreateChainListRegistryOptions) => ChainListRegistry;
@@ -0,0 +1,169 @@
1
+ import { lifiChainsUrl, lifiEndpoints, lifiRequestHeaders, parseLifiChains } from '@gibs/bridge-sdk/consolidated-quote';
2
+ import { chainIdByNearIntentsChain, nearIntentsRequestHeaders, nearIntentsTokensUrl, parseNearIntentsTokens, } from '@gibs/bridge-sdk/near-intents';
3
+ import { parseRelayChains, relayChainsUrl } from '@gibs/bridge-sdk/relay-currencies';
4
+ const scheduleTimer = (callback, delayMs) => {
5
+ const handle = setTimeout(callback, Math.max(0, delayMs));
6
+ if (typeof handle === 'object' && handle !== null && 'unref' in handle) {
7
+ handle.unref();
8
+ }
9
+ return () => clearTimeout(handle);
10
+ };
11
+ /**
12
+ * Builds LI.FI's own chain-list source (`GET /v1/chains`).
13
+ *
14
+ * @param baseUrl - overrides LI.FI's own API root, mirroring
15
+ * `LifiProviderConfig.baseUrl` — omitted uses `lifiChainsUrl` directly
16
+ * @returns the source
17
+ */
18
+ export const lifiChainListSource = (baseUrl) => ({
19
+ load: async (fetchImpl) => {
20
+ const url = baseUrl === undefined ? lifiChainsUrl : lifiEndpoints(baseUrl).chains;
21
+ const response = await fetchImpl(url, { headers: lifiRequestHeaders() });
22
+ if (!response.ok) {
23
+ throw new Error(`LI.FI chains request failed with status ${response.status}`);
24
+ }
25
+ const parsed = parseLifiChains(await response.json());
26
+ if (parsed === null) {
27
+ throw new Error('LI.FI chains response had an unexpected shape');
28
+ }
29
+ return { chainIds: parsed.map((chain) => chain.id), capability: parsed };
30
+ },
31
+ });
32
+ /**
33
+ * Builds Relay's own chain-list source (`GET /chains`).
34
+ *
35
+ * @param baseUrl - overrides Relay's own API root, mirroring
36
+ * `RelayProviderConfig.baseUrl` — omitted uses `relayChainsUrl` directly
37
+ * @returns the source
38
+ */
39
+ export const relayChainListSource = (baseUrl) => ({
40
+ load: async (fetchImpl) => {
41
+ const url = baseUrl === undefined ? relayChainsUrl : `${baseUrl.replace(/\/+$/, '')}/chains`;
42
+ const response = await fetchImpl(url);
43
+ if (!response.ok) {
44
+ throw new Error(`Relay chains request failed with status ${response.status}`);
45
+ }
46
+ const parsed = parseRelayChains(await response.json());
47
+ if (parsed === null) {
48
+ throw new Error('Relay chains response had an unexpected shape');
49
+ }
50
+ return { chainIds: parsed, capability: parsed };
51
+ },
52
+ });
53
+ /**
54
+ * Builds NEAR Intents' own chain-list source, read off `GET /v0/tokens` —
55
+ * the service has no chain-listing endpoint of its own, so the chain ids
56
+ * this exposes are DERIVED, by mapping every asset's `blockchain` short name
57
+ * through `chainIdByNearIntentsChain` and keeping only the names this
58
+ * repository already resolves to a chain id (an unresolved name names no
59
+ * route this codebase can build anyway, so it is dropped rather than
60
+ * guessed at).
61
+ *
62
+ * The full parsed asset list rides along as {@link ChainListLoadResult.capability}
63
+ * so `carriers/near-intents.ts` can build a request from it directly,
64
+ * without a second fetch under a second refresh cadence.
65
+ *
66
+ * @param baseUrl - overrides NEAR Intents' own API root, mirroring
67
+ * `NearIntentsProviderConfig.baseUrl` — omitted uses `nearIntentsTokensUrl` directly
68
+ * @returns the source
69
+ */
70
+ export const nearIntentsChainListSource = (baseUrl) => ({
71
+ load: async (fetchImpl) => {
72
+ const url = baseUrl === undefined ? nearIntentsTokensUrl : `${baseUrl.replace(/\/+$/, '')}/v0/tokens`;
73
+ const response = await fetchImpl(url, { headers: nearIntentsRequestHeaders() });
74
+ if (!response.ok) {
75
+ throw new Error(`NEAR Intents tokens request failed with status ${response.status}`);
76
+ }
77
+ const assets = parseNearIntentsTokens(await response.json());
78
+ const chainIds = new Set();
79
+ for (const asset of assets) {
80
+ const chainId = chainIdByNearIntentsChain.get(asset.blockchain);
81
+ if (chainId !== undefined)
82
+ chainIds.add(chainId);
83
+ }
84
+ return { chainIds: Array.from(chainIds), capability: assets };
85
+ },
86
+ });
87
+ /** Every built-in provider's own chain-list source, keyed by {@link CarrierId} — the map {@link createChainListRegistry} is typically built from. */
88
+ export const builtInChainListSources = (baseUrls = {}) => ({
89
+ lifi: lifiChainListSource(baseUrls.lifi),
90
+ relay: relayChainListSource(baseUrls.relay),
91
+ 'near-intents': nearIntentsChainListSource(baseUrls['near-intents']),
92
+ });
93
+ // ---------------------------------------------------------------------------
94
+ // ChainListRegistry — refresh cadence, staleness, the could-not-ask gate
95
+ // ---------------------------------------------------------------------------
96
+ /** How often {@link ChainListRegistry.startAutoRefresh} refreshes every registered source. */
97
+ export const DEFAULT_CHAIN_LIST_REFRESH_INTERVAL_MS = 10 * 60_000;
98
+ /** How long a snapshot is still served after its own refresh cadence would have replaced it, once refreshing itself starts failing. */
99
+ export const DEFAULT_CHAIN_LIST_MAX_STALE_MS = 60 * 60_000;
100
+ /**
101
+ * Builds a fresh {@link ChainListRegistry} with no snapshots loaded — every
102
+ * id refuses `could-not-ask` (via {@link ChainListRegistry.chainIds} returning
103
+ * null) until its first successful {@link ChainListRegistry.refresh}.
104
+ *
105
+ * @param options - see {@link CreateChainListRegistryOptions}
106
+ * @returns the registry
107
+ */
108
+ export const createChainListRegistry = (options) => {
109
+ const fetchImpl = options.fetch ?? fetch;
110
+ const now = options.now ?? Date.now;
111
+ const maxStaleMs = options.maxStaleMs ?? DEFAULT_CHAIN_LIST_MAX_STALE_MS;
112
+ const refreshIntervalMs = options.refreshIntervalMs ?? DEFAULT_CHAIN_LIST_REFRESH_INTERVAL_MS;
113
+ const snapshots = new Map();
114
+ const inFlight = new Map();
115
+ let timers = [];
116
+ const freshSnapshot = (id) => {
117
+ const snapshot = snapshots.get(id);
118
+ if (snapshot === undefined)
119
+ return null;
120
+ return now() - snapshot.loadedAtMs > maxStaleMs ? null : snapshot;
121
+ };
122
+ const refresh = (id) => {
123
+ const already = inFlight.get(id);
124
+ if (already !== undefined)
125
+ return already;
126
+ const source = options.sources[id];
127
+ if (source === undefined)
128
+ return Promise.resolve();
129
+ const task = source
130
+ .load(fetchImpl)
131
+ .then(({ chainIds, capability }) => {
132
+ snapshots.set(id, { chainIds: new Set(chainIds), capability, loadedAtMs: now() });
133
+ })
134
+ .catch(() => {
135
+ // See this type's own `refresh` doc: a failed refresh keeps the
136
+ // previous snapshot (if any) serving rather than clearing it.
137
+ })
138
+ .finally(() => {
139
+ inFlight.delete(id);
140
+ });
141
+ inFlight.set(id, task);
142
+ return task;
143
+ };
144
+ return {
145
+ isLoaded: (id) => freshSnapshot(id) !== null,
146
+ chainIds: (id) => freshSnapshot(id)?.chainIds ?? null,
147
+ capability: (id) => freshSnapshot(id)?.capability,
148
+ refreshStampMs: (id) => freshSnapshot(id)?.loadedAtMs ?? null,
149
+ refresh,
150
+ refreshAll: async () => {
151
+ await Promise.all(Object.keys(options.sources).map((id) => refresh(id)));
152
+ },
153
+ startAutoRefresh: () => {
154
+ for (const id of Object.keys(options.sources)) {
155
+ const tick = () => {
156
+ void refresh(id).finally(() => {
157
+ timers.push(scheduleTimer(tick, refreshIntervalMs));
158
+ });
159
+ };
160
+ timers.push(scheduleTimer(tick, refreshIntervalMs));
161
+ }
162
+ },
163
+ stopAutoRefresh: () => {
164
+ for (const cancel of timers)
165
+ cancel();
166
+ timers = [];
167
+ },
168
+ };
169
+ };
@@ -0,0 +1,102 @@
1
+ import { type DisplayQuoteRequest, type DisplayQuoteResponse } from './quote-service-contract.js';
2
+ import { type QuoteUpstreamFunnel } from './upstream.js';
3
+ import type { Carrier, QuoteClientConfig } from './types.js';
4
+ /** The 6 s local-mode per-(ask, carrier) deadline `docs/quote-service.md`'s "Decisions" section names. */
5
+ export declare const DEFAULT_LOCAL_ASK_DEADLINE_MS = 6000;
6
+ /** 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." */
7
+ export declare const SERVICE_BREAKER_MS = 60000;
8
+ /** What a caller supplies beyond {@link QuoteClientConfig} to build a client — the pieces `docs/quote-service.md`'s "Carriers" section leaves to be wired per host. */
9
+ export type QuoteClientOptions = {
10
+ /**
11
+ * The carriers this client asks in local mode: built-in aggregators
12
+ * (LI.FI, Relay, NEAR Intents) or a host's own registered carrier. Their
13
+ * own network calls are wrapped through an `UpstreamFunnel` this function
14
+ * builds from {@link QuoteClientConfig.neverSendChainIds} and
15
+ * {@link QuoteClientConfig.limits} before ever asking one.
16
+ *
17
+ * NOT BUILT FROM `QuoteClientConfig.providers` HERE. The real LI.FI/Relay/
18
+ * NEAR Intents `ProviderAsker` implementations are a later step
19
+ * (`docs/quote-service.md`'s "Library" section); until they exist, a
20
+ * caller supplies whatever carriers it already has — real or, in a test,
21
+ * a fake built against the `Carrier` interface.
22
+ */
23
+ readonly carriers?: readonly Carrier[];
24
+ /** Overrides {@link DEFAULT_LOCAL_ASK_DEADLINE_MS}. */
25
+ readonly localAskDeadlineMs?: number;
26
+ /**
27
+ * Overrides the {@link QuoteUpstreamFunnel} this client would otherwise
28
+ * build internally from `config.neverSendChainIds`/`config.limits`.
29
+ *
30
+ * SUPPLY THIS WHEN `carriers` WERE ALREADY BUILT AGAINST AN EXTERNAL
31
+ * FUNNEL'S `guardedFetch` — `upstream.ts`'s own head note describes the
32
+ * pattern: build the funnel first, construct each carrier with
33
+ * `(input, init) => funnel.guardedFetch(carrierId, input, init)`, THEN
34
+ * wrap. Passing that SAME funnel instance here keeps its Retry-After
35
+ * cooldown and concurrency state consistent between the wire-level guard
36
+ * (`guardedFetch`, already wired into each carrier at construction) and
37
+ * the structural guard (`wrap`, applied inside this function) — building a
38
+ * second, independent funnel from the same config would run both guards,
39
+ * but each against its OWN state, so a `429` the wire-level guard records
40
+ * would never be seen by the structural one, and `statsSnapshot`'s
41
+ * `rateLimited429s`/`queueDepth` would read whichever funnel this client
42
+ * happened to build rather than the one that actually saw the traffic.
43
+ * Omitted builds a fresh funnel from `config`, exactly as before — correct
44
+ * whenever `carriers` were NOT built against an external funnel.
45
+ */
46
+ readonly funnel?: QuoteUpstreamFunnel;
47
+ /**
48
+ * Reads the host's current chain-list refresh generation — forwarded
49
+ * straight to {@link createAnswerCache}'s own `chainListRefreshStamp`
50
+ * (`cache.ts`), so a `cannot-carry` entry expires when the host's chain
51
+ * lists next reload rather than after a fixed duration, per
52
+ * `docs/quote-service.md`'s "Cache" section: "`cannot-carry` follows the
53
+ * chain-list refresh." Omitted leaves `cache.ts`'s own default (a
54
+ * `cannot-carry` entry never expires by generation, only by
55
+ * least-recently-used eviction) — correct for a caller with no chain-list
56
+ * registry of its own wired up yet.
57
+ */
58
+ readonly chainListRefreshStamp?: () => string;
59
+ };
60
+ /**
61
+ * The live counters `docs/quote-service.md`'s "Endpoint" section names for
62
+ * `/stats` — `@gibs/quote-service`'s `QuoteServiceStats` is this same shape,
63
+ * read straight off {@link QuoteClient.statsSnapshot} rather than recomputed;
64
+ * see this module's `QuoteClientStatsRecorder` for where each counter is
65
+ * actually incremented.
66
+ */
67
+ export type QuoteClientStats = {
68
+ readonly hits: number;
69
+ readonly misses: number;
70
+ readonly joined: number;
71
+ /** Upstream calls made, keyed by carrier id (`lifi`, `relay`, `near-intents`, or a host-registered carrier). */
72
+ readonly upstreamCallsByCarrier: Readonly<Record<string, number>>;
73
+ readonly rateLimited429s: number;
74
+ readonly queueDepth: number;
75
+ };
76
+ /** What {@link createQuoteClient} returns. */
77
+ export type QuoteClient = {
78
+ /**
79
+ * Answers one `POST /v1/display-quotes` request — see
80
+ * `docs/quote-service.md`'s "Endpoint" section for the full contract.
81
+ *
82
+ * @param request - 1 to 24 asks, validated against {@link displayQuoteRequestSchema}
83
+ * @returns one answer per ask, in ask order
84
+ */
85
+ readonly displayQuotes: (request: DisplayQuoteRequest) => Promise<DisplayQuoteResponse>;
86
+ /**
87
+ * A live snapshot of this client's own counters — see
88
+ * {@link QuoteClientStats}. Cheap to call repeatedly (`/stats` polls it);
89
+ * never resets on read.
90
+ */
91
+ readonly statsSnapshot: () => QuoteClientStats;
92
+ };
93
+ /**
94
+ * Builds a {@link QuoteClient} from {@link QuoteClientConfig} — see this
95
+ * module's head note and `docs/quote-service.md`'s "Library" section for the
96
+ * three modes `config` selects between.
97
+ *
98
+ * @param config - see {@link QuoteClientConfig}
99
+ * @param options - see {@link QuoteClientOptions}
100
+ * @returns the client
101
+ */
102
+ export declare const createQuoteClient: (config: QuoteClientConfig, options?: QuoteClientOptions) => QuoteClient;