import { createSlice } from '@reduxjs/toolkit' import BigNumber from 'bignumber.js' import log from 'loglevel' import { getStorageItem, setStorageItem } from '../../../lib/storage-helpers' import { addToken, addUnapprovedTransaction, fetchAndSetQuotes, forceUpdateMetamaskState, resetSwapsPostFetchState, setBackgroundSwapRouteState, setInitialGasEstimate, setSwapsErrorKey, setSwapsTxGasPrice, setApproveTxId, setTradeTxId, stopPollingForQuotes, updateAndApproveTx, updateTransaction, resetBackgroundSwapsState, setSwapsLiveness, setSelectedQuoteAggId, setSwapsTxGasLimit, } from '../../store/actions' import { AWAITING_SWAP_ROUTE, BUILD_QUOTE_ROUTE, LOADING_QUOTES_ROUTE, SWAPS_ERROR_ROUTE, SWAPS_MAINTENANCE_ROUTE, } from '../../helpers/constants/routes' import { fetchSwapsFeatureLiveness, fetchSwapsGasPrices, } from '../../pages/swaps/swaps.util' import { calcGasTotal } from '../../pages/send/send.utils' import { decimalToHex, getValueFromWeiHex, hexMax, decGWEIToHexWEI, hexToDecimal, hexWEIToDecGWEI, } from '../../helpers/utils/conversions.util' import { conversionLessThan } from '../../helpers/utils/conversion-util' import { calcTokenAmount } from '../../helpers/utils/token-util' import { getSelectedAccount, getTokenExchangeRates, getUSDConversionRate, } from '../../selectors' import { ERROR_FETCHING_QUOTES, QUOTES_NOT_AVAILABLE_ERROR, ETH_SWAPS_TOKEN_OBJECT, SWAP_FAILED_ERROR, SWAPS_FETCH_ORDER_CONFLICT, } from '../../helpers/constants/swaps' import { TRANSACTION_CATEGORIES } from '../../../../shared/constants/transaction' const GAS_PRICES_LOADING_STATES = { INITIAL: 'INITIAL', LOADING: 'LOADING', FAILED: 'FAILED', COMPLETED: 'COMPLETED', } const initialState = { aggregatorMetadata: null, approveTxId: null, balanceError: false, fetchingQuotes: false, fromToken: null, quotesFetchStartTime: null, topAssets: {}, toToken: null, customGas: { price: null, limit: null, loading: GAS_PRICES_LOADING_STATES.INITIAL, priceEstimates: {}, priceEstimatesLastRetrieved: 0, fallBackPrice: null, }, } const slice = createSlice({ name: 'swaps', initialState, reducers: { clearSwapsState: () => initialState, navigatedBackToBuildQuote: (state) => { state.approveTxId = null state.balanceError = false state.fetchingQuotes = false state.customGas.limit = null state.customGas.price = null }, retriedGetQuotes: (state) => { state.approveTxId = null state.balanceError = false state.fetchingQuotes = false }, setAggregatorMetadata: (state, action) => { state.aggregatorMetadata = action.payload }, setBalanceError: (state, action) => { state.balanceError = action.payload }, setFetchingQuotes: (state, action) => { state.fetchingQuotes = action.payload }, setFromToken: (state, action) => { state.fromToken = action.payload }, setQuotesFetchStartTime: (state, action) => { state.quotesFetchStartTime = action.payload }, setTopAssets: (state, action) => { state.topAssets = action.payload }, setToToken: (state, action) => { state.toToken = action.payload }, swapCustomGasModalClosed: (state) => { state.customGas.price = null state.customGas.limit = null }, swapCustomGasModalPriceEdited: (state, action) => { state.customGas.price = action.payload }, swapCustomGasModalLimitEdited: (state, action) => { state.customGas.limit = action.payload }, swapGasPriceEstimatesFetchStarted: (state) => { state.customGas.loading = GAS_PRICES_LOADING_STATES.LOADING }, swapGasPriceEstimatesFetchFailed: (state) => { state.customGas.loading = GAS_PRICES_LOADING_STATES.FAILED }, swapGasPriceEstimatesFetchCompleted: (state, action) => { state.customGas.priceEstimates = action.payload.priceEstimates state.customGas.loading = GAS_PRICES_LOADING_STATES.COMPLETED state.customGas.priceEstimatesLastRetrieved = action.payload.priceEstimatesLastRetrieved }, retrievedFallbackSwapsGasPrice: (state, action) => { state.customGas.fallBackPrice = action.payload }, }, }) const { actions, reducer } = slice export default reducer // Selectors export const getAggregatorMetadata = (state) => state.swaps.aggregatorMetadata export const getBalanceError = (state) => state.swaps.balanceError export const getFromToken = (state) => state.swaps.fromToken export const getTopAssets = (state) => state.swaps.topAssets export const getToToken = (state) => state.swaps.toToken export const getFetchingQuotes = (state) => state.swaps.fetchingQuotes export const getQuotesFetchStartTime = (state) => state.swaps.quotesFetchStartTime export const getSwapsCustomizationModalPrice = (state) => state.swaps.customGas.price export const getSwapsCustomizationModalLimit = (state) => state.swaps.customGas.limit export const swapGasPriceEstimateIsLoading = (state) => state.swaps.customGas.loading === GAS_PRICES_LOADING_STATES.LOADING export const swapGasEstimateLoadingHasFailed = (state) => state.swaps.customGas.loading === GAS_PRICES_LOADING_STATES.INITIAL export const getSwapGasPriceEstimateData = (state) => state.swaps.customGas.priceEstimates export const getSwapsPriceEstimatesLastRetrieved = (state) => state.swaps.customGas.priceEstimatesLastRetrieved export const getSwapsFallbackGasPrice = (state) => state.swaps.customGas.fallBackPrice export function shouldShowCustomPriceTooLowWarning(state) { const { average } = getSwapGasPriceEstimateData(state) const customGasPrice = getSwapsCustomizationModalPrice(state) if (!customGasPrice || average === undefined) { return false } const customPriceRisksSwapFailure = conversionLessThan( { value: customGasPrice, fromNumericBase: 'hex', fromDenomination: 'WEI', toDenomination: 'GWEI', }, { value: average, fromNumericBase: 'dec' }, ) return customPriceRisksSwapFailure } // Background selectors const getSwapsState = (state) => state.metamask.swapsState export const getSwapsFeatureLiveness = (state) => state.metamask.swapsState.swapsFeatureIsLive export const getBackgroundSwapRouteState = (state) => state.metamask.swapsState.routeState export const getCustomSwapsGas = (state) => state.metamask.swapsState.customMaxGas export const getCustomSwapsGasPrice = (state) => state.metamask.swapsState.customGasPrice export const getFetchParams = (state) => state.metamask.swapsState.fetchParams export const getQuotes = (state) => state.metamask.swapsState.quotes export const getQuotesLastFetched = (state) => state.metamask.swapsState.quotesLastFetched export const getSelectedQuote = (state) => { const { selectedAggId, quotes } = getSwapsState(state) return quotes[selectedAggId] } export const getSwapsErrorKey = (state) => getSwapsState(state)?.errorKey export const getShowQuoteLoadingScreen = (state) => state.swaps.showQuoteLoadingScreen export const getSwapsTokens = (state) => state.metamask.swapsState.tokens export const getSwapsWelcomeMessageSeenStatus = (state) => state.metamask.swapsWelcomeMessageHasBeenShown export const getTopQuote = (state) => { const { topAggId, quotes } = getSwapsState(state) return quotes[topAggId] } export const getApproveTxId = (state) => state.metamask.swapsState.approveTxId export const getTradeTxId = (state) => state.metamask.swapsState.tradeTxId export const getUsedQuote = (state) => getSelectedQuote(state) || getTopQuote(state) // Compound selectors export const getDestinationTokenInfo = (state) => getFetchParams(state)?.metaData?.destinationTokenInfo export const getUsedSwapsGasPrice = (state) => getCustomSwapsGasPrice(state) || getSwapsFallbackGasPrice(state) export const getApproveTxParams = (state) => { const { approvalNeeded } = getSelectedQuote(state) || getTopQuote(state) || {} if (!approvalNeeded) { return null } const data = getSwapsState(state)?.customApproveTxData || approvalNeeded.data const gasPrice = getCustomSwapsGasPrice(state) || approvalNeeded.gasPrice return { ...approvalNeeded, gasPrice, data } } // Actions / action-creators const { clearSwapsState, navigatedBackToBuildQuote, retriedGetQuotes, swapGasPriceEstimatesFetchCompleted, swapGasPriceEstimatesFetchStarted, swapGasPriceEstimatesFetchFailed, setAggregatorMetadata, setBalanceError, setFetchingQuotes, setFromToken, setQuotesFetchStartTime, setTopAssets, setToToken, swapCustomGasModalPriceEdited, swapCustomGasModalLimitEdited, retrievedFallbackSwapsGasPrice, swapCustomGasModalClosed, } = actions export { clearSwapsState, setAggregatorMetadata, setBalanceError, setFetchingQuotes, setFromToken as setSwapsFromToken, setQuotesFetchStartTime as setSwapQuotesFetchStartTime, setTopAssets, setToToken as setSwapToToken, swapCustomGasModalPriceEdited, swapCustomGasModalLimitEdited, swapCustomGasModalClosed, } export const navigateBackToBuildQuote = (history) => { return async (dispatch) => { // TODO: Ensure any fetch in progress is cancelled await dispatch(resetSwapsPostFetchState()) dispatch(navigatedBackToBuildQuote()) history.push(BUILD_QUOTE_ROUTE) } } export const prepareForRetryGetQuotes = () => { return async (dispatch) => { // TODO: Ensure any fetch in progress is cancelled await dispatch(resetSwapsPostFetchState()) dispatch(retriedGetQuotes()) } } export const prepareToLeaveSwaps = () => { return async (dispatch) => { dispatch(clearSwapsState()) await dispatch(resetBackgroundSwapsState()) } } export const swapsQuoteSelected = (aggId) => { return (dispatch) => { dispatch(swapCustomGasModalLimitEdited(null)) dispatch(setSelectedQuoteAggId(aggId)) dispatch(setSwapsTxGasLimit('')) } } export const fetchAndSetSwapsGasPriceInfo = () => { return async (dispatch) => { const basicEstimates = await dispatch(fetchMetaSwapsGasPriceEstimates()) if (basicEstimates?.fast) { dispatch(setSwapsTxGasPrice(decGWEIToHexWEI(basicEstimates.fast))) } } } export const fetchQuotesAndSetQuoteState = ( history, inputValue, maxSlippage, metaMetricsEvent, ) => { return async (dispatch, getState) => { let swapsFeatureIsLive = false try { swapsFeatureIsLive = await fetchSwapsFeatureLiveness() } catch (error) { log.error('Failed to fetch Swaps liveness, defaulting to false.', error) } await dispatch(setSwapsLiveness(swapsFeatureIsLive)) if (!swapsFeatureIsLive) { await history.push(SWAPS_MAINTENANCE_ROUTE) return } const state = getState() const fetchParams = getFetchParams(state) const selectedAccount = getSelectedAccount(state) const balanceError = getBalanceError(state) const fetchParamsFromToken = fetchParams?.metaData?.sourceTokenInfo?.symbol === 'ETH' ? { ...ETH_SWAPS_TOKEN_OBJECT, string: getValueFromWeiHex({ value: selectedAccount.balance, numberOfDecimals: 4, toDenomination: 'ETH', }), balance: hexToDecimal(selectedAccount.balance), } : fetchParams?.metaData?.sourceTokenInfo const selectedFromToken = getFromToken(state) || fetchParamsFromToken || {} const selectedToToken = getToToken(state) || fetchParams?.metaData?.destinationTokenInfo || {} const { address: fromTokenAddress, symbol: fromTokenSymbol, decimals: fromTokenDecimals, iconUrl: fromTokenIconUrl, balance: fromTokenBalance, } = selectedFromToken const { address: toTokenAddress, symbol: toTokenSymbol, decimals: toTokenDecimals, iconUrl: toTokenIconUrl, } = selectedToToken await dispatch(setBackgroundSwapRouteState('loading')) history.push(LOADING_QUOTES_ROUTE) dispatch(setFetchingQuotes(true)) const contractExchangeRates = getTokenExchangeRates(state) let destinationTokenAddedForSwap = false if (toTokenSymbol !== 'ETH' && !contractExchangeRates[toTokenAddress]) { destinationTokenAddedForSwap = true await dispatch( addToken( toTokenAddress, toTokenSymbol, toTokenDecimals, toTokenIconUrl, true, ), ) } if ( fromTokenSymbol !== 'ETH' && !contractExchangeRates[fromTokenAddress] && fromTokenBalance && new BigNumber(fromTokenBalance, 16).gt(0) ) { dispatch( addToken( fromTokenAddress, fromTokenSymbol, fromTokenDecimals, fromTokenIconUrl, true, ), ) } const swapsTokens = getSwapsTokens(state) const sourceTokenInfo = swapsTokens?.find(({ address }) => address === fromTokenAddress) || selectedFromToken const destinationTokenInfo = swapsTokens?.find(({ address }) => address === toTokenAddress) || selectedToToken dispatch(setFromToken(selectedFromToken)) metaMetricsEvent({ event: 'Quotes Requested', category: 'swaps', sensitiveProperties: { token_from: fromTokenSymbol, token_from_amount: String(inputValue), token_to: toTokenSymbol, request_type: balanceError ? 'Quote' : 'Order', slippage: maxSlippage, custom_slippage: maxSlippage !== 2, anonymizedData: true, }, }) try { const fetchStartTime = Date.now() dispatch(setQuotesFetchStartTime(fetchStartTime)) const fetchAndSetQuotesPromise = dispatch( fetchAndSetQuotes( { slippage: maxSlippage, sourceToken: fromTokenAddress, destinationToken: toTokenAddress, value: inputValue, fromAddress: selectedAccount.address, destinationTokenAddedForSwap, balanceError, sourceDecimals: fromTokenDecimals, }, { sourceTokenInfo, destinationTokenInfo, accountBalance: selectedAccount.balance, }, ), ) const gasPriceFetchPromise = dispatch(fetchAndSetSwapsGasPriceInfo()) const [[fetchedQuotes, selectedAggId]] = await Promise.all([ fetchAndSetQuotesPromise, gasPriceFetchPromise, ]) if (Object.values(fetchedQuotes)?.length === 0) { metaMetricsEvent({ event: 'No Quotes Available', category: 'swaps', sensitiveProperties: { token_from: fromTokenSymbol, token_from_amount: String(inputValue), token_to: toTokenSymbol, request_type: balanceError ? 'Quote' : 'Order', slippage: maxSlippage, custom_slippage: maxSlippage !== 2, }, }) dispatch(setSwapsErrorKey(QUOTES_NOT_AVAILABLE_ERROR)) } else { const newSelectedQuote = fetchedQuotes[selectedAggId] metaMetricsEvent({ event: 'Quotes Received', category: 'swaps', sensitiveProperties: { token_from: fromTokenSymbol, token_from_amount: String(inputValue), token_to: toTokenSymbol, token_to_amount: calcTokenAmount( newSelectedQuote.destinationAmount, newSelectedQuote.decimals || 18, ), request_type: balanceError ? 'Quote' : 'Order', slippage: maxSlippage, custom_slippage: maxSlippage !== 2, response_time: Date.now() - fetchStartTime, best_quote_source: newSelectedQuote.aggregator, available_quotes: Object.values(fetchedQuotes)?.length, anonymizedData: true, }, }) dispatch(setInitialGasEstimate(selectedAggId)) } } catch (e) { // A newer swap request is running, so simply bail and let the newer request respond if (e.message === SWAPS_FETCH_ORDER_CONFLICT) { log.debug(`Swap fetch order conflict detected; ignoring older request`) return } // TODO: Check for any errors we should expect to occur in production, and report others to Sentry log.error(`Error fetching quotes: `, e) dispatch(setSwapsErrorKey(ERROR_FETCHING_QUOTES)) } dispatch(setFetchingQuotes(false)) } } export const signAndSendTransactions = (history, metaMetricsEvent) => { return async (dispatch, getState) => { let swapsFeatureIsLive = false try { swapsFeatureIsLive = await fetchSwapsFeatureLiveness() } catch (error) { log.error('Failed to fetch Swaps liveness, defaulting to false.', error) } await dispatch(setSwapsLiveness(swapsFeatureIsLive)) if (!swapsFeatureIsLive) { await history.push(SWAPS_MAINTENANCE_ROUTE) return } const state = getState() const customSwapsGas = getCustomSwapsGas(state) const fetchParams = getFetchParams(state) const { metaData, value: swapTokenValue, slippage } = fetchParams const { sourceTokenInfo = {}, destinationTokenInfo = {} } = metaData await dispatch(setBackgroundSwapRouteState('awaiting')) await dispatch(stopPollingForQuotes()) history.push(AWAITING_SWAP_ROUTE) const { fast: fastGasEstimate } = getSwapGasPriceEstimateData(state) const usedQuote = getUsedQuote(state) const usedTradeTxParams = usedQuote.trade const estimatedGasLimit = new BigNumber( usedQuote?.gasEstimate || decimalToHex(usedQuote?.averageGas || 0), 16, ) const estimatedGasLimitWithMultiplier = estimatedGasLimit .times(1.4, 10) .round(0) .toString(16) const maxGasLimit = customSwapsGas || hexMax( `0x${decimalToHex(usedQuote?.maxGas || 0)}`, estimatedGasLimitWithMultiplier, ) const usedGasPrice = getUsedSwapsGasPrice(state) usedTradeTxParams.gas = maxGasLimit usedTradeTxParams.gasPrice = usedGasPrice const usdConversionRate = getUSDConversionRate(state) const destinationValue = calcTokenAmount( usedQuote.destinationAmount, destinationTokenInfo.decimals || 18, ).toPrecision(8) const usedGasLimitEstimate = usedQuote?.gasEstimateWithRefund || `0x${decimalToHex(usedQuote?.averageGas || 0)}` const totalGasLimitEstimate = new BigNumber(usedGasLimitEstimate, 16) .plus(usedQuote.approvalNeeded?.gas || '0x0', 16) .toString(16) const gasEstimateTotalInUSD = getValueFromWeiHex({ value: calcGasTotal(totalGasLimitEstimate, usedGasPrice), toCurrency: 'usd', conversionRate: usdConversionRate, numberOfDecimals: 6, }) const swapMetaData = { token_from: sourceTokenInfo.symbol, token_from_amount: String(swapTokenValue), token_to: destinationTokenInfo.symbol, token_to_amount: destinationValue, slippage, custom_slippage: slippage !== 2, best_quote_source: getTopQuote(state)?.aggregator, available_quotes: getQuotes(state)?.length, other_quote_selected: usedQuote.aggregator !== getTopQuote(state)?.aggregator, other_quote_selected_source: usedQuote.aggregator === getTopQuote(state)?.aggregator ? '' : usedQuote.aggregator, gas_fees: gasEstimateTotalInUSD, estimated_gas: estimatedGasLimit.toString(10), suggested_gas_price: fastGasEstimate, used_gas_price: hexWEIToDecGWEI(usedGasPrice), average_savings: usedQuote.savings?.total, performance_savings: usedQuote.savings?.performance, fee_savings: usedQuote.savings?.fee, median_metamask_fee: usedQuote.savings?.medianMetaMaskFee, } metaMetricsEvent({ event: 'Swap Started', category: 'swaps', sensitiveProperties: swapMetaData, }) let finalApproveTxMeta const approveTxParams = getApproveTxParams(state) if (approveTxParams) { const approveTxMeta = await dispatch( addUnapprovedTransaction( { ...approveTxParams, amount: '0x0' }, 'metamask', ), ) await dispatch(setApproveTxId(approveTxMeta.id)) finalApproveTxMeta = await dispatch( updateTransaction( { ...approveTxMeta, transactionCategory: TRANSACTION_CATEGORIES.SWAP_APPROVAL, sourceTokenSymbol: sourceTokenInfo.symbol, }, true, ), ) try { await dispatch(updateAndApproveTx(finalApproveTxMeta, true)) } catch (e) { await dispatch(setSwapsErrorKey(SWAP_FAILED_ERROR)) history.push(SWAPS_ERROR_ROUTE) return } } const tradeTxMeta = await dispatch( addUnapprovedTransaction(usedTradeTxParams, 'metamask'), ) dispatch(setTradeTxId(tradeTxMeta.id)) const finalTradeTxMeta = await dispatch( updateTransaction( { ...tradeTxMeta, sourceTokenSymbol: sourceTokenInfo.symbol, destinationTokenSymbol: destinationTokenInfo.symbol, transactionCategory: TRANSACTION_CATEGORIES.SWAP, destinationTokenDecimals: destinationTokenInfo.decimals, destinationTokenAddress: destinationTokenInfo.address, swapMetaData, swapTokenValue, approvalTxId: finalApproveTxMeta?.id, }, true, ), ) try { await dispatch(updateAndApproveTx(finalTradeTxMeta, true)) } catch (e) { await dispatch(setSwapsErrorKey(SWAP_FAILED_ERROR)) history.push(SWAPS_ERROR_ROUTE) return } await forceUpdateMetamaskState(dispatch) } } export function fetchMetaSwapsGasPriceEstimates() { return async (dispatch, getState) => { const state = getState() const priceEstimatesLastRetrieved = getSwapsPriceEstimatesLastRetrieved( state, ) const timeLastRetrieved = priceEstimatesLastRetrieved || (await getStorageItem('METASWAP_GAS_PRICE_ESTIMATES_LAST_RETRIEVED')) || 0 dispatch(swapGasPriceEstimatesFetchStarted()) let priceEstimates try { if (Date.now() - timeLastRetrieved > 30000) { priceEstimates = await fetchSwapsGasPrices() } else { const cachedPriceEstimates = await getStorageItem( 'METASWAP_GAS_PRICE_ESTIMATES', ) priceEstimates = cachedPriceEstimates || (await fetchSwapsGasPrices()) } } catch (e) { log.warn('Fetching swaps gas prices failed:', e) if (!e.message?.match(/NetworkError|Fetch failed with status:/u)) { throw e } dispatch(swapGasPriceEstimatesFetchFailed()) try { const gasPrice = await global.ethQuery.gasPrice() const gasPriceInDecGWEI = hexWEIToDecGWEI(gasPrice.toString(10)) dispatch(retrievedFallbackSwapsGasPrice(gasPriceInDecGWEI)) return null } catch (networkGasPriceError) { console.error( `Failed to retrieve fallback gas price: `, networkGasPriceError, ) return null } } const timeRetrieved = Date.now() await Promise.all([ setStorageItem('METASWAP_GAS_PRICE_ESTIMATES', priceEstimates), setStorageItem( 'METASWAP_GAS_PRICE_ESTIMATES_LAST_RETRIEVED', timeRetrieved, ), ]) dispatch( swapGasPriceEstimatesFetchCompleted({ priceEstimates, priceEstimatesLastRetrieved: timeRetrieved, }), ) return priceEstimates } }