refactor: add migration step

This commit is contained in:
kyranjamie
2022-01-24 12:28:01 +01:00
committed by kyranjamie
parent 2a35a718fe
commit 4bcfc30212
43 changed files with 1886 additions and 955 deletions

View File

@@ -22,7 +22,14 @@ module.exports = {
from: { path: '^src/app/*', pathNot: ['^src/app/store/*'] },
to: {
path: ['^src/app/store/*'],
pathNot: [`src/app.*\.hooks\.ts`, `src/app.*\.models\.ts`, `src/app.*\.utils\.ts`],
pathNot: [
`src/app/store/index.ts`,
`src/app.*\.actions\.ts`,
`src/app.*\.selectors\.ts`,
`src/app.*\.hooks\.ts`,
`src/app.*\.models\.ts`,
`src/app.*\.utils\.ts`,
],
},
},
{

View File

@@ -18,6 +18,7 @@ Object.keys(compilerOptions.paths).forEach(key => {
});
module.exports = {
setupFilesAfterEnv: ['./tests/jest-unit.setup.js'],
collectCoverage: true,
coverageReporters: ['html', 'json-summary'],
collectCoverageFrom: ['src/**/*.{ts,tsx}'],

View File

@@ -5,7 +5,7 @@
"version": "3.2.0",
"author": "Hiro Systems PBC",
"scripts": {
"dev": "concurrently 'node webpack/dev-server.js' 'yarn remotedev --port=8000'",
"dev": "concurrently --raw 'node webpack/dev-server.js' 'redux-devtools --hostname=localhost --port=8000'",
"dev:test-app": "webpack serve --config test-app/webpack/webpack.config.dev.js",
"build": "cross-env NODE_ENV=production EXT_ENV=prod webpack --config webpack/webpack.config.prod.js",
"build:analyze": "cross-env ANALYZE=true NODE_ENV=production EXT_ENV=prod webpack --config webpack/webpack.config.prod.js",
@@ -165,6 +165,7 @@
"@babel/preset-typescript": "7.16.5",
"@babel/runtime": "7.16.5",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.4",
"@redux-devtools/cli": "1.0.5",
"@schemastore/web-manifest": "0.0.5",
"@stacks/auth": "3.0.0",
"@stacks/connect-react": "12.0.0",
@@ -226,6 +227,7 @@
"glob-parent": "6.0.2",
"html-webpack-plugin": "5.5.0",
"jest": "26.6.3",
"jest-chrome": "0.7.2",
"jest-circus": "27.3.1",
"jest-dev-server": "6.0.0",
"jest-junit": "13.0.0",
@@ -239,8 +241,7 @@
"react-refresh": "0.11.0",
"react-test-renderer": "17.0.2",
"redux-devtools-extension": "2.13.9",
"remote-redux-devtools": "^0.5.16",
"remotedev-server": "^0.3.1",
"remote-redux-devtools": "0.5.16",
"schema-inspector": "2.0.1",
"speed-measure-webpack-plugin": "1.5.0",
"standard-version": "9.3.0",
@@ -265,6 +266,7 @@
"**/**/prismjs": "1.25.0",
"**/**/xmldom": "github:xmldom/xmldom#0.7.0",
"**/**/@stacks/network": "3.0.0",
"@redux-devtools/cli/**/tar": "4.4.18",
"bn.js": "5.2.0",
"buffer": "6.0.3",
"css-what": "5.0.1",

View File

@@ -18,7 +18,7 @@ import { jotaiWrappedReactQueryQueryClient as queryClient } from '@app/store/com
import { theme } from './common/theme';
import { GlobalStyles } from './components/global-styles/global-styles';
import { AppRoutes } from './routes/app-routes';
import { persistor, store } from './store/root-reducer';
import { persistor, store } from './store';
import { LoadingSpinner } from './components/loading-spinner';
const reactQueryDevToolsEnabled = false;

View File

@@ -1,30 +1,33 @@
import { useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { Wallet } from '@stacks/wallet-sdk';
import { keySlice } from '@app/store/keys/key.slice';
import { useAnalytics } from './analytics/use-analytics';
import { useAppDispatch } from '@app/store';
import { clearSessionLocalData } from '@app/common/store-utils';
import { stxChainSlice } from '@app/store/chains/stx-chain.slice';
import { createNewAccount } from '@app/store/chains/stx-chain.actions';
import { setWalletEncryptionPassword, unlockWalletAction } from '@app/store/keys/keys.actions';
import { createNewAccount, stxChainActions } from '@app/store/chains/stx-chain.actions';
import { keyActions } from '@app/store/keys/key.actions';
import { useAnalytics } from './analytics/use-analytics';
export function useKeyActions() {
const analytics = useAnalytics();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
return useMemo(
() => ({
async setPassword(password: string) {
return dispatch(setWalletEncryptionPassword(password));
return dispatch(keyActions.setWalletEncryptionPassword(password));
},
generateWalletKey() {
return dispatch(keyActions.generateWalletKey());
},
async unlockWallet(password: string) {
return dispatch(unlockWalletAction(password));
return dispatch(keyActions.unlockWalletAction(password));
},
switchAccount(index: number) {
return dispatch(stxChainSlice.actions.switchAccount(index));
return dispatch(stxChainActions.switchAccount(index));
},
async createNewAccount(wallet: Wallet) {
@@ -33,12 +36,12 @@ export function useKeyActions() {
async signOut() {
void analytics.track('sign_out');
dispatch(keySlice.actions.signOut());
dispatch(keyActions.signOut());
clearSessionLocalData();
},
lockWallet() {
return dispatch(keySlice.actions.lockWallet());
return dispatch(keyActions.lockWallet());
},
}),
[analytics, dispatch]

View File

@@ -39,7 +39,7 @@ export function useWallet() {
const networks = useNetworkState();
const currentNetwork = useCurrentNetworkState();
const currentNetworkKey = useCurrentNetworkKey();
const vaultMessenger = useKeyActions();
const keyActions = useKeyActions();
const currentAccountDisplayName = currentAccount
? getAccountDisplayName(currentAccount)
@@ -79,6 +79,6 @@ export function useWallet() {
setLatestNonce,
setWallet,
cancelAuthentication,
...vaultMessenger,
...keyActions,
};
}

View File

@@ -1,11 +1,12 @@
import { useCallback, useEffect, useState } from 'react';
import { Box, Flex, Button, Stack } from '@stacks/ui';
import { Wallet } from '@stacks/wallet-sdk';
import { Body } from '@app/components/typography';
import { SettingsSelectors } from '@tests/integration/settings.selectors';
import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
import { useKeyActions } from '@app/common/hooks/use-key-actions';
import { useGeneratedCurrentWallet } from '@app/store/chains/stx-chain.actions';
import { Wallet } from '@stacks/wallet-sdk';
import { useGeneratedCurrentWallet } from '@app/store/chains/stx-chain.selectors';
interface CreateAccountProps {
close: () => void;

View File

@@ -15,6 +15,7 @@ import { HomePageSelectors } from '@tests/page-objects/home-page.selectors';
import { useCurrentAccount } from '@app/store/accounts/account.hooks';
import { AccountInfoFetcher, BalanceFetcher } from './components/fetchers';
import { CENTERED_FULL_PAGE_MAX_WIDTH } from '@app/components/global-styles/full-page-styles';
import { HomeTabs } from './components/home-tabs';
export function Home() {
const { decodedAuthRequest } = useOnboardingState();

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { validateMnemonic } from 'bip39';
import {
extractPhraseFromPasteEvent,
@@ -14,9 +15,8 @@ import {
useSeedInputErrorState,
} from '@app/store/onboarding/onboarding.hooks';
import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
import { useDispatch } from 'react-redux';
import { keySlice } from '@app/store/keys/key.slice';
import { validateMnemonic } from 'bip39';
import { useAppDispatch } from '@app/store';
import { keyActions } from '@app/store/keys/key.actions';
export function useSignIn() {
const [, setMagicRecoveryCode] = useMagicRecoveryCodeState();
@@ -29,7 +29,7 @@ export function useSignIn() {
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const handleSetError = useCallback(
(
@@ -74,7 +74,7 @@ export function useSignIn() {
}
try {
dispatch(keySlice.actions.saveUsersSecretKeyToBeRestored(parsedKeyInput));
dispatch(keyActions.saveUsersSecretKeyToBeRestored(parsedKeyInput));
void analytics.track('submit_valid_secret_key');
navigate(RouteUrls.SetPassword);
setIsIdle();

View File

@@ -7,17 +7,15 @@ import { useOnboardingState } from '@app/common/hooks/auth/use-onboarding-state'
import { Header } from '@app/components/header';
import { RouteUrls } from '@shared/route-urls';
import { useHasAllowedDiagnostics } from '@app/store/onboarding/onboarding.hooks';
import { WelcomeLayout } from './welcome.layout';
import { useDispatch } from 'react-redux';
import { keySlice } from '@app/store/keys/key.slice';
import { useKeyActions } from '@app/common/hooks/use-key-actions';
export const WelcomePage = memo(() => {
const [hasAllowedDiagnostics] = useHasAllowedDiagnostics();
const navigate = useNavigate();
const { decodedAuthRequest } = useOnboardingState();
const analytics = useAnalytics();
const dispatch = useDispatch();
const keyActions = useKeyActions();
useRouteHeader(<Header hideActions />);
@@ -25,9 +23,8 @@ export const WelcomePage = memo(() => {
const startOnboarding = useCallback(async () => {
setIsGeneratingWallet(true);
// await makeWallet();
dispatch(keySlice.actions.generateWalletKey());
keyActions.generateWalletKey();
void analytics.track('generate_new_secret_key');
@@ -35,7 +32,7 @@ export const WelcomePage = memo(() => {
navigate(RouteUrls.SetPassword);
}
navigate(RouteUrls.BackUpSecretKey);
}, [dispatch, analytics, decodedAuthRequest, navigate]);
}, [keyActions, analytics, decodedAuthRequest, navigate]);
useEffect(() => {
if (hasAllowedDiagnostics === undefined) navigate(RouteUrls.RequestDiagnostics);

View File

@@ -0,0 +1,51 @@
import { memo, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Stack } from '@stacks/ui';
import { useRouteHeader } from '@app/common/hooks/use-route-header';
import { useWallet } from '@app/common/hooks/use-wallet';
import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
import { Header } from '@app/components/header';
import { RouteUrls } from '@shared/route-urls';
import { useCurrentKey, useGeneratedSecretKey } from '@app/store/keys/key.selectors';
import { SecretKeyActions } from './components/secret-key-actions';
import { SecretKeyMessage } from './components/secret-key-message';
import { SecretKeyCard } from './components/secret-key-card';
export const SaveSecretKey = memo(() => {
const { hasSetPassword } = useWallet();
const analytics = useAnalytics();
const navigate = useNavigate();
const tmpSecretKey = useGeneratedSecretKey();
const currentKey = useCurrentKey();
const secretKey = currentKey?.secretKey ?? tmpSecretKey;
useRouteHeader(
<Header
onClose={
hasSetPassword ? () => navigate(RouteUrls.Home) : () => navigate(RouteUrls.Onboarding)
}
hideActions={!hasSetPassword}
title={hasSetPassword ? 'Your Secret Key' : 'Save your Secret Key'}
/>
);
useEffect(() => {
if (!secretKey) navigate(RouteUrls.Onboarding);
}, [navigate, secretKey]);
useEffect(() => {
void analytics.page('view', '/save-your-secret-key');
}, [analytics]);
return (
<Stack spacing="loose">
<SecretKeyMessage />
<SecretKeyCard seedPhrase={secretKey ?? ''} />
<SecretKeyActions />
</Stack>
);
});

View File

@@ -45,14 +45,14 @@ export function AppRoutes(): JSX.Element | null {
}, [analytics, pathname]);
const hasStateRehydrated = useHasStateRehydrated();
const currentWallet = useCurrentKey();
const currentKey = useCurrentKey();
useEffect(() => {
// This ensures the route is correct bc the VaultLoader is slow to set wallet state
if (pathname === RouteUrls.Home && !currentWallet?.hasSetPassword)
navigate(RouteUrls.Onboarding);
if (pathname === RouteUrls.Home && !currentKey?.hasSetPassword) navigate(RouteUrls.Onboarding);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentWallet?.hasSetPassword]);
}, [currentKey?.hasSetPassword]);
// check to prevent renders before the state has rehydrated
if (!hasStateRehydrated) return <>rehdryating state</>;

View File

@@ -1,12 +1,12 @@
// import { useEffect } from 'react';
import { useEffect } from 'react';
// import { InternalMethods } from '@shared/message-types';
import { keyActions } from '@app/store/keys/key.actions';
export function useOnSignOut(_handler: () => void) {
// useEffect(() => {
// chrome.runtime.onMessage.addListener(message => {
// // if (message.method === InternalMethods.signOut) handler();
// });
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, []);
export function useOnSignOut(handler: () => void) {
useEffect(() => {
chrome.runtime.onMessage.addListener(message => {
if (message?.method === keyActions.signOut.type) handler();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}

View File

@@ -1,12 +1,12 @@
// import { useEffect } from 'react';
import { useEffect } from 'react';
// import { InternalMethods } from '@shared/message-types';
import { keyActions } from '@app/store/keys/key.actions';
export function useOnWalletLock(_handler: () => void) {
// useEffect(() => {
// chrome.runtime.onMessage.addListener(message => {
// if (message.method === InternalMethods.lockWallet) handler();
// });
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, []);
export function useOnWalletLock(handler: () => void) {
useEffect(() => {
chrome.runtime.onMessage.addListener(message => {
if (message?.method === keyActions.lockWallet.type) handler();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}

View File

@@ -1,9 +1,6 @@
import { useAsync } from 'react-async-hook';
import { useSelector } from 'react-redux';
import {
createWalletGaiaConfig,
generateNewAccount,
generateWallet,
updateWalletConfig,
Wallet,
} from '@stacks/wallet-sdk';
@@ -11,11 +8,9 @@ import {
import { gaiaUrl } from '@shared/constants';
import { logger } from '@shared/logger';
import { saveWalletConfigLocally } from '@shared/utils/wallet-config-helper';
import memoize from 'promise-memoize';
import { selectCurrentKey, useCurrentKey } from '../keys/key.slice';
import { AppThunk } from '../root-reducer';
import { selectStxChain, stxChainSlice } from './stx-chain.slice';
import { selectCurrentKey } from '@app/store/keys/key.selectors';
import { AppThunk } from '@app/store';
import { stxChainSlice } from './stx-chain.slice';
export const createNewAccount = (wallet: Wallet): AppThunk => {
return async (dispatch, getState) => {
@@ -44,31 +39,4 @@ export const createNewAccount = (wallet: Wallet): AppThunk => {
};
};
export const deriveWalletWithAccounts = memoize(
async (secretKey: string, highestAccountIndex: number) => {
// Here we only want the resulting `Wallet` objects, but the API
// requires a password (so it can also return an encrypted key)
const walletSdk = await generateWallet({ secretKey, password: '' });
// To generate a new account, the wallet-sdk requires the entire `Wallet` to
// be supplied so that it can count the `wallet.accounts[]` length, and return
// a new `Wallet` object with all the accounts. As we want to generate them
// all, we must set the updated value and read it again in the loop
let walWithAccounts = walletSdk;
for (let i = 0; i < highestAccountIndex; i++) {
walWithAccounts = generateNewAccount(walWithAccounts);
}
return walWithAccounts;
}
);
export function useGeneratedCurrentWallet() {
const currAccount = useCurrentKey();
const stxChainState = useSelector(selectStxChain);
return useAsync(async () => {
if (!currAccount) return undefined;
return deriveWalletWithAccounts(
currAccount.secretKey,
stxChainState.default.highestAccountIndex
);
}, [currAccount, stxChainState]).result;
}
export const stxChainActions = stxChainSlice.actions;

View File

@@ -0,0 +1,34 @@
import { Wallet } from '@stacks/wallet-sdk';
import { useAsync } from 'react-async-hook';
import { useSelector } from 'react-redux';
import memoize from 'promise-memoize';
import { RootState } from '@app/store';
import { useCurrentKey } from '@app/store/keys/key.selectors';
import { RequestDerivedStxAccounts } from '@shared/vault/vault-types';
import { InternalMethods } from '@shared/message-types';
const selectStxChain = (state: RootState) => state.chains.stx;
export const deriveWalletWithAccounts = memoize(
async (secretKey: string, highestAccountIndex: number): Promise<Wallet> => {
const message: RequestDerivedStxAccounts = {
method: InternalMethods.RequestDerivedStxAccounts,
payload: { secretKey, highestAccountIndex },
};
return new Promise(resolve => chrome.runtime.sendMessage(message, resp => resolve(resp)));
}
);
export function useGeneratedCurrentWallet() {
const currAccount = useCurrentKey();
const stxChainState = useSelector(selectStxChain);
return useAsync(async () => {
if (!currAccount) return undefined;
if (!currAccount.secretKey) return undefined;
return deriveWalletWithAccounts(
currAccount.secretKey,
stxChainState.default.highestAccountIndex
);
}, [currAccount, stxChainState]).result;
}

View File

@@ -1,7 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { keySlice } from '../keys/key.slice';
import { RootState } from '../root-reducer';
interface StxChainKeyState {
highestAccountIndex: number;
@@ -37,5 +36,3 @@ export const stxChainSlice = createSlice({
},
},
});
export const selectStxChain = (state: RootState) => state.chains.stx;

View File

@@ -1,4 +1,4 @@
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { atomWithStore } from 'jotai/redux';
import storage from 'redux-persist/lib/storage';
import devToolsEnhancer from 'remote-redux-devtools';
@@ -16,6 +16,7 @@ import {
import { keySlice } from './keys/key.slice';
import { stxChainSlice } from './chains/stx-chain.slice';
import { broadcastActionTypeToOtherFramesMiddleware } from './utils/broadcast-action-types';
// const storage = new ChromeStorage(chrome.storage.local, chrome.runtime);
@@ -37,12 +38,14 @@ const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
middleware: getDefaultMiddleware => [
...getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
broadcastActionTypeToOtherFramesMiddleware,
],
enhancers: [
devToolsEnhancer({
hostname: 'localhost',
@@ -54,15 +57,17 @@ export const store = configureStore({
export const persistor = persistStore(store);
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof persistedReducer>;
export type AppThunk<ReturnType = void> = ThunkAction<ReturnType, RootState, unknown, AnyAction>;
type AppDispatch = typeof store.dispatch;
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const storeAtom = atomWithStore(store);
export const selectHasRehydrated = (state: RootState) => state._persist.rehydrated;
const selectHasRehydrated = (state: RootState) => state._persist.rehydrated;
export function useHasStateRehydrated() {
return useSelector(selectHasRehydrated);

View File

@@ -1,11 +1,12 @@
import { encryptMnemonic } from '@background/crypto/mnemonic-encryption';
import { getDecryptedWalletDetails } from '@background/wallet/unlock-wallet';
import { gaiaUrl } from '@shared/constants';
import { generateWallet, restoreWalletAccounts } from '@stacks/wallet-sdk';
import { stxChainSlice } from '../chains/stx-chain.slice';
import { AppThunk } from '../root-reducer';
import { keySlice, selectCurrentKey, selectGeneratedSecretKey } from './key.slice';
import { decryptMnemonic, encryptMnemonic } from '@shared/crypto/mnemonic-encryption';
import { gaiaUrl } from '@shared/constants';
import { AppThunk } from '@app/store';
import { stxChainSlice } from '../chains/stx-chain.slice';
import { keySlice } from './key.slice';
import { selectCurrentKey, selectGeneratedSecretKey } from './key.selectors';
async function restoredWalletHighestGeneratedAccountIndex(secretKey: string) {
try {
@@ -23,18 +24,17 @@ async function restoredWalletHighestGeneratedAccountIndex(secretKey: string) {
}
}
export const setWalletEncryptionPassword = (password: string): AppThunk => {
const setWalletEncryptionPassword = (password: string): AppThunk => {
return async (dispatch, getState) => {
const secretKey = selectGeneratedSecretKey(getState());
if (!secretKey) throw new Error('Cannot generate wallet without first having generated a key');
const { encryptedSecretKey, salt } = await encryptMnemonic({ secretKey, password });
const highestAccountIndex = await restoredWalletHighestGeneratedAccountIndex(secretKey);
dispatch(
keySlice.actions.addNewKey({
keySlice.actions.createNewWalletComplete({
type: 'software',
id: 'default',
salt,
hasSetPassword: true,
secretKey,
encryptedSecretKey,
})
@@ -44,12 +44,18 @@ export const setWalletEncryptionPassword = (password: string): AppThunk => {
};
};
export const unlockWalletAction = (password: string): AppThunk => {
const unlockWalletAction = (password: string): AppThunk => {
return async (dispatch, getState) => {
const currentKey = selectCurrentKey(getState());
if (!currentKey) return;
const { encryptedSecretKey, salt } = currentKey;
const vault = await getDecryptedWalletDetails(encryptedSecretKey, password, salt);
dispatch(keySlice.actions.updateCurrentWallet({ id: 'default', changes: { ...vault } }));
const decryptedData = await decryptMnemonic({ encryptedSecretKey, password, salt });
dispatch(keySlice.actions.unlockWalletComplete(decryptedData));
};
};
export const keyActions = {
...keySlice.actions,
setWalletEncryptionPassword,
unlockWalletAction,
};

View File

@@ -0,0 +1,24 @@
import { useMemo } from 'react';
import { createSelector } from '@reduxjs/toolkit';
import { useSelector } from 'react-redux';
import { RootState } from '@app/store';
const selectWalletSlice = (state: RootState) => state.keys;
export const selectGeneratedSecretKey = createSelector(selectWalletSlice, state => state.secretKey);
export const selectCurrentKey = createSelector(selectWalletSlice, state => state.entities.default);
export function withDerivedKeyInformation(key: ReturnType<typeof selectCurrentKey>) {
return { ...key, hasSetPassword: !!key?.encryptedSecretKey };
}
export function useCurrentKey() {
const currentKey = useSelector(selectCurrentKey);
return useMemo(() => withDerivedKeyInformation(currentKey), [currentKey]);
}
export function useGeneratedSecretKey() {
return useSelector(selectGeneratedSecretKey);
}

View File

@@ -1,19 +1,13 @@
import { useSelector } from 'react-redux';
import { createEntityAdapter, createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { createEntityAdapter, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { generateSecretKey } from '@stacks/wallet-sdk';
import { RootState } from '../root-reducer';
import { migrateVaultReducerStoreToNewStateStructure } from '../utils/vault-reducer-migration';
interface KeyConfigSoftware {
type: 'software';
id: string;
encryptedSecretKey: string;
salt: string;
// Legacy property from pre-vault reducer refactor. A wallet never arrives to
// the state prior to there being a password set for the key's encryption.
// `hasSetPassword` can later be inferred on the presence of an encryptedKey
// property.
hasSetPassword: boolean;
secretKey: string;
}
@@ -21,13 +15,15 @@ const keyAdapter = createEntityAdapter<KeyConfigSoftware>();
// Only used during onboarding, pre-wallet creation
// Could well be persisted elsewhere
interface TempKeyState {
interface ExtraKeyState {
secretKey?: null | string;
}
export const initialKeysState = keyAdapter.getInitialState<ExtraKeyState>({ secretKey: null });
export const keySlice = createSlice({
name: 'keys',
initialState: keyAdapter.getInitialState<TempKeyState>({ secretKey: null }),
initialState: migrateVaultReducerStoreToNewStateStructure(initialKeysState),
reducers: {
generateWalletKey(state) {
state.secretKey = generateSecretKey(256);
@@ -41,12 +37,14 @@ export const keySlice = createSlice({
state.secretKey = null;
},
addNewKey(state, action: PayloadAction<KeyConfigSoftware>) {
createNewWalletComplete(state, action: PayloadAction<KeyConfigSoftware>) {
state.secretKey = null;
keyAdapter.addOne(state, action.payload);
},
updateCurrentWallet: keyAdapter.updateOne,
unlockWalletComplete(state, action: PayloadAction<Partial<KeyConfigSoftware>>) {
keyAdapter.updateOne(state, { id: 'default', changes: action.payload });
},
lockWallet(state) {
keyAdapter.updateOne(state, { id: 'default', changes: { secretKey: null } as any });
@@ -57,13 +55,3 @@ export const keySlice = createSlice({
},
},
});
const selectWalletSlice = (state: RootState) => state.keys;
export const selectGeneratedSecretKey = createSelector(selectWalletSlice, state => state.secretKey);
export const selectCurrentKey = createSelector(selectWalletSlice, state => state.entities.default);
export function useCurrentKey() {
return useSelector(selectCurrentKey);
}

View File

@@ -7,7 +7,6 @@ import {
magicRecoveryCodeState,
secretKeyState,
seedInputErrorState,
seedInputState,
} from './onboarding';
export function useAuthRequest() {
@@ -18,10 +17,6 @@ export function useUpdateAuthRequest() {
return useUpdateAtom(authRequestState);
}
export function useSeedInputState() {
return useAtom(seedInputState);
}
export function useSeedInputErrorState() {
return useAtom(seedInputErrorState);
}

View File

@@ -13,7 +13,6 @@ interface AuthRequestState {
}
export const magicRecoveryCodePasswordState = atom('');
export const seedInputState = atom('');
export const seedInputErrorState = atom<string | undefined>(undefined);
export const secretKeyState = atom(null);
export const magicRecoveryCodeState = atom<null | string>(null);
@@ -29,10 +28,3 @@ export const hasAllowedDiagnosticsState = atomWithStorage<boolean | undefined>(
userHasAllowedDiagnosticsKey,
undefined
);
magicRecoveryCodePasswordState.debugLabel = 'magicRecoveryCodePasswordState';
seedInputState.debugLabel = 'seedInputState';
seedInputErrorState.debugLabel = 'seedInputErrorState';
secretKeyState.debugLabel = 'secretKeyState';
magicRecoveryCodeState.debugLabel = 'magicRecoveryCodeState';
authRequestState.debugLabel = 'authRequestState';

View File

@@ -0,0 +1,7 @@
import { AnyAction, Middleware } from '@reduxjs/toolkit';
export const broadcastActionTypeToOtherFramesMiddleware: Middleware =
_store => next => (action: AnyAction) => {
chrome.runtime.sendMessage({ method: action.type });
return next(action);
};

View File

@@ -1,6 +1,7 @@
const driverMap = new WeakMap();
const runtimeMap = new WeakMap();
// ts-unused-exports:disable-next-line
export class ChromeStorage {
constructor(driver: any, runtime: any) {
driverMap.set(this, driver);

View File

@@ -0,0 +1,52 @@
import { LocalStorageMock } from '@tests/mocks/localStorage-mock';
import { migrateVaultReducerStoreToNewStateStructure } from './vault-reducer-migration';
(global as any).localStorage = new LocalStorageMock();
describe(migrateVaultReducerStoreToNewStateStructure.name, () => {
describe('migration scenario', () => {
beforeEach(() => {
localStorage.setItem('stacks-wallet-salt', 'test-salt');
localStorage.setItem('stacks-wallet-encrypted-key', 'test-encrypted-key');
});
test('that it reads localstorage wallet values', () => {
jest.spyOn(global.localStorage.__proto__, 'getItem');
migrateVaultReducerStoreToNewStateStructure({} as any);
expect(localStorage.getItem).toHaveBeenCalledWith('stacks-wallet-salt');
expect(localStorage.getItem).toHaveBeenCalledWith('stacks-wallet-encrypted-key');
});
test('that it returns a migrated state object when wallet values are detected', () => {
const returnedValue = migrateVaultReducerStoreToNewStateStructure({} as any);
expect(returnedValue).toEqual({
ids: ['default'],
entities: {
default: {
type: 'software',
id: 'default',
encryptedSecretKey: 'test-encrypted-key',
salt: 'test-salt',
hasSetPassword: true,
},
},
});
});
test('it removes the existing existing localStorage values', () => {
jest.spyOn(global.localStorage.__proto__, 'removeItem');
migrateVaultReducerStoreToNewStateStructure({} as any);
expect(localStorage.removeItem).toHaveBeenCalledWith('stacks-wallet-salt');
expect(localStorage.removeItem).toHaveBeenCalledWith('stacks-wallet-encrypted-key');
});
});
describe('no migration needed scenario', () => {
test('nothing happens when no localStorage values are detected', () => {
const returnedValue = migrateVaultReducerStoreToNewStateStructure({} as any);
expect(returnedValue).toEqual({});
});
});
});

View File

@@ -0,0 +1,30 @@
import deepMerge from 'deepmerge';
import { logger } from '@shared/logger';
import type { initialKeysState } from '../keys/key.slice';
export function migrateVaultReducerStoreToNewStateStructure(initialState: typeof initialKeysState) {
const salt = localStorage.getItem('stacks-wallet-salt');
const encryptedSecretKey = localStorage.getItem('stacks-wallet-encrypted-key');
if (salt && encryptedSecretKey) {
logger.debug(
'VaultReducer generated Hiro Wallet detected. Running migrating to keys store structure'
);
const migratedState = {
ids: ['default'],
entities: {
default: {
type: 'software',
id: 'default',
encryptedSecretKey,
salt,
hasSetPassword: true,
},
},
};
localStorage.removeItem('stacks-wallet-salt');
localStorage.removeItem('stacks-wallet-encrypted-key');
return deepMerge(initialState, migratedState);
}
return initialState;
}

View File

@@ -1,29 +1,27 @@
import { useCallback } from 'react';
import { useAtom } from 'jotai';
import { useAtomCallback, useAtomValue } from 'jotai/utils';
import type { BackgroundActions, InMemorySoftwareWalletVault } from '@shared/vault/vault-types';
import { gaiaUrl } from '@shared/constants';
import { useOnboardingState } from '@app/common/hooks/auth/use-onboarding-state';
import {
createWalletGaiaConfig,
getOrCreateWalletConfig,
makeAuthResponse,
updateWalletConfigWithApp,
} from '@stacks/wallet-sdk';
import { gaiaUrl } from '@shared/constants';
import { useOnboardingState } from '@app/common/hooks/auth/use-onboarding-state';
import { currentAccountStxAddressState } from '@app/store/accounts';
import { localNonceState } from '@app/store/accounts/nonce';
import { currentNetworkState } from '@app/store/network/networks';
import { useAtom } from 'jotai';
import { useCallback } from 'react';
import { finalizeAuthResponse } from '@app/common/actions/finalize-auth-response';
import { logger } from '@shared/logger';
import {
encryptedSecretKeyState,
hasSetPasswordState,
secretKeyState,
walletState,
} from './wallet';
import { finalizeAuthResponse } from '@app/common/actions/finalize-auth-response';
import { logger } from '@shared/logger';
import { useDispatch } from 'react-redux';
import { stxChainSlice } from '../chains/stx-chain.slice';
import { useKeyActions } from '@app/common/hooks/use-key-actions';
export function useWalletState() {
return useAtom(walletState);
@@ -57,7 +55,7 @@ export function useSetLatestNonceCallback() {
export function useFinishSignInCallback() {
const { decodedAuthRequest, authRequest, appName, appIcon } = useOnboardingState();
const dispatch = useDispatch();
const keyActions = useKeyActions();
return useAtomCallback<void, number>(
useCallback(
async (get, _set, accountIndex) => {
@@ -94,18 +92,10 @@ export function useFinishSignInCallback() {
scopes: decodedAuthRequest.scopes,
account,
});
dispatch(stxChainSlice.actions.switchAccount(accountIndex));
keyActions.switchAccount(accountIndex);
finalizeAuthResponse({ decodedAuthRequest, authRequest, authResponse });
},
[decodedAuthRequest, authRequest, appIcon, appName, dispatch]
[decodedAuthRequest, authRequest, appIcon, appName, keyActions]
)
);
}
export function sendBackgroundMessage(message: BackgroundActions) {
return new Promise(resolve =>
chrome.runtime.sendMessage(message, (vaultResponse: InMemorySoftwareWalletVault) => {
resolve(vaultResponse);
})
);
}

View File

@@ -2,8 +2,9 @@ import { atom } from 'jotai';
import { fetchWalletConfig, createWalletGaiaConfig } from '@stacks/wallet-sdk';
import { gaiaUrl } from '@shared/constants';
import { textToBytes } from '@app/common/store-utils';
import { storeAtom } from '../root-reducer';
import { deriveWalletWithAccounts } from '../chains/stx-chain.actions';
import { storeAtom } from '..';
import { deriveWalletWithAccounts } from '../chains/stx-chain.selectors';
import { withDerivedKeyInformation } from '../keys/key.selectors';
export const walletState = atom(async get => {
const store = get(storeAtom);
@@ -24,7 +25,8 @@ export const walletConfigState = atom(async get => {
export const hasSetPasswordState = atom(get => {
const store = get(storeAtom);
return !!store.keys.entities.default?.hasSetPassword;
if (!store.keys.entities.default) return;
return withDerivedKeyInformation(store.keys.entities.default).hasSetPassword;
});
export const encryptedSecretKeyState = atom(get => {

View File

@@ -13,14 +13,12 @@ import { initSentry } from '@shared/utils/sentry-init';
import {
CONTENT_SCRIPT_PORT,
ExternalMethods,
InternalMethods,
MessageFromContentScript,
} from '@shared/message-types';
import { popupCenter } from '@background/popup-center';
import type { BackgroundActions } from '@shared/vault/vault-types';
import { initContextMenuActions } from '@background/init-context-menus';
import { logger } from '@shared/logger';
import { backgroundMessageHandler } from './message-handler';
const IS_TEST_ENV = process.env.TEST_ENV === 'true';
@@ -28,6 +26,7 @@ initSentry();
initContextMenuActions();
//
// Playwright does not currently support Chrome extension popup testing:
// https://github.com/microsoft/playwright/issues/5593
async function openRequestInFullPage(path: string, urlParams: URLSearchParams) {
@@ -36,7 +35,6 @@ async function openRequestInFullPage(path: string, urlParams: URLSearchParams) {
});
}
// Listen for install event
chrome.runtime.onInstalled.addListener(details => {
Sentry.wrap(async () => {
if (details.reason === 'install' && !IS_TEST_ENV) {
@@ -95,25 +93,12 @@ chrome.runtime.onConnect.addListener(port =>
})
);
function validateMessagesAreFromExtension(sender: chrome.runtime.MessageSender) {
// Only respond to internal messages from our UI, not content scripts in other applications
return sender.url?.startsWith(chrome.runtime.getURL(''));
}
// Listen for events triggered by the background memory vault
chrome.runtime.onMessage.addListener((message: BackgroundActions, sender, sendResponse) =>
//
// Events from the popup script
// Listener fn must return `true` to indicate the response will be async
chrome.runtime.onMessage.addListener((message, sender, sendResponse) =>
Sentry.wrap(() => {
if (!validateMessagesAreFromExtension(sender)) {
logger.error('Received background script msg from ' + sender.url);
return;
}
switch (message.method) {
case InternalMethods.TestAction: {
sendResponse();
}
}
void backgroundMessageHandler(message, sender, sendResponse);
return true;
})
);

View File

@@ -0,0 +1,43 @@
import { logger } from '@shared/logger';
import { InternalMethods } from '@shared/message-types';
import { BackgroundActions } from '@shared/vault/vault-types';
import { generateNewAccount, generateWallet } from '@stacks/wallet-sdk';
import memoize from 'promise-memoize';
function validateMessagesAreFromExtension(sender: chrome.runtime.MessageSender) {
// Only respond to internal messages from our UI, not content scripts in other applications
return sender.url?.startsWith(chrome.runtime.getURL(''));
}
const deriveWalletWithAccounts = memoize(async (secretKey: string, highestAccountIndex: number) => {
// Here we only want the resulting `Wallet` objects, but the API
// requires a password (so it can also return an encrypted key)
const walletSdk = await generateWallet({ secretKey, password: '' });
// To generate a new account, the wallet-sdk requires the entire `Wallet` to
// be supplied so that it can count the `wallet.accounts[]` length, and return
// a new `Wallet` object with all the accounts. As we want to generate them
// all, we must set the updated value and read it again in the loop
let walWithAccounts = walletSdk;
for (let i = 0; i < highestAccountIndex; i++) {
walWithAccounts = generateNewAccount(walWithAccounts);
}
return walWithAccounts;
});
export async function backgroundMessageHandler(
message: BackgroundActions,
sender: chrome.runtime.MessageSender,
sendResponse: (response?: any) => void
) {
if (!validateMessagesAreFromExtension(sender)) {
logger.error('Error: Received background script msg from ' + sender.url);
return;
}
switch (message.method) {
case InternalMethods.RequestDerivedStxAccounts: {
const { secretKey, highestAccountIndex } = message.payload;
const walletsWithAccounts = await deriveWalletWithAccounts(secretKey, highestAccountIndex);
sendResponse(walletsWithAccounts);
}
}
}

View File

@@ -1,41 +0,0 @@
import { decryptMnemonic } from '@background/crypto/mnemonic-encryption';
import { getWallet } from './wallet-utils';
export async function getDecryptedWalletDetails(
encryptedSecretKey: string,
password: string,
salt: string | undefined
) {
const hasSetPassword = password !== undefined;
const decryptedData = await decryptMnemonic({
encryptedSecretKey,
password,
salt,
});
const keyInfo = {
secretKey: decryptedData.secretKey,
encryptedSecretKey: encryptedSecretKey,
currentAccountIndex: 0,
hasSetPassword,
};
const wallet = await getWallet({
secretKey: decryptedData.secretKey,
salt: decryptedData.salt,
password,
});
console.log({ wallet });
// if (!wallet) return;
const result = {
...keyInfo,
wallet,
salt: decryptedData.salt,
encryptedSecretKey: decryptedData.encryptedSecretKey,
};
return result;
}

View File

@@ -12,7 +12,7 @@ export enum ExternalMethods {
}
export enum InternalMethods {
TestAction = 'TestAction',
RequestDerivedStxAccounts = 'RequestDerivedStxAccounts',
}
export type ExtensionMethods = ExternalMethods | InternalMethods;

View File

@@ -1,17 +1,5 @@
import { Wallet } from '@stacks/wallet-sdk';
import { ExtensionMethods, InternalMethods, Message } from '@shared/message-types';
// In-memory (background) wallet instance
export interface InMemorySoftwareWalletVault {
encryptedSecretKey?: string;
salt?: string;
secretKey?: string;
wallet?: Wallet;
currentAccountIndex?: number;
hasSetPassword: boolean;
}
/**
* Vault <-> Background Script
*/
@@ -20,6 +8,9 @@ type BackgroundMessage<Msg extends ExtensionMethods, Payload = undefined> = Omit
'source'
>;
export type TestMessage = BackgroundMessage<InternalMethods.TestAction, string>;
export type RequestDerivedStxAccounts = BackgroundMessage<
InternalMethods.RequestDerivedStxAccounts,
{ secretKey: string; highestAccountIndex: number }
>;
export type BackgroundActions = TestMessage;
export type BackgroundActions = RequestDerivedStxAccounts;

1
tests/jest-unit.setup.js Normal file
View File

@@ -0,0 +1 @@
Object.assign(global, require('jest-chrome'));

View File

@@ -0,0 +1,22 @@
export class LocalStorageMock {
store: any;
constructor() {
this.store = {};
}
clear() {
this.store = {};
}
getItem(key: string) {
return this.store[key] || null;
}
setItem(key: string, value: string) {
this.store[key] = String(value);
}
removeItem(key: string) {
delete this.store[key];
}
}

View File

@@ -77,7 +77,7 @@ const config = {
inpage: path.join(SRC_ROOT_PATH, 'inpage', 'inpage.ts'),
'content-script': path.join(SRC_ROOT_PATH, 'content-scripts', 'content-script.ts'),
index: path.join(SRC_ROOT_PATH, 'app', 'index.tsx'),
'decryption-worker': path.join(SRC_ROOT_PATH, 'background/workers/decryption-worker.ts'),
'decryption-worker': path.join(SRC_ROOT_PATH, 'shared/workers/decryption-worker.ts'),
},
output: {
path: DIST_ROOT_PATH,

2184
yarn.lock

File diff suppressed because it is too large Load Diff