From bf4171dfbdfdef012f64447c902706827f8b1af4 Mon Sep 17 00:00:00 2001 From: William O'Beirne Date: Wed, 23 Aug 2017 02:57:18 -0400 Subject: [PATCH] Transaction confirmation modal (#108) * Add a little arrow icon. * Replaced toEther function with toUnit to reduce the number of conversion functions wed need. Add tests for conversion functions. * First pass at a styled confirm transaction modal. * More data about data * Hook up generated transaction with modal * Fix modal position * Add from address. Restyle a bit. * Only show textareas and button if transaction has been generated. * Remove need for param. * Copy. * Use non-relative path. * Initial crack at transaction token support. * Fix flow type * Unit tests for contracts and erc20 * Convert contract class to ethereumjs-abi, caught a bug * Add decodeArgs for contracts, decodeTransfer for erc20 * Show token value in modal * Show value from transaction data in confirmation. * Show address of receiver, not token contract * Flow type * Only accept bigs * Unlog * Use ethereumjs-abis method ID function * Get transaction stuff out of state. Leave todo notes. * Intuit token from transaction to address. * Move generate transaction out of node and into libs/transaction. * timeout -> interval * Promise.reject -> throw * Get default currency from network. * Add more unit tests for decoding. Adopt the $ prefix for decoding calls. * Use signed transaction in confirmation modal. --- common/assets/images/icon-dot-arrow.svg | 16 ++ common/components/ui/Modal.jsx | 31 +-- common/components/ui/Modal.scss | 18 +- common/config/data.js | 9 +- .../components/AmountField.jsx | 20 +- .../components/ConfirmationModal.jsx | 218 ++++++++++++++++++ .../components/ConfirmationModal.scss | 47 ++++ .../components/UnitDropdown.jsx | 4 +- .../Tabs/SendTransaction/components/index.js | 1 + .../containers/Tabs/SendTransaction/index.jsx | 176 ++++++++------ common/libs/contract.js | 81 ++++--- common/libs/erc20.js | 22 +- common/libs/nodes/base.js | 16 +- common/libs/nodes/rpc/client.js | 21 ++ common/libs/nodes/rpc/index.js | 122 ++-------- common/libs/nodes/rpc/types.js | 24 +- common/libs/transaction.js | 120 ++++++++++ common/libs/units.js | 18 +- common/reducers/wallet.js | 4 +- common/selectors/config.js | 6 +- package-lock.json | 23 ++ package.json | 1 + spec/libs/contract.spec.js | 122 ++++++++++ spec/libs/erc20.spec.js | 40 ++++ spec/libs/units.spec.js | 50 ++++ 25 files changed, 928 insertions(+), 282 deletions(-) create mode 100644 common/assets/images/icon-dot-arrow.svg create mode 100644 common/containers/Tabs/SendTransaction/components/ConfirmationModal.jsx create mode 100644 common/containers/Tabs/SendTransaction/components/ConfirmationModal.scss create mode 100644 spec/libs/contract.spec.js create mode 100644 spec/libs/erc20.spec.js create mode 100644 spec/libs/units.spec.js diff --git a/common/assets/images/icon-dot-arrow.svg b/common/assets/images/icon-dot-arrow.svg new file mode 100644 index 00000000..9856f9d0 --- /dev/null +++ b/common/assets/images/icon-dot-arrow.svg @@ -0,0 +1,16 @@ + + + + Artboard + Created with Sketch. + + + + + + + + + + + \ No newline at end of file diff --git a/common/components/ui/Modal.jsx b/common/components/ui/Modal.jsx index 35cafd78..50476d69 100644 --- a/common/components/ui/Modal.jsx +++ b/common/components/ui/Modal.jsx @@ -1,6 +1,5 @@ // @flow import React, { Component } from 'react'; -import PropTypes from 'prop-types'; import closeIcon from 'assets/images/icon-x.svg'; import './Modal.scss'; @@ -18,6 +17,8 @@ type Props = { | 'warning' | 'danger' | 'link', + disabled?: boolean, + // $FlowFixMe - Why the fuck doesn't this like onClick? onClick: () => void }[], handleClose: () => void, @@ -26,27 +27,6 @@ type Props = { export default class Modal extends Component { props: Props; - static propTypes = { - isOpen: PropTypes.bool, - title: PropTypes.node.isRequired, - children: PropTypes.node, - buttons: PropTypes.arrayOf( - PropTypes.shape({ - text: PropTypes.node.isRequired, - type: PropTypes.oneOf([ - 'default', - 'primary', - 'success', - 'info', - 'warning', - 'danger', - 'link' - ]), - onClick: PropTypes.func.isRequired - }) - ), - handleClose: PropTypes.func.isRequired - }; componentDidMount() { this.updateBodyClass(); @@ -95,7 +75,12 @@ export default class Modal extends Component { } return ( - ); diff --git a/common/components/ui/Modal.scss b/common/components/ui/Modal.scss index 61f46144..cc96325d 100644 --- a/common/components/ui/Modal.scss +++ b/common/components/ui/Modal.scss @@ -7,6 +7,7 @@ $m-header-padding: 15px; $m-header-height: 62px; $m-content-padding: 20px; $m-footer-height: 58px; +$m-close-size: 26px; $m-anim-speed: 400ms; @keyframes modalshade-open { @@ -23,11 +24,11 @@ $m-anim-speed: 400ms; 0%, 30% { opacity: 0; - transform: translate(-50%, -50%) scale(0.88); + transform: translateX(-50%) scale(0.88); } 100% { opacity: 1; - transform: translate(-50%, -50%) scale(1); + transform: translateX(-50%) scale(1); } } @@ -49,7 +50,7 @@ $m-anim-speed: 400ms; .Modal { position: fixed; - top: 50%; + top: 30px; left: 50%; max-width: 95%; max-width: calc(100% - #{$m-window-padding * 2}); @@ -57,7 +58,7 @@ $m-anim-speed: 400ms; max-height: calc(100% - #{$m-window-padding * 2}); background: $m-background; border-radius: 4px; - transform: translate(-50%, -50%); + transform: translateX(-50%); z-index: $zindex-modal; overflow: hidden; display: none; @@ -77,8 +78,13 @@ $m-anim-speed: 400ms; &-title { margin: 0; + padding-right: $m-close-size; font-size: $font-size-large; line-height: $m-header-height; + height: $m-header-height; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } &-close { @@ -86,8 +92,8 @@ $m-anim-speed: 400ms; position: absolute; top: 50%; right: $m-header-padding; - height: 26px; - width: 26px; + height: $m-close-size; + width: $m-close-size; opacity: 0.8; transform: translateY(-50%) translateZ(0); diff --git a/common/config/data.js b/common/config/data.js index cbafa630..79d5ab31 100644 --- a/common/config/data.js +++ b/common/config/data.js @@ -140,6 +140,13 @@ export type NetworkConfig = { contracts: ?Array }; +export type NodeConfig = { + network: string, + lib: RPCNode, + service: string, + estimateGas: ?boolean +}; + export const NETWORKS: { [key: string]: NetworkConfig } = { ETH: { name: 'ETH', @@ -159,7 +166,7 @@ export const NETWORKS: { [key: string]: NetworkConfig } = { } }; -export const NODES = { +export const NODES: { [key: string]: NodeConfig } = { eth_mew: { network: 'ETH', lib: new RPCNode('https://api.myetherapi.com/eth'), diff --git a/common/containers/Tabs/SendTransaction/components/AmountField.jsx b/common/containers/Tabs/SendTransaction/components/AmountField.jsx index 8421dfee..c832c745 100644 --- a/common/containers/Tabs/SendTransaction/components/AmountField.jsx +++ b/common/containers/Tabs/SendTransaction/components/AmountField.jsx @@ -1,7 +1,7 @@ // @flow -import React from "react"; -import translate from "translations"; -import UnitDropdown from "./UnitDropdown"; +import React from 'react'; +import translate from 'translations'; +import UnitDropdown from './UnitDropdown'; type Props = { value: string, @@ -19,23 +19,23 @@ export default class AmountField extends React.Component { return (
0 - ? "is-valid" - : "is-invalid"}`} + ? 'is-valid' + : 'is-invalid'}`} type="text" - placeholder={translate("SEND_amount_short")} + placeholder={translate('SEND_amount_short')} value={value} disabled={isReadonly} onChange={isReadonly ? void 0 : this.onValueChange} />
@@ -43,7 +43,7 @@ export default class AmountField extends React.Component {

- {translate("SEND_TransferTotal")} + {translate('SEND_TransferTotal')}

} @@ -65,7 +65,7 @@ export default class AmountField extends React.Component { onSendEverything = () => { if (this.props.onChange) { - this.props.onChange("everything", this.props.unit); + this.props.onChange('everything', this.props.unit); } }; } diff --git a/common/containers/Tabs/SendTransaction/components/ConfirmationModal.jsx b/common/containers/Tabs/SendTransaction/components/ConfirmationModal.jsx new file mode 100644 index 00000000..32d50a04 --- /dev/null +++ b/common/containers/Tabs/SendTransaction/components/ConfirmationModal.jsx @@ -0,0 +1,218 @@ +// @flow +import './ConfirmationModal.scss'; +import React from 'react'; +import translate from 'translations'; +import Big from 'bignumber.js'; +import EthTx from 'ethereumjs-tx'; +import { connect } from 'react-redux'; +import BaseWallet from 'libs/wallet/base'; +import { toUnit, toTokenDisplay } from 'libs/units'; +import ERC20 from 'libs/erc20'; +import { getTransactionFields } from 'libs/transaction'; +import { getTokens } from 'selectors/wallet'; +import { getNetworkConfig } from 'selectors/config'; +import type { NodeConfig } from 'config/data'; +import type { Token, NetworkConfig } from 'config/data'; + +import Modal from 'components/ui/Modal'; +import Identicon from 'components/ui/Identicon'; + +type Props = { + signedTransaction: string, + transaction: EthTx, + wallet: BaseWallet, + node: NodeConfig, + token: ?Token, + network: NetworkConfig, + onConfirm: (string, EthTx) => void, + onCancel: () => void +}; + +type State = { + fromAddress: string, + timeToRead: number +}; + +class ConfirmationModal extends React.Component { + props: Props; + state: State; + + constructor(props: Props) { + super(props); + + this.state = { + fromAddress: '', + timeToRead: 5 + }; + } + + componentWillReceiveProps(newProps: Props) { + // Reload address if the wallet changes + if (newProps.wallet !== this.props.wallet) { + this._setWalletAddress(this.props.wallet); + } + } + + // Count down 5 seconds before allowing them to confirm + readTimer = 0; + componentDidMount() { + this.readTimer = setInterval(() => { + if (this.state.timeToRead > 0) { + this.setState({ timeToRead: this.state.timeToRead - 1 }); + } else { + clearInterval(this.readTimer); + } + }, 1000); + + this._setWalletAddress(this.props.wallet); + } + + componentWillUnmount() { + clearInterval(this.readTimer); + } + + _setWalletAddress(wallet: BaseWallet) { + wallet.getAddress().then(fromAddress => { + this.setState({ fromAddress }); + }); + } + + _decodeTransaction() { + const { transaction, token } = this.props; + const { to, value, data, gasPrice } = getTransactionFields(transaction); + let fixedValue; + let toAddress; + + if (token) { + // $FlowFixMe - If you have a token prop, you have data + const tokenData = ERC20.$transfer(data); + fixedValue = toTokenDisplay(new Big(tokenData.value), token).toString(); + toAddress = tokenData.to; + } else { + fixedValue = toUnit(new Big(value, 16), 'wei', 'ether').toString(); + toAddress = to; + } + + return { + value: fixedValue, + gasPrice: toUnit(new Big(gasPrice, 16), 'wei', 'gwei').toString(), + data, + toAddress + }; + } + + _confirm() { + if (this.state.timeToRead < 1) { + const { signedTransaction, transaction } = this.props; + this.props.onConfirm(signedTransaction, transaction); + } + } + + render() { + const { node, token, network, onCancel } = this.props; + const { fromAddress, timeToRead } = this.state; + const { toAddress, value, gasPrice, data } = this._decodeTransaction(); + + const buttonPrefix = timeToRead > 0 ? `(${timeToRead}) ` : ''; + const buttons = [ + { + text: buttonPrefix + translate('SENDModal_Yes'), + type: 'primary', + disabled: timeToRead > 0, + onClick: this._confirm() + }, + { + text: translate('SENDModal_No'), + type: 'default', + onClick: onCancel + } + ]; + + const symbol = token ? token.symbol : network.unit; + + return ( + +
+
+
+ +
+
+
+
+ {value} {symbol} +
+
+
+ +
+
+ +
    +
  • + You are sending from {fromAddress} +
  • +
  • + You are sending to {toAddress} +
  • +
  • + You are sending{' '} + + {value} {symbol} + {' '} + with a gas price of {gasPrice} gwei +
  • +
  • + You are interacting with the {node.network}{' '} + network provided by {node.service} +
  • + {!token && +
  • + {data + ? + You are sending the following data:{' '} +