/* @flow */ import React from 'react'; import { FormattedMessage } from 'react-intl'; import { Link } from 'trezor-ui-components'; 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 { initialState } from 'reducers/SendFormEthereumReducer'; import * as reducerUtils from 'reducers/utils'; import * as ethUtils from 'utils/ethUtils'; import { toFiatCurrency, fromFiatCurrency } from 'utils/fiatConverter'; import { debounce } from 'utils/common'; import type { Dispatch, GetState, State as ReducersState, Action, ThunkAction, AsyncAction, TrezorDevice, } from 'flowtype'; import type { State, FeeLevel } from 'reducers/SendFormEthereumReducer'; import l10nMessages from 'components/notifications/Context/actions.messages'; import * as SessionStorageActions from '../SessionStorageActions'; import { prepareEthereumTx, serializeEthereumTx } from '../TxActions'; import * as BlockchainActions from './BlockchainActions'; import * as ValidationActions from './SendFormValidationActions'; // list of all actions which has influence on "sendFormEthereum" reducer // other actions will be ignored const actions = [ ACCOUNT.UPDATE_SELECTED_ACCOUNT, WEB3.GAS_PRICE_UPDATED, ...Object.values(SEND).filter(v => typeof v === 'string'), ]; const debouncedValidation = debounce((dispatch: Dispatch) => { const validated = dispatch(ValidationActions.validation()); dispatch({ type: SEND.VALIDATION, networkType: 'ethereum', state: validated, }); }, 300); /* * 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.sendFormEthereum.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', 'tokens'], } ); if ( shouldUpdate && currentState.sendFormEthereum.currency !== currentState.sendFormEthereum.networkSymbol ) { // make sure that this token is added into account const { account, tokens } = getState().selectedAccount; if (!account) return; const token = reducerUtils.findToken( tokens, account.descriptor, currentState.sendFormEthereum.currency, account.deviceState ); if (!token) { // token not found, re-init form dispatch(init()); return; } } // check if "sendFormEthereum" reducer changed if (!shouldUpdate) { shouldUpdate = reducerUtils.observeChanges( prevState.sendFormEthereum, currentState.sendFormEthereum ); } if (shouldUpdate) { debouncedValidation(dispatch); } }; /* * Called from "observe" action * Initialize "sendFormEthereum" 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.loadEthereumDraftTransaction()); if (stateFromStorage) { // TODO: consider if current gasPrice should be set here as "recommendedGasPrice" dispatch({ type: SEND.INIT, networkType: 'ethereum', state: stateFromStorage, }); if (stateFromStorage.domainResolving) { dispatch(onDomainChange(stateFromStorage.address)); } return; } const gasPrice: BigNumber = await dispatch( BlockchainActions.getGasPrice(network.shortcut, network.defaultGasPrice) ); const gasLimit = network.defaultGasLimit.toString(); const feeLevels = ValidationActions.getFeeLevels(network.symbol, gasPrice, gasLimit); const selectedFeeLevel = ValidationActions.getSelectedFeeLevel( feeLevels, initialState.selectedFeeLevel ); // initial local currency is set according to wallet settings const { localCurrency } = getState().wallet; dispatch({ type: SEND.INIT, networkType: 'ethereum', state: { ...initialState, networkName: network.shortcut, networkSymbol: network.symbol, currency: network.symbol, localCurrency, feeLevels, selectedFeeLevel, recommendedGasPrice: gasPrice.toString(), gasLimit, gasPrice: gasPrice.toString(), }, }); }; /* * Called from UI from "advanced" button */ export const toggleAdvanced = (): Action => ({ type: SEND.TOGGLE_ADVANCED, networkType: 'ethereum', }); /* * Called from UI from "clear" button */ export const onClear = (): AsyncAction => async ( dispatch: Dispatch, getState: GetState ): Promise => { const { network } = getState().selectedAccount; const { advanced } = getState().sendFormEthereum; if (!network) return; // clear transaction draft from session storage dispatch(SessionStorageActions.clear()); const gasPrice: BigNumber = await dispatch( BlockchainActions.getGasPrice(network.shortcut, network.defaultGasPrice) ); const gasLimit = network.defaultGasLimit.toString(); const feeLevels = ValidationActions.getFeeLevels(network.symbol, gasPrice, gasLimit); const selectedFeeLevel = ValidationActions.getSelectedFeeLevel( feeLevels, initialState.selectedFeeLevel ); // initial local currency is set according to wallet settings const { localCurrency } = getState().wallet; dispatch({ type: SEND.CLEAR, networkType: 'ethereum', state: { ...initialState, networkName: network.shortcut, networkSymbol: network.symbol, currency: network.symbol, localCurrency, feeLevels, selectedFeeLevel, recommendedGasPrice: gasPrice.toString(), gasLimit, gasPrice: gasPrice.toString(), advanced, }, }); }; export const onDomainChange = (domain: string): AsyncAction => async ( dispatch: Dispatch, getState: GetState ): Promise => { const state = getState().sendFormEthereum; dispatch({ type: SEND.DOMAIN_RESOLVING, networkType: 'ethereum', state: { ...state, address: domain, resolvedDomain: '', domainResolving: true, }, }); const address = await dispatch(BlockchainActions.resolveDomain(domain, 'ETH')); dispatch({ type: SEND.DOMAIN_COMPLETE, networkType: 'ethereum', state: { ...state, untouched: false, touched: { ...state.touched, address: true }, address: address || domain, resolvedDomain: address ? domain : '', domainResolving: false, }, }); }; /* * Called from UI on "address" field change */ export const onAddressChange = (address: string): ThunkAction => ( dispatch: Dispatch, getState: GetState ): void => { const state: State = getState().sendFormEthereum; if (state.domainResolving) { return; } if (address.endsWith('.crypto')) { dispatch(onDomainChange(address)); } else { dispatch({ type: SEND.CHANGE, networkType: 'ethereum', state: { ...state, untouched: false, touched: { ...state.touched, address: true }, address, domainResolving: false, }, }); } }; /* * Called from UI on "amount" field change */ export const onAmountChange = ( amount: string, shouldUpdateLocalAmount: boolean = true ): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { const state = getState().sendFormEthereum; dispatch({ type: SEND.CHANGE, networkType: 'ethereum', state: { ...state, untouched: false, touched: { ...state.touched, amount: true }, setMax: false, amount, }, }); if (shouldUpdateLocalAmount) { const { localCurrency } = getState().sendFormEthereum; const fiatRates = getState().fiat.find(f => f.network === state.currency.toLowerCase()); const localAmount = toFiatCurrency(amount, localCurrency, fiatRates); dispatch(onLocalAmountChange(localAmount, false)); } }; /* * Called from UI on "localAmount" field change */ export const onLocalAmountChange = ( localAmount: string, shouldUpdateAmount: boolean = true ): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { const state = getState().sendFormEthereum; const { localCurrency } = getState().sendFormEthereum; const { network } = getState().selectedAccount; // updates localAmount dispatch({ type: SEND.CHANGE, networkType: 'ethereum', state: { ...state, untouched: false, touched: { ...state.touched, localAmount: true }, setMax: false, localAmount, }, }); // updates amount if (shouldUpdateAmount) { if (!network) return; // converts amount in local currency to crypto currency that will be sent const fiatRates = getState().fiat.find(f => f.network === state.currency.toLowerCase()); const amount = fromFiatCurrency(localAmount, localCurrency, fiatRates, network.decimals); dispatch(onAmountChange(amount, false)); } }; /* * Called from UI on "localCurrency" selection change */ export const onLocalCurrencyChange = (localCurrency: { value: string, label: string, }): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { const state = getState().sendFormEthereum; dispatch({ type: SEND.CHANGE, networkType: 'ethereum', state: { ...state, localCurrency: localCurrency.value, }, }); // Recalculates local amount with new currency rates dispatch(onAmountChange(state.amount, true)); }; /* * 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().sendFormEthereum; 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, networkType: 'ethereum', state: { ...state, currency: currency.value, feeLevels, selectedFeeLevel, gasLimit, }, }); // Recalculates local amount with new currency rates dispatch(onAmountChange(state.amount, true)); }; /* * Called from UI from "set max" button */ export const onSetMax = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { const state = getState().sendFormEthereum; dispatch({ type: SEND.CHANGE, networkType: 'ethereum', 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().sendFormEthereum; 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, networkType: 'ethereum', 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().sendFormEthereum; const feeLevels = ValidationActions.getFeeLevels( network.symbol, state.recommendedGasPrice, state.gasLimit, state.selectedFeeLevel ); const selectedFeeLevel = ValidationActions.getSelectedFeeLevel( feeLevels, state.selectedFeeLevel ); dispatch({ type: SEND.CHANGE, networkType: 'ethereum', 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().sendFormEthereum; // switch to custom fee level let newSelectedFeeLevel; if (state.selectedFeeLevel.value !== 'Custom') newSelectedFeeLevel = state.feeLevels.find(f => f.value === 'Custom'); dispatch({ type: SEND.CHANGE, networkType: 'ethereum', state: { ...state, untouched: false, touched: { ...state.touched, gasPrice: true }, gasPrice, selectedFeeLevel: newSelectedFeeLevel || state.selectedFeeLevel, }, }); }; /* * 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().sendFormEthereum; // 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, networkType: 'ethereum', 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().sendFormEthereum; dispatch({ type: SEND.CHANGE, networkType: 'ethereum', 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().sendFormEthereum; dispatch({ type: SEND.CHANGE, networkType: 'ethereum', state: { ...state, calculatingGasLimit: true, untouched: false, touched: { ...state.touched, data: true }, data, }, }); dispatch(estimateGasPrice()); }; export const setDefaultGasLimit = (): ThunkAction => ( dispatch: Dispatch, getState: GetState ): void => { const state: State = getState().sendFormEthereum; const { network } = getState().selectedAccount; if (!network) return; const isToken = state.currency !== state.networkSymbol; const gasLimit = isToken ? network.defaultGasLimitTokens.toString() : network.defaultGasLimit.toString(); dispatch({ type: SEND.CHANGE, networkType: 'ethereum', state: { ...state, calculatingGasLimit: false, untouched: false, touched: { ...state.touched, gasLimit: false }, gasLimit, }, }); }; /* * 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().sendFormEthereum; 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().sendFormEthereum.data === requestedData) { dispatch(onGasLimitChange(gasLimit)); } }; /* * Called from UI from "send" button */ export const onSend = (): AsyncAction => async ( dispatch: Dispatch, getState: GetState ): Promise => { const { account, network, pending } = getState().selectedAccount; if (!account || account.networkType !== 'ethereum' || !network) return; const currentState: State = getState().sendFormEthereum; const isToken = currentState.currency !== currentState.networkSymbol; const pendingNonce = new BigNumber(reducerUtils.getPendingSequence(pending)); const nonce = pendingNonce.gt(0) && pendingNonce.gt(account.nonce) ? pendingNonce.toString() : account.nonce; const txData = await dispatch( prepareEthereumTx({ network: network.shortcut, token: isToken ? reducerUtils.findToken( getState().tokens, account.descriptor, currentState.currency, account.deviceState ) : null, from: account.descriptor, 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.accountPath, transaction: txData, }); if (!signedTransaction || !signedTransaction.success) { dispatch({ type: NOTIFICATION.ADD, payload: { variant: 'error', title: , message: signedTransaction.payload.error, cancelable: true, actions: [], }, }); return; } try { const serializedTx: string = await dispatch( serializeEthereumTx({ ...txData, r: signedTransaction.payload.r, s: signedTransaction.payload.s, v: signedTransaction.payload.v, }) ); 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 }); // clear session storage dispatch(SessionStorageActions.clear()); // reset form dispatch(init()); dispatch({ type: NOTIFICATION.ADD, payload: { variant: 'success', title: , message: ( ), cancelable: true, actions: [], }, }); } catch (error) { console.error(error); dispatch({ type: NOTIFICATION.ADD, payload: { variant: 'error', title: , message: error.message || error, cancelable: true, actions: [], }, }); } }; export default { toggleAdvanced, onAddressChange, onAmountChange, onLocalAmountChange, onCurrencyChange, onLocalCurrencyChange, onSetMax, onFeeLevelChange, updateFeeLevels, onGasPriceChange, onGasLimitChange, setDefaultGasLimit, onNonceChange, onDataChange, onSend, onClear, };