Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 130 additions & 83 deletions src/actions/ExchangeRateActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand All @@ -25,7 +35,7 @@ type AssetPair = ReturnType<typeof asAssetPair>
type ExchangeRateCache = ReturnType<typeof asExchangeRateCache>
type ExchangeRateCacheFile = ReturnType<typeof asExchangeRateCacheFile>

const exchangeRateCache: ExchangeRateCache = {}
let exchangeRateCache: ExchangeRateCache = {}

const asRatesResponse = asObject({
data: asArray(
Expand All @@ -48,49 +58,48 @@ export function updateExchangeRates(): ThunkAction<Promise<void>> {
}
}

/**
* 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<string, AssetPair>()
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<GuiExchangeRates> {
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)
Expand All @@ -100,43 +109,70 @@ async function buildExchangeRates(state: RootState): Promise<GuiExchangeRates> {
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<string, AssetPair>()
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
Expand All @@ -155,22 +191,19 @@ async function buildExchangeRates(state: RootState): Promise<GuiExchangeRates> {
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'
}
}
}
Expand All @@ -182,23 +215,37 @@ async function buildExchangeRates(state: RootState): Promise<GuiExchangeRates> {
} 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
}
9 changes: 0 additions & 9 deletions src/util/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>

export async function asyncWaterfall(asyncFuncs: AsyncFunction[], timeoutMs: number = 5000): Promise<any> {
Expand Down
Loading