diff --git a/packages/neuron-ui/src/components/AddressBook/addressBook.module.scss b/packages/neuron-ui/src/components/AddressBook/addressBook.module.scss index 118ab0d4b2..325e953d3e 100644 --- a/packages/neuron-ui/src/components/AddressBook/addressBook.module.scss +++ b/packages/neuron-ui/src/components/AddressBook/addressBook.module.scss @@ -168,6 +168,20 @@ } } +.privateKey { + background: transparent; + border: none; + cursor: pointer; + &:hover { + svg { + g, + path { + stroke: var(--primary-color); + } + } + } +} + @media screen and (max-width: 1330px) { .container { .balance { diff --git a/packages/neuron-ui/src/components/AddressBook/index.tsx b/packages/neuron-ui/src/components/AddressBook/index.tsx index 19c5e5d7c3..947c8675cb 100644 --- a/packages/neuron-ui/src/components/AddressBook/index.tsx +++ b/packages/neuron-ui/src/components/AddressBook/index.tsx @@ -3,7 +3,8 @@ import { useTranslation } from 'react-i18next' import { useState as useGlobalState, useDispatch } from 'states' import Dialog from 'widgets/Dialog' import CopyZone from 'widgets/CopyZone' -import { Copy } from 'widgets/Icons/icon' +import ViewPrivateKey from 'components/ViewPrivateKey' +import { Copy, PrivateKey } from 'widgets/Icons/icon' import Table, { TableProps, SortType } from 'widgets/Table' import { shannonToCKBFormatter, useLocalDescription } from 'utils' import { HIDE_BALANCE } from 'utils/const' @@ -44,6 +45,7 @@ const AddressBook = ({ onClose }: { onClose?: () => void }) => { const dispatch = useDispatch() const { onChangeEditStatus, onSubmitDescription } = useLocalDescription('address', walletId, dispatch) + const [viewPrivateKeyAddress, setViewPrivateKeyAddress] = useState('') const columns = useMemo['columns']>( () => [ @@ -149,6 +151,21 @@ const AddressBook = ({ onClose }: { onClose?: () => void }) => { return 0 }, }, + { + title: '', + dataIndex: 'key', + align: 'left', + width: '40px', + render(_, __, { address }) { + return ( + + + + ) + }, + }, ], [t] ) @@ -179,6 +196,10 @@ const AddressBook = ({ onClose }: { onClose?: () => void }) => { } /> + + {!!viewPrivateKeyAddress && ( + setViewPrivateKeyAddress('')} /> + )} ) diff --git a/packages/neuron-ui/src/components/Receive/index.tsx b/packages/neuron-ui/src/components/Receive/index.tsx index 5387b873d0..a182a1ea2e 100644 --- a/packages/neuron-ui/src/components/Receive/index.tsx +++ b/packages/neuron-ui/src/components/Receive/index.tsx @@ -7,7 +7,8 @@ import Button from 'widgets/Button' import CopyZone from 'widgets/CopyZone' import QRCode from 'widgets/QRCode' import Tooltip from 'widgets/Tooltip' -import { AddressTransform, Download, Copy, Attention, SuccessNoBorder } from 'widgets/Icons/icon' +import ViewPrivateKey from 'components/ViewPrivateKey' +import { AddressTransform, Download, Copy, Attention, SuccessNoBorder, PrivateKey } from 'widgets/Icons/icon' import VerifyHardwareAddress from './VerifyHardwareAddress' import styles from './receive.module.scss' import { useCopyAndDownloadQrCode, useSwitchAddress } from './hooks' @@ -29,6 +30,7 @@ export const AddressQrCodeWithCopyZone = ({ ) const [isCopySuccess, setIsCopySuccess] = useState(false) + const [showViewPrivateKey, setShowViewPrivateKey] = useState(false) const timer = useRef>() const { ref, onCopyQrCode, onDownloadQrCode, showCopySuccess } = useCopyAndDownloadQrCode() @@ -70,19 +72,27 @@ export const AddressQrCodeWithCopyZone = ({ {showAddress} - +
+ + +
+ + {showViewPrivateKey && setShowViewPrivateKey(false)} />} ) } diff --git a/packages/neuron-ui/src/components/Receive/receive.module.scss b/packages/neuron-ui/src/components/Receive/receive.module.scss index 68b6ab7a56..0f9587a1ea 100644 --- a/packages/neuron-ui/src/components/Receive/receive.module.scss +++ b/packages/neuron-ui/src/components/Receive/receive.module.scss @@ -125,26 +125,43 @@ color: var(--main-text-color); } -.addressToggle { - width: 100%; +.actionWrap { margin-top: 8px; - appearance: none; - border: none; - background: none; display: flex; justify-content: center; - align-items: center; - font-size: 12px; - font-family: PingFang SC; - font-style: normal; - font-weight: 500; - color: var(--primary-color); - line-height: normal; - cursor: pointer; + gap: 32px; - svg { - pointer-events: none; - margin-right: 5px; + button { + appearance: none; + border: none; + background: none; + font-size: 12px; + font-family: PingFang SC; + font-style: normal; + font-weight: 500; + color: var(--primary-color); + line-height: normal; + cursor: pointer; + display: flex; + align-items: center; + } + + .addressToggle { + svg { + pointer-events: none; + margin-right: 5px; + } + } + + .privateKey { + svg { + width: 16px; + margin-right: 3px; + g, + path { + stroke: var(--primary-color); + } + } } } diff --git a/packages/neuron-ui/src/components/ViewPrivateKey/index.tsx b/packages/neuron-ui/src/components/ViewPrivateKey/index.tsx new file mode 100644 index 0000000000..b076b8dd1d --- /dev/null +++ b/packages/neuron-ui/src/components/ViewPrivateKey/index.tsx @@ -0,0 +1,145 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useState as useGlobalState } from 'states' +import Dialog from 'widgets/Dialog' +import TextField from 'widgets/TextField' +import Alert from 'widgets/Alert' +import { errorFormatter, useCopy, isSuccessResponse } from 'utils' +import { Attention, Copy } from 'widgets/Icons/icon' +import { getPrivateKeyByAddress } from 'services/remote' +import styles from './viewPrivateKey.module.scss' + +const ViewPrivateKey = ({ onClose, address }: { onClose?: () => void; address?: string }) => { + const [t] = useTranslation() + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [privateKey, setPrivateKey] = useState('') + const [isLoading, setIsLoading] = useState(false) + const { copied, onCopy, copyTimes } = useCopy() + const { + wallet: { id: walletID = '' }, + } = useGlobalState() + + useEffect(() => { + setPassword('') + setError('') + }, [setError, setPassword]) + + const onChange = useCallback( + (e: React.SyntheticEvent) => { + const { value } = e.target as HTMLInputElement + setPassword(value) + setError('') + }, + [setPassword, setError] + ) + + const onSubmit = useCallback( + async (e?: React.FormEvent) => { + if (e) { + e.preventDefault() + } + if (!password) { + return + } + setIsLoading(true) + try { + const res = await getPrivateKeyByAddress({ + walletID, + address, + password, + }) + + setIsLoading(false) + + if (!isSuccessResponse(res)) { + setError(errorFormatter(res.message, t)) + return + } + setPrivateKey(res.result) + } catch (err) { + setIsLoading(false) + } + }, + [walletID, password, setError, t] + ) + + if (privateKey) { + return ( + +
+
+ + {t('addresses.view-private-key-tip')} +
+ + {t('addresses.private-key')}} + value={privateKey} + field="password" + type="password" + disabled + suffix={ +
+ onCopy(privateKey)} /> +
+ } + /> + + {copied ? ( + + {t('common.copied')} + + ) : null} +
+
+ ) + } + return ( + +
+
+ + {t('addresses.view-private-key-tip')} +
+ + +
+
+ ) +} + +ViewPrivateKey.displayName = 'ViewPrivateKey' + +export default ViewPrivateKey diff --git a/packages/neuron-ui/src/components/ViewPrivateKey/viewPrivateKey.module.scss b/packages/neuron-ui/src/components/ViewPrivateKey/viewPrivateKey.module.scss new file mode 100644 index 0000000000..c5ecfa4b63 --- /dev/null +++ b/packages/neuron-ui/src/components/ViewPrivateKey/viewPrivateKey.module.scss @@ -0,0 +1,39 @@ +@import '../../styles/mixin.scss'; + +.passwordInput { + margin-top: 16px; +} + +.dialog { + width: 700px; +} + +.tip { + color: var(--warn-text-color); + background: var(--warn-background-color); + margin: -20px -16px 0; + display: flex; + align-items: center; + justify-content: center; + height: 32px; + font-size: 12px; + gap: 4px; + font-weight: 500; + border-bottom: 1px solid var(--warn-border-color); +} + +.label { + font-weight: 500; + color: var(--main-text-color); + font-size: 14px; +} + +.copy { + display: flex; + align-items: center; + margin-left: 6px; +} + +.notice { + @include dialog-copy-animation; +} diff --git a/packages/neuron-ui/src/locales/ar.json b/packages/neuron-ui/src/locales/ar.json index 3703ca6595..5ccbd9fce9 100644 --- a/packages/neuron-ui/src/locales/ar.json +++ b/packages/neuron-ui/src/locales/ar.json @@ -383,7 +383,10 @@ "request-payment": "طلب الدفع", "view-on-explorer": "عرض على المستكشف", "default-description": "لا شيء", - "all-address": "كل العناوين" + "all-address": "كل العناوين", + "view-private-key": "عرض المفتاح الخاص", + "private-key": "المفتاح الخاص", + "view-private-key-tip": "إذا فقدت مفتاحك الخاص، فستفقد أصولك. (عرضه في بيئة آمنة)" }, "settings": { "title": { @@ -822,7 +825,7 @@ "lock-info-dialog": { "address-info": "معلومات العنوان", "deprecated-address": "عنوان مهمل" - }, + }, "updates": { "check-updates": "تحقق من التحديثات", "checking-updates": "جارٍ التحقق...", @@ -1327,4 +1330,4 @@ "verify-wallet": "تحقق" } } -} \ No newline at end of file +} diff --git a/packages/neuron-ui/src/locales/en.json b/packages/neuron-ui/src/locales/en.json index 4c5142990e..51e7787181 100644 --- a/packages/neuron-ui/src/locales/en.json +++ b/packages/neuron-ui/src/locales/en.json @@ -383,7 +383,10 @@ "request-payment": "Request Payment", "view-on-explorer": "View on Explorer", "default-description": "None", - "all-address": "All" + "all-address": "All", + "view-private-key": "View Private Key", + "private-key": "Private Key", + "view-private-key-tip": "If you lose your private key, your assets will be gone. (View in secure environment)" }, "settings": { "title": { diff --git a/packages/neuron-ui/src/locales/es.json b/packages/neuron-ui/src/locales/es.json index 2ba59528b9..5ac87dd864 100644 --- a/packages/neuron-ui/src/locales/es.json +++ b/packages/neuron-ui/src/locales/es.json @@ -375,7 +375,10 @@ "request-payment": "Solicitar Pago", "view-on-explorer": "Ver en el Explorador", "default-description": "Ninguna", - "all-address": "Todas" + "all-address": "Todas", + "view-private-key": "Ver clave privada", + "private-key": "Clave privada", + "view-private-key-tip": "Si pierdes tu clave privada, tus activos desaparecerán. (Ver en un entorno seguro)" }, "settings": { "title": { diff --git a/packages/neuron-ui/src/locales/fr.json b/packages/neuron-ui/src/locales/fr.json index 9973dc4e07..8db344661e 100644 --- a/packages/neuron-ui/src/locales/fr.json +++ b/packages/neuron-ui/src/locales/fr.json @@ -382,7 +382,10 @@ "request-payment": "Demander un paiement", "view-on-explorer": "Voir sur l'explorateur", "default-description": "Aucun", - "all-address": "Toutes" + "all-address": "Toutes", + "view-private-key": "Voir la clé privée", + "private-key": "Clé privée", + "view-private-key-tip": "Si vous perdez votre clé privée, vos actifs seront perdus. (Voir dans un environnement sécurisé)" }, "settings": { "title": { diff --git a/packages/neuron-ui/src/locales/zh-tw.json b/packages/neuron-ui/src/locales/zh-tw.json index 6f69bd6bfd..72f4eb29b4 100644 --- a/packages/neuron-ui/src/locales/zh-tw.json +++ b/packages/neuron-ui/src/locales/zh-tw.json @@ -378,7 +378,10 @@ "request-payment": "請求支付", "view-on-explorer": "在瀏覽器中查看", "default-description": "無", - "all-address": "全部地址" + "all-address": "全部地址", + "view-private-key": "查看私鑰", + "private-key": "私鑰", + "view-private-key-tip": "如果您丟失了私鑰,您的資產將無法找回。(請在安全環境中查看)" }, "settings": { "title": { diff --git a/packages/neuron-ui/src/locales/zh.json b/packages/neuron-ui/src/locales/zh.json index 5f2e16103e..1fe8278e5f 100644 --- a/packages/neuron-ui/src/locales/zh.json +++ b/packages/neuron-ui/src/locales/zh.json @@ -376,7 +376,10 @@ "request-payment": "请求支付", "view-on-explorer": "在浏览器中查看", "default-description": "无", - "all-address": "全部地址" + "all-address": "全部地址", + "view-private-key": "查看私钥", + "private-key": "私钥", + "view-private-key-tip": "如果您丢失了私钥,您的资产将无法找回。(请在安全环境中查看)" }, "settings": { "title": { diff --git a/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts b/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts index c8cb6f0722..ba8703f318 100644 --- a/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts +++ b/packages/neuron-ui/src/services/remote/remoteApiWrapper.ts @@ -69,6 +69,7 @@ type Action = | 'backup-wallet' | 'update-wallet-start-block-number' | 'get-all-addresses' + | 'get-private-key-by-address' | 'update-address-description' | 'request-password' | 'send-tx' diff --git a/packages/neuron-ui/src/services/remote/wallets.ts b/packages/neuron-ui/src/services/remote/wallets.ts index 3f97e5b4ea..0610cb6521 100644 --- a/packages/neuron-ui/src/services/remote/wallets.ts +++ b/packages/neuron-ui/src/services/remote/wallets.ts @@ -14,6 +14,7 @@ export const updateWalletStartBlockNumber = remoteApi('get-all-addresses') +export const getPrivateKeyByAddress = remoteApi('get-private-key-by-address') export const updateAddressDescription = remoteApi('update-address-description') export const requestPassword = remoteApi('request-password') diff --git a/packages/neuron-ui/src/styles/theme.scss b/packages/neuron-ui/src/styles/theme.scss index 1421495687..4ef564a699 100644 --- a/packages/neuron-ui/src/styles/theme.scss +++ b/packages/neuron-ui/src/styles/theme.scss @@ -34,6 +34,7 @@ body { --main-pagination-text-color: #666666; --warn-text-color: #f68c2a; --warn-background-color: #fff6eb; + --warn-border-color: rgba(252, 136, 0, 0.2); --svg-fill-color: #ffffff; --lock-info-title-border: #e5e5e5; --script-tag-background-color: #eafbf6; @@ -89,6 +90,7 @@ body { --main-pagination-text-color: #999999; --warn-text-color: #f68c2a; --warn-background-color: rgba(255, 140, 0, 0.2); + --warn-border-color: rgba(252, 136, 0, 0.2); --svg-fill-color: #333333; --lock-info-title-border: #343e3c; --script-tag-background-color: #171b1a; diff --git a/packages/neuron-ui/src/types/Controller/index.d.ts b/packages/neuron-ui/src/types/Controller/index.d.ts index 6a1b6f22b2..3df4fe6782 100644 --- a/packages/neuron-ui/src/types/Controller/index.d.ts +++ b/packages/neuron-ui/src/types/Controller/index.d.ts @@ -184,6 +184,12 @@ declare namespace Controller { message: string } + interface GetPrivateKeyParams { + walletID: string + address?: string + password: string + } + interface VerifyMessageParams { address: string signature: string diff --git a/packages/neuron-ui/src/widgets/Icons/PrivateKey.svg b/packages/neuron-ui/src/widgets/Icons/PrivateKey.svg new file mode 100644 index 0000000000..c102dee0fd --- /dev/null +++ b/packages/neuron-ui/src/widgets/Icons/PrivateKey.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/neuron-ui/src/widgets/Icons/icon.tsx b/packages/neuron-ui/src/widgets/Icons/icon.tsx index 21eabcb23a..f9d0a6faf8 100644 --- a/packages/neuron-ui/src/widgets/Icons/icon.tsx +++ b/packages/neuron-ui/src/widgets/Icons/icon.tsx @@ -62,6 +62,7 @@ import { ReactComponent as QuestionSvg } from './Question.svg' import { ReactComponent as DetailsSvg } from './Details.svg' import { ReactComponent as ConfirmSvg } from './Confirm.svg' import { ReactComponent as UploadSvg } from './Upload.svg' +import { ReactComponent as PrivateKeySvg } from './PrivateKey.svg' import styles from './icon.module.scss' @@ -140,3 +141,4 @@ export const Question = WrapSvg(QuestionSvg) export const Details = WrapSvg(DetailsSvg) export const Confirm = WrapSvg(ConfirmSvg) export const Upload = WrapSvg(UploadSvg) +export const PrivateKey = WrapSvg(PrivateKeySvg) diff --git a/packages/neuron-ui/src/widgets/TextField/index.tsx b/packages/neuron-ui/src/widgets/TextField/index.tsx index 3f2208d302..d4f71acae0 100644 --- a/packages/neuron-ui/src/widgets/TextField/index.tsx +++ b/packages/neuron-ui/src/widgets/TextField/index.tsx @@ -132,8 +132,7 @@ const TextField = React.forwardRef( {...rest} /> )} - {suffix && (typeof suffix === 'string' ? {suffix} : suffix)} - {!suffix && type === 'password' && ( + {type === 'password' && ( : } )} + {suffix && (typeof suffix === 'string' ? {suffix} : suffix)} {hint ? {hint} : null} {error ? ( diff --git a/packages/neuron-wallet/src/controllers/api.ts b/packages/neuron-wallet/src/controllers/api.ts index f7f75d5b9e..f8cf5e4e50 100644 --- a/packages/neuron-wallet/src/controllers/api.ts +++ b/packages/neuron-wallet/src/controllers/api.ts @@ -440,6 +440,14 @@ export default class ApiController { return this.#walletsController.getAllAddresses(id) }) + handle('get-private-key-by-address', async (_, { walletID, password, address }) => { + return this.#walletsController.getPrivateKeyByAddress({ + walletID, + password, + address, + }) + }) + handle( 'update-address-description', async (_, params: { walletID: string; address: string; description: string }) => { diff --git a/packages/neuron-wallet/src/controllers/wallets.ts b/packages/neuron-wallet/src/controllers/wallets.ts index a47f3c2015..2307c6be40 100644 --- a/packages/neuron-wallet/src/controllers/wallets.ts +++ b/packages/neuron-wallet/src/controllers/wallets.ts @@ -699,4 +699,24 @@ export default class WalletsController { } }) } + + public async getPrivateKeyByAddress({ + walletID, + password, + address, + }: { + walletID: string + password: string + address?: string + }) { + const privateKey = await AddressService.getPrivateKeyByAddress({ + walletID, + password, + address, + }) + return { + status: ResponseCode.Success, + result: privateKey, + } + } } diff --git a/packages/neuron-wallet/src/services/addresses.ts b/packages/neuron-wallet/src/services/addresses.ts index 8f16b045a1..115e1927f1 100644 --- a/packages/neuron-wallet/src/services/addresses.ts +++ b/packages/neuron-wallet/src/services/addresses.ts @@ -1,5 +1,9 @@ import { hd } from '@ckb-lumos/lumos' +import { bytes } from '@ckb-lumos/lumos/codec' +import WalletService from './wallets' +import { AddressNotFound } from '../exceptions' import { publicKeyToAddress, DefaultAddressNumber } from '../utils/scriptAndAddress' +import AssetAccountService from './asset-account-service' import { Address as AddressInterface } from '../models/address' import AddressCreatedSubject from '../models/subjects/address-created-subject' import NetworksService from '../services/networks' @@ -445,4 +449,36 @@ export default class AddressService { public static async deleteByWalletId(walletId: string): Promise { await getConnection().createQueryBuilder().delete().from(HdPublicKeyInfo).where({ walletId }).execute() } + + public static async getPrivateKeyByAddress({ + walletID, + password, + address, + }: { + walletID: string + password: string + address?: string + }): Promise { + const wallet = WalletService.getInstance().get(walletID) + + const addresses = await AddressService.getAddressesByWalletId(walletID) + let addr = address ? addresses.find(addr => addr.address === address) : addresses[0] + + if (!addr) { + const usedBlake160s = new Set(await AssetAccountService.blake160sOfAssetAccounts()) + addr = addresses.find(a => !usedBlake160s.has(a.blake160))! + } + + if (!addr) { + throw new AddressNotFound() + } + + const masterPrivateKey = wallet.loadKeystore().extendedPrivateKey(password) + const masterKeychain = new hd.Keychain( + Buffer.from(bytes.bytify(masterPrivateKey.privateKey)), + Buffer.from(bytes.bytify(masterPrivateKey.chainCode)) + ) + + return `0x${masterKeychain.derivePath(addr.path).privateKey.toString('hex')}` + } }