@le-space/rates 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/rates.js ADDED
@@ -0,0 +1,669 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Exchange rates for the day of a booking: how many euros one unit of an
3
+ // asset was worth. The one definition of "the rate of a day" for Le Space's
4
+ // tools (Belege, the Eigenbeleg API and web UI; Le-Space/eigenbeleg-agent#6,
5
+ // decided 2026-11-06: the price at 00:00 UTC).
6
+ //
7
+ // It runs wherever `fetch` reaches the sources. Belege's browser cannot ask
8
+ // CoinGecko, Kraken or the ECB itself (CORS, and the CoinGecko key stays in
9
+ // the keychain), so there the bridge runs it; the caller hands in `fetch` and
10
+ // the keys (`coingeckoKey`, and the Alchemy key for dex-rate.js).
11
+ //
12
+ // What "the rate of a day" means, the same for every source:
13
+ // - a crypto asset: its price at 00:00 UTC of that day
14
+ // 1. CoinGecko /coins/{id}/history (its daily snapshot at 00:00 UTC)
15
+ // 2. failing that, Kraken's daily candle of that day, its open price
16
+ // 3. a day before Kraken's daily candles: Kraken's first trade of the pair
17
+ // at or after 00:00 UTC of that day – the price the candle would have
18
+ // opened with; none on that day, no rate
19
+ // CoinGecko's public and demo API answer for the last 365 days only. Kraken
20
+ // answers one call with its last 720 daily candles; the completed ones are
21
+ // kept per pair, so a wallet with bookings on many days costs one Kraken
22
+ // call per asset, not one per day – Kraken's public API refuses more than a
23
+ // handful in a row ("EGeneral:Too many requests"), and those days then had
24
+ // no rate at all. A day older than the candles costs one call to Kraken's
25
+ // trade history, one at a time and spaced, for the same reason.
26
+ // With `prefer: 'kraken'` (a booking on the Kraken exchange) the order
27
+ // turns: Kraken's own EUR price first, CoinGecko as the fallback; and
28
+ // Kraken can then price any asset it trades against EUR (`<SYMBOL>EUR`),
29
+ // also one Belege does not list yet.
30
+ // - a currency: the ECB reference rate of that day, or the last one before
31
+ // it (weekends, holidays), inverted: EUR per unit
32
+ // - RUB: the ECB publishes none since 2022-03-01; then the Bank of Russia's
33
+ // official rate valid on that day (RUB per EUR, set on the working day
34
+ // before), inverted
35
+ // - KZT: the National Bank of Kazakhstan's official rate of the day (KZT per
36
+ // EUR), inverted (#325)
37
+ // - a currency neither publishes against the euro, but the Bank of Russia
38
+ // lists (UZS, GEL, AMD, …): a cross rate through the ruble from the same
39
+ // day's file, EUR per unit = (RUB per unit) / (RUB per EUR); marked
40
+ // `cbr-cross`, so nobody takes it for an official EUR rate
41
+
42
+ // Every answer says which source it came from and for what moment, so a
43
+ // booking can show where its euro amount came from.
44
+ //
45
+ // Rates are decimal strings, never floats: `60123.45`, `0.000012`.
46
+ // Nothing about the person's bookings leaves the bridge: a request names an
47
+ // asset and a day, nothing else.
48
+
49
+ import { multiplyRates } from './dex-rate.js';
50
+
51
+ /**
52
+ * @typedef {object} AssetSources
53
+ * @property {string} [coingecko] CoinGecko coin id
54
+ * @property {string} [kraken] Kraken OHLC pair against EUR
55
+ * @property {boolean} [ecb] a currency with an ECB reference rate
56
+ * @property {boolean} [cbr] the Bank of Russia's official rate, where the ECB has none
57
+ * @property {boolean} [nbk] the National Bank of Kazakhstan's official rate against EUR
58
+ * @property {boolean} [cbrCross] a cross rate through the ruble, from the Bank of Russia's daily rates
59
+ */
60
+
61
+ /**
62
+ * The currencies with an ECB reference rate (BGN until the end of 2025, RUB
63
+ * until 2022-03-01; a day without one falls through to the next source).
64
+ */
65
+ export const ECB_CURRENCIES = Object.freeze(
66
+ 'USD JPY BGN CZK DKK GBP HUF PLN RON SEK CHF ISK NOK TRY AUD BRL CAD CNY HKD IDR ILS INR KRW MXN MYR NZD PHP SGD THB ZAR RUB'.split(
67
+ ' '
68
+ )
69
+ );
70
+
71
+ /**
72
+ * The currencies the Bank of Russia rates daily and the ECB does not: priced
73
+ * as a cross rate through the ruble (#325). Its SDR line (XDR) is no currency.
74
+ */
75
+ export const CBR_CROSS_CURRENCIES = Object.freeze(
76
+ 'AED AMD AZN BYN EGP GEL KGS KZT MDL QAR RSD TJS TMT UAH UZS VND'.split(' ')
77
+ );
78
+
79
+ /** The assets the bridge can price, by the symbol the app uses. */
80
+ export const RATE_SOURCES = /** @type {Readonly<Record<string, AssetSources>>} */ (
81
+ Object.freeze({
82
+ BTC: { coingecko: 'bitcoin', kraken: 'XBTEUR' },
83
+ ETH: { coingecko: 'ethereum', kraken: 'ETHEUR' },
84
+ USDC: { coingecko: 'usd-coin', kraken: 'USDCEUR' },
85
+ ALEPH: { coingecko: 'aleph' },
86
+ NYM: { coingecko: 'nym', kraken: 'NYMEUR' },
87
+ AKT: { coingecko: 'akash-network', kraken: 'AKTEUR' },
88
+ FIL: { coingecko: 'filecoin', kraken: 'FILEUR' },
89
+ XMR: { coingecko: 'monero', kraken: 'XMREUR' },
90
+ POL: { coingecko: 'polygon-ecosystem-token', kraken: 'POLEUR' },
91
+ // No EUR pair on Kraken; a swap's other side prices it where CoinGecko has no rate (#163).
92
+ USDFC: { coingecko: 'usdfc' },
93
+ ...Object.fromEntries(ECB_CURRENCIES.map((c) => [c, { ecb: true }])),
94
+ RUB: { ecb: true, cbr: true },
95
+ ...Object.fromEntries(CBR_CROSS_CURRENCIES.map((c) => [c, { cbrCross: true }])),
96
+ // The country's central bank first; the cross rate when it has no answer.
97
+ KZT: { nbk: true, cbrCross: true }
98
+ })
99
+ );
100
+
101
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
102
+
103
+ export class RateError extends Error {
104
+ /** @param {string} message @param {number} status */
105
+ constructor(message, status) {
106
+ super(message);
107
+ this.name = 'RateError';
108
+ this.status = status;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * A number from a JSON API as a plain decimal string, without an exponent.
114
+ *
115
+ * @param {unknown} value
116
+ * @returns {string | null}
117
+ */
118
+ export function toDecimal(value) {
119
+ const n = typeof value === 'string' ? Number(value) : value;
120
+ if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return null;
121
+ const plain = String(n);
122
+ if (!/e/i.test(plain)) return plain;
123
+ return n.toFixed(20).replace(/0+$/, '').replace(/\.$/, '');
124
+ }
125
+
126
+ /**
127
+ * 1 / rate, as a decimal string with 12 significant digits after the point.
128
+ *
129
+ * @param {string} rate
130
+ */
131
+ export function invert(rate) {
132
+ const [int, frac = ''] = rate.split('.');
133
+ const scale = 10n ** BigInt(frac.length);
134
+ const value = BigInt(int + frac);
135
+ if (value === 0n) throw new RateError('rate is zero', 502);
136
+ const digits = 12n;
137
+ const q = (scale * 10n ** digits * 10n) / value; // one extra digit to round
138
+ const rounded = (q + 5n) / 10n;
139
+ const s = rounded.toString().padStart(Number(digits) + 1, '0');
140
+ const out = `${s.slice(0, -Number(digits))}.${s.slice(-Number(digits))}`;
141
+ return out.replace(/0+$/, '').replace(/\.$/, '');
142
+ }
143
+
144
+ /** `2026-09-01` → `01-09-2026` (CoinGecko's date format) */
145
+ const ddmmyyyy = (/** @type {string} */ day) => day.split('-').reverse().join('-');
146
+
147
+ /** @param {string} day */
148
+ const startOfDay = (day) => Math.floor(Date.parse(`${day}T00:00:00Z`) / 1000);
149
+
150
+ /** Waits before Kraken is asked again after "too many requests", in ms. */
151
+ const KRAKEN_RETRIES = Object.freeze([1000, 2000, 4000, 8000]);
152
+ /** The pause between two calls to Kraken's trade history (one per day older than the candles). */
153
+ const KRAKEN_TRADES_GAP_MS = 1100;
154
+
155
+ /**
156
+ * @param {object} [options]
157
+ * @param {typeof fetch} [options.fetch]
158
+ * @param {() => Promise<string | null>} [options.coingeckoKey] a CoinGecko demo key, if the caller has one
159
+ * @param {() => Date} [options.now]
160
+ * @param {number} [options.cacheSize]
161
+ * @param {(ms: number) => Promise<void>} [options.delay] the wait before Kraken is asked again after it said "too many requests"
162
+ * @param {ReturnType<typeof import('./dex-rate.js').createDexRates> | null} [options.dex]
163
+ * a token's pool rate at a block, when CoinGecko has none (issue #163)
164
+ */
165
+ /**
166
+ * CoinGecko's platform ids of the EVM chains a wallet can be on, for a token's
167
+ * rate by its contract (issue #115: a token not in the list).
168
+ */
169
+ export const COINGECKO_PLATFORMS = Object.freeze({
170
+ ethereum: 'ethereum',
171
+ base: 'base',
172
+ arbitrum: 'arbitrum-one',
173
+ optimism: 'optimistic-ethereum',
174
+ polygon: 'polygon-pos'
175
+ });
176
+
177
+ export function createRateService({
178
+ fetch: f = fetch,
179
+ coingeckoKey = async () => null,
180
+ now = () => new Date(),
181
+ cacheSize = 500,
182
+ dex = null,
183
+ delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
184
+ } = {}) {
185
+ /** @type {Map<string, Rate>} */
186
+ const cache = new Map();
187
+ /** @type {Map<string, string | null>} `<platform>:<contract>` → CoinGecko coin id */
188
+ const ids = new Map();
189
+
190
+ /**
191
+ * @param {string} url
192
+ * @param {Record<string, string>} [headers]
193
+ */
194
+ async function getJson(url, headers = {}) {
195
+ let res;
196
+ try {
197
+ res = await f(url, { headers: { accept: 'application/json', ...headers } });
198
+ } catch {
199
+ return null;
200
+ }
201
+ if (!res.ok) return null;
202
+ return res.json().catch(() => null);
203
+ }
204
+
205
+ /** @param {string} id @param {string} day */
206
+ async function fromCoinGecko(id, day) {
207
+ const key = await coingeckoKey().catch(() => null);
208
+ const body = await getJson(
209
+ `https://api.coingecko.com/api/v3/coins/${encodeURIComponent(id)}/history?date=${ddmmyyyy(day)}&localization=false`,
210
+ key ? { 'x-cg-demo-api-key': key } : {}
211
+ );
212
+ const eur = toDecimal(body?.market_data?.current_price?.eur);
213
+ if (!eur) return null;
214
+ return {
215
+ rate: eur,
216
+ usdRate: toDecimal(body?.market_data?.current_price?.usd),
217
+ source: 'coingecko',
218
+ at: `${day}T00:00:00Z`
219
+ };
220
+ }
221
+
222
+ /**
223
+ * A token's CoinGecko id by its contract; null when CoinGecko does not know it.
224
+ *
225
+ * @param {string} platform
226
+ * @param {string} contract 0x + 40 hex, lower case
227
+ */
228
+ async function idOfContract(platform, contract) {
229
+ const k = `${platform}:${contract}`;
230
+ if (ids.has(k)) return ids.get(k) ?? null;
231
+ const key = await coingeckoKey().catch(() => null);
232
+ const body = await getJson(
233
+ `https://api.coingecko.com/api/v3/coins/${encodeURIComponent(platform)}/contract/${contract}`,
234
+ key ? { 'x-cg-demo-api-key': key } : {}
235
+ );
236
+ const id = typeof body?.id === 'string' && /^[a-z0-9-]{1,100}$/.test(body.id) ? body.id : null;
237
+ ids.set(k, id);
238
+ return id;
239
+ }
240
+
241
+ /**
242
+ * Per Kraken pair: the open of each completed day, by its 00:00 UTC in
243
+ * seconds, and the start of the day the series was asked on. Days before
244
+ * its first candle Kraken has no daily candle for; days from `until` on
245
+ * need a newer series.
246
+ *
247
+ * @type {Map<string, { until: number, opens: Map<number, string> }>}
248
+ */
249
+ const krakenSeries = new Map();
250
+ /** @type {Map<string, Promise<{ until: number, opens: Map<number, string> } | null>>} one call per pair at a time */
251
+ const krakenAsking = new Map();
252
+
253
+ /** Kraken's daily candles of a pair; on "too many requests" it is asked again, a few times, waiting longer each time. @param {string} pair */
254
+ async function krakenOhlc(pair) {
255
+ const url = `https://api.kraken.com/0/public/OHLC?pair=${encodeURIComponent(pair)}&interval=1440`;
256
+ for (let attempt = 0; ; attempt++) {
257
+ let res;
258
+ try {
259
+ res = await f(url, { headers: { accept: 'application/json' } });
260
+ } catch {
261
+ return null;
262
+ }
263
+ const body = res.ok ? await res.json().catch(() => null) : null;
264
+ const errors = Array.isArray(body?.error) ? body.error.map(String) : [];
265
+ const throttled =
266
+ res.status === 429 || errors.some((e) => /too many requests|rate limit/i.test(e));
267
+ if (throttled && attempt < KRAKEN_RETRIES.length) {
268
+ await delay(KRAKEN_RETRIES[attempt]);
269
+ continue;
270
+ }
271
+ if (!body || errors.length) return null;
272
+ const key = Object.keys(body.result ?? {}).find((k) => k !== 'last');
273
+ const candles = key ? body.result[key] : null;
274
+ return Array.isArray(candles) ? /** @type {unknown[][]} */ (candles) : null;
275
+ }
276
+ }
277
+
278
+ /** @param {string} pair */
279
+ function loadKraken(pair) {
280
+ let asking = krakenAsking.get(pair);
281
+ if (!asking) {
282
+ asking = (async () => {
283
+ const candles = await krakenOhlc(pair);
284
+ if (!candles) return null;
285
+ const until = startOfDay(now().toISOString().slice(0, 10));
286
+ /** @type {Map<number, string>} */
287
+ const opens = new Map();
288
+ for (const c of candles) {
289
+ const time = Array.isArray(c) ? Number(c[0]) : NaN;
290
+ const open = Array.isArray(c) ? toDecimal(c[1]) : null;
291
+ if (Number.isSafeInteger(time) && open) opens.set(time, open);
292
+ }
293
+ // Today's candle is kept out, as the day's answer is (see rate()).
294
+ const done = new Map([...opens].filter(([time]) => time < until));
295
+ if (done.size) krakenSeries.set(pair, { until, opens: done });
296
+ return { until, opens };
297
+ })().finally(() => krakenAsking.delete(pair));
298
+ krakenAsking.set(pair, asking);
299
+ }
300
+ return asking;
301
+ }
302
+
303
+ /** @param {string} pair @param {string} day */
304
+ async function fromKraken(pair, day) {
305
+ const time = startOfDay(day);
306
+ const kept = krakenSeries.get(pair);
307
+ const series = kept && time < kept.until ? kept : await loadKraken(pair);
308
+ const open = series?.opens.get(time);
309
+ if (open) return { rate: open, usdRate: null, source: 'kraken', at: `${day}T00:00:00Z` };
310
+ // Older than the 720 candles: the first trade of that day.
311
+ const first = series?.opens.size ? Math.min(...series.opens.keys()) : null;
312
+ return first !== null && time < first ? fromKrakenTrades(pair, time) : null;
313
+ }
314
+
315
+ /** Calls to the trade history, one after the other, spaced (Kraken counts them). */
316
+ let tradesQueue = Promise.resolve();
317
+
318
+ /**
319
+ * Kraken's first trade of a pair at or after `time` (00:00 UTC of a day, in
320
+ * seconds), if it falls on that day: the price the day's candle would have
321
+ * opened with. Before a pair was listed Kraken answers with its first trade
322
+ * ever, which is not that day's.
323
+ *
324
+ * @param {string} pair
325
+ * @param {number} time
326
+ */
327
+ function fromKrakenTrades(pair, time) {
328
+ const url = `https://api.kraken.com/0/public/Trades?pair=${encodeURIComponent(pair)}&since=${time}&count=1`;
329
+ const ask = tradesQueue.then(async () => {
330
+ for (let attempt = 0; ; attempt++) {
331
+ let res;
332
+ try {
333
+ res = await f(url, { headers: { accept: 'application/json' } });
334
+ } catch {
335
+ return null;
336
+ }
337
+ const body = res.ok ? await res.json().catch(() => null) : null;
338
+ const errors = Array.isArray(body?.error) ? body.error.map(String) : [];
339
+ const throttled =
340
+ res.status === 429 || errors.some((e) => /too many requests|rate limit/i.test(e));
341
+ if (throttled && attempt < KRAKEN_RETRIES.length) {
342
+ await delay(KRAKEN_RETRIES[attempt]);
343
+ continue;
344
+ }
345
+ if (!body || errors.length) return null;
346
+ const key = Object.keys(body.result ?? {}).find((k) => k !== 'last');
347
+ const trade = key && Array.isArray(body.result[key]) ? body.result[key][0] : null;
348
+ const price = Array.isArray(trade) ? toDecimal(trade[0]) : null;
349
+ const at = Array.isArray(trade) ? Number(trade[2]) : NaN;
350
+ if (!price || !Number.isFinite(at) || at < time || at >= time + 86400) return null;
351
+ return /** @type {const} */ ({
352
+ rate: price,
353
+ usdRate: null,
354
+ source: 'kraken',
355
+ at: new Date(Math.floor(at * 1000)).toISOString(),
356
+ ref: `kraken-trade:${trade[6] ?? ''}`
357
+ });
358
+ }
359
+ });
360
+ tradesQueue = ask.then(
361
+ () => delay(KRAKEN_TRADES_GAP_MS),
362
+ () => delay(KRAKEN_TRADES_GAP_MS)
363
+ );
364
+ return ask;
365
+ }
366
+
367
+ /**
368
+ * A token's rate from its deepest pool against ETH or USDC at the booking's
369
+ * block, times that quote's rate of the day (dex-rate.js, issue #163).
370
+ *
371
+ * @param {{ contract: string, chain: string, block: number, decimals: number }} p
372
+ * @param {string} day
373
+ */
374
+ async function fromPool(p, day) {
375
+ /** @type {Map<string, Promise<string | null>>} */
376
+ const quotes = new Map();
377
+ /** @param {'ETH' | 'USDC'} q */
378
+ const quoteEur = (q) => {
379
+ if (!quotes.has(q)) {
380
+ quotes.set(
381
+ q,
382
+ rate(q, day).then(
383
+ (r) => r.rate,
384
+ () => null
385
+ )
386
+ );
387
+ }
388
+ return /** @type {Promise<string | null>} */ (quotes.get(q));
389
+ };
390
+ const price = await dex?.poolPrice({ ...p, quoteEur }).catch(() => null);
391
+ if (!price) return null;
392
+ const eur = await quoteEur(price.quote);
393
+ if (!eur) return null;
394
+ return {
395
+ rate: multiplyRates(price.price, eur),
396
+ usdRate: null,
397
+ source: /** @type {const} */ ('dex'),
398
+ at: price.at || `${day}T00:00:00Z`,
399
+ ref: `uniswap-v${price.version}:${price.pool}@${price.block}`
400
+ };
401
+ }
402
+
403
+ /**
404
+ * EUR per unit of a currency from the ECB reference rate of the day or the
405
+ * last one before it.
406
+ *
407
+ * @param {string} currency
408
+ * @param {string} day
409
+ */
410
+ async function fromEcb(currency, day) {
411
+ const start = new Date(Date.parse(`${day}T00:00:00Z`) - 7 * 86400_000)
412
+ .toISOString()
413
+ .slice(0, 10);
414
+ const body = await getJson(
415
+ `https://data-api.ecb.europa.eu/service/data/EXR/D.${currency}.EUR.SP00.A?startPeriod=${start}&endPeriod=${day}&format=jsondata`
416
+ );
417
+ const series = body?.dataSets?.[0]?.series?.['0:0:0:0:0']?.observations;
418
+ const dates = body?.structure?.dimensions?.observation?.[0]?.values;
419
+ if (!series || !Array.isArray(dates)) return null;
420
+ /** @type {{ date: string, usdPerEur: string }[]} */
421
+ const days = [];
422
+ for (const [index, obs] of Object.entries(series)) {
423
+ const date = dates[Number(index)]?.id;
424
+ const value = toDecimal(/** @type {any} */ (obs)?.[0]);
425
+ if (typeof date === 'string' && date <= day && value) days.push({ date, usdPerEur: value });
426
+ }
427
+ const last = days.sort((a, b) => (a.date < b.date ? 1 : -1))[0];
428
+ if (!last) return null;
429
+ return {
430
+ rate: invert(last.usdPerEur),
431
+ usdRate: currency === 'USD' ? '1' : null,
432
+ source: 'ecb',
433
+ at: `${last.date}T00:00:00Z`
434
+ };
435
+ }
436
+
437
+ /**
438
+ * The text of an official rates file, or null when it cannot be had.
439
+ *
440
+ * @param {string} url
441
+ * @param {string} encoding
442
+ */
443
+ async function getText(url, encoding) {
444
+ try {
445
+ const res = await f(url, { headers: { accept: 'application/xml' } });
446
+ if (!res.ok) return null;
447
+ return new TextDecoder(encoding).decode(await res.arrayBuffer());
448
+ } catch {
449
+ return null;
450
+ }
451
+ }
452
+
453
+ /**
454
+ * @typedef {object} CbrDay
455
+ * @property {string} date YYYY-MM-DD the rates are valid for
456
+ * @property {Map<string, { nominal: string, value: string }>} valutes RUB per `nominal` units, by code
457
+ */
458
+
459
+ /** @type {Map<string, Promise<CbrDay | null>>} one file per day, for RUB and every cross rate */
460
+ const cbrDays = new Map();
461
+
462
+ /**
463
+ * The Bank of Russia's official rates valid on the day: `XML_daily.asp`
464
+ * answers with the last rates set for it (on the working day before; none
465
+ * on weekends and holidays), RUB per `Nominal` units of each currency.
466
+ * Null when the file is not to be had, or is for a later day.
467
+ *
468
+ * @param {string} day
469
+ * @returns {Promise<CbrDay | null>}
470
+ */
471
+ function cbrDay(day) {
472
+ const known = cbrDays.get(day);
473
+ if (known) return known;
474
+ const loading = (async () => {
475
+ // windows-1251; the dates, codes and numbers read here are ASCII.
476
+ const text = await getText(
477
+ `https://www.cbr.ru/scripts/XML_daily.asp?date_req=${ddmmyyyy(day).replaceAll('-', '/')}`,
478
+ 'latin1'
479
+ );
480
+ if (!text) return null;
481
+ const valid = /<ValCurs\b[^>]*\bDate="(\d{2})\.(\d{2})\.(\d{4})"/.exec(text);
482
+ if (!valid) return null;
483
+ const date = `${valid[3]}-${valid[2]}-${valid[1]}`;
484
+ if (date > day) return null;
485
+ /** @type {Map<string, { nominal: string, value: string }>} */
486
+ const valutes = new Map();
487
+ for (const [entry] of text.matchAll(/<Valute\b[^>]*>[\s\S]*?<\/Valute>/g)) {
488
+ const code = /<CharCode>([A-Z]{3})<\/CharCode>/.exec(entry)?.[1];
489
+ const nominal = /<Nominal>([1-9]\d*)<\/Nominal>/.exec(entry)?.[1];
490
+ const value = /<Value>(\d+),(\d+)<\/Value>/.exec(entry);
491
+ if (code && nominal && value && /[1-9]/.test(value[1] + value[2])) {
492
+ valutes.set(code, { nominal, value: `${value[1]}.${value[2]}` });
493
+ }
494
+ }
495
+ return { date, valutes };
496
+ })();
497
+ cbrDays.set(day, loading);
498
+ // A failed answer is asked again next time; only the last days are kept.
499
+ loading.then((d) => {
500
+ if (!d) cbrDays.delete(day);
501
+ });
502
+ if (cbrDays.size > 60) cbrDays.delete(/** @type {string} */ (cbrDays.keys().next().value));
503
+ return loading;
504
+ }
505
+
506
+ /**
507
+ * EUR per RUB on the day: the Bank of Russia's RUB per `nominal` EUR, inverted.
508
+ *
509
+ * @param {CbrDay} d
510
+ */
511
+ function eurPerRub(d) {
512
+ const eur = d.valutes.get('EUR');
513
+ if (!eur) return null;
514
+ const perRub = invert(eur.value);
515
+ return eur.nominal === '1' ? perRub : multiplyRates(perRub, eur.nominal);
516
+ }
517
+
518
+ /** EUR per RUB from the Bank of Russia's official rates valid on the day. @param {string} day */
519
+ async function fromCbr(day) {
520
+ const d = await cbrDay(day);
521
+ const rate = d && eurPerRub(d);
522
+ if (!d || !rate) return null;
523
+ return { rate, usdRate: null, source: 'cbr', at: `${d.date}T00:00:00Z` };
524
+ }
525
+
526
+ /**
527
+ * A cross rate through the ruble, from one day's file: EUR per unit =
528
+ * (RUB per unit) × (EUR per RUB). Not an official EUR rate, and named so.
529
+ *
530
+ * @param {string} currency
531
+ * @param {string} day
532
+ */
533
+ async function fromCbrCross(currency, day) {
534
+ const d = await cbrDay(day);
535
+ const line = d?.valutes.get(currency);
536
+ const perRub = d && eurPerRub(d);
537
+ if (!d || !line || !perRub) return null;
538
+ const perNominal = multiplyRates(line.value, perRub);
539
+ return {
540
+ rate: line.nominal === '1' ? perNominal : multiplyRates(perNominal, invert(line.nominal)),
541
+ usdRate: null,
542
+ source: 'cbr-cross',
543
+ at: `${d.date}T00:00:00Z`
544
+ };
545
+ }
546
+
547
+ /**
548
+ * EUR per KZT from the National Bank of Kazakhstan's official rates of the
549
+ * day (`get_rates.cfm`, KZT per `quant` units of each currency; it answers
550
+ * for weekends too, with the last rate set).
551
+ *
552
+ * @param {string} day
553
+ */
554
+ async function fromNbk(day) {
555
+ const [y, m, d] = day.split('-');
556
+ const text = await getText(
557
+ `https://nationalbank.kz/rss/get_rates.cfm?fdate=${d}.${m}.${y}`,
558
+ 'utf-8'
559
+ );
560
+ if (!text) return null;
561
+ const valid = /<date>(\d{2})\.(\d{2})\.(\d{4})<\/date>/.exec(text);
562
+ const item =
563
+ /<item>(?:(?!<\/item>)[\s\S])*?<title>EUR<\/title>(?:(?!<\/item>)[\s\S])*?<\/item>/.exec(
564
+ text
565
+ )?.[0] ?? '';
566
+ const value = /<description>(\d+(?:\.\d+)?)<\/description>/.exec(item)?.[1];
567
+ const quant = /<quant>([1-9]\d*)<\/quant>/.exec(item)?.[1];
568
+ if (!valid || !value || !quant || !/[1-9]/.test(value)) return null;
569
+ const date = `${valid[3]}-${valid[2]}-${valid[1]}`;
570
+ if (date > day) return null;
571
+ // `value` KZT per `quant` EUR: EUR per KZT = quant / value.
572
+ const perKzt = invert(value);
573
+ return {
574
+ rate: quant === '1' ? perKzt : multiplyRates(perKzt, quant),
575
+ usdRate: null,
576
+ source: 'nbk',
577
+ at: `${date}T00:00:00Z`
578
+ };
579
+ }
580
+
581
+ /**
582
+ * @typedef {{ asset: string, date: string, currency: 'EUR', rate: string, usdRate: string | null, source: 'coingecko' | 'kraken' | 'ecb' | 'cbr' | 'nbk' | 'cbr-cross' | 'dex', at: string, ref?: string }} Rate
583
+ * `ref` for `dex`: `uniswap-v2:<pool>@<block>`, V4 with its pool id
584
+ */
585
+
586
+ /**
587
+ * The rate of one unit of `asset` in EUR on `date`.
588
+ *
589
+ * @param {string} asset a symbol from RATE_SOURCES; with prefer 'kraken', any symbol
590
+ * @param {string} date YYYY-MM-DD, not in the future
591
+ * @param {{ prefer?: 'kraken' | null, contract?: string | null, chain?: string | null, block?: number | null, decimals?: number | null }} [options]
592
+ * `contract` and `chain`: a token not in the list, by its contract only – its
593
+ * symbol is its own claim and says nothing (issue #115); with `block` and
594
+ * `decimals`, the DEX pool at that block when CoinGecko has no rate (#163)
595
+ * @returns {Promise<Rate>}
596
+ */
597
+ async function rate(
598
+ asset,
599
+ date,
600
+ { prefer = null, contract = null, chain = null, block = null, decimals = null } = {}
601
+ ) {
602
+ const platform =
603
+ contract && chain && Object.hasOwn(COINGECKO_PLATFORMS, chain)
604
+ ? COINGECKO_PLATFORMS[/** @type {keyof typeof COINGECKO_PLATFORMS} */ (chain)]
605
+ : null;
606
+ if (contract && (!platform || !/^0x[0-9a-f]{40}$/.test(contract))) {
607
+ throw new RateError('a token rate needs a contract and an EVM chain', 400);
608
+ }
609
+ const listed = Object.hasOwn(RATE_SOURCES, asset) ? RATE_SOURCES[asset] : null;
610
+ const byContract =
611
+ contract && platform
612
+ ? await (async () => {
613
+ const id = await idOfContract(platform, contract);
614
+ return id ? { coingecko: id } : null;
615
+ })()
616
+ : undefined;
617
+ const sources =
618
+ byContract !== undefined
619
+ ? byContract
620
+ : prefer === 'kraken' && /^[A-Z0-9]{2,10}$/.test(asset)
621
+ ? {
622
+ ...listed,
623
+ kraken:
624
+ listed?.ecb || listed?.cbrCross || listed?.nbk
625
+ ? undefined
626
+ : (listed?.kraken ?? `${asset}EUR`)
627
+ }
628
+ : listed;
629
+ const pool =
630
+ contract && chain && block && decimals !== null && dex
631
+ ? { contract, chain, block, decimals }
632
+ : null;
633
+ if (!sources && !pool) throw new RateError(`no rate source for ${asset}`, contract ? 502 : 400);
634
+ if (!DAY.test(date) || Number.isNaN(Date.parse(`${date}T00:00:00Z`))) {
635
+ throw new RateError('date must be YYYY-MM-DD', 400);
636
+ }
637
+ if (date > now().toISOString().slice(0, 10)) {
638
+ throw new RateError('date lies in the future', 400);
639
+ }
640
+ const key = `${contract ?? asset}@${date}@${prefer ?? ''}@${pool ? block : ''}`;
641
+ const cached = cache.get(key);
642
+ if (cached) return cached;
643
+
644
+ const coingecko = async () =>
645
+ sources?.coingecko ? fromCoinGecko(sources.coingecko, date) : null;
646
+ const kraken = async () => (sources?.kraken ? fromKraken(sources.kraken, date) : null);
647
+ const found =
648
+ (sources?.ecb ? await fromEcb(asset, date) : null) ??
649
+ (sources?.cbr ? await fromCbr(date) : null) ??
650
+ (sources?.nbk ? await fromNbk(date) : null) ??
651
+ (sources?.cbrCross ? await fromCbrCross(asset, date) : null) ??
652
+ (prefer === 'kraken'
653
+ ? ((await kraken()) ?? (await coingecko()))
654
+ : ((await coingecko()) ?? (await kraken()))) ??
655
+ (pool ? await fromPool(pool, date) : null);
656
+ if (!found) throw new RateError(`no rate found for ${asset} on ${date}`, 502);
657
+
658
+ /** @type {Rate} */
659
+ const result = { asset, date, currency: 'EUR', ...found };
660
+ // Today's rate may still change until the day is over; only past days are kept.
661
+ if (date < now().toISOString().slice(0, 10)) {
662
+ if (cache.size >= cacheSize) cache.delete(/** @type {string} */ (cache.keys().next().value));
663
+ cache.set(key, result);
664
+ }
665
+ return result;
666
+ }
667
+
668
+ return { rate };
669
+ }