diff --git a/CHANGELOG.md b/CHANGELOG.md index 671bb7a6f..ee9548606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,12 @@ ## Current Master +## 3.12.0 2017-10-25 + - Add support for alternative ENS TLDs (Ethereum Name Service Top-Level Domains). - Lower minimum gas price to 0.1 GWEI. - Remove web3 injection message from production (thanks to @ChainsawBaby) +- Add additional debugging info to our state logs, specifically OS version and browser version. ## 3.11.2 2017-10-21 diff --git a/app/manifest.json b/app/manifest.json index 65d7a4811..ff595c717 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -1,7 +1,7 @@ { "name": "MetaMask", "short_name": "Metamask", - "version": "4.0.3", + "version": "4.0.4", "manifest_version": 2, "author": "https://metamask.io", "description": "Ethereum Browser Extension", diff --git a/app/scripts/controllers/transactions.js b/app/scripts/controllers/transactions.js index ef659a300..a861c0342 100644 --- a/app/scripts/controllers/transactions.js +++ b/app/scripts/controllers/transactions.js @@ -133,7 +133,7 @@ module.exports = class TransactionController extends EventEmitter { async newUnapprovedTransaction (txParams) { log.debug(`MetaMaskController newUnapprovedTransaction ${JSON.stringify(txParams)}`) const txMeta = await this.addUnapprovedTransaction(txParams) - this.emit('newUnaprovedTx', txMeta) + this.emit('newUnapprovedTx', txMeta) // listen for tx completion (success, fail) return new Promise((resolve, reject) => { this.txStateManager.once(`${txMeta.id}:finished`, (completedTx) => { @@ -170,6 +170,7 @@ module.exports = class TransactionController extends EventEmitter { async addTxDefaults (txMeta) { const txParams = txMeta.txParams // ensure value + txMeta.gasPriceSpecified = Boolean(txParams.gasPrice) const gasPrice = txParams.gasPrice || await this.query.gasPrice() txParams.gasPrice = ethUtil.addHexPrefix(gasPrice.toString(16)) txParams.value = txParams.value || '0x0' diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index a8a10e106..bd71da8e0 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -128,7 +128,7 @@ module.exports = class MetamaskController extends EventEmitter { blockTracker: this.blockTracker, ethQuery: this.ethQuery, }) - this.txController.on('newUnaprovedTx', opts.showUnapprovedTx.bind(opts)) + this.txController.on('newUnapprovedTx', opts.showUnapprovedTx.bind(opts)) // computed balances (accounting for pending transactions) this.balancesController = new BalancesController({ diff --git a/app/scripts/platforms/extension.js b/app/scripts/platforms/extension.js index 0afe04b74..2f47512eb 100644 --- a/app/scripts/platforms/extension.js +++ b/app/scripts/platforms/extension.js @@ -17,6 +17,15 @@ class ExtensionPlatform { return extension.runtime.getManifest().version } + getPlatformInfo (cb) { + try { + extension.runtime.getPlatformInfo((platform) => { + cb(null, platform) + }) + } catch (e) { + cb(e) + } + } } module.exports = ExtensionPlatform diff --git a/test/unit/tx-controller-test.js b/test/unit/tx-controller-test.js index bb51ab01f..aeefd5ec6 100644 --- a/test/unit/tx-controller-test.js +++ b/test/unit/tx-controller-test.js @@ -118,8 +118,8 @@ describe('Transaction Controller', function () { stub.restore() }) - it('should emit newUnaprovedTx event and pass txMeta as the first argument', function (done) { - txController.once('newUnaprovedTx', (txMetaFromEmit) => { + it('should emit newUnapprovedTx event and pass txMeta as the first argument', function (done) { + txController.once('newUnapprovedTx', (txMetaFromEmit) => { assert(txMetaFromEmit, 'txMeta is falsey') assert.equal(txMetaFromEmit.id, 1, 'the right txMeta was passed') done() @@ -129,7 +129,7 @@ describe('Transaction Controller', function () { }) it('should resolve when finished and status is submitted and resolve with the hash', function (done) { - txController.once('newUnaprovedTx', (txMetaFromEmit) => { + txController.once('newUnapprovedTx', (txMetaFromEmit) => { setTimeout(() => { txController.setTxHash(txMetaFromEmit.id, '0x0') txController.txStateManager.setTxStatusSubmitted(txMetaFromEmit.id) @@ -145,7 +145,7 @@ describe('Transaction Controller', function () { }) it('should reject when finished and status is rejected', function (done) { - txController.once('newUnaprovedTx', (txMetaFromEmit) => { + txController.once('newUnapprovedTx', (txMetaFromEmit) => { setTimeout(() => { txController.txStateManager.setTxStatusRejected(txMetaFromEmit.id) }, 10) diff --git a/ui/app/accounts/import/index.js b/ui/app/accounts/import/index.js index c66dcfc66..b7d9a9537 100644 --- a/ui/app/accounts/import/index.js +++ b/ui/app/accounts/import/index.js @@ -75,6 +75,7 @@ AccountImportSubview.prototype.render = function () { } }), onChange: (opt) => { + props.dispatch(actions.showImportPage()) this.setState({ type: opt.value }) }, }), diff --git a/ui/app/actions.js b/ui/app/actions.js index 5d3befa63..2ca62c41f 100644 --- a/ui/app/actions.js +++ b/ui/app/actions.js @@ -115,6 +115,7 @@ var actions = { TRANSACTION_ERROR: 'TRANSACTION_ERROR', NEXT_TX: 'NEXT_TX', PREVIOUS_TX: 'PREV_TX', + EDIT_TX: 'EDIT_TX', signMsg: signMsg, cancelMsg: cancelMsg, signPersonalMsg, @@ -129,10 +130,13 @@ var actions = { completedTx: completedTx, txError: txError, nextTx: nextTx, + editTx, previousTx: previousTx, cancelAllTx: cancelAllTx, viewPendingTx: viewPendingTx, VIEW_PENDING_TX: 'VIEW_PENDING_TX', + updateTransactionParams, + UPDATE_TRANSACTION_PARAMS: 'UPDATE_TRANSACTION_PARAMS', // send screen estimateGas, getGasPrice, @@ -140,19 +144,23 @@ var actions = { UPDATE_GAS_PRICE: 'UPDATE_GAS_PRICE', UPDATE_GAS_TOTAL: 'UPDATE_GAS_TOTAL', UPDATE_SEND_FROM: 'UPDATE_SEND_FROM', + UPDATE_SEND_TOKEN_BALANCE: 'UPDATE_SEND_TOKEN_BALANCE', UPDATE_SEND_TO: 'UPDATE_SEND_TO', UPDATE_SEND_AMOUNT: 'UPDATE_SEND_AMOUNT', UPDATE_SEND_MEMO: 'UPDATE_SEND_MEMO', UPDATE_SEND_ERRORS: 'UPDATE_SEND_ERRORS', + UPDATE_SEND: 'UPDATE_SEND', CLEAR_SEND: 'CLEAR_SEND', updateGasLimit, updateGasPrice, updateGasTotal, + updateSendTokenBalance, updateSendFrom, updateSendTo, updateSendAmount, updateSendMemo, updateSendErrors, + updateSend, clearSend, setSelectedAddress, // app messages @@ -584,6 +592,13 @@ function updateGasTotal (gasTotal) { } } +function updateSendTokenBalance (tokenBalance) { + return { + type: actions.UPDATE_SEND_TOKEN_BALANCE, + value: tokenBalance, + } +} + function updateSendFrom (from) { return { type: actions.UPDATE_SEND_FROM, @@ -619,6 +634,13 @@ function updateSendErrors (error) { } } +function updateSend (newSend) { + return { + type: actions.UPDATE_SEND, + value: newSend, + } +} + function clearSend () { return { type: actions.CLEAR_SEND, @@ -659,6 +681,8 @@ function updateAndApproveTx (txData) { log.debug(`actions calling background.updateAndApproveTx`) background.updateAndApproveTransaction(txData, (err) => { dispatch(actions.hideLoadingIndication()) + dispatch(actions.updateTransactionParams(txData.id, txData.txParams)) + dispatch(actions.clearSend()) if (err) { dispatch(actions.txError(err)) dispatch(actions.goHome()) @@ -676,6 +700,14 @@ function completedTx (id) { } } +function updateTransactionParams (id, txParams) { + return { + type: actions.UPDATE_TRANSACTION_PARAMS, + id, + value: txParams, + } +} + function txError (err) { return { type: actions.TRANSACTION_ERROR, @@ -939,6 +971,13 @@ function previousTx () { } } +function editTx (txId) { + return { + type: actions.EDIT_TX, + value: txId, + } +} + function showConfigPage (transitionForward = true) { return { type: actions.SHOW_CONFIG_PAGE, diff --git a/ui/app/components/currency-input.js b/ui/app/components/currency-input.js new file mode 100644 index 000000000..66880091f --- /dev/null +++ b/ui/app/components/currency-input.js @@ -0,0 +1,95 @@ +const Component = require('react').Component +const h = require('react-hyperscript') +const inherits = require('util').inherits + +module.exports = CurrencyInput + +inherits(CurrencyInput, Component) +function CurrencyInput (props) { + Component.call(this) + + this.state = { + value: sanitizeValue(props.value), + } +} + +function removeNonDigits (str) { + return str.match(/\d|$/g).join('') +} + +// Removes characters that are not digits, then removes leading zeros +function sanitizeInteger (val) { + return String(parseInt(removeNonDigits(val) || '0', 10)) +} + +function sanitizeDecimal (val) { + return removeNonDigits(val) +} + +// Take a single string param and returns a non-negative integer or float as a string. +// Breaks the input into three parts: the integer, the decimal point, and the decimal/fractional part. +// Removes leading zeros from the integer, and non-digits from the integer and decimal +// The integer is returned as '0' in cases where it would be empty. A decimal point is +// included in the returned string if one is included in the param +// Examples: +// sanitizeValue('0') -> '0' +// sanitizeValue('a') -> '0' +// sanitizeValue('010.') -> '10.' +// sanitizeValue('0.005') -> '0.005' +// sanitizeValue('22.200') -> '22.200' +// sanitizeValue('.200') -> '0.200' +// sanitizeValue('a.b.1.c,89.123') -> '0.189123' +function sanitizeValue (value) { + let [ , integer, point, decimal] = (/([^.]*)([.]?)([^.]*)/).exec(value) + + integer = sanitizeInteger(integer) || '0' + decimal = sanitizeDecimal(decimal) + + return `${integer}${point}${decimal}` +} + +CurrencyInput.prototype.handleChange = function (newValue) { + const { onInputChange } = this.props + + this.setState({ value: sanitizeValue(newValue) }) + + onInputChange(sanitizeValue(newValue)) +} + +// If state.value === props.value plus a decimal point, or at least one +// zero or a decimal point and at least one zero, then this returns state.value +// after it is sanitized with getValueParts +CurrencyInput.prototype.getValueToRender = function () { + const { value } = this.props + const { value: stateValue } = this.state + + const trailingStateString = (new RegExp(`^${value}(.+)`)).exec(stateValue) + const trailingDecimalAndZeroes = trailingStateString && (/^[.0]0*/).test(trailingStateString[1]) + + return sanitizeValue(trailingDecimalAndZeroes + ? stateValue + : value) +} + +CurrencyInput.prototype.render = function () { + const { + className, + placeholder, + readOnly, + inputRef, + } = this.props + + const inputSizeMultiplier = readOnly ? 1 : 1.2 + + const valueToRender = this.getValueToRender() + + return h('input', { + className, + value: valueToRender, + placeholder, + size: valueToRender.length * inputSizeMultiplier, + readOnly, + onChange: e => this.handleChange(e.target.value), + ref: inputRef, + }) +} diff --git a/ui/app/components/customize-gas-modal/index.js b/ui/app/components/customize-gas-modal/index.js index 101a19d9f..485dacf90 100644 --- a/ui/app/components/customize-gas-modal/index.js +++ b/ui/app/components/customize-gas-modal/index.js @@ -73,6 +73,8 @@ function getOriginalState (props) { gasLimit, gasTotal, error: null, + priceSigZeros: '', + priceSigDec: '', } } @@ -107,7 +109,6 @@ CustomizeGasModal.prototype.validate = function ({ gasTotal, gasLimit }) { const { amount, balance, - primaryCurrency, selectedToken, amountConversionRate, conversionRate, @@ -116,10 +117,9 @@ CustomizeGasModal.prototype.validate = function ({ gasTotal, gasLimit }) { let error = null const balanceIsSufficient = isBalanceSufficient({ - amount, + amount: selectedToken ? '0' : amount, gasTotal, balance, - primaryCurrency, selectedToken, amountConversionRate, conversionRate, @@ -170,6 +170,13 @@ CustomizeGasModal.prototype.convertAndSetGasLimit = function (newGasLimit) { CustomizeGasModal.prototype.convertAndSetGasPrice = function (newGasPrice) { const { gasLimit } = this.state + const sigZeros = String(newGasPrice).match(/^\d+[.]\d*?(0+)$/) + const sigDec = String(newGasPrice).match(/^\d+([.])0*$/) + + this.setState({ + priceSigZeros: sigZeros && sigZeros[1] || '', + priceSigDec: sigDec && sigDec[1] || '', + }) const gasPrice = conversionUtil(newGasPrice, { fromNumericBase: 'dec', @@ -191,15 +198,17 @@ CustomizeGasModal.prototype.convertAndSetGasPrice = function (newGasPrice) { CustomizeGasModal.prototype.render = function () { const { hideModal } = this.props - const { gasPrice, gasLimit, gasTotal, error } = this.state + const { gasPrice, gasLimit, gasTotal, error, priceSigZeros, priceSigDec } = this.state - const convertedGasPrice = conversionUtil(gasPrice, { + let convertedGasPrice = conversionUtil(gasPrice, { fromNumericBase: 'hex', toNumericBase: 'dec', fromDenomination: 'WEI', toDenomination: 'GWEI', }) + convertedGasPrice += convertedGasPrice.match(/[.]/) ? priceSigZeros : `${priceSigDec}${priceSigZeros}` + const convertedGasLimit = conversionUtil(gasLimit, { fromNumericBase: 'hex', toNumericBase: 'dec', @@ -224,7 +233,7 @@ CustomizeGasModal.prototype.render = function () { value: convertedGasPrice, min: MIN_GAS_PRICE_GWEI, // max: 1000, - step: MIN_GAS_PRICE_GWEI, + step: multiplyCurrencies(MIN_GAS_PRICE_GWEI, 10), onChange: value => this.convertAndSetGasPrice(value), title: 'Gas Price (GWEI)', copy: 'We calculate the suggested gas prices based on network success rates.', diff --git a/ui/app/components/dropdowns/components/dropdown.js b/ui/app/components/dropdowns/components/dropdown.js index ddcb7998f..15d064be8 100644 --- a/ui/app/components/dropdowns/components/dropdown.js +++ b/ui/app/components/dropdowns/components/dropdown.js @@ -31,7 +31,7 @@ class Dropdown extends Component { containerClassName, useCssTransition, isOpen, - zIndex: 30, + zIndex: 55, onClickOutside, style, innerStyle: innerStyleDefaults, diff --git a/ui/app/components/dropdowns/network-dropdown.js b/ui/app/components/dropdowns/network-dropdown.js index 20dfca590..0908faf01 100644 --- a/ui/app/components/dropdowns/network-dropdown.js +++ b/ui/app/components/dropdowns/network-dropdown.js @@ -75,11 +75,12 @@ NetworkDropdown.prototype.render = function () { } }, containerClassName: 'network-droppo', - zIndex: 11, + zIndex: 55, style: { position: 'absolute', top: '58px', minWidth: '309px', + zIndex: '55px', }, innerStyle: { padding: '18px 8px', diff --git a/ui/app/components/input-number.js b/ui/app/components/input-number.js index e28807c13..fd8c5c309 100644 --- a/ui/app/components/input-number.js +++ b/ui/app/components/input-number.js @@ -1,11 +1,12 @@ const Component = require('react').Component const h = require('react-hyperscript') const inherits = require('util').inherits +const CurrencyInput = require('./currency-input') const { addCurrencies, conversionGTE, conversionLTE, - toNegative, + subtractCurrencies, } = require('../conversion-util') module.exports = InputNumber @@ -17,18 +18,24 @@ function InputNumber () { this.setValue = this.setValue.bind(this) } +function isValidInput (text) { + const re = /^([1-9]\d*|0)(\.|\.\d*)?$/ + return re.test(text) +} + InputNumber.prototype.setValue = function (newValue) { + if (newValue && !isValidInput(newValue)) return const { fixed, min = -1, max = Infinity, onChange } = this.props - newValue = Number(fixed ? newValue.toFixed(4) : newValue) + newValue = fixed ? newValue.toFixed(4) : newValue const newValueGreaterThanMin = conversionGTE( - { value: newValue, fromNumericBase: 'dec' }, + { value: newValue || '0', fromNumericBase: 'dec' }, { value: min, fromNumericBase: 'hex' }, ) const newValueLessThanMax = conversionLTE( - { value: newValue, fromNumericBase: 'dec' }, + { value: newValue || '0', fromNumericBase: 'dec' }, { value: max, fromNumericBase: 'hex' }, ) if (newValueGreaterThanMin && newValueLessThanMax) { @@ -44,11 +51,13 @@ InputNumber.prototype.render = function () { const { unitLabel, step = 1, placeholder, value = 0 } = this.props return h('div.customize-gas-input-wrapper', {}, [ - h('input.customize-gas-input', { - placeholder, - type: 'number', + h(CurrencyInput, { + className: 'customize-gas-input', value, - onChange: (e) => this.setValue(e.target.value), + placeholder, + onInputChange: newValue => { + this.setValue(newValue) + }, }), h('span.gas-tooltip-input-detail', {}, [unitLabel]), h('div.gas-tooltip-input-arrows', {}, [ @@ -57,7 +66,7 @@ InputNumber.prototype.render = function () { }), h('i.fa.fa-angle-down', { style: { cursor: 'pointer' }, - onClick: () => this.setValue(addCurrencies(value, toNegative(step))), + onClick: () => this.setValue(subtractCurrencies(value, step)), }), ]), ]) diff --git a/ui/app/components/loading.js b/ui/app/components/loading.js index 587212015..9442121fe 100644 --- a/ui/app/components/loading.js +++ b/ui/app/components/loading.js @@ -10,20 +10,7 @@ class LoadingIndicator extends Component { render () { return ( - h('.full-flex-height', { - style: { - left: '0px', - zIndex: 50, - position: 'absolute', - flexDirection: 'column', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - height: '100%', - width: '100%', - background: 'rgba(255, 255, 255, 0.8)', - }, - }, [ + h('.full-flex-height.loading-overlay', {}, [ h('img', { src: 'images/loading.svg', }), diff --git a/ui/app/components/modals/buy-options-modal.js b/ui/app/components/modals/buy-options-modal.js index 33615c483..d735983f9 100644 --- a/ui/app/components/modals/buy-options-modal.js +++ b/ui/app/components/modals/buy-options-modal.js @@ -3,6 +3,7 @@ const h = require('react-hyperscript') const inherits = require('util').inherits const connect = require('react-redux').connect const actions = require('../../actions') +const networkNames = require('../../../../app/scripts/config.js').networkNames function mapStateToProps (state) { return { @@ -22,6 +23,7 @@ function mapDispatchToProps (dispatch) { showAccountDetailModal: () => { dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })) }, + toFaucet: network => dispatch(actions.buyEth({ network })), } } @@ -32,7 +34,20 @@ function BuyOptions () { module.exports = connect(mapStateToProps, mapDispatchToProps)(BuyOptions) +BuyOptions.prototype.renderModalContentOption = function (title, header, onClick) { + return h('div.buy-modal-content-option', { + onClick, + }, [ + h('div.buy-modal-content-option-title', {}, title), + h('div.buy-modal-content-option-subtitle', {}, header), + ]) +} + BuyOptions.prototype.render = function () { + const { network, toCoinbase, address, toFaucet } = this.props + const isTestNetwork = ['3', '4', '42'].find(n => n === network) + const networkName = networkNames[network] + return h('div', {}, [ h('div.buy-modal-content.transfers-subview', { }, [ @@ -47,27 +62,20 @@ BuyOptions.prototype.render = function () { h('div.buy-modal-content-options.flex-column.flex-center', {}, [ - h('div.buy-modal-content-option', { - onClick: () => { - const { toCoinbase, address } = this.props - toCoinbase(address) - }, - }, [ - h('div.buy-modal-content-option-title', {}, 'Coinbase'), - h('div.buy-modal-content-option-subtitle', {}, 'Deposit with Fiat'), - ]), + isTestNetwork + ? this.renderModalContentOption(networkName, 'Test Faucet', () => toFaucet(network)) + : this.renderModalContentOption('Coinbase', 'Deposit with Fiat', () => toCoinbase(address)), // h('div.buy-modal-content-option', {}, [ // h('div.buy-modal-content-option-title', {}, 'Shapeshift'), // h('div.buy-modal-content-option-subtitle', {}, 'Trade any digital asset for any other'), // ]), - h('div.buy-modal-content-option', { - onClick: () => this.goToAccountDetailsModal(), - }, [ - h('div.buy-modal-content-option-title', {}, 'Direct Deposit'), - h('div.buy-modal-content-option-subtitle', {}, 'Deposit from another account'), - ]), + this.renderModalContentOption( + 'Direct Deposit', + 'Deposit from another account', + () => this.goToAccountDetailsModal() + ), ]), diff --git a/ui/app/components/modals/modal.js b/ui/app/components/modals/modal.js index 842081f40..f2909f3c3 100644 --- a/ui/app/components/modals/modal.js +++ b/ui/app/components/modals/modal.js @@ -189,7 +189,7 @@ const MODALS = { } const BACKDROPSTYLE = { - backgroundColor: 'rgba(245, 245, 245, 0.85)', + backgroundColor: 'rgba(0, 0, 0, 0.5)', } function mapStateToProps (state) { diff --git a/ui/app/components/pending-tx/confirm-send-ether.js b/ui/app/components/pending-tx/confirm-send-ether.js index d12bc499b..1264da153 100644 --- a/ui/app/components/pending-tx/confirm-send-ether.js +++ b/ui/app/components/pending-tx/confirm-send-ether.js @@ -19,6 +19,7 @@ function mapStateToProps (state) { conversionRate, identities, currentCurrency, + send, } = state.metamask const accounts = state.metamask.accounts const selectedAddress = state.metamask.selectedAddress || Object.keys(accounts)[0] @@ -27,12 +28,32 @@ function mapStateToProps (state) { identities, selectedAddress, currentCurrency, + send, } } function mapDispatchToProps (dispatch) { return { - backToAccountDetail: address => dispatch(actions.backToAccountDetail(address)), + clearSend: () => dispatch(actions.clearSend()), + editTransaction: txMeta => { + const { id, txParams } = txMeta + const { + gas: gasLimit, + gasPrice, + to, + value: amount, + } = txParams + dispatch(actions.updateSend({ + gasLimit, + gasPrice, + gasTotal: null, + to, + amount, + errors: { to: null, amount: null }, + editingTransactionId: id, + })) + dispatch(actions.showSendPage()) + }, cancelTransaction: ({ id }) => dispatch(actions.cancelTx({ id })), } } @@ -157,7 +178,7 @@ ConfirmSendEther.prototype.getData = function () { } ConfirmSendEther.prototype.render = function () { - const { backToAccountDetail, selectedAddress, currentCurrency } = this.props + const { editTransaction, currentCurrency, clearSend } = this.props const txMeta = this.gatherTxMeta() const txParams = txMeta.txParams || {} @@ -199,8 +220,8 @@ ConfirmSendEther.prototype.render = function () { h('div.confirm-screen-wrapper.flex-column.flex-grow', [ h('h3.flex-center.confirm-screen-header', [ h('button.confirm-screen-back-button', { - onClick: () => backToAccountDetail(selectedAddress), - }, 'BACK'), + onClick: () => editTransaction(txMeta), + }, 'EDIT'), h('div.confirm-screen-title', 'Confirm Transaction'), h('div.confirm-screen-header-tip'), ]), @@ -371,7 +392,10 @@ ConfirmSendEther.prototype.render = function () { }, [ // Cancel Button h('div.cancel.btn-light.confirm-screen-cancel-button', { - onClick: (event) => this.cancel(event, txMeta), + onClick: (event) => { + clearSend() + this.cancel(event, txMeta) + }, }, 'CANCEL'), // Accept Button @@ -421,6 +445,26 @@ ConfirmSendEther.prototype.gatherTxMeta = function () { const state = this.state const txData = clone(state.txData) || clone(props.txData) + if (props.send.editingTransactionId) { + const { + send: { + memo, + amount: value, + gasLimit: gas, + gasPrice, + }, + } = props + const { txParams: { from, to } } = txData + txData.txParams = { + from: ethUtil.addHexPrefix(from), + to: ethUtil.addHexPrefix(to), + memo: memo && ethUtil.addHexPrefix(memo), + value: ethUtil.addHexPrefix(value), + gas: ethUtil.addHexPrefix(gas), + gasPrice: ethUtil.addHexPrefix(gasPrice), + } + } + // log.debug(`UI has defaulted to tx meta ${JSON.stringify(txData)}`) return txData } diff --git a/ui/app/components/pending-tx/confirm-send-token.js b/ui/app/components/pending-tx/confirm-send-token.js index 3b8ae7f7f..cc2df8299 100644 --- a/ui/app/components/pending-tx/confirm-send-token.js +++ b/ui/app/components/pending-tx/confirm-send-token.js @@ -2,9 +2,10 @@ const Component = require('react').Component const { connect } = require('react-redux') const h = require('react-hyperscript') const inherits = require('util').inherits -const abi = require('human-standard-token-abi') +const ethAbi = require('ethereumjs-abi') +const tokenAbi = require('human-standard-token-abi') const abiDecoder = require('abi-decoder') -abiDecoder.addABI(abi) +abiDecoder.addABI(tokenAbi) const actions = require('../../actions') const clone = require('clone') const Identicon = require('../identicon') @@ -15,12 +16,16 @@ const { multiplyCurrencies, addCurrencies, } = require('../../conversion-util') +const { + calcTokenAmount, +} = require('../../token-util') const { MIN_GAS_PRICE_HEX } = require('../send/send-constants') const { getTokenExchangeRate, getSelectedAddress, + getSelectedTokenContract, } = require('../../selectors') module.exports = connect(mapStateToProps, mapDispatchToProps)(ConfirmSendToken) @@ -29,6 +34,7 @@ function mapStateToProps (state, ownProps) { const { token: { symbol }, txData } = ownProps const { txParams } = txData || {} const tokenData = txParams.data && abiDecoder.decodeMethod(txParams.data) + const { conversionRate, identities, @@ -44,6 +50,8 @@ function mapStateToProps (state, ownProps) { tokenExchangeRate, tokenData: tokenData || {}, currentCurrency: currentCurrency.toUpperCase(), + send: state.metamask.send, + tokenContract: getSelectedTokenContract(state), } } @@ -54,6 +62,33 @@ function mapDispatchToProps (dispatch, ownProps) { backToAccountDetail: address => dispatch(actions.backToAccountDetail(address)), cancelTransaction: ({ id }) => dispatch(actions.cancelTx({ id })), updateTokenExchangeRate: () => dispatch(actions.updateTokenExchangeRate(symbol)), + editTransaction: txMeta => { + const { token: { address } } = ownProps + const { txParams, id } = txMeta + const tokenData = txParams.data && abiDecoder.decodeMethod(txParams.data) + const { params = [] } = tokenData + const { value } = params[1] || {} + const amount = conversionUtil(value, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + }) + const { + gas: gasLimit, + gasPrice, + to, + } = txParams + dispatch(actions.setSelectedToken(address)) + dispatch(actions.updateSend({ + gasLimit, + gasPrice, + gasTotal: null, + to, + amount, + errors: { to: null, amount: null }, + editingTransactionId: id, + })) + dispatch(actions.showSendTokenPage()) + }, } } @@ -65,16 +100,34 @@ function ConfirmSendToken () { } ConfirmSendToken.prototype.componentWillMount = function () { + const { tokenContract, selectedAddress } = this.props + tokenContract && tokenContract + .balanceOf(selectedAddress) + .then(usersToken => { + }) this.props.updateTokenExchangeRate() } ConfirmSendToken.prototype.getAmount = function () { - const { conversionRate, tokenExchangeRate, token, tokenData } = this.props + const { + conversionRate, + tokenExchangeRate, + token, + tokenData, + send: { amount, editingTransactionId }, + } = this.props const { params = [] } = tokenData - const { value } = params[1] || {} + let { value } = params[1] || {} const { decimals } = token - const multiplier = Math.pow(10, Number(decimals || 0)) - const sendTokenAmount = Number(value / multiplier) + + if (editingTransactionId) { + value = conversionUtil(amount, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + }) + } + + const sendTokenAmount = calcTokenAmount(value, decimals) return { fiat: tokenExchangeRate @@ -240,9 +293,8 @@ ConfirmSendToken.prototype.renderTotalPlusGas = function () { } ConfirmSendToken.prototype.render = function () { - const { backToAccountDetail, selectedAddress } = this.props + const { editTransaction } = this.props const txMeta = this.gatherTxMeta() - const { from: { address: fromAddress, @@ -264,8 +316,8 @@ ConfirmSendToken.prototype.render = function () { h('div.confirm-screen-wrapper.flex-column.flex-grow', [ h('h3.flex-center.confirm-screen-header', [ h('button.confirm-screen-back-button', { - onClick: () => backToAccountDetail(selectedAddress), - }, 'BACK'), + onClick: () => editTransaction(txMeta), + }, 'EDIT'), h('div.confirm-screen-title', 'Confirm Transaction'), h('div.confirm-screen-header-tip'), ]), @@ -387,6 +439,38 @@ ConfirmSendToken.prototype.gatherTxMeta = function () { const state = this.state const txData = clone(state.txData) || clone(props.txData) + if (props.send.editingTransactionId) { + const { + send: { + memo, + amount, + gasLimit: gas, + gasPrice, + }, + } = props + + const { txParams: { from, to } } = txData + + const tokenParams = { + from: ethUtil.addHexPrefix(from), + value: '0', + gas: ethUtil.addHexPrefix(gas), + gasPrice: ethUtil.addHexPrefix(gasPrice), + } + + const data = '0xa9059cbb' + Array.prototype.map.call( + ethAbi.rawEncode(['address', 'uint256'], [to, ethUtil.addHexPrefix(amount)]), + x => ('00' + x.toString(16)).slice(-2) + ).join('') + + txData.txParams = { + ...tokenParams, + to: ethUtil.addHexPrefix(to), + memo: memo && ethUtil.addHexPrefix(memo), + data, + } + } + // log.debug(`UI has defaulted to tx meta ${JSON.stringify(txData)}`) return txData } diff --git a/ui/app/components/send/account-list-item.js b/ui/app/components/send/account-list-item.js index 2378a4671..1ad3f69c1 100644 --- a/ui/app/components/send/account-list-item.js +++ b/ui/app/components/send/account-list-item.js @@ -22,6 +22,7 @@ module.exports = connect(mapStateToProps)(AccountListItem) AccountListItem.prototype.render = function () { const { + className, account, handleClick, icon = null, @@ -34,6 +35,7 @@ AccountListItem.prototype.render = function () { const { name, address, balance } = account || {} return h('div.account-list-item', { + className, onClick: () => handleClick({ name, address, balance }), }, [ diff --git a/ui/app/components/send/currency-display.js b/ui/app/components/send/currency-display.js index 45986e371..819fee0a0 100644 --- a/ui/app/components/send/currency-display.js +++ b/ui/app/components/send/currency-display.js @@ -1,6 +1,7 @@ const Component = require('react').Component const h = require('react-hyperscript') const inherits = require('util').inherits +const CurrencyInput = require('../currency-input') const { conversionUtil, multiplyCurrencies } = require('../../conversion-util') module.exports = CurrencyDisplay @@ -8,15 +9,6 @@ module.exports = CurrencyDisplay inherits(CurrencyDisplay, Component) function CurrencyDisplay () { Component.call(this) - - this.state = { - value: null, - } -} - -function isValidInput (text) { - const re = /^([1-9]\d*|0)(\.|\.\d*)?$/ - return re.test(text) } function toHexWei (value) { @@ -39,6 +31,28 @@ CurrencyDisplay.prototype.getAmount = function (value) { : toHexWei(value) } +CurrencyDisplay.prototype.getValueToRender = function () { + const { selectedToken, conversionRate, value } = this.props + + const { decimals, symbol } = selectedToken || {} + const multiplier = Math.pow(10, Number(decimals || 0)) + + return selectedToken + ? conversionUtil(value, { + fromNumericBase: 'hex', + toCurrency: symbol, + conversionRate: multiplier, + invertConversionRate: true, + }) + : conversionUtil(value, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromDenomination: 'WEI', + numberOfDecimals: 6, + conversionRate, + }) +} + CurrencyDisplay.prototype.render = function () { const { className = 'currency-display', @@ -49,64 +63,41 @@ CurrencyDisplay.prototype.render = function () { convertedCurrency, readOnly = false, inError = false, - value: initValue, handleChange, - validate, } = this.props - const { value } = this.state - const initValueToRender = conversionUtil(initValue, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - fromDenomination: 'WEI', - numberOfDecimals: 6, - conversionRate, - }) + const valueToRender = this.getValueToRender() - const convertedValue = conversionUtil(value || initValueToRender, { + let convertedValue = conversionUtil(valueToRender, { fromNumericBase: 'dec', fromCurrency: primaryCurrency, toCurrency: convertedCurrency, numberOfDecimals: 2, conversionRate, }) - - const inputSizeMultiplier = readOnly ? 1 : 1.2 + convertedValue = Number(convertedValue).toFixed(2) return h('div', { className, style: { borderColor: inError ? 'red' : null, }, + onClick: () => this.currencyInput.focus(), }, [ h('div.currency-display__primary-row', [ h('div.currency-display__input-wrapper', [ - h('input', { + h(CurrencyInput, { className: primaryBalanceClassName, - value: `${value || initValueToRender}`, + value: `${valueToRender}`, placeholder: '0', - size: (value || initValueToRender).length * inputSizeMultiplier, readOnly, - onChange: (event) => { - let newValue = event.target.value - - if (newValue === '') { - newValue = '0' - } else if (newValue.match(/^0[1-9]$/)) { - newValue = newValue.match(/[1-9]/)[0] - } - - if (newValue && !isValidInput(newValue)) { - event.preventDefault() - } else { - validate(this.getAmount(newValue)) - this.setState({ value: newValue }) - } + onInputChange: newValue => { + handleChange(this.getAmount(newValue)) }, - onBlur: event => !readOnly && handleChange(this.getAmount(event.target.value)), + inputRef: input => { this.currencyInput = input }, }), h('span.currency-display__currency-symbol', primaryCurrency), diff --git a/ui/app/components/send/from-dropdown.js b/ui/app/components/send/from-dropdown.js index bcae5ede8..0686fbe73 100644 --- a/ui/app/components/send/from-dropdown.js +++ b/ui/app/components/send/from-dropdown.js @@ -35,6 +35,7 @@ FromDropdown.prototype.renderDropdown = function () { h('div.send-v2__from-dropdown__list', {}, [ ...accounts.map(account => h(AccountListItem, { + className: 'account-list-item__dropdown', account, handleClick: () => { onSelect(account) diff --git a/ui/app/components/send/send-constants.js b/ui/app/components/send/send-constants.js index 0a4f85bc9..a961ffcd8 100644 --- a/ui/app/components/send/send-constants.js +++ b/ui/app/components/send/send-constants.js @@ -11,6 +11,7 @@ const MIN_GAS_PRICE_GWEI = ethUtil.addHexPrefix(conversionUtil(MIN_GAS_PRICE_HEX toDenomination: 'GWEI', fromNumericBase: 'hex', toNumericBase: 'hex', + numberOfDecimals: 1, })) const MIN_GAS_TOTAL = multiplyCurrencies(MIN_GAS_LIMIT_HEX, MIN_GAS_PRICE_HEX, { diff --git a/ui/app/components/send/send-utils.js b/ui/app/components/send/send-utils.js index 6ec04a223..d8211930d 100644 --- a/ui/app/components/send/send-utils.js +++ b/ui/app/components/send/send-utils.js @@ -1,11 +1,17 @@ -const { addCurrencies, conversionGreaterThan } = require('../../conversion-util') +const { + addCurrencies, + conversionUtil, + conversionGTE, +} = require('../../conversion-util') +const { + calcTokenAmount, +} = require('../../token-util') function isBalanceSufficient ({ - amount, - gasTotal, + amount = '0x0', + gasTotal = '0x0', balance, primaryCurrency, - selectedToken, amountConversionRate, conversionRate, }) { @@ -15,7 +21,7 @@ function isBalanceSufficient ({ toNumericBase: 'hex', }) - const balanceIsSufficient = conversionGreaterThan( + const balanceIsSufficient = conversionGTE( { value: balance, fromNumericBase: 'hex', @@ -26,13 +32,37 @@ function isBalanceSufficient ({ value: totalAmount, fromNumericBase: 'hex', conversionRate: amountConversionRate, - fromCurrency: selectedToken || primaryCurrency, + fromCurrency: primaryCurrency, }, ) return balanceIsSufficient } +function isTokenBalanceSufficient ({ + amount = '0x0', + tokenBalance, + decimals, +}) { + const amountInDec = conversionUtil(amount, { + fromNumericBase: 'hex', + }) + + const tokenBalanceIsSufficient = conversionGTE( + { + value: tokenBalance, + fromNumericBase: 'dec', + }, + { + value: calcTokenAmount(amountInDec, decimals), + fromNumericBase: 'dec', + }, + ) + + return tokenBalanceIsSufficient +} + module.exports = { isBalanceSufficient, + isTokenBalanceSufficient, } diff --git a/ui/app/components/send/send-v2-container.js b/ui/app/components/send/send-v2-container.js index 5a6e83ae6..4451a6113 100644 --- a/ui/app/components/send/send-v2-container.js +++ b/ui/app/components/send/send-v2-container.js @@ -13,6 +13,7 @@ const { getSendFrom, getCurrentCurrency, getSelectedTokenToFiatRate, + getSelectedTokenContract, } = require('../../selectors') module.exports = connect(mapStateToProps, mapDispatchToProps)(SendEther) @@ -48,6 +49,7 @@ function mapStateToProps (state) { convertedCurrency: getCurrentCurrency(state), data, amountConversionRate: selectedToken ? tokenToFiatRate : conversionRate, + tokenContract: getSelectedTokenContract(state), } } @@ -61,9 +63,13 @@ function mapDispatchToProps (dispatch) { dispatch(actions.signTokenTx(tokenAddress, toAddress, amount, txData)) ), signTx: txParams => dispatch(actions.signTx(txParams)), + updateAndApproveTx: txParams => dispatch(actions.updateAndApproveTx(txParams)), setSelectedAddress: address => dispatch(actions.setSelectedAddress(address)), addToAddressBook: address => dispatch(actions.addToAddressBook(address)), updateGasTotal: newTotal => dispatch(actions.updateGasTotal(newTotal)), + updateGasPrice: newGasPrice => dispatch(actions.updateGasPrice(newGasPrice)), + updateGasLimit: newGasLimit => dispatch(actions.updateGasLimit(newGasLimit)), + updateSendTokenBalance: tokenBalance => dispatch(actions.updateSendTokenBalance(tokenBalance)), updateSendFrom: newFrom => dispatch(actions.updateSendFrom(newFrom)), updateSendTo: newTo => dispatch(actions.updateSendTo(newTo)), updateSendAmount: newAmount => dispatch(actions.updateSendAmount(newAmount)), @@ -71,5 +77,6 @@ function mapDispatchToProps (dispatch) { updateSendErrors: newError => dispatch(actions.updateSendErrors(newError)), goHome: () => dispatch(actions.goHome()), clearSend: () => dispatch(actions.clearSend()), + backToConfirmScreen: editingTransactionId => dispatch(actions.showConfTxPage({ id: editingTransactionId })), } } diff --git a/ui/app/components/send/to-autocomplete.js b/ui/app/components/send/to-autocomplete.js index df43fcc4a..e0cdd0a58 100644 --- a/ui/app/components/send/to-autocomplete.js +++ b/ui/app/components/send/to-autocomplete.js @@ -38,6 +38,7 @@ ToAutoComplete.prototype.renderDropdown = function () { ...accountsToRender.map(account => h(AccountListItem, { account, + className: 'account-list-item__dropdown', handleClick: () => { onChange(account.address) closeDropdown() @@ -88,7 +89,7 @@ ToAutoComplete.prototype.render = function () { inError, } = this.props - return h('div.to-autocomplete', {}, [ + return h('div.send-v2__to-autocomplete', {}, [ h('input.send-v2__to-autocomplete__input', { placeholder: 'Recipient Address', diff --git a/ui/app/components/token-list.js b/ui/app/components/token-list.js index b6a27fd5a..8e06e0f27 100644 --- a/ui/app/components/token-list.js +++ b/ui/app/components/token-list.js @@ -151,10 +151,7 @@ TokenList.prototype.componentDidUpdate = function (nextProps) { } TokenList.prototype.updateBalances = function (tokens) { - const heldTokens = tokens.filter(token => { - return token.balance !== '0' && token.string !== '0.000' - }) - this.setState({ tokens: heldTokens, isLoading: false }) + this.setState({ tokens, isLoading: false }) } TokenList.prototype.componentWillUnmount = function () { diff --git a/ui/app/components/tx-list-item.js b/ui/app/components/tx-list-item.js index 26de19f15..4e76147a1 100644 --- a/ui/app/components/tx-list-item.js +++ b/ui/app/components/tx-list-item.js @@ -10,6 +10,7 @@ const Identicon = require('./identicon') const contractMap = require('eth-contract-metadata') const { conversionUtil, multiplyCurrencies } = require('../conversion-util') +const { calcTokenAmount } = require('../token-util') const { getCurrentCurrency } = require('../selectors') @@ -135,8 +136,7 @@ TxListItem.prototype.getSendTokenTotal = async function () { const { params = [] } = decodedData || {} const { value } = params[1] || {} const { decimals, symbol } = await this.getTokenInfo() - const multiplier = Math.pow(10, Number(decimals || 0)) - const total = Number(value / multiplier) + const total = calcTokenAmount(value, decimals) const pair = symbol && `${symbol.toLowerCase()}_eth` diff --git a/ui/app/conversion-util.js b/ui/app/conversion-util.js index 9359d7c90..ee42ebea1 100644 --- a/ui/app/conversion-util.js +++ b/ui/app/conversion-util.js @@ -37,7 +37,7 @@ const BIG_NUMBER_GWEI_MULTIPLIER = new BigNumber('1000000000') // Individual Setters const convert = R.invoker(1, 'times') -const round = R.invoker(2, 'round')(R.__, BigNumber.ROUND_DOWN) +const round = R.invoker(2, 'round')(R.__, BigNumber.ROUND_HALF_DOWN) const invertConversionRate = conversionRate => () => new BigNumber(1.0).div(conversionRate) const decToBigNumberViaString = n => R.pipe(String, toBigNumber['dec']) @@ -53,7 +53,7 @@ const toNormalizedDenomination = { } const toSpecifiedDenomination = { WEI: bigNumber => bigNumber.times(BIG_NUMBER_WEI_MULTIPLIER).round(), - GWEI: bigNumber => bigNumber.times(BIG_NUMBER_GWEI_MULTIPLIER).round(1), + GWEI: bigNumber => bigNumber.times(BIG_NUMBER_GWEI_MULTIPLIER).round(9), } const baseChange = { hex: n => n.toString(16), @@ -145,6 +145,20 @@ const addCurrencies = (a, b, options = {}) => { }) } +const subtractCurrencies = (a, b, options = {}) => { + const { + aBase, + bBase, + ...conversionOptions + } = options + const value = (new BigNumber(a, aBase)).minus(b, bBase) + + return converter({ + value, + ...conversionOptions, + }) +} + const multiplyCurrencies = (a, b, options = {}) => { const { multiplicandBase, @@ -169,6 +183,7 @@ const conversionGreaterThan = ( ) => { const firstValue = converter({ ...firstProps }) const secondValue = converter({ ...secondProps }) + return firstValue.gt(secondValue) } @@ -202,4 +217,5 @@ module.exports = { conversionGTE, conversionLTE, toNegative, + subtractCurrencies, } diff --git a/ui/app/css/itcss/components/account-dropdown.scss b/ui/app/css/itcss/components/account-dropdown.scss index c298c4019..725da9d39 100644 --- a/ui/app/css/itcss/components/account-dropdown.scss +++ b/ui/app/css/itcss/components/account-dropdown.scss @@ -69,4 +69,15 @@ overflow: hidden; text-overflow: ellipsis; } + + &__dropdown { + &:hover { + background: rgba($alto, .2); + cursor: pointer; + + input { + background: rgba($alto, .1); + } + } + } } diff --git a/ui/app/css/itcss/components/index.scss b/ui/app/css/itcss/components/index.scss index 4ba02be67..dfb4f23f0 100644 --- a/ui/app/css/itcss/components/index.scss +++ b/ui/app/css/itcss/components/index.scss @@ -16,6 +16,8 @@ @import './confirm.scss'; +@import './loading-overlay.scss'; + // Balances @import './hero-balance.scss'; diff --git a/ui/app/css/itcss/components/loading-overlay.scss b/ui/app/css/itcss/components/loading-overlay.scss new file mode 100644 index 000000000..15009c1e6 --- /dev/null +++ b/ui/app/css/itcss/components/loading-overlay.scss @@ -0,0 +1,21 @@ +.loading-overlay { + left: 0px; + z-index: 50; + position: absolute; + flex-direction: column; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + background: rgba(255, 255, 255, 0.8); + + @media screen and (max-width: 575px) { + margin-top: 56px; + height: calc(100% - 56px); + } + + @media screen and (min-width: 576px) { + margin-top: 75px; + height: calc(100% - 75px); + } +} diff --git a/ui/app/css/itcss/components/send.scss b/ui/app/css/itcss/components/send.scss index 282eef030..2bd192788 100644 --- a/ui/app/css/itcss/components/send.scss +++ b/ui/app/css/itcss/components/send.scss @@ -577,6 +577,7 @@ line-height: 16px; font-size: 12px; color: $tundora; + position: relative; &__close-area { position: fixed; @@ -591,7 +592,7 @@ z-index: 1050; position: absolute; height: 220px; - width: 240px; + width: 100%; border: 1px solid $geyser; border-radius: 4px; background-color: $white; @@ -628,6 +629,15 @@ } } + &__amount-max { + color: $curious-blue; + font-family: Roboto; + font-size: 12px; + left: 8px; + border: none; + cursor: pointer; + } + &__gas-fee-display { width: 100%; } diff --git a/ui/app/reducers.js b/ui/app/reducers.js index e1a890535..70b7e71dc 100644 --- a/ui/app/reducers.js +++ b/ui/app/reducers.js @@ -1,4 +1,5 @@ const extend = require('xtend') +const copyToClipboard = require('copy-to-clipboard') // // Sub-Reducers take in the complete state and return their sub-state @@ -41,17 +42,33 @@ function rootReducer (state, action) { return state } -window.logState = function () { +window.logStateString = function (cb) { const state = window.METAMASK_CACHED_LOG_STATE - let version - try { - version = global.platform.getVersion() - } catch (e) { - version = 'unable to load version.' - } - state.version = version - const stateString = JSON.stringify(state, removeSeedWords, 2) - return stateString + const version = global.platform.getVersion() + const browser = window.navigator.userAgent + return global.platform.getPlatformInfo((err, platform) => { + if (err) { + return cb(err) + } + state.version = version + state.platform = platform + state.browser = browser + const stateString = JSON.stringify(state, removeSeedWords, 2) + return cb(null, stateString) + }) +} + +window.logState = function (toClipboard) { + return window.logStateString((err, result) => { + if (err) { + console.error(err.message) + } else if (toClipboard) { + copyToClipboard(result) + console.log('State log copied') + } else { + console.log(result) + } + }) } function removeSeedWords (key, value) { diff --git a/ui/app/reducers/app.js b/ui/app/reducers/app.js index d84f264c9..3a4fb536d 100644 --- a/ui/app/reducers/app.js +++ b/ui/app/reducers/app.js @@ -175,6 +175,7 @@ function reduceApp (state, action) { name: 'import-menu', }, transForward: true, + warning: null, }) case actions.SHOW_INFO_PAGE: diff --git a/ui/app/reducers/metamask.js b/ui/app/reducers/metamask.js index 50c9712ff..83161320e 100644 --- a/ui/app/reducers/metamask.js +++ b/ui/app/reducers/metamask.js @@ -27,11 +27,13 @@ function reduceMetamask (state, action) { gasLimit: null, gasPrice: null, gasTotal: null, + tokenBalance: null, from: '', to: '', amount: '0x0', memo: '', errors: {}, + editingTransactionId: null, }, coinOptions: {}, }, state.metamask) @@ -107,6 +109,14 @@ function reduceMetamask (state, action) { } return newState + case actions.EDIT_TX: + return extend(metamaskState, { + send: { + ...metamaskState.send, + editingTransactionId: action.value, + }, + }) + case actions.SHOW_NEW_VAULT_SEED: return extend(metamaskState, { isUnlocked: true, @@ -140,9 +150,9 @@ function reduceMetamask (state, action) { case actions.SAVE_ACCOUNT_LABEL: const account = action.value.account const name = action.value.label - var id = {} + const id = {} id[account] = extend(metamaskState.identities[account], { name }) - var identities = extend(metamaskState.identities, id) + const identities = extend(metamaskState.identities, id) return extend(metamaskState, { identities }) case actions.SET_CURRENT_FIAT: @@ -196,6 +206,14 @@ function reduceMetamask (state, action) { }, }) + case actions.UPDATE_SEND_TOKEN_BALANCE: + return extend(metamaskState, { + send: { + ...metamaskState.send, + tokenBalance: action.value, + }, + }) + case actions.UPDATE_SEND_FROM: return extend(metamaskState, { send: { @@ -239,20 +257,44 @@ function reduceMetamask (state, action) { }, }) + case actions.UPDATE_SEND: + return extend(metamaskState, { + send: { + ...metamaskState.send, + ...action.value, + }, + }) + case actions.CLEAR_SEND: return extend(metamaskState, { send: { gasLimit: null, gasPrice: null, gasTotal: null, + tokenBalance: null, from: '', to: '', amount: '0x0', memo: '', errors: {}, + editingTransactionId: null, }, }) + case actions.UPDATE_TRANSACTION_PARAMS: + const { id: txId, value } = action + let { selectedAddressTxList } = metamaskState + selectedAddressTxList = selectedAddressTxList.map(tx => { + if (tx.id === txId) { + tx.txParams = value + } + return tx + }) + + return extend(metamaskState, { + selectedAddressTxList, + }) + case actions.PAIR_UPDATE: const { value: { marketinfo: pairMarketInfo } } = action return extend(metamaskState, { diff --git a/ui/app/selectors.js b/ui/app/selectors.js index 3a15cef4c..a5f9a75d8 100644 --- a/ui/app/selectors.js +++ b/ui/app/selectors.js @@ -1,4 +1,5 @@ const valuesFor = require('./util').valuesFor +const abi = require('human-standard-token-abi') const { multiplyCurrencies, @@ -22,6 +23,7 @@ const selectors = { getCurrentCurrency, getSendAmount, getSelectedTokenToFiatRate, + getSelectedTokenContract, } module.exports = selectors @@ -149,3 +151,10 @@ function getSelectedTokenToFiatRate (state) { return tokenToFiatRate } + +function getSelectedTokenContract (state) { + const selectedToken = getSelectedToken(state) + return selectedToken + ? global.eth.contract(abi).at(selectedToken.address) + : null +} diff --git a/ui/app/send-v2.js b/ui/app/send-v2.js index 6ce3e1b70..788ae87b4 100644 --- a/ui/app/send-v2.js +++ b/ui/app/send-v2.js @@ -2,6 +2,8 @@ const { inherits } = require('util') const PersistentForm = require('../lib/persistent-form') const h = require('react-hyperscript') +const ethUtil = require('ethereumjs-util') + const Identicon = require('./components/identicon') const FromDropdown = require('./components/send/from-dropdown') const ToAutoComplete = require('./components/send/to-autocomplete') @@ -9,15 +11,24 @@ const CurrencyDisplay = require('./components/send/currency-display') const MemoTextArea = require('./components/send/memo-textarea') const GasFeeDisplay = require('./components/send/gas-fee-display-v2') -const { MIN_GAS_TOTAL } = require('./components/send/send-constants') +const { + MIN_GAS_TOTAL, + MIN_GAS_PRICE_HEX, + MIN_GAS_LIMIT_HEX, +} = require('./components/send/send-constants') const { multiplyCurrencies, conversionGreaterThan, + subtractCurrencies, } = require('./conversion-util') +const { + calcTokenAmount, +} = require('./token-util') const { isBalanceSufficient, -} = require('./components/send/send-utils.js') + isTokenBalanceSufficient, +} = require('./components/send/send-utils') const { isValidAddress } = require('./util') module.exports = SendTransactionScreen @@ -40,6 +51,36 @@ function SendTransactionScreen () { this.validateAmount = this.validateAmount.bind(this) } +const getParamsForGasEstimate = function (selectedAddress, symbol, data) { + const estimatedGasParams = { + from: selectedAddress, + gas: '746a528800', + } + + if (symbol) { + Object.assign(estimatedGasParams, { value: '0x0' }) + } + + if (data) { + Object.assign(estimatedGasParams, { data }) + } + + return estimatedGasParams +} + +SendTransactionScreen.prototype.updateSendTokenBalance = function (usersToken) { + if (!usersToken) return + + const { + selectedToken = {}, + updateSendTokenBalance, + } = this.props + const { decimals } = selectedToken || {} + const tokenBalance = calcTokenAmount(usersToken.balance.toString(), decimals) + + updateSendTokenBalance(tokenBalance) +} + SendTransactionScreen.prototype.componentWillMount = function () { const { updateTokenExchangeRate, @@ -49,40 +90,75 @@ SendTransactionScreen.prototype.componentWillMount = function () { selectedAddress, data, updateGasTotal, + from, + tokenContract, + editingTransactionId, + gasPrice, + gasLimit, } = this.props const { symbol } = selectedToken || {} - const estimateGasParams = { - from: selectedAddress, - gas: '746a528800', - } - if (symbol) { updateTokenExchangeRate(symbol) - Object.assign(estimateGasParams, { value: '0x0' }) } - if (data) { - Object.assign(estimateGasParams, { data }) - } + const estimateGasParams = getParamsForGasEstimate(selectedAddress, symbol, data) - Promise - .all([ - getGasPrice(), - estimateGas({ - from: selectedAddress, - gas: '746a528800', - }), - ]) - .then(([gasPrice, gas]) => { + const tokenBalancePromise = tokenContract && tokenContract.balanceOf(from.address) + let newGasTotal + if (!editingTransactionId) { + Promise + .all([ + getGasPrice(), + estimateGas(estimateGasParams), + tokenBalancePromise, + ]) + .then(([gasPrice, gas, usersToken]) => { - const newGasTotal = multiplyCurrencies(gas, gasPrice, { - toNumericBase: 'hex', - multiplicandBase: 16, - multiplierBase: 16, + const newGasTotal = multiplyCurrencies(gas, gasPrice, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, + }) + updateGasTotal(newGasTotal) + this.updateSendTokenBalance(usersToken) }) - updateGasTotal(newGasTotal) + } else { + newGasTotal = multiplyCurrencies(gasLimit, gasPrice, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, }) + updateGasTotal(newGasTotal) + tokenBalancePromise && tokenBalancePromise.then( + usersToken => this.updateSendTokenBalance(usersToken)) + } +} + +SendTransactionScreen.prototype.componentDidUpdate = function (prevProps) { + const { + from: { balance }, + gasTotal, + tokenBalance, + amount, + selectedToken, + } = this.props + const { + from: { balance: prevBalance }, + gasTotal: prevGasTotal, + tokenBalance: prevTokenBalance, + } = prevProps + + const notFirstRender = [prevBalance, prevGasTotal].every(n => n !== null) + + const balanceHasChanged = balance !== prevBalance + const gasTotalHasChange = gasTotal !== prevGasTotal + const tokenBalanceHasChanged = selectedToken && tokenBalance !== prevTokenBalance + const amountValidationChange = balanceHasChanged || gasTotalHasChange || tokenBalanceHasChanged + + if (notFirstRender && amountValidationChange) { + this.validateAmount(amount) + } } SendTransactionScreen.prototype.renderHeaderIcon = function () { @@ -144,12 +220,24 @@ SendTransactionScreen.prototype.renderErrorMessage = function (errorType) { : null } +SendTransactionScreen.prototype.handleFromChange = async function (newFrom) { + const { + updateSendFrom, + tokenContract, + } = this.props + + if (tokenContract) { + const usersToken = await tokenContract.balanceOf(newFrom.address) + this.updateSendTokenBalance(usersToken) + } + updateSendFrom(newFrom) +} + SendTransactionScreen.prototype.renderFromRow = function () { const { from, fromAccounts, conversionRate, - updateSendFrom, } = this.props const { fromDropdownOpen } = this.state @@ -163,7 +251,7 @@ SendTransactionScreen.prototype.renderFromRow = function () { dropdownOpen: fromDropdownOpen, accounts: fromAccounts, selectedAccount: from, - onSelect: updateSendFrom, + onSelect: newFrom => this.handleFromChange(newFrom), openDropdown: () => this.setState({ fromDropdownOpen: true }), closeDropdown: () => this.setState({ fromDropdownOpen: false }), conversionRate, @@ -227,9 +315,41 @@ SendTransactionScreen.prototype.handleAmountChange = function (value) { const amount = value const { updateSendAmount } = this.props + this.validateAmount(amount) updateSendAmount(amount) } +SendTransactionScreen.prototype.setAmountToMax = function () { + const { + from: { balance }, + updateSendAmount, + updateSendErrors, + updateGasPrice, + updateGasLimit, + updateGasTotal, + tokenBalance, + selectedToken, + } = this.props + const { decimals } = selectedToken || {} + const multiplier = Math.pow(10, Number(decimals || 0)) + + const maxAmount = selectedToken + ? multiplyCurrencies(tokenBalance, multiplier, {toNumericBase: 'hex'}) + : subtractCurrencies( + ethUtil.addHexPrefix(balance), + ethUtil.addHexPrefix(MIN_GAS_TOTAL), + { toNumericBase: 'hex' } + ) + + updateSendErrors({ amount: null }) + if (!selectedToken) { + updateGasPrice(MIN_GAS_PRICE_HEX) + updateGasLimit(MIN_GAS_LIMIT_HEX) + updateGasTotal(MIN_GAS_TOTAL) + } + updateSendAmount(maxAmount) +} + SendTransactionScreen.prototype.validateAmount = function (value) { const { from: { balance }, @@ -239,21 +359,30 @@ SendTransactionScreen.prototype.validateAmount = function (value) { primaryCurrency, selectedToken, gasTotal, + tokenBalance, } = this.props + const { decimals } = selectedToken || {} const amount = value let amountError = null - const sufficientBalance = isBalanceSufficient({ - amount, + amount: selectedToken ? '0x0' : amount, gasTotal, balance, primaryCurrency, - selectedToken, amountConversionRate, conversionRate, }) + let sufficientTokens + if (selectedToken) { + sufficientTokens = isTokenBalanceSufficient({ + tokenBalance, + amount, + decimals, + }) + } + const amountLessThanZero = conversionGreaterThan( { value: 0, fromNumericBase: 'dec' }, { value: amount, fromNumericBase: 'hex' }, @@ -261,6 +390,8 @@ SendTransactionScreen.prototype.validateAmount = function (value) { if (!sufficientBalance) { amountError = 'Insufficient funds.' + } else if (selectedToken && !sufficientTokens) { + amountError = 'Insufficient tokens.' } else if (amountLessThanZero) { amountError = 'Can not send negative amounts of ETH.' } @@ -275,15 +406,20 @@ SendTransactionScreen.prototype.renderAmountRow = function () { convertedCurrency, amountConversionRate, errors, + amount, } = this.props - const { amount } = this.state - return h('div.send-v2__form-row', [ h('div.send-v2__form-label', [ 'Amount:', this.renderErrorMessage('amount'), + !errors.amount && h('div.send-v2__amount-max', { + onClick: (event) => { + event.preventDefault() + this.setAmountToMax() + }, + }, [ 'Max' ]), ]), h('div.send-v2__form-field', [ @@ -292,10 +428,9 @@ SendTransactionScreen.prototype.renderAmountRow = function () { primaryCurrency, convertedCurrency, selectedToken, - value: amount, + value: amount || '0x0', conversionRate: amountConversionRate, handleChange: this.handleAmountChange, - validate: this.validateAmount, }), ]), @@ -335,8 +470,7 @@ SendTransactionScreen.prototype.renderGasRow = function () { } SendTransactionScreen.prototype.renderMemoRow = function () { - const { updateSendMemo } = this.props - const { memo } = this.state + const { updateSendMemo, memo } = this.props return h('div.send-v2__form-row', [ @@ -383,7 +517,7 @@ SendTransactionScreen.prototype.renderFooter = function () { errors: { amount: amountError, to: toError }, } = this.props - const noErrors = amountError === null && toError === null + const noErrors = !amountError && toError === null const errorClass = noErrors ? '' : '__disabled' return h('div.send-v2__footer', [ @@ -433,11 +567,12 @@ SendTransactionScreen.prototype.onSubmit = function (event) { signTokenTx, signTx, selectedToken, - clearSend, + editingTransactionId, errors: { amount: amountError, to: toError }, + backToConfirmScreen, } = this.props - const noErrors = amountError === null && toError === null + const noErrors = !amountError && toError === null if (!noErrors) { return @@ -445,6 +580,11 @@ SendTransactionScreen.prototype.onSubmit = function (event) { this.addToAddressBookIfNew(to) + if (editingTransactionId) { + backToConfirmScreen(editingTransactionId) + return + } + const txParams = { from, value: '0', @@ -457,8 +597,6 @@ SendTransactionScreen.prototype.onSubmit = function (event) { txParams.to = to } - clearSend() - selectedToken ? signTokenTx(selectedToken.address, to, amount, txParams) : signTx(txParams) diff --git a/ui/app/token-tracker.js b/ui/app/token-tracker.js new file mode 100644 index 000000000..e69de29bb diff --git a/ui/app/token-util.js b/ui/app/token-util.js index eec518556..f84051ef5 100644 --- a/ui/app/token-util.js +++ b/ui/app/token-util.js @@ -31,6 +31,15 @@ const tokenInfoGetter = function () { } } +function calcTokenAmount (value, decimals) { + const multiplier = Math.pow(10, Number(decimals || 0)) + const amount = Number(value / multiplier) + + return amount +} + + module.exports = { tokenInfoGetter, + calcTokenAmount, } diff --git a/yarn.lock b/yarn.lock index 8f63c1fff..f8bd06cab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8379,6 +8379,10 @@ redux-logger@^3.0.6: dependencies: deep-diff "^0.3.5" +redux-test-utils@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/redux-test-utils/-/redux-test-utils-0.1.3.tgz#0d89100f100f86c7c7214976eaece88e7e45bf74" + redux-thunk@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.2.0.tgz#e615a16e16b47a19a515766133d1e3e99b7852e5"