@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.
Files changed (118) 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 +60 -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 +149 -0
  65. package/dist/routing/carriers/pulsex.js +453 -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 +653 -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 +353 -0
  89. package/dist/routing/search/score.js +427 -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/llms.txt +169 -0
  101. package/package.json +204 -23
  102. package/src/chain-info.test.ts +0 -968
  103. package/src/chain-info.ts +0 -498
  104. package/src/chains.test.ts +0 -123
  105. package/src/chains.ts +0 -88
  106. package/src/config.test.ts +0 -679
  107. package/src/config.ts +0 -500
  108. package/src/fee-type.test.ts +0 -45
  109. package/src/fee-type.ts +0 -14
  110. package/src/image-links.test.ts +0 -160
  111. package/src/image-links.ts +0 -65
  112. package/src/index.ts +0 -8
  113. package/src/settings.test.ts +0 -2754
  114. package/src/settings.ts +0 -875
  115. package/src/types.ts +0 -48
  116. package/tsconfig.json +0 -17
  117. package/vitest.config.ts +0 -13
  118. /package/{src/abis.ts → dist/abis.js} +0 -0
@@ -0,0 +1,983 @@
1
+ import { decodeFunctionData, encodeFunctionData, erc20Abi, maxUint256, zeroAddress, } from 'viem';
2
+ import { Chains } from './config.js';
3
+ import { isNonEthereumVirtualMachineOrigin, } from './consolidated-quote.js';
4
+ import { defaultReferralFeeBasisPoints, referralIntegrator, resolveAttribution } from './referral.js';
5
+ import { depositAddressChainIds, relayChainIds, } from './chain-names.js';
6
+ import { normalizeRelaySteps } from './route-steps.js';
7
+ import { relayQuoteFees } from './quote-fees.js';
8
+ /**
9
+ * Relay (relay.link) as a second consolidated-route quote provider, competing
10
+ * with LI.FI on price. Relay quotes the same backwards-from-destination route:
11
+ * deliver exactly `toAmount` of the Ethereum-side token, then run our omnibridge
12
+ * entry call against it.
13
+ *
14
+ * One semantic differs from LI.FI and the difference is load-bearing. Relay has
15
+ * NO destination-chain fallback address. When the destination call cannot fill,
16
+ * Relay refunds on the ORIGIN chain to `refundTo`, in the origin currency — the
17
+ * user ends up back where they started, not holding the Ethereum-side token at
18
+ * a fallback. We therefore map our `fallbackAddress` onto `refundTo`; a failed
19
+ * Relay route is recovered on the origin chain, never stranded on Ethereum.
20
+ */
21
+ /**
22
+ * Relay's API root. Steps carry RELATIVE post endpoints (`/authorize`), so a
23
+ * caller completing one has to resolve it against this.
24
+ */
25
+ export const relayApiBaseUrl = 'https://api.relay.link';
26
+ /** Relay quote endpoint. Anonymous access works at a 50-requests-per-minute tier. */
27
+ export const relayQuoteUrl = `${relayApiBaseUrl}/quote/v2`;
28
+ /** Relay requests endpoint; filters by origin or destination transaction hash. */
29
+ /**
30
+ * The request-lookup endpoint, used to poll a route's progress.
31
+ *
32
+ * v3 RATHER THAN v2 BECAUSE v2 IS GOING AWAY. Relay began staged rate limiting
33
+ * of `/requests/v2` on 2026-09-01 and retires it on 2026-11-24, saying so in a
34
+ * `deprecation` block on every v2 response. v3 REQUIRES `x-api-key` - it
35
+ * answers 400 without one, where v2 served anonymous callers from a shared
36
+ * pool - so a caller must send `relayRequestHeaders(yourRelayApiKey)`.
37
+ */
38
+ export const relayRequestsUrl = 'https://api.relay.link/requests/v3';
39
+ /**
40
+ * Builds the headers for a Relay request. `apiKey` is Relay's `x-api-key`
41
+ * value. This package ships no key: the caller supplies its own (the Gibs
42
+ * interface keeps its key in `packages/ui/src/lib/config.ts`).
43
+ * Relay raises the account's rate limit and attributes traffic to it when the
44
+ * header is present.
45
+ *
46
+ * When `apiKey` is absent or an empty string, the returned headers carry NO
47
+ * `x-api-key` entry at all — not an empty one.
48
+ *
49
+ * WHETHER THAT IS SURVIVABLE DEPENDS ENTIRELY ON WHICH ENDPOINT IS BEING CALLED,
50
+ * and this note used to say only the reassuring half. Measured against the live
51
+ * service:
52
+ *
53
+ * POST /quote no key -> 200 routing works anonymously
54
+ * GET /requests/v3 no key -> 400 "headers must have required
55
+ * property 'x-api-key'"
56
+ *
57
+ * So a missing key is a normal operating state for ROUTING — a reader on a
58
+ * local build or the anonymous InterPlanetary File System deployment still gets
59
+ * real quotes, and clearing the key disables attribution rather than the
60
+ * product. It is NOT survivable for STATUS. Every status poll from a keyless
61
+ * build fails, which means a consolidated record never learns its provider
62
+ * finished, never records the entry-chain transaction, and never releases from
63
+ * the pending list. That is not a degraded rate limit; it is the feature not
64
+ * working at all, silently.
65
+ */
66
+ export const relayRequestHeaders = (apiKey) => {
67
+ const headers = { 'Content-Type': 'application/json' };
68
+ if (apiKey) {
69
+ headers['x-api-key'] = apiKey;
70
+ }
71
+ return headers;
72
+ };
73
+ /**
74
+ * Gibs Finance's own Relay `referrer`, for callers that want to attribute to
75
+ * Gibs Finance. The request builders do NOT default to it: pass it, or your
76
+ * own, as `relayReferrer` on the request. Relay reads any `referrer` as "a
77
+ * referrer was supplied" and then requires an API key, so a default would
78
+ * break every caller without one.
79
+ */
80
+ export const relayReferrer = referralIntegrator;
81
+ /**
82
+ * Application fee in basis points charged on Relay routes. Ten basis points is
83
+ * one tenth of a percent. Sent as `appFees[0].fee` when a payout recipient is
84
+ * configured; absent entirely when the recipient is missing.
85
+ */
86
+ export const relayReferralFeeBasisPoints = defaultReferralFeeBasisPoints;
87
+ export { relayReferralRecipient } from './referral.js';
88
+ /** Extra gas added to the destination budget to cover a prepended approval call. */
89
+ export const relayApprovalGasBuffer = 60000n;
90
+ /**
91
+ * Relay's chain identifiers and their display names live in `chain-names.ts`
92
+ * so the route-step normalizer can name a Relay chain without importing this
93
+ * module, which imports it. Re-exported here because callers reach for them
94
+ * alongside the rest of the Relay surface.
95
+ */
96
+ export { relayChainIds, relayChainNames } from './chain-names.js';
97
+ /**
98
+ * Relay's pseudo chain identifiers, mapped to the ecosystem each belongs to.
99
+ * Data rather than a branch chain, so registering another Relay origin is one
100
+ * entry here instead of another conditional.
101
+ */
102
+ const ecosystemByRelayChainId = new Map([
103
+ [relayChainIds.solana, 'svm'],
104
+ // two chain ids, one ecosystem: the mapping is many-to-one by design
105
+ [relayChainIds.eclipse, 'svm'],
106
+ [relayChainIds.bitcoin, 'bvm'],
107
+ [relayChainIds.tron, 'tvm'],
108
+ [relayChainIds.ton, 'tonvm'],
109
+ [relayChainIds.hyperliquid, 'hypevm'],
110
+ ]);
111
+ /**
112
+ * The chains a reader pays by hand, mapped to the ecosystem each belongs to.
113
+ *
114
+ * KEPT APART FROM THE RELAY MAP ABOVE, because none of these is a Relay chain
115
+ * and a map named for Relay should not claim one. They are here at all because
116
+ * {@link ecosystemByChainId} answers `evm` for every identifier it does not
117
+ * know. An unregistered Zcash identifier would therefore read as an Ethereum
118
+ * chain — which has a wallet, a signer and a transaction — and the reader would
119
+ * be asked to sign on a chain they do not hold.
120
+ *
121
+ * Typed `Record<DepositAddressChain, Ecosystem>` rather than `Map<number, …>`
122
+ * so a chain added to {@link depositAddressChainIds} without an ecosystem
123
+ * here is a compile error. This is the table finding this danger is about: a
124
+ * silent miss is exactly the "reads as Ethereum" failure the comment above
125
+ * warns against, and a compile error is the only guard that cannot be
126
+ * forgotten.
127
+ */
128
+ const ecosystemByDepositAddressChain = {
129
+ zcash: 'zcash',
130
+ };
131
+ /** {@link ecosystemByDepositAddressChain}, keyed by chain id for lookup. */
132
+ const ecosystemByDepositAddressChainId = new Map(Object.keys(depositAddressChainIds).map((chain) => [
133
+ depositAddressChainIds[chain],
134
+ ecosystemByDepositAddressChain[chain],
135
+ ]));
136
+ /**
137
+ * Maps a chain identifier to its virtual-machine ecosystem. Relay's non-Ethereum
138
+ * pseudo chain identifiers map to their own ecosystem; every other identifier
139
+ * (every Ethereum Virtual Machine chain) maps to `evm`. Total — never throws.
140
+ *
141
+ * Lives here rather than in `ecosystems.ts` because it depends on
142
+ * `relayChainIds` (defined in this file); the `Ecosystem` import is type-only,
143
+ * so `ecosystems.ts` stays a dependency-free leaf and no import cycle is formed.
144
+ *
145
+ * @param chainId - a chain identifier (an Ethereum chain id, or a Relay pseudo id)
146
+ * @returns the ecosystem that chain belongs to
147
+ */
148
+ export const ecosystemByChainId = (chainId) => ecosystemByRelayChainId.get(chainId) ?? ecosystemByDepositAddressChainId.get(chainId) ?? 'evm';
149
+ /**
150
+ * Relay's native-currency address strings, used verbatim as `originCurrency`
151
+ * when the origin pays in the chain's native asset.
152
+ *
153
+ * Source: https://api.relay.link/chains.
154
+ */
155
+ export const relayNativeCurrencyAddresses = {
156
+ /**
157
+ * Ethereum Virtual Machine native currency (Ether, native gas tokens). The
158
+ * all-zero address Relay uses across every Ethereum Virtual Machine chain.
159
+ */
160
+ ethereumVirtualMachine: '0x0000000000000000000000000000000000000000',
161
+ /**
162
+ * Solana native currency (SOL), addressed by the System Program. Nine
163
+ * decimals.
164
+ */
165
+ solana: '11111111111111111111111111111111',
166
+ /** Bitcoin native currency (BTC). Eight decimals. */
167
+ bitcoin: 'bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqmql8k8',
168
+ /** Tron native currency (TRX). Six decimals. */
169
+ tron: 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb',
170
+ /**
171
+ * The Open Network's native currency as Relay reports it: Gram, NOT the
172
+ * Toncoin gas asset. Nine decimals.
173
+ */
174
+ ton: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c',
175
+ /**
176
+ * Hyperliquid's native currency as Relay reports it: USD Coin, NOT the Hype
177
+ * gas asset, addressed by a sixteen-byte HyperCore token identifier. Eight
178
+ * decimals. Deliberately NOT the twenty-byte all-zero Ethereum address.
179
+ */
180
+ hyperliquid: '0x00000000000000000000000000000000',
181
+ };
182
+ /**
183
+ * Converts a slippage fraction into the basis-point string Relay expects.
184
+ *
185
+ * @param slippage - tolerance as a fraction (0.005 means half a percent)
186
+ * @returns basis points as a string (0.005 becomes "50")
187
+ */
188
+ export const slippageToBasisPoints = (slippage) => Math.round(slippage * 10_000).toString();
189
+ /**
190
+ * The `referrer` field of a Relay request body, present only when the caller
191
+ * named one. Relay reads any `referrer`, an empty one included, as "a referrer
192
+ * was supplied" and then requires an API key, so absence is the safe default.
193
+ *
194
+ * @param request - carries the caller's `relayReferrer`, if any
195
+ * @returns `{ referrer }`, or an empty object
196
+ */
197
+ export const relayReferrerField = (request) => {
198
+ const referrer = resolveAttribution(request.relayReferrer, undefined);
199
+ return referrer === undefined ? {} : { referrer };
200
+ };
201
+ /**
202
+ * Builds the Relay contract-call quote request from a provider-agnostic
203
+ * consolidated request. The omnibridge entry call becomes the final `txs`
204
+ * entry; when it needs an ERC20 allowance (the relayTokens shape, where
205
+ * `approvalTarget` is set) an `approve` call is prepended so Relay's executor
206
+ * grants it before relaying.
207
+ *
208
+ * @returns the request body, or null when the inputs cannot form a valid call
209
+ */
210
+ export const buildRelayQuoteRequest = (request) => {
211
+ const { destinationCall, toAmount } = request;
212
+ // A DIRECT ROUTE — no omnibridge leg, so nothing to call on arrival.
213
+ //
214
+ // Solved FORWARDS rather than backwards, which is the whole difference. A
215
+ // route into the omnibridge must deliver an exact amount to the entry call,
216
+ // so it asks Relay for an exact OUTPUT. Here the reader says what they are
217
+ // spending and the delivered amount is whatever the market gives, so it asks
218
+ // for an exact INPUT and names a `recipient` instead of a transaction to run.
219
+ if (destinationCall === undefined) {
220
+ const fromAmount = request.fromAmount;
221
+ if (fromAmount === undefined || fromAmount <= 0n) {
222
+ return null;
223
+ }
224
+ const nonEthereumOriginDirect = isNonEthereumVirtualMachineOrigin(request);
225
+ return {
226
+ user: request.fromAddress,
227
+ originChainId: request.fromChainId,
228
+ originCurrency: request.fromTokenAddress,
229
+ destinationChainId: request.toChainId ?? Number(Chains.ETH),
230
+ destinationCurrency: request.toTokenAddress,
231
+ amount: fromAmount.toString(),
232
+ tradeType: 'EXACT_INPUT',
233
+ // Where the funds land. On a route through the omnibridge this is encoded
234
+ // inside the entry call; with no call to carry it, Relay is told directly.
235
+ recipient: request.recipient,
236
+ // Same rule as the omnibridge path below: Relay refunds on the ORIGIN
237
+ // chain, so a non-Ethereum origin cannot be refunded to an Ethereum
238
+ // fallback address and must name its own.
239
+ refundTo: nonEthereumOriginDirect ? request.fromAddress : request.fallbackAddress,
240
+ ...relayReferrerField(request),
241
+ ...(request.slippage === undefined
242
+ ? {}
243
+ : { slippageTolerance: slippageToBasisPoints(request.slippage) }),
244
+ ...(request.originVmType === 'svm' && request.solanaFeePayer !== undefined
245
+ ? { depositFeePayer: request.solanaFeePayer }
246
+ : {}),
247
+ ...(request.originVmType === 'bvm' && request.bitcoinPublicKey !== undefined
248
+ ? { additionalData: { userPublicKey: request.bitcoinPublicKey } }
249
+ : {}),
250
+ ...(request.relayAppFee &&
251
+ request.relayAppFee.basisPoints > 0n &&
252
+ request.relayAppFee.recipient
253
+ ? {
254
+ appFees: [
255
+ {
256
+ recipient: request.relayAppFee.recipient,
257
+ fee: request.relayAppFee.basisPoints.toString(),
258
+ },
259
+ ],
260
+ }
261
+ : {}),
262
+ };
263
+ }
264
+ if (toAmount <= 0n) {
265
+ return null;
266
+ }
267
+ // A NATIVE ENTRY (`wrapAndRelayTokens`) CARRIES VALUE, and Relay's solver
268
+ // attaches it: a dry quote with this exact call simulates it on the entry
269
+ // chain (see `providers.ts`'s `destinationCallCarriesNativeValue` for the
270
+ // measurement). Relay delivers the native coin itself, so no approval is
271
+ // prepended — the call's own `approvalTarget` is null for this shape.
272
+ const entryCall = {
273
+ to: destinationCall.to,
274
+ value: destinationCall.value.toString(),
275
+ data: destinationCall.data,
276
+ };
277
+ const needsApproval = destinationCall.approvalTarget !== null;
278
+ // grant an unbounded allowance, the standard executor pattern: Relay's
279
+ // Multicaller holds the bridged tokens only transiently during the call
280
+ // sequence, and an exact allowance would strand funds if the entry call ever
281
+ // pulls a balance rather than the fixed amount encoded in its calldata
282
+ const approvalCall = needsApproval
283
+ ? {
284
+ to: request.toTokenAddress,
285
+ value: '0',
286
+ data: encodeFunctionData({
287
+ abi: erc20Abi,
288
+ functionName: 'approve',
289
+ args: [destinationCall.approvalTarget, maxUint256],
290
+ }),
291
+ }
292
+ : null;
293
+ const txs = approvalCall ? [approvalCall, entryCall] : [entryCall];
294
+ const txsGasLimit = destinationCall.gasLimit + (needsApproval ? relayApprovalGasBuffer : 0n);
295
+ // The omnibridge has two entry points: Ethereum and Binance Smart Chain. The
296
+ // destination chain MUST track the entry chain the request targets, because
297
+ // `destinationCurrency` is the entry-chain token address — pairing a
298
+ // Binance-side token with a hardcoded Ethereum destination makes Relay reject
299
+ // the quote with "Invalid input or output currency" (that token does not exist
300
+ // on Ethereum). Defaults to Ethereum, preserving the original single-entry
301
+ // behavior when `toChainId` is omitted.
302
+ const destinationChainId = request.toChainId ?? Number(Chains.ETH);
303
+ const nonEthereumOrigin = isNonEthereumVirtualMachineOrigin(request);
304
+ // Relay always refunds a failed route on the ORIGIN chain. For an Ethereum
305
+ // Virtual Machine origin the user's Ethereum-side fallback address doubles as
306
+ // that origin refund target (unchanged behavior). For a non-Ethereum origin
307
+ // the fallback address is an Ethereum address Relay cannot refund to on the
308
+ // origin chain, so the refund must go to the user's own origin-chain address.
309
+ const refundTo = nonEthereumOrigin ? request.fromAddress : request.fallbackAddress;
310
+ // Relay's Solana fee sponsorship; only included for a Solana origin that names a payer.
311
+ const includeDepositFeePayer = request.originVmType === 'svm' && request.solanaFeePayer !== undefined;
312
+ // Relay's Bitcoin pay-to-script-hash public key; only for a Bitcoin origin that supplies one.
313
+ const includeBitcoinPublicKey = request.originVmType === 'bvm' && request.bitcoinPublicKey !== undefined;
314
+ return {
315
+ user: request.fromAddress,
316
+ originChainId: request.fromChainId,
317
+ originCurrency: request.fromTokenAddress,
318
+ destinationChainId,
319
+ destinationCurrency: request.toTokenAddress,
320
+ amount: toAmount.toString(),
321
+ tradeType: 'EXACT_OUTPUT',
322
+ // origin-chain refund target — Relay's only failure remedy for this route
323
+ refundTo,
324
+ ...relayReferrerField(request),
325
+ txs,
326
+ txsGasLimit: Number(txsGasLimit),
327
+ ...(request.slippage === undefined
328
+ ? {}
329
+ : { slippageTolerance: slippageToBasisPoints(request.slippage) }),
330
+ ...(includeDepositFeePayer ? { depositFeePayer: request.solanaFeePayer } : {}),
331
+ ...(includeBitcoinPublicKey
332
+ ? { additionalData: { userPublicKey: request.bitcoinPublicKey } }
333
+ : {}),
334
+ ...(request.relayAppFee && request.relayAppFee.basisPoints > 0n && request.relayAppFee.recipient
335
+ ? { appFees: [{ recipient: request.relayAppFee.recipient, fee: request.relayAppFee.basisPoints.toString() }] }
336
+ : {}),
337
+ };
338
+ };
339
+ const isHexString = (value) => typeof value === 'string' && value.startsWith('0x');
340
+ /**
341
+ * Parses a quantity Relay may report as a hex string, a decimal string, or a
342
+ * number into a bigint.
343
+ *
344
+ * @returns the bigint value, or null when the input is not a parseable quantity
345
+ */
346
+ const parseQuantity = (value) => {
347
+ if (typeof value === 'number' && Number.isInteger(value)) {
348
+ return BigInt(value);
349
+ }
350
+ if (typeof value !== 'string' || value === '') {
351
+ return null;
352
+ }
353
+ try {
354
+ return BigInt(value);
355
+ }
356
+ catch {
357
+ return null;
358
+ }
359
+ };
360
+ const asRecord = (value) => {
361
+ if (typeof value !== 'object' || value === null) {
362
+ return null;
363
+ }
364
+ return value;
365
+ };
366
+ /**
367
+ * Pulls the signable transaction out of a Relay step item.
368
+ *
369
+ * @returns the transaction data, or null when the item has no usable transaction
370
+ */
371
+ const stepItemTransaction = (item) => {
372
+ const record = asRecord(item);
373
+ const data = asRecord(record?.data);
374
+ if (!data) {
375
+ return null;
376
+ }
377
+ if (!isHexString(data.to) || !isHexString(data.data)) {
378
+ return null;
379
+ }
380
+ return data;
381
+ };
382
+ /**
383
+ * Pulls a Solana instruction set out of a Relay deposit step item. A Solana
384
+ * deposit carries `data.instructions[]` plus the address-lookup-table addresses
385
+ * to compile a versioned transaction, and no `to`/`data` hex fields.
386
+ *
387
+ * @returns the instruction set, or null when the item is not a Solana deposit
388
+ */
389
+ const stepItemSolanaInstructions = (item) => {
390
+ const data = asRecord(asRecord(item)?.data);
391
+ if (!data || !Array.isArray(data.instructions)) {
392
+ return null;
393
+ }
394
+ const instructions = data.instructions.filter((instruction) => {
395
+ const value = asRecord(instruction);
396
+ return (Array.isArray(value?.keys) &&
397
+ typeof value?.programId === 'string' &&
398
+ typeof value?.data === 'string');
399
+ });
400
+ if (instructions.length === 0) {
401
+ return null;
402
+ }
403
+ const lookupTables = Array.isArray(data.addressLookupTableAddresses)
404
+ ? data.addressLookupTableAddresses.filter((address) => typeof address === 'string')
405
+ : [];
406
+ return { instructions, addressLookupTableAddresses: lookupTables };
407
+ };
408
+ /**
409
+ * Pulls a Bitcoin partially-signed transaction out of a Relay deposit step
410
+ * item. Relay hex-encodes it (the "psbt\xff" magic, `70736274ff…`) and it is not
411
+ * `0x`-prefixed, so the Ethereum hex gate does not match it.
412
+ *
413
+ * @returns the partially-signed transaction, or null when the item is not a Bitcoin deposit
414
+ */
415
+ const stepItemBitcoinPsbt = (item) => {
416
+ const data = asRecord(asRecord(item)?.data);
417
+ if (!data || typeof data.psbt !== 'string' || data.psbt === '') {
418
+ return null;
419
+ }
420
+ return { psbt: data.psbt };
421
+ };
422
+ /**
423
+ * Normalizes a typed-data type table, keeping only well-formed `{name, type}`
424
+ * fields. Shared by the Hyperliquid action and its authorization, which carry
425
+ * the same table shape under different keys.
426
+ *
427
+ * @returns the type table; empty when the payload carries none
428
+ */
429
+ const typedDataTypes = (value) => {
430
+ const record = asRecord(value);
431
+ if (!record) {
432
+ return {};
433
+ }
434
+ const entries = Object.entries(record).flatMap(([name, fields]) => {
435
+ if (!Array.isArray(fields)) {
436
+ return [];
437
+ }
438
+ const parsed = fields.flatMap((field) => {
439
+ const candidate = asRecord(field);
440
+ return typeof candidate?.name === 'string' && typeof candidate?.type === 'string'
441
+ ? [{ name: candidate.name, type: candidate.type }]
442
+ : [];
443
+ });
444
+ return [[name, parsed]];
445
+ });
446
+ return Object.fromEntries(entries);
447
+ };
448
+ /**
449
+ * Pulls a Tron contract call out of a Relay deposit step item. Tron's wire shape
450
+ * nests snake_case fields under `parameter` and names the contract kind in
451
+ * `type`; its `data` is bare hex with no `0x` prefix, so the Ethereum hex gate
452
+ * never claims it.
453
+ *
454
+ * @returns the contract call, or null when the item is not a Tron deposit
455
+ */
456
+ const stepItemTronContract = (item) => {
457
+ const data = asRecord(asRecord(item)?.data);
458
+ if (!data || typeof data.type !== 'string') {
459
+ return null;
460
+ }
461
+ const parameter = asRecord(data.parameter);
462
+ if (!parameter) {
463
+ return null;
464
+ }
465
+ const ownerAddress = parameter.owner_address;
466
+ const contractAddress = parameter.contract_address;
467
+ const callData = parameter.data;
468
+ // require every field we map, so a wire-shape change fails closed rather than
469
+ // producing a deposit with silently empty addresses
470
+ if (typeof ownerAddress !== 'string' ||
471
+ typeof contractAddress !== 'string' ||
472
+ typeof callData !== 'string') {
473
+ return null;
474
+ }
475
+ return {
476
+ contractType: data.type,
477
+ ownerAddress,
478
+ contractAddress,
479
+ // a call that sends no TRX omits the field rather than sending zero
480
+ callValue: parseQuantity(parameter.call_value) ?? 0n,
481
+ data: callData,
482
+ };
483
+ };
484
+ /**
485
+ * Pulls a TON message list out of a Relay deposit step item. TON wallets send a
486
+ * list of messages rather than one transaction, and each payload is a base64
487
+ * bag-of-cells rather than hex.
488
+ *
489
+ * @returns the message list, or null when the item is not a TON deposit
490
+ */
491
+ const stepItemTonMessages = (item) => {
492
+ const data = asRecord(asRecord(item)?.data);
493
+ if (!data || !Array.isArray(data.messages)) {
494
+ return null;
495
+ }
496
+ const messages = data.messages.flatMap((entry) => {
497
+ const record = asRecord(entry);
498
+ if (!record || typeof record.to !== 'string' || typeof record.body !== 'string') {
499
+ return [];
500
+ }
501
+ const value = parseQuantity(record.value);
502
+ if (value === null) {
503
+ return [];
504
+ }
505
+ return [
506
+ {
507
+ to: record.to,
508
+ value,
509
+ body: record.body,
510
+ // mode 0 sends with no special flags, TON's own default
511
+ mode: typeof record.mode === 'number' ? record.mode : 0,
512
+ // absent means non-bouncing; only an explicit true bounces
513
+ bounce: record.bounce === true,
514
+ from: typeof record.from === 'string' ? record.from : null,
515
+ },
516
+ ];
517
+ });
518
+ if (messages.length === 0) {
519
+ return null;
520
+ }
521
+ return { messages };
522
+ };
523
+ /**
524
+ * Pulls the typed-data action out of a Relay Hyperliquid deposit step item.
525
+ * Nothing here is broadcast: the action is signed and posted to Hyperliquid's
526
+ * API, so there is no `to`, no calldata, and no gas for the Ethereum hex gate to
527
+ * match.
528
+ *
529
+ * @returns the action, or null when the item is not a Hyperliquid deposit
530
+ */
531
+ const stepItemHyperliquidAction = (item) => {
532
+ const data = asRecord(asRecord(item)?.data);
533
+ if (!data || typeof data.eip712PrimaryType !== 'string') {
534
+ return null;
535
+ }
536
+ const action = asRecord(data.action);
537
+ if (!action || typeof action.type !== 'string') {
538
+ return null;
539
+ }
540
+ return {
541
+ action: { type: action.type, parameters: asRecord(action.parameters) ?? {} },
542
+ eip712Types: typedDataTypes(data.eip712Types),
543
+ eip712PrimaryType: data.eip712PrimaryType,
544
+ nonce: parseQuantity(data.nonce) ?? 0n,
545
+ };
546
+ };
547
+ /**
548
+ * Finds the authorization a Hyperliquid deposit must complete before its action
549
+ * can be submitted. It arrives as its own `signature` step ahead of the deposit,
550
+ * which the deposit scan skips because it carries no deposit shape — so it is
551
+ * collected separately and attached to the deposit, rather than lost.
552
+ *
553
+ * @param steps - every step on the quote
554
+ * @returns the authorization, or null when the quote carries none
555
+ */
556
+ const stepAuthorization = (steps) => {
557
+ for (const step of steps) {
558
+ if (step.kind !== 'signature' || !Array.isArray(step.items)) {
559
+ continue;
560
+ }
561
+ for (const item of step.items) {
562
+ const data = asRecord(asRecord(item)?.data);
563
+ const sign = asRecord(data?.sign);
564
+ const post = asRecord(data?.post);
565
+ if (!sign || !post || typeof sign.primaryType !== 'string') {
566
+ continue;
567
+ }
568
+ if (typeof post.endpoint !== 'string' || typeof post.method !== 'string') {
569
+ continue;
570
+ }
571
+ return {
572
+ sign: {
573
+ signatureKind: typeof sign.signatureKind === 'string' ? sign.signatureKind : '',
574
+ domain: asRecord(sign.domain) ?? {},
575
+ types: typedDataTypes(sign.types),
576
+ value: asRecord(sign.value) ?? {},
577
+ primaryType: sign.primaryType,
578
+ },
579
+ post: {
580
+ endpoint: post.endpoint,
581
+ method: post.method,
582
+ body: asRecord(post.body) ?? {},
583
+ },
584
+ };
585
+ }
586
+ }
587
+ return null;
588
+ };
589
+ /**
590
+ * Recognizes the one deposit shape a Relay step item carries, trying the
591
+ * Ethereum transaction shape first (the only one gated on hex `to`/`data`), then
592
+ * each non-Ethereum shape in turn. Every shape is keyed on a field the others do
593
+ * not carry, so the recognizers are mutually exclusive and the order is a
594
+ * cheapest-check-first convenience rather than a correctness requirement — a
595
+ * property the tests pin, because a future overlap would silently mis-route a
596
+ * deposit to the wrong ecosystem's signer.
597
+ *
598
+ * @returns the discriminated deposit, or null when the item carries none
599
+ */
600
+ const stepItemDeposit = (item) => {
601
+ const transaction = stepItemTransaction(item);
602
+ if (transaction) {
603
+ return { kind: 'evm', transaction };
604
+ }
605
+ const solana = stepItemSolanaInstructions(item);
606
+ if (solana) {
607
+ return { kind: 'svm', ...solana };
608
+ }
609
+ const bitcoin = stepItemBitcoinPsbt(item);
610
+ if (bitcoin) {
611
+ return { kind: 'bvm', psbt: bitcoin.psbt };
612
+ }
613
+ const tron = stepItemTronContract(item);
614
+ if (tron) {
615
+ return { kind: 'tvm', contract: tron };
616
+ }
617
+ const ton = stepItemTonMessages(item);
618
+ if (ton) {
619
+ return { kind: 'tonvm', messages: ton.messages };
620
+ }
621
+ const hyperliquid = stepItemHyperliquidAction(item);
622
+ if (hyperliquid) {
623
+ return { kind: 'hypevm', action: hyperliquid };
624
+ }
625
+ return null;
626
+ };
627
+ /**
628
+ * Extracts the ERC20 spender from a Relay `approve` step so our own approval
629
+ * machinery can grant it. The origin token, not Relay, decides the spender, so
630
+ * we decode it from the ready-made approval calldata rather than guessing.
631
+ *
632
+ * @returns the spender address, or null when the step is absent or undecodable
633
+ */
634
+ const approvalSpender = (steps) => {
635
+ const approveStep = steps.find((step) => step.id === 'approve');
636
+ if (!approveStep) {
637
+ return null;
638
+ }
639
+ const items = approveStep.items;
640
+ if (!Array.isArray(items)) {
641
+ return null;
642
+ }
643
+ for (const item of items) {
644
+ const transaction = stepItemTransaction(item);
645
+ if (!transaction) {
646
+ continue;
647
+ }
648
+ try {
649
+ const decoded = decodeFunctionData({ abi: erc20Abi, data: transaction.data });
650
+ if (decoded.functionName === 'approve') {
651
+ return decoded.args[0];
652
+ }
653
+ }
654
+ catch {
655
+ // not an approve call we recognize; keep scanning
656
+ }
657
+ }
658
+ return null;
659
+ };
660
+ /**
661
+ * Finds the deposit the user signs after any approval — the first
662
+ * deposit-bearing item on a step that is not the `approve` step — and reports
663
+ * which source ecosystem's shape it carries.
664
+ *
665
+ * @returns the step and its discriminated deposit, or null when none is present
666
+ */
667
+ const depositStep = (steps) => {
668
+ for (const step of steps) {
669
+ if (step.id === 'approve') {
670
+ continue;
671
+ }
672
+ const items = step.items;
673
+ if (!Array.isArray(items)) {
674
+ continue;
675
+ }
676
+ for (const item of items) {
677
+ const deposit = stepItemDeposit(item);
678
+ if (deposit) {
679
+ return { step, deposit };
680
+ }
681
+ }
682
+ }
683
+ return null;
684
+ };
685
+ /**
686
+ * Maps a discriminated Relay deposit onto the shared `OriginDeposit` union.
687
+ * Ethereum reads its chain id from the deposit transaction; the non-Ethereum
688
+ * variants have no chain id on the deposit payload, so they read it from the
689
+ * quote's origin currency (`details.currencyIn.currency.chainId`).
690
+ *
691
+ * The non-Ethereum variants are matched by an EXHAUSTIVE switch rather than a
692
+ * trailing fallback: an unhandled variant must be a compile error, because
693
+ * quietly treating one ecosystem's deposit as another's would hand a payload to
694
+ * the wrong signer.
695
+ *
696
+ * @param found - the deposit and the step it was found on
697
+ * @param currencyIn - the quote's `details.currencyIn`, the only source of an
698
+ * origin chain id for a non-Ethereum deposit
699
+ * @param steps - every step on the quote, needed to collect the Hyperliquid
700
+ * authorization that arrives as its own step
701
+ * @returns the origin deposit, or null when a required field is unparseable
702
+ */
703
+ const relayOriginDeposit = (found, currencyIn, steps) => {
704
+ const { step, deposit } = found;
705
+ if (deposit.kind === 'evm') {
706
+ const chainId = parseQuantity(deposit.transaction.chainId);
707
+ if (chainId === null) {
708
+ return null;
709
+ }
710
+ return {
711
+ ecosystem: 'evm',
712
+ to: deposit.transaction.to,
713
+ data: deposit.transaction.data,
714
+ value: parseQuantity(deposit.transaction.value) ?? 0n,
715
+ chainId: Number(chainId),
716
+ gasLimit: parseQuantity(deposit.transaction.gas),
717
+ };
718
+ }
719
+ const originCurrency = asRecord(currencyIn.currency);
720
+ if (typeof originCurrency?.chainId !== 'number') {
721
+ return null;
722
+ }
723
+ const chainId = originCurrency.chainId;
724
+ switch (deposit.kind) {
725
+ case 'svm':
726
+ return {
727
+ ecosystem: 'svm',
728
+ form: 'instructions',
729
+ instructions: deposit.instructions,
730
+ addressLookupTableAddresses: deposit.addressLookupTableAddresses,
731
+ chainId,
732
+ };
733
+ case 'tvm':
734
+ return { ecosystem: 'tvm', ...deposit.contract, chainId };
735
+ case 'tonvm':
736
+ return { ecosystem: 'tonvm', messages: deposit.messages, chainId };
737
+ case 'hypevm':
738
+ return {
739
+ ecosystem: 'hypevm',
740
+ ...deposit.action,
741
+ authorization: stepAuthorization(steps),
742
+ chainId,
743
+ };
744
+ case 'bvm': {
745
+ const amountSats = parseQuantity(currencyIn.amount);
746
+ if (amountSats === null) {
747
+ return null;
748
+ }
749
+ return {
750
+ ecosystem: 'bvm',
751
+ psbt: deposit.psbt,
752
+ depositAddress: typeof step.depositAddress === 'string' ? step.depositAddress : '',
753
+ amountSats,
754
+ chainId,
755
+ };
756
+ }
757
+ }
758
+ };
759
+ /**
760
+ * Normalizes a Relay `/quote/v2` response into the shared consolidated quote
761
+ * shape. The origin approval Relay returns as its own step is folded back into
762
+ * `approvalAddress` so the executor raises it through the same path as LI.FI;
763
+ * the deposit step becomes the single `originDeposit`.
764
+ *
765
+ * @returns the normalized quote, or null when the payload shape is unexpected
766
+ */
767
+ export const parseRelayQuote = (payload) => {
768
+ const quote = asRecord(payload);
769
+ if (!quote) {
770
+ return null;
771
+ }
772
+ const steps = Array.isArray(quote.steps)
773
+ ? quote.steps.filter((step) => asRecord(step) !== null)
774
+ : null;
775
+ if (!steps || steps.length === 0) {
776
+ return null;
777
+ }
778
+ const details = asRecord(quote.details);
779
+ const currencyIn = asRecord(details?.currencyIn);
780
+ if (!currencyIn) {
781
+ return null;
782
+ }
783
+ const fromAmount = parseQuantity(currencyIn.amount);
784
+ if (fromAmount === null) {
785
+ return null;
786
+ }
787
+ if (typeof details?.timeEstimate !== 'number') {
788
+ return null;
789
+ }
790
+ const found = depositStep(steps);
791
+ if (!found) {
792
+ return null;
793
+ }
794
+ // the identifier lives on the steps in the verified response shape; fall back
795
+ // through a top-level field so a future nesting change does not reject quotes
796
+ const requestId = typeof quote.requestId === 'string'
797
+ ? quote.requestId
798
+ : steps.map((step) => step.requestId).find((id) => typeof id === 'string');
799
+ if (typeof requestId !== 'string') {
800
+ return null;
801
+ }
802
+ const originDeposit = relayOriginDeposit(found, currencyIn, steps);
803
+ if (originDeposit === null) {
804
+ return null;
805
+ }
806
+ const spender = approvalSpender(steps);
807
+ return {
808
+ provider: 'relay',
809
+ quoteId: requestId,
810
+ tool: 'relay',
811
+ toolName: 'Relay',
812
+ fromAmount,
813
+ // WHAT THE ROUTE DELIVERS, WHEN ONLY RELAY KNOWS. An omnibridge route names
814
+ // its own delivered amount in the request and asks for an exact output, so
815
+ // this repeats what the caller already said. A direct route asks for an
816
+ // exact input, and the delivered amount exists nowhere until this answer.
817
+ // Undefined rather than zero when the field is missing: zero is a real
818
+ // quantity and would read as "this route delivers nothing".
819
+ ...(() => {
820
+ const out = parseQuantity(asRecord(details?.currencyOut)?.amount);
821
+ return out === null ? {} : { toAmount: out };
822
+ })(),
823
+ fromAmountUsd: typeof currencyIn.amountUsd === 'string' ? currencyIn.amountUsd : null,
824
+ // The delivered side valued by the same answer that valued the paid side —
825
+ // see `ConsolidatedQuote.toAmountUsd` for why it may not come from anywhere
826
+ // else. Omitted rather than nulled when Relay leaves it out.
827
+ ...(() => {
828
+ const outUsd = asRecord(details?.currencyOut)?.amountUsd;
829
+ return typeof outUsd === 'string' ? { toAmountUsd: outUsd } : {};
830
+ })(),
831
+ // a native origin token is pulled with no allowance, so no spender applies
832
+ approvalAddress: spender !== null && spender !== zeroAddress ? spender : null,
833
+ executionDurationSeconds: details.timeEstimate,
834
+ // Relay's `steps` are operational (approve + deposit); the per-leg route
835
+ // lives in `details.route`, so the whole quote is normalized, not `steps`.
836
+ steps: normalizeRelaySteps(quote),
837
+ originDeposit,
838
+ fees: relayQuoteFees(payload),
839
+ };
840
+ };
841
+ /**
842
+ * Builds the requests-lookup address for a Relay route BY ITS QUOTE
843
+ * IDENTIFIER.
844
+ *
845
+ * IT USED TO ASK BY ORIGIN TRANSACTION HASH, AND THAT RETURNED OTHER PEOPLE'S
846
+ * TRANSFERS. `/requests/v3` does not accept a `hash` filter: it ignores the
847
+ * parameter entirely and answers with the twenty most recent requests ACROSS
848
+ * ALL USERS. Measured, with two different origin hashes a minute apart:
849
+ *
850
+ * ?hash=0x447149ed… -> 20 requests, first is 8453:0x7d7c3de2… -> 4663:…
851
+ * ?hash=0xa650d2d7… -> the SAME 20 requests, same first entry
852
+ *
853
+ * `parseRelayStatus` reads `requests[0]`, so both of this account's transfers
854
+ * were updated from a stranger's — they took the same entry-chain transaction
855
+ * hash, and neither was their own. A transfer was reported finished because
856
+ * somebody else's had finished.
857
+ *
858
+ * `id` is the filter v3 actually honours. The same lookup by quote identifier
859
+ * returns exactly ONE request, and its `inTxs` name the origin transaction the
860
+ * reader really signed. The caller should assert that correspondence rather
861
+ * than trust it — see `checkConsolidatedRecord`.
862
+ */
863
+ export const relayStatusUrl = ({ quoteId }) => {
864
+ const url = new URL(relayRequestsUrl);
865
+ url.searchParams.set('id', quoteId);
866
+ return url.toString();
867
+ };
868
+ /**
869
+ * Relay's request lifecycle states. Terminal states are `success` (the
870
+ * destination call filled), `failure`, and `refund` (made whole on the origin
871
+ * chain). Every other state is still in flight.
872
+ */
873
+ const relayInFlightStatuses = ['waiting', 'depositing', 'pending', 'submitted', 'delayed'];
874
+ const isEthereumTransactionHash = (value) => typeof value === 'string' && /^0x[0-9a-fA-F]{64}$/.test(value);
875
+ /**
876
+ * Finds the destination-chain transaction hash among a Relay request's output
877
+ * transactions — the hash that gates the handover to the omnibridge indexer.
878
+ * The destination chain is the omnibridge entry chain the route targeted
879
+ * (Ethereum or Binance Smart Chain), so it is supplied by the caller rather
880
+ * than assumed.
881
+ *
882
+ * @param request - a Relay request record
883
+ * @param destinationChainId - the omnibridge entry chain the route targeted
884
+ * @returns the destination-chain transaction hash, or null when none recorded
885
+ */
886
+ const outputTransactions = (request) => {
887
+ // RELAY NESTS THESE UNDER `data`. An earlier version read `request.outTxs`,
888
+ // which no response has ever carried: measured 2026-09-01 across a hundred
889
+ // live records, `request.outTxs` was present on ZERO and `request.data.outTxs`
890
+ // on every one. So this returned null for every completed route, and the
891
+ // interface reported DONE while linking nothing. The unit tests missed it
892
+ // because they were written to the same wrong shape as the parser.
893
+ const data = asRecord(request.data);
894
+ if (Array.isArray(data?.outTxs)) {
895
+ return data.outTxs;
896
+ }
897
+ // Kept as a fallback rather than deleted: it costs one array check, and a
898
+ // response that does flatten them still reads correctly.
899
+ if (Array.isArray(request.outTxs)) {
900
+ return request.outTxs;
901
+ }
902
+ return [];
903
+ };
904
+ /**
905
+ * The transaction hash on one output entry. `/requests/v2` names the field
906
+ * `hash` and `/requests/v3` names it `txHash`; both appear in the wild, so both
907
+ * are read rather than choosing by endpoint version.
908
+ */
909
+ const outputHashOf = (entry) => {
910
+ const record = asRecord(entry);
911
+ if (isEthereumTransactionHash(record?.txHash)) {
912
+ return record.txHash;
913
+ }
914
+ if (isEthereumTransactionHash(record?.hash)) {
915
+ return record.hash;
916
+ }
917
+ return null;
918
+ };
919
+ const destinationOutputHash = (request, destinationChainId) => {
920
+ for (const entry of outputTransactions(request)) {
921
+ if (Number(asRecord(entry)?.chainId) !== destinationChainId) {
922
+ continue;
923
+ }
924
+ const hash = outputHashOf(entry);
925
+ if (hash) {
926
+ return hash;
927
+ }
928
+ }
929
+ const txHashes = request.txHashes;
930
+ if (Array.isArray(txHashes)) {
931
+ const hash = txHashes.find((entry) => isEthereumTransactionHash(entry));
932
+ if (isEthereumTransactionHash(hash)) {
933
+ return hash;
934
+ }
935
+ }
936
+ return null;
937
+ };
938
+ /**
939
+ * Maps a Relay request status onto the provider-agnostic consolidated status.
940
+ * `success` reports `DONE`/`COMPLETED` because for this route the destination
941
+ * fill IS our omnibridge entry call; `refund` reports `DONE`/`REFUNDED` because
942
+ * the user was made whole on the origin chain. Relay never strands funds on
943
+ * Ethereum, so the partial/stranded status LI.FI can return has no analogue.
944
+ *
945
+ * @param payload - a `/requests/v2?hash=` response body
946
+ * @param options.destinationChainId - the omnibridge entry chain the route
947
+ * targeted (Ethereum or Binance Smart Chain); defaults to Ethereum,
948
+ * preserving the original single-entry behavior
949
+ * @returns the normalized status, or null when no matching request is present
950
+ */
951
+ export const parseRelayStatus = (payload, { destinationChainId = Number(Chains.ETH) } = {}) => {
952
+ const data = asRecord(payload);
953
+ if (!data) {
954
+ return null;
955
+ }
956
+ const requests = data.requests;
957
+ if (!Array.isArray(requests) || requests.length === 0) {
958
+ return { status: 'NOT_FOUND', substatus: null, receivingTxHash: null };
959
+ }
960
+ const request = asRecord(requests[0]);
961
+ if (!request) {
962
+ return null;
963
+ }
964
+ const status = request.status;
965
+ if (typeof status !== 'string') {
966
+ return null;
967
+ }
968
+ const receivingTxHash = destinationOutputHash(request, destinationChainId);
969
+ if (status === 'success') {
970
+ return { status: 'DONE', substatus: 'COMPLETED', receivingTxHash };
971
+ }
972
+ if (status === 'refund') {
973
+ return { status: 'DONE', substatus: 'REFUNDED', receivingTxHash: null };
974
+ }
975
+ if (status === 'failure') {
976
+ return { status: 'FAILED', substatus: status, receivingTxHash: null };
977
+ }
978
+ if (relayInFlightStatuses.some((inFlight) => inFlight === status)) {
979
+ return { status: 'PENDING', substatus: status, receivingTxHash };
980
+ }
981
+ // an unrecognized status is treated as still indexing rather than terminal
982
+ return { status: 'NOT_FOUND', substatus: status, receivingTxHash };
983
+ };