1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-23 01:39:44 +01:00

ENS Reverse Resolution support (#7177)

* ENS Reverse Resolution support
* Save punycode for ENS domains with Unicode characters
* Update SenderToRecipient recipientEns tooltip
* Use cached results when reverse-resolving ENS names
* Display ENS names in tx activity log
This commit is contained in:
Whymarrh Whitby 2019-11-01 15:24:00 -02:30 committed by GitHub
parent f9cd775eae
commit eed4a9ed65
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 373 additions and 17 deletions

View File

@ -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

View File

@ -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

View File

@ -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),

View File

@ -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",

View File

@ -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)
}
})
})
})

View File

@ -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}
/>

View File

@ -1 +1 @@
export { default } from './transaction-list-item-details.component'
export { default } from './transaction-list-item-details.container'

View File

@ -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 (
<div className="transaction-list-item-details">
@ -192,8 +209,9 @@ export default class TransactionListItemDetails extends PureComponent {
<SenderToRecipient
variant={FLAT_VARIANT}
addressOnly
recipientAddress={to}
senderAddress={from}
recipientEns={recipientEns}
recipientAddress={recipientAddress}
senderAddress={senderAddress}
onRecipientClick={() => {
this.context.metricsEvent({
eventOpts: {

View File

@ -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)

View File

@ -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}
/>
</div>
)

View File

@ -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 (
<Tooltip
position="bottom"
title={this.state.senderAddressCopied ? t('copiedExclamation') : t('copyAddress')}
html={
this.state.senderAddressCopied
? <p>{t('copiedExclamation')}</p>
: addressOnly
? <p>{t('copyAddress')}</p>
: (
<p>
{addressSlicer(checksummedSenderAddress)}<br/>
{t('copyAddress')}
</p>
)
}
wrapperClassName="sender-to-recipient__tooltip-wrapper"
containerClassName="sender-to-recipient__tooltip-container"
onHidden={() => this.setState({ senderAddressCopied: false })}
>
<div className="sender-to-recipient__name">
<span>{ addressOnly ? `${t('from')}: ` : '' }</span>
{ addressOnly ? checksummedSenderAddress : senderName }
{
addressOnly
? <span>{`${t('from')}: ${checksummedSenderAddress}`}</span>
: senderName
}
</div>
</Tooltip>
)
@ -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() }
<Tooltip
position="bottom"
title={this.state.recipientAddressCopied ? t('copiedExclamation') : t('copyAddress')}
html={
this.state.senderAddressCopied
? <p>{t('copiedExclamation')}</p>
: (addressOnly && !recipientNickname && !recipientEns)
? <p>{t('copyAddress')}</p>
: (
<p>
{addressSlicer(checksummedRecipientAddress)}<br/>
{t('copyAddress')}
</p>
)
}
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 {
<span>{ addressOnly ? `${t('to')}: ` : '' }</span>
{
addressOnly
? checksummedRecipientAddress
: (recipientNickname || recipientName || this.context.t('newContract'))
? (recipientNickname || recipientEns || checksummedRecipientAddress)
: (recipientNickname || recipientEns || recipientName || this.context.t('newContract'))
}
</div>
</Tooltip>

View File

@ -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 (
<div className={wrapperClassName}>
{children}
@ -51,6 +53,7 @@ export default class Tooltip extends PureComponent {
return (
<div className={wrapperClassName}>
<ReactTippy
html={html}
className={containerClassName}
disabled={disabled}
title={title}

View File

@ -64,6 +64,7 @@ export default class ConfirmTransactionBase extends Component {
tokenData: PropTypes.object,
tokenProps: PropTypes.object,
toName: PropTypes.string,
toEns: PropTypes.string,
toNickname: PropTypes.string,
transactionStatus: PropTypes.string,
txData: PropTypes.object,
@ -103,6 +104,7 @@ export default class ConfirmTransactionBase extends Component {
transactionCategory: PropTypes.string,
getNextNonce: PropTypes.func,
nextNonce: PropTypes.number,
tryReverseResolveAddress: PropTypes.func.isRequired,
}
state = {
@ -567,7 +569,7 @@ export default class ConfirmTransactionBase extends Component {
}
componentDidMount () {
const { txData: { origin, id } = {}, cancelTransaction, getNextNonce } = this.props
const { toAddress, txData: { origin, id } = {}, cancelTransaction, getNextNonce, tryReverseResolveAddress } = this.props
const { metricsEvent } = this.context
metricsEvent({
eventOpts: {
@ -598,6 +600,7 @@ export default class ConfirmTransactionBase extends Component {
}
getNextNonce()
tryReverseResolveAddress(toAddress)
}
componentWillUnmount () {
@ -613,6 +616,7 @@ export default class ConfirmTransactionBase extends Component {
fromAddress,
toName,
toAddress,
toEns,
toNickname,
methodData,
valid: propsValid = true,
@ -643,6 +647,7 @@ export default class ConfirmTransactionBase extends Component {
fromAddress={fromAddress}
toName={toName}
toAddress={toAddress}
toEns={toEns}
toNickname={toNickname}
showEdit={onEdit && !isTxReprice}
// In the event that the key is falsy (and inherently invalid), use a fallback string

View File

@ -18,6 +18,7 @@ import {
setMetaMetricsSendCount,
updateTransaction,
getNextNonce,
tryReverseResolveAddress,
} from '../../store/actions'
import {
INSUFFICIENT_FUNDS_ERROR_KEY,
@ -51,6 +52,7 @@ const mapStateToProps = (state, ownProps) => {
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))

View File

@ -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`)

View File

@ -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==