@gibs/bridge-sdk 1.12.0 → 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 (117) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +234 -432
  3. package/dist/abis.d.ts +1 -0
  4. package/dist/bridge-limits.d.ts +103 -0
  5. package/dist/bridge-limits.js +84 -0
  6. package/dist/chain-info.d.ts +169 -0
  7. package/dist/chain-info.js +410 -0
  8. package/dist/chain-names.d.ts +160 -0
  9. package/dist/chain-names.js +200 -0
  10. package/dist/chains.d.ts +65 -0
  11. package/dist/chains.js +284 -0
  12. package/dist/config.d.ts +216 -0
  13. package/dist/config.js +493 -0
  14. package/dist/consolidated-quote.d.ts +554 -0
  15. package/dist/consolidated-quote.js +773 -0
  16. package/dist/destination-calldata.d.ts +118 -0
  17. package/dist/destination-calldata.js +152 -0
  18. package/dist/ecosystems.d.ts +217 -0
  19. package/dist/ecosystems.js +288 -0
  20. package/dist/fee-type.d.ts +10 -0
  21. package/dist/fee-type.js +9 -0
  22. package/dist/hyperliquid-execution.d.ts +152 -0
  23. package/dist/hyperliquid-execution.js +168 -0
  24. package/dist/image-links.d.ts +117 -0
  25. package/dist/image-links.js +142 -0
  26. package/dist/index.d.ts +20 -0
  27. package/dist/index.js +20 -0
  28. package/dist/lifi-execution-guard.d.ts +199 -0
  29. package/dist/lifi-execution-guard.js +400 -0
  30. package/dist/near-intents-conformance.d.ts +85 -0
  31. package/dist/near-intents-conformance.js +329 -0
  32. package/dist/near-intents-route.d.ts +47 -0
  33. package/dist/near-intents-route.js +265 -0
  34. package/dist/near-intents.d.ts +524 -0
  35. package/dist/near-intents.js +1055 -0
  36. package/dist/origin-deposit.d.ts +326 -0
  37. package/dist/origin-deposit.js +68 -0
  38. package/dist/pre-quote.d.ts +198 -0
  39. package/dist/pre-quote.js +175 -0
  40. package/dist/prices.d.ts +107 -0
  41. package/dist/prices.js +162 -0
  42. package/dist/providers.d.ts +221 -0
  43. package/dist/providers.js +157 -0
  44. package/dist/quote-fees.d.ts +102 -0
  45. package/dist/quote-fees.js +195 -0
  46. package/dist/referral.d.ts +77 -0
  47. package/dist/referral.js +72 -0
  48. package/dist/relay-currencies.d.ts +47 -0
  49. package/dist/relay-currencies.js +89 -0
  50. package/dist/relay-quote.d.ts +301 -0
  51. package/dist/relay-quote.js +983 -0
  52. package/dist/route-steps.d.ts +138 -0
  53. package/dist/route-steps.js +305 -0
  54. package/dist/routing/amounts.d.ts +80 -0
  55. package/dist/routing/amounts.js +120 -0
  56. package/dist/routing/candidate-route.d.ts +43 -0
  57. package/dist/routing/candidate-route.js +45 -0
  58. package/dist/routing/candidate.d.ts +237 -0
  59. package/dist/routing/candidate.js +96 -0
  60. package/dist/routing/carriers/ask-answer.d.ts +58 -0
  61. package/dist/routing/carriers/ask-answer.js +1 -0
  62. package/dist/routing/carriers/omnibridge.d.ts +137 -0
  63. package/dist/routing/carriers/omnibridge.js +419 -0
  64. package/dist/routing/carriers/pulsex.d.ts +111 -0
  65. package/dist/routing/carriers/pulsex.js +404 -0
  66. package/dist/routing/carriers/wrap.d.ts +25 -0
  67. package/dist/routing/carriers/wrap.js +115 -0
  68. package/dist/routing/composed-route-quality.d.ts +103 -0
  69. package/dist/routing/composed-route-quality.js +108 -0
  70. package/dist/routing/entry-chain-anchor.d.ts +88 -0
  71. package/dist/routing/entry-chain-anchor.js +55 -0
  72. package/dist/routing/format.d.ts +31 -0
  73. package/dist/routing/format.js +58 -0
  74. package/dist/routing/index.d.ts +34 -0
  75. package/dist/routing/index.js +42 -0
  76. package/dist/routing/producers.d.ts +770 -0
  77. package/dist/routing/producers.js +1564 -0
  78. package/dist/routing/providers.d.ts +26 -0
  79. package/dist/routing/providers.js +38 -0
  80. package/dist/routing/route-data.d.ts +355 -0
  81. package/dist/routing/route-data.js +1 -0
  82. package/dist/routing/search/compose.d.ts +168 -0
  83. package/dist/routing/search/compose.js +98 -0
  84. package/dist/routing/search/graph.d.ts +639 -0
  85. package/dist/routing/search/graph.js +445 -0
  86. package/dist/routing/search/plan-route-data.d.ts +244 -0
  87. package/dist/routing/search/plan-route-data.js +767 -0
  88. package/dist/routing/search/score.d.ts +324 -0
  89. package/dist/routing/search/score.js +400 -0
  90. package/dist/routing/usd-value.d.ts +17 -0
  91. package/dist/routing/usd-value.js +25 -0
  92. package/dist/settings.d.ts +692 -0
  93. package/dist/settings.js +909 -0
  94. package/dist/ton-execution.d.ts +117 -0
  95. package/dist/ton-execution.js +247 -0
  96. package/dist/tron-execution.d.ts +146 -0
  97. package/dist/tron-execution.js +239 -0
  98. package/dist/types.d.ts +51 -0
  99. package/dist/types.js +1 -0
  100. package/package.json +203 -23
  101. package/src/chain-info.test.ts +0 -968
  102. package/src/chain-info.ts +0 -498
  103. package/src/chains.test.ts +0 -123
  104. package/src/chains.ts +0 -88
  105. package/src/config.test.ts +0 -679
  106. package/src/config.ts +0 -500
  107. package/src/fee-type.test.ts +0 -45
  108. package/src/fee-type.ts +0 -14
  109. package/src/image-links.test.ts +0 -160
  110. package/src/image-links.ts +0 -65
  111. package/src/index.ts +0 -8
  112. package/src/settings.test.ts +0 -2754
  113. package/src/settings.ts +0 -875
  114. package/src/types.ts +0 -48
  115. package/tsconfig.json +0 -17
  116. package/vitest.config.ts +0 -13
  117. /package/{src/abis.ts → dist/abis.js} +0 -0
@@ -0,0 +1,767 @@
1
+ import * as imageLinks from '../../image-links.js';
2
+ import { chainDisplayName } from '../../chain-names.js';
3
+ import { findProviderById } from '../../providers.js';
4
+ import { describeDeferredCrossing } from '../../near-intents-route.js';
5
+ import { candidateDeliveredUsd, candidateOutsideFeesUsd, candidatePaidUsd, pulsechainChainId, providerDisplayName, } from '../producers.js';
6
+ import { assetNodeKey, planComposedBasis, } from './graph.js';
7
+ /**
8
+ * PHASE 1 — turns a priced {@link RoutePlan} into the SAME `RouteData` /
9
+ * `RouteCandidate` / `RouteScoreInputs` shapes today's four hand-written
10
+ * producers emit: {@link buildNearIntentsTwoStepRouteData} (`../../near-intents-route.js`),
11
+ * {@link buildConversionRouteData}, {@link buildOutboundRouteData} and
12
+ * {@link buildDirectCrossingCandidate} (paired with `buildCandidateRouteData`)
13
+ * — all in `../producers.js` and `../candidate.js`. See the route-search
14
+ * design's section 6, "what it absorbs," and this module's own tests
15
+ * (`plan-route-data.test.ts`) for the parity checks against each of the four.
16
+ *
17
+ * WHAT THIS MODULE DOES NOT DO: PRICE A PLAN. Every existing builder prices
18
+ * a section from carrier-specific knowledge this module has no access to — a
19
+ * bridge's own fee-manager rate (`crossingCost` in `../producers.js`), a
20
+ * PulseX swap's estimate (`buildPulseXSwapEstimateData`), or an aggregator's
21
+ * own paid/delivered spread (`candidatePaidUsd` / `candidateDeliveredUsd`,
22
+ * both reused here rather than restated). A `RoutePlan` (`./graph.js`) names
23
+ * the STRUCTURE of a path and a base-unit delivered amount per hop; it
24
+ * carries no United States dollar figure at all. So every dollar figure this
25
+ * module places on a `RouteData` — a section's fee, a section's landing
26
+ * value, the route's total cost — is a CALLER-SUPPLIED input
27
+ * ({@link PlanSectionPricing}), never a formula computed here. This mirrors
28
+ * `RouteScoreInputs`' own doc in `score.ts`: "the caller is responsible for
29
+ * turning a priced RoutePlan into these fields."
30
+ *
31
+ * WHAT THIS MODULE DOES DO: compose the STRUCTURE — which section a hop
32
+ * belongs to, whether that section is deferred, what it is labelled, what
33
+ * its hops are titled, and what its token flow is — directly from the
34
+ * plan's graph shape, using exactly the same conventions today's four
35
+ * builders use. Where those four builders DISAGREE with each other (and two
36
+ * of them genuinely do — see `hopIsAutomatic` below), this module picks ONE
37
+ * rule and documents which existing convention it follows and which it
38
+ * knowingly departs from, per this project's "surface conflicts, don't
39
+ * average them" rule. Every departure is named in this module's tests.
40
+ */
41
+ // ---------------------------------------------------------------------------
42
+ // Carrier classification
43
+ // ---------------------------------------------------------------------------
44
+ /**
45
+ * Whether `carrier` is an omnibridge crossing (`'pulsechain'` or
46
+ * `'tokensex'`) — a genuine type predicate, not a plain boolean, so every
47
+ * call site narrows `carrier` from the wide {@link HopCarrier} union down to
48
+ * {@link Provider}. `providerDisplayName` (`../producers.js`) is total only
49
+ * over `Provider`'s two keys and falls back to the generic string
50
+ * "Omnibridge" for anything else; a plain-boolean guard left the call sites
51
+ * below typed as `HopCarrier` and accepting a bare `string`, which is the
52
+ * SAME shape of fault that let a NEAR Intents leg display as "Omnibridge"
53
+ * (see `plan-route-data.test.ts`'s now-fixed "documented divergence" case).
54
+ * Narrowing here makes that class of mistake fail to compile instead of
55
+ * silently mis-naming a hop.
56
+ */
57
+ const isBridgeCarrier = (carrier) => carrier === 'pulsechain' || carrier === 'tokensex';
58
+ /** Whether `carrier` is a consolidated-route aggregator (LI.FI, Relay, NEAR Intents). */
59
+ const isAggregatorCarrier = (carrier) => carrier === 'lifi' || carrier === 'relay' || carrier === 'near-intents';
60
+ /** Whether a hop lands on PulseChain — the signal every label/title rule below keys on. */
61
+ const hopLandsOnPulsechain = (hop) => hop.edge.to.chainId === pulsechainChainId;
62
+ /** Whether `hopIndex` is the plan's LAST hop overall — a completing leg, not a starting one. */
63
+ const isFinalHopOfPlan = (hopIndex, plan) => hopIndex === plan.hops.length - 1;
64
+ /**
65
+ * Resolves a node's display token and asserts its `decimals` agree with the
66
+ * NODE's own `decimals` — the field `graph.ts` added specifically so a hop
67
+ * touching two independently-resolved mentions of the same token can be
68
+ * checked against each other without a second lookup (see `AssetNode`'s own
69
+ * doc). A name is a hypothesis, not evidence: `resolveToken` is a caller
70
+ * hook this module does not control, and trusting its `decimals` blind would
71
+ * silently misplace a decimal point in a real amount exactly the way
72
+ * `compose.ts`'s `decimals-mismatch` refusal exists to prevent one hop
73
+ * downstream. The NODE's decimals wins as the returned, asserted value.
74
+ *
75
+ * @param node - the graph node to resolve
76
+ * @param resolveToken - resolves the node to its full display token
77
+ * @returns the resolved token and the node's own, asserted decimals
78
+ */
79
+ const resolveNodeWithAssertedDecimals = (node, resolveToken) => {
80
+ const token = resolveToken(node);
81
+ if (token.decimals !== node.decimals) {
82
+ throw new Error(`resolveToken disagrees with the graph node about ${assetNodeKey(node)}'s decimals: ` +
83
+ `node says ${node.decimals}, the resolved token says ${token.decimals}`);
84
+ }
85
+ return { token, decimals: node.decimals };
86
+ };
87
+ /**
88
+ * Builds the raw `RouteTokenRef` for one graph node, resolving its logo the
89
+ * same way every existing builder's own token-node helper does
90
+ * (`tokenNode` in `../../near-intents-route.js`, the inline node literals in
91
+ * `buildCandidateRouteData`): the token's own image first, the chain badge
92
+ * from `imageLinks.networkOrPseudo` second — which is what makes a
93
+ * pay-by-hand origin's invented chain id resolve to a real badge instead of
94
+ * a 404ing numeric lookup.
95
+ *
96
+ * @param node - the graph node to render
97
+ * @param resolveToken - resolves the node to its full display token
98
+ * @returns the raw token reference
99
+ */
100
+ const toRouteTokenRef = (node, resolveToken) => {
101
+ const { token, decimals } = resolveNodeWithAssertedDecimals(node, resolveToken);
102
+ return {
103
+ symbol: token.symbol,
104
+ address: token.address,
105
+ chainId: node.chainId,
106
+ decimals,
107
+ // NOT `image`, which checksums the address as an Ethereum one and throws
108
+ // for anything else. A plan can start on a pay-by-hand chain (Zcash's
109
+ // native sentinel is `t1Hsc…`), and that throw blanked the whole bridge
110
+ // form the moment such a route was served. A token's own logo still wins;
111
+ // an address no picture can be asked for draws its chain's mark.
112
+ logoUri: token.logoURI ? token.logoURI : imageLinks.tokenImageOrChainFallback(token),
113
+ chainLogoUri: imageLinks.networkOrPseudo(node.chainId),
114
+ };
115
+ };
116
+ /**
117
+ * The distinct stops a signature group's hops walk: the origin, then every
118
+ * node a hop lands on, collapsing a hop that lands back on the node the
119
+ * flow already holds — generalizing `deriveTokenFlow` in `../producers.js`
120
+ * from RAW ADDRESS-STRING comparison (`isSameTokenRef`) to
121
+ * {@link assetNodeKey}, which is already the collision-free, casing-normalized
122
+ * identity every other part of the graph model uses.
123
+ *
124
+ * THIS IS WHERE THE RECENTLY-FIXED BUG STAYS FIXED. `deriveTokenFlow` used to
125
+ * leave a route showing only its origin when a candidate itemized no hops at
126
+ * all — see `../producers.js`'s own note on the defect. A `RoutePlan` always
127
+ * carries at least one hop for a real crossing, and this walk always ends on
128
+ * that hop's `to` node, so the flow can never stop short of the plan's own
129
+ * declared destination for its group.
130
+ *
131
+ * @param hops - one signature group's hops, first to last
132
+ * @param resolveToken - resolves a graph node to its full display token
133
+ * @returns the group's token flow, origin first
134
+ */
135
+ const buildGroupTokenFlow = (hops, resolveToken) => {
136
+ const originNode = hops[0].edge.from;
137
+ const nodeFlow = hops.reduce((flow, hop) => {
138
+ const produced = hop.edge.to;
139
+ const held = flow[flow.length - 1];
140
+ if (assetNodeKey(held) === assetNodeKey(produced))
141
+ return flow;
142
+ return [...flow, produced];
143
+ }, [originNode]);
144
+ return nodeFlow.map((node) => toRouteTokenRef(node, resolveToken));
145
+ };
146
+ // ---------------------------------------------------------------------------
147
+ // Section-level labelling
148
+ // ---------------------------------------------------------------------------
149
+ /**
150
+ * A signature group's own hop-index list, resolved to its hops.
151
+ *
152
+ * @param group - one entry of `plan.signatures` — hop indices into `plan.hops`
153
+ * @param plan - the plan those indices are drawn from
154
+ * @returns the group's hops, first to last
155
+ */
156
+ const groupHops = (group, plan) => group.map((hopIndex) => plan.hops[hopIndex]);
157
+ /**
158
+ * Whether every hop of a group is carried by PulseX — the standalone-swap
159
+ * shape `buildPulseXSwapSectionData` draws, labelled "Swap on PulseX"
160
+ * regardless of the group's position in the plan.
161
+ */
162
+ const isPulseXOnlyGroup = (hops) => hops.every((hop) => hop.edge.carrier === 'PulseX');
163
+ /** Whether a group's LAST hop is the omnibridge crossing — the fact that decides "Bridge to X". */
164
+ const groupEndsWithBridgeHop = (hops) => isBridgeCarrier(hops[hops.length - 1].edge.carrier);
165
+ /**
166
+ * A signature section's label, generalizing the four distinct conventions
167
+ * found across today's producers into one rule:
168
+ *
169
+ * - **"Swap on PulseX"** — every hop in the group is carried by PulseX.
170
+ * Matches `buildPulseXSwapSectionData`'s hard-coded label.
171
+ * - **"Bridge to {X}"** — the WHOLE plan is one signature (matches
172
+ * `buildCandidateRouteData`, which always uses this label for its one
173
+ * section, omnibridge leg or not), OR the group's last hop IS the
174
+ * omnibridge crossing (matches `buildNearIntentsTwoStepRouteData`'s
175
+ * second section AND `buildOutboundRouteData`'s first section).
176
+ * - **"Complete at {X}"** — the group is the plan's LAST signature and does
177
+ * NOT end on a bridge hop. Matches `buildOutboundRouteData`'s second
178
+ * section, in BOTH its shapes (the entry-chain swap and the onward
179
+ * aggregator hop) — the section label names where the journey finishes
180
+ * regardless of which service carries the last leg.
181
+ * - **"Send to {X}"** — every other case: a non-final, non-bridge-ending
182
+ * group. Matches `buildNearIntentsTwoStepRouteData`'s first section.
183
+ *
184
+ * @param group - the group's hop indices
185
+ * @param groupIndex - the group's position in `plan.signatures`
186
+ * @param plan - the plan the group belongs to
187
+ * @returns the section's label
188
+ */
189
+ const sectionLabel = (group, groupIndex, plan) => {
190
+ const hops = groupHops(group, plan);
191
+ if (isPulseXOnlyGroup(hops))
192
+ return 'Swap on PulseX';
193
+ const destinationChainId = hops[hops.length - 1].edge.to.chainId;
194
+ const isOnlyGroup = plan.signatures.length === 1;
195
+ if (isOnlyGroup || groupEndsWithBridgeHop(hops)) {
196
+ return `Bridge to ${chainDisplayName(destinationChainId)}`;
197
+ }
198
+ const isFinalGroup = groupIndex === plan.signatures.length - 1;
199
+ if (isFinalGroup) {
200
+ return `Complete at ${chainDisplayName(destinationChainId)}`;
201
+ }
202
+ return `Send to ${chainDisplayName(destinationChainId)}`;
203
+ };
204
+ // ---------------------------------------------------------------------------
205
+ // Hop-level rendering
206
+ // ---------------------------------------------------------------------------
207
+ /**
208
+ * A hop's display provider name.
209
+ *
210
+ * A bridge carrier resolves through {@link providerDisplayName} (the same
211
+ * "PulseChain Bridge" / "Tokens Express Bridge" table every builder reads).
212
+ * PulseX is named directly. An aggregator carrier resolves through the
213
+ * PROVIDER REGISTRY's own label (`providerById(id).label`) — DELIBERATELY,
214
+ * not through `providerDisplayName`, because that function's table only
215
+ * ever held the two omnibridge providers. `buildNearIntentsTwoStepRouteData`
216
+ * calls `providerDisplayName(candidate.quote.provider)` for its aggregator
217
+ * hop today, and `'near-intents'` is not a key in that table, so it silently
218
+ * falls back to the generic string `'Omnibridge'` — naming NEAR Intents
219
+ * after the bridge it is not. That reads as an unintentional defect in the
220
+ * existing code, not a convention worth reproducing; this function returns
221
+ * `'NEAR Intents'` instead. See this module's test file and the report back
222
+ * to the owner for the specific call-out.
223
+ *
224
+ * @param carrier - the hop's carrier
225
+ * @returns the display name for that carrier
226
+ */
227
+ const hopProviderName = (carrier) => {
228
+ if (isBridgeCarrier(carrier))
229
+ return providerDisplayName(carrier);
230
+ if (carrier === 'PulseX')
231
+ return 'PulseX';
232
+ // TOTAL OVER EVERY CARRIER. The engine plans hops for carriers the provider
233
+ // registry does not hold (a local wrap, a host's own plug-in). Casting such
234
+ // an id to a provider id and asking the throwing lookup crashed the whole
235
+ // bridge form the first time a plan began with a wrap. A carrier with no
236
+ // registered label is named by its own id.
237
+ return findProviderById(carrier)?.label ?? carrier;
238
+ };
239
+ /**
240
+ * A hop's title, generalizing the title conventions of every existing
241
+ * builder:
242
+ *
243
+ * - a bridge hop landing on PulseChain → `"Bridge to PulseChain"` (matches
244
+ * `buildCandidateRouteData`'s omnibridge hop and
245
+ * `buildNearIntentsTwoStepRouteData`'s crossing hop);
246
+ * - a bridge hop landing elsewhere (leaving PulseChain) →
247
+ * `providerDisplayName(carrier)`, e.g. `"PulseChain Bridge"` (matches
248
+ * `buildOutboundRouteData`'s bridge hop exactly);
249
+ * - a PulseX hop → `"Swap to {toSymbol}"` (matches `buildPulseXSwapSectionData`);
250
+ * - an aggregator hop that is the plan's FINAL hop (nothing follows it,
251
+ * so this signature is the one that COMPLETES the journey) →
252
+ * `"Bridge to {toChainName}"` (matches `buildOutboundRouteData`'s onward
253
+ * hop, which names the chain it finishes on even though an aggregator,
254
+ * not the omnibridge, carries it);
255
+ * - any other aggregator hop (something follows it) → `"Send to {toChainName}"`
256
+ * (matches `buildNearIntentsTwoStepRouteData`'s aggregator hop).
257
+ *
258
+ * @param hop - the hop to title
259
+ * @param hopIndex - the hop's index into `plan.hops`
260
+ * @param plan - the plan the hop belongs to
261
+ * @param resolveToken - resolves a graph node to its full display token
262
+ * @returns the hop's title
263
+ */
264
+ const hopTitle = (hop, hopIndex, plan, resolveToken) => {
265
+ const carrier = hop.edge.carrier;
266
+ if (isBridgeCarrier(carrier)) {
267
+ return hopLandsOnPulsechain(hop)
268
+ ? `Bridge to ${chainDisplayName(pulsechainChainId)}`
269
+ : providerDisplayName(carrier);
270
+ }
271
+ if (carrier === 'PulseX') {
272
+ return `Swap to ${resolveToken(hop.edge.to).symbol}`;
273
+ }
274
+ const destinationChainName = chainDisplayName(hop.edge.to.chainId);
275
+ return isFinalHopOfPlan(hopIndex, plan)
276
+ ? `Bridge to ${destinationChainName}`
277
+ : `Send to ${destinationChainName}`;
278
+ };
279
+ /**
280
+ * A hop's conversion subtitle.
281
+ *
282
+ * The source data has two conventions for this string: the candidate-based
283
+ * builders (`buildCandidateRouteData`'s omnibridge hop,
284
+ * `buildNearIntentsTwoStepRouteData`'s both hops) write an ARROW-ONLY clause
285
+ * (`"→ USDC"`), leaving the origin side implicit; the settings-based
286
+ * builders (`buildOutboundRouteData`, `buildPulseXSwapSectionData`) write
287
+ * the FULL two-sided clause (`"PLS → USDC"`). Both are correct prose for
288
+ * their own hop; this function reproduces BOTH conventions rather than
289
+ * picking one, keyed on the exact same signal {@link hopTitle} already uses
290
+ * to distinguish a starting/intermediate leg from a completing one: arrow-only
291
+ * for a bridge hop landing on PulseChain or an aggregator hop that is NOT the
292
+ * plan's final hop (there is more journey to draw, so the origin side is
293
+ * already on screen from the previous hop); full two-sided otherwise.
294
+ *
295
+ * @param hop - the hop to describe
296
+ * @param hopIndex - the hop's index into `plan.hops`
297
+ * @param plan - the plan the hop belongs to
298
+ * @param resolveToken - resolves a graph node to its full display token
299
+ * @returns the hop's conversion subtitle
300
+ */
301
+ const hopConversion = (hop, hopIndex, plan, resolveToken) => {
302
+ const carrier = hop.edge.carrier;
303
+ const toSymbol = resolveToken(hop.edge.to).symbol;
304
+ const usesArrowOnly = (isBridgeCarrier(carrier) && hopLandsOnPulsechain(hop)) ||
305
+ (isAggregatorCarrier(carrier) && !isFinalHopOfPlan(hopIndex, plan));
306
+ if (usesArrowOnly)
307
+ return `→ ${toSymbol}`;
308
+ const fromSymbol = resolveToken(hop.edge.from).symbol;
309
+ return `${fromSymbol} → ${toSymbol}`;
310
+ };
311
+ /**
312
+ * Whether a hop runs AUTOMATICALLY inside a signature a PRECEDING hop
313
+ * already signed — true exactly when the hop is not the first hop of its
314
+ * own signature group, per `RouteHopData.automatic`'s own doc ("runs
315
+ * automatically inside the SECTION's single signature").
316
+ *
317
+ * THIS MATCHES THREE OF THE FOUR TARGET SHAPES EXACTLY —
318
+ * `buildCandidateRouteData` (an aggregator hop with index > 0 inside its
319
+ * section, and the omnibridge hop only when an aggregator hop preceded it),
320
+ * `buildNearIntentsTwoStepRouteData` (both of its hops are their own
321
+ * section's first and only hop, so both are `false`), and
322
+ * `buildPulseXSwapSectionData` (its one hop is always `false`, hard-coded
323
+ * for the same reason).
324
+ *
325
+ * IT DOES NOT MATCH `buildOutboundRouteData`'s BRIDGE HOP, which hard-codes
326
+ * `true` even though it is that section's own first and only hop — a SECOND,
327
+ * DIFFERENT reading of "automatic" that describes the omnibridge's own
328
+ * far-side auto-completion (no reader signature needed to FINISH the
329
+ * crossing once signed) rather than whether the hop itself needed no
330
+ * signature to START. `buildDirectRouteData` (a separate, non-plan family
331
+ * this module does not otherwise touch) hard-codes the same reading. This
332
+ * function follows the field's own documented meaning and the majority
333
+ * convention (three of four builders) rather than that reading — see this
334
+ * module's test file for the specific, deliberate divergence this produces
335
+ * against `buildOutboundRouteData`'s bridge hop.
336
+ *
337
+ * @param hopIndex - the hop's index into `plan.hops`
338
+ * @param plan - the plan the hop belongs to
339
+ * @returns whether the hop is automatic
340
+ */
341
+ const hopIsAutomatic = (hopIndex, plan) => {
342
+ const group = plan.signatures.find((candidateGroup) => candidateGroup.includes(hopIndex));
343
+ return group !== undefined && group[0] !== hopIndex;
344
+ };
345
+ /**
346
+ * Builds the raw `RouteHopData` for one planned hop.
347
+ *
348
+ * @param hop - the hop to render
349
+ * @param hopIndex - the hop's index into `plan.hops`
350
+ * @param plan - the plan the hop belongs to
351
+ * @param resolveToken - resolves a graph node to its full display token
352
+ * @returns the raw hop data
353
+ */
354
+ const toRouteHopData = (hop, hopIndex, plan, resolveToken) => ({
355
+ providerName: hopProviderName(hop.edge.carrier),
356
+ providerKey: hop.edge.carrier,
357
+ title: hopTitle(hop, hopIndex, plan, resolveToken),
358
+ conversion: hopConversion(hop, hopIndex, plan, resolveToken),
359
+ fromToken: toRouteTokenRef(hop.edge.from, resolveToken),
360
+ toToken: toRouteTokenRef(hop.edge.to, resolveToken),
361
+ // The per-hop dollar figure is a section-level, caller-supplied input (see
362
+ // this module's head doc); a raw hop carries none of its own here.
363
+ feeUsdValue: null,
364
+ automatic: hopIsAutomatic(hopIndex, plan),
365
+ });
366
+ // ---------------------------------------------------------------------------
367
+ // Route-level derivations — candidate.ts's promised derivation, made real
368
+ // ---------------------------------------------------------------------------
369
+ /**
370
+ * The plan's omnibridge provider, or null when the plan crosses no bridge
371
+ * hop at all — exactly the derivation `RouteCandidate.plan`'s own TSDoc
372
+ * (`../candidate.ts`) promises: "that same hop's `carrier`... the `to` node's
373
+ * `chainId` of the plan's LAST hop whose carrier is `'pulsechain'` or
374
+ * `'tokensex'`." Reading the LAST such hop, not the first, matters for a
375
+ * plan with two bridge crossings (an outbound leg out of PulseChain followed
376
+ * later by a separate inbound one) — not a shape any of today's four
377
+ * builders draws, but one the general search can.
378
+ *
379
+ * @param plan - the plan to derive from
380
+ * @returns the omnibridge provider, or null
381
+ */
382
+ export const deriveOmnibridgeProviderFromPlan = (plan) => {
383
+ const bridgeHop = [...plan.hops].reverse().find((hop) => isBridgeCarrier(hop.edge.carrier));
384
+ if (bridgeHop === undefined)
385
+ return null;
386
+ // `Array.prototype.find`'s own predicate narrowing does not flow back out
387
+ // of the callback into the found element's type — only `isBridgeCarrier`
388
+ // called again, directly on the plain `carrier` binding, narrows it here.
389
+ // Re-declaring the return type as the real `Provider | null` (rather than
390
+ // the wide `string | null` this used to widen to) is what surfaces a
391
+ // mismatch at the source the moment a caller narrows further downstream,
392
+ // instead of a silent `string` riding all the way to `providerDisplayName`.
393
+ const { carrier } = bridgeHop.edge;
394
+ return isBridgeCarrier(carrier) ? carrier : null;
395
+ };
396
+ /**
397
+ * The plan's entry chain id — the chain the plan's FIRST hop lands on, when
398
+ * that hop is carried by an aggregator (LI.FI, Relay or NEAR Intents); null
399
+ * when the plan's first hop IS the omnibridge itself (a direct, same-chain
400
+ * crossing — `buildDirectCrossingCandidate`'s shape) or is a PulseX swap.
401
+ *
402
+ * MATCHES `RouteCandidate.entryChainId`'S EXISTING MEANING throughout the
403
+ * candidate-based builders — "the chain the omnibridge leg starts on" — for
404
+ * every candidate that carries one, including a candidate whose aggregator
405
+ * reaches its OWN destination with no further bridge hop at all (today's
406
+ * `makeCandidate` test fixtures set `entryChainId` to the destination chain
407
+ * in exactly that case, because it is still where the aggregator's own leg
408
+ * lands).
409
+ *
410
+ * @param plan - the plan to derive from
411
+ * @returns the entry chain id, or null for a direct crossing or a swap-only plan
412
+ */
413
+ export const deriveEntryChainIdFromPlan = (plan) => {
414
+ const firstHop = plan.hops[0];
415
+ if (!firstHop)
416
+ return null;
417
+ return isAggregatorCarrier(firstHop.edge.carrier) ? firstHop.edge.to.chainId : null;
418
+ };
419
+ /**
420
+ * The decimal chain ids every INTERMEDIATE hop of the plan passes through,
421
+ * origin and destination excluded — `RouteData.viaChainIds`'s own contract.
422
+ * Undefined (never an empty array) when the plan has nothing to report, per
423
+ * that field's own "ABSENT... on any route with no intermediate chain" doc.
424
+ *
425
+ * @param plan - the plan to derive from
426
+ * @returns the via chain ids in hop order, or undefined
427
+ */
428
+ export const deriveViaChainIdsFromPlan = (plan) => {
429
+ const intermediateChainIds = plan.hops.slice(0, -1).map((hop) => hop.edge.to.chainId);
430
+ return intermediateChainIds.length > 0 ? intermediateChainIds : undefined;
431
+ };
432
+ /**
433
+ * The plan's `RouteKind`, per the route-search design's section 1: `'direct'`
434
+ * when signature 1 IS the omnibridge crossing itself (no aggregator prefix —
435
+ * `buildDirectCrossingCandidate`'s shape, and `buildOutboundRouteData`'s
436
+ * shape), `'conversion'` when the LAST hop is a PulseX swap
437
+ * (`buildConversionRouteData`'s shape), `'aggregated'` otherwise (an
438
+ * aggregator carries signature 1, fused with a bridge hop or not —
439
+ * `buildNearIntentsTwoStepRouteData`'s shape, and the general
440
+ * aggregator-fused shape).
441
+ *
442
+ * DIVERGES FROM TODAY'S LITERAL OUTPUT FOR ONE CASE, DELIBERATELY: a
443
+ * same-chain candidate built by `buildDirectCrossingCandidate` and rendered
444
+ * through `buildCandidateRouteData` today always comes back `kind:
445
+ * 'aggregated'`, because that builder hard-codes `'aggregated'`
446
+ * unconditionally — an artifact of `RouteCandidate.entryChainId` being
447
+ * non-nullable, so `buildCandidateRouteData` always assumes an
448
+ * aggregator-entry shape even for a candidate with no aggregator leg at all.
449
+ * This function instead answers `'direct'` for that exact plan, matching how
450
+ * `buildDirectRouteData` (the OTHER, non-candidate 'direct' family) already
451
+ * represents the identical real-world same-chain crossing. See this
452
+ * module's test file for the specific case.
453
+ *
454
+ * @param plan - the plan to derive from
455
+ * @returns the route's kind
456
+ */
457
+ export const derivePlanKind = (plan) => {
458
+ const lastHop = plan.hops[plan.hops.length - 1];
459
+ if (lastHop && lastHop.edge.carrier === 'PulseX')
460
+ return 'conversion';
461
+ const firstHop = plan.hops[0];
462
+ if (firstHop && isBridgeCarrier(firstHop.edge.carrier))
463
+ return 'direct';
464
+ return 'aggregated';
465
+ };
466
+ /**
467
+ * Narrows a plan's {@link HopCarrier} (a plain string, since `HopEdge.carrier`
468
+ * moved off the closed `ProviderKey` union) down to `RouteData.headlineProviderKey`'s
469
+ * own closed {@link ProviderKey} type, for the display/logo lookup tables that
470
+ * type exists to protect (`resolveProviderLogoUri` / `resolveProviderDisplayName`
471
+ * — see `ProviderKey`'s own doc in `../../providers.js`).
472
+ *
473
+ * A GIBS PLAN NEVER CARRIES ANYTHING ELSE. Every carrier a `RoutePlan` this
474
+ * module renders can name is one gibs itself registered — the three built-in
475
+ * aggregators, the omnibridge, or PulseX (`routing/carriers/`,
476
+ * `packages/ui/src/lib/state/useRouteSearch.ts`) — so this throws rather than
477
+ * guessing when it sees anything else: a plan naming an unrecognized carrier
478
+ * is a bug in whatever built it, not a display gap to paper over silently.
479
+ *
480
+ * @param carrier - the plan's headline carrier
481
+ * @returns the same value, typed as `ProviderKey`
482
+ */
483
+ const toHeadlineProviderKey = (carrier) => {
484
+ if (isBridgeCarrier(carrier))
485
+ return carrier;
486
+ if (isAggregatorCarrier(carrier))
487
+ return carrier;
488
+ if (carrier === 'PulseX')
489
+ return carrier;
490
+ throw new Error(`toHeadlineProviderKey: unrecognized carrier "${carrier}"`);
491
+ };
492
+ /**
493
+ * The plan's headline provider — the omnibridge crossing it out when it
494
+ * crosses one, or whichever carrier performs signature 1 when it does not.
495
+ * Matches every existing builder's own headline choice: the omnibridge when
496
+ * present (`buildCandidateRouteData`, `buildNearIntentsTwoStepRouteData`,
497
+ * `buildOutboundRouteData`), the aggregator's own key as the fallback
498
+ * (`buildCandidateRouteData`'s no-omnibridge branch).
499
+ *
500
+ * @param plan - the plan to derive from
501
+ * @returns the headline provider key
502
+ */
503
+ const deriveHeadlineProviderKeyFromPlan = (plan) => toHeadlineProviderKey(deriveOmnibridgeProviderFromPlan(plan) ?? plan.hops[0].edge.carrier);
504
+ /**
505
+ * The anchor a two-signature plan leaves the reader holding between its
506
+ * first and second groups — `RouteCandidate.plan`'s own TSDoc: "the anchor
507
+ * the reader is left holding between signatures... taken from the first hop
508
+ * of the second group." Reuses `describeDeferredCrossing`
509
+ * (`../../near-intents-route.js`) rather than re-validating the same
510
+ * "positive amount, resolved token" rule a second time.
511
+ *
512
+ * Null when the plan has only one signature group (there is no deferred
513
+ * crossing to describe) or the plan is malformed (a signature group naming
514
+ * hop indices the plan does not have).
515
+ *
516
+ * @param plan - the plan to derive from
517
+ * @param resolveToken - resolves a graph node to its full display token
518
+ * @param anchorUsdValue - the anchor's own carrier's valuation of the
519
+ * landing, exactly as `DeferredCrossing.anchorUsdValue` expects — supplied
520
+ * by the caller, since a plan carries no United States dollar figures.
521
+ * @returns the deferred crossing, or null
522
+ */
523
+ export const deriveDeferredCrossingFromPlan = (plan, resolveToken, anchorUsdValue) => {
524
+ const secondGroup = plan.signatures[1];
525
+ const firstGroup = plan.signatures[0];
526
+ if (!secondGroup || !firstGroup)
527
+ return null;
528
+ const anchorHopIndex = secondGroup[0];
529
+ const landingHopIndex = firstGroup[firstGroup.length - 1];
530
+ if (anchorHopIndex === undefined || landingHopIndex === undefined)
531
+ return null;
532
+ const anchorHop = plan.hops[anchorHopIndex];
533
+ const landingHop = plan.hops[landingHopIndex];
534
+ if (!anchorHop || !landingHop)
535
+ return null;
536
+ return describeDeferredCrossing({
537
+ anchorToken: resolveToken(anchorHop.edge.from),
538
+ anchorAmount: landingHop.outcome.deliveredAmount,
539
+ anchorUsdValue,
540
+ });
541
+ };
542
+ /**
543
+ * Whether `hop`'s own figure carries no value at all — an `EstimateBasis`
544
+ * (`graph.ts`) of `'unpriced'`: a bare decimal rescale with nothing behind
545
+ * it (see that type's own doc). {@link sectionLandingAmount} is what a
546
+ * `true` result means for the section's (and the route's) headline figure:
547
+ * it is left OUT of `amount` entirely, never shown at all.
548
+ */
549
+ const hopOutcomeIsUnpriced = (hop) => hop.outcome.kind === 'estimate' && hop.outcome.basis === 'unpriced';
550
+ /**
551
+ * What `hop` reports for `RouteLandingData.estimateBasis` /
552
+ * `RouteOutputData.estimateBasis` (`../route-data.ts`): the hop's own
553
+ * `EstimateBasis` string when it is a composed/probed figure, `null` for a
554
+ * bound quote — and `null` for `'unpriced'` too, per that field's own doc
555
+ * ("'unpriced' never reaches here"), since {@link sectionLandingAmount}
556
+ * already leaves an unpriced hop's `amount` out entirely rather than
557
+ * labelling it.
558
+ *
559
+ * @param hop - the hop to read
560
+ * @returns the estimate basis to report, or null
561
+ */
562
+ const hopEstimateBasis = (hop) => {
563
+ if (hop.outcome.kind !== 'estimate')
564
+ return null;
565
+ if (hop.outcome.basis === 'unpriced')
566
+ return null;
567
+ return hop.outcome.basis;
568
+ };
569
+ /**
570
+ * A section's landing amount: the caller's explicit override when given
571
+ * ({@link PlanSectionPricing.landingAmountOverride}'s own doc), else `null`
572
+ * when the section's last hop is `'unpriced'` (see {@link hopOutcomeIsUnpriced}
573
+ * — a figure with no value behind it is never shown, marked or not), else
574
+ * the hop's own composed delivered amount.
575
+ *
576
+ * @param pricing - the section's caller-supplied pricing
577
+ * @param lastHop - the section's last hop
578
+ * @returns the amount to land the section on, or null
579
+ */
580
+ const sectionLandingAmount = (pricing, lastHop) => {
581
+ if ('landingAmountOverride' in pricing)
582
+ return pricing.landingAmountOverride ?? null;
583
+ if (hopOutcomeIsUnpriced(lastHop))
584
+ return null;
585
+ return lastHop.outcome.deliveredAmount;
586
+ };
587
+ /**
588
+ * Turns a priced {@link RoutePlan} into a raw {@link RouteData} — one section
589
+ * per signature group, `deferred` on every group after the first, each
590
+ * section's hops and token flow drawn structurally from the plan, and the
591
+ * dollar figures overlaid from `inputs.sections`. See this module's head doc
592
+ * for why the dollar figures are caller-supplied rather than derived here.
593
+ *
594
+ * NEVER SHOWS AN UNPRICED HEADLINE FIGURE, AND MARKS EVERY OTHER ESTIMATE AS
595
+ * ONE — PHASE 3. A section (and the route's own `output`) whose last hop is
596
+ * grounded on `EstimateBasis` `'unpriced'` lands on `amount: null`, whatever
597
+ * the caller's own pricing says, per {@link sectionLandingAmount}'s own doc.
598
+ * Every OTHER estimate (`'probe-rate'`, `'priced-conversion'`) still shows
599
+ * its figure, with `estimateBasis` set to that basis so a reader is never
600
+ * shown a composed/probed number as if it were a confirmed quote — see
601
+ * `RouteOutputData.estimateBasis`'s own doc (`../route-data.ts`) for the
602
+ * full rule this reads off {@link hopEstimateBasis}.
603
+ *
604
+ * @param inputs - the plan, a token resolver, an id, and per-section pricing
605
+ * @returns the raw route data
606
+ */
607
+ export const buildRouteDataFromPlan = (inputs) => {
608
+ const { plan, resolveToken, routeId, sections, totalCostUsdValue, netValueUsd, etaMinutes } = inputs;
609
+ if (plan.hops.length === 0) {
610
+ throw new Error('buildRouteDataFromPlan: a plan needs at least one hop');
611
+ }
612
+ if (sections.length !== plan.signatures.length) {
613
+ throw new Error(`buildRouteDataFromPlan: expected ${plan.signatures.length} section(s) of pricing, got ${sections.length}`);
614
+ }
615
+ const routeSections = plan.signatures.map((group, groupIndex) => {
616
+ const hops = groupHops(group, plan);
617
+ const pricing = sections[groupIndex];
618
+ const lastHop = hops[hops.length - 1];
619
+ const landing = resolveNodeWithAssertedDecimals(lastHop.edge.to, resolveToken);
620
+ const landingIsUnpriced = hopOutcomeIsUnpriced(lastHop);
621
+ return {
622
+ id: `${routeId}-signature-${groupIndex + 1}`,
623
+ label: sectionLabel(group, groupIndex, plan),
624
+ providerKey: hops[0].edge.carrier,
625
+ tokenFlow: buildGroupTokenFlow(hops, resolveToken),
626
+ landing: {
627
+ amount: sectionLandingAmount(pricing, lastHop),
628
+ decimals: landing.decimals,
629
+ symbol: landing.token.symbol,
630
+ usdValue: landingIsUnpriced ? null : pricing.landingUsdValue,
631
+ estimateBasis: hopEstimateBasis(lastHop),
632
+ },
633
+ feeUsdValue: pricing.feeUsdValue,
634
+ fees: pricing.fees ? [...pricing.fees] : undefined,
635
+ etaMinutes: pricing.etaMinutes,
636
+ etaSeconds: pricing.etaSeconds,
637
+ hops: group.map((hopIndex) => toRouteHopData(plan.hops[hopIndex], hopIndex, plan, resolveToken)),
638
+ deferred: groupIndex > 0 ? true : undefined,
639
+ deferredNote: pricing.deferredNote,
640
+ };
641
+ });
642
+ const lastSection = routeSections[routeSections.length - 1];
643
+ return {
644
+ kind: derivePlanKind(plan),
645
+ entryChainId: deriveEntryChainIdFromPlan(plan),
646
+ headlineProviderKey: deriveHeadlineProviderKeyFromPlan(plan),
647
+ sections: routeSections,
648
+ output: {
649
+ amount: lastSection.landing?.amount ?? null,
650
+ decimals: lastSection.landing?.decimals ?? 0,
651
+ symbol: lastSection.landing?.symbol ?? '',
652
+ usdValue: lastSection.landing?.usdValue ?? null,
653
+ estimateBasis: lastSection.landing?.estimateBasis ?? null,
654
+ },
655
+ totalCostUsdValue,
656
+ netValueUsd,
657
+ etaMinutes,
658
+ // The single legacy `viaChainId` a not-yet-migrated consumer may still
659
+ // read (`RouteOption`, `journeyPlan.ts`'s current `pathKeyFor` — see the
660
+ // route-search design's section 1) — the first intermediate chain, when
661
+ // there is one. `buildOutboundRouteData` sets this to the chain its one
662
+ // bridge hop lands on, which is exactly this plan's first via chain.
663
+ viaChainId: deriveViaChainIdsFromPlan(plan)?.[0],
664
+ pathKey: plan.pathKey,
665
+ viaChainIds: deriveViaChainIdsFromPlan(plan),
666
+ };
667
+ };
668
+ /**
669
+ * Turns a priced {@link RoutePlan} into a {@link RouteCandidate}, filling
670
+ * `plan` and deriving `deferredCrossing`, `entryChainId` and
671
+ * `omnibridgeProvider` from it exactly as `RouteCandidate.plan`'s own TSDoc
672
+ * (`../candidate.ts`) promises.
673
+ *
674
+ * @param inputs - the plan, a token resolver, an id, and the caller's pricing
675
+ * @returns the route candidate
676
+ */
677
+ export const buildRouteCandidateFromPlan = (inputs) => {
678
+ const { plan, resolveToken, id, inputAmount, paidUsdValue, lastHopDeliveredUsdValue, destinationUsdPrice, omnibridgeFeeAmount = 0n, anchorUsdValue = null, requestedAmount, fees = [], executionDurationSeconds = 0, } = inputs;
679
+ const firstHop = plan.hops[0];
680
+ const lastHop = plan.hops[plan.hops.length - 1];
681
+ if (!firstHop || !lastHop) {
682
+ throw new Error('buildRouteCandidateFromPlan: a plan needs at least one hop');
683
+ }
684
+ const omnibridgeProvider = deriveOmnibridgeProviderFromPlan(plan);
685
+ const entryChainId = deriveEntryChainIdFromPlan(plan) ?? lastHop.edge.to.chainId;
686
+ const originToken = resolveNodeWithAssertedDecimals(firstHop.edge.from, resolveToken).token;
687
+ const destinationToken = resolveNodeWithAssertedDecimals(lastHop.edge.to, resolveToken).token;
688
+ const deferredCrossing = deriveDeferredCrossingFromPlan(plan, resolveToken, anchorUsdValue);
689
+ const carrier = firstHop.edge.carrier;
690
+ const quote = {
691
+ provider: carrier,
692
+ quoteId: id,
693
+ tool: carrier,
694
+ toolName: hopProviderName(carrier),
695
+ fromAmount: inputAmount,
696
+ fromAmountUsd: paidUsdValue,
697
+ ...(omnibridgeProvider === null && lastHopDeliveredUsdValue !== null
698
+ ? { toAmount: lastHop.outcome.deliveredAmount, toAmountUsd: lastHopDeliveredUsdValue }
699
+ : {}),
700
+ approvalAddress: null,
701
+ executionDurationSeconds,
702
+ steps: [],
703
+ fees: [...fees],
704
+ // A PRICED-BUT-NOT-RESERVED deposit (`UnreservedDeposit`,
705
+ // `../../origin-deposit.js`) — the variant that EXISTS for exactly this
706
+ // case: "a route on display... priced with a dry quote, which carries
707
+ // figures and no address" (that type's own doc). This candidate exists
708
+ // for scoring and for `RouteData`/`RouteCandidate` shape assembly, never
709
+ // for a real signature, so it has nothing to reserve.
710
+ //
711
+ // THIS IS NOT A STAND-IN FOR THE ORIGIN'S REAL ECOSYSTEM. An earlier
712
+ // version of this line hard-coded `{ ecosystem: 'evm', ... }`
713
+ // unconditionally, which is wrong for any non-Ethereum-Virtual-Machine
714
+ // origin a general-search plan can now represent (a Zcash or Solana
715
+ // first hop, for example) — a deposit's ecosystem must come from the
716
+ // chain, never asserted past it. `UnreservedDeposit` sidesteps the
717
+ // question honestly instead of answering it wrongly: it is
718
+ // discriminated by `kind`, not by `ecosystem`, and carries no
719
+ // ecosystem-specific fields at all, so there is no wrong shape to pick
720
+ // regardless of which ecosystem `firstHop.edge.from` names.
721
+ originDeposit: { kind: 'unreserved', chainId: firstHop.edge.from.chainId },
722
+ };
723
+ return {
724
+ id,
725
+ quote,
726
+ omnibridgeProvider,
727
+ entryChainId,
728
+ originToken,
729
+ destinationToken,
730
+ deliveredAmount: lastHop.outcome.deliveredAmount,
731
+ omnibridgeFeeAmount,
732
+ destinationUsdPrice,
733
+ requestedAmount,
734
+ deferredCrossing,
735
+ plan,
736
+ };
737
+ };
738
+ /**
739
+ * Turns a priced {@link RoutePlan} into {@link RouteScoreInputs} — reusing
740
+ * `candidateDeliveredUsd`, `candidatePaidUsd` and `candidateOutsideFeesUsd`
741
+ * (`../producers.js`) against the SAME candidate
742
+ * {@link buildRouteCandidateFromPlan} builds, exactly as `RouteScoreInputs`'
743
+ * own doc in `score.ts` asks: "generalized to the plan's LAST hop and FIRST
744
+ * hop respectively... import and reuse them; do not write a second
745
+ * valuation."
746
+ *
747
+ * @param valuation - the plan, a token resolver, an id, and the caller's pricing
748
+ * @param gaslessIntermediateChainCount - intermediate chains the connected
749
+ * account holds no gas on — supplied by the caller, never computed here
750
+ * @returns the plan's score inputs
751
+ */
752
+ export const buildRouteScoreInputsFromPlan = (valuation, gaslessIntermediateChainCount) => {
753
+ const candidate = buildRouteCandidateFromPlan(valuation);
754
+ return {
755
+ deliveredUsd: candidateDeliveredUsd(candidate) ?? 0,
756
+ paidUsd: candidatePaidUsd(candidate) ?? 0,
757
+ outsideFeesUsd: candidateOutsideFeesUsd(candidate),
758
+ legClaimedDurationsSeconds: valuation.legClaimedDurationsSeconds,
759
+ readerActionCount: valuation.plan.signatures.length,
760
+ gaslessIntermediateChainCount,
761
+ // PHASE 3 — read directly off the plan's own hops, never re-derived: see
762
+ // `planComposedBasis`'s own doc (`graph.ts`) for why this always agrees
763
+ // with `planIsRankable`, and `RouteScoreInputs.basis`'s own doc for what
764
+ // `scoreRoutePlan` does with it.
765
+ basis: planComposedBasis(valuation.plan.hops),
766
+ };
767
+ };