@blazium/ton-connect-mobile 1.1.5 → 1.2.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.
@@ -248,14 +248,23 @@ export function parseCallbackURL(url: string, scheme: string): {
248
248
  }
249
249
 
250
250
  // Extract encoded payload
251
- const encoded = url.substring(expectedPrefix.length);
251
+ let encoded = url.substring(expectedPrefix.length);
252
+
253
+ // CRITICAL FIX: Decode URL encoding first (wallet may URL-encode the payload)
254
+ try {
255
+ encoded = decodeURIComponent(encoded);
256
+ } catch (error) {
257
+ // If decodeURIComponent fails, try using the original encoded string
258
+ // Some wallets may not URL-encode the payload
259
+ console.log('[TON Connect] Payload not URL-encoded, using as-is');
260
+ }
252
261
 
253
262
  // CRITICAL FIX: Validate base64 payload size (prevent DoS)
254
263
  if (encoded.length === 0 || encoded.length > 5000) {
255
264
  return { type: 'unknown', data: null };
256
265
  }
257
266
 
258
- // CRITICAL FIX: Validate base64 characters only
267
+ // CRITICAL FIX: Validate base64 characters only (after URL decoding)
259
268
  if (!/^[A-Za-z0-9_-]+$/.test(encoded)) {
260
269
  return { type: 'unknown', data: null };
261
270
  }
@@ -306,14 +315,20 @@ export function parseCallbackURL(url: string, scheme: string): {
306
315
 
307
316
  /**
308
317
  * Extract wallet info from connection response
318
+ * CRITICAL: This function assumes response has been validated by validateConnectionResponse
309
319
  */
310
320
  export function extractWalletInfo(
311
321
  response: ConnectionResponsePayload
312
322
  ): WalletInfo {
323
+ // CRITICAL FIX: Add null checks to prevent runtime errors
324
+ if (!response || !response.name || !response.address || !response.publicKey) {
325
+ throw new Error('Invalid connection response: missing required fields');
326
+ }
327
+
313
328
  return {
314
329
  name: response.name,
315
- appName: response.appName,
316
- version: response.version,
330
+ appName: response.appName || response.name,
331
+ version: response.version || 'unknown',
317
332
  platform: response.platform || 'unknown',
318
333
  address: response.address,
319
334
  publicKey: response.publicKey,
@@ -360,13 +375,65 @@ export function validateTransactionRequest(
360
375
  return { valid: false, error: 'Transaction must have at least one message' };
361
376
  }
362
377
 
363
- for (const msg of request.messages) {
364
- if (!msg.address) {
365
- return { valid: false, error: 'Message address is required' };
378
+ // CRITICAL: Validate each message
379
+ for (let i = 0; i < request.messages.length; i++) {
380
+ const msg = request.messages[i];
381
+
382
+ // Validate address
383
+ if (!msg.address || typeof msg.address !== 'string') {
384
+ return { valid: false, error: `Message ${i + 1}: Address is required and must be a string` };
385
+ }
386
+
387
+ // CRITICAL: Validate TON address format (EQ... or 0Q...)
388
+ if (!/^(EQ|0Q)[A-Za-z0-9_-]{46}$/.test(msg.address)) {
389
+ return { valid: false, error: `Message ${i + 1}: Invalid TON address format. Address must start with EQ or 0Q and be 48 characters long.` };
366
390
  }
367
- if (!msg.amount || isNaN(Number(msg.amount))) {
368
- return { valid: false, error: 'Message amount must be a valid number' };
391
+
392
+ // Validate amount
393
+ if (!msg.amount || typeof msg.amount !== 'string') {
394
+ return { valid: false, error: `Message ${i + 1}: Amount is required and must be a string (nanotons)` };
395
+ }
396
+
397
+ // CRITICAL: Validate amount is a valid positive number (nanotons)
398
+ try {
399
+ const amount = BigInt(msg.amount);
400
+ if (amount <= 0n) {
401
+ return { valid: false, error: `Message ${i + 1}: Amount must be greater than 0` };
402
+ }
403
+ // Check for reasonable maximum (prevent overflow)
404
+ if (amount > BigInt('1000000000000000000')) { // 1 billion TON
405
+ return { valid: false, error: `Message ${i + 1}: Amount exceeds maximum allowed (1 billion TON)` };
406
+ }
407
+ } catch (error) {
408
+ return { valid: false, error: `Message ${i + 1}: Amount must be a valid number string (nanotons)` };
369
409
  }
410
+
411
+ // Validate payload if provided (must be base64)
412
+ if (msg.payload !== undefined && msg.payload !== null) {
413
+ if (typeof msg.payload !== 'string') {
414
+ return { valid: false, error: `Message ${i + 1}: Payload must be a base64 string` };
415
+ }
416
+ // Basic base64 validation
417
+ if (msg.payload.length > 0 && !/^[A-Za-z0-9+/=]+$/.test(msg.payload)) {
418
+ return { valid: false, error: `Message ${i + 1}: Payload must be valid base64 encoded` };
419
+ }
420
+ }
421
+
422
+ // Validate stateInit if provided (must be base64)
423
+ if (msg.stateInit !== undefined && msg.stateInit !== null) {
424
+ if (typeof msg.stateInit !== 'string') {
425
+ return { valid: false, error: `Message ${i + 1}: StateInit must be a base64 string` };
426
+ }
427
+ // Basic base64 validation
428
+ if (msg.stateInit.length > 0 && !/^[A-Za-z0-9+/=]+$/.test(msg.stateInit)) {
429
+ return { valid: false, error: `Message ${i + 1}: StateInit must be valid base64 encoded` };
430
+ }
431
+ }
432
+ }
433
+
434
+ // CRITICAL: Limit maximum number of messages (prevent DoS)
435
+ if (request.messages.length > 255) {
436
+ return { valid: false, error: 'Transaction cannot have more than 255 messages' };
370
437
  }
371
438
 
372
439
  return { valid: true };
@@ -31,7 +31,7 @@ export const SUPPORTED_WALLETS: WalletDefinition[] = [
31
31
  appName: 'Tonkeeper',
32
32
  universalLink: 'https://app.tonkeeper.com/ton-connect',
33
33
  deepLink: 'tonkeeper://',
34
- platforms: ['ios', 'android'],
34
+ platforms: ['ios', 'android', 'web'], // CRITICAL FIX: Tonkeeper Web is supported
35
35
  preferredReturnStrategy: 'post_redirect', // CRITICAL FIX: 'back' strategy may not send callback properly, use 'post_redirect'
36
36
  requiresReturnScheme: true, // CRITICAL FIX: Mobile apps need returnScheme for proper callback handling
37
37
  },
package/src/index.ts CHANGED
@@ -40,7 +40,7 @@ import { getWalletByName, getDefaultWallet, SUPPORTED_WALLETS, type WalletDefini
40
40
  * Custom error classes
41
41
  */
42
42
  export class TonConnectError extends Error {
43
- constructor(message: string, public code?: string) {
43
+ constructor(message: string, public code?: string, public recoverySuggestion?: string) {
44
44
  super(message);
45
45
  this.name = 'TonConnectError';
46
46
  }
@@ -48,35 +48,55 @@ export class TonConnectError extends Error {
48
48
 
49
49
  export class ConnectionTimeoutError extends TonConnectError {
50
50
  constructor() {
51
- super('Connection request timed out', 'CONNECTION_TIMEOUT');
51
+ super(
52
+ 'Connection request timed out. The wallet did not respond in time.',
53
+ 'CONNECTION_TIMEOUT',
54
+ 'Please make sure the wallet app is installed and try again. If the issue persists, check your internet connection.'
55
+ );
52
56
  this.name = 'ConnectionTimeoutError';
53
57
  }
54
58
  }
55
59
 
56
60
  export class TransactionTimeoutError extends TonConnectError {
57
61
  constructor() {
58
- super('Transaction request timed out', 'TRANSACTION_TIMEOUT');
62
+ super(
63
+ 'Transaction request timed out. The wallet did not respond in time.',
64
+ 'TRANSACTION_TIMEOUT',
65
+ 'Please check the wallet app and try again. Make sure you approve or reject the transaction in the wallet.'
66
+ );
59
67
  this.name = 'TransactionTimeoutError';
60
68
  }
61
69
  }
62
70
 
63
71
  export class UserRejectedError extends TonConnectError {
64
- constructor() {
65
- super('User rejected the request', 'USER_REJECTED');
72
+ constructor(message?: string) {
73
+ super(
74
+ message || 'User rejected the request',
75
+ 'USER_REJECTED',
76
+ 'The user cancelled the operation in the wallet app.'
77
+ );
66
78
  this.name = 'UserRejectedError';
67
79
  }
68
80
  }
69
81
 
70
82
  export class ConnectionInProgressError extends TonConnectError {
71
83
  constructor() {
72
- super('Connection request already in progress', 'CONNECTION_IN_PROGRESS');
84
+ super(
85
+ 'Connection request already in progress',
86
+ 'CONNECTION_IN_PROGRESS',
87
+ 'Please wait for the current connection attempt to complete before trying again.'
88
+ );
73
89
  this.name = 'ConnectionInProgressError';
74
90
  }
75
91
  }
76
92
 
77
93
  export class TransactionInProgressError extends TonConnectError {
78
94
  constructor() {
79
- super('Transaction request already in progress', 'TRANSACTION_IN_PROGRESS');
95
+ super(
96
+ 'Transaction request already in progress',
97
+ 'TRANSACTION_IN_PROGRESS',
98
+ 'Please wait for the current transaction to complete before sending another one.'
99
+ );
80
100
  this.name = 'TransactionInProgressError';
81
101
  }
82
102
  }
@@ -235,8 +255,9 @@ export class TonConnectMobile {
235
255
  console.log('[TON Connect] Parsed callback:', parsed.type, parsed.data ? 'has data' : 'no data');
236
256
 
237
257
  // CRITICAL FIX: Check for sign data response first (before other handlers)
238
- if (this.signDataPromise && !this.signDataPromise.timeout) {
239
- // Sign data request is pending
258
+ // Note: We check if promise exists and hasn't timed out (timeout !== null means not timed out yet)
259
+ if (this.signDataPromise && this.signDataPromise.timeout !== null) {
260
+ // Sign data request is pending and hasn't timed out
240
261
  if (parsed.type === 'error' && parsed.data) {
241
262
  const errorData = parsed.data as ErrorResponse;
242
263
  if (errorData?.error) {
@@ -327,12 +348,18 @@ export class TonConnectMobile {
327
348
  this.notifyStatusChange();
328
349
 
329
350
  // Resolve connection promise
351
+ // CRITICAL: Only resolve if promise still exists and hasn't timed out
330
352
  if (this.connectionPromise) {
353
+ // Clear timeout if it exists
331
354
  if (this.connectionPromise.timeout !== null) {
332
355
  clearTimeout(this.connectionPromise.timeout);
333
356
  }
334
- this.connectionPromise.resolve(wallet);
357
+ // Store reference before clearing to prevent race conditions
358
+ const promise = this.connectionPromise;
359
+ // Clear promise first
335
360
  this.connectionPromise = null;
361
+ // Then resolve
362
+ promise.resolve(wallet);
336
363
  }
337
364
  }
338
365
 
@@ -346,15 +373,21 @@ export class TonConnectMobile {
346
373
  }
347
374
 
348
375
  // Resolve transaction promise
376
+ // CRITICAL: Only resolve if promise still exists and hasn't timed out
349
377
  if (this.transactionPromise) {
378
+ // Clear timeout if it exists
350
379
  if (this.transactionPromise.timeout !== null) {
351
380
  clearTimeout(this.transactionPromise.timeout);
352
381
  }
353
- this.transactionPromise.resolve({
382
+ // Store reference before clearing
383
+ const promise = this.transactionPromise;
384
+ // Clear promise first to prevent race conditions
385
+ this.transactionPromise = null;
386
+ // Then resolve
387
+ promise.resolve({
354
388
  boc: response.boc,
355
389
  signature: response.signature,
356
390
  });
357
- this.transactionPromise = null;
358
391
  }
359
392
  }
360
393
 
@@ -376,6 +409,14 @@ export class TonConnectMobile {
376
409
  this.transactionPromise.reject(error);
377
410
  this.transactionPromise = null;
378
411
  }
412
+ // CRITICAL FIX: Also clear signDataPromise to prevent memory leaks
413
+ if (this.signDataPromise) {
414
+ if (this.signDataPromise.timeout !== null) {
415
+ clearTimeout(this.signDataPromise.timeout);
416
+ }
417
+ this.signDataPromise.reject(error);
418
+ this.signDataPromise = null;
419
+ }
379
420
  }
380
421
 
381
422
  /**
@@ -754,6 +795,38 @@ export class TonConnectMobile {
754
795
  return this.currentWallet;
755
796
  }
756
797
 
798
+ /**
799
+ * Check if a wallet is available on the current platform
800
+ * Note: This is a best-effort check and may not be 100% accurate
801
+ * CRITICAL FIX: On web, if wallet has universalLink, it's considered available
802
+ * because universal links can open in new tabs/windows
803
+ */
804
+ async isWalletAvailable(walletName?: string): Promise<boolean> {
805
+ const wallet = walletName ? getWalletByName(walletName) : this.currentWallet;
806
+ if (!wallet) {
807
+ return false;
808
+ }
809
+
810
+ // CRITICAL FIX: Check adapter type to reliably detect web platform
811
+ // WebAdapter is only used on web, so this is the most reliable check
812
+ const isWeb = this.adapter.constructor.name === 'WebAdapter';
813
+
814
+ if (isWeb) {
815
+ // On web, if wallet has universalLink or supports web platform, it's available
816
+ // Universal links can open in a new tab on web
817
+ return wallet.platforms.includes('web') || !!wallet.universalLink;
818
+ }
819
+
820
+ // On mobile, we can't reliably check if wallet is installed
821
+ // Return true if wallet supports the current platform
822
+ // eslint-disable-next-line no-undef
823
+ const platform = typeof globalThis !== 'undefined' && (globalThis as any).Platform
824
+ ? (globalThis as any).Platform.OS === 'ios' ? 'ios' : 'android'
825
+ : 'android';
826
+
827
+ return wallet.platforms.includes(platform);
828
+ }
829
+
757
830
  /**
758
831
  * Set preferred wallet for connections
759
832
  */
@@ -928,3 +1001,7 @@ export * from './types';
928
1001
  export type { WalletDefinition } from './core/wallets';
929
1002
  export { SUPPORTED_WALLETS, getWalletByName, getDefaultWallet, getWalletsForPlatform } from './core/wallets';
930
1003
 
1004
+ // Export utilities
1005
+ export * from './utils/transactionBuilder';
1006
+ export * from './utils/retry';
1007
+
@@ -6,6 +6,7 @@
6
6
  import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
7
7
  import { TonConnectMobile, ConnectionStatus, WalletInfo, SendTransactionRequest } from '../index';
8
8
  import type { TonConnectMobileConfig } from '../types';
9
+ import { WalletSelectionModal } from './WalletSelectionModal';
9
10
 
10
11
  /**
11
12
  * Account information (compatible with @tonconnect/ui-react)
@@ -108,7 +109,18 @@ export function TonConnectUIProvider({
108
109
  children,
109
110
  sdkInstance,
110
111
  }: TonConnectUIProviderProps): JSX.Element {
111
- const [sdk] = useState<TonConnectMobile>(() => sdkInstance || new TonConnectMobile(config));
112
+ // CRITICAL: Initialize SDK only once
113
+ const [sdk] = useState<TonConnectMobile>(() => {
114
+ if (sdkInstance) {
115
+ return sdkInstance;
116
+ }
117
+ try {
118
+ return new TonConnectMobile(config);
119
+ } catch (error) {
120
+ console.error('[TonConnectUIProvider] Failed to initialize SDK:', error);
121
+ throw error;
122
+ }
123
+ });
112
124
  const [walletState, setWalletState] = useState<WalletState | null>(null);
113
125
  const [modalOpen, setModalOpen] = useState(false);
114
126
  const [isConnecting, setIsConnecting] = useState(false);
@@ -160,8 +172,10 @@ export function TonConnectUIProvider({
160
172
 
161
173
  // Open modal
162
174
  const openModal = useCallback(async () => {
163
- setModalOpen(true);
164
- }, []);
175
+ if (!walletState?.connected) {
176
+ setModalOpen(true);
177
+ }
178
+ }, [walletState?.connected]);
165
179
 
166
180
  // Close modal
167
181
  const closeModal = useCallback(() => {
@@ -199,11 +213,24 @@ export function TonConnectUIProvider({
199
213
  // Send transaction
200
214
  const sendTransaction = useCallback(
201
215
  async (transaction: SendTransactionRequest): Promise<TransactionResponse> => {
202
- const response = await sdk.sendTransaction(transaction);
203
- return {
204
- boc: response.boc,
205
- signature: response.signature,
206
- };
216
+ try {
217
+ // Validate transaction before sending
218
+ if (!transaction || !transaction.messages || transaction.messages.length === 0) {
219
+ throw new Error('Invalid transaction: messages array is required and cannot be empty');
220
+ }
221
+ if (!transaction.validUntil || transaction.validUntil <= Date.now()) {
222
+ throw new Error('Invalid transaction: validUntil must be in the future');
223
+ }
224
+
225
+ const response = await sdk.sendTransaction(transaction);
226
+ return {
227
+ boc: response.boc,
228
+ signature: response.signature,
229
+ };
230
+ } catch (error) {
231
+ console.error('[TonConnectUIProvider] Transaction error:', error);
232
+ throw error;
233
+ }
207
234
  },
208
235
  [sdk]
209
236
  );
@@ -211,11 +238,21 @@ export function TonConnectUIProvider({
211
238
  // Sign data
212
239
  const signData = useCallback(
213
240
  async (request: SignDataRequest): Promise<SignDataResponse> => {
214
- const response = await sdk.signData(request.data, request.version);
215
- return {
216
- signature: response.signature,
217
- timestamp: response.timestamp,
218
- };
241
+ try {
242
+ // Validate request
243
+ if (!request || (!request.data && request.data !== '')) {
244
+ throw new Error('Invalid sign data request: data is required');
245
+ }
246
+
247
+ const response = await sdk.signData(request.data, request.version);
248
+ return {
249
+ signature: response.signature,
250
+ timestamp: response.timestamp,
251
+ };
252
+ } catch (error) {
253
+ console.error('[TonConnectUIProvider] Sign data error:', error);
254
+ throw error;
255
+ }
219
256
  },
220
257
  [sdk]
221
258
  );
@@ -240,7 +277,16 @@ export function TonConnectUIProvider({
240
277
  sdk,
241
278
  };
242
279
 
243
- return <TonConnectUIContext.Provider value={contextValue}>{children}</TonConnectUIContext.Provider>;
280
+ return (
281
+ <TonConnectUIContext.Provider value={contextValue}>
282
+ {children}
283
+ {/* Auto-show wallet selection modal when modalOpen is true */}
284
+ <WalletSelectionModal
285
+ visible={modalOpen && !walletState?.connected}
286
+ onClose={closeModal}
287
+ />
288
+ </TonConnectUIContext.Provider>
289
+ );
244
290
  }
245
291
 
246
292
  /**