@gibs/quotes 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +45 -0
  3. package/dist/cache.d.ts +73 -0
  4. package/dist/cache.js +137 -0
  5. package/dist/carriers/calibration.d.ts +115 -0
  6. package/dist/carriers/calibration.js +83 -0
  7. package/dist/carriers/lifi.d.ts +63 -0
  8. package/dist/carriers/lifi.js +217 -0
  9. package/dist/carriers/near-intents.d.ts +91 -0
  10. package/dist/carriers/near-intents.js +249 -0
  11. package/dist/carriers/price-catalogue.d.ts +186 -0
  12. package/dist/carriers/price-catalogue.js +191 -0
  13. package/dist/carriers/relay.d.ts +97 -0
  14. package/dist/carriers/relay.js +242 -0
  15. package/dist/chain-lists.d.ts +144 -0
  16. package/dist/chain-lists.js +169 -0
  17. package/dist/client.d.ts +102 -0
  18. package/dist/client.js +402 -0
  19. package/dist/index.d.ts +19 -0
  20. package/dist/index.js +18 -0
  21. package/dist/quote-guards.d.ts +128 -0
  22. package/dist/quote-guards.js +240 -0
  23. package/dist/quote-refusals.d.ts +180 -0
  24. package/dist/quote-refusals.js +284 -0
  25. package/dist/quote-service-contract.d.ts +2240 -0
  26. package/dist/quote-service-contract.js +247 -0
  27. package/dist/search/capabilities.d.ts +68 -0
  28. package/dist/search/capabilities.js +28 -0
  29. package/dist/search/enumerate.d.ts +162 -0
  30. package/dist/search/enumerate.js +313 -0
  31. package/dist/search/index.d.ts +17 -0
  32. package/dist/search/index.js +17 -0
  33. package/dist/search/serve.d.ts +178 -0
  34. package/dist/search/serve.js +142 -0
  35. package/dist/search/waves.d.ts +481 -0
  36. package/dist/search/waves.js +939 -0
  37. package/dist/single-flight.d.ts +90 -0
  38. package/dist/single-flight.js +142 -0
  39. package/dist/types.d.ts +502 -0
  40. package/dist/types.js +1 -0
  41. package/dist/upstream.d.ts +126 -0
  42. package/dist/upstream.js +343 -0
  43. package/llms.txt +217 -0
  44. package/package.json +142 -0
@@ -0,0 +1,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
+ };