diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index abea7f3..6a45358 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "git+https://github.com/MetaMask/snap-institutional-wallet.git" }, "source": { - "shasum": "2kLOK4/mWAaSYQkQZ5ABdIUtlrTQXgnrzZWFudNjN9s=", + "shasum": "xC69NMxzQ9d+X0HrsOyJJM6Pna8zpxzeSdt3WVYySaw=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/index.ts b/packages/snap/src/index.ts index b2bf830..7f167eb 100644 --- a/packages/snap/src/index.ts +++ b/packages/snap/src/index.ts @@ -34,7 +34,6 @@ import type { SnapContext } from './lib/types/Context'; import { CustodianApiMap, CustodianType } from './lib/types/CustodianType'; import logger from './logger'; import { InternalMethod, originPermissions } from './permissions'; -// @audit - this file needs unittests /** * Verify if the caller can call the requested method. diff --git a/packages/snap/src/keyring.test.ts b/packages/snap/src/keyring.test.ts new file mode 100644 index 0000000..523c396 --- /dev/null +++ b/packages/snap/src/keyring.test.ts @@ -0,0 +1,330 @@ +import { MethodNotFoundError } from '@metamask/snaps-sdk'; + +import { CustodialKeyring } from './keyring'; +import { TOKEN_EXPIRED_EVENT } from './lib/custodian-types/constants'; +import { CustodianApiMap } from './lib/types/CustodianType'; +import type { ICustodianApi } from './lib/types/ICustodianApi'; + +// Mock dependencies +jest.mock('./lib/custodian-types/custodianMetadata', () => ({ + custodianMetadata: { + BitGo: { + enabled: false, + apiBaseUrl: 'https://mock-url.com', + apiVersion: 'BitGo', + custodianPublishesTransaction: true, + iconUrl: 'https://mock-url.com/icon.svg', + }, + ECA3: { + enabled: true, + apiBaseUrl: 'https://mock-url.com', + apiVersion: 'ECA3', + custodianPublishesTransaction: false, + iconUrl: 'https://mock-url.com/icon.svg', + }, + }, +})); + +jest.mock('./features/info-message/rendex'); +jest.mock('@metamask/keyring-api', () => ({ + ...jest.requireActual('@metamask/keyring-api'), + emitSnapKeyringEvent: jest.fn(), +})); + +jest.mock('./lib/types/CustodianType', () => ({ + CustodianType: { + ECA3: 'ECA3', + ECA1: 'ECA1', + BitGo: 'BitGo', + Cactus: 'Cactus', + }, + CustodianApiMap: { + ECA3: jest.fn(), + }, +})); + +describe('CustodialKeyring', () => { + let keyring: CustodialKeyring; + let mockStateManager: any; + let mockRequestManager: any; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Setup mock state manager + mockStateManager = { + listAccounts: jest.fn(), + getAccount: jest.fn(), + withTransaction: jest.fn(), + removeAccounts: jest.fn(), + getWalletByAddress: jest.fn(), + listWallets: jest.fn(), + updateWalletDetails: jest.fn(), + addWallet: jest.fn(), + }; + + // Setup mock request manager + mockRequestManager = { + upsertRequest: jest.fn(), + listRequests: jest.fn(), + }; + + // Create keyring instance + keyring = new CustodialKeyring(mockStateManager, mockRequestManager); + }); + + describe('listAccounts', () => { + it('should return accounts from state manager', async () => { + const mockAccounts = [{ id: '1', address: '0x123' }]; + mockStateManager.listAccounts.mockResolvedValue(mockAccounts); + + const result = await keyring.listAccounts(); + expect(result).toStrictEqual(mockAccounts); + expect(mockStateManager.listAccounts).toHaveBeenCalled(); + }); + }); + + describe('getAccount', () => { + it('should return account from state manager', async () => { + const mockAccount = { id: '1', address: '0x123' }; + mockStateManager.getAccount.mockResolvedValue(mockAccount); + + const result = await keyring.getAccount('1'); + expect(result).toStrictEqual(mockAccount); + expect(mockStateManager.getAccount).toHaveBeenCalledWith('1'); + }); + + it('should return undefined for non-existent account', async () => { + mockStateManager.getAccount.mockResolvedValue(null); + + const result = await keyring.getAccount('nonexistent'); + expect(result).toBeUndefined(); + }); + }); + + describe('filterAccountChains', () => { + it('should filter supported chains', async () => { + const mockAccount = { id: '1', address: '0x123' }; + const mockWallet = { + account: mockAccount, + details: { + custodianType: 'ECA3', + token: 'mock-token', + custodianApiUrl: 'https://api.example.com', + refreshTokenUrl: 'https://refresh.example.com', + }, + }; + + mockStateManager.getAccount.mockResolvedValue(mockAccount); + mockStateManager.getWalletByAddress.mockResolvedValue(mockWallet); + + // Mock the custodian API to return decimal chain IDs + const mockCustodianApi = { + getSupportedChains: jest.fn().mockResolvedValue(['eip155:1']), // CAIP-2 format + }; + + jest + .spyOn(keyring as any, 'getCustodianApiForAddress') + .mockImplementation(async () => Promise.resolve(mockCustodianApi)); + + const result = await keyring.filterAccountChains('1', ['0x1', '0x2']); + expect(result).toStrictEqual(['0x1']); + }); + + it('should throw error for non-existent account', async () => { + mockStateManager.getAccount.mockResolvedValue(null); + + await expect(keyring.filterAccountChains('1', [])).rejects.toThrow( + "Account '1' not found", + ); + }); + }); + + describe('updateAccount', () => { + it('should throw MethodNotFoundError', async () => { + await expect(keyring.updateAccount({} as any)).rejects.toThrow( + MethodNotFoundError, + ); + }); + }); + + describe('deleteAccount', () => { + it('should delete account and emit event', async () => { + mockStateManager.withTransaction.mockImplementation((callback: any) => + callback(), + ); + + await keyring.deleteAccount('1'); + + expect(mockStateManager.removeAccounts).toHaveBeenCalledWith(['1']); + expect(mockStateManager.withTransaction).toHaveBeenCalled(); + }); + }); + + describe('submitRequest', () => { + it('should handle personal sign request', async () => { + const mockAccount = { + id: '1', + address: '0x123', + options: { + custodian: { + displayName: 'Test Custodian', + deferPublication: false, + }, + }, + }; + const mockRequest = { + id: 'request-1', + scope: 'scope-1', + account: mockAccount.id, + request: { + method: 'personal_sign', + params: ['message', mockAccount.address], + }, + }; + + const mockWallet = { + account: mockAccount, + details: { + custodianType: 'ECA3', + token: 'mock-token', + custodianApiUrl: 'https://api.example.com', + refreshTokenUrl: 'https://refresh.example.com', + }, + }; + + mockStateManager.getAccount.mockResolvedValue(mockAccount); + mockStateManager.getWalletByAddress.mockResolvedValue(mockWallet); + + // Mock the custodian API + const mockCustodianApi = { + signPersonalMessage: jest.fn().mockResolvedValue({ id: 'msg-1' }), + getSignedMessageLink: jest.fn().mockResolvedValue({ + text: 'View Message', + id: 'msg-1', + url: 'https://example.com', + action: 'view', + }), + }; + jest + .spyOn(keyring as any, 'getCustodianApiForAddress') + .mockResolvedValue(mockCustodianApi); + + const result = await keyring.submitRequest(mockRequest); + + expect(result).toStrictEqual({ pending: true }); + expect(mockRequestManager.upsertRequest).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'message', + subType: 'personalSign', + }), + ); + }); + + // Add more tests for other request types... + }); + + describe('getCustodianApiForAddress', () => { + it('should handle token expiry events and update wallet details', async () => { + const mockAddress = '0x123'; + const mockWallets = [ + { + account: { id: '1', address: mockAddress }, + details: { + token: 'oldToken', + custodianApiUrl: 'https://api.example.com', + custodianType: 'ECA3', + refreshTokenUrl: 'https://refresh.example.com', + }, + }, + { + account: { id: '2', address: '0x456' }, + details: { + token: 'oldToken', + custodianApiUrl: 'https://api.example.com', + custodianType: 'ECA3', + refreshTokenUrl: 'https://refresh.example.com', + }, + }, + { + account: { id: '3', address: '0x789' }, + details: { + token: 'differentToken', + custodianApiUrl: 'https://different.api.com', + custodianType: 'ECA3', + refreshTokenUrl: 'https://refresh.example.com', + }, + }, + ]; + + const updatedDetails = new Map(); + + mockStateManager.getWalletByAddress.mockImplementation( + async (address: string) => { + return Promise.resolve( + mockWallets.find((wallet) => wallet.account.address === address), + ); + }, + ); + + mockStateManager.listWallets.mockImplementation(async () => { + return Promise.resolve(mockWallets); + }); + + mockStateManager.updateWalletDetails.mockImplementation( + async (id: string, details: any) => { + updatedDetails.set(id, { ...details }); + return Promise.resolve(); + }, + ); + + let tokenEventCallback: ((event: any) => Promise<void>) | undefined; + const mockCustodianApi: Partial<ICustodianApi> = { + on: jest.fn().mockImplementation((event, callback) => { + if (event === TOKEN_EXPIRED_EVENT) { + tokenEventCallback = callback; + } + }), + getSupportedChains: jest.fn(), + }; + + const ECA3Mock = + CustodianApiMap.ECA3 as unknown as jest.Mock<ICustodianApi>; + ECA3Mock.mockImplementation(() => mockCustodianApi as ICustodianApi); + + // Call the method under test + const api = await (keyring as any).getCustodianApiForAddress(mockAddress); + + // Simulate token expiry event and wait for all promises to resolve + if (tokenEventCallback) { + await tokenEventCallback({ + oldRefreshToken: 'oldToken', + newRefreshToken: 'newToken', + apiUrl: 'https://api.example.com', + }); + } + + // Wait for any pending promises + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Verify each wallet update + const expectedDetails = { + token: 'newToken', + custodianApiUrl: 'https://api.example.com', + custodianType: 'ECA3', + refreshTokenUrl: 'https://refresh.example.com', + }; + + expect(updatedDetails.get('1')).toStrictEqual(expectedDetails); + expect(updatedDetails.get('2')).toStrictEqual(expectedDetails); + expect(updatedDetails.has('3')).toBe(false); + + // Verify the event listener was set up + expect(api.on).toHaveBeenCalledWith( + TOKEN_EXPIRED_EVENT, + expect.any(Function), + ); + }); + }); +}); diff --git a/packages/snap/src/keyring.tsx b/packages/snap/src/keyring.tsx index 81b5e66..2500564 100644 --- a/packages/snap/src/keyring.tsx +++ b/packages/snap/src/keyring.tsx @@ -52,7 +52,7 @@ type RequestManagerFacade = { }; export class CustodialKeyring implements Keyring { - #custodianApi: Record<string, ICustodianApi>; // maps address to memoized custodian api + #custodianApi: Map<string, ICustodianApi>; #requestManagerFacade: RequestManagerFacade; @@ -63,7 +63,7 @@ export class CustodialKeyring implements Keyring { requestManagerFacade: RequestManagerFacade, ) { this.#stateManager = stateManager; - this.#custodianApi = {}; + this.#custodianApi = new Map<string, ICustodianApi>(); this.#requestManagerFacade = requestManagerFacade; } @@ -221,13 +221,13 @@ export class CustodialKeyring implements Keyring { } async getCustodianApiForAddress(address: string): Promise<ICustodianApi> { - if (!this.#custodianApi[address]) { + if (!this.#custodianApi.has(address)) { const wallet = await this.#stateManager.getWalletByAddress(address); if (!wallet) { throw new Error(`Wallet for account ${address} does not exist`); } const custodianApi = this.#getCustodianApi(wallet.details); - this.#custodianApi[address] = custodianApi; + this.#custodianApi.set(address, custodianApi); custodianApi.on( TOKEN_EXPIRED_EVENT, (payload: IRefreshTokenChangeEvent) => { @@ -235,7 +235,7 @@ export class CustodialKeyring implements Keyring { }, ); } - return this.#custodianApi[address] as ICustodianApi; + return this.#custodianApi.get(address) as ICustodianApi; } #getCustodianApi(details: OnBoardingRpcRequest): ICustodianApi { @@ -259,20 +259,21 @@ export class CustodialKeyring implements Keyring { wallet.details.token === payload.oldRefreshToken && wallet.details.custodianApiUrl === payload.apiUrl, ); - // Update the custodian api for each wallet - walletsToUpdate.forEach((wallet) => { - wallet.details.token = payload.newRefreshToken; - }); - - // Delete the custodian api from the cache for each address - walletsToUpdate.forEach((wallet) => { - delete this.#custodianApi[wallet.account.address]; - }); for (const wallet of walletsToUpdate) { + // Create new details object with updated token + const updatedDetails = { + ...wallet.details, + token: payload.newRefreshToken, + }; + + // Clear cache + this.#custodianApi.delete(wallet.account.address); + + // Update state with new details await this.#stateManager.updateWalletDetails( wallet.account.id, - wallet.details, + updatedDetails, ); } }