diff --git a/.eslintrc.json b/.eslintrc.json index 54110f3d..9f0c853c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -30,5 +30,8 @@ "no-unreachable": 1, "no-alert": 0, "react/jsx-uses-react": 1 + }, + "globals": { + "SyntheticInputEvent": false } } diff --git a/common/actions/ens.js b/common/actions/ens.js new file mode 100644 index 00000000..0d3f7818 --- /dev/null +++ b/common/actions/ens.js @@ -0,0 +1,33 @@ +// @flow + +export type ResolveEnsNameAction = { + type: 'ENS_RESOLVE', + payload: string +}; + +export type CacheEnsAddressAction = { + type: 'ENS_CACHE', + payload: { + ensName: string, + address: string + } +}; + +export type EnsAction = ResolveEnsNameAction | CacheEnsAddressAction; + +export function resolveEnsName(name: string): ResolveEnsNameAction { + return { + type: 'ENS_RESOLVE', + payload: name + }; +} + +export function cacheEnsAddress(ensName: string, address: string): CacheEnsAddressAction { + return { + type: 'ENS_CACHE', + payload: { + ensName, + address + } + }; +} diff --git a/common/components/Header/components/TabsOptions.jsx b/common/components/Header/components/TabsOptions.jsx index dada34d9..569fef3a 100644 --- a/common/components/Header/components/TabsOptions.jsx +++ b/common/components/Header/components/TabsOptions.jsx @@ -9,7 +9,8 @@ const tabs = [ link: '/' }, { - name: 'NAV_SendEther' + name: 'NAV_SendEther', + link: 'send-transaction' }, { name: 'NAV_Swap', diff --git a/common/components/Header/index.jsx b/common/components/Header/index.jsx index 4d8417da..727d4cac 100644 --- a/common/components/Header/index.jsx +++ b/common/components/Header/index.jsx @@ -3,7 +3,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import TabsOptions from './components/TabsOptions'; import { Link } from 'react-router'; -import Dropdown from '../ui/Dropdown'; +import { Dropdown } from 'components/ui'; import { languages, nodeList } from '../../config/data'; export default class Header extends Component { diff --git a/common/components/ui/Dropdown.jsx b/common/components/ui/Dropdown.jsx index 4c70ed0f..0ccc6c43 100644 --- a/common/components/ui/Dropdown.jsx +++ b/common/components/ui/Dropdown.jsx @@ -19,7 +19,7 @@ export default class DropdownComponent extends Component { ariaLabel: string, formatTitle: (option: any) => any, extra?: any, - onChange: () => void + onChange: (value: any) => void }; state = { diff --git a/common/components/ui/Identicon.jsx b/common/components/ui/Identicon.jsx new file mode 100644 index 00000000..2422bedd --- /dev/null +++ b/common/components/ui/Identicon.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { toDataUrl } from 'ethereum-blockies'; +import { isValidAddress } from 'eth/validators'; + +type Props = { + address: string +}; + +export default function Identicon(props: Props) { + // FIXME breaks on failed checksums + const style = !isValidAddress(props.address) + ? {} + : { backgroundImage: `url(${toDataUrl(props.address.toLowerCase())})` }; + return
; +} diff --git a/common/components/ui/UnlockHeader.jsx b/common/components/ui/UnlockHeader.jsx new file mode 100644 index 00000000..e74d052f --- /dev/null +++ b/common/components/ui/UnlockHeader.jsx @@ -0,0 +1,45 @@ +// @flow +import React from 'react'; +import PropTypes from 'prop-types'; +import translate from 'translations'; + +export default class UnlockHeader extends React.Component { + props: { + title: string + }; + static propTypes = { + title: PropTypes.string.isRequired + }; + + state: { + expanded: boolean + } = { + expanded: true + }; + + render() { + return ( +
+
+ + {this.state.expanded ? '-' : '+'} + +

{translate(this.props.title)}

+
+ {this.state.expanded && +
+ {/* @@if (site === 'cx' ) { } + @@if (site === 'mew' ) { } */} +
} + + {this.state.expanded &&
} +
+ ); + } + + toggleExpanded = () => { + this.setState(state => { + return { expanded: !state.expanded }; + }); + }; +} diff --git a/common/components/ui/index.js b/common/components/ui/index.js new file mode 100644 index 00000000..471d34e3 --- /dev/null +++ b/common/components/ui/index.js @@ -0,0 +1,5 @@ +// @flow + +export { default as Dropdown } from './Dropdown'; +export { default as UnlockHeader } from './UnlockHeader'; +export { default as Identicon } from './Identicon'; diff --git a/common/containers/Tabs/SendTransaction/components/AddressField.jsx b/common/containers/Tabs/SendTransaction/components/AddressField.jsx new file mode 100644 index 00000000..fd95da84 --- /dev/null +++ b/common/containers/Tabs/SendTransaction/components/AddressField.jsx @@ -0,0 +1,74 @@ +// @flow +import React from 'react'; +import { Identicon } from 'components/ui'; +import { getEnsAddress } from 'selectors/ens'; +import { connect } from 'react-redux'; +import type { State } from 'reducers'; +import { isValidENSorEtherAddress, isValidENSAddress } from 'eth/validators'; +import { resolveEnsName } from 'actions/ens'; + +type PublicProps = { + placeholder: string, + value: string, + onChange?: (value: string) => void +}; + +export class AddressField extends React.Component { + props: PublicProps & { + ensAddress: ?string, + resolveEnsName: typeof resolveEnsName + }; + + render() { + const { placeholder, value, ensAddress } = this.props; + const isReadonly = !this.props.onChange; + // FIXME identicon is passed address only if valid + return ( +
+
+ + + {!!ensAddress && +

+ ↳ + + {ensAddress} + +

} +
+
+ +
+
+ ); + } + + onChange = (e: SyntheticInputEvent) => { + const newValue = e.target.value; + const { onChange } = this.props; + if (!onChange) { + return; + } + // FIXME debounce? + if (isValidENSAddress(newValue)) { + this.props.resolveEnsName(newValue); + } + onChange(newValue); + }; +} + +function mapStateToProps(state: State, props: PublicProps) { + return { + ensAddress: getEnsAddress(state, props.value) + }; +} + +export default connect(mapStateToProps, { resolveEnsName })(AddressField); diff --git a/common/containers/Tabs/SendTransaction/components/AmountField.jsx b/common/containers/Tabs/SendTransaction/components/AmountField.jsx new file mode 100644 index 00000000..4234da8c --- /dev/null +++ b/common/containers/Tabs/SendTransaction/components/AmountField.jsx @@ -0,0 +1,67 @@ +// @flow +import React from 'react'; +import translate from 'translations'; +import UnitDropdown from './UnitDropdown'; + +type Props = { + value: string, + unit: string, + onChange?: (value: string, unit: string) => void +}; + +export default class AmountField extends React.Component { + props: Props; + + render() { + const { value, unit, onChange } = this.props; + const isReadonly = !onChange; + return ( +
+ +
+ 0 + ? 'is-valid' + : 'is-invalid'}`} + type="text" + placeholder={translate('SEND_amount_short')} + value={value} + disabled={isReadonly} + onChange={isReadonly ? void 0 : this.onValueChange} + /> + +
+ {!isReadonly && +

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

} +
+ ); + } + + onUnitChange = (unit: string) => { + if (this.props.onChange) { + this.props.onChange(this.props.value, unit); + } + }; + + onValueChange = (e: SyntheticInputEvent) => { + if (this.props.onChange) { + this.props.onChange(e.target.value, this.props.unit); + } + }; + + onSendEverything = () => { + if (this.props.onChange) { + this.props.onChange('everything', this.props.unit); + } + }; +} diff --git a/common/containers/Tabs/SendTransaction/components/CustomMessage.jsx b/common/containers/Tabs/SendTransaction/components/CustomMessage.jsx new file mode 100644 index 00000000..99a91ead --- /dev/null +++ b/common/containers/Tabs/SendTransaction/components/CustomMessage.jsx @@ -0,0 +1,21 @@ +// @flow +import React from 'react'; + +type Props = { + message?: { + to: string, + msg: string + } +}; + +export default function CustomMessage(props: Props) { + return ( +
+ {!!props.message && +
+

A message from {props.message.to}

+

{props.message.msg}

+
} +
+ ); +} diff --git a/common/containers/Tabs/SendTransaction/components/DataField.jsx b/common/containers/Tabs/SendTransaction/components/DataField.jsx new file mode 100644 index 00000000..1cc35fc3 --- /dev/null +++ b/common/containers/Tabs/SendTransaction/components/DataField.jsx @@ -0,0 +1,63 @@ +// @flow +import React from 'react'; +import translate from 'translations'; +import { isValidHex } from 'eth/validators'; + +export default class DataField extends React.Component { + props: { + value: string, + onChange?: (e: string) => void + }; + state = { + expanded: false + }; + render() { + const { value } = this.props; + const { expanded } = this.state; + const valid = isValidHex(value || ''); + const readOnly = !this.props.onChange; + + return ( +
+
+ {!expanded && + +

+ {translate('TRANS_advanced')} +

+
} + {expanded && +
+
+ + +
+
} +
+
+ ); + } + + expand = () => { + this.setState({ expanded: true }); + }; + + onChange = (e: SyntheticInputEvent) => { + if (this.props.onChange) { + this.props.onChange(e.target.value); + } + }; +} diff --git a/common/containers/Tabs/SendTransaction/components/Donate.jsx b/common/containers/Tabs/SendTransaction/components/Donate.jsx new file mode 100644 index 00000000..281e1879 --- /dev/null +++ b/common/containers/Tabs/SendTransaction/components/Donate.jsx @@ -0,0 +1,38 @@ +// @flow + +import React from 'react'; +import translate from 'translations'; + +export default class Donate extends React.Component { + props: { + onDonate: (address: string, amount: string, unit: string) => void + }; + state: { + clicked: boolean + } = { + clicked: false + }; + render() { + return ( +
+

+ {translate('sidebar_donation')} +

+ + {translate('sidebar_donate')} + + {this.state.clicked && +
+ {translate('sidebar_thanks')} +
} +
+ ); + } + + onClick = () => { + // FIXME move to config + this.props.onDonate('0x7cB57B5A97eAbe94205C07890BE4c1aD31E486A8', '1', 'ETH'); + + this.setState({ clicked: true }); + }; +} diff --git a/common/containers/Tabs/SendTransaction/components/GasField.jsx b/common/containers/Tabs/SendTransaction/components/GasField.jsx new file mode 100644 index 00000000..788c3373 --- /dev/null +++ b/common/containers/Tabs/SendTransaction/components/GasField.jsx @@ -0,0 +1,40 @@ +// @flow + +import React from 'react'; +import translate from 'translations'; + +export default class GasField extends React.Component { + props: { + value: string, + onChange?: (value: string) => void | null + }; + render() { + const { value, onChange } = this.props; + const isReadonly = !onChange; + + return ( +
+
+ + 0 + ? 'is-valid' + : 'is-invalid'}`} + type="text" + placeholder="21000" + disabled={isReadonly} + value={value} + onChange={this.onChange} + /> +
+
+ ); + } + + onChange = (e: SyntheticInputEvent) => { + if (this.props.onChange) { + this.props.onChange(e.target.value); + } + }; +} diff --git a/common/containers/Tabs/SendTransaction/components/UnitDropdown.jsx b/common/containers/Tabs/SendTransaction/components/UnitDropdown.jsx new file mode 100644 index 00000000..c412182f --- /dev/null +++ b/common/containers/Tabs/SendTransaction/components/UnitDropdown.jsx @@ -0,0 +1,56 @@ +// @flow +import React from 'react'; + +export default class UnitDropdown extends React.Component { + props: { + value: string, + options: string[], + onChange?: (value: string) => void + }; + state: { + expanded: boolean + } = { + expanded: false + }; + + render() { + const { value, options, onChange } = this.props; + const isReadonly = !onChange; + + return ( +
+ + + {value} + + + {this.state.expanded && + !isReadonly && + } +
+ ); + } + + onToggleExpand = () => { + this.setState(state => { + return { + expanded: !state.expanded + }; + }); + }; +} diff --git a/common/containers/Tabs/SendTransaction/components/index.js b/common/containers/Tabs/SendTransaction/components/index.js new file mode 100644 index 00000000..b2370061 --- /dev/null +++ b/common/containers/Tabs/SendTransaction/components/index.js @@ -0,0 +1,7 @@ +// @flow +export { default as Donate } from './Donate'; +export { default as DataField } from './DataField'; +export { default as GasField } from './GasField'; +export { default as CustomMessage } from './CustomMessage'; +export { default as AmountField } from './AmountField'; +export { default as AddressField } from './AddressField'; diff --git a/common/containers/Tabs/SendTransaction/index.jsx b/common/containers/Tabs/SendTransaction/index.jsx new file mode 100644 index 00000000..2247f098 --- /dev/null +++ b/common/containers/Tabs/SendTransaction/index.jsx @@ -0,0 +1,270 @@ +// @flow + +import React from 'react'; +import PropTypes from 'prop-types'; +import translate from 'translations'; +import { UnlockHeader } from 'components/ui'; +import { + Donate, + DataField, + CustomMessage, + GasField, + AmountField, + AddressField +} from './components'; +import pickBy from 'lodash/pickBy'; +// import type { Transaction } from './types'; +import customMessages from './messages'; + +type State = { + readOnly: boolean, + to: string, + value: string, + unit: string, + gasLimit: string, + data: string, + gasChanged: boolean +}; + +function getParam(query: { [string]: string }, key: string) { + const keys = Object.keys(query); + const index = keys.findIndex(k => k.toLowerCase() === key.toLowerCase()); + if (index === -1) { + return null; + } + + return query[keys[index]]; +} + +// TODO query string +// TODO how to handle DATA? + +export class SendTransaction extends React.Component { + static propTypes = { + location: PropTypes.object.isRequired + }; + props: { + location: { + query: { + [string]: string + } + } + }; + state: State = { + hasQueryString: false, + readOnly: false, + // FIXME use correct defaults + to: '', + value: '999.11', + unit: 'ether', + gasLimit: '21000', + data: '', + gasChanged: false + }; + + componentDidMount() { + const queryPresets = pickBy(this.parseQuery()); + if (Object.keys(queryPresets).length) { + this.setState({ ...queryPresets, hasQueryString: true }); + } + + this.setState(pickBy(queryPresets)); + } + + render() { + const unlocked = true; //wallet != null + const unitReadable = 'UNITREADABLE'; + const nodeUnit = 'NODEUNIT'; + const hasEnoughBalance = false; + const { to, value, unit, gasLimit, data, readOnly, hasQueryString } = this.state; + const customMessage = customMessages.find(m => m.to === to); + + // tokens + // ng-show="token.balance!=0 && token.balance!='loading' || token.type!=='default' || tokenVisibility=='shown'" + + return ( +
+
+
+ + {hasQueryString && +
+

+ {translate('WARN_Send_Link')} +

+
} + + + + {unlocked && +
+ {'' /* */} +
+
+ {'' /* */} +
+ +
+
+ +
+ {readOnly && + !hasEnoughBalance && +
+
+ + Warning! You do not have enough funds to + complete this swap. + + {' '} +
+ Please add more funds or access a different wallet. +
+
} + +
+

+ {translate('SEND_trans')} +

+
+ + + + {unit === 'ether' && + } + + + + +
+
+ + +
+
+ + +
+
+ + + +
+ {'' /* */} + { + '' /* @@if (site === 'mew' ) { @@include( './sendTx-content.tpl', { "site": "mew" } ) } + @@if (site === 'cx' ) { @@include( './sendTx-content.tpl', { "site": "cx" } ) } + + @@if (site === 'mew' ) { @@include( './sendTx-modal.tpl', { "site": "mew" } ) } + @@if (site === 'cx' ) { @@include( './sendTx-modal.tpl', { "site": "cx" } ) } */ + } +
} +
+
+
+ ); + } + + parseQuery() { + const query = this.props.location.query; + const to = getParam(query, 'to'); + const data = getParam(query, 'data'); + // FIXME validate token against presets + const unit = getParam(query, 'tokenSymbol'); + const value = getParam(query, 'value'); + let gasLimit = getParam(query, 'gas'); + if (gasLimit === null) { + gasLimit = getParam(query, 'limit'); + } + const readOnly = getParam(query, 'readOnly') == null ? false : true; + + return { to, data, value, unit, gasLimit, readOnly }; + } + + // FIXME use mkTx instead or something that could take care of default gas/data and whatnot, + // FIXME also should it reset gasChanged? + onNewTx = ( + address: string, + amount: string, + unit: string, + data: string = '', + gasLimit: string = '21000' + ) => { + this.setState({ + to: address, + value: amount, + unit, + data, + gasLimit, + gasChanged: false + }); + }; + + onAddressChange = (value: string) => { + this.setState({ + to: value + }); + }; + + onDataChange = (e: SyntheticInputEvent) => { + const value = e.target.value; + if (this.state.unit !== 'ether') { + return; + } + this.setState({ + ...this.state, + data: value + }); + }; + + onGasChange = (value: string) => { + this.setState({ gasLimit: value, gasChanged: true }); + }; + + onAmountChange = (value: string, unit: string) => { + this.setState({ + value, + unit + }); + }; +} +// export connected version +export default SendTransaction; diff --git a/common/containers/Tabs/SendTransaction/messages.js b/common/containers/Tabs/SendTransaction/messages.js new file mode 100644 index 00000000..22808052 --- /dev/null +++ b/common/containers/Tabs/SendTransaction/messages.js @@ -0,0 +1,32 @@ +// @flow + +export default [ + { + // donation address example + to: '0x7cB57B5A97eAbe94205C07890BE4c1aD31E486A8', + gasLimit: 21000, + data: '', + msg: 'Thank you for donating to MyEtherWallet. TO THE MOON!' + }, + { + // BAT + to: '0x0D8775F648430679A709E98d2b0Cb6250d2887EF', + gasLimit: 200000, + data: '0xb4427263', + msg: 'BAT. THE SALE IS OVER. STOP CLOGGING THE BLOCKCHAIN PLEASE' + }, + { + // BANCOR + to: '0x00000', + gasLimit: 200000, + data: '', + msg: 'Bancor. Starts June XX, 2017.' + }, + { + // Moeda + to: '0x4870E705a3def9DDa6da7A953D1cd3CCEDD08573', + gasLimit: 200000, + data: '', + msg: 'Moeda. Ends at block 4,111,557.' + } +]; diff --git a/common/containers/Tabs/SendTransaction/ref.js b/common/containers/Tabs/SendTransaction/ref.js new file mode 100644 index 00000000..e071f9ab --- /dev/null +++ b/common/containers/Tabs/SendTransaction/ref.js @@ -0,0 +1,336 @@ +'use strict'; +var sendTxCtrl = function($scope, $sce, walletService) { + $scope.ajaxReq = ajaxReq; + $scope.unitReadable = ajaxReq.type; + $scope.sendTxModal = new Modal(document.getElementById('sendTransaction')); + walletService.wallet = null; + walletService.password = ''; + $scope.showAdvance = $scope.showRaw = false; + $scope.dropdownEnabled = true; + $scope.Validator = Validator; + $scope.gasLimitChanged = false; + // Tokens + $scope.tokenVisibility = 'hidden'; + $scope.tokenTx = { + to: '', + value: 0, + id: -1 + }; + $scope.customGasMsg = ''; + + // For token sale holders: + // 1. Add the address users are sending to + // 2. Add the gas limit users should use to send successfully (this avoids OOG errors) + // 3. Add any data if applicable + // 4. Add a message if you want. + + $scope.tx = { + // if there is no gasLimit or gas key in the URI, use the default value. Otherwise use value of gas or gasLimit. gasLimit wins over gas if both present + gasLimit: globalFuncs.urlGet('gaslimit') != null || globalFuncs.urlGet('gas') != null + ? globalFuncs.urlGet('gaslimit') != null + ? globalFuncs.urlGet('gaslimit') + : globalFuncs.urlGet('gas') + : globalFuncs.defaultTxGasLimit, + data: globalFuncs.urlGet('data') == null ? '' : globalFuncs.urlGet('data'), + to: globalFuncs.urlGet('to') == null ? '' : globalFuncs.urlGet('to'), + unit: 'ether', + value: globalFuncs.urlGet('value') == null ? '' : globalFuncs.urlGet('value'), + nonce: null, + gasPrice: null, + donate: false, + tokenSymbol: globalFuncs.urlGet('tokenSymbol') == null + ? false + : globalFuncs.urlGet('tokenSymbol'), + readOnly: globalFuncs.urlGet('readOnly') == null ? false : true + }; + $scope.setSendMode = function(sendMode, tokenId = '', tokenSymbol = '') { + $scope.tx.sendMode = sendMode; + $scope.unitReadable = ''; + if (sendMode == 'ether') { + $scope.unitReadable = ajaxReq.type; + } else { + $scope.unitReadable = tokenSymbol; + $scope.tokenTx.id = tokenId; + } + $scope.dropdownAmount = false; + }; + $scope.setTokenSendMode = function() { + if ($scope.tx.sendMode == 'token' && !$scope.tx.tokenSymbol) { + $scope.tx.tokenSymbol = $scope.wallet.tokenObjs[0].symbol; + $scope.wallet.tokenObjs[0].type = 'custom'; + $scope.setSendMode($scope.tx.sendMode, 0, $scope.tx.tokenSymbol); + } else if ($scope.tx.tokenSymbol) { + for (var i = 0; i < $scope.wallet.tokenObjs.length; i++) { + if ( + $scope.wallet.tokenObjs[i].symbol + .toLowerCase() + .indexOf($scope.tx.tokenSymbol.toLowerCase()) !== -1 + ) { + $scope.wallet.tokenObjs[i].type = 'custom'; + $scope.setSendMode('token', i, $scope.wallet.tokenObjs[i].symbol); + break; + } else $scope.tokenTx.id = -1; + } + } + if ($scope.tx.sendMode != 'token') $scope.tokenTx.id = -1; + }; + var applyScope = function() { + if (!$scope.$$phase) $scope.$apply(); + }; + var defaultInit = function() { + globalFuncs.urlGet('sendMode') == null + ? $scope.setSendMode('ether') + : $scope.setSendMode(globalFuncs.urlGet('sendMode')); + $scope.showAdvance = + globalFuncs.urlGet('gaslimit') != null || + globalFuncs.urlGet('gas') != null || + globalFuncs.urlGet('data') != null; + if ( + globalFuncs.urlGet('data') || + globalFuncs.urlGet('value') || + globalFuncs.urlGet('to') || + globalFuncs.urlGet('gaslimit') || + globalFuncs.urlGet('sendMode') || + globalFuncs.urlGet('gas') || + globalFuncs.urlGet('tokenSymbol') + ) + $scope.hasQueryString = true; // if there is a query string, show an warning at top of page + }; + $scope.$watch( + function() { + if (walletService.wallet == null) return null; + return walletService.wallet.getAddressString(); + }, + function() { + if (walletService.wallet == null) return; + $scope.wallet = walletService.wallet; + $scope.wd = true; + $scope.wallet.setBalance(applyScope); + $scope.wallet.setTokens(); + if ($scope.parentTxConfig) { + var setTxObj = function() { + $scope.tx.to = $scope.parentTxConfig.to; + $scope.tx.value = $scope.parentTxConfig.value; + $scope.tx.sendMode = $scope.parentTxConfig.sendMode + ? $scope.parentTxConfig.sendMode + : 'ether'; + $scope.tx.tokenSymbol = $scope.parentTxConfig.tokenSymbol + ? $scope.parentTxConfig.tokenSymbol + : ''; + $scope.tx.readOnly = $scope.parentTxConfig.readOnly + ? $scope.parentTxConfig.readOnly + : false; + }; + $scope.$watch( + 'parentTxConfig', + function() { + setTxObj(); + }, + true + ); + } + $scope.setTokenSendMode(); + defaultInit(); + } + ); + $scope.$watch('ajaxReq.key', function() { + if ($scope.wallet) { + $scope.setSendMode('ether'); + $scope.wallet.setBalance(applyScope); + $scope.wallet.setTokens(); + } + }); + $scope.$watch( + 'tokenTx', + function() { + if ( + $scope.wallet && + $scope.wallet.tokenObjs !== undefined && + $scope.wallet.tokenObjs[$scope.tokenTx.id] !== undefined && + $scope.Validator.isValidAddress($scope.tokenTx.to) && + $scope.Validator.isPositiveNumber($scope.tokenTx.value) + ) { + if ($scope.estimateTimer) clearTimeout($scope.estimateTimer); + $scope.estimateTimer = setTimeout(function() { + $scope.estimateGasLimit(); + }, 500); + } + }, + true + ); + $scope.$watch( + 'tx', + function(newValue, oldValue) { + $scope.showRaw = false; + if (oldValue.sendMode != newValue.sendMode && newValue.sendMode == 'ether') { + $scope.tx.data = ''; + $scope.tx.gasLimit = globalFuncs.defaultTxGasLimit; + } + if ( + newValue.gasLimit == oldValue.gasLimit && + $scope.wallet && + $scope.Validator.isValidAddress($scope.tx.to) && + $scope.Validator.isPositiveNumber($scope.tx.value) && + $scope.Validator.isValidHex($scope.tx.data) && + $scope.tx.sendMode != 'token' + ) { + if ($scope.estimateTimer) clearTimeout($scope.estimateTimer); + $scope.estimateTimer = setTimeout(function() { + $scope.estimateGasLimit(); + }, 500); + } + if ($scope.tx.sendMode == 'token') { + $scope.tokenTx.to = $scope.tx.to; + $scope.tokenTx.value = $scope.tx.value; + } + }, + true + ); + $scope.estimateGasLimit = function() { + $scope.customGasMsg = ''; + if ($scope.gasLimitChanged) return; + for (var i in $scope.customGas) { + if ($scope.tx.to.toLowerCase() == $scope.customGas[i].to.toLowerCase()) { + $scope.showAdvance = $scope.customGas[i].data != '' ? true : false; + $scope.tx.gasLimit = $scope.customGas[i].gasLimit; + $scope.tx.data = $scope.customGas[i].data; + $scope.customGasMsg = $scope.customGas[i].msg != '' ? $scope.customGas[i].msg : ''; + return; + } + } + if (globalFuncs.lightMode) { + $scope.tx.gasLimit = globalFuncs.defaultTokenGasLimit; + return; + } + var estObj = { + to: $scope.tx.to, + from: $scope.wallet.getAddressString(), + value: ethFuncs.sanitizeHex( + ethFuncs.decimalToHex(etherUnits.toWei($scope.tx.value, $scope.tx.unit)) + ) + }; + if ($scope.tx.data != '') estObj.data = ethFuncs.sanitizeHex($scope.tx.data); + if ($scope.tx.sendMode == 'token') { + estObj.to = $scope.wallet.tokenObjs[$scope.tokenTx.id].getContractAddress(); + estObj.data = $scope.wallet.tokenObjs[$scope.tokenTx.id].getData( + $scope.tokenTx.to, + $scope.tokenTx.value + ).data; + estObj.value = '0x00'; + } + ethFuncs.estimateGas(estObj, function(data) { + uiFuncs.notifier.close(); + if (!data.error) { + if (data.data == '-1') $scope.notifier.danger(globalFuncs.errorMsgs[21]); + $scope.tx.gasLimit = data.data; + } else $scope.notifier.danger(data.msg); + }); + }; + var isEnough = function(valA, valB) { + return new BigNumber(valA).lte(new BigNumber(valB)); + }; + $scope.hasEnoughBalance = function() { + if ($scope.wallet.balance == 'loading') return false; + return isEnough($scope.tx.value, $scope.wallet.balance); + }; + $scope.generateTx = function() { + if (!$scope.Validator.isValidAddress($scope.tx.to)) { + $scope.notifier.danger(globalFuncs.errorMsgs[5]); + return; + } + var txData = uiFuncs.getTxData($scope); + if ($scope.tx.sendMode == 'token') { + // if the amount of tokens you are trying to send > tokens you have, throw error + if (!isEnough($scope.tx.value, $scope.wallet.tokenObjs[$scope.tokenTx.id].balance)) { + $scope.notifier.danger(globalFuncs.errorMsgs[0]); + return; + } + txData.to = $scope.wallet.tokenObjs[$scope.tokenTx.id].getContractAddress(); + txData.data = $scope.wallet.tokenObjs[$scope.tokenTx.id].getData( + $scope.tokenTx.to, + $scope.tokenTx.value + ).data; + txData.value = '0x00'; + } + uiFuncs.generateTx(txData, function(rawTx) { + if (!rawTx.isError) { + $scope.rawTx = rawTx.rawTx; + $scope.signedTx = rawTx.signedTx; + $scope.showRaw = true; + } else { + $scope.showRaw = false; + $scope.notifier.danger(rawTx.error); + } + if (!$scope.$$phase) $scope.$apply(); + }); + }; + $scope.sendTx = function() { + $scope.sendTxModal.close(); + uiFuncs.sendTx($scope.signedTx, function(resp) { + if (!resp.isError) { + var bExStr = $scope.ajaxReq.type != nodes.nodeTypes.Custom + ? "View TX
" + : ''; + var emailLink = + 'Confused? Email Us.'; + $scope.notifier.success( + globalFuncs.successMsgs[2] + + resp.data + + '

' + + bExStr + + '

' + + emailLink + + '

' + ); + $scope.wallet.setBalance(applyScope); + if ($scope.tx.sendMode == 'token') + $scope.wallet.tokenObjs[$scope.tokenTx.id].setBalance(); + } else { + $scope.notifier.danger(resp.error); + } + }); + }; + $scope.transferAllBalance = function() { + if ($scope.tx.sendMode != 'token') { + uiFuncs.transferAllBalance( + $scope.wallet.getAddressString(), + $scope.tx.gasLimit, + function(resp) { + if (!resp.isError) { + $scope.tx.unit = resp.unit; + $scope.tx.value = resp.value; + } else { + $scope.showRaw = false; + $scope.notifier.danger(resp.error); + } + } + ); + } else { + $scope.tx.value = $scope.wallet.tokenObjs[$scope.tokenTx.id].getBalance(); + } + }; +}; +module.exports = sendTxCtrl; diff --git a/common/containers/Tabs/SendTransaction/types.js b/common/containers/Tabs/SendTransaction/types.js new file mode 100644 index 00000000..40e3040b --- /dev/null +++ b/common/containers/Tabs/SendTransaction/types.js @@ -0,0 +1,9 @@ +// @flow + +export type Transaction = { + to: string, + value: number, + unit: string, // 'ether' or token symbol + gasLimit: number, + data?: string // supported only in case of eth transfers, union type? +}; diff --git a/common/eth/ens.js b/common/eth/ens.js new file mode 100644 index 00000000..4110b482 --- /dev/null +++ b/common/eth/ens.js @@ -0,0 +1,10 @@ +// @flow +import uts46 from 'idna-uts46'; + +export function normalise(name: string): string { + try { + return uts46.toUnicode(name, { useStd3ASCII: true, transitional: false }); + } catch (e) { + throw e; + } +} diff --git a/common/eth/validators.js b/common/eth/validators.js new file mode 100644 index 00000000..bff7f035 --- /dev/null +++ b/common/eth/validators.js @@ -0,0 +1,62 @@ +// @flow +import { normalise } from './ens'; +import { toChecksumAddress } from 'ethereumjs-util'; + +export function isValidHex(str: string): boolean { + if (typeof str !== 'string') { + return false; + } + if (str === '') return true; + str = str.substring(0, 2) == '0x' ? str.substring(2).toUpperCase() : str.toUpperCase(); + var re = /^[0-9A-F]+$/g; + return re.test(str); +} + +export function isValidENSorEtherAddress(address: string): boolean { + return isValidAddress(address) || isValidENSAddress(address); +} + +export function isValidENSName(str: string) { + try { + return str.length > 6 && normalise(str) != '' && str.substring(0, 2) != '0x'; + } catch (e) { + return false; + } +} + +export function isValidENSAddress(address: string): boolean { + try { + const normalized = normalise(address); + var tld = normalized.substr(normalized.lastIndexOf('.') + 1); + var validTLDs = { + eth: true, + test: true, + reverse: true + }; + if (validTLDs[tld]) return true; + } catch (e) { + return false; + } + return false; +} + +function isChecksumAddress(address: string): boolean { + return address == toChecksumAddress(address); +} + +function validateEtherAddress(address: string): boolean { + if (address.substring(0, 2) != '0x') return false; + else if (!/^(0x)?[0-9a-f]{40}$/i.test(address)) return false; + else if (/^(0x)?[0-9a-f]{40}$/.test(address) || /^(0x)?[0-9A-F]{40}$/.test(address)) + return true; + else return isChecksumAddress(address); +} + +// FIXME already in swap PR somewhere +export function isValidAddress(address: string): boolean { + if (!address) { + return false; + } + if (address == '0x0000000000000000000000000000000000000000') return false; + return validateEtherAddress(address); +} diff --git a/common/index.jsx b/common/index.jsx index be993df6..93cbaf9b 100644 --- a/common/index.jsx +++ b/common/index.jsx @@ -1,20 +1,21 @@ -import React from 'react' -import {render} from 'react-dom' -import {syncHistoryWithStore, routerMiddleware} from 'react-router-redux' -import {composeWithDevTools} from 'redux-devtools-extension' -import Perf from 'react-addons-perf' -import {createStore, applyMiddleware} from 'redux' -import RootReducer from './reducers' -import {Root} from 'components' -import {Routing, history} from './routing' -import {createLogger} from 'redux-logger' -import createSagaMiddleware from 'redux-saga' -import notificationsSaga from './sagas/notifications' +import React from 'react'; +import { render } from 'react-dom'; +import { syncHistoryWithStore, routerMiddleware } from 'react-router-redux'; +import { composeWithDevTools } from 'redux-devtools-extension'; +import Perf from 'react-addons-perf'; +import { createStore, applyMiddleware } from 'redux'; +import RootReducer from './reducers'; +import { Root } from 'components'; +import { Routing, history } from './routing'; +import { createLogger } from 'redux-logger'; +import createSagaMiddleware from 'redux-saga'; +import notificationsSaga from './sagas/notifications'; +import ensSaga from './sagas/ens'; // application styles -import 'assets/styles/etherwallet-master.less' +import 'assets/styles/etherwallet-master.less'; -const sagaMiddleware = createSagaMiddleware() +const sagaMiddleware = createSagaMiddleware(); const configureStore = () => { let sagaApplied = applyMiddleware(sagaMiddleware); @@ -33,22 +34,22 @@ const configureStore = () => { } store = createStore(RootReducer, sagaApplied, middleware); - sagaMiddleware.run(notificationsSaga) - return store + sagaMiddleware.run(notificationsSaga); + sagaMiddleware.run(ensSaga); + return store; }; -const renderRoot = (Root) => { +const renderRoot = Root => { let store = configureStore(); let syncedHistory = syncHistoryWithStore(history, store); render( - , document.getElementById('app')) + , + document.getElementById('app') + ); }; renderRoot(Root); if (module.hot) { - module.hot.accept() + module.hot.accept(); } diff --git a/common/reducers/config.js b/common/reducers/config.js index aaf5b38a..984a08a5 100644 --- a/common/reducers/config.js +++ b/common/reducers/config.js @@ -1,15 +1,22 @@ +// @flow import { CONFIG_LANGUAGE_CHANGE, CONFIG_NODE_CHANGE } from 'actions/config'; import {languages, nodeList} from '../config/data'; +export type State = { + // FIXME + languageSelection: string, + nodeSelection: string +} + const initialState = { languageSelection: languages[0], nodeSelection: nodeList[0] } -export function config(state = initialState, action) { +export function config(state: State = initialState, action): State { switch (action.type) { case CONFIG_LANGUAGE_CHANGE: { return { diff --git a/common/reducers/ens.js b/common/reducers/ens.js new file mode 100644 index 00000000..8937206f --- /dev/null +++ b/common/reducers/ens.js @@ -0,0 +1,20 @@ +// @flow +import type { EnsAction, CacheEnsAddressAction } from 'actions/ens'; + +export type State = { [string]: string }; + +const initialState: State = {}; + +function cacheEnsAddress(state: State, action: CacheEnsAddressAction): State { + const { ensName, address } = action.payload; + return { ...state, [ensName]: address }; +} + +export function ens(state: State = initialState, action: EnsAction): State { + switch (action.type) { + case 'ENS_CACHE': + return cacheEnsAddress(state, action); + default: + return state; + } +} diff --git a/common/reducers/generateWallet.js b/common/reducers/generateWallet.js index 33df5af4..e9bdb6ef 100644 --- a/common/reducers/generateWallet.js +++ b/common/reducers/generateWallet.js @@ -1,3 +1,4 @@ +// @flow import { GENERATE_WALLET_SHOW_PASSWORD, GENERATE_WALLET_FILE, @@ -5,14 +6,21 @@ import { GENERATE_WALLET_CONFIRM_CONTINUE_TO_PAPER } from 'actions/generateWalletConstants'; -const initialState = { +export type State = { + showPassword: boolean, + generateWalletFile: boolean, + hasDownloadedWalletFile: boolean, + canProceedToPaper: boolean +} + +const initialState: State = { showPassword: false, generateWalletFile: false, hasDownloadedWalletFile: false, canProceedToPaper: false }; -export function generateWallet(state = initialState, action) { +export function generateWallet(state: State = initialState, action): State { switch (action.type) { case GENERATE_WALLET_SHOW_PASSWORD: { return { diff --git a/common/reducers/index.js b/common/reducers/index.js index 3161d505..73f2735f 100644 --- a/common/reducers/index.js +++ b/common/reducers/index.js @@ -1,18 +1,35 @@ // @flow -import * as generateWallet from './generateWallet' -import * as config from './config' -import * as swap from './swap' -import * as notifications from './notifications' +import * as generateWallet from './generateWallet'; +import type { State as GenerateWalletState } from './generateWallet'; -import { reducer as formReducer } from 'redux-form' -import {combineReducers} from 'redux'; -import {routerReducer} from 'react-router-redux' +import * as config from './config'; +import type { State as ConfigState } from './config'; + +import * as swap from './swap'; + +import * as notifications from './notifications'; +import type { State as NotificationsState } from './notifications'; + +import * as ens from './ens'; +import type { State as EnsState } from './ens'; + +import { reducer as formReducer } from 'redux-form'; +import { combineReducers } from 'redux'; +import { routerReducer } from 'react-router-redux'; + +export type State = { + generateWallet: GenerateWalletState, + conig: ConfigState, + notifications: NotificationsState, + ens: EnsState +}; export default combineReducers({ ...generateWallet, ...config, ...swap, ...notifications, + ...ens, form: formReducer, routing: routerReducer -}) +}); diff --git a/common/reducers/notifications.js b/common/reducers/notifications.js index 28be68e6..362bf6bf 100644 --- a/common/reducers/notifications.js +++ b/common/reducers/notifications.js @@ -6,7 +6,7 @@ import type { CloseNotificationAction } from 'actions/notifications'; -type State = Notification[]; +export type State = Notification[]; const initialState: State = []; diff --git a/common/routing/index.jsx b/common/routing/index.jsx index 17d500f7..491039e3 100644 --- a/common/routing/index.jsx +++ b/common/routing/index.jsx @@ -1,27 +1,26 @@ import React from 'react'; -import {browserHistory, Redirect, Route} from 'react-router'; -import {useBasename} from 'history'; -import {App} from 'containers'; -import GenerateWallet from 'containers/Tabs/GenerateWallet' -import ViewWallet from 'containers/Tabs/ViewWallet' -import Help from 'containers/Tabs/Help' -import Swap from 'containers/Tabs/Swap' +import { browserHistory, Redirect, Route } from 'react-router'; +import { useBasename } from 'history'; +import { App } from 'containers'; +import GenerateWallet from 'containers/Tabs/GenerateWallet'; +import ViewWallet from 'containers/Tabs/ViewWallet'; +import Help from 'containers/Tabs/Help'; +import Swap from 'containers/Tabs/Swap'; +import SendTransaction from 'containers/Tabs/SendTransaction'; +export const history = getHistory(); -export const history = getHistory() - -export const Routing = () => ( - - - - - - - - -) +export const Routing = () => + + + + + + + + ; function getHistory() { - const basename = '' - return useBasename(() => browserHistory)({basename}) + const basename = ''; + return useBasename(() => browserHistory)({ basename }); } diff --git a/common/sagas/ens.js b/common/sagas/ens.js new file mode 100644 index 00000000..6c9fd571 --- /dev/null +++ b/common/sagas/ens.js @@ -0,0 +1,33 @@ +// @flow +import { takeEvery, call, put, select } from 'redux-saga/effects'; +import { delay } from 'redux-saga'; +import { cacheEnsAddress } from 'actions/ens'; +import type { ResolveEnsNameAction } from 'actions/ens'; +import { getEnsAddress } from 'selectors/ens'; + +function* resolveEns(action: ResolveEnsNameAction) { + const ensName = action.payload; + // FIXME Add resolve logic + //// _ens.getAddress(scope.addressDrtv.ensAddressField, function(data) { + // if (data.error) uiFuncs.notifier.danger(data.msg); + // else if (data.data == '0x0000000000000000000000000000000000000000' || data.data == '0x') { + // setValue('0x0000000000000000000000000000000000000000'); + // scope.addressDrtv.derivedAddress = '0x0000000000000000000000000000000000000000'; + // scope.addressDrtv.showDerivedAddress = true; + // } else { + // setValue(data.data); + // scope.addressDrtv.derivedAddress = ethUtil.toChecksumAddress(data.data); + // scope.addressDrtv.showDerivedAddress = true; + + const cachedEnsAddress = yield select(getEnsAddress, ensName); + + if (cachedEnsAddress) { + return; + } + yield call(delay, 1000); + yield put(cacheEnsAddress(ensName, '0x7cB57B5A97eAbe94205C07890BE4c1aD31E486A8')); +} + +export default function* notificationsSaga() { + yield takeEvery('ENS_RESOLVE', resolveEns); +} diff --git a/common/selectors/ens.js b/common/selectors/ens.js new file mode 100644 index 00000000..bd33384b --- /dev/null +++ b/common/selectors/ens.js @@ -0,0 +1,6 @@ +// @flow +import type { State } from 'reducers'; + +export function getEnsAddress(state: State, ensName: string): ?string { + return state.ens[ensName]; +} diff --git a/package.json b/package.json index ee4c86d0..67af8b8c 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "description": "MyEtherWallet v4", "dependencies": { "axios": "^0.16.2", + "ethereum-blockies": "https://github.com/MyEtherWallet/blockies.git", + "ethereumjs-util": "^5.1.2", + "idna-uts46": "^1.1.0", "lodash": "^4.17.4", "prop-types": "^15.5.8", "react": "^15.4.2", diff --git a/webpack_config/webpack.base.js b/webpack_config/webpack.base.js index 8056b374..14142cd1 100644 --- a/webpack_config/webpack.base.js +++ b/webpack_config/webpack.base.js @@ -52,7 +52,7 @@ module.exports = { { test: /\.(js|jsx)$/, loaders: ['babel-loader'], - exclude: [/node_modules/] + exclude: [/node_modules\/(?!ethereum-blockies)/] }, { test: /\.(ico|jpg|png|gif|eot|otf|webp|ttf|woff|woff2)(\?.*)?$/,