diff --git a/src/actions/ExchangeRateActions.ts b/src/actions/ExchangeRateActions.ts index 49ccb9b97d5..a6ad3bbc5d5 100644 --- a/src/actions/ExchangeRateActions.ts +++ b/src/actions/ExchangeRateActions.ts @@ -5,7 +5,7 @@ import { makeReactNativeDisklet } from 'disklet' import { RootState, ThunkAction } from '../types/reduxTypes' import { GuiExchangeRates } from '../types/types' import { fetchRates } from '../util/network' -import { datelog, DECIMAL_PRECISION, getYesterdayDateRoundDownHour } from '../util/utils' +import { datelog, DECIMAL_PRECISION } from '../util/utils' const disklet = makeReactNativeDisklet() const EXCHANGE_RATES_FILENAME = 'exchangeRates.json' @@ -14,8 +14,18 @@ const HOUR_MS = 1000 * 60 * 60 const ONE_DAY = 1000 * 60 * 60 * 24 const ONE_MONTH = 1000 * 60 * 60 * 24 * 30 -const asAssetPair = asObject({ currency_pair: asString, date: asOptional(asString), expiration: asNumber }) -const asExchangeRateCache = asObject(asObject({ expiration: asNumber, rate: asString })) +const asAssetPair = asObject({ + currency_pair: asString, + date: asOptional(asString), // Defaults to today if not specified + expiration: asNumber +}) + +const asExchangeRateCache = asObject( + asObject({ + expiration: asNumber, + rate: asString + }) +) const asExchangeRateCacheFile = asObject({ rates: asExchangeRateCache, assetPairs: asArray(asAssetPair) @@ -25,7 +35,7 @@ type AssetPair = ReturnType type ExchangeRateCache = ReturnType type ExchangeRateCacheFile = ReturnType -const exchangeRateCache: ExchangeRateCache = {} +let exchangeRateCache: ExchangeRateCache = {} const asRatesResponse = asObject({ data: asArray( @@ -48,49 +58,48 @@ export function updateExchangeRates(): ThunkAction> { } } -/** - * Remove duplicates and expired entries from the given array of AssetPair. - * If two items share the same currency_pair and date, - * only keep the one with the higher expiration. - */ -function filterAssetPairs(assetPairs: AssetPair[]): AssetPair[] { - const map = new Map() - const now = Date.now() - for (const asset of assetPairs) { - if (asset.expiration < now) continue - // Construct a key based on currency_pair and date (including handling for empty/undefined date) - const key = `${asset.currency_pair}_${asset.date ?? ''}` - - const existing = map.get(key) - if (existing == null || asset.expiration > existing.expiration) { - map.set(key, asset) - } - } - - return [...map.values()] -} - async function buildExchangeRates(state: RootState): Promise { + const accountIsoFiat = state.ui.settings.defaultIsoFiat const { account } = state.core const { currencyWallets } = account + + // Look up various dates: const now = Date.now() - const initialAssetPairs: AssetPair[] = [] - let numCacheEntries = 0 + const pairExpiration = now + ONE_MONTH + const rateExpiration = now + ONE_DAY + const yesterday = getYesterdayDateRoundDownHour(now).toISOString() - // Load exchange rate cache off disk - if (Object.keys(exchangeRateCache).length === 0) { + // What we need to fetch from the server: + const initialAssetPairs: AssetPair[] = [] + let hasWallets = false + let hasCachedRates = false + + // If we have loaded the cache before, keep any un-expired entries: + const rateCache: ExchangeRateCache = {} + const cachedKeys = Object.keys(exchangeRateCache) + if (cachedKeys.length > 0) { + for (const key of cachedKeys) { + if (exchangeRateCache[key].expiration > now) { + rateCache[key] = exchangeRateCache[key] + hasCachedRates = true + } + } + } else { + // Load exchange rate cache off disk, since we haven't done that yet: try { const raw = await disklet.getText(EXCHANGE_RATES_FILENAME) const json = JSON.parse(raw) - const exchangeRateCacheFile = asExchangeRateCacheFile(json) - const { assetPairs, rates } = exchangeRateCacheFile - // Prune expired rates + const { assetPairs, rates } = asExchangeRateCacheFile(json) + + // Keep un-expired rates: for (const key of Object.keys(rates)) { if (rates[key].expiration > now) { - exchangeRateCache[key] = rates[key] - numCacheEntries++ + rateCache[key] = rates[key] + hasCachedRates = true } } + + // Keep un-expired asset pairs: for (const pair of assetPairs) { if (pair.expiration > now) { initialAssetPairs.push(pair) @@ -100,43 +109,70 @@ async function buildExchangeRates(state: RootState): Promise { datelog('Error loading exchange rate cache:', String(e)) } } - const accountIsoFiat = state.ui.settings.defaultIsoFiat - const expiration = now + ONE_MONTH - const yesterdayDate = getYesterdayDateRoundDownHour() + // If the user's fiat isn't dollars, get it's price: if (accountIsoFiat !== 'iso:USD') { - initialAssetPairs.push({ currency_pair: `iso:USD_${accountIsoFiat}`, date: undefined, expiration }) + initialAssetPairs.push({ + currency_pair: `iso:USD_${accountIsoFiat}`, + date: undefined, + expiration: pairExpiration + }) } - const walletIds = Object.keys(currencyWallets) - for (const id of walletIds) { - const wallet = currencyWallets[id] - const currencyCode = wallet.currencyInfo.currencyCode - // need to get both forward and backwards exchange rates for wallets & account fiats, for each parent currency AND each token - initialAssetPairs.push({ currency_pair: `${currencyCode}_${accountIsoFiat}`, date: undefined, expiration }) - initialAssetPairs.push({ currency_pair: `${currencyCode}_iso:USD`, date: `${yesterdayDate}`, expiration }) - // now add tokens, if they exist - if (accountIsoFiat !== 'iso:USD') { - initialAssetPairs.push({ currency_pair: `iso:USD_${accountIsoFiat}`, date: undefined, expiration }) - } + + for (const walletId of Object.keys(currencyWallets)) { + const wallet = currencyWallets[walletId] + const { currencyCode } = wallet.currencyInfo + hasWallets = true + + // Get the primary asset's prices for today and yesterday, + // but with yesterday's price in dollars: + initialAssetPairs.push({ + currency_pair: `${currencyCode}_${accountIsoFiat}`, + date: undefined, + expiration: pairExpiration + }) + initialAssetPairs.push({ + currency_pair: `${currencyCode}_iso:USD`, + date: yesterday, + expiration: pairExpiration + }) + + // Do the same for any tokens: for (const tokenId of wallet.enabledTokenIds) { - if (wallet.currencyConfig.allTokens[tokenId] == null) continue - const { currencyCode: tokenCode } = wallet.currencyConfig.allTokens[tokenId] - if (tokenCode !== currencyCode) { - initialAssetPairs.push({ currency_pair: `${tokenCode}_${accountIsoFiat}`, date: undefined, expiration }) - initialAssetPairs.push({ currency_pair: `${tokenCode}_iso:USD`, date: `${yesterdayDate}`, expiration }) - } + const token = wallet.currencyConfig.allTokens[tokenId] + if (token == null) continue + if (token.currencyCode === currencyCode) continue + initialAssetPairs.push({ + currency_pair: `${token.currencyCode}_${accountIsoFiat}`, + date: undefined, + expiration: pairExpiration + }) + initialAssetPairs.push({ + currency_pair: `${token.currencyCode}_iso:USD`, + date: yesterday, + expiration: pairExpiration + }) } } - const filteredAssetPairs = filterAssetPairs(initialAssetPairs) - const assetPairs = [...filteredAssetPairs] + // De-duplicate asset pairs: + const assetMap = new Map() + for (const asset of initialAssetPairs) { + const key = `${asset.currency_pair}_${asset.date ?? ''}` + + const existing = assetMap.get(key) + if (existing == null || asset.expiration > existing.expiration) { + assetMap.set(key, asset) + } + } + const filteredAssetPairs = [...assetMap.values()] /** * On initial load, buildExchangeRates may get called before any wallets are * loaded. In this case, we can skip the rates fetch and use the cache to * save on the network delay. */ - const skipRatesFetch = walletIds.length === 0 && numCacheEntries > 0 + const skipRatesFetch = hasCachedRates && !hasWallets while (filteredAssetPairs.length > 0) { if (skipRatesFetch) break @@ -155,22 +191,19 @@ async function buildExchangeRates(state: RootState): Promise { const cleanedRates = asRatesResponse(json) for (const rate of cleanedRates.data) { const { currency_pair: currencyPair, exchangeRate, date } = rate - const newDate = new Date(date).valueOf() + const isHistorical = now - new Date(date).valueOf() > HOUR_MS + const key = isHistorical ? `${currencyPair}_${date}` : currencyPair - const key = now - newDate > HOUR_MS ? `${currencyPair}_${date}` : currencyPair - const cachedRate = exchangeRateCache[key] ?? { expiration: 0, rate: '0' } if (exchangeRate != null) { - cachedRate.rate = exchangeRate - cachedRate.expiration = now + ONE_DAY - } - exchangeRateCache[key] = cachedRate - - const codes = key.split('_') - const reverseExchangeRateKey = `${codes[1]}_${codes[0]}${codes[2] ? '_' + codes[2] : ''}` - if (exchangeRateCache[reverseExchangeRateKey] == null) { - exchangeRateCache[reverseExchangeRateKey] = { expiration: cachedRate.expiration, rate: '0' } - if (!eq(cachedRate.rate, '0')) { - exchangeRateCache[reverseExchangeRateKey].rate = div('1', cachedRate.rate, DECIMAL_PRECISION) + rateCache[key] = { + expiration: rateExpiration, + rate: exchangeRate + } + } else if (rateCache[key] == null) { + // We at least need a placeholder: + rateCache[key] = { + expiration: 0, + rate: '0' } } } @@ -182,23 +215,37 @@ async function buildExchangeRates(state: RootState): Promise { } while (--tries > 0) } - // Save exchange rate cache to disk + // Save exchange rate cache to disk: try { - const exchangeRateCacheFile: ExchangeRateCacheFile = { rates: exchangeRateCache, assetPairs } + const exchangeRateCacheFile: ExchangeRateCacheFile = { + rates: rateCache, + assetPairs: filteredAssetPairs + } await disklet.setText(EXCHANGE_RATES_FILENAME, JSON.stringify(exchangeRateCacheFile)) } catch (e) { datelog('Error saving exchange rate cache:', String(e)) } + exchangeRateCache = rateCache + // Build the GUI rate structure: const serverRates: GuiExchangeRates = { 'iso:USD_iso:USD': '1' } - for (const key of Object.keys(exchangeRateCache)) { - const rate = exchangeRateCache[key] - if (rate.expiration > now) { - serverRates[key] = rate.rate - } else { - delete exchangeRateCache[key] - } + for (const key of Object.keys(rateCache)) { + const { rate } = rateCache[key] + serverRates[key] = rate + + // Include reverse rates: + const codes = key.split('_') + const reverseKey = `${codes[1]}_${codes[0]}${codes[2] ? '_' + codes[2] : ''}` + serverRates[reverseKey] = eq(rate, '0') ? '0' : div('1', rate, DECIMAL_PRECISION) } - return serverRates } + +const getYesterdayDateRoundDownHour = (now?: Date | number): Date => { + const yesterday = now == null ? new Date() : new Date(now) + yesterday.setMinutes(0) + yesterday.setSeconds(0) + yesterday.setMilliseconds(0) + yesterday.setDate(yesterday.getDate() - 1) + return yesterday +} diff --git a/src/util/utils.ts b/src/util/utils.ts index d0140049d90..d54609062fa 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -338,15 +338,6 @@ export const getTotalFiatAmountFromExchangeRates = (state: RootState, isoFiatCur return total } -export const getYesterdayDateRoundDownHour = () => { - const date = new Date() - date.setMinutes(0) - date.setSeconds(0) - date.setMilliseconds(0) - const yesterday = date.setDate(date.getDate() - 1) - return new Date(yesterday).toISOString() -} - type AsyncFunction = () => Promise export async function asyncWaterfall(asyncFuncs: AsyncFunction[], timeoutMs: number = 5000): Promise {