Merge pull request #388 from poanetwork/vb-send-all

"Send all" option for simple coin transfers
This commit is contained in:
Victor Baranov 2020-06-12 17:12:41 +03:00 committed by GitHub
commit 3ec0cccd32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 803 additions and 268 deletions

View File

@ -2,6 +2,7 @@
## Current Master
- [#388](https://github.com/poanetwork/nifty-wallet/pull/388) - (Feature) "Send all" option for simple coin transfers
- [#385](https://github.com/poanetwork/nifty-wallet/pull/385) - (Feature) Display value of current pending tx's nonce on send tx screen
- [#384](https://github.com/poanetwork/nifty-wallet/pull/384) - (Fix) placement of HW Connect button title
- [#383](https://github.com/poanetwork/nifty-wallet/pull/383) - (Chore) Replace POA-ETH Binance link to POA-BTC

View File

@ -10,7 +10,7 @@ import abiDecoder from 'abi-decoder'
abiDecoder.addABI(abi)
import TransactionStateManager from './tx-state-manager'
const TxGasUtil = require('./tx-gas-utils')
import TxGasUtil from './tx-gas-utils'
const PendingTransactionTracker = require('./pending-tx-tracker')
import NonceTracker from 'nonce-tracker'
import * as txUtils from './lib/util'

View File

@ -1,11 +1,8 @@
const EthQuery = require('ethjs-query')
const {
hexToBn,
BnMultiplyByFraction,
bnToHex,
} = require('../../lib/util')
const { addHexPrefix } = require('ethereumjs-util')
const SIMPLE_GAS_COST = '0x5208' // Hex for 21000, cost of a simple send.
import EthQuery from 'ethjs-query'
import { hexToBn, BnMultiplyByFraction, bnToHex } from '../../lib/util'
import { addHexPrefix } from 'ethereumjs-util'
import { MIN_GAS_LIMIT_HEX } from '../../../../ui/app/components/send/send.constants'
import log from 'loglevel'
/**
tx-gas-utils are gas utility methods for Transaction manager
@ -14,24 +11,31 @@ and used to do things like calculate gas of a tx.
@param {Object} provider - A network provider.
*/
class TxGasUtil {
export default class TxGasUtil {
constructor (provider) {
this.query = new EthQuery(provider)
}
/**
@param txMeta {Object} - the txMeta object
@returns {object} the txMeta object with the gas written to the txParams
@param {Object} txMeta - the txMeta object
@returns {GasAnalysisResult} The result of the gas analysis
*/
async analyzeGasUsage (txMeta) {
const block = await this.query.getBlockByNumber('latest', false)
let estimatedGasHex
// fallback to block gasLimit
const blockGasLimitBN = hexToBn(block.gasLimit)
const saferGasLimitBN = BnMultiplyByFraction(blockGasLimitBN, 19, 20)
let estimatedGasHex = bnToHex(saferGasLimitBN)
try {
estimatedGasHex = await this.estimateTxGas(txMeta, block.gasLimit)
} catch (err) {
} catch (error) {
log.warn(error)
txMeta.simulationFails = {
reason: err.message,
reason: error.message,
errorKey: error.errorKey,
debug: { blockNumber: block.number, blockGasLimit: block.gasLimit },
}
return txMeta
}
@ -63,9 +67,9 @@ class TxGasUtil {
if (recipient) code = await this.query.getCode(recipient)
if (hasRecipient && (!code || code === '0x' || code === '0x0')) {
txParams.gas = SIMPLE_GAS_COST
txParams.gas = MIN_GAS_LIMIT_HEX
txMeta.simpleSend = true // Prevents buffer addition
return SIMPLE_GAS_COST
return MIN_GAS_LIMIT_HEX
}
// if not, fall back to block gasLimit
@ -103,9 +107,9 @@ class TxGasUtil {
/**
Adds a gas buffer with out exceeding the block gas limit
@param initialGasLimitHex {string} - the initial gas limit to add the buffer too
@param blockGasLimitHex {string} - the block gas limit
@returns {string} the buffered gas limit as a hex string
@param {string} initialGasLimitHex - the initial gas limit to add the buffer too
@param {string} blockGasLimitHex - the block gas limit
@returns {string} - the buffered gas limit as a hex string
*/
addGasBuffer (initialGasLimitHex, blockGasLimitHex) {
const initialGasLimitBn = hexToBn(initialGasLimitHex)
@ -114,12 +118,14 @@ class TxGasUtil {
const bufferedGasLimitBn = initialGasLimitBn.muln(1.5)
// if initialGasLimit is above blockGasLimit, dont modify it
if (initialGasLimitBn.gt(upperGasLimitBn)) return bnToHex(initialGasLimitBn)
if (initialGasLimitBn.gt(upperGasLimitBn)) {
return bnToHex(initialGasLimitBn)
}
// if bufferedGasLimit is below blockGasLimit, use bufferedGasLimit
if (bufferedGasLimitBn.lt(upperGasLimitBn)) return bnToHex(bufferedGasLimitBn)
if (bufferedGasLimitBn.lt(upperGasLimitBn)) {
return bnToHex(bufferedGasLimitBn)
}
// otherwise use blockGasLimit
return bnToHex(upperGasLimitBn)
}
}
module.exports = TxGasUtil

View File

@ -443,7 +443,7 @@ module.exports = class MetamaskController extends EventEmitter {
setDProvider: this.setDProvider.bind(this),
markPasswordForgotten: this.markPasswordForgotten.bind(this),
unMarkPasswordForgotten: this.unMarkPasswordForgotten.bind(this),
getGasPrice: (cb) => cb(null, this.getGasPrice()),
getGasPrice: nodeify(this.getGasPrice, this),
getPendingNonce: nodeify(this.getPendingNonce, this),
// shapeshift
@ -1870,10 +1870,9 @@ module.exports = class MetamaskController extends EventEmitter {
* @returns {string} A hex representation of the suggested wei gas price.
*/
async getGasPrice () {
return new Promise(async (resolve, reject) => {
return new Promise(async (resolve) => {
const { networkController } = this
const networkIdStr = networkController.store.getState().network
const networkId = parseInt(networkIdStr)
const isETHC = networkId === CLASSIC_CODE || networkId === MAINNET_CODE

View File

@ -34,7 +34,8 @@ class ConfirmSeedScreen extends Component {
}
}
componentWillMount () {
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount () {
const { seedWords, history } = this.props
if (!seedWords) {

View File

@ -40,7 +40,8 @@ class CreatePasswordScreen extends Component {
this.animationEventEmitter = new EventEmitter()
}
componentWillMount () {
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount () {
const { isInitialized, history } = this.props
if (isInitialized) {

View File

@ -58,7 +58,8 @@ class BackupPhraseScreen extends Component {
}
}
componentWillMount () {
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount () {
const { seedWords, history } = this.props
if (!seedWords) {

View File

@ -26,7 +26,8 @@ export class ShapeShiftForm extends Component {
isLoading: false,
};
componentWillMount () {
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount () {
this.props.shapeShiftSubview()
}

View File

@ -159,7 +159,7 @@ AddSuggestedTokenScreen.prototype.render = function () {
)
}
AddSuggestedTokenScreen.prototype.componentWillMount = function () {
AddSuggestedTokenScreen.prototype.UNSAFE_componentWillMount = function () {
if (typeof global.ethereumProvider === 'undefined') return
}

View File

@ -316,7 +316,8 @@ export default class AddTokenScreen extends Component {
])
}
componentWillMount () {
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount () {
if (typeof global.ethereumProvider === 'undefined') return
this.eth = new Eth(global.ethereumProvider)

View File

@ -1,212 +1,219 @@
const Component = require('react').Component
import { Component } from 'react'
import PropTypes from 'prop-types'
import debounce from 'debounce'
import copyToClipboard from 'copy-to-clipboard'
import ENS from 'ethjs-ens'
import log from 'loglevel'
const h = require('react-hyperscript')
const inherits = require('util').inherits
const debounce = require('debounce')
const copyToClipboard = require('copy-to-clipboard')
const ENS = require('ethjs-ens')
const networkMap = require('ethjs-ens/lib/network-map.json')
const RNSRegistryData = require('@rsksmart/rns-registry/RNSRegistryData.json')
const ensRE = /.+\..+$/
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
const log = require('loglevel')
const { isValidENSAddress, isValidRNSAddress } = require('../util')
const {
RSK_CODE,
RSK_TESTNET_CODE,
} = require('../../../app/scripts/controllers/network/enums')
module.exports = EnsInput
class EnsInput extends Component {
static propTypes = {
network: PropTypes.string,
onChange: PropTypes.func,
placeholder: PropTypes.string,
name: PropTypes.string,
identities: PropTypes.object,
addressBook: PropTypes.array,
updateSendTo: PropTypes.func,
}
inherits(EnsInput, Component)
function EnsInput () {
Component.call(this)
}
render () {
const props = this.props
EnsInput.prototype.render = function () {
const props = this.props
function onInputChange () {
const recipient = document.querySelector('input[name="address"]').value
this.props.updateSendTo(recipient, ' ')
const network = this.props.network
const networkHasEnsSupport = getNetworkEnsSupport(network)
const networkHasRnsSupport = getNetworkRnsSupport(network)
if (!networkHasEnsSupport && !networkHasRnsSupport) return
function onInputChange () {
if (recipient.match(ensRE) === null) {
return this.setState({
loadingEns: false,
ensResolution: null,
ensFailure: null,
toError: null,
})
}
this.setState({
loadingEns: true,
})
this.checkName()
}
return (
h('div', {
style: { width: '100%' },
}, [
h('input.large-input', {
name: props.name,
placeholder: props.placeholder,
list: 'addresses',
onChange: onInputChange.bind(this),
}),
// The address book functionality.
h('datalist#addresses',
[
// Corresponds to the addresses owned.
Object.keys(props.identities).map((key) => {
const identity = props.identities[key]
return h('option', {
value: identity.address,
label: identity.name,
key: identity.address,
})
}),
// Corresponds to previously sent-to addresses.
props.addressBook.map((identity) => {
return h('option', {
value: identity.address,
label: identity.name,
key: identity.address,
})
}),
]),
this.ensIcon(),
])
)
}
componentDidMount () {
const network = this.props.network
const networkHasEnsSupport = getNetworkEnsSupport(network)
const networkHasRnsSupport = getNetworkRnsSupport(network)
if (!networkHasEnsSupport && !networkHasRnsSupport) return
this.setState({ ensResolution: ZERO_ADDRESS })
if (networkHasEnsSupport) {
const provider = global.ethereumProvider
this.ens = new ENS({ provider, network })
this.checkName = debounce(this.lookupEnsName.bind(this, 'ENS'), 200)
} else if (networkHasRnsSupport) {
const registryAddress = getRnsRegistryAddress(network)
const provider = global.ethereumProvider
this.ens = new ENS({ provider, network, registryAddress })
this.checkName = debounce(this.lookupEnsName.bind(this, 'RNS'), 200)
}
}
lookupEnsName (nameService) {
const recipient = document.querySelector('input[name="address"]').value
if (recipient.match(ensRE) === null) {
return this.setState({
const { ensResolution } = this.state
log.info(`${nameService} attempting to resolve name: ${recipient}`)
this.ens.lookup(recipient.trim())
.then((address) => {
if (address === ZERO_ADDRESS) throw new Error('No address has been set for this name.')
if (address !== ensResolution) {
this.setState({
loadingEns: false,
ensResolution: address,
nickname: recipient.trim(),
hoverText: address + '\nClick to Copy',
ensFailure: false,
toError: null,
})
}
})
.catch((reason) => {
const setStateObj = {
loadingEns: false,
ensResolution: null,
ensFailure: null,
ensResolution: recipient,
ensFailure: true,
toError: null,
}
if (
(isValidENSAddress(recipient) || isValidRNSAddress(recipient)) &&
reason.message === 'ENS name not defined.'
) {
setStateObj.hoverText = '${nameService} name not found'
setStateObj.toError = `${nameService.toLowerCase()}NameNotFound`
setStateObj.ensFailure = false
} else {
log.error(reason)
setStateObj.hoverText = reason.message
}
return this.setState(setStateObj)
})
}
componentDidUpdate (prevProps, prevState) {
const state = this.state || {}
const ensResolution = state.ensResolution
// If an address is sent without a nickname, meaning not from ENS or from
// the user's own accounts, a default of a one-space string is used.
const nickname = state.nickname || ' '
if (prevState && ensResolution && this.props.onChange &&
ensResolution !== prevState.ensResolution) {
this.props.onChange({ toAddress: ensResolution, nickname, toError: state.toError, toWarning: state.toWarning })
}
}
ensIcon () {
const { hoverText } = this.state || {}
return h('span', {
title: hoverText,
style: {
position: 'absolute',
padding: '6px 0px',
right: '0px',
transform: 'translatex(-40px)',
},
}, this.ensIconContents())
}
ensIconContents () {
const { loadingEns, ensFailure, ensResolution, toError } = this.state || { ensResolution: ZERO_ADDRESS}
if (toError) return
if (loadingEns) {
return h('img', {
src: 'images/loading.svg',
style: {
width: '30px',
height: '30px',
transform: 'translateY(-6px)',
marginRight: '-5px',
},
})
}
this.setState({
loadingEns: true,
})
this.checkName()
}
return (
h('div', {
style: { width: '100%' },
}, [
h('input.large-input', {
name: props.name,
placeholder: props.placeholder,
list: 'addresses',
onChange: onInputChange.bind(this),
}),
// The address book functionality.
h('datalist#addresses',
[
// Corresponds to the addresses owned.
Object.keys(props.identities).map((key) => {
const identity = props.identities[key]
return h('option', {
value: identity.address,
label: identity.name,
key: identity.address,
})
}),
// Corresponds to previously sent-to addresses.
props.addressBook.map((identity) => {
return h('option', {
value: identity.address,
label: identity.name,
key: identity.address,
})
}),
]),
this.ensIcon(),
])
)
}
EnsInput.prototype.componentDidMount = function () {
const network = this.props.network
const networkHasEnsSupport = getNetworkEnsSupport(network)
const networkHasRnsSupport = getNetworkRnsSupport(network)
this.setState({ ensResolution: ZERO_ADDRESS })
if (networkHasEnsSupport) {
const provider = global.ethereumProvider
this.ens = new ENS({ provider, network })
this.checkName = debounce(this.lookupEnsName.bind(this, 'ENS'), 200)
} else if (networkHasRnsSupport) {
const registryAddress = getRnsRegistryAddress(network)
const provider = global.ethereumProvider
this.ens = new ENS({ provider, network, registryAddress })
this.checkName = debounce(this.lookupEnsName.bind(this, 'RNS'), 200)
}
}
EnsInput.prototype.lookupEnsName = function (nameService) {
const recipient = document.querySelector('input[name="address"]').value
const { ensResolution } = this.state
log.info(`${nameService} attempting to resolve name: ${recipient}`)
this.ens.lookup(recipient.trim())
.then((address) => {
if (address === ZERO_ADDRESS) throw new Error('No address has been set for this name.')
if (address !== ensResolution) {
this.setState({
loadingEns: false,
ensResolution: address,
nickname: recipient.trim(),
hoverText: address + '\nClick to Copy',
ensFailure: false,
toError: null,
if (ensFailure) {
return h('i.fa.fa-warning.fa-lg.warning', {
style: {
color: '#df2265',
background: 'white',
},
})
}
})
.catch((reason) => {
const setStateObj = {
loadingEns: false,
ensResolution: recipient,
ensFailure: true,
toError: null,
if (ensResolution && (ensResolution !== ZERO_ADDRESS)) {
return h('i.fa.fa-check-circle.fa-lg.cursor-pointer', {
style: {
color: '#60db97',
background: 'white',
},
onClick: (event) => {
event.preventDefault()
event.stopPropagation()
copyToClipboard(ensResolution)
},
})
}
if (
(isValidENSAddress(recipient) || isValidRNSAddress(recipient)) &&
reason.message === 'ENS name not defined.'
) {
setStateObj.hoverText = `${nameService} name not found`
setStateObj.toError = `${nameService.toLowerCase()}NameNotFound`
setStateObj.ensFailure = false
} else {
log.error(reason)
setStateObj.hoverText = reason.message
}
return this.setState(setStateObj)
})
}
EnsInput.prototype.componentDidUpdate = function (prevProps, prevState) {
const state = this.state || {}
const ensResolution = state.ensResolution
// If an address is sent without a nickname, meaning not from ENS or from
// the user's own accounts, a default of a one-space string is used.
const nickname = state.nickname || ' '
if (prevState && ensResolution && this.props.onChange &&
ensResolution !== prevState.ensResolution) {
this.props.onChange({ toAddress: ensResolution, nickname, toError: state.toError, toWarning: state.toWarning })
}
}
EnsInput.prototype.ensIcon = function (recipient) {
const { hoverText } = this.state || {}
return h('span', {
title: hoverText,
style: {
position: 'absolute',
padding: '6px 0px',
right: '0px',
transform: 'translatex(-40px)',
},
}, this.ensIconContents(recipient))
}
EnsInput.prototype.ensIconContents = function (recipient) {
const { loadingEns, ensFailure, ensResolution, toError } = this.state || { ensResolution: ZERO_ADDRESS}
if (toError) return
if (loadingEns) {
return h('img', {
src: 'images/loading.svg',
style: {
width: '30px',
height: '30px',
transform: 'translateY(-6px)',
marginRight: '-5px',
},
})
}
if (ensFailure) {
return h('i.fa.fa-warning.fa-lg.warning', {
style: {
color: '#df2265',
background: 'white',
},
})
}
if (ensResolution && (ensResolution !== ZERO_ADDRESS)) {
return h('i.fa.fa-check-circle.fa-lg.cursor-pointer', {
style: {
color: '#60db97',
background: 'white',
},
onClick: (event) => {
event.preventDefault()
event.stopPropagation()
copyToClipboard(ensResolution)
},
})
}
}
@ -229,3 +236,5 @@ function getRnsRegistryAddress (network) {
return
}
module.exports = EnsInput

View File

@ -25,10 +25,11 @@ const { tokenInfoGetter, calcTokenAmount } = require('../../../ui/app/token-util
import BigNumber from 'bignumber.js'
import ethNetProps from 'eth-net-props'
import { getMetaMaskAccounts } from '../../../ui/app/selectors'
import { MIN_GAS_LIMIT_DEC } from '../../../ui/app/components/send/send.constants'
import * as Toast from './toast'
const MIN_GAS_PRICE_BN = new BN('0')
const MIN_GAS_LIMIT_BN = new BN('21000')
const MIN_GAS_LIMIT_BN = new BN(MIN_GAS_LIMIT_DEC)
const emptyAddress = '0x0000000000000000000000000000000000000000'
class PendingTx extends Component {
@ -472,12 +473,7 @@ class PendingTx extends Component {
},
}, [
h('.cell.label'),
h('.cell.value', {
style: {
fontFamily: 'Nunito Regular',
fontSize: '14px',
},
}, `Data included: ${dataLength} bytes`),
h('.cell.value', `Data included: ${dataLength} bytes`),
]),
]), // End of Table
@ -591,7 +587,8 @@ class PendingTx extends Component {
}
}
componentWillMount () {
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount () {
const txMeta = this.gatherTxMeta()
const txParams = txMeta.txParams || {}
if (this.props.isToken || this.state.isToken) {

View File

@ -0,0 +1,75 @@
import React, { Component } from 'react'
import PropTypes from 'prop-types'
export default class AmountMaxButton extends Component {
static propTypes = {
balance: PropTypes.string,
buttonDataLoading: PropTypes.bool,
clearMaxAmount: PropTypes.func,
inError: PropTypes.bool,
gasTotal: PropTypes.string,
maxModeOn: PropTypes.bool,
sendToken: PropTypes.object,
setAmountToMax: PropTypes.func,
setMaxModeTo: PropTypes.func,
updateGasData: PropTypes.func,
tokenBalance: PropTypes.string,
address: PropTypes.string,
amount: PropTypes.string,
to: PropTypes.string,
blockGasLimit: PropTypes.string,
data: PropTypes.string,
}
async setMaxAmount () {
const {
updateGasData,
address,
sendToken,
amount: value,
to,
data,
blockGasLimit,
setAmountToMax,
} = this.props
const params = { address, sendToken, blockGasLimit, to, value, data }
await updateGasData(params)
const {
balance,
gasTotal,
tokenBalance,
} = this.props
setAmountToMax({
balance,
gasTotal,
sendToken,
tokenBalance,
})
}
onMaxClick = () => {
const { setMaxModeTo, clearMaxAmount, maxModeOn } = this.props
if (!maxModeOn) {
setMaxModeTo(true)
this.setMaxAmount()
} else {
setMaxModeTo(false)
clearMaxAmount()
}
}
render () {
const { maxModeOn, buttonDataLoading, inError } = this.props
return (
<div className="send__amount-max secondary-description" onClick={buttonDataLoading || inError ? null : this.onMaxClick}>
<input type="checkbox" checked={maxModeOn} readOnly />
{'send max amount'}
</div>
)
}
}

View File

@ -0,0 +1,49 @@
import { connect } from 'react-redux'
import {
getGasTotal,
getSendToken,
getSendFromBalance,
getTokenBalance,
getSendMaxModeState,
getSendTo,
getSendHexData,
} from '../../../../../ui/app/selectors'
import { calcMaxAmount } from './amount-max-button.utils.js'
import {
updateSendAmount,
setMaxModeTo,
updateGasData,
} from '../../../../../ui/app/actions'
import AmountMaxButton from './amount-max-button.component'
export default connect(mapStateToProps, mapDispatchToProps)(AmountMaxButton)
function mapStateToProps (state) {
return {
balance: getSendFromBalance(state),
gasTotal: getGasTotal(state),
maxModeOn: getSendMaxModeState(state),
sendToken: getSendToken(state),
tokenBalance: getTokenBalance(state),
send: state.metamask.send,
amount: state.metamask.send.amount,
blockGasLimit: state.metamask.currentBlockGasLimit,
address: state.metamask.selectedAddress,
to: getSendTo(state),
data: getSendHexData(state),
}
}
function mapDispatchToProps (dispatch) {
return {
setAmountToMax: (maxAmountDataObject) => {
dispatch(updateSendAmount(calcMaxAmount(maxAmountDataObject)))
},
clearMaxAmount: () => {
dispatch(updateSendAmount('0'))
},
setMaxModeTo: (bool) => dispatch(setMaxModeTo(bool)),
updateGasData: (params) => dispatch(updateGasData(params)),
}
}

View File

@ -0,0 +1,32 @@
import { multiplyCurrencies, subtractCurrencies, BIG_NUMBER_WEI_MULTIPLIER } from '../../../../../ui/app/conversion-util'
import ethUtil from 'ethereumjs-util'
import BigNumber from 'bignumber.js'
export function calcMaxAmount ({ balance, gasTotal, sendToken, tokenBalance }) {
const { decimals } = sendToken || {}
const multiplier = Math.pow(10, Number(decimals || 0))
let maxBalance
if (sendToken) {
maxBalance = multiplyCurrencies(
tokenBalance,
multiplier,
{
toNumericBase: 'hex',
multiplicandBase: 16,
},
)
} else {
const maxBalanceInWei =
subtractCurrencies(
ethUtil.addHexPrefix(balance),
ethUtil.addHexPrefix(gasTotal),
{ toNumericBase: 'dec' },
)
const maxBalanceInWeiBN = new BigNumber(maxBalanceInWei.toString())
maxBalance = maxBalanceInWeiBN.div(BIG_NUMBER_WEI_MULTIPLIER).toString()
}
return maxBalance
}

View File

@ -0,0 +1 @@
export { default } from './amount-max-button.container'

View File

@ -0,0 +1,97 @@
import React from 'react'
import assert from 'assert'
import { shallow } from 'enzyme'
import sinon from 'sinon'
import AmountMaxButton from '../amount-max-button.component.js'
describe('AmountMaxButton Component', function () {
let wrapper
let instance
const propsMethodSpies = {
setAmountToMax: sinon.spy(),
setMaxModeTo: sinon.spy(),
}
const MOCK_EVENT = { preventDefault: () => {} }
before(function () {
sinon.spy(AmountMaxButton.prototype, 'setMaxAmount')
})
beforeEach(function () {
wrapper = shallow((
<AmountMaxButton
balance="mockBalance"
gasTotal="mockGasTotal"
maxModeOn={false}
sendToken={ { address: 'mockTokenAddress' } }
setAmountToMax={propsMethodSpies.setAmountToMax}
setMaxModeTo={propsMethodSpies.setMaxModeTo}
tokenBalance="mockTokenBalance"
/>
), {
context: {
t: (str) => str + '_t',
metricsEvent: () => {},
},
})
instance = wrapper.instance()
})
afterEach(function () {
propsMethodSpies.setAmountToMax.resetHistory()
propsMethodSpies.setMaxModeTo.resetHistory()
AmountMaxButton.prototype.setMaxAmount.resetHistory()
})
after(function () {
sinon.restore()
})
describe('setMaxAmount', function () {
it('should call setAmountToMax with the correct params', function () {
assert.equal(propsMethodSpies.setAmountToMax.callCount, 0)
instance.setMaxAmount()
assert.equal(propsMethodSpies.setAmountToMax.callCount, 1)
assert.deepEqual(
propsMethodSpies.setAmountToMax.getCall(0).args,
[{
balance: 'mockBalance',
gasTotal: 'mockGasTotal',
sendToken: { address: 'mockTokenAddress' },
tokenBalance: 'mockTokenBalance',
}],
)
})
})
describe('render', function () {
it('should render an element with a send-v2__amount-max class', function () {
assert(wrapper.exists('.send-v2__amount-max'))
})
it('should call setMaxModeTo and setMaxAmount when the checkbox is checked', function () {
const {
onClick,
} = wrapper.find('.send-v2__amount-max').props()
assert.equal(AmountMaxButton.prototype.setMaxAmount.callCount, 0)
assert.equal(propsMethodSpies.setMaxModeTo.callCount, 0)
onClick(MOCK_EVENT)
assert.equal(AmountMaxButton.prototype.setMaxAmount.callCount, 1)
assert.equal(propsMethodSpies.setMaxModeTo.callCount, 1)
assert.deepEqual(
propsMethodSpies.setMaxModeTo.getCall(0).args,
[true],
)
})
it('should render the expected text when maxModeOn is false', function () {
wrapper.setProps({ maxModeOn: false })
assert.equal(wrapper.find('.send-v2__amount-max').text(), 'max_t')
})
})
})

View File

@ -0,0 +1,93 @@
import assert from 'assert'
import proxyquire from 'proxyquire'
import sinon from 'sinon'
let mapStateToProps
let mapDispatchToProps
const actionSpies = {
setMaxModeTo: sinon.spy(),
updateSendAmount: sinon.spy(),
}
const duckActionSpies = {
updateSendErrors: sinon.spy(),
}
proxyquire('../amount-max-button.container.js', {
'react-redux': {
connect: (ms, md) => {
mapStateToProps = ms
mapDispatchToProps = md
return () => ({})
},
},
'../../../../../selectors': {
getGasTotal: (s) => `mockGasTotal:${s}`,
getSendToken: (s) => `mockSendToken:${s}`,
getSendFromBalance: (s) => `mockBalance:${s}`,
getTokenBalance: (s) => `mockTokenBalance:${s}`,
getSendMaxModeState: (s) => `mockMaxModeOn:${s}`,
getBasicGasEstimateLoadingStatus: (s) => `mockButtonDataLoading:${s}`,
},
'./amount-max-button.utils.js': { calcMaxAmount: (mockObj) => mockObj.val + 1 },
'../../../../../store/actions': actionSpies,
'../../../../../ducks/send/send.duck': duckActionSpies,
})
describe('amount-max-button container', function () {
describe('mapStateToProps()', function () {
it('should map the correct properties to props', function () {
assert.deepEqual(mapStateToProps('mockState'), {
balance: 'mockBalance:mockState',
buttonDataLoading: 'mockButtonDataLoading:mockState',
gasTotal: 'mockGasTotal:mockState',
maxModeOn: 'mockMaxModeOn:mockState',
sendToken: 'mockSendToken:mockState',
tokenBalance: 'mockTokenBalance:mockState',
})
})
})
describe('mapDispatchToProps()', function () {
let dispatchSpy
let mapDispatchToPropsObject
beforeEach(function () {
dispatchSpy = sinon.spy()
mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy)
})
describe('setAmountToMax()', function () {
it('should dispatch an action', function () {
mapDispatchToPropsObject.setAmountToMax({ val: 11, foo: 'bar' })
assert(dispatchSpy.calledTwice)
assert(duckActionSpies.updateSendErrors.calledOnce)
assert.deepEqual(
duckActionSpies.updateSendErrors.getCall(0).args[0],
{ amount: null },
)
assert(actionSpies.updateSendAmount.calledOnce)
assert.equal(
actionSpies.updateSendAmount.getCall(0).args[0],
12,
)
})
})
describe('setMaxModeTo()', function () {
it('should dispatch an action', function () {
mapDispatchToPropsObject.setMaxModeTo('mockVal')
assert(dispatchSpy.calledOnce)
assert.equal(
actionSpies.setMaxModeTo.getCall(0).args[0],
'mockVal',
)
})
})
})
})

View File

@ -0,0 +1,27 @@
import assert from 'assert'
import {
calcMaxAmount,
} from '../amount-max-button.utils.js'
describe('amount-max-button utils', function () {
describe('calcMaxAmount()', function () {
it('should calculate the correct amount when no sendToken defined', function () {
assert.deepEqual(calcMaxAmount({
balance: 'ffffff',
gasTotal: 'ff',
sendToken: false,
}), 'ffff00')
})
it('should calculate the correct amount when a sendToken is defined', function () {
assert.deepEqual(calcMaxAmount({
sendToken: {
decimals: 10,
},
tokenBalance: '64',
}), 'e8d4a51000')
})
})
})

View File

@ -139,7 +139,8 @@ class SendTransactionScreen extends PersistentForm {
PersistentForm.call(this)
}
componentWillMount () {
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount () {
this.getContractMethods()
}

View File

@ -14,14 +14,14 @@ import ethUtil from 'ethereumjs-util'
import SendProfile from './send-profile'
import SendHeader from './send-header'
import ErrorComponent from '../error'
import { getMetaMaskAccounts } from '../../../../ui/app/selectors'
import { getMetaMaskAccounts, getGasTotal, getTokenBalance, getCurrentEthBalance, getSendToken, getSendTo } from '../../../../ui/app/selectors'
import * as Toast from '../toast'
import AmountMaxButton from './amount-max-button'
const optionalDataLabelStyle = {
background: '#ffffff',
color: '#333333',
marginTop: '16px',
marginBottom: '16px',
}
const optionalDataValueStyle = {
width: '100%',
@ -33,11 +33,12 @@ class SendTransactionScreen extends PersistentForm {
super(props)
this.state = {
pendingNonce: null,
recipient: null,
}
}
async getPendingNonce () {
const pendingNonce = await this.props.dispatch(actions.getPendingNonce(this.props.address))
async fetchPendingNonce () {
const pendingNonce = await this.props.getPendingNonce(this.props.address)
this.setState({pendingNonce: pendingNonce})
}
@ -50,6 +51,7 @@ class SendTransactionScreen extends PersistentForm {
identities,
addressBook,
error,
updateSendTo,
} = props
return (
@ -73,6 +75,8 @@ class SendTransactionScreen extends PersistentForm {
network={network}
identities={identities}
addressBook={addressBook}
value={this.state.recipient || ''}
updateSendTo={updateSendTo}
/>
</section>
@ -88,14 +92,21 @@ class SendTransactionScreen extends PersistentForm {
dataset={{
persistentFormid: 'tx-amount',
}}
disabled={!!this.props.maxModeOn}
value={this.props.amount || ''}
onChange={(e) => {
const newAmount = e.target.value
this.props.updateSendAmount(newAmount)
}}
/>
<button
onClick={this.onSubmit.bind(this)}>
Next
</button>
</button>
</section>
<section className="flex-row flex-left amount-max-container"><AmountMaxButton /></section>
<h3 className="flex-center"
style={optionalDataLabelStyle}
@ -111,6 +122,10 @@ class SendTransactionScreen extends PersistentForm {
dataset={{
persistentFormid: 'tx-data',
}}
onChange={(e) => {
const newTxData = e.target.value
this.props.updateSendHexData(newTxData)
}}
/>
</section>
@ -137,16 +152,23 @@ class SendTransactionScreen extends PersistentForm {
}
componentDidMount () {
this.getPendingNonce()
this._isMounted = true
if (this._isMounted) {
this.fetchPendingNonce()
}
}
componentWillUnmount () {
this.props.dispatch(actions.displayWarning(''))
this.props.displayWarning('')
this.props.updateSendAmount(null)
this.props.setMaxModeTo(false)
this.props.updateSendTo('')
this._isMounted = false
}
navigateToAccounts (event) {
event.stopPropagation()
this.props.dispatch(actions.showAccountsPage())
this.props.showAccountsPage()
}
recipientDidChange (recipient, nickname) {
@ -154,6 +176,7 @@ class SendTransactionScreen extends PersistentForm {
recipient: recipient,
nickname: nickname,
})
this.props.updateSendTo(recipient, nickname)
}
onSubmit () {
@ -175,14 +198,14 @@ class SendTransactionScreen extends PersistentForm {
if (isNaN(input) || input === '') {
message = 'Invalid ether value.'
return this.props.dispatch(actions.displayWarning(message))
return this.props.displayWarning(message)
}
if (parts[1]) {
const decimal = parts[1]
if (decimal.length > 18) {
message = 'Ether amount is too precise.'
return this.props.dispatch(actions.displayWarning(message))
return this.props.displayWarning(message)
}
}
@ -193,32 +216,32 @@ class SendTransactionScreen extends PersistentForm {
if (value.gt(balance)) {
message = 'Insufficient funds.'
return this.props.dispatch(actions.displayWarning(message))
return this.props.displayWarning(message)
}
if (input < 0) {
message = 'Can not send negative amounts of ETH.'
return this.props.dispatch(actions.displayWarning(message))
return this.props.displayWarning(message)
}
if ((isInvalidChecksumAddress(recipient, this.props.network))) {
message = 'Recipient address checksum is invalid.'
return this.props.dispatch(actions.displayWarning(message))
return this.props.displayWarning(message)
}
if ((!isValidAddress(recipient, this.props.network) && !txData) || (!recipient && !txData)) {
message = 'Recipient address is invalid.'
return this.props.dispatch(actions.displayWarning(message))
return this.props.displayWarning(message)
}
if (!isHex(ethUtil.stripHexPrefix(txData)) && txData) {
message = 'Transaction data must be hex string.'
return this.props.dispatch(actions.displayWarning(message))
return this.props.displayWarning(message)
}
this.props.dispatch(actions.hideWarning())
this.props.hideWarning()
this.props.dispatch(actions.addToAddressBook(recipient, nickname))
this.props.addToAddressBook(recipient, nickname)
const txParams = {
from: this.props.address,
@ -229,19 +252,30 @@ class SendTransactionScreen extends PersistentForm {
if (txData) txParams.data = txData
if (txCustomNonce) txParams.nonce = '0x' + parseInt(txCustomNonce, 10).toString(16)
this.props.dispatch(actions.signTx(txParams))
this.props.signTx(txParams)
}
}
function mapStateToProps (state) {
const accounts = getMetaMaskAccounts(state)
const balance = getCurrentEthBalance(state)
const gasTotal = getGasTotal(state)
const result = {
send: state.metamask.send,
address: state.metamask.selectedAddress,
accounts,
identities: state.metamask.identities,
warning: state.appState.warning,
network: state.metamask.network,
addressBook: state.metamask.addressBook,
balance,
gasTotal,
to: getSendTo(state),
sendToken: getSendToken(state),
tokenBalance: getTokenBalance(state),
amount: state.metamask.send.amount,
maxModeOn: state.metamask.send.maxModeOn,
blockGasLimit: state.metamask.currentBlockGasLimit,
}
result.error = result.warning && result.warning.split('.')[0]
@ -251,4 +285,19 @@ function mapStateToProps (state) {
return result
}
module.exports = connect(mapStateToProps)(SendTransactionScreen)
function mapDispatchToProps (dispatch) {
return {
addToAddressBook: (recipient, nickname) => dispatch(actions.addToAddressBook(recipient, nickname)),
showAccountsPage: () => dispatch(actions.showAccountsPage()),
displayWarning: (msg) => dispatch(actions.displayWarning(msg)),
hideWarning: () => dispatch(actions.hideWarning()),
getPendingNonce: (address) => dispatch(actions.getPendingNonce(address)),
signTx: (txParams) => dispatch(actions.signTx(txParams)),
updateSendAmount: (amount) => dispatch(actions.updateSendAmount(amount)),
setMaxModeTo: (maxMode) => dispatch(actions.setMaxModeTo(maxMode)),
updateSendTo: (to, nickname) => dispatch(actions.updateSendTo(to, nickname)),
updateSendHexData: (txData) => dispatch(actions.updateSendHexData(txData)),
}
}
module.exports = connect(mapStateToProps, mapDispatchToProps)(SendTransactionScreen)

View File

@ -630,10 +630,6 @@ input.large-input {
/* accounts screen */
.identity-section {
}
.identity-section .identity-panel {
background: #E9E9E9;
border-bottom: 1px solid #B1B1B1;
@ -670,10 +666,6 @@ input.large-input {
flex-grow: 10;
}
.name-label{
}
.unapproved-tx-icon {
height: 16px;
width: 16px;
@ -735,14 +727,14 @@ input.large-input {
/* Send Screen */
.send-screen {
}
.send-screen section {
margin: 10px 30px;
}
.send-screen section.amount-max-container {
margin-top: 0;
}
.send-screen input {
width: 100%;
font-size: 14px;
@ -750,17 +742,34 @@ input.large-input {
border-radius: 3px;
}
.send__amount-max {
display: flex;
width: 100%;
align-items: center;
}
.send__amount-max input {
width: 10px;
height: 10px;
margin-left: 0;
}
.secondary-description {
font-family: 'Nunito Regular';
font-size: 14px
}
/* Ether Balance Widget */
.ether-balance-label {
color: #ABA9AA;
}
.icon-size{
.icon-size {
width: 20px;
}
.info{
.info {
font-family: 'Nunito Regular';
padding-bottom: 10px;
display: inline-block;
@ -829,10 +838,6 @@ input.large-input {
font-family: Nunito Semibold;
}
.buy-radio {
}
.eth-warning{
transition: opacity 400ms ease-in, transform 400ms ease-in;
}

View File

@ -3,7 +3,7 @@ const Transaction = require('ethereumjs-tx')
const { hexToBn, bnToHex } = require('../../../../../app/scripts/lib/util')
const TxUtils = require('../../../../../app/scripts/controllers/transactions/tx-gas-utils')
import TxUtils from '../../../../../app/scripts/controllers/transactions/tx-gas-utils'
describe('txUtils', function () {

View File

@ -1061,7 +1061,6 @@ function setGasTotal (gasTotal) {
function updateGasData ({
blockGasLimit,
recentBlocks,
selectedAddress,
selectedToken,
to,
@ -1100,11 +1099,13 @@ function updateGasData ({
dispatch(actions.setGasTotal(gasEstimate))
dispatch(updateSendErrors({ gasLoadingError: null }))
dispatch(actions.gasLoadingFinished())
return Promise.resolve()
})
.catch(err => {
log.error(err)
dispatch(updateSendErrors({ gasLoadingError: 'gasLoadingError' }))
dispatch(actions.gasLoadingFinished())
return Promise.reject()
})
}
}

View File

@ -248,4 +248,5 @@ module.exports = {
conversionMax,
toNegative,
subtractCurrencies,
BIG_NUMBER_WEI_MULTIPLIER,
}

View File

@ -1,10 +1,16 @@
const abi = require('human-standard-token-abi')
import abi from 'human-standard-token-abi'
import { pipe } from 'ramda'
import {
transactionsSelector,
} from './selectors/transactions'
const {
import { addHexPrefix } from 'ethereumjs-util'
import {
conversionUtil,
multiplyCurrencies,
} = require('./conversion-util')
} from './conversion-util'
import {
calcGasTotal,
} from './components/send/send.utils'
const selectors = {
getSelectedAddress,
@ -33,6 +39,19 @@ const selectors = {
preferencesSelector,
getMetaMaskAccounts,
getUsePhishDetect,
getGasLimit,
getGasPrice,
getGasTotal,
getGasPriceInHexWei,
priceEstimateToWei,
getCurrentEthBalance,
getSendToken,
getTokenBalance,
getSendFromBalance,
getSendFromObject,
getSendTo,
getSendHexData,
getTargetAccount,
}
module.exports = selectors
@ -151,12 +170,20 @@ function getSendFrom (state) {
return state.metamask.send.from
}
function getSendTo (state) {
return state.metamask.send.to
}
function getSendAmount (state) {
return state.metamask.send.amount
}
function getSendMaxModeState (state) {
return state.metamask.send.maxModeOn
return state.metamask.send.maxModeOn || false
}
function getSendHexData (state) {
return state.metamask.send.data
}
function getCurrentCurrency (state) {
@ -203,3 +230,62 @@ function getTotalUnapprovedCount ({ metamask }) {
function preferencesSelector ({ metamask }) {
return metamask.preferences
}
function getGasLimit (state) {
return state.metamask.send.gasLimit || '0'
}
function getGasPrice (state) {
return state.metamask.send.gasPrice
}
function getGasTotal (state) {
return calcGasTotal(getGasLimit(state), getGasPrice(state))
}
function priceEstimateToWei (priceEstimate) {
return conversionUtil(priceEstimate, {
fromNumericBase: 'hex',
toNumericBase: 'hex',
fromDenomination: 'GWEI',
toDenomination: 'WEI',
numberOfDecimals: 9,
})
}
function getGasPriceInHexWei (price) {
return pipe(
(x) => conversionUtil(x, { fromNumericBase: 'dec', toNumericBase: 'hex' }),
priceEstimateToWei,
addHexPrefix,
)(price)
}
function getCurrentEthBalance (state) {
return getCurrentAccountWithSendEtherInfo(state).balance
}
function getSendToken (state) {
return state.metamask.send.token
}
function getTokenBalance (state) {
return state.metamask.send.tokenBalance
}
function getSendFromBalance (state) {
const fromAccount = getSendFromObject(state)
return fromAccount.balance
}
function getSendFromObject (state) {
const fromAddress = getSendFrom(state)
return fromAddress
? getTargetAccount(state, fromAddress)
: getSelectedAccount(state)
}
function getTargetAccount (state, targetAddress) {
const accounts = getMetaMaskAccounts(state)
return accounts[targetAddress]
}

View File

@ -25,7 +25,8 @@ class WelcomeScreen extends Component {
this.animationEventEmitter = new EventEmitter()
}
componentWillMount () {
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount () {
const { history, welcomeScreenSeen } = this.props
if (welcomeScreenSeen) {