Offline Send (#276)

* offline-send mvp

* cleanup unneeded imports

* - create pollOfflineStatus action, action creator, interface

* expand UnlockHeader when collapse-button is clicked, instead of div

* kick-off pollOfflineStatus upon SendTransaction mount.

* Create sagas for polling offline status

* remove comment

* - create CONFIG_FORCE_OFFLINE action, action creator, interface

* Adjust OfflineToggle terms to "Force Online/Offline", and understand when forced offline and when really offline.

* - Assume offline in SendTransaction when either offline or forcedOffline

* - handle forceOffline action in reducer
- adjust state type / provide default state for forceOffline in config reducer

* adjust test to pass with different key name

* fix incorrect import

* - allow size to be specified in offline toggle

* - Decode and display nonce in confirmation modal

* - set default nonces when forced offline and have online connectivity based on transaction count
- pass nonce to generateCompleteTransaction
- refactor componentDidUpdate

* Allow optional nonce to be passed to generateCompleteTransaction

* - create stripHexPrefix function

* - cleanup sagas

* move getParam into helper util

* update address on component update

* - show spinner while transaction is being signed
- reset state when wallet instance changes (new wallet instantiated via UnlockHeader)

* center-align offline message

* Adjust force offline/online button text

* - validate nonces when offline
- only estimate gas when online
- don't show send tx button when offline

* - break generateCompleteTransactionFromRawTransaction into multiple functions.
- support offline generation in generateCompleteTransaction (and generateCompleteTransactionFromRawTransaction). Balance checking is now only done when not offline to support offline generation.

* Create Help component (to be used as a tooltip)

* Disable hardware wallets when offline.

* Hide Send Entire Balance when balance is falsy

* Show help icon in nonce field.

* - show helper instructions on how to broadcast when user is offline after generating a tx
- hardcoded gas limits when offline
- refactors

* create isPositiveInteger helper function

* fix nonce validation

* really fix nonce validation (specifically the input highlighting)

* remove stray // @flow's

* remove offline tab nav

* remove unused action arg

* address PR comments
This commit is contained in:
Daniel Ternyak 2017-10-10 22:04:49 -07:00 committed by GitHub
parent 4858f96520
commit b493a0c968
27 changed files with 707 additions and 161 deletions

View File

@ -1,11 +1,25 @@
import * as interfaces from './actionTypes';
import { TypeKeys } from './constants';
export type TForceOfflineConfig = typeof forceOfflineConfig;
export function forceOfflineConfig(): interfaces.ForceOfflineAction {
return {
type: TypeKeys.CONFIG_FORCE_OFFLINE
};
}
export type TToggleOfflineConfig = typeof toggleOfflineConfig;
export function toggleOfflineConfig(): interfaces.ToggleOfflineAction {
return {
type: TypeKeys.CONFIG_TOGGLE_OFFLINE
};
}
export type TChangeLanguage = typeof changeLanguage;
export function changeLanguage(sign: string): interfaces.ChangeLanguageAction {
return {
type: TypeKeys.CONFIG_LANGUAGE_CHANGE,
value: sign
payload: sign
};
}
@ -13,7 +27,7 @@ export type TChangeNode = typeof changeNode;
export function changeNode(value: string): interfaces.ChangeNodeAction {
return {
type: TypeKeys.CONFIG_NODE_CHANGE,
value
payload: value
};
}
@ -21,7 +35,14 @@ export type TChangeGasPrice = typeof changeGasPrice;
export function changeGasPrice(value: number): interfaces.ChangeGasPriceAction {
return {
type: TypeKeys.CONFIG_GAS_PRICE,
value
payload: value
};
}
export type TPollOfflineStatus = typeof pollOfflineStatus;
export function pollOfflineStatus(): interfaces.PollOfflineStatus {
return {
type: TypeKeys.CONFIG_POLL_OFFLINE_STATUS
};
}

View File

@ -1,22 +1,37 @@
import { TypeKeys } from './constants';
/*** Toggle Offline ***/
export interface ToggleOfflineAction {
type: TypeKeys.CONFIG_TOGGLE_OFFLINE;
}
/*** Force Offline ***/
export interface ForceOfflineAction {
type: TypeKeys.CONFIG_FORCE_OFFLINE;
}
/*** Change Language ***/
export interface ChangeLanguageAction {
type: TypeKeys.CONFIG_LANGUAGE_CHANGE;
value: string;
payload: string;
}
/*** Change Node ***/
export interface ChangeNodeAction {
type: TypeKeys.CONFIG_NODE_CHANGE;
// FIXME $keyof?
value: string;
payload: string;
}
/*** Change gas price ***/
export interface ChangeGasPriceAction {
type: TypeKeys.CONFIG_GAS_PRICE;
value: number;
payload: number;
}
/*** Poll offline status ***/
export interface PollOfflineStatus {
type: TypeKeys.CONFIG_POLL_OFFLINE_STATUS;
}
/*** Change Node ***/
@ -30,4 +45,7 @@ export type ConfigAction =
| ChangeNodeAction
| ChangeLanguageAction
| ChangeGasPriceAction
| ToggleOfflineAction
| PollOfflineStatus
| ForceOfflineAction
| ChangeNodeIntentAction;

View File

@ -2,5 +2,8 @@ export enum TypeKeys {
CONFIG_LANGUAGE_CHANGE = 'CONFIG_LANGUAGE_CHANGE',
CONFIG_NODE_CHANGE = 'CONFIG_NODE_CHANGE',
CONFIG_NODE_CHANGE_INTENT = 'CONFIG_NODE_CHANGE_INTENT',
CONFIG_GAS_PRICE = 'CONFIG_GAS_PRICE'
CONFIG_GAS_PRICE = 'CONFIG_GAS_PRICE',
CONFIG_TOGGLE_OFFLINE = 'CONFIG_TOGGLE_OFFLINE',
CONFIG_FORCE_OFFLINE = 'CONFIG_FORCE_OFFLINE',
CONFIG_POLL_OFFLINE_STATUS = 'CONFIG_POLL_OFFLINE_STATUS'
}

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496.16 496.16"><defs><style>.cls-1{fill:#04778d}.cls-2{fill:#fff}.cls-3{fill:#f34002}</style></defs><title>Offline Icon</title><path class="cls-1" d="M496.16 248.09C496.16 111.06 385.09 0 248.08 0S0 111.06 0 248.09s111.07 248.07 248.08 248.07 248.08-111.07 248.08-248.07z"/><circle class="cls-2" cx="248.08" cy="332.81" r="49.15"/><path class="cls-2" d="M172.13 289.13c20.29-23.3 47.26-36.13 76-36.13s55.66 12.83 76 36.13l16-18.34c-24.55-28.2-57.2-43.73-91.93-43.73s-67.37 15.53-91.92 43.73l16 18.34z"/><path class="cls-2" d="M138.56 248.41c29.26-33.6 68.15-52.1 109.52-52.1s80.27 18.5 109.52 52.1l16-18.34c-33.52-38.49-78.09-59.7-125.49-59.7s-92 21.2-125.49 59.7l16 18.34z"/><path class="cls-2" d="M105.31 208.06c38.14-43.8 88.84-67.92 142.77-67.92s104.64 24.12 142.77 67.92l16-18.34C364.42 141 308 114.21 248.08 114.21S131.74 141 89.34 189.72l16 18.34zM248.08 376.37h.01v.01h-.01z"/><circle class="cls-3" cx="340.29" cy="343.74" r="63.78"/><path class="cls-2" d="M315 365.51l16.82-21.59-14.12-19.21a36.92 36.92 0 0 1-3-4.8 8.64 8.64 0 0 1-1-3.86 4.31 4.31 0 0 1 1.92-3.4 7.36 7.36 0 0 1 4.69-1.51 7 7 0 0 1 4.95 1.65 42.15 42.15 0 0 1 4.9 6.11l11.28 16 12.05-16 2.54-3.47a18.66 18.66 0 0 1 2-2.39 6.53 6.53 0 0 1 2.18-1.42 7.61 7.61 0 0 1 2.79-.47 7.11 7.11 0 0 1 4.69 1.51 4.53 4.53 0 0 1 1.82 3.58q0 3-3.95 8.21l-14.82 19.48 16 21.59a35.31 35.31 0 0 1 3.13 4.71 7.7 7.7 0 0 1 1 3.54 5.09 5.09 0 0 1-.87 2.89 6 6 0 0 1-2.46 2.07 8.17 8.17 0 0 1-3.59.77 7.7 7.7 0 0 1-3.64-.79 7.44 7.44 0 0 1-2.41-2q-.92-1.17-3.44-4.55l-13.23-18.3-14.05 18.84q-1.64 2.26-2.33 3.16a12.43 12.43 0 0 1-1.67 1.76 7.32 7.32 0 0 1-2.31 1.35 9 9 0 0 1-3.13.5 7 7 0 0 1-4.59-1.49 5.27 5.27 0 0 1-1.82-4.33q-.04-3.32 3.67-8.14z"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496.16 496.16"><defs><style>.cls-1{fill:#04778d}.cls-2{fill:#fff}.cls-3{fill:#00e65f}</style></defs><title>Online Icon</title><path class="cls-1" d="M496.16 248.09C496.16 111.06 385.09 0 248.08 0S0 111.06 0 248.09s111.07 248.07 248.08 248.07 248.08-111.07 248.08-248.07z"/><circle class="cls-2" cx="248.08" cy="332.81" r="49.15"/><path class="cls-2" d="M172.13 289.13c20.29-23.3 47.26-36.13 76-36.13s55.66 12.83 76 36.13l16-18.34c-24.55-28.2-57.2-43.73-91.93-43.73s-67.37 15.53-91.92 43.73l16 18.34z"/><path class="cls-2" d="M138.56 248.41c29.26-33.6 68.15-52.1 109.52-52.1s80.27 18.5 109.52 52.1l16-18.34c-33.52-38.49-78.09-59.7-125.49-59.7s-92 21.2-125.49 59.7l16 18.34z"/><path class="cls-2" d="M105.31 208.06c38.14-43.8 88.84-67.92 142.77-67.92s104.64 24.12 142.77 67.92l16-18.34C364.42 141 308 114.21 248.08 114.21S131.74 141 89.34 189.72l16 18.34zM248.08 376.37h.01v.01h-.01z"/><circle class="cls-3" cx="340.29" cy="343.74" r="63.78"/><path class="cls-2" d="M383.53 317.43c-1.85-4.77-5.61-4-9.7-3.21-2.44.51-13.28 3.68-30.44 21.77a144.85 144.85 0 0 0-14.91 18.06c-1.89-2.32-4.05-4.8-6.33-7.08a91.5 91.5 0 0 0-15.11-12 6.95 6.95 0 0 0-7.26 11.85 78.31 78.31 0 0 1 12.54 10 107.89 107.89 0 0 1 11.29 14 6.95 6.95 0 0 0 12.52-2.17c0-.06 2.77-7.68 17.32-23 11.72-12.36 19.54-16.29 22.25-17.38h.08l.25-.12a7.69 7.69 0 0 1 .73-.24h-.2l3.65-1.58c3.55-1.54 4.7-5.33 3.32-8.9z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -25,11 +25,20 @@ export default class AccountInfo extends React.Component<Props, State> {
address: ''
};
public async setAddressFromWallet() {
const address = await this.props.wallet.getAddress();
if (address !== this.state.address) {
this.setState({ address });
}
}
public componentDidMount() {
this.props.fetchCCRates();
this.props.wallet.getAddress().then(address => {
this.setState({ address });
});
this.setAddressFromWallet();
}
public componentDidUpdate() {
this.setAddressFromWallet();
}
// TODO: don't use any;

View File

@ -0,0 +1,55 @@
import React from 'react';
import {
forceOfflineConfig as dForceOfflineConfig,
TForceOfflineConfig
} from 'actions/config';
import OfflineSymbol from 'components/ui/OfflineSymbol';
import { connect } from 'react-redux';
import { AppState } from 'reducers';
type sizeType = 'small' | 'medium' | 'large';
interface OfflineToggleProps {
offline: boolean;
forceOffline: boolean;
forceOfflineConfig: TForceOfflineConfig;
size?: sizeType;
}
class OfflineToggle extends React.Component<OfflineToggleProps, {}> {
public render() {
const { forceOfflineConfig, offline, forceOffline, size } = this.props;
return (
<div>
{!offline ? (
<div className="row text-center">
<div className="col-md-3">
<OfflineSymbol offline={offline || forceOffline} size={size} />
</div>
<div className="col-md-6">
<button className="btn-xs btn-info" onClick={forceOfflineConfig}>
{forceOffline ? 'Go Online' : 'Go Offline'}
</button>
</div>
</div>
) : (
<div className="text-center">
<h5>You are currently offline.</h5>
</div>
)}
</div>
);
}
}
function mapStateToProps(state: AppState) {
return {
offline: state.config.offline,
forceOffline: state.config.forceOffline
};
}
export default connect(mapStateToProps, {
forceOfflineConfig: dForceOfflineConfig
})(OfflineToggle);

View File

@ -23,6 +23,7 @@ import EquivalentValues from './EquivalentValues';
import Promos from './Promos';
import TokenBalances from './TokenBalances';
import { State } from 'reducers/rates';
import OfflineToggle from './OfflineToggle';
interface Props {
wallet: IWallet;
@ -59,6 +60,10 @@ export class BalanceSidebar extends React.Component<Props, {}> {
}
const blocks: Block[] = [
{
name: 'Go Offline',
content: <OfflineToggle />
},
{
name: 'Account Info',
content: (

View File

@ -17,9 +17,6 @@ const tabs = [
name: 'NAV_Swap',
to: 'swap'
},
{
name: 'NAV_Offline'
},
{
name: 'NAV_ViewWallet'
// to: 'view-wallet'

View File

@ -19,6 +19,7 @@ import MnemonicDecrypt from './Mnemonic';
import PrivateKeyDecrypt, { PrivateKeyValue } from './PrivateKey';
import TrezorDecrypt from './Trezor';
import ViewOnlyDecrypt from './ViewOnly';
import { AppState } from 'reducers';
const WALLETS = {
'keystore-file': {
@ -76,6 +77,7 @@ interface Props {
dispatch: Dispatch<
UnlockKeystoreAction | UnlockMnemonicAction | UnlockPrivateKeyAction
>;
offline: boolean;
}
interface State {
@ -106,6 +108,13 @@ export class WalletDecrypt extends Component<Props, State> {
);
}
public isOnlineRequiredWalletAndOffline(selectedWalletKey) {
const onlineRequiredWallets = ['trezor', 'ledger-nano-s'];
return (
this.props.offline && onlineRequiredWallets.includes(selectedWalletKey)
);
}
public buildWalletOptions() {
return map(WALLETS, (wallet, key) => {
const isSelected = this.state.selectedWalletKey === key;
@ -120,7 +129,9 @@ export class WalletDecrypt extends Component<Props, State> {
value={key}
checked={isSelected}
onChange={this.handleDecryptionChoiceChange}
disabled={wallet.disabled}
disabled={
wallet.disabled || this.isOnlineRequiredWalletAndOffline(key)
}
/>
<span id={`${key}-label`}>{translate(wallet.lid)}</span>
</label>
@ -191,4 +202,10 @@ export class WalletDecrypt extends Component<Props, State> {
};
}
export default connect()(WalletDecrypt);
function mapStateToProps(state: AppState) {
return {
offline: state.config.offline
};
}
export default connect(mapStateToProps)(WalletDecrypt);

View File

@ -1,4 +1,3 @@
// @flow
import React, { Component } from 'react';
import classnames from 'classnames';
import DropdownShell from './DropdownShell';

View File

@ -1,4 +1,3 @@
// @flow
import React, { Component } from 'react';
import classnames from 'classnames';

View File

@ -0,0 +1,39 @@
import React from 'react';
import helpIcon from 'assets/images/icon-help.svg';
import translate, { translateRaw } from 'translations';
type sizeType = 'small' | 'medium' | 'large';
interface HelpProps {
link: string;
size?: sizeType;
helpText?: string;
}
const Help = ({ size, link, helpText }: HelpProps) => {
let width = 30;
let height = 12;
switch (size) {
case 'medium':
width = width * 3;
height = height * 3;
break;
case 'large':
width = width * 4;
height = height * 4;
break;
default:
break;
}
return (
<a href={link} className={'account-help-icon'} target={'_blank'}>
<img src={helpIcon} width={width} height={height} />
{helpText && <p className="account-help-text">{helpText}</p>}
</a>
);
};
export default Help;

View File

@ -0,0 +1,32 @@
import React from 'react';
import wifiOn from 'assets/images/wifi-on.svg';
import wifiOff from 'assets/images/wifi-off.svg';
type sizeType = 'small' | 'medium' | 'large';
interface OfflineSymbolProps {
offline: boolean;
size?: sizeType;
}
const OfflineSymbol = ({ offline, size }: OfflineSymbolProps) => {
let width = 30;
let height = 12;
switch (size) {
case 'medium':
width = width * 3;
height = height * 3;
break;
case 'large':
width = width * 4;
height = height * 4;
break;
default:
break;
}
return <img src={offline ? wifiOff : wifiOn} width={width} height={height} />;
};
export default OfflineSymbol;

View File

@ -3,10 +3,9 @@ import { IWallet } from 'libs/wallet/IWallet';
import React from 'react';
import { connect } from 'react-redux';
import { AppState } from 'reducers';
import translate from 'translations';
interface Props {
title: string;
title: React.ReactElement<any>;
wallet: IWallet;
}
interface State {
@ -29,17 +28,14 @@ export class UnlockHeader extends React.Component<Props, State> {
}
public render() {
const { title } = this.props;
return (
<article className="collapse-container">
<div onClick={this.toggleExpanded}>
<a className="collapse-button">
<span>
{this.state.expanded ? '-' : '+'}
</span>
<div>
<a className="collapse-button" onClick={this.toggleExpanded}>
<span>{this.state.expanded ? '-' : '+'}</span>
</a>
<h1>
{translate(this.props.title)}
</h1>
<h1>{title}</h1>
</div>
{this.state.expanded && <WalletDecrypt />}
{this.state.expanded && <hr />}

View File

@ -1,11 +1,13 @@
import React from 'react';
import translate, { translateRaw } from 'translations';
import UnitDropdown from './UnitDropdown';
import { Ether } from 'libs/units';
interface Props {
value: string;
unit: string;
tokens: string[];
balance: number | null | Ether;
onChange?(value: string, unit: string): void;
}
@ -13,14 +15,12 @@ export default class AmountField extends React.Component {
public props: Props;
public render() {
const { value, unit, onChange } = this.props;
const { value, unit, onChange, balance } = this.props;
const isReadonly = !onChange;
return (
<div className="row form-group">
<div className="col-xs-11">
<label>
{translate('SEND_amount')}
</label>
<label>{translate('SEND_amount')}</label>
<div className="input-group">
<input
className={`form-control ${isFinite(Number(value)) &&
@ -40,13 +40,15 @@ export default class AmountField extends React.Component {
/>
</div>
{!isReadonly &&
<span className="help-block">
<a onClick={this.onSendEverything}>
<span className="strong">
{translate('SEND_TransferTotal')}
</span>
</a>
</span>}
balance && (
<span className="help-block">
<a onClick={this.onSendEverything}>
<span className="strong">
{translate('SEND_TransferTotal')}
</span>
</a>
</span>
)}
</div>
</div>
);

View File

@ -78,7 +78,13 @@ class ConfirmationModal extends React.Component<Props, State> {
public render() {
const { node, token, network, onClose, broadCastTxStatus } = this.props;
const { fromAddress, timeToRead } = this.state;
const { toAddress, value, gasPrice, data } = this.decodeTransaction();
const {
toAddress,
value,
gasPrice,
data,
nonce
} = this.decodeTransaction();
const buttonPrefix = timeToRead > 0 ? `(${timeToRead}) ` : '';
const buttons: IButton[] = [
@ -138,6 +144,9 @@ class ConfirmationModal extends React.Component<Props, State> {
<li className="ConfModal-details-detail">
You are sending to <code>{toAddress}</code>
</li>
<li className="ConfModal-details-detail">
You are sending with a nonce of <code>{nonce}</code>
</li>
<li className="ConfModal-details-detail">
You are sending{' '}
<strong>
@ -191,7 +200,9 @@ class ConfirmationModal extends React.Component<Props, State> {
private decodeTransaction() {
const { transaction, token } = this.props;
const { to, value, data, gasPrice } = getTransactionFields(transaction);
const { to, value, data, gasPrice, nonce } = getTransactionFields(
transaction
);
let fixedValue;
let toAddress;
@ -208,7 +219,8 @@ class ConfirmationModal extends React.Component<Props, State> {
value: fixedValue,
gasPrice: toUnit(new Big(gasPrice, 16), 'wei', 'gwei').toString(),
data,
toAddress
toAddress,
nonce
};
}

View File

@ -0,0 +1,54 @@
import React from 'react';
import Help from 'components/ui/Help';
import { isPositiveInteger } from 'utils/helpers';
interface PublicProps {
placeholder: string;
value: number | null | undefined;
onChange(value: number): void;
}
const isValidNonce = (value: string | null | undefined) => {
let valid;
if (value === '0') {
valid = true;
} else if (!value) {
valid = false;
} else {
valid = isPositiveInteger(parseInt(value, 10));
}
return valid;
};
export default class NonceField extends React.Component<PublicProps, {}> {
public render() {
const { placeholder, value } = this.props;
const strValue = value ? value.toString() : '';
return (
<div className="row form-group">
<div className="col-xs-11">
<Help
size={'small'}
link={
'https://myetherwallet.github.io/knowledge-base/transactions/what-is-nonce.html'
}
/>
<label>Nonce</label>
<input
className={`form-control ${isValidNonce(strValue)
? 'is-valid'
: 'is-invalid'}`}
type="number"
value={strValue}
placeholder={placeholder}
onChange={this.onChange}
/>
</div>
</div>
);
}
private onChange = (e: any) => {
this.props.onChange(e.target.value);
};
}

View File

@ -5,3 +5,4 @@ export { default as CustomMessage } from './CustomMessage';
export { default as AmountField } from './AmountField';
export { default as AddressField } from './AddressField';
export { default as ConfirmationModal } from './ConfirmationModal';
export { default as NonceField } from './NonceField';

View File

@ -1,18 +1,23 @@
import { showNotification, TShowNotification } from 'actions/notifications';
import {
broadcastTx,
TBroadcastTx,
resetWallet,
TResetWallet
} from 'actions/wallet';
import Big from 'bignumber.js';
import { BalanceSidebar } from 'components';
// COMPONENTS
import { UnlockHeader } from 'components/ui';
import { donationAddressMap, NetworkConfig, NodeConfig } from 'config/data';
import Spinner from 'components/ui/Spinner';
import TabSection from 'containers/TabSection';
import { BalanceSidebar } from 'components';
import { UnlockHeader } from 'components/ui';
import {
NonceField,
AddressField,
AmountField,
ConfirmationModal,
CustomMessage,
DataField,
GasField
} from './components';
import NavigationPrompt from './components/NavigationPrompt';
// CONFIG
import { donationAddressMap, NetworkConfig, NodeConfig } from 'config/data';
// LIBS
import { stripHexPrefix } from 'libs/values';
import { TransactionWithoutGas } from 'libs/messages';
import { RPCNode } from 'libs/nodes';
import {
@ -25,20 +30,30 @@ import {
} from 'libs/transaction';
import { Ether, GWei, UnitKey, Wei } from 'libs/units';
import { isValidETHAddress } from 'libs/validators';
// LIBS
import { IWallet } from 'libs/wallet/IWallet';
import pickBy from 'lodash/pickBy';
import React from 'react';
// REDUX
import { connect } from 'react-redux';
import { AppState } from 'reducers';
import { showNotification, TShowNotification } from 'actions/notifications';
import {
broadcastTx,
TBroadcastTx,
resetWallet,
TResetWallet
} from 'actions/wallet';
import {
pollOfflineStatus as dPollOfflineStatus,
TPollOfflineStatus
} from 'actions/config';
// SELECTORS
import {
getGasPriceGwei,
getNetworkConfig,
getNodeConfig,
getNodeLib
} from 'selectors/config';
// SELECTORS
import {
getTokenBalances,
getTokens,
@ -49,27 +64,11 @@ import {
import translate from 'translations';
// UTILS
import { formatGasLimit } from 'utils/formatters';
import {
AddressField,
AmountField,
ConfirmationModal,
CustomMessage,
DataField,
GasField
} from './components';
import { getParam } from 'utils/helpers';
import queryString from 'query-string';
// MISC
import customMessages from './messages';
function getParam(query: { [key: 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]];
}
interface State {
hasQueryString: boolean;
readOnly: boolean;
@ -84,6 +83,9 @@ interface State {
transaction: CompleteTransaction | null;
showTxConfirm: boolean;
generateDisabled: boolean;
nonce: number | null | undefined;
hasSetDefaultNonce: boolean;
generateTxProcessing: boolean;
}
interface Props {
@ -99,6 +101,9 @@ interface Props {
showNotification: TShowNotification;
broadcastTx: TBroadcastTx;
resetWallet: TResetWallet;
offline: boolean;
forceOffline: boolean;
pollOfflineStatus: TPollOfflineStatus;
location: { search: string };
}
@ -114,40 +119,66 @@ const initialState: State = {
gasChanged: false,
showTxConfirm: false,
transaction: null,
generateDisabled: true
generateDisabled: true,
nonce: null,
hasSetDefaultNonce: false,
generateTxProcessing: false
};
export class SendTransaction extends React.Component<Props, State> {
public state: State = initialState;
public componentDidMount() {
this.props.pollOfflineStatus();
const queryPresets = pickBy(this.parseQuery());
if (Object.keys(queryPresets).length) {
this.setState({ ...queryPresets, hasQueryString: true });
this.setState(state => {
return {
...state,
...queryPresets,
hasQueryString: true
};
});
}
}
public componentDidUpdate(prevProps: Props, prevState: State) {
public haveFieldsChanged(prevState) {
return (
this.state.to !== prevState.to ||
this.state.value !== prevState.value ||
this.state.unit !== prevState.unit ||
this.state.data !== prevState.data
);
}
public shouldReEstimateGas(prevState) {
// TODO listen to gas price changes here
// TODO debounce the call
if (
// handle gas estimation
// if any relevant fields changed
return (
this.haveFieldsChanged(prevState) &&
// if gas has not changed
!this.state.gasChanged &&
// if we have valid tx
this.isValid() &&
// if any relevant fields changed
(this.state.to !== prevState.to ||
this.state.value !== prevState.value ||
this.state.unit !== prevState.unit ||
this.state.data !== prevState.data)
) {
if (!isNaN(parseInt(this.state.value, 10))) {
this.estimateGas();
}
(this.isValid() || (this.props.offline || this.props.forceOffline))
);
}
public handleGasEstimationOnUpdate(prevState) {
if (this.shouldReEstimateGas(prevState)) {
this.estimateGas();
}
}
public handleGenerateDisabledOnUpdate() {
if (this.state.generateDisabled === this.isValid()) {
this.setState({ generateDisabled: !this.isValid() });
}
}
public handleBroadcastTransactionOnUpdate() {
// handle clearing the form once broadcast transaction promise resolves and compontent updates
const componentStateTransaction = this.state.transaction;
if (componentStateTransaction) {
// lives in redux state
@ -165,6 +196,42 @@ export class SendTransaction extends React.Component<Props, State> {
}
}
public async handleSetNonceWhenOfflineOnUpdate() {
const { offline, forceOffline, wallet, nodeLib } = this.props;
const { hasSetDefaultNonce, nonce } = this.state;
const unlocked = !!wallet;
if (unlocked) {
const from = await wallet.getAddress();
if (forceOffline && !offline && !hasSetDefaultNonce) {
const nonceHex = await nodeLib.getTransactionCount(from);
const newNonce = parseInt(stripHexPrefix(nonceHex), 10);
this.setState({ nonce: newNonce, hasSetDefaultNonce: true });
}
if (!forceOffline && !offline && nonce) {
// set hasSetDefaultNonce back to false in case user toggles force offline several times
this.setState({ nonce: null, hasSetDefaultNonce: false });
}
}
}
public handleWalletStateOnUpdate(prevProps) {
if (this.props.wallet !== prevProps.wallet) {
this.setState(initialState);
}
}
public componentDidUpdate(prevProps: Props, prevState: State) {
this.handleGasEstimationOnUpdate(prevState);
this.handleGenerateDisabledOnUpdate();
this.handleBroadcastTransactionOnUpdate();
this.handleSetNonceWhenOfflineOnUpdate();
this.handleWalletStateOnUpdate(prevProps);
}
public onNonceChange = (value: number) => {
this.setState({ nonce: value });
};
public render() {
const unlocked = !!this.props.wallet;
const {
@ -176,13 +243,25 @@ export class SendTransaction extends React.Component<Props, State> {
readOnly,
hasQueryString,
showTxConfirm,
transaction
transaction,
nonce,
generateTxProcessing
} = this.state;
const { offline, forceOffline, balance } = this.props;
const customMessage = customMessages.find(m => m.to === to);
return (
<TabSection>
<section className="Tab-content">
<UnlockHeader title={'NAV_SendEther'} />
<UnlockHeader
title={
<div>
{translate('NAV_SendEther')}
{offline || forceOffline ? (
<span style={{ color: 'red' }}> (Offline)</span>
) : null}
</div>
}
/>
<NavigationPrompt
when={unlocked}
onConfirm={this.props.resetWallet}
@ -206,6 +285,7 @@ export class SendTransaction extends React.Component<Props, State> {
<AmountField
value={value}
unit={unit}
balance={balance}
tokens={this.props.tokenBalances
.filter(token => !token.balance.eq(0))
.map(token => token.symbol)
@ -216,6 +296,15 @@ export class SendTransaction extends React.Component<Props, State> {
value={gasLimit}
onChange={readOnly ? void 0 : this.onGasChange}
/>
{(offline || forceOffline) && (
<div>
<NonceField
value={nonce}
onChange={this.onNonceChange}
placeholder={'0'}
/>
</div>
)}
{unit === 'ether' && (
<DataField
value={data}
@ -236,6 +325,14 @@ export class SendTransaction extends React.Component<Props, State> {
</div>
</div>
{generateTxProcessing && (
<div className="container">
<div className="row form-group text-center">
<Spinner size="5x" />
</div>
</div>
)}
{transaction && (
<div>
<div className="row form-group">
@ -256,18 +353,35 @@ export class SendTransaction extends React.Component<Props, State> {
rows={4}
readOnly={true}
/>
{offline && (
<p>
To broadcast this transaction, paste the above
into{' '}
<a href="https://myetherwallet.com/pushTx">
{' '}
myetherwallet.com/pushTx
</a>{' '}
or{' '}
<a href="https://etherscan.io/pushTx">
{' '}
etherscan.io/pushTx
</a>
</p>
)}
</div>
</div>
<div className="form-group">
<button
className="btn btn-primary btn-block col-sm-11"
disabled={!this.state.transaction}
onClick={this.openTxModal}
>
{translate('SEND_trans')}
</button>
</div>
{!offline && (
<div className="form-group">
<button
className="btn btn-primary btn-block col-sm-11"
disabled={!this.state.transaction}
onClick={this.openTxModal}
>
{translate('SEND_trans')}
</button>
</div>
)}
</div>
)}
</div>
@ -312,6 +426,18 @@ export class SendTransaction extends React.Component<Props, State> {
return { to, data, value, unit, gasLimit, readOnly };
}
public isValidNonce() {
const { offline, forceOffline } = this.props;
const { nonce } = this.state;
let valid = true;
if (offline || forceOffline) {
if (!nonce || nonce < 0) {
valid = false;
}
}
return valid;
}
public isValid() {
const { to, value, gasLimit } = this.state;
return (
@ -321,7 +447,8 @@ export class SendTransaction extends React.Component<Props, State> {
!isNaN(Number(value)) &&
isFinite(Number(value)) &&
!isNaN(parseInt(gasLimit, 10)) &&
isFinite(parseInt(gasLimit, 10))
isFinite(parseInt(gasLimit, 10)) &&
this.isValidNonce()
);
}
@ -338,16 +465,35 @@ export class SendTransaction extends React.Component<Props, State> {
return await formatTxInput(wallet, transactionInput);
}
public isValidValue() {
return !isNaN(parseInt(this.state.value, 10));
}
public async estimateGas() {
if (isNaN(parseInt(this.state.value, 10))) {
const { offline, forceOffline, nodeLib } = this.props;
let gasLimit;
if (offline || forceOffline) {
const { unit } = this.state;
if (unit === 'ether') {
gasLimit = 21000;
} else {
gasLimit = 150000;
}
this.setState({ gasLimit });
return;
}
if (!this.isValidValue()) {
return;
}
try {
const cachedFormattedTx = await this.getFormattedTxFromState();
// Grab a reference to state. If it has changed by the time the estimateGas
// call comes back, we don't want to replace the gasLimit in state.
const state = this.state;
const gasLimit = await this.props.nodeLib.estimateGas(cachedFormattedTx);
gasLimit = await nodeLib.estimateGas(cachedFormattedTx);
if (this.state === state) {
this.setState({ gasLimit: formatGasLimit(gasLimit, state.unit) });
} else {
@ -434,10 +580,10 @@ export class SendTransaction extends React.Component<Props, State> {
);
public generateTxFromState = async () => {
this.setState({ generateTxProcessing: true });
await this.resetJustTx();
const { nodeLib, wallet, gasPrice, network } = this.props;
const { token, unit, value, to, data, gasLimit } = this.state;
const { nodeLib, wallet, gasPrice, network, offline } = this.props;
const { token, unit, value, to, data, gasLimit, nonce } = this.state;
const chainId = network.chainId;
const transactionInput = {
token,
@ -454,10 +600,13 @@ export class SendTransaction extends React.Component<Props, State> {
gasPrice,
bigGasLimit,
chainId,
transactionInput
transactionInput,
nonce,
offline
);
this.setState({ transaction: signedTx });
this.setState({ transaction: signedTx, generateTxProcessing: false });
} catch (err) {
this.setState({ generateTxProcessing: false });
this.props.showNotification('danger', err.message, 5000);
}
};
@ -493,12 +642,15 @@ function mapStateToProps(state: AppState) {
network: getNetworkConfig(state),
tokens: getTokens(state),
gasPrice: new GWei(getGasPriceGwei(state)).toWei(),
transactions: state.wallet.transactions
transactions: state.wallet.transactions,
offline: state.config.offline,
forceOffline: state.config.forceOffline
};
}
export default connect(mapStateToProps, {
showNotification,
broadcastTx,
resetWallet
resetWallet,
pollOfflineStatus: dPollOfflineStatus
})(SendTransaction);

View File

@ -1,4 +1,3 @@
import Big, { BigNumber } from 'bignumber.js';
import { Token } from 'config/data';
import EthTx from 'ethereumjs-tx';
import { addHexPrefix, padToEven, toChecksumAddress } from 'ethereumjs-util';
@ -11,6 +10,7 @@ import { isValidETHAddress } from 'libs/validators';
import { stripHexPrefixAndLower, valueToHex } from 'libs/values';
import { IWallet } from 'libs/wallet';
import translate, { translateRaw } from 'translations';
import Big, { BigNumber } from 'bignumber.js';
export interface TransactionInput {
token?: Token | null;
@ -71,13 +71,66 @@ export function getTransactionFields(tx: EthTx) {
};
}
export async function generateCompleteTransactionFromRawTransaction(
function getValue(
token: Token | null | undefined,
tx: ExtendedRawTransaction
): BigNumber {
let value;
if (token) {
value = new Big(ERC20.$transfer(tx.data).value);
} else {
value = new Big(tx.value);
}
return value;
}
async function getBalance(
node: INode,
tx: ExtendedRawTransaction,
wallet: IWallet,
token: Token | null | undefined
): Promise<CompleteTransaction> {
const { to, data, gasLimit, gasPrice, from, chainId, nonce } = tx;
) {
const { from } = tx;
const ETHBalance = await node.getBalance(from);
let balance;
if (token) {
balance = toTokenUnit(await node.getTokenBalance(tx.from, token), token);
} else {
balance = ETHBalance.amount;
}
return {
balance,
ETHBalance
};
}
async function balanceCheck(
node: INode,
tx: ExtendedRawTransaction,
token: Token | null | undefined,
value: BigNumber,
gasCost: Wei
) {
// Ensure their balance exceeds the amount they're sending
const { balance, ETHBalance } = await getBalance(node, tx, token);
if (value.gt(balance)) {
throw new Error(translateRaw('GETH_Balance'));
}
// ensure gas cost is not greaterThan current eth balance
// TODO check that eth balance is not lesser than txAmount + gasCost
if (gasCost.amount.gt(ETHBalance.amount)) {
throw new Error(
`gasCost: ${gasCost.amount} greaterThan ETHBalance: ${ETHBalance.amount}`
);
}
}
function generateTxValidation(
to: string,
token: Token | null | undefined,
data: string,
gasLimit: BigNumber | string,
gasPrice: Wei | string
) {
// Reject bad addresses
if (!isValidETHAddress(to)) {
throw new Error(translateRaw('ERROR_5'));
@ -106,28 +159,29 @@ export async function generateCompleteTransactionFromRawTransaction(
'Gas price too high. Please contact support if this was not a mistake.'
);
}
// build gasCost by multiplying gasPrice * gasLimit
}
export async function generateCompleteTransactionFromRawTransaction(
node: INode,
tx: ExtendedRawTransaction,
wallet: IWallet,
token: Token | null | undefined,
offline?: boolean
): Promise<CompleteTransaction> {
const { to, data, gasLimit, gasPrice, chainId, nonce } = tx;
// validation
generateTxValidation(to, token, data, gasLimit, gasPrice);
// duplicated from generateTxValidation -- typescript bug
if (typeof gasLimit === 'string' || typeof gasPrice === 'string') {
throw Error('Gas Limit and Gas Price should be of type bignumber');
}
// computed gas cost (gasprice * gaslimit)
const gasCost: Wei = new Wei(gasPrice.amount.times(gasLimit));
// Ensure their balance exceeds the amount they're sending
let value;
let balance;
const ETHBalance: Wei = await node.getBalance(from);
if (token) {
value = new Big(ERC20.$transfer(tx.data).value);
balance = toTokenUnit(await node.getTokenBalance(tx.from, token), token);
} else {
value = new Big(tx.value);
balance = ETHBalance.amount;
}
if (value.gt(balance)) {
throw new Error(translateRaw('GETH_Balance'));
}
// ensure gas cost is not greaterThan current eth balance
// TODO check that eth balance is not lesser than txAmount + gasCost
if (gasCost.amount.gt(ETHBalance.amount)) {
throw new Error(
`gasCost: ${gasCost.amount} greaterThan ETHBalance: ${ETHBalance.amount}`
);
// get amount value (either in ETH or in Token)
const value = getValue(token, tx);
// if not offline, ensure that balance exceeds costs
if (!offline) {
await balanceCheck(node, tx, token, value, gasCost);
}
// Taken from v3's `sanitizeHex`, ensures that the value is a %2 === 0
// prefix'd hex value.
@ -141,19 +195,13 @@ export async function generateCompleteTransactionFromRawTransaction(
data: data ? cleanHex(data) : '',
chainId: chainId || 1
};
// Sign the transaction
const rawTxJson = JSON.stringify(cleanedRawTx);
const signedTx = await wallet.signRawTransaction(cleanedRawTx);
// Repeat all of this shit for Flow typechecking. Sealed objects don't
// like spreads, so we have to be explicit.
return {
nonce: cleanedRawTx.nonce,
gasPrice: cleanedRawTx.gasPrice,
gasLimit: cleanedRawTx.gasLimit,
to: cleanedRawTx.to,
value: cleanedRawTx.value,
data: cleanedRawTx.data,
chainId: cleanedRawTx.chainId,
...cleanedRawTx,
rawTx: rawTxJson,
signedTx
};
@ -191,7 +239,9 @@ export async function generateCompleteTransaction(
gasPrice: Wei,
gasLimit: BigNumber,
chainId: number,
transactionInput: TransactionInput
transactionInput: TransactionInput,
nonce?: number | null,
offline?: boolean
): Promise<CompleteTransaction> {
const { token } = transactionInput;
const { from, to, value, data } = await formatTxInput(
@ -199,7 +249,7 @@ export async function generateCompleteTransaction(
transactionInput
);
const transaction: ExtendedRawTransaction = {
nonce: await nodeLib.getTransactionCount(from),
nonce: nonce ? `0x${nonce}` : await nodeLib.getTransactionCount(from),
from,
to,
gasLimit,
@ -212,7 +262,8 @@ export async function generateCompleteTransaction(
nodeLib,
transaction,
wallet,
token
token,
offline
);
}

View File

@ -1,7 +1,11 @@
import { Ether } from 'libs/units';
export function stripHexPrefixAndLower(address: string): string {
return address.replace('0x', '').toLowerCase();
export function stripHexPrefix(value: string) {
return value.replace('0x', '');
}
export function stripHexPrefixAndLower(value: string): string {
return stripHexPrefix(value).toLowerCase();
}
export function valueToHex(value: Ether): string {

View File

@ -2,42 +2,62 @@ import {
ChangeGasPriceAction,
ChangeLanguageAction,
ChangeNodeAction,
ConfigAction
ConfigAction,
ToggleOfflineAction,
ForceOfflineAction
} from 'actions/config';
import { TypeKeys } from 'actions/config/constants';
import { languages, NODES } from '../config/data';
import { NODES } from '../config/data';
export interface State {
// FIXME
languageSelection: string;
nodeSelection: string;
gasPriceGwei: number;
offline: boolean;
forceOffline: boolean;
}
export const INITIAL_STATE: State = {
languageSelection: 'en',
nodeSelection: Object.keys(NODES)[0],
gasPriceGwei: 21
gasPriceGwei: 21,
offline: false,
forceOffline: false
};
function changeLanguage(state: State, action: ChangeLanguageAction): State {
return {
...state,
languageSelection: action.value
languageSelection: action.payload
};
}
function changeNode(state: State, action: ChangeNodeAction): State {
return {
...state,
nodeSelection: action.value
nodeSelection: action.payload
};
}
function changeGasPrice(state: State, action: ChangeGasPriceAction): State {
return {
...state,
gasPriceGwei: action.value
gasPriceGwei: action.payload
};
}
function toggleOffline(state: State, action: ToggleOfflineAction): State {
return {
...state,
offline: !state.offline
};
}
function forceOffline(state: State, action: ForceOfflineAction): State {
return {
...state,
forceOffline: !state.forceOffline
};
}
@ -52,6 +72,10 @@ export function config(
return changeNode(state, action);
case TypeKeys.CONFIG_GAS_PRICE:
return changeGasPrice(state, action);
case TypeKeys.CONFIG_TOGGLE_OFFLINE:
return toggleOffline(state, action);
case TypeKeys.CONFIG_FORCE_OFFLINE:
return forceOffline(state, action);
default:
return state;
}

View File

@ -1,9 +1,46 @@
import { SagaIterator } from 'redux-saga';
import { call, put, select, takeEvery } from 'redux-saga/effects';
import { changeNode } from 'actions/config';
import { delay, SagaIterator } from 'redux-saga';
import {
call,
cancel,
fork,
put,
take,
takeLatest,
takeEvery,
select
} from 'redux-saga/effects';
import { NODES } from 'config/data';
import { getNodeConfig } from 'selectors/config';
import { AppState } from 'reducers';
import { TypeKeys } from 'actions/config/constants';
import {
toggleOfflineConfig,
TToggleOfflineConfig,
changeNode
} from 'actions/config';
import { State as ConfigState } from 'reducers/config';
export const getConfig = (state: AppState): ConfigState => state.config;
export function* pollOfflineStatus(): SagaIterator {
while (true) {
const offline = !navigator.onLine;
const config = yield select(getConfig);
const offlineState = config.offline;
if (offline !== offlineState) {
yield put(toggleOfflineConfig());
}
yield call(delay, 250);
}
}
// Fork our recurring API call, watch for the need to cancel.
function* handlePollOfflineStatus(): SagaIterator {
const pollOfflineStatusTask = yield fork(pollOfflineStatus);
yield take('CONFIG_STOP_POLL_OFFLINE_STATE');
yield cancel(pollOfflineStatusTask);
}
// @HACK For now we reload the app when doing a language swap to force non-connected
// data to reload. Also the use of timeout to avoid using additional actions for now.
function* reload(): SagaIterator {
@ -20,7 +57,11 @@ function* handleNodeChangeIntent(action): SagaIterator {
}
}
export default function* handleConfigChanges(): SagaIterator {
yield takeEvery('CONFIG_NODE_CHANGE_INTENT', handleNodeChangeIntent);
yield takeEvery('CONFIG_LANGUAGE_CHANGE', reload);
export default function* configSaga(): SagaIterator {
yield takeLatest(
TypeKeys.CONFIG_POLL_OFFLINE_STATUS,
handlePollOfflineStatus
);
yield takeEvery(TypeKeys.CONFIG_NODE_CHANGE_INTENT, handleNodeChangeIntent);
yield takeEvery(TypeKeys.CONFIG_LANGUAGE_CHANGE, reload);
}

View File

@ -1,4 +1,4 @@
import handleConfigChanges from './config';
import configSaga from './config';
import contracts from './contracts';
import deterministicWallets from './deterministicWallets';
import notifications from './notifications';
@ -12,7 +12,7 @@ import wallet from './wallet';
export default {
bityTimeRemaining,
handleConfigChanges,
configSaga,
postBityOrderSaga,
pollBityOrderStatusSaga,
getBityRatesSaga,

View File

@ -1,3 +1,16 @@
export function getKeyByValue(object, value) {
return Object.keys(object).find(key => object[key] === value);
}
export function getParam(query: { [key: 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]];
}
export function isPositiveInteger(n: number) {
return Number.isInteger(n) && n > 0;
}

View File

@ -5,7 +5,7 @@ describe('actions', () => {
const value = 'en';
const expectedAction = {
type: 'CONFIG_LANGUAGE_CHANGE',
value
payload: value
};
expect(changeLanguage(value)).toEqual(expectedAction);
});