From 51253665be3674a7d47b8c8addf2fb744186506a Mon Sep 17 00:00:00 2001 From: Szymon Lesisz Date: Sat, 22 Sep 2018 15:39:56 +0200 Subject: [PATCH 01/11] change blockchain/web3 "estimateGasLimit" to return string --- src/actions/BlockchainActions.js | 9 ++++++--- src/actions/Web3Actions.js | 18 +++++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/actions/BlockchainActions.js b/src/actions/BlockchainActions.js index 5414df9d..2ba9c50a 100644 --- a/src/actions/BlockchainActions.js +++ b/src/actions/BlockchainActions.js @@ -83,9 +83,12 @@ export const getGasPrice = (network: string, defaultGasPrice: number): PromiseAc } } -export const estimateGasLimit = (network: string, data: string, value: string, gasPrice: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - return await dispatch( Web3Actions.estimateGasLimit(network, { to: '', data, value, gasPrice }) ); -} +export const estimateGasLimit = (network: string, data: string, value: string, gasPrice: string): PromiseAction => async (dispatch: Dispatch): Promise => dispatch(Web3Actions.estimateGasLimit(network, { + to: '', + data, + value, + gasPrice, +})); export const onBlockMined = (coinInfo: any): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { // incoming "coinInfo" from TrezorConnect is CoinInfo | EthereumNetwork type diff --git a/src/actions/Web3Actions.js b/src/actions/Web3Actions.js index b4611bd3..5a6ebab7 100644 --- a/src/actions/Web3Actions.js +++ b/src/actions/Web3Actions.js @@ -279,16 +279,20 @@ export const updateGasPrice = (network: string): PromiseAction => async (d } -export const estimateGasLimit = (network: string, options: EstimateGasOptions): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const instance = await dispatch( initWeb3(network) ); +export const estimateGasLimit = (network: string, $options: EstimateGasOptions): PromiseAction => async (dispatch: Dispatch): Promise => { + const instance = await dispatch(initWeb3(network)); // TODO: allow data starting with 0x ... - options.to = '0x0000000000000000000000000000000000000000'; - options.data = `0x${options.data.length % 2 === 0 ? options.data : `0${options.data}`}`; - options.value = instance.web3.utils.toHex( EthereumjsUnits.convert(options.value || '0', 'ether', 'wei') ); - options.gasPrice = instance.web3.utils.toHex( EthereumjsUnits.convert(options.gasPrice, 'gwei', 'wei') ); + const data = `0x${$options.data.length % 2 === 0 ? $options.data : `0${$options.data}`}`; + const options = { + ...$options, + to: '0x0000000000000000000000000000000000000000', + data, + value: instance.web3.utils.toHex(EthereumjsUnits.convert($options.value || '0', 'ether', 'wei')), + gasPrice: instance.web3.utils.toHex(EthereumjsUnits.convert($options.gasPrice, 'gwei', 'wei')), + }; const limit = await instance.web3.eth.estimateGas(options); - return limit; + return limit.toString(); }; export const disconnect = (coinInfo: any): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { From a9e811ab5ba232b0e6adfbfeba13f2c19d4361da Mon Sep 17 00:00:00 2001 From: Szymon Lesisz Date: Sat, 22 Sep 2018 15:40:32 +0200 Subject: [PATCH 02/11] add utility to observe reducers fields change --- src/reducers/utils/index.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/reducers/utils/index.js b/src/reducers/utils/index.js index b5c0056c..9b5ba7f5 100644 --- a/src/reducers/utils/index.js +++ b/src/reducers/utils/index.js @@ -115,3 +115,22 @@ export const getWeb3 = (state: State): ?Web3Instance => { if (!locationState.network) return null; return state.web3.find(w3 => w3.network === locationState.network); }; + +export const observeChanges = (prev: ?(Object | Array), current: ?(Object | Array), fields?: Array): boolean => { + if (prev !== current) { + // 1. one of the objects is null/undefined + if (!prev || !current) return true; + // 2. object are Arrays and they have different length + if (Array.isArray(prev) && Array.isArray(current)) return prev.length !== current.length; + // 3. no nested field to check + if (!Array.isArray(fields)) return true; + // 4. validate nested field + if (prev instanceof Object && current instanceof Object) { + for (let i = 0; i < fields.length; i++) { + const key = fields[i]; + if (prev[key] !== current[key]) return true; + } + } + } + return false; +}; From 88d2a65340bbc0bc63ad777fbee988ef2d5562d1 Mon Sep 17 00:00:00 2001 From: Szymon Lesisz Date: Sat, 22 Sep 2018 18:46:53 +0200 Subject: [PATCH 03/11] BlockchainAction "estimateGasLimit" with proxied responses --- src/actions/BlockchainActions.js | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/actions/BlockchainActions.js b/src/actions/BlockchainActions.js index 2ba9c50a..ecec58e0 100644 --- a/src/actions/BlockchainActions.js +++ b/src/actions/BlockchainActions.js @@ -83,12 +83,31 @@ export const getGasPrice = (network: string, defaultGasPrice: number): PromiseAc } } -export const estimateGasLimit = (network: string, data: string, value: string, gasPrice: string): PromiseAction => async (dispatch: Dispatch): Promise => dispatch(Web3Actions.estimateGasLimit(network, { - to: '', - data, - value, - gasPrice, -})); +const estimateProxy: Array> = []; +export const estimateGasLimit = (network: string, data: string, value: string, gasPrice: string): PromiseAction => async (dispatch: Dispatch): Promise => { + // Since this method could be called multiple times in short period of time + // check for pending calls in proxy and if there more than two (first is current running and the second is waiting for result of first) + // TODO: should reject second call immediately? + if (estimateProxy.length > 0) { + // wait for proxy result (but do not process it) + await estimateProxy[0]; + } + + const call = dispatch(Web3Actions.estimateGasLimit(network, { + to: '', + data, + value, + gasPrice, + })); + // add current call to proxy + estimateProxy.push(call); + // wait for result + const result = await call; + // remove current call from proxy + estimateProxy.splice(0, 1); + // return result + return result; +}; export const onBlockMined = (coinInfo: any): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { // incoming "coinInfo" from TrezorConnect is CoinInfo | EthereumNetwork type From 1846d936e212765b1274f8d49ab0b830c9f8a32e Mon Sep 17 00:00:00 2001 From: Szymon Lesisz Date: Sat, 22 Sep 2018 18:47:31 +0200 Subject: [PATCH 04/11] updated SessionStorage actions --- src/actions/SessionStorageActions.js | 43 +++++++++++++++++----------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/actions/SessionStorageActions.js b/src/actions/SessionStorageActions.js index 9457aa14..33c953c8 100644 --- a/src/actions/SessionStorageActions.js +++ b/src/actions/SessionStorageActions.js @@ -4,47 +4,56 @@ import type { State as SendFormState } from 'reducers/SendFormReducer'; import type { ThunkAction, + PayloadAction, GetState, Dispatch, } from 'flowtype'; -const PREFIX: string = 'trezor:draft-tx:'; +const TX_PREFIX: string = 'trezor:draft-tx:'; -export const save = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { +export const saveDraftTransaction = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { if (typeof window.localStorage === 'undefined') return; - const location = getState().router.location.pathname; const state = getState().sendForm; - if (!state.untouched) { - try { - window.sessionStorage.setItem(`${PREFIX}${location}`, JSON.stringify(state)); - } catch (error) { - console.error(`Saving sessionStorage error: ${error}`); - } + if (state.untouched) return; + + const location = getState().router.location.pathname; + try { + // save state as it is + // "loadDraftTransaction" will do the validation + window.sessionStorage.setItem(`${TX_PREFIX}${location}`, JSON.stringify(state)); + } catch (error) { + console.error(`Saving sessionStorage error: ${error}`); } }; -export const load = (location: string): ?SendFormState => { - if (typeof window.localStorage === 'undefined') return; +export const loadDraftTransaction = (): PayloadAction => (dispatch: Dispatch, getState: GetState): ?SendFormState => { + if (typeof window.localStorage === 'undefined') return null; try { - const value: string = window.sessionStorage.getItem(`${PREFIX}${location}`); + const location = getState().router.location.pathname; + const value: string = window.sessionStorage.getItem(`${TX_PREFIX}${location}`); const state: ?SendFormState = JSON.parse(value); - if (state && state.address === '' && (state.amount === '' || state.amount === '0')) { - window.sessionStorage.removeItem(`${PREFIX}${location}`); - return; + if (state) { + // decide if draft is valid and should be returned + // ignore this draft if has any error + if (Object.keys(state.errors).length > 0) { + window.sessionStorage.removeItem(`${TX_PREFIX}${location}`); + return null; + } + return state; } - return state; } catch (error) { console.error(`Loading sessionStorage error: ${error}`); } + return null; }; export const clear = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { if (typeof window.localStorage === 'undefined') return; const location = getState().router.location.pathname; try { - window.sessionStorage.removeItem(`${PREFIX}${location}`); + window.sessionStorage.removeItem(`${TX_PREFIX}${location}`); } catch (error) { console.error(`Clearing sessionStorage error: ${error}`); } From 52cf4f1e8e575468536c33e7959e89f5879a96c6 Mon Sep 17 00:00:00 2001 From: Szymon Lesisz Date: Sat, 22 Sep 2018 18:48:28 +0200 Subject: [PATCH 05/11] clearing sendForm and sessionStorage references from SelectedAccountActions --- src/actions/SelectedAccountActions.js | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/src/actions/SelectedAccountActions.js b/src/actions/SelectedAccountActions.js index dd54a365..4e01c1a5 100644 --- a/src/actions/SelectedAccountActions.js +++ b/src/actions/SelectedAccountActions.js @@ -3,13 +3,9 @@ import { LOCATION_CHANGE } from 'react-router-redux'; import * as ACCOUNT from 'actions/constants/account'; -import * as SEND from 'actions/constants/send'; import * as NOTIFICATION from 'actions/constants/notification'; import * as PENDING from 'actions/constants/pendingTx'; -import * as SendFormActions from 'actions/SendFormActions'; -import * as SessionStorageActions from 'actions/SessionStorageActions'; - import * as stateUtils from 'reducers/utils'; import type { @@ -36,17 +32,6 @@ export const updateSelectedValues = (prevState: State, action: Action): AsyncAct const state: State = getState(); const { location } = state.router; - // reset form to default - if (action.type === SEND.TX_COMPLETE) { - // dispatch( SendFormActions.init() ); - // linear action - // SessionStorageActions.clear(location.pathname); - } - - if (prevState.sendForm !== state.sendForm) { - dispatch(SessionStorageActions.save()); - } - // handle devices state change (from trezor-connect events or location change) if (locationChange || prevState.accounts !== state.accounts @@ -85,11 +70,6 @@ export const updateSelectedValues = (prevState: State, action: Action): AsyncAct payload, }); - // initialize SendFormReducer - if (location.state.send && getState().sendForm.currency === '') { - dispatch(SendFormActions.init()); - } - if (location.state.send) { const rejectedTxs = pending.filter(tx => tx.rejected); rejectedTxs.forEach((tx) => { From 6cf3cb4bdb33a7a747cfdf8b31bac64c1dafa1da Mon Sep 17 00:00:00 2001 From: Szymon Lesisz Date: Sat, 22 Sep 2018 18:49:05 +0200 Subject: [PATCH 06/11] refactoring SendFromActions --- src/actions/SendFormActions.js | 877 +++++++---------------- src/actions/SendFormValidationActions.js | 413 +++++++++++ src/actions/constants/send.js | 18 +- src/reducers/SendFormReducer.js | 85 +-- 4 files changed, 687 insertions(+), 706 deletions(-) create mode 100644 src/actions/SendFormValidationActions.js diff --git a/src/actions/SendFormActions.js b/src/actions/SendFormActions.js index eab6a786..1302d611 100644 --- a/src/actions/SendFormActions.js +++ b/src/actions/SendFormActions.js @@ -1,33 +1,27 @@ /* @flow */ - -import EthereumjsUtil from 'ethereumjs-util'; -import EthereumjsUnits from 'ethereumjs-units'; -import EthereumjsTx from 'ethereumjs-tx'; import TrezorConnect from 'trezor-connect'; import BigNumber from 'bignumber.js'; import * as NOTIFICATION from 'actions/constants/notification'; import * as SEND from 'actions/constants/send'; +import * as WEB3 from 'actions/constants/web3'; +import * as ValidationActions from 'actions/SendFormValidationActions'; import { initialState } from 'reducers/SendFormReducer'; import { findToken } from 'reducers/TokensReducer'; -import { findDevice, getPendingAmount, getPendingNonce } from 'reducers/utils'; -import * as stateUtils from 'reducers/utils'; -import { validateAddress } from 'utils/ethUtils'; +import * as reducerUtils from 'reducers/utils'; import type { Dispatch, GetState, + State as ReducersState, Action, ThunkAction, AsyncAction, TrezorDevice, } from 'flowtype'; -import type { Coin } from 'reducers/LocalStorageReducer'; -import type { Token } from 'reducers/TokensReducer'; import type { State, FeeLevel } from 'reducers/SendFormReducer'; import type { Account } from 'reducers/AccountsReducer'; -import type { Props } from 'views/Wallet/views/AccountSend/Container'; import * as SessionStorageActions from './SessionStorageActions'; import { prepareEthereumTx, serializeEthereumTx } from './TxActions'; import * as BlockchainActions from './BlockchainActions'; @@ -44,188 +38,73 @@ export type SendTxAction = { txData: any, }; -export type SendFormAction = SendTxAction | { - type: typeof SEND.INIT, - state: State +export type SendFormAction = { + type: typeof SEND.INIT | typeof SEND.VALIDATION | typeof SEND.CHANGE, + state: State, } | { - type: typeof SEND.DISPOSE -} | { - type: typeof SEND.TOGGLE_ADVANCED -} | { - type: typeof SEND.VALIDATION, - errors: {[k: string]: string}, - warnings: {[k: string]: string}, - infos: {[k: string]: string} -} | { - type: typeof SEND.ADDRESS_VALIDATION, - state: State -} | { - type: typeof SEND.ADDRESS_CHANGE, - state: State -} | { - type: typeof SEND.AMOUNT_CHANGE, - state: State -} | { - type: typeof SEND.CURRENCY_CHANGE, - state: State -} | { - type: typeof SEND.SET_MAX, - state: State -} | { - type: typeof SEND.FEE_LEVEL_CHANGE, - state: State -} | { - type: typeof SEND.UPDATE_FEE_LEVELS, - state: State -} | { - type: typeof SEND.FEE_LEVEL_CHANGE, - state: State -} | { - type: typeof SEND.GAS_PRICE_CHANGE, - state: State -} | { - type: typeof SEND.GAS_LIMIT_CHANGE, - state: State -} | { - type: typeof SEND.NONCE_CHANGE, - state: State -} | { - type: typeof SEND.DATA_CHANGE, - state: State -} | { - type: typeof SEND.SEND, -} | { - type: typeof SEND.TX_ERROR, -} | { - type: typeof SEND.FROM_SESSION_STORAGE, - address: string, - amount: string, - setMax: boolean, - selectedCurrency: string, - selectedFeeLevel: any, - advanced: boolean, - gasLimit: string, - gasPrice: string, - data: string, - nonce: string, - touched: any, -} + type: typeof SEND.TOGGLE_ADVANCED | typeof SEND.TX_SENDING | typeof SEND.TX_ERROR, +} | SendTxAction; -//const numberRegExp = new RegExp('^([0-9]{0,10}\\.)?[0-9]{1,18}$'); -const numberRegExp: RegExp = new RegExp('^(0|0\\.([0-9]+)?|[1-9][0-9]*\\.?([0-9]+)?|\\.[0-9]+)$'); +/* +* Called from WalletService on EACH action +*/ +export const observe = (prevState: ReducersState, action: Action): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const currentState = getState(); + // do not proceed if it's not "send" url + if (!currentState.router.location.state.send) return; -export const calculateFee = (gasPrice: string, gasLimit: string): string => { - try { - return EthereumjsUnits.convert(new BigNumber(gasPrice).times(gasLimit), 'gwei', 'ether'); - } catch (error) { - return '0'; + // if action type is SEND.VALIDATION which is called as result of this process + // save data to session storage + if (action.type === SEND.VALIDATION) { + dispatch(SessionStorageActions.saveDraftTransaction()); + return; + } + + // if send form was not initialized + if (currentState.sendForm.currency === '') { + dispatch(init()); + return; + } + + // handle gasPrice update from backend + // recalculate fee levels if needed + if (action.type === WEB3.GAS_PRICE_UPDATED) { + dispatch(ValidationActions.onGasPriceUpdated(action.network, action.gasPrice)); + return; + } + + let shouldUpdate: boolean = false; + // check if "selectedAccount" reducer changed + const selectedAccountChanged = reducerUtils.observeChanges(prevState.selectedAccount, currentState.selectedAccount, ['account', 'tokens', 'pending']); + if (selectedAccountChanged) { + // double check + // there are only few fields that we are interested in + // check them to avoid unnecessary calculation and validation + const accountChanged = reducerUtils.observeChanges(prevState.selectedAccount.account, currentState.selectedAccount.account, ['balance', 'nonce']); + const tokensChanged = reducerUtils.observeChanges(prevState.selectedAccount.tokens, currentState.selectedAccount.tokens); + const pendingChanged = reducerUtils.observeChanges(prevState.selectedAccount.pending, currentState.selectedAccount.pending); + shouldUpdate = accountChanged || tokensChanged || pendingChanged; + } + + // check if "sendForm" reducer changed + if (!shouldUpdate) { + shouldUpdate = reducerUtils.observeChanges(prevState.sendForm, currentState.sendForm); + } + + if (shouldUpdate) { + const validated = dispatch(ValidationActions.validation()); + dispatch({ + type: SEND.VALIDATION, + state: validated, + }); } }; -export const calculateTotal = (amount: string, gasPrice: string, gasLimit: string): string => { - try { - return new BigNumber(amount).plus(calculateFee(gasPrice, gasLimit)).toString(10); - } catch (error) { - return '0'; - } -}; - -export const calculateMaxAmount = (balance: BigNumber, gasPrice: string, gasLimit: string): string => { - try { - // TODO - minus pendings - const fee = calculateFee(gasPrice, gasLimit); - const max = balance.minus(fee); - if (max.lessThan(0)) return '0'; - return max.toString(10); - } catch (error) { - return '0'; - } -}; - -export const calculate = (prevProps: Props, props: Props) => { - const { - account, - tokens, - pending, - } = props.selectedAccount; - if (!account) return; - - const state = props.sendForm; - const isToken: boolean = state.currency !== state.networkSymbol; - - // account balance - // token balance - // gasLimit, gasPrice changed - - // const shouldRecalculateAmount = - // (prevProps.selectedAccount.account !== account) - // || (prevProps.) - - - if (state.setMax) { - const pendingAmount: BigNumber = getPendingAmount(pending, state.currency, isToken); - - if (isToken) { - const token: ?Token = findToken(tokens, account.address, state.currency, account.deviceState); - if (token) { - state.amount = new BigNumber(token.balance).minus(pendingAmount).toString(10); - } - } else { - const b = new BigNumber(account.balance).minus(pendingAmount); - state.amount = calculateMaxAmount(b, state.gasPrice, state.gasLimit); - } - } - - // amount changed - // fee changed - state.total = calculateTotal(isToken ? '0' : state.amount, state.gasPrice, state.gasLimit); - - if (state.selectedFeeLevel.value === 'Custom') { - state.selectedFeeLevel.label = `${calculateFee(state.gasPrice, state.gasLimit)} ${state.networkSymbol}`; - state.selectedFeeLevel.gasPrice = state.gasPrice; - } -}; - - -export const getFeeLevels = (symbol: string, gasPrice: BigNumber | string, gasLimit: string, selected?: FeeLevel): Array => { - const price: BigNumber = typeof gasPrice === 'string' ? new BigNumber(gasPrice) : gasPrice; - const quarter: BigNumber = price.dividedBy(4); - const high: string = price.plus(quarter.times(2)).toString(10); - const low: string = price.minus(quarter.times(2)).toString(10); - - const customLevel: FeeLevel = selected && selected.value === 'Custom' ? { - value: 'Custom', - gasPrice: selected.gasPrice, - // label: `${ calculateFee(gasPrice, gasLimit) } ${ symbol }` - label: `${calculateFee(selected.gasPrice, gasLimit)} ${symbol}`, - } : { - value: 'Custom', - gasPrice: low, - label: '', - }; - - return [ - { - value: 'High', - gasPrice: high, - label: `${calculateFee(high, gasLimit)} ${symbol}`, - }, - { - value: 'Normal', - gasPrice: gasPrice.toString(), - label: `${calculateFee(price.toString(10), gasLimit)} ${symbol}`, - }, - { - value: 'Low', - gasPrice: low, - label: `${calculateFee(low, gasLimit)} ${symbol}`, - }, - customLevel, - ]; -}; - - -// initialize component +/* +* Called from "observe" action +* Initialize "sendForm" reducer data +* Get data either from session storage or "selectedAccount" reducer +*/ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { const { account, @@ -234,8 +113,9 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS if (!account || !network) return; - const stateFromStorage = SessionStorageActions.load(getState().router.location.pathname); + const stateFromStorage = dispatch(SessionStorageActions.loadDraftTransaction()); if (stateFromStorage) { + // TODO: consider if current gasPrice should be set here as "recommendedGasPrice" dispatch({ type: SEND.INIT, state: stateFromStorage, @@ -243,269 +123,70 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS return; } - // TODO: check if there are some unfinished tx in localStorage - - - // const gasPrice: BigNumber = new BigNumber(EthereumjsUnits.convert(web3.gasPrice, 'wei', 'gwei')) || new BigNumber(network.defaultGasPrice); const gasPrice: BigNumber = await dispatch(BlockchainActions.getGasPrice(network.network, network.defaultGasPrice)); - // const gasPrice: BigNumber = new BigNumber(network.defaultGasPrice); - const gasLimit: string = network.defaultGasLimit.toString(); - const feeLevels: Array = getFeeLevels(network.symbol, gasPrice, gasLimit); - - // TODO: get nonce - // TODO: LOAD DATA FROM SESSION STORAGE - - const state: State = { - ...initialState, - networkName: network.network, - networkSymbol: network.symbol, - currency: network.symbol, - feeLevels, - selectedFeeLevel: feeLevels.find(f => f.value === 'Normal'), - recommendedGasPrice: gasPrice.toString(), - gasLimit, - gasPrice: gasPrice.toString(), - }; + const gasLimit = network.defaultGasLimit.toString(); + const feeLevels = ValidationActions.getFeeLevels(network.symbol, gasPrice, gasLimit); + const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, initialState.selectedFeeLevel); dispatch({ type: SEND.INIT, - state, + state: { + ...initialState, + networkName: network.network, + networkSymbol: network.symbol, + currency: network.symbol, + feeLevels, + selectedFeeLevel, + recommendedGasPrice: gasPrice.toString(), + gasLimit, + gasPrice: gasPrice.toString(), + }, }); }; -export const toggleAdvanced = (/* address: string */): Action => ({ +/* +* Called from UI from "advanced" button +*/ +export const toggleAdvanced = (): Action => ({ type: SEND.TOGGLE_ADVANCED, }); - -const addressValidation = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const { - account, - network, - } = getState().selectedAccount; - if (!account || !network) return; - - const state: State = getState().sendForm; - const infos = { ...state.infos }; - const warnings = { ...state.warnings }; - - - if (state.untouched || !state.touched.address) return; - - const savedAccounts = getState().accounts.filter(a => a.address.toLowerCase() === state.address.toLowerCase()); - if (savedAccounts.length > 0) { - // check if found account belongs to this network - // corner-case: when same derivation path is used on different networks - const currentNetworkAccount = savedAccounts.find(a => a.network === network.network); - if (currentNetworkAccount) { - const device: ?TrezorDevice = findDevice(getState().devices, currentNetworkAccount.deviceID, currentNetworkAccount.deviceState); - if (device) { - infos.address = `${device.instanceLabel} Account #${(currentNetworkAccount.index + 1)}`; - } - } else { - const otherNetworkAccount = savedAccounts[0]; - const device: ?TrezorDevice = findDevice(getState().devices, otherNetworkAccount.deviceID, otherNetworkAccount.deviceState); - const { coins } = getState().localStorage.config; - const otherNetwork: ?Coin = coins.find(c => c.network === otherNetworkAccount.network); - if (device && otherNetwork) { - warnings.address = `Looks like it's ${device.instanceLabel} Account #${(otherNetworkAccount.index + 1)} address of ${otherNetwork.name} network`; - } - } - } else { - delete warnings.address; - delete infos.address; - } - - dispatch({ - type: SEND.ADDRESS_VALIDATION, - state: { - ...state, - infos, - warnings, - }, - }); -}; - - -export const validation = (props: Props): void => { - const { - account, - network, - tokens, - pending, - } = props.selectedAccount; - if (!account || !network) return; - - - const state: State = props.sendForm; - - const errors: {[k: string]: string} = {}; - const warnings: {[k: string]: string} = {}; - const infos: {[k: string]: string} = {}; - - if (state.untouched) return; - // valid address - if (state.touched.address) { - const addressError = validateAddress(state.address); - if (addressError) { - errors.address = addressError; - } - - // address warning or info may be set in addressValidation ThunkAction - // do not override them - if (state.warnings.address) { - warnings.address = state.warnings.address; - } - - if (state.infos.address) { - infos.address = state.infos.address; - } - } - - // valid amount - // https://stackoverflow.com/a/42701461 - //const regexp = new RegExp('^(?:[0-9]{0,10}\\.)?[0-9]{1,18}$'); - if (state.touched.amount) { - if (state.amount.length < 1) { - errors.amount = 'Amount is not set'; - } else if (state.amount.length > 0 && !state.amount.match(numberRegExp)) { - errors.amount = 'Amount is not a number'; - } else { - let decimalRegExp: RegExp; - const pendingAmount: BigNumber = getPendingAmount(pending, state.currency, state.currency !== state.networkSymbol); - - if (state.currency !== state.networkSymbol) { - const token = findToken(tokens, account.address, state.currency, account.deviceState); - if (token) { - if (parseInt(token.decimals) > 0) { - //decimalRegExp = new RegExp('^(0|0\\.([0-9]{0,' + token.decimals + '})?|[1-9]+\\.?([0-9]{0,' + token.decimals + '})?|\\.[0-9]{1,' + token.decimals + '})$'); - decimalRegExp = new RegExp(`^(0|0\\.([0-9]{0,${token.decimals}})?|[1-9][0-9]*\\.?([0-9]{0,${token.decimals}})?|\\.[0-9]{1,${token.decimals}})$`); - } else { - // decimalRegExp = new RegExp('^(0|0\\.?|[1-9]+\\.?)$'); - decimalRegExp = new RegExp('^[0-9]+$'); - } - - if (!state.amount.match(decimalRegExp)) { - errors.amount = `Maximum ${token.decimals} decimals allowed`; - } else if (new BigNumber(state.total).greaterThan(account.balance)) { - errors.amount = `Not enough ${state.networkSymbol} to cover transaction fee`; - } else if (new BigNumber(state.amount).greaterThan(new BigNumber(token.balance).minus(pendingAmount))) { - errors.amount = 'Not enough funds'; - } else if (new BigNumber(state.amount).lessThanOrEqualTo('0')) { - errors.amount = 'Amount is too low'; - } - } - } else { - decimalRegExp = new RegExp('^(0|0\\.([0-9]{0,18})?|[1-9][0-9]*\\.?([0-9]{0,18})?|\\.[0-9]{0,18})$'); - if (!state.amount.match(decimalRegExp)) { - errors.amount = 'Maximum 18 decimals allowed'; - } else if (new BigNumber(state.total).greaterThan(new BigNumber(account.balance).minus(pendingAmount))) { - errors.amount = 'Not enough funds'; - } - } - } - } - - // valid gas limit - if (state.touched.gasLimit) { - if (state.gasLimit.length < 1) { - errors.gasLimit = 'Gas limit is not set'; - } else if (state.gasLimit.length > 0 && !state.gasLimit.match(numberRegExp)) { - errors.gasLimit = 'Gas limit is not a number'; - } else { - const gl: BigNumber = new BigNumber(state.gasLimit); - if (gl.lessThan(1)) { - errors.gasLimit = 'Gas limit is too low'; - } else if (gl.lessThan(state.currency !== state.networkSymbol ? network.defaultGasLimitTokens : network.defaultGasLimit)) { - warnings.gasLimit = 'Gas limit is below recommended'; - } - } - } - - // valid gas price - if (state.touched.gasPrice) { - if (state.gasPrice.length < 1) { - errors.gasPrice = 'Gas price is not set'; - } else if (state.gasPrice.length > 0 && !state.gasPrice.match(numberRegExp)) { - errors.gasPrice = 'Gas price is not a number'; - } else { - const gp: BigNumber = new BigNumber(state.gasPrice); - if (gp.greaterThan(1000)) { - warnings.gasPrice = 'Gas price is too high'; - } else if (gp.lessThanOrEqualTo('0')) { - errors.gasPrice = 'Gas price is too low'; - } - } - } - - // valid nonce - if (state.touched.nonce) { - const re = new RegExp('^[0-9]+$'); - if (state.nonce.length < 1) { - errors.nonce = 'Nonce is not set'; - } else if (!state.nonce.match(re)) { - errors.nonce = 'Nonce is not a valid number'; - } else { - const n: BigNumber = new BigNumber(state.nonce); - if (n.lessThan(account.nonce)) { - warnings.nonce = 'Nonce is lower than recommended'; - } else if (n.greaterThan(account.nonce)) { - warnings.nonce = 'Nonce is greater than recommended'; - } - } - } - - // valid data - if (state.touched.data && state.data.length > 0) { - const re = /^[0-9A-Fa-f]+$/g; - if (!re.test(state.data)) { - errors.data = 'Data is not valid hexadecimal'; - } - } - - // valid nonce? - - state.errors = errors; - state.warnings = warnings; - state.infos = infos; -}; - - +/* +* Called from UI on "address" field change +*/ export const onAddressChange = (address: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { const state: State = getState().sendForm; - const touched = { ...state.touched }; - touched.address = true; - dispatch({ - type: SEND.ADDRESS_CHANGE, + type: SEND.CHANGE, state: { ...state, untouched: false, - touched, + touched: { ...state.touched, address: true }, address, }, }); - - dispatch(addressValidation()); }; +/* +* Called from UI on "amount" field change +*/ export const onAmountChange = (amount: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { const state = getState().sendForm; - const touched = { ...state.touched }; - touched.amount = true; - dispatch({ - type: SEND.AMOUNT_CHANGE, + type: SEND.CHANGE, state: { ...state, untouched: false, - touched, + touched: { ...state.touched, amount: true }, setMax: false, amount, }, }); }; +/* +* Called from UI on "currency" selection change +*/ export const onCurrencyChange = (currency: { value: string, label: string }): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { const { account, @@ -513,85 +194,84 @@ export const onCurrencyChange = (currency: { value: string, label: string }): Th } = getState().selectedAccount; if (!account || !network) return; - const currentState: State = getState().sendForm; - const isToken: boolean = currency.value !== currentState.networkSymbol; - const gasLimit: string = isToken ? network.defaultGasLimitTokens.toString() : network.defaultGasLimit.toString(); + const state = getState().sendForm; - const feeLevels: Array = getFeeLevels(network.symbol, currentState.recommendedGasPrice, gasLimit, currentState.selectedFeeLevel); - const selectedFeeLevel: ?FeeLevel = feeLevels.find(f => f.value === currentState.selectedFeeLevel.value); - if (!selectedFeeLevel) return; + const isToken = currency.value !== state.networkSymbol; + const gasLimit = isToken ? network.defaultGasLimitTokens.toString() : network.defaultGasLimit.toString(); - const state: State = { - ...currentState, - currency: currency.value, - // amount, - // total, - feeLevels, - selectedFeeLevel, - gasLimit, - }; + const feeLevels = ValidationActions.getFeeLevels(network.symbol, state.recommendedGasPrice, gasLimit, state.selectedFeeLevel); + const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel); dispatch({ - type: SEND.CURRENCY_CHANGE, - state, + type: SEND.CHANGE, + state: { + ...state, + currency: currency.value, + feeLevels, + selectedFeeLevel, + gasLimit, + }, }); }; +/* +* Called from UI from "set max" button +*/ export const onSetMax = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { const state = getState().sendForm; - const touched = { ...state.touched }; - touched.amount = true; - dispatch({ - type: SEND.SET_MAX, + type: SEND.CHANGE, state: { ...state, untouched: false, - touched, + touched: { ...state.touched, amount: true }, setMax: !state.setMax, }, }); }; +/* +* Called from UI on "fee" selection change +*/ export const onFeeLevelChange = (feeLevel: FeeLevel): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const { - network, - } = getState().selectedAccount; - if (!network) return; + const state = getState().sendForm; - const currentState: State = getState().sendForm; - const isToken: boolean = currentState.currency !== currentState.networkSymbol; + const isCustom = feeLevel.value === 'Custom'; + let newGasLimit = state.gasLimit; + let newGasPrice = state.gasPrice; + const advanced = isCustom ? true : state.advanced; - const state: State = { - ...currentState, - untouched: false, - selectedFeeLevel: feeLevel, - }; - - if (feeLevel.value === 'Custom') { - state.advanced = true; - feeLevel.gasPrice = state.gasPrice; - feeLevel.label = `${calculateFee(state.gasPrice, state.gasLimit)} ${state.networkSymbol}`; - } else { - const customLevel: ?FeeLevel = state.feeLevels.find(f => f.value === 'Custom'); - if (customLevel) customLevel.label = ''; - state.gasPrice = feeLevel.gasPrice; + if (!isCustom) { + // if selected fee is not custom + // update gasLimit to default and gasPrice to selected value + const { network } = getState().selectedAccount; + if (!network) return; + const isToken = state.currency !== state.networkSymbol; if (isToken) { - state.gasLimit = network.defaultGasLimitTokens.toString(); + newGasLimit = network.defaultGasLimitTokens.toString(); } else { - state.gasLimit = state.data.length > 0 ? state.gasLimit : network.defaultGasLimit.toString(); + // corner case: gas limit was changed by user OR by "estimateGasPrice" action + // leave gasLimit as it is + newGasLimit = state.touched.gasLimit ? state.gasLimit : network.defaultGasLimit.toString(); } + newGasPrice = feeLevel.gasPrice; } dispatch({ - type: SEND.FEE_LEVEL_CHANGE, - state, + type: SEND.CHANGE, + state: { + ...state, + advanced, + selectedFeeLevel: feeLevel, + gasLimit: newGasLimit, + gasPrice: newGasPrice, + }, }); }; -// Manually triggered from user -// Update gasPrice to recommended value - +/* +* Called from UI from "update recommended fees" button +*/ export const updateFeeLevels = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { const { account, @@ -599,118 +279,122 @@ export const updateFeeLevels = (): ThunkAction => (dispatch: Dispatch, getState: } = getState().selectedAccount; if (!account || !network) return; - const currentState: State = getState().sendForm; - const isToken: boolean = currentState.currency !== currentState.networkSymbol; - let gasLimit: string = isToken ? network.defaultGasLimitTokens.toString() : network.defaultGasLimit.toString(); - - // override custom settings - if (currentState.selectedFeeLevel.value === 'Custom') { - // update only gasPrice - currentState.selectedFeeLevel.gasPrice = currentState.recommendedGasPrice; - // leave gas limit as it was - ({ gasLimit } = currentState); - } - - const feeLevels: Array = getFeeLevels(network.symbol, currentState.recommendedGasPrice, gasLimit, currentState.selectedFeeLevel); - const selectedFeeLevel: ?FeeLevel = feeLevels.find(f => f.value === currentState.selectedFeeLevel.value); - if (!selectedFeeLevel) return; - - const state: State = { - ...currentState, - feeLevels, - selectedFeeLevel, - gasPrice: selectedFeeLevel.gasPrice, - gasPriceNeedsUpdate: false, - }; - - dispatch({ - type: SEND.UPDATE_FEE_LEVELS, - state, - }); -}; - -export const onGasPriceChange = (gasPrice: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const currentState: State = getState().sendForm; - const isToken: boolean = currentState.currency !== currentState.networkSymbol; - - const touched = { ...currentState.touched }; - touched.gasPrice = true; - - const state: State = { - ...currentState, - untouched: false, - touched, - gasPrice, - }; - - if (currentState.selectedFeeLevel.value !== 'Custom') { - const customLevel = currentState.feeLevels.find(f => f.value === 'Custom'); - if (!customLevel) return; - state.selectedFeeLevel = customLevel; - } - - dispatch({ - type: SEND.GAS_PRICE_CHANGE, - state, - }); -}; - -export const onGasLimitChange = (gasLimit: string/* , shouldUpdateFeeLevels: boolean = false */): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - const currentState: State = getState().sendForm; - - const touched = { ...currentState.touched }; - touched.gasLimit = true; - - const state: State = { - ...currentState, - calculatingGasLimit: false, - untouched: false, - touched, - gasLimit, - }; - - if (currentState.selectedFeeLevel.value !== 'Custom') { - const customLevel = currentState.feeLevels.find(f => f.value === 'Custom'); - if (!customLevel) return; - state.selectedFeeLevel = customLevel; - } - - dispatch({ - type: SEND.GAS_LIMIT_CHANGE, - state, - }); -}; - -export const onNonceChange = (nonce: string): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const currentState: State = getState().sendForm; - const touched = { ...currentState.touched }; - touched.nonce = true; - - const state: State = { - ...currentState, - untouched: false, - touched, - nonce, - }; - - dispatch({ - type: SEND.NONCE_CHANGE, - state, - }); -}; - -const estimateGasPrice = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const { - network, - } = getState().selectedAccount; - if (!network) return; - const state: State = getState().sendForm; - const requestedData = state.data; + const feeLevels = ValidationActions.getFeeLevels(network.symbol, state.recommendedGasPrice, state.gasLimit, state.selectedFeeLevel); + const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel); - const re = /^[0-9A-Fa-f]+$/g; + dispatch({ + type: SEND.CHANGE, + state: { + ...state, + feeLevels, + selectedFeeLevel, + gasPrice: selectedFeeLevel.gasPrice, + gasPriceNeedsUpdate: false, + }, + }); +}; + +/* +* Called from UI on "gas price" field change +*/ +export const onGasPriceChange = (gasPrice: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const state: State = getState().sendForm; + // switch to custom fee level + let newSelectedFeeLevel = state.selectedFeeLevel; + if (state.selectedFeeLevel.value !== 'Custom') newSelectedFeeLevel = state.feeLevels.find(f => f.value === 'Custom'); + + dispatch({ + type: SEND.CHANGE, + state: { + ...state, + untouched: false, + touched: { ...state.touched, gasPrice: true }, + gasPrice, + selectedFeeLevel: newSelectedFeeLevel, + }, + }); +}; + +/* +* Called from UI on "data" field change +* OR from "estimateGasPrice" action +*/ +export const onGasLimitChange = (gasLimit: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const { network } = getState().selectedAccount; + if (!network) return; + const state: State = getState().sendForm; + // recalculate feeLevels with recommended gasPrice + const feeLevels = ValidationActions.getFeeLevels(network.symbol, state.recommendedGasPrice, gasLimit, state.selectedFeeLevel); + const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel); + + dispatch({ + type: SEND.CHANGE, + state: { + ...state, + calculatingGasLimit: false, + untouched: false, + touched: { ...state.touched, gasLimit: true }, + gasLimit, + feeLevels, + selectedFeeLevel, + }, + }); +}; + +/* +* Called from UI on "nonce" field change +*/ +export const onNonceChange = (nonce: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const state: State = getState().sendForm; + dispatch({ + type: SEND.CHANGE, + state: { + ...state, + untouched: false, + touched: { ...state.touched, nonce: true }, + nonce, + }, + }); +}; + +/* +* Called from UI on "data" field change +*/ +export const onDataChange = (data: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const state: State = getState().sendForm; + dispatch({ + type: SEND.CHANGE, + state: { + ...state, + calculatingGasLimit: true, + untouched: false, + touched: { ...state.touched, data: true }, + data, + }, + }); + + dispatch(estimateGasPrice()); +}; + +/* +* Internal method +* Called from "onDataChange" action +* try to asynchronously download data from backend +*/ +const estimateGasPrice = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const state: State = getState().sendForm; + const { network } = getState().selectedAccount; + if (!network) { + // stop "calculatingGasLimit" process + dispatch(onGasLimitChange(state.gasLimit)); + return; + } + + const requestedData = state.data; + const re = /^[0-9A-Fa-f]+$/g; // TODO: allow "0x" prefix if (!re.test(requestedData)) { - // to stop calculating + // stop "calculatingGasLimit" process dispatch(onGasLimitChange(requestedData.length > 0 ? state.gasLimit : network.defaultGasLimit.toString())); return; } @@ -721,40 +405,18 @@ const estimateGasPrice = (): AsyncAction => async (dispatch: Dispatch, getState: return; } - const gasLimit: number = await dispatch(BlockchainActions.estimateGasLimit(network.network, state.data, state.amount, state.gasPrice)); + const gasLimit = await dispatch(BlockchainActions.estimateGasLimit(network.network, state.data, state.amount, state.gasPrice)); + // double check "data" field + // possible race condition when data changed before backend respond if (getState().sendForm.data === requestedData) { - dispatch(onGasLimitChange(gasLimit.toString())); + dispatch(onGasLimitChange(gasLimit)); } }; -export const onDataChange = (data: string): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const currentState: State = getState().sendForm; - const touched = { ...currentState.touched }; - touched.data = true; - - const state: State = { - ...currentState, - calculatingGasLimit: true, - untouched: false, - touched, - data, - }; - - if (currentState.selectedFeeLevel.value !== 'Custom') { - const customLevel = currentState.feeLevels.find(f => f.value === 'Custom'); - if (!customLevel) return; - state.selectedFeeLevel = customLevel; - } - - dispatch({ - type: SEND.DATA_CHANGE, - state, - }); - - dispatch(estimateGasPrice()); -}; - +/* +* Called from UI from "send" button +*/ export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { const { account, @@ -767,8 +429,7 @@ export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: Ge const currentState: State = getState().sendForm; const isToken: boolean = currentState.currency !== currentState.networkSymbol; - const address_n = account.addressPath; - const pendingNonce: number = stateUtils.getPendingNonce(pending); + const pendingNonce: number = reducerUtils.getPendingNonce(pending); const nonce = pendingNonce > 0 && pendingNonce >= account.nonce ? pendingNonce : account.nonce; const txData = await dispatch(prepareEthereumTx({ @@ -793,7 +454,7 @@ export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: Ge state: selected.state, }, useEmptyPassphrase: !selected.instance, - path: address_n, + path: account.addressPath, transaction: txData, }); @@ -826,7 +487,7 @@ export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: Ge throw new Error(push.payload.error); } - const txid = push.payload.txid; + const { txid } = push.payload; dispatch({ type: SEND.TX_COMPLETE, diff --git a/src/actions/SendFormValidationActions.js b/src/actions/SendFormValidationActions.js new file mode 100644 index 00000000..6f7b09f0 --- /dev/null +++ b/src/actions/SendFormValidationActions.js @@ -0,0 +1,413 @@ +/* @flow */ + +import BigNumber from 'bignumber.js'; +import EthereumjsUtil from 'ethereumjs-util'; +import EthereumjsUnits from 'ethereumjs-units'; +import { findToken } from 'reducers/TokensReducer'; +import { findDevice, getPendingAmount } from 'reducers/utils'; +import * as SEND from 'actions/constants/send'; + +import type { + Dispatch, + GetState, + PayloadAction, +} from 'flowtype'; +import type { State, FeeLevel } from 'reducers/SendFormReducer'; + +// general regular expressions +const NUMBER_RE: RegExp = new RegExp('^(0|0\\.([0-9]+)?|[1-9][0-9]*\\.?([0-9]+)?|\\.[0-9]+)$'); +const UPPERCASE_RE = new RegExp('^(.*[A-Z].*)$'); +const ABS_RE = new RegExp('^[0-9]+$'); +const ETH_18_RE = new RegExp('^(0|0\\.([0-9]{0,18})?|[1-9][0-9]*\\.?([0-9]{0,18})?|\\.[0-9]{0,18})$'); +const HEX_RE = new RegExp('^[0-9A-Fa-f]+$'); +const dynamicRegexp = (decimals: number): RegExp => { + if (decimals > 0) { + return new RegExp(`^(0|0\\.([0-9]{0,${decimals}})?|[1-9][0-9]*\\.?([0-9]{0,${decimals}})?|\\.[0-9]{1,${decimals}})$`); + } + return ABS_RE; +}; + +/* +* Called from SendFormActions.observe +* Reaction for WEB3.GAS_PRICE_UPDATED action +*/ +export const onGasPriceUpdated = (network: string, gasPrice: string): PayloadAction => (dispatch: Dispatch, getState: GetState): void => { + // testing random data + // function getRandomInt(min, max) { + // return Math.floor(Math.random() * (max - min + 1)) + min; + // } + // const newPrice = getRandomInt(10, 50).toString(); + + const state = getState().sendForm; + if (network === state.networkSymbol) return; + + // check if new price is different then currently recommended + const newPrice: string = EthereumjsUnits.convert(gasPrice, 'wei', 'gwei'); + + if (newPrice !== state.recommendedGasPrice) { + if (!state.untouched) { + // if there is a transaction draft let the user know + // and let him update manually + dispatch({ + type: SEND.CHANGE, + state: { + ...state, + gasPriceNeedsUpdate: true, + recommendedGasPrice: newPrice, + }, + }); + } else { + // automatically update feeLevels and gasPrice + const feeLevels = getFeeLevels(state.networkSymbol, newPrice, state.gasLimit); + const selectedFeeLevel = getSelectedFeeLevel(feeLevels, state.selectedFeeLevel); + dispatch({ + type: SEND.CHANGE, + state: { + ...state, + gasPriceNeedsUpdate: false, + recommendedGasPrice: newPrice, + gasPrice: selectedFeeLevel.gasPrice, + feeLevels, + selectedFeeLevel, + }, + }); + } + } +}; + +/* +* Recalculate amount, total and fees +*/ +export const validation = (): PayloadAction => (dispatch: Dispatch, getState: GetState): State => { + // clone deep nested object + // to avoid overrides across state history + let state: State = JSON.parse(JSON.stringify(getState().sendForm)); + // reset errors + state.errors = {}; + state.warnings = {}; + state.infos = {}; + state = dispatch(recalculate(state)); + state = dispatch(updateCustomFeeLabel(state)); + state = dispatch(addressValidation(state)); + state = dispatch(addressLabel(state)); + state = dispatch(amountValidation(state)); + state = dispatch(gasLimitValidation(state)); + state = dispatch(gasPriceValidation(state)); + state = dispatch(nonceValidation(state)); + state = dispatch(dataValidation(state)); + return state; +}; + +export const recalculate = ($state: State): PayloadAction => (dispatch: Dispatch, getState: GetState): State => { + const { + account, + tokens, + pending, + } = getState().selectedAccount; + if (!account) return $state; + + const state = { ...$state }; + const isToken = state.currency !== state.networkSymbol; + + if (state.setMax) { + const pendingAmount = getPendingAmount(pending, state.currency, isToken); + if (isToken) { + const token = findToken(tokens, account.address, state.currency, account.deviceState); + if (token) { + state.amount = new BigNumber(token.balance).minus(pendingAmount).toString(10); + } + } else { + const b = new BigNumber(account.balance).minus(pendingAmount); + state.amount = calculateMaxAmount(b, state.gasPrice, state.gasLimit); + } + } + + state.total = calculateTotal(isToken ? '0' : state.amount, state.gasPrice, state.gasLimit); + return state; +}; + +export const updateCustomFeeLabel = ($state: State): PayloadAction => (): State => { + const state = { ...$state }; + if ($state.selectedFeeLevel.value === 'Custom') { + state.selectedFeeLevel = { + ...state.selectedFeeLevel, + gasPrice: state.gasPrice, + label: `${calculateFee(state.gasPrice, state.gasLimit)} ${state.networkSymbol}`, + }; + } + return state; +}; + +/* +* Address value validation +*/ +export const addressValidation = ($state: State): PayloadAction => (): State => { + const state = { ...$state }; + if (!state.touched.address) return state; + + const { address } = state; + + if (address.length < 1) { + state.errors.address = 'Address is not set'; + } else if (!EthereumjsUtil.isValidAddress(address)) { + state.errors.address = 'Address is not valid'; + } else if (address.match(UPPERCASE_RE) && !EthereumjsUtil.isValidChecksumAddress(address)) { + state.errors.address = 'Address is not a valid checksum'; + } + return state; +}; + +/* +* Address label assignation +*/ +export const addressLabel = ($state: State): PayloadAction => (dispatch: Dispatch, getState: GetState): State => { + const state = { ...$state }; + if (!state.touched.address || state.errors.address) return state; + + const { + account, + network, + } = getState().selectedAccount; + if (!account || !network) return state; + const { address } = state; + + const savedAccounts = getState().accounts.filter(a => a.address.toLowerCase() === address.toLowerCase()); + if (savedAccounts.length > 0) { + // check if found account belongs to this network + const currentNetworkAccount = savedAccounts.find(a => a.network === network.network); + if (currentNetworkAccount) { + const device = findDevice(getState().devices, currentNetworkAccount.deviceID, currentNetworkAccount.deviceState); + if (device) { + state.infos.address = `${device.instanceLabel} Account #${(currentNetworkAccount.index + 1)}`; + } + } else { + // corner-case: the same derivation path is used on different networks + const otherNetworkAccount = savedAccounts[0]; + const device = findDevice(getState().devices, otherNetworkAccount.deviceID, otherNetworkAccount.deviceState); + const { coins } = getState().localStorage.config; + const otherNetwork = coins.find(c => c.network === otherNetworkAccount.network); + if (device && otherNetwork) { + state.warnings.address = `Looks like it's ${device.instanceLabel} Account #${(otherNetworkAccount.index + 1)} address of ${otherNetwork.name} network`; + } + } + } + + return state; +}; + +/* +* Amount value validation +*/ +export const amountValidation = ($state: State): PayloadAction => (dispatch: Dispatch, getState: GetState): State => { + const state = { ...$state }; + if (!state.touched.amount) return state; + + const { + account, + tokens, + pending, + } = getState().selectedAccount; + if (!account) return state; + + const { amount } = state; + if (amount.length < 1) { + state.errors.amount = 'Amount is not set'; + } else if (amount.length > 0 && !amount.match(NUMBER_RE)) { + state.errors.amount = 'Amount is not a number'; + } else { + const isToken: boolean = state.currency !== state.networkSymbol; + const pendingAmount: BigNumber = getPendingAmount(pending, state.currency, isToken); + + if (isToken) { + const token = findToken(tokens, account.address, state.currency, account.deviceState); + if (!token) return state; + const decimalRegExp = dynamicRegexp(parseInt(token.decimals, 0)); + + if (!state.amount.match(decimalRegExp)) { + state.errors.amount = `Maximum ${token.decimals} decimals allowed`; + } else if (new BigNumber(state.total).greaterThan(account.balance)) { + state.errors.amount = `Not enough ${state.networkSymbol} to cover transaction fee`; + } else if (new BigNumber(state.amount).greaterThan(new BigNumber(token.balance).minus(pendingAmount))) { + state.errors.amount = 'Not enough funds'; + } else if (new BigNumber(state.amount).lessThanOrEqualTo('0')) { + state.errors.amount = 'Amount is too low'; + } + } else if (!state.amount.match(ETH_18_RE)) { + state.errors.amount = 'Maximum 18 decimals allowed'; + } else if (new BigNumber(state.total).greaterThan(new BigNumber(account.balance).minus(pendingAmount))) { + state.errors.amount = 'Not enough funds'; + } + } + return state; +}; + +/* +* Gas limit value validation +*/ +export const gasLimitValidation = ($state: State): PayloadAction => (dispatch: Dispatch, getState: GetState): State => { + const state = { ...$state }; + if (!state.touched.gasLimit) return state; + + const { + network, + } = getState().selectedAccount; + if (!network) return state; + + const { gasLimit } = state; + if (gasLimit.length < 1) { + state.errors.gasLimit = 'Gas limit is not set'; + } else if (gasLimit.length > 0 && !gasLimit.match(NUMBER_RE)) { + state.errors.gasLimit = 'Gas limit is not a number'; + } else { + const gl: BigNumber = new BigNumber(gasLimit); + if (gl.lessThan(1)) { + state.errors.gasLimit = 'Gas limit is too low'; + } else if (gl.lessThan(state.currency !== state.networkSymbol ? network.defaultGasLimitTokens : network.defaultGasLimit)) { + state.warnings.gasLimit = 'Gas limit is below recommended'; + } + } + return state; +}; + +/* +* Gas price value validation +*/ +export const gasPriceValidation = ($state: State): PayloadAction => (): State => { + const state = { ...$state }; + if (!state.touched.gasPrice) return state; + + const { gasPrice } = state; + if (gasPrice.length < 1) { + state.errors.gasPrice = 'Gas price is not set'; + } else if (gasPrice.length > 0 && !gasPrice.match(NUMBER_RE)) { + state.errors.gasPrice = 'Gas price is not a number'; + } else { + const gp: BigNumber = new BigNumber(gasPrice); + if (gp.greaterThan(1000)) { + state.warnings.gasPrice = 'Gas price is too high'; + } else if (gp.lessThanOrEqualTo('0')) { + state.errors.gasPrice = 'Gas price is too low'; + } + } + return state; +}; + +/* +* Nonce value validation +*/ +export const nonceValidation = ($state: State): PayloadAction => (dispatch: Dispatch, getState: GetState): State => { + const state = { ...$state }; + if (!state.touched.nonce) return state; + + const { + account, + } = getState().selectedAccount; + if (!account) return state; + + const { nonce } = state; + if (nonce.length < 1) { + state.errors.nonce = 'Nonce is not set'; + } else if (!nonce.match(ABS_RE)) { + state.errors.nonce = 'Nonce is not a valid number'; + } else { + const n: BigNumber = new BigNumber(nonce); + if (n.lessThan(account.nonce)) { + state.warnings.nonce = 'Nonce is lower than recommended'; + } else if (n.greaterThan(account.nonce)) { + state.warnings.nonce = 'Nonce is greater than recommended'; + } + } + return state; +}; + +/* +* Gas price value validation +*/ +export const dataValidation = ($state: State): PayloadAction => (): State => { + const state = { ...$state }; + if (!state.touched.data || state.data.length === 0) return state; + if (!HEX_RE.test(state.data)) { + state.errors.data = 'Data is not valid hexadecimal'; + } + return state; +}; + +/* +* UTILITIES +*/ + +export const calculateFee = (gasPrice: string, gasLimit: string): string => { + try { + return EthereumjsUnits.convert(new BigNumber(gasPrice).times(gasLimit), 'gwei', 'ether'); + } catch (error) { + return '0'; + } +}; + +export const calculateTotal = (amount: string, gasPrice: string, gasLimit: string): string => { + try { + return new BigNumber(amount).plus(calculateFee(gasPrice, gasLimit)).toString(10); + } catch (error) { + return '0'; + } +}; + +export const calculateMaxAmount = (balance: BigNumber, gasPrice: string, gasLimit: string): string => { + try { + // TODO - minus pendings + const fee = calculateFee(gasPrice, gasLimit); + const max = balance.minus(fee); + if (max.lessThan(0)) return '0'; + return max.toString(10); + } catch (error) { + return '0'; + } +}; + +export const getFeeLevels = (symbol: string, gasPrice: BigNumber | string, gasLimit: string, selected?: FeeLevel): Array => { + const price: BigNumber = typeof gasPrice === 'string' ? new BigNumber(gasPrice) : gasPrice; + const quarter: BigNumber = price.dividedBy(4); + const high: string = price.plus(quarter.times(2)).toString(10); + const low: string = price.minus(quarter.times(2)).toString(10); + + const customLevel: FeeLevel = selected && selected.value === 'Custom' ? { + value: 'Custom', + gasPrice: selected.gasPrice, + // label: `${ calculateFee(gasPrice, gasLimit) } ${ symbol }` + label: `${calculateFee(selected.gasPrice, gasLimit)} ${symbol}`, + } : { + value: 'Custom', + gasPrice: low, + label: '', + }; + + return [ + { + value: 'High', + gasPrice: high, + label: `${calculateFee(high, gasLimit)} ${symbol}`, + }, + { + value: 'Normal', + gasPrice: gasPrice.toString(), + label: `${calculateFee(price.toString(10), gasLimit)} ${symbol}`, + }, + { + value: 'Low', + gasPrice: low, + label: `${calculateFee(low, gasLimit)} ${symbol}`, + }, + customLevel, + ]; +}; + +export const getSelectedFeeLevel = (feeLevels: Array, selected: FeeLevel): FeeLevel => { + const { value } = selected; + let selectedFeeLevel: ?FeeLevel; + selectedFeeLevel = feeLevels.find(f => f.value === value); + if (!selectedFeeLevel) { + // fallback to default + selectedFeeLevel = feeLevels.find(f => f.value === 'Normal'); + } + return selectedFeeLevel || selected; +}; \ No newline at end of file diff --git a/src/actions/constants/send.js b/src/actions/constants/send.js index 08528692..a5d12906 100644 --- a/src/actions/constants/send.js +++ b/src/actions/constants/send.js @@ -1,23 +1,9 @@ /* @flow */ - export const INIT: 'send__init' = 'send__init'; -export const DISPOSE: 'send__dispose' = 'send__dispose'; +export const CHANGE: 'send__change' = 'send__change'; export const VALIDATION: 'send__validation' = 'send__validation'; -export const ADDRESS_VALIDATION: 'send__address_validation' = 'send__address_validation'; -export const ADDRESS_CHANGE: 'send__address_change' = 'send__address_change'; -export const AMOUNT_CHANGE: 'send__amount_change' = 'send__amount_change'; -export const SET_MAX: 'send__set_max' = 'send__set_max'; -export const CURRENCY_CHANGE: 'send__currency_change' = 'send__currency_change'; -export const FEE_LEVEL_CHANGE: 'send__fee_level_change' = 'send__fee_level_change'; -export const GAS_PRICE_CHANGE: 'send__gas_price_change' = 'send__gas_price_change'; -export const GAS_LIMIT_CHANGE: 'send__gas_limit_change' = 'send__gas_limit_change'; -export const NONCE_CHANGE: 'send__nonce_change' = 'send__nonce_change'; -export const UPDATE_FEE_LEVELS: 'send__update_fee_levels' = 'send__update_fee_levels'; -export const DATA_CHANGE: 'send__data_change' = 'send__data_change'; -export const SEND: 'send__submit' = 'send__submit'; +export const TX_SENDING: 'send__tx_sending' = 'send__tx_sending'; export const TX_COMPLETE: 'send__tx_complete' = 'send__tx_complete'; export const TX_ERROR: 'send__tx_error' = 'send__tx_error'; export const TOGGLE_ADVANCED: 'send__toggle_advanced' = 'send__toggle_advanced'; - -export const FROM_SESSION_STORAGE: 'send__from_session_storage' = 'send__from_session_storage'; \ No newline at end of file diff --git a/src/reducers/SendFormReducer.js b/src/reducers/SendFormReducer.js index df8902e3..b370be6e 100644 --- a/src/reducers/SendFormReducer.js +++ b/src/reducers/SendFormReducer.js @@ -1,17 +1,9 @@ /* @flow */ - -import EthereumjsUnits from 'ethereumjs-units'; import * as SEND from 'actions/constants/send'; -import * as WEB3 from 'actions/constants/web3'; import * as ACCOUNT from 'actions/constants/account'; -import { getFeeLevels } from 'actions/SendFormActions'; - import type { Action } from 'flowtype'; -import type { - Web3UpdateGasPriceAction, -} from 'actions/Web3Actions'; export type FeeLevel = { label: string; @@ -82,46 +74,16 @@ export const initialState: State = { infos: {}, }; - -const onGasPriceUpdated = (state: State, action: Web3UpdateGasPriceAction): State => { - // function getRandomInt(min, max) { - // return Math.floor(Math.random() * (max - min + 1)) + min; - // } - // const newPrice = getRandomInt(10, 50).toString(); - const newPrice: string = EthereumjsUnits.convert(action.gasPrice, 'wei', 'gwei'); - if (action.network === state.networkName && newPrice !== state.recommendedGasPrice) { - const newState: State = { ...state }; - if (!state.untouched) { - newState.gasPriceNeedsUpdate = true; - newState.recommendedGasPrice = newPrice; - } else { - const newFeeLevels = getFeeLevels(state.networkSymbol, newPrice, state.gasLimit); - const selectedFeeLevel: ?FeeLevel = newFeeLevels.find(f => f.value === 'Normal'); - if (!selectedFeeLevel) return state; - newState.recommendedGasPrice = newPrice; - newState.feeLevels = newFeeLevels; - newState.selectedFeeLevel = selectedFeeLevel; - newState.gasPrice = selectedFeeLevel.gasPrice; - } - return newState; - } - return state; -}; - - export default (state: State = initialState, action: Action): State => { switch (action.type) { case SEND.INIT: + case SEND.CHANGE: + case SEND.VALIDATION: return action.state; case ACCOUNT.DISPOSE: return initialState; - // this will be called right after Web3 instance initialization before any view is shown - // and async during app live time - case WEB3.GAS_PRICE_UPDATED: - return onGasPriceUpdated(state, action); - case SEND.TOGGLE_ADVANCED: return { @@ -129,22 +91,7 @@ export default (state: State = initialState, action: Action): State => { advanced: !state.advanced, }; - - // user actions - case SEND.ADDRESS_CHANGE: - case SEND.ADDRESS_VALIDATION: - case SEND.AMOUNT_CHANGE: - case SEND.SET_MAX: - case SEND.CURRENCY_CHANGE: - case SEND.FEE_LEVEL_CHANGE: - case SEND.UPDATE_FEE_LEVELS: - case SEND.GAS_PRICE_CHANGE: - case SEND.GAS_LIMIT_CHANGE: - case SEND.NONCE_CHANGE: - case SEND.DATA_CHANGE: - return action.state; - - case SEND.SEND: + case SEND.TX_SENDING: return { ...state, sending: true, @@ -156,32 +103,6 @@ export default (state: State = initialState, action: Action): State => { sending: false, }; - case SEND.VALIDATION: - return { - ...state, - errors: action.errors, - warnings: action.warnings, - infos: action.infos, - }; - - case SEND.FROM_SESSION_STORAGE: - return { - ...state, - - address: action.address, - amount: action.amount, - setMax: action.setMax, - selectedCurrency: action.selectedCurrency, - selectedFeeLevel: action.selectedFeeLevel, - advanced: action.advanced, - gasLimit: action.gasLimit, - gasPrice: action.gasPrice, - data: action.data, - nonce: action.nonce, - untouched: false, - touched: action.touched, - }; - default: return state; } From 9f98e7dd7e20a1d69562091c9062b1040e87d8a3 Mon Sep 17 00:00:00 2001 From: Szymon Lesisz Date: Sat, 22 Sep 2018 18:49:25 +0200 Subject: [PATCH 07/11] observe from WalletService --- src/services/WalletService.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/services/WalletService.js b/src/services/WalletService.js index ab9f6b95..8856b0c9 100644 --- a/src/services/WalletService.js +++ b/src/services/WalletService.js @@ -10,6 +10,7 @@ import * as NotificationActions from 'actions/NotificationActions'; import * as LocalStorageActions from 'actions/LocalStorageActions'; import * as TrezorConnectActions from 'actions/TrezorConnectActions'; import * as SelectedAccountActions from 'actions/SelectedAccountActions'; +import * as SendFormActionActions from 'actions/SendFormActions'; import type { Middleware, @@ -91,6 +92,8 @@ const WalletService: Middleware = (api: MiddlewareAPI) => (next: MiddlewareDispa api.dispatch(NotificationActions.clear(prevLocation.state, currentLocation.state)); } + // observe send form props changes + api.dispatch(SendFormActionActions.observe(prevState, action)); // update common values in WallerReducer api.dispatch(WalletActions.updateSelectedValues(prevState, action)); From 91f731c34e13db21a3e70ce3c7bd53885f7fe095 Mon Sep 17 00:00:00 2001 From: Szymon Lesisz Date: Sat, 22 Sep 2018 18:50:15 +0200 Subject: [PATCH 08/11] making sendform component a stateless + spliting advanced to a separate component --- .../AccountSend/components/Advanced/index.js | 221 ++++++ src/views/Wallet/views/AccountSend/index.js | 677 ++++++------------ 2 files changed, 457 insertions(+), 441 deletions(-) create mode 100644 src/views/Wallet/views/AccountSend/components/Advanced/index.js diff --git a/src/views/Wallet/views/AccountSend/components/Advanced/index.js b/src/views/Wallet/views/AccountSend/components/Advanced/index.js new file mode 100644 index 00000000..dd9d44e6 --- /dev/null +++ b/src/views/Wallet/views/AccountSend/components/Advanced/index.js @@ -0,0 +1,221 @@ +/* @flow */ + +import React from 'react'; +import styled from 'styled-components'; +import colors from 'config/colors'; + +import Input from 'components/inputs/Input'; +import Textarea from 'components/Textarea'; +import Tooltip from 'components/Tooltip'; +import Icon from 'components/Icon'; +import Link from 'components/Link'; +import ICONS from 'config/icons'; + +import type { Props } from '../../Container'; + +// duplicates from ../../Container +const InputRow = styled.div` + margin-bottom: 20px; +`; +// duplicates end + +const InputLabelWrapper = styled.div` + display: flex; + align-items: center; +`; + +const GreenSpan = styled.span` + color: ${colors.GREEN_PRIMARY}; +`; + +const AdvancedSettingsWrapper = styled.div` + padding: 20px 0; + display: flex; + flex-direction: column; + justify-content: space-between; + + border-top: 1px solid ${colors.DIVIDER}; +`; + +const GasInputRow = styled(InputRow)` + width: 100%; + display: flex; +`; + +const GasInput = styled(Input)` + &:first-child { + padding-right: 20px; + } +`; + +const StyledTextarea = styled(Textarea)` + margin-bottom: 20px; + height: 80px; +`; + +const AdvancedSettingsSendButtonWrapper = styled.div` + width: 100%; + display: flex; + justify-content: flex-end; +`; + +const getGasLimitInputState = (gasLimitErrors: string, gasLimitWarnings: string): string => { + let state = ''; + if (gasLimitWarnings && !gasLimitErrors) { + state = 'warning'; + } + if (gasLimitErrors) { + state = 'error'; + } + return state; +}; + +const getGasPriceInputState = (gasPriceErrors: string, gasPriceWarnings: string): string => { + let state = ''; + if (gasPriceWarnings && !gasPriceErrors) { + state = 'warning'; + } + if (gasPriceErrors) { + state = 'error'; + } + return state; +}; + +// stateless component +const AdvancedForm = (props: Props) => { + const { + network, + } = props.selectedAccount; + if (!network) return null; + const { + networkSymbol, + currency, + recommendedGasPrice, + errors, + warnings, + infos, + data, + gasLimit, + gasPrice, + } = props.sendForm; + const { + onGasLimitChange, + onGasPriceChange, + onDataChange, + } = props.sendFormActions; + + let gasLimitTooltipCurrency: string; + let gasLimitTooltipValue: string; + if (networkSymbol !== currency) { + gasLimitTooltipCurrency = 'tokens'; + gasLimitTooltipValue = network.defaultGasLimitTokens.toString(10); + } else { + gasLimitTooltipCurrency = networkSymbol; + gasLimitTooltipValue = network.defaultGasLimit.toString(10); + } + + return ( + + + + Gas limit + + Gas limit is the amount of gas to send with your transaction.
+ TX fee = gas price * gas limit & is paid to miners for including your TX in a block.
+ Increasing this number will not get your TX mined faster.
+ Default value for sending {gasLimitTooltipCurrency} is {gasLimitTooltipValue} + + )} + placement="top" + > + +
+ + )} + bottomText={errors.gasLimit || warnings.gasLimit || infos.gasLimit} + value={gasLimit} + isDisabled={networkSymbol === currency && data.length > 0} + onChange={event => onGasLimitChange(event.target.value)} + /> + + + Gas price + + Gas Price is the amount you pay per unit of gas.
+ TX fee = gas price * gas limit & is paid to miners for including your TX in a block.
+ Higher the gas price = faster transaction, but more expensive. Recommended is {recommendedGasPrice} GWEI.
+ Read more + + )} + placement="top" + > + +
+ + )} + bottomText={errors.gasPrice || warnings.gasPrice || infos.gasPrice} + value={gasPrice} + onChange={event => onGasPriceChange(event.target.value)} + /> +
+ + + Data + + Data is usually used when you send transactions to contracts. + + )} + placement="top" + > + + + + )} + bottomText={errors.data || warnings.data || infos.data} + disabled={networkSymbol !== currency} + value={networkSymbol !== currency ? '' : data} + onChange={event => onDataChange(event.target.value)} + /> + + + { props.children } + +
+ ); +}; + +export default AdvancedForm; \ No newline at end of file diff --git a/src/views/Wallet/views/AccountSend/index.js b/src/views/Wallet/views/AccountSend/index.js index c2318e72..64863ab3 100644 --- a/src/views/Wallet/views/AccountSend/index.js +++ b/src/views/Wallet/views/AccountSend/index.js @@ -1,6 +1,6 @@ /* @flow */ -import React, { Component } from 'react'; +import React from 'react'; import styled, { css } from 'styled-components'; import { Select } from 'components/Select'; import Button from 'components/Button'; @@ -12,11 +12,9 @@ import { FONT_SIZE, FONT_WEIGHT, TRANSITION } from 'config/variables'; import colors from 'config/colors'; import P from 'components/Paragraph'; import { H2 } from 'components/Heading'; -import Textarea from 'components/Textarea'; -import Tooltip from 'components/Tooltip'; -import { calculate, validation } from 'actions/SendFormActions'; import SelectedAccount from 'views/Wallet/components/SelectedAccount'; import type { Token } from 'flowtype'; +import AdvancedForm from './components/Advanced'; import PendingTransactions from './components/PendingTransactions'; import type { Props } from './Container'; @@ -25,11 +23,6 @@ import type { Props } from './Container'; // and put it inside config/variables.js const SmallScreenWidth = '850px'; -type State = { - isAdvancedSettingsHidden: boolean, - shouldAnimateAdvancedSettingsToggle: boolean, -}; - const Wrapper = styled.section` padding: 0 48px; `; @@ -143,453 +136,255 @@ const SendButton = styled(Button)` } `; -const AdvancedSettingsWrapper = styled.div` - padding: 20px 0; - display: flex; - flex-direction: column; - justify-content: space-between; - - border-top: 1px solid ${colors.DIVIDER}; -`; - -const GasInputRow = styled(InputRow)` - width: 100%; - display: flex; -`; - -const GasInput = styled(Input)` - &:first-child { - padding-right: 20px; - } -`; - -const AdvancedSettingsSendButtonWrapper = styled.div` - width: 100%; - display: flex; - justify-content: flex-end; -`; - -const StyledTextarea = styled(Textarea)` - margin-bottom: 20px; - height: 80px; -`; - const AdvancedSettingsIcon = styled(Icon)` margin-left: 10px; `; -const GreenSpan = styled.span` - color: ${colors.GREEN_PRIMARY}; -`; +// render helpers +const getAddressInputState = (address: string, addressErrors: string, addressWarnings: string): string => { + let state = ''; + if (address && !addressErrors) { + state = 'success'; + } + if (addressWarnings && !addressErrors) { + state = 'warning'; + } + if (addressErrors) { + state = 'error'; + } + return state; +}; -const InputLabelWrapper = styled.div` - display: flex; - align-items: center; -`; +const getAmountInputState = (amountErrors: string, amountWarnings: string): string => { + let state = ''; + if (amountWarnings && !amountErrors) { + state = 'warning'; + } + if (amountErrors) { + state = 'error'; + } + return state; +}; -class AccountSend extends Component { - constructor(props: Props) { - super(props); - this.state = { - isAdvancedSettingsHidden: true, - shouldAnimateAdvancedSettingsToggle: false, - }; +const getTokensSelectData = (tokens: Array, accountNetwork: any): Array<{ value: string, label: string }> => { + const tokensSelectData: Array<{ value: string, label: string }> = tokens.map(t => ({ value: t.symbol, label: t.symbol })); + tokensSelectData.unshift({ value: accountNetwork.symbol, label: accountNetwork.symbol }); + + return tokensSelectData; +}; + +// stateless component +const AccountSend = (props: Props) => { + const device = props.wallet.selectedDevice; + const { + account, + network, + discovery, + tokens, + } = props.selectedAccount; + const { + address, + amount, + setMax, + networkSymbol, + currency, + feeLevels, + selectedFeeLevel, + gasPriceNeedsUpdate, + total, + errors, + warnings, + infos, + data, + sending, + advanced, + } = props.sendForm; + + const { + toggleAdvanced, + onAddressChange, + onAmountChange, + onSetMax, + onCurrencyChange, + onFeeLevelChange, + updateFeeLevels, + onSend, + } = props.sendFormActions; + + if (!device || !account || !discovery || !network) return null; + + let isSendButtonDisabled: boolean = Object.keys(errors).length > 0 || total === '0' || amount.length === 0 || address.length === 0 || sending; + let sendButtonText: string = 'Send'; + if (networkSymbol !== currency && amount.length > 0 && !errors.amount) { + sendButtonText += ` ${amount} ${currency.toUpperCase()}`; + } else if (networkSymbol === currency && total !== '0') { + sendButtonText += ` ${total} ${network.symbol}`; } - componentWillReceiveProps(newProps: Props) { - calculate(this.props, newProps); - validation(newProps); + if (!device.connected) { + sendButtonText = 'Device is not connected'; + isSendButtonDisabled = true; + } else if (!device.available) { + sendButtonText = 'Device is unavailable'; + isSendButtonDisabled = true; + } else if (!discovery.completed) { + sendButtonText = 'Loading accounts'; + isSendButtonDisabled = true; } - getAddressInputState(address: string, addressErrors: string, addressWarnings: string) { - let state = ''; - if (address && !addressErrors) { - state = 'success'; - } - if (addressWarnings && !addressErrors) { - state = 'warning'; - } - if (addressErrors) { - state = 'error'; - } - return state; - } + const tokensSelectData = getTokensSelectData(tokens, network); + const isAdvancedSettingsHidden = !advanced; + // eslint workaround (is this some bug?) + // if i put {true} directly to "AdvancedSettingsIcon" component + // i get eslint error + const advancedButtonCanAnimate = true; - getAmountInputState(amountErrors: string, amountWarnings: string) { - let state = ''; - if (amountWarnings && !amountErrors) { - state = 'warning'; - } - if (amountErrors) { - state = 'error'; - } - return state; - } + return ( + + + Send Ethereum or tokens + + onAddressChange(event.target.value)} + /> + - getGasLimitInputState(gasLimitErrors: string, gasLimitWarnings: string) { - let state = ''; - if (gasLimitWarnings && !gasLimitErrors) { - state = 'warning'; - } - if (gasLimitErrors) { - state = 'error'; - } - return state; - } - - getGasPriceInputState(gasPriceErrors: string, gasPriceWarnings: string) { - let state = ''; - if (gasPriceWarnings && !gasPriceErrors) { - state = 'warning'; - } - if (gasPriceErrors) { - state = 'error'; - } - return state; - } - - getTokensSelectData(tokens: Array, accountNetwork: any) { - const tokensSelectData: Array<{ value: string, label: string }> = tokens.map(t => ({ value: t.symbol, label: t.symbol })); - tokensSelectData.unshift({ value: accountNetwork.symbol, label: accountNetwork.symbol }); - - return tokensSelectData; - } - - handleToggleAdvancedSettingsButton() { - this.toggleAdvancedSettings(); - } - - toggleAdvancedSettings() { - this.setState(previousState => ({ - isAdvancedSettingsHidden: !previousState.isAdvancedSettingsHidden, - shouldAnimateAdvancedSettingsToggle: true, - })); - } - - render() { - const device = this.props.wallet.selectedDevice; - const { - account, - network, - discovery, - tokens, - } = this.props.selectedAccount; - const { - address, - amount, - setMax, - networkSymbol, - currency, - feeLevels, - selectedFeeLevel, - recommendedGasPrice, - gasPriceNeedsUpdate, - total, - errors, - warnings, - infos, - data, - sending, - gasLimit, - gasPrice, - } = this.props.sendForm; - - const { - onAddressChange, - onAmountChange, - onSetMax, - onCurrencyChange, - onFeeLevelChange, - updateFeeLevels, - onSend, - onGasLimitChange, - onGasPriceChange, - onDataChange, - } = this.props.sendFormActions; - - if (!device || !account || !discovery || !network) return null; - - let isSendButtonDisabled: boolean = Object.keys(errors).length > 0 || total === '0' || amount.length === 0 || address.length === 0 || sending; - let sendButtonText: string = 'Send'; - if (networkSymbol !== currency && amount.length > 0 && !errors.amount) { - sendButtonText += ` ${amount} ${currency.toUpperCase()}`; - } else if (networkSymbol === currency && total !== '0') { - sendButtonText += ` ${total} ${network.symbol}`; - } - - if (!device.connected) { - sendButtonText = 'Device is not connected'; - isSendButtonDisabled = true; - } else if (!device.available) { - sendButtonText = 'Device is unavailable'; - isSendButtonDisabled = true; - } else if (!discovery.completed) { - sendButtonText = 'Loading accounts'; - isSendButtonDisabled = true; - } - - const tokensSelectData = this.getTokensSelectData(tokens, network); - - let gasLimitTooltipCurrency: string; - let gasLimitTooltipValue: string; - if (networkSymbol !== currency) { - gasLimitTooltipCurrency = 'tokens'; - gasLimitTooltipValue = network.defaultGasLimitTokens.toString(10); - } else { - gasLimitTooltipCurrency = networkSymbol; - gasLimitTooltipValue = network.defaultGasLimit.toString(10); - } - - - return ( - - - Send Ethereum or tokens - - onAddressChange(event.target.value)} - /> - - - - onAmountChange(event.target.value)} - bottomText={errors.amount || warnings.amount || infos.amount} - sideAddons={[ - ( - onSetMax()} - isActive={setMax} - > - {!setMax && ( - - )} - {setMax && ( - - )} - Set max - - ), - ( - - ), - ]} - /> - - - - - Fee - {gasPriceNeedsUpdate && ( - - - Recommended fees updated. Click here to use them - - )} - - onAmountChange(event.target.value)} + bottomText={errors.amount || warnings.amount || infos.amount} + sideAddons={[ + ( + onSetMax()} + isActive={setMax} > - {sendButtonText} - - - - )} + {!setMax && ( + + )} + {setMax && ( + + )} + Set max + + ), + ( + + ), + ]} + /> + - {this.props.selectedAccount.pending.length > 0 && ( - + + Fee + {gasPriceNeedsUpdate && ( + + + Recommended fees updated. Click here to use them + + )} + +