Skip to content

Commit

Permalink
Merge pull request #83 from spacemeshos/feat-verify-address
Browse files Browse the repository at this point in the history
Add verify address button to Receive modal
  • Loading branch information
brusherru authored Sep 17, 2024
2 parents 58cbf4d + adec66c commit 6671e49
Show file tree
Hide file tree
Showing 5 changed files with 256 additions and 52 deletions.
19 changes: 4 additions & 15 deletions src/components/CopyButton.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<typeof setTimeout>;

return (
<Tooltip label="Copied" isOpen={isCopied}>
<IconButton
variant={withOutline ? 'whiteOutline' : 'ghostWhite'}
aria-label="Copy to clipboard"
size="xs"
onClick={() => {
clearTimeout(timeout);
copy(value);
setIsCopied(true);
timeout = setTimeout(() => {
setIsCopied(false);
}, 5000);
}}
onClick={() => onCopy(value)}
disabled={isCopied}
icon={<IconCopy size={withOutline ? 12 : iconSize} />}
ml={1}
Expand Down
167 changes: 147 additions & 20 deletions src/components/Receive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from 'react';
import QRCode from 'react-qr-code';

import {
Box,
Button,
Modal,
ModalBody,
Expand All @@ -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';

Expand All @@ -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<Error | null>(
null
);
const [verificationStatus, setVerificationStatus] =
useState<VerificationStatus | null>(null);

const verify = async (): Promise<VerificationStatus> => {
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<typeof setTimeout>;
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 (
<Modal isOpen={isOpen} onClose={onClose} isCentered size="xl">
<Modal isOpen={isOpen} onClose={close} isCentered size="xl">
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
{verificationStatus !== VerificationStatus.Pending && (
<ModalCloseButton />
)}
<ModalHeader textAlign="center">Receive funds</ModalHeader>
<ModalBody>
<Text fontSize="sm" mb={4} textAlign="center">
Expand All @@ -56,21 +118,86 @@ function ReceiveModal({
<QRCode
bgColor="var(--chakra-colors-brand-lightGray)"
fgColor="var(--chakra-colors-blackAlpha-500)"
style={{ height: 'auto', maxWidth: '100%', width: '100%' }}
style={{
height: 'auto',
maxWidth: '300px',
width: '100%',
marginLeft: 'auto',
marginRight: 'auto',
}}
value={account.address}
/>
<Box my={2} minH={42} fontSize="sm" textAlign="center">
{verificationStatus === VerificationStatus.ApprovedCorrect && (
<Text color="brand.green">
The address is verified successfully!
</Text>
)}
{verificationStatus === VerificationStatus.Pending && (
<Text color="yellow">
Please review the address on the Ledger device.
<br />
Approve if it matches the address above.
</Text>
)}
{verificationStatus === VerificationStatus.NotConnected && (
<Text color="yellow">
The ledger device is not connected.
<br />
Please connect the device and then click &quot;Verify&quot;
button once again.
</Text>
)}
{verificationStatus === VerificationStatus.ApprovedWrong && (
<Text color="brand.red">
The address is incorrect, probably you are using another Ledger
device.
</Text>
)}
{verificationStatus === VerificationStatus.Rejected && (
<Text color="red">
The address was rejected on the Ledger device.
</Text>
)}
{verificationError && (
<Text color="brand.red">
Cannot verify the address:
<br />
{verificationError.message}
</Text>
)}
</Box>
{isLedgerBasedAccount(account) && (
<Box textAlign="center">
<Button
variant="whiteModal"
onClick={verifyAndShow}
isDisabled={verificationStatus === VerificationStatus.Pending}
w={{ base: '100%', md: '80%' }}
maxW={340}
>
Verify
</Button>
</Box>
)}
</ModalBody>
<ModalFooter>
<ModalFooter gap={2}>
<Button
isDisabled={isCopied}
onClick={onCopyClick(account.address)}
mr={2}
w="50%"
isDisabled={
isCopied || verificationStatus === VerificationStatus.Pending
}
onClick={() => onCopy(account.address)}
w={{ base: '100%', md: '50%' }}
variant="whiteModal"
>
{isCopied ? 'Copied' : 'Copy'}
</Button>
<Button variant="whiteModal" onClick={onClose} ml={2} w="50%">
<Button
variant="whiteModal"
onClick={close}
w={{ base: '100%', md: '50%' }}
isDisabled={verificationStatus === VerificationStatus.Pending}
>
OK
</Button>
</ModalFooter>
Expand Down
35 changes: 34 additions & 1 deletion src/hooks/useWalletSelectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
};
};
16 changes: 3 additions & 13 deletions src/screens/createWallet/CreateMnemonicScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<typeof setTimeout>;
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 (
<Flex
Expand Down
Loading

0 comments on commit 6671e49

Please sign in to comment.