diff --git a/packages/neuron-ui/src/components/DetectDuplicateWalletDialog/detectDuplicateWalletDialog.module.scss b/packages/neuron-ui/src/components/DetectDuplicateWalletDialog/detectDuplicateWalletDialog.module.scss new file mode 100755 index 0000000000..68343457f0 --- /dev/null +++ b/packages/neuron-ui/src/components/DetectDuplicateWalletDialog/detectDuplicateWalletDialog.module.scss @@ -0,0 +1,32 @@ +.content { + width: 648px; + .detail { + margin: 0; + color: var(--secondary-text-color); + } + .groupWrap { + margin-top: 16px; + height: 177px; + overflow-y: scroll; + border: 1px solid var(--divide-line-color); + border-radius: 8px; + padding-left: 16px; + > div { + border-bottom: 1px solid var(--divide-line-color); + padding-top: 16px; + &:last-child { + border: none; + } + } + .radioItem { + padding: 0 0 16px; + &:hover, + &:focus { + background: transparent; + button { + visibility: visible; + } + } + } + } +} diff --git a/packages/neuron-ui/src/components/DetectDuplicateWalletDialog/index.tsx b/packages/neuron-ui/src/components/DetectDuplicateWalletDialog/index.tsx new file mode 100644 index 0000000000..cc98e15f20 --- /dev/null +++ b/packages/neuron-ui/src/components/DetectDuplicateWalletDialog/index.tsx @@ -0,0 +1,114 @@ +import React, { useMemo, useState, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import Dialog from 'widgets/Dialog' +import RadioGroup from 'widgets/RadioGroup' +import { useState as useGlobalState, useDispatch, AppActions } from 'states' +import { requestPassword } from 'services/remote' +import styles from './detectDuplicateWalletDialog.module.scss' + +const DetectDuplicateWalletDialog = ({ onClose }: { onClose: () => void }) => { + const { + wallet: { id: currentID = '' }, + settings: { wallets = [] }, + } = useGlobalState() + const dispatch = useDispatch() + const [duplicatedWallets, setDuplicatedWallets] = useState([]) + const [t] = useTranslation() + + const groups = useMemo(() => { + const obj: { + [key: string]: State.WalletIdentity[] + } = {} + wallets.forEach(item => { + if (item.extendedKey in obj) { + obj[item.extendedKey].push(item) + } else { + obj[item.extendedKey] = [item] + } + }) + + return Object.values(obj).filter(list => list.length > 1) + }, [wallets]) + + const handleGroupChange = useCallback( + (checked: string) => { + const [extendedKey, id] = checked.split('_') + + const list = wallets + .filter(item => { + if (item.extendedKey === extendedKey) { + return item.id !== id + } + return duplicatedWallets.includes(item.id) + }) + .map(item => item.id) + + setDuplicatedWallets(list) + }, + [wallets, duplicatedWallets, setDuplicatedWallets] + ) + + const onConfirm = useCallback(async () => { + const getRequest = (id: string) => { + if (wallets.find(item => (item.id === id && item.device) || item.isWatchOnly)) { + return requestPassword({ walletID: id, action: 'delete-wallet' }) + } + return new Promise(resolve => { + dispatch({ + type: AppActions.RequestPassword, + payload: { + walletID: id, + actionType: 'delete', + onSuccess: async () => { + await resolve(id) + }, + }, + }) + }) + } + + const requestToDeleteIds = duplicatedWallets + .filter(item => item !== currentID) + .concat(duplicatedWallets.includes(currentID) ? [currentID] : []) + + // eslint-disable-next-line no-restricted-syntax + for (const id of requestToDeleteIds) { + // eslint-disable-next-line no-await-in-loop + await getRequest(id) + } + onClose() + }, [wallets, duplicatedWallets, requestPassword, onClose, dispatch]) + + return ( + +
+

{t('settings.wallet-manager.detected-duplicate.detail')}

+
+ {groups.map(group => ( + ({ + value: `${wallet.extendedKey}_${wallet.id}`, + label: {wallet.name}, + }))} + /> + ))} +
+
+
+ ) +} + +DetectDuplicateWalletDialog.displayName = 'DetectDuplicateWalletDialog' + +export default DetectDuplicateWalletDialog diff --git a/packages/neuron-ui/src/components/ImportHardware/name-wallet.tsx b/packages/neuron-ui/src/components/ImportHardware/name-wallet.tsx index 5960236158..4c555b2cce 100644 --- a/packages/neuron-ui/src/components/ImportHardware/name-wallet.tsx +++ b/packages/neuron-ui/src/components/ImportHardware/name-wallet.tsx @@ -3,10 +3,11 @@ import { useTranslation } from 'react-i18next' import Button from 'widgets/Button' import TextField from 'widgets/TextField' import { createHardwareWallet } from 'services/remote' -import { CONSTANTS, isSuccessResponse, useDialogWrapper } from 'utils' +import { CONSTANTS, isSuccessResponse, useDialogWrapper, ErrorCode } from 'utils' import Alert from 'widgets/Alert' import { FinishCreateLoading, getAlertStatus } from 'components/WalletWizard' import { importedWalletDialogShown } from 'services/localCache' +import ReplaceDuplicateWalletDialog, { useReplaceDuplicateWallet } from 'components/ReplaceDuplicateWalletDialog' import { ImportStep, ActionType, ImportHardwareState } from './common' import styles from './findDevice.module.scss' @@ -26,6 +27,7 @@ const NameWallet = ({ const [walletName, setWalletName] = useState(`${model?.manufacturer} ${model?.product}`) const [errorMsg, setErrorMsg] = useState('') const { dialogRef, openDialog, closeDialog } = useDialogWrapper() + const { onImportingExitingWalletError, dialogProps } = useReplaceDuplicateWallet() const onBack = useCallback(() => { dispatch({ step: ImportStep.ImportHardware }) @@ -46,6 +48,11 @@ const NameWallet = ({ importedWalletDialogShown.setStatus(res.result.id, true) } } else { + if (res.status === ErrorCode.DuplicateImportWallet) { + onImportingExitingWalletError(res.message) + return + } + setErrorMsg(typeof res.message === 'string' ? res.message : res.message!.content!) } }) @@ -62,33 +69,37 @@ const NameWallet = ({ }, []) return ( -
-
{t('import-hardware.title.name-wallet')}
-
- -
- - {errorMsg || t('wizard.new-name')} - -
-
- - + <> +
+
{t('import-hardware.title.name-wallet')}
+
+ +
+ + {errorMsg || t('wizard.new-name')} + +
+
+ + + + + ) } diff --git a/packages/neuron-ui/src/components/ImportKeystore/index.tsx b/packages/neuron-ui/src/components/ImportKeystore/index.tsx index 07be66530d..e3b71e74ac 100644 --- a/packages/neuron-ui/src/components/ImportKeystore/index.tsx +++ b/packages/neuron-ui/src/components/ImportKeystore/index.tsx @@ -15,6 +15,7 @@ import { useDialogWrapper, } from 'utils' +import ReplaceDuplicateWalletDialog, { useReplaceDuplicateWallet } from 'components/ReplaceDuplicateWalletDialog' import { FinishCreateLoading, CreateFirstWalletNav } from 'components/WalletWizard' import TextField from 'widgets/TextField' import { importedWalletDialogShown } from 'services/localCache' @@ -48,6 +49,7 @@ const ImportKeystore = () => { const navigate = useNavigate() const [fields, setFields] = useState(defaultFields) const [openingFile, setOpeningFile] = useState(false) + const { onImportingExitingWalletError, dialogProps } = useReplaceDuplicateWallet() const goBack = useGoBack() const disabled = !!( @@ -115,6 +117,11 @@ const ImportKeystore = () => { throw new PasswordIncorrectException() } + if (res.status === ErrorCode.DuplicateImportWallet) { + onImportingExitingWalletError(res.message) + return + } + if (res.message) { const msg = typeof res.message === 'string' ? res.message : res.message.content || '' if (msg) { @@ -193,51 +200,55 @@ const ImportKeystore = () => { ) return ( -
-
{t('import-keystore.title')}
- - {Object.entries(fields) - .filter(([key]) => !key.endsWith('Error')) - .map(([key, value]) => { - return ( - {}} - role="button" - tabIndex={-1} - > - {t('import-keystore.select-file')} - - ) : null - } - required - /> - ) - })} -
- -
- - + <> +
+
{t('import-keystore.title')}
+ + {Object.entries(fields) + .filter(([key]) => !key.endsWith('Error')) + .map(([key, value]) => { + return ( + {}} + role="button" + tabIndex={-1} + > + {t('import-keystore.select-file')} + + ) : null + } + required + /> + ) + })} +
+ +
+ + + + + ) } diff --git a/packages/neuron-ui/src/components/PasswordRequest/hooks.ts b/packages/neuron-ui/src/components/PasswordRequest/hooks.ts index db51eb2835..6f8991b5d6 100644 --- a/packages/neuron-ui/src/components/PasswordRequest/hooks.ts +++ b/packages/neuron-ui/src/components/PasswordRequest/hooks.ts @@ -164,7 +164,9 @@ export default ({ } case 'delete': { await deleteWallet({ id: walletID, password })(dispatch).then(status => { - if (status === ErrorCode.PasswordIncorrect) { + if (isSuccessResponse({ status })) { + onSuccess?.() + } else if (status === ErrorCode.PasswordIncorrect) { throw new PasswordIncorrectException() } }) diff --git a/packages/neuron-ui/src/components/ReplaceDuplicateWalletDialog/index.tsx b/packages/neuron-ui/src/components/ReplaceDuplicateWalletDialog/index.tsx new file mode 100644 index 0000000000..c644a496e9 --- /dev/null +++ b/packages/neuron-ui/src/components/ReplaceDuplicateWalletDialog/index.tsx @@ -0,0 +1,129 @@ +import React, { useMemo, useState, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import Dialog from 'widgets/Dialog' +import RadioGroup from 'widgets/RadioGroup' +import { useState as useGlobalState, useDispatch, updateWalletProperty, setCurrentWallet } from 'states' +import { RoutePath, isSuccessResponse } from 'utils' +import { replaceWallet } from 'services/remote' +import styles from './replaceDuplicateWalletDialog.module.scss' + +const useReplaceDuplicateWallet = () => { + const [extendedKey, setExtendedKey] = useState('') + const [importedWalletId, setImportedWalletId] = useState('') + + const onClose = useCallback(() => { + setImportedWalletId('') + }, [setImportedWalletId]) + + const onImportingExitingWalletError = ( + message: + | string + | { + content?: string + meta?: { [key: string]: string } + } + ) => { + try { + const msg = typeof message === 'string' ? '' : message.content + if (msg) { + const obj = JSON.parse(msg) + setExtendedKey(obj.extendedKey) + setImportedWalletId(obj.id) + } + } catch (error) { + onClose() + } + } + + const show = useMemo(() => !!extendedKey && !!importedWalletId, [importedWalletId, extendedKey]) + + return { + onImportingExitingWalletError, + dialogProps: { + show, + onClose, + extendedKey, + importedWalletId, + }, + } +} + +const ReplaceDuplicateWalletDialog = ({ + show, + onClose, + extendedKey, + importedWalletId, +}: { + show: boolean + onClose: () => void + extendedKey: string + importedWalletId: string +}) => { + const { + settings: { wallets = [] }, + } = useGlobalState() + const dispatch = useDispatch() + const navigate = useNavigate() + const [selectedId, setSelectedId] = useState('') + const [t] = useTranslation() + + const group = useMemo(() => wallets.filter(item => item.extendedKey === extendedKey), [wallets, extendedKey]) + + const handleGroupChange = useCallback( + (checked: string) => { + setSelectedId(checked as string) + }, + [setSelectedId] + ) + + const onConfirm = useCallback(() => { + replaceWallet({ + existingWalletId: selectedId, + importedWalletId, + }) + .then(res => { + if (isSuccessResponse(res)) { + navigate(RoutePath.Overview) + return + } + onClose() + }) + .finally(() => { + setSelectedId('') + }) + }, [selectedId, updateWalletProperty, onClose, dispatch, setCurrentWallet]) + + return ( + +
+

{t('settings.wallet-manager.importing-existing.detail')}

+
+ ({ + value: wallet.id, + label: {wallet.name}, + }))} + /> +
+
+
+ ) +} + +ReplaceDuplicateWalletDialog.displayName = 'ReplaceDuplicateWalletDialog' + +export default ReplaceDuplicateWalletDialog + +export { useReplaceDuplicateWallet } diff --git a/packages/neuron-ui/src/components/ReplaceDuplicateWalletDialog/replaceDuplicateWalletDialog.module.scss b/packages/neuron-ui/src/components/ReplaceDuplicateWalletDialog/replaceDuplicateWalletDialog.module.scss new file mode 100755 index 0000000000..a4b447ec48 --- /dev/null +++ b/packages/neuron-ui/src/components/ReplaceDuplicateWalletDialog/replaceDuplicateWalletDialog.module.scss @@ -0,0 +1,24 @@ +.content { + width: 648px; + .detail { + margin: 0; + color: var(--secondary-text-color); + } + .groupWrap { + margin-top: 16px; + overflow-y: scroll; + border: 1px solid var(--divide-line-color); + border-radius: 8px; + padding: 16px 0 0 16px; + .radioItem { + padding: 0 0 16px; + &:hover, + &:focus { + background: transparent; + button { + visibility: visible; + } + } + } + } +} diff --git a/packages/neuron-ui/src/components/WalletSetting/index.tsx b/packages/neuron-ui/src/components/WalletSetting/index.tsx index 821ae19c7b..7dc3885e1e 100644 --- a/packages/neuron-ui/src/components/WalletSetting/index.tsx +++ b/packages/neuron-ui/src/components/WalletSetting/index.tsx @@ -1,18 +1,22 @@ -import React, { useEffect, useCallback, useState } from 'react' +import React, { useEffect, useCallback, useState, useMemo } from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { WalletWizardPath } from 'components/WalletWizard' -import { ReactComponent as EditWallet } from 'widgets/Icons/Edit.svg' -import { ReactComponent as DeleteWallet } from 'widgets/Icons/Delete.svg' -import { ReactComponent as CreateWallet } from 'widgets/Icons/Add.svg' -import { ReactComponent as ImportKeystore } from 'widgets/Icons/SoftWalletImportKeystore.svg' -import { ReactComponent as Export } from 'widgets/Icons/Export.svg' -import { ReactComponent as ImportHardware } from 'widgets/Icons/HardWalletImport.svg' -import { ReactComponent as AddSimple } from 'widgets/Icons/AddSimple.svg' +import { + Edit as EditWallet, + Add as CreateWallet, + Detect, + Delete as DeleteWallet, + Export, + AddSimple, + ImportKeystore, + ImportHardware, +} from 'widgets/Icons/icon' import Tooltip from 'widgets/Tooltip' import Toast from 'widgets/Toast' import { StateDispatch } from 'states' import WalletEditorDialog from 'components/WalletEditorDialog' +import DetectDuplicateWalletDialog from 'components/DetectDuplicateWalletDialog' import { backToTop, RoutePath, @@ -63,6 +67,13 @@ const WalletSetting = ({ const [showEditDialog, setShowEditDialog] = useState(false) const [editWallet, setEditWallet] = useState('') const [notice, setNotice] = useState('') + const [showDetectDialog, setShowDetectDialog] = useState(false) + + const hasDuplicateWallets = useMemo(() => { + const extendedKeys = wallets.map(item => item.extendedKey) + const extendedKeySet = new Set(extendedKeys) + return extendedKeys.length > extendedKeySet.size + }, [wallets]) useEffect(() => { backToTop() @@ -124,6 +135,14 @@ const WalletSetting = ({ setNotice(t('settings.wallet-manager.edit-success')) }, [setShowEditDialog, setNotice]) + const handleDetect = useCallback(() => { + setShowDetectDialog(true) + }, [setShowDetectDialog]) + + const onDetectDialogClose = useCallback(() => { + setShowDetectDialog(false) + }, [setShowDetectDialog]) + return (
-
+
@@ -168,10 +187,16 @@ const WalletSetting = ({ trigger="click" showTriangle > - + + {hasDuplicateWallets ? ( + + ) : null}
+ {showDetectDialog ? : null} + setNotice('')} />
) diff --git a/packages/neuron-ui/src/components/WalletSetting/walletSetting.module.scss b/packages/neuron-ui/src/components/WalletSetting/walletSetting.module.scss index 9ad7f189cc..9905525747 100644 --- a/packages/neuron-ui/src/components/WalletSetting/walletSetting.module.scss +++ b/packages/neuron-ui/src/components/WalletSetting/walletSetting.module.scss @@ -7,14 +7,19 @@ padding: 0 0 5px 8px; } -.addWrap { +.actionWrap { position: relative; - display: inline-block; + display: flex; + gap: 24px; margin: 16px 0 0 14px; } -.addBtn { +.actionBtn { @include icon-hover-button(); + svg { + width: 16px; + height: 16px; + } } .actions { diff --git a/packages/neuron-ui/src/components/WalletWizard/index.tsx b/packages/neuron-ui/src/components/WalletWizard/index.tsx index 15f1c79c71..384492854a 100644 --- a/packages/neuron-ui/src/components/WalletWizard/index.tsx +++ b/packages/neuron-ui/src/components/WalletWizard/index.tsx @@ -18,6 +18,7 @@ import { } from 'utils' import i18n from 'utils/i18n' import MnemonicInput from 'widgets/MnemonicInput' +import ReplaceDuplicateWalletDialog, { useReplaceDuplicateWallet } from 'components/ReplaceDuplicateWalletDialog' import Alert from 'widgets/Alert' import { Loading } from 'widgets/Icons/icon' import TextField from 'widgets/TextField' @@ -49,6 +50,10 @@ const importWalletWithMnemonic = (params: Controller.ImportMnemonicParams) => (n importedWalletDialogShown.setStatus(res.result.id, true) navigate(RoutePath.Overview) } else if (res.status > 0) { + if (res.status === ErrorCode.DuplicateImportWallet) { + throw res + } + showErrorMessage(i18n.t(`messages.error`), i18n.t(`messages.codes.${res.status}`)) } else if (res.message) { const msg = typeof res.message === 'string' ? res.message : res.message.content || '' @@ -315,6 +320,8 @@ const Submission = ({ state = initState, wallets = [], dispatch }: WizardElement const [t] = useTranslation() const message = 'wizard.set-wallet-name-and-password' + const { onImportingExitingWalletError, dialogProps } = useReplaceDuplicateWallet() + const isNameUnused = useMemo(() => name && !wallets.find(w => w.name === name), [name, wallets]) const isPwdComplex = useMemo(() => { try { @@ -372,7 +379,13 @@ const Submission = ({ state = initState, wallets = [], dispatch }: WizardElement if (type === MnemonicAction.Create) { createWalletWithMnemonic(p)(navigate).finally(() => closeDialog()) } else { - importWalletWithMnemonic(p)(navigate).finally(() => closeDialog()) + importWalletWithMnemonic(p)(navigate) + .catch(error => { + onImportingExitingWalletError(error.message) + }) + .finally(() => { + closeDialog() + }) } }, 0) }, @@ -380,52 +393,55 @@ const Submission = ({ state = initState, wallets = [], dispatch }: WizardElement ) return ( -
- {type === MnemonicAction.Create && ( -
- {[0, 1, 2].map(v => ( -
- ))} + <> + + {type === MnemonicAction.Create && ( +
+ {[0, 1, 2].map(v => ( +
+ ))} +
+ )} +
{t(message)}
+ {submissionInputs.map((input, idx) => ( +
+ +
+ ))} +
+ + {t('wizard.new-name')} + + + {t('wizard.complex-password')} + + + {t('wizard.same-password')} +
- )} -
{t(message)}
- {submissionInputs.map((input, idx) => ( -
- +
+ +
- ))} -
- - {t('wizard.new-name')} - - - {t('wizard.complex-password')} - - - {t('wizard.same-password')} - -
-
- -
- - + + + + ) } diff --git a/packages/neuron-ui/src/locales/en.json b/packages/neuron-ui/src/locales/en.json index cb3558039d..2ce6ffc762 100644 --- a/packages/neuron-ui/src/locales/en.json +++ b/packages/neuron-ui/src/locales/en.json @@ -191,7 +191,8 @@ "repeat-password": "Repeat Password", "finish-create": "Finish Creating", "creating-wallet": "Wallet is being preparing, please wait", - "add-one": "Add One More" + "add-one": "Add One More", + "detect-duplicate-wallets": "Detect Duplicate Wallets" }, "import-keystore": { "title": "Import Keystore File", @@ -392,6 +393,15 @@ "password": "Password", "wallet-detail": { "balance": "Balance" + }, + "importing-existing": { + "title": "Importing an existing wallet", + "detail": "You imported a wallet that already exists, choose to replace the existing wallet or cancel the import.", + "replace": "Replace" + }, + "detected-duplicate": { + "title": "Detected duplicate wallets", + "detail": "You have duplicate wallets in your account and it is recommended that you choose to keep the wallet that needs to be unique to make it less difficult to manage." } }, "network": { diff --git a/packages/neuron-ui/src/locales/zh-tw.json b/packages/neuron-ui/src/locales/zh-tw.json index fbd4630cba..58e78a4864 100644 --- a/packages/neuron-ui/src/locales/zh-tw.json +++ b/packages/neuron-ui/src/locales/zh-tw.json @@ -185,7 +185,8 @@ "repeat-password": "重復密碼", "finish-create": "完成創建", "creating-wallet": "錢包準備中,請稍後", - "add-one": "添加錢包" + "add-one": "添加錢包", + "detect-duplicate-wallets": "檢測重複錢包" }, "import-keystore": { "title": "導入 Keystore 文件", @@ -387,6 +388,15 @@ "password": "密碼", "wallet-detail": { "balance": "餘額" + }, + "importing-existing": { + "title": "導入已存在錢包", + "detail": "您導入了一個已經存在的錢包,請選擇替換現有錢包或取消導入。", + "replace": "替換" + }, + "detected-duplicate": { + "title": "檢測到重複的錢包", + "detail": "您的帳戶中有重複的錢包,建議您選擇保留需要獨一無二的錢包,以便於更輕鬆地管理。" } }, "network": { diff --git a/packages/neuron-ui/src/locales/zh.json b/packages/neuron-ui/src/locales/zh.json index 3144a97ca0..794abc32b8 100644 --- a/packages/neuron-ui/src/locales/zh.json +++ b/packages/neuron-ui/src/locales/zh.json @@ -184,7 +184,8 @@ "repeat-password": "重复密码", "finish-create": "完成创建", "creating-wallet": "钱包准备中,请稍后", - "add-one": "添加钱包" + "add-one": "添加钱包", + "detect-duplicate-wallets": "检测重复钱包" }, "import-keystore": { "title": "导入 Keystore 文件", @@ -385,6 +386,15 @@ "password": "密码", "wallet-detail": { "balance": "余额" + }, + "importing-existing": { + "title": "导入已存在钱包", + "detail": "您导入了一个已经存在的钱包,请选择替换现有钱包或取消导入。", + "replace": "替换" + }, + "detected-duplicate": { + "title": "检测到重复的钱包", + "detail": "您的账户中有重复的钱包,建议您选择保留需要独一无二的钱包,以便于更轻松地管理。" } }, "network": { diff --git a/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts b/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts index be0709d0db..9b12b9dccb 100644 --- a/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts +++ b/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts @@ -59,6 +59,7 @@ type Action = | 'create-wallet' | 'update-wallet' | 'delete-wallet' + | 'replace-wallet' | 'backup-wallet' | 'update-wallet-start-block-number' | 'get-all-addresses' diff --git a/packages/neuron-ui/src/services/remote/wallets.ts b/packages/neuron-ui/src/services/remote/wallets.ts index 9c1e496c66..33c26d1f82 100644 --- a/packages/neuron-ui/src/services/remote/wallets.ts +++ b/packages/neuron-ui/src/services/remote/wallets.ts @@ -8,6 +8,7 @@ export const importKeystore = remoteApi('import export const createWallet = remoteApi('create-wallet') export const updateWallet = remoteApi('update-wallet') export const deleteWallet = remoteApi('delete-wallet') +export const replaceWallet = remoteApi('replace-wallet') export const backupWallet = remoteApi('backup-wallet') export const updateWalletStartBlockNumber = remoteApi( 'update-wallet-start-block-number' diff --git a/packages/neuron-ui/src/states/init/wallet.ts b/packages/neuron-ui/src/states/init/wallet.ts index 0de42e6d8e..617f6276c7 100644 --- a/packages/neuron-ui/src/states/init/wallet.ts +++ b/packages/neuron-ui/src/states/init/wallet.ts @@ -5,6 +5,7 @@ export const emptyWallet: State.Wallet = { id: '', balance: '0', addresses: [], + extendedKey: '', } const wallet = currentWallet.load() @@ -17,6 +18,7 @@ export const walletState: State.Wallet = { device: wallet?.device, isHD: wallet?.isHD, isWatchOnly: wallet?.isWatchOnly, + extendedKey: '', } export default walletState diff --git a/packages/neuron-ui/src/stories/Navbar.stories.tsx b/packages/neuron-ui/src/stories/Navbar.stories.tsx index f4d5b24c27..0e75b2d797 100644 --- a/packages/neuron-ui/src/stories/Navbar.stories.tsx +++ b/packages/neuron-ui/src/stories/Navbar.stories.tsx @@ -3,7 +3,7 @@ import Navbar from 'containers/Navbar' import { withRouter } from 'storybook-addon-react-router-v6' import { initStates } from 'states' -const wallets: State.WalletIdentity[] = [{ id: 'wallet id', name: 'wallet name' }] +const wallets: State.WalletIdentity[] = [{ id: 'wallet id', name: 'wallet name', extendedKey: '' }] const meta: Meta = { component: Navbar, diff --git a/packages/neuron-ui/src/stories/PasswordRequest.stories.tsx b/packages/neuron-ui/src/stories/PasswordRequest.stories.tsx index 0b28b821f6..1bf2f6f8ed 100644 --- a/packages/neuron-ui/src/stories/PasswordRequest.stories.tsx +++ b/packages/neuron-ui/src/stories/PasswordRequest.stories.tsx @@ -22,7 +22,7 @@ const states: { [title: string]: State.AppWithNeuronWallet } = { ...initStates, settings: { ...initStates.settings, - wallets: [{ id: '1', name: 'test wallet' }], + wallets: [{ id: '1', name: 'test wallet', extendedKey: '' }], }, app: { ...initStates.app, @@ -36,7 +36,7 @@ const states: { [title: string]: State.AppWithNeuronWallet } = { ...initStates, settings: { ...initStates.settings, - wallets: [{ id: '1', name: 'test wallet' }], + wallets: [{ id: '1', name: 'test wallet', extendedKey: '' }], }, app: { ...initStates.app, @@ -50,7 +50,7 @@ const states: { [title: string]: State.AppWithNeuronWallet } = { ...initStates, settings: { ...initStates.settings, - wallets: [{ id: '1', name: 'test wallet' }], + wallets: [{ id: '1', name: 'test wallet', extendedKey: '' }], }, app: { ...initStates.app, @@ -64,7 +64,7 @@ const states: { [title: string]: State.AppWithNeuronWallet } = { ...initStates, settings: { ...initStates.settings, - wallets: [{ id: '1', name: 'test wallet' }], + wallets: [{ id: '1', name: 'test wallet', extendedKey: '' }], }, app: { ...initStates.app, @@ -78,7 +78,7 @@ const states: { [title: string]: State.AppWithNeuronWallet } = { ...initStates, settings: { ...initStates.settings, - wallets: [{ id: '1', name: 'test wallet' }], + wallets: [{ id: '1', name: 'test wallet', extendedKey: '' }], }, app: { ...initStates.app, @@ -92,7 +92,7 @@ const states: { [title: string]: State.AppWithNeuronWallet } = { ...initStates, settings: { ...initStates.settings, - wallets: [{ id: '1', name: 'test wallet' }], + wallets: [{ id: '1', name: 'test wallet', extendedKey: '' }], }, app: { ...initStates.app, diff --git a/packages/neuron-ui/src/stories/WalletSetting.stories.tsx b/packages/neuron-ui/src/stories/WalletSetting.stories.tsx index 219204eb88..208a16bc7c 100644 --- a/packages/neuron-ui/src/stories/WalletSetting.stories.tsx +++ b/packages/neuron-ui/src/stories/WalletSetting.stories.tsx @@ -9,10 +9,12 @@ const states: { [title: string]: State.WalletIdentity[] } = { { id: '1', name: 'Wallet 1', + extendedKey: '', }, { id: '2', name: 'Wallet 2', + extendedKey: '', }, ], } diff --git a/packages/neuron-ui/src/styles/mixin.scss b/packages/neuron-ui/src/styles/mixin.scss index 1d8ea27ba7..9dfa2f6de3 100644 --- a/packages/neuron-ui/src/styles/mixin.scss +++ b/packages/neuron-ui/src/styles/mixin.scss @@ -161,6 +161,7 @@ color: $normalColor !important; font-weight: 500; font-size: 14px; + line-height: 20px; cursor: pointer; display: flex; align-items: center; diff --git a/packages/neuron-ui/src/types/App/index.d.ts b/packages/neuron-ui/src/types/App/index.d.ts index 2c01956c4b..a7187731a4 100644 --- a/packages/neuron-ui/src/types/App/index.d.ts +++ b/packages/neuron-ui/src/types/App/index.d.ts @@ -207,6 +207,7 @@ declare namespace State { isHD?: boolean isWatchOnly?: boolean startBlockNumber?: string + extendedKey: string } interface DeviceInfo { diff --git a/packages/neuron-ui/src/types/Controller/index.d.ts b/packages/neuron-ui/src/types/Controller/index.d.ts index a0a743e90f..5be3667b02 100644 --- a/packages/neuron-ui/src/types/Controller/index.d.ts +++ b/packages/neuron-ui/src/types/Controller/index.d.ts @@ -52,6 +52,11 @@ declare namespace Controller { password: string } + interface ReplaceWalletParams { + existingWalletId: string + importedWalletId: string + } + interface BackupWalletParams { id: string password: string diff --git a/packages/neuron-ui/src/utils/enums.ts b/packages/neuron-ui/src/utils/enums.ts index d7b457a6da..647f421446 100644 --- a/packages/neuron-ui/src/utils/enums.ts +++ b/packages/neuron-ui/src/utils/enums.ts @@ -109,6 +109,7 @@ export enum ErrorCode { DeviceInSleep = 501, // active warning WaitForFullySynced = 600, + DuplicateImportWallet = 118, } export enum SyncStatus { diff --git a/packages/neuron-ui/src/widgets/Icons/Detect.svg b/packages/neuron-ui/src/widgets/Icons/Detect.svg new file mode 100644 index 0000000000..30a972e89e --- /dev/null +++ b/packages/neuron-ui/src/widgets/Icons/Detect.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/neuron-ui/src/widgets/Icons/icon.tsx b/packages/neuron-ui/src/widgets/Icons/icon.tsx index e58d9aa183..ecdd2b8c2e 100644 --- a/packages/neuron-ui/src/widgets/Icons/icon.tsx +++ b/packages/neuron-ui/src/widgets/Icons/icon.tsx @@ -52,6 +52,10 @@ import { ReactComponent as LockSvg } from './Lock.svg' import { ReactComponent as LockCellSvg } from './LockCell.svg' import { ReactComponent as UnLockSvg } from './Unlock.svg' import { ReactComponent as ConsumeSvg } from './Consume.svg' +import { ReactComponent as DetectSvg } from './Detect.svg' +import { ReactComponent as DeleteSvg } from './Delete.svg' +import { ReactComponent as ImportKeystoreSvg } from './SoftWalletImportKeystore.svg' +import { ReactComponent as ImportHardwareSvg } from './HardWalletImport.svg' import styles from './icon.module.scss' @@ -120,3 +124,7 @@ export const Lock = WrapSvg(LockSvg, styles.withTheme) export const LockCell = WrapSvg(LockCellSvg) export const UnLock = WrapSvg(UnLockSvg) export const Consume = WrapSvg(ConsumeSvg) +export const Detect = WrapSvg(DetectSvg) +export const Delete = WrapSvg(DeleteSvg) +export const ImportKeystore = WrapSvg(ImportKeystoreSvg) +export const ImportHardware = WrapSvg(ImportHardwareSvg) diff --git a/packages/neuron-ui/src/widgets/RadioGroup/index.tsx b/packages/neuron-ui/src/widgets/RadioGroup/index.tsx index 0355b90693..0268abc203 100644 --- a/packages/neuron-ui/src/widgets/RadioGroup/index.tsx +++ b/packages/neuron-ui/src/widgets/RadioGroup/index.tsx @@ -12,8 +12,8 @@ export interface RadioGroupOptions { export interface RadioGroupProps { options: RadioGroupOptions[] onChange?: (arg: string) => void - defaultValue?: string | number - value?: string | number + defaultValue?: string + value?: string itemClassName?: string className?: string inputIdPrefix?: string diff --git a/packages/neuron-wallet/src/controllers/api.ts b/packages/neuron-wallet/src/controllers/api.ts index a766ecbbc3..5545842aab 100644 --- a/packages/neuron-wallet/src/controllers/api.ts +++ b/packages/neuron-wallet/src/controllers/api.ts @@ -376,6 +376,10 @@ export default class ApiController { return this.#walletsController.delete({ id, password }) }) + handle('replace-wallet', async (_, { existingWalletId = '', importedWalletId = '' }) => { + return this.#walletsController.replaceWallet(existingWalletId, importedWalletId) + }) + handle('backup-wallet', async (_, { id = '', password = '' }) => { return this.#walletsController.backup({ id, password }) }) diff --git a/packages/neuron-wallet/src/controllers/wallets.ts b/packages/neuron-wallet/src/controllers/wallets.ts index 2865217439..61b972a0b4 100644 --- a/packages/neuron-wallet/src/controllers/wallets.ts +++ b/packages/neuron-wallet/src/controllers/wallets.ts @@ -42,7 +42,7 @@ export default class WalletsController { } return { status: ResponseCode.Success, - result: wallets.map(({ name, id, device }) => ({ name, id, device })), + result: wallets.map(({ name, id, device, extendedKey }) => ({ name, id, device, extendedKey })), } } @@ -632,6 +632,15 @@ export default class WalletsController { } } + public async replaceWallet(existingWalletId: string, importedWalletId: string): Promise> { + const walletsService = WalletsService.getInstance() + await walletsService.replace(existingWalletId, importedWalletId) + + return { + status: ResponseCode.Success, + } + } + // Important: Check password before calling this, unless it's backing up a watch only wallet. private async backupWallet(id: string): Promise> { const walletsService = WalletsService.getInstance() diff --git a/packages/neuron-wallet/src/exceptions/wallet.ts b/packages/neuron-wallet/src/exceptions/wallet.ts index fc69196c86..25962d77a3 100644 --- a/packages/neuron-wallet/src/exceptions/wallet.ts +++ b/packages/neuron-wallet/src/exceptions/wallet.ts @@ -78,6 +78,13 @@ export class WalletFunctionNotSupported extends Error { } } +export class DuplicateImportWallet extends Error { + public code = 118 + constructor(errorStr: string) { + super(errorStr) + } +} + export default { WalletNotFound, CurrentWalletNotSet, @@ -88,4 +95,5 @@ export default { LiveCapacityNotEnough, CapacityNotEnoughForChange, InvalidKeystore, + DuplicateImportWallet, } diff --git a/packages/neuron-wallet/src/services/wallets.ts b/packages/neuron-wallet/src/services/wallets.ts index 021bc3202c..6d7306c8d4 100644 --- a/packages/neuron-wallet/src/services/wallets.ts +++ b/packages/neuron-wallet/src/services/wallets.ts @@ -1,5 +1,5 @@ import { v4 as uuid } from 'uuid' -import { WalletNotFound, IsRequired, UsedName, WalletFunctionNotSupported } from '../exceptions' +import { WalletNotFound, IsRequired, UsedName, WalletFunctionNotSupported, DuplicateImportWallet } from '../exceptions' import Store from '../models/store' import Keystore from '../models/keys/keystore' import WalletDeletedSubject from '../models/subjects/wallet-deleted-subject' @@ -278,6 +278,7 @@ export default class WalletService { private listStore: Store // Save wallets (meta info except keystore, which is persisted separately) private walletsKey = 'wallets' private currentWalletKey = 'current' + private importedWallet: Wallet | undefined public static getInstance = () => { if (!WalletService.instance) { @@ -375,18 +376,55 @@ export default class WalletService { throw new UsedName('Wallet') } - const wallet = this.fromJSON({ ...props, id: uuid() }) + const id = uuid() + + const wallet = this.fromJSON({ ...props, id }) if (!wallet.isHardware()) { wallet.saveKeystore(props.keystore!) } + if (this.getAll().find(item => item.extendedKey === props.extendedKey)) { + this.importedWallet = wallet + throw new DuplicateImportWallet(JSON.stringify({ extendedKey: props.extendedKey, id })) + } + this.listStore.writeSync(this.walletsKey, [...this.getAll(), wallet.toJSON()]) this.setCurrent(wallet.id) return wallet } + public replace = async (existingWalletId: string, importedWalletId: string) => { + const wallet = this.get(existingWalletId) + if (!wallet || !this.importedWallet) { + throw new WalletNotFound(existingWalletId) + } + + const newWallet = this.importedWallet?.toJSON() + if (importedWalletId !== newWallet.id) { + throw new WalletNotFound(importedWalletId) + } + if (wallet.toJSON().extendedKey !== newWallet.extendedKey) { + throw new Error('The wallets are not the same and cannot be replaced.') + } + + const wallets = this.getAll() + + this.listStore.writeSync(this.walletsKey, [...wallets, newWallet]) + + this.setCurrent(newWallet.id) + + await AddressService.deleteByWalletId(existingWalletId) + + const newWallets = wallets.filter(w => w.id !== existingWalletId) + this.listStore.writeSync(this.walletsKey, [...newWallets, newWallet]) + + if (!wallet.isHardware()) { + wallet.deleteKeystore() + } + } + public update = (id: string, props: Omit) => { const wallets = this.getAll() const index = wallets.findIndex((w: WalletProperties) => w.id === id) diff --git a/packages/neuron-wallet/tests/services/wallets.test.ts b/packages/neuron-wallet/tests/services/wallets.test.ts index e43a8f7c56..f696f2c270 100644 --- a/packages/neuron-wallet/tests/services/wallets.test.ts +++ b/packages/neuron-wallet/tests/services/wallets.test.ts @@ -1,6 +1,6 @@ import Keystore from '../../src/models/keys/keystore' import { when } from 'jest-when' -import { WalletFunctionNotSupported } from '../../src/exceptions/wallet' +import { WalletFunctionNotSupported, DuplicateImportWallet } from '../../src/exceptions/wallet' import { AddressType } from '../../src/models/keys/address' import { Manufacturer } from '../../src/services/hardware/common' @@ -53,6 +53,7 @@ describe('wallet service', () => { let wallet2: WalletProperties let wallet3: WalletProperties let wallet4: WalletProperties + let wallet5: WalletProperties const fakePublicKey = 'keykeykeykeykeykeykeykeykeykeykeykeykeykeykeykeykeykeykeykeykeykey' const fakeChainCode = 'codecodecodecodecodecodecodecodecodecodecodecodecodecodecodecode' @@ -121,7 +122,7 @@ describe('wallet service', () => { wallet3 = { name: 'wallet-test3', id: '', - extendedKey: 'a', + extendedKey: 'b', keystore: new Keystore( { cipher: 'wallet3', @@ -155,6 +156,29 @@ describe('wallet service', () => { addressType: AddressType.Receiving, }, } + + wallet5 = { + name: 'wallet-test5', + id: '', + extendedKey: 'a', + keystore: new Keystore( + { + cipher: 'wallet5', + cipherparams: { iv: 'wallet1' }, + ciphertext: 'wallet5', + kdf: '5', + kdfparams: { + dklen: 1, + n: 1, + r: 1, + p: 1, + salt: '1', + }, + mac: '5', + }, + '5' + ), + } }) afterEach(() => { @@ -424,7 +448,7 @@ describe('wallet service', () => { }) expect(stubbedGenerateAndSaveForExtendedKeyQueue).toHaveBeenCalledWith({ walletId: createdWallet3.id, - extendedKey: expect.objectContaining({ publicKey: 'a' }), + extendedKey: expect.objectContaining({ publicKey: 'b' }), isImporting: false, receivingAddressCount: 20, changeAddressCount: 10, @@ -483,4 +507,31 @@ describe('wallet service', () => { expect(checkAndGenerateAddressesMock).toHaveBeenCalledTimes(1) }) }) + + describe('DuplicateImportWallet', () => { + beforeEach(() => { + walletService.create(wallet2) + }) + it('create an exiting wallet', () => { + expect(() => walletService.create(wallet5)).toThrowError(DuplicateImportWallet) + }) + }) + + describe('ReplaceWallet', () => { + let createdWallet2: any + beforeEach(() => { + createdWallet2 = walletService.create(wallet2) + }) + it('replace an exiting wallet', async () => { + try { + walletService.create(wallet5) + } catch (error) { + const { extendedKey, id } = JSON.parse(error.message) + await walletService.replace(createdWallet2.id, id) + expect(extendedKey).toBe('a') + expect(() => walletService.get(createdWallet2.id)).toThrowError() + expect(walletService.get(id).name).toBe(wallet5.name) + } + }) + }) })