@blazium/ton-connect-mobile 1.0.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.
package/src/index.ts ADDED
@@ -0,0 +1,578 @@
1
+ /**
2
+ * TON Connect Mobile SDK
3
+ * Production-ready implementation for React Native and Expo
4
+ */
5
+
6
+ // Type declarations for runtime globals (imported from index.d.ts via reference)
7
+ /// <reference path="./index.d.ts" />
8
+
9
+ import {
10
+ TonConnectMobileConfig,
11
+ ConnectionStatus,
12
+ WalletInfo,
13
+ SendTransactionRequest,
14
+ StatusChangeCallback,
15
+ PlatformAdapter,
16
+ } from './types';
17
+ import {
18
+ buildConnectionRequest,
19
+ buildTransactionRequest,
20
+ parseCallbackURL,
21
+ extractWalletInfo,
22
+ validateConnectionResponse,
23
+ validateTransactionRequest,
24
+ validateTransactionResponse,
25
+ } from './core/protocol';
26
+ import type {
27
+ ConnectionResponsePayload,
28
+ TransactionResponsePayload,
29
+ ErrorResponse,
30
+ } from './types';
31
+ import { verifyConnectionProof, generateSessionId } from './core/crypto';
32
+ import { ExpoAdapter } from './adapters/expo';
33
+ import { ReactNativeAdapter } from './adapters/react-native';
34
+ import { WebAdapter } from './adapters/web';
35
+
36
+ /**
37
+ * Custom error classes
38
+ */
39
+ export class TonConnectError extends Error {
40
+ constructor(message: string, public code?: string) {
41
+ super(message);
42
+ this.name = 'TonConnectError';
43
+ }
44
+ }
45
+
46
+ export class ConnectionTimeoutError extends TonConnectError {
47
+ constructor() {
48
+ super('Connection request timed out', 'CONNECTION_TIMEOUT');
49
+ this.name = 'ConnectionTimeoutError';
50
+ }
51
+ }
52
+
53
+ export class TransactionTimeoutError extends TonConnectError {
54
+ constructor() {
55
+ super('Transaction request timed out', 'TRANSACTION_TIMEOUT');
56
+ this.name = 'TransactionTimeoutError';
57
+ }
58
+ }
59
+
60
+ export class UserRejectedError extends TonConnectError {
61
+ constructor() {
62
+ super('User rejected the request', 'USER_REJECTED');
63
+ this.name = 'UserRejectedError';
64
+ }
65
+ }
66
+
67
+ export class ConnectionInProgressError extends TonConnectError {
68
+ constructor() {
69
+ super('Connection request already in progress', 'CONNECTION_IN_PROGRESS');
70
+ this.name = 'ConnectionInProgressError';
71
+ }
72
+ }
73
+
74
+ export class TransactionInProgressError extends TonConnectError {
75
+ constructor() {
76
+ super('Transaction request already in progress', 'TRANSACTION_IN_PROGRESS');
77
+ this.name = 'TransactionInProgressError';
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Main TON Connect Mobile SDK class
83
+ */
84
+ export class TonConnectMobile {
85
+ private adapter: PlatformAdapter;
86
+ private config: Required<TonConnectMobileConfig>;
87
+ private statusChangeCallbacks: Set<StatusChangeCallback> = new Set();
88
+ private currentStatus: ConnectionStatus = { connected: false, wallet: null };
89
+ private urlUnsubscribe: (() => void) | null = null;
90
+ private connectionPromise: {
91
+ resolve: (wallet: WalletInfo) => void;
92
+ reject: (error: Error) => void;
93
+ timeout: number | null;
94
+ } | null = null;
95
+ private transactionPromise: {
96
+ resolve: (response: { boc: string; signature: string }) => void;
97
+ reject: (error: Error) => void;
98
+ timeout: number | null;
99
+ } | null = null;
100
+
101
+ constructor(config: TonConnectMobileConfig) {
102
+ // Validate config
103
+ if (!config.manifestUrl) {
104
+ throw new TonConnectError('manifestUrl is required');
105
+ }
106
+ if (!config.scheme) {
107
+ throw new TonConnectError('scheme is required');
108
+ }
109
+
110
+ this.config = {
111
+ storageKeyPrefix: 'tonconnect_',
112
+ connectionTimeout: 300000, // 5 minutes
113
+ transactionTimeout: 300000, // 5 minutes
114
+ ...config,
115
+ };
116
+
117
+ // Initialize platform adapter
118
+ this.adapter = this.createAdapter();
119
+
120
+ // Set up URL listener
121
+ this.setupURLListener();
122
+
123
+ // Load persisted session
124
+ this.loadSession();
125
+ }
126
+
127
+ /**
128
+ * Create platform adapter based on available modules
129
+ */
130
+ private createAdapter(): PlatformAdapter {
131
+ // Check if we're in a web environment
132
+ // eslint-disable-next-line no-undef
133
+ if (typeof globalThis !== 'undefined' && (globalThis as any).window && (globalThis as any).document) {
134
+ // Web platform
135
+ return new WebAdapter();
136
+ }
137
+
138
+ // Try to detect Expo environment
139
+ try {
140
+ // Check if expo-linking is available
141
+ if (typeof require !== 'undefined') {
142
+ const expoLinking = require('expo-linking');
143
+ if (expoLinking) {
144
+ return new ExpoAdapter();
145
+ }
146
+ }
147
+ } catch {
148
+ // expo-linking not available, continue to React Native adapter
149
+ }
150
+
151
+ // Fall back to React Native adapter
152
+ // This will work for both React Native CLI and Expo (since Expo also has react-native)
153
+ return new ReactNativeAdapter();
154
+ }
155
+
156
+ /**
157
+ * Set up URL listener for wallet callbacks
158
+ */
159
+ private setupURLListener(): void {
160
+ this.urlUnsubscribe = this.adapter.addURLListener((url) => {
161
+ this.handleCallback(url);
162
+ });
163
+
164
+ // Also check initial URL (when app was opened via deep link)
165
+ this.adapter.getInitialURL().then((url) => {
166
+ if (url) {
167
+ this.handleCallback(url);
168
+ }
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Handle callback from wallet
174
+ */
175
+ private handleCallback(url: string): void {
176
+ const parsed = parseCallbackURL(url, this.config.scheme);
177
+
178
+ if (parsed.type === 'connect' && parsed.data) {
179
+ this.handleConnectionResponse(parsed.data as ConnectionResponsePayload);
180
+ } else if (parsed.type === 'transaction' && parsed.data) {
181
+ this.handleTransactionResponse(parsed.data as TransactionResponsePayload);
182
+ } else if (parsed.type === 'error' && parsed.data) {
183
+ const errorData = parsed.data as ErrorResponse;
184
+ if (errorData?.error) {
185
+ if (errorData.error.code === 300 || errorData.error.message?.toLowerCase().includes('reject')) {
186
+ this.rejectWithError(new UserRejectedError());
187
+ } else {
188
+ this.rejectWithError(
189
+ new TonConnectError(errorData.error.message || 'Unknown error', String(errorData.error.code))
190
+ );
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Handle connection response from wallet
198
+ */
199
+ private handleConnectionResponse(response: ConnectionResponsePayload): void {
200
+ if (!validateConnectionResponse(response)) {
201
+ this.rejectWithError(new TonConnectError('Invalid connection response'));
202
+ return;
203
+ }
204
+
205
+ // Verify proof if present
206
+ if (response.proof) {
207
+ const isValid = verifyConnectionProof(response, this.config.manifestUrl);
208
+ if (!isValid) {
209
+ this.rejectWithError(new TonConnectError('Connection proof verification failed'));
210
+ return;
211
+ }
212
+ } else {
213
+ // Log warning if proof is missing (security consideration)
214
+ console.warn('TON Connect: Connection proof missing - wallet may not support proof verification');
215
+ }
216
+
217
+ const wallet = extractWalletInfo(response);
218
+
219
+ // Validate session ID before saving
220
+ if (!this.validateSessionId(response.session)) {
221
+ this.rejectWithError(new TonConnectError('Invalid session ID format'));
222
+ return;
223
+ }
224
+
225
+ // Save session
226
+ this.saveSession(response.session, wallet).catch((error) => {
227
+ console.error('TON Connect: Failed to save session:', error);
228
+ // Continue anyway - connection is still valid
229
+ });
230
+
231
+ // Update status
232
+ this.currentStatus = { connected: true, wallet };
233
+ this.notifyStatusChange();
234
+
235
+ // Resolve connection promise
236
+ if (this.connectionPromise) {
237
+ if (this.connectionPromise.timeout !== null) {
238
+ clearTimeout(this.connectionPromise.timeout);
239
+ }
240
+ this.connectionPromise.resolve(wallet);
241
+ this.connectionPromise = null;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Handle transaction response from wallet
247
+ */
248
+ private handleTransactionResponse(response: TransactionResponsePayload): void {
249
+ if (!validateTransactionResponse(response)) {
250
+ this.rejectWithError(new TonConnectError('Invalid transaction response'));
251
+ return;
252
+ }
253
+
254
+ // Resolve transaction promise
255
+ if (this.transactionPromise) {
256
+ if (this.transactionPromise.timeout !== null) {
257
+ clearTimeout(this.transactionPromise.timeout);
258
+ }
259
+ this.transactionPromise.resolve({
260
+ boc: response.boc,
261
+ signature: response.signature,
262
+ });
263
+ this.transactionPromise = null;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Reject current promise with error
269
+ */
270
+ private rejectWithError(error: Error): void {
271
+ if (this.connectionPromise) {
272
+ if (this.connectionPromise.timeout !== null) {
273
+ clearTimeout(this.connectionPromise.timeout);
274
+ }
275
+ this.connectionPromise.reject(error);
276
+ this.connectionPromise = null;
277
+ }
278
+ if (this.transactionPromise) {
279
+ if (this.transactionPromise.timeout !== null) {
280
+ clearTimeout(this.transactionPromise.timeout);
281
+ }
282
+ this.transactionPromise.reject(error);
283
+ this.transactionPromise = null;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Connect to wallet
289
+ */
290
+ async connect(): Promise<WalletInfo> {
291
+ // If already connected, return current wallet
292
+ if (this.currentStatus.connected && this.currentStatus.wallet) {
293
+ return this.currentStatus.wallet;
294
+ }
295
+
296
+ // CRITICAL FIX: Check if connection is already in progress
297
+ if (this.connectionPromise) {
298
+ throw new ConnectionInProgressError();
299
+ }
300
+
301
+ // Build connection request URL
302
+ const url = buildConnectionRequest(this.config.manifestUrl, this.config.scheme);
303
+
304
+ // Create promise for connection
305
+ return new Promise<WalletInfo>((resolve, reject) => {
306
+ let timeout: number | null = null;
307
+
308
+ this.connectionPromise = {
309
+ resolve: (wallet: WalletInfo) => {
310
+ if (timeout !== null) {
311
+ clearTimeout(timeout);
312
+ }
313
+ this.connectionPromise = null;
314
+ resolve(wallet);
315
+ },
316
+ reject: (error: Error) => {
317
+ if (timeout !== null) {
318
+ clearTimeout(timeout);
319
+ }
320
+ this.connectionPromise = null;
321
+ reject(error);
322
+ },
323
+ timeout: null,
324
+ };
325
+
326
+ // Set timeout
327
+ timeout = setTimeout(() => {
328
+ if (this.connectionPromise) {
329
+ this.connectionPromise.reject(new ConnectionTimeoutError());
330
+ }
331
+ }, this.config.connectionTimeout) as unknown as number;
332
+
333
+ this.connectionPromise.timeout = timeout;
334
+
335
+ // Open wallet app
336
+ this.adapter.openURL(url).catch((error) => {
337
+ if (this.connectionPromise) {
338
+ this.connectionPromise.reject(
339
+ new TonConnectError(`Failed to open wallet: ${error?.message || String(error)}`)
340
+ );
341
+ }
342
+ });
343
+ });
344
+ }
345
+
346
+ /**
347
+ * Send transaction
348
+ */
349
+ async sendTransaction(request: SendTransactionRequest): Promise<{ boc: string; signature: string }> {
350
+ // Validate request
351
+ const validation = validateTransactionRequest(request);
352
+ if (!validation.valid) {
353
+ throw new TonConnectError(validation.error || 'Invalid transaction request');
354
+ }
355
+
356
+ // Check if connected
357
+ if (!this.currentStatus.connected || !this.currentStatus.wallet) {
358
+ throw new TonConnectError('Not connected to wallet. Call connect() first.');
359
+ }
360
+
361
+ // CRITICAL FIX: Check if transaction is already in progress
362
+ if (this.transactionPromise) {
363
+ throw new TransactionInProgressError();
364
+ }
365
+
366
+ // Build transaction request URL
367
+ const url = buildTransactionRequest(this.config.manifestUrl, request, this.config.scheme);
368
+
369
+ // Create promise for transaction
370
+ return new Promise<{ boc: string; signature: string }>((resolve, reject) => {
371
+ let timeout: number | null = null;
372
+
373
+ this.transactionPromise = {
374
+ resolve: (response: { boc: string; signature: string }) => {
375
+ if (timeout !== null) {
376
+ clearTimeout(timeout);
377
+ }
378
+ this.transactionPromise = null;
379
+ resolve(response);
380
+ },
381
+ reject: (error: Error) => {
382
+ if (timeout !== null) {
383
+ clearTimeout(timeout);
384
+ }
385
+ this.transactionPromise = null;
386
+ reject(error);
387
+ },
388
+ timeout: null,
389
+ };
390
+
391
+ // Set timeout
392
+ timeout = setTimeout(() => {
393
+ if (this.transactionPromise) {
394
+ this.transactionPromise.reject(new TransactionTimeoutError());
395
+ }
396
+ }, this.config.transactionTimeout) as unknown as number;
397
+
398
+ this.transactionPromise.timeout = timeout;
399
+
400
+ // Open wallet app
401
+ this.adapter.openURL(url).catch((error) => {
402
+ if (this.transactionPromise) {
403
+ this.transactionPromise.reject(
404
+ new TonConnectError(`Failed to open wallet: ${error?.message || String(error)}`)
405
+ );
406
+ }
407
+ });
408
+ });
409
+ }
410
+
411
+ /**
412
+ * Disconnect from wallet
413
+ */
414
+ async disconnect(): Promise<void> {
415
+ // Clear session
416
+ await this.clearSession();
417
+
418
+ // Update status
419
+ this.currentStatus = { connected: false, wallet: null };
420
+ this.notifyStatusChange();
421
+ }
422
+
423
+ /**
424
+ * Get current connection status
425
+ */
426
+ getStatus(): ConnectionStatus {
427
+ return { ...this.currentStatus };
428
+ }
429
+
430
+ /**
431
+ * Subscribe to status changes
432
+ */
433
+ onStatusChange(callback: StatusChangeCallback): () => void {
434
+ this.statusChangeCallbacks.add(callback);
435
+
436
+ // Immediately call with current status
437
+ callback(this.getStatus());
438
+
439
+ // Return unsubscribe function
440
+ return () => {
441
+ this.statusChangeCallbacks.delete(callback);
442
+ };
443
+ }
444
+
445
+ /**
446
+ * Notify all status change callbacks
447
+ */
448
+ private notifyStatusChange(): void {
449
+ const status = this.getStatus();
450
+ this.statusChangeCallbacks.forEach((callback) => {
451
+ try {
452
+ callback(status);
453
+ } catch (error) {
454
+ // Ignore errors in callbacks
455
+ }
456
+ });
457
+ }
458
+
459
+ /**
460
+ * Validate session ID format
461
+ */
462
+ private validateSessionId(sessionId: string): boolean {
463
+ if (!sessionId || typeof sessionId !== 'string') {
464
+ return false;
465
+ }
466
+ // Session ID should be reasonable length (1-200 characters)
467
+ if (sessionId.length === 0 || sessionId.length > 200) {
468
+ return false;
469
+ }
470
+ // Basic validation: should not contain control characters
471
+ if (/[\x00-\x1F\x7F]/.test(sessionId)) {
472
+ return false;
473
+ }
474
+ return true;
475
+ }
476
+
477
+ /**
478
+ * Save session to storage
479
+ */
480
+ private async saveSession(sessionId: string, wallet: WalletInfo): Promise<void> {
481
+ // Validate inputs
482
+ if (!this.validateSessionId(sessionId)) {
483
+ throw new TonConnectError('Invalid session ID format');
484
+ }
485
+ if (!wallet || !wallet.address || !wallet.publicKey) {
486
+ throw new TonConnectError('Invalid wallet data');
487
+ }
488
+
489
+ try {
490
+ const sessionKey = `${this.config.storageKeyPrefix}session`;
491
+ const walletKey = `${this.config.storageKeyPrefix}wallet`;
492
+
493
+ await this.adapter.setItem(sessionKey, sessionId);
494
+ await this.adapter.setItem(walletKey, JSON.stringify(wallet));
495
+ } catch (error) {
496
+ // Log error but don't throw - connection is still valid
497
+ console.error('TON Connect: Failed to save session to storage:', error);
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Load session from storage
503
+ */
504
+ private async loadSession(): Promise<void> {
505
+ try {
506
+ const sessionKey = `${this.config.storageKeyPrefix}session`;
507
+ const walletKey = `${this.config.storageKeyPrefix}wallet`;
508
+
509
+ const sessionId = await this.adapter.getItem(sessionKey);
510
+ const walletJson = await this.adapter.getItem(walletKey);
511
+
512
+ if (sessionId && walletJson) {
513
+ try {
514
+ // Validate session ID
515
+ if (!this.validateSessionId(sessionId)) {
516
+ await this.clearSession();
517
+ return;
518
+ }
519
+
520
+ const wallet = JSON.parse(walletJson) as WalletInfo;
521
+
522
+ // Validate wallet data
523
+ if (!wallet || !wallet.address || !wallet.publicKey) {
524
+ await this.clearSession();
525
+ return;
526
+ }
527
+
528
+ this.currentStatus = { connected: true, wallet };
529
+ this.notifyStatusChange();
530
+ } catch (error) {
531
+ // Invalid wallet data, clear it
532
+ console.error('TON Connect: Invalid session data, clearing:', error);
533
+ await this.clearSession();
534
+ }
535
+ }
536
+ } catch (error) {
537
+ // Log storage errors for debugging
538
+ console.error('TON Connect: Failed to load session from storage:', error);
539
+ }
540
+ }
541
+
542
+ /**
543
+ * Clear session from storage
544
+ */
545
+ private async clearSession(): Promise<void> {
546
+ try {
547
+ const sessionKey = `${this.config.storageKeyPrefix}session`;
548
+ const walletKey = `${this.config.storageKeyPrefix}wallet`;
549
+
550
+ await this.adapter.removeItem(sessionKey);
551
+ await this.adapter.removeItem(walletKey);
552
+ } catch (error) {
553
+ // Ignore storage errors
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Cleanup resources
559
+ */
560
+ destroy(): void {
561
+ if (this.urlUnsubscribe) {
562
+ this.urlUnsubscribe();
563
+ this.urlUnsubscribe = null;
564
+ }
565
+
566
+ if ('destroy' in this.adapter && typeof (this.adapter as { destroy?: () => void }).destroy === 'function') {
567
+ (this.adapter as { destroy: () => void }).destroy();
568
+ }
569
+
570
+ this.statusChangeCallbacks.clear();
571
+ this.connectionPromise = null;
572
+ this.transactionPromise = null;
573
+ }
574
+ }
575
+
576
+ // Export types
577
+ export * from './types';
578
+