diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index 2fc658019..6d51f83ab 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -301,6 +301,15 @@
"confirmed": {
"message": "Confirmed"
},
+ "confusableUnicode": {
+ "message": "'$1' is similar to '$2'."
+ },
+ "confusableZeroWidthUnicode": {
+ "message": "Zero-width character found."
+ },
+ "confusingEnsDomain": {
+ "message": "We have detected a confusable character in the ENS name. Check the ENS name to avoid a potential scam."
+ },
"congratulations": {
"message": "Congratulations"
},
diff --git a/package.json b/package.json
index fb998dfff..dfc829560 100644
--- a/package.json
+++ b/package.json
@@ -177,6 +177,7 @@
"single-call-balance-checker-abi": "^1.0.0",
"swappable-obj-proxy": "^1.1.0",
"textarea-caret": "^3.0.1",
+ "unicode-confusables": "^0.1.1",
"valid-url": "^1.0.9",
"web3": "^0.20.7",
"web3-stream-provider": "^4.0.0"
diff --git a/ui/app/components/ui/confusable/confusable.component.js b/ui/app/components/ui/confusable/confusable.component.js
new file mode 100644
index 000000000..6e1ac6b40
--- /dev/null
+++ b/ui/app/components/ui/confusable/confusable.component.js
@@ -0,0 +1,39 @@
+import React, { useMemo } from 'react';
+import PropTypes from 'prop-types';
+import { confusables } from 'unicode-confusables';
+import Tooltip from '../tooltip';
+import { useI18nContext } from '../../../hooks/useI18nContext';
+
+const Confusable = ({ input }) => {
+ const t = useI18nContext();
+ const confusableData = useMemo(() => {
+ return confusables(input);
+ }, [input]);
+
+ return confusableData.map(({ point, similarTo }, index) => {
+ const zeroWidth = similarTo === '';
+ if (similarTo === undefined) {
+ return point;
+ }
+ return (
+
+ {zeroWidth ? '?' : point}
+
+ );
+ });
+};
+
+Confusable.propTypes = {
+ input: PropTypes.string.isRequired,
+};
+
+export default Confusable;
diff --git a/ui/app/components/ui/confusable/index.js b/ui/app/components/ui/confusable/index.js
new file mode 100644
index 000000000..e117d0df6
--- /dev/null
+++ b/ui/app/components/ui/confusable/index.js
@@ -0,0 +1 @@
+export { default } from './confusable.component';
diff --git a/ui/app/components/ui/confusable/index.scss b/ui/app/components/ui/confusable/index.scss
new file mode 100644
index 000000000..1178eb55d
--- /dev/null
+++ b/ui/app/components/ui/confusable/index.scss
@@ -0,0 +1,5 @@
+.confusable {
+ &__point {
+ color: $Red-500;
+ }
+}
diff --git a/ui/app/components/ui/confusable/test/confusable.component.test.js b/ui/app/components/ui/confusable/test/confusable.component.test.js
new file mode 100644
index 000000000..d3166ccd7
--- /dev/null
+++ b/ui/app/components/ui/confusable/test/confusable.component.test.js
@@ -0,0 +1,26 @@
+import assert from 'assert';
+import React from 'react';
+import { shallow } from 'enzyme';
+import Confusable from '../confusable.component';
+
+describe('Confusable component', function () {
+ it('should detect zero-width unicode', function () {
+ const wrapper = shallow();
+ assert.ok(wrapper.find('.confusable__point').length === 1);
+ });
+
+ it('should detect homoglyphic unicode points', function () {
+ const wrapper = shallow();
+ assert.ok(wrapper.find('.confusable__point').length === 1);
+ });
+
+ it('should detect multiple homoglyphic unicode points', function () {
+ const wrapper = shallow();
+ assert.ok(wrapper.find('.confusable__point').length === 5);
+ });
+
+ it('should not detect emoji', function () {
+ const wrapper = shallow();
+ assert.ok(wrapper.find('.confusable__point').length === 0);
+ });
+});
diff --git a/ui/app/components/ui/tooltip/tooltip.js b/ui/app/components/ui/tooltip/tooltip.js
index 9ae60d2a5..19d244737 100644
--- a/ui/app/components/ui/tooltip/tooltip.js
+++ b/ui/app/components/ui/tooltip/tooltip.js
@@ -17,6 +17,7 @@ export default class Tooltip extends PureComponent {
trigger: 'mouseenter focus',
wrapperClassName: undefined,
theme: '',
+ tag: 'div',
};
static propTypes = {
@@ -36,6 +37,7 @@ export default class Tooltip extends PureComponent {
style: PropTypes.object,
theme: PropTypes.string,
tabIndex: PropTypes.number,
+ tag: PropTypes.string,
};
render() {
@@ -56,34 +58,36 @@ export default class Tooltip extends PureComponent {
style,
theme,
tabIndex,
+ tag,
} = this.props;
if (!title && !html) {
return
{children}
;
}
- return (
-
-
- {children}
-
-
+ return React.createElement(
+ tag,
+ { className: wrapperClassName },
+
+ {children}
+ ,
);
}
}
diff --git a/ui/app/components/ui/ui-components.scss b/ui/app/components/ui/ui-components.scss
index cc8effe24..96d0c684a 100644
--- a/ui/app/components/ui/ui-components.scss
+++ b/ui/app/components/ui/ui-components.scss
@@ -12,6 +12,7 @@
@import 'chip/chip';
@import 'circle-icon/index';
@import 'color-indicator/color-indicator';
+@import 'confusable/index';
@import 'currency-display/index';
@import 'currency-input/index';
@import 'definition-list/definition-list';
diff --git a/ui/app/pages/send/send-content/add-recipient/add-recipient.component.js b/ui/app/pages/send/send-content/add-recipient/add-recipient.component.js
index a2126b552..a13a4a666 100644
--- a/ui/app/pages/send/send-content/add-recipient/add-recipient.component.js
+++ b/ui/app/pages/send/send-content/add-recipient/add-recipient.component.js
@@ -8,6 +8,7 @@ import ContactList from '../../../../components/app/contact-list';
import RecipientGroup from '../../../../components/app/contact-list/recipient-group/recipient-group.component';
import { ellipsify } from '../../send.utils';
import Button from '../../../../components/ui/button';
+import Confusable from '../../../../components/ui/confusable';
export default class AddRecipient extends Component {
static propTypes = {
@@ -128,7 +129,7 @@ export default class AddRecipient extends Component {
- {name || ellipsify(address)}
+ {name ? : ellipsify(address)}
{name && (
diff --git a/ui/app/pages/send/send-content/add-recipient/add-recipient.js b/ui/app/pages/send/send-content/add-recipient/add-recipient.js
index 9a423e974..195da89cb 100644
--- a/ui/app/pages/send/send-content/add-recipient/add-recipient.js
+++ b/ui/app/pages/send/send-content/add-recipient/add-recipient.js
@@ -1,16 +1,19 @@
import ethUtil from 'ethereumjs-util';
import contractMap from '@metamask/contract-metadata';
+import { isConfusing } from 'unicode-confusables';
import {
REQUIRED_ERROR,
INVALID_RECIPIENT_ADDRESS_ERROR,
KNOWN_RECIPIENT_ADDRESS_ERROR,
INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR,
+ CONFUSING_ENS_ERROR,
} from '../../send.constants';
import {
isValidAddress,
isEthNetwork,
checkExistingAddresses,
+ isValidDomainName,
} from '../../../../helpers/utils/util';
export function getToErrorObject(to, hasHexData = false, network) {
@@ -36,6 +39,9 @@ export function getToWarningObject(to, tokens = [], sendToken = null) {
checkExistingAddresses(to, tokens))
) {
toWarning = KNOWN_RECIPIENT_ADDRESS_ERROR;
+ } else if (isValidDomainName(to) && isConfusing(to)) {
+ toWarning = CONFUSING_ENS_ERROR;
}
+
return { to: toWarning };
}
diff --git a/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-utils.test.js b/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-utils.test.js
index 9d4947107..cdf1a0c36 100644
--- a/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-utils.test.js
+++ b/ui/app/pages/send/send-content/add-recipient/tests/add-recipient-utils.test.js
@@ -6,6 +6,7 @@ import {
REQUIRED_ERROR,
INVALID_RECIPIENT_ADDRESS_ERROR,
KNOWN_RECIPIENT_ADDRESS_ERROR,
+ CONFUSING_ENS_ERROR,
} from '../../../send.constants';
const stubs = {
@@ -93,5 +94,17 @@ describe('add-recipient utils', function () {
},
);
});
+
+ it('should warn if name is a valid domain and confusable', function () {
+ assert.deepEqual(getToWarningObject('vitalik.eth'), {
+ to: CONFUSING_ENS_ERROR,
+ });
+ });
+
+ it('should not warn if name is a valid domain and not confusable', function () {
+ assert.deepEqual(getToWarningObject('vitalik.eth'), {
+ to: null,
+ });
+ });
});
});
diff --git a/ui/app/pages/send/send.constants.js b/ui/app/pages/send/send.constants.js
index 7b1a1f1a3..5584a6c99 100644
--- a/ui/app/pages/send/send.constants.js
+++ b/ui/app/pages/send/send.constants.js
@@ -35,6 +35,7 @@ const INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR =
'invalidAddressRecipientNotEthNetwork';
const REQUIRED_ERROR = 'required';
const KNOWN_RECIPIENT_ADDRESS_ERROR = 'knownAddressRecipient';
+const CONFUSING_ENS_ERROR = 'confusingEnsDomain';
const SIMPLE_GAS_COST = '0x5208'; // Hex for 21000, cost of a simple send.
const BASE_TOKEN_GAS_COST = '0x186a0'; // Hex for 100000, a base estimate for token transfers.
@@ -53,6 +54,7 @@ export {
MIN_GAS_TOTAL,
NEGATIVE_ETH_ERROR,
REQUIRED_ERROR,
+ CONFUSING_ENS_ERROR,
SIMPLE_GAS_COST,
TOKEN_TRANSFER_FUNCTION_SIGNATURE,
BASE_TOKEN_GAS_COST,
diff --git a/yarn.lock b/yarn.lock
index 95a33254a..1f7a9175e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -24436,6 +24436,11 @@ unicode-canonical-property-names-ecmascript@^1.0.4:
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
integrity sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==
+unicode-confusables@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/unicode-confusables/-/unicode-confusables-0.1.1.tgz#17f14e8dc53ff81c12e92fd86e836ebdf14ea0c2"
+ integrity sha512-XTPBWmT88BDpXz9NycWk4KxDn+/AJmJYYaYBwuIH9119sopwk2E9GxU9azc+JNbhEsfiPul78DGocEihCp6MFQ==
+
unicode-match-property-ecmascript@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz#8ed2a32569961bce9227d09cd3ffbb8fed5f020c"