diff --git a/packages/sdk-multichain/src/domain/store/client.ts b/packages/sdk-multichain/src/domain/store/client.ts index 6575ef7fa..8a83ef490 100644 --- a/packages/sdk-multichain/src/domain/store/client.ts +++ b/packages/sdk-multichain/src/domain/store/client.ts @@ -1,34 +1,11 @@ /* c8 ignore start */ -export interface ChannelConfig { - channelId: string; - validUntil: number; - otherKey?: string; - localKey?: string; - walletVersion?: string; - deeplinkProtocolAvailable?: boolean; - relayPersistence?: boolean; // Set if the session has full relay persistence (can exchange message without the other side connected) - /** - * lastActive: ms value of the last time connection was ready CLIENTS_READY event. - * */ - lastActive?: number; -} export abstract class StoreClient { abstract getAnonId(): Promise; - abstract getExtensionId(): Promise; - - abstract getChannelConfig(): Promise; - abstract setExtensionId(extensionId: string): Promise; - abstract setAnonId(anonId: string): Promise; - - abstract setChannelConfig(channelConfig: ChannelConfig): Promise; - abstract removeExtensionId(): Promise; - abstract removeAnonId(): Promise; - abstract getDebug(): Promise; } diff --git a/packages/sdk-multichain/src/store/adapters/node.ts b/packages/sdk-multichain/src/store/adapters/node.ts new file mode 100644 index 000000000..b76e5ce0a --- /dev/null +++ b/packages/sdk-multichain/src/store/adapters/node.ts @@ -0,0 +1,49 @@ +import { StoreAdapter } from "../../domain"; +import fs from 'fs' +import path from 'path'; + +const CONFIG_FILE = path.resolve(process.cwd(), '.metamask.json'); + +export class StoreAdapterNode extends StoreAdapter { + + + private safeParse(contents: string): Record { + try { + return JSON.parse(contents); + } catch (e) { + return {}; + } + } + + async getItem(key: string): Promise { + if (!fs.existsSync(CONFIG_FILE)) { + return null; + } + const contents = fs.readFileSync(CONFIG_FILE, 'utf8'); + const config = this.safeParse(contents); + if (config[key] !== undefined) { + return config[key]; + } + return null; + } + + async setItem(key: string, value: string): Promise { + if (!fs.existsSync(CONFIG_FILE)) { + fs.writeFileSync(CONFIG_FILE, '{}'); + } + const contents = fs.readFileSync(CONFIG_FILE, 'utf8'); + const config = this.safeParse(contents); + config[key] = value; + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + } + + async deleteItem(key: string): Promise { + if (!fs.existsSync(CONFIG_FILE)) { + return; + } + const contents = fs.readFileSync(CONFIG_FILE, 'utf8'); + const config = this.safeParse(contents); + delete config[key]; + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + } +} diff --git a/packages/sdk-multichain/src/store/adapters/rn.ts b/packages/sdk-multichain/src/store/adapters/rn.ts new file mode 100644 index 000000000..f8771769e --- /dev/null +++ b/packages/sdk-multichain/src/store/adapters/rn.ts @@ -0,0 +1,16 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { StoreAdapter } from "../../domain"; + +export class StoreAdapterRN extends StoreAdapter { + async getItem(key: string): Promise { + return AsyncStorage.getItem(key); + } + + async setItem(key: string, value: string): Promise { + return AsyncStorage.setItem(key, value); + } + + async deleteItem(key: string): Promise { + return AsyncStorage.removeItem(key); + } +} diff --git a/packages/sdk-multichain/src/store/adapters/web.ts b/packages/sdk-multichain/src/store/adapters/web.ts new file mode 100644 index 000000000..1380a6de1 --- /dev/null +++ b/packages/sdk-multichain/src/store/adapters/web.ts @@ -0,0 +1,21 @@ +import { StoreAdapter } from "../../domain"; + +export class StoreAdapterWeb extends StoreAdapter { + private get internal() { + if (typeof window === 'undefined' || !window.localStorage) { + throw new Error('localStorage is not available in this environment'); + } + return window.localStorage; + } + async getItem(key: string): Promise { + return this.internal.getItem(key); + } + + async setItem(key: string, value: string): Promise { + this.internal.setItem(key, value); + } + + async deleteItem(key: string): Promise { + this.internal.removeItem(key); + } +} diff --git a/packages/sdk-multichain/src/store/index.test.ts b/packages/sdk-multichain/src/store/index.test.ts new file mode 100644 index 000000000..0cc4dc739 --- /dev/null +++ b/packages/sdk-multichain/src/store/index.test.ts @@ -0,0 +1,193 @@ +import * as t from 'vitest' +import fs from 'fs' +import path from 'path'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + + + +import { Store } from './index'; +import { StoreAdapter } from '../domain'; +import { StoreAdapterWeb } from './adapters/web'; +import { StoreAdapterRN } from './adapters/rn'; +import { StoreAdapterNode } from './adapters/node'; + +/** + * Dummy mocked storage to keep track of data between tests + */ +const nativeStorageStub = { + data: new Map(), + getItem: t.vi.fn((key: string) => nativeStorageStub.data.get(key) || null), + setItem: t.vi.fn((key: string, value: string) => { + nativeStorageStub.data.set(key, value); + }), + removeItem: t.vi.fn((key: string) => { + nativeStorageStub.data.delete(key); + }), + clear: t.vi.fn(() => { + nativeStorageStub.data.clear(); + }), +} + +// Reusable test function that can be used with any adapter +function createStoreTests( + adapterName: string, + createAdapter: () => StoreAdapter, + setupMocks?: () => void, + cleanupMocks?: () => void +) { + let store: Store; + let adapter: StoreAdapter; + + t.beforeEach(async () => { + setupMocks?.(); + adapter = createAdapter(); + store = new Store(adapter); + }); + + t.afterEach(async () => { + nativeStorageStub.data.clear() + cleanupMocks?.(); + }); + + t.describe(`${adapterName} constructor`, () => { + t.it('should create a Store instance with the provided adapter', () => { + t.expect(store).toBeInstanceOf(Store); + }); + }); + + t.describe(`${adapterName} getAnonId`, () => { + t.it('should return the anonymous ID when it exists', async () => { + await adapter.setItem('anonId', 'test-anon-id'); + const result = await store.getAnonId(); + t.expect(result).toBe('test-anon-id'); + }); + + t.it('should return null when anonymous ID does not exist', async () => { + const result = await store.getAnonId(); + t.expect(result).toBeNull(); + }); + }); + + t.describe(`${adapterName} setAnonId`, () => { + t.it('should set the anonymous ID successfully', async () => { + await store.setAnonId('new-anon-id'); + const result = await adapter.getItem('anonId'); + t.expect(result).toBe('new-anon-id'); + }); + }); + + t.describe(`${adapterName} removeAnonId`, () => { + t.it('should remove the anonymous ID successfully', async () => { + await adapter.setItem('anonId', 'test-anon-id'); + const beforeRemove = await adapter.getItem('anonId'); + t.expect(beforeRemove).toBe('test-anon-id'); + + await store.removeAnonId(); + const afterRemove = await adapter.getItem('anonId'); + t.expect(afterRemove).toBeNull(); + }); + }); + + t.describe(`${adapterName} getExtensionId`, () => { + t.it('should return the extension ID when it exists', async () => { + await adapter.setItem('extensionId', 'test-extension-id'); + const result = await store.getExtensionId(); + t.expect(result).toBe('test-extension-id'); + }); + + t.it('should return null when extension ID does not exist', async () => { + const result = await store.getExtensionId(); + t.expect(result).toBeNull(); + }); + }); + + t.describe(`${adapterName} setExtensionId`, () => { + t.it('should set the extension ID successfully', async () => { + await store.setExtensionId('new-extension-id'); + const result = await adapter.getItem('extensionId'); + t.expect(result).toBe('new-extension-id'); + }); + }); + + t.describe(`${adapterName} removeExtensionId`, () => { + t.it('should remove the extension ID successfully', async () => { + await adapter.setItem('extensionId', 'test-extension-id'); + const beforeRemove = await adapter.getItem('extensionId'); + t.expect(beforeRemove).toBe('test-extension-id'); + + await store.removeExtensionId(); + const afterRemove = await adapter.getItem('extensionId'); + t.expect(afterRemove).toBeNull(); + }); + }); + + t.describe(`${adapterName} getDebug`, () => { + t.it('should return the debug value when it exists', async () => { + await adapter.setItem('DEBUG', 'metamask-sdk:*'); + const result = await store.getDebug(); + t.expect(result).toBe('metamask-sdk:*'); + }); + + t.it('should return null when debug value does not exist', async () => { + const result = await store.getDebug(); + t.expect(result).toBeNull(); + }); + }); +} + + +t.describe(`Store with NodeAdapter`, () => { + // Test with Node Adapter and mocked file system + createStoreTests( + 'NodeAdapter', + () => new StoreAdapterNode(), + async () => { + const memfs = new Map() + t.vi.spyOn(fs, 'existsSync').mockImplementation((path) => memfs.has(path.toString())) + t.vi.spyOn(fs, 'writeFileSync').mockImplementation((path, data) => memfs.set(path.toString(), data)) + t.vi.spyOn(fs, 'readFileSync').mockImplementation((path) => memfs.get(path.toString())) + } + ); + + t.it("Should gracefully manage deleteItem even if the config file does not exist", async () => { + const CONFIG_FILE = path.resolve(process.cwd(), '.metamask.json'); + t.vi.spyOn(fs, 'existsSync').mockImplementation(() => false) + const store = new Store(new StoreAdapterNode()) + await store.removeExtensionId() + t.expect(fs.existsSync).toHaveBeenCalledWith(CONFIG_FILE) + }) +}); + +t.describe(`Store with WebAdapter`, () => { + //Test browser storage with mocked local storage + createStoreTests( + 'WebAdapter', + () => new StoreAdapterWeb(), + () => { + t.vi.stubGlobal('window', { + localStorage: nativeStorageStub, + }); + } + ); + + t.it("Should throw an exception if we try using the store with a browser that doesn't support localStorage", async () => { + t.vi.stubGlobal('window', { + localStorage: null, + }); + const store = new Store(new StoreAdapterWeb()); + await t.expect(() => store.getAnonId()).rejects.toThrow(); + }); +}); + +t.describe(`Store with RNAdapter`, () => { + // Test RN storage with mocked AsyncStorage + createStoreTests( + 'RNAdapter', + () => new StoreAdapterRN(), + () => { + t.vi.spyOn(AsyncStorage, 'getItem').mockImplementation(async (key) => nativeStorageStub.getItem(key)) + t.vi.spyOn(AsyncStorage, 'setItem').mockImplementation(async (key, value) => nativeStorageStub.setItem(key, value)) + t.vi.spyOn(AsyncStorage, 'removeItem').mockImplementation(async (key) => nativeStorageStub.removeItem(key)) + } + ); +}); diff --git a/packages/sdk-multichain/src/store/index.ts b/packages/sdk-multichain/src/store/index.ts new file mode 100644 index 000000000..333ad6a9d --- /dev/null +++ b/packages/sdk-multichain/src/store/index.ts @@ -0,0 +1,38 @@ +import type {StoreClient, StoreAdapter } from "../domain"; + + +export class Store implements StoreClient { + readonly #adapter: StoreAdapter; + + constructor(adapter: StoreAdapter) { + this.#adapter = adapter; + } + + async getAnonId(): Promise { + return this.#adapter.getItem('anonId'); + } + + async getExtensionId(): Promise { + return this.#adapter.getItem('extensionId'); + } + + async setAnonId(anonId: string): Promise { + return this.#adapter.setItem('anonId', anonId); + } + + async setExtensionId(extensionId: string): Promise { + return this.#adapter.setItem('extensionId', extensionId); + } + + async removeExtensionId(): Promise { + return this.#adapter.deleteItem('extensionId'); + } + + async removeAnonId(): Promise { + return this.#adapter.deleteItem('anonId'); + } + + async getDebug(): Promise { + return this.#adapter.getItem('DEBUG'); + } +}