@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.
- package/dist/adapters/expo.js +3 -3
- package/dist/adapters/react-native.js +3 -3
- package/dist/core/protocol.d.ts +1 -0
- package/dist/core/protocol.js +69 -9
- package/dist/core/wallets.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +37 -11
- package/dist/react/TonConnectUIProvider.js +46 -11
- package/dist/react/WalletSelectionModal.js +26 -11
- package/dist/utils/transactionBuilder.js +50 -7
- package/package.json +1 -1
- package/src/adapters/expo.ts +3 -3
- package/src/adapters/react-native.ts +3 -3
- package/src/core/protocol.ts +76 -9
- package/src/core/wallets.ts +1 -1
- package/src/index.ts +46 -11
- package/src/react/TonConnectUIProvider.tsx +45 -11
- package/src/react/WalletSelectionModal.tsx +28 -19
- package/src/utils/transactionBuilder.ts +56 -7
package/dist/adapters/expo.js
CHANGED
|
@@ -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
|
|
71
|
-
//
|
|
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
|
|
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
|
|
59
|
-
//
|
|
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
|
|
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.');
|
package/dist/core/protocol.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/core/protocol.js
CHANGED
|
@@ -195,12 +195,21 @@ function parseCallbackURL(url, scheme) {
|
|
|
195
195
|
return { type: 'unknown', data: null };
|
|
196
196
|
}
|
|
197
197
|
// Extract encoded payload
|
|
198
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
return { valid: false, error:
|
|
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
|
}
|
package/dist/core/wallets.js
CHANGED
|
@@ -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 (
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
659
|
-
//
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 },
|
|
73
|
-
react_1.default.createElement(react_native_1.
|
|
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:
|
|
63
|
+
validUntil: expiration,
|
|
47
64
|
messages: [
|
|
48
65
|
{
|
|
49
66
|
address: to,
|
|
50
|
-
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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:
|
|
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.
|
|
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",
|
package/src/adapters/expo.ts
CHANGED
|
@@ -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
|
|
82
|
-
//
|
|
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
|
|
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
|
|
71
|
-
//
|
|
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
|
|
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(
|
package/src/core/protocol.ts
CHANGED
|
@@ -248,14 +248,23 @@ export function parseCallbackURL(url: string, scheme: string): {
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
// Extract encoded payload
|
|
251
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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 };
|
package/src/core/wallets.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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 (
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
780
|
-
//
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
{
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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:
|
|
65
|
+
validUntil: expiration,
|
|
46
66
|
messages: [
|
|
47
67
|
{
|
|
48
68
|
address: to,
|
|
49
|
-
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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:
|
|
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
|
|