diff --git a/README.md b/README.md index e799524719..fab58f3b0f 100644 --- a/README.md +++ b/README.md @@ -81,10 +81,10 @@ those directories. Directory | Description ------------------------|------------- -`/.github` | workflow for shift left automation -`/app/api` | api & middlewares for application, non-UI logic +`/.github` | GitHub Workflow for shift left automation +`/app/api` | API and middlewares logic for application, for non-UI logic only `/app/assets` | assets of the project that can be loaded at startup -`/app/components` | top level components for a shared design language +`/app/components` | top level components for a atomic shared design language `/app/contexts` | shared contexts for application, non-UI logic `/app/hooks` | shared hooks for application, for UI logic only `/app/screens` | screens hierarchy tree matching directory hierarchy tree diff --git a/__mocks__/expo-secure-store.ts b/__mocks__/expo-secure-store.ts new file mode 100644 index 0000000000..9dbec3b491 --- /dev/null +++ b/__mocks__/expo-secure-store.ts @@ -0,0 +1,5 @@ +export default { + getItemAsync: jest.fn(), + setItemAsync: jest.fn(), + deleteItemAsync: jest.fn() +} diff --git a/app/api/index.ts b/app/api/index.ts new file mode 100644 index 0000000000..5167b42c98 --- /dev/null +++ b/app/api/index.ts @@ -0,0 +1,3 @@ +export * from './wallet/persistence' +export * from './storage' +export * from './logging' diff --git a/app/api/storage.test.ts b/app/api/storage.test.ts deleted file mode 100644 index 009e1d6eaa..0000000000 --- a/app/api/storage.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { EnvironmentNetwork } from "../environment"; -import * as storage from "./storage"; - -const getItem = jest.spyOn(AsyncStorage, 'getItem') -const setItem = jest.spyOn(AsyncStorage, 'setItem') -const removeItem = jest.spyOn(AsyncStorage, 'removeItem') - -beforeEach(() => { - jest.clearAllMocks() -}) - -describe('network', () => { - it('should default to Local Playground', async () => { - expect(await storage.getNetwork()).toBe(EnvironmentNetwork.LocalPlayground) - expect(getItem).toBeCalled() - }); - - it('should call setItem', async () => { - await storage.setNetwork(EnvironmentNetwork.RemotePlayground) - expect(setItem).toBeCalled() - }); - - it('should get Local Playground', async () => { - getItem.mockResolvedValue(EnvironmentNetwork.LocalPlayground) - expect(await storage.getNetwork()).toBe(EnvironmentNetwork.LocalPlayground) - }); - - it('should get Local Playground', async () => { - getItem.mockResolvedValue(EnvironmentNetwork.RemotePlayground) - expect(await storage.getNetwork()).toBe(EnvironmentNetwork.RemotePlayground) - }); - - it('should errored as network is not part of environment', async () => { - await expect(storage.setNetwork(EnvironmentNetwork.MainNet)) - .rejects.toThrow('network is not part of environment') - }); -}) - -describe('item', () => { - beforeEach(() => { - getItem.mockResolvedValue(EnvironmentNetwork.RemotePlayground) - }) - - it('should getItem with environment and network prefixed key', async () => { - await storage.getItem('get') - expect(getItem).toBeCalledTimes(2) - expect(getItem).toBeCalledWith('Development.NETWORK') - expect(getItem).toBeCalledWith('Development.Remote Playground.get') - }) - - it('should setItem with environment and network prefixed key', async () => { - await storage.setItem('set', 'value') - expect(setItem).toBeCalledWith('Development.Remote Playground.set', 'value') - }) - - it('should removeItem with environment and network prefixed key', async () => { - await storage.removeItem('remove') - expect(removeItem).toBeCalledWith('Development.Remote Playground.remove') - }) -}) diff --git a/app/api/storage.ts b/app/api/storage.ts deleted file mode 100644 index 9a35c34a9b..0000000000 --- a/app/api/storage.ts +++ /dev/null @@ -1,59 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' -import { EnvironmentNetwork, getEnvironment } from '../environment' - -/** - * @return EnvironmentNetwork if invalid, will be set to `networks[0]` - */ -export async function getNetwork (): Promise { - const env = getEnvironment() - const network = await AsyncStorage.getItem(`${env.name}.NETWORK`) - - if ((env.networks as any[]).includes(network)) { - return network as EnvironmentNetwork - } - - await setNetwork(env.networks[0]) - return env.networks[0] -} - -/** - * @param network {EnvironmentNetwork} with set with 'environment' prefixed - */ -export async function setNetwork (network: EnvironmentNetwork): Promise { - const env = getEnvironment() - - if (!env.networks.includes(network)) { - throw new Error('network is not part of environment') - } - - await AsyncStorage.setItem(`${env.name}.NETWORK`, network) -} - -async function getKey (key: string): Promise { - const env = getEnvironment() - const network = await getNetwork() - return `${env.name}.${network}.${key}` -} - -/** - * @param key {string} of item with 'environment' and 'network' prefixed - * @return {string | null} - */ -export async function getItem (key: string): Promise { - return await AsyncStorage.getItem(await getKey(key)) -} - -/** - * @param key {string} of item with 'environment' and 'network' prefixed - * @param value {string} to set - */ -export async function setItem (key: string, value: string): Promise { - return await AsyncStorage.setItem(await getKey(key), value) -} - -/** - * @param key {string} of item with 'environment' and 'network' prefixed - */ -export async function removeItem (key: string): Promise { - await AsyncStorage.removeItem(await getKey(key)) -} diff --git a/app/api/storage/index.test.ts b/app/api/storage/index.test.ts new file mode 100644 index 0000000000..37e9f2f2de --- /dev/null +++ b/app/api/storage/index.test.ts @@ -0,0 +1,138 @@ +import ExpoSecureStore from "expo-secure-store"; +import { EnvironmentNetwork } from "../../environment"; +import { StorageAPI } from "./index"; + +// TODO(fuxingloh): 'jest-expo' only test native (provider.native.ts) by default, need to improve testing capability + +const getItem = jest.spyOn(ExpoSecureStore, 'getItemAsync') +const setItem = jest.spyOn(ExpoSecureStore, 'setItemAsync') +const removeItem = jest.spyOn(ExpoSecureStore, 'deleteItemAsync') + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('network', () => { + it('should default to Local Playground', async () => { + expect(await StorageAPI.getNetwork()).toBe(EnvironmentNetwork.LocalPlayground) + expect(getItem).toBeCalled() + }); + + it('should call setItem', async () => { + await StorageAPI.setNetwork(EnvironmentNetwork.RemotePlayground) + expect(setItem).toBeCalled() + }); + + it('should get Local Playground', async () => { + getItem.mockResolvedValue(EnvironmentNetwork.LocalPlayground) + expect(await StorageAPI.getNetwork()).toBe(EnvironmentNetwork.LocalPlayground) + }); + + it('should get Local Playground', async () => { + getItem.mockResolvedValue(EnvironmentNetwork.RemotePlayground) + expect(await StorageAPI.getNetwork()).toBe(EnvironmentNetwork.RemotePlayground) + }); + + it('should errored as network is not part of environment', async () => { + await expect(StorageAPI.setNetwork(EnvironmentNetwork.MainNet)) + .rejects.toThrow('network is not part of environment') + }); +}) + +describe('item', () => { + beforeEach(() => { + getItem.mockResolvedValue(EnvironmentNetwork.RemotePlayground) + }) + + it('should getItem with environment and network prefixed key', async () => { + await StorageAPI.getItem('get') + expect(getItem).toBeCalledTimes(2) + expect(getItem).toBeCalledWith('Development.NETWORK') + expect(getItem).toBeCalledWith('Development.Remote Playground.get') + }) + + it('should setItem with environment and network prefixed key', async () => { + await StorageAPI.setItem('set', 'value') + expect(setItem).toBeCalledWith('Development.Remote Playground.set', 'value') + }) + + it('should removeItem with environment and network prefixed key', async () => { + await StorageAPI.removeItem('remove') + expect(removeItem).toBeCalledWith('Development.Remote Playground.remove') + }) +}) + +describe('byte length validation', () => { + beforeEach(() => { + getItem.mockResolvedValue(EnvironmentNetwork.LocalPlayground) + }) + + it('should set if 1 byte length', async () => { + await StorageAPI.setItem('key', '1') + expect(setItem).toBeCalledWith('Development.Local Playground.key', '1') + }) + + it('should set if 100 byte length', async () => { + await StorageAPI.setItem('key', '0000000000100000000020000000003000000000400000000050000000006000000000700000000080000000009000000000') + expect(setItem).toBeCalledWith('Development.Local Playground.key', '0000000000100000000020000000003000000000400000000050000000006000000000700000000080000000009000000000') + }) + + function generateText (length: number, sequence: string) { + let text = '' + for (let i = 0; i < length; i++) { + text += sequence + } + return text + } + + it('should set if 2047 byte length', async () => { + const text = generateText(2047, '0') + + await StorageAPI.setItem('key', text) + expect(setItem).toBeCalledWith('Development.Local Playground.key', text) + }) + + it('should error if 2048 byte length', async () => { + const text = generateText(2048, '0') + const promise = StorageAPI.setItem('key', text) + await expect(promise).rejects.toThrow('value exceed 2048 bytes, unable to setItem') + }) + + it('should error if 2049 byte length', async () => { + const text = generateText(2049, '0') + + const promise = StorageAPI.setItem('key', text) + await expect(promise).rejects.toThrow('value exceed 2048 bytes, unable to setItem') + }) + + describe('utf-8 1-4 char', () => { + it('should set if 3 byte length utf-8', async () => { + const text = generateText(1, '好') + + await StorageAPI.setItem('key', text) + expect(setItem).toBeCalledWith('Development.Local Playground.key', text) + }) + + it('should set if 2046 byte length utf-8', async () => { + const text = generateText(682, '好') + + await StorageAPI.setItem('key', text) + expect(setItem).toBeCalledWith('Development.Local Playground.key', text) + }) + + + it('should error if 2049 byte length', async () => { + const text = generateText(683, '好') + + const promise = StorageAPI.setItem('key', text) + await expect(promise).rejects.toThrow('value exceed 2048 bytes, unable to setItem') + }) + + it('should error if 3072 byte length', async () => { + const text = generateText(1024, '好') + + const promise = StorageAPI.setItem('key', text) + await expect(promise).rejects.toThrow('value exceed 2048 bytes, unable to setItem') + }) + }) +}) diff --git a/app/api/storage/index.ts b/app/api/storage/index.ts new file mode 100644 index 0000000000..38e436ec14 --- /dev/null +++ b/app/api/storage/index.ts @@ -0,0 +1,71 @@ +import { EnvironmentNetwork, getEnvironment } from '../../environment' +import { StorageProvider } from './provider' + +/** + * @return EnvironmentNetwork if invalid, will be set to `networks[0]` + */ +async function getNetwork (): Promise { + const env = getEnvironment() + const network = await StorageProvider.getItem(`${env.name}.NETWORK`) + + if ((env.networks as any[]).includes(network)) { + return network as EnvironmentNetwork + } + + await setNetwork(env.networks[0]) + return env.networks[0] +} + +/** + * @param network {EnvironmentNetwork} with set with 'environment' prefixed + */ +async function setNetwork (network: EnvironmentNetwork): Promise { + const env = getEnvironment() + + if (!env.networks.includes(network)) { + throw new Error('network is not part of environment') + } + + await StorageProvider.setItem(`${env.name}.NETWORK`, network) +} + +async function getKey (key: string): Promise { + const env = getEnvironment() + const network = await getNetwork() + return `${env.name}.${network}.${key}` +} + +/** + * @param key {string} of item with 'environment' and 'network' prefixed + * @return {string | null} + */ +async function getItem (key: string): Promise { + return await StorageProvider.getItem(await getKey(key)) +} + +/** + * @param key {string} of item with 'environment' and 'network' prefixed + * @param value {string} to set + * @throws Error when byte length exceed 2048 bytes + */ +async function setItem (key: string, value: string): Promise { + if (Buffer.byteLength(value, 'utf-8') >= 2048) { + throw new Error('value exceed 2048 bytes, unable to setItem') + } + return await StorageProvider.setItem(await getKey(key), value) +} + +/** + * @param key {string} of item with 'environment' and 'network' prefixed + */ +async function removeItem (key: string): Promise { + await StorageProvider.removeItem(await getKey(key)) +} + +export const StorageAPI = { + getNetwork, + setNetwork, + getItem, + setItem, + removeItem +} diff --git a/app/api/storage/provider/index.ts b/app/api/storage/provider/index.ts new file mode 100644 index 0000000000..b53d26dc83 --- /dev/null +++ b/app/api/storage/provider/index.ts @@ -0,0 +1,15 @@ +import { Provider } from './provider' + +/** + * Provider storage interface for platform agnostic storage provider + */ +export interface IStorage { + getItem: (key: string) => Promise + setItem: (key: string, value: string) => Promise + removeItem: (key: string) => Promise +} + +/** + * Platform agnostic storage provider + */ +export const StorageProvider = Provider diff --git a/app/api/storage/provider/provider.native.ts b/app/api/storage/provider/provider.native.ts new file mode 100644 index 0000000000..7d3cf6b2d6 --- /dev/null +++ b/app/api/storage/provider/provider.native.ts @@ -0,0 +1,8 @@ +import ExpoSecureStore from 'expo-secure-store' +import { IStorage } from './index' + +export const Provider: IStorage = { + getItem: ExpoSecureStore.getItemAsync, + setItem: ExpoSecureStore.setItemAsync, + removeItem: ExpoSecureStore.deleteItemAsync +} diff --git a/app/api/storage/provider/provider.ts b/app/api/storage/provider/provider.ts new file mode 100644 index 0000000000..72de0214a0 --- /dev/null +++ b/app/api/storage/provider/provider.ts @@ -0,0 +1,11 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { IStorage } from './index' + +/** + * NOTE: We don't officially support web platform yet. + */ +export const Provider: IStorage = { + getItem: AsyncStorage.getItem, + setItem: AsyncStorage.setItem, + removeItem: AsyncStorage.removeItem +} diff --git a/app/api/wallet/persistence.test.ts b/app/api/wallet/persistence.test.ts index ebbf4b1336..bd8c3f87c1 100644 --- a/app/api/wallet/persistence.test.ts +++ b/app/api/wallet/persistence.test.ts @@ -1,54 +1,205 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; +import ExpoSecureStore from "expo-secure-store"; import { EnvironmentNetwork } from "../../environment"; import { WalletPersistence, WalletType } from "./persistence"; -const getItem = jest.spyOn(AsyncStorage, 'getItem') -const setItem = jest.spyOn(AsyncStorage, 'setItem') +// TODO(fuxingloh): 'jest-expo' only test native (provider.native.ts) by default, need to improve testing capability + +const getItem = jest.spyOn(ExpoSecureStore, 'getItemAsync') +const setItem = jest.spyOn(ExpoSecureStore, 'setItemAsync') +const removeItem = jest.spyOn(ExpoSecureStore, 'deleteItemAsync') beforeEach(() => { jest.clearAllMocks() }) -it('should getWallets() that is empty', async () => { - getItem - .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) - .mockResolvedValueOnce("[]") +describe('WalletPersistence.get()', () => { + it('should get empty', async () => { + getItem + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) + .mockResolvedValueOnce("0") - const items = await WalletPersistence.get() - expect(items.length).toStrictEqual(0) -}) + const items = await WalletPersistence.get() + expect(items.length).toStrictEqual(0) -it('should add() add()', async () => { - getItem - .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) - .mockResolvedValueOnce("[]") - .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) - .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) - .mockResolvedValueOnce("[]") - .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) + expect(getItem).toBeCalledWith('Development.NETWORK') + expect(getItem).toBeCalledWith('Development.Local Playground.WALLET.count') + }) - await WalletPersistence.add({ raw: "seed-1", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }) - await WalletPersistence.add({ raw: "seed-2", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }) + it('should get non empty', async () => { + getItem + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) + .mockResolvedValueOnce("1") + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) + .mockResolvedValueOnce(JSON.stringify({ raw: "raw-1", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" })) - expect(getItem).toBeCalledTimes(6) + const items = await WalletPersistence.get() + expect(items.length).toStrictEqual(1) + expect(items).toStrictEqual( + [{ raw: "raw-1", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }] + ) + }) }) -it('should remove()', async () => { - getItem - .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) - .mockResolvedValueOnce(JSON.stringify([ - { version: 'v1', type: WalletType.MNEMONIC_UNPROTECTED, raw: '1' }, - { version: 'v1', type: WalletType.MNEMONIC_UNPROTECTED, raw: '2' }, - ])) - .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) - - await WalletPersistence.remove(0) - - expect(setItem).toBeCalledTimes(1) - expect(setItem).toBeCalledWith( - "Development.Local Playground.WALLET", - JSON.stringify([ - { version: 'v1', type: WalletType.MNEMONIC_UNPROTECTED, raw: '2' }, +describe('WalletPersistence.set()', () => { + it('should set 0 wallet, clear 0 wallet', async () => { + getItem + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // get WALLET.count (network) + .mockResolvedValueOnce("0") + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // set WALLET.count (network) + + await WalletPersistence.set([]) + + expect(getItem).toBeCalledTimes(3) + expect(setItem).toBeCalledWith('Development.Local Playground.WALLET.count', "0") + }) + + it('should set 1 wallet, clear 0 wallet', async () => { + getItem + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // get WALLET.count (network) + .mockResolvedValueOnce("0") + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // set WALLET.0 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // set WALLET.count (network) + + await WalletPersistence.set([ + { raw: "raw-1", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }, ]) - ) + + expect(getItem).toBeCalledTimes(4) + expect(setItem).toBeCalledWith('Development.Local Playground.WALLET.0', + JSON.stringify({ raw: "raw-1", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }) + ) + expect(setItem).toBeCalledWith('Development.Local Playground.WALLET.count', "1") + }) + + it('should set 1 wallet, clear 1 wallet', async () => { + getItem + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // get WALLET.count (network) + .mockResolvedValueOnce("1") + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // remove WALLET.0 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // set WALLET.0 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // set WALLET.count (network) + + await WalletPersistence.set([ + { raw: "raw-1", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }, + ]) + + expect(getItem).toBeCalledTimes(5) + expect(removeItem).toBeCalledWith('Development.Local Playground.WALLET.0') + + expect(setItem).toBeCalledWith('Development.Local Playground.WALLET.0', + JSON.stringify({ raw: "raw-1", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }) + ) + expect(setItem).toBeCalledWith('Development.Local Playground.WALLET.count', "1") + }) + + it('should set 1 wallet, clear 3 wallet', async () => { + getItem + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // get WALLET.count (network) + .mockResolvedValueOnce("3") + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // remove WALLET.0 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // remove WALLET.2 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // remove WALLET.3 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // set WALLET.0 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // set WALLET.count (network) + + await WalletPersistence.set([ + { raw: "raw-1", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }, + ]) + + expect(getItem).toBeCalledTimes(7) + expect(removeItem).toBeCalledWith('Development.Local Playground.WALLET.0') + expect(removeItem).toBeCalledWith('Development.Local Playground.WALLET.1') + expect(removeItem).toBeCalledWith('Development.Local Playground.WALLET.2') + + expect(setItem).toBeCalledWith('Development.Local Playground.WALLET.0', + JSON.stringify({ raw: "raw-1", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }) + ) + expect(setItem).toBeCalledWith('Development.Local Playground.WALLET.count', "1") + }) + + it('should set 2 wallet, clear 0 wallet', async () => { + getItem + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // get WALLET.count (network) + .mockResolvedValueOnce("0") + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // set WALLET.0 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // set WALLET.1 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // set WALLET.count (network) + + await WalletPersistence.set([ + { raw: "raw-1", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }, + { raw: "raw-2", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }, + ]) + + expect(getItem).toBeCalledTimes(5) + + expect(setItem).toBeCalledWith('Development.Local Playground.WALLET.0', + JSON.stringify({ raw: "raw-1", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }) + ) + expect(setItem).toBeCalledWith('Development.Local Playground.WALLET.1', + JSON.stringify({ raw: "raw-2", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }) + ) + expect(setItem).toBeCalledWith('Development.Local Playground.WALLET.count', "2") + }) + + it('should set 2 wallet, clear 1 wallet', async () => { + getItem + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // get WALLET.count (network) + .mockResolvedValueOnce("1") + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // remove WALLET.0 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // set WALLET.0 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // set WALLET.1 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // set WALLET.count (network) + + await WalletPersistence.set([ + { raw: "raw-1", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }, + { raw: "raw-2", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }, + ]) + + expect(getItem).toBeCalledTimes(6) + + expect(removeItem).toBeCalledWith('Development.Local Playground.WALLET.0') + + expect(setItem).toBeCalledWith('Development.Local Playground.WALLET.0', + JSON.stringify({ raw: "raw-1", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }) + ) + expect(setItem).toBeCalledWith('Development.Local Playground.WALLET.1', + JSON.stringify({ raw: "raw-2", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }) + ) + expect(setItem).toBeCalledWith('Development.Local Playground.WALLET.count', "2") + }) + + it('should set 3 wallet, clear 3 wallet', async () => { + getItem + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // get WALLET.count (network) + .mockResolvedValueOnce("3") + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // remove WALLET.0 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // remove WALLET.2 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // remove WALLET.3 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // set WALLET.0 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // set WALLET.1 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // set WALLET.2 (network) + .mockResolvedValueOnce(EnvironmentNetwork.LocalPlayground) // set WALLET.count (network) + + await WalletPersistence.set([ + { raw: "raw-1", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }, + { raw: "raw-2", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }, + { raw: "raw-3", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }, + ]) + + expect(getItem).toBeCalledTimes(9) + expect(removeItem).toBeCalledWith('Development.Local Playground.WALLET.0') + expect(removeItem).toBeCalledWith('Development.Local Playground.WALLET.1') + expect(removeItem).toBeCalledWith('Development.Local Playground.WALLET.2') + + expect(setItem).toBeCalledWith('Development.Local Playground.WALLET.0', + JSON.stringify({ raw: "raw-1", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }) + ) + expect(setItem).toBeCalledWith('Development.Local Playground.WALLET.1', + JSON.stringify({ raw: "raw-2", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }) + ) + expect(setItem).toBeCalledWith('Development.Local Playground.WALLET.2', + JSON.stringify({ raw: "raw-3", type: WalletType.MNEMONIC_UNPROTECTED, version: "v1" }) + ) + expect(setItem).toBeCalledWith('Development.Local Playground.WALLET.count', "3") + }) }) diff --git a/app/api/wallet/persistence.ts b/app/api/wallet/persistence.ts index c86f8eca1a..e8e1eb2c61 100644 --- a/app/api/wallet/persistence.ts +++ b/app/api/wallet/persistence.ts @@ -1,4 +1,4 @@ -import { getItem, setItem } from '../storage' +import { StorageAPI } from '../storage' export enum WalletType { MNEMONIC_UNPROTECTED = 'MNEMONIC_UNPROTECTED' @@ -13,28 +13,41 @@ export interface WalletPersistenceData { } async function get (): Promise>> { - const json = await getItem('WALLET') - if (json !== null) { - return JSON.parse(json) - } + const count: string = await StorageAPI.getItem('WALLET.count') ?? '0' + + const list: Array> = [] + for (let i = 0; i < parseInt(count); i++) { + const data = await StorageAPI.getItem(`WALLET.${i}`) + if (data === null) { + throw new Error(`WALLET.count=${count} but ${i} doesn't exist`) + } - return [] + list[i] = JSON.parse(data) + } + return list } +/** + * @param wallets to set, override previous set wallet + */ async function set (wallets: Array>): Promise { - await setItem('WALLET', JSON.stringify(wallets)) -} + await clear() -async function add (data: WalletPersistenceData): Promise { - const wallets = await get() - wallets.push(data) - await set(wallets) + for (let i = 0; i < wallets.length; i++) { + await StorageAPI.setItem(`WALLET.${i}`, JSON.stringify(wallets[i])) + } + await StorageAPI.setItem('WALLET.count', `${wallets.length}`) } -async function remove (index: number): Promise { - const wallets = await get() - wallets.splice(index, 1) - await set(wallets) +/** + * Clear all persisted wallet + */ +async function clear (): Promise { + const count: string = await StorageAPI.getItem('WALLET.count') ?? '0' + + for (let i = 0; i < parseInt(count); i++) { + await StorageAPI.removeItem(`WALLET.${i}`) + } } /** @@ -42,7 +55,5 @@ async function remove (index: number): Promise { */ export const WalletPersistence = { set, - get, - add, - remove + get } diff --git a/app/components/OceanInterface/OceanInterface.test.tsx b/app/components/OceanInterface/OceanInterface.test.tsx index f955313824..fe84288e40 100644 --- a/app/components/OceanInterface/OceanInterface.test.tsx +++ b/app/components/OceanInterface/OceanInterface.test.tsx @@ -34,7 +34,7 @@ describe('oceanInterface', () => { const v2 = '020000000001010000000000000000000000000000000000000000000000000000000000000000ffffffff050393700500ffffffff038260498a040000001976a9143db7aeb218455b697e94f6ff00c548e72221231d88ac7e67ce1d0000000017a914dd7730517e0e4969b4e43677ff5bee682e53420a870000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf90120000000000000000000000000000000000000000000000000000000000000000000000000' const buffer = SmartBuffer.fromBuffer(Buffer.from(v2, 'hex')) const signed = new CTransactionSegWit(buffer) - + const initialState: Partial = { ocean: { height: 49, diff --git a/app/contexts/NetworkContext.tsx b/app/contexts/NetworkContext.tsx index bdac15aae7..59ecafc3f3 100644 --- a/app/contexts/NetworkContext.tsx +++ b/app/contexts/NetworkContext.tsx @@ -1,7 +1,6 @@ import { NetworkName } from '@defichain/jellyfish-network' import React, { createContext, useContext, useEffect, useState } from 'react' -import { Logging } from '../api/logging' -import * as storage from '../api/storage' +import { Logging, StorageAPI } from '../api' import { EnvironmentNetwork } from '../environment' interface Network { @@ -33,7 +32,7 @@ export function NetworkProvider (props: React.PropsWithChildren): JSX.Eleme const [network, setNetwork] = useState(undefined) useEffect(() => { - storage.getNetwork().then(async value => { + StorageAPI.getNetwork().then(async value => { setNetwork(value) }).catch(Logging.error) }, []) @@ -46,7 +45,7 @@ export function NetworkProvider (props: React.PropsWithChildren): JSX.Eleme network: network, networkName: networkMapper(network), async updateNetwork (value: EnvironmentNetwork): Promise { - await storage.setNetwork(value) + await StorageAPI.setNetwork(value) setNetwork(value) }, async reloadNetwork (): Promise { diff --git a/app/contexts/PlaygroundContext.tsx b/app/contexts/PlaygroundContext.tsx index 79de314fd1..d2e70e6092 100644 --- a/app/contexts/PlaygroundContext.tsx +++ b/app/contexts/PlaygroundContext.tsx @@ -1,7 +1,6 @@ import { PlaygroundApiClient, PlaygroundRpcClient } from '@defichain/playground-api-client' import React, { createContext, useContext, useEffect, useMemo, useState } from 'react' -import { Logging } from '../api/logging' -import { setNetwork } from '../api/storage' +import { Logging, StorageAPI } from '../api' import { EnvironmentNetwork, getEnvironment, isPlayground } from '../environment' import { useNetworkContext } from './NetworkContext' @@ -53,7 +52,7 @@ export function useConnectedPlayground (): boolean { async function findPlayground (): Promise { for (const network of environment.networks.filter(isPlayground)) { if (await isConnected(network)) { - await setNetwork(network) + await StorageAPI.setNetwork(network) break } } diff --git a/app/contexts/WalletManagementContext.tsx b/app/contexts/WalletManagementContext.tsx index d03c148c87..ea1a452c77 100644 --- a/app/contexts/WalletManagementContext.tsx +++ b/app/contexts/WalletManagementContext.tsx @@ -7,12 +7,21 @@ import { useWhaleApiClient } from './WhaleContext' interface WalletManagement { wallets: WhaleWallet[] + /** + * @param {WalletPersistenceData} data to set, only 1 wallet is supported for now + */ setWallet: (data: WalletPersistenceData) => Promise clearWallets: () => Promise } const WalletManagementContext = createContext(undefined as any) +/** + * WalletManagement Context wrapped within + * + * This context enable wallet management by allow access to all configured wallets. + * Setting, removing and getting individual wallet. + */ export function useWalletManagementContext (): WalletManagement { return useContext(WalletManagementContext) } diff --git a/package-lock.json b/package-lock.json index ea3c5cf4d3..bda40d70e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "expo-linking": "~2.3.1", "expo-localization": "~10.2.0", "expo-random": "~11.2.0", + "expo-secure-store": "^10.2.0", "expo-splash-screen": "~0.11.2", "expo-status-bar": "~1.0.4", "expo-updates": "~0.8.2", @@ -17083,6 +17084,11 @@ "base64-js": "^1.3.0" } }, + "node_modules/expo-secure-store": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-10.2.0.tgz", + "integrity": "sha512-yNahMY3qzEotAYdsE02ps4yGfDay2twasHfsI/7gJB9SrwXYFx5bJuCDk8uTo8jsm6psvDjO+9VMM2DSPHik2A==" + }, "node_modules/expo-splash-screen": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-0.11.2.tgz", @@ -54152,6 +54158,11 @@ "base64-js": "^1.3.0" } }, + "expo-secure-store": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-10.2.0.tgz", + "integrity": "sha512-yNahMY3qzEotAYdsE02ps4yGfDay2twasHfsI/7gJB9SrwXYFx5bJuCDk8uTo8jsm6psvDjO+9VMM2DSPHik2A==" + }, "expo-splash-screen": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-0.11.2.tgz", diff --git a/package.json b/package.json index 4931ccc1ef..6ff6a4b946 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "start:android": "expo start --android", "start:ios": "expo start --ios", "start:web": "expo start --web", - "playground:start": "docker-compose up", + "playground:start": "docker-compose rm -fsv && docker-compose up", "test:ci": "jest --ci --coverage", "cypress:open": "cypress open", "cypress:run": "cypress run --headless --browser chrome", @@ -49,6 +49,7 @@ "expo-linking": "~2.3.1", "expo-localization": "~10.2.0", "expo-random": "~11.2.0", + "expo-secure-store": "^10.2.0", "expo-splash-screen": "~0.11.2", "expo-status-bar": "~1.0.4", "expo-updates": "~0.8.2",