Skip to content

Commit ef8089b

Browse files
authored
Merge pull request #5447 from EdgeApp/william/fix-rate-cache
Reorganize & clean exchange-rate logic
2 parents b4e2393 + d47fe40 commit ef8089b

File tree

2 files changed

+130
-92
lines changed

2 files changed

+130
-92
lines changed

src/actions/ExchangeRateActions.ts

Lines changed: 130 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { makeReactNativeDisklet } from 'disklet'
55
import { RootState, ThunkAction } from '../types/reduxTypes'
66
import { GuiExchangeRates } from '../types/types'
77
import { fetchRates } from '../util/network'
8-
import { datelog, DECIMAL_PRECISION, getYesterdayDateRoundDownHour } from '../util/utils'
8+
import { datelog, DECIMAL_PRECISION } from '../util/utils'
99

1010
const disklet = makeReactNativeDisklet()
1111
const EXCHANGE_RATES_FILENAME = 'exchangeRates.json'
@@ -14,8 +14,18 @@ const HOUR_MS = 1000 * 60 * 60
1414
const ONE_DAY = 1000 * 60 * 60 * 24
1515
const ONE_MONTH = 1000 * 60 * 60 * 24 * 30
1616

17-
const asAssetPair = asObject({ currency_pair: asString, date: asOptional(asString), expiration: asNumber })
18-
const asExchangeRateCache = asObject(asObject({ expiration: asNumber, rate: asString }))
17+
const asAssetPair = asObject({
18+
currency_pair: asString,
19+
date: asOptional(asString), // Defaults to today if not specified
20+
expiration: asNumber
21+
})
22+
23+
const asExchangeRateCache = asObject(
24+
asObject({
25+
expiration: asNumber,
26+
rate: asString
27+
})
28+
)
1929
const asExchangeRateCacheFile = asObject({
2030
rates: asExchangeRateCache,
2131
assetPairs: asArray(asAssetPair)
@@ -25,7 +35,7 @@ type AssetPair = ReturnType<typeof asAssetPair>
2535
type ExchangeRateCache = ReturnType<typeof asExchangeRateCache>
2636
type ExchangeRateCacheFile = ReturnType<typeof asExchangeRateCacheFile>
2737

28-
const exchangeRateCache: ExchangeRateCache = {}
38+
let exchangeRateCache: ExchangeRateCache = {}
2939

3040
const asRatesResponse = asObject({
3141
data: asArray(
@@ -48,49 +58,48 @@ export function updateExchangeRates(): ThunkAction<Promise<void>> {
4858
}
4959
}
5060

51-
/**
52-
* Remove duplicates and expired entries from the given array of AssetPair.
53-
* If two items share the same currency_pair and date,
54-
* only keep the one with the higher expiration.
55-
*/
56-
function filterAssetPairs(assetPairs: AssetPair[]): AssetPair[] {
57-
const map = new Map<string, AssetPair>()
58-
const now = Date.now()
59-
for (const asset of assetPairs) {
60-
if (asset.expiration < now) continue
61-
// Construct a key based on currency_pair and date (including handling for empty/undefined date)
62-
const key = `${asset.currency_pair}_${asset.date ?? ''}`
63-
64-
const existing = map.get(key)
65-
if (existing == null || asset.expiration > existing.expiration) {
66-
map.set(key, asset)
67-
}
68-
}
69-
70-
return [...map.values()]
71-
}
72-
7361
async function buildExchangeRates(state: RootState): Promise<GuiExchangeRates> {
62+
const accountIsoFiat = state.ui.settings.defaultIsoFiat
7463
const { account } = state.core
7564
const { currencyWallets } = account
65+
66+
// Look up various dates:
7667
const now = Date.now()
77-
const initialAssetPairs: AssetPair[] = []
78-
let numCacheEntries = 0
68+
const pairExpiration = now + ONE_MONTH
69+
const rateExpiration = now + ONE_DAY
70+
const yesterday = getYesterdayDateRoundDownHour(now).toISOString()
7971

80-
// Load exchange rate cache off disk
81-
if (Object.keys(exchangeRateCache).length === 0) {
72+
// What we need to fetch from the server:
73+
const initialAssetPairs: AssetPair[] = []
74+
let hasWallets = false
75+
let hasCachedRates = false
76+
77+
// If we have loaded the cache before, keep any un-expired entries:
78+
const rateCache: ExchangeRateCache = {}
79+
const cachedKeys = Object.keys(exchangeRateCache)
80+
if (cachedKeys.length > 0) {
81+
for (const key of cachedKeys) {
82+
if (exchangeRateCache[key].expiration > now) {
83+
rateCache[key] = exchangeRateCache[key]
84+
hasCachedRates = true
85+
}
86+
}
87+
} else {
88+
// Load exchange rate cache off disk, since we haven't done that yet:
8289
try {
8390
const raw = await disklet.getText(EXCHANGE_RATES_FILENAME)
8491
const json = JSON.parse(raw)
85-
const exchangeRateCacheFile = asExchangeRateCacheFile(json)
86-
const { assetPairs, rates } = exchangeRateCacheFile
87-
// Prune expired rates
92+
const { assetPairs, rates } = asExchangeRateCacheFile(json)
93+
94+
// Keep un-expired rates:
8895
for (const key of Object.keys(rates)) {
8996
if (rates[key].expiration > now) {
90-
exchangeRateCache[key] = rates[key]
91-
numCacheEntries++
97+
rateCache[key] = rates[key]
98+
hasCachedRates = true
9299
}
93100
}
101+
102+
// Keep un-expired asset pairs:
94103
for (const pair of assetPairs) {
95104
if (pair.expiration > now) {
96105
initialAssetPairs.push(pair)
@@ -100,43 +109,70 @@ async function buildExchangeRates(state: RootState): Promise<GuiExchangeRates> {
100109
datelog('Error loading exchange rate cache:', String(e))
101110
}
102111
}
103-
const accountIsoFiat = state.ui.settings.defaultIsoFiat
104112

105-
const expiration = now + ONE_MONTH
106-
const yesterdayDate = getYesterdayDateRoundDownHour()
113+
// If the user's fiat isn't dollars, get it's price:
107114
if (accountIsoFiat !== 'iso:USD') {
108-
initialAssetPairs.push({ currency_pair: `iso:USD_${accountIsoFiat}`, date: undefined, expiration })
115+
initialAssetPairs.push({
116+
currency_pair: `iso:USD_${accountIsoFiat}`,
117+
date: undefined,
118+
expiration: pairExpiration
119+
})
109120
}
110-
const walletIds = Object.keys(currencyWallets)
111-
for (const id of walletIds) {
112-
const wallet = currencyWallets[id]
113-
const currencyCode = wallet.currencyInfo.currencyCode
114-
// need to get both forward and backwards exchange rates for wallets & account fiats, for each parent currency AND each token
115-
initialAssetPairs.push({ currency_pair: `${currencyCode}_${accountIsoFiat}`, date: undefined, expiration })
116-
initialAssetPairs.push({ currency_pair: `${currencyCode}_iso:USD`, date: `${yesterdayDate}`, expiration })
117-
// now add tokens, if they exist
118-
if (accountIsoFiat !== 'iso:USD') {
119-
initialAssetPairs.push({ currency_pair: `iso:USD_${accountIsoFiat}`, date: undefined, expiration })
120-
}
121+
122+
for (const walletId of Object.keys(currencyWallets)) {
123+
const wallet = currencyWallets[walletId]
124+
const { currencyCode } = wallet.currencyInfo
125+
hasWallets = true
126+
127+
// Get the primary asset's prices for today and yesterday,
128+
// but with yesterday's price in dollars:
129+
initialAssetPairs.push({
130+
currency_pair: `${currencyCode}_${accountIsoFiat}`,
131+
date: undefined,
132+
expiration: pairExpiration
133+
})
134+
initialAssetPairs.push({
135+
currency_pair: `${currencyCode}_iso:USD`,
136+
date: yesterday,
137+
expiration: pairExpiration
138+
})
139+
140+
// Do the same for any tokens:
121141
for (const tokenId of wallet.enabledTokenIds) {
122-
if (wallet.currencyConfig.allTokens[tokenId] == null) continue
123-
const { currencyCode: tokenCode } = wallet.currencyConfig.allTokens[tokenId]
124-
if (tokenCode !== currencyCode) {
125-
initialAssetPairs.push({ currency_pair: `${tokenCode}_${accountIsoFiat}`, date: undefined, expiration })
126-
initialAssetPairs.push({ currency_pair: `${tokenCode}_iso:USD`, date: `${yesterdayDate}`, expiration })
127-
}
142+
const token = wallet.currencyConfig.allTokens[tokenId]
143+
if (token == null) continue
144+
if (token.currencyCode === currencyCode) continue
145+
initialAssetPairs.push({
146+
currency_pair: `${token.currencyCode}_${accountIsoFiat}`,
147+
date: undefined,
148+
expiration: pairExpiration
149+
})
150+
initialAssetPairs.push({
151+
currency_pair: `${token.currencyCode}_iso:USD`,
152+
date: yesterday,
153+
expiration: pairExpiration
154+
})
128155
}
129156
}
130157

131-
const filteredAssetPairs = filterAssetPairs(initialAssetPairs)
132-
const assetPairs = [...filteredAssetPairs]
158+
// De-duplicate asset pairs:
159+
const assetMap = new Map<string, AssetPair>()
160+
for (const asset of initialAssetPairs) {
161+
const key = `${asset.currency_pair}_${asset.date ?? ''}`
162+
163+
const existing = assetMap.get(key)
164+
if (existing == null || asset.expiration > existing.expiration) {
165+
assetMap.set(key, asset)
166+
}
167+
}
168+
const filteredAssetPairs = [...assetMap.values()]
133169

134170
/**
135171
* On initial load, buildExchangeRates may get called before any wallets are
136172
* loaded. In this case, we can skip the rates fetch and use the cache to
137173
* save on the network delay.
138174
*/
139-
const skipRatesFetch = walletIds.length === 0 && numCacheEntries > 0
175+
const skipRatesFetch = hasCachedRates && !hasWallets
140176

141177
while (filteredAssetPairs.length > 0) {
142178
if (skipRatesFetch) break
@@ -155,22 +191,19 @@ async function buildExchangeRates(state: RootState): Promise<GuiExchangeRates> {
155191
const cleanedRates = asRatesResponse(json)
156192
for (const rate of cleanedRates.data) {
157193
const { currency_pair: currencyPair, exchangeRate, date } = rate
158-
const newDate = new Date(date).valueOf()
194+
const isHistorical = now - new Date(date).valueOf() > HOUR_MS
195+
const key = isHistorical ? `${currencyPair}_${date}` : currencyPair
159196

160-
const key = now - newDate > HOUR_MS ? `${currencyPair}_${date}` : currencyPair
161-
const cachedRate = exchangeRateCache[key] ?? { expiration: 0, rate: '0' }
162197
if (exchangeRate != null) {
163-
cachedRate.rate = exchangeRate
164-
cachedRate.expiration = now + ONE_DAY
165-
}
166-
exchangeRateCache[key] = cachedRate
167-
168-
const codes = key.split('_')
169-
const reverseExchangeRateKey = `${codes[1]}_${codes[0]}${codes[2] ? '_' + codes[2] : ''}`
170-
if (exchangeRateCache[reverseExchangeRateKey] == null) {
171-
exchangeRateCache[reverseExchangeRateKey] = { expiration: cachedRate.expiration, rate: '0' }
172-
if (!eq(cachedRate.rate, '0')) {
173-
exchangeRateCache[reverseExchangeRateKey].rate = div('1', cachedRate.rate, DECIMAL_PRECISION)
198+
rateCache[key] = {
199+
expiration: rateExpiration,
200+
rate: exchangeRate
201+
}
202+
} else if (rateCache[key] == null) {
203+
// We at least need a placeholder:
204+
rateCache[key] = {
205+
expiration: 0,
206+
rate: '0'
174207
}
175208
}
176209
}
@@ -182,23 +215,37 @@ async function buildExchangeRates(state: RootState): Promise<GuiExchangeRates> {
182215
} while (--tries > 0)
183216
}
184217

185-
// Save exchange rate cache to disk
218+
// Save exchange rate cache to disk:
186219
try {
187-
const exchangeRateCacheFile: ExchangeRateCacheFile = { rates: exchangeRateCache, assetPairs }
220+
const exchangeRateCacheFile: ExchangeRateCacheFile = {
221+
rates: rateCache,
222+
assetPairs: filteredAssetPairs
223+
}
188224
await disklet.setText(EXCHANGE_RATES_FILENAME, JSON.stringify(exchangeRateCacheFile))
189225
} catch (e) {
190226
datelog('Error saving exchange rate cache:', String(e))
191227
}
228+
exchangeRateCache = rateCache
192229

230+
// Build the GUI rate structure:
193231
const serverRates: GuiExchangeRates = { 'iso:USD_iso:USD': '1' }
194-
for (const key of Object.keys(exchangeRateCache)) {
195-
const rate = exchangeRateCache[key]
196-
if (rate.expiration > now) {
197-
serverRates[key] = rate.rate
198-
} else {
199-
delete exchangeRateCache[key]
200-
}
232+
for (const key of Object.keys(rateCache)) {
233+
const { rate } = rateCache[key]
234+
serverRates[key] = rate
235+
236+
// Include reverse rates:
237+
const codes = key.split('_')
238+
const reverseKey = `${codes[1]}_${codes[0]}${codes[2] ? '_' + codes[2] : ''}`
239+
serverRates[reverseKey] = eq(rate, '0') ? '0' : div('1', rate, DECIMAL_PRECISION)
201240
}
202-
203241
return serverRates
204242
}
243+
244+
const getYesterdayDateRoundDownHour = (now?: Date | number): Date => {
245+
const yesterday = now == null ? new Date() : new Date(now)
246+
yesterday.setMinutes(0)
247+
yesterday.setSeconds(0)
248+
yesterday.setMilliseconds(0)
249+
yesterday.setDate(yesterday.getDate() - 1)
250+
return yesterday
251+
}

src/util/utils.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -338,15 +338,6 @@ export const getTotalFiatAmountFromExchangeRates = (state: RootState, isoFiatCur
338338
return total
339339
}
340340

341-
export const getYesterdayDateRoundDownHour = () => {
342-
const date = new Date()
343-
date.setMinutes(0)
344-
date.setSeconds(0)
345-
date.setMilliseconds(0)
346-
const yesterday = date.setDate(date.getDate() - 1)
347-
return new Date(yesterday).toISOString()
348-
}
349-
350341
type AsyncFunction = () => Promise<any>
351342

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

0 commit comments

Comments
 (0)