@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,939 @@
1
+ import { assetNodeKey, compareRoutePlansByGasAdjustedDeliveredAmount, composeRouteDelivery, gasAdjustedDeliveredAmount, hopEdgeKey, hopOutputAmount, isRateLimitedCarrier, planIsConfirmed, planIsRankable, zeroGasCostFor, } from '@gibs/bridge-sdk/routing';
2
+ export { isRateLimitedCarrier } from '@gibs/bridge-sdk/routing';
3
+ /** The real clock: `Date.now()` and a real `setTimeout`. What production code uses. */
4
+ export const realWaveClock = {
5
+ now: () => Date.now(),
6
+ after: (milliseconds) => new Promise((resolve) => {
7
+ setTimeout(resolve, milliseconds);
8
+ }),
9
+ };
10
+ // ---------------------------------------------------------------------------
11
+ // Budgets and caps
12
+ // ---------------------------------------------------------------------------
13
+ /** Round A's time budget: every selected first-aggregator-hop ask, given six seconds to answer, one parallel flight. */
14
+ export const ROUND_A_TIME_BUDGET_MS = 6_000;
15
+ /** Round B's time budget. */
16
+ export const ROUND_B_TIME_BUDGET_MS = 4_000;
17
+ /** {@link confirmCandidate}'s own time budget for its single ask. */
18
+ export const CONFIRM_CANDIDATE_TIME_BUDGET_MS = 4_000;
19
+ /** The most paths Round A asks per provider — LI.FI and Relay's own share of the owner's caps. */
20
+ export const DEFAULT_PROVIDER_CONCURRENCY_LIMIT = 4;
21
+ /**
22
+ * NEAR Intents' own, lower Round A cap. Measured: 40 concurrent requests
23
+ * against these providers fail about 80% of the time, while 40 in sequence
24
+ * pass — see `packages/ui/.../quoteLimiter.ts`'s own doc for the same
25
+ * measurement applied to the interface's query layer. NEAR Intents is capped
26
+ * more tightly than the other two because every one of its requests reserves
27
+ * state on the far side even when asked `dry`.
28
+ */
29
+ export const NEAR_INTENTS_CONCURRENCY_LIMIT = 3;
30
+ /** The most paths Round B asks per provider — a tighter follow-up pass than Round A. */
31
+ export const ROUND_B_MAX_REQUESTS_PER_PROVIDER = 2;
32
+ /** The most next-best, still-unconfirmed plans {@link searchRouteWaves} returns as `estimatedCandidates`. */
33
+ export const MAX_ESTIMATED_CANDIDATES = 5;
34
+ /**
35
+ * How far a live answer must diverge from its own pre-round estimate, as a
36
+ * fraction of the estimate, before {@link searchRouteWaves} reports it to
37
+ * {@link SearchRouteWavesOptions.onCalibrationDivergence}.
38
+ */
39
+ export const CALIBRATION_DIVERGENCE_RATIO_THRESHOLD = 0.01;
40
+ /** Provider concurrency caps, by `ProviderId`. Every registered provider defaults to {@link DEFAULT_PROVIDER_CONCURRENCY_LIMIT}. */
41
+ const providerConcurrencyLimit = (providerId) => providerId === 'near-intents' ? NEAR_INTENTS_CONCURRENCY_LIMIT : DEFAULT_PROVIDER_CONCURRENCY_LIMIT;
42
+ /** How long one cached edge answer is trusted before it is asked again. */
43
+ export const EDGE_ANSWER_CACHE_TTL_MS = 30_000;
44
+ // ---------------------------------------------------------------------------
45
+ // The edge answer cache — keyed by edge and a two-significant-figure amount bucket
46
+ // ---------------------------------------------------------------------------
47
+ /**
48
+ * Rounds a positive base-unit amount to two significant figures, always by
49
+ * bigint arithmetic — never through a float, which would misround a large
50
+ * amount long before the two figures this needs. `123,456,789n` buckets to
51
+ * `120,000,000n`; `7n` buckets to `7n` (already two-or-fewer digits).
52
+ *
53
+ * WHY TWO SIGNIFICANT FIGURES. Caching lets "typing reuse" an answer — a
54
+ * reader adjusting the fourth digit of an amount should not re-ask every
55
+ * provider, but a reader who has typed a materially different amount should.
56
+ * Two significant figures is coarse enough to absorb a keystroke and fine
57
+ * enough that a doubled or halved amount still buckets differently.
58
+ *
59
+ * @param amount - the amount to bucket, in base units
60
+ * @returns the bucketed amount; zero and negative inputs bucket to zero
61
+ */
62
+ export const twoSignificantFigureAmountBucket = (amount) => {
63
+ if (amount <= 0n)
64
+ return 0n;
65
+ const digitCount = amount.toString().length;
66
+ if (digitCount <= 2)
67
+ return amount;
68
+ const divisor = 10n ** BigInt(digitCount - 2);
69
+ const flooredBucket = amount / divisor;
70
+ const remainder = amount % divisor;
71
+ const roundedBucket = remainder * 2n >= divisor ? flooredBucket + 1n : flooredBucket;
72
+ return roundedBucket * divisor;
73
+ };
74
+ const edgeAnswerCacheKey = (edge, askedAmount) => `${hopEdgeKey(edge)}#${twoSignificantFigureAmountBucket(askedAmount).toString()}`;
75
+ /**
76
+ * Builds a fresh, empty {@link EdgeAnswerCache}.
77
+ *
78
+ * @param clock - supplies `now()` for recording and expiring entries;
79
+ * defaults to {@link realWaveClock}
80
+ * @returns the cache
81
+ */
82
+ export const createEdgeAnswerCache = (clock = realWaveClock) => {
83
+ const entries = new Map();
84
+ return {
85
+ get: (edge, askedAmount) => {
86
+ const key = edgeAnswerCacheKey(edge, askedAmount);
87
+ const entry = entries.get(key);
88
+ if (entry === undefined)
89
+ return null;
90
+ if (clock.now() - entry.recordedAtMs > EDGE_ANSWER_CACHE_TTL_MS) {
91
+ entries.delete(key);
92
+ return null;
93
+ }
94
+ return entry;
95
+ },
96
+ set: (edge, askedAmount, answer) => {
97
+ entries.set(edgeAnswerCacheKey(edge, askedAmount), { askedAmount, answer, recordedAtMs: clock.now() });
98
+ },
99
+ };
100
+ };
101
+ const floorsCacheKey = (edge) => [edge.carrier, assetNodeKey(edge.from), assetNodeKey(edge.to)].join('|');
102
+ /**
103
+ * Builds a fresh, empty {@link FloorsCache}.
104
+ *
105
+ * @returns the cache
106
+ */
107
+ export const createFloorsCache = () => {
108
+ const floorsByEdge = new Map();
109
+ return {
110
+ record: (edge, floor) => {
111
+ const key = floorsCacheKey(edge);
112
+ const existing = floorsByEdge.get(key);
113
+ // Keep the LARGEST known floor. A provider's own minimum only ever
114
+ // narrows the workable range from below; a smaller figure learned
115
+ // later (a different, unrelated decline) must never overwrite a
116
+ // larger one already proven true.
117
+ if (existing !== undefined && existing.minimumAmount >= floor.minimumAmount)
118
+ return;
119
+ floorsByEdge.set(key, floor);
120
+ },
121
+ read: (edge) => {
122
+ const floor = floorsByEdge.get(floorsCacheKey(edge));
123
+ return floor === undefined ? [] : [floor];
124
+ },
125
+ };
126
+ };
127
+ // ---------------------------------------------------------------------------
128
+ // The per-provider concurrency limiter
129
+ // ---------------------------------------------------------------------------
130
+ /**
131
+ * A minimal keyed semaphore: at most `limit` tasks registered under the same
132
+ * key run at once; the rest queue in the order they arrived. Internal to
133
+ * this module — see the module doc for why it is not shared with
134
+ * `packages/ui/.../quoteLimiter.ts`.
135
+ */
136
+ class KeyedConcurrencyLimiter {
137
+ activeCountByKey = new Map();
138
+ queueByKey = new Map();
139
+ /**
140
+ * Runs `task` once fewer than `limit` tasks are active under `key`,
141
+ * waiting in a FIFO queue otherwise.
142
+ *
143
+ * @param key - the concurrency group, e.g. a provider id
144
+ * @param limit - the most tasks allowed active under `key` at once
145
+ * @param task - the work to run once a slot is free
146
+ * @returns whatever `task` resolves to
147
+ */
148
+ async run(key, limit, task) {
149
+ await this.acquire(key, limit);
150
+ try {
151
+ return await task();
152
+ }
153
+ finally {
154
+ this.release(key);
155
+ }
156
+ }
157
+ acquire(key, limit) {
158
+ const active = this.activeCountByKey.get(key) ?? 0;
159
+ if (active < limit) {
160
+ this.activeCountByKey.set(key, active + 1);
161
+ return Promise.resolve();
162
+ }
163
+ return new Promise((resolve) => {
164
+ const queue = this.queueByKey.get(key) ?? [];
165
+ queue.push(() => {
166
+ this.activeCountByKey.set(key, (this.activeCountByKey.get(key) ?? 0) + 1);
167
+ resolve();
168
+ });
169
+ this.queueByKey.set(key, queue);
170
+ });
171
+ }
172
+ release(key) {
173
+ const active = this.activeCountByKey.get(key) ?? 0;
174
+ this.activeCountByKey.set(key, Math.max(0, active - 1));
175
+ const queue = this.queueByKey.get(key);
176
+ const next = queue?.shift();
177
+ if (next !== undefined)
178
+ next();
179
+ }
180
+ }
181
+ // ---------------------------------------------------------------------------
182
+ // Composing a path from known answers
183
+ // ---------------------------------------------------------------------------
184
+ /**
185
+ * One entry in a path's original, enumerate.ts-provided chain: the amount
186
+ * that ORIGINALLY flowed into hop `index`, and what that hop's own original
187
+ * outcome said it delivered. Used both as the fallback rate for any hop this
188
+ * search never got a fresh answer for, and to detect when a hop's own upstream
189
+ * amount has NOT changed — see {@link recomposePath}.
190
+ */
191
+ const originalHopInputAmounts = (path, amount) => {
192
+ const inputs = [];
193
+ let currentAmount = amount;
194
+ for (const hop of path.hops) {
195
+ inputs.push(currentAmount);
196
+ currentAmount = hop.outcome.deliveredAmount;
197
+ }
198
+ return inputs;
199
+ };
200
+ /**
201
+ * The amount THIS SEARCH WOULD ASK at for each hop of `path`: walk the
202
+ * chain, preferring a landed hop's GUARANTEED MINIMUM over its expected
203
+ * figure, falling back to {@link originalHopInputAmounts}'s chain one hop at
204
+ * a time when nothing fresh is cached for a hop yet.
205
+ *
206
+ * BUILT ITERATIVELY, EACH STEP FEEDING THE NEXT — hop `i`'s amount is looked
207
+ * up in `cache` using hop `i − 1`'s OWN amount from THIS SAME array, so a
208
+ * cache lookup here always lands on the EXACT bucket a real ask actually
209
+ * used.
210
+ *
211
+ * @param path - the plan to compute asked amounts for
212
+ * @param amount - the reader's real amount entering the plan's first hop
213
+ * @param cache - the edge answers gathered so far
214
+ * @returns one asked amount per hop, in hop order
215
+ */
216
+ const propagatedHopInputAmounts = (path, amount, cache) => {
217
+ const original = originalHopInputAmounts(path, amount);
218
+ const propagated = [amount];
219
+ for (let index = 0; index < path.hops.length - 1; index += 1) {
220
+ const askedAmount = propagated[index];
221
+ const entry = cache.get(path.hops[index].edge, askedAmount);
222
+ const nextAmount = entry !== null && entry.answer.ok
223
+ ? (entry.answer.minimumDeliveredAmount ?? entry.answer.deliveredAmount)
224
+ : (original[index + 1] ?? askedAmount);
225
+ propagated.push(nextAmount);
226
+ }
227
+ return propagated;
228
+ };
229
+ /**
230
+ * Looks up the freshest known answer for one hop of one path: the cache
231
+ * entry for `edge` at `askedAmount`'s bucket, or null when nothing is
232
+ * cached yet.
233
+ */
234
+ const lookupAnswer = (cache, edge, askedAmount) => cache.get(edge, askedAmount);
235
+ /**
236
+ * The first hop's GUARANTEED minimum — see `HopOutcome`'s
237
+ * `minimumDeliveredAmount` — when its live answer was recorded at exactly the
238
+ * amount this plan asks it at and stated one; null otherwise. Mirrors the
239
+ * condition under which {@link recomposePath} composes the first hop as a
240
+ * binding `'quote'`, so a minimum is never attached to an estimate.
241
+ */
242
+ const firstHopGuaranteedMinimum = (path, askedAmountForHop, originalInputs, startAmount, cache) => {
243
+ const firstHop = path.hops[0];
244
+ if (firstHop === undefined)
245
+ return null;
246
+ const askedAmount = askedAmountForHop[0] ?? originalInputs[0] ?? startAmount;
247
+ const entry = lookupAnswer(cache, firstHop.edge, askedAmount);
248
+ if (entry === null || !entry.answer.ok || entry.askedAmount !== askedAmount)
249
+ return null;
250
+ return entry.answer.minimumDeliveredAmount;
251
+ };
252
+ /**
253
+ * Rebuilds one {@link RoutePlan} from whatever answers are cached so far,
254
+ * falling back to the plan's ORIGINAL enumerate.ts outcome for any hop with
255
+ * no fresh answer. The first hop's answer composes as a binding
256
+ * {@link ComposableHopFigure} `'quote'` ONLY when the cache entry was itself
257
+ * recorded at THIS hop's exact `askedAmount` — every other hop, and a
258
+ * first-hop cache hit recorded at a DIFFERENT amount that merely shares its
259
+ * two-significant-figure bucket, composes as a `'probeRate'` instead.
260
+ *
261
+ * A HOP enumerate.ts ALREADY ANSWERED EXACTLY (`carrier.exact`, kind
262
+ * `'quote'`) STAYS A TRUE QUOTE WHEN NOTHING CHANGED. "Local steps are exact
263
+ * now": enumerate.ts computes a local hop's `deliveredAmount` against the
264
+ * REAL amount flowing into it at build time, so a hop with no fresh cache
265
+ * entry (this module never asks a local carrier) and whose upstream amount
266
+ * has not moved since enumerate.ts computed it is STILL exactly what it
267
+ * always was — composing it as a scaled `'probeRate'` estimate would throw
268
+ * that precision away for no reason. Only when an EARLIER hop's amount has
269
+ * genuinely changed (a live aggregator answer landed upstream of this local
270
+ * hop) does this fall back to scaling the hop's own original rate, because
271
+ * this module has no way to re-run a local carrier's own closed-form math
272
+ * without asking it — see this branch's own comment, below.
273
+ *
274
+ * @param path - the plan to recompose
275
+ * @param startAmount - the reader's real amount entering the path's first hop
276
+ * @param askedAmountForHop - what amount each hop of `path` was (or would
277
+ * be) asked at, by hop index
278
+ * @param cache - the edge answers gathered so far
279
+ * @returns the recomposed plan, with `hops[].outcome` refreshed
280
+ */
281
+ const recomposePath = (path, startAmount, askedAmountForHop, cache) => {
282
+ const originalInputs = originalHopInputAmounts(path, startAmount);
283
+ const refusalReasonByHopIndex = [];
284
+ const composableHops = path.hops.map((hop, index) => {
285
+ const askedAmount = askedAmountForHop[index] ?? originalInputs[index] ?? startAmount;
286
+ const entry = lookupAnswer(cache, hop.edge, askedAmount);
287
+ if (entry !== null && entry.answer.ok) {
288
+ refusalReasonByHopIndex.push(null);
289
+ if (index === 0 && entry.askedAmount === askedAmount) {
290
+ return { edge: hop.edge, figure: { kind: 'quote', outputAmount: entry.answer.deliveredAmount } };
291
+ }
292
+ const rate = { numerator: entry.answer.deliveredAmount, denominator: entry.askedAmount };
293
+ return { edge: hop.edge, figure: { kind: 'probeRate', rate, basis: 'probe-rate' } };
294
+ }
295
+ refusalReasonByHopIndex.push(entry !== null && !entry.answer.ok ? (entry.answer.refusal.reason ?? entry.answer.refusal.kind) : null);
296
+ const originalInputForThisHop = originalInputs[index] ?? startAmount;
297
+ // See this function's own "STAYS A TRUE QUOTE" doc: a local hop's own
298
+ // upstream amount is unchanged, so its original exact figure still
299
+ // applies verbatim.
300
+ if (hop.outcome.kind === 'quote' && askedAmount === originalInputForThisHop) {
301
+ return { edge: hop.edge, figure: { kind: 'quote', outputAmount: hop.outcome.deliveredAmount } };
302
+ }
303
+ // No fresh answer, and either this hop is an `'estimate'` awaiting a live
304
+ // ask, or it is a `'quote'` local hop whose upstream amount has genuinely
305
+ // moved — the best available figure without a network ask is the hop's
306
+ // OWN original rate, scaled to the amount actually arriving now.
307
+ const fallbackBasis = hop.outcome.kind === 'estimate' ? hop.outcome.basis : 'probe-rate';
308
+ const rate = { numerator: hop.outcome.deliveredAmount, denominator: originalInputForThisHop };
309
+ return { edge: hop.edge, figure: { kind: 'probeRate', rate, basis: fallbackBasis } };
310
+ });
311
+ const composed = composeRouteDelivery(startAmount, composableHops);
312
+ if (composed.kind === 'refused') {
313
+ return path;
314
+ }
315
+ const firstHopMinimum = firstHopGuaranteedMinimum(path, askedAmountForHop, originalInputs, startAmount, cache);
316
+ const updatedHops = [];
317
+ let runningAmount = startAmount;
318
+ for (let index = 0; index < path.hops.length; index += 1) {
319
+ const hop = path.hops[index];
320
+ const figureHop = composableHops[index];
321
+ const deliveredAmount = hopOutputAmount(figureHop, runningAmount);
322
+ if (deliveredAmount === null) {
323
+ return path;
324
+ }
325
+ const minimumDeliveredAmount = index === 0 && figureHop.figure.kind === 'quote' ? firstHopMinimum : null;
326
+ // A FUSED HOP HANDS THE NEXT ONE EXACTLY ITS GUARANTEED MINIMUM. Its
327
+ // signature is an exact-output request whose target is that minimum (the
328
+ // entry call embeds the figure), so composing the next hop from the
329
+ // EXPECTED amount would show the reader more than the signed request can
330
+ // ever deliver. The difference stays in the reader's wallet unspent.
331
+ runningAmount = hop.edge.fusesNext && minimumDeliveredAmount !== null ? minimumDeliveredAmount : deliveredAmount;
332
+ const refusalReason = refusalReasonByHopIndex[index] ?? null;
333
+ const outcome = figureHop.figure.kind === 'quote'
334
+ ? minimumDeliveredAmount === null
335
+ ? { kind: 'quote', deliveredAmount }
336
+ : { kind: 'quote', deliveredAmount, minimumDeliveredAmount }
337
+ : refusalReason === null
338
+ ? { kind: 'estimate', deliveredAmount, basis: figureHop.figure.basis }
339
+ : { kind: 'estimate', deliveredAmount, basis: figureHop.figure.basis, refusalReason };
340
+ updatedHops.push({ ...hop, outcome });
341
+ }
342
+ return { ...path, hops: updatedHops };
343
+ };
344
+ /**
345
+ * The plan as its fused first hop's signature really delivers it: the hop
346
+ * hands the next one `entryTarget` instead of its guaranteed minimum.
347
+ *
348
+ * WHY. {@link recomposePath} hands the hop after a fused one exactly the
349
+ * guaranteed minimum, because the pre-quote's floor is the entry call's first
350
+ * target. That pre-quote never paid for the entry call. When the provider's
351
+ * exact-output answer prices the call past the typed amount, the host shrinks
352
+ * the target until the spend fits (`fitEntryTarget` in the interface), so the
353
+ * cost comes out of the output. A plan that still composed from the minimum
354
+ * would show the reader more than the signature can deliver, and the press
355
+ * would report a price move that was never a move.
356
+ *
357
+ * Every later hop keeps its own rate — what it delivered for what it was
358
+ * handed — applied to the smaller amount, the same way a hop whose upstream
359
+ * amount moved is composed everywhere else in this module. The first hop's
360
+ * own outcome is untouched: it still names the minimum the entry call was
361
+ * first built for, which is what the signer checks the target against.
362
+ *
363
+ * @param plan - a plan whose first hop fuses with the next
364
+ * @param entryTarget - the entry amount the signature embeds, in the first
365
+ * hop's `to` node's base units
366
+ * @returns the plan itself when `entryTarget` is the minimum, the plan
367
+ * composed from `entryTarget` when it is smaller, or null when the first
368
+ * hop is not a fused quote with a minimum, or `entryTarget` is not positive
369
+ * or is above that minimum
370
+ */
371
+ export const planWithEntryTarget = (plan, entryTarget) => {
372
+ const firstHop = plan.hops[0];
373
+ if (firstHop === undefined || !firstHop.edge.fusesNext || firstHop.outcome.kind !== 'quote')
374
+ return null;
375
+ const minimum = firstHop.outcome.minimumDeliveredAmount;
376
+ if (minimum === undefined || entryTarget <= 0n || entryTarget > minimum)
377
+ return null;
378
+ if (entryTarget === minimum)
379
+ return plan;
380
+ const laterHops = [];
381
+ let handedOriginally = minimum;
382
+ let handedNow = entryTarget;
383
+ for (const hop of plan.hops.slice(1)) {
384
+ const rate = { numerator: hop.outcome.deliveredAmount, denominator: handedOriginally };
385
+ const deliveredAmount = hopOutputAmount({ edge: hop.edge, figure: { kind: 'probeRate', rate, basis: 'probe-rate' } }, handedNow);
386
+ if (deliveredAmount === null)
387
+ return null;
388
+ laterHops.push({ ...hop, outcome: { ...hop.outcome, deliveredAmount } });
389
+ handedOriginally = hop.outcome.deliveredAmount;
390
+ handedNow = deliveredAmount;
391
+ }
392
+ return { ...plan, hops: [firstHop, ...laterHops] };
393
+ };
394
+ /**
395
+ * Computes a {@link CalibrationDivergenceRecord} for one fresh live answer
396
+ * against its edge's pre-round estimate, or null when the estimate's own
397
+ * rate cannot be computed (a zero input amount) or the divergence does not
398
+ * clear {@link CALIBRATION_DIVERGENCE_RATIO_THRESHOLD}.
399
+ */
400
+ const calibrationDivergence = (options) => {
401
+ const { edge, askedAmount, liveDeliveredAmount, estimatedDeliveredAmount, estimatedInputAmount } = options;
402
+ if (estimatedInputAmount <= 0n || askedAmount <= 0n)
403
+ return null;
404
+ const estimatedRate = Number(estimatedDeliveredAmount) / Number(estimatedInputAmount);
405
+ if (!Number.isFinite(estimatedRate) || estimatedRate === 0)
406
+ return null;
407
+ const liveRate = Number(liveDeliveredAmount) / Number(askedAmount);
408
+ const divergenceRatio = (liveRate - estimatedRate) / estimatedRate;
409
+ if (!Number.isFinite(divergenceRatio) || Math.abs(divergenceRatio) < CALIBRATION_DIVERGENCE_RATIO_THRESHOLD) {
410
+ return null;
411
+ }
412
+ return {
413
+ edge,
414
+ askedAmount,
415
+ liveDeliveredAmount,
416
+ estimatedDeliveredAmount,
417
+ estimatedInputAmount,
418
+ divergenceRatio,
419
+ };
420
+ };
421
+ /**
422
+ * Every aggregator edge's PRE-ROUND estimate, keyed by {@link hopEdgeKey} —
423
+ * built once per {@link searchRouteWaves} call from `paths`' own pristine
424
+ * hops, so a live answer landing later has something to compare against.
425
+ * Keeps the FIRST estimate seen for a given edge key; two paths reaching the
426
+ * same edge at two different (and so differently price-impacted) amounts is
427
+ * an accepted approximation here — see {@link CalibrationDivergenceRecord}'s
428
+ * own doc for what this feeds.
429
+ */
430
+ const buildPreRoundEstimatesByEdgeKey = (paths, amount) => {
431
+ const estimates = new Map();
432
+ for (const path of paths) {
433
+ const inputs = originalHopInputAmounts(path, amount);
434
+ path.hops.forEach((hop, index) => {
435
+ if (!isRateLimitedCarrier(hop.edge.carrier))
436
+ return;
437
+ if (hop.outcome.kind !== 'estimate')
438
+ return;
439
+ const key = hopEdgeKey(hop.edge);
440
+ if (estimates.has(key))
441
+ return;
442
+ estimates.set(key, { inputAmount: inputs[index] ?? amount, deliveredAmount: hop.outcome.deliveredAmount });
443
+ });
444
+ }
445
+ return estimates;
446
+ };
447
+ /** Dedupes a list of planned asks by edge key + asked amount, keeping the first occurrence. */
448
+ const dedupeAsks = (asks) => {
449
+ const seen = new Set();
450
+ const result = [];
451
+ for (const ask of asks) {
452
+ const key = `${hopEdgeKey(ask.edge)}#${ask.askedAmount.toString()}`;
453
+ if (seen.has(key))
454
+ continue;
455
+ seen.add(key);
456
+ result.push(ask);
457
+ }
458
+ return result;
459
+ };
460
+ /**
461
+ * Runs one dispatch: dedupes the asks, prunes any already floored, serves
462
+ * cache hits without a request (except an ask marked
463
+ * {@link PlannedAsk.requireExactAmount}), dispatches cache misses through the
464
+ * per-provider limiter, races the whole batch against a time budget, and
465
+ * reports any fresh answer that diverges from its own pre-round estimate.
466
+ *
467
+ * NO SHARED REQUEST COUNTER. Unlike the previous wave-based design, the
468
+ * asks handed to this function are ALREADY the exact, capped set a round's
469
+ * own selection step decided on (see {@link buildRoundAAsks}/
470
+ * {@link buildRoundBAsks}) — this function's only job is running them
471
+ * safely, not deciding how many to run.
472
+ *
473
+ * @returns every refusal freshly learned this round (cache hits are not
474
+ * re-reported; they were reported the round they were first learned)
475
+ */
476
+ const runDispatch = async (options) => {
477
+ const { asks, timeBudgetMs, askEdge, clock, cache, floors, limiter, preRoundEstimatesByEdgeKey, onCalibrationDivergence, inFlight, } = options;
478
+ const deduped = dedupeAsks(asks);
479
+ const freshRefusals = [];
480
+ const dispatchable = [];
481
+ for (const ask of deduped) {
482
+ const cachedEntry = cache.get(ask.edge, ask.askedAmount);
483
+ const cacheAnswersThisAsk = cachedEntry !== null && (ask.requireExactAmount !== true || cachedEntry.askedAmount === ask.askedAmount);
484
+ if (cacheAnswersThisAsk)
485
+ continue;
486
+ const floor = floors.read(ask.edge)[0];
487
+ if (floor !== undefined && floor.minimumAmount > ask.askedAmount) {
488
+ cache.set(ask.edge, ask.askedAmount, {
489
+ ok: false,
490
+ refusal: { kind: 'below-minimum', reason: floor.reason, minimumAmount: floor.minimumAmount },
491
+ });
492
+ continue;
493
+ }
494
+ dispatchable.push(ask);
495
+ }
496
+ const pendingSettlements = dispatchable.map((ask) => {
497
+ const inFlightKey = `${hopEdgeKey(ask.edge)}#${ask.askedAmount.toString()}`;
498
+ const alreadyAsking = inFlight.get(inFlightKey);
499
+ if (alreadyAsking !== undefined)
500
+ return alreadyAsking;
501
+ const limiterKey = ask.edge.carrier;
502
+ const limit = isRateLimitedCarrier(ask.edge.carrier)
503
+ ? providerConcurrencyLimit(ask.edge.carrier)
504
+ : Number.POSITIVE_INFINITY;
505
+ const settlement = limiter
506
+ .run(limiterKey, limit, () => askEdge({ edge: ask.edge, inputAmount: ask.askedAmount }))
507
+ .then((answer) => {
508
+ cache.set(ask.edge, ask.askedAmount, answer);
509
+ if (answer.ok) {
510
+ const estimate = preRoundEstimatesByEdgeKey.get(hopEdgeKey(ask.edge));
511
+ if (estimate !== undefined && onCalibrationDivergence !== undefined) {
512
+ const record = calibrationDivergence({
513
+ edge: ask.edge,
514
+ askedAmount: ask.askedAmount,
515
+ liveDeliveredAmount: answer.deliveredAmount,
516
+ estimatedDeliveredAmount: estimate.deliveredAmount,
517
+ estimatedInputAmount: estimate.inputAmount,
518
+ });
519
+ if (record !== null)
520
+ onCalibrationDivergence(record);
521
+ }
522
+ return;
523
+ }
524
+ freshRefusals.push({ edge: ask.edge, askedAmount: ask.askedAmount, refusal: answer.refusal });
525
+ if (answer.refusal.minimumAmount !== null) {
526
+ floors.record(ask.edge, {
527
+ reason: answer.refusal.reason ?? answer.refusal.kind,
528
+ minimumAmount: answer.refusal.minimumAmount,
529
+ });
530
+ }
531
+ })
532
+ .catch(() => {
533
+ // An askEdge that rejects outright (rather than resolving to
534
+ // `{ok: false, ...}`) is treated as an unanswered edge: it stays
535
+ // absent from the cache, so the fallback estimate in
536
+ // `recomposePath` is used and nothing about the omission is
537
+ // recorded as a floor.
538
+ })
539
+ .finally(() => {
540
+ inFlight.delete(inFlightKey);
541
+ });
542
+ inFlight.set(inFlightKey, settlement);
543
+ return settlement;
544
+ });
545
+ await Promise.race([Promise.all(pendingSettlements), clock.after(timeBudgetMs)]);
546
+ return freshRefusals;
547
+ };
548
+ /**
549
+ * Stably moves every UNCONFIRMED plan (`planIsConfirmed` false) below every
550
+ * CONFIRMED one, keeping each group's own relative order from
551
+ * `compareRoutePlans` untouched. `Array.prototype.sort` is a STABLE sort
552
+ * (guaranteed since ECMAScript 2019), which is what makes this safe.
553
+ *
554
+ * @param ranked - plans already sorted by the search's own comparator
555
+ * @returns the same plans, confirmed ones first, each group's own order preserved
556
+ */
557
+ const partitionConfirmedFirst = (ranked) => [...ranked].sort((a, b) => {
558
+ const confirmedA = planIsConfirmed(a.hops);
559
+ const confirmedB = planIsConfirmed(b.hops);
560
+ if (confirmedA === confirmedB)
561
+ return 0;
562
+ return confirmedA ? -1 : 1;
563
+ });
564
+ // ---------------------------------------------------------------------------
565
+ // Pruning — an estimate that cannot beat the best confirmed route is dropped
566
+ // ---------------------------------------------------------------------------
567
+ /**
568
+ * How far above its own figure an estimated hop may still turn out to land,
569
+ * in parts per million, charged once for every `'estimate'` hop in a plan.
570
+ * The same 1% {@link CALIBRATION_DIVERGENCE_RATIO_THRESHOLD} names as the
571
+ * point where a live answer counts as having moved away from its estimate:
572
+ * inside it, the estimate was right; past it, the carrier's own model is
573
+ * corrected. So 1% per estimated hop is the widest honest "best case" the
574
+ * engine can state from what it knows before asking.
575
+ */
576
+ export const ESTIMATE_ALLOWANCE_PPM_PER_ESTIMATED_HOP = 10000n;
577
+ const PARTS_PER_MILLION = 1000000n;
578
+ /**
579
+ * The most a plan could still deliver, net of every signature's gas, in its
580
+ * destination token's base units — or null when the plan has no figure a
581
+ * ranking may trust (`planIsRankable` false), which is a plan the engine can
582
+ * neither bound nor show.
583
+ *
584
+ * AN UPPER BOUND, BUILT ONLY FROM WHAT IS KNOWN AHEAD OF TIME. Each hop's
585
+ * figure already carries that carrier's own known fee (a local carrier's
586
+ * exact read, an aggregator's provider model, or the price catalogue), and
587
+ * {@link gasAdjustedDeliveredAmount} subtracts the real gas of EVERY
588
+ * signature. The only optimism added is
589
+ * {@link ESTIMATE_ALLOWANCE_PPM_PER_ESTIMATED_HOP} per hop that is still an
590
+ * estimate. A confirmed plan gets no allowance: its figure is what it is.
591
+ *
592
+ * @param plan - the plan to bound
593
+ * @param gasCostFor - the real cost of one signature
594
+ * @returns the plan's best-case net delivery, or null when it is not rankable
595
+ */
596
+ export const planBestCaseNetDelivery = (plan, gasCostFor) => {
597
+ if (!planIsRankable(plan.hops))
598
+ return null;
599
+ const deliveredAmount = plan.hops[plan.hops.length - 1]?.outcome.deliveredAmount ?? 0n;
600
+ const estimatedHopCount = BigInt(plan.hops.filter((hop) => hop.outcome.kind === 'estimate').length);
601
+ const allowance = (deliveredAmount * ESTIMATE_ALLOWANCE_PPM_PER_ESTIMATED_HOP * estimatedHopCount) / PARTS_PER_MILLION;
602
+ return gasAdjustedDeliveredAmount(plan, gasCostFor) + allowance;
603
+ };
604
+ /**
605
+ * The best net delivery among `plans`' CONFIRMED plans — delivered minus the
606
+ * gas of every signature — or null when none of them is confirmed yet.
607
+ *
608
+ * @param plans - any plans; unconfirmed ones are ignored
609
+ * @param gasCostFor - the real cost of one signature
610
+ * @returns the best confirmed net delivery, or null
611
+ */
612
+ export const bestConfirmedNetDelivery = (plans, gasCostFor) => plans.reduce((best, plan) => {
613
+ if (!planIsConfirmed(plan.hops))
614
+ return best;
615
+ const net = gasAdjustedDeliveredAmount(plan, gasCostFor);
616
+ return best === null || net > best ? net : best;
617
+ }, null);
618
+ /**
619
+ * Whether an unconfirmed plan's best case still beats `bestConfirmed`. An
620
+ * unrankable plan never does: with no figure there is nothing to compare,
621
+ * and the owner's rule is that such a path is never shown. With nothing
622
+ * confirmed yet, every rankable plan is still in the running.
623
+ *
624
+ * GENERIC AND COST-BASED. Nothing here reads a chain, a carrier or a token.
625
+ * A path that leaves a chain and comes back is dropped for the same reason
626
+ * any other path is: each extra signature costs at least its gas, and each
627
+ * crossing costs at least its known fee, so its best case falls short.
628
+ *
629
+ * @param plan - the unconfirmed plan
630
+ * @param bestConfirmed - the best confirmed net delivery, or null when none exists
631
+ * @param gasCostFor - the real cost of one signature
632
+ * @returns true when the plan could still win
633
+ */
634
+ export const estimateCanStillWin = (plan, bestConfirmed, gasCostFor) => {
635
+ const bestCase = planBestCaseNetDelivery(plan, gasCostFor);
636
+ if (bestCase === null)
637
+ return false;
638
+ if (bestConfirmed === null)
639
+ return true;
640
+ return bestCase > bestConfirmed;
641
+ };
642
+ /**
643
+ * Drops every unconfirmed, RANKABLE path whose best case cannot beat the best
644
+ * path already confirmed by a local exact read — BEFORE any provider is
645
+ * asked, so a path that cannot win never spends a live quote.
646
+ *
647
+ * An unrankable path is kept here, on purpose: it has no figure to bound, so
648
+ * nothing proves it loses, and a live answer is the only way it can ever get
649
+ * one. It is still never SHOWN as an estimate — see
650
+ * {@link splitConfirmedAndEstimated}.
651
+ *
652
+ * @param paths - every enumerated path
653
+ * @param gasCostFor - the real cost of one signature
654
+ * @returns the paths still worth asking about
655
+ */
656
+ export const prunePathsThatCannotWin = (paths, gasCostFor) => {
657
+ const bestConfirmed = bestConfirmedNetDelivery(paths, gasCostFor);
658
+ if (bestConfirmed === null)
659
+ return paths;
660
+ return paths.filter((path) => planIsConfirmed(path.hops) || !planIsRankable(path.hops) || estimateCanStillWin(path, bestConfirmed, gasCostFor));
661
+ };
662
+ /**
663
+ * Splits plans already sorted best first into the two lists a search
664
+ * returns: every confirmed plan, and at most {@link MAX_ESTIMATED_CANDIDATES}
665
+ * unconfirmed ones that carry an estimate AND could still beat the best
666
+ * confirmed plan. Each list keeps the sorted order it was handed, so the
667
+ * estimates are the top five by estimated net output.
668
+ *
669
+ * @param ranked - plans sorted by the search's own comparator, best first
670
+ * @param gasCostFor - the real cost of one signature
671
+ * @returns the confirmed plans and the estimates worth showing
672
+ */
673
+ const splitConfirmedAndEstimated = (ranked, gasCostFor) => {
674
+ const plans = ranked.filter((plan) => planIsConfirmed(plan.hops));
675
+ const bestConfirmed = bestConfirmedNetDelivery(plans, gasCostFor);
676
+ const estimatedCandidates = ranked
677
+ .filter((plan) => !planIsConfirmed(plan.hops) && estimateCanStillWin(plan, bestConfirmed, gasCostFor))
678
+ .slice(0, MAX_ESTIMATED_CANDIDATES);
679
+ return { plans, estimatedCandidates };
680
+ };
681
+ /**
682
+ * Folds one plan that {@link SearchRouteWavesResult.confirmCandidate} just
683
+ * re-priced back into a finished search result — WITHOUT which a confirmed
684
+ * estimate had nowhere to go. `confirmCandidate` only writes the live answer
685
+ * into the search's own caches; the result a host renders is plain data and
686
+ * never changes on its own, so the estimate row stayed an estimate forever.
687
+ *
688
+ * The plan replaces the entry with its own `pathKey` wherever it sat. A plan
689
+ * that is now confirmed joins `plans`; one that is not stays an estimate
690
+ * only while it still carries a figure and could still win. Both lists are
691
+ * re-sorted and re-pruned, because a newly confirmed plan can raise the bar
692
+ * every remaining estimate must clear.
693
+ *
694
+ * @param result - the search result to update
695
+ * @param plan - the plan `confirmCandidate` returned
696
+ * @param options - the SAME comparator and gas model the search ranked with
697
+ * @returns a new result; `result` itself is never changed
698
+ */
699
+ export const applyConfirmedCandidate = (result, plan, options) => {
700
+ const others = [...result.plans, ...result.estimatedCandidates].filter((entry) => entry.pathKey !== plan.pathKey);
701
+ const ranked = [...others, plan].sort(options.compareRoutePlans);
702
+ return { ...result, ...splitConfirmedAndEstimated(ranked, options.gasCostFor) };
703
+ };
704
+ // ---------------------------------------------------------------------------
705
+ // Round selection — which paths' aggregator hops get asked, and in what order
706
+ // ---------------------------------------------------------------------------
707
+ /** Every hop index of `path` carried by a rate-limited (aggregator) carrier, in hop order. */
708
+ const aggregatorHopIndices = (path) => path.hops.reduce((indices, hop, index) => {
709
+ if (isRateLimitedCarrier(hop.edge.carrier))
710
+ indices.push(index);
711
+ return indices;
712
+ }, []);
713
+ /** The amount flowing into hop `hopIndex` of `path`, from enumerate.ts's own pristine chain. */
714
+ const originalAmountAtHop = (path, amount, hopIndex) => originalHopInputAmounts(path, amount)[hopIndex] ?? amount;
715
+ /**
716
+ * Selects Round A's asks: each path's FIRST aggregator hop, in pre-screen
717
+ * order (`paths`' own order — `enumerate.ts`'s output), until each provider
718
+ * holds its own cap ({@link DEFAULT_PROVIDER_CONCURRENCY_LIMIT}/
719
+ * {@link NEAR_INTENTS_CONCURRENCY_LIMIT}) — WITH a diversity reservation: the
720
+ * best-ranked path per first-aggregator carrier, and the best-ranked path per
721
+ * signature count (1, 2, 3), are guaranteed a slot ahead of plain pre-screen
722
+ * fill, so a search dominated by one provider's paths still asks the other
723
+ * providers, and a search dominated by long paths still asks a short one.
724
+ *
725
+ * A path with no aggregator hop at all (every hop already exact) needs no
726
+ * Round A ask — it is simply absent from the result.
727
+ *
728
+ * @param paths - every candidate path, in pre-screen order
729
+ * @param amount - the reader's real amount entering each path's first hop
730
+ * @returns one candidate ask per selected path, never more than one per path
731
+ */
732
+ const buildRoundAAsks = (paths, amount) => {
733
+ const candidates = [];
734
+ for (const path of paths) {
735
+ const hopIndex = aggregatorHopIndices(path)[0];
736
+ if (hopIndex === undefined)
737
+ continue;
738
+ const providerId = path.hops[hopIndex].edge.carrier;
739
+ candidates.push({ path, hopIndex, providerId, askedAmount: originalAmountAtHop(path, amount, hopIndex) });
740
+ }
741
+ const guaranteed = [];
742
+ const bestByProvider = new Map();
743
+ const bestBySignatureCount = new Map();
744
+ for (const candidate of candidates) {
745
+ if (!bestByProvider.has(candidate.providerId)) {
746
+ bestByProvider.set(candidate.providerId, candidate);
747
+ guaranteed.push(candidate);
748
+ }
749
+ const signatureCount = candidate.path.signatures.length;
750
+ if (signatureCount >= 1 && signatureCount <= 3 && !bestBySignatureCount.has(signatureCount)) {
751
+ bestBySignatureCount.set(signatureCount, candidate);
752
+ guaranteed.push(candidate);
753
+ }
754
+ }
755
+ const selected = [];
756
+ const selectedPathKeys = new Set();
757
+ const usedByProvider = new Map();
758
+ const tryAdd = (candidate) => {
759
+ if (selectedPathKeys.has(candidate.path.pathKey))
760
+ return;
761
+ const used = usedByProvider.get(candidate.providerId) ?? 0;
762
+ if (used >= providerConcurrencyLimit(candidate.providerId))
763
+ return;
764
+ selected.push(candidate);
765
+ selectedPathKeys.add(candidate.path.pathKey);
766
+ usedByProvider.set(candidate.providerId, used + 1);
767
+ };
768
+ for (const candidate of guaranteed)
769
+ tryAdd(candidate);
770
+ for (const candidate of candidates)
771
+ tryAdd(candidate);
772
+ return selected;
773
+ };
774
+ /**
775
+ * Selects Round B's asks: each ranked path's SECOND aggregator hop, asked at
776
+ * Round A's own guaranteed minimum for that path (propagated through any
777
+ * local-exact hops between the two aggregator hops), up to
778
+ * {@link ROUND_B_MAX_REQUESTS_PER_PROVIDER} per provider. Only a path whose
779
+ * FIRST aggregator hop already landed a live answer in Round A is eligible —
780
+ * a path Round A never reached (its provider's cap was already spent) has no
781
+ * guaranteed minimum to ask Round B's second hop against, so it is skipped
782
+ * here rather than asked at a stale estimate.
783
+ *
784
+ * @param rankedAfterRoundA - paths ranked by the search's own comparator
785
+ * after Round A's answers were recomposed in — best first
786
+ * @param amount - the reader's real amount entering each path's first hop
787
+ * @param cache - the edge answers gathered so far (Round A's own answers)
788
+ * @returns one candidate ask per selected path, capped per provider
789
+ */
790
+ const buildRoundBAsks = (rankedAfterRoundA, amount, cache) => {
791
+ const candidates = [];
792
+ for (const path of rankedAfterRoundA) {
793
+ const indices = aggregatorHopIndices(path);
794
+ const firstIndex = indices[0];
795
+ const secondIndex = indices[1];
796
+ if (firstIndex === undefined || secondIndex === undefined)
797
+ continue;
798
+ const firstAskedAmount = originalAmountAtHop(path, amount, firstIndex);
799
+ const firstEntry = cache.get(path.hops[firstIndex].edge, firstAskedAmount);
800
+ if (firstEntry === null || !firstEntry.answer.ok)
801
+ continue;
802
+ const propagated = propagatedHopInputAmounts(path, amount, cache);
803
+ const askedAmount = propagated[secondIndex] ?? originalAmountAtHop(path, amount, secondIndex);
804
+ const providerId = path.hops[secondIndex].edge.carrier;
805
+ candidates.push({ path, hopIndex: secondIndex, providerId, askedAmount });
806
+ }
807
+ const selected = [];
808
+ const usedByProvider = new Map();
809
+ for (const candidate of candidates) {
810
+ const used = usedByProvider.get(candidate.providerId) ?? 0;
811
+ if (used >= ROUND_B_MAX_REQUESTS_PER_PROVIDER)
812
+ continue;
813
+ selected.push(candidate);
814
+ usedByProvider.set(candidate.providerId, used + 1);
815
+ }
816
+ return selected;
817
+ };
818
+ /**
819
+ * The Round-A/Round-B route search: prices every plan `enumerate.ts` found
820
+ * against real aggregators, asking only the hops that are not already exact,
821
+ * in two bounded, budgeted rounds, re-ranking after each one.
822
+ *
823
+ * - **Round A** asks each path's first aggregator hop — see {@link buildRoundAAsks}
824
+ * — within {@link ROUND_A_TIME_BUDGET_MS}, one parallel flight.
825
+ * - **Round B** asks each ranked path's second aggregator hop, at Round A's
826
+ * own guaranteed minimum — see {@link buildRoundBAsks} — within
827
+ * {@link ROUND_B_TIME_BUDGET_MS}.
828
+ *
829
+ * Every hop without a fresh answer — a local-exact hop (never asked, on
830
+ * purpose), or an aggregator hop this search's caps left unasked — keeps
831
+ * composing from its own last-known figure, so a partial result is always a
832
+ * complete, ranked list of plans, never a list with holes in it.
833
+ *
834
+ * PRUNED FIRST: {@link prunePathsThatCannotWin} drops every path whose best
835
+ * case cannot beat a plan a local exact read already confirmed, before any
836
+ * provider is asked.
837
+ *
838
+ * THE FINAL SPLIT: `plans` (confirmed) VERSUS `estimatedCandidates`
839
+ * (unconfirmed plans that carry an estimate and could still win, capped at
840
+ * {@link MAX_ESTIMATED_CANDIDATES}) —
841
+ * {@link partitionConfirmedFirst} orders every plan confirmed-first exactly as
842
+ * before; this function then SLICES that ordering into the two output
843
+ * fields, rather than returning one merged list, per the owner's own model:
844
+ * an estimate is listed separately, never blended into the ranked winners.
845
+ *
846
+ * @param options - see {@link SearchRouteWavesOptions}
847
+ * @returns the re-priced plans, split into confirmed and estimated
848
+ * candidates, every refusal collected, and a way to confirm one candidate
849
+ * on demand
850
+ */
851
+ export const searchRouteWaves = async (options) => {
852
+ const { amount, askEdge, gasCostFor = zeroGasCostFor, compareRoutePlans = compareRoutePlansByGasAdjustedDeliveredAmount(gasCostFor), clock = realWaveClock, edgeAnswerCache, floorsCache, onWaveSettled, onCalibrationDivergence, } = options;
853
+ // PRUNED BEFORE ANYTHING IS ASKED. A path whose best case cannot beat a
854
+ // route a local exact read already confirmed is never quoted and never
855
+ // shown — see `prunePathsThatCannotWin`.
856
+ const paths = prunePathsThatCannotWin(options.paths, gasCostFor);
857
+ const cache = edgeAnswerCache ?? createEdgeAnswerCache(clock);
858
+ const floors = floorsCache ?? createFloorsCache();
859
+ const limiter = new KeyedConcurrencyLimiter();
860
+ const inFlight = new Map();
861
+ const allRefusals = [];
862
+ const preRoundEstimatesByEdgeKey = buildPreRoundEstimatesByEdgeKey(paths, amount);
863
+ const pathsByKey = new Map(paths.map((path) => [path.pathKey, path]));
864
+ const dispatch = (asks, timeBudgetMs) => runDispatch({
865
+ asks,
866
+ timeBudgetMs,
867
+ askEdge,
868
+ clock,
869
+ cache,
870
+ floors,
871
+ limiter,
872
+ preRoundEstimatesByEdgeKey,
873
+ inFlight,
874
+ ...(onCalibrationDivergence === undefined ? {} : { onCalibrationDivergence }),
875
+ });
876
+ const recomposeAll = () => paths.map((path) => recomposePath(path, amount, propagatedHopInputAmounts(path, amount, cache), cache));
877
+ /**
878
+ * The next aggregator hop of `path` that has no usable answer yet, as an
879
+ * ask at the amount the hops before it now deliver — or null when every
880
+ * aggregator hop of the path already has one.
881
+ */
882
+ const nextUnansweredAsk = (path) => {
883
+ const propagated = propagatedHopInputAmounts(path, amount, cache);
884
+ const hopIndex = aggregatorHopIndices(path).find((index) => {
885
+ const askedAmount = propagated[index] ?? originalAmountAtHop(path, amount, index);
886
+ const entry = cache.get(path.hops[index].edge, askedAmount);
887
+ const satisfiesBinding = index === 0 ? entry !== null && entry.askedAmount === askedAmount : entry !== null;
888
+ return !satisfiesBinding;
889
+ });
890
+ if (hopIndex === undefined)
891
+ return null;
892
+ return {
893
+ edge: path.hops[hopIndex].edge,
894
+ askedAmount: propagated[hopIndex] ?? originalAmountAtHop(path, amount, hopIndex),
895
+ requireExactAmount: hopIndex === 0,
896
+ };
897
+ };
898
+ /**
899
+ * EVERY AGGREGATOR HOP, ONE AFTER ANOTHER — not only the next one. A later
900
+ * hop is asked at what the earlier one really delivers, so it cannot go
901
+ * first; but stopping after one ask (as this once did) left a path with two
902
+ * aggregator hops an estimate even after it came into view. It stops early
903
+ * only when an ask is refused or does not answer inside its budget: a
904
+ * later hop has no real input amount to be asked at then.
905
+ */
906
+ const confirmCandidate = async (pathKey) => {
907
+ const original = pathsByKey.get(pathKey);
908
+ if (original === undefined)
909
+ return null;
910
+ for (let asked = 0; asked < aggregatorHopIndices(original).length; asked += 1) {
911
+ const ask = nextUnansweredAsk(original);
912
+ if (ask === null)
913
+ break;
914
+ allRefusals.push(...(await dispatch([ask], CONFIRM_CANDIDATE_TIME_BUDGET_MS)));
915
+ const entry = cache.get(ask.edge, ask.askedAmount);
916
+ if (entry === null || !entry.answer.ok)
917
+ break;
918
+ }
919
+ return recomposePath(original, amount, propagatedHopInputAmounts(original, amount, cache), cache);
920
+ };
921
+ if (paths.length === 0) {
922
+ return { plans: [], estimatedCandidates: [], refusals: [], confirmCandidate };
923
+ }
924
+ // -- Round A --------------------------------------------------------------
925
+ const roundAAsks = buildRoundAAsks(paths, amount).map((candidate) => ({
926
+ edge: candidate.path.hops[candidate.hopIndex].edge,
927
+ askedAmount: candidate.askedAmount,
928
+ requireExactAmount: candidate.hopIndex === 0,
929
+ }));
930
+ allRefusals.push(...(await dispatch(roundAAsks, ROUND_A_TIME_BUDGET_MS)));
931
+ let ranked = [...recomposeAll()].sort(compareRoutePlans);
932
+ onWaveSettled?.({ round: 'A', plans: ranked });
933
+ // -- Round B --------------------------------------------------------------
934
+ const roundBAsks = buildRoundBAsks(ranked, amount, cache).map((candidate) => ({ edge: candidate.path.hops[candidate.hopIndex].edge, askedAmount: candidate.askedAmount }));
935
+ allRefusals.push(...(await dispatch(roundBAsks, ROUND_B_TIME_BUDGET_MS)));
936
+ ranked = [...recomposeAll()].sort(compareRoutePlans);
937
+ onWaveSettled?.({ round: 'B', plans: ranked });
938
+ return { ...splitConfirmedAndEstimated(partitionConfirmedFirst(ranked), gasCostFor), refusals: allRefusals, confirmCandidate };
939
+ };