diff options
author | frankiebee <frankie.diamond@gmail.com> | 2017-07-12 03:18:44 +0800 |
---|---|---|
committer | frankiebee <frankie.diamond@gmail.com> | 2017-07-12 03:18:44 +0800 |
commit | a670e54973ed1bae20455507a4b3c44e231ba822 (patch) | |
tree | 00300ea3362b7b48be26d6547f89ed4c5bfac597 | |
parent | c121ac21ec3bed0381e36de7ead1b583a3da148c (diff) | |
parent | f5de16c91174fbbf208e5aef8f542d3bbbb3cb93 (diff) | |
download | tangerine-wallet-browser-a670e54973ed1bae20455507a4b3c44e231ba822.tar tangerine-wallet-browser-a670e54973ed1bae20455507a4b3c44e231ba822.tar.gz tangerine-wallet-browser-a670e54973ed1bae20455507a4b3c44e231ba822.tar.bz2 tangerine-wallet-browser-a670e54973ed1bae20455507a4b3c44e231ba822.tar.lz tangerine-wallet-browser-a670e54973ed1bae20455507a4b3c44e231ba822.tar.xz tangerine-wallet-browser-a670e54973ed1bae20455507a4b3c44e231ba822.tar.zst tangerine-wallet-browser-a670e54973ed1bae20455507a4b3c44e231ba822.zip |
Merge branch 'nonce-tracker' of https://github.com/MetaMask/metamask-plugin into nonce-tracker
-rw-r--r-- | CHANGELOG.md | 27 | ||||
-rw-r--r-- | app/manifest.json | 2 | ||||
-rw-r--r-- | app/scripts/controllers/preferences.js | 29 | ||||
-rw-r--r-- | app/scripts/controllers/transactions.js | 65 | ||||
-rw-r--r-- | app/scripts/metamask-controller.js | 12 | ||||
-rw-r--r-- | app/scripts/migrations/015.js | 38 | ||||
-rw-r--r-- | app/scripts/migrations/index.js | 1 | ||||
-rw-r--r-- | package.json | 12 | ||||
-rw-r--r-- | test/unit/tx-controller-test.js | 49 | ||||
-rw-r--r-- | ui/app/account-detail.js | 10 | ||||
-rw-r--r-- | ui/app/accounts/import/index.js | 9 | ||||
-rw-r--r-- | ui/app/actions.js | 27 | ||||
-rw-r--r-- | ui/app/add-token.js | 219 | ||||
-rw-r--r-- | ui/app/app.js | 54 | ||||
-rw-r--r-- | ui/app/components/ens-input.js | 2 | ||||
-rw-r--r-- | ui/app/components/network.js | 23 | ||||
-rw-r--r-- | ui/app/components/pending-tx.js | 2 | ||||
-rw-r--r-- | ui/app/components/token-cell.js | 42 | ||||
-rw-r--r-- | ui/app/components/token-list.js | 109 | ||||
-rw-r--r-- | ui/app/config.js | 28 | ||||
-rw-r--r-- | ui/app/keychains/hd/create-vault-complete.js | 2 | ||||
-rw-r--r-- | ui/app/reducers/app.js | 10 | ||||
-rw-r--r-- | ui/app/send.js | 4 | ||||
-rw-r--r-- | ui/lib/etherscan-prefix-for-network.js | 21 | ||||
-rw-r--r-- | ui/lib/explorer-link.js | 21 |
25 files changed, 689 insertions, 129 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 96dc79d9a..3966ea1bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,40 @@ ## Current Master +## 3.8.3 2017-7-6 + +- Re-enable default token list. +- Add origin header to dapp-bound requests to allow providers to throttle sites. +- Fix bug that could sometimes resubmit a transaction that had been stalled due to low balance after balance was restored. + +## 3.8.2 2017-7-3 + +- No longer show network loading indication on config screen, to allow selecting custom RPCs. +- Visually indicate that network spinner is a menu. +- Indicate what network is being searched for when disconnected. + +## 3.8.1 2017-6-30 + +- Temporarily disabled loading popular tokens by default to improve performance. +- Remove SEND token button until a better token sending form can be built, due to some precision issues. +- Fix precision bug in token balances. +- Cache token symbol and precisions to reduce network load. +- Transpile some newer JavaScript, restores compatibility with some older browsers. + +## 3.8.0 2017-6-28 + +- 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. +- Add "send" link to token list, which goes to TokenFactory. - 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. - Seed word confirmation wording is now scarier. - Fix error for invalid seed words. - Prevent users from submitting two duplicate transactions by disabling submit. +- Allow Dapps to specify gas price as hex string. +- Add button for copying state logs to clipboard. ## 3.7.8 2017-6-12 diff --git a/app/manifest.json b/app/manifest.json index 7ae20158c..aafc33e66 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -1,7 +1,7 @@ { "name": "MetaMask", "short_name": "Metamask", - "version": "3.7.8", + "version": "3.8.3", "manifest_version": 2, "author": "https://metamask.io", "description": "Ethereum Browser Extension", 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/controllers/transactions.js b/app/scripts/controllers/transactions.js index 14b423d5d..fb0219fc3 100644 --- a/app/scripts/controllers/transactions.js +++ b/app/scripts/controllers/transactions.js @@ -8,8 +8,6 @@ const TxProviderUtil = require('../lib/tx-utils') const createId = require('../lib/random-id') const NonceTracker = require('../lib/nonce-tracker') -const RETRY_LIMIT = 200 - module.exports = class TransactionController extends EventEmitter { constructor (opts) { super() @@ -36,7 +34,10 @@ module.exports = class TransactionController extends EventEmitter { this.query = opts.ethQuery this.txProviderUtils = new TxProviderUtil(this.query) this.blockTracker.on('rawBlock', this.checkForTxInBlock.bind(this)) - this.blockTracker.on('latest', this.resubmitPendingTxs.bind(this)) + // this is a little messy but until ethstore has been either + // removed or redone this is to guard against the race condition + // where ethStore hasent been populated by the results yet + this.blockTracker.once('latest', () => this.blockTracker.on('latest', this.resubmitPendingTxs.bind(this))) this.blockTracker.on('sync', this.queryPendingTxs.bind(this)) this.signEthTx = opts.signTransaction this.ethStore = opts.ethStore @@ -162,13 +163,15 @@ module.exports = class TransactionController extends EventEmitter { const txParams = txMeta.txParams // ensure value txParams.value = txParams.value || '0x0' - this.query.gasPrice((err, gasPrice) => { - if (err) return cb(err) - // set gasPrice - txParams.gasPrice = gasPrice - // set gasLimit - this.txProviderUtils.analyzeGasUsage(txMeta, cb) - }) + if (!txParams.gasPrice) { + this.query.gasPrice((err, gasPrice) => { + if (err) return cb(err) + // set gasPrice + txParams.gasPrice = gasPrice + }) + } + // set gasLimit + this.txProviderUtils.analyzeGasUsage(txMeta, cb) } getUnapprovedTxList () { @@ -429,10 +432,24 @@ module.exports = class TransactionController extends EventEmitter { // only try resubmitting if their are transactions to resubmit if (!pending.length) return const resubmit = denodeify(this._resubmitTx.bind(this)) - Promise.all(pending.map(txMeta => resubmit(txMeta))) + pending.forEach((txMeta) => resubmit(txMeta) .catch((reason) => { - log.info('Problem resubmitting tx', reason) - }) + /* + Dont marked as failed if the error is a "known" transaction warning + "there is already a transaction with the same sender-nonce + but higher/same gas price" + */ + const errorMessage = reason.message.toLowerCase() + const isKnownTx = ( + // geth + errorMessage === 'replacement transaction underpriced' + || errorMessage.startsWith('known transaction') + // parity + || errorMessage === 'gas price too low to replace' + ) + // ignore resubmit warnings, return early + if (!isKnownTx) this.setTxStatusFailed(txMeta.id, reason.message) + })) } _resubmitTx (txMeta, cb) { @@ -443,15 +460,25 @@ module.exports = class TransactionController extends EventEmitter { const gtBalance = Number.parseInt(txMeta.txParams.value) > Number.parseInt(balance) if (!('retryCount' in txMeta)) txMeta.retryCount = 0 - // if the value of the transaction is greater then the balance - // or the nonce of the transaction is lower then the accounts nonce - // dont resubmit the tx - if (gtBalance || txNonce < nonce) return cb() + // if the value of the transaction is greater then the balance, fail. + if (gtBalance) { + const message = 'Insufficient balance.' + this.setTxStatusFailed(txMeta.id, message) + cb() + return log.error(message) + } + + // if the nonce of the transaction is lower then the accounts nonce, fail. + if (txNonce < nonce) { + const message = 'Invalid nonce.' + this.setTxStatusFailed(txMeta.id, message) + cb() + return log.error(message) + } + // Only auto-submit already-signed txs: if (!('rawTx' in txMeta)) return cb() - if (txMeta.retryCount > RETRY_LIMIT) return - // Increment a try counter. txMeta.retryCount++ const rawTx = txMeta.rawTx diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index be64a45c7..573594b39 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -184,7 +184,9 @@ module.exports = class MetamaskController extends EventEmitter { eth_syncing: false, web3_clientVersion: `MetaMask/v${version}`, }, + // rpc data source rpcUrl: this.networkController.getCurrentRpcAddress(), + originHttpHeaderKey: 'X-Metamask-Origin', // account mgmt getAccounts: (cb) => { const isUnlocked = this.keyringController.memStore.getState().isUnlocked @@ -293,6 +295,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), @@ -355,8 +358,13 @@ module.exports = class MetamaskController extends EventEmitter { } setupProviderConnection (outStream, originDomain) { - streamIntoProvider(outStream, this.provider, logger) - function logger (err, request, response) { + streamIntoProvider(outStream, this.provider, onRequest, onResponse) + // append dapp origin domain to request + function onRequest (request) { + request.origin = originDomain + } + // log rpc activity + function onResponse (err, request, response) { if (err) return console.error(err) if (response.error) { console.error('Error in RPC response:\n', response.error) diff --git a/app/scripts/migrations/015.js b/app/scripts/migrations/015.js new file mode 100644 index 000000000..4b839580b --- /dev/null +++ b/app/scripts/migrations/015.js @@ -0,0 +1,38 @@ +const version = 15 + +/* + +This migration sets transactions with the 'Gave up submitting tx.' err message +to a 'failed' stated + +*/ + +const clone = require('clone') + +module.exports = { + version, + + migrate: function (originalVersionedData) { + const versionedData = clone(originalVersionedData) + versionedData.meta.version = version + try { + const state = versionedData.data + const newState = transformState(state) + versionedData.data = newState + } catch (err) { + console.warn(`MetaMask Migration #${version}` + err.stack) + } + return Promise.resolve(versionedData) + }, +} + +function transformState (state) { + const newState = state + const transactions = newState.TransactionController.transactions + newState.TransactionController.transactions = transactions.map((txMeta) => { + if (!txMeta.err) return txMeta + else if (txMeta.err.message === 'Gave up submitting tx.') txMeta.status = 'failed' + return txMeta + }) + return newState +} diff --git a/app/scripts/migrations/index.js b/app/scripts/migrations/index.js index fb1ad7863..651ee6a9c 100644 --- a/app/scripts/migrations/index.js +++ b/app/scripts/migrations/index.js @@ -25,4 +25,5 @@ module.exports = [ require('./012'), require('./013'), require('./014'), + require('./015'), ] diff --git a/package.json b/package.json index 82792351d..16149f7b7 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "eth-query": "^2.1.2", "eth-sig-util": "^1.1.1", "eth-simple-keyring": "^1.1.1", - "eth-token-tracker": "^1.0.9", + "eth-token-tracker": "^1.1.2", "ethereumjs-tx": "^1.3.0", "ethereumjs-util": "ethereumjs/ethereumjs-util#ac5d0908536b447083ea422b435da27f26615de9", "ethereumjs-wallet": "^0.6.0", @@ -104,7 +104,7 @@ "qrcode-npm": "0.0.3", "react": "^15.0.2", "react-addons-css-transition-group": "^15.0.2", - "react-dom": "^15.0.2", + "react-dom": "^15.5.4", "react-hyperscript": "^2.2.2", "react-markdown": "^2.3.0", "react-redux": "^4.4.5", @@ -124,9 +124,9 @@ "through2": "^2.0.1", "valid-url": "^1.0.9", "vreme": "^3.0.2", - "web3": "0.18.2", - "web3-provider-engine": "^13.0.3", - "web3-stream-provider": "^2.0.6", + "web3": "0.19.1", + "web3-provider-engine": "^13.1.1", + "web3-stream-provider": "^3.0.1", "xtend": "^4.0.1" }, "devDependencies": { @@ -142,7 +142,6 @@ "brfs": "^1.4.3", "browserify": "^13.0.0", "chai": "^3.5.0", - "clone": "^1.0.2", "deep-freeze-strict": "^1.1.1", "del": "^2.2.0", "envify": "^4.0.0", @@ -174,7 +173,6 @@ "qs": "^6.2.0", "qunit": "^0.9.1", "react-addons-test-utils": "^15.5.1", - "react-dom": "^15.5.4", "react-test-renderer": "^15.5.4", "react-testutils-additions": "^15.2.0", "sinon": "^1.17.3", diff --git a/test/unit/tx-controller-test.js b/test/unit/tx-controller-test.js index 8ce6a5a65..a5af13915 100644 --- a/test/unit/tx-controller-test.js +++ b/test/unit/tx-controller-test.js @@ -18,9 +18,10 @@ describe('Transaction Controller', function () { txController = new TransactionController({ networkStore: new ObservableStore(currentNetworkId), txHistoryLimit: 10, - blockTracker: { getCurrentBlock: noop, on: noop }, + blockTracker: { getCurrentBlock: noop, on: noop, once: noop }, provider: { sendAsync: noop }, ethQuery: new EthQuery({ sendAsync: noop }), + ethStore: { getState: noop }, signTransaction: (ethTx) => new Promise((resolve) => { ethTx.sign(privKey) resolve() @@ -269,7 +270,7 @@ describe('Transaction Controller', function () { }) - it('does not overwrite set values', function (done) { + it('does not overwrite set values', function () { this.timeout(15000) const wrongValue = '0x05' @@ -288,9 +289,7 @@ describe('Transaction Controller', function () { const pubStub = sinon.stub(txController.txProviderUtils, 'publishTransaction') .callsArgWithAsync(1, null, originalValue) - txController.approveTransaction(txMeta.id).then((err) => { - assert.ifError(err, 'should not error') - + return txController.approveTransaction(txMeta.id).then(() => { const result = txController.getTx(txMeta.id) const params = result.txParams @@ -302,7 +301,6 @@ describe('Transaction Controller', function () { priceStub.restore() signStub.restore() pubStub.restore() - done() }) }) }) @@ -318,4 +316,43 @@ describe('Transaction Controller', function () { }) }) }) + + describe('#_resubmitTx with a too-low balance', function () { + it('should fail the transaction', function (done) { + const from = '0xda0da0' + const txMeta = { + id: 1, + status: 'submitted', + metamaskNetworkId: currentNetworkId, + txParams: { + from, + nonce: '0x1', + value: '0xfffff', + }, + } + + const lowBalance = '0x0' + const fakeStoreState = { accounts: {} } + fakeStoreState.accounts[from] = { + balance: lowBalance, + nonce: '0x0', + } + + // Stubbing out current account state: + const getStateStub = sinon.stub(txController.ethStore, 'getState') + .returns(fakeStoreState) + + // Adding the fake tx: + txController.addTx(clone(txMeta)) + + txController._resubmitTx(txMeta, function (err) { + assert.ifError(err, 'should not throw an error') + const updatedMeta = txController.getTx(txMeta.id) + assert.notEqual(updatedMeta.status, txMeta.status, 'status changed.') + assert.equal(updatedMeta.status, 'failed', 'tx set to failed.') + done() + }) + }) + }) }) + 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/accounts/import/index.js b/ui/app/accounts/import/index.js index a0f0f9bdb..97b387229 100644 --- a/ui/app/accounts/import/index.js +++ b/ui/app/accounts/import/index.js @@ -2,6 +2,7 @@ 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') import Select from 'react-select' // Subviews @@ -37,6 +38,14 @@ AccountImportSubview.prototype.render = function () { style: { }, }, [ + 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', 'Import Accounts'), + ]), h('div', { style: { padding: '10px', 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..15ef7a852 --- /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 Symbol'), + ]), + + 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..1a63002e1 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') @@ -65,9 +66,9 @@ function mapStateToProps (state) { App.prototype.render = function () { var props = this.props const { isLoading, loadingMessage, transForward, network } = props - const isLoadingNetwork = network === 'loading' + const isLoadingNetwork = network === 'loading' && props.currentView.name !== 'config' const loadMessage = loadingMessage || isLoadingNetwork ? - 'Searching for Network' : null + `Connecting to ${this.getNetworkName()}` : null log.debug('Main ui render function') @@ -135,7 +136,7 @@ App.prototype.renderAppBar = function () { }, }, [ - h('div', { + h('div.left-menu-section', { style: { display: 'flex', flexDirection: 'row', @@ -150,21 +151,15 @@ App.prototype.renderAppBar = function () { src: '/images/icon-128.png', }), - h('#network-spacer.flex-center', { - style: { - marginRight: '-72px', + h(NetworkIndicator, { + network: this.props.network, + provider: this.props.provider, + onClick: (event) => { + event.preventDefault() + event.stopPropagation() + this.setState({ isNetworkMenuOpen: !isNetworkMenuOpen }) }, - }, [ - h(NetworkIndicator, { - network: this.props.network, - provider: this.props.provider, - onClick: (event) => { - event.preventDefault() - event.stopPropagation() - this.setState({ isNetworkMenuOpen: !isNetworkMenuOpen }) - }, - }), - ]), + }), ]), // metamask name @@ -458,6 +453,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'}) @@ -550,6 +549,27 @@ App.prototype.renderCustomOption = function (provider) { } } +App.prototype.getNetworkName = function () { + const { provider } = this.props + const providerName = provider.type + + let name + + if (providerName === 'mainnet') { + name = 'Main Ethereum Network' + } else if (providerName === 'ropsten') { + name = 'Ropsten Test Network' + } else if (providerName === 'kovan') { + name = 'Kovan Test Network' + } else if (providerName === 'rinkeby') { + name = 'Rinkeby Test Network' + } else { + name = 'Unknown Private Network' + } + + return name +} + App.prototype.renderCommonRpc = function (rpcList, provider) { const { rpcTarget } = provider const props = this.props diff --git a/ui/app/components/ens-input.js b/ui/app/components/ens-input.js index 16c50db84..3a33ebf74 100644 --- a/ui/app/components/ens-input.js +++ b/ui/app/components/ens-input.js @@ -41,7 +41,6 @@ EnsInput.prototype.render = function () { this.checkName() }, }) - return h('div', { style: { width: '100%' }, }, [ @@ -55,6 +54,7 @@ EnsInput.prototype.render = function () { return h('option', { value: identity.address, label: identity.name, + key: identity.address, }) }), // Corresponds to previously sent-to addresses. diff --git a/ui/app/components/network.js b/ui/app/components/network.js index 31a8fc17c..d5d3e18cd 100644 --- a/ui/app/components/network.js +++ b/ui/app/components/network.js @@ -22,15 +22,24 @@ Network.prototype.render = function () { let iconName, hoverText if (networkNumber === 'loading') { - return h('img.network-indicator', { - title: 'Attempting to connect to blockchain.', - onClick: (event) => this.props.onClick(event), + return h('span', { style: { - width: '27px', - marginRight: '-27px', + display: 'flex', + alignItems: 'center', + flexDirection: 'row', }, - src: 'images/loading.svg', - }) + onClick: (event) => this.props.onClick(event), + }, [ + h('img', { + title: 'Attempting to connect to blockchain.', + style: { + width: '27px', + }, + src: 'images/loading.svg', + }), + h('i.fa.fa-sort-desc'), + ]) + } else if (providerName === 'mainnet') { hoverText = 'Main Ethereum Network' iconName = 'ethereum-network' diff --git a/ui/app/components/pending-tx.js b/ui/app/components/pending-tx.js index f33a5d948..d7d602f31 100644 --- a/ui/app/components/pending-tx.js +++ b/ui/app/components/pending-tx.js @@ -315,7 +315,7 @@ PendingTx.prototype.render = function () { // Accept Button h('input.confirm.btn-green', { type: 'submit', - value: 'ACCEPT', + value: 'SUBMIT', style: { marginLeft: '10px' }, disabled: insufficientBalance || !this.state.valid || !isValidAddress || this.state.submitting, }), diff --git a/ui/app/components/token-cell.js b/ui/app/components/token-cell.js index d3a895d36..19d7139bb 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 @@ -17,12 +18,7 @@ TokenCell.prototype.render = function () { return ( h('li.token-cell', { style: { cursor: network === '1' ? 'pointer' : 'default' }, - onClick: (event) => { - const url = urlFor(address, userAddress, network) - if (url) { - navigateTo(url) - } - }, + onClick: this.view.bind(this, address, userAddress, network), }, [ h(Identicon, { @@ -32,15 +28,45 @@ TokenCell.prototype.render = function () { }), h('h3', `${string || 0} ${symbol}`), + + h('span', { style: { flex: '1 0 auto' } }), + + /* + h('button', { + onClick: this.send.bind(this, address), + }, 'SEND'), + */ + ]) ) } +TokenCell.prototype.send = function (address, event) { + event.preventDefault() + event.stopPropagation() + const url = tokenFactoryFor(address) + if (url) { + navigateTo(url) + } +} + +TokenCell.prototype.view = function (address, userAddress, network, event) { + const url = etherscanLinkFor(address, userAddress, network) + if (url) { + navigateTo(url) + } +} + function navigateTo (url) { global.platform.openWindow({ url }) } -function urlFor (tokenAddress, address, network) { - return `https://etherscan.io/token/${tokenAddress}?a=${address}` +function etherscanLinkFor (tokenAddress, address, network) { + const prefix = prefixForNetwork(network) + return `https://${prefix}etherscan.io/token/${tokenAddress}?a=${address}` +} + +function tokenFactoryFor (tokenAddress) { + return `https://tokenfactory.surge.sh/#/token/${tokenAddress}` } diff --git a/ui/app/components/token-list.js b/ui/app/components/token-list.js index 633d3ccfe..20cfa897e 100644 --- a/ui/app/components/token-list.js +++ b/ui/app/components/token-list.js @@ -3,14 +3,15 @@ const h = require('react-hyperscript') 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 = [] +const contracts = require('eth-contract-metadata') 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) { @@ -80,6 +109,7 @@ TokenList.prototype.message = function (body) { height: '250px', alignItems: 'center', justifyContent: 'center', + padding: '30px', }, }, body) } @@ -101,7 +131,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 +165,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 +177,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/config.js b/ui/app/config.js index d7be26757..62785c49b 100644 --- a/ui/app/config.js +++ b/ui/app/config.js @@ -5,6 +5,7 @@ const connect = require('react-redux').connect const actions = require('./actions') const currencies = require('./conversion.json').rows const validUrl = require('valid-url') +const copyToClipboard = require('copy-to-clipboard') module.exports = connect(mapStateToProps)(ConfigScreen) @@ -85,8 +86,35 @@ ConfigScreen.prototype.render = function () { }, }, 'Save'), ]), + h('hr.horizontal-line'), + currentConversionInformation(metamaskState, state), + + h('hr.horizontal-line'), + + h('div', { + style: { + marginTop: '20px', + }, + }, [ + h('p', { + style: { + fontFamily: 'Montserrat Light', + fontSize: '13px', + }, + }, `State logs contain your public account addresses and sent transactions.`), + h('br'), + h('button', { + style: { + alignSelf: 'center', + }, + onClick (event) { + copyToClipboard(window.logState()) + }, + }, 'Copy State Logs'), + ]), + h('hr.horizontal-line'), h('div', { diff --git a/ui/app/keychains/hd/create-vault-complete.js b/ui/app/keychains/hd/create-vault-complete.js index 9741155f7..a318a9b50 100644 --- a/ui/app/keychains/hd/create-vault-complete.js +++ b/ui/app/keychains/hd/create-vault-complete.js @@ -20,7 +20,7 @@ function mapStateToProps (state) { CreateVaultCompleteScreen.prototype.render = function () { var state = this.props - var seed = state.seed || state.cachedSeed + var seed = state.seed || state.cachedSeed || '' return ( 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/app/send.js b/ui/app/send.js index fd6994145..a21a219eb 100644 --- a/ui/app/send.js +++ b/ui/app/send.js @@ -189,7 +189,7 @@ SendTransactionScreen.prototype.render = function () { style: { textTransform: 'uppercase', }, - }, 'Send'), + }, 'Next'), ]), @@ -244,7 +244,7 @@ SendTransactionScreen.prototype.recipientDidChange = function (recipient, nickna SendTransactionScreen.prototype.onSubmit = function () { const state = this.state || {} - const recipient = state.recipient || document.querySelector('input[name="address"]').value + const recipient = state.recipient || document.querySelector('input[name="address"]').value.replace(/^[.\s]+|[.\s]+$/g, '') const nickname = state.nickname || ' ' const input = document.querySelector('input[name="amount"]').value const value = util.normalizeEthStringToWei(input) 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}` } |