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"
}
}