@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/LICENSE +662 -0
- package/README.md +26 -2
- package/package.json +39 -3
- package/src/aleph-credits.js +61 -0
- package/src/dex-rate.js +386 -0
- package/src/index.js +35 -0
- package/src/rates.js +669 -0
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
|
+
}
|