diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d8a814b9..7427cf3b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - No longer stop rebroadcasting transactions - Add list of popular tokens held to the account detail view. +- Add ability to add Tokens to token list. - Add a warning to JSON file import. - Fix bug where slowly mined txs would sometimes be incorrectly marked as failed. - Fix bug where badge count did not reflect personal_sign pending messages. diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js index aa8e05fcc..e45224593 100644 --- a/app/scripts/controllers/preferences.js +++ b/app/scripts/controllers/preferences.js @@ -8,13 +8,11 @@ class PreferencesController { const initState = extend({ frequentRpcList: [], currentAccountTab: 'history', + tokens: [], }, opts.initState) this.store = new ObservableStore(initState) } - - // - // PUBLIC METHODS - // +// PUBLIC METHODS setSelectedAddress (_address) { return new Promise((resolve, reject) => { @@ -28,6 +26,29 @@ class PreferencesController { return this.store.getState().selectedAddress } + addToken (rawAddress, symbol, decimals) { + const address = normalizeAddress(rawAddress) + const newEntry = { address, symbol, decimals } + + const tokens = this.store.getState().tokens + const previousIndex = tokens.find((token, index) => { + return token.address === address + }) + + if (previousIndex) { + tokens[previousIndex] = newEntry + } else { + tokens.push(newEntry) + } + + this.store.updateState({ tokens }) + return Promise.resolve() + } + + getTokens () { + return this.store.getState().tokens + } + updateFrequentRpcList (_url) { return this.addToFrequentRpcList(_url) .then((rpcList) => { diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 1a83c70f5..782641b3f 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -293,6 +293,7 @@ module.exports = class MetamaskController extends EventEmitter { // PreferencesController setSelectedAddress: nodeify(preferencesController.setSelectedAddress).bind(preferencesController), + addToken: nodeify(preferencesController.addToken).bind(preferencesController), setCurrentAccountTab: nodeify(preferencesController.setCurrentAccountTab).bind(preferencesController), setDefaultRpc: nodeify(this.setDefaultRpc).bind(this), setCustomRpc: nodeify(this.setCustomRpc).bind(this), diff --git a/ui/app/account-detail.js b/ui/app/account-detail.js index 836032b3c..bed05a7fb 100644 --- a/ui/app/account-detail.js +++ b/ui/app/account-detail.js @@ -35,6 +35,7 @@ function mapStateToProps (state) { conversionRate: state.metamask.conversionRate, currentCurrency: state.metamask.currentCurrency, currentAccountTab: state.metamask.currentAccountTab, + tokens: state.metamask.tokens, } } @@ -273,11 +274,16 @@ AccountDetailScreen.prototype.tabSections = function () { AccountDetailScreen.prototype.tabSwitchView = function () { const props = this.props const { address, network } = props - const { currentAccountTab } = this.props + const { currentAccountTab, tokens } = this.props switch (currentAccountTab) { case 'tokens': - return h(TokenList, { userAddress: address, network }) + return h(TokenList, { + userAddress: address, + network, + tokens, + addToken: () => this.props.dispatch(actions.showAddTokenPage()), + }) default: return this.transactionList() } diff --git a/ui/app/actions.js b/ui/app/actions.js index b6b5d6eb1..d99291e46 100644 --- a/ui/app/actions.js +++ b/ui/app/actions.js @@ -121,7 +121,10 @@ var actions = { SET_PROVIDER_TYPE: 'SET_PROVIDER_TYPE', USE_ETHERSCAN_PROVIDER: 'USE_ETHERSCAN_PROVIDER', useEtherscanProvider: useEtherscanProvider, - showConfigPage: showConfigPage, + showConfigPage, + SHOW_ADD_TOKEN_PAGE: 'SHOW_ADD_TOKEN_PAGE', + showAddTokenPage, + addToken, setRpcTarget: setRpcTarget, setDefaultRpcTarget: setDefaultRpcTarget, setProviderType: setProviderType, @@ -627,6 +630,28 @@ function showConfigPage (transitionForward = true) { } } +function showAddTokenPage (transitionForward = true) { + return { + type: actions.SHOW_ADD_TOKEN_PAGE, + value: transitionForward, + } +} + +function addToken (address, symbol, decimals) { + return (dispatch) => { + dispatch(actions.showLoadingIndication()) + background.addToken(address, symbol, decimals, (err) => { + dispatch(actions.hideLoadingIndication()) + if (err) { + return dispatch(actions.displayWarning(err.message)) + } + setTimeout(() => { + dispatch(actions.goHome()) + }, 250) + }) + } +} + function goBackToInitView () { return { type: actions.BACK_TO_INIT_MENU, diff --git a/ui/app/add-token.js b/ui/app/add-token.js new file mode 100644 index 000000000..b303b5c0d --- /dev/null +++ b/ui/app/add-token.js @@ -0,0 +1,219 @@ +const inherits = require('util').inherits +const Component = require('react').Component +const h = require('react-hyperscript') +const connect = require('react-redux').connect +const actions = require('./actions') + +const ethUtil = require('ethereumjs-util') +const abi = require('human-standard-token-abi') +const Eth = require('ethjs-query') +const EthContract = require('ethjs-contract') + +const emptyAddr = '0x0000000000000000000000000000000000000000' + +module.exports = connect(mapStateToProps)(AddTokenScreen) + +function mapStateToProps (state) { + return { + } +} + +inherits(AddTokenScreen, Component) +function AddTokenScreen () { + this.state = { + warning: null, + address: null, + symbol: 'TOKEN', + decimals: 18, + } + Component.call(this) +} + +AddTokenScreen.prototype.render = function () { + const state = this.state + const props = this.props + const { warning, symbol, decimals } = state + + return ( + h('.flex-column.flex-grow', [ + + // subtitle and nav + h('.section-title.flex-row.flex-center', [ + h('i.fa.fa-arrow-left.fa-lg.cursor-pointer', { + onClick: (event) => { + props.dispatch(actions.goHome()) + }, + }), + h('h2.page-subtitle', 'Add 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('span', { + style: { fontWeight: 'bold', paddingRight: '10px'}, + }, 'Token Address'), + ]), + + h('section.flex-row.flex-center', [ + h('input#token-address', { + name: 'address', + placeholder: 'Token Address', + onChange: this.tokenAddressDidChange.bind(this), + style: { + width: 'inherit', + flex: '1 0 auto', + height: '30px', + margin: '8px', + }, + }), + ]), + + h('div', [ + h('span', { + style: { fontWeight: 'bold', paddingRight: '10px'}, + }, 'Token Sybmol'), + ]), + + h('div', { style: {display: 'flex'} }, [ + h('input#token_symbol', { + placeholder: `Like "ETH"`, + value: symbol, + style: { + width: 'inherit', + flex: '1 0 auto', + height: '30px', + margin: '8px', + }, + onChange: (event) => { + var element = event.target + var symbol = element.value + this.setState({ symbol }) + }, + }), + ]), + + h('div', [ + h('span', { + style: { fontWeight: 'bold', paddingRight: '10px'}, + }, 'Decimals of Precision'), + ]), + + h('div', { style: {display: 'flex'} }, [ + h('input#token_decimals', { + value: decimals, + type: 'number', + min: 0, + max: 36, + style: { + width: 'inherit', + flex: '1 0 auto', + height: '30px', + margin: '8px', + }, + onChange: (event) => { + var element = event.target + var decimals = element.value.trim() + this.setState({ decimals }) + }, + }), + ]), + + h('button', { + style: { + alignSelf: 'center', + }, + onClick: (event) => { + const valid = this.validateInputs() + if (!valid) return + + const { address, symbol, decimals } = this.state + this.props.dispatch(actions.addToken(address.trim(), symbol.trim(), decimals)) + }, + }, 'Add'), + ]), + ]), + ]) + ) +} + +AddTokenScreen.prototype.componentWillMount = function () { + if (typeof global.ethereumProvider === 'undefined') return + + this.eth = new Eth(global.ethereumProvider) + this.contract = new EthContract(this.eth) + this.TokenContract = this.contract(abi) +} + +AddTokenScreen.prototype.tokenAddressDidChange = function (event) { + const el = event.target + const address = el.value.trim() + if (ethUtil.isValidAddress(address) && address !== emptyAddr) { + this.setState({ address }) + this.attemptToAutoFillTokenParams(address) + } +} + +AddTokenScreen.prototype.validateInputs = function () { + let msg = '' + const state = this.state + const { address, symbol, decimals } = state + + 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 isValid = validAddress && validDecimals + + if (!isValid) { + this.setState({ + warning: msg, + }) + } else { + this.setState({ warning: null }) + } + + return isValid +} + +AddTokenScreen.prototype.attemptToAutoFillTokenParams = async function (address) { + const contract = this.TokenContract.at(address) + + const results = await Promise.all([ + contract.symbol(), + contract.decimals(), + ]) + + const [ symbol, decimals ] = results + if (symbol && decimals) { + console.log('SETTING SYMBOL AND DECIMALS', { symbol, decimals }) + this.setState({ symbol: symbol[0], decimals: decimals[0].toString() }) + } +} + diff --git a/ui/app/app.js b/ui/app/app.js index d444a8349..8bf69b5ad 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -19,6 +19,7 @@ const NoticeScreen = require('./components/notice') const generateLostAccountsNotice = require('../lib/lost-accounts-notice') // other views const ConfigScreen = require('./config') +const AddTokenScreen = require('./add-token') const Import = require('./accounts/import') const InfoScreen = require('./info') const Loading = require('./components/loading') @@ -458,6 +459,10 @@ App.prototype.renderPrimary = function () { log.debug('rendering confirm tx screen') return h(ConfirmTxScreen, {key: 'confirm-tx'}) + case 'add-token': + log.debug('rendering add-token screen from unlock screen.') + return h(AddTokenScreen, {key: 'add-token'}) + case 'config': log.debug('rendering config screen') return h(ConfigScreen, {key: 'config'}) diff --git a/ui/app/components/token-cell.js b/ui/app/components/token-cell.js index d3a895d36..4d2cacb01 100644 --- a/ui/app/components/token-cell.js +++ b/ui/app/components/token-cell.js @@ -2,6 +2,7 @@ const Component = require('react').Component const h = require('react-hyperscript') const inherits = require('util').inherits const Identicon = require('./identicon') +const prefixForNetwork = require('../../lib/etherscan-prefix-for-network') module.exports = TokenCell @@ -41,6 +42,7 @@ function navigateTo (url) { } function urlFor (tokenAddress, address, network) { - return `https://etherscan.io/token/${tokenAddress}?a=${address}` + const prefix = prefixForNetwork(network) + return `https://${prefix}etherscan.io/token/${tokenAddress}?a=${address}` } diff --git a/ui/app/components/token-list.js b/ui/app/components/token-list.js index 633d3ccfe..ac7ab8309 100644 --- a/ui/app/components/token-list.js +++ b/ui/app/components/token-list.js @@ -4,13 +4,14 @@ const inherits = require('util').inherits const TokenTracker = require('eth-token-tracker') const TokenCell = require('./token-cell.js') const contracts = require('eth-contract-metadata') +const normalizeAddress = require('eth-sig-util').normalize -const tokens = [] +const defaultTokens = [] for (const address in contracts) { const contract = contracts[address] if (contract.erc20) { contract.address = address - tokens.push(contract) + defaultTokens.push(contract) } } @@ -18,15 +19,18 @@ module.exports = TokenList inherits(TokenList, Component) function TokenList () { - this.state = { tokens, isLoading: true, network: null } + this.state = { + tokens: [], + isLoading: true, + network: null, + } Component.call(this) } TokenList.prototype.render = function () { const state = this.state const { tokens, isLoading, error } = state - - const { userAddress } = this.props + const { userAddress, network } = this.props if (isLoading) { return this.message('Loading') @@ -37,40 +41,65 @@ TokenList.prototype.render = function () { return this.message('There was a problem loading your token balances.') } - const network = this.props.network - const tokenViews = tokens.map((tokenData) => { tokenData.network = network tokenData.userAddress = userAddress return h(TokenCell, tokenData) }) - return ( + return h('div', [ h('ol', { style: { - height: '302px', + height: '260px', overflowY: 'auto', + display: 'flex', + flexDirection: 'column', }, - }, [h('style', ` - - li.token-cell { - display: flex; - flex-direction: row; - align-items: center; - padding: 10px; - } - - li.token-cell > h3 { - margin-left: 12px; - } - - li.token-cell:hover { - background: white; - cursor: pointer; - } + }, [ + h('style', ` + + li.token-cell { + display: flex; + flex-direction: row; + align-items: center; + padding: 10px; + } + + li.token-cell > h3 { + margin-left: 12px; + } + + li.token-cell:hover { + background: white; + cursor: pointer; + } + + `), + ...tokenViews, + tokenViews.length ? null : this.message('No Tokens Found.'), + ]), + this.addTokenButtonElement(), + ]) +} - `)].concat(tokenViews.length ? tokenViews : this.message('No Tokens Found.'))) - ) +TokenList.prototype.addTokenButtonElement = function () { + return h('div', [ + h('div.footer.hover-white.pointer', { + key: 'reveal-account-bar', + onClick: () => { + this.props.addToken() + }, + style: { + display: 'flex', + height: '40px', + padding: '10px', + justifyContent: 'center', + alignItems: 'center', + }, + }, [ + h('i.fa.fa-plus.fa-lg'), + ]), + ]) } TokenList.prototype.message = function (body) { @@ -101,7 +130,7 @@ TokenList.prototype.createFreshTokenTracker = function () { this.tracker = new TokenTracker({ userAddress, provider: global.ethereumProvider, - tokens: tokens, + tokens: uniqueMergeTokens(defaultTokens, this.props.tokens), pollingInterval: 8000, }) @@ -135,8 +164,10 @@ TokenList.prototype.componentWillUpdate = function (nextProps) { } } -TokenList.prototype.updateBalances = function (tokenData) { - const heldTokens = tokenData.filter(token => token.balance !== '0' && token.string !== '0.000') +TokenList.prototype.updateBalances = function (tokens) { + const heldTokens = tokens.filter(token => { + return token.balance !== '0' && token.string !== '0.000' + }) this.setState({ tokens: heldTokens, isLoading: false }) } @@ -145,3 +176,16 @@ TokenList.prototype.componentWillUnmount = function () { this.tracker.stop() } +function uniqueMergeTokens (tokensA, tokensB) { + const uniqueAddresses = [] + const result = [] + tokensA.concat(tokensB).forEach((token) => { + const normal = normalizeAddress(token.address) + if (!uniqueAddresses.includes(normal)) { + uniqueAddresses.push(normal) + result.push(token) + } + }) + return result +} + diff --git a/ui/app/reducers/app.js b/ui/app/reducers/app.js index deacad0a7..2fcc9bfe0 100644 --- a/ui/app/reducers/app.js +++ b/ui/app/reducers/app.js @@ -103,7 +103,17 @@ function reduceApp (state, action) { transForward: action.value, }) + case actions.SHOW_ADD_TOKEN_PAGE: + return extend(appState, { + currentView: { + name: 'add-token', + context: appState.currentView.context, + }, + transForward: action.value, + }) + case actions.SHOW_IMPORT_PAGE: + return extend(appState, { currentView: { name: 'import-menu', diff --git a/ui/lib/etherscan-prefix-for-network.js b/ui/lib/etherscan-prefix-for-network.js new file mode 100644 index 000000000..2c1904f1c --- /dev/null +++ b/ui/lib/etherscan-prefix-for-network.js @@ -0,0 +1,21 @@ +module.exports = function (network) { + const net = parseInt(network) + let prefix + switch (net) { + case 1: // main net + prefix = '' + break + case 3: // ropsten test net + prefix = 'ropsten.' + break + case 4: // rinkeby test net + prefix = 'rinkeby.' + break + case 42: // kovan test net + prefix = 'kovan.' + break + default: + prefix = '' + } + return prefix +} diff --git a/ui/lib/explorer-link.js b/ui/lib/explorer-link.js index e11249551..3b82ecd5f 100644 --- a/ui/lib/explorer-link.js +++ b/ui/lib/explorer-link.js @@ -1,21 +1,6 @@ +const prefixForNetwork = require('./etherscan-prefix-for-network') + module.exports = function (hash, network) { - const net = parseInt(network) - let prefix - switch (net) { - case 1: // main net - prefix = '' - break - case 3: // ropsten test net - prefix = 'ropsten.' - break - case 4: // rinkeby test net - prefix = 'rinkeby.' - break - case 42: // kovan test net - prefix = 'kovan.' - break - default: - prefix = '' - } + const prefix = prefixForNetwork(network) return `http://${prefix}etherscan.io/tx/${hash}` }