Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 0 additions & 23 deletions packages/sdk-multichain/src/domain/store/client.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>;

abstract getExtensionId(): Promise<string | null>;

abstract getChannelConfig(): Promise<ChannelConfig | null>;

abstract setExtensionId(extensionId: string): Promise<void>;

abstract setAnonId(anonId: string): Promise<void>;

abstract setChannelConfig(channelConfig: ChannelConfig): Promise<void>;

abstract removeExtensionId(): Promise<void>;

abstract removeAnonId(): Promise<void>;

abstract getDebug(): Promise<string | null>;
}
49 changes: 49 additions & 0 deletions packages/sdk-multichain/src/store/adapters/node.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
try {
return JSON.parse(contents);
} catch (e) {
return {};
}
}

async getItem(key: string): Promise<string | null> {
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<void> {
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<void> {
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));
}
}
16 changes: 16 additions & 0 deletions packages/sdk-multichain/src/store/adapters/rn.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
return AsyncStorage.getItem(key);
}

async setItem(key: string, value: string): Promise<void> {
return AsyncStorage.setItem(key, value);
}

async deleteItem(key: string): Promise<void> {
return AsyncStorage.removeItem(key);
}
}
21 changes: 21 additions & 0 deletions packages/sdk-multichain/src/store/adapters/web.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
return this.internal.getItem(key);
}

async setItem(key: string, value: string): Promise<void> {
this.internal.setItem(key, value);
}

async deleteItem(key: string): Promise<void> {
this.internal.removeItem(key);
}
}
193 changes: 193 additions & 0 deletions packages/sdk-multichain/src/store/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>(),
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<string, any>()
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))
}
);
});
38 changes: 38 additions & 0 deletions packages/sdk-multichain/src/store/index.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
return this.#adapter.getItem('anonId');
}

async getExtensionId(): Promise<string | null> {
return this.#adapter.getItem('extensionId');
}

async setAnonId(anonId: string): Promise<void> {
return this.#adapter.setItem('anonId', anonId);
}

async setExtensionId(extensionId: string): Promise<void> {
return this.#adapter.setItem('extensionId', extensionId);
}

async removeExtensionId(): Promise<void> {
return this.#adapter.deleteItem('extensionId');
}

async removeAnonId(): Promise<void> {
return this.#adapter.deleteItem('anonId');
}

async getDebug(): Promise<string | null> {
return this.#adapter.getItem('DEBUG');
}
}
Loading