Skip to content

Commit

Permalink
Reorganize & clean exchange-rate logic
Browse files Browse the repository at this point in the history
Pull request #5423 contained a bunch of logic errors, so clean everything up in a way that makes it less confusing and less buggy.

- Break up long lines.
- Fix misleading comments.
- Give variables better names.
- Don't copy data needlessly.
- Stomp old cache entries instead of mutating them.
- Only add the account fiat pair once, instead of in two separate places.
- Expire cache entries once a load-time, instead of doing this in three separate places.
- Do not store reverse rates in the cache, but derive them at the very end.
  • Loading branch information
swansontec committed Feb 11, 2025
1 parent b4e2393 commit d47fe40
Show file tree
Hide file tree
Showing 2 changed files with 130 additions and 92 deletions.
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

0 comments on commit d47fe40

Please sign in to comment.