diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index 7314dbc..e576422 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -1,8 +1,7 @@ -import { useState } from 'react'; - import { IconButton, Tooltip, useBreakpointValue } from '@chakra-ui/react'; import { IconCopy } from '@tabler/icons-react'; -import { useCopyToClipboard } from '@uidotdev/usehooks'; + +import useCopy from '../hooks/useCopy'; type CopyButtonProps = { value: string; @@ -13,26 +12,16 @@ function CopyButton({ value, withOutline = false, }: CopyButtonProps): JSX.Element { - const [isCopied, setIsCopied] = useState(false); - const [, copy] = useCopyToClipboard(); + const { isCopied, onCopy } = useCopy(); const iconSize = useBreakpointValue({ base: 14, md: 18 }, { ssr: false }); - let timeout: ReturnType; - return ( { - clearTimeout(timeout); - copy(value); - setIsCopied(true); - timeout = setTimeout(() => { - setIsCopied(false); - }, 5000); - }} + onClick={() => onCopy(value)} disabled={isCopied} icon={} ml={1} diff --git a/src/components/Receive.tsx b/src/components/Receive.tsx index 8d5e107..5ff187c 100644 --- a/src/components/Receive.tsx +++ b/src/components/Receive.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import QRCode from 'react-qr-code'; import { + Box, Button, Modal, ModalBody, @@ -12,9 +13,19 @@ import { ModalOverlay, Text, } from '@chakra-ui/react'; -import { useCopyToClipboard } from '@uidotdev/usehooks'; +import useCopy from '../hooks/useCopy'; +import { useIsLedgerAccount } from '../hooks/useWalletSelectors'; +import useHardwareWallet, { + VerificationStatus, +} from '../store/useHardwareWallet'; +import useWallet from '../store/useWallet'; import { AccountWithAddress } from '../types/wallet'; +import { + isMultiSigAccount, + isSingleSigAccount, + isVestingAccount, +} from '../utils/account'; import CopyButton from './CopyButton'; @@ -29,24 +40,75 @@ function ReceiveModal({ isOpen, onClose, }: ReceiveModalProps): JSX.Element { - const [isCopied, setIsCopied] = useState(false); - const [, copy] = useCopyToClipboard(); + const { isCopied, onCopy } = useCopy(); + const isLedgerBasedAccount = useIsLedgerAccount(); + const { wallet } = useWallet(); + const { checkDeviceConnection, connectedDevice } = useHardwareWallet(); + + const [verificationError, setVerificationError] = useState( + null + ); + const [verificationStatus, setVerificationStatus] = + useState(null); + + const verify = async (): Promise => { + if (!wallet || !(await checkDeviceConnection()) || !connectedDevice) { + return VerificationStatus.NotConnected; + } + if (isSingleSigAccount(account)) { + const key = wallet.keychain.find( + (k) => k.publicKey === account.spawnArguments.PublicKey + ); + if (!key || !key.path) { + throw new Error('Key not found'); + } + return connectedDevice.actions.verify(key.path, account); + } + if (isMultiSigAccount(account) || isVestingAccount(account)) { + const keyIndex = wallet.keychain.findIndex((k) => + account.spawnArguments.PublicKeys.includes(k.publicKey) + ); + const key = wallet.keychain[keyIndex]; + if (!key || !key.path) { + throw new Error('Key not found'); + } + return connectedDevice.actions.verify(key.path, account, keyIndex); + } + throw new Error('Unknown account type'); + }; + + const close = () => { + if (verificationStatus !== VerificationStatus.Pending) { + setVerificationError(null); + setVerificationStatus(null); - let timeout: ReturnType; - const onCopyClick = (data: string) => () => { - clearTimeout(timeout); - copy(data); - setIsCopied(true); - timeout = setTimeout(() => { - setIsCopied(false); - }, 5000); + onClose(); + } + }; + + const verifyAndShow = () => { + // Show "verify on device" message + setVerificationStatus(VerificationStatus.Pending); + // Reset errors + setVerificationError(null); + // Verify the address + verify() + .then((res) => { + setVerificationStatus(res); + }) + .catch((err) => { + setVerificationStatus(null); + setVerificationError(err); + }); }; return ( - + - + {verificationStatus !== VerificationStatus.Pending && ( + + )} Receive funds @@ -56,21 +118,86 @@ function ReceiveModal({ + + {verificationStatus === VerificationStatus.ApprovedCorrect && ( + + The address is verified successfully! + + )} + {verificationStatus === VerificationStatus.Pending && ( + + Please review the address on the Ledger device. +
+ Approve if it matches the address above. +
+ )} + {verificationStatus === VerificationStatus.NotConnected && ( + + The ledger device is not connected. +
+ Please connect the device and then click "Verify" + button once again. +
+ )} + {verificationStatus === VerificationStatus.ApprovedWrong && ( + + The address is incorrect, probably you are using another Ledger + device. + + )} + {verificationStatus === VerificationStatus.Rejected && ( + + The address was rejected on the Ledger device. + + )} + {verificationError && ( + + Cannot verify the address: +
+ {verificationError.message} +
+ )} +
+ {isLedgerBasedAccount(account) && ( + + + + )}
- + - diff --git a/src/hooks/useWalletSelectors.ts b/src/hooks/useWalletSelectors.ts index 3e627aa..99bfb0e 100644 --- a/src/hooks/useWalletSelectors.ts +++ b/src/hooks/useWalletSelectors.ts @@ -4,7 +4,13 @@ import { O } from '@mobily/ts-belt'; import { StdTemplateKeys } from '@spacemesh/sm-codec'; import useWallet from '../store/useWallet'; -import { Account, AccountWithAddress } from '../types/wallet'; +import { Account, AccountWithAddress, KeyPairType } from '../types/wallet'; +import { + isMultiSigAccount, + isSingleSigAccount, + isVaultAccount, + isVestingAccount, +} from '../utils/account'; import { AnySpawnArguments } from '../utils/templates'; import { computeAddress } from '../utils/wallet'; @@ -32,3 +38,30 @@ export const useCurrentAccount = ( const acc = accounts[selectedAccount]; return O.fromNullable(acc); }; + +export const useIsLedgerAccount = () => { + const { wallet } = useWallet(); + return (account: AccountWithAddress) => { + if (!wallet) return false; + // TODO + if (isSingleSigAccount(account)) { + const key = wallet.keychain.find( + (k) => k.publicKey === account.spawnArguments.PublicKey + ); + return key?.type === KeyPairType.Hardware; + } + if (isMultiSigAccount(account) || isVestingAccount(account)) { + const keys = wallet.keychain.filter((k) => + account.spawnArguments.PublicKeys.includes(k.publicKey) + ); + return keys.some((k) => k.type === KeyPairType.Hardware); + } + if (isVaultAccount(account)) { + // It has no key, so it cannot be ledger + return false; + } + + // Any other cases are unexpected and default to `false` + return false; + }; +}; diff --git a/src/screens/createWallet/CreateMnemonicScreen.tsx b/src/screens/createWallet/CreateMnemonicScreen.tsx index 59aae17..4f7bf80 100644 --- a/src/screens/createWallet/CreateMnemonicScreen.tsx +++ b/src/screens/createWallet/CreateMnemonicScreen.tsx @@ -10,11 +10,11 @@ import { useBreakpointValue, } from '@chakra-ui/react'; import { IconArrowNarrowRight } from '@tabler/icons-react'; -import { useCopyToClipboard } from '@uidotdev/usehooks'; import BackButton from '../../components/BackButton'; import GreenHeader from '../../components/welcome/GreenHeader'; import Logo from '../../components/welcome/Logo'; +import useCopy from '../../hooks/useCopy'; import useWallet from '../../store/useWallet'; import { useWalletCreation } from './WalletCreationContext'; @@ -23,24 +23,14 @@ function CreateMnemonicScreen(): JSX.Element { const { generateMnemonic } = useWallet(); const ctx = useWalletCreation(); const [mnemonic, setMnemonic] = useState(ctx.mnemonic || generateMnemonic()); - const [isCopied, setIsCopied] = useState(false); - const [, copy] = useCopyToClipboard(); const navigate = useNavigate(); const columns = useBreakpointValue({ base: 2, md: 3 }, { ssr: false }); - - let timeout: ReturnType; + const { isCopied, onCopy } = useCopy(); const regenerateMnemonic = () => { setMnemonic(generateMnemonic()); }; - const onCopyClick = () => { - clearTimeout(timeout); - copy(mnemonic); - setIsCopied(true); - timeout = setTimeout(() => { - setIsCopied(false); - }, 5000); - }; + const onCopyClick = () => onCopy(mnemonic); return ( Promise; signTx: (path: string, blob: Uint8Array) => Promise; + verify: ( + path: string, + account: AccountWithAddress, + index?: number + ) => Promise; }; }; @@ -88,6 +111,20 @@ const createLedgerTransport = ( } }; +const verifyAddress = + (account: AccountWithAddress) => (response: ResponseAddress) => + response.address === account.address + ? VerificationStatus.ApprovedCorrect + : VerificationStatus.ApprovedWrong; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const handleVerificationError = (err: any) => { + if (err.returnCode === LedgerError.TransactionRejected) { + return VerificationStatus.Rejected; + } + throw err; +}; + const useHardwareWallet = (): UseHardwareWalletHook => { const [device, setDevice] = useState(null); const [connectionError, setConnectionError] = useState(null); @@ -132,6 +169,34 @@ const useHardwareWallet = (): UseHardwareWalletHook => { modalApproval.onClose(); return res; }, + verify: async (path, account, index = 0) => { + const result = (() => { + if (isSingleSigAccount(account)) { + return app.getAddressAndPubKey(path, true); + } + if (isMultiSigAccount(account) || isVestingAccount(account)) { + return app.getAddressMultisig( + path, + index, + new Account( + AccountType.Multisig, + account.spawnArguments.Required, + account.spawnArguments.PublicKeys.length, + account.spawnArguments.PublicKeys.map((pk, idx) => ({ + index: idx, + pubkey: Buffer.from(pk, 'hex'), + })) + ) + ); + } + throw new Error('Unsupported account type'); + })(); + + return result + .then(verifyAddress(account)) + .catch(handleVerificationError) + .catch(handleLedgerError); + }, }, }); modalConnect.onClose();