diff --git a/app/scripts/controllers/ens/ens.js b/app/scripts/controllers/ens/ens.js new file mode 100644 index 000000000..eb2586a7d --- /dev/null +++ b/app/scripts/controllers/ens/ens.js @@ -0,0 +1,25 @@ +const EthJsEns = require('ethjs-ens') +const ensNetworkMap = require('ethjs-ens/lib/network-map.json') + +class Ens { + static getNetworkEnsSupport (network) { + return Boolean(ensNetworkMap[network]) + } + + constructor ({ network, provider } = {}) { + this._ethJsEns = new EthJsEns({ + network, + provider, + }) + } + + lookup (ensName) { + return this._ethJsEns.lookup(ensName) + } + + reverse (address) { + return this._ethJsEns.reverse(address) + } +} + +module.exports = Ens diff --git a/app/scripts/controllers/ens/index.js b/app/scripts/controllers/ens/index.js new file mode 100644 index 000000000..6456f8b53 --- /dev/null +++ b/app/scripts/controllers/ens/index.js @@ -0,0 +1,75 @@ +const ethUtil = require('ethereumjs-util') +const ObservableStore = require('obs-store') +const punycode = require('punycode') +const Ens = require('./ens') + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' +const ZERO_X_ERROR_ADDRESS = '0x' + +class EnsController { + constructor ({ ens, provider, networkStore } = {}) { + const initState = { + ensResolutionsByAddress: {}, + } + + this._ens = ens + if (!this._ens) { + const network = networkStore.getState() + if (Ens.getNetworkEnsSupport(network)) { + this._ens = new Ens({ + network, + provider, + }) + } + } + + this.store = new ObservableStore(initState) + networkStore.subscribe((network) => { + this.store.putState(initState) + this._ens = new Ens({ + network, + provider, + }) + }) + } + + reverseResolveAddress (address) { + return this._reverseResolveAddress(ethUtil.toChecksumAddress(address)) + } + + async _reverseResolveAddress (address) { + if (!this._ens) { + return undefined + } + + const state = this.store.getState() + if (state.ensResolutionsByAddress[address]) { + return state.ensResolutionsByAddress[address] + } + + const domain = await this._ens.reverse(address) + const registeredAddress = await this._ens.lookup(domain) + if (registeredAddress === ZERO_ADDRESS || registeredAddress === ZERO_X_ERROR_ADDRESS) { + return undefined + } + + if (ethUtil.toChecksumAddress(registeredAddress) !== address) { + return undefined + } + + this._updateResolutionsByAddress(address, punycode.toASCII(domain)) + return domain + } + + _updateResolutionsByAddress (address, domain) { + const oldState = this.store.getState() + this.store.putState({ + ensResolutionsByAddress: { + ...oldState.ensResolutionsByAddress, + [address]: domain, + }, + }) + } +} + +module.exports = EnsController diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index eac6d1e81..1c607a4c6 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -23,6 +23,7 @@ const createLoggerMiddleware = require('./lib/createLoggerMiddleware') const providerAsMiddleware = require('eth-json-rpc-middleware/providerAsMiddleware') const {setupMultiplex} = require('./lib/stream-utils.js') const KeyringController = require('eth-keyring-controller') +const EnsController = require('./controllers/ens') const NetworkController = require('./controllers/network') const PreferencesController = require('./controllers/preferences') const AppStateController = require('./controllers/app-state') @@ -138,6 +139,11 @@ module.exports = class MetamaskController extends EventEmitter { networkController: this.networkController, }) + this.ensController = new EnsController({ + provider: this.provider, + networkStore: this.networkController.networkStore, + }) + this.incomingTransactionsController = new IncomingTransactionsController({ blockTracker: this.blockTracker, networkController: this.networkController, @@ -315,6 +321,8 @@ module.exports = class MetamaskController extends EventEmitter { // ThreeBoxController ThreeBoxController: this.threeBoxController.store, ABTestController: this.abTestController.store, + // ENS Controller + EnsController: this.ensController.store, }) this.memStore.subscribe(this.sendUpdate.bind(this)) } @@ -501,6 +509,9 @@ module.exports = class MetamaskController extends EventEmitter { // AppStateController setLastActiveTime: nodeify(this.appStateController.setLastActiveTime, this.appStateController), + // EnsController + tryReverseResolveAddress: nodeify(this.ensController.reverseResolveAddress, this.ensController), + // KeyringController setLocked: nodeify(this.setLocked, this), createNewVaultAndKeychain: nodeify(this.createNewVaultAndKeychain, this), diff --git a/package.json b/package.json index 3a8d5ea1a..5c155f5ea 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,7 @@ "prop-types": "^15.6.1", "pubnub": "4.24.4", "pump": "^3.0.0", + "punycode": "^2.1.1", "qrcode-generator": "1.4.1", "ramda": "^0.24.1", "react": "^15.6.2", diff --git a/test/unit/app/controllers/ens-controller-test.js b/test/unit/app/controllers/ens-controller-test.js new file mode 100644 index 000000000..1eb52a17c --- /dev/null +++ b/test/unit/app/controllers/ens-controller-test.js @@ -0,0 +1,135 @@ +const assert = require('assert') +const sinon = require('sinon') +const ObservableStore = require('obs-store') +const HttpProvider = require('ethjs-provider-http') +const EnsController = require('../../../../app/scripts/controllers/ens') + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' +const ZERO_X_ERROR_ADDRESS = '0x' + +describe('EnsController', function () { + describe('#constructor', function () { + it('should construct the controller given a provider and a network', async () => { + const provider = new HttpProvider('https://ropsten.infura.io') + const currentNetworkId = '3' + const networkStore = new ObservableStore(currentNetworkId) + const ens = new EnsController({ + provider, + networkStore, + }) + + assert.ok(ens._ens) + }) + + it('should construct the controller given an existing ENS instance', async () => { + const networkStore = { + subscribe: sinon.spy(), + } + const ens = new EnsController({ + ens: {}, + networkStore, + }) + + assert.ok(ens._ens) + }) + }) + + describe('#reverseResolveName', function () { + it('should resolve to an ENS name', async () => { + const address = '0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5' + const networkStore = { + subscribe: sinon.spy(), + } + const ens = new EnsController({ + ens: { + reverse: sinon.stub().withArgs(address).returns('peaksignal.eth'), + lookup: sinon.stub().withArgs('peaksignal.eth').returns(address), + }, + networkStore, + }) + + const name = await ens.reverseResolveAddress(address) + assert.equal(name, 'peaksignal.eth') + }) + + it('should only resolve an ENS name once', async () => { + const address = '0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5' + const reverse = sinon.stub().withArgs(address).returns('peaksignal.eth') + const lookup = sinon.stub().withArgs('peaksignal.eth').returns(address) + const networkStore = { + subscribe: sinon.spy(), + } + const ens = new EnsController({ + ens: { + reverse, + lookup, + }, + networkStore, + }) + + assert.equal(await ens.reverseResolveAddress(address), 'peaksignal.eth') + assert.equal(await ens.reverseResolveAddress(address), 'peaksignal.eth') + assert.ok(lookup.calledOnce) + assert.ok(reverse.calledOnce) + }) + + it('should fail if the name is registered to a different address than the reverse-resolved', async () => { + const address = '0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5' + const networkStore = { + subscribe: sinon.spy(), + } + const ens = new EnsController({ + ens: { + reverse: sinon.stub().withArgs(address).returns('peaksignal.eth'), + lookup: sinon.stub().withArgs('peaksignal.eth').returns('0xfoo'), + }, + networkStore, + }) + + const name = await ens.reverseResolveAddress(address) + assert.strictEqual(name, undefined) + }) + + it('should throw an error when the lookup resolves to the zero address', async () => { + const address = '0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5' + const networkStore = { + subscribe: sinon.spy(), + } + const ens = new EnsController({ + ens: { + reverse: sinon.stub().withArgs(address).returns('peaksignal.eth'), + lookup: sinon.stub().withArgs('peaksignal.eth').returns(ZERO_ADDRESS), + }, + networkStore, + }) + + try { + await ens.reverseResolveAddress(address) + assert.fail('#reverseResolveAddress did not throw') + } catch (e) { + assert.ok(e) + } + }) + + it('should throw an error the lookup resolves to the zero x address', async () => { + const address = '0x8e5d75d60224ea0c33d0041e75de68b1c3cb6dd5' + const networkStore = { + subscribe: sinon.spy(), + } + const ens = new EnsController({ + ens: { + reverse: sinon.stub().withArgs(address).returns('peaksignal.eth'), + lookup: sinon.stub().withArgs('peaksignal.eth').returns(ZERO_X_ERROR_ADDRESS), + }, + networkStore, + }) + + try { + await ens.reverseResolveAddress(address) + assert.fail('#reverseResolveAddress did not throw') + } catch (e) { + assert.ok(e) + } + }) + }) +}) diff --git a/ui/app/components/app/confirm-page-container/confirm-page-container.component.js b/ui/app/components/app/confirm-page-container/confirm-page-container.component.js index d26daf786..41d9d5952 100644 --- a/ui/app/components/app/confirm-page-container/confirm-page-container.component.js +++ b/ui/app/components/app/confirm-page-container/confirm-page-container.component.js @@ -24,6 +24,7 @@ export default class ConfirmPageContainer extends Component { fromName: PropTypes.string, toAddress: PropTypes.string, toName: PropTypes.string, + toEns: PropTypes.string, toNickname: PropTypes.string, // Content contentComponent: PropTypes.node, @@ -69,6 +70,7 @@ export default class ConfirmPageContainer extends Component { fromName, fromAddress, toName, + toEns, toNickname, toAddress, disabled, @@ -128,6 +130,7 @@ export default class ConfirmPageContainer extends Component { senderAddress={fromAddress} recipientName={toName} recipientAddress={toAddress} + recipientEns={toEns} recipientNickname={toNickname} assetImage={renderAssetImage ? assetImage : undefined} /> diff --git a/ui/app/components/app/transaction-list-item-details/index.js b/ui/app/components/app/transaction-list-item-details/index.js index 0e878d032..83bd53e7d 100644 --- a/ui/app/components/app/transaction-list-item-details/index.js +++ b/ui/app/components/app/transaction-list-item-details/index.js @@ -1 +1 @@ -export { default } from './transaction-list-item-details.component' +export { default } from './transaction-list-item-details.container' diff --git a/ui/app/components/app/transaction-list-item-details/transaction-list-item-details.component.js b/ui/app/components/app/transaction-list-item-details/transaction-list-item-details.component.js index 983bbf6e5..f27c74970 100644 --- a/ui/app/components/app/transaction-list-item-details/transaction-list-item-details.component.js +++ b/ui/app/components/app/transaction-list-item-details/transaction-list-item-details.component.js @@ -17,6 +17,10 @@ export default class TransactionListItemDetails extends PureComponent { metricsEvent: PropTypes.func, } + static defaultProps = { + recipientEns: null, + } + static propTypes = { onCancel: PropTypes.func, onRetry: PropTypes.func, @@ -26,7 +30,11 @@ export default class TransactionListItemDetails extends PureComponent { isEarliestNonce: PropTypes.bool, cancelDisabled: PropTypes.bool, transactionGroup: PropTypes.object, + recipientEns: PropTypes.string, + recipientAddress: PropTypes.string.isRequired, rpcPrefs: PropTypes.object, + senderAddress: PropTypes.string.isRequired, + tryReverseResolveAddress: PropTypes.func.isRequired, } state = { @@ -82,6 +90,12 @@ export default class TransactionListItemDetails extends PureComponent { }) } + async componentDidMount () { + const { recipientAddress, tryReverseResolveAddress } = this.props + + tryReverseResolveAddress(recipientAddress) + } + renderCancel () { const { t } = this.context const { @@ -128,11 +142,14 @@ export default class TransactionListItemDetails extends PureComponent { showRetry, onCancel, onRetry, + recipientEns, + recipientAddress, rpcPrefs: { blockExplorerUrl } = {}, + senderAddress, isEarliestNonce, } = this.props const { primaryTransaction: transaction } = transactionGroup - const { hash, txParams: { to, from } = {} } = transaction + const { hash } = transaction return (
@@ -192,8 +209,9 @@ export default class TransactionListItemDetails extends PureComponent { { this.context.metricsEvent({ eventOpts: { diff --git a/ui/app/components/app/transaction-list-item-details/transaction-list-item-details.container.js b/ui/app/components/app/transaction-list-item-details/transaction-list-item-details.container.js new file mode 100644 index 000000000..50f93f497 --- /dev/null +++ b/ui/app/components/app/transaction-list-item-details/transaction-list-item-details.container.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux' +import TransactionListItemDetails from './transaction-list-item-details.component' +import { checksumAddress } from '../../../helpers/utils/util' +import { tryReverseResolveAddress } from '../../../store/actions' + +const mapStateToProps = (state, ownProps) => { + const { metamask } = state + const { + ensResolutionsByAddress, + } = metamask + const { recipientAddress } = ownProps + const address = checksumAddress(recipientAddress) + const recipientEns = ensResolutionsByAddress[address] || '' + + return { + recipientEns, + } +} + +const mapDispatchToProps = (dispatch) => { + return { + tryReverseResolveAddress: (address) => { + return dispatch(tryReverseResolveAddress(address)) + }, + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(TransactionListItemDetails) diff --git a/ui/app/components/app/transaction-list-item/transaction-list-item.component.js b/ui/app/components/app/transaction-list-item/transaction-list-item.component.js index d1d6f061d..12350ada6 100644 --- a/ui/app/components/app/transaction-list-item/transaction-list-item.component.js +++ b/ui/app/components/app/transaction-list-item/transaction-list-item.component.js @@ -191,6 +191,7 @@ export default class TransactionListItem extends PureComponent { } = this.props const { txParams = {} } = transaction const { showTransactionDetails } = this.state + const fromAddress = txParams.from const toAddress = tokenData ? tokenData.params && tokenData.params[0] && tokenData.params[0].value || txParams.to : txParams.to @@ -253,6 +254,8 @@ export default class TransactionListItem extends PureComponent { showCancel={showCancel} cancelDisabled={!hasEnoughCancelGas} rpcPrefs={rpcPrefs} + senderAddress={fromAddress} + recipientAddress={toAddress} />
) diff --git a/ui/app/components/ui/sender-to-recipient/sender-to-recipient.component.js b/ui/app/components/ui/sender-to-recipient/sender-to-recipient.component.js index c8e7a1870..3102f17e3 100644 --- a/ui/app/components/ui/sender-to-recipient/sender-to-recipient.component.js +++ b/ui/app/components/ui/sender-to-recipient/sender-to-recipient.component.js @@ -5,7 +5,7 @@ import Identicon from '../identicon' import Tooltip from '../tooltip-v2' import copyToClipboard from 'copy-to-clipboard' import { DEFAULT_VARIANT, CARDS_VARIANT, FLAT_VARIANT } from './sender-to-recipient.constants' -import { checksumAddress } from '../../../helpers/utils/util' +import { checksumAddress, addressSlicer } from '../../../helpers/utils/util' const variantHash = { [DEFAULT_VARIANT]: 'sender-to-recipient--default', @@ -18,6 +18,7 @@ export default class SenderToRecipient extends PureComponent { senderName: PropTypes.string, senderAddress: PropTypes.string, recipientName: PropTypes.string, + recipientEns: PropTypes.string, recipientAddress: PropTypes.string, recipientNickname: PropTypes.string, t: PropTypes.func, @@ -60,14 +61,28 @@ export default class SenderToRecipient extends PureComponent { return ( {t('copiedExclamation')}

+ : addressOnly + ?

{t('copyAddress')}

+ : ( +

+ {addressSlicer(checksummedSenderAddress)}
+ {t('copyAddress')} +

+ ) + } wrapperClassName="sender-to-recipient__tooltip-wrapper" containerClassName="sender-to-recipient__tooltip-container" onHidden={() => this.setState({ senderAddressCopied: false })} >
- { addressOnly ? `${t('from')}: ` : '' } - { addressOnly ? checksummedSenderAddress : senderName } + { + addressOnly + ? {`${t('from')}: ${checksummedSenderAddress}`} + : senderName + }
) @@ -90,7 +105,7 @@ export default class SenderToRecipient extends PureComponent { renderRecipientWithAddress () { const { t } = this.context - const { recipientName, recipientAddress, recipientNickname, addressOnly, onRecipientClick } = this.props + const { recipientEns, recipientName, recipientAddress, recipientNickname, addressOnly, onRecipientClick } = this.props const checksummedRecipientAddress = checksumAddress(recipientAddress) return ( @@ -107,7 +122,18 @@ export default class SenderToRecipient extends PureComponent { { this.renderRecipientIdenticon() } {t('copiedExclamation')}

+ : (addressOnly && !recipientNickname && !recipientEns) + ?

{t('copyAddress')}

+ : ( +

+ {addressSlicer(checksummedRecipientAddress)}
+ {t('copyAddress')} +

+ ) + } wrapperClassName="sender-to-recipient__tooltip-wrapper" containerClassName="sender-to-recipient__tooltip-container" onHidden={() => this.setState({ recipientAddressCopied: false })} @@ -116,8 +142,8 @@ export default class SenderToRecipient extends PureComponent { { addressOnly ? `${t('to')}: ` : '' } { addressOnly - ? checksummedRecipientAddress - : (recipientNickname || recipientName || this.context.t('newContract')) + ? (recipientNickname || recipientEns || checksummedRecipientAddress) + : (recipientNickname || recipientEns || recipientName || this.context.t('newContract')) }
diff --git a/ui/app/components/ui/tooltip-v2.js b/ui/app/components/ui/tooltip-v2.js index b54026794..8d63e1515 100644 --- a/ui/app/components/ui/tooltip-v2.js +++ b/ui/app/components/ui/tooltip-v2.js @@ -8,6 +8,7 @@ export default class Tooltip extends PureComponent { children: null, containerClassName: '', hideOnClick: false, + html: null, onHidden: null, position: 'left', size: 'small', @@ -21,6 +22,7 @@ export default class Tooltip extends PureComponent { children: PropTypes.node, containerClassName: PropTypes.string, disabled: PropTypes.bool, + html: PropTypes.node, onHidden: PropTypes.func, position: PropTypes.oneOf([ 'top', @@ -38,9 +40,9 @@ export default class Tooltip extends PureComponent { } render () { - const {arrow, children, containerClassName, disabled, position, size, title, trigger, onHidden, wrapperClassName, style } = this.props + const {arrow, children, containerClassName, disabled, position, html, size, title, trigger, onHidden, wrapperClassName, style } = this.props - if (!title) { + if (!title && !html) { return (
{children} @@ -51,6 +53,7 @@ export default class Tooltip extends PureComponent { return (
{ const isMainnet = getIsMainnet(state) const { confirmTransaction, metamask } = state const { + ensResolutionsByAddress, conversionRate, identities, addressBook, @@ -93,7 +95,9 @@ const mapStateToProps = (state, ownProps) => { : addressSlicer(checksumAddress(toAddress)) ) - const addressBookObject = addressBook[checksumAddress(toAddress)] + const checksummedAddress = checksumAddress(toAddress) + const addressBookObject = addressBook[checksummedAddress] + const toEns = ensResolutionsByAddress[checksummedAddress] || '' const toNickname = addressBookObject ? addressBookObject.name : '' const isTxReprice = Boolean(lastGasPrice) const transactionStatus = transaction ? transaction.status : '' @@ -134,6 +138,7 @@ const mapStateToProps = (state, ownProps) => { fromAddress, fromName, toAddress, + toEns, toName, toNickname, ethTransactionAmount, @@ -176,6 +181,9 @@ const mapStateToProps = (state, ownProps) => { export const mapDispatchToProps = dispatch => { return { + tryReverseResolveAddress: (address) => { + return dispatch(tryReverseResolveAddress(address)) + }, updateCustomNonce: value => { customNonceValue = value dispatch(updateCustomNonce(value)) diff --git a/ui/app/store/actions.js b/ui/app/store/actions.js index 59bad34bf..f76024590 100644 --- a/ui/app/store/actions.js +++ b/ui/app/store/actions.js @@ -392,6 +392,8 @@ var actions = { setShowRestorePromptToFalse, turnThreeBoxSyncingOn, turnThreeBoxSyncingOnAndInitialize, + + tryReverseResolveAddress, } module.exports = actions @@ -599,6 +601,19 @@ function requestRevealSeedWords (password) { } } +function tryReverseResolveAddress (address) { + return () => { + return new Promise((resolve) => { + background.tryReverseResolveAddress(address, (err) => { + if (err) { + log.error(err) + } + resolve() + }) + }) + } +} + function fetchInfoToSync () { return dispatch => { log.debug(`background.fetchInfoToSync`) diff --git a/yarn.lock b/yarn.lock index d752122bd..774d204e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21366,7 +21366,7 @@ punycode@^1.2.4, punycode@^1.3.2, punycode@^1.4.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= -punycode@^2.1.0: +punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==