@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,313 @@
|
|
|
1
|
+
import { isDepositAddressChain } from '@gibs/bridge-sdk/chain-names';
|
|
2
|
+
import { assetNodeKey, compareRoutePlansByScoreAdjustedDeliveredAmount, groupHopsBySignature, hopEdgeKey, routePlanPathKey, } from '@gibs/bridge-sdk/routing';
|
|
3
|
+
/**
|
|
4
|
+
* Depth-first search over a fixed node universe, pruning BEFORE anything is
|
|
5
|
+
* asked over the network (route search design, section 2). Everything here
|
|
6
|
+
* is pure: the node universe, the {@link CapabilityIndex}, the starting
|
|
7
|
+
* amount and the estimate function all arrive as arguments, and nothing is
|
|
8
|
+
* fetched — but a hop whose carrier answers through {@link Carrier.exact}
|
|
9
|
+
* (a synchronous LOCAL read, never a network call) carries a real `'quote'`
|
|
10
|
+
* outcome straight out of this module; every other hop carries an
|
|
11
|
+
* `'estimate'`, refined later by `waves.ts`'s live network asks.
|
|
12
|
+
*
|
|
13
|
+
* CARRIER-AGNOSTIC. Every prior version of this module special-cased ONE
|
|
14
|
+
* host's carriers directly (a hard-coded PulseX-only-runs-on-PulseChain
|
|
15
|
+
* check, and a hard-coded "only NEAR Intents may open a pay-by-hand
|
|
16
|
+
* origin"). Both are now the registering carrier's OWN job: `edgesFrom` only
|
|
17
|
+
* ever returns an edge for the pairs it can actually carry
|
|
18
|
+
* (`routing/carriers/pulsex.ts`'s own scoping, for gibs), and
|
|
19
|
+
* {@link Carrier.depositMode} states whether a carrier accepts a by-hand
|
|
20
|
+
* deposit — so this module never needs to know any carrier's NAME to enforce
|
|
21
|
+
* either rule, only the vocabulary every registered `Carrier` speaks.
|
|
22
|
+
*/
|
|
23
|
+
/** Depth cap: at most this many hops in one plan (design section 2, item 3). */
|
|
24
|
+
export const DEFAULT_MAX_HOPS = 4;
|
|
25
|
+
/** Signature cap: at most this many signature groups in one plan. */
|
|
26
|
+
export const DEFAULT_MAX_SIGNATURES = 3;
|
|
27
|
+
/**
|
|
28
|
+
* A hard, absolute ceiling on how many finished plans one
|
|
29
|
+
* {@link enumerateRoutes} call collects — a SAFETY VALVE, not a ranking cut.
|
|
30
|
+
* The owner's own routing model is "iterate through each possible pathway
|
|
31
|
+
* and order them by output": every path within `maxHops`/`maxSignatures` is
|
|
32
|
+
* enumerated and returned, sorted, with nothing trimmed to a top-N slice.
|
|
33
|
+
* This constant exists only to bound the search on a node universe
|
|
34
|
+
* pathological enough that the hop/signature caps alone do not keep it
|
|
35
|
+
* finite in practice; hitting it is a signal to revisit the node universe or
|
|
36
|
+
* the caps, not evidence that ranking picked correctly among what remained.
|
|
37
|
+
*/
|
|
38
|
+
export const MAX_ENUMERATED_PATHS = 10_000;
|
|
39
|
+
/** Dedupes a node list by {@link assetNodeKey}, keeping the first occurrence. */
|
|
40
|
+
const uniqueNodes = (nodes) => {
|
|
41
|
+
const seen = new Map();
|
|
42
|
+
for (const node of nodes) {
|
|
43
|
+
const key = assetNodeKey(node);
|
|
44
|
+
if (!seen.has(key)) {
|
|
45
|
+
seen.set(key, node);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return Array.from(seen.values());
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Whether `node` sits on an Ethereum-Virtual-Machine chain — the one thing a
|
|
52
|
+
* deferred leg's start must be, because `deferredLeg.ts`'s `isComposableAddress`
|
|
53
|
+
* can only compose an address there (design section 2, item 2).
|
|
54
|
+
*/
|
|
55
|
+
const isEvmNode = (node) => node.ecosystem === 'evm';
|
|
56
|
+
/**
|
|
57
|
+
* Decides what one candidate hop delivers, preferring the acting carrier's
|
|
58
|
+
* own {@link Carrier.exact} over its {@link Carrier.estimate} over the
|
|
59
|
+
* caller-supplied price-feed `estimateHopOutput` — the owner's own model:
|
|
60
|
+
* "each individual step and its associated fees can be known ahead of time
|
|
61
|
+
* ... you can at least start by getting their price for each token and their
|
|
62
|
+
* fee." Three guard-clause branches, no nesting:
|
|
63
|
+
*
|
|
64
|
+
* 1. `exact` defined — the carrier KNOWS its own math (the omnibridge
|
|
65
|
+
* crossing, a PulseX swap). A `'delivered'` result becomes a `'quote'`
|
|
66
|
+
* outcome; `null` (`'no-answer'`) or `'refused'` PRUNES the hop entirely
|
|
67
|
+
* rather than falling through — a carrier that knows its own
|
|
68
|
+
* exact math and says no is a firm refusal, not a number to estimate
|
|
69
|
+
* around. A CARRIER WITH `exact` NEVER YIELDS AN `'estimate'` OUTCOME.
|
|
70
|
+
* 2. `estimate` defined (and `exact` was not) AND IT RETURNS A RESULT — the
|
|
71
|
+
* carrier's own provider model answers, always grounded on the
|
|
72
|
+
* `'provider-model'` {@link @gibs/bridge-sdk/routing!EstimateBasis}. A
|
|
73
|
+
* carrier whose `estimate` returns `null` (most commonly: it has no price
|
|
74
|
+
* for one side of this edge — see `types.ts`'s `Carrier.estimate` doc)
|
|
75
|
+
* falls through to branch 3, exactly as if `estimate` were absent.
|
|
76
|
+
* 3. Neither, or `estimate` returned `null` — the caller's own price-feed
|
|
77
|
+
* `estimateHopOutput` answers, exactly as it did before either method
|
|
78
|
+
* existed.
|
|
79
|
+
*
|
|
80
|
+
* @param options - the acting carrier (if registered), the candidate edge, the input amount, and the price-feed fallback
|
|
81
|
+
* @returns the resolved delivered amount and outcome, the carrier's own refusal, or no answer
|
|
82
|
+
*/
|
|
83
|
+
const resolveHopOutcome = (options) => {
|
|
84
|
+
const { carrier, edge, amountIn, estimateHopOutput } = options;
|
|
85
|
+
if (carrier?.exact !== undefined) {
|
|
86
|
+
const exactResult = carrier.exact(edge, amountIn);
|
|
87
|
+
if (exactResult === null) {
|
|
88
|
+
return { kind: 'no-answer' };
|
|
89
|
+
}
|
|
90
|
+
if (exactResult.outcome === 'refused') {
|
|
91
|
+
return { kind: 'refused', refusal: exactResult.refusal };
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
kind: 'delivered',
|
|
95
|
+
deliveredAmount: exactResult.deliveredAmount,
|
|
96
|
+
outcome: { kind: 'quote', deliveredAmount: exactResult.deliveredAmount },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (carrier?.estimate !== undefined) {
|
|
100
|
+
const estimateResult = carrier.estimate(edge, amountIn);
|
|
101
|
+
if (estimateResult !== null) {
|
|
102
|
+
return {
|
|
103
|
+
kind: 'delivered',
|
|
104
|
+
deliveredAmount: estimateResult.deliveredAmount,
|
|
105
|
+
outcome: { kind: 'estimate', deliveredAmount: estimateResult.deliveredAmount, basis: 'provider-model' },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const { deliveredAmount, basis } = estimateHopOutput(edge, amountIn);
|
|
110
|
+
return { kind: 'delivered', deliveredAmount, outcome: { kind: 'estimate', deliveredAmount, basis } };
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* The venues one carrier offers between two nodes, read off the carrier's own
|
|
114
|
+
* `edgesFrom` — each a separate candidate hop, quoted and ranked on its own
|
|
115
|
+
* (see `HopEdge.venue`). A carrier whose edges name no venue yields the one
|
|
116
|
+
* venue-less candidate every carrier had before venues existed.
|
|
117
|
+
*
|
|
118
|
+
* Only edges the carrier says it performs under THIS id count: a registered
|
|
119
|
+
* view may answer `edgesFrom` for several ids.
|
|
120
|
+
*
|
|
121
|
+
* @param carrier - the registered carrier, or undefined when none is registered under `carrierId`
|
|
122
|
+
* @param carrierId - the id the capability index named for this pair
|
|
123
|
+
* @param from - the hop's input node
|
|
124
|
+
* @param to - the hop's output node
|
|
125
|
+
* @returns the distinct venues, or `[undefined]` when the carrier names none
|
|
126
|
+
*/
|
|
127
|
+
const venuesBetween = (carrier, carrierId, from, to) => {
|
|
128
|
+
if (carrier === undefined)
|
|
129
|
+
return [undefined];
|
|
130
|
+
const venues = new Set();
|
|
131
|
+
for (const edge of carrier.edgesFrom(from, to)) {
|
|
132
|
+
if (edge.carrier === carrierId && edge.venue !== undefined)
|
|
133
|
+
venues.add(edge.venue);
|
|
134
|
+
}
|
|
135
|
+
return venues.size === 0 ? [undefined] : [...venues];
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Depth-first search from `options.origin` to `options.destination` over
|
|
139
|
+
* `options.nodes`, pruning every candidate hop before it is added and
|
|
140
|
+
* collecting every finished plan the hop/signature caps allow, up to the
|
|
141
|
+
* {@link MAX_ENUMERATED_PATHS} safety valve.
|
|
142
|
+
*
|
|
143
|
+
* PRUNING, APPLIED BEFORE A CANDIDATE HOP IS EVER ADDED (design section 2,
|
|
144
|
+
* item 2):
|
|
145
|
+
* - the carrier must support both nodes ({@link CapabilityIndex.carriersFrom});
|
|
146
|
+
* - no node is revisited within one plan;
|
|
147
|
+
* - the same carrier never runs twice in a row, whatever venue each hop names
|
|
148
|
+
* (so one carrier never splits a hop across two of its own venues);
|
|
149
|
+
* - a pay-by-hand origin's first hop must be filled by a carrier whose
|
|
150
|
+
* {@link Carrier.depositMode} accepts a by-hand deposit — `'by-hand-deposit'`
|
|
151
|
+
* or `'either'`, never `'wallet-signed'` alone
|
|
152
|
+
* ({@link isDepositAddressChain} is the pay-by-hand test);
|
|
153
|
+
* - a hop that starts a DEFERRED leg (every signature group after the first)
|
|
154
|
+
* must start on an Ethereum-Virtual-Machine chain;
|
|
155
|
+
* - a known floor, if any, must be at or below the amount flowing into that
|
|
156
|
+
* hop;
|
|
157
|
+
* - the acting carrier's own {@link Carrier.exact}, when it has one, must not
|
|
158
|
+
* refuse the amount flowing into that hop ({@link resolveHopOutcome}); a
|
|
159
|
+
* refusal it does give is handed to `onExactRefusal`, once per edge and
|
|
160
|
+
* amount;
|
|
161
|
+
* - the plan must not exceed `maxHops` hops or `maxSignatures` signature
|
|
162
|
+
* groups (grouped with {@link groupHopsBySignature}, reused rather than
|
|
163
|
+
* re-derived, so a fused pair always counts as one signature here exactly
|
|
164
|
+
* as it does everywhere else that reads a `RoutePlan`).
|
|
165
|
+
*
|
|
166
|
+
* A HOP'S `fusesNext` CANNOT BE KNOWN WHEN IT IS BUILT — it depends on the hop
|
|
167
|
+
* that follows it, which does not exist yet in a depth-first walk. So each
|
|
168
|
+
* hop is built provisionally with `fusesNext: false`, and is replaced with an
|
|
169
|
+
* updated copy (never mutated) the moment its successor is chosen, once
|
|
170
|
+
* {@link CapabilityIndex.fusesWithBridgeEntry} can actually answer.
|
|
171
|
+
*
|
|
172
|
+
* @param options - the search's endpoints, node universe, capability index and estimate function
|
|
173
|
+
* @returns every plan the caps allow, best first, never exceeding {@link MAX_ENUMERATED_PATHS}
|
|
174
|
+
*/
|
|
175
|
+
export const enumerateRoutes = (options) => {
|
|
176
|
+
const { origin, destination, amount, nodes, capabilities, estimateHopOutput, knownFloors = () => [], maxHops = DEFAULT_MAX_HOPS, maxSignatures = DEFAULT_MAX_SIGNATURES, compareRoutePlans = compareRoutePlansByScoreAdjustedDeliveredAmount, onExactRefusal, } = options;
|
|
177
|
+
const originKey = assetNodeKey(origin);
|
|
178
|
+
const destinationKey = assetNodeKey(destination);
|
|
179
|
+
if (originKey === destinationKey) {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
const searchNodes = uniqueNodes([origin, destination, ...nodes]);
|
|
183
|
+
const found = [];
|
|
184
|
+
const foundPathKeys = new Set();
|
|
185
|
+
// The same edge is reached at the same amount by many paths; its refusal is
|
|
186
|
+
// one fact, reported once.
|
|
187
|
+
const reportedRefusalKeys = new Set();
|
|
188
|
+
const reportExactRefusal = (refusal) => {
|
|
189
|
+
if (onExactRefusal === undefined)
|
|
190
|
+
return;
|
|
191
|
+
const refusalKey = `${hopEdgeKey(refusal.edge)}#${refusal.askedAmount.toString()}`;
|
|
192
|
+
if (reportedRefusalKeys.has(refusalKey))
|
|
193
|
+
return;
|
|
194
|
+
reportedRefusalKeys.add(refusalKey);
|
|
195
|
+
onExactRefusal(refusal);
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* Extends the path ending at `currentNode` by one hop, over every candidate
|
|
199
|
+
* node and every carrier that connects them, recursing on whatever survives
|
|
200
|
+
* pruning.
|
|
201
|
+
*
|
|
202
|
+
* @param currentNode - where the path currently ends
|
|
203
|
+
* @param visitedKeys - every node key already used in this path
|
|
204
|
+
* @param hopsSoFar - the path's hops so far, last one's `fusesNext` still provisional
|
|
205
|
+
* @param currentAmount - the estimated amount available at `currentNode`
|
|
206
|
+
*/
|
|
207
|
+
const visit = (currentNode, visitedKeys, hopsSoFar, currentAmount) => {
|
|
208
|
+
if (hopsSoFar.length >= maxHops || found.length >= MAX_ENUMERATED_PATHS) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const previousHop = hopsSoFar[hopsSoFar.length - 1];
|
|
212
|
+
for (const candidateNode of searchNodes) {
|
|
213
|
+
const candidateKey = assetNodeKey(candidateNode);
|
|
214
|
+
if (visitedKeys.has(candidateKey)) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
for (const carrier of capabilities.carriersFrom(currentNode, candidateNode)) {
|
|
218
|
+
for (const venue of venuesBetween(capabilities.carrierById(carrier), carrier, currentNode, candidateNode)) {
|
|
219
|
+
// RE-CHECKED PER CANDIDATE, NOT ONLY AT `visit`'S OWN ENTRY — many
|
|
220
|
+
// candidate hops (parallel carriers, or parallel venues of one
|
|
221
|
+
// carrier, between the same pair of nodes) are walked inside ONE call
|
|
222
|
+
// to `visit`, with no recursion between them, so the top-of-function
|
|
223
|
+
// guard alone would never see the cap cross mid-loop.
|
|
224
|
+
if (found.length >= MAX_ENUMERATED_PATHS) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (previousHop !== undefined && previousHop.edge.carrier === carrier) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const isFirstHop = hopsSoFar.length === 0;
|
|
231
|
+
const depositMode = capabilities.carrierById(carrier)?.depositMode;
|
|
232
|
+
const acceptsByHandDeposit = depositMode === 'by-hand-deposit' || depositMode === 'either';
|
|
233
|
+
if (isFirstHop && isDepositAddressChain(origin.chainId) && !acceptsByHandDeposit) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const previousFusesThisHop = previousHop === undefined
|
|
237
|
+
? false
|
|
238
|
+
: capabilities.fusesWithBridgeEntry({
|
|
239
|
+
carrier: previousHop.edge.carrier,
|
|
240
|
+
hopFrom: previousHop.edge.from,
|
|
241
|
+
nextHopCarrier: carrier,
|
|
242
|
+
nextHopFrom: currentNode,
|
|
243
|
+
nextHopDestinationChainId: candidateNode.chainId,
|
|
244
|
+
});
|
|
245
|
+
const startsNewGroup = previousHop === undefined || !previousFusesThisHop;
|
|
246
|
+
const startsDeferredLeg = startsNewGroup && !isFirstHop;
|
|
247
|
+
if (startsDeferredLeg && !isEvmNode(currentNode)) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const floors = knownFloors({ carrier, from: currentNode, to: candidateNode });
|
|
251
|
+
if (floors.some((floor) => floor.minimumAmount > currentAmount)) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const provisionalEdge = {
|
|
255
|
+
carrier,
|
|
256
|
+
from: currentNode,
|
|
257
|
+
to: candidateNode,
|
|
258
|
+
fusesNext: false,
|
|
259
|
+
origin: isFirstHop && isDepositAddressChain(currentNode.chainId) ? 'by-hand' : 'wallet',
|
|
260
|
+
floors,
|
|
261
|
+
...(venue === undefined ? {} : { venue }),
|
|
262
|
+
};
|
|
263
|
+
const resolved = resolveHopOutcome({
|
|
264
|
+
carrier: capabilities.carrierById(carrier),
|
|
265
|
+
edge: provisionalEdge,
|
|
266
|
+
amountIn: currentAmount,
|
|
267
|
+
estimateHopOutput,
|
|
268
|
+
});
|
|
269
|
+
if (resolved.kind === 'refused') {
|
|
270
|
+
reportExactRefusal({ edge: provisionalEdge, askedAmount: currentAmount, refusal: resolved.refusal });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (resolved.kind === 'no-answer') {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const { deliveredAmount, outcome } = resolved;
|
|
277
|
+
const newHop = {
|
|
278
|
+
edge: provisionalEdge,
|
|
279
|
+
request: null,
|
|
280
|
+
outcome,
|
|
281
|
+
};
|
|
282
|
+
const updatedHopsSoFar = previousHop === undefined
|
|
283
|
+
? [newHop]
|
|
284
|
+
: [
|
|
285
|
+
...hopsSoFar.slice(0, -1),
|
|
286
|
+
{ ...previousHop, edge: { ...previousHop.edge, fusesNext: previousFusesThisHop } },
|
|
287
|
+
newHop,
|
|
288
|
+
];
|
|
289
|
+
if (groupHopsBySignature(updatedHopsSoFar).length > maxSignatures) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const newVisitedKeys = new Set(visitedKeys);
|
|
293
|
+
newVisitedKeys.add(candidateKey);
|
|
294
|
+
if (candidateKey === destinationKey) {
|
|
295
|
+
const pathKey = routePlanPathKey(updatedHopsSoFar);
|
|
296
|
+
if (!foundPathKeys.has(pathKey)) {
|
|
297
|
+
foundPathKeys.add(pathKey);
|
|
298
|
+
found.push({
|
|
299
|
+
hops: updatedHopsSoFar,
|
|
300
|
+
signatures: groupHopsBySignature(updatedHopsSoFar),
|
|
301
|
+
pathKey,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
visit(candidateNode, newVisitedKeys, updatedHopsSoFar, deliveredAmount);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
visit(origin, new Set([originKey]), [], amount);
|
|
312
|
+
return found.sort(compareRoutePlans);
|
|
313
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route-search maximization engine: "get every quote, then pick the most
|
|
3
|
+
* tokens out for the fewest steps." Carrier-agnostic — see
|
|
4
|
+
* `docs/quote-service.md`'s "Carriers" section. A host registers whatever
|
|
5
|
+
* `Carrier`s (`../types.js`) it wants searched over; nothing in this
|
|
6
|
+
* directory names a host's own carrier.
|
|
7
|
+
*
|
|
8
|
+
* The pure graph data model (`AssetNode`, `HopEdge`, `RoutePlan`, ...) and
|
|
9
|
+
* the composition/scoring math stay in `@gibs/bridge-sdk/routing` — see that
|
|
10
|
+
* package's `routing/index.ts` for why this package cannot import them back
|
|
11
|
+
* the other way (a circular package dependency) and re-exports them under
|
|
12
|
+
* its own `Carrier*` names in `../types.js` instead.
|
|
13
|
+
*/
|
|
14
|
+
export * from './capabilities.js';
|
|
15
|
+
export * from './enumerate.js';
|
|
16
|
+
export * from './serve.js';
|
|
17
|
+
export * from './waves.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route-search maximization engine: "get every quote, then pick the most
|
|
3
|
+
* tokens out for the fewest steps." Carrier-agnostic — see
|
|
4
|
+
* `docs/quote-service.md`'s "Carriers" section. A host registers whatever
|
|
5
|
+
* `Carrier`s (`../types.js`) it wants searched over; nothing in this
|
|
6
|
+
* directory names a host's own carrier.
|
|
7
|
+
*
|
|
8
|
+
* The pure graph data model (`AssetNode`, `HopEdge`, `RoutePlan`, ...) and
|
|
9
|
+
* the composition/scoring math stay in `@gibs/bridge-sdk/routing` — see that
|
|
10
|
+
* package's `routing/index.ts` for why this package cannot import them back
|
|
11
|
+
* the other way (a circular package dependency) and re-exports them under
|
|
12
|
+
* its own `Carrier*` names in `../types.js` instead.
|
|
13
|
+
*/
|
|
14
|
+
export * from './capabilities.js';
|
|
15
|
+
export * from './enumerate.js';
|
|
16
|
+
export * from './serve.js';
|
|
17
|
+
export * from './waves.js';
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { type ComposedRouteVerdict, type PlannedHop, type RoutePlan } from '@gibs/bridge-sdk/routing';
|
|
2
|
+
/**
|
|
3
|
+
* Decides whether a priced {@link RoutePlan} may be served to a reader as a
|
|
4
|
+
* route they can sign — the boundary between the route-search engine
|
|
5
|
+
* (`enumerate.ts`, `waves.ts`) and a host's own execution flow.
|
|
6
|
+
*
|
|
7
|
+
* HOST-AGNOSTIC. This file names no carrier, no chain, and no host. It reads
|
|
8
|
+
* only the plan's own structure and the facts the caller passes in
|
|
9
|
+
* {@link ServableContext}. A host decides what to DO with a `'withheld'`
|
|
10
|
+
* verdict (grey the route out, hide it, show a reason) and what to do with a
|
|
11
|
+
* `'servable'` one (build the real signing request for
|
|
12
|
+
* {@link ServableVerdict.firstSectionRequest}); this file only judges.
|
|
13
|
+
*
|
|
14
|
+
* ONE PLAN, ONE VERDICT. Call {@link servableVerdict} again whenever the plan
|
|
15
|
+
* or the context changes — a fresh quote, a new gas balance, a new recipient.
|
|
16
|
+
* This module keeps no state of its own.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Who — or what — signs a plan's FIRST section.
|
|
20
|
+
*
|
|
21
|
+
* `'by-hand'` — the reader sends funds to a deposit address. No wallet
|
|
22
|
+
* transaction starts this section.
|
|
23
|
+
*
|
|
24
|
+
* `'rate-limited-provider'` — a rate-limited aggregator carries the first
|
|
25
|
+
* hop ({@link isRateLimitedCarrier}). A host must go get a fresh, binding
|
|
26
|
+
* quote from that provider before it can build a signable transaction.
|
|
27
|
+
*
|
|
28
|
+
* `'free-carrier'` — a free, local carrier (a bridge read, a pool read)
|
|
29
|
+
* carries the first hop. A host can build the signable transaction directly,
|
|
30
|
+
* with no external quote request first.
|
|
31
|
+
*/
|
|
32
|
+
export type PlanFirstLegKind = 'by-hand' | 'rate-limited-provider' | 'free-carrier';
|
|
33
|
+
/**
|
|
34
|
+
* Reads {@link PlanFirstLegKind} off a plan's FIRST hop only.
|
|
35
|
+
*
|
|
36
|
+
* THE OLD `derivePlanKind` (`@gibs/bridge-sdk`'s `plan-route-data.ts`) READS
|
|
37
|
+
* THE WRONG HOP FOR THIS QUESTION. It answers `'conversion'` when the plan's
|
|
38
|
+
* LAST hop is a swap — a fact about how the journey ENDS, useful for a
|
|
39
|
+
* section label, useless for deciding who signs FIRST. A three-hop plan that
|
|
40
|
+
* starts with an aggregator and ends with a swap reads `'conversion'` from
|
|
41
|
+
* `derivePlanKind`, which tells a host nothing about what the very first
|
|
42
|
+
* signature needs. This function asks a narrower, serving-specific question
|
|
43
|
+
* — "what starts section one?" — and reads only `plan.hops[0]`.
|
|
44
|
+
*
|
|
45
|
+
* @param plan - the plan to classify
|
|
46
|
+
* @returns the first section's signer kind
|
|
47
|
+
*/
|
|
48
|
+
export declare const planFirstLegKind: (plan: RoutePlan) => PlanFirstLegKind;
|
|
49
|
+
/**
|
|
50
|
+
* Checks a structural fact about a plan: does each section start on the
|
|
51
|
+
* exact asset the PREVIOUS section ended on?
|
|
52
|
+
*
|
|
53
|
+
* WHY THIS CAN EVER BE FALSE. `enumerate.ts` only ever builds a plan whose
|
|
54
|
+
* hops already chain this way — a depth-first walk cannot skip a node. This
|
|
55
|
+
* check exists for a plan this module did NOT build itself: one assembled by
|
|
56
|
+
* hand, by a test, or by a future caller that composes a plan from pieces.
|
|
57
|
+
* {@link servableVerdict} treats a `false` result as a defect in whatever
|
|
58
|
+
* built the plan, not a business reason to withhold it — see that function's
|
|
59
|
+
* own doc.
|
|
60
|
+
*
|
|
61
|
+
* @param plan - the plan to check
|
|
62
|
+
* @returns true when every section's first hop starts where the previous
|
|
63
|
+
* section's last hop landed; true trivially for a one-section plan
|
|
64
|
+
*/
|
|
65
|
+
export declare const sectionHandoverAgrees: (plan: RoutePlan) => boolean;
|
|
66
|
+
/** What a host must go build once {@link servableVerdict} clears a plan's first section. */
|
|
67
|
+
export type FirstSectionRequest = {
|
|
68
|
+
/** The plan's first signature group's hops, first to last. */
|
|
69
|
+
readonly hops: readonly PlannedHop[];
|
|
70
|
+
/** The reader's real, typed amount entering the very first hop, in that hop's `from` node's base units. */
|
|
71
|
+
readonly inputAmount: bigint;
|
|
72
|
+
};
|
|
73
|
+
/** Why {@link servableVerdict} withheld a plan. One reason per verdict — the first check that fails wins. */
|
|
74
|
+
export type ServableWithheldReason = {
|
|
75
|
+
/** No hop of the plan has a live, provider-confirmed figure yet — see `planIsConfirmed`. */
|
|
76
|
+
readonly kind: 'unconfirmed';
|
|
77
|
+
} | {
|
|
78
|
+
/** The plan asks for more signatures than the owner allows. */
|
|
79
|
+
readonly kind: 'too-many-signatures';
|
|
80
|
+
readonly signatureCount: number;
|
|
81
|
+
readonly maxSignatures: number;
|
|
82
|
+
} | {
|
|
83
|
+
/**
|
|
84
|
+
* A section other than the first starts on a chain the reader holds no
|
|
85
|
+
* native gas on, and the hop landing there does not deliver native
|
|
86
|
+
* coin either — so the reader would have nothing to pay that section's
|
|
87
|
+
* own signature with.
|
|
88
|
+
*/
|
|
89
|
+
readonly kind: 'gasless-middle-chain';
|
|
90
|
+
readonly chainId: number;
|
|
91
|
+
} | {
|
|
92
|
+
/**
|
|
93
|
+
* The reader named a recipient other than their own wallet for a
|
|
94
|
+
* section that is NOT the plan's last one. Only the last section may
|
|
95
|
+
* carry a custom recipient — every earlier section must land back in
|
|
96
|
+
* the reader's own wallet, because the reader still has to sign the
|
|
97
|
+
* next section from wherever the previous one landed.
|
|
98
|
+
*/
|
|
99
|
+
readonly kind: 'middle-recipient-not-reader';
|
|
100
|
+
readonly sectionIndex: number;
|
|
101
|
+
} | {
|
|
102
|
+
/** A composed, multi-signature plan failed (or has not yet passed) the value-loss gate. */
|
|
103
|
+
readonly kind: 'quality';
|
|
104
|
+
readonly verdict: ComposedRouteVerdict | null;
|
|
105
|
+
};
|
|
106
|
+
/** The verdict {@link servableVerdict} returns. */
|
|
107
|
+
export type ServableVerdict = {
|
|
108
|
+
readonly kind: 'servable';
|
|
109
|
+
/** What a host needs to go build a real, signable request for the plan's first section. */
|
|
110
|
+
readonly firstSectionRequest: FirstSectionRequest;
|
|
111
|
+
/** Who signs the first section. */
|
|
112
|
+
readonly leg: PlanFirstLegKind;
|
|
113
|
+
} | {
|
|
114
|
+
readonly kind: 'withheld';
|
|
115
|
+
readonly reason: ServableWithheldReason;
|
|
116
|
+
};
|
|
117
|
+
/** Owner decision, 2026-09-23: "minimize the steps, but if you have to go through 3 ... that is what you have to do." */
|
|
118
|
+
export declare const MAX_SERVABLE_SIGNATURES = 3;
|
|
119
|
+
/** Everything {@link servableVerdict} needs beyond the plan's own structure — every fact a host, never this module, measures. */
|
|
120
|
+
export type ServableContext = {
|
|
121
|
+
/** The reader's real, typed amount entering the plan's first hop. */
|
|
122
|
+
readonly amount: bigint;
|
|
123
|
+
/**
|
|
124
|
+
* Chain ids the reader holds no native gas on right now, from a live
|
|
125
|
+
* wallet balance read. A chain absent from this set is treated as funded.
|
|
126
|
+
*/
|
|
127
|
+
readonly gaslessChainIds: ReadonlySet<number>;
|
|
128
|
+
/**
|
|
129
|
+
* Section indices (0-based, into `plan.signatures`) where the reader named
|
|
130
|
+
* a recipient other than their own connected wallet. Only the plan's LAST
|
|
131
|
+
* section index may legitimately appear here.
|
|
132
|
+
*/
|
|
133
|
+
readonly customRecipientSectionIndexes: ReadonlySet<number>;
|
|
134
|
+
/**
|
|
135
|
+
* The composed-route quality verdict for this plan (`assessComposedRoute`,
|
|
136
|
+
* `@gibs/bridge-sdk/routing`). Read only when the plan has more than one
|
|
137
|
+
* signature — a single-signature plan strands the reader nowhere between
|
|
138
|
+
* signatures, so the gate this verdict feeds does not apply to it. `null`
|
|
139
|
+
* or omitted means "not priced yet," which withholds a multi-signature
|
|
140
|
+
* plan exactly as a `'rejected'` verdict would.
|
|
141
|
+
*/
|
|
142
|
+
readonly qualityVerdict?: ComposedRouteVerdict | null;
|
|
143
|
+
/** Overrides the signature-count ceiling. Defaults to {@link MAX_SERVABLE_SIGNATURES}. */
|
|
144
|
+
readonly maxSignatures?: number;
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Decides whether `plan` may be served to a reader as a route they can sign
|
|
148
|
+
* right now, against the five reasons a route can be held back:
|
|
149
|
+
*
|
|
150
|
+
* 1. **`unconfirmed`** — some hop still rests on an estimate, not a live
|
|
151
|
+
* quote (`planIsConfirmed`).
|
|
152
|
+
* 2. **`too-many-signatures`** — more signature groups than
|
|
153
|
+
* {@link ServableContext.maxSignatures} allows.
|
|
154
|
+
* 3. **`middle-recipient-not-reader`** — a non-last section would land
|
|
155
|
+
* somewhere other than the reader's own wallet.
|
|
156
|
+
* 4. **`gasless-middle-chain`** — a non-first section starts on a chain the
|
|
157
|
+
* reader cannot pay gas on, and nothing lands native coin there to cover
|
|
158
|
+
* it.
|
|
159
|
+
* 5. **`quality`** — a composed, multi-signature plan fails the value-loss
|
|
160
|
+
* gate (`assessComposedRoute`).
|
|
161
|
+
*
|
|
162
|
+
* Checked in that order; the first failing check is the reason returned. A
|
|
163
|
+
* plan that clears all five is `'servable'`, carrying the first section's
|
|
164
|
+
* hops and the first-hop signer kind ({@link planFirstLegKind}) so a host can
|
|
165
|
+
* go build the real signing request.
|
|
166
|
+
*
|
|
167
|
+
* THROWS, RATHER THAN RETURNING A VERDICT, FOR A MALFORMED PLAN — an empty
|
|
168
|
+
* plan, or one that fails {@link sectionHandoverAgrees}. Both name a defect
|
|
169
|
+
* in whatever BUILT the plan, not a business reason to withhold a route a
|
|
170
|
+
* reader could otherwise take; see `toHeadlineProviderKey` in
|
|
171
|
+
* `@gibs/bridge-sdk`'s `plan-route-data.ts` for the same pattern applied to
|
|
172
|
+
* the same class of fault.
|
|
173
|
+
*
|
|
174
|
+
* @param plan - the plan to judge
|
|
175
|
+
* @param context - the live facts this module cannot measure on its own
|
|
176
|
+
* @returns the verdict
|
|
177
|
+
*/
|
|
178
|
+
export declare const servableVerdict: (plan: RoutePlan, context: ServableContext) => ServableVerdict;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { isNativeAsset } from '@gibs/bridge-sdk/ecosystems';
|
|
2
|
+
import { assetNodeKey, composedRouteMayBeOffered, isRateLimitedCarrier, planIsConfirmed, } from '@gibs/bridge-sdk/routing';
|
|
3
|
+
/**
|
|
4
|
+
* Reads {@link PlanFirstLegKind} off a plan's FIRST hop only.
|
|
5
|
+
*
|
|
6
|
+
* THE OLD `derivePlanKind` (`@gibs/bridge-sdk`'s `plan-route-data.ts`) READS
|
|
7
|
+
* THE WRONG HOP FOR THIS QUESTION. It answers `'conversion'` when the plan's
|
|
8
|
+
* LAST hop is a swap — a fact about how the journey ENDS, useful for a
|
|
9
|
+
* section label, useless for deciding who signs FIRST. A three-hop plan that
|
|
10
|
+
* starts with an aggregator and ends with a swap reads `'conversion'` from
|
|
11
|
+
* `derivePlanKind`, which tells a host nothing about what the very first
|
|
12
|
+
* signature needs. This function asks a narrower, serving-specific question
|
|
13
|
+
* — "what starts section one?" — and reads only `plan.hops[0]`.
|
|
14
|
+
*
|
|
15
|
+
* @param plan - the plan to classify
|
|
16
|
+
* @returns the first section's signer kind
|
|
17
|
+
*/
|
|
18
|
+
export const planFirstLegKind = (plan) => {
|
|
19
|
+
const firstHop = plan.hops[0];
|
|
20
|
+
if (firstHop === undefined) {
|
|
21
|
+
throw new Error('planFirstLegKind: a plan needs at least one hop');
|
|
22
|
+
}
|
|
23
|
+
if (firstHop.edge.origin === 'by-hand') {
|
|
24
|
+
return 'by-hand';
|
|
25
|
+
}
|
|
26
|
+
return isRateLimitedCarrier(firstHop.edge.carrier) ? 'rate-limited-provider' : 'free-carrier';
|
|
27
|
+
};
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// sectionHandoverAgrees
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
/**
|
|
32
|
+
* Checks a structural fact about a plan: does each section start on the
|
|
33
|
+
* exact asset the PREVIOUS section ended on?
|
|
34
|
+
*
|
|
35
|
+
* WHY THIS CAN EVER BE FALSE. `enumerate.ts` only ever builds a plan whose
|
|
36
|
+
* hops already chain this way — a depth-first walk cannot skip a node. This
|
|
37
|
+
* check exists for a plan this module did NOT build itself: one assembled by
|
|
38
|
+
* hand, by a test, or by a future caller that composes a plan from pieces.
|
|
39
|
+
* {@link servableVerdict} treats a `false` result as a defect in whatever
|
|
40
|
+
* built the plan, not a business reason to withhold it — see that function's
|
|
41
|
+
* own doc.
|
|
42
|
+
*
|
|
43
|
+
* @param plan - the plan to check
|
|
44
|
+
* @returns true when every section's first hop starts where the previous
|
|
45
|
+
* section's last hop landed; true trivially for a one-section plan
|
|
46
|
+
*/
|
|
47
|
+
export const sectionHandoverAgrees = (plan) => {
|
|
48
|
+
for (let sectionIndex = 1; sectionIndex < plan.signatures.length; sectionIndex += 1) {
|
|
49
|
+
const previousGroup = plan.signatures[sectionIndex - 1];
|
|
50
|
+
const currentGroup = plan.signatures[sectionIndex];
|
|
51
|
+
if (previousGroup === undefined || currentGroup === undefined)
|
|
52
|
+
return false;
|
|
53
|
+
const previousLastHop = plan.hops[previousGroup[previousGroup.length - 1] ?? -1];
|
|
54
|
+
const currentFirstHop = plan.hops[currentGroup[0] ?? -1];
|
|
55
|
+
if (previousLastHop === undefined || currentFirstHop === undefined)
|
|
56
|
+
return false;
|
|
57
|
+
if (assetNodeKey(previousLastHop.edge.to) !== assetNodeKey(currentFirstHop.edge.from))
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
};
|
|
62
|
+
/** Owner decision, 2026-09-23: "minimize the steps, but if you have to go through 3 ... that is what you have to do." */
|
|
63
|
+
export const MAX_SERVABLE_SIGNATURES = 3;
|
|
64
|
+
/**
|
|
65
|
+
* Decides whether `plan` may be served to a reader as a route they can sign
|
|
66
|
+
* right now, against the five reasons a route can be held back:
|
|
67
|
+
*
|
|
68
|
+
* 1. **`unconfirmed`** — some hop still rests on an estimate, not a live
|
|
69
|
+
* quote (`planIsConfirmed`).
|
|
70
|
+
* 2. **`too-many-signatures`** — more signature groups than
|
|
71
|
+
* {@link ServableContext.maxSignatures} allows.
|
|
72
|
+
* 3. **`middle-recipient-not-reader`** — a non-last section would land
|
|
73
|
+
* somewhere other than the reader's own wallet.
|
|
74
|
+
* 4. **`gasless-middle-chain`** — a non-first section starts on a chain the
|
|
75
|
+
* reader cannot pay gas on, and nothing lands native coin there to cover
|
|
76
|
+
* it.
|
|
77
|
+
* 5. **`quality`** — a composed, multi-signature plan fails the value-loss
|
|
78
|
+
* gate (`assessComposedRoute`).
|
|
79
|
+
*
|
|
80
|
+
* Checked in that order; the first failing check is the reason returned. A
|
|
81
|
+
* plan that clears all five is `'servable'`, carrying the first section's
|
|
82
|
+
* hops and the first-hop signer kind ({@link planFirstLegKind}) so a host can
|
|
83
|
+
* go build the real signing request.
|
|
84
|
+
*
|
|
85
|
+
* THROWS, RATHER THAN RETURNING A VERDICT, FOR A MALFORMED PLAN — an empty
|
|
86
|
+
* plan, or one that fails {@link sectionHandoverAgrees}. Both name a defect
|
|
87
|
+
* in whatever BUILT the plan, not a business reason to withhold a route a
|
|
88
|
+
* reader could otherwise take; see `toHeadlineProviderKey` in
|
|
89
|
+
* `@gibs/bridge-sdk`'s `plan-route-data.ts` for the same pattern applied to
|
|
90
|
+
* the same class of fault.
|
|
91
|
+
*
|
|
92
|
+
* @param plan - the plan to judge
|
|
93
|
+
* @param context - the live facts this module cannot measure on its own
|
|
94
|
+
* @returns the verdict
|
|
95
|
+
*/
|
|
96
|
+
export const servableVerdict = (plan, context) => {
|
|
97
|
+
if (plan.hops.length === 0 || plan.signatures.length === 0) {
|
|
98
|
+
throw new Error('servableVerdict: a plan needs at least one hop');
|
|
99
|
+
}
|
|
100
|
+
if (!sectionHandoverAgrees(plan)) {
|
|
101
|
+
throw new Error('servableVerdict: a section does not start where the previous one landed');
|
|
102
|
+
}
|
|
103
|
+
if (!planIsConfirmed(plan.hops)) {
|
|
104
|
+
return { kind: 'withheld', reason: { kind: 'unconfirmed' } };
|
|
105
|
+
}
|
|
106
|
+
const maxSignatures = context.maxSignatures ?? MAX_SERVABLE_SIGNATURES;
|
|
107
|
+
if (plan.signatures.length > maxSignatures) {
|
|
108
|
+
return {
|
|
109
|
+
kind: 'withheld',
|
|
110
|
+
reason: { kind: 'too-many-signatures', signatureCount: plan.signatures.length, maxSignatures },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const lastSectionIndex = plan.signatures.length - 1;
|
|
114
|
+
for (const sectionIndex of context.customRecipientSectionIndexes) {
|
|
115
|
+
if (sectionIndex !== lastSectionIndex) {
|
|
116
|
+
return { kind: 'withheld', reason: { kind: 'middle-recipient-not-reader', sectionIndex } };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (let sectionIndex = 1; sectionIndex < plan.signatures.length; sectionIndex += 1) {
|
|
120
|
+
const previousGroup = plan.signatures[sectionIndex - 1];
|
|
121
|
+
const landingHop = plan.hops[previousGroup[previousGroup.length - 1]];
|
|
122
|
+
const landingNode = landingHop.edge.to;
|
|
123
|
+
const landsNativeCoin = isNativeAsset(landingNode.address, landingNode.ecosystem);
|
|
124
|
+
if (!landsNativeCoin && context.gaslessChainIds.has(landingNode.chainId)) {
|
|
125
|
+
return { kind: 'withheld', reason: { kind: 'gasless-middle-chain', chainId: landingNode.chainId } };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (plan.signatures.length > 1) {
|
|
129
|
+
const verdict = context.qualityVerdict ?? null;
|
|
130
|
+
const passesQualityGate = verdict !== null && composedRouteMayBeOffered(verdict);
|
|
131
|
+
if (!passesQualityGate) {
|
|
132
|
+
return { kind: 'withheld', reason: { kind: 'quality', verdict } };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const firstGroup = plan.signatures[0];
|
|
136
|
+
const firstSectionHops = firstGroup.map((hopIndex) => plan.hops[hopIndex]);
|
|
137
|
+
return {
|
|
138
|
+
kind: 'servable',
|
|
139
|
+
firstSectionRequest: { hops: firstSectionHops, inputAmount: context.amount },
|
|
140
|
+
leg: planFirstLegKind(plan),
|
|
141
|
+
};
|
|
142
|
+
};
|