@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,329 @@
1
+ /**
2
+ * Does the live service still answer in the shape this repository parses?
3
+ *
4
+ * THIS EXISTS BECAUSE NEAR INTENTS HAS NO TEST NETWORK. The vendor states it
5
+ * four times in its own current material, most plainly in its list of common
6
+ * questions: "There is no testnet deployment and no plans for one. We
7
+ * recommend testing on NEAR mainnet using separate dev/test NEAR accounts."
8
+ * So there is no safe copy of the service to check an integration against, and
9
+ * the only honest substitute is a priced request against production that
10
+ * reserves nothing — a dry quote. See `docs/near-intents-capability-survey.md`.
11
+ *
12
+ * WHAT THIS CLOSES THAT THE FIXTURES CANNOT. `near-intents.test.ts` runs
13
+ * against real captures taken on 2026-09-17. A capture is a photograph: it
14
+ * proves the parser reads what the service sent that day, and it goes on
15
+ * proving that forever, including after the service stops sending it. Every
16
+ * parser here is tolerant by design — it returns null rather than throwing —
17
+ * so a field that changes type does not break a test, it removes a provider
18
+ * from the interface with no message anywhere. `amountOut` arriving as a
19
+ * number instead of a string is the whole failure: 37 green tests, and NEAR
20
+ * Intents silently never bids again.
21
+ *
22
+ * So these functions name every field the parsers actually depend on and
23
+ * report what is missing, rather than returning a normalized value. Handed a
24
+ * live response they answer "has production drifted away from the captures".
25
+ * Handed a capture they answer "is this capture still a fair stand-in".
26
+ *
27
+ * They are deliberately NOT parsers. A parser answers "can I use this"; these
28
+ * answer "is this what I was written against", which is a different question
29
+ * and needs the failures enumerated rather than collapsed into a null.
30
+ */
31
+ import { chainIdByNearIntentsChain } from './near-intents.js';
32
+ /** Which parser depends on a field, so a finding says what will break. */
33
+ const describe = (value) => {
34
+ if (value === undefined) {
35
+ return 'absent';
36
+ }
37
+ if (value === null) {
38
+ return 'null';
39
+ }
40
+ if (Array.isArray(value)) {
41
+ return `array of ${value.length}`;
42
+ }
43
+ return typeof value;
44
+ };
45
+ const asRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
46
+ ? value
47
+ : null;
48
+ /**
49
+ * Checks one field and returns a finding when it is not the required type.
50
+ *
51
+ * A base-unit amount must be a NON-EMPTY string, because that is what
52
+ * `parseAmount` in `near-intents.ts` requires: it refuses an empty string and
53
+ * refuses a number, so either one silently collapses the whole quote to null.
54
+ */
55
+ const requireField = ({ container, key, path, kind, }) => {
56
+ const value = container[key];
57
+ if (kind === 'amount') {
58
+ if (typeof value === 'string' && value !== '') {
59
+ return null;
60
+ }
61
+ return { path, expected: 'a non-empty string of base units', found: describe(value) };
62
+ }
63
+ if (kind === 'number') {
64
+ return typeof value === 'number'
65
+ ? null
66
+ : { path, expected: 'a number', found: describe(value) };
67
+ }
68
+ if (kind === 'array') {
69
+ return Array.isArray(value) ? null : { path, expected: 'an array', found: describe(value) };
70
+ }
71
+ return typeof value === 'string' && value !== ''
72
+ ? null
73
+ : { path, expected: 'a non-empty string', found: describe(value) };
74
+ };
75
+ const compact = (findings) => findings.filter((finding) => finding !== null);
76
+ /**
77
+ * Checks a `POST /v0/quote` answer against everything the quote parsers read.
78
+ *
79
+ * THE DOLLAR FIGURES ARE CHECKED THOUGH THE PARSER TOLERATES THEIR ABSENCE.
80
+ * `parseNearIntentsQuote` turns a missing `amountInUsd` into null and carries
81
+ * on, which is right for a quote and wrong for a conformance check: losing the
82
+ * dollar figure leaves the reader a route with no price beside it, and nothing
83
+ * else in the system would report that the service stopped sending one.
84
+ *
85
+ * @param payload - the raw JSON body the service answered with
86
+ * @param options.expectDepositAddress - true for an executable (`dry: false`)
87
+ * answer, false for a dry one. A dry answer omits the deposit address by
88
+ * design, so requiring it there would report a finding on a healthy service
89
+ * @returns every departure from the expected shape; empty means conformant
90
+ */
91
+ export const nearIntentsQuoteShapeFindings = (payload, { expectDepositAddress }) => {
92
+ const body = asRecord(payload);
93
+ if (!body) {
94
+ return [{ path: '', expected: 'an object', found: describe(payload) }];
95
+ }
96
+ const quote = asRecord(body.quote);
97
+ if (!quote) {
98
+ return [{ path: 'quote', expected: 'an object', found: describe(body.quote) }];
99
+ }
100
+ const findings = compact([
101
+ requireField({ container: quote, key: 'amountIn', path: 'quote.amountIn', kind: 'amount' }),
102
+ requireField({ container: quote, key: 'amountOut', path: 'quote.amountOut', kind: 'amount' }),
103
+ requireField({
104
+ container: quote,
105
+ key: 'minAmountOut',
106
+ path: 'quote.minAmountOut',
107
+ kind: 'amount',
108
+ }),
109
+ requireField({
110
+ container: quote,
111
+ key: 'withdrawFee',
112
+ path: 'quote.withdrawFee',
113
+ kind: 'amount',
114
+ }),
115
+ requireField({
116
+ container: quote,
117
+ key: 'timeEstimate',
118
+ path: 'quote.timeEstimate',
119
+ kind: 'number',
120
+ }),
121
+ requireField({
122
+ container: quote,
123
+ key: 'amountInUsd',
124
+ path: 'quote.amountInUsd',
125
+ kind: 'string',
126
+ }),
127
+ requireField({
128
+ container: quote,
129
+ key: 'amountOutUsd',
130
+ path: 'quote.amountOutUsd',
131
+ kind: 'string',
132
+ }),
133
+ expectDepositAddress
134
+ ? requireField({
135
+ container: quote,
136
+ key: 'depositAddress',
137
+ path: 'quote.depositAddress',
138
+ kind: 'string',
139
+ })
140
+ : null,
141
+ ]);
142
+ return [...findings, ...nearIntentsAppFeeFindings(body.quoteRequest)];
143
+ };
144
+ /**
145
+ * Checks the echoed request for the surcharge a key-less caller is charged.
146
+ *
147
+ * THE SERVICE INSERTS THIS, NOT US. Nothing in this repository asks for an
148
+ * `appFees` entry, and the fee parser only finds the twenty-five-basis-point
149
+ * charge because the service writes it into the request it echoes back. If it
150
+ * ever stops echoing it, the charge does not stop — it disappears from the
151
+ * fee rows and the reader is shown a route that costs a quarter of a percent
152
+ * more than the interface says. That is a silent understatement of cost, so it
153
+ * is a finding rather than a tolerated absence.
154
+ */
155
+ const nearIntentsAppFeeFindings = (quoteRequest) => {
156
+ const request = asRecord(quoteRequest);
157
+ if (!request) {
158
+ return [
159
+ { path: 'quoteRequest', expected: 'an object', found: describe(quoteRequest) },
160
+ ];
161
+ }
162
+ const appFees = request.appFees;
163
+ if (!Array.isArray(appFees)) {
164
+ return [{ path: 'quoteRequest.appFees', expected: 'an array', found: describe(appFees) }];
165
+ }
166
+ if (appFees.length === 0) {
167
+ return [
168
+ {
169
+ path: 'quoteRequest.appFees',
170
+ expected: 'at least one entry, the surcharge the service adds to a key-less request',
171
+ found: 'array of 0',
172
+ },
173
+ ];
174
+ }
175
+ return compact(appFees.map((entry, index) => {
176
+ const record = asRecord(entry);
177
+ if (!record) {
178
+ return {
179
+ path: `quoteRequest.appFees[${index}]`,
180
+ expected: 'an object',
181
+ found: describe(entry),
182
+ };
183
+ }
184
+ return requireField({
185
+ container: record,
186
+ key: 'fee',
187
+ path: `quoteRequest.appFees[${index}].fee`,
188
+ kind: 'number',
189
+ });
190
+ }));
191
+ };
192
+ /**
193
+ * Checks a `GET /v0/tokens` answer, including the chain names we map by hand.
194
+ *
195
+ * AN UNMAPPED CHAIN NAME IS THE DRIFT MOST LIKELY TO HAPPEN AND LEAST LIKELY
196
+ * TO BE NOTICED. `chainIdByNearIntentsChain` is a hand-written table, and
197
+ * `findNearIntentsAsset` matches through it — so if the service renames a
198
+ * chain or adds one, every asset on that chain becomes invisible to this
199
+ * repository while the asset list parses perfectly and every test stays green.
200
+ * The names are reported rather than the assets, because a new chain we do not
201
+ * carry is ordinary and a renamed chain we DO carry is the emergency.
202
+ *
203
+ * @param payload - the raw JSON body from the asset endpoint
204
+ * @param options.requiredChains - the service's own names for the chains this
205
+ * repository must keep reaching. A name missing from the answer means the
206
+ * service renamed or dropped it
207
+ * @returns every departure from the expected shape; empty means conformant
208
+ */
209
+ export const nearIntentsTokenListShapeFindings = (payload, { requiredChains }) => {
210
+ if (!Array.isArray(payload)) {
211
+ return [{ path: '', expected: 'an array of assets', found: describe(payload) }];
212
+ }
213
+ if (payload.length === 0) {
214
+ return [{ path: '', expected: 'at least one asset', found: 'array of 0' }];
215
+ }
216
+ const findings = [];
217
+ const chainsSeen = new Set();
218
+ payload.forEach((entry, index) => {
219
+ const record = asRecord(entry);
220
+ if (!record) {
221
+ findings.push({ path: `[${index}]`, expected: 'an object', found: describe(entry) });
222
+ return;
223
+ }
224
+ const blockchain = record.blockchain;
225
+ if (typeof blockchain === 'string') {
226
+ chainsSeen.add(blockchain);
227
+ }
228
+ findings.push(...compact([
229
+ requireField({
230
+ container: record,
231
+ key: 'assetId',
232
+ path: `[${index}].assetId`,
233
+ kind: 'string',
234
+ }),
235
+ requireField({
236
+ container: record,
237
+ key: 'blockchain',
238
+ path: `[${index}].blockchain`,
239
+ kind: 'string',
240
+ }),
241
+ requireField({ container: record, key: 'symbol', path: `[${index}].symbol`, kind: 'string' }),
242
+ requireField({
243
+ container: record,
244
+ key: 'decimals',
245
+ path: `[${index}].decimals`,
246
+ kind: 'number',
247
+ }),
248
+ ]));
249
+ });
250
+ for (const chain of requiredChains) {
251
+ if (chainsSeen.has(chain)) {
252
+ continue;
253
+ }
254
+ findings.push({
255
+ path: `chain "${chain}"`,
256
+ expected: 'present in the asset list, because chainIdByNearIntentsChain maps it',
257
+ found: 'absent — the service renamed or dropped it',
258
+ });
259
+ }
260
+ return findings;
261
+ };
262
+ /**
263
+ * The service's own names for every chain this repository maps to a chain
264
+ * identifier, which is exactly the set whose renaming would be invisible.
265
+ */
266
+ export const nearIntentsMappedChainNames = () => [
267
+ ...chainIdByNearIntentsChain.keys(),
268
+ ];
269
+ /**
270
+ * Checks a `GET /v0/status` answer against what the status parser reads.
271
+ *
272
+ * THE DELIVERY HASH IS NOT REQUIRED, and that is not laxity. A route awaiting
273
+ * its deposit has genuinely not produced one, and the captured
274
+ * `status-pending-deposit.json` is exactly that case — so requiring it would
275
+ * report a finding on a perfectly healthy in-flight transfer. What is required
276
+ * is that the ARRAY exists, because its absence is the difference between "no
277
+ * hash yet" and "the service stopped telling us where the funds landed".
278
+ *
279
+ * @param payload - the raw JSON body from the status endpoint
280
+ * @returns every departure from the expected shape; empty means conformant
281
+ */
282
+ export const nearIntentsStatusShapeFindings = (payload) => {
283
+ const body = asRecord(payload);
284
+ if (!body) {
285
+ return [{ path: '', expected: 'an object', found: describe(payload) }];
286
+ }
287
+ const findings = compact([
288
+ requireField({ container: body, key: 'status', path: 'status', kind: 'string' }),
289
+ ]);
290
+ const swapDetails = asRecord(body.swapDetails);
291
+ if (!swapDetails) {
292
+ return [
293
+ ...findings,
294
+ { path: 'swapDetails', expected: 'an object', found: describe(body.swapDetails) },
295
+ ];
296
+ }
297
+ return [
298
+ ...findings,
299
+ ...compact([
300
+ requireField({
301
+ container: swapDetails,
302
+ key: 'destinationChainTxHashes',
303
+ path: 'swapDetails.destinationChainTxHashes',
304
+ kind: 'array',
305
+ }),
306
+ ]),
307
+ ];
308
+ };
309
+ /**
310
+ * Checks a refusal body carries the message the refusal parser reads.
311
+ *
312
+ * BOTH SIZE FLOORS ARRIVE ONLY AS PROSE IN THIS FIELD. There is no code, no
313
+ * limits endpoint and no structured figure — `parseNearIntentsQuoteRefusal`
314
+ * runs a regular expression over the sentence. So if the wording changes, a
315
+ * refusal that should withdraw the route from the competition instead reads as
316
+ * `other`, and the reader waits for a bid that will never come.
317
+ *
318
+ * @param payload - the raw JSON body of a refused quote
319
+ * @returns every departure from the expected shape; empty means conformant
320
+ */
321
+ export const nearIntentsRefusalShapeFindings = (payload) => {
322
+ const body = asRecord(payload);
323
+ if (!body) {
324
+ return [{ path: '', expected: 'an object', found: describe(payload) }];
325
+ }
326
+ return compact([
327
+ requireField({ container: body, key: 'message', path: 'message', kind: 'string' }),
328
+ ]);
329
+ };
@@ -0,0 +1,47 @@
1
+ import type { DeferredCrossing, RouteCandidate } from './routing/candidate.js';
2
+ import type { RouteData } from './routing/route-data.js';
3
+ /**
4
+ * Builds the two-signature route for a candidate whose aggregator cannot
5
+ * execute the omnibridge entry call.
6
+ *
7
+ * The result has exactly two sections, and the order carries the meaning:
8
+ *
9
+ * 1. The aggregator's leg, signed NOW. It ends with the anchor token sitting
10
+ * in the reader's own wallet on the entry chain.
11
+ * 2. The omnibridge crossing, marked `deferred`, signed LATER by the reader.
12
+ *
13
+ * COSTS ARE SPLIT BETWEEN THE SECTIONS RATHER THAN POOLED, because a reader
14
+ * deciding whether to accept a second signature needs to know what each half
15
+ * costs. The first section's spread is the difference between the aggregator's
16
+ * own paid and delivered figures — both from the one answer that priced them,
17
+ * never from two feeds. The second section's cost is the omnibridge crossing
18
+ * fee, computed from the known on-chain rate.
19
+ *
20
+ * THE DELIVERED FIGURE IS AN ESTIMATE AND THE INTERFACE KNOWS IT. A route whose
21
+ * last section is deferred has not been quoted end to end, so
22
+ * `outputAmountIsUnsettled` blanks the received-amount field on the reader's
23
+ * behalf. The figure is still carried — the conversion route sets its estimate
24
+ * the same way — so the route can be ranked and compared.
25
+ *
26
+ * @param candidate - the route candidate, which must carry a `deferredCrossing`
27
+ * @returns the two-section route, or null when the candidate names no deferred
28
+ * crossing or no omnibridge provider to perform it
29
+ */
30
+ export declare const buildNearIntentsTwoStepRouteData: (candidate: RouteCandidate) => RouteData | null;
31
+ /**
32
+ * Describes the anchor a two-signature route leaves the reader holding.
33
+ *
34
+ * A small constructor rather than an inline object literal at the call site,
35
+ * so the three fields are named and documented in one place and a caller
36
+ * cannot quietly pair one aggregator's amount with another's valuation.
37
+ *
38
+ * @param options.anchorToken - the entry-chain token the aggregator delivers
39
+ * @param options.anchorAmount - how much of it lands, in its base units
40
+ * @param options.anchorUsdValue - the AGGREGATOR'S own valuation of that landing
41
+ * @returns the deferred-crossing record, or null when no amount is known
42
+ */
43
+ export declare const describeDeferredCrossing: ({ anchorToken, anchorAmount, anchorUsdValue, }: {
44
+ anchorToken: DeferredCrossing["anchorToken"] | null;
45
+ anchorAmount: bigint | null | undefined;
46
+ anchorUsdValue: string | null | undefined;
47
+ }) => DeferredCrossing | null;
@@ -0,0 +1,265 @@
1
+ import * as imageLinks from './image-links.js';
2
+ import { BRIDGE_SETTLEMENT_ETA_MINUTES, providerDisplayName, pulsechainChainId, subtractUsdValues, } from './routing/producers.js';
3
+ import { providerById } from './providers.js';
4
+ import { usdValueOfAmount } from './routing/usd-value.js';
5
+ import { chainDisplayName } from './chain-names.js';
6
+ /**
7
+ * The route shape for an aggregator that cannot execute on the chain it
8
+ * delivers to.
9
+ *
10
+ * WHY THIS EXISTS AT ALL. Every other consolidated route in this repository is
11
+ * ONE signature: the aggregator carries the reader's funds to an omnibridge
12
+ * entry chain and its executor performs the entry call on arrival, so the
13
+ * crossing into PulseChain happens inside the same transaction the reader
14
+ * already signed. NEAR Intents cannot do that — it delivers tokens to an
15
+ * address and runs nothing — and no arrangement of recipients works around it,
16
+ * because every omnibridge entry point learns who the reader is from a data
17
+ * field and a plain token transfer has none. See the head of `near-intents.ts`.
18
+ *
19
+ * So the crossing becomes TWO signatures, and the honest thing is to draw it
20
+ * that way. The aggregator lands the anchor token in the reader's OWN wallet on
21
+ * the entry chain; the reader then signs the ordinary omnibridge call
22
+ * themselves. The interface already renders exactly this shape — a section
23
+ * marked `deferred` draws "You sign this later" on the route itself rather than
24
+ * hiding the fact in a tooltip — so nothing new had to be invented to show it.
25
+ *
26
+ * WHAT THIS MUST NEVER DO is present the second signature as optional, or let
27
+ * the route claim a delivered amount as though it were settled. The reader is
28
+ * mid-journey until they sign again, and a route that implies otherwise is
29
+ * selling a single-signature experience it does not deliver.
30
+ */
31
+ /**
32
+ * Turns a token into the display node the route strip draws, resolving its logo
33
+ * here rather than in the view.
34
+ *
35
+ * Pre-resolved deliberately: a view-layer image fallback reaches only the nodes
36
+ * a builder left unset, so a builder that sets the field and a builder that
37
+ * does not produce different pictures for the same token.
38
+ *
39
+ * The chain badge goes through `networkOrPseudo`, not `network` — a deposit-
40
+ * address origin (Zcash) carries a local, invented chain id the numeric
41
+ * endpoint 404s on. See that helper's own note for why this lives in
42
+ * `image-links.ts` rather than the richer client-side resolver.
43
+ */
44
+ const tokenNode = (token, chainId) => ({
45
+ symbol: token.symbol,
46
+ address: token.address,
47
+ chainId,
48
+ decimals: token.decimals,
49
+ // `tokenImageOrChainFallback`, NOT `image` — the ANCHOR node this call also
50
+ // draws can reach here before its exact address is known (the deferred
51
+ // crossing has not settled yet), and `image` either throws on an address it
52
+ // cannot checksum or draws nothing at all. See that helper's own note.
53
+ logoUri: imageLinks.tokenImageOrChainFallback(token),
54
+ chainLogoUri: imageLinks.networkOrPseudo(chainId),
55
+ });
56
+ /**
57
+ * Parses one of the aggregator's own dollar figures.
58
+ *
59
+ * They arrive as decimal strings. A figure that will not parse becomes null
60
+ * rather than zero, because zero is a real value that reads as "this leg is
61
+ * free" — which is the one thing a cost figure must never say by accident.
62
+ */
63
+ const usdNumber = (value) => {
64
+ if (typeof value !== 'string' || value === '') {
65
+ return null;
66
+ }
67
+ const parsed = Number(value);
68
+ return Number.isFinite(parsed) ? parsed : null;
69
+ };
70
+ /**
71
+ * Builds the two-signature route for a candidate whose aggregator cannot
72
+ * execute the omnibridge entry call.
73
+ *
74
+ * The result has exactly two sections, and the order carries the meaning:
75
+ *
76
+ * 1. The aggregator's leg, signed NOW. It ends with the anchor token sitting
77
+ * in the reader's own wallet on the entry chain.
78
+ * 2. The omnibridge crossing, marked `deferred`, signed LATER by the reader.
79
+ *
80
+ * COSTS ARE SPLIT BETWEEN THE SECTIONS RATHER THAN POOLED, because a reader
81
+ * deciding whether to accept a second signature needs to know what each half
82
+ * costs. The first section's spread is the difference between the aggregator's
83
+ * own paid and delivered figures — both from the one answer that priced them,
84
+ * never from two feeds. The second section's cost is the omnibridge crossing
85
+ * fee, computed from the known on-chain rate.
86
+ *
87
+ * THE DELIVERED FIGURE IS AN ESTIMATE AND THE INTERFACE KNOWS IT. A route whose
88
+ * last section is deferred has not been quoted end to end, so
89
+ * `outputAmountIsUnsettled` blanks the received-amount field on the reader's
90
+ * behalf. The figure is still carried — the conversion route sets its estimate
91
+ * the same way — so the route can be ranked and compared.
92
+ *
93
+ * @param candidate - the route candidate, which must carry a `deferredCrossing`
94
+ * @returns the two-section route, or null when the candidate names no deferred
95
+ * crossing or no omnibridge provider to perform it
96
+ */
97
+ export const buildNearIntentsTwoStepRouteData = (candidate) => {
98
+ const deferredCrossing = candidate.deferredCrossing;
99
+ if (!deferredCrossing) {
100
+ return null;
101
+ }
102
+ // No omnibridge leg means there is no second signature, which means this is
103
+ // not the shape being built. A route the aggregator carries end to end is an
104
+ // ordinary one-signature route and belongs to `buildCandidateRouteData`.
105
+ const omnibridgeProviderKey = candidate.omnibridgeProvider;
106
+ if (omnibridgeProviderKey === null) {
107
+ return null;
108
+ }
109
+ const { originToken, destinationToken } = candidate;
110
+ const { anchorToken, anchorAmount, anchorUsdValue } = deferredCrossing;
111
+ const originNode = tokenNode(originToken, originToken.chainId);
112
+ const anchorNode = tokenNode(anchorToken, candidate.entryChainId);
113
+ const destinationNode = tokenNode(destinationToken, pulsechainChainId);
114
+ const paidUsd = usdNumber(candidate.quote.fromAmountUsd);
115
+ const landedUsd = usdNumber(anchorUsdValue);
116
+ // The aggregator's spread on its own leg: what the reader paid, less what the
117
+ // aggregator says arrives. Both figures come from the same answer, so the
118
+ // difference is the route and nothing else.
119
+ const aggregatorFeeUsd = subtractUsdValues(paidUsd, landedUsd);
120
+ const crossingFeeUsd = usdValueOfAmount({
121
+ balance: candidate.omnibridgeFeeAmount,
122
+ decimals: destinationToken.decimals,
123
+ price: candidate.destinationUsdPrice,
124
+ });
125
+ const etaSeconds = candidate.quote.executionDurationSeconds > 0 ? candidate.quote.executionDurationSeconds : null;
126
+ const etaMinutes = candidate.quote.executionDurationSeconds > 0
127
+ ? Math.ceil(candidate.quote.executionDurationSeconds / 60)
128
+ : null;
129
+ const aggregatorHop = {
130
+ // THIS HOP IS THE AGGREGATOR, NOT THE OMNIBRIDGE — `providerDisplayName`
131
+ // (used for the crossing hop below) is keyed only on `Providers.PULSECHAIN`
132
+ // / `Providers.TOKENSEX` and falls back to the generic "Omnibridge" for
133
+ // anything else. Run through it here, `candidate.quote.provider` (a
134
+ // `ProviderId` — 'lifi' | 'relay' | 'near-intents') had no entry, so a
135
+ // NEAR Intents two-step route named its first leg "Omnibridge" — the
136
+ // bridge, not the aggregator that actually carried it. `providerById` is
137
+ // total over `ProviderId` and throws on an unregistered one rather than
138
+ // silently naming a different provider.
139
+ providerName: providerById(candidate.quote.provider).label,
140
+ providerKey: candidate.quote.provider,
141
+ title: `Send to ${chainDisplayName(candidate.entryChainId)}`,
142
+ conversion: `→ ${anchorToken.symbol}`,
143
+ fromToken: originNode,
144
+ toToken: anchorNode,
145
+ feeUsdValue: aggregatorFeeUsd,
146
+ // The reader's own signature starts this leg; nothing runs inside it.
147
+ automatic: false,
148
+ };
149
+ const aggregatorSection = {
150
+ id: `${candidate.id}-signature-1`,
151
+ // Named for where the funds END UP, and pointedly NOT "Bridge to
152
+ // PulseChain": this signature does not reach PulseChain and a label that
153
+ // said so would be the single-signature claim this route exists to avoid.
154
+ label: `Send to ${chainDisplayName(candidate.entryChainId)}`,
155
+ providerKey: candidate.quote.provider,
156
+ tokenFlow: [originNode, anchorNode],
157
+ // WHAT THE READER IS LEFT HOLDING between the two signatures, in their own
158
+ // wallet on the entry chain. This is the step's whole point.
159
+ landing: {
160
+ amount: anchorAmount,
161
+ decimals: anchorToken.decimals,
162
+ symbol: anchorToken.symbol,
163
+ usdValue: landedUsd,
164
+ },
165
+ feeUsdValue: aggregatorFeeUsd,
166
+ fees: candidate.quote.fees,
167
+ etaMinutes,
168
+ etaSeconds,
169
+ hops: [aggregatorHop],
170
+ };
171
+ const crossingHop = {
172
+ providerName: providerDisplayName(omnibridgeProviderKey),
173
+ providerKey: omnibridgeProviderKey,
174
+ title: `Bridge to ${chainDisplayName(pulsechainChainId)}`,
175
+ conversion: `→ ${destinationToken.symbol}`,
176
+ fromToken: anchorNode,
177
+ toToken: destinationNode,
178
+ feeUsdValue: crossingFeeUsd,
179
+ // NOT automatic, and this is the difference the whole route turns on. On a
180
+ // one-signature route the aggregator's executor makes this call; here the
181
+ // reader does, by hand, after the anchor lands.
182
+ automatic: false,
183
+ };
184
+ const crossingSection = {
185
+ id: `${candidate.id}-signature-2`,
186
+ label: `Bridge to ${chainDisplayName(pulsechainChainId)}`,
187
+ providerKey: omnibridgeProviderKey,
188
+ tokenFlow: [anchorNode, destinationNode],
189
+ landing: {
190
+ amount: candidate.deliveredAmount,
191
+ decimals: destinationToken.decimals,
192
+ symbol: destinationToken.symbol,
193
+ usdValue: subtractUsdValues(landedUsd, crossingFeeUsd),
194
+ },
195
+ feeUsdValue: crossingFeeUsd,
196
+ // THE FLAG THAT MAKES THE ROUTE HONEST. It is what draws "You sign this
197
+ // later" on the step, what makes the primary button name the step it
198
+ // actually signs, and what stops any surface stating the delivered amount
199
+ // as settled. Removing it would turn this into a route that silently
200
+ // promises one signature and asks for two.
201
+ deferred: true,
202
+ // `sectionDescription` in the route strip reads `section.deferredNote ??
203
+ // defaultDeferredNote()`, so a note supplied HERE replaces the default
204
+ // sentence outright rather than adding to it. The default sentence is
205
+ // "Fee is approximate — quoted when you return." — the one fact that
206
+ // tells the reader the output figure they are looking at is not settled.
207
+ // A note that names only the second signature and drops that fact leaves
208
+ // a settled-looking figure on screen with nothing anywhere saying
209
+ // otherwise, so this note must carry BOTH facts itself.
210
+ deferredNote: `The bridge crossing is a second signature you make once the funds arrive on ${chainDisplayName(candidate.entryChainId)}. The amount shown is approximate and is quoted again when you return.`,
211
+ // The crossing's own settlement wait, which is the long part of the journey
212
+ // and has nothing to do with the aggregator's estimate above.
213
+ etaMinutes: BRIDGE_SETTLEMENT_ETA_MINUTES,
214
+ hops: [crossingHop],
215
+ };
216
+ const totalCostUsdValue = aggregatorFeeUsd === null && crossingFeeUsd === null
217
+ ? null
218
+ : (aggregatorFeeUsd ?? 0) + (crossingFeeUsd ?? 0);
219
+ return {
220
+ kind: 'aggregated',
221
+ entryChainId: candidate.entryChainId,
222
+ // Named after the omnibridge, matching the one-signature route: a route is
223
+ // named for the thing that performs its defining leg.
224
+ headlineProviderKey: omnibridgeProviderKey,
225
+ sections: [aggregatorSection, crossingSection],
226
+ output: {
227
+ amount: candidate.deliveredAmount,
228
+ decimals: destinationToken.decimals,
229
+ symbol: destinationToken.symbol,
230
+ // The source basis, carried the whole way: what was paid, less what each
231
+ // leg costs. Re-pricing the delivered token against its own feed would
232
+ // state this route's cost twice and the two would not agree.
233
+ usdValue: subtractUsdValues(paidUsd, totalCostUsdValue),
234
+ },
235
+ totalCostUsdValue,
236
+ // Ranked on the same net figure as every other route: delivered value less
237
+ // everything the journey costs.
238
+ netValueUsd: subtractUsdValues(paidUsd, totalCostUsdValue),
239
+ // The reader's wait is BOTH legs, not the aggregator's leg alone. A route
240
+ // that reported only the first would claim to finish before its second
241
+ // signature had even been asked for.
242
+ etaMinutes: (etaMinutes ?? 0) + BRIDGE_SETTLEMENT_ETA_MINUTES,
243
+ };
244
+ };
245
+ /**
246
+ * Describes the anchor a two-signature route leaves the reader holding.
247
+ *
248
+ * A small constructor rather than an inline object literal at the call site,
249
+ * so the three fields are named and documented in one place and a caller
250
+ * cannot quietly pair one aggregator's amount with another's valuation.
251
+ *
252
+ * @param options.anchorToken - the entry-chain token the aggregator delivers
253
+ * @param options.anchorAmount - how much of it lands, in its base units
254
+ * @param options.anchorUsdValue - the AGGREGATOR'S own valuation of that landing
255
+ * @returns the deferred-crossing record, or null when no amount is known
256
+ */
257
+ export const describeDeferredCrossing = ({ anchorToken, anchorAmount, anchorUsdValue, }) => {
258
+ if (anchorToken === null) {
259
+ return null;
260
+ }
261
+ if (anchorAmount === null || anchorAmount === undefined || anchorAmount <= 0n) {
262
+ return null;
263
+ }
264
+ return { anchorToken, anchorAmount, anchorUsdValue: anchorUsdValue ?? null };
265
+ };