// ---------------------------------------------------------------------------
// 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
fiat: Array
}
interface RatesResponse {
crypto?: Array
fiat?: Array
}
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
})