From d3010153444b0e8275e9c77d0c05e1b35e4c37b7 Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Tue, 3 Dec 2024 11:15:18 -0500 Subject: [PATCH] feat: active session management for stale / removed sessions (#356) There have been reports that sessions no longer exist despite a Signer still being available. This PR introduces a more active role in the library to automatically detect this kind of a mismatch. It will remove a Signer when its session is no longer valid. A session might become invalid either by expiring, by a user disconnecting from the wallet, or some sort of a desync from IndexedDB. Adds better typing for Log classes, and disables logs during test:watch to make it easier to debug tests. Signed-off-by: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> --- package-lock.json | 4 +- package.json | 2 +- src/lib/dapp/DAppSigner.ts | 29 +- src/lib/dapp/SessionNotFoundError.ts | 6 + src/lib/dapp/index.ts | 55 ++- src/lib/shared/logger.ts | 10 +- test/dapp/DAppSigner.test.ts | 272 ++++++++--- test/dapp/index.test.ts | 664 ++++++++++++++++++++++++--- 8 files changed, 899 insertions(+), 143 deletions(-) create mode 100644 src/lib/dapp/SessionNotFoundError.ts diff --git a/package-lock.json b/package-lock.json index acaa1119..5e9e1f3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hashgraph/hedera-wallet-connect", - "version": "1.4.1", + "version": "1.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hashgraph/hedera-wallet-connect", - "version": "1.4.1", + "version": "1.4.2", "license": "Apache-2.0", "devDependencies": { "@hashgraph/hedera-wallet-connect": "^1.3.4", diff --git a/package.json b/package.json index 3bc110ff..c77f19f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hashgraph/hedera-wallet-connect", - "version": "1.4.1", + "version": "1.4.2", "description": "A library to facilitate integrating Hedera with WalletConnect", "repository": { "type": "git", diff --git a/src/lib/dapp/DAppSigner.ts b/src/lib/dapp/DAppSigner.ts index 28bfb885..0330aa1d 100644 --- a/src/lib/dapp/DAppSigner.ts +++ b/src/lib/dapp/DAppSigner.ts @@ -60,7 +60,8 @@ import { Uint8ArrayToBase64String, Uint8ArrayToString, } from '../shared' -import { DefaultLogger, ILogger } from '../shared/logger' +import { DefaultLogger, ILogger, LogLevel } from '../shared/logger' +import { SessionNotFoundError } from './SessionNotFoundError' const clients: Record = {} @@ -73,7 +74,7 @@ export class DAppSigner implements Signer { public readonly topic: string, private readonly ledgerId: LedgerId = LedgerId.MAINNET, public readonly extensionId?: string, - logLevel: 'error' | 'warn' | 'info' | 'debug' = 'debug', + logLevel: LogLevel = 'debug', ) { this.logger = new DefaultLogger(logLevel) } @@ -82,7 +83,7 @@ export class DAppSigner implements Signer { * Sets the logging level for the DAppSigner * @param level - The logging level to set */ - public setLogLevel(level: 'error' | 'warn' | 'info' | 'debug'): void { + public setLogLevel(level: LogLevel): void { if (this.logger instanceof DefaultLogger) { this.logger.setLogLevel(level) } @@ -116,6 +117,25 @@ export class DAppSigner implements Signer { } request(request: { method: string; params: any }): Promise { + // Avoid a wallet call if the session is no longer valid + if (!this?.signClient?.session?.get(this.topic)) { + this.logger.error( + 'Session no longer exists, signer will be removed. Please reconnect to the wallet.', + ) + // Notify DAppConnector to remove this signer + this.signClient.emit({ + topic: this.topic, + event: { + name: 'session_delete', + data: { topic: this.topic }, + }, + chainId: ledgerIdToCAIPChainId(this.ledgerId), + }) + throw new SessionNotFoundError( + 'Session no longer exists. Please reconnect to the wallet.', + ) + } + if (this.extensionId) extensionOpen(this.extensionId) return this.signClient.request({ topic: this.topic, @@ -265,6 +285,7 @@ export class DAppSigner implements Signer { return { result: TransactionResponse.fromJSON(result) as OutputT } } catch (error) { this.logger.error('Error executing transaction request:', error) + return { error } } } @@ -356,10 +377,12 @@ export class DAppSigner implements Signer { query: queryToBase64String(query), }, }) + this.logger.debug('Query request completed successfully', result) return { result: this._parseQueryResponse(query, result.response) as OutputT } } catch (error) { + this.logger.error('Error executing query request:', error) return { error } } } diff --git a/src/lib/dapp/SessionNotFoundError.ts b/src/lib/dapp/SessionNotFoundError.ts new file mode 100644 index 00000000..64814ae7 --- /dev/null +++ b/src/lib/dapp/SessionNotFoundError.ts @@ -0,0 +1,6 @@ +export class SessionNotFoundError extends Error { + constructor(message: string) { + super(message) + this.name = 'SessionNotFoundError' + } +} diff --git a/src/lib/dapp/index.ts b/src/lib/dapp/index.ts index 7d3fdd62..69f777a3 100644 --- a/src/lib/dapp/index.ts +++ b/src/lib/dapp/index.ts @@ -24,7 +24,7 @@ import QRCodeModal from '@walletconnect/qrcode-modal' import { WalletConnectModal } from '@walletconnect/modal' import SignClient from '@walletconnect/sign-client' import { getSdkError } from '@walletconnect/utils' -import { DefaultLogger, ILogger } from '../shared/logger' +import { DefaultLogger, ILogger, LogLevel } from '../shared/logger' import { HederaJsonRpcMethod, accountAndLedgerFromSession, @@ -54,6 +54,7 @@ import { DAppSigner } from './DAppSigner' import { JsonRpcResult } from '@walletconnect/jsonrpc-types' export * from './DAppSigner' +export { SessionNotFoundError } from './SessionNotFoundError' type BaseLogger = 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'fatal' @@ -91,7 +92,7 @@ export class DAppConnector { methods?: string[], events?: string[], chains?: string[], - logLevel: 'error' | 'warn' | 'info' | 'debug' = 'debug', + logLevel: LogLevel = 'debug', ) { this.logger = new DefaultLogger(logLevel) this.dAppMetadata = metadata @@ -120,7 +121,7 @@ export class DAppConnector { * Sets the logging level for the DAppConnector * @param level - The logging level to set */ - public setLogLevel(level: 'error' | 'warn' | 'info' | 'debug'): void { + public setLogLevel(level: LogLevel): void { if (this.logger instanceof DefaultLogger) { this.logger.setLogLevel(level) } @@ -151,6 +152,11 @@ export class DAppConnector { this.walletConnectClient.on('session_event', this.handleSessionEvent.bind(this)) this.walletConnectClient.on('session_update', this.handleSessionUpdate.bind(this)) this.walletConnectClient.on('session_delete', this.handleSessionDelete.bind(this)) + // Listen for custom session_delete events from DAppSigner + this.walletConnectClient.core.events.on( + 'session_delete', + this.handleSessionDelete.bind(this), + ) this.walletConnectClient.core.pairing.events.on( 'pairing_delete', this.handlePairingDelete.bind(this), @@ -269,9 +275,10 @@ export class DAppConnector { } /** - * Validates the session by checking if the session exists. + * Validates the session by checking if the session exists and is valid. + * Also ensures the signer exists for the session. * @param topic - The topic of the session to validate. - * @returns {boolean} - True if the session exists, false otherwise. + * @returns {boolean} - True if the session exists and has a valid signer, false otherwise. */ private validateSession(topic: string): boolean { try { @@ -280,12 +287,24 @@ export class DAppConnector { } const session = this.walletConnectClient.session.get(topic) - + const hasSigner = this.signers.some((signer) => signer.topic === topic) if (!session) { + // If session doesn't exist but we have a signer for it, clean up + if (hasSigner) { + this.logger.warn(`Signer exists but no session found for topic: ${topic}`) + this.handleSessionDelete({ topic }) + } return false } + + if (!hasSigner) { + this.logger.warn(`Session exists but no signer found for topic: ${topic}`) + return false + } + return true - } catch { + } catch (e) { + this.logger.error('Error validating session:', e) return false } } @@ -687,13 +706,23 @@ export class DAppConnector { private handleSessionDelete(event: { topic: string }) { this.logger.info('Session deleted:', event) - this.signers = this.signers.filter((signer) => signer.topic !== event.topic) - try { - this.disconnect(event.topic) - } catch (e) { - this.logger.error('Error disconnecting session:', e) + let deletedSigner: boolean = false + this.signers = this.signers.filter((signer) => { + if (signer.topic !== event.topic) { + return true + } + deletedSigner = true + return false + }) + // prevent emitting disconnected event if signers is untouched. + if (deletedSigner) { + try { + this.disconnect(event.topic) + } catch (e) { + this.logger.error('Error disconnecting session:', e) + } + this.logger.info('Session deleted and signer removed') } - this.logger.info('Session deleted by wallet') } private handlePairingDelete(event: { topic: string }) { diff --git a/src/lib/shared/logger.ts b/src/lib/shared/logger.ts index 20207ce9..2d9bc761 100644 --- a/src/lib/shared/logger.ts +++ b/src/lib/shared/logger.ts @@ -5,18 +5,20 @@ export interface ILogger { debug(message: string, ...args: any[]): void } +export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'off' + export class DefaultLogger implements ILogger { - private logLevel: 'error' | 'warn' | 'info' | 'debug' = 'info' + private logLevel: LogLevel = 'info' - constructor(logLevel: 'error' | 'warn' | 'info' | 'debug' = 'info') { + constructor(logLevel: LogLevel = 'info') { this.logLevel = logLevel } - setLogLevel(level: 'error' | 'warn' | 'info' | 'debug'): void { + setLogLevel(level: LogLevel): void { this.logLevel = level } - getLogLevel(): 'error' | 'warn' | 'info' | 'debug' { + getLogLevel(): LogLevel { return this.logLevel } diff --git a/test/dapp/DAppSigner.test.ts b/test/dapp/DAppSigner.test.ts index f89b1e2b..85f39c7d 100644 --- a/test/dapp/DAppSigner.test.ts +++ b/test/dapp/DAppSigner.test.ts @@ -66,6 +66,8 @@ import { import { ISignClient, SessionTypes } from '@walletconnect/types' import Long from 'long' import { Buffer } from 'buffer' +import { SessionNotFoundError } from '../../src/lib/dapp/SessionNotFoundError' +import { connect } from 'http2' jest.mock('../../src/lib/shared/extensionController', () => ({ extensionOpen: jest.fn(), @@ -86,7 +88,15 @@ describe('DAppSigner', () => { const testExtensionId = 'test-extension-id' beforeEach(() => { - connector = new DAppConnector(dAppMetadata, LedgerId.TESTNET, projectId) + connector = new DAppConnector( + dAppMetadata, + LedgerId.TESTNET, + projectId, + undefined, + undefined, + undefined, + 'off', + ) // @ts-ignore connector.signers = connector.createSigners(fakeSession) signer = connector.signers[0] @@ -94,7 +104,30 @@ describe('DAppSigner', () => { mockSignClient = { request: jest.fn(), metadata: dAppMetadata, - } as any + connect: jest.fn(), + disconnect: jest.fn(), + session: { + get: jest.fn(() => fakeSession), + }, + emit: jest.fn(), + } as any as ISignClient + + // Mock the Hedera client + const mockClient = { + getNodeAccountIdsForExecute: jest.fn().mockReturnValue([new AccountId(3)]), + network: { + '0.0.3': new AccountId(3), + '0.0.4': new AccountId(4), + '0.0.5': new AccountId(5), + }, + execute: jest.fn(), + isAutoValidateChecksumsEnabled: jest.fn().mockReturnValue(false), + mirrorNetwork: ['testnet.mirrornode.hedera.com:443'], + isMainnet: false, + isTestnet: true, + } + + jest.spyOn(Client, 'forTestnet').mockReturnValue(mockClient as any) signer = new DAppSigner( testAccountId, @@ -102,10 +135,12 @@ describe('DAppSigner', () => { testTopic, LedgerId.TESTNET, testExtensionId, + 'off', ) }) afterEach(() => { + jest.restoreAllMocks() global.gc && global.gc() }) @@ -242,6 +277,109 @@ describe('DAppSigner', () => { }) }) + describe('_tryExecuteTransactionRequest', () => { + beforeEach(() => { + mockSignClient = { + request: jest.fn(), + metadata: dAppMetadata, + session: { + get: jest.fn(() => fakeSession), + }, + emit: jest.fn(), + } as any as ISignClient + + signer = new DAppSigner( + testAccountId, + mockSignClient, + testTopic, + LedgerId.TESTNET, + testExtensionId, + 'off', + ) + }) + + it('should handle transaction execution error', async () => { + const mockError = new Error('Transaction execution failed') + mockSignClient.request.mockRejectedValue(mockError) + + const mockTransaction = prepareTestTransaction(new TopicCreateTransaction(), { + freeze: true, + }) + const mockRequest = { + toBytes: jest.fn().mockReturnValue(mockTransaction.toBytes()), + } + + // @ts-ignore - accessing private method for testing + const result = await signer._tryExecuteTransactionRequest(mockRequest) + + expect(result.result).toBeUndefined() + expect(result.error).toBe(mockError) + }) + + it('should handle successful transaction execution', async () => { + const mockResponse = { + signedTransactions: ['mockSignedTransaction'], + receipt: { + status: 22, + accountId: null, + fileId: null, + contractId: null, + topicId: null, + tokenId: null, + scheduleId: null, + exchangeRate: { + hbars: 1, + cents: 1, + expirationTime: 1234567890, + }, + }, + transactionId: '0.0.123@1234567890.000000000', + transactionHash: '0x1234567890abcdef', + nodeId: '0.0.3', + hash: 'hash', + } + + // Mock session.get to return a session + mockSignClient.session.get.mockReturnValue({ topic: testTopic }) + mockSignClient.request.mockResolvedValue(mockResponse) + + const mockTransaction = prepareTestTransaction(new TopicCreateTransaction(), { + freeze: true, + }) + const mockRequest = { + toBytes: jest.fn().mockReturnValue(mockTransaction.toBytes()), + } + + // @ts-ignore - accessing private method for testing + const result = await signer._tryExecuteTransactionRequest(mockRequest) + + console.log('result is', result) + + expect(result.result).toBeDefined() + expect(result.error).toBeUndefined() + }) + + it('should handle session not found error', async () => { + ;(mockSignClient.session.get as jest.Mock).mockReturnValue(null) + + const mockTransaction = prepareTestTransaction(new TopicCreateTransaction(), { + freeze: true, + }) + const mockRequest = { + toBytes: jest.fn().mockReturnValue(mockTransaction.toBytes()), + } + + // @ts-ignore - accessing private method for testing + const result = await signer._tryExecuteTransactionRequest(mockRequest) + + expect(result.result).toBeUndefined() + expect(result.error).toBeInstanceOf(Error) + expect(result.error.message).toBe( + 'Session no longer exists. Please reconnect to the wallet.', + ) + }) + }) + describe('sign', () => { let signerRequestSpy: jest.SpyInstance @@ -498,7 +636,7 @@ describe('DAppSigner', () => { realmNum: Long.fromNumber(0), accountNum: Long.fromNumber(123), }, - contractAccountID: '', + contractAccountID: null, deleted: false, proxyAccountID: null, proxyReceived: Long.ZERO, @@ -666,7 +804,10 @@ describe('DAppSigner', () => { topic: testTopic, request: { method: HederaJsonRpcMethod.SignTransaction, - params: expect.any(Object), + params: expect.objectContaining({ + signerAccountId: 'hedera:testnet:' + signer.getAccountId().toString(), + transactionBody: expect.any(String), + }), }, chainId: expect.any(String), }) @@ -699,6 +840,56 @@ describe('DAppSigner', () => { }) }) + describe('session validation', () => { + let mockSignClient: jest.Mocked + let signer: DAppSigner + + beforeEach(() => { + // Create a fresh signer and mock client for each test + mockSignClient = { + request: jest.fn(), + metadata: dAppMetadata, + session: { + get: jest.fn(), + }, + emit: jest.fn(), + } as any as ISignClient + + signer = new DAppSigner( + testAccountId, + mockSignClient, + testTopic, + LedgerId.TESTNET, + testExtensionId, + 'off', + ) + }) + + it('should throw SessionNotFoundError when session does not exist', async () => { + // Mock session.get to return null to simulate deleted session + ;(mockSignClient.session.get as jest.Mock).mockReturnValue(null) + + try { + await signer.request({ method: 'test', params: {} }) + fail('Expected request to throw SessionNotFoundError') + } catch (error) { + expect(error).toBeInstanceOf(SessionNotFoundError) + } + }) + + it('should proceed with request when session exists', async () => { + // Mock session.get to return a valid session + ;(mockSignClient.session.get as jest.Mock).mockReturnValue(fakeSession) + + const mockResponse = { success: true } + mockSignClient.request.mockResolvedValue(mockResponse) + + const result = await signer.request({ method: 'test', params: {} }) + expect(result).toEqual(mockResponse) + expect(mockSignClient.emit).not.toHaveBeenCalled() + }) + }) + describe('account queries', () => { let signerRequestSpy: jest.SpyInstance @@ -833,6 +1024,22 @@ describe('DAppSigner', () => { }) describe('executeReceiptQueryFromRequest', () => { + beforeEach(() => { + const mockClient = { + getNodeAccountIdsForExecute: jest.fn().mockReturnValue([new AccountId(3)]), + network: { + '0.0.3': new AccountId(3), + '0.0.4': new AccountId(4), + '0.0.5': new AccountId(5), + }, + } + jest.spyOn(Client, 'forTestnet').mockReturnValue(mockClient as any) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + it('should execute free receipt query successfully', async () => { const mockReceipt = TransactionReceipt.fromBytes( proto.TransactionGetReceiptResponse.encode({ @@ -847,9 +1054,8 @@ describe('DAppSigner', () => { }).finish(), ) - jest - .spyOn(TransactionReceiptQuery.prototype, 'execute') - .mockResolvedValueOnce(mockReceipt) + const mockExecute = jest.fn().mockResolvedValue(mockReceipt) + jest.spyOn(TransactionReceiptQuery.prototype, 'execute').mockImplementation(mockExecute) const receiptQuery = new TransactionReceiptQuery().setTransactionId( TransactionId.generate(testAccountId), @@ -860,57 +1066,7 @@ describe('DAppSigner', () => { expect(result.result).toBeDefined() expect(result.error).toBeUndefined() - expect(TransactionReceiptQuery.prototype.execute).toHaveBeenCalled() - }) - - it('should handle successful receipt query with no error in result', async () => { - // Mock the execute method directly on TransactionReceiptQuery - jest.spyOn(TransactionReceiptQuery.prototype, 'execute').mockResolvedValueOnce( - TransactionReceipt.fromBytes( - proto.TransactionGetReceiptResponse.encode({ - receipt: { - status: proto.ResponseCodeEnum.SUCCESS, - accountID: { - shardNum: Long.fromNumber(0), - realmNum: Long.fromNumber(0), - accountNum: Long.fromNumber(123), - }, - }, - }).finish(), - ), - ) - - const receiptQuery = new TransactionReceiptQuery().setTransactionId( - TransactionId.generate(testAccountId), - ) - - // @ts-ignore - accessing private method for testing - const result = await signer.executeReceiptQueryFromRequest(receiptQuery) - - expect(result.error).toBeUndefined() - expect(result.result).toBeDefined() - expect(result.result).toBeInstanceOf(TransactionReceipt) - }, 15000) - - it('should return error when receipt query fails', async () => { - const mockError = new Error('Receipt query failed') - - // Mock the execute method to throw an error - jest.spyOn(TransactionReceiptQuery.prototype, 'execute').mockRejectedValueOnce(mockError) - - const receiptQuery = new TransactionReceiptQuery().setTransactionId( - TransactionId.generate(testAccountId), - ) - - // @ts-ignore - accessing private method for testing - const result = await signer.executeReceiptQueryFromRequest(receiptQuery) - - expect(result.result).toBeUndefined() - expect(result.error).toBe(mockError) - }) - - afterEach(() => { - jest.restoreAllMocks() + expect(mockExecute).toHaveBeenCalled() }) }) diff --git a/test/dapp/index.test.ts b/test/dapp/index.test.ts index bc35e3a7..c31e6d21 100644 --- a/test/dapp/index.test.ts +++ b/test/dapp/index.test.ts @@ -38,8 +38,8 @@ import { queryToBase64String, transactionToBase64String, DAppSigner, + ledgerIdToCAIPChainId, base64StringToUint8Array, - networkNamespaces, Uint8ArrayToBase64String, extractFirstSignature, } from '../../src' @@ -50,14 +50,15 @@ import { prepareTestTransaction, testUserAccountId, } from '../_helpers' -import Client, { SignClient } from '@walletconnect/sign-client' -import { SessionTypes } from '@walletconnect/types' +import { SignClient } from '@walletconnect/sign-client' +import { ISignClient, SessionTypes } from '@walletconnect/types' +import { networkNamespaces } from '../../src/lib/shared' import * as nacl from 'tweetnacl' import { proto } from '@hashgraph/proto' describe('DAppConnector', () => { let connector: DAppConnector - let mockSignClient: Client + let mockSignClient: SignClient const fakeSession = useJsonFixture('fakeSession') as SessionTypes.Struct const mockTopic = '1234567890abcdef' @@ -98,7 +99,7 @@ describe('DAppConnector', () => { connector = new DAppConnector(dAppMetadata, LedgerId.TESTNET, projectId, methods, events) expect(connector.dAppMetadata).toBe(dAppMetadata) - expect(connector.network).toBe(LedgerId.TESTNET) + expect(connector.network).toBe(LedgerId.TESTNET, 'off') expect(connector.projectId).toBe(projectId) expect(connector.supportedMethods).toEqual(methods) expect(connector.supportedEvents).toEqual(events) @@ -108,7 +109,7 @@ describe('DAppConnector', () => { connector = new DAppConnector(dAppMetadata, LedgerId.TESTNET, projectId) expect(connector.dAppMetadata).toBe(dAppMetadata) - expect(connector.network).toBe(LedgerId.TESTNET) + expect(connector.network).toBe(LedgerId.TESTNET, 'off') expect(connector.projectId).toBe(projectId) expect(connector.supportedMethods).toEqual(Object.values(HederaJsonRpcMethod)) expect(connector.supportedEvents).toEqual([]) @@ -119,7 +120,7 @@ describe('DAppConnector', () => { it('should init SignClient correctly', async () => { await connector.init({ logger: 'error' }) - expect(connector.walletConnectClient).toBeInstanceOf(Client) + expect(connector.walletConnectClient).toBeInstanceOf(SignClient) expect(connector.walletConnectClient?.metadata).toBe(dAppMetadata) expect(connector.walletConnectClient?.core.projectId).toBe(projectId) expect(connector.walletConnectClient?.core.relayUrl).toBe('wss://relay.walletconnect.com') @@ -193,7 +194,13 @@ describe('DAppConnector', () => { }) connector.signers = [ - new DAppSigner(testUserAccountId, mockSignClient, fakeSession.topic, LedgerId.TESTNET), + new DAppSigner( + testUserAccountId, + mockSignClient, + fakeSession.topic, + LedgerId.TESTNET, + 'off', + ), ] lastSignerRequestMock = jest.spyOn(connector.signers[0], 'request') @@ -573,29 +580,17 @@ describe('DAppConnector', () => { }) describe('event handlers', () => { - beforeEach(async () => { + beforeEach(() => { + connector = new DAppConnector( + dAppMetadata, + LedgerId.TESTNET, + projectId, + undefined, + undefined, + undefined, + 'off', + ) connector.walletConnectClient = mockSignClient - - // Mock session.get to return a valid session - const mockSession = { - ...fakeSession, - topic: mockTopic, - namespaces: { - hedera: { - accounts: [`hedera:testnet:${testUserAccountId.toString()}`], - methods: Object.values(HederaJsonRpcMethod), - events: [], - }, - }, - } - jest.spyOn(mockSignClient.session, 'get').mockReturnValue(mockSession) - - // Mock createSigners to return a valid signer - jest - .spyOn(connector as any, 'createSigners') - .mockReturnValue([ - new DAppSigner(testUserAccountId, mockSignClient, mockTopic, LedgerId.TESTNET), - ]) }) it('should handle session event', () => { @@ -607,9 +602,11 @@ describe('DAppConnector', () => { // Call handler directly connector['handleSessionEvent']({ + id: 1, topic: mockTopic, params: { event: { name: 'chainChanged', data: {} }, + chainId: ledgerIdToCAIPChainId(LedgerId.TESTNET).toString(), }, }) @@ -644,7 +641,7 @@ describe('DAppConnector', () => { // Add initial signer connector.signers = [ - new DAppSigner(testUserAccountId, mockSignClient, mockTopic, LedgerId.TESTNET), + new DAppSigner(testUserAccountId, mockSignClient, mockTopic, LedgerId.TESTNET, 'off'), ] // Call handler directly @@ -655,12 +652,23 @@ describe('DAppConnector', () => { }) it('should handle pairing delete', () => { + // Setup const disconnectSpy = jest.spyOn(connector, 'disconnect') - disconnectSpy.mockImplementation(async () => true) + disconnectSpy.mockImplementation(async () => { + connector.signers = [] + return true + }) // Add initial signer connector.signers = [ - new DAppSigner(testUserAccountId, mockSignClient, mockTopic, LedgerId.TESTNET), + new DAppSigner( + testUserAccountId, + mockSignClient, + mockTopic, + LedgerId.TESTNET, + undefined, + 'off', + ), ] // Call handler directly @@ -669,58 +677,421 @@ describe('DAppConnector', () => { expect(disconnectSpy).toHaveBeenCalledWith(mockTopic) expect(connector.signers.length).toBe(0) }) + + it('should handle empty signers array', () => { + connector.signers = [] + expect(connector.signers.length).toBe(0) + + // @ts-ignore + connector.handlePairingDelete({ topic: mockTopic }) + + expect(connector.signers.length).toBe(0) + }) + + it('should handle undefined topic gracefully', () => { + connector.signers = [ + new DAppSigner( + testUserAccountId, + mockSignClient, + mockTopic, + LedgerId.TESTNET, + undefined, + 'off', + ), + ] + expect(connector.signers.length).toBe(1) + + // @ts-ignore + connector.handlePairingDelete({ topic: undefined as any }) + + expect(connector.signers.length).toBe(1) + }) + + it('should handle null topic gracefully', () => { + connector.signers = [ + new DAppSigner( + testUserAccountId, + mockSignClient, + mockTopic, + LedgerId.TESTNET, + undefined, + 'off', + ), + ] + expect(connector.signers.length).toBe(1) + + // @ts-ignore + connector.handlePairingDelete({ topic: null as any }) + + expect(connector.signers.length).toBe(1) + }) + + it('should handle empty string topic gracefully', () => { + connector.signers = [ + new DAppSigner( + testUserAccountId, + mockSignClient, + mockTopic, + LedgerId.TESTNET, + undefined, + 'off', + ), + ] + expect(connector.signers.length).toBe(1) + + // @ts-ignore + connector.handlePairingDelete({ topic: '' }) + + expect(connector.signers.length).toBe(1) + }) }) describe('validateSession', () => { + beforeEach(() => { + const getMock = jest.fn() as jest.MockedFunction + mockSignClient = { + session: { + get: getMock, + }, + } as unknown as ISignClient + + connector = new DAppConnector( + dAppMetadata, + LedgerId.TESTNET, + projectId, + undefined, + undefined, + undefined, + 'off', + ) + connector.walletConnectClient = mockSignClient + }) + it('should return false when walletConnectClient is not initialized', () => { connector.walletConnectClient = undefined - // @ts-ignore - accessing private method for testing + // @ts-ignore expect(connector.validateSession(mockTopic)).toBe(false) }) - it('should return false when session does not exist', () => { - connector.walletConnectClient = mockSignClient - jest.spyOn(connector.walletConnectClient.session, 'get').mockImplementation(() => { - throw new Error('Session not found') + it('should return false when session.get throws error', () => { + mockSignClient.session.get.mockImplementation(() => { + throw new Error('Session error') }) - // @ts-ignore - accessing private method for testing + // @ts-ignore expect(connector.validateSession(mockTopic)).toBe(false) }) - it('should return true when session exists', () => { - connector.walletConnectClient = mockSignClient - // @ts-ignore - accessing private method for testing + it('should return false when session does not exist', () => { + mockSignClient.session.get.mockReturnValue(null) + // @ts-ignore + expect(connector.validateSession(mockTopic)).toBe(false) + }) + + it('should return false when session exists but topic does not match', () => { + mockSignClient.session.get.mockReturnValue({ + topic: 'different-topic', + } as SessionTypes.Struct) + // @ts-ignore + expect(connector.validateSession(mockTopic)).toBe(false) + }) + + it('should return true when session exists with matching topic', () => { + mockSignClient.session.get.mockReturnValue({ + topic: mockTopic, + } as SessionTypes.Struct) + connector.signers = [ + new DAppSigner( + testUserAccountId, + mockSignClient, + mockTopic, + LedgerId.TESTNET, + undefined, + 'off', + ), + ] + // @ts-ignore + expect(connector.validateSession(mockTopic)).toBe(true) + }) + + it('should return false when topic is undefined', () => { + // @ts-ignore + expect(connector.validateSession(undefined as any)).toBe(false) + }) + + it('should return false when topic is null', () => { + // @ts-ignore + expect(connector.validateSession(null as any)).toBe(false) + }) + + it('should return false when topic is empty string', () => { + // @ts-ignore + expect(connector.validateSession('')).toBe(false) + }) + + it('should return false when session exists but no signer', () => { + mockSignClient.session.get.mockReturnValue({ + topic: mockTopic, + } as SessionTypes.Struct) + connector.signers = [] + // @ts-ignore + expect(connector.validateSession(mockTopic)).toBe(false) + }) + + it('should return true when session and signer exist', () => { + mockSignClient.session.get.mockReturnValue({ + topic: mockTopic, + } as SessionTypes.Struct) + connector.signers = [ + new DAppSigner( + testUserAccountId, + mockSignClient, + mockTopic, + LedgerId.TESTNET, + undefined, + 'off', + ), + ] + // @ts-ignore expect(connector.validateSession(mockTopic)).toBe(true) }) + + it('should call handleSessionDelete when session does not exist but signer exists', () => { + // Mock session.get to return null (session doesn't exist) + mockSignClient.session.get.mockReturnValue(null) + + // Create a signer with the topic + const topic = 'non-existent-session-topic' + connector.signers = [ + new DAppSigner( + testUserAccountId, + mockSignClient, + topic, + LedgerId.TESTNET, + undefined, + 'off', + ), + ] + + // Spy on handleSessionDelete + const handleSessionDeleteSpy = jest.spyOn(connector as any, 'handleSessionDelete') + + // Call validateSession + // @ts-ignore + expect(connector.validateSession(topic)).toBe(false) + + // Verify handleSessionDelete was called with correct topic + expect(handleSessionDeleteSpy).toHaveBeenCalledWith({ topic }) + expect(handleSessionDeleteSpy).toHaveBeenCalledTimes(1) + + // Verify signer was removed + expect(connector.signers.length).toBe(0) + }) }) - describe('validateAndRefreshSigners', () => { - it('should remove invalid signers', () => { - const validTopic = 'valid-topic' - const invalidTopic = 'invalid-topic' + describe('handleSessionDelete', () => { + beforeEach(() => { + connector = new DAppConnector( + dAppMetadata, + LedgerId.TESTNET, + projectId, + undefined, + undefined, + undefined, + 'off', + ) + connector.walletConnectClient = mockSignClient + connector.signers = [ + new DAppSigner( + testUserAccountId, + mockSignClient, + mockTopic, + LedgerId.TESTNET, + undefined, + 'off', + ), + ] + }) + + it('should remove signer when session is deleted', () => { + expect(connector.signers.length).toBe(1) + + // @ts-ignore + connector.handleSessionDelete({ topic: mockTopic }) + + expect(connector.signers.length).toBe(0) + }) + + it('should ignore session deletion for different topic', () => { + expect(connector.signers.length).toBe(1) + + // @ts-ignore + connector.handleSessionDelete({ topic: 'different-topic' }) + + expect(connector.signers.length).toBe(1) + }) + + it('should handle session deletion when no signers exist', () => { + connector.signers = [] + expect(connector.signers.length).toBe(0) + + // @ts-ignore + connector.handleSessionDelete({ topic: mockTopic }) + + expect(connector.signers.length).toBe(0) + }) + + it('should handle undefined topic gracefully', () => { + expect(connector.signers.length).toBe(1) + + // @ts-ignore + connector.handleSessionDelete({ topic: undefined as any }) + expect(connector.signers.length).toBe(1) + }) + + it('should handle null topic gracefully', () => { + expect(connector.signers.length).toBe(1) + + // @ts-ignore + connector.handleSessionDelete({ topic: null as any }) + + expect(connector.signers.length).toBe(1) + }) + + it('should handle empty topic string gracefully', () => { + expect(connector.signers.length).toBe(1) + + // @ts-ignore + connector.handleSessionDelete({ topic: '' }) + + expect(connector.signers.length).toBe(1) + }) + }) + + describe('handlePairingDelete', () => { + beforeEach(() => { + connector = new DAppConnector( + dAppMetadata, + LedgerId.TESTNET, + projectId, + undefined, + undefined, + undefined, + 'off', + ) + connector.walletConnectClient = mockSignClient + }) + + it('should handle pairing deletion when topic matches', () => { connector.signers = [ - new DAppSigner(testUserAccountId, mockSignClient, validTopic, LedgerId.TESTNET), - new DAppSigner(testUserAccountId, mockSignClient, invalidTopic, LedgerId.TESTNET), + new DAppSigner( + testUserAccountId, + mockSignClient, + mockTopic, + LedgerId.TESTNET, + undefined, + 'off', + ), ] + expect(connector.signers.length).toBe(1) - // Mock validateSession to return true for validTopic and false for invalidTopic - // @ts-ignore - accessing private method for testing - jest - .spyOn(connector, 'validateSession') - .mockImplementation((topic) => topic === validTopic) + // @ts-ignore + connector.handlePairingDelete({ topic: mockTopic }) - // @ts-ignore - accessing private method for testing - connector.validateAndRefreshSigners() + expect(connector.signers.length).toBe(0) + }) + + it('should ignore pairing deletion for different topic', () => { + connector.signers = [ + new DAppSigner( + testUserAccountId, + mockSignClient, + mockTopic, + LedgerId.TESTNET, + undefined, + 'off', + ), + ] + expect(connector.signers.length).toBe(1) + + // @ts-ignore + connector.handlePairingDelete({ topic: 'different-topic' }) + + expect(connector.signers.length).toBe(1) + }) + + it('should handle empty signers array', () => { + connector.signers = [] + expect(connector.signers.length).toBe(0) + + // @ts-ignore + connector.handlePairingDelete({ topic: mockTopic }) + + expect(connector.signers.length).toBe(0) + }) + + it('should handle undefined topic gracefully', () => { + connector.signers = [ + new DAppSigner( + testUserAccountId, + mockSignClient, + mockTopic, + LedgerId.TESTNET, + undefined, + 'off', + ), + ] + expect(connector.signers.length).toBe(1) + + // @ts-ignore + connector.handlePairingDelete({ topic: undefined as any }) + + expect(connector.signers.length).toBe(1) + }) + + it('should handle null topic gracefully', () => { + connector.signers = [ + new DAppSigner( + testUserAccountId, + mockSignClient, + mockTopic, + LedgerId.TESTNET, + undefined, + 'off', + ), + ] + expect(connector.signers.length).toBe(1) + + // @ts-ignore + connector.handlePairingDelete({ topic: null as any }) + + expect(connector.signers.length).toBe(1) + }) + + it('should handle empty string topic gracefully', () => { + connector.signers = [ + new DAppSigner( + testUserAccountId, + mockSignClient, + mockTopic, + LedgerId.TESTNET, + undefined, + 'off', + ), + ] + expect(connector.signers.length).toBe(1) + + // @ts-ignore + connector.handlePairingDelete({ topic: '' }) expect(connector.signers.length).toBe(1) - expect(connector.signers[0].topic).toBe(validTopic) }) }) describe('connect and connectExtension', () => { it('should connect using URI and handle extension ID', async () => { const mockUri = 'wc:1234...' + const extensionId = 'test-extension' const mockSession = { topic: mockTopic, namespaces: { @@ -730,8 +1101,8 @@ describe('DAppConnector', () => { events: [], }, }, + sessionProperties: { extensionId }, } as unknown as SessionTypes.Struct - const extensionId = 'test-extension' // Initialize walletConnectClient connector.walletConnectClient = mockSignClient @@ -746,11 +1117,11 @@ describe('DAppConnector', () => { mockSignClient.session.update = updateSpy const launchCallback = jest.fn() - await connector.connect(launchCallback, undefined, extensionId) + await connector.connect(launchCallback, undefined, 'test-extension') expect(launchCallback).toHaveBeenCalledWith(mockUri) expect(updateSpy).toHaveBeenCalledWith(mockTopic, { - sessionProperties: { extensionId }, + sessionProperties: { extensionId: 'test-extension' }, }) }) @@ -829,7 +1200,6 @@ describe('DAppConnector', () => { mockSignClient, 'existing-topic', LedgerId.TESTNET, - 'ext1', ) const newSession = { @@ -841,8 +1211,7 @@ describe('DAppConnector', () => { events: [], }, }, - sessionProperties: { extensionId: 'ext1' }, - } as SessionTypes.Struct + } as unknown as SessionTypes.Struct connector.signers = [existingSigner] connector.walletConnectClient = mockSignClient @@ -1016,4 +1385,175 @@ describe('DAppConnector', () => { expect(connectURISpy).toHaveBeenCalledWith(pairingTopic) }) }) + + describe('validateAndRefreshSigners', () => { + beforeEach(() => { + mockSignClient = { + session: { + get: jest.fn(), + }, + } as unknown as ISignClient + + connector = new DAppConnector( + dAppMetadata, + LedgerId.TESTNET, + projectId, + undefined, + undefined, + undefined, + 'off', + ) + connector.walletConnectClient = mockSignClient + }) + + it('should remove signers with invalid sessions', () => { + // Create two signers with different topics + const validTopic = 'valid-topic' + const invalidTopic = 'invalid-topic' + + // Mock session.get to return valid session for one topic and null for the other + mockSignClient.session.get.mockImplementation((topic: string) => { + if (topic === validTopic) { + return { topic: validTopic } as SessionTypes.Struct + } + return null + }) + + // Set up signers + connector.signers = [ + new DAppSigner( + testUserAccountId, + mockSignClient, + validTopic, + LedgerId.TESTNET, + undefined, + 'off', + ), + new DAppSigner( + testUserAccountId, + mockSignClient, + invalidTopic, + LedgerId.TESTNET, + undefined, + 'off', + ), + ] + + // Call private method + // @ts-ignore - accessing private method for testing + connector.validateAndRefreshSigners() + + // Verify only valid signer remains + expect(connector.signers.length).toBe(1) + expect(connector.signers[0].topic).toBe(validTopic) + }) + + it('should keep all signers when all sessions are valid', () => { + // Create multiple signers with valid topics + const topic1 = 'topic-1' + const topic2 = 'topic-2' + + // Mock session.get to return valid sessions for all topics + mockSignClient.session.get.mockReturnValue({ topic: 'valid' } as SessionTypes.Struct) + + // Set up signers + const signers = [ + new DAppSigner( + testUserAccountId, + mockSignClient, + topic1, + LedgerId.TESTNET, + undefined, + 'off', + ), + new DAppSigner( + testUserAccountId, + mockSignClient, + topic2, + LedgerId.TESTNET, + undefined, + 'off', + ), + ] + connector.signers = signers + + // Call private method + // @ts-ignore - accessing private method for testing + connector.validateAndRefreshSigners() + + // Verify all signers remain + expect(connector.signers.length).toBe(2) + expect(connector.signers).toEqual(signers) + }) + + it('should remove all signers when all sessions are invalid', () => { + // Mock session.get to return null for all topics + mockSignClient.session.get.mockReturnValue(null) + + // Set up signers + connector.signers = [ + new DAppSigner( + testUserAccountId, + mockSignClient, + 'topic1', + LedgerId.TESTNET, + undefined, + 'off', + ), + new DAppSigner( + testUserAccountId, + mockSignClient, + 'topic2', + LedgerId.TESTNET, + undefined, + 'off', + ), + ] + + // Call private method + // @ts-ignore - accessing private method for testing + connector.validateAndRefreshSigners() + + // Verify all signers are removed + expect(connector.signers.length).toBe(0) + }) + + it('should handle empty signers array', () => { + // Set up empty signers array + connector.signers = [] + + // Call private method + // @ts-ignore - accessing private method for testing + connector.validateAndRefreshSigners() + + // Verify no errors and signers remain empty + expect(connector.signers.length).toBe(0) + }) + + it('should handle errors in session validation', () => { + // Mock session.get to throw an error + mockSignClient.session.get.mockImplementation(() => { + throw new Error('Session validation error') + }) + + // Set up signers + connector.signers = [ + new DAppSigner( + testUserAccountId, + mockSignClient, + 'topic1', + LedgerId.TESTNET, + undefined, + 'off', + ), + ] + + // Call private method + // @ts-ignore - accessing private method for testing + connector.validateAndRefreshSigners() + + // Verify signer is removed due to validation error + expect(connector.signers.length).toBe(0) + }) + }) })