@meshconnect/uwc-ton-connector 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,627 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import type { Network, TonConnectWalletProvider } from '@meshconnect/uwc-types'
3
+
4
+ const {
5
+ mockSdkConnect,
6
+ mockDisconnect,
7
+ mockOnStatusChange,
8
+ mockSendTransaction,
9
+ mockSignData,
10
+ mockPauseConnection,
11
+ mockUnPauseConnection,
12
+ MockTonConnect,
13
+ MockUserRejectsError,
14
+ getLastSdkInstance,
15
+ setNextConnectedState
16
+ } = vi.hoisted(() => {
17
+ const mockSdkConnect = vi.fn()
18
+ const mockDisconnect = vi.fn()
19
+ const mockOnStatusChange = vi.fn()
20
+ const mockSendTransaction = vi.fn()
21
+ const mockSignData = vi.fn()
22
+ const mockPauseConnection = vi.fn()
23
+ const mockUnPauseConnection = vi.fn()
24
+
25
+ class MockUserRejectsError extends Error {
26
+ constructor(message?: string) {
27
+ super(message)
28
+ Object.setPrototypeOf(this, MockUserRejectsError.prototype)
29
+ }
30
+ }
31
+
32
+ const sdkInstances: { current: MockTonConnect | null } = { current: null }
33
+ let nextConnectedState = false
34
+
35
+ class MockTonConnect {
36
+ connected = nextConnectedState
37
+ account: {
38
+ address: string
39
+ chain: string
40
+ walletStateInit: string
41
+ } | null = null
42
+ connect = mockSdkConnect
43
+ disconnect = mockDisconnect
44
+ onStatusChange = mockOnStatusChange
45
+ sendTransaction = mockSendTransaction
46
+ signData = mockSignData
47
+ getWallets = vi.fn().mockResolvedValue([
48
+ {
49
+ appName: 'tonkeeper',
50
+ universalLink: 'https://app.tonkeeper.com/ton-connect',
51
+ bridgeUrl: 'https://bridge.tonapi.io/bridge'
52
+ }
53
+ ])
54
+ pauseConnection = mockPauseConnection
55
+ unPauseConnection = mockUnPauseConnection
56
+ constructor() {
57
+ this.connected = nextConnectedState
58
+ sdkInstances.current = this
59
+ }
60
+ }
61
+
62
+ return {
63
+ mockSdkConnect,
64
+ mockDisconnect,
65
+ mockOnStatusChange,
66
+ mockSendTransaction,
67
+ mockSignData,
68
+ mockPauseConnection,
69
+ mockUnPauseConnection,
70
+ MockTonConnect,
71
+ MockUserRejectsError,
72
+ getLastSdkInstance: () => sdkInstances.current,
73
+ setNextConnectedState: (state: boolean) => {
74
+ nextConnectedState = state
75
+ }
76
+ }
77
+ })
78
+
79
+ vi.mock('@tonconnect/sdk', () => ({
80
+ TonConnect: MockTonConnect,
81
+ UserRejectsError: MockUserRejectsError,
82
+ toUserFriendlyAddress: (hex: string) =>
83
+ `UQ_converted_${hex.split(':')[1]?.slice(0, 8)}`
84
+ }))
85
+
86
+ vi.mock('./ton-transaction-utils', async () => {
87
+ const actual = await vi.importActual<
88
+ typeof import('./ton-transaction-utils')
89
+ >('./ton-transaction-utils')
90
+ return {
91
+ ...actual,
92
+ bocToHash: async (boc: string) => `hash_of_${boc}`
93
+ }
94
+ })
95
+
96
+ import { TonConnectConnector } from './ton-connect-connector'
97
+
98
+ const TON_NETWORK: Network = {
99
+ internalId: -239,
100
+ id: 'tvm:-239',
101
+ namespace: 'tvm',
102
+ name: 'TON',
103
+ logoUrl: 'https://example.com/ton.svg',
104
+ nativeCurrency: { name: 'Toncoin', symbol: 'TON', decimals: 9 },
105
+ networkType: 'tvm'
106
+ }
107
+
108
+ const PROVIDER: TonConnectWalletProvider = {
109
+ supportedNetworkIds: ['tvm:-239'],
110
+ walletListAppName: 'tonkeeper'
111
+ }
112
+
113
+ function createConnector() {
114
+ return new TonConnectConnector({
115
+ manifestUrl: 'https://example.com/tonconnect-manifest.json'
116
+ })
117
+ }
118
+
119
+ function simulateSuccessfulConnect() {
120
+ mockSdkConnect.mockReturnValue('tc://connect?...')
121
+ mockOnStatusChange.mockImplementation(callback => {
122
+ setTimeout(() => {
123
+ callback({ account: { address: '0:abcdef1234567890' } })
124
+ }, 10)
125
+ return vi.fn()
126
+ })
127
+ }
128
+
129
+ describe('TonConnectConnector', () => {
130
+ let connector: TonConnectConnector
131
+
132
+ beforeEach(() => {
133
+ vi.clearAllMocks()
134
+ connector = createConnector()
135
+ })
136
+
137
+ afterEach(() => {
138
+ vi.useRealTimers()
139
+ })
140
+
141
+ describe('constructor', () => {
142
+ it('stores config and does not create SDK instance eagerly', () => {
143
+ expect(getLastSdkInstance()).toBeNull()
144
+ })
145
+
146
+ it('accepts optional walletsListSource', () => {
147
+ const c = new TonConnectConnector({
148
+ manifestUrl: 'https://example.com/manifest.json',
149
+ walletsListSource: 'https://example.com/wallets.json'
150
+ })
151
+ expect(c).toBeInstanceOf(TonConnectConnector)
152
+ })
153
+ })
154
+
155
+ describe('connect', () => {
156
+ it('requires a TonConnectWalletProvider', async () => {
157
+ await expect(connector.connect(TON_NETWORK)).rejects.toThrow(
158
+ 'TonConnectWalletProvider is required'
159
+ )
160
+ })
161
+
162
+ it('requires walletListAppName in provider', async () => {
163
+ const providerNoAppName = {
164
+ supportedNetworkIds: ['tvm:-239'],
165
+ walletListAppName: ''
166
+ } as TonConnectWalletProvider
167
+ await expect(
168
+ connector.connect(TON_NETWORK, providerNoAppName)
169
+ ).rejects.toThrow('walletListAppName is required')
170
+ })
171
+
172
+ it('throws when walletListAppName is not found in SDK wallet list', async () => {
173
+ simulateSuccessfulConnect()
174
+ const unknownProvider: TonConnectWalletProvider = {
175
+ supportedNetworkIds: ['tvm:-239'],
176
+ walletListAppName: 'unknownWallet'
177
+ }
178
+ await expect(
179
+ connector.connect(TON_NETWORK, unknownProvider)
180
+ ).rejects.toThrow(
181
+ "Wallet 'unknownWallet' not found in TonConnect wallet list"
182
+ )
183
+ })
184
+
185
+ it('creates SDK with NamespacedStorage uwc-ton-remote', async () => {
186
+ simulateSuccessfulConnect()
187
+
188
+ await connector.connect(TON_NETWORK, PROVIDER)
189
+
190
+ const sdk = getLastSdkInstance()
191
+ expect(sdk).not.toBeNull()
192
+ })
193
+
194
+ it('calls sdk.connect with universalLink and bridgeUrl', async () => {
195
+ simulateSuccessfulConnect()
196
+
197
+ await connector.connect(TON_NETWORK, PROVIDER)
198
+
199
+ expect(mockSdkConnect).toHaveBeenCalledWith({
200
+ universalLink: 'https://app.tonkeeper.com/ton-connect',
201
+ bridgeUrl: 'https://bridge.tonapi.io/bridge'
202
+ })
203
+ })
204
+
205
+ it('exposes the connection URI while approval is pending, clears it on success', async () => {
206
+ // URI is available during the pending window (sdk.connect sets it synchronously
207
+ // AFTER onStatusChange is registered). It is cleared after the wallet approves
208
+ // so the QR modal dismisses cleanly.
209
+ let capturedURI: string | undefined
210
+
211
+ mockOnStatusChange.mockImplementation(callback => {
212
+ // sdk.connect() is called after onStatusChange returns, so read the URI
213
+ // inside the scheduled callback (by which time sdk.connect has already run).
214
+ setTimeout(() => {
215
+ capturedURI = connector.getConnectionURI()
216
+ callback({ account: { address: '0:abc' } })
217
+ }, 0)
218
+ return vi.fn()
219
+ })
220
+
221
+ await connector.connect(TON_NETWORK, PROVIDER)
222
+
223
+ expect(capturedURI).toBe('tc://connect?...')
224
+ expect(() => connector.getConnectionURI()).toThrow(
225
+ 'No connection URI available'
226
+ )
227
+ })
228
+
229
+ it('sets up onStatusChange BEFORE calling sdk.connect', async () => {
230
+ const callOrder: string[] = []
231
+
232
+ mockOnStatusChange.mockImplementation(callback => {
233
+ callOrder.push('onStatusChange')
234
+ setTimeout(() => {
235
+ callback({ account: { address: '0:abcdef1234567890' } })
236
+ }, 10)
237
+ return vi.fn()
238
+ })
239
+
240
+ mockSdkConnect.mockImplementation(() => {
241
+ callOrder.push('connect')
242
+ return 'tc://uri'
243
+ })
244
+
245
+ await connector.connect(TON_NETWORK, PROVIDER)
246
+
247
+ expect(callOrder).toEqual(['onStatusChange', 'connect'])
248
+ })
249
+
250
+ it('converts raw address to user-friendly format', async () => {
251
+ simulateSuccessfulConnect()
252
+
253
+ const result = await connector.connect(TON_NETWORK, PROVIDER)
254
+
255
+ expect(result.address).toBe('UQ_converted_abcdef12')
256
+ })
257
+
258
+ it('returns correct ConnectorResult shape', async () => {
259
+ simulateSuccessfulConnect()
260
+
261
+ const result = await connector.connect(TON_NETWORK, PROVIDER)
262
+
263
+ expect(result).toEqual({
264
+ networkId: 'tvm:-239',
265
+ address: 'UQ_converted_abcdef12',
266
+ availableAddresses: [
267
+ { address: 'UQ_converted_abcdef12', networkId: 'tvm:-239' }
268
+ ]
269
+ })
270
+ })
271
+
272
+ it('rejects when wallet returns null account and cleans up', async () => {
273
+ mockSdkConnect.mockReturnValue('tc://uri')
274
+ mockDisconnect.mockResolvedValue(undefined)
275
+ mockOnStatusChange.mockImplementation(callback => {
276
+ setTimeout(() => callback(null), 10)
277
+ return vi.fn()
278
+ })
279
+
280
+ await expect(connector.connect(TON_NETWORK, PROVIDER)).rejects.toThrow(
281
+ 'rejected'
282
+ )
283
+
284
+ // URI cleared and SDK disconnected on rejection
285
+ expect(() => connector.getConnectionURI()).toThrow(
286
+ 'No connection URI available'
287
+ )
288
+ expect(mockDisconnect).toHaveBeenCalled()
289
+ })
290
+
291
+ it('rejects via error callback and cleans up', async () => {
292
+ mockSdkConnect.mockReturnValue('tc://uri')
293
+ mockDisconnect.mockResolvedValue(undefined)
294
+ mockOnStatusChange.mockImplementation((_callback, errorCallback) => {
295
+ setTimeout(
296
+ () =>
297
+ errorCallback({
298
+ payload: { code: 0, message: 'User rejected the request' }
299
+ }),
300
+ 10
301
+ )
302
+ return vi.fn()
303
+ })
304
+
305
+ await expect(connector.connect(TON_NETWORK, PROVIDER)).rejects.toThrow(
306
+ 'User rejected the request'
307
+ )
308
+
309
+ // URI cleared and SDK disconnected on error callback
310
+ expect(() => connector.getConnectionURI()).toThrow(
311
+ 'No connection URI available'
312
+ )
313
+ expect(mockDisconnect).toHaveBeenCalled()
314
+ })
315
+
316
+ it('times out after 5 minutes and cleans up', async () => {
317
+ vi.useFakeTimers()
318
+
319
+ mockSdkConnect.mockReturnValue('tc://uri')
320
+ mockOnStatusChange.mockReturnValue(vi.fn())
321
+ mockDisconnect.mockResolvedValue(undefined)
322
+
323
+ const connectPromise = connector.connect(TON_NETWORK, PROVIDER)
324
+ const resultPromise = connectPromise.catch((e: Error) => e)
325
+
326
+ await vi.advanceTimersByTimeAsync(5 * 60 * 1000)
327
+
328
+ const error = await resultPromise
329
+ expect(error).toBeInstanceOf(Error)
330
+ expect(error.message).toContain('timed out')
331
+
332
+ // URI cleared and SDK disconnected on timeout
333
+ expect(() => connector.getConnectionURI()).toThrow(
334
+ 'No connection URI available'
335
+ )
336
+ expect(mockDisconnect).toHaveBeenCalled()
337
+ })
338
+
339
+ it('ignores late timeout after successful connection', async () => {
340
+ vi.useFakeTimers()
341
+
342
+ simulateSuccessfulConnect()
343
+ mockDisconnect.mockResolvedValue(undefined)
344
+
345
+ const connectPromise = connector.connect(TON_NETWORK, PROVIDER)
346
+ await vi.advanceTimersByTimeAsync(10)
347
+ await connectPromise
348
+
349
+ mockDisconnect.mockClear()
350
+ await vi.advanceTimersByTimeAsync(5 * 60 * 1000)
351
+ expect(mockDisconnect).not.toHaveBeenCalled()
352
+ })
353
+
354
+ it('cleans up previous SDK on reconnect', async () => {
355
+ simulateSuccessfulConnect()
356
+ mockDisconnect.mockResolvedValue(undefined)
357
+
358
+ await connector.connect(TON_NETWORK, PROVIDER)
359
+ await connector.connect(TON_NETWORK, PROVIDER)
360
+
361
+ expect(mockDisconnect).toHaveBeenCalled()
362
+ })
363
+
364
+ it('rejects concurrent connect calls while one is in-flight', async () => {
365
+ mockSdkConnect.mockReturnValue('tc://uri')
366
+ mockOnStatusChange.mockImplementation(callback => {
367
+ setTimeout(() => {
368
+ callback({ account: { address: '0:abcdef1234567890' } })
369
+ }, 50)
370
+ return vi.fn()
371
+ })
372
+
373
+ const first = connector.connect(TON_NETWORK, PROVIDER)
374
+ await expect(connector.connect(TON_NETWORK, PROVIDER)).rejects.toThrow(
375
+ 'already in progress'
376
+ )
377
+
378
+ await first
379
+ })
380
+
381
+ it('disconnects stale session from NamespacedStorage on fresh SDK', async () => {
382
+ setNextConnectedState(true)
383
+ mockDisconnect.mockResolvedValue(undefined)
384
+ simulateSuccessfulConnect()
385
+
386
+ await connector.connect(TON_NETWORK, PROVIDER)
387
+
388
+ expect(mockDisconnect).toHaveBeenCalled()
389
+ setNextConnectedState(false)
390
+ })
391
+
392
+ it('sets up visibility change listener', async () => {
393
+ const addEventSpy = vi.spyOn(document, 'addEventListener')
394
+ simulateSuccessfulConnect()
395
+
396
+ await connector.connect(TON_NETWORK, PROVIDER)
397
+
398
+ expect(addEventSpy).toHaveBeenCalledWith(
399
+ 'visibilitychange',
400
+ expect.any(Function)
401
+ )
402
+ addEventSpy.mockRestore()
403
+ })
404
+ })
405
+
406
+ describe('getConnectionURI', () => {
407
+ it('throws before connect', () => {
408
+ expect(() => connector.getConnectionURI()).toThrow(
409
+ 'No connection URI available'
410
+ )
411
+ })
412
+
413
+ it('clears URI after successful connect', async () => {
414
+ simulateSuccessfulConnect()
415
+ await connector.connect(TON_NETWORK, PROVIDER)
416
+ // URI is consumed on success — getConnectionURI should throw until next connect
417
+ expect(() => connector.getConnectionURI()).toThrow(
418
+ 'No connection URI available'
419
+ )
420
+ })
421
+ })
422
+
423
+ describe('signMessage', () => {
424
+ it('throws when not connected', async () => {
425
+ await expect(connector.signMessage!('hello')).rejects.toThrow(
426
+ 'No active TON connection'
427
+ )
428
+ })
429
+
430
+ it('delegates to executeSignData and returns result', async () => {
431
+ simulateSuccessfulConnect()
432
+ await connector.connect(TON_NETWORK, PROVIDER)
433
+
434
+ mockSignData.mockResolvedValue({
435
+ signature: 'sig123',
436
+ timestamp: 1700000000,
437
+ domain: 'example.com'
438
+ })
439
+
440
+ const result = await connector.signMessage!('test message')
441
+
442
+ expect(mockSignData).toHaveBeenCalled()
443
+ expect(result).toEqual({
444
+ type: 'tvm',
445
+ signature: 'sig123',
446
+ timestamp: 1700000000,
447
+ domain: 'example.com',
448
+ payload: { type: 'text', text: 'test message' }
449
+ })
450
+ })
451
+ })
452
+
453
+ describe('sendTransaction', () => {
454
+ it('throws when not connected', async () => {
455
+ await expect(
456
+ connector.sendTransaction!({
457
+ to: 'UQBxyz',
458
+ amount: '1000000000',
459
+ from: 'UQBabc'
460
+ })
461
+ ).rejects.toThrow('No active TON connection')
462
+ })
463
+
464
+ it('builds transaction, sends via SDK, and returns hash', async () => {
465
+ simulateSuccessfulConnect()
466
+ await connector.connect(TON_NETWORK, PROVIDER)
467
+
468
+ getLastSdkInstance().account = {
469
+ address: '0:abcdef1234567890',
470
+ chain: '-239'
471
+ }
472
+ mockSendTransaction.mockResolvedValue({ boc: 'te6cckEBAQEA...' })
473
+
474
+ const result = await connector.sendTransaction!({
475
+ to: 'UQBxyz',
476
+ amount: '1000000000',
477
+ from: 'UQBabc'
478
+ })
479
+
480
+ expect(mockSendTransaction).toHaveBeenCalled()
481
+ expect(result).toBe('hash_of_te6cckEBAQEA...')
482
+ })
483
+
484
+ it('uses sdk.account.address as sender raw address', async () => {
485
+ simulateSuccessfulConnect()
486
+ await connector.connect(TON_NETWORK, PROVIDER)
487
+
488
+ getLastSdkInstance().account = {
489
+ address: '0:sender_raw',
490
+ chain: '-239'
491
+ }
492
+ mockSendTransaction.mockResolvedValue({ boc: 'boc123' })
493
+
494
+ await connector.sendTransaction!({
495
+ to: 'UQBxyz',
496
+ amount: '500000000',
497
+ from: 'UQBabc'
498
+ })
499
+
500
+ const txArg = mockSendTransaction.mock.calls[0][0]
501
+ expect(txArg.from).toBe('0:sender_raw')
502
+ })
503
+
504
+ it('wraps UserRejectsError with clear message', async () => {
505
+ simulateSuccessfulConnect()
506
+ await connector.connect(TON_NETWORK, PROVIDER)
507
+
508
+ getLastSdkInstance().account = { address: '0:abc', chain: '-239' }
509
+ mockSendTransaction.mockRejectedValue(
510
+ new MockUserRejectsError('User rejected')
511
+ )
512
+
513
+ await expect(
514
+ connector.sendTransaction!({
515
+ to: 'UQBxyz',
516
+ amount: '1000000000',
517
+ from: 'UQBabc'
518
+ })
519
+ ).rejects.toThrow('rejected')
520
+ })
521
+ })
522
+
523
+ describe('switchNetwork', () => {
524
+ it('is a no-op that returns the current address and networkId', async () => {
525
+ simulateSuccessfulConnect()
526
+ await connector.connect(TON_NETWORK, PROVIDER)
527
+
528
+ const result = await connector.switchNetwork(TON_NETWORK)
529
+
530
+ expect(result.networkId).toBe('tvm:-239')
531
+ expect(result.address).toBe('UQ_converted_abcdef12')
532
+ })
533
+ })
534
+
535
+ describe('disconnect', () => {
536
+ it('calls sdk.disconnect and clears state', async () => {
537
+ simulateSuccessfulConnect()
538
+ mockDisconnect.mockResolvedValue(undefined)
539
+ await connector.connect(TON_NETWORK, PROVIDER)
540
+
541
+ await connector.disconnect!()
542
+
543
+ expect(mockDisconnect).toHaveBeenCalled()
544
+ })
545
+
546
+ it('does not throw if sdk.disconnect fails', async () => {
547
+ simulateSuccessfulConnect()
548
+ await connector.connect(TON_NETWORK, PROVIDER)
549
+ mockDisconnect.mockRejectedValue(new Error('already disconnected'))
550
+
551
+ await expect(connector.disconnect!()).resolves.toBeUndefined()
552
+ })
553
+
554
+ it('is safe to call when never connected', async () => {
555
+ await expect(connector.disconnect!()).resolves.toBeUndefined()
556
+ })
557
+
558
+ it('resets connectInFlight so subsequent connect works after disconnect', async () => {
559
+ simulateSuccessfulConnect()
560
+ mockDisconnect.mockResolvedValue(undefined)
561
+
562
+ await connector.connect(TON_NETWORK, PROVIDER)
563
+
564
+ // Manually set flag to simulate a stuck state
565
+ connector['connectInFlight'] = true
566
+
567
+ await connector.disconnect!()
568
+
569
+ // Should be able to connect again without "already in progress"
570
+ simulateSuccessfulConnect()
571
+ const result = await connector.connect(TON_NETWORK, PROVIDER)
572
+ expect(result.address).toBeDefined()
573
+ })
574
+ })
575
+
576
+ describe('destroy', () => {
577
+ it('disconnects SDK and removes visibility listener', async () => {
578
+ const removeSpy = vi.spyOn(document, 'removeEventListener')
579
+ simulateSuccessfulConnect()
580
+ mockDisconnect.mockResolvedValue(undefined)
581
+ await connector.connect(TON_NETWORK, PROVIDER)
582
+
583
+ await connector.destroy()
584
+
585
+ expect(mockDisconnect).toHaveBeenCalled()
586
+ expect(removeSpy).toHaveBeenCalledWith(
587
+ 'visibilitychange',
588
+ expect.any(Function)
589
+ )
590
+ removeSpy.mockRestore()
591
+ })
592
+
593
+ it('is safe to call when never connected', async () => {
594
+ await expect(connector.destroy()).resolves.toBeUndefined()
595
+ })
596
+ })
597
+
598
+ describe('visibility handling', () => {
599
+ it('calls unPauseConnection when page becomes visible', async () => {
600
+ simulateSuccessfulConnect()
601
+ await connector.connect(TON_NETWORK, PROVIDER)
602
+
603
+ Object.defineProperty(document, 'visibilityState', {
604
+ value: 'visible',
605
+ writable: true,
606
+ configurable: true
607
+ })
608
+ document.dispatchEvent(new Event('visibilitychange'))
609
+
610
+ expect(mockUnPauseConnection).toHaveBeenCalled()
611
+ })
612
+
613
+ it('calls pauseConnection when page becomes hidden', async () => {
614
+ simulateSuccessfulConnect()
615
+ await connector.connect(TON_NETWORK, PROVIDER)
616
+
617
+ Object.defineProperty(document, 'visibilityState', {
618
+ value: 'hidden',
619
+ writable: true,
620
+ configurable: true
621
+ })
622
+ document.dispatchEvent(new Event('visibilitychange'))
623
+
624
+ expect(mockPauseConnection).toHaveBeenCalled()
625
+ })
626
+ })
627
+ })