diff --git a/packages/neuron-ui/src/components/CellInfoDialog/cellInfoDialog.module.scss b/packages/neuron-ui/src/components/CellInfoDialog/cellInfoDialog.module.scss index 148d7b4f99..1831f13f93 100644 --- a/packages/neuron-ui/src/components/CellInfoDialog/cellInfoDialog.module.scss +++ b/packages/neuron-ui/src/components/CellInfoDialog/cellInfoDialog.module.scss @@ -1,3 +1,5 @@ +@import '../../styles/mixin.scss'; + .cellInfoDialog { min-width: 650px; } @@ -270,3 +272,7 @@ top: 4px; cursor: pointer; } + +.notice { + @include dialog-copy-animation; +} diff --git a/packages/neuron-ui/src/components/CellInfoDialog/index.tsx b/packages/neuron-ui/src/components/CellInfoDialog/index.tsx index 20bc08a669..e75da3363a 100644 --- a/packages/neuron-ui/src/components/CellInfoDialog/index.tsx +++ b/packages/neuron-ui/src/components/CellInfoDialog/index.tsx @@ -1,11 +1,14 @@ -import React, { useMemo, useState } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import Dialog from 'widgets/Dialog' -import { calculateUsedCapacity, shannonToCKBFormatter } from 'utils' +import { calculateUsedCapacity, getExplorerUrl, shannonToCKBFormatter, truncateMiddle, useCopy } from 'utils' import { useTranslation } from 'react-i18next' import Tabs from 'widgets/Tabs' import { type TFunction } from 'i18next' import { Script } from '@ckb-lumos/base' import Switch from 'widgets/Switch' +import { Copy, ExplorerIcon } from 'widgets/Icons/icon' +import Alert from 'widgets/Alert' +import { openExternal } from 'services/remote' import styles from './cellInfoDialog.module.scss' type ScriptRenderType = 'table' | 'raw' @@ -108,13 +111,26 @@ const useTabs = ({ t, output }: { t: TFunction; output?: State.DetailedOutput }) } } -const CellInfoDialog = ({ onCancel, output }: { onCancel: () => void; output?: State.DetailedOutput }) => { +const CellInfoDialog = ({ + onCancel, + output, + isMainnet, +}: { + onCancel: () => void + output?: State.DetailedOutput + isMainnet: boolean +}) => { const [t] = useTranslation() const { tabs, currentTab, setCurrentTab, scriptRenderType, setScriptRenderType } = useTabs({ t, output, }) + const { copied, copyTimes, onCopy } = useCopy() + const onOpenTx = useCallback(() => { + const explorerUrl = getExplorerUrl(isMainnet) + openExternal(`${explorerUrl}/transaction/${output?.outPoint.txHash}`) + }, [isMainnet, output?.outPoint.txHash]) if (!output) { return null } @@ -124,6 +140,13 @@ const CellInfoDialog = ({ onCancel, output }: { onCancel: () => void; output?: S title={
{t('cell-manage.cell-detail-dialog.title')} +
+ {t('cell-manage.cell-detail-dialog.transaction-hash')} + :   + {truncateMiddle(output.outPoint.txHash, 10, 10)} + onCopy(output.outPoint.txHash)} /> + +
} onCancel={onCancel} @@ -149,6 +172,11 @@ const CellInfoDialog = ({ onCancel, output }: { onCancel: () => void; output?: S ) : null} + {copied ? ( + + {t('common.copied')} + + ) : null} ) } diff --git a/packages/neuron-ui/src/components/CellManagement/cellManagement.module.scss b/packages/neuron-ui/src/components/CellManagement/cellManagement.module.scss index 14ed375b5a..c286a5007a 100644 --- a/packages/neuron-ui/src/components/CellManagement/cellManagement.module.scss +++ b/packages/neuron-ui/src/components/CellManagement/cellManagement.module.scss @@ -163,9 +163,9 @@ @include checkbox; .multiActions { - position: absolute; + position: fixed; bottom: 24px; - left: 50%; + left: calc(50% + 80px); transform: translateX(-50%); padding: 12px 40px 12px 40px; border-radius: 40px; @@ -235,4 +235,22 @@ margin-right: 4px; } } + + .hardWalletImg { + width: 88px; + height: 88px; + margin: 16px 0 24px 0; + } + + .lockActions { + margin-top: 24px; + display: flex; + justify-content: center; + gap: 16px; + } + + .hardwalletErr { + justify-content: center; + margin-top: 12px; + } } diff --git a/packages/neuron-ui/src/components/CellManagement/hooks.ts b/packages/neuron-ui/src/components/CellManagement/hooks.ts index 9076750ece..e38c6cec29 100644 --- a/packages/neuron-ui/src/components/CellManagement/hooks.ts +++ b/packages/neuron-ui/src/components/CellManagement/hooks.ts @@ -1,12 +1,20 @@ +import { CkbAppNotFoundException, DeviceNotFoundException } from 'exceptions' +import { TFunction } from 'i18next' import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { + connectDevice, + getDeviceCkbAppVersion, + getDevices, getLiveCells, + getPlatform, updateLiveCellsLocalInfo, updateLiveCellsLockStatus as updateLiveCellsLockStatusAPI, + updateWallet, } from 'services/remote' +import { ControllerResponse } from 'services/remote/remoteApiWrapper' import { AppActions, useDispatch } from 'states' -import { LockScriptCategory, RoutePath, TypeScriptCategory, isSuccessResponse, outPointToStr } from 'utils' +import { ErrorCode, LockScriptCategory, RoutePath, TypeScriptCategory, isSuccessResponse, outPointToStr } from 'utils' import { SortType } from 'widgets/Table' const cellTypeOrder: Record = { @@ -204,6 +212,7 @@ export const useAction = ({ resetPassword, setError, password, + verifyDeviceStatus, }: { liveCells: State.LiveCellWithLocalInfo[] currentPageLiveCells: State.LiveCellWithLocalInfo[] @@ -212,6 +221,7 @@ export const useAction = ({ resetPassword: () => void setError: (error: string) => void password: string + verifyDeviceStatus: () => Promise }) => { const dispatch = useDispatch() const navigate = useNavigate() @@ -219,7 +229,7 @@ export const useAction = ({ const [operateCells, setOperateCells] = useState([]) const [loading, setLoading] = useState(false) const onOpenActionDialog = useCallback( - (e: React.SyntheticEvent) => { + async (e: React.SyntheticEvent) => { e.stopPropagation() const { action: curAction, index } = e.currentTarget.dataset as { action: Actions; index: string } if (!curAction || index === undefined || !currentPageLiveCells[+index]) return @@ -227,24 +237,27 @@ export const useAction = ({ setOperateCells([operateCell]) setAction(curAction) resetPassword() + await verifyDeviceStatus() }, [currentPageLiveCells, setOperateCells, dispatch, navigate] ) const onMultiAction = useCallback( - (e: React.SyntheticEvent) => { + async (e: React.SyntheticEvent) => { e.stopPropagation() const { action: curAction } = e.currentTarget.dataset as { action: Actions } if (!curAction || !selectedOutPoints.size) return setOperateCells(liveCells.filter(v => selectedOutPoints.has(outPointToStr(v.outPoint)))) setAction(curAction) resetPassword() + await verifyDeviceStatus() }, [liveCells, selectedOutPoints, setOperateCells, dispatch, navigate] ) - const onActionConfirm = useCallback(() => { + const onActionConfirm = useCallback(async () => { switch (action) { case 'lock': case 'unlock': + if (!(await verifyDeviceStatus())) return setLoading(true) updateLiveCellsLockStatus({ outPoints: operateCells.map(v => v.outPoint), @@ -362,3 +375,124 @@ export const usePassword = () => { resetPassword, } } + +export const useHardWallet = ({ wallet, t }: { wallet: State.WalletIdentity; t: TFunction }) => { + const isWin32 = useMemo(() => { + return getPlatform() === 'win32' + }, []) + const [error, setError] = useState() + const isNotAvailable = useMemo(() => { + return error === ErrorCode.DeviceNotFound || error === ErrorCode.CkbAppNotFound + }, [error]) + + const [deviceInfo, setDeviceInfo] = useState(wallet.device) + const [isReconnecting, setIsReconnecting] = useState(false) + + const ensureDeviceAvailable = useCallback( + async (device: State.DeviceInfo) => { + try { + const connectionRes = await connectDevice(device) + let { descriptor } = device + if (!isSuccessResponse(connectionRes)) { + // for win32, opening or closing the ckb app changes the HID descriptor(deviceInfo), + // so if we can't connect to the device, we need to re-search device automatically. + // for unix, the descriptor never changes unless user plugs the device into another USB port, + // in that case, mannauly re-search device one time will do. + if (isWin32) { + setIsReconnecting(true) + const devicesRes = await getDevices(device) + setIsReconnecting(false) + if (isSuccessResponse(devicesRes) && Array.isArray(devicesRes.result) && devicesRes.result.length > 0) { + const [updatedDeviceInfo] = devicesRes.result + descriptor = updatedDeviceInfo.descriptor + setDeviceInfo(updatedDeviceInfo) + } else { + throw new DeviceNotFoundException() + } + } else { + throw new DeviceNotFoundException() + } + } + + // getDeviceCkbAppVersion will halt forever while in win32 sleep mode. + const ckbVersionRes = await Promise.race([ + getDeviceCkbAppVersion(descriptor), + new Promise((_, reject) => { + setTimeout(() => reject(), 1000) + }), + ]).catch(() => { + return { status: ErrorCode.DeviceInSleep } + }) + + if (!isSuccessResponse(ckbVersionRes)) { + if (ckbVersionRes.status !== ErrorCode.DeviceInSleep) { + throw new CkbAppNotFoundException() + } else { + throw new DeviceNotFoundException() + } + } + setError(undefined) + return true + } catch (err) { + if (err instanceof CkbAppNotFoundException || err instanceof DeviceNotFoundException) { + setError(err.code) + } + return false + } + }, + [isWin32] + ) + + const reconnect = useCallback(async () => { + if (!deviceInfo) return + setError(undefined) + setIsReconnecting(true) + try { + const res = await getDevices(deviceInfo) + if (isSuccessResponse(res) && Array.isArray(res.result) && res.result.length > 0) { + const [device] = res.result + setDeviceInfo(device) + if (device.descriptor !== deviceInfo.descriptor) { + await updateWallet({ + id: wallet.id, + device, + }) + } + await ensureDeviceAvailable(device) + } else { + setError(ErrorCode.DeviceNotFound) + } + } catch (err) { + setError(ErrorCode.DeviceNotFound) + } finally { + setIsReconnecting(false) + } + }, [deviceInfo, ensureDeviceAvailable, wallet.id]) + + const verifyDeviceStatus = useCallback(async () => { + if (deviceInfo) { + return ensureDeviceAvailable(deviceInfo) + } + return true + }, [ensureDeviceAvailable, deviceInfo]) + + const errorMessage = useMemo(() => { + switch (error) { + case ErrorCode.DeviceNotFound: + return t('hardware-verify-address.status.disconnect') + case ErrorCode.CkbAppNotFound: + return t(CkbAppNotFoundException.message) + default: + return error + } + }, [error, t]) + return { + deviceInfo, + isReconnecting, + isNotAvailable, + reconnect, + verifyDeviceStatus, + errorMessage, + setError, + } +} diff --git a/packages/neuron-ui/src/components/CellManagement/index.tsx b/packages/neuron-ui/src/components/CellManagement/index.tsx index e8752c869c..48f94566e2 100644 --- a/packages/neuron-ui/src/components/CellManagement/index.tsx +++ b/packages/neuron-ui/src/components/CellManagement/index.tsx @@ -13,6 +13,7 @@ import { outPointToStr, LockScriptCategory, getLockTimestamp, + isMainnet as isMainnetUtil, } from 'utils' import { HIDE_BALANCE } from 'utils/const' import Tooltip from 'widgets/Tooltip' @@ -23,7 +24,10 @@ import TextField from 'widgets/TextField' import { useSearchParams } from 'react-router-dom' import CellInfoDialog from 'components/CellInfoDialog' import { computeScriptHash } from '@ckb-lumos/base/lib/utils' -import { Actions, useAction, useLiveCells, usePassword, useSelect } from './hooks' +import Hardware from 'widgets/Icons/Hardware.png' +import Button from 'widgets/Button' +import Alert from 'widgets/Alert' +import { Actions, useAction, useHardWallet, useLiveCells, usePassword, useSelect } from './hooks' import styles from './cellManagement.module.scss' const getColumns = ({ @@ -214,11 +218,14 @@ const getColumns = ({ const CellManagement = () => { const { app: { epoch }, - wallet: { balance = '' }, + wallet, chain: { syncState: { bestKnownBlockTimestamp }, + networkID, }, + settings: { networks }, } = useGlobalState() + const isMainnet = useMemo(() => isMainnetUtil(networks, networkID), [networks, networkID]) const [t] = useTranslation() const [searchParams] = useSearchParams() const breadPages = useMemo(() => [{ label: t('cell-manage.title') }], [t]) @@ -236,6 +243,17 @@ const CellManagement = () => { return liveCells.slice(pageSize * (pageNo - 1), pageSize * pageNo) }, [pageNo, pageSize, liveCells]) const { onSelect, onSelectAll, isAllSelected, selectedOutPoints, hasSelectLocked, isAllLocked } = useSelect(liveCells) + const { + isReconnecting, + isNotAvailable, + reconnect, + verifyDeviceStatus, + errorMessage: hardwalletError, + setError: setHardwalletError, + } = useHardWallet({ + wallet, + t, + }) const { password, error, onPasswordChange, setError, resetPassword } = usePassword() const { action, operateCells, onActionCancel, onActionConfirm, onOpenActionDialog, onMultiAction, loading } = useAction({ @@ -243,9 +261,10 @@ const CellManagement = () => { currentPageLiveCells, updateLiveCellsLockStatus, selectedOutPoints, - setError, + setError: wallet.device ? setHardwalletError : setError, resetPassword, password, + verifyDeviceStatus, }) const columns = useMemo( () => @@ -285,7 +304,7 @@ const CellManagement = () => { {showBalance ? : } {t('cell-manage.wallet-balance')}    - {`${showBalance ? shannonToCKBFormatter(balance) : HIDE_BALANCE} CKB`} + {`${showBalance ? shannonToCKBFormatter(wallet.balance) : HIDE_BALANCE} CKB`} } @@ -329,39 +348,82 @@ const CellManagement = () => { : undefined } onCancel={onActionCancel} + isMainnet={isMainnet} /> - -

- {t(`cell-manage.cell-${action}-dialog.capacity`, { capacity: totalCapacity })} -

- - {action === Actions.Lock ? ( - - - {t('cell-manage.cell-lock-dialog.locked-cell-can-not-use')} - - ) : null} -
+ {wallet.device ? ( + +

+ {t(`cell-manage.cell-${action}-dialog.capacity`, { capacity: totalCapacity })} +

+
+ hard-wallet +
+ {action === Actions.Lock ? ( + + + {t('cell-manage.cell-lock-dialog.locked-cell-can-not-use')} + + ) : null} +
+ + +
+ {hardwalletError ? ( + + {hardwalletError} + + ) : null} +
+ ) : ( + +

+ {t(`cell-manage.cell-${action}-dialog.capacity`, { capacity: totalCapacity })} +

+ + {action === Actions.Lock ? ( + + + {t('cell-manage.cell-lock-dialog.locked-cell-can-not-use')} + + ) : null} +
+ )} { type="failed" onCancel={() => navigate(-1)} /> - + ) } diff --git a/packages/neuron-ui/src/locales/en.json b/packages/neuron-ui/src/locales/en.json index fb0619c3d2..c42810c3f5 100644 --- a/packages/neuron-ui/src/locales/en.json +++ b/packages/neuron-ui/src/locales/en.json @@ -1198,6 +1198,7 @@ }, "cell-detail-dialog": { "title": "Cell Detail", + "transaction-hash": "Transaction Hash", "capacity-used": "Used Capacity", "data": "Data", "total": "Total", @@ -1232,7 +1233,8 @@ "password-placeholder": "Please input wallet password", "lock": "Lock", "unlock": "Unlock", - "consume": "Consume" + "consume": "Consume", + "verify": "Verify" }, "send-tx-detail": { "page-title": "Transaction Detail", diff --git a/packages/neuron-ui/src/locales/es.json b/packages/neuron-ui/src/locales/es.json index 2eb59dcae6..8d38510953 100644 --- a/packages/neuron-ui/src/locales/es.json +++ b/packages/neuron-ui/src/locales/es.json @@ -1177,6 +1177,7 @@ }, "cell-detail-dialog": { "title": "Detalles de la Cell", + "transaction-hash": "Hash de Transacción", "capacity-used": "Capacidad utilizada", "data": "Datos", "total": "Total", @@ -1211,7 +1212,8 @@ "password-placeholder": "Ingrese la contraseña de la billetera", "lock": "Bloquear", "unlock": "Desbloquear", - "consume": "Consumir" + "consume": "Consumir", + "verify": "Verificar" }, "send-tx-detail": { "page-title": "Detalles de la transacción", diff --git a/packages/neuron-ui/src/locales/fr.json b/packages/neuron-ui/src/locales/fr.json index f4bdcb9e8d..5cd1baf641 100644 --- a/packages/neuron-ui/src/locales/fr.json +++ b/packages/neuron-ui/src/locales/fr.json @@ -1188,6 +1188,7 @@ }, "cell-detail-dialog": { "title": "Détail de la cellule", + "transaction-hash": "Hash de transaction", "capacity-used": "Capacité utilisée", "data": "Données", "total": "Total", @@ -1222,7 +1223,8 @@ "password-placeholder": "Veuillez entrer le mot de passe du Wallet", "lock": "Verrouiller", "unlock": "Déverrouiller", - "consume": "Consommer" + "consume": "Consommer", + "verify": "Vérifier" }, "send-tx-detail": { "page-title": "Détail de l'historique", diff --git a/packages/neuron-ui/src/locales/zh-tw.json b/packages/neuron-ui/src/locales/zh-tw.json index e08c32e39a..0208b9e9d4 100644 --- a/packages/neuron-ui/src/locales/zh-tw.json +++ b/packages/neuron-ui/src/locales/zh-tw.json @@ -1169,6 +1169,7 @@ }, "cell-detail-dialog": { "title": "Cell詳情", + "transaction-hash": "交易哈希", "capacity-used": "容量使用", "data": "數據", "total": "總量", @@ -1199,7 +1200,8 @@ "password-placeholder": "請輸入錢包密碼", "lock": "鎖定", "unlock": "解鎖", - "consume": "消耗" + "consume": "消耗", + "verify": "驗證" }, "send-tx-detail": { "page-title": "交易詳情", diff --git a/packages/neuron-ui/src/locales/zh.json b/packages/neuron-ui/src/locales/zh.json index 036ab6d4dd..fbb85cf7af 100644 --- a/packages/neuron-ui/src/locales/zh.json +++ b/packages/neuron-ui/src/locales/zh.json @@ -1190,6 +1190,7 @@ }, "cell-detail-dialog": { "title": "Cell详情", + "transaction-hash": "交易哈希", "capacity-used": "容量使用", "data": "数据", "total": "总量", @@ -1224,7 +1225,8 @@ "password-placeholder": "请输入钱包密码", "lock": "锁定", "unlock": "解锁", - "consume": "消耗" + "consume": "消耗", + "verify": "验证" }, "send-tx-detail": { "page-title": "交易详情", diff --git a/packages/neuron-ui/src/widgets/Icons/Hardware.png b/packages/neuron-ui/src/widgets/Icons/Hardware.png new file mode 100755 index 0000000000..53939e5f0a Binary files /dev/null and b/packages/neuron-ui/src/widgets/Icons/Hardware.png differ diff --git a/packages/neuron-wallet/src/controllers/api.ts b/packages/neuron-wallet/src/controllers/api.ts index 1f0bae8933..d442eaefe7 100644 --- a/packages/neuron-wallet/src/controllers/api.ts +++ b/packages/neuron-wallet/src/controllers/api.ts @@ -926,7 +926,7 @@ export default class ApiController { params: { outPoints: OutPoint[] locked: boolean - password: string + password?: string lockScripts: CKBComponents.Script[] } ) => { diff --git a/packages/neuron-wallet/src/controllers/cell-management.ts b/packages/neuron-wallet/src/controllers/cell-management.ts index 690de0d4c7..4e0ee8ce20 100644 --- a/packages/neuron-wallet/src/controllers/cell-management.ts +++ b/packages/neuron-wallet/src/controllers/cell-management.ts @@ -61,7 +61,7 @@ export default class CellManagement { outPoints: OutPointSDK[], locked: boolean, lockScripts: Script[], - password: string + password: string = '' ) { // check wallet password const currentWallet = WalletService.getInstance().getCurrent() @@ -69,7 +69,7 @@ export default class CellManagement { const addresses = new Set((await AddressService.getAddressesByWalletId(currentWallet.id)).map(v => v.address)) const isMainnet = NetworksService.getInstance().isMainnet() if (!lockScripts.every(v => addresses.has(scriptToAddress(v, isMainnet)))) throw new AddressNotFound() - await SignMessage.sign(currentWallet.id, scriptToAddress(lockScripts[0], isMainnet), password, 'verify password') + await SignMessage.sign(currentWallet.id, scriptToAddress(lockScripts[0], isMainnet), password, 'verify cell owner') return CellLocalInfoService.updateLiveCellLockStatus(outPoints, locked) } diff --git a/packages/neuron-wallet/tests/controllers/cell-management.test.ts b/packages/neuron-wallet/tests/controllers/cell-management.test.ts index e1ad5a0623..eb5a357e29 100644 --- a/packages/neuron-wallet/tests/controllers/cell-management.test.ts +++ b/packages/neuron-wallet/tests/controllers/cell-management.test.ts @@ -155,7 +155,7 @@ describe('CellManage', () => { getAddressesByWalletIdMock.mockResolvedValueOnce([{ address }]) const outPoints = [new OutPoint(`0x${'00'.repeat(32)}`, '0')] await CellManagement.updateLiveCellsLockStatus(outPoints, true, [lockScript], 'password') - expect(signMock).toBeCalledWith('walletId1', address, 'password', 'verify password') + expect(signMock).toBeCalledWith('walletId1', address, 'password', 'verify cell owner') expect(updateLiveCellLockStatusMock).toBeCalledWith(outPoints, true) }) })