@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,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
|
+
};
|