@gibs/bridge-sdk 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +234 -432
  3. package/dist/abis.d.ts +1 -0
  4. package/dist/bridge-limits.d.ts +103 -0
  5. package/dist/bridge-limits.js +84 -0
  6. package/dist/chain-info.d.ts +169 -0
  7. package/dist/chain-info.js +410 -0
  8. package/dist/chain-names.d.ts +160 -0
  9. package/dist/chain-names.js +200 -0
  10. package/dist/chains.d.ts +65 -0
  11. package/dist/chains.js +284 -0
  12. package/dist/config.d.ts +216 -0
  13. package/dist/config.js +493 -0
  14. package/dist/consolidated-quote.d.ts +554 -0
  15. package/dist/consolidated-quote.js +773 -0
  16. package/dist/destination-calldata.d.ts +118 -0
  17. package/dist/destination-calldata.js +152 -0
  18. package/dist/ecosystems.d.ts +217 -0
  19. package/dist/ecosystems.js +288 -0
  20. package/dist/fee-type.d.ts +10 -0
  21. package/dist/fee-type.js +9 -0
  22. package/dist/hyperliquid-execution.d.ts +152 -0
  23. package/dist/hyperliquid-execution.js +168 -0
  24. package/dist/image-links.d.ts +117 -0
  25. package/dist/image-links.js +142 -0
  26. package/dist/index.d.ts +20 -0
  27. package/dist/index.js +20 -0
  28. package/dist/lifi-execution-guard.d.ts +199 -0
  29. package/dist/lifi-execution-guard.js +400 -0
  30. package/dist/near-intents-conformance.d.ts +85 -0
  31. package/dist/near-intents-conformance.js +329 -0
  32. package/dist/near-intents-route.d.ts +47 -0
  33. package/dist/near-intents-route.js +265 -0
  34. package/dist/near-intents.d.ts +524 -0
  35. package/dist/near-intents.js +1055 -0
  36. package/dist/origin-deposit.d.ts +326 -0
  37. package/dist/origin-deposit.js +68 -0
  38. package/dist/pre-quote.d.ts +198 -0
  39. package/dist/pre-quote.js +175 -0
  40. package/dist/prices.d.ts +107 -0
  41. package/dist/prices.js +162 -0
  42. package/dist/providers.d.ts +221 -0
  43. package/dist/providers.js +157 -0
  44. package/dist/quote-fees.d.ts +102 -0
  45. package/dist/quote-fees.js +195 -0
  46. package/dist/referral.d.ts +77 -0
  47. package/dist/referral.js +72 -0
  48. package/dist/relay-currencies.d.ts +47 -0
  49. package/dist/relay-currencies.js +89 -0
  50. package/dist/relay-quote.d.ts +301 -0
  51. package/dist/relay-quote.js +983 -0
  52. package/dist/route-steps.d.ts +138 -0
  53. package/dist/route-steps.js +305 -0
  54. package/dist/routing/amounts.d.ts +80 -0
  55. package/dist/routing/amounts.js +120 -0
  56. package/dist/routing/candidate-route.d.ts +43 -0
  57. package/dist/routing/candidate-route.js +45 -0
  58. package/dist/routing/candidate.d.ts +237 -0
  59. package/dist/routing/candidate.js +96 -0
  60. package/dist/routing/carriers/ask-answer.d.ts +58 -0
  61. package/dist/routing/carriers/ask-answer.js +1 -0
  62. package/dist/routing/carriers/omnibridge.d.ts +137 -0
  63. package/dist/routing/carriers/omnibridge.js +419 -0
  64. package/dist/routing/carriers/pulsex.d.ts +111 -0
  65. package/dist/routing/carriers/pulsex.js +404 -0
  66. package/dist/routing/carriers/wrap.d.ts +25 -0
  67. package/dist/routing/carriers/wrap.js +115 -0
  68. package/dist/routing/composed-route-quality.d.ts +103 -0
  69. package/dist/routing/composed-route-quality.js +108 -0
  70. package/dist/routing/entry-chain-anchor.d.ts +88 -0
  71. package/dist/routing/entry-chain-anchor.js +55 -0
  72. package/dist/routing/format.d.ts +31 -0
  73. package/dist/routing/format.js +58 -0
  74. package/dist/routing/index.d.ts +34 -0
  75. package/dist/routing/index.js +42 -0
  76. package/dist/routing/producers.d.ts +770 -0
  77. package/dist/routing/producers.js +1564 -0
  78. package/dist/routing/providers.d.ts +26 -0
  79. package/dist/routing/providers.js +38 -0
  80. package/dist/routing/route-data.d.ts +355 -0
  81. package/dist/routing/route-data.js +1 -0
  82. package/dist/routing/search/compose.d.ts +168 -0
  83. package/dist/routing/search/compose.js +98 -0
  84. package/dist/routing/search/graph.d.ts +639 -0
  85. package/dist/routing/search/graph.js +445 -0
  86. package/dist/routing/search/plan-route-data.d.ts +244 -0
  87. package/dist/routing/search/plan-route-data.js +767 -0
  88. package/dist/routing/search/score.d.ts +324 -0
  89. package/dist/routing/search/score.js +400 -0
  90. package/dist/routing/usd-value.d.ts +17 -0
  91. package/dist/routing/usd-value.js +25 -0
  92. package/dist/settings.d.ts +692 -0
  93. package/dist/settings.js +909 -0
  94. package/dist/ton-execution.d.ts +117 -0
  95. package/dist/ton-execution.js +247 -0
  96. package/dist/tron-execution.d.ts +146 -0
  97. package/dist/tron-execution.js +239 -0
  98. package/dist/types.d.ts +51 -0
  99. package/dist/types.js +1 -0
  100. package/package.json +203 -23
  101. package/src/chain-info.test.ts +0 -968
  102. package/src/chain-info.ts +0 -498
  103. package/src/chains.test.ts +0 -123
  104. package/src/chains.ts +0 -88
  105. package/src/config.test.ts +0 -679
  106. package/src/config.ts +0 -500
  107. package/src/fee-type.test.ts +0 -45
  108. package/src/fee-type.ts +0 -14
  109. package/src/image-links.test.ts +0 -160
  110. package/src/image-links.ts +0 -65
  111. package/src/index.ts +0 -8
  112. package/src/settings.test.ts +0 -2754
  113. package/src/settings.ts +0 -875
  114. package/src/types.ts +0 -48
  115. package/tsconfig.json +0 -17
  116. package/vitest.config.ts +0 -13
  117. /package/{src/abis.ts → dist/abis.js} +0 -0
@@ -0,0 +1,404 @@
1
+ import { multicallRead } from '@gibs/common/multicall';
2
+ import { getAddress, parseAbi, zeroAddress } from 'viem';
3
+ import * as abis from '../../abis.js';
4
+ import { chainsMetadata } from '../../chains.js';
5
+ import { Chains, nativeAssetOut, toChain, uniV2Factories, uniV2Routers } from '../../config.js';
6
+ import { sameAddress } from '../../ecosystems.js';
7
+ import { pulseXConversionSwapPath } from '../providers.js';
8
+ import { pulsechainChainId } from '../producers.js';
9
+ import { assetNodeKey } from '../search/graph.js';
10
+ /**
11
+ * PulseX (the PulseChain-native decentralized exchange, used here for a
12
+ * conversion swap leg) as a plug-in `Carrier` for `@gibs/quotes`'s
13
+ * route-search engine. GIBS-SPECIFIC — see `docs/quote-service.md`'s
14
+ * "Carriers" section: "never built in ... PulseX." Registered from
15
+ * `packages/ui/src/lib/state/useRouteSearch.ts` / `route-search-quotes.ts`.
16
+ *
17
+ * See `omnibridge.ts`'s head doc for why this returns a plain object shaped
18
+ * like `Carrier` rather than importing that type.
19
+ */
20
+ /** WPLS, the wrapped-native intermediary {@link pulseXConversionSwapPath} routes conversions through. */
21
+ const wplsAddress = nativeAssetOut[Chains.PLS];
22
+ /**
23
+ * Every PulseX deployment on PulseChain, V1 first: `uniV2Routers` paired with
24
+ * `uniV2Factories` by index. Each factory is the router's own
25
+ * `router.factory()` read on chain — see `uniV2Factories`'s own doc in
26
+ * `config.ts`. A router with no recorded factory is left out, because its
27
+ * pools cannot be read.
28
+ */
29
+ export const pulseXVenues = (uniV2Routers[Chains.PLS] ?? []).flatMap((router, index) => {
30
+ const factory = uniV2Factories[Chains.PLS]?.[index];
31
+ return factory === undefined ? [] : [{ router: getAddress(router), factory: getAddress(factory) }];
32
+ });
33
+ /**
34
+ * The venue an edge with no {@link HopEdge.venue} runs on: V1, index 0. Every
35
+ * PulseX edge built before venues existed was quoted and signed on V1 — the
36
+ * same-chain swap sender's own hand-built edge still is — so this keeps that
37
+ * meaning rather than guessing a new one.
38
+ */
39
+ const defaultPulseXVenue = pulseXVenues[0];
40
+ /**
41
+ * The router a PulseX edge must be signed on — the one its figure was quoted
42
+ * against. Reads the edge's own {@link HopEdge.venue}; an edge with none is a
43
+ * V1 edge (see {@link defaultPulseXVenue}).
44
+ *
45
+ * @param edge - the edge to sign
46
+ * @returns the router, or null when the edge is not a PulseX edge or names a venue that is not a known PulseX router
47
+ */
48
+ export const pulseXRouterForEdge = (edge) => pulseXVenueForEdge(edge)?.router ?? null;
49
+ /**
50
+ * The {@link PulseXVenue} a PulseX edge runs on — see {@link pulseXRouterForEdge}.
51
+ *
52
+ * @param edge - the edge
53
+ * @returns the venue, or null when there is none
54
+ */
55
+ const pulseXVenueForEdge = (edge) => {
56
+ if (edge.carrier !== 'PulseX')
57
+ return null;
58
+ if (edge.venue === undefined)
59
+ return defaultPulseXVenue ?? null;
60
+ const venue = edge.venue;
61
+ return pulseXVenues.find((candidate) => sameAddress(candidate.router, venue, 'evm')) ?? null;
62
+ };
63
+ /**
64
+ * A node's own address for a PulseX pool lookup: native PLS (the zero
65
+ * address) is not a token the router or its factory ever holds a pool for —
66
+ * only Wrapped PLS is — so a native node resolves to {@link wplsAddress}
67
+ * before it ever reaches {@link pulseXConversionSwapPath}. Every other node
68
+ * is unchanged.
69
+ *
70
+ * MIRRORS `useRouteSearch.ts`'s `quotePathAddress` (the UI's async `ask`
71
+ * already resolves native this way through that function) — this is the
72
+ * SAME rule, restated here because `prepare`/`exact` never go through the
73
+ * UI's `readAmountOut` at all; see {@link pulseXPoolPath}'s own doc.
74
+ *
75
+ * @param node - the node to resolve
76
+ * @returns the node's own address, or {@link wplsAddress} for native PLS
77
+ */
78
+ const poolTokenAddress = (node) => (sameAddress(node.address, zeroAddress, 'evm') ? wplsAddress : node.address);
79
+ /**
80
+ * The router swap path a PulseX hop from `from` to `to` prices and signs
81
+ * along, or null when there is no swap for PulseX to do — including when
82
+ * both ends resolve to the SAME pool token, which is true of PLS<->WPLS
83
+ * (native PLS resolves to {@link wplsAddress} via {@link poolTokenAddress},
84
+ * same as Wrapped PLS itself): that pair is the wrap carrier's job, never a
85
+ * PulseX hop, so this returns null rather than a zero-length or self-paired
86
+ * path.
87
+ *
88
+ * EVERY PULSEX QUOTE AND SIGNATURE IN THIS FILE MUST GO THROUGH THIS ONE
89
+ * FUNCTION — `pulseXCarries`, `prepare`'s pool discovery, and `exact`'s hop
90
+ * pricing all call it, so native PLS resolves the same way in every one of
91
+ * them. Before this existed, `pulseXCarries` and `prepare`/`exact` called
92
+ * `pulseXConversionSwapPath` directly on the raw node address: a native PLS
93
+ * node produced a path that LITERALLY STARTED WITH THE ZERO ADDRESS
94
+ * (`[zeroAddress, wplsAddress, ...]`), which `pulseXCarries` accepted (its
95
+ * length is still >= 2) but for which `prepare` could never find a pool —
96
+ * the factory has no pair for the zero address — so `exact` always answered
97
+ * `null` for a direct native-PLS PulseX hop, and `enumerate.ts` pruned it,
98
+ * leaving only the two-signature wrap-then-swap path.
99
+ *
100
+ * @param from - the hop's origin node
101
+ * @param to - the hop's destination node
102
+ * @returns the swap path, or null when there is nothing for PulseX to swap
103
+ */
104
+ export const pulseXPoolPath = (from, to) => {
105
+ const fromAddress = poolTokenAddress(from);
106
+ const toAddress = poolTokenAddress(to);
107
+ if (sameAddress(fromAddress, toAddress, 'evm'))
108
+ return null;
109
+ return pulseXConversionSwapPath(fromAddress, toAddress, wplsAddress);
110
+ };
111
+ /**
112
+ * Whether PulseX can carry funds directly from `from` to `to`: both must be
113
+ * distinct Ethereum-Virtual-Machine tokens on PulseChain itself — PulseX runs
114
+ * only on PulseChain, never on an entry chain.
115
+ *
116
+ * NO TOKEN ALLOWLIST IS CONSULTED, on purpose: "any PulseChain token with a
117
+ * {@link pulseXPoolPath}" is every PulseChain token, because that function
118
+ * is total over any two addresses that do not resolve to the same pool
119
+ * token — it collapses to a direct hop only when one side is already WPLS,
120
+ * and otherwise always routes through WPLS. This calls it anyway and checks
121
+ * the result is at least two addresses long, so a future change that
122
+ * narrowed it to return an empty path for some input would fail this
123
+ * predicate rather than silently offer a hop with no path.
124
+ */
125
+ const pulseXCarries = (from, to) => {
126
+ if (from.ecosystem !== 'evm' || to.ecosystem !== 'evm')
127
+ return false;
128
+ if (from.chainId !== pulsechainChainId || to.chainId !== pulsechainChainId)
129
+ return false;
130
+ const swapPath = pulseXPoolPath(from, to);
131
+ return swapPath !== null && swapPath.length >= 2;
132
+ };
133
+ /**
134
+ * PulseX's own constant-product swap fee, as a numerator over
135
+ * {@link PULSEX_FEE_DENOMINATOR} — the SAME `getAmountOut` formula every
136
+ * Uniswap-V2-style router uses: `amountOut = floor(amountIn * feeNumerator *
137
+ * reserveOut / (reserveIn * feeDenominator + amountIn * feeNumerator))`.
138
+ *
139
+ * MEASURED, NOT ASSUMED — see `uniV2Factories`'s own doc in `config.ts` for
140
+ * the full account. Reading live reserves from both PulseX router
141
+ * deployments (V1 `0x98bf93…`, V2 `0x165C34…`) and back-solving this ratio
142
+ * against each router's own `getAmountsOut`, on a one-hop and a two-hop
143
+ * path, gave the IDENTICAL ratio — `9971/10000` — for both, to the base
144
+ * unit. One constant serves both, and `pulsex-carrier.fork.test.ts` pins
145
+ * this claim against live chain data rather than trusting the arithmetic
146
+ * alone.
147
+ */
148
+ const PULSEX_FEE_NUMERATOR = 9971n;
149
+ /** The denominator {@link PULSEX_FEE_NUMERATOR} is a fraction of. */
150
+ const PULSEX_FEE_DENOMINATOR = 10000n;
151
+ /** The minimal read-only factory ABI `prepare` needs — just enough to resolve a pool's address from its two tokens. */
152
+ const univ2FactoryAbi = parseAbi([
153
+ 'function getPair(address tokenA, address tokenB) external view returns (address pair)',
154
+ ]);
155
+ /**
156
+ * The Uniswap-V2-style constant-product `getAmountOut`, applied with
157
+ * {@link PULSEX_FEE_NUMERATOR}. Reproduces the router's own on-chain formula
158
+ * exactly — see `pulsex-carrier.fork.test.ts` — so PRICE IMPACT IS ALREADY
159
+ * IN HERE: this is the same nonlinear function the router evaluates, not a
160
+ * flat-fee approximation with an impact correction bolted on afterward.
161
+ *
162
+ * @param amountIn - the input amount, in `reserveIn`'s token's base units
163
+ * @param reserveIn - the pool's reserve of the input token
164
+ * @param reserveOut - the pool's reserve of the output token
165
+ * @returns the output amount, in `reserveOut`'s token's base units; 0 when either reserve is 0
166
+ */
167
+ const constantProductAmountOut = (amountIn, reserveIn, reserveOut) => {
168
+ if (reserveIn <= 0n || reserveOut <= 0n || amountIn <= 0n)
169
+ return 0n;
170
+ const amountInWithFee = amountIn * PULSEX_FEE_NUMERATOR;
171
+ const numerator = amountInWithFee * reserveOut;
172
+ const denominator = reserveIn * PULSEX_FEE_DENOMINATOR + amountInWithFee;
173
+ return numerator / denominator;
174
+ };
175
+ /** The stable key one pool is stored and looked up under — the two token addresses, lower-cased and sorted, so either query order finds the same entry. */
176
+ const poolKey = (tokenA, tokenB) => {
177
+ const [lower, higher] = [tokenA.toLowerCase(), tokenB.toLowerCase()].sort();
178
+ return `${lower}|${higher}`;
179
+ };
180
+ /** The key one venue's pool is stored under: the same two tokens on V1 and on V2 are two different pools with two different reserves. */
181
+ const venuePoolKey = (venue, tokenA, tokenB) => `${venue.factory.toLowerCase()}|${poolKey(tokenA, tokenB)}`;
182
+ /** Every consecutive (tokenIn, tokenOut) hop a PulseX swap path implies. */
183
+ const hopsAlongPath = (path) => path.slice(0, -1).map((tokenIn, index) => [tokenIn, path[index + 1]]);
184
+ /**
185
+ * Batch-reads reserves for every pool `nodes`' candidate PulseX paths touch,
186
+ * on EVERY venue in `venues`, once, so {@link createPulseXCarrier}'s `exact`
187
+ * can answer every hop SYNCHRONOUSLY afterward.
188
+ *
189
+ * EACH VENUE IS READ THROUGH ITS OWN FACTORY. The same two tokens usually
190
+ * have a pool on V1 and another on V2, with different reserves; the two are
191
+ * stored apart ({@link venuePoolKey}) and never blended. `PULSEX_FEE_NUMERATOR`
192
+ * is proven identical for both deployments (see its own doc), so the formula
193
+ * is shared and only the reserves differ.
194
+ *
195
+ * TWO MULTICALLS ON PULSECHAIN, WHATEVER THE VENUE COUNT: `getPair` must
196
+ * resolve each pool's address before `getReserves` can be read from it, so
197
+ * the second read cannot join the first — but every venue's `getPair` rides
198
+ * the first multicall and every pool's `getReserves` rides the second.
199
+ *
200
+ * @param nodes - every node the search might branch through
201
+ * @param venues - the venues this carrier offers
202
+ * @param clientForChain - see {@link CreatePulseXCarrierOptions}'s own field
203
+ * @returns every pool this universe touches, keyed by {@link venuePoolKey} — pools with no `getPair` result are simply absent, not an error
204
+ */
205
+ const preparePulseXReserves = async ({ nodes, venues, clientForChain, }) => {
206
+ const reservesByPoolKey = new Map();
207
+ const client = clientForChain(pulsechainChainId);
208
+ if (client === null || venues.length === 0)
209
+ return reservesByPoolKey;
210
+ const pulsechainNodes = nodes.filter((node) => node.ecosystem === 'evm' && node.chainId === pulsechainChainId);
211
+ const poolTokenPairs = new Map();
212
+ for (const from of pulsechainNodes) {
213
+ for (const to of pulsechainNodes) {
214
+ if (assetNodeKey(from) === assetNodeKey(to))
215
+ continue;
216
+ if (!pulseXCarries(from, to))
217
+ continue;
218
+ const path = pulseXPoolPath(from, to);
219
+ if (path === null)
220
+ continue;
221
+ for (const [tokenIn, tokenOut] of hopsAlongPath(path)) {
222
+ poolTokenPairs.set(poolKey(tokenIn, tokenOut), [tokenIn, tokenOut]);
223
+ }
224
+ }
225
+ }
226
+ if (poolTokenPairs.size === 0)
227
+ return reservesByPoolKey;
228
+ const poolQueries = venues.flatMap((venue) => Array.from(poolTokenPairs.values()).map(([tokenIn, tokenOut]) => ({
229
+ key: venuePoolKey(venue, tokenIn, tokenOut),
230
+ factory: venue.factory,
231
+ tokenIn,
232
+ tokenOut,
233
+ })));
234
+ const factoryResults = await multicallRead({
235
+ chain: chainsMetadata[toChain(pulsechainChainId)],
236
+ client,
237
+ abi: univ2FactoryAbi,
238
+ calls: poolQueries.map((query) => ({
239
+ functionName: 'getPair',
240
+ target: query.factory,
241
+ args: [query.tokenIn, query.tokenOut],
242
+ allowFailure: true,
243
+ })),
244
+ });
245
+ const pairAddressByPoolKey = new Map();
246
+ poolQueries.forEach((query, index) => {
247
+ const pairAddress = factoryResults[index];
248
+ // A decoded address is exactly 42 characters; a failed `allowFailure`
249
+ // call's undecoded revert bytes are almost never that length — see
250
+ // `omnibridge.ts`'s identical guard for why `typeof` alone cannot tell
251
+ // success from failure for an address-returning call.
252
+ if (typeof pairAddress === 'string' && pairAddress.length === 42 && pairAddress !== zeroAddress) {
253
+ pairAddressByPoolKey.set(query.key, getAddress(pairAddress));
254
+ }
255
+ });
256
+ if (pairAddressByPoolKey.size === 0)
257
+ return reservesByPoolKey;
258
+ const reservePoolKeys = Array.from(pairAddressByPoolKey.keys());
259
+ const reserveResults = await multicallRead({
260
+ chain: chainsMetadata[toChain(pulsechainChainId)],
261
+ client,
262
+ abi: abis.pair,
263
+ calls: reservePoolKeys.map((key) => ({
264
+ functionName: 'getReserves',
265
+ target: pairAddressByPoolKey.get(key),
266
+ allowFailure: true,
267
+ })),
268
+ });
269
+ reservePoolKeys.forEach((key, index) => {
270
+ const result = reserveResults[index];
271
+ if (!Array.isArray(result))
272
+ return;
273
+ const [reserve0, reserve1] = result;
274
+ if (typeof reserve0 !== 'bigint' || typeof reserve1 !== 'bigint')
275
+ return;
276
+ // Standard Uniswap-V2-style factories sort a pair's tokens before
277
+ // creating it, so `token0` is always the LEXICOGRAPHICALLY LOWER
278
+ // address — confirmed against live PulseX pools 2026-09-24 (both V1 and
279
+ // V2), never re-derived with an extra `token0()` read.
280
+ reservesByPoolKey.set(key, { reserveOfLowerAddress: reserve0, reserveOfHigherAddress: reserve1 });
281
+ });
282
+ return reservesByPoolKey;
283
+ };
284
+ /**
285
+ * The venues a carrier offers: every known venue, or only the routers the
286
+ * host names. A named router that is not a known PulseX router is a
287
+ * configuration error, thrown at construction rather than offered silently.
288
+ *
289
+ * @param routers - the routers the host can sign on, or undefined for all
290
+ * @returns the venues, in {@link pulseXVenues} order
291
+ */
292
+ const selectPulseXVenues = (routers) => {
293
+ if (routers === undefined)
294
+ return pulseXVenues;
295
+ for (const router of routers) {
296
+ if (!pulseXVenues.some((venue) => sameAddress(venue.router, router, 'evm'))) {
297
+ throw new Error(`createPulseXCarrier: ${router} is not a known PulseX router on PulseChain`);
298
+ }
299
+ }
300
+ return pulseXVenues.filter((venue) => routers.some((router) => sameAddress(venue.router, router, 'evm')));
301
+ };
302
+ /**
303
+ * Builds the PulseX conversion swap as a plug-in carrier.
304
+ *
305
+ * @param options - see {@link CreatePulseXCarrierOptions}
306
+ * @returns the carrier — `id: 'PulseX'`
307
+ */
308
+ export const createPulseXCarrier = (options) => {
309
+ const { readAmountOut, clientForChain } = options;
310
+ const venues = selectPulseXVenues(options.routers);
311
+ // ONE EDGE PER VENUE. Each names its router as its `venue`, so the search
312
+ // quotes and ranks V1 and V2 as two separate hops and the better one wins
313
+ // on its own figure — and the winner is signed on the router it names.
314
+ const edgesFrom = (from, to) => {
315
+ if (assetNodeKey(from) === assetNodeKey(to))
316
+ return [];
317
+ if (!pulseXCarries(from, to))
318
+ return [];
319
+ return venues.map((venue) => ({
320
+ carrier: 'PulseX',
321
+ from,
322
+ to,
323
+ fusesNext: false,
324
+ origin: 'wallet',
325
+ floors: [],
326
+ venue: venue.router,
327
+ }));
328
+ };
329
+ const ask = async ({ edge, inputAmount }) => {
330
+ const amountOut = await readAmountOut(edge, inputAmount);
331
+ if (amountOut === null || amountOut <= 0n) {
332
+ return { ok: false, refusal: { kind: 'no-quote', reason: 'no liquidity on this PulseX path', minimumAmount: null } };
333
+ }
334
+ return {
335
+ ok: true,
336
+ deliveredAmount: amountOut,
337
+ // getAmountsOut is exact for the CURRENT block; no slippage tolerance is
338
+ // modelled here, so the guaranteed figure is left equal to the expected
339
+ // one, matching the omnibridge carrier's own convention.
340
+ minimumDeliveredAmount: amountOut,
341
+ claimedDurationSeconds: 0,
342
+ };
343
+ };
344
+ // Populated by `prepare`, read by `exact` — see `omnibridge.ts`'s identical
345
+ // field for why a mutable closure here is safe (client-side,
346
+ // one-search-at-a-time; PulseX is likewise "never built into" the shared
347
+ // display-quote service).
348
+ let reservesByPoolKey = new Map();
349
+ const prepare = async (nodes) => {
350
+ if (clientForChain === undefined)
351
+ return;
352
+ reservesByPoolKey = await preparePulseXReserves({ nodes, venues, clientForChain });
353
+ };
354
+ const exact = (edge, amountIn) => {
355
+ if (!pulseXCarries(edge.from, edge.to))
356
+ return null;
357
+ // Quoted on the edge's OWN venue — the router it will be signed on — and
358
+ // only on a venue this carrier offers.
359
+ const venue = pulseXVenueForEdge(edge);
360
+ if (venue === null || !venues.includes(venue))
361
+ return null;
362
+ const path = pulseXPoolPath(edge.from, edge.to);
363
+ if (path === null)
364
+ return null;
365
+ const hops = hopsAlongPath(path);
366
+ if (hops.length === 0)
367
+ return null;
368
+ let runningAmount = amountIn;
369
+ for (const [tokenIn, tokenOut] of hops) {
370
+ const reserves = reservesByPoolKey.get(venuePoolKey(venue, tokenIn, tokenOut));
371
+ // `prepare` never read this pool — a structural non-answer, not a
372
+ // refusal (`exact`'s own doc: `null` means "nothing to say"). NOTE
373
+ // THIS DOES NOT FALL BACK TO AN ESTIMATE: a carrier with `exact`
374
+ // defined never yields an `'estimate'` outcome (see
375
+ // `enumerate.ts`'s `resolveHopOutcome`, branch 1) — `null` here
376
+ // PRUNES the hop entirely, the same as a refusal.
377
+ if (reserves === undefined)
378
+ return null;
379
+ const tokenInIsLower = tokenIn.toLowerCase() < tokenOut.toLowerCase();
380
+ const reserveIn = tokenInIsLower ? reserves.reserveOfLowerAddress : reserves.reserveOfHigherAddress;
381
+ const reserveOut = tokenInIsLower ? reserves.reserveOfHigherAddress : reserves.reserveOfLowerAddress;
382
+ const hopAmountOut = constantProductAmountOut(runningAmount, reserveIn, reserveOut);
383
+ if (hopAmountOut <= 0n) {
384
+ return { outcome: 'refused', refusal: { kind: 'no-quote', reason: 'no liquidity on this PulseX path', minimumAmount: null } };
385
+ }
386
+ runningAmount = hopAmountOut;
387
+ }
388
+ return { outcome: 'delivered', deliveredAmount: runningAmount };
389
+ };
390
+ return {
391
+ id: 'PulseX',
392
+ edgesFrom,
393
+ ask,
394
+ prepare,
395
+ exact,
396
+ isRateLimited: false,
397
+ // A same-chain swap requires the reader's own wallet signature — never
398
+ // a by-hand deposit.
399
+ depositMode: 'wallet-signed',
400
+ sender: 'swap',
401
+ // A same-chain swap settles with the transaction itself.
402
+ settlementSeconds: 0,
403
+ };
404
+ };
@@ -0,0 +1,25 @@
1
+ import { type AssetNode, type HopEdge } from '../search/graph.js';
2
+ import type { CarrierAskAnswer, CarrierExactResult } from './ask-answer.js';
3
+ /**
4
+ * Builds the native wrap/unwrap carrier as a plug-in.
5
+ *
6
+ * NO OPTIONS, NO `prepare`. There is no local read to batch — a wrap has no
7
+ * minimum, no maximum, no fee rate and no reserve to be wrong about; it is a
8
+ * pure 1:1 identity over every chain `config.ts` names a wrapped-native
9
+ * token for. `exact` alone is total over every edge this carrier offers.
10
+ *
11
+ * @returns the carrier — `id: 'wrap'`
12
+ */
13
+ export declare const createWrapCarrier: () => {
14
+ id: string;
15
+ edgesFrom: (from: AssetNode, to: AssetNode) => readonly HopEdge[];
16
+ ask: ({ edge, inputAmount }: {
17
+ edge: HopEdge;
18
+ inputAmount: bigint;
19
+ }) => Promise<CarrierAskAnswer>;
20
+ exact: (edge: HopEdge, amountIn: bigint) => CarrierExactResult | null;
21
+ isRateLimited: boolean;
22
+ depositMode: "wallet-signed";
23
+ sender: "swap";
24
+ settlementSeconds: number;
25
+ };
@@ -0,0 +1,115 @@
1
+ import { getAddress, zeroAddress } from 'viem';
2
+ import { nativeAssetOut, toChain } from '../../config.js';
3
+ import { assetNodeKey } from '../search/graph.js';
4
+ /**
5
+ * Native-coin wrap/unwrap (PLS <-> WPLS on PulseChain, and the same pair on
6
+ * every other Ethereum-Virtual-Machine chain the search graph touches — ETH
7
+ * <-> WETH, BNB <-> WBNB) as a plug-in `Carrier` for `@gibs/quotes`'s
8
+ * route-search engine. GIBS-SPECIFIC in the sense `omnibridge.ts`/`pulsex.ts`
9
+ * are (see `omnibridge.ts`'s head doc for why this returns a plain object
10
+ * shaped like `Carrier` rather than importing that type) — though the
11
+ * carrier itself names no chain: it derives every wrap pair from
12
+ * `config.ts`'s own `nativeAssetOut` map, so a chain added there gains a
13
+ * wrap edge with no change here.
14
+ *
15
+ * WHY THIS IS A SEPARATE CARRIER FROM PulseX. `pulsex.ts`'s own
16
+ * `readPulseXAmountOut` (via `pulseXSwapPathForEdge` in
17
+ * `packages/ui/src/lib/state/useRouteSearch.ts`) quotes native Pulse AS
18
+ * Wrapped Pulse — both map to the same `quotePathAddress` — so a PLS<->WPLS
19
+ * pair collapses to "no swap to quote" and PulseX has never carried it.
20
+ * There is no PulseX POOL for native-versus-wrapped; it is a 1:1 contract
21
+ * call (`deposit()`/`withdraw(uint256)`), not a trade, so it needed its own
22
+ * carrier rather than a PulseX special case.
23
+ *
24
+ * SENDER: `'swap'`, THE SAME NAME `pulsex.ts` USES — the reader's own
25
+ * connected wallet signs a `deposit()`/`withdraw(uint256)` call on the
26
+ * wrapped token's own contract (WETH9-style; confirmed present on WPLS by
27
+ * bytecode selector 2026-09-24), exactly the wallet a same-chain PulseX swap
28
+ * already uses. WHETHER `settings.ts`'s EXISTING swap-transaction builder
29
+ * (`pulseXSwapTransactionInputs`) CAN BUILD THIS CALL: NO, not without a
30
+ * change there — that builder only ever targets a PulseX ROUTER address and
31
+ * encodes `swapExact*` calldata; a wrap needs `to` = the wrapped token
32
+ * itself and `data` = plain `deposit()`/`withdraw(amount)` calldata, which
33
+ * is a different (and simpler) shape. Sharing the `'swap'` sender name says
34
+ * "the same wallet, the same signing step in the flow" — it does not claim
35
+ * the existing builder already emits this calldata. Building that call is
36
+ * `settings.ts`'s job, out of this carrier's scope; this module answers only
37
+ * the route-search question ("what does this edge deliver, exactly").
38
+ */
39
+ /**
40
+ * Whether this carrier can wrap/unwrap directly between `from` and `to`:
41
+ * same Ethereum-Virtual-Machine chain, and the two addresses are EXACTLY the
42
+ * zero-address/wrapped-native pair `config.ts`'s `nativeAssetOut` names for
43
+ * that chain — nothing else, so a plain token pair that merely happens to
44
+ * share a chain is never mistaken for a wrap.
45
+ */
46
+ const wrapCarries = (from, to) => {
47
+ if (from.ecosystem !== 'evm' || to.ecosystem !== 'evm')
48
+ return false;
49
+ if (from.chainId !== to.chainId)
50
+ return false;
51
+ const wrappedNativeAddress = nativeAssetOut[toChain(from.chainId)];
52
+ if (wrappedNativeAddress === undefined)
53
+ return false;
54
+ const wrapped = getAddress(wrappedNativeAddress).toLowerCase();
55
+ const fromAddress = from.address.toLowerCase();
56
+ const toAddress = to.address.toLowerCase();
57
+ const fromIsNative = fromAddress === zeroAddress;
58
+ const toIsNative = toAddress === zeroAddress;
59
+ if (fromIsNative)
60
+ return toAddress === wrapped;
61
+ if (toIsNative)
62
+ return fromAddress === wrapped;
63
+ return false;
64
+ };
65
+ /** Every wrap/unwrap edge is worth exactly `amountIn`, in `edge.to`'s base units — native and wrapped share the same decimals on every chain this carrier has ever seen (18, both sides, everywhere `nativeAssetOut` names). */
66
+ const wrapExact = (edge, amountIn) => {
67
+ if (!wrapCarries(edge.from, edge.to))
68
+ return null;
69
+ return { outcome: 'delivered', deliveredAmount: amountIn };
70
+ };
71
+ /**
72
+ * Builds the native wrap/unwrap carrier as a plug-in.
73
+ *
74
+ * NO OPTIONS, NO `prepare`. There is no local read to batch — a wrap has no
75
+ * minimum, no maximum, no fee rate and no reserve to be wrong about; it is a
76
+ * pure 1:1 identity over every chain `config.ts` names a wrapped-native
77
+ * token for. `exact` alone is total over every edge this carrier offers.
78
+ *
79
+ * @returns the carrier — `id: 'wrap'`
80
+ */
81
+ export const createWrapCarrier = () => {
82
+ const edgesFrom = (from, to) => {
83
+ if (assetNodeKey(from) === assetNodeKey(to))
84
+ return [];
85
+ if (!wrapCarries(from, to))
86
+ return [];
87
+ return [{ carrier: 'wrap', from, to, fusesNext: false, origin: 'wallet', floors: [] }];
88
+ };
89
+ const ask = async ({ edge, inputAmount }) => {
90
+ const result = wrapExact(edge, inputAmount);
91
+ if (result === null || result.outcome === 'refused') {
92
+ return { ok: false, refusal: { kind: 'cannot-carry', reason: 'not a native/wrapped pair', minimumAmount: null } };
93
+ }
94
+ return {
95
+ ok: true,
96
+ deliveredAmount: result.deliveredAmount,
97
+ // 1:1, always — there is no slippage to guarantee against.
98
+ minimumDeliveredAmount: result.deliveredAmount,
99
+ claimedDurationSeconds: 0,
100
+ };
101
+ };
102
+ return {
103
+ id: 'wrap',
104
+ edgesFrom,
105
+ ask,
106
+ exact: wrapExact,
107
+ isRateLimited: false,
108
+ // A `deposit()`/`withdraw()` call requires the reader's own wallet
109
+ // signature — never a by-hand deposit.
110
+ depositMode: 'wallet-signed',
111
+ sender: 'swap',
112
+ // A same-chain call settles with the transaction itself.
113
+ settlementSeconds: 0,
114
+ };
115
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Whether a composed multi-signature route is good enough to offer at all.
3
+ *
4
+ * WHY A ROUTE NEEDS A GATE THAT A SINGLE CROSSING DOES NOT. A one-signature
5
+ * route that turns out badly costs the reader one transaction and they still
6
+ * hold something they chose. A composed route — an aggregator leg, then an
7
+ * omnibridge crossing, then a swap — strands them between signatures: after
8
+ * the first they hold an anchor token on a chain they did not ask for, and the
9
+ * only way out is to pay again. So a cheap first leg followed by a bad swap is
10
+ * worse than the route it replaced, and the difference has to be found BEFORE
11
+ * the route is listed rather than after the reader has signed.
12
+ *
13
+ * NOTHING HERE COSTS A REQUEST. Both figures the verdict reads are already in
14
+ * hand when a composer runs: the swap's price impact comes from the on-chain
15
+ * `getAmountsOut` pair the estimate already fetched, and the two dollar values
16
+ * come from the route's own basis. The gate is free, which is why it can run
17
+ * on every route rather than only on suspicious ones.
18
+ */
19
+ /**
20
+ * The price impact above which a composed route is withdrawn, in percent.
21
+ *
22
+ * MEASURED RATHER THAN CHOSEN. Across the anchor and size combinations swept
23
+ * on 2026-09-21 — bridged wrapped ether, USD Coin, DAI and USDT into wrapped
24
+ * PulseChain coin, at roughly forty and one hundred and forty United States
25
+ * dollars — the impact ran from 0.008 percent to 0.037 percent, with bridged
26
+ * USDT the one outlier at 0.34 to 0.37 percent. Three percent is two orders of
27
+ * magnitude above the normal case and an order above the worst observed, so it
28
+ * cannot fire on an ordinary route; and it is still far below the point where
29
+ * a reader is quietly losing real money on a leg they cannot undo.
30
+ *
31
+ * See `docs/near-intents-conversion-composition.md` for the full sweep.
32
+ */
33
+ export declare const COMPOSED_ROUTE_MAX_PRICE_IMPACT_PERCENT = 3;
34
+ /**
35
+ * The end-to-end value loss above which a composed route is withdrawn, in
36
+ * percent of what the reader pays.
37
+ *
38
+ * A SECOND TEST BECAUSE THE FIRST CAN READ FINE ON A BROKEN POOL. Price impact
39
+ * is measured against a marginal reference quote taken from the same pool, so
40
+ * a pool that is uniformly mispriced — a stale pair, a pair holding almost
41
+ * nothing — reports a small impact while paying out far less than the tokens
42
+ * going in are worth. Only comparing the two ends of the journey catches that.
43
+ *
44
+ * Measured whole-journey loss on the same sweep ran from 0.81 to 1.36 percent,
45
+ * of which the swap fee alone is 0.29. Five percent leaves every observed
46
+ * route untouched and still stops a route that is quietly eating a twentieth
47
+ * of the reader's money.
48
+ */
49
+ export declare const COMPOSED_ROUTE_MAX_VALUE_LOSS_PERCENT = 5;
50
+ /**
51
+ * The verdict on one composed route.
52
+ *
53
+ * THREE STATES RATHER THAN A BOOLEAN, because "we have not priced this yet"
54
+ * and "we priced it and it is bad" are different facts and a caller must be
55
+ * able to act differently on them. Collapsing them would either hide a route
56
+ * that is merely still loading or offer one nobody has checked.
57
+ */
58
+ export type ComposedRouteVerdict = {
59
+ readonly state: 'acceptable';
60
+ } | {
61
+ readonly state: 'unknown';
62
+ readonly missing: 'price-impact' | 'route-value';
63
+ } | {
64
+ readonly state: 'rejected';
65
+ readonly reason: 'price-impact' | 'value-loss';
66
+ /** The measured figure, in percent, that failed its ceiling. */
67
+ readonly measuredPercent: number;
68
+ /** The ceiling it failed. */
69
+ readonly ceilingPercent: number;
70
+ };
71
+ /**
72
+ * Judges a composed route against both ceilings.
73
+ *
74
+ * Checks price impact first and value loss second, because impact names the
75
+ * specific leg at fault while value loss only says the journey as a whole went
76
+ * wrong — so reporting impact when both fail is the more useful answer.
77
+ *
78
+ * @param options.priceImpactPercent - the swap leg's price impact, from the
79
+ * estimate's marginal reference quote; null when not yet quoted
80
+ * @param options.paidUsdValue - what the reader spends at the origin, in
81
+ * United States dollars; null when unpriced
82
+ * @param options.deliveredUsdValue - what the route delivers, valued on the
83
+ * SAME basis as `paidUsdValue`; null when unpriced
84
+ * @returns the verdict
85
+ */
86
+ export declare const assessComposedRoute: ({ priceImpactPercent, paidUsdValue, deliveredUsdValue, }: {
87
+ priceImpactPercent: number | null | undefined;
88
+ paidUsdValue: number | null | undefined;
89
+ deliveredUsdValue: number | null | undefined;
90
+ }) => ComposedRouteVerdict;
91
+ /**
92
+ * Whether a composed route may be listed on the strip.
93
+ *
94
+ * ONLY AN EXPLICIT PASS LISTS THE ROUTE. An unknown verdict withholds it, the
95
+ * same as a rejection, because a three-signature route nobody has checked is
96
+ * the case this gate exists for. The two remain distinct in
97
+ * {@link ComposedRouteVerdict} so a surface can tell the reader "still
98
+ * pricing" rather than "no route", which a boolean alone could not.
99
+ *
100
+ * @param verdict - the verdict from {@link assessComposedRoute}
101
+ * @returns true only when the route passed both ceilings
102
+ */
103
+ export declare const composedRouteMayBeOffered: (verdict: ComposedRouteVerdict) => boolean;