diff --git a/src/actions/ripple/BlockchainActions.js b/src/actions/ripple/BlockchainActions.js index 5d73bd8b..ff6f4978 100644 --- a/src/actions/ripple/BlockchainActions.js +++ b/src/actions/ripple/BlockchainActions.js @@ -1,7 +1,7 @@ /* @flow */ import TrezorConnect from 'trezor-connect'; -// import * as BLOCKCHAIN from 'actions/constants/blockchain'; +import * as BLOCKCHAIN from 'actions/constants/blockchain'; import * as PENDING from 'actions/constants/pendingTx'; import * as AccountsActions from 'actions/AccountsActions'; import { toDecimalAmount } from 'utils/formatUtils'; @@ -11,6 +11,9 @@ import type { Dispatch, GetState, PromiseAction, + PayloadAction, + Network, + BlockchainFeeLevel, } from 'flowtype'; const DECIMALS: number = 6; @@ -23,22 +26,40 @@ export const subscribe = (network: string): PromiseAction => async (dispat }); }; +// Get current known fee +// Use default values from appConfig.json if it wasn't downloaded from blockchain yet +// update them later, after onBlockMined event +export const getFeeLevels = (network: Network): PayloadAction> => (dispatch: Dispatch, getState: GetState): Array => { + const blockchain = getState().blockchain.find(b => b.shortcut === network.shortcut); + if (!blockchain || blockchain.feeLevels.length < 1) { + return network.fee.levels.map(level => ({ + name: level.name, + value: level.value, + })); + } + return blockchain.feeLevels; +}; + export const onBlockMined = (network: string): PromiseAction => async (dispatch: Dispatch, getState: GetState): Promise => { const blockchain = getState().blockchain.find(b => b.shortcut === network); - if (!blockchain) return; + if (!blockchain) return; // flowtype fallback - // const fee = await TrezorConnect.blockchainGetFee({ - // coin: network, - // }); - // if (!fee.success) return; + // if last update was more than 5 minutes ago + const now = new Date().getTime(); + if (blockchain.feeTimestamp < now - 300000) { + const feeRequest = await TrezorConnect.blockchainEstimateFee({ + coin: network, + }); + if (feeRequest.success) { + dispatch({ + type: BLOCKCHAIN.UPDATE_FEE, + shortcut: network, + feeLevels: feeRequest.payload, + }); + } + } - // if (fee.payload !== blockchain.fee) { - // dispatch({ - // type: BLOCKCHAIN.UPDATE_FEE, - // shortcut: network, - // fee: fee.payload, - // }); - // } + // TODO: check for blockchain rollbacks here! const accounts: Array = getState().accounts.filter(a => a.network === network); // console.warn('ACCOUNTS', accounts); diff --git a/src/actions/ripple/SendFormActions.js b/src/actions/ripple/SendFormActions.js index 73b70e9d..d737f53c 100644 --- a/src/actions/ripple/SendFormActions.js +++ b/src/actions/ripple/SendFormActions.js @@ -16,9 +16,10 @@ import type { AsyncAction, TrezorDevice, } from 'flowtype'; -import type { State } from 'reducers/SendFormRippleReducer'; +import type { State, FeeLevel } from 'reducers/SendFormRippleReducer'; import * as SessionStorageActions from '../SessionStorageActions'; +import * as BlockchainActions from './BlockchainActions'; import * as ValidationActions from './SendFormValidationActions'; /* @@ -43,14 +44,14 @@ export const observe = (prevState: ReducersState, action: Action): ThunkAction = // handle gasPrice update from backend // recalculate fee levels if needed if (action.type === BLOCKCHAIN.UPDATE_FEE) { - dispatch(ValidationActions.onFeeUpdated(action.shortcut, action.fee)); + dispatch(ValidationActions.onFeeUpdated(action.shortcut, action.feeLevels)); return; } let shouldUpdate: boolean = false; // check if "selectedAccount" reducer changed shouldUpdate = reducerUtils.observeChanges(prevState.selectedAccount, currentState.selectedAccount, { - account: ['balance', 'nonce'], + account: ['balance', 'sequence'], }); // check if "sendForm" reducer changed @@ -79,7 +80,7 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS network, } = getState().selectedAccount; - if (!account || !network) return; + if (!account || account.networkType !== 'ripple' || !network) return; const stateFromStorage = dispatch(SessionStorageActions.loadRippleDraftTransaction()); if (stateFromStorage) { @@ -91,7 +92,8 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS return; } - const feeLevels = dispatch(ValidationActions.getFeeLevels(network.symbol)); + const blockchainFeeLevels = dispatch(BlockchainActions.getFeeLevels(network)); + const feeLevels = dispatch(ValidationActions.getFeeLevels(blockchainFeeLevels)); const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, initialState.selectedFeeLevel); dispatch({ @@ -103,11 +105,20 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS networkSymbol: network.symbol, feeLevels, selectedFeeLevel, + fee: network.fee.defaultFee, sequence: '1', }, }); }; +/* +* Called from UI from "advanced" button +*/ +export const toggleAdvanced = (): Action => ({ + type: SEND.TOGGLE_ADVANCED, + networkType: 'ripple', +}); + /* * Called from UI on "address" field change */ @@ -160,6 +171,78 @@ export const onSetMax = (): ThunkAction => (dispatch: Dispatch, getState: GetSta }); }; +/* +* Called from UI on "fee" selection change +*/ +export const onFeeLevelChange = (feeLevel: FeeLevel): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const state = getState().sendFormRipple; + + const isCustom = feeLevel.value === 'Custom'; + const advanced = isCustom ? true : state.advanced; + + dispatch({ + type: SEND.CHANGE, + networkType: 'ripple', + state: { + ...state, + advanced, + selectedFeeLevel: feeLevel, + fee: isCustom ? state.selectedFeeLevel.fee : feeLevel.fee, + }, + }); +}; + +/* +* 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 blockchainFeeLevels = dispatch(BlockchainActions.getFeeLevels(network)); + const state: State = getState().sendFormRipple; + const feeLevels = dispatch(ValidationActions.getFeeLevels(blockchainFeeLevels, state.selectedFeeLevel)); + const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel); + + dispatch({ + type: SEND.CHANGE, + networkType: 'ripple', + state: { + ...state, + feeLevels, + selectedFeeLevel, + feeNeedsUpdate: false, + }, + }); +}; + +/* +* Called from UI on "advanced / fee" field change +*/ +export const onFeeChange = (fee: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const { network } = getState().selectedAccount; + if (!network) return; + const state: State = getState().sendFormRipple; + + // 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, + networkType: 'ripple', + state: { + ...state, + untouched: false, + touched: { ...state.touched, fee: true }, + selectedFeeLevel: newSelectedFeeLevel, + fee, + }, + }); +}; /* * Called from UI from "send" button @@ -190,7 +273,7 @@ export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: Ge useEmptyPassphrase: selected.useEmptyPassphrase, path: account.accountPath, transaction: { - fee: blockchain.fee, // Fee must be in the range of 10 to 10,000 drops + fee: currentState.selectedFeeLevel.fee, // Fee must be in the range of 10 to 10,000 drops flags: 0x80000000, sequence: account.sequence, payment: { @@ -256,8 +339,12 @@ export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: Ge }; export default { + toggleAdvanced, onAddressChange, onAmountChange, onSetMax, + onFeeLevelChange, + updateFeeLevels, + onFeeChange, onSend, }; \ No newline at end of file diff --git a/src/actions/ripple/SendFormValidationActions.js b/src/actions/ripple/SendFormValidationActions.js index fd473181..12b5524a 100644 --- a/src/actions/ripple/SendFormValidationActions.js +++ b/src/actions/ripple/SendFormValidationActions.js @@ -9,11 +9,13 @@ import type { Dispatch, GetState, PayloadAction, + BlockchainFeeLevel, } from 'flowtype'; import type { State, FeeLevel } from 'reducers/SendFormRippleReducer'; import AddressValidator from 'wallet-address-validator'; // general regular expressions +const ABS_RE = new RegExp('^[0-9]+$'); const NUMBER_RE: RegExp = new RegExp('^(0|0\\.([0-9]+)?|[1-9][0-9]*\\.?([0-9]+)?|\\.[0-9]+)$'); const XRP_6_RE = new RegExp('^(0|0\\.([0-9]{0,6})?|[1-9][0-9]*\\.?([0-9]{0,6})?|\\.[0-9]{0,6})$'); @@ -21,11 +23,10 @@ const XRP_6_RE = new RegExp('^(0|0\\.([0-9]{0,6})?|[1-9][0-9]*\\.?([0-9]{0,6})?| * Called from SendFormActions.observe * Reaction for BLOCKCHAIN.FEE_UPDATED action */ -export const onFeeUpdated = (network: string, fee: string): PayloadAction => (dispatch: Dispatch, getState: GetState): void => { +export const onFeeUpdated = (network: string, feeLevels: Array): PayloadAction => (dispatch: Dispatch, getState: GetState): void => { const state = getState().sendFormRipple; if (network === state.networkSymbol) return; - if (!state.untouched) { // if there is a transaction draft let the user know // and let him update manually @@ -35,24 +36,21 @@ export const onFeeUpdated = (network: string, fee: string): PayloadAction state: { ...state, feeNeedsUpdate: true, - recommendedFee: fee, }, }); return; } - // automatically update feeLevels and gasPrice - const feeLevels = dispatch(getFeeLevels(state.networkSymbol)); - const selectedFeeLevel = getSelectedFeeLevel(feeLevels, state.selectedFeeLevel); + // automatically update feeLevels + const newFeeLevels = dispatch(getFeeLevels(feeLevels)); + const selectedFeeLevel = getSelectedFeeLevel(newFeeLevels, state.selectedFeeLevel); dispatch({ type: SEND.CHANGE, networkType: 'ripple', state: { ...state, feeNeedsUpdate: false, - recommendedFee: fee, - gasPrice: selectedFeeLevel.gasPrice, - feeLevels, + feeLevels: newFeeLevels, selectedFeeLevel, }, }); @@ -70,9 +68,11 @@ export const validation = (): PayloadAction => (dispatch: Dispatch, getSt 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(feeValidation(state)); return state; }; @@ -83,19 +83,27 @@ const recalculateTotalAmount = ($state: State): PayloadAction => (dispatc } = getState().selectedAccount; if (!account) return $state; - const blockchain = getState().blockchain.find(b => b.shortcut === account.network); - if (!blockchain) return $state; - const fee = toDecimalAmount(blockchain.fee, 6); - const state = { ...$state }; if (state.setMax) { const pendingAmount = getPendingAmount(pending, state.networkSymbol, false); - const b = new BigNumber(account.balance).minus(pendingAmount); - state.amount = calculateMaxAmount(b, fee); + const availableBalance = new BigNumber(account.balance).minus(pendingAmount); + state.amount = calculateMaxAmount(availableBalance, state.selectedFeeLevel.fee); } - state.total = calculateTotal(state.amount, fee); + state.total = calculateTotal(state.amount, state.selectedFeeLevel.fee); + return state; +}; + +const updateCustomFeeLabel = ($state: State): PayloadAction => (): State => { + const state = { ...$state }; + if ($state.selectedFeeLevel.value === 'Custom') { + state.selectedFeeLevel = { + ...state.selectedFeeLevel, + fee: state.fee, + label: `${toDecimalAmount(state.fee, 6)} ${state.networkSymbol}`, + }; + } return state; }; @@ -189,6 +197,33 @@ const amountValidation = ($state: State): PayloadAction => (dispatch: Dis return state; }; +/* +* Fee value validation +*/ +export const feeValidation = ($state: State): PayloadAction => (dispatch: Dispatch, getState: GetState): State => { + const state = { ...$state }; + if (!state.touched.fee) return state; + + const { + network, + } = getState().selectedAccount; + if (!network) return state; + + const { fee } = state; + if (fee.length < 1) { + state.errors.fee = 'Fee is not set'; + } else if (fee.length > 0 && !fee.match(ABS_RE)) { + state.errors.fee = 'Fee must be an absolute number'; + } else { + const gl: BigNumber = new BigNumber(fee); + if (gl.lessThan(10)) { + state.errors.fee = 'Fee is below recommended'; + } + } + return state; +}; + + /* * UTILITIES */ @@ -212,36 +247,32 @@ const calculateMaxAmount = (balance: BigNumber, fee: string): string => { } }; -export const getFeeLevels = (symbol: string): PayloadAction> => (dispatch: Dispatch, getState: GetState): Array => { - const blockchain = getState().blockchain.find(b => b.shortcut === symbol.toLowerCase()); - if (!blockchain) { - // return default fee levels (TODO: get them from config) - return [{ - value: 'Normal', - gasPrice: '0.000012', - label: `0.000012 ${symbol}`, - }]; - } +// Generate FeeLevel dataset for "fee" select +export const getFeeLevels = (feeLevels: Array, selected?: FeeLevel): PayloadAction> => (dispatch: Dispatch, getState: GetState): Array => { + const { network } = getState().selectedAccount; + if (!network) return []; // flowtype fallback - const xrpDrops = toDecimalAmount(blockchain.fee, 6); + // map BlockchainFeeLevel to SendFormReducer FeeLevel + const levels = feeLevels.map(level => ({ + value: level.name, + fee: level.value, + label: `${toDecimalAmount(level.value, network.decimals)} ${network.symbol}`, + })); - // TODO: calc fee levels - return [{ - value: 'Normal', - gasPrice: xrpDrops, - label: `${xrpDrops} ${symbol}`, - }]; + // add "Custom" level + const customLevel = selected && selected.value === 'Custom' ? { + value: 'Custom', + fee: selected.fee, + label: `${toDecimalAmount(selected.fee, network.decimals)} ${network.symbol}`, + } : { + value: 'Custom', + fee: '0', + label: '', + }; + + return levels.concat([customLevel]); }; -// export const getFeeLevels = (shortcut: string): Array => ([ -// { -// value: 'Normal', -// gasPrice: '1', -// label: `1 ${shortcut}`, -// }, -// ]); - - export const getSelectedFeeLevel = (feeLevels: Array, selected: FeeLevel): FeeLevel => { const { value } = selected; let selectedFeeLevel: ?FeeLevel; diff --git a/src/reducers/SendFormRippleReducer.js b/src/reducers/SendFormRippleReducer.js index f340936c..eaedcfa1 100644 --- a/src/reducers/SendFormRippleReducer.js +++ b/src/reducers/SendFormRippleReducer.js @@ -7,7 +7,7 @@ import type { Action } from 'flowtype'; export type FeeLevel = { label: string; - gasPrice: string; + fee: string; value: string; } @@ -24,7 +24,7 @@ export type State = { setMax: boolean; feeLevels: Array; selectedFeeLevel: FeeLevel; - recommendedFee: string; + fee: string; feeNeedsUpdate: boolean; sequence: string; total: string; @@ -49,11 +49,11 @@ export const initialState: State = { setMax: false, feeLevels: [], selectedFeeLevel: { - label: 'Normal', - gasPrice: '0', value: 'Normal', + label: '', + fee: '0', }, - recommendedFee: '0', + fee: '0', feeNeedsUpdate: false, sequence: '0', total: '0',