Skip to content

Commit 4329aa0

Browse files
committed
Add script to replay a logged rate cache as rate-server queries
ratesCacheToQueries.ts takes the exchange-rate cache blob captured in the support logs and converts its subscribed pair lists into the v3/rates query bodies the app would send, mirroring convertToRatesParams. Emits JSON bodies or ready-to-run curl commands so a rate issue seen in a user's logs can be reproduced directly against the rates server.
1 parent 057740d commit 4329aa0

3 files changed

Lines changed: 321 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased (develop)
44

5+
- 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.
6+
- 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.
7+
58
## 4.50.0 (2026-07-21)
69

710
- added: Changelly swap provider

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"precommit": "npm run localize && npm run update-eslint-warnings && lint-staged && tsc && npm test",
4949
"prepare.ios": "(cd ios; pod repo update; pod install)",
5050
"prepare": "husky install && ./scripts/prepare.sh",
51+
"rates-cache-replay": "node -r sucrase/register scripts/ratesCacheReplay.ts",
5152
"server": "node ./loggingServer.js",
5253
"start": "react-native start",
5354
"test": "TZ=America/Los_Angeles jest",

scripts/ratesCacheReplay.ts

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
// ---------------------------------------------------------------------------
2+
// ratesCacheReplay
3+
//
4+
// Replays a logged exchange-rate cache blob (the `***Exchange Rate Cache***`
5+
// section captured in the support logs, or any JSON matching that shape)
6+
// against the rates server: it rebuilds the `v3/rates` queries the app would
7+
// have sent for the pairs in the blob, sends them, and reports what the server
8+
// returns for every requested pair — so a rate problem seen in a user's logs
9+
// can be reproduced and inspected directly.
10+
//
11+
// Query construction mirrors `convertToRatesParams` in
12+
// `src/actions/ExchangeRateActions.ts` — keep the two in sync. Only the
13+
// subscribed pairs present in the blob (`cryptoPairs` / `fiatPairs`) are
14+
// replayed; wallet-derived and historical pairs the app adds at runtime are
15+
// not part of the cache and so are not reproduced here.
16+
//
17+
// Each requested pair is reported as one of:
18+
// <rate> the server returned a rate
19+
// NO RATE the server returned the pair but declined to price it
20+
// MISSING the server omitted the pair from its response entirely
21+
//
22+
// Usage:
23+
// node -r sucrase/register scripts/ratesCacheReplay.ts <blob.json>
24+
// pbpaste | node -r sucrase/register scripts/ratesCacheReplay.ts
25+
// node -r sucrase/register scripts/ratesCacheReplay.ts <blob.json> --server https://rates4.edge.app
26+
// node -r sucrase/register scripts/ratesCacheReplay.ts <blob.json> --dry-run
27+
//
28+
// Input may be the bare cache JSON or a log excerpt containing it — the first
29+
// complete JSON object in the text is used. `--server` picks which rates host
30+
// to hit (default rates3; point it at rates4 to compare hosts). `--dry-run`
31+
// prints the query bodies without sending them.
32+
// ---------------------------------------------------------------------------
33+
34+
import * as fs from 'fs'
35+
36+
// Mirrors RATES_SERVER_MAX_QUERY_SIZE in ExchangeRateActions.ts:
37+
const RATES_SERVER_MAX_QUERY_SIZE = 100
38+
// Default v3 rate server (see RATES_SERVERS in src/util/network.ts):
39+
const DEFAULT_RATES_SERVER = 'https://rates3.edge.app'
40+
41+
// Mirrors removeIsoPrefix in src/util/utils.ts:
42+
const removeIsoPrefix = (currencyCode: string): string =>
43+
currencyCode.replace('iso:', '')
44+
45+
interface CryptoAsset {
46+
pluginId: string
47+
tokenId?: string | null
48+
}
49+
interface CryptoFiatPair {
50+
asset: CryptoAsset
51+
targetFiat: string
52+
isoDate?: string
53+
expiration: number
54+
}
55+
interface FiatFiatPair {
56+
fiatCode: string
57+
targetFiat: string
58+
isoDate?: string
59+
expiration: number
60+
}
61+
interface RateCacheBlob {
62+
cryptoPairs?: CryptoFiatPair[]
63+
fiatPairs?: FiatFiatPair[]
64+
}
65+
66+
interface RatesQuery {
67+
targetFiat: string
68+
crypto: Array<{ isoDate: string; asset: CryptoAsset }>
69+
fiat: Array<{ isoDate: string; fiatCode: string }>
70+
}
71+
interface RatesResponse {
72+
crypto?: Array<{ asset?: CryptoAsset; rate?: number | null }>
73+
fiat?: Array<{ fiatCode?: string; rate?: number | null }>
74+
}
75+
76+
type Outcome = 'resolved' | 'no-rate' | 'missing'
77+
interface PairResult {
78+
label: string
79+
outcome: Outcome
80+
rate?: number | null
81+
}
82+
83+
/** Identity for matching a response entry back to the pair we asked for. */
84+
const cryptoKey = (asset: CryptoAsset): string =>
85+
`${asset.pluginId}:${asset.tokenId ?? ''}`
86+
87+
/**
88+
* Group the cached pairs by targetFiat and chunk them into query bodies,
89+
* exactly as convertToRatesParams does in the app.
90+
*/
91+
function cacheToQueries(blob: RateCacheBlob, isoNow: string): RatesQuery[] {
92+
const cryptoPairs = blob.cryptoPairs ?? []
93+
const fiatPairs = blob.fiatPairs ?? []
94+
95+
const grouped = new Map<
96+
string,
97+
{ crypto: CryptoFiatPair[]; fiat: FiatFiatPair[] }
98+
>()
99+
const bucket = (
100+
targetFiat: string
101+
): { crypto: CryptoFiatPair[]; fiat: FiatFiatPair[] } => {
102+
let entry = grouped.get(targetFiat)
103+
if (entry == null) {
104+
entry = { crypto: [], fiat: [] }
105+
grouped.set(targetFiat, entry)
106+
}
107+
return entry
108+
}
109+
for (const pair of cryptoPairs) bucket(pair.targetFiat).crypto.push(pair)
110+
for (const pair of fiatPairs) bucket(pair.targetFiat).fiat.push(pair)
111+
112+
const queries: RatesQuery[] = []
113+
for (const [targetFiat, { crypto, fiat }] of grouped.entries()) {
114+
const remainingCrypto = [...crypto]
115+
const remainingFiat = [...fiat]
116+
while (remainingCrypto.length > 0 || remainingFiat.length > 0) {
117+
const cryptoChunk = remainingCrypto.splice(0, RATES_SERVER_MAX_QUERY_SIZE)
118+
const fiatChunk = remainingFiat.splice(0, RATES_SERVER_MAX_QUERY_SIZE)
119+
queries.push({
120+
targetFiat: removeIsoPrefix(targetFiat),
121+
crypto: cryptoChunk.map(pair => ({
122+
isoDate:
123+
pair.isoDate == null
124+
? isoNow
125+
: new Date(pair.isoDate).toISOString(),
126+
asset: pair.asset
127+
})),
128+
fiat: fiatChunk.map(pair => ({
129+
isoDate:
130+
pair.isoDate == null
131+
? isoNow
132+
: new Date(pair.isoDate).toISOString(),
133+
fiatCode: removeIsoPrefix(pair.fiatCode)
134+
}))
135+
})
136+
}
137+
}
138+
return queries
139+
}
140+
141+
/**
142+
* Pull the first complete JSON object out of arbitrary text, so a pasted log
143+
* excerpt (with surrounding lines) works as well as bare JSON.
144+
*/
145+
function extractFirstJsonObject(text: string): string {
146+
const start = text.indexOf('{')
147+
if (start < 0) throw new Error('No JSON object found in input')
148+
let depth = 0
149+
let inString = false
150+
let escaped = false
151+
for (let i = start; i < text.length; i++) {
152+
const ch = text[i]
153+
if (inString) {
154+
if (escaped) escaped = false
155+
else if (ch === '\\') escaped = true
156+
else if (ch === '"') inString = false
157+
continue
158+
}
159+
if (ch === '"') inString = true
160+
else if (ch === '{') depth++
161+
else if (ch === '}') {
162+
depth--
163+
if (depth === 0) return text.slice(start, i + 1)
164+
}
165+
}
166+
throw new Error('Unbalanced JSON object in input')
167+
}
168+
169+
/**
170+
* Match every pair we asked for against what the server actually returned.
171+
*/
172+
function matchResults(
173+
query: RatesQuery,
174+
response: RatesResponse
175+
): PairResult[] {
176+
const cryptoByKey = new Map<string, { rate?: number | null }>()
177+
for (const entry of response.crypto ?? []) {
178+
if (entry.asset != null) cryptoByKey.set(cryptoKey(entry.asset), entry)
179+
}
180+
const fiatByCode = new Map<string, { rate?: number | null }>()
181+
for (const entry of response.fiat ?? []) {
182+
if (entry.fiatCode != null) fiatByCode.set(entry.fiatCode, entry)
183+
}
184+
185+
const results: PairResult[] = []
186+
for (const requested of query.crypto) {
187+
const label =
188+
requested.asset.tokenId == null
189+
? requested.asset.pluginId
190+
: `${requested.asset.pluginId}:${requested.asset.tokenId}`
191+
const found = cryptoByKey.get(cryptoKey(requested.asset))
192+
if (found == null) results.push({ label, outcome: 'missing' })
193+
else if (found.rate == null) results.push({ label, outcome: 'no-rate' })
194+
else results.push({ label, outcome: 'resolved', rate: found.rate })
195+
}
196+
for (const requested of query.fiat) {
197+
const label = `${requested.fiatCode} (fiat)`
198+
const found = fiatByCode.get(requested.fiatCode)
199+
if (found == null) results.push({ label, outcome: 'missing' })
200+
else if (found.rate == null) results.push({ label, outcome: 'no-rate' })
201+
else results.push({ label, outcome: 'resolved', rate: found.rate })
202+
}
203+
return results
204+
}
205+
206+
function printResults(results: PairResult[]): void {
207+
const width = results.reduce((max, r) => Math.max(max, r.label.length), 0)
208+
for (const result of results) {
209+
const value =
210+
result.outcome === 'resolved'
211+
? String(result.rate)
212+
: result.outcome.toUpperCase()
213+
console.log(` ${result.label.padEnd(width)} ${value}`)
214+
}
215+
}
216+
217+
async function main(): Promise<void> {
218+
const argv = process.argv.slice(2)
219+
let server = DEFAULT_RATES_SERVER
220+
let dryRun = false
221+
let file: string | undefined
222+
for (let i = 0; i < argv.length; i++) {
223+
const arg = argv[i]
224+
if (arg === '--dry-run') dryRun = true
225+
else if (arg === '--server') server = argv[++i]
226+
else if (!arg.startsWith('--')) file = arg
227+
}
228+
229+
const raw =
230+
file != null && file !== '-'
231+
? fs.readFileSync(file, 'utf8')
232+
: fs.readFileSync(0, 'utf8')
233+
234+
const blob = JSON.parse(extractFirstJsonObject(raw)) as RateCacheBlob
235+
const queries = cacheToQueries(blob, new Date().toISOString())
236+
const cryptoCount = blob.cryptoPairs?.length ?? 0
237+
const fiatCount = blob.fiatPairs?.length ?? 0
238+
console.log(
239+
`${queries.length} quer${
240+
queries.length === 1 ? 'y' : 'ies'
241+
} from ${cryptoCount} crypto + ${fiatCount} fiat cached pairs`
242+
)
243+
244+
if (dryRun) {
245+
for (const query of queries) console.log(JSON.stringify(query))
246+
return
247+
}
248+
249+
const totals = { resolved: 0, 'no-rate': 0, missing: 0 }
250+
let failedQueries = 0
251+
252+
for (const [index, query] of queries.entries()) {
253+
console.log(
254+
`\nquery ${index} targetFiat=${query.targetFiat} ${query.crypto.length} crypto + ${query.fiat.length} fiat -> ${server}/v3/rates`
255+
)
256+
const started = Date.now()
257+
let response
258+
try {
259+
response = await fetch(`${server}/v3/rates`, {
260+
method: 'POST',
261+
headers: { 'Content-Type': 'application/json' },
262+
body: JSON.stringify(query)
263+
})
264+
} catch (error) {
265+
failedQueries++
266+
console.log(` request failed: ${String(error)}`)
267+
continue
268+
}
269+
const elapsed = Date.now() - started
270+
const text = await response.text()
271+
if (!response.ok) {
272+
failedQueries++
273+
console.log(
274+
` HTTP ${response.status} (${elapsed}ms): ${text.slice(0, 300)}`
275+
)
276+
continue
277+
}
278+
279+
let parsed: RatesResponse
280+
try {
281+
parsed = JSON.parse(text)
282+
} catch (error) {
283+
failedQueries++
284+
console.log(
285+
` HTTP 200 (${elapsed}ms) but unparseable body: ${text.slice(0, 300)}`
286+
)
287+
continue
288+
}
289+
290+
const results = matchResults(query, parsed)
291+
console.log(` HTTP ${response.status} (${elapsed}ms)`)
292+
printResults(results)
293+
const counts = { resolved: 0, 'no-rate': 0, missing: 0 }
294+
for (const result of results) counts[result.outcome]++
295+
for (const key of Object.keys(counts) as Outcome[])
296+
totals[key] += counts[key]
297+
console.log(
298+
` -> ${counts.resolved} resolved, ${counts['no-rate']} no rate, ${counts.missing} missing`
299+
)
300+
}
301+
302+
console.log(
303+
`\nTOTAL: ${totals.resolved} resolved, ${totals['no-rate']} no rate, ${
304+
totals.missing
305+
} missing across ${queries.length} quer${
306+
queries.length === 1 ? 'y' : 'ies'
307+
}${failedQueries > 0 ? ` (${failedQueries} failed)` : ''}`
308+
)
309+
if (totals.missing > 0 || totals['no-rate'] > 0 || failedQueries > 0) {
310+
process.exitCode = 1
311+
}
312+
}
313+
314+
main().catch((error: unknown) => {
315+
console.error(String(error))
316+
process.exitCode = 1
317+
})

0 commit comments

Comments
 (0)