@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
|
@@ -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
|
+
};
|
package/dist/client.d.ts
ADDED
|
@@ -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;
|