@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,1564 @@
1
+ /**
2
+ * Pure route-producer helpers and constants shared by every route builder.
3
+ * Lives in the software development kit so the builders can relocate here in
4
+ * subsequent tasks without pulling user-interface dependencies into the kit.
5
+ *
6
+ * All helpers are framework-agnostic: no React, no Svelte, no display formatters.
7
+ */
8
+ import { Chains, Providers, pathway } from '../config.js';
9
+ import { chainDisplayName } from '../chain-names.js';
10
+ import { usdValueOfAmount } from './usd-value.js';
11
+ import * as imageLinks from '../image-links.js';
12
+ import { roundUsdToCents } from './format.js';
13
+ import { formatUnits } from 'viem';
14
+ // ---------------------------------------------------------------------------
15
+ // Constants
16
+ // ---------------------------------------------------------------------------
17
+ /**
18
+ * The PulseX swap fee in basis points (hundredths of a percent). The deferred
19
+ * outbound leg swaps the bridged intermediate token into the wanted token on the
20
+ * entry chain at this rate, so the deferred section's fee is APPROXIMATE — the
21
+ * binding quote is only known when the user returns. 29 basis points is roughly
22
+ * 0.29%.
23
+ */
24
+ const PULSEX_SWAP_FEE_BASIS_POINTS = 29n;
25
+ /** The denominator that turns {@link PULSEX_SWAP_FEE_BASIS_POINTS} into a fraction. */
26
+ const BASIS_POINTS_DENOMINATOR = 10000n;
27
+ /**
28
+ * Divisor for the MARGINAL reference swap quote. A swap's price impact cannot be
29
+ * read from the trade quote alone — `getAmountsOut(fullInput)` already folds the
30
+ * impact into its output, and the PulseChain USD prices are themselves PulseX
31
+ * pool quotes, so differencing them cancels the impact out (it vanishes to
32
+ * $0.00). Instead the estimate quotes a TINY reference input — `1/this` of the
33
+ * trade — whose own impact is negligible, giving the near-spot marginal rate. The
34
+ * gap between that marginal rate and the full trade's realized rate IS the price
35
+ * impact (the swap fee rides in BOTH quotes, so it cancels and the gap is impact
36
+ * alone). 1/10000 of the trade is small enough to be effectively marginal yet
37
+ * large enough that its output does not round away.
38
+ */
39
+ export const PULSEX_MARGINAL_REFERENCE_DIVISOR = 10000n;
40
+ /**
41
+ * The approximate wall-clock wait for an omnibridge crossing to settle before a
42
+ * DEFERRED swap leg can be signed — the same ~20 minutes whether the swap happens
43
+ * on the entry chain (outbound PulseChain → Ethereum/Binance Smart Chain) or on
44
+ * PulseChain (conversion route: bridge an anchor in, then swap on PulseX).
45
+ * Surfaced compactly as `~20 min` in the deferred section head, mirroring how
46
+ * every other section shows its estimate, rather than spelled out inside the
47
+ * deferred note.
48
+ */
49
+ export const BRIDGE_SETTLEMENT_ETA_MINUTES = 20;
50
+ /**
51
+ * The approximate wall-clock duration of the SWAP itself, in seconds — a PulseX
52
+ * swap (or an entry-chain swap) settles in roughly ten seconds once signed. This
53
+ * is the swap leg's OWN time, distinct from the ~20-minute crossing it waits
54
+ * behind ({@link BRIDGE_SETTLEMENT_ETA_MINUTES}): the wait belongs on the BRIDGE
55
+ * leg, the near-instant swap on the SWAP leg, so each section reads honestly
56
+ * ("~20 min" to cross, then "~10 sec" to swap) rather than stamping the crossing's
57
+ * twenty minutes onto the swap.
58
+ */
59
+ const PULSEX_SWAP_ETA_SECONDS = 10;
60
+ /** Decimal chain id for PulseChain — the shared destination of every inbound route. */
61
+ export const pulsechainChainId = Number(Chains.PLS);
62
+ // ---------------------------------------------------------------------------
63
+ // Private backing maps
64
+ // ---------------------------------------------------------------------------
65
+ /**
66
+ * The fully spelled-out display name of each omnibridge provider, keyed by the
67
+ * raw provider identifier the builders carry. These are the headline names the
68
+ * route strip shows as the route's bridge.
69
+ *
70
+ * Typed `Record<Provider, string>`, exhaustive over the two real omnibridge
71
+ * crossings, so a third one added to `Providers` without a name here is a
72
+ * compile error rather than a silent gap in {@link omnibridgeProviderDisplayName}.
73
+ */
74
+ const omnibridgeProviderDisplayNames = {
75
+ [Providers.PULSECHAIN]: 'PulseChain Bridge',
76
+ [Providers.TOKENSEX]: 'Tokens Express Bridge',
77
+ };
78
+ /**
79
+ * Whether `value` genuinely names an omnibridge crossing, narrowing it to
80
+ * {@link Provider}.
81
+ *
82
+ * @param value - a string that may or may not be a real omnibridge provider
83
+ * @returns whether `value` is one of the two registered crossings
84
+ */
85
+ const isOmnibridgeProvider = (value) => value === Providers.PULSECHAIN || value === Providers.TOKENSEX;
86
+ /**
87
+ * The display name for an omnibridge crossing, fully spelled out. Narrowed to
88
+ * exactly {@link Provider} — the two real crossings — rather than any string,
89
+ * so passing an aggregator's `ProviderId` (or anything else) is a COMPILE
90
+ * ERROR here, not a silently wrong name.
91
+ *
92
+ * PREFER THIS OVER {@link providerDisplayName} wherever the caller already
93
+ * holds a genuine `Provider`. `providerDisplayName` exists only because some
94
+ * callers (`RouteCandidate.omnibridgeProvider`, `RouteData.headlineProviderKey`)
95
+ * are typed `string` at their own source and cannot be narrowed from here.
96
+ *
97
+ * @param provider - a real omnibridge provider
98
+ * @returns the spelled-out provider name
99
+ */
100
+ export const omnibridgeProviderDisplayName = (provider) => omnibridgeProviderDisplayNames[provider];
101
+ /**
102
+ * The fully spelled-out display name of an omnibridge ENTRY chain, keyed by its
103
+ * decimal chain id. Acronyms are spelled out rather than read from viem's terse
104
+ * `name`. Ethereum and Binance Smart Chain are the only omnibridge entry points;
105
+ * any other chain id has no entry and {@link resolveEntryChainName} returns null.
106
+ */
107
+ const entryChainDisplayName = {
108
+ [Number(Chains.ETH)]: 'Ethereum',
109
+ [Number(Chains.BNB)]: 'Binance Smart Chain',
110
+ };
111
+ // ---------------------------------------------------------------------------
112
+ // Helpers
113
+ // ---------------------------------------------------------------------------
114
+ /**
115
+ * Adds two nullable United States dollar NUMBERS. A null operand contributes
116
+ * nothing; two nulls yield null so an all-unpriced route keeps a null cost rather
117
+ * than a misleading 0.
118
+ *
119
+ * This sums the RAW values and leaves the single cent-rounding to the display
120
+ * adapter (round(a + b)). It deliberately differs from the pre-split
121
+ * `sumFormattedUsd`, which summed two already-cent-rounded strings
122
+ * (round(a) + round(b)); the two can diverge by a cent when both operands carry
123
+ * sub-cent fractions. Rounding the total once is the more accurate result and
124
+ * keeps this helper free of any display formatter, so the raw producers stay
125
+ * relocatable. See the "sums the raw total once" test for the locked behavior.
126
+ *
127
+ * @param first - the first dollar value, or null/undefined when absent
128
+ * @param second - the second dollar value, or null/undefined when absent
129
+ * @returns the sum, or null when both operands are absent
130
+ */
131
+ export const addUsdValues = (first, second) => {
132
+ if (first == null && second == null)
133
+ return null;
134
+ return (first ?? 0) + (second ?? 0);
135
+ };
136
+ /**
137
+ * Subtracts a cost from a dollar value on the SAME basis — the operation every
138
+ * producer uses to derive what a leg delivers from what entered it.
139
+ *
140
+ * Unlike {@link addUsdValues} this is deliberately NOT symmetric about null: an
141
+ * unknown starting value cannot be reduced, so it stays unknown, while an unknown
142
+ * cost is treated as no cost. That asymmetry is what keeps a route with one
143
+ * unpriced leg from reporting a delivered value it never established.
144
+ *
145
+ * @param value - the dollar value entering the leg, or null/undefined when unknown
146
+ * @param cost - the leg's dollar cost, or null/undefined when unpriced
147
+ * @returns the value less the cost, or null when the starting value is unknown
148
+ */
149
+ export const subtractUsdValues = (value, cost) => {
150
+ if (value == null)
151
+ return null;
152
+ return value - (cost ?? 0);
153
+ };
154
+ /**
155
+ * The display name for an omnibridge crossing, accepting a raw `string` rather
156
+ * than {@link Provider}.
157
+ *
158
+ * PREFER {@link omnibridgeProviderDisplayName} INSTEAD wherever the caller
159
+ * already holds a genuine `Provider` — that one is a compile error for
160
+ * anything else. This wide version exists only for callers whose OWN field is
161
+ * typed `string` at its source and so cannot be narrowed here:
162
+ * `RouteCandidate.omnibridgeProvider` and `RouteData.headlineProviderKey`
163
+ * (`routing/candidate.ts`, `routing/route-data.ts`).
164
+ *
165
+ * KEPT DELIBERATELY NON-THROWING, UNLIKE {@link providerById}. The obvious
166
+ * next step — throw on an unrecognized key, the way `providerById` does for
167
+ * an aggregator — was tried and reverted: `toRouteView.ts`'s headline
168
+ * resolution calls this with `RouteData.headlineProviderKey`, a `string` this
169
+ * repository does not otherwise guarantee is a real provider of ANY kind, and
170
+ * an existing, deliberate test (`toRouteView.test.ts`, "headline falls back
171
+ * to 'Omnibridge' for an unknown headline provider key") requires that a
172
+ * genuinely-unrecognized key render a generic label rather than crash the
173
+ * whole route card. A NEAR Intents leg being mislabeled "Omnibridge" was a
174
+ * real defect (see `near-intents-route.ts`), fixed there by calling the
175
+ * RIGHT function (`providerById(id).label`) instead of this one — not by
176
+ * making this one less forgiving of input nobody validates before it arrives.
177
+ *
178
+ * `'PulseX'` IS RECOGNIZED HERE TOO, unlike an aggregator `ProviderId`, and for
179
+ * a different reason than the omnibridge case: it needs no registry lookup —
180
+ * there is exactly one PulseX, the label IS the key — so there is nothing an
181
+ * aggregator-style `providerById(id).label` call would add. A same-chain
182
+ * PulseX swap route (`RouteData.kind === 'swap'`) has NO omnibridge leg at
183
+ * all, so falling back to "Omnibridge" for it would name a bridge that never
184
+ * ran.
185
+ *
186
+ * @param provider - the raw provider key (e.g. `'pulsechain'`, `'PulseX'`)
187
+ * @returns the spelled-out provider name, or "Omnibridge" when unregistered
188
+ */
189
+ export const providerDisplayName = (provider) => {
190
+ if (provider === 'PulseX')
191
+ return 'PulseX';
192
+ return isOmnibridgeProvider(provider) ? omnibridgeProviderDisplayName(provider) : 'Omnibridge';
193
+ };
194
+ /**
195
+ * The fully spelled-out display name of an omnibridge entry chain, or null when
196
+ * the chain id is null or is not one of the two entry points (Ethereum, Binance
197
+ * Smart Chain). A null result makes route builders return null, so an
198
+ * unrecognised destination leaves the strip empty rather than naming a chain it
199
+ * cannot bridge to.
200
+ *
201
+ * @param entryChainId - the decimal entry chain id, or null for direct routes
202
+ * @returns the spelled-out chain name, or null
203
+ */
204
+ export const resolveEntryChainName = (entryChainId) => {
205
+ if (entryChainId === null)
206
+ return null;
207
+ return entryChainDisplayName[entryChainId] ?? null;
208
+ };
209
+ /**
210
+ * Parses one of the aggregator's own United States dollar figures —
211
+ * `fromAmountUsd` or `toAmountUsd` on a candidate's quote.
212
+ *
213
+ * `Number('')` IS `0`, NOT "UNKNOWN", and both the LI.FI and Relay quote
214
+ * parsers can hand this module a blank amount string. Read with a bare
215
+ * `Number()`, a blank `fromAmountUsd` priced a route's origin leg at zero
216
+ * cost — one measured case showed a route reporting a net gain of +$273 that
217
+ * had in fact cost real money, and led the strip on the strength of it — and
218
+ * a blank `toAmountUsd` would do the same to what a direct route (no
219
+ * omnibridge leg) reports as delivered. A dollar figure only means something
220
+ * when it is a positive, finite number: blank text, non-numeric text, zero,
221
+ * and a negative number are all treated as UNPRICED rather than free.
222
+ *
223
+ * @param raw - the provider's amount string, or null/undefined when absent
224
+ * @returns the parsed value, or null when the field cannot be trusted
225
+ */
226
+ export const usdNumber = (raw) => {
227
+ if (raw == null || raw.trim() === '')
228
+ return null;
229
+ const value = Number(raw);
230
+ return Number.isFinite(value) && value > 0 ? value : null;
231
+ };
232
+ /**
233
+ * What the user pays for a candidate's origin leg, in United States dollars, as
234
+ * the provider itself valued it (`fromAmountUsd`). This is the candidate's SOURCE
235
+ * BASIS: every dollar figure the route reports is derived from it, so the costs
236
+ * shown and the value delivered can never drift onto different price sources.
237
+ *
238
+ * @param candidate - the priced route candidate
239
+ * @returns the amount paid in United States dollars, or null when the provider gave none
240
+ */
241
+ /**
242
+ * Exported so `search/plan-route-data.ts` can generalize this SAME valuation
243
+ * to a general-search plan's first hop, per the route-search design's
244
+ * `RouteScoreInputs` doc ("import and reuse them; do not write a second
245
+ * valuation") — a candidate-shaped module boundary, not a second formula.
246
+ */
247
+ export const candidatePaidUsd = (candidate) => usdNumber(candidate.quote.fromAmountUsd);
248
+ /**
249
+ * The cost of a candidate, in United States dollars, as the difference between
250
+ * what the user pays for the aggregator leg ({@link candidatePaidUsd}) and what the
251
+ * delivered tokens are worth. Returns null when the provider gave no
252
+ * `fromAmountUsd` or the delivered tokens are unpriced — the fee then shows only
253
+ * what it can. The figure is clamped at zero because a fleeting price-source
254
+ * skew should never render a negative fee.
255
+ *
256
+ * @param candidate - the priced route candidate
257
+ * @returns the fee in United States dollars, or null when either side is unpriced
258
+ */
259
+ const candidateFeeUsd = (candidate) => {
260
+ const paid = candidatePaidUsd(candidate);
261
+ if (paid === null)
262
+ return null;
263
+ // Charges levied outside `fromAmount` are part of the cost even though the
264
+ // difference between paid and delivered cannot see them.
265
+ const outside = candidateOutsideFeesUsd(candidate);
266
+ const delivered = candidateDeliveredUsd(candidate);
267
+ if (delivered === null)
268
+ return null;
269
+ return Math.max(0, paid + outside - delivered);
270
+ };
271
+ /**
272
+ * What a candidate's delivered tokens are worth, in United States dollars.
273
+ *
274
+ * TWO SOURCES, AND WHICH ONE APPLIES IS DECIDED BY WHETHER A SECOND LEG
275
+ * FOLLOWS — never by which one happens to be present.
276
+ *
277
+ * A route with no omnibridge leg ends where the provider drops the funds, so
278
+ * the provider valued the very tokens the reader receives, in the same answer
279
+ * and on the same feed as `fromAmountUsd`. The difference between the two is
280
+ * then the route's cost and nothing else. This is the case that had no price at
281
+ * all: the destination chain is not one the bridge's own price sources cover,
282
+ * so `destinationUsdPrice` arrived null and every direct route reported no fee.
283
+ *
284
+ * A route that continues through the omnibridge must NOT use it. The provider's
285
+ * delivered side there is the entry chain's tokens, handed to the mediator —
286
+ * not what finally lands on PulseChain, which is what `deliveredAmount` counts.
287
+ * Those two differ by the omnibridge's own fee, so reading the provider's
288
+ * figure would report a crossing as free.
289
+ *
290
+ * Exported because the ranking must value a route on the SAME basis the route
291
+ * states. The ranker used to price the delivered tokens against
292
+ * `destinationUsdPrice` alone, which is null on every direct route, so every
293
+ * direct route was unpriced and the ranker fell back to an order that could not
294
+ * see what each route delivers.
295
+ *
296
+ * @param candidate - the priced route candidate
297
+ * @returns the delivered value in United States dollars, or null when unpriced
298
+ */
299
+ export const candidateDeliveredUsd = (candidate) => {
300
+ if (candidate.omnibridgeProvider === null) {
301
+ const quoted = usdNumber(candidate.quote.toAmountUsd);
302
+ if (quoted !== null)
303
+ return quoted;
304
+ }
305
+ if (candidate.destinationUsdPrice === null)
306
+ return null;
307
+ return usdValueOfAmount({
308
+ balance: candidate.deliveredAmount,
309
+ decimals: candidate.destinationToken.decimals,
310
+ price: candidate.destinationUsdPrice,
311
+ });
312
+ };
313
+ /**
314
+ * The share of the requested amount below which an unspent remainder is treated
315
+ * as dust and not reported, in basis points (hundredths of a percent). Ten basis
316
+ * points is 0.1%.
317
+ *
318
+ * A backward-solved quote lands a handful of base units away from the amount it
319
+ * was asked for even when it consumes essentially all of it — the control case
320
+ * measured eleven wei left over on a five-hundredths-of-an-ether transfer. A
321
+ * remainder that small is rounding, not money the user gets to keep, and naming
322
+ * it would add a line to every route that says nothing.
323
+ */
324
+ const UNSPENT_MATERIAL_BASIS_POINTS = 10n;
325
+ /**
326
+ * The origin tokens a candidate's quote does NOT consume — the difference between
327
+ * what the user typed and what the backward-solved quote turned out to need.
328
+ *
329
+ * Valued from the candidate's OWN source basis (`fromAmountUsd` divided across
330
+ * `fromAmount`) rather than from a separate price feed, for the same reason every
331
+ * other figure on the route is: two feeds disagree, and a remainder priced against
332
+ * a different one would reopen the gap this whole model exists to close.
333
+ *
334
+ * Returns null when the requested amount is unknown, when the quote consumed all
335
+ * of it, or when the remainder is below {@link UNSPENT_MATERIAL_BASIS_POINTS} —
336
+ * a route that spends what it was given says nothing about unspent tokens.
337
+ *
338
+ * @param candidate - the priced route candidate
339
+ * @returns the unspent remainder, or null when there is none worth naming
340
+ */
341
+ const candidateUnspent = (candidate) => {
342
+ const requested = candidate.requestedAmount;
343
+ if (requested == null || requested <= 0n)
344
+ return null;
345
+ const spent = candidate.quote.fromAmount;
346
+ if (spent <= 0n || spent >= requested)
347
+ return null;
348
+ const remainder = requested - spent;
349
+ if ((remainder * BASIS_POINTS_DENOMINATOR) / requested < UNSPENT_MATERIAL_BASIS_POINTS) {
350
+ return null;
351
+ }
352
+ const paidUsd = candidatePaidUsd(candidate);
353
+ // The remainder's value is the paid basis scaled by the ratio of the two
354
+ // amounts, which is exact regardless of the origin token's decimals and needs
355
+ // no per-token price at all.
356
+ const usdValue = paidUsd === null
357
+ ? null
358
+ : (paidUsd * Number(formatUnits(remainder, candidate.originToken.decimals))) /
359
+ Number(formatUnits(spent, candidate.originToken.decimals));
360
+ return {
361
+ amount: remainder,
362
+ decimals: candidate.originToken.decimals,
363
+ symbol: candidate.originToken.symbol,
364
+ usdValue: usdValue !== null && Number.isFinite(usdValue) ? usdValue : null,
365
+ };
366
+ };
367
+ /**
368
+ * A token address shortened for display, for example `0x1234…5678`. Preserves
369
+ * short addresses that are already readable without truncation.
370
+ *
371
+ * @param address - the full token address
372
+ * @returns the shortened address string
373
+ */
374
+ const shortenAddress = (address) => address.length > 11 ? `${address.slice(0, 6)}…${address.slice(-4)}` : address;
375
+ /**
376
+ * The token label for a step endpoint: the provider-supplied SYMBOL when present
377
+ * (for example "USDC"), otherwise a SHORTENED address. Never the raw full address,
378
+ * which is unreadable in the hop conversion line.
379
+ *
380
+ * @param symbol - the token symbol, or null when not supplied by the provider
381
+ * @param address - the token's full on-chain address, used as fallback
382
+ * @returns the label to display for this endpoint
383
+ */
384
+ const stepTokenLabel = (symbol, address) => symbol ?? shortenAddress(address);
385
+ /**
386
+ * The swap conversion label for a normalized aggregator route step — the
387
+ * "from token → to token" string shown in the hop's conversion line. Returns null
388
+ * when the step carries no token addresses (Relay steps are empty; the hop then
389
+ * shows no conversion clause).
390
+ *
391
+ * @param step - one normalized aggregator route step
392
+ * @returns the conversion string, or null when address data is absent
393
+ */
394
+ const stepConversion = (step) => {
395
+ if (!step.fromToken || !step.toToken)
396
+ return null;
397
+ return `${stepTokenLabel(step.fromTokenSymbol, step.fromToken)} → ${stepTokenLabel(step.toTokenSymbol, step.toToken)}`;
398
+ };
399
+ /**
400
+ * A fee rate as percent text: 3e15 reads "0.3%". Exact, because it scales the
401
+ * integer and lets `formatUnits` place the point, rather than going through a
402
+ * floating point number that prints 2.9699999999999998.
403
+ */
404
+ const rateAsPercentText = (rate) => `${formatUnits(rate * 100n, 18)}%`;
405
+ /** A label, with the rate beside it when the rate is known. */
406
+ const labelWithRate = (label, rate) => rate == null ? label : `${label} (${rateAsPercentText(rate)})`;
407
+ /** A dollar value as the cent string a {@link QuoteFee} carries, or null when unpriced. */
408
+ const feeAmountUsdText = (value) => value === null ? null : roundUsdToCents(value).toFixed(2);
409
+ /**
410
+ * The token the bridge takes its own fee in.
411
+ *
412
+ * The fee manager lives on the home mediator, on PulseChain, and the pathway
413
+ * says which side of the crossing that is. Leaving PulseChain it is `'from'`:
414
+ * the home mediator takes the fee from the tokens it receives, before it sends
415
+ * the message. Arriving it is `'to'`: the home mediator takes the fee from the
416
+ * tokens it releases. So the fee is always in the PulseChain side's token.
417
+ *
418
+ * @returns the fee's token, or null when the bridge key names no pathway
419
+ */
420
+ const bridgeFeeToken = ({ bridgeKey, sourceToken, arrivingToken, }) => {
421
+ // Not production-only: the testnet pathways live in a second map, and a
422
+ // mainnet key finds its own pathway in the first map either way.
423
+ const crossing = pathway(bridgeKey, false);
424
+ if (!crossing)
425
+ return null;
426
+ return crossing.feeManager === 'from' ? sourceToken : arrivingToken;
427
+ };
428
+ /**
429
+ * The cost of one omnibridge crossing: the bridge's own fee plus the Gibs
430
+ * delivery fee, both valued on the route's source basis.
431
+ *
432
+ * A PARTIAL SUM IS NEVER A COST. When either fee is unknown the figure is null,
433
+ * because the known half alone would read as the whole cost and understate it.
434
+ * A delivery the reader did not ask for is not unknown: it is a real zero.
435
+ *
436
+ * The delivery fee is in the ARRIVING token: the destination router takes it
437
+ * from the value that reaches it.
438
+ *
439
+ * EVERY AMOUNT IS COUNTED IN SOURCE BASE UNITS, because the settings cascade
440
+ * derives both fees from `amountToBridge`, which is. So both are valued with the
441
+ * source token's decimals and price, the same basis the route's delivered value
442
+ * uses. The bridge keeps a token's decimals when it creates the counterpart, so
443
+ * that count is also the count in the arriving token each charge names.
444
+ *
445
+ * @returns the crossing's cost and the charges behind it
446
+ */
447
+ const crossingCost = ({ bridgeKey, feeRoots, sourceToken, arrivingToken, sourceUsdPrice, }) => {
448
+ const [provider] = bridgeKey;
449
+ const { bridgeFeeAmount, bridgeFeeRate, deliveryFee } = feeRoots;
450
+ const bridgeToken = bridgeFeeToken({ bridgeKey, sourceToken, arrivingToken });
451
+ const bridgeFeeUsdValue = bridgeFeeAmount === null || bridgeToken === null
452
+ ? null
453
+ : usdValueOfAmount({ balance: bridgeFeeAmount, decimals: sourceToken.decimals, price: sourceUsdPrice });
454
+ const bridgeFees = bridgeFeeAmount === null || bridgeToken === null || bridgeFeeAmount === 0n
455
+ ? []
456
+ : [
457
+ {
458
+ kind: 'service',
459
+ label: labelWithRate(`${omnibridgeProviderDisplayName(provider)} fee`, bridgeFeeRate),
460
+ amount: bridgeFeeAmount,
461
+ token: bridgeToken,
462
+ amountUsd: feeAmountUsdText(bridgeFeeUsdValue),
463
+ includedInFromAmount: true,
464
+ },
465
+ ];
466
+ if (deliveryFee.status === 'not-requested') {
467
+ return { feeUsdValue: bridgeFeeUsdValue, fees: bridgeFees };
468
+ }
469
+ const deliveryAmount = deliveryFee.amount;
470
+ const deliveryFeeUsdValue = deliveryAmount === null
471
+ ? null
472
+ : usdValueOfAmount({ balance: deliveryAmount, decimals: sourceToken.decimals, price: sourceUsdPrice });
473
+ const deliveryFees = deliveryAmount === null || deliveryAmount === 0n
474
+ ? []
475
+ : [
476
+ {
477
+ kind: 'service',
478
+ label: labelWithRate('Gibs delivery fee', deliveryFee.rate),
479
+ amount: deliveryAmount,
480
+ token: arrivingToken,
481
+ amountUsd: feeAmountUsdText(deliveryFeeUsdValue),
482
+ includedInFromAmount: true,
483
+ },
484
+ ];
485
+ const feeUsdValue = bridgeFeeUsdValue === null || deliveryFeeUsdValue === null
486
+ ? null
487
+ : bridgeFeeUsdValue + deliveryFeeUsdValue;
488
+ return { feeUsdValue, fees: [...bridgeFees, ...deliveryFees] };
489
+ };
490
+ /** A token as the fee token a {@link QuoteFee} names, on the chain it is presented on. */
491
+ const toFeeToken = (token, chainId) => ({
492
+ symbol: token.symbol,
493
+ decimals: token.decimals,
494
+ chainId,
495
+ });
496
+ /**
497
+ * The raw token reference for one end of a normalized aggregator step — the shape
498
+ * an intermediate stop is rendered from.
499
+ *
500
+ * The provider's own token image wins when present. When it supplies none, the
501
+ * logo is left UNSET for a `0x` address so the adapter derives it from the image
502
+ * service, and set to null for a non-Ethereum address (Bitcoin, Solana, Tron, The
503
+ * Open Network), which the image service cannot key by a checksummed address.
504
+ * Decimals are omitted: an intermediate stop formats no amount.
505
+ *
506
+ * @param endpoint - one end of a normalized step
507
+ * @returns the raw token reference for that end
508
+ */
509
+ const stepTokenRef = (endpoint) => ({
510
+ symbol: endpoint.symbol ?? shortenAddress(endpoint.address),
511
+ address: endpoint.address,
512
+ chainId: endpoint.chainId,
513
+ logoUri: endpoint.logoUri ?? (endpoint.address.startsWith('0x') ? undefined : null),
514
+ chainLogoUri: imageLinks.networkOrPseudo(endpoint.chainId),
515
+ });
516
+ /** The token a step consumes, as a raw reference. */
517
+ const stepFromTokenRef = (step) => stepTokenRef({
518
+ address: step.fromToken,
519
+ symbol: step.fromTokenSymbol,
520
+ chainId: step.fromChainId,
521
+ logoUri: step.fromTokenLogoUri,
522
+ });
523
+ /** The token a step produces, as a raw reference — the stop it lands the funds on. */
524
+ const stepToTokenRef = (step) => stepTokenRef({
525
+ address: step.toToken,
526
+ symbol: step.toTokenSymbol,
527
+ chainId: step.toChainId,
528
+ logoUri: step.toTokenLogoUri,
529
+ });
530
+ /**
531
+ * True when two references name the same token on the same chain. Hex addresses
532
+ * compare case-insensitively (providers disagree on checksum casing); anything
533
+ * else compares exactly, because a base58 address is genuinely case-sensitive.
534
+ */
535
+ const isSameTokenRef = (left, right) => {
536
+ if (left.chainId !== right.chainId)
537
+ return false;
538
+ if (left.address === right.address)
539
+ return true;
540
+ if (!left.address.startsWith('0x') || !right.address.startsWith('0x'))
541
+ return false;
542
+ return left.address.toLowerCase() === right.address.toLowerCase();
543
+ };
544
+ /**
545
+ * The token flow a set of hops walks: the origin, then the token each hop
546
+ * PRODUCES. Every entry except the first is a stop the funds actually reach, and
547
+ * the last hop's output is the destination — so the intermediate stops are exactly
548
+ * the hops minus the last one.
549
+ *
550
+ * A hop that names no output token contributes nothing (the flow still reaches the
551
+ * destination through the hops that do), and a hop that produces the token the
552
+ * flow is already holding is collapsed rather than drawn twice.
553
+ *
554
+ * @param origin - the token the route starts from
555
+ * @param hops - the hops the signature performs, first to last
556
+ * @returns the ordered token flow, origin first and destination last
557
+ */
558
+ const deriveTokenFlow = (origin, hops) => hops.reduce((flow, hop) => {
559
+ const produced = hop.toToken;
560
+ if (!produced)
561
+ return flow;
562
+ const held = flow[flow.length - 1];
563
+ if (held && isSameTokenRef(held, produced))
564
+ return flow;
565
+ return [...flow, produced];
566
+ }, [origin]);
567
+ /**
568
+ * Everything a candidate is charged that sits OUTSIDE `fromAmount`, in United
569
+ * States dollars.
570
+ *
571
+ * THIS REPLACES A PER-STEP GAS SUM that could only ever see part of the picture.
572
+ * That sum read `step.gasUsd`, so it was ZERO for every Relay quote — Relay
573
+ * publishes no per-leg breakdown — and for LI.FI it counted the network gas
574
+ * while missing the larger charge: a LayerZero fee of about 1.37 dollars marked
575
+ * `included: false`, paid on top in native currency. Reading the quote's own
576
+ * normalized fee list catches both, and cannot double-count the per-step gas
577
+ * against the aggregate the provider reports.
578
+ *
579
+ * A charge whose dollar value the provider did not give is skipped rather than
580
+ * guessed. It is still named in the breakdown the reader can open.
581
+ *
582
+ * Exported for the same reason {@link candidatePaidUsd} is — `search/plan-route-data.ts`
583
+ * reuses this exact valuation rather than restating it for a plan's last hop.
584
+ */
585
+ export const candidateOutsideFeesUsd = (candidate) => {
586
+ const fromQuote = candidate.quote.fees.reduce((total, fee) => {
587
+ if (fee.includedInFromAmount) {
588
+ return total;
589
+ }
590
+ const value = Number(fee.amountUsd);
591
+ return Number.isFinite(value) ? total + value : total;
592
+ }, 0);
593
+ // A provider that reports its network cost once, for the whole route, gives a
594
+ // gas row here and that row is the whole of it. One that reports gas only per
595
+ // leg gives no such row, and the per-step figures are then the only record of
596
+ // it — counting them as well when the row IS present would bill the same gas
597
+ // twice, so this is a fallback and never an addition.
598
+ const reportsGasOnce = candidate.quote.fees.some((fee) => !fee.includedInFromAmount && fee.kind === 'gas');
599
+ if (reportsGasOnce) {
600
+ return fromQuote;
601
+ }
602
+ return fromQuote + candidate.quote.steps.reduce((total, step) => total + (step.gasUsd ?? 0), 0);
603
+ };
604
+ /**
605
+ * The user's NET dollar outcome for a candidate: the value delivered minus
606
+ * everything paid to reach it. Three cost components:
607
+ *
608
+ * - the origin tokens spent (`quote.fromAmountUsd`);
609
+ * - every charge paid on top ({@link candidateOutsideFeesUsd}), NOT part of
610
+ * `fromAmount` — the sender's own network gas, and any provider fee the
611
+ * quote marked as charged outside the amount;
612
+ * - the omnibridge CROSSING fee, already netted into `deliveredAmount`.
613
+ *
614
+ * The delivered value comes from {@link candidateDeliveredUsd}, the same figure
615
+ * the route's own dollar value and cost are built from. This is the one ranking
616
+ * rule: the route strip orders by it, so the route the reader sees costing least
617
+ * is the route that leads.
618
+ *
619
+ * @param candidate - the priced route candidate
620
+ * @returns the net outcome in United States dollars, or null when the leg or
621
+ * the delivered tokens are unpriced
622
+ */
623
+ export const candidateNetValueUsd = (candidate) => {
624
+ const paid = candidatePaidUsd(candidate);
625
+ if (paid === null)
626
+ return null;
627
+ const delivered = candidateDeliveredUsd(candidate);
628
+ if (delivered === null)
629
+ return null;
630
+ return delivered - paid - candidateOutsideFeesUsd(candidate);
631
+ };
632
+ /**
633
+ * Builds the RAW, data-only {@link RouteData} for one priced aggregator candidate
634
+ * — the framework-agnostic half of the candidate path, carrying NUMBERS, raw
635
+ * token references, and raw provider keys ONLY. The display wrapper runs this
636
+ * through `toRouteView` to resolve every logo, name, formatted string, and the
637
+ * structural id.
638
+ *
639
+ * The candidate is a single signature (the aggregator swap auto-calls the
640
+ * omnibridge), so it produces exactly one {@link RouteSectionData} whose hops are
641
+ * the aggregator legs the provider reported (Relay reports none) followed by the
642
+ * omnibridge crossing leg. Each hop carries its OWN numeric fee so the user sees
643
+ * where cost accrues.
644
+ *
645
+ * @param candidate - one priced route candidate
646
+ * @returns the raw, data-only route for the candidate
647
+ */
648
+ export const buildCandidateRouteData = (candidate) => {
649
+ const feeUsdValue = candidateFeeUsd(candidate);
650
+ // THE PROVIDER'S OWN FIGURE, IN ITS OWN UNIT, CARRIED THROUGH UNROUNDED.
651
+ //
652
+ // This used to keep only `Math.ceil(seconds / 60)`, and that one expression
653
+ // erased the difference the reader most needed to see. Measured live on Base
654
+ // to Polygon: LI.FI claimed 29 seconds and Relay claimed 3, and both rendered
655
+ // "~1 min". A reader comparing them had no way to tell a route that settles
656
+ // before they look away from one that claims ten times the wait — so the
657
+ // strip handed the choice to a 0.05% difference in delivered tokens, which is
658
+ // nine cents on a hundred-and-twenty-six-dollar transfer.
659
+ //
660
+ // The section therefore carries the seconds and the view picks the unit (see
661
+ // `sectionWaitLabel` in the route strip). `etaMinutes` stays beside it for the
662
+ // route-level field and for every consumer that still reads minutes.
663
+ //
664
+ // It is the provider's CLAIM and nothing more. One live route ran about
665
+ // seventy-five times its own estimate, which is why the strip labels this the
666
+ // provider's estimate rather than presenting it as a fact.
667
+ const etaSeconds = candidate.quote.executionDurationSeconds > 0 ? candidate.quote.executionDurationSeconds : null;
668
+ const etaMinutes = candidate.quote.executionDurationSeconds > 0
669
+ ? Math.ceil(candidate.quote.executionDurationSeconds / 60)
670
+ : null;
671
+ if (feeUsdValue === null) {
672
+ console.warn(`[route-strip] fee unavailable: candidate=${candidate.id}` +
673
+ ` provider=${candidate.quote.provider}` +
674
+ ` entryChainId=${candidate.entryChainId}` +
675
+ ` omnibridgeProvider=${candidate.omnibridgeProvider}` +
676
+ ` quote.fromAmountUsd=${candidate.quote.fromAmountUsd}` +
677
+ // BOTH delivered-value sources, because which one applies depends on
678
+ // whether a second leg follows — naming only one sends whoever reads
679
+ // this to the wrong place. See `candidateDeliveredUsd`.
680
+ ` quote.toAmountUsd=${candidate.quote.toAmountUsd ?? null}` +
681
+ ` destinationUsdPrice=${candidate.destinationUsdPrice}`);
682
+ }
683
+ const { originToken, destinationToken } = candidate;
684
+ // Token logos are pre-resolved here via the image service (which lives in the
685
+ // software development kit, so the producer stays framework-agnostic), passing
686
+ // the FULL token so its own `logoURI` is preferred over the address-derived
687
+ // fallback — matching the pre-split `buildCandidateTokenFlow` exactly.
688
+ const originNode = {
689
+ symbol: originToken.symbol,
690
+ address: originToken.address,
691
+ chainId: originToken.chainId,
692
+ decimals: originToken.decimals,
693
+ // `tokenImageOrChainFallback` — see the identical note on `destinationNode`
694
+ // below; the same "address not resolvable yet" case applies to either end.
695
+ logoUri: imageLinks.tokenImageOrChainFallback(originToken),
696
+ // A pay-by-hand origin (Zcash) carries a local, invented chain id
697
+ // `imageLinks.network` cannot look up by number — see `networkOrPseudo`.
698
+ chainLogoUri: imageLinks.networkOrPseudo(originToken.chainId),
699
+ };
700
+ // Read up front, before the node below, so the node itself never claims
701
+ // PulseChain for a candidate that does not enter the omnibridge. A candidate
702
+ // whose omnibridge leg is absent (NEAR Intents, or any aggregator delivering
703
+ // straight to its own destination chain) lands on `destinationToken.chainId`
704
+ // — Base, in the case that pinned this — and a node hardcoded to PulseChain
705
+ // named the wrong chain for every stop that reused it, including the
706
+ // token-flow fallback below.
707
+ const omnibridgeProviderKey = candidate.omnibridgeProvider;
708
+ // THE CHAIN THIS SIGNATURE ACTUALLY ENDS ON. The label was the constant
709
+ // 'Bridge to PulseChain', true of every route that existed when it was
710
+ // written. A route the omnibridge does not carry ends somewhere else
711
+ // entirely, and titled that way the tracker named a chain the funds never
712
+ // touch — over a transfer to Base that had already landed.
713
+ const landingChainId = omnibridgeProviderKey === null ? destinationToken.chainId : pulsechainChainId;
714
+ const destinationNode = {
715
+ symbol: destinationToken.symbol,
716
+ address: destinationToken.address,
717
+ chainId: landingChainId,
718
+ decimals: destinationToken.decimals,
719
+ // `tokenImageOrChainFallback`, NOT `image` — a not-yet-bridged token's
720
+ // address can still be unresolved when this producer draws the DELIVERED
721
+ // node (see the repository's own "Not-Yet-Bridged Tokens" gotcha), and
722
+ // `image` either throws on it or silently draws nothing. See that
723
+ // helper's own note.
724
+ logoUri: imageLinks.tokenImageOrChainFallback(destinationToken),
725
+ // `networkOrPseudo`, matching `originNode` — a destination is not known to
726
+ // ever be a deposit-address pseudo chain today, but the badge helper is the
727
+ // one every other node in this builder goes through, and there is no reason
728
+ // this one should be the exception that 404s if that ever changes.
729
+ chainLogoUri: imageLinks.networkOrPseudo(landingChainId),
730
+ };
731
+ const aggregatorHops = candidate.quote.steps.map((step, index) => ({
732
+ providerName: step.label,
733
+ // The aggregator step carries its tool's real logo url straight from the quote
734
+ // — raw data, not a derived view concern, so it rides on the raw hop.
735
+ providerLogoUri: step.providerLogoUri,
736
+ title: step.label,
737
+ conversion: stepConversion(step),
738
+ // The tokens this leg moves between, structurally — the section's token flow is
739
+ // derived from these, so every stop the funds make is one the provider named.
740
+ fromToken: stepFromTokenRef(step),
741
+ toToken: stepToTokenRef(step),
742
+ feeUsdValue: step.feeUsd,
743
+ // The first hop is the user's single signature; later hops run inside it.
744
+ automatic: index > 0,
745
+ }));
746
+ // NO BRIDGE LEG MEANS NO BRIDGE HOP. A candidate the omnibridge does not
747
+ // carry ends where the aggregator leaves it, so the crossing hop below simply
748
+ // does not exist — appending one would draw a step nobody performs and name a
749
+ // bridge that is not involved.
750
+ const omnibridgeHop = omnibridgeProviderKey === null
751
+ ? null
752
+ : {
753
+ providerName: providerDisplayName(omnibridgeProviderKey),
754
+ providerKey: omnibridgeProviderKey,
755
+ title: 'Bridge to PulseChain',
756
+ conversion: `→ ${candidate.destinationToken.symbol}`,
757
+ // The crossing consumes whatever the last aggregator leg produced — the anchor
758
+ // token on the entry chain — or the origin token when there were no legs.
759
+ fromToken: aggregatorHops[aggregatorHops.length - 1]?.toToken ?? originNode,
760
+ toToken: destinationNode,
761
+ feeUsdValue: usdValueOfAmount({
762
+ balance: candidate.omnibridgeFeeAmount,
763
+ decimals: candidate.destinationToken.decimals,
764
+ price: candidate.destinationUsdPrice,
765
+ }),
766
+ // The omnibridge call is always auto-called by the aggregator leg before it.
767
+ automatic: aggregatorHops.length > 0,
768
+ };
769
+ const hops = omnibridgeHop === null ? aggregatorHops : [...aggregatorHops, omnibridgeHop];
770
+ // Origin, then every stop the funds reach on the way — NOT a collapsed pair. The
771
+ // last hop is the crossing, so the flow still ends on the destination, EXCEPT
772
+ // when the provider itemised no legs at all (Relay and NEAR Intents both report
773
+ // zero `quote.steps`) and there is no omnibridge hop to supply one either — a
774
+ // candidate delivering straight to its own destination chain. `deriveTokenFlow`
775
+ // then has nothing to reduce over and stops at the origin, which is the exact
776
+ // defect that showed a NEAR Intents route to Base USD Coin as "At ETH on
777
+ // Ethereum": the pill drew the one-and-only flow node it was given, and that
778
+ // node had never left the origin. The route's own `destinationToken` is known
779
+ // regardless of what got itemised, so it is appended here whenever the flow
780
+ // does not already end on it — deduplicated, so an itemised leg that DID reach
781
+ // the destination is never drawn twice.
782
+ const rawTokenFlow = deriveTokenFlow(originNode, hops);
783
+ const lastStop = rawTokenFlow[rawTokenFlow.length - 1];
784
+ const tokenFlow = lastStop && isSameTokenRef(lastStop, destinationNode) ? rawTokenFlow : [...rawTokenFlow, destinationNode];
785
+ const section = {
786
+ // The section id stays a stable string key derived from the candidate id —
787
+ // section ids are within-route keys and do not need to be structural hashes.
788
+ id: `${candidate.id}-signature-1`,
789
+ label: `Bridge to ${chainDisplayName(landingChainId)}`,
790
+ // The signature is executed by the cross-chain AGGREGATOR (LI.FI / Relay) that
791
+ // carries the bridge leg — that is the provider to surface on this section, not
792
+ // the omnibridge headline.
793
+ providerKey: candidate.quote.provider,
794
+ tokenFlow,
795
+ // What this signature leaves the user holding on PulseChain. Valued on the
796
+ // same source basis as the route's output so the step's landing figure and
797
+ // the route's delivered figure are the same number, not two derivations of it.
798
+ landing: {
799
+ amount: candidate.deliveredAmount,
800
+ decimals: candidate.destinationToken.decimals,
801
+ symbol: candidate.destinationToken.symbol,
802
+ usdValue: subtractUsdValues(candidatePaidUsd(candidate), feeUsdValue),
803
+ },
804
+ unspent: candidateUnspent(candidate),
805
+ feeUsdValue,
806
+ // The itemization behind `feeUsdValue`, plus the charges that difference
807
+ // cannot see: anything the provider levies outside `fromAmount`.
808
+ fees: candidate.quote.fees,
809
+ etaMinutes,
810
+ etaSeconds,
811
+ hops,
812
+ };
813
+ return {
814
+ kind: 'aggregated',
815
+ // The entry chain this aggregator route crosses through (Ethereum or Binance
816
+ // Smart Chain), carried explicitly so Bridge.tsx can link the selected route
817
+ // back to its quoted entry candidate without string-parsing the id.
818
+ entryChainId: candidate.entryChainId,
819
+ // The route's headline provider is the omnibridge it crosses on — or, when
820
+ // it crosses none, the aggregator that carries the whole journey. A route
821
+ // has to be named after something that actually performs it.
822
+ headlineProviderKey: omnibridgeProviderKey ?? candidate.quote.provider,
823
+ sections: [section],
824
+ output: {
825
+ amount: candidate.deliveredAmount,
826
+ decimals: candidate.destinationToken.decimals,
827
+ symbol: candidate.destinationToken.symbol,
828
+ // Valued on the SOURCE basis — what was paid, less this route's cost — NOT by
829
+ // re-pricing the delivered token against its own feed. The two differ whenever
830
+ // the destination asset trades away from its origin-chain counterpart, and that
831
+ // difference used to land silently in this figure: the page then stated the
832
+ // route's cost twice and the two never agreed. Deriving it keeps the fee clamp
833
+ // above from opening a gap too. Falls back to the destination feed only when
834
+ // the provider gave no `fromAmountUsd`, so an unpriced leg still shows what it can.
835
+ usdValue: subtractUsdValues(candidatePaidUsd(candidate), feeUsdValue) ??
836
+ usdValueOfAmount({
837
+ balance: candidate.deliveredAmount,
838
+ decimals: candidate.destinationToken.decimals,
839
+ price: candidate.destinationUsdPrice,
840
+ }),
841
+ },
842
+ // The aggregate cost is the single section's spread (this route is one
843
+ // signature), so it equals the section fee; null when the leg is unpriced.
844
+ totalCostUsdValue: feeUsdValue,
845
+ // The net dollar outcome this route is ranked on (delivered value minus origin
846
+ // tokens paid, network gas, and the omnibridge crossing fee).
847
+ netValueUsd: candidateNetValueUsd(candidate),
848
+ etaMinutes,
849
+ };
850
+ };
851
+ // ---------------------------------------------------------------------------
852
+ // Producer: direct route
853
+ // ---------------------------------------------------------------------------
854
+ /**
855
+ * Builds the ALWAYS-PRESENT direct omnibridge route from the current outbound
856
+ * settings, so the strip is never empty for a resolvable pathway.
857
+ *
858
+ * The direct route is a SINGLE SIGNATURE — the user signs the omnibridge call
859
+ * once — so it is exactly ONE {@link RouteSectionData}. Returns null only when
860
+ * the pathway is NOT resolvable (a missing input or output asset).
861
+ *
862
+ * @param roots - the resolved settings + price + logo roots (see {@link DirectRouteRoots})
863
+ * @returns the single direct {@link RouteData}, or null when unresolvable
864
+ */
865
+ export const buildDirectRouteData = (roots) => {
866
+ const { bridgeKey, assetIn, assetOut, estimatedAmountOut, assetInUsdPrice, assetOutUsdPrice, assetInLogoUri, assetOutLogoUri, fromChainLogoUri, toChainLogoUri, advanced, } = roots;
867
+ if (!assetIn || !assetOut)
868
+ return null;
869
+ const [provider, fromChain, toChain] = bridgeKey;
870
+ // The bridge's own fee plus the delivery fee, valued on the source basis; null
871
+ // when either is unknown or the input asset is unpriced.
872
+ const { feeUsdValue, fees } = crossingCost({
873
+ bridgeKey,
874
+ feeRoots: roots,
875
+ sourceToken: toFeeToken(assetIn, Number(fromChain)),
876
+ arrivingToken: toFeeToken(assetOut, Number(toChain)),
877
+ sourceUsdPrice: assetInUsdPrice,
878
+ });
879
+ const hop = {
880
+ providerName: omnibridgeProviderDisplayName(provider),
881
+ // The direct route's provider is the omnibridge it crosses on; the adapter
882
+ // resolves its logo from this key.
883
+ providerKey: provider,
884
+ title: omnibridgeProviderDisplayName(provider),
885
+ conversion: `${assetIn.symbol} → ${assetOut.symbol}`,
886
+ feeUsdValue,
887
+ // Delivery auto-calls the completion on the PulseChain side, so the user's
888
+ // single signature covers the whole leg.
889
+ automatic: true,
890
+ };
891
+ const section = {
892
+ id: `direct-${provider}-${fromChain}-signature-1`,
893
+ // NAMED FROM `toChain`, NOT HARD-CODED. A direct crossing runs both ways —
894
+ // a foreign chain into PulseChain, or PulseChain out to a foreign chain —
895
+ // and a literal "Bridge to PulseChain" on every direct route named the
896
+ // wrong destination on every outbound crossing: the label read "Bridge to
897
+ // PulseChain" while the arrival stage it produces (derived correctly, from
898
+ // the section's own last token-flow node — see `tracker/trackerView.ts`'s
899
+ // `sectionArrivalStage`) correctly read "Arrive on Ethereum" for the exact
900
+ // same signature. One route, two destinations stated for it.
901
+ label: `Bridge to ${chainDisplayName(Number(toChain))}`,
902
+ // The direct section carries NO section provider (matching the pre-split
903
+ // builder): the route strip falls back to the hop's provider for the badge,
904
+ // and the structural id stays identical.
905
+ providerKey: null,
906
+ tokenFlow: [
907
+ {
908
+ symbol: assetIn.symbol,
909
+ address: assetIn.address,
910
+ chainId: Number(fromChain),
911
+ decimals: assetIn.decimals,
912
+ logoUri: assetInLogoUri,
913
+ chainLogoUri: fromChainLogoUri,
914
+ },
915
+ {
916
+ symbol: assetOut.symbol,
917
+ address: assetOut.address,
918
+ chainId: Number(toChain),
919
+ decimals: assetOut.decimals,
920
+ logoUri: assetOutLogoUri,
921
+ chainLogoUri: toChainLogoUri,
922
+ },
923
+ ],
924
+ // The direct crossing IS the whole route, so what this step lands in is what
925
+ // the route delivers — priced on the source basis for the same reason the
926
+ // output is (see the output note below).
927
+ landing: {
928
+ amount: estimatedAmountOut,
929
+ decimals: assetOut.decimals,
930
+ symbol: assetOut.symbol,
931
+ usdValue: usdValueOfAmount({
932
+ balance: estimatedAmountOut,
933
+ decimals: assetOut.decimals,
934
+ price: assetInUsdPrice ?? assetOutUsdPrice ?? null,
935
+ }),
936
+ },
937
+ feeUsdValue,
938
+ // Who takes what: the bridge, then the delivery service.
939
+ fees,
940
+ // The direct crossing waits on the SAME omnibridge settlement as the
941
+ // outbound route's own "Bridge to {entry chain}" section
942
+ // ({@link buildOutboundRouteData}), which quotes this identical constant for
943
+ // the identical wait. Leaving it null here was the one crossing section that
944
+ // never got it, so the strip and the tracker both had no duration to show for
945
+ // the most common route in the product.
946
+ etaMinutes: BRIDGE_SETTLEMENT_ETA_MINUTES,
947
+ hops: [hop],
948
+ advanced,
949
+ };
950
+ return {
951
+ kind: 'direct',
952
+ // The direct omnibridge route IS the crossing — it does not pivot through a
953
+ // distinct aggregator entry chain — so `entryChainId` is null.
954
+ entryChainId: null,
955
+ headlineProviderKey: provider,
956
+ sections: [section],
957
+ output: {
958
+ amount: estimatedAmountOut,
959
+ decimals: assetOut.decimals,
960
+ symbol: assetOut.symbol,
961
+ // Priced with the SOURCE asset's price, deliberately, even though the amount is
962
+ // counted in the destination token's units. A direct crossing hands back the
963
+ // same asset on another chain, and the bridged counterpart routinely trades at
964
+ // a discount to it — pricing the two sides from their own feeds made a
965
+ // zero-fee crossing report a loss (100 USDC in, 100 USDC out, "$100.03" paid
966
+ // and "$99.42" received). The delivered COUNT still comes from the destination
967
+ // token's decimals; only the basis is shared. The destination's own price
968
+ // remains the fallback so a route whose SOURCE side is unpriced still shows a
969
+ // value rather than nothing.
970
+ usdValue: usdValueOfAmount({
971
+ balance: estimatedAmountOut,
972
+ decimals: assetOut.decimals,
973
+ price: assetInUsdPrice ?? assetOutUsdPrice ?? null,
974
+ }),
975
+ },
976
+ // The direct route is a single signature, so its aggregate cost is that one
977
+ // section's fee: the bridge's own fee plus delivery. Null when either is unknown.
978
+ totalCostUsdValue: feeUsdValue,
979
+ // THE SAME BASIS AGGREGATED ROUTES ARE RANKED ON: delivered value less what
980
+ // was paid. The delivered amount is what is sent less both fees, and both
981
+ // sides are priced on the source token, so the difference is exactly the
982
+ // negated cost. It leaves out the sender's own network gas, which this
983
+ // route does not know; an aggregated route counts it when its quote does.
984
+ netValueUsd: feeUsdValue === null ? null : -feeUsdValue,
985
+ // The direct route is the only option for now, so it is always the best.
986
+ isBest: true,
987
+ deltaPercent: null,
988
+ };
989
+ };
990
+ /**
991
+ * Builds the TWO-SIGNATURE OUTBOUND raw {@link RouteData} from PulseChain, across
992
+ * an omnibridge entry chain (Ethereum or Binance Smart Chain), to wherever the
993
+ * second signature lands.
994
+ *
995
+ * The route is exactly TWO {@link RouteSectionData}s:
996
+ * - SECTION 1 ("Bridge to {entry chain}") — the omnibridge out.
997
+ * - SECTION 2 ("Complete at {final chain}") — DEFERRED. Signed only after section
998
+ * 1's crossing settles, and carrying no binding quote until then.
999
+ *
1000
+ * SECTION 2 HAS TWO SHAPES, and `roots.onward` is what picks between them.
1001
+ * Without it the deferred leg is a swap that stays on the entry chain: the
1002
+ * existing behaviour, down to its approximate ~0.29% PulseX fee. With it the
1003
+ * deferred leg is an onward BRIDGE to `onward.chainId`, carried by the named
1004
+ * provider, and it states NO fee — see the fee note in the body for why quoting
1005
+ * the swap constant there would be a fabricated number.
1006
+ *
1007
+ * WHY THE FIRST LEG CANNOT WIDEN THE SAME WAY. The bridged message carries
1008
+ * `(token, receiver, value, data)` and no chain id, and a mediator's counterpart
1009
+ * is wired in at deploy time, so the omnibridge's destination is structural
1010
+ * rather than a parameter. The reach comes from composing a second signature on
1011
+ * the far side, which is what this builder draws.
1012
+ *
1013
+ * Returns null when `assetIn`, `intermediateToken`, or `finalToken` is missing,
1014
+ * when the crossing's destination is not one of the two omnibridge entry chains,
1015
+ * or when `onward.chainId` disagrees with the final token's own `chainId`.
1016
+ *
1017
+ * @param roots - the resolved settings + price + logo roots (see {@link OutboundRouteRoots})
1018
+ * @returns the two-section raw outbound {@link RouteData}, or null when unresolvable
1019
+ */
1020
+ export const buildOutboundRouteData = (roots) => {
1021
+ const { bridgeKey, assetIn, intermediateToken, finalToken, estimatedAmountOut, assetInUsdPrice, intermediateUsdPrice, assetInLogoUri, assetOutLogoUri, finalTokenLogoUri, fromChainLogoUri, intermediateChainLogoUri, onward = null, } = roots;
1022
+ if (!assetIn || !intermediateToken || !finalToken)
1023
+ return null;
1024
+ const [provider, fromChain, toChain] = bridgeKey;
1025
+ const entryChainId = Number(toChain);
1026
+ const entryChainName = resolveEntryChainName(entryChainId);
1027
+ if (entryChainName === null)
1028
+ return null;
1029
+ // A TOKEN DOES NOT CARRY A CHAIN, so the caller states the onward chain and the
1030
+ // token states its own, and the two must agree. Reading one from the other is
1031
+ // the cross-chain address mistake this codebase has already paid for: an origin
1032
+ // address asked for on the destination chain fails silently, and succeeds by
1033
+ // coincidence when some unrelated contract answers at the same address.
1034
+ // Disagreement here means one of the two is wrong and there is no way to tell
1035
+ // which, so the route is not drawn at all.
1036
+ if (onward !== null && onward.chainId !== finalToken.chainId)
1037
+ return null;
1038
+ // Where the SECOND signature leaves the user. Same as the crossing's landing
1039
+ // when the deferred leg is a swap; the onward chain when it is a bridge.
1040
+ const finalChainId = onward === null ? entryChainId : onward.chainId;
1041
+ const finalChainName = onward === null ? entryChainName : onward.chainName;
1042
+ // SECTION 1 fee: the crossing's bridge fee plus its delivery fee, priced in the
1043
+ // source token. Null when either is unknown.
1044
+ const { feeUsdValue: sectionOneFeeValue, fees: sectionOneFees } = crossingCost({
1045
+ bridgeKey,
1046
+ feeRoots: roots,
1047
+ sourceToken: toFeeToken(assetIn, Number(fromChain)),
1048
+ arrivingToken: toFeeToken(intermediateToken, entryChainId),
1049
+ sourceUsdPrice: assetInUsdPrice,
1050
+ });
1051
+ // SECTION 2's swap fee is APPROXIMATE: PulseX takes ~0.29% of the swapped value,
1052
+ // applied here to the bridged intermediate amount.
1053
+ //
1054
+ // AN ONWARD HOP GETS NO FIGURE AT ALL, and that is the honest answer rather
1055
+ // than a gap. `PULSEX_SWAP_FEE_BASIS_POINTS` prices a PulseX swap; an
1056
+ // aggregator bridge to another chain charges its own spread plus that chain's
1057
+ // gas, and neither is 0.29%. Reusing the constant would print a number that
1058
+ // looks measured and is not, beside section 1's fee, which really is measured.
1059
+ // The aggregator quotes the leg when the user returns to sign it, and until
1060
+ // then null is what we know.
1061
+ const sectionTwoFeeValue = onward !== null
1062
+ ? null
1063
+ : usdValueOfAmount({
1064
+ balance: (estimatedAmountOut * PULSEX_SWAP_FEE_BASIS_POINTS) / BASIS_POINTS_DENOMINATOR,
1065
+ decimals: intermediateToken.decimals,
1066
+ price: intermediateUsdPrice,
1067
+ });
1068
+ const bridgeHop = {
1069
+ providerName: omnibridgeProviderDisplayName(provider),
1070
+ providerKey: provider,
1071
+ title: omnibridgeProviderDisplayName(provider),
1072
+ conversion: `${assetIn.symbol} → ${intermediateToken.symbol}`,
1073
+ feeUsdValue: sectionOneFeeValue,
1074
+ // Delivery auto-completes on the entry chain, so the single signature covers
1075
+ // the whole omnibridge leg.
1076
+ automatic: true,
1077
+ };
1078
+ const sectionOne = {
1079
+ id: `outbound-${provider}-${fromChain}-${toChain}-signature-1`,
1080
+ label: `Bridge to ${entryChainName}`,
1081
+ tokenFlow: [
1082
+ {
1083
+ symbol: assetIn.symbol,
1084
+ address: assetIn.address,
1085
+ decimals: assetIn.decimals,
1086
+ chainId: Number(fromChain),
1087
+ logoUri: assetInLogoUri,
1088
+ chainLogoUri: fromChainLogoUri,
1089
+ },
1090
+ {
1091
+ symbol: intermediateToken.symbol,
1092
+ address: intermediateToken.address,
1093
+ decimals: intermediateToken.decimals,
1094
+ chainId: entryChainId,
1095
+ logoUri: assetOutLogoUri,
1096
+ chainLogoUri: intermediateChainLogoUri,
1097
+ },
1098
+ ],
1099
+ // The crossing lands the user in the INTERMEDIATE token on the entry chain —
1100
+ // the whole point of naming a step's landing, since this is exactly the stop
1101
+ // a two-signature route leaves you sitting at while you wait to sign again.
1102
+ landing: {
1103
+ amount: estimatedAmountOut,
1104
+ decimals: intermediateToken.decimals,
1105
+ symbol: intermediateToken.symbol,
1106
+ usdValue: usdValueOfAmount({
1107
+ balance: estimatedAmountOut,
1108
+ decimals: intermediateToken.decimals,
1109
+ price: intermediateUsdPrice,
1110
+ }),
1111
+ },
1112
+ feeUsdValue: sectionOneFeeValue,
1113
+ // Who takes what on the crossing: the bridge, then the delivery service.
1114
+ fees: sectionOneFees,
1115
+ // The omnibridge crossing out is the ~20-minute wait the deferred swap sits
1116
+ // behind, so the wait is shown HERE, on the crossing leg.
1117
+ etaMinutes: BRIDGE_SETTLEMENT_ETA_MINUTES,
1118
+ hops: [bridgeHop],
1119
+ };
1120
+ // SECTION 2 — DEFERRED. Signed only after section 1 settles. A swap on the entry
1121
+ // chain (approximate ~0.29% fee), or an onward bridge carried by a named
1122
+ // aggregator (no fee stated until it is quoted).
1123
+ const deferredHop = onward === null
1124
+ ? {
1125
+ providerName: 'Swap',
1126
+ title: `Swap to ${finalToken.symbol}`,
1127
+ conversion: `${intermediateToken.symbol} → ${finalToken.symbol}`,
1128
+ feeUsdValue: sectionTwoFeeValue,
1129
+ automatic: false,
1130
+ }
1131
+ : {
1132
+ // THE SERVICE THAT WILL ACTUALLY CARRY IT. "Swap" names PulseX, and a
1133
+ // reader who saw it beside a Base destination would be told the wrong
1134
+ // thing about who holds their funds for the second leg.
1135
+ providerName: onward.providerName,
1136
+ title: `Bridge to ${onward.chainName}`,
1137
+ conversion: `${intermediateToken.symbol} → ${finalToken.symbol}`,
1138
+ feeUsdValue: null,
1139
+ automatic: false,
1140
+ };
1141
+ const sectionTwo = {
1142
+ id: `outbound-${provider}-${fromChain}-${toChain}-signature-2`,
1143
+ label: `Complete at ${finalChainName}`,
1144
+ tokenFlow: [
1145
+ {
1146
+ symbol: intermediateToken.symbol,
1147
+ address: intermediateToken.address,
1148
+ decimals: intermediateToken.decimals,
1149
+ chainId: entryChainId,
1150
+ logoUri: assetOutLogoUri,
1151
+ chainLogoUri: intermediateChainLogoUri,
1152
+ },
1153
+ {
1154
+ symbol: finalToken.symbol,
1155
+ address: finalToken.address,
1156
+ decimals: finalToken.decimals,
1157
+ // THE LINE THIS WHOLE CHANGE IS ABOUT. Pinned to `entryChainId`, the
1158
+ // deferred leg could only ever be a swap where the crossing landed, and
1159
+ // the route strip drew a Base token wearing Ethereum's badge.
1160
+ chainId: finalChainId,
1161
+ logoUri: finalTokenLogoUri,
1162
+ chainLogoUri: onward === null ? intermediateChainLogoUri : onward.chainLogoUri,
1163
+ },
1164
+ ],
1165
+ // The deferred swap names the token it will land in but NOT an amount: it is
1166
+ // quoted only when the user returns, and stating a figure now would present an
1167
+ // estimate with the same weight as the crossing's real one.
1168
+ landing: {
1169
+ amount: null,
1170
+ decimals: finalToken.decimals,
1171
+ symbol: finalToken.symbol,
1172
+ usdValue: null,
1173
+ },
1174
+ feeUsdValue: sectionTwoFeeValue,
1175
+ // The swap itself is near-instant (~10 seconds); the ~20-minute wait it sits
1176
+ // behind is shown on section 1 (the crossing).
1177
+ //
1178
+ // AN ONWARD HOP STATES NO ESTIMATE, for the same reason it states no fee. Ten
1179
+ // seconds is how long a PulseX swap takes. How long an aggregator takes to
1180
+ // carry value to another chain depends on the pair and on the route it picks,
1181
+ // so the figure would be borrowed from a different operation.
1182
+ ...(onward === null ? { etaSeconds: PULSEX_SWAP_ETA_SECONDS } : {}),
1183
+ deferred: true,
1184
+ deferredNote: onward === null
1185
+ ? `Fee is approximate — quoted at ${entryChainName} when you return.`
1186
+ : `Quoted by ${onward.providerName} at ${entryChainName} when you return.`,
1187
+ hops: [deferredHop],
1188
+ };
1189
+ return {
1190
+ // 'direct' EVEN WHEN THE SECOND LEG IS AN AGGREGATOR, and this is a decision
1191
+ // rather than a leftover. `kind` is what the interface dispatches EXECUTION
1192
+ // on: `executesViaConsolidated` sends a 'direct' route through the omnibridge
1193
+ // sender and everything else through the aggregator sender. The only
1194
+ // signature this route asks for now is section 1 — a plain omnibridge
1195
+ // crossing out of PulseChain — so the omnibridge sender is the correct one.
1196
+ // Calling it 'aggregated' would hand the button to a sender holding no quote
1197
+ // for it, because the onward leg is not quoted until the user returns and
1198
+ // starts a fresh route from the entry chain.
1199
+ kind: 'direct',
1200
+ entryChainId: null,
1201
+ // The route is shown crossing VIA the chain it bridges to. Already correct
1202
+ // for an onward hop: PulseChain to Base really does travel via Ethereum.
1203
+ viaChainId: entryChainId,
1204
+ headlineProviderKey: provider,
1205
+ sections: [sectionOne, sectionTwo],
1206
+ output: {
1207
+ // The final amount is quoted on return (deferred leg) — unknown at display time.
1208
+ amount: null,
1209
+ decimals: finalToken.decimals,
1210
+ symbol: finalToken.symbol,
1211
+ usdValue: null,
1212
+ },
1213
+ // Aggregate cost is section 1's crossing fees plus section 2's APPROXIMATE swap
1214
+ // fee (~0.29%). Section 2 drops out when it states no figure (an onward hop
1215
+ // is quoted only on return). Section 1 cannot: it is the signature the reader
1216
+ // makes now, so without its cost there is no total, only the swap's guess.
1217
+ totalCostUsdValue: sectionOneFeeValue === null ? null : addUsdValues(sectionOneFeeValue, sectionTwoFeeValue),
1218
+ isBest: true,
1219
+ deltaPercent: null,
1220
+ };
1221
+ };
1222
+ /**
1223
+ * Builds a single composable raw {@link RouteSectionData} for a swap on PulseX,
1224
+ * the decentralized exchange on PulseChain. A composing route appends this to its
1225
+ * `sections` array. Both tokens live on PulseChain (chain id 369), so both
1226
+ * token-flow nodes carry the PulseChain chain badge.
1227
+ *
1228
+ * The section carries raw numeric fields (`feePercent`, `priceImpactPercent`,
1229
+ * `feeUsdValue`); the adapter (`toRouteView`) formats them into the display
1230
+ * breakdown string.
1231
+ *
1232
+ * @param roots - the section's identity, tokens, pricing, and logos
1233
+ * @returns one raw PulseX swap {@link RouteSectionData}
1234
+ */
1235
+ export const buildPulseXSwapSectionData = (roots) => {
1236
+ const { id, fromToken, toToken, feeUsdValue = null, priceImpactPercent = null, landedAmount = null, landedUsdValue = null, etaMinutes = null, etaSeconds = null, fromTokenLogoUri, toTokenLogoUri, pulsechainChainLogoUri, deferred = false, advanced = null, } = roots;
1237
+ const swapHop = {
1238
+ providerName: 'PulseX',
1239
+ // Resolve the PulseX logo via the provider key in the adapter; the raw hop
1240
+ // carries only the key, not the url.
1241
+ providerKey: 'PulseX',
1242
+ title: `Swap to ${toToken.symbol}`,
1243
+ conversion: `${fromToken.symbol} → ${toToken.symbol}`,
1244
+ feeUsdValue,
1245
+ // A standalone PulseX swap is the user's own signature, not an automatic hop.
1246
+ automatic: false,
1247
+ };
1248
+ const tokenFlow = [
1249
+ { symbol: fromToken.symbol, address: fromToken.address, chainId: pulsechainChainId, decimals: fromToken.decimals, logoUri: fromTokenLogoUri, chainLogoUri: pulsechainChainLogoUri },
1250
+ { symbol: toToken.symbol, address: toToken.address, chainId: pulsechainChainId, decimals: toToken.decimals, logoUri: toTokenLogoUri, chainLogoUri: pulsechainChainLogoUri },
1251
+ ];
1252
+ // The swap section always shows the flat PulseX fee in its breakdown; the price
1253
+ // impact clause is added by the adapter only when the impact is present and
1254
+ // non-trivial. Round the impact to two decimals and drop it when it is <= 0.
1255
+ const roundedImpact = priceImpactPercent === null || Number(priceImpactPercent.toFixed(2)) <= 0
1256
+ ? null
1257
+ : Number(priceImpactPercent.toFixed(2));
1258
+ return {
1259
+ id,
1260
+ label: 'Swap on PulseX',
1261
+ providerKey: 'PulseX',
1262
+ tokenFlow,
1263
+ // The swap is the last step of whatever composes it, so what it lands in is
1264
+ // what the user ends up holding.
1265
+ landing: {
1266
+ amount: landedAmount,
1267
+ decimals: toToken.decimals,
1268
+ symbol: toToken.symbol,
1269
+ usdValue: landedUsdValue,
1270
+ },
1271
+ feeUsdValue,
1272
+ // 0.29 — the flat PulseX fee percentage, kept in lockstep with the basis-point
1273
+ // constant so the breakdown line tracks the rate the estimate applies.
1274
+ feePercent: Number(PULSEX_SWAP_FEE_BASIS_POINTS) / 100,
1275
+ priceImpactPercent: roundedImpact,
1276
+ etaMinutes,
1277
+ etaSeconds,
1278
+ hops: [swapHop],
1279
+ // A PulseX swap is normally an immediate signature; deferred only when it sits
1280
+ // behind a prior section's crossing.
1281
+ deferred: deferred ? true : undefined,
1282
+ // NO `bridgeFrom`/`bridgeTo` — a same-chain swap crosses no bridge, and
1283
+ // leaving them unset (rather than null) keeps the disclosure from naming a
1284
+ // mediator that never runs. `destinationRouter` is the PulseX router
1285
+ // itself here, not a delivery leg inside a crossing — see the field's own
1286
+ // note on `RouteSectionData`.
1287
+ advanced: advanced === null ? undefined : advanced,
1288
+ };
1289
+ };
1290
+ /**
1291
+ * Composes a raw {@link RouteData} for the conversion route: bridge a liquid anchor
1292
+ * token onto PulseChain, then a DEFERRED PulseX swap to the wanted token.
1293
+ *
1294
+ * Operates entirely at the raw data level — no display formatting. The caller runs
1295
+ * the result through `toRouteView` to get the display-ready `RouteOption`.
1296
+ *
1297
+ * Per the owner's two locked decisions:
1298
+ *
1299
+ * - **LOCKED DECISION 1 — normalise via the liquid anchor.** When the omnibridge
1300
+ * cannot deliver the wanted token directly (the wanted token has not yet been
1301
+ * bridged, or its canonical foreign-chain pair is illiquid), the rails bridge a
1302
+ * well-known liquid anchor token instead and a PulseX swap leg converts it on
1303
+ * PulseChain. The {@link ConversionRouteComposition.baseData} is the raw route
1304
+ * that delivers that anchor; this function appends the conversion leg. The two
1305
+ * combined sections represent one complete user journey: bridge to the anchor,
1306
+ * then swap the anchor to the wanted token.
1307
+ *
1308
+ * - **LOCKED DECISION 2 — the swap is a deferred second signature.** The PulseX
1309
+ * swap can only be signed AFTER the PulseChain Bridge lands the anchor on
1310
+ * PulseChain — the funds must arrive before the user can authorise the swap. The
1311
+ * appended section therefore carries `deferred: true` and an ESTIMATED fee that
1312
+ * firms up on arrival (price impact and pool depth may shift in the ~20 minutes
1313
+ * the crossing takes). Do not collapse these into one signature.
1314
+ *
1315
+ * The composed route:
1316
+ * - keeps the base route's provider and entry chain (`entryChainId` / `via`) — it
1317
+ * is the same PulseChain Bridge crossing with a swap bolted on at the
1318
+ * destination;
1319
+ * - takes a DISTINCT identifier (`<baseRouteId>-pulsex-swap`) so React keys and
1320
+ * selection never collide with the bare anchor-delivery route;
1321
+ * - re-drives the output to the wanted token: the estimate's delivered amount, its
1322
+ * United States dollar value, and the wanted token's symbol (the bare anchor
1323
+ * route would otherwise claim the anchor as the delivered token);
1324
+ * - sums the swap's total cost (fee + price impact) into `totalCostUsdValue` so
1325
+ * the aggregate cost is honest — aggregator spread + omnibridge crossing +
1326
+ * PulseX swap — and subtracts it from `netValueUsd`.
1327
+ *
1328
+ * @param composition - the base route data, swap tokens, estimate, logos, and the
1329
+ * injected base route id (computed by the caller via `toRouteView`)
1330
+ * @returns the composed raw two-section conversion route (bridge, then deferred PulseX swap)
1331
+ */
1332
+ export const buildConversionRouteData = (composition) => {
1333
+ const { baseData, baseRouteId, anchorToken, wantedToken, estimate, anchorTokenLogoUri, wantedTokenLogoUri, pulsechainChainLogoUri, } = composition;
1334
+ // The anchor bridges the two legs: it is BOTH the base route's delivered node and
1335
+ // the swap's input node. Reuse the delivered node's pre-resolved logo so the same
1336
+ // token shows ONE icon across the boundary; fall back to the threaded anchor logo.
1337
+ const deliveredAnchorNode = baseData.sections.at(-1)?.tokenFlow.at(-1);
1338
+ const boundaryAnchorLogoUri = deliveredAnchorNode?.logoUri ?? anchorTokenLogoUri;
1339
+ const swapSection = buildPulseXSwapSectionData({
1340
+ id: `${baseRouteId}-pulsex-swap`,
1341
+ fromToken: anchorToken,
1342
+ toToken: wantedToken,
1343
+ // The section cost is the swap's TOTAL value loss (fee + price impact) so the
1344
+ // route reconciles: input value − each step's cost = delivered output.
1345
+ feeUsdValue: estimate.costUsdValue,
1346
+ priceImpactPercent: estimate.priceImpactPercent,
1347
+ // The swap lands the user in the wanted token. Its value is the bridge leg's
1348
+ // landing less what the swap costs — the same subtraction the route's output
1349
+ // uses, so the last step's landing figure and the route's delivered figure
1350
+ // cannot drift apart.
1351
+ landedAmount: estimate.outputAmount,
1352
+ landedUsdValue: subtractUsdValues(baseData.output.usdValue, estimate.costUsdValue),
1353
+ // The swap is near-instant (~10s); the ~20-minute wait sits on the crossing below.
1354
+ etaSeconds: PULSEX_SWAP_ETA_SECONDS,
1355
+ fromTokenLogoUri: boundaryAnchorLogoUri,
1356
+ toTokenLogoUri: wantedTokenLogoUri,
1357
+ pulsechainChainLogoUri,
1358
+ // The bridge must land on PulseChain before the swap can be signed.
1359
+ deferred: true,
1360
+ });
1361
+ // The ~20-minute crossing wait belongs on the section that LANDS THE FUNDS ON
1362
+ // PULSECHAIN, replacing whatever entry-chain-only estimate that section carried.
1363
+ //
1364
+ // THE INDEX IS NOT A PROXY HERE, and the difference is worth stating because
1365
+ // this project has been bitten by one. A positional "last section" stood in for
1366
+ // "the section Gibs delivers" elsewhere and broke the moment a deferred leg
1367
+ // appeared. This is the other case: a base route is BUILT to end on PulseChain —
1368
+ // that is what makes it a base a swap can be appended to — so its final section
1369
+ // IS the landing section by construction, for a one-signature base and for the
1370
+ // two-signature NEAR Intents base alike. The name says which fact is meant, so a
1371
+ // future base that ends somewhere else fails a test rather than a reader.
1372
+ const pulsechainLandingSectionIndex = baseData.sections.length - 1;
1373
+ const bridgeSections = baseData.sections.map((section, index) => index === pulsechainLandingSectionIndex
1374
+ ? { ...section, etaMinutes: BRIDGE_SETTLEMENT_ETA_MINUTES, etaSeconds: null }
1375
+ : section);
1376
+ return {
1377
+ ...baseData,
1378
+ // The conversion route crosses the aggregator entry chain then swaps on PulseX.
1379
+ kind: 'conversion',
1380
+ // Carry the aggregator entry chain so Bridge.tsx can link back to the candidate.
1381
+ entryChainId: baseData.entryChainId ?? null,
1382
+ sections: [...bridgeSections, swapSection],
1383
+ output: {
1384
+ amount: estimate.outputAmount,
1385
+ decimals: wantedToken.decimals,
1386
+ symbol: wantedToken.symbol,
1387
+ // The wanted token's COUNT comes from the swap quote, but its value continues
1388
+ // the base route's basis: what the anchor was worth on arrival, less the swap's
1389
+ // cost. Re-pricing the wanted token against its own feed — which is what this
1390
+ // used to do — sets the figure by the router's realized rate instead, and that
1391
+ // rate routinely disagrees with the ratio of the two feeds by about a dollar on
1392
+ // a hundred. The route then appeared to deliver more than its own cost rows allowed.
1393
+ usdValue: subtractUsdValues(baseData.output.usdValue, estimate.costUsdValue),
1394
+ },
1395
+ // Aggregate cost = base cost + the swap's total loss; null only when both unknown.
1396
+ totalCostUsdValue: addUsdValues(baseData.totalCostUsdValue, estimate.costUsdValue),
1397
+ // Net = base net − the swap's total loss; null when either is unknown.
1398
+ netValueUsd: baseData.netValueUsd == null || estimate.costUsdValue == null
1399
+ ? null
1400
+ : baseData.netValueUsd - estimate.costUsdValue,
1401
+ };
1402
+ };
1403
+ /**
1404
+ * Derives a {@link PulseXSwapEstimateData} — the raw NUMERIC form of the PulseX
1405
+ * swap estimate — from a PulseX `getAmountsOut` quote.
1406
+ *
1407
+ * Returns raw numbers rather than formatted currency strings, so the conversion
1408
+ * composition can flow them straight into a {@link RouteData} without re-parsing
1409
+ * currency strings.
1410
+ *
1411
+ * Returns null when the quote is absent or empty (the router reverted or has not
1412
+ * loaded) — the composing route then has no swap leg to append.
1413
+ *
1414
+ * @param params.amountsOut - the router's amounts-out array, or null when unquoted
1415
+ * @param params.inputAmount - the anchor amount entering the swap, in its base units
1416
+ * @param params.inputToken - the anchor token (the swap input)
1417
+ * @param params.inputUsdPrice - the anchor token's per-token dollar price, or null
1418
+ * @param params.outputToken - the wanted token (the swap output)
1419
+ * @param params.outputUsdPrice - the wanted token's per-token dollar price, or null
1420
+ * @param params.referenceAmountsOut - marginal reference quote amounts-out, or null
1421
+ * @param params.referenceInputAmount - the reference quote's input amount
1422
+ * @returns the derived estimate with numeric fields, or null when the quote yields no output amount
1423
+ */
1424
+ export const buildPulseXSwapEstimateData = ({ amountsOut, inputAmount, inputToken, inputUsdPrice, outputToken, outputUsdPrice, referenceAmountsOut = null, referenceInputAmount = 0n, }) => {
1425
+ if (!amountsOut || amountsOut.length === 0)
1426
+ return null;
1427
+ const outputAmount = amountsOut[amountsOut.length - 1];
1428
+ if (typeof outputAmount !== 'bigint')
1429
+ return null;
1430
+ // The swap fee is APPROXIMATE: PulseX takes a flat ~0.29% of the trade. Apply that
1431
+ // rate to the swapped value, preferring the input (anchor) side and falling back
1432
+ // to the output (wanted) side, so the fee still shows when only ONE side is priced.
1433
+ const feeUsdValue = usdValueOfAmount({ balance: (inputAmount * PULSEX_SWAP_FEE_BASIS_POINTS) / BASIS_POINTS_DENOMINATOR, decimals: inputToken.decimals, price: inputUsdPrice }) ??
1434
+ usdValueOfAmount({ balance: (outputAmount * PULSEX_SWAP_FEE_BASIS_POINTS) / BASIS_POINTS_DENOMINATOR, decimals: outputToken.decimals, price: outputUsdPrice });
1435
+ // PRICE IMPACT comes from the MARGINAL reference quote, NOT from differencing the
1436
+ // two PulseChain USD prices: those prices are themselves PulseX pool quotes, so
1437
+ // value-in minus value-out cancels the fee and impact to ~$0 (the bug this
1438
+ // replaces). Instead compare the full trade's realized rate against the tiny
1439
+ // reference quote's near-spot rate. The swap fee rides in BOTH `getAmountsOut`
1440
+ // results, so it cancels in the ratio and the gap is price impact ALONE.
1441
+ const marginalRate = (() => {
1442
+ if (!referenceAmountsOut || referenceAmountsOut.length === 0)
1443
+ return null;
1444
+ if (referenceInputAmount <= 0n)
1445
+ return null;
1446
+ const referenceOutput = referenceAmountsOut[referenceAmountsOut.length - 1];
1447
+ if (typeof referenceOutput !== 'bigint' || referenceOutput <= 0n)
1448
+ return null;
1449
+ const referenceInputWhole = Number(formatUnits(referenceInputAmount, inputToken.decimals));
1450
+ const referenceOutputWhole = Number(formatUnits(referenceOutput, outputToken.decimals));
1451
+ if (!(referenceInputWhole > 0) || !(referenceOutputWhole > 0))
1452
+ return null;
1453
+ return referenceOutputWhole / referenceInputWhole;
1454
+ })();
1455
+ const inputWhole = Number(formatUnits(inputAmount, inputToken.decimals));
1456
+ const outputWhole = Number(formatUnits(outputAmount, outputToken.decimals));
1457
+ // The output the trade WOULD deliver at the marginal (near-spot) rate with no
1458
+ // price impact — fee still applied, since the marginal rate carries it too.
1459
+ const idealOutputWhole = marginalRate !== null ? inputWhole * marginalRate : null;
1460
+ // The impact loss in OUTPUT-token units: what the marginal rate promised minus
1461
+ // what the curve actually delivered. The fee is in both, so this is impact only.
1462
+ const impactLossOutput = idealOutputWhole !== null ? Math.max(0, idealOutputWhole - outputWhole) : null;
1463
+ const priceImpactPercent = impactLossOutput !== null && idealOutputWhole !== null && idealOutputWhole > 0
1464
+ ? Math.max(0, (impactLossOutput / idealOutputWhole) * 100)
1465
+ : null;
1466
+ // Value the impact loss in dollars — preferring the output token's price, falling
1467
+ // back to the input side via the marginal rate so the impact still shows when
1468
+ // only the anchor is priced.
1469
+ const impactUsd = (() => {
1470
+ if (impactLossOutput === null)
1471
+ return null;
1472
+ if (outputUsdPrice !== null)
1473
+ return impactLossOutput * outputUsdPrice;
1474
+ if (inputUsdPrice !== null && marginalRate !== null && marginalRate > 0) {
1475
+ return (impactLossOutput / marginalRate) * inputUsdPrice;
1476
+ }
1477
+ return null;
1478
+ })();
1479
+ // Total cost is the flat fee PLUS the price impact — both real, neither cancelled.
1480
+ // Round the fee to cents the SAME way the pre-split builder did (it summed the
1481
+ // already-formatted fee string), so the total cost is identical to the cent.
1482
+ const feeRounded = feeUsdValue === null ? null : roundUsdToCents(feeUsdValue);
1483
+ const costUsdValue = feeRounded !== null && Number.isFinite(feeRounded)
1484
+ ? feeRounded + (impactUsd ?? 0)
1485
+ : impactUsd;
1486
+ if (costUsdValue === null) {
1487
+ console.warn(`[route-strip] fee unavailable: PulseX swap leg` +
1488
+ ` wantedToken=${outputToken.symbol}` +
1489
+ ` anchorTokenUsdPrice=${inputUsdPrice}` +
1490
+ ` wantedTokenUsdPrice=${outputUsdPrice}`);
1491
+ }
1492
+ return {
1493
+ outputAmount,
1494
+ feeUsdValue,
1495
+ costUsdValue,
1496
+ priceImpactPercent,
1497
+ etaMinutes: null,
1498
+ };
1499
+ };
1500
+ /**
1501
+ * Composes a raw {@link RouteData} for a SAME-CHAIN PulseX swap: PulseChain on
1502
+ * both sides of the selection, one signature, no crossing at all.
1503
+ *
1504
+ * WHY THIS IS ITS OWN PRODUCER, RATHER THAN `buildConversionRouteData` WITH NO
1505
+ * BASE. The conversion route APPENDS a swap section to a bridge route that
1506
+ * already delivered the anchor onto PulseChain; its cost, its `entryChainId`,
1507
+ * and its "deferred" swap all inherit from that base. A same-chain swap has no
1508
+ * base — the reader already holds the input token on PulseChain before
1509
+ * pressing anything — so the section is signed IMMEDIATELY (`deferred` stays
1510
+ * false, the {@link buildPulseXSwapSectionData} default) and the route has no
1511
+ * `entryChainId` at all, exactly like the direct omnibridge route it sits
1512
+ * beside in the strip.
1513
+ *
1514
+ * NOT DEFERRED, AND THAT IS LOAD-BEARING FOR THE TRACKER. `deferred` means "the
1515
+ * reader has not yet signed this" (see `sectionProgress.ts`); a same-chain swap
1516
+ * is signed in the very press that builds it, so marking it deferred would tell
1517
+ * the tracker a signature is still owed for a transaction already broadcast.
1518
+ * What DOES need care downstream is the opposite failure the tracker's own
1519
+ * `routeChainSequence` can produce for a route whose every stop shares one
1520
+ * chain id — see `routeProgress.ts`'s `reachedChainIndex` for the fix that
1521
+ * keeps a same-chain route from reading "delivered" the instant it is merely
1522
+ * broadcast.
1523
+ *
1524
+ * @param roots - the route's identity, tokens, pricing, and logos
1525
+ * @returns the one-section same-chain swap {@link RouteData}
1526
+ */
1527
+ export const buildSameChainSwapRouteData = (roots) => {
1528
+ const { id, fromToken, toToken, estimate, inputUsdValue, fromTokenLogoUri, toTokenLogoUri, pulsechainChainLogoUri, advanced, } = roots;
1529
+ const swapSection = buildPulseXSwapSectionData({
1530
+ id: `${id}-pulsex-swap`,
1531
+ fromToken,
1532
+ toToken,
1533
+ feeUsdValue: estimate.costUsdValue,
1534
+ priceImpactPercent: estimate.priceImpactPercent,
1535
+ landedAmount: estimate.outputAmount,
1536
+ landedUsdValue: subtractUsdValues(inputUsdValue, estimate.costUsdValue),
1537
+ etaSeconds: PULSEX_SWAP_ETA_SECONDS,
1538
+ fromTokenLogoUri,
1539
+ toTokenLogoUri,
1540
+ pulsechainChainLogoUri,
1541
+ // Signed immediately — see the function doc above for why this must stay false.
1542
+ deferred: false,
1543
+ advanced,
1544
+ });
1545
+ return {
1546
+ kind: 'swap',
1547
+ // No aggregator entry chain: the whole journey is one signature on PulseChain.
1548
+ entryChainId: null,
1549
+ headlineProviderKey: 'PulseX',
1550
+ sections: [swapSection],
1551
+ output: {
1552
+ amount: estimate.outputAmount,
1553
+ decimals: toToken.decimals,
1554
+ symbol: toToken.symbol,
1555
+ usdValue: subtractUsdValues(inputUsdValue, estimate.costUsdValue),
1556
+ },
1557
+ totalCostUsdValue: estimate.costUsdValue,
1558
+ netValueUsd: inputUsdValue == null || estimate.costUsdValue == null
1559
+ ? null
1560
+ : inputUsdValue - estimate.costUsdValue,
1561
+ // The swap's own near-instant duration; there is no crossing wait to add.
1562
+ etaMinutes: null,
1563
+ };
1564
+ };