Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix #10010 - Fetch swap quote refresh time from API #10069

Merged
merged 10 commits into from
Dec 15, 2020
45 changes: 39 additions & 6 deletions app/scripts/controllers/swaps.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand All @@ -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: {},
Expand All @@ -61,6 +67,7 @@ const initialState = {
topAggId: null,
routeState: '',
swapsFeatureIsLive: false,
swapsQuoteRefreshTime: FALLBACK_QUOTE_REFRESH_TIME,
},
}

Expand All @@ -73,13 +80,15 @@ export default class SwapsController {
tokenRatesStore,
fetchTradesInfo = defaultFetchTradesInfo,
fetchSwapsFeatureLiveness = defaultFetchSwapsFeatureLiveness,
fetchSwapsQuoteRefreshTime = defaultFetchSwapsQuoteRefreshTime,
}) {
this.store = new ObservableStore({
swapsState: { ...initialState.swapsState },
})

this._fetchTradesInfo = fetchTradesInfo
this._fetchSwapsFeatureLiveness = fetchSwapsFeatureLiveness
this._fetchSwapsQuoteRefreshTime = fetchSwapsQuoteRefreshTime

this.getBufferedGasLimit = getBufferedGasLimit
this.tokenRatesStore = tokenRatesStore
Expand All @@ -101,19 +110,39 @@ 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)
darkwing marked this conversation as resolved.
Show resolved Hide resolved
}

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(
swapsState.fetchParams,
swapsState.fetchParams?.metaData,
true,
)
}, QUOTE_POLLING_INTERVAL)
}, swapsQuoteRefreshTime - QUOTE_POLLING_DIFFERENCE_INTERVAL)
darkwing marked this conversation as resolved.
Show resolved Hide resolved
}

stopPollingForQuotes() {
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -422,6 +453,7 @@ export default class SwapsController {
tokens: swapsState.tokens,
fetchParams: swapsState.fetchParams,
swapsFeatureIsLive: swapsState.swapsFeatureIsLive,
swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime,
},
})
clearTimeout(this.pollingTimeout)
Expand All @@ -435,6 +467,7 @@ export default class SwapsController {
...initialState.swapsState,
tokens: swapsState.tokens,
swapsFeatureIsLive: swapsState.swapsFeatureIsLive,
swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime,
},
})
clearTimeout(this.pollingTimeout)
Expand Down
25 changes: 22 additions & 3 deletions test/unit/app/controllers/swaps-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this stub isn't being reset correctly between tests, but we can solve that separately in a later PR. Might as well fix all of these stubs at once, since they all follow the same pattern.


describe('SwapsController', function () {
let provider
Expand All @@ -140,6 +142,7 @@ describe('SwapsController', function () {
tokenRatesStore: MOCK_TOKEN_RATES_STORE,
fetchTradesInfo: fetchTradesInfoStub,
fetchSwapsFeatureLiveness: fetchSwapsFeatureLivenessStub,
fetchSwapsQuoteRefreshTime: fetchSwapsQuoteRefreshTimeStub,
})
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -805,6 +812,7 @@ describe('SwapsController', function () {
assert.deepStrictEqual(swapsState, {
...EMPTY_INIT_STATE.swapsState,
tokens: old.tokens,
swapsQuoteRefreshTime: old.swapsQuoteRefreshTime,
})
})

Expand Down Expand Up @@ -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()
Expand All @@ -862,6 +876,7 @@ describe('SwapsController', function () {
tokens,
fetchParams,
swapsFeatureIsLive,
swapsQuoteRefreshTime,
})
})
})
Expand Down Expand Up @@ -1615,3 +1630,7 @@ function getTopQuoteAndSavingsBaseExpectedResults() {
},
}
}

function getMockQuoteRefreshTime() {
return 45000
}
3 changes: 3 additions & 0 deletions ui/app/ducks/swaps/swaps.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 9 additions & 6 deletions ui/app/pages/swaps/countdown-timer/countdown-timer.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -31,7 +31,7 @@ function timeBelowWarningTime(timer, warningTime) {
export default function CountdownTimer({
timeStarted,
timeOnly,
timerBase = TIMER_BASE,
timerBase,
warningTime,
labelKey,
infoTooltipLabelKey,
Expand All @@ -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(() => {
Expand All @@ -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
Expand Down
33 changes: 27 additions & 6 deletions ui/app/pages/swaps/swaps.util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Expand Down Expand Up @@ -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`

Expand Down
Loading