@blazium/ton-connect-mobile 1.2.0 → 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.
@@ -67,15 +67,15 @@ class ExpoAdapter {
67
67
  else {
68
68
  console.log('[ExpoAdapter] Skipping canOpenURL check (Android compatibility)');
69
69
  }
70
- // CRITICAL FIX: Android'de canOpenURL() tonconnect:// protokolünü tanımayabilir
71
- // Bu yüzden direkt openURL() çağırıyoruz. Eğer açılamazsa hata fırlatır.
70
+ // CRITICAL FIX: On Android, canOpenURL() may not recognize tonconnect:// protocol
71
+ // So we call openURL() directly. If it fails, it will throw an error.
72
72
  await Linking.openURL(url);
73
73
  console.log('[ExpoAdapter] URL opened successfully');
74
74
  return true;
75
75
  }
76
76
  catch (error) {
77
77
  console.error('[ExpoAdapter] Error in openURL:', error);
78
- // Android'de tonconnect:// protokolü tanınmıyorsa veya cüzdan yüklü değilse hata verir
78
+ // On Android, if tonconnect:// protocol is not recognized or wallet is not installed, it will throw an error
79
79
  const errorMessage = error?.message || String(error);
80
80
  if (errorMessage.includes('No Activity found') || errorMessage.includes('No app found') || errorMessage.includes('Cannot open URL')) {
81
81
  throw new Error('No TON wallet app found. Please install Tonkeeper or another TON Connect compatible wallet from Google Play Store.');
@@ -55,15 +55,15 @@ class ReactNativeAdapter {
55
55
  else {
56
56
  console.log('[ReactNativeAdapter] Skipping canOpenURL check (Android compatibility)');
57
57
  }
58
- // CRITICAL FIX: Android'de canOpenURL() tonconnect:// protokolünü tanımayabilir
59
- // Bu yüzden direkt openURL() çağırıyoruz. Eğer açılamazsa hata fırlatır.
58
+ // CRITICAL FIX: On Android, canOpenURL() may not recognize tonconnect:// protocol
59
+ // So we call openURL() directly. If it fails, it will throw an error.
60
60
  await Linking.openURL(url);
61
61
  console.log('[ReactNativeAdapter] URL opened successfully');
62
62
  return true;
63
63
  }
64
64
  catch (error) {
65
65
  console.error('[ReactNativeAdapter] Error in openURL:', error);
66
- // Android'de tonconnect:// protokolü tanınmıyorsa veya cüzdan yüklü değilse hata verir
66
+ // On Android, if tonconnect:// protocol is not recognized or wallet is not installed, it will throw an error
67
67
  const errorMessage = error?.message || String(error);
68
68
  if (errorMessage.includes('No Activity found') || errorMessage.includes('No app found') || errorMessage.includes('Cannot open URL')) {
69
69
  throw new Error('No TON wallet app found. Please install Tonkeeper or another TON Connect compatible wallet from Google Play Store.');
@@ -35,6 +35,7 @@ export declare function parseCallbackURL(url: string, scheme: string): {
35
35
  };
36
36
  /**
37
37
  * Extract wallet info from connection response
38
+ * CRITICAL: This function assumes response has been validated by validateConnectionResponse
38
39
  */
39
40
  export declare function extractWalletInfo(response: ConnectionResponsePayload): WalletInfo;
40
41
  /**
@@ -195,12 +195,21 @@ function parseCallbackURL(url, scheme) {
195
195
  return { type: 'unknown', data: null };
196
196
  }
197
197
  // Extract encoded payload
198
- const encoded = url.substring(expectedPrefix.length);
198
+ let encoded = url.substring(expectedPrefix.length);
199
+ // CRITICAL FIX: Decode URL encoding first (wallet may URL-encode the payload)
200
+ try {
201
+ encoded = decodeURIComponent(encoded);
202
+ }
203
+ catch (error) {
204
+ // If decodeURIComponent fails, try using the original encoded string
205
+ // Some wallets may not URL-encode the payload
206
+ console.log('[TON Connect] Payload not URL-encoded, using as-is');
207
+ }
199
208
  // CRITICAL FIX: Validate base64 payload size (prevent DoS)
200
209
  if (encoded.length === 0 || encoded.length > 5000) {
201
210
  return { type: 'unknown', data: null };
202
211
  }
203
- // CRITICAL FIX: Validate base64 characters only
212
+ // CRITICAL FIX: Validate base64 characters only (after URL decoding)
204
213
  if (!/^[A-Za-z0-9_-]+$/.test(encoded)) {
205
214
  return { type: 'unknown', data: null };
206
215
  }
@@ -241,12 +250,17 @@ function parseCallbackURL(url, scheme) {
241
250
  }
242
251
  /**
243
252
  * Extract wallet info from connection response
253
+ * CRITICAL: This function assumes response has been validated by validateConnectionResponse
244
254
  */
245
255
  function extractWalletInfo(response) {
256
+ // CRITICAL FIX: Add null checks to prevent runtime errors
257
+ if (!response || !response.name || !response.address || !response.publicKey) {
258
+ throw new Error('Invalid connection response: missing required fields');
259
+ }
246
260
  return {
247
261
  name: response.name,
248
- appName: response.appName,
249
- version: response.version,
262
+ appName: response.appName || response.name,
263
+ version: response.version || 'unknown',
250
264
  platform: response.platform || 'unknown',
251
265
  address: response.address,
252
266
  publicKey: response.publicKey,
@@ -280,13 +294,59 @@ function validateTransactionRequest(request) {
280
294
  if (!request.messages || request.messages.length === 0) {
281
295
  return { valid: false, error: 'Transaction must have at least one message' };
282
296
  }
283
- for (const msg of request.messages) {
284
- if (!msg.address) {
285
- return { valid: false, error: 'Message address is required' };
297
+ // CRITICAL: Validate each message
298
+ for (let i = 0; i < request.messages.length; i++) {
299
+ const msg = request.messages[i];
300
+ // Validate address
301
+ if (!msg.address || typeof msg.address !== 'string') {
302
+ return { valid: false, error: `Message ${i + 1}: Address is required and must be a string` };
303
+ }
304
+ // CRITICAL: Validate TON address format (EQ... or 0Q...)
305
+ if (!/^(EQ|0Q)[A-Za-z0-9_-]{46}$/.test(msg.address)) {
306
+ return { valid: false, error: `Message ${i + 1}: Invalid TON address format. Address must start with EQ or 0Q and be 48 characters long.` };
307
+ }
308
+ // Validate amount
309
+ if (!msg.amount || typeof msg.amount !== 'string') {
310
+ return { valid: false, error: `Message ${i + 1}: Amount is required and must be a string (nanotons)` };
311
+ }
312
+ // CRITICAL: Validate amount is a valid positive number (nanotons)
313
+ try {
314
+ const amount = BigInt(msg.amount);
315
+ if (amount <= 0n) {
316
+ return { valid: false, error: `Message ${i + 1}: Amount must be greater than 0` };
317
+ }
318
+ // Check for reasonable maximum (prevent overflow)
319
+ if (amount > BigInt('1000000000000000000')) { // 1 billion TON
320
+ return { valid: false, error: `Message ${i + 1}: Amount exceeds maximum allowed (1 billion TON)` };
321
+ }
286
322
  }
287
- if (!msg.amount || isNaN(Number(msg.amount))) {
288
- return { valid: false, error: 'Message amount must be a valid number' };
323
+ catch (error) {
324
+ return { valid: false, error: `Message ${i + 1}: Amount must be a valid number string (nanotons)` };
289
325
  }
326
+ // Validate payload if provided (must be base64)
327
+ if (msg.payload !== undefined && msg.payload !== null) {
328
+ if (typeof msg.payload !== 'string') {
329
+ return { valid: false, error: `Message ${i + 1}: Payload must be a base64 string` };
330
+ }
331
+ // Basic base64 validation
332
+ if (msg.payload.length > 0 && !/^[A-Za-z0-9+/=]+$/.test(msg.payload)) {
333
+ return { valid: false, error: `Message ${i + 1}: Payload must be valid base64 encoded` };
334
+ }
335
+ }
336
+ // Validate stateInit if provided (must be base64)
337
+ if (msg.stateInit !== undefined && msg.stateInit !== null) {
338
+ if (typeof msg.stateInit !== 'string') {
339
+ return { valid: false, error: `Message ${i + 1}: StateInit must be a base64 string` };
340
+ }
341
+ // Basic base64 validation
342
+ if (msg.stateInit.length > 0 && !/^[A-Za-z0-9+/=]+$/.test(msg.stateInit)) {
343
+ return { valid: false, error: `Message ${i + 1}: StateInit must be valid base64 encoded` };
344
+ }
345
+ }
346
+ }
347
+ // CRITICAL: Limit maximum number of messages (prevent DoS)
348
+ if (request.messages.length > 255) {
349
+ return { valid: false, error: 'Transaction cannot have more than 255 messages' };
290
350
  }
291
351
  return { valid: true };
292
352
  }
@@ -17,7 +17,7 @@ exports.SUPPORTED_WALLETS = [
17
17
  appName: 'Tonkeeper',
18
18
  universalLink: 'https://app.tonkeeper.com/ton-connect',
19
19
  deepLink: 'tonkeeper://',
20
- platforms: ['ios', 'android'],
20
+ platforms: ['ios', 'android', 'web'], // CRITICAL FIX: Tonkeeper Web is supported
21
21
  preferredReturnStrategy: 'post_redirect', // CRITICAL FIX: 'back' strategy may not send callback properly, use 'post_redirect'
22
22
  requiresReturnScheme: true, // CRITICAL FIX: Mobile apps need returnScheme for proper callback handling
23
23
  },
package/dist/index.d.ts CHANGED
@@ -103,6 +103,8 @@ export declare class TonConnectMobile {
103
103
  /**
104
104
  * Check if a wallet is available on the current platform
105
105
  * Note: This is a best-effort check and may not be 100% accurate
106
+ * CRITICAL FIX: On web, if wallet has universalLink, it's considered available
107
+ * because universal links can open in new tabs/windows
106
108
  */
107
109
  isWalletAvailable(walletName?: string): Promise<boolean>;
108
110
  /**
package/dist/index.js CHANGED
@@ -60,14 +60,14 @@ class UserRejectedError extends TonConnectError {
60
60
  exports.UserRejectedError = UserRejectedError;
61
61
  class ConnectionInProgressError extends TonConnectError {
62
62
  constructor() {
63
- super('Connection request already in progress', 'CONNECTION_IN_PROGRESS');
63
+ super('Connection request already in progress', 'CONNECTION_IN_PROGRESS', 'Please wait for the current connection attempt to complete before trying again.');
64
64
  this.name = 'ConnectionInProgressError';
65
65
  }
66
66
  }
67
67
  exports.ConnectionInProgressError = ConnectionInProgressError;
68
68
  class TransactionInProgressError extends TonConnectError {
69
69
  constructor() {
70
- super('Transaction request already in progress', 'TRANSACTION_IN_PROGRESS');
70
+ super('Transaction request already in progress', 'TRANSACTION_IN_PROGRESS', 'Please wait for the current transaction to complete before sending another one.');
71
71
  this.name = 'TransactionInProgressError';
72
72
  }
73
73
  }
@@ -198,8 +198,9 @@ class TonConnectMobile {
198
198
  const parsed = (0, protocol_1.parseCallbackURL)(url, this.config.scheme);
199
199
  console.log('[TON Connect] Parsed callback:', parsed.type, parsed.data ? 'has data' : 'no data');
200
200
  // CRITICAL FIX: Check for sign data response first (before other handlers)
201
- if (this.signDataPromise && !this.signDataPromise.timeout) {
202
- // Sign data request is pending
201
+ // Note: We check if promise exists and hasn't timed out (timeout !== null means not timed out yet)
202
+ if (this.signDataPromise && this.signDataPromise.timeout !== null) {
203
+ // Sign data request is pending and hasn't timed out
203
204
  if (parsed.type === 'error' && parsed.data) {
204
205
  const errorData = parsed.data;
205
206
  if (errorData?.error) {
@@ -284,12 +285,18 @@ class TonConnectMobile {
284
285
  this.currentStatus = { connected: true, wallet };
285
286
  this.notifyStatusChange();
286
287
  // Resolve connection promise
288
+ // CRITICAL: Only resolve if promise still exists and hasn't timed out
287
289
  if (this.connectionPromise) {
290
+ // Clear timeout if it exists
288
291
  if (this.connectionPromise.timeout !== null) {
289
292
  clearTimeout(this.connectionPromise.timeout);
290
293
  }
291
- this.connectionPromise.resolve(wallet);
294
+ // Store reference before clearing to prevent race conditions
295
+ const promise = this.connectionPromise;
296
+ // Clear promise first
292
297
  this.connectionPromise = null;
298
+ // Then resolve
299
+ promise.resolve(wallet);
293
300
  }
294
301
  }
295
302
  /**
@@ -301,15 +308,21 @@ class TonConnectMobile {
301
308
  return;
302
309
  }
303
310
  // Resolve transaction promise
311
+ // CRITICAL: Only resolve if promise still exists and hasn't timed out
304
312
  if (this.transactionPromise) {
313
+ // Clear timeout if it exists
305
314
  if (this.transactionPromise.timeout !== null) {
306
315
  clearTimeout(this.transactionPromise.timeout);
307
316
  }
308
- this.transactionPromise.resolve({
317
+ // Store reference before clearing
318
+ const promise = this.transactionPromise;
319
+ // Clear promise first to prevent race conditions
320
+ this.transactionPromise = null;
321
+ // Then resolve
322
+ promise.resolve({
309
323
  boc: response.boc,
310
324
  signature: response.signature,
311
325
  });
312
- this.transactionPromise = null;
313
326
  }
314
327
  }
315
328
  /**
@@ -330,6 +343,14 @@ class TonConnectMobile {
330
343
  this.transactionPromise.reject(error);
331
344
  this.transactionPromise = null;
332
345
  }
346
+ // CRITICAL FIX: Also clear signDataPromise to prevent memory leaks
347
+ if (this.signDataPromise) {
348
+ if (this.signDataPromise.timeout !== null) {
349
+ clearTimeout(this.signDataPromise.timeout);
350
+ }
351
+ this.signDataPromise.reject(error);
352
+ this.signDataPromise = null;
353
+ }
333
354
  }
334
355
  /**
335
356
  * Connect to wallet
@@ -649,16 +670,21 @@ class TonConnectMobile {
649
670
  /**
650
671
  * Check if a wallet is available on the current platform
651
672
  * Note: This is a best-effort check and may not be 100% accurate
673
+ * CRITICAL FIX: On web, if wallet has universalLink, it's considered available
674
+ * because universal links can open in new tabs/windows
652
675
  */
653
676
  async isWalletAvailable(walletName) {
654
677
  const wallet = walletName ? (0, wallets_1.getWalletByName)(walletName) : this.currentWallet;
655
678
  if (!wallet) {
656
679
  return false;
657
680
  }
658
- // On web, check if wallet supports web platform
659
- // eslint-disable-next-line no-undef
660
- if (typeof globalThis !== 'undefined' && globalThis.window) {
661
- return wallet.platforms.includes('web');
681
+ // CRITICAL FIX: Check adapter type to reliably detect web platform
682
+ // WebAdapter is only used on web, so this is the most reliable check
683
+ const isWeb = this.adapter.constructor.name === 'WebAdapter';
684
+ if (isWeb) {
685
+ // On web, if wallet has universalLink or supports web platform, it's available
686
+ // Universal links can open in a new tab on web
687
+ return wallet.platforms.includes('web') || !!wallet.universalLink;
662
688
  }
663
689
  // On mobile, we can't reliably check if wallet is installed
664
690
  // Return true if wallet supports the current platform
@@ -51,7 +51,19 @@ const TonConnectUIContext = (0, react_1.createContext)(null);
51
51
  * Compatible with @tonconnect/ui-react API
52
52
  */
53
53
  function TonConnectUIProvider({ config, children, sdkInstance, }) {
54
- const [sdk] = (0, react_1.useState)(() => sdkInstance || new index_1.TonConnectMobile(config));
54
+ // CRITICAL: Initialize SDK only once
55
+ const [sdk] = (0, react_1.useState)(() => {
56
+ if (sdkInstance) {
57
+ return sdkInstance;
58
+ }
59
+ try {
60
+ return new index_1.TonConnectMobile(config);
61
+ }
62
+ catch (error) {
63
+ console.error('[TonConnectUIProvider] Failed to initialize SDK:', error);
64
+ throw error;
65
+ }
66
+ });
55
67
  const [walletState, setWalletState] = (0, react_1.useState)(null);
56
68
  const [modalOpen, setModalOpen] = (0, react_1.useState)(false);
57
69
  const [isConnecting, setIsConnecting] = (0, react_1.useState)(false);
@@ -135,19 +147,42 @@ function TonConnectUIProvider({ config, children, sdkInstance, }) {
135
147
  }, [sdk]);
136
148
  // Send transaction
137
149
  const sendTransaction = (0, react_1.useCallback)(async (transaction) => {
138
- const response = await sdk.sendTransaction(transaction);
139
- return {
140
- boc: response.boc,
141
- signature: response.signature,
142
- };
150
+ try {
151
+ // Validate transaction before sending
152
+ if (!transaction || !transaction.messages || transaction.messages.length === 0) {
153
+ throw new Error('Invalid transaction: messages array is required and cannot be empty');
154
+ }
155
+ if (!transaction.validUntil || transaction.validUntil <= Date.now()) {
156
+ throw new Error('Invalid transaction: validUntil must be in the future');
157
+ }
158
+ const response = await sdk.sendTransaction(transaction);
159
+ return {
160
+ boc: response.boc,
161
+ signature: response.signature,
162
+ };
163
+ }
164
+ catch (error) {
165
+ console.error('[TonConnectUIProvider] Transaction error:', error);
166
+ throw error;
167
+ }
143
168
  }, [sdk]);
144
169
  // Sign data
145
170
  const signData = (0, react_1.useCallback)(async (request) => {
146
- const response = await sdk.signData(request.data, request.version);
147
- return {
148
- signature: response.signature,
149
- timestamp: response.timestamp,
150
- };
171
+ try {
172
+ // Validate request
173
+ if (!request || (!request.data && request.data !== '')) {
174
+ throw new Error('Invalid sign data request: data is required');
175
+ }
176
+ const response = await sdk.signData(request.data, request.version);
177
+ return {
178
+ signature: response.signature,
179
+ timestamp: response.timestamp,
180
+ };
181
+ }
182
+ catch (error) {
183
+ console.error('[TonConnectUIProvider] Sign data error:', error);
184
+ throw error;
185
+ }
151
186
  }, [sdk]);
152
187
  // Create TonConnectUI instance
153
188
  const tonConnectUI = {
@@ -27,18 +27,36 @@ function WalletSelectionModal({ visible, onClose, wallets: customWallets, style,
27
27
  }
28
28
  else {
29
29
  const supportedWallets = sdk.getSupportedWallets();
30
- // Filter wallets for current platform
30
+ // CRITICAL FIX: On web, show all wallets with universalLink (they can open in new tab)
31
+ // On mobile, filter by platform
31
32
  const platform = react_native_1.Platform.OS === 'ios' ? 'ios' : react_native_1.Platform.OS === 'android' ? 'android' : 'web';
32
- const platformWallets = supportedWallets.filter((w) => w.platforms.includes(platform));
33
+ let platformWallets;
34
+ if (platform === 'web') {
35
+ // On web, show all wallets with universalLink (they can open in new tab)
36
+ platformWallets = supportedWallets.filter((w) => w.platforms.includes('web') || !!w.universalLink);
37
+ }
38
+ else {
39
+ platformWallets = supportedWallets.filter((w) => w.platforms.includes(platform));
40
+ }
33
41
  setWallets(platformWallets);
34
42
  }
35
43
  }, [sdk, customWallets]);
36
44
  // Handle wallet selection
37
45
  const handleSelectWallet = async (wallet) => {
46
+ // Prevent multiple simultaneous connection attempts
47
+ if (connectingWallet) {
48
+ return;
49
+ }
38
50
  try {
39
51
  setConnectingWallet(wallet.name);
40
52
  // Set preferred wallet
41
- sdk.setPreferredWallet(wallet.name);
53
+ try {
54
+ sdk.setPreferredWallet(wallet.name);
55
+ }
56
+ catch (error) {
57
+ console.error('[WalletSelectionModal] Failed to set preferred wallet:', error);
58
+ // Continue anyway - SDK will use default wallet
59
+ }
42
60
  // Close modal
43
61
  onClose();
44
62
  // Small delay to ensure modal closes
@@ -47,13 +65,9 @@ function WalletSelectionModal({ visible, onClose, wallets: customWallets, style,
47
65
  await tonConnectUI.connectWallet();
48
66
  }
49
67
  catch (error) {
50
- console.error('Wallet connection error:', error);
68
+ console.error('[WalletSelectionModal] Wallet connection error:', error);
51
69
  setConnectingWallet(null);
52
- // Re-open modal on error
53
- onClose();
54
- setTimeout(() => {
55
- // Modal will be re-opened by parent if needed
56
- }, 500);
70
+ // Error is handled by SDK/UI, just reset connecting state
57
71
  }
58
72
  };
59
73
  return (react_1.default.createElement(react_native_1.Modal, { visible: visible, animationType: "slide", transparent: true, onRequestClose: onClose },
@@ -69,8 +83,9 @@ function WalletSelectionModal({ visible, onClose, wallets: customWallets, style,
69
83
  react_1.default.createElement(react_native_1.Text, { style: styles.emptyStateSubtext }, "Please install a TON wallet app to continue"))) : (wallets.map((wallet) => {
70
84
  const isConnecting = connectingWallet === wallet.name;
71
85
  return (react_1.default.createElement(react_native_1.TouchableOpacity, { key: wallet.name, style: [styles.walletItem, isConnecting && styles.walletItemConnecting], onPress: () => handleSelectWallet(wallet), disabled: isConnecting },
72
- react_1.default.createElement(react_native_1.View, { style: styles.walletIconContainer }, wallet.iconUrl ? (react_1.default.createElement(react_native_1.Image, { source: { uri: wallet.iconUrl }, style: styles.walletIcon })) : (react_1.default.createElement(react_native_1.View, { style: styles.walletIconPlaceholder },
73
- react_1.default.createElement(react_native_1.Text, { style: styles.walletIconText }, wallet.name.charAt(0).toUpperCase())))),
86
+ react_1.default.createElement(react_native_1.View, { style: styles.walletIconContainer },
87
+ react_1.default.createElement(react_native_1.View, { style: styles.walletIconPlaceholder },
88
+ react_1.default.createElement(react_native_1.Text, { style: styles.walletIconText }, wallet.name.charAt(0).toUpperCase()))),
74
89
  react_1.default.createElement(react_native_1.View, { style: styles.walletInfo },
75
90
  react_1.default.createElement(react_native_1.Text, { style: styles.walletName }, wallet.name),
76
91
  react_1.default.createElement(react_native_1.Text, { style: styles.walletAppName }, wallet.appName)),
@@ -42,12 +42,29 @@ function nanoToTon(nanotons) {
42
42
  * @returns Transaction request
43
43
  */
44
44
  function buildTransferTransaction(to, amount, validUntil) {
45
+ // Validate address
46
+ if (!to || typeof to !== 'string') {
47
+ throw new Error('Recipient address is required');
48
+ }
49
+ if (!isValidTonAddress(to)) {
50
+ throw new Error(`Invalid TON address format: ${to}`);
51
+ }
52
+ // Validate amount
53
+ const nanoAmount = tonToNano(amount);
54
+ if (BigInt(nanoAmount) <= 0n) {
55
+ throw new Error('Transaction amount must be greater than 0');
56
+ }
57
+ // Validate validUntil
58
+ const expiration = validUntil || Date.now() + 5 * 60 * 1000; // 5 minutes default
59
+ if (expiration <= Date.now()) {
60
+ throw new Error('Transaction expiration must be in the future');
61
+ }
45
62
  return {
46
- validUntil: validUntil || Date.now() + 5 * 60 * 1000, // 5 minutes default
63
+ validUntil: expiration,
47
64
  messages: [
48
65
  {
49
66
  address: to,
50
- amount: tonToNano(amount),
67
+ amount: nanoAmount,
51
68
  },
52
69
  ],
53
70
  };
@@ -59,12 +76,38 @@ function buildTransferTransaction(to, amount, validUntil) {
59
76
  * @returns Transaction request
60
77
  */
61
78
  function buildMultiTransferTransaction(transfers, validUntil) {
62
- return {
63
- validUntil: validUntil || Date.now() + 5 * 60 * 1000,
64
- messages: transfers.map((transfer) => ({
79
+ // Validate transfers array
80
+ if (!transfers || !Array.isArray(transfers) || transfers.length === 0) {
81
+ throw new Error('Transfers array is required and cannot be empty');
82
+ }
83
+ if (transfers.length > 255) {
84
+ throw new Error('Maximum 255 transfers allowed per transaction');
85
+ }
86
+ // Validate each transfer
87
+ const messages = transfers.map((transfer, index) => {
88
+ if (!transfer.to || typeof transfer.to !== 'string') {
89
+ throw new Error(`Transfer ${index + 1}: Recipient address is required`);
90
+ }
91
+ if (!isValidTonAddress(transfer.to)) {
92
+ throw new Error(`Transfer ${index + 1}: Invalid TON address format: ${transfer.to}`);
93
+ }
94
+ const nanoAmount = tonToNano(transfer.amount);
95
+ if (BigInt(nanoAmount) <= 0n) {
96
+ throw new Error(`Transfer ${index + 1}: Amount must be greater than 0`);
97
+ }
98
+ return {
65
99
  address: transfer.to,
66
- amount: tonToNano(transfer.amount),
67
- })),
100
+ amount: nanoAmount,
101
+ };
102
+ });
103
+ // Validate validUntil
104
+ const expiration = validUntil || Date.now() + 5 * 60 * 1000;
105
+ if (expiration <= Date.now()) {
106
+ throw new Error('Transaction expiration must be in the future');
107
+ }
108
+ return {
109
+ validUntil: expiration,
110
+ messages,
68
111
  };
69
112
  }
70
113
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blazium/ton-connect-mobile",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Production-ready TON Connect Mobile SDK for React Native and Expo. Implements the real TonConnect protocol for mobile applications using deep links and callbacks.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -78,14 +78,14 @@ export class ExpoAdapter implements PlatformAdapter {
78
78
  console.log('[ExpoAdapter] Skipping canOpenURL check (Android compatibility)');
79
79
  }
80
80
 
81
- // CRITICAL FIX: Android'de canOpenURL() tonconnect:// protokolünü tanımayabilir
82
- // Bu yüzden direkt openURL() çağırıyoruz. Eğer açılamazsa hata fırlatır.
81
+ // CRITICAL FIX: On Android, canOpenURL() may not recognize tonconnect:// protocol
82
+ // So we call openURL() directly. If it fails, it will throw an error.
83
83
  await Linking.openURL(url);
84
84
  console.log('[ExpoAdapter] URL opened successfully');
85
85
  return true;
86
86
  } catch (error: any) {
87
87
  console.error('[ExpoAdapter] Error in openURL:', error);
88
- // Android'de tonconnect:// protokolü tanınmıyorsa veya cüzdan yüklü değilse hata verir
88
+ // On Android, if tonconnect:// protocol is not recognized or wallet is not installed, it will throw an error
89
89
  const errorMessage = error?.message || String(error);
90
90
  if (errorMessage.includes('No Activity found') || errorMessage.includes('No app found') || errorMessage.includes('Cannot open URL')) {
91
91
  throw new Error(
@@ -67,14 +67,14 @@ export class ReactNativeAdapter implements PlatformAdapter {
67
67
  console.log('[ReactNativeAdapter] Skipping canOpenURL check (Android compatibility)');
68
68
  }
69
69
 
70
- // CRITICAL FIX: Android'de canOpenURL() tonconnect:// protokolünü tanımayabilir
71
- // Bu yüzden direkt openURL() çağırıyoruz. Eğer açılamazsa hata fırlatır.
70
+ // CRITICAL FIX: On Android, canOpenURL() may not recognize tonconnect:// protocol
71
+ // So we call openURL() directly. If it fails, it will throw an error.
72
72
  await Linking.openURL(url);
73
73
  console.log('[ReactNativeAdapter] URL opened successfully');
74
74
  return true;
75
75
  } catch (error: any) {
76
76
  console.error('[ReactNativeAdapter] Error in openURL:', error);
77
- // Android'de tonconnect:// protokolü tanınmıyorsa veya cüzdan yüklü değilse hata verir
77
+ // On Android, if tonconnect:// protocol is not recognized or wallet is not installed, it will throw an error
78
78
  const errorMessage = error?.message || String(error);
79
79
  if (errorMessage.includes('No Activity found') || errorMessage.includes('No app found') || errorMessage.includes('Cannot open URL')) {
80
80
  throw new Error(
@@ -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
@@ -81,14 +81,22 @@ export class UserRejectedError extends TonConnectError {
81
81
 
82
82
  export class ConnectionInProgressError extends TonConnectError {
83
83
  constructor() {
84
- 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
+ );
85
89
  this.name = 'ConnectionInProgressError';
86
90
  }
87
91
  }
88
92
 
89
93
  export class TransactionInProgressError extends TonConnectError {
90
94
  constructor() {
91
- 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
+ );
92
100
  this.name = 'TransactionInProgressError';
93
101
  }
94
102
  }
@@ -247,8 +255,9 @@ export class TonConnectMobile {
247
255
  console.log('[TON Connect] Parsed callback:', parsed.type, parsed.data ? 'has data' : 'no data');
248
256
 
249
257
  // CRITICAL FIX: Check for sign data response first (before other handlers)
250
- if (this.signDataPromise && !this.signDataPromise.timeout) {
251
- // 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
252
261
  if (parsed.type === 'error' && parsed.data) {
253
262
  const errorData = parsed.data as ErrorResponse;
254
263
  if (errorData?.error) {
@@ -339,12 +348,18 @@ export class TonConnectMobile {
339
348
  this.notifyStatusChange();
340
349
 
341
350
  // Resolve connection promise
351
+ // CRITICAL: Only resolve if promise still exists and hasn't timed out
342
352
  if (this.connectionPromise) {
353
+ // Clear timeout if it exists
343
354
  if (this.connectionPromise.timeout !== null) {
344
355
  clearTimeout(this.connectionPromise.timeout);
345
356
  }
346
- this.connectionPromise.resolve(wallet);
357
+ // Store reference before clearing to prevent race conditions
358
+ const promise = this.connectionPromise;
359
+ // Clear promise first
347
360
  this.connectionPromise = null;
361
+ // Then resolve
362
+ promise.resolve(wallet);
348
363
  }
349
364
  }
350
365
 
@@ -358,15 +373,21 @@ export class TonConnectMobile {
358
373
  }
359
374
 
360
375
  // Resolve transaction promise
376
+ // CRITICAL: Only resolve if promise still exists and hasn't timed out
361
377
  if (this.transactionPromise) {
378
+ // Clear timeout if it exists
362
379
  if (this.transactionPromise.timeout !== null) {
363
380
  clearTimeout(this.transactionPromise.timeout);
364
381
  }
365
- 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({
366
388
  boc: response.boc,
367
389
  signature: response.signature,
368
390
  });
369
- this.transactionPromise = null;
370
391
  }
371
392
  }
372
393
 
@@ -388,6 +409,14 @@ export class TonConnectMobile {
388
409
  this.transactionPromise.reject(error);
389
410
  this.transactionPromise = null;
390
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
+ }
391
420
  }
392
421
 
393
422
  /**
@@ -769,6 +798,8 @@ export class TonConnectMobile {
769
798
  /**
770
799
  * Check if a wallet is available on the current platform
771
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
772
803
  */
773
804
  async isWalletAvailable(walletName?: string): Promise<boolean> {
774
805
  const wallet = walletName ? getWalletByName(walletName) : this.currentWallet;
@@ -776,10 +807,14 @@ export class TonConnectMobile {
776
807
  return false;
777
808
  }
778
809
 
779
- // On web, check if wallet supports web platform
780
- // eslint-disable-next-line no-undef
781
- if (typeof globalThis !== 'undefined' && (globalThis as any).window) {
782
- return wallet.platforms.includes('web');
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;
783
818
  }
784
819
 
785
820
  // On mobile, we can't reliably check if wallet is installed
@@ -109,7 +109,18 @@ export function TonConnectUIProvider({
109
109
  children,
110
110
  sdkInstance,
111
111
  }: TonConnectUIProviderProps): JSX.Element {
112
- 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
+ });
113
124
  const [walletState, setWalletState] = useState<WalletState | null>(null);
114
125
  const [modalOpen, setModalOpen] = useState(false);
115
126
  const [isConnecting, setIsConnecting] = useState(false);
@@ -202,11 +213,24 @@ export function TonConnectUIProvider({
202
213
  // Send transaction
203
214
  const sendTransaction = useCallback(
204
215
  async (transaction: SendTransactionRequest): Promise<TransactionResponse> => {
205
- const response = await sdk.sendTransaction(transaction);
206
- return {
207
- boc: response.boc,
208
- signature: response.signature,
209
- };
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
+ }
210
234
  },
211
235
  [sdk]
212
236
  );
@@ -214,11 +238,21 @@ export function TonConnectUIProvider({
214
238
  // Sign data
215
239
  const signData = useCallback(
216
240
  async (request: SignDataRequest): Promise<SignDataResponse> => {
217
- const response = await sdk.signData(request.data, request.version);
218
- return {
219
- signature: response.signature,
220
- timestamp: response.timestamp,
221
- };
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
+ }
222
256
  },
223
257
  [sdk]
224
258
  );
@@ -11,7 +11,6 @@ import {
11
11
  TouchableOpacity,
12
12
  ScrollView,
13
13
  StyleSheet,
14
- Image,
15
14
  Platform,
16
15
  } from 'react-native';
17
16
  import { useTonConnectUI, useTonConnectSDK } from './index';
@@ -49,20 +48,37 @@ export function WalletSelectionModal({
49
48
  setWallets(customWallets);
50
49
  } else {
51
50
  const supportedWallets = sdk.getSupportedWallets();
52
- // Filter wallets for current platform
51
+ // CRITICAL FIX: On web, show all wallets with universalLink (they can open in new tab)
52
+ // On mobile, filter by platform
53
53
  const platform = Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'web';
54
- const platformWallets = supportedWallets.filter((w) => w.platforms.includes(platform));
54
+ let platformWallets: WalletDefinition[];
55
+ if (platform === 'web') {
56
+ // On web, show all wallets with universalLink (they can open in new tab)
57
+ platformWallets = supportedWallets.filter((w) => w.platforms.includes('web') || !!w.universalLink);
58
+ } else {
59
+ platformWallets = supportedWallets.filter((w) => w.platforms.includes(platform));
60
+ }
55
61
  setWallets(platformWallets);
56
62
  }
57
63
  }, [sdk, customWallets]);
58
64
 
59
65
  // Handle wallet selection
60
66
  const handleSelectWallet = async (wallet: WalletDefinition) => {
67
+ // Prevent multiple simultaneous connection attempts
68
+ if (connectingWallet) {
69
+ return;
70
+ }
71
+
61
72
  try {
62
73
  setConnectingWallet(wallet.name);
63
74
 
64
75
  // Set preferred wallet
65
- sdk.setPreferredWallet(wallet.name);
76
+ try {
77
+ sdk.setPreferredWallet(wallet.name);
78
+ } catch (error) {
79
+ console.error('[WalletSelectionModal] Failed to set preferred wallet:', error);
80
+ // Continue anyway - SDK will use default wallet
81
+ }
66
82
 
67
83
  // Close modal
68
84
  onClose();
@@ -73,13 +89,9 @@ export function WalletSelectionModal({
73
89
  // Connect
74
90
  await tonConnectUI.connectWallet();
75
91
  } catch (error) {
76
- console.error('Wallet connection error:', error);
92
+ console.error('[WalletSelectionModal] Wallet connection error:', error);
77
93
  setConnectingWallet(null);
78
- // Re-open modal on error
79
- onClose();
80
- setTimeout(() => {
81
- // Modal will be re-opened by parent if needed
82
- }, 500);
94
+ // Error is handled by SDK/UI, just reset connecting state
83
95
  }
84
96
  };
85
97
 
@@ -121,15 +133,12 @@ export function WalletSelectionModal({
121
133
  disabled={isConnecting}
122
134
  >
123
135
  <View style={styles.walletIconContainer}>
124
- {wallet.iconUrl ? (
125
- <Image source={{ uri: wallet.iconUrl }} style={styles.walletIcon} />
126
- ) : (
127
- <View style={styles.walletIconPlaceholder}>
128
- <Text style={styles.walletIconText}>
129
- {wallet.name.charAt(0).toUpperCase()}
130
- </Text>
131
- </View>
132
- )}
136
+ {/* Always use placeholder to avoid web image loading issues */}
137
+ <View style={styles.walletIconPlaceholder}>
138
+ <Text style={styles.walletIconText}>
139
+ {wallet.name.charAt(0).toUpperCase()}
140
+ </Text>
141
+ </View>
133
142
  </View>
134
143
  <View style={styles.walletInfo}>
135
144
  <Text style={styles.walletName}>{wallet.name}</Text>
@@ -41,12 +41,32 @@ export function buildTransferTransaction(
41
41
  amount: number | string,
42
42
  validUntil?: number
43
43
  ): SendTransactionRequest {
44
+ // Validate address
45
+ if (!to || typeof to !== 'string') {
46
+ throw new Error('Recipient address is required');
47
+ }
48
+ if (!isValidTonAddress(to)) {
49
+ throw new Error(`Invalid TON address format: ${to}`);
50
+ }
51
+
52
+ // Validate amount
53
+ const nanoAmount = tonToNano(amount);
54
+ if (BigInt(nanoAmount) <= 0n) {
55
+ throw new Error('Transaction amount must be greater than 0');
56
+ }
57
+
58
+ // Validate validUntil
59
+ const expiration = validUntil || Date.now() + 5 * 60 * 1000; // 5 minutes default
60
+ if (expiration <= Date.now()) {
61
+ throw new Error('Transaction expiration must be in the future');
62
+ }
63
+
44
64
  return {
45
- validUntil: validUntil || Date.now() + 5 * 60 * 1000, // 5 minutes default
65
+ validUntil: expiration,
46
66
  messages: [
47
67
  {
48
68
  address: to,
49
- amount: tonToNano(amount),
69
+ amount: nanoAmount,
50
70
  },
51
71
  ],
52
72
  };
@@ -62,12 +82,41 @@ export function buildMultiTransferTransaction(
62
82
  transfers: Array<{ to: string; amount: number | string }>,
63
83
  validUntil?: number
64
84
  ): SendTransactionRequest {
65
- return {
66
- validUntil: validUntil || Date.now() + 5 * 60 * 1000,
67
- messages: transfers.map((transfer) => ({
85
+ // Validate transfers array
86
+ if (!transfers || !Array.isArray(transfers) || transfers.length === 0) {
87
+ throw new Error('Transfers array is required and cannot be empty');
88
+ }
89
+ if (transfers.length > 255) {
90
+ throw new Error('Maximum 255 transfers allowed per transaction');
91
+ }
92
+
93
+ // Validate each transfer
94
+ const messages = transfers.map((transfer, index) => {
95
+ if (!transfer.to || typeof transfer.to !== 'string') {
96
+ throw new Error(`Transfer ${index + 1}: Recipient address is required`);
97
+ }
98
+ if (!isValidTonAddress(transfer.to)) {
99
+ throw new Error(`Transfer ${index + 1}: Invalid TON address format: ${transfer.to}`);
100
+ }
101
+ const nanoAmount = tonToNano(transfer.amount);
102
+ if (BigInt(nanoAmount) <= 0n) {
103
+ throw new Error(`Transfer ${index + 1}: Amount must be greater than 0`);
104
+ }
105
+ return {
68
106
  address: transfer.to,
69
- amount: tonToNano(transfer.amount),
70
- })),
107
+ amount: nanoAmount,
108
+ };
109
+ });
110
+
111
+ // Validate validUntil
112
+ const expiration = validUntil || Date.now() + 5 * 60 * 1000;
113
+ if (expiration <= Date.now()) {
114
+ throw new Error('Transaction expiration must be in the future');
115
+ }
116
+
117
+ return {
118
+ validUntil: expiration,
119
+ messages,
71
120
  };
72
121
  }
73
122