@defuse-protocol/nearintents-mpp-sdk 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +235 -0
- package/dist/Errors.d.ts +57 -0
- package/dist/Errors.d.ts.map +1 -0
- package/dist/Errors.js +54 -0
- package/dist/Errors.js.map +1 -0
- package/dist/Methods.d.ts +44 -0
- package/dist/Methods.d.ts.map +1 -0
- package/dist/Methods.js +23 -0
- package/dist/Methods.js.map +1 -0
- package/dist/Types.d.ts +125 -0
- package/dist/Types.d.ts.map +1 -0
- package/dist/Types.js +195 -0
- package/dist/Types.js.map +1 -0
- package/dist/client/Charge.d.ts +141 -0
- package/dist/client/Charge.d.ts.map +1 -0
- package/dist/client/Charge.js +167 -0
- package/dist/client/Charge.js.map +1 -0
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +5 -0
- package/dist/client/index.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/OneClick.d.ts +243 -0
- package/dist/internal/OneClick.d.ts.map +1 -0
- package/dist/internal/OneClick.js +422 -0
- package/dist/internal/OneClick.js.map +1 -0
- package/dist/server/Charge.d.ts +241 -0
- package/dist/server/Charge.d.ts.map +1 -0
- package/dist/server/Charge.js +404 -0
- package/dist/server/Charge.js.map +1 -0
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +5 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +87 -9
- package/src/Errors.ts +86 -0
- package/src/Methods.ts +24 -0
- package/src/Types.ts +256 -0
- package/src/client/Charge.ts +268 -0
- package/src/client/index.ts +4 -0
- package/src/index.ts +4 -0
- package/src/internal/OneClick.ts +634 -0
- package/src/server/Charge.ts +621 -0
- package/src/server/index.ts +4 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import { Errors } from 'mppx'
|
|
2
|
+
|
|
3
|
+
import { SettlementFailedError } from '../Errors.js'
|
|
4
|
+
import * as Types from '../Types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 1Click Swap API settlement core.
|
|
8
|
+
*
|
|
9
|
+
* Thin, dependency-free client for the three endpoints this method uses
|
|
10
|
+
* (`POST /v0/quote`, `POST /v0/deposit/submit`, `GET /v0/status`) plus the
|
|
11
|
+
* token-list-driven CAIP-19 ↔ 1Click asset mapping and the terminal-state →
|
|
12
|
+
* MPP problem-type mapping. Everything is injectable (`fetch`, `baseUrl`) so
|
|
13
|
+
* tests run against the in-process mock — never live 1Click.
|
|
14
|
+
*
|
|
15
|
+
* Designed to be shared with the x402 1CS Gateway workstream: one settlement
|
|
16
|
+
* core, two protocol adapters.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const defaultBaseUrl = 'https://1click.chaindefuser.com'
|
|
20
|
+
|
|
21
|
+
export type Config = {
|
|
22
|
+
/** API base URL. @default "https://1click.chaindefuser.com" */
|
|
23
|
+
baseUrl?: string | undefined
|
|
24
|
+
/**
|
|
25
|
+
* Partner JWT (env `ONE_CLICK_JWT`). Server-side only — MUST never appear
|
|
26
|
+
* in any challenge field. Unauthenticated requests incur a 0.2% fee.
|
|
27
|
+
*/
|
|
28
|
+
jwt?: string | undefined
|
|
29
|
+
/** Fetch implementation. @default globalThis.fetch */
|
|
30
|
+
fetch?: typeof globalThis.fetch | undefined
|
|
31
|
+
/** Per-request timeout in milliseconds. @default 30000 */
|
|
32
|
+
requestTimeoutMs?: number | undefined
|
|
33
|
+
/** Additional CAIP-2 → 1Click blockchain-code entries, merged over {@link defaultNetworks}. */
|
|
34
|
+
networks?: Record<string, string> | undefined
|
|
35
|
+
/** Additional blockchain-code → SLIP-44 coin-type entries, merged over {@link defaultNativeCoinTypes}. */
|
|
36
|
+
nativeCoinTypes?: Record<string, number> | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Errors
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The 1Click API request failed (application error or unusable response).
|
|
45
|
+
* Note {@link OneClickUnavailableError} (network failure / 5xx) is a subclass —
|
|
46
|
+
* narrow on it first when the distinction matters.
|
|
47
|
+
*/
|
|
48
|
+
export class OneClickError extends Error {
|
|
49
|
+
override readonly name: string = 'OneClickError'
|
|
50
|
+
readonly status: number
|
|
51
|
+
readonly body: unknown
|
|
52
|
+
|
|
53
|
+
constructor(message: string, options: { status?: number; body?: unknown; cause?: unknown } = {}) {
|
|
54
|
+
super(message, options.cause !== undefined ? { cause: options.cause } : undefined)
|
|
55
|
+
this.status = options.status ?? 0
|
|
56
|
+
this.body = options.body
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The 1Click API was unreachable or returned a server error (network failure
|
|
62
|
+
* or HTTP 5xx). Per the spec, callers MUST surface this as an HTTP 5xx —
|
|
63
|
+
* never as `verification-failed` — and MUST NOT settle the credential.
|
|
64
|
+
*/
|
|
65
|
+
export class OneClickUnavailableError extends OneClickError {
|
|
66
|
+
override readonly name = 'OneClickUnavailableError'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `pollToTerminal` exceeded its time budget without reaching a terminal status. */
|
|
70
|
+
export class PollTimeoutError extends Error {
|
|
71
|
+
override readonly name = 'PollTimeoutError'
|
|
72
|
+
/** Last successfully observed status, if any poll succeeded. */
|
|
73
|
+
readonly lastStatus: SwapStatus | undefined
|
|
74
|
+
/** Last transport error, if polling was failing when the budget ran out. */
|
|
75
|
+
readonly lastError: unknown
|
|
76
|
+
|
|
77
|
+
constructor(options: { timeoutMs: number; lastStatus?: SwapStatus; lastError?: unknown }) {
|
|
78
|
+
super(
|
|
79
|
+
`1Click status polling timed out after ${options.timeoutMs}ms` +
|
|
80
|
+
(options.lastStatus ? ` (last status: ${options.lastStatus})` : ''),
|
|
81
|
+
)
|
|
82
|
+
this.lastStatus = options.lastStatus
|
|
83
|
+
this.lastError = options.lastError
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// API types (subset of the 1Click OpenAPI actually used by this method)
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
export type TokenInfo = {
|
|
92
|
+
/** 1Click-native asset id (e.g. `nep141:arb-0xaf88….omft.near`). */
|
|
93
|
+
assetId: string
|
|
94
|
+
decimals: number
|
|
95
|
+
/** 1Click blockchain code (e.g. "arb", "btc", "near"). */
|
|
96
|
+
blockchain: string
|
|
97
|
+
symbol: string
|
|
98
|
+
/** Absent for chain-native assets (e.g. BTC). */
|
|
99
|
+
contractAddress?: string | undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type Quote = {
|
|
103
|
+
/** Unique single-use deposit address on the origin chain. */
|
|
104
|
+
depositAddress: string
|
|
105
|
+
/** Required alongside the deposit on memo-based chains (e.g. Stellar). */
|
|
106
|
+
depositMemo?: string | undefined
|
|
107
|
+
/** Input amount with slippage buffer baked in — the challenge `amount`. */
|
|
108
|
+
amountIn: string
|
|
109
|
+
/** Minimum input that still guarantees `amountOut` — the verification threshold. */
|
|
110
|
+
minAmountIn: string
|
|
111
|
+
/** Exact output amount the merchant receives (EXACT_OUTPUT). */
|
|
112
|
+
amountOut: string
|
|
113
|
+
minAmountOut?: string | undefined
|
|
114
|
+
/** Deposit deadline (ISO 8601) — the challenge `expires` upper bound. */
|
|
115
|
+
deadline: string
|
|
116
|
+
timeWhenInactive?: string | undefined
|
|
117
|
+
/** Estimated swap completion time in seconds after deposit confirmation. */
|
|
118
|
+
timeEstimate: number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type QuoteResult = {
|
|
122
|
+
quote: Quote
|
|
123
|
+
/** Echo of the quote request. */
|
|
124
|
+
quoteRequest: Record<string, unknown>
|
|
125
|
+
/** 1Click service signature over the quote — persist for dispute resolution. */
|
|
126
|
+
signature?: string | undefined
|
|
127
|
+
timestamp?: string | undefined
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type SwapStatus =
|
|
131
|
+
| 'KNOWN_DEPOSIT_TX'
|
|
132
|
+
| 'PENDING_DEPOSIT'
|
|
133
|
+
| 'INCOMPLETE_DEPOSIT'
|
|
134
|
+
| 'PROCESSING'
|
|
135
|
+
| 'SUCCESS'
|
|
136
|
+
| 'REFUNDED'
|
|
137
|
+
| 'FAILED'
|
|
138
|
+
|
|
139
|
+
export type TransactionDetails = {
|
|
140
|
+
hash: string
|
|
141
|
+
explorerUrl?: string | undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type SwapDetails = {
|
|
145
|
+
originChainTxHashes?: TransactionDetails[] | undefined
|
|
146
|
+
destinationChainTxHashes?: TransactionDetails[] | undefined
|
|
147
|
+
intentHashes?: string[] | undefined
|
|
148
|
+
nearTxHashes?: string[] | undefined
|
|
149
|
+
amountIn?: string | undefined
|
|
150
|
+
amountOut?: string | undefined
|
|
151
|
+
depositedAmount?: string | undefined
|
|
152
|
+
refundedAmount?: string | undefined
|
|
153
|
+
refundReason?: string | undefined
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type StatusResult = {
|
|
157
|
+
status: SwapStatus
|
|
158
|
+
updatedAt?: string | undefined
|
|
159
|
+
swapDetails?: SwapDetails | undefined
|
|
160
|
+
quoteResponse?: Record<string, unknown> | undefined
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Terminal settlement statuses per the spec (§Settlement Procedure): the swap
|
|
165
|
+
* either delivered (`SUCCESS`) or the deposit is refunded to `refundTo`.
|
|
166
|
+
* `INCOMPLETE_DEPOSIT` is treated as terminal for settlement even though the
|
|
167
|
+
* backend may still accept top-ups before the deadline.
|
|
168
|
+
*/
|
|
169
|
+
export const terminalStatuses = ['SUCCESS', 'FAILED', 'REFUNDED', 'INCOMPLETE_DEPOSIT'] as const
|
|
170
|
+
|
|
171
|
+
export function isTerminal(status: SwapStatus): boolean {
|
|
172
|
+
return (terminalStatuses as readonly string[]).includes(status)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// HTTP core
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
async function request<result>(
|
|
180
|
+
config: Config,
|
|
181
|
+
path: string,
|
|
182
|
+
init: { method: 'GET' | 'POST'; body?: unknown; searchParams?: Record<string, string> },
|
|
183
|
+
): Promise<result> {
|
|
184
|
+
const baseUrl = config.baseUrl ?? defaultBaseUrl
|
|
185
|
+
const fetchFn = config.fetch ?? globalThis.fetch
|
|
186
|
+
const url = new URL(path, baseUrl)
|
|
187
|
+
for (const [key, value] of Object.entries(init.searchParams ?? {})) {
|
|
188
|
+
url.searchParams.set(key, value)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let response: Response
|
|
192
|
+
try {
|
|
193
|
+
response = await fetchFn(url, {
|
|
194
|
+
method: init.method,
|
|
195
|
+
headers: {
|
|
196
|
+
accept: 'application/json',
|
|
197
|
+
...(init.body !== undefined && { 'content-type': 'application/json' }),
|
|
198
|
+
...(config.jwt && { authorization: `Bearer ${config.jwt}` }),
|
|
199
|
+
},
|
|
200
|
+
...(init.body !== undefined && { body: JSON.stringify(init.body) }),
|
|
201
|
+
signal: AbortSignal.timeout(config.requestTimeoutMs ?? 30_000),
|
|
202
|
+
})
|
|
203
|
+
} catch (error) {
|
|
204
|
+
throw new OneClickUnavailableError(`1Click request failed: ${init.method} ${path}`, {
|
|
205
|
+
cause: error,
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const body = await response.json().catch(() => undefined)
|
|
210
|
+
|
|
211
|
+
if (response.status >= 500)
|
|
212
|
+
throw new OneClickUnavailableError(
|
|
213
|
+
`1Click returned ${response.status} for ${init.method} ${path}`,
|
|
214
|
+
{ status: response.status, body },
|
|
215
|
+
)
|
|
216
|
+
if (!response.ok)
|
|
217
|
+
throw new OneClickError(
|
|
218
|
+
`1Click returned ${response.status} for ${init.method} ${path}: ${detailOf(body)}`,
|
|
219
|
+
{ status: response.status, body },
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
return body as result
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function detailOf(body: unknown): string {
|
|
226
|
+
if (body && typeof body === 'object' && 'message' in body) return String(body.message)
|
|
227
|
+
return body === undefined ? 'no response body' : JSON.stringify(body)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// Endpoints
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
/** Fetches the supported token list (`GET /v0/tokens`). No auth required. */
|
|
235
|
+
export async function getTokens(config: Config): Promise<TokenInfo[]> {
|
|
236
|
+
const tokens = await request<TokenInfo[]>(config, '/v0/tokens', { method: 'GET' })
|
|
237
|
+
if (!Array.isArray(tokens))
|
|
238
|
+
throw new OneClickError('1Click token list response is not an array.', { body: tokens })
|
|
239
|
+
return tokens
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export type QuoteParameters = {
|
|
243
|
+
/** Origin asset as a 1Click asset id (map from CAIP-19 via {@link AssetMap}). */
|
|
244
|
+
originAsset: string
|
|
245
|
+
/** Destination asset as a 1Click asset id. */
|
|
246
|
+
destinationAsset: string
|
|
247
|
+
/** Exact output amount the merchant must receive, in base units (EXACT_OUTPUT). */
|
|
248
|
+
amountOut: string
|
|
249
|
+
/** Merchant address on the destination chain. */
|
|
250
|
+
recipient: string
|
|
251
|
+
/** Merchant-configured refund address on the origin chain. */
|
|
252
|
+
refundTo: string
|
|
253
|
+
/** Slippage tolerance in basis points (applied to the input side). */
|
|
254
|
+
slippageTolerance: number
|
|
255
|
+
/** Quote/deposit deadline (ISO 8601). Size to the origin chain (BTC ≈ 45–60 min). */
|
|
256
|
+
deadline: string
|
|
257
|
+
/** `SIMPLE` (default) or `MEMO` for memo-based chains. */
|
|
258
|
+
depositMode?: 'SIMPLE' | 'MEMO' | undefined
|
|
259
|
+
referral?: string | undefined
|
|
260
|
+
sessionId?: string | undefined
|
|
261
|
+
quoteWaitingTimeMs?: number | undefined
|
|
262
|
+
/** Escape hatch merged last into the raw request body. */
|
|
263
|
+
overrides?: Record<string, unknown> | undefined
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Requests a wet (`dry: false`) EXACT_OUTPUT quote (`POST /v0/quote`),
|
|
268
|
+
* yielding the unique single-use deposit address for one challenge.
|
|
269
|
+
*/
|
|
270
|
+
export async function quote(config: Config, parameters: QuoteParameters): Promise<QuoteResult> {
|
|
271
|
+
const {
|
|
272
|
+
amountOut,
|
|
273
|
+
deadline,
|
|
274
|
+
depositMode,
|
|
275
|
+
destinationAsset,
|
|
276
|
+
originAsset,
|
|
277
|
+
overrides,
|
|
278
|
+
quoteWaitingTimeMs,
|
|
279
|
+
recipient,
|
|
280
|
+
referral,
|
|
281
|
+
refundTo,
|
|
282
|
+
sessionId,
|
|
283
|
+
slippageTolerance,
|
|
284
|
+
} = parameters
|
|
285
|
+
|
|
286
|
+
const body = {
|
|
287
|
+
dry: false,
|
|
288
|
+
swapType: 'EXACT_OUTPUT',
|
|
289
|
+
depositType: 'ORIGIN_CHAIN',
|
|
290
|
+
recipientType: 'DESTINATION_CHAIN',
|
|
291
|
+
refundType: 'ORIGIN_CHAIN',
|
|
292
|
+
amount: amountOut,
|
|
293
|
+
originAsset,
|
|
294
|
+
destinationAsset,
|
|
295
|
+
recipient,
|
|
296
|
+
refundTo,
|
|
297
|
+
slippageTolerance,
|
|
298
|
+
deadline,
|
|
299
|
+
...(depositMode !== undefined && { depositMode }),
|
|
300
|
+
...(referral !== undefined && { referral }),
|
|
301
|
+
...(sessionId !== undefined && { sessionId }),
|
|
302
|
+
...(quoteWaitingTimeMs !== undefined && { quoteWaitingTimeMs }),
|
|
303
|
+
...overrides,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const result = await request<{ quote?: Quote; quoteRequest?: Record<string, unknown> }>(
|
|
307
|
+
config,
|
|
308
|
+
'/v0/quote',
|
|
309
|
+
{ method: 'POST', body },
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
const quote_ = result?.quote
|
|
313
|
+
for (const field of ['depositAddress', 'amountIn', 'minAmountIn', 'deadline'] as const) {
|
|
314
|
+
if (!quote_?.[field])
|
|
315
|
+
throw new OneClickError(`1Click quote response is missing "quote.${field}".`, {
|
|
316
|
+
body: result,
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
quote: quote_ as Quote,
|
|
322
|
+
quoteRequest: result.quoteRequest ?? body,
|
|
323
|
+
...(hasString(result, 'signature') && { signature: result.signature }),
|
|
324
|
+
...(hasString(result, 'timestamp') && { timestamp: result.timestamp }),
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function hasString<key extends string>(value: unknown, key: key): value is Record<key, string> {
|
|
329
|
+
return (
|
|
330
|
+
typeof value === 'object' &&
|
|
331
|
+
value !== null &&
|
|
332
|
+
typeof (value as Record<string, unknown>)[key] === 'string'
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Notifies 1Click of a sent deposit (`POST /v0/deposit/submit`). Optional but
|
|
338
|
+
* recommended after verification — it accelerates deposit detection.
|
|
339
|
+
*/
|
|
340
|
+
export async function submitDeposit(
|
|
341
|
+
config: Config,
|
|
342
|
+
parameters: { txHash: string; depositAddress: string; memo?: string | undefined },
|
|
343
|
+
): Promise<StatusResult> {
|
|
344
|
+
const { depositAddress, memo, txHash } = parameters
|
|
345
|
+
return request<StatusResult>(config, '/v0/deposit/submit', {
|
|
346
|
+
method: 'POST',
|
|
347
|
+
body: { txHash, depositAddress, ...(memo !== undefined && { memo }) },
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Fetches the swap status for a deposit address (`GET /v0/status`). */
|
|
352
|
+
export async function getStatus(
|
|
353
|
+
config: Config,
|
|
354
|
+
parameters: { depositAddress: string; depositMemo?: string | undefined },
|
|
355
|
+
): Promise<StatusResult> {
|
|
356
|
+
const { depositAddress, depositMemo } = parameters
|
|
357
|
+
return request<StatusResult>(config, '/v0/status', {
|
|
358
|
+
method: 'GET',
|
|
359
|
+
searchParams: {
|
|
360
|
+
depositAddress,
|
|
361
|
+
...(depositMemo !== undefined && { depositMemo }),
|
|
362
|
+
},
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export type PollParameters = {
|
|
367
|
+
depositAddress: string
|
|
368
|
+
depositMemo?: string | undefined
|
|
369
|
+
/** Total time budget. Derive from the challenge window + `timeEstimate` + margin. */
|
|
370
|
+
timeoutMs: number
|
|
371
|
+
/** Delay between polls. @default 2000 */
|
|
372
|
+
intervalMs?: number | undefined
|
|
373
|
+
/** Invoked once per observed status change (not per poll tick). */
|
|
374
|
+
onStatus?: ((status: SwapStatus) => void) | undefined
|
|
375
|
+
signal?: AbortSignal | undefined
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Polls the status endpoint until a terminal status ({@link terminalStatuses})
|
|
380
|
+
* or the time budget is exceeded.
|
|
381
|
+
*
|
|
382
|
+
* Transient transport failures ({@link OneClickUnavailableError}) are retried
|
|
383
|
+
* until the budget runs out; if the budget ends on a failing endpoint the last
|
|
384
|
+
* error is rethrown (so callers surface 5xx, not a false timeout). A budget
|
|
385
|
+
* that ends while the swap is still in flight throws {@link PollTimeoutError}.
|
|
386
|
+
*/
|
|
387
|
+
export async function pollToTerminal(
|
|
388
|
+
config: Config,
|
|
389
|
+
parameters: PollParameters,
|
|
390
|
+
): Promise<StatusResult> {
|
|
391
|
+
const { depositAddress, depositMemo, onStatus, signal, timeoutMs } = parameters
|
|
392
|
+
const intervalMs = parameters.intervalMs ?? 2000
|
|
393
|
+
const deadline = Date.now() + timeoutMs
|
|
394
|
+
|
|
395
|
+
let lastStatus: SwapStatus | undefined
|
|
396
|
+
let lastError: unknown
|
|
397
|
+
|
|
398
|
+
while (true) {
|
|
399
|
+
signal?.throwIfAborted()
|
|
400
|
+
try {
|
|
401
|
+
const result = await getStatus(config, { depositAddress, depositMemo })
|
|
402
|
+
if (result.status !== lastStatus) onStatus?.(result.status)
|
|
403
|
+
lastStatus = result.status
|
|
404
|
+
lastError = undefined
|
|
405
|
+
if (isTerminal(result.status)) return result
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (!(error instanceof OneClickUnavailableError)) throw error
|
|
408
|
+
lastError = error
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (Date.now() + intervalMs > deadline) {
|
|
412
|
+
if (lastError !== undefined) throw lastError
|
|
413
|
+
throw new PollTimeoutError({
|
|
414
|
+
timeoutMs,
|
|
415
|
+
...(lastStatus !== undefined && { lastStatus }),
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
await sleep(intervalMs, signal)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
423
|
+
return new Promise((resolve, reject) => {
|
|
424
|
+
if (signal?.aborted) {
|
|
425
|
+
reject(signal.reason ?? new Error('Aborted.'))
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
const timer = setTimeout(() => {
|
|
429
|
+
signal?.removeEventListener('abort', onAbort)
|
|
430
|
+
resolve()
|
|
431
|
+
}, ms)
|
|
432
|
+
const onAbort = () => {
|
|
433
|
+
clearTimeout(timer)
|
|
434
|
+
reject(signal?.reason ?? new Error('Aborted.'))
|
|
435
|
+
}
|
|
436
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// Terminal-state → MPP problem mapping (spec §Error Codes)
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Maps a non-success terminal status to its MPP problem type:
|
|
446
|
+
* `INCOMPLETE_DEPOSIT` → `payment-insufficient`; `FAILED`/`REFUNDED` →
|
|
447
|
+
* `settlement-failed`. Returns `undefined` for `SUCCESS`. Throws if the
|
|
448
|
+
* status is not terminal.
|
|
449
|
+
*/
|
|
450
|
+
export function terminalError(result: StatusResult): Errors.PaymentError | undefined {
|
|
451
|
+
const { status, swapDetails } = result
|
|
452
|
+
switch (status) {
|
|
453
|
+
case 'SUCCESS':
|
|
454
|
+
return undefined
|
|
455
|
+
case 'INCOMPLETE_DEPOSIT':
|
|
456
|
+
return new Errors.PaymentInsufficientError({
|
|
457
|
+
reason: swapDetails?.depositedAmount
|
|
458
|
+
? `deposit of ${swapDetails.depositedAmount} is below the required minimum`
|
|
459
|
+
: 'deposit is below the required minimum',
|
|
460
|
+
})
|
|
461
|
+
case 'FAILED':
|
|
462
|
+
case 'REFUNDED':
|
|
463
|
+
return new SettlementFailedError({
|
|
464
|
+
reason: swapDetails?.refundReason ?? `1Click terminal status ${status}`,
|
|
465
|
+
})
|
|
466
|
+
default:
|
|
467
|
+
throw new Error(`Status "${status}" is not terminal.`)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Extracts the destination-chain delivery tx hash (the receipt `reference`). */
|
|
472
|
+
export function destinationTxHash(result: StatusResult): string | undefined {
|
|
473
|
+
return result.swapDetails?.destinationChainTxHashes?.[0]?.hash
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Checks whether the credential's `payload.hash` is among the origin-chain
|
|
478
|
+
* transactions the backend observed for this deposit address — the spec's
|
|
479
|
+
* status-observation deposit confirmation. Hex hashes compare
|
|
480
|
+
* case-insensitively; other formats (base58, …) compare exactly.
|
|
481
|
+
*/
|
|
482
|
+
export function matchesOriginTx(result: StatusResult, hash: string): boolean {
|
|
483
|
+
const hashes = result.swapDetails?.originChainTxHashes ?? []
|
|
484
|
+
return hashes.some((tx) => txHashEqual(tx.hash, hash))
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function txHashEqual(a: string, b: string): boolean {
|
|
488
|
+
const hexLike = /^(0x)?[0-9a-fA-F]{16,}$/
|
|
489
|
+
if (hexLike.test(a) && hexLike.test(b)) return normalizeHex(a) === normalizeHex(b)
|
|
490
|
+
return a === b
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function normalizeHex(hash: string): string {
|
|
494
|
+
return hash.toLowerCase().replace(/^0x/, '')
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
// CAIP-19 ↔ 1Click asset mapping (token-list driven)
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* CAIP-2 chain identifier → 1Click blockchain code, for chains whose CAIP-2
|
|
503
|
+
* form is well established. Extensible per-instance via `Config.networks` —
|
|
504
|
+
* unsupported chains need a config entry, not a package release.
|
|
505
|
+
*/
|
|
506
|
+
export const defaultNetworks: Record<string, string> = {
|
|
507
|
+
'near:mainnet': 'near',
|
|
508
|
+
'eip155:1': 'eth',
|
|
509
|
+
'eip155:10': 'op',
|
|
510
|
+
'eip155:56': 'bsc',
|
|
511
|
+
'eip155:100': 'gnosis',
|
|
512
|
+
'eip155:137': 'pol',
|
|
513
|
+
'eip155:143': 'monad',
|
|
514
|
+
'eip155:196': 'xlayer',
|
|
515
|
+
'eip155:8453': 'base',
|
|
516
|
+
'eip155:42161': 'arb',
|
|
517
|
+
'eip155:43114': 'avax',
|
|
518
|
+
'eip155:80094': 'bera',
|
|
519
|
+
'eip155:534352': 'scroll',
|
|
520
|
+
'bip122:000000000019d6689c085ae165831e93': 'btc',
|
|
521
|
+
'bip122:12a765e31ffd4059bada1e25190f6e98': 'ltc',
|
|
522
|
+
'bip122:1a91e3dace36e2be3bf030a65679fe82': 'doge',
|
|
523
|
+
'bip122:000000000000000000651ef99cb9fcbe': 'bch',
|
|
524
|
+
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': 'sol',
|
|
525
|
+
'ton:-239': 'ton',
|
|
526
|
+
'sui:mainnet': 'sui',
|
|
527
|
+
'stellar:pubnet': 'stellar',
|
|
528
|
+
'xrpl:0': 'xrp',
|
|
529
|
+
'starknet:SN_MAIN': 'starknet',
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* 1Click blockchain code → SLIP-44 coin type used to render chain-native
|
|
534
|
+
* assets (`<caip2>/slip44:<coinType>`). Extensible via `Config.nativeCoinTypes`.
|
|
535
|
+
*/
|
|
536
|
+
export const defaultNativeCoinTypes: Record<string, number> = {
|
|
537
|
+
btc: 0,
|
|
538
|
+
ltc: 2,
|
|
539
|
+
doge: 3,
|
|
540
|
+
bch: 145,
|
|
541
|
+
eth: 60,
|
|
542
|
+
base: 60,
|
|
543
|
+
arb: 60,
|
|
544
|
+
op: 60,
|
|
545
|
+
scroll: 60,
|
|
546
|
+
bsc: 714,
|
|
547
|
+
pol: 966,
|
|
548
|
+
gnosis: 700,
|
|
549
|
+
avax: 9000,
|
|
550
|
+
xrp: 144,
|
|
551
|
+
sol: 501,
|
|
552
|
+
ton: 607,
|
|
553
|
+
stellar: 148,
|
|
554
|
+
sui: 784,
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** CAIP-19 asset-namespace used when rendering a contract asset for a chain namespace. */
|
|
558
|
+
const contractAssetNamespaces: Record<string, string> = {
|
|
559
|
+
eip155: 'erc20',
|
|
560
|
+
near: 'nep141',
|
|
561
|
+
solana: 'token',
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export type AssetMap = {
|
|
565
|
+
/** Resolves a CAIP-19 asset id to the matching 1Click token, or `undefined`. */
|
|
566
|
+
tokenOf(caip19Id: string): TokenInfo | undefined
|
|
567
|
+
/** Resolves a CAIP-19 asset id to a 1Click asset id, or `undefined`. */
|
|
568
|
+
toAssetId(caip19Id: string): string | undefined
|
|
569
|
+
/** Renders a 1Click asset id as a CAIP-19 asset id, or `undefined` when unmapped. */
|
|
570
|
+
toCaip19(assetId: string): string | undefined
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Builds a bidirectional CAIP-19 ↔ 1Click asset map from the `/v0/tokens`
|
|
575
|
+
* list. Contract assets match on `contractAddress` (case-insensitively for
|
|
576
|
+
* `eip155` chains); `slip44` assets match the chain's token that has no
|
|
577
|
+
* `contractAddress` (chain-native), and only for the chain's canonical coin
|
|
578
|
+
* type — a wrong coin type resolves to nothing rather than silently mapping
|
|
579
|
+
* to the native asset.
|
|
580
|
+
*/
|
|
581
|
+
export function createAssetMap(tokens: TokenInfo[], config: Config = {}): AssetMap {
|
|
582
|
+
const networks = { ...defaultNetworks, ...config.networks }
|
|
583
|
+
const nativeCoinTypes = { ...defaultNativeCoinTypes, ...config.nativeCoinTypes }
|
|
584
|
+
const blockchainToCaip2 = new Map<string, string>()
|
|
585
|
+
for (const [caip2Id, code] of Object.entries(networks)) {
|
|
586
|
+
if (!blockchainToCaip2.has(code)) blockchainToCaip2.set(code, caip2Id)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function blockchainOf(caip19Id: string): string | undefined {
|
|
590
|
+
const chain = Types.chainOf(caip19Id)
|
|
591
|
+
return Object.entries(networks).find(([caip2Id]) => Types.networkEqual(caip2Id, chain))?.[1]
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function tokenOf(caip19Id: string): TokenInfo | undefined {
|
|
595
|
+
const parsed = Types.parseCaip19(caip19Id)
|
|
596
|
+
const blockchain = blockchainOf(caip19Id)
|
|
597
|
+
if (!blockchain) return undefined
|
|
598
|
+
|
|
599
|
+
if (parsed.assetNamespace === 'slip44') {
|
|
600
|
+
const coinType = nativeCoinTypes[blockchain]
|
|
601
|
+
if (coinType === undefined || parsed.assetReference !== String(coinType)) return undefined
|
|
602
|
+
return tokens.find((token) => token.blockchain === blockchain && !token.contractAddress)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const caseInsensitive = parsed.chain.namespace === 'eip155'
|
|
606
|
+
const reference = caseInsensitive ? parsed.assetReference.toLowerCase() : parsed.assetReference
|
|
607
|
+
return tokens.find((token) => {
|
|
608
|
+
if (token.blockchain !== blockchain || !token.contractAddress) return false
|
|
609
|
+
const address = caseInsensitive ? token.contractAddress.toLowerCase() : token.contractAddress
|
|
610
|
+
return address === reference
|
|
611
|
+
})
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
tokenOf,
|
|
616
|
+
toAssetId(caip19Id) {
|
|
617
|
+
return tokenOf(caip19Id)?.assetId
|
|
618
|
+
},
|
|
619
|
+
toCaip19(assetId) {
|
|
620
|
+
const token = tokens.find((entry) => entry.assetId === assetId)
|
|
621
|
+
if (!token) return undefined
|
|
622
|
+
const caip2Id = blockchainToCaip2.get(token.blockchain)
|
|
623
|
+
if (!caip2Id) return undefined
|
|
624
|
+
if (token.contractAddress) {
|
|
625
|
+
const namespace = Types.parseCaip2(caip2Id).namespace
|
|
626
|
+
const assetNamespace = contractAssetNamespaces[namespace] ?? 'token'
|
|
627
|
+
return `${caip2Id}/${assetNamespace}:${token.contractAddress}`
|
|
628
|
+
}
|
|
629
|
+
const coinType = nativeCoinTypes[token.blockchain]
|
|
630
|
+
if (coinType === undefined) return undefined
|
|
631
|
+
return `${caip2Id}/slip44:${coinType}`
|
|
632
|
+
},
|
|
633
|
+
}
|
|
634
|
+
}
|