@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,1055 @@
1
+ import { arbitrum, avalanche, base, bsc, gnosis, mainnet, optimism, polygon, scroll, } from 'viem/chains';
2
+ import { encodeFunctionData, erc20Abi, isAddress } from 'viem';
3
+ import { depositAddressChainIds, isDepositAddressChain, relayChainIds, } from './chain-names.js';
4
+ import { isNativeAsset, isValidAddressIn, sameAddress } from './ecosystems.js';
5
+ import { ecosystemByChainId } from './relay-quote.js';
6
+ import { resolveAttribution } from './referral.js';
7
+ /**
8
+ * NEAR Intents, through its "one click" quoting service.
9
+ *
10
+ * THIS PROVIDER CANNOT EXECUTE ON THE DESTINATION CHAIN, AND IS REGISTERED
11
+ * ANYWAY. Read both halves of that before changing anything here.
12
+ *
13
+ * The service validates its request body against a closed property list and
14
+ * answers `property destinationCall should not exist` for every name a call
15
+ * could travel under — `destinationCall`, `calldata`, `msg`, `txs`, `hook`,
16
+ * `action`, `postSwapAction`. The only thing nameable on the destination side
17
+ * is an address: `recipient`, with a `recipientType` of `DESTINATION_CHAIN`,
18
+ * `INTENTS` or `CONFIDENTIAL_INTENTS`. That is measured, not inferred from
19
+ * silence. `docs/near-intents-capability-survey.md` holds the evidence.
20
+ *
21
+ * DELIVERING TO THE MEDIATOR INSTEAD IS NOT A WAY AROUND IT, and the reason is
22
+ * structural rather than a matter of configuration. The service will happily
23
+ * deliver to a contract — a quote naming a well-known router as `recipient` is
24
+ * accepted. But every omnibridge entry point learns WHO THE READER IS from a
25
+ * data field: `transferAndCall` carries the recipient in its third argument,
26
+ * `relayTokens` in its second, `wrapAndRelayTokens` in its first. A plain
27
+ * token transfer has no data field. So a bare arrival cannot name a recipient,
28
+ * which means it can only ever credit the sender — the solver — or nobody at
29
+ * all. Do not attempt it.
30
+ *
31
+ * SO THE ROUTE TAKES TWO SIGNATURES, and the interface says so on the route
32
+ * strip rather than in a tooltip. The reader signs the origin transfer; NEAR
33
+ * Intents lands the anchor token in THEIR OWN WALLET on the entry chain; then
34
+ * they sign the ordinary bridge leg themselves. `near-intents-route.ts` builds
35
+ * that shape, and `executesDestinationCalls: false` on the descriptor is what
36
+ * keeps this provider out of the single-signature machinery, including the
37
+ * entry-anchor pre-quote — that phase feeds an exact-output target to the
38
+ * providers that DO execute, and a target only a non-executing provider can
39
+ * reach is a target nobody can fill.
40
+ *
41
+ * TWO SIZE FLOORS MAKE THIS A ROUTING RULE, NOT A FOOTNOTE. Binance Smart
42
+ * Chain — one of the two omnibridge entry chains — refuses any transfer worth
43
+ * one thousand United States dollars or less, in either direction, and Polygon
44
+ * and Tron carry floors of their own. The service imposed these on 2026-09-09
45
+ * and calls them temporary. Only asking reveals them, so a route has to be
46
+ * WITHDRAWN on a refusal rather than left to fail: see
47
+ * {@link NearIntentsQuoteRefusal} and `near-intents-route.ts`.
48
+ */
49
+ /** Base address for the NEAR Intents one-click service. */
50
+ export const nearIntentsApiBaseUrl = 'https://1click.chaindefuser.com';
51
+ /** Quote endpoint. Verified live: no key required, and a key-less request is answered in full. */
52
+ export const nearIntentsQuoteUrl = `${nearIntentsApiBaseUrl}/v0/quote`;
53
+ /** The list of assets the service can carry, keyed by its own asset identifiers. */
54
+ export const nearIntentsTokensUrl = `${nearIntentsApiBaseUrl}/v0/tokens`;
55
+ /**
56
+ * Optional notification that the reader's deposit has been sent. The service
57
+ * detects the deposit on its own; telling it the transaction hash only makes
58
+ * the detection faster. Takes `{ txHash, depositAddress }`.
59
+ */
60
+ export const nearIntentsDepositSubmitUrl = `${nearIntentsApiBaseUrl}/v0/deposit/submit`;
61
+ /**
62
+ * What a key-less request costs, in basis points of the input amount.
63
+ *
64
+ * NOT A FEE THIS CODE CHARGES OR CAN DECLINE. The service inserts it into the
65
+ * echoed request as an `appFees` entry payable to its own account whenever no
66
+ * key is presented, so a reader on a key-less route pays a quarter of a percent
67
+ * that no part of this repository asked for. It is named here so the parser can
68
+ * surface it as a charge rather than let it disappear into the delivered
69
+ * amount. A partner key reduces it; obtaining one is an owner decision.
70
+ */
71
+ export const nearIntentsUnauthenticatedFeeBasisPoints = 25;
72
+ /**
73
+ * What a partner-key request costs instead, in basis points of the input
74
+ * amount, for an ordinary (non-stablecoin) pair.
75
+ *
76
+ * Read from the service's own current documentation on 2026-09-18:
77
+ * `docs.near-intents.org/integration/distribution-channels/1click-api/authentication`.
78
+ * See `docs/near-intents-partner-key.md` for the full process of obtaining a
79
+ * key and what else changes once one is configured.
80
+ */
81
+ export const nearIntentsAuthenticatedFeeBasisPoints = 20;
82
+ /**
83
+ * What a partner-key request costs on a stablecoin pair or a same-asset
84
+ * multi-chain route, in basis points of the input amount.
85
+ *
86
+ * Read from the same page as {@link nearIntentsAuthenticatedFeeBasisPoints} on
87
+ * 2026-09-18. Kept as its own constant rather than a special case of the other
88
+ * one because the service documents it as a distinct figure for a distinct
89
+ * class of route, not a discount computed from the ordinary rate.
90
+ */
91
+ export const nearIntentsAuthenticatedStablecoinFeeBasisPoints = 1;
92
+ /**
93
+ * Builds the status-poll address for one quote.
94
+ *
95
+ * The deposit address IS the lookup key here, unlike every other provider in
96
+ * this repository, which key by a request identifier. Each quote is issued its
97
+ * own deposit address, so the two are one and the same fact — see
98
+ * {@link parseNearIntentsQuote}, which puts the deposit address in `quoteId`
99
+ * for exactly this reason.
100
+ *
101
+ * @param options.quoteId - the quote's deposit address
102
+ * @returns the fully-formed status address
103
+ */
104
+ export const nearIntentsStatusUrl = ({ quoteId }) => {
105
+ const url = new URL(`${nearIntentsApiBaseUrl}/v0/status`);
106
+ url.searchParams.set('depositAddress', quoteId);
107
+ return url.toString();
108
+ };
109
+ /**
110
+ * What this repository calls itself when it asks the one-click service.
111
+ *
112
+ * SENT BECAUSE THE SERVICE REFUSES SOME CALLERS BY NAME, and until now the
113
+ * name was whatever the runtime happened to supply. The service sits behind
114
+ * Cloudflare, which answers a refused caller with HTTP 403 and a body reading
115
+ * `error code: 1010` — a page, not a quote, so a parser sees a shape it cannot
116
+ * read and the whole provider looks like an outage.
117
+ *
118
+ * MEASURED against the live quote endpoint on 2026-09-21, one identical body,
119
+ * one header changed:
120
+ *
121
+ * | user agent | answer |
122
+ * | ------------------------- | ------ |
123
+ * | none (Node 26 default) | 201 |
124
+ * | `undici` | 201 |
125
+ * | `node` | 201 |
126
+ * | `curl/8.7.1` | 201 |
127
+ * | `python-requests/2.32` | 201 |
128
+ * | `Python-urllib/3.14` | 403 |
129
+ * | this value | 201 |
130
+ *
131
+ * So nothing is blocked today. The point is that whether we are blocked was
132
+ * being decided by a list we do not control, matched against a string we never
133
+ * chose — and a runtime upgrade could change that string without anyone here
134
+ * touching a line. Naming ourselves fixes the input to that decision.
135
+ *
136
+ * A BROWSER IGNORES THIS, and that is fine. `User-Agent` is a forbidden header
137
+ * name for `fetch`, so the browser drops it silently and sends its own. The
138
+ * value therefore governs Node alone: the live test harness, the deposit
139
+ * poller, and any script written later.
140
+ */
141
+ export const nearIntentsUserAgent = 'gibs-bridge/1.0 (+https://gibs.finance)';
142
+ /**
143
+ * Request headers for the one-click service.
144
+ *
145
+ * THE KEY IS A JSON WEB TOKEN, NOT A PLAIN IDENTIFIER LIKE RELAY'S. The
146
+ * service's own documentation accepts it as `Authorization: Bearer` as well as
147
+ * `X-API-Key`, calls it a "JWT token" throughout, and tells an integrator to
148
+ * "store your JWT token securely and never commit it to version control. Use
149
+ * environment variables or secure secret management systems." Relay's
150
+ * documentation says none of that about its key. `X-API-Key` is the form sent
151
+ * here because the service accepts it and it matches the header name Relay
152
+ * already uses, so one code path serves both — but the caller of this
153
+ * function decides where the value comes from, and that decision is where the
154
+ * secrecy question has to be answered. See `packages/ui/src/lib/config.ts`
155
+ * and `docs/near-intents-partner-key.md`.
156
+ *
157
+ * THE DEFAULT `User-Agent` NAMES GIBS FINANCE, AND THAT COSTS A CALLER
158
+ * NOTHING: it carries no key and no rate-limit budget, and a browser drops it.
159
+ * Pass `options.userAgent` to name yourself instead, or `null` to send none and
160
+ * let the runtime use its own.
161
+ *
162
+ * @param apiKey - a partner key, when one is configured
163
+ * @param options - `userAgent`: your own value, or `null` for none (defaults to {@link nearIntentsUserAgent})
164
+ * @returns the headers, carrying the key only when there is one to carry
165
+ */
166
+ export const nearIntentsRequestHeaders = (apiKey, options = {}) => {
167
+ const userAgent = resolveAttribution(options.userAgent, nearIntentsUserAgent);
168
+ return {
169
+ 'Content-Type': 'application/json',
170
+ ...(userAgent === undefined ? {} : { 'User-Agent': userAgent }),
171
+ ...(apiKey ? { 'X-API-Key': apiKey } : {}),
172
+ };
173
+ };
174
+ /**
175
+ * This repository's chain identifiers, keyed by the service's short chain name.
176
+ *
177
+ * The Ethereum-virtual-machine entries are read from viem rather than written
178
+ * down, because a chain identifier typed from memory is a hypothesis that every
179
+ * later reader will trust — and a wrong one here does not fail loudly, it
180
+ * simply quotes a different chain's token.
181
+ *
182
+ * The non-Ethereum entries are this repository's own identifiers for those
183
+ * families, which are Relay's numbers (see {@link relayChainIds}); the service
184
+ * uses short names and has no numeric identifier of its own to reconcile with.
185
+ *
186
+ * DELIBERATELY SHORTER THAN THE SERVICE'S CHAIN LIST. The service names about
187
+ * thirty-five chains. Only the ones this bridge can actually originate from or
188
+ * enter are listed, because an unlisted chain resolves to no asset and
189
+ * therefore to no route, which is the honest outcome, whereas a guessed
190
+ * identifier resolves to a route on the wrong chain.
191
+ */
192
+ export const chainIdByNearIntentsChain = new Map([
193
+ ['eth', mainnet.id],
194
+ ['bsc', bsc.id],
195
+ ['base', base.id],
196
+ ['arb', arbitrum.id],
197
+ ['op', optimism.id],
198
+ ['pol', polygon.id],
199
+ ['avax', avalanche.id],
200
+ ['gnosis', gnosis.id],
201
+ ['scroll', scroll.id],
202
+ ['sol', relayChainIds.solana],
203
+ ['btc', relayChainIds.bitcoin],
204
+ ['tron', relayChainIds.tron],
205
+ ['ton', relayChainIds.ton],
206
+ ['hypercore', relayChainIds.hyperliquid],
207
+ // THE ONE ENTRY WITH NO WALLET BEHIND IT. Zcash is not a Relay chain and not
208
+ // a LI.FI chain; this repository names it itself, in `chain-names.ts`, so
209
+ // that a route from it can be priced and drawn. The reader pays the address
210
+ // this service issues, by hand, from wherever they hold the coin.
211
+ ['zec', depositAddressChainIds.zcash],
212
+ ]);
213
+ const asRecord = (value) => {
214
+ if (typeof value !== 'object' || value === null) {
215
+ return null;
216
+ }
217
+ return value;
218
+ };
219
+ /**
220
+ * Reads `GET /v0/tokens` into the asset records the identifier lookup needs.
221
+ *
222
+ * An entry missing any field the lookup reads is dropped rather than patched
223
+ * with a default: an asset whose decimals were invented would price every
224
+ * amount wrong by orders of magnitude, and an asset whose identifier is
225
+ * missing cannot be quoted at all.
226
+ *
227
+ * @param payload - the raw JSON body from `GET /v0/tokens`
228
+ * @returns every readable asset, in the order the service listed them
229
+ */
230
+ export const parseNearIntentsTokens = (payload) => {
231
+ if (!Array.isArray(payload)) {
232
+ return [];
233
+ }
234
+ const assets = [];
235
+ for (const entry of payload) {
236
+ const record = asRecord(entry);
237
+ if (!record) {
238
+ continue;
239
+ }
240
+ const { assetId, blockchain, symbol, decimals, contractAddress, price, priceUpdatedAt } = record;
241
+ if (typeof assetId !== 'string' || typeof blockchain !== 'string') {
242
+ continue;
243
+ }
244
+ if (typeof symbol !== 'string' || typeof decimals !== 'number') {
245
+ continue;
246
+ }
247
+ assets.push({
248
+ assetId,
249
+ blockchain,
250
+ symbol,
251
+ decimals,
252
+ contractAddress: typeof contractAddress === 'string' ? contractAddress : null,
253
+ price: typeof price === 'number' && Number.isFinite(price) ? price : null,
254
+ priceUpdatedAt: typeof priceUpdatedAt === 'string' ? priceUpdatedAt : null,
255
+ });
256
+ }
257
+ return assets;
258
+ };
259
+ /**
260
+ * Finds the service's identifier for one token on one chain.
261
+ *
262
+ * THE IDENTIFIER CANNOT BE CONSTRUCTED, ONLY LOOKED UP. It reads like a
263
+ * pattern — a chain name, the token's address, a fixed suffix — and for an
264
+ * Ethereum-virtual-machine token it currently is one. It is not one for the
265
+ * other families: Solana's USD Coin carries a hexadecimal handle that is
266
+ * nothing like its base58 mint. Building the string from its parts therefore
267
+ * produces an identifier that is well-formed and refers to nothing, so the
268
+ * asset list is the only source.
269
+ *
270
+ * @param options.assets - the parsed asset list
271
+ * @param options.chainId - this repository's identifier for the chain
272
+ * @param options.tokenAddress - the token's address on that chain
273
+ * @param options.ecosystem - the chain's virtual-machine family, which decides
274
+ * whether the native-coin test and the address comparison are meaningful
275
+ * @returns the matching asset, or null when the service does not carry it
276
+ */
277
+ export const findNearIntentsAsset = ({ assets, chainId, tokenAddress, ecosystem, }) => {
278
+ const onChain = assets.filter((asset) => chainIdByNearIntentsChain.get(asset.blockchain) === chainId);
279
+ if (onChain.length === 0) {
280
+ return null;
281
+ }
282
+ if (isNativeAsset(tokenAddress, ecosystem)) {
283
+ return onChain.find((asset) => asset.contractAddress === null) ?? null;
284
+ }
285
+ // Only an Ethereum-virtual-machine chain reports a token's real address here,
286
+ // so a match on any other family would be a coincidence of hexadecimal.
287
+ if (ecosystem !== 'evm') {
288
+ return null;
289
+ }
290
+ const match = onChain.find((asset) => asset.contractAddress !== null && sameAddress(asset.contractAddress, tokenAddress, 'evm'));
291
+ return match ?? null;
292
+ };
293
+ /**
294
+ * How far ahead the requested refund deadline is set, in milliseconds.
295
+ *
296
+ * WHAT IS SENT, NOT WHAT COMES BACK. The service does not honour this value: it
297
+ * returns a `deadline` of exactly three days after the one asked for, and
298
+ * returns `timeWhenInactive` equal to that same instant rather than to the
299
+ * earlier moment the documentation describes. Both are visible in the checked-in
300
+ * capture — asked 2026-09-17T23:49:52.805Z, answered 2026-09-20T23:49:52.805Z.
301
+ *
302
+ * So anything that needs the real clock must read `quote.deadline` off the
303
+ * answer. Reading `quoteRequest.deadline` back gives this number, which is the
304
+ * request echoed, not a commitment; and treating `deadline` and
305
+ * `timeWhenInactive` as two milestones reads a distinction the service is not
306
+ * currently making.
307
+ */
308
+ const defaultDeadlineMilliseconds = 30 * 60 * 1000;
309
+ /**
310
+ * Slippage THIS MODULE sends when the caller states none, in basis points.
311
+ *
312
+ * Not a default the service holds. `slippageTolerance` is a required property —
313
+ * the service rejects a request without it — so there is no "leave it to them"
314
+ * option here as there is with the other providers, and one percent is a value
315
+ * chosen on this side.
316
+ */
317
+ const defaultSlippageBasisPoints = 100;
318
+ /**
319
+ * Converts a slippage fraction into the basis points the service expects.
320
+ *
321
+ * @param slippage - tolerance as a fraction (0.005 means half a percent)
322
+ * @returns basis points (0.005 becomes 50)
323
+ */
324
+ export const slippageToNearIntentsBasisPoints = (slippage) => Math.round(slippage * 10_000);
325
+ /**
326
+ * Reads the build inputs out of the shared provider context.
327
+ *
328
+ * Returns null when the asset list is absent, and that is not a defensive
329
+ * check — without the list no asset identifier can be resolved, so there is no
330
+ * request to form. Answering null leaves the route to the providers that can
331
+ * express it, which is the same thing every other builder here does when it
332
+ * cannot express a request.
333
+ */
334
+ const buildOptionsFrom = (context, { dry }) => {
335
+ const assets = context?.nearIntentsAssets;
336
+ if (assets === undefined || assets.length === 0) {
337
+ return null;
338
+ }
339
+ return { assets, now: context?.now ?? new Date(), referral: context?.referral, dry };
340
+ };
341
+ /**
342
+ * Where a refund goes, and it is a different answer for a chain the reader
343
+ * cannot sign on.
344
+ *
345
+ * THE ORDINARY CASE. A refund lands back on the ORIGIN chain, at the reader's
346
+ * own origin-chain address — never the delivery address, which could not
347
+ * receive one there.
348
+ *
349
+ * THE PAY-BY-HAND CASE, WHICH IS THE WHOLE REASON THIS FLOW IS POSSIBLE. A
350
+ * reader with no Zcash wallet has no Zcash address to refund to, and
351
+ * `refundType` offers no destination-chain option: the three values are
352
+ * `ORIGIN_CHAIN`, `INTENTS` and `CONFIDENTIAL_INTENTS`. `INTENTS` credits the
353
+ * refund to a ledger row inside the settlement contract `intents.near`, and the
354
+ * row may be named for the reader's own Ethereum address, which the same
355
+ * Ethereum key controls. So the reader types nothing, and the refund fee falls
356
+ * from 32,000 base units to zero: crediting a balance inside a contract needs
357
+ * no Zcash transaction. `docs/near-implicit-account-refunds.md` checks the
358
+ * claim at the protocol level.
359
+ *
360
+ * TWO TRAPS, BOTH MEASURED, BOTH CAPABLE OF COSTING SOMEBODY THEIR MONEY.
361
+ *
362
+ * FIRST, LOWER CASE IS NOT COSMETIC. The account name must be lower case. The
363
+ * same address in its checksummed spelling is refused outright with `refundTo
364
+ * is not valid` — the capture is
365
+ * `__fixtures__/near-intents/quote-error-zcash-refund-mixed-case.json`.
366
+ *
367
+ * SECOND, ACCEPTANCE PROVES NOTHING. On the intents path the service checks
368
+ * only that the string looks like a NEAR account name — the literal
369
+ * `not-an-address-at-all` quotes happily. A wrong value therefore passes
370
+ * quoting and is discovered only when a swap is refunded to nobody. That is
371
+ * why this reads `connectedEthereumAccount`, whose contract is that it comes
372
+ * from the wallet connection, and why it builds nothing when that is absent
373
+ * or is not an Ethereum address.
374
+ *
375
+ * @param options.fromChainId - the origin chain
376
+ * @param options.fromAddress - the reader's address on the origin chain
377
+ * @param options.connectedEthereumAccount - the reader's connected Ethereum account
378
+ * @returns the two refund properties, or null when a pay-by-hand route has no
379
+ * connected Ethereum account to name
380
+ */
381
+ const refundFields = ({ fromChainId, fromAddress, connectedEthereumAccount, }) => {
382
+ if (!isDepositAddressChain(fromChainId)) {
383
+ return { refundTo: fromAddress, refundType: 'ORIGIN_CHAIN' };
384
+ }
385
+ if (connectedEthereumAccount === undefined || !isAddress(connectedEthereumAccount)) {
386
+ return null;
387
+ }
388
+ return { refundTo: connectedEthereumAccount.toLowerCase(), refundType: 'INTENTS' };
389
+ };
390
+ /**
391
+ * The smallest Zcash deposit that can actually be credited, in base units:
392
+ * 0.01 Zcash.
393
+ *
394
+ * THE LARGER OF TWO FIGURES THAT DISAGREE. The quoting service prices a Zcash
395
+ * input down to about two cents — the smaller floor, read live from its
396
+ * refusal by {@link parseNearIntentsQuoteRefusal}. But Zcash crosses on the
397
+ * Proof of Authority bridge, not the Omni Bridge, and that bridge publishes
398
+ * `min_deposit_amount: 1000000` for native Zcash. A quote between the two is
399
+ * priced by one surface and below the other's floor, so it takes the money and
400
+ * stalls — and the published stuck-deposit recovery flow is written for the
401
+ * Omni Bridge, so it does not cover Zcash. Measured 2026-09-21; see
402
+ * `docs/near-intents-capability-survey.md`.
403
+ */
404
+ export const zcashWorkingFloorBaseUnits = 1000000n;
405
+ /**
406
+ * The working floor for each chain a reader pays by hand, in base units of
407
+ * that chain's native asset, one entry per member of {@link DepositAddressChain}.
408
+ * Typed on the union rather than on `number` so a pay-by-hand chain added to
409
+ * {@link depositAddressChainIds} without a floor here is a compile error.
410
+ */
411
+ const workingFloorBaseUnitsByChain = {
412
+ zcash: zcashWorkingFloorBaseUnits,
413
+ };
414
+ /** {@link workingFloorBaseUnitsByChain}, keyed by chain id for lookup. */
415
+ const workingFloorByChainId = new Map(Object.keys(depositAddressChainIds).map((chain) => [
416
+ depositAddressChainIds[chain],
417
+ workingFloorBaseUnitsByChain[chain],
418
+ ]));
419
+ /**
420
+ * The working floor for a pay-by-hand chain, in base units, or null when the
421
+ * chain has none registered.
422
+ *
423
+ * EXPORTED SO A SCREEN NEVER KEEPS ITS OWN COPY. {@link isBelowWorkingFloor} is
424
+ * the decision this library enforces when quoting; this is the SAME table,
425
+ * exposed so a payment screen can PRINT the figure it refuses under instead of
426
+ * hand-copying the table — a copy that would silently disagree with quoting
427
+ * the moment a second pay-by-hand chain gets a floor here and not there.
428
+ *
429
+ * @param chainId - the origin chain
430
+ * @returns the floor in base units, or null when the chain has none
431
+ */
432
+ export const workingFloorFor = (chainId) => workingFloorByChainId.get(chainId) ?? null;
433
+ /**
434
+ * Whether an amount is below the floor a deposit on this chain can actually
435
+ * be credited at.
436
+ *
437
+ * ASK IT BEFORE ASKING FOR A LIVE QUOTE. The quote request builder already
438
+ * declines such an amount, so no deposit address is ever issued for money that
439
+ * would stall. This predicate lets a surface say WHY there is no route, and
440
+ * what size would work.
441
+ *
442
+ * @param options.chainId - the origin chain
443
+ * @param options.amount - the input, in base units of the origin asset
444
+ * @returns whether the amount is below that chain's working floor
445
+ */
446
+ export const isBelowWorkingFloor = ({ chainId, amount, }) => {
447
+ const floor = workingFloorByChainId.get(chainId);
448
+ return floor !== undefined && amount < floor;
449
+ };
450
+ /**
451
+ * Builds the request body every quote and pre-quote shares.
452
+ *
453
+ * THE ORIGIN ECOSYSTEM IS DERIVED FROM THE CHAIN ID, NOT ACCEPTED AS AN
454
+ * ARGUMENT. It used to be read off the request's own `originVmType` while the
455
+ * pay-by-hand `swapType` decision two lines below read `fromChainId` through
456
+ * {@link isDepositAddressChain} — two signals that a caller could set to
457
+ * disagree, most easily by simply leaving `originVmType` unset on a
458
+ * deposit-address request. `near-intents.test.ts` certified exactly that
459
+ * contradiction as a valid Zcash request before this was fixed. Deriving both
460
+ * decisions from the same chain id, in the same function, makes the
461
+ * disagreement impossible to construct rather than merely unlikely.
462
+ */
463
+ const buildBody = ({ fromChainId, fromTokenAddress, fromAddress, connectedEthereumAccount, toChainId, toTokenAddress, recipient, amount, swapType, slippage, options, }) => {
464
+ if (amount <= 0n) {
465
+ return null;
466
+ }
467
+ if (isBelowWorkingFloor({ chainId: fromChainId, amount })) {
468
+ return null;
469
+ }
470
+ const originAsset = findNearIntentsAsset({
471
+ assets: options.assets,
472
+ chainId: fromChainId,
473
+ tokenAddress: fromTokenAddress,
474
+ ecosystem: ecosystemByChainId(fromChainId),
475
+ });
476
+ if (originAsset === null) {
477
+ return null;
478
+ }
479
+ // The delivered side of every route this module can express lands on an
480
+ // entry chain, which is always an Ethereum Virtual Machine chain.
481
+ const destinationAsset = findNearIntentsAsset({
482
+ assets: options.assets,
483
+ chainId: toChainId,
484
+ tokenAddress: toTokenAddress,
485
+ ecosystem: 'evm',
486
+ });
487
+ if (destinationAsset === null) {
488
+ return null;
489
+ }
490
+ const refund = refundFields({ fromChainId, fromAddress, connectedEthereumAccount });
491
+ if (refund === null) {
492
+ return null;
493
+ }
494
+ return {
495
+ dry: options.dry,
496
+ // A reader paying by hand gets the flexible band; a reader whose wallet
497
+ // composes the transfer to the exact base unit does not need one.
498
+ swapType: isDepositAddressChain(fromChainId) ? 'FLEX_INPUT' : swapType,
499
+ slippageTolerance: slippage === undefined
500
+ ? defaultSlippageBasisPoints
501
+ : slippageToNearIntentsBasisPoints(slippage),
502
+ originAsset: originAsset.assetId,
503
+ depositType: 'ORIGIN_CHAIN',
504
+ destinationAsset: destinationAsset.assetId,
505
+ amount: amount.toString(),
506
+ ...refund,
507
+ recipient,
508
+ recipientType: 'DESTINATION_CHAIN',
509
+ deadline: new Date(options.now.getTime() + defaultDeadlineMilliseconds).toISOString(),
510
+ ...(options.referral === undefined ? {} : { referral: options.referral }),
511
+ };
512
+ };
513
+ /**
514
+ * Builds the forward-solving origin-to-anchor pre-quote body.
515
+ *
516
+ * READ THE HEAD OF THIS MODULE BEFORE CALLING THIS FROM THE ROUTER. The
517
+ * entry-anchor pre-quote takes the largest delivered amount across providers
518
+ * and hands it to the providers that execute, so a pre-quote from a provider
519
+ * that cannot execute raises a target nobody can reach. This builder exists
520
+ * because the request shape is real and testable, not because that wiring is
521
+ * wanted.
522
+ *
523
+ * @param request - the provider-agnostic pre-quote request
524
+ * @param options - the asset list, the clock, and the referral tag
525
+ * @returns the request body, or null when the service carries neither asset or
526
+ * the amount is not positive
527
+ */
528
+ export const buildNearIntentsPreQuoteRequest = (request, context) => {
529
+ // A pre-quote prices a route it will never execute, so it must not reserve a
530
+ // deposit address; asking dry also keeps it out of the service's records.
531
+ const options = buildOptionsFrom(context, { dry: true });
532
+ if (options === null) {
533
+ return null;
534
+ }
535
+ const recipient = request.recipient ?? request.fromAddress;
536
+ return buildBody({
537
+ fromChainId: request.fromChainId,
538
+ fromTokenAddress: request.fromTokenAddress,
539
+ fromAddress: request.fromAddress,
540
+ // A pre-quote carries no connected account, so a pay-by-hand origin builds
541
+ // no pre-quote. None is wanted: this provider never bids in the pre-quote.
542
+ connectedEthereumAccount: undefined,
543
+ toChainId: request.toChainId,
544
+ toTokenAddress: request.toTokenAddress,
545
+ recipient,
546
+ amount: request.amount,
547
+ swapType: 'EXACT_INPUT',
548
+ slippage: request.slippage,
549
+ options: { ...options, dry: true },
550
+ });
551
+ };
552
+ /**
553
+ * Builds the execution quote body for a crossing with nothing to run on arrival.
554
+ *
555
+ * REFUSES ANY REQUEST CARRYING A DESTINATION CALL, and that refusal is the
556
+ * whole point of it. The service has no field a call can travel in, so a
557
+ * request built without the call would look like a valid quote for the same
558
+ * route and would deliver the reader's funds to an address instead of into the
559
+ * omnibridge — a silent, funds-affecting difference. Returning null leaves that
560
+ * route to the providers that execute.
561
+ *
562
+ * @param request - the provider-agnostic consolidated request
563
+ * @param context - carries the asset list, the clock and the referral tag
564
+ * @param options.dry - price only, reserving nothing. What a route ON DISPLAY
565
+ * must ask; a live quote reserves a deposit address for three days, so it is
566
+ * asked only when the reader commits. See `UnreservedDeposit`.
567
+ * @returns the request body, or null when the route needs a destination call,
568
+ * names no input amount, or the service carries neither asset
569
+ */
570
+ export const buildNearIntentsQuoteRequest = (request, context, { dry = false } = {}) => {
571
+ if (request.destinationCall !== undefined) {
572
+ return null;
573
+ }
574
+ const { fromAmount, toChainId } = request;
575
+ if (fromAmount === undefined || toChainId === undefined) {
576
+ return null;
577
+ }
578
+ const options = buildOptionsFrom(context, { dry });
579
+ if (options === null) {
580
+ return null;
581
+ }
582
+ return buildBody({
583
+ fromChainId: request.fromChainId,
584
+ fromTokenAddress: request.fromTokenAddress,
585
+ fromAddress: request.fromAddress,
586
+ connectedEthereumAccount: request.connectedEthereumAccount,
587
+ toChainId,
588
+ toTokenAddress: request.toTokenAddress,
589
+ recipient: request.recipient,
590
+ amount: fromAmount,
591
+ swapType: 'EXACT_INPUT',
592
+ slippage: request.slippage,
593
+ options: { ...options, dry },
594
+ });
595
+ };
596
+ const parseAmount = (value) => {
597
+ if (typeof value !== 'string' || value === '') {
598
+ return null;
599
+ }
600
+ try {
601
+ return BigInt(value);
602
+ }
603
+ catch {
604
+ return null;
605
+ }
606
+ };
607
+ /**
608
+ * Parses a quote response into the delivered amounts a pre-quote reports.
609
+ *
610
+ * `amountOut` is the expected delivery and `minAmountOut` the guaranteed floor
611
+ * after slippage, which is the same pair the other providers' pre-quote parsers
612
+ * produce, so the three rank on like figures.
613
+ *
614
+ * @param payload - the raw JSON body from `POST /v0/quote`
615
+ * @returns the delivered amounts, or null when the payload is an error or
616
+ * otherwise not a quote
617
+ */
618
+ export const parseNearIntentsPreQuote = (payload) => {
619
+ const quote = asRecord(asRecord(payload)?.quote);
620
+ if (!quote) {
621
+ return null;
622
+ }
623
+ const expectedAmount = parseAmount(quote.amountOut);
624
+ if (expectedAmount === null) {
625
+ return null;
626
+ }
627
+ const minimumAmount = parseAmount(quote.minAmountOut);
628
+ return { expectedAmount, minimumAmount: minimumAmount ?? expectedAmount };
629
+ };
630
+ /**
631
+ * Builds the transfer the reader signs to fund a quote.
632
+ *
633
+ * THE SERVICE RETURNS NO TRANSACTION, ONLY AN ADDRESS. Every other provider in
634
+ * this repository hands back a signable payload; this one hands back a deposit
635
+ * address and watches the chain for an ordinary transfer into it. So the origin
636
+ * deposit is CONSTRUCTED here from the request, not read off the response, and
637
+ * the response alone is not enough to produce one.
638
+ *
639
+ * There is never an approval. Nothing pulls from the reader's account — they
640
+ * send — so a spender allowance has nothing to authorize.
641
+ *
642
+ * @param options.depositAddress - the address the quote reserved
643
+ * @param options.amount - the input amount, in the origin token's base units
644
+ * @param options.request - the request the quote answered
645
+ * @returns the Ethereum deposit, or null for an origin family this cannot build
646
+ */
647
+ const nearIntentsOriginDeposit = ({ depositAddress, amount, request, }) => {
648
+ // Only Ethereum-virtual-machine origins are built. The other families need a
649
+ // transfer composed in that family's own encoding — a Solana instruction, a
650
+ // Bitcoin transaction — and no signer in this repository accepts a transfer
651
+ // this module composed. Returning null declines the route rather than
652
+ // handing a wallet a payload meant for a different chain.
653
+ //
654
+ // Derived from the chain id, never from the request's own `originVmType` —
655
+ // see the note on {@link buildBody}, which fixes the same signal for the
656
+ // same reason.
657
+ if (ecosystemByChainId(request.fromChainId) !== 'evm') {
658
+ return null;
659
+ }
660
+ if (!depositAddress.startsWith('0x')) {
661
+ return null;
662
+ }
663
+ const chainId = request.fromChainId;
664
+ if (isNativeAsset(request.fromTokenAddress, 'evm')) {
665
+ return {
666
+ ecosystem: 'evm',
667
+ to: depositAddress,
668
+ data: '0x',
669
+ value: amount,
670
+ chainId,
671
+ gasLimit: null,
672
+ };
673
+ }
674
+ return {
675
+ ecosystem: 'evm',
676
+ to: request.fromTokenAddress,
677
+ data: encodeFunctionData({
678
+ abi: erc20Abi,
679
+ functionName: 'transfer',
680
+ args: [depositAddress, amount],
681
+ }),
682
+ value: 0n,
683
+ chainId,
684
+ gasLimit: null,
685
+ };
686
+ };
687
+ /**
688
+ * Builds the deposit for a chain the reader pays by hand: an address, an
689
+ * amount, a tolerance and a clock, and nothing to sign.
690
+ *
691
+ * EVERY FIELD IS READ OFF THE LIVE ANSWER, and each one refuses rather than
692
+ * defaults, because a wrong figure here is shown to a person who then sends
693
+ * money by hand:
694
+ *
695
+ * - the address must be a well-formed address ON THE ORIGIN CHAIN. An answer
696
+ * that issued an Ethereum-shaped address for a Zcash route is not a Zcash
697
+ * deposit, whatever else it says.
698
+ * - the displayed minimum is the LARGER of two floors, never `minAmountIn`
699
+ * alone. `minAmountIn` is the service's flexible-input floor, priced about
700
+ * one percent under the quoted amount; {@link isBelowWorkingFloor} is the
701
+ * bridge's own floor, which does not move with the quote and can sit above
702
+ * `minAmountIn` for a small Zcash amount. A reader told to send the smaller
703
+ * figure sends a deposit the bridge will not credit, and it stalls with no
704
+ * published recovery — see {@link zcashWorkingFloorBaseUnits}. The combined
705
+ * minimum still may not exceed the quoted amount; one that did would tell
706
+ * the reader to send more than asked, so that is refused rather than shown.
707
+ * - `deadline` is the answer's own. The request's echoed deadline is ignored
708
+ * by the service, which substitutes one three days out — see
709
+ * {@link defaultDeadlineMilliseconds}.
710
+ *
711
+ * @param options.quote - the `quote` record of a live answer
712
+ * @param options.depositAddress - the address that answer issued
713
+ * @param options.amount - the quoted input, in base units of the origin asset
714
+ * @param options.chainId - the origin chain
715
+ * @param options.decimals - the origin asset's decimal places
716
+ * @returns the deposit, or null when any field is missing or malformed
717
+ */
718
+ const depositAddressOriginDeposit = ({ quote, depositAddress, amount, chainId, decimals, }) => {
719
+ if (!isValidAddressIn(depositAddress, ecosystemByChainId(chainId))) {
720
+ return null;
721
+ }
722
+ const flexibleInputFloor = parseAmount(quote.minAmountIn);
723
+ if (flexibleInputFloor === null || flexibleInputFloor <= 0n) {
724
+ return null;
725
+ }
726
+ const bridgeWorkingFloor = workingFloorByChainId.get(chainId) ?? 0n;
727
+ const minimumAmount = flexibleInputFloor > bridgeWorkingFloor ? flexibleInputFloor : bridgeWorkingFloor;
728
+ if (minimumAmount > amount) {
729
+ return null;
730
+ }
731
+ const expiresAtMs = typeof quote.deadline === 'string' ? Date.parse(quote.deadline) : Number.NaN;
732
+ if (Number.isNaN(expiresAtMs)) {
733
+ return null;
734
+ }
735
+ return {
736
+ kind: 'deposit-address',
737
+ chainId,
738
+ depositAddress,
739
+ amount,
740
+ minimumAmount,
741
+ decimals,
742
+ expiresAtMs,
743
+ };
744
+ };
745
+ /**
746
+ * Reads what the route charges.
747
+ *
748
+ * TWO OF THE THREE NUMBERS THE SERVICE REPORTS ARE COSTS, AND ONE IS NOT.
749
+ * `withdrawFee` is taken out of the delivered amount on every successful route,
750
+ * and the key-less surcharge in the echoed `appFees` is taken out of the input,
751
+ * so both are real. `refundFee` is charged ONLY when a swap is refunded — it is
752
+ * the price of a route that did not happen — so counting it would overstate
753
+ * what a completed crossing costs, and it is deliberately left out.
754
+ */
755
+ const nearIntentsQuoteFees = ({ quote, quoteRequest, originToken, destinationToken, }) => {
756
+ const fees = [];
757
+ const withdrawFee = parseAmount(quote.withdrawFee);
758
+ if (withdrawFee !== null && withdrawFee > 0n) {
759
+ fees.push({
760
+ kind: 'service',
761
+ label: 'Withdrawal fee',
762
+ amount: withdrawFee,
763
+ token: destinationToken,
764
+ amountUsd: null,
765
+ // Taken out of what is delivered rather than added to what is sent, so
766
+ // the reader never pays it on top.
767
+ includedInFromAmount: true,
768
+ });
769
+ }
770
+ const amountIn = parseAmount(quote.amountIn);
771
+ const appFees = quoteRequest?.appFees;
772
+ if (amountIn === null || !Array.isArray(appFees)) {
773
+ return fees;
774
+ }
775
+ for (const entry of appFees) {
776
+ const basisPoints = asRecord(entry)?.fee;
777
+ if (typeof basisPoints !== 'number' || basisPoints <= 0) {
778
+ continue;
779
+ }
780
+ fees.push({
781
+ kind: 'service',
782
+ label: 'NEAR Intents platform fee',
783
+ amount: (amountIn * BigInt(Math.round(basisPoints))) / 10000n,
784
+ token: originToken,
785
+ amountUsd: null,
786
+ includedInFromAmount: true,
787
+ });
788
+ }
789
+ return fees;
790
+ };
791
+ /**
792
+ * Normalizes a quote response into the shared consolidated quote shape.
793
+ *
794
+ * READS THE REQUEST AS WELL AS THE RESPONSE, unlike every other parser here,
795
+ * and it has to: the response names a deposit address and no transaction, so
796
+ * the origin chain, the origin token and the ecosystem — everything needed to
797
+ * build the transfer the reader signs — exist only on the request. That is why
798
+ * {@link ProviderQuoteContext} carries a request at all.
799
+ *
800
+ * A DRY QUOTE IS REFUSED. It carries prices and no deposit address, so it can
801
+ * never be executed, and a quote that cannot be executed must not be returned
802
+ * as one.
803
+ *
804
+ * @param payload - the raw JSON body from `POST /v0/quote`
805
+ * @param context - carries the request this answers and both tokens' metadata
806
+ * @returns the normalized quote, or null when the payload cannot produce one
807
+ */
808
+ /**
809
+ * The parts of an answer every NEAR Intents quote needs, reserved or not.
810
+ *
811
+ * @returns the pieces, or null when the context or the body is unusable
812
+ */
813
+ const quoteEssentials = (payload, context) => {
814
+ const request = context?.request;
815
+ const originToken = context?.originToken;
816
+ const destinationToken = context?.destinationToken;
817
+ // Without the request there is no chain and no token to compose a transfer
818
+ // from, so there is no quote to return — the same answer as a malformed body.
819
+ if (request === undefined || originToken === undefined || destinationToken === undefined) {
820
+ return null;
821
+ }
822
+ const body = asRecord(payload);
823
+ const quote = asRecord(body?.quote);
824
+ if (!quote) {
825
+ return null;
826
+ }
827
+ const fromAmount = parseAmount(quote.amountIn);
828
+ if (fromAmount === null) {
829
+ return null;
830
+ }
831
+ if (typeof quote.timeEstimate !== 'number') {
832
+ return null;
833
+ }
834
+ return { request, originToken, destinationToken, body, quote, fromAmount, timeEstimate: quote.timeEstimate };
835
+ };
836
+ /**
837
+ * Assembles the shared quote shape around a deposit.
838
+ *
839
+ * @returns the normalized quote
840
+ */
841
+ const nearIntentsQuoteShape = ({ essentials: { body, quote, fromAmount, timeEstimate, originToken, destinationToken }, quoteId, originDeposit, }) => {
842
+ const toAmount = parseAmount(quote.amountOut);
843
+ return {
844
+ provider: 'near-intents',
845
+ quoteId,
846
+ tool: 'near-intents',
847
+ toolName: 'NEAR Intents',
848
+ fromAmount,
849
+ ...(toAmount === null ? {} : { toAmount }),
850
+ fromAmountUsd: typeof quote.amountInUsd === 'string' ? quote.amountInUsd : null,
851
+ ...(typeof quote.amountOutUsd === 'string' ? { toAmountUsd: quote.amountOutUsd } : {}),
852
+ // Nothing pulls from the reader's account, so no spender is ever approved.
853
+ approvalAddress: null,
854
+ executionDurationSeconds: timeEstimate,
855
+ // The service reports no per-leg breakdown, only the two ends of the trade.
856
+ steps: [],
857
+ originDeposit,
858
+ fees: nearIntentsQuoteFees({
859
+ quote,
860
+ quoteRequest: asRecord(body?.quoteRequest),
861
+ originToken,
862
+ destinationToken,
863
+ }),
864
+ };
865
+ };
866
+ /**
867
+ * Normalizes a DRY answer into a quote that can be drawn and not paid.
868
+ *
869
+ * Same figures, fees and timing as a live quote; its deposit is
870
+ * {@link UnreservedDeposit} and its `quoteId` is empty, because a dry answer
871
+ * reserves no address and the address is a quote's identity. A caller that
872
+ * reaches the commit asks live with `buildNearIntentsQuoteRequest(…, { dry:
873
+ * false })` and pays that answer instead.
874
+ *
875
+ * @param payload - the raw JSON body of a `dry: true` quote
876
+ * @param context - the same context the live parser reads
877
+ * @returns the priced quote, or null when the body is not a quote
878
+ */
879
+ export const parseNearIntentsPriceQuote = (payload, context) => {
880
+ const essentials = quoteEssentials(payload, context);
881
+ if (essentials === null) {
882
+ return null;
883
+ }
884
+ return nearIntentsQuoteShape({
885
+ essentials,
886
+ quoteId: '',
887
+ originDeposit: { kind: 'unreserved', chainId: essentials.request.fromChainId },
888
+ });
889
+ };
890
+ export const parseNearIntentsQuote = (payload, context) => {
891
+ const essentials = quoteEssentials(payload, context);
892
+ if (essentials === null) {
893
+ return null;
894
+ }
895
+ const { request, originToken, quote, fromAmount } = essentials;
896
+ const depositAddress = quote.depositAddress;
897
+ if (typeof depositAddress !== 'string' || depositAddress === '') {
898
+ return null;
899
+ }
900
+ const originDeposit = isDepositAddressChain(request.fromChainId)
901
+ ? depositAddressOriginDeposit({
902
+ quote,
903
+ depositAddress,
904
+ amount: fromAmount,
905
+ chainId: request.fromChainId,
906
+ decimals: originToken.decimals,
907
+ })
908
+ : nearIntentsOriginDeposit({ depositAddress, amount: fromAmount, request });
909
+ if (originDeposit === null) {
910
+ return null;
911
+ }
912
+ // The deposit address is this quote's identity: the service issues a fresh
913
+ // one per quote and the status endpoint is keyed by it. See
914
+ // {@link nearIntentsStatusUrl}.
915
+ return nearIntentsQuoteShape({ essentials, quoteId: depositAddress, originDeposit });
916
+ };
917
+ // "Temporary swap limits: minimum swap amount is $1,000" — thousands separators
918
+ // and all. Captured live against Binance Smart Chain, Polygon and Tron.
919
+ const chainMinimumPattern = /minimum swap amount is \$([\d,]+(?:\.\d+)?)/;
920
+ // "Amount is too low for bridge, try at least 303824" — base units of the
921
+ // origin asset, quoted live and different on consecutive requests.
922
+ const bridgeMinimumPattern = /try at least (\d+)/;
923
+ /**
924
+ * Reads why a quote request was declined.
925
+ *
926
+ * WORTH READING RATHER THAN TREATING AS A FAILURE. A route below one of these
927
+ * floors is not a fault to retry — it is a route this provider will never
928
+ * carry at that size, so a router can drop the provider from the competition
929
+ * and a surface can say what size would work. Left as a bare rejection, the
930
+ * same route simply produces a slower answer with one fewer bidder and nothing
931
+ * on screen to explain it.
932
+ *
933
+ * @param payload - the raw JSON body of a declined `POST /v0/quote`
934
+ * @returns the refusal, or null when the body carries no message to read
935
+ */
936
+ export const parseNearIntentsQuoteRefusal = (payload) => {
937
+ const message = asRecord(payload)?.message;
938
+ if (typeof message !== 'string') {
939
+ return null;
940
+ }
941
+ const chainMinimum = chainMinimumPattern.exec(message);
942
+ if (chainMinimum?.[1] !== undefined) {
943
+ return { reason: 'belowChainMinimum', minimumUsd: Number(chainMinimum[1].replaceAll(',', '')) };
944
+ }
945
+ const bridgeMinimum = bridgeMinimumPattern.exec(message);
946
+ if (bridgeMinimum?.[1] !== undefined) {
947
+ return { reason: 'belowBridgeMinimum', minimumAmount: BigInt(bridgeMinimum[1]) };
948
+ }
949
+ if (message.includes('No liquidity available')) {
950
+ return { reason: 'noLiquidity' };
951
+ }
952
+ return { reason: 'other' };
953
+ };
954
+ /**
955
+ * The lifecycle states the status endpoint reports. `SUCCESS`, `REFUNDED` and
956
+ * `FAILED` are terminal; the rest are still in flight.
957
+ */
958
+ const nearIntentsInFlightStatuses = [
959
+ 'KNOWN_DEPOSIT_TX',
960
+ 'PENDING_DEPOSIT',
961
+ 'INCOMPLETE_DEPOSIT',
962
+ 'PROCESSING',
963
+ ];
964
+ const isEthereumTransactionHash = (value) => typeof value === 'string' && /^0x[0-9a-fA-F]{64}$/.test(value);
965
+ /**
966
+ * Finds the destination-chain transaction hash, which is what hands a route
967
+ * over from provider polling to the omnibridge indexer.
968
+ *
969
+ * The hashes arrive as records carrying a `hash` and an explorer address, and
970
+ * the destination chain may not be an Ethereum one, so anything that is not a
971
+ * full thirty-two-byte hash collapses to null rather than being passed on as
972
+ * though it were a transaction.
973
+ */
974
+ const destinationTransactionHash = (swapDetails) => {
975
+ const hashes = swapDetails?.destinationChainTxHashes;
976
+ if (!Array.isArray(hashes)) {
977
+ return null;
978
+ }
979
+ for (const entry of hashes) {
980
+ const hash = asRecord(entry)?.hash;
981
+ if (isEthereumTransactionHash(hash)) {
982
+ return hash;
983
+ }
984
+ }
985
+ return null;
986
+ };
987
+ /**
988
+ * Where a refund was credited, when it went into an account inside NEAR
989
+ * Intents rather than back to the origin chain.
990
+ *
991
+ * READ FROM THE PROVIDER'S OWN ECHO OF THE REQUEST. The status response carries
992
+ * the quote request it answered, so the refund's account, type and asset come
993
+ * from what the provider says it did — not from a guess on our side about
994
+ * which asset a chain's refund would be in. The amount is the provider's own
995
+ * `refundedAmount`.
996
+ *
997
+ * NULL FOR AN `ORIGIN_CHAIN` REFUND, which lands back in the reader's own
998
+ * origin wallet and needs nothing from them, and null whenever any field is
999
+ * missing: a refund step that names the wrong account or asset would send the
1000
+ * reader looking in the wrong place.
1001
+ *
1002
+ * @param body - the whole status response
1003
+ * @param swapDetails - its `swapDetails` record
1004
+ * @returns the account refund, or null
1005
+ */
1006
+ const intentsAccountRefund = (body, swapDetails) => {
1007
+ const request = asRecord(asRecord(body?.quoteResponse)?.quoteRequest);
1008
+ if (request?.refundType !== 'INTENTS') {
1009
+ return null;
1010
+ }
1011
+ const { refundTo, originAsset } = request;
1012
+ const refundedAmount = swapDetails?.refundedAmount;
1013
+ if (typeof refundTo !== 'string' || typeof originAsset !== 'string') {
1014
+ return null;
1015
+ }
1016
+ if (typeof refundedAmount !== 'string' || !/^\d+$/.test(refundedAmount)) {
1017
+ return null;
1018
+ }
1019
+ return { account: refundTo.toLowerCase(), assetId: originAsset, baseUnits: refundedAmount };
1020
+ };
1021
+ /**
1022
+ * Normalizes a `GET /v0/status` response.
1023
+ *
1024
+ * @param payload - the raw JSON body
1025
+ * @returns the normalized status, or null when the payload names no status
1026
+ */
1027
+ export const parseNearIntentsStatus = (payload) => {
1028
+ const body = asRecord(payload);
1029
+ const status = body?.status;
1030
+ if (typeof status !== 'string') {
1031
+ return null;
1032
+ }
1033
+ const swapDetails = asRecord(body?.swapDetails);
1034
+ const receivingTxHash = destinationTransactionHash(swapDetails);
1035
+ if (status === 'SUCCESS') {
1036
+ return { status: 'DONE', substatus: 'COMPLETED', receivingTxHash };
1037
+ }
1038
+ if (status === 'REFUNDED') {
1039
+ return {
1040
+ status: 'DONE',
1041
+ substatus: 'REFUNDED',
1042
+ receivingTxHash: null,
1043
+ refund: intentsAccountRefund(body, swapDetails),
1044
+ };
1045
+ }
1046
+ if (status === 'FAILED') {
1047
+ return { status: 'FAILED', substatus: status, receivingTxHash: null };
1048
+ }
1049
+ if (nearIntentsInFlightStatuses.some((inFlight) => inFlight === status)) {
1050
+ return { status: 'PENDING', substatus: status, receivingTxHash };
1051
+ }
1052
+ // An unrecognized state is treated as still settling rather than terminal, so
1053
+ // a new state the service adds never reads as a completed transfer.
1054
+ return { status: 'NOT_FOUND', substatus: status, receivingTxHash };
1055
+ };