@gibs/bridge-sdk 1.12.0 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +234 -432
- package/dist/abis.d.ts +1 -0
- package/dist/bridge-limits.d.ts +103 -0
- package/dist/bridge-limits.js +84 -0
- package/dist/chain-info.d.ts +169 -0
- package/dist/chain-info.js +410 -0
- package/dist/chain-names.d.ts +160 -0
- package/dist/chain-names.js +200 -0
- package/dist/chains.d.ts +65 -0
- package/dist/chains.js +284 -0
- package/dist/config.d.ts +216 -0
- package/dist/config.js +493 -0
- package/dist/consolidated-quote.d.ts +554 -0
- package/dist/consolidated-quote.js +773 -0
- package/dist/destination-calldata.d.ts +118 -0
- package/dist/destination-calldata.js +152 -0
- package/dist/ecosystems.d.ts +217 -0
- package/dist/ecosystems.js +288 -0
- package/dist/fee-type.d.ts +10 -0
- package/dist/fee-type.js +9 -0
- package/dist/hyperliquid-execution.d.ts +152 -0
- package/dist/hyperliquid-execution.js +168 -0
- package/dist/image-links.d.ts +117 -0
- package/dist/image-links.js +142 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +20 -0
- package/dist/lifi-execution-guard.d.ts +199 -0
- package/dist/lifi-execution-guard.js +400 -0
- package/dist/near-intents-conformance.d.ts +85 -0
- package/dist/near-intents-conformance.js +329 -0
- package/dist/near-intents-route.d.ts +47 -0
- package/dist/near-intents-route.js +265 -0
- package/dist/near-intents.d.ts +524 -0
- package/dist/near-intents.js +1055 -0
- package/dist/origin-deposit.d.ts +326 -0
- package/dist/origin-deposit.js +68 -0
- package/dist/pre-quote.d.ts +198 -0
- package/dist/pre-quote.js +175 -0
- package/dist/prices.d.ts +107 -0
- package/dist/prices.js +162 -0
- package/dist/providers.d.ts +221 -0
- package/dist/providers.js +157 -0
- package/dist/quote-fees.d.ts +102 -0
- package/dist/quote-fees.js +195 -0
- package/dist/referral.d.ts +77 -0
- package/dist/referral.js +72 -0
- package/dist/relay-currencies.d.ts +47 -0
- package/dist/relay-currencies.js +89 -0
- package/dist/relay-quote.d.ts +301 -0
- package/dist/relay-quote.js +983 -0
- package/dist/route-steps.d.ts +138 -0
- package/dist/route-steps.js +305 -0
- package/dist/routing/amounts.d.ts +80 -0
- package/dist/routing/amounts.js +120 -0
- package/dist/routing/candidate-route.d.ts +43 -0
- package/dist/routing/candidate-route.js +45 -0
- package/dist/routing/candidate.d.ts +237 -0
- package/dist/routing/candidate.js +96 -0
- package/dist/routing/carriers/ask-answer.d.ts +60 -0
- package/dist/routing/carriers/ask-answer.js +1 -0
- package/dist/routing/carriers/omnibridge.d.ts +137 -0
- package/dist/routing/carriers/omnibridge.js +419 -0
- package/dist/routing/carriers/pulsex.d.ts +149 -0
- package/dist/routing/carriers/pulsex.js +453 -0
- package/dist/routing/carriers/wrap.d.ts +25 -0
- package/dist/routing/carriers/wrap.js +115 -0
- package/dist/routing/composed-route-quality.d.ts +103 -0
- package/dist/routing/composed-route-quality.js +108 -0
- package/dist/routing/entry-chain-anchor.d.ts +88 -0
- package/dist/routing/entry-chain-anchor.js +55 -0
- package/dist/routing/format.d.ts +31 -0
- package/dist/routing/format.js +58 -0
- package/dist/routing/index.d.ts +34 -0
- package/dist/routing/index.js +42 -0
- package/dist/routing/producers.d.ts +770 -0
- package/dist/routing/producers.js +1564 -0
- package/dist/routing/providers.d.ts +26 -0
- package/dist/routing/providers.js +38 -0
- package/dist/routing/route-data.d.ts +355 -0
- package/dist/routing/route-data.js +1 -0
- package/dist/routing/search/compose.d.ts +168 -0
- package/dist/routing/search/compose.js +98 -0
- package/dist/routing/search/graph.d.ts +653 -0
- package/dist/routing/search/graph.js +445 -0
- package/dist/routing/search/plan-route-data.d.ts +244 -0
- package/dist/routing/search/plan-route-data.js +767 -0
- package/dist/routing/search/score.d.ts +353 -0
- package/dist/routing/search/score.js +427 -0
- package/dist/routing/usd-value.d.ts +17 -0
- package/dist/routing/usd-value.js +25 -0
- package/dist/settings.d.ts +692 -0
- package/dist/settings.js +909 -0
- package/dist/ton-execution.d.ts +117 -0
- package/dist/ton-execution.js +247 -0
- package/dist/tron-execution.d.ts +146 -0
- package/dist/tron-execution.js +239 -0
- package/dist/types.d.ts +51 -0
- package/dist/types.js +1 -0
- package/llms.txt +169 -0
- package/package.json +204 -23
- package/src/chain-info.test.ts +0 -968
- package/src/chain-info.ts +0 -498
- package/src/chains.test.ts +0 -123
- package/src/chains.ts +0 -88
- package/src/config.test.ts +0 -679
- package/src/config.ts +0 -500
- package/src/fee-type.test.ts +0 -45
- package/src/fee-type.ts +0 -14
- package/src/image-links.test.ts +0 -160
- package/src/image-links.ts +0 -65
- package/src/index.ts +0 -8
- package/src/settings.test.ts +0 -2754
- package/src/settings.ts +0 -875
- package/src/types.ts +0 -48
- package/tsconfig.json +0 -17
- package/vitest.config.ts +0 -13
- /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
|
+
};
|