Skip to content

Commit 815a8bb

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 1ebfd71 commit 815a8bb

3 files changed

Lines changed: 186 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-to-queries` script to replay it as rate-server queries.
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-to-queries": "node -r sucrase/register scripts/ratesCacheToQueries.ts",
5152
"server": "node ./loggingServer.js",
5253
"start": "react-native start",
5354
"test": "TZ=America/Los_Angeles jest",

scripts/ratesCacheToQueries.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// ---------------------------------------------------------------------------
2+
// ratesCacheToQueries
3+
//
4+
// Converts a logged exchange-rate cache blob (the `***Exchange Rate Cache***`
5+
// section captured in the support logs, or any JSON matching that shape) into
6+
// the rate-server query bodies the app would POST to `v3/rates`, so a rate
7+
// problem seen in a user's logs can be replayed directly against the server.
8+
//
9+
// This mirrors `convertToRatesParams` in
10+
// `src/actions/ExchangeRateActions.ts` — keep the two in sync. It only emits
11+
// queries for the subscribed pairs present in the blob (`cryptoPairs` /
12+
// `fiatPairs`); wallet-derived and historical pairs the app adds at runtime
13+
// are not part of the cache and so are not reproduced here.
14+
//
15+
// Usage:
16+
// node -r sucrase/register scripts/ratesCacheToQueries.ts <blob.json>
17+
// pbpaste | node -r sucrase/register scripts/ratesCacheToQueries.ts
18+
// node -r sucrase/register scripts/ratesCacheToQueries.ts <blob.json> --curl
19+
//
20+
// Input may be the bare cache JSON or a log excerpt containing it — the first
21+
// complete JSON object in the text is used. `--curl` emits ready-to-run curl
22+
// commands instead of the JSON bodies.
23+
// ---------------------------------------------------------------------------
24+
25+
import * as fs from 'fs'
26+
27+
// Mirrors RATES_SERVER_MAX_QUERY_SIZE in ExchangeRateActions.ts:
28+
const RATES_SERVER_MAX_QUERY_SIZE = 100
29+
// One of the v3 rate servers (see RATES_SERVERS in src/util/network.ts):
30+
const RATES_SERVER = 'https://rates3.edge.app'
31+
32+
// Mirrors removeIsoPrefix in src/util/utils.ts:
33+
const removeIsoPrefix = (currencyCode: string): string =>
34+
currencyCode.replace('iso:', '')
35+
36+
interface CryptoAsset {
37+
pluginId: string
38+
tokenId?: string | null
39+
}
40+
interface CryptoFiatPair {
41+
asset: CryptoAsset
42+
targetFiat: string
43+
isoDate?: string
44+
expiration: number
45+
}
46+
interface FiatFiatPair {
47+
fiatCode: string
48+
targetFiat: string
49+
isoDate?: string
50+
expiration: number
51+
}
52+
interface RateCacheBlob {
53+
cryptoPairs?: CryptoFiatPair[]
54+
fiatPairs?: FiatFiatPair[]
55+
}
56+
57+
interface RatesQuery {
58+
targetFiat: string
59+
crypto: Array<{ isoDate: string; asset: CryptoAsset; rate?: number }>
60+
fiat: Array<{ isoDate: string; fiatCode: string; rate?: number }>
61+
}
62+
63+
/**
64+
* Group the cached pairs by targetFiat and chunk them into query bodies,
65+
* exactly as convertToRatesParams does in the app.
66+
*/
67+
function cacheToQueries(blob: RateCacheBlob, isoNow: string): RatesQuery[] {
68+
const cryptoPairs = blob.cryptoPairs ?? []
69+
const fiatPairs = blob.fiatPairs ?? []
70+
71+
const grouped = new Map<
72+
string,
73+
{ crypto: CryptoFiatPair[]; fiat: FiatFiatPair[] }
74+
>()
75+
const bucket = (
76+
targetFiat: string
77+
): { crypto: CryptoFiatPair[]; fiat: FiatFiatPair[] } => {
78+
let entry = grouped.get(targetFiat)
79+
if (entry == null) {
80+
entry = { crypto: [], fiat: [] }
81+
grouped.set(targetFiat, entry)
82+
}
83+
return entry
84+
}
85+
for (const pair of cryptoPairs) bucket(pair.targetFiat).crypto.push(pair)
86+
for (const pair of fiatPairs) bucket(pair.targetFiat).fiat.push(pair)
87+
88+
const queries: RatesQuery[] = []
89+
for (const [targetFiat, { crypto, fiat }] of grouped.entries()) {
90+
const remainingCrypto = [...crypto]
91+
const remainingFiat = [...fiat]
92+
while (remainingCrypto.length > 0 || remainingFiat.length > 0) {
93+
const cryptoChunk = remainingCrypto.splice(0, RATES_SERVER_MAX_QUERY_SIZE)
94+
const fiatChunk = remainingFiat.splice(0, RATES_SERVER_MAX_QUERY_SIZE)
95+
queries.push({
96+
targetFiat: removeIsoPrefix(targetFiat),
97+
crypto: cryptoChunk.map(pair => ({
98+
isoDate:
99+
pair.isoDate == null
100+
? isoNow
101+
: new Date(pair.isoDate).toISOString(),
102+
asset: pair.asset
103+
})),
104+
fiat: fiatChunk.map(pair => ({
105+
isoDate:
106+
pair.isoDate == null
107+
? isoNow
108+
: new Date(pair.isoDate).toISOString(),
109+
fiatCode: removeIsoPrefix(pair.fiatCode)
110+
}))
111+
})
112+
}
113+
}
114+
return queries
115+
}
116+
117+
/**
118+
* Pull the first complete JSON object out of arbitrary text, so a pasted log
119+
* excerpt (with surrounding lines) works as well as bare JSON.
120+
*/
121+
function extractFirstJsonObject(text: string): string {
122+
const start = text.indexOf('{')
123+
if (start < 0) throw new Error('No JSON object found in input')
124+
let depth = 0
125+
let inString = false
126+
let escaped = false
127+
for (let i = start; i < text.length; i++) {
128+
const ch = text[i]
129+
if (inString) {
130+
if (escaped) escaped = false
131+
else if (ch === '\\') escaped = true
132+
else if (ch === '"') inString = false
133+
continue
134+
}
135+
if (ch === '"') inString = true
136+
else if (ch === '{') depth++
137+
else if (ch === '}') {
138+
depth--
139+
if (depth === 0) return text.slice(start, i + 1)
140+
}
141+
}
142+
throw new Error('Unbalanced JSON object in input')
143+
}
144+
145+
function main(): void {
146+
const args = process.argv.slice(2)
147+
const emitCurl = args.includes('--curl')
148+
const fileArg = args.find(arg => !arg.startsWith('--'))
149+
150+
const raw =
151+
fileArg != null && fileArg !== '-'
152+
? fs.readFileSync(fileArg, 'utf8')
153+
: fs.readFileSync(0, 'utf8')
154+
155+
const blob = JSON.parse(extractFirstJsonObject(raw)) as RateCacheBlob
156+
const isoNow = new Date().toISOString()
157+
const queries = cacheToQueries(blob, isoNow)
158+
159+
if (emitCurl) {
160+
for (const query of queries) {
161+
const body = JSON.stringify(query).replace(/'/g, "'\\''")
162+
process.stdout.write(
163+
`curl -sS -X POST '${RATES_SERVER}/v3/rates' -H 'Content-Type: application/json' -d '${body}'\n`
164+
)
165+
}
166+
} else {
167+
// One query body per line — each line is a valid v3/rates POST body:
168+
for (const query of queries) {
169+
process.stdout.write(`${JSON.stringify(query)}\n`)
170+
}
171+
}
172+
173+
const cryptoCount = blob.cryptoPairs?.length ?? 0
174+
const fiatCount = blob.fiatPairs?.length ?? 0
175+
process.stderr.write(
176+
`\n${queries.length} quer${
177+
queries.length === 1 ? 'y' : 'ies'
178+
} from ${cryptoCount} crypto + ${fiatCount} fiat cached pairs\n`
179+
)
180+
}
181+
182+
main()

0 commit comments

Comments
 (0)