[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/EdgeApp/edge-react-gui/develop/scripts/ratesCacheReplay.ts [Back]  [Original]

// ---------------------------------------------------------------------------
// 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
})

Web Proxy Viewer  |  New URL  |  Original Page