aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--app/_locales/en/messages.json3
-rw-r--r--app/scripts/background.js20
-rw-r--r--app/scripts/controllers/preferences.js116
-rw-r--r--app/scripts/metamask-controller.js3
-rw-r--r--old-ui/app/account-detail.js5
-rw-r--r--old-ui/app/add-suggested-token.js202
-rw-r--r--old-ui/app/app.js6
-rw-r--r--test/unit/app/controllers/preferences-controller-test.js110
-rw-r--r--ui/app/actions.js47
-rw-r--r--ui/app/app.js3
-rw-r--r--ui/app/components/balance-component.js6
-rw-r--r--ui/app/components/identicon.js64
-rw-r--r--ui/app/components/modals/hide-token-confirmation-modal.js5
-rw-r--r--ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js126
-rw-r--r--ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js29
-rw-r--r--ui/app/components/pages/confirm-add-suggested-token/index.js2
-rw-r--r--ui/app/components/pages/home.js10
-rw-r--r--ui/app/components/token-cell.js3
-rw-r--r--ui/app/components/token-list.js9
-rw-r--r--ui/app/reducers/app.js9
-rw-r--r--ui/app/routes.js2
21 files changed, 730 insertions, 50 deletions
diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index a25a2bd59..f61fb4335 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -29,6 +29,9 @@
"addTokens": {
"message": "Add Tokens"
},
+ "addSuggestedTokens": {
+ "message": "Add Suggested Tokens"
+ },
"addAcquiredTokens": {
"message": "Add the tokens you've acquired using MetaMask"
},
diff --git a/app/scripts/background.js b/app/scripts/background.js
index d4d87e0d5..546fef569 100644
--- a/app/scripts/background.js
+++ b/app/scripts/background.js
@@ -256,6 +256,7 @@ function setupController (initState, initLangCode) {
showUnconfirmedMessage: triggerUi,
unlockAccountMessage: triggerUi,
showUnapprovedTx: triggerUi,
+ showWatchAssetUi: showWatchAssetUi,
// initial state
initState,
// 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.
extension.runtime.onInstalled.addListener(function (details) {
if ((details.reason === 'install') && (!METAMASK_DEBUG)) {
extension.tabs.create({url: 'https://metamask.io/#how-it-works'})
}
})
+
diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js
index 707fd7de9..a03abbf79 100644
--- a/app/scripts/controllers/preferences.js
+++ b/app/scripts/controllers/preferences.js
@@ -1,5 +1,6 @@
const ObservableStore = require('obs-store')
const normalizeAddress = require('eth-sig-util').normalize
+const isValidAddress = require('ethereumjs-util').isValidAddress
const extend = require('xtend')
@@ -14,6 +15,7 @@ class PreferencesController {
* @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 {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 {object} store.featureFlags A key-boolean map, where keys refer to features and booleans to whether the
* user wishes to see that feature
@@ -26,7 +28,9 @@ class PreferencesController {
frequentRpcList: [],
currentAccountTab: 'history',
accountTokens: {},
+ assetImages: {},
tokens: [],
+ suggestedTokens: {},
useBlockie: false,
featureFlags: {},
currentLocale: opts.initLangCode,
@@ -37,6 +41,7 @@ class PreferencesController {
this.diagnostics = opts.diagnostics
this.network = opts.network
this.store = new ObservableStore(initState)
+ this.showWatchAssetUi = opts.showWatchAssetUi
this._subscribeProviderType()
}
// PUBLIC METHODS
@@ -51,6 +56,53 @@ class PreferencesController {
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, imageUrl } = tokenOpts
+ const address = normalizeAddress(rawAddress)
+ const newEntry = { address, symbol, decimals, imageUrl }
+ 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
*
@@ -186,6 +238,13 @@ class PreferencesController {
return selected
}
+ removeSuggestedTokens () {
+ return new Promise((resolve, reject) => {
+ this.store.updateState({ suggestedTokens: {} })
+ resolve({})
+ })
+ }
+
/**
* Setter for the `selectedAddress` property
*
@@ -232,11 +291,11 @@ class PreferencesController {
* @returns {Promise<array>} Promises the new array of AddedToken objects.
*
*/
- async addToken (rawAddress, symbol, decimals) {
+ async addToken (rawAddress, symbol, decimals, imageUrl) {
const address = normalizeAddress(rawAddress)
const newEntry = { address, symbol, decimals }
-
const tokens = this.store.getState().tokens
+ const assetImages = this.getAssetImages()
const previousEntry = tokens.find((token, index) => {
return token.address === address
})
@@ -247,7 +306,8 @@ class PreferencesController {
} else {
tokens.push(newEntry)
}
- this._updateAccountTokens(tokens)
+ assetImages[address] = imageUrl
+ this._updateAccountTokens(tokens, assetImages)
return Promise.resolve(tokens)
}
@@ -260,8 +320,10 @@ class PreferencesController {
*/
removeToken (rawAddress) {
const tokens = this.store.getState().tokens
+ const assetImages = this.getAssetImages()
const updatedTokens = tokens.filter(token => token.address !== rawAddress)
- this._updateAccountTokens(updatedTokens)
+ delete assetImages[rawAddress]
+ this._updateAccountTokens(updatedTokens, assetImages)
return Promise.resolve(updatedTokens)
}
@@ -387,6 +449,7 @@ class PreferencesController {
//
// PRIVATE METHODS
//
+
/**
* Subscription to network provider type.
*
@@ -405,10 +468,10 @@ class PreferencesController {
* @param {array} tokens Array of tokens to be updated.
*
*/
- _updateAccountTokens (tokens) {
+ _updateAccountTokens (tokens, assetImages) {
const { accountTokens, providerType, selectedAddress } = this._getTokenRelatedStates()
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]
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, imageUrl } = options
+ const rawAddress = address
+ try {
+ this._validateERC20AssetParams({ rawAddress, symbol, decimals })
+ } catch (err) {
+ return err
+ }
+ const tokenOpts = { rawAddress, decimals, symbol, imageUrl }
+ 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
diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js
index 29838ad2d..63385f4fc 100644
--- a/app/scripts/metamask-controller.js
+++ b/app/scripts/metamask-controller.js
@@ -88,6 +88,7 @@ module.exports = class MetamaskController extends EventEmitter {
this.preferencesController = new PreferencesController({
initState: initState.PreferencesController,
initLangCode: opts.initLangCode,
+ showWatchAssetUi: opts.showWatchAssetUi,
network: this.networkController,
})
@@ -386,6 +387,7 @@ module.exports = class MetamaskController extends EventEmitter {
setSelectedAddress: nodeify(preferencesController.setSelectedAddress, preferencesController),
addToken: nodeify(preferencesController.addToken, preferencesController),
removeToken: nodeify(preferencesController.removeToken, preferencesController),
+ removeSuggestedTokens: nodeify(preferencesController.removeSuggestedTokens, preferencesController),
setCurrentAccountTab: nodeify(preferencesController.setCurrentAccountTab, preferencesController),
setAccountLabel: nodeify(preferencesController.setAccountLabel, preferencesController),
setFeatureFlag: nodeify(preferencesController.setFeatureFlag, preferencesController),
@@ -1242,6 +1244,7 @@ module.exports = class MetamaskController extends EventEmitter {
engine.push(createOriginMiddleware({ origin }))
engine.push(createLoggerMiddleware({ origin }))
engine.push(filterMiddleware)
+ engine.push(this.preferencesController.requestWatchAsset.bind(this.preferencesController))
engine.push(createProviderMiddleware({ provider: this.provider }))
// setup connection
diff --git a/old-ui/app/account-detail.js b/old-ui/app/account-detail.js
index c67f0cf71..d240fc38e 100644
--- a/old-ui/app/account-detail.js
+++ b/old-ui/app/account-detail.js
@@ -32,6 +32,7 @@ function mapStateToProps (state) {
currentCurrency: state.metamask.currentCurrency,
currentAccountTab: state.metamask.currentAccountTab,
tokens: state.metamask.tokens,
+ suggestedTokens: state.metamask.suggestedTokens,
computedBalances: state.metamask.computedBalances,
}
}
@@ -49,6 +50,10 @@ AccountDetailScreen.prototype.render = function () {
var account = props.accounts[selected]
const { network, conversionRate, currentCurrency } = props
+ if (Object.keys(props.suggestedTokens).length > 0) {
+ this.props.dispatch(actions.showAddSuggestedTokenPage())
+ }
+
return (
h('.account-detail-section.full-flex-height', [
diff --git a/old-ui/app/add-suggested-token.js b/old-ui/app/add-suggested-token.js
new file mode 100644
index 000000000..51be4c5f2
--- /dev/null
+++ b/old-ui/app/add-suggested-token.js
@@ -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
+}
diff --git a/old-ui/app/app.js b/old-ui/app/app.js
index d3e9e823b..9be21ebad 100644
--- a/old-ui/app/app.js
+++ b/old-ui/app/app.js
@@ -23,6 +23,7 @@ const generateLostAccountsNotice = require('../lib/lost-accounts-notice')
// other views
const ConfigScreen = require('./config')
const AddTokenScreen = require('./add-token')
+const AddSuggestedTokenScreen = require('./add-suggested-token')
const Import = require('./accounts/import')
const InfoScreen = require('./info')
const NewUiAnnouncement = require('./new-ui-annoucement')
@@ -74,6 +75,7 @@ function mapStateToProps (state) {
lostAccounts: state.metamask.lostAccounts,
frequentRpcList: state.metamask.frequentRpcList || [],
featureFlags,
+ suggestedTokens: state.metamask.suggestedTokens,
// state needed to get account dropdown temporarily rendering from app bar
identities,
@@ -236,6 +238,10 @@ App.prototype.renderPrimary = function () {
log.debug('rendering add-token screen from unlock screen.')
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':
log.debug('rendering config screen')
return h(ConfigScreen, {key: 'config'})
diff --git a/test/unit/app/controllers/preferences-controller-test.js b/test/unit/app/controllers/preferences-controller-test.js
index 9b2c846bd..58fc3d9c5 100644
--- a/test/unit/app/controllers/preferences-controller-test.js
+++ b/test/unit/app/controllers/preferences-controller-test.js
@@ -1,6 +1,7 @@
const assert = require('assert')
const ObservableStore = require('obs-store')
const PreferencesController = require('../../../../app/scripts/controllers/preferences')
+const sinon = require('sinon')
describe('preferences controller', function () {
let preferencesController
@@ -339,5 +340,114 @@ describe('preferences controller', function () {
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('should 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 imageUrl = 'someimageurl'
+ req.params.options = { address, symbol, decimals, imageUrl }
+
+ 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].imageUrl, imageUrl, 'set imageUrl correctly')
+ })
+
+ it('should add token correctly if user confirms', async function () {
+ const address = '0xabcdef1234567'
+ const symbol = 'ABBR'
+ const decimals = 5
+ const imageUrl = 'someimageurl'
+ req.params.options = { address, symbol, decimals, imageUrl }
+
+ sandbox.stub(preferencesController, '_validateERC20AssetParams').returns(true)
+ preferencesController.showWatchAssetUi = async () => {
+ await preferencesController.addToken(address, symbol, decimals, imageUrl)
+ }
+
+ 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 imageurl correctly`)
+ })
+ })
})
diff --git a/ui/app/actions.js b/ui/app/actions.js
index 6bcc64e17..b5f97d374 100644
--- a/ui/app/actions.js
+++ b/ui/app/actions.js
@@ -227,11 +227,14 @@ var actions = {
SET_PROVIDER_TYPE: 'SET_PROVIDER_TYPE',
showConfigPage,
SHOW_ADD_TOKEN_PAGE: 'SHOW_ADD_TOKEN_PAGE',
+ SHOW_ADD_SUGGESTED_TOKEN_PAGE: 'SHOW_ADD_SUGGESTED_TOKEN_PAGE',
showAddTokenPage,
+ showAddSuggestedTokenPage,
addToken,
addTokens,
removeToken,
updateTokens,
+ removeSuggestedTokens,
UPDATE_TOKENS: 'UPDATE_TOKENS',
setRpcTarget: setRpcTarget,
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, imageUrl) {
return (dispatch) => {
dispatch(actions.showLoadingIndication())
return new Promise((resolve, reject) => {
- background.addToken(address, symbol, decimals, (err, tokens) => {
+ background.addToken(address, symbol, decimals, imageUrl, (err, tokens) => {
dispatch(actions.hideLoadingIndication())
if (err) {
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) {
return {
type: actions.UPDATE_TOKENS,
@@ -1650,6 +1681,12 @@ function updateTokens (newTokens) {
}
}
+function clearPendingTokens () {
+ return {
+ type: actions.CLEAR_PENDING_TOKENS,
+ }
+}
+
function goBackToInitView () {
return {
type: actions.BACK_TO_INIT_MENU,
@@ -2310,9 +2347,3 @@ function setPendingTokens (pendingTokens) {
payload: tokens,
}
}
-
-function clearPendingTokens () {
- return {
- type: actions.CLEAR_PENDING_TOKENS,
- }
-}
diff --git a/ui/app/app.js b/ui/app/app.js
index 4fcf092ca..cdda44d40 100644
--- a/ui/app/app.js
+++ b/ui/app/app.js
@@ -26,6 +26,7 @@ const RestoreVaultPage = require('./components/pages/keychains/restore-vault').d
const RevealSeedConfirmation = require('./components/pages/keychains/reveal-seed')
const AddTokenPage = require('./components/pages/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 NoticeScreen = require('./components/pages/notice')
@@ -51,6 +52,7 @@ const {
RESTORE_VAULT_ROUTE,
ADD_TOKEN_ROUTE,
CONFIRM_ADD_TOKEN_ROUTE,
+ CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE,
NEW_ACCOUNT_ROUTE,
SEND_ROUTE,
CONFIRM_TRANSACTION_ROUTE,
@@ -85,6 +87,7 @@ class App extends Component {
h(Authenticated, { path: SEND_ROUTE, exact, component: SendTransactionScreen }),
h(Authenticated, { path: ADD_TOKEN_ROUTE, exact, component: AddTokenPage }),
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: DEFAULT_ROUTE, exact, component: Home }),
])
diff --git a/ui/app/components/balance-component.js b/ui/app/components/balance-component.js
index e31552f2d..9af27f4ec 100644
--- a/ui/app/components/balance-component.js
+++ b/ui/app/components/balance-component.js
@@ -22,6 +22,7 @@ function mapStateToProps (state) {
network,
conversionRate: state.metamask.conversionRate,
currentCurrency: state.metamask.currentCurrency,
+ assetImages: state.metamask.assetImages,
}
}
@@ -32,7 +33,9 @@ function BalanceComponent () {
BalanceComponent.prototype.render = function () {
const props = this.props
- const { token, network } = props
+ const { token, network, assetImages } = props
+ let imageUrl
+ if (token) imageUrl = assetImages[token.address]
return h('div.balance-container', {}, [
@@ -45,6 +48,7 @@ BalanceComponent.prototype.render = function () {
diameter: 50,
address: token && token.address,
network,
+ imageUrl,
}),
token ? this.renderTokenBalance() : this.renderBalance(),
diff --git a/ui/app/components/identicon.js b/ui/app/components/identicon.js
index 424048745..6b632352f 100644
--- a/ui/app/components/identicon.js
+++ b/ui/app/components/identicon.js
@@ -26,36 +26,42 @@ function mapStateToProps (state) {
IdenticonComponent.prototype.render = function () {
var props = this.props
- const { className = '', address } = props
+ const { className = '', address, imageUrl } = props
var diameter = props.diameter || this.defaultDiameter
-
- return address
- ? (
- h('div', {
- className: `${className} identicon`,
- key: 'identicon-' + address,
- style: {
- display: 'flex',
- flexShrink: 0,
- alignItems: 'center',
- justifyContent: 'center',
- height: diameter,
- width: diameter,
- borderRadius: diameter / 2,
- overflow: 'hidden',
- },
- })
- )
- : (
- h('img.balance-icon', {
- src: './images/eth_logo.svg',
- style: {
- height: diameter,
- width: diameter,
- borderRadius: diameter / 2,
- },
- })
- )
+ const style = {
+ height: diameter,
+ width: diameter,
+ borderRadius: diameter / 2,
+ }
+ if (imageUrl) {
+ return h('img', {
+ className: `${className} identicon`,
+ src: imageUrl,
+ style: {
+ ...style,
+ },
+ })
+ } else if (address) {
+ return h('div', {
+ className: `${className} identicon`,
+ key: 'identicon-' + address,
+ style: {
+ display: 'flex',
+ flexShrink: 0,
+ alignItems: 'center',
+ justifyContent: 'center',
+ ...style,
+ overflow: 'hidden',
+ },
+ })
+ } else {
+ return h('img.balance-icon', {
+ src: './images/eth_logo.svg',
+ style: {
+ ...style,
+ },
+ })
+ }
}
IdenticonComponent.prototype.componentDidMount = function () {
diff --git a/ui/app/components/modals/hide-token-confirmation-modal.js b/ui/app/components/modals/hide-token-confirmation-modal.js
index 1518fa9a0..bdecc0593 100644
--- a/ui/app/components/modals/hide-token-confirmation-modal.js
+++ b/ui/app/components/modals/hide-token-confirmation-modal.js
@@ -10,6 +10,7 @@ function mapStateToProps (state) {
return {
network: state.metamask.network,
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 () {
- const { token, network, hideToken, hideModal } = this.props
+ const { token, network, hideToken, hideModal, assetImages } = this.props
const { symbol, address } = token
+ const imageUrl = assetImages[address]
return h('div.hide-token-confirmation', {}, [
h('div.hide-token-confirmation__container', {
@@ -55,6 +57,7 @@ HideTokenConfirmationModal.prototype.render = function () {
diameter: 45,
address,
network,
+ imageUrl,
}),
h('div.hide-token-confirmation__symbol', {}, symbol),
diff --git a/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js b/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js
new file mode 100644
index 000000000..37d9aca02
--- /dev/null
+++ b/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.component.js
@@ -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 '../confirm-add-token/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, imageUrl } = 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}
+ imageUrl={imageUrl}
+ />
+ <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>
+ )
+ }
+}
diff --git a/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js b/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js
new file mode 100644
index 000000000..89291ff4f
--- /dev/null
+++ b/ui/app/components/pages/confirm-add-suggested-token/confirm-add-suggested-token.container.js
@@ -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, imageUrl}) => dispatch(addToken(address, symbol, decimals, imageUrl)),
+ removeSuggestedTokens: () => dispatch(removeSuggestedTokens()),
+ }
+}
+
+export default compose(
+ withRouter,
+ connect(mapStateToProps, mapDispatchToProps)
+)(ConfirmAddSuggestedToken)
diff --git a/ui/app/components/pages/confirm-add-suggested-token/index.js b/ui/app/components/pages/confirm-add-suggested-token/index.js
new file mode 100644
index 000000000..2ca56b43c
--- /dev/null
+++ b/ui/app/components/pages/confirm-add-suggested-token/index.js
@@ -0,0 +1,2 @@
+import ConfirmAddSuggestedToken from './confirm-add-suggested-token.container'
+module.exports = ConfirmAddSuggestedToken
diff --git a/ui/app/components/pages/home.js b/ui/app/components/pages/home.js
index 5e3fdc9af..6ee083579 100644
--- a/ui/app/components/pages/home.js
+++ b/ui/app/components/pages/home.js
@@ -25,6 +25,7 @@ const {
RESTORE_VAULT_ROUTE,
CONFIRM_TRANSACTION_ROUTE,
NOTICE_ROUTE,
+ CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE,
} = require('../../routes')
const { unconfirmedTransactionsCountSelector } = require('../../selectors/confirm-transaction')
@@ -33,9 +34,15 @@ class Home extends Component {
componentDidMount () {
const {
history,
+ suggestedTokens = {},
unconfirmedTransactionsCount = 0,
} = this.props
+ // suggested new tokens
+ if (Object.keys(suggestedTokens).length > 0) {
+ history.push(CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE)
+ }
+
// unapprovedTxs and unapproved messages
if (unconfirmedTransactionsCount > 0) {
history.push(CONFIRM_TRANSACTION_ROUTE)
@@ -165,6 +172,7 @@ Home.propTypes = {
isPopup: PropTypes.bool,
isMouseUser: PropTypes.bool,
t: PropTypes.func,
+ suggestedTokens: PropTypes.object,
unconfirmedTransactionsCount: PropTypes.number,
}
@@ -226,7 +234,7 @@ function mapStateToProps (state) {
isRevealingSeedWords: state.metamask.isRevealingSeedWords,
Qr: state.appState.Qr,
welcomeScreenSeen: state.metamask.welcomeScreenSeen,
-
+ suggestedTokens: state.metamask.suggestedTokens,
// state needed to get account dropdown temporarily rendering from app bar
selected,
unconfirmedTransactionsCount: unconfirmedTransactionsCountSelector(state),
diff --git a/ui/app/components/token-cell.js b/ui/app/components/token-cell.js
index 4100d76a5..a84d8eda0 100644
--- a/ui/app/components/token-cell.js
+++ b/ui/app/components/token-cell.js
@@ -56,8 +56,8 @@ TokenCell.prototype.render = function () {
sidebarOpen,
currentCurrency,
// userAddress,
+ imageUrl,
} = props
-
let currentTokenToFiatRate
let currentTokenInFiat
let formattedFiat = ''
@@ -97,6 +97,7 @@ TokenCell.prototype.render = function () {
diameter: 50,
address,
network,
+ imageUrl,
}),
h('div.token-list-item__balance-ellipsis', null, [
diff --git a/ui/app/components/token-list.js b/ui/app/components/token-list.js
index 42351cf89..907793026 100644
--- a/ui/app/components/token-list.js
+++ b/ui/app/components/token-list.js
@@ -13,6 +13,7 @@ function mapStateToProps (state) {
network: state.metamask.network,
tokens: state.metamask.tokens,
userAddress: selectors.getSelectedAddress(state),
+ assetImages: state.metamask.assetImages,
}
}
@@ -44,10 +45,9 @@ function TokenList () {
}
TokenList.prototype.render = function () {
- const { userAddress } = this.props
+ const { userAddress, assetImages } = this.props
const state = this.state
const { tokens, isLoading, error } = state
-
if (isLoading) {
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.imageUrl = assetImages[tokenData.address]
+ return h(TokenCell, tokenData)
+ }))
}
diff --git a/ui/app/reducers/app.js b/ui/app/reducers/app.js
index c246e7904..7be9b8d40 100644
--- a/ui/app/reducers/app.js
+++ b/ui/app/reducers/app.js
@@ -209,6 +209,15 @@ function reduceApp (state, action) {
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:
return extend(appState, {
currentView: {
diff --git a/ui/app/routes.js b/ui/app/routes.js
index f6b2a7a55..76afed5db 100644
--- a/ui/app/routes.js
+++ b/ui/app/routes.js
@@ -7,6 +7,7 @@ const CONFIRM_SEED_ROUTE = '/confirm-seed'
const RESTORE_VAULT_ROUTE = '/restore-vault'
const ADD_TOKEN_ROUTE = '/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 IMPORT_ACCOUNT_ROUTE = '/new-account/import'
const CONNECT_HARDWARE_ROUTE = '/new-account/connect'
@@ -41,6 +42,7 @@ module.exports = {
RESTORE_VAULT_ROUTE,
ADD_TOKEN_ROUTE,
CONFIRM_ADD_TOKEN_ROUTE,
+ CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE,
NEW_ACCOUNT_ROUTE,
IMPORT_ACCOUNT_ROUTE,
CONNECT_HARDWARE_ROUTE,