From 08b41f372150f3335f4b2ef976a339351acc68a0 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 16 Feb 2022 16:22:38 +0100 Subject: [PATCH 1/8] Add offline account functionality to use with Quill --- src/components/BusyBottom/constants.ts | 17 ++- .../SignTransactionWallets/Offline.tsx | 117 ++++++++++++++++++ .../SignTransactionWallets/index.ts | 2 + src/components/WalletUnlock/Offline.tsx | 92 ++++++++++++++ src/components/WalletUnlock/index.ts | 1 + src/config/wallets.ts | 16 +++ .../AddAccount/AddAccountForm.reducer.tsx | 1 + src/features/AddAccount/stories.tsx | 5 + .../non-deterministic/address.ts | 4 +- src/services/WalletService/walletService.ts | 3 + src/translations/lang/en.json | 5 +- src/types/busyBottom.ts | 3 +- src/types/walletId.ts | 1 + src/utils/makeTransaction.ts | 2 +- 14 files changed, 264 insertions(+), 5 deletions(-) create mode 100644 src/components/SignTransactionWallets/Offline.tsx create mode 100644 src/components/WalletUnlock/Offline.tsx diff --git a/src/components/BusyBottom/constants.ts b/src/components/BusyBottom/constants.ts index 4516d39f86d..0fedf4cb969 100644 --- a/src/components/BusyBottom/constants.ts +++ b/src/components/BusyBottom/constants.ts @@ -111,5 +111,20 @@ export const configs: Record< }, SUPPORT_LINK ], - SUPPORT: [SUPPORT_LINK] + SUPPORT: [SUPPORT_LINK], + OFFLINE: [ + { + copy: 'BUSY_BOTTOM_OFFLINE_1', + // @todo: Update link + link: getKBHelpArticle(KB_HELP_ARTICLE.WHAT_IS_WALLETCONNECT), + external: true + }, + { + copy: 'BUSY_BOTTOM_OFFLINE_2', + // @todo: Update link + link: getKBHelpArticle(KB_HELP_ARTICLE.HOW_TO_USE_WALLETCONNECT), + external: true + }, + SUPPORT_LINK + ] }; diff --git a/src/components/SignTransactionWallets/Offline.tsx b/src/components/SignTransactionWallets/Offline.tsx new file mode 100644 index 00000000000..6de030c7a87 --- /dev/null +++ b/src/components/SignTransactionWallets/Offline.tsx @@ -0,0 +1,117 @@ +import { useMemo, useState } from 'react'; + +import { serialize } from '@ethersproject/transactions'; +import styled from 'styled-components'; + +import { Body, Box, BusyBottom, Button, Heading, InputField, LinkApp } from '@components'; +import { TxIntermediaryDisplay } from '@components/TransactionFlow/displays'; +import { isContractInteraction } from '@components/TransactionFlow/helpers'; +import { getWalletConfig, IWalletConfig, ROUTE_PATHS, WALLETS_CONFIG } from '@config'; +import { useNetworks } from '@services/Store'; +import { getContractName, useSelector } from '@store'; +import { FONT_SIZE, SPACING } from '@theme'; +import translate, { translateRaw } from '@translations'; +import { BusyBottomConfig, ISignComponentProps, ITxObject, StoreAccount, WalletId } from '@types'; +import { makeTransaction } from '@utils'; + +export enum WalletSigningState { + SUBMITTING, + REJECTED, + ADDRESS_MISMATCH, + NETWORK_MISMATCH, + SUCCESS, + UNKNOWN //used upon component initialization when wallet status is not determined +} + +export function SignTransactionOffline({ + senderAccount, + rawTransaction +}: // onSuccess +ISignComponentProps) { + const [walletState] = useState(WalletSigningState.UNKNOWN); + // const desiredAddress = getAddress(senderAccount.address); + + const { getNetworkByChainId } = useNetworks(); + const detectedNetwork = getNetworkByChainId(rawTransaction.chainId); + const networkName = detectedNetwork ? detectedNetwork.name : translateRaw('UNKNOWN_NETWORK'); + const walletConfig = getWalletConfig(WalletId.OFFLINE); + + const network = senderAccount.networkId; + const contractName = useSelector(getContractName(network, rawTransaction.to)); + + return ( + + ); +} + +const Footer = styled.div` + width: 100%; + margin-top: 2em; +`; + +export interface UIProps { + walletConfig: IWalletConfig; + walletState: WalletSigningState; + networkName: string; + senderAccount: StoreAccount; + rawTransaction: ITxObject; + contractName?: string; +} + +export const SignTransactionOfflineUI = ({ + walletConfig, + rawTransaction, + contractName +}: UIProps) => { + const rawTransactionHex = useMemo(() => serialize(makeTransaction(rawTransaction)), [ + rawTransaction + ]); + + return ( + + + {translate('SIGN_TX_TITLE', { + $walletName: walletConfig.name || WALLETS_CONFIG.OFFLINE.name + })} + + + {translate('SIGN_TX_OFFLINE_PROMPT', { + $walletName: walletConfig.name || WALLETS_CONFIG.OFFLINE.name + })} + + {isContractInteraction(rawTransaction.data) && rawTransaction.to && ( + + + + )} + + + + + + + + <> + + {translateRaw('SIGN_TX_EXPLANATION')} + +
+ +
+ +
+ ); +}; diff --git a/src/components/SignTransactionWallets/index.ts b/src/components/SignTransactionWallets/index.ts index 603e3471269..959853b0199 100644 --- a/src/components/SignTransactionWallets/index.ts +++ b/src/components/SignTransactionWallets/index.ts @@ -2,6 +2,7 @@ import { SigningComponents, WalletId } from '@types'; import SignTransactionGridPlus from './GridPlus'; import { default as SignTransactionLedger } from './Ledger'; +import { SignTransactionOffline } from './Offline'; import { default as SignTransactionTrezor } from './Trezor'; import { default as SignTransactionWalletConnect } from './WalletConnect'; import { default as SignTransactionWeb3 } from './Web3'; @@ -19,6 +20,7 @@ export const WALLET_STEPS: SigningComponents = { [WalletId.TREZOR_NEW]: SignTransactionTrezor, [WalletId.WALLETCONNECT]: SignTransactionWalletConnect, [WalletId.GRIDPLUS]: SignTransactionGridPlus, + [WalletId.OFFLINE]: SignTransactionOffline, [WalletId.VIEW_ONLY]: null }; export { default as HardwareSignTransaction } from './Hardware'; diff --git a/src/components/WalletUnlock/Offline.tsx b/src/components/WalletUnlock/Offline.tsx new file mode 100644 index 00000000000..030071665c4 --- /dev/null +++ b/src/components/WalletUnlock/Offline.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react'; + +import { Form, Formik } from 'formik'; +import equals from 'ramda/src/equals'; +import styled from 'styled-components'; + +import { Body, Box, Button, ContactLookupField, Heading } from '@components'; +import { useNetworks } from '@services/Store'; +import { WalletFactory } from '@services/WalletService'; +import { COLORS } from '@theme'; +import { translateRaw } from '@translations'; +import { ErrorObject, FormData, IReceiverAddress, TAddress, WalletId } from '@types'; +import { isFormValid, toChecksumAddressByChainId } from '@utils'; + +const FormWrapper = styled(Form)` + padding-top: 2em; +`; + +const ButtonWrapper = styled(Button)` + margin-top: 4em; +`; + +interface Props { + formData: FormData; + + onUnlock(param: any): void; +} + +const WalletService = WalletFactory[WalletId.OFFLINE]; + +interface FormValues { + address: IReceiverAddress; +} + +const initialFormikValues: FormValues = { + address: { + display: '', + value: '' + } +}; + +export function OfflineDecrypt({ formData, onUnlock }: Props) { + const { getNetworkById } = useNetworks(); + const [isResolvingDomain, setIsResolvingDomain] = useState(false); + const [network] = useState(getNetworkById(formData.network)); + + const onSubmit = (fields: FormValues) => { + if (equals(fields, initialFormikValues)) return; + onUnlock( + WalletService.init({ + address: toChecksumAddressByChainId(fields.address.value, network.chainId) as TAddress + }) + ); + }; + + return ( + + + {translateRaw('INPUT_OFFLINE_ADDRESS_LABEL')} + + + {translateRaw('OFFLINE_DISCLAIMER')} + + + {({ errors, touched, values, setFieldError, setFieldTouched, setFieldValue }) => ( + + + onSubmit(values)} + > + {translateRaw('ACTION_6')} + + + )} + + + ); +} diff --git a/src/components/WalletUnlock/index.ts b/src/components/WalletUnlock/index.ts index 514b1796497..780f9b4d97a 100644 --- a/src/components/WalletUnlock/index.ts +++ b/src/components/WalletUnlock/index.ts @@ -6,3 +6,4 @@ export { Ledger as LegderUnlock } from './Ledger'; export { Trezor as TrezorUnlock } from './Trezor'; export { GridPlus as GridPlusUnlock } from './GridPlus'; export * from './HWLegacy'; +export * from './Offline'; diff --git a/src/config/wallets.ts b/src/config/wallets.ts index 70b8dc213ea..332ef5b38c5 100644 --- a/src/config/wallets.ts +++ b/src/config/wallets.ts @@ -1,5 +1,6 @@ // @ADD_ACCOUNT_@todo: Icons really belongs to the WalletButton or a WalletIcon // component. +import offlineIcon from '@assets/images/icn-desktop-app.svg'; import viewOnlyIcon from '@assets/images/icn-view-only.svg'; import CoinbaseWalletIcon from '@assets/images/wallets/coinbase.svg'; import FrameIcon from '@assets/images/wallets/frame.svg'; @@ -226,6 +227,21 @@ export const WALLETS_CONFIG: Record = { supportsNonce: false } }, + [WalletId.OFFLINE]: { + id: WalletId.OFFLINE, + name: 'Offline', + isDeterministic: false, + isSecure: true, + isDesktopOnly: false, + type: WalletType.MISC, + lid: 'OFFLINE', + icon: offlineIcon, + description: 'ADD_OFFLINE_DESC', + helpLink: '', + flags: { + supportsNonce: false + } + }, [WalletId.WALLETCONNECT]: { id: WalletId.WALLETCONNECT, name: 'WalletConnect', diff --git a/src/features/AddAccount/AddAccountForm.reducer.tsx b/src/features/AddAccount/AddAccountForm.reducer.tsx index efa75cb7280..2e781e16731 100644 --- a/src/features/AddAccount/AddAccountForm.reducer.tsx +++ b/src/features/AddAccount/AddAccountForm.reducer.tsx @@ -33,6 +33,7 @@ const handleUnlock = (walletType: WalletId | undefined, payload: any) => { const wallets = (() => { switch (walletType) { case WalletId.VIEW_ONLY: + case WalletId.OFFLINE: return [payload]; case WalletId.WEB3: return payload.map((payloadItem: any) => ({ diff --git a/src/features/AddAccount/stories.tsx b/src/features/AddAccount/stories.tsx index e4426230382..e24175c8cb7 100644 --- a/src/features/AddAccount/stories.tsx +++ b/src/features/AddAccount/stories.tsx @@ -1,6 +1,7 @@ import { GridPlusUnlock, LegderUnlock, + OfflineDecrypt, TrezorUnlock, ViewOnlyDecrypt, WalletConnectDecrypt, @@ -39,5 +40,9 @@ export const getStories = (): IStory[] => [ { name: WalletId.VIEW_ONLY, steps: [NetworkSelectPanel, ViewOnlyDecrypt] + }, + { + name: WalletId.OFFLINE, + steps: [NetworkSelectPanel, OfflineDecrypt] } ]; diff --git a/src/services/WalletService/non-deterministic/address.ts b/src/services/WalletService/non-deterministic/address.ts index 05b91b63734..b83349b9045 100644 --- a/src/services/WalletService/non-deterministic/address.ts +++ b/src/services/WalletService/non-deterministic/address.ts @@ -3,9 +3,11 @@ import { IReadOnlyWallet } from '../IWallet'; export default class AddressOnlyWallet implements IReadOnlyWallet { public address = ''; public readonly isReadOnly = true; + public readonly isOffline: boolean; - constructor(address: string) { + constructor(address: string, isOffline: boolean = false) { this.address = address; + this.isOffline = isOffline; } public getAddress() { diff --git a/src/services/WalletService/walletService.ts b/src/services/WalletService/walletService.ts index 70b52493d99..85e45d4df28 100644 --- a/src/services/WalletService/walletService.ts +++ b/src/services/WalletService/walletService.ts @@ -49,6 +49,9 @@ export const WalletFactory = { [WalletId.VIEW_ONLY]: { init: ({ address }: ViewOnlyWalletInitArgs) => new AddressOnlyWallet(address) }, + [WalletId.OFFLINE]: { + init: ({ address }: ViewOnlyWalletInitArgs) => new AddressOnlyWallet(address, true) + }, [WalletId.WALLETCONNECT]: { init: ({ address, signMessageHandler, killHandler }: WalletConnectWalletInitArgs) => new WalletConnectWallet(address, signMessageHandler, killHandler) diff --git a/src/translations/lang/en.json b/src/translations/lang/en.json index e5a3f29d1f8..434b8b19d86 100644 --- a/src/translations/lang/en.json +++ b/src/translations/lang/en.json @@ -1046,6 +1046,9 @@ "HW_SIGN_ADDRESS_MISMATCH": "This transaction was signed by the incorrect account on your hardware wallet. Please be sure you are connected to $address in order to proceed.", "SELECT_A_MIGRATION": "Select a migration", "TOKEN_MIGRATION_HEADER": "Migrate Tokens", - "TX_DEPLOY_ADDRESS_LABEL": "Transaction deployed the following contract" + "TX_DEPLOY_ADDRESS_LABEL": "Transaction deployed the following contract", + "INPUT_OFFLINE_ADDRESS_LABEL": "Add an offline Address or ENS Name", + "OFFLINE_DISCLAIMER": "Adding an \"offline\" account means that you will not be able to send a transaction from it through MyCrypto. In order to send a transaction you can use Quill.", + "SIGN_TX_OFFLINE_PROMPT": "Enter the raw transaction in Quill in order to sign the transaction. You can then broadcast the signed transaction through MyCrypto." } } diff --git a/src/types/busyBottom.ts b/src/types/busyBottom.ts index 4094cf18da8..d10d6498558 100644 --- a/src/types/busyBottom.ts +++ b/src/types/busyBottom.ts @@ -7,5 +7,6 @@ export enum BusyBottomConfig { TREZOR = 'TREZOR', GRIDPLUS = 'GRIDPLUS', WALLETCONNECT = 'WALLETCONNECT', - SUPPORT = 'SUPPORT' + SUPPORT = 'SUPPORT', + OFFLINE = 'OFFLINE' } diff --git a/src/types/walletId.ts b/src/types/walletId.ts index a41d2131526..106ea26cbbc 100644 --- a/src/types/walletId.ts +++ b/src/types/walletId.ts @@ -9,6 +9,7 @@ export enum WalletId { TREZOR = 'TREZOR', VIEW_ONLY = 'VIEW_ONLY', WALLETCONNECT = 'WALLETCONNECT', + OFFLINE = 'OFFLINE', LEDGER_NANO_S_NEW = 'LEDGER_NANO_S_NEW', TREZOR_NEW = 'TREZOR_NEW', diff --git a/src/utils/makeTransaction.ts b/src/utils/makeTransaction.ts index 21e58c45412..417bb229d82 100644 --- a/src/utils/makeTransaction.ts +++ b/src/utils/makeTransaction.ts @@ -18,7 +18,7 @@ import { import { bigify } from './bigify'; import { fromWei, gasPriceToBase, toTokenBase, toWei, Wei } from './units'; -export const makeTransaction = (t: ITxObject): TransactionRequest => { +export const makeTransaction = (t: ITxObject): TransactionRequest & { nonce: number } => { // Hardware wallets need `from` param excluded const { from, ...tx } = t; return { ...tx, nonce: new BigNumber(t.nonce, 10).toNumber() }; From f5305ec5bbdc37d925c2a17a3608afd4dc89c761 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Wed, 16 Feb 2022 16:26:56 +0100 Subject: [PATCH 2/8] Remove some unused stuff --- .../SignTransactionWallets/Offline.tsx | 32 ++----------------- 1 file changed, 3 insertions(+), 29 deletions(-) diff --git a/src/components/SignTransactionWallets/Offline.tsx b/src/components/SignTransactionWallets/Offline.tsx index 6de030c7a87..b4bdab91289 100644 --- a/src/components/SignTransactionWallets/Offline.tsx +++ b/src/components/SignTransactionWallets/Offline.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { serialize } from '@ethersproject/transactions'; import styled from 'styled-components'; @@ -7,33 +7,13 @@ import { Body, Box, BusyBottom, Button, Heading, InputField, LinkApp } from '@co import { TxIntermediaryDisplay } from '@components/TransactionFlow/displays'; import { isContractInteraction } from '@components/TransactionFlow/helpers'; import { getWalletConfig, IWalletConfig, ROUTE_PATHS, WALLETS_CONFIG } from '@config'; -import { useNetworks } from '@services/Store'; import { getContractName, useSelector } from '@store'; import { FONT_SIZE, SPACING } from '@theme'; import translate, { translateRaw } from '@translations'; -import { BusyBottomConfig, ISignComponentProps, ITxObject, StoreAccount, WalletId } from '@types'; +import { BusyBottomConfig, ISignComponentProps, ITxObject, WalletId } from '@types'; import { makeTransaction } from '@utils'; -export enum WalletSigningState { - SUBMITTING, - REJECTED, - ADDRESS_MISMATCH, - NETWORK_MISMATCH, - SUCCESS, - UNKNOWN //used upon component initialization when wallet status is not determined -} - -export function SignTransactionOffline({ - senderAccount, - rawTransaction -}: // onSuccess -ISignComponentProps) { - const [walletState] = useState(WalletSigningState.UNKNOWN); - // const desiredAddress = getAddress(senderAccount.address); - - const { getNetworkByChainId } = useNetworks(); - const detectedNetwork = getNetworkByChainId(rawTransaction.chainId); - const networkName = detectedNetwork ? detectedNetwork.name : translateRaw('UNKNOWN_NETWORK'); +export function SignTransactionOffline({ senderAccount, rawTransaction }: ISignComponentProps) { const walletConfig = getWalletConfig(WalletId.OFFLINE); const network = senderAccount.networkId; @@ -42,9 +22,6 @@ ISignComponentProps) { return ( @@ -58,9 +35,6 @@ const Footer = styled.div` export interface UIProps { walletConfig: IWalletConfig; - walletState: WalletSigningState; - networkName: string; - senderAccount: StoreAccount; rawTransaction: ITxObject; contractName?: string; } From 59c0f16561e532e595d24e0d41c07081f76df5e5 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Thu, 17 Feb 2022 15:05:05 +0100 Subject: [PATCH 3/8] Add some tests --- .../__fixtures__/erc20Web3TxConfig.json | 3 +- .../SignTransactionWallets/Offline.test.tsx | 72 +++++++++++++++++++ src/components/WalletUnlock/Offline.test.tsx | 72 +++++++++++++++++++ src/components/WalletUnlock/Offline.tsx | 55 +++++++------- 4 files changed, 177 insertions(+), 25 deletions(-) create mode 100644 src/components/SignTransactionWallets/Offline.test.tsx create mode 100644 src/components/WalletUnlock/Offline.test.tsx diff --git a/jest_config/__fixtures__/erc20Web3TxConfig.json b/jest_config/__fixtures__/erc20Web3TxConfig.json index b649d6c7bc1..25beb1ba828 100644 --- a/jest_config/__fixtures__/erc20Web3TxConfig.json +++ b/jest_config/__fixtures__/erc20Web3TxConfig.json @@ -1949,5 +1949,6 @@ "gasLimit": "32048", "nonce": "25", "data": "0xa9059cbb0000000000000000000000005197b5b062288bbf29008c92b08010a92dd677cd000000000000000000000000000000000000000000000000002386f26fc10000", - "value": "0" + "value": "0", + "networkId": "Ropsten" } diff --git a/src/components/SignTransactionWallets/Offline.test.tsx b/src/components/SignTransactionWallets/Offline.test.tsx new file mode 100644 index 00000000000..3a7fd22497a --- /dev/null +++ b/src/components/SignTransactionWallets/Offline.test.tsx @@ -0,0 +1,72 @@ +import React, { ComponentProps } from 'react'; + +import { serialize } from '@ethersproject/transactions'; +import { APP_STATE, mockAppState, simpleRender } from 'test-utils'; + +import { WALLETS_CONFIG } from '@config'; +import SignTransaction from '@features/SendAssets/components/SignTransaction'; +import { fERC20Web3TxConfigJSON, fTxConfig } from '@fixtures'; +import { translateRaw } from '@translations'; +import { WalletId } from '@types'; +import { makeTransaction } from '@utils'; + +const defaultProps: ComponentProps = { + txConfig: { + ...fTxConfig, + senderAccount: { + ...fTxConfig.senderAccount, + address: '0x31497f490293cf5a4540b81c9f59910f62519b63', + wallet: WalletId.OFFLINE + } + }, + onComplete: jest.fn() +}; + +const getComponent = (props = defaultProps) => { + return simpleRender(, { + initialState: mockAppState({ + networks: APP_STATE.networks + }) + }); +}; + +describe('SignTransactionOffline', () => { + it('renders', () => { + const { getByText } = getComponent(); + expect( + getByText(translateRaw('SIGN_TX_TITLE', { $walletName: WALLETS_CONFIG.OFFLINE.name })) + ).toBeInTheDocument(); + }); + + it('shows the raw transaction', () => { + const { getByText } = getComponent(); + + const transaction = serialize(makeTransaction(fTxConfig.rawTransaction)); + expect(getByText(transaction)).toBeInTheDocument(); + }); + + it('shows the contract information', () => { + const { getByText } = getComponent({ + txConfig: { + ...fERC20Web3TxConfigJSON, + senderAccount: { + ...fTxConfig.senderAccount, + address: '0x31497f490293cf5a4540b81c9f59910f62519b63', + wallet: WalletId.OFFLINE + } + }, + onComplete: jest.fn() + }); + + const transaction = serialize(makeTransaction(fERC20Web3TxConfigJSON.rawTransaction)); + expect(getByText(transaction)).toBeInTheDocument(); + expect( + getByText( + translateRaw('TRANSACTION_PERFORMED_VIA_CONTRACT', { + $contractName: translateRaw('UNKNOWN') + }), + { exact: false } + ) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/WalletUnlock/Offline.test.tsx b/src/components/WalletUnlock/Offline.test.tsx new file mode 100644 index 00000000000..b921d9f06f8 --- /dev/null +++ b/src/components/WalletUnlock/Offline.test.tsx @@ -0,0 +1,72 @@ +import React, { ComponentProps } from 'react'; + +import { fireEvent } from '@testing-library/react'; +import { simpleRender } from 'test-utils'; + +import { WALLETS_CONFIG } from '@config'; +import { AddressOnlyWallet } from '@services'; +import { translateRaw } from '@translations'; +import { FormData, WalletId } from '@types'; + +import { OfflineDecrypt } from './Offline'; + +const defaultProps = { + wallet: WALLETS_CONFIG[WalletId.OFFLINE], + formData: ({ network: 'Ethereum' } as unknown) as FormData, + onUnlock: jest.fn() +}; + +const getComponent = (props: Partial> = {}) => { + return simpleRender(); +}; + +describe('Offline', () => { + it('renders', () => { + const { getByText } = getComponent(); + expect( + getByText(translateRaw('INPUT_OFFLINE_ADDRESS_LABEL'), { exact: false }) + ).toBeInTheDocument(); + }); + + it('validates the address', async () => { + const fn = jest.fn(); + const { getByTestId, getByText } = getComponent({ onUnlock: fn }); + + const selector = getByTestId('selector'); + const input = selector.querySelector('input')!; + const button = getByText(translateRaw('ACTION_6')); + + fireEvent.click(button); + expect(fn).not.toHaveBeenCalled(); + + fireEvent.click(input); + input.focus(); + fireEvent.change(input, { target: { value: 'foo' } }); + input.blur(); + + fireEvent.click(button); + expect(fn).not.toHaveBeenCalled(); + expect(getByText(translateRaw('TO_FIELD_ERROR'))).toBeInTheDocument(); + }); + + it('calls onUnlock', async () => { + const fn = jest.fn(); + const { getByTestId, getByText } = getComponent({ onUnlock: fn }); + + const selector = getByTestId('selector'); + const input = selector.querySelector('input')!; + const button = getByText(translateRaw('ACTION_6')); + + fireEvent.click(input); + input.focus(); + fireEvent.change(input, { target: { value: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520' } }); + input.blur(); + + fireEvent.click(button); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith( + new AddressOnlyWallet('0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520', true) + ); + }); +}); diff --git a/src/components/WalletUnlock/Offline.tsx b/src/components/WalletUnlock/Offline.tsx index 030071665c4..d1a184d8824 100644 --- a/src/components/WalletUnlock/Offline.tsx +++ b/src/components/WalletUnlock/Offline.tsx @@ -62,30 +62,37 @@ export function OfflineDecrypt({ formData, onUnlock }: Props) { {translateRaw('OFFLINE_DISCLAIMER')} - {({ errors, touched, values, setFieldError, setFieldTouched, setFieldValue }) => ( - - - onSubmit(values)} - > - {translateRaw('ACTION_6')} - - - )} + {({ errors, touched, values, setFieldError, setFieldTouched, setFieldValue }) => { + const handleSubmit = () => onSubmit(values); + + return ( + + + + {translateRaw('ACTION_6')} + + + ); + }} ); From 8b3035e544896bb908bfae4176e9d4f484a8dfe9 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 18 Feb 2022 15:36:26 +0100 Subject: [PATCH 4/8] Add QR code scanner --- package.json | 3 +- .../BroadcastTransactionFlow.test.tsx | 19 ++++ .../components/BroadcastTx.tsx | 74 ++++++++++----- .../components/Scanner.test.tsx | 39 ++++++++ .../components/Scanner.tsx | 89 +++++++++++++++++++ .../BroadcastTransaction/components/index.ts | 1 + .../components/useScanner.test.ts | 69 ++++++++++++++ .../components/useScanner.ts | 35 ++++++++ src/index.tsx | 1 + src/translations/lang/en.json | 5 +- yarn.lock | 12 +++ 11 files changed, 323 insertions(+), 24 deletions(-) create mode 100644 src/features/BroadcastTransaction/components/Scanner.test.tsx create mode 100644 src/features/BroadcastTransaction/components/Scanner.tsx create mode 100644 src/features/BroadcastTransaction/components/useScanner.test.ts create mode 100644 src/features/BroadcastTransaction/components/useScanner.ts diff --git a/package.json b/package.json index f5dd7e0d223..f90ed11d06b 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "lodash": "4.17.21", "papaparse": "5.2.0", "post-message-stream": "3.0.0", + "qr-scanner": "^1.4.0", "qrcode": "1.5.0", "query-string": "6.0.0", "ramda": "0.27.2", @@ -236,7 +237,7 @@ "storybook": "start-storybook -p 3001 --ci", "build:storybook": "build-storybook", "tscheck": "tsc --noEmit", - "start": " yarn run dev", + "start": "yarn run dev", "serve": "ws -d dist/web/ --spa index.html", "prod": "npm run build && npm run serve", "precommit": "lint-staged", diff --git a/src/features/BroadcastTransaction/BroadcastTransactionFlow.test.tsx b/src/features/BroadcastTransaction/BroadcastTransactionFlow.test.tsx index c11469894a0..521af51ebd0 100644 --- a/src/features/BroadcastTransaction/BroadcastTransactionFlow.test.tsx +++ b/src/features/BroadcastTransaction/BroadcastTransactionFlow.test.tsx @@ -19,6 +19,14 @@ jest.mock('@vendor', () => { }; }); +jest.mock('qr-scanner', () => { + return class QrScanner { + static hasCamera() { + return Promise.resolve(true); + } + }; +}); + function getComponent() { return simpleRender(, { initialState: mockAppState({ @@ -56,4 +64,15 @@ describe('BroadcastTransactionFlow', () => { expect(getByText(translateRaw('BROADCAST_TX_RECEIPT_TITLE'))).toBeInTheDocument() ); }); + + it('shows the QR scanner', async () => { + const { getByText, getByTestId } = getComponent(); + const button = getByText(translateRaw('SCAN_QR')); + + // Need to wait for a bit for the scanner button to become enabled + await new Promise((resolve) => setTimeout(resolve, 100)); + fireEvent.click(button); + + await waitFor(() => expect(getByTestId('scanner')).toBeInTheDocument()); + }); }); diff --git a/src/features/BroadcastTransaction/components/BroadcastTx.tsx b/src/features/BroadcastTransaction/components/BroadcastTx.tsx index 52e01fd298c..fa7e39deb77 100644 --- a/src/features/BroadcastTransaction/components/BroadcastTx.tsx +++ b/src/features/BroadcastTransaction/components/BroadcastTx.tsx @@ -2,6 +2,7 @@ import { FormEvent, Fragment, useEffect, useState } from 'react'; import { parse as parseTransaction, Transaction } from '@ethersproject/transactions'; import { toBuffer } from 'ethereumjs-util'; +import QrScanner from 'qr-scanner'; import styled from 'styled-components'; import { @@ -14,12 +15,14 @@ import { NetworkSelector, Tooltip } from '@components'; +import { Scanner } from '@features/BroadcastTransaction/components/Scanner'; import { verifyTransaction } from '@helpers'; import { getNetworkByChainId } from '@services/Store/Network'; import { useSelector } from '@store'; import { selectNetworks } from '@store/network.slice'; import translate, { translateRaw } from '@translations'; import { ISignedTx, NetworkId } from '@types'; +import { useEffectOnce } from '@vendor'; const ContentWrapper = styled.div` display: flex; @@ -96,6 +99,9 @@ interface Props { } export const BroadcastTx = ({ signedTx, networkId, onComplete, handleNetworkChanged }: Props) => { + const [isScanner, setScanner] = useState(false); + const [isScannerEnabled, setScannerEnabled] = useState(false); + const [userInput, setUserInput] = useState(signedTx); const [inputError, setInputError] = useState(''); const [transaction, setTransaction] = useState( @@ -103,6 +109,12 @@ export const BroadcastTx = ({ signedTx, networkId, onComplete, handleNetworkChan ); const networks = useSelector(selectNetworks); + const handleToggleScanner = () => setScanner((value) => !value); + + useEffectOnce(() => { + QrScanner.hasCamera().then(setScannerEnabled).catch(console.error); + }); + useEffect(() => { if (transaction && verifyTransaction(transaction)) { setInputError(''); @@ -119,34 +131,52 @@ export const BroadcastTx = ({ signedTx, networkId, onComplete, handleNetworkChan setTransaction(makeTxFromSignedTx(trimmedValue)); }; + const handleScan = (signedTransaction: string) => { + setScanner(false); + setUserInput(signedTransaction); + setInputError(''); + setTransaction(makeTxFromSignedTx(signedTransaction.trim())); + }; + + const handleComplete = () => onComplete(userInput.trim()); + const validNetwork = transaction && transaction.chainId ? getNetworkByChainId(transaction?.chainId, networks) : true; const isValid = transaction !== undefined && inputError.length === 0 && validNetwork; return ( - {translate('BROADCAST_TX_DESCRIPTION')} - - - 0 ? inputError : ''} - marginBottom="0" - /> - - {!validNetwork && transaction && ( - - {translate('BROADCAST_TX_INVALID_CHAIN_ID', { - $chain_id: transaction.chainId.toString() - })} - - )} + {translateRaw('BROADCAST_TX_DESCRIPTION')} + + + {isScanner ? ( + + ) : ( + + + 0 ? inputError : ''} + marginBottom="0" + /> + + {!validNetwork && transaction && ( + + {translate('BROADCAST_TX_INVALID_CHAIN_ID', { + $chain_id: transaction.chainId.toString() + })} + + )} + + )} {isValid && ( {!transaction!.chainId && ( @@ -163,7 +193,7 @@ export const BroadcastTx = ({ signedTx, networkId, onComplete, handleNetworkChan )} - onComplete(userInput.trim())}> + {translateRaw('SEND_TRANS')} diff --git a/src/features/BroadcastTransaction/components/Scanner.test.tsx b/src/features/BroadcastTransaction/components/Scanner.test.tsx new file mode 100644 index 00000000000..8f4da76fca3 --- /dev/null +++ b/src/features/BroadcastTransaction/components/Scanner.test.tsx @@ -0,0 +1,39 @@ +import { simpleRender } from 'test-utils'; + +import { fSignedTx } from '@fixtures'; +import { translateRaw } from '@translations'; + +import { Scanner } from './Scanner'; +import { useScanner } from './useScanner'; + +jest.mock('./useScanner', () => ({ + useScanner: jest.fn().mockReturnValue({ + isLoading: true, + error: '' + }) +})); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('Scanner', () => { + it('shows a spinner', async () => { + const { getByTestId } = simpleRender(); + expect(getByTestId('spinner')).toBeInTheDocument(); + }); + + it('decodes the QR data', () => { + const onScan = jest.fn(); + const { getByText } = simpleRender(); + + const mock = useScanner as jest.MockedFunction; + const handleDecode = mock.mock.calls[0][1]; + + handleDecode({ data: 'foo' }); + expect(getByText(translateRaw('INVALID_SIGNED_TRANSACTION_QR'))).toBeInTheDocument(); + + handleDecode({ data: fSignedTx }); + expect(onScan).toHaveBeenCalledWith(fSignedTx); + }); +}); diff --git a/src/features/BroadcastTransaction/components/Scanner.tsx b/src/features/BroadcastTransaction/components/Scanner.tsx new file mode 100644 index 00000000000..e0dbf806760 --- /dev/null +++ b/src/features/BroadcastTransaction/components/Scanner.tsx @@ -0,0 +1,89 @@ +import { FunctionComponent, useRef, useState } from 'react'; + +import { parse as parseTransaction } from '@ethersproject/transactions'; +import { toBuffer } from 'ethereumjs-util'; +import styled from 'styled-components'; + +import { Box, Spinner } from '@components'; +import { useScanner } from '@features/BroadcastTransaction/components/useScanner'; +import { verifyTransaction } from '@helpers'; +import { COLORS } from '@theme'; +import { translateRaw } from '@translations'; + +const VideoWrapper = styled.div` + width: 100%; + position: relative; + + svg { + stroke: #a086f7 !important; + } +`; + +const SpinnerWrapper = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; +`; + +const Video = styled.video` + width: 100%; + height: 100%; + object-fit: cover; +`; + +const Error = styled.span` + min-height: 22px; + color: ${COLORS.ERROR_RED}; +`; + +interface ScannerProps { + onScan(signedTransaction: string): void; +} + +export const Scanner: FunctionComponent = ({ onScan }) => { + const [decodeError, setDecodeError] = useState(''); + + const handleDecode = ({ data }: { data: string }) => { + setDecodeError(''); + + try { + const buffer = toBuffer(data); + const transaction = parseTransaction(buffer); + + if (verifyTransaction(transaction)) { + onScan(data); + } + } catch (error) { + setDecodeError(translateRaw('INVALID_SIGNED_TRANSACTION_QR')); + } + }; + + const videoRef = useRef(null); + const { isLoading, error } = useScanner(videoRef, handleDecode); + + return ( + + + + {(error || decodeError) && {error || decodeError}} + + ); +}; diff --git a/src/features/BroadcastTransaction/components/index.ts b/src/features/BroadcastTransaction/components/index.ts index 1b7fc8661dc..31c2eee3a92 100644 --- a/src/features/BroadcastTransaction/components/index.ts +++ b/src/features/BroadcastTransaction/components/index.ts @@ -1 +1,2 @@ export { BroadcastTx } from './BroadcastTx'; +export * from './Scanner'; diff --git a/src/features/BroadcastTransaction/components/useScanner.test.ts b/src/features/BroadcastTransaction/components/useScanner.test.ts new file mode 100644 index 00000000000..df2e0f29083 --- /dev/null +++ b/src/features/BroadcastTransaction/components/useScanner.test.ts @@ -0,0 +1,69 @@ +import { RefObject } from 'react'; + +import { waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import QrScanner from 'qr-scanner'; + +import { useScanner } from './useScanner'; + + +jest.mock('qr-scanner'); + +describe('useScanner', () => { + it('initialises the scanner', async () => { + const start = jest.fn().mockResolvedValue(undefined); + const destroy = jest.fn(); + const mock = QrScanner as jest.MockedClass; + + mock.mockImplementation( + () => + (({ + start, + destroy + } as unknown) as QrScanner) + ); + + const ref = { + current: {} + } as RefObject; + + const { result, unmount } = renderHook(() => useScanner(ref, jest.fn())); + expect(result.current.isLoading).toBe(true); + + // Triggers an update + await waitFor(() => undefined); + + expect(result.current.isLoading).toBe(false); + expect(start).toHaveBeenCalledTimes(1); + + unmount(); + + expect(destroy).toHaveBeenCalledTimes(1); + }); + + it('returns errors', async () => { + const start = jest.fn().mockRejectedValue('foo'); + const destroy = jest.fn(); + const mock = QrScanner as jest.MockedClass; + + mock.mockImplementation( + () => + (({ + start, + destroy + } as unknown) as QrScanner) + ); + + const ref = { + current: {} + } as RefObject; + + const { result } = renderHook(() => useScanner(ref, jest.fn())); + expect(result.current.isLoading).toBe(true); + + // Triggers an update + await waitFor(() => undefined); + + expect(result.current.error).toBe('foo'); + }); +}); diff --git a/src/features/BroadcastTransaction/components/useScanner.ts b/src/features/BroadcastTransaction/components/useScanner.ts new file mode 100644 index 00000000000..f530553c795 --- /dev/null +++ b/src/features/BroadcastTransaction/components/useScanner.ts @@ -0,0 +1,35 @@ +import { RefObject, useEffect, useMemo, useState } from 'react'; + +import QrScanner from 'qr-scanner'; + +/** + * This hook is needed to make it testable. `simpleRender()` does not initialise the ref, so the cod would + * never be executed then. + */ +export const useScanner = ( + ref: RefObject, + onDecode: (message: { data: string }) => void +): { isLoading: boolean; error: string; scanner: QrScanner | null } => { + const [isLoading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const scanner = useMemo( + () => ref.current && new QrScanner(ref.current, onDecode, { highlightScanRegion: true }), + [ref.current] + ); + + useEffect(() => { + setLoading(true); + + if (scanner) { + scanner + .start() + .catch(setError) + .finally(() => setLoading(false)); + + return () => scanner.destroy(); + } + }, [scanner]); + + return { isLoading, error, scanner }; +}; diff --git a/src/index.tsx b/src/index.tsx index fd570ba33e5..f6af6abab72 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -26,6 +26,7 @@ import { ethereumMock } from '@vendor'; * https://developer.mozilla.org/en-US/docs/Web/API/Document/domain) * 4. Since we run 3 environments we dynamically set the domain to the appropriate hostname. */ +// @todo: This breaks remote debugging document.domain = getRootDomain(document.location.hostname); // disables drag-and-drop due to potential security issues by Cure53 recommendation diff --git a/src/translations/lang/en.json b/src/translations/lang/en.json index 434b8b19d86..e1bdded85fa 100644 --- a/src/translations/lang/en.json +++ b/src/translations/lang/en.json @@ -1049,6 +1049,9 @@ "TX_DEPLOY_ADDRESS_LABEL": "Transaction deployed the following contract", "INPUT_OFFLINE_ADDRESS_LABEL": "Add an offline Address or ENS Name", "OFFLINE_DISCLAIMER": "Adding an \"offline\" account means that you will not be able to send a transaction from it through MyCrypto. In order to send a transaction you can use Quill.", - "SIGN_TX_OFFLINE_PROMPT": "Enter the raw transaction in Quill in order to sign the transaction. You can then broadcast the signed transaction through MyCrypto." + "SIGN_TX_OFFLINE_PROMPT": "Enter the raw transaction in Quill in order to sign the transaction. You can then broadcast the signed transaction through MyCrypto.", + "ENTER_SIGNED_TRANSACTION_MANUALLY": "Manually enter signed transaction", + "SCAN_QR": "Scan QR code", + "INVALID_SIGNED_TRANSACTION_QR": "Unable to process QR code" } } diff --git a/yarn.lock b/yarn.lock index 25d2284338a..88cca199fcf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4618,6 +4618,11 @@ resolved "https://registry.yarnpkg.com/@types/npmlog/-/npmlog-4.1.2.tgz#d070fe6a6b78755d1092a3dc492d34c3d8f871c4" integrity sha512-4QQmOF5KlwfxJ5IGXFIudkeLCdMABz03RcUXu+LCb24zmln8QW6aDjuGl4d4XPVLf2j+FnjelHTP7dvceAFbhA== +"@types/offscreencanvas@^2019.6.4": + version "2019.6.4" + resolved "https://registry.yarnpkg.com/@types/offscreencanvas/-/offscreencanvas-2019.6.4.tgz#64f6d120b53925028299c744fcdd32d2cd525963" + integrity sha512-u8SAgdZ8ROtkTF+mfZGOscl0or6BSj9A4g37e6nvxDc+YB/oDut0wHkK2PBBiC2bNR8TS0CPV+1gAk4fNisr1Q== + "@types/overlayscrollbars@^1.12.0": version "1.12.0" resolved "https://registry.yarnpkg.com/@types/overlayscrollbars/-/overlayscrollbars-1.12.0.tgz#98456caceca8ad73bd5bb572632a585074e70764" @@ -17765,6 +17770,13 @@ q@^1.1.2: resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= +qr-scanner@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/qr-scanner/-/qr-scanner-1.4.0.tgz#12e5cf1657f30e3ee079421d34e02e1b2d5909b8" + integrity sha512-/WGS0Vf39G/AEKslmE4EGevoYhhbX2iX4tsFp9dT0wtHJgKlCPk2vCXOJr8HnCICyj4TJS6GRmUUGjBf8AUEHA== + dependencies: + "@types/offscreencanvas" "^2019.6.4" + qrcode-terminal@^0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.10.0.tgz#a76a48e2610a18f97fa3a2bd532b682acff86c53" From 6d663452998d025d3aa998c19ad854414a6b7d72 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 18 Feb 2022 15:39:25 +0100 Subject: [PATCH 5/8] Fix dependency version --- package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f90ed11d06b..683d1888877 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "lodash": "4.17.21", "papaparse": "5.2.0", "post-message-stream": "3.0.0", - "qr-scanner": "^1.4.0", + "qr-scanner": "1.4.0", "qrcode": "1.5.0", "query-string": "6.0.0", "ramda": "0.27.2", diff --git a/yarn.lock b/yarn.lock index 88cca199fcf..a1251cc8e3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17770,7 +17770,7 @@ q@^1.1.2: resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= -qr-scanner@^1.4.0: +qr-scanner@1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/qr-scanner/-/qr-scanner-1.4.0.tgz#12e5cf1657f30e3ee079421d34e02e1b2d5909b8" integrity sha512-/WGS0Vf39G/AEKslmE4EGevoYhhbX2iX4tsFp9dT0wtHJgKlCPk2vCXOJr8HnCICyj4TJS6GRmUUGjBf8AUEHA== From 57b5a563b7cbc9eab1f615f64786226ee57d5753 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Fri, 18 Feb 2022 16:03:54 +0100 Subject: [PATCH 6/8] Run prettier --- src/features/BroadcastTransaction/components/useScanner.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/features/BroadcastTransaction/components/useScanner.test.ts b/src/features/BroadcastTransaction/components/useScanner.test.ts index df2e0f29083..8d231112ce3 100644 --- a/src/features/BroadcastTransaction/components/useScanner.test.ts +++ b/src/features/BroadcastTransaction/components/useScanner.test.ts @@ -6,7 +6,6 @@ import QrScanner from 'qr-scanner'; import { useScanner } from './useScanner'; - jest.mock('qr-scanner'); describe('useScanner', () => { From cf4efd50c000a1e8dfff027ffdbde4e0b00f218b Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 22 Feb 2022 10:34:43 +0100 Subject: [PATCH 7/8] Update icon and translations --- src/assets/images/icn-offline.svg | 30 +++++++++++++++++++ src/config/wallets.ts | 2 +- .../components/useScanner.ts | 2 +- src/translations/lang/en.json | 3 +- 4 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 src/assets/images/icn-offline.svg diff --git a/src/assets/images/icn-offline.svg b/src/assets/images/icn-offline.svg new file mode 100644 index 00000000000..39554be68b4 --- /dev/null +++ b/src/assets/images/icn-offline.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/config/wallets.ts b/src/config/wallets.ts index 332ef5b38c5..22e08e2393a 100644 --- a/src/config/wallets.ts +++ b/src/config/wallets.ts @@ -1,6 +1,6 @@ // @ADD_ACCOUNT_@todo: Icons really belongs to the WalletButton or a WalletIcon // component. -import offlineIcon from '@assets/images/icn-desktop-app.svg'; +import offlineIcon from '@assets/images/icn-offline.svg'; import viewOnlyIcon from '@assets/images/icn-view-only.svg'; import CoinbaseWalletIcon from '@assets/images/wallets/coinbase.svg'; import FrameIcon from '@assets/images/wallets/frame.svg'; diff --git a/src/features/BroadcastTransaction/components/useScanner.ts b/src/features/BroadcastTransaction/components/useScanner.ts index f530553c795..343feaed5d8 100644 --- a/src/features/BroadcastTransaction/components/useScanner.ts +++ b/src/features/BroadcastTransaction/components/useScanner.ts @@ -3,7 +3,7 @@ import { RefObject, useEffect, useMemo, useState } from 'react'; import QrScanner from 'qr-scanner'; /** - * This hook is needed to make it testable. `simpleRender()` does not initialise the ref, so the cod would + * This hook is needed to make it testable. `simpleRender()` does not initialise the ref, so the code would * never be executed then. */ export const useScanner = ( diff --git a/src/translations/lang/en.json b/src/translations/lang/en.json index e1bdded85fa..4295cf0c198 100644 --- a/src/translations/lang/en.json +++ b/src/translations/lang/en.json @@ -1052,6 +1052,7 @@ "SIGN_TX_OFFLINE_PROMPT": "Enter the raw transaction in Quill in order to sign the transaction. You can then broadcast the signed transaction through MyCrypto.", "ENTER_SIGNED_TRANSACTION_MANUALLY": "Manually enter signed transaction", "SCAN_QR": "Scan QR code", - "INVALID_SIGNED_TRANSACTION_QR": "Unable to process QR code" + "INVALID_SIGNED_TRANSACTION_QR": "Unable to process QR code", + "OFFLINE": "Offline" } } From 542bc2e7ab3dc87fe0504959b0ed46e9fd952489 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn Date: Tue, 22 Feb 2022 13:52:44 +0100 Subject: [PATCH 8/8] Simplify QR scanner --- package.json | 2 +- .../BroadcastTransactionFlow.test.tsx | 10 +-- .../components/BroadcastTx.tsx | 5 +- .../components/Scanner.test.tsx | 27 +++---- .../components/Scanner.tsx | 79 ++++++------------- .../components/useScanner.test.ts | 68 ---------------- .../components/useScanner.ts | 35 -------- src/features/BroadcastTransaction/helpers.ts | 8 ++ yarn.lock | 54 ++++++++++--- 9 files changed, 92 insertions(+), 196 deletions(-) delete mode 100644 src/features/BroadcastTransaction/components/useScanner.test.ts delete mode 100644 src/features/BroadcastTransaction/components/useScanner.ts create mode 100644 src/features/BroadcastTransaction/helpers.ts diff --git a/package.json b/package.json index 683d1888877..206f267d817 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,6 @@ "lodash": "4.17.21", "papaparse": "5.2.0", "post-message-stream": "3.0.0", - "qr-scanner": "1.4.0", "qrcode": "1.5.0", "query-string": "6.0.0", "ramda": "0.27.2", @@ -67,6 +66,7 @@ "react-dom": "17.0.2", "react-is": "16.12.0", "react-markdown": "6.0.2", + "react-qr-reader": "3.0.0-beta-1", "react-redux": "7.2.4", "react-responsive": "8.2.0", "react-router-dom": "5.1.2", diff --git a/src/features/BroadcastTransaction/BroadcastTransactionFlow.test.tsx b/src/features/BroadcastTransaction/BroadcastTransactionFlow.test.tsx index 521af51ebd0..6f41010f78b 100644 --- a/src/features/BroadcastTransaction/BroadcastTransactionFlow.test.tsx +++ b/src/features/BroadcastTransaction/BroadcastTransactionFlow.test.tsx @@ -19,13 +19,9 @@ jest.mock('@vendor', () => { }; }); -jest.mock('qr-scanner', () => { - return class QrScanner { - static hasCamera() { - return Promise.resolve(true); - } - }; -}); +jest.mock('./helpers', () => ({ + hasCamera: jest.fn().mockResolvedValue(true) +})); function getComponent() { return simpleRender(, { diff --git a/src/features/BroadcastTransaction/components/BroadcastTx.tsx b/src/features/BroadcastTransaction/components/BroadcastTx.tsx index fa7e39deb77..a139586a8c9 100644 --- a/src/features/BroadcastTransaction/components/BroadcastTx.tsx +++ b/src/features/BroadcastTransaction/components/BroadcastTx.tsx @@ -2,7 +2,6 @@ import { FormEvent, Fragment, useEffect, useState } from 'react'; import { parse as parseTransaction, Transaction } from '@ethersproject/transactions'; import { toBuffer } from 'ethereumjs-util'; -import QrScanner from 'qr-scanner'; import styled from 'styled-components'; import { @@ -24,6 +23,8 @@ import translate, { translateRaw } from '@translations'; import { ISignedTx, NetworkId } from '@types'; import { useEffectOnce } from '@vendor'; +import { hasCamera } from '../helpers'; + const ContentWrapper = styled.div` display: flex; flex-direction: column; @@ -112,7 +113,7 @@ export const BroadcastTx = ({ signedTx, networkId, onComplete, handleNetworkChan const handleToggleScanner = () => setScanner((value) => !value); useEffectOnce(() => { - QrScanner.hasCamera().then(setScannerEnabled).catch(console.error); + hasCamera().then(setScannerEnabled).catch(console.error); }); useEffect(() => { diff --git a/src/features/BroadcastTransaction/components/Scanner.test.tsx b/src/features/BroadcastTransaction/components/Scanner.test.tsx index 8f4da76fca3..6a1bdee6d66 100644 --- a/src/features/BroadcastTransaction/components/Scanner.test.tsx +++ b/src/features/BroadcastTransaction/components/Scanner.test.tsx @@ -1,39 +1,32 @@ +import { Result } from '@zxing/library'; +import { QrReader } from 'react-qr-reader'; import { simpleRender } from 'test-utils'; import { fSignedTx } from '@fixtures'; import { translateRaw } from '@translations'; import { Scanner } from './Scanner'; -import { useScanner } from './useScanner'; - -jest.mock('./useScanner', () => ({ - useScanner: jest.fn().mockReturnValue({ - isLoading: true, - error: '' - }) -})); beforeEach(() => { jest.clearAllMocks(); }); -describe('Scanner', () => { - it('shows a spinner', async () => { - const { getByTestId } = simpleRender(); - expect(getByTestId('spinner')).toBeInTheDocument(); - }); +jest.mock('react-qr-reader', () => ({ + QrReader: jest.fn().mockReturnValue(null) +})); +describe('Scanner', () => { it('decodes the QR data', () => { const onScan = jest.fn(); const { getByText } = simpleRender(); - const mock = useScanner as jest.MockedFunction; - const handleDecode = mock.mock.calls[0][1]; + const mock = QrReader as jest.MockedFunction; + const handleDecode = mock.mock.calls[0][0].onResult!; - handleDecode({ data: 'foo' }); + handleDecode({ getText: () => 'foo' } as Result); expect(getByText(translateRaw('INVALID_SIGNED_TRANSACTION_QR'))).toBeInTheDocument(); - handleDecode({ data: fSignedTx }); + handleDecode({ getText: () => fSignedTx } as Result); expect(onScan).toHaveBeenCalledWith(fSignedTx); }); }); diff --git a/src/features/BroadcastTransaction/components/Scanner.tsx b/src/features/BroadcastTransaction/components/Scanner.tsx index e0dbf806760..1485023613e 100644 --- a/src/features/BroadcastTransaction/components/Scanner.tsx +++ b/src/features/BroadcastTransaction/components/Scanner.tsx @@ -1,41 +1,15 @@ -import { FunctionComponent, useRef, useState } from 'react'; +import { FunctionComponent, useState } from 'react'; import { parse as parseTransaction } from '@ethersproject/transactions'; import { toBuffer } from 'ethereumjs-util'; +import { OnResultFunction, QrReader } from 'react-qr-reader'; import styled from 'styled-components'; -import { Box, Spinner } from '@components'; -import { useScanner } from '@features/BroadcastTransaction/components/useScanner'; +import { Box } from '@components'; import { verifyTransaction } from '@helpers'; import { COLORS } from '@theme'; import { translateRaw } from '@translations'; -const VideoWrapper = styled.div` - width: 100%; - position: relative; - - svg { - stroke: #a086f7 !important; - } -`; - -const SpinnerWrapper = styled.div` - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: center; -`; - -const Video = styled.video` - width: 100%; - height: 100%; - object-fit: cover; -`; - const Error = styled.span` min-height: 22px; color: ${COLORS.ERROR_RED}; @@ -46,26 +20,26 @@ interface ScannerProps { } export const Scanner: FunctionComponent = ({ onScan }) => { - const [decodeError, setDecodeError] = useState(''); - - const handleDecode = ({ data }: { data: string }) => { - setDecodeError(''); - - try { - const buffer = toBuffer(data); - const transaction = parseTransaction(buffer); - - if (verifyTransaction(transaction)) { - onScan(data); + const [error, setError] = useState(''); + + const handleDecode: OnResultFunction = (data) => { + setError(''); + + if (data) { + try { + const text = data.getText(); + const buffer = toBuffer(text); + const transaction = parseTransaction(buffer); + + if (verifyTransaction(transaction)) { + onScan(text!); + } + } catch (error) { + setError(translateRaw('INVALID_SIGNED_TRANSACTION_QR')); } - } catch (error) { - setDecodeError(translateRaw('INVALID_SIGNED_TRANSACTION_QR')); } }; - const videoRef = useRef(null); - const { isLoading, error } = useScanner(videoRef, handleDecode); - return ( = ({ onScan }) => { alignItems="center" data-testid="scanner" > - - - {(error || decodeError) && {error || decodeError}} + + {error && {error}} ); }; diff --git a/src/features/BroadcastTransaction/components/useScanner.test.ts b/src/features/BroadcastTransaction/components/useScanner.test.ts deleted file mode 100644 index 8d231112ce3..00000000000 --- a/src/features/BroadcastTransaction/components/useScanner.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { RefObject } from 'react'; - -import { waitFor } from '@testing-library/react'; -import { renderHook } from '@testing-library/react-hooks'; -import QrScanner from 'qr-scanner'; - -import { useScanner } from './useScanner'; - -jest.mock('qr-scanner'); - -describe('useScanner', () => { - it('initialises the scanner', async () => { - const start = jest.fn().mockResolvedValue(undefined); - const destroy = jest.fn(); - const mock = QrScanner as jest.MockedClass; - - mock.mockImplementation( - () => - (({ - start, - destroy - } as unknown) as QrScanner) - ); - - const ref = { - current: {} - } as RefObject; - - const { result, unmount } = renderHook(() => useScanner(ref, jest.fn())); - expect(result.current.isLoading).toBe(true); - - // Triggers an update - await waitFor(() => undefined); - - expect(result.current.isLoading).toBe(false); - expect(start).toHaveBeenCalledTimes(1); - - unmount(); - - expect(destroy).toHaveBeenCalledTimes(1); - }); - - it('returns errors', async () => { - const start = jest.fn().mockRejectedValue('foo'); - const destroy = jest.fn(); - const mock = QrScanner as jest.MockedClass; - - mock.mockImplementation( - () => - (({ - start, - destroy - } as unknown) as QrScanner) - ); - - const ref = { - current: {} - } as RefObject; - - const { result } = renderHook(() => useScanner(ref, jest.fn())); - expect(result.current.isLoading).toBe(true); - - // Triggers an update - await waitFor(() => undefined); - - expect(result.current.error).toBe('foo'); - }); -}); diff --git a/src/features/BroadcastTransaction/components/useScanner.ts b/src/features/BroadcastTransaction/components/useScanner.ts deleted file mode 100644 index 343feaed5d8..00000000000 --- a/src/features/BroadcastTransaction/components/useScanner.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { RefObject, useEffect, useMemo, useState } from 'react'; - -import QrScanner from 'qr-scanner'; - -/** - * This hook is needed to make it testable. `simpleRender()` does not initialise the ref, so the code would - * never be executed then. - */ -export const useScanner = ( - ref: RefObject, - onDecode: (message: { data: string }) => void -): { isLoading: boolean; error: string; scanner: QrScanner | null } => { - const [isLoading, setLoading] = useState(true); - const [error, setError] = useState(''); - - const scanner = useMemo( - () => ref.current && new QrScanner(ref.current, onDecode, { highlightScanRegion: true }), - [ref.current] - ); - - useEffect(() => { - setLoading(true); - - if (scanner) { - scanner - .start() - .catch(setError) - .finally(() => setLoading(false)); - - return () => scanner.destroy(); - } - }, [scanner]); - - return { isLoading, error, scanner }; -}; diff --git a/src/features/BroadcastTransaction/helpers.ts b/src/features/BroadcastTransaction/helpers.ts new file mode 100644 index 00000000000..8e668736895 --- /dev/null +++ b/src/features/BroadcastTransaction/helpers.ts @@ -0,0 +1,8 @@ +export const hasCamera = async (): Promise => { + if (!navigator.mediaDevices) { + return false; + } + + const devices = await navigator.mediaDevices.enumerateDevices(); + return !!devices.find((device) => device.kind === 'videoinput'); +}; diff --git a/yarn.lock b/yarn.lock index a1251cc8e3f..44592e471dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4618,11 +4618,6 @@ resolved "https://registry.yarnpkg.com/@types/npmlog/-/npmlog-4.1.2.tgz#d070fe6a6b78755d1092a3dc492d34c3d8f871c4" integrity sha512-4QQmOF5KlwfxJ5IGXFIudkeLCdMABz03RcUXu+LCb24zmln8QW6aDjuGl4d4XPVLf2j+FnjelHTP7dvceAFbhA== -"@types/offscreencanvas@^2019.6.4": - version "2019.6.4" - resolved "https://registry.yarnpkg.com/@types/offscreencanvas/-/offscreencanvas-2019.6.4.tgz#64f6d120b53925028299c744fcdd32d2cd525963" - integrity sha512-u8SAgdZ8ROtkTF+mfZGOscl0or6BSj9A4g37e6nvxDc+YB/oDut0wHkK2PBBiC2bNR8TS0CPV+1gAk4fNisr1Q== - "@types/overlayscrollbars@^1.12.0": version "1.12.0" resolved "https://registry.yarnpkg.com/@types/overlayscrollbars/-/overlayscrollbars-1.12.0.tgz#98456caceca8ad73bd5bb572632a585074e70764" @@ -5544,6 +5539,27 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== +"@zxing/browser@0.0.7": + version "0.0.7" + resolved "https://registry.yarnpkg.com/@zxing/browser/-/browser-0.0.7.tgz#5fa7680a867b660f48d3288fdf63e0174ad531c7" + integrity sha512-AepzMgDnD6EjxewqmXpHJsi4S3Gw9ilZJLIbTf6fWuWySEcHBodnGu3p7FWlgq1Sd5QyfPhTum5z3CBkkhMVng== + optionalDependencies: + "@zxing/text-encoding" "^0.9.0" + +"@zxing/library@^0.18.3": + version "0.18.6" + resolved "https://registry.yarnpkg.com/@zxing/library/-/library-0.18.6.tgz#717af8c6c1fd982865e21051afdd7b470ae6674c" + integrity sha512-bulZ9JHoLFd9W36pi+7e7DnEYNJhljYjZ1UTsKPOoLMU3qtC+REHITeCRNx40zTRJZx18W5TBRXt5pq2Uopjsw== + dependencies: + ts-custom-error "^3.0.0" + optionalDependencies: + "@zxing/text-encoding" "~0.9.0" + +"@zxing/text-encoding@^0.9.0", "@zxing/text-encoding@~0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@zxing/text-encoding/-/text-encoding-0.9.0.tgz#fb50ffabc6c7c66a0c96b4c03e3d9be74864b70b" + integrity sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA== + abab@^2.0.3: version "2.0.5" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a" @@ -17770,13 +17786,6 @@ q@^1.1.2: resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= -qr-scanner@1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/qr-scanner/-/qr-scanner-1.4.0.tgz#12e5cf1657f30e3ee079421d34e02e1b2d5909b8" - integrity sha512-/WGS0Vf39G/AEKslmE4EGevoYhhbX2iX4tsFp9dT0wtHJgKlCPk2vCXOJr8HnCICyj4TJS6GRmUUGjBf8AUEHA== - dependencies: - "@types/offscreencanvas" "^2019.6.4" - qrcode-terminal@^0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.10.0.tgz#a76a48e2610a18f97fa3a2bd532b682acff86c53" @@ -18284,6 +18293,15 @@ react-popper@^2.2.4: react-fast-compare "^3.0.1" warning "^4.0.2" +react-qr-reader@3.0.0-beta-1: + version "3.0.0-beta-1" + resolved "https://registry.yarnpkg.com/react-qr-reader/-/react-qr-reader-3.0.0-beta-1.tgz#e04a20876409313439959d8e0ea6df3ba6e36d68" + integrity sha512-5HeFH9x/BlziRYQYGK2AeWS9WiKYZtGGMs9DXy3bcySTX3C9UJL9EwcPnWw8vlf7JP4FcrAlr1SnZ5nsWLQGyw== + dependencies: + "@zxing/browser" "0.0.7" + "@zxing/library" "^0.18.3" + rollup "^2.67.2" + react-redux@7.2.4: version "7.2.4" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.4.tgz#1ebb474032b72d806de2e0519cd07761e222e225" @@ -19321,6 +19339,13 @@ rlp@^2.0.0, rlp@^2.2.3, rlp@^2.2.4: dependencies: bn.js "^4.11.1" +rollup@^2.67.2: + version "2.68.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.68.0.tgz#6ccabfd649447f8f21d62bf41662e5caece3bd66" + integrity sha512-XrMKOYK7oQcTio4wyTz466mucnd8LzkiZLozZ4Rz0zQD+HeX4nUK4B8GrTX/2EvN2/vBF/i2WnaXboPxo0JylA== + optionalDependencies: + fsevents "~2.3.2" + rskjs-util@1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/rskjs-util/-/rskjs-util-1.0.3.tgz#c0242d9308d7dc1eb653ffbf0dd1af1309bf55ca" @@ -21416,6 +21441,11 @@ tryer@^1.0.1: resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA== +ts-custom-error@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ts-custom-error/-/ts-custom-error-3.2.0.tgz#ff8f80a3812bab9dc448536312da52dce1b720fb" + integrity sha512-cBvC2QjtvJ9JfWLvstVnI45Y46Y5dMxIaG1TDMGAD/R87hpvqFL+7LhvUDhnRCfOnx/xitollFWWvUKKKhbN0A== + ts-dedent@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.0.0.tgz#47c5eb23d9096f3237cc413bc82d387d36dbe690"