diff --git a/src/actions/TxActions.js b/src/actions/TxActions.js index e3d35cd5..ca9d3560 100644 --- a/src/actions/TxActions.js +++ b/src/actions/TxActions.js @@ -12,7 +12,7 @@ import type { PromiseAction, } from 'flowtype'; -import type { EthereumTransaction } from 'trezor-connect'; +import type { EthereumTransaction, RippleTransaction } from 'trezor-connect'; import type { Token } from 'reducers/TokensReducer'; type EthereumTxRequest = { @@ -60,4 +60,27 @@ export const prepareEthereumTx = (tx: EthereumTxRequest): PromiseAction => async (): Promise => { const ethTx = new EthereumjsTx(tx); return `0x${ethTx.serialize().toString('hex')}`; -}; \ No newline at end of file +}; + +// type RippleTxRequest = { +// network: string; +// from: string; +// to: string; +// amount: string; +// data: string; +// gasLimit: string; +// gasPrice: string; +// nonce: number; +// } + +// export const prepareRippleTx = (tx: RippleTxRequest): PromiseAction => async (dispatch: Dispatch): Promise => { +// // TODO: fetch account sequence +// return { +// fee: string, +// flags?: number, +// sequence?: number, +// maxLedgerVersion?: number, // Proto: "last_ledger_sequence" +// payment: Payment, +// } + +// }; \ No newline at end of file diff --git a/src/actions/ripple/RippleSendFormActions.js b/src/actions/ripple/RippleSendFormActions.js new file mode 100644 index 00000000..839c2453 --- /dev/null +++ b/src/actions/ripple/RippleSendFormActions.js @@ -0,0 +1,670 @@ +/* @flow */ +import React from 'react'; +import Link from 'components/Link'; +import TrezorConnect from 'trezor-connect'; +import BigNumber from 'bignumber.js'; +import * as ACCOUNT from 'actions/constants/account'; +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 * as reducerUtils from 'reducers/utils'; +import * as ethUtils from 'utils/ethUtils'; + +import type { + Dispatch, + GetState, + State as ReducersState, + Action, + ThunkAction, + AsyncAction, + TrezorDevice, +} from 'flowtype'; +import type { State, FeeLevel } from 'reducers/SendFormReducer'; +import type { Account } from 'reducers/AccountsReducer'; +import * as SessionStorageActions from '../SessionStorageActions'; +import { prepareEthereumTx, serializeEthereumTx } from '../TxActions'; +import * as BlockchainActions from '../BlockchainActions'; + +export type SendTxAction = { + type: typeof SEND.TX_COMPLETE, + account: Account, + selectedCurrency: string, + amount: string, + total: string, + tx: any, + nonce: number, + txid: string, + txData: any, +}; + +export type SendFormAction = { + type: typeof SEND.INIT | typeof SEND.VALIDATION | typeof SEND.CHANGE, + state: State, +} | { + type: typeof SEND.TOGGLE_ADVANCED | typeof SEND.TX_SENDING | typeof SEND.TX_ERROR, +} | SendTxAction; + +// list of all actions which has influence on "sendForm" reducer +// other actions will be ignored +const actions = [ + ACCOUNT.UPDATE_SELECTED_ACCOUNT, + WEB3.GAS_PRICE_UPDATED, + ...Object.values(SEND).filter(v => typeof v === 'string'), +]; + +/* +* Called from WalletService +*/ +export const observe = (prevState: ReducersState, action: Action): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + // ignore not listed actions + if (actions.indexOf(action.type) < 0) return; + + const currentState = getState(); + // do not proceed if it's not "send" url + if (!currentState.router.location.state.send) return; + + // 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 + shouldUpdate = reducerUtils.observeChanges(prevState.selectedAccount, currentState.selectedAccount, { + account: ['balance', 'nonce'], + }); + + // 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, + }); + } +}; + +/* +* 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, + network, + } = getState().selectedAccount; + + if (!account || !network) return; + + const stateFromStorage = dispatch(SessionStorageActions.loadDraftTransaction()); + if (stateFromStorage) { + // TODO: consider if current gasPrice should be set here as "recommendedGasPrice" + dispatch({ + type: SEND.INIT, + state: stateFromStorage, + }); + return; + } + + if (network.type === 'ethereum') { + const gasPrice: BigNumber = network.type === 'ethereum' ? await dispatch(BlockchainActions.getGasPrice(network.shortcut, network.defaultGasPrice)) : new BigNumber(0); + 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: { + ...initialState, + networkName: network.shortcut, + networkSymbol: network.symbol, + currency: network.symbol, + feeLevels, + selectedFeeLevel, + recommendedGasPrice: gasPrice.toString(), + gasLimit, + gasPrice: gasPrice.toString(), + }, + }); + } else { + const feeLevels = ValidationActions.getFeeLevels(network.symbol, '1', '1'); + const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, initialState.selectedFeeLevel); + dispatch({ + type: SEND.INIT, + state: { + ...initialState, + networkName: network.shortcut, + networkSymbol: network.symbol, + currency: network.symbol, + feeLevels, + selectedFeeLevel, + recommendedGasPrice: '1', + gasLimit: '1', + gasPrice: '1', + }, + }); + } +}; + +/* +* Called from UI from "advanced" button +*/ +export const toggleAdvanced = (): Action => ({ + type: SEND.TOGGLE_ADVANCED, +}); + +/* +* Called from UI on "address" field change +*/ +export const onAddressChange = (address: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const state: State = getState().sendForm; + dispatch({ + type: SEND.CHANGE, + state: { + ...state, + untouched: false, + touched: { ...state.touched, address: true }, + address, + }, + }); +}; + +/* +* Called from UI on "amount" field change +*/ +export const onAmountChange = (amount: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const state = getState().sendForm; + dispatch({ + type: SEND.CHANGE, + state: { + ...state, + untouched: false, + 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, + network, + } = getState().selectedAccount; + if (!account || !network) return; + + const state = getState().sendForm; + + const isToken = currency.value !== state.networkSymbol; + const gasLimit = isToken ? network.defaultGasLimitTokens.toString() : network.defaultGasLimit.toString(); + + const feeLevels = ValidationActions.getFeeLevels(network.symbol, state.recommendedGasPrice, gasLimit, state.selectedFeeLevel); + const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel); + + dispatch({ + 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; + dispatch({ + type: SEND.CHANGE, + state: { + ...state, + untouched: false, + 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 state = getState().sendForm; + + const isCustom = feeLevel.value === 'Custom'; + let newGasLimit = state.gasLimit; + let newGasPrice = state.gasPrice; + const advanced = isCustom ? true : state.advanced; + + 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) { + newGasLimit = network.defaultGasLimitTokens.toString(); + } else { + // 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.CHANGE, + state: { + ...state, + advanced, + selectedFeeLevel: feeLevel, + gasLimit: newGasLimit, + gasPrice: newGasPrice, + }, + }); +}; + +/* +* Called from UI from "update recommended fees" button +*/ +export const updateFeeLevels = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const { + account, + network, + } = getState().selectedAccount; + if (!account || !network) return; + + const state: State = getState().sendForm; + const feeLevels = ValidationActions.getFeeLevels(network.symbol, state.recommendedGasPrice, state.gasLimit, state.selectedFeeLevel); + const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel); + + 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; + if (!ethUtils.isHex(requestedData)) { + // stop "calculatingGasLimit" process + dispatch(onGasLimitChange(requestedData.length > 0 ? state.gasLimit : network.defaultGasLimit.toString())); + return; + } + + if (state.data.length < 1) { + // set default + dispatch(onGasLimitChange(network.defaultGasLimit.toString())); + return; + } + + const gasLimit = await dispatch(BlockchainActions.estimateGasLimit(network.shortcut, 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)); + } +}; + +/* +* Called from UI from "send" button +*/ + +export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const { + account, + network, + } = getState().selectedAccount; + + const selected: ?TrezorDevice = getState().wallet.selectedDevice; + if (!selected) return; + + if (!account || !network) return; + + const currentState: State = getState().sendForm; + + const signedTransaction = await TrezorConnect.rippleSignTransaction({ + device: { + path: selected.path, + instance: selected.instance, + state: selected.state, + }, + useEmptyPassphrase: selected.useEmptyPassphrase, + path: account.addressPath, + transaction: { + fee: '100000', + flags: 0x80000000, + sequence: account.nonce, + payment: { + amount: currentState.amount, + destination: currentState.address, + }, + }, + }); + + if (!signedTransaction || !signedTransaction.success) { + dispatch({ + type: NOTIFICATION.ADD, + payload: { + type: 'error', + title: 'Transaction error', + message: signedTransaction.payload.error, + cancelable: true, + actions: [], + }, + }); + return; + } + + const push = await TrezorConnect.pushTransaction({ + tx: signedTransaction.payload.serializedTx, + coin: network.shortcut, + }); + + if (!push.success) { + dispatch({ + type: NOTIFICATION.ADD, + payload: { + type: 'error', + title: 'Transaction error', + message: push.payload.error, + cancelable: true, + actions: [], + }, + }); + return; + } + + const { txid } = push.payload; + + dispatch({ + type: SEND.TX_COMPLETE, + account, + selectedCurrency: currentState.currency, + amount: currentState.amount, + total: currentState.total, + tx: {}, + nonce: account.nonce, + txid, + txData: {}, + }); + + // clear session storage + dispatch(SessionStorageActions.clear()); + + // reset form + dispatch(init()); + + dispatch({ + type: NOTIFICATION.ADD, + payload: { + type: 'success', + title: 'Transaction success', + message: See transaction detail, + cancelable: true, + actions: [], + }, + }); +}; +export const onSend1 = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { + const { + account, + network, + pending, + } = getState().selectedAccount; + + if (!account || !network) return; + + const currentState: State = getState().sendForm; + + const isToken: boolean = currentState.currency !== currentState.networkSymbol; + const pendingNonce: number = reducerUtils.getPendingNonce(pending); + const nonce = pendingNonce > 0 && pendingNonce >= account.nonce ? pendingNonce : account.nonce; + + const txData = await dispatch(prepareEthereumTx({ + network: network.shortcut, + token: isToken ? findToken(getState().tokens, account.address, currentState.currency, account.deviceState) : null, + from: account.address, + to: currentState.address, + amount: currentState.amount, + data: currentState.data, + gasLimit: currentState.gasLimit, + gasPrice: currentState.gasPrice, + nonce, + })); + + const selected: ?TrezorDevice = getState().wallet.selectedDevice; + if (!selected) return; + + const signedTransaction = await TrezorConnect.ethereumSignTransaction({ + device: { + path: selected.path, + instance: selected.instance, + state: selected.state, + }, + // useEmptyPassphrase: !selected.instance, + useEmptyPassphrase: selected.useEmptyPassphrase, + path: account.addressPath, + transaction: txData, + }); + + if (!signedTransaction || !signedTransaction.success) { + dispatch({ + type: NOTIFICATION.ADD, + payload: { + type: 'error', + title: 'Transaction error', + message: signedTransaction.payload.error, + cancelable: true, + actions: [], + }, + }); + return; + } + + txData.r = signedTransaction.payload.r; + txData.s = signedTransaction.payload.s; + txData.v = signedTransaction.payload.v; + + try { + const serializedTx: string = await dispatch(serializeEthereumTx(txData)); + const push = await TrezorConnect.pushTransaction({ + tx: serializedTx, + coin: network.shortcut, + }); + + if (!push.success) { + throw new Error(push.payload.error); + } + + const { txid } = push.payload; + + dispatch({ + type: SEND.TX_COMPLETE, + account, + selectedCurrency: currentState.currency, + amount: currentState.amount, + total: currentState.total, + tx: txData, + nonce, + txid, + txData, + }); + + // clear session storage + dispatch(SessionStorageActions.clear()); + + // reset form + dispatch(init()); + + dispatch({ + type: NOTIFICATION.ADD, + payload: { + type: 'success', + title: 'Transaction success', + message: See transaction detail, + cancelable: true, + actions: [], + }, + }); + } catch (error) { + dispatch({ + type: NOTIFICATION.ADD, + payload: { + type: 'error', + title: 'Transaction error', + message: error.message || error, + cancelable: true, + actions: [], + }, + }); + } +}; + +export default { + toggleAdvanced, + onAddressChange, + onAmountChange, + onCurrencyChange, + onSetMax, + onFeeLevelChange, + updateFeeLevels, + onGasPriceChange, + onGasLimitChange, + onNonceChange, + onDataChange, + onSend, +}; \ No newline at end of file diff --git a/src/actions/ripple/RippleSendFormValidationActions.js b/src/actions/ripple/RippleSendFormValidationActions.js new file mode 100644 index 00000000..2c39bb31 --- /dev/null +++ b/src/actions/ripple/RippleSendFormValidationActions.js @@ -0,0 +1,419 @@ +/* @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 * as ethUtils from 'utils/ethUtils'; + +import type { + Dispatch, + GetState, + PayloadAction, +} from 'flowtype'; +import type { State, FeeLevel } from 'reducers/SendFormReducer'; + +// general regular expressions +const RIPPLE_ADDRESS_RE = new RegExp('^r[1-9A-HJ-NP-Za-km-z]{25,34}$'); +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 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(recalculateTotalAmount(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 recalculateTotalAmount = ($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 => (dispatch: Dispatch, getState: GetState): State => { + const state = { ...$state }; + if (!state.touched.address) return state; + + const { network } = getState().selectedAccount; + if (!network) return state; + + const { address } = state; + + if (address.length < 1) { + state.errors.address = 'Address is not set'; + } else if (network.type === 'ethereum' && !EthereumjsUtil.isValidAddress(address)) { + state.errors.address = 'Address is not valid'; + } else if (network.type === 'ethereum' && address.match(UPPERCASE_RE) && !EthereumjsUtil.isValidChecksumAddress(address)) { + state.errors.address = 'Address is not a valid checksum'; + } else if (network.type === 'ripple' && !address.match(RIPPLE_ADDRESS_RE)) { + state.errors.address = 'Address is not valid'; + } + 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.shortcut); + 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 { networks } = getState().localStorage.config; + const otherNetwork = networks.find(c => c.shortcut === 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 (!ethUtils.isHex(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/reducers/RippleSendFormReducer.js b/src/reducers/RippleSendFormReducer.js new file mode 100644 index 00000000..2426cf62 --- /dev/null +++ b/src/reducers/RippleSendFormReducer.js @@ -0,0 +1,97 @@ +/* @flow */ + +import * as SEND from 'actions/constants/send'; +import * as ACCOUNT from 'actions/constants/account'; + +import type { Action } from 'flowtype'; + +export type FeeLevel = { + label: string; + gasPrice: string; + value: string; +} + +export type State = { + +networkName: string; + +networkSymbol: string; + + // form fields + advanced: boolean; + untouched: boolean; // set to true when user made any changes in form + touched: {[k: string]: boolean}; + address: string; + amount: string; + setMax: boolean; + feeLevels: Array; + selectedFeeLevel: FeeLevel; + sequence: string; + total: string; + + errors: {[k: string]: string}; + warnings: {[k: string]: string}; + infos: {[k: string]: string}; + + sending: boolean; +} + + +export const initialState: State = { + networkName: '', + networkSymbol: '', + currency: '', + + advanced: false, + untouched: true, + touched: {}, + address: '', + //address: '0x574BbB36871bA6b78E27f4B4dCFb76eA0091880B', + amount: '', + setMax: false, + feeLevels: [], + selectedFeeLevel: { + label: 'Normal', + gasPrice: '0', + value: 'Normal', + }, + sequence: '0', + total: '0', + sending: false, + errors: {}, + warnings: {}, + infos: {}, +}; + +export default (state: State = initialState, action: Action): State => { + switch (action.type) { + case SEND.INIT: + case SEND.CHANGE: + case SEND.VALIDATION: + // return action.state; + return state; + + case ACCOUNT.DISPOSE: + return initialState; + + + case SEND.TOGGLE_ADVANCED: + return { + ...state, + advanced: !state.advanced, + }; + + case SEND.TX_SENDING: + return { + ...state, + sending: true, + }; + + case SEND.TX_ERROR: + return { + ...state, + sending: false, + }; + + default: + return state; + } +}; \ No newline at end of file diff --git a/src/reducers/index.js b/src/reducers/index.js index a6406053..3225fc15 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -11,6 +11,7 @@ import web3 from 'reducers/Web3Reducer'; import accounts from 'reducers/AccountsReducer'; import selectedAccount from 'reducers/SelectedAccountReducer'; import sendForm from 'reducers/SendFormReducer'; +import rippleSendForm from 'reducers/RippleSendFormReducer'; import receive from 'reducers/ReceiveReducer'; import summary from 'reducers/SummaryReducer'; import tokens from 'reducers/TokensReducer'; @@ -33,6 +34,7 @@ const reducers = { accounts, selectedAccount, sendForm, + rippleSendForm, receive, summary, tokens,