diff --git a/app/components/UI/ApproveTransactionReview/AddNickNameHeader/index.tsx b/app/components/UI/ApproveTransactionReview/AddNickNameHeader/index.tsx new file mode 100644 index 00000000000..57e1b8b9c76 --- /dev/null +++ b/app/components/UI/ApproveTransactionReview/AddNickNameHeader/index.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { View } from 'react-native'; +import Text from '../../../Base/Text'; +import { strings } from '../../../../../locales/i18n'; +import AntDesignIcon from 'react-native-vector-icons/AntDesign'; + +interface HeaderProps { + onUpdateContractNickname: () => void; + nicknameExists: boolean; + headerWrapperStyle?: any; + headerTextStyle?: any; + iconStyle?: any; +} + +const Header = (props: HeaderProps) => { + const { onUpdateContractNickname, nicknameExists, headerWrapperStyle, headerTextStyle, iconStyle } = props; + return ( + + + {nicknameExists ? strings('nickname.edit_nickname') : strings('nickname.add_nickname')} + + onUpdateContractNickname()} /> + + ); +}; + +export default Header; diff --git a/app/components/UI/ApproveTransactionReview/AddNickname/index.tsx b/app/components/UI/ApproveTransactionReview/AddNickname/index.tsx new file mode 100644 index 00000000000..36a81d2e01a --- /dev/null +++ b/app/components/UI/ApproveTransactionReview/AddNickname/index.tsx @@ -0,0 +1,256 @@ +import React, { useState } from 'react'; +import { SafeAreaView, View, StyleSheet, TextInput, TouchableOpacity } from 'react-native'; +import AntDesignIcon from 'react-native-vector-icons/AntDesign'; +import { colors, fontStyles } from '../../../../styles/common'; +import EthereumAddress from '../../EthereumAddress'; +import Engine from '../../../../core/Engine'; +import AnalyticsV2 from '../../../../util/analyticsV2'; +import { toChecksumAddress } from 'ethereumjs-util'; +import { connect } from 'react-redux'; +import StyledButton from '../../StyledButton'; +import Text from '../../../Base/Text'; +import InfoModal from '../../Swaps/components/InfoModal'; +import { showSimpleNotification } from '../../../../actions/notification'; +import Identicon from '../../../UI/Identicon'; +import Feather from 'react-native-vector-icons/Feather'; +import { strings } from '../../../../../locales/i18n'; +import GlobalAlert from '../../../UI/GlobalAlert'; +import { showAlert } from '../../../../actions/alert'; +import ClipboardManager from '../../../../core/ClipboardManager'; +import Header from '../AddNickNameHeader'; +import ShowBlockExplorer from '../ShowBlockExplorer'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.white, + }, + headerWrapper: { + position: 'relative', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginHorizontal: 15, + marginVertical: 5, + paddingVertical: 10, + }, + icon: { + position: 'absolute', + right: 0, + padding: 10, + }, + headerText: { + color: colors.black, + textAlign: 'center', + fontSize: 15, + }, + addressWrapperPrimary: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 10, + }, + addressWrapper: { + backgroundColor: colors.blue100, + flexDirection: 'row', + alignItems: 'center', + borderRadius: 40, + paddingVertical: 10, + paddingHorizontal: 15, + width: '90%', + }, + address: { + fontSize: 12, + color: colors.grey400, + letterSpacing: 0.8, + marginLeft: 10, + }, + label: { + fontSize: 14, + paddingVertical: 12, + color: colors.fontPrimary, + }, + input: { + ...fontStyles.normal, + fontSize: 12, + borderColor: colors.grey200, + borderRadius: 5, + borderWidth: 2, + padding: 10, + flexDirection: 'row', + alignItems: 'center', + }, + bodyWrapper: { + marginHorizontal: 20, + marginBottom: 'auto', + }, + updateButton: { + marginHorizontal: 20, + }, + addressIdenticon: { + alignItems: 'center', + marginVertical: 10, + }, + actionIcon: { + color: colors.blue, + }, +}); + +interface AddNicknameProps { + onUpdateContractNickname: () => void; + contractAddress: string; + network: number; + nicknameExists: boolean; + nickname: string; + addressBook: []; + showModalAlert: (config: any) => void; + networkState: any; + type: string; +} + +const getAnalyticsParams = () => { + try { + const { NetworkController } = Engine.context as any; + const { type } = NetworkController?.state?.provider || {}; + return { + network_name: type, + }; + } catch (error) { + return {}; + } +}; + +const AddNickname = (props: AddNicknameProps) => { + const { + onUpdateContractNickname, + contractAddress, + nicknameExists, + nickname, + showModalAlert, + networkState: { + network, + provider: { type }, + }, + } = props; + + const [newNickname, setNewNickname] = useState(nickname); + const [isBlockExplorerVisible, setIsBlockExplorerVisible] = useState(false); + const [showFullAddress, setShowFullAddress] = useState(false); + + const copyContractAddress = async () => { + await ClipboardManager.setString(contractAddress); + showModalAlert({ + isVisible: true, + autodismiss: 1500, + content: 'clipboard-alert', + data: { msg: strings('transactions.address_copied_to_clipboard') }, + }); + + AnalyticsV2.trackEvent(AnalyticsV2.ANALYTICS_EVENTS.CONTRACT_ADDRESS_COPIED, getAnalyticsParams()); + }; + + const saveTokenNickname = () => { + const { AddressBookController } = Engine.context; + if (!newNickname || !contractAddress) return; + AddressBookController.set(toChecksumAddress(contractAddress), newNickname, network); + onUpdateContractNickname(); + AnalyticsV2.trackEvent(AnalyticsV2.ANALYTICS_EVENTS.CONTRACT_ADDRESS_NICKNAME, getAnalyticsParams()); + }; + + const showFullAddressModal = () => { + setShowFullAddress(!showFullAddress); + }; + + const toggleBlockExplorer = () => setIsBlockExplorerVisible(true); + + return ( + + {isBlockExplorerVisible ? ( + + ) : ( + <> +
+ + {showFullAddress && ( + + )} + + + + {strings('nickname.address')} + + + + + + + + {strings('nickname.name')} + + + + + {strings('nickname.save_nickname')} + + + + + )} + + ); +}; + +const mapStateToProps = (state: any) => ({ + addressBook: state.engine.backgroundState.AddressBookController.addressBook, + networkState: state.engine.backgroundState.NetworkController, +}); + +const mapDispatchToProps = (dispatch: any) => ({ + showModalAlert: (config) => dispatch(showAlert(config)), + showSimpleNotification: (notification: Notification) => dispatch(showSimpleNotification(notification)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(AddNickname); diff --git a/app/components/UI/ApproveTransactionReview/ShowBlockExplorer/index.tsx b/app/components/UI/ApproveTransactionReview/ShowBlockExplorer/index.tsx new file mode 100644 index 00000000000..608a35f3e02 --- /dev/null +++ b/app/components/UI/ApproveTransactionReview/ShowBlockExplorer/index.tsx @@ -0,0 +1,65 @@ +import React, { useState } from 'react'; +import { View, StyleSheet } from 'react-native'; +import WebviewProgressBar from '../../../UI/WebviewProgressBar'; +import { getEtherscanAddressUrl, getEtherscanBaseUrl } from '../../../../util/etherscan'; +import { WebView } from 'react-native-webview'; +import Text from '../../../Base/Text'; +import AntDesignIcon from 'react-native-vector-icons/AntDesign'; + +const styles = StyleSheet.create({ + progressBarWrapper: { + height: 3, + width: '100%', + left: 0, + right: 0, + bottom: 0, + position: 'absolute', + zIndex: 999999, + }, +}); + +interface ShowBlockExplorerProps { + contractAddress: string; + type: string; + setIsBlockExplorerVisible: (isBlockExplorerVisible: boolean) => void; + headerWrapperStyle?: any; + headerTextStyle?: any; + iconStyle?: any; +} + +const ShowBlockExplorer = (props: ShowBlockExplorerProps) => { + const { type, contractAddress, setIsBlockExplorerVisible, headerWrapperStyle, headerTextStyle, iconStyle } = props; + const [loading, setLoading] = useState(0); + const url = getEtherscanAddressUrl(type, contractAddress); + const etherscan_url = getEtherscanBaseUrl(type).replace('https://', ''); + + const onLoadProgress = ({ nativeEvent: { progress } }: { nativeEvent: { progress: number } }) => { + setLoading(progress); + }; + + const renderProgressBar = () => ( + + + + ); + + return ( + <> + + + {etherscan_url} + + setIsBlockExplorerVisible(false)} + /> + + + {renderProgressBar()} + + ); +}; + +export default ShowBlockExplorer; diff --git a/app/components/UI/ApproveTransactionReview/index.js b/app/components/UI/ApproveTransactionReview/index.js index dbb6b661960..ebc11bab97c 100644 --- a/app/components/UI/ApproveTransactionReview/index.js +++ b/app/components/UI/ApproveTransactionReview/index.js @@ -12,6 +12,7 @@ import { strings } from '../../../../locales/i18n'; import { setTransactionObject } from '../../../actions/transaction'; import { GAS_ESTIMATE_TYPES, util } from '@metamask/controllers'; import { renderFromWei, weiToFiatNumber, fromTokenMinimalUnit, toTokenMinimalUnit } from '../../../util/number'; +import EthereumAddress from '../EthereumAddress'; import { getTicker, getNormalizedTxState, @@ -20,6 +21,8 @@ import { decodeApproveData, generateApproveData, } from '../../../util/transactions'; +import Feather from 'react-native-vector-icons/Feather'; +import Identicon from '../../UI/Identicon'; import { showAlert } from '../../../actions/alert'; import Analytics from '../../../core/Analytics'; import { ANALYTICS_EVENT_OPTS } from '../../../util/analytics'; @@ -55,7 +58,7 @@ const styles = StyleSheet.create({ textAlign: 'center', color: colors.black, lineHeight: 34, - marginVertical: 16, + marginVertical: 8, paddingHorizontal: 16, }, explanation: { @@ -72,7 +75,7 @@ const styles = StyleSheet.create({ fontSize: 12, lineHeight: 20, textAlign: 'center', - marginVertical: 20, + marginVertical: 10, borderWidth: 1, borderRadius: 20, borderColor: colors.blue, @@ -91,6 +94,21 @@ const styles = StyleSheet.create({ flexDirection: 'column', alignItems: 'center', }, + addressWrapper: { + backgroundColor: colors.blue000, + flexDirection: 'row', + alignItems: 'center', + borderRadius: 40, + paddingHorizontal: 10, + paddingVertical: 5, + }, + address: { + fontSize: 13, + marginHorizontal: 8, + color: colors.blue700, + ...fontStyles.normal, + maxWidth: 120, + }, errorWrapper: { marginTop: 12, paddingHorizontal: 10, @@ -114,14 +132,26 @@ const styles = StyleSheet.create({ ...fontStyles.bold, }, actionViewWrapper: { - height: Device.isMediumDevice() ? 200 : 350, - }, - actionViewChildren: { - height: 300, + height: Device.isMediumDevice() ? 200 : 280, }, paddingHorizontal: { paddingHorizontal: 16, }, + contactWrapper: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginVertical: 15, + }, + nickname: { + ...fontStyles.normal, + textAlign: 'center', + color: colors.blue500, + marginBottom: 10, + }, + actionIcon: { + color: colors.blue, + }, }); const { ORIGIN_DEEPLINK, ORIGIN_QR_CODE } = AppConstants.DEEPLINKS; @@ -249,6 +279,18 @@ class ApproveTransactionReview extends PureComponent { * Dispatch set transaction object from transaction action */ setTransactionObject: PropTypes.func, + /** + * Update contract nickname + */ + onUpdateContractNickname: PropTypes.func, + /** + * The saved nickname of the address + */ + nickname: PropTypes.string, + /** + * Check if nickname is saved + */ + nicknameExists: PropTypes.bool, }; state = { @@ -419,6 +461,7 @@ class ApproveTransactionReview extends PureComponent { content: 'clipboard-alert', data: { msg: strings('transactions.address_copied_to_clipboard') }, }); + AnalyticsV2.trackEvent(AnalyticsV2.ANALYTICS_EVENTS.CONTRACT_ADDRESS_COPIED, this.getAnalyticsParams()); }; edit = () => { @@ -518,6 +561,10 @@ class ApproveTransactionReview extends PureComponent { ); }; + toggleDisplay = () => { + this.props.onUpdateContractNickname(); + }; + renderDetails = () => { const { host, tokenSymbol, spenderAddress } = this.state; @@ -553,7 +600,7 @@ class ApproveTransactionReview extends PureComponent { return ( <> - + @@ -563,11 +610,41 @@ class ApproveTransactionReview extends PureComponent { { tokenSymbol } )} + + + {strings('spend_limit_edition.edit_permission')} + + {`${strings( `spend_limit_edition.${originIsDeeplink ? 'you_trust_this_address' : 'you_trust_this_site'}` )}`} + + {strings('nickname.contract')}: + + + {this.props.nicknameExists ? ( + + {this.props.nickname} + + ) : ( + + )} + + + + + {this.props.nicknameExists ? 'Edit' : 'Add'} {strings('nickname.nickname')} + - - - {strings('spend_limit_edition.edit_permission')} - - @@ -667,6 +739,7 @@ class ApproveTransactionReview extends PureComponent { }; renderTransactionReview = () => { + const { nickname, nicknameExists } = this.props; const { host, method, @@ -685,6 +758,8 @@ class ApproveTransactionReview extends PureComponent { toggleViewDetails={this.toggleViewDetails} toggleViewData={this.toggleViewData} copyContractAddress={this.copyContractAddress} + nickname={nickname} + nicknameExists={nicknameExists} address={renderShortAddress(to)} host={host} allowance={allowance} diff --git a/app/components/UI/EthereumAddress/index.js b/app/components/UI/EthereumAddress/index.js index 6f6f7783119..70d8db22bb1 100644 --- a/app/components/UI/EthereumAddress/index.js +++ b/app/components/UI/EthereumAddress/index.js @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { Text } from 'react-native'; -import { renderShortAddress, renderFullAddress } from '../../../util/address'; +import { renderShortAddress, renderFullAddress, renderSlightlyLongAddress } from '../../../util/address'; import { isValidAddress } from 'ethereumjs-util'; /** @@ -42,6 +42,8 @@ class EthereumAddress extends PureComponent { if (isValidAddress(rawAddress)) { if (type && type === 'short') { formattedAddress = renderShortAddress(rawAddress); + } else if (type && type === 'mid') { + formattedAddress = renderSlightlyLongAddress(rawAddress); } else { formattedAddress = renderFullAddress(rawAddress); } diff --git a/app/components/UI/Swaps/components/InfoModal.js b/app/components/UI/Swaps/components/InfoModal.js index 66d160a331d..1e3844166ad 100644 --- a/app/components/UI/Swaps/components/InfoModal.js +++ b/app/components/UI/Swaps/components/InfoModal.js @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { StyleSheet, View, TouchableOpacity, SafeAreaView } from 'react-native'; import Modal from 'react-native-modal'; +import Text from '../../../Base/Text'; import IonicIcon from 'react-native-vector-icons/Ionicons'; import Title from '../../../Base/Title'; @@ -48,7 +49,7 @@ const styles = StyleSheet.create({ }, }); -function InfoModal({ title, body, isVisible, toggleModal, propagateSwipe }) { +function InfoModal({ title, body, isVisible, toggleModal, message, propagateSwipe }) { return ( - {title} - - - + {title && {title}} + {message && {message}} + {!message && ( + + + + )} {body} @@ -77,6 +81,7 @@ InfoModal.propTypes = { body: PropTypes.node, toggleModal: PropTypes.func, propagateSwipe: PropTypes.bool, + message: PropTypes.string, }; export default InfoModal; diff --git a/app/components/UI/TransactionReview/TransactionReviewDetailsCard/index.js b/app/components/UI/TransactionReview/TransactionReviewDetailsCard/index.js index f6bc29f945d..04f2ac3962d 100644 --- a/app/components/UI/TransactionReview/TransactionReviewDetailsCard/index.js +++ b/app/components/UI/TransactionReview/TransactionReviewDetailsCard/index.js @@ -73,6 +73,8 @@ const styles = StyleSheet.create({ address: { ...fontStyles.bold, color: colors.blue, + marginHorizontal: 8, + maxWidth: 120, }, }); @@ -88,6 +90,8 @@ export default class TransactionReviewDetailsCard extends Component { data: PropTypes.string, displayViewData: PropTypes.bool, method: PropTypes.string, + nickname: PropTypes.string, + nicknameExists: PropTypes.bool, }; render() { @@ -102,6 +106,8 @@ export default class TransactionReviewDetailsCard extends Component { data, method, displayViewData, + nickname, + nicknameExists, } = this.props; return ( @@ -117,7 +123,13 @@ export default class TransactionReviewDetailsCard extends Component { {strings('spend_limit_edition.contract_address')} - {address} + {nicknameExists ? ( + + {nickname} + + ) : ( + {address} + )} { @@ -234,6 +249,10 @@ class Approve extends PureComponent { } }; + onUpdateContractNickname = () => { + this.setState({ addNickname: !this.state.addNickname }); + }; + startPolling = async () => { const { GasFeeController } = Engine.context; const pollToken = await GasFeeController.getGasFeeEstimatesAndStartPolling(this.state.pollToken); @@ -533,7 +552,10 @@ class Approve extends PureComponent { isAnimating, transactionConfirmed, } = this.state; - const { transaction, gasEstimateType, gasFeeEstimates, primaryCurrency, chainId } = this.props; + const { transaction, addressBook, network, gasEstimateType, gasFeeEstimates, primaryCurrency, chainId } = + this.props; + + const addressData = checkIfAddressIsSaved(addressBook, network, transaction); if (!transaction.id) return null; return ( @@ -541,7 +563,7 @@ class Approve extends PureComponent { isVisible={this.props.modalVisible} animationIn="slideInUp" animationOut="slideOutDown" - style={styles.bottomModal} + style={this.state.addNickname ? styles.updateNickView : styles.bottomModal} backdropOpacity={0.7} animationInTiming={600} animationOutTiming={600} @@ -551,84 +573,101 @@ class Approve extends PureComponent { swipeDirection={'down'} propagateSwipe > - - {mode === 'review' && ( - - - {/** View fixes layout issue after removing */} - - - )} - - {mode !== 'review' && - (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET ? ( - - ) : ( - - ))} - + {this.state.addNickname ? ( + 0 ? addressData[0].nickname : ''} + /> + ) : ( + + {mode === 'review' && ( + + 0 ? addressData[0].nickname : ''} + /> + {/** View fixes layout issue after removing */} + + + )} + + {mode !== 'review' && + (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET ? ( + + ) : ( + + ))} + + )} + ); }; @@ -649,6 +688,8 @@ const mapStateToProps = (state) => ({ nativeCurrency: state.engine.backgroundState.CurrencyRateController.nativeCurrency, conversionRate: state.engine.backgroundState.CurrencyRateController.conversionRate, networkType: state.engine.backgroundState.NetworkController.provider.type, + addressBook: state.engine.backgroundState.AddressBookController.addressBook, + network: state.engine.backgroundState.NetworkController.network, }); const mapDispatchToProps = (dispatch) => ({ diff --git a/app/components/Views/ChoosePassword/index.js b/app/components/Views/ChoosePassword/index.js index c50527838ef..9dc4d5c49bf 100644 --- a/app/components/Views/ChoosePassword/index.js +++ b/app/components/Views/ChoosePassword/index.js @@ -535,6 +535,7 @@ class ChoosePassword extends PureComponent { style={styles.biometrySwitch} trackColor={Device.isIos() ? { true: colors.green300, false: colors.grey300 } : null} ios_backgroundColor={colors.grey300} + testID={'remember-me-toggle'} /> )} diff --git a/app/components/Views/SendFlow/AddressList/index.js b/app/components/Views/SendFlow/AddressList/index.js index 7fb55afb91f..822c5f50ccf 100644 --- a/app/components/Views/SendFlow/AddressList/index.js +++ b/app/components/Views/SendFlow/AddressList/index.js @@ -1,9 +1,10 @@ import React, { PureComponent } from 'react'; -import { StyleSheet, View, Text, TouchableOpacity, ScrollView } from 'react-native'; +import { StyleSheet, View, Text, TouchableOpacity, ScrollView, ActivityIndicator } from 'react-native'; import { colors, fontStyles } from '../../../../styles/common'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import Fuse from 'fuse.js'; +import { isSmartContractAddress } from '../../../../util/transactions'; import { strings } from '../../../../../locales/i18n'; import AddressElement from '../AddressElement'; @@ -33,6 +34,7 @@ const styles = StyleSheet.create({ backgroundColor: colors.grey000, flexDirection: 'row', alignItems: 'center', + justifyContent: 'space-between', borderBottomWidth: 1, borderBottomColor: colors.grey050, padding: 8, @@ -48,12 +50,12 @@ const styles = StyleSheet.create({ }, }); -const LabelElement = (label) => ( +const LabelElement = (label, checkingForSmartContracts, showLoading) => ( 1 ? {} : styles.labelElementInitialText]}>{label} + {showLoading && checkingForSmartContracts && } ); - /** * View that wraps the wraps the "Send" screen */ @@ -98,6 +100,7 @@ class AddressList extends PureComponent { myAccountsOpened: false, processedAddressBookList: undefined, contactElements: [], + checkingForSmartContracts: false, }; networkAddressBook = {}; @@ -149,12 +152,32 @@ class AddressList extends PureComponent { parseAddressBook = (networkAddressBookList) => { const contactElements = []; const addressBookTree = {}; + networkAddressBookList.forEach((contact) => { + this.setState({ checkingForSmartContracts: true }); + + isSmartContractAddress(contact.address, contact.chainId) + .then((isSmartContract) => { + if (isSmartContract) { + contact.isSmartContract = true; + return this.setState({ checkingForSmartContracts: false }); + } + + contact.isSmartContract = false; + return this.setState({ checkingForSmartContracts: false }); + }) + .catch(() => { + contact.isSmartContract = false; + }); + }); + networkAddressBookList.forEach((contact) => { const contactNameInitial = contact && contact.name && contact.name[0]; const nameInitial = contactNameInitial && contactNameInitial.match(/[a-z]/i); const initial = nameInitial ? nameInitial[0] : strings('address_book.others'); if (Object.keys(addressBookTree).includes(initial)) { addressBookTree[initial].push(contact); + } else if (contact.isSmartContract && !this.props.onlyRenderAddressBook) { + null; } else { addressBookTree[initial] = [contact]; } @@ -200,10 +223,13 @@ class AddressList extends PureComponent { renderElement = (element) => { const { onAccountPress, onAccountLongPress } = this.props; + if (typeof element === 'string') { return LabelElement(element); } + const key = element.address + element.name; + return ( ); }; @@ -224,7 +251,7 @@ class AddressList extends PureComponent { if (!recents.length || inputSearch) return; return ( <> - {LabelElement(strings('address_book.recents'))} + {LabelElement(strings('address_book.recents'), this.state.checkingForSmartContracts, 'showLoading')} {recents .filter((recent) => recent != null) .map((address, index) => ( @@ -243,12 +270,29 @@ class AddressList extends PureComponent { render = () => { const { contactElements } = this.state; const { onlyRenderAddressBook } = this.props; + const sendFlowContacts = []; + + contactElements.filter((element) => { + if (typeof element === 'object' && element.isSmartContract === false) { + const nameInitial = element && element.name && element.name[0]; + if (sendFlowContacts.includes(nameInitial)) { + sendFlowContacts.push(element); + } else { + sendFlowContacts.push(nameInitial); + sendFlowContacts.push(element); + } + } + return element; + }); + return ( {!onlyRenderAddressBook && this.renderMyAccounts()} {!onlyRenderAddressBook && this.renderRecents()} - {contactElements.length ? contactElements.map(this.renderElement) : null} + {!onlyRenderAddressBook + ? sendFlowContacts.map(this.renderElement) + : contactElements.map(this.renderElement)} ); diff --git a/app/util/address/index.js b/app/util/address/index.js index 76f519d46cd..ee7146e9fb2 100644 --- a/app/util/address/index.js +++ b/app/util/address/index.js @@ -28,6 +28,12 @@ export function renderShortAddress(address, chars = 4) { return `${checksummedAddress.substr(0, chars + 2)}...${checksummedAddress.substr(-chars)}`; } +export function renderSlightlyLongAddress(address, chars = 4) { + if (!address) return address; + const checksummedAddress = toChecksumAddress(address); + return `${checksummedAddress.substr(0, chars + 20)}...${checksummedAddress.substr(-chars)}`; +} + /** * Returns address name if it's in known identities * diff --git a/app/util/analyticsV2.js b/app/util/analyticsV2.js index 95f595f12f7..812b4961b06 100644 --- a/app/util/analyticsV2.js +++ b/app/util/analyticsV2.js @@ -17,6 +17,8 @@ export const ANALYTICS_EVENTS_V2 = { DAPP_TRANSACTION_STARTED: generateOpt('Dapp Transaction Started'), DAPP_TRANSACTION_COMPLETED: generateOpt('Dapp Transaction Completed'), DAPP_TRANSACTION_CANCELLED: generateOpt('Dapp Transaction Cancelled'), + CONTRACT_ADDRESS_COPIED: generateOpt('Contract Address Copied'), + CONTRACT_ADDRESS_NICKNAME: generateOpt('Contract Address Nickname'), // Sign request SIGN_REQUEST_STARTED: generateOpt('Sign Request Started'), SIGN_REQUEST_COMPLETED: generateOpt('Sign Request Completed'), diff --git a/app/util/checkAddress.tsx b/app/util/checkAddress.tsx new file mode 100644 index 00000000000..989b278d5ec --- /dev/null +++ b/app/util/checkAddress.tsx @@ -0,0 +1,20 @@ +import { toChecksumAddress } from 'ethereumjs-util'; + +const checkIfAddressIsSaved = (addressBook: [], network: string, transaction: any) => { + if (transaction.to === undefined) { + return []; + } + for (const [key, value] of Object.entries(addressBook)) { + const addressValues = Object.values(value).map((val: any) => ({ + address: toChecksumAddress(val.address), + nickname: val.name, + })); + + if (addressValues.some((x) => x.address === toChecksumAddress(transaction.to) && key === network)) { + return addressValues.filter((x) => x.address === toChecksumAddress(transaction.to)); + } + return []; + } +}; + +export default checkIfAddressIsSaved; diff --git a/e2e/pages/ContractNickNameView.js b/e2e/pages/ContractNickNameView.js new file mode 100644 index 00000000000..2d4c977d050 --- /dev/null +++ b/e2e/pages/ContractNickNameView.js @@ -0,0 +1,36 @@ +import TestHelpers from '../helpers'; + +const CONTRACT_ADD_NICKNAME_CONTAINER_ID = 'contract-nickname-view'; +const CONTRACT_ADD_NICKNAME_INPUT_BOX_ID = 'contract-name-input'; +const CONFIRM_BUTTON_ID = 'nickname.save_nickname'; + +export default class ContractNickNameView { + static async typeContractNickName(nickName) { + if (device.getPlatform() === 'android') { + await TestHelpers.replaceTextInField(CONTRACT_ADD_NICKNAME_INPUT_BOX_ID, nickName); + await element(by.id(CONTRACT_ADD_NICKNAME_INPUT_BOX_ID)).tapReturnKey(); + } else { + await TestHelpers.typeTextAndHideKeyboard(CONTRACT_ADD_NICKNAME_INPUT_BOX_ID, nickName); + } + } + + static async clearNickName() { + await TestHelpers.replaceTextInField(CONTRACT_ADD_NICKNAME_INPUT_BOX_ID, ''); + } + + static async tapConfirmButton() { + await TestHelpers.waitAndTap(CONFIRM_BUTTON_ID); + } + + static async isVisible() { + await TestHelpers.checkIfVisible(CONTRACT_ADD_NICKNAME_CONTAINER_ID); + } + + static async isNotVisible() { + await TestHelpers.checkIfNotVisible(CONTRACT_ADD_NICKNAME_CONTAINER_ID); + } + + static async isContractNickNameInInputBoxVisible(nickName) { + await TestHelpers.checkIfElementWithTextIsVisible(nickName); + } +} diff --git a/e2e/pages/Onboarding/CreatePasswordView.js b/e2e/pages/Onboarding/CreatePasswordView.js index 27ed463dacf..ae5418421df 100644 --- a/e2e/pages/Onboarding/CreatePasswordView.js +++ b/e2e/pages/Onboarding/CreatePasswordView.js @@ -6,7 +6,13 @@ const CONFIRM_PASSWORD_INPUT_BOX_ID = 'input-password-confirm'; const IOS_I_UNDERSTAND_BUTTON_ID = 'password-understand-box'; const ANDROID_I_UNDERSTAND_BUTTON_ID = 'i-understand-text'; const CREATE_PASSWORD_BUTTON_ID = 'submit-button'; +const REMEMBER_ME_ID = 'remember-me-toggle'; + export default class CreatePasswordView { + static async toggleRememberMe() { + await TestHelpers.tap(REMEMBER_ME_ID); + } + static async enterPassword(password) { await TestHelpers.typeTextAndHideKeyboard(CREATE_PASSWORD_INPUT_BOX_ID, password); } diff --git a/e2e/pages/SendView.js b/e2e/pages/SendView.js index aa50f210a00..c4704ad416d 100644 --- a/e2e/pages/SendView.js +++ b/e2e/pages/SendView.js @@ -25,6 +25,9 @@ export default class SendView { } } + static async tapAndLongPress() { + await TestHelpers.tapAndLongPress(ADDRESS_INPUT_BOX_ID); + } static async tapAddAddressToAddressBook() { await TestHelpers.waitAndTap(ADD_TO_ADDRESS_BOOK_BUTTON_ID); } @@ -53,4 +56,7 @@ export default class SendView { static async isSavedAliasVisible(name) { await TestHelpers.checkIfElementWithTextIsVisible(name); } + static async isSavedAliasIsNotVisible(name) { + await TestHelpers.checkIfElementWithTextIsNotVisible(name); + } } diff --git a/e2e/pages/modals/ApprovalModal.js b/e2e/pages/modals/ApprovalModal.js new file mode 100644 index 00000000000..cecc1189eef --- /dev/null +++ b/e2e/pages/modals/ApprovalModal.js @@ -0,0 +1,33 @@ +import TestHelpers from '../../helpers'; + +const APPROVAL_MODAL_CONTAINER_ID = 'approve-modal-test-id'; +const COPY_CONTRACT_ADDRESS_ID = 'contract-address'; + +export default class ApprovalModal { + static async tapAddNickName() { + await TestHelpers.tapByText('Add nickname'); + } + static async tapEditNickName() { + await TestHelpers.tapByText('Edit nickname'); + } + + static async tapRejectButton() { + await TestHelpers.tapByText('Reject'); + } + static async tapApproveButton() { + await TestHelpers.tapByText('Approve'); + } + static async tapToCopyContractAddress() { + await TestHelpers.tap(COPY_CONTRACT_ADDRESS_ID); + } + static async isVisible() { + await TestHelpers.checkIfVisible(APPROVAL_MODAL_CONTAINER_ID); + } + static async isNotVisible() { + await TestHelpers.checkIfNotVisible(APPROVAL_MODAL_CONTAINER_ID); + } + + static async isContractNickNameVisible(nickName) { + await TestHelpers.checkIfElementWithTextIsVisible(nickName); + } +} diff --git a/e2e/specs/contract-nickname.spec.js b/e2e/specs/contract-nickname.spec.js new file mode 100644 index 00000000000..50a969da160 --- /dev/null +++ b/e2e/specs/contract-nickname.spec.js @@ -0,0 +1,158 @@ +'use strict'; +import ImportWalletView from '../pages/Onboarding/ImportWalletView'; +import OnboardingView from '../pages/Onboarding/OnboardingView'; +import OnboardingCarouselView from '../pages/Onboarding/OnboardingCarouselView'; + +import ContractNickNameView from '../pages/ContractNickNameView'; +import SendView from '../pages/SendView'; + +import DrawerView from '../pages/Drawer/DrawerView'; +import MetaMetricsOptIn from '../pages/Onboarding/MetaMetricsOptInView'; +import WalletView from '../pages/WalletView'; + +import AddContactView from '../pages/Drawer/Settings/Contacts/AddContactView'; +import ContactsView from '../pages/Drawer/Settings/Contacts/ContactsView'; +import SettingsView from '../pages/Drawer/Settings/SettingsView'; + +import ApprovalModal from '../pages/modals/ApprovalModal'; +import NetworkListModal from '../pages/modals/NetworkListModal'; +import OnboardingWizardModal from '../pages/modals/OnboardingWizardModal'; + +import TestHelpers from '../helpers'; + +const SECRET_RECOVERY_PHRASE = 'fold media south add since false relax immense pause cloth just raven'; +const PASSWORD = `12345678`; +const RINKEBY = 'Rinkeby Test Network'; +const APPROVAL_DEEPLINK_URL = + 'https://metamask.app.link/send/0x01BE23585060835E02B77ef475b0Cc51aA1e0709@4/approve?address=0x178e3e6c9f547A00E33150F7104427ea02cfc747&uint256=5e8'; +const CONTRACT_NICK_NAME_TEXT = 'Ace RoMaIn'; + +describe('Adding Contract Nickname', () => { + beforeEach(() => { + jest.setTimeout(150000); + }); + + it('should import via seed phrase and validate in settings', async () => { + await OnboardingCarouselView.isVisible(); + await OnboardingCarouselView.tapOnGetStartedButton(); + + await OnboardingView.isVisible(); + await OnboardingView.tapImportWalletFromSeedPhrase(); + + await MetaMetricsOptIn.isVisible(); + await MetaMetricsOptIn.tapAgreeButton(); + + await ImportWalletView.isVisible(); + }); + + it('should attempt to import wallet with invalid secret recovery phrase', async () => { + await ImportWalletView.toggleRememberMe(); + await ImportWalletView.enterSecretRecoveryPhrase(SECRET_RECOVERY_PHRASE); + await ImportWalletView.enterPassword(PASSWORD); + await ImportWalletView.reEnterPassword(PASSWORD); + await WalletView.isVisible(); + }); + + it('should dismiss the onboarding wizard', async () => { + // dealing with flakiness on bitrise. + await TestHelpers.delay(1000); + try { + await OnboardingWizardModal.isVisible(); + await OnboardingWizardModal.tapNoThanksButton(); + await OnboardingWizardModal.isNotVisible(); + } catch { + // + } + }); + + it('should switch to rinkeby', async () => { + await WalletView.tapNetworksButtonOnNavBar(); + await NetworkListModal.changeNetwork(RINKEBY); + + await WalletView.isNetworkNameVisible(RINKEBY); + }); + + it('should deep link to the approval modal', async () => { + await TestHelpers.openDeepLink(APPROVAL_DEEPLINK_URL); + await TestHelpers.delay(3000); + await ApprovalModal.isVisible(); + }); + it('should add a nickname to the contract', async () => { + await ApprovalModal.tapAddNickName(); + + await ContractNickNameView.isVisible(); + await ContractNickNameView.typeContractNickName(CONTRACT_NICK_NAME_TEXT); + await ContractNickNameView.isContractNickNameInInputBoxVisible(CONTRACT_NICK_NAME_TEXT); + await ContractNickNameView.tapConfirmButton(); + + await ApprovalModal.isContractNickNameVisible(CONTRACT_NICK_NAME_TEXT); + }); + + it('should edit the contract nickname', async () => { + await ApprovalModal.tapEditNickName(); + + await ContractNickNameView.isContractNickNameInInputBoxVisible(CONTRACT_NICK_NAME_TEXT); + await ContractNickNameView.clearNickName(); + await ContractNickNameView.typeContractNickName('Ace'); + await ContractNickNameView.tapConfirmButton(); + + await ApprovalModal.isContractNickNameVisible('Ace'); + await ApprovalModal.tapToCopyContractAddress(); + await ApprovalModal.tapRejectButton(); + }); + + it('should verify contract does not appear in contacts view', async () => { + // Check that we are on the wallet screen + await WalletView.isVisible(); + await WalletView.tapDrawerButton(); + + await DrawerView.isVisible(); + await DrawerView.tapSettings(); + + await SettingsView.tapContacts(); + + await ContactsView.isVisible(); + await ContactsView.isContactAliasVisible('Ace'); + }); + + it('should return to the send view', async () => { + // Open Drawer + await AddContactView.tapBackButton(); + await SettingsView.tapCloseButton(); + + await WalletView.tapDrawerButton(); + + await DrawerView.isVisible(); + await DrawerView.tapSendButton(); + // Make sure view with my accounts visible + await SendView.isTransferBetweenMyAccountsButtonVisible(); + }); + + it('should verify the contract nickname does not appear in send flow', async () => { + await SendView.isSavedAliasIsNotVisible('Ace'); + }); + + it('should deep link to the approval modal and approve transaction', async () => { + await TestHelpers.openDeepLink(APPROVAL_DEEPLINK_URL); + await TestHelpers.delay(3000); + await ApprovalModal.isVisible(); + await ApprovalModal.isContractNickNameVisible('Ace'); + + await ApprovalModal.tapApproveButton(); + await ApprovalModal.isNotVisible(); + }); + + it('should go to the send view again', async () => { + // Open Drawer + await WalletView.tapDrawerButton(); + + await DrawerView.isVisible(); + await DrawerView.tapSendButton(); + // Make sure view with my accounts visible + await SendView.isTransferBetweenMyAccountsButtonVisible(); + }); + + it('should verify the contract nickname does not appear in recents', async () => { + await SendView.isSavedAliasIsNotVisible('Ace'); + }); +}); diff --git a/e2e/specs/deeplinks.spec.js b/e2e/specs/deeplinks.spec.js index 97b88e13431..2e97bb48d7b 100644 --- a/e2e/specs/deeplinks.spec.js +++ b/e2e/specs/deeplinks.spec.js @@ -3,18 +3,19 @@ import TestHelpers from '../helpers'; import OnboardingView from '../pages/Onboarding/OnboardingView'; import OnboardingCarouselView from '../pages/Onboarding/OnboardingCarouselView'; -import OnboardingWizardModal from '../pages/modals/OnboardingWizardModal'; +import MetaMetricsOptIn from '../pages/Onboarding/MetaMetricsOptInView'; import ImportWalletView from '../pages/Onboarding/ImportWalletView'; -import { Browser } from '../pages/Drawer/Browser'; -import TransactionConfirmationView from '../pages/TransactionConfirmView'; -import MetaMetricsOptIn from '../pages/Onboarding/MetaMetricsOptInView'; +import OnboardingWizardModal from '../pages/modals/OnboardingWizardModal'; +import ConnectModal from '../pages/modals/ConnectModal'; + +import { Browser } from '../pages/Drawer/Browser'; import DrawerView from '../pages/Drawer/DrawerView'; -import SettingsView from '../pages/Drawer/Settings/SettingsView'; -import WalletView from '../pages/WalletView'; import NetworkView from '../pages/Drawer/Settings/NetworksView'; +import SettingsView from '../pages/Drawer/Settings/SettingsView'; -import ConnectModal from '../pages/modals/ConnectModal'; +import TransactionConfirmationView from '../pages/TransactionConfirmView'; +import WalletView from '../pages/WalletView'; const SECRET_RECOVERY_PHRASE = 'fold media south add since false relax immense pause cloth just raven'; const PASSWORD = `12345678`; @@ -22,15 +23,15 @@ const PASSWORD = `12345678`; const BINANCE_RPC_URL = 'https://bsc-dataseed1.binance.org'; const POLYGON_RPC_URL = 'https://polygon-rpc.com/'; -const binanceDeepLink = 'https://metamask.app.link/send/0xB8B4EE5B1b693971eB60bDa15211570df2dB228A@56?value=1e14'; +const BINANCE_DEEPLINK_URL = 'https://metamask.app.link/send/0xB8B4EE5B1b693971eB60bDa15211570df2dB228A@56?value=1e14'; -const polygonDeepLink = +const POLYGON_DEEPLINK_URL = 'https://metamask.app.link/send/0x0000000000000000000000000000000000001010@137/transfer?address=0xC5b2b5ae370876c0122910F92a13bef85A133E56&uint256=3e18'; -const ethereumDeepLink = 'https://metamask.app.link/send/0x1FDb169Ef12954F20A15852980e1F0C122BfC1D6@1?value=1e13'; -const rinkebyDeepLink = 'https://metamask.app.link/send/0x1FDb169Ef12954F20A15852980e1F0C122BfC1D6@4?value=1e13'; +const ETHEREUM_DEEPLINK_URL = 'https://metamask.app.link/send/0x1FDb169Ef12954F20A15852980e1F0C122BfC1D6@1?value=1e13'; +const RINKEBY_DEEPLINK_URL = 'https://metamask.app.link/send/0x1FDb169Ef12954F20A15852980e1F0C122BfC1D6@4?value=1e13'; -const dAppDeepLink = 'https://metamask.app.link/dapp/app.uniswap.org'; +const DAPP_DEEPLINK_URL = 'https://metamask.app.link/dapp/app.uniswap.org'; describe('Deep linking Tests', () => { beforeEach(() => { @@ -70,13 +71,12 @@ describe('Deep linking Tests', () => { // } }); - - it('should attempt to deep link to the send flow with a custom network not added to wallet', async () => { + it('should deep link to the send flow with a custom network not added to wallet', async () => { const networkNotFoundText = 'Network not found'; const networkErrorBodyMessage = 'Network with chain id 56 not found in your wallet. Please add the network first.'; - await TestHelpers.openDeepLink(binanceDeepLink); + await TestHelpers.openDeepLink(BINANCE_DEEPLINK_URL); await TestHelpers.delay(3000); await TestHelpers.checkIfElementWithTextIsVisible(networkNotFoundText); await TestHelpers.checkIfElementWithTextIsVisible(networkErrorBodyMessage); @@ -144,7 +144,7 @@ describe('Deep linking Tests', () => { }); it('should deep link to the send flow on matic', async () => { - await TestHelpers.openDeepLink(polygonDeepLink); + await TestHelpers.openDeepLink(POLYGON_DEEPLINK_URL); await TestHelpers.delay(4500); await TransactionConfirmationView.isVisible(); @@ -153,14 +153,14 @@ describe('Deep linking Tests', () => { await TransactionConfirmationView.tapCancelButton(); }); it('should deep link to the send flow on BSC', async () => { - await TestHelpers.openDeepLink(binanceDeepLink); + await TestHelpers.openDeepLink(BINANCE_DEEPLINK_URL); await TestHelpers.delay(4500); await TransactionConfirmationView.isVisible(); await TransactionConfirmationView.isNetworkNameVisible('Binance Smart Chain Mainnet'); }); it('should deep link to the send flow on Rinkeby and submit the transaction', async () => { - await TestHelpers.openDeepLink(rinkebyDeepLink); + await TestHelpers.openDeepLink(RINKEBY_DEEPLINK_URL); await TestHelpers.delay(4500); await TransactionConfirmationView.isVisible(); await TransactionConfirmationView.isNetworkNameVisible('Rinkeby Test Network'); @@ -172,7 +172,7 @@ describe('Deep linking Tests', () => { }); it('should deep link to the send flow on mainnet', async () => { - await TestHelpers.openDeepLink(ethereumDeepLink); + await TestHelpers.openDeepLink(ETHEREUM_DEEPLINK_URL); await TestHelpers.delay(4500); await TransactionConfirmationView.isVisible(); @@ -180,7 +180,7 @@ describe('Deep linking Tests', () => { }); it('should deep link to a dapp (Uniswap)', async () => { - await TestHelpers.openDeepLink(dAppDeepLink); + await TestHelpers.openDeepLink(DAPP_DEEPLINK_URL); await TestHelpers.delay(4500); await ConnectModal.isVisible(); diff --git a/locales/languages/en.json b/locales/languages/en.json index d702d6fea6c..f1b559ac7db 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -783,9 +783,9 @@ "minimum": "1.00 {{tokenSymbol}} minimum", "cancel": "Cancel", "approve": "Approve", - "allow_to_access": "Give this site permission to access your {{tokenSymbol}}?", + "allow_to_access": "Give permission to access your {{tokenSymbol}}?", "allow_to_address_access": "Give this address access your {{tokenSymbol}}?", - "you_trust_this_site": "Do you trust this site? By granting this permission, you're allowing this site to access your funds.", + "you_trust_this_site": "By granting permission, you're allowing the following contract to access your funds.", "you_trust_this_address": "Do you trust this address? By granting this permission, you're allowing this address to access your funds.", "edit_permission": "Edit permission", "edit": "Edit", @@ -1822,5 +1822,15 @@ "help_description_1": "We're here to help! Check out the FAQs below or ", "help_description_2": "contact support", "help_description_3": " for help!" + }, + "nickname": { + "add_nickname": "Add nickname", + "edit_nickname": "Edit nickname", + "save_nickname": "Confirm", + "address": "Contract Address", + "name": "Contract Name", + "name_placeholder": "Add a nickname to this address", + "contract": "Contract", + "nickname": "nickname" } }