diff --git a/app/scripts/controllers/swaps.js b/app/scripts/controllers/swaps.js index c7691dffe..e230b8d63 100644 --- a/app/scripts/controllers/swaps.js +++ b/app/scripts/controllers/swaps.js @@ -17,6 +17,7 @@ import { import { fetchTradesInfo as defaultFetchTradesInfo, fetchSwapsFeatureLiveness as defaultFetchSwapsFeatureLiveness, + fetchSwapsQuoteRefreshTime as defaultFetchSwapsQuoteRefreshTime, } from '../../../ui/app/pages/swaps/swaps.util' const METASWAP_ADDRESS = '0x881d40237659c251811cec9c364ef91dc08d300c' @@ -28,6 +29,14 @@ const MAX_GAS_LIMIT = 2500000 // 3 seems to be an appropriate balance of giving users the time they need when MetaMask is not left idle, and turning polling off when it is. const POLL_COUNT_LIMIT = 3 +// If for any reason the MetaSwap API fails to provide a refresh time, +// provide a reasonable fallback to avoid further errors +const FALLBACK_QUOTE_REFRESH_TIME = 60000 + +// This is the amount of time to wait, after successfully fetching quotes +// and their gas estimates, before fetching for new quotes +const QUOTE_POLLING_DIFFERENCE_INTERVAL = 10 * 1000 + function calculateGasEstimateWithRefund( maxGas = MAX_GAS_LIMIT, estimatedRefund = 0, @@ -42,9 +51,6 @@ function calculateGasEstimateWithRefund( return gasEstimateWithRefund } -// This is the amount of time to wait, after successfully fetching quotes and their gas estimates, before fetching for new quotes -const QUOTE_POLLING_INTERVAL = 50 * 1000 - const initialState = { swapsState: { quotes: {}, @@ -61,6 +67,7 @@ const initialState = { topAggId: null, routeState: '', swapsFeatureIsLive: false, + swapsQuoteRefreshTime: FALLBACK_QUOTE_REFRESH_TIME, }, } @@ -73,6 +80,7 @@ export default class SwapsController { tokenRatesStore, fetchTradesInfo = defaultFetchTradesInfo, fetchSwapsFeatureLiveness = defaultFetchSwapsFeatureLiveness, + fetchSwapsQuoteRefreshTime = defaultFetchSwapsQuoteRefreshTime, }) { this.store = new ObservableStore({ swapsState: { ...initialState.swapsState }, @@ -80,6 +88,7 @@ export default class SwapsController { this._fetchTradesInfo = fetchTradesInfo this._fetchSwapsFeatureLiveness = fetchSwapsFeatureLiveness + this._fetchSwapsQuoteRefreshTime = fetchSwapsQuoteRefreshTime this.getBufferedGasLimit = getBufferedGasLimit this.tokenRatesStore = tokenRatesStore @@ -101,11 +110,31 @@ export default class SwapsController { this._setupSwapsLivenessFetching() } + // Sets the refresh rate for quote updates from the MetaSwap API + async _setSwapsQuoteRefreshTime() { + // Default to fallback time unless API returns valid response + let swapsQuoteRefreshTime = FALLBACK_QUOTE_REFRESH_TIME + try { + swapsQuoteRefreshTime = await this._fetchSwapsQuoteRefreshTime() + } catch (e) { + console.error('Request for swaps quote refresh time failed: ', e) + } + + const { swapsState } = this.store.getState() + this.store.updateState({ + swapsState: { ...swapsState, swapsQuoteRefreshTime }, + }) + } + // Once quotes are fetched, we poll for new ones to keep the quotes up to date. Market and aggregator contract conditions can change fast enough // that quotes will no longer be available after 1 or 2 minutes. When fetchAndSetQuotes is first called it, receives fetch that parameters are stored in // state. These stored parameters are used on subsequent calls made during polling. // Note: we stop polling after 3 requests, until new quotes are explicitly asked for. The logic that enforces that maximum is in the body of fetchAndSetQuotes pollForNewQuotes() { + const { + swapsState: { swapsQuoteRefreshTime }, + } = this.store.getState() + this.pollingTimeout = setTimeout(() => { const { swapsState } = this.store.getState() this.fetchAndSetQuotes( @@ -113,7 +142,7 @@ export default class SwapsController { swapsState.fetchParams?.metaData, true, ) - }, QUOTE_POLLING_INTERVAL) + }, swapsQuoteRefreshTime - QUOTE_POLLING_DIFFERENCE_INTERVAL) } stopPollingForQuotes() { @@ -128,7 +157,6 @@ export default class SwapsController { if (!fetchParams) { return null } - // Every time we get a new request that is not from the polling, we reset the poll count so we can poll for up to three more sets of quotes with these new params. if (!isPolledRequest) { this.pollCount = 0 @@ -144,7 +172,10 @@ export default class SwapsController { const indexOfCurrentCall = this.indexOfNewestCallInFlight + 1 this.indexOfNewestCallInFlight = indexOfCurrentCall - let newQuotes = await this._fetchTradesInfo(fetchParams) + let [newQuotes] = await Promise.all([ + this._fetchTradesInfo(fetchParams), + this._setSwapsQuoteRefreshTime(), + ]) newQuotes = mapValues(newQuotes, (quote) => ({ ...quote, @@ -422,6 +453,7 @@ export default class SwapsController { tokens: swapsState.tokens, fetchParams: swapsState.fetchParams, swapsFeatureIsLive: swapsState.swapsFeatureIsLive, + swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime, }, }) clearTimeout(this.pollingTimeout) @@ -435,6 +467,7 @@ export default class SwapsController { ...initialState.swapsState, tokens: swapsState.tokens, swapsFeatureIsLive: swapsState.swapsFeatureIsLive, + swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime, }, }) clearTimeout(this.pollingTimeout) diff --git a/test/unit/app/controllers/swaps-test.js b/test/unit/app/controllers/swaps-test.js index 775c53d9f..6aa3e0476 100644 --- a/test/unit/app/controllers/swaps-test.js +++ b/test/unit/app/controllers/swaps-test.js @@ -121,12 +121,14 @@ const EMPTY_INIT_STATE = { topAggId: null, routeState: '', swapsFeatureIsLive: false, + swapsQuoteRefreshTime: 60000, }, } const sandbox = sinon.createSandbox() const fetchTradesInfoStub = sandbox.stub() const fetchSwapsFeatureLivenessStub = sandbox.stub() +const fetchSwapsQuoteRefreshTimeStub = sandbox.stub() describe('SwapsController', function () { let provider @@ -140,6 +142,7 @@ describe('SwapsController', function () { tokenRatesStore: MOCK_TOKEN_RATES_STORE, fetchTradesInfo: fetchTradesInfoStub, fetchSwapsFeatureLiveness: fetchSwapsFeatureLivenessStub, + fetchSwapsQuoteRefreshTime: fetchSwapsQuoteRefreshTimeStub, }) } @@ -639,9 +642,9 @@ describe('SwapsController', function () { const quotes = await swapsController.fetchAndSetQuotes(undefined) assert.strictEqual(quotes, null) }) - it('calls fetchTradesInfo with the given fetchParams and returns the correct quotes', async function () { fetchTradesInfoStub.resolves(getMockQuotes()) + fetchSwapsQuoteRefreshTimeStub.resolves(getMockQuoteRefreshTime()) // Make it so approval is not required sandbox @@ -682,9 +685,9 @@ describe('SwapsController', function () { true, ) }) - it('performs the allowance check', async function () { fetchTradesInfoStub.resolves(getMockQuotes()) + fetchSwapsQuoteRefreshTimeStub.resolves(getMockQuoteRefreshTime()) // Make it so approval is not required const allowanceStub = sandbox @@ -707,6 +710,7 @@ describe('SwapsController', function () { it('gets the gas limit if approval is required', async function () { fetchTradesInfoStub.resolves(MOCK_QUOTES_APPROVAL_REQUIRED) + fetchSwapsQuoteRefreshTimeStub.resolves(getMockQuoteRefreshTime()) // Ensure approval is required sandbox @@ -732,6 +736,7 @@ describe('SwapsController', function () { it('marks the best quote', async function () { fetchTradesInfoStub.resolves(getMockQuotes()) + fetchSwapsQuoteRefreshTimeStub.resolves(getMockQuoteRefreshTime()) // Make it so approval is not required sandbox @@ -762,6 +767,7 @@ describe('SwapsController', function () { } const quotes = { ...getMockQuotes(), [bestAggId]: bestQuote } fetchTradesInfoStub.resolves(quotes) + fetchSwapsQuoteRefreshTimeStub.resolves(getMockQuoteRefreshTime()) // Make it so approval is not required sandbox @@ -779,6 +785,7 @@ describe('SwapsController', function () { it('does not mark as best quote if no conversion rate exists for destination token', async function () { fetchTradesInfoStub.resolves(getMockQuotes()) + fetchSwapsQuoteRefreshTimeStub.resolves(getMockQuoteRefreshTime()) // Make it so approval is not required sandbox @@ -805,6 +812,7 @@ describe('SwapsController', function () { assert.deepStrictEqual(swapsState, { ...EMPTY_INIT_STATE.swapsState, tokens: old.tokens, + swapsQuoteRefreshTime: old.swapsQuoteRefreshTime, }) }) @@ -850,8 +858,14 @@ describe('SwapsController', function () { const tokens = 'test' const fetchParams = 'test' const swapsFeatureIsLive = false + const swapsQuoteRefreshTime = 0 swapsController.store.updateState({ - swapsState: { tokens, fetchParams, swapsFeatureIsLive }, + swapsState: { + tokens, + fetchParams, + swapsFeatureIsLive, + swapsQuoteRefreshTime, + }, }) swapsController.resetPostFetchState() @@ -862,6 +876,7 @@ describe('SwapsController', function () { tokens, fetchParams, swapsFeatureIsLive, + swapsQuoteRefreshTime, }) }) }) @@ -1615,3 +1630,7 @@ function getTopQuoteAndSavingsBaseExpectedResults() { }, } } + +function getMockQuoteRefreshTime() { + return 45000 +} diff --git a/ui/app/ducks/swaps/swaps.js b/ui/app/ducks/swaps/swaps.js index dd7fab0a1..f6d36c204 100644 --- a/ui/app/ducks/swaps/swaps.js +++ b/ui/app/ducks/swaps/swaps.js @@ -224,6 +224,9 @@ const getSwapsState = (state) => state.metamask.swapsState export const getSwapsFeatureLiveness = (state) => state.metamask.swapsState.swapsFeatureIsLive +export const getSwapsQuoteRefreshTime = (state) => + state.metamask.swapsState.swapsQuoteRefreshTime + export const getBackgroundSwapRouteState = (state) => state.metamask.swapsState.routeState diff --git a/ui/app/pages/swaps/countdown-timer/countdown-timer.js b/ui/app/pages/swaps/countdown-timer/countdown-timer.js index 9a7362536..393a51849 100644 --- a/ui/app/pages/swaps/countdown-timer/countdown-timer.js +++ b/ui/app/pages/swaps/countdown-timer/countdown-timer.js @@ -1,11 +1,11 @@ import React, { useState, useEffect, useContext, useRef } from 'react' +import { useSelector } from 'react-redux' import PropTypes from 'prop-types' import classnames from 'classnames' import { Duration } from 'luxon' import { I18nContext } from '../../../contexts/i18n' import InfoTooltip from '../../../components/ui/info-tooltip' - -const TIMER_BASE = 60000 +import { getSwapsQuoteRefreshTime } from '../../../ducks/swaps/swaps' // Return the mm:ss start time of the countdown timer. // If time has elapsed between `timeStarted` the time current time, @@ -31,7 +31,7 @@ function timeBelowWarningTime(timer, warningTime) { export default function CountdownTimer({ timeStarted, timeOnly, - timerBase = TIMER_BASE, + timerBase, warningTime, labelKey, infoTooltipLabelKey, @@ -40,9 +40,12 @@ export default function CountdownTimer({ const intervalRef = useRef() const initialTimeStartedRef = useRef() + const swapsQuoteRefreshTime = useSelector(getSwapsQuoteRefreshTime) + const timerStart = Number(timerBase) || swapsQuoteRefreshTime + const [currentTime, setCurrentTime] = useState(() => Date.now()) const [timer, setTimer] = useState(() => - getNewTimer(currentTime, timeStarted, timerBase), + getNewTimer(currentTime, timeStarted, timerStart), ) useEffect(() => { @@ -67,14 +70,14 @@ export default function CountdownTimer({ initialTimeStartedRef.current = timeStarted const newCurrentTime = Date.now() setCurrentTime(newCurrentTime) - setTimer(getNewTimer(newCurrentTime, timeStarted, timerBase)) + setTimer(getNewTimer(newCurrentTime, timeStarted, timerStart)) clearInterval(intervalRef.current) intervalRef.current = setInterval(() => { setTimer(decreaseTimerByOne) }, 1000) } - }, [timeStarted, timer, timerBase]) + }, [timeStarted, timer, timerStart]) const formattedTimer = Duration.fromMillis(timer).toFormat('m:ss') let time diff --git a/ui/app/pages/swaps/swaps.util.js b/ui/app/pages/swaps/swaps.util.js index b031a1c12..e64cbf8fd 100644 --- a/ui/app/pages/swaps/swaps.util.js +++ b/ui/app/pages/swaps/swaps.util.js @@ -24,20 +24,24 @@ const TOKEN_TRANSFER_LOG_TOPIC_HASH = const CACHE_REFRESH_ONE_HOUR = 3600000 +const METASWAP_API_HOST = 'https://api.metaswap.codefi.network' + const getBaseApi = function (type) { switch (type) { case 'trade': - return `https://api.metaswap.codefi.network/trades?` + return `${METASWAP_API_HOST}/trades?` case 'tokens': - return `https://api.metaswap.codefi.network/tokens` + return `${METASWAP_API_HOST}/tokens` case 'topAssets': - return `https://api.metaswap.codefi.network/topAssets` + return `${METASWAP_API_HOST}/topAssets` case 'featureFlag': - return `https://api.metaswap.codefi.network/featureFlag` + return `${METASWAP_API_HOST}/featureFlag` case 'aggregatorMetadata': - return `https://api.metaswap.codefi.network/aggregatorMetadata` + return `${METASWAP_API_HOST}/aggregatorMetadata` case 'gasPrices': - return `https://api.metaswap.codefi.network/gasPrices` + return `${METASWAP_API_HOST}/gasPrices` + case 'refreshTime': + return `${METASWAP_API_HOST}/quoteRefreshRate` default: throw new Error('getBaseApi requires an api call type') } @@ -328,6 +332,23 @@ export async function fetchSwapsFeatureLiveness() { return status?.active } +export async function fetchSwapsQuoteRefreshTime() { + const response = await fetchWithCache( + getBaseApi('refreshTime'), + { method: 'GET' }, + { cacheRefreshTime: 600000 }, + ) + + // We presently use milliseconds in the UI + if (typeof response?.seconds === 'number' && response.seconds > 0) { + return response.seconds * 1000 + } + + throw new Error( + `MetaMask - refreshTime provided invalid response: ${response}`, + ) +} + export async function fetchTokenPrice(address) { const query = `contract_addresses=${address}&vs_currencies=eth` diff --git a/ui/app/pages/swaps/view-quote/view-quote.js b/ui/app/pages/swaps/view-quote/view-quote.js index d3e54575f..ea004a9f0 100644 --- a/ui/app/pages/swaps/view-quote/view-quote.js +++ b/ui/app/pages/swaps/view-quote/view-quote.js @@ -28,6 +28,7 @@ import { signAndSendTransactions, getBackgroundSwapRouteState, swapsQuoteSelected, + getSwapsQuoteRefreshTime, } from '../../../ducks/swaps/swaps' import { conversionRateSelector, @@ -115,6 +116,7 @@ export default function ViewQuote() { const topQuote = useSelector(getTopQuote) const usedQuote = selectedQuote || topQuote const tradeValue = usedQuote?.trade?.value ?? '0x0' + const swapsQuoteRefreshTime = useSelector(getSwapsQuoteRefreshTime) const { isBestQuote } = usedQuote const fetchParamsSourceToken = fetchParams?.sourceToken @@ -263,14 +265,23 @@ export default function ViewQuote() { useEffect(() => { const currentTime = Date.now() const timeSinceLastFetched = currentTime - quotesLastFetched - if (timeSinceLastFetched > 60000 && !dispatchedSafeRefetch) { + if ( + timeSinceLastFetched > swapsQuoteRefreshTime && + !dispatchedSafeRefetch + ) { setDispatchedSafeRefetch(true) dispatch(safeRefetchQuotes()) - } else if (timeSinceLastFetched > 60000) { + } else if (timeSinceLastFetched > swapsQuoteRefreshTime) { dispatch(setSwapsErrorKey(QUOTES_EXPIRED_ERROR)) history.push(SWAPS_ERROR_ROUTE) } - }, [quotesLastFetched, dispatchedSafeRefetch, dispatch, history]) + }, [ + quotesLastFetched, + dispatchedSafeRefetch, + dispatch, + history, + swapsQuoteRefreshTime, + ]) useEffect(() => { if (!originalApproveAmount && approveAmount) {