diff options
Diffstat (limited to 'ui/app/send.js')
-rw-r--r-- | ui/app/send.js | 698 |
1 files changed, 476 insertions, 222 deletions
diff --git a/ui/app/send.js b/ui/app/send.js index e59c1130e..5643d927b 100644 --- a/ui/app/send.js +++ b/ui/app/send.js @@ -1,42 +1,325 @@ -const inherits = require('util').inherits +const { inherits } = require('util') const PersistentForm = require('../lib/persistent-form') const h = require('react-hyperscript') const connect = require('react-redux').connect const Identicon = require('./components/identicon') -const actions = require('./actions') -const util = require('./util') -const numericBalance = require('./util').numericBalance -const addressSummary = require('./util').addressSummary -const isHex = require('./util').isHex -const EthBalance = require('./components/eth-balance') const EnsInput = require('./components/ens-input') -const ethUtil = require('ethereumjs-util') +const GasTooltip = require('./components/send/gas-tooltip') +const CurrencyToggle = require('./components/send/currency-toggle') +const GasFeeDisplay = require('./components/send/gas-fee-display') +const { getSelectedIdentity } = require('./selectors') + +const { + showAccountsPage, + backToAccountDetail, + displayWarning, + hideWarning, + addToAddressBook, + signTx, + estimateGas, + getGasPrice, +} = require('./actions') +const { stripHexPrefix, addHexPrefix } = require('ethereumjs-util') +const { isHex, numericBalance, isValidAddress, allNull } = require('./util') +const { conversionUtil, conversionGreaterThan } = require('./conversion-util') + module.exports = connect(mapStateToProps)(SendTransactionScreen) function mapStateToProps (state) { - var result = { - address: state.metamask.selectedAddress, - accounts: state.metamask.accounts, - identities: state.metamask.identities, - warning: state.appState.warning, - network: state.metamask.network, - addressBook: state.metamask.addressBook, - conversionRate: state.metamask.conversionRate, - currentCurrency: state.metamask.currentCurrency, - } - - result.error = result.warning && result.warning.split('.')[0] - - result.account = result.accounts[result.address] - result.identity = result.identities[result.address] - result.balance = result.account ? numericBalance(result.account.balance) : null + const { + selectedAddress: address, + accounts, + identities, + network, + addressBook, + conversionRate, + currentBlockGasLimit: blockGasLimit, + } = state.metamask + const { warning } = state.appState + const selectedIdentity = getSelectedIdentity(state) + const account = accounts[address] - return result + return { + address, + accounts, + identities, + network, + addressBook, + conversionRate, + blockGasLimit, + warning, + selectedIdentity, + error: warning && warning.split('.')[0], + account, + identity: identities[address], + balance: account ? account.balance : null, + } } inherits(SendTransactionScreen, PersistentForm) function SendTransactionScreen () { PersistentForm.call(this) + + // [WIP] These are the bare minimum of tx props needed to sign a transaction + // We will need a few more for contract-related interactions + this.state = { + newTx: { + from: '', + to: '', + amountToSend: '0x0', + gasPrice: null, + gas: null, + amount: '0x0', + txData: null, + memo: '', + }, + activeCurrency: 'USD', + tooltipIsOpen: false, + errors: {}, + isValid: false, + } + + this.back = this.back.bind(this) + this.closeTooltip = this.closeTooltip.bind(this) + this.onSubmit = this.onSubmit.bind(this) + this.setActiveCurrency = this.setActiveCurrency.bind(this) + this.toggleTooltip = this.toggleTooltip.bind(this) + this.validate = this.validate.bind(this) + this.getAmountToSend = this.getAmountToSend.bind(this) + this.setErrorsFor = this.setErrorsFor.bind(this) + this.clearErrorsFor = this.clearErrorsFor.bind(this) + + this.renderFromInput = this.renderFromInput.bind(this) + this.renderToInput = this.renderToInput.bind(this) + this.renderAmountInput = this.renderAmountInput.bind(this) + this.renderGasInput = this.renderGasInput.bind(this) + this.renderMemoInput = this.renderMemoInput.bind(this) + this.renderErrorMessage = this.renderErrorMessage.bind(this) +} + +SendTransactionScreen.prototype.componentWillMount = function () { + const { newTx } = this.state + const { address } = this.props + + Promise.all([ + this.props.dispatch(getGasPrice()), + this.props.dispatch(estimateGas({ + from: address, + gas: '746a528800', + })), + ]) + .then(([blockGasPrice, estimatedGas]) => { + console.log({ blockGasPrice, estimatedGas}) + this.setState({ + newTx: { + ...newTx, + gasPrice: blockGasPrice, + gas: estimatedGas, + }, + }) + }) +} + +SendTransactionScreen.prototype.renderErrorMessage = function(errorType, warning) { + const { errors } = this.state + const errorMessage = errors[errorType]; + + return errorMessage || warning + ? h('div.send-screen-input-wrapper__error-message', [ errorMessage || warning ]) + : null +} + +SendTransactionScreen.prototype.renderFromInput = function (from, identities) { + return h('div.send-screen-input-wrapper', [ + + h('div', 'From:'), + + h('input.large-input.send-screen-input', { + list: 'accounts', + placeholder: 'Account', + value: from, + onChange: (event) => { + this.setState({ + newTx: { + ...this.state.newTx, + from: event.target.value, + }, + }) + }, + onBlur: () => this.setErrorsFor('from'), + onFocus: event => { + this.clearErrorsFor('from') + this.state.newTx.from && event.target.select() + }, + }), + + h('datalist#accounts', [ + Object.entries(identities).map(([key, { address, name }]) => { + return h('option', { + value: address, + label: name, + key: address, + }) + }), + ]), + + this.renderErrorMessage('from'), + + ]) +} + +SendTransactionScreen.prototype.renderToInput = function (to, identities, addressBook) { + return h('div.send-screen-input-wrapper', [ + + h('div', 'To:'), + + h('input.large-input.send-screen-input', { + name: 'address', + list: 'addresses', + placeholder: 'Address', + value: to, + onChange: (event) => { + this.setState({ + newTx: { + ...this.state.newTx, + to: event.target.value, + }, + }) + }, + onBlur: () => { + this.setErrorsFor('to') + }, + onFocus: event => { + this.clearErrorsFor('to') + this.state.newTx.to && event.target.select() + }, + }), + + h('datalist#addresses', [ + // Corresponds to the addresses owned. + ...Object.entries(identities).map(([key, { address, name }]) => { + return h('option', { + value: address, + label: name, + key: address, + }) + }), + // Corresponds to previously sent-to addresses. + ...addressBook.map(({ address, name }) => { + return h('option', { + value: address, + label: name, + key: address, + }) + }), + ]), + + this.renderErrorMessage('to'), + + ]) +} + +SendTransactionScreen.prototype.renderAmountInput = function (activeCurrency) { + return h('div.send-screen-input-wrapper', [ + + h('div.send-screen-amount-labels', [ + h('span', 'Amount'), + h(CurrencyToggle, { + activeCurrency, + onClick: (newCurrency) => this.setActiveCurrency(newCurrency), + }), // holding on icon from design + ]), + + h('input.large-input.send-screen-input', { + placeholder: `0 ${activeCurrency}`, + type: 'number', + onChange: (event) => { + const amountToSend = event.target.value + ? this.getAmountToSend(event.target.value) + : '0x0' + + this.setState({ + newTx: Object.assign( + this.state.newTx, + { + amount: event.target.value, + amountToSend: amountToSend, + } + ), + }) + }, + onBlur: () => { + this.setErrorsFor('amount') + }, + onFocus: () => this.clearErrorsFor('amount'), + }), + + this.renderErrorMessage('amount'), + + ]) +} + +SendTransactionScreen.prototype.renderGasInput = function (gasPrice, gas, activeCurrency, conversionRate, blockGasLimit) { + return h('div.send-screen-input-wrapper', [ + this.state.tooltipIsOpen && h(GasTooltip, { + className: 'send-tooltip', + gasPrice, + gasLimit: gas, + onClose: this.closeTooltip, + onFeeChange: ({gasLimit, gasPrice}) => { + this.setState({ + newTx: { + ...this.state.newTx, + gas: gasLimit, + gasPrice, + }, + }) + }, + }), + + h('div.send-screen-gas-labels', [ + h('span', [ + h('i.fa.fa-bolt'), + 'Gas fee:', + ]), + h('span', 'What\'s this?'), + ]), + + // TODO: handle loading time when switching to USD + h('div.large-input.send-screen-gas-input', {}, [ + h(GasFeeDisplay, { + activeCurrency, + conversionRate, + gas, + gasPrice, + blockGasLimit, + }), + h('div.send-screen-gas-input-customize', { + onClick: this.toggleTooltip, + }, [ + 'Customize', + ]), + ]), + + ]) +} + +SendTransactionScreen.prototype.renderMemoInput = function () { + return h('div.send-screen-input-wrapper', [ + h('div', 'Transaction memo (optional)'), + h('input.large-input.send-screen-input', { + onChange: () => { + this.setState({ + newTx: Object.assign( + this.state.newTx, + { + memo: event.target.value, + } + ), + }) + }, + }), + ]) } SendTransactionScreen.prototype.render = function () { @@ -44,250 +327,221 @@ SendTransactionScreen.prototype.render = function () { const props = this.props const { - address, - account, - identity, - network, + warning, identities, addressBook, conversionRate, - currentCurrency, } = props + const { + blockGasLimit, + newTx, + activeCurrency, + isValid, + } = this.state + const { gas, gasPrice } = newTx + return ( - h('.send-screen.flex-column.flex-grow', [ + h('div.send-screen-wrapper', [ + // Main Send token Card + h('div.send-screen-card', [ - // - // Sender Profile - // + h('img.send-eth-icon', { src: '../images/eth_logo.svg' }), - h('.account-data-subsection.flex-row.flex-grow', { - style: { - margin: '0 20px', - }, - }, [ + h('div.send-screen__title', 'Send'), - // header - identicon + nav - h('.flex-row.flex-space-between', { - style: { - marginTop: '15px', - }, - }, [ - // back button - h('i.fa.fa-arrow-left.fa-lg.cursor-pointer.color-orange', { - onClick: this.back.bind(this), - }), - - // large identicon - h('.identicon-wrapper.flex-column.flex-center.select-none', [ - h(Identicon, { - diameter: 62, - address: address, - }), - ]), - - // invisible place holder - h('i.fa.fa-users.fa-lg.invisible', { - style: { - marginTop: '28px', - }, - }), - - ]), - - // account label - - h('.flex-column', { - style: { - marginTop: '10px', - alignItems: 'flex-start', - }, - }, [ - h('h2.font-medium.color-forest.flex-center', { - style: { - paddingTop: '8px', - marginBottom: '8px', - }, - }, identity && identity.name), - - // address and getter actions - h('.flex-row.flex-center', { - style: { - marginBottom: '8px', - }, - }, [ - - h('div', { - style: { - lineHeight: '16px', - }, - }, addressSummary(address)), - - ]), - - // balance - h('.flex-row.flex-center', [ - - h(EthBalance, { - value: account && account.balance, - conversionRate, - currentCurrency, - }), - - ]), - ]), - ]), + h('div.send-screen__subtitle', 'Send Ethereum to anyone with an Ethereum account'), - // - // Required Fields - // - - h('h3.flex-center.text-transform-uppercase', { - style: { - background: '#EBEBEB', - color: '#AEAEAE', - marginTop: '15px', - marginBottom: '16px', - }, - }, [ - 'Send Transaction', - ]), + this.renderFromInput(this.state.newTx.from, identities), - // error message - props.error && h('span.error.flex-center', props.error), - - // 'to' field - h('section.flex-row.flex-center', [ - h(EnsInput, { - name: 'address', - placeholder: 'Recipient Address', - onChange: this.recipientDidChange.bind(this), - network, - identities, - addressBook, - }), - ]), + this.renderToInput(this.state.newTx.to, identities, addressBook), - // 'amount' and send button - h('section.flex-row.flex-center', [ + this.renderAmountInput(activeCurrency), - h('input.large-input', { - name: 'amount', - placeholder: 'Amount', - type: 'number', - style: { - marginRight: '6px', - }, - dataset: { - persistentFormId: 'tx-amount', - }, - }), + this.renderGasInput( + gasPrice || '0x0', + gas || '0x0', + activeCurrency, + conversionRate, + blockGasLimit + ), - h('button.primary', { - onClick: this.onSubmit.bind(this), - style: { - textTransform: 'uppercase', - }, - }, 'Next'), + this.renderMemoInput(), - ]), + this.renderErrorMessage(null, warning), - // - // Optional Fields - // - h('h3.flex-center.text-transform-uppercase', { - style: { - background: '#EBEBEB', - color: '#AEAEAE', - marginTop: '16px', - marginBottom: '16px', - }, - }, [ - 'Transaction Data (optional)', ]), - // 'data' field + // Buttons underneath card h('section.flex-column.flex-center', [ - h('input.large-input', { - name: 'txData', - placeholder: '0x01234', - style: { - width: '100%', - resize: 'none', - }, - dataset: { - persistentFormId: 'tx-data', - }, - }), + h('button.btn-secondary.send-screen__send-button', { + className: !isValid && 'send-screen__send-button__disabled', + onClick: (event) => isValid && this.onSubmit(event), + }, 'Next'), + h('button.btn-tertiary.send-screen__cancel-button', { + onClick: this.back, + }, 'Cancel'), ]), ]) + ) } -SendTransactionScreen.prototype.navigateToAccounts = function (event) { - event.stopPropagation() - this.props.dispatch(actions.showAccountsPage()) +SendTransactionScreen.prototype.toggleTooltip = function () { + this.setState({ tooltipIsOpen: !this.state.tooltipIsOpen }) +} + +SendTransactionScreen.prototype.closeTooltip = function () { + this.setState({ tooltipIsOpen: false }) +} + +SendTransactionScreen.prototype.setActiveCurrency = function (newCurrency) { + this.setState({ activeCurrency: newCurrency }) } SendTransactionScreen.prototype.back = function () { var address = this.props.address - this.props.dispatch(actions.backToAccountDetail(address)) + this.props.dispatch(backToAccountDetail(address)) } -SendTransactionScreen.prototype.recipientDidChange = function (recipient, nickname) { - this.setState({ - recipient: recipient, - nickname: nickname, - }) -} +SendTransactionScreen.prototype.validate = function (balance, amountToSend, { to, from }) { + const sufficientBalance = conversionGreaterThan( + { + value: balance, + fromNumericBase: 'hex', + }, + { + value: amountToSend, + fromNumericBase: 'hex', + }, + ) -SendTransactionScreen.prototype.onSubmit = function () { - const state = this.state || {} - const recipient = state.recipient || document.querySelector('input[name="address"]').value.replace(/^[.\s]+|[.\s]+$/g, '') - const nickname = state.nickname || ' ' - const input = document.querySelector('input[name="amount"]').value - const value = util.normalizeEthStringToWei(input) - const txData = document.querySelector('input[name="txData"]').value - const balance = this.props.balance - let message - - if (value.gt(balance)) { - message = 'Insufficient funds.' - return this.props.dispatch(actions.displayWarning(message)) + const amountLessThanZero = conversionGreaterThan( + { + value: 0, + fromNumericBase: 'dec', + }, + { + value: amountToSend, + fromNumericBase: 'hex', + }, + ) + + const errors = {} + + if (!sufficientBalance) { + errors.amount = 'Insufficient funds.' + } + + if (amountLessThanZero) { + errors.amount = 'Can not send negative amounts of ETH.' } - if (input < 0) { - message = 'Can not send negative amounts of ETH.' - return this.props.dispatch(actions.displayWarning(message)) + if (!from) { + errors.from = 'Required' } - if ((util.isInvalidChecksumAddress(recipient))) { - message = 'Recipient address checksum is invalid.' - return this.props.dispatch(actions.displayWarning(message)) + if (from && !isValidAddress(from)) { + errors.from = 'Sender address is invalid.' } - if ((!util.isValidAddress(recipient) && !txData) || (!recipient && !txData)) { - message = 'Recipient address is invalid.' - return this.props.dispatch(actions.displayWarning(message)) + if (!to) { + errors.to = 'Required' } - if (!isHex(ethUtil.stripHexPrefix(txData)) && txData) { - message = 'Transaction data must be hex string.' - return this.props.dispatch(actions.displayWarning(message)) + if (to && !isValidAddress(to)) { + errors.to = 'Recipient address is invalid.' } - this.props.dispatch(actions.hideWarning()) + // if (txData && !isHex(stripHexPrefix(txData))) { + // message = 'Transaction data must be hex string.' + // return this.props.dispatch(displayWarning(message)) + // } + + return { + isValid: allNull(errors), + errors, + } +} - this.props.dispatch(actions.addToAddressBook(recipient, nickname)) +SendTransactionScreen.prototype.getAmountToSend = function (amount) { + const { activeCurrency } = this.state + const { conversionRate } = this.props + + return conversionUtil(amount, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + fromCurrency: activeCurrency, + toCurrency: 'ETH', + toDenomination: 'WEI', + conversionRate, + invertConversionRate: activeCurrency !== 'ETH', + }) +} + +SendTransactionScreen.prototype.setErrorsFor = function (field) { + const { balance } = this.props + const { newTx, errors: previousErrors } = this.state + const { amountToSend } = newTx + + const { + isValid, + errors: newErrors + } = this.validate(balance, amountToSend, newTx) + + const nextErrors = Object.assign({}, previousErrors, { + [field]: newErrors[field] || null + }) + + if (!isValid) { + this.setState({ + errors: nextErrors, + isValid, + }) + } +} + +SendTransactionScreen.prototype.clearErrorsFor = function (field) { + const { errors: previousErrors } = this.state + const nextErrors = Object.assign({}, previousErrors, { + [field]: null + }) + + this.setState({ + errors: nextErrors, + isValid: allNull(nextErrors), + }) +} + +SendTransactionScreen.prototype.onSubmit = function (event) { + event.preventDefault() + const { warning, balance } = this.props + const state = this.state || {} + + const recipient = state.newTx.to + const sender = state.newTx.from + const nickname = state.nickname || ' ' + + // TODO: convert this to hex when created and include it in send + const txData = state.newTx.memo + + this.props.dispatch(hideWarning()) + + this.props.dispatch(addToAddressBook(recipient, nickname)) var txParams = { - from: this.props.address, - value: '0x' + value.toString(16), + from: this.state.newTx.from, + to: this.state.newTx.to, + + value: this.state.newTx.amountToSend, + + gas: this.state.newTx.gas, + gasPrice: this.state.newTx.gasPrice, } - if (recipient) txParams.to = ethUtil.addHexPrefix(recipient) + if (recipient) txParams.to = addHexPrefix(recipient) if (txData) txParams.data = txData - this.props.dispatch(actions.signTx(txParams)) + this.props.dispatch(signTx(txParams)) } |