Merge pull request #4606 from MetaMask/WatchTokenFeature

Add metamask_watchAsset
feature/default_network_editable
Bruno Barbieri 6 years ago committed by GitHub
commit 4560df6e73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      CHANGELOG.md
  2. 3
      app/_locales/en/messages.json
  3. 20
      app/scripts/background.js
  4. 116
      app/scripts/controllers/preferences.js
  5. 3
      app/scripts/metamask-controller.js
  6. 5
      old-ui/app/account-detail.js
  7. 202
      old-ui/app/add-suggested-token.js
  8. 6
      old-ui/app/app.js
  9. 12252
      package-lock.json
  10. 110
      test/unit/app/controllers/preferences-controller-test.js
  11. 47
      ui/app/actions.js
  12. 3
      ui/app/app.js
  13. 8
      ui/app/components/balance-component.js
  14. 37
      ui/app/components/identicon.js
  15. 5
      ui/app/components/modals/hide-token-confirmation-modal.js
  16. 126
      ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js
  17. 29
      ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js
  18. 2
      ui/app/components/pages/confirm-add-suggested-token/index.js
  19. 13
      ui/app/components/pages/home/home.component.js
  20. 2
      ui/app/components/pages/home/home.container.js
  21. 3
      ui/app/components/token-cell.js
  22. 9
      ui/app/components/token-list.js
  23. 9
      ui/app/reducers/app.js
  24. 2
      ui/app/routes.js

@ -2,6 +2,8 @@
## Current Develop Branch ## Current Develop Branch
- (#4606)[https://github.com/MetaMask/metamask-extension/pull/4606]: Add new metamask_watchAsset method.
## 4.9.3 Wed Aug 15 2018 ## 4.9.3 Wed Aug 15 2018
- (#4897)[https://github.com/MetaMask/metamask-extension/pull/4897]: QR code scan for recipient addresses. - (#4897)[https://github.com/MetaMask/metamask-extension/pull/4897]: QR code scan for recipient addresses.

@ -29,6 +29,9 @@
"addTokens": { "addTokens": {
"message": "Add Tokens" "message": "Add Tokens"
}, },
"addSuggestedTokens": {
"message": "Add Suggested Tokens"
},
"addAcquiredTokens": { "addAcquiredTokens": {
"message": "Add the tokens you've acquired using MetaMask" "message": "Add the tokens you've acquired using MetaMask"
}, },

@ -256,6 +256,7 @@ function setupController (initState, initLangCode) {
showUnconfirmedMessage: triggerUi, showUnconfirmedMessage: triggerUi,
unlockAccountMessage: triggerUi, unlockAccountMessage: triggerUi,
showUnapprovedTx: triggerUi, showUnapprovedTx: triggerUi,
showWatchAssetUi: showWatchAssetUi,
// initial state // initial state
initState, initState,
// initial locale code // initial locale code
@ -443,9 +444,28 @@ function triggerUi () {
}) })
} }
/**
* Opens the browser popup for user confirmation of watchAsset
* then it waits until user interact with the UI
*/
function showWatchAssetUi () {
triggerUi()
return new Promise(
(resolve) => {
var interval = setInterval(() => {
if (!notificationIsOpen) {
clearInterval(interval)
resolve()
}
}, 1000)
}
)
}
// On first install, open a window to MetaMask website to how-it-works. // On first install, open a window to MetaMask website to how-it-works.
extension.runtime.onInstalled.addListener(function (details) { extension.runtime.onInstalled.addListener(function (details) {
if ((details.reason === 'install') && (!METAMASK_DEBUG)) { if ((details.reason === 'install') && (!METAMASK_DEBUG)) {
extension.tabs.create({url: 'https://metamask.io/#how-it-works'}) extension.tabs.create({url: 'https://metamask.io/#how-it-works'})
} }
}) })

@ -1,5 +1,6 @@
const ObservableStore = require('obs-store') const ObservableStore = require('obs-store')
const normalizeAddress = require('eth-sig-util').normalize const normalizeAddress = require('eth-sig-util').normalize
const { isValidAddress } = require('ethereumjs-util')
const extend = require('xtend') const extend = require('xtend')
@ -14,6 +15,7 @@ class PreferencesController {
* @property {string} store.currentAccountTab Indicates the selected tab in the ui * @property {string} store.currentAccountTab Indicates the selected tab in the ui
* @property {array} store.tokens The tokens the user wants display in their token lists * @property {array} store.tokens The tokens the user wants display in their token lists
* @property {object} store.accountTokens The tokens stored per account and then per network type * @property {object} store.accountTokens The tokens stored per account and then per network type
* @property {object} store.assetImages Contains assets objects related to assets added
* @property {boolean} store.useBlockie The users preference for blockie identicons within the UI * @property {boolean} store.useBlockie The users preference for blockie identicons within the UI
* @property {object} store.featureFlags A key-boolean map, where keys refer to features and booleans to whether the * @property {object} store.featureFlags A key-boolean map, where keys refer to features and booleans to whether the
* user wishes to see that feature * user wishes to see that feature
@ -26,7 +28,9 @@ class PreferencesController {
frequentRpcList: [], frequentRpcList: [],
currentAccountTab: 'history', currentAccountTab: 'history',
accountTokens: {}, accountTokens: {},
assetImages: {},
tokens: [], tokens: [],
suggestedTokens: {},
useBlockie: false, useBlockie: false,
featureFlags: {}, featureFlags: {},
currentLocale: opts.initLangCode, currentLocale: opts.initLangCode,
@ -37,6 +41,7 @@ class PreferencesController {
this.diagnostics = opts.diagnostics this.diagnostics = opts.diagnostics
this.network = opts.network this.network = opts.network
this.store = new ObservableStore(initState) this.store = new ObservableStore(initState)
this.showWatchAssetUi = opts.showWatchAssetUi
this._subscribeProviderType() this._subscribeProviderType()
} }
// PUBLIC METHODS // PUBLIC METHODS
@ -51,6 +56,53 @@ class PreferencesController {
this.store.updateState({ useBlockie: val }) this.store.updateState({ useBlockie: val })
} }
getSuggestedTokens () {
return this.store.getState().suggestedTokens
}
getAssetImages () {
return this.store.getState().assetImages
}
addSuggestedERC20Asset (tokenOpts) {
this._validateERC20AssetParams(tokenOpts)
const suggested = this.getSuggestedTokens()
const { rawAddress, symbol, decimals, image } = tokenOpts
const address = normalizeAddress(rawAddress)
const newEntry = { address, symbol, decimals, image }
suggested[address] = newEntry
this.store.updateState({ suggestedTokens: suggested })
}
/**
* RPC engine middleware for requesting new asset added
*
* @param req
* @param res
* @param {Function} - next
* @param {Function} - end
*/
async requestWatchAsset (req, res, next, end) {
if (req.method === 'metamask_watchAsset') {
const { type, options } = req.params
switch (type) {
case 'ERC20':
const result = await this._handleWatchAssetERC20(options)
if (result instanceof Error) {
end(result)
} else {
res.result = result
end()
}
break
default:
end(new Error(`Asset of type ${type} not supported`))
}
} else {
next()
}
}
/** /**
* Getter for the `useBlockie` property * Getter for the `useBlockie` property
* *
@ -186,6 +238,13 @@ class PreferencesController {
return selected return selected
} }
removeSuggestedTokens () {
return new Promise((resolve, reject) => {
this.store.updateState({ suggestedTokens: {} })
resolve({})
})
}
/** /**
* Setter for the `selectedAddress` property * Setter for the `selectedAddress` property
* *
@ -232,11 +291,11 @@ class PreferencesController {
* @returns {Promise<array>} Promises the new array of AddedToken objects. * @returns {Promise<array>} Promises the new array of AddedToken objects.
* *
*/ */
async addToken (rawAddress, symbol, decimals) { async addToken (rawAddress, symbol, decimals, image) {
const address = normalizeAddress(rawAddress) const address = normalizeAddress(rawAddress)
const newEntry = { address, symbol, decimals } const newEntry = { address, symbol, decimals }
const tokens = this.store.getState().tokens const tokens = this.store.getState().tokens
const assetImages = this.getAssetImages()
const previousEntry = tokens.find((token, index) => { const previousEntry = tokens.find((token, index) => {
return token.address === address return token.address === address
}) })
@ -247,7 +306,8 @@ class PreferencesController {
} else { } else {
tokens.push(newEntry) tokens.push(newEntry)
} }
this._updateAccountTokens(tokens) assetImages[address] = image
this._updateAccountTokens(tokens, assetImages)
return Promise.resolve(tokens) return Promise.resolve(tokens)
} }
@ -260,8 +320,10 @@ class PreferencesController {
*/ */
removeToken (rawAddress) { removeToken (rawAddress) {
const tokens = this.store.getState().tokens const tokens = this.store.getState().tokens
const assetImages = this.getAssetImages()
const updatedTokens = tokens.filter(token => token.address !== rawAddress) const updatedTokens = tokens.filter(token => token.address !== rawAddress)
this._updateAccountTokens(updatedTokens) delete assetImages[rawAddress]
this._updateAccountTokens(updatedTokens, assetImages)
return Promise.resolve(updatedTokens) return Promise.resolve(updatedTokens)
} }
@ -387,6 +449,7 @@ class PreferencesController {
// //
// PRIVATE METHODS // PRIVATE METHODS
// //
/** /**
* Subscription to network provider type. * Subscription to network provider type.
* *
@ -405,10 +468,10 @@ class PreferencesController {
* @param {array} tokens Array of tokens to be updated. * @param {array} tokens Array of tokens to be updated.
* *
*/ */
_updateAccountTokens (tokens) { _updateAccountTokens (tokens, assetImages) {
const { accountTokens, providerType, selectedAddress } = this._getTokenRelatedStates() const { accountTokens, providerType, selectedAddress } = this._getTokenRelatedStates()
accountTokens[selectedAddress][providerType] = tokens accountTokens[selectedAddress][providerType] = tokens
this.store.updateState({ accountTokens, tokens }) this.store.updateState({ accountTokens, tokens, assetImages })
} }
/** /**
@ -438,6 +501,47 @@ class PreferencesController {
const tokens = accountTokens[selectedAddress][providerType] const tokens = accountTokens[selectedAddress][providerType]
return { tokens, accountTokens, providerType, selectedAddress } return { tokens, accountTokens, providerType, selectedAddress }
} }
/**
* Handle the suggestion of an ERC20 asset through `watchAsset`
* *
* @param {Promise} promise Promise according to addition of ERC20 token
*
*/
async _handleWatchAssetERC20 (options) {
const { address, symbol, decimals, image } = options
const rawAddress = address
try {
this._validateERC20AssetParams({ rawAddress, symbol, decimals })
} catch (err) {
return err
}
const tokenOpts = { rawAddress, decimals, symbol, image }
this.addSuggestedERC20Asset(tokenOpts)
return this.showWatchAssetUi().then(() => {
const tokenAddresses = this.getTokens().filter(token => token.address === normalizeAddress(rawAddress))
return tokenAddresses.length > 0
})
}
/**
* Validates that the passed options for suggested token have all required properties.
*
* @param {Object} opts The options object to validate
* @throws {string} Throw a custom error indicating that address, symbol and/or decimals
* doesn't fulfill requirements
*
*/
_validateERC20AssetParams (opts) {
const { rawAddress, symbol, decimals } = opts
if (!rawAddress || !symbol || !decimals) throw new Error(`Cannot suggest token without address, symbol, and decimals`)
if (!(symbol.length < 6)) throw new Error(`Invalid symbol ${symbol} more than five characters`)
const numDecimals = parseInt(decimals, 10)
if (isNaN(numDecimals) || numDecimals > 36 || numDecimals < 0) {
throw new Error(`Invalid decimals ${decimals} must be at least 0, and not over 36`)
}
if (!isValidAddress(rawAddress)) throw new Error(`Invalid address ${rawAddress}`)
}
} }
module.exports = PreferencesController module.exports = PreferencesController

@ -92,6 +92,7 @@ module.exports = class MetamaskController extends EventEmitter {
this.preferencesController = new PreferencesController({ this.preferencesController = new PreferencesController({
initState: initState.PreferencesController, initState: initState.PreferencesController,
initLangCode: opts.initLangCode, initLangCode: opts.initLangCode,
showWatchAssetUi: opts.showWatchAssetUi,
network: this.networkController, network: this.networkController,
}) })
@ -386,6 +387,7 @@ module.exports = class MetamaskController extends EventEmitter {
setSelectedAddress: nodeify(preferencesController.setSelectedAddress, preferencesController), setSelectedAddress: nodeify(preferencesController.setSelectedAddress, preferencesController),
addToken: nodeify(preferencesController.addToken, preferencesController), addToken: nodeify(preferencesController.addToken, preferencesController),
removeToken: nodeify(preferencesController.removeToken, preferencesController), removeToken: nodeify(preferencesController.removeToken, preferencesController),
removeSuggestedTokens: nodeify(preferencesController.removeSuggestedTokens, preferencesController),
setCurrentAccountTab: nodeify(preferencesController.setCurrentAccountTab, preferencesController), setCurrentAccountTab: nodeify(preferencesController.setCurrentAccountTab, preferencesController),
setAccountLabel: nodeify(preferencesController.setAccountLabel, preferencesController), setAccountLabel: nodeify(preferencesController.setAccountLabel, preferencesController),
setFeatureFlag: nodeify(preferencesController.setFeatureFlag, preferencesController), setFeatureFlag: nodeify(preferencesController.setFeatureFlag, preferencesController),
@ -1250,6 +1252,7 @@ module.exports = class MetamaskController extends EventEmitter {
engine.push(createOriginMiddleware({ origin })) engine.push(createOriginMiddleware({ origin }))
engine.push(createLoggerMiddleware({ origin })) engine.push(createLoggerMiddleware({ origin }))
engine.push(filterMiddleware) engine.push(filterMiddleware)
engine.push(this.preferencesController.requestWatchAsset.bind(this.preferencesController))
engine.push(createProviderMiddleware({ provider: this.provider })) engine.push(createProviderMiddleware({ provider: this.provider }))
// setup connection // setup connection

@ -32,6 +32,7 @@ function mapStateToProps (state) {
currentCurrency: state.metamask.currentCurrency, currentCurrency: state.metamask.currentCurrency,
currentAccountTab: state.metamask.currentAccountTab, currentAccountTab: state.metamask.currentAccountTab,
tokens: state.metamask.tokens, tokens: state.metamask.tokens,
suggestedTokens: state.metamask.suggestedTokens,
computedBalances: state.metamask.computedBalances, computedBalances: state.metamask.computedBalances,
} }
} }
@ -49,6 +50,10 @@ AccountDetailScreen.prototype.render = function () {
var account = props.accounts[selected] var account = props.accounts[selected]
const { network, conversionRate, currentCurrency } = props const { network, conversionRate, currentCurrency } = props
if (Object.keys(props.suggestedTokens).length > 0) {
this.props.dispatch(actions.showAddSuggestedTokenPage())
}
return ( return (
h('.account-detail-section.full-flex-height', [ h('.account-detail-section.full-flex-height', [

@ -0,0 +1,202 @@
const inherits = require('util').inherits
const Component = require('react').Component
const h = require('react-hyperscript')
const connect = require('react-redux').connect
const actions = require('../../ui/app/actions')
const Tooltip = require('./components/tooltip.js')
const ethUtil = require('ethereumjs-util')
const Copyable = require('./components/copyable')
const addressSummary = require('./util').addressSummary
module.exports = connect(mapStateToProps)(AddSuggestedTokenScreen)
function mapStateToProps (state) {
return {
identities: state.metamask.identities,
suggestedTokens: state.metamask.suggestedTokens,
}
}
inherits(AddSuggestedTokenScreen, Component)
function AddSuggestedTokenScreen () {
this.state = {
warning: null,
}
Component.call(this)
}
AddSuggestedTokenScreen.prototype.render = function () {
const state = this.state
const props = this.props
const { warning } = state
const key = Object.keys(props.suggestedTokens)[0]
const { address, symbol, decimals } = props.suggestedTokens[key]
return (
h('.flex-column.flex-grow', [
// subtitle and nav
h('.section-title.flex-row.flex-center', [
h('h2.page-subtitle', 'Add Suggested Token'),
]),
h('.error', {
style: {
display: warning ? 'block' : 'none',
padding: '0 20px',
textAlign: 'center',
},
}, warning),
// conf view
h('.flex-column.flex-justify-center.flex-grow.select-none', [
h('.flex-space-around', {
style: {
padding: '20px',
},
}, [
h('div', [
h(Tooltip, {
position: 'top',
title: 'The contract of the actual token contract. Click for more info.',
}, [
h('a', {
style: { fontWeight: 'bold', paddingRight: '10px'},
href: 'https://support.metamask.io/kb/article/24-what-is-a-token-contract-address',
target: '_blank',
}, [
h('span', 'Token Contract Address '),
h('i.fa.fa-question-circle'),
]),
]),
]),
h('div', {
style: { display: 'flex' },
}, [
h(Copyable, {
value: ethUtil.toChecksumAddress(address),
}, [
h('span#token-address', {
style: {
width: 'inherit',
flex: '1 0 auto',
height: '30px',
margin: '8px',
display: 'flex',
},
}, addressSummary(address, 24, 4, false)),
]),
]),
h('div', [
h('span', {
style: { fontWeight: 'bold', paddingRight: '10px'},
}, 'Token Symbol'),
]),
h('div', { style: {display: 'flex'} }, [
h('p#token_symbol', {
style: {
width: 'inherit',
flex: '1 0 auto',
height: '30px',
margin: '8px',
},
}, symbol),
]),
h('div', [
h('span', {
style: { fontWeight: 'bold', paddingRight: '10px'},
}, 'Decimals of Precision'),
]),
h('div', { style: {display: 'flex'} }, [
h('p#token_decimals', {
type: 'number',
style: {
width: 'inherit',
flex: '1 0 auto',
height: '30px',
margin: '8px',
},
}, decimals),
]),
h('button', {
style: {
alignSelf: 'center',
margin: '8px',
},
onClick: (event) => {
this.props.dispatch(actions.removeSuggestedTokens())
},
}, 'Cancel'),
h('button', {
style: {
alignSelf: 'center',
margin: '8px',
},
onClick: (event) => {
const valid = this.validateInputs({ address, symbol, decimals })
if (!valid) return
this.props.dispatch(actions.addToken(address.trim(), symbol.trim(), decimals))
.then(() => {
this.props.dispatch(actions.removeSuggestedTokens())
})
},
}, 'Add'),
]),
]),
])
)
}
AddSuggestedTokenScreen.prototype.componentWillMount = function () {
if (typeof global.ethereumProvider === 'undefined') return
}
AddSuggestedTokenScreen.prototype.validateInputs = function (opts) {
let msg = ''
const identitiesList = Object.keys(this.props.identities)
const { address, symbol, decimals } = opts
const standardAddress = ethUtil.addHexPrefix(address).toLowerCase()
const validAddress = ethUtil.isValidAddress(address)
if (!validAddress) {
msg += 'Address is invalid.'
}
const validDecimals = decimals >= 0 && decimals < 36
if (!validDecimals) {
msg += 'Decimals must be at least 0, and not over 36. '
}
const symbolLen = symbol.trim().length
const validSymbol = symbolLen > 0 && symbolLen < 10
if (!validSymbol) {
msg += 'Symbol must be between 0 and 10 characters.'
}
const ownAddress = identitiesList.includes(standardAddress)
if (ownAddress) {
msg = 'Personal address detected. Input the token contract address.'
}
const isValid = validAddress && validDecimals && !ownAddress
if (!isValid) {
this.setState({
warning: msg,
})
} else {
this.setState({ warning: null })
}
return isValid
}

@ -23,6 +23,7 @@ const generateLostAccountsNotice = require('../lib/lost-accounts-notice')
// other views // other views
const ConfigScreen = require('./config') const ConfigScreen = require('./config')
const AddTokenScreen = require('./add-token') const AddTokenScreen = require('./add-token')
const AddSuggestedTokenScreen = require('./add-suggested-token')
const Import = require('./accounts/import') const Import = require('./accounts/import')
const InfoScreen = require('./info') const InfoScreen = require('./info')
const NewUiAnnouncement = require('./new-ui-annoucement') const NewUiAnnouncement = require('./new-ui-annoucement')
@ -74,6 +75,7 @@ function mapStateToProps (state) {
lostAccounts: state.metamask.lostAccounts, lostAccounts: state.metamask.lostAccounts,
frequentRpcList: state.metamask.frequentRpcList || [], frequentRpcList: state.metamask.frequentRpcList || [],
featureFlags, featureFlags,
suggestedTokens: state.metamask.suggestedTokens,
// state needed to get account dropdown temporarily rendering from app bar // state needed to get account dropdown temporarily rendering from app bar
identities, identities,
@ -236,6 +238,10 @@ App.prototype.renderPrimary = function () {
log.debug('rendering add-token screen from unlock screen.') log.debug('rendering add-token screen from unlock screen.')
return h(AddTokenScreen, {key: 'add-token'}) return h(AddTokenScreen, {key: 'add-token'})
case 'add-suggested-token':
log.debug('rendering add-suggested-token screen from unlock screen.')
return h(AddSuggestedTokenScreen, {key: 'add-suggested-token'})
case 'config': case 'config':
log.debug('rendering config screen') log.debug('rendering config screen')
return h(ConfigScreen, {key: 'config'}) return h(ConfigScreen, {key: 'config'})

12252
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,6 +1,7 @@
const assert = require('assert') const assert = require('assert')
const ObservableStore = require('obs-store') const ObservableStore = require('obs-store')
const PreferencesController = require('../../../../app/scripts/controllers/preferences') const PreferencesController = require('../../../../app/scripts/controllers/preferences')
const sinon = require('sinon')
describe('preferences controller', function () { describe('preferences controller', function () {
let preferencesController let preferencesController
@ -339,5 +340,114 @@ describe('preferences controller', function () {
assert.deepEqual(tokensSecond, initialTokensSecond, 'tokens equal for same network') assert.deepEqual(tokensSecond, initialTokensSecond, 'tokens equal for same network')
}) })
}) })
describe('on watchAsset', function () {
var stubNext, stubEnd, stubHandleWatchAssetERC20, asy, req, res
const sandbox = sinon.createSandbox()
beforeEach(() => {
req = {params: {}}
res = {}
asy = {next: () => {}, end: () => {}}
stubNext = sandbox.stub(asy, 'next')
stubEnd = sandbox.stub(asy, 'end').returns(0)
stubHandleWatchAssetERC20 = sandbox.stub(preferencesController, '_handleWatchAssetERC20')
})
after(() => {
sandbox.restore()
})
it('shouldn not do anything if method not corresponds', async function () {
const asy = {next: () => {}, end: () => {}}
var stubNext = sandbox.stub(asy, 'next')
var stubEnd = sandbox.stub(asy, 'end').returns(0)
req.method = 'metamask'
await preferencesController.requestWatchAsset(req, res, asy.next, asy.end)
sandbox.assert.notCalled(stubEnd)
sandbox.assert.called(stubNext)
})
it('should do something if method is supported', async function () {
const asy = {next: () => {}, end: () => {}}
var stubNext = sandbox.stub(asy, 'next')
var stubEnd = sandbox.stub(asy, 'end').returns(0)
req.method = 'metamask_watchAsset'
req.params.type = 'someasset'
await preferencesController.requestWatchAsset(req, res, asy.next, asy.end)
sandbox.assert.called(stubEnd)
sandbox.assert.notCalled(stubNext)
})
it('should through error if method is supported but asset type is not', async function () {
req.method = 'metamask_watchAsset'
req.params.type = 'someasset'
await preferencesController.requestWatchAsset(req, res, asy.next, asy.end)
sandbox.assert.called(stubEnd)
sandbox.assert.notCalled(stubHandleWatchAssetERC20)
sandbox.assert.notCalled(stubNext)
assert.deepEqual(res, {})
})
it('should trigger handle add asset if type supported', async function () {
const asy = {next: () => {}, end: () => {}}
req.method = 'metamask_watchAsset'
req.params.type = 'ERC20'
await preferencesController.requestWatchAsset(req, res, asy.next, asy.end)
sandbox.assert.called(stubHandleWatchAssetERC20)
})
})
describe('on watchAsset of type ERC20', function () {
var req
const sandbox = sinon.createSandbox()
beforeEach(() => {
req = {params: {type: 'ERC20'}}
})
after(() => {
sandbox.restore()
})
it('should add suggested token', async function () {
const address = '0xabcdef1234567'
const symbol = 'ABBR'
const decimals = 5
const image = 'someimage'
req.params.options = { address, symbol, decimals, image }
sandbox.stub(preferencesController, '_validateERC20AssetParams').returns(true)
preferencesController.showWatchAssetUi = async () => {}
await preferencesController._handleWatchAssetERC20(req.params.options)
const suggested = preferencesController.getSuggestedTokens()
assert.equal(Object.keys(suggested).length, 1, `one token added ${Object.keys(suggested)}`)
assert.equal(suggested[address].address, address, 'set address correctly')
assert.equal(suggested[address].symbol, symbol, 'set symbol correctly')
assert.equal(suggested[address].decimals, decimals, 'set decimals correctly')
assert.equal(suggested[address].image, image, 'set image correctly')
})
it('should add token correctly if user confirms', async function () {
const address = '0xabcdef1234567'
const symbol = 'ABBR'
const decimals = 5
const image = 'someimage'
req.params.options = { address, symbol, decimals, image }
sandbox.stub(preferencesController, '_validateERC20AssetParams').returns(true)
preferencesController.showWatchAssetUi = async () => {
await preferencesController.addToken(address, symbol, decimals, image)
}
await preferencesController._handleWatchAssetERC20(req.params.options)
const tokens = preferencesController.getTokens()
assert.equal(tokens.length, 1, `one token added`)
const added = tokens[0]
assert.equal(added.address, address, 'set address correctly')
assert.equal(added.symbol, symbol, 'set symbol correctly')
assert.equal(added.decimals, decimals, 'set decimals correctly')
const assetImages = preferencesController.getAssetImages()
assert.ok(assetImages[address], `set image correctly`)
})
})
}) })

@ -227,11 +227,14 @@ var actions = {
SET_PROVIDER_TYPE: 'SET_PROVIDER_TYPE', SET_PROVIDER_TYPE: 'SET_PROVIDER_TYPE',
showConfigPage, showConfigPage,
SHOW_ADD_TOKEN_PAGE: 'SHOW_ADD_TOKEN_PAGE', SHOW_ADD_TOKEN_PAGE: 'SHOW_ADD_TOKEN_PAGE',
SHOW_ADD_SUGGESTED_TOKEN_PAGE: 'SHOW_ADD_SUGGESTED_TOKEN_PAGE',
showAddTokenPage, showAddTokenPage,
showAddSuggestedTokenPage,
addToken, addToken,
addTokens, addTokens,
removeToken, removeToken,
updateTokens, updateTokens,
removeSuggestedTokens,
UPDATE_TOKENS: 'UPDATE_TOKENS', UPDATE_TOKENS: 'UPDATE_TOKENS',
setRpcTarget: setRpcTarget, setRpcTarget: setRpcTarget,
setProviderType: setProviderType, setProviderType: setProviderType,
@ -1589,11 +1592,18 @@ function showAddTokenPage (transitionForward = true) {
} }
} }
function addToken (address, symbol, decimals) { function showAddSuggestedTokenPage (transitionForward = true) {
return {
type: actions.SHOW_ADD_SUGGESTED_TOKEN_PAGE,
value: transitionForward,
}
}
function addToken (address, symbol, decimals, image) {
return (dispatch) => { return (dispatch) => {
dispatch(actions.showLoadingIndication()) dispatch(actions.showLoadingIndication())
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
background.addToken(address, symbol, decimals, (err, tokens) => { background.addToken(address, symbol, decimals, image, (err, tokens) => {
dispatch(actions.hideLoadingIndication()) dispatch(actions.hideLoadingIndication())
if (err) { if (err) {
dispatch(actions.displayWarning(err.message)) dispatch(actions.displayWarning(err.message))
@ -1643,6 +1653,27 @@ function addTokens (tokens) {
} }
} }
function removeSuggestedTokens () {
return (dispatch) => {
dispatch(actions.showLoadingIndication())
return new Promise((resolve, reject) => {
background.removeSuggestedTokens((err, suggestedTokens) => {
dispatch(actions.hideLoadingIndication())
if (err) {
dispatch(actions.displayWarning(err.message))
}
dispatch(actions.clearPendingTokens())
if (global.METAMASK_UI_TYPE === ENVIRONMENT_TYPE_NOTIFICATION) {
return global.platform.closeCurrentWindow()
}
resolve(suggestedTokens)
})
})
.then(() => updateMetamaskStateFromBackground())
.then(suggestedTokens => dispatch(actions.updateMetamaskState({...suggestedTokens})))
}
}
function updateTokens (newTokens) { function updateTokens (newTokens) {
return { return {
type: actions.UPDATE_TOKENS, type: actions.UPDATE_TOKENS,
@ -1650,6 +1681,12 @@ function updateTokens (newTokens) {
} }
} }
function clearPendingTokens () {
return {
type: actions.CLEAR_PENDING_TOKENS,
}
}
function goBackToInitView () { function goBackToInitView () {
return { return {
type: actions.BACK_TO_INIT_MENU, type: actions.BACK_TO_INIT_MENU,
@ -2310,9 +2347,3 @@ function setPendingTokens (pendingTokens) {
payload: tokens, payload: tokens,
} }
} }
function clearPendingTokens () {
return {
type: actions.CLEAR_PENDING_TOKENS,
}
}

@ -26,6 +26,7 @@ const RestoreVaultPage = require('./components/pages/keychains/restore-vault').d
const RevealSeedConfirmation = require('./components/pages/keychains/reveal-seed') const RevealSeedConfirmation = require('./components/pages/keychains/reveal-seed')
const AddTokenPage = require('./components/pages/add-token') const AddTokenPage = require('./components/pages/add-token')
const ConfirmAddTokenPage = require('./components/pages/confirm-add-token') const ConfirmAddTokenPage = require('./components/pages/confirm-add-token')
const ConfirmAddSuggestedTokenPage = require('./components/pages/confirm-add-suggested-token')
const CreateAccountPage = require('./components/pages/create-account') const CreateAccountPage = require('./components/pages/create-account')
const NoticeScreen = require('./components/pages/notice') const NoticeScreen = require('./components/pages/notice')
@ -51,6 +52,7 @@ const {
RESTORE_VAULT_ROUTE, RESTORE_VAULT_ROUTE,
ADD_TOKEN_ROUTE, ADD_TOKEN_ROUTE,
CONFIRM_ADD_TOKEN_ROUTE, CONFIRM_ADD_TOKEN_ROUTE,
CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE,
NEW_ACCOUNT_ROUTE, NEW_ACCOUNT_ROUTE,
SEND_ROUTE, SEND_ROUTE,
CONFIRM_TRANSACTION_ROUTE, CONFIRM_TRANSACTION_ROUTE,
@ -85,6 +87,7 @@ class App extends Component {
h(Authenticated, { path: SEND_ROUTE, exact, component: SendTransactionScreen }), h(Authenticated, { path: SEND_ROUTE, exact, component: SendTransactionScreen }),
h(Authenticated, { path: ADD_TOKEN_ROUTE, exact, component: AddTokenPage }), h(Authenticated, { path: ADD_TOKEN_ROUTE, exact, component: AddTokenPage }),
h(Authenticated, { path: CONFIRM_ADD_TOKEN_ROUTE, exact, component: ConfirmAddTokenPage }), h(Authenticated, { path: CONFIRM_ADD_TOKEN_ROUTE, exact, component: ConfirmAddTokenPage }),
h(Authenticated, { path: CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE, exact, component: ConfirmAddSuggestedTokenPage }),
h(Authenticated, { path: NEW_ACCOUNT_ROUTE, component: CreateAccountPage }), h(Authenticated, { path: NEW_ACCOUNT_ROUTE, component: CreateAccountPage }),
h(Authenticated, { path: DEFAULT_ROUTE, exact, component: Home }), h(Authenticated, { path: DEFAULT_ROUTE, exact, component: Home }),
]) ])

@ -21,6 +21,7 @@ function mapStateToProps (state) {
network, network,
conversionRate: state.metamask.conversionRate, conversionRate: state.metamask.conversionRate,
currentCurrency: state.metamask.currentCurrency, currentCurrency: state.metamask.currentCurrency,
assetImages: state.metamask.assetImages,
} }
} }
@ -31,7 +32,9 @@ function BalanceComponent () {
BalanceComponent.prototype.render = function () { BalanceComponent.prototype.render = function () {
const props = this.props const props = this.props
const { token, network } = props const { token, network, assetImages } = props
const address = token && token.address
const image = assetImages && address ? assetImages[token.address] : undefined
return h('div.balance-container', {}, [ return h('div.balance-container', {}, [
@ -42,8 +45,9 @@ BalanceComponent.prototype.render = function () {
// }), // }),
h(Identicon, { h(Identicon, {
diameter: 50, diameter: 50,
address: token && token.address, address,
network, network,
image,
}), }),
token ? this.renderTokenBalance() : this.renderBalance(), token ? this.renderTokenBalance() : this.renderBalance(),

@ -26,12 +26,23 @@ function mapStateToProps (state) {
IdenticonComponent.prototype.render = function () { IdenticonComponent.prototype.render = function () {
var props = this.props var props = this.props
const { className = '', address } = props const { className = '', address, image } = props
var diameter = props.diameter || this.defaultDiameter var diameter = props.diameter || this.defaultDiameter
const style = {
return address height: diameter,
? ( width: diameter,
h('div', { borderRadius: diameter / 2,
}
if (image) {
return h('img', {
className: `${className} identicon`,
src: image,
style: {
...style,
},
})
} else if (address) {
return h('div', {
className: `${className} identicon`, className: `${className} identicon`,
key: 'identicon-' + address, key: 'identicon-' + address,
style: { style: {
@ -39,24 +50,18 @@ IdenticonComponent.prototype.render = function () {
flexShrink: 0, flexShrink: 0,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
height: diameter, ...style,
width: diameter,
borderRadius: diameter / 2,
overflow: 'hidden', overflow: 'hidden',
}, },
}) })
) } else {
: ( return h('img.balance-icon', {
h('img', {
className: `${className} balance-icon`,
src: './images/eth_logo.svg', src: './images/eth_logo.svg',
style: { style: {
height: diameter, ...style,
width: diameter,
borderRadius: diameter / 2,
}, },
}) })
) }
} }
IdenticonComponent.prototype.componentDidMount = function () { IdenticonComponent.prototype.componentDidMount = function () {

@ -10,6 +10,7 @@ function mapStateToProps (state) {
return { return {
network: state.metamask.network, network: state.metamask.network,
token: state.appState.modal.modalState.props.token, token: state.appState.modal.modalState.props.token,
assetImages: state.metamask.assetImages,
} }
} }
@ -40,8 +41,9 @@ module.exports = connect(mapStateToProps, mapDispatchToProps)(HideTokenConfirmat
HideTokenConfirmationModal.prototype.render = function () { HideTokenConfirmationModal.prototype.render = function () {
const { token, network, hideToken, hideModal } = this.props const { token, network, hideToken, hideModal, assetImages } = this.props
const { symbol, address } = token const { symbol, address } = token
const image = assetImages[address]
return h('div.hide-token-confirmation', {}, [ return h('div.hide-token-confirmation', {}, [
h('div.hide-token-confirmation__container', { h('div.hide-token-confirmation__container', {
@ -55,6 +57,7 @@ HideTokenConfirmationModal.prototype.render = function () {
diameter: 45, diameter: 45,
address, address,
network, network,
image,
}), }),
h('div.hide-token-confirmation__symbol', {}, symbol), h('div.hide-token-confirmation__symbol', {}, symbol),

@ -0,0 +1,126 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { DEFAULT_ROUTE } from '../../../routes'
import Button from '../../button'
import Identicon from '../../../components/identicon'
import TokenBalance from '../../token-balance'
export default class ConfirmAddSuggestedToken extends Component {
static contextTypes = {
t: PropTypes.func,
}
static propTypes = {
history: PropTypes.object,
clearPendingTokens: PropTypes.func,
addToken: PropTypes.func,
pendingTokens: PropTypes.object,
removeSuggestedTokens: PropTypes.func,
}
componentDidMount () {
const { pendingTokens = {}, history } = this.props
if (Object.keys(pendingTokens).length === 0) {
history.push(DEFAULT_ROUTE)
}
}
getTokenName (name, symbol) {
return typeof name === 'undefined'
? symbol
: `${name} (${symbol})`
}
render () {
const { addToken, pendingTokens, removeSuggestedTokens, history } = this.props
const pendingTokenKey = Object.keys(pendingTokens)[0]
const pendingToken = pendingTokens[pendingTokenKey]
return (
<div className="page-container">
<div className="page-container__header">
<div className="page-container__title">
{ this.context.t('addSuggestedTokens') }
</div>
<div className="page-container__subtitle">
{ this.context.t('likeToAddTokens') }
</div>
</div>
<div className="page-container__content">
<div className="confirm-add-token">
<div className="confirm-add-token__header">
<div className="confirm-add-token__token">
{ this.context.t('token') }
</div>
<div className="confirm-add-token__balance">
{ this.context.t('balance') }
</div>
</div>
<div className="confirm-add-token__token-list">
{
Object.entries(pendingTokens)
.map(([ address, token ]) => {
const { name, symbol, image } = token
return (
<div
className="confirm-add-token__token-list-item"
key={address}
>
<div className="confirm-add-token__token confirm-add-token__data">
<Identicon
className="confirm-add-token__token-icon"
diameter={48}
address={address}
image={image}
/>
<div className="confirm-add-token__name">
{ this.getTokenName(name, symbol) }
</div>
</div>
<div className="confirm-add-token__balance">
<TokenBalance token={token} />
</div>
</div>
)
})
}
</div>
</div>
</div>
<div className="page-container__footer">
<Button
type="default"
large
className="page-container__footer-button"
onClick={() => {
removeSuggestedTokens()
.then(() => {
history.push(DEFAULT_ROUTE)
})
}}
>
{ this.context.t('cancel') }
</Button>
<Button
type="primary"
large
className="page-container__footer-button"
onClick={() => {
addToken(pendingToken)
.then(() => {
removeSuggestedTokens()
.then(() => {
history.push(DEFAULT_ROUTE)
})
})
}}
>
{ this.context.t('addToken') }
</Button>
</div>
</div>
)
}
}

@ -0,0 +1,29 @@
import { connect } from 'react-redux'
import { compose } from 'recompose'
import ConfirmAddSuggestedToken from './confirm-add-suggested-token.component'
import { withRouter } from 'react-router-dom'
const extend = require('xtend')
const { addToken, removeSuggestedTokens } = require('../../../actions')
const mapStateToProps = ({ metamask }) => {
const { pendingTokens, suggestedTokens } = metamask
const params = extend(pendingTokens, suggestedTokens)
return {
pendingTokens: params,
}
}
const mapDispatchToProps = dispatch => {
return {
addToken: ({address, symbol, decimals, image}) => dispatch(addToken(address, symbol, decimals, image)),
removeSuggestedTokens: () => dispatch(removeSuggestedTokens()),
}
}
export default compose(
withRouter,
connect(mapStateToProps, mapDispatchToProps)
)(ConfirmAddSuggestedToken)

@ -0,0 +1,2 @@
import ConfirmAddSuggestedToken from './confirm-add-suggested-token.container'
module.exports = ConfirmAddSuggestedToken

@ -9,6 +9,7 @@ import {
RESTORE_VAULT_ROUTE, RESTORE_VAULT_ROUTE,
CONFIRM_TRANSACTION_ROUTE, CONFIRM_TRANSACTION_ROUTE,
NOTICE_ROUTE, NOTICE_ROUTE,
CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE,
} from '../../../routes' } from '../../../routes'
export default class Home extends PureComponent { export default class Home extends PureComponent {
@ -18,11 +19,21 @@ export default class Home extends PureComponent {
lostAccounts: PropTypes.array, lostAccounts: PropTypes.array,
forgottenPassword: PropTypes.bool, forgottenPassword: PropTypes.bool,
seedWords: PropTypes.string, seedWords: PropTypes.string,
suggestedTokens: PropTypes.object,
unconfirmedTransactionsCount: PropTypes.number, unconfirmedTransactionsCount: PropTypes.number,
} }
componentDidMount () { componentDidMount () {
const { history, unconfirmedTransactionsCount = 0 } = this.props const {
history,
suggestedTokens = {},
unconfirmedTransactionsCount = 0,
} = this.props
// suggested new tokens
if (Object.keys(suggestedTokens).length > 0) {
history.push(CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE)
}
if (unconfirmedTransactionsCount > 0) { if (unconfirmedTransactionsCount > 0) {
history.push(CONFIRM_TRANSACTION_ROUTE) history.push(CONFIRM_TRANSACTION_ROUTE)

@ -10,6 +10,7 @@ const mapStateToProps = state => {
noActiveNotices, noActiveNotices,
lostAccounts, lostAccounts,
seedWords, seedWords,
suggestedTokens,
} = metamask } = metamask
const { forgottenPassword } = appState const { forgottenPassword } = appState
@ -18,6 +19,7 @@ const mapStateToProps = state => {
lostAccounts, lostAccounts,
forgottenPassword, forgottenPassword,
seedWords, seedWords,
suggestedTokens,
unconfirmedTransactionsCount: unconfirmedTransactionsCountSelector(state), unconfirmedTransactionsCount: unconfirmedTransactionsCountSelector(state),
} }
} }

@ -56,8 +56,8 @@ TokenCell.prototype.render = function () {
sidebarOpen, sidebarOpen,
currentCurrency, currentCurrency,
// userAddress, // userAddress,
image,
} = props } = props
let currentTokenToFiatRate let currentTokenToFiatRate
let currentTokenInFiat let currentTokenInFiat
let formattedFiat = '' let formattedFiat = ''
@ -97,6 +97,7 @@ TokenCell.prototype.render = function () {
diameter: 50, diameter: 50,
address, address,
network, network,
image,
}), }),
h('div.token-list-item__balance-ellipsis', null, [ h('div.token-list-item__balance-ellipsis', null, [

@ -13,6 +13,7 @@ function mapStateToProps (state) {
network: state.metamask.network, network: state.metamask.network,
tokens: state.metamask.tokens, tokens: state.metamask.tokens,
userAddress: selectors.getSelectedAddress(state), userAddress: selectors.getSelectedAddress(state),
assetImages: state.metamask.assetImages,
} }
} }
@ -44,10 +45,9 @@ function TokenList () {
} }
TokenList.prototype.render = function () { TokenList.prototype.render = function () {
const { userAddress } = this.props const { userAddress, assetImages } = this.props
const state = this.state const state = this.state
const { tokens, isLoading, error } = state const { tokens, isLoading, error } = state
if (isLoading) { if (isLoading) {
return this.message(this.context.t('loadingTokens')) return this.message(this.context.t('loadingTokens'))
} }
@ -74,7 +74,10 @@ TokenList.prototype.render = function () {
]) ])
} }
return h('div', tokens.map((tokenData) => h(TokenCell, tokenData))) return h('div', tokens.map((tokenData) => {
tokenData.image = assetImages[tokenData.address]
return h(TokenCell, tokenData)
}))
} }

@ -209,6 +209,15 @@ function reduceApp (state, action) {
transForward: action.value, transForward: action.value,
}) })
case actions.SHOW_ADD_SUGGESTED_TOKEN_PAGE:
return extend(appState, {
currentView: {
name: 'add-suggested-token',
context: appState.currentView.context,
},
transForward: action.value,
})
case actions.SHOW_IMPORT_PAGE: case actions.SHOW_IMPORT_PAGE:
return extend(appState, { return extend(appState, {
currentView: { currentView: {

@ -7,6 +7,7 @@ const CONFIRM_SEED_ROUTE = '/confirm-seed'
const RESTORE_VAULT_ROUTE = '/restore-vault' const RESTORE_VAULT_ROUTE = '/restore-vault'
const ADD_TOKEN_ROUTE = '/add-token' const ADD_TOKEN_ROUTE = '/add-token'
const CONFIRM_ADD_TOKEN_ROUTE = '/confirm-add-token' const CONFIRM_ADD_TOKEN_ROUTE = '/confirm-add-token'
const CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE = '/confirm-add-suggested-token'
const NEW_ACCOUNT_ROUTE = '/new-account' const NEW_ACCOUNT_ROUTE = '/new-account'
const IMPORT_ACCOUNT_ROUTE = '/new-account/import' const IMPORT_ACCOUNT_ROUTE = '/new-account/import'
const CONNECT_HARDWARE_ROUTE = '/new-account/connect' const CONNECT_HARDWARE_ROUTE = '/new-account/connect'
@ -41,6 +42,7 @@ module.exports = {
RESTORE_VAULT_ROUTE, RESTORE_VAULT_ROUTE,
ADD_TOKEN_ROUTE, ADD_TOKEN_ROUTE,
CONFIRM_ADD_TOKEN_ROUTE, CONFIRM_ADD_TOKEN_ROUTE,
CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE,
NEW_ACCOUNT_ROUTE, NEW_ACCOUNT_ROUTE,
IMPORT_ACCOUNT_ROUTE, IMPORT_ACCOUNT_ROUTE,
CONNECT_HARDWARE_ROUTE, CONNECT_HARDWARE_ROUTE,

Loading…
Cancel
Save