;
+
+interface RouteConfig {
+ params?: P;
+ search?: S;
+}
+
+export type RedirectionHandler = (...args: R extends never ? [] : [R]) => void;
+
+export const useRedirection = = never>(
+ path: string
+): [RedirectionHandler] => {
+ const history = useHistory();
+
+ const handleRedirection: RedirectionHandler = useCallback(
+ (config = {}) => {
+ let url = path;
+ if (config?.params) {
+ url = generatePath(url, config.params);
+ }
+ if (config?.search) {
+ const searchParams = new URLSearchParams(config.search);
+ url = `${url}?${searchParams.toString()}`;
+ }
+ history.push(url);
+ },
+ [path, history]
+ );
+
+ return [handleRedirection];
+};
diff --git a/apps/browser-extension-wallet/src/hooks/useStakingRewards.ts b/apps/browser-extension-wallet/src/hooks/useStakingRewards.ts
new file mode 100644
index 0000000000..3843cc6947
--- /dev/null
+++ b/apps/browser-extension-wallet/src/hooks/useStakingRewards.ts
@@ -0,0 +1,42 @@
+import { useWalletStore } from '@src/stores';
+import BigNumber from 'bignumber.js';
+import { useMemo } from 'react';
+import { combineLatest, map } from 'rxjs';
+import { useObservable } from './useObservable';
+
+interface UseStakingRewardsReturns {
+ totalRewards: BigInt | number;
+ lastReward: BigInt | number;
+}
+
+const LAST_STABLE_EPOCH = 2;
+
+export const useStakingRewards = (): UseStakingRewardsReturns => {
+ const { inMemoryWallet } = useWalletStore();
+ const rewardsSummary = useObservable(
+ useMemo(
+ () =>
+ combineLatest([inMemoryWallet.currentEpoch$, inMemoryWallet.delegation.rewardsHistory$]).pipe(
+ map(([{ epochNo }, { all }]) => {
+ // rewards do not match the ones in the explorer because we aren't taking into account chain rollbacks
+ const lastNonVolatileEpoch = epochNo.valueOf() - LAST_STABLE_EPOCH;
+ const confirmedRewardHistory = all.filter(({ epoch }) => epoch.valueOf() <= lastNonVolatileEpoch);
+
+ return {
+ totalRewards:
+ confirmedRewardHistory?.length > 0
+ ? // eslint-disable-next-line unicorn/no-null
+ BigNumber.sum.apply(null, confirmedRewardHistory.map(({ rewards }) => rewards.toString()) ?? [])
+ : 0,
+ lastReward: confirmedRewardHistory[confirmedRewardHistory.length - 1]?.rewards || 0
+ };
+ })
+ ),
+ [inMemoryWallet.currentEpoch$, inMemoryWallet.delegation.rewardsHistory$]
+ )
+ );
+ return {
+ totalRewards: rewardsSummary?.totalRewards || 0,
+ lastReward: rewardsSummary?.lastReward || 0
+ };
+};
diff --git a/apps/browser-extension-wallet/src/hooks/useStickyTopPosition.ts b/apps/browser-extension-wallet/src/hooks/useStickyTopPosition.ts
new file mode 100644
index 0000000000..2ccd424bf9
--- /dev/null
+++ b/apps/browser-extension-wallet/src/hooks/useStickyTopPosition.ts
@@ -0,0 +1,19 @@
+import { useState, useEffect } from 'react';
+
+export const useStickyTopPosition = (childHeight: { offsetTop: number }): number => {
+ const [top, setTop] = useState(0);
+ useEffect(() => {
+ const mainContainer = document.querySelector('#main');
+ const sidePanel = document.querySelector('#side-panel');
+ const setSidePanelPositionOnScroll = () => {
+ const offsetTop = childHeight?.offsetTop || mainContainer?.getBoundingClientRect()?.top || 0;
+ const childHeightDifference = offsetTop - sidePanel?.getBoundingClientRect()?.top || 0;
+ if (!Number.isNaN(childHeightDifference) && childHeightDifference > 0) setTop(childHeightDifference);
+ };
+
+ mainContainer.addEventListener('scroll', setSidePanelPositionOnScroll);
+ return () => mainContainer.removeEventListener('scroll', setSidePanelPositionOnScroll);
+ }, [childHeight?.offsetTop]);
+
+ return top;
+};
diff --git a/apps/browser-extension-wallet/src/hooks/useSyncingTheFirstTime.ts b/apps/browser-extension-wallet/src/hooks/useSyncingTheFirstTime.ts
new file mode 100644
index 0000000000..f95f815630
--- /dev/null
+++ b/apps/browser-extension-wallet/src/hooks/useSyncingTheFirstTime.ts
@@ -0,0 +1,24 @@
+import { useMemo } from 'react';
+import { useWalletStore } from '@src/stores';
+import { concat, of } from 'rxjs';
+import { map, take, filter } from 'rxjs/operators';
+import { useObservable } from './useObservable';
+
+export const useSyncingTheFirstTime = (): boolean => {
+ const { inMemoryWallet } = useWalletStore();
+
+ const isSyncingForTheFirstTime$ = useMemo(
+ () =>
+ concat(
+ of(true),
+ inMemoryWallet.syncStatus.isSettled$.pipe(
+ filter((s: boolean) => s),
+ map(() => false),
+ take(1)
+ )
+ ),
+ [inMemoryWallet.syncStatus.isSettled$]
+ );
+
+ return useObservable(isSyncingForTheFirstTime$);
+};
diff --git a/apps/browser-extension-wallet/src/hooks/useTimeSpentOnPage.ts b/apps/browser-extension-wallet/src/hooks/useTimeSpentOnPage.ts
new file mode 100644
index 0000000000..15420640ae
--- /dev/null
+++ b/apps/browser-extension-wallet/src/hooks/useTimeSpentOnPage.ts
@@ -0,0 +1,16 @@
+import { useCallback, useState } from 'react';
+import dayjs from 'dayjs';
+
+export interface UseTimeSpentOnPage {
+ calculateTimeSpentOnPage: () => number;
+ updateEnteredAtTime: (date?: dayjs.ConfigType) => void;
+}
+
+export const useTimeSpentOnPage = (initialDate?: dayjs.ConfigType): UseTimeSpentOnPage => {
+ const [enteredAtTime, setEnteredAtTime] = useState(dayjs(initialDate));
+
+ return {
+ calculateTimeSpentOnPage: useCallback(() => dayjs().diff(enteredAtTime, 'seconds'), [enteredAtTime]),
+ updateEnteredAtTime: useCallback((date?: dayjs.ConfigType) => setEnteredAtTime(dayjs(date)), [setEnteredAtTime])
+ };
+};
diff --git a/apps/browser-extension-wallet/src/hooks/useWalletInfoSubscriber.ts b/apps/browser-extension-wallet/src/hooks/useWalletInfoSubscriber.ts
new file mode 100644
index 0000000000..1a72ec9fcb
--- /dev/null
+++ b/apps/browser-extension-wallet/src/hooks/useWalletInfoSubscriber.ts
@@ -0,0 +1,17 @@
+import { useWalletStore } from '@src/stores';
+import { getWalletFromStorage } from '@src/utils/get-wallet-from-storage';
+import { useEffect } from 'react';
+
+export const useWalletInfoSubscriber = (): void => {
+ const { inMemoryWallet, setWalletInfo } = useWalletStore();
+
+ useEffect(() => {
+ inMemoryWallet?.addresses$.subscribe(([addresses]) => {
+ setWalletInfo({
+ name: getWalletFromStorage()?.name ?? 'Lace',
+ address: addresses.address,
+ rewardAccount: addresses.rewardAccount
+ });
+ });
+ }, [inMemoryWallet?.addresses$, setWalletInfo]);
+};
diff --git a/apps/browser-extension-wallet/src/hooks/useWalletManager.ts b/apps/browser-extension-wallet/src/hooks/useWalletManager.ts
new file mode 100644
index 0000000000..c96a47186a
--- /dev/null
+++ b/apps/browser-extension-wallet/src/hooks/useWalletManager.ts
@@ -0,0 +1,381 @@
+import { useCallback } from 'react';
+import dayjs from 'dayjs';
+import { Wallet } from '@lace/cardano';
+import { useWalletStore } from '@stores';
+import { useCardanoWalletManagerContext } from '@providers/CardanoWalletManager';
+import { useAppSettingsContext } from '@providers/AppSettings';
+import { useBackgroundServiceAPIContext } from '@providers/BackgroundServiceAPI';
+import { AddressBookSchema, NftFoldersSchema, addressBookSchema, nftFoldersSchema, useDbState } from '@src/lib/storage';
+import { deleteFromLocalStorage, saveValueInLocalStorage } from '@src/utils/local-storage';
+import { config } from '@src/config';
+import { getWalletFromStorage } from '@src/utils/get-wallet-from-storage';
+
+const { AVAILABLE_CHAINS, CHAIN } = config();
+
+export interface CreateWallet {
+ name: string;
+ mnemonic: string[];
+ password: string;
+ chainId: Wallet.Cardano.ChainId;
+}
+
+export interface CreateWalletData {
+ wallet: Wallet.CardanoWallet;
+ encryptedKeyAgents: Uint8Array;
+ name: string;
+}
+
+export interface SetWallet {
+ walletInstance: CreateWalletData;
+ chainName?: Wallet.ChainName;
+ mnemonicVerificationFrequency?: string;
+}
+
+export interface CreateHardwareWallet {
+ accountIndex?: number;
+ name: string;
+ deviceConnection: Wallet.DeviceConnection;
+ chainId: Wallet.Cardano.ChainId;
+ connectedDevice: Wallet.HardwareWallets;
+}
+export interface DeleteWallet {
+ isForgotPasswordFlow?: boolean;
+}
+
+export interface UseWalletManager {
+ lockWallet: () => void;
+ unlockWallet: (password: string) => Promise;
+ loadWallet: (callback?: (result: boolean) => void) => Promise;
+ createWallet: (args: CreateWallet) => Promise;
+ setWallet: (args: SetWallet) => Promise;
+ getPassword: () => Promise;
+ createHardwareWallet: (args: CreateHardwareWallet) => Promise;
+ connectHardwareWallet: (model: Wallet.HardwareWallets) => Promise;
+ saveHardwareWallet: (wallet: Wallet.CardanoWalletByChain, chainName?: Wallet.ChainName) => Promise;
+ deleteWallet: (isForgotPasswordFlow?: boolean) => Promise;
+ executeWithPassword: (password: string, promiseFn: () => Promise, cleanPassword?: boolean) => Promise;
+ switchNetwork: (chainName: Wallet.ChainName) => Promise;
+}
+
+/** Connects a hardware wallet device */
+export const connectHardwareWallet = async (model: Wallet.HardwareWallets): Promise =>
+ await Wallet.connectDevice(model);
+
+export const useWalletManager = (): UseWalletManager => {
+ const cardanoWalletManager = useCardanoWalletManagerContext();
+ const {
+ walletLock,
+ setWalletLock,
+ keyAgentData,
+ setKeyAgentData,
+ setCardanoWallet,
+ resetWalletLock,
+ setCurrentChain,
+ setCardanoCoin,
+ environmentName,
+ walletManagerUi
+ } = useWalletStore();
+ const [settings, updateAppSettings] = useAppSettingsContext();
+ const {
+ utils: { clearTable: clearAddressBook }
+ } = useDbState([], addressBookSchema);
+ const {
+ utils: { clearTable: clearNftsFolders }
+ } = useDbState([], nftFoldersSchema);
+ const backgroundService = useBackgroundServiceAPIContext();
+
+ const storeMnemonicInBackgroundScript = useCallback(
+ async (mnemonic: string[], password: string) => {
+ const walletEncrypted = await Wallet.KeyManagement.emip3encrypt(
+ Buffer.from(Wallet.KeyManagement.util.joinMnemonicWords(mnemonic)),
+ Buffer.from(password)
+ );
+ await backgroundService.setBackgroundStorage({ mnemonic: JSON.stringify(walletEncrypted) });
+ },
+ [backgroundService]
+ );
+
+ /**
+ * Called by the wallet when needed to decrypt private key.
+ *
+ * Input password must be set before a function that needs it is executed (e.g. finalizeTx()),
+ * and should be cleared afterwards
+ */
+ const getPassword: () => Promise = useCallback(
+ async () => backgroundService.getWalletPassword(),
+ [backgroundService]
+ );
+
+ /**
+ * Sets the wallet password and clears it right after running the promise unless `cleanPassword` is `true`
+ */
+ const executeWithPassword = useCallback(
+ async (password: string, promiseFn: () => Promise, cleanPassword = true): Promise => {
+ try {
+ backgroundService.setWalletPassword(Buffer.from(password));
+ return await promiseFn();
+ } finally {
+ // Delete the password so we don't keep it in state. `cleanPassword` flag is needed for cip30 use
+ if (cleanPassword) backgroundService.setWalletPassword();
+ }
+ },
+ [backgroundService]
+ );
+
+ /**
+ * Deletes wallet info in storage, which should be stored encrypted with the wallet password as lock
+ */
+ const lockWallet = useCallback(async (): Promise => {
+ // TODO: if !walletLock then browser storage is corrupted and won't be able to unlock later.
+ // what should we do in this case? should we display an error screen to the user with a CTA to delete all data?
+ if (!walletLock) return;
+ // Deletes key agent data from storage and clears states
+ await backgroundService.clearBackgroundStorage(['keyAgentsByChain']);
+ deleteFromLocalStorage('keyAgentData');
+
+ setKeyAgentData();
+ setCardanoWallet();
+ }, [backgroundService, setKeyAgentData, setCardanoWallet, walletLock]);
+
+ /**
+ * Recovers wallet info from encrypted lock using the wallet password
+ */
+ const unlockWallet = useCallback(
+ async (password: string): Promise => {
+ // TODO: if !walletLock then browser storage is corrupted and won't be able to unlock later.
+ // what should we do in this case? should we display an error screen to the user with a CTA to delete all data?
+ if (!walletLock) return;
+ const walletDecrypted = await Wallet.KeyManagement.emip3decrypt(walletLock, Buffer.from(password));
+
+ // eslint-disable-next-line consistent-return
+ return JSON.parse(walletDecrypted.toString());
+ },
+ [walletLock]
+ );
+
+ /**
+ * Loads wallet from key agent serialized data in storage
+ */
+ const loadWallet = useCallback(
+ async (callback?: (result: boolean) => void) => {
+ // Wallet info for current network
+ if (!keyAgentData) return;
+ const walletName = getWalletFromStorage()?.name;
+ if (!walletName) return;
+
+ const wallet = await cardanoWalletManager.restoreWallet(
+ walletManagerUi,
+ walletName,
+ keyAgentData,
+ getPassword,
+ environmentName,
+ undefined,
+ callback
+ );
+ setCardanoWallet(wallet);
+ },
+ [keyAgentData, cardanoWalletManager, walletManagerUi, getPassword, environmentName, setCardanoWallet]
+ );
+
+ /**
+ * Creates or restores a new wallet with the cardano-js-sdk
+ * and saves it in browser storage with the data to lock/unlock it
+ */
+ const createWallet = useCallback(
+ async ({ mnemonic, name, password, chainId }: CreateWallet): Promise => {
+ const { keyAgentsByChain, ...wallet } = await executeWithPassword(password, () =>
+ cardanoWalletManager.createCardanoWallet(walletManagerUi, name, mnemonic, getPassword, chainId)
+ );
+
+ // Encrypt key agents with password for lock/unlock feature
+ const encryptedKeyAgents = await Wallet.KeyManagement.emip3encrypt(
+ Buffer.from(JSON.stringify(keyAgentsByChain)),
+ Buffer.from(password)
+ );
+
+ // Save in storage
+ await storeMnemonicInBackgroundScript(mnemonic, password);
+ await backgroundService.setBackgroundStorage({ keyAgentsByChain });
+ saveValueInLocalStorage({ key: 'lock', value: encryptedKeyAgents });
+ saveValueInLocalStorage({ key: 'wallet', value: { name } });
+ saveValueInLocalStorage({ key: 'keyAgentData', value: wallet.keyAgent.serializableData });
+
+ return { wallet, encryptedKeyAgents, name };
+ },
+ [
+ backgroundService,
+ walletManagerUi,
+ executeWithPassword,
+ storeMnemonicInBackgroundScript,
+ getPassword,
+ cardanoWalletManager
+ ]
+ );
+
+ const setWallet = useCallback(
+ async ({ walletInstance, mnemonicVerificationFrequency = '', chainName = CHAIN }: SetWallet): Promise => {
+ updateAppSettings({
+ chainName,
+ mnemonicVerificationFrequency,
+ lastMnemonicVerification: dayjs().valueOf().toString()
+ });
+
+ // Set wallet states
+ setWalletLock(walletInstance.encryptedKeyAgents);
+ setCardanoWallet(walletInstance.wallet);
+ setKeyAgentData(walletInstance.wallet.keyAgent.serializableData);
+ setCurrentChain(chainName);
+ },
+ [updateAppSettings, setWalletLock, setCardanoWallet, setKeyAgentData, setCurrentChain]
+ );
+
+ /**
+ * Creates a Ledger hardware wallet
+ * and saves it in browser storage with the data to lock/unlock it
+ */
+ const createHardwareWallet = useCallback(
+ async ({
+ accountIndex = 0,
+ deviceConnection,
+ name,
+ chainId,
+ connectedDevice
+ }: CreateHardwareWallet): Promise =>
+ cardanoWalletManager.createHardwareWallet(walletManagerUi, {
+ accountIndex,
+ deviceConnection,
+ name,
+ activeChainId: chainId,
+ connectedDevice
+ }),
+ [walletManagerUi, cardanoWalletManager]
+ );
+
+ /**
+ * Saves hardware wallet in storage and updates wallet store
+ */
+ const saveHardwareWallet = useCallback(
+ async (wallet: Wallet.CardanoWalletByChain, chainName = CHAIN): Promise => {
+ const { keyAgentsByChain, ...cardanoWallet } = wallet;
+
+ // Save in storage
+ await backgroundService.setBackgroundStorage({ keyAgentsByChain });
+ saveValueInLocalStorage({ key: 'wallet', value: { name: cardanoWallet.name } });
+ saveValueInLocalStorage({ key: 'keyAgentData', value: cardanoWallet.keyAgent.serializableData });
+
+ updateAppSettings({
+ chainName,
+ // Doesn't make sense for hardware wallets
+ mnemonicVerificationFrequency: ''
+ });
+
+ // Set wallet states
+ // eslint-disable-next-line unicorn/no-null
+ setWalletLock(null); // Lock is not available for hardware wallets
+ setCardanoWallet(cardanoWallet);
+ setKeyAgentData(cardanoWallet.keyAgent.serializableData);
+ setCurrentChain(chainName);
+ },
+ [backgroundService, updateAppSettings, setWalletLock, setCardanoWallet, setKeyAgentData, setCurrentChain]
+ );
+
+ /**
+ * Deletes Wallet from memory, all info from browser storage and destroys all stores
+ */
+ const deleteWallet = useCallback(
+ async (isForgotPasswordFlow = false): Promise => {
+ await Wallet.shutdownWallet(walletManagerUi);
+ deleteFromLocalStorage('appSettings');
+ deleteFromLocalStorage('showDappBetaModal');
+ deleteFromLocalStorage('lastStaking');
+ deleteFromLocalStorage('userInfo');
+ deleteFromLocalStorage('keyAgentData');
+ await backgroundService.clearBackgroundStorage(['message', 'mnemonic', 'keyAgentsByChain']);
+ setKeyAgentData();
+ resetWalletLock();
+ setCardanoWallet();
+ setCurrentChain(CHAIN);
+
+ if (!isForgotPasswordFlow) {
+ deleteFromLocalStorage('wallet');
+ deleteFromLocalStorage('analyticsAccepted');
+ deleteFromLocalStorage('analyticsUserId');
+ clearAddressBook();
+ clearNftsFolders();
+ }
+ },
+ [
+ walletManagerUi,
+ backgroundService,
+ clearAddressBook,
+ clearNftsFolders,
+ setKeyAgentData,
+ resetWalletLock,
+ setCardanoWallet,
+ setCurrentChain
+ ]
+ );
+
+ /**
+ * Deactivates current wallet and activates it again with the new network
+ */
+ const switchNetwork = useCallback(
+ async (chainName: Wallet.ChainName): Promise => {
+ const chainId = Wallet.Cardano.ChainIds[chainName];
+ console.log('Switching chain to', chainName, AVAILABLE_CHAINS);
+ if (!chainId || !AVAILABLE_CHAINS.includes(chainName)) throw new Error('Chain not supported');
+
+ const backgroundStorage = await backgroundService.getBackgroundStorage();
+ const keyAgentsByChain: Wallet.KeyAgentsByChain = backgroundStorage.keyAgentsByChain;
+ const walletName = getWalletFromStorage()?.name;
+
+ if (!keyAgentsByChain[chainName] || !walletName) throw new Error('Wallet data for chosen chain not found');
+ const { keyAgentData: newKeyAgent } = keyAgentsByChain[chainName];
+ if (!newKeyAgent) throw new Error('Wallet data for chosen chain is empty');
+
+ const { asyncKeyAgent } = await Wallet.restoreWalletFromKeyAgent(
+ walletManagerUi,
+ walletName,
+ newKeyAgent,
+ getPassword,
+ chainName,
+ false
+ );
+
+ await Wallet.switchKeyAgents(walletManagerUi, walletName, asyncKeyAgent, chainName);
+
+ updateAppSettings({ ...settings, chainName });
+ saveValueInLocalStorage({ key: 'wallet', value: { name: walletName } });
+ saveValueInLocalStorage({ key: 'keyAgentData', value: newKeyAgent });
+
+ setCurrentChain(chainName);
+ setCardanoCoin(chainId);
+ setKeyAgentData(newKeyAgent);
+ },
+ [
+ backgroundService,
+ getPassword,
+ setCardanoCoin,
+ setCurrentChain,
+ settings,
+ updateAppSettings,
+ walletManagerUi,
+ setKeyAgentData
+ ]
+ );
+
+ return {
+ lockWallet,
+ unlockWallet,
+ loadWallet,
+ createWallet,
+ setWallet,
+ getPassword,
+ createHardwareWallet,
+ connectHardwareWallet,
+ saveHardwareWallet,
+ deleteWallet,
+ executeWithPassword,
+ switchNetwork
+ };
+};
diff --git a/apps/browser-extension-wallet/src/index-dapp-connector.tsx b/apps/browser-extension-wallet/src/index-dapp-connector.tsx
new file mode 100644
index 0000000000..7991cb480c
--- /dev/null
+++ b/apps/browser-extension-wallet/src/index-dapp-connector.tsx
@@ -0,0 +1,9 @@
+// This is needed for 'syncWebAssembly' to work. There might be a better split point,
+// e.g. when app has loaded and rendered something,
+// but before anything from '@lace/cardano' is imported.
+// Basically, we need to `await import('@emurgo/cardano-serialization-lib-browser')`
+// before static imports from '@lace/cardano' can work.
+// --
+// 'asyncWebAssembly' is not suitable as seems to expect all wasm imports
+// to be done dynamically, which is not the case in cardano-js-sdk.
+import('./dapp-connector');
diff --git a/apps/browser-extension-wallet/src/index-options.tsx b/apps/browser-extension-wallet/src/index-options.tsx
new file mode 100644
index 0000000000..e4760b30ea
--- /dev/null
+++ b/apps/browser-extension-wallet/src/index-options.tsx
@@ -0,0 +1 @@
+import('./views/browser-view');
diff --git a/apps/browser-extension-wallet/src/index-popup.tsx b/apps/browser-extension-wallet/src/index-popup.tsx
new file mode 100644
index 0000000000..238fe2862f
--- /dev/null
+++ b/apps/browser-extension-wallet/src/index-popup.tsx
@@ -0,0 +1,9 @@
+// This is needed for 'syncWebAssembly' to work. There might be a better split point,
+// e.g. when app has loaded and rendered something,
+// but before anything from '@lace/cardano' is imported.
+// Basically, we need to `await import('@dcspark/cardano-multiplatform-lib-asmjs": "^3.1.0",')`
+// before static imports from '@lace/cardano' can work.
+// --
+// 'asyncWebAssembly' is not suitable as seems to expect all wasm imports
+// to be done dynamically, which is not the case in cardano-js-sdk.
+import('./popup');
diff --git a/apps/browser-extension-wallet/src/index.ts b/apps/browser-extension-wallet/src/index.ts
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/browser-extension-wallet/src/lib/axios.ts b/apps/browser-extension-wallet/src/lib/axios.ts
new file mode 100644
index 0000000000..b00349e4d4
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/axios.ts
@@ -0,0 +1,7 @@
+import { createAxiosInstance } from '@lace/core';
+
+const CACHE_MAX_AGE = 300_000; // 5 minutes
+
+const axiosClient = createAxiosInstance({ cache: { options: { maxAge: CACHE_MAX_AGE } } });
+
+export { axiosClient };
diff --git a/apps/browser-extension-wallet/src/lib/i18n.ts b/apps/browser-extension-wallet/src/lib/i18n.ts
new file mode 100644
index 0000000000..8391256840
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/i18n.ts
@@ -0,0 +1,40 @@
+/* eslint-disable @typescript-eslint/no-var-requires */
+/* eslint-disable unicorn/prefer-module */
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+
+type I18NextResources = Partial>;
+
+export enum SupportedLanguages {
+ EN = 'en'
+}
+const DEFAULT_LANG = SupportedLanguages.EN;
+
+const resources: I18NextResources = {};
+for (const lang of Object.values(SupportedLanguages)) {
+ Object.assign(resources, {
+ [lang]: {
+ translation: {
+ ...require(`./translations/${lang}.json`),
+ ...require(`./translations/legal.${lang}.ts`).default,
+ ...require(`./translations/cookie-policy.${lang}.ts`).default
+ }
+ }
+ });
+}
+
+i18n.use(initReactI18next).init({
+ fallbackLng: DEFAULT_LANG,
+ interpolation: {
+ // not needed for react as it escapes by default
+ escapeValue: false
+ },
+ lng: SupportedLanguages.EN,
+ resources,
+ react: {
+ useSuspense: false,
+ transSupportBasicHtmlNodes: true
+ }
+});
+
+export default i18n;
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/api-consumers.ts b/apps/browser-extension-wallet/src/lib/scripts/background/api-consumers.ts
new file mode 100644
index 0000000000..a70db8b8f9
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/api-consumers.ts
@@ -0,0 +1,72 @@
+import {
+ RemoteAuthenticator,
+ RemoteAuthenticatorMethod,
+ ApiError,
+ DataSignError,
+ PaginateError,
+ TxSendError,
+ TxSignError,
+ WalletApi,
+ WalletApiMethodNames
+} from '@cardano-sdk/dapp-connector';
+import { Shutdown, GetErrorPrototype } from '@cardano-sdk/util';
+import {
+ consumeRemoteApi,
+ MessengerDependencies,
+ RemoteApiProperties,
+ RemoteApiPropertyType
+} from '@cardano-sdk/web-extension';
+import fromPairs from 'lodash/fromPairs';
+
+export const RemoteAuthenticatorMethodNames: Array = [
+ 'haveAccess',
+ 'requestAccess',
+ 'revokeAccess'
+];
+
+export interface RemoteAuthenticatorApiProps {
+ walletName: string;
+}
+
+export interface ConsumeRemoteWalletApiProps {
+ walletName: string;
+}
+
+const authenticatorChannel = (walletName: string) => `authenticator-${walletName}`;
+const walletApiChannel = (walletName: string) => `wallet-api-${walletName}`;
+
+const cip30errorTypes = [ApiError, DataSignError, PaginateError, TxSendError, TxSignError];
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const getErrorPrototype: GetErrorPrototype = (err: any) =>
+ cip30errorTypes.find((ErrorType) => ErrorType.prototype.name === err.name)?.prototype || Error.prototype;
+
+// copied from sdk
+export const consumeRemoteAuthenticatorApi = (
+ { walletName }: RemoteAuthenticatorApiProps,
+ dependencies: MessengerDependencies
+): RemoteAuthenticator & Shutdown =>
+ consumeRemoteApi(
+ {
+ baseChannel: authenticatorChannel(walletName),
+ properties: fromPairs(
+ RemoteAuthenticatorMethodNames.map((prop) => [prop, RemoteApiPropertyType.MethodReturningPromise])
+ ) as RemoteApiProperties
+ },
+ dependencies
+ );
+
+// copied from sdk
+export const consumeRemoteWalletApi = (
+ { walletName }: ConsumeRemoteWalletApiProps,
+ dependencies: MessengerDependencies
+): WalletApi =>
+ consumeRemoteApi(
+ {
+ baseChannel: walletApiChannel(walletName),
+ getErrorPrototype,
+ properties: fromPairs(
+ WalletApiMethodNames.map((prop) => [prop, RemoteApiPropertyType.MethodReturningPromise])
+ ) as RemoteApiProperties
+ },
+ dependencies
+ );
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/authenticator.ts b/apps/browser-extension-wallet/src/lib/scripts/background/authenticator.ts
new file mode 100644
index 0000000000..679b7d7e00
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/authenticator.ts
@@ -0,0 +1,35 @@
+import {
+ PersistentAuthenticatorStorage,
+ createPersistentAuthenticatorStorage,
+ PersistentAuthenticator
+} from '@cardano-sdk/dapp-connector';
+import { DAPP_CHANNELS } from '@src/utils/constants';
+import { Subject } from 'rxjs';
+import { requestAccessDebounced } from './requestAccess';
+import { storage as webStorage } from 'webextension-polyfill';
+
+const createStorage = (_storage: PersistentAuthenticatorStorage) => {
+ const origins$ = new Subject();
+ // emit once on load
+ _storage
+ .get()
+ .then((origins) => origins$.next(origins))
+ .catch((error) => {
+ throw error;
+ });
+ return {
+ get: _storage.get,
+ set: (origins: string[]) => {
+ origins$.next(origins);
+ return _storage.set(origins);
+ },
+ origins$
+ };
+};
+
+const authenticatorStorage = createPersistentAuthenticatorStorage(DAPP_CHANNELS.originsList, webStorage.local);
+const internalStorage = createStorage(authenticatorStorage);
+export const authenticator = new PersistentAuthenticator(
+ { requestAccess: requestAccessDebounced },
+ { logger: console, storage: internalStorage }
+);
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/cip30.ts b/apps/browser-extension-wallet/src/lib/scripts/background/cip30.ts
new file mode 100644
index 0000000000..532ecc6d02
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/cip30.ts
@@ -0,0 +1,90 @@
+import { cip30 as walletCip30 } from '@cardano-sdk/wallet';
+import { ensureUiIsOpenAndLoaded, getLastActiveTab } from './util';
+import { userPromptService } from './services/dappService';
+import { Wallet } from '@lace/cardano';
+import { getRandomIcon } from '@src/utils/get-random-icon';
+import { authenticator } from './authenticator';
+import { wallet$ } from './wallet';
+import { runtime } from 'webextension-polyfill';
+import { exposeApi, RemoteApiPropertyType, cip30 } from '@cardano-sdk/web-extension';
+import { DAPP_CHANNELS } from '../../../utils/constants';
+import { DappDataService } from '../types';
+import { BehaviorSubject, of } from 'rxjs';
+
+const dappSignTxData$ = new BehaviorSubject(undefined);
+const dappSignData$ = new BehaviorSubject<{
+ addr: Wallet.Cardano.PaymentAddress;
+ payload: Wallet.HexBlob;
+}>(undefined);
+
+const getDappInfoFromLastActiveTab: () => Promise = async () =>
+ await getLastActiveTab().then((t) => ({
+ logo: t.favIconUrl || getRandomIcon({ id: origin, size: 40 }),
+ name: t.title || t.url.split('//')[1],
+ url: t.url.replace(/\/$/, '')
+ }));
+
+export const confirmationCallback: walletCip30.CallbackConfirmation = async (
+ args: walletCip30.SignDataCallbackParams | walletCip30.SignTxCallbackParams | walletCip30.SubmitTxCallbackParams
+): Promise => {
+ switch (args.type) {
+ case walletCip30.Cip30ConfirmationCallbackType.SubmitTx: {
+ // We don't need another callback for this callback, so long as we ensure callbacks for signing tx's
+ // Remove this method once it is dropped from the SDK in future build
+ // Also transactions can be submitted by the dApps externally
+ // once they've got the witnesss keys if they construct their own transactions
+ return Promise.resolve(true);
+ }
+ case walletCip30.Cip30ConfirmationCallbackType.SignTx: {
+ try {
+ const { logo, name, url } = await getDappInfoFromLastActiveTab();
+ dappSignTxData$.next(args.data);
+ await ensureUiIsOpenAndLoaded(`#/dapp/sign-tx?url=${url}&name=${name}&logo=${logo}`);
+
+ return userPromptService.allowSignTx();
+ } catch (error) {
+ console.log(error);
+ // eslint-disable-next-line unicorn/no-useless-undefined
+ dappSignTxData$.next(undefined);
+ return false;
+ }
+ }
+ case walletCip30.Cip30ConfirmationCallbackType.SignData: {
+ try {
+ const { logo, name, url } = await getDappInfoFromLastActiveTab();
+ dappSignData$.next(args.data);
+ await ensureUiIsOpenAndLoaded(`#/dapp/sign-data?url=${url}&name=${name}&logo=${logo}`);
+
+ return userPromptService.allowSignData();
+ } catch (error) {
+ console.log(error);
+ // eslint-disable-next-line unicorn/no-useless-undefined
+ dappSignData$.next(undefined);
+ return false;
+ }
+ }
+ default:
+ return Promise.reject();
+ }
+};
+
+const walletApi = walletCip30.createWalletApi(wallet$, confirmationCallback, { logger: console });
+cip30.initializeBackgroundScript(
+ { walletName: process.env.WALLET_NAME },
+ { authenticator, logger: console, runtime, walletApi }
+);
+
+exposeApi(
+ {
+ baseChannel: DAPP_CHANNELS.dappData,
+ properties: {
+ getSignTxData: RemoteApiPropertyType.MethodReturningPromise,
+ getSignDataData: RemoteApiPropertyType.MethodReturningPromise
+ },
+ api$: of({
+ getSignTxData: () => Promise.resolve(dappSignTxData$.value),
+ getSignDataData: () => Promise.resolve(dappSignData$.value)
+ })
+ },
+ { logger: console, runtime }
+);
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/config.ts b/apps/browser-extension-wallet/src/lib/scripts/background/config.ts
new file mode 100644
index 0000000000..fa4af55a5d
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/config.ts
@@ -0,0 +1,48 @@
+import axiosFetchAdapter from '@vespaiach/axios-fetch-adapter';
+import { Wallet } from '@lace/cardano';
+import { RemoteApiPropertyType } from '@cardano-sdk/web-extension';
+import { getBaseUrlForChain } from '@src/utils/chain';
+
+export const backgroundServiceProperties = {
+ requestMessage$: RemoteApiPropertyType.HotObservable,
+ migrationState$: RemoteApiPropertyType.HotObservable,
+ coinPrices: {
+ adaPrices$: RemoteApiPropertyType.HotObservable,
+ tokenPrices$: RemoteApiPropertyType.HotObservable
+ },
+ handleOpenBrowser: RemoteApiPropertyType.MethodReturningPromise,
+ handleChangeTheme: RemoteApiPropertyType.MethodReturningPromise,
+ clearBackgroundStorage: RemoteApiPropertyType.MethodReturningPromise,
+ getBackgroundStorage: RemoteApiPropertyType.MethodReturningPromise,
+ setBackgroundStorage: RemoteApiPropertyType.MethodReturningPromise,
+ getWalletPassword: RemoteApiPropertyType.MethodReturningPromise,
+ setWalletPassword: RemoteApiPropertyType.MethodReturningPromise,
+ resetStorage: RemoteApiPropertyType.MethodReturningPromise,
+ backendFailures$: RemoteApiPropertyType.HotObservable
+};
+
+export const getProviders = (chainName: Wallet.ChainName): Wallet.WalletProvidersDependencies => {
+ const baseCardanoServicesUrl = getBaseUrlForChain(chainName);
+
+ return Wallet.createProviders({
+ axiosAdapter: axiosFetchAdapter,
+ httpProviders: {
+ assetProvider: `${baseCardanoServicesUrl}/asset`,
+ chainHistoryProvider: `${baseCardanoServicesUrl}/chain-history`,
+ networkInfoProvider: `${baseCardanoServicesUrl}/network-info`,
+ rewardsProvider: `${baseCardanoServicesUrl}/rewards`,
+ stakePoolProvider: `${baseCardanoServicesUrl}/stake-pool`,
+ txSubmitProvider: `${baseCardanoServicesUrl}/tx-submit`,
+ utxoProvider: `${baseCardanoServicesUrl}/utxo`
+ }
+ });
+};
+export const ownOrigin = globalThis.location.origin;
+
+export const BASE_EXTENSION_APP_URL = 'app.html';
+
+export const cip30WalletProperties = {
+ // eslint-disable-next-line max-len
+ icon: "data:image/svg+xml,%3Csvg width='45' height='45' viewBox='0 0 45 45' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M7.20581 19.7194C6.97849 20.2972 6.78249 20.8867 6.61867 21.4856C5.89494 24.1253 5.77235 26.8937 6.25994 29.5871C6.28266 29.721 6.30896 29.855 6.33766 29.9877C6.4405 30.4971 6.56725 31.0065 6.71553 31.5052C6.87936 32.06 7.07427 32.6185 7.29789 33.1637C8.7964 36.8437 11.4627 39.9293 14.8864 41.9457C15.6873 42.4187 16.5242 42.8281 17.3892 43.1702C20.6506 44.4602 24.2155 44.7792 27.6541 44.0885C31.0927 43.3979 34.258 41.7272 36.7683 39.2778C38.3477 37.7378 39.6295 35.9197 40.5494 33.9147C40.3927 34.0797 40.2313 34.2435 40.0711 34.4038C37.4141 37.0117 34.0672 38.8061 30.4246 39.5756C30.2596 39.6569 30.0922 39.7311 29.9235 39.8052C27.3481 40.9225 24.5061 41.2775 21.735 40.8279C18.9638 40.3784 16.3798 39.1432 14.2897 37.2689C14.0505 37.0561 13.8182 36.8336 13.5926 36.6017L13.4909 36.4952C11.4347 34.3597 10.0778 31.6485 9.60099 28.7226C9.55794 28.4603 9.52167 28.1964 9.49217 27.9309C9.43497 27.4039 9.40583 26.8742 9.40488 26.3441C9.39359 23.459 10.2366 20.6351 11.8276 18.2282C12.2275 17.62 12.6732 17.0431 13.1609 16.5027C13.3666 16.2743 13.5818 16.0471 13.8066 15.8283C16.4992 13.1837 20.1109 11.6838 23.8848 11.643C23.9434 11.643 24.0115 11.643 24.0785 11.643H24.2339C27.4415 11.6683 30.5515 12.7496 33.0828 14.7198C32.9951 14.3953 32.8959 14.0741 32.7851 13.756C32.6954 13.5049 32.6009 13.2597 32.4969 13.017C31.9158 11.6256 31.0986 10.3452 30.0814 9.23227L30.0228 9.18803C29.1693 8.89153 28.2941 8.66125 27.4052 8.49925C25.1046 8.07597 22.7442 8.09749 20.4516 8.56263C19.3778 8.77909 18.3263 9.09412 17.3103 9.50372C16.8212 9.69983 16.3453 9.91507 15.8957 10.1447C14.904 10.6473 13.9608 11.2406 13.0784 11.9168L12.9994 11.9766C12.4412 12.4128 11.9078 12.8799 11.4019 13.3757C10.2717 14.4804 9.29155 15.7287 8.48651 17.0887C8.02665 17.8674 7.62689 18.6801 7.29071 19.5197L7.28114 19.7182L7.20581 19.7194ZM18.1521 5.9534C17.0596 5.95245 15.9692 6.05172 14.8948 6.24996C14.7537 6.27268 14.6174 6.29899 14.481 6.33008C13.9788 6.43292 13.473 6.56086 12.9719 6.71153C12.6802 6.79644 12.3896 6.89091 12.1014 6.99135C11.8132 7.0918 11.5477 7.19464 11.2739 7.30585C7.5843 8.79264 4.49573 11.4672 2.49676 14.9063C2.24206 15.3392 1.99931 15.7948 1.77929 16.26C1.60231 16.6295 1.43609 17.0061 1.28662 17.3828C-0.413844 21.6522 -0.429267 26.4081 1.24347 30.6884C2.91621 34.9687 6.15224 38.4539 10.2969 40.439L10.5481 40.5585C10.3806 40.3995 10.2144 40.2369 10.053 40.0719C7.44279 37.411 5.64753 34.0595 4.87879 30.4122C4.81063 30.2926 4.72334 30.1001 4.63963 29.9064C4.5069 29.6015 4.38732 29.2906 4.27491 28.9725C3.6319 27.1539 3.35445 25.2262 3.45849 23.3001C3.56253 21.374 4.04602 19.4875 4.88118 17.7487C5.16523 17.1562 5.48717 16.5827 5.84499 16.0316C6.24791 15.4151 6.69723 14.8303 7.18907 14.2821C7.4354 14.0059 7.69011 13.7404 7.95796 13.4857C8.45652 13.0044 8.99012 12.5607 9.55435 12.1584C10.4163 11.5414 11.343 11.0202 12.3178 10.6039C14.603 8.86233 17.2524 7.66007 20.0678 7.08702C21.112 6.8698 22.1722 6.73862 23.2379 6.6948L22.9987 6.62544C21.4217 6.1797 19.7909 5.95356 18.1521 5.9534V5.9534ZM21.2325 37.7616L21.4717 37.8273C24.1094 38.5547 26.8767 38.6805 29.5696 38.1957L29.6437 38.1825C29.7633 38.1598 29.8829 38.1383 30.0025 38.1119C30.4975 38.0127 30.9866 37.8883 31.5056 37.7341C32.0246 37.5798 32.5411 37.404 33.0601 37.196L33.2371 37.4351L33.1295 37.1673C35.3219 36.2836 37.3184 34.976 39.0044 33.3192C40.1359 32.2115 41.1179 30.9609 41.9258 29.5991C42.3923 28.8074 42.7973 27.9811 43.1371 27.1274C44.4487 23.8612 44.7818 20.2839 44.0958 16.8317C43.4098 13.3795 41.7342 10.2015 39.2735 7.68491V7.68491C37.7389 6.11162 35.9283 4.83362 33.9319 3.91457C36.1679 6.04959 37.8668 8.68359 38.8896 11.6011C39.1754 12.4129 39.4091 13.2421 39.5892 14.0836C39.6537 14.2032 39.7386 14.3897 39.8188 14.5751C40.2832 15.6624 40.6144 16.8019 40.8053 17.9688C40.9355 18.7637 41.0011 19.5679 41.0014 20.3735C41.007 23.2506 40.1648 26.0655 38.5799 28.4667C38.1801 29.0749 37.734 29.6514 37.2454 30.191C37.0397 30.4206 36.8245 30.6478 36.5997 30.8666L36.4956 30.9647C35.226 32.1895 33.7442 33.1733 32.1226 33.8681C30.5316 35.063 28.7658 36.0052 26.8874 36.6614C26.0713 36.9493 25.2372 37.1838 24.3906 37.3634C23.3504 37.5813 22.2942 37.7149 21.2325 37.7628V37.7616ZM14.3973 35.2504C15.2548 35.5521 16.1343 35.7867 17.0281 35.9523C19.3407 36.3904 21.7171 36.368 24.0211 35.8866C24.8231 35.719 25.6132 35.4989 26.3864 35.2277C26.659 35.132 26.9281 35.0292 27.1912 34.9228C27.6539 34.7362 28.1047 34.5305 28.534 34.3129C31.6453 32.7525 34.2367 30.3223 35.9934 27.3175C36.2457 26.8918 36.4873 26.4362 36.7109 25.9615C36.8807 25.6027 37.0349 25.2512 37.1808 24.8924L37.2012 24.8362C38.4673 21.6854 38.8136 18.2401 38.1997 14.9003V14.892C38.1793 14.7616 38.153 14.6337 38.1243 14.5045C38.0239 14.0071 37.8959 13.4953 37.7417 12.9799C37.6544 12.6869 37.5623 12.3964 37.4595 12.1058C37.365 11.8403 37.2669 11.5784 37.1605 11.3201C35.6856 7.63287 33.0272 4.54032 29.6031 2.52864C29.1415 2.2548 28.668 2.00369 28.1968 1.77888C27.8488 1.61266 27.4985 1.45601 27.1421 1.31491C22.8673 -0.40841 18.0966 -0.438843 13.8002 1.2298C9.50371 2.89845 6.00412 6.14088 4.01303 10.2977C3.97596 10.3755 3.93889 10.4532 3.90302 10.5369C6.03985 8.29653 8.67675 6.59391 11.598 5.56835C12.404 5.28601 13.2267 5.05359 14.0613 4.8724C14.194 4.79946 14.3937 4.70858 14.597 4.62368C14.8852 4.5041 15.1806 4.38452 15.4807 4.28048C16.2996 3.9932 17.1418 3.77716 17.9979 3.63475C21.6437 3.04462 25.3781 3.84571 28.4611 5.87926C29.0651 6.27329 29.6379 6.71304 30.1747 7.19464C30.4425 7.4338 30.7016 7.68212 30.9519 7.93962C32.1751 9.21219 33.1575 10.6958 33.8517 12.3186C33.9821 12.6247 34.1029 12.9333 34.2105 13.2502C34.498 14.0619 34.7133 14.8974 34.8538 15.747C34.9004 16.0112 34.9351 16.2803 34.965 16.5482C35.2664 19.2655 34.7947 22.0127 33.6042 24.4739C33.0016 25.7325 32.2247 26.8999 31.2963 27.9417C29.9367 29.4529 28.2794 30.6668 26.4284 31.5072C24.5774 32.3476 22.5728 32.7964 20.5401 32.8253C19.8147 32.8353 19.0896 32.7901 18.371 32.6902C16.8586 32.4808 15.388 32.037 14.0123 31.3748C13.077 30.9268 12.1909 30.3826 11.3684 29.7509C11.9155 31.8031 12.9543 33.691 14.3949 35.2516L14.3973 35.2504ZM10.963 27.3582C12.0346 28.4496 13.2893 29.3449 14.67 30.0033C14.9777 30.1507 15.2894 30.2859 15.6051 30.4086C13.3915 28.0523 12.0214 25.0287 11.7092 21.8109C11.0674 23.5868 10.813 25.4794 10.963 27.3617V27.3582ZM28.839 14.0382C31.0416 16.3937 32.4128 19.4053 32.7432 22.6132C33.3848 20.8547 33.6417 18.9789 33.4966 17.1126V17.1066C32.1808 15.7632 30.5926 14.7169 28.839 14.0382V14.0382Z' fill='url(%23paint0_radial_7152_190498)'/%3E%3Cdefs%3E%3CradialGradient id='paint0_radial_7152_190498' cx='0' cy='0' r='1' gradientUnits='userSpaceOnUse' gradientTransform='translate(22.219 22.2222) scale(22.2239)'%3E%3Cstop stop-color='%23FDC300'/%3E%3Cstop offset='0.11' stop-color='%23FDC205'/%3E%3Cstop offset='0.25' stop-color='%23FDBF13'/%3E%3Cstop offset='0.39' stop-color='%23FDB92B'/%3E%3Cstop offset='0.54' stop-color='%23FEB24C'/%3E%3Cstop offset='0.7' stop-color='%23FEA977'/%3E%3Cstop offset='0.86' stop-color='%23FF9DAA'/%3E%3Cstop offset='1' stop-color='%23FF92DE'/%3E%3C/radialGradient%3E%3C/defs%3E%3C/svg%3E%0A",
+ walletName: process.env.WALLET_NAME
+};
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/content.ts b/apps/browser-extension-wallet/src/lib/scripts/background/content.ts
new file mode 100644
index 0000000000..6c40f1613d
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/content.ts
@@ -0,0 +1,10 @@
+import { runtime } from 'webextension-polyfill';
+import { cip30 } from '@cardano-sdk/web-extension';
+// Disable logging in production for performance & security measures
+if (process.env.USE_DAPP_CONNECTOR === 'true') {
+ console.log('initializing content script');
+ cip30.initializeContentScript(
+ { injectedScriptSrc: runtime.getURL('./js/inject.js'), walletName: process.env.WALLET_NAME },
+ { logger: console, runtime }
+ );
+}
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/index.ts b/apps/browser-extension-wallet/src/lib/scripts/background/index.ts
new file mode 100644
index 0000000000..48bf860cd2
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/index.ts
@@ -0,0 +1,5 @@
+import './cip30';
+import './onError';
+import './onUpdate';
+import './services';
+import './keep-alive-sw';
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/inject.ts b/apps/browser-extension-wallet/src/lib/scripts/background/inject.ts
new file mode 100644
index 0000000000..2426cd25d4
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/inject.ts
@@ -0,0 +1,45 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { Cip30Wallet, injectGlobal, WalletApi, WalletProperties } from '@cardano-sdk/dapp-connector';
+import { cip30, injectedRuntime, MessengerDependencies } from '@cardano-sdk/web-extension';
+import { consumeRemoteAuthenticatorApi, consumeRemoteWalletApi } from './api-consumers';
+
+const initializeInjectedScript = (props: WalletProperties, { logger }: cip30.InitializeInjectedDependencies) => {
+ const dependencies: MessengerDependencies = {
+ logger,
+ runtime: injectedRuntime
+ };
+
+ const authenticator = consumeRemoteAuthenticatorApi(props, dependencies);
+ const walletApi = consumeRemoteWalletApi(props, dependencies);
+
+ // Add experimental.getCollateral to CIP-30 API
+ const api = new Proxy({} as WalletApi & { extensions: any }, {
+ // eslint-disable-next-line consistent-return
+ get(_, prop) {
+ const method = (walletApi as any)[prop];
+ if (typeof method === 'function') return method.bind(walletApi);
+ if (prop === 'experimental') {
+ return {
+ getCollateral: async () => {
+ const getCollateralFromWallet = walletApi.getCollateral.bind(walletApi);
+ return await getCollateralFromWallet();
+ }
+ };
+ }
+ }
+ });
+
+ const wallet = new Cip30Wallet(props, { api, authenticator, logger });
+ injectGlobal(window, wallet, logger);
+};
+
+const cip30WalletProperties = {
+ // eslint-disable-next-line max-len
+ icon: "data:image/svg+xml,%3Csvg width='45' height='45' viewBox='0 0 45 45' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M7.20581 19.7194C6.97849 20.2972 6.78249 20.8867 6.61867 21.4856C5.89494 24.1253 5.77235 26.8937 6.25994 29.5871C6.28266 29.721 6.30896 29.855 6.33766 29.9877C6.4405 30.4971 6.56725 31.0065 6.71553 31.5052C6.87936 32.06 7.07427 32.6185 7.29789 33.1637C8.7964 36.8437 11.4627 39.9293 14.8864 41.9457C15.6873 42.4187 16.5242 42.8281 17.3892 43.1702C20.6506 44.4602 24.2155 44.7792 27.6541 44.0885C31.0927 43.3979 34.258 41.7272 36.7683 39.2778C38.3477 37.7378 39.6295 35.9197 40.5494 33.9147C40.3927 34.0797 40.2313 34.2435 40.0711 34.4038C37.4141 37.0117 34.0672 38.8061 30.4246 39.5756C30.2596 39.6569 30.0922 39.7311 29.9235 39.8052C27.3481 40.9225 24.5061 41.2775 21.735 40.8279C18.9638 40.3784 16.3798 39.1432 14.2897 37.2689C14.0505 37.0561 13.8182 36.8336 13.5926 36.6017L13.4909 36.4952C11.4347 34.3597 10.0778 31.6485 9.60099 28.7226C9.55794 28.4603 9.52167 28.1964 9.49217 27.9309C9.43497 27.4039 9.40583 26.8742 9.40488 26.3441C9.39359 23.459 10.2366 20.6351 11.8276 18.2282C12.2275 17.62 12.6732 17.0431 13.1609 16.5027C13.3666 16.2743 13.5818 16.0471 13.8066 15.8283C16.4992 13.1837 20.1109 11.6838 23.8848 11.643C23.9434 11.643 24.0115 11.643 24.0785 11.643H24.2339C27.4415 11.6683 30.5515 12.7496 33.0828 14.7198C32.9951 14.3953 32.8959 14.0741 32.7851 13.756C32.6954 13.5049 32.6009 13.2597 32.4969 13.017C31.9158 11.6256 31.0986 10.3452 30.0814 9.23227L30.0228 9.18803C29.1693 8.89153 28.2941 8.66125 27.4052 8.49925C25.1046 8.07597 22.7442 8.09749 20.4516 8.56263C19.3778 8.77909 18.3263 9.09412 17.3103 9.50372C16.8212 9.69983 16.3453 9.91507 15.8957 10.1447C14.904 10.6473 13.9608 11.2406 13.0784 11.9168L12.9994 11.9766C12.4412 12.4128 11.9078 12.8799 11.4019 13.3757C10.2717 14.4804 9.29155 15.7287 8.48651 17.0887C8.02665 17.8674 7.62689 18.6801 7.29071 19.5197L7.28114 19.7182L7.20581 19.7194ZM18.1521 5.9534C17.0596 5.95245 15.9692 6.05172 14.8948 6.24996C14.7537 6.27268 14.6174 6.29899 14.481 6.33008C13.9788 6.43292 13.473 6.56086 12.9719 6.71153C12.6802 6.79644 12.3896 6.89091 12.1014 6.99135C11.8132 7.0918 11.5477 7.19464 11.2739 7.30585C7.5843 8.79264 4.49573 11.4672 2.49676 14.9063C2.24206 15.3392 1.99931 15.7948 1.77929 16.26C1.60231 16.6295 1.43609 17.0061 1.28662 17.3828C-0.413844 21.6522 -0.429267 26.4081 1.24347 30.6884C2.91621 34.9687 6.15224 38.4539 10.2969 40.439L10.5481 40.5585C10.3806 40.3995 10.2144 40.2369 10.053 40.0719C7.44279 37.411 5.64753 34.0595 4.87879 30.4122C4.81063 30.2926 4.72334 30.1001 4.63963 29.9064C4.5069 29.6015 4.38732 29.2906 4.27491 28.9725C3.6319 27.1539 3.35445 25.2262 3.45849 23.3001C3.56253 21.374 4.04602 19.4875 4.88118 17.7487C5.16523 17.1562 5.48717 16.5827 5.84499 16.0316C6.24791 15.4151 6.69723 14.8303 7.18907 14.2821C7.4354 14.0059 7.69011 13.7404 7.95796 13.4857C8.45652 13.0044 8.99012 12.5607 9.55435 12.1584C10.4163 11.5414 11.343 11.0202 12.3178 10.6039C14.603 8.86233 17.2524 7.66007 20.0678 7.08702C21.112 6.8698 22.1722 6.73862 23.2379 6.6948L22.9987 6.62544C21.4217 6.1797 19.7909 5.95356 18.1521 5.9534V5.9534ZM21.2325 37.7616L21.4717 37.8273C24.1094 38.5547 26.8767 38.6805 29.5696 38.1957L29.6437 38.1825C29.7633 38.1598 29.8829 38.1383 30.0025 38.1119C30.4975 38.0127 30.9866 37.8883 31.5056 37.7341C32.0246 37.5798 32.5411 37.404 33.0601 37.196L33.2371 37.4351L33.1295 37.1673C35.3219 36.2836 37.3184 34.976 39.0044 33.3192C40.1359 32.2115 41.1179 30.9609 41.9258 29.5991C42.3923 28.8074 42.7973 27.9811 43.1371 27.1274C44.4487 23.8612 44.7818 20.2839 44.0958 16.8317C43.4098 13.3795 41.7342 10.2015 39.2735 7.68491V7.68491C37.7389 6.11162 35.9283 4.83362 33.9319 3.91457C36.1679 6.04959 37.8668 8.68359 38.8896 11.6011C39.1754 12.4129 39.4091 13.2421 39.5892 14.0836C39.6537 14.2032 39.7386 14.3897 39.8188 14.5751C40.2832 15.6624 40.6144 16.8019 40.8053 17.9688C40.9355 18.7637 41.0011 19.5679 41.0014 20.3735C41.007 23.2506 40.1648 26.0655 38.5799 28.4667C38.1801 29.0749 37.734 29.6514 37.2454 30.191C37.0397 30.4206 36.8245 30.6478 36.5997 30.8666L36.4956 30.9647C35.226 32.1895 33.7442 33.1733 32.1226 33.8681C30.5316 35.063 28.7658 36.0052 26.8874 36.6614C26.0713 36.9493 25.2372 37.1838 24.3906 37.3634C23.3504 37.5813 22.2942 37.7149 21.2325 37.7628V37.7616ZM14.3973 35.2504C15.2548 35.5521 16.1343 35.7867 17.0281 35.9523C19.3407 36.3904 21.7171 36.368 24.0211 35.8866C24.8231 35.719 25.6132 35.4989 26.3864 35.2277C26.659 35.132 26.9281 35.0292 27.1912 34.9228C27.6539 34.7362 28.1047 34.5305 28.534 34.3129C31.6453 32.7525 34.2367 30.3223 35.9934 27.3175C36.2457 26.8918 36.4873 26.4362 36.7109 25.9615C36.8807 25.6027 37.0349 25.2512 37.1808 24.8924L37.2012 24.8362C38.4673 21.6854 38.8136 18.2401 38.1997 14.9003V14.892C38.1793 14.7616 38.153 14.6337 38.1243 14.5045C38.0239 14.0071 37.8959 13.4953 37.7417 12.9799C37.6544 12.6869 37.5623 12.3964 37.4595 12.1058C37.365 11.8403 37.2669 11.5784 37.1605 11.3201C35.6856 7.63287 33.0272 4.54032 29.6031 2.52864C29.1415 2.2548 28.668 2.00369 28.1968 1.77888C27.8488 1.61266 27.4985 1.45601 27.1421 1.31491C22.8673 -0.40841 18.0966 -0.438843 13.8002 1.2298C9.50371 2.89845 6.00412 6.14088 4.01303 10.2977C3.97596 10.3755 3.93889 10.4532 3.90302 10.5369C6.03985 8.29653 8.67675 6.59391 11.598 5.56835C12.404 5.28601 13.2267 5.05359 14.0613 4.8724C14.194 4.79946 14.3937 4.70858 14.597 4.62368C14.8852 4.5041 15.1806 4.38452 15.4807 4.28048C16.2996 3.9932 17.1418 3.77716 17.9979 3.63475C21.6437 3.04462 25.3781 3.84571 28.4611 5.87926C29.0651 6.27329 29.6379 6.71304 30.1747 7.19464C30.4425 7.4338 30.7016 7.68212 30.9519 7.93962C32.1751 9.21219 33.1575 10.6958 33.8517 12.3186C33.9821 12.6247 34.1029 12.9333 34.2105 13.2502C34.498 14.0619 34.7133 14.8974 34.8538 15.747C34.9004 16.0112 34.9351 16.2803 34.965 16.5482C35.2664 19.2655 34.7947 22.0127 33.6042 24.4739C33.0016 25.7325 32.2247 26.8999 31.2963 27.9417C29.9367 29.4529 28.2794 30.6668 26.4284 31.5072C24.5774 32.3476 22.5728 32.7964 20.5401 32.8253C19.8147 32.8353 19.0896 32.7901 18.371 32.6902C16.8586 32.4808 15.388 32.037 14.0123 31.3748C13.077 30.9268 12.1909 30.3826 11.3684 29.7509C11.9155 31.8031 12.9543 33.691 14.3949 35.2516L14.3973 35.2504ZM10.963 27.3582C12.0346 28.4496 13.2893 29.3449 14.67 30.0033C14.9777 30.1507 15.2894 30.2859 15.6051 30.4086C13.3915 28.0523 12.0214 25.0287 11.7092 21.8109C11.0674 23.5868 10.813 25.4794 10.963 27.3617V27.3582ZM28.839 14.0382C31.0416 16.3937 32.4128 19.4053 32.7432 22.6132C33.3848 20.8547 33.6417 18.9789 33.4966 17.1126V17.1066C32.1808 15.7632 30.5926 14.7169 28.839 14.0382V14.0382Z' fill='url(%23paint0_radial_7152_190498)'/%3E%3Cdefs%3E%3CradialGradient id='paint0_radial_7152_190498' cx='0' cy='0' r='1' gradientUnits='userSpaceOnUse' gradientTransform='translate(22.219 22.2222) scale(22.2239)'%3E%3Cstop stop-color='%23FDC300'/%3E%3Cstop offset='0.11' stop-color='%23FDC205'/%3E%3Cstop offset='0.25' stop-color='%23FDBF13'/%3E%3Cstop offset='0.39' stop-color='%23FDB92B'/%3E%3Cstop offset='0.54' stop-color='%23FEB24C'/%3E%3Cstop offset='0.7' stop-color='%23FEA977'/%3E%3Cstop offset='0.86' stop-color='%23FF9DAA'/%3E%3Cstop offset='1' stop-color='%23FF92DE'/%3E%3C/radialGradient%3E%3C/defs%3E%3C/svg%3E%0A",
+ walletName: process.env.WALLET_NAME
+};
+if (process.env.USE_DAPP_CONNECTOR === 'true') {
+ console.log('injecting content script');
+ // Disable logging in production for performance & security measures
+ initializeInjectedScript(cip30WalletProperties, { logger: console });
+}
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/keep-alive-sw.ts b/apps/browser-extension-wallet/src/lib/scripts/background/keep-alive-sw.ts
new file mode 100644
index 0000000000..f6f0d667b8
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/keep-alive-sw.ts
@@ -0,0 +1,22 @@
+/* eslint-disable no-magic-numbers */
+import { Runtime, runtime } from 'webextension-polyfill';
+
+interface TimedPort extends Runtime.Port {
+ _timer?: NodeJS.Timeout;
+}
+
+const deleteTimer = (port: TimedPort) => {
+ if (port._timer) {
+ clearTimeout(port._timer);
+ delete port._timer;
+ }
+};
+const forceReconnect = (port: TimedPort) => {
+ deleteTimer(port);
+ port.disconnect();
+};
+runtime.onConnect.addListener((port: TimedPort) => {
+ if (port.name !== 'keepAlive') return;
+ port.onDisconnect.addListener(deleteTimer);
+ port._timer = setTimeout(forceReconnect, 250e3, port);
+});
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/onError.ts b/apps/browser-extension-wallet/src/lib/scripts/background/onError.ts
new file mode 100644
index 0000000000..9052512039
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/onError.ts
@@ -0,0 +1,50 @@
+/* eslint-disable no-magic-numbers, sonarjs/no-duplicate-string */
+import { WebRequest, webRequest, runtime } from 'webextension-polyfill';
+import { MessageTypes } from '../types';
+import { backendFailures$, requestMessage$ } from './services';
+
+const INTERNAL_SERVER_ERROR_STATUS_CODE = 500;
+const GATEWAY_TIMEOUT_STATUS_CODE = 503;
+const UNAUTHORIZED_STATUS_CODE = 401;
+
+const handleProviderServerErrors = (data: WebRequest.OnCompletedDetailsType) => {
+ if (data?.type === 'xmlhttprequest' && runtime.getURL('').startsWith(data.initiator)) {
+ if (data.statusCode > UNAUTHORIZED_STATUS_CODE && data.statusCode < GATEWAY_TIMEOUT_STATUS_CODE) {
+ // A backend service request has failed, increment the failed requests count
+ backendFailures$.next(backendFailures$.value + 1);
+ } else {
+ // Reset the failed counter
+ backendFailures$.next(0);
+ }
+ }
+};
+
+const handleRequests = (data: WebRequest.OnCompletedDetailsType) => {
+ // every status code number that is below 500 would be considered as successful
+ if (data?.type === 'xmlhttprequest' && data.statusCode < INTERNAL_SERVER_ERROR_STATUS_CODE) {
+ requestMessage$.next({ type: MessageTypes.HTTP_CONNECTION, data: { connected: true } });
+ webRequest.onCompleted.removeListener(handleRequests);
+ }
+};
+
+const handleConnectionIssues = async (error: WebRequest.OnErrorOccurredDetailsType) => {
+ if (
+ error?.type !== 'xmlhttprequest' ||
+ error?.error !== 'net::ERR_INTERNET_DISCONNECTED' ||
+ // checks if URL of the resource that triggered this request is equal to the url of the extension
+ !runtime.getURL('').startsWith(error?.initiator)
+ )
+ return;
+
+ console.log('xmlhttprequest:net::ERR_INTERNET_DISCONNECTED', error);
+
+ requestMessage$.next({ type: MessageTypes.HTTP_CONNECTION, data: { connected: false } });
+ if (!webRequest.onCompleted.hasListener(handleRequests)) {
+ webRequest.onCompleted.addListener(handleRequests, { urls: [''] });
+ }
+};
+
+if (!webRequest.onErrorOccurred.hasListener(handleConnectionIssues))
+ webRequest.onErrorOccurred.addListener(handleConnectionIssues, { urls: [''] });
+
+webRequest.onCompleted.addListener(handleProviderServerErrors, { urls: [''] });
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/onUpdate.ts b/apps/browser-extension-wallet/src/lib/scripts/background/onUpdate.ts
new file mode 100644
index 0000000000..6cf1437df6
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/onUpdate.ts
@@ -0,0 +1,43 @@
+import { Runtime, runtime, storage } from 'webextension-polyfill';
+import { initMigrationState } from './util';
+import { checkMigrations } from '../migrations';
+import { ABOUT_EXTENSION_KEY, ExtensionUpdateData, MigrationState } from '../types';
+
+type UpdateType = 'downgrade' | 'update';
+
+// migrations
+const checkMigrationsOnUpdate = async (details: Runtime.OnInstalledDetailsType) => {
+ if (details.reason === 'update' || details.reason === 'install') {
+ // Initialize migration state with not-loaded
+ await initMigrationState();
+ // Set migration state to up-to-date on install or check migrations on update
+ !details.previousVersion
+ ? await storage.local.set({ MIGRATION_STATE: { state: 'up-to-date' } as MigrationState })
+ : await checkMigrations(details.previousVersion);
+ }
+};
+
+const compareVersions = (v1 = '', v2 = '') => v1.localeCompare(v2, undefined, { numeric: true, sensitivity: 'base' });
+
+// extension updates announcements
+const displayReleaseAnnouncements = async ({ reason }: Runtime.OnInstalledDetailsType) => {
+ const { version: currentVersion } = runtime.getManifest();
+
+ const { aboutExtension } = (await storage.local.get(ABOUT_EXTENSION_KEY)) || {};
+ const previousVersion = aboutExtension?.version;
+
+ if (reason === 'update' && currentVersion !== previousVersion) {
+ const updateType: UpdateType = compareVersions(currentVersion, previousVersion) < 0 ? 'downgrade' : 'update';
+
+ await storage.local.set({
+ [ABOUT_EXTENSION_KEY]: { version: currentVersion, acknowledged: false, reason: updateType } as ExtensionUpdateData
+ });
+
+ console.log('extension got updated due to reason:', updateType);
+ }
+};
+
+// Only add an event listener if it doesn't exist
+if (!runtime.onInstalled.hasListener(displayReleaseAnnouncements))
+ runtime.onInstalled.addListener(displayReleaseAnnouncements);
+if (!runtime.onInstalled.hasListener(checkMigrationsOnUpdate)) runtime.onInstalled.addListener(checkMigrationsOnUpdate);
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/requestAccess.ts b/apps/browser-extension-wallet/src/lib/scripts/background/requestAccess.ts
new file mode 100644
index 0000000000..35c4180dfe
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/requestAccess.ts
@@ -0,0 +1,40 @@
+import pDebounce from 'p-debounce';
+import { Origin, RequestAccess } from '@cardano-sdk/dapp-connector';
+import { storage as webStorage, tabs } from 'webextension-polyfill';
+import { AuthorizedDappStorage } from '@src/types';
+
+import { authorizedDappsList, userPromptService } from './services/dappService';
+import { ensureUiIsOpenAndLoaded, getDappInfo, getLastActiveTab } from './util';
+import { authenticator } from './authenticator';
+import { AUTHORIZED_DAPPS_KEY } from '../types';
+
+const DEBOUNCE_THROTTLE = 500;
+
+export const requestAccess: RequestAccess = async (origin: Origin) => {
+ const launchingTab = await getLastActiveTab();
+ const { logo, name, url } = await getDappInfo(origin);
+ const dappUrl = `#/dapp/connect?url=${url}&name=${name}&logo=${logo}`;
+ await ensureUiIsOpenAndLoaded(dappUrl);
+ const isAllowed = await userPromptService.allowOrigin(origin);
+ if (isAllowed === 'deny') return Promise.resolve(false);
+ if (isAllowed === 'allow') {
+ const { authorizedDapps }: AuthorizedDappStorage = await webStorage.local.get(AUTHORIZED_DAPPS_KEY);
+ if (authorizedDapps) {
+ await webStorage.local.set({ authorizedDapps: [...authorizedDapps, { logo, name, url }] });
+ authorizedDappsList.next([...authorizedDapps, { logo, name, url }]);
+ } else {
+ await webStorage.local.set({ authorizedDapps: [{ logo, name, url }] });
+ authorizedDappsList.next([{ logo, name, url }]);
+ }
+ } else {
+ tabs.onRemoved.addListener((t) => {
+ if (t === launchingTab.id) {
+ authenticator.revokeAccess(origin);
+ tabs.onRemoved.removeListener(this);
+ }
+ });
+ }
+ return Promise.resolve(true);
+};
+
+export const requestAccessDebounced = pDebounce(requestAccess, DEBOUNCE_THROTTLE);
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/services/dappService.ts b/apps/browser-extension-wallet/src/lib/scripts/background/services/dappService.ts
new file mode 100644
index 0000000000..73fcee22f3
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/services/dappService.ts
@@ -0,0 +1,75 @@
+import { Origin } from '@cardano-sdk/dapp-connector';
+import { consumeRemoteApi, exposeApi, RemoteApiPropertyType } from '@cardano-sdk/web-extension';
+import { AuthorizedDappService, AuthorizedDappStorage } from '@src/types/dappConnector';
+import { DAPP_CHANNELS } from '@src/utils/constants';
+import { Subject, of } from 'rxjs';
+import { runtime, storage as webStorage } from 'webextension-polyfill';
+import { authenticator } from '../authenticator';
+import { Wallet } from '@lace/cardano';
+import { AUTHORIZED_DAPPS_KEY } from '@lib/scripts/types';
+
+const getAuthorizedDappListFromStorage = async () => {
+ const { authorizedDapps }: AuthorizedDappStorage = await webStorage.local.get(AUTHORIZED_DAPPS_KEY);
+ return authorizedDapps;
+};
+
+export const authorizedDappsList = new Subject();
+
+const authorizedDappsApi: AuthorizedDappService = {
+ authorizedDappsList,
+ removeAuthorizedDapp: async (origin: Origin): Promise => {
+ console.debug(`revoking access for ${origin}`);
+ const accessRevoked = await authenticator.revokeAccess(origin);
+ if (accessRevoked) {
+ const { authorizedDapps }: AuthorizedDappStorage = await webStorage.local.get(AUTHORIZED_DAPPS_KEY);
+ const updated = authorizedDapps.filter((d) => d.url !== origin);
+ authorizedDappsList.next(updated);
+ webStorage.local.set({ authorizedDapps: updated });
+ }
+ return accessRevoked;
+ }
+};
+
+export const dappServiceProperties = {
+ authorizedDappsList: RemoteApiPropertyType.HotObservable,
+ removeAuthorizedDapp: RemoteApiPropertyType.MethodReturningPromise
+};
+
+exposeApi(
+ {
+ api$: of(authorizedDappsApi),
+ baseChannel: DAPP_CHANNELS.authorizedDapps,
+ properties: dappServiceProperties
+ },
+ { logger: console, runtime }
+);
+
+export interface UserPromptService {
+ allowOrigin(origin: Origin): Promise<'allow' | 'just-once' | 'deny'>;
+ allowSignData(): Promise;
+ allowSignTx(): Promise;
+}
+
+export const userPromptService = consumeRemoteApi(
+ {
+ baseChannel: DAPP_CHANNELS.userPrompt,
+ properties: {
+ allowOrigin: RemoteApiPropertyType.MethodReturningPromise,
+ allowSignData: RemoteApiPropertyType.MethodReturningPromise,
+ allowSignTx: RemoteApiPropertyType.MethodReturningPromise
+ }
+ },
+ { logger: console, runtime }
+);
+
+try {
+ getAuthorizedDappListFromStorage()
+ .then((dapps) => {
+ authorizedDappsList.next(dapps);
+ })
+ .catch((error) => {
+ throw new Error(error);
+ });
+} catch (error) {
+ console.log(error);
+}
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/services/index.ts b/apps/browser-extension-wallet/src/lib/scripts/background/services/index.ts
new file mode 100644
index 0000000000..ce5999c037
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/services/index.ts
@@ -0,0 +1,2 @@
+export * from './dappService';
+export * from './utilityServices';
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts b/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts
new file mode 100644
index 0000000000..71e50d247a
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts
@@ -0,0 +1,205 @@
+import { runtime, tabs, storage as webStorage } from 'webextension-polyfill';
+import {
+ BackgroundService,
+ BaseChannels,
+ BrowserViewSections,
+ ChangeThemeData,
+ Message,
+ MessageTypes,
+ OpenBrowserData,
+ MigrationState,
+ TokenPrices,
+ CoinPrices
+} from '../../types';
+import { Subject, of, BehaviorSubject } from 'rxjs';
+import { walletRoutePaths } from '@routes/wallet-paths';
+import { backgroundServiceProperties } from '../config';
+import { exposeApi } from '@cardano-sdk/web-extension';
+import { Cardano, Asset } from '@cardano-sdk/core';
+import { config } from '@src/config';
+import {
+ setBackgroundStorage,
+ clearBackgroundStorage,
+ getBackgroundStorage,
+ convertToAssetName,
+ getADAPriceFromBackgroundStorage
+} from '../util';
+import { currencies as currenciesMap, currencyCode } from '@providers/currency/constants';
+import { tokenInLovelacePrices } from '@utils/token-prices-lovelace-list';
+
+export const requestMessage$ = new Subject();
+export const backendFailures$ = new BehaviorSubject(0);
+
+const coinPrices: CoinPrices = {
+ adaPrices$: new BehaviorSubject({
+ prices: {},
+ status: 'idle'
+ }),
+ tokenPrices$: new BehaviorSubject({
+ tokens: new Map(),
+ status: 'idle'
+ })
+};
+
+const migrationState$ = new BehaviorSubject(undefined);
+let walletPassword: Uint8Array;
+
+const handleOpenBrowser = async (data: OpenBrowserData) => {
+ let path = '';
+ switch (data.section) {
+ case BrowserViewSections.SEND_ADVANCED:
+ path = '';
+ await setBackgroundStorage({ message: { type: MessageTypes.OPEN_BROWSER_VIEW, data } });
+ break;
+ case BrowserViewSections.STAKING:
+ path = walletRoutePaths.staking;
+ break;
+ case BrowserViewSections.NFTS:
+ path = walletRoutePaths.nfts;
+ break;
+ case BrowserViewSections.TRANSACTION:
+ path = walletRoutePaths.activity;
+ break;
+ case BrowserViewSections.ADDRESS_BOOK:
+ path = walletRoutePaths.addressBook;
+ break;
+ case BrowserViewSections.SETTINGS:
+ path = walletRoutePaths.settings;
+ break;
+ case BrowserViewSections.COLLATERAL_SETTINGS:
+ path = walletRoutePaths.settings;
+ await setBackgroundStorage({ message: { type: MessageTypes.OPEN_COLLATERAL_SETTINGS, data } });
+ break;
+ case BrowserViewSections.FORGOT_PASSWORD:
+ path = walletRoutePaths.setup.restore;
+ break;
+ }
+ await tabs.create({ url: `app.html#${path}` }).catch((error) => console.log(error));
+};
+
+const handleChangeTheme = (data: ChangeThemeData) => requestMessage$.next({ type: MessageTypes.CHANGE_THEME, data });
+
+const { ADA_PRICE_CHECK_INTERVAL, SAVED_PRICE_DURATION } = config();
+const fetchTokenPrices = () => {
+ fetch('https://analyticsv2.muesliswap.com/ticker')
+ .then(async (response) => {
+ const prices: Record =
+ await response.json();
+ const tokenPrices: TokenPrices = new Map();
+
+ for (const [key, priceInfo] of Object.entries(prices)) {
+ // the key is a concatenation of policy id + . + decoded asset name + _ADA, so we need to split it to get policy id and asset name
+ const [policy, assetNameAsHex] = key.split('.');
+ // get decoded asset name + _ADA in index 1 and split it to get asset name
+ const strAssetName = assetNameAsHex.split('_')[0];
+ // if for some reason we couldn't get any of this field jump to the next token
+ if (!policy || !strAssetName) continue;
+ // to be able to convert this to a type asset name first we need to convert it to hexadecimal
+ const assetName = convertToAssetName(strAssetName);
+ if (!assetName) continue;
+ const policyId = Cardano.PolicyId(policy);
+ // get the asset id to use as key for tokenPrices Map
+ const assetId = Asset.util.assetIdFromPolicyAndName(policyId, assetName);
+ // it is possible for the price to come as NA so we need check this
+ const price = priceInfo.last_price === 'NA' ? 0 : (priceInfo.last_price as number);
+
+ // eslint-disable-next-line no-magic-numbers
+ const priceInAda = tokenInLovelacePrices[assetId] ? price / 1_000_000 : price; // check if the price is in lovelace
+
+ tokenPrices.set(assetId, {
+ id: key,
+ priceInAda,
+ priceVariationPercentage24h: priceInfo.price_change
+ });
+ }
+
+ coinPrices.tokenPrices$.next({ tokens: tokenPrices, status: 'fetched' });
+ })
+ .catch((error) => {
+ console.log('Error fetching coin prices:', error);
+ coinPrices.tokenPrices$.next({ ...coinPrices.tokenPrices$.value, status: 'error' });
+ });
+};
+
+const fetchAdaPrice = () => {
+ const vsCurrencies =
+ (Object.keys(currenciesMap) as currencyCode[]).map((code) => code.toLowerCase()).join(',') || 'usd';
+ fetch(
+ `https://api.coingecko.com/api/v3/simple/price?ids=cardano&vs_currencies=${vsCurrencies}&include_24hr_change=true`
+ )
+ .then(async (response) => {
+ const { cardano: prices } = await response.json();
+ // save the last fetched ada price in background storage
+ await setBackgroundStorage({
+ fiatPrices: {
+ prices,
+ timestamp: Date.now()
+ }
+ });
+ coinPrices.adaPrices$.next({
+ prices,
+ status: 'fetched'
+ });
+ })
+ .catch(async (error) => {
+ console.log('Error fetching coin prices:', error);
+ // If for some reason we couldn't fetch the ada price, get it from background store
+ const adaPrice = await getADAPriceFromBackgroundStorage();
+ if (!adaPrice) return coinPrices.adaPrices$.next({ prices: {}, status: 'error', timestamp: undefined });
+
+ const { prices, timestamp } = adaPrice;
+
+ const currentDate = Date.now();
+ const timePassedSinceLastSaved = currentDate - timestamp;
+ // eslint-disable-next-line no-magic-numbers
+ const timePassedInMinutes = Math.floor(timePassedSinceLastSaved / 60_000);
+ // We need this in case if the wallet is opened after a long period of time and the value is too old to be used
+ // in that case we omit the saved value
+ // we can set this period of time with an env variable, by default is 720 minutes
+ const shouldSetPriceValues = timePassedInMinutes < SAVED_PRICE_DURATION;
+ const nextPriceValues = shouldSetPriceValues ? prices : {};
+ const nextTimestamp = shouldSetPriceValues ? timestamp : undefined;
+ return coinPrices.adaPrices$.next({ prices: nextPriceValues, status: 'error', timestamp: nextTimestamp });
+ });
+};
+
+fetchAdaPrice();
+setInterval(fetchAdaPrice, ADA_PRICE_CHECK_INTERVAL);
+
+/* TODO: before enable token price fetching, we need this ticket https://input-output.atlassian.net/browse/ADP-2821 to be resolved
+ once we have this resolved, we can enable token price fetching by adding USE_TOKEN_PRINCING=true to the environment variables
+*/
+if (process.env.USE_TOKEN_PRICING === 'true') {
+ fetchTokenPrices();
+ setInterval(fetchTokenPrices, ADA_PRICE_CHECK_INTERVAL);
+}
+
+exposeApi(
+ {
+ api$: of({
+ handleOpenBrowser,
+ requestMessage$,
+ migrationState$,
+ coinPrices,
+ handleChangeTheme,
+ clearBackgroundStorage,
+ getBackgroundStorage,
+ setBackgroundStorage,
+ getWalletPassword: () => {
+ if (!walletPassword) throw new Error('Missing password');
+ return walletPassword;
+ },
+ setWalletPassword: (password?: Uint8Array) => {
+ walletPassword = password;
+ },
+ resetStorage: async () => {
+ await clearBackgroundStorage();
+ await webStorage.local.set({ MIGRATION_STATE: { state: 'up-to-date' } as MigrationState });
+ },
+ backendFailures$
+ }),
+ baseChannel: BaseChannels.BACKGROUND_ACTIONS,
+ properties: backgroundServiceProperties
+ },
+ { logger: console, runtime }
+);
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/util.ts b/apps/browser-extension-wallet/src/lib/scripts/background/util.ts
new file mode 100644
index 0000000000..b79859b3d0
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/util.ts
@@ -0,0 +1,180 @@
+/* eslint-disable no-magic-numbers */
+import { Origin } from '@cardano-sdk/dapp-connector';
+import { POPUP_WINDOW } from '@src/utils/constants';
+import { getRandomIcon } from '@src/utils/get-random-icon';
+import { runtime, Tabs, tabs, Windows, windows, storage as webStorage } from 'webextension-polyfill';
+import { Wallet } from '@lace/cardano';
+import { BackgroundStorage, BackgroundStorageKeys, MigrationState } from '../types';
+
+type WindowPosition = {
+ top: number;
+ left: number;
+};
+
+type WindowSize = {
+ height: number;
+ width: number;
+};
+
+type WindowSizeAndPositionProps = WindowPosition & WindowSize;
+
+export const INITIAL_STORAGE = { MIGRATION_STATE: { state: 'not-loaded' } as MigrationState };
+
+/**
+ * Gets the background storage content
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const getBackgroundStorage = async (): Promise => {
+ const { BACKGROUND_STORAGE } = await webStorage.local.get('BACKGROUND_STORAGE');
+ return BACKGROUND_STORAGE;
+};
+
+export const getADAPriceFromBackgroundStorage = async (): Promise => {
+ const backgroundStorage = await getBackgroundStorage();
+ return backgroundStorage?.fiatPrices;
+};
+
+/**
+ * Deletes the specified `keys` from the background storage.
+ *
+ * If no `keys` are passed then **ALL** of it is cleared.
+ *
+ * @param keys Optional. List of keys to delete from storage
+ */
+export const clearBackgroundStorage = async (keys?: BackgroundStorageKeys[]): Promise => {
+ if (!keys?.length) {
+ await webStorage.local.remove('BACKGROUND_STORAGE');
+ return;
+ }
+ const backgroundStorage = await getBackgroundStorage();
+
+ for (const key of keys) delete backgroundStorage?.[key];
+ await webStorage.local.set({ BACKGROUND_STORAGE: backgroundStorage ?? {} });
+};
+
+/**
+ * Adds content to the background storage. Does not replace it.
+ */
+export const setBackgroundStorage = async (data: BackgroundStorage): Promise => {
+ const backgroundStorage = await getBackgroundStorage();
+
+ await webStorage.local.set({ BACKGROUND_STORAGE: { ...backgroundStorage, ...data } });
+};
+
+/**
+ * Initialize MIGRATION_STATE
+ */
+export const initMigrationState = async (): Promise => {
+ await webStorage.local.set(INITIAL_STORAGE);
+};
+
+/**
+ * getDappInfo
+ * @param origin - URL of website calling dApp connector
+ * @returns {Promise}
+ */
+export const getDappInfo = async (origin: Origin): Promise =>
+ await tabs.query({ url: `${origin}/*` }).then((t) => ({
+ logo: t[0].favIconUrl || getRandomIcon({ id: origin, size: 40 }),
+ name: t[0].title || origin.split('//')[1],
+ url: origin
+ }));
+
+const calculatePopupWindowPositionAndSize = (
+ window: Windows.Window,
+ popup: WindowSize
+): WindowSizeAndPositionProps => ({
+ // eslint-disable-next-line no-magic-numbers
+ top: Math.floor(window.top + (window.height - POPUP_WINDOW.height) / 2),
+ // eslint-disable-next-line no-magic-numbers
+ left: Math.floor(window.left + (window.width - POPUP_WINDOW.width) / 2),
+ ...popup
+});
+
+const createTab = async (url: string, active = false) =>
+ tabs.create({
+ url: runtime.getURL(url),
+ active
+ });
+
+const createWindow = (
+ tabId: number,
+ windowSize: WindowSizeAndPositionProps,
+ type: Windows.CreateType,
+ focused = false
+) =>
+ windows.create({
+ tabId,
+ type,
+ focused,
+ ...windowSize
+ });
+
+/**
+ * launchCip30Popup
+ * @param url - Originating url of current dapp
+ * @returns tab - Tab of currently launched dApp connector
+ */
+export const launchCip30Popup = async (url: string): Promise => {
+ const currentWindow = await windows.getCurrent();
+ const tab = await createTab(`../dappConnector.html${url}`);
+ const newWindow = await createWindow(
+ tab.id,
+ calculatePopupWindowPositionAndSize(currentWindow, POPUP_WINDOW),
+ 'popup',
+ true
+ );
+ newWindow.alwaysOnTop = true;
+ return tab;
+};
+
+const waitForTabLoad = (tab: Tabs.Tab) =>
+ // eslint-disable-next-line promise/avoid-new
+ new Promise((resolve) => {
+ const listener = (tabId: number, changeInfo: Tabs.OnUpdatedChangeInfoType) => {
+ // make sure the status is 'complete' and it's the right tab
+ if (tabId === tab.id && changeInfo.status === 'complete') {
+ tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ };
+ tabs.onUpdated.addListener(listener);
+ });
+
+export const ensureUiIsOpenAndLoaded = async (url?: string): Promise => {
+ // Close all preeviously opened cip30 popups
+ const openTabs = await tabs.query({ windowType: 'popup' });
+ for (const tab of openTabs) {
+ windows.remove(tab.windowId);
+ }
+
+ const tab = await launchCip30Popup(url);
+ if (tab.status !== 'complete') {
+ await waitForTabLoad(tab);
+ }
+ return tab;
+};
+
+export const getWalletName = (): string => {
+ if (!process.env.WALLET_NAME) {
+ throw new Error('No wallet name declared in .env');
+ }
+ return `${process.env.WALLET_NAME}`;
+};
+
+export const getLastActiveTab: () => Promise = async () =>
+ await (
+ await tabs.query({ currentWindow: true, active: true })
+ )[0];
+
+export const convertToAssetName = (str: string): Wallet.Cardano.AssetName => {
+ try {
+ const hexAssetName = Buffer.from(str).toString('hex');
+ return Wallet.Cardano.AssetName(hexAssetName);
+ } catch (error) {
+ // the api has inconsistent data, not all assets name comes decoded
+ console.log(`unable to parse asset name: ${error.message}`);
+ }
+
+ return '' as unknown as Wallet.Cardano.AssetName;
+};
diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/wallet.ts b/apps/browser-extension-wallet/src/lib/scripts/background/wallet.ts
new file mode 100644
index 0000000000..2953a568d0
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/background/wallet.ts
@@ -0,0 +1,61 @@
+import { runtime, storage as webStorage } from 'webextension-polyfill';
+import { of } from 'rxjs';
+import { getProviders } from './config';
+import { SingleAddressWallet, storage } from '@cardano-sdk/wallet';
+
+import {
+ StoresFactory,
+ WalletFactory,
+ WalletManagerActivateProps,
+ WalletManagerWorker,
+ exposeApi,
+ walletManagerChannel,
+ walletManagerProperties
+} from '@cardano-sdk/web-extension';
+import { config } from '@src/config';
+import { Wallet } from '@lace/cardano';
+
+const logger = console;
+
+const walletFactory: WalletFactory = {
+ create: async (
+ props: WalletManagerActivateProps,
+ dependencies: { keyAgent: Wallet.KeyManagement.AsyncKeyAgent; stores: storage.WalletStores }
+ ) => {
+ const chainName: Wallet.ChainName =
+ props.provider?.type === (Wallet.WalletManagerProviderTypes.CARDANO_SERVICES_PROVIDER as unknown as string)
+ ? (props.provider.options as { chainName: Wallet.ChainName }).chainName
+ : config().CHAIN;
+ const providers = getProviders(chainName);
+
+ return new SingleAddressWallet(
+ { name: props.observableWalletName },
+ {
+ keyAgent: dependencies.keyAgent,
+ logger,
+ ...providers,
+ stores: dependencies.stores
+ }
+ );
+ }
+};
+
+const storesFactory: StoresFactory = {
+ create: ({ walletId: observableWalletName }) => storage.createPouchDbWalletStores(observableWalletName, { logger })
+};
+
+const walletManager = new WalletManagerWorker(
+ { walletName: process.env.WALLET_NAME },
+ { logger, runtime, storesFactory, walletFactory, managerStorage: webStorage.local }
+);
+
+exposeApi(
+ {
+ api$: of(walletManager),
+ baseChannel: walletManagerChannel(process.env.WALLET_NAME),
+ properties: walletManagerProperties
+ },
+ { logger, runtime }
+);
+
+export const wallet$ = (() => walletManager.activeWallet$)();
diff --git a/apps/browser-extension-wallet/src/lib/scripts/keep-alive-ui.ts b/apps/browser-extension-wallet/src/lib/scripts/keep-alive-ui.ts
new file mode 100644
index 0000000000..eaac0bccf9
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/keep-alive-ui.ts
@@ -0,0 +1,6 @@
+import { runtime } from 'webextension-polyfill';
+
+(function connect() {
+ const port = runtime.connect({ name: 'keepAlive' });
+ port.onDisconnect.addListener(connect);
+})();
diff --git a/apps/browser-extension-wallet/src/lib/scripts/migrations/__tests__/migrations.test.ts b/apps/browser-extension-wallet/src/lib/scripts/migrations/__tests__/migrations.test.ts
new file mode 100644
index 0000000000..ca834aa78b
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/migrations/__tests__/migrations.test.ts
@@ -0,0 +1,391 @@
+/* eslint-disable camelcase */
+/* eslint-disable no-magic-numbers */
+/* eslint-disable sonarjs/no-duplicate-string */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable unicorn/no-useless-undefined */
+import { MigrationState } from '@lib/scripts/types';
+import { Manifest, runtime, storage } from 'webextension-polyfill';
+import { applyMigrations, checkMigrations, Migration, migrationsRequirePassword } from '../migrations';
+
+const windowReload = jest.fn();
+const mockPersistanceFunctions = (implementations?: {
+ prepareImpl: () => any;
+ assertImpl: () => any;
+ persistImpl: () => any;
+ rollbackImpl: () => any;
+}) => ({
+ prepare: jest.fn(implementations?.prepareImpl),
+ assert: jest.fn(implementations?.assertImpl),
+ persist: jest.fn(implementations?.persistImpl),
+ rollback: jest.fn(implementations?.rollbackImpl)
+});
+const mockMigration = (version: string) => ({
+ version,
+ upgradeReturn: mockPersistanceFunctions(),
+ downgradeReturn: mockPersistanceFunctions()
+});
+
+const buildMigrations = (): {
+ migrations: Migration[];
+ getMigrationMocks: (version: string) => ReturnType;
+} => {
+ const migrations: Migration[] = [];
+ const migrationMocks: ReturnType[] = [];
+
+ ['1.0.0', '2.0.0', '3.0.0', '3.1.0', '3.1.5'].forEach((version) => {
+ const mocked = mockMigration(version);
+
+ migrations.push({
+ version,
+ upgrade: jest.fn(() => mocked.upgradeReturn),
+ downgrade: jest.fn(() => mocked.downgradeReturn)
+ });
+ migrationMocks.push(mocked);
+ });
+ return { migrations, getMigrationMocks: (version) => migrationMocks.find((mock) => mock.version === version) };
+};
+
+describe('migrations', () => {
+ let mockMigrations: Migration[] = [];
+ let getMigrationMocks: (version: string) => ReturnType;
+ let windowSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ const mocked = buildMigrations();
+ mockMigrations = mocked.migrations;
+ getMigrationMocks = mocked.getMigrationMocks;
+ windowSpy = jest
+ .spyOn(window, 'window', 'get')
+ .mockImplementation(
+ () => ({ location: { reload: windowReload } as unknown as Location } as unknown as Window & typeof globalThis)
+ );
+ });
+
+ afterEach(async () => {
+ windowSpy.mockRestore();
+ await storage.local.clear();
+ });
+
+ describe('applyMigrations', () => {
+ describe('do not apply', () => {
+ test.each([
+ ['up-to-date', undefined, undefined],
+ ['not-loaded', undefined, undefined],
+ ['error', '0.0.1', '3.1.5']
+ ])('when migrationState is %s', async (state, from, to) => {
+ await applyMigrations({ state: state as any, from, to }, undefined, mockMigrations);
+ mockMigrations.forEach((migration) => {
+ expect(migration.upgrade).not.toHaveBeenCalled();
+ expect(migration.downgrade).not.toHaveBeenCalled();
+ });
+ });
+
+ test('when there are no migrations between from and to', async () => {
+ await applyMigrations({ state: 'not-applied', from: '1.4.0', to: '1.6.0' }, undefined, mockMigrations);
+ mockMigrations.forEach((migration) => {
+ expect(migration.upgrade).not.toHaveBeenCalled();
+ expect(migration.downgrade).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('apply', () => {
+ test('all available upgrades when migrating from an older version to a newer one', async () => {
+ await applyMigrations({ state: 'not-applied', from: '1.0.0', to: '3.1.0' }, 'password', mockMigrations);
+ const shouldApply = mockMigrations.filter((migration) =>
+ ['2.0.0', '3.0.0', '3.1.0'].includes(migration.version)
+ );
+ const shouldNotApply = mockMigrations.filter(
+ (migration) => !['2.0.0', '3.0.0', '3.1.0'].includes(migration.version)
+ );
+ shouldApply.forEach((migration) => {
+ const { assert, persist, prepare, rollback } = getMigrationMocks(migration.version).upgradeReturn;
+ expect(migration.upgrade).toHaveBeenCalledWith('password');
+ expect(prepare).toHaveBeenCalledTimes(1);
+ expect(assert).toHaveBeenCalledTimes(1);
+ expect(assert.mock.invocationCallOrder[0]).toBeGreaterThan(prepare.mock.invocationCallOrder[0]);
+ expect(persist).toHaveBeenCalledTimes(1);
+ expect(persist.mock.invocationCallOrder[0]).toBeGreaterThan(assert.mock.invocationCallOrder[0]);
+ expect(rollback).not.toHaveBeenCalled();
+ expect(migration.downgrade).not.toHaveBeenCalled();
+ });
+ shouldNotApply.forEach((migration) => {
+ expect(migration.upgrade).not.toHaveBeenCalled();
+ expect(migration.downgrade).not.toHaveBeenCalled();
+ });
+ });
+ test('all available upgrades in ascending order according to version number', async () => {
+ const unsortedMigrations = [
+ { version: '2.0.0', upgrade: jest.fn(() => mockPersistanceFunctions()) },
+ { version: '3.0.0', upgrade: jest.fn(() => mockPersistanceFunctions()) },
+ { version: '1.0.0', upgrade: jest.fn(() => mockPersistanceFunctions()) }
+ ];
+ await applyMigrations({ state: 'not-applied', from: '0.0.9', to: '3.0.0' }, undefined, unsortedMigrations);
+ expect(unsortedMigrations[2].upgrade).toHaveBeenCalled();
+ expect(unsortedMigrations[0].upgrade).toHaveBeenCalled();
+ expect(unsortedMigrations[0].upgrade.mock.invocationCallOrder[0]).toBeGreaterThan(
+ unsortedMigrations[2].upgrade.mock.invocationCallOrder[0]
+ );
+ expect(unsortedMigrations[1].upgrade).toHaveBeenCalled();
+ expect(unsortedMigrations[1].upgrade.mock.invocationCallOrder[0]).toBeGreaterThan(
+ unsortedMigrations[0].upgrade.mock.invocationCallOrder[0]
+ );
+ });
+ test.todo('implement downgrades and test');
+
+ test('and skip migrations that should be skipped', async () => {
+ const withSkipMigrations = [...mockMigrations];
+ withSkipMigrations[2].shouldSkip = jest.fn().mockResolvedValue(true);
+ console.log(withSkipMigrations[2]);
+ await applyMigrations({ state: 'not-applied', from: '0.5.0', to: '3.1.5' }, undefined, withSkipMigrations);
+ const shouldApply = mockMigrations.filter((_, index) => index !== 2);
+ const shouldSkip = mockMigrations.filter((_, index) => index === 2);
+ shouldApply.forEach((migration) => {
+ expect(migration.upgrade).toHaveReturnedTimes(1);
+ });
+ shouldSkip.forEach((migration) => {
+ expect(migration.upgrade).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('rollback and retry', () => {
+ test('when an error occurs on prepare', async () => {
+ const mockUpgrade = {
+ prepare: jest
+ .fn()
+ .mockRejectedValueOnce(new Error('some error'))
+ .mockImplementationOnce(() => Promise.resolve(undefined)),
+ assert: jest.fn(),
+ persist: jest.fn(),
+ rollback: jest.fn()
+ };
+ const failingMigration: Migration = {
+ version: '10.0.0',
+ upgrade: () => mockUpgrade
+ };
+
+ await applyMigrations({ state: 'not-applied', from: '9.9.9', to: '10.0.0' }, undefined, [failingMigration]);
+ expect(mockUpgrade.prepare).toHaveBeenCalledTimes(2);
+ expect(mockUpgrade.assert).toHaveBeenCalledTimes(1);
+ expect(mockUpgrade.persist).toHaveBeenCalledTimes(1);
+ expect(mockUpgrade.rollback).toHaveBeenCalledTimes(1);
+ });
+
+ test('when an error occurs on assert', async () => {
+ const mockUpgrade = {
+ prepare: jest.fn(),
+ assert: jest
+ .fn()
+ .mockRejectedValueOnce(new Error('some error'))
+ .mockImplementationOnce(() => Promise.resolve(undefined)),
+ persist: jest.fn(),
+ rollback: jest.fn()
+ };
+ const failingMigration: Migration = {
+ version: '10.0.0',
+ upgrade: () => mockUpgrade
+ };
+
+ await applyMigrations({ state: 'not-applied', from: '9.9.9', to: '10.0.0' }, undefined, [failingMigration]);
+ expect(mockUpgrade.prepare).toHaveBeenCalledTimes(2);
+ expect(mockUpgrade.assert).toHaveBeenCalledTimes(2);
+ expect(mockUpgrade.persist).toHaveBeenCalledTimes(1);
+ expect(mockUpgrade.rollback).toHaveBeenCalledTimes(1);
+ });
+
+ test('when an error occurs on persist', async () => {
+ const mockUpgrade = {
+ prepare: jest.fn(),
+ assert: jest.fn(),
+ persist: jest
+ .fn()
+ .mockRejectedValueOnce(new Error('some error'))
+ .mockImplementationOnce(() => Promise.resolve(undefined)),
+ rollback: jest.fn()
+ };
+ const failingMigration: Migration = {
+ version: '10.0.0',
+ upgrade: () => mockUpgrade
+ };
+
+ await applyMigrations({ state: 'not-applied', from: '9.9.9', to: '10.0.0' }, undefined, [failingMigration]);
+ expect(mockUpgrade.prepare).toHaveBeenCalledTimes(2);
+ expect(mockUpgrade.assert).toHaveBeenCalledTimes(2);
+ expect(mockUpgrade.persist).toHaveBeenCalledTimes(2);
+ expect(mockUpgrade.rollback).toHaveBeenCalledTimes(1);
+ });
+
+ test('then set error state and do not throw when reached max attempts', async () => {
+ const mockUpgrade = {
+ prepare: jest.fn().mockRejectedValue(new Error('some error')),
+ assert: jest.fn(),
+ persist: jest.fn(),
+ rollback: jest.fn()
+ };
+ const failingMigration: Migration = {
+ version: '10.0.0',
+ upgrade: () => mockUpgrade
+ };
+
+ await expect(
+ applyMigrations({ state: 'not-applied', from: '9.9.9', to: '10.0.0' }, undefined, [failingMigration])
+ ).resolves.toBeUndefined();
+ expect(mockUpgrade.prepare).toHaveBeenCalledTimes(5);
+ expect(mockUpgrade.assert).toHaveBeenCalledTimes(0);
+ expect(mockUpgrade.persist).toHaveBeenCalledTimes(0);
+ expect(mockUpgrade.rollback).toHaveBeenCalledTimes(5);
+ expect(storage.local.set).toHaveBeenCalledWith({
+ MIGRATION_STATE: { state: 'error', from: '9.9.9', to: '10.0.0' }
+ });
+ });
+ });
+
+ describe('migration state changes', () => {
+ const initialState: MigrationState = { state: 'not-applied', from: '1.0.0', to: '3.0.0' };
+ beforeEach(async () => {
+ await storage.local.set(initialState);
+ (storage.local.set as jest.Mock).mockClear(); // clear mock so initial set is not count
+ });
+
+ afterEach(async () => {
+ await storage.local.clear();
+ });
+
+ describe('set to "migrating" state', () => {
+ test('when a migration starts', async () => {
+ await applyMigrations(initialState, undefined, mockMigrations);
+ expect(storage.local.set).toHaveBeenNthCalledWith(1, {
+ MIGRATION_STATE: { ...initialState, state: 'migrating' }
+ });
+ });
+
+ test('and update the "from" field when a migration is applied successfully', async () => {
+ await applyMigrations(initialState, undefined, mockMigrations);
+ expect(storage.local.set).toHaveBeenNthCalledWith(2, {
+ MIGRATION_STATE: { ...initialState, from: '2.0.0', state: 'migrating' }
+ });
+ expect(storage.local.set).toHaveBeenNthCalledWith(3, {
+ MIGRATION_STATE: { ...initialState, from: '3.0.0', state: 'migrating' }
+ });
+ });
+ });
+
+ describe('set to "up-to-date" state', () => {
+ test('when all migrations were applied successfully', async () => {
+ await applyMigrations(initialState, undefined, mockMigrations);
+ expect(storage.local.set).toHaveBeenLastCalledWith({
+ MIGRATION_STATE: { state: 'up-to-date' }
+ });
+ });
+ });
+
+ describe('set to "error" state', () => {
+ test('when all attempts to migrate failed', async () => {
+ const mockUpgrade = {
+ prepare: jest.fn().mockRejectedValue(new Error('some error')),
+ assert: jest.fn(),
+ persist: jest.fn(),
+ rollback: jest.fn()
+ };
+ const failingMigration: Migration = {
+ version: '2.0.0',
+ upgrade: () => mockUpgrade
+ };
+
+ await applyMigrations(initialState, undefined, [failingMigration]);
+ expect(storage.local.set).toHaveBeenLastCalledWith({
+ MIGRATION_STATE: { state: 'error', from: '1.0.0', to: '3.0.0' }
+ });
+ });
+ });
+ });
+ });
+ });
+ describe('migrationsRequirePassword', () => {
+ const noPasswordMigration: Migration = {
+ version: '4.0.0',
+ upgrade: jest.fn(),
+ requiresPassword: jest.fn(() => false)
+ };
+ const withPasswordMigration: Migration = {
+ version: '5.0.0',
+ upgrade: jest.fn(),
+ requiresPassword: jest.fn(() => true)
+ };
+ describe('is false', () => {
+ test.each([
+ ['up-to-date', undefined, undefined],
+ ['not-loaded', undefined, undefined],
+ ['error', '0.0.1', '3.1.5']
+ ])('when migrationState is %s', async (state, from, to) => {
+ await expect(
+ migrationsRequirePassword({ state: state as any, from, to }, [...mockMigrations, noPasswordMigration])
+ ).resolves.toBe(false);
+ expect(noPasswordMigration.requiresPassword).not.toHaveBeenCalled();
+ });
+
+ test('when there are no migrations between from and to', async () => {
+ await expect(
+ migrationsRequirePassword({ state: 'not-applied', from: '1.4.0', to: '1.6.0' }, [
+ ...mockMigrations,
+ noPasswordMigration
+ ])
+ ).resolves.toBe(false);
+ expect(noPasswordMigration.requiresPassword).not.toHaveBeenCalled();
+ });
+
+ test('when requiresPassword returns false or is not defined for all migrations', async () => {
+ await expect(
+ migrationsRequirePassword({ state: 'not-applied', from: '0.0.1', to: '4.0.0' }, [
+ ...mockMigrations,
+ noPasswordMigration
+ ])
+ ).resolves.toBe(false);
+ expect(noPasswordMigration.requiresPassword).toHaveBeenCalled();
+ });
+ });
+ describe('is true', () => {
+ test('when some migration requires the password', async () => {
+ await expect(
+ migrationsRequirePassword({ state: 'not-applied', from: '0.0.1', to: '5.0.0' }, [
+ ...mockMigrations,
+ noPasswordMigration,
+ withPasswordMigration
+ ])
+ ).resolves.toBe(true);
+ expect(noPasswordMigration.requiresPassword).toHaveReturnedWith(false);
+ expect(withPasswordMigration.requiresPassword).toHaveReturnedWith(true);
+ });
+ });
+ });
+ describe('checkMigrations', () => {
+ beforeAll(() => {
+ runtime.getManifest = jest.fn(() => ({ version: '3.0.0' } as Manifest.ManifestBase));
+ });
+ afterAll(() => {
+ delete runtime.getManifest;
+ });
+ describe('set migration state to "up-to-date"', () => {
+ // TODO: change when downgrades implemented [LW-5595]
+ test('when is a downgrade', async () => {
+ await checkMigrations('4.0.0', mockMigrations);
+ expect(storage.local.set).toHaveBeenCalledWith({ MIGRATION_STATE: { state: 'up-to-date' } });
+ });
+
+ test('when no upgrades found between previous and current versions', async () => {
+ await checkMigrations('2.9.9', mockMigrations);
+ expect(storage.local.set).toHaveBeenCalledWith({ MIGRATION_STATE: { state: 'up-to-date' } });
+ });
+ });
+
+ describe('set migration state to "not-applied"', () => {
+ // TODO: add downgrade case when implemented [LW-5595]
+ test('when at least one upgrade was found between previous and current versions', async () => {
+ await checkMigrations('1.0.0', mockMigrations);
+ expect(storage.local.set).toHaveBeenCalledWith({
+ MIGRATION_STATE: { state: 'not-applied', from: '1.0.0', to: '3.0.0' }
+ });
+ });
+ });
+ });
+});
diff --git a/apps/browser-extension-wallet/src/lib/scripts/migrations/__tests__/util.test.ts b/apps/browser-extension-wallet/src/lib/scripts/migrations/__tests__/util.test.ts
new file mode 100644
index 0000000000..3f2a338a5f
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/migrations/__tests__/util.test.ts
@@ -0,0 +1,45 @@
+/* eslint-disable unicorn/no-useless-undefined */
+import { compareVersions } from '../util';
+
+describe('migrations utils', () => {
+ describe('compareVersions', () => {
+ describe('returns 1', () => {
+ test('when v1 is greater than v2', () => {
+ const compareByX = compareVersions('1.2.3', '0.2.3');
+ const compareByY = compareVersions('0.3.3', '0.2.3');
+ const compareByZ = compareVersions('0.2.4', '0.2.3');
+
+ expect(compareByX).toEqual(1);
+ expect(compareByY).toEqual(1);
+ expect(compareByZ).toEqual(1);
+ });
+
+ test('when there is v1 but no v2', () => {
+ expect(compareVersions('1.2.3', undefined)).toEqual(1);
+ });
+ });
+ describe('returns -1', () => {
+ test('when v1 is lower than v2', () => {
+ const compareByX = compareVersions('0.2.3', '1.2.3');
+ const compareByY = compareVersions('0.2.3', '0.3.3');
+ const compareByZ = compareVersions('0.2.3', '0.2.4');
+
+ expect(compareByX).toEqual(-1);
+ expect(compareByY).toEqual(-1);
+ expect(compareByZ).toEqual(-1);
+ });
+ test('when there is v2 but no v1', () => {
+ expect(compareVersions(undefined, '1.2.3')).toEqual(-1);
+ });
+ });
+ describe('returns 0', () => {
+ test('when v1 and v2 are equal', () => {
+ expect(compareVersions('1.2.3', '1.2.3')).toEqual(0);
+ });
+
+ test('when both v1 and v2 are not defined', () => {
+ expect(compareVersions(undefined, undefined)).toEqual(0);
+ });
+ });
+ });
+});
diff --git a/apps/browser-extension-wallet/src/lib/scripts/migrations/__tests__/versions/v0_6_0.test.ts b/apps/browser-extension-wallet/src/lib/scripts/migrations/__tests__/versions/v0_6_0.test.ts
new file mode 100644
index 0000000000..3533320794
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/migrations/__tests__/versions/v0_6_0.test.ts
@@ -0,0 +1,420 @@
+/* eslint-disable sonarjs/no-identical-functions */
+/* eslint-disable unicorn/no-null */
+/* eslint-disable sonarjs/no-duplicate-string */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable no-magic-numbers */
+/* eslint-disable camelcase */
+import { Wallet } from '@lace/cardano';
+import { PromiseResolvedType } from '@src/types';
+import { mockKeyAgentDataTestnet, mockKeyAgentsByChain, mockWalletInfoTestnet } from '@src/utils/mocks/test-helpers';
+import { storage } from 'webextension-polyfill';
+import { InvalidMigrationData } from '../../errors';
+import { MigrationPersistance } from '../../migrations';
+import { v0_6_0 } from '../../versions';
+
+const encryptData = async (data: any, password: string) =>
+ Wallet.KeyManagement.emip3encrypt(Buffer.from(JSON.stringify(data)), Buffer.from(password));
+
+interface OldWalletInfo {
+ keyAgentData?: any;
+ name?: string;
+ address?: any;
+ rewardAccount?: any;
+ walletId?: string;
+}
+interface OldAppSettings {
+ chainId?: string;
+ mnemonicVerificationFrequency?: string;
+ lastMnemonicVerification?: string;
+}
+interface OldStorage {
+ walletPassword?: string;
+ walletName?: string;
+ keyAgentType?: Wallet.KeyManagement.KeyAgentType;
+ appSettings?: OldAppSettings;
+ walletInfoData?: OldWalletInfo;
+ lock?: false | Uint8Array | null;
+}
+
+const mockInitialStorage = async ({
+ walletPassword = 'pass',
+ walletName = 'test wallet',
+ appSettings = { chainId: 'Preprod' },
+ keyAgentType = Wallet.KeyManagement.KeyAgentType.InMemory,
+ walletInfoData = {
+ keyAgentData: { ...mockKeyAgentDataTestnet, __typename: keyAgentType },
+ name: walletName,
+ address: mockWalletInfoTestnet.address,
+ rewardAccount: mockWalletInfoTestnet.rewardAccount,
+ walletId: 'abcdef1234567890'
+ },
+ lock
+}: OldStorage = {}): Promise => {
+ const oldLock = !lock && lock !== null ? await encryptData(walletInfoData, walletPassword) : null;
+ localStorage.setItem('wallet', JSON.stringify(walletInfoData));
+ localStorage.setItem('appSettings', JSON.stringify(appSettings));
+ localStorage.setItem('lock', JSON.stringify(oldLock));
+ const name = walletInfoData.name ?? walletName;
+ await storage.local.set({ BACKGROUND_STORAGE: { walletName: name } });
+ const agentType = walletInfoData.keyAgentData.__typename ?? keyAgentType;
+ return { walletPassword, walletInfoData, lock: oldLock, walletName: name, appSettings, keyAgentType: agentType };
+};
+
+const mockTmpStorage = async (initialStorage: OldStorage) => {
+ let newLock = null;
+ const newAppSettings = JSON.stringify({
+ chainName: initialStorage.appSettings.chainId,
+ lastMnemonicVerification: initialStorage.appSettings.lastMnemonicVerification,
+ mnemonicVerificationFrequency: initialStorage.appSettings.mnemonicVerificationFrequency
+ });
+ const newWalletInfo = JSON.stringify({ name: initialStorage.walletName });
+ const newKeyAgentData = JSON.stringify(initialStorage.walletInfoData.keyAgentData);
+ const newKeyAgentsByChain = { BACKGROUND_STORAGE: { keyAgentsByChain_tmp: mockKeyAgentsByChain } };
+ localStorage.setItem('appSettings_tmp', newAppSettings);
+ localStorage.setItem('wallet_tmp', newWalletInfo);
+ localStorage.setItem('keyAgentData_tmp', newKeyAgentData);
+ if (initialStorage.lock) {
+ newLock = JSON.stringify(await encryptData(mockKeyAgentsByChain, initialStorage.walletPassword));
+ localStorage.setItem('lock_tmp', newLock);
+ }
+ await storage.local.set(newKeyAgentsByChain);
+ return { newAppSettings, newWalletInfo, newKeyAgentData, newLock, newKeyAgentsByChain };
+};
+
+type TmpStorage = PromiseResolvedType;
+
+describe('v0.6.0 migration', () => {
+ let localStorageSetSpy: jest.SpyInstance;
+ let localStorageRemoveSpy: jest.SpyInstance;
+
+ beforeAll(() => {
+ localStorageSetSpy = jest.spyOn(Storage.prototype, 'setItem');
+ localStorageRemoveSpy = jest.spyOn(Storage.prototype, 'removeItem');
+ });
+
+ beforeEach(async () => {
+ localStorage.clear();
+ await storage.local.clear();
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe('requirePassword', () => {
+ test('is true if lock is in storage ', () => {
+ localStorage.setItem('lock', JSON.stringify(Buffer.from('asd')));
+ expect(v0_6_0.requiresPassword()).toBe(true);
+ });
+ test('is false if there is no lock in storage ', () => {
+ expect(v0_6_0.requiresPassword()).toBe(false);
+ });
+ });
+ describe('upgrade', () => {
+ describe('when no wallet exists', () => {
+ let migrationPersistance: MigrationPersistance;
+ beforeAll(async () => {
+ migrationPersistance = await v0_6_0.upgrade();
+ });
+ describe('prepare', () => {
+ test('does not save any information', async () => {
+ await migrationPersistance.prepare();
+ expect(localStorageSetSpy).not.toHaveBeenCalled();
+ expect(storage.local.set as jest.Mock).not.toHaveBeenCalled();
+ });
+ });
+ describe('assert', () => {
+ test('throws an error when there is temporary wallet data', async () => {
+ localStorage.setItem('keyAgentData_tmp', JSON.stringify(mockKeyAgentDataTestnet));
+ await expect(migrationPersistance.assert()).rejects.toThrow(
+ new InvalidMigrationData('0.6.0', 'Wallet data should not exist')
+ );
+ });
+ test('resolves to true if there is no temporary data', async () => {
+ await expect(migrationPersistance.assert()).resolves.toBe(true);
+ });
+ });
+ describe('persist', () => {
+ test('does not save any information but still try to clear tmp keys', async () => {
+ await migrationPersistance.persist();
+ expect(localStorageSetSpy).not.toHaveBeenCalled();
+ expect(localStorageRemoveSpy).toHaveBeenCalledTimes(4);
+ expect(localStorageRemoveSpy).toBeCalledWith('appSettings_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('wallet_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('keyAgentData_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('lock_tmp');
+ expect(storage.local.set as jest.Mock).toHaveBeenCalledTimes(2);
+ });
+ });
+ describe('rollback', () => {
+ test('does not save any information but still try to clear tmp and new keys', async () => {
+ await migrationPersistance.rollback();
+ expect(localStorageSetSpy).not.toHaveBeenCalled();
+ expect(localStorageRemoveSpy).toHaveBeenCalledTimes(5);
+ expect(localStorageRemoveSpy).toBeCalledWith('appSettings_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('wallet_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('keyAgentData');
+ expect(localStorageRemoveSpy).toBeCalledWith('keyAgentData_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('lock_tmp');
+ expect(storage.local.set as jest.Mock).toHaveBeenCalledTimes(2);
+ });
+ });
+ });
+ describe('when an InMemory wallet exists and is not locked', () => {
+ let migrationPersistance: MigrationPersistance;
+ let initialStorage: OldStorage;
+ beforeAll(async () => {
+ initialStorage = await mockInitialStorage();
+ migrationPersistance = await v0_6_0.upgrade(initialStorage.walletPassword);
+ });
+ describe('prepare', () => {
+ test('saves temporary storage with the updated information', async () => {
+ await migrationPersistance.prepare();
+ expect(localStorageSetSpy).toHaveBeenCalledTimes(4);
+ assertCalledWithArg(localStorageSetSpy, 'wallet_tmp', 0);
+ assertCalledWithArg(localStorageSetSpy, 'appSettings_tmp', 0);
+ assertCalledWithArg(localStorageSetSpy, 'keyAgentData_tmp', 0);
+ assertCalledWithArg(localStorageSetSpy, 'lock_tmp', 0);
+ expect(storage.local.set as jest.Mock).toHaveBeenCalledTimes(1);
+ });
+ });
+ describe('assert', () => {
+ beforeEach(async () => {
+ await mockTmpStorage(initialStorage);
+ jest.clearAllMocks();
+ });
+ test('resolves to true if all temporary data is valid', async () => {
+ await expect(migrationPersistance.assert()).resolves.toBe(true);
+ });
+ test('throws an error when there is no chainName in appSettings_tmp', async () => {
+ localStorage.setItem('appSettings_tmp', JSON.stringify({ chainId: 'Preprod' }));
+ await expect(migrationPersistance.assert()).rejects.toThrow(
+ new InvalidMigrationData('0.6.0', 'Missing chain name in app settings')
+ );
+ });
+ test('throws an error when keyAgentData_tmp is missing', async () => {
+ localStorage.removeItem('keyAgentData_tmp');
+ await expect(migrationPersistance.assert()).rejects.toThrow(
+ new InvalidMigrationData('0.6.0', 'Wallet data missing')
+ );
+ });
+ test('throws an error when wallet_tmp is missing', async () => {
+ localStorage.removeItem('wallet_tmp');
+ await expect(migrationPersistance.assert()).rejects.toThrow(
+ new InvalidMigrationData('0.6.0', 'Wallet data missing')
+ );
+ });
+ test('throws an error when keyAgentsByChain_tmp is missing', async () => {
+ await storage.local.set({ BACKGROUND_STORAGE: {} });
+ await expect(migrationPersistance.assert()).rejects.toThrow(
+ new InvalidMigrationData('0.6.0', 'Wallet data missing')
+ );
+ });
+ test('throws an error when there is no name in wallet_tmp', async () => {
+ localStorage.setItem('wallet_tmp', JSON.stringify({}));
+ await expect(migrationPersistance.assert()).rejects.toThrow(
+ new InvalidMigrationData('0.6.0', 'Missing name in wallet info')
+ );
+ });
+ test('throws an error when keyAgentData_tmp is missing a field', async () => {
+ const keyAgentData_tmp = { ...mockKeyAgentDataTestnet };
+ delete keyAgentData_tmp.__typename;
+ localStorage.setItem('keyAgentData_tmp', JSON.stringify(keyAgentData_tmp));
+ await expect(migrationPersistance.assert()).rejects.toThrow(
+ new InvalidMigrationData('0.6.0', 'Missing field in key agent data')
+ );
+ });
+ test('throws an error when keyAgentDataByChain_tmp is missing a chain', async () => {
+ await storage.local.set({
+ BACKGROUND_STORAGE: { keyAgentsByChain_tmp: { Preprod: mockKeyAgentDataTestnet } }
+ });
+ await expect(migrationPersistance.assert()).rejects.toThrow(
+ new InvalidMigrationData('0.6.0', 'Missing key agent for one or more chains')
+ );
+ });
+ test('throws an error when decrypted lock_tmp is not the same as keyAgentsByChain_tmp', async () => {
+ const wrongLock = await encryptData(mockWalletInfoTestnet, initialStorage.walletPassword);
+ localStorage.setItem('lock_tmp', JSON.stringify(wrongLock));
+ await expect(migrationPersistance.assert()).rejects.toThrow(
+ new InvalidMigrationData('0.6.0', 'Decrypted lock is not valid')
+ );
+ });
+ });
+ describe('persist', () => {
+ let tmpStorage: TmpStorage;
+ beforeEach(async () => {
+ tmpStorage = await mockTmpStorage(initialStorage);
+ jest.clearAllMocks();
+ });
+ test('saves all temporary information as permanent before clearing it', async () => {
+ await migrationPersistance.persist();
+ expect(localStorageSetSpy).toHaveBeenCalledTimes(4);
+ expect(localStorageSetSpy).toHaveBeenCalledWith('appSettings', tmpStorage.newAppSettings);
+ expect(localStorageSetSpy).toHaveBeenCalledWith('wallet', tmpStorage.newWalletInfo);
+ expect(localStorageSetSpy).toHaveBeenCalledWith('keyAgentData', tmpStorage.newKeyAgentData);
+ expect(localStorageSetSpy).toHaveBeenCalledWith('lock', tmpStorage.newLock);
+ expect(localStorageRemoveSpy).toHaveBeenCalledTimes(4);
+ expect(localStorageRemoveSpy).toBeCalledWith('appSettings_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('wallet_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('keyAgentData_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('lock_tmp');
+ expect(storage.local.set as jest.Mock).toHaveBeenCalledTimes(3);
+ });
+ });
+ describe('rollback', () => {
+ beforeEach(async () => {
+ await mockTmpStorage(initialStorage);
+ jest.clearAllMocks();
+ });
+ test('restores the original stored data and clears the temporary storage', async () => {
+ await migrationPersistance.rollback();
+ expect(localStorageSetSpy).toHaveBeenCalledTimes(3);
+ expect(localStorageSetSpy).toHaveBeenCalledWith('appSettings', JSON.stringify(initialStorage.appSettings));
+ expect(localStorageSetSpy).toHaveBeenCalledWith('wallet', JSON.stringify(initialStorage.walletInfoData));
+ expect(localStorageSetSpy).toHaveBeenCalledWith('lock', JSON.stringify(initialStorage.lock));
+ expect(localStorageRemoveSpy).toHaveBeenCalledTimes(5);
+ expect(localStorageRemoveSpy).toBeCalledWith('keyAgentData');
+ expect(localStorageRemoveSpy).toBeCalledWith('appSettings_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('wallet_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('keyAgentData_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('lock_tmp');
+ expect(storage.local.set as jest.Mock).toHaveBeenCalledTimes(3);
+ });
+ });
+ });
+ describe('when an InMemory wallet exists but it is locked', () => {
+ let migrationPersistance: MigrationPersistance;
+ let initialStorage: OldStorage;
+ beforeAll(async () => {
+ initialStorage = await mockInitialStorage({
+ walletInfoData: {
+ keyAgentData: mockKeyAgentDataTestnet,
+ address: mockWalletInfoTestnet.address,
+ rewardAccount: mockWalletInfoTestnet.rewardAccount,
+ walletId: 'abcdef1234567890'
+ }
+ });
+ storage.local.remove('wallet');
+ migrationPersistance = await v0_6_0.upgrade(initialStorage.walletPassword);
+ });
+ describe('prepare', () => {
+ test('saves wallet_tmp and keyAgentData_tmp with decrypted lock and name in background storage', async () => {
+ await migrationPersistance.prepare();
+ expect(localStorageSetSpy).toHaveBeenCalledTimes(4);
+ expect(localStorageSetSpy).toHaveBeenCalledWith(
+ 'wallet_tmp',
+ JSON.stringify({ name: initialStorage.walletName })
+ );
+ expect(localStorageSetSpy).toHaveBeenCalledWith(
+ 'keyAgentData_tmp',
+ JSON.stringify(initialStorage.walletInfoData.keyAgentData)
+ );
+ expect(storage.local.set as jest.Mock).toHaveBeenCalledTimes(1);
+ });
+ });
+ describe('assert', () => {
+ beforeEach(async () => {
+ await mockTmpStorage(initialStorage);
+ jest.clearAllMocks();
+ });
+ test('resolves to true if all temporary data is valid', async () => {
+ await expect(migrationPersistance.assert()).resolves.toBe(true);
+ });
+ });
+ describe('persist', () => {
+ beforeEach(async () => {
+ await mockTmpStorage(initialStorage);
+ jest.clearAllMocks();
+ });
+ test('saves all temporary information as permanent before clearing it', async () => {
+ await migrationPersistance.persist();
+ expect(localStorageSetSpy).toHaveBeenCalledTimes(4);
+ expect(localStorageRemoveSpy).toHaveBeenCalledTimes(4);
+ expect(storage.local.set as jest.Mock).toHaveBeenCalledTimes(3);
+ });
+ });
+ describe('rollback', () => {
+ beforeEach(async () => {
+ await mockTmpStorage(initialStorage);
+ jest.clearAllMocks();
+ });
+ test('restores the original stored data and clears the temporary storage', async () => {
+ await migrationPersistance.rollback();
+ expect(localStorageSetSpy).toHaveBeenCalledTimes(3);
+ expect(localStorageRemoveSpy).toHaveBeenCalledTimes(5);
+ expect(storage.local.set as jest.Mock).toHaveBeenCalledTimes(3);
+ });
+ });
+ });
+ describe('when a hardware wallet exists', () => {
+ let migrationPersistance: MigrationPersistance;
+ let initialStorage: OldStorage;
+ beforeAll(async () => {
+ initialStorage = await mockInitialStorage({
+ lock: null,
+ keyAgentType: Wallet.KeyManagement.KeyAgentType.Ledger
+ });
+ migrationPersistance = await v0_6_0.upgrade(initialStorage.walletPassword);
+ });
+ describe('prepare', () => {
+ test('saves temporary storage with the updated information', async () => {
+ await migrationPersistance.prepare();
+ expect(localStorageSetSpy).toHaveBeenCalledTimes(3);
+ assertCalledWithArg(localStorageSetSpy, 'wallet_tmp', 0);
+ assertCalledWithArg(localStorageSetSpy, 'appSettings_tmp', 0);
+ assertCalledWithArg(localStorageSetSpy, 'keyAgentData_tmp', 0);
+ expect(storage.local.set as jest.Mock).toHaveBeenCalledTimes(1);
+ });
+ });
+ describe('assert', () => {
+ beforeEach(async () => {
+ await mockTmpStorage(initialStorage);
+ jest.clearAllMocks();
+ });
+ test('resolves to true if all temporary data is valid', async () => {
+ await expect(migrationPersistance.assert()).resolves.toBe(true);
+ });
+ });
+ describe('persist', () => {
+ let tmpStorage: TmpStorage;
+ beforeEach(async () => {
+ tmpStorage = await mockTmpStorage(initialStorage);
+ jest.clearAllMocks();
+ });
+ test('saves all temporary information as permanent before clearing it', async () => {
+ await migrationPersistance.persist();
+ expect(localStorageSetSpy).toHaveBeenCalledTimes(3);
+ expect(localStorageSetSpy).toHaveBeenCalledWith('appSettings', tmpStorage.newAppSettings);
+ expect(localStorageSetSpy).toHaveBeenCalledWith('wallet', tmpStorage.newWalletInfo);
+ expect(localStorageSetSpy).toHaveBeenCalledWith('keyAgentData', tmpStorage.newKeyAgentData);
+ expect(localStorageRemoveSpy).toHaveBeenCalledTimes(4);
+ expect(localStorageRemoveSpy).toBeCalledWith('appSettings_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('wallet_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('keyAgentData_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('lock_tmp');
+ expect(storage.local.set as jest.Mock).toHaveBeenCalledTimes(3);
+ });
+ });
+ describe('rollback', () => {
+ beforeEach(async () => {
+ await mockTmpStorage(initialStorage);
+ jest.clearAllMocks();
+ });
+ test('restores the original stored data and clears the temporary storage', async () => {
+ await migrationPersistance.rollback();
+ expect(localStorageSetSpy).toHaveBeenCalledTimes(2);
+ expect(localStorageSetSpy).toHaveBeenCalledWith('appSettings', JSON.stringify(initialStorage.appSettings));
+ expect(localStorageSetSpy).toHaveBeenCalledWith('wallet', JSON.stringify(initialStorage.walletInfoData));
+ expect(localStorageRemoveSpy).toHaveBeenCalledTimes(5);
+ expect(localStorageRemoveSpy).toBeCalledWith('keyAgentData');
+ expect(localStorageRemoveSpy).toBeCalledWith('appSettings_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('wallet_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('keyAgentData_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('lock_tmp');
+ expect(storage.local.set as jest.Mock).toHaveBeenCalledTimes(3);
+ });
+ });
+ });
+ });
+});
diff --git a/apps/browser-extension-wallet/src/lib/scripts/migrations/__tests__/versions/v1_0_0.test.ts b/apps/browser-extension-wallet/src/lib/scripts/migrations/__tests__/versions/v1_0_0.test.ts
new file mode 100644
index 0000000000..7ff905c1e1
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/migrations/__tests__/versions/v1_0_0.test.ts
@@ -0,0 +1,224 @@
+/* eslint-disable sonarjs/no-identical-functions */
+/* eslint-disable no-magic-numbers */
+/* eslint-disable camelcase */
+import { PromiseResolvedType } from '@src/types';
+import { mockKeyAgentDataTestnet, mockKeyAgentsByChain } from '@src/utils/mocks/test-helpers';
+import { storage } from 'webextension-polyfill';
+import { InvalidMigrationData } from '../../errors';
+import { MigrationPersistance } from '../../migrations';
+import { v_1_0_0 } from '../../versions';
+
+const mockInitialStorage = async () => {
+ const keyAgentData = mockKeyAgentDataTestnet;
+ const appSettings = { lastMnemonicVerification: '', chainName: 'Preprod' };
+ const keyAgentsByChain = mockKeyAgentsByChain;
+ localStorage.setItem('keyAgentData', JSON.stringify(keyAgentData));
+ localStorage.setItem('appSettings', JSON.stringify(appSettings));
+ await storage.local.set({ BACKGROUND_STORAGE: { keyAgentsByChain } });
+ return { keyAgentData, appSettings, keyAgentsByChain };
+};
+
+describe('v1.0.0 migration', () => {
+ let localStorageSetSpy: jest.SpyInstance;
+ let localStorageRemoveSpy: jest.SpyInstance;
+
+ beforeAll(() => {
+ localStorageSetSpy = jest.spyOn(Storage.prototype, 'setItem');
+ localStorageRemoveSpy = jest.spyOn(Storage.prototype, 'removeItem');
+ });
+
+ beforeEach(async () => {
+ localStorage.clear();
+ await storage.local.clear();
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe('upgrade', () => {
+ describe('when there is no wallet and no app settings', () => {
+ let migrationPersistance: MigrationPersistance;
+ beforeAll(async () => {
+ migrationPersistance = await v_1_0_0.upgrade();
+ });
+ describe('prepare', () => {
+ test('does not save any temporary data', () => {
+ migrationPersistance.prepare();
+ expect(localStorageSetSpy).not.toHaveBeenCalled();
+ expect(storage.local.set as jest.Mock).not.toHaveBeenCalled();
+ });
+ });
+ describe('assert', () => {
+ test('returns true when there is no temporary data', () => {
+ expect(migrationPersistance.assert()).toBe(true);
+ });
+ });
+ describe('persist', () => {
+ test('does not update any information but still tries to clear temporary keys', () => {
+ migrationPersistance.persist();
+ expect(localStorageSetSpy).not.toHaveBeenCalled();
+ expect(localStorageRemoveSpy).toHaveBeenCalledTimes(2);
+ expect(localStorageRemoveSpy).toBeCalledWith('appSettings_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('keyAgentData_tmp');
+ });
+ });
+ describe('rollback', () => {
+ test('does not save any information but still tries to clear temporary keys', () => {
+ migrationPersistance.rollback();
+ expect(localStorageSetSpy).not.toHaveBeenCalled();
+ expect(localStorageRemoveSpy).toHaveBeenCalledTimes(2);
+ expect(localStorageRemoveSpy).toBeCalledWith('appSettings_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('keyAgentData_tmp');
+ });
+ });
+ });
+ describe('when there is a locked wallet and app settings', () => {
+ let migrationPersistance: MigrationPersistance;
+ beforeAll(async () => {
+ localStorage.setItem('appSettings', JSON.stringify({ lastMnemonicVerification: '', chainName: 'Preprod' }));
+ migrationPersistance = await v_1_0_0.upgrade();
+ });
+ describe('prepare', () => {
+ test('saves temporary appSettings with mainnet as chain', () => {
+ migrationPersistance.prepare();
+ expect(localStorageSetSpy).toHaveBeenCalledWith(
+ 'appSettings_tmp',
+ JSON.stringify({ lastMnemonicVerification: '', chainName: 'Mainnet' })
+ );
+ });
+ });
+ describe('assert', () => {
+ test('returns true when chainName in temporary appSettings is Mainnet', () => {
+ localStorage.setItem('appSettings_tmp', JSON.stringify({ chainName: 'Mainnet' }));
+ expect(migrationPersistance.assert()).toBe(true);
+ });
+ test('throws an error when chainName in temporary appSettings is not Mainnet', () => {
+ localStorage.setItem('appSettings_tmp', JSON.stringify({ chainName: 'Preprod' }));
+ expect(migrationPersistance.assert).toThrow(
+ new InvalidMigrationData('1.0.0', 'Chain name in app settings is not Mainnet')
+ );
+ });
+ });
+ describe('persist', () => {
+ test('updates appSettings and clears temporary data', () => {
+ localStorage.setItem('appSettings_tmp', JSON.stringify({ chainName: 'Mainnet' }));
+ localStorageSetSpy.mockClear();
+ migrationPersistance.persist();
+ expect(localStorageSetSpy).toHaveBeenCalledTimes(1);
+ expect(localStorageSetSpy).toHaveBeenCalledWith('appSettings', JSON.stringify({ chainName: 'Mainnet' }));
+ expect(localStorageRemoveSpy).toHaveBeenCalledTimes(2);
+ expect(localStorageRemoveSpy).toBeCalledWith('appSettings_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('keyAgentData_tmp');
+ });
+ });
+ describe('rollback', () => {
+ test('restores the original stored data and clears the temporary data', () => {
+ localStorage.setItem('appSettings_tmp', JSON.stringify({ chainName: 'Mainnet' }));
+ localStorageSetSpy.mockClear();
+ migrationPersistance.rollback();
+ expect(localStorageSetSpy).toHaveBeenCalledTimes(1);
+ expect(localStorageSetSpy).toHaveBeenCalledWith(
+ 'appSettings',
+ JSON.stringify({ lastMnemonicVerification: '', chainName: 'Preprod' })
+ );
+ expect(localStorageRemoveSpy).toHaveBeenCalledTimes(2);
+ expect(localStorageRemoveSpy).toBeCalledWith('appSettings_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('keyAgentData_tmp');
+ });
+ });
+ });
+ describe('when there is a wallet and is not locked', () => {
+ let migrationPersistance: MigrationPersistance;
+ let initialStorage: PromiseResolvedType;
+ beforeAll(async () => {
+ initialStorage = await mockInitialStorage();
+ migrationPersistance = await v_1_0_0.upgrade();
+ });
+ describe('prepare', () => {
+ test('saves temporary storage with the updated key agent and app settings', () => {
+ migrationPersistance.prepare();
+ expect(localStorageSetSpy).toHaveBeenCalledTimes(2);
+ expect(localStorageSetSpy).toHaveBeenCalledWith(
+ 'appSettings_tmp',
+ JSON.stringify({ ...initialStorage.appSettings, chainName: 'Mainnet' })
+ );
+ expect(localStorageSetSpy).toHaveBeenCalledWith(
+ 'keyAgentData_tmp',
+ JSON.stringify(initialStorage.keyAgentsByChain.Mainnet.keyAgentData)
+ );
+ });
+ test('throws an error and clears any temporary storage when there is no key agent for mainnet', async () => {
+ await mockInitialStorage();
+ await storage.local.set({
+ BACKGROUND_STORAGE: {
+ keyAgentsByChain: { Preprod: mockKeyAgentsByChain.Preprod, Preview: mockKeyAgentsByChain.Preview }
+ }
+ });
+ const { prepare } = await v_1_0_0.upgrade();
+ expect(prepare).toThrow(new Error('Failing 1.0.0 migration as the mainnet data needed is not present'));
+ expect(localStorageRemoveSpy).toHaveBeenCalledTimes(2);
+ });
+ });
+ describe('assert', () => {
+ test('returns true if all temporary data is valid', () => {
+ expect(migrationPersistance.assert()).toBe(true);
+ });
+ test('throws an error when chainName in temporary appSettings is not Mainnet', () => {
+ localStorage.setItem('appSettings_tmp', JSON.stringify({ chainName: 'Preprod' }));
+ expect(migrationPersistance.assert).toThrow(
+ new InvalidMigrationData('1.0.0', 'Chain name in app settings is not Mainnet')
+ );
+ });
+ test('throws an error when keyAgentData_tmp does not match mainnet key agent', () => {
+ localStorage.setItem('keyAgentData_tmp', JSON.stringify(initialStorage.keyAgentsByChain.Preprod));
+ expect(migrationPersistance.assert).toThrow(
+ new InvalidMigrationData('1.0.0', 'Key agent data does not match Mainnet key agent')
+ );
+ });
+ });
+ describe('persist', () => {
+ beforeEach(() => {
+ localStorage.setItem('appSettings_tmp', JSON.stringify({ chainName: 'Mainnet' }));
+ localStorage.setItem(
+ 'keyAgentData_tmp',
+ JSON.stringify(initialStorage.keyAgentsByChain.Mainnet.keyAgentData)
+ );
+ jest.clearAllMocks();
+ });
+ test('saves all temporary information as permanent before clearing it', () => {
+ migrationPersistance.persist();
+ expect(localStorageSetSpy).toHaveBeenCalledTimes(2);
+ expect(localStorageSetSpy).toHaveBeenCalledWith('appSettings', JSON.stringify({ chainName: 'Mainnet' }));
+ expect(localStorageSetSpy).toHaveBeenCalledWith(
+ 'keyAgentData',
+ JSON.stringify(initialStorage.keyAgentsByChain.Mainnet.keyAgentData)
+ );
+ expect(localStorageRemoveSpy).toHaveBeenCalledTimes(2);
+ expect(localStorageRemoveSpy).toBeCalledWith('appSettings_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('keyAgentData_tmp');
+ });
+ });
+ describe('rollback', () => {
+ beforeEach(() => {
+ localStorage.setItem('appSettings_tmp', JSON.stringify({ chainName: 'Mainnet' }));
+ localStorage.setItem(
+ 'keyAgentData_tmp',
+ JSON.stringify(initialStorage.keyAgentsByChain.Mainnet.keyAgentData)
+ );
+ jest.clearAllMocks();
+ });
+ test('restores the original stored data and clears the temporary storage', () => {
+ migrationPersistance.rollback();
+ expect(localStorageSetSpy).toHaveBeenCalledTimes(2);
+ expect(localStorageSetSpy).toHaveBeenCalledWith('appSettings', JSON.stringify(initialStorage.appSettings));
+ expect(localStorageSetSpy).toHaveBeenCalledWith('keyAgentData', JSON.stringify(initialStorage.keyAgentData));
+ expect(localStorageRemoveSpy).toHaveBeenCalledTimes(2);
+ expect(localStorageRemoveSpy).toBeCalledWith('appSettings_tmp');
+ expect(localStorageRemoveSpy).toBeCalledWith('keyAgentData_tmp');
+ });
+ });
+ });
+ });
+});
diff --git a/apps/browser-extension-wallet/src/lib/scripts/migrations/errors.ts b/apps/browser-extension-wallet/src/lib/scripts/migrations/errors.ts
new file mode 100644
index 0000000000..608fab4088
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/migrations/errors.ts
@@ -0,0 +1,5 @@
+export class InvalidMigrationData extends Error {
+ constructor(version: string, reason?: string) {
+ super(`Invalid migrated data for version ${version}${reason ? '. Reason: '.concat(reason) : '.'}`);
+ }
+}
diff --git a/apps/browser-extension-wallet/src/lib/scripts/migrations/index.ts b/apps/browser-extension-wallet/src/lib/scripts/migrations/index.ts
new file mode 100644
index 0000000000..b5103a46a4
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/migrations/index.ts
@@ -0,0 +1,2 @@
+export * from './migrations';
+export * from './util';
diff --git a/apps/browser-extension-wallet/src/lib/scripts/migrations/migrations.ts b/apps/browser-extension-wallet/src/lib/scripts/migrations/migrations.ts
new file mode 100644
index 0000000000..83b195fe45
--- /dev/null
+++ b/apps/browser-extension-wallet/src/lib/scripts/migrations/migrations.ts
@@ -0,0 +1,164 @@
+/* eslint-disable sonarjs/no-duplicate-string */
+import { runtime, storage } from 'webextension-polyfill';
+import pRetry from 'p-retry';
+import { MigrationState } from '../types';
+import { compareVersions, isVersionNewerThan, isVersionOlderThanOrEqual } from './util';
+import * as versions from './versions';
+
+const MIGRATIONS_RETRIES = 4; // 5 attempts = first attempt + 4 retries
+
+export interface MigrationPersistance {
+ /**
+ * Executed at the start of the migration
+ *
+ * Prepares storage with temporary data for the migration
+ */
+ prepare: () => void | Promise;
+ /**
+ * Executed after a successful migration preparation
+ *
+ * It retrieves the temporary storage and runs some checks on it
+ */
+ assert: () => boolean | Promise;
+ /**
+ * Executed after a successful migration assertion
+ *
+ * It retrieves the temporary storage, replaces the actual storage with it and finally clears the temporary storage
+ */
+ persist: () => void | Promise