import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' const { mockConnect, mockDisconnect, mockOnStatusChange, mockRestoreConnection, mockSendTransaction, mockSignData, MockTonConnect, MockUserRejectsError, getLastSdkInstance } = vi.hoisted(() => { const mockConnect = vi.fn() const mockDisconnect = vi.fn() const mockOnStatusChange = vi.fn() const mockRestoreConnection = vi.fn() const mockSendTransaction = vi.fn() const mockSignData = vi.fn() class MockUserRejectsError extends Error { constructor(message?: string) { super(message) Object.setPrototypeOf(this, MockUserRejectsError.prototype) } } const sdkInstances: { current: any } = { current: null } class MockTonConnect { static startConnected = false connected = MockTonConnect.startConnected account: { address: string } | null = null connect = mockConnect disconnect = mockDisconnect onStatusChange = mockOnStatusChange restoreConnection = mockRestoreConnection sendTransaction = mockSendTransaction signData = mockSignData __options: any constructor(options?: any) { this.__options = options sdkInstances.current = this } } return { mockConnect, mockDisconnect, mockOnStatusChange, mockRestoreConnection, mockSendTransaction, mockSignData, MockTonConnect, MockUserRejectsError, getLastSdkInstance: () => sdkInstances.current } }) vi.mock('@tonconnect/sdk', () => ({ TonConnect: MockTonConnect, UserRejectsError: MockUserRejectsError, toUserFriendlyAddress: (hex: string) => `UQ_converted_${hex.split(':')[1]?.slice(0, 8)}` })) vi.mock('@meshconnect/uwc-ton-connector', async () => { const actual = await vi.importActual< typeof import('@meshconnect/uwc-ton-connector') >('@meshconnect/uwc-ton-connector') return { ...actual, bocToHash: async (boc: string) => `hash_of_${boc}` } }) import { TonWalletService } from './ton-wallet-service' import { WALLET_RESPONSE_TIMEOUT_MS, NamespacedStorage } from '@meshconnect/uwc-ton-connector' async function connectService( svc: TonWalletService ): Promise { mockOnStatusChange.mockImplementation(callback => { setTimeout(() => { callback({ account: { address: 'UQBconnected' } }) }, 10) return vi.fn() }) mockConnect.mockResolvedValue(undefined) await svc.connect('tonkeeper') return svc } describe('TonWalletService', () => { let service: TonWalletService beforeEach(() => { vi.clearAllMocks() localStorage.clear() // Default: restoreConnection is a no-op resolve. Individual tests can // override to simulate a wallet that emits a wallet on restore. mockRestoreConnection.mockResolvedValue(undefined) service = new TonWalletService('https://example.com/manifest.json') }) afterEach(() => { // Some tests switch to fake timers; never let that leak into the next test. vi.useRealTimers() }) describe('connect', () => { it('sets up onStatusChange listener before calling sdk.connect', async () => { const callOrder: string[] = [] mockOnStatusChange.mockImplementation(callback => { callOrder.push('onStatusChange') setTimeout(() => { callback({ account: { address: 'UQBtest123' } }) }, 10) return vi.fn() }) mockConnect.mockImplementation(() => { callOrder.push('connect') return Promise.resolve() }) await service.connect('tonkeeper') expect(callOrder).toEqual(['onStatusChange', 'connect']) }) it('disables built-in SDK analytics', async () => { await connectService(service) expect(getLastSdkInstance().__options?.analytics?.mode).toBe('off') }) it('returns user-friendly address on success', async () => { mockOnStatusChange.mockImplementation(callback => { setTimeout(() => { callback({ account: { address: 'UQBtest123' } }) }, 10) return vi.fn() }) mockConnect.mockResolvedValue(undefined) const result = await service.connect('tonkeeper') expect(result.address).toBe('UQBtest123') expect(service.getAccount()).toBe('UQBtest123') expect(service.getConnectedJsBridgeKey()).toBe('tonkeeper') }) it('rejects when wallet returns null (user rejected)', async () => { mockOnStatusChange.mockImplementation(callback => { setTimeout(() => callback(null), 10) return vi.fn() }) mockConnect.mockResolvedValue(undefined) await expect(service.connect('tonkeeper')).rejects.toThrow('rejected') }) it('rejects with clean message from error callback payload', async () => { mockOnStatusChange.mockImplementation((_callback, errorCallback) => { setTimeout( () => errorCallback({ payload: { code: 0, message: 'Reject request' }, message: '[TON_CONNECT_SDK_ERROR] _UnknownError\nReject request' }), 10 ) return vi.fn() }) mockConnect.mockResolvedValue(undefined) await expect(service.connect('tonkeeper')).rejects.toThrow( 'Reject request' ) }) it('extracts last line from SDK error when error callback has no payload', async () => { mockOnStatusChange.mockImplementation((_callback, errorCallback) => { setTimeout( () => errorCallback({ message: '[TON_CONNECT_SDK_ERROR] _UnknownError\nReject request' }), 10 ) return vi.fn() }) mockConnect.mockResolvedValue(undefined) await expect(service.connect('tonkeeper')).rejects.toThrow( 'Reject request' ) }) it('uses default message when error callback has no message', async () => { mockOnStatusChange.mockImplementation((_callback, errorCallback) => { setTimeout(() => errorCallback({}), 10) return vi.fn() }) mockConnect.mockResolvedValue(undefined) await expect(service.connect('tonkeeper')).rejects.toThrow( 'TON wallet connection was rejected' ) }) it('converts raw 0:hex address to user-friendly format', async () => { mockOnStatusChange.mockImplementation(callback => { setTimeout(() => { callback({ account: { address: '0:abcdef1234567890' } }) }, 10) return vi.fn() }) mockConnect.mockResolvedValue(undefined) const result = await service.connect('tonkeeper') expect(result.address).toBe('UQ_converted_abcdef12') }) it('throws when manifestUrl is not set', async () => { const noManifestService = new TonWalletService() await expect(noManifestService.connect('tonkeeper')).rejects.toThrow( 'manifestUrl is required' ) }) it('resolves manifestUrl getter at connect time', async () => { const getter = vi.fn(() => 'https://resolved.com/manifest.json') const lazyService = new TonWalletService(getter) mockOnStatusChange.mockImplementation(callback => { setTimeout(() => { callback({ account: { address: 'UQBtest123' } }) }, 10) return vi.fn() }) mockConnect.mockResolvedValue(undefined) await lazyService.connect('tonkeeper') expect(getter).toHaveBeenCalled() const sdkInstance = getLastSdkInstance() expect(sdkInstance.__options.manifestUrl).toBe( 'https://resolved.com/manifest.json' ) }) it('works with static string manifestUrl (backwards compatible)', async () => { mockOnStatusChange.mockImplementation(callback => { setTimeout(() => { callback({ account: { address: 'UQBtest123' } }) }, 10) return vi.fn() }) mockConnect.mockResolvedValue(undefined) await service.connect('tonkeeper') const sdkInstance = getLastSdkInstance() expect(sdkInstance.__options.manifestUrl).toBe( 'https://example.com/manifest.json' ) }) it('cleans up existing SDK instance before reconnecting', async () => { await connectService(service) mockDisconnect.mockResolvedValue(undefined) mockOnStatusChange.mockImplementation(callback => { setTimeout(() => { callback({ account: { address: 'UQBnewAddress' } }) }, 10) return vi.fn() }) mockConnect.mockResolvedValue(undefined) const result = await service.connect('mytonwallet') expect(result.address).toBe('UQBnewAddress') expect(mockDisconnect).toHaveBeenCalled() }) it('invokes onSessionRestored when restoration succeeds', async () => { localStorage.setItem( 'uwc-ton-injected:ton-connect-storage_bridge-connection', JSON.stringify({ type: 'injected', jsBridgeKey: 'tonkeeper', nextRpcRequestId: 0 }) ) mockRestoreConnection.mockResolvedValue(undefined) mockOnStatusChange.mockImplementation(callback => { queueMicrotask(() => callback({ account: { address: '0:abc' } })) return vi.fn() }) const onSessionRestored = vi.fn() const restoringService = new TonWalletService( 'https://example.com/manifest.json', onSessionRestored ) await restoringService.connect('tonkeeper') expect(onSessionRestored).toHaveBeenCalledTimes(1) }) it('does not invoke onSessionRestored on a fresh handshake', async () => { const onSessionRestored = vi.fn() const freshService = new TonWalletService( 'https://example.com/manifest.json', onSessionRestored ) mockOnStatusChange.mockImplementation(callback => { setTimeout(() => { callback({ account: { address: 'UQBfresh' } }) }, 10) return vi.fn() }) mockConnect.mockResolvedValue(undefined) await freshService.connect('tonkeeper') expect(onSessionRestored).not.toHaveBeenCalled() }) it('restores from storage without prompting when an injected session matches the requested jsBridgeKey', async () => { // Seed a prior injected session in the same namespace the service uses. localStorage.setItem( 'uwc-ton-injected:ton-connect-storage_bridge-connection', JSON.stringify({ type: 'injected', jsBridgeKey: 'tonkeeper', nextRpcRequestId: 0 }) ) mockRestoreConnection.mockImplementation(() => Promise.resolve()) mockOnStatusChange.mockImplementation(callback => { queueMicrotask(() => callback({ account: { address: '0:restoredHex' } }) ) return vi.fn() }) const result = await service.connect('tonkeeper') expect(result.address).toBe('UQ_converted_restored') expect(mockRestoreConnection).toHaveBeenCalledTimes(1) // Fresh handshake must NOT run when restore succeeds. expect(mockConnect).not.toHaveBeenCalled() }) it('falls through to fresh connect when stored session targets a different wallet', async () => { const storageKey = 'uwc-ton-injected:ton-connect-storage_bridge-connection' localStorage.setItem( storageKey, JSON.stringify({ type: 'injected', jsBridgeKey: 'mytonwallet', nextRpcRequestId: 0 }) ) mockOnStatusChange.mockImplementation(callback => { setTimeout(() => { callback({ account: { address: 'UQBfresh' } }) }, 10) return vi.fn() }) mockConnect.mockResolvedValue(undefined) const result = await service.connect('tonkeeper') expect(result.address).toBe('UQBfresh') // Preflight wipes the stale session (directly, without calling // sdk.disconnect — which would throw on a fresh SDK) and the fresh // handshake runs. expect(localStorage.getItem(storageKey)).toBeNull() expect(mockDisconnect).not.toHaveBeenCalled() expect(mockConnect).toHaveBeenCalled() }) it('ignores late timeout after successful connection (settled guard)', async () => { vi.useFakeTimers() // Wallet approves immediately mockOnStatusChange.mockImplementation(callback => { setTimeout(() => { callback({ account: { address: 'UQBtest' } }) }, 10) return vi.fn() }) mockConnect.mockResolvedValue(undefined) mockDisconnect.mockResolvedValue(undefined) const connectPromise = service.connect('tonkeeper') await vi.advanceTimersByTimeAsync(10) const result = await connectPromise expect(result.address).toBe('UQBtest') // Advance past the timeout — should NOT call disconnect mockDisconnect.mockClear() await vi.advanceTimersByTimeAsync(60_000) expect(mockDisconnect).not.toHaveBeenCalled() vi.useRealTimers() }) it('cleans up SDK when address conversion throws', async () => { // Import the mocked module to spy on toFriendlyAddress const tonConnector = await import('@meshconnect/uwc-ton-connector') const spy = vi .spyOn(tonConnector, 'toFriendlyAddress') .mockImplementation(() => { throw new Error('Invalid address format') }) mockOnStatusChange.mockImplementation(callback => { setTimeout(() => { callback({ account: { address: '0:malformed' } }) }, 10) return vi.fn() }) mockConnect.mockResolvedValue(undefined) mockDisconnect.mockResolvedValue(undefined) await expect(service.connect('tonkeeper')).rejects.toThrow( 'Invalid address format' ) expect(mockDisconnect).toHaveBeenCalled() expect(service.getAccount()).toBeNull() spy.mockRestore() }) it('rejects with timeout error when wallet does not respond', async () => { vi.useFakeTimers() // onStatusChange never fires — simulates user ignoring the prompt mockOnStatusChange.mockReturnValue(vi.fn()) mockConnect.mockResolvedValue(undefined) mockDisconnect.mockResolvedValue(undefined) const connectPromise = service.connect('tonkeeper') // Attach handler BEFORE advancing timers to prevent unhandled rejection const resultPromise = connectPromise.catch((e: Error) => e) await vi.runAllTimersAsync() const error = await resultPromise expect(error).toBeInstanceOf(Error) expect(error.message).toContain('timed out') vi.useRealTimers() }) it('cleans up the freshly-created SDK and rethrows when the restore preflight throws', async () => { const tonConnector = await import('@meshconnect/uwc-ton-connector') const spy = vi .spyOn(tonConnector, 'tryRestoreInjectedSession') .mockRejectedValue(new Error('TON Connect connection was cancelled')) mockDisconnect.mockResolvedValue(undefined) await expect(service.connect('tonkeeper')).rejects.toThrow('cancelled') // The SDK created for this connect must not be orphaned on a restore throw. expect(mockDisconnect).toHaveBeenCalled() expect(service.getAccount()).toBeNull() spy.mockRestore() }) it('aborts an in-flight restore when a newer connect starts, so the first connect rejects instead of lingering', async () => { // Seed a restorable session so connect #1 enters the restore preflight. localStorage.setItem( 'uwc-ton-injected:ton-connect-storage_bridge-connection', JSON.stringify({ type: 'injected', jsBridgeKey: 'tonkeeper', nextRpcRequestId: 0 }) ) // Restore parks: the wallet never emits and restoreConnection never // settles, so connect #1 sits in awaitRestoredAccount. That hook is // registered immediately before onStatusChange subscribes, so the // subscription is a deterministic "connect #1 is parked" signal — // no wall-clock sleep. let resolveSubscribed: () => void = () => undefined const subscribed = new Promise(resolve => { resolveSubscribed = resolve }) mockRestoreConnection.mockReturnValue(new Promise(() => undefined)) mockOnStatusChange.mockImplementation(() => { resolveSubscribed() return vi.fn() }) const connect1 = service.connect('tonkeeper') const settled1 = connect1.catch((e: Error) => e) await subscribed // A second connect must abort the first BEFORE tearing down its SDK. const connect2 = service.connect('tonkeeper') connect2.catch(() => undefined) const result1 = await settled1 expect(result1).toBeInstanceOf(Error) expect((result1 as Error).message).toContain('cancelled') }) it('cancels an in-flight connect when disconnect() lands during the restore storage read', async () => { // Seed a restorable session so, absent cancellation, the restore SUCCEEDS // — a clean, fast contrast to the cancelled rejection. localStorage.setItem( 'uwc-ton-injected:ton-connect-storage_bridge-connection', JSON.stringify({ type: 'injected', jsBridgeKey: 'tonkeeper', nextRpcRequestId: 0 }) ) // Restore parks on restoreConnection; the wallet emits via onStatusChange. mockRestoreConnection.mockReturnValue(new Promise(() => undefined)) mockOnStatusChange.mockImplementation(cb => { setTimeout(() => cb({ account: { address: 'UQBrestored' } }), 0) return vi.fn() }) mockDisconnect.mockResolvedValue(undefined) // Park the first storage read (the bridge-connection getItem) so // disconnect() runs in the window BEFORE awaitRestoredAccount registers // the abort hook — the pre-registration gap from Copilot's #151 review. // disconnect() must advance the generation to cancel here. let releaseRead: (value: string | null) => void = () => undefined let signalEntered: () => void = () => undefined const readEntered = new Promise(resolve => { signalEntered = resolve }) const getItemSpy = vi .spyOn(NamespacedStorage.prototype, 'getItem') .mockImplementationOnce( () => new Promise(resolve => { signalEntered() releaseRead = resolve }) ) const connectP = service.connect('tonkeeper').catch((e: Error) => e) await readEntered await service.disconnect() releaseRead( JSON.stringify({ type: 'injected', jsBridgeKey: 'tonkeeper', nextRpcRequestId: 0 }) ) const result = await connectP expect(result).toBeInstanceOf(Error) expect((result as Error).message).toContain('cancelled') getItemSpy.mockRestore() }) }) describe('sendTransaction', () => { it('throws when not connected', async () => { await expect( service.sendTransaction({ to: 'UQBxyz', amount: '1000000000', from: 'UQBabc' }) ).rejects.toThrow('No active TON connection') }) it('delegates to transactionService and returns result', async () => { await connectService(service) mockSendTransaction.mockResolvedValue('te6cckEBAQEA...') const result = await service.sendTransaction({ to: 'UQBxyz', amount: '1000000000', from: 'UQBabc' }) expect(mockSendTransaction).toHaveBeenCalled() expect(result).toBe('hash_of_te6cckEBAQEA...') }) it('throws expired and clears the session when the wallet never responds', async () => { await connectService(service) const storageKey = 'uwc-ton-injected:ton-connect-storage_bridge-connection' localStorage.setItem(storageKey, '{"type":"injected"}') // Session evicted mid-flight — the bridge request never resolves. mockSendTransaction.mockReturnValue(new Promise(() => undefined)) vi.useFakeTimers() const captured = service .sendTransaction({ to: 'UQBxyz', amount: '1000000000', from: 'UQBabc' }) .catch((e: unknown) => e) await vi.advanceTimersByTimeAsync(WALLET_RESPONSE_TIMEOUT_MS) const error = (await captured) as { name?: string; type?: string } vi.useRealTimers() expect(error.name).toBe('WalletConnectorError') expect(error.type).toBe('expired') expect(localStorage.getItem(storageKey)).toBeNull() }) }) describe('signMessage', () => { it('throws when not connected', async () => { await expect(service.signMessage('hello')).rejects.toThrow( 'No active TON connection' ) }) it('delegates to transactionService and returns result', async () => { await connectService(service) mockSignData.mockResolvedValue({ signature: 'sig123', timestamp: 1700000000, domain: 'example.com' }) const result = await service.signMessage('test message') expect(mockSignData).toHaveBeenCalled() expect(result).toEqual({ type: 'tvm', signature: 'sig123', timestamp: 1700000000, domain: 'example.com', payload: { type: 'text', text: 'test message' } }) }) }) describe('disconnect', () => { it('clears all connection state', async () => { mockOnStatusChange.mockImplementation(callback => { setTimeout(() => { callback({ account: { address: 'UQBtest' } }) }, 10) return vi.fn() }) mockConnect.mockResolvedValue(undefined) mockDisconnect.mockResolvedValue(undefined) await service.connect('tonkeeper') expect(service.getAccount()).toBe('UQBtest') await service.disconnect() expect(service.getAccount()).toBeNull() expect(service.getConnectedJsBridgeKey()).toBeNull() }) it('does not throw if sdk.disconnect fails', async () => { mockOnStatusChange.mockImplementation(callback => { setTimeout(() => { callback({ account: { address: 'UQBtest' } }) }, 10) return vi.fn() }) mockConnect.mockResolvedValue(undefined) await service.connect('tonkeeper') mockDisconnect.mockRejectedValue(new Error('already disconnected')) await expect(service.disconnect()).resolves.toBeUndefined() }) }) describe('buildAvailableAddresses', () => { it('filters to only tvm: network IDs', () => { const result = service.buildAvailableAddresses( ['tvm:-239', 'eip155:1', 'tvm:-3'], 'UQBtest' ) expect(result).toEqual([ { address: 'UQBtest', networkId: 'tvm:-239' }, { address: 'UQBtest', networkId: 'tvm:-3' } ]) }) it('returns empty array when no tvm networks', () => { const result = service.buildAvailableAddresses( ['eip155:1', 'solana:mainnet'], 'UQBtest' ) expect(result).toEqual([]) }) }) describe('isConnected / setConnectionState', () => { it('returns false when not connected', () => { expect(service.isConnected()).toBe(false) }) it('returns true after connecting', async () => { await connectService(service) expect(service.isConnected()).toBe(true) }) it('setConnectionState updates address', () => { service.setConnectionState('UQBmanual') expect(service.getAccount()).toBe('UQBmanual') expect(service.isConnected()).toBe(true) }) it('setConnectionState with null clears address', () => { service.setConnectionState('UQBmanual') service.setConnectionState(null) expect(service.getAccount()).toBeNull() expect(service.isConnected()).toBe(false) }) }) describe('checkExistingConnection', () => { it('returns null when no SDK instance', async () => { expect(await service.checkExistingConnection()).toBeNull() }) it('returns address when SDK has active account', async () => { await connectService(service) // Simulate SDK's internal state after successful connection getLastSdkInstance().account = { address: 'UQBconnected' } const address = await service.checkExistingConnection() expect(address).toBe('UQBconnected') }) it('returns null after disconnect', async () => { await connectService(service) mockDisconnect.mockResolvedValue(undefined) await service.disconnect() expect(await service.checkExistingConnection()).toBeNull() }) }) describe('getTonBridgeKeysFromExpectedWallets', () => { it('extracts jsBridgeKeys from wallet metadata', () => { const wallets = [ { extensionInjectedProvider: { namespaceMetaData: { tvm: { jsBridgeKey: 'tonkeeper' } } } }, { extensionInjectedProvider: { namespaceMetaData: { tvm: { jsBridgeKey: 'okxTonWallet' } } } }, { extensionInjectedProvider: { namespaceMetaData: { eip155: { eip155Name: 'metamask', injectedId: 'isMetaMask', supportsAddingNetworks: true, requiresUserApprovalOnNetworkSwitch: false } } } } ] // @ts-expect-error — partial WalletMetadata for test const keys = service.getTonBridgeKeysFromExpectedWallets(wallets) expect(keys).toEqual(['tonkeeper', 'okxTonWallet']) }) it('returns empty array with no wallets', () => { expect(service.getTonBridgeKeysFromExpectedWallets([])).toEqual([]) }) }) })