@sip-protocol/sdk 0.1.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.
@@ -0,0 +1,367 @@
1
+ /**
2
+ * NEAR 1Click API HTTP Client
3
+ *
4
+ * Provides typed access to the NEAR Intents 1Click API for cross-chain swaps.
5
+ *
6
+ * @see https://docs.near-intents.org/near-intents/integration/distribution-channels/1click-api
7
+ */
8
+
9
+ import {
10
+ type OneClickConfig,
11
+ type OneClickToken,
12
+ type OneClickQuoteRequest,
13
+ type OneClickQuoteResponse,
14
+ type OneClickDepositSubmit,
15
+ type OneClickStatusResponse,
16
+ type OneClickWithdrawal,
17
+ type OneClickError,
18
+ OneClickSwapStatus,
19
+ OneClickErrorCode,
20
+ } from '@sip-protocol/types'
21
+ import { NetworkError, ErrorCode, ValidationError } from '../errors'
22
+
23
+ /**
24
+ * Default configuration values
25
+ */
26
+ const DEFAULTS = {
27
+ baseUrl: 'https://1click.chaindefuser.com',
28
+ timeout: 30000,
29
+ } as const
30
+
31
+ /**
32
+ * HTTP client for NEAR 1Click API
33
+ *
34
+ * @example
35
+ * ```typescript
36
+ * const client = new OneClickClient({
37
+ * jwtToken: process.env.NEAR_INTENTS_JWT,
38
+ * })
39
+ *
40
+ * // Get available tokens
41
+ * const tokens = await client.getTokens()
42
+ *
43
+ * // Request a quote
44
+ * const quote = await client.quote({
45
+ * swapType: OneClickSwapType.EXACT_INPUT,
46
+ * originAsset: 'near:mainnet:wrap.near',
47
+ * destinationAsset: 'eth:1:native',
48
+ * amount: '1000000000000000000000000',
49
+ * refundTo: 'user.near',
50
+ * recipient: '0x742d35Cc...',
51
+ * depositType: 'near',
52
+ * refundType: 'near',
53
+ * recipientType: 'eth',
54
+ * })
55
+ *
56
+ * // Check status
57
+ * const status = await client.getStatus(quote.depositAddress)
58
+ * ```
59
+ */
60
+ export class OneClickClient {
61
+ private readonly baseUrl: string
62
+ private readonly jwtToken?: string
63
+ private readonly timeout: number
64
+ private readonly fetchFn: typeof fetch
65
+
66
+ constructor(config: OneClickConfig = {}) {
67
+ this.baseUrl = config.baseUrl ?? DEFAULTS.baseUrl
68
+ this.jwtToken = config.jwtToken
69
+ this.timeout = config.timeout ?? DEFAULTS.timeout
70
+ this.fetchFn = config.fetch ?? globalThis.fetch
71
+ }
72
+
73
+ /**
74
+ * Get all supported tokens
75
+ *
76
+ * @returns Array of supported tokens with metadata
77
+ */
78
+ async getTokens(): Promise<OneClickToken[]> {
79
+ return this.get<OneClickToken[]>('/v0/tokens')
80
+ }
81
+
82
+ /**
83
+ * Request a swap quote
84
+ *
85
+ * @param request - Quote request parameters
86
+ * @returns Quote response with deposit address and amounts
87
+ * @throws {NetworkError} On API errors
88
+ * @throws {ValidationError} On invalid parameters
89
+ */
90
+ async quote(request: OneClickQuoteRequest): Promise<OneClickQuoteResponse> {
91
+ this.validateQuoteRequest(request)
92
+ return this.post<OneClickQuoteResponse>('/v0/quote', request)
93
+ }
94
+
95
+ /**
96
+ * Request a dry quote (preview without deposit address)
97
+ *
98
+ * Useful for UI price estimates without committing to a swap.
99
+ *
100
+ * @param request - Quote request parameters (dry flag set automatically)
101
+ * @returns Quote preview without deposit address
102
+ */
103
+ async dryQuote(request: Omit<OneClickQuoteRequest, 'dry'>): Promise<OneClickQuoteResponse> {
104
+ return this.quote({ ...request, dry: true })
105
+ }
106
+
107
+ /**
108
+ * Submit deposit transaction notification
109
+ *
110
+ * Call this after depositing to the depositAddress to speed up detection.
111
+ *
112
+ * @param deposit - Deposit submission details
113
+ * @returns Updated quote response
114
+ */
115
+ async submitDeposit(deposit: OneClickDepositSubmit): Promise<OneClickQuoteResponse> {
116
+ if (!deposit.txHash) {
117
+ throw new ValidationError('txHash is required', 'deposit.txHash')
118
+ }
119
+ if (!deposit.depositAddress) {
120
+ throw new ValidationError('depositAddress is required', 'deposit.depositAddress')
121
+ }
122
+ return this.post<OneClickQuoteResponse>('/v0/deposit/submit', deposit)
123
+ }
124
+
125
+ /**
126
+ * Get swap status
127
+ *
128
+ * @param depositAddress - Deposit address from quote
129
+ * @param depositMemo - Optional memo for memo-based deposits
130
+ * @returns Current swap status
131
+ */
132
+ async getStatus(depositAddress: string, depositMemo?: string): Promise<OneClickStatusResponse> {
133
+ if (!depositAddress) {
134
+ throw new ValidationError('depositAddress is required', 'depositAddress')
135
+ }
136
+
137
+ const params = new URLSearchParams({ depositAddress })
138
+ if (depositMemo) {
139
+ params.set('depositMemo', depositMemo)
140
+ }
141
+
142
+ return this.get<OneClickStatusResponse>(`/v0/status?${params.toString()}`)
143
+ }
144
+
145
+ /**
146
+ * Poll status until terminal state or timeout
147
+ *
148
+ * @param depositAddress - Deposit address from quote
149
+ * @param options - Polling options
150
+ * @returns Final status when terminal state reached
151
+ */
152
+ async waitForStatus(
153
+ depositAddress: string,
154
+ options: {
155
+ /** Polling interval in ms (default: 3000) */
156
+ interval?: number
157
+ /** Maximum wait time in ms (default: 300000 = 5 minutes) */
158
+ timeout?: number
159
+ /** Callback on each status check */
160
+ onStatus?: (status: OneClickStatusResponse) => void
161
+ } = {}
162
+ ): Promise<OneClickStatusResponse> {
163
+ const interval = options.interval ?? 3000
164
+ const timeout = options.timeout ?? 300000
165
+ const startTime = Date.now()
166
+
167
+ const terminalStates = new Set([
168
+ OneClickSwapStatus.SUCCESS,
169
+ OneClickSwapStatus.FAILED,
170
+ OneClickSwapStatus.REFUNDED,
171
+ ])
172
+
173
+ while (Date.now() - startTime < timeout) {
174
+ const status = await this.getStatus(depositAddress)
175
+
176
+ if (options.onStatus) {
177
+ options.onStatus(status)
178
+ }
179
+
180
+ if (terminalStates.has(status.status)) {
181
+ return status
182
+ }
183
+
184
+ await this.delay(interval)
185
+ }
186
+
187
+ throw new NetworkError(
188
+ `Status polling timed out after ${timeout}ms`,
189
+ ErrorCode.NETWORK_TIMEOUT,
190
+ { endpoint: '/v0/status', context: { depositAddress, timeout } }
191
+ )
192
+ }
193
+
194
+ /**
195
+ * Get withdrawals for ANY_INPUT deposits
196
+ *
197
+ * @param depositAddress - Deposit address
198
+ * @param depositMemo - Optional deposit memo
199
+ * @param options - Pagination options
200
+ * @returns Array of withdrawals
201
+ */
202
+ async getWithdrawals(
203
+ depositAddress: string,
204
+ depositMemo?: string,
205
+ options: {
206
+ timestampFrom?: string
207
+ page?: number
208
+ limit?: number
209
+ sortOrder?: 'asc' | 'desc'
210
+ } = {}
211
+ ): Promise<OneClickWithdrawal[]> {
212
+ const params = new URLSearchParams({ depositAddress })
213
+ if (depositMemo) params.set('depositMemo', depositMemo)
214
+ if (options.timestampFrom) params.set('timestampFrom', options.timestampFrom)
215
+ if (options.page) params.set('page', options.page.toString())
216
+ if (options.limit) params.set('limit', options.limit.toString())
217
+ if (options.sortOrder) params.set('sortOrder', options.sortOrder)
218
+
219
+ return this.get<OneClickWithdrawal[]>(`/v0/any-input/withdrawals?${params.toString()}`)
220
+ }
221
+
222
+ // ─── Private Methods ──────────────────────────────────────────────────────────
223
+
224
+ private validateQuoteRequest(request: OneClickQuoteRequest): void {
225
+ if (!request.swapType) {
226
+ throw new ValidationError('swapType is required', 'request.swapType')
227
+ }
228
+ if (!request.originAsset) {
229
+ throw new ValidationError('originAsset is required', 'request.originAsset')
230
+ }
231
+ if (!request.destinationAsset) {
232
+ throw new ValidationError('destinationAsset is required', 'request.destinationAsset')
233
+ }
234
+ if (!request.amount) {
235
+ throw new ValidationError('amount is required', 'request.amount')
236
+ }
237
+ if (!request.refundTo) {
238
+ throw new ValidationError('refundTo is required', 'request.refundTo')
239
+ }
240
+ if (!request.recipient) {
241
+ throw new ValidationError('recipient is required', 'request.recipient')
242
+ }
243
+ if (!request.depositType) {
244
+ throw new ValidationError('depositType is required', 'request.depositType')
245
+ }
246
+ if (!request.refundType) {
247
+ throw new ValidationError('refundType is required', 'request.refundType')
248
+ }
249
+ if (!request.recipientType) {
250
+ throw new ValidationError('recipientType is required', 'request.recipientType')
251
+ }
252
+ }
253
+
254
+ private async get<T>(path: string): Promise<T> {
255
+ return this.request<T>('GET', path)
256
+ }
257
+
258
+ private async post<T>(path: string, body: unknown): Promise<T> {
259
+ return this.request<T>('POST', path, body)
260
+ }
261
+
262
+ private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
263
+ const url = `${this.baseUrl}${path}`
264
+ const headers: Record<string, string> = {
265
+ 'Content-Type': 'application/json',
266
+ }
267
+
268
+ if (this.jwtToken) {
269
+ headers['Authorization'] = `Bearer ${this.jwtToken}`
270
+ }
271
+
272
+ const controller = new AbortController()
273
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout)
274
+
275
+ try {
276
+ const response = await this.fetchFn(url, {
277
+ method,
278
+ headers,
279
+ body: body ? JSON.stringify(body) : undefined,
280
+ signal: controller.signal,
281
+ })
282
+
283
+ clearTimeout(timeoutId)
284
+
285
+ if (!response.ok) {
286
+ const error = await this.parseError(response)
287
+ throw new NetworkError(
288
+ error.message || `API request failed with status ${response.status}`,
289
+ this.mapErrorCode(error.code, response.status),
290
+ {
291
+ endpoint: url,
292
+ statusCode: response.status,
293
+ context: { error },
294
+ }
295
+ )
296
+ }
297
+
298
+ return response.json() as Promise<T>
299
+ } catch (error) {
300
+ clearTimeout(timeoutId)
301
+
302
+ if (error instanceof NetworkError) {
303
+ throw error
304
+ }
305
+
306
+ if (error instanceof Error && error.name === 'AbortError') {
307
+ throw new NetworkError(
308
+ `Request timed out after ${this.timeout}ms`,
309
+ ErrorCode.NETWORK_TIMEOUT,
310
+ { endpoint: url }
311
+ )
312
+ }
313
+
314
+ throw new NetworkError(
315
+ `Network request failed: ${error instanceof Error ? error.message : String(error)}`,
316
+ ErrorCode.NETWORK_FAILED,
317
+ { endpoint: url, cause: error instanceof Error ? error : undefined }
318
+ )
319
+ }
320
+ }
321
+
322
+ private async parseError(response: Response): Promise<OneClickError> {
323
+ try {
324
+ return await response.json()
325
+ } catch {
326
+ return {
327
+ code: 'UNKNOWN_ERROR',
328
+ message: `HTTP ${response.status}: ${response.statusText}`,
329
+ }
330
+ }
331
+ }
332
+
333
+ private mapErrorCode(apiCode: string | undefined, statusCode: number): ErrorCode {
334
+ if (apiCode) {
335
+ switch (apiCode) {
336
+ case OneClickErrorCode.INSUFFICIENT_LIQUIDITY:
337
+ case OneClickErrorCode.UNSUPPORTED_PAIR:
338
+ case OneClickErrorCode.AMOUNT_TOO_LOW:
339
+ case OneClickErrorCode.DEADLINE_TOO_SHORT:
340
+ case OneClickErrorCode.INVALID_PARAMS:
341
+ return ErrorCode.VALIDATION_FAILED
342
+
343
+ case OneClickErrorCode.RATE_LIMITED:
344
+ return ErrorCode.RATE_LIMITED
345
+ }
346
+ }
347
+
348
+ switch (statusCode) {
349
+ case 400:
350
+ return ErrorCode.VALIDATION_FAILED
351
+ case 401:
352
+ return ErrorCode.API_ERROR
353
+ case 429:
354
+ return ErrorCode.RATE_LIMITED
355
+ case 500:
356
+ case 502:
357
+ case 503:
358
+ return ErrorCode.NETWORK_UNAVAILABLE
359
+ default:
360
+ return ErrorCode.API_ERROR
361
+ }
362
+ }
363
+
364
+ private delay(ms: number): Promise<void> {
365
+ return new Promise(resolve => setTimeout(resolve, ms))
366
+ }
367
+ }