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