@blazium/ton-connect-mobile 1.2.5 → 1.2.6

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.
@@ -1,443 +1,217 @@
1
- /**
2
- * Core TonConnect protocol implementation
3
- * Pure TypeScript, no platform dependencies
4
- */
5
-
6
- // Type declarations for runtime globals
7
- declare const TextEncoder: {
8
- new (): {
9
- encode(input: string): Uint8Array;
10
- };
11
- } | undefined;
12
-
13
- import {
14
- ConnectionRequestPayload,
15
- ConnectionResponsePayload,
16
- TransactionRequestPayload,
17
- TransactionResponsePayload,
18
- ErrorResponse,
19
- WalletInfo,
20
- TransactionMessage,
21
- SendTransactionRequest,
22
- } from '../types';
23
-
24
- /**
25
- * TonConnect protocol constants
26
- */
27
- const PROTOCOL_VERSION = '2';
28
- const CONNECT_PREFIX = 'tonconnect://connect';
29
- const CONNECT_UNIVERSAL_PREFIX = 'https://app.tonkeeper.com/ton-connect';
30
- const SEND_TRANSACTION_PREFIX = 'tonconnect://send-transaction';
31
- const SEND_TRANSACTION_UNIVERSAL_PREFIX = 'https://app.tonkeeper.com/ton-connect/send-transaction';
32
- const CALLBACK_PREFIX = 'tonconnect';
33
-
34
- /**
35
- * Get TextEncoder with availability check
36
- */
37
- function getTextEncoder(): { encode(input: string): Uint8Array } {
38
- if (typeof TextEncoder !== 'undefined') {
39
- return new TextEncoder();
40
- }
41
- throw new Error('TextEncoder is not available. Please use React Native 0.59+ or add a polyfill.');
42
- }
43
-
44
- /**
45
- * Encode string to base64
46
- */
47
- function base64Encode(str: string): string {
48
- // Use TextEncoder to convert string to bytes
49
- const encoder = getTextEncoder();
50
- const bytes = encoder.encode(str);
51
-
52
- // Convert bytes to base64
53
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
54
- let result = '';
55
- let i = 0;
56
-
57
- while (i < bytes.length) {
58
- const a = bytes[i++];
59
- const b = i < bytes.length ? bytes[i++] : 0;
60
- const c = i < bytes.length ? bytes[i++] : 0;
61
-
62
- const bitmap = (a << 16) | (b << 8) | c;
63
-
64
- result += chars.charAt((bitmap >> 18) & 63);
65
- result += chars.charAt((bitmap >> 12) & 63);
66
- result += i - 2 < bytes.length ? chars.charAt((bitmap >> 6) & 63) : '=';
67
- result += i - 1 < bytes.length ? chars.charAt(bitmap & 63) : '=';
68
- }
69
-
70
- return result;
71
- }
72
-
73
- /**
74
- * Decode base64 to string
75
- */
76
- function base64Decode(base64: string): string {
77
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
78
- let buffer = 0;
79
- let bitsCollected = 0;
80
- let result = '';
81
-
82
- for (let i = 0; i < base64.length; i++) {
83
- const ch = base64[i];
84
- if (ch === '=') break;
85
-
86
- const index = chars.indexOf(ch);
87
- if (index === -1) continue;
88
-
89
- buffer = (buffer << 6) | index;
90
- bitsCollected += 6;
91
-
92
- if (bitsCollected >= 8) {
93
- bitsCollected -= 8;
94
- result += String.fromCharCode((buffer >> bitsCollected) & 0xff);
95
- buffer &= (1 << bitsCollected) - 1;
96
- }
97
- }
98
-
99
- return result;
100
- }
101
-
102
- /**
103
- * Encode JSON to base64 URL-safe string
104
- */
105
- export function encodeBase64URL(data: unknown): string {
106
- const json = JSON.stringify(data);
107
- const base64 = base64Encode(json);
108
- // Convert to URL-safe base64
109
- return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
110
- }
111
-
112
- /**
113
- * Decode base64 URL-safe string to JSON
114
- */
115
- export function decodeBase64URL<T>(encoded: string): T {
116
- // Convert from URL-safe base64
117
- const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
118
- // Add padding if needed
119
- const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
120
- const json = base64Decode(padded);
121
- return JSON.parse(json) as T;
122
- }
123
-
124
- /**
125
- * Build connection request URL
126
- * Format: tonconnect://connect?<base64_encoded_payload>
127
- * Or universal link: https://app.tonkeeper.com/ton-connect?<base64_encoded_payload>
128
- * Or custom wallet universal link
129
- */
130
- export function buildConnectionRequest(
131
- manifestUrl: string,
132
- returnScheme: string,
133
- walletUniversalLink?: string,
134
- returnStrategy?: 'back' | 'post_redirect' | 'none',
135
- requiresReturnScheme?: boolean
136
- ): string {
137
- // Build payload with required fields
138
- const payload: ConnectionRequestPayload = {
139
- manifestUrl,
140
- items: [{ name: 'ton_addr' }],
141
- returnStrategy: returnStrategy || 'back',
142
- };
143
-
144
- // CRITICAL FIX: Many wallets (Tonhub, MyTonWallet, Telegram Wallet) require returnScheme
145
- // in the payload to properly handle mobile app callbacks. While not in the official
146
- // protocol spec, it's a de-facto requirement for mobile apps.
147
- if (requiresReturnScheme !== false) {
148
- // Default to true if not specified - safer to include it
149
- payload.returnScheme = returnScheme;
150
- }
151
-
152
- const encoded = encodeBase64URL(payload);
153
-
154
- // Use custom wallet universal link if provided
155
- if (walletUniversalLink) {
156
- return `${walletUniversalLink}?${encoded}`;
157
- }
158
-
159
- // Default to Tonkeeper universal link for Android compatibility
160
- return `${CONNECT_UNIVERSAL_PREFIX}?${encoded}`;
161
- }
162
-
163
- /**
164
- * Build transaction request URL
165
- * Format: tonconnect://send-transaction?<base64_encoded_payload>
166
- * Or universal link: https://app.tonkeeper.com/ton-connect/send-transaction?<base64_encoded_payload>
167
- * Or custom wallet universal link
168
- */
169
- export function buildTransactionRequest(
170
- manifestUrl: string,
171
- request: SendTransactionRequest,
172
- returnScheme: string,
173
- walletUniversalLink?: string,
174
- returnStrategy?: 'back' | 'post_redirect' | 'none',
175
- requiresReturnScheme?: boolean
176
- ): string {
177
- const payload: TransactionRequestPayload = {
178
- manifestUrl,
179
- request: {
180
- validUntil: request.validUntil,
181
- messages: request.messages.map((msg) => ({
182
- address: msg.address,
183
- amount: msg.amount,
184
- payload: msg.payload,
185
- stateInit: msg.stateInit,
186
- })),
187
- network: request.network,
188
- from: request.from,
189
- },
190
- returnStrategy: returnStrategy || 'back',
191
- };
192
-
193
- // CRITICAL FIX: Include returnScheme for mobile wallets that require it
194
- if (requiresReturnScheme !== false) {
195
- payload.returnScheme = returnScheme;
196
- }
197
-
198
- const encoded = encodeBase64URL(payload);
199
-
200
- // Use custom wallet universal link if provided
201
- if (walletUniversalLink) {
202
- // For transaction, append /send-transaction to the base universal link
203
- const baseUrl = walletUniversalLink.endsWith('/ton-connect')
204
- ? walletUniversalLink
205
- : `${walletUniversalLink}/ton-connect`;
206
- return `${baseUrl}/send-transaction?${encoded}`;
207
- }
208
-
209
- // Default to Tonkeeper universal link for Android compatibility
210
- return `${SEND_TRANSACTION_UNIVERSAL_PREFIX}?${encoded}`;
211
- }
212
-
213
- /**
214
- * Parse callback URL
215
- * Format: <scheme>://tonconnect?<base64_encoded_response>
216
- */
217
- export function parseCallbackURL(url: string, scheme: string): {
218
- type: 'connect' | 'transaction' | 'error' | 'unknown';
219
- data: ConnectionResponsePayload | TransactionResponsePayload | ErrorResponse | null;
220
- } {
221
- try {
222
- // CRITICAL FIX: Validate URL input
223
- if (!url || typeof url !== 'string') {
224
- return { type: 'unknown', data: null };
225
- }
226
-
227
- // CRITICAL FIX: Validate URL length (prevent DoS)
228
- if (url.length > 10000) {
229
- return { type: 'unknown', data: null };
230
- }
231
-
232
- // CRITICAL FIX: Validate scheme format
233
- if (!scheme || typeof scheme !== 'string' || scheme.length === 0 || scheme.length > 50) {
234
- return { type: 'unknown', data: null };
235
- }
236
-
237
- // CRITICAL FIX: Exact scheme matching (case-sensitive)
238
- const expectedPrefix = `${scheme}://${CALLBACK_PREFIX}?`;
239
- if (!url.startsWith(expectedPrefix)) {
240
- return { type: 'unknown', data: null };
241
- }
242
-
243
- // CRITICAL FIX: Validate URL structure - should be exactly scheme://tonconnect?<payload>
244
- // Check that there's no additional path or query params
245
- const urlAfterScheme = url.substring(scheme.length + 3); // After "scheme://"
246
- if (!urlAfterScheme.startsWith(`${CALLBACK_PREFIX}?`)) {
247
- return { type: 'unknown', data: null };
248
- }
249
-
250
- // Extract encoded payload
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
- }
261
-
262
- // CRITICAL FIX: Validate base64 payload size (prevent DoS)
263
- if (encoded.length === 0 || encoded.length > 5000) {
264
- return { type: 'unknown', data: null };
265
- }
266
-
267
- // CRITICAL FIX: Validate base64 characters only (after URL decoding)
268
- if (!/^[A-Za-z0-9_-]+$/.test(encoded)) {
269
- return { type: 'unknown', data: null };
270
- }
271
-
272
- const decoded = decodeBase64URL(encoded);
273
-
274
- // Validate decoded data is an object
275
- if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) {
276
- return { type: 'unknown', data: null };
277
- }
278
-
279
- // Check if it's an error response
280
- if ('error' in decoded && typeof decoded.error === 'object') {
281
- const errorData = decoded as ErrorResponse;
282
- if (errorData.error && typeof errorData.error.code === 'number' && typeof errorData.error.message === 'string') {
283
- return { type: 'error', data: errorData };
284
- }
285
- }
286
-
287
- // Check if it's a connection response (has session, address, publicKey)
288
- if (
289
- 'session' in decoded &&
290
- 'address' in decoded &&
291
- 'publicKey' in decoded &&
292
- typeof decoded.session === 'string' &&
293
- typeof decoded.address === 'string' &&
294
- typeof decoded.publicKey === 'string'
295
- ) {
296
- return { type: 'connect', data: decoded as ConnectionResponsePayload };
297
- }
298
-
299
- // Check if it's a transaction response (has boc, signature)
300
- if (
301
- 'boc' in decoded &&
302
- 'signature' in decoded &&
303
- typeof decoded.boc === 'string' &&
304
- typeof decoded.signature === 'string'
305
- ) {
306
- return { type: 'transaction', data: decoded as TransactionResponsePayload };
307
- }
308
-
309
- return { type: 'unknown', data: null };
310
- } catch (error) {
311
- // Log error for debugging but don't expose details
312
- return { type: 'unknown', data: null };
313
- }
314
- }
315
-
316
- /**
317
- * Extract wallet info from connection response
318
- * CRITICAL: This function assumes response has been validated by validateConnectionResponse
319
- */
320
- export function extractWalletInfo(
321
- response: ConnectionResponsePayload
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
-
328
- return {
329
- name: response.name,
330
- appName: response.appName || response.name,
331
- version: response.version || 'unknown',
332
- platform: response.platform || 'unknown',
333
- address: response.address,
334
- publicKey: response.publicKey,
335
- icon: response.icon,
336
- };
337
- }
338
-
339
- /**
340
- * Validate connection response
341
- * CRITICAL FIX: Only validate truly required fields (session, address, publicKey, name)
342
- * appName and version are optional - extractWalletInfo has fallbacks for them
343
- */
344
- export function validateConnectionResponse(
345
- response: ConnectionResponsePayload
346
- ): boolean {
347
- return !!(
348
- response.session &&
349
- response.address &&
350
- response.publicKey &&
351
- response.name
352
- // Note: appName and version are optional - extractWalletInfo handles fallbacks
353
- // Some wallets may not send these fields, and that's OK
354
- );
355
- }
356
-
357
- /**
358
- * Validate transaction response
359
- */
360
- export function validateTransactionResponse(
361
- response: TransactionResponsePayload
362
- ): boolean {
363
- return !!(response.boc && response.signature);
364
- }
365
-
366
- /**
367
- * Validate transaction request
368
- */
369
- export function validateTransactionRequest(
370
- request: SendTransactionRequest
371
- ): { valid: boolean; error?: string } {
372
- if (!request.validUntil || request.validUntil <= Date.now()) {
373
- return { valid: false, error: 'Transaction request has expired' };
374
- }
375
-
376
- if (!request.messages || request.messages.length === 0) {
377
- return { valid: false, error: 'Transaction must have at least one message' };
378
- }
379
-
380
- // CRITICAL: Validate each message
381
- for (let i = 0; i < request.messages.length; i++) {
382
- const msg = request.messages[i];
383
-
384
- // Validate address
385
- if (!msg.address || typeof msg.address !== 'string') {
386
- return { valid: false, error: `Message ${i + 1}: Address is required and must be a string` };
387
- }
388
-
389
- // CRITICAL: Validate TON address format (EQ... or 0Q...)
390
- if (!/^(EQ|0Q)[A-Za-z0-9_-]{46}$/.test(msg.address)) {
391
- return { valid: false, error: `Message ${i + 1}: Invalid TON address format. Address must start with EQ or 0Q and be 48 characters long.` };
392
- }
393
-
394
- // Validate amount
395
- if (!msg.amount || typeof msg.amount !== 'string') {
396
- return { valid: false, error: `Message ${i + 1}: Amount is required and must be a string (nanotons)` };
397
- }
398
-
399
- // CRITICAL: Validate amount is a valid positive number (nanotons)
400
- try {
401
- const amount = BigInt(msg.amount);
402
- if (amount <= 0n) {
403
- return { valid: false, error: `Message ${i + 1}: Amount must be greater than 0` };
404
- }
405
- // Check for reasonable maximum (prevent overflow)
406
- if (amount > BigInt('1000000000000000000')) { // 1 billion TON
407
- return { valid: false, error: `Message ${i + 1}: Amount exceeds maximum allowed (1 billion TON)` };
408
- }
409
- } catch (error) {
410
- return { valid: false, error: `Message ${i + 1}: Amount must be a valid number string (nanotons)` };
411
- }
412
-
413
- // Validate payload if provided (must be base64)
414
- if (msg.payload !== undefined && msg.payload !== null) {
415
- if (typeof msg.payload !== 'string') {
416
- return { valid: false, error: `Message ${i + 1}: Payload must be a base64 string` };
417
- }
418
- // Basic base64 validation
419
- if (msg.payload.length > 0 && !/^[A-Za-z0-9+/=]+$/.test(msg.payload)) {
420
- return { valid: false, error: `Message ${i + 1}: Payload must be valid base64 encoded` };
421
- }
422
- }
423
-
424
- // Validate stateInit if provided (must be base64)
425
- if (msg.stateInit !== undefined && msg.stateInit !== null) {
426
- if (typeof msg.stateInit !== 'string') {
427
- return { valid: false, error: `Message ${i + 1}: StateInit must be a base64 string` };
428
- }
429
- // Basic base64 validation
430
- if (msg.stateInit.length > 0 && !/^[A-Za-z0-9+/=]+$/.test(msg.stateInit)) {
431
- return { valid: false, error: `Message ${i + 1}: StateInit must be valid base64 encoded` };
432
- }
433
- }
434
- }
435
-
436
- // CRITICAL: Limit maximum number of messages (prevent DoS)
437
- if (request.messages.length > 255) {
438
- return { valid: false, error: 'Transaction cannot have more than 255 messages' };
439
- }
440
-
441
- return { valid: true };
442
- }
443
-
1
+ /**
2
+ * TON Connect v2 Protocol Implementation
3
+ * Builds correct universal links and parses bridge responses
4
+ */
5
+
6
+ import type {
7
+ SendTransactionRequest,
8
+ WalletInfo,
9
+ ConnectEvent,
10
+ ConnectErrorEvent,
11
+ RpcResponse,
12
+ RpcErrorResponse,
13
+ } from '../types';
14
+
15
+ /**
16
+ * Protocol version
17
+ */
18
+ const PROTOCOL_VERSION = '2';
19
+
20
+ /**
21
+ * Build a TON Connect v2 universal link for wallet connection
22
+ * Format: {universalLink}?v=2&id={sessionId}&r={connectRequest}&ret={returnStrategy}
23
+ */
24
+ export function buildConnectUniversalLink(
25
+ universalLink: string,
26
+ sessionId: string,
27
+ manifestUrl: string,
28
+ returnStrategy: string = 'back'
29
+ ): string {
30
+ // Build connect request (TON Connect v2 format)
31
+ const connectRequest = {
32
+ manifestUrl,
33
+ items: [{ name: 'ton_addr' }],
34
+ };
35
+
36
+ // Build URL with proper query parameters
37
+ const r = JSON.stringify(connectRequest);
38
+ const params = [
39
+ `v=${PROTOCOL_VERSION}`,
40
+ `id=${sessionId}`,
41
+ `r=${encodeURIComponent(r)}`,
42
+ `ret=${encodeURIComponent(returnStrategy)}`,
43
+ ];
44
+
45
+ // Handle wallet universal links that may already have query params
46
+ const separator = universalLink.includes('?') ? '&' : '?';
47
+ return `${universalLink}${separator}${params.join('&')}`;
48
+ }
49
+
50
+ /**
51
+ * Build a universal link to bring wallet to foreground (for pending transactions)
52
+ * Format: {universalLink}?ret={returnStrategy}
53
+ */
54
+ export function buildReturnUniversalLink(
55
+ universalLink: string,
56
+ returnStrategy: string = 'back'
57
+ ): string {
58
+ const separator = universalLink.includes('?') ? '&' : '?';
59
+ return `${universalLink}${separator}ret=${encodeURIComponent(returnStrategy)}`;
60
+ }
61
+
62
+ /**
63
+ * Build a JSON-RPC request for sendTransaction
64
+ */
65
+ export function buildSendTransactionRpcRequest(
66
+ request: SendTransactionRequest,
67
+ id: number
68
+ ): string {
69
+ // TON Connect v2 sendTransaction format
70
+ const params = JSON.stringify({
71
+ valid_until: Math.floor(request.validUntil / 1000), // Convert ms to seconds
72
+ network: request.network === 'testnet' ? '-3' : '-239',
73
+ from: request.from,
74
+ messages: request.messages.map((msg) => ({
75
+ address: msg.address,
76
+ amount: msg.amount,
77
+ payload: msg.payload,
78
+ stateInit: msg.stateInit,
79
+ })),
80
+ });
81
+
82
+ return JSON.stringify({
83
+ method: 'sendTransaction',
84
+ params: [params],
85
+ id,
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Build a JSON-RPC request for disconnect
91
+ */
92
+ export function buildDisconnectRpcRequest(id: number): string {
93
+ return JSON.stringify({
94
+ method: 'disconnect',
95
+ params: [],
96
+ id,
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Parse a connect response from the wallet (received via bridge, after decryption)
102
+ */
103
+ export function parseConnectResponse(
104
+ decrypted: string
105
+ ): { type: 'connect'; data: ConnectEvent } | { type: 'error'; data: ConnectErrorEvent } | null {
106
+ try {
107
+ const parsed = JSON.parse(decrypted);
108
+
109
+ if (parsed.event === 'connect' && parsed.payload) {
110
+ return { type: 'connect', data: parsed as ConnectEvent };
111
+ }
112
+
113
+ if (parsed.event === 'connect_error' && parsed.payload) {
114
+ return { type: 'error', data: parsed as ConnectErrorEvent };
115
+ }
116
+
117
+ return null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Parse an RPC response from the wallet (for sendTransaction, disconnect, etc.)
125
+ */
126
+ export function parseRpcResponse(
127
+ decrypted: string
128
+ ): { type: 'result'; data: RpcResponse } | { type: 'error'; data: RpcErrorResponse } | { type: 'event'; event: string; data: any } | null {
129
+ try {
130
+ const parsed = JSON.parse(decrypted);
131
+
132
+ // Check for events (disconnect, etc.)
133
+ if (parsed.event) {
134
+ return { type: 'event', event: parsed.event, data: parsed.payload || null };
135
+ }
136
+
137
+ // Check for RPC result
138
+ if ('result' in parsed && parsed.id !== undefined) {
139
+ return { type: 'result', data: parsed as RpcResponse };
140
+ }
141
+
142
+ // Check for RPC error
143
+ if ('error' in parsed && parsed.id !== undefined) {
144
+ return { type: 'error', data: parsed as RpcErrorResponse };
145
+ }
146
+
147
+ return null;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Extract wallet info from a connect event
155
+ */
156
+ export function extractWalletInfoFromEvent(event: ConnectEvent): WalletInfo {
157
+ const tonAddr = event.payload.items.find((item: any) => item.name === 'ton_addr');
158
+ if (!tonAddr) {
159
+ throw new Error('Connect response missing ton_addr item');
160
+ }
161
+
162
+ const device = event.payload.device || {};
163
+
164
+ return {
165
+ name: device.appName || 'Unknown Wallet',
166
+ appName: device.appName || 'unknown',
167
+ version: device.appVersion || 'unknown',
168
+ platform: (device.platform as 'ios' | 'android' | 'unknown') || 'unknown',
169
+ address: tonAddr.address,
170
+ publicKey: tonAddr.publicKey,
171
+ network: tonAddr.network,
172
+ walletStateInit: tonAddr.walletStateInit,
173
+ icon: undefined,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Validate transaction request
179
+ */
180
+ export function validateTransactionRequest(
181
+ request: SendTransactionRequest
182
+ ): { valid: boolean; error?: string } {
183
+ if (!request.validUntil || request.validUntil <= Date.now()) {
184
+ return { valid: false, error: 'Transaction request has expired' };
185
+ }
186
+
187
+ if (!request.messages || request.messages.length === 0) {
188
+ return { valid: false, error: 'Transaction must have at least one message' };
189
+ }
190
+
191
+ for (let i = 0; i < request.messages.length; i++) {
192
+ const msg = request.messages[i];
193
+
194
+ if (!msg.address || typeof msg.address !== 'string') {
195
+ return { valid: false, error: `Message ${i + 1}: Address is required` };
196
+ }
197
+
198
+ if (!msg.amount || typeof msg.amount !== 'string') {
199
+ return { valid: false, error: `Message ${i + 1}: Amount is required (nanotons string)` };
200
+ }
201
+
202
+ try {
203
+ const amount = BigInt(msg.amount);
204
+ if (amount <= 0n) {
205
+ return { valid: false, error: `Message ${i + 1}: Amount must be > 0` };
206
+ }
207
+ } catch {
208
+ return { valid: false, error: `Message ${i + 1}: Amount must be a valid number string` };
209
+ }
210
+ }
211
+
212
+ if (request.messages.length > 255) {
213
+ return { valid: false, error: 'Maximum 255 messages per transaction' };
214
+ }
215
+
216
+ return { valid: true };
217
+ }