diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a81bda8397..eb6f5121988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased (develop) +- added: Verbose logging for exchange rate queries: the request body, resolved/rate-less counts, and errors are captured when the Verbose Logging setting is enabled. +- added: Exchange-rate cache snapshot in the support log output, plus a `rates-cache-replay` script that re-runs those queries against the rates server and reports the result for each pair. +- fixed: Exchange rate queries no longer request each chain's own asset twice, which had been inflating every rate query with duplicate pairs. + ## 4.50.0 (2026-07-21) - added: Changelly swap provider diff --git a/package.json b/package.json index 9f48c5c3bb1..98cfd034840 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "precommit": "npm run localize && npm run update-eslint-warnings && lint-staged && tsc && npm test", "prepare.ios": "(cd ios; pod repo update; pod install)", "prepare": "husky install && ./scripts/prepare.sh", + "rates-cache-replay": "node -r sucrase/register scripts/ratesCacheReplay.ts", "server": "node ./loggingServer.js", "start": "react-native start", "test": "TZ=America/Los_Angeles jest", diff --git a/scripts/ratesCacheReplay.ts b/scripts/ratesCacheReplay.ts new file mode 100644 index 00000000000..1e4a17fb566 --- /dev/null +++ b/scripts/ratesCacheReplay.ts @@ -0,0 +1,317 @@ +// --------------------------------------------------------------------------- +// ratesCacheReplay +// +// Replays a logged exchange-rate cache blob (the `***Exchange Rate Cache***` +// section captured in the support logs, or any JSON matching that shape) +// against the rates server: it rebuilds the `v3/rates` queries the app would +// have sent for the pairs in the blob, sends them, and reports what the server +// returns for every requested pair — so a rate problem seen in a user's logs +// can be reproduced and inspected directly. +// +// Query construction mirrors `convertToRatesParams` in +// `src/actions/ExchangeRateActions.ts` — keep the two in sync. Only the +// subscribed pairs present in the blob (`cryptoPairs` / `fiatPairs`) are +// replayed; wallet-derived and historical pairs the app adds at runtime are +// not part of the cache and so are not reproduced here. +// +// Each requested pair is reported as one of: +// the server returned a rate +// NO RATE the server returned the pair but declined to price it +// MISSING the server omitted the pair from its response entirely +// +// Usage: +// node -r sucrase/register scripts/ratesCacheReplay.ts +// pbpaste | node -r sucrase/register scripts/ratesCacheReplay.ts +// node -r sucrase/register scripts/ratesCacheReplay.ts --server https://rates4.edge.app +// node -r sucrase/register scripts/ratesCacheReplay.ts --dry-run +// +// Input may be the bare cache JSON or a log excerpt containing it — the first +// complete JSON object in the text is used. `--server` picks which rates host +// to hit (default rates3; point it at rates4 to compare hosts). `--dry-run` +// prints the query bodies without sending them. +// --------------------------------------------------------------------------- + +import * as fs from 'fs' + +// Mirrors RATES_SERVER_MAX_QUERY_SIZE in ExchangeRateActions.ts: +const RATES_SERVER_MAX_QUERY_SIZE = 100 +// Default v3 rate server (see RATES_SERVERS in src/util/network.ts): +const DEFAULT_RATES_SERVER = 'https://rates3.edge.app' + +// Mirrors removeIsoPrefix in src/util/utils.ts: +const removeIsoPrefix = (currencyCode: string): string => + currencyCode.replace('iso:', '') + +interface CryptoAsset { + pluginId: string + tokenId?: string | null +} +interface CryptoFiatPair { + asset: CryptoAsset + targetFiat: string + isoDate?: string + expiration: number +} +interface FiatFiatPair { + fiatCode: string + targetFiat: string + isoDate?: string + expiration: number +} +interface RateCacheBlob { + cryptoPairs?: CryptoFiatPair[] + fiatPairs?: FiatFiatPair[] +} + +interface RatesQuery { + targetFiat: string + crypto: Array<{ isoDate: string; asset: CryptoAsset }> + fiat: Array<{ isoDate: string; fiatCode: string }> +} +interface RatesResponse { + crypto?: Array<{ asset?: CryptoAsset; rate?: number | null }> + fiat?: Array<{ fiatCode?: string; rate?: number | null }> +} + +type Outcome = 'resolved' | 'no-rate' | 'missing' +interface PairResult { + label: string + outcome: Outcome + rate?: number | null +} + +/** Identity for matching a response entry back to the pair we asked for. */ +const cryptoKey = (asset: CryptoAsset): string => + `${asset.pluginId}:${asset.tokenId ?? ''}` + +/** + * Group the cached pairs by targetFiat and chunk them into query bodies, + * exactly as convertToRatesParams does in the app. + */ +function cacheToQueries(blob: RateCacheBlob, isoNow: string): RatesQuery[] { + const cryptoPairs = blob.cryptoPairs ?? [] + const fiatPairs = blob.fiatPairs ?? [] + + const grouped = new Map< + string, + { crypto: CryptoFiatPair[]; fiat: FiatFiatPair[] } + >() + const bucket = ( + targetFiat: string + ): { crypto: CryptoFiatPair[]; fiat: FiatFiatPair[] } => { + let entry = grouped.get(targetFiat) + if (entry == null) { + entry = { crypto: [], fiat: [] } + grouped.set(targetFiat, entry) + } + return entry + } + for (const pair of cryptoPairs) bucket(pair.targetFiat).crypto.push(pair) + for (const pair of fiatPairs) bucket(pair.targetFiat).fiat.push(pair) + + const queries: RatesQuery[] = [] + for (const [targetFiat, { crypto, fiat }] of grouped.entries()) { + const remainingCrypto = [...crypto] + const remainingFiat = [...fiat] + while (remainingCrypto.length > 0 || remainingFiat.length > 0) { + const cryptoChunk = remainingCrypto.splice(0, RATES_SERVER_MAX_QUERY_SIZE) + const fiatChunk = remainingFiat.splice(0, RATES_SERVER_MAX_QUERY_SIZE) + queries.push({ + targetFiat: removeIsoPrefix(targetFiat), + crypto: cryptoChunk.map(pair => ({ + isoDate: + pair.isoDate == null + ? isoNow + : new Date(pair.isoDate).toISOString(), + asset: pair.asset + })), + fiat: fiatChunk.map(pair => ({ + isoDate: + pair.isoDate == null + ? isoNow + : new Date(pair.isoDate).toISOString(), + fiatCode: removeIsoPrefix(pair.fiatCode) + })) + }) + } + } + return queries +} + +/** + * Pull the first complete JSON object out of arbitrary text, so a pasted log + * excerpt (with surrounding lines) works as well as bare JSON. + */ +function extractFirstJsonObject(text: string): string { + const start = text.indexOf('{') + if (start < 0) throw new Error('No JSON object found in input') + let depth = 0 + let inString = false + let escaped = false + for (let i = start; i < text.length; i++) { + const ch = text[i] + if (inString) { + if (escaped) escaped = false + else if (ch === '\\') escaped = true + else if (ch === '"') inString = false + continue + } + if (ch === '"') inString = true + else if (ch === '{') depth++ + else if (ch === '}') { + depth-- + if (depth === 0) return text.slice(start, i + 1) + } + } + throw new Error('Unbalanced JSON object in input') +} + +/** + * Match every pair we asked for against what the server actually returned. + */ +function matchResults( + query: RatesQuery, + response: RatesResponse +): PairResult[] { + const cryptoByKey = new Map() + for (const entry of response.crypto ?? []) { + if (entry.asset != null) cryptoByKey.set(cryptoKey(entry.asset), entry) + } + const fiatByCode = new Map() + for (const entry of response.fiat ?? []) { + if (entry.fiatCode != null) fiatByCode.set(entry.fiatCode, entry) + } + + const results: PairResult[] = [] + for (const requested of query.crypto) { + const label = + requested.asset.tokenId == null + ? requested.asset.pluginId + : `${requested.asset.pluginId}:${requested.asset.tokenId}` + const found = cryptoByKey.get(cryptoKey(requested.asset)) + if (found == null) results.push({ label, outcome: 'missing' }) + else if (found.rate == null) results.push({ label, outcome: 'no-rate' }) + else results.push({ label, outcome: 'resolved', rate: found.rate }) + } + for (const requested of query.fiat) { + const label = `${requested.fiatCode} (fiat)` + const found = fiatByCode.get(requested.fiatCode) + if (found == null) results.push({ label, outcome: 'missing' }) + else if (found.rate == null) results.push({ label, outcome: 'no-rate' }) + else results.push({ label, outcome: 'resolved', rate: found.rate }) + } + return results +} + +function printResults(results: PairResult[]): void { + const width = results.reduce((max, r) => Math.max(max, r.label.length), 0) + for (const result of results) { + const value = + result.outcome === 'resolved' + ? String(result.rate) + : result.outcome.toUpperCase() + console.log(` ${result.label.padEnd(width)} ${value}`) + } +} + +async function main(): Promise { + const argv = process.argv.slice(2) + let server = DEFAULT_RATES_SERVER + let dryRun = false + let file: string | undefined + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === '--dry-run') dryRun = true + else if (arg === '--server') server = argv[++i] + else if (!arg.startsWith('--')) file = arg + } + + const raw = + file != null && file !== '-' + ? fs.readFileSync(file, 'utf8') + : fs.readFileSync(0, 'utf8') + + const blob = JSON.parse(extractFirstJsonObject(raw)) as RateCacheBlob + const queries = cacheToQueries(blob, new Date().toISOString()) + const cryptoCount = blob.cryptoPairs?.length ?? 0 + const fiatCount = blob.fiatPairs?.length ?? 0 + console.log( + `${queries.length} quer${ + queries.length === 1 ? 'y' : 'ies' + } from ${cryptoCount} crypto + ${fiatCount} fiat cached pairs` + ) + + if (dryRun) { + for (const query of queries) console.log(JSON.stringify(query)) + return + } + + const totals = { resolved: 0, 'no-rate': 0, missing: 0 } + let failedQueries = 0 + + for (const [index, query] of queries.entries()) { + console.log( + `\nquery ${index} targetFiat=${query.targetFiat} ${query.crypto.length} crypto + ${query.fiat.length} fiat -> ${server}/v3/rates` + ) + const started = Date.now() + let response + try { + response = await fetch(`${server}/v3/rates`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(query) + }) + } catch (error) { + failedQueries++ + console.log(` request failed: ${String(error)}`) + continue + } + const elapsed = Date.now() - started + const text = await response.text() + if (!response.ok) { + failedQueries++ + console.log( + ` HTTP ${response.status} (${elapsed}ms): ${text.slice(0, 300)}` + ) + continue + } + + let parsed: RatesResponse + try { + parsed = JSON.parse(text) + } catch (error) { + failedQueries++ + console.log( + ` HTTP 200 (${elapsed}ms) but unparseable body: ${text.slice(0, 300)}` + ) + continue + } + + const results = matchResults(query, parsed) + console.log(` HTTP ${response.status} (${elapsed}ms)`) + printResults(results) + const counts = { resolved: 0, 'no-rate': 0, missing: 0 } + for (const result of results) counts[result.outcome]++ + for (const key of Object.keys(counts) as Outcome[]) + totals[key] += counts[key] + console.log( + ` -> ${counts.resolved} resolved, ${counts['no-rate']} no rate, ${counts.missing} missing` + ) + } + + console.log( + `\nTOTAL: ${totals.resolved} resolved, ${totals['no-rate']} no rate, ${ + totals.missing + } missing across ${queries.length} quer${ + queries.length === 1 ? 'y' : 'ies' + }${failedQueries > 0 ? ` (${failedQueries} failed)` : ''}` + ) + if (totals.missing > 0 || totals['no-rate'] > 0 || failedQueries > 0) { + process.exitCode = 1 + } +} + +main().catch((error: unknown) => { + console.error(String(error)) + process.exitCode = 1 +}) diff --git a/src/__tests__/actions/ExchangeRateActions.test.ts b/src/__tests__/actions/ExchangeRateActions.test.ts new file mode 100644 index 00000000000..0149d1e4bda --- /dev/null +++ b/src/__tests__/actions/ExchangeRateActions.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from '@jest/globals' + +import { + type ExchangeRateCache, + mergePairCache +} from '../../actions/ExchangeRateActions' + +type PairCache = ReturnType + +const PAIR_EXPIRATION = 5000 +const rateEntry = { + current: 1, + yesterday: 1, + yesterdayTimestamp: 0, + expiration: 0 +} + +const noRates: ExchangeRateCache = { crypto: {}, fiat: {} } +const emptyCache: PairCache = { cryptoPairs: [], fiatPairs: [] } + +describe('mergePairCache', () => { + it('collapses a pair stored without a tokenId and one stored with null', () => { + // Older cache entries were written without a `tokenId` key at all, so they + // read back as undefined, while newer ones carry an explicit null. Both + // mean "the chain's own asset". + const previous: PairCache = { + cryptoPairs: [ + { + asset: { pluginId: 'bitcoin', tokenId: undefined }, + targetFiat: 'iso:USD', + isoDate: undefined, + expiration: 1 + }, + { + asset: { pluginId: 'bitcoin', tokenId: null }, + targetFiat: 'iso:USD', + isoDate: undefined, + expiration: 1 + } + ], + fiatPairs: [] + } + + const out = mergePairCache(noRates, previous, PAIR_EXPIRATION) + + expect(out.cryptoPairs).toHaveLength(1) + }) + + it('does not append a duplicate for a chain asset already cached', () => { + const previous: PairCache = { + cryptoPairs: [ + // Written with no tokenId key, so it reads back off disk as undefined: + { + asset: { pluginId: 'bitcoin', tokenId: undefined }, + targetFiat: 'iso:USD', + isoDate: undefined, + expiration: 1 + } + ], + fiatPairs: [] + } + const rates: ExchangeRateCache = { + crypto: { bitcoin: { '': { 'iso:USD': rateEntry } } }, + fiat: {} + } + + const out = mergePairCache(rates, previous, PAIR_EXPIRATION) + + expect(out.cryptoPairs).toHaveLength(1) + expect(out.cryptoPairs[0].asset.tokenId).toBeNull() + expect(out.cryptoPairs[0].expiration).toBe(PAIR_EXPIRATION) + }) + + it('stays stable across repeated merges', () => { + const rates: ExchangeRateCache = { + crypto: { bitcoin: { '': { 'iso:USD': rateEntry } } }, + fiat: { 'iso:EUR': { 'iso:USD': rateEntry } } + } + + let cache = emptyCache + for (let i = 0; i < 5; i++) { + cache = mergePairCache(rates, cache, PAIR_EXPIRATION) + } + + expect(cache.cryptoPairs).toHaveLength(1) + expect(cache.fiatPairs).toHaveLength(1) + }) + + it('keeps tokens separate from their chain asset', () => { + const rates: ExchangeRateCache = { + crypto: { + ethereum: { + '': { 'iso:USD': rateEntry }, + a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48: { 'iso:USD': rateEntry } + } + }, + fiat: {} + } + + const out = mergePairCache(rates, emptyCache, PAIR_EXPIRATION) + + const tokenIds = out.cryptoPairs.map(pair => pair.asset.tokenId) + expect(tokenIds).toHaveLength(2) + expect(tokenIds).toContain(null) + expect(tokenIds).toContain('a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48') + }) + + it('keeps the same asset against different target fiats separate', () => { + const rates: ExchangeRateCache = { + crypto: { + bitcoin: { '': { 'iso:USD': rateEntry, 'iso:EUR': rateEntry } } + }, + fiat: {} + } + + const out = mergePairCache(rates, emptyCache, PAIR_EXPIRATION) + + expect(out.cryptoPairs).toHaveLength(2) + }) +}) diff --git a/src/actions/ExchangeRateActions.ts b/src/actions/ExchangeRateActions.ts index ebb6a79247d..0f6995118d9 100644 --- a/src/actions/ExchangeRateActions.ts +++ b/src/actions/ExchangeRateActions.ts @@ -8,6 +8,7 @@ import { asRatesParams, type RatesParams } from '../util/exchangeRates' +import { log } from '../util/logger' import { fetchRates } from '../util/network' import { datelog, fixFiatCurrencyCode, removeIsoPrefix } from '../util/utils' @@ -73,11 +74,21 @@ type ExchangeRateCacheFile = ReturnType let exchangeRateCache: ExchangeRateCacheFile | undefined +/** + * Returns the in-memory exchange-rate cache, including the subscribed + * pair lists, for inclusion in support log output. The logged blob can be + * replayed against the rates server with `scripts/ratesCacheReplay.ts`. + */ +export function getExchangeRateCacheDump(): ExchangeRateCacheFile | undefined { + return exchangeRateCache +} + export function updateExchangeRates(): ThunkAction> { return async (dispatch, getState) => { const state = getState() - const { account } = state.core + const { account, context } = state.core const { defaultIsoFiat } = state.ui.settings + const verbose = context.logSettings?.defaultLogLevel === 'info' const now = Date.now() const yesterday = getYesterdayDateRoundDownHour(now).toISOString() @@ -112,7 +123,8 @@ export function updateExchangeRates(): ThunkAction> { defaultIsoFiat, exchangeRateCache, now, - yesterday + yesterday, + verbose ) dispatch({ @@ -182,13 +194,16 @@ async function loadExchangeRateCache(): Promise { /** * Fetches exchange rates from the server, and writes them out to disk. + * When `verbose` is set, requested pairs, resolved pairs, and errors are + * written to the captured logs. */ async function fetchExchangeRates( account: EdgeAccount, accountIsoFiat: string, cache: ExchangeRateCacheFile, now: number, - yesterday: string + yesterday: string, + verbose: boolean ): Promise { const { currencyWallets } = account @@ -318,11 +333,17 @@ async function fetchExchangeRates( } const requests = convertToRatesParams(cryptoPairMap, fiatPairMap) - const promises = requests.map(async query => { + const promises = requests.map(async (query, queryIndex) => { + // Log the exact request body so it can be replayed verbatim against the + // rates server (e.g. `curl -X POST .../v3/rates -d `): + const body = JSON.stringify(query) + if (verbose) { + log(`rates query ${queryIndex} request: ${body}`) + } const options = { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(query) + body } try { const response = await fetchRates('v3/rates', options) @@ -331,6 +352,30 @@ async function fetchExchangeRates( const cleanedRates = asRatesParams(json) const targetFiat = fixFiatCurrencyCode(cleanedRates.targetFiat) + if (verbose) { + // The requested pairs are already logged above (request body), so + // here we only summarize the outcome and name the pairs the server + // returned without a rate: + let resolvedCount = 0 + const noRateKeys: string[] = [] + for (const entry of cleanedRates.crypto) { + if (entry.rate != null) resolvedCount++ + else + noRateKeys.push(cryptoRateLogKey(entry, cleanedRates.targetFiat)) + } + for (const entry of cleanedRates.fiat) { + if (entry.rate != null) resolvedCount++ + else noRateKeys.push(fiatRateLogKey(entry, cleanedRates.targetFiat)) + } + log( + `rates query ${queryIndex} result: ${resolvedCount} resolved, ${ + noRateKeys.length + } no-rate${ + noRateKeys.length > 0 ? `: ${noRateKeys.join(', ')}` : '' + }` + ) + } + for (const cryptoRate of cleanedRates.crypto) { const { asset, isoDate, rate } = cryptoRate if (rate == null) continue @@ -408,8 +453,23 @@ async function fetchExchangeRates( rateObj.expiration = rateExpiration } + } else if (verbose) { + const text = await response.text() + log( + `rates query ${queryIndex} failed: HTTP ${ + response.status + }: ${text.slice(0, 200)}` + ) } } catch (error: unknown) { + if (verbose) { + // Pass an Error through as-is: `log` renders it with its stack, which + // names the call site that failed. Stringifying it here would lose that. + log( + `rates query ${queryIndex} error:`, + error instanceof Error ? error : String(error) + ) + } console.log( `buildExchangeRates error querying rates server ${String(error)}` ) @@ -418,58 +478,16 @@ async function fetchExchangeRates( await Promise.allSettled(promises) // Merge successful rate responses into the pair cache - const cryptoPairCache = [...(exchangeRateCache?.cryptoPairs ?? [])] - const fiatPairCache = [...(exchangeRateCache?.fiatPairs ?? [])] - for (const [pluginId, tokenObj] of Object.entries(rates.crypto)) { - for (const [tokenId, rateObj] of Object.entries(tokenObj)) { - for (const targetFiat of Object.keys(rateObj)) { - const edgeTokenId = tokenId === '' ? null : tokenId - const cryptoPairIndex = cryptoPairCache.findIndex( - pair => - pair.asset.pluginId === pluginId && - pair.asset.tokenId === edgeTokenId && - pair.targetFiat === targetFiat - ) - if (cryptoPairIndex === -1) { - cryptoPairCache.push({ - asset: { pluginId, tokenId: edgeTokenId }, - targetFiat, - isoDate: undefined, - expiration: pairExpiration - }) - } else { - cryptoPairCache[cryptoPairIndex] = { - asset: { pluginId, tokenId: edgeTokenId }, - targetFiat, - isoDate: undefined, - expiration: pairExpiration - } - } - } - } - } - for (const [fiatCode, fiatObj] of Object.entries(rates.fiat)) { - for (const targetFiat of Object.keys(fiatObj)) { - const fiatPairIndex = fiatPairCache.findIndex( - pair => pair.fiatCode === fiatCode && pair.targetFiat === targetFiat - ) - if (fiatPairIndex === -1) { - fiatPairCache.push({ - fiatCode, - targetFiat, - isoDate: undefined, - expiration: pairExpiration - }) - } else { - fiatPairCache[fiatPairIndex] = { - fiatCode, - targetFiat, - isoDate: undefined, - expiration: pairExpiration - } - } - } - } + const { cryptoPairs: cryptoPairCache, fiatPairs: fiatPairCache } = + mergePairCache( + rates, + { + cryptoPairs: exchangeRateCache?.cryptoPairs ?? [], + fiatPairs: exchangeRateCache?.fiatPairs ?? [] + }, + pairExpiration + ) + // Update the in-memory cache: exchangeRateCache = { rates, @@ -485,6 +503,80 @@ async function fetchExchangeRates( }) } +/** + * Key for a crypto pair. A missing `tokenId` and an explicit `null` both mean + * "the chain's own asset", so they must produce the same key — this matches + * how `loadExchangeRateCache` de-duplicates the pairs it reads from disk. + */ +const cryptoPairKey = ( + pluginId: string, + tokenId: string | null | undefined, + targetFiat: string +): string => `${pluginId}${tokenId != null ? `_${tokenId}` : ''}_${targetFiat}` + +const fiatPairKey = (fiatCode: string, targetFiat: string): string => + `${fiatCode}_${targetFiat}` + +/** + * Merges the assets we just fetched rates for into the subscribed pair lists. + * + * Pairs are keyed rather than matched by a linear scan, so an entry stored + * without a `tokenId` key and one stored with an explicit `null` collapse into + * a single pair. Comparing the two with `===` treated them as different + * assets, which appended a duplicate for every chain's own asset on each + * refresh and inflated later rate queries. + * + * Exported for unit tests. + */ +export function mergePairCache( + rates: ExchangeRateCache, + previous: { cryptoPairs: CryptoFiatPair[]; fiatPairs: FiatFiatPair[] }, + pairExpiration: number +): { cryptoPairs: CryptoFiatPair[]; fiatPairs: FiatFiatPair[] } { + const cryptoPairs = new Map() + for (const pair of previous.cryptoPairs) { + const key = cryptoPairKey( + pair.asset.pluginId, + pair.asset.tokenId, + pair.targetFiat + ) + cryptoPairs.set(key, pair) + } + for (const [pluginId, tokenObj] of Object.entries(rates.crypto)) { + for (const [tokenId, rateObj] of Object.entries(tokenObj)) { + for (const targetFiat of Object.keys(rateObj)) { + const edgeTokenId = tokenId === '' ? null : tokenId + cryptoPairs.set(cryptoPairKey(pluginId, edgeTokenId, targetFiat), { + asset: { pluginId, tokenId: edgeTokenId }, + targetFiat, + isoDate: undefined, + expiration: pairExpiration + }) + } + } + } + + const fiatPairs = new Map() + for (const pair of previous.fiatPairs) { + fiatPairs.set(fiatPairKey(pair.fiatCode, pair.targetFiat), pair) + } + for (const [fiatCode, fiatObj] of Object.entries(rates.fiat)) { + for (const targetFiat of Object.keys(fiatObj)) { + fiatPairs.set(fiatPairKey(fiatCode, targetFiat), { + fiatCode, + targetFiat, + isoDate: undefined, + expiration: pairExpiration + }) + } + } + + return { + cryptoPairs: Array.from(cryptoPairs.values()), + fiatPairs: Array.from(fiatPairs.values()) + } +} + const getYesterdayDateRoundDownHour = (now?: Date | number): Date => { const yesterday = now == null ? new Date() : new Date(now) yesterday.setMinutes(0) @@ -494,6 +586,31 @@ const getYesterdayDateRoundDownHour = (now?: Date | number): Date => { return yesterday } +/** + * Compact log key for a crypto rate entry: + * `pluginId[_tokenId]_targetFiat[@isoDate]` + */ +function cryptoRateLogKey( + entry: RatesParams['crypto'][number], + targetFiat: string +): string { + const { asset, isoDate } = entry + const tokenIdStr = asset.tokenId != null ? `_${asset.tokenId}` : '' + const dateStr = isoDate != null ? `@${isoDate.toISOString()}` : '' + return `${asset.pluginId}${tokenIdStr}_${targetFiat}${dateStr}` +} + +/** + * Compact log key for a fiat rate entry: `fiatCode_targetFiat[@isoDate]` + */ +function fiatRateLogKey( + entry: RatesParams['fiat'][number], + targetFiat: string +): string { + const dateStr = entry.isoDate != null ? `@${entry.isoDate.toISOString()}` : '' + return `${entry.fiatCode}_${targetFiat}${dateStr}` +} + /** * Convert maps to an array of RatesParams objects grouped by targetFiat. */ diff --git a/src/actions/LogActions.tsx b/src/actions/LogActions.tsx index 92254508fd1..00a5a4d6b2c 100644 --- a/src/actions/LogActions.tsx +++ b/src/actions/LogActions.tsx @@ -32,6 +32,7 @@ import { getCurrencyCode } from '../util/CurrencyInfoHelpers' import { base58 } from '../util/encoding' import { clearLogs, logWithType, readLogs } from '../util/logger' import { getOsVersion } from '../util/utils' +import { getExchangeRateCacheDump } from './ExchangeRateActions' const logsUri = 'https://logs1.edge.app/v1/log/' @@ -242,6 +243,13 @@ os: ${Platform.OS} ${Platform.Version} device: ${getBrand()} ${getDeviceId()} ` + // Include the current exchange-rate cache, so support can see which + // rate pairs the app is subscribed to and which rates it actually has: + const rateCache = getExchangeRateCacheDump() + logOutput.data += `***Exchange Rate Cache***\n${ + rateCache == null ? 'not loaded' : JSON.stringify(rateCache) + }\n` + // Get activity logs const activityLogs = await readLogs('activity') activityOutput.data += activityLogs ?? ''